6232c7c99a
Plans 1-3 implemented: Plan 1 — Foundation - Add google_fonts ^6.2.1 - lib/core/theme.dart: slate-indigo ColorScheme (dark/light), Fraunces headings, GradientButton widget - lib/providers/capture_work_queue_provider.dart: in-memory sequential work queue; CaptureWorkQueueNotifier drains one item at a time; captureResultProvider feeds snackbars to UI - lib/app.dart: wire fabledDarkTheme/fabledLightTheme; replace blocking _QuickCaptureBar with queue-based implementation (progress bar, badge) Plan 2 — Navigation - 3-tab shell: Briefing · Library · Chat (was Notes · Tasks · Projects · Chat) - lib/screens/library/library_screen.dart: unified notes+tasks+projects list with filter pills, status sub-filter for tasks, live search, FAB - lib/widgets/library_item_card.dart: NoteLibraryCard, TaskLibraryCard (status cycle), ProjectLibraryCard - lib/screens/chat/conversations_tab_screen.dart: focused replacement for ConversationsListScreen - Delete 5 dead screens: notes_list, tasks_list, project_list, conversations_list, quick_capture Plan 3 — Briefing - lib/data/models/briefing_conversation.dart - lib/data/api/briefing_api.dart: getToday, getHistory, getMessages, triggerSlot - lib/providers/briefing_provider.dart: BriefingNotifier with sendReply (optimistic + SSE + poll, same pattern as MessagesNotifier) and refresh - lib/widgets/chat_message_bubble.dart: extracted + redesigned shared bubble (ghost user bubbles, left-accent assistant bubbles) - lib/widgets/briefing_digest_card.dart: expandable first-message card - lib/screens/briefing/briefing_screen.dart: digest card, conversation list, streaming reply bar, refresh button, history overflow menu - lib/screens/briefing/briefing_history_screen.dart: read-only past dates Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
114 lines
3.8 KiB
Dart
114 lines
3.8 KiB
Dart
import 'package:flutter_riverpod/flutter_riverpod.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 = StateProvider<bool>((ref) => false);
|
|
|
|
final briefingProvider =
|
|
AsyncNotifierProvider<BriefingNotifier, BriefingConversation>(
|
|
BriefingNotifier.new);
|
|
|
|
class BriefingNotifier extends AsyncNotifier<BriefingConversation> {
|
|
@override
|
|
Future<BriefingConversation> build() async {
|
|
return ref.read(briefingApiProvider).getToday();
|
|
}
|
|
|
|
/// Trigger a briefing slot (e.g. "compilation") then reload.
|
|
Future<void> refresh(String slot) async {
|
|
await ref.read(briefingApiProvider).triggerSlot(slot);
|
|
ref.invalidateSelf();
|
|
await future;
|
|
}
|
|
|
|
/// 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<void> sendReply(String content) async {
|
|
final conv = state.valueOrNull;
|
|
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 chunk in chatApi.streamGeneration(convId)) {
|
|
streamedContent = true;
|
|
final current = state.valueOrNull;
|
|
if (current == null) break;
|
|
final msgs = current.messages;
|
|
if (msgs.isEmpty) continue;
|
|
final updated = msgs.last.copyWith(content: msgs.last.content + chunk);
|
|
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.valueOrNull;
|
|
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.valueOrNull;
|
|
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;
|
|
}
|
|
}
|
|
}
|