From 42163826fea37b2122cf2cb7683c140d603ab8d8 Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Fri, 25 Sep 2026 12:16:24 +0200 Subject: [PATCH] =?UTF-8?q?fix(#173):=209-slice=20manuel=20avec=20coins=20?= =?UTF-8?q?=C3=A0=20taille=20logique=20fixe?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remplace DecorationImage.centerSlice (inopérant si bulle trop basse) par un peintre 9 zones : coins fixes, centre extensible. Co-authored-by: Cursor --- frontend/lib/models/card_assets.dart | 14 +- .../lib/widgets/quotidien/cartes_feed.dart | 22 +- .../widgets/quotidien/nine_slice_asset.dart | 214 ++++++++++++++++++ 3 files changed, 228 insertions(+), 22 deletions(-) create mode 100644 frontend/lib/widgets/quotidien/nine_slice_asset.dart diff --git a/frontend/lib/models/card_assets.dart b/frontend/lib/models/card_assets.dart index f046103..3b51a6d 100644 --- a/frontend/lib/models/card_assets.dart +++ b/frontend/lib/models/card_assets.dart @@ -25,13 +25,11 @@ enum CardColorHorizontal { final String path; const CardColorHorizontal(this.path); - /// Taille intrinsèque des PNG `card_*_h` (~1024×512). - static const Size sourceSize = Size(1024, 512); + /// 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); - /// Facteur d’affichage : PNG ~2× → coins 9-slice en taille logique correcte. - static const double displayScale = 2.0; - - /// 9-slice en pixels image : coins fixes, bords H/V + centre extensibles. - /// Même logique que extensible.png — sans fichier `.9.png` Android. - static const Rect centerSlice = Rect.fromLTRB(100, 90, 924, 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 d3bf935..2713326 100644 --- a/frontend/lib/widgets/quotidien/cartes_feed.dart +++ b/frontend/lib/widgets/quotidien/cartes_feed.dart @@ -3,6 +3,7 @@ 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'; import 'package:p_tits_pas/widgets/quotidien/quotidien_theme.dart'; /// Feed de bulles (file d'attention) — ticket #173. @@ -287,20 +288,12 @@ class _BulleTile extends StatelessWidget { final treated = card.statut == CarteStatut.traitee; return Opacity( opacity: treated ? 0.55 : 1, - child: Container( + child: NineSliceAsset( + assetPath: card.backgroundAsset, + centerSlice: CardColorHorizontal.centerSlice, + cornerWidth: CardColorHorizontal.cornerWidth, + cornerHeight: CardColorHorizontal.cornerHeight, constraints: const BoxConstraints(minHeight: 96), - decoration: BoxDecoration( - image: DecorationImage( - image: ExactAssetImage( - card.backgroundAsset, - scale: CardColorHorizontal.displayScale, - ), - // 9-slice dynamique : coins fixes, bords / centre étirés - // selon la taille réelle de la bulle (tout écran). - centerSlice: CardColorHorizontal.centerSlice, - fit: BoxFit.fill, - ), - ), padding: const EdgeInsets.fromLTRB(20, 14, 20, 14), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, @@ -381,7 +374,8 @@ class _BulleTile extends StatelessWidget { spacing: 8, runSpacing: 4, children: [ - if (card.responseMode == CarteResponseMode.acceptRefuse) ...[ + if (card.responseMode == + CarteResponseMode.acceptRefuse) ...[ _ActionChip( label: 'Accepter', onTap: onAccept, diff --git a/frontend/lib/widgets/quotidien/nine_slice_asset.dart b/frontend/lib/widgets/quotidien/nine_slice_asset.dart new file mode 100644 index 0000000..85406cb --- /dev/null +++ b/frontend/lib/widgets/quotidien/nine_slice_asset.dart @@ -0,0 +1,214 @@ +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; + + /// Zone centrale extensible en pixels source (LTRB du centre). + final Rect centerSlice; + + /// Largeur logique cible des coins gauche / droite. + final double cornerWidth; + + /// Hauteur logique cible des coins haut / bas. + final double cornerHeight; + + final Widget? child; + final EdgeInsetsGeometry? padding; + final BoxConstraints? constraints; + + const NineSliceAsset({ + super.key, + required this.assetPath, + required this.centerSlice, + this.cornerWidth = 56, + this.cornerHeight = 40, + this.child, + this.padding, + this.constraints, + }); + + @override + State createState() => _NineSliceAssetState(); +} + +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(); + } + } + + @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; + } + setState(() { + _image?.dispose(); + _image = frame.image; + _loadedPath = path; + }); + } + + @override + Widget build(BuildContext context) { + 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, + ), + ), + ); + } +} + +class _NineSlicePainter extends CustomPainter { + final ui.Image image; + final Rect centerSlice; + final double cornerWidth; + final double cornerHeight; + + _NineSlicePainter({ + required this.image, + 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 srcW = image.width.toDouble(); + final srcH = image.height.toDouble(); + + // 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); + 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); + + // 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 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); + } + + // Ligne du 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), + ); + + // Ligne du 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), + ); + + // Ligne du 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.centerSlice != centerSlice || + oldDelegate.cornerWidth != cornerWidth || + oldDelegate.cornerHeight != cornerHeight; + } +}