Files
petitspas/frontend/lib/widgets/quotidien/cartes_feed.dart
T
jmartinandCursor 5a73da898b fix(#173): tri stable des bulles entre parents
Même ordre API (statut puis modifie_le) avec départage par id,
appliqué au chargement et après chaque réponse.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-05 12:21:25 +02:00

1063 lines
34 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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';
import 'package:p_tits_pas/widgets/quotidien/quotidien_theme.dart';
/// Feed de bulles (file d'attention) — ticket #173.
/// Widget partagé parent / AM ; les actions dépendent du rôle + payload API.
class CartesFeed extends StatefulWidget {
final String? placementId;
final VoidCallback? onAjouterBulle;
const CartesFeed({
super.key,
this.placementId,
this.onAjouterBulle,
});
@override
State<CartesFeed> createState() => _CartesFeedState();
}
class _CartesFeedState extends State<CartesFeed> {
static const double _fadeExtent = 0.08;
List<CarteBulle> _items = const [];
bool _loading = true;
String? _error;
String? _busyCardId;
final ScrollController _scrollController = ScrollController();
bool _isScrollable = false;
bool _fadeTop = false;
bool _fadeBottom = false;
@override
void initState() {
super.initState();
_scrollController.addListener(_syncScrollFades);
_load();
}
@override
void dispose() {
_scrollController.removeListener(_syncScrollFades);
_scrollController.dispose();
super.dispose();
}
@override
void didUpdateWidget(covariant CartesFeed oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.placementId != widget.placementId) {
_load();
}
}
void _syncScrollFades() {
if (!_scrollController.hasClients) return;
final position = _scrollController.position;
final scrollable = position.maxScrollExtent > 0.0;
final threshold = position.viewportDimension * _fadeExtent / 2;
final fadeTop = scrollable && position.pixels > threshold;
final fadeBottom = scrollable &&
position.pixels < (position.maxScrollExtent - threshold);
if (scrollable != _isScrollable ||
fadeTop != _fadeTop ||
fadeBottom != _fadeBottom) {
setState(() {
_isScrollable = scrollable;
_fadeTop = fadeTop;
_fadeBottom = fadeBottom;
});
}
}
/// Aligné sur le tri API : refusées → ouvertes → traitées, puis
/// `modifie_le` DESC, `id` en départage (ordre stable entre parents).
static List<CarteBulle> _trier(List<CarteBulle> items) {
int rang(CarteStatut s) {
switch (s) {
case CarteStatut.refusee:
return 0;
case CarteStatut.ouverte:
return 1;
case CarteStatut.traitee:
return 2;
}
}
final out = List<CarteBulle>.from(items);
out.sort((a, b) {
final byStatut = rang(a.statut).compareTo(rang(b.statut));
if (byStatut != 0) return byStatut;
final am = a.modifieLe?.millisecondsSinceEpoch ?? 0;
final bm = b.modifieLe?.millisecondsSinceEpoch ?? 0;
final byModif = bm.compareTo(am); // DESC
if (byModif != 0) return byModif;
return a.id.compareTo(b.id);
});
return out;
}
Future<void> _load() async {
setState(() {
_loading = true;
_error = null;
});
try {
final items = await CardsService.lister(
placementId: widget.placementId,
);
if (!mounted) return;
setState(() {
_items = _trier(items);
_loading = false;
});
WidgetsBinding.instance.addPostFrameCallback((_) => _syncScrollFades());
} catch (e) {
if (!mounted) return;
setState(() {
_error = e.toString().replaceFirst('Exception: ', '');
_loading = false;
});
}
}
Future<void> _respond(
CarteBulle card, {
required String action,
String? comment,
}) async {
setState(() => _busyCardId = card.id);
try {
final updated = await CardsService.repondre(
cardId: card.id,
action: action,
comment: comment,
);
if (!mounted) return;
setState(() {
_items = _trier([
for (final c in _items) if (c.id == updated.id) updated else c,
]);
_busyCardId = null;
});
} catch (e) {
if (!mounted) return;
setState(() => _busyCardId = null);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(e.toString().replaceFirst('Exception: ', '')),
),
);
}
}
Future<void> _onAccept(CarteBulle card) async {
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(
'Accepter la demande ?',
style: GoogleFonts.merienda(fontWeight: FontWeight.w600),
),
content: Text(
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),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Annuler'),
),
FilledButton(
onPressed: () => Navigator.pop(ctx, true),
child: const Text('Confirmer'),
),
],
),
);
if (ok == true) {
await _respond(card, action: 'accept');
}
}
Future<void> _onAck(CarteBulle card) async {
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(
'Prise en compte',
style: GoogleFonts.merienda(fontWeight: FontWeight.w600),
),
content: Text(
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),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Annuler'),
),
FilledButton(
onPressed: () => Navigator.pop(ctx, true),
child: const Text('Bien reçu'),
),
],
),
);
if (ok == true) {
await _respond(card, action: 'ack');
}
}
Future<void> _onRefuse(CarteBulle card) async {
final comment = await showDialog<String>(
context: context,
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 (comment != null && comment.isNotEmpty) {
await _respond(card, action: 'refuse', comment: comment);
}
}
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;
String fmt(String? iso) {
if (iso == null || iso.isEmpty) return '…';
final dt = DateTime.tryParse(iso);
if (dt == null) return iso;
return '${dt.day.toString().padLeft(2, '0')}/'
'${dt.month.toString().padLeft(2, '0')}/${dt.year}';
}
if (d == null && f == null) return 'période non précisée';
if (d != null && f != null && d == f) return fmt(d);
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(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
const Icon(
Icons.style_outlined,
color: QuotidienTheme.lavender,
size: 22,
),
const SizedBox(width: 8),
Text(
'Cartes',
style: GoogleFonts.merienda(
fontSize: 18,
fontWeight: FontWeight.w600,
color: QuotidienTheme.ink,
),
),
const Spacer(),
TextButton.icon(
onPressed: widget.onAjouterBulle,
icon: const Icon(Icons.add, size: 18),
label: Text(
'Ajouter',
style: GoogleFonts.merriweather(fontSize: 13),
),
),
],
),
const SizedBox(height: 8),
Expanded(child: _buildBody()),
],
);
}
Widget _buildBody() {
if (_loading) {
return const Center(
child: SizedBox(
width: 28,
height: 28,
child: CircularProgressIndicator(strokeWidth: 2),
),
);
}
if (_error != null) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
_error!,
textAlign: TextAlign.center,
style: GoogleFonts.merriweather(
fontSize: 13,
color: QuotidienTheme.ink,
),
),
const SizedBox(height: 8),
TextButton(onPressed: _load, child: const Text('Réessayer')),
],
),
);
}
if (_items.isEmpty) {
return Center(
child: Text(
'Aucune bulle pour le moment.\nLa file d’attention est vide — c’est normal.',
textAlign: TextAlign.center,
style: GoogleFonts.merriweather(
fontSize: 13,
color: QuotidienTheme.muted,
height: 1.4,
),
),
);
}
return NotificationListener<ScrollMetricsNotification>(
onNotification: (_) {
_syncScrollFades();
return false;
},
child: ShaderMask(
blendMode: BlendMode.dstIn,
shaderCallback: (Rect bounds) {
final stops = <double>[
0.0,
_fadeExtent,
1.0 - _fadeExtent,
1.0,
];
if (!_isScrollable) {
return const LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: <Color>[
Colors.black,
Colors.black,
Colors.black,
Colors.black,
],
stops: [0.0, 0.08, 0.92, 1.0],
).createShader(bounds);
}
final topMask = _fadeTop ? Colors.transparent : Colors.black;
final bottomMask = _fadeBottom ? Colors.transparent : Colors.black;
return LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: <Color>[
topMask,
Colors.black,
Colors.black,
bottomMask,
],
stops: stops,
).createShader(bounds);
},
child: RefreshIndicator(
onRefresh: _load,
child: ListView.separated(
controller: _scrollController,
padding: const EdgeInsets.only(top: 4, bottom: 8),
itemCount: _items.length,
separatorBuilder: (_, __) => const SizedBox(height: 10),
itemBuilder: (context, index) {
final card = _items[index];
return _BulleTile(
card: card,
busy: _busyCardId == card.id,
onAccept: () => _onAccept(card),
onAck: () => _onAck(card),
onRefuse: () => _onRefuse(card),
);
},
),
),
),
);
}
}
/// 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;
final VoidCallback onAck;
final VoidCallback onRefuse;
const _BulleTile({
required this.card,
required this.busy,
required this.onAccept,
required this.onAck,
required this.onRefuse,
});
static const _mois = [
'janvier',
'février',
'mars',
'avril',
'mai',
'juin',
'juillet',
'août',
'septembre',
'octobre',
'novembre',
'décembre',
];
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}';
}
/// Une ligne type maquette : « Du 04 au 06 novembre 2026 ».
String _fmtPeriodeUneLigne() {
final d1 = DateTime.tryParse(card.dateDebut ?? '');
final d2 = DateTime.tryParse(card.dateFin ?? '');
if (d1 == null && d2 == null) return '';
if (d1 != null && d2 == null) return 'Le ${_fmtLong(card.dateDebut)}';
if (d1 == null && d2 != null) return 'Au ${_fmtLong(card.dateFin)}';
final a = d1!;
final b = d2!;
final j1 = a.day.toString().padLeft(2, '0');
final j2 = b.day.toString().padLeft(2, '0');
if (a.year == b.year && a.month == b.month && a.day == b.day) {
return 'Le $j1 ${_mois[a.month - 1]} ${a.year}';
}
if (a.year == b.year && a.month == b.month) {
return 'Du $j1 au $j2 ${_mois[a.month - 1]} ${a.year}';
}
if (a.year == b.year) {
return 'Du $j1 ${_mois[a.month - 1]} au $j2 ${_mois[b.month - 1]} ${a.year}';
}
return 'Du $j1 ${_mois[a.month - 1]} ${a.year} '
'au $j2 ${_mois[b.month - 1]} ${b.year}';
}
/// Arrêt maladie : reste opaque tant que la période n’est pas finie.
bool get _attenuee {
if (card.isArretMaladie && card.periodeEncoreActive) return false;
return card.statut == CarteStatut.traitee;
}
@override
Widget build(BuildContext context) {
final periode = _fmtPeriodeUneLigne();
final encre = _Encre.pour(card.colorFamily);
return Opacity(
opacity: _attenuee ? 0.55 : 1,
child: Carte(
assetPath: card.backgroundAsset,
constraints: const BoxConstraints(minHeight: 140),
padding: const EdgeInsets.fromLTRB(22, 18, 18, 14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
card.titreAffiche,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: GoogleFonts.merienda(
fontSize: 18,
fontWeight: FontWeight.w700,
color: encre.ink,
),
),
if (periode.isNotEmpty) ...[
const SizedBox(height: 10),
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Image.asset(
'assets/images/cartes/icon_calendrier_crayon.png',
width: 28,
height: 28,
fit: BoxFit.contain,
filterQuality: FilterQuality.high,
color: encre.ink,
colorBlendMode: BlendMode.srcIn,
),
const SizedBox(width: 8),
Expanded(
child: Text(
periode,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: GoogleFonts.merienda(
fontSize: 15,
fontWeight: FontWeight.w600,
color: encre.date,
height: 1.25,
),
),
),
],
),
],
if (card.isCongeAm &&
card.statut == CarteStatut.refusee &&
card.motifRefus != null) ...[
const SizedBox(height: 8),
Text(
'Motif : ${card.motifRefus}',
maxLines: 4,
overflow: TextOverflow.ellipsis,
style: GoogleFonts.merriweather(
fontSize: 13,
fontStyle: FontStyle.italic,
height: 1.3,
color: encre.ink,
),
),
] else if (card.isCongeAm &&
card.statut == CarteStatut.ouverte &&
card.isCreator) ...[
const SizedBox(height: 8),
Text(
'En attente de réponse',
style: GoogleFonts.merriweather(
fontSize: 12,
color: QuotidienTheme.muted,
),
),
],
],
),
),
if (card.iconAsset != null) ...[
const SizedBox(width: 8),
Image.asset(
card.iconAsset!,
width: 72,
height: 72,
fit: BoxFit.contain,
filterQuality: FilterQuality.high,
),
],
],
),
const SizedBox(height: 10),
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
if (card.peutRepondre)
busy
? const SizedBox(
width: 28,
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: [
_PilulePeinteButton(
asset:
'assets/images/cartes/btn_coche_pilule.png',
tooltip: 'Accepter',
onTap: onAccept,
),
const SizedBox(width: 10),
_PilulePeinteButton(
asset:
'assets/images/cartes/btn_croix_pilule.png',
tooltip: 'Refuser',
onTap: onRefuse,
),
],
)
else if (card.isCongeAm &&
(card.statut == CarteStatut.traitee ||
card.statut == CarteStatut.refusee))
Expanded(
child: Text(
// Accordé à « Demande de congés ».
card.statut == CarteStatut.refusee
? 'Refusée'
: 'Validée',
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.isCongeAm &&
(card.statut == CarteStatut.traitee ||
card.statut == CarteStatut.refusee)))
const Spacer(),
InkWell(
onTap: () {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Agenda — à venir.'),
duration: Duration(seconds: 2),
),
);
},
borderRadius: BorderRadius.circular(6),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 4,
vertical: 4,
),
child: Text(
'Ouvrir dans l’agenda ↗',
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.right,
style: GoogleFonts.merienda(
fontSize: 13,
fontWeight: FontWeight.w600,
color: encre.link,
decoration: TextDecoration.underline,
decorationColor: encre.link,
),
),
),
),
],
),
],
),
),
);
}
Widget _buildGeneric(BuildContext context) {
final memeJour = card.dateDebut != null &&
card.dateFin != null &&
card.dateDebut == card.dateFin;
return Opacity(
opacity: _attenuee ? 0.55 : 1,
child: Carte(
assetPath: card.backgroundAsset,
constraints: const BoxConstraints(minHeight: 120),
padding: const EdgeInsets.fromLTRB(22, 16, 22, 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
card.titreAffiche,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: GoogleFonts.merienda(
fontSize: 18,
fontWeight: FontWeight.w700,
color: QuotidienTheme.ink,
),
),
if (card.dateDebut != null || card.dateFin != null) ...[
const SizedBox(height: 10),
Padding(
padding: const EdgeInsets.only(left: 12),
child: memeJour
? Text(
'Le ${_fmtLong(card.dateDebut)}',
style: GoogleFonts.merienda(
fontSize: 17,
fontWeight: FontWeight.w600,
color: QuotidienTheme.ink,
height: 1.35,
),
)
: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Du ${_fmtLong(card.dateDebut)}',
style: GoogleFonts.merienda(
fontSize: 17,
fontWeight: FontWeight.w600,
color: QuotidienTheme.ink,
height: 1.35,
),
),
Text(
'Au ${_fmtLong(card.dateFin)}',
style: GoogleFonts.merienda(
fontSize: 17,
fontWeight: FontWeight.w600,
color: QuotidienTheme.ink,
height: 1.35,
),
),
],
),
),
],
],
),
),
if (card.iconAsset != null) ...[
const SizedBox(width: 10),
Image.asset(
card.iconAsset!,
width: 80,
height: 80,
fit: BoxFit.contain,
filterQuality: FilterQuality.high,
),
],
],
),
if (card.statut == CarteStatut.refusee) ...[
const SizedBox(height: 6),
Text(
card.lastRefuseComment != null &&
card.lastRefuseComment!.trim().isNotEmpty
? 'Refusé — ${card.lastRefuseComment}'
: 'Refusé',
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: GoogleFonts.merriweather(
fontSize: 12,
fontStyle: FontStyle.italic,
color: QuotidienTheme.ink,
),
),
] else if (card.statut == CarteStatut.ouverte &&
card.isCreator) ...[
const SizedBox(height: 6),
Text(
'En attente de réponse',
style: GoogleFonts.merriweather(
fontSize: 12,
color: QuotidienTheme.muted,
),
),
],
if (card.peutRepondre) ...[
const SizedBox(height: 14),
if (busy)
const Center(
child: SizedBox(
width: 22,
height: 22,
child: CircularProgressIndicator(strokeWidth: 2),
),
)
else if (card.responseMode == CarteResponseMode.acceptRefuse)
Row(
children: [
Expanded(
child: _ActionPill(
asset: QuotidienTheme.pillTurquoiseAsset,
iconAsset: 'assets/images/coche.png',
tooltip: 'Accepter',
onTap: onAccept,
),
),
const SizedBox(width: 12),
Expanded(
child: _ActionPill(
asset: QuotidienTheme.pillCorailAsset,
iconAsset: 'assets/images/red_cross2.png',
tooltip: 'Refuser',
onTap: onRefuse,
),
),
],
)
else if (card.responseMode == CarteResponseMode.ack)
_ActionPill(
asset: QuotidienTheme.pillTurquoiseAsset,
iconAsset: 'assets/images/coche.png',
tooltip: 'Bien reçu',
onTap: onAck,
),
],
],
),
),
);
}
}
/// 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;
final String tooltip;
final VoidCallback onTap;
const _PilulePeinteButton({
required this.asset,
required this.tooltip,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return Tooltip(
message: tooltip,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(20),
child: Image.asset(
asset,
height: 36,
fit: BoxFit.contain,
filterQuality: FilterQuality.high,
),
),
);
}
}
/// Bouton pilule dessiné (fond pastel + coche / croix).
class _ActionPill extends StatelessWidget {
final String asset;
final String iconAsset;
final String tooltip;
final VoidCallback onTap;
const _ActionPill({
required this.asset,
required this.iconAsset,
required this.tooltip,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return Tooltip(
message: tooltip,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(24),
child: SizedBox(
height: 40,
child: Stack(
alignment: Alignment.center,
children: [
Positioned.fill(
child: Image.asset(asset, fit: BoxFit.fill),
),
Image.asset(
iconAsset,
height: 22,
width: 22,
fit: BoxFit.contain,
),
],
),
),
),
);
}
}