From be40614274653b9c281323a1c6166434e5139269 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Tue, 17 Feb 2026 14:03:24 -0500 Subject: [PATCH] feat: Implement session management with SessionListener and SessionHandlerMixin --- apps/mobile/apps/staff/lib/main.dart | 26 ++- .../lib/src/widgets/session_listener.dart | 149 +++++++++++++ apps/mobile/apps/staff/pubspec.yaml | 2 + .../lib/src/routing/client/navigator.dart | 11 +- .../core/lib/src/routing/staff/navigator.dart | 7 + .../data_connect/lib/krow_data_connect.dart | 1 + .../src/services/data_connect_service.dart | 29 ++- .../mixins/session_handler_mixin.dart | 199 ++++++++++++++++++ 8 files changed, 410 insertions(+), 14 deletions(-) create mode 100644 apps/mobile/apps/staff/lib/src/widgets/session_listener.dart create mode 100644 apps/mobile/packages/data_connect/lib/src/services/mixins/session_handler_mixin.dart diff --git a/apps/mobile/apps/staff/lib/main.dart b/apps/mobile/apps/staff/lib/main.dart index eba7af00..73c8ad95 100644 --- a/apps/mobile/apps/staff/lib/main.dart +++ b/apps/mobile/apps/staff/lib/main.dart @@ -5,25 +5,34 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_modular/flutter_modular.dart'; +import 'package:krow_data_connect/krow_data_connect.dart'; import 'package:krowwithus_staff/firebase_options.dart'; import 'package:staff_authentication/staff_authentication.dart' as staff_authentication; import 'package:staff_main/staff_main.dart' as staff_main; import 'package:krow_core/core.dart'; +import 'src/widgets/session_listener.dart'; + void main() async { WidgetsFlutterBinding.ensureInitialized(); - await Firebase.initializeApp( - options: DefaultFirebaseOptions.currentPlatform, - ); - + await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform); + // Register global BLoC observer for centralized error logging Bloc.observer = CoreBlocObserver( logEvents: true, logStateChanges: false, // Set to true for verbose debugging ); - - runApp(ModularApp(module: AppModule(), child: const AppWidget())); + + // Initialize session listener for Firebase Auth state changes + DataConnectService.instance.initializeAuthListener(); + + runApp( + ModularApp( + module: AppModule(), + child: const SessionListener(child: AppWidget()), + ), + ); } /// The main application module. @@ -34,7 +43,10 @@ class AppModule extends Module { @override void routes(RouteManager r) { // Set the initial route to the authentication module - r.module(StaffPaths.root, module: staff_authentication.StaffAuthenticationModule()); + r.module( + StaffPaths.root, + module: staff_authentication.StaffAuthenticationModule(), + ); r.module(StaffPaths.main, module: staff_main.StaffMainModule()); } diff --git a/apps/mobile/apps/staff/lib/src/widgets/session_listener.dart b/apps/mobile/apps/staff/lib/src/widgets/session_listener.dart new file mode 100644 index 00000000..bc40deea --- /dev/null +++ b/apps/mobile/apps/staff/lib/src/widgets/session_listener.dart @@ -0,0 +1,149 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_modular/flutter_modular.dart'; +import 'package:krow_core/core.dart'; +import 'package:krow_data_connect/krow_data_connect.dart'; + +/// A widget that listens to session state changes and handles global reactions. +/// +/// This widget wraps the entire app and provides centralized session management, +/// such as logging out when the session expires or handling session errors. +class SessionListener extends StatefulWidget { + /// Creates a [SessionListener]. + const SessionListener({required this.child, super.key}); + + /// The child widget to wrap. + final Widget child; + + @override + State createState() => _SessionListenerState(); +} + +class _SessionListenerState extends State { + late StreamSubscription _sessionSubscription; + bool _sessionExpiredDialogShown = false; + + @override + void initState() { + super.initState(); + _setupSessionListener(); + } + + void _setupSessionListener() { + _sessionSubscription = DataConnectService.instance.onSessionStateChanged + .listen((SessionState state) { + _handleSessionChange(state); + }); + } + + void _handleSessionChange(SessionState state) { + if (!mounted) return; + + switch (state.type) { + case SessionStateType.unauthenticated: + debugPrint( + '[SessionListener] Unauthenticated: Session expired or user logged out', + ); + // Show expiration dialog if not already shown + if (!_sessionExpiredDialogShown) { + _sessionExpiredDialogShown = true; + _showSessionExpiredDialog(); + } + break; + + case SessionStateType.authenticated: + // Session restored or user authenticated + _sessionExpiredDialogShown = false; + debugPrint('[SessionListener] Authenticated: ${state.userId}'); + + // Navigate to the main app + Modular.to.toStaffHome(); + break; + + case SessionStateType.error: + // Show error notification with option to retry or logout + debugPrint('[SessionListener] Session error: ${state.errorMessage}'); + _showSessionErrorDialog(state.errorMessage ?? 'Session error occurred'); + break; + + case SessionStateType.loading: + // Session is loading, optionally show a loading indicator + debugPrint('[SessionListener] Session loading...'); + break; + } + } + + /// Shows a dialog when the session expires. + void _showSessionExpiredDialog() { + showDialog( + context: context, + barrierDismissible: false, + builder: (BuildContext context) { + return AlertDialog( + title: const Text('Session Expired'), + content: const Text( + 'Your session has expired. Please log in again to continue.', + ), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(); + _proceedToLogin(); + }, + child: const Text('Log In'), + ), + ], + ); + }, + ); + } + + /// Shows a dialog when a session error occurs, with retry option. + void _showSessionErrorDialog(String errorMessage) { + showDialog( + context: context, + barrierDismissible: false, + builder: (BuildContext context) { + return AlertDialog( + title: const Text('Session Error'), + content: Text(errorMessage), + actions: [ + TextButton( + onPressed: () { + // User can retry by dismissing and continuing + Modular.to.pop(); + }, + child: const Text('Continue'), + ), + TextButton( + onPressed: () { + Navigator.of(context).pop(); + _proceedToLogin(); + }, + child: const Text('Log Out'), + ), + ], + ); + }, + ); + } + + /// Navigate to login screen and clear navigation stack. + void _proceedToLogin() { + // Clear service caches on sign-out + DataConnectService.instance.handleSignOut(); + + // Navigate to authentication + Modular.to.toGetStarted(); + } + + @override + void dispose() { + _sessionSubscription.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => widget.child; +} diff --git a/apps/mobile/apps/staff/pubspec.yaml b/apps/mobile/apps/staff/pubspec.yaml index 8bc77687..d3b270ef 100644 --- a/apps/mobile/apps/staff/pubspec.yaml +++ b/apps/mobile/apps/staff/pubspec.yaml @@ -28,6 +28,8 @@ dependencies: path: ../../packages/features/staff/staff_main krow_core: path: ../../packages/core + krow_data_connect: + path: ../../packages/data_connect cupertino_icons: ^1.0.8 flutter_modular: ^6.3.0 firebase_core: ^4.4.0 diff --git a/apps/mobile/packages/core/lib/src/routing/client/navigator.dart b/apps/mobile/packages/core/lib/src/routing/client/navigator.dart index d51abda4..4c7bcd34 100644 --- a/apps/mobile/packages/core/lib/src/routing/client/navigator.dart +++ b/apps/mobile/packages/core/lib/src/routing/client/navigator.dart @@ -21,20 +21,27 @@ import 'route_paths.dart'; /// /// See also: /// * [ClientPaths] for route path constants -/// * [StaffNavigator] for Staff app navigation +/// * [ClientNavigator] for Client app navigation extension ClientNavigator on IModularNavigator { // ========================================================================== // AUTHENTICATION FLOWS // ========================================================================== /// Navigate to the root authentication screen. - /// + /// /// This effectively logs out the user by navigating to root. /// Used when signing out or session expires. void toClientRoot() { navigate(ClientPaths.root); } + /// Navigates to the get started page. + /// + /// This is the landing page for unauthenticated users, offering login/signup options. + void toClientGetStartedPage() { + navigate(ClientPaths.getStarted); + } + /// Navigates to the client sign-in page. /// /// This page allows existing clients to log in using email/password diff --git a/apps/mobile/packages/core/lib/src/routing/staff/navigator.dart b/apps/mobile/packages/core/lib/src/routing/staff/navigator.dart index 2e22a0ce..bcfdbaa0 100644 --- a/apps/mobile/packages/core/lib/src/routing/staff/navigator.dart +++ b/apps/mobile/packages/core/lib/src/routing/staff/navigator.dart @@ -36,6 +36,13 @@ extension StaffNavigator on IModularNavigator { navigate(StaffPaths.root); } + /// Navigates to the get started page. + /// + /// This is the landing page for unauthenticated users, offering login/signup options. + void toGetStartedPage() { + navigate(StaffPaths.getStarted); + } + /// Navigates to the phone verification page. /// /// Used for both login and signup flows to verify phone numbers via OTP. diff --git a/apps/mobile/packages/data_connect/lib/krow_data_connect.dart b/apps/mobile/packages/data_connect/lib/krow_data_connect.dart index 833f7115..7afa4c97 100644 --- a/apps/mobile/packages/data_connect/lib/krow_data_connect.dart +++ b/apps/mobile/packages/data_connect/lib/krow_data_connect.dart @@ -13,6 +13,7 @@ export 'src/session/client_session_store.dart'; // Export the generated Data Connect SDK export 'src/dataconnect_generated/generated.dart'; export 'src/services/data_connect_service.dart'; +export 'src/services/mixins/session_handler_mixin.dart'; export 'src/session/staff_session_store.dart'; export 'src/services/mixins/data_error_handler.dart'; diff --git a/apps/mobile/packages/data_connect/lib/src/services/data_connect_service.dart b/apps/mobile/packages/data_connect/lib/src/services/data_connect_service.dart index 95f712c6..539face8 100644 --- a/apps/mobile/packages/data_connect/lib/src/services/data_connect_service.dart +++ b/apps/mobile/packages/data_connect/lib/src/services/data_connect_service.dart @@ -7,11 +7,12 @@ import 'package:krow_domain/krow_domain.dart'; import '../../krow_data_connect.dart' as dc; import 'mixins/data_error_handler.dart'; +import 'mixins/session_handler_mixin.dart'; /// A centralized service for interacting with Firebase Data Connect. /// /// This service provides common utilities and context management for all repositories. -class DataConnectService with DataErrorHandler { +class DataConnectService with DataErrorHandler, SessionHandlerMixin { DataConnectService._(); /// The singleton instance of the [DataConnectService]. @@ -50,8 +51,11 @@ class DataConnectService with DataErrorHandler { } try { - final fdc.QueryResult - response = await executeProtected( + final fdc.QueryResult< + dc.GetStaffByUserIdData, + dc.GetStaffByUserIdVariables + > + response = await executeProtected( () => connector.getStaffByUserId(userId: user.uid).execute(), ); @@ -130,13 +134,18 @@ class DataConnectService with DataErrorHandler { Future run( Future Function() action, { bool requiresAuthentication = true, - }) { + }) async { if (requiresAuthentication && auth.currentUser == null) { throw const NotAuthenticatedException( technicalMessage: 'User must be authenticated to perform this action', ); } - return executeProtected(action); + + return executeProtected(() async { + // Ensure session token is valid and refresh if needed + await ensureSessionValid(); + return action(); + }); } /// Clears the internal cache (e.g., on logout). @@ -144,4 +153,14 @@ class DataConnectService with DataErrorHandler { _cachedStaffId = null; _cachedBusinessId = null; } + + /// Handle session sign-out by clearing caches. + void handleSignOut() { + clearCache(); + } + + /// Dispose all resources (call on app shutdown). + Future dispose() async { + await disposeSessionHandler(); + } } diff --git a/apps/mobile/packages/data_connect/lib/src/services/mixins/session_handler_mixin.dart b/apps/mobile/packages/data_connect/lib/src/services/mixins/session_handler_mixin.dart new file mode 100644 index 00000000..433b813b --- /dev/null +++ b/apps/mobile/packages/data_connect/lib/src/services/mixins/session_handler_mixin.dart @@ -0,0 +1,199 @@ +import 'dart:async'; + +import 'package:firebase_auth/firebase_auth.dart' as firebase_auth; +import 'package:flutter/cupertino.dart'; + +/// Enum representing the current session state. +enum SessionStateType { loading, authenticated, unauthenticated, error } + +/// Data class for session state. +class SessionState { + /// Creates a [SessionState]. + SessionState({required this.type, this.userId, this.errorMessage}); + + /// Creates a loading state. + factory SessionState.loading() => + SessionState(type: SessionStateType.loading); + + /// Creates an authenticated state. + factory SessionState.authenticated({required String userId}) => + SessionState(type: SessionStateType.authenticated, userId: userId); + + /// Creates an unauthenticated state. + factory SessionState.unauthenticated() => + SessionState(type: SessionStateType.unauthenticated); + + /// Creates an error state. + factory SessionState.error(String message) => + SessionState(type: SessionStateType.error, errorMessage: message); + + /// The type of session state. + final SessionStateType type; + + /// The current user ID (if authenticated). + final String? userId; + + /// Error message (if error occurred). + final String? errorMessage; + + @override + String toString() => + 'SessionState(type: $type, userId: $userId, error: $errorMessage)'; +} + +/// Mixin for handling Firebase Auth session management, token refresh, and state emissions. +mixin SessionHandlerMixin { + /// Stream controller for session state changes. + final StreamController _sessionStateController = + StreamController.broadcast(); + + /// Public stream for listening to session state changes. + Stream get onSessionStateChanged => + _sessionStateController.stream; + + /// Last token refresh timestamp to avoid excessive checks. + DateTime? _lastTokenRefreshTime; + + /// Subscription to auth state changes. + StreamSubscription? _authStateSubscription; + + /// Minimum interval between token refresh checks. + static const Duration _minRefreshCheckInterval = Duration(seconds: 2); + + /// Time before token expiry to trigger a refresh. + static const Duration _refreshThreshold = Duration(minutes: 5); + + /// Firebase Auth instance (to be provided by implementing class). + firebase_auth.FirebaseAuth get auth; + + /// Initialize the auth state listener (call once on app startup). + void initializeAuthListener() { + // Cancel any existing subscription first + _authStateSubscription?.cancel(); + + // Listen to Firebase auth state changes + _authStateSubscription = auth.authStateChanges().listen( + (firebase_auth.User? user) async { + if (user == null) { + _handleSignOut(); + } else { + await _handleSignIn(user); + } + }, + onError: (Object error) { + _emitSessionState(SessionState.error(error.toString())); + }, + ); + } + + /// Ensures the Firebase auth token is valid and refreshes if needed. + /// Retries up to 3 times with exponential backoff before emitting error. + Future ensureSessionValid() async { + final firebase_auth.User? user = auth.currentUser; + + // No user = not authenticated, skip check + if (user == null) return; + + // Optimization: Skip if we just checked within the last 2 seconds + final DateTime now = DateTime.now(); + if (_lastTokenRefreshTime != null) { + final Duration timeSinceLastCheck = now.difference( + _lastTokenRefreshTime!, + ); + if (timeSinceLastCheck < _minRefreshCheckInterval) { + return; // Skip redundant check + } + } + + const int maxRetries = 3; + int retryCount = 0; + + while (retryCount < maxRetries) { + try { + // Get token result (doesn't fetch from network unless needed) + final firebase_auth.IdTokenResult idToken = + await user.getIdTokenResult(); + + // Extract expiration time + final DateTime? expiryTime = idToken.expirationTime; + + if (expiryTime == null) { + return; // Token info unavailable, proceed anyway + } + + // Calculate time until expiry + final Duration timeUntilExpiry = expiryTime.difference(now); + + // If token expires within 5 minutes, refresh it + if (timeUntilExpiry <= _refreshThreshold) { + await user.getIdTokenResult(); + } + + // Update last refresh check timestamp + _lastTokenRefreshTime = now; + return; // Success, exit retry loop + } catch (e) { + retryCount++; + debugPrint( + 'Token validation error (attempt $retryCount/$maxRetries): $e', + ); + + // If we've exhausted retries, emit error + if (retryCount >= maxRetries) { + _emitSessionState( + SessionState.error( + 'Token validation failed after $maxRetries attempts: $e', + ), + ); + return; + } + + // Exponential backoff: 1s, 2s, 4s + final Duration backoffDuration = Duration( + seconds: 1 << (retryCount - 1), // 2^(retryCount-1) + ); + debugPrint('Retrying token validation in ${backoffDuration.inSeconds}s'); + await Future.delayed(backoffDuration); + } + } + } + + /// Handle user sign-in event. + Future _handleSignIn(firebase_auth.User user) async { + try { + _emitSessionState(SessionState.loading()); + + // Get fresh token to validate session + final firebase_auth.IdTokenResult idToken = await user.getIdTokenResult(); + if (idToken.expirationTime != null && + DateTime.now().difference(idToken.expirationTime!) < + const Duration(minutes: 5)) { + // Token is expiring soon, refresh it + await user.getIdTokenResult(); + } + + // Emit authenticated state + _emitSessionState(SessionState.authenticated(userId: user.uid)); + } catch (e) { + _emitSessionState(SessionState.error(e.toString())); + } + } + + /// Handle user sign-out event. + void _handleSignOut() { + _emitSessionState(SessionState.unauthenticated()); + } + + /// Emit session state update. + void _emitSessionState(SessionState state) { + if (!_sessionStateController.isClosed) { + _sessionStateController.add(state); + } + } + + /// Dispose session handler resources. + Future disposeSessionHandler() async { + await _authStateSubscription?.cancel(); + await _sessionStateController.close(); + } +}