Files
petitspas/frontend/lib/widgets/quotidien/cartes_feed.dart
T
jmartinandCursor 535af3b146 fix(#173): actions en pilules + modales, fin des libellés À traiter
Arrêt maladie reste visible jusqu’à la date de fin ; accepter /
refuser / ack passent par une confirmation modale.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-01 17:10:41 +02:00

540 lines
15 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/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> {
List<CarteBulle> _items = const [];
bool _loading = true;
String? _error;
String? _busyCardId;
@override
void initState() {
super.initState();
_load();
}
@override
void didUpdateWidget(covariant CartesFeed oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.placementId != widget.placementId) {
_load();
}
}
Future<void> _load() async {
setState(() {
_loading = true;
_error = null;
});
try {
final items = await CardsService.lister(
placementId: widget.placementId,
);
if (!mounted) return;
setState(() {
_items = items;
_loading = false;
});
} 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 = [
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(
'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(
'Confirmez-vous avoir bien reçu cet arrêt maladie '
'(${_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 controller = TextEditingController();
final ok = await showDialog<bool>(
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'),
),
],
),
);
if (ok == true && controller.text.trim().isNotEmpty) {
await _respond(
card,
action: 'refuse',
comment: controller.text.trim(),
);
}
controller.dispose();
}
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)}';
}
@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 RefreshIndicator(
onRefresh: _load,
child: ListView.separated(
padding: const EdgeInsets.only(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),
);
},
),
);
}
}
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,
});
String get _periode {
final d = card.dateDebut;
final f = card.dateFin;
if (d == null && f == null) return '';
if (d != null && f != null && d == f) return _fmt(d);
return '${_fmt(d)} → ${_fmt(f)}';
}
String _fmt(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');
final mm = dt.month.toString().padLeft(2, '0');
return '$jj/$mm/${dt.year}';
}
/// Pas de « À traiter / Traité » : seulement un indice utile (refus / attente créateur).
String? get _indice {
if (card.statut == CarteStatut.refusee) return 'Refusé';
if (card.statut == CarteStatut.ouverte && card.isCreator) {
return 'En attente de réponse';
}
return null;
}
/// 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 indice = _indice;
return Opacity(
opacity: _attenuee ? 0.55 : 1,
child: Carte(
assetPath: card.backgroundAsset,
constraints: const BoxConstraints(minHeight: 96),
padding: const EdgeInsets.fromLTRB(20, 14, 20, 14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Text(
card.titreAffiche,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: GoogleFonts.merienda(
fontSize: 16,
fontWeight: FontWeight.w700,
color: QuotidienTheme.ink,
),
),
),
if (indice != null)
Text(
indice,
style: GoogleFonts.merriweather(
fontSize: 11,
color: QuotidienTheme.muted,
),
),
],
),
if (_periode.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
_periode,
style: GoogleFonts.merriweather(
fontSize: 13,
color: QuotidienTheme.ink,
),
),
],
if (card.motif != null && card.motif!.trim().isNotEmpty) ...[
const SizedBox(height: 2),
Text(
card.motif!,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: GoogleFonts.merriweather(
fontSize: 12,
color: QuotidienTheme.muted,
),
),
],
if (card.statut == CarteStatut.refusee &&
card.lastRefuseComment != null &&
card.lastRefuseComment!.trim().isNotEmpty) ...[
const SizedBox(height: 4),
Text(
'Motif : ${card.lastRefuseComment}',
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: GoogleFonts.merriweather(
fontSize: 12,
fontStyle: FontStyle.italic,
color: QuotidienTheme.ink,
),
),
],
if (card.peutRepondre) ...[
const SizedBox(height: 10),
if (busy)
const Align(
alignment: Alignment.centerRight,
child: SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
),
)
else
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
if (card.responseMode ==
CarteResponseMode.acceptRefuse) ...[
_ActionPill(
asset: QuotidienTheme.pillTurquoiseAsset,
iconAsset: 'assets/images/coche.png',
tooltip: 'Accepter',
onTap: onAccept,
),
const SizedBox(width: 10),
_ActionPill(
asset: QuotidienTheme.pillCorailAsset,
iconAsset: 'assets/images/red_cross2.png',
tooltip: 'Refuser',
onTap: onRefuse,
),
],
if (card.responseMode == CarteResponseMode.ack)
_ActionPill(
asset: QuotidienTheme.pillTurquoiseAsset,
iconAsset: 'assets/images/coche.png',
tooltip: 'Bien reçu',
onTap: onAck,
),
],
),
],
],
),
),
);
}
}
/// 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(20),
child: SizedBox(
width: 52,
height: 32,
child: Stack(
alignment: Alignment.center,
children: [
Positioned.fill(
child: Image.asset(asset, fit: BoxFit.fill),
),
Image.asset(
iconAsset,
height: 18,
width: 18,
fit: BoxFit.contain,
),
],
),
),
),
);
}
}