import 'package:flutter/material.dart'; import 'package:lucide_icons/lucide_icons.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 'data/repositories/write_queue.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/calendar_provider.dart'; import 'providers/chat_provider.dart'; import 'providers/journal_provider.dart'; import 'providers/knowledge_provider.dart'; import 'providers/settings_provider.dart'; import 'providers/update_provider.dart'; import 'screens/auth/login_screen.dart'; import 'screens/journal/journal_screen.dart'; import 'screens/knowledge/knowledge_screen.dart'; import 'screens/library/project_tasks_screen.dart'; import 'screens/chat/chat_screen.dart'; 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/setup/setup_screen.dart'; import 'screens/splash/splash_screen.dart'; import 'screens/tasks/task_edit_screen.dart'; import 'screens/calendar/calendar_screen.dart'; import 'providers/voice_provider.dart'; import 'widgets/offline_banner.dart'; import 'widgets/voice_mic_button.dart'; // ChangeNotifier that fires when auth or server URL changes, // 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()); ref.listen(hasEverLoggedInProvider, (_, _) => 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); final hasEverLoggedIn = ref.read(hasEverLoggedInProvider); 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; } // Offline and never logged in on this device — can't prove identity, // so gate behind login. Offline + ever-logged-in falls through to // normal navigation with the offline banner surfacing in _Shell. if (authStatus == AuthStatus.offline && !hasEverLoggedIn) { if (location != Routes.login && location != Routes.setup) { return Routes.login; } return null; } return null; }, routes: [ 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.journal, builder: (_, _) => const JournalScreen(), ), GoRoute( path: Routes.knowledge, builder: (_, _) => const KnowledgeScreen(), ), GoRoute( path: Routes.conversations, builder: (_, _) => const ConversationsTabScreen(), ), GoRoute( path: Routes.projects, builder: (_, _) => const ProjectsScreen(), ), 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 _baseTabs = [ Routes.journal, Routes.knowledge, Routes.conversations, Routes.projects, ]; List _tabs() => [ ..._baseTabs, Routes.calendar, ]; // 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((_) { // Clean up any leftover APKs from previous update cycles. ref.read(updateProvider.notifier).cleanup(); // Silent update check — only if we haven't already checked this session. final repoUrl = ref.read(forgejoRepoUrlProvider); if (repoUrl != null && repoUrl.isNotEmpty) { 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 journal 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.read(conversationsProvider.notifier).refresh(); ref.read(calendarProvider.notifier).refresh(); ref.read(knowledgeProvider.notifier).refresh(); // journalProvider is an AsyncNotifier; invalidating is safe even if // the journal screen isn't currently mounted. ref.invalidate(journalProvider); } /// Refresh only the provider backing the given shell tab route. void _refreshTab(String route) { if (route == Routes.journal) { ref.invalidate(journalProvider); } else if (route == Routes.knowledge) { ref.read(knowledgeProvider.notifier).refresh(); } else if (route == Routes.conversations) { ref.read(conversationsProvider.notifier).refresh(); } else if (route == Routes.calendar) { ref.read(calendarProvider.notifier).refresh(); } } 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, List tabs) { for (var i = 0; i < tabs.length; i++) { if (location.startsWith(tabs[i])) return i; } return 0; } void _showMoreSheet(BuildContext context) { showModalBottomSheet( context: context, builder: (_) => SafeArea( child: Column( mainAxisSize: MainAxisSize.min, children: [ ListTile( leading: const Icon(LucideIcons.folder), title: const Text('Projects'), onTap: () { Navigator.pop(context); context.push(Routes.projects); }, ), ListTile( leading: const Icon(LucideIcons.calendar), title: const Text('Calendar'), onTap: () { Navigator.pop(context); context.push(Routes.calendar); }, ), ], ), ), ); } void _showUpdateSnackbar(UpdateState update) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('v${update.latestVersion} ready to install'), duration: const Duration(seconds: 6), action: SnackBarAction( label: 'Install', onPressed: () => ref.read(updateProvider.notifier).install(), ), ), ); } void _showQueueFailureSnackbar(QueueFailure failure) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(failure.message), behavior: SnackBarBehavior.floating, duration: const Duration(seconds: 6), ), ); } @override Widget build(BuildContext context) { // Show update dialog once when a new version is detected. ref.listen(updateProvider, (prev, next) { if (next.status == UpdateStatus.readyToInstall && prev?.status != UpdateStatus.readyToInstall) { WidgetsBinding.instance .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 index = _tabIndex(location, tabs); // Refresh the incoming tab's data when switching between shell tabs. if (_prevTabIndex != null && _prevTabIndex != index) { final route = index < tabs.length ? tabs[index] : tabs[0]; WidgetsBinding.instance.addPostFrameCallback((_) => _refreshTab(route)); } _prevTabIndex = index; final child = widget.child; final isWide = MediaQuery.of(context).size.width >= 600; if (isWide) { return Scaffold( body: SafeArea( child: Column( children: [ const OfflineBanner(), const _QuickCaptureBar(), Expanded( child: Row( children: [ NavigationRail( selectedIndex: index, onDestinationSelected: (i) => context.go(tabs[i]), labelType: NavigationRailLabelType.all, destinations: const [ NavigationRailDestination( icon: Icon(LucideIcons.bookOpen), selectedIcon: Icon(LucideIcons.bookOpen), label: Text('Journal'), ), NavigationRailDestination( icon: Icon(LucideIcons.lightbulb), selectedIcon: Icon(LucideIcons.lightbulb), label: Text('Knowledge'), ), NavigationRailDestination( icon: Icon(LucideIcons.messageCircle), selectedIcon: Icon(LucideIcons.messageCircle), label: Text('Chat'), ), NavigationRailDestination( icon: Icon(LucideIcons.folder), selectedIcon: Icon(LucideIcons.folder), label: Text('Projects'), ), NavigationRailDestination( icon: Icon(LucideIcons.calendar), selectedIcon: Icon(LucideIcons.calendar), label: Text('Calendar'), ), ], ), const VerticalDivider(width: 1), Expanded(child: child), ], ), ), ], ), ), ); } return Scaffold( body: Column( children: [ const OfflineBanner(), const _QuickCaptureBar(), Expanded( child: MediaQuery.removePadding( context: context, removeTop: true, child: child, ), ), ], ), bottomNavigationBar: NavigationBar( selectedIndex: index >= 3 ? 3 : index, onDestinationSelected: (i) { if (i == 3) { _showMoreSheet(context); } else { context.go(tabs[i]); } }, destinations: const [ NavigationDestination( icon: Icon(LucideIcons.bookOpen), selectedIcon: Icon(LucideIcons.bookOpen), label: 'Journal', ), NavigationDestination( icon: Icon(LucideIcons.lightbulb), selectedIcon: Icon(LucideIcons.lightbulb), label: 'Knowledge', ), NavigationDestination( icon: Icon(LucideIcons.messageCircle), selectedIcon: Icon(LucideIcons.messageCircle), label: 'Chat', ), NavigationDestination( icon: Icon(LucideIcons.moreHorizontal), selectedIcon: Icon(LucideIcons.moreHorizontal), 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(); 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(LucideIcons.uploadCloud), ) : isWorking ? const Padding( padding: EdgeInsets.all(12), child: SizedBox( width: 16, height: 16, child: CircularProgressIndicator( strokeWidth: 2), ), ) : const Icon(LucideIcons.sparkles), suffixIcon: _controller.text.trim().isNotEmpty ? IconButton( icon: const Icon(LucideIcons.send), onPressed: _submit, tooltip: 'Capture', ) : null, ), ), ), VoiceMicButton( mode: ref.watch(voiceProvider).mode, voiceModeActive: ref.watch(voiceProvider).voiceModeActive, amplitude: ref.watch(voiceProvider).amplitude, onTap: _toggleCaptureMic, ), IconButton( icon: const Icon(LucideIcons.settings), 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, ); } }