149 lines
5.8 KiB
Dart
149 lines
5.8 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:go_router/go_router.dart';
|
|
import 'package:supabase_flutter/supabase_flutter.dart';
|
|
|
|
import '../../../core/network/supabase_status.dart';
|
|
import '../../features/auth/presentation/controllers/auth_controller.dart';
|
|
import '../../features/auth/presentation/controllers/dev_auth_bypass.dart';
|
|
import '../../features/auth/presentation/screens/login_screen.dart';
|
|
import '../../features/exercise/presentation/screens/exercise_screen.dart';
|
|
import '../../features/medication/presentation/screens/medication_screen.dart';
|
|
import '../../features/nutrition/presentation/screens/nutrition_screen.dart';
|
|
import '../../features/nutrition/presentation/screens/food_search_screen.dart';
|
|
import '../../features/meal_planner/presentation/screens/meal_planner_screen.dart';
|
|
import '../../features/pantry/presentation/screens/pantry_screen.dart';
|
|
import '../../features/recipes/presentation/screens/recipes_screen.dart';
|
|
import '../../features/settings/presentation/screens/settings_screen.dart';
|
|
import '../scaffold/adaptive_home_scaffold.dart';
|
|
|
|
/// Locations that may be visited without an authenticated session.
|
|
const Set<String> _publicPaths = {'/login'};
|
|
|
|
final _rootNavigatorKey = GlobalKey<NavigatorState>();
|
|
final _shellNavigatorKey = GlobalKey<NavigatorState>();
|
|
|
|
/// Whether the current session satisfies the authenticated guard.
|
|
///
|
|
/// Reads the live GoTrue session when the SDK is initialized and reports
|
|
/// `false` otherwise. Watches [authStateProvider] so the value is re-derived
|
|
/// on every GoTrue transition — `currentSession` itself is a plain, non
|
|
/// reactive getter, so without the watch this provider would cache whatever
|
|
/// was true at first read (e.g. booting already signed-in) and a later
|
|
/// sign-out could never redirect to `/login`. Extracted into a provider so
|
|
/// integration tests can simulate an authenticated (or unauthenticated)
|
|
/// session without booting Supabase — they simply override this value.
|
|
final isAuthenticatedProvider = Provider<bool>(
|
|
(ref) {
|
|
// Recompute on every auth transition (signedIn / signedOut /
|
|
// tokenRefreshed / initialSession).
|
|
ref.watch(authStateProvider);
|
|
return isSupabaseAvailable &&
|
|
Supabase.instance.client.auth.currentSession != null;
|
|
},
|
|
);
|
|
|
|
/// Application router with a reactive authentication guard and dev bypass hook.
|
|
final routerProvider = Provider<GoRouter>((ref) {
|
|
final authRefreshNotifier = ValueNotifier<int>(0);
|
|
ref.onDispose(authRefreshNotifier.dispose);
|
|
|
|
// Trigger re-evaluation on GoTrue auth transitions or dev bypass toggle.
|
|
//
|
|
// The auth bump is deferred by one microtask so Riverpod finishes
|
|
// propagating the transition to dependent providers (notably
|
|
// isAuthenticatedProvider, which watches authStateProvider) before the
|
|
// redirect guard re-reads them; ValueNotifier listeners otherwise run
|
|
// inside the provider-notification cascade and could observe a stale
|
|
// cached value (symptom: sign-out clears the session but never navigates
|
|
// to /login).
|
|
ref.listen(authStateProvider, (_, __) {
|
|
Future.microtask(() => authRefreshNotifier.value++);
|
|
});
|
|
ref.listen(devAuthBypassProvider, (_, __) => authRefreshNotifier.value++);
|
|
|
|
return GoRouter(
|
|
navigatorKey: _rootNavigatorKey,
|
|
initialLocation: '/medication',
|
|
refreshListenable: authRefreshNotifier,
|
|
redirect: (context, state) => _authGuard(context, state, ref),
|
|
routes: [
|
|
GoRoute(
|
|
path: '/login',
|
|
builder: (context, state) => const LoginScreen(),
|
|
),
|
|
// Full-screen food search (pushed above the shell so it covers the
|
|
// navigation rail). `?preset=pantry` reorders the product action
|
|
// sheet to emphasize adding to pantry stock.
|
|
GoRoute(
|
|
path: '/food-search',
|
|
builder: (context, state) => FoodSearchScreen(
|
|
preset: foodSearchPresetFrom(state.uri.queryParameters['preset']),
|
|
),
|
|
),
|
|
ShellRoute(
|
|
navigatorKey: _shellNavigatorKey,
|
|
builder: (context, state, child) {
|
|
return AdaptiveHomeScaffold(child: child);
|
|
},
|
|
routes: [
|
|
GoRoute(
|
|
path: '/medication',
|
|
builder: (context, state) => const MedicationScreen(),
|
|
),
|
|
GoRoute(
|
|
path: '/nutrition',
|
|
builder: (context, state) => const NutritionScreen(),
|
|
),
|
|
GoRoute(
|
|
path: '/exercise',
|
|
builder: (context, state) => const ExerciseScreen(),
|
|
),
|
|
GoRoute(
|
|
path: '/meal-planner',
|
|
builder: (context, state) => const MealPlannerScreen(),
|
|
),
|
|
GoRoute(
|
|
path: '/recipes',
|
|
builder: (context, state) => const RecipesScreen(),
|
|
),
|
|
GoRoute(
|
|
path: '/pantry',
|
|
builder: (context, state) => const PantryScreen(),
|
|
),
|
|
GoRoute(
|
|
path: '/settings',
|
|
builder: (context, state) => const SettingsScreen(),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
);
|
|
});
|
|
|
|
/// Redirect guard enforcing the passwordless session lifecycle with dev bypass.
|
|
///
|
|
/// * Development bypass (debug builds only): `/login` -> `/medication`, all
|
|
/// other routes pass without a session.
|
|
/// * Unauthenticated sessions on a protected route -> `/login`.
|
|
/// * Authenticated sessions on `/login` -> `/medication`.
|
|
String? _authGuard(BuildContext context, GoRouterState state, Ref ref) {
|
|
final isDevBypassed = ref.read(devAuthBypassProvider);
|
|
final location = state.matchedLocation;
|
|
|
|
if (isDevBypassed) {
|
|
if (location == '/login') return '/medication';
|
|
return null;
|
|
}
|
|
|
|
final isAuthenticated = ref.read(isAuthenticatedProvider);
|
|
|
|
if (!isAuthenticated && !_publicPaths.contains(location)) {
|
|
return '/login';
|
|
}
|
|
if (isAuthenticated && location == '/login') {
|
|
return '/medication';
|
|
}
|
|
return null;
|
|
}
|