diff --git a/frontend/lib/services/api/cards_stream_client.dart b/frontend/lib/services/api/cards_stream_client.dart new file mode 100644 index 0000000..35cf690 --- /dev/null +++ b/frontend/lib/services/api/cards_stream_client.dart @@ -0,0 +1,154 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:p_tits_pas/models/carte_bulle.dart'; +import 'package:p_tits_pas/services/api/api_config.dart'; +import 'package:p_tits_pas/services/api/tokenService.dart'; + +import 'cards_stream_connector_stub.dart' + if (dart.library.html) 'cards_stream_connector_web.dart' + if (dart.library.io) 'cards_stream_connector_io.dart'; + +/// Événement temps réel file d'attention — ticket #195. +class CardsStreamEvent { + final String type; + final String? cardId; + final CarteBulle? card; + + const CardsStreamEvent({ + required this.type, + this.cardId, + this.card, + }); +} + +/// Client SSE `GET /cards/stream` avec reconnexion. +class CardsStreamClient { + final void Function(CardsStreamEvent event) onEvent; + final VoidCallback? onReconnectSync; + + CardsStreamClient({ + required this.onEvent, + this.onReconnectSync, + }); + + final CardsStreamConnector _connector = CardsStreamConnector(); + bool _started = false; + bool _stopRequested = false; + int _attempt = 0; + Timer? _retryTimer; + bool _hadConnection = false; + + void start() { + if (_started) return; + _started = true; + _stopRequested = false; + _attempt = 0; + unawaited(_connect()); + } + + void stop() { + _stopRequested = true; + _started = false; + _retryTimer?.cancel(); + _retryTimer = null; + _connector.close(); + } + + Future _connect() async { + if (_stopRequested) return; + + final token = await TokenService.getToken(); + if (token == null || token.isEmpty) { + _scheduleRetry(); + return; + } + + final base = + '${ApiConfig.baseUrl}${ApiConfig.cardsStream}'.replaceAll( + RegExp(r'/+$'), + '', + ); + + // Web (EventSource) : token en query. IO : Bearer dans le connecteur. + final url = kIsWeb + ? Uri.parse(base).replace( + queryParameters: {'access_token': token}, + ).toString() + : base; + + await _connector.connect( + url: url, + bearerToken: kIsWeb ? null : token, + onEvent: _handleRaw, + onError: (_) { + if (_stopRequested) return; + _connector.close(); + _scheduleRetry(); + }, + ); + } + + void _handleRaw(String eventType, String data) { + if (_stopRequested) return; + + // Premier événement (souvent heartbeat) = connexion OK. + if (!_hadConnection) { + _hadConnection = true; + _attempt = 0; + } else if (eventType == 'heartbeat') { + _attempt = 0; + return; + } + + if (eventType == 'heartbeat') return; + + try { + final decoded = jsonDecode(data); + if (decoded is! Map) return; + final map = Map.from(decoded); + final type = (map['event']?.toString().isNotEmpty == true) + ? map['event'].toString() + : eventType; + final cardId = map['card_id']?.toString(); + final payload = map['data']; + + CarteBulle? card; + if (type == 'response.added' && payload is Map) { + final nested = payload['card']; + if (nested is Map) { + card = CarteBulle.fromJson(Map.from(nested)); + } + } else if ((type == 'card.created' || type == 'card.updated') && + payload is Map) { + card = CarteBulle.fromJson(Map.from(payload)); + } + + onEvent( + CardsStreamEvent( + type: type, + cardId: cardId ?? card?.id, + card: card, + ), + ); + } catch (_) { + // Événement mal formé : ignorer. + } + } + + void _scheduleRetry() { + if (_stopRequested) return; + _retryTimer?.cancel(); + final delaySec = (1 << _attempt.clamp(0, 4)).clamp(1, 30); + _attempt++; + _retryTimer = Timer(Duration(seconds: delaySec), () { + if (_stopRequested) return; + if (_hadConnection) { + // Resync REST après coupure pour ne pas manquer d'événements. + onReconnectSync?.call(); + } + unawaited(_connect()); + }); + } +} diff --git a/frontend/lib/services/api/cards_stream_connector_io.dart b/frontend/lib/services/api/cards_stream_connector_io.dart new file mode 100644 index 0000000..fca64f6 --- /dev/null +++ b/frontend/lib/services/api/cards_stream_connector_io.dart @@ -0,0 +1,114 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:http/http.dart' as http; + +/// Connecteur SSE via HTTP stream (IO / mobile) — ticket #195. +class CardsStreamConnector { + http.Client? _client; + StreamSubscription>? _sub; + bool _closed = false; + + Future connect({ + required String url, + String? bearerToken, + required void Function(String eventType, String data) onEvent, + required void Function(Object error) onError, + }) async { + close(); + _closed = false; + final client = http.Client(); + _client = client; + + try { + final request = http.Request('GET', Uri.parse(url)); + request.headers['Accept'] = 'text/event-stream'; + request.headers['Cache-Control'] = 'no-cache'; + if (bearerToken != null && bearerToken.isNotEmpty) { + request.headers['Authorization'] = 'Bearer $bearerToken'; + } + + final response = await client.send(request); + if (response.statusCode != 200) { + onError( + StateError('SSE cards HTTP ${response.statusCode}'), + ); + close(); + return; + } + + String? eventType; + final dataBuf = StringBuffer(); + final lineBuf = StringBuffer(); + + void flushEvent() { + if (dataBuf.isEmpty) { + eventType = null; + return; + } + onEvent(eventType ?? 'message', dataBuf.toString()); + dataBuf.clear(); + eventType = null; + } + + void handleLine(String line) { + if (line.isEmpty) { + flushEvent(); + return; + } + if (line.startsWith(':')) return; + if (line.startsWith('event:')) { + eventType = line.substring(6).trim(); + return; + } + if (line.startsWith('data:')) { + final payload = line.length > 5 ? line.substring(5) : ''; + final trimmed = payload.startsWith(' ') + ? payload.substring(1) + : payload; + if (dataBuf.isNotEmpty) dataBuf.write('\n'); + dataBuf.write(trimmed); + } + } + + _sub = response.stream.listen( + (chunk) { + if (_closed) return; + lineBuf.write(utf8.decode(chunk, allowMalformed: true)); + var content = lineBuf.toString(); + var idx = content.indexOf('\n'); + while (idx >= 0) { + var line = content.substring(0, idx); + if (line.endsWith('\r')) { + line = line.substring(0, line.length - 1); + } + handleLine(line); + content = content.substring(idx + 1); + idx = content.indexOf('\n'); + } + lineBuf + ..clear() + ..write(content); + }, + onError: (Object e) { + if (!_closed) onError(e); + }, + onDone: () { + if (!_closed) onError(StateError('SSE cards fermé')); + }, + cancelOnError: true, + ); + } catch (e) { + if (!_closed) onError(e); + close(); + } + } + + void close() { + _closed = true; + _sub?.cancel(); + _sub = null; + _client?.close(); + _client = null; + } +} diff --git a/frontend/lib/services/api/cards_stream_connector_stub.dart b/frontend/lib/services/api/cards_stream_connector_stub.dart new file mode 100644 index 0000000..f50d0f8 --- /dev/null +++ b/frontend/lib/services/api/cards_stream_connector_stub.dart @@ -0,0 +1,13 @@ +import 'dart:async'; + +/// Connecteur SSE — stub (tests / plateformes sans impl). +class CardsStreamConnector { + Future connect({ + required String url, + String? bearerToken, + required void Function(String eventType, String data) onEvent, + required void Function(Object error) onError, + }) async {} + + void close() {} +} diff --git a/frontend/lib/services/api/cards_stream_connector_web.dart b/frontend/lib/services/api/cards_stream_connector_web.dart new file mode 100644 index 0000000..9e5e6b7 --- /dev/null +++ b/frontend/lib/services/api/cards_stream_connector_web.dart @@ -0,0 +1,60 @@ +// Connecteur web uniquement (import conditionnel). +// ignore_for_file: avoid_web_libraries_in_flutter, deprecated_member_use + +import 'dart:html' as html; + +/// Connecteur SSE navigateur (EventSource) — ticket #195. +/// Auth via `?access_token=` (EventSource ne permet pas Authorization). +class CardsStreamConnector { + html.EventSource? _source; + final Map _listeners = {}; + + Future connect({ + required String url, + String? bearerToken, + required void Function(String eventType, String data) onEvent, + required void Function(Object error) onError, + }) async { + close(); + final source = html.EventSource(url); + _source = source; + + const names = [ + 'card.created', + 'card.updated', + 'card.deleted', + 'response.added', + 'heartbeat', + ]; + for (final name in names) { + void listener(html.Event raw) { + final msg = raw as html.MessageEvent; + final data = msg.data?.toString() ?? ''; + if (data.isEmpty) return; + onEvent(name, data); + } + + _listeners[name] = listener; + source.addEventListener(name, listener); + } + + void onErr(html.Event _) { + onError(StateError('SSE cards déconnecté')); + } + + _listeners['error'] = onErr; + source.addEventListener('error', onErr); + } + + void close() { + final source = _source; + if (source != null) { + _listeners.forEach((name, listener) { + source.removeEventListener(name, listener); + }); + source.close(); + } + _listeners.clear(); + _source = null; + } +} diff --git a/frontend/lib/widgets/quotidien/cartes_feed.dart b/frontend/lib/widgets/quotidien/cartes_feed.dart index a4e0cea..279b021 100644 --- a/frontend/lib/widgets/quotidien/cartes_feed.dart +++ b/frontend/lib/widgets/quotidien/cartes_feed.dart @@ -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 { 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 { } } + 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 { } } - Future _onAck(CarteBulle card) async { - final ok = await showDialog( - 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 _onAck(CarteBulle card) => _respond(card, action: 'ack'); Future _onRefuse(CarteBulle card) async { final comment = await showDialog( @@ -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(