From 7027f009dc61fd4bf0c952280d62fbe9f50dd9f9 Mon Sep 17 00:00:00 2001 From: syed-tp Date: Tue, 8 Sep 2026 19:01:11 +0530 Subject: [PATCH 1/2] feat: implement synchronous initial route redirection based on pre-boot auth token check --- app/lib/main.dart | 10 ++++++++++ app/pubspec.lock | 2 +- app/pubspec.yaml | 1 + .../data/providers/shared_preferences_provider.dart | 10 ++++++++++ packages/testpress/lib/navigation/app_router.dart | 9 ++++++++- 5 files changed, 30 insertions(+), 2 deletions(-) diff --git a/app/lib/main.dart b/app/lib/main.dart index ba16bf24..8f98cbd8 100644 --- a/app/lib/main.dart +++ b/app/lib/main.dart @@ -4,15 +4,25 @@ import 'package:testpress/testpress.dart'; import 'package:flutter_quill/flutter_quill.dart' as quill; import 'package:google_fonts/google_fonts.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); AppConfig.validate(); final sharedPreferences = await SharedPreferences.getInstance(); + + // Read the auth token before runApp() so the router can set initialLocation + // correctly on the very first frame — no OnboardingScreen flash for logged-in users. + // authProvider still performs full async verification after the app launches. + const secureStorage = FlutterSecureStorage(); + final token = await secureStorage.read(key: 'auth_token'); + final isLoggedIn = token != null && token.trim().isNotEmpty; + runApp( ProviderScope( overrides: [ sharedPreferencesProvider.overrideWithValue(sharedPreferences), + cachedAuthFlagProvider.overrideWithValue(isLoggedIn), ], child: const CortexAppRoot(), ), diff --git a/app/pubspec.lock b/app/pubspec.lock index 97245c80..5a78d33a 100644 --- a/app/pubspec.lock +++ b/app/pubspec.lock @@ -560,7 +560,7 @@ packages: source: hosted version: "2.6.1" flutter_secure_storage: - dependency: transitive + dependency: "direct main" description: name: flutter_secure_storage sha256: "9cad52d75ebc511adfae3d447d5d13da15a55a92c9410e50f67335b6d21d16ea" diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 79b6e707..6b41a703 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -17,6 +17,7 @@ dependencies: flutter_quill: ^11.5.1 http: ^1.6.0 shared_preferences: ^2.3.0 + flutter_secure_storage: ^9.2.2 dev_dependencies: flutter_test: diff --git a/packages/core/lib/data/providers/shared_preferences_provider.dart b/packages/core/lib/data/providers/shared_preferences_provider.dart index cd46d4e2..d70f71e8 100644 --- a/packages/core/lib/data/providers/shared_preferences_provider.dart +++ b/packages/core/lib/data/providers/shared_preferences_provider.dart @@ -8,3 +8,13 @@ final sharedPreferencesProvider = Provider((ref) { 'sharedPreferencesProvider must be overridden in ProviderScope', ); }); + +/// Cached auth flag read from [FlutterSecureStorage] in main() before runApp(). +/// +/// This is the single synchronous signal used by [goRouterProvider] to set +/// [initialLocation] correctly on cold start — without waiting for the async +/// auth verification that happens inside [authProvider]. +/// +/// Must be overridden in [ProviderScope] with the pre-boot token check result. +/// [authProvider] still performs full async verification after the app launches. +final cachedAuthFlagProvider = Provider((ref) => false); diff --git a/packages/testpress/lib/navigation/app_router.dart b/packages/testpress/lib/navigation/app_router.dart index 5009d66a..71efa8f3 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: [ From 201a61667935bb18a41e3166ad7fe2356f1d0414 Mon Sep 17 00:00:00 2001 From: syed-tp Date: Tue, 8 Sep 2026 19:21:56 +0530 Subject: [PATCH 2/2] feat: implement pre-boot auth state check and add cachedAuthFlagProvider for immediate router initialization --- app/lib/main.dart | 11 +-- app/pubspec.lock | 2 +- app/pubspec.yaml | 2 +- .../lib/data/auth/auth_local_data_source.dart | 14 +++ .../core/lib/data/auth/auth_provider.dart | 10 ++ packages/core/lib/data/data.dart | 1 + .../shared_preferences_provider.dart | 10 -- .../test/data/auth/auth_repository_test.dart | 1 - .../lesson_detail/lesson_web_view.dart | 1 - .../lib/navigation/routes/auth_routes.dart | 16 +++- .../test/navigation/app_router_test.dart | 94 ++++++++++++++++++- 11 files changed, 136 insertions(+), 26 deletions(-) diff --git a/app/lib/main.dart b/app/lib/main.dart index 8f98cbd8..467efab5 100644 --- a/app/lib/main.dart +++ b/app/lib/main.dart @@ -4,19 +4,16 @@ import 'package:testpress/testpress.dart'; import 'package:flutter_quill/flutter_quill.dart' as quill; import 'package:google_fonts/google_fonts.dart'; import 'package:shared_preferences/shared_preferences.dart'; -import 'package:flutter_secure_storage/flutter_secure_storage.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); AppConfig.validate(); final sharedPreferences = await SharedPreferences.getInstance(); - // Read the auth token before runApp() so the router can set initialLocation - // correctly on the very first frame — no OnboardingScreen flash for logged-in users. - // authProvider still performs full async verification after the app launches. - const secureStorage = FlutterSecureStorage(); - final token = await secureStorage.read(key: 'auth_token'); - final isLoggedIn = token != null && token.trim().isNotEmpty; + // 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( diff --git a/app/pubspec.lock b/app/pubspec.lock index 5a78d33a..97245c80 100644 --- a/app/pubspec.lock +++ b/app/pubspec.lock @@ -560,7 +560,7 @@ packages: source: hosted version: "2.6.1" flutter_secure_storage: - dependency: "direct main" + dependency: transitive description: name: flutter_secure_storage sha256: "9cad52d75ebc511adfae3d447d5d13da15a55a92c9410e50f67335b6d21d16ea" diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 6b41a703..404879ff 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -17,7 +17,7 @@ dependencies: flutter_quill: ^11.5.1 http: ^1.6.0 shared_preferences: ^2.3.0 - flutter_secure_storage: ^9.2.2 + dev_dependencies: flutter_test: 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 be4b7ba8..23e1a9e1 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 56b85acb..b6d5b060 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 141c7811..2fb8d1bc 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/lib/data/providers/shared_preferences_provider.dart b/packages/core/lib/data/providers/shared_preferences_provider.dart index d70f71e8..cd46d4e2 100644 --- a/packages/core/lib/data/providers/shared_preferences_provider.dart +++ b/packages/core/lib/data/providers/shared_preferences_provider.dart @@ -8,13 +8,3 @@ final sharedPreferencesProvider = Provider((ref) { 'sharedPreferencesProvider must be overridden in ProviderScope', ); }); - -/// Cached auth flag read from [FlutterSecureStorage] in main() before runApp(). -/// -/// This is the single synchronous signal used by [goRouterProvider] to set -/// [initialLocation] correctly on cold start — without waiting for the async -/// auth verification that happens inside [authProvider]. -/// -/// Must be overridden in [ProviderScope] with the pre-boot token check result. -/// [authProvider] still performs full async verification after the app launches. -final cachedAuthFlagProvider = Provider((ref) => false); diff --git a/packages/core/test/data/auth/auth_repository_test.dart b/packages/core/test/data/auth/auth_repository_test.dart index 1a7c0c37..48dc4d6c 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 1a799cf4..4884e94b 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/routes/auth_routes.dart b/packages/testpress/lib/navigation/routes/auth_routes.dart index 41fa0e96..2062f32c 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 1e52b72a..d0601a58 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'); + }, + ); + }); }