Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c3a650e3f5 | |||
| a77b71e0e2 | |||
| 116dc42923 | |||
| 76aff4ea9e | |||
| 7df7b5ff85 | |||
| fe10067761 | |||
| 6ef658558a | |||
| b9e68e3bc8 | |||
| 1d9e4af6f3 | |||
| 0f05f47eef | |||
| 5bdd4f565b | |||
| dd250788f6 | |||
| 5e48a4fb69 | |||
| 01aa362d3c | |||
| 3c9602c7c9 | |||
| aba0ca6256 | |||
| 70a3279192 | |||
| fc6c9648f9 | |||
| 413b82f724 | |||
| 5f11b344a3 | |||
| 8959b62abe | |||
| c967a49e5a | |||
| 58d4cfab4d |
+134
-152
@@ -1,4 +1,5 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:lucide_icons/lucide_icons.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
@@ -6,20 +7,20 @@ import 'package:flutter_timezone/flutter_timezone.dart';
|
|||||||
|
|
||||||
import 'core/constants.dart';
|
import 'core/constants.dart';
|
||||||
import 'core/theme.dart';
|
import 'core/theme.dart';
|
||||||
|
import 'data/repositories/write_queue.dart';
|
||||||
import 'providers/api_client_provider.dart';
|
import 'providers/api_client_provider.dart';
|
||||||
import 'providers/auth_provider.dart';
|
import 'providers/auth_provider.dart';
|
||||||
import 'core/exceptions.dart';
|
import 'core/exceptions.dart';
|
||||||
import 'providers/capture_queue_provider.dart';
|
import 'providers/capture_queue_provider.dart';
|
||||||
import 'providers/capture_work_queue_provider.dart';
|
import 'providers/capture_work_queue_provider.dart';
|
||||||
import 'providers/briefing_provider.dart';
|
|
||||||
import 'providers/calendar_provider.dart';
|
import 'providers/calendar_provider.dart';
|
||||||
import 'providers/chat_provider.dart';
|
import 'providers/chat_provider.dart';
|
||||||
|
import 'providers/journal_provider.dart';
|
||||||
import 'providers/knowledge_provider.dart';
|
import 'providers/knowledge_provider.dart';
|
||||||
import 'providers/news_provider.dart';
|
|
||||||
import 'providers/settings_provider.dart';
|
import 'providers/settings_provider.dart';
|
||||||
import 'providers/update_provider.dart';
|
import 'providers/update_provider.dart';
|
||||||
import 'screens/auth/login_screen.dart';
|
import 'screens/auth/login_screen.dart';
|
||||||
import 'screens/briefing/briefing_screen.dart';
|
import 'screens/journal/journal_screen.dart';
|
||||||
import 'screens/knowledge/knowledge_screen.dart';
|
import 'screens/knowledge/knowledge_screen.dart';
|
||||||
import 'screens/library/project_tasks_screen.dart';
|
import 'screens/library/project_tasks_screen.dart';
|
||||||
import 'screens/chat/chat_screen.dart';
|
import 'screens/chat/chat_screen.dart';
|
||||||
@@ -29,12 +30,12 @@ import 'screens/projects/project_edit_screen.dart';
|
|||||||
import 'screens/projects/projects_screen.dart';
|
import 'screens/projects/projects_screen.dart';
|
||||||
import 'screens/notes/note_edit_screen.dart';
|
import 'screens/notes/note_edit_screen.dart';
|
||||||
import 'screens/settings/settings_screen.dart';
|
import 'screens/settings/settings_screen.dart';
|
||||||
import 'screens/news/news_screen.dart';
|
|
||||||
import 'screens/setup/setup_screen.dart';
|
import 'screens/setup/setup_screen.dart';
|
||||||
import 'screens/splash/splash_screen.dart';
|
import 'screens/splash/splash_screen.dart';
|
||||||
import 'screens/tasks/task_edit_screen.dart';
|
import 'screens/tasks/task_edit_screen.dart';
|
||||||
import 'screens/calendar/calendar_screen.dart';
|
import 'screens/calendar/calendar_screen.dart';
|
||||||
import 'providers/voice_provider.dart';
|
import 'providers/voice_provider.dart';
|
||||||
|
import 'widgets/offline_banner.dart';
|
||||||
import 'widgets/voice_mic_button.dart';
|
import 'widgets/voice_mic_button.dart';
|
||||||
|
|
||||||
// ChangeNotifier that fires when auth or server URL changes,
|
// ChangeNotifier that fires when auth or server URL changes,
|
||||||
@@ -44,6 +45,7 @@ class _RouterNotifier extends ChangeNotifier {
|
|||||||
_RouterNotifier(Ref ref) {
|
_RouterNotifier(Ref ref) {
|
||||||
ref.listen(authProvider, (_, _) => notifyListeners());
|
ref.listen(authProvider, (_, _) => notifyListeners());
|
||||||
ref.listen(serverUrlProvider, (_, _) => notifyListeners());
|
ref.listen(serverUrlProvider, (_, _) => notifyListeners());
|
||||||
|
ref.listen(hasEverLoggedInProvider, (_, _) => notifyListeners());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,6 +59,7 @@ final routerProvider = Provider<GoRouter>((ref) {
|
|||||||
final location = state.matchedLocation;
|
final location = state.matchedLocation;
|
||||||
final serverUrl = ref.read(serverUrlProvider);
|
final serverUrl = ref.read(serverUrlProvider);
|
||||||
final authStatus = ref.read(authProvider);
|
final authStatus = ref.read(authProvider);
|
||||||
|
final hasEverLoggedIn = ref.read(hasEverLoggedInProvider);
|
||||||
|
|
||||||
if (serverUrl == null || serverUrl.isEmpty) {
|
if (serverUrl == null || serverUrl.isEmpty) {
|
||||||
if (location != Routes.setup) return Routes.setup;
|
if (location != Routes.setup) return Routes.setup;
|
||||||
@@ -70,6 +73,16 @@ final routerProvider = Provider<GoRouter>((ref) {
|
|||||||
return null;
|
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;
|
return null;
|
||||||
},
|
},
|
||||||
routes: [
|
routes: [
|
||||||
@@ -148,8 +161,8 @@ final routerProvider = Provider<GoRouter>((ref) {
|
|||||||
builder: (context, state, child) => _Shell(child: child),
|
builder: (context, state, child) => _Shell(child: child),
|
||||||
routes: [
|
routes: [
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: Routes.briefing,
|
path: Routes.journal,
|
||||||
builder: (_, _) => const BriefingScreen(),
|
builder: (_, _) => const JournalScreen(),
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: Routes.knowledge,
|
path: Routes.knowledge,
|
||||||
@@ -159,20 +172,16 @@ final routerProvider = Provider<GoRouter>((ref) {
|
|||||||
path: Routes.conversations,
|
path: Routes.conversations,
|
||||||
builder: (_, _) => const ConversationsTabScreen(),
|
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 +195,18 @@ class _Shell extends ConsumerStatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _ShellState extends ConsumerState<_Shell> with WidgetsBindingObserver {
|
class _ShellState extends ConsumerState<_Shell> with WidgetsBindingObserver {
|
||||||
static const _tabs = [
|
static const _baseTabs = [
|
||||||
Routes.briefing,
|
Routes.journal,
|
||||||
Routes.knowledge,
|
Routes.knowledge,
|
||||||
Routes.conversations,
|
Routes.conversations,
|
||||||
|
Routes.projects,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
List<String> _tabs() => [
|
||||||
|
..._baseTabs,
|
||||||
|
Routes.calendar,
|
||||||
|
];
|
||||||
|
|
||||||
// Minimum gap between app-resume refreshes to avoid hammering the server.
|
// Minimum gap between app-resume refreshes to avoid hammering the server.
|
||||||
static const _resumeCooldown = Duration(seconds: 30);
|
static const _resumeCooldown = Duration(seconds: 30);
|
||||||
DateTime? _lastResumeRefresh;
|
DateTime? _lastResumeRefresh;
|
||||||
@@ -202,6 +217,8 @@ class _ShellState extends ConsumerState<_Shell> with WidgetsBindingObserver {
|
|||||||
super.initState();
|
super.initState();
|
||||||
WidgetsBinding.instance.addObserver(this);
|
WidgetsBinding.instance.addObserver(this);
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
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.
|
// Silent update check — only if we haven't already checked this session.
|
||||||
final repoUrl = ref.read(forgejoRepoUrlProvider);
|
final repoUrl = ref.read(forgejoRepoUrlProvider);
|
||||||
if (repoUrl != null && repoUrl.isNotEmpty) {
|
if (repoUrl != null && repoUrl.isNotEmpty) {
|
||||||
@@ -210,7 +227,7 @@ class _ShellState extends ConsumerState<_Shell> with WidgetsBindingObserver {
|
|||||||
ref.read(updateProvider.notifier).check(repoUrl);
|
ref.read(updateProvider.notifier).check(repoUrl);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Sync device timezone to backend so briefing and chat use local time.
|
// Sync device timezone to backend so journal and chat use local time.
|
||||||
_syncTimezone();
|
_syncTimezone();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -238,23 +255,22 @@ class _ShellState extends ConsumerState<_Shell> with WidgetsBindingObserver {
|
|||||||
void _refreshAll() {
|
void _refreshAll() {
|
||||||
ref.read(conversationsProvider.notifier).refresh();
|
ref.read(conversationsProvider.notifier).refresh();
|
||||||
ref.read(calendarProvider.notifier).refresh();
|
ref.read(calendarProvider.notifier).refresh();
|
||||||
ref.read(newsProvider.notifier).refresh();
|
|
||||||
ref.read(knowledgeProvider.notifier).refresh();
|
ref.read(knowledgeProvider.notifier).refresh();
|
||||||
// briefingProvider is an AsyncNotifier family; invalidating is safe
|
// journalProvider is an AsyncNotifier; invalidating is safe even if
|
||||||
// even if no conversation is open — it doesn't cause flicker since
|
// the journal screen isn't currently mounted.
|
||||||
// the briefing screen isn't a list view.
|
ref.invalidate(journalProvider);
|
||||||
ref.invalidate(briefingProvider);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Refresh only the provider backing the given shell tab index.
|
/// Refresh only the provider backing the given shell tab route.
|
||||||
void _refreshTab(int index) {
|
void _refreshTab(String route) {
|
||||||
switch (index) {
|
if (route == Routes.journal) {
|
||||||
case 0:
|
ref.invalidate(journalProvider);
|
||||||
ref.invalidate(briefingProvider);
|
} else if (route == Routes.knowledge) {
|
||||||
case 1:
|
ref.read(knowledgeProvider.notifier).refresh();
|
||||||
ref.read(knowledgeProvider.notifier).refresh();
|
} else if (route == Routes.conversations) {
|
||||||
case 2:
|
ref.read(conversationsProvider.notifier).refresh();
|
||||||
ref.read(conversationsProvider.notifier).refresh();
|
} else if (route == Routes.calendar) {
|
||||||
|
ref.read(calendarProvider.notifier).refresh();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -267,14 +283,9 @@ class _ShellState extends ConsumerState<_Shell> with WidgetsBindingObserver {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
int _tabIndex(String location) {
|
int _tabIndex(String location, List<String> tabs) {
|
||||||
for (var i = 0; i < _tabs.length; i++) {
|
for (var i = 0; i < tabs.length; i++) {
|
||||||
if (location.startsWith(_tabs[i])) return i;
|
if (location.startsWith(tabs[i])) return i;
|
||||||
}
|
|
||||||
if (location.startsWith(Routes.projects) ||
|
|
||||||
location.startsWith(Routes.news) ||
|
|
||||||
location.startsWith(Routes.calendar)) {
|
|
||||||
return 3;
|
|
||||||
}
|
}
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
@@ -287,7 +298,7 @@ class _ShellState extends ConsumerState<_Shell> with WidgetsBindingObserver {
|
|||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.folder_outlined),
|
leading: const Icon(LucideIcons.folder),
|
||||||
title: const Text('Projects'),
|
title: const Text('Projects'),
|
||||||
onTap: () {
|
onTap: () {
|
||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
@@ -295,15 +306,7 @@ class _ShellState extends ConsumerState<_Shell> with WidgetsBindingObserver {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.newspaper_outlined),
|
leading: const Icon(LucideIcons.calendar),
|
||||||
title: const Text('News'),
|
|
||||||
onTap: () {
|
|
||||||
Navigator.pop(context);
|
|
||||||
context.push(Routes.news);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
ListTile(
|
|
||||||
leading: const Icon(Icons.calendar_month_outlined),
|
|
||||||
title: const Text('Calendar'),
|
title: const Text('Calendar'),
|
||||||
onTap: () {
|
onTap: () {
|
||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
@@ -316,66 +319,26 @@ class _ShellState extends ConsumerState<_Shell> with WidgetsBindingObserver {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _showUpdateDialog(UpdateState update) {
|
void _showUpdateSnackbar(UpdateState update) {
|
||||||
showDialog<void>(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
context: context,
|
SnackBar(
|
||||||
builder: (dialogContext) => Consumer(
|
content: Text('v${update.latestVersion} ready to install'),
|
||||||
builder: (context, ref, _) {
|
duration: const Duration(seconds: 6),
|
||||||
final state = ref.watch(updateProvider);
|
action: SnackBarAction(
|
||||||
final isDownloading = state.status == UpdateStatus.downloading;
|
label: 'Install',
|
||||||
return AlertDialog(
|
onPressed: () => ref.read(updateProvider.notifier).install(),
|
||||||
title: const Text('Update available'),
|
),
|
||||||
content: Column(
|
),
|
||||||
mainAxisSize: MainAxisSize.min,
|
);
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
}
|
||||||
children: [
|
|
||||||
Text('Version ${state.latestVersion ?? '?'} is ready to install.'),
|
void _showQueueFailureSnackbar(QueueFailure failure) {
|
||||||
if (state.currentVersion != null)
|
if (!mounted) return;
|
||||||
Text(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
'Installed: v${state.currentVersion}',
|
SnackBar(
|
||||||
style: Theme.of(context).textTheme.bodySmall,
|
content: Text(failure.message),
|
||||||
),
|
behavior: SnackBarBehavior.floating,
|
||||||
if (isDownloading) ...[
|
duration: const Duration(seconds: 6),
|
||||||
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'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -384,18 +347,37 @@ class _ShellState extends ConsumerState<_Shell> with WidgetsBindingObserver {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
// Show update dialog once when a new version is detected.
|
// Show update dialog once when a new version is detected.
|
||||||
ref.listen(updateProvider, (prev, next) {
|
ref.listen(updateProvider, (prev, next) {
|
||||||
if (next.status == UpdateStatus.available &&
|
if (next.status == UpdateStatus.readyToInstall &&
|
||||||
prev?.status != UpdateStatus.available) {
|
prev?.status != UpdateStatus.readyToInstall) {
|
||||||
WidgetsBinding.instance
|
WidgetsBinding.instance
|
||||||
.addPostFrameCallback((_) => _showUpdateDialog(next));
|
.addPostFrameCallback((_) => _showUpdateSnackbar(next));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Phase 3 — drain the offline write queue when we transition into
|
||||||
|
// an online state. Fires for unknown→authenticated (cold start),
|
||||||
|
// offline→authenticated (came back), and unauthenticated→authenticated
|
||||||
|
// (login). Idempotent if the queue is empty.
|
||||||
|
ref.listen(authProvider, (prev, next) {
|
||||||
|
if (next == AuthStatus.authenticated &&
|
||||||
|
prev != AuthStatus.authenticated) {
|
||||||
|
ref.read(writeQueueProvider).replay();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Phase 3 — surface queue failures (overwrites, missing targets, 4xx
|
||||||
|
// rejections) so the user knows their offline edit didn't land.
|
||||||
|
ref.listen(writeQueueFailuresProvider, (_, next) {
|
||||||
|
next.whenData(_showQueueFailureSnackbar);
|
||||||
|
});
|
||||||
|
final tabs = _tabs();
|
||||||
final location = GoRouterState.of(context).matchedLocation;
|
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.
|
// Refresh the incoming tab's data when switching between shell tabs.
|
||||||
if (_prevTabIndex != null && _prevTabIndex != index && index < 3) {
|
if (_prevTabIndex != null && _prevTabIndex != index) {
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) => _refreshTab(index));
|
final route = index < tabs.length ? tabs[index] : tabs[0];
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) => _refreshTab(route));
|
||||||
}
|
}
|
||||||
_prevTabIndex = index;
|
_prevTabIndex = index;
|
||||||
|
|
||||||
@@ -407,40 +389,40 @@ class _ShellState extends ConsumerState<_Shell> with WidgetsBindingObserver {
|
|||||||
body: SafeArea(
|
body: SafeArea(
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
|
const OfflineBanner(),
|
||||||
const _QuickCaptureBar(),
|
const _QuickCaptureBar(),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
NavigationRail(
|
NavigationRail(
|
||||||
selectedIndex: index,
|
selectedIndex: index,
|
||||||
onDestinationSelected: (i) {
|
onDestinationSelected: (i) => context.go(tabs[i]),
|
||||||
if (i == 3) {
|
|
||||||
_showMoreSheet(context);
|
|
||||||
} else {
|
|
||||||
context.go(_tabs[i]);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
labelType: NavigationRailLabelType.all,
|
labelType: NavigationRailLabelType.all,
|
||||||
destinations: const [
|
destinations: const [
|
||||||
NavigationRailDestination(
|
NavigationRailDestination(
|
||||||
icon: Icon(Icons.wb_sunny_outlined),
|
icon: Icon(LucideIcons.bookOpen),
|
||||||
selectedIcon: Icon(Icons.wb_sunny),
|
selectedIcon: Icon(LucideIcons.bookOpen),
|
||||||
label: Text('Briefing'),
|
label: Text('Journal'),
|
||||||
),
|
),
|
||||||
NavigationRailDestination(
|
NavigationRailDestination(
|
||||||
icon: Icon(Icons.menu_book_outlined),
|
icon: Icon(LucideIcons.lightbulb),
|
||||||
selectedIcon: Icon(Icons.menu_book),
|
selectedIcon: Icon(LucideIcons.lightbulb),
|
||||||
label: Text('Knowledge'),
|
label: Text('Knowledge'),
|
||||||
),
|
),
|
||||||
NavigationRailDestination(
|
NavigationRailDestination(
|
||||||
icon: Icon(Icons.chat_bubble_outline),
|
icon: Icon(LucideIcons.messageCircle),
|
||||||
selectedIcon: Icon(Icons.chat_bubble),
|
selectedIcon: Icon(LucideIcons.messageCircle),
|
||||||
label: Text('Chat'),
|
label: Text('Chat'),
|
||||||
),
|
),
|
||||||
NavigationRailDestination(
|
NavigationRailDestination(
|
||||||
icon: Icon(Icons.more_horiz_outlined),
|
icon: Icon(LucideIcons.folder),
|
||||||
selectedIcon: Icon(Icons.more_horiz),
|
selectedIcon: Icon(LucideIcons.folder),
|
||||||
label: Text('More'),
|
label: Text('Projects'),
|
||||||
|
),
|
||||||
|
NavigationRailDestination(
|
||||||
|
icon: Icon(LucideIcons.calendar),
|
||||||
|
selectedIcon: Icon(LucideIcons.calendar),
|
||||||
|
label: Text('Calendar'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -458,6 +440,7 @@ class _ShellState extends ConsumerState<_Shell> with WidgetsBindingObserver {
|
|||||||
return Scaffold(
|
return Scaffold(
|
||||||
body: Column(
|
body: Column(
|
||||||
children: [
|
children: [
|
||||||
|
const OfflineBanner(),
|
||||||
const _QuickCaptureBar(),
|
const _QuickCaptureBar(),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: MediaQuery.removePadding(
|
child: MediaQuery.removePadding(
|
||||||
@@ -469,33 +452,33 @@ class _ShellState extends ConsumerState<_Shell> with WidgetsBindingObserver {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
bottomNavigationBar: NavigationBar(
|
bottomNavigationBar: NavigationBar(
|
||||||
selectedIndex: index,
|
selectedIndex: index >= 3 ? 3 : index,
|
||||||
onDestinationSelected: (i) {
|
onDestinationSelected: (i) {
|
||||||
if (i == 3) {
|
if (i == 3) {
|
||||||
_showMoreSheet(context);
|
_showMoreSheet(context);
|
||||||
} else {
|
} else {
|
||||||
context.go(_tabs[i]);
|
context.go(tabs[i]);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
destinations: const [
|
destinations: const [
|
||||||
NavigationDestination(
|
NavigationDestination(
|
||||||
icon: Icon(Icons.wb_sunny_outlined),
|
icon: Icon(LucideIcons.bookOpen),
|
||||||
selectedIcon: Icon(Icons.wb_sunny),
|
selectedIcon: Icon(LucideIcons.bookOpen),
|
||||||
label: 'Briefing',
|
label: 'Journal',
|
||||||
),
|
),
|
||||||
NavigationDestination(
|
NavigationDestination(
|
||||||
icon: Icon(Icons.menu_book_outlined),
|
icon: Icon(LucideIcons.lightbulb),
|
||||||
selectedIcon: Icon(Icons.menu_book),
|
selectedIcon: Icon(LucideIcons.lightbulb),
|
||||||
label: 'Knowledge',
|
label: 'Knowledge',
|
||||||
),
|
),
|
||||||
NavigationDestination(
|
NavigationDestination(
|
||||||
icon: Icon(Icons.chat_bubble_outline),
|
icon: Icon(LucideIcons.messageCircle),
|
||||||
selectedIcon: Icon(Icons.chat_bubble),
|
selectedIcon: Icon(LucideIcons.messageCircle),
|
||||||
label: 'Chat',
|
label: 'Chat',
|
||||||
),
|
),
|
||||||
NavigationDestination(
|
NavigationDestination(
|
||||||
icon: Icon(Icons.more_horiz_outlined),
|
icon: Icon(LucideIcons.moreHorizontal),
|
||||||
selectedIcon: Icon(Icons.more_horiz),
|
selectedIcon: Icon(LucideIcons.moreHorizontal),
|
||||||
label: 'More',
|
label: 'More',
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -523,7 +506,6 @@ class _QuickCaptureBarState extends ConsumerState<_QuickCaptureBar> {
|
|||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_controller.dispose();
|
_controller.dispose();
|
||||||
ref.read(voiceProvider.notifier).exitVoiceMode();
|
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -627,7 +609,7 @@ class _QuickCaptureBarState extends ConsumerState<_QuickCaptureBar> {
|
|||||||
prefixIcon: totalPending > 0
|
prefixIcon: totalPending > 0
|
||||||
? Badge(
|
? Badge(
|
||||||
label: Text('$totalPending'),
|
label: Text('$totalPending'),
|
||||||
child: const Icon(Icons.cloud_upload_outlined),
|
child: const Icon(LucideIcons.uploadCloud),
|
||||||
)
|
)
|
||||||
: isWorking
|
: isWorking
|
||||||
? const Padding(
|
? const Padding(
|
||||||
@@ -639,10 +621,10 @@ class _QuickCaptureBarState extends ConsumerState<_QuickCaptureBar> {
|
|||||||
strokeWidth: 2),
|
strokeWidth: 2),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
: const Icon(Icons.auto_awesome_outlined),
|
: const Icon(LucideIcons.sparkles),
|
||||||
suffixIcon: _controller.text.trim().isNotEmpty
|
suffixIcon: _controller.text.trim().isNotEmpty
|
||||||
? IconButton(
|
? IconButton(
|
||||||
icon: const Icon(Icons.send),
|
icon: const Icon(LucideIcons.send),
|
||||||
onPressed: _submit,
|
onPressed: _submit,
|
||||||
tooltip: 'Capture',
|
tooltip: 'Capture',
|
||||||
)
|
)
|
||||||
@@ -657,7 +639,7 @@ class _QuickCaptureBarState extends ConsumerState<_QuickCaptureBar> {
|
|||||||
onTap: _toggleCaptureMic,
|
onTap: _toggleCaptureMic,
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.settings_outlined),
|
icon: const Icon(LucideIcons.settings),
|
||||||
tooltip: 'Settings',
|
tooltip: 'Settings',
|
||||||
onPressed: () => context.push(Routes.settings),
|
onPressed: () => context.push(Routes.settings),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -16,8 +16,7 @@ abstract class Routes {
|
|||||||
static const chat = '/chat/:id';
|
static const chat = '/chat/:id';
|
||||||
static const quickCapture = '/quick-capture';
|
static const quickCapture = '/quick-capture';
|
||||||
static const settings = '/settings';
|
static const settings = '/settings';
|
||||||
static const briefing = '/briefing';
|
static const journal = '/journal';
|
||||||
static const news = '/news';
|
|
||||||
static const calendar = '/calendar';
|
static const calendar = '/calendar';
|
||||||
static const projectTasks = '/projects/:id/tasks';
|
static const projectTasks = '/projects/:id/tasks';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,3 +17,12 @@ class AuthException extends AppException {
|
|||||||
class NotFoundException extends AppException {
|
class NotFoundException extends AppException {
|
||||||
const NotFoundException(super.message);
|
const NotFoundException(super.message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 4xx/5xx response received from the server (excluding 401/404 which have
|
||||||
|
/// dedicated subclasses). Distinguished from `NetworkException` (connection
|
||||||
|
/// failure) so the offline write queue can drop non-retryable failures
|
||||||
|
/// instead of looping forever on a 422.
|
||||||
|
class ServerException extends AppException {
|
||||||
|
final int statusCode;
|
||||||
|
const ServerException(super.message, this.statusCode);
|
||||||
|
}
|
||||||
|
|||||||
+149
-35
@@ -2,35 +2,142 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:google_fonts/google_fonts.dart';
|
import 'package:google_fonts/google_fonts.dart';
|
||||||
|
|
||||||
// ── Colour constants ──────────────────────────────────────────────────────────
|
// ── Colour constants ──────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Mirrors the web frontend's design system (`docs/design-system.md` in
|
||||||
|
// fabledscribe). Foundation pass shipped on web 2026-04-27 in `7a9a8b7`;
|
||||||
|
// this is the equivalent palette swap for Flutter. Per-screen "surface
|
||||||
|
// phase" reclassification (button Hybrid rule, border audit, etc.) is
|
||||||
|
// deferred — most widgets read `colorScheme.primary` so the palette flip
|
||||||
|
// alone covers a large surface.
|
||||||
|
|
||||||
const _darkBackground = Color(0xFF0F0F14);
|
// Dark mode — Obsidian / Iron / Pewter / Parchment (FabledSword baseline)
|
||||||
const _darkSurface = Color(0xFF16161F);
|
const _darkBackground = Color(0xFF14171A); // Obsidian
|
||||||
const _darkSurfaceVar = Color(0xFF1A1A24);
|
const _darkSurface = Color(0xFF1E2228); // Iron
|
||||||
const _darkPrimary = Color(0xFF7C3AED);
|
const _darkSurfaceVar = Color(0xFF2C313A); // Slate
|
||||||
const _darkOnSurface = Color(0xFFE4E4F0);
|
const _darkPrimary = Color(0xFF5B4A8A); // dusty violet (Scribe accent)
|
||||||
const _darkOnSurfaceVar = Color(0xFF8888A8);
|
const _darkPrimaryDeep = Color(0xFF3F3560); // gradient stop
|
||||||
const _darkOutline = Color(0xFF2A2A3A);
|
const _darkOnSurface = Color(0xFFE8E4D8); // Parchment
|
||||||
|
const _darkOnSurfaceVar = Color(0xFFC2BFB4); // Vellum
|
||||||
|
const _darkOutline = Color(0xFF3F4651); // Pewter
|
||||||
|
|
||||||
const _lightBackground = Color(0xFFF5F5FB);
|
// Light mode — warm parchment (Scribe iteration)
|
||||||
const _lightSurface = Color(0xFFFFFFFF);
|
const _lightBackground = Color(0xFFF5F1E8); // warm cream page
|
||||||
const _lightSurfaceVar = Color(0xFFF0F0F8);
|
const _lightSurface = Color(0xFFFBF8F0); // near-white card
|
||||||
const _lightPrimary = Color(0xFF7C3AED);
|
const _lightSurfaceVar = Color(0xFFEFEAE0); // inset / hover surface
|
||||||
const _lightOnSurface = Color(0xFF1A1A1A);
|
const _lightPrimary = Color(0xFF5B4A8A); // dusty violet (same on both modes)
|
||||||
const _lightOnSurfaceVar = Color(0xFF666666);
|
const _lightOnSurface = Color(0xFF14171A); // deep ink (Obsidian inverted)
|
||||||
const _lightOutline = Color(0xFFDDDDE8);
|
const _lightOnSurfaceVar = Color(0xFF5A5852); // warm mid grey
|
||||||
|
const _lightOutline = Color(0xFFD9D6CE); // warm light pewter
|
||||||
|
|
||||||
|
// Semantic — identical across themes
|
||||||
|
const _semanticError = Color(0xFFC04A1F); // terracotta — validation/error
|
||||||
|
const _semanticErrorBg = Color(0xFF7E2A1F); // Oxblood-hover for dark error container
|
||||||
|
const _semanticErrorFg = Color(0xFFFEE2E2); // light text on dark error container
|
||||||
|
|
||||||
|
// Action tokens — Hybrid rule per the doc. These don't fit ColorScheme
|
||||||
|
// natively (Material's primary/secondary/tertiary slots all carry the
|
||||||
|
// brand accent), so they live on a ThemeExtension exposed below.
|
||||||
|
const _actionPrimary = Color(0xFF4A5D3F); // Moss
|
||||||
|
const _actionPrimaryHover = Color(0xFF5A6F4D);
|
||||||
|
const _actionSecondary = Color(0xFF8B7355); // Bronze
|
||||||
|
const _actionSecondaryHover = Color(0xFFA0876A);
|
||||||
|
const _actionDestructive = Color(0xFF6B2118); // Oxblood
|
||||||
|
const _actionDestructiveHover = Color(0xFF7E2A1F);
|
||||||
|
const _actionGhostBorder = Color(0xFF3F4651); // Pewter (same as outline)
|
||||||
|
|
||||||
|
// ── Action ThemeExtension ─────────────────────────────────────────────────────
|
||||||
|
// Read with: Theme.of(context).extension<ActionColors>()!.primary
|
||||||
|
//
|
||||||
|
// Flutter's ColorScheme has primary/secondary/tertiary all conceptually
|
||||||
|
// "branded", whereas the doc's Hybrid rule reserves accent for brand
|
||||||
|
// moments (Send, empty-state CTAs) and routes action buttons through a
|
||||||
|
// separate Moss/Bronze/Oxblood/Pewter palette. This extension carries
|
||||||
|
// those tokens without polluting ColorScheme.
|
||||||
|
|
||||||
|
@immutable
|
||||||
|
class ActionColors extends ThemeExtension<ActionColors> {
|
||||||
|
final Color primary; // Moss — Save / Confirm
|
||||||
|
final Color primaryHover;
|
||||||
|
final Color secondary; // Bronze — Cancel / alternate paths
|
||||||
|
final Color secondaryHover;
|
||||||
|
final Color destructive; // Oxblood — Delete / irreversible
|
||||||
|
final Color destructiveHover;
|
||||||
|
final Color ghostBorder; // Pewter — tertiary / "later" / "skip"
|
||||||
|
|
||||||
|
const ActionColors({
|
||||||
|
required this.primary,
|
||||||
|
required this.primaryHover,
|
||||||
|
required this.secondary,
|
||||||
|
required this.secondaryHover,
|
||||||
|
required this.destructive,
|
||||||
|
required this.destructiveHover,
|
||||||
|
required this.ghostBorder,
|
||||||
|
});
|
||||||
|
|
||||||
|
static const _kStandard = ActionColors(
|
||||||
|
primary: _actionPrimary,
|
||||||
|
primaryHover: _actionPrimaryHover,
|
||||||
|
secondary: _actionSecondary,
|
||||||
|
secondaryHover: _actionSecondaryHover,
|
||||||
|
destructive: _actionDestructive,
|
||||||
|
destructiveHover: _actionDestructiveHover,
|
||||||
|
ghostBorder: _actionGhostBorder,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
ActionColors copyWith({
|
||||||
|
Color? primary,
|
||||||
|
Color? primaryHover,
|
||||||
|
Color? secondary,
|
||||||
|
Color? secondaryHover,
|
||||||
|
Color? destructive,
|
||||||
|
Color? destructiveHover,
|
||||||
|
Color? ghostBorder,
|
||||||
|
}) {
|
||||||
|
return ActionColors(
|
||||||
|
primary: primary ?? this.primary,
|
||||||
|
primaryHover: primaryHover ?? this.primaryHover,
|
||||||
|
secondary: secondary ?? this.secondary,
|
||||||
|
secondaryHover: secondaryHover ?? this.secondaryHover,
|
||||||
|
destructive: destructive ?? this.destructive,
|
||||||
|
destructiveHover: destructiveHover ?? this.destructiveHover,
|
||||||
|
ghostBorder: ghostBorder ?? this.ghostBorder,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
ActionColors lerp(ThemeExtension<ActionColors>? other, double t) {
|
||||||
|
if (other is! ActionColors) return this;
|
||||||
|
return ActionColors(
|
||||||
|
primary: Color.lerp(primary, other.primary, t)!,
|
||||||
|
primaryHover: Color.lerp(primaryHover, other.primaryHover, t)!,
|
||||||
|
secondary: Color.lerp(secondary, other.secondary, t)!,
|
||||||
|
secondaryHover: Color.lerp(secondaryHover, other.secondaryHover, t)!,
|
||||||
|
destructive: Color.lerp(destructive, other.destructive, t)!,
|
||||||
|
destructiveHover: Color.lerp(destructiveHover, other.destructiveHover, t)!,
|
||||||
|
ghostBorder: Color.lerp(ghostBorder, other.ghostBorder, t)!,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Typography ─────────────────────────────────────────────────────────────────
|
// ── Typography ─────────────────────────────────────────────────────────────────
|
||||||
|
// Inter for body / labels / titleMedium-and-below.
|
||||||
|
// Fraunces for display / headline / titleLarge — only at ≥18px per the doc.
|
||||||
|
// JetBrains Mono available via GoogleFonts.jetBrainsMono() at call sites for
|
||||||
|
// code blocks (Flutter's TextTheme has no dedicated mono slot).
|
||||||
|
|
||||||
TextTheme _buildTextTheme(TextTheme base) {
|
TextTheme _buildTextTheme(TextTheme base) {
|
||||||
|
final inter = GoogleFonts.interTextTheme(base);
|
||||||
final fraunces = GoogleFonts.frauncesTextTheme(base);
|
final fraunces = GoogleFonts.frauncesTextTheme(base);
|
||||||
return base.copyWith(
|
return inter.copyWith(
|
||||||
// Headings / titles use Fraunces
|
displayLarge: fraunces.displayLarge,
|
||||||
|
displayMedium: fraunces.displayMedium,
|
||||||
|
displaySmall: fraunces.displaySmall,
|
||||||
headlineLarge: fraunces.headlineLarge,
|
headlineLarge: fraunces.headlineLarge,
|
||||||
headlineMedium: fraunces.headlineMedium,
|
headlineMedium: fraunces.headlineMedium,
|
||||||
headlineSmall: fraunces.headlineSmall,
|
headlineSmall: fraunces.headlineSmall,
|
||||||
titleLarge: fraunces.titleLarge,
|
titleLarge: fraunces.titleLarge,
|
||||||
titleMedium: fraunces.titleMedium,
|
// titleMedium / titleSmall / body* / label* stay Inter
|
||||||
// Body / labels remain system default
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,7 +148,7 @@ ThemeData fabledDarkTheme() {
|
|||||||
brightness: Brightness.dark,
|
brightness: Brightness.dark,
|
||||||
primary: _darkPrimary,
|
primary: _darkPrimary,
|
||||||
onPrimary: Colors.white,
|
onPrimary: Colors.white,
|
||||||
primaryContainer: const Color(0xFF5B21B6),
|
primaryContainer: _darkPrimaryDeep,
|
||||||
onPrimaryContainer: _darkOnSurface,
|
onPrimaryContainer: _darkOnSurface,
|
||||||
secondary: _darkPrimary,
|
secondary: _darkPrimary,
|
||||||
onSecondary: Colors.white,
|
onSecondary: Colors.white,
|
||||||
@@ -51,10 +158,10 @@ ThemeData fabledDarkTheme() {
|
|||||||
onTertiary: Colors.white,
|
onTertiary: Colors.white,
|
||||||
tertiaryContainer: _darkSurfaceVar,
|
tertiaryContainer: _darkSurfaceVar,
|
||||||
onTertiaryContainer: _darkOnSurface,
|
onTertiaryContainer: _darkOnSurface,
|
||||||
error: const Color(0xFFEF4444),
|
error: _semanticError,
|
||||||
onError: Colors.white,
|
onError: Colors.white,
|
||||||
errorContainer: const Color(0xFF7F1D1D),
|
errorContainer: _semanticErrorBg,
|
||||||
onErrorContainer: const Color(0xFFFEE2E2),
|
onErrorContainer: _semanticErrorFg,
|
||||||
surface: _darkSurface,
|
surface: _darkSurface,
|
||||||
onSurface: _darkOnSurface,
|
onSurface: _darkOnSurface,
|
||||||
surfaceContainerHighest: _darkSurfaceVar,
|
surfaceContainerHighest: _darkSurfaceVar,
|
||||||
@@ -73,6 +180,7 @@ ThemeData fabledDarkTheme() {
|
|||||||
colorScheme: cs,
|
colorScheme: cs,
|
||||||
scaffoldBackgroundColor: _darkBackground,
|
scaffoldBackgroundColor: _darkBackground,
|
||||||
textTheme: _buildTextTheme(ThemeData.dark().textTheme),
|
textTheme: _buildTextTheme(ThemeData.dark().textTheme),
|
||||||
|
extensions: const [ActionColors._kStandard],
|
||||||
cardTheme: CardThemeData(
|
cardTheme: CardThemeData(
|
||||||
color: _darkSurface,
|
color: _darkSurface,
|
||||||
elevation: 2,
|
elevation: 2,
|
||||||
@@ -91,15 +199,15 @@ ThemeData fabledDarkTheme() {
|
|||||||
filled: true,
|
filled: true,
|
||||||
fillColor: _darkSurfaceVar,
|
fillColor: _darkSurfaceVar,
|
||||||
border: OutlineInputBorder(
|
border: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(24),
|
borderRadius: BorderRadius.circular(8),
|
||||||
borderSide: BorderSide(color: _darkOutline),
|
borderSide: BorderSide(color: _darkOutline),
|
||||||
),
|
),
|
||||||
enabledBorder: OutlineInputBorder(
|
enabledBorder: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(24),
|
borderRadius: BorderRadius.circular(8),
|
||||||
borderSide: BorderSide(color: _darkOutline),
|
borderSide: BorderSide(color: _darkOutline),
|
||||||
),
|
),
|
||||||
focusedBorder: OutlineInputBorder(
|
focusedBorder: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(24),
|
borderRadius: BorderRadius.circular(8),
|
||||||
borderSide: BorderSide(color: _darkPrimary, width: 2),
|
borderSide: BorderSide(color: _darkPrimary, width: 2),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -118,7 +226,9 @@ ThemeData fabledLightTheme() {
|
|||||||
brightness: Brightness.light,
|
brightness: Brightness.light,
|
||||||
primary: _lightPrimary,
|
primary: _lightPrimary,
|
||||||
onPrimary: Colors.white,
|
onPrimary: Colors.white,
|
||||||
primaryContainer: const Color(0xFFEDE5FF),
|
// Warm parchment-tinted primary container, replacing the prior cool
|
||||||
|
// indigo `#EDE5FF`. Used for chip/badge backgrounds at low alpha.
|
||||||
|
primaryContainer: const Color(0xFFEDE9F4),
|
||||||
onPrimaryContainer: _lightOnSurface,
|
onPrimaryContainer: _lightOnSurface,
|
||||||
secondary: _lightPrimary,
|
secondary: _lightPrimary,
|
||||||
onSecondary: Colors.white,
|
onSecondary: Colors.white,
|
||||||
@@ -128,10 +238,10 @@ ThemeData fabledLightTheme() {
|
|||||||
onTertiary: Colors.white,
|
onTertiary: Colors.white,
|
||||||
tertiaryContainer: _lightSurfaceVar,
|
tertiaryContainer: _lightSurfaceVar,
|
||||||
onTertiaryContainer: _lightOnSurface,
|
onTertiaryContainer: _lightOnSurface,
|
||||||
error: const Color(0xFFDC2626),
|
error: _semanticError,
|
||||||
onError: Colors.white,
|
onError: Colors.white,
|
||||||
errorContainer: const Color(0xFFFEE2E2),
|
errorContainer: const Color(0xFFFEE2E2),
|
||||||
onErrorContainer: const Color(0xFF7F1D1D),
|
onErrorContainer: _semanticErrorBg,
|
||||||
surface: _lightSurface,
|
surface: _lightSurface,
|
||||||
onSurface: _lightOnSurface,
|
onSurface: _lightOnSurface,
|
||||||
surfaceContainerHighest: _lightSurfaceVar,
|
surfaceContainerHighest: _lightSurfaceVar,
|
||||||
@@ -150,6 +260,7 @@ ThemeData fabledLightTheme() {
|
|||||||
colorScheme: cs,
|
colorScheme: cs,
|
||||||
scaffoldBackgroundColor: _lightBackground,
|
scaffoldBackgroundColor: _lightBackground,
|
||||||
textTheme: _buildTextTheme(ThemeData.light().textTheme),
|
textTheme: _buildTextTheme(ThemeData.light().textTheme),
|
||||||
|
extensions: const [ActionColors._kStandard],
|
||||||
cardTheme: CardThemeData(
|
cardTheme: CardThemeData(
|
||||||
color: _lightSurface,
|
color: _lightSurface,
|
||||||
elevation: 1,
|
elevation: 1,
|
||||||
@@ -168,15 +279,15 @@ ThemeData fabledLightTheme() {
|
|||||||
filled: true,
|
filled: true,
|
||||||
fillColor: _lightSurfaceVar,
|
fillColor: _lightSurfaceVar,
|
||||||
border: OutlineInputBorder(
|
border: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(24),
|
borderRadius: BorderRadius.circular(8),
|
||||||
borderSide: BorderSide(color: _lightOutline),
|
borderSide: BorderSide(color: _lightOutline),
|
||||||
),
|
),
|
||||||
enabledBorder: OutlineInputBorder(
|
enabledBorder: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(24),
|
borderRadius: BorderRadius.circular(8),
|
||||||
borderSide: BorderSide(color: _lightOutline),
|
borderSide: BorderSide(color: _lightOutline),
|
||||||
),
|
),
|
||||||
focusedBorder: OutlineInputBorder(
|
focusedBorder: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(24),
|
borderRadius: BorderRadius.circular(8),
|
||||||
borderSide: BorderSide(color: _lightPrimary, width: 2),
|
borderSide: BorderSide(color: _lightPrimary, width: 2),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -191,7 +302,10 @@ ThemeData fabledLightTheme() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── GradientButton ─────────────────────────────────────────────────────────────
|
// ── GradientButton ─────────────────────────────────────────────────────────────
|
||||||
// Use wherever the web app uses the indigo gradient button (send, primary actions).
|
// Brand-moment CTA equivalent of the web's `--gradient-cta` — chat send,
|
||||||
|
// journal send, primary "Scribe-feature" actions. Reserve for those moments
|
||||||
|
// per the doc's Hybrid rule; use FilledButton with ActionColors.primary
|
||||||
|
// (Moss) for everything else.
|
||||||
|
|
||||||
class GradientButton extends StatelessWidget {
|
class GradientButton extends StatelessWidget {
|
||||||
final VoidCallback? onPressed;
|
final VoidCallback? onPressed;
|
||||||
@@ -218,15 +332,15 @@ class GradientButton extends StatelessWidget {
|
|||||||
: const LinearGradient(
|
: const LinearGradient(
|
||||||
begin: Alignment.topLeft,
|
begin: Alignment.topLeft,
|
||||||
end: Alignment.bottomRight,
|
end: Alignment.bottomRight,
|
||||||
colors: [Color(0xFF7C3AED), Color(0xFF5B21B6)],
|
colors: [_darkPrimary, _darkPrimaryDeep],
|
||||||
),
|
),
|
||||||
color: disabled ? const Color(0xFF7C3AED) : null,
|
color: disabled ? _darkPrimary : null,
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
boxShadow: disabled
|
boxShadow: disabled
|
||||||
? null
|
? null
|
||||||
: [
|
: [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: const Color(0xFF7C3AED).withValues(alpha: 0.45),
|
color: _darkPrimary.withValues(alpha: 0.45),
|
||||||
blurRadius: 8,
|
blurRadius: 8,
|
||||||
offset: const Offset(0, 3),
|
offset: const Offset(0, 3),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -63,5 +63,21 @@ AppException dioToApp(DioException e) {
|
|||||||
final status = e.response?.statusCode;
|
final status = e.response?.statusCode;
|
||||||
if (status == 401) return const AuthException('Not authenticated.');
|
if (status == 401) return const AuthException('Not authenticated.');
|
||||||
if (status == 404) return const NotFoundException('Resource not found.');
|
if (status == 404) return const NotFoundException('Resource not found.');
|
||||||
|
if (status != null && status >= 400) {
|
||||||
|
final msg = _extractServerErrorMessage(e.response) ??
|
||||||
|
'Request failed (HTTP $status).';
|
||||||
|
return ServerException(msg, status);
|
||||||
|
}
|
||||||
return NetworkException(e.message ?? 'Unknown network error.');
|
return NetworkException(e.message ?? 'Unknown network error.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String? _extractServerErrorMessage(Response? resp) {
|
||||||
|
final data = resp?.data;
|
||||||
|
if (data is Map<String, dynamic>) {
|
||||||
|
for (final key in const ['detail', 'error', 'message']) {
|
||||||
|
final value = data[key];
|
||||||
|
if (value is String && value.isNotEmpty) return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,97 +0,0 @@
|
|||||||
import 'package:dio/dio.dart';
|
|
||||||
|
|
||||||
import '../models/briefing_conversation.dart';
|
|
||||||
import '../models/message.dart';
|
|
||||||
import 'api_client.dart';
|
|
||||||
|
|
||||||
class BriefingApi {
|
|
||||||
final Dio _dio;
|
|
||||||
const BriefingApi(this._dio);
|
|
||||||
|
|
||||||
/// GET /api/briefing/conversations/today
|
|
||||||
/// Returns (or creates) today's briefing conversation with messages embedded.
|
|
||||||
Future<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},
|
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
|
/// [context] is optional recent conversation text passed as initial_prompt
|
||||||
/// to Whisper, reducing mishearings of domain-specific words.
|
/// to Whisper, reducing mishearings of domain-specific words.
|
||||||
/// Returns empty string on empty or error response.
|
/// Returns empty string on empty or error response.
|
||||||
@@ -49,8 +49,8 @@ class VoiceApi {
|
|||||||
final fields = <String, dynamic>{
|
final fields = <String, dynamic>{
|
||||||
'audio': MultipartFile.fromBytes(
|
'audio': MultipartFile.fromBytes(
|
||||||
audioBytes,
|
audioBytes,
|
||||||
filename: 'audio.m4a',
|
filename: 'audio.wav',
|
||||||
contentType: DioMediaType('audio', 'mp4'),
|
contentType: DioMediaType('audio', 'wav'),
|
||||||
),
|
),
|
||||||
if (context != null && context.isNotEmpty) 'context': context,
|
if (context != null && context.isNotEmpty) 'context': context,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,760 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:drift/drift.dart';
|
||||||
|
import 'package:drift/native.dart';
|
||||||
|
import 'package:path/path.dart' as p;
|
||||||
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
import 'package:sqlite3_flutter_libs/sqlite3_flutter_libs.dart';
|
||||||
|
|
||||||
|
import '../models/calendar_event.dart';
|
||||||
|
import '../models/conversation.dart';
|
||||||
|
import '../models/milestone.dart';
|
||||||
|
import '../models/note.dart';
|
||||||
|
import '../models/project.dart';
|
||||||
|
import '../models/task.dart';
|
||||||
|
|
||||||
|
part 'database.g.dart';
|
||||||
|
|
||||||
|
// ── Tables ───────────────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Each cached_* table mirrors the corresponding model 1:1. Tags / list fields
|
||||||
|
// are stored as JSON-encoded text; SQLite lacks a native list type and
|
||||||
|
// Drift's typed converters add ceremony we don't need for read-side caching.
|
||||||
|
// `cachedAt` tracks when the row was last written from a successful API
|
||||||
|
// response; the SyncMetadata table tracks the per-domain "last bulk fetch"
|
||||||
|
// timestamp used by the OfflineBanner.
|
||||||
|
|
||||||
|
class CachedNotes extends Table {
|
||||||
|
IntColumn get id => integer()();
|
||||||
|
TextColumn get title => text()();
|
||||||
|
TextColumn get body => text()();
|
||||||
|
TextColumn get tagsJson => text().withDefault(const Constant('[]'))();
|
||||||
|
TextColumn get noteType => text().withDefault(const Constant('note'))();
|
||||||
|
IntColumn get projectId => integer().nullable()();
|
||||||
|
IntColumn get milestoneId => integer().nullable()();
|
||||||
|
DateTimeColumn get createdAt => dateTime()();
|
||||||
|
DateTimeColumn get updatedAt => dateTime()();
|
||||||
|
DateTimeColumn get cachedAt =>
|
||||||
|
dateTime().clientDefault(() => DateTime.now())();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Set<Column> get primaryKey => {id};
|
||||||
|
}
|
||||||
|
|
||||||
|
class CachedTasks extends Table {
|
||||||
|
IntColumn get id => integer()();
|
||||||
|
TextColumn get title => text()();
|
||||||
|
TextColumn get description => text().nullable()();
|
||||||
|
TextColumn get status => text()();
|
||||||
|
TextColumn get priority => text()();
|
||||||
|
DateTimeColumn get dueDate => dateTime().nullable()();
|
||||||
|
IntColumn get projectId => integer().nullable()();
|
||||||
|
IntColumn get milestoneId => integer().nullable()();
|
||||||
|
IntColumn get parentId => integer().nullable()();
|
||||||
|
DateTimeColumn get createdAt => dateTime()();
|
||||||
|
DateTimeColumn get updatedAt => dateTime()();
|
||||||
|
DateTimeColumn get cachedAt =>
|
||||||
|
dateTime().clientDefault(() => DateTime.now())();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Set<Column> get primaryKey => {id};
|
||||||
|
}
|
||||||
|
|
||||||
|
class CachedProjects extends Table {
|
||||||
|
IntColumn get id => integer()();
|
||||||
|
TextColumn get title => text()();
|
||||||
|
TextColumn get description => text().nullable()();
|
||||||
|
TextColumn get goal => text().nullable()();
|
||||||
|
TextColumn get status => text()();
|
||||||
|
TextColumn get color => text().nullable()();
|
||||||
|
TextColumn get autoSummary => text().nullable()();
|
||||||
|
DateTimeColumn get createdAt => dateTime()();
|
||||||
|
DateTimeColumn get updatedAt => dateTime()();
|
||||||
|
DateTimeColumn get cachedAt =>
|
||||||
|
dateTime().clientDefault(() => DateTime.now())();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Set<Column> get primaryKey => {id};
|
||||||
|
}
|
||||||
|
|
||||||
|
class CachedMilestones extends Table {
|
||||||
|
IntColumn get id => integer()();
|
||||||
|
IntColumn get projectId => integer()();
|
||||||
|
TextColumn get title => text()();
|
||||||
|
TextColumn get description => text().nullable()();
|
||||||
|
TextColumn get status => text()();
|
||||||
|
IntColumn get orderIndex => integer().withDefault(const Constant(0))();
|
||||||
|
IntColumn get total => integer().withDefault(const Constant(0))();
|
||||||
|
IntColumn get completed => integer().withDefault(const Constant(0))();
|
||||||
|
RealColumn get pct => real().withDefault(const Constant(0.0))();
|
||||||
|
DateTimeColumn get createdAt => dateTime()();
|
||||||
|
DateTimeColumn get updatedAt => dateTime()();
|
||||||
|
DateTimeColumn get cachedAt =>
|
||||||
|
dateTime().clientDefault(() => DateTime.now())();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Set<Column> get primaryKey => {id};
|
||||||
|
}
|
||||||
|
|
||||||
|
class CachedCalendarEvents extends Table {
|
||||||
|
IntColumn get id => integer()();
|
||||||
|
TextColumn get title => text()();
|
||||||
|
DateTimeColumn get startDt => dateTime()();
|
||||||
|
DateTimeColumn get endDt => dateTime().nullable()();
|
||||||
|
BoolColumn get allDay => boolean().withDefault(const Constant(false))();
|
||||||
|
TextColumn get description => text().withDefault(const Constant(''))();
|
||||||
|
TextColumn get location => text().withDefault(const Constant(''))();
|
||||||
|
TextColumn get color => text().withDefault(const Constant(''))();
|
||||||
|
TextColumn get recurrence => text().nullable()();
|
||||||
|
IntColumn get projectId => integer().nullable()();
|
||||||
|
IntColumn get reminderMinutes => integer().nullable()();
|
||||||
|
DateTimeColumn get cachedAt =>
|
||||||
|
dateTime().clientDefault(() => DateTime.now())();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Set<Column> get primaryKey => {id};
|
||||||
|
}
|
||||||
|
|
||||||
|
class CachedConversations extends Table {
|
||||||
|
IntColumn get id => integer()();
|
||||||
|
TextColumn get title => text()();
|
||||||
|
DateTimeColumn get createdAt => dateTime()();
|
||||||
|
DateTimeColumn get updatedAt => dateTime()();
|
||||||
|
DateTimeColumn get cachedAt =>
|
||||||
|
dateTime().clientDefault(() => DateTime.now())();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Set<Column> get primaryKey => {id};
|
||||||
|
}
|
||||||
|
|
||||||
|
class SyncMetadata extends Table {
|
||||||
|
TextColumn get domain => text()();
|
||||||
|
DateTimeColumn get lastSyncedAt => dateTime()();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Set<Column> get primaryKey => {domain};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Phase 3 — generic offline-write queue. One row per pending API call;
|
||||||
|
/// drained in oldest-first order when the device comes back online.
|
||||||
|
///
|
||||||
|
/// `targetId` is the server id for updates/deletes; `tempId` is the
|
||||||
|
/// client-allocated negative placeholder for offline creates. `payloadJson`
|
||||||
|
/// is the typed args the API method needs (verb-specific shape).
|
||||||
|
/// `baselineUpdatedAt` snapshots the cached row's `updated_at` at edit
|
||||||
|
/// time so the replayer can drop the op if the server has moved on
|
||||||
|
/// (server-wins conflict resolution).
|
||||||
|
class PendingWrites extends Table {
|
||||||
|
IntColumn get id => integer().autoIncrement()();
|
||||||
|
TextColumn get domain => text()();
|
||||||
|
TextColumn get verb => text()();
|
||||||
|
IntColumn get targetId => integer().nullable()();
|
||||||
|
IntColumn get tempId => integer().nullable()();
|
||||||
|
TextColumn get payloadJson => text().withDefault(const Constant('{}'))();
|
||||||
|
DateTimeColumn get baselineUpdatedAt => dateTime().nullable()();
|
||||||
|
DateTimeColumn get createdAt =>
|
||||||
|
dateTime().clientDefault(() => DateTime.now())();
|
||||||
|
IntColumn get tries => integer().withDefault(const Constant(0))();
|
||||||
|
TextColumn get lastError => text().nullable()();
|
||||||
|
}
|
||||||
|
|
||||||
|
const String kSyncDomainNotes = 'notes';
|
||||||
|
const String kSyncDomainTasks = 'tasks';
|
||||||
|
const String kSyncDomainProjects = 'projects';
|
||||||
|
const String kSyncDomainMilestones = 'milestones';
|
||||||
|
const String kSyncDomainEvents = 'events';
|
||||||
|
const String kSyncDomainConversations = 'conversations';
|
||||||
|
|
||||||
|
const String kWriteVerbCreate = 'create';
|
||||||
|
const String kWriteVerbUpdate = 'update';
|
||||||
|
const String kWriteVerbDelete = 'delete';
|
||||||
|
|
||||||
|
@DriftDatabase(tables: [
|
||||||
|
CachedNotes,
|
||||||
|
CachedTasks,
|
||||||
|
CachedProjects,
|
||||||
|
CachedMilestones,
|
||||||
|
CachedCalendarEvents,
|
||||||
|
CachedConversations,
|
||||||
|
SyncMetadata,
|
||||||
|
PendingWrites,
|
||||||
|
])
|
||||||
|
class FabledDatabase extends _$FabledDatabase {
|
||||||
|
FabledDatabase() : super(_openConnection());
|
||||||
|
FabledDatabase.forTesting(super.executor);
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get schemaVersion => 3;
|
||||||
|
|
||||||
|
@override
|
||||||
|
MigrationStrategy get migration => MigrationStrategy(
|
||||||
|
onCreate: (m) => m.createAll(),
|
||||||
|
onUpgrade: (m, from, to) async {
|
||||||
|
// v1 → v2: added the per-domain caches beyond notes.
|
||||||
|
if (from < 2) {
|
||||||
|
await m.createTable(cachedTasks);
|
||||||
|
await m.createTable(cachedProjects);
|
||||||
|
await m.createTable(cachedMilestones);
|
||||||
|
await m.createTable(cachedCalendarEvents);
|
||||||
|
await m.createTable(cachedConversations);
|
||||||
|
}
|
||||||
|
// v2 → v3: added the offline write queue.
|
||||||
|
if (from < 3) {
|
||||||
|
await m.createTable(pendingWrites);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Notes ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
Future<List<Note>> getAllNotes() async {
|
||||||
|
final rows = await (select(cachedNotes)
|
||||||
|
..orderBy([(t) => OrderingTerm.desc(t.updatedAt)]))
|
||||||
|
.get();
|
||||||
|
return rows.map(_noteFromRow).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Note?> getNote(int id) async {
|
||||||
|
final row = await (select(cachedNotes)..where((t) => t.id.equals(id)))
|
||||||
|
.getSingleOrNull();
|
||||||
|
return row == null ? null : _noteFromRow(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> replaceAllNotes(List<Note> notes) async {
|
||||||
|
await transaction(() async {
|
||||||
|
await delete(cachedNotes).go();
|
||||||
|
if (notes.isNotEmpty) {
|
||||||
|
await batch((b) {
|
||||||
|
b.insertAll(cachedNotes, notes.map(_noteToCompanion).toList());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await _setLastSyncIn(kSyncDomainNotes, DateTime.now());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> upsertNote(Note note) async {
|
||||||
|
await into(cachedNotes).insertOnConflictUpdate(_noteToCompanion(note));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> deleteNote(int id) async {
|
||||||
|
await (delete(cachedNotes)..where((t) => t.id.equals(id))).go();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tasks ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
Future<List<Task>> getAllTasks() async {
|
||||||
|
final rows = await (select(cachedTasks)
|
||||||
|
..orderBy([(t) => OrderingTerm.desc(t.updatedAt)]))
|
||||||
|
.get();
|
||||||
|
return rows.map(_taskFromRow).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Task?> getTask(int id) async {
|
||||||
|
final row = await (select(cachedTasks)..where((t) => t.id.equals(id)))
|
||||||
|
.getSingleOrNull();
|
||||||
|
return row == null ? null : _taskFromRow(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<Task>> getTasksByProject(int projectId) async {
|
||||||
|
final rows = await (select(cachedTasks)
|
||||||
|
..where((t) => t.projectId.equals(projectId))
|
||||||
|
..orderBy([(t) => OrderingTerm.desc(t.updatedAt)]))
|
||||||
|
.get();
|
||||||
|
return rows.map(_taskFromRow).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<Task>> getSubTasks(int parentId) async {
|
||||||
|
final rows = await (select(cachedTasks)
|
||||||
|
..where((t) => t.parentId.equals(parentId))
|
||||||
|
..orderBy([(t) => OrderingTerm.desc(t.updatedAt)]))
|
||||||
|
.get();
|
||||||
|
return rows.map(_taskFromRow).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> replaceAllTasks(List<Task> tasks) async {
|
||||||
|
await transaction(() async {
|
||||||
|
await delete(cachedTasks).go();
|
||||||
|
if (tasks.isNotEmpty) {
|
||||||
|
await batch((b) {
|
||||||
|
b.insertAll(cachedTasks, tasks.map(_taskToCompanion).toList());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await _setLastSyncIn(kSyncDomainTasks, DateTime.now());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> upsertTask(Task task) async {
|
||||||
|
await into(cachedTasks).insertOnConflictUpdate(_taskToCompanion(task));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> deleteTask(int id) async {
|
||||||
|
await (delete(cachedTasks)..where((t) => t.id.equals(id))).go();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Projects ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
Future<List<Project>> getAllProjects() async {
|
||||||
|
final rows = await (select(cachedProjects)
|
||||||
|
..orderBy([(t) => OrderingTerm.desc(t.updatedAt)]))
|
||||||
|
.get();
|
||||||
|
return rows.map(_projectFromRow).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Project?> getProject(int id) async {
|
||||||
|
final row = await (select(cachedProjects)..where((t) => t.id.equals(id)))
|
||||||
|
.getSingleOrNull();
|
||||||
|
return row == null ? null : _projectFromRow(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> replaceAllProjects(List<Project> projects) async {
|
||||||
|
await transaction(() async {
|
||||||
|
await delete(cachedProjects).go();
|
||||||
|
if (projects.isNotEmpty) {
|
||||||
|
await batch((b) {
|
||||||
|
b.insertAll(
|
||||||
|
cachedProjects,
|
||||||
|
projects.map(_projectToCompanion).toList(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await _setLastSyncIn(kSyncDomainProjects, DateTime.now());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> upsertProject(Project project) async {
|
||||||
|
await into(cachedProjects)
|
||||||
|
.insertOnConflictUpdate(_projectToCompanion(project));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> deleteProject(int id) async {
|
||||||
|
await (delete(cachedProjects)..where((t) => t.id.equals(id))).go();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Milestones ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
Future<List<Milestone>> getMilestonesForProject(int projectId) async {
|
||||||
|
final rows = await (select(cachedMilestones)
|
||||||
|
..where((t) => t.projectId.equals(projectId))
|
||||||
|
..orderBy([(t) => OrderingTerm.asc(t.orderIndex)]))
|
||||||
|
.get();
|
||||||
|
return rows.map(_milestoneFromRow).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> replaceMilestonesForProject(
|
||||||
|
int projectId,
|
||||||
|
List<Milestone> milestones,
|
||||||
|
) async {
|
||||||
|
await transaction(() async {
|
||||||
|
await (delete(cachedMilestones)
|
||||||
|
..where((t) => t.projectId.equals(projectId)))
|
||||||
|
.go();
|
||||||
|
if (milestones.isNotEmpty) {
|
||||||
|
await batch((b) {
|
||||||
|
b.insertAll(
|
||||||
|
cachedMilestones,
|
||||||
|
milestones.map(_milestoneToCompanion).toList(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await _setLastSyncIn(kSyncDomainMilestones, DateTime.now());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> upsertMilestone(Milestone milestone) async {
|
||||||
|
await into(cachedMilestones)
|
||||||
|
.insertOnConflictUpdate(_milestoneToCompanion(milestone));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> deleteMilestone(int id) async {
|
||||||
|
await (delete(cachedMilestones)..where((t) => t.id.equals(id))).go();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Calendar events ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Returns events whose start_dt falls within [from, to). Recurrence
|
||||||
|
/// expansion still happens server-side; a stored event that has a
|
||||||
|
/// non-null recurrence rule will only have one row in cache (its
|
||||||
|
/// canonical instance), so cached date-range queries on recurring
|
||||||
|
/// events are best-effort and may miss future occurrences.
|
||||||
|
Future<List<CalendarEvent>> getEventsInRange(
|
||||||
|
DateTime from, DateTime to) async {
|
||||||
|
final rows = await (select(cachedCalendarEvents)
|
||||||
|
..where((t) =>
|
||||||
|
t.startDt.isBiggerOrEqualValue(from) &
|
||||||
|
t.startDt.isSmallerThanValue(to))
|
||||||
|
..orderBy([(t) => OrderingTerm.asc(t.startDt)]))
|
||||||
|
.get();
|
||||||
|
return rows.map(_eventFromRow).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replace the cache for the given range. Events outside [from, to)
|
||||||
|
/// are left alone — this matches the API's range-scoped semantics so
|
||||||
|
/// repeated fetches over disjoint ranges don't clobber each other.
|
||||||
|
Future<void> replaceEventsInRange(
|
||||||
|
DateTime from,
|
||||||
|
DateTime to,
|
||||||
|
List<CalendarEvent> events,
|
||||||
|
) async {
|
||||||
|
await transaction(() async {
|
||||||
|
await (delete(cachedCalendarEvents)
|
||||||
|
..where((t) =>
|
||||||
|
t.startDt.isBiggerOrEqualValue(from) &
|
||||||
|
t.startDt.isSmallerThanValue(to)))
|
||||||
|
.go();
|
||||||
|
if (events.isNotEmpty) {
|
||||||
|
await batch((b) {
|
||||||
|
b.insertAll(
|
||||||
|
cachedCalendarEvents,
|
||||||
|
events.map(_eventToCompanion).toList(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await _setLastSyncIn(kSyncDomainEvents, DateTime.now());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> upsertEvent(CalendarEvent event) async {
|
||||||
|
await into(cachedCalendarEvents)
|
||||||
|
.insertOnConflictUpdate(_eventToCompanion(event));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> deleteEvent(int id) async {
|
||||||
|
await (delete(cachedCalendarEvents)..where((t) => t.id.equals(id))).go();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Conversations (chat list — not messages) ─────────────────────────────
|
||||||
|
|
||||||
|
Future<List<Conversation>> getAllConversations() async {
|
||||||
|
final rows = await (select(cachedConversations)
|
||||||
|
..orderBy([(t) => OrderingTerm.desc(t.updatedAt)]))
|
||||||
|
.get();
|
||||||
|
return rows.map(_conversationFromRow).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> replaceAllConversations(
|
||||||
|
List<Conversation> conversations) async {
|
||||||
|
await transaction(() async {
|
||||||
|
await delete(cachedConversations).go();
|
||||||
|
if (conversations.isNotEmpty) {
|
||||||
|
await batch((b) {
|
||||||
|
b.insertAll(
|
||||||
|
cachedConversations,
|
||||||
|
conversations.map(_conversationToCompanion).toList(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await _setLastSyncIn(kSyncDomainConversations, DateTime.now());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> deleteConversation(int id) async {
|
||||||
|
await (delete(cachedConversations)..where((t) => t.id.equals(id))).go();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Sync metadata ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
Future<DateTime?> getLastSync(String domain) async {
|
||||||
|
final row = await (select(syncMetadata)
|
||||||
|
..where((t) => t.domain.equals(domain)))
|
||||||
|
.getSingleOrNull();
|
||||||
|
return row?.lastSyncedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Most recent sync across all domains. Used by the OfflineBanner so the
|
||||||
|
/// hint reads as "your data was current as of X" regardless of which
|
||||||
|
/// screen the user happens to be on.
|
||||||
|
Future<DateTime?> getLatestSync() async {
|
||||||
|
final row = await (select(syncMetadata)
|
||||||
|
..orderBy([(t) => OrderingTerm.desc(t.lastSyncedAt)])
|
||||||
|
..limit(1))
|
||||||
|
.getSingleOrNull();
|
||||||
|
return row?.lastSyncedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _setLastSyncIn(String domain, DateTime timestamp) {
|
||||||
|
return into(syncMetadata).insertOnConflictUpdate(
|
||||||
|
SyncMetadataCompanion(
|
||||||
|
domain: Value(domain),
|
||||||
|
lastSyncedAt: Value(timestamp),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Pending writes (Phase 3 offline queue) ───────────────────────────────
|
||||||
|
|
||||||
|
/// Allocate a fresh negative id used as a placeholder for an
|
||||||
|
/// offline-created row until its server id arrives via replay.
|
||||||
|
Future<int> nextTempId() async {
|
||||||
|
final query = customSelect(
|
||||||
|
'SELECT MIN(temp_id) AS m FROM pending_writes WHERE temp_id IS NOT NULL',
|
||||||
|
);
|
||||||
|
final row = await query.getSingleOrNull();
|
||||||
|
final current = row?.read<int?>('m');
|
||||||
|
return (current ?? 0) - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<int> enqueuePending({
|
||||||
|
required String domain,
|
||||||
|
required String verb,
|
||||||
|
int? targetId,
|
||||||
|
int? tempId,
|
||||||
|
Map<String, dynamic> payload = const {},
|
||||||
|
DateTime? baselineUpdatedAt,
|
||||||
|
}) {
|
||||||
|
return into(pendingWrites).insert(
|
||||||
|
PendingWritesCompanion.insert(
|
||||||
|
domain: domain,
|
||||||
|
verb: verb,
|
||||||
|
targetId: Value(targetId),
|
||||||
|
tempId: Value(tempId),
|
||||||
|
payloadJson: Value(jsonEncode(payload)),
|
||||||
|
baselineUpdatedAt: Value(baselineUpdatedAt),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find the queued create-op for an offline-created row so a follow-up
|
||||||
|
/// edit can be coalesced into the original payload (no separate update
|
||||||
|
/// op gets queued). Returns null if no matching create is queued.
|
||||||
|
Future<PendingWrite?> findQueuedCreate(String domain, int tempId) {
|
||||||
|
return (select(pendingWrites)
|
||||||
|
..where((t) =>
|
||||||
|
t.domain.equals(domain) &
|
||||||
|
t.verb.equals(kWriteVerbCreate) &
|
||||||
|
t.tempId.equals(tempId)))
|
||||||
|
.getSingleOrNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> updatePendingPayload(int opId, Map<String, dynamic> payload) {
|
||||||
|
return (update(pendingWrites)..where((t) => t.id.equals(opId))).write(
|
||||||
|
PendingWritesCompanion(payloadJson: Value(jsonEncode(payload))),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> deletePending(int opId) {
|
||||||
|
return (delete(pendingWrites)..where((t) => t.id.equals(opId))).go();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pending ops oldest first — replay order.
|
||||||
|
Future<List<PendingWrite>> listPending() {
|
||||||
|
return (select(pendingWrites)
|
||||||
|
..orderBy([(t) => OrderingTerm.asc(t.id)]))
|
||||||
|
.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<int> queueDepth() async {
|
||||||
|
final row = await customSelect('SELECT COUNT(*) AS c FROM pending_writes')
|
||||||
|
.getSingle();
|
||||||
|
return row.read<int>('c');
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> markPendingFailed(int opId, String error) {
|
||||||
|
return (update(pendingWrites)..where((t) => t.id.equals(opId))).write(
|
||||||
|
PendingWritesCompanion(
|
||||||
|
tries: const Value.absent(),
|
||||||
|
lastError: Value(error),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> incrementPendingTries(int opId) {
|
||||||
|
return customStatement(
|
||||||
|
'UPDATE pending_writes SET tries = tries + 1 WHERE id = ?',
|
||||||
|
[opId],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Watch queue depth — used by OfflineBanner for the "Retry (N)" affordance.
|
||||||
|
Stream<int> watchQueueDepth() {
|
||||||
|
final query = customSelect(
|
||||||
|
'SELECT COUNT(*) AS c FROM pending_writes',
|
||||||
|
readsFrom: {pendingWrites},
|
||||||
|
);
|
||||||
|
return query.watchSingle().map((row) => row.read<int>('c'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Watch the set of ids in [domain] that have a queued write (either a
|
||||||
|
/// `target_id` for an update/delete or a `temp_id` for an offline create).
|
||||||
|
/// Phase 4 — used by per-row pending-sync indicators in the list views.
|
||||||
|
Stream<Set<int>> watchPendingIds(String domain) {
|
||||||
|
final query = customSelect(
|
||||||
|
'SELECT target_id, temp_id FROM pending_writes WHERE domain = ?',
|
||||||
|
variables: [Variable.withString(domain)],
|
||||||
|
readsFrom: {pendingWrites},
|
||||||
|
);
|
||||||
|
return query.watch().map((rows) {
|
||||||
|
final ids = <int>{};
|
||||||
|
for (final r in rows) {
|
||||||
|
final t = r.read<int?>('target_id');
|
||||||
|
final tmp = r.read<int?>('temp_id');
|
||||||
|
if (t != null) ids.add(t);
|
||||||
|
if (tmp != null) ids.add(tmp);
|
||||||
|
}
|
||||||
|
return ids;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Row ↔ model converters ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
CachedNotesCompanion _noteToCompanion(Note n) => CachedNotesCompanion(
|
||||||
|
id: Value(n.id),
|
||||||
|
title: Value(n.title),
|
||||||
|
body: Value(n.body),
|
||||||
|
tagsJson: Value(jsonEncode(n.tags)),
|
||||||
|
noteType: Value(n.noteType),
|
||||||
|
projectId: Value(n.projectId),
|
||||||
|
milestoneId: Value(n.milestoneId),
|
||||||
|
createdAt: Value(n.createdAt),
|
||||||
|
updatedAt: Value(n.updatedAt),
|
||||||
|
);
|
||||||
|
|
||||||
|
Note _noteFromRow(CachedNote r) => Note(
|
||||||
|
id: r.id,
|
||||||
|
title: r.title,
|
||||||
|
body: r.body,
|
||||||
|
tags: (jsonDecode(r.tagsJson) as List<dynamic>).cast<String>(),
|
||||||
|
noteType: r.noteType,
|
||||||
|
projectId: r.projectId,
|
||||||
|
milestoneId: r.milestoneId,
|
||||||
|
createdAt: r.createdAt,
|
||||||
|
updatedAt: r.updatedAt,
|
||||||
|
);
|
||||||
|
|
||||||
|
CachedTasksCompanion _taskToCompanion(Task t) => CachedTasksCompanion(
|
||||||
|
id: Value(t.id),
|
||||||
|
title: Value(t.title),
|
||||||
|
description: Value(t.description),
|
||||||
|
status: Value(t.status.value),
|
||||||
|
priority: Value(t.priority.value),
|
||||||
|
dueDate: Value(t.dueDate),
|
||||||
|
projectId: Value(t.projectId),
|
||||||
|
milestoneId: Value(t.milestoneId),
|
||||||
|
parentId: Value(t.parentId),
|
||||||
|
createdAt: Value(t.createdAt),
|
||||||
|
updatedAt: Value(t.updatedAt),
|
||||||
|
);
|
||||||
|
|
||||||
|
Task _taskFromRow(CachedTask r) => Task(
|
||||||
|
id: r.id,
|
||||||
|
title: r.title,
|
||||||
|
description: r.description,
|
||||||
|
status: TaskStatusExtension.fromString(r.status),
|
||||||
|
priority: TaskPriorityExtension.fromString(r.priority),
|
||||||
|
dueDate: r.dueDate,
|
||||||
|
projectId: r.projectId,
|
||||||
|
milestoneId: r.milestoneId,
|
||||||
|
parentId: r.parentId,
|
||||||
|
createdAt: r.createdAt,
|
||||||
|
updatedAt: r.updatedAt,
|
||||||
|
);
|
||||||
|
|
||||||
|
CachedProjectsCompanion _projectToCompanion(Project p) =>
|
||||||
|
CachedProjectsCompanion(
|
||||||
|
id: Value(p.id),
|
||||||
|
title: Value(p.title),
|
||||||
|
description: Value(p.description),
|
||||||
|
goal: Value(p.goal),
|
||||||
|
status: Value(p.status),
|
||||||
|
color: Value(p.color),
|
||||||
|
autoSummary: Value(p.autoSummary),
|
||||||
|
createdAt: Value(p.createdAt),
|
||||||
|
updatedAt: Value(p.updatedAt),
|
||||||
|
);
|
||||||
|
|
||||||
|
Project _projectFromRow(CachedProject r) => Project(
|
||||||
|
id: r.id,
|
||||||
|
title: r.title,
|
||||||
|
description: r.description,
|
||||||
|
goal: r.goal,
|
||||||
|
status: r.status,
|
||||||
|
color: r.color,
|
||||||
|
autoSummary: r.autoSummary,
|
||||||
|
createdAt: r.createdAt,
|
||||||
|
updatedAt: r.updatedAt,
|
||||||
|
);
|
||||||
|
|
||||||
|
CachedMilestonesCompanion _milestoneToCompanion(Milestone m) =>
|
||||||
|
CachedMilestonesCompanion(
|
||||||
|
id: Value(m.id),
|
||||||
|
projectId: Value(m.projectId),
|
||||||
|
title: Value(m.title),
|
||||||
|
description: Value(m.description),
|
||||||
|
status: Value(m.status),
|
||||||
|
orderIndex: Value(m.orderIndex),
|
||||||
|
total: Value(m.total),
|
||||||
|
completed: Value(m.completed),
|
||||||
|
pct: Value(m.pct),
|
||||||
|
createdAt: Value(m.createdAt),
|
||||||
|
updatedAt: Value(m.updatedAt),
|
||||||
|
);
|
||||||
|
|
||||||
|
Milestone _milestoneFromRow(CachedMilestone r) => Milestone(
|
||||||
|
id: r.id,
|
||||||
|
projectId: r.projectId,
|
||||||
|
title: r.title,
|
||||||
|
description: r.description,
|
||||||
|
status: r.status,
|
||||||
|
orderIndex: r.orderIndex,
|
||||||
|
total: r.total,
|
||||||
|
completed: r.completed,
|
||||||
|
pct: r.pct,
|
||||||
|
createdAt: r.createdAt,
|
||||||
|
updatedAt: r.updatedAt,
|
||||||
|
);
|
||||||
|
|
||||||
|
CachedCalendarEventsCompanion _eventToCompanion(CalendarEvent e) =>
|
||||||
|
CachedCalendarEventsCompanion(
|
||||||
|
id: Value(e.id),
|
||||||
|
title: Value(e.title),
|
||||||
|
startDt: Value(e.startDt),
|
||||||
|
endDt: Value(e.endDt),
|
||||||
|
allDay: Value(e.allDay),
|
||||||
|
description: Value(e.description),
|
||||||
|
location: Value(e.location),
|
||||||
|
color: Value(e.color),
|
||||||
|
recurrence: Value(e.recurrence),
|
||||||
|
projectId: Value(e.projectId),
|
||||||
|
reminderMinutes: Value(e.reminderMinutes),
|
||||||
|
);
|
||||||
|
|
||||||
|
CalendarEvent _eventFromRow(CachedCalendarEvent r) => CalendarEvent(
|
||||||
|
id: r.id,
|
||||||
|
title: r.title,
|
||||||
|
startDt: r.startDt,
|
||||||
|
endDt: r.endDt,
|
||||||
|
allDay: r.allDay,
|
||||||
|
description: r.description,
|
||||||
|
location: r.location,
|
||||||
|
color: r.color,
|
||||||
|
recurrence: r.recurrence,
|
||||||
|
projectId: r.projectId,
|
||||||
|
reminderMinutes: r.reminderMinutes,
|
||||||
|
);
|
||||||
|
|
||||||
|
CachedConversationsCompanion _conversationToCompanion(Conversation c) =>
|
||||||
|
CachedConversationsCompanion(
|
||||||
|
id: Value(c.id),
|
||||||
|
title: Value(c.title),
|
||||||
|
createdAt: Value(c.createdAt),
|
||||||
|
updatedAt: Value(c.updatedAt),
|
||||||
|
);
|
||||||
|
|
||||||
|
Conversation _conversationFromRow(CachedConversation r) => Conversation(
|
||||||
|
id: r.id,
|
||||||
|
title: r.title,
|
||||||
|
createdAt: r.createdAt,
|
||||||
|
updatedAt: r.updatedAt,
|
||||||
|
);
|
||||||
|
|
||||||
|
LazyDatabase _openConnection() {
|
||||||
|
return LazyDatabase(() async {
|
||||||
|
if (Platform.isAndroid) {
|
||||||
|
await applyWorkaroundToOpenSqlite3OnOldAndroidVersions();
|
||||||
|
}
|
||||||
|
final dir = await getApplicationDocumentsDirectory();
|
||||||
|
final file = File(p.join(dir.path, 'fabled_cache.sqlite'));
|
||||||
|
return NativeDatabase.createInBackground(file);
|
||||||
|
});
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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,
|
id: json['id'] as int,
|
||||||
noteType: json['note_type'] as String? ?? 'note',
|
noteType: json['note_type'] as String? ?? 'note',
|
||||||
title: json['title'] as String? ?? '',
|
title: json['title'] as String? ?? '',
|
||||||
body: json['body'] as String? ?? '',
|
body: (json['snippet'] ?? json['body']) as String? ?? '',
|
||||||
tags: (json['tags'] as List<dynamic>?)
|
tags: (json['tags'] as List<dynamic>?)
|
||||||
?.map((e) => e as String)
|
?.map((e) => e as String)
|
||||||
.toList() ??
|
.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?,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,21 +1,46 @@
|
|||||||
|
import '../../core/exceptions.dart';
|
||||||
import '../api/chat_api.dart';
|
import '../api/chat_api.dart';
|
||||||
export '../api/chat_api.dart'
|
export '../api/chat_api.dart'
|
||||||
show ChatStreamEvent, ChatTextChunk, ChatStatusUpdate, ChatToolCall;
|
show ChatStreamEvent, ChatTextChunk, ChatStatusUpdate, ChatToolCall;
|
||||||
|
import '../local/database.dart';
|
||||||
import '../models/conversation.dart';
|
import '../models/conversation.dart';
|
||||||
import '../models/message.dart';
|
import '../models/message.dart';
|
||||||
|
|
||||||
|
/// Chat repository with read-through caching for the conversation list only.
|
||||||
|
/// Messages and the live SSE stream are intentionally not cached — they are
|
||||||
|
/// per-conversation, large, and require a live network anyway.
|
||||||
class ChatRepository {
|
class ChatRepository {
|
||||||
final ChatApi _api;
|
final ChatApi _api;
|
||||||
const ChatRepository(this._api);
|
final FabledDatabase _db;
|
||||||
|
|
||||||
|
const ChatRepository(this._api, this._db);
|
||||||
|
|
||||||
|
Future<List<Conversation>> getConversations() async {
|
||||||
|
try {
|
||||||
|
final conversations = await _api.getConversations();
|
||||||
|
await _db.replaceAllConversations(conversations);
|
||||||
|
return conversations;
|
||||||
|
} on NetworkException {
|
||||||
|
final cached = await _db.getAllConversations();
|
||||||
|
if (cached.isEmpty) rethrow;
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<List<Conversation>> getConversations() => _api.getConversations();
|
|
||||||
Future<Conversation> createConversation(String title) =>
|
Future<Conversation> createConversation(String title) =>
|
||||||
_api.createConversation(title);
|
_api.createConversation(title);
|
||||||
Future<void> deleteConversation(int id) => _api.deleteConversation(id);
|
|
||||||
|
Future<void> deleteConversation(int id) async {
|
||||||
|
await _api.deleteConversation(id);
|
||||||
|
await _db.deleteConversation(id);
|
||||||
|
}
|
||||||
|
|
||||||
Future<(Conversation, List<Message>)> getMessages(int conversationId) =>
|
Future<(Conversation, List<Message>)> getMessages(int conversationId) =>
|
||||||
_api.getMessages(conversationId);
|
_api.getMessages(conversationId);
|
||||||
|
|
||||||
Future<void> sendMessage(int conversationId, String content) =>
|
Future<void> sendMessage(int conversationId, String content) =>
|
||||||
_api.sendMessage(conversationId, content);
|
_api.sendMessage(conversationId, content);
|
||||||
|
|
||||||
Stream<ChatStreamEvent> streamGeneration(int conversationId) =>
|
Stream<ChatStreamEvent> streamGeneration(int conversationId) =>
|
||||||
_api.streamGeneration(conversationId);
|
_api.streamGeneration(conversationId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import '../../core/exceptions.dart';
|
||||||
|
import '../api/events_api.dart';
|
||||||
|
import '../local/database.dart';
|
||||||
|
import '../models/calendar_event.dart';
|
||||||
|
|
||||||
|
/// Calendar events repository with read-through caching (Phase 1+2) and
|
||||||
|
/// offline-write queueing (Phase 3). Range-scoped reads: `getEvents(from, to)`
|
||||||
|
/// mirrors that window into the cache (events outside the window are left
|
||||||
|
/// alone) so successive disjoint range fetches don't clobber each other.
|
||||||
|
///
|
||||||
|
/// CalendarEvent has no `updatedAt` field, so write replay uses last-
|
||||||
|
/// writer-wins on update/delete (no server-side baseline check).
|
||||||
|
class EventsRepository {
|
||||||
|
final EventsApi _api;
|
||||||
|
final FabledDatabase _db;
|
||||||
|
|
||||||
|
const EventsRepository(this._api, this._db);
|
||||||
|
|
||||||
|
Future<List<CalendarEvent>> getEvents(DateTime from, DateTime to) async {
|
||||||
|
try {
|
||||||
|
final events = await _api.getEvents(from, to);
|
||||||
|
await _db.replaceEventsInRange(from, to, events);
|
||||||
|
return events;
|
||||||
|
} on NetworkException {
|
||||||
|
final cached = await _db.getEventsInRange(from, to);
|
||||||
|
if (cached.isEmpty) rethrow;
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<CalendarEvent> createEvent(Map<String, dynamic> payload) async {
|
||||||
|
try {
|
||||||
|
final event = await _api.createEvent(payload);
|
||||||
|
await _db.upsertEvent(event);
|
||||||
|
return event;
|
||||||
|
} on NetworkException {
|
||||||
|
final tempId = await _db.nextTempId();
|
||||||
|
final optimistic = _eventFromPayload(tempId, payload);
|
||||||
|
await _db.upsertEvent(optimistic);
|
||||||
|
await _db.enqueuePending(
|
||||||
|
domain: kSyncDomainEvents,
|
||||||
|
verb: kWriteVerbCreate,
|
||||||
|
tempId: tempId,
|
||||||
|
payload: payload,
|
||||||
|
);
|
||||||
|
return optimistic;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<CalendarEvent> updateEvent(
|
||||||
|
int id, Map<String, dynamic> fields) async {
|
||||||
|
try {
|
||||||
|
final updated = await _api.updateEvent(id, fields);
|
||||||
|
await _db.upsertEvent(updated);
|
||||||
|
return updated;
|
||||||
|
} on NetworkException {
|
||||||
|
final cached = (await _db.getEventsInRange(
|
||||||
|
DateTime.fromMicrosecondsSinceEpoch(0),
|
||||||
|
DateTime.now().add(const Duration(days: 365 * 100)),
|
||||||
|
))
|
||||||
|
.where((e) => e.id == id)
|
||||||
|
.firstOrNull;
|
||||||
|
if (cached == null) rethrow;
|
||||||
|
final optimistic = _applyEventFields(cached, fields);
|
||||||
|
await _db.upsertEvent(optimistic);
|
||||||
|
if (id < 0) {
|
||||||
|
final queued = await _db.findQueuedCreate(kSyncDomainEvents, id);
|
||||||
|
if (queued != null) {
|
||||||
|
await _db.updatePendingPayload(queued.id, fields);
|
||||||
|
return optimistic;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await _db.enqueuePending(
|
||||||
|
domain: kSyncDomainEvents,
|
||||||
|
verb: kWriteVerbUpdate,
|
||||||
|
targetId: id,
|
||||||
|
payload: fields,
|
||||||
|
);
|
||||||
|
return optimistic;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> deleteEvent(int id) async {
|
||||||
|
try {
|
||||||
|
await _api.deleteEvent(id);
|
||||||
|
await _db.deleteEvent(id);
|
||||||
|
} on NetworkException {
|
||||||
|
if (id < 0) {
|
||||||
|
final queued = await _db.findQueuedCreate(kSyncDomainEvents, id);
|
||||||
|
if (queued != null) await _db.deletePending(queued.id);
|
||||||
|
await _db.deleteEvent(id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await _db.deleteEvent(id);
|
||||||
|
await _db.enqueuePending(
|
||||||
|
domain: kSyncDomainEvents,
|
||||||
|
verb: kWriteVerbDelete,
|
||||||
|
targetId: id,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
CalendarEvent _eventFromPayload(int id, Map<String, dynamic> p) {
|
||||||
|
return CalendarEvent(
|
||||||
|
id: id,
|
||||||
|
title: p['title'] as String? ?? '',
|
||||||
|
startDt: _parseIso(p['start_dt'])!,
|
||||||
|
endDt: _parseIso(p['end_dt']),
|
||||||
|
allDay: p['all_day'] as bool? ?? false,
|
||||||
|
description: p['description'] as String? ?? '',
|
||||||
|
location: p['location'] as String? ?? '',
|
||||||
|
color: p['color'] as String? ?? '',
|
||||||
|
recurrence: p['recurrence'] as String?,
|
||||||
|
projectId: p['project_id'] as int?,
|
||||||
|
reminderMinutes: p['reminder_minutes'] as int?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
CalendarEvent _applyEventFields(CalendarEvent e, Map<String, dynamic> f) {
|
||||||
|
return CalendarEvent(
|
||||||
|
id: e.id,
|
||||||
|
title: f['title'] as String? ?? e.title,
|
||||||
|
startDt: _parseIso(f['start_dt']) ?? e.startDt,
|
||||||
|
endDt: f.containsKey('end_dt') ? _parseIso(f['end_dt']) : e.endDt,
|
||||||
|
allDay: f['all_day'] as bool? ?? e.allDay,
|
||||||
|
description: f['description'] as String? ?? e.description,
|
||||||
|
location: f['location'] as String? ?? e.location,
|
||||||
|
color: f['color'] as String? ?? e.color,
|
||||||
|
recurrence:
|
||||||
|
f.containsKey('recurrence') ? f['recurrence'] as String? : e.recurrence,
|
||||||
|
projectId: f.containsKey('project_id')
|
||||||
|
? f['project_id'] as int?
|
||||||
|
: e.projectId,
|
||||||
|
reminderMinutes: f.containsKey('reminder_minutes')
|
||||||
|
? f['reminder_minutes'] as int?
|
||||||
|
: e.reminderMinutes,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
DateTime? _parseIso(dynamic raw) {
|
||||||
|
if (raw is String && raw.isNotEmpty) return DateTime.tryParse(raw)?.toLocal();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -1,26 +1,164 @@
|
|||||||
|
import '../../core/exceptions.dart';
|
||||||
import '../api/milestones_api.dart';
|
import '../api/milestones_api.dart';
|
||||||
|
import '../local/database.dart';
|
||||||
import '../models/milestone.dart';
|
import '../models/milestone.dart';
|
||||||
|
|
||||||
|
/// Milestones repository with read-through caching (Phase 1+2) and
|
||||||
|
/// offline-write queueing (Phase 3). See `notes_repository.dart` for the
|
||||||
|
/// full pattern.
|
||||||
class MilestonesRepository {
|
class MilestonesRepository {
|
||||||
final MilestonesApi _api;
|
final MilestonesApi _api;
|
||||||
const MilestonesRepository(this._api);
|
final FabledDatabase _db;
|
||||||
|
|
||||||
Future<List<Milestone>> getAll(int projectId, {String? status}) =>
|
const MilestonesRepository(this._api, this._db);
|
||||||
_api.getAll(projectId, status: status);
|
|
||||||
|
Future<List<Milestone>> getAll(int projectId, {String? status}) async {
|
||||||
|
try {
|
||||||
|
final milestones = await _api.getAll(projectId, status: status);
|
||||||
|
// Only mirror the full per-project list to cache on the unfiltered
|
||||||
|
// fetch; a status filter would otherwise drop entries from cache.
|
||||||
|
if (status == null) {
|
||||||
|
await _db.replaceMilestonesForProject(projectId, milestones);
|
||||||
|
} else {
|
||||||
|
for (final m in milestones) {
|
||||||
|
await _db.upsertMilestone(m);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return milestones;
|
||||||
|
} on NetworkException {
|
||||||
|
final cached = await _db.getMilestonesForProject(projectId);
|
||||||
|
if (cached.isEmpty) rethrow;
|
||||||
|
if (status != null) {
|
||||||
|
return cached.where((m) => m.status == status).toList();
|
||||||
|
}
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<Milestone> create(
|
Future<Milestone> create(
|
||||||
int projectId, {
|
int projectId, {
|
||||||
required String title,
|
required String title,
|
||||||
String? description,
|
String? description,
|
||||||
int orderIndex = 0,
|
int orderIndex = 0,
|
||||||
}) =>
|
}) async {
|
||||||
_api.create(projectId,
|
try {
|
||||||
title: title, description: description, orderIndex: orderIndex);
|
final milestone = await _api.create(
|
||||||
|
projectId,
|
||||||
|
title: title,
|
||||||
|
description: description,
|
||||||
|
orderIndex: orderIndex,
|
||||||
|
);
|
||||||
|
await _db.upsertMilestone(milestone);
|
||||||
|
return milestone;
|
||||||
|
} on NetworkException {
|
||||||
|
final tempId = await _db.nextTempId();
|
||||||
|
final now = DateTime.now();
|
||||||
|
final optimistic = Milestone(
|
||||||
|
id: tempId,
|
||||||
|
projectId: projectId,
|
||||||
|
title: title,
|
||||||
|
description: description,
|
||||||
|
status: 'active',
|
||||||
|
orderIndex: orderIndex,
|
||||||
|
total: 0,
|
||||||
|
completed: 0,
|
||||||
|
pct: 0.0,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
);
|
||||||
|
await _db.upsertMilestone(optimistic);
|
||||||
|
await _db.enqueuePending(
|
||||||
|
domain: kSyncDomainMilestones,
|
||||||
|
verb: kWriteVerbCreate,
|
||||||
|
tempId: tempId,
|
||||||
|
payload: {
|
||||||
|
'project_id': projectId,
|
||||||
|
'title': title,
|
||||||
|
'description': description,
|
||||||
|
'order_index': orderIndex,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return optimistic;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<Milestone> update(
|
Future<Milestone> update(
|
||||||
int projectId, int milestoneId, Map<String, dynamic> fields) =>
|
int projectId,
|
||||||
_api.update(projectId, milestoneId, fields);
|
int milestoneId,
|
||||||
|
Map<String, dynamic> fields,
|
||||||
|
) async {
|
||||||
|
try {
|
||||||
|
final updated = await _api.update(projectId, milestoneId, fields);
|
||||||
|
await _db.upsertMilestone(updated);
|
||||||
|
return updated;
|
||||||
|
} on NetworkException {
|
||||||
|
final cached = (await _db.getMilestonesForProject(projectId))
|
||||||
|
.where((m) => m.id == milestoneId)
|
||||||
|
.firstOrNull;
|
||||||
|
if (cached == null) rethrow;
|
||||||
|
final optimistic = _applyMilestoneFields(cached, fields);
|
||||||
|
await _db.upsertMilestone(optimistic);
|
||||||
|
if (milestoneId < 0) {
|
||||||
|
final queued =
|
||||||
|
await _db.findQueuedCreate(kSyncDomainMilestones, milestoneId);
|
||||||
|
if (queued != null) {
|
||||||
|
// Carry project_id through coalesced payload — replay needs it.
|
||||||
|
final merged = <String, dynamic>{
|
||||||
|
'project_id': projectId,
|
||||||
|
...fields,
|
||||||
|
};
|
||||||
|
await _db.updatePendingPayload(queued.id, merged);
|
||||||
|
return optimistic;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await _db.enqueuePending(
|
||||||
|
domain: kSyncDomainMilestones,
|
||||||
|
verb: kWriteVerbUpdate,
|
||||||
|
targetId: milestoneId,
|
||||||
|
payload: <String, dynamic>{'project_id': projectId, ...fields},
|
||||||
|
baselineUpdatedAt: cached.updatedAt,
|
||||||
|
);
|
||||||
|
return optimistic;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> delete(int projectId, int milestoneId) =>
|
Future<void> delete(int projectId, int milestoneId) async {
|
||||||
_api.delete(projectId, milestoneId);
|
try {
|
||||||
|
await _api.delete(projectId, milestoneId);
|
||||||
|
await _db.deleteMilestone(milestoneId);
|
||||||
|
} on NetworkException {
|
||||||
|
if (milestoneId < 0) {
|
||||||
|
final queued =
|
||||||
|
await _db.findQueuedCreate(kSyncDomainMilestones, milestoneId);
|
||||||
|
if (queued != null) await _db.deletePending(queued.id);
|
||||||
|
await _db.deleteMilestone(milestoneId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await _db.deleteMilestone(milestoneId);
|
||||||
|
await _db.enqueuePending(
|
||||||
|
domain: kSyncDomainMilestones,
|
||||||
|
verb: kWriteVerbDelete,
|
||||||
|
targetId: milestoneId,
|
||||||
|
payload: {'project_id': projectId},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Milestone _applyMilestoneFields(Milestone m, Map<String, dynamic> f) {
|
||||||
|
return Milestone(
|
||||||
|
id: m.id,
|
||||||
|
projectId: m.projectId,
|
||||||
|
title: f['title'] as String? ?? m.title,
|
||||||
|
description: f.containsKey('description')
|
||||||
|
? f['description'] as String?
|
||||||
|
: m.description,
|
||||||
|
status: f['status'] as String? ?? m.status,
|
||||||
|
orderIndex: f['order_index'] as int? ?? m.orderIndex,
|
||||||
|
total: m.total,
|
||||||
|
completed: m.completed,
|
||||||
|
pct: m.pct,
|
||||||
|
createdAt: m.createdAt,
|
||||||
|
updatedAt: m.updatedAt,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,53 @@
|
|||||||
|
import '../../core/exceptions.dart';
|
||||||
import '../api/notes_api.dart';
|
import '../api/notes_api.dart';
|
||||||
|
import '../local/database.dart';
|
||||||
import '../models/note.dart';
|
import '../models/note.dart';
|
||||||
|
|
||||||
|
/// Notes repository with read-through caching for Tier 2 offline support
|
||||||
|
/// (Phase 1+2) and offline-write queueing (Phase 3).
|
||||||
|
///
|
||||||
|
/// Reads attempt the network first; on success the response is written to
|
||||||
|
/// the local Drift cache. On `NetworkException` the read falls back to the
|
||||||
|
/// cache (rethrowing if the cache is empty so the UI can show its
|
||||||
|
/// fresh-install empty state).
|
||||||
|
///
|
||||||
|
/// Writes hit the server then sync the cache. On `NetworkException` the
|
||||||
|
/// write is queued in `pending_writes` and applied optimistically to the
|
||||||
|
/// cache (negative temp-id for creates, in-place update for edits, removal
|
||||||
|
/// for deletes). Subsequent edits to a temp-id row are coalesced into the
|
||||||
|
/// queued create — only one server call is made per offline-created row.
|
||||||
|
///
|
||||||
|
/// `AuthStatus.offline` is owned by `AuthNotifier.verify()` (a periodic
|
||||||
|
/// heartbeat). This repository deliberately does not poke that state.
|
||||||
class NotesRepository {
|
class NotesRepository {
|
||||||
final NotesApi _api;
|
final NotesApi _api;
|
||||||
const NotesRepository(this._api);
|
final FabledDatabase _db;
|
||||||
|
|
||||||
Future<List<Note>> getAll() => _api.getAll();
|
const NotesRepository(this._api, this._db);
|
||||||
Future<Note> getOne(int id) => _api.getOne(id);
|
|
||||||
|
Future<List<Note>> getAll() async {
|
||||||
|
try {
|
||||||
|
final notes = await _api.getAll();
|
||||||
|
await _db.replaceAllNotes(notes);
|
||||||
|
return notes;
|
||||||
|
} on NetworkException {
|
||||||
|
final cached = await _db.getAllNotes();
|
||||||
|
if (cached.isEmpty) rethrow;
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Note> getOne(int id) async {
|
||||||
|
try {
|
||||||
|
final note = await _api.getOne(id);
|
||||||
|
await _db.upsertNote(note);
|
||||||
|
return note;
|
||||||
|
} on NetworkException {
|
||||||
|
final cached = await _db.getNote(id);
|
||||||
|
if (cached == null) rethrow;
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<Note> create(
|
Future<Note> create(
|
||||||
String title,
|
String title,
|
||||||
@@ -14,9 +55,46 @@ class NotesRepository {
|
|||||||
List<String> tags = const [],
|
List<String> tags = const [],
|
||||||
int? projectId,
|
int? projectId,
|
||||||
String noteType = 'note',
|
String noteType = 'note',
|
||||||
}) =>
|
}) async {
|
||||||
_api.create(title, body,
|
try {
|
||||||
tags: tags, projectId: projectId, noteType: noteType);
|
final note = await _api.create(
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
tags: tags,
|
||||||
|
projectId: projectId,
|
||||||
|
noteType: noteType,
|
||||||
|
);
|
||||||
|
await _db.upsertNote(note);
|
||||||
|
return note;
|
||||||
|
} on NetworkException {
|
||||||
|
final tempId = await _db.nextTempId();
|
||||||
|
final now = DateTime.now();
|
||||||
|
final optimistic = Note(
|
||||||
|
id: tempId,
|
||||||
|
title: title,
|
||||||
|
body: body,
|
||||||
|
tags: tags,
|
||||||
|
noteType: noteType,
|
||||||
|
projectId: projectId,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
);
|
||||||
|
await _db.upsertNote(optimistic);
|
||||||
|
await _db.enqueuePending(
|
||||||
|
domain: kSyncDomainNotes,
|
||||||
|
verb: kWriteVerbCreate,
|
||||||
|
tempId: tempId,
|
||||||
|
payload: {
|
||||||
|
'title': title,
|
||||||
|
'body': body,
|
||||||
|
'tags': tags,
|
||||||
|
'project_id': projectId,
|
||||||
|
'note_type': noteType,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return optimistic;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<Note> update(
|
Future<Note> update(
|
||||||
int id,
|
int id,
|
||||||
@@ -26,12 +104,76 @@ class NotesRepository {
|
|||||||
int? projectId,
|
int? projectId,
|
||||||
bool clearProject = false,
|
bool clearProject = false,
|
||||||
String noteType = 'note',
|
String noteType = 'note',
|
||||||
}) =>
|
}) async {
|
||||||
_api.update(id, title, body,
|
try {
|
||||||
tags: tags,
|
final updated = await _api.update(
|
||||||
projectId: projectId,
|
id,
|
||||||
clearProject: clearProject,
|
title,
|
||||||
noteType: noteType);
|
body,
|
||||||
|
tags: tags,
|
||||||
|
projectId: projectId,
|
||||||
|
clearProject: clearProject,
|
||||||
|
noteType: noteType,
|
||||||
|
);
|
||||||
|
await _db.upsertNote(updated);
|
||||||
|
return updated;
|
||||||
|
} on NetworkException {
|
||||||
|
final cached = await _db.getNote(id);
|
||||||
|
if (cached == null) rethrow;
|
||||||
|
final payload = <String, dynamic>{
|
||||||
|
'title': title,
|
||||||
|
'body': body,
|
||||||
|
'tags': tags,
|
||||||
|
'project_id': projectId,
|
||||||
|
'clear_project': clearProject,
|
||||||
|
'note_type': noteType,
|
||||||
|
};
|
||||||
|
final optimistic = cached.copyWith(
|
||||||
|
title: title,
|
||||||
|
body: body,
|
||||||
|
tags: tags,
|
||||||
|
noteType: noteType,
|
||||||
|
projectId: clearProject ? null : projectId,
|
||||||
|
);
|
||||||
|
await _db.upsertNote(optimistic);
|
||||||
|
// Edits to an offline-created row coalesce into the queued create.
|
||||||
|
if (id < 0) {
|
||||||
|
final queued = await _db.findQueuedCreate(kSyncDomainNotes, id);
|
||||||
|
if (queued != null) {
|
||||||
|
await _db.updatePendingPayload(queued.id, payload);
|
||||||
|
return optimistic;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await _db.enqueuePending(
|
||||||
|
domain: kSyncDomainNotes,
|
||||||
|
verb: kWriteVerbUpdate,
|
||||||
|
targetId: id,
|
||||||
|
payload: payload,
|
||||||
|
baselineUpdatedAt: cached.updatedAt,
|
||||||
|
);
|
||||||
|
return optimistic;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> delete(int id) => _api.delete(id);
|
Future<void> delete(int id) async {
|
||||||
|
try {
|
||||||
|
await _api.delete(id);
|
||||||
|
await _db.deleteNote(id);
|
||||||
|
} on NetworkException {
|
||||||
|
// Deleting an offline-created row that never reached the server
|
||||||
|
// just drops the queued create — no server call needed.
|
||||||
|
if (id < 0) {
|
||||||
|
final queued = await _db.findQueuedCreate(kSyncDomainNotes, id);
|
||||||
|
if (queued != null) await _db.deletePending(queued.id);
|
||||||
|
await _db.deleteNote(id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await _db.deleteNote(id);
|
||||||
|
await _db.enqueuePending(
|
||||||
|
domain: kSyncDomainNotes,
|
||||||
|
verb: kWriteVerbDelete,
|
||||||
|
targetId: id,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,31 +1,168 @@
|
|||||||
|
import '../../core/exceptions.dart';
|
||||||
import '../api/projects_api.dart';
|
import '../api/projects_api.dart';
|
||||||
|
import '../local/database.dart';
|
||||||
import '../models/project.dart';
|
import '../models/project.dart';
|
||||||
|
|
||||||
|
/// Projects repository with read-through caching (Phase 1+2) and offline-write
|
||||||
|
/// queueing (Phase 3). See `notes_repository.dart` for the full pattern.
|
||||||
|
///
|
||||||
|
/// `getAll`'s sort/order/status query parameters are server-side filters; the
|
||||||
|
/// cache stores the unfiltered list as last seen. Offline fallback returns
|
||||||
|
/// the full cache regardless of the requested filters — close enough for a
|
||||||
|
/// "view what you had" experience while disconnected.
|
||||||
class ProjectsRepository {
|
class ProjectsRepository {
|
||||||
final ProjectsApi _api;
|
final ProjectsApi _api;
|
||||||
const ProjectsRepository(this._api);
|
final FabledDatabase _db;
|
||||||
|
|
||||||
|
const ProjectsRepository(this._api, this._db);
|
||||||
|
|
||||||
Future<List<Project>> getAll({
|
Future<List<Project>> getAll({
|
||||||
String? status,
|
String? status,
|
||||||
String sort = 'updated_at',
|
String sort = 'updated_at',
|
||||||
String order = 'desc',
|
String order = 'desc',
|
||||||
}) =>
|
}) async {
|
||||||
_api.getAll(status: status, sort: sort, order: order);
|
try {
|
||||||
Future<Project> getOne(int id) => _api.getOne(id);
|
final projects =
|
||||||
|
await _api.getAll(status: status, sort: sort, order: order);
|
||||||
|
// Only refresh the cache on the unfiltered default fetch — otherwise a
|
||||||
|
// status=archived call would clobber the active-projects cache.
|
||||||
|
if (status == null) {
|
||||||
|
await _db.replaceAllProjects(projects);
|
||||||
|
} else {
|
||||||
|
for (final p in projects) {
|
||||||
|
await _db.upsertProject(p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return projects;
|
||||||
|
} on NetworkException {
|
||||||
|
final cached = await _db.getAllProjects();
|
||||||
|
if (cached.isEmpty) rethrow;
|
||||||
|
if (status != null) {
|
||||||
|
return cached.where((p) => p.status == status).toList();
|
||||||
|
}
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Project> getOne(int id) async {
|
||||||
|
try {
|
||||||
|
final project = await _api.getOne(id);
|
||||||
|
await _db.upsertProject(project);
|
||||||
|
return project;
|
||||||
|
} on NetworkException {
|
||||||
|
final cached = await _db.getProject(id);
|
||||||
|
if (cached == null) rethrow;
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<Project> create({
|
Future<Project> create({
|
||||||
required String title,
|
required String title,
|
||||||
String? description,
|
String? description,
|
||||||
String? goal,
|
String? goal,
|
||||||
String? color,
|
String? color,
|
||||||
String status = 'active',
|
String status = 'active',
|
||||||
}) =>
|
}) async {
|
||||||
_api.create(
|
try {
|
||||||
title: title,
|
final project = await _api.create(
|
||||||
description: description,
|
title: title,
|
||||||
goal: goal,
|
description: description,
|
||||||
color: color,
|
goal: goal,
|
||||||
status: status);
|
color: color,
|
||||||
Future<Project> update(int id, Map<String, dynamic> fields) =>
|
status: status,
|
||||||
_api.update(id, fields);
|
);
|
||||||
Future<void> delete(int id) => _api.delete(id);
|
await _db.upsertProject(project);
|
||||||
|
return project;
|
||||||
|
} on NetworkException {
|
||||||
|
final tempId = await _db.nextTempId();
|
||||||
|
final now = DateTime.now();
|
||||||
|
final optimistic = Project(
|
||||||
|
id: tempId,
|
||||||
|
title: title,
|
||||||
|
description: description,
|
||||||
|
goal: goal,
|
||||||
|
status: status,
|
||||||
|
color: color,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
);
|
||||||
|
await _db.upsertProject(optimistic);
|
||||||
|
await _db.enqueuePending(
|
||||||
|
domain: kSyncDomainProjects,
|
||||||
|
verb: kWriteVerbCreate,
|
||||||
|
tempId: tempId,
|
||||||
|
payload: {
|
||||||
|
'title': title,
|
||||||
|
'description': description,
|
||||||
|
'goal': goal,
|
||||||
|
'color': color,
|
||||||
|
'status': status,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return optimistic;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Project> update(int id, Map<String, dynamic> fields) async {
|
||||||
|
try {
|
||||||
|
final updated = await _api.update(id, fields);
|
||||||
|
await _db.upsertProject(updated);
|
||||||
|
return updated;
|
||||||
|
} on NetworkException {
|
||||||
|
final cached = await _db.getProject(id);
|
||||||
|
if (cached == null) rethrow;
|
||||||
|
final optimistic = _applyProjectFields(cached, fields);
|
||||||
|
await _db.upsertProject(optimistic);
|
||||||
|
if (id < 0) {
|
||||||
|
final queued = await _db.findQueuedCreate(kSyncDomainProjects, id);
|
||||||
|
if (queued != null) {
|
||||||
|
await _db.updatePendingPayload(queued.id, fields);
|
||||||
|
return optimistic;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await _db.enqueuePending(
|
||||||
|
domain: kSyncDomainProjects,
|
||||||
|
verb: kWriteVerbUpdate,
|
||||||
|
targetId: id,
|
||||||
|
payload: fields,
|
||||||
|
baselineUpdatedAt: cached.updatedAt,
|
||||||
|
);
|
||||||
|
return optimistic;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> delete(int id) async {
|
||||||
|
try {
|
||||||
|
await _api.delete(id);
|
||||||
|
await _db.deleteProject(id);
|
||||||
|
} on NetworkException {
|
||||||
|
if (id < 0) {
|
||||||
|
final queued = await _db.findQueuedCreate(kSyncDomainProjects, id);
|
||||||
|
if (queued != null) await _db.deletePending(queued.id);
|
||||||
|
await _db.deleteProject(id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await _db.deleteProject(id);
|
||||||
|
await _db.enqueuePending(
|
||||||
|
domain: kSyncDomainProjects,
|
||||||
|
verb: kWriteVerbDelete,
|
||||||
|
targetId: id,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Project _applyProjectFields(Project p, Map<String, dynamic> f) {
|
||||||
|
return Project(
|
||||||
|
id: p.id,
|
||||||
|
title: f['title'] as String? ?? p.title,
|
||||||
|
description:
|
||||||
|
f.containsKey('description') ? f['description'] as String? : p.description,
|
||||||
|
goal: f.containsKey('goal') ? f['goal'] as String? : p.goal,
|
||||||
|
status: f['status'] as String? ?? p.status,
|
||||||
|
color: f.containsKey('color') ? f['color'] as String? : p.color,
|
||||||
|
autoSummary: p.autoSummary,
|
||||||
|
createdAt: p.createdAt,
|
||||||
|
updatedAt: p.updatedAt,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,63 @@
|
|||||||
|
import '../../core/exceptions.dart';
|
||||||
import '../api/tasks_api.dart';
|
import '../api/tasks_api.dart';
|
||||||
|
import '../local/database.dart';
|
||||||
import '../models/task.dart';
|
import '../models/task.dart';
|
||||||
|
|
||||||
|
/// Tasks repository with read-through caching (Phase 1+2) and offline-write
|
||||||
|
/// queueing (Phase 3). See `notes_repository.dart` for the full pattern.
|
||||||
class TasksRepository {
|
class TasksRepository {
|
||||||
final TasksApi _api;
|
final TasksApi _api;
|
||||||
const TasksRepository(this._api);
|
final FabledDatabase _db;
|
||||||
|
|
||||||
Future<List<Task>> getAll() => _api.getAll();
|
const TasksRepository(this._api, this._db);
|
||||||
Future<Task> getOne(int id) => _api.getOne(id);
|
|
||||||
|
Future<List<Task>> getAll() async {
|
||||||
|
try {
|
||||||
|
final tasks = await _api.getAll();
|
||||||
|
await _db.replaceAllTasks(tasks);
|
||||||
|
return tasks;
|
||||||
|
} on NetworkException {
|
||||||
|
final cached = await _db.getAllTasks();
|
||||||
|
if (cached.isEmpty) rethrow;
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Task> getOne(int id) async {
|
||||||
|
try {
|
||||||
|
final task = await _api.getOne(id);
|
||||||
|
await _db.upsertTask(task);
|
||||||
|
return task;
|
||||||
|
} on NetworkException {
|
||||||
|
final cached = await _db.getTask(id);
|
||||||
|
if (cached == null) rethrow;
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<Task>> getByProject(int projectId) async {
|
||||||
|
try {
|
||||||
|
final tasks = await _api.getByProject(projectId);
|
||||||
|
for (final t in tasks) {
|
||||||
|
await _db.upsertTask(t);
|
||||||
|
}
|
||||||
|
return tasks;
|
||||||
|
} on NetworkException {
|
||||||
|
return _db.getTasksByProject(projectId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<Task>> getSubTasks(int parentId) async {
|
||||||
|
try {
|
||||||
|
final tasks = await _api.getSubTasks(parentId);
|
||||||
|
for (final t in tasks) {
|
||||||
|
await _db.upsertTask(t);
|
||||||
|
}
|
||||||
|
return tasks;
|
||||||
|
} on NetworkException {
|
||||||
|
return _db.getSubTasks(parentId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<Task> create({
|
Future<Task> create({
|
||||||
required String title,
|
required String title,
|
||||||
@@ -16,8 +67,9 @@ class TasksRepository {
|
|||||||
DateTime? dueDate,
|
DateTime? dueDate,
|
||||||
int? projectId,
|
int? projectId,
|
||||||
int? parentId,
|
int? parentId,
|
||||||
}) =>
|
}) async {
|
||||||
_api.create(
|
try {
|
||||||
|
final task = await _api.create(
|
||||||
title: title,
|
title: title,
|
||||||
description: description,
|
description: description,
|
||||||
status: status,
|
status: status,
|
||||||
@@ -26,12 +78,119 @@ class TasksRepository {
|
|||||||
projectId: projectId,
|
projectId: projectId,
|
||||||
parentId: parentId,
|
parentId: parentId,
|
||||||
);
|
);
|
||||||
|
await _db.upsertTask(task);
|
||||||
|
return task;
|
||||||
|
} on NetworkException {
|
||||||
|
final tempId = await _db.nextTempId();
|
||||||
|
final now = DateTime.now();
|
||||||
|
final optimistic = Task(
|
||||||
|
id: tempId,
|
||||||
|
title: title,
|
||||||
|
description: description,
|
||||||
|
status: status,
|
||||||
|
priority: priority,
|
||||||
|
dueDate: dueDate,
|
||||||
|
projectId: projectId,
|
||||||
|
parentId: parentId,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
);
|
||||||
|
await _db.upsertTask(optimistic);
|
||||||
|
await _db.enqueuePending(
|
||||||
|
domain: kSyncDomainTasks,
|
||||||
|
verb: kWriteVerbCreate,
|
||||||
|
tempId: tempId,
|
||||||
|
payload: {
|
||||||
|
'title': title,
|
||||||
|
'description': description,
|
||||||
|
'status': status.value,
|
||||||
|
'priority': priority.value,
|
||||||
|
'due_date': dueDate?.toIso8601String(),
|
||||||
|
'project_id': projectId,
|
||||||
|
'parent_id': parentId,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return optimistic;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<List<Task>> getByProject(int projectId) => _api.getByProject(projectId);
|
Future<Task> update(int id, Map<String, dynamic> fields) async {
|
||||||
Future<List<Task>> getSubTasks(int parentId) => _api.getSubTasks(parentId);
|
try {
|
||||||
|
final updated = await _api.update(id, fields);
|
||||||
|
await _db.upsertTask(updated);
|
||||||
|
return updated;
|
||||||
|
} on NetworkException {
|
||||||
|
final cached = await _db.getTask(id);
|
||||||
|
if (cached == null) rethrow;
|
||||||
|
final optimistic = _applyTaskFields(cached, fields);
|
||||||
|
await _db.upsertTask(optimistic);
|
||||||
|
if (id < 0) {
|
||||||
|
final queued = await _db.findQueuedCreate(kSyncDomainTasks, id);
|
||||||
|
if (queued != null) {
|
||||||
|
await _db.updatePendingPayload(queued.id, fields);
|
||||||
|
return optimistic;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await _db.enqueuePending(
|
||||||
|
domain: kSyncDomainTasks,
|
||||||
|
verb: kWriteVerbUpdate,
|
||||||
|
targetId: id,
|
||||||
|
payload: fields,
|
||||||
|
baselineUpdatedAt: cached.updatedAt,
|
||||||
|
);
|
||||||
|
return optimistic;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<Task> update(int id, Map<String, dynamic> fields) =>
|
Future<void> delete(int id) async {
|
||||||
_api.update(id, fields);
|
try {
|
||||||
|
await _api.delete(id);
|
||||||
Future<void> delete(int id) => _api.delete(id);
|
await _db.deleteTask(id);
|
||||||
|
} on NetworkException {
|
||||||
|
if (id < 0) {
|
||||||
|
final queued = await _db.findQueuedCreate(kSyncDomainTasks, id);
|
||||||
|
if (queued != null) await _db.deletePending(queued.id);
|
||||||
|
await _db.deleteTask(id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await _db.deleteTask(id);
|
||||||
|
await _db.enqueuePending(
|
||||||
|
domain: kSyncDomainTasks,
|
||||||
|
verb: kWriteVerbDelete,
|
||||||
|
targetId: id,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Applies a partial-fields map (the same shape sent to the PUT endpoint)
|
||||||
|
/// to a cached Task so the UI can show the edit immediately.
|
||||||
|
Task _applyTaskFields(Task t, Map<String, dynamic> f) {
|
||||||
|
return Task(
|
||||||
|
id: t.id,
|
||||||
|
title: f['title'] as String? ?? t.title,
|
||||||
|
description:
|
||||||
|
f.containsKey('body') ? f['body'] as String? : t.description,
|
||||||
|
status: f.containsKey('status')
|
||||||
|
? TaskStatusExtension.fromString(f['status'] as String?)
|
||||||
|
: t.status,
|
||||||
|
priority: f.containsKey('priority')
|
||||||
|
? TaskPriorityExtension.fromString(f['priority'] as String?)
|
||||||
|
: t.priority,
|
||||||
|
dueDate: f.containsKey('due_date')
|
||||||
|
? (f['due_date'] is String
|
||||||
|
? DateTime.tryParse(f['due_date'] as String)
|
||||||
|
: null)
|
||||||
|
: t.dueDate,
|
||||||
|
projectId: f.containsKey('project_id')
|
||||||
|
? f['project_id'] as int?
|
||||||
|
: t.projectId,
|
||||||
|
milestoneId: f.containsKey('milestone_id')
|
||||||
|
? f['milestone_id'] as int?
|
||||||
|
: t.milestoneId,
|
||||||
|
parentId:
|
||||||
|
f.containsKey('parent_id') ? f['parent_id'] as int? : t.parentId,
|
||||||
|
createdAt: t.createdAt,
|
||||||
|
updatedAt: t.updatedAt,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,385 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import '../../core/exceptions.dart';
|
||||||
|
import '../api/events_api.dart';
|
||||||
|
import '../api/milestones_api.dart';
|
||||||
|
import '../api/notes_api.dart';
|
||||||
|
import '../api/projects_api.dart';
|
||||||
|
import '../api/tasks_api.dart';
|
||||||
|
import '../local/database.dart';
|
||||||
|
import '../models/note.dart';
|
||||||
|
import '../models/project.dart';
|
||||||
|
import '../models/task.dart';
|
||||||
|
|
||||||
|
/// Reasons a queued write was dropped during replay. Surfaced to the UI as
|
||||||
|
/// a one-time SnackBar so the user knows their offline edit didn't land.
|
||||||
|
enum QueueFailureReason { overwritten, missing, rejected }
|
||||||
|
|
||||||
|
class QueueFailure {
|
||||||
|
final QueueFailureReason reason;
|
||||||
|
final String domain;
|
||||||
|
final String? title;
|
||||||
|
final String? detail;
|
||||||
|
const QueueFailure({
|
||||||
|
required this.reason,
|
||||||
|
required this.domain,
|
||||||
|
this.title,
|
||||||
|
this.detail,
|
||||||
|
});
|
||||||
|
|
||||||
|
String get message {
|
||||||
|
final label = title?.isNotEmpty == true ? '"$title"' : 'an offline edit';
|
||||||
|
return switch (reason) {
|
||||||
|
QueueFailureReason.overwritten =>
|
||||||
|
'$label was overwritten by a newer change on the server.',
|
||||||
|
QueueFailureReason.missing =>
|
||||||
|
'$label was deleted on the server before your offline edit could be saved.',
|
||||||
|
QueueFailureReason.rejected =>
|
||||||
|
'Failed to save $label: ${detail ?? 'rejected by server.'}',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drains the offline write queue (Phase 3). Owns the network → server
|
||||||
|
/// dispatch logic for every queued op; repos write to `pending_writes`,
|
||||||
|
/// this service replays them.
|
||||||
|
///
|
||||||
|
/// Replay semantics:
|
||||||
|
/// - oldest-first, single in-flight `replay()` call
|
||||||
|
/// - on `NetworkException` (or 5xx): leave op in place, stop the loop
|
||||||
|
/// - on conflict (server `updated_at` newer than op baseline): drop +
|
||||||
|
/// surface as `overwritten`
|
||||||
|
/// - on 404: for delete → silent success; for update → drop + `missing`
|
||||||
|
/// - on other 4xx: drop + `rejected`
|
||||||
|
class WriteQueue {
|
||||||
|
final FabledDatabase _db;
|
||||||
|
final NotesApi _notesApi;
|
||||||
|
final TasksApi _tasksApi;
|
||||||
|
final ProjectsApi _projectsApi;
|
||||||
|
final MilestonesApi _milestonesApi;
|
||||||
|
final EventsApi _eventsApi;
|
||||||
|
|
||||||
|
final StreamController<QueueFailure> _failures =
|
||||||
|
StreamController<QueueFailure>.broadcast();
|
||||||
|
bool _replaying = false;
|
||||||
|
|
||||||
|
WriteQueue({
|
||||||
|
required FabledDatabase db,
|
||||||
|
required NotesApi notesApi,
|
||||||
|
required TasksApi tasksApi,
|
||||||
|
required ProjectsApi projectsApi,
|
||||||
|
required MilestonesApi milestonesApi,
|
||||||
|
required EventsApi eventsApi,
|
||||||
|
}) : _db = db,
|
||||||
|
_notesApi = notesApi,
|
||||||
|
_tasksApi = tasksApi,
|
||||||
|
_projectsApi = projectsApi,
|
||||||
|
_milestonesApi = milestonesApi,
|
||||||
|
_eventsApi = eventsApi;
|
||||||
|
|
||||||
|
Stream<QueueFailure> get failures => _failures.stream;
|
||||||
|
|
||||||
|
void dispose() => _failures.close();
|
||||||
|
|
||||||
|
/// Drain the queue. Reentrant calls are no-ops while a replay is in
|
||||||
|
/// flight — the in-flight one will see new entries on its next iteration.
|
||||||
|
Future<void> replay() async {
|
||||||
|
if (_replaying) return;
|
||||||
|
_replaying = true;
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
|
final ops = await _db.listPending();
|
||||||
|
if (ops.isEmpty) break;
|
||||||
|
final op = ops.first;
|
||||||
|
final keepGoing = await _replayOne(op);
|
||||||
|
if (!keepGoing) break;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
_replaying = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> _replayOne(PendingWrite op) async {
|
||||||
|
final payload = jsonDecode(op.payloadJson) as Map<String, dynamic>;
|
||||||
|
await _db.incrementPendingTries(op.id);
|
||||||
|
try {
|
||||||
|
switch (op.domain) {
|
||||||
|
case kSyncDomainNotes:
|
||||||
|
await _replayNote(op, payload);
|
||||||
|
case kSyncDomainTasks:
|
||||||
|
await _replayTask(op, payload);
|
||||||
|
case kSyncDomainProjects:
|
||||||
|
await _replayProject(op, payload);
|
||||||
|
case kSyncDomainMilestones:
|
||||||
|
await _replayMilestone(op, payload);
|
||||||
|
case kSyncDomainEvents:
|
||||||
|
await _replayEvent(op, payload);
|
||||||
|
default:
|
||||||
|
// Unknown domain — drop so we don't loop forever.
|
||||||
|
await _db.deletePending(op.id);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
await _db.deletePending(op.id);
|
||||||
|
return true;
|
||||||
|
} on NetworkException catch (e) {
|
||||||
|
// Still offline — leave op for next replay trigger.
|
||||||
|
await _db.markPendingFailed(op.id, e.message);
|
||||||
|
return false;
|
||||||
|
} on ServerException catch (e) {
|
||||||
|
if (e.statusCode >= 500) {
|
||||||
|
// Likely transient — keep + stop the loop, retry next time.
|
||||||
|
await _db.markPendingFailed(op.id, e.message);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// 4xx — non-retryable. Drop + surface.
|
||||||
|
await _db.deletePending(op.id);
|
||||||
|
_failures.add(QueueFailure(
|
||||||
|
reason: QueueFailureReason.rejected,
|
||||||
|
domain: op.domain,
|
||||||
|
title: payload['title'] as String?,
|
||||||
|
detail: e.message,
|
||||||
|
));
|
||||||
|
return true;
|
||||||
|
} on NotFoundException {
|
||||||
|
// Target gone server-side. Update → tell user; delete → silent success.
|
||||||
|
await _db.deletePending(op.id);
|
||||||
|
if (op.verb != kWriteVerbDelete) {
|
||||||
|
_failures.add(QueueFailure(
|
||||||
|
reason: QueueFailureReason.missing,
|
||||||
|
domain: op.domain,
|
||||||
|
title: payload['title'] as String?,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
// Clean the cache so the optimistic row doesn't linger.
|
||||||
|
await _evictCache(op.domain, op.targetId ?? op.tempId);
|
||||||
|
return true;
|
||||||
|
} on _OverwrittenSignal catch (s) {
|
||||||
|
await _db.deletePending(op.id);
|
||||||
|
_failures.add(QueueFailure(
|
||||||
|
reason: QueueFailureReason.overwritten,
|
||||||
|
domain: op.domain,
|
||||||
|
title: s.title,
|
||||||
|
));
|
||||||
|
return true;
|
||||||
|
} on AppException catch (e) {
|
||||||
|
// Auth or other — surface and drop so the queue can drain.
|
||||||
|
await _db.deletePending(op.id);
|
||||||
|
_failures.add(QueueFailure(
|
||||||
|
reason: QueueFailureReason.rejected,
|
||||||
|
domain: op.domain,
|
||||||
|
title: payload['title'] as String?,
|
||||||
|
detail: e.message,
|
||||||
|
));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _evictCache(String domain, int? id) async {
|
||||||
|
if (id == null) return;
|
||||||
|
switch (domain) {
|
||||||
|
case kSyncDomainNotes:
|
||||||
|
await _db.deleteNote(id);
|
||||||
|
case kSyncDomainTasks:
|
||||||
|
await _db.deleteTask(id);
|
||||||
|
case kSyncDomainProjects:
|
||||||
|
await _db.deleteProject(id);
|
||||||
|
case kSyncDomainMilestones:
|
||||||
|
await _db.deleteMilestone(id);
|
||||||
|
case kSyncDomainEvents:
|
||||||
|
await _db.deleteEvent(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Notes ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
Future<void> _replayNote(PendingWrite op, Map<String, dynamic> p) async {
|
||||||
|
switch (op.verb) {
|
||||||
|
case kWriteVerbCreate:
|
||||||
|
final note = await _notesApi.create(
|
||||||
|
p['title'] as String? ?? '',
|
||||||
|
p['body'] as String? ?? '',
|
||||||
|
tags: _stringList(p['tags']),
|
||||||
|
projectId: p['project_id'] as int?,
|
||||||
|
noteType: p['note_type'] as String? ?? 'note',
|
||||||
|
);
|
||||||
|
if (op.tempId != null) await _db.deleteNote(op.tempId!);
|
||||||
|
await _db.upsertNote(note);
|
||||||
|
case kWriteVerbUpdate:
|
||||||
|
await _conflictGuard<Note>(
|
||||||
|
baseline: op.baselineUpdatedAt,
|
||||||
|
fetch: () => _notesApi.getOne(op.targetId!),
|
||||||
|
updatedAt: (n) => n.updatedAt,
|
||||||
|
title: (n) => n.title,
|
||||||
|
syncCache: (n) => _db.upsertNote(n),
|
||||||
|
);
|
||||||
|
final note = await _notesApi.update(
|
||||||
|
op.targetId!,
|
||||||
|
p['title'] as String? ?? '',
|
||||||
|
p['body'] as String? ?? '',
|
||||||
|
tags: _stringList(p['tags']),
|
||||||
|
projectId: p['project_id'] as int?,
|
||||||
|
clearProject: p['clear_project'] as bool? ?? false,
|
||||||
|
noteType: p['note_type'] as String? ?? 'note',
|
||||||
|
);
|
||||||
|
await _db.upsertNote(note);
|
||||||
|
case kWriteVerbDelete:
|
||||||
|
try {
|
||||||
|
await _notesApi.delete(op.targetId!);
|
||||||
|
} on NotFoundException {/* already gone */}
|
||||||
|
await _db.deleteNote(op.targetId!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tasks ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
Future<void> _replayTask(PendingWrite op, Map<String, dynamic> p) async {
|
||||||
|
switch (op.verb) {
|
||||||
|
case kWriteVerbCreate:
|
||||||
|
final task = await _tasksApi.create(
|
||||||
|
title: p['title'] as String? ?? '',
|
||||||
|
description: p['description'] as String?,
|
||||||
|
status: TaskStatusExtension.fromString(p['status'] as String?),
|
||||||
|
priority: TaskPriorityExtension.fromString(p['priority'] as String?),
|
||||||
|
dueDate: _parseDate(p['due_date']),
|
||||||
|
projectId: p['project_id'] as int?,
|
||||||
|
parentId: p['parent_id'] as int?,
|
||||||
|
);
|
||||||
|
if (op.tempId != null) await _db.deleteTask(op.tempId!);
|
||||||
|
await _db.upsertTask(task);
|
||||||
|
case kWriteVerbUpdate:
|
||||||
|
await _conflictGuard<Task>(
|
||||||
|
baseline: op.baselineUpdatedAt,
|
||||||
|
fetch: () => _tasksApi.getOne(op.targetId!),
|
||||||
|
updatedAt: (t) => t.updatedAt,
|
||||||
|
title: (t) => t.title,
|
||||||
|
syncCache: (t) => _db.upsertTask(t),
|
||||||
|
);
|
||||||
|
final task = await _tasksApi.update(op.targetId!, p);
|
||||||
|
await _db.upsertTask(task);
|
||||||
|
case kWriteVerbDelete:
|
||||||
|
try {
|
||||||
|
await _tasksApi.delete(op.targetId!);
|
||||||
|
} on NotFoundException {/* already gone */}
|
||||||
|
await _db.deleteTask(op.targetId!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Projects ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
Future<void> _replayProject(PendingWrite op, Map<String, dynamic> p) async {
|
||||||
|
switch (op.verb) {
|
||||||
|
case kWriteVerbCreate:
|
||||||
|
final project = await _projectsApi.create(
|
||||||
|
title: p['title'] as String? ?? '',
|
||||||
|
description: p['description'] as String?,
|
||||||
|
goal: p['goal'] as String?,
|
||||||
|
color: p['color'] as String?,
|
||||||
|
status: p['status'] as String? ?? 'active',
|
||||||
|
);
|
||||||
|
if (op.tempId != null) await _db.deleteProject(op.tempId!);
|
||||||
|
await _db.upsertProject(project);
|
||||||
|
case kWriteVerbUpdate:
|
||||||
|
await _conflictGuard<Project>(
|
||||||
|
baseline: op.baselineUpdatedAt,
|
||||||
|
fetch: () => _projectsApi.getOne(op.targetId!),
|
||||||
|
updatedAt: (proj) => proj.updatedAt,
|
||||||
|
title: (proj) => proj.title,
|
||||||
|
syncCache: (proj) => _db.upsertProject(proj),
|
||||||
|
);
|
||||||
|
final project = await _projectsApi.update(op.targetId!, p);
|
||||||
|
await _db.upsertProject(project);
|
||||||
|
case kWriteVerbDelete:
|
||||||
|
try {
|
||||||
|
await _projectsApi.delete(op.targetId!);
|
||||||
|
} on NotFoundException {/* already gone */}
|
||||||
|
await _db.deleteProject(op.targetId!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Milestones ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
Future<void> _replayMilestone(
|
||||||
|
PendingWrite op, Map<String, dynamic> p) async {
|
||||||
|
final projectId = p['project_id'] as int;
|
||||||
|
switch (op.verb) {
|
||||||
|
case kWriteVerbCreate:
|
||||||
|
final milestone = await _milestonesApi.create(
|
||||||
|
projectId,
|
||||||
|
title: p['title'] as String? ?? '',
|
||||||
|
description: p['description'] as String?,
|
||||||
|
orderIndex: p['order_index'] as int? ?? 0,
|
||||||
|
);
|
||||||
|
if (op.tempId != null) await _db.deleteMilestone(op.tempId!);
|
||||||
|
await _db.upsertMilestone(milestone);
|
||||||
|
case kWriteVerbUpdate:
|
||||||
|
// Milestones API has no getOne — skip the conflict pre-check and
|
||||||
|
// let the PUT apply unconditionally. If two clients fight over the
|
||||||
|
// same milestone, last-writer-wins is acceptable here.
|
||||||
|
final milestone = await _milestonesApi.update(
|
||||||
|
projectId,
|
||||||
|
op.targetId!,
|
||||||
|
Map<String, dynamic>.from(p)..remove('project_id'),
|
||||||
|
);
|
||||||
|
await _db.upsertMilestone(milestone);
|
||||||
|
case kWriteVerbDelete:
|
||||||
|
try {
|
||||||
|
await _milestonesApi.delete(projectId, op.targetId!);
|
||||||
|
} on NotFoundException {/* already gone */}
|
||||||
|
await _db.deleteMilestone(op.targetId!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Events ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
Future<void> _replayEvent(PendingWrite op, Map<String, dynamic> p) async {
|
||||||
|
switch (op.verb) {
|
||||||
|
case kWriteVerbCreate:
|
||||||
|
final event = await _eventsApi.createEvent(p);
|
||||||
|
if (op.tempId != null) await _db.deleteEvent(op.tempId!);
|
||||||
|
await _db.upsertEvent(event);
|
||||||
|
case kWriteVerbUpdate:
|
||||||
|
// Events API also has no getOne — skip the pre-check; last-writer-wins.
|
||||||
|
final event = await _eventsApi.updateEvent(op.targetId!, p);
|
||||||
|
await _db.upsertEvent(event);
|
||||||
|
case kWriteVerbDelete:
|
||||||
|
try {
|
||||||
|
await _eventsApi.deleteEvent(op.targetId!);
|
||||||
|
} on NotFoundException {/* already gone */}
|
||||||
|
await _db.deleteEvent(op.targetId!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Server-wins conflict guard. Fetch the target, compare its `updated_at`
|
||||||
|
/// against the baseline captured when the user made the offline edit;
|
||||||
|
/// if the server is newer, sync cache and signal the caller to drop.
|
||||||
|
Future<void> _conflictGuard<T>({
|
||||||
|
required DateTime? baseline,
|
||||||
|
required Future<T> Function() fetch,
|
||||||
|
required DateTime Function(T) updatedAt,
|
||||||
|
required String Function(T) title,
|
||||||
|
required Future<void> Function(T) syncCache,
|
||||||
|
}) async {
|
||||||
|
if (baseline == null) return;
|
||||||
|
final server = await fetch();
|
||||||
|
if (updatedAt(server).isAfter(baseline)) {
|
||||||
|
await syncCache(server);
|
||||||
|
throw _OverwrittenSignal(title(server));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
List<String> _stringList(dynamic raw) =>
|
||||||
|
raw is List ? raw.map((e) => e.toString()).toList() : const [];
|
||||||
|
|
||||||
|
DateTime? _parseDate(dynamic raw) {
|
||||||
|
if (raw is String && raw.isNotEmpty) return DateTime.tryParse(raw);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _OverwrittenSignal implements Exception {
|
||||||
|
final String title;
|
||||||
|
const _OverwrittenSignal(this.title);
|
||||||
|
}
|
||||||
@@ -4,26 +4,28 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
|
|
||||||
import '../data/api/api_client.dart';
|
import '../data/api/api_client.dart';
|
||||||
import '../data/api/auth_api.dart';
|
import '../data/api/auth_api.dart';
|
||||||
import '../data/api/briefing_api.dart';
|
|
||||||
import '../data/api/chat_api.dart';
|
import '../data/api/chat_api.dart';
|
||||||
|
import '../data/api/journal_api.dart';
|
||||||
import '../data/api/knowledge_api.dart';
|
import '../data/api/knowledge_api.dart';
|
||||||
import '../data/api/voice_api.dart';
|
import '../data/api/voice_api.dart';
|
||||||
import '../data/api/milestones_api.dart';
|
import '../data/api/milestones_api.dart';
|
||||||
import '../data/api/events_api.dart';
|
import '../data/api/events_api.dart';
|
||||||
import '../data/api/news_api.dart';
|
|
||||||
import '../data/api/notes_api.dart';
|
import '../data/api/notes_api.dart';
|
||||||
import '../data/api/projects_api.dart';
|
import '../data/api/projects_api.dart';
|
||||||
import '../data/api/quick_capture_api.dart';
|
import '../data/api/quick_capture_api.dart';
|
||||||
import '../data/api/settings_api.dart';
|
import '../data/api/settings_api.dart';
|
||||||
import '../data/api/tasks_api.dart';
|
import '../data/api/tasks_api.dart';
|
||||||
|
import '../data/local/database.dart';
|
||||||
import '../data/repositories/auth_repository.dart';
|
import '../data/repositories/auth_repository.dart';
|
||||||
import '../data/repositories/chat_repository.dart';
|
import '../data/repositories/chat_repository.dart';
|
||||||
|
import '../data/repositories/events_repository.dart';
|
||||||
import '../data/repositories/knowledge_repository.dart';
|
import '../data/repositories/knowledge_repository.dart';
|
||||||
import '../data/repositories/voice_repository.dart';
|
import '../data/repositories/voice_repository.dart';
|
||||||
import '../data/repositories/milestones_repository.dart';
|
import '../data/repositories/milestones_repository.dart';
|
||||||
import '../data/repositories/notes_repository.dart';
|
import '../data/repositories/notes_repository.dart';
|
||||||
import '../data/repositories/projects_repository.dart';
|
import '../data/repositories/projects_repository.dart';
|
||||||
import '../data/repositories/tasks_repository.dart';
|
import '../data/repositories/tasks_repository.dart';
|
||||||
|
import '../data/repositories/write_queue.dart';
|
||||||
import 'settings_provider.dart';
|
import 'settings_provider.dart';
|
||||||
|
|
||||||
final cookieJarProvider = Provider<PersistCookieJar>((ref) {
|
final cookieJarProvider = Provider<PersistCookieJar>((ref) {
|
||||||
@@ -61,24 +63,45 @@ final projectsApiProvider = Provider<ProjectsApi>((ref) {
|
|||||||
return ProjectsApi(ref.watch(dioProvider));
|
return ProjectsApi(ref.watch(dioProvider));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// Local SQLite cache used by repositories for read-through caching and
|
||||||
|
/// offline fallback. Backed by Drift; lives for the lifetime of the
|
||||||
|
/// ProviderScope and is closed automatically when the scope disposes.
|
||||||
|
final fabledDatabaseProvider = Provider<FabledDatabase>((ref) {
|
||||||
|
final db = FabledDatabase();
|
||||||
|
ref.onDispose(db.close);
|
||||||
|
return db;
|
||||||
|
});
|
||||||
|
|
||||||
final authRepositoryProvider = Provider<AuthRepository>((ref) {
|
final authRepositoryProvider = Provider<AuthRepository>((ref) {
|
||||||
return AuthRepository(ref.watch(authApiProvider));
|
return AuthRepository(ref.watch(authApiProvider));
|
||||||
});
|
});
|
||||||
|
|
||||||
final notesRepositoryProvider = Provider<NotesRepository>((ref) {
|
final notesRepositoryProvider = Provider<NotesRepository>((ref) {
|
||||||
return NotesRepository(ref.watch(notesApiProvider));
|
return NotesRepository(
|
||||||
|
ref.watch(notesApiProvider),
|
||||||
|
ref.watch(fabledDatabaseProvider),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
final tasksRepositoryProvider = Provider<TasksRepository>((ref) {
|
final tasksRepositoryProvider = Provider<TasksRepository>((ref) {
|
||||||
return TasksRepository(ref.watch(tasksApiProvider));
|
return TasksRepository(
|
||||||
|
ref.watch(tasksApiProvider),
|
||||||
|
ref.watch(fabledDatabaseProvider),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
final chatRepositoryProvider = Provider<ChatRepository>((ref) {
|
final chatRepositoryProvider = Provider<ChatRepository>((ref) {
|
||||||
return ChatRepository(ref.watch(chatApiProvider));
|
return ChatRepository(
|
||||||
|
ref.watch(chatApiProvider),
|
||||||
|
ref.watch(fabledDatabaseProvider),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
final projectsRepositoryProvider = Provider<ProjectsRepository>((ref) {
|
final projectsRepositoryProvider = Provider<ProjectsRepository>((ref) {
|
||||||
return ProjectsRepository(ref.watch(projectsApiProvider));
|
return ProjectsRepository(
|
||||||
|
ref.watch(projectsApiProvider),
|
||||||
|
ref.watch(fabledDatabaseProvider),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
final milestonesApiProvider = Provider<MilestonesApi>((ref) {
|
final milestonesApiProvider = Provider<MilestonesApi>((ref) {
|
||||||
@@ -86,7 +109,10 @@ final milestonesApiProvider = Provider<MilestonesApi>((ref) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
final milestonesRepositoryProvider = Provider<MilestonesRepository>((ref) {
|
final milestonesRepositoryProvider = Provider<MilestonesRepository>((ref) {
|
||||||
return MilestonesRepository(ref.watch(milestonesApiProvider));
|
return MilestonesRepository(
|
||||||
|
ref.watch(milestonesApiProvider),
|
||||||
|
ref.watch(fabledDatabaseProvider),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
final knowledgeApiProvider = Provider<KnowledgeApi>((ref) {
|
final knowledgeApiProvider = Provider<KnowledgeApi>((ref) {
|
||||||
@@ -97,8 +123,8 @@ final knowledgeRepositoryProvider = Provider<KnowledgeRepository>((ref) {
|
|||||||
return KnowledgeRepository(ref.watch(knowledgeApiProvider));
|
return KnowledgeRepository(ref.watch(knowledgeApiProvider));
|
||||||
});
|
});
|
||||||
|
|
||||||
final briefingApiProvider = Provider<BriefingApi>((ref) {
|
final journalApiProvider = Provider<JournalApi>((ref) {
|
||||||
return BriefingApi(ref.watch(dioProvider));
|
return JournalApi(ref.watch(dioProvider));
|
||||||
});
|
});
|
||||||
|
|
||||||
final settingsApiProvider = Provider<SettingsApi>((ref) {
|
final settingsApiProvider = Provider<SettingsApi>((ref) {
|
||||||
@@ -113,10 +139,54 @@ final voiceRepositoryProvider = Provider<VoiceRepository>((ref) {
|
|||||||
return VoiceRepository(ref.watch(voiceApiProvider));
|
return VoiceRepository(ref.watch(voiceApiProvider));
|
||||||
});
|
});
|
||||||
|
|
||||||
final newsApiProvider = Provider<NewsApi>((ref) {
|
|
||||||
return NewsApi(ref.watch(dioProvider));
|
|
||||||
});
|
|
||||||
|
|
||||||
final eventsApiProvider = Provider<EventsApi>((ref) {
|
final eventsApiProvider = Provider<EventsApi>((ref) {
|
||||||
return EventsApi(ref.watch(dioProvider));
|
return EventsApi(ref.watch(dioProvider));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
final eventsRepositoryProvider = Provider<EventsRepository>((ref) {
|
||||||
|
return EventsRepository(
|
||||||
|
ref.watch(eventsApiProvider),
|
||||||
|
ref.watch(fabledDatabaseProvider),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Phase 3 — drains the offline write queue. Listen on
|
||||||
|
/// `writeQueueFailuresProvider` to surface dropped ops to the user;
|
||||||
|
/// `writeQueueDepthProvider` powers the OfflineBanner's "Retry (N)" affordance.
|
||||||
|
final writeQueueProvider = Provider<WriteQueue>((ref) {
|
||||||
|
final queue = WriteQueue(
|
||||||
|
db: ref.watch(fabledDatabaseProvider),
|
||||||
|
notesApi: ref.watch(notesApiProvider),
|
||||||
|
tasksApi: ref.watch(tasksApiProvider),
|
||||||
|
projectsApi: ref.watch(projectsApiProvider),
|
||||||
|
milestonesApi: ref.watch(milestonesApiProvider),
|
||||||
|
eventsApi: ref.watch(eventsApiProvider),
|
||||||
|
);
|
||||||
|
ref.onDispose(queue.dispose);
|
||||||
|
return queue;
|
||||||
|
});
|
||||||
|
|
||||||
|
final writeQueueDepthProvider = StreamProvider<int>((ref) {
|
||||||
|
return ref.watch(fabledDatabaseProvider).watchQueueDepth();
|
||||||
|
});
|
||||||
|
|
||||||
|
final writeQueueFailuresProvider = StreamProvider<QueueFailure>((ref) {
|
||||||
|
return ref.watch(writeQueueProvider).failures;
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Per-domain set of ids with a queued write (target_id ∪ temp_id). Phase 4
|
||||||
|
/// — used by `PendingSyncBadge` in list views to mark rows that are still
|
||||||
|
/// in flight.
|
||||||
|
final pendingNoteIdsProvider = StreamProvider<Set<int>>((ref) {
|
||||||
|
return ref.watch(fabledDatabaseProvider).watchPendingIds(kSyncDomainNotes);
|
||||||
|
});
|
||||||
|
|
||||||
|
final pendingTaskIdsProvider = StreamProvider<Set<int>>((ref) {
|
||||||
|
return ref.watch(fabledDatabaseProvider).watchPendingIds(kSyncDomainTasks);
|
||||||
|
});
|
||||||
|
|
||||||
|
final pendingProjectIdsProvider = StreamProvider<Set<int>>((ref) {
|
||||||
|
return ref
|
||||||
|
.watch(fabledDatabaseProvider)
|
||||||
|
.watchPendingIds(kSyncDomainProjects);
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../core/exceptions.dart';
|
||||||
import 'api_client_provider.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);
|
final authProvider = NotifierProvider<AuthNotifier, AuthStatus>(AuthNotifier.new);
|
||||||
|
|
||||||
@@ -14,7 +16,12 @@ class AuthNotifier extends Notifier<AuthStatus> {
|
|||||||
try {
|
try {
|
||||||
final repo = ref.read(authRepositoryProvider);
|
final repo = ref.read(authRepositoryProvider);
|
||||||
final ok = await repo.verify();
|
final ok = await repo.verify();
|
||||||
|
if (ok) {
|
||||||
|
await ref.read(hasEverLoggedInProvider.notifier).markLoggedIn();
|
||||||
|
}
|
||||||
state = ok ? AuthStatus.authenticated : AuthStatus.unauthenticated;
|
state = ok ? AuthStatus.authenticated : AuthStatus.unauthenticated;
|
||||||
|
} on NetworkException {
|
||||||
|
state = AuthStatus.offline;
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
state = AuthStatus.unauthenticated;
|
state = AuthStatus.unauthenticated;
|
||||||
}
|
}
|
||||||
@@ -23,6 +30,7 @@ class AuthNotifier extends Notifier<AuthStatus> {
|
|||||||
Future<void> login(String username, String password) async {
|
Future<void> login(String username, String password) async {
|
||||||
final repo = ref.read(authRepositoryProvider);
|
final repo = ref.read(authRepositoryProvider);
|
||||||
await repo.login(username, password);
|
await repo.login(username, password);
|
||||||
|
await ref.read(hasEverLoggedInProvider.notifier).markLoggedIn();
|
||||||
state = AuthStatus.authenticated;
|
state = AuthStatus.authenticated;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ class CalendarNotifier extends AsyncNotifier<CalendarState> {
|
|||||||
// Fetch current month ± 1 month as the initial window.
|
// Fetch current month ± 1 month as the initial window.
|
||||||
final from = DateTime(now.year, now.month - 1, 1);
|
final from = DateTime(now.year, now.month - 1, 1);
|
||||||
final to = DateTime(now.year, now.month + 2, 0, 23, 59, 59);
|
final to = DateTime(now.year, now.month + 2, 0, 23, 59, 59);
|
||||||
final events = await ref.watch(eventsApiProvider).getEvents(from, to);
|
final events = await ref.watch(eventsRepositoryProvider).getEvents(from, to);
|
||||||
return CalendarState(
|
return CalendarState(
|
||||||
eventsByDay: _groupByDay(events),
|
eventsByDay: _groupByDay(events),
|
||||||
selectedDay: today,
|
selectedDay: today,
|
||||||
@@ -60,7 +60,7 @@ class CalendarNotifier extends AsyncNotifier<CalendarState> {
|
|||||||
Future<void> refresh() async {
|
Future<void> refresh() async {
|
||||||
final current = state.value;
|
final current = state.value;
|
||||||
if (current == null) return;
|
if (current == null) return;
|
||||||
final events = await ref.read(eventsApiProvider).getEvents(
|
final events = await ref.read(eventsRepositoryProvider).getEvents(
|
||||||
current.loadedRange.start,
|
current.loadedRange.start,
|
||||||
current.loadedRange.end,
|
current.loadedRange.end,
|
||||||
);
|
);
|
||||||
@@ -93,7 +93,7 @@ class CalendarNotifier extends AsyncNotifier<CalendarState> {
|
|||||||
try {
|
try {
|
||||||
final from = DateTime(month.year, month.month, 1);
|
final from = DateTime(month.year, month.month, 1);
|
||||||
final to = DateTime(month.year, month.month + 1, 0, 23, 59, 59);
|
final to = DateTime(month.year, month.month + 1, 0, 23, 59, 59);
|
||||||
final events = await ref.read(eventsApiProvider).getEvents(from, to);
|
final events = await ref.read(eventsRepositoryProvider).getEvents(from, to);
|
||||||
final s = state.value!;
|
final s = state.value!;
|
||||||
final merged = Map<DateTime, List<CalendarEvent>>.from(s.eventsByDay);
|
final merged = Map<DateTime, List<CalendarEvent>>.from(s.eventsByDay);
|
||||||
for (final e in events) {
|
for (final e in events) {
|
||||||
|
|||||||
@@ -3,12 +3,12 @@ import 'dart:async';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
import '../data/api/chat_api.dart';
|
import '../data/api/chat_api.dart';
|
||||||
import '../data/models/briefing_conversation.dart';
|
import '../data/models/journal_day.dart';
|
||||||
import '../data/models/message.dart';
|
import '../data/models/message.dart';
|
||||||
import 'api_client_provider.dart';
|
import 'api_client_provider.dart';
|
||||||
|
|
||||||
/// Drives the loading indicator in BriefingScreen's reply area.
|
/// Drives the loading indicator in JournalScreen's reply area.
|
||||||
final isBriefingStreamingProvider =
|
final isJournalStreamingProvider =
|
||||||
NotifierProvider<_BoolNotifier, bool>(_BoolNotifier.new);
|
NotifierProvider<_BoolNotifier, bool>(_BoolNotifier.new);
|
||||||
|
|
||||||
class _BoolNotifier extends Notifier<bool> {
|
class _BoolNotifier extends Notifier<bool> {
|
||||||
@@ -16,23 +16,22 @@ class _BoolNotifier extends Notifier<bool> {
|
|||||||
bool build() => false;
|
bool build() => false;
|
||||||
}
|
}
|
||||||
|
|
||||||
final briefingProvider =
|
final journalProvider =
|
||||||
AsyncNotifierProvider<BriefingNotifier, BriefingConversation>(
|
AsyncNotifierProvider<JournalNotifier, JournalDay>(JournalNotifier.new);
|
||||||
BriefingNotifier.new);
|
|
||||||
|
|
||||||
class BriefingNotifier extends AsyncNotifier<BriefingConversation> {
|
class JournalNotifier extends AsyncNotifier<JournalDay> {
|
||||||
@override
|
@override
|
||||||
Future<BriefingConversation> build() async {
|
Future<JournalDay> build() async {
|
||||||
return ref.read(briefingApiProvider).getToday();
|
return ref.read(journalApiProvider).getToday();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Silently fetch the latest briefing and patch state without triggering
|
/// Silently fetch today's journal and patch state without triggering
|
||||||
/// AsyncLoading — existing content stays visible while the fetch is in flight.
|
/// AsyncLoading — existing content stays visible while the fetch is in flight.
|
||||||
Future<void> silentRefresh() async {
|
Future<void> silentRefresh() async {
|
||||||
final current = state.value;
|
final current = state.value;
|
||||||
if (current == null) return;
|
if (current == null) return;
|
||||||
try {
|
try {
|
||||||
final fresh = await ref.read(briefingApiProvider).getToday();
|
final fresh = await ref.read(journalApiProvider).getToday();
|
||||||
final curLast = current.messages.isNotEmpty ? current.messages.last : null;
|
final curLast = current.messages.isNotEmpty ? current.messages.last : null;
|
||||||
final newLast = fresh.messages.isNotEmpty ? fresh.messages.last : null;
|
final newLast = fresh.messages.isNotEmpty ? fresh.messages.last : null;
|
||||||
if (fresh.messages.length != current.messages.length ||
|
if (fresh.messages.length != current.messages.length ||
|
||||||
@@ -40,26 +39,25 @@ class BriefingNotifier extends AsyncNotifier<BriefingConversation> {
|
|||||||
state = AsyncData(fresh);
|
state = AsyncData(fresh);
|
||||||
}
|
}
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
// Network hiccup — silently ignore, keep existing content
|
// Network hiccup — silently ignore, keep existing content.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Trigger a briefing slot (e.g. "compilation") then reload.
|
/// Force-regenerate today's daily prep then reload.
|
||||||
Future<void> refresh(String slot) async {
|
Future<void> regeneratePrep() async {
|
||||||
await ref.read(briefingApiProvider).triggerSlot(slot);
|
await ref.read(journalApiProvider).triggerPrep();
|
||||||
ref.invalidateSelf();
|
ref.invalidateSelf();
|
||||||
await future;
|
await future;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Re-fetch the current briefing conversation and unfreeze a stuck
|
/// Re-fetch today's journal and unfreeze a stuck streaming state if the
|
||||||
/// streaming state if the server-side message is already complete.
|
/// server-side message is already complete.
|
||||||
///
|
///
|
||||||
/// Same role as MessagesNotifier.refresh() in chat_provider: when an SSE
|
/// Same role as MessagesNotifier.refresh() in chat_provider: when an SSE
|
||||||
/// socket dies silently (mobile network handoff, app backgrounded mid-stream,
|
/// socket dies silently the send loop never observes close and
|
||||||
/// reverse proxy dropping idle sockets) the send loop never observes close
|
/// [isJournalStreamingProvider] stays stuck true. This is the manual
|
||||||
/// and [isBriefingStreamingProvider] stays stuck true. This is the manual
|
/// recovery path hit by pull-to-refresh, the AppBar refresh button, and
|
||||||
/// recovery path hit by pull-to-refresh, the AppBar refresh button, and the
|
/// the lifecycle-resume hook.
|
||||||
/// lifecycle-resume hook.
|
|
||||||
Future<void> refreshMessages() async {
|
Future<void> refreshMessages() async {
|
||||||
final current = state.value;
|
final current = state.value;
|
||||||
if (current == null) {
|
if (current == null) {
|
||||||
@@ -67,7 +65,7 @@ class BriefingNotifier extends AsyncNotifier<BriefingConversation> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
final fresh = await ref.read(briefingApiProvider).getToday();
|
final fresh = await ref.read(journalApiProvider).getToday();
|
||||||
state = AsyncData(fresh);
|
state = AsyncData(fresh);
|
||||||
final messages = fresh.messages;
|
final messages = fresh.messages;
|
||||||
Message? lastAssistant;
|
Message? lastAssistant;
|
||||||
@@ -78,46 +76,14 @@ class BriefingNotifier extends AsyncNotifier<BriefingConversation> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (lastAssistant != null && lastAssistant.status != 'generating') {
|
if (lastAssistant != null && lastAssistant.status != 'generating') {
|
||||||
ref.read(isBriefingStreamingProvider.notifier).state = false;
|
ref.read(isJournalStreamingProvider.notifier).state = false;
|
||||||
}
|
}
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
// Network hiccup — keep existing state; user can retry.
|
// Network hiccup — keep existing state; user can retry.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Inject a news article as context and trigger generation.
|
/// Send a reply to today's journal conversation.
|
||||||
///
|
|
||||||
/// Mirrors sendReply() but calls the /discuss endpoint instead of
|
|
||||||
/// /messages so the backend injects article content before generating.
|
|
||||||
Future<void> discussArticle(int convId, int itemId) async {
|
|
||||||
final conv = state.value;
|
|
||||||
if (conv == null) return;
|
|
||||||
final chatApi = ref.read(chatApiProvider);
|
|
||||||
final briefingApi = ref.read(briefingApiProvider);
|
|
||||||
|
|
||||||
final previous = conv.messages;
|
|
||||||
final placeholder = Message(
|
|
||||||
conversationId: convId,
|
|
||||||
role: MessageRole.assistant,
|
|
||||||
content: '',
|
|
||||||
status: 'generating',
|
|
||||||
);
|
|
||||||
state = AsyncData(conv.copyWith(messages: [...previous, placeholder]));
|
|
||||||
ref.read(isBriefingStreamingProvider.notifier).state = true;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await briefingApi.discussArticle(convId, itemId);
|
|
||||||
} catch (e) {
|
|
||||||
state = AsyncData(conv.copyWith(messages: previous));
|
|
||||||
ref.read(isBriefingStreamingProvider.notifier).state = false;
|
|
||||||
rethrow;
|
|
||||||
}
|
|
||||||
|
|
||||||
final streamedContent = await _consumeStream(chatApi.streamGeneration(convId));
|
|
||||||
await _pollUntilComplete(convId, streamedContent);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Send a reply to today's briefing conversation.
|
|
||||||
///
|
///
|
||||||
/// Mirrors MessagesNotifier.sendMessage() in chat_provider with the same
|
/// Mirrors MessagesNotifier.sendMessage() in chat_provider with the same
|
||||||
/// stall-watchdog pattern:
|
/// stall-watchdog pattern:
|
||||||
@@ -126,12 +92,14 @@ class BriefingNotifier extends AsyncNotifier<BriefingConversation> {
|
|||||||
/// 3. SSE stream with per-event timeout (stall watchdog)
|
/// 3. SSE stream with per-event timeout (stall watchdog)
|
||||||
/// 4. Poll until complete
|
/// 4. Poll until complete
|
||||||
Future<void> sendReply(String content) async {
|
Future<void> sendReply(String content) async {
|
||||||
final conv = state.value;
|
final day = state.value;
|
||||||
|
if (day == null) return;
|
||||||
|
final conv = day.conversation;
|
||||||
if (conv == null) return;
|
if (conv == null) return;
|
||||||
final convId = conv.id;
|
final convId = conv.id;
|
||||||
final chatApi = ref.read(chatApiProvider);
|
final chatApi = ref.read(chatApiProvider);
|
||||||
|
|
||||||
final previous = conv.messages;
|
final previous = day.messages;
|
||||||
final userMsg = Message(
|
final userMsg = Message(
|
||||||
conversationId: convId,
|
conversationId: convId,
|
||||||
role: MessageRole.user,
|
role: MessageRole.user,
|
||||||
@@ -143,14 +111,14 @@ class BriefingNotifier extends AsyncNotifier<BriefingConversation> {
|
|||||||
content: '',
|
content: '',
|
||||||
status: 'generating',
|
status: 'generating',
|
||||||
);
|
);
|
||||||
state = AsyncData(conv.copyWith(messages: [...previous, userMsg, placeholder]));
|
state = AsyncData(day.copyWith(messages: [...previous, userMsg, placeholder]));
|
||||||
ref.read(isBriefingStreamingProvider.notifier).state = true;
|
ref.read(isJournalStreamingProvider.notifier).state = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await chatApi.sendMessage(convId, content);
|
await chatApi.sendMessage(convId, content);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
state = AsyncData(conv.copyWith(messages: previous));
|
state = AsyncData(day.copyWith(messages: previous));
|
||||||
ref.read(isBriefingStreamingProvider.notifier).state = false;
|
ref.read(isJournalStreamingProvider.notifier).state = false;
|
||||||
rethrow;
|
rethrow;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,19 +126,15 @@ class BriefingNotifier extends AsyncNotifier<BriefingConversation> {
|
|||||||
await _pollUntilComplete(convId, streamedContent);
|
await _pollUntilComplete(convId, streamedContent);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Consume an SSE stream into the current briefing conversation state.
|
/// Consume an SSE stream into the current journal day state.
|
||||||
///
|
///
|
||||||
/// Uses a StreamIterator with a per-event timeout as a stall watchdog —
|
/// Uses a StreamIterator with a per-event timeout as a stall watchdog —
|
||||||
/// same rationale as MessagesNotifier.sendMessage() in chat_provider.dart.
|
/// same rationale as MessagesNotifier.sendMessage() in chat_provider.dart.
|
||||||
/// Mobile networks occasionally drop SSE sockets silently: the TCP
|
/// Mobile networks occasionally drop SSE sockets silently. If no event
|
||||||
/// connection is half-closed, Dio never sees the close, and `await for`
|
/// arrives within the watchdog window we bail out and let polling
|
||||||
/// hangs forever with [isBriefingStreamingProvider] stuck true. If no event
|
/// reconcile state from the server.
|
||||||
/// arrives within the watchdog window we bail out and let the polling pass
|
|
||||||
/// below reconcile state from the server.
|
|
||||||
///
|
///
|
||||||
/// Returns whether any text content was actually streamed (the polling
|
/// Returns whether any text content was actually streamed.
|
||||||
/// pass uses this to decide whether it's safe to overwrite with a possibly
|
|
||||||
/// empty server-side row).
|
|
||||||
Future<bool> _consumeStream(Stream<ChatStreamEvent> stream) async {
|
Future<bool> _consumeStream(Stream<ChatStreamEvent> stream) async {
|
||||||
const stallTimeout = Duration(seconds: 45);
|
const stallTimeout = Duration(seconds: 45);
|
||||||
bool streamedContent = false;
|
bool streamedContent = false;
|
||||||
@@ -208,24 +172,25 @@ class BriefingNotifier extends AsyncNotifier<BriefingConversation> {
|
|||||||
return streamedContent;
|
return streamedContent;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Poll /api/briefing messages until the last assistant row is complete,
|
/// Poll today's journal until the last assistant row is complete. Always
|
||||||
/// same reconcile pattern as MessagesNotifier.sendMessage(). Always clears
|
/// clears [isJournalStreamingProvider] at the end so the input can't stay
|
||||||
/// [isBriefingStreamingProvider] at the end so the input can't stay locked.
|
/// locked.
|
||||||
Future<void> _pollUntilComplete(int convId, bool streamedContent) async {
|
Future<void> _pollUntilComplete(int convId, bool streamedContent) async {
|
||||||
final briefingApi = ref.read(briefingApiProvider);
|
final journalApi = ref.read(journalApiProvider);
|
||||||
try {
|
try {
|
||||||
for (var attempt = 0; attempt < 20; attempt++) {
|
for (var attempt = 0; attempt < 20; attempt++) {
|
||||||
if (attempt > 0) await Future.delayed(const Duration(seconds: 2));
|
if (attempt > 0) await Future.delayed(const Duration(seconds: 2));
|
||||||
final fresh = await briefingApi.getMessages(convId);
|
final fresh = await journalApi.getToday();
|
||||||
final done = fresh.any(
|
final freshMsgs = fresh.messages;
|
||||||
|
final done = freshMsgs.any(
|
||||||
(m) => m.role == MessageRole.assistant && m.status != 'generating',
|
(m) => m.role == MessageRole.assistant && m.status != 'generating',
|
||||||
);
|
);
|
||||||
final hasContent = fresh.any(
|
final hasContent = freshMsgs.any(
|
||||||
(m) => m.role == MessageRole.assistant && m.content.isNotEmpty,
|
(m) => m.role == MessageRole.assistant && m.content.isNotEmpty,
|
||||||
);
|
);
|
||||||
final current = state.value;
|
final current = state.value;
|
||||||
if (current != null && (!streamedContent || done || hasContent)) {
|
if (current != null && (!streamedContent || done || hasContent)) {
|
||||||
state = AsyncData(current.copyWith(messages: fresh));
|
state = AsyncData(current.copyWith(messages: freshMsgs));
|
||||||
}
|
}
|
||||||
if (done) break;
|
if (done) break;
|
||||||
}
|
}
|
||||||
@@ -241,7 +206,7 @@ class BriefingNotifier extends AsyncNotifier<BriefingConversation> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
ref.read(isBriefingStreamingProvider.notifier).state = false;
|
ref.read(isJournalStreamingProvider.notifier).state = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
import 'api_client_provider.dart';
|
||||||
|
|
||||||
const _kServerUrl = 'server_url';
|
const _kServerUrl = 'server_url';
|
||||||
const _kThemeMode = 'theme_mode';
|
const _kThemeMode = 'theme_mode';
|
||||||
const _kForgejoRepoUrl = 'forgejo_repo_url';
|
const _kForgejoRepoUrl = 'forgejo_repo_url';
|
||||||
|
const _kHasEverLoggedIn = 'has_ever_logged_in';
|
||||||
|
|
||||||
final sharedPreferencesProvider = Provider<SharedPreferences>((ref) {
|
final sharedPreferencesProvider = Provider<SharedPreferences>((ref) {
|
||||||
throw UnimplementedError('Override in ProviderScope');
|
throw UnimplementedError('Override in ProviderScope');
|
||||||
@@ -82,3 +85,47 @@ class ServerUrlNotifier extends Notifier<String?> {
|
|||||||
state = null;
|
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:dio/dio.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:open_file/open_file.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:path_provider/path_provider.dart';
|
||||||
import 'package:permission_handler/permission_handler.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 {
|
class UpdateState {
|
||||||
final UpdateStatus status;
|
final UpdateStatus status;
|
||||||
@@ -14,6 +23,7 @@ class UpdateState {
|
|||||||
final String? downloadUrl;
|
final String? downloadUrl;
|
||||||
final double downloadProgress;
|
final double downloadProgress;
|
||||||
final String? errorMessage;
|
final String? errorMessage;
|
||||||
|
final String? apkPath;
|
||||||
|
|
||||||
const UpdateState({
|
const UpdateState({
|
||||||
this.status = UpdateStatus.idle,
|
this.status = UpdateStatus.idle,
|
||||||
@@ -22,6 +32,7 @@ class UpdateState {
|
|||||||
this.downloadUrl,
|
this.downloadUrl,
|
||||||
this.downloadProgress = 0.0,
|
this.downloadProgress = 0.0,
|
||||||
this.errorMessage,
|
this.errorMessage,
|
||||||
|
this.apkPath,
|
||||||
});
|
});
|
||||||
|
|
||||||
UpdateState copyWith({
|
UpdateState copyWith({
|
||||||
@@ -31,6 +42,7 @@ class UpdateState {
|
|||||||
String? downloadUrl,
|
String? downloadUrl,
|
||||||
double? downloadProgress,
|
double? downloadProgress,
|
||||||
String? errorMessage,
|
String? errorMessage,
|
||||||
|
String? apkPath,
|
||||||
}) =>
|
}) =>
|
||||||
UpdateState(
|
UpdateState(
|
||||||
status: status ?? this.status,
|
status: status ?? this.status,
|
||||||
@@ -39,6 +51,7 @@ class UpdateState {
|
|||||||
downloadUrl: downloadUrl ?? this.downloadUrl,
|
downloadUrl: downloadUrl ?? this.downloadUrl,
|
||||||
downloadProgress: downloadProgress ?? this.downloadProgress,
|
downloadProgress: downloadProgress ?? this.downloadProgress,
|
||||||
errorMessage: errorMessage ?? this.errorMessage,
|
errorMessage: errorMessage ?? this.errorMessage,
|
||||||
|
apkPath: apkPath ?? this.apkPath,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,20 +59,15 @@ class UpdateNotifier extends Notifier<UpdateState> {
|
|||||||
@override
|
@override
|
||||||
UpdateState build() => const UpdateState();
|
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 {
|
Future<void> check(String repoUrl) async {
|
||||||
state = state.copyWith(status: UpdateStatus.checking);
|
state = state.copyWith(status: UpdateStatus.checking);
|
||||||
try {
|
try {
|
||||||
final packageInfo = await PackageInfo.fromPlatform();
|
final packageInfo = await PackageInfo.fromPlatform();
|
||||||
// Combine versionName + buildNumber to match the YY.MM.DD.N tag format.
|
|
||||||
final currentVersion =
|
final currentVersion =
|
||||||
'${packageInfo.version}.${packageInfo.buildNumber}';
|
'${packageInfo.version}.${packageInfo.buildNumber}';
|
||||||
|
|
||||||
// Parse repo URL → Forgejo API endpoint
|
|
||||||
final uri = Uri.parse(repoUrl);
|
final uri = Uri.parse(repoUrl);
|
||||||
final parts =
|
final parts = uri.pathSegments.where((s) => s.isNotEmpty).toList();
|
||||||
uri.pathSegments.where((s) => s.isNotEmpty).toList();
|
|
||||||
if (parts.length < 2) throw 'Invalid repository URL (need /owner/repo)';
|
if (parts.length < 2) throw 'Invalid repository URL (need /owner/repo)';
|
||||||
final apiUrl =
|
final apiUrl =
|
||||||
'${uri.scheme}://${uri.authority}/api/v1/repos/${parts[0]}/${parts[1]}/releases/latest';
|
'${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;
|
tagName.startsWith('v') ? tagName.substring(1) : tagName;
|
||||||
|
|
||||||
if (_isNewer(latestVersion, currentVersion)) {
|
if (_isNewer(latestVersion, currentVersion)) {
|
||||||
final assets =
|
final assets = (response.data['assets'] as List<dynamic>? ?? [])
|
||||||
(response.data['assets'] as List<dynamic>? ?? [])
|
.cast<Map<String, dynamic>>();
|
||||||
.cast<Map<String, dynamic>>();
|
|
||||||
final apk = assets.firstWhere(
|
final apk = assets.firstWhere(
|
||||||
(a) => (a['name'] as String? ?? '').endsWith('.apk'),
|
(a) => (a['name'] as String? ?? '').endsWith('.apk'),
|
||||||
orElse: () => {},
|
orElse: () => {},
|
||||||
);
|
);
|
||||||
if (apk.isNotEmpty) {
|
if (apk.isNotEmpty) {
|
||||||
|
final downloadUrl = apk['browser_download_url'] as String?;
|
||||||
state = state.copyWith(
|
state = state.copyWith(
|
||||||
status: UpdateStatus.available,
|
|
||||||
currentVersion: currentVersion,
|
currentVersion: currentVersion,
|
||||||
latestVersion: latestVersion,
|
latestVersion: latestVersion,
|
||||||
downloadUrl: apk['browser_download_url'] as String?,
|
downloadUrl: downloadUrl,
|
||||||
);
|
);
|
||||||
|
await _downloadInBackground();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -101,11 +109,52 @@ class UpdateNotifier extends Notifier<UpdateState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> downloadAndInstall() async {
|
Future<void> _downloadInBackground() async {
|
||||||
if (state.downloadUrl == null) return;
|
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;
|
final installPermission = await Permission.requestInstallPackages.status;
|
||||||
if (!installPermission.isGranted) {
|
if (!installPermission.isGranted) {
|
||||||
final result = await Permission.requestInstallPackages.request();
|
final result = await Permission.requestInstallPackages.request();
|
||||||
@@ -119,30 +168,13 @@ class UpdateNotifier extends Notifier<UpdateState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
state = state.copyWith(status: UpdateStatus.downloading, downloadProgress: 0);
|
|
||||||
|
|
||||||
try {
|
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(
|
final result = await OpenFile.open(
|
||||||
path,
|
path,
|
||||||
type: 'application/vnd.android.package-archive',
|
type: 'application/vnd.android.package-archive',
|
||||||
);
|
);
|
||||||
|
|
||||||
if (result.type == ResultType.done) {
|
if (result.type == ResultType.done) {
|
||||||
// Installer launched — reset to idle so the dialog closes naturally.
|
|
||||||
state = const UpdateState();
|
state = const UpdateState();
|
||||||
} else {
|
} else {
|
||||||
state = state.copyWith(
|
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();
|
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) {
|
bool _isNewer(String latest, String current) {
|
||||||
try {
|
try {
|
||||||
final l = latest.split('.').map(int.parse).toList();
|
final l = latest.split('.').map(int.parse).toList();
|
||||||
|
|||||||
+181
-144
@@ -1,13 +1,14 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:collection';
|
import 'dart:collection';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
import 'dart:math';
|
||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
|
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:just_audio/just_audio.dart';
|
import 'package:just_audio/just_audio.dart';
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
import 'package:permission_handler/permission_handler.dart';
|
import 'package:permission_handler/permission_handler.dart';
|
||||||
import 'package:record/record.dart';
|
import 'package:vad/vad.dart';
|
||||||
|
|
||||||
import 'api_client_provider.dart';
|
import 'api_client_provider.dart';
|
||||||
|
|
||||||
@@ -51,6 +52,53 @@ String stripMarkdownForTts(String text) {
|
|||||||
.trim();
|
.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 ─────────────────────────────────────────────────────────────────────
|
// ── State ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
enum VoiceMode { idle, recording, transcribing, playing }
|
enum VoiceMode { idle, recording, transcribing, playing }
|
||||||
@@ -59,9 +107,7 @@ class VoiceState {
|
|||||||
final VoiceMode mode;
|
final VoiceMode mode;
|
||||||
final bool voiceModeActive;
|
final bool voiceModeActive;
|
||||||
final bool available;
|
final bool available;
|
||||||
/// Normalized mic amplitude 0.0–1.0 while recording. Drives the live
|
/// Normalized mic amplitude 0.0–1.0 while recording.
|
||||||
/// pulse on VoiceMicButton so the user has obvious feedback that audio
|
|
||||||
/// is actually being picked up.
|
|
||||||
final double amplitude;
|
final double amplitude;
|
||||||
|
|
||||||
const VoiceState({
|
const VoiceState({
|
||||||
@@ -88,35 +134,25 @@ class VoiceState {
|
|||||||
// ── Provider ──────────────────────────────────────────────────────────────────
|
// ── Provider ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
final voiceProvider =
|
final voiceProvider =
|
||||||
NotifierProvider<VoiceNotifier, VoiceState>(VoiceNotifier.new);
|
NotifierProvider.autoDispose<VoiceNotifier, VoiceState>(VoiceNotifier.new);
|
||||||
|
|
||||||
// ── Notifier ──────────────────────────────────────────────────────────────────
|
// ── Notifier ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
class VoiceNotifier extends Notifier<VoiceState> {
|
class VoiceNotifier extends Notifier<VoiceState> {
|
||||||
// Audio I/O
|
// Audio playback
|
||||||
AudioRecorder? _recorder;
|
|
||||||
AudioPlayer? _player;
|
AudioPlayer? _player;
|
||||||
StreamSubscription<Amplitude>? _amplitudeSubscription;
|
|
||||||
|
|
||||||
// Recording / silence detection
|
// VAD — sole owner of the microphone
|
||||||
//
|
VadHandler? _vadHandler;
|
||||||
// Silence threshold is dynamic: we track the session peak dBFS and treat
|
StreamSubscription<void>? _vadSpeechStartSub;
|
||||||
// "silent" as "current level is at least _dropFromPeakDb below peak."
|
StreamSubscription<List<double>>? _vadSpeechEndSub;
|
||||||
// This auto-calibrates to whatever mic + room the user is on rather than
|
StreamSubscription<({double isSpeech, double notSpeech, List<double> frame})>?
|
||||||
// assuming a fixed ambient level. Until the peak climbs above
|
_vadFrameSub;
|
||||||
// _dynamicArmDb we fall back to a conservative static threshold so a
|
StreamSubscription<String>? _vadErrorSub;
|
||||||
// quiet room doesn't spin forever. A grace period at the start of the
|
bool _speechDetected = false;
|
||||||
// recording gives the user time to begin speaking before silence checks
|
int _speechStartMs = 0;
|
||||||
// arm.
|
static const _vadGraceMs = 1500;
|
||||||
int _recordingStartMs = 0;
|
bool _disposed = false;
|
||||||
int _silenceMs = 0;
|
|
||||||
double _peakDb = -100.0;
|
|
||||||
static const _fallbackThresholdDb = -35.0;
|
|
||||||
static const _dropFromPeakDb = 15.0;
|
|
||||||
static const _dynamicArmDb = -20.0;
|
|
||||||
static const _graceMs = 1500;
|
|
||||||
static const _silenceDurationMs = 2000;
|
|
||||||
static const _minRecordingMs = 300;
|
|
||||||
|
|
||||||
// Voice mode callbacks
|
// Voice mode callbacks
|
||||||
Future<void> Function(String transcript)? _onTranscript;
|
Future<void> Function(String transcript)? _onTranscript;
|
||||||
@@ -128,11 +164,10 @@ class VoiceNotifier extends Notifier<VoiceState> {
|
|||||||
int _lastSeenLength = 0;
|
int _lastSeenLength = 0;
|
||||||
bool _streamComplete = false;
|
bool _streamComplete = false;
|
||||||
|
|
||||||
// Last complete assistant response — passed to Whisper as initial_prompt
|
// Whisper context hint
|
||||||
// to reduce STT mishearings of domain-specific words.
|
|
||||||
String _lastAssistantContent = '';
|
String _lastAssistantContent = '';
|
||||||
|
|
||||||
// Empty transcript counter — show feedback after consecutive blanks
|
// Empty transcript counter
|
||||||
int _emptyTranscriptCount = 0;
|
int _emptyTranscriptCount = 0;
|
||||||
|
|
||||||
// TTS playback queue
|
// TTS playback queue
|
||||||
@@ -143,52 +178,51 @@ class VoiceNotifier extends Notifier<VoiceState> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
VoiceState build() {
|
VoiceState build() {
|
||||||
|
_disposed = false;
|
||||||
_player = AudioPlayer();
|
_player = AudioPlayer();
|
||||||
ref.onDispose(() {
|
ref.onDispose(() {
|
||||||
_amplitudeSubscription?.cancel();
|
_disposed = true;
|
||||||
_recorder?.dispose();
|
_cancelSubscriptions();
|
||||||
|
_vadHandler?.dispose();
|
||||||
|
_vadHandler = null;
|
||||||
_player?.dispose();
|
_player?.dispose();
|
||||||
|
_player = null;
|
||||||
});
|
});
|
||||||
return const VoiceState();
|
return const VoiceState();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Public API ──────────────────────────────────────────────────────────────
|
// ── 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({
|
Future<void> enterVoiceMode({
|
||||||
required Future<void> Function(String transcript) onTranscript,
|
required Future<void> Function(String transcript) onTranscript,
|
||||||
bool enableTts = false,
|
bool enableTts = false,
|
||||||
required void Function(String message) onError,
|
required void Function(String message) onError,
|
||||||
}) async {
|
}) async {
|
||||||
if (state.voiceModeActive) {
|
if (state.voiceModeActive) {
|
||||||
|
if (state.mode == VoiceMode.recording && !_speechDetected) {
|
||||||
|
onError('No speech detected');
|
||||||
|
}
|
||||||
exitVoiceMode();
|
exitVoiceMode();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check server availability — STT is required, TTS is optional.
|
|
||||||
try {
|
try {
|
||||||
final status = await ref.read(voiceRepositoryProvider).checkStatus();
|
final status = await ref.read(voiceRepositoryProvider).checkStatus();
|
||||||
if (!status.enabled || !status.stt) {
|
if (!status.enabled || !status.stt) {
|
||||||
onError('Speech-to-text not available on this server');
|
onError('Speech-to-text not available on this server');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Downgrade to STT-only when TTS is unavailable
|
|
||||||
if (!status.tts) enableTts = false;
|
if (!status.tts) enableTts = false;
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
onError('Could not reach voice service');
|
onError('Could not reach voice service');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check microphone permission
|
|
||||||
var permStatus = await Permission.microphone.request();
|
var permStatus = await Permission.microphone.request();
|
||||||
if (permStatus == PermissionStatus.permanentlyDenied) {
|
if (permStatus == PermissionStatus.permanentlyDenied) {
|
||||||
onError('Microphone blocked — opening settings');
|
onError('Microphone blocked — opening settings');
|
||||||
final opened = await openAppSettings();
|
final opened = await openAppSettings();
|
||||||
if (!opened) return;
|
if (!opened) return;
|
||||||
// Re-check after user returns from settings
|
|
||||||
permStatus = await Permission.microphone.status;
|
permStatus = await Permission.microphone.status;
|
||||||
}
|
}
|
||||||
if (!permStatus.isGranted) {
|
if (!permStatus.isGranted) {
|
||||||
@@ -206,25 +240,11 @@ class VoiceNotifier extends Notifier<VoiceState> {
|
|||||||
await _startListening();
|
await _startListening();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Exit voice mode, stop all recording and TTS.
|
|
||||||
void exitVoiceMode() {
|
void exitVoiceMode() {
|
||||||
_amplitudeSubscription?.cancel();
|
_cleanup();
|
||||||
_amplitudeSubscription = null;
|
if (!_disposed) state = const VoiceState();
|
||||||
_recorder?.stop();
|
|
||||||
_player?.stop();
|
|
||||||
_ttsQueue.clear();
|
|
||||||
_ttsPlaying = false;
|
|
||||||
_sentenceBuffer = '';
|
|
||||||
_lastSeenLength = 0;
|
|
||||||
_streamComplete = false;
|
|
||||||
_onTranscript = null;
|
|
||||||
_onError = null;
|
|
||||||
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}) {
|
void feedContent(String fullContent, {required bool isComplete}) {
|
||||||
if (!state.voiceModeActive || !_enableTts) return;
|
if (!state.voiceModeActive || !_enableTts) return;
|
||||||
|
|
||||||
@@ -243,116 +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 ──────────────────────────────────────────────────────
|
// ── Internal recording ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
Future<void> _startListening() async {
|
Future<void> _startListening() async {
|
||||||
if (!state.voiceModeActive) return;
|
if (_disposed || !state.voiceModeActive) return;
|
||||||
|
|
||||||
_silenceMs = 0;
|
_speechDetected = false;
|
||||||
_peakDb = -100.0;
|
_speechStartMs = 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';
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Recreate the recorder each session — the record package can leave
|
await _stopVad();
|
||||||
// the native AudioRecord in a bad state after stop/error cycles,
|
_vadHandler = VadHandler.create();
|
||||||
// and a stale instance is the most common cause of "could not start".
|
|
||||||
_recorder?.dispose();
|
|
||||||
_recorder = AudioRecorder();
|
|
||||||
|
|
||||||
if (!await _recorder!.hasPermission()) {
|
_vadSpeechStartSub = _vadHandler!.onSpeechStart.listen((_) {
|
||||||
_onError?.call('Microphone permission was revoked');
|
if (_disposed || !state.voiceModeActive) return;
|
||||||
exitVoiceMode();
|
if (!_speechDetected) {
|
||||||
return;
|
_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) {
|
} catch (e) {
|
||||||
_onError?.call('Microphone error: $e');
|
_onError?.call('Microphone error: $e');
|
||||||
exitVoiceMode();
|
_cleanup();
|
||||||
|
if (!_disposed) state = const VoiceState();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onAmplitude(Amplitude event) {
|
Future<void> _stopVad() async {
|
||||||
if (!state.voiceModeActive) return;
|
_cancelSubscriptions();
|
||||||
|
if (_vadHandler != null) {
|
||||||
final db = event.current;
|
final handler = _vadHandler!;
|
||||||
final validDb = !(db.isNaN || db.isInfinite);
|
_vadHandler = null;
|
||||||
|
await handler.dispose();
|
||||||
// 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);
|
|
||||||
}
|
|
||||||
if (db > _peakDb) _peakDb = db;
|
|
||||||
}
|
|
||||||
|
|
||||||
final elapsed =
|
|
||||||
DateTime.now().millisecondsSinceEpoch - _recordingStartMs;
|
|
||||||
// Grace period at the start — let the user begin speaking before we
|
|
||||||
// start silence-counting. Also suppresses any false triggers while
|
|
||||||
// the native recorder is still warming up.
|
|
||||||
if (elapsed < _graceMs) return;
|
|
||||||
if (elapsed < _minRecordingMs) return;
|
|
||||||
|
|
||||||
// Dynamic threshold once we've seen real speech; static fallback
|
|
||||||
// before that so a dead-silent session doesn't spin forever.
|
|
||||||
final threshold =
|
|
||||||
_peakDb > _dynamicArmDb ? _peakDb - _dropFromPeakDb : _fallbackThresholdDb;
|
|
||||||
final isSilent = !validDb || db < threshold;
|
|
||||||
|
|
||||||
if (isSilent) {
|
|
||||||
_silenceMs += 200;
|
|
||||||
if (_silenceMs >= _silenceDurationMs) {
|
|
||||||
_amplitudeSubscription?.cancel();
|
|
||||||
_amplitudeSubscription = null;
|
|
||||||
_handleSilence();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
_silenceMs = 0;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _handleSilence() async {
|
Future<void> _handleSpeechEnd(List<double> audioSamples) async {
|
||||||
if (!state.voiceModeActive) return;
|
if (_disposed || !state.voiceModeActive) return;
|
||||||
state = state.copyWith(mode: VoiceMode.transcribing);
|
state = state.copyWith(mode: VoiceMode.transcribing);
|
||||||
|
|
||||||
final path = await _recorder!.stop();
|
|
||||||
if (path == null || !state.voiceModeActive) return;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final bytes = await File(path).readAsBytes();
|
final wavBytes = encodeWav(audioSamples);
|
||||||
await File(path).delete().catchError((_) => File(path));
|
|
||||||
|
|
||||||
if (!state.voiceModeActive) return;
|
if (_disposed || !state.voiceModeActive) return;
|
||||||
|
|
||||||
final transcript = await ref.read(voiceRepositoryProvider).transcribe(
|
final transcript = await ref.read(voiceRepositoryProvider).transcribe(
|
||||||
bytes,
|
wavBytes,
|
||||||
context: _lastAssistantContent.isNotEmpty ? _lastAssistantContent : null,
|
context:
|
||||||
|
_lastAssistantContent.isNotEmpty ? _lastAssistantContent : null,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!state.voiceModeActive) return;
|
if (_disposed || !state.voiceModeActive) return;
|
||||||
|
|
||||||
if (transcript.isEmpty) {
|
if (transcript.isEmpty) {
|
||||||
_emptyTranscriptCount++;
|
_emptyTranscriptCount++;
|
||||||
if (_emptyTranscriptCount >= 3) {
|
if (_emptyTranscriptCount >= 3) {
|
||||||
_onError?.call('No speech detected — tap the mic to try again');
|
_onError?.call('No speech detected — tap the mic to try again');
|
||||||
exitVoiceMode();
|
_cleanup();
|
||||||
|
if (!_disposed) state = const VoiceState();
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await _startListening();
|
await _startListening();
|
||||||
@@ -360,24 +395,25 @@ class VoiceNotifier extends Notifier<VoiceState> {
|
|||||||
}
|
}
|
||||||
_emptyTranscriptCount = 0;
|
_emptyTranscriptCount = 0;
|
||||||
|
|
||||||
// Reset TTS state for this new turn
|
|
||||||
_sentenceBuffer = '';
|
_sentenceBuffer = '';
|
||||||
_lastSeenLength = 0;
|
_lastSeenLength = 0;
|
||||||
_streamComplete = false;
|
_streamComplete = false;
|
||||||
|
|
||||||
if (_enableTts) {
|
if (_enableTts && !_disposed) {
|
||||||
state = state.copyWith(mode: VoiceMode.playing);
|
state = state.copyWith(mode: VoiceMode.playing);
|
||||||
}
|
}
|
||||||
|
|
||||||
await _onTranscript?.call(transcript);
|
await _onTranscript?.call(transcript);
|
||||||
|
|
||||||
// If TTS is not enabled, loop immediately
|
// In STT-only mode (no TTS), return to idle after transcript is sent.
|
||||||
if (!_enableTts && state.voiceModeActive) {
|
// The user taps the mic again to record another message.
|
||||||
await _startListening();
|
if (!_enableTts && !_disposed && state.voiceModeActive) {
|
||||||
|
state = state.copyWith(mode: VoiceMode.idle);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_onError?.call('Voice error: transcription failed');
|
_onError?.call('Voice error: transcription failed');
|
||||||
exitVoiceMode();
|
_cleanup();
|
||||||
|
if (!_disposed) state = const VoiceState();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -405,7 +441,7 @@ class VoiceNotifier extends Notifier<VoiceState> {
|
|||||||
try {
|
try {
|
||||||
final wavBytes =
|
final wavBytes =
|
||||||
await ref.read(voiceRepositoryProvider).synthesise(text);
|
await ref.read(voiceRepositoryProvider).synthesise(text);
|
||||||
if (!state.voiceModeActive) return;
|
if (_disposed || !state.voiceModeActive) return;
|
||||||
_ttsQueue.add(wavBytes);
|
_ttsQueue.add(wavBytes);
|
||||||
if (!_ttsPlaying) _drainTtsQueue();
|
if (!_ttsPlaying) _drainTtsQueue();
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
@@ -444,6 +480,7 @@ class VoiceNotifier extends Notifier<VoiceState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _checkRestartListening() {
|
void _checkRestartListening() {
|
||||||
|
if (_disposed) return;
|
||||||
if (_streamComplete &&
|
if (_streamComplete &&
|
||||||
_ttsQueue.isEmpty &&
|
_ttsQueue.isEmpty &&
|
||||||
!_ttsPlaying &&
|
!_ttsPlaying &&
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'dart:io' as io;
|
import 'dart:io' as io;
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:lucide_icons/lucide_icons.dart';
|
||||||
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
|
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
@@ -72,7 +73,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
|
|||||||
_usernameController.text.trim(),
|
_usernameController.text.trim(),
|
||||||
_passwordController.text,
|
_passwordController.text,
|
||||||
);
|
);
|
||||||
if (mounted) context.go(Routes.briefing);
|
if (mounted) context.go(Routes.journal);
|
||||||
} on AuthException catch (e) {
|
} on AuthException catch (e) {
|
||||||
setState(() => _error = e.message);
|
setState(() => _error = e.message);
|
||||||
} on AppException catch (e) {
|
} on AppException catch (e) {
|
||||||
@@ -90,7 +91,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
|
|||||||
cookieJar: ref.read(cookieJarProvider),
|
cookieJar: ref.read(cookieJarProvider),
|
||||||
onSuccess: () async {
|
onSuccess: () async {
|
||||||
await ref.read(authProvider.notifier).verify();
|
await ref.read(authProvider.notifier).verify();
|
||||||
if (mounted) context.go(Routes.briefing);
|
if (mounted) context.go(Routes.journal);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
));
|
));
|
||||||
@@ -119,7 +120,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
|
|||||||
const SizedBox(height: 32),
|
const SizedBox(height: 32),
|
||||||
if (_oauthEnabled) ...[
|
if (_oauthEnabled) ...[
|
||||||
FilledButton.icon(
|
FilledButton.icon(
|
||||||
icon: const Icon(Icons.login),
|
icon: const Icon(LucideIcons.logIn),
|
||||||
label: const Text('Sign in with SSO'),
|
label: const Text('Sign in with SSO'),
|
||||||
onPressed: _openOAuth,
|
onPressed: _openOAuth,
|
||||||
),
|
),
|
||||||
@@ -155,8 +156,8 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
|
|||||||
border: const OutlineInputBorder(),
|
border: const OutlineInputBorder(),
|
||||||
suffixIcon: IconButton(
|
suffixIcon: IconButton(
|
||||||
icon: Icon(_obscure
|
icon: Icon(_obscure
|
||||||
? Icons.visibility
|
? LucideIcons.eye
|
||||||
: Icons.visibility_off),
|
: LucideIcons.eyeOff),
|
||||||
onPressed: () =>
|
onPressed: () =>
|
||||||
setState(() => _obscure = !_obscure),
|
setState(() => _obscure = !_obscure),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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);
|
|
||||||
});
|
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:lucide_icons/lucide_icons.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:table_calendar/table_calendar.dart';
|
import 'package:table_calendar/table_calendar.dart';
|
||||||
|
|
||||||
@@ -91,7 +92,7 @@ class CalendarScreen extends ConsumerWidget {
|
|||||||
notifier: notifier,
|
notifier: notifier,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: const Icon(Icons.add),
|
child: const Icon(LucideIcons.plus),
|
||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:lucide_icons/lucide_icons.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../../core/theme.dart';
|
||||||
import '../../data/models/calendar_event.dart';
|
import '../../data/models/calendar_event.dart';
|
||||||
import '../../providers/api_client_provider.dart';
|
import '../../providers/api_client_provider.dart';
|
||||||
import '../../providers/calendar_provider.dart';
|
import '../../providers/calendar_provider.dart';
|
||||||
@@ -118,12 +120,12 @@ class _EventFormSheetState extends ConsumerState<EventFormSheet> {
|
|||||||
'color': _color,
|
'color': _color,
|
||||||
'recurrence': rrule,
|
'recurrence': rrule,
|
||||||
};
|
};
|
||||||
final api = ref.read(eventsApiProvider);
|
final repo = ref.read(eventsRepositoryProvider);
|
||||||
if (_isCreate) {
|
if (_isCreate) {
|
||||||
final created = await api.createEvent(payload);
|
final created = await repo.createEvent(payload);
|
||||||
widget.notifier.addEvent(created);
|
widget.notifier.addEvent(created);
|
||||||
} else {
|
} else {
|
||||||
final updated = await api.updateEvent(widget.event!.id, payload);
|
final updated = await repo.updateEvent(widget.event!.id, payload);
|
||||||
widget.notifier.updateEvent(updated);
|
widget.notifier.updateEvent(updated);
|
||||||
}
|
}
|
||||||
if (mounted) Navigator.of(context).pop();
|
if (mounted) Navigator.of(context).pop();
|
||||||
@@ -152,11 +154,10 @@ class _EventFormSheetState extends ConsumerState<EventFormSheet> {
|
|||||||
),
|
),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.pop(dialogContext, true),
|
onPressed: () => Navigator.pop(dialogContext, true),
|
||||||
child: Text(
|
style: TextButton.styleFrom(
|
||||||
'Delete',
|
foregroundColor: Theme.of(dialogContext).extension<ActionColors>()!.destructive,
|
||||||
style: TextStyle(
|
|
||||||
color: Theme.of(dialogContext).colorScheme.error),
|
|
||||||
),
|
),
|
||||||
|
child: const Text('Delete'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -164,7 +165,7 @@ class _EventFormSheetState extends ConsumerState<EventFormSheet> {
|
|||||||
if (confirmed != true || !mounted) return;
|
if (confirmed != true || !mounted) return;
|
||||||
setState(() => _saving = true);
|
setState(() => _saving = true);
|
||||||
try {
|
try {
|
||||||
await ref.read(eventsApiProvider).deleteEvent(widget.event!.id);
|
await ref.read(eventsRepositoryProvider).deleteEvent(widget.event!.id);
|
||||||
widget.notifier.removeEvent(widget.event!.id, widget.event!.startDt);
|
widget.notifier.removeEvent(widget.event!.id, widget.event!.startDt);
|
||||||
if (mounted) Navigator.of(context).pop();
|
if (mounted) Navigator.of(context).pop();
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
@@ -273,7 +274,7 @@ class _EventFormSheetState extends ConsumerState<EventFormSheet> {
|
|||||||
const Spacer(),
|
const Spacer(),
|
||||||
if (!_isCreate)
|
if (!_isCreate)
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.delete_outline),
|
icon: const Icon(LucideIcons.trash2),
|
||||||
color: Theme.of(context).colorScheme.error,
|
color: Theme.of(context).colorScheme.error,
|
||||||
onPressed: _saving ? null : _delete,
|
onPressed: _saving ? null : _delete,
|
||||||
),
|
),
|
||||||
@@ -303,7 +304,7 @@ class _EventFormSheetState extends ConsumerState<EventFormSheet> {
|
|||||||
// Start date
|
// Start date
|
||||||
ListTile(
|
ListTile(
|
||||||
contentPadding: EdgeInsets.zero,
|
contentPadding: EdgeInsets.zero,
|
||||||
leading: const Icon(Icons.calendar_today_outlined),
|
leading: const Icon(LucideIcons.calendarDays),
|
||||||
title: Text(_fmtDate(_startDt)),
|
title: Text(_fmtDate(_startDt)),
|
||||||
subtitle: const Text('Start date'),
|
subtitle: const Text('Start date'),
|
||||||
onTap: _pickStartDate,
|
onTap: _pickStartDate,
|
||||||
@@ -313,7 +314,7 @@ class _EventFormSheetState extends ConsumerState<EventFormSheet> {
|
|||||||
if (!_allDay)
|
if (!_allDay)
|
||||||
ListTile(
|
ListTile(
|
||||||
contentPadding: EdgeInsets.zero,
|
contentPadding: EdgeInsets.zero,
|
||||||
leading: const Icon(Icons.access_time_outlined),
|
leading: const Icon(LucideIcons.clock),
|
||||||
title: Text(_fmtTime(_startDt)),
|
title: Text(_fmtTime(_startDt)),
|
||||||
subtitle: const Text('Start time'),
|
subtitle: const Text('Start time'),
|
||||||
onTap: _pickStartTime,
|
onTap: _pickStartTime,
|
||||||
@@ -322,14 +323,14 @@ class _EventFormSheetState extends ConsumerState<EventFormSheet> {
|
|||||||
// End date
|
// End date
|
||||||
ListTile(
|
ListTile(
|
||||||
contentPadding: EdgeInsets.zero,
|
contentPadding: EdgeInsets.zero,
|
||||||
leading: const Icon(Icons.event_outlined),
|
leading: const Icon(LucideIcons.calendarCheck),
|
||||||
title: Text(
|
title: Text(
|
||||||
_endDt != null ? _fmtDate(_endDt!) : 'No end date'),
|
_endDt != null ? _fmtDate(_endDt!) : 'No end date'),
|
||||||
subtitle: const Text('End date'),
|
subtitle: const Text('End date'),
|
||||||
onTap: _pickEndDate,
|
onTap: _pickEndDate,
|
||||||
trailing: _endDt != null
|
trailing: _endDt != null
|
||||||
? IconButton(
|
? IconButton(
|
||||||
icon: const Icon(Icons.clear),
|
icon: const Icon(LucideIcons.x),
|
||||||
onPressed: () => setState(() => _endDt = null),
|
onPressed: () => setState(() => _endDt = null),
|
||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
@@ -339,7 +340,7 @@ class _EventFormSheetState extends ConsumerState<EventFormSheet> {
|
|||||||
if (!_allDay && _endDt != null)
|
if (!_allDay && _endDt != null)
|
||||||
ListTile(
|
ListTile(
|
||||||
contentPadding: EdgeInsets.zero,
|
contentPadding: EdgeInsets.zero,
|
||||||
leading: const Icon(Icons.access_time_outlined),
|
leading: const Icon(LucideIcons.clock),
|
||||||
title: Text(_fmtTime(_endDt!)),
|
title: Text(_fmtTime(_endDt!)),
|
||||||
subtitle: const Text('End time'),
|
subtitle: const Text('End time'),
|
||||||
onTap: _pickEndTime,
|
onTap: _pickEndTime,
|
||||||
@@ -351,14 +352,14 @@ class _EventFormSheetState extends ConsumerState<EventFormSheet> {
|
|||||||
if (_isCustomRrule)
|
if (_isCustomRrule)
|
||||||
ListTile(
|
ListTile(
|
||||||
contentPadding: EdgeInsets.zero,
|
contentPadding: EdgeInsets.zero,
|
||||||
leading: const Icon(Icons.repeat_outlined),
|
leading: const Icon(LucideIcons.repeat),
|
||||||
title: const Text('Custom (read-only)'),
|
title: const Text('Custom (read-only)'),
|
||||||
subtitle: Text(widget.event!.recurrence ?? ''),
|
subtitle: Text(widget.event!.recurrence ?? ''),
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
const Icon(Icons.repeat_outlined),
|
const Icon(LucideIcons.repeat),
|
||||||
const SizedBox(width: 16),
|
const SizedBox(width: 16),
|
||||||
DropdownButton<String?>(
|
DropdownButton<String?>(
|
||||||
value: _recurrence,
|
value: _recurrence,
|
||||||
@@ -419,7 +420,7 @@ class _EventFormSheetState extends ConsumerState<EventFormSheet> {
|
|||||||
width: _color.isEmpty ? 2 : 1,
|
width: _color.isEmpty ? 2 : 1,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: const Icon(Icons.block, size: 16),
|
child: const Icon(LucideIcons.ban, size: 16),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
..._presetColors.map((hex) {
|
..._presetColors.map((hex) {
|
||||||
@@ -451,10 +452,13 @@ class _EventFormSheetState extends ConsumerState<EventFormSheet> {
|
|||||||
|
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
// Save button
|
// Save button — Moss action-primary per Hybrid rule
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
child: FilledButton(
|
child: FilledButton(
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
backgroundColor: Theme.of(context).extension<ActionColors>()!.primary,
|
||||||
|
),
|
||||||
onPressed: _saving ? null : _save,
|
onPressed: _saving ? null : _save,
|
||||||
child: _saving
|
child: _saving
|
||||||
? const SizedBox(
|
? const SizedBox(
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:lucide_icons/lucide_icons.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
import '../../core/exceptions.dart';
|
import '../../core/exceptions.dart';
|
||||||
@@ -68,8 +69,6 @@ class _ChatScreenState extends ConsumerState<ChatScreen>
|
|||||||
WidgetsBinding.instance.removeObserver(this);
|
WidgetsBinding.instance.removeObserver(this);
|
||||||
_controller.dispose();
|
_controller.dispose();
|
||||||
_scrollController.dispose();
|
_scrollController.dispose();
|
||||||
// Exit voice mode if the user navigates away mid-session.
|
|
||||||
ref.read(voiceProvider.notifier).exitVoiceMode();
|
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,7 +174,7 @@ class _ChatScreenState extends ConsumerState<ChatScreen>
|
|||||||
height: 18,
|
height: 18,
|
||||||
child: CircularProgressIndicator(strokeWidth: 2),
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
)
|
)
|
||||||
: const Icon(Icons.refresh),
|
: const Icon(LucideIcons.refreshCw),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -282,7 +281,7 @@ class _ChatScreenState extends ConsumerState<ChatScreen>
|
|||||||
child:
|
child:
|
||||||
CircularProgressIndicator(strokeWidth: 2),
|
CircularProgressIndicator(strokeWidth: 2),
|
||||||
)
|
)
|
||||||
: const Icon(Icons.send),
|
: const Icon(LucideIcons.send),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,33 +1,87 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:lucide_icons/lucide_icons.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
import '../../core/constants.dart';
|
import '../../core/constants.dart';
|
||||||
|
import '../../core/theme.dart';
|
||||||
import '../../providers/chat_provider.dart';
|
import '../../providers/chat_provider.dart';
|
||||||
|
import 'chat_screen.dart';
|
||||||
|
|
||||||
class ConversationsTabScreen extends ConsumerWidget {
|
class ConversationsTabScreen extends ConsumerStatefulWidget {
|
||||||
const ConversationsTabScreen({super.key});
|
const ConversationsTabScreen({super.key});
|
||||||
|
|
||||||
@override
|
@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),
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
backgroundColor: Theme.of(dialogContext).extension<ActionColors>()!.destructive,
|
||||||
|
),
|
||||||
|
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 theme = Theme.of(context);
|
||||||
final convsAsync = ref.watch(conversationsProvider);
|
final convsAsync = ref.watch(conversationsProvider);
|
||||||
|
|
||||||
return Scaffold(
|
final listPanel = Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: Text('Chat', style: theme.textTheme.titleLarge),
|
title: Text('Chat', style: theme.textTheme.titleLarge),
|
||||||
actions: [
|
actions: [
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.add),
|
icon: const Icon(LucideIcons.plus),
|
||||||
tooltip: 'New conversation',
|
tooltip: 'New conversation',
|
||||||
onPressed: () async {
|
onPressed: _createConversation,
|
||||||
final conv = await ref
|
|
||||||
.read(conversationsProvider.notifier)
|
|
||||||
.create('');
|
|
||||||
if (context.mounted) {
|
|
||||||
context.push(Routes.chat.replaceFirst(':id', '${conv.id}'));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -40,37 +94,31 @@ class ConversationsTabScreen extends ConsumerWidget {
|
|||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Icon(Icons.chat_bubble_outline,
|
Icon(LucideIcons.messageCircle,
|
||||||
size: 48, color: theme.colorScheme.onSurfaceVariant),
|
size: 48, color: theme.colorScheme.onSurfaceVariant),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text('No conversations yet',
|
Text('No conversations yet',
|
||||||
style: theme.textTheme.titleMedium),
|
style: theme.textTheme.titleMedium),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
FilledButton.icon(
|
FilledButton.icon(
|
||||||
icon: const Icon(Icons.add),
|
icon: const Icon(LucideIcons.plus),
|
||||||
label: const Text('Start a conversation'),
|
label: const Text('Start a conversation'),
|
||||||
onPressed: () async {
|
onPressed: _createConversation,
|
||||||
final conv = await ref
|
|
||||||
.read(conversationsProvider.notifier)
|
|
||||||
.create('');
|
|
||||||
if (context.mounted) {
|
|
||||||
context.push(
|
|
||||||
Routes.chat.replaceFirst(':id', '${conv.id}'));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return RefreshIndicator(
|
return RefreshIndicator(
|
||||||
onRefresh: () => ref.read(conversationsProvider.notifier).refresh(),
|
onRefresh: () =>
|
||||||
|
ref.read(conversationsProvider.notifier).refresh(),
|
||||||
child: ListView.builder(
|
child: ListView.builder(
|
||||||
itemCount: convs.length,
|
itemCount: convs.length,
|
||||||
itemBuilder: (ctx, i) {
|
itemBuilder: (ctx, i) {
|
||||||
final c = convs[i];
|
final c = convs[i];
|
||||||
|
final selected = isWide && c.id == _selectedConvId;
|
||||||
return ListTile(
|
return ListTile(
|
||||||
leading: const Icon(Icons.chat_bubble_outline),
|
leading: const Icon(LucideIcons.messageCircle),
|
||||||
title: Text(
|
title: Text(
|
||||||
c.title.isEmpty ? 'New conversation' : c.title,
|
c.title.isEmpty ? 'New conversation' : c.title,
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
@@ -79,13 +127,12 @@ class ConversationsTabScreen extends ConsumerWidget {
|
|||||||
_relativeTime(c.updatedAt),
|
_relativeTime(c.updatedAt),
|
||||||
style: theme.textTheme.labelSmall,
|
style: theme.textTheme.labelSmall,
|
||||||
),
|
),
|
||||||
|
selected: selected,
|
||||||
trailing: IconButton(
|
trailing: IconButton(
|
||||||
icon: const Icon(Icons.delete_outline),
|
icon: const Icon(LucideIcons.trash2),
|
||||||
onPressed: () =>
|
onPressed: () => _confirmDelete(c.id, c.title),
|
||||||
_confirmDelete(context, ref, c.id, c.title),
|
|
||||||
),
|
),
|
||||||
onTap: () =>
|
onTap: () => _openConversation(c.id),
|
||||||
ctx.push(Routes.chat.replaceFirst(':id', '${c.id}')),
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -93,28 +140,38 @@ class ConversationsTabScreen extends ConsumerWidget {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _confirmDelete(
|
if (!isWide) return listPanel;
|
||||||
BuildContext context, WidgetRef ref, int id, String title) async {
|
|
||||||
final ok = await showDialog<bool>(
|
return Row(
|
||||||
context: context,
|
children: [
|
||||||
builder: (dialogContext) => AlertDialog(
|
SizedBox(
|
||||||
title: const Text('Delete conversation?'),
|
width: 320,
|
||||||
content: Text('"$title" will be permanently deleted.'),
|
child: listPanel,
|
||||||
actions: [
|
),
|
||||||
TextButton(
|
const VerticalDivider(width: 1),
|
||||||
onPressed: () => Navigator.pop(dialogContext, false),
|
Expanded(
|
||||||
child: const Text('Cancel')),
|
child: _selectedConvId != null
|
||||||
FilledButton(
|
? ChatScreen(
|
||||||
onPressed: () => Navigator.pop(dialogContext, true),
|
key: ValueKey(_selectedConvId),
|
||||||
child: const Text('Delete')),
|
conversationId: _selectedConvId!,
|
||||||
],
|
)
|
||||||
),
|
: Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(LucideIcons.messageCircle,
|
||||||
|
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,86 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:lucide_icons/lucide_icons.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(LucideIcons.bookOpen),
|
||||||
|
title: Text(isoDate),
|
||||||
|
trailing: const Icon(LucideIcons.chevronRight),
|
||||||
|
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);
|
||||||
|
});
|
||||||
+85
-172
@@ -1,33 +1,29 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:lucide_icons/lucide_icons.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
import '../../core/exceptions.dart';
|
import '../../core/exceptions.dart';
|
||||||
import '../../data/models/message.dart';
|
import '../../data/models/message.dart';
|
||||||
import '../../providers/briefing_provider.dart';
|
import '../../providers/journal_provider.dart';
|
||||||
import '../../providers/api_client_provider.dart';
|
|
||||||
import '../../widgets/chat_message_bubble.dart';
|
|
||||||
import '../../widgets/weather_card.dart';
|
|
||||||
import '../../widgets/news_card.dart';
|
|
||||||
import 'briefing_history_screen.dart';
|
|
||||||
import '../../providers/voice_provider.dart';
|
import '../../providers/voice_provider.dart';
|
||||||
|
import '../../widgets/chat_message_bubble.dart';
|
||||||
import '../../widgets/voice_mic_button.dart';
|
import '../../widgets/voice_mic_button.dart';
|
||||||
|
import 'journal_history_screen.dart';
|
||||||
|
|
||||||
class BriefingScreen extends ConsumerStatefulWidget {
|
class JournalScreen extends ConsumerStatefulWidget {
|
||||||
const BriefingScreen({super.key});
|
const JournalScreen({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
ConsumerState<BriefingScreen> createState() => _BriefingScreenState();
|
ConsumerState<JournalScreen> createState() => _JournalScreenState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _BriefingScreenState extends ConsumerState<BriefingScreen>
|
class _JournalScreenState extends ConsumerState<JournalScreen>
|
||||||
with WidgetsBindingObserver {
|
with WidgetsBindingObserver {
|
||||||
final _controller = TextEditingController();
|
final _controller = TextEditingController();
|
||||||
final _scrollController = ScrollController();
|
final _scrollController = ScrollController();
|
||||||
bool _refreshing = false;
|
bool _refreshing = false;
|
||||||
// rss_item_id -> 'up' | 'down' | null
|
|
||||||
final Map<int, String?> _reactions = {};
|
|
||||||
|
|
||||||
Timer? _pollTimer;
|
Timer? _pollTimer;
|
||||||
bool _appInForeground = true;
|
bool _appInForeground = true;
|
||||||
@@ -36,31 +32,29 @@ class _BriefingScreenState extends ConsumerState<BriefingScreen>
|
|||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
WidgetsBinding.instance.addObserver(this);
|
WidgetsBinding.instance.addObserver(this);
|
||||||
_pollTimer = Timer.periodic(const Duration(seconds: 60), (_) => _pollSilently());
|
_pollTimer =
|
||||||
|
Timer.periodic(const Duration(seconds: 60), (_) => _pollSilently());
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||||
final wasBackground = !_appInForeground;
|
final wasBackground = !_appInForeground;
|
||||||
_appInForeground = state == AppLifecycleState.resumed;
|
_appInForeground = state == AppLifecycleState.resumed;
|
||||||
// On resume, force a message refresh. This also unfreezes a stuck
|
|
||||||
// streaming state if the SSE socket died while the app was backgrounded
|
|
||||||
// — silentRefresh won't do that because it guards on isStreaming.
|
|
||||||
if (_appInForeground && wasBackground && mounted) {
|
if (_appInForeground && wasBackground && mounted) {
|
||||||
ref.read(briefingProvider.notifier).refreshMessages();
|
ref.read(journalProvider.notifier).refreshMessages();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _pollSilently() {
|
void _pollSilently() {
|
||||||
if (!_appInForeground || !mounted) return;
|
if (!_appInForeground || !mounted) return;
|
||||||
final isStreaming = ref.read(isBriefingStreamingProvider);
|
final isStreaming = ref.read(isJournalStreamingProvider);
|
||||||
if (isStreaming) return;
|
if (isStreaming) return;
|
||||||
ref.read(briefingProvider.notifier).silentRefresh();
|
ref.read(journalProvider.notifier).silentRefresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _pullToRefresh() async {
|
Future<void> _pullToRefresh() async {
|
||||||
try {
|
try {
|
||||||
await ref.read(briefingProvider.notifier).refreshMessages();
|
await ref.read(journalProvider.notifier).refreshMessages();
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
@@ -76,7 +70,6 @@ class _BriefingScreenState extends ConsumerState<BriefingScreen>
|
|||||||
WidgetsBinding.instance.removeObserver(this);
|
WidgetsBinding.instance.removeObserver(this);
|
||||||
_controller.dispose();
|
_controller.dispose();
|
||||||
_scrollController.dispose();
|
_scrollController.dispose();
|
||||||
ref.read(voiceProvider.notifier).exitVoiceMode();
|
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,7 +90,7 @@ class _BriefingScreenState extends ConsumerState<BriefingScreen>
|
|||||||
if (text.isEmpty) return;
|
if (text.isEmpty) return;
|
||||||
_controller.clear();
|
_controller.clear();
|
||||||
try {
|
try {
|
||||||
await ref.read(briefingProvider.notifier).sendReply(text);
|
await ref.read(journalProvider.notifier).sendReply(text);
|
||||||
} on AppException catch (e) {
|
} on AppException catch (e) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context)
|
ScaffoldMessenger.of(context)
|
||||||
@@ -112,39 +105,6 @@ class _BriefingScreenState extends ConsumerState<BriefingScreen>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _handleDiscuss(int convId, int itemId) async {
|
|
||||||
try {
|
|
||||||
await ref.read(briefingProvider.notifier).discussArticle(convId, itemId);
|
|
||||||
} on AppException catch (e) {
|
|
||||||
if (mounted) {
|
|
||||||
ScaffoldMessenger.of(context)
|
|
||||||
.showSnackBar(SnackBar(content: Text(e.message)));
|
|
||||||
}
|
|
||||||
} catch (_) {
|
|
||||||
if (mounted) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
const SnackBar(content: Text('Failed to start discussion.')),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _handleReaction(int itemId, String reaction) async {
|
|
||||||
final current = _reactions[itemId];
|
|
||||||
final next = current == reaction ? null : reaction;
|
|
||||||
setState(() => _reactions[itemId] = next);
|
|
||||||
final api = ref.read(briefingApiProvider);
|
|
||||||
try {
|
|
||||||
if (next == null) {
|
|
||||||
await api.deleteRssReaction(itemId);
|
|
||||||
} else {
|
|
||||||
await api.postRssReaction(itemId, reaction);
|
|
||||||
}
|
|
||||||
} catch (_) {
|
|
||||||
setState(() => _reactions[itemId] = current);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _toggleVoiceMode() async {
|
Future<void> _toggleVoiceMode() async {
|
||||||
final voice = ref.read(voiceProvider);
|
final voice = ref.read(voiceProvider);
|
||||||
if (voice.voiceModeActive) {
|
if (voice.voiceModeActive) {
|
||||||
@@ -153,7 +113,7 @@ class _BriefingScreenState extends ConsumerState<BriefingScreen>
|
|||||||
}
|
}
|
||||||
await ref.read(voiceProvider.notifier).enterVoiceMode(
|
await ref.read(voiceProvider.notifier).enterVoiceMode(
|
||||||
onTranscript: (transcript) async {
|
onTranscript: (transcript) async {
|
||||||
await ref.read(briefingProvider.notifier).sendReply(transcript);
|
await ref.read(journalProvider.notifier).sendReply(transcript);
|
||||||
},
|
},
|
||||||
enableTts: true,
|
enableTts: true,
|
||||||
onError: (msg) {
|
onError: (msg) {
|
||||||
@@ -168,11 +128,11 @@ class _BriefingScreenState extends ConsumerState<BriefingScreen>
|
|||||||
Future<void> _refresh() async {
|
Future<void> _refresh() async {
|
||||||
setState(() => _refreshing = true);
|
setState(() => _refreshing = true);
|
||||||
try {
|
try {
|
||||||
await ref.read(briefingProvider.notifier).refresh('compilation');
|
await ref.read(journalProvider.notifier).regeneratePrep();
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(content: Text('Could not generate briefing.')),
|
const SnackBar(content: Text('Could not regenerate prep.')),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@@ -182,20 +142,19 @@ class _BriefingScreenState extends ConsumerState<BriefingScreen>
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final briefingAsync = ref.watch(briefingProvider);
|
final journalAsync = ref.watch(journalProvider);
|
||||||
final isStreaming = ref.watch(isBriefingStreamingProvider);
|
final isStreaming = ref.watch(isJournalStreamingProvider);
|
||||||
final voiceState = ref.watch(voiceProvider);
|
final voiceState = ref.watch(voiceProvider);
|
||||||
final scheme = Theme.of(context).colorScheme;
|
final scheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
// Scroll to bottom when messages change
|
ref.listen(journalProvider, (prev, next) => _scrollToBottom());
|
||||||
ref.listen(briefingProvider, (prev, next) => _scrollToBottom());
|
|
||||||
|
|
||||||
// Feed streaming assistant content to VoiceNotifier for TTS.
|
// Feed streaming assistant content to VoiceNotifier for TTS.
|
||||||
ref.listen(briefingProvider, (prev, next) {
|
ref.listen(journalProvider, (prev, next) {
|
||||||
if (!voiceState.voiceModeActive) return;
|
if (!voiceState.voiceModeActive) return;
|
||||||
final conv = next.value;
|
final day = next.value;
|
||||||
if (conv == null || conv.messages.isEmpty) return;
|
if (day == null || day.messages.isEmpty) return;
|
||||||
final last = conv.messages.last;
|
final last = day.messages.last;
|
||||||
if (last.role != MessageRole.assistant) return;
|
if (last.role != MessageRole.assistant) return;
|
||||||
final isComplete = last.status != 'generating';
|
final isComplete = last.status != 'generating';
|
||||||
ref
|
ref
|
||||||
@@ -208,7 +167,7 @@ class _BriefingScreenState extends ConsumerState<BriefingScreen>
|
|||||||
title: Column(
|
title: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text('Briefing', style: Theme.of(context).textTheme.titleLarge),
|
Text('Journal', style: Theme.of(context).textTheme.titleLarge),
|
||||||
Text(
|
Text(
|
||||||
_todayLabel(),
|
_todayLabel(),
|
||||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||||
@@ -229,107 +188,94 @@ class _BriefingScreenState extends ConsumerState<BriefingScreen>
|
|||||||
)
|
)
|
||||||
else
|
else
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.refresh_outlined),
|
icon: const Icon(LucideIcons.refreshCw),
|
||||||
tooltip: 'Generate briefing',
|
tooltip: 'Regenerate prep',
|
||||||
onPressed: _refresh,
|
onPressed: _refresh,
|
||||||
),
|
),
|
||||||
PopupMenuButton<String>(
|
PopupMenuButton<String>(
|
||||||
onSelected: (value) {
|
onSelected: (value) {
|
||||||
if (value == 'history') {
|
if (value == 'history') {
|
||||||
Navigator.of(context).push(MaterialPageRoute(
|
Navigator.of(context).push(MaterialPageRoute(
|
||||||
builder: (_) => const BriefingHistoryScreen(),
|
builder: (_) => const JournalHistoryScreen(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
itemBuilder: (_) => const [
|
itemBuilder: (_) => const [
|
||||||
PopupMenuItem(
|
PopupMenuItem(
|
||||||
value: 'history',
|
value: 'history',
|
||||||
child: Text('View past briefings'),
|
child: Text('Past days'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
body: briefingAsync.when(
|
body: journalAsync.when(
|
||||||
loading: () => const Center(child: CircularProgressIndicator()),
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
error: (err, stack) => Center(
|
error: (err, stack) => Center(
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
const Text("Could not load today's briefing."),
|
const Text("Could not load today's journal."),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
FilledButton.tonal(
|
FilledButton.tonal(
|
||||||
onPressed: () => ref.invalidate(briefingProvider),
|
onPressed: () => ref.invalidate(journalProvider),
|
||||||
child: const Text('Retry'),
|
child: const Text('Retry'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
data: (conv) {
|
data: (day) {
|
||||||
return Column(
|
final isWide = MediaQuery.of(context).size.width >= 600;
|
||||||
|
Widget body = Column(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: RefreshIndicator(
|
child: RefreshIndicator(
|
||||||
onRefresh: _pullToRefresh,
|
onRefresh: _pullToRefresh,
|
||||||
child: CustomScrollView(
|
child: CustomScrollView(
|
||||||
controller: _scrollController,
|
controller: _scrollController,
|
||||||
// AlwaysScrollable so pull-to-refresh fires even when
|
|
||||||
// the briefing is empty or shorter than the viewport.
|
|
||||||
physics: const AlwaysScrollableScrollPhysics(),
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
slivers: [
|
slivers: [
|
||||||
if (conv.messages.isEmpty)
|
if (day.messages.isEmpty)
|
||||||
SliverFillRemaining(
|
SliverFillRemaining(
|
||||||
child: Center(
|
child: Center(
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'No briefing yet today.',
|
'No prep yet today.',
|
||||||
style: Theme.of(context)
|
style: Theme.of(context)
|
||||||
.textTheme
|
.textTheme
|
||||||
.bodyMedium
|
.bodyMedium
|
||||||
?.copyWith(color: scheme.onSurfaceVariant),
|
?.copyWith(color: scheme.onSurfaceVariant),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
FilledButton.tonal(
|
FilledButton.tonal(
|
||||||
onPressed: _refresh,
|
onPressed: _refresh,
|
||||||
child: const Text('Generate now'),
|
child: const Text('Generate now'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
SliverPadding(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 8, vertical: 8),
|
||||||
|
sliver: SliverList.builder(
|
||||||
|
itemCount: day.messages.length,
|
||||||
|
itemBuilder: (_, i) =>
|
||||||
|
_JournalMessageItem(message: day.messages[i]),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
|
||||||
else
|
|
||||||
SliverPadding(
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: 8, vertical: 8),
|
|
||||||
sliver: SliverList.builder(
|
|
||||||
itemCount: conv.messages.length,
|
|
||||||
itemBuilder: (_, i) {
|
|
||||||
final msg = conv.messages[i];
|
|
||||||
return _BriefingMessageItem(
|
|
||||||
message: msg,
|
|
||||||
convId: conv.id,
|
|
||||||
reactions: _reactions,
|
|
||||||
onReaction: _handleReaction,
|
|
||||||
onDiscuss: _handleDiscuss,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Progress bar while streaming
|
|
||||||
if (isStreaming)
|
if (isStreaming)
|
||||||
LinearProgressIndicator(
|
LinearProgressIndicator(
|
||||||
minHeight: 2,
|
minHeight: 2,
|
||||||
color: scheme.primary,
|
color: scheme.primary,
|
||||||
),
|
),
|
||||||
|
|
||||||
// Voice mode banner
|
|
||||||
if (voiceState.voiceModeActive)
|
if (voiceState.voiceModeActive)
|
||||||
Container(
|
Container(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
@@ -344,7 +290,6 @@ class _BriefingScreenState extends ConsumerState<BriefingScreen>
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// Reply bar
|
|
||||||
const Divider(height: 1),
|
const Divider(height: 1),
|
||||||
SafeArea(
|
SafeArea(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
@@ -357,7 +302,7 @@ class _BriefingScreenState extends ConsumerState<BriefingScreen>
|
|||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
hintText: voiceState.voiceModeActive
|
hintText: voiceState.voiceModeActive
|
||||||
? 'Listening…'
|
? 'Listening…'
|
||||||
: 'Reply to your briefing…',
|
: 'Tell your journal…',
|
||||||
hintStyle: voiceState.voiceModeActive
|
hintStyle: voiceState.voiceModeActive
|
||||||
? const TextStyle(fontStyle: FontStyle.italic)
|
? const TextStyle(fontStyle: FontStyle.italic)
|
||||||
: null,
|
: null,
|
||||||
@@ -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,21 @@ class _BriefingScreenState extends ConsumerState<BriefingScreen>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Renders a single briefing message with optional WeatherCard above it
|
/// Renders a single journal message. The daily-prep prose itself already
|
||||||
/// and RSS reaction buttons below it (for assistant messages with metadata).
|
/// covers weather ("Weather at home will reach a high of 15.9° ..."), so
|
||||||
class _BriefingMessageItem extends StatelessWidget {
|
/// the journal screen leaves rendering to the message bubble — no separate
|
||||||
|
/// weather card on top.
|
||||||
|
class _JournalMessageItem extends StatelessWidget {
|
||||||
final Message message;
|
final Message message;
|
||||||
final int convId;
|
|
||||||
final Map<int, String?> reactions;
|
|
||||||
final void Function(int itemId, String reaction) onReaction;
|
|
||||||
final void Function(int convId, int itemId) onDiscuss;
|
|
||||||
|
|
||||||
const _BriefingMessageItem({
|
const _JournalMessageItem({required this.message});
|
||||||
required this.message,
|
|
||||||
required this.convId,
|
|
||||||
required this.reactions,
|
|
||||||
required this.onReaction,
|
|
||||||
required this.onDiscuss,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final meta = message.metadata;
|
return ChatMessageBubble(message: message);
|
||||||
final isAssistant = message.role == MessageRole.assistant;
|
|
||||||
|
|
||||||
// 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();
|
|
||||||
|
|
||||||
return Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
||||||
children: [
|
|
||||||
if (hasWeatherKey) 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 {
|
class _GradientSendButton extends StatelessWidget {
|
||||||
final VoidCallback? onPressed;
|
final VoidCallback? onPressed;
|
||||||
final bool isStreaming;
|
final bool isStreaming;
|
||||||
@@ -488,7 +401,7 @@ class _GradientSendButton extends StatelessWidget {
|
|||||||
: const LinearGradient(
|
: const LinearGradient(
|
||||||
begin: Alignment.topLeft,
|
begin: Alignment.topLeft,
|
||||||
end: Alignment.bottomRight,
|
end: Alignment.bottomRight,
|
||||||
colors: [Color(0xFF7C3AED), Color(0xFF5B21B6)],
|
colors: [Color(0xFF5B4A8A), Color(0xFF3F3560)],
|
||||||
),
|
),
|
||||||
color: disabled ? scheme.onSurface.withValues(alpha: 0.12) : null,
|
color: disabled ? scheme.onSurface.withValues(alpha: 0.12) : null,
|
||||||
borderRadius: BorderRadius.circular(10),
|
borderRadius: BorderRadius.circular(10),
|
||||||
@@ -504,7 +417,7 @@ class _GradientSendButton extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
: Icon(
|
: Icon(
|
||||||
Icons.send,
|
LucideIcons.send,
|
||||||
color: disabled
|
color: disabled
|
||||||
? scheme.onSurface.withValues(alpha: 0.38)
|
? scheme.onSurface.withValues(alpha: 0.38)
|
||||||
: Colors.white,
|
: Colors.white,
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:lucide_icons/lucide_icons.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
@@ -95,7 +96,7 @@ class _KnowledgeScreenState extends ConsumerState<KnowledgeScreen>
|
|||||||
: const Text('Knowledge'),
|
: const Text('Knowledge'),
|
||||||
actions: [
|
actions: [
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: Icon(_searchActive ? Icons.close : Icons.search),
|
icon: Icon(_searchActive ? LucideIcons.x : LucideIcons.search),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
setState(() => _searchActive = !_searchActive);
|
setState(() => _searchActive = !_searchActive);
|
||||||
if (!_searchActive) {
|
if (!_searchActive) {
|
||||||
@@ -149,7 +150,7 @@ class _KnowledgeScreenState extends ConsumerState<KnowledgeScreen>
|
|||||||
floatingActionButton: FloatingActionButton(
|
floatingActionButton: FloatingActionButton(
|
||||||
onPressed: () => _onFabTapped(context),
|
onPressed: () => _onFabTapped(context),
|
||||||
tooltip: 'New',
|
tooltip: 'New',
|
||||||
child: const Icon(Icons.edit_outlined),
|
child: const Icon(LucideIcons.pencil),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -186,18 +187,57 @@ class _KnowledgeScreenState extends ConsumerState<KnowledgeScreen>
|
|||||||
}
|
}
|
||||||
return RefreshIndicator(
|
return RefreshIndicator(
|
||||||
onRefresh: () => ref.read(knowledgeProvider.notifier).refresh(),
|
onRefresh: () => ref.read(knowledgeProvider.notifier).refresh(),
|
||||||
child: ListView.separated(
|
child: LayoutBuilder(
|
||||||
controller: _scrollController,
|
builder: (context, constraints) {
|
||||||
itemCount: items.length + (state.isLoadingBatch ? 1 : 0),
|
final cols = constraints.maxWidth >= 900
|
||||||
separatorBuilder: (_, _) => const Divider(height: 1),
|
? 3
|
||||||
itemBuilder: (_, i) {
|
: constraints.maxWidth >= 600
|
||||||
if (i >= items.length) {
|
? 2
|
||||||
return const Padding(
|
: 1;
|
||||||
padding: EdgeInsets.all(16),
|
if (cols == 1) {
|
||||||
child: Center(child: CircularProgressIndicator()),
|
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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -220,7 +260,7 @@ class _KnowledgeScreenState extends ConsumerState<KnowledgeScreen>
|
|||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
_TypePickerRow(
|
_TypePickerRow(
|
||||||
icon: Icons.description_outlined,
|
icon: LucideIcons.fileText,
|
||||||
label: 'Note',
|
label: 'Note',
|
||||||
description: 'General note or document',
|
description: 'General note or document',
|
||||||
onTap: () {
|
onTap: () {
|
||||||
@@ -229,7 +269,7 @@ class _KnowledgeScreenState extends ConsumerState<KnowledgeScreen>
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
_TypePickerRow(
|
_TypePickerRow(
|
||||||
icon: Icons.person_outlined,
|
icon: LucideIcons.user,
|
||||||
label: 'Person',
|
label: 'Person',
|
||||||
description: 'Contact, colleague, or reference person',
|
description: 'Contact, colleague, or reference person',
|
||||||
onTap: () {
|
onTap: () {
|
||||||
@@ -238,7 +278,7 @@ class _KnowledgeScreenState extends ConsumerState<KnowledgeScreen>
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
_TypePickerRow(
|
_TypePickerRow(
|
||||||
icon: Icons.place_outlined,
|
icon: LucideIcons.mapPin,
|
||||||
label: 'Place',
|
label: 'Place',
|
||||||
description: 'Location, venue, or place of interest',
|
description: 'Location, venue, or place of interest',
|
||||||
onTap: () {
|
onTap: () {
|
||||||
@@ -247,7 +287,7 @@ class _KnowledgeScreenState extends ConsumerState<KnowledgeScreen>
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
_TypePickerRow(
|
_TypePickerRow(
|
||||||
icon: Icons.checklist_outlined,
|
icon: LucideIcons.listChecks,
|
||||||
label: 'List',
|
label: 'List',
|
||||||
description: 'Checklist or structured list',
|
description: 'Checklist or structured list',
|
||||||
onTap: () {
|
onTap: () {
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:lucide_icons/lucide_icons.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
import '../../core/constants.dart';
|
import '../../core/constants.dart';
|
||||||
|
import '../../data/local/database.dart';
|
||||||
import '../../data/models/milestone.dart';
|
import '../../data/models/milestone.dart';
|
||||||
import '../../data/models/project.dart';
|
import '../../data/models/project.dart';
|
||||||
import '../../data/models/task.dart';
|
import '../../data/models/task.dart';
|
||||||
@@ -10,6 +12,7 @@ import '../../providers/api_client_provider.dart';
|
|||||||
import '../../providers/milestones_provider.dart';
|
import '../../providers/milestones_provider.dart';
|
||||||
import '../../providers/projects_provider.dart';
|
import '../../providers/projects_provider.dart';
|
||||||
import '../../providers/tasks_provider.dart';
|
import '../../providers/tasks_provider.dart';
|
||||||
|
import '../../widgets/pending_sync_badge.dart';
|
||||||
|
|
||||||
class ProjectTasksScreen extends ConsumerStatefulWidget {
|
class ProjectTasksScreen extends ConsumerStatefulWidget {
|
||||||
final int projectId;
|
final int projectId;
|
||||||
@@ -55,11 +58,11 @@ class _ProjectTasksScreenState extends ConsumerState<ProjectTasksScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Color _parseColor(String? hex) {
|
Color _parseColor(String? hex) {
|
||||||
if (hex == null || hex.isEmpty) return const Color(0xFF7C3AED);
|
if (hex == null || hex.isEmpty) return const Color(0xFF5B4A8A);
|
||||||
try {
|
try {
|
||||||
return Color(int.parse(hex.replaceFirst('#', '0xFF')));
|
return Color(int.parse(hex.replaceFirst('#', '0xFF')));
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
return const Color(0xFF7C3AED);
|
return const Color(0xFF5B4A8A);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,7 +99,7 @@ class _ProjectTasksScreenState extends ConsumerState<ProjectTasksScreen> {
|
|||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.edit_outlined),
|
icon: const Icon(LucideIcons.pencil),
|
||||||
tooltip: 'Edit project',
|
tooltip: 'Edit project',
|
||||||
onPressed: () =>
|
onPressed: () =>
|
||||||
context.push('/projects/${widget.projectId}/edit'),
|
context.push('/projects/${widget.projectId}/edit'),
|
||||||
@@ -133,7 +136,7 @@ class _ProjectTasksScreenState extends ConsumerState<ProjectTasksScreen> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Icon(Icons.check_box_outlined,
|
Icon(LucideIcons.checkSquare,
|
||||||
size: 48,
|
size: 48,
|
||||||
color: Theme.of(context).colorScheme.onSurfaceVariant),
|
color: Theme.of(context).colorScheme.onSurfaceVariant),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
@@ -302,9 +305,9 @@ class _TaskRow extends StatelessWidget {
|
|||||||
});
|
});
|
||||||
|
|
||||||
IconData get _statusIcon => switch (effectiveStatus) {
|
IconData get _statusIcon => switch (effectiveStatus) {
|
||||||
TaskStatus.done => Icons.check_circle,
|
TaskStatus.done => LucideIcons.checkCircle2,
|
||||||
TaskStatus.inProgress => Icons.timelapse,
|
TaskStatus.inProgress => LucideIcons.loader,
|
||||||
TaskStatus.todo => Icons.radio_button_unchecked,
|
TaskStatus.todo => LucideIcons.circle,
|
||||||
};
|
};
|
||||||
|
|
||||||
Color _statusColor(BuildContext context) {
|
Color _statusColor(BuildContext context) {
|
||||||
@@ -345,18 +348,28 @@ class _TaskRow extends StatelessWidget {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Row(
|
||||||
task.title.isNotEmpty ? task.title : 'Untitled',
|
children: [
|
||||||
style: theme.textTheme.titleSmall?.copyWith(
|
Flexible(
|
||||||
decoration: effectiveStatus == TaskStatus.done
|
child: Text(
|
||||||
? TextDecoration.lineThrough
|
task.title.isNotEmpty ? task.title : 'Untitled',
|
||||||
: null,
|
style: theme.textTheme.titleSmall?.copyWith(
|
||||||
color: effectiveStatus == TaskStatus.done
|
decoration: effectiveStatus == TaskStatus.done
|
||||||
? theme.colorScheme.onSurfaceVariant
|
? TextDecoration.lineThrough
|
||||||
: null,
|
: null,
|
||||||
),
|
color: effectiveStatus == TaskStatus.done
|
||||||
maxLines: 2,
|
? theme.colorScheme.onSurfaceVariant
|
||||||
overflow: TextOverflow.ellipsis,
|
: null,
|
||||||
|
),
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
PendingSyncBadge(
|
||||||
|
domain: kSyncDomainTasks,
|
||||||
|
id: task.id,
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
if (task.dueDate != null) ...[
|
if (task.dueDate != null) ...[
|
||||||
const SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
|
|||||||
@@ -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),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:lucide_icons/lucide_icons.dart';
|
||||||
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
|
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
import '../../core/constants.dart';
|
import '../../core/constants.dart';
|
||||||
|
import '../../core/theme.dart';
|
||||||
import '../../core/wikilink_syntax.dart';
|
import '../../core/wikilink_syntax.dart';
|
||||||
import '../../providers/notes_provider.dart';
|
import '../../providers/notes_provider.dart';
|
||||||
|
|
||||||
@@ -44,13 +46,13 @@ class NoteDetailScreen extends ConsumerWidget {
|
|||||||
data: (note) => Row(
|
data: (note) => Row(
|
||||||
children: [
|
children: [
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.edit),
|
icon: const Icon(LucideIcons.pencil),
|
||||||
onPressed: () => context.push(
|
onPressed: () => context.push(
|
||||||
Routes.noteEdit.replaceFirst(':id', '$noteId'),
|
Routes.noteEdit.replaceFirst(':id', '$noteId'),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.delete),
|
icon: const Icon(LucideIcons.trash2),
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
final confirm = await showDialog<bool>(
|
final confirm = await showDialog<bool>(
|
||||||
context: context,
|
context: context,
|
||||||
@@ -65,6 +67,11 @@ class NoteDetailScreen extends ConsumerWidget {
|
|||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () =>
|
onPressed: () =>
|
||||||
Navigator.pop(dialogContext, true),
|
Navigator.pop(dialogContext, true),
|
||||||
|
style: TextButton.styleFrom(
|
||||||
|
foregroundColor: Theme.of(dialogContext)
|
||||||
|
.extension<ActionColors>()!
|
||||||
|
.destructive,
|
||||||
|
),
|
||||||
child: const Text('Delete'),
|
child: const Text('Delete'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:lucide_icons/lucide_icons.dart';
|
||||||
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
|
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
import '../../core/exceptions.dart';
|
import '../../core/exceptions.dart';
|
||||||
|
import '../../core/theme.dart';
|
||||||
import '../../core/wikilink_syntax.dart';
|
import '../../core/wikilink_syntax.dart';
|
||||||
import '../../providers/api_client_provider.dart';
|
import '../../providers/api_client_provider.dart';
|
||||||
import '../../providers/notes_provider.dart';
|
import '../../providers/notes_provider.dart';
|
||||||
@@ -85,6 +87,9 @@ class _NoteEditScreenState extends ConsumerState<NoteEditScreen> {
|
|||||||
),
|
),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.pop(dialogContext, true),
|
onPressed: () => Navigator.pop(dialogContext, true),
|
||||||
|
style: TextButton.styleFrom(
|
||||||
|
foregroundColor: Theme.of(dialogContext).extension<ActionColors>()!.destructive,
|
||||||
|
),
|
||||||
child: const Text('Delete'),
|
child: const Text('Delete'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -163,12 +168,12 @@ class _NoteEditScreenState extends ConsumerState<NoteEditScreen> {
|
|||||||
actions: [
|
actions: [
|
||||||
if (widget.noteId != null)
|
if (widget.noteId != null)
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.delete_outline),
|
icon: const Icon(LucideIcons.trash2),
|
||||||
tooltip: 'Delete',
|
tooltip: 'Delete',
|
||||||
onPressed: _delete,
|
onPressed: _delete,
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: Icon(_preview ? Icons.edit : Icons.preview),
|
icon: Icon(_preview ? LucideIcons.pencil : LucideIcons.eye),
|
||||||
tooltip: _preview ? 'Edit' : 'Preview',
|
tooltip: _preview ? 'Edit' : 'Preview',
|
||||||
onPressed: () => setState(() => _preview = !_preview),
|
onPressed: () => setState(() => _preview = !_preview),
|
||||||
),
|
),
|
||||||
@@ -179,7 +184,7 @@ class _NoteEditScreenState extends ConsumerState<NoteEditScreen> {
|
|||||||
height: 20,
|
height: 20,
|
||||||
child: CircularProgressIndicator(strokeWidth: 2),
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
)
|
)
|
||||||
: const Icon(Icons.check),
|
: const Icon(LucideIcons.check),
|
||||||
onPressed: _saving ? null : _save,
|
onPressed: _saving ? null : _save,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -272,7 +277,7 @@ class _TagInput extends StatelessWidget {
|
|||||||
label: Text('#$tag', style: const TextStyle(fontSize: 12)),
|
label: Text('#$tag', style: const TextStyle(fontSize: 12)),
|
||||||
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||||
deleteIcon: const Icon(Icons.close, size: 14),
|
deleteIcon: const Icon(LucideIcons.x, size: 14),
|
||||||
onDeleted: () => onRemove(tag),
|
onDeleted: () => onRemove(tag),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:lucide_icons/lucide_icons.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
@@ -105,7 +106,7 @@ class _ProjectEditScreenState extends ConsumerState<ProjectEditScreen> {
|
|||||||
height: 20,
|
height: 20,
|
||||||
child: CircularProgressIndicator(strokeWidth: 2),
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
)
|
)
|
||||||
: const Icon(Icons.check),
|
: const Icon(LucideIcons.check),
|
||||||
onPressed: _saving ? null : _save,
|
onPressed: _saving ? null : _save,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:lucide_icons/lucide_icons.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
import '../../data/local/database.dart';
|
||||||
import '../../data/models/project.dart';
|
import '../../data/models/project.dart';
|
||||||
import '../../providers/projects_provider.dart';
|
import '../../providers/projects_provider.dart';
|
||||||
|
import '../../widgets/pending_sync_badge.dart';
|
||||||
|
|
||||||
class ProjectsScreen extends ConsumerWidget {
|
class ProjectsScreen extends ConsumerWidget {
|
||||||
const ProjectsScreen({super.key});
|
const ProjectsScreen({super.key});
|
||||||
@@ -30,7 +33,7 @@ class ProjectsScreen extends ConsumerWidget {
|
|||||||
floatingActionButton: FloatingActionButton(
|
floatingActionButton: FloatingActionButton(
|
||||||
onPressed: () => context.push('/projects/new'),
|
onPressed: () => context.push('/projects/new'),
|
||||||
tooltip: 'New project',
|
tooltip: 'New project',
|
||||||
child: const Icon(Icons.add),
|
child: const Icon(LucideIcons.plus),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -49,7 +52,12 @@ class _ProjectCard extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return ListTile(
|
return ListTile(
|
||||||
title: Text(project.title),
|
title: Row(
|
||||||
|
children: [
|
||||||
|
Flexible(child: Text(project.title)),
|
||||||
|
PendingSyncBadge(domain: kSyncDomainProjects, id: project.id),
|
||||||
|
],
|
||||||
|
),
|
||||||
subtitle: Column(
|
subtitle: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:lucide_icons/lucide_icons.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
@@ -24,28 +25,28 @@ class SettingsScreen extends ConsumerWidget {
|
|||||||
ListTile(
|
ListTile(
|
||||||
title: const Text('Server URL'),
|
title: const Text('Server URL'),
|
||||||
subtitle: Text(serverUrl ?? 'Not configured'),
|
subtitle: Text(serverUrl ?? 'Not configured'),
|
||||||
leading: const Icon(Icons.dns),
|
leading: const Icon(LucideIcons.server),
|
||||||
onTap: () => context.go(Routes.setup),
|
onTap: () => context.go(Routes.setup),
|
||||||
),
|
),
|
||||||
const Divider(),
|
const Divider(),
|
||||||
ListTile(
|
ListTile(
|
||||||
title: const Text('Appearance'),
|
title: const Text('Appearance'),
|
||||||
leading: const Icon(Icons.brightness_6),
|
leading: const Icon(LucideIcons.sunMoon),
|
||||||
trailing: SegmentedButton<ThemeMode>(
|
trailing: SegmentedButton<ThemeMode>(
|
||||||
segments: const [
|
segments: const [
|
||||||
ButtonSegment(
|
ButtonSegment(
|
||||||
value: ThemeMode.system,
|
value: ThemeMode.system,
|
||||||
icon: Icon(Icons.brightness_auto),
|
icon: Icon(LucideIcons.contrast),
|
||||||
tooltip: 'System',
|
tooltip: 'System',
|
||||||
),
|
),
|
||||||
ButtonSegment(
|
ButtonSegment(
|
||||||
value: ThemeMode.light,
|
value: ThemeMode.light,
|
||||||
icon: Icon(Icons.light_mode),
|
icon: Icon(LucideIcons.sun),
|
||||||
tooltip: 'Light',
|
tooltip: 'Light',
|
||||||
),
|
),
|
||||||
ButtonSegment(
|
ButtonSegment(
|
||||||
value: ThemeMode.dark,
|
value: ThemeMode.dark,
|
||||||
icon: Icon(Icons.dark_mode),
|
icon: Icon(LucideIcons.moon),
|
||||||
tooltip: 'Dark',
|
tooltip: 'Dark',
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -58,7 +59,7 @@ class SettingsScreen extends ConsumerWidget {
|
|||||||
|
|
||||||
// ── Updates ──────────────────────────────────────────────────────
|
// ── Updates ──────────────────────────────────────────────────────
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.update),
|
leading: const Icon(LucideIcons.refreshCw),
|
||||||
title: const Text('Update repository'),
|
title: const Text('Update repository'),
|
||||||
subtitle: Text(repoUrl?.isNotEmpty == true
|
subtitle: Text(repoUrl?.isNotEmpty == true
|
||||||
? repoUrl!
|
? repoUrl!
|
||||||
@@ -66,7 +67,7 @@ class SettingsScreen extends ConsumerWidget {
|
|||||||
onTap: () => _editRepoUrl(context, ref, repoUrl),
|
onTap: () => _editRepoUrl(context, ref, repoUrl),
|
||||||
),
|
),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.info_outline),
|
leading: const Icon(LucideIcons.info),
|
||||||
title: const Text('App version'),
|
title: const Text('App version'),
|
||||||
subtitle: _versionSubtitle(update),
|
subtitle: _versionSubtitle(update),
|
||||||
trailing: update.status == UpdateStatus.checking
|
trailing: update.status == UpdateStatus.checking
|
||||||
@@ -92,13 +93,13 @@ class SettingsScreen extends ConsumerWidget {
|
|||||||
child: const Text('Check'),
|
child: const Text('Check'),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (update.status == UpdateStatus.available ||
|
if (update.status == UpdateStatus.readyToInstall ||
|
||||||
update.status == UpdateStatus.downloading)
|
update.status == UpdateStatus.downloading)
|
||||||
_UpdateTile(update: update),
|
_UpdateTile(update: update),
|
||||||
if (update.status == UpdateStatus.error)
|
if (update.status == UpdateStatus.error)
|
||||||
ListTile(
|
ListTile(
|
||||||
leading:
|
leading:
|
||||||
const Icon(Icons.error_outline, color: Colors.red),
|
const Icon(LucideIcons.alertCircle, color: Colors.red),
|
||||||
title: const Text('Update check failed'),
|
title: const Text('Update check failed'),
|
||||||
subtitle: Text(
|
subtitle: Text(
|
||||||
update.errorMessage ?? 'Unknown error',
|
update.errorMessage ?? 'Unknown error',
|
||||||
@@ -110,7 +111,7 @@ class SettingsScreen extends ConsumerWidget {
|
|||||||
const Divider(),
|
const Divider(),
|
||||||
ListTile(
|
ListTile(
|
||||||
title: const Text('Sign Out'),
|
title: const Text('Sign Out'),
|
||||||
leading: const Icon(Icons.logout),
|
leading: const Icon(LucideIcons.logOut),
|
||||||
onTap: () async {
|
onTap: () async {
|
||||||
await ref.read(authProvider.notifier).logout();
|
await ref.read(authProvider.notifier).logout();
|
||||||
if (context.mounted) context.go(Routes.login);
|
if (context.mounted) context.go(Routes.login);
|
||||||
@@ -131,7 +132,7 @@ class SettingsScreen extends ConsumerWidget {
|
|||||||
if (update.status == UpdateStatus.upToDate) {
|
if (update.status == UpdateStatus.upToDate) {
|
||||||
return Text('v$current — up to date');
|
return Text('v$current — up to date');
|
||||||
}
|
}
|
||||||
if (update.status == UpdateStatus.available ||
|
if (update.status == UpdateStatus.readyToInstall ||
|
||||||
update.status == UpdateStatus.downloading) {
|
update.status == UpdateStatus.downloading) {
|
||||||
return Text('v$current installed');
|
return Text('v$current installed');
|
||||||
}
|
}
|
||||||
@@ -184,7 +185,7 @@ class _UpdateTile extends ConsumerWidget {
|
|||||||
final isDownloading = update.status == UpdateStatus.downloading;
|
final isDownloading = update.status == UpdateStatus.downloading;
|
||||||
|
|
||||||
return ListTile(
|
return ListTile(
|
||||||
leading: const Icon(Icons.system_update, color: Colors.green),
|
leading: const Icon(LucideIcons.downloadCloud, color: Colors.green),
|
||||||
title: Text('v${update.latestVersion} available'),
|
title: Text('v${update.latestVersion} available'),
|
||||||
subtitle: isDownloading
|
subtitle: isDownloading
|
||||||
? Column(
|
? Column(
|
||||||
@@ -202,12 +203,12 @@ class _UpdateTile extends ConsumerWidget {
|
|||||||
'${(update.downloadProgress * 100).toStringAsFixed(0)}%'),
|
'${(update.downloadProgress * 100).toStringAsFixed(0)}%'),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
: const Text('Tap to download and install'),
|
: const Text('Ready to install'),
|
||||||
trailing: isDownloading
|
trailing: isDownloading
|
||||||
? null
|
? null
|
||||||
: FilledButton(
|
: FilledButton(
|
||||||
onPressed: () =>
|
onPressed: () =>
|
||||||
ref.read(updateProvider.notifier).downloadAndInstall(),
|
ref.read(updateProvider.notifier).install(),
|
||||||
child: const Text('Install'),
|
child: const Text('Install'),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -29,8 +29,13 @@ class _SplashScreenState extends ConsumerState<SplashScreen> {
|
|||||||
await ref.read(authProvider.notifier).verify();
|
await ref.read(authProvider.notifier).verify();
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
final status = ref.read(authProvider);
|
final status = ref.read(authProvider);
|
||||||
|
final hasEverLoggedIn = ref.read(hasEverLoggedInProvider);
|
||||||
if (status == AuthStatus.authenticated) {
|
if (status == AuthStatus.authenticated) {
|
||||||
context.go(Routes.briefing);
|
context.go(Routes.journal);
|
||||||
|
} else if (status == AuthStatus.offline && hasEverLoggedIn) {
|
||||||
|
// 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 {
|
} else {
|
||||||
context.go(Routes.login);
|
context.go(Routes.login);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:lucide_icons/lucide_icons.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
import '../../core/constants.dart';
|
import '../../core/constants.dart';
|
||||||
import '../../core/exceptions.dart';
|
import '../../core/exceptions.dart';
|
||||||
|
import '../../core/theme.dart';
|
||||||
import '../../data/models/task.dart';
|
import '../../data/models/task.dart';
|
||||||
import '../../providers/api_client_provider.dart';
|
import '../../providers/api_client_provider.dart';
|
||||||
import '../../providers/tasks_provider.dart';
|
import '../../providers/tasks_provider.dart';
|
||||||
@@ -111,6 +113,9 @@ class _TaskEditScreenState extends ConsumerState<TaskEditScreen> {
|
|||||||
child: const Text('Cancel')),
|
child: const Text('Cancel')),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.pop(dialogContext, true),
|
onPressed: () => Navigator.pop(dialogContext, true),
|
||||||
|
style: TextButton.styleFrom(
|
||||||
|
foregroundColor: Theme.of(dialogContext).extension<ActionColors>()!.destructive,
|
||||||
|
),
|
||||||
child: const Text('Delete')),
|
child: const Text('Delete')),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -190,7 +195,7 @@ class _TaskEditScreenState extends ConsumerState<TaskEditScreen> {
|
|||||||
actions: [
|
actions: [
|
||||||
if (widget.taskId != null)
|
if (widget.taskId != null)
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.delete),
|
icon: const Icon(LucideIcons.trash2),
|
||||||
onPressed: _delete,
|
onPressed: _delete,
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
@@ -200,7 +205,7 @@ class _TaskEditScreenState extends ConsumerState<TaskEditScreen> {
|
|||||||
height: 20,
|
height: 20,
|
||||||
child: CircularProgressIndicator(strokeWidth: 2),
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
)
|
)
|
||||||
: const Icon(Icons.check),
|
: const Icon(LucideIcons.check),
|
||||||
onPressed: _saving ? null : _save,
|
onPressed: _saving ? null : _save,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -272,10 +277,10 @@ class _TaskEditScreenState extends ConsumerState<TaskEditScreen> {
|
|||||||
title: Text(_dueDate == null
|
title: Text(_dueDate == null
|
||||||
? 'No due date'
|
? 'No due date'
|
||||||
: 'Due: ${_dueDate!.toLocal().toString().substring(0, 10)}'),
|
: 'Due: ${_dueDate!.toLocal().toString().substring(0, 10)}'),
|
||||||
leading: const Icon(Icons.calendar_today),
|
leading: const Icon(LucideIcons.calendarDays),
|
||||||
trailing: _dueDate != null
|
trailing: _dueDate != null
|
||||||
? IconButton(
|
? IconButton(
|
||||||
icon: const Icon(Icons.clear),
|
icon: const Icon(LucideIcons.x),
|
||||||
onPressed: () =>
|
onPressed: () =>
|
||||||
setState(() => _dueDate = null),
|
setState(() => _dueDate = null),
|
||||||
)
|
)
|
||||||
@@ -331,7 +336,7 @@ class _SubTasksSection extends ConsumerWidget {
|
|||||||
const Spacer(),
|
const Spacer(),
|
||||||
TextButton.icon(
|
TextButton.icon(
|
||||||
onPressed: onAdd,
|
onPressed: onAdd,
|
||||||
icon: const Icon(Icons.add, size: 16),
|
icon: const Icon(LucideIcons.plus, size: 16),
|
||||||
label: const Text('Add'),
|
label: const Text('Add'),
|
||||||
style: TextButton.styleFrom(
|
style: TextButton.styleFrom(
|
||||||
foregroundColor: colorScheme.primary,
|
foregroundColor: colorScheme.primary,
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
import 'dart:math' show min;
|
import 'dart:math' show min;
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:dio/dio.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
|
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
import '../data/models/message.dart';
|
import '../data/models/message.dart';
|
||||||
|
import '../providers/api_client_provider.dart';
|
||||||
|
import '../providers/settings_provider.dart';
|
||||||
import 'tool_call_chip.dart';
|
import 'tool_call_chip.dart';
|
||||||
|
|
||||||
class ChatMessageBubble extends StatelessWidget {
|
class ChatMessageBubble extends ConsumerWidget {
|
||||||
final Message message;
|
final Message message;
|
||||||
final String streamingStatus;
|
final String streamingStatus;
|
||||||
const ChatMessageBubble({
|
const ChatMessageBubble({
|
||||||
@@ -16,9 +21,11 @@ class ChatMessageBubble extends StatelessWidget {
|
|||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final isUser = message.role == MessageRole.user;
|
final isUser = message.role == MessageRole.user;
|
||||||
final scheme = Theme.of(context).colorScheme;
|
final scheme = Theme.of(context).colorScheme;
|
||||||
|
final serverUrl = ref.watch(serverUrlProvider) ?? '';
|
||||||
|
final dio = ref.watch(dioProvider);
|
||||||
final isGenerating = message.status == 'generating';
|
final isGenerating = message.status == 'generating';
|
||||||
final toolCalls = message.toolCalls ?? const [];
|
final toolCalls = message.toolCalls ?? const [];
|
||||||
|
|
||||||
@@ -34,13 +41,16 @@ class ChatMessageBubble extends StatelessWidget {
|
|||||||
maxWidth: min(MediaQuery.of(context).size.width * 0.82, 480),
|
maxWidth: min(MediaQuery.of(context).size.width * 0.82, 480),
|
||||||
),
|
),
|
||||||
margin: const EdgeInsets.symmetric(vertical: 4, horizontal: 4),
|
margin: const EdgeInsets.symmetric(vertical: 4, horizontal: 4),
|
||||||
|
// Illuminated Transcript pattern (mirrors web's ChatMessage.vue):
|
||||||
|
// - User bubble: transparent bg, neutral Pewter border, only the
|
||||||
|
// bottom-right corner clipped (the "from-me" tail).
|
||||||
|
// - Assistant bubble: card surface, 2px accent left edge (the
|
||||||
|
// "illuminated capital"), accent-tinted glow shadow + depth
|
||||||
|
// shadow, only the bottom-left corner clipped.
|
||||||
decoration: isUser
|
decoration: isUser
|
||||||
? BoxDecoration(
|
? BoxDecoration(
|
||||||
color: Colors.transparent,
|
color: Colors.transparent,
|
||||||
border: Border.all(
|
border: Border.all(color: scheme.outline, width: 1),
|
||||||
color: scheme.primary.withValues(alpha: 0.35),
|
|
||||||
width: 1,
|
|
||||||
),
|
|
||||||
borderRadius: const BorderRadius.only(
|
borderRadius: const BorderRadius.only(
|
||||||
topLeft: Radius.circular(16),
|
topLeft: Radius.circular(16),
|
||||||
topRight: Radius.circular(16),
|
topRight: Radius.circular(16),
|
||||||
@@ -49,16 +59,28 @@ class ChatMessageBubble extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
: BoxDecoration(
|
: BoxDecoration(
|
||||||
color: scheme.surfaceContainerHighest,
|
color: scheme.surface,
|
||||||
border: Border(
|
border: Border(
|
||||||
left: BorderSide(color: scheme.primary, width: 2),
|
left: BorderSide(color: scheme.primary, width: 2),
|
||||||
),
|
),
|
||||||
borderRadius: const BorderRadius.only(
|
borderRadius: const BorderRadius.only(
|
||||||
topLeft: Radius.circular(4),
|
topLeft: Radius.circular(16),
|
||||||
topRight: Radius.circular(16),
|
topRight: Radius.circular(16),
|
||||||
bottomLeft: Radius.circular(4),
|
bottomLeft: Radius.circular(4),
|
||||||
bottomRight: Radius.circular(16),
|
bottomRight: Radius.circular(16),
|
||||||
),
|
),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: scheme.primary.withValues(alpha: 0.14),
|
||||||
|
blurRadius: 28,
|
||||||
|
offset: const Offset(0, 4),
|
||||||
|
),
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withValues(alpha: 0.4),
|
||||||
|
blurRadius: 8,
|
||||||
|
offset: const Offset(0, 2),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||||
@@ -116,6 +138,14 @@ class ChatMessageBubble extends StatelessWidget {
|
|||||||
if (message.content.isNotEmpty)
|
if (message.content.isNotEmpty)
|
||||||
MarkdownBody(
|
MarkdownBody(
|
||||||
data: message.content,
|
data: message.content,
|
||||||
|
imageBuilder: (uri, title, alt) {
|
||||||
|
return _AuthImage(
|
||||||
|
uri: uri,
|
||||||
|
alt: alt,
|
||||||
|
serverUrl: serverUrl,
|
||||||
|
dio: dio,
|
||||||
|
);
|
||||||
|
},
|
||||||
styleSheet: MarkdownStyleSheet(
|
styleSheet: MarkdownStyleSheet(
|
||||||
p: TextStyle(
|
p: TextStyle(
|
||||||
color: isUser
|
color: isUser
|
||||||
@@ -161,3 +191,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'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,26 +1,28 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:lucide_icons/lucide_icons.dart';
|
||||||
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
|
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
|
||||||
|
|
||||||
import '../data/models/message.dart';
|
import '../data/models/message.dart';
|
||||||
|
|
||||||
class BriefingDigestCard extends StatefulWidget {
|
class JournalPrepCard extends StatefulWidget {
|
||||||
/// The first assistant message from today's briefing, or null if none yet.
|
/// The first assistant message from today's journal — the daily prep
|
||||||
|
/// (LLM-generated briefing-style opener), or null if not yet generated.
|
||||||
final Message? message;
|
final Message? message;
|
||||||
|
|
||||||
/// Called when the user taps "Generate now".
|
/// Called when the user taps "Generate now".
|
||||||
final VoidCallback? onGenerateNow;
|
final VoidCallback? onGenerateNow;
|
||||||
|
|
||||||
const BriefingDigestCard({
|
const JournalPrepCard({
|
||||||
super.key,
|
super.key,
|
||||||
required this.message,
|
required this.message,
|
||||||
this.onGenerateNow,
|
this.onGenerateNow,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<BriefingDigestCard> createState() => _BriefingDigestCardState();
|
State<JournalPrepCard> createState() => _JournalPrepCardState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _BriefingDigestCardState extends State<BriefingDigestCard> {
|
class _JournalPrepCardState extends State<JournalPrepCard> {
|
||||||
bool _expanded = false;
|
bool _expanded = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -43,10 +45,9 @@ class _BriefingDigestCardState extends State<BriefingDigestCard> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
// Header row
|
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(Icons.wb_sunny_outlined, size: 18, color: scheme.primary),
|
Icon(LucideIcons.bookOpen, size: 18, color: scheme.primary),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Text(
|
Text(
|
||||||
_todayLabel(),
|
_todayLabel(),
|
||||||
@@ -57,11 +58,9 @@ class _BriefingDigestCardState extends State<BriefingDigestCard> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
|
|
||||||
// Body
|
|
||||||
if (widget.message == null) ...[
|
if (widget.message == null) ...[
|
||||||
Text(
|
Text(
|
||||||
'No briefing yet today.',
|
'No prep yet today.',
|
||||||
style: textTheme.bodyMedium?.copyWith(
|
style: textTheme.bodyMedium?.copyWith(
|
||||||
color: scheme.onSurfaceVariant,
|
color: scheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
@@ -1,18 +1,24 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:lucide_icons/lucide_icons.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
import '../data/local/database.dart';
|
||||||
import '../data/models/knowledge_item.dart';
|
import '../data/models/knowledge_item.dart';
|
||||||
|
import 'pending_sync_badge.dart';
|
||||||
|
|
||||||
class KnowledgeItemCard extends StatelessWidget {
|
class KnowledgeItemCard extends StatelessWidget {
|
||||||
final KnowledgeItem item;
|
final KnowledgeItem item;
|
||||||
const KnowledgeItemCard({super.key, required this.item});
|
const KnowledgeItemCard({super.key, required this.item});
|
||||||
|
|
||||||
|
String get _pendingDomain =>
|
||||||
|
item.noteType == 'task' ? kSyncDomainTasks : kSyncDomainNotes;
|
||||||
|
|
||||||
IconData get _icon => switch (item.noteType) {
|
IconData get _icon => switch (item.noteType) {
|
||||||
'person' => Icons.person_outlined,
|
'person' => LucideIcons.user,
|
||||||
'place' => Icons.place_outlined,
|
'place' => LucideIcons.mapPin,
|
||||||
'list' => Icons.checklist_outlined,
|
'list' => LucideIcons.listChecks,
|
||||||
'task' => Icons.task_alt_outlined,
|
'task' => LucideIcons.checkCircle2,
|
||||||
_ => Icons.description_outlined,
|
_ => LucideIcons.fileText,
|
||||||
};
|
};
|
||||||
|
|
||||||
Color _statusColor(BuildContext context) {
|
Color _statusColor(BuildContext context) {
|
||||||
@@ -27,22 +33,41 @@ class KnowledgeItemCard extends StatelessWidget {
|
|||||||
|
|
||||||
String? get _subtitle {
|
String? get _subtitle {
|
||||||
if (item.noteType == 'task') {
|
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}';
|
if (item.dueDate != null) return 'Due ${item.dueDate}';
|
||||||
return item.status;
|
return item.status;
|
||||||
}
|
}
|
||||||
if (item.body.trim().isEmpty) return null;
|
if (item.body.trim().isEmpty) return null;
|
||||||
final preview = item.body.trim().replaceAll('\n', ' ');
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return ListTile(
|
return ListTile(
|
||||||
leading: Icon(_icon, color: _statusColor(context)),
|
leading: Icon(_icon, color: _statusColor(context)),
|
||||||
title: Text(
|
title: Row(
|
||||||
item.title.isEmpty ? '(untitled)' : item.title,
|
children: [
|
||||||
maxLines: 1,
|
Flexible(
|
||||||
overflow: TextOverflow.ellipsis,
|
child: Text(
|
||||||
|
item.title.isEmpty ? '(untitled)' : item.title,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
PendingSyncBadge(domain: _pendingDomain, id: item.id),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
subtitle: _subtitle != null
|
subtitle: _subtitle != null
|
||||||
? Text(
|
? Text(
|
||||||
@@ -53,13 +78,65 @@ class KnowledgeItemCard extends StatelessWidget {
|
|||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
trailing: item.tags.isNotEmpty ? _TagChips(tags: item.tags) : null,
|
trailing: item.tags.isNotEmpty ? _TagChips(tags: item.tags) : null,
|
||||||
onTap: () {
|
onTap: () => _onTap(context),
|
||||||
if (item.noteType == 'task') {
|
);
|
||||||
context.push('/tasks/${item.id}/edit');
|
}
|
||||||
} else {
|
|
||||||
context.push('/notes/${item.id}');
|
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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
PendingSyncBadge(domain: _pendingDomain, id: item.id),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
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,136 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:lucide_icons/lucide_icons.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../providers/api_client_provider.dart';
|
||||||
|
import '../providers/auth_provider.dart';
|
||||||
|
|
||||||
|
/// Sticky banner shown when the backend is unreachable. Surfaces a retry
|
||||||
|
/// action plus a "last sync X ago" hint when there is cached content to
|
||||||
|
/// fall back on (Tier 2 offline mode).
|
||||||
|
class OfflineBanner extends ConsumerStatefulWidget {
|
||||||
|
const OfflineBanner({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<OfflineBanner> createState() => _OfflineBannerState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _OfflineBannerState extends ConsumerState<OfflineBanner> {
|
||||||
|
bool _retrying = false;
|
||||||
|
DateTime? _lastSync;
|
||||||
|
Timer? _refreshTimer;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadLastSync();
|
||||||
|
// Refresh the relative-time string every 30s so "5 min ago" rolls
|
||||||
|
// forward without the user having to interact with the banner.
|
||||||
|
_refreshTimer = Timer.periodic(
|
||||||
|
const Duration(seconds: 30),
|
||||||
|
(_) => _loadLastSync(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_refreshTimer?.cancel();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadLastSync() async {
|
||||||
|
if (!mounted) return;
|
||||||
|
try {
|
||||||
|
// Most-recent sync across all cached domains so the hint reads as
|
||||||
|
// "your data was current as of X" regardless of which screen the
|
||||||
|
// user is looking at.
|
||||||
|
final ts = await ref.read(fabledDatabaseProvider).getLatestSync();
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _lastSync = ts);
|
||||||
|
} catch (_) {
|
||||||
|
// Non-critical — banner just won't show the sync hint.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _retry() async {
|
||||||
|
if (_retrying) return;
|
||||||
|
setState(() => _retrying = true);
|
||||||
|
try {
|
||||||
|
await ref.read(authProvider.notifier).verify();
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _retrying = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _relativeAgo(DateTime ts) {
|
||||||
|
final diff = DateTime.now().difference(ts);
|
||||||
|
if (diff.isNegative) return null;
|
||||||
|
if (diff.inMinutes < 1) return 'just now';
|
||||||
|
if (diff.inMinutes < 60) return '${diff.inMinutes} min ago';
|
||||||
|
if (diff.inHours < 24) {
|
||||||
|
final h = diff.inHours;
|
||||||
|
return '$h ${h == 1 ? 'hour' : 'hours'} ago';
|
||||||
|
}
|
||||||
|
final d = diff.inDays;
|
||||||
|
return '$d ${d == 1 ? 'day' : 'days'} ago';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final status = ref.watch(authProvider);
|
||||||
|
if (status != AuthStatus.offline) return const SizedBox.shrink();
|
||||||
|
|
||||||
|
final scheme = Theme.of(context).colorScheme;
|
||||||
|
final ago = _lastSync == null ? null : _relativeAgo(_lastSync!);
|
||||||
|
// Pending queue depth — shown on the Retry button so the user knows
|
||||||
|
// there's offline work waiting to land when they come back online.
|
||||||
|
final pending = ref.watch(writeQueueDepthProvider).asData?.value ?? 0;
|
||||||
|
|
||||||
|
final baseMessage = ago == null
|
||||||
|
? 'Offline — showing cached data.'
|
||||||
|
: 'Offline — last sync $ago.';
|
||||||
|
final message = pending > 0
|
||||||
|
? '$baseMessage $pending pending change${pending == 1 ? '' : 's'}.'
|
||||||
|
: baseMessage;
|
||||||
|
final retryLabel = pending > 0 ? 'Retry ($pending)' : 'Retry';
|
||||||
|
|
||||||
|
return Material(
|
||||||
|
color: scheme.errorContainer,
|
||||||
|
child: SafeArea(
|
||||||
|
bottom: false,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(LucideIcons.cloudOff,
|
||||||
|
size: 18, color: scheme.onErrorContainer),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
message,
|
||||||
|
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),
|
||||||
|
)
|
||||||
|
: Text(retryLabel),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:lucide_icons/lucide_icons.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../data/local/database.dart';
|
||||||
|
import '../providers/api_client_provider.dart';
|
||||||
|
|
||||||
|
/// Small cloud-upload glyph shown next to a row whose id has a queued
|
||||||
|
/// offline write (Phase 4). Renders nothing when the queue is empty for
|
||||||
|
/// that id, so list views stay clean during normal online operation.
|
||||||
|
class PendingSyncBadge extends ConsumerWidget {
|
||||||
|
final String domain;
|
||||||
|
final int id;
|
||||||
|
final double size;
|
||||||
|
|
||||||
|
const PendingSyncBadge({
|
||||||
|
super.key,
|
||||||
|
required this.domain,
|
||||||
|
required this.id,
|
||||||
|
this.size = 14,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final pending = switch (domain) {
|
||||||
|
kSyncDomainNotes => ref.watch(pendingNoteIdsProvider).asData?.value,
|
||||||
|
kSyncDomainTasks => ref.watch(pendingTaskIdsProvider).asData?.value,
|
||||||
|
kSyncDomainProjects =>
|
||||||
|
ref.watch(pendingProjectIdsProvider).asData?.value,
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
if (pending == null || !pending.contains(id)) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
final scheme = Theme.of(context).colorScheme;
|
||||||
|
return Tooltip(
|
||||||
|
message: 'Pending sync — will save when online',
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||||
|
child: Icon(
|
||||||
|
LucideIcons.uploadCloud,
|
||||||
|
size: size,
|
||||||
|
color: scheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:lucide_icons/lucide_icons.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
import '../core/constants.dart';
|
import '../core/constants.dart';
|
||||||
@@ -45,24 +46,24 @@ const Map<String, String> _toolLabels = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
IconData _iconFor(String fn) {
|
IconData _iconFor(String fn) {
|
||||||
if (fn.contains('note')) return Icons.sticky_note_2_outlined;
|
if (fn.contains('note')) return LucideIcons.stickyNote;
|
||||||
if (fn.contains('task')) return Icons.check_circle_outline;
|
if (fn.contains('task')) return LucideIcons.checkCircle2;
|
||||||
if (fn.contains('event') || fn.contains('calendar')) {
|
if (fn.contains('event') || fn.contains('calendar')) {
|
||||||
return Icons.event_outlined;
|
return LucideIcons.calendarCheck;
|
||||||
}
|
}
|
||||||
if (fn.contains('project')) return Icons.folder_outlined;
|
if (fn.contains('project')) return LucideIcons.folder;
|
||||||
if (fn.contains('milestone')) return Icons.flag_outlined;
|
if (fn.contains('milestone')) return LucideIcons.flag;
|
||||||
if (fn.contains('web') || fn.contains('research') || fn.contains('article')) {
|
if (fn.contains('web') || fn.contains('research') || fn.contains('article')) {
|
||||||
return Icons.public;
|
return LucideIcons.globe;
|
||||||
}
|
}
|
||||||
if (fn.contains('image')) return Icons.image_outlined;
|
if (fn.contains('image')) return LucideIcons.image;
|
||||||
if (fn.contains('person') || fn.contains('profile')) {
|
if (fn.contains('person') || fn.contains('profile')) {
|
||||||
return Icons.person_outline;
|
return LucideIcons.user;
|
||||||
}
|
}
|
||||||
if (fn.contains('place')) return Icons.place_outlined;
|
if (fn.contains('place')) return LucideIcons.mapPin;
|
||||||
if (fn.contains('rag') || fn.contains('scope')) return Icons.tune;
|
if (fn.contains('rag') || fn.contains('scope')) return LucideIcons.sliders;
|
||||||
if (fn.contains('calculate')) return Icons.calculate_outlined;
|
if (fn.contains('calculate')) return LucideIcons.calculator;
|
||||||
return Icons.auto_awesome;
|
return LucideIcons.sparkles;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pull the destination route for this tool call from its `result` payload,
|
/// Pull the destination route for this tool call from its `result` payload,
|
||||||
@@ -161,7 +162,7 @@ class ToolCallChip extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
if (route != null) ...[
|
if (route != null) ...[
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
Icon(Icons.arrow_forward, size: 11, color: fg),
|
Icon(LucideIcons.arrowRight, size: 11, color: fg),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:lucide_icons/lucide_icons.dart';
|
||||||
|
|
||||||
import '../providers/voice_provider.dart';
|
import '../providers/voice_provider.dart';
|
||||||
|
|
||||||
@@ -48,8 +49,8 @@ class VoiceMicButton extends StatelessWidget {
|
|||||||
color: iconColor,
|
color: iconColor,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
VoiceMode.playing => Icon(Icons.volume_up, color: iconColor, size: 20),
|
VoiceMode.playing => Icon(LucideIcons.volume2, color: iconColor, size: 20),
|
||||||
_ => Icon(Icons.mic, color: iconColor, size: 20),
|
_ => Icon(LucideIcons.mic, color: iconColor, size: 20),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,185 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
|
|
||||||
class WeatherCard extends StatelessWidget {
|
|
||||||
final Map<String, dynamic>? weather;
|
|
||||||
|
|
||||||
const WeatherCard({super.key, required this.weather});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final scheme = Theme.of(context).colorScheme;
|
|
||||||
|
|
||||||
if (weather == null) {
|
|
||||||
return Container(
|
|
||||||
margin: const EdgeInsets.only(bottom: 12),
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: scheme.surfaceContainerHighest,
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
border: Border.all(color: scheme.outlineVariant),
|
|
||||||
),
|
|
||||||
child: Text(
|
|
||||||
'Weather data unavailable — will retry at next slot.',
|
|
||||||
style: TextStyle(
|
|
||||||
color: scheme.onSurfaceVariant,
|
|
||||||
fontStyle: FontStyle.italic,
|
|
||||||
fontSize: 13,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
final w = weather!;
|
|
||||||
final location = w['location'] as String? ?? '';
|
|
||||||
final currentTemp = w['current_temp'];
|
|
||||||
final condition = w['condition'] as String? ?? '';
|
|
||||||
final todayHigh = w['today_high'];
|
|
||||||
final todayLow = w['today_low'];
|
|
||||||
final yesterdayHigh = w['yesterday_high'];
|
|
||||||
final fetchedAt = w['fetched_at'] as String?;
|
|
||||||
final forecast = (w['forecast'] as List<dynamic>? ?? [])
|
|
||||||
.cast<Map<String, dynamic>>();
|
|
||||||
|
|
||||||
String? tempDelta;
|
|
||||||
if (todayHigh != null && yesterdayHigh != null) {
|
|
||||||
final diff = (todayHigh as num) - (yesterdayHigh as num);
|
|
||||||
if (diff.abs() < 1) {
|
|
||||||
tempDelta = 'Same as yesterday';
|
|
||||||
} else {
|
|
||||||
final dir = diff > 0 ? 'warmer' : 'cooler';
|
|
||||||
tempDelta = '${diff.abs().round()}° $dir than yesterday';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
String? fetchedLabel;
|
|
||||||
if (fetchedAt != null) {
|
|
||||||
try {
|
|
||||||
final dt = DateTime.parse(fetchedAt).toLocal();
|
|
||||||
final h = dt.hour % 12 == 0 ? 12 : dt.hour % 12;
|
|
||||||
final m = dt.minute.toString().padLeft(2, '0');
|
|
||||||
final period = dt.hour < 12 ? 'AM' : 'PM';
|
|
||||||
fetchedLabel = '$h:$m $period';
|
|
||||||
} catch (_) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
return Container(
|
|
||||||
margin: const EdgeInsets.only(bottom: 12),
|
|
||||||
padding: const EdgeInsets.all(14),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: scheme.surfaceContainerHighest,
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
border: Border.all(color: scheme.outlineVariant),
|
|
||||||
),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
// Header: location + fetched time
|
|
||||||
Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
location,
|
|
||||||
style: TextStyle(
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
fontSize: 14,
|
|
||||||
color: scheme.onSurface,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (fetchedLabel != null)
|
|
||||||
Text(
|
|
||||||
'as of $fetchedLabel',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 12,
|
|
||||||
color: scheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
// Current temp + condition
|
|
||||||
Row(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.baseline,
|
|
||||||
textBaseline: TextBaseline.alphabetic,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
'$currentTemp°',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 36,
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
color: scheme.onSurface,
|
|
||||||
height: 1,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 10),
|
|
||||||
Text(
|
|
||||||
condition,
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 14,
|
|
||||||
color: scheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
// Today high/low + delta
|
|
||||||
if (todayHigh != null) ...[
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Text(
|
|
||||||
'Today: $todayHigh° / $todayLow°'
|
|
||||||
'${tempDelta != null ? ' · $tempDelta' : ''}',
|
|
||||||
style: TextStyle(fontSize: 13, color: scheme.onSurfaceVariant),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
// Forecast strip
|
|
||||||
if (forecast.isNotEmpty) ...[
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
const Divider(height: 1),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
SingleChildScrollView(
|
|
||||||
scrollDirection: Axis.horizontal,
|
|
||||||
child: Row(
|
|
||||||
spacing: 8,
|
|
||||||
children: forecast.map((day) {
|
|
||||||
return SizedBox(
|
|
||||||
width: 64,
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
day['day'] as String? ?? '',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: scheme.onSurface,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 3),
|
|
||||||
Text(
|
|
||||||
day['condition'] as String? ?? '',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 11,
|
|
||||||
color: scheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
maxLines: 2,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 3),
|
|
||||||
Text(
|
|
||||||
'${day['high']}° / ${day['low']}°',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 12,
|
|
||||||
color: scheme.onSurface,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}).toList(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
#include <flutter_timezone/flutter_timezone_plugin.h>
|
#include <flutter_timezone/flutter_timezone_plugin.h>
|
||||||
#include <open_file_linux/open_file_linux_plugin.h>
|
#include <open_file_linux/open_file_linux_plugin.h>
|
||||||
#include <record_linux/record_linux_plugin.h>
|
#include <record_linux/record_linux_plugin.h>
|
||||||
|
#include <sqlite3_flutter_libs/sqlite3_flutter_libs_plugin.h>
|
||||||
#include <url_launcher_linux/url_launcher_plugin.h>
|
#include <url_launcher_linux/url_launcher_plugin.h>
|
||||||
|
|
||||||
void fl_register_plugins(FlPluginRegistry* registry) {
|
void fl_register_plugins(FlPluginRegistry* registry) {
|
||||||
@@ -21,6 +22,9 @@ void fl_register_plugins(FlPluginRegistry* registry) {
|
|||||||
g_autoptr(FlPluginRegistrar) record_linux_registrar =
|
g_autoptr(FlPluginRegistrar) record_linux_registrar =
|
||||||
fl_plugin_registry_get_registrar_for_plugin(registry, "RecordLinuxPlugin");
|
fl_plugin_registry_get_registrar_for_plugin(registry, "RecordLinuxPlugin");
|
||||||
record_linux_plugin_register_with_registrar(record_linux_registrar);
|
record_linux_plugin_register_with_registrar(record_linux_registrar);
|
||||||
|
g_autoptr(FlPluginRegistrar) sqlite3_flutter_libs_registrar =
|
||||||
|
fl_plugin_registry_get_registrar_for_plugin(registry, "Sqlite3FlutterLibsPlugin");
|
||||||
|
sqlite3_flutter_libs_plugin_register_with_registrar(sqlite3_flutter_libs_registrar);
|
||||||
g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar =
|
g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar =
|
||||||
fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
|
fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
|
||||||
url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);
|
url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);
|
||||||
|
|||||||
@@ -6,10 +6,12 @@ list(APPEND FLUTTER_PLUGIN_LIST
|
|||||||
flutter_timezone
|
flutter_timezone
|
||||||
open_file_linux
|
open_file_linux
|
||||||
record_linux
|
record_linux
|
||||||
|
sqlite3_flutter_libs
|
||||||
url_launcher_linux
|
url_launcher_linux
|
||||||
)
|
)
|
||||||
|
|
||||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||||
|
vad
|
||||||
)
|
)
|
||||||
|
|
||||||
set(PLUGIN_BUNDLED_LIBRARIES)
|
set(PLUGIN_BUNDLED_LIBRARIES)
|
||||||
|
|||||||
@@ -11,8 +11,9 @@ import flutter_timezone
|
|||||||
import just_audio
|
import just_audio
|
||||||
import open_file_mac
|
import open_file_mac
|
||||||
import package_info_plus
|
import package_info_plus
|
||||||
import record_darwin
|
import record_macos
|
||||||
import shared_preferences_foundation
|
import shared_preferences_foundation
|
||||||
|
import sqlite3_flutter_libs
|
||||||
import url_launcher_macos
|
import url_launcher_macos
|
||||||
|
|
||||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||||
@@ -22,7 +23,8 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
|||||||
JustAudioPlugin.register(with: registry.registrar(forPlugin: "JustAudioPlugin"))
|
JustAudioPlugin.register(with: registry.registrar(forPlugin: "JustAudioPlugin"))
|
||||||
OpenFilePlugin.register(with: registry.registrar(forPlugin: "OpenFilePlugin"))
|
OpenFilePlugin.register(with: registry.registrar(forPlugin: "OpenFilePlugin"))
|
||||||
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
|
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"))
|
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||||
|
Sqlite3FlutterLibsPlugin.register(with: registry.registrar(forPlugin: "Sqlite3FlutterLibsPlugin"))
|
||||||
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
|
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
|
||||||
}
|
}
|
||||||
|
|||||||
+176
-8
@@ -57,6 +57,54 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.2"
|
version: "2.1.2"
|
||||||
|
build:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: build
|
||||||
|
sha256: a156715e7cd728130c592f30552575908aae5b100005fbc1f0fb16b3c03a3d10
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.0.6"
|
||||||
|
build_config:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: build_config
|
||||||
|
sha256: "4070d2a59f8eec34c97c86ceb44403834899075f66e8a9d59706f8e7834f6f71"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.3.0"
|
||||||
|
build_daemon:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: build_daemon
|
||||||
|
sha256: bf05f6e12cfea92d3c09308d7bcdab1906cd8a179b023269eed00c071004b957
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.1.1"
|
||||||
|
build_runner:
|
||||||
|
dependency: "direct dev"
|
||||||
|
description:
|
||||||
|
name: build_runner
|
||||||
|
sha256: "22fdcc3cfeb9d974d7408718c4be15ec5e9b1b350088f3a6c88f154e74dd700d"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.14.1"
|
||||||
|
built_collection:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: built_collection
|
||||||
|
sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "5.1.1"
|
||||||
|
built_value:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: built_value
|
||||||
|
sha256: "0730c18c770d05636a8f945c32a4d7d81cb6e0f0148c8db4ad12e7748f7e49af"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "8.12.5"
|
||||||
characters:
|
characters:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -65,6 +113,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.4.1"
|
version: "1.4.1"
|
||||||
|
charcode:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: charcode
|
||||||
|
sha256: fb0f1107cac15a5ea6ef0a6ef71a807b9e4267c713bb93e00e92d737cc8dbd8a
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.4.0"
|
||||||
checked_yaml:
|
checked_yaml:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -153,6 +209,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.8"
|
version: "1.0.8"
|
||||||
|
dart_style:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: dart_style
|
||||||
|
sha256: "29f7ecc274a86d32920b1d9cfc7502fa87220da41ec60b55f329559d5732e2b2"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.1.7"
|
||||||
dio:
|
dio:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -177,6 +241,22 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.2"
|
version: "2.1.2"
|
||||||
|
drift:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: drift
|
||||||
|
sha256: "055c249d1f91be5a47fe447f88afc24c4ca6f4cd6c5ed66767b4797d48acc2e5"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.32.1"
|
||||||
|
drift_dev:
|
||||||
|
dependency: "direct dev"
|
||||||
|
description:
|
||||||
|
name: drift_dev
|
||||||
|
sha256: "88a9de3af8571518148a6d8a513b57779fd1e60a026d3ab8a481a878fba01d91"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.32.1"
|
||||||
equatable:
|
equatable:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -368,6 +448,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "8.0.2"
|
version: "8.0.2"
|
||||||
|
graphs:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: graphs
|
||||||
|
sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.3.2"
|
||||||
hooks:
|
hooks:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -496,6 +584,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.3.0"
|
version: "1.3.0"
|
||||||
|
lucide_icons:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: lucide_icons
|
||||||
|
sha256: ad24d0fd65707e48add30bebada7d90bff2a1bba0a72d6e9b19d44246b0e83c4
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.257.0"
|
||||||
markdown:
|
markdown:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -649,7 +745,7 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "3.2.1"
|
version: "3.2.1"
|
||||||
path:
|
path:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: path
|
name: path
|
||||||
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
|
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
|
||||||
@@ -800,14 +896,30 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.2.0"
|
version: "2.2.0"
|
||||||
|
pubspec_parse:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: pubspec_parse
|
||||||
|
sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.5.0"
|
||||||
|
recase:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: recase
|
||||||
|
sha256: e4eb4ec2dcdee52dcf99cb4ceabaffc631d7424ee55e56f280bc039737f89213
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.1.0"
|
||||||
record:
|
record:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: record
|
name: record
|
||||||
sha256: "2e3d56d196abcd69f1046339b75e5f3855b2406fc087e5991f6703f188aa03a6"
|
sha256: d5b6b334f3ab02460db6544e08583c942dbf23e3504bf1e14fd4cbe3d9409277
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "5.2.1"
|
version: "6.2.0"
|
||||||
record_android:
|
record_android:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -816,22 +928,30 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.5.1"
|
version: "1.5.1"
|
||||||
record_darwin:
|
record_ios:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: record_darwin
|
name: record_ios
|
||||||
sha256: e487eccb19d82a9a39cd0126945cfc47b9986e0df211734e2788c95e3f63c82c
|
sha256: "8df7c136131bd05efc19256af29b2ba6ccc000ccc2c80d4b6b6d7a8d21a3b5a9"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.2.2"
|
version: "1.2.0"
|
||||||
record_linux:
|
record_linux:
|
||||||
dependency: "direct overridden"
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: record_linux
|
name: record_linux
|
||||||
sha256: c31a35cc158cd666fc6395f7f56fc054f31685571684be6b97670a27649ce5c7
|
sha256: c31a35cc158cd666fc6395f7f56fc054f31685571684be6b97670a27649ce5c7
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.3.0"
|
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:
|
record_platform_interface:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -973,6 +1093,14 @@ packages:
|
|||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
version: "0.0.0"
|
||||||
|
source_gen:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: source_gen
|
||||||
|
sha256: ec37cc0e6694374cbef59ed79685572c870a54ede6fa30a3e420feb3adffea02
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.2.3"
|
||||||
source_map_stack_trace:
|
source_map_stack_trace:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -997,6 +1125,30 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.10.2"
|
version: "1.10.2"
|
||||||
|
sqlite3:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: sqlite3
|
||||||
|
sha256: "56da3e13ed7d28a66f930aa2b2b29db6736a233f08283326e96321dd812030f5"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.3.1"
|
||||||
|
sqlite3_flutter_libs:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: sqlite3_flutter_libs
|
||||||
|
sha256: eeb9e3a45207649076b808f8a5a74d68770d0b7f26ccef6d5f43106eee5375ad
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.5.42"
|
||||||
|
sqlparser:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: sqlparser
|
||||||
|
sha256: ab2b467425f1d4f3acfa5fd11a08226f7d6c26ff102c06be1807e1dff34e050b
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.44.3"
|
||||||
stack_trace:
|
stack_trace:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -1021,6 +1173,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.4"
|
version: "2.1.4"
|
||||||
|
stream_transform:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: stream_transform
|
||||||
|
sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.1.1"
|
||||||
string_scanner:
|
string_scanner:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -1165,6 +1325,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.5.3"
|
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:
|
vector_math:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
+11
-3
@@ -12,6 +12,7 @@ dependencies:
|
|||||||
sdk: flutter
|
sdk: flutter
|
||||||
|
|
||||||
cupertino_icons: ^1.0.8
|
cupertino_icons: ^1.0.8
|
||||||
|
lucide_icons: ^0.257.0
|
||||||
flutter_riverpod: ^3.3.1
|
flutter_riverpod: ^3.3.1
|
||||||
go_router: ^17.1.0
|
go_router: ^17.1.0
|
||||||
dio: ^5.6.0
|
dio: ^5.6.0
|
||||||
@@ -28,18 +29,25 @@ dependencies:
|
|||||||
google_fonts: ^8.0.2
|
google_fonts: ^8.0.2
|
||||||
flutter_timezone: ^5.0.2
|
flutter_timezone: ^5.0.2
|
||||||
url_launcher: ^6.3.1
|
url_launcher: ^6.3.1
|
||||||
record: ^5.0.0
|
record: ^6.2.0
|
||||||
|
vad: ^0.0.7
|
||||||
just_audio: ^0.9.39
|
just_audio: ^0.9.39
|
||||||
table_calendar: ^3.1.2
|
table_calendar: ^3.1.2
|
||||||
|
|
||||||
dependency_overrides:
|
# Tier 2 offline mode — local SQL store via Drift (sqlite3 under the hood).
|
||||||
record_linux: ^1.3.0
|
# Drift was preferred over Hive in the design since the repo already mirrors
|
||||||
|
# well-defined backend schemas (notes/tasks/projects/etc.) one-to-one.
|
||||||
|
drift: ^2.20.0
|
||||||
|
sqlite3_flutter_libs: ^0.5.24
|
||||||
|
path: ^1.9.0
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
flutter_lints: ^6.0.0
|
flutter_lints: ^6.0.0
|
||||||
flutter_launcher_icons: ^0.14.3
|
flutter_launcher_icons: ^0.14.3
|
||||||
|
drift_dev: ^2.20.0
|
||||||
|
build_runner: ^2.4.13
|
||||||
|
|
||||||
flutter_launcher_icons:
|
flutter_launcher_icons:
|
||||||
android: true
|
android: true
|
||||||
|
|||||||
+29
-65
@@ -1,10 +1,9 @@
|
|||||||
import 'package:fabled_app/data/models/calendar_event.dart';
|
import 'package:fabled_app/data/models/calendar_event.dart';
|
||||||
import 'package:fabled_app/data/api/voice_api.dart';
|
import 'package:fabled_app/data/api/voice_api.dart';
|
||||||
|
import 'package:fabled_app/data/repositories/write_queue.dart';
|
||||||
import 'package:fabled_app/providers/voice_provider.dart';
|
import 'package:fabled_app/providers/voice_provider.dart';
|
||||||
import 'package:fabled_app/data/models/knowledge_item.dart';
|
import 'package:fabled_app/data/models/knowledge_item.dart';
|
||||||
import 'package:fabled_app/data/models/note.dart';
|
import 'package:fabled_app/data/models/note.dart';
|
||||||
import 'package:fabled_app/data/models/news_item.dart';
|
|
||||||
import 'package:fabled_app/data/models/briefing_feed.dart';
|
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
@@ -150,47 +149,6 @@ void main() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
group('NewsItem.fromJson', () {
|
|
||||||
test('parses all fields', () {
|
|
||||||
final json = {
|
|
||||||
'id': 42,
|
|
||||||
'title': 'Big news',
|
|
||||||
'url': 'https://example.com/article',
|
|
||||||
'snippet': 'A short summary.',
|
|
||||||
'source': 'Example News',
|
|
||||||
'published_at': '2026-01-15T10:00:00',
|
|
||||||
'topics': ['tech', 'ai'],
|
|
||||||
'reaction': 'up',
|
|
||||||
};
|
|
||||||
final item = NewsItem.fromJson(json);
|
|
||||||
expect(item.id, equals(42));
|
|
||||||
expect(item.title, equals('Big news'));
|
|
||||||
expect(item.url, equals('https://example.com/article'));
|
|
||||||
expect(item.snippet, equals('A short summary.'));
|
|
||||||
expect(item.source, equals('Example News'));
|
|
||||||
expect(item.publishedAt, equals(DateTime.parse('2026-01-15T10:00:00')));
|
|
||||||
expect(item.topics, equals(['tech', 'ai']));
|
|
||||||
expect(item.reaction, equals('up'));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('handles null published_at and reaction', () {
|
|
||||||
final json = {
|
|
||||||
'id': 1,
|
|
||||||
'title': '',
|
|
||||||
'url': '',
|
|
||||||
'snippet': '',
|
|
||||||
'source': '',
|
|
||||||
'published_at': null,
|
|
||||||
'topics': <dynamic>[],
|
|
||||||
'reaction': null,
|
|
||||||
};
|
|
||||||
final item = NewsItem.fromJson(json);
|
|
||||||
expect(item.publishedAt, isNull);
|
|
||||||
expect(item.reaction, isNull);
|
|
||||||
expect(item.topics, isEmpty);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
group('CalendarEvent.fromJson', () {
|
group('CalendarEvent.fromJson', () {
|
||||||
test('parses all fields', () {
|
test('parses all fields', () {
|
||||||
final json = {
|
final json = {
|
||||||
@@ -250,30 +208,36 @@ void main() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
group('BriefingFeed.fromJson', () {
|
group('QueueFailure.message', () {
|
||||||
test('parses all fields', () {
|
test('overwritten with title quotes the title', () {
|
||||||
final json = {
|
final f = QueueFailure(
|
||||||
'id': 7,
|
reason: QueueFailureReason.overwritten,
|
||||||
'title': 'Hacker News',
|
domain: 'notes',
|
||||||
'url': 'https://news.ycombinator.com/rss',
|
title: 'Grocery list',
|
||||||
'category': 'tech',
|
);
|
||||||
};
|
expect(f.message, contains('"Grocery list"'));
|
||||||
final feed = BriefingFeed.fromJson(json);
|
expect(f.message, contains('overwritten'));
|
||||||
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', () {
|
test('rejected without title falls back to generic phrasing', () {
|
||||||
final json = {
|
final f = QueueFailure(
|
||||||
'id': 8,
|
reason: QueueFailureReason.rejected,
|
||||||
'title': 'Feed',
|
domain: 'tasks',
|
||||||
'url': 'https://example.com/rss',
|
detail: 'title required',
|
||||||
'category': null,
|
);
|
||||||
};
|
expect(f.message, contains('an offline edit'));
|
||||||
final feed = BriefingFeed.fromJson(json);
|
expect(f.message, contains('title required'));
|
||||||
expect(feed.category, isNull);
|
});
|
||||||
|
|
||||||
|
test('missing communicates server-side deletion', () {
|
||||||
|
final f = QueueFailure(
|
||||||
|
reason: QueueFailureReason.missing,
|
||||||
|
domain: 'projects',
|
||||||
|
title: 'Q2 launch',
|
||||||
|
);
|
||||||
|
expect(f.message, contains('"Q2 launch"'));
|
||||||
|
expect(f.message, contains('deleted on the server'));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
#include <flutter_timezone/flutter_timezone_plugin_c_api.h>
|
#include <flutter_timezone/flutter_timezone_plugin_c_api.h>
|
||||||
#include <permission_handler_windows/permission_handler_windows_plugin.h>
|
#include <permission_handler_windows/permission_handler_windows_plugin.h>
|
||||||
#include <record_windows/record_windows_plugin_c_api.h>
|
#include <record_windows/record_windows_plugin_c_api.h>
|
||||||
|
#include <sqlite3_flutter_libs/sqlite3_flutter_libs_plugin.h>
|
||||||
#include <url_launcher_windows/url_launcher_windows.h>
|
#include <url_launcher_windows/url_launcher_windows.h>
|
||||||
|
|
||||||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||||
@@ -21,6 +22,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
|
|||||||
registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin"));
|
registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin"));
|
||||||
RecordWindowsPluginCApiRegisterWithRegistrar(
|
RecordWindowsPluginCApiRegisterWithRegistrar(
|
||||||
registry->GetRegistrarForPlugin("RecordWindowsPluginCApi"));
|
registry->GetRegistrarForPlugin("RecordWindowsPluginCApi"));
|
||||||
|
Sqlite3FlutterLibsPluginRegisterWithRegistrar(
|
||||||
|
registry->GetRegistrarForPlugin("Sqlite3FlutterLibsPlugin"));
|
||||||
UrlLauncherWindowsRegisterWithRegistrar(
|
UrlLauncherWindowsRegisterWithRegistrar(
|
||||||
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
|
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,10 +7,12 @@ list(APPEND FLUTTER_PLUGIN_LIST
|
|||||||
flutter_timezone
|
flutter_timezone
|
||||||
permission_handler_windows
|
permission_handler_windows
|
||||||
record_windows
|
record_windows
|
||||||
|
sqlite3_flutter_libs
|
||||||
url_launcher_windows
|
url_launcher_windows
|
||||||
)
|
)
|
||||||
|
|
||||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||||
|
vad
|
||||||
)
|
)
|
||||||
|
|
||||||
set(PLUGIN_BUNDLED_LIBRARIES)
|
set(PLUGIN_BUNDLED_LIBRARIES)
|
||||||
|
|||||||
Reference in New Issue
Block a user