Merge pull request 'Release v26.04.18.1 — Offline-aware launch' (#28) from dev into main
This commit was merged in pull request #28.
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