Files
petitspas/frontend/lib/widgets/quotidien/cartes_feed.dart
T
jmartinandCursor d53682110d feat(#173): feed bulles parent branché sur GET /cards
Affiche la file d’attention pastel (assets card_*_h) filtrée
par couple courant, avec accept / refus motivé / ack.

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

439 lines
12 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/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> _onRefuse(CarteBulle card) async {
final controller = TextEditingController();
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(
'Motiver le refus',
style: GoogleFonts.merienda(fontWeight: FontWeight.w600),
),
content: TextField(
controller: controller,
maxLines: 3,
autofocus: true,
decoration: const InputDecoration(
hintText: 'Expliquez le 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();
}
@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: () => _respond(card, action: 'accept'),
onAck: () => _respond(card, action: 'ack'),
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}';
}
String get _statutLabel {
switch (card.statut) {
case CarteStatut.ouverte:
return card.isCreator ? 'En attente' : 'À traiter';
case CarteStatut.refusee:
return 'Refusé';
case CarteStatut.traitee:
return 'Traité';
}
}
@override
Widget build(BuildContext context) {
final treated = card.statut == CarteStatut.traitee;
return Opacity(
opacity: treated ? 0.55 : 1,
child: Container(
constraints: const BoxConstraints(minHeight: 88),
decoration: BoxDecoration(
image: DecorationImage(
image: AssetImage(card.backgroundAsset),
fit: BoxFit.fill,
),
),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Expanded(
child: Text(
card.titre,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: GoogleFonts.merienda(
fontSize: 16,
fontWeight: FontWeight.w700,
color: QuotidienTheme.ink,
),
),
),
Text(
_statutLabel,
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 refus : ${card.lastRefuseComment}',
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: GoogleFonts.merriweather(
fontSize: 12,
fontStyle: FontStyle.italic,
color: QuotidienTheme.ink,
),
),
],
if (card.peutRepondre) ...[
const SizedBox(height: 8),
if (busy)
const Align(
alignment: Alignment.centerLeft,
child: SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
),
)
else
Wrap(
spacing: 8,
runSpacing: 4,
children: [
if (card.responseMode == CarteResponseMode.acceptRefuse) ...[
_ActionChip(
label: 'Accepter',
onTap: onAccept,
),
_ActionChip(
label: 'Refuser',
onTap: onRefuse,
destructive: true,
),
],
if (card.responseMode == CarteResponseMode.ack)
_ActionChip(
label: 'Bien reçu',
onTap: onAck,
),
],
),
],
],
),
),
);
}
}
class _ActionChip extends StatelessWidget {
final String label;
final VoidCallback onTap;
final bool destructive;
const _ActionChip({
required this.label,
required this.onTap,
this.destructive = false,
});
@override
Widget build(BuildContext context) {
return Material(
color: Colors.white.withValues(alpha: 0.55),
borderRadius: BorderRadius.circular(16),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(16),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
child: Text(
label,
style: GoogleFonts.merriweather(
fontSize: 12,
fontWeight: FontWeight.w600,
color: destructive
? const Color(0xFFB00020)
: QuotidienTheme.ink,
),
),
),
),
);
}
}