feat(#173): SSE live des bulles + ack sans modale

Abonnement /cards/stream pour sync co-parent en temps réel ;
acquittement (arrêt maladie) en un clic, sans confirmation.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-05 15:41:34 +02:00
co-authored by Cursor
parent 1d3ec0b759
commit d42927297d
5 changed files with 429 additions and 50 deletions
+88 -50
View File
@@ -3,6 +3,7 @@ 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/widgets/quotidien/carte.dart';
import 'package:p_tits_pas/widgets/quotidien/quotidien_theme.dart';
@@ -35,15 +36,20 @@ class _CartesFeedState extends State<CartesFeed> {
bool _fadeTop = false;
bool _fadeBottom = false;
CardsStreamClient? _stream;
@override
void initState() {
super.initState();
_scrollController.addListener(_syncScrollFades);
_load();
_startStream();
}
@override
void dispose() {
_stream?.stop();
_stream = null;
_scrollController.removeListener(_syncScrollFades);
_scrollController.dispose();
super.dispose();
@@ -57,6 +63,58 @@ class _CartesFeedState extends State<CartesFeed> {
}
}
void _startStream() {
_stream?.stop();
_stream = CardsStreamClient(
onEvent: _onStreamEvent,
onReconnectSync: () {
if (mounted) _load();
},
)..start();
}
void _onStreamEvent(CardsStreamEvent event) {
if (!mounted) return;
if (event.type == 'heartbeat') return;
if (event.type == 'card.deleted') {
final id = event.cardId;
if (id == null || id.isEmpty) return;
setState(() {
_items = _trier([
for (final c in _items)
if (c.id != id) c,
]);
});
WidgetsBinding.instance.addPostFrameCallback((_) => _syncScrollFades());
return;
}
final card = event.card;
if (card == null) return;
final filter = widget.placementId;
final matchesFilter = filter == null ||
filter.isEmpty ||
card.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,
]);
} else {
_items = _trier([..._items, card]);
}
});
WidgetsBinding.instance.addPostFrameCallback((_) => _syncScrollFades());
}
void _syncScrollFades() {
if (!_scrollController.hasClients) return;
final position = _scrollController.position;
@@ -189,41 +247,8 @@ class _CartesFeedState extends State<CartesFeed> {
}
}
Future<void> _onAck(CarteBulle card) async {
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(
'Prise en compte',
style: GoogleFonts.merienda(fontWeight: FontWeight.w600),
),
content: Text(
card.isArretMaladie
? 'Confirmez-vous avoir pris connaissance de cet arrêt maladie '
'${_periodeDuAu(card)} ?'
: card.typeCode.startsWith('absence_enfant')
? 'Confirmez-vous avoir pris connaissance de l’absence '
'${_periodeDuAu(card)} ?'
: 'Confirmez-vous avoir bien reçu « ${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('Bien reçu'),
),
],
),
);
if (ok == true) {
await _respond(card, action: 'ack');
}
}
/// Ack immédiat (arrêt maladie, etc.) — pas de modale : un clic = bien reçu.
Future<void> _onAck(CarteBulle card) => _respond(card, action: 'ack');
Future<void> _onRefuse(CarteBulle card) async {
final comment = await showDialog<String>(
@@ -555,6 +580,24 @@ class _BulleTile extends StatelessWidget {
return card.statut == CarteStatut.traitee;
}
/// Congés (Validée / Refusée) ou carte ack (Bien reçu).
bool get _montreStatutReponse {
if (card.peutRepondre) return false;
if (card.isCongeAm &&
(card.statut == CarteStatut.traitee ||
card.statut == CarteStatut.refusee)) {
return true;
}
return card.responseMode == CarteResponseMode.ack &&
card.statut == CarteStatut.traitee;
}
String get _libelleStatutReponse {
if (card.responseMode == CarteResponseMode.ack) return 'Bien reçu';
// Accordé à « Demande de congés ».
return card.statut == CarteStatut.refusee ? 'Refusée' : 'Validée';
}
@override
Widget build(BuildContext context) {
final periode = _fmtPeriodeUneLigne();
@@ -632,12 +675,15 @@ class _BulleTile extends StatelessWidget {
color: encre.ink,
),
),
] else if (card.isCongeAm &&
card.statut == CarteStatut.ouverte &&
card.isCreator) ...[
] else if (card.statut == CarteStatut.ouverte &&
card.isCreator &&
(card.isCongeAm ||
card.responseMode == CarteResponseMode.ack)) ...[
const SizedBox(height: 8),
Text(
'En attente de réponse',
card.responseMode == CarteResponseMode.ack
? 'En attente d’acquittement'
: 'En attente de réponse',
style: GoogleFonts.merriweather(
fontSize: 12,
color: QuotidienTheme.muted,
@@ -695,15 +741,10 @@ class _BulleTile extends StatelessWidget {
),
],
)
else if (card.isCongeAm &&
(card.statut == CarteStatut.traitee ||
card.statut == CarteStatut.refusee))
else if (_montreStatutReponse)
Expanded(
child: Text(
// Accordé à « Demande de congés ».
card.statut == CarteStatut.refusee
? 'Refusée'
: 'Validée',
_libelleStatutReponse,
textAlign: TextAlign.center,
style: GoogleFonts.merienda(
fontSize: 14,
@@ -714,10 +755,7 @@ class _BulleTile extends StatelessWidget {
),
),
if (card.peutRepondre) const SizedBox(width: 8),
if (!(card.isCongeAm &&
(card.statut == CarteStatut.traitee ||
card.statut == CarteStatut.refusee)))
const Spacer(),
if (!_montreStatutReponse) const Spacer(),
InkWell(
onTap: () {
ScaffoldMessenger.of(context).showSnackBar(