fix(#173): 9-slice manuel avec coins à taille logique fixe
Remplace DecorationImage.centerSlice (inopérant si bulle trop basse) par un peintre 9 zones : coins fixes, centre extensible. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -25,13 +25,11 @@ enum CardColorHorizontal {
|
|||||||
final String path;
|
final String path;
|
||||||
const CardColorHorizontal(this.path);
|
const CardColorHorizontal(this.path);
|
||||||
|
|
||||||
/// Taille intrinsèque des PNG `card_*_h` (~1024×512).
|
/// Zone centrale extensible en pixels source (~1024×512).
|
||||||
static const Size sourceSize = Size(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.
|
/// Taille logique des coins (indépendante de la largeur d’écran).
|
||||||
static const double displayScale = 2.0;
|
static const double cornerWidth = 56;
|
||||||
|
static const double cornerHeight = 40;
|
||||||
/// 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);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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/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/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,20 +288,12 @@ 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: Container(
|
child: NineSliceAsset(
|
||||||
constraints: const BoxConstraints(minHeight: 96),
|
assetPath: card.backgroundAsset,
|
||||||
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,
|
centerSlice: CardColorHorizontal.centerSlice,
|
||||||
fit: BoxFit.fill,
|
cornerWidth: CardColorHorizontal.cornerWidth,
|
||||||
),
|
cornerHeight: CardColorHorizontal.cornerHeight,
|
||||||
),
|
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(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
@@ -381,7 +374,8 @@ class _BulleTile extends StatelessWidget {
|
|||||||
spacing: 8,
|
spacing: 8,
|
||||||
runSpacing: 4,
|
runSpacing: 4,
|
||||||
children: [
|
children: [
|
||||||
if (card.responseMode == CarteResponseMode.acceptRefuse) ...[
|
if (card.responseMode ==
|
||||||
|
CarteResponseMode.acceptRefuse) ...[
|
||||||
_ActionChip(
|
_ActionChip(
|
||||||
label: 'Accepter',
|
label: 'Accepter',
|
||||||
onTap: onAccept,
|
onTap: onAccept,
|
||||||
|
|||||||
@@ -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<NineSliceAsset> createState() => _NineSliceAssetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_image?.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user