diff --git a/frontend/lib/widgets/quotidien/carte.dart b/frontend/lib/widgets/quotidien/carte.dart new file mode 100644 index 0000000..bd3289e --- /dev/null +++ b/frontend/lib/widgets/quotidien/carte.dart @@ -0,0 +1,251 @@ +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +/// Mode d’extension du fond pastel. +enum CarteStretch { + /// 9 zones (bulles / cartes content). + both, + + /// Uniquement horizontal — bouts en demi-cercle (bandeau couple). + horizontal, +} + +/// Fond pastel extensible (capacité par défaut des « cartes » UI). +/// +/// Coins / bouts à taille logique fixe ; le reste s’étire selon [stretch]. +class Carte extends StatefulWidget { + final String assetPath; + final CarteStretch stretch; + final Rect centerSlice; + final double cornerWidth; + final double cornerHeight; + final Widget? child; + final EdgeInsetsGeometry? padding; + final BoxConstraints? constraints; + final double? width; + final double? height; + + /// Carte / bulle content (`card_*_h.png`, ~1024×512). + const Carte({ + super.key, + required this.assetPath, + this.child, + this.padding, + this.constraints, + this.width, + this.height, + this.stretch = CarteStretch.both, + this.centerSlice = const Rect.fromLTRB(160, 90, 864, 422), + this.cornerWidth = 56, + this.cornerHeight = 40, + }); + + /// Bandeau couple enfant / nounou (`bandeau_*.png`, ~1024×160). + /// Étirement horizontal seul pour garder les bouts ronds. + const Carte.bandeau({ + super.key, + required this.assetPath, + this.child, + this.padding, + this.constraints, + this.width, + this.height, + }) : stretch = CarteStretch.horizontal, + // Demi-cercle source ≈ moitié de la hauteur (160/2 = 80). + centerSlice = const Rect.fromLTRB(80, 0, 944, 160), + cornerWidth = 45, + cornerHeight = 45; + + @override + State createState() => _CarteState(); +} + +class _CarteImageCache { + static final Map> _futures = {}; + + static Future load(String path) { + return _futures.putIfAbsent(path, () async { + final data = await rootBundle.load(path); + final codec = await ui.instantiateImageCodec( + data.buffer.asUint8List(), + ); + final frame = await codec.getNextFrame(); + return frame.image; + }); + } +} + +class _CarteState extends State { + ui.Image? _image; + String? _loadedPath; + + @override + void initState() { + super.initState(); + _load(); + } + + @override + void didUpdateWidget(covariant Carte oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.assetPath != widget.assetPath) { + _load(); + } + } + + Future _load() async { + final path = widget.assetPath; + final image = await _CarteImageCache.load(path); + if (!mounted) return; + setState(() { + _image = image; + _loadedPath = path; + }); + } + + @override + Widget build(BuildContext context) { + final img = _image; + final ready = img != null && _loadedPath == widget.assetPath; + + return SizedBox( + width: widget.width, + height: widget.height, + child: Container( + constraints: widget.constraints, + child: CustomPaint( + painter: ready + ? _CartePainter( + image: img!, + stretch: widget.stretch, + centerSlice: widget.centerSlice, + cornerWidth: widget.cornerWidth, + cornerHeight: widget.cornerHeight, + ) + : null, + child: Padding( + padding: widget.padding ?? EdgeInsets.zero, + child: widget.child, + ), + ), + ), + ); + } +} + +class _CartePainter extends CustomPainter { + final ui.Image image; + final CarteStretch stretch; + final Rect centerSlice; + final double cornerWidth; + final double cornerHeight; + + _CartePainter({ + required this.image, + required this.stretch, + required this.centerSlice, + required this.cornerWidth, + required this.cornerHeight, + }); + + @override + void paint(Canvas canvas, Size size) { + if (size.width <= 0 || size.height <= 0) return; + + final paint = Paint()..filterQuality = FilterQuality.high; + final srcW = image.width.toDouble(); + final srcH = image.height.toDouble(); + + void draw(Rect src, Rect dst) { + if (src.width <= 0 || src.height <= 0) return; + if (dst.width <= 0 || dst.height <= 0) return; + canvas.drawImageRect(image, src, dst, paint); + } + + if (stretch == CarteStretch.horizontal) { + // 3 bandes pleine hauteur : bout | milieu | bout (pilule). + // Largeur du bout = moitié de la hauteur → demi-cercle propre. + final tip = (size.height / 2).clamp(1.0, size.width / 2); + final srcTip = centerSlice.left.clamp(1.0, srcW / 2); + final midW = (size.width - tip * 2).clamp(0.0, size.width); + + draw( + Rect.fromLTWH(0, 0, srcTip, srcH), + Rect.fromLTWH(0, 0, tip, size.height), + ); + draw( + Rect.fromLTWH(srcTip, 0, (srcW - srcTip * 2).clamp(1.0, srcW), srcH), + Rect.fromLTWH(tip, 0, midW, size.height), + ); + draw( + Rect.fromLTWH(srcW - srcTip, 0, srcTip, srcH), + Rect.fromLTWH(tip + midW, 0, tip, size.height), + ); + return; + } + + // 9 zones (bulles). + final srcLeft = centerSlice.left.clamp(0.0, srcW); + final srcTop = centerSlice.top.clamp(0.0, srcH); + final srcRight = centerSlice.right.clamp(0.0, srcW); + final srcBottom = centerSlice.bottom.clamp(0.0, srcH); + + final srcLeftW = srcLeft; + final srcTopH = srcTop; + final srcRightW = srcW - srcRight; + final srcBottomH = srcH - srcBottom; + final srcCenterW = (srcRight - srcLeft).clamp(1.0, srcW); + final srcCenterH = (srcBottom - srcTop).clamp(1.0, srcH); + + final dstLeftW = cornerWidth.clamp(1.0, size.width / 2); + final dstRightW = cornerWidth.clamp(1.0, size.width / 2); + final dstTopH = cornerHeight.clamp(1.0, size.height / 2); + final dstBottomH = cornerHeight.clamp(1.0, size.height / 2); + final dstCenterW = + (size.width - dstLeftW - dstRightW).clamp(0.0, size.width); + final dstCenterH = + (size.height - dstTopH - dstBottomH).clamp(0.0, size.height); + + // Haut + draw(Rect.fromLTWH(0, 0, srcLeftW, srcTopH), + Rect.fromLTWH(0, 0, dstLeftW, dstTopH)); + draw(Rect.fromLTWH(srcLeft, 0, srcCenterW, srcTopH), + Rect.fromLTWH(dstLeftW, 0, dstCenterW, dstTopH)); + draw(Rect.fromLTWH(srcRight, 0, srcRightW, srcTopH), + Rect.fromLTWH(dstLeftW + dstCenterW, 0, dstRightW, dstTopH)); + + // Milieu + draw(Rect.fromLTWH(0, srcTop, srcLeftW, srcCenterH), + Rect.fromLTWH(0, dstTopH, dstLeftW, dstCenterH)); + draw(Rect.fromLTWH(srcLeft, srcTop, srcCenterW, srcCenterH), + Rect.fromLTWH(dstLeftW, dstTopH, dstCenterW, dstCenterH)); + draw(Rect.fromLTWH(srcRight, srcTop, srcRightW, srcCenterH), + Rect.fromLTWH(dstLeftW + dstCenterW, dstTopH, dstRightW, dstCenterH)); + + // Bas + draw(Rect.fromLTWH(0, srcBottom, srcLeftW, srcBottomH), + Rect.fromLTWH(0, dstTopH + dstCenterH, dstLeftW, dstBottomH)); + draw(Rect.fromLTWH(srcLeft, srcBottom, srcCenterW, srcBottomH), + Rect.fromLTWH(dstLeftW, dstTopH + dstCenterH, dstCenterW, dstBottomH)); + draw( + Rect.fromLTWH(srcRight, srcBottom, srcRightW, srcBottomH), + Rect.fromLTWH( + dstLeftW + dstCenterW, + dstTopH + dstCenterH, + dstRightW, + dstBottomH, + ), + ); + } + + @override + bool shouldRepaint(covariant _CartePainter oldDelegate) { + return oldDelegate.image != image || + oldDelegate.stretch != stretch || + oldDelegate.centerSlice != centerSlice || + oldDelegate.cornerWidth != cornerWidth || + oldDelegate.cornerHeight != cornerHeight; + } +} diff --git a/frontend/lib/widgets/quotidien/cartes_feed.dart b/frontend/lib/widgets/quotidien/cartes_feed.dart index 5d1c549..48357e2 100644 --- a/frontend/lib/widgets/quotidien/cartes_feed.dart +++ b/frontend/lib/widgets/quotidien/cartes_feed.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:p_tits_pas/models/carte_bulle.dart'; import 'package:p_tits_pas/services/api/cards_service.dart'; -import 'package:p_tits_pas/widgets/quotidien/nine_slice_asset.dart'; +import 'package:p_tits_pas/widgets/quotidien/carte.dart'; import 'package:p_tits_pas/widgets/quotidien/quotidien_theme.dart'; /// Feed de bulles (file d'attention) — ticket #173. @@ -287,7 +287,7 @@ class _BulleTile extends StatelessWidget { final treated = card.statut == CarteStatut.traitee; return Opacity( opacity: treated ? 0.55 : 1, - child: NineSliceAsset.card( + child: Carte( assetPath: card.backgroundAsset, constraints: const BoxConstraints(minHeight: 96), padding: const EdgeInsets.fromLTRB(20, 14, 20, 14), diff --git a/frontend/lib/widgets/quotidien/couple_selector_bandeau.dart b/frontend/lib/widgets/quotidien/couple_selector_bandeau.dart index 24436b5..a910baa 100644 --- a/frontend/lib/widgets/quotidien/couple_selector_bandeau.dart +++ b/frontend/lib/widgets/quotidien/couple_selector_bandeau.dart @@ -3,7 +3,7 @@ import 'package:google_fonts/google_fonts.dart'; import 'package:p_tits_pas/models/couple_garde.dart'; import 'package:p_tits_pas/services/api/api_config.dart'; import 'package:p_tits_pas/widgets/common/auth_network_image.dart'; -import 'package:p_tits_pas/widgets/quotidien/nine_slice_asset.dart'; +import 'package:p_tits_pas/widgets/quotidien/carte.dart'; import 'package:p_tits_pas/widgets/quotidien/quotidien_theme.dart'; /// Rôle qui consulte le bandeau : côté parent (enfant | nounou) ou côté AM @@ -175,7 +175,7 @@ class _CoupleCard extends StatelessWidget { ); } - return NineSliceAsset.bandeau( + return Carte.bandeau( assetPath: QuotidienTheme .bandeauColors[colorIndex % QuotidienTheme.bandeauColors.length], height: 90, @@ -309,7 +309,7 @@ class _CoupleBandeauSkeleton extends StatelessWidget { Colors.white.withValues(alpha: 0.5), BlendMode.lighten, ), - child: NineSliceAsset.bandeau( + child: Carte.bandeau( assetPath: QuotidienTheme.bandeauLime, height: 90, child: const Center( @@ -329,7 +329,7 @@ class _CoupleBandeauEmpty extends StatelessWidget { @override Widget build(BuildContext context) { - return NineSliceAsset.bandeau( + return Carte.bandeau( assetPath: QuotidienTheme.bandeauLime, height: 90, padding: const EdgeInsets.symmetric(horizontal: 24), @@ -365,7 +365,7 @@ class _CoupleBandeauError extends StatelessWidget { QuotidienTheme.coral.withValues(alpha: 0.3), BlendMode.srcATop, ), - child: NineSliceAsset.bandeau( + child: Carte.bandeau( assetPath: QuotidienTheme.bandeauLime, height: 90, padding: const EdgeInsets.symmetric(horizontal: 24), diff --git a/frontend/lib/widgets/quotidien/nine_slice_asset.dart b/frontend/lib/widgets/quotidien/nine_slice_asset.dart deleted file mode 100644 index 41b8b71..0000000 --- a/frontend/lib/widgets/quotidien/nine_slice_asset.dart +++ /dev/null @@ -1,268 +0,0 @@ -import 'dart:ui' as ui; - -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; - -/// Paramètres de découpe 9-slice (schéma extensible.png). -class NineSliceSpec { - /// Zone centrale extensible en pixels source (LTRB du centre). - final Rect centerSlice; - - /// Largeur logique fixe des coins gauche / droite. - final double cornerWidth; - - /// Hauteur logique fixe des coins haut / bas. - final double cornerHeight; - - const NineSliceSpec({ - required this.centerSlice, - required this.cornerWidth, - required this.cornerHeight, - }); - - /// Bulles / cartes horizontales `card_*_h.png` (~1024×512). - static const cards = NineSliceSpec( - centerSlice: Rect.fromLTRB(160, 90, 864, 422), - cornerWidth: 56, - cornerHeight: 40, - ); - - /// Bandeaux couple `bandeau_*.png` (~1024×160). - static const bandeau = NineSliceSpec( - centerSlice: Rect.fromLTRB(140, 32, 884, 128), - cornerWidth: 56, - cornerHeight: 28, - ); - - @override - bool operator ==(Object other) => - identical(this, other) || - other is NineSliceSpec && - centerSlice == other.centerSlice && - cornerWidth == other.cornerWidth && - cornerHeight == other.cornerHeight; - - @override - int get hashCode => Object.hash(centerSlice, cornerWidth, cornerHeight); -} - -/// Fond pastel extensible réutilisable (cartes, bandeau couple, etc.). -/// -/// Coins à taille logique fixe ; bords H/V + centre s’étirent. -/// Pas de fichier `.9.png` Android. -class NineSliceAsset extends StatefulWidget { - final String assetPath; - final NineSliceSpec spec; - final Widget? child; - final EdgeInsetsGeometry? padding; - final BoxConstraints? constraints; - final double? width; - final double? height; - - const NineSliceAsset({ - super.key, - required this.assetPath, - this.spec = NineSliceSpec.cards, - this.child, - this.padding, - this.constraints, - this.width, - this.height, - }); - - /// Raccourci pour les bulles de la file d’attention. - const NineSliceAsset.card({ - super.key, - required this.assetPath, - this.child, - this.padding, - this.constraints, - this.width, - this.height, - }) : spec = NineSliceSpec.cards; - - /// Raccourci pour le bandeau choix enfant / nounou (ou parents). - const NineSliceAsset.bandeau({ - super.key, - required this.assetPath, - this.child, - this.padding, - this.constraints, - this.width, - this.height, - }) : spec = NineSliceSpec.bandeau; - - @override - State createState() => _NineSliceAssetState(); -} - -/// Cache partagé pour éviter de recharger le même PNG à chaque bulle. -class _NineSliceImageCache { - static final Map> _futures = {}; - - static Future load(String path) { - return _futures.putIfAbsent(path, () async { - final data = await rootBundle.load(path); - final codec = await ui.instantiateImageCodec( - data.buffer.asUint8List(), - ); - final frame = await codec.getNextFrame(); - return frame.image; - }); - } -} - -class _NineSliceAssetState extends State { - ui.Image? _image; - String? _loadedPath; - - @override - void initState() { - super.initState(); - _load(); - } - - @override - void didUpdateWidget(covariant NineSliceAsset oldWidget) { - super.didUpdateWidget(oldWidget); - if (oldWidget.assetPath != widget.assetPath) { - _load(); - } - } - - Future _load() async { - final path = widget.assetPath; - final image = await _NineSliceImageCache.load(path); - if (!mounted) return; - setState(() { - _image = image; - _loadedPath = path; - }); - } - - @override - Widget build(BuildContext context) { - final img = _image; - final ready = img != null && _loadedPath == widget.assetPath; - - return SizedBox( - width: widget.width, - height: widget.height, - child: Container( - constraints: widget.constraints, - child: CustomPaint( - painter: ready - ? _NineSlicePainter( - image: img!, - spec: widget.spec, - ) - : null, - child: Padding( - padding: widget.padding ?? EdgeInsets.zero, - child: widget.child, - ), - ), - ), - ); - } -} - -class _NineSlicePainter extends CustomPainter { - final ui.Image image; - final NineSliceSpec spec; - - _NineSlicePainter({ - required this.image, - required this.spec, - }); - - @override - void paint(Canvas canvas, Size size) { - if (size.width <= 0 || size.height <= 0) return; - - final srcW = image.width.toDouble(); - final srcH = image.height.toDouble(); - final centerSlice = spec.centerSlice; - - final srcLeft = centerSlice.left.clamp(0.0, srcW); - final srcTop = centerSlice.top.clamp(0.0, srcH); - final srcRight = centerSlice.right.clamp(0.0, srcW); - final srcBottom = centerSlice.bottom.clamp(0.0, srcH); - - final srcLeftW = srcLeft; - final srcTopH = srcTop; - final srcRightW = srcW - srcRight; - final srcBottomH = srcH - srcBottom; - final srcCenterW = (srcRight - srcLeft).clamp(1.0, srcW); - final srcCenterH = (srcBottom - srcTop).clamp(1.0, srcH); - - final dstLeftW = spec.cornerWidth.clamp(1.0, size.width / 2); - final dstRightW = spec.cornerWidth.clamp(1.0, size.width / 2); - final dstTopH = spec.cornerHeight.clamp(1.0, size.height / 2); - final dstBottomH = spec.cornerHeight.clamp(1.0, size.height / 2); - final dstCenterW = - (size.width - dstLeftW - dstRightW).clamp(0.0, size.width); - final dstCenterH = - (size.height - dstTopH - dstBottomH).clamp(0.0, size.height); - - final paint = Paint()..filterQuality = FilterQuality.high; - - void draw(Rect src, Rect dst) { - if (src.width <= 0 || src.height <= 0) return; - if (dst.width <= 0 || dst.height <= 0) return; - canvas.drawImageRect(image, src, dst, paint); - } - - // Haut - draw( - Rect.fromLTWH(0, 0, srcLeftW, srcTopH), - Rect.fromLTWH(0, 0, dstLeftW, dstTopH), - ); - draw( - Rect.fromLTWH(srcLeft, 0, srcCenterW, srcTopH), - Rect.fromLTWH(dstLeftW, 0, dstCenterW, dstTopH), - ); - draw( - Rect.fromLTWH(srcRight, 0, srcRightW, srcTopH), - Rect.fromLTWH(dstLeftW + dstCenterW, 0, dstRightW, dstTopH), - ); - - // Milieu - draw( - Rect.fromLTWH(0, srcTop, srcLeftW, srcCenterH), - Rect.fromLTWH(0, dstTopH, dstLeftW, dstCenterH), - ); - draw( - Rect.fromLTWH(srcLeft, srcTop, srcCenterW, srcCenterH), - Rect.fromLTWH(dstLeftW, dstTopH, dstCenterW, dstCenterH), - ); - draw( - Rect.fromLTWH(srcRight, srcTop, srcRightW, srcCenterH), - Rect.fromLTWH(dstLeftW + dstCenterW, dstTopH, dstRightW, dstCenterH), - ); - - // Bas - draw( - Rect.fromLTWH(0, srcBottom, srcLeftW, srcBottomH), - Rect.fromLTWH(0, dstTopH + dstCenterH, dstLeftW, dstBottomH), - ); - draw( - Rect.fromLTWH(srcLeft, srcBottom, srcCenterW, srcBottomH), - Rect.fromLTWH(dstLeftW, dstTopH + dstCenterH, dstCenterW, dstBottomH), - ); - draw( - Rect.fromLTWH(srcRight, srcBottom, srcRightW, srcBottomH), - Rect.fromLTWH( - dstLeftW + dstCenterW, - dstTopH + dstCenterH, - dstRightW, - dstBottomH, - ), - ); - } - - @override - bool shouldRepaint(covariant _NineSlicePainter oldDelegate) { - return oldDelegate.image != image || oldDelegate.spec != spec; - } -}