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((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?; 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 _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( 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( 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 _drainOfflineQueue() async { if (!mounted) return; final queue = ref.read(captureQueueProvider); if (queue.isEmpty) return; for (final text in List.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().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 _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, ); } }