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:
2026-04-18 12:58:18 -04:00
parent 3c9602c7c9
commit 01aa362d3c
5 changed files with 127 additions and 1 deletions
+15
View File
@@ -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<GoRouter>((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<GoRouter>((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(