feat(#173): layout bulles unifié, sac_lapin, statut Validé/Refusé

Icône absence aquarelle, calendrier teinté à la volée, encres par
famille de couleur, modales congés clarifiées, et libellé Validé/Refusé
dans la barre d’actions une fois la demande traitée.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-05 11:13:28 +02:00
co-authored by Cursor
parent d61e34a1e6
commit edab253296
7 changed files with 236 additions and 86 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 591 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 683 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 289 KiB

+1 -1
View File
@@ -114,7 +114,7 @@ class CarteBulle {
return 'assets/images/cartes/icon_carte_maladie.png'; return 'assets/images/cartes/icon_carte_maladie.png';
case 'absence_enfant': case 'absence_enfant':
case 'absence_enfant_modif': case 'absence_enfant_modif':
return 'assets/images/cartes/icon_carte_absence.png'; return 'assets/images/sac_lapin.png';
// soin_enfant : icône selon sous-type payload — à brancher plus tard // soin_enfant : icône selon sous-type payload — à brancher plus tard
default: default:
return null; return null;
+235 -85
View File
@@ -1,5 +1,6 @@
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/carte.dart'; import 'package:p_tits_pas/widgets/quotidien/carte.dart';
@@ -138,8 +139,10 @@ class _CartesFeedState extends State<CartesFeed> {
style: GoogleFonts.merienda(fontWeight: FontWeight.w600), style: GoogleFonts.merienda(fontWeight: FontWeight.w600),
), ),
content: Text( content: Text(
'Confirmez-vous l’acceptation de « ${card.titreAffiche} » ' card.isCongeAm
'(${_periodeCourte(card)}) ?', ? 'Confirmez-vous les congés ${_periodeDuAu(card)} ?'
: 'Confirmez-vous l’acceptation de « ${card.titreAffiche} » '
'(${_periodeCourte(card)}) ?',
style: GoogleFonts.merriweather(fontSize: 14, height: 1.4), style: GoogleFonts.merriweather(fontSize: 14, height: 1.4),
), ),
actions: [ actions: [
@@ -168,8 +171,14 @@ class _CartesFeedState extends State<CartesFeed> {
style: GoogleFonts.merienda(fontWeight: FontWeight.w600), style: GoogleFonts.merienda(fontWeight: FontWeight.w600),
), ),
content: Text( content: Text(
'Confirmez-vous avoir bien reçu cet arrêt maladie ' card.isArretMaladie
'(${_periodeCourte(card)}) ?', ? 'Confirmez-vous avoir pris connaissance de cet arrêt maladie '
'${_periodeDuAu(card)} ?'
: card.typeCode.startsWith('absence_enfant')
? 'Confirmez-vous avoir pris connaissance de l’absence '
'${_periodeDuAu(card)} ?'
: 'Confirmez-vous avoir bien reçu « ${card.titreAffiche} » '
'(${_periodeCourte(card)}) ?',
style: GoogleFonts.merriweather(fontSize: 14, height: 1.4), style: GoogleFonts.merriweather(fontSize: 14, height: 1.4),
), ),
actions: [ actions: [
@@ -190,59 +199,36 @@ class _CartesFeedState extends State<CartesFeed> {
} }
Future<void> _onRefuse(CarteBulle card) async { Future<void> _onRefuse(CarteBulle card) async {
final controller = TextEditingController(); final comment = await showDialog<String>(
final ok = await showDialog<bool>(
context: context, context: context,
builder: (ctx) => AlertDialog( builder: (ctx) => _RefuseDialog(
title: Text( title: card.isCongeAm ? 'Refuser les congés ?' : 'Refuser la demande',
'Refuser la demande', message: card.isCongeAm
style: GoogleFonts.merienda(fontWeight: FontWeight.w600), ? 'Indiquez le motif du refus des congés '
), '${_periodeDuAu(card)}.'
content: Column( : 'Indiquez le motif du refus pour « ${card.titreAffiche} ».',
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Indiquez le motif du refus pour « ${card.titreAffiche} ».',
style: GoogleFonts.merriweather(fontSize: 14, height: 1.4),
),
const SizedBox(height: 12),
TextField(
controller: controller,
maxLines: 3,
autofocus: true,
decoration: const InputDecoration(
hintText: 'Motif du refus…',
border: OutlineInputBorder(),
),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Annuler'),
),
FilledButton(
onPressed: () {
if (controller.text.trim().isEmpty) return;
Navigator.pop(ctx, true);
},
child: const Text('Refuser'),
),
],
), ),
); );
if (ok == true && controller.text.trim().isNotEmpty) { if (comment != null && comment.isNotEmpty) {
await _respond( await _respond(card, action: 'refuse', comment: comment);
card,
action: 'refuse',
comment: controller.text.trim(),
);
} }
controller.dispose();
} }
static const _mois = [
'janvier',
'février',
'mars',
'avril',
'mai',
'juin',
'juillet',
'août',
'septembre',
'octobre',
'novembre',
'décembre',
];
static String _periodeCourte(CarteBulle card) { static String _periodeCourte(CarteBulle card) {
final d = card.dateDebut; final d = card.dateDebut;
final f = card.dateFin; final f = card.dateFin;
@@ -258,6 +244,23 @@ class _CartesFeedState extends State<CartesFeed> {
return '${fmt(d)} → ${fmt(f)}'; return '${fmt(d)} → ${fmt(f)}';
} }
/// « du 04 novembre 2026 au 06 novembre 2026 »
static String _periodeDuAu(CarteBulle card) {
String fmtLong(String? iso) {
if (iso == null || iso.isEmpty) return '…';
final dt = DateTime.tryParse(iso);
if (dt == null) return iso;
final jj = dt.day.toString().padLeft(2, '0');
return '$jj ${_mois[dt.month - 1]} ${dt.year}';
}
final d = card.dateDebut;
final f = card.dateFin;
if (d == null && f == null) return 'pour une période non précisée';
if (d != null && (f == null || d == f)) return 'du ${fmtLong(d)}';
return 'du ${fmtLong(d)} au ${fmtLong(f)}';
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Column( return Column(
@@ -404,14 +407,60 @@ class _CartesFeedState extends State<CartesFeed> {
} }
} }
class _BulleTile extends StatelessWidget { /// Encre (titre / date / lien) accordée à la teinte de fond de la bulle.
/// Encre aqua foncé — harmonisée fond aqua / valise (pas le violet). class _Encre {
static const Color _aquaInk = Color(0xFF315F63); final Color ink;
/// Date : même famille, ~12 % plus claire. final Color date;
static const Color _aquaDate = Color(0xFF4A7276); final Color link;
/// Lien agenda : légère dérive bleue. const _Encre(this.ink, this.date, this.link);
static const Color _aquaLink = Color(0xFF2B5868);
/// Aqua → pétrole désaturé ; pêche → brun chaud ; bleu → marine doux ;
/// rose → bois de rose ; lavande → prune.
static _Encre pour(CardColorHorizontal c) {
switch (c) {
case CardColorHorizontal.aqua:
return const _Encre(
Color(0xFF315F63),
Color(0xFF4A7276),
Color(0xFF2B5868),
);
case CardColorHorizontal.peach:
return const _Encre(
Color(0xFF7A4A2E),
Color(0xFF8F6248),
Color(0xFF6E4530),
);
case CardColorHorizontal.blue:
return const _Encre(
Color(0xFF2F4A6E),
Color(0xFF486083),
Color(0xFF2A4670),
);
case CardColorHorizontal.pink:
return const _Encre(
Color(0xFF7A3E52),
Color(0xFF8E5668),
Color(0xFF6E3A50),
);
case CardColorHorizontal.lavender:
return const _Encre(
Color(0xFF5C3D5C),
Color(0xFF70536F),
Color(0xFF553A60),
);
case CardColorHorizontal.red:
case CardColorHorizontal.lime:
case CardColorHorizontal.green:
return const _Encre(
Color(0xFF2F2F2F),
Color(0xFF6B6B6B),
Color(0xFF3A3A3A),
);
}
}
}
class _BulleTile extends StatelessWidget {
final CarteBulle card; final CarteBulle card;
final bool busy; final bool busy;
final VoidCallback onAccept; final VoidCallback onAccept;
@@ -481,12 +530,8 @@ class _BulleTile extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (card.isCongeAm) return _buildConge(context);
return _buildGeneric(context);
}
Widget _buildConge(BuildContext context) {
final periode = _fmtPeriodeUneLigne(); final periode = _fmtPeriodeUneLigne();
final encre = _Encre.pour(card.colorFamily);
return Opacity( return Opacity(
opacity: _attenuee ? 0.55 : 1, opacity: _attenuee ? 0.55 : 1,
@@ -511,7 +556,7 @@ class _BulleTile extends StatelessWidget {
style: GoogleFonts.merienda( style: GoogleFonts.merienda(
fontSize: 18, fontSize: 18,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
color: _aquaInk, color: encre.ink,
), ),
), ),
if (periode.isNotEmpty) ...[ if (periode.isNotEmpty) ...[
@@ -525,6 +570,8 @@ class _BulleTile extends StatelessWidget {
height: 28, height: 28,
fit: BoxFit.contain, fit: BoxFit.contain,
filterQuality: FilterQuality.high, filterQuality: FilterQuality.high,
color: encre.ink,
colorBlendMode: BlendMode.srcIn,
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded( Expanded(
@@ -535,7 +582,7 @@ class _BulleTile extends StatelessWidget {
style: GoogleFonts.merienda( style: GoogleFonts.merienda(
fontSize: 15, fontSize: 15,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: _aquaDate, color: encre.date,
height: 1.25, height: 1.25,
), ),
), ),
@@ -555,7 +602,7 @@ class _BulleTile extends StatelessWidget {
style: GoogleFonts.merriweather( style: GoogleFonts.merriweather(
fontSize: 12, fontSize: 12,
fontStyle: FontStyle.italic, fontStyle: FontStyle.italic,
color: _aquaInk, color: encre.ink,
), ),
), ),
] else if (card.statut == CarteStatut.ouverte && ] else if (card.statut == CarteStatut.ouverte &&
@@ -595,25 +642,51 @@ class _BulleTile extends StatelessWidget {
height: 28, height: 28,
child: CircularProgressIndicator(strokeWidth: 2), child: CircularProgressIndicator(strokeWidth: 2),
) )
: Row( : card.responseMode == CarteResponseMode.ack
mainAxisSize: MainAxisSize.min, ? _PilulePeinteButton(
children: [
_PilulePeinteButton(
asset: asset:
'assets/images/cartes/btn_coche_pilule.png', 'assets/images/cartes/btn_coche_pilule.png',
tooltip: 'Accepter', tooltip: 'Bien reçu',
onTap: onAccept, onTap: onAck,
), )
const SizedBox(width: 10), : Row(
_PilulePeinteButton( mainAxisSize: MainAxisSize.min,
asset: children: [
'assets/images/cartes/btn_croix_pilule.png', _PilulePeinteButton(
tooltip: 'Refuser', asset:
onTap: onRefuse, 'assets/images/cartes/btn_coche_pilule.png',
), tooltip: 'Accepter',
], onTap: onAccept,
), ),
const Spacer(), const SizedBox(width: 10),
_PilulePeinteButton(
asset:
'assets/images/cartes/btn_croix_pilule.png',
tooltip: 'Refuser',
onTap: onRefuse,
),
],
)
else if (card.statut == CarteStatut.traitee ||
card.statut == CarteStatut.refusee)
Expanded(
child: Text(
card.statut == CarteStatut.refusee
? 'Refusé'
: 'Validé',
textAlign: TextAlign.center,
style: GoogleFonts.merienda(
fontSize: 14,
fontWeight: FontWeight.w600,
fontStyle: FontStyle.italic,
color: encre.ink.withValues(alpha: 0.75),
),
),
),
if (card.peutRepondre) const SizedBox(width: 8),
if (!(card.statut == CarteStatut.traitee ||
card.statut == CarteStatut.refusee))
const Spacer(),
InkWell( InkWell(
onTap: () { onTap: () {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
@@ -631,12 +704,15 @@ class _BulleTile extends StatelessWidget {
), ),
child: Text( child: Text(
'Ouvrir dans l’agenda ↗', 'Ouvrir dans l’agenda ↗',
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.right,
style: GoogleFonts.merienda( style: GoogleFonts.merienda(
fontSize: 13, fontSize: 13,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: _aquaLink, color: encre.link,
decoration: TextDecoration.underline, decoration: TextDecoration.underline,
decorationColor: _aquaLink, decorationColor: encre.link,
), ),
), ),
), ),
@@ -807,6 +883,80 @@ class _BulleTile extends StatelessWidget {
} }
} }
/// Modale de refus : le controller vit avec le State (Échap / Annuler OK).
class _RefuseDialog extends StatefulWidget {
final String title;
final String message;
const _RefuseDialog({
required this.title,
required this.message,
});
@override
State<_RefuseDialog> createState() => _RefuseDialogState();
}
class _RefuseDialogState extends State<_RefuseDialog> {
late final TextEditingController _controller;
@override
void initState() {
super.initState();
_controller = TextEditingController();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text(
widget.title,
style: GoogleFonts.merienda(fontWeight: FontWeight.w600),
),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
widget.message,
style: GoogleFonts.merriweather(fontSize: 14, height: 1.4),
),
const SizedBox(height: 12),
TextField(
controller: _controller,
maxLines: 3,
autofocus: true,
decoration: const InputDecoration(
hintText: 'Motif du refus…',
border: OutlineInputBorder(),
),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Annuler'),
),
FilledButton(
onPressed: () {
final text = _controller.text.trim();
if (text.isEmpty) return;
Navigator.pop(context, text);
},
child: const Text('Refuser'),
),
],
);
}
}
/// Bouton pilule peinte (coche / croix intégrées) — carte congés. /// Bouton pilule peinte (coche / croix intégrées) — carte congés.
class _PilulePeinteButton extends StatelessWidget { class _PilulePeinteButton extends StatelessWidget {
final String asset; final String asset;