import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'core/constants.dart'; import 'core/exceptions.dart'; import 'providers/api_client_provider.dart'; import 'providers/auth_provider.dart'; import 'providers/capture_queue_provider.dart'; import 'providers/milestones_provider.dart'; import 'providers/notes_provider.dart'; import 'providers/projects_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/chat/chat_screen.dart'; import 'screens/chat/conversations_list_screen.dart'; import 'screens/notes/note_detail_screen.dart'; import 'screens/notes/note_edit_screen.dart'; import 'screens/notes/notes_list_screen.dart'; import 'screens/projects/project_list_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/tasks/tasks_list_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.chat, builder: (_, state) => ChatScreen( conversationId: int.parse(state.pathParameters['id']!), ), ), ShellRoute( builder: (context, state, child) => _Shell(child: child), routes: [ GoRoute( path: Routes.notes, builder: (_, _) => const NotesListScreen(), ), GoRoute( path: Routes.tasks, builder: (_, _) => const TasksListScreen(), ), GoRoute( path: Routes.projects, builder: (_, _) => const ProjectListScreen(), ), GoRoute( path: Routes.conversations, builder: (_, _) => const ConversationsListScreen(), ), ], ), ], ); }); 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.notes, Routes.tasks, Routes.projects, Routes.conversations]; @override void initState() { super.initState(); // Silent update check on first app load. WidgetsBinding.instance.addPostFrameCallback((_) { final repoUrl = ref.read(forgejoRepoUrlProvider); if (repoUrl != null && repoUrl.isNotEmpty) { ref.read(updateProvider.notifier).check(repoUrl); } }); } 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, ), ], ], ), actions: [ TextButton( onPressed: () => Navigator.pop(dialogContext), child: const Text('Later'), ), if (!isDownloading) 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; // Use NavigationRail whenever the screen is wide enough — covers both // phone landscape and tablets in either orientation (600 dp breakpoint). 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.note_outlined), selectedIcon: Icon(Icons.note), label: Text('Notes'), ), NavigationRailDestination( icon: Icon(Icons.check_box_outlined), selectedIcon: Icon(Icons.check_box), label: Text('Tasks'), ), NavigationRailDestination( icon: Icon(Icons.folder_outlined), selectedIcon: Icon(Icons.folder), label: Text('Projects'), ), 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: child), ], ), bottomNavigationBar: NavigationBar( selectedIndex: index, onDestinationSelected: (i) => context.go(_tabs[i]), destinations: const [ NavigationDestination(icon: Icon(Icons.note), label: 'Notes'), NavigationDestination(icon: Icon(Icons.check_box), label: 'Tasks'), NavigationDestination(icon: Icon(Icons.folder), label: 'Projects'), NavigationDestination( icon: 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(); bool _busy = false; @override void initState() { super.initState(); // Retry any offline-queued captures from previous sessions. WidgetsBinding.instance.addPostFrameCallback((_) => _drainQueue()); } @override void dispose() { _controller.dispose(); super.dispose(); } Future _submit() async { final text = _controller.text.trim(); if (text.isEmpty || _busy) return; _controller.clear(); setState(() => _busy = true); // Capture the messenger before the async gap so we can show a snackbar // even if the user has navigated to a deeper view by the time it resolves. final messenger = ScaffoldMessenger.of(context); try { final result = await ref.read(quickCaptureApiProvider).capture(text); switch (result.type) { case 'note': ref.invalidate(notesProvider); case 'task': case 'todo': ref.invalidate(tasksProvider); ref.invalidate(projectsProvider); ref.invalidate(projectMilestonesProvider); } final msg = result.message.isNotEmpty ? result.message : '${_typeLabel(result.type)} created: ${result.title}'; messenger.showSnackBar( SnackBar(content: Text(msg), behavior: SnackBarBehavior.floating), ); _drainQueue(); // Silently flush any offline-queued captures. } on NetworkException { await ref.read(captureQueueProvider.notifier).enqueue(text); messenger.showSnackBar( const SnackBar( content: Text( "You're offline — capture saved and will retry automatically."), behavior: SnackBarBehavior.floating, ), ); } on AppException catch (e) { messenger.showSnackBar( SnackBar(content: Text(e.message), behavior: SnackBarBehavior.floating), ); } catch (_) { messenger.showSnackBar( const SnackBar( content: Text('Capture failed. Please try again.'), behavior: SnackBarBehavior.floating, ), ); } finally { if (mounted) setState(() => _busy = false); } } Future _drainQueue() 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); if (!mounted) break; await ref.read(captureQueueProvider.notifier).dequeue(text); switch (result.type) { case 'note': ref.invalidate(notesProvider); case 'task': case 'todo': ref.invalidate(tasksProvider); ref.invalidate(projectsProvider); ref.invalidate(projectMilestonesProvider); } } on NetworkException { break; // Still offline — stop draining. } catch (_) { // Server/parse error — remove to avoid infinite retries. if (mounted) await ref.read(captureQueueProvider.notifier).dequeue(text); } } } String _typeLabel(String type) => switch (type) { 'note' => 'Note', 'task' => 'Task', 'event' => 'Event', 'todo' => 'To-do', _ => type, }; String _hintForLocation(String location) { if (location.startsWith(Routes.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 queueCount = ref.watch(captureQueueProvider).length; return SafeArea( bottom: false, child: Padding( padding: const EdgeInsets.fromLTRB(12, 8, 4, 4), child: Row( children: [ Expanded( child: TextField( controller: _controller, enabled: !_busy, textInputAction: TextInputAction.send, onSubmitted: (_) => _submit(), onChanged: (_) => setState(() {}), decoration: InputDecoration( hintText: _hintForLocation(location), isDense: true, contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), border: OutlineInputBorder( borderRadius: BorderRadius.circular(24), ), prefixIcon: _busy ? const Padding( padding: EdgeInsets.all(12), child: SizedBox( width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2), ), ) : queueCount > 0 ? Badge( label: Text('$queueCount'), child: const Icon(Icons.cloud_upload_outlined), ) : const Icon(Icons.auto_awesome_outlined), suffixIcon: _controller.text.trim().isNotEmpty && !_busy ? 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), ), ], ), ), ); } } 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: ThemeData( colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo), useMaterial3: true, ), darkTheme: ThemeData( colorScheme: ColorScheme.fromSeed( seedColor: Colors.indigo, brightness: Brightness.dark, ), useMaterial3: true, ), routerConfig: router, ); } }