Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5e48a4fb69 | |||
| 01aa362d3c |
@@ -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(
|
||||
|
||||
@@ -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, AuthStatus>(AuthNotifier.new);
|
||||
|
||||
@@ -14,7 +16,12 @@ class AuthNotifier extends Notifier<AuthStatus> {
|
||||
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<AuthStatus> {
|
||||
Future<void> 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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<SharedPreferences>((ref) {
|
||||
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 =
|
||||
AsyncNotifierProvider<ServerSettingsNotifier, Map<String, dynamic>>(
|
||||
ServerSettingsNotifier.new);
|
||||
|
||||
@@ -29,8 +29,13 @@ class _SplashScreenState extends ConsumerState<SplashScreen> {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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