refactor(#173): widget NineSliceAsset réutilisable (cartes + bandeau)

Presets cards/bandeau, cache images, appliqué au sélecteur couple.

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 42163826fe
commit 2c461abc82
4 changed files with 172 additions and 142 deletions
-8
View File
@@ -24,12 +24,4 @@ enum CardColorHorizontal {
final String path; final String path;
const CardColorHorizontal(this.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;
} }
@@ -1,6 +1,5 @@
import 'package:flutter/material.dart'; 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/card_assets.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/nine_slice_asset.dart';
@@ -288,11 +287,8 @@ 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( child: NineSliceAsset.card(
assetPath: card.backgroundAsset, assetPath: card.backgroundAsset,
centerSlice: CardColorHorizontal.centerSlice,
cornerWidth: CardColorHorizontal.cornerWidth,
cornerHeight: CardColorHorizontal.cornerHeight,
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),
child: Column( child: Column(
@@ -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/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/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
@@ -174,14 +175,10 @@ class _CoupleCard extends StatelessWidget {
); );
} }
return Container( return NineSliceAsset.bandeau(
assetPath: QuotidienTheme
.bandeauColors[colorIndex % QuotidienTheme.bandeauColors.length],
height: 90, height: 90,
decoration: BoxDecoration(
image: DecorationImage(
image: AssetImage(QuotidienTheme.bandeauColors[colorIndex % QuotidienTheme.bandeauColors.length]),
fit: BoxFit.fill,
),
),
padding: const EdgeInsets.symmetric(horizontal: 24), padding: const EdgeInsets.symmetric(horizontal: 24),
child: Row( child: Row(
children: [ children: [
@@ -190,7 +187,8 @@ class _CoupleCard extends StatelessWidget {
padding: const EdgeInsets.only(right: 12), padding: const EdgeInsets.only(right: 12),
child: _MembreTile( child: _MembreTile(
photoUrl: couple.enfant.photoUrl, 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.prenom!
: couple.enfant.displayName(fallback: 'Enfant'), : couple.enfant.displayName(fallback: 'Enfant'),
fallbackIcon: Icons.child_care, fallbackIcon: Icons.child_care,
@@ -215,12 +213,12 @@ class _CoupleCard extends StatelessWidget {
), ),
SizedBox( SizedBox(
width: 28, width: 28,
child: showChevron child: showChevron
? const Icon( ? const Icon(
Icons.keyboard_arrow_down, Icons.keyboard_arrow_down,
color: QuotidienTheme.ink, color: QuotidienTheme.ink,
) )
: null, : null,
), ),
], ],
), ),
@@ -306,24 +304,22 @@ class _CoupleBandeauSkeleton extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Container( return ColorFiltered(
height: 90, colorFilter: ColorFilter.mode(
decoration: BoxDecoration( Colors.white.withValues(alpha: 0.5),
image: DecorationImage( BlendMode.lighten,
image: const AssetImage(QuotidienTheme.bandeauLime), // Un asset existant ),
fit: BoxFit.fill, child: NineSliceAsset.bandeau(
colorFilter: ColorFilter.mode( assetPath: QuotidienTheme.bandeauLime,
Colors.white.withValues(alpha: 0.5), height: 90,
BlendMode.lighten, 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Container( return NineSliceAsset.bandeau(
assetPath: QuotidienTheme.bandeauLime,
height: 90, 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), padding: const EdgeInsets.symmetric(horizontal: 24),
child: Row( child: Row(
children: [ children: [
@@ -369,38 +360,35 @@ class _CoupleBandeauError extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Container( return ColorFiltered(
height: 90, colorFilter: ColorFilter.mode(
decoration: BoxDecoration( QuotidienTheme.coral.withValues(alpha: 0.3),
image: DecorationImage( BlendMode.srcATop,
image: const AssetImage(QuotidienTheme.bandeauLime), // Un asset existant
fit: BoxFit.fill,
colorFilter: ColorFilter.mode(
QuotidienTheme.coral.withValues(alpha: 0.3),
BlendMode.srcATop,
),
),
), ),
padding: const EdgeInsets.symmetric(horizontal: 24), child: NineSliceAsset.bandeau(
child: Row( assetPath: QuotidienTheme.bandeauLime,
children: [ height: 90,
const Icon(Icons.error_outline, color: QuotidienTheme.ink), padding: const EdgeInsets.symmetric(horizontal: 24),
const SizedBox(width: 12), child: Row(
Expanded( children: [
child: Text( const Icon(Icons.error_outline, color: QuotidienTheme.ink),
message, const SizedBox(width: 12),
style: GoogleFonts.merriweather( Expanded(
fontSize: 13, child: Text(
color: QuotidienTheme.ink, message,
style: GoogleFonts.merriweather(
fontSize: 13,
color: QuotidienTheme.ink,
),
), ),
), ),
), if (onRetry != null)
if (onRetry != null) TextButton(
TextButton( onPressed: onRetry,
onPressed: onRetry, child: const Text('Réessayer'),
child: const Text('Réessayer'), ),
), ],
], ),
), ),
); );
} }
@@ -3,42 +3,115 @@ import 'dart:ui' as ui;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
/// Fond 9-slice dynamique (schéma extensible.png). /// Paramètres de découpe 9-slice (schéma extensible.png).
/// class NineSliceSpec {
/// - 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). /// Zone centrale extensible en pixels source (LTRB du centre).
final Rect centerSlice; final Rect centerSlice;
/// Largeur logique cible des coins gauche / droite. /// Largeur logique fixe des coins gauche / droite.
final double cornerWidth; final double cornerWidth;
/// Hauteur logique cible des coins haut / bas. /// Hauteur logique fixe des coins haut / bas.
final double cornerHeight; 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 Widget? child;
final EdgeInsetsGeometry? padding; final EdgeInsetsGeometry? padding;
final BoxConstraints? constraints; final BoxConstraints? constraints;
final double? width;
final double? height;
const NineSliceAsset({ const NineSliceAsset({
super.key, super.key,
required this.assetPath, required this.assetPath,
required this.centerSlice, this.spec = NineSliceSpec.cards,
this.cornerWidth = 56,
this.cornerHeight = 40,
this.child, this.child,
this.padding, this.padding,
this.constraints, 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 @override
State<NineSliceAsset> createState() => _NineSliceAssetState(); 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> { class _NineSliceAssetState extends State<NineSliceAsset> {
ui.Image? _image; ui.Image? _image;
String? _loadedPath; String? _loadedPath;
@@ -57,25 +130,12 @@ class _NineSliceAssetState extends State<NineSliceAsset> {
} }
} }
@override
void dispose() {
_image?.dispose();
super.dispose();
}
Future<void> _load() async { Future<void> _load() async {
final path = widget.assetPath; final path = widget.assetPath;
final data = await rootBundle.load(path); final image = await _NineSliceImageCache.load(path);
final bytes = data.buffer.asUint8List(); if (!mounted) return;
final codec = await ui.instantiateImageCodec(bytes);
final frame = await codec.getNextFrame();
if (!mounted) {
frame.image.dispose();
return;
}
setState(() { setState(() {
_image?.dispose(); _image = image;
_image = frame.image;
_loadedPath = path; _loadedPath = path;
}); });
} }
@@ -85,20 +145,22 @@ class _NineSliceAssetState extends State<NineSliceAsset> {
final img = _image; final img = _image;
final ready = img != null && _loadedPath == widget.assetPath; final ready = img != null && _loadedPath == widget.assetPath;
return Container( return SizedBox(
constraints: widget.constraints, width: widget.width,
child: CustomPaint( height: widget.height,
painter: ready child: Container(
? _NineSlicePainter( constraints: widget.constraints,
image: img!, child: CustomPaint(
centerSlice: widget.centerSlice, painter: ready
cornerWidth: widget.cornerWidth, ? _NineSlicePainter(
cornerHeight: widget.cornerHeight, image: img!,
) spec: widget.spec,
: null, )
child: Padding( : null,
padding: widget.padding ?? EdgeInsets.zero, child: Padding(
child: widget.child, padding: widget.padding ?? EdgeInsets.zero,
child: widget.child,
),
), ),
), ),
); );
@@ -107,15 +169,11 @@ class _NineSliceAssetState extends State<NineSliceAsset> {
class _NineSlicePainter extends CustomPainter { class _NineSlicePainter extends CustomPainter {
final ui.Image image; final ui.Image image;
final Rect centerSlice; final NineSliceSpec spec;
final double cornerWidth;
final double cornerHeight;
_NineSlicePainter({ _NineSlicePainter({
required this.image, required this.image,
required this.centerSlice, required this.spec,
required this.cornerWidth,
required this.cornerHeight,
}); });
@override @override
@@ -124,8 +182,8 @@ class _NineSlicePainter extends CustomPainter {
final srcW = image.width.toDouble(); final srcW = image.width.toDouble();
final srcH = image.height.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 srcLeft = centerSlice.left.clamp(0.0, srcW);
final srcTop = centerSlice.top.clamp(0.0, srcH); final srcTop = centerSlice.top.clamp(0.0, srcH);
final srcRight = centerSlice.right.clamp(0.0, srcW); 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 srcCenterW = (srcRight - srcLeft).clamp(1.0, srcW);
final srcCenterH = (srcBottom - srcTop).clamp(1.0, srcH); final srcCenterH = (srcBottom - srcTop).clamp(1.0, srcH);
// Destination : coins à taille logique fixe, plafonnés à la moitié final dstLeftW = spec.cornerWidth.clamp(1.0, size.width / 2);
// pour rester valides sur très petits widgets. final dstRightW = spec.cornerWidth.clamp(1.0, size.width / 2);
final dstLeftW = cornerWidth.clamp(1.0, size.width / 2); final dstTopH = spec.cornerHeight.clamp(1.0, size.height / 2);
final dstRightW = cornerWidth.clamp(1.0, size.width / 2); final dstBottomH = spec.cornerHeight.clamp(1.0, size.height / 2);
final dstTopH = cornerHeight.clamp(1.0, size.height / 2); final dstCenterW =
final dstBottomH = cornerHeight.clamp(1.0, size.height / 2); (size.width - dstLeftW - dstRightW).clamp(0.0, size.width);
final dstCenterW = (size.width - dstLeftW - dstRightW).clamp(0.0, size.width);
final dstCenterH = final dstCenterH =
(size.height - dstTopH - dstBottomH).clamp(0.0, size.height); (size.height - dstTopH - dstBottomH).clamp(0.0, size.height);
@@ -156,7 +213,7 @@ class _NineSlicePainter extends CustomPainter {
canvas.drawImageRect(image, src, dst, paint); canvas.drawImageRect(image, src, dst, paint);
} }
// Ligne du haut // Haut
draw( draw(
Rect.fromLTWH(0, 0, srcLeftW, srcTopH), Rect.fromLTWH(0, 0, srcLeftW, srcTopH),
Rect.fromLTWH(0, 0, dstLeftW, dstTopH), Rect.fromLTWH(0, 0, dstLeftW, dstTopH),
@@ -170,7 +227,7 @@ class _NineSlicePainter extends CustomPainter {
Rect.fromLTWH(dstLeftW + dstCenterW, 0, dstRightW, dstTopH), Rect.fromLTWH(dstLeftW + dstCenterW, 0, dstRightW, dstTopH),
); );
// Ligne du milieu // Milieu
draw( draw(
Rect.fromLTWH(0, srcTop, srcLeftW, srcCenterH), Rect.fromLTWH(0, srcTop, srcLeftW, srcCenterH),
Rect.fromLTWH(0, dstTopH, dstLeftW, dstCenterH), Rect.fromLTWH(0, dstTopH, dstLeftW, dstCenterH),
@@ -184,7 +241,7 @@ class _NineSlicePainter extends CustomPainter {
Rect.fromLTWH(dstLeftW + dstCenterW, dstTopH, dstRightW, dstCenterH), Rect.fromLTWH(dstLeftW + dstCenterW, dstTopH, dstRightW, dstCenterH),
); );
// Ligne du bas // Bas
draw( draw(
Rect.fromLTWH(0, srcBottom, srcLeftW, srcBottomH), Rect.fromLTWH(0, srcBottom, srcLeftW, srcBottomH),
Rect.fromLTWH(0, dstTopH + dstCenterH, dstLeftW, dstBottomH), Rect.fromLTWH(0, dstTopH + dstCenterH, dstLeftW, dstBottomH),
@@ -206,9 +263,6 @@ class _NineSlicePainter extends CustomPainter {
@override @override
bool shouldRepaint(covariant _NineSlicePainter oldDelegate) { bool shouldRepaint(covariant _NineSlicePainter oldDelegate) {
return oldDelegate.image != image || return oldDelegate.image != image || oldDelegate.spec != spec;
oldDelegate.centerSlice != centerSlice ||
oldDelegate.cornerWidth != cornerWidth ||
oldDelegate.cornerHeight != cornerHeight;
} }
} }