glp-companion/lib/app/router/app_router.dart

139 lines
5.4 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/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(),
),
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;
}