import 'dart:async'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../data/api/chat_api.dart'; import '../data/models/journal_day.dart'; import '../data/models/message.dart'; import 'api_client_provider.dart'; /// Drives the loading indicator in JournalScreen's reply area. final isJournalStreamingProvider = NotifierProvider<_BoolNotifier, bool>(_BoolNotifier.new); class _BoolNotifier extends Notifier { @override bool build() => false; } final journalProvider = AsyncNotifierProvider(JournalNotifier.new); class JournalNotifier extends AsyncNotifier { @override Future build() async { return ref.read(journalApiProvider).getToday(); } /// Silently fetch today's journal and patch state without triggering /// AsyncLoading — existing content stays visible while the fetch is in flight. Future silentRefresh() async { final current = state.value; if (current == null) return; try { final fresh = await ref.read(journalApiProvider).getToday(); final curLast = current.messages.isNotEmpty ? current.messages.last : null; final newLast = fresh.messages.isNotEmpty ? fresh.messages.last : null; if (fresh.messages.length != current.messages.length || newLast?.content != curLast?.content) { state = AsyncData(fresh); } } catch (_) { // Network hiccup — silently ignore, keep existing content. } } /// Force-regenerate today's daily prep then reload. Future regeneratePrep() async { await ref.read(journalApiProvider).triggerPrep(); ref.invalidateSelf(); await future; } /// Re-fetch today's journal and unfreeze a stuck streaming state if the /// server-side message is already complete. /// /// Same role as MessagesNotifier.refresh() in chat_provider: when an SSE /// socket dies silently the send loop never observes close and /// [isJournalStreamingProvider] stays stuck true. This is the manual /// recovery path hit by pull-to-refresh, the AppBar refresh button, and /// the lifecycle-resume hook. Future refreshMessages() async { final current = state.value; if (current == null) { ref.invalidateSelf(); return; } try { final fresh = await ref.read(journalApiProvider).getToday(); state = AsyncData(fresh); final messages = fresh.messages; Message? lastAssistant; for (var i = messages.length - 1; i >= 0; i--) { if (messages[i].role == MessageRole.assistant) { lastAssistant = messages[i]; break; } } if (lastAssistant != null && lastAssistant.status != 'generating') { ref.read(isJournalStreamingProvider.notifier).state = false; } } catch (_) { // Network hiccup — keep existing state; user can retry. } } /// Send a reply to today's journal conversation. /// /// Mirrors MessagesNotifier.sendMessage() in chat_provider with the same /// stall-watchdog pattern: /// 1. Optimistic UI update /// 2. POST message to chat endpoint /// 3. SSE stream with per-event timeout (stall watchdog) /// 4. Poll until complete Future sendReply(String content) async { final day = state.value; if (day == null) return; final conv = day.conversation; if (conv == null) return; final convId = conv.id; final chatApi = ref.read(chatApiProvider); final previous = day.messages; final userMsg = Message( conversationId: convId, role: MessageRole.user, content: content, ); final placeholder = Message( conversationId: convId, role: MessageRole.assistant, content: '', status: 'generating', ); state = AsyncData(day.copyWith(messages: [...previous, userMsg, placeholder])); ref.read(isJournalStreamingProvider.notifier).state = true; try { await chatApi.sendMessage(convId, content); } catch (e) { state = AsyncData(day.copyWith(messages: previous)); ref.read(isJournalStreamingProvider.notifier).state = false; rethrow; } final streamedContent = await _consumeStream(chatApi.streamGeneration(convId)); await _pollUntilComplete(convId, streamedContent); } /// Consume an SSE stream into the current journal day state. /// /// Uses a StreamIterator with a per-event timeout as a stall watchdog — /// same rationale as MessagesNotifier.sendMessage() in chat_provider.dart. /// Mobile networks occasionally drop SSE sockets silently. If no event /// arrives within the watchdog window we bail out and let polling /// reconcile state from the server. /// /// Returns whether any text content was actually streamed. Future _consumeStream(Stream stream) async { const stallTimeout = Duration(seconds: 45); bool streamedContent = false; final iter = StreamIterator(stream); try { while (await iter.moveNext().timeout(stallTimeout)) { final event = iter.current; final current = state.value; if (current == null) break; final msgs = current.messages; if (msgs.isEmpty) continue; if (event is ChatTextChunk) { streamedContent = true; final updated = msgs.last.copyWith(content: msgs.last.content + event.text); state = AsyncData(current.copyWith( messages: [...msgs.sublist(0, msgs.length - 1), updated])); } else if (event is ChatToolCall) { final last = msgs.last; if (last.role != MessageRole.assistant) continue; final nextCalls = [...?last.toolCalls, event.toolCall]; final updated = last.copyWith(toolCalls: nextCalls); state = AsyncData(current.copyWith( messages: [...msgs.sublist(0, msgs.length - 1), updated])); } } } on TimeoutException { // Stall watchdog — no SSE event for stallTimeout. Fall through to // polling so the UI eventually unfreezes even if the socket is dead. } catch (_) { // SSE failed — fall through to polling. } finally { await iter.cancel(); } return streamedContent; } /// Poll today's journal until the last assistant row is complete. Always /// clears [isJournalStreamingProvider] at the end so the input can't stay /// locked. Future _pollUntilComplete(int convId, bool streamedContent) async { final journalApi = ref.read(journalApiProvider); try { for (var attempt = 0; attempt < 20; attempt++) { if (attempt > 0) await Future.delayed(const Duration(seconds: 2)); final fresh = await journalApi.getToday(); final freshMsgs = fresh.messages; final done = freshMsgs.any( (m) => m.role == MessageRole.assistant && m.status != 'generating', ); final hasContent = freshMsgs.any( (m) => m.role == MessageRole.assistant && m.content.isNotEmpty, ); final current = state.value; if (current != null && (!streamedContent || done || hasContent)) { state = AsyncData(current.copyWith(messages: freshMsgs)); } if (done) break; } } catch (_) { final current = state.value; if (current != null) { final msgs = current.messages; if (msgs.isNotEmpty && msgs.last.status == 'generating') { state = AsyncData(current.copyWith(messages: [ ...msgs.sublist(0, msgs.length - 1), msgs.last.copyWith(status: 'complete'), ])); } } } finally { ref.read(isJournalStreamingProvider.notifier).state = false; } } }