feat(journal): replace briefing surface with journal; remove news/RSS

The backend retired /api/briefing/* and the RSS feature entirely. This
Flutter change mirrors what landed web-side: rename the briefing surface
to journal, repoint at /api/journal/*, and drop the news/RSS UI since
its endpoints no longer exist.

New (mirrors briefing structure with adapted shapes):
- lib/data/api/journal_api.dart — getToday, getDay, getDays, triggerPrep
- lib/data/models/journal_day.dart — {day_date, conversation, messages}
- lib/providers/journal_provider.dart — async notifier, sendReply, polling,
  silent refresh, regeneratePrep. Mirrors the briefing notifier 1:1
- lib/widgets/journal_prep_card.dart — adapted briefing_digest_card
- lib/screens/journal/journal_screen.dart — adapted briefing_screen,
  weather card preserved (rendered from msg_metadata.sections.weather
  on the daily-prep assistant message). News cards / RSS reactions /
  article-discuss removed
- lib/screens/journal/journal_history_screen.dart — past days picker
  pulls /api/journal/days, drills into /api/journal/day/<iso>

Wiring:
- Routes.briefing → Routes.journal (constants.dart)
- Routes.news removed
- briefingApiProvider → journalApiProvider (api_client_provider.dart)
- newsApiProvider removed
- app.dart: shell tab "Briefing" → "Journal"; News destination removed
  from nav rail, bottom nav, and the More sheet
- splash_screen.dart and login_screen.dart: redirect Routes.journal
  instead of Routes.briefing
- chat_api.dart: drop openArticleInChat (calls deleted /api/chat/from-article)
- settings_provider.dart: drop rssEnabled getter and rssEnabledProvider

Deleted:
- lib/screens/briefing/ (whole directory)
- lib/screens/news/ (whole directory)
- lib/data/api/briefing_api.dart, news_api.dart
- lib/data/models/briefing_conversation.dart, briefing_feed.dart, news_item.dart
- lib/providers/briefing_provider.dart, news_provider.dart
- lib/widgets/briefing_digest_card.dart, news_card.dart
- test cases for NewsItem and BriefingFeed in test/widget_test.dart

flutter analyze: 0 issues.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-04-27 07:58:09 -04:00
parent 5e48a4fb69
commit dd250788f6
23 changed files with 375 additions and 1389 deletions
+28 -55
View File
@@ -11,15 +11,14 @@ import 'providers/auth_provider.dart';
import 'core/exceptions.dart'; import 'core/exceptions.dart';
import 'providers/capture_queue_provider.dart'; import 'providers/capture_queue_provider.dart';
import 'providers/capture_work_queue_provider.dart'; import 'providers/capture_work_queue_provider.dart';
import 'providers/briefing_provider.dart';
import 'providers/calendar_provider.dart'; import 'providers/calendar_provider.dart';
import 'providers/chat_provider.dart'; import 'providers/chat_provider.dart';
import 'providers/journal_provider.dart';
import 'providers/knowledge_provider.dart'; import 'providers/knowledge_provider.dart';
import 'providers/news_provider.dart';
import 'providers/settings_provider.dart'; import 'providers/settings_provider.dart';
import 'providers/update_provider.dart'; import 'providers/update_provider.dart';
import 'screens/auth/login_screen.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/knowledge/knowledge_screen.dart';
import 'screens/library/project_tasks_screen.dart'; import 'screens/library/project_tasks_screen.dart';
import 'screens/chat/chat_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/projects/projects_screen.dart';
import 'screens/notes/note_edit_screen.dart'; import 'screens/notes/note_edit_screen.dart';
import 'screens/settings/settings_screen.dart'; import 'screens/settings/settings_screen.dart';
import 'screens/news/news_screen.dart';
import 'screens/setup/setup_screen.dart'; import 'screens/setup/setup_screen.dart';
import 'screens/splash/splash_screen.dart'; import 'screens/splash/splash_screen.dart';
import 'screens/tasks/task_edit_screen.dart'; import 'screens/tasks/task_edit_screen.dart';
@@ -161,8 +159,8 @@ final routerProvider = Provider<GoRouter>((ref) {
builder: (context, state, child) => _Shell(child: child), builder: (context, state, child) => _Shell(child: child),
routes: [ routes: [
GoRoute( GoRoute(
path: Routes.briefing, path: Routes.journal,
builder: (_, _) => const BriefingScreen(), builder: (_, _) => const JournalScreen(),
), ),
GoRoute( GoRoute(
path: Routes.knowledge, path: Routes.knowledge,
@@ -176,10 +174,6 @@ final routerProvider = Provider<GoRouter>((ref) {
path: Routes.projects, path: Routes.projects,
builder: (_, _) => const ProjectsScreen(), builder: (_, _) => const ProjectsScreen(),
), ),
GoRoute(
path: Routes.news,
builder: (_, _) => const NewsScreen(),
),
GoRoute( GoRoute(
path: Routes.calendar, path: Routes.calendar,
builder: (_, _) => const CalendarScreen(), builder: (_, _) => const CalendarScreen(),
@@ -200,15 +194,14 @@ class _Shell extends ConsumerStatefulWidget {
class _ShellState extends ConsumerState<_Shell> with WidgetsBindingObserver { class _ShellState extends ConsumerState<_Shell> with WidgetsBindingObserver {
static const _baseTabs = [ static const _baseTabs = [
Routes.briefing, Routes.journal,
Routes.knowledge, Routes.knowledge,
Routes.conversations, Routes.conversations,
Routes.projects, Routes.projects,
]; ];
List<String> _tabs(bool rssEnabled) => [ List<String> _tabs() => [
..._baseTabs, ..._baseTabs,
if (rssEnabled) Routes.news,
Routes.calendar, Routes.calendar,
]; ];
@@ -232,7 +225,7 @@ class _ShellState extends ConsumerState<_Shell> with WidgetsBindingObserver {
ref.read(updateProvider.notifier).check(repoUrl); 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(); _syncTimezone();
}); });
} }
@@ -260,24 +253,20 @@ class _ShellState extends ConsumerState<_Shell> with WidgetsBindingObserver {
void _refreshAll() { void _refreshAll() {
ref.read(conversationsProvider.notifier).refresh(); ref.read(conversationsProvider.notifier).refresh();
ref.read(calendarProvider.notifier).refresh(); ref.read(calendarProvider.notifier).refresh();
ref.read(newsProvider.notifier).refresh();
ref.read(knowledgeProvider.notifier).refresh(); ref.read(knowledgeProvider.notifier).refresh();
// briefingProvider is an AsyncNotifier family; invalidating is safe // journalProvider is an AsyncNotifier; invalidating is safe even if
// even if no conversation is open — it doesn't cause flicker since // the journal screen isn't currently mounted.
// the briefing screen isn't a list view. ref.invalidate(journalProvider);
ref.invalidate(briefingProvider);
} }
/// Refresh only the provider backing the given shell tab route. /// Refresh only the provider backing the given shell tab route.
void _refreshTab(String route) { void _refreshTab(String route) {
if (route == Routes.briefing) { if (route == Routes.journal) {
ref.invalidate(briefingProvider); ref.invalidate(journalProvider);
} else if (route == Routes.knowledge) { } else if (route == Routes.knowledge) {
ref.read(knowledgeProvider.notifier).refresh(); ref.read(knowledgeProvider.notifier).refresh();
} else if (route == Routes.conversations) { } else if (route == Routes.conversations) {
ref.read(conversationsProvider.notifier).refresh(); ref.read(conversationsProvider.notifier).refresh();
} else if (route == Routes.news) {
ref.read(newsProvider.notifier).refresh();
} else if (route == Routes.calendar) { } else if (route == Routes.calendar) {
ref.read(calendarProvider.notifier).refresh(); ref.read(calendarProvider.notifier).refresh();
} }
@@ -314,15 +303,6 @@ class _ShellState extends ConsumerState<_Shell> with WidgetsBindingObserver {
context.push(Routes.projects); 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( ListTile(
leading: const Icon(Icons.calendar_month_outlined), leading: const Icon(Icons.calendar_month_outlined),
title: const Text('Calendar'), title: const Text('Calendar'),
@@ -360,8 +340,7 @@ class _ShellState extends ConsumerState<_Shell> with WidgetsBindingObserver {
.addPostFrameCallback((_) => _showUpdateSnackbar(next)); .addPostFrameCallback((_) => _showUpdateSnackbar(next));
} }
}); });
final rssEnabled = ref.watch(rssEnabledProvider); final tabs = _tabs();
final tabs = _tabs(rssEnabled);
final location = GoRouterState.of(context).matchedLocation; final location = GoRouterState.of(context).matchedLocation;
final index = _tabIndex(location, tabs); final index = _tabIndex(location, tabs);
@@ -389,34 +368,28 @@ class _ShellState extends ConsumerState<_Shell> with WidgetsBindingObserver {
selectedIndex: index, selectedIndex: index,
onDestinationSelected: (i) => context.go(tabs[i]), onDestinationSelected: (i) => context.go(tabs[i]),
labelType: NavigationRailLabelType.all, labelType: NavigationRailLabelType.all,
destinations: [ destinations: const [
const NavigationRailDestination( NavigationRailDestination(
icon: Icon(Icons.wb_sunny_outlined),
selectedIcon: Icon(Icons.wb_sunny),
label: Text('Briefing'),
),
const NavigationRailDestination(
icon: Icon(Icons.menu_book_outlined), icon: Icon(Icons.menu_book_outlined),
selectedIcon: Icon(Icons.menu_book), selectedIcon: Icon(Icons.menu_book),
label: Text('Journal'),
),
NavigationRailDestination(
icon: Icon(Icons.lightbulb_outline),
selectedIcon: Icon(Icons.lightbulb),
label: Text('Knowledge'), label: Text('Knowledge'),
), ),
const NavigationRailDestination( NavigationRailDestination(
icon: Icon(Icons.chat_bubble_outline), icon: Icon(Icons.chat_bubble_outline),
selectedIcon: Icon(Icons.chat_bubble), selectedIcon: Icon(Icons.chat_bubble),
label: Text('Chat'), label: Text('Chat'),
), ),
const NavigationRailDestination( NavigationRailDestination(
icon: Icon(Icons.folder_outlined), icon: Icon(Icons.folder_outlined),
selectedIcon: Icon(Icons.folder), selectedIcon: Icon(Icons.folder),
label: Text('Projects'), label: Text('Projects'),
), ),
if (ref.watch(rssEnabledProvider)) NavigationRailDestination(
const NavigationRailDestination(
icon: Icon(Icons.newspaper_outlined),
selectedIcon: Icon(Icons.newspaper),
label: Text('News'),
),
const NavigationRailDestination(
icon: Icon(Icons.calendar_month_outlined), icon: Icon(Icons.calendar_month_outlined),
selectedIcon: Icon(Icons.calendar_month), selectedIcon: Icon(Icons.calendar_month),
label: Text('Calendar'), label: Text('Calendar'),
@@ -458,14 +431,14 @@ class _ShellState extends ConsumerState<_Shell> with WidgetsBindingObserver {
} }
}, },
destinations: const [ destinations: const [
NavigationDestination(
icon: Icon(Icons.wb_sunny_outlined),
selectedIcon: Icon(Icons.wb_sunny),
label: 'Briefing',
),
NavigationDestination( NavigationDestination(
icon: Icon(Icons.menu_book_outlined), icon: Icon(Icons.menu_book_outlined),
selectedIcon: Icon(Icons.menu_book), selectedIcon: Icon(Icons.menu_book),
label: 'Journal',
),
NavigationDestination(
icon: Icon(Icons.lightbulb_outline),
selectedIcon: Icon(Icons.lightbulb),
label: 'Knowledge', label: 'Knowledge',
), ),
NavigationDestination( NavigationDestination(
+1 -2
View File
@@ -16,8 +16,7 @@ abstract class Routes {
static const chat = '/chat/:id'; static const chat = '/chat/:id';
static const quickCapture = '/quick-capture'; static const quickCapture = '/quick-capture';
static const settings = '/settings'; static const settings = '/settings';
static const briefing = '/briefing'; static const journal = '/journal';
static const news = '/news';
static const calendar = '/calendar'; static const calendar = '/calendar';
static const projectTasks = '/projects/:id/tasks'; static const projectTasks = '/projects/:id/tasks';
} }
-97
View File
@@ -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<BriefingConversation> getToday() async {
try {
final response = await _dio.get('/api/briefing/conversations/today');
return BriefingConversation.fromJson(
response.data as Map<String, dynamic>);
} on DioException catch (e) {
throw dioToApp(e);
}
}
/// GET /api/briefing/conversations
/// Returns list of past briefing conversations (no messages embedded).
Future<List<BriefingConversation>> getHistory() async {
try {
final response = await _dio.get('/api/briefing/conversations');
final data = response.data as Map<String, dynamic>;
final list = data['conversations'] as List<dynamic>;
return list
.map((e) => BriefingConversation.fromJson(e as Map<String, dynamic>))
.toList();
} on DioException catch (e) {
throw dioToApp(e);
}
}
/// GET /api/briefing/conversations/`<id>`/messages
Future<List<Message>> getMessages(int convId) async {
try {
final response =
await _dio.get('/api/briefing/conversations/$convId/messages');
final data = response.data as Map<String, dynamic>;
final list = data['messages'] as List<dynamic>;
return list
.map((e) => Message.fromJson(e as Map<String, dynamic>))
.toList();
} on DioException catch (e) {
throw dioToApp(e);
}
}
/// POST /api/briefing/trigger body: {"slot": slot}
/// slot: "compilation" | "morning" | "midday" | "afternoon"
Future<void> 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<void> 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<int> 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<String, dynamic>;
return data['assistant_message_id'] as int;
} on DioException catch (e) {
throw dioToApp(e);
}
}
/// DELETE /api/briefing/rss-reactions/{rssItemId}
Future<void> deleteRssReaction(int rssItemId) async {
try {
await _dio.delete('/api/briefing/rss-reactions/$rssItemId');
} on DioException catch (e) {
throw dioToApp(e);
}
}
}
-13
View File
@@ -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<int> openArticleInChat(int itemId) async {
try {
final response =
await _dio.post('/api/chat/from-article/$itemId', data: <String, dynamic>{});
final data = response.data as Map<String, dynamic>;
return data['conversation_id'] as int;
} on DioException catch (e) {
throw dioToApp(e);
}
}
} }
+55
View File
@@ -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<JournalDay> getToday() async {
try {
final response = await _dio.get('/api/journal/today');
return JournalDay.fromJson(response.data as Map<String, dynamic>);
} on DioException catch (e) {
throw dioToApp(e);
}
}
/// GET /api/journal/day/&lt;iso_date&gt;
Future<JournalDay> getDay(String isoDate) async {
try {
final response = await _dio.get('/api/journal/day/$isoDate');
return JournalDay.fromJson(response.data as Map<String, dynamic>);
} on DioException catch (e) {
throw dioToApp(e);
}
}
/// GET /api/journal/days — list of dates with journal content, newest first.
Future<List<String>> getDays() async {
try {
final response = await _dio.get('/api/journal/days');
final data = response.data as Map<String, dynamic>;
final list = data['days'] as List<dynamic>;
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<void> triggerPrep({String? isoDate}) async {
try {
final body = <String, dynamic>{};
if (isoDate != null) body['date'] = isoDate;
await _dio.post('/api/journal/trigger-prep', data: body);
} on DioException catch (e) {
throw dioToApp(e);
}
}
}
-52
View File
@@ -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<List<NewsItem>> getNewsItems({
int days = 90,
int limit = 40,
int offset = 0,
int? feedId,
}) async {
try {
final params = <String, dynamic>{
'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<String, dynamic>;
final list = data['items'] as List<dynamic>;
return list
.map((e) => NewsItem.fromJson(e as Map<String, dynamic>))
.toList();
} on DioException catch (e) {
throw dioToApp(e);
}
}
/// GET /api/briefing/feeds
Future<List<BriefingFeed>> getFeeds() async {
try {
final response = await _dio.get('/api/briefing/feeds');
final list = response.data as List<dynamic>;
return list
.map((e) => BriefingFeed.fromJson(e as Map<String, dynamic>))
.toList();
} on DioException catch (e) {
throw dioToApp(e);
}
}
}
@@ -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<Message> messages;
const BriefingConversation({
required this.id,
required this.title,
this.briefingDate,
required this.messages,
});
factory BriefingConversation.fromJson(Map<String, dynamic> json) {
final rawMessages = json['messages'] as List<dynamic>? ?? [];
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<String, dynamic>))
.toList(),
);
}
BriefingConversation copyWith({List<Message>? messages}) =>
BriefingConversation(
id: id,
title: title,
briefingDate: briefingDate,
messages: messages ?? this.messages,
);
}
-20
View File
@@ -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<String, dynamic> json) => BriefingFeed(
id: json['id'] as int,
title: json['title'] as String? ?? '',
url: json['url'] as String? ?? '',
category: json['category'] as String?,
);
}
+60
View File
@@ -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<String, dynamic> 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/&lt;iso&gt;.
/// `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<Message> messages;
const JournalDay({
required this.dayDate,
required this.conversation,
required this.messages,
});
factory JournalDay.fromJson(Map<String, dynamic> json) {
final convRaw = json['conversation'] as Map<String, dynamic>?;
final rawMessages = json['messages'] as List<dynamic>? ?? [];
return JournalDay(
dayDate: json['day_date'] as String,
conversation:
convRaw == null ? null : JournalConversation.fromJson(convRaw),
messages: rawMessages
.map((e) => Message.fromJson(e as Map<String, dynamic>))
.toList(),
);
}
JournalDay copyWith({List<Message>? messages}) => JournalDay(
dayDate: dayDate,
conversation: conversation,
messages: messages ?? this.messages,
);
}
-37
View File
@@ -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<String> 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<String, dynamic> 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<dynamic>?)
?.cast<String>()
.toList() ??
[],
reaction: json['reaction'] as String?,
);
}
+3 -8
View File
@@ -4,13 +4,12 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../data/api/api_client.dart'; import '../data/api/api_client.dart';
import '../data/api/auth_api.dart'; import '../data/api/auth_api.dart';
import '../data/api/briefing_api.dart';
import '../data/api/chat_api.dart'; import '../data/api/chat_api.dart';
import '../data/api/journal_api.dart';
import '../data/api/knowledge_api.dart'; import '../data/api/knowledge_api.dart';
import '../data/api/voice_api.dart'; import '../data/api/voice_api.dart';
import '../data/api/milestones_api.dart'; import '../data/api/milestones_api.dart';
import '../data/api/events_api.dart'; import '../data/api/events_api.dart';
import '../data/api/news_api.dart';
import '../data/api/notes_api.dart'; import '../data/api/notes_api.dart';
import '../data/api/projects_api.dart'; import '../data/api/projects_api.dart';
import '../data/api/quick_capture_api.dart'; import '../data/api/quick_capture_api.dart';
@@ -97,8 +96,8 @@ final knowledgeRepositoryProvider = Provider<KnowledgeRepository>((ref) {
return KnowledgeRepository(ref.watch(knowledgeApiProvider)); return KnowledgeRepository(ref.watch(knowledgeApiProvider));
}); });
final briefingApiProvider = Provider<BriefingApi>((ref) { final journalApiProvider = Provider<JournalApi>((ref) {
return BriefingApi(ref.watch(dioProvider)); return JournalApi(ref.watch(dioProvider));
}); });
final settingsApiProvider = Provider<SettingsApi>((ref) { final settingsApiProvider = Provider<SettingsApi>((ref) {
@@ -113,10 +112,6 @@ final voiceRepositoryProvider = Provider<VoiceRepository>((ref) {
return VoiceRepository(ref.watch(voiceApiProvider)); return VoiceRepository(ref.watch(voiceApiProvider));
}); });
final newsApiProvider = Provider<NewsApi>((ref) {
return NewsApi(ref.watch(dioProvider));
});
final eventsApiProvider = Provider<EventsApi>((ref) { final eventsApiProvider = Provider<EventsApi>((ref) {
return EventsApi(ref.watch(dioProvider)); return EventsApi(ref.watch(dioProvider));
}); });
@@ -3,12 +3,12 @@ import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../data/api/chat_api.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 '../data/models/message.dart';
import 'api_client_provider.dart'; import 'api_client_provider.dart';
/// Drives the loading indicator in BriefingScreen's reply area. /// Drives the loading indicator in JournalScreen's reply area.
final isBriefingStreamingProvider = final isJournalStreamingProvider =
NotifierProvider<_BoolNotifier, bool>(_BoolNotifier.new); NotifierProvider<_BoolNotifier, bool>(_BoolNotifier.new);
class _BoolNotifier extends Notifier<bool> { class _BoolNotifier extends Notifier<bool> {
@@ -16,23 +16,22 @@ class _BoolNotifier extends Notifier<bool> {
bool build() => false; bool build() => false;
} }
final briefingProvider = final journalProvider =
AsyncNotifierProvider<BriefingNotifier, BriefingConversation>( AsyncNotifierProvider<JournalNotifier, JournalDay>(JournalNotifier.new);
BriefingNotifier.new);
class BriefingNotifier extends AsyncNotifier<BriefingConversation> { class JournalNotifier extends AsyncNotifier<JournalDay> {
@override @override
Future<BriefingConversation> build() async { Future<JournalDay> build() async {
return ref.read(briefingApiProvider).getToday(); 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. /// AsyncLoading existing content stays visible while the fetch is in flight.
Future<void> silentRefresh() async { Future<void> silentRefresh() async {
final current = state.value; final current = state.value;
if (current == null) return; if (current == null) return;
try { 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 curLast = current.messages.isNotEmpty ? current.messages.last : null;
final newLast = fresh.messages.isNotEmpty ? fresh.messages.last : null; final newLast = fresh.messages.isNotEmpty ? fresh.messages.last : null;
if (fresh.messages.length != current.messages.length || if (fresh.messages.length != current.messages.length ||
@@ -40,26 +39,25 @@ class BriefingNotifier extends AsyncNotifier<BriefingConversation> {
state = AsyncData(fresh); state = AsyncData(fresh);
} }
} catch (_) { } catch (_) {
// Network hiccup silently ignore, keep existing content // Network hiccup silently ignore, keep existing content.
} }
} }
/// Trigger a briefing slot (e.g. "compilation") then reload. /// Force-regenerate today's daily prep then reload.
Future<void> refresh(String slot) async { Future<void> regeneratePrep() async {
await ref.read(briefingApiProvider).triggerSlot(slot); await ref.read(journalApiProvider).triggerPrep();
ref.invalidateSelf(); ref.invalidateSelf();
await future; await future;
} }
/// Re-fetch the current briefing conversation and unfreeze a stuck /// Re-fetch today's journal and unfreeze a stuck streaming state if the
/// streaming state if the server-side message is already complete. /// server-side message is already complete.
/// ///
/// Same role as MessagesNotifier.refresh() in chat_provider: when an SSE /// Same role as MessagesNotifier.refresh() in chat_provider: when an SSE
/// socket dies silently (mobile network handoff, app backgrounded mid-stream, /// socket dies silently the send loop never observes close and
/// reverse proxy dropping idle sockets) the send loop never observes close /// [isJournalStreamingProvider] stays stuck true. This is the manual
/// and [isBriefingStreamingProvider] stays stuck true. This is the manual /// recovery path hit by pull-to-refresh, the AppBar refresh button, and
/// recovery path hit by pull-to-refresh, the AppBar refresh button, and the /// the lifecycle-resume hook.
/// lifecycle-resume hook.
Future<void> refreshMessages() async { Future<void> refreshMessages() async {
final current = state.value; final current = state.value;
if (current == null) { if (current == null) {
@@ -67,7 +65,7 @@ class BriefingNotifier extends AsyncNotifier<BriefingConversation> {
return; return;
} }
try { try {
final fresh = await ref.read(briefingApiProvider).getToday(); final fresh = await ref.read(journalApiProvider).getToday();
state = AsyncData(fresh); state = AsyncData(fresh);
final messages = fresh.messages; final messages = fresh.messages;
Message? lastAssistant; Message? lastAssistant;
@@ -78,46 +76,14 @@ class BriefingNotifier extends AsyncNotifier<BriefingConversation> {
} }
} }
if (lastAssistant != null && lastAssistant.status != 'generating') { if (lastAssistant != null && lastAssistant.status != 'generating') {
ref.read(isBriefingStreamingProvider.notifier).state = false; ref.read(isJournalStreamingProvider.notifier).state = false;
} }
} catch (_) { } catch (_) {
// Network hiccup keep existing state; user can retry. // Network hiccup keep existing state; user can retry.
} }
} }
/// Inject a news article as context and trigger generation. /// Send a reply to today's journal conversation.
///
/// Mirrors sendReply() but calls the /discuss endpoint instead of
/// /messages so the backend injects article content before generating.
Future<void> 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.
/// ///
/// Mirrors MessagesNotifier.sendMessage() in chat_provider with the same /// Mirrors MessagesNotifier.sendMessage() in chat_provider with the same
/// stall-watchdog pattern: /// stall-watchdog pattern:
@@ -126,12 +92,14 @@ class BriefingNotifier extends AsyncNotifier<BriefingConversation> {
/// 3. SSE stream with per-event timeout (stall watchdog) /// 3. SSE stream with per-event timeout (stall watchdog)
/// 4. Poll until complete /// 4. Poll until complete
Future<void> sendReply(String content) async { Future<void> sendReply(String content) async {
final conv = state.value; final day = state.value;
if (day == null) return;
final conv = day.conversation;
if (conv == null) return; if (conv == null) return;
final convId = conv.id; final convId = conv.id;
final chatApi = ref.read(chatApiProvider); final chatApi = ref.read(chatApiProvider);
final previous = conv.messages; final previous = day.messages;
final userMsg = Message( final userMsg = Message(
conversationId: convId, conversationId: convId,
role: MessageRole.user, role: MessageRole.user,
@@ -143,14 +111,14 @@ class BriefingNotifier extends AsyncNotifier<BriefingConversation> {
content: '', content: '',
status: 'generating', status: 'generating',
); );
state = AsyncData(conv.copyWith(messages: [...previous, userMsg, placeholder])); state = AsyncData(day.copyWith(messages: [...previous, userMsg, placeholder]));
ref.read(isBriefingStreamingProvider.notifier).state = true; ref.read(isJournalStreamingProvider.notifier).state = true;
try { try {
await chatApi.sendMessage(convId, content); await chatApi.sendMessage(convId, content);
} catch (e) { } catch (e) {
state = AsyncData(conv.copyWith(messages: previous)); state = AsyncData(day.copyWith(messages: previous));
ref.read(isBriefingStreamingProvider.notifier).state = false; ref.read(isJournalStreamingProvider.notifier).state = false;
rethrow; rethrow;
} }
@@ -158,19 +126,15 @@ class BriefingNotifier extends AsyncNotifier<BriefingConversation> {
await _pollUntilComplete(convId, streamedContent); 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 /// Uses a StreamIterator with a per-event timeout as a stall watchdog
/// same rationale as MessagesNotifier.sendMessage() in chat_provider.dart. /// same rationale as MessagesNotifier.sendMessage() in chat_provider.dart.
/// Mobile networks occasionally drop SSE sockets silently: the TCP /// Mobile networks occasionally drop SSE sockets silently. If no event
/// connection is half-closed, Dio never sees the close, and `await for` /// arrives within the watchdog window we bail out and let polling
/// hangs forever with [isBriefingStreamingProvider] stuck true. If no event /// reconcile state from the server.
/// arrives within the watchdog window we bail out and let the polling pass
/// below reconcile state from the server.
/// ///
/// Returns whether any text content was actually streamed (the polling /// Returns whether any text content was actually streamed.
/// pass uses this to decide whether it's safe to overwrite with a possibly
/// empty server-side row).
Future<bool> _consumeStream(Stream<ChatStreamEvent> stream) async { Future<bool> _consumeStream(Stream<ChatStreamEvent> stream) async {
const stallTimeout = Duration(seconds: 45); const stallTimeout = Duration(seconds: 45);
bool streamedContent = false; bool streamedContent = false;
@@ -208,24 +172,25 @@ class BriefingNotifier extends AsyncNotifier<BriefingConversation> {
return streamedContent; return streamedContent;
} }
/// Poll /api/briefing messages until the last assistant row is complete, /// Poll today's journal until the last assistant row is complete. Always
/// same reconcile pattern as MessagesNotifier.sendMessage(). Always clears /// clears [isJournalStreamingProvider] at the end so the input can't stay
/// [isBriefingStreamingProvider] at the end so the input can't stay locked. /// locked.
Future<void> _pollUntilComplete(int convId, bool streamedContent) async { Future<void> _pollUntilComplete(int convId, bool streamedContent) async {
final briefingApi = ref.read(briefingApiProvider); final journalApi = ref.read(journalApiProvider);
try { try {
for (var attempt = 0; attempt < 20; attempt++) { for (var attempt = 0; attempt < 20; attempt++) {
if (attempt > 0) await Future.delayed(const Duration(seconds: 2)); if (attempt > 0) await Future.delayed(const Duration(seconds: 2));
final fresh = await briefingApi.getMessages(convId); final fresh = await journalApi.getToday();
final done = fresh.any( final freshMsgs = fresh.messages;
final done = freshMsgs.any(
(m) => m.role == MessageRole.assistant && m.status != 'generating', (m) => m.role == MessageRole.assistant && m.status != 'generating',
); );
final hasContent = fresh.any( final hasContent = freshMsgs.any(
(m) => m.role == MessageRole.assistant && m.content.isNotEmpty, (m) => m.role == MessageRole.assistant && m.content.isNotEmpty,
); );
final current = state.value; final current = state.value;
if (current != null && (!streamedContent || done || hasContent)) { if (current != null && (!streamedContent || done || hasContent)) {
state = AsyncData(current.copyWith(messages: fresh)); state = AsyncData(current.copyWith(messages: freshMsgs));
} }
if (done) break; if (done) break;
} }
@@ -241,7 +206,7 @@ class BriefingNotifier extends AsyncNotifier<BriefingConversation> {
} }
} }
} finally { } finally {
ref.read(isBriefingStreamingProvider.notifier).state = false; ref.read(isJournalStreamingProvider.notifier).state = false;
} }
} }
} }
-177
View File
@@ -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<NewsItem> items;
final int offset;
final bool hasMore;
final bool loadingMore;
final int? selectedFeedId;
final Map<int, String?> reactions;
const NewsState({
required this.items,
required this.offset,
required this.hasMore,
required this.loadingMore,
required this.selectedFeedId,
required this.reactions,
});
NewsState copyWith({
List<NewsItem>? items,
int? offset,
bool? hasMore,
bool? loadingMore,
Object? selectedFeedId = _sentinel,
Map<int, String?>? 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, NewsState>(NewsNotifier.new);
class NewsNotifier extends AsyncNotifier<NewsState> {
static const _limit = 40;
@override
Future<NewsState> 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<void> 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<void> 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<int, String?>.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<void> 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, List<BriefingFeed>>(FeedsNotifier.new);
class FeedsNotifier extends AsyncNotifier<List<BriefingFeed>> {
@override
Future<List<BriefingFeed>> build() async {
return ref.watch(newsApiProvider).getFeeds();
}
}
-10
View File
@@ -125,17 +125,7 @@ class ServerSettingsNotifier extends AsyncNotifier<Map<String, dynamic>> {
} }
} }
bool get rssEnabled {
final data = state.value ?? {};
return data['rss_enabled']?.toString().toLowerCase() == 'true';
}
Future<void> refresh() async { Future<void> refresh() async {
state = AsyncData(await ref.read(settingsApiProvider).getAll()); state = AsyncData(await ref.read(settingsApiProvider).getAll());
} }
} }
final rssEnabledProvider = Provider<bool>((ref) {
final settings = ref.watch(serverSettingsProvider).value ?? {};
return settings['rss_enabled']?.toString().toLowerCase() == 'true';
});
+2 -2
View File
@@ -72,7 +72,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
_usernameController.text.trim(), _usernameController.text.trim(),
_passwordController.text, _passwordController.text,
); );
if (mounted) context.go(Routes.briefing); if (mounted) context.go(Routes.journal);
} on AuthException catch (e) { } on AuthException catch (e) {
setState(() => _error = e.message); setState(() => _error = e.message);
} on AppException catch (e) { } on AppException catch (e) {
@@ -90,7 +90,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
cookieJar: ref.read(cookieJarProvider), cookieJar: ref.read(cookieJarProvider),
onSuccess: () async { onSuccess: () async {
await ref.read(authProvider.notifier).verify(); await ref.read(authProvider.notifier).verify();
if (mounted) context.go(Routes.briefing); if (mounted) context.go(Routes.journal);
}, },
), ),
)); ));
@@ -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<List<BriefingConversation>>((ref) async {
return ref.watch(briefingApiProvider).getHistory();
});
final _briefingMessagesProvider =
FutureProvider.family<List<Message>, int>((ref, convId) async {
return ref.watch(briefingApiProvider).getMessages(convId);
});
@@ -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<List<String>>((ref) async {
return ref.watch(journalApiProvider).getDays();
});
final _journalDayProvider =
FutureProvider.family<JournalDay, String>((ref, isoDate) async {
return ref.watch(journalApiProvider).getDay(isoDate);
});
@@ -5,30 +5,25 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/exceptions.dart'; import '../../core/exceptions.dart';
import '../../data/models/message.dart'; import '../../data/models/message.dart';
import '../../providers/briefing_provider.dart'; import '../../providers/journal_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/voice_provider.dart'; import '../../providers/voice_provider.dart';
import '../../widgets/chat_message_bubble.dart';
import '../../widgets/voice_mic_button.dart'; import '../../widgets/voice_mic_button.dart';
import '../../widgets/weather_card.dart';
import 'journal_history_screen.dart';
class BriefingScreen extends ConsumerStatefulWidget { class JournalScreen extends ConsumerStatefulWidget {
const BriefingScreen({super.key}); const JournalScreen({super.key});
@override @override
ConsumerState<BriefingScreen> createState() => _BriefingScreenState(); ConsumerState<JournalScreen> createState() => _JournalScreenState();
} }
class _BriefingScreenState extends ConsumerState<BriefingScreen> class _JournalScreenState extends ConsumerState<JournalScreen>
with WidgetsBindingObserver { with WidgetsBindingObserver {
final _controller = TextEditingController(); final _controller = TextEditingController();
final _scrollController = ScrollController(); final _scrollController = ScrollController();
bool _refreshing = false; bool _refreshing = false;
// rss_item_id -> 'up' | 'down' | null
final Map<int, String?> _reactions = {};
Timer? _pollTimer; Timer? _pollTimer;
bool _appInForeground = true; bool _appInForeground = true;
@@ -37,31 +32,29 @@ class _BriefingScreenState extends ConsumerState<BriefingScreen>
void initState() { void initState() {
super.initState(); super.initState();
WidgetsBinding.instance.addObserver(this); WidgetsBinding.instance.addObserver(this);
_pollTimer = Timer.periodic(const Duration(seconds: 60), (_) => _pollSilently()); _pollTimer =
Timer.periodic(const Duration(seconds: 60), (_) => _pollSilently());
} }
@override @override
void didChangeAppLifecycleState(AppLifecycleState state) { void didChangeAppLifecycleState(AppLifecycleState state) {
final wasBackground = !_appInForeground; final wasBackground = !_appInForeground;
_appInForeground = state == AppLifecycleState.resumed; _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) { if (_appInForeground && wasBackground && mounted) {
ref.read(briefingProvider.notifier).refreshMessages(); ref.read(journalProvider.notifier).refreshMessages();
} }
} }
void _pollSilently() { void _pollSilently() {
if (!_appInForeground || !mounted) return; if (!_appInForeground || !mounted) return;
final isStreaming = ref.read(isBriefingStreamingProvider); final isStreaming = ref.read(isJournalStreamingProvider);
if (isStreaming) return; if (isStreaming) return;
ref.read(briefingProvider.notifier).silentRefresh(); ref.read(journalProvider.notifier).silentRefresh();
} }
Future<void> _pullToRefresh() async { Future<void> _pullToRefresh() async {
try { try {
await ref.read(briefingProvider.notifier).refreshMessages(); await ref.read(journalProvider.notifier).refreshMessages();
} catch (_) { } catch (_) {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
@@ -97,7 +90,7 @@ class _BriefingScreenState extends ConsumerState<BriefingScreen>
if (text.isEmpty) return; if (text.isEmpty) return;
_controller.clear(); _controller.clear();
try { try {
await ref.read(briefingProvider.notifier).sendReply(text); await ref.read(journalProvider.notifier).sendReply(text);
} on AppException catch (e) { } on AppException catch (e) {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context) ScaffoldMessenger.of(context)
@@ -112,39 +105,6 @@ class _BriefingScreenState extends ConsumerState<BriefingScreen>
} }
} }
Future<void> _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<void> _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<void> _toggleVoiceMode() async { Future<void> _toggleVoiceMode() async {
final voice = ref.read(voiceProvider); final voice = ref.read(voiceProvider);
if (voice.voiceModeActive) { if (voice.voiceModeActive) {
@@ -153,7 +113,7 @@ class _BriefingScreenState extends ConsumerState<BriefingScreen>
} }
await ref.read(voiceProvider.notifier).enterVoiceMode( await ref.read(voiceProvider.notifier).enterVoiceMode(
onTranscript: (transcript) async { onTranscript: (transcript) async {
await ref.read(briefingProvider.notifier).sendReply(transcript); await ref.read(journalProvider.notifier).sendReply(transcript);
}, },
enableTts: true, enableTts: true,
onError: (msg) { onError: (msg) {
@@ -168,11 +128,11 @@ class _BriefingScreenState extends ConsumerState<BriefingScreen>
Future<void> _refresh() async { Future<void> _refresh() async {
setState(() => _refreshing = true); setState(() => _refreshing = true);
try { try {
await ref.read(briefingProvider.notifier).refresh('compilation'); await ref.read(journalProvider.notifier).regeneratePrep();
} catch (_) { } catch (_) {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Could not generate briefing.')), const SnackBar(content: Text('Could not regenerate prep.')),
); );
} }
} finally { } finally {
@@ -182,20 +142,19 @@ class _BriefingScreenState extends ConsumerState<BriefingScreen>
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final briefingAsync = ref.watch(briefingProvider); final journalAsync = ref.watch(journalProvider);
final isStreaming = ref.watch(isBriefingStreamingProvider); final isStreaming = ref.watch(isJournalStreamingProvider);
final voiceState = ref.watch(voiceProvider); final voiceState = ref.watch(voiceProvider);
final scheme = Theme.of(context).colorScheme; final scheme = Theme.of(context).colorScheme;
// Scroll to bottom when messages change ref.listen(journalProvider, (prev, next) => _scrollToBottom());
ref.listen(briefingProvider, (prev, next) => _scrollToBottom());
// Feed streaming assistant content to VoiceNotifier for TTS. // Feed streaming assistant content to VoiceNotifier for TTS.
ref.listen(briefingProvider, (prev, next) { ref.listen(journalProvider, (prev, next) {
if (!voiceState.voiceModeActive) return; if (!voiceState.voiceModeActive) return;
final conv = next.value; final day = next.value;
if (conv == null || conv.messages.isEmpty) return; if (day == null || day.messages.isEmpty) return;
final last = conv.messages.last; final last = day.messages.last;
if (last.role != MessageRole.assistant) return; if (last.role != MessageRole.assistant) return;
final isComplete = last.status != 'generating'; final isComplete = last.status != 'generating';
ref ref
@@ -208,7 +167,7 @@ class _BriefingScreenState extends ConsumerState<BriefingScreen>
title: Column( title: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text('Briefing', style: Theme.of(context).textTheme.titleLarge), Text('Journal', style: Theme.of(context).textTheme.titleLarge),
Text( Text(
_todayLabel(), _todayLabel(),
style: Theme.of(context).textTheme.labelSmall?.copyWith( style: Theme.of(context).textTheme.labelSmall?.copyWith(
@@ -230,42 +189,42 @@ class _BriefingScreenState extends ConsumerState<BriefingScreen>
else else
IconButton( IconButton(
icon: const Icon(Icons.refresh_outlined), icon: const Icon(Icons.refresh_outlined),
tooltip: 'Generate briefing', tooltip: 'Regenerate prep',
onPressed: _refresh, onPressed: _refresh,
), ),
PopupMenuButton<String>( PopupMenuButton<String>(
onSelected: (value) { onSelected: (value) {
if (value == 'history') { if (value == 'history') {
Navigator.of(context).push(MaterialPageRoute( Navigator.of(context).push(MaterialPageRoute(
builder: (_) => const BriefingHistoryScreen(), builder: (_) => const JournalHistoryScreen(),
)); ));
} }
}, },
itemBuilder: (_) => const [ itemBuilder: (_) => const [
PopupMenuItem( PopupMenuItem(
value: 'history', value: 'history',
child: Text('View past briefings'), child: Text('Past days'),
), ),
], ],
), ),
], ],
), ),
body: briefingAsync.when( body: journalAsync.when(
loading: () => const Center(child: CircularProgressIndicator()), loading: () => const Center(child: CircularProgressIndicator()),
error: (err, stack) => Center( error: (err, stack) => Center(
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
const Text("Could not load today's briefing."), const Text("Could not load today's journal."),
const SizedBox(height: 12), const SizedBox(height: 12),
FilledButton.tonal( FilledButton.tonal(
onPressed: () => ref.invalidate(briefingProvider), onPressed: () => ref.invalidate(journalProvider),
child: const Text('Retry'), child: const Text('Retry'),
), ),
], ],
), ),
), ),
data: (conv) { data: (day) {
final isWide = MediaQuery.of(context).size.width >= 600; final isWide = MediaQuery.of(context).size.width >= 600;
Widget body = Column( Widget body = Column(
children: [ children: [
@@ -274,64 +233,49 @@ class _BriefingScreenState extends ConsumerState<BriefingScreen>
onRefresh: _pullToRefresh, onRefresh: _pullToRefresh,
child: CustomScrollView( child: CustomScrollView(
controller: _scrollController, controller: _scrollController,
// AlwaysScrollable so pull-to-refresh fires even when
// the briefing is empty or shorter than the viewport.
physics: const AlwaysScrollableScrollPhysics(), physics: const AlwaysScrollableScrollPhysics(),
slivers: [ slivers: [
if (conv.messages.isEmpty) if (day.messages.isEmpty)
SliverFillRemaining( SliverFillRemaining(
child: Center( child: Center(
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Text( Text(
'No briefing yet today.', 'No prep yet today.',
style: Theme.of(context) style: Theme.of(context)
.textTheme .textTheme
.bodyMedium .bodyMedium
?.copyWith(color: scheme.onSurfaceVariant), ?.copyWith(color: scheme.onSurfaceVariant),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
FilledButton.tonal( FilledButton.tonal(
onPressed: _refresh, onPressed: _refresh,
child: const Text('Generate now'), 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) if (isStreaming)
LinearProgressIndicator( LinearProgressIndicator(
minHeight: 2, minHeight: 2,
color: scheme.primary, color: scheme.primary,
), ),
// Voice mode banner
if (voiceState.voiceModeActive) if (voiceState.voiceModeActive)
Container( Container(
width: double.infinity, width: double.infinity,
@@ -346,7 +290,6 @@ class _BriefingScreenState extends ConsumerState<BriefingScreen>
), ),
), ),
), ),
// Reply bar
const Divider(height: 1), const Divider(height: 1),
SafeArea( SafeArea(
child: Padding( child: Padding(
@@ -359,7 +302,7 @@ class _BriefingScreenState extends ConsumerState<BriefingScreen>
decoration: InputDecoration( decoration: InputDecoration(
hintText: voiceState.voiceModeActive hintText: voiceState.voiceModeActive
? 'Listening…' ? 'Listening…'
: 'Reply to your briefing', : 'Tell your journal',
hintStyle: voiceState.voiceModeActive hintStyle: voiceState.voiceModeActive
? const TextStyle(fontStyle: FontStyle.italic) ? const TextStyle(fontStyle: FontStyle.italic)
: null, : null,
@@ -422,65 +365,46 @@ class _BriefingScreenState extends ConsumerState<BriefingScreen>
} }
} }
/// Renders a single briefing message with optional WeatherCard above it /// Renders a single journal message. For the daily-prep assistant message,
/// and RSS reaction buttons below it (for assistant messages with metadata). /// also renders a WeatherCard above the bubble (weather lives in the
class _BriefingMessageItem extends StatelessWidget { /// nested metadata.sections.weather payload).
class _JournalMessageItem extends StatelessWidget {
final Message message; final Message message;
final int convId;
final Map<int, String?> reactions;
final void Function(int itemId, String reaction) onReaction;
final void Function(int convId, int itemId) onDiscuss;
final bool rssEnabled;
const _BriefingMessageItem({ const _JournalMessageItem({required this.message});
required this.message,
required this.convId,
required this.reactions,
required this.onReaction,
required this.onDiscuss,
this.rssEnabled = false,
});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final meta = message.metadata; final meta = message.metadata;
final isAssistant = message.role == MessageRole.assistant; 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) Map<String, dynamic>? weatherData;
final bool hasWeatherKey = isAssistant && meta != null && meta.containsKey('weather'); if (isPrep) {
final weatherData = hasWeatherKey ? meta['weather'] as Map<String, dynamic>? : null; // The journal prep stores its structured data under metadata.sections.
final sections = meta['sections'] as Map<String, dynamic>?;
// RSS news cards cap at 3 (only when RSS is enabled) final weather = sections?['weather'];
final rssItems = <RssItemMeta>[]; if (weather is List && weather.isNotEmpty) {
if (rssEnabled && isAssistant && meta != null) { // Show the first location's weather card. The widget expects a
final raw = (meta['rss_items'] as List<dynamic>?)?.cast<Map<String, dynamic>>() ?? []; // single location dict; we pass the first one through.
rssItems.addAll(raw.map(RssItemMeta.fromJson).take(3)); weatherData = weather.first as Map<String, dynamic>?;
} else if (weather is Map) {
weatherData = weather as Map<String, dynamic>;
}
} }
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
if (hasWeatherKey) WeatherCard(weather: weatherData), if (weatherData != null) WeatherCard(weather: weatherData),
ChatMessageBubble(message: message), 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 { class _GradientSendButton extends StatelessWidget {
final VoidCallback? onPressed; final VoidCallback? onPressed;
final bool isStreaming; final bool isStreaming;
-236
View File
@@ -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<NewsScreen> createState() => _NewsScreenState();
}
class _NewsScreenState extends ConsumerState<NewsScreen> {
final Set<int> _openingChat = {};
Future<void> _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<void> _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<BriefingFeed> 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<int?>(
value: selectedFeedId,
underline: const SizedBox.shrink(),
items: [
const DropdownMenuItem<int?>(
value: null,
child: Text('All feeds'),
),
...feeds.map(
(f) => DropdownMenuItem<int?>(
value: f.id,
child: Text(f.title),
),
),
],
onChanged: (v) => onChanged(v),
),
],
),
);
}
}
+2 -2
View File
@@ -31,11 +31,11 @@ class _SplashScreenState extends ConsumerState<SplashScreen> {
final status = ref.read(authProvider); final status = ref.read(authProvider);
final hasEverLoggedIn = ref.read(hasEverLoggedInProvider); final hasEverLoggedIn = ref.read(hasEverLoggedInProvider);
if (status == AuthStatus.authenticated) { if (status == AuthStatus.authenticated) {
context.go(Routes.briefing); context.go(Routes.journal);
} else if (status == AuthStatus.offline && hasEverLoggedIn) { } else if (status == AuthStatus.offline && hasEverLoggedIn) {
// Server unreachable but this user has logged in before — land them on // Server unreachable but this user has logged in before — land them on
// the briefing with the offline banner rather than the login screen. // the briefing with the offline banner rather than the login screen.
context.go(Routes.briefing); context.go(Routes.journal);
} else { } else {
context.go(Routes.login); context.go(Routes.login);
} }
@@ -3,24 +3,25 @@ import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
import '../data/models/message.dart'; import '../data/models/message.dart';
class BriefingDigestCard extends StatefulWidget { class JournalPrepCard extends StatefulWidget {
/// The first assistant message from today's briefing, or null if none yet. /// 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; final Message? message;
/// Called when the user taps "Generate now". /// Called when the user taps "Generate now".
final VoidCallback? onGenerateNow; final VoidCallback? onGenerateNow;
const BriefingDigestCard({ const JournalPrepCard({
super.key, super.key,
required this.message, required this.message,
this.onGenerateNow, this.onGenerateNow,
}); });
@override @override
State<BriefingDigestCard> createState() => _BriefingDigestCardState(); State<JournalPrepCard> createState() => _JournalPrepCardState();
} }
class _BriefingDigestCardState extends State<BriefingDigestCard> { class _JournalPrepCardState extends State<JournalPrepCard> {
bool _expanded = false; bool _expanded = false;
@override @override
@@ -43,10 +44,9 @@ class _BriefingDigestCardState extends State<BriefingDigestCard> {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// Header row
Row( Row(
children: [ 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), const SizedBox(width: 8),
Text( Text(
_todayLabel(), _todayLabel(),
@@ -57,11 +57,9 @@ class _BriefingDigestCardState extends State<BriefingDigestCard> {
], ],
), ),
const SizedBox(height: 10), const SizedBox(height: 10),
// Body
if (widget.message == null) ...[ if (widget.message == null) ...[
Text( Text(
'No briefing yet today.', 'No prep yet today.',
style: textTheme.bodyMedium?.copyWith( style: textTheme.bodyMedium?.copyWith(
color: scheme.onSurfaceVariant, color: scheme.onSurfaceVariant,
), ),
-234
View File
@@ -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<String, dynamic> 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<void> _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)),
),
);
}
}
-69
View File
@@ -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/providers/voice_provider.dart';
import 'package:fabled_app/data/models/knowledge_item.dart'; import 'package:fabled_app/data/models/knowledge_item.dart';
import 'package:fabled_app/data/models/note.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'; import 'package:flutter_test/flutter_test.dart';
void main() { 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': <dynamic>[],
'reaction': null,
};
final item = NewsItem.fromJson(json);
expect(item.publishedAt, isNull);
expect(item.reaction, isNull);
expect(item.topics, isEmpty);
});
});
group('CalendarEvent.fromJson', () { group('CalendarEvent.fromJson', () {
test('parses all fields', () { test('parses all fields', () {
final json = { 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);
});
});
} }