This repository has been archived on 2026-06-02. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
FabledApp/lib/app.dart
T

694 lines
22 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:flutter_timezone/flutter_timezone.dart';
import 'core/constants.dart';
import 'core/theme.dart';
import 'providers/api_client_provider.dart';
import 'providers/auth_provider.dart';
import 'core/exceptions.dart';
import 'providers/capture_queue_provider.dart';
import 'providers/capture_work_queue_provider.dart';
import 'providers/briefing_provider.dart';
import 'providers/calendar_provider.dart';
import 'providers/chat_provider.dart';
import 'providers/knowledge_provider.dart';
import 'providers/news_provider.dart';
import 'providers/settings_provider.dart';
import 'providers/update_provider.dart';
import 'screens/auth/login_screen.dart';
import 'screens/briefing/briefing_screen.dart';
import 'screens/knowledge/knowledge_screen.dart';
import 'screens/library/project_tasks_screen.dart';
import 'screens/chat/chat_screen.dart';
import 'screens/chat/conversations_tab_screen.dart';
import 'screens/notes/note_detail_screen.dart';
import 'screens/projects/project_edit_screen.dart';
import 'screens/projects/projects_screen.dart';
import 'screens/notes/note_edit_screen.dart';
import 'screens/settings/settings_screen.dart';
import 'screens/news/news_screen.dart';
import 'screens/setup/setup_screen.dart';
import 'screens/splash/splash_screen.dart';
import 'screens/tasks/task_edit_screen.dart';
import 'screens/calendar/calendar_screen.dart';
import 'providers/voice_provider.dart';
import 'widgets/voice_mic_button.dart';
// ChangeNotifier that fires when auth or server URL changes,
// used as GoRouter.refreshListenable so the router re-evaluates redirects
// without being recreated.
class _RouterNotifier extends ChangeNotifier {
_RouterNotifier(Ref ref) {
ref.listen(authProvider, (_, _) => notifyListeners());
ref.listen(serverUrlProvider, (_, _) => notifyListeners());
}
}
final routerProvider = Provider<GoRouter>((ref) {
final notifier = _RouterNotifier(ref);
return GoRouter(
refreshListenable: notifier,
initialLocation: Routes.splash,
redirect: (context, state) {
final location = state.matchedLocation;
final serverUrl = ref.read(serverUrlProvider);
final authStatus = ref.read(authProvider);
if (serverUrl == null || serverUrl.isEmpty) {
if (location != Routes.setup) return Routes.setup;
return null;
}
if (authStatus == AuthStatus.unauthenticated) {
if (location != Routes.login && location != Routes.setup) {
return Routes.login;
}
return null;
}
return null;
},
routes: [
GoRoute(
path: Routes.splash,
builder: (_, _) => const SplashScreen(),
),
GoRoute(
path: Routes.setup,
builder: (_, _) => const SetupScreen(),
),
GoRoute(
path: Routes.login,
builder: (_, _) => const LoginScreen(),
),
GoRoute(
path: Routes.settings,
builder: (_, _) => const SettingsScreen(),
),
GoRoute(
path: Routes.noteNew,
builder: (_, state) {
final extra = state.extra as Map<String, dynamic>?;
return NoteEditScreen(noteType: extra?['noteType'] as String?);
},
),
GoRoute(
path: Routes.noteDetail,
builder: (_, state) => NoteDetailScreen(
noteId: int.parse(state.pathParameters['id']!),
),
),
GoRoute(
path: Routes.noteEdit,
builder: (_, state) => NoteEditScreen(
noteId: int.parse(state.pathParameters['id']!),
),
),
GoRoute(
path: Routes.taskNew,
builder: (_, state) => TaskEditScreen(
initialProjectId: state.uri.queryParameters['projectId'] != null
? int.tryParse(state.uri.queryParameters['projectId']!)
: null,
),
),
GoRoute(
path: Routes.taskEdit,
builder: (_, state) => TaskEditScreen(
taskId: int.parse(state.pathParameters['id']!),
),
),
GoRoute(
path: Routes.projectTasks,
builder: (_, state) => ProjectTasksScreen(
projectId: int.parse(state.pathParameters['id']!),
),
),
GoRoute(
path: '/projects/new',
builder: (_, _) => const ProjectEditScreen(),
),
GoRoute(
path: Routes.projectEdit,
builder: (_, state) => ProjectEditScreen(
projectId: int.parse(state.pathParameters['id']!),
),
),
GoRoute(
path: Routes.chat,
builder: (_, state) => ChatScreen(
conversationId: int.parse(state.pathParameters['id']!),
),
),
ShellRoute(
builder: (context, state, child) => _Shell(child: child),
routes: [
GoRoute(
path: Routes.briefing,
builder: (_, _) => const BriefingScreen(),
),
GoRoute(
path: Routes.knowledge,
builder: (_, _) => const KnowledgeScreen(),
),
GoRoute(
path: Routes.conversations,
builder: (_, _) => const ConversationsTabScreen(),
),
],
),
GoRoute(
path: Routes.projects,
builder: (_, _) => const ProjectsScreen(),
),
GoRoute(
path: Routes.news,
builder: (_, _) => const NewsScreen(),
),
GoRoute(
path: Routes.calendar,
builder: (_, _) => const CalendarScreen(),
),
],
);
});
class _Shell extends ConsumerStatefulWidget {
final Widget child;
const _Shell({required this.child});
@override
ConsumerState<_Shell> createState() => _ShellState();
}
class _ShellState extends ConsumerState<_Shell> with WidgetsBindingObserver {
static const _tabs = [
Routes.briefing,
Routes.knowledge,
Routes.conversations,
];
// Minimum gap between app-resume refreshes to avoid hammering the server.
static const _resumeCooldown = Duration(seconds: 30);
DateTime? _lastResumeRefresh;
int? _prevTabIndex;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
WidgetsBinding.instance.addPostFrameCallback((_) {
// Silent update check — only if we haven't already checked this session.
final repoUrl = ref.read(forgejoRepoUrlProvider);
if (repoUrl != null && repoUrl.isNotEmpty) {
final status = ref.read(updateProvider).status;
if (status == UpdateStatus.idle || status == UpdateStatus.error) {
ref.read(updateProvider.notifier).check(repoUrl);
}
}
// Sync device timezone to backend so briefing and chat use local time.
_syncTimezone();
});
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state != AppLifecycleState.resumed) return;
final now = DateTime.now();
if (_lastResumeRefresh != null &&
now.difference(_lastResumeRefresh!) < _resumeCooldown) {
return;
}
_lastResumeRefresh = now;
_refreshAll();
}
/// Refresh every major data provider. Safe to call speculatively —
/// providers that aren't currently watched are already disposed.
void _refreshAll() {
ref.invalidate(conversationsProvider);
ref.invalidate(calendarProvider);
ref.invalidate(newsProvider);
// Notifier (not AsyncNotifier) — needs explicit refresh call.
ref.read(knowledgeProvider.notifier).refresh();
// briefingProvider is an AsyncNotifier family; invalidating the family
// is safe even if no conversation is open.
ref.invalidate(briefingProvider);
}
/// Refresh only the provider backing the given shell tab index.
void _refreshTab(int index) {
switch (index) {
case 0:
ref.invalidate(briefingProvider);
case 1:
ref.read(knowledgeProvider.notifier).refresh();
case 2:
ref.invalidate(conversationsProvider);
}
}
Future<void> _syncTimezone() async {
try {
final tzInfo = await FlutterTimezone.getLocalTimezone();
await ref.read(settingsApiProvider).syncTimezone(tzInfo.identifier);
} catch (_) {
// Best-effort — failure is non-critical.
}
}
int _tabIndex(String location) {
for (var i = 0; i < _tabs.length; i++) {
if (location.startsWith(_tabs[i])) return i;
}
if (location.startsWith(Routes.projects) ||
location.startsWith(Routes.news) ||
location.startsWith(Routes.calendar)) {
return 3;
}
return 0;
}
void _showMoreSheet(BuildContext context) {
showModalBottomSheet<void>(
context: context,
builder: (_) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.folder_outlined),
title: const Text('Projects'),
onTap: () {
Navigator.pop(context);
context.push(Routes.projects);
},
),
ListTile(
leading: const Icon(Icons.newspaper_outlined),
title: const Text('News'),
onTap: () {
Navigator.pop(context);
context.push(Routes.news);
},
),
ListTile(
leading: const Icon(Icons.calendar_month_outlined),
title: const Text('Calendar'),
onTap: () {
Navigator.pop(context);
context.push(Routes.calendar);
},
),
],
),
),
);
}
void _showUpdateDialog(UpdateState update) {
showDialog<void>(
context: context,
builder: (dialogContext) => Consumer(
builder: (context, ref, _) {
final state = ref.watch(updateProvider);
final isDownloading = state.status == UpdateStatus.downloading;
return AlertDialog(
title: const Text('Update available'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Version ${state.latestVersion ?? '?'} is ready to install.'),
if (state.currentVersion != null)
Text(
'Installed: v${state.currentVersion}',
style: Theme.of(context).textTheme.bodySmall,
),
if (isDownloading) ...[
const SizedBox(height: 16),
LinearProgressIndicator(
value: state.downloadProgress > 0
? state.downloadProgress
: null,
),
const SizedBox(height: 4),
Text(
'Downloading… '
'${(state.downloadProgress * 100).toStringAsFixed(0)}%',
style: Theme.of(context).textTheme.bodySmall,
),
],
if (state.status == UpdateStatus.error &&
state.errorMessage != null) ...[
const SizedBox(height: 12),
Text(
state.errorMessage!,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.error,
),
),
],
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext),
child: const Text('Later'),
),
if (!isDownloading && state.downloadUrl != null)
FilledButton(
onPressed: () => ref
.read(updateProvider.notifier)
.downloadAndInstall(),
child: const Text('Download & Install'),
),
],
);
},
),
);
}
@override
Widget build(BuildContext context) {
// Show update dialog once when a new version is detected.
ref.listen(updateProvider, (prev, next) {
if (next.status == UpdateStatus.available &&
prev?.status != UpdateStatus.available) {
WidgetsBinding.instance
.addPostFrameCallback((_) => _showUpdateDialog(next));
}
});
final location = GoRouterState.of(context).matchedLocation;
final index = _tabIndex(location);
// Refresh the incoming tab's data when switching between shell tabs.
if (_prevTabIndex != null && _prevTabIndex != index && index < 3) {
WidgetsBinding.instance.addPostFrameCallback((_) => _refreshTab(index));
}
_prevTabIndex = index;
final child = widget.child;
final isWide = MediaQuery.of(context).size.width >= 600;
if (isWide) {
return Scaffold(
body: SafeArea(
child: Column(
children: [
const _QuickCaptureBar(),
Expanded(
child: Row(
children: [
NavigationRail(
selectedIndex: index,
onDestinationSelected: (i) {
if (i == 3) {
_showMoreSheet(context);
} else {
context.go(_tabs[i]);
}
},
labelType: NavigationRailLabelType.all,
destinations: const [
NavigationRailDestination(
icon: Icon(Icons.wb_sunny_outlined),
selectedIcon: Icon(Icons.wb_sunny),
label: Text('Briefing'),
),
NavigationRailDestination(
icon: Icon(Icons.menu_book_outlined),
selectedIcon: Icon(Icons.menu_book),
label: Text('Knowledge'),
),
NavigationRailDestination(
icon: Icon(Icons.chat_bubble_outline),
selectedIcon: Icon(Icons.chat_bubble),
label: Text('Chat'),
),
NavigationRailDestination(
icon: Icon(Icons.more_horiz_outlined),
selectedIcon: Icon(Icons.more_horiz),
label: Text('More'),
),
],
),
const VerticalDivider(width: 1),
Expanded(child: child),
],
),
),
],
),
),
);
}
return Scaffold(
body: Column(
children: [
const _QuickCaptureBar(),
Expanded(
child: MediaQuery.removePadding(
context: context,
removeTop: true,
child: child,
),
),
],
),
bottomNavigationBar: NavigationBar(
selectedIndex: index,
onDestinationSelected: (i) {
if (i == 3) {
_showMoreSheet(context);
} else {
context.go(_tabs[i]);
}
},
destinations: const [
NavigationDestination(
icon: Icon(Icons.wb_sunny_outlined),
selectedIcon: Icon(Icons.wb_sunny),
label: 'Briefing',
),
NavigationDestination(
icon: Icon(Icons.menu_book_outlined),
selectedIcon: Icon(Icons.menu_book),
label: 'Knowledge',
),
NavigationDestination(
icon: Icon(Icons.chat_bubble_outline),
selectedIcon: Icon(Icons.chat_bubble),
label: 'Chat',
),
NavigationDestination(
icon: Icon(Icons.more_horiz_outlined),
selectedIcon: Icon(Icons.more_horiz),
label: 'More',
),
],
),
);
}
}
class _QuickCaptureBar extends ConsumerStatefulWidget {
const _QuickCaptureBar();
@override
ConsumerState<_QuickCaptureBar> createState() => _QuickCaptureBarState();
}
class _QuickCaptureBarState extends ConsumerState<_QuickCaptureBar> {
final _controller = TextEditingController();
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) => _drainOfflineQueue());
}
@override
void dispose() {
_controller.dispose();
ref.read(voiceProvider.notifier).exitVoiceMode();
super.dispose();
}
void _submit() {
final text = _controller.text.trim();
if (text.isEmpty) return;
_controller.clear();
setState(() {}); // clear suffix icon
ref.read(captureWorkQueueProvider.notifier).enqueue(text);
}
Future<void> _drainOfflineQueue() async {
if (!mounted) return;
final queue = ref.read(captureQueueProvider);
if (queue.isEmpty) return;
for (final text in List<String>.from(queue)) {
if (!mounted) break;
try {
final conv =
await ref.read(conversationsProvider.notifier).create('');
final chatRepo = ref.read(chatRepositoryProvider);
await chatRepo.sendMessage(conv.id, text);
chatRepo.streamGeneration(conv.id).drain<void>().ignore();
await ref.read(captureQueueProvider.notifier).dequeue(text);
} on NetworkException {
break;
} catch (_) {
// Server error — drop from queue to prevent ghost items.
await ref.read(captureQueueProvider.notifier).dequeue(text);
}
}
}
Future<void> _toggleCaptureMic() async {
final voice = ref.read(voiceProvider);
if (voice.voiceModeActive) {
ref.read(voiceProvider.notifier).exitVoiceMode();
return;
}
await ref.read(voiceProvider.notifier).enterVoiceMode(
onTranscript: (transcript) async {
ref.read(captureWorkQueueProvider.notifier).enqueue(transcript);
},
enableTts: false,
onError: (msg) {
if (!mounted) return;
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text(msg)));
},
);
}
String _hintForLocation(String location) {
if (location.startsWith(Routes.knowledge)) return 'Capture a note…';
if (location.startsWith(Routes.projects)) return 'Capture a note…';
if (location.startsWith(Routes.conversations)) return 'Ask Fabled…';
return 'Capture a note…';
}
@override
Widget build(BuildContext context) {
final location = GoRouterState.of(context).matchedLocation;
final offlineQueueCount = ref.watch(captureQueueProvider).length;
final workQueue = ref.watch(captureWorkQueueProvider);
final isWorking = workQueue.isNotEmpty;
final totalPending = workQueue.length + offlineQueueCount;
// Show snackbar when a result is published.
ref.listen(captureResultProvider, (_, result) {
if (result == null || !mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(result.message),
behavior: SnackBarBehavior.floating,
),
);
});
return SafeArea(
bottom: false,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(12, 8, 4, 4),
child: Row(
children: [
Expanded(
child: TextField(
controller: _controller,
textInputAction: TextInputAction.send,
onSubmitted: (_) => _submit(),
onChanged: (_) => setState(() {}),
decoration: InputDecoration(
hintText: ref.watch(voiceProvider).voiceModeActive
? 'Listening…'
: _hintForLocation(location),
isDense: true,
contentPadding: const EdgeInsets.symmetric(
horizontal: 14, vertical: 10),
prefixIcon: totalPending > 0
? Badge(
label: Text('$totalPending'),
child: const Icon(Icons.cloud_upload_outlined),
)
: isWorking
? const Padding(
padding: EdgeInsets.all(12),
child: SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2),
),
)
: const Icon(Icons.auto_awesome_outlined),
suffixIcon: _controller.text.trim().isNotEmpty
? IconButton(
icon: const Icon(Icons.send),
onPressed: _submit,
tooltip: 'Capture',
)
: null,
),
),
),
VoiceMicButton(
mode: ref.watch(voiceProvider).mode,
voiceModeActive: ref.watch(voiceProvider).voiceModeActive,
onTap: _toggleCaptureMic,
),
IconButton(
icon: const Icon(Icons.settings_outlined),
tooltip: 'Settings',
onPressed: () => context.push(Routes.settings),
),
],
),
),
// Thin progress bar while the work queue is draining.
if (isWorking)
const LinearProgressIndicator(minHeight: 2)
else
const SizedBox(height: 2),
],
),
);
}
}
class FabledApp extends ConsumerWidget {
const FabledApp({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final router = ref.watch(routerProvider);
final themeMode = ref.watch(themeModeProvider);
return MaterialApp.router(
title: 'Fabled',
themeMode: themeMode,
theme: fabledLightTheme(),
darkTheme: fabledDarkTheme(),
routerConfig: router,
);
}
}