diff --git a/lib/app.dart b/lib/app.dart index afda4f0..57c3a65 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -35,6 +35,7 @@ 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, @@ -44,6 +45,7 @@ class _RouterNotifier extends ChangeNotifier { _RouterNotifier(Ref ref) { ref.listen(authProvider, (_, _) => notifyListeners()); ref.listen(serverUrlProvider, (_, _) => notifyListeners()); + ref.listen(hasEverLoggedInProvider, (_, _) => notifyListeners()); } } @@ -57,6 +59,7 @@ final routerProvider = Provider((ref) { 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; @@ -70,6 +73,16 @@ final routerProvider = Provider((ref) { 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: [ @@ -367,6 +380,7 @@ class _ShellState extends ConsumerState<_Shell> with WidgetsBindingObserver { body: SafeArea( child: Column( children: [ + const OfflineBanner(), const _QuickCaptureBar(), Expanded( child: Row( @@ -423,6 +437,7 @@ class _ShellState extends ConsumerState<_Shell> with WidgetsBindingObserver { return Scaffold( body: Column( children: [ + const OfflineBanner(), const _QuickCaptureBar(), Expanded( child: MediaQuery.removePadding( diff --git a/lib/providers/auth_provider.dart b/lib/providers/auth_provider.dart index fe82d88..9aaf06b 100644 --- a/lib/providers/auth_provider.dart +++ b/lib/providers/auth_provider.dart @@ -1,8 +1,10 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../core/exceptions.dart'; import 'api_client_provider.dart'; +import 'settings_provider.dart'; -enum AuthStatus { unknown, authenticated, unauthenticated } +enum AuthStatus { unknown, authenticated, unauthenticated, offline } final authProvider = NotifierProvider(AuthNotifier.new); @@ -14,7 +16,12 @@ class AuthNotifier extends Notifier { try { final repo = ref.read(authRepositoryProvider); final ok = await repo.verify(); + if (ok) { + await ref.read(hasEverLoggedInProvider.notifier).markLoggedIn(); + } state = ok ? AuthStatus.authenticated : AuthStatus.unauthenticated; + } on NetworkException { + state = AuthStatus.offline; } catch (_) { state = AuthStatus.unauthenticated; } @@ -23,6 +30,7 @@ class AuthNotifier extends Notifier { Future login(String username, String password) async { final repo = ref.read(authRepositoryProvider); await repo.login(username, password); + await ref.read(hasEverLoggedInProvider.notifier).markLoggedIn(); state = AuthStatus.authenticated; } diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart index 62a6682..70385df 100644 --- a/lib/providers/settings_provider.dart +++ b/lib/providers/settings_provider.dart @@ -7,6 +7,7 @@ import 'api_client_provider.dart'; const _kServerUrl = 'server_url'; const _kThemeMode = 'theme_mode'; const _kForgejoRepoUrl = 'forgejo_repo_url'; +const _kHasEverLoggedIn = 'has_ever_logged_in'; final sharedPreferencesProvider = Provider((ref) { throw UnimplementedError('Override in ProviderScope'); @@ -85,6 +86,31 @@ class ServerUrlNotifier extends Notifier { } } +/// Tracks whether this install has ever completed a successful login. +/// Used so offline users who've logged in before land on the briefing (with a +/// banner) instead of being dumped onto the login screen as if freshly installed. +final hasEverLoggedInProvider = + NotifierProvider(HasEverLoggedInNotifier.new); + +class HasEverLoggedInNotifier extends Notifier { + @override + bool build() { + final prefs = ref.watch(sharedPreferencesProvider); + return prefs.getBool(_kHasEverLoggedIn) ?? false; + } + + Future markLoggedIn() async { + if (state) return; + await ref.read(sharedPreferencesProvider).setBool(_kHasEverLoggedIn, true); + state = true; + } + + Future clear() async { + await ref.read(sharedPreferencesProvider).remove(_kHasEverLoggedIn); + state = false; + } +} + final serverSettingsProvider = AsyncNotifierProvider>( ServerSettingsNotifier.new); diff --git a/lib/screens/splash/splash_screen.dart b/lib/screens/splash/splash_screen.dart index 9de7075..c096ab5 100644 --- a/lib/screens/splash/splash_screen.dart +++ b/lib/screens/splash/splash_screen.dart @@ -29,8 +29,13 @@ class _SplashScreenState extends ConsumerState { await ref.read(authProvider.notifier).verify(); if (!mounted) return; final status = ref.read(authProvider); + final hasEverLoggedIn = ref.read(hasEverLoggedInProvider); if (status == AuthStatus.authenticated) { context.go(Routes.briefing); + } else if (status == AuthStatus.offline && hasEverLoggedIn) { + // Server unreachable but this user has logged in before — land them on + // the briefing with the offline banner rather than the login screen. + context.go(Routes.briefing); } else { context.go(Routes.login); } diff --git a/lib/widgets/offline_banner.dart b/lib/widgets/offline_banner.dart new file mode 100644 index 0000000..2d9ee72 --- /dev/null +++ b/lib/widgets/offline_banner.dart @@ -0,0 +1,72 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../providers/auth_provider.dart'; + +/// Sticky banner shown when the backend is unreachable. Surfaces a retry +/// action and reserves space for future "last sync X min ago" messaging once +/// Tier 2 offline caching lands. +class OfflineBanner extends ConsumerStatefulWidget { + const OfflineBanner({super.key}); + + @override + ConsumerState createState() => _OfflineBannerState(); +} + +class _OfflineBannerState extends ConsumerState { + bool _retrying = false; + + Future _retry() async { + if (_retrying) return; + setState(() => _retrying = true); + try { + await ref.read(authProvider.notifier).verify(); + } finally { + if (mounted) setState(() => _retrying = false); + } + } + + @override + Widget build(BuildContext context) { + final status = ref.watch(authProvider); + if (status != AuthStatus.offline) return const SizedBox.shrink(); + + final scheme = Theme.of(context).colorScheme; + return Material( + color: scheme.errorContainer, + child: SafeArea( + bottom: false, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Row( + children: [ + Icon(Icons.cloud_off_outlined, + size: 18, color: scheme.onErrorContainer), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Offline — showing cached data.', + style: TextStyle(color: scheme.onErrorContainer), + ), + ), + TextButton( + onPressed: _retrying ? null : _retry, + style: TextButton.styleFrom( + foregroundColor: scheme.onErrorContainer, + padding: const EdgeInsets.symmetric(horizontal: 12), + ), + child: _retrying + ? const SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Retry'), + ), + ], + ), + ), + ), + ); + } +}