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';
case 'absence_enfant':
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
default:
return null;
+216 -66
View File
@@ -1,5 +1,6 @@
import 'package:flutter/material.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/services/api/cards_service.dart';
import 'package:p_tits_pas/widgets/quotidien/carte.dart';
@@ -138,7 +139,9 @@ class _CartesFeedState extends State<CartesFeed> {
style: GoogleFonts.merienda(fontWeight: FontWeight.w600),
),
content: Text(
'Confirmez-vous l’acceptation de « ${card.titreAffiche} » '
card.isCongeAm
? 'Confirmez-vous les congés ${_periodeDuAu(card)} ?'
: 'Confirmez-vous l’acceptation de « ${card.titreAffiche} » '
'(${_periodeCourte(card)}) ?',
style: GoogleFonts.merriweather(fontSize: 14, height: 1.4),
),
@@ -168,7 +171,13 @@ class _CartesFeedState extends State<CartesFeed> {
style: GoogleFonts.merienda(fontWeight: FontWeight.w600),
),
content: Text(
'Confirmez-vous avoir bien reçu cet arrêt maladie '
card.isArretMaladie
? '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),
),
@@ -190,59 +199,36 @@ class _CartesFeedState extends State<CartesFeed> {
}
Future<void> _onRefuse(CarteBulle card) async {
final controller = TextEditingController();
final ok = await showDialog<bool>(
final comment = await showDialog<String>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(
'Refuser la demande',
style: GoogleFonts.merienda(fontWeight: FontWeight.w600),
),
content: Column(
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'),
),
],
builder: (ctx) => _RefuseDialog(
title: card.isCongeAm ? 'Refuser les congés ?' : 'Refuser la demande',
message: card.isCongeAm
? 'Indiquez le motif du refus des congés '
'${_periodeDuAu(card)}.'
: 'Indiquez le motif du refus pour « ${card.titreAffiche} ».',
),
);
if (ok == true && controller.text.trim().isNotEmpty) {
await _respond(
card,
action: 'refuse',
comment: controller.text.trim(),
);
if (comment != null && comment.isNotEmpty) {
await _respond(card, action: 'refuse', comment: comment);
}
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) {
final d = card.dateDebut;
final f = card.dateFin;
@@ -258,6 +244,23 @@ class _CartesFeedState extends State<CartesFeed> {
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
Widget build(BuildContext context) {
return Column(
@@ -404,14 +407,60 @@ class _CartesFeedState extends State<CartesFeed> {
}
}
class _BulleTile extends StatelessWidget {
/// Encre aqua foncé — harmonisée fond aqua / valise (pas le violet).
static const Color _aquaInk = Color(0xFF315F63);
/// Date : même famille, ~12 % plus claire.
static const Color _aquaDate = Color(0xFF4A7276);
/// Lien agenda : légère dérive bleue.
static const Color _aquaLink = Color(0xFF2B5868);
/// Encre (titre / date / lien) accordée à la teinte de fond de la bulle.
class _Encre {
final Color ink;
final Color date;
final Color link;
const _Encre(this.ink, this.date, this.link);
/// 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 bool busy;
final VoidCallback onAccept;
@@ -481,12 +530,8 @@ class _BulleTile extends StatelessWidget {
@override
Widget build(BuildContext context) {
if (card.isCongeAm) return _buildConge(context);
return _buildGeneric(context);
}
Widget _buildConge(BuildContext context) {
final periode = _fmtPeriodeUneLigne();
final encre = _Encre.pour(card.colorFamily);
return Opacity(
opacity: _attenuee ? 0.55 : 1,
@@ -511,7 +556,7 @@ class _BulleTile extends StatelessWidget {
style: GoogleFonts.merienda(
fontSize: 18,
fontWeight: FontWeight.w700,
color: _aquaInk,
color: encre.ink,
),
),
if (periode.isNotEmpty) ...[
@@ -525,6 +570,8 @@ class _BulleTile extends StatelessWidget {
height: 28,
fit: BoxFit.contain,
filterQuality: FilterQuality.high,
color: encre.ink,
colorBlendMode: BlendMode.srcIn,
),
const SizedBox(width: 8),
Expanded(
@@ -535,7 +582,7 @@ class _BulleTile extends StatelessWidget {
style: GoogleFonts.merienda(
fontSize: 15,
fontWeight: FontWeight.w600,
color: _aquaDate,
color: encre.date,
height: 1.25,
),
),
@@ -555,7 +602,7 @@ class _BulleTile extends StatelessWidget {
style: GoogleFonts.merriweather(
fontSize: 12,
fontStyle: FontStyle.italic,
color: _aquaInk,
color: encre.ink,
),
),
] else if (card.statut == CarteStatut.ouverte &&
@@ -595,6 +642,13 @@ class _BulleTile extends StatelessWidget {
height: 28,
child: CircularProgressIndicator(strokeWidth: 2),
)
: card.responseMode == CarteResponseMode.ack
? _PilulePeinteButton(
asset:
'assets/images/cartes/btn_coche_pilule.png',
tooltip: 'Bien reçu',
onTap: onAck,
)
: Row(
mainAxisSize: MainAxisSize.min,
children: [
@@ -612,7 +666,26 @@ class _BulleTile extends StatelessWidget {
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(
onTap: () {
@@ -631,12 +704,15 @@ class _BulleTile extends StatelessWidget {
),
child: Text(
'Ouvrir dans l’agenda ↗',
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.right,
style: GoogleFonts.merienda(
fontSize: 13,
fontWeight: FontWeight.w600,
color: _aquaLink,
color: encre.link,
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.
class _PilulePeinteButton extends StatelessWidget {
final String asset;