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
+26
View File
@@ -80,6 +80,32 @@ class CarteBulle {
this.modifieLe,
});
CarteBulle copyWith({
CarteStatut? statut,
bool? isCreator,
String? lastRefuseComment,
DateTime? modifieLe,
Map<String, dynamic>? 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();
@@ -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<AmDashboardScreen> {
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<AmDashboardScreen> {
}
}
/// Colonne gauche : bandeau couple AM (#170) puis flux de cartes (#174).
class _LeftColumn extends StatelessWidget {
final List<CoupleGarde> couples;
final String? selectedCoupleId;
@@ -145,6 +148,7 @@ class _LeftColumn extends StatelessWidget {
final String? error;
final VoidCallback onRetry;
final ValueChanged<CoupleGarde> 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,
),
),
],
+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),