From ad48ab28cc4c851e42dcf92bff914e4dfa8a41c4 Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Mon, 5 Oct 2026 16:16:36 +0200 Subject: [PATCH] =?UTF-8?q?feat(#174):=20feed=20bulles=20AM=20+=20libell?= =?UTF-8?q?=C3=A9s=20statut=20/=20tri=20stable?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Branche CartesFeed sur le TdB AM ; Vu / En attente de validation ; préserve is_creator SSE et tri par cree_le pour limiter les écarts. Co-authored-by: Cursor --- frontend/lib/models/carte_bulle.dart | 26 +++++ .../lib/screens/am/am_dashboard_screen.dart | 19 ++-- .../lib/widgets/quotidien/cartes_feed.dart | 107 +++++++++++------- 3 files changed, 106 insertions(+), 46 deletions(-) diff --git a/frontend/lib/models/carte_bulle.dart b/frontend/lib/models/carte_bulle.dart index fbc3b94..d59e1a9 100644 --- a/frontend/lib/models/carte_bulle.dart +++ b/frontend/lib/models/carte_bulle.dart @@ -80,6 +80,32 @@ class CarteBulle { this.modifieLe, }); + CarteBulle copyWith({ + CarteStatut? statut, + bool? isCreator, + String? lastRefuseComment, + DateTime? modifieLe, + Map? payload, + }) { + return CarteBulle( + id: id, + typeCode: typeCode, + titre: titre, + idPlacement: idPlacement, + idAbsence: idAbsence, + operation: operation, + statut: statut ?? this.statut, + payload: payload ?? this.payload, + purgeAt: purgeAt, + creePar: creePar, + isCreator: isCreator ?? this.isCreator, + responseMode: responseMode, + lastRefuseComment: lastRefuseComment ?? this.lastRefuseComment, + creeLe: creeLe, + modifieLe: modifieLe ?? this.modifieLe, + ); + } + String? get dateDebut => payload['date_debut']?.toString(); String? get dateFin => payload['date_fin']?.toString(); String? get motif => payload['motif']?.toString(); diff --git a/frontend/lib/screens/am/am_dashboard_screen.dart b/frontend/lib/screens/am/am_dashboard_screen.dart index 959f656..1dc0ed2 100644 --- a/frontend/lib/screens/am/am_dashboard_screen.dart +++ b/frontend/lib/screens/am/am_dashboard_screen.dart @@ -3,14 +3,15 @@ import 'package:p_tits_pas/models/couple_garde.dart'; import 'package:p_tits_pas/models/user.dart'; import 'package:p_tits_pas/services/auth_service.dart'; import 'package:p_tits_pas/services/couple_garde_service.dart'; +import 'package:p_tits_pas/widgets/quotidien/cartes_feed.dart'; import 'package:p_tits_pas/widgets/quotidien/couple_selector_bandeau.dart'; import 'package:p_tits_pas/widgets/quotidien/quotidien_shell.dart'; import 'package:p_tits_pas/widgets/quotidien/quotidien_theme.dart'; import 'package:p_tits_pas/screens/home/parent_screen/agenda_absences_stub.dart'; /// Dashboard assistante maternelle – coquille 3 colonnes quotidien (#169). -/// Colonne gauche : sélecteur de couple enfant–parent(s) (#170). -/// Métier cartes / blog / messagerie : tickets C/D/E. +/// Colonne gauche : sélecteur couple (#170) + feed bulles (#174). +/// Création « Ajouter une bulle » (congé / arrêt) : ticket #176. class AmDashboardScreen extends StatefulWidget { const AmDashboardScreen({super.key}); @@ -116,6 +117,7 @@ class _AmDashboardScreenState extends State { error: _couplesError, onRetry: _loadCouples, onCoupleSelected: _selectCouple, + onAjouterBulle: () => _soon('Ajouter une bulle (#176)'), ), centerColumn: const QuotidienColumnPlaceholder( title: 'Blog', @@ -138,6 +140,7 @@ class _AmDashboardScreenState extends State { } } +/// Colonne gauche : bandeau couple AM (#170) puis flux de cartes (#174). class _LeftColumn extends StatelessWidget { final List couples; final String? selectedCoupleId; @@ -145,6 +148,7 @@ class _LeftColumn extends StatelessWidget { final String? error; final VoidCallback onRetry; final ValueChanged onCoupleSelected; + final VoidCallback onAjouterBulle; const _LeftColumn({ required this.couples, @@ -153,6 +157,7 @@ class _LeftColumn extends StatelessWidget { required this.error, required this.onRetry, required this.onCoupleSelected, + required this.onAjouterBulle, }); @override @@ -172,12 +177,10 @@ class _LeftColumn extends StatelessWidget { onCoupleSelected: onCoupleSelected, ), const SizedBox(height: 14), - const Expanded( - child: QuotidienColumnPlaceholder( - title: 'Cartes', - subtitle: - 'Absences, congés AM, sorties à valider\n(à brancher — ticket #174).', - icon: Icons.style_outlined, + Expanded( + child: CartesFeed( + placementId: selectedCoupleId, + onAjouterBulle: onAjouterBulle, ), ), ], diff --git a/frontend/lib/widgets/quotidien/cartes_feed.dart b/frontend/lib/widgets/quotidien/cartes_feed.dart index 279b021..f71dcc2 100644 --- a/frontend/lib/widgets/quotidien/cartes_feed.dart +++ b/frontend/lib/widgets/quotidien/cartes_feed.dart @@ -1,14 +1,17 @@ +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/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. +/// 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; final VoidCallback? onAjouterBulle; @@ -37,15 +40,23 @@ class _CartesFeedState extends State { bool _fadeBottom = false; CardsStreamClient? _stream; + String? _userId; @override void initState() { super.initState(); _scrollController.addListener(_syncScrollFades); + unawaited(_loadUserId()); _load(); _startStream(); } + Future _loadUserId() async { + final user = await AuthService.getCurrentUser(); + if (!mounted) return; + _userId = user?.id; + } + @override void dispose() { _stream?.stop(); @@ -73,9 +84,25 @@ class _CartesFeedState extends State { )..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; @@ -90,26 +117,36 @@ class _CartesFeedState extends State { return; } - final card = event.card; - if (card == null) return; + final raw = event.card; + if (raw == null) return; final filter = widget.placementId; final matchesFilter = filter == null || filter.isEmpty || - card.idPlacement == filter; + raw.idPlacement == filter; setState(() { if (!matchesFilter) { _items = _trier([ for (final c in _items) - if (c.id != card.id) c, - ]); - } else if (_items.any((c) => c.id == card.id)) { - _items = _trier([ - for (final c in _items) if (c.id == card.id) card else c, + if (c.id != raw.id) c, ]); } else { - _items = _trier([..._items, card]); + 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()); @@ -134,8 +171,9 @@ class _CartesFeedState extends State { } } - /// Aligné sur le tri API : refusées → ouvertes → traitées, puis - /// `modifie_le` DESC, `id` en départage (ordre stable entre parents). + /// Tri stable partagé : refusées → ouvertes → traitées, puis + /// `cree_le` DESC + `id` (pas `modifie_le` : une validation ne + /// re-mélange plus la file différemment selon les clients). static List _trier(List items) { int rang(CarteStatut s) { switch (s) { @@ -152,10 +190,10 @@ class _CartesFeedState extends State { out.sort((a, b) { final byStatut = rang(a.statut).compareTo(rang(b.statut)); if (byStatut != 0) return byStatut; - final am = a.modifieLe?.millisecondsSinceEpoch ?? 0; - final bm = b.modifieLe?.millisecondsSinceEpoch ?? 0; - final byModif = bm.compareTo(am); // DESC - if (byModif != 0) return byModif; + 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; @@ -580,20 +618,27 @@ class _BulleTile extends StatelessWidget { return card.statut == CarteStatut.traitee; } - /// Congés (Validée / Refusée) ou carte ack (Bien reçu). + /// 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 && - (card.statut == CarteStatut.traitee || - card.statut == CarteStatut.refusee)) { - return true; + 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 'Bien reçu'; + 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'; } @@ -675,20 +720,6 @@ class _BulleTile extends StatelessWidget { color: encre.ink, ), ), - ] else if (card.statut == CarteStatut.ouverte && - card.isCreator && - (card.isCongeAm || - card.responseMode == CarteResponseMode.ack)) ...[ - const SizedBox(height: 8), - Text( - card.responseMode == CarteResponseMode.ack - ? 'En attente d’acquittement' - : 'En attente de réponse', - style: GoogleFonts.merriweather( - fontSize: 12, - color: QuotidienTheme.muted, - ), - ), ], ], ), @@ -747,7 +778,7 @@ class _BulleTile extends StatelessWidget { _libelleStatutReponse, textAlign: TextAlign.center, style: GoogleFonts.merienda( - fontSize: 14, + fontSize: 16, fontWeight: FontWeight.w600, fontStyle: FontStyle.italic, color: encre.ink.withValues(alpha: 0.75),