diff --git a/frontend/lib/models/card_assets.dart b/frontend/lib/models/card_assets.dart index 3b51a6d..af6982d 100644 --- a/frontend/lib/models/card_assets.dart +++ b/frontend/lib/models/card_assets.dart @@ -24,12 +24,4 @@ enum CardColorHorizontal { final String path; const CardColorHorizontal(this.path); - - /// Zone centrale extensible en pixels source (~1024×512). - /// Coins source ≈ 160×90 — mappés ensuite à une taille logique fixe. - static const Rect centerSlice = Rect.fromLTRB(160, 90, 864, 422); - - /// Taille logique des coins (indépendante de la largeur d’écran). - static const double cornerWidth = 56; - static const double cornerHeight = 40; } diff --git a/frontend/lib/widgets/quotidien/cartes_feed.dart b/frontend/lib/widgets/quotidien/cartes_feed.dart index 2713326..5d1c549 100644 --- a/frontend/lib/widgets/quotidien/cartes_feed.dart +++ b/frontend/lib/widgets/quotidien/cartes_feed.dart @@ -1,6 +1,5 @@ import 'package:flutter/material.dart'; import 'package:google_fonts/google_fonts.dart'; -import 'package:p_tits_pas/models/card_assets.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'; @@ -288,11 +287,8 @@ class _BulleTile extends StatelessWidget { final treated = card.statut == CarteStatut.traitee; return Opacity( opacity: treated ? 0.55 : 1, - child: NineSliceAsset( + child: NineSliceAsset.card( assetPath: card.backgroundAsset, - centerSlice: CardColorHorizontal.centerSlice, - cornerWidth: CardColorHorizontal.cornerWidth, - cornerHeight: CardColorHorizontal.cornerHeight, constraints: const BoxConstraints(minHeight: 96), padding: const EdgeInsets.fromLTRB(20, 14, 20, 14), child: Column( diff --git a/frontend/lib/widgets/quotidien/couple_selector_bandeau.dart b/frontend/lib/widgets/quotidien/couple_selector_bandeau.dart index a95671c..24436b5 100644 --- a/frontend/lib/widgets/quotidien/couple_selector_bandeau.dart +++ b/frontend/lib/widgets/quotidien/couple_selector_bandeau.dart @@ -3,6 +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/quotidien_theme.dart'; /// Rôle qui consulte le bandeau : côté parent (enfant | nounou) ou côté AM @@ -174,14 +175,10 @@ class _CoupleCard extends StatelessWidget { ); } - return Container( + return NineSliceAsset.bandeau( + assetPath: QuotidienTheme + .bandeauColors[colorIndex % QuotidienTheme.bandeauColors.length], height: 90, - decoration: BoxDecoration( - image: DecorationImage( - image: AssetImage(QuotidienTheme.bandeauColors[colorIndex % QuotidienTheme.bandeauColors.length]), - fit: BoxFit.fill, - ), - ), padding: const EdgeInsets.symmetric(horizontal: 24), child: Row( children: [ @@ -190,7 +187,8 @@ class _CoupleCard extends StatelessWidget { padding: const EdgeInsets.only(right: 12), child: _MembreTile( photoUrl: couple.enfant.photoUrl, - name: (couple.enfant.prenom != null && couple.enfant.prenom!.isNotEmpty) + name: (couple.enfant.prenom != null && + couple.enfant.prenom!.isNotEmpty) ? couple.enfant.prenom! : couple.enfant.displayName(fallback: 'Enfant'), fallbackIcon: Icons.child_care, @@ -215,12 +213,12 @@ class _CoupleCard extends StatelessWidget { ), SizedBox( width: 28, - child: showChevron - ? const Icon( - Icons.keyboard_arrow_down, - color: QuotidienTheme.ink, - ) - : null, + child: showChevron + ? const Icon( + Icons.keyboard_arrow_down, + color: QuotidienTheme.ink, + ) + : null, ), ], ), @@ -306,24 +304,22 @@ class _CoupleBandeauSkeleton extends StatelessWidget { @override Widget build(BuildContext context) { - return Container( - height: 90, - decoration: BoxDecoration( - image: DecorationImage( - image: const AssetImage(QuotidienTheme.bandeauLime), // Un asset existant - fit: BoxFit.fill, - colorFilter: ColorFilter.mode( - Colors.white.withValues(alpha: 0.5), - BlendMode.lighten, + return ColorFiltered( + colorFilter: ColorFilter.mode( + Colors.white.withValues(alpha: 0.5), + BlendMode.lighten, + ), + child: NineSliceAsset.bandeau( + assetPath: QuotidienTheme.bandeauLime, + height: 90, + child: const Center( + child: SizedBox( + width: 26, + height: 26, + child: CircularProgressIndicator(strokeWidth: 2), ), ), ), - alignment: Alignment.center, - child: const SizedBox( - width: 26, - height: 26, - child: CircularProgressIndicator(strokeWidth: 2), - ), ); } } @@ -333,14 +329,9 @@ class _CoupleBandeauEmpty extends StatelessWidget { @override Widget build(BuildContext context) { - return Container( + return NineSliceAsset.bandeau( + assetPath: QuotidienTheme.bandeauLime, height: 90, - decoration: const BoxDecoration( - image: DecorationImage( - image: AssetImage(QuotidienTheme.bandeauLime), // Un asset existant par défaut - fit: BoxFit.fill, - ), - ), padding: const EdgeInsets.symmetric(horizontal: 24), child: Row( children: [ @@ -369,38 +360,35 @@ class _CoupleBandeauError extends StatelessWidget { @override Widget build(BuildContext context) { - return Container( - height: 90, - decoration: BoxDecoration( - image: DecorationImage( - image: const AssetImage(QuotidienTheme.bandeauLime), // Un asset existant - fit: BoxFit.fill, - colorFilter: ColorFilter.mode( - QuotidienTheme.coral.withValues(alpha: 0.3), - BlendMode.srcATop, - ), - ), + return ColorFiltered( + colorFilter: ColorFilter.mode( + QuotidienTheme.coral.withValues(alpha: 0.3), + BlendMode.srcATop, ), - padding: const EdgeInsets.symmetric(horizontal: 24), - child: Row( - children: [ - const Icon(Icons.error_outline, color: QuotidienTheme.ink), - const SizedBox(width: 12), - Expanded( - child: Text( - message, - style: GoogleFonts.merriweather( - fontSize: 13, - color: QuotidienTheme.ink, + child: NineSliceAsset.bandeau( + assetPath: QuotidienTheme.bandeauLime, + height: 90, + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Row( + children: [ + const Icon(Icons.error_outline, color: QuotidienTheme.ink), + const SizedBox(width: 12), + Expanded( + child: Text( + message, + style: GoogleFonts.merriweather( + fontSize: 13, + color: QuotidienTheme.ink, + ), ), ), - ), - if (onRetry != null) - TextButton( - onPressed: onRetry, - child: const Text('Réessayer'), - ), - ], + if (onRetry != null) + TextButton( + onPressed: onRetry, + child: const Text('Réessayer'), + ), + ], + ), ), ); } diff --git a/frontend/lib/widgets/quotidien/nine_slice_asset.dart b/frontend/lib/widgets/quotidien/nine_slice_asset.dart index 85406cb..41b8b71 100644 --- a/frontend/lib/widgets/quotidien/nine_slice_asset.dart +++ b/frontend/lib/widgets/quotidien/nine_slice_asset.dart @@ -3,42 +3,115 @@ import 'dart:ui' as ui; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -/// Fond 9-slice dynamique (schéma extensible.png). -/// -/// - Coins : taille logique fixe (ne s’étirent pas avec l’écran) -/// - Bords H / V + centre : s’étirent pour remplir le widget -/// - Pas besoin de fichier `.9.png` Android -class NineSliceAsset extends StatefulWidget { - final String assetPath; - +/// 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 cible des coins gauche / droite. + /// Largeur logique fixe des coins gauche / droite. final double cornerWidth; - /// Hauteur logique cible des coins haut / bas. + /// 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, - required this.centerSlice, - this.cornerWidth = 56, - this.cornerHeight = 40, + 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; @@ -57,25 +130,12 @@ class _NineSliceAssetState extends State { } } - @override - void dispose() { - _image?.dispose(); - super.dispose(); - } - Future _load() async { final path = widget.assetPath; - final data = await rootBundle.load(path); - final bytes = data.buffer.asUint8List(); - final codec = await ui.instantiateImageCodec(bytes); - final frame = await codec.getNextFrame(); - if (!mounted) { - frame.image.dispose(); - return; - } + final image = await _NineSliceImageCache.load(path); + if (!mounted) return; setState(() { - _image?.dispose(); - _image = frame.image; + _image = image; _loadedPath = path; }); } @@ -85,20 +145,22 @@ class _NineSliceAssetState extends State { final img = _image; final ready = img != null && _loadedPath == widget.assetPath; - return Container( - constraints: widget.constraints, - child: CustomPaint( - painter: ready - ? _NineSlicePainter( - image: img!, - centerSlice: widget.centerSlice, - cornerWidth: widget.cornerWidth, - cornerHeight: widget.cornerHeight, - ) - : null, - child: Padding( - padding: widget.padding ?? EdgeInsets.zero, - child: widget.child, + 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, + ), ), ), ); @@ -107,15 +169,11 @@ class _NineSliceAssetState extends State { class _NineSlicePainter extends CustomPainter { final ui.Image image; - final Rect centerSlice; - final double cornerWidth; - final double cornerHeight; + final NineSliceSpec spec; _NineSlicePainter({ required this.image, - required this.centerSlice, - required this.cornerWidth, - required this.cornerHeight, + required this.spec, }); @override @@ -124,8 +182,8 @@ class _NineSlicePainter extends CustomPainter { final srcW = image.width.toDouble(); final srcH = image.height.toDouble(); + final centerSlice = spec.centerSlice; - // Découpe source (pixels image) — coins / bords / centre. final srcLeft = centerSlice.left.clamp(0.0, srcW); final srcTop = centerSlice.top.clamp(0.0, srcH); final srcRight = centerSlice.right.clamp(0.0, srcW); @@ -138,13 +196,12 @@ class _NineSlicePainter extends CustomPainter { final srcCenterW = (srcRight - srcLeft).clamp(1.0, srcW); final srcCenterH = (srcBottom - srcTop).clamp(1.0, srcH); - // Destination : coins à taille logique fixe, plafonnés à la moitié - // pour rester valides sur très petits widgets. - 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 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); @@ -156,7 +213,7 @@ class _NineSlicePainter extends CustomPainter { canvas.drawImageRect(image, src, dst, paint); } - // Ligne du haut + // Haut draw( Rect.fromLTWH(0, 0, srcLeftW, srcTopH), Rect.fromLTWH(0, 0, dstLeftW, dstTopH), @@ -170,7 +227,7 @@ class _NineSlicePainter extends CustomPainter { Rect.fromLTWH(dstLeftW + dstCenterW, 0, dstRightW, dstTopH), ); - // Ligne du milieu + // Milieu draw( Rect.fromLTWH(0, srcTop, srcLeftW, srcCenterH), Rect.fromLTWH(0, dstTopH, dstLeftW, dstCenterH), @@ -184,7 +241,7 @@ class _NineSlicePainter extends CustomPainter { Rect.fromLTWH(dstLeftW + dstCenterW, dstTopH, dstRightW, dstCenterH), ); - // Ligne du bas + // Bas draw( Rect.fromLTWH(0, srcBottom, srcLeftW, srcBottomH), Rect.fromLTWH(0, dstTopH + dstCenterH, dstLeftW, dstBottomH), @@ -206,9 +263,6 @@ class _NineSlicePainter extends CustomPainter { @override bool shouldRepaint(covariant _NineSlicePainter oldDelegate) { - return oldDelegate.image != image || - oldDelegate.centerSlice != centerSlice || - oldDelegate.cornerWidth != cornerWidth || - oldDelegate.cornerHeight != cornerHeight; + return oldDelegate.image != image || oldDelegate.spec != spec; } }