feat(#174): feed bulles AM + libellés statut / tri stable

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 <cursoragent@cursor.com>
This commit is contained in:
2026-10-05 16:16:36 +02:00
co-authored by Cursor
parent 342686f4e0
commit ad48ab28cc
3 changed files with 106 additions and 46 deletions
+69 -38
View File
@@ -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<CartesFeed> {
bool _fadeBottom = false;
CardsStreamClient? _stream;
String? _userId;
@override
void initState() {
super.initState();
_scrollController.addListener(_syncScrollFades);
unawaited(_loadUserId());
_load();
_startStream();
}
Future<void> _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<CartesFeed> {
)..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<CartesFeed> {
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<CartesFeed> {
}
}
/// 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<CarteBulle> _trier(List<CarteBulle> items) {
int rang(CarteStatut s) {
switch (s) {
@@ -152,10 +190,10 @@ class _CartesFeedState extends State<CartesFeed> {
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),