Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5bdd4f565b | |||
| dd250788f6 | |||
| 5e48a4fb69 | |||
| 01aa362d3c | |||
| 3c9602c7c9 | |||
| aba0ca6256 | |||
| 70a3279192 | |||
| fc6c9648f9 | |||
| 413b82f724 | |||
| 5f11b344a3 | |||
| 8959b62abe | |||
| c967a49e5a | |||
| 58d4cfab4d | |||
| ee0f354312 | |||
| bdaa5210f0 |
+92
-140
@@ -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,12 +28,12 @@ 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';
|
||||
import 'screens/calendar/calendar_screen.dart';
|
||||
import 'providers/voice_provider.dart';
|
||||
import 'widgets/offline_banner.dart';
|
||||
import 'widgets/voice_mic_button.dart';
|
||||
|
||||
// ChangeNotifier that fires when auth or server URL changes,
|
||||
@@ -44,6 +43,7 @@ class _RouterNotifier extends ChangeNotifier {
|
||||
_RouterNotifier(Ref ref) {
|
||||
ref.listen(authProvider, (_, _) => notifyListeners());
|
||||
ref.listen(serverUrlProvider, (_, _) => notifyListeners());
|
||||
ref.listen(hasEverLoggedInProvider, (_, _) => notifyListeners());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,6 +57,7 @@ final routerProvider = Provider<GoRouter>((ref) {
|
||||
final location = state.matchedLocation;
|
||||
final serverUrl = ref.read(serverUrlProvider);
|
||||
final authStatus = ref.read(authProvider);
|
||||
final hasEverLoggedIn = ref.read(hasEverLoggedInProvider);
|
||||
|
||||
if (serverUrl == null || serverUrl.isEmpty) {
|
||||
if (location != Routes.setup) return Routes.setup;
|
||||
@@ -70,6 +71,16 @@ final routerProvider = Provider<GoRouter>((ref) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Offline and never logged in on this device — can't prove identity,
|
||||
// so gate behind login. Offline + ever-logged-in falls through to
|
||||
// normal navigation with the offline banner surfacing in _Shell.
|
||||
if (authStatus == AuthStatus.offline && !hasEverLoggedIn) {
|
||||
if (location != Routes.login && location != Routes.setup) {
|
||||
return Routes.login;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
routes: [
|
||||
@@ -148,8 +159,8 @@ final routerProvider = Provider<GoRouter>((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,
|
||||
@@ -159,20 +170,16 @@ final routerProvider = Provider<GoRouter>((ref) {
|
||||
path: Routes.conversations,
|
||||
builder: (_, _) => const ConversationsTabScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: Routes.projects,
|
||||
builder: (_, _) => const ProjectsScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: Routes.calendar,
|
||||
builder: (_, _) => const CalendarScreen(),
|
||||
),
|
||||
],
|
||||
),
|
||||
GoRoute(
|
||||
path: Routes.projects,
|
||||
builder: (_, _) => const ProjectsScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: Routes.news,
|
||||
builder: (_, _) => const NewsScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: Routes.calendar,
|
||||
builder: (_, _) => const CalendarScreen(),
|
||||
),
|
||||
],
|
||||
);
|
||||
});
|
||||
@@ -186,12 +193,18 @@ class _Shell extends ConsumerStatefulWidget {
|
||||
}
|
||||
|
||||
class _ShellState extends ConsumerState<_Shell> with WidgetsBindingObserver {
|
||||
static const _tabs = [
|
||||
Routes.briefing,
|
||||
static const _baseTabs = [
|
||||
Routes.journal,
|
||||
Routes.knowledge,
|
||||
Routes.conversations,
|
||||
Routes.projects,
|
||||
];
|
||||
|
||||
List<String> _tabs() => [
|
||||
..._baseTabs,
|
||||
Routes.calendar,
|
||||
];
|
||||
|
||||
// Minimum gap between app-resume refreshes to avoid hammering the server.
|
||||
static const _resumeCooldown = Duration(seconds: 30);
|
||||
DateTime? _lastResumeRefresh;
|
||||
@@ -202,6 +215,8 @@ class _ShellState extends ConsumerState<_Shell> with WidgetsBindingObserver {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
// Clean up any leftover APKs from previous update cycles.
|
||||
ref.read(updateProvider.notifier).cleanup();
|
||||
// Silent update check — only if we haven't already checked this session.
|
||||
final repoUrl = ref.read(forgejoRepoUrlProvider);
|
||||
if (repoUrl != null && repoUrl.isNotEmpty) {
|
||||
@@ -210,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();
|
||||
});
|
||||
}
|
||||
@@ -238,23 +253,22 @@ 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 index.
|
||||
void _refreshTab(int index) {
|
||||
switch (index) {
|
||||
case 0:
|
||||
ref.invalidate(briefingProvider);
|
||||
case 1:
|
||||
ref.read(knowledgeProvider.notifier).refresh();
|
||||
case 2:
|
||||
ref.read(conversationsProvider.notifier).refresh();
|
||||
/// Refresh only the provider backing the given shell tab route.
|
||||
void _refreshTab(String route) {
|
||||
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.calendar) {
|
||||
ref.read(calendarProvider.notifier).refresh();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -267,14 +281,9 @@ class _ShellState extends ConsumerState<_Shell> with WidgetsBindingObserver {
|
||||
}
|
||||
}
|
||||
|
||||
int _tabIndex(String location) {
|
||||
for (var i = 0; i < _tabs.length; i++) {
|
||||
if (location.startsWith(_tabs[i])) return i;
|
||||
}
|
||||
if (location.startsWith(Routes.projects) ||
|
||||
location.startsWith(Routes.news) ||
|
||||
location.startsWith(Routes.calendar)) {
|
||||
return 3;
|
||||
int _tabIndex(String location, List<String> tabs) {
|
||||
for (var i = 0; i < tabs.length; i++) {
|
||||
if (location.startsWith(tabs[i])) return i;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
@@ -294,14 +303,6 @@ class _ShellState extends ConsumerState<_Shell> with WidgetsBindingObserver {
|
||||
context.push(Routes.projects);
|
||||
},
|
||||
),
|
||||
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'),
|
||||
@@ -316,66 +317,15 @@ class _ShellState extends ConsumerState<_Shell> with WidgetsBindingObserver {
|
||||
);
|
||||
}
|
||||
|
||||
void _showUpdateDialog(UpdateState update) {
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
builder: (dialogContext) => Consumer(
|
||||
builder: (context, ref, _) {
|
||||
final state = ref.watch(updateProvider);
|
||||
final isDownloading = state.status == UpdateStatus.downloading;
|
||||
return AlertDialog(
|
||||
title: const Text('Update available'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Version ${state.latestVersion ?? '?'} is ready to install.'),
|
||||
if (state.currentVersion != null)
|
||||
Text(
|
||||
'Installed: v${state.currentVersion}',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
if (isDownloading) ...[
|
||||
const SizedBox(height: 16),
|
||||
LinearProgressIndicator(
|
||||
value: state.downloadProgress > 0
|
||||
? state.downloadProgress
|
||||
: null,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Downloading… '
|
||||
'${(state.downloadProgress * 100).toStringAsFixed(0)}%',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
if (state.status == UpdateStatus.error &&
|
||||
state.errorMessage != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
state.errorMessage!,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(dialogContext),
|
||||
child: const Text('Later'),
|
||||
),
|
||||
if (!isDownloading && state.downloadUrl != null)
|
||||
FilledButton(
|
||||
onPressed: () => ref
|
||||
.read(updateProvider.notifier)
|
||||
.downloadAndInstall(),
|
||||
child: const Text('Download & Install'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
void _showUpdateSnackbar(UpdateState update) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('v${update.latestVersion} ready to install'),
|
||||
duration: const Duration(seconds: 6),
|
||||
action: SnackBarAction(
|
||||
label: 'Install',
|
||||
onPressed: () => ref.read(updateProvider.notifier).install(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -384,18 +334,20 @@ class _ShellState extends ConsumerState<_Shell> with WidgetsBindingObserver {
|
||||
Widget build(BuildContext context) {
|
||||
// Show update dialog once when a new version is detected.
|
||||
ref.listen(updateProvider, (prev, next) {
|
||||
if (next.status == UpdateStatus.available &&
|
||||
prev?.status != UpdateStatus.available) {
|
||||
if (next.status == UpdateStatus.readyToInstall &&
|
||||
prev?.status != UpdateStatus.readyToInstall) {
|
||||
WidgetsBinding.instance
|
||||
.addPostFrameCallback((_) => _showUpdateDialog(next));
|
||||
.addPostFrameCallback((_) => _showUpdateSnackbar(next));
|
||||
}
|
||||
});
|
||||
final tabs = _tabs();
|
||||
final location = GoRouterState.of(context).matchedLocation;
|
||||
final index = _tabIndex(location);
|
||||
final index = _tabIndex(location, tabs);
|
||||
|
||||
// Refresh the incoming tab's data when switching between shell tabs.
|
||||
if (_prevTabIndex != null && _prevTabIndex != index && index < 3) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _refreshTab(index));
|
||||
if (_prevTabIndex != null && _prevTabIndex != index) {
|
||||
final route = index < tabs.length ? tabs[index] : tabs[0];
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _refreshTab(route));
|
||||
}
|
||||
_prevTabIndex = index;
|
||||
|
||||
@@ -407,29 +359,24 @@ class _ShellState extends ConsumerState<_Shell> with WidgetsBindingObserver {
|
||||
body: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
const OfflineBanner(),
|
||||
const _QuickCaptureBar(),
|
||||
Expanded(
|
||||
child: Row(
|
||||
children: [
|
||||
NavigationRail(
|
||||
selectedIndex: index,
|
||||
onDestinationSelected: (i) {
|
||||
if (i == 3) {
|
||||
_showMoreSheet(context);
|
||||
} else {
|
||||
context.go(_tabs[i]);
|
||||
}
|
||||
},
|
||||
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'),
|
||||
),
|
||||
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'),
|
||||
),
|
||||
NavigationRailDestination(
|
||||
@@ -438,9 +385,14 @@ class _ShellState extends ConsumerState<_Shell> with WidgetsBindingObserver {
|
||||
label: Text('Chat'),
|
||||
),
|
||||
NavigationRailDestination(
|
||||
icon: Icon(Icons.more_horiz_outlined),
|
||||
selectedIcon: Icon(Icons.more_horiz),
|
||||
label: Text('More'),
|
||||
icon: Icon(Icons.folder_outlined),
|
||||
selectedIcon: Icon(Icons.folder),
|
||||
label: Text('Projects'),
|
||||
),
|
||||
NavigationRailDestination(
|
||||
icon: Icon(Icons.calendar_month_outlined),
|
||||
selectedIcon: Icon(Icons.calendar_month),
|
||||
label: Text('Calendar'),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -458,6 +410,7 @@ class _ShellState extends ConsumerState<_Shell> with WidgetsBindingObserver {
|
||||
return Scaffold(
|
||||
body: Column(
|
||||
children: [
|
||||
const OfflineBanner(),
|
||||
const _QuickCaptureBar(),
|
||||
Expanded(
|
||||
child: MediaQuery.removePadding(
|
||||
@@ -469,23 +422,23 @@ class _ShellState extends ConsumerState<_Shell> with WidgetsBindingObserver {
|
||||
],
|
||||
),
|
||||
bottomNavigationBar: NavigationBar(
|
||||
selectedIndex: index,
|
||||
selectedIndex: index >= 3 ? 3 : index,
|
||||
onDestinationSelected: (i) {
|
||||
if (i == 3) {
|
||||
_showMoreSheet(context);
|
||||
} else {
|
||||
context.go(_tabs[i]);
|
||||
context.go(tabs[i]);
|
||||
}
|
||||
},
|
||||
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(
|
||||
@@ -523,7 +476,6 @@ class _QuickCaptureBarState extends ConsumerState<_QuickCaptureBar> {
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
ref.read(voiceProvider.notifier).exitVoiceMode();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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/<iso_date>
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,4 +10,13 @@ class SettingsApi {
|
||||
data: {'user_timezone': ianaTimezone},
|
||||
);
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> getAll() async {
|
||||
final response = await _dio.get<Map<String, dynamic>>('/api/settings');
|
||||
return response.data ?? {};
|
||||
}
|
||||
|
||||
Future<void> update(Map<String, String> updates) async {
|
||||
await _dio.put<void>('/api/settings', data: updates);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ class VoiceApi {
|
||||
}
|
||||
}
|
||||
|
||||
/// POST WebM/Opus audio bytes and return the transcript string.
|
||||
/// POST audio bytes (WAV) and return the transcript string.
|
||||
/// [context] is optional recent conversation text passed as initial_prompt
|
||||
/// to Whisper, reducing mishearings of domain-specific words.
|
||||
/// Returns empty string on empty or error response.
|
||||
@@ -49,8 +49,8 @@ class VoiceApi {
|
||||
final fields = <String, dynamic>{
|
||||
'audio': MultipartFile.fromBytes(
|
||||
audioBytes,
|
||||
filename: 'audio.m4a',
|
||||
contentType: DioMediaType('audio', 'mp4'),
|
||||
filename: 'audio.wav',
|
||||
contentType: DioMediaType('audio', 'wav'),
|
||||
),
|
||||
if (context != null && context.isNotEmpty) 'context': context,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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?,
|
||||
);
|
||||
}
|
||||
@@ -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/<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<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,
|
||||
);
|
||||
}
|
||||
@@ -52,7 +52,7 @@ class KnowledgeItem {
|
||||
id: json['id'] as int,
|
||||
noteType: json['note_type'] as String? ?? 'note',
|
||||
title: json['title'] as String? ?? '',
|
||||
body: json['body'] as String? ?? '',
|
||||
body: (json['snippet'] ?? json['body']) as String? ?? '',
|
||||
tags: (json['tags'] as List<dynamic>?)
|
||||
?.map((e) => e as String)
|
||||
.toList() ??
|
||||
|
||||
@@ -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?,
|
||||
);
|
||||
}
|
||||
@@ -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<KnowledgeRepository>((ref) {
|
||||
return KnowledgeRepository(ref.watch(knowledgeApiProvider));
|
||||
});
|
||||
|
||||
final briefingApiProvider = Provider<BriefingApi>((ref) {
|
||||
return BriefingApi(ref.watch(dioProvider));
|
||||
final journalApiProvider = Provider<JournalApi>((ref) {
|
||||
return JournalApi(ref.watch(dioProvider));
|
||||
});
|
||||
|
||||
final settingsApiProvider = Provider<SettingsApi>((ref) {
|
||||
@@ -113,10 +112,6 @@ final voiceRepositoryProvider = Provider<VoiceRepository>((ref) {
|
||||
return VoiceRepository(ref.watch(voiceApiProvider));
|
||||
});
|
||||
|
||||
final newsApiProvider = Provider<NewsApi>((ref) {
|
||||
return NewsApi(ref.watch(dioProvider));
|
||||
});
|
||||
|
||||
final eventsApiProvider = Provider<EventsApi>((ref) {
|
||||
return EventsApi(ref.watch(dioProvider));
|
||||
});
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../core/exceptions.dart';
|
||||
import 'api_client_provider.dart';
|
||||
import 'settings_provider.dart';
|
||||
|
||||
enum AuthStatus { unknown, authenticated, unauthenticated }
|
||||
enum AuthStatus { unknown, authenticated, unauthenticated, offline }
|
||||
|
||||
final authProvider = NotifierProvider<AuthNotifier, AuthStatus>(AuthNotifier.new);
|
||||
|
||||
@@ -14,7 +16,12 @@ class AuthNotifier extends Notifier<AuthStatus> {
|
||||
try {
|
||||
final repo = ref.read(authRepositoryProvider);
|
||||
final ok = await repo.verify();
|
||||
if (ok) {
|
||||
await ref.read(hasEverLoggedInProvider.notifier).markLoggedIn();
|
||||
}
|
||||
state = ok ? AuthStatus.authenticated : AuthStatus.unauthenticated;
|
||||
} on NetworkException {
|
||||
state = AuthStatus.offline;
|
||||
} catch (_) {
|
||||
state = AuthStatus.unauthenticated;
|
||||
}
|
||||
@@ -23,6 +30,7 @@ class AuthNotifier extends Notifier<AuthStatus> {
|
||||
Future<void> login(String username, String password) async {
|
||||
final repo = ref.read(authRepositoryProvider);
|
||||
await repo.login(username, password);
|
||||
await ref.read(hasEverLoggedInProvider.notifier).markLoggedIn();
|
||||
state = AuthStatus.authenticated;
|
||||
}
|
||||
|
||||
|
||||
@@ -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<bool> {
|
||||
@@ -16,23 +16,22 @@ class _BoolNotifier extends Notifier<bool> {
|
||||
bool build() => false;
|
||||
}
|
||||
|
||||
final briefingProvider =
|
||||
AsyncNotifierProvider<BriefingNotifier, BriefingConversation>(
|
||||
BriefingNotifier.new);
|
||||
final journalProvider =
|
||||
AsyncNotifierProvider<JournalNotifier, JournalDay>(JournalNotifier.new);
|
||||
|
||||
class BriefingNotifier extends AsyncNotifier<BriefingConversation> {
|
||||
class JournalNotifier extends AsyncNotifier<JournalDay> {
|
||||
@override
|
||||
Future<BriefingConversation> build() async {
|
||||
return ref.read(briefingApiProvider).getToday();
|
||||
Future<JournalDay> 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<void> 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<BriefingConversation> {
|
||||
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<void> refresh(String slot) async {
|
||||
await ref.read(briefingApiProvider).triggerSlot(slot);
|
||||
/// Force-regenerate today's daily prep then reload.
|
||||
Future<void> 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<void> refreshMessages() async {
|
||||
final current = state.value;
|
||||
if (current == null) {
|
||||
@@ -67,7 +65,7 @@ class BriefingNotifier extends AsyncNotifier<BriefingConversation> {
|
||||
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<BriefingConversation> {
|
||||
}
|
||||
}
|
||||
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<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.
|
||||
/// 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<BriefingConversation> {
|
||||
/// 3. SSE stream with per-event timeout (stall watchdog)
|
||||
/// 4. Poll until complete
|
||||
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;
|
||||
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<BriefingConversation> {
|
||||
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<BriefingConversation> {
|
||||
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<bool> _consumeStream(Stream<ChatStreamEvent> stream) async {
|
||||
const stallTimeout = Duration(seconds: 45);
|
||||
bool streamedContent = false;
|
||||
@@ -208,24 +172,25 @@ class BriefingNotifier extends AsyncNotifier<BriefingConversation> {
|
||||
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<void> _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<BriefingConversation> {
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
ref.read(isBriefingStreamingProvider.notifier).state = false;
|
||||
ref.read(isJournalStreamingProvider.notifier).state = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,12 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'api_client_provider.dart';
|
||||
|
||||
const _kServerUrl = 'server_url';
|
||||
const _kThemeMode = 'theme_mode';
|
||||
const _kForgejoRepoUrl = 'forgejo_repo_url';
|
||||
const _kHasEverLoggedIn = 'has_ever_logged_in';
|
||||
|
||||
final sharedPreferencesProvider = Provider<SharedPreferences>((ref) {
|
||||
throw UnimplementedError('Override in ProviderScope');
|
||||
@@ -82,3 +85,47 @@ class ServerUrlNotifier extends Notifier<String?> {
|
||||
state = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Tracks whether this install has ever completed a successful login.
|
||||
/// Used so offline users who've logged in before land on the briefing (with a
|
||||
/// banner) instead of being dumped onto the login screen as if freshly installed.
|
||||
final hasEverLoggedInProvider =
|
||||
NotifierProvider<HasEverLoggedInNotifier, bool>(HasEverLoggedInNotifier.new);
|
||||
|
||||
class HasEverLoggedInNotifier extends Notifier<bool> {
|
||||
@override
|
||||
bool build() {
|
||||
final prefs = ref.watch(sharedPreferencesProvider);
|
||||
return prefs.getBool(_kHasEverLoggedIn) ?? false;
|
||||
}
|
||||
|
||||
Future<void> markLoggedIn() async {
|
||||
if (state) return;
|
||||
await ref.read(sharedPreferencesProvider).setBool(_kHasEverLoggedIn, true);
|
||||
state = true;
|
||||
}
|
||||
|
||||
Future<void> clear() async {
|
||||
await ref.read(sharedPreferencesProvider).remove(_kHasEverLoggedIn);
|
||||
state = false;
|
||||
}
|
||||
}
|
||||
|
||||
final serverSettingsProvider =
|
||||
AsyncNotifierProvider<ServerSettingsNotifier, Map<String, dynamic>>(
|
||||
ServerSettingsNotifier.new);
|
||||
|
||||
class ServerSettingsNotifier extends AsyncNotifier<Map<String, dynamic>> {
|
||||
@override
|
||||
Future<Map<String, dynamic>> build() async {
|
||||
try {
|
||||
return await ref.read(settingsApiProvider).getAll();
|
||||
} catch (_) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> refresh() async {
|
||||
state = AsyncData(await ref.read(settingsApiProvider).getAll());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:open_file/open_file.dart';
|
||||
@@ -5,7 +7,14 @@ import 'package:package_info_plus/package_info_plus.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
|
||||
enum UpdateStatus { idle, checking, available, downloading, upToDate, error }
|
||||
enum UpdateStatus {
|
||||
idle,
|
||||
checking,
|
||||
downloading,
|
||||
readyToInstall,
|
||||
upToDate,
|
||||
error,
|
||||
}
|
||||
|
||||
class UpdateState {
|
||||
final UpdateStatus status;
|
||||
@@ -14,6 +23,7 @@ class UpdateState {
|
||||
final String? downloadUrl;
|
||||
final double downloadProgress;
|
||||
final String? errorMessage;
|
||||
final String? apkPath;
|
||||
|
||||
const UpdateState({
|
||||
this.status = UpdateStatus.idle,
|
||||
@@ -22,6 +32,7 @@ class UpdateState {
|
||||
this.downloadUrl,
|
||||
this.downloadProgress = 0.0,
|
||||
this.errorMessage,
|
||||
this.apkPath,
|
||||
});
|
||||
|
||||
UpdateState copyWith({
|
||||
@@ -31,6 +42,7 @@ class UpdateState {
|
||||
String? downloadUrl,
|
||||
double? downloadProgress,
|
||||
String? errorMessage,
|
||||
String? apkPath,
|
||||
}) =>
|
||||
UpdateState(
|
||||
status: status ?? this.status,
|
||||
@@ -39,6 +51,7 @@ class UpdateState {
|
||||
downloadUrl: downloadUrl ?? this.downloadUrl,
|
||||
downloadProgress: downloadProgress ?? this.downloadProgress,
|
||||
errorMessage: errorMessage ?? this.errorMessage,
|
||||
apkPath: apkPath ?? this.apkPath,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -46,20 +59,15 @@ class UpdateNotifier extends Notifier<UpdateState> {
|
||||
@override
|
||||
UpdateState build() => const UpdateState();
|
||||
|
||||
/// [repoUrl] is the Forgejo repo page URL, e.g.
|
||||
/// "https://git.example.com/user/fabled_app"
|
||||
Future<void> check(String repoUrl) async {
|
||||
state = state.copyWith(status: UpdateStatus.checking);
|
||||
try {
|
||||
final packageInfo = await PackageInfo.fromPlatform();
|
||||
// Combine versionName + buildNumber to match the YY.MM.DD.N tag format.
|
||||
final currentVersion =
|
||||
'${packageInfo.version}.${packageInfo.buildNumber}';
|
||||
|
||||
// Parse repo URL → Forgejo API endpoint
|
||||
final uri = Uri.parse(repoUrl);
|
||||
final parts =
|
||||
uri.pathSegments.where((s) => s.isNotEmpty).toList();
|
||||
final parts = uri.pathSegments.where((s) => s.isNotEmpty).toList();
|
||||
if (parts.length < 2) throw 'Invalid repository URL (need /owner/repo)';
|
||||
final apiUrl =
|
||||
'${uri.scheme}://${uri.authority}/api/v1/repos/${parts[0]}/${parts[1]}/releases/latest';
|
||||
@@ -70,20 +78,20 @@ class UpdateNotifier extends Notifier<UpdateState> {
|
||||
tagName.startsWith('v') ? tagName.substring(1) : tagName;
|
||||
|
||||
if (_isNewer(latestVersion, currentVersion)) {
|
||||
final assets =
|
||||
(response.data['assets'] as List<dynamic>? ?? [])
|
||||
.cast<Map<String, dynamic>>();
|
||||
final assets = (response.data['assets'] as List<dynamic>? ?? [])
|
||||
.cast<Map<String, dynamic>>();
|
||||
final apk = assets.firstWhere(
|
||||
(a) => (a['name'] as String? ?? '').endsWith('.apk'),
|
||||
orElse: () => {},
|
||||
);
|
||||
if (apk.isNotEmpty) {
|
||||
final downloadUrl = apk['browser_download_url'] as String?;
|
||||
state = state.copyWith(
|
||||
status: UpdateStatus.available,
|
||||
currentVersion: currentVersion,
|
||||
latestVersion: latestVersion,
|
||||
downloadUrl: apk['browser_download_url'] as String?,
|
||||
downloadUrl: downloadUrl,
|
||||
);
|
||||
await _downloadInBackground();
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -101,11 +109,52 @@ class UpdateNotifier extends Notifier<UpdateState> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> downloadAndInstall() async {
|
||||
Future<void> _downloadInBackground() async {
|
||||
if (state.downloadUrl == null) return;
|
||||
state = state.copyWith(status: UpdateStatus.downloading, downloadProgress: 0);
|
||||
|
||||
try {
|
||||
final dir = await _apkDir();
|
||||
await _cleanupApks(dir);
|
||||
|
||||
final path = '${dir.path}/fabled_${state.latestVersion}.apk';
|
||||
|
||||
await Dio().download(
|
||||
state.downloadUrl!,
|
||||
path,
|
||||
onReceiveProgress: (received, total) {
|
||||
if (total > 0) {
|
||||
state = state.copyWith(downloadProgress: received / total);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
final file = File(path);
|
||||
if (!await file.exists() || await file.length() == 0) {
|
||||
state = state.copyWith(
|
||||
status: UpdateStatus.error,
|
||||
errorMessage: 'Download failed — file is empty',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
state = state.copyWith(
|
||||
status: UpdateStatus.readyToInstall,
|
||||
apkPath: path,
|
||||
downloadProgress: 1.0,
|
||||
);
|
||||
} catch (e) {
|
||||
state = state.copyWith(
|
||||
status: UpdateStatus.error,
|
||||
errorMessage: e.toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> install() async {
|
||||
final path = state.apkPath;
|
||||
if (path == null) return;
|
||||
|
||||
// Android 8+ requires explicit per-app "Install unknown apps" approval
|
||||
// beyond the manifest declaration. Check and redirect to Settings if needed.
|
||||
final installPermission = await Permission.requestInstallPackages.status;
|
||||
if (!installPermission.isGranted) {
|
||||
final result = await Permission.requestInstallPackages.request();
|
||||
@@ -119,30 +168,13 @@ class UpdateNotifier extends Notifier<UpdateState> {
|
||||
}
|
||||
}
|
||||
|
||||
state = state.copyWith(status: UpdateStatus.downloading, downloadProgress: 0);
|
||||
|
||||
try {
|
||||
final dir = await getExternalStorageDirectory() ??
|
||||
await getTemporaryDirectory();
|
||||
final path = '${dir.path}/fabled_update.apk';
|
||||
|
||||
await Dio().download(
|
||||
state.downloadUrl!,
|
||||
path,
|
||||
onReceiveProgress: (received, total) {
|
||||
if (total > 0) {
|
||||
state = state.copyWith(downloadProgress: received / total);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
final result = await OpenFile.open(
|
||||
path,
|
||||
type: 'application/vnd.android.package-archive',
|
||||
);
|
||||
|
||||
if (result.type == ResultType.done) {
|
||||
// Installer launched — reset to idle so the dialog closes naturally.
|
||||
state = const UpdateState();
|
||||
} else {
|
||||
state = state.copyWith(
|
||||
@@ -158,8 +190,30 @@ class UpdateNotifier extends Notifier<UpdateState> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove any previously cached APKs.
|
||||
Future<void> cleanup() async {
|
||||
final dir = await _apkDir();
|
||||
await _cleanupApks(dir);
|
||||
}
|
||||
|
||||
void dismiss() => state = const UpdateState();
|
||||
|
||||
Future<Directory> _apkDir() async {
|
||||
return await getExternalStorageDirectory() ??
|
||||
await getTemporaryDirectory();
|
||||
}
|
||||
|
||||
Future<void> _cleanupApks(Directory dir) async {
|
||||
try {
|
||||
final entries = dir.listSync();
|
||||
for (final entry in entries) {
|
||||
if (entry is File && entry.path.endsWith('.apk')) {
|
||||
await entry.delete();
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
bool _isNewer(String latest, String current) {
|
||||
try {
|
||||
final l = latest.split('.').map(int.parse).toList();
|
||||
|
||||
+181
-121
@@ -1,13 +1,14 @@
|
||||
import 'dart:async';
|
||||
import 'dart:collection';
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:just_audio/just_audio.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
import 'package:record/record.dart';
|
||||
import 'package:vad/vad.dart';
|
||||
|
||||
import 'api_client_provider.dart';
|
||||
|
||||
@@ -51,6 +52,53 @@ String stripMarkdownForTts(String text) {
|
||||
.trim();
|
||||
}
|
||||
|
||||
/// Encode float PCM samples (-1..1) as a 16-bit mono WAV at 16 kHz.
|
||||
Uint8List encodeWav(List<double> samples, {int sampleRate = 16000}) {
|
||||
final numSamples = samples.length;
|
||||
final dataSize = numSamples * 2;
|
||||
final fileSize = 44 + dataSize;
|
||||
final buf = ByteData(fileSize);
|
||||
|
||||
// RIFF header
|
||||
buf.setUint8(0, 0x52); // R
|
||||
buf.setUint8(1, 0x49); // I
|
||||
buf.setUint8(2, 0x46); // F
|
||||
buf.setUint8(3, 0x46); // F
|
||||
buf.setUint32(4, fileSize - 8, Endian.little);
|
||||
buf.setUint8(8, 0x57); // W
|
||||
buf.setUint8(9, 0x41); // A
|
||||
buf.setUint8(10, 0x56); // V
|
||||
buf.setUint8(11, 0x45); // E
|
||||
|
||||
// fmt chunk
|
||||
buf.setUint8(12, 0x66); // f
|
||||
buf.setUint8(13, 0x6D); // m
|
||||
buf.setUint8(14, 0x74); // t
|
||||
buf.setUint8(15, 0x20); // (space)
|
||||
buf.setUint32(16, 16, Endian.little); // chunk size
|
||||
buf.setUint16(20, 1, Endian.little); // PCM format
|
||||
buf.setUint16(22, 1, Endian.little); // mono
|
||||
buf.setUint32(24, sampleRate, Endian.little);
|
||||
buf.setUint32(28, sampleRate * 2, Endian.little); // byte rate
|
||||
buf.setUint16(32, 2, Endian.little); // block align
|
||||
buf.setUint16(34, 16, Endian.little); // bits per sample
|
||||
|
||||
// data chunk
|
||||
buf.setUint8(36, 0x64); // d
|
||||
buf.setUint8(37, 0x61); // a
|
||||
buf.setUint8(38, 0x74); // t
|
||||
buf.setUint8(39, 0x61); // a
|
||||
buf.setUint32(40, dataSize, Endian.little);
|
||||
|
||||
for (var i = 0; i < numSamples; i++) {
|
||||
final clamped = samples[i].clamp(-1.0, 1.0);
|
||||
final int16 = (clamped * 32767).round().clamp(-32768, 32767);
|
||||
buf.setInt16(44 + i * 2, int16, Endian.little);
|
||||
}
|
||||
|
||||
return buf.buffer.asUint8List();
|
||||
}
|
||||
|
||||
// ── State ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
enum VoiceMode { idle, recording, transcribing, playing }
|
||||
@@ -59,9 +107,7 @@ class VoiceState {
|
||||
final VoiceMode mode;
|
||||
final bool voiceModeActive;
|
||||
final bool available;
|
||||
/// Normalized mic amplitude 0.0–1.0 while recording. Drives the live
|
||||
/// pulse on VoiceMicButton so the user has obvious feedback that audio
|
||||
/// is actually being picked up.
|
||||
/// Normalized mic amplitude 0.0–1.0 while recording.
|
||||
final double amplitude;
|
||||
|
||||
const VoiceState({
|
||||
@@ -88,22 +134,25 @@ class VoiceState {
|
||||
// ── Provider ──────────────────────────────────────────────────────────────────
|
||||
|
||||
final voiceProvider =
|
||||
NotifierProvider<VoiceNotifier, VoiceState>(VoiceNotifier.new);
|
||||
NotifierProvider.autoDispose<VoiceNotifier, VoiceState>(VoiceNotifier.new);
|
||||
|
||||
// ── Notifier ──────────────────────────────────────────────────────────────────
|
||||
|
||||
class VoiceNotifier extends Notifier<VoiceState> {
|
||||
// Audio I/O
|
||||
AudioRecorder? _recorder;
|
||||
// Audio playback
|
||||
AudioPlayer? _player;
|
||||
StreamSubscription<Amplitude>? _amplitudeSubscription;
|
||||
|
||||
// Recording / silence detection
|
||||
int _recordingStartMs = 0;
|
||||
int _silenceMs = 0;
|
||||
static const _silenceThresholdDb = -40.0;
|
||||
static const _silenceDurationMs = 1500;
|
||||
static const _minRecordingMs = 300;
|
||||
// VAD — sole owner of the microphone
|
||||
VadHandler? _vadHandler;
|
||||
StreamSubscription<void>? _vadSpeechStartSub;
|
||||
StreamSubscription<List<double>>? _vadSpeechEndSub;
|
||||
StreamSubscription<({double isSpeech, double notSpeech, List<double> frame})>?
|
||||
_vadFrameSub;
|
||||
StreamSubscription<String>? _vadErrorSub;
|
||||
bool _speechDetected = false;
|
||||
int _speechStartMs = 0;
|
||||
static const _vadGraceMs = 1500;
|
||||
bool _disposed = false;
|
||||
|
||||
// Voice mode callbacks
|
||||
Future<void> Function(String transcript)? _onTranscript;
|
||||
@@ -115,11 +164,10 @@ class VoiceNotifier extends Notifier<VoiceState> {
|
||||
int _lastSeenLength = 0;
|
||||
bool _streamComplete = false;
|
||||
|
||||
// Last complete assistant response — passed to Whisper as initial_prompt
|
||||
// to reduce STT mishearings of domain-specific words.
|
||||
// Whisper context hint
|
||||
String _lastAssistantContent = '';
|
||||
|
||||
// Empty transcript counter — show feedback after consecutive blanks
|
||||
// Empty transcript counter
|
||||
int _emptyTranscriptCount = 0;
|
||||
|
||||
// TTS playback queue
|
||||
@@ -130,52 +178,51 @@ class VoiceNotifier extends Notifier<VoiceState> {
|
||||
|
||||
@override
|
||||
VoiceState build() {
|
||||
_disposed = false;
|
||||
_player = AudioPlayer();
|
||||
ref.onDispose(() {
|
||||
_amplitudeSubscription?.cancel();
|
||||
_recorder?.dispose();
|
||||
_disposed = true;
|
||||
_cancelSubscriptions();
|
||||
_vadHandler?.dispose();
|
||||
_vadHandler = null;
|
||||
_player?.dispose();
|
||||
_player = null;
|
||||
});
|
||||
return const VoiceState();
|
||||
}
|
||||
|
||||
// ── Public API ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// Enter voice mode. Checks server availability and mic permission first.
|
||||
/// [onTranscript] is called with the transcript when a recording completes.
|
||||
/// [enableTts] — if true, TTS plays when [feedContent] is called.
|
||||
/// [onError] — called with a human-readable message on failure.
|
||||
Future<void> enterVoiceMode({
|
||||
required Future<void> Function(String transcript) onTranscript,
|
||||
bool enableTts = false,
|
||||
required void Function(String message) onError,
|
||||
}) async {
|
||||
if (state.voiceModeActive) {
|
||||
if (state.mode == VoiceMode.recording && !_speechDetected) {
|
||||
onError('No speech detected');
|
||||
}
|
||||
exitVoiceMode();
|
||||
return;
|
||||
}
|
||||
|
||||
// Check server availability — STT is required, TTS is optional.
|
||||
try {
|
||||
final status = await ref.read(voiceRepositoryProvider).checkStatus();
|
||||
if (!status.enabled || !status.stt) {
|
||||
onError('Speech-to-text not available on this server');
|
||||
return;
|
||||
}
|
||||
// Downgrade to STT-only when TTS is unavailable
|
||||
if (!status.tts) enableTts = false;
|
||||
} catch (_) {
|
||||
onError('Could not reach voice service');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check microphone permission
|
||||
var permStatus = await Permission.microphone.request();
|
||||
if (permStatus == PermissionStatus.permanentlyDenied) {
|
||||
onError('Microphone blocked — opening settings');
|
||||
final opened = await openAppSettings();
|
||||
if (!opened) return;
|
||||
// Re-check after user returns from settings
|
||||
permStatus = await Permission.microphone.status;
|
||||
}
|
||||
if (!permStatus.isGranted) {
|
||||
@@ -193,25 +240,11 @@ class VoiceNotifier extends Notifier<VoiceState> {
|
||||
await _startListening();
|
||||
}
|
||||
|
||||
/// Exit voice mode, stop all recording and TTS.
|
||||
void exitVoiceMode() {
|
||||
_amplitudeSubscription?.cancel();
|
||||
_amplitudeSubscription = null;
|
||||
_recorder?.stop();
|
||||
_player?.stop();
|
||||
_ttsQueue.clear();
|
||||
_ttsPlaying = false;
|
||||
_sentenceBuffer = '';
|
||||
_lastSeenLength = 0;
|
||||
_streamComplete = false;
|
||||
_onTranscript = null;
|
||||
_onError = null;
|
||||
state = const VoiceState();
|
||||
_cleanup();
|
||||
if (!_disposed) state = const VoiceState();
|
||||
}
|
||||
|
||||
/// Feed streaming assistant content for TTS synthesis.
|
||||
/// Call from screens with the full [fullContent] string on each update.
|
||||
/// Set [isComplete] to true when the stream has finished.
|
||||
void feedContent(String fullContent, {required bool isComplete}) {
|
||||
if (!state.voiceModeActive || !_enableTts) return;
|
||||
|
||||
@@ -230,106 +263,131 @@ class VoiceNotifier extends Notifier<VoiceState> {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Internal helpers ────────────────────────────────────────────────────────
|
||||
|
||||
void _cleanup() {
|
||||
_cancelSubscriptions();
|
||||
final handler = _vadHandler;
|
||||
_vadHandler = null;
|
||||
handler?.dispose();
|
||||
_player?.stop();
|
||||
_ttsQueue.clear();
|
||||
_ttsPlaying = false;
|
||||
_sentenceBuffer = '';
|
||||
_lastSeenLength = 0;
|
||||
_streamComplete = false;
|
||||
_speechDetected = false;
|
||||
_onTranscript = null;
|
||||
_onError = null;
|
||||
}
|
||||
|
||||
void _cancelSubscriptions() {
|
||||
_vadSpeechStartSub?.cancel();
|
||||
_vadSpeechStartSub = null;
|
||||
_vadSpeechEndSub?.cancel();
|
||||
_vadSpeechEndSub = null;
|
||||
_vadFrameSub?.cancel();
|
||||
_vadFrameSub = null;
|
||||
_vadErrorSub?.cancel();
|
||||
_vadErrorSub = null;
|
||||
}
|
||||
|
||||
// ── Internal recording ──────────────────────────────────────────────────────
|
||||
|
||||
Future<void> _startListening() async {
|
||||
if (!state.voiceModeActive) return;
|
||||
if (_disposed || !state.voiceModeActive) return;
|
||||
|
||||
_silenceMs = 0;
|
||||
_recordingStartMs = DateTime.now().millisecondsSinceEpoch;
|
||||
state = state.copyWith(mode: VoiceMode.recording);
|
||||
|
||||
final dir = _tempDir ?? await getTemporaryDirectory();
|
||||
final path =
|
||||
'${dir.path}/voice_rec_${DateTime.now().millisecondsSinceEpoch}.m4a';
|
||||
_speechDetected = false;
|
||||
_speechStartMs = 0;
|
||||
|
||||
try {
|
||||
// Recreate the recorder each session — the record package can leave
|
||||
// the native AudioRecord in a bad state after stop/error cycles,
|
||||
// and a stale instance is the most common cause of "could not start".
|
||||
_recorder?.dispose();
|
||||
_recorder = AudioRecorder();
|
||||
await _stopVad();
|
||||
_vadHandler = VadHandler.create();
|
||||
|
||||
if (!await _recorder!.hasPermission()) {
|
||||
_onError?.call('Microphone permission was revoked');
|
||||
exitVoiceMode();
|
||||
return;
|
||||
_vadSpeechStartSub = _vadHandler!.onSpeechStart.listen((_) {
|
||||
if (_disposed || !state.voiceModeActive) return;
|
||||
if (!_speechDetected) {
|
||||
_speechDetected = true;
|
||||
_speechStartMs = DateTime.now().millisecondsSinceEpoch;
|
||||
}
|
||||
});
|
||||
|
||||
_vadSpeechEndSub = _vadHandler!.onSpeechEnd.listen((audioSamples) {
|
||||
if (_disposed || !state.voiceModeActive) return;
|
||||
final now = DateTime.now().millisecondsSinceEpoch;
|
||||
final sinceStart = _speechStartMs > 0 ? now - _speechStartMs : 0;
|
||||
if (_speechDetected && sinceStart >= _vadGraceMs) {
|
||||
_stopVad();
|
||||
_handleSpeechEnd(audioSamples);
|
||||
}
|
||||
});
|
||||
|
||||
_vadFrameSub = _vadHandler!.onFrameProcessed.listen((event) {
|
||||
if (_disposed || !state.voiceModeActive) return;
|
||||
final frame = event.frame;
|
||||
if (frame.isEmpty) return;
|
||||
double sumSq = 0;
|
||||
for (final s in frame) {
|
||||
sumSq += s * s;
|
||||
}
|
||||
final rms = sqrt(sumSq / frame.length);
|
||||
final norm = (rms * 4.0).clamp(0.0, 1.0);
|
||||
if ((norm - state.amplitude).abs() > 0.02) {
|
||||
state = state.copyWith(amplitude: norm);
|
||||
}
|
||||
});
|
||||
|
||||
_vadErrorSub = _vadHandler!.onError.listen((msg) {
|
||||
if (_disposed) return;
|
||||
_onError?.call('VAD error: $msg');
|
||||
});
|
||||
|
||||
await _vadHandler!.startListening(model: 'v5');
|
||||
|
||||
// Only show recording UI after the mic is actually open.
|
||||
if (!_disposed && state.voiceModeActive) {
|
||||
state = state.copyWith(mode: VoiceMode.recording, amplitude: 0.0);
|
||||
}
|
||||
|
||||
await _recorder!.start(
|
||||
const RecordConfig(encoder: AudioEncoder.aacLc, sampleRate: 16000),
|
||||
path: path,
|
||||
);
|
||||
|
||||
_amplitudeSubscription?.cancel();
|
||||
_amplitudeSubscription = _recorder!
|
||||
.onAmplitudeChanged(const Duration(milliseconds: 200))
|
||||
.listen(_onAmplitude);
|
||||
} catch (e) {
|
||||
_onError?.call('Microphone error: $e');
|
||||
exitVoiceMode();
|
||||
_cleanup();
|
||||
if (!_disposed) state = const VoiceState();
|
||||
}
|
||||
}
|
||||
|
||||
void _onAmplitude(Amplitude event) {
|
||||
if (!state.voiceModeActive) return;
|
||||
|
||||
final db = event.current;
|
||||
final validDb = !(db.isNaN || db.isInfinite);
|
||||
|
||||
// Normalize dB to 0..1 for the pulse animation. -60dB is dead-quiet,
|
||||
// 0dB is peak; we clamp and bias so the button visibly breathes even
|
||||
// on soft speech without exploding on loud input.
|
||||
if (validDb) {
|
||||
final norm = ((db + 60.0) / 60.0).clamp(0.0, 1.0);
|
||||
if ((norm - state.amplitude).abs() > 0.02) {
|
||||
state = state.copyWith(amplitude: norm);
|
||||
}
|
||||
}
|
||||
|
||||
final elapsed =
|
||||
DateTime.now().millisecondsSinceEpoch - _recordingStartMs;
|
||||
if (elapsed < _minRecordingMs) return;
|
||||
|
||||
final isSilent = !validDb || db < _silenceThresholdDb;
|
||||
|
||||
if (isSilent) {
|
||||
_silenceMs += 200;
|
||||
if (_silenceMs >= _silenceDurationMs) {
|
||||
_amplitudeSubscription?.cancel();
|
||||
_amplitudeSubscription = null;
|
||||
_handleSilence();
|
||||
}
|
||||
} else {
|
||||
_silenceMs = 0;
|
||||
Future<void> _stopVad() async {
|
||||
_cancelSubscriptions();
|
||||
if (_vadHandler != null) {
|
||||
final handler = _vadHandler!;
|
||||
_vadHandler = null;
|
||||
await handler.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handleSilence() async {
|
||||
if (!state.voiceModeActive) return;
|
||||
Future<void> _handleSpeechEnd(List<double> audioSamples) async {
|
||||
if (_disposed || !state.voiceModeActive) return;
|
||||
state = state.copyWith(mode: VoiceMode.transcribing);
|
||||
|
||||
final path = await _recorder!.stop();
|
||||
if (path == null || !state.voiceModeActive) return;
|
||||
|
||||
try {
|
||||
final bytes = await File(path).readAsBytes();
|
||||
await File(path).delete().catchError((_) => File(path));
|
||||
final wavBytes = encodeWav(audioSamples);
|
||||
|
||||
if (!state.voiceModeActive) return;
|
||||
if (_disposed || !state.voiceModeActive) return;
|
||||
|
||||
final transcript = await ref.read(voiceRepositoryProvider).transcribe(
|
||||
bytes,
|
||||
context: _lastAssistantContent.isNotEmpty ? _lastAssistantContent : null,
|
||||
wavBytes,
|
||||
context:
|
||||
_lastAssistantContent.isNotEmpty ? _lastAssistantContent : null,
|
||||
);
|
||||
|
||||
if (!state.voiceModeActive) return;
|
||||
if (_disposed || !state.voiceModeActive) return;
|
||||
|
||||
if (transcript.isEmpty) {
|
||||
_emptyTranscriptCount++;
|
||||
if (_emptyTranscriptCount >= 3) {
|
||||
_onError?.call('No speech detected — tap the mic to try again');
|
||||
exitVoiceMode();
|
||||
_cleanup();
|
||||
if (!_disposed) state = const VoiceState();
|
||||
|
||||
return;
|
||||
}
|
||||
await _startListening();
|
||||
@@ -337,24 +395,25 @@ class VoiceNotifier extends Notifier<VoiceState> {
|
||||
}
|
||||
_emptyTranscriptCount = 0;
|
||||
|
||||
// Reset TTS state for this new turn
|
||||
_sentenceBuffer = '';
|
||||
_lastSeenLength = 0;
|
||||
_streamComplete = false;
|
||||
|
||||
if (_enableTts) {
|
||||
if (_enableTts && !_disposed) {
|
||||
state = state.copyWith(mode: VoiceMode.playing);
|
||||
}
|
||||
|
||||
await _onTranscript?.call(transcript);
|
||||
|
||||
// If TTS is not enabled, loop immediately
|
||||
if (!_enableTts && state.voiceModeActive) {
|
||||
await _startListening();
|
||||
// In STT-only mode (no TTS), return to idle after transcript is sent.
|
||||
// The user taps the mic again to record another message.
|
||||
if (!_enableTts && !_disposed && state.voiceModeActive) {
|
||||
state = state.copyWith(mode: VoiceMode.idle);
|
||||
}
|
||||
} catch (e) {
|
||||
_onError?.call('Voice error: transcription failed');
|
||||
exitVoiceMode();
|
||||
_cleanup();
|
||||
if (!_disposed) state = const VoiceState();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -382,7 +441,7 @@ class VoiceNotifier extends Notifier<VoiceState> {
|
||||
try {
|
||||
final wavBytes =
|
||||
await ref.read(voiceRepositoryProvider).synthesise(text);
|
||||
if (!state.voiceModeActive) return;
|
||||
if (_disposed || !state.voiceModeActive) return;
|
||||
_ttsQueue.add(wavBytes);
|
||||
if (!_ttsPlaying) _drainTtsQueue();
|
||||
} catch (_) {
|
||||
@@ -421,6 +480,7 @@ class VoiceNotifier extends Notifier<VoiceState> {
|
||||
}
|
||||
|
||||
void _checkRestartListening() {
|
||||
if (_disposed) return;
|
||||
if (_streamComplete &&
|
||||
_ttsQueue.isEmpty &&
|
||||
!_ttsPlaying &&
|
||||
|
||||
@@ -72,7 +72,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
|
||||
_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<LoginScreen> {
|
||||
cookieJar: ref.read(cookieJarProvider),
|
||||
onSuccess: () async {
|
||||
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);
|
||||
});
|
||||
@@ -68,8 +68,6 @@ class _ChatScreenState extends ConsumerState<ChatScreen>
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
_controller.dispose();
|
||||
_scrollController.dispose();
|
||||
// Exit voice mode if the user navigates away mid-session.
|
||||
ref.read(voiceProvider.notifier).exitVoiceMode();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
|
||||
@@ -4,30 +4,79 @@ import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../core/constants.dart';
|
||||
import '../../providers/chat_provider.dart';
|
||||
import 'chat_screen.dart';
|
||||
|
||||
class ConversationsTabScreen extends ConsumerWidget {
|
||||
class ConversationsTabScreen extends ConsumerStatefulWidget {
|
||||
const ConversationsTabScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
ConsumerState<ConversationsTabScreen> createState() =>
|
||||
_ConversationsTabScreenState();
|
||||
}
|
||||
|
||||
class _ConversationsTabScreenState
|
||||
extends ConsumerState<ConversationsTabScreen> {
|
||||
int? _selectedConvId;
|
||||
|
||||
Future<void> _createConversation() async {
|
||||
final conv =
|
||||
await ref.read(conversationsProvider.notifier).create('');
|
||||
if (!mounted) return;
|
||||
final isWide = MediaQuery.of(context).size.width >= 600;
|
||||
if (isWide) {
|
||||
setState(() => _selectedConvId = conv.id);
|
||||
} else {
|
||||
context.push(Routes.chat.replaceFirst(':id', '${conv.id}'));
|
||||
}
|
||||
}
|
||||
|
||||
void _openConversation(int id) {
|
||||
final isWide = MediaQuery.of(context).size.width >= 600;
|
||||
if (isWide) {
|
||||
setState(() => _selectedConvId = id);
|
||||
} else {
|
||||
context.push(Routes.chat.replaceFirst(':id', '$id'));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _confirmDelete(int id, String title) async {
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: const Text('Delete conversation?'),
|
||||
content: Text('"$title" will be permanently deleted.'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(dialogContext, false),
|
||||
child: const Text('Cancel')),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(dialogContext, true),
|
||||
child: const Text('Delete')),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (ok == true) {
|
||||
await ref.read(conversationsProvider.notifier).delete(id);
|
||||
if (_selectedConvId == id) {
|
||||
setState(() => _selectedConvId = null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isWide = MediaQuery.of(context).size.width >= 600;
|
||||
final theme = Theme.of(context);
|
||||
final convsAsync = ref.watch(conversationsProvider);
|
||||
|
||||
return Scaffold(
|
||||
final listPanel = Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text('Chat', style: theme.textTheme.titleLarge),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.add),
|
||||
tooltip: 'New conversation',
|
||||
onPressed: () async {
|
||||
final conv = await ref
|
||||
.read(conversationsProvider.notifier)
|
||||
.create('');
|
||||
if (context.mounted) {
|
||||
context.push(Routes.chat.replaceFirst(':id', '${conv.id}'));
|
||||
}
|
||||
},
|
||||
onPressed: _createConversation,
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -49,26 +98,20 @@ class ConversationsTabScreen extends ConsumerWidget {
|
||||
FilledButton.icon(
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('Start a conversation'),
|
||||
onPressed: () async {
|
||||
final conv = await ref
|
||||
.read(conversationsProvider.notifier)
|
||||
.create('');
|
||||
if (context.mounted) {
|
||||
context.push(
|
||||
Routes.chat.replaceFirst(':id', '${conv.id}'));
|
||||
}
|
||||
},
|
||||
onPressed: _createConversation,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
return RefreshIndicator(
|
||||
onRefresh: () => ref.read(conversationsProvider.notifier).refresh(),
|
||||
onRefresh: () =>
|
||||
ref.read(conversationsProvider.notifier).refresh(),
|
||||
child: ListView.builder(
|
||||
itemCount: convs.length,
|
||||
itemBuilder: (ctx, i) {
|
||||
final c = convs[i];
|
||||
final selected = isWide && c.id == _selectedConvId;
|
||||
return ListTile(
|
||||
leading: const Icon(Icons.chat_bubble_outline),
|
||||
title: Text(
|
||||
@@ -79,13 +122,12 @@ class ConversationsTabScreen extends ConsumerWidget {
|
||||
_relativeTime(c.updatedAt),
|
||||
style: theme.textTheme.labelSmall,
|
||||
),
|
||||
selected: selected,
|
||||
trailing: IconButton(
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
onPressed: () =>
|
||||
_confirmDelete(context, ref, c.id, c.title),
|
||||
onPressed: () => _confirmDelete(c.id, c.title),
|
||||
),
|
||||
onTap: () =>
|
||||
ctx.push(Routes.chat.replaceFirst(':id', '${c.id}')),
|
||||
onTap: () => _openConversation(c.id),
|
||||
);
|
||||
},
|
||||
),
|
||||
@@ -93,28 +135,38 @@ class ConversationsTabScreen extends ConsumerWidget {
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _confirmDelete(
|
||||
BuildContext context, WidgetRef ref, int id, String title) async {
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: const Text('Delete conversation?'),
|
||||
content: Text('"$title" will be permanently deleted.'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(dialogContext, false),
|
||||
child: const Text('Cancel')),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(dialogContext, true),
|
||||
child: const Text('Delete')),
|
||||
],
|
||||
),
|
||||
if (!isWide) return listPanel;
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 320,
|
||||
child: listPanel,
|
||||
),
|
||||
const VerticalDivider(width: 1),
|
||||
Expanded(
|
||||
child: _selectedConvId != null
|
||||
? ChatScreen(
|
||||
key: ValueKey(_selectedConvId),
|
||||
conversationId: _selectedConvId!,
|
||||
)
|
||||
: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.chat_bubble_outline,
|
||||
size: 48,
|
||||
color: theme.colorScheme.onSurfaceVariant),
|
||||
const SizedBox(height: 16),
|
||||
Text('Select a conversation',
|
||||
style: theme.textTheme.titleMedium),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
if (ok == true) {
|
||||
await ref.read(conversationsProvider.notifier).delete(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
+97
-159
@@ -5,29 +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/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<BriefingScreen> createState() => _BriefingScreenState();
|
||||
ConsumerState<JournalScreen> createState() => _JournalScreenState();
|
||||
}
|
||||
|
||||
class _BriefingScreenState extends ConsumerState<BriefingScreen>
|
||||
class _JournalScreenState extends ConsumerState<JournalScreen>
|
||||
with WidgetsBindingObserver {
|
||||
final _controller = TextEditingController();
|
||||
final _scrollController = ScrollController();
|
||||
bool _refreshing = false;
|
||||
// rss_item_id -> 'up' | 'down' | null
|
||||
final Map<int, String?> _reactions = {};
|
||||
|
||||
Timer? _pollTimer;
|
||||
bool _appInForeground = true;
|
||||
@@ -36,31 +32,29 @@ class _BriefingScreenState extends ConsumerState<BriefingScreen>
|
||||
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<void> _pullToRefresh() async {
|
||||
try {
|
||||
await ref.read(briefingProvider.notifier).refreshMessages();
|
||||
await ref.read(journalProvider.notifier).refreshMessages();
|
||||
} catch (_) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
@@ -76,7 +70,6 @@ class _BriefingScreenState extends ConsumerState<BriefingScreen>
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
_controller.dispose();
|
||||
_scrollController.dispose();
|
||||
ref.read(voiceProvider.notifier).exitVoiceMode();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -97,7 +90,7 @@ class _BriefingScreenState extends ConsumerState<BriefingScreen>
|
||||
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<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 {
|
||||
final voice = ref.read(voiceProvider);
|
||||
if (voice.voiceModeActive) {
|
||||
@@ -153,7 +113,7 @@ class _BriefingScreenState extends ConsumerState<BriefingScreen>
|
||||
}
|
||||
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<BriefingScreen>
|
||||
Future<void> _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<BriefingScreen>
|
||||
|
||||
@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<BriefingScreen>
|
||||
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,106 +189,93 @@ class _BriefingScreenState extends ConsumerState<BriefingScreen>
|
||||
else
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh_outlined),
|
||||
tooltip: 'Generate briefing',
|
||||
tooltip: 'Regenerate prep',
|
||||
onPressed: _refresh,
|
||||
),
|
||||
PopupMenuButton<String>(
|
||||
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) {
|
||||
return Column(
|
||||
data: (day) {
|
||||
final isWide = MediaQuery.of(context).size.width >= 600;
|
||||
Widget body = Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: RefreshIndicator(
|
||||
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,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Progress bar while streaming
|
||||
if (isStreaming)
|
||||
LinearProgressIndicator(
|
||||
minHeight: 2,
|
||||
color: scheme.primary,
|
||||
),
|
||||
|
||||
// Voice mode banner
|
||||
if (voiceState.voiceModeActive)
|
||||
Container(
|
||||
width: double.infinity,
|
||||
@@ -344,7 +290,6 @@ class _BriefingScreenState extends ConsumerState<BriefingScreen>
|
||||
),
|
||||
),
|
||||
),
|
||||
// Reply bar
|
||||
const Divider(height: 1),
|
||||
SafeArea(
|
||||
child: Padding(
|
||||
@@ -357,7 +302,7 @@ class _BriefingScreenState extends ConsumerState<BriefingScreen>
|
||||
decoration: InputDecoration(
|
||||
hintText: voiceState.voiceModeActive
|
||||
? 'Listening…'
|
||||
: 'Reply to your briefing…',
|
||||
: 'Tell your journal…',
|
||||
hintStyle: voiceState.voiceModeActive
|
||||
? const TextStyle(fontStyle: FontStyle.italic)
|
||||
: null,
|
||||
@@ -392,6 +337,15 @@ class _BriefingScreenState extends ConsumerState<BriefingScreen>
|
||||
),
|
||||
],
|
||||
);
|
||||
if (isWide) {
|
||||
body = Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 700),
|
||||
child: body,
|
||||
),
|
||||
);
|
||||
}
|
||||
return body;
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -411,62 +365,46 @@ class _BriefingScreenState extends ConsumerState<BriefingScreen>
|
||||
}
|
||||
}
|
||||
|
||||
/// 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<int, String?> reactions;
|
||||
final void Function(int itemId, String reaction) onReaction;
|
||||
final void Function(int convId, int itemId) onDiscuss;
|
||||
|
||||
const _BriefingMessageItem({
|
||||
required this.message,
|
||||
required this.convId,
|
||||
required this.reactions,
|
||||
required this.onReaction,
|
||||
required this.onDiscuss,
|
||||
});
|
||||
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<String, dynamic>? : null;
|
||||
|
||||
// RSS news cards — cap at 3
|
||||
final rssItemsRaw = isAssistant && meta != null
|
||||
? (meta['rss_items'] as List<dynamic>?)?.cast<Map<String, dynamic>>() ?? []
|
||||
: <Map<String, dynamic>>[];
|
||||
final rssItems = rssItemsRaw.map(RssItemMeta.fromJson).take(3).toList();
|
||||
Map<String, dynamic>? weatherData;
|
||||
if (isPrep) {
|
||||
// The journal prep stores its structured data under metadata.sections.
|
||||
final sections = meta['sections'] as Map<String, dynamic>?;
|
||||
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<String, dynamic>?;
|
||||
} else if (weather is Map) {
|
||||
weatherData = weather as Map<String, dynamic>;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -186,18 +186,57 @@ class _KnowledgeScreenState extends ConsumerState<KnowledgeScreen>
|
||||
}
|
||||
return RefreshIndicator(
|
||||
onRefresh: () => ref.read(knowledgeProvider.notifier).refresh(),
|
||||
child: ListView.separated(
|
||||
controller: _scrollController,
|
||||
itemCount: items.length + (state.isLoadingBatch ? 1 : 0),
|
||||
separatorBuilder: (_, _) => const Divider(height: 1),
|
||||
itemBuilder: (_, i) {
|
||||
if (i >= items.length) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final cols = constraints.maxWidth >= 900
|
||||
? 3
|
||||
: constraints.maxWidth >= 600
|
||||
? 2
|
||||
: 1;
|
||||
if (cols == 1) {
|
||||
return ListView.separated(
|
||||
controller: _scrollController,
|
||||
itemCount: items.length + (state.isLoadingBatch ? 1 : 0),
|
||||
separatorBuilder: (_, _) => const Divider(height: 1),
|
||||
itemBuilder: (_, i) {
|
||||
if (i >= items.length) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
return KnowledgeItemCard(item: items[i]);
|
||||
},
|
||||
);
|
||||
}
|
||||
return KnowledgeItemCard(item: items[i]);
|
||||
return CustomScrollView(
|
||||
controller: _scrollController,
|
||||
slivers: [
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
sliver: SliverGrid(
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: cols,
|
||||
crossAxisSpacing: 8,
|
||||
mainAxisSpacing: 8,
|
||||
childAspectRatio: 1.8,
|
||||
),
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(_, i) {
|
||||
if (i >= items.length) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator());
|
||||
}
|
||||
return KnowledgeItemCard(item: items[i])
|
||||
.buildGridCard(context);
|
||||
},
|
||||
childCount:
|
||||
items.length + (state.isLoadingBatch ? 1 : 0),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,187 +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.')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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: ListView.builder(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
|
||||
itemCount: news.items.length + 1,
|
||||
itemBuilder: (_, i) {
|
||||
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],
|
||||
onReaction: (itemId, reaction) => ref
|
||||
.read(newsProvider.notifier)
|
||||
.toggleReaction(itemId, reaction),
|
||||
onDiscuss: _openingChat.contains(item.id)
|
||||
? null
|
||||
: () => _handleDiscuss(item.id),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -92,7 +92,7 @@ class SettingsScreen extends ConsumerWidget {
|
||||
child: const Text('Check'),
|
||||
),
|
||||
),
|
||||
if (update.status == UpdateStatus.available ||
|
||||
if (update.status == UpdateStatus.readyToInstall ||
|
||||
update.status == UpdateStatus.downloading)
|
||||
_UpdateTile(update: update),
|
||||
if (update.status == UpdateStatus.error)
|
||||
@@ -131,7 +131,7 @@ class SettingsScreen extends ConsumerWidget {
|
||||
if (update.status == UpdateStatus.upToDate) {
|
||||
return Text('v$current — up to date');
|
||||
}
|
||||
if (update.status == UpdateStatus.available ||
|
||||
if (update.status == UpdateStatus.readyToInstall ||
|
||||
update.status == UpdateStatus.downloading) {
|
||||
return Text('v$current installed');
|
||||
}
|
||||
@@ -202,12 +202,12 @@ class _UpdateTile extends ConsumerWidget {
|
||||
'${(update.downloadProgress * 100).toStringAsFixed(0)}%'),
|
||||
],
|
||||
)
|
||||
: const Text('Tap to download and install'),
|
||||
: const Text('Ready to install'),
|
||||
trailing: isDownloading
|
||||
? null
|
||||
: FilledButton(
|
||||
onPressed: () =>
|
||||
ref.read(updateProvider.notifier).downloadAndInstall(),
|
||||
ref.read(updateProvider.notifier).install(),
|
||||
child: const Text('Install'),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -29,8 +29,13 @@ class _SplashScreenState extends ConsumerState<SplashScreen> {
|
||||
await ref.read(authProvider.notifier).verify();
|
||||
if (!mounted) return;
|
||||
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.journal);
|
||||
} else {
|
||||
context.go(Routes.login);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
import 'dart:math' show min;
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../data/models/message.dart';
|
||||
import '../providers/api_client_provider.dart';
|
||||
import '../providers/settings_provider.dart';
|
||||
import 'tool_call_chip.dart';
|
||||
|
||||
class ChatMessageBubble extends StatelessWidget {
|
||||
class ChatMessageBubble extends ConsumerWidget {
|
||||
final Message message;
|
||||
final String streamingStatus;
|
||||
const ChatMessageBubble({
|
||||
@@ -16,9 +21,11 @@ class ChatMessageBubble extends StatelessWidget {
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final isUser = message.role == MessageRole.user;
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final serverUrl = ref.watch(serverUrlProvider) ?? '';
|
||||
final dio = ref.watch(dioProvider);
|
||||
final isGenerating = message.status == 'generating';
|
||||
final toolCalls = message.toolCalls ?? const [];
|
||||
|
||||
@@ -116,6 +123,14 @@ class ChatMessageBubble extends StatelessWidget {
|
||||
if (message.content.isNotEmpty)
|
||||
MarkdownBody(
|
||||
data: message.content,
|
||||
imageBuilder: (uri, title, alt) {
|
||||
return _AuthImage(
|
||||
uri: uri,
|
||||
alt: alt,
|
||||
serverUrl: serverUrl,
|
||||
dio: dio,
|
||||
);
|
||||
},
|
||||
styleSheet: MarkdownStyleSheet(
|
||||
p: TextStyle(
|
||||
color: isUser
|
||||
@@ -161,3 +176,69 @@ class ChatMessageBubble extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AuthImage extends StatefulWidget {
|
||||
final Uri uri;
|
||||
final String? alt;
|
||||
final String serverUrl;
|
||||
final Dio dio;
|
||||
|
||||
const _AuthImage({
|
||||
required this.uri,
|
||||
this.alt,
|
||||
required this.serverUrl,
|
||||
required this.dio,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_AuthImage> createState() => _AuthImageState();
|
||||
}
|
||||
|
||||
class _AuthImageState extends State<_AuthImage> {
|
||||
late Future<Uint8List> _future;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_future = _fetchImage();
|
||||
}
|
||||
|
||||
Future<Uint8List> _fetchImage() async {
|
||||
var url = widget.uri.toString();
|
||||
if (url.startsWith('/')) {
|
||||
url = '${widget.serverUrl}$url';
|
||||
}
|
||||
final response = await widget.dio.get<List<int>>(
|
||||
url,
|
||||
options: Options(responseType: ResponseType.bytes),
|
||||
);
|
||||
return Uint8List.fromList(response.data!);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FutureBuilder<Uint8List>(
|
||||
future: _future,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||
return const SizedBox(
|
||||
height: 100,
|
||||
child: Center(child: CircularProgressIndicator(strokeWidth: 2)),
|
||||
);
|
||||
}
|
||||
if (snapshot.hasError || !snapshot.hasData) {
|
||||
return Text(widget.alt ?? 'Image failed to load');
|
||||
}
|
||||
return ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Image.memory(
|
||||
snapshot.data!,
|
||||
fit: BoxFit.contain,
|
||||
errorBuilder: (_, _, _) =>
|
||||
Text(widget.alt ?? 'Image failed to load'),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<BriefingDigestCard> createState() => _BriefingDigestCardState();
|
||||
State<JournalPrepCard> createState() => _JournalPrepCardState();
|
||||
}
|
||||
|
||||
class _BriefingDigestCardState extends State<BriefingDigestCard> {
|
||||
class _JournalPrepCardState extends State<JournalPrepCard> {
|
||||
bool _expanded = false;
|
||||
|
||||
@override
|
||||
@@ -43,10 +44,9 @@ class _BriefingDigestCardState extends State<BriefingDigestCard> {
|
||||
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<BriefingDigestCard> {
|
||||
],
|
||||
),
|
||||
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,
|
||||
),
|
||||
@@ -27,12 +27,24 @@ class KnowledgeItemCard extends StatelessWidget {
|
||||
|
||||
String? get _subtitle {
|
||||
if (item.noteType == 'task') {
|
||||
if (item.body.trim().isNotEmpty) {
|
||||
final preview = item.body.trim().replaceAll('\n', ' ');
|
||||
return preview.length > 200 ? '${preview.substring(0, 200)}…' : preview;
|
||||
}
|
||||
if (item.dueDate != null) return 'Due ${item.dueDate}';
|
||||
return item.status;
|
||||
}
|
||||
if (item.body.trim().isEmpty) return null;
|
||||
final preview = item.body.trim().replaceAll('\n', ' ');
|
||||
return preview.length > 120 ? '${preview.substring(0, 120)}…' : preview;
|
||||
return preview.length > 200 ? '${preview.substring(0, 200)}…' : preview;
|
||||
}
|
||||
|
||||
void _onTap(BuildContext context) {
|
||||
if (item.noteType == 'task') {
|
||||
context.push('/tasks/${item.id}/edit');
|
||||
} else {
|
||||
context.push('/notes/${item.id}');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -53,13 +65,64 @@ class KnowledgeItemCard extends StatelessWidget {
|
||||
)
|
||||
: null,
|
||||
trailing: item.tags.isNotEmpty ? _TagChips(tags: item.tags) : null,
|
||||
onTap: () {
|
||||
if (item.noteType == 'task') {
|
||||
context.push('/tasks/${item.id}/edit');
|
||||
} else {
|
||||
context.push('/notes/${item.id}');
|
||||
}
|
||||
},
|
||||
onTap: () => _onTap(context),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildGridCard(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
return Card(
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
side: BorderSide(color: scheme.outlineVariant),
|
||||
),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
onTap: () => _onTap(context),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(_icon, size: 18, color: _statusColor(context)),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
item.title.isEmpty ? '(untitled)' : item.title,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: textTheme.titleSmall
|
||||
?.copyWith(fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (_subtitle != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
_subtitle!,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
maxLines: 4,
|
||||
style: textTheme.bodySmall?.copyWith(
|
||||
color: scheme.onSurfaceVariant,
|
||||
height: 1.4,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
if (item.tags.isNotEmpty) ...[
|
||||
const Spacer(),
|
||||
_TagChips(tags: item.tags),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,232 +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;
|
||||
|
||||
const NewsCard({
|
||||
super.key,
|
||||
required this.item,
|
||||
required this.reaction,
|
||||
required this.onReaction,
|
||||
this.onDiscuss,
|
||||
});
|
||||
|
||||
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: 2,
|
||||
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)),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../providers/auth_provider.dart';
|
||||
|
||||
/// Sticky banner shown when the backend is unreachable. Surfaces a retry
|
||||
/// action and reserves space for future "last sync X min ago" messaging once
|
||||
/// Tier 2 offline caching lands.
|
||||
class OfflineBanner extends ConsumerStatefulWidget {
|
||||
const OfflineBanner({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<OfflineBanner> createState() => _OfflineBannerState();
|
||||
}
|
||||
|
||||
class _OfflineBannerState extends ConsumerState<OfflineBanner> {
|
||||
bool _retrying = false;
|
||||
|
||||
Future<void> _retry() async {
|
||||
if (_retrying) return;
|
||||
setState(() => _retrying = true);
|
||||
try {
|
||||
await ref.read(authProvider.notifier).verify();
|
||||
} finally {
|
||||
if (mounted) setState(() => _retrying = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final status = ref.watch(authProvider);
|
||||
if (status != AuthStatus.offline) return const SizedBox.shrink();
|
||||
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
return Material(
|
||||
color: scheme.errorContainer,
|
||||
child: SafeArea(
|
||||
bottom: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.cloud_off_outlined,
|
||||
size: 18, color: scheme.onErrorContainer),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Offline — showing cached data.',
|
||||
style: TextStyle(color: scheme.onErrorContainer),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: _retrying ? null : _retry,
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: scheme.onErrorContainer,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
),
|
||||
child: _retrying
|
||||
? const SizedBox(
|
||||
width: 14,
|
||||
height: 14,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Text('Retry'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
|
||||
)
|
||||
|
||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||
vad
|
||||
)
|
||||
|
||||
set(PLUGIN_BUNDLED_LIBRARIES)
|
||||
|
||||
@@ -11,7 +11,7 @@ import flutter_timezone
|
||||
import just_audio
|
||||
import open_file_mac
|
||||
import package_info_plus
|
||||
import record_darwin
|
||||
import record_macos
|
||||
import shared_preferences_foundation
|
||||
import url_launcher_macos
|
||||
|
||||
@@ -22,7 +22,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
JustAudioPlugin.register(with: registry.registrar(forPlugin: "JustAudioPlugin"))
|
||||
OpenFilePlugin.register(with: registry.registrar(forPlugin: "OpenFilePlugin"))
|
||||
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
|
||||
RecordPlugin.register(with: registry.registrar(forPlugin: "RecordPlugin"))
|
||||
RecordMacOsPlugin.register(with: registry.registrar(forPlugin: "RecordMacOsPlugin"))
|
||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
|
||||
}
|
||||
|
||||
+23
-7
@@ -804,10 +804,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: record
|
||||
sha256: "2e3d56d196abcd69f1046339b75e5f3855b2406fc087e5991f6703f188aa03a6"
|
||||
sha256: d5b6b334f3ab02460db6544e08583c942dbf23e3504bf1e14fd4cbe3d9409277
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.2.1"
|
||||
version: "6.2.0"
|
||||
record_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -816,22 +816,30 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.5.1"
|
||||
record_darwin:
|
||||
record_ios:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: record_darwin
|
||||
sha256: e487eccb19d82a9a39cd0126945cfc47b9986e0df211734e2788c95e3f63c82c
|
||||
name: record_ios
|
||||
sha256: "8df7c136131bd05efc19256af29b2ba6ccc000ccc2c80d4b6b6d7a8d21a3b5a9"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.2"
|
||||
version: "1.2.0"
|
||||
record_linux:
|
||||
dependency: "direct overridden"
|
||||
dependency: transitive
|
||||
description:
|
||||
name: record_linux
|
||||
sha256: c31a35cc158cd666fc6395f7f56fc054f31685571684be6b97670a27649ce5c7
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.0"
|
||||
record_macos:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: record_macos
|
||||
sha256: "084902e63fc9c0c224c29203d6c75f0bdf9b6a40536c9d916393c8f4c4256488"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.1"
|
||||
record_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1165,6 +1173,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.5.3"
|
||||
vad:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: vad
|
||||
sha256: ef6c8b12c5af7a6a519ff5684f074b8a2ac00c434705f544af379ea77bccd258
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.0.7+1"
|
||||
vector_math:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
+2
-4
@@ -28,13 +28,11 @@ dependencies:
|
||||
google_fonts: ^8.0.2
|
||||
flutter_timezone: ^5.0.2
|
||||
url_launcher: ^6.3.1
|
||||
record: ^5.0.0
|
||||
record: ^6.2.0
|
||||
vad: ^0.0.7
|
||||
just_audio: ^0.9.39
|
||||
table_calendar: ^3.1.2
|
||||
|
||||
dependency_overrides:
|
||||
record_linux: ^1.3.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
|
||||
@@ -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': <dynamic>[],
|
||||
'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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
|
||||
)
|
||||
|
||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||
vad
|
||||
)
|
||||
|
||||
set(PLUGIN_BUNDLED_LIBRARIES)
|
||||
|
||||
Reference in New Issue
Block a user