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/exceptions.dart'; import 'core/theme.dart'; import 'providers/api_client_provider.dart'; import 'providers/auth_provider.dart'; import 'providers/capture_queue_provider.dart'; import 'providers/capture_work_queue_provider.dart'; import 'providers/notes_provider.dart'; import 'providers/settings_provider.dart'; import 'providers/update_provider.dart'; import 'providers/tasks_provider.dart'; import 'screens/auth/login_screen.dart'; import 'screens/briefing/briefing_screen.dart'; import 'screens/library/project_tasks_screen.dart'; import 'screens/chat/chat_screen.dart'; import 'screens/chat/conversations_tab_screen.dart'; import 'screens/library/library_screen.dart'; import 'screens/notes/note_detail_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'; // 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: (_, _) => const NoteEditScreen(), ), 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: 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.library, builder: (_, _) => const LibraryScreen(), ), GoRoute( path: Routes.conversations, builder: (_, _) => const ConversationsTabScreen(), ), ], ), ], ); }); class _Shell extends ConsumerStatefulWidget { final Widget child; const _Shell({required this.child}); @override ConsumerState<_Shell> createState() => _ShellState(); } class _ShellState extends ConsumerState<_Shell> { static const _tabs = [ Routes.briefing, Routes.library, Routes.conversations, ]; @override void initState() { super.initState(); 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(); }); } 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; } return 0; } 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); 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) => 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.library_books_outlined), selectedIcon: Icon(Icons.library_books), label: Text('Library'), ), NavigationRailDestination( icon: Icon(Icons.chat_bubble_outline), selectedIcon: Icon(Icons.chat_bubble), label: Text('Chat'), ), ], ), 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) => context.go(_tabs[i]), destinations: const [ NavigationDestination( icon: Icon(Icons.wb_sunny_outlined), selectedIcon: Icon(Icons.wb_sunny), label: 'Briefing', ), NavigationDestination( icon: Icon(Icons.library_books_outlined), selectedIcon: Icon(Icons.library_books), label: 'Library', ), NavigationDestination( icon: Icon(Icons.chat_bubble_outline), selectedIcon: Icon(Icons.chat_bubble), label: 'Chat', ), ], ), ); } } 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; final api = ref.read(quickCaptureApiProvider); for (final text in List.from(queue)) { if (!mounted) break; try { final result = await api.capture(text); // Dequeue before the mounted check — SharedPreferences doesn't need // the widget alive, and skipping this would leave a ghost item. await ref.read(captureQueueProvider.notifier).dequeue(text); if (!mounted) break; switch (result.type) { case 'note': ref.invalidate(notesProvider); case 'task': case 'todo': ref.invalidate(tasksProvider); } } on NetworkException { break; } catch (_) { // Server error or unexpected failure — drop from queue to prevent // ghost items that can never be cleared. await ref.read(captureQueueProvider.notifier).dequeue(text); } } } String _hintForLocation(String location) { if (location.startsWith(Routes.library) && location.contains('tasks')) { return 'Add a task…'; } 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: _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, ), ), ), 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, ); } }