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:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user