refactor(#173): widget Carte (extensible par défaut) + fix bandeau

Renomme NineSlice* en Carte. Le bandeau couple s’étire en
horizontal seulement pour garder les bouts demi-cercle.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-01 17:10:41 +02:00
co-authored by Cursor
parent 2c461abc82
commit 5eb0f13e75
4 changed files with 258 additions and 275 deletions
+251
View File
@@ -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<Carte> createState() => _CarteState();
}
class _CarteImageCache {
static final Map<String, Future<ui.Image>> _futures = {};
static Future<ui.Image> 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<Carte> {
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<void> _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;
}
}
@@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart'; import 'package:google_fonts/google_fonts.dart';
import 'package:p_tits_pas/models/carte_bulle.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/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'; import 'package:p_tits_pas/widgets/quotidien/quotidien_theme.dart';
/// Feed de bulles (file d'attention) — ticket #173. /// Feed de bulles (file d'attention) — ticket #173.
@@ -287,7 +287,7 @@ class _BulleTile extends StatelessWidget {
final treated = card.statut == CarteStatut.traitee; final treated = card.statut == CarteStatut.traitee;
return Opacity( return Opacity(
opacity: treated ? 0.55 : 1, opacity: treated ? 0.55 : 1,
child: NineSliceAsset.card( child: Carte(
assetPath: card.backgroundAsset, assetPath: card.backgroundAsset,
constraints: const BoxConstraints(minHeight: 96), constraints: const BoxConstraints(minHeight: 96),
padding: const EdgeInsets.fromLTRB(20, 14, 20, 14), padding: const EdgeInsets.fromLTRB(20, 14, 20, 14),
@@ -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/models/couple_garde.dart';
import 'package:p_tits_pas/services/api/api_config.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/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'; 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 /// 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 assetPath: QuotidienTheme
.bandeauColors[colorIndex % QuotidienTheme.bandeauColors.length], .bandeauColors[colorIndex % QuotidienTheme.bandeauColors.length],
height: 90, height: 90,
@@ -309,7 +309,7 @@ class _CoupleBandeauSkeleton extends StatelessWidget {
Colors.white.withValues(alpha: 0.5), Colors.white.withValues(alpha: 0.5),
BlendMode.lighten, BlendMode.lighten,
), ),
child: NineSliceAsset.bandeau( child: Carte.bandeau(
assetPath: QuotidienTheme.bandeauLime, assetPath: QuotidienTheme.bandeauLime,
height: 90, height: 90,
child: const Center( child: const Center(
@@ -329,7 +329,7 @@ class _CoupleBandeauEmpty extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return NineSliceAsset.bandeau( return Carte.bandeau(
assetPath: QuotidienTheme.bandeauLime, assetPath: QuotidienTheme.bandeauLime,
height: 90, height: 90,
padding: const EdgeInsets.symmetric(horizontal: 24), padding: const EdgeInsets.symmetric(horizontal: 24),
@@ -365,7 +365,7 @@ class _CoupleBandeauError extends StatelessWidget {
QuotidienTheme.coral.withValues(alpha: 0.3), QuotidienTheme.coral.withValues(alpha: 0.3),
BlendMode.srcATop, BlendMode.srcATop,
), ),
child: NineSliceAsset.bandeau( child: Carte.bandeau(
assetPath: QuotidienTheme.bandeauLime, assetPath: QuotidienTheme.bandeauLime,
height: 90, height: 90,
padding: const EdgeInsets.symmetric(horizontal: 24), padding: const EdgeInsets.symmetric(horizontal: 24),
@@ -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<NineSliceAsset> createState() => _NineSliceAssetState();
}
/// Cache partagé pour éviter de recharger le même PNG à chaque bulle.
class _NineSliceImageCache {
static final Map<String, Future<ui.Image>> _futures = {};
static Future<ui.Image> 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<NineSliceAsset> {
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<void> _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;
}
}