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 createState() => _CartesFeedState(); } class _CartesFeedState extends State { List _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 _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 _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 _onRefuse(CarteBulle card) async { final controller = TextEditingController(); final ok = await showDialog( 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: Carte( assetPath: card.backgroundAsset, constraints: const BoxConstraints(minHeight: 96), padding: const EdgeInsets.fromLTRB(20, 14, 20, 14), 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, ), ), ), ), ); } }