Files
petitspas/frontend/lib/widgets/quotidien/cartes_feed.dart
T
jmartinandCursor a36e9ef176 feat(#175,#176): modale générique Ajouter une carte + CTA pilule
Creation absence (parent) et conge/arret (AM) via une seule modale
Material, catalogue UI extensible, calendrier de periode compact.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-05 22:36:29 +02:00

1195 lines
37 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 'dart:async';
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/services/api/cards_stream_client.dart';
import 'package:p_tits_pas/services/auth_service.dart';
import 'package:p_tits_pas/widgets/quotidien/ajouter_carte_modal.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) — tickets #173 (parent) / #174 (AM).
/// Widget partagé ; les actions dépendent du rôle + payload API.
class CartesFeed extends StatefulWidget {
final String? placementId;
/// Conservé pour override éventuel ; sinon le CTA ouvre [showAjouterCarteModal].
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;
CardsStreamClient? _stream;
String? _userId;
@override
void initState() {
super.initState();
_scrollController.addListener(_syncScrollFades);
unawaited(_loadUserId());
_load();
_startStream();
}
Future<void> _loadUserId() async {
final user = await AuthService.getCurrentUser();
if (!mounted) return;
_userId = user?.id;
}
@override
void dispose() {
_stream?.stop();
_stream = null;
_scrollController.removeListener(_syncScrollFades);
_scrollController.dispose();
super.dispose();
}
@override
void didUpdateWidget(covariant CartesFeed oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.placementId != widget.placementId) {
_load();
}
}
void _startStream() {
_stream?.stop();
_stream = CardsStreamClient(
onEvent: _onStreamEvent,
onReconnectSync: () {
if (mounted) _load();
},
)..start();
}
/// SSE diffuse le DTO du répondant / créateur : recalcule `is_creator` local.
CarteBulle _avecIsCreatorLocal(CarteBulle incoming, {CarteBulle? previous}) {
if (previous != null) {
return incoming.copyWith(isCreator: previous.isCreator);
}
final uid = _userId;
if (uid != null &&
incoming.creePar != null &&
incoming.creePar!.isNotEmpty) {
return incoming.copyWith(isCreator: incoming.creePar == uid);
}
return incoming;
}
void _onStreamEvent(CardsStreamEvent event) {
if (!mounted) return;
if (event.type == 'heartbeat') return;
// `response.added` puis `card.updated` : on ne traite que l’update carte.
if (event.type == 'response.added') return;
if (event.type == 'card.deleted') {
final id = event.cardId;
if (id == null || id.isEmpty) return;
setState(() {
_items = _trier([
for (final c in _items)
if (c.id != id) c,
]);
});
WidgetsBinding.instance.addPostFrameCallback((_) => _syncScrollFades());
return;
}
final raw = event.card;
if (raw == null) return;
final filter = widget.placementId;
final matchesFilter = filter == null ||
filter.isEmpty ||
raw.idPlacement == filter;
setState(() {
if (!matchesFilter) {
_items = _trier([
for (final c in _items)
if (c.id != raw.id) c,
]);
} else {
CarteBulle? previous;
for (final c in _items) {
if (c.id == raw.id) {
previous = c;
break;
}
}
final card = _avecIsCreatorLocal(raw, previous: previous);
if (previous != null) {
_items = _trier([
for (final c in _items) if (c.id == card.id) card else c,
]);
} else {
_items = _trier([..._items, card]);
}
}
});
WidgetsBinding.instance.addPostFrameCallback((_) => _syncScrollFades());
}
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;
});
}
}
/// Tri stable partagé : refusées → encore actives (ouvertes / infos
/// période en cours) → traitées passées, puis `cree_le` DESC + `id`.
static List<CarteBulle> _trier(List<CarteBulle> items) {
int rang(CarteBulle c) {
switch (c.statut) {
case CarteStatut.refusee:
return 0;
case CarteStatut.ouverte:
return 1;
case CarteStatut.traitee:
// Absence info / arrêt encore dans la période : même rang que
// ouverte (sinon elles tombent en bas dès la création).
return c.restePleineDansFile ? 1 : 2;
}
}
final out = List<CarteBulle>.from(items);
out.sort((a, b) {
final byStatut = rang(a).compareTo(rang(b));
if (byStatut != 0) return byStatut;
final ac = a.creeLe?.millisecondsSinceEpoch ?? 0;
final bc = b.creeLe?.millisecondsSinceEpoch ?? 0;
final byCree = bc.compareTo(ac); // DESC
if (byCree != 0) return byCree;
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');
}
}
/// Ack immédiat (arrêt maladie, etc.) — pas de modale : un clic = bien reçu.
Future<void> _onAck(CarteBulle card) => _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 SizedBox(height: 8),
Expanded(child: _buildBody()),
_AjouterCarteCta(onTap: _onAjouterCarte),
],
);
}
Future<void> _onAjouterCarte() async {
if (widget.onAjouterBulle != null) {
widget.onAjouterBulle!();
return;
}
final placementId = widget.placementId;
if (placementId == null || placementId.isEmpty) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Sélectionnez un couple de garde pour ajouter une carte.',
),
),
);
return;
}
final created = await showAjouterCarteModal(
context,
placementId: placementId,
);
if (created != null && mounted) {
// SSE peut précéder ; refresh pour garantir la cohérence locale.
await _load();
}
}
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: 4),
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 / absence info : reste opaque tant que la période n’est pas finie
/// (même si `traitee` — absence = info seule dès la création).
bool get _attenuee {
if (card.restePleineDansFile) return false;
return card.statut == CarteStatut.traitee;
}
/// Statut bas de carte (centré) : Validée / Refusée / En attente / Vu / Bien reçu.
bool get _montreStatutReponse {
if (card.peutRepondre) return false;
if (card.isCongeAm) {
if (card.statut == CarteStatut.ouverte) return card.isCreator;
return card.statut == CarteStatut.traitee ||
card.statut == CarteStatut.refusee;
}
// Arrêt (ack) : AM créateur → « Vu » seulement après ack parent.
// Destinataire → « Bien reçu » une fois traité.
return card.responseMode == CarteResponseMode.ack &&
card.statut == CarteStatut.traitee;
}
String get _libelleStatutReponse {
if (card.responseMode == CarteResponseMode.ack) {
return card.isCreator ? 'Vu' : 'Bien reçu';
}
if (card.isCongeAm && card.statut == CarteStatut.ouverte) {
return 'En attente de validation';
}
// Accordé à « Demande de congés ».
return card.statut == CarteStatut.refusee ? 'Refusée' : 'Validée';
}
@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,
),
),
],
],
),
),
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 (_montreStatutReponse)
Expanded(
child: Text(
_libelleStatutReponse,
textAlign: TextAlign.center,
style: GoogleFonts.merienda(
fontSize: 16,
fontWeight: FontWeight.w600,
fontStyle: FontStyle.italic,
color: encre.ink.withValues(alpha: 0.75),
),
),
),
if (card.peutRepondre) const SizedBox(width: 8),
if (!_montreStatutReponse) 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'),
),
],
);
}
}
/// CTA « Ajouter une carte » — pilule aquarelle en bas du feed (#175).
class _AjouterCarteCta extends StatelessWidget {
final VoidCallback onTap;
const _AjouterCarteCta({required this.onTap});
static const _asset =
'assets/images/cartes/btn_ajouter_carte_pilule.png';
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(4, 12, 4, 14),
child: Center(
child: Tooltip(
message: 'Ajouter une carte',
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(28),
child: Semantics(
button: true,
label: 'Ajouter une carte',
child: Image.asset(
_asset,
height: 48,
fit: BoxFit.contain,
filterQuality: FilterQuality.high,
),
),
),
),
),
),
);
}
}
/// 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,
),
],
),
),
),
);
}
}