feat(offline): detect server offline and keep returning users signed in
Previously, if the backend was unreachable at launch, the splash screen routed to the login screen. The server URL remained persisted in SharedPreferences but visually appeared lost, frustrating any user who isn't the service operator. - New AuthStatus.offline distinguishes network failures (NetworkException) from HTTP 401 in AuthNotifier.verify(). - Persist has_ever_logged_in flag on first successful verify/login. - Offline + ever-logged-in lands on the briefing with a sticky offline banner (retry button) instead of being punted to login. - OfflineBanner widget is Tier-2-ready so we can surface "last sync X min ago" once real caching lands (Fable task #147).
This commit is contained in:
@@ -35,6 +35,7 @@ import 'screens/splash/splash_screen.dart';
|
|||||||
import 'screens/tasks/task_edit_screen.dart';
|
import 'screens/tasks/task_edit_screen.dart';
|
||||||
import 'screens/calendar/calendar_screen.dart';
|
import 'screens/calendar/calendar_screen.dart';
|
||||||
import 'providers/voice_provider.dart';
|
import 'providers/voice_provider.dart';
|
||||||
|
import 'widgets/offline_banner.dart';
|
||||||
import 'widgets/voice_mic_button.dart';
|
import 'widgets/voice_mic_button.dart';
|
||||||
|
|
||||||
// ChangeNotifier that fires when auth or server URL changes,
|
// ChangeNotifier that fires when auth or server URL changes,
|
||||||
@@ -44,6 +45,7 @@ class _RouterNotifier extends ChangeNotifier {
|
|||||||
_RouterNotifier(Ref ref) {
|
_RouterNotifier(Ref ref) {
|
||||||
ref.listen(authProvider, (_, _) => notifyListeners());
|
ref.listen(authProvider, (_, _) => notifyListeners());
|
||||||
ref.listen(serverUrlProvider, (_, _) => notifyListeners());
|
ref.listen(serverUrlProvider, (_, _) => notifyListeners());
|
||||||
|
ref.listen(hasEverLoggedInProvider, (_, _) => notifyListeners());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,6 +59,7 @@ final routerProvider = Provider<GoRouter>((ref) {
|
|||||||
final location = state.matchedLocation;
|
final location = state.matchedLocation;
|
||||||
final serverUrl = ref.read(serverUrlProvider);
|
final serverUrl = ref.read(serverUrlProvider);
|
||||||
final authStatus = ref.read(authProvider);
|
final authStatus = ref.read(authProvider);
|
||||||
|
final hasEverLoggedIn = ref.read(hasEverLoggedInProvider);
|
||||||
|
|
||||||
if (serverUrl == null || serverUrl.isEmpty) {
|
if (serverUrl == null || serverUrl.isEmpty) {
|
||||||
if (location != Routes.setup) return Routes.setup;
|
if (location != Routes.setup) return Routes.setup;
|
||||||
@@ -70,6 +73,16 @@ final routerProvider = Provider<GoRouter>((ref) {
|
|||||||
return null;
|
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;
|
return null;
|
||||||
},
|
},
|
||||||
routes: [
|
routes: [
|
||||||
@@ -367,6 +380,7 @@ class _ShellState extends ConsumerState<_Shell> with WidgetsBindingObserver {
|
|||||||
body: SafeArea(
|
body: SafeArea(
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
|
const OfflineBanner(),
|
||||||
const _QuickCaptureBar(),
|
const _QuickCaptureBar(),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Row(
|
child: Row(
|
||||||
@@ -423,6 +437,7 @@ class _ShellState extends ConsumerState<_Shell> with WidgetsBindingObserver {
|
|||||||
return Scaffold(
|
return Scaffold(
|
||||||
body: Column(
|
body: Column(
|
||||||
children: [
|
children: [
|
||||||
|
const OfflineBanner(),
|
||||||
const _QuickCaptureBar(),
|
const _QuickCaptureBar(),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: MediaQuery.removePadding(
|
child: MediaQuery.removePadding(
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../core/exceptions.dart';
|
||||||
import 'api_client_provider.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, AuthStatus>(AuthNotifier.new);
|
final authProvider = NotifierProvider<AuthNotifier, AuthStatus>(AuthNotifier.new);
|
||||||
|
|
||||||
@@ -14,7 +16,12 @@ class AuthNotifier extends Notifier<AuthStatus> {
|
|||||||
try {
|
try {
|
||||||
final repo = ref.read(authRepositoryProvider);
|
final repo = ref.read(authRepositoryProvider);
|
||||||
final ok = await repo.verify();
|
final ok = await repo.verify();
|
||||||
|
if (ok) {
|
||||||
|
await ref.read(hasEverLoggedInProvider.notifier).markLoggedIn();
|
||||||
|
}
|
||||||
state = ok ? AuthStatus.authenticated : AuthStatus.unauthenticated;
|
state = ok ? AuthStatus.authenticated : AuthStatus.unauthenticated;
|
||||||
|
} on NetworkException {
|
||||||
|
state = AuthStatus.offline;
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
state = AuthStatus.unauthenticated;
|
state = AuthStatus.unauthenticated;
|
||||||
}
|
}
|
||||||
@@ -23,6 +30,7 @@ class AuthNotifier extends Notifier<AuthStatus> {
|
|||||||
Future<void> login(String username, String password) async {
|
Future<void> login(String username, String password) async {
|
||||||
final repo = ref.read(authRepositoryProvider);
|
final repo = ref.read(authRepositoryProvider);
|
||||||
await repo.login(username, password);
|
await repo.login(username, password);
|
||||||
|
await ref.read(hasEverLoggedInProvider.notifier).markLoggedIn();
|
||||||
state = AuthStatus.authenticated;
|
state = AuthStatus.authenticated;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import 'api_client_provider.dart';
|
|||||||
const _kServerUrl = 'server_url';
|
const _kServerUrl = 'server_url';
|
||||||
const _kThemeMode = 'theme_mode';
|
const _kThemeMode = 'theme_mode';
|
||||||
const _kForgejoRepoUrl = 'forgejo_repo_url';
|
const _kForgejoRepoUrl = 'forgejo_repo_url';
|
||||||
|
const _kHasEverLoggedIn = 'has_ever_logged_in';
|
||||||
|
|
||||||
final sharedPreferencesProvider = Provider<SharedPreferences>((ref) {
|
final sharedPreferencesProvider = Provider<SharedPreferences>((ref) {
|
||||||
throw UnimplementedError('Override in ProviderScope');
|
throw UnimplementedError('Override in ProviderScope');
|
||||||
@@ -85,6 +86,31 @@ class ServerUrlNotifier extends Notifier<String?> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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, bool>(HasEverLoggedInNotifier.new);
|
||||||
|
|
||||||
|
class HasEverLoggedInNotifier extends Notifier<bool> {
|
||||||
|
@override
|
||||||
|
bool build() {
|
||||||
|
final prefs = ref.watch(sharedPreferencesProvider);
|
||||||
|
return prefs.getBool(_kHasEverLoggedIn) ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> markLoggedIn() async {
|
||||||
|
if (state) return;
|
||||||
|
await ref.read(sharedPreferencesProvider).setBool(_kHasEverLoggedIn, true);
|
||||||
|
state = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> clear() async {
|
||||||
|
await ref.read(sharedPreferencesProvider).remove(_kHasEverLoggedIn);
|
||||||
|
state = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
final serverSettingsProvider =
|
final serverSettingsProvider =
|
||||||
AsyncNotifierProvider<ServerSettingsNotifier, Map<String, dynamic>>(
|
AsyncNotifierProvider<ServerSettingsNotifier, Map<String, dynamic>>(
|
||||||
ServerSettingsNotifier.new);
|
ServerSettingsNotifier.new);
|
||||||
|
|||||||
@@ -29,8 +29,13 @@ class _SplashScreenState extends ConsumerState<SplashScreen> {
|
|||||||
await ref.read(authProvider.notifier).verify();
|
await ref.read(authProvider.notifier).verify();
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
final status = ref.read(authProvider);
|
final status = ref.read(authProvider);
|
||||||
|
final hasEverLoggedIn = ref.read(hasEverLoggedInProvider);
|
||||||
if (status == AuthStatus.authenticated) {
|
if (status == AuthStatus.authenticated) {
|
||||||
context.go(Routes.briefing);
|
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 {
|
} else {
|
||||||
context.go(Routes.login);
|
context.go(Routes.login);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<OfflineBanner> createState() => _OfflineBannerState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _OfflineBannerState extends ConsumerState<OfflineBanner> {
|
||||||
|
bool _retrying = false;
|
||||||
|
|
||||||
|
Future<void> _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'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user