From 95979b5569530ca2b11c5de995d64bb86048c5cf Mon Sep 17 00:00:00 2001 From: syed-tp Date: Wed, 9 Sep 2026 12:42:09 +0530 Subject: [PATCH 1/3] perf(auth): optimize session expiry transition to login --- packages/core/lib/data/auth/auth_provider.dart | 3 +-- packages/core/lib/data/auth/auth_repository.dart | 7 ++++--- packages/testpress/lib/navigation/app_router.dart | 4 ++-- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/core/lib/data/auth/auth_provider.dart b/packages/core/lib/data/auth/auth_provider.dart index b6d5b0609..13f816c20 100644 --- a/packages/core/lib/data/auth/auth_provider.dart +++ b/packages/core/lib/data/auth/auth_provider.dart @@ -127,6 +127,7 @@ class Auth extends _$Auth { } Future logout() async { + state = const AsyncData(false); try { // Safety net: explicitly clear the user row to guarantee no stale data leaks if the full purge fails final userRepo = await ref.read(userRepositoryProvider.future); @@ -136,8 +137,6 @@ class Auth extends _$Auth { await resetUseCase.execute(); await _repository.logout(); - - state = const AsyncData(false); } catch (e, stackTrace) { ref .read(sentryServiceProvider) diff --git a/packages/core/lib/data/auth/auth_repository.dart b/packages/core/lib/data/auth/auth_repository.dart index 6f40656cd..071f92ce9 100644 --- a/packages/core/lib/data/auth/auth_repository.dart +++ b/packages/core/lib/data/auth/auth_repository.dart @@ -117,12 +117,13 @@ class AuthRepository { Future logout() async { final token = await _localDataSource.getToken(); + await _clearToken(); try { - await _apiService.logout(authToken: token); + if (token != null && token.isNotEmpty) { + await _apiService.logout(authToken: token); + } } catch (_) { // Still logout locally if API fails - } finally { - await _clearToken(); } } diff --git a/packages/testpress/lib/navigation/app_router.dart b/packages/testpress/lib/navigation/app_router.dart index 71efa8f3f..16be6b7e6 100644 --- a/packages/testpress/lib/navigation/app_router.dart +++ b/packages/testpress/lib/navigation/app_router.dart @@ -160,9 +160,9 @@ class _AppShellBuilder extends ConsumerWidget { Positioned.fill( child: SessionExpiredDialog( message: sessionExpiredMessage, - onSignIn: () async { - await ref.read(authProvider.notifier).logout(); + onSignIn: () { ref.read(sessionExpiredProvider.notifier).state = null; + ref.read(authProvider.notifier).logout(); }, ), ), From 8bd29dc20bb8796f28b2cbcb91046326155e622a Mon Sep 17 00:00:00 2001 From: syed-tp Date: Wed, 9 Sep 2026 13:14:18 +0530 Subject: [PATCH 2/3] refactor: make logout cleanup asynchronous and gate login methods on its completion --- .../core/lib/data/auth/auth_provider.dart | 18 +++- .../test/data/auth/auth_provider_test.dart | 96 +++++++++++++++++++ .../testpress/lib/navigation/app_router.dart | 8 +- 3 files changed, 119 insertions(+), 3 deletions(-) diff --git a/packages/core/lib/data/auth/auth_provider.dart b/packages/core/lib/data/auth/auth_provider.dart index 13f816c20..b9e09860a 100644 --- a/packages/core/lib/data/auth/auth_provider.dart +++ b/packages/core/lib/data/auth/auth_provider.dart @@ -62,6 +62,10 @@ final cachedAuthFlagProvider = Provider((ref) => false); class Auth extends _$Auth { AuthRepository get _repository => ref.read(authRepositoryProvider); + /// Tracks the in-flight logout cleanup so login methods can wait for it + /// before writing new session data to the DB. + Future _cleanupFuture = Future.value(); + @override FutureOr build() async { return await _repository.isUserLoggedIn(); @@ -71,12 +75,16 @@ class Auth extends _$Auth { required String username, required String password, }) async { + // Wait for any in-flight logout cleanup to finish before writing + // new session data — prevents stale cleanup from wiping a fresh login. + await _cleanupFuture; await _repository.loginWithPassword(username: username, password: password); state = const AsyncData(true); } Future loginWithGoogle() async { + await _cleanupFuture; await _repository.loginWithGoogle(); state = const AsyncData(true); @@ -89,6 +97,7 @@ class Auth extends _$Auth { String? phone, String? countryCode, }) async { + await _cleanupFuture; await _repository.register( username: username, email: email, @@ -117,6 +126,7 @@ class Auth extends _$Auth { required String phoneNumber, String? email, }) async { + await _cleanupFuture; await _repository.verifyOtp( otp: otp, phoneNumber: phoneNumber, @@ -127,7 +137,13 @@ class Auth extends _$Auth { } Future logout() async { + // Flip auth state immediately — router redirects to Login on this frame. + // Store the cleanup work in _cleanupFuture so login methods can gate on it. state = const AsyncData(false); + _cleanupFuture = _runCleanup(); + } + + Future _runCleanup() async { try { // Safety net: explicitly clear the user row to guarantee no stale data leaks if the full purge fails final userRepo = await ref.read(userRepositoryProvider.future); @@ -145,8 +161,6 @@ class Auth extends _$Auth { stackTrace: stackTrace, level: AppErrorLevel.error, ); - state = const AsyncData(false); - rethrow; } } diff --git a/packages/core/test/data/auth/auth_provider_test.dart b/packages/core/test/data/auth/auth_provider_test.dart index 9089a6950..f0fe742c3 100644 --- a/packages/core/test/data/auth/auth_provider_test.dart +++ b/packages/core/test/data/auth/auth_provider_test.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter_test/flutter_test.dart'; import 'package:mockito/annotations.dart'; import 'package:mockito/mockito.dart'; @@ -99,12 +101,106 @@ void main() { expect(container.read(authProvider).value, isTrue); // Act + // logout() fires cleanup in the background — drain the event queue so + // all async hops in _runCleanup() complete before verifying mock calls. await container.read(authProvider.notifier).logout(); + await pumpEventQueue(); // Assert expect(container.read(authProvider).value, isFalse); verify(mockResetUseCase.execute()).called(1); verify(mockRepository.logout()).called(1); }); + + // Regression: auth state must flip to false synchronously on logout(), + // before any cleanup awaits, so the router redirects on the same frame. + test( + 'logout flips state to false synchronously before cleanup completes', + () async { + // Arrange + when(mockRepository.isUserLoggedIn()).thenAnswer((_) async => true); + await container.read(authProvider.future); + + final cleanupStarted = Completer(); + final cleanupGate = Completer(); + + when(mockUserRepo.clearCurrentUser()).thenAnswer((_) async { + cleanupStarted.complete(); + await cleanupGate.future; // block cleanup mid-flight + }); + + // Act — fire logout but don't await it; it returns immediately after + // flipping state, while cleanup is still blocked above. + final logoutFuture = container.read(authProvider.notifier).logout(); + + // Wait until cleanup has actually started (proving it's in-flight) + await cleanupStarted.future; + + // Assert — state is already false even though cleanup hasn't finished + expect( + container.read(authProvider).value, + isFalse, + reason: + 'auth state must flip synchronously so the router can redirect ' + 'on the same frame, before cleanup completes', + ); + + // Unblock cleanup and finish the logout + cleanupGate.complete(); + await logoutFuture; + }, + ); + + // Regression: a login attempted while logout cleanup is still running + // must not proceed until cleanup finishes — prevents stale cleanup from + // wiping the new session's freshly-written user row or cached data. + test( + 'loginWithPassword waits for in-flight logout cleanup before writing', + () async { + // Arrange + when(mockRepository.isUserLoggedIn()).thenAnswer((_) async => true); + await container.read(authProvider.future); + + final cleanupGate = Completer(); + final loginCalled = Completer(); + + // Block cleanup mid-flight + when(mockUserRepo.clearCurrentUser()).thenAnswer((_) async { + await cleanupGate.future; + }); + + // loginWithPassword should not be called until cleanup gate opens + when( + mockRepository.loginWithPassword(username: 'user', password: 'pass'), + ).thenAnswer((_) async { + loginCalled.complete(); + }); + + // Act — logout (cleanup blocked), then immediately attempt login + final logoutFuture = container.read(authProvider.notifier).logout(); + final loginFuture = container + .read(authProvider.notifier) + .loginWithPassword(username: 'user', password: 'pass'); + + // Give a short window — login must NOT have proceeded yet + await Future.delayed(const Duration(milliseconds: 50)); + expect( + loginCalled.isCompleted, + isFalse, + reason: + 'loginWithPassword must not write data while cleanup is still ' + 'in-flight', + ); + + // Unblock cleanup + cleanupGate.complete(); + await logoutFuture; + + // Now login should complete + await loginFuture; + expect(loginCalled.isCompleted, isTrue); + expect(container.read(authProvider).value, isTrue); + }, + ); }); } diff --git a/packages/testpress/lib/navigation/app_router.dart b/packages/testpress/lib/navigation/app_router.dart index 16be6b7e6..6f50254e1 100644 --- a/packages/testpress/lib/navigation/app_router.dart +++ b/packages/testpress/lib/navigation/app_router.dart @@ -161,8 +161,14 @@ class _AppShellBuilder extends ConsumerWidget { child: SessionExpiredDialog( message: sessionExpiredMessage, onSignIn: () { - ref.read(sessionExpiredProvider.notifier).state = null; + // Flip auth state first — router begins the transition to + // Login immediately. Clear the session message after the + // next frame so the dialog dismounts with the outgoing + // route, not before it, avoiding any overlap flash. ref.read(authProvider.notifier).logout(); + WidgetsBinding.instance.addPostFrameCallback((_) { + ref.read(sessionExpiredProvider.notifier).state = null; + }); }, ), ), From 1c66aebcc5d3b9e321de33e78cca7058ae17d410 Mon Sep 17 00:00:00 2001 From: syed-tp Date: Wed, 9 Sep 2026 14:10:01 +0530 Subject: [PATCH 3/3] refactor: move session expired overlay from app router to main application builder for consistent global presentation --- app/lib/main.dart | 25 ++++++++++++++++++- .../testpress/lib/navigation/app_router.dart | 19 -------------- 2 files changed, 24 insertions(+), 20 deletions(-) diff --git a/app/lib/main.dart b/app/lib/main.dart index 467efab5f..0dc9d49c4 100644 --- a/app/lib/main.dart +++ b/app/lib/main.dart @@ -65,6 +65,7 @@ class CortexApp extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final design = Design.of(context); final languageAsync = ref.watch(appLanguageSettingsNotifierProvider); + final sessionExpiredMessage = ref.watch(sessionExpiredProvider); final locale = languageAsync.maybeWhen( data: (settings) => settings.languageCode == 'system' @@ -112,7 +113,7 @@ class CortexApp extends ConsumerWidget { builder: (context, child) { final originalData = MediaQuery.of(context); final systemScale = originalData.textScaler.scale(1.0); - return MediaQuery( + final scaled = MediaQuery( data: originalData.copyWith( textScaler: TextScaler.linear(systemScale * scaleMultiplier), ), @@ -124,6 +125,28 @@ class CortexApp extends ConsumerWidget { child: child ?? const SizedBox.shrink(), ), ); + + // Session expired overlay is placed here — above the Navigator — + // so it is never part of any route transition and cannot overlap + // the incoming Login screen. + if (sessionExpiredMessage != null) { + return Stack( + children: [ + scaled, + Positioned.fill( + child: SessionExpiredDialog( + message: sessionExpiredMessage, + onSignIn: () { + ref.read(sessionExpiredProvider.notifier).state = null; + ref.read(authProvider.notifier).logout(); + }, + ), + ), + ], + ); + } + + return scaled; }, ); } diff --git a/packages/testpress/lib/navigation/app_router.dart b/packages/testpress/lib/navigation/app_router.dart index 6f50254e1..1133e6c24 100644 --- a/packages/testpress/lib/navigation/app_router.dart +++ b/packages/testpress/lib/navigation/app_router.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; -import 'package:flutter/widgets.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'routes.dart'; import 'package:core/core.dart'; @@ -117,7 +116,6 @@ class _AppShellBuilder extends ConsumerWidget { final items = activeTabs.map((tab) => tab.toTabItem(settings)).toList(); final isLogoutSheetOpen = ref.watch(isLogoutSheetOpenProvider); final activeTabId = allTabs[navigationShell.currentIndex].id; - final sessionExpiredMessage = ref.watch(sessionExpiredProvider); void closeSheet() => ref.read(isLogoutSheetOpenProvider.notifier).state = false; @@ -155,23 +153,6 @@ class _AppShellBuilder extends ConsumerWidget { ), child: navigationShell, ), - // Session expired overlay — shown above all content when a 401 fires - if (sessionExpiredMessage != null) - Positioned.fill( - child: SessionExpiredDialog( - message: sessionExpiredMessage, - onSignIn: () { - // Flip auth state first — router begins the transition to - // Login immediately. Clear the session message after the - // next frame so the dialog dismounts with the outgoing - // route, not before it, avoiding any overlap flash. - ref.read(authProvider.notifier).logout(); - WidgetsBinding.instance.addPostFrameCallback((_) { - ref.read(sessionExpiredProvider.notifier).state = null; - }); - }, - ), - ), ], ); },