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 { static const double _fadeExtent = 0.08; List _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; }); } } 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; }); WidgetsBinding.instance.addPostFrameCallback((_) => _syncScrollFades()); } 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 _onAccept(CarteBulle card) async { final ok = await showDialog( 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 _onAck(CarteBulle card) async { final ok = await showDialog( 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 _onRefuse(CarteBulle card) async { final controller = TextEditingController(); final ok = await showDialog( 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 NotificationListener( onNotification: (_) { _syncScrollFades(); return false; }, child: ShaderMask( blendMode: BlendMode.dstIn, shaderCallback: (Rect bounds) { final stops = [ 0.0, _fadeExtent, 1.0 - _fadeExtent, 1.0, ]; if (!_isScrollable) { return const LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ 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: [ 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), ); }, ), ), ), ); } } 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 _fmtLong(String? iso) { if (iso == null || iso.isEmpty) return '…'; final dt = DateTime.tryParse(iso); if (dt == null) return iso; const mois = [ 'janvier', 'février', 'mars', 'avril', 'mai', 'juin', 'juillet', 'août', 'septembre', 'octobre', 'novembre', 'décembre', ]; final jj = dt.day.toString().padLeft(2, '0'); return '$jj ${mois[dt.month - 1]} ${dt.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 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, ), ], ], ), ), ); } } /// Bouton pilule dessiné pleine largeur (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, ), ], ), ), ), ); } }