diff --git a/app/lib/main.dart b/app/lib/main.dart index ba16bf24b..467efab5f 100644 --- a/app/lib/main.dart +++ b/app/lib/main.dart @@ -9,10 +9,17 @@ void main() async { WidgetsFlutterBinding.ensureInitialized(); AppConfig.validate(); final sharedPreferences = await SharedPreferences.getInstance(); + + // Read auth state before runApp() via the encapsulated helper so the router + // can set initialLocation correctly on the first frame for authenticated + // users. Never throws — see AuthLocalDataSource.checkCachedLogin(). + final isLoggedIn = await AuthLocalDataSource.checkCachedLogin(); + runApp( ProviderScope( overrides: [ sharedPreferencesProvider.overrideWithValue(sharedPreferences), + cachedAuthFlagProvider.overrideWithValue(isLoggedIn), ], child: const CortexAppRoot(), ), diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 79b6e7078..404879ff2 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -18,6 +18,7 @@ dependencies: http: ^1.6.0 shared_preferences: ^2.3.0 + dev_dependencies: flutter_test: sdk: flutter diff --git a/packages/core/lib/data/auth/auth_local_data_source.dart b/packages/core/lib/data/auth/auth_local_data_source.dart index be4b7ba8a..23e1a9e18 100644 --- a/packages/core/lib/data/auth/auth_local_data_source.dart +++ b/packages/core/lib/data/auth/auth_local_data_source.dart @@ -3,6 +3,20 @@ import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'types/auth_exception.dart'; class AuthLocalDataSource { + /// Pre-boot helper: called in main() before runApp() to obtain a fast + /// synchronous-equivalent auth signal without duplicating storage logic. + /// + /// Never throws — a bad Android Keystore (e.g. after an OS/backup restore) + /// returns false so the app starts in a graceful unauthenticated state + /// instead of crashing before runApp(). + static Future checkCachedLogin() async { + try { + return await AuthLocalDataSource().isUserLoggedIn(); + } catch (_) { + return false; + } + } + static const _authTokenKey = 'auth_token'; final FlutterSecureStorage _storage; diff --git a/packages/core/lib/data/auth/auth_provider.dart b/packages/core/lib/data/auth/auth_provider.dart index 56b85acbb..b6d5b0609 100644 --- a/packages/core/lib/data/auth/auth_provider.dart +++ b/packages/core/lib/data/auth/auth_provider.dart @@ -48,6 +48,16 @@ final authRepositoryProvider = Provider((ref) { /// Non-null = show the SessionExpiredDialog with this message. final sessionExpiredProvider = StateProvider((ref) => null); +/// Cached pre-boot auth signal set in main() before runApp(). +/// +/// This is the synchronous routing hint used by [goRouterProvider] to set +/// [initialLocation] correctly on cold start — read from [AuthLocalDataSource] +/// before [runApp] via [AuthLocalDataSource.checkCachedLogin]. +/// +/// [authProvider] still performs full async verification after launch. +/// Must be overridden in [ProviderScope] with the result of that pre-boot check. +final cachedAuthFlagProvider = Provider((ref) => false); + @Riverpod(keepAlive: true) class Auth extends _$Auth { AuthRepository get _repository => ref.read(authRepositoryProvider); diff --git a/packages/core/lib/data/data.dart b/packages/core/lib/data/data.dart index 141c7811b..2fb8d1bcb 100644 --- a/packages/core/lib/data/data.dart +++ b/packages/core/lib/data/data.dart @@ -52,6 +52,7 @@ export 'db/database_provider.dart'; // Auth export 'auth/auth_provider.dart'; +export 'auth/auth_local_data_source.dart'; export 'auth/types/auth_exception.dart'; // Sources diff --git a/packages/core/test/data/auth/auth_repository_test.dart b/packages/core/test/data/auth/auth_repository_test.dart index 1a7c0c370..48dc4d6c3 100644 --- a/packages/core/test/data/auth/auth_repository_test.dart +++ b/packages/core/test/data/auth/auth_repository_test.dart @@ -3,7 +3,6 @@ import 'package:mockito/annotations.dart'; import 'package:mockito/mockito.dart'; import 'package:core/data/data.dart'; import 'package:core/data/auth/auth_api_service.dart'; -import 'package:core/data/auth/auth_local_data_source.dart'; import 'package:core/data/auth/auth_repository.dart'; @GenerateNiceMocks([ diff --git a/packages/courses/lib/widgets/lesson_detail/lesson_web_view.dart b/packages/courses/lib/widgets/lesson_detail/lesson_web_view.dart index 1a799cf4a..4884e94bc 100644 --- a/packages/courses/lib/widgets/lesson_detail/lesson_web_view.dart +++ b/packages/courses/lib/widgets/lesson_detail/lesson_web_view.dart @@ -3,7 +3,6 @@ import 'dart:io' show Platform; import 'package:flutter/widgets.dart'; import 'package:webview_flutter/webview_flutter.dart'; import 'package:core/core.dart'; -import 'package:core/data/auth/auth_local_data_source.dart'; import 'package:core/data/data.dart'; /// A WebView-based viewer for HTML and Embedded lesson content. diff --git a/packages/testpress/lib/navigation/app_router.dart b/packages/testpress/lib/navigation/app_router.dart index 5009d66a2..71efa8f3f 100644 --- a/packages/testpress/lib/navigation/app_router.dart +++ b/packages/testpress/lib/navigation/app_router.dart @@ -15,9 +15,16 @@ final _rootNavigatorKey = GlobalKey(debugLabel: 'root'); final goRouterProvider = Provider((ref) { const allTabs = NavTab.values; + // Use the pre-boot token check (read in main() before runApp()) to set + // initialLocation on the first frame. For authenticated users this means + // the router starts directly at /home — OnboardingScreen is never mounted. + // authProvider still performs full async verification after launch. + final isLoggedIn = ref.read(cachedAuthFlagProvider); + final initialLocation = isLoggedIn ? '/home' : '/onboarding'; + final router = GoRouter( navigatorKey: _rootNavigatorKey, - initialLocation: '/onboarding', + initialLocation: initialLocation, observers: [SentryService.createNavigatorObserver()], redirect: (context, state) => AuthRoutes.redirect(context, state), routes: [ diff --git a/packages/testpress/lib/navigation/routes/auth_routes.dart b/packages/testpress/lib/navigation/routes/auth_routes.dart index 41fa0e96b..2062f32cd 100644 --- a/packages/testpress/lib/navigation/routes/auth_routes.dart +++ b/packages/testpress/lib/navigation/routes/auth_routes.dart @@ -1,6 +1,7 @@ import 'package:flutter/widgets.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:core/core.dart'; +import 'package:core/data/data.dart'; import 'package:profile/profile.dart'; import '../bootstrap_provider.dart'; import '../page_transitions/slide_transition_page.dart'; @@ -17,15 +18,22 @@ class AuthRoutes { }; static String? redirect(BuildContext context, GoRouterState state) { - final bootstrapState = ProviderScope.containerOf( - context, - listen: false, - ).read(bootstrapProvider); + final container = ProviderScope.containerOf(context, listen: false); + final bootstrapState = container.read(bootstrapProvider); final path = state.uri.path; final isAuthRoute = _authPaths.contains(path); if (bootstrapState == BootstrapState.loading) { if (path == '/onboarding') return null; + + // Respect the pre-boot cached auth signal so the loading gate doesn't + // immediately bounce initialLocation='/home' back to /onboarding before + // authProvider has a chance to resolve. authProvider still performs full + // async verification in the background; once bootstrapProvider transitions + // out of loading, router.refresh() fires and the correct redirect applies. + final cachedIsLoggedIn = container.read(cachedAuthFlagProvider); + if (cachedIsLoggedIn && !isAuthRoute) return null; + return '/onboarding'; } diff --git a/packages/testpress/test/navigation/app_router_test.dart b/packages/testpress/test/navigation/app_router_test.dart index 1e52b72ae..d0601a587 100644 --- a/packages/testpress/test/navigation/app_router_test.dart +++ b/packages/testpress/test/navigation/app_router_test.dart @@ -1,10 +1,24 @@ +import 'dart:async'; + import 'package:core/core.dart'; import 'package:core/data/data.dart'; - +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:testpress/navigation/app_router.dart'; +import 'package:testpress/navigation/bootstrap_provider.dart'; + +// --------------------------------------------------------------------------- +// Minimal Auth mocks (same pattern as bootstrap_provider_test.dart) +// --------------------------------------------------------------------------- +class _AuthLoading extends Auth { + @override + FutureOr build() => Completer().future; // never resolves +} void main() { + // ------------------------------------------------------------------------- + // buildPrimaryNavigationItems + // ------------------------------------------------------------------------- group('buildPrimaryNavigationItems', () { test('keeps Profile as the last destination', () { final defaultSettings = InstituteSettings.fromJson({ @@ -37,4 +51,82 @@ void main() { skip: !AppConfig.showInfoTab, ); }); + + // ------------------------------------------------------------------------- + // goRouterProvider — initialLocation driven by cachedAuthFlagProvider + // ------------------------------------------------------------------------- + group('goRouterProvider initialLocation', () { + test('is /home when cachedAuthFlagProvider is true', () { + final container = ProviderContainer( + overrides: [ + cachedAuthFlagProvider.overrideWithValue(true), + authProvider.overrideWith(_AuthLoading.new), + instituteSettingsProvider.overrideWith((ref) => null), + ], + ); + addTearDown(container.dispose); + + final router = container.read(goRouterProvider); + expect(router.routeInformationProvider.value.uri.path, '/home'); + }); + + test('is /onboarding when cachedAuthFlagProvider is false', () { + final container = ProviderContainer( + overrides: [ + cachedAuthFlagProvider.overrideWithValue(false), + authProvider.overrideWith(_AuthLoading.new), + instituteSettingsProvider.overrideWith((ref) => null), + ], + ); + addTearDown(container.dispose); + + final router = container.read(goRouterProvider); + expect(router.routeInformationProvider.value.uri.path, '/onboarding'); + }); + }); + + // ------------------------------------------------------------------------- + // AuthRoutes.redirect — loading gate respects cachedAuthFlagProvider + // ------------------------------------------------------------------------- + group('AuthRoutes.redirect loading gate', () { + test('does NOT redirect /home → /onboarding when bootstrapState=loading ' + 'and cachedAuthFlagProvider=true', () async { + final container = ProviderContainer( + overrides: [ + cachedAuthFlagProvider.overrideWithValue(true), + authProvider.overrideWith(_AuthLoading.new), + instituteSettingsProvider.overrideWith((ref) => null), + ], + ); + addTearDown(container.dispose); + + // bootstrapState must be loading (auth never resolves in this test) + final bootstrap = container.read(bootstrapProvider); + expect(bootstrap, BootstrapState.loading); + + // With cachedAuthFlagProvider=true the router starts at /home. + // Verify the router is indeed at /home rather than having been + // bounced back to /onboarding by the loading gate. + final router = container.read(goRouterProvider); + expect(router.routeInformationProvider.value.uri.path, '/home'); + }); + + test( + 'still redirects unrecognised paths → /onboarding when bootstrapState=loading ' + 'and cachedAuthFlagProvider=false', + () { + final container = ProviderContainer( + overrides: [ + cachedAuthFlagProvider.overrideWithValue(false), + authProvider.overrideWith(_AuthLoading.new), + instituteSettingsProvider.overrideWith((ref) => null), + ], + ); + addTearDown(container.dispose); + + final router = container.read(goRouterProvider); + expect(router.routeInformationProvider.value.uri.path, '/onboarding'); + }, + ); + }); }