Merge branch 'feature/174-feed-bulles-am' into develop

#174 feed bulles AM (widget partagé + polish UX).
This commit is contained in:
2026-10-05 16:16:41 +02:00
3 changed files with 106 additions and 46 deletions
+26
View File
@@ -80,6 +80,32 @@ class CarteBulle {
this.modifieLe, 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 dateDebut => payload['date_debut']?.toString();
String? get dateFin => payload['date_fin']?.toString(); String? get dateFin => payload['date_fin']?.toString();
String? get motif => payload['motif']?.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/models/user.dart';
import 'package:p_tits_pas/services/auth_service.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/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/couple_selector_bandeau.dart';
import 'package:p_tits_pas/widgets/quotidien/quotidien_shell.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/widgets/quotidien/quotidien_theme.dart';
import 'package:p_tits_pas/screens/home/parent_screen/agenda_absences_stub.dart'; import 'package:p_tits_pas/screens/home/parent_screen/agenda_absences_stub.dart';
/// Dashboard assistante maternelle – coquille 3 colonnes quotidien (#169). /// Dashboard assistante maternelle – coquille 3 colonnes quotidien (#169).
/// Colonne gauche : sélecteur de couple enfant–parent(s) (#170). /// Colonne gauche : sélecteur couple (#170) + feed bulles (#174).
/// Métier cartes / blog / messagerie : tickets C/D/E. /// Création « Ajouter une bulle » (congé / arrêt) : ticket #176.
class AmDashboardScreen extends StatefulWidget { class AmDashboardScreen extends StatefulWidget {
const AmDashboardScreen({super.key}); const AmDashboardScreen({super.key});
@@ -116,6 +117,7 @@ class _AmDashboardScreenState extends State<AmDashboardScreen> {
error: _couplesError, error: _couplesError,
onRetry: _loadCouples, onRetry: _loadCouples,
onCoupleSelected: _selectCouple, onCoupleSelected: _selectCouple,
onAjouterBulle: () => _soon('Ajouter une bulle (#176)'),
), ),
centerColumn: const QuotidienColumnPlaceholder( centerColumn: const QuotidienColumnPlaceholder(
title: 'Blog', 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 { class _LeftColumn extends StatelessWidget {
final List<CoupleGarde> couples; final List<CoupleGarde> couples;
final String? selectedCoupleId; final String? selectedCoupleId;
@@ -145,6 +148,7 @@ class _LeftColumn extends StatelessWidget {
final String? error; final String? error;
final VoidCallback onRetry; final VoidCallback onRetry;
final ValueChanged<CoupleGarde> onCoupleSelected; final ValueChanged<CoupleGarde> onCoupleSelected;
final VoidCallback onAjouterBulle;
const _LeftColumn({ const _LeftColumn({
required this.couples, required this.couples,
@@ -153,6 +157,7 @@ class _LeftColumn extends StatelessWidget {
required this.error, required this.error,
required this.onRetry, required this.onRetry,
required this.onCoupleSelected, required this.onCoupleSelected,
required this.onAjouterBulle,
}); });
@override @override
@@ -172,12 +177,10 @@ class _LeftColumn extends StatelessWidget {
onCoupleSelected: onCoupleSelected, onCoupleSelected: onCoupleSelected,
), ),
const SizedBox(height: 14), const SizedBox(height: 14),
const Expanded( Expanded(
child: QuotidienColumnPlaceholder( child: CartesFeed(
title: 'Cartes', placementId: selectedCoupleId,
subtitle: onAjouterBulle: onAjouterBulle,
'Absences, congés AM, sorties à valider\n(à brancher — ticket #174).',
icon: Icons.style_outlined,
), ),
), ),
], ],
+69 -38
View File
@@ -1,14 +1,17 @@
import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart'; import 'package:google_fonts/google_fonts.dart';
import 'package:p_tits_pas/models/card_assets.dart'; import 'package:p_tits_pas/models/card_assets.dart';
import 'package:p_tits_pas/models/carte_bulle.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_service.dart';
import 'package:p_tits_pas/services/api/cards_stream_client.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/carte.dart';
import 'package:p_tits_pas/widgets/quotidien/quotidien_theme.dart'; import 'package:p_tits_pas/widgets/quotidien/quotidien_theme.dart';
/// Feed de bulles (file d'attention) — ticket #173. /// Feed de bulles (file d'attention) — tickets #173 (parent) / #174 (AM).
/// Widget partagé parent / AM ; les actions dépendent du rôle + payload API. /// Widget partagé ; les actions dépendent du rôle + payload API.
class CartesFeed extends StatefulWidget { class CartesFeed extends StatefulWidget {
final String? placementId; final String? placementId;
final VoidCallback? onAjouterBulle; final VoidCallback? onAjouterBulle;
@@ -37,15 +40,23 @@ class _CartesFeedState extends State<CartesFeed> {
bool _fadeBottom = false; bool _fadeBottom = false;
CardsStreamClient? _stream; CardsStreamClient? _stream;
String? _userId;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_scrollController.addListener(_syncScrollFades); _scrollController.addListener(_syncScrollFades);
unawaited(_loadUserId());
_load(); _load();
_startStream(); _startStream();
} }
Future<void> _loadUserId() async {
final user = await AuthService.getCurrentUser();
if (!mounted) return;
_userId = user?.id;
}
@override @override
void dispose() { void dispose() {
_stream?.stop(); _stream?.stop();
@@ -73,9 +84,25 @@ class _CartesFeedState extends State<CartesFeed> {
)..start(); )..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) { void _onStreamEvent(CardsStreamEvent event) {
if (!mounted) return; if (!mounted) return;
if (event.type == 'heartbeat') 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') { if (event.type == 'card.deleted') {
final id = event.cardId; final id = event.cardId;
@@ -90,26 +117,36 @@ class _CartesFeedState extends State<CartesFeed> {
return; return;
} }
final card = event.card; final raw = event.card;
if (card == null) return; if (raw == null) return;
final filter = widget.placementId; final filter = widget.placementId;
final matchesFilter = filter == null || final matchesFilter = filter == null ||
filter.isEmpty || filter.isEmpty ||
card.idPlacement == filter; raw.idPlacement == filter;
setState(() { setState(() {
if (!matchesFilter) { if (!matchesFilter) {
_items = _trier([ _items = _trier([
for (final c in _items) for (final c in _items)
if (c.id != card.id) c, if (c.id != raw.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,
]); ]);
} else { } 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()); WidgetsBinding.instance.addPostFrameCallback((_) => _syncScrollFades());
@@ -134,8 +171,9 @@ class _CartesFeedState extends State<CartesFeed> {
} }
} }
/// Aligné sur le tri API : refusées → ouvertes → traitées, puis /// Tri stable partagé : refusées → ouvertes → traitées, puis
/// `modifie_le` DESC, `id` en départage (ordre stable entre parents). /// `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) { static List<CarteBulle> _trier(List<CarteBulle> items) {
int rang(CarteStatut s) { int rang(CarteStatut s) {
switch (s) { switch (s) {
@@ -152,10 +190,10 @@ class _CartesFeedState extends State<CartesFeed> {
out.sort((a, b) { out.sort((a, b) {
final byStatut = rang(a.statut).compareTo(rang(b.statut)); final byStatut = rang(a.statut).compareTo(rang(b.statut));
if (byStatut != 0) return byStatut; if (byStatut != 0) return byStatut;
final am = a.modifieLe?.millisecondsSinceEpoch ?? 0; final ac = a.creeLe?.millisecondsSinceEpoch ?? 0;
final bm = b.modifieLe?.millisecondsSinceEpoch ?? 0; final bc = b.creeLe?.millisecondsSinceEpoch ?? 0;
final byModif = bm.compareTo(am); // DESC final byCree = bc.compareTo(ac); // DESC
if (byModif != 0) return byModif; if (byCree != 0) return byCree;
return a.id.compareTo(b.id); return a.id.compareTo(b.id);
}); });
return out; return out;
@@ -580,20 +618,27 @@ class _BulleTile extends StatelessWidget {
return card.statut == CarteStatut.traitee; 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 { bool get _montreStatutReponse {
if (card.peutRepondre) return false; if (card.peutRepondre) return false;
if (card.isCongeAm && if (card.isCongeAm) {
(card.statut == CarteStatut.traitee || if (card.statut == CarteStatut.ouverte) return card.isCreator;
card.statut == CarteStatut.refusee)) { return card.statut == CarteStatut.traitee ||
return true; 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 && return card.responseMode == CarteResponseMode.ack &&
card.statut == CarteStatut.traitee; card.statut == CarteStatut.traitee;
} }
String get _libelleStatutReponse { 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 ». // Accordé à « Demande de congés ».
return card.statut == CarteStatut.refusee ? 'Refusée' : 'Validée'; return card.statut == CarteStatut.refusee ? 'Refusée' : 'Validée';
} }
@@ -675,20 +720,6 @@ class _BulleTile extends StatelessWidget {
color: encre.ink, 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, _libelleStatutReponse,
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: GoogleFonts.merienda( style: GoogleFonts.merienda(
fontSize: 14, fontSize: 16,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
fontStyle: FontStyle.italic, fontStyle: FontStyle.italic,
color: encre.ink.withValues(alpha: 0.75), color: encre.ink.withValues(alpha: 0.75),