release: squash merge develop → master

Quotidien / Epic C : feed bulles parent (#173), module Cartes (#194/#195),
evenements_agenda (#205), couples garde AM (#169–#171), API absences (#172).

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-05 15:44:10 +02:00
co-authored by Cursor
parent ba6078feab
commit da024d997a
94 changed files with 6263 additions and 236 deletions
@@ -185,6 +185,8 @@ class BaseFormScreen extends StatelessWidget {
return CardColorVertical.pink.path;
case CardColorHorizontal.red:
return CardColorVertical.red.path;
case CardColorHorizontal.aqua:
return CardColorVertical.aqua.path;
}
}
+9 -3
View File
@@ -205,9 +205,14 @@ class _ChildCardWidgetState extends State<ChildCardWidget> {
);
}
final Color baseCardColorForShadow = widget.childData.cardColor == CardColorVertical.lavender
? Colors.purple.shade200
: (widget.childData.cardColor == CardColorVertical.pink ? Colors.pink.shade200 : Colors.grey.shade200);
final Color baseCardColorForShadow =
widget.childData.cardColor == CardColorVertical.lavender
? Colors.purple.shade200
: (widget.childData.cardColor == CardColorVertical.pink
? Colors.pink.shade200
: (widget.childData.cardColor == CardColorVertical.aqua
? Colors.teal.shade200
: Colors.grey.shade200));
final double photoSide = 200.0 * (config.isMobile ? 0.8 : 1.0);
final double editableCardWidth = config.isMobile
? double.infinity
@@ -340,6 +345,7 @@ class _ChildCardWidgetState extends State<ChildCardWidget> {
// Mapping manuel simple
if (widget.childData.cardColor.path.contains('lavender')) horizontalCardAsset = CardColorHorizontal.lavender.path;
else if (widget.childData.cardColor.path.contains('aqua')) horizontalCardAsset = CardColorHorizontal.aqua.path;
else if (widget.childData.cardColor.path.contains('blue')) horizontalCardAsset = CardColorHorizontal.blue.path;
else if (widget.childData.cardColor.path.contains('green')) horizontalCardAsset = CardColorHorizontal.green.path;
else if (widget.childData.cardColor.path.contains('lime')) horizontalCardAsset = CardColorHorizontal.lime.path;
@@ -1302,6 +1302,8 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
return CardColorVertical.pink.path;
case CardColorHorizontal.red:
return CardColorVertical.red.path;
case CardColorHorizontal.aqua:
return CardColorVertical.aqua.path;
}
}
}
@@ -642,6 +642,8 @@ class _PresentationFormScreenState extends State<PresentationFormScreen> {
return CardColorVertical.pink.path;
case CardColorHorizontal.red:
return CardColorVertical.red.path;
case CardColorHorizontal.aqua:
return CardColorVertical.aqua.path;
}
}
}
@@ -1196,6 +1196,8 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
return CardColorVertical.pink.path;
case CardColorHorizontal.red:
return CardColorVertical.red.path;
case CardColorHorizontal.aqua:
return CardColorVertical.aqua.path;
}
}
}
+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;
}
}
File diff suppressed because it is too large Load Diff
@@ -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/carte.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
@@ -37,7 +38,7 @@ class CoupleSelectorBandeau extends StatelessWidget {
CoupleGarde? get _selected {
if (couples.isEmpty) return null;
if (selectedCoupleId != null) {
if (selectedCoupleId != null) {
for (final c in couples) {
if (c.id == selectedCoupleId) return c;
}
@@ -133,16 +134,51 @@ class _CoupleCard extends StatelessWidget {
required this.colorIndex,
});
/// Libellé parents côté AM : « Sophie » ou « Thomas et Claire ».
static String _parentsLabel(List<CoupleMembre> parents) {
if (parents.isEmpty) return 'Parents';
String prenomOf(CoupleMembre p) {
final prenom = (p.prenom ?? '').trim();
if (prenom.isNotEmpty) return prenom;
return p.displayName(fallback: 'Parent');
}
if (parents.length == 1) return prenomOf(parents.first);
return '${prenomOf(parents[0])} et ${prenomOf(parents[1])}';
}
@override
Widget build(BuildContext context) {
return Container(
height: 90,
decoration: BoxDecoration(
image: DecorationImage(
image: AssetImage(QuotidienTheme.bandeauColors[colorIndex % QuotidienTheme.bandeauColors.length]),
fit: BoxFit.fill,
// Parent mode: enfant | AM (photo + prénom)
// AM mode: enfant | parent(s) — texte seul, pas de vignette parent
final isParentMode = mode == CoupleBandeauMode.parent;
Widget amOrParentsWidget;
if (isParentMode) {
amOrParentsWidget = _MembreTile(
photoUrl: couple.am?.photoUrl,
name: (couple.am?.prenom != null && couple.am!.prenom!.isNotEmpty)
? couple.am!.prenom!
: (couple.am?.displayName(fallback: 'Nounou') ?? 'Nounou'),
fallbackIcon: Icons.volunteer_activism,
);
} else {
amOrParentsWidget = Text(
_parentsLabel(couple.parents),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: GoogleFonts.merienda(
fontSize: 22,
fontWeight: FontWeight.bold,
color: QuotidienTheme.ink,
),
),
);
}
return Carte.bandeau(
assetPath: QuotidienTheme
.bandeauColors[colorIndex % QuotidienTheme.bandeauColors.length],
height: 90,
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Row(
children: [
@@ -151,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,
@@ -171,29 +208,17 @@ class _CoupleCard extends StatelessWidget {
Expanded(
child: Padding(
padding: const EdgeInsets.only(left: 12),
child: _MembreTile(
photoUrl: couple.am.photoUrl,
name: (couple.am.prenom != null && couple.am.prenom!.isNotEmpty)
? couple.am.prenom!
: couple.am.displayName(
fallback: mode == CoupleBandeauMode.parent
? 'Nounou'
: 'Parent',
),
fallbackIcon: mode == CoupleBandeauMode.parent
? Icons.volunteer_activism
: Icons.person_outline,
),
child: amOrParentsWidget,
),
),
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,
),
],
),
@@ -279,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: Carte.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),
),
);
}
}
@@ -306,14 +329,9 @@ class _CoupleBandeauEmpty extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Container(
return Carte.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: [
@@ -342,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: Carte.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'),
),
],
),
),
);
}
@@ -183,15 +183,16 @@ class _UserMenu extends StatelessWidget {
title: Text('Profil'),
),
),
const PopupMenuItem(
value: 'search_am',
child: ListTile(
dense: true,
contentPadding: EdgeInsets.zero,
leading: Icon(Icons.search, size: 20),
title: Text('Recherche AM'),
if (onSearchAmTap != null)
const PopupMenuItem(
value: 'search_am',
child: ListTile(
dense: true,
contentPadding: EdgeInsets.zero,
leading: Icon(Icons.search, size: 20),
title: Text('Recherche AM'),
),
),
),
const PopupMenuItem(
value: 'settings',
child: ListTile(
@@ -6,6 +6,7 @@ abstract final class QuotidienTheme {
static const Color ink = Color(0xFF2F2F2F);
static const Color ivory = Color(0xFFFFFEF9);
static const Color turquoise = Color(0xFF8AD0C8);
static const Color peach = Color(0xFFFFCCB6);
static const Color lavender = Color(0xFFC6A3D8);
static const Color coral = Color(0xFFF4A28C);
static const Color softGreenPill = Color(0xFFB8D9A8);
@@ -48,6 +49,7 @@ abstract final class QuotidienTheme {
'assets/images/bg_turquoise_pill.png';
static const String pillLavenderAsset =
'assets/images/bg_lavender_pill.png';
static const String pillCorailAsset = 'assets/images/bg_corail_pill.png';
/// Trait crayon gris (2400×28, transparent) : séparateurs bandeau / corps /
/// footer. Version verticale (28×2400) entre les colonnes.