import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../data/api/chat_api.dart'; import '../data/models/briefing_conversation.dart'; import '../data/models/message.dart'; import 'api_client_provider.dart'; /// Drives the loading indicator in BriefingScreen's reply area. final isBriefingStreamingProvider = NotifierProvider<_BoolNotifier, bool>(_BoolNotifier.new); class _BoolNotifier extends Notifier { @override bool build() => false; } final briefingProvider = AsyncNotifierProvider( BriefingNotifier.new); class BriefingNotifier extends AsyncNotifier { @override Future build() async { return ref.read(briefingApiProvider).getToday(); } /// Silently fetch the latest briefing 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(briefingApiProvider).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 } } /// Trigger a briefing slot (e.g. "compilation") then reload. Future refresh(String slot) async { await ref.read(briefingApiProvider).triggerSlot(slot); ref.invalidateSelf(); await future; } /// Inject a news article as context and trigger generation. /// /// Mirrors sendReply() but calls the /discuss endpoint instead of /// /messages so the backend injects article content before generating. Future discussArticle(int convId, int itemId) async { final conv = state.value; if (conv == null) return; final chatApi = ref.read(chatApiProvider); final briefingApi = ref.read(briefingApiProvider); final previous = conv.messages; final placeholder = Message( conversationId: convId, role: MessageRole.assistant, content: '', status: 'generating', ); state = AsyncData(conv.copyWith(messages: [...previous, placeholder])); ref.read(isBriefingStreamingProvider.notifier).state = true; try { await briefingApi.discussArticle(convId, itemId); } catch (e) { state = AsyncData(conv.copyWith(messages: previous)); ref.read(isBriefingStreamingProvider.notifier).state = false; rethrow; } // SSE stream (best-effort) bool streamedContent = false; try { await for (final event in chatApi.streamGeneration(convId)) { if (event is! ChatTextChunk) continue; streamedContent = true; final current = state.value; if (current == null) break; final msgs = current.messages; if (msgs.isEmpty) continue; final updated = msgs.last.copyWith(content: msgs.last.content + event.text); state = AsyncData(current.copyWith( messages: [...msgs.sublist(0, msgs.length - 1), updated])); } } catch (_) { // Fall through to polling. } // Poll until complete (max 20 attempts, 2s apart) try { for (var attempt = 0; attempt < 20; attempt++) { if (attempt > 0) await Future.delayed(const Duration(seconds: 2)); final fresh = await briefingApi.getMessages(convId); final done = fresh.any( (m) => m.role == MessageRole.assistant && m.status != 'generating', ); final hasContent = fresh.any( (m) => m.role == MessageRole.assistant && m.content.isNotEmpty, ); final current = state.value; if (current != null && (!streamedContent || done || hasContent)) { state = AsyncData(current.copyWith(messages: fresh)); } 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(isBriefingStreamingProvider.notifier).state = false; } } /// Send a reply to today's briefing conversation. /// /// Mirrors MessagesNotifier.sendMessage(): /// 1. Optimistic UI update /// 2. POST message to chat endpoint /// 3. SSE stream (best-effort) /// 4. Poll until complete Future sendReply(String content) async { final conv = state.value; if (conv == null) return; final convId = conv.id; final chatApi = ref.read(chatApiProvider); final previous = conv.messages; final userMsg = Message( conversationId: convId, role: MessageRole.user, content: content, ); final placeholder = Message( conversationId: convId, role: MessageRole.assistant, content: '', status: 'generating', ); state = AsyncData(conv.copyWith(messages: [...previous, userMsg, placeholder])); ref.read(isBriefingStreamingProvider.notifier).state = true; try { await chatApi.sendMessage(convId, content); } catch (e) { state = AsyncData(conv.copyWith(messages: previous)); ref.read(isBriefingStreamingProvider.notifier).state = false; rethrow; } // SSE stream (best-effort) bool streamedContent = false; try { await for (final event in chatApi.streamGeneration(convId)) { if (event is! ChatTextChunk) continue; streamedContent = true; final current = state.value; if (current == null) break; final msgs = current.messages; if (msgs.isEmpty) continue; final updated = msgs.last.copyWith(content: msgs.last.content + event.text); state = AsyncData(current.copyWith( messages: [...msgs.sublist(0, msgs.length - 1), updated])); } } catch (_) { // Fall through to polling. } // Poll until complete (max 20 attempts, 2s apart) try { for (var attempt = 0; attempt < 20; attempt++) { if (attempt > 0) await Future.delayed(const Duration(seconds: 2)); final fresh = await ref.read(briefingApiProvider).getMessages(convId); final done = fresh.any( (m) => m.role == MessageRole.assistant && m.status != 'generating', ); final hasContent = fresh.any( (m) => m.role == MessageRole.assistant && m.content.isNotEmpty, ); final current = state.value; if (current != null && (!streamedContent || done || hasContent)) { state = AsyncData(current.copyWith(messages: fresh)); } if (done) break; } } catch (_) { // Clear the generating placeholder so UI doesn't spin forever. 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(isBriefingStreamingProvider.notifier).state = false; } } }