diff --git a/lib/app.dart b/lib/app.dart index 57c3a65..5d0db2f 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -11,15 +11,14 @@ import 'providers/auth_provider.dart'; import 'core/exceptions.dart'; import 'providers/capture_queue_provider.dart'; import 'providers/capture_work_queue_provider.dart'; -import 'providers/briefing_provider.dart'; import 'providers/calendar_provider.dart'; import 'providers/chat_provider.dart'; +import 'providers/journal_provider.dart'; import 'providers/knowledge_provider.dart'; -import 'providers/news_provider.dart'; import 'providers/settings_provider.dart'; import 'providers/update_provider.dart'; import 'screens/auth/login_screen.dart'; -import 'screens/briefing/briefing_screen.dart'; +import 'screens/journal/journal_screen.dart'; import 'screens/knowledge/knowledge_screen.dart'; import 'screens/library/project_tasks_screen.dart'; import 'screens/chat/chat_screen.dart'; @@ -29,7 +28,6 @@ import 'screens/projects/project_edit_screen.dart'; import 'screens/projects/projects_screen.dart'; import 'screens/notes/note_edit_screen.dart'; import 'screens/settings/settings_screen.dart'; -import 'screens/news/news_screen.dart'; import 'screens/setup/setup_screen.dart'; import 'screens/splash/splash_screen.dart'; import 'screens/tasks/task_edit_screen.dart'; @@ -161,8 +159,8 @@ final routerProvider = Provider((ref) { builder: (context, state, child) => _Shell(child: child), routes: [ GoRoute( - path: Routes.briefing, - builder: (_, _) => const BriefingScreen(), + path: Routes.journal, + builder: (_, _) => const JournalScreen(), ), GoRoute( path: Routes.knowledge, @@ -176,10 +174,6 @@ final routerProvider = Provider((ref) { path: Routes.projects, builder: (_, _) => const ProjectsScreen(), ), - GoRoute( - path: Routes.news, - builder: (_, _) => const NewsScreen(), - ), GoRoute( path: Routes.calendar, builder: (_, _) => const CalendarScreen(), @@ -200,15 +194,14 @@ class _Shell extends ConsumerStatefulWidget { class _ShellState extends ConsumerState<_Shell> with WidgetsBindingObserver { static const _baseTabs = [ - Routes.briefing, + Routes.journal, Routes.knowledge, Routes.conversations, Routes.projects, ]; - List _tabs(bool rssEnabled) => [ + List _tabs() => [ ..._baseTabs, - if (rssEnabled) Routes.news, Routes.calendar, ]; @@ -232,7 +225,7 @@ class _ShellState extends ConsumerState<_Shell> with WidgetsBindingObserver { ref.read(updateProvider.notifier).check(repoUrl); } } - // Sync device timezone to backend so briefing and chat use local time. + // Sync device timezone to backend so journal and chat use local time. _syncTimezone(); }); } @@ -260,24 +253,20 @@ class _ShellState extends ConsumerState<_Shell> with WidgetsBindingObserver { void _refreshAll() { ref.read(conversationsProvider.notifier).refresh(); ref.read(calendarProvider.notifier).refresh(); - ref.read(newsProvider.notifier).refresh(); ref.read(knowledgeProvider.notifier).refresh(); - // briefingProvider is an AsyncNotifier family; invalidating is safe - // even if no conversation is open — it doesn't cause flicker since - // the briefing screen isn't a list view. - ref.invalidate(briefingProvider); + // journalProvider is an AsyncNotifier; invalidating is safe even if + // the journal screen isn't currently mounted. + ref.invalidate(journalProvider); } /// Refresh only the provider backing the given shell tab route. void _refreshTab(String route) { - if (route == Routes.briefing) { - ref.invalidate(briefingProvider); + if (route == Routes.journal) { + ref.invalidate(journalProvider); } else if (route == Routes.knowledge) { ref.read(knowledgeProvider.notifier).refresh(); } else if (route == Routes.conversations) { ref.read(conversationsProvider.notifier).refresh(); - } else if (route == Routes.news) { - ref.read(newsProvider.notifier).refresh(); } else if (route == Routes.calendar) { ref.read(calendarProvider.notifier).refresh(); } @@ -314,15 +303,6 @@ class _ShellState extends ConsumerState<_Shell> with WidgetsBindingObserver { context.push(Routes.projects); }, ), - if (ref.read(rssEnabledProvider)) - ListTile( - leading: const Icon(Icons.newspaper_outlined), - title: const Text('News'), - onTap: () { - Navigator.pop(context); - context.push(Routes.news); - }, - ), ListTile( leading: const Icon(Icons.calendar_month_outlined), title: const Text('Calendar'), @@ -360,8 +340,7 @@ class _ShellState extends ConsumerState<_Shell> with WidgetsBindingObserver { .addPostFrameCallback((_) => _showUpdateSnackbar(next)); } }); - final rssEnabled = ref.watch(rssEnabledProvider); - final tabs = _tabs(rssEnabled); + final tabs = _tabs(); final location = GoRouterState.of(context).matchedLocation; final index = _tabIndex(location, tabs); @@ -389,34 +368,28 @@ class _ShellState extends ConsumerState<_Shell> with WidgetsBindingObserver { selectedIndex: index, onDestinationSelected: (i) => context.go(tabs[i]), labelType: NavigationRailLabelType.all, - destinations: [ - const NavigationRailDestination( - icon: Icon(Icons.wb_sunny_outlined), - selectedIcon: Icon(Icons.wb_sunny), - label: Text('Briefing'), - ), - const NavigationRailDestination( + destinations: const [ + NavigationRailDestination( icon: Icon(Icons.menu_book_outlined), selectedIcon: Icon(Icons.menu_book), + label: Text('Journal'), + ), + NavigationRailDestination( + icon: Icon(Icons.lightbulb_outline), + selectedIcon: Icon(Icons.lightbulb), label: Text('Knowledge'), ), - const NavigationRailDestination( + NavigationRailDestination( icon: Icon(Icons.chat_bubble_outline), selectedIcon: Icon(Icons.chat_bubble), label: Text('Chat'), ), - const NavigationRailDestination( + NavigationRailDestination( icon: Icon(Icons.folder_outlined), selectedIcon: Icon(Icons.folder), label: Text('Projects'), ), - if (ref.watch(rssEnabledProvider)) - const NavigationRailDestination( - icon: Icon(Icons.newspaper_outlined), - selectedIcon: Icon(Icons.newspaper), - label: Text('News'), - ), - const NavigationRailDestination( + NavigationRailDestination( icon: Icon(Icons.calendar_month_outlined), selectedIcon: Icon(Icons.calendar_month), label: Text('Calendar'), @@ -458,14 +431,14 @@ class _ShellState extends ConsumerState<_Shell> with WidgetsBindingObserver { } }, destinations: const [ - NavigationDestination( - icon: Icon(Icons.wb_sunny_outlined), - selectedIcon: Icon(Icons.wb_sunny), - label: 'Briefing', - ), NavigationDestination( icon: Icon(Icons.menu_book_outlined), selectedIcon: Icon(Icons.menu_book), + label: 'Journal', + ), + NavigationDestination( + icon: Icon(Icons.lightbulb_outline), + selectedIcon: Icon(Icons.lightbulb), label: 'Knowledge', ), NavigationDestination( diff --git a/lib/core/constants.dart b/lib/core/constants.dart index 2de5da1..5866cb1 100644 --- a/lib/core/constants.dart +++ b/lib/core/constants.dart @@ -16,8 +16,7 @@ abstract class Routes { static const chat = '/chat/:id'; static const quickCapture = '/quick-capture'; static const settings = '/settings'; - static const briefing = '/briefing'; - static const news = '/news'; + static const journal = '/journal'; static const calendar = '/calendar'; static const projectTasks = '/projects/:id/tasks'; } diff --git a/lib/data/api/briefing_api.dart b/lib/data/api/briefing_api.dart deleted file mode 100644 index 27e7c7e..0000000 --- a/lib/data/api/briefing_api.dart +++ /dev/null @@ -1,97 +0,0 @@ -import 'package:dio/dio.dart'; - -import '../models/briefing_conversation.dart'; -import '../models/message.dart'; -import 'api_client.dart'; - -class BriefingApi { - final Dio _dio; - const BriefingApi(this._dio); - - /// GET /api/briefing/conversations/today - /// Returns (or creates) today's briefing conversation with messages embedded. - Future getToday() async { - try { - final response = await _dio.get('/api/briefing/conversations/today'); - return BriefingConversation.fromJson( - response.data as Map); - } on DioException catch (e) { - throw dioToApp(e); - } - } - - /// GET /api/briefing/conversations - /// Returns list of past briefing conversations (no messages embedded). - Future> getHistory() async { - try { - final response = await _dio.get('/api/briefing/conversations'); - final data = response.data as Map; - final list = data['conversations'] as List; - return list - .map((e) => BriefingConversation.fromJson(e as Map)) - .toList(); - } on DioException catch (e) { - throw dioToApp(e); - } - } - - /// GET /api/briefing/conversations/``/messages - Future> getMessages(int convId) async { - try { - final response = - await _dio.get('/api/briefing/conversations/$convId/messages'); - final data = response.data as Map; - final list = data['messages'] as List; - return list - .map((e) => Message.fromJson(e as Map)) - .toList(); - } on DioException catch (e) { - throw dioToApp(e); - } - } - - /// POST /api/briefing/trigger body: {"slot": slot} - /// slot: "compilation" | "morning" | "midday" | "afternoon" - Future triggerSlot(String slot) async { - try { - await _dio.post('/api/briefing/trigger', data: {'slot': slot}); - } on DioException catch (e) { - throw dioToApp(e); - } - } - - /// POST /api/briefing/rss-reactions body: {rss_item_id, reaction: "up"|"down"} - Future postRssReaction(int rssItemId, String reaction) async { - try { - await _dio.post('/api/briefing/rss-reactions', - data: {'rss_item_id': rssItemId, 'reaction': reaction}); - } on DioException catch (e) { - throw dioToApp(e); - } - } - - /// POST /api/briefing/articles/{itemId}/discuss body: {"conv_id": convId} - /// Injects the article as context and triggers LLM generation. - /// Returns the assistant_message_id of the generating placeholder. - Future discussArticle(int convId, int itemId) async { - try { - final response = await _dio.post( - '/api/briefing/articles/$itemId/discuss', - data: {'conv_id': convId}, - ); - final data = response.data as Map; - return data['assistant_message_id'] as int; - } on DioException catch (e) { - throw dioToApp(e); - } - } - - /// DELETE /api/briefing/rss-reactions/{rssItemId} - Future deleteRssReaction(int rssItemId) async { - try { - await _dio.delete('/api/briefing/rss-reactions/$rssItemId'); - } on DioException catch (e) { - throw dioToApp(e); - } - } -} diff --git a/lib/data/api/chat_api.dart b/lib/data/api/chat_api.dart index 79c599a..834c279 100644 --- a/lib/data/api/chat_api.dart +++ b/lib/data/api/chat_api.dart @@ -164,17 +164,4 @@ class ChatApi { } } - /// POST /api/chat/from-article/{itemId} - /// Creates or retrieves a chat conversation seeded with the article. - /// Returns the conversation_id. - Future openArticleInChat(int itemId) async { - try { - final response = - await _dio.post('/api/chat/from-article/$itemId', data: {}); - final data = response.data as Map; - return data['conversation_id'] as int; - } on DioException catch (e) { - throw dioToApp(e); - } - } } diff --git a/lib/data/api/journal_api.dart b/lib/data/api/journal_api.dart new file mode 100644 index 0000000..1147c67 --- /dev/null +++ b/lib/data/api/journal_api.dart @@ -0,0 +1,55 @@ +import 'package:dio/dio.dart'; + +import '../models/journal_day.dart'; +import 'api_client.dart'; + +class JournalApi { + final Dio _dio; + const JournalApi(this._dio); + + /// GET /api/journal/today + /// Creates today's journal conversation + daily prep on demand if absent, + /// then returns the day payload. + Future getToday() async { + try { + final response = await _dio.get('/api/journal/today'); + return JournalDay.fromJson(response.data as Map); + } on DioException catch (e) { + throw dioToApp(e); + } + } + + /// GET /api/journal/day/<iso_date> + Future getDay(String isoDate) async { + try { + final response = await _dio.get('/api/journal/day/$isoDate'); + return JournalDay.fromJson(response.data as Map); + } on DioException catch (e) { + throw dioToApp(e); + } + } + + /// GET /api/journal/days — list of dates with journal content, newest first. + Future> getDays() async { + try { + final response = await _dio.get('/api/journal/days'); + final data = response.data as Map; + final list = data['days'] as List; + return list.map((e) => e as String).toList(); + } on DioException catch (e) { + throw dioToApp(e); + } + } + + /// POST /api/journal/trigger-prep — force-regenerate today's daily prep + /// (or a specific day if [isoDate] is given). + Future triggerPrep({String? isoDate}) async { + try { + final body = {}; + if (isoDate != null) body['date'] = isoDate; + await _dio.post('/api/journal/trigger-prep', data: body); + } on DioException catch (e) { + throw dioToApp(e); + } + } +} diff --git a/lib/data/api/news_api.dart b/lib/data/api/news_api.dart deleted file mode 100644 index 3dc94d9..0000000 --- a/lib/data/api/news_api.dart +++ /dev/null @@ -1,52 +0,0 @@ -import 'package:dio/dio.dart'; - -import '../models/briefing_feed.dart'; -import '../models/news_item.dart'; -import 'api_client.dart'; - -class NewsApi { - final Dio _dio; - const NewsApi(this._dio); - - /// GET /api/briefing/news - /// Returns up to [limit] items starting at [offset], optionally filtered by [feedId]. - Future> getNewsItems({ - int days = 90, - int limit = 40, - int offset = 0, - int? feedId, - }) async { - try { - final params = { - 'days': days, - 'limit': limit, - 'offset': offset, - if (feedId != null) 'feed_id': feedId, - }; - final response = await _dio.get( - '/api/briefing/news', - queryParameters: params, - ); - final data = response.data as Map; - final list = data['items'] as List; - return list - .map((e) => NewsItem.fromJson(e as Map)) - .toList(); - } on DioException catch (e) { - throw dioToApp(e); - } - } - - /// GET /api/briefing/feeds - Future> getFeeds() async { - try { - final response = await _dio.get('/api/briefing/feeds'); - final list = response.data as List; - return list - .map((e) => BriefingFeed.fromJson(e as Map)) - .toList(); - } on DioException catch (e) { - throw dioToApp(e); - } - } -} diff --git a/lib/data/models/briefing_conversation.dart b/lib/data/models/briefing_conversation.dart deleted file mode 100644 index 31b6dd0..0000000 --- a/lib/data/models/briefing_conversation.dart +++ /dev/null @@ -1,35 +0,0 @@ -import 'message.dart'; - -class BriefingConversation { - final int id; - final String title; - final String? briefingDate; // YYYY-MM-DD or null - final List messages; - - const BriefingConversation({ - required this.id, - required this.title, - this.briefingDate, - required this.messages, - }); - - factory BriefingConversation.fromJson(Map json) { - final rawMessages = json['messages'] as List? ?? []; - return BriefingConversation( - id: json['id'] as int, - title: json['title'] as String? ?? '', - briefingDate: json['briefing_date'] as String?, - messages: rawMessages - .map((e) => Message.fromJson(e as Map)) - .toList(), - ); - } - - BriefingConversation copyWith({List? messages}) => - BriefingConversation( - id: id, - title: title, - briefingDate: briefingDate, - messages: messages ?? this.messages, - ); -} diff --git a/lib/data/models/briefing_feed.dart b/lib/data/models/briefing_feed.dart deleted file mode 100644 index 6bfc71e..0000000 --- a/lib/data/models/briefing_feed.dart +++ /dev/null @@ -1,20 +0,0 @@ -class BriefingFeed { - final int id; - final String title; - final String url; - final String? category; - - const BriefingFeed({ - required this.id, - required this.title, - required this.url, - this.category, - }); - - factory BriefingFeed.fromJson(Map json) => BriefingFeed( - id: json['id'] as int, - title: json['title'] as String? ?? '', - url: json['url'] as String? ?? '', - category: json['category'] as String?, - ); -} diff --git a/lib/data/models/journal_day.dart b/lib/data/models/journal_day.dart new file mode 100644 index 0000000..1b16610 --- /dev/null +++ b/lib/data/models/journal_day.dart @@ -0,0 +1,60 @@ +import 'message.dart'; + +/// Lightweight conversation header for a journal day — just enough to drive +/// navigation and labels. The full message list comes alongside in [JournalDay]. +class JournalConversation { + final int id; + final String title; + final String conversationType; + final String? dayDate; // YYYY-MM-DD or null + + const JournalConversation({ + required this.id, + required this.title, + required this.conversationType, + this.dayDate, + }); + + factory JournalConversation.fromJson(Map json) => + JournalConversation( + id: json['id'] as int, + title: json['title'] as String? ?? '', + conversationType: + json['conversation_type'] as String? ?? 'journal', + dayDate: json['day_date'] as String?, + ); +} + +/// Payload returned by GET /api/journal/today and /api/journal/day/<iso>. +/// `conversation` is null on a day with no journal content yet (rare — +/// the today endpoint creates it on demand). +class JournalDay { + final String dayDate; + final JournalConversation? conversation; + final List messages; + + const JournalDay({ + required this.dayDate, + required this.conversation, + required this.messages, + }); + + factory JournalDay.fromJson(Map json) { + final convRaw = json['conversation'] as Map?; + final rawMessages = json['messages'] as List? ?? []; + return JournalDay( + dayDate: json['day_date'] as String, + conversation: + convRaw == null ? null : JournalConversation.fromJson(convRaw), + messages: rawMessages + .map((e) => Message.fromJson(e as Map)) + .toList(), + ); + } + + JournalDay copyWith({List? messages}) => JournalDay( + dayDate: dayDate, + conversation: conversation, + messages: messages ?? this.messages, + ); +} diff --git a/lib/data/models/news_item.dart b/lib/data/models/news_item.dart deleted file mode 100644 index 23ca547..0000000 --- a/lib/data/models/news_item.dart +++ /dev/null @@ -1,37 +0,0 @@ -class NewsItem { - final int id; - final String title; - final String url; - final String snippet; - final String source; - final DateTime? publishedAt; - final List topics; - final String? reaction; - - const NewsItem({ - required this.id, - required this.title, - required this.url, - required this.snippet, - required this.source, - this.publishedAt, - required this.topics, - this.reaction, - }); - - factory NewsItem.fromJson(Map json) => NewsItem( - id: json['id'] as int, - title: json['title'] as String? ?? '', - url: json['url'] as String? ?? '', - snippet: json['snippet'] as String? ?? '', - source: json['source'] as String? ?? '', - publishedAt: json['published_at'] != null - ? DateTime.tryParse(json['published_at'] as String) - : null, - topics: (json['topics'] as List?) - ?.cast() - .toList() ?? - [], - reaction: json['reaction'] as String?, - ); -} diff --git a/lib/providers/api_client_provider.dart b/lib/providers/api_client_provider.dart index e84d6f5..5b0a50e 100644 --- a/lib/providers/api_client_provider.dart +++ b/lib/providers/api_client_provider.dart @@ -4,13 +4,12 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../data/api/api_client.dart'; import '../data/api/auth_api.dart'; -import '../data/api/briefing_api.dart'; import '../data/api/chat_api.dart'; +import '../data/api/journal_api.dart'; import '../data/api/knowledge_api.dart'; import '../data/api/voice_api.dart'; import '../data/api/milestones_api.dart'; import '../data/api/events_api.dart'; -import '../data/api/news_api.dart'; import '../data/api/notes_api.dart'; import '../data/api/projects_api.dart'; import '../data/api/quick_capture_api.dart'; @@ -97,8 +96,8 @@ final knowledgeRepositoryProvider = Provider((ref) { return KnowledgeRepository(ref.watch(knowledgeApiProvider)); }); -final briefingApiProvider = Provider((ref) { - return BriefingApi(ref.watch(dioProvider)); +final journalApiProvider = Provider((ref) { + return JournalApi(ref.watch(dioProvider)); }); final settingsApiProvider = Provider((ref) { @@ -113,10 +112,6 @@ final voiceRepositoryProvider = Provider((ref) { return VoiceRepository(ref.watch(voiceApiProvider)); }); -final newsApiProvider = Provider((ref) { - return NewsApi(ref.watch(dioProvider)); -}); - final eventsApiProvider = Provider((ref) { return EventsApi(ref.watch(dioProvider)); }); diff --git a/lib/providers/briefing_provider.dart b/lib/providers/journal_provider.dart similarity index 57% rename from lib/providers/briefing_provider.dart rename to lib/providers/journal_provider.dart index f5b1560..ef10a6e 100644 --- a/lib/providers/briefing_provider.dart +++ b/lib/providers/journal_provider.dart @@ -3,12 +3,12 @@ import 'dart:async'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../data/api/chat_api.dart'; -import '../data/models/briefing_conversation.dart'; +import '../data/models/journal_day.dart'; import '../data/models/message.dart'; import 'api_client_provider.dart'; -/// Drives the loading indicator in BriefingScreen's reply area. -final isBriefingStreamingProvider = +/// Drives the loading indicator in JournalScreen's reply area. +final isJournalStreamingProvider = NotifierProvider<_BoolNotifier, bool>(_BoolNotifier.new); class _BoolNotifier extends Notifier { @@ -16,23 +16,22 @@ class _BoolNotifier extends Notifier { bool build() => false; } -final briefingProvider = - AsyncNotifierProvider( - BriefingNotifier.new); +final journalProvider = + AsyncNotifierProvider(JournalNotifier.new); -class BriefingNotifier extends AsyncNotifier { +class JournalNotifier extends AsyncNotifier { @override - Future build() async { - return ref.read(briefingApiProvider).getToday(); + Future build() async { + return ref.read(journalApiProvider).getToday(); } - /// Silently fetch the latest briefing and patch state without triggering + /// 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(briefingApiProvider).getToday(); + 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 || @@ -40,26 +39,25 @@ class BriefingNotifier extends AsyncNotifier { state = AsyncData(fresh); } } catch (_) { - // Network hiccup — silently ignore, keep existing content + // 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); + /// Force-regenerate today's daily prep then reload. + Future regeneratePrep() async { + await ref.read(journalApiProvider).triggerPrep(); ref.invalidateSelf(); await future; } - /// Re-fetch the current briefing conversation and unfreeze a stuck - /// streaming state if the server-side message is already complete. + /// 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 (mobile network handoff, app backgrounded mid-stream, - /// reverse proxy dropping idle sockets) the send loop never observes close - /// and [isBriefingStreamingProvider] stays stuck true. This is the manual - /// recovery path hit by pull-to-refresh, the AppBar refresh button, and the - /// lifecycle-resume hook. + /// 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) { @@ -67,7 +65,7 @@ class BriefingNotifier extends AsyncNotifier { return; } try { - final fresh = await ref.read(briefingApiProvider).getToday(); + final fresh = await ref.read(journalApiProvider).getToday(); state = AsyncData(fresh); final messages = fresh.messages; Message? lastAssistant; @@ -78,46 +76,14 @@ class BriefingNotifier extends AsyncNotifier { } } if (lastAssistant != null && lastAssistant.status != 'generating') { - ref.read(isBriefingStreamingProvider.notifier).state = false; + ref.read(isJournalStreamingProvider.notifier).state = false; } } catch (_) { // Network hiccup — keep existing state; user can retry. } } - /// 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; - } - - final streamedContent = await _consumeStream(chatApi.streamGeneration(convId)); - await _pollUntilComplete(convId, streamedContent); - } - - /// Send a reply to today's briefing conversation. + /// Send a reply to today's journal conversation. /// /// Mirrors MessagesNotifier.sendMessage() in chat_provider with the same /// stall-watchdog pattern: @@ -126,12 +92,14 @@ class BriefingNotifier extends AsyncNotifier { /// 3. SSE stream with per-event timeout (stall watchdog) /// 4. Poll until complete Future sendReply(String content) async { - final conv = state.value; + 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 = conv.messages; + final previous = day.messages; final userMsg = Message( conversationId: convId, role: MessageRole.user, @@ -143,14 +111,14 @@ class BriefingNotifier extends AsyncNotifier { content: '', status: 'generating', ); - state = AsyncData(conv.copyWith(messages: [...previous, userMsg, placeholder])); - ref.read(isBriefingStreamingProvider.notifier).state = true; + state = AsyncData(day.copyWith(messages: [...previous, userMsg, placeholder])); + ref.read(isJournalStreamingProvider.notifier).state = true; try { await chatApi.sendMessage(convId, content); } catch (e) { - state = AsyncData(conv.copyWith(messages: previous)); - ref.read(isBriefingStreamingProvider.notifier).state = false; + state = AsyncData(day.copyWith(messages: previous)); + ref.read(isJournalStreamingProvider.notifier).state = false; rethrow; } @@ -158,19 +126,15 @@ class BriefingNotifier extends AsyncNotifier { await _pollUntilComplete(convId, streamedContent); } - /// Consume an SSE stream into the current briefing conversation state. + /// 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: the TCP - /// connection is half-closed, Dio never sees the close, and `await for` - /// hangs forever with [isBriefingStreamingProvider] stuck true. If no event - /// arrives within the watchdog window we bail out and let the polling pass - /// below reconcile state from the server. + /// 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 (the polling - /// pass uses this to decide whether it's safe to overwrite with a possibly - /// empty server-side row). + /// Returns whether any text content was actually streamed. Future _consumeStream(Stream stream) async { const stallTimeout = Duration(seconds: 45); bool streamedContent = false; @@ -208,24 +172,25 @@ class BriefingNotifier extends AsyncNotifier { return streamedContent; } - /// Poll /api/briefing messages until the last assistant row is complete, - /// same reconcile pattern as MessagesNotifier.sendMessage(). Always clears - /// [isBriefingStreamingProvider] at the end so the input can't stay locked. + /// 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 briefingApi = ref.read(briefingApiProvider); + 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 briefingApi.getMessages(convId); - final done = fresh.any( + final fresh = await journalApi.getToday(); + final freshMsgs = fresh.messages; + final done = freshMsgs.any( (m) => m.role == MessageRole.assistant && m.status != 'generating', ); - final hasContent = fresh.any( + 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: fresh)); + state = AsyncData(current.copyWith(messages: freshMsgs)); } if (done) break; } @@ -241,7 +206,7 @@ class BriefingNotifier extends AsyncNotifier { } } } finally { - ref.read(isBriefingStreamingProvider.notifier).state = false; + ref.read(isJournalStreamingProvider.notifier).state = false; } } } diff --git a/lib/providers/news_provider.dart b/lib/providers/news_provider.dart deleted file mode 100644 index 5377bdd..0000000 --- a/lib/providers/news_provider.dart +++ /dev/null @@ -1,177 +0,0 @@ -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import '../data/models/briefing_feed.dart'; -import '../data/models/news_item.dart'; -import 'api_client_provider.dart'; - -// ─── NewsState ──────────────────────────────────────────────────────────────── - -class NewsState { - final List items; - final int offset; - final bool hasMore; - final bool loadingMore; - final int? selectedFeedId; - final Map reactions; - - const NewsState({ - required this.items, - required this.offset, - required this.hasMore, - required this.loadingMore, - required this.selectedFeedId, - required this.reactions, - }); - - NewsState copyWith({ - List? items, - int? offset, - bool? hasMore, - bool? loadingMore, - Object? selectedFeedId = _sentinel, - Map? reactions, - }) { - return NewsState( - items: items ?? this.items, - offset: offset ?? this.offset, - hasMore: hasMore ?? this.hasMore, - loadingMore: loadingMore ?? this.loadingMore, - selectedFeedId: selectedFeedId == _sentinel - ? this.selectedFeedId - : selectedFeedId as int?, - reactions: reactions ?? this.reactions, - ); - } -} - -const _sentinel = Object(); - -// ─── NewsNotifier ───────────────────────────────────────────────────────────── - -final newsProvider = - AsyncNotifierProvider(NewsNotifier.new); - -class NewsNotifier extends AsyncNotifier { - static const _limit = 40; - - @override - Future build() async { - final items = await ref.watch(newsApiProvider).getNewsItems( - days: 90, - limit: _limit, - offset: 0, - ); - return NewsState( - items: items, - offset: items.length, - hasMore: items.length == _limit, - loadingMore: false, - selectedFeedId: null, - reactions: {for (final item in items) item.id: item.reaction}, - ); - } - - /// Re-fetch the first page without clearing state (no flicker). - Future refresh() async { - final current = state.value; - final items = await ref.read(newsApiProvider).getNewsItems( - days: 90, - limit: _limit, - offset: 0, - feedId: current?.selectedFeedId, - ); - state = AsyncData(NewsState( - items: items, - offset: items.length, - hasMore: items.length == _limit, - loadingMore: false, - selectedFeedId: current?.selectedFeedId, - reactions: {for (final item in items) item.id: item.reaction}, - )); - } - - Future loadMore() async { - final current = state.value; - if (current == null || current.loadingMore || !current.hasMore) return; - state = AsyncData(current.copyWith(loadingMore: true)); - try { - final items = await ref.read(newsApiProvider).getNewsItems( - days: 90, - limit: _limit, - offset: current.offset, - feedId: current.selectedFeedId, - ); - final updatedReactions = Map.from(current.reactions); - for (final item in items) { - updatedReactions.putIfAbsent(item.id, () => item.reaction); - } - state = AsyncData(current.copyWith( - items: [...current.items, ...items], - offset: current.offset + items.length, - hasMore: items.length == _limit, - loadingMore: false, - reactions: updatedReactions, - )); - } catch (e) { - final recovered = state.value ?? current; - state = AsyncData(recovered.copyWith(loadingMore: false)); - rethrow; - } - } - - Future setFeed(int? feedId) async { - state = const AsyncLoading(); - try { - final items = await ref.read(newsApiProvider).getNewsItems( - days: 90, - limit: _limit, - offset: 0, - feedId: feedId, - ); - state = AsyncData(NewsState( - items: items, - offset: items.length, - hasMore: items.length == _limit, - loadingMore: false, - selectedFeedId: feedId, - reactions: {for (final item in items) item.id: item.reaction}, - )); - } catch (e, st) { - state = AsyncError(e, st); - } - } - - void toggleReaction(int itemId, String reaction) { - final current = state.value; - if (current == null) return; - final prev = current.reactions[itemId]; - final next = prev == reaction ? null : reaction; - state = AsyncData(current.copyWith( - reactions: {...current.reactions, itemId: next}, - )); - final briefingApi = ref.read(briefingApiProvider); - final future = next == null - ? briefingApi.deleteRssReaction(itemId) - : briefingApi.postRssReaction(itemId, next); - future.catchError((_) { - final s = state.value; - if (s != null) { - state = AsyncData(s.copyWith( - reactions: {...s.reactions, itemId: prev}, - )); - } - }); - } -} - -// ─── FeedsNotifier ──────────────────────────────────────────────────────────── - -final feedsProvider = - AsyncNotifierProvider>(FeedsNotifier.new); - -class FeedsNotifier extends AsyncNotifier> { - @override - Future> build() async { - return ref.watch(newsApiProvider).getFeeds(); - } -} diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart index 70385df..3355a93 100644 --- a/lib/providers/settings_provider.dart +++ b/lib/providers/settings_provider.dart @@ -125,17 +125,7 @@ class ServerSettingsNotifier extends AsyncNotifier> { } } - bool get rssEnabled { - final data = state.value ?? {}; - return data['rss_enabled']?.toString().toLowerCase() == 'true'; - } - Future refresh() async { state = AsyncData(await ref.read(settingsApiProvider).getAll()); } } - -final rssEnabledProvider = Provider((ref) { - final settings = ref.watch(serverSettingsProvider).value ?? {}; - return settings['rss_enabled']?.toString().toLowerCase() == 'true'; -}); diff --git a/lib/screens/auth/login_screen.dart b/lib/screens/auth/login_screen.dart index b8101d1..832c7f6 100644 --- a/lib/screens/auth/login_screen.dart +++ b/lib/screens/auth/login_screen.dart @@ -72,7 +72,7 @@ class _LoginScreenState extends ConsumerState { _usernameController.text.trim(), _passwordController.text, ); - if (mounted) context.go(Routes.briefing); + if (mounted) context.go(Routes.journal); } on AuthException catch (e) { setState(() => _error = e.message); } on AppException catch (e) { @@ -90,7 +90,7 @@ class _LoginScreenState extends ConsumerState { cookieJar: ref.read(cookieJarProvider), onSuccess: () async { await ref.read(authProvider.notifier).verify(); - if (mounted) context.go(Routes.briefing); + if (mounted) context.go(Routes.journal); }, ), )); diff --git a/lib/screens/briefing/briefing_history_screen.dart b/lib/screens/briefing/briefing_history_screen.dart deleted file mode 100644 index f40d66b..0000000 --- a/lib/screens/briefing/briefing_history_screen.dart +++ /dev/null @@ -1,88 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import '../../data/models/briefing_conversation.dart'; -import '../../data/models/message.dart'; -import '../../providers/api_client_provider.dart'; -import '../../widgets/chat_message_bubble.dart'; - -class BriefingHistoryScreen extends ConsumerWidget { - const BriefingHistoryScreen({super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final historyAsync = ref.watch(_briefingHistoryProvider); - - return Scaffold( - appBar: AppBar(title: const Text('Past Briefings')), - body: historyAsync.when( - loading: () => const Center(child: CircularProgressIndicator()), - error: (_, _) => - const Center(child: Text('Could not load briefing history.')), - data: (convs) { - if (convs.isEmpty) { - return const Center(child: Text('No past briefings.')); - } - return ListView.builder( - itemCount: convs.length, - itemBuilder: (context, i) { - final conv = convs[i]; - final label = conv.briefingDate ?? conv.title; - return ListTile( - leading: const Icon(Icons.wb_sunny_outlined), - title: Text(label), - trailing: const Icon(Icons.chevron_right), - onTap: () => Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => _BriefingDetailScreen(conv: conv), - ), - ), - ); - }, - ); - }, - ), - ); - } -} - -/// Lazily loads and displays all messages for a past briefing. -class _BriefingDetailScreen extends ConsumerWidget { - final BriefingConversation conv; - const _BriefingDetailScreen({required this.conv}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final messagesAsync = ref.watch(_briefingMessagesProvider(conv.id)); - - return Scaffold( - appBar: AppBar(title: Text(conv.briefingDate ?? conv.title)), - body: messagesAsync.when( - loading: () => const Center(child: CircularProgressIndicator()), - error: (_, _) => const Center(child: Text('Could not load messages.')), - data: (messages) { - if (messages.isEmpty) { - return const Center(child: Text('No messages.')); - } - return ListView.builder( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 12), - itemCount: messages.length, - itemBuilder: (_, i) => ChatMessageBubble(message: messages[i]), - ); - }, - ), - ); - } -} - -// ── Private providers (scoped to this file) ────────────────────────────────── - -final _briefingHistoryProvider = - FutureProvider>((ref) async { - return ref.watch(briefingApiProvider).getHistory(); -}); - -final _briefingMessagesProvider = - FutureProvider.family, int>((ref, convId) async { - return ref.watch(briefingApiProvider).getMessages(convId); -}); diff --git a/lib/screens/journal/journal_history_screen.dart b/lib/screens/journal/journal_history_screen.dart new file mode 100644 index 0000000..d88724b --- /dev/null +++ b/lib/screens/journal/journal_history_screen.dart @@ -0,0 +1,85 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../data/models/journal_day.dart'; +import '../../providers/api_client_provider.dart'; +import '../../widgets/chat_message_bubble.dart'; + +class JournalHistoryScreen extends ConsumerWidget { + const JournalHistoryScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final daysAsync = ref.watch(_journalDaysProvider); + + return Scaffold( + appBar: AppBar(title: const Text('Past Days')), + body: daysAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (_, _) => + const Center(child: Text('Could not load journal history.')), + data: (days) { + if (days.isEmpty) { + return const Center(child: Text('No past days.')); + } + return ListView.builder( + itemCount: days.length, + itemBuilder: (context, i) { + final isoDate = days[i]; + return ListTile( + leading: const Icon(Icons.menu_book_outlined), + title: Text(isoDate), + trailing: const Icon(Icons.chevron_right), + onTap: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => _JournalDayDetailScreen(isoDate: isoDate), + ), + ), + ); + }, + ); + }, + ), + ); + } +} + +class _JournalDayDetailScreen extends ConsumerWidget { + final String isoDate; + const _JournalDayDetailScreen({required this.isoDate}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final dayAsync = ref.watch(_journalDayProvider(isoDate)); + + return Scaffold( + appBar: AppBar(title: Text(isoDate)), + body: dayAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (_, _) => + const Center(child: Text('Could not load that day.')), + data: (day) { + if (day.messages.isEmpty) { + return const Center(child: Text('No messages.')); + } + return ListView.builder( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 12), + itemCount: day.messages.length, + itemBuilder: (_, i) => ChatMessageBubble(message: day.messages[i]), + ); + }, + ), + ); + } +} + +// ── Private providers (scoped to this file) ────────────────────────────────── + +final _journalDaysProvider = FutureProvider>((ref) async { + return ref.watch(journalApiProvider).getDays(); +}); + +final _journalDayProvider = + FutureProvider.family((ref, isoDate) async { + return ref.watch(journalApiProvider).getDay(isoDate); +}); diff --git a/lib/screens/briefing/briefing_screen.dart b/lib/screens/journal/journal_screen.dart similarity index 60% rename from lib/screens/briefing/briefing_screen.dart rename to lib/screens/journal/journal_screen.dart index 90fcfb0..df46534 100644 --- a/lib/screens/briefing/briefing_screen.dart +++ b/lib/screens/journal/journal_screen.dart @@ -5,30 +5,25 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../core/exceptions.dart'; import '../../data/models/message.dart'; -import '../../providers/briefing_provider.dart'; -import '../../providers/api_client_provider.dart'; -import '../../widgets/chat_message_bubble.dart'; -import '../../widgets/weather_card.dart'; -import '../../widgets/news_card.dart'; -import 'briefing_history_screen.dart'; -import '../../providers/settings_provider.dart'; +import '../../providers/journal_provider.dart'; import '../../providers/voice_provider.dart'; +import '../../widgets/chat_message_bubble.dart'; import '../../widgets/voice_mic_button.dart'; +import '../../widgets/weather_card.dart'; +import 'journal_history_screen.dart'; -class BriefingScreen extends ConsumerStatefulWidget { - const BriefingScreen({super.key}); +class JournalScreen extends ConsumerStatefulWidget { + const JournalScreen({super.key}); @override - ConsumerState createState() => _BriefingScreenState(); + ConsumerState createState() => _JournalScreenState(); } -class _BriefingScreenState extends ConsumerState +class _JournalScreenState extends ConsumerState with WidgetsBindingObserver { final _controller = TextEditingController(); final _scrollController = ScrollController(); bool _refreshing = false; - // rss_item_id -> 'up' | 'down' | null - final Map _reactions = {}; Timer? _pollTimer; bool _appInForeground = true; @@ -37,31 +32,29 @@ class _BriefingScreenState extends ConsumerState void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); - _pollTimer = Timer.periodic(const Duration(seconds: 60), (_) => _pollSilently()); + _pollTimer = + Timer.periodic(const Duration(seconds: 60), (_) => _pollSilently()); } @override void didChangeAppLifecycleState(AppLifecycleState state) { final wasBackground = !_appInForeground; _appInForeground = state == AppLifecycleState.resumed; - // On resume, force a message refresh. This also unfreezes a stuck - // streaming state if the SSE socket died while the app was backgrounded - // — silentRefresh won't do that because it guards on isStreaming. if (_appInForeground && wasBackground && mounted) { - ref.read(briefingProvider.notifier).refreshMessages(); + ref.read(journalProvider.notifier).refreshMessages(); } } void _pollSilently() { if (!_appInForeground || !mounted) return; - final isStreaming = ref.read(isBriefingStreamingProvider); + final isStreaming = ref.read(isJournalStreamingProvider); if (isStreaming) return; - ref.read(briefingProvider.notifier).silentRefresh(); + ref.read(journalProvider.notifier).silentRefresh(); } Future _pullToRefresh() async { try { - await ref.read(briefingProvider.notifier).refreshMessages(); + await ref.read(journalProvider.notifier).refreshMessages(); } catch (_) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( @@ -97,7 +90,7 @@ class _BriefingScreenState extends ConsumerState if (text.isEmpty) return; _controller.clear(); try { - await ref.read(briefingProvider.notifier).sendReply(text); + await ref.read(journalProvider.notifier).sendReply(text); } on AppException catch (e) { if (mounted) { ScaffoldMessenger.of(context) @@ -112,39 +105,6 @@ class _BriefingScreenState extends ConsumerState } } - Future _handleDiscuss(int convId, int itemId) async { - try { - await ref.read(briefingProvider.notifier).discussArticle(convId, itemId); - } on AppException catch (e) { - if (mounted) { - ScaffoldMessenger.of(context) - .showSnackBar(SnackBar(content: Text(e.message))); - } - } catch (_) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Failed to start discussion.')), - ); - } - } - } - - Future _handleReaction(int itemId, String reaction) async { - final current = _reactions[itemId]; - final next = current == reaction ? null : reaction; - setState(() => _reactions[itemId] = next); - final api = ref.read(briefingApiProvider); - try { - if (next == null) { - await api.deleteRssReaction(itemId); - } else { - await api.postRssReaction(itemId, reaction); - } - } catch (_) { - setState(() => _reactions[itemId] = current); - } - } - Future _toggleVoiceMode() async { final voice = ref.read(voiceProvider); if (voice.voiceModeActive) { @@ -153,7 +113,7 @@ class _BriefingScreenState extends ConsumerState } await ref.read(voiceProvider.notifier).enterVoiceMode( onTranscript: (transcript) async { - await ref.read(briefingProvider.notifier).sendReply(transcript); + await ref.read(journalProvider.notifier).sendReply(transcript); }, enableTts: true, onError: (msg) { @@ -168,11 +128,11 @@ class _BriefingScreenState extends ConsumerState Future _refresh() async { setState(() => _refreshing = true); try { - await ref.read(briefingProvider.notifier).refresh('compilation'); + await ref.read(journalProvider.notifier).regeneratePrep(); } catch (_) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Could not generate briefing.')), + const SnackBar(content: Text('Could not regenerate prep.')), ); } } finally { @@ -182,20 +142,19 @@ class _BriefingScreenState extends ConsumerState @override Widget build(BuildContext context) { - final briefingAsync = ref.watch(briefingProvider); - final isStreaming = ref.watch(isBriefingStreamingProvider); + final journalAsync = ref.watch(journalProvider); + final isStreaming = ref.watch(isJournalStreamingProvider); final voiceState = ref.watch(voiceProvider); final scheme = Theme.of(context).colorScheme; - // Scroll to bottom when messages change - ref.listen(briefingProvider, (prev, next) => _scrollToBottom()); + ref.listen(journalProvider, (prev, next) => _scrollToBottom()); // Feed streaming assistant content to VoiceNotifier for TTS. - ref.listen(briefingProvider, (prev, next) { + ref.listen(journalProvider, (prev, next) { if (!voiceState.voiceModeActive) return; - final conv = next.value; - if (conv == null || conv.messages.isEmpty) return; - final last = conv.messages.last; + final day = next.value; + if (day == null || day.messages.isEmpty) return; + final last = day.messages.last; if (last.role != MessageRole.assistant) return; final isComplete = last.status != 'generating'; ref @@ -208,7 +167,7 @@ class _BriefingScreenState extends ConsumerState title: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text('Briefing', style: Theme.of(context).textTheme.titleLarge), + Text('Journal', style: Theme.of(context).textTheme.titleLarge), Text( _todayLabel(), style: Theme.of(context).textTheme.labelSmall?.copyWith( @@ -230,42 +189,42 @@ class _BriefingScreenState extends ConsumerState else IconButton( icon: const Icon(Icons.refresh_outlined), - tooltip: 'Generate briefing', + tooltip: 'Regenerate prep', onPressed: _refresh, ), PopupMenuButton( onSelected: (value) { if (value == 'history') { Navigator.of(context).push(MaterialPageRoute( - builder: (_) => const BriefingHistoryScreen(), + builder: (_) => const JournalHistoryScreen(), )); } }, itemBuilder: (_) => const [ PopupMenuItem( value: 'history', - child: Text('View past briefings'), + child: Text('Past days'), ), ], ), ], ), - body: briefingAsync.when( + body: journalAsync.when( loading: () => const Center(child: CircularProgressIndicator()), error: (err, stack) => Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ - const Text("Could not load today's briefing."), + const Text("Could not load today's journal."), const SizedBox(height: 12), FilledButton.tonal( - onPressed: () => ref.invalidate(briefingProvider), + onPressed: () => ref.invalidate(journalProvider), child: const Text('Retry'), ), ], ), ), - data: (conv) { + data: (day) { final isWide = MediaQuery.of(context).size.width >= 600; Widget body = Column( children: [ @@ -274,64 +233,49 @@ class _BriefingScreenState extends ConsumerState onRefresh: _pullToRefresh, child: CustomScrollView( controller: _scrollController, - // AlwaysScrollable so pull-to-refresh fires even when - // the briefing is empty or shorter than the viewport. physics: const AlwaysScrollableScrollPhysics(), slivers: [ - if (conv.messages.isEmpty) - SliverFillRemaining( - child: Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - 'No briefing yet today.', - style: Theme.of(context) - .textTheme - .bodyMedium - ?.copyWith(color: scheme.onSurfaceVariant), - ), - const SizedBox(height: 12), - FilledButton.tonal( - onPressed: _refresh, - child: const Text('Generate now'), - ), - ], + if (day.messages.isEmpty) + SliverFillRemaining( + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'No prep yet today.', + style: Theme.of(context) + .textTheme + .bodyMedium + ?.copyWith(color: scheme.onSurfaceVariant), + ), + const SizedBox(height: 12), + FilledButton.tonal( + onPressed: _refresh, + child: const Text('Generate now'), + ), + ], + ), + ), + ) + else + SliverPadding( + padding: const EdgeInsets.symmetric( + horizontal: 8, vertical: 8), + sliver: SliverList.builder( + itemCount: day.messages.length, + itemBuilder: (_, i) => + _JournalMessageItem(message: day.messages[i]), ), ), - ) - else - SliverPadding( - padding: const EdgeInsets.symmetric( - horizontal: 8, vertical: 8), - sliver: SliverList.builder( - itemCount: conv.messages.length, - itemBuilder: (_, i) { - final msg = conv.messages[i]; - return _BriefingMessageItem( - message: msg, - convId: conv.id, - reactions: _reactions, - onReaction: _handleReaction, - onDiscuss: _handleDiscuss, - rssEnabled: ref.watch(rssEnabledProvider), - ); - }, - ), - ), ], ), ), ), - - // Progress bar while streaming if (isStreaming) LinearProgressIndicator( minHeight: 2, color: scheme.primary, ), - - // Voice mode banner if (voiceState.voiceModeActive) Container( width: double.infinity, @@ -346,7 +290,6 @@ class _BriefingScreenState extends ConsumerState ), ), ), - // Reply bar const Divider(height: 1), SafeArea( child: Padding( @@ -359,7 +302,7 @@ class _BriefingScreenState extends ConsumerState decoration: InputDecoration( hintText: voiceState.voiceModeActive ? 'Listening…' - : 'Reply to your briefing…', + : 'Tell your journal…', hintStyle: voiceState.voiceModeActive ? const TextStyle(fontStyle: FontStyle.italic) : null, @@ -422,65 +365,46 @@ class _BriefingScreenState extends ConsumerState } } -/// Renders a single briefing message with optional WeatherCard above it -/// and RSS reaction buttons below it (for assistant messages with metadata). -class _BriefingMessageItem extends StatelessWidget { +/// Renders a single journal message. For the daily-prep assistant message, +/// also renders a WeatherCard above the bubble (weather lives in the +/// nested metadata.sections.weather payload). +class _JournalMessageItem extends StatelessWidget { final Message message; - final int convId; - final Map reactions; - final void Function(int itemId, String reaction) onReaction; - final void Function(int convId, int itemId) onDiscuss; - final bool rssEnabled; - const _BriefingMessageItem({ - required this.message, - required this.convId, - required this.reactions, - required this.onReaction, - required this.onDiscuss, - this.rssEnabled = false, - }); + const _JournalMessageItem({required this.message}); @override Widget build(BuildContext context) { final meta = message.metadata; final isAssistant = message.role == MessageRole.assistant; + final isPrep = isAssistant && + meta != null && + meta['kind'] == 'daily_prep'; - // Weather: show card above when metadata.weather key is present (even if null value) - final bool hasWeatherKey = isAssistant && meta != null && meta.containsKey('weather'); - final weatherData = hasWeatherKey ? meta['weather'] as Map? : null; - - // RSS news cards — cap at 3 (only when RSS is enabled) - final rssItems = []; - if (rssEnabled && isAssistant && meta != null) { - final raw = (meta['rss_items'] as List?)?.cast>() ?? []; - rssItems.addAll(raw.map(RssItemMeta.fromJson).take(3)); + Map? weatherData; + if (isPrep) { + // The journal prep stores its structured data under metadata.sections. + final sections = meta['sections'] as Map?; + final weather = sections?['weather']; + if (weather is List && weather.isNotEmpty) { + // Show the first location's weather card. The widget expects a + // single location dict; we pass the first one through. + weatherData = weather.first as Map?; + } else if (weather is Map) { + weatherData = weather as Map; + } } return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - if (hasWeatherKey) WeatherCard(weather: weatherData), + if (weatherData != null) WeatherCard(weather: weatherData), ChatMessageBubble(message: message), - if (rssItems.isNotEmpty) - Padding( - padding: const EdgeInsets.fromLTRB(4, 4, 4, 4), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: rssItems.map((item) => NewsCard( - item: item, - reaction: reactions[item.id], - onReaction: onReaction, - onDiscuss: () => onDiscuss(convId, item.id), - )).toList(), - ), - ), ], ); } } - class _GradientSendButton extends StatelessWidget { final VoidCallback? onPressed; final bool isStreaming; diff --git a/lib/screens/news/news_screen.dart b/lib/screens/news/news_screen.dart deleted file mode 100644 index 18dcd1c..0000000 --- a/lib/screens/news/news_screen.dart +++ /dev/null @@ -1,236 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:go_router/go_router.dart'; - -import '../../core/constants.dart'; -import '../../data/models/briefing_feed.dart'; -import '../../providers/api_client_provider.dart'; -import '../../providers/news_provider.dart'; -import '../../widgets/news_card.dart'; - -class NewsScreen extends ConsumerStatefulWidget { - const NewsScreen({super.key}); - - @override - ConsumerState createState() => _NewsScreenState(); -} - -class _NewsScreenState extends ConsumerState { - final Set _openingChat = {}; - - Future _handleDiscuss(int itemId) async { - if (_openingChat.contains(itemId)) return; - setState(() => _openingChat.add(itemId)); - try { - final conversationId = - await ref.read(chatApiProvider).openArticleInChat(itemId); - if (mounted) { - context.push(Routes.chat.replaceFirst(':id', '$conversationId')); - } - } catch (_) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Failed to open article in chat.')), - ); - } - } finally { - if (mounted) setState(() => _openingChat.remove(itemId)); - } - } - - Future _loadMore() async { - try { - await ref.read(newsProvider.notifier).loadMore(); - } catch (_) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Failed to load more articles.')), - ); - } - } - } - - Widget _buildNewsItem(NewsState news, int i, {int cols = 1}) { - if (i >= news.items.length) { - if (!news.hasMore) return const SizedBox.shrink(); - return Padding( - padding: const EdgeInsets.symmetric(vertical: 8), - child: Center( - child: news.loadingMore - ? const CircularProgressIndicator() - : FilledButton.tonal( - onPressed: _loadMore, - child: const Text('Load more'), - ), - ), - ); - } - final item = news.items[i]; - return NewsCard( - item: RssItemMeta.fromNewsItem(item), - reaction: news.reactions[item.id], - snippetMaxLines: cols > 1 ? 5 : 2, - onReaction: (itemId, reaction) => - ref.read(newsProvider.notifier).toggleReaction(itemId, reaction), - onDiscuss: _openingChat.contains(item.id) - ? null - : () => _handleDiscuss(item.id), - ); - } - - @override - Widget build(BuildContext context) { - final newsAsync = ref.watch(newsProvider); - final feedsAsync = ref.watch(feedsProvider); - final scheme = Theme.of(context).colorScheme; - - return Scaffold( - appBar: AppBar( - title: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('News', style: Theme.of(context).textTheme.titleLarge), - Text( - 'Last 90 days', - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: scheme.onSurfaceVariant, - ), - ), - ], - ), - ), - body: newsAsync.when( - loading: () => const Center(child: CircularProgressIndicator()), - error: (e, _) => Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Text('Could not load news.'), - const SizedBox(height: 12), - FilledButton.tonal( - onPressed: () => ref.invalidate(newsProvider), - child: const Text('Retry'), - ), - ], - ), - ), - data: (news) => Column( - children: [ - _FeedFilter( - feeds: feedsAsync.value ?? [], - selectedFeedId: news.selectedFeedId, - onChanged: (feedId) => - ref.read(newsProvider.notifier).setFeed(feedId), - ), - const Divider(height: 1), - Expanded( - child: RefreshIndicator( - onRefresh: () => ref.read(newsProvider.notifier).refresh(), - child: LayoutBuilder( - builder: (context, constraints) { - final cols = constraints.maxWidth >= 900 - ? 3 - : constraints.maxWidth >= 600 - ? 2 - : 1; - if (cols == 1) { - return ListView.builder( - padding: const EdgeInsets.symmetric( - horizontal: 8, vertical: 8), - itemCount: news.items.length + 1, - itemBuilder: (_, i) => - _buildNewsItem(news, i), - ); - } - return CustomScrollView( - slivers: [ - SliverPadding( - padding: const EdgeInsets.all(8), - sliver: SliverGrid( - gridDelegate: - SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: cols, - crossAxisSpacing: 8, - mainAxisSpacing: 8, - childAspectRatio: 1.6, - ), - delegate: SliverChildBuilderDelegate( - (_, i) => _buildNewsItem(news, i, cols: cols), - childCount: news.items.length, - ), - ), - ), - if (news.hasMore) - SliverToBoxAdapter( - child: Padding( - padding: - const EdgeInsets.symmetric(vertical: 12), - child: Center( - child: news.loadingMore - ? const CircularProgressIndicator() - : FilledButton.tonal( - onPressed: _loadMore, - child: const Text('Load more'), - ), - ), - ), - ), - ], - ); - }, - ), - ), - ), - ], - ), - ), - ); - } -} - -class _FeedFilter extends StatelessWidget { - final List feeds; - final int? selectedFeedId; - final void Function(int? feedId) onChanged; - - const _FeedFilter({ - required this.feeds, - required this.selectedFeedId, - required this.onChanged, - }); - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.fromLTRB(16, 6, 16, 6), - child: Row( - children: [ - Text( - 'Feed:', - style: Theme.of(context).textTheme.labelMedium?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(width: 8), - DropdownButton( - value: selectedFeedId, - underline: const SizedBox.shrink(), - items: [ - const DropdownMenuItem( - value: null, - child: Text('All feeds'), - ), - ...feeds.map( - (f) => DropdownMenuItem( - value: f.id, - child: Text(f.title), - ), - ), - ], - onChanged: (v) => onChanged(v), - ), - ], - ), - ); - } -} diff --git a/lib/screens/splash/splash_screen.dart b/lib/screens/splash/splash_screen.dart index c096ab5..b1e77cf 100644 --- a/lib/screens/splash/splash_screen.dart +++ b/lib/screens/splash/splash_screen.dart @@ -31,11 +31,11 @@ class _SplashScreenState extends ConsumerState { final status = ref.read(authProvider); final hasEverLoggedIn = ref.read(hasEverLoggedInProvider); if (status == AuthStatus.authenticated) { - context.go(Routes.briefing); + context.go(Routes.journal); } else if (status == AuthStatus.offline && hasEverLoggedIn) { // Server unreachable but this user has logged in before — land them on // the briefing with the offline banner rather than the login screen. - context.go(Routes.briefing); + context.go(Routes.journal); } else { context.go(Routes.login); } diff --git a/lib/widgets/briefing_digest_card.dart b/lib/widgets/journal_prep_card.dart similarity index 88% rename from lib/widgets/briefing_digest_card.dart rename to lib/widgets/journal_prep_card.dart index 287e042..0e44630 100644 --- a/lib/widgets/briefing_digest_card.dart +++ b/lib/widgets/journal_prep_card.dart @@ -3,24 +3,25 @@ import 'package:flutter_markdown_plus/flutter_markdown_plus.dart'; import '../data/models/message.dart'; -class BriefingDigestCard extends StatefulWidget { - /// The first assistant message from today's briefing, or null if none yet. +class JournalPrepCard extends StatefulWidget { + /// The first assistant message from today's journal — the daily prep + /// (LLM-generated briefing-style opener), or null if not yet generated. final Message? message; /// Called when the user taps "Generate now". final VoidCallback? onGenerateNow; - const BriefingDigestCard({ + const JournalPrepCard({ super.key, required this.message, this.onGenerateNow, }); @override - State createState() => _BriefingDigestCardState(); + State createState() => _JournalPrepCardState(); } -class _BriefingDigestCardState extends State { +class _JournalPrepCardState extends State { bool _expanded = false; @override @@ -43,10 +44,9 @@ class _BriefingDigestCardState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Header row Row( children: [ - Icon(Icons.wb_sunny_outlined, size: 18, color: scheme.primary), + Icon(Icons.menu_book_outlined, size: 18, color: scheme.primary), const SizedBox(width: 8), Text( _todayLabel(), @@ -57,11 +57,9 @@ class _BriefingDigestCardState extends State { ], ), const SizedBox(height: 10), - - // Body if (widget.message == null) ...[ Text( - 'No briefing yet today.', + 'No prep yet today.', style: textTheme.bodyMedium?.copyWith( color: scheme.onSurfaceVariant, ), diff --git a/lib/widgets/news_card.dart b/lib/widgets/news_card.dart deleted file mode 100644 index 9363c4b..0000000 --- a/lib/widgets/news_card.dart +++ /dev/null @@ -1,234 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:url_launcher/url_launcher.dart'; - -import '../data/models/news_item.dart'; - -class RssItemMeta { - final int id; - final String title; - final String url; - final String source; - final String snippet; - final DateTime? publishedAt; - - const RssItemMeta({ - required this.id, - required this.title, - required this.url, - required this.source, - required this.snippet, - this.publishedAt, - }); - - factory RssItemMeta.fromJson(Map json) => RssItemMeta( - id: json['id'] as int, - title: json['title'] as String? ?? '', - url: json['url'] as String? ?? '', - source: json['source'] as String? ?? '', - snippet: json['snippet'] as String? ?? '', - publishedAt: json['published_at'] != null - ? DateTime.tryParse(json['published_at'] as String) - : null, - ); - - factory RssItemMeta.fromNewsItem(NewsItem item) => RssItemMeta( - id: item.id, - title: item.title, - url: item.url, - source: item.source, - snippet: item.snippet, - publishedAt: item.publishedAt, - ); - - String get relativeDate { - if (publishedAt == null) return ''; - final diff = DateTime.now().difference(publishedAt!); - if (diff.inHours < 24) return '${diff.inHours}h ago'; - if (diff.inHours < 48) return 'Yesterday'; - return '${publishedAt!.month}/${publishedAt!.day}'; - } -} - -class NewsCard extends StatelessWidget { - final RssItemMeta item; - final String? reaction; // 'up' | 'down' | null - final void Function(int itemId, String reaction) onReaction; - final VoidCallback? onDiscuss; - final int snippetMaxLines; - - const NewsCard({ - super.key, - required this.item, - required this.reaction, - required this.onReaction, - this.onDiscuss, - this.snippetMaxLines = 2, - }); - - Future _openUrl() async { - if (item.url.isEmpty) return; - final uri = Uri.tryParse(item.url); - if (uri != null && await canLaunchUrl(uri)) { - await launchUrl(uri, mode: LaunchMode.externalApplication); - } - } - - @override - Widget build(BuildContext context) { - final scheme = Theme.of(context).colorScheme; - final textTheme = Theme.of(context).textTheme; - - return Card( - margin: const EdgeInsets.symmetric(vertical: 4), - elevation: 0, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10), - side: BorderSide(color: scheme.outlineVariant), - ), - child: Padding( - padding: const EdgeInsets.fromLTRB(12, 10, 12, 10), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Source + date row - Row( - children: [ - if (item.source.isNotEmpty) - Text( - item.source.toUpperCase(), - style: textTheme.labelSmall?.copyWith( - color: scheme.primary, - fontWeight: FontWeight.w700, - letterSpacing: 0.6, - ), - ), - if (item.source.isNotEmpty && item.relativeDate.isNotEmpty) - Padding( - padding: const EdgeInsets.only(left: 8), - child: Text( - item.relativeDate, - style: textTheme.labelSmall?.copyWith( - color: scheme.onSurfaceVariant, - ), - ), - ), - ], - ), - const SizedBox(height: 4), - // Title — tappable if URL present. Text stays in onSurface for - // contrast; the primary-colored underline carries the "this is a - // link" signal without tanking readability. - GestureDetector( - onTap: item.url.isNotEmpty ? _openUrl : null, - child: Text( - item.title, - style: textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w600, - color: scheme.onSurface, - height: 1.3, - decoration: item.url.isNotEmpty ? TextDecoration.underline : null, - decorationColor: scheme.primary.withValues(alpha: 0.7), - decorationThickness: 1.5, - ), - ), - ), - // Snippet - if (item.snippet.isNotEmpty) ...[ - const SizedBox(height: 4), - Text( - item.snippet, - maxLines: snippetMaxLines, - overflow: TextOverflow.ellipsis, - style: textTheme.bodySmall?.copyWith( - color: scheme.onSurfaceVariant, - height: 1.45, - ), - ), - ], - const SizedBox(height: 8), - // Actions row: reactions + discuss - Row( - children: [ - _ReactionButton( - emoji: '👍', - active: reaction == 'up', - onTap: () => onReaction(item.id, 'up'), - ), - const SizedBox(width: 6), - _ReactionButton( - emoji: '👎', - active: reaction == 'down', - onTap: () => onReaction(item.id, 'down'), - ), - if (onDiscuss != null) ...[ - const Spacer(), - _DiscussButton(onTap: onDiscuss!), - ], - ], - ), - ], - ), - ), - ); - } -} - -class _DiscussButton extends StatelessWidget { - final VoidCallback onTap; - const _DiscussButton({required this.onTap}); - - @override - Widget build(BuildContext context) { - final scheme = Theme.of(context).colorScheme; - return GestureDetector( - onTap: onTap, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 3), - decoration: BoxDecoration( - border: Border.all(color: scheme.primary.withValues(alpha: 0.5)), - borderRadius: BorderRadius.circular(6), - ), - child: Text( - 'Discuss', - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w600, - color: scheme.primary, - ), - ), - ), - ); - } -} - -class _ReactionButton extends StatelessWidget { - final String emoji; - final bool active; - final VoidCallback onTap; - - const _ReactionButton({ - required this.emoji, - required this.active, - required this.onTap, - }); - - @override - Widget build(BuildContext context) { - final scheme = Theme.of(context).colorScheme; - return GestureDetector( - onTap: onTap, - child: AnimatedContainer( - duration: const Duration(milliseconds: 150), - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), - decoration: BoxDecoration( - color: active ? scheme.primary.withValues(alpha: 0.12) : Colors.transparent, - border: Border.all( - color: active ? scheme.primary : scheme.outlineVariant, - ), - borderRadius: BorderRadius.circular(6), - ), - child: Text(emoji, style: const TextStyle(fontSize: 14)), - ), - ); - } -} diff --git a/test/widget_test.dart b/test/widget_test.dart index 7f6d93a..211d3b4 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -3,8 +3,6 @@ import 'package:fabled_app/data/api/voice_api.dart'; import 'package:fabled_app/providers/voice_provider.dart'; import 'package:fabled_app/data/models/knowledge_item.dart'; import 'package:fabled_app/data/models/note.dart'; -import 'package:fabled_app/data/models/news_item.dart'; -import 'package:fabled_app/data/models/briefing_feed.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { @@ -150,47 +148,6 @@ void main() { }); }); - group('NewsItem.fromJson', () { - test('parses all fields', () { - final json = { - 'id': 42, - 'title': 'Big news', - 'url': 'https://example.com/article', - 'snippet': 'A short summary.', - 'source': 'Example News', - 'published_at': '2026-01-15T10:00:00', - 'topics': ['tech', 'ai'], - 'reaction': 'up', - }; - final item = NewsItem.fromJson(json); - expect(item.id, equals(42)); - expect(item.title, equals('Big news')); - expect(item.url, equals('https://example.com/article')); - expect(item.snippet, equals('A short summary.')); - expect(item.source, equals('Example News')); - expect(item.publishedAt, equals(DateTime.parse('2026-01-15T10:00:00'))); - expect(item.topics, equals(['tech', 'ai'])); - expect(item.reaction, equals('up')); - }); - - test('handles null published_at and reaction', () { - final json = { - 'id': 1, - 'title': '', - 'url': '', - 'snippet': '', - 'source': '', - 'published_at': null, - 'topics': [], - 'reaction': null, - }; - final item = NewsItem.fromJson(json); - expect(item.publishedAt, isNull); - expect(item.reaction, isNull); - expect(item.topics, isEmpty); - }); - }); - group('CalendarEvent.fromJson', () { test('parses all fields', () { final json = { @@ -250,30 +207,4 @@ void main() { }); }); - group('BriefingFeed.fromJson', () { - test('parses all fields', () { - final json = { - 'id': 7, - 'title': 'Hacker News', - 'url': 'https://news.ycombinator.com/rss', - 'category': 'tech', - }; - final feed = BriefingFeed.fromJson(json); - expect(feed.id, equals(7)); - expect(feed.title, equals('Hacker News')); - expect(feed.url, equals('https://news.ycombinator.com/rss')); - expect(feed.category, equals('tech')); - }); - - test('handles null category', () { - final json = { - 'id': 8, - 'title': 'Feed', - 'url': 'https://example.com/rss', - 'category': null, - }; - final feed = BriefingFeed.fromJson(json); - expect(feed.category, isNull); - }); - }); }