From 5b78f339a14fa5c95b1a8912d7c0c350b2ddd14f Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Tue, 17 Feb 2026 15:19:08 -0500 Subject: [PATCH] feat: Implement session management with SessionListener and integrate krow_data_connect --- apps/mobile/apps/client/lib/main.dart | 14 +- .../lib/src/widgets/session_listener.dart | 163 ++++++++++++++++++ apps/mobile/apps/client/pubspec.yaml | 1 + .../presentation/pages/client_intro_page.dart | 46 +---- .../auth_repository_impl.dart | 69 ++++---- 5 files changed, 209 insertions(+), 84 deletions(-) create mode 100644 apps/mobile/apps/client/lib/src/widgets/session_listener.dart diff --git a/apps/mobile/apps/client/lib/main.dart b/apps/mobile/apps/client/lib/main.dart index 47d6a076..a0e67c19 100644 --- a/apps/mobile/apps/client/lib/main.dart +++ b/apps/mobile/apps/client/lib/main.dart @@ -14,8 +14,10 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_modular/flutter_modular.dart'; import 'package:krow_core/core.dart'; +import 'package:krow_data_connect/krow_data_connect.dart'; import 'firebase_options.dart'; +import 'src/widgets/session_listener.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -28,8 +30,18 @@ void main() async { logEvents: true, logStateChanges: false, // Set to true for verbose debugging ); + + // Initialize session listener for Firebase Auth state changes + DataConnectService.instance.initializeAuthListener( + allowedRoles: ['CLIENT', 'BUSINESS', 'BOTH'], // Only allow users with CLIENT, BUSINESS, or BOTH roles + ); - runApp(ModularApp(module: AppModule(), child: const AppWidget())); + runApp( + ModularApp( + module: AppModule(), + child: const SessionListener(child: AppWidget()), + ), + ); } /// The main application module for the Client app. diff --git a/apps/mobile/apps/client/lib/src/widgets/session_listener.dart b/apps/mobile/apps/client/lib/src/widgets/session_listener.dart new file mode 100644 index 00000000..abb7b559 --- /dev/null +++ b/apps/mobile/apps/client/lib/src/widgets/session_listener.dart @@ -0,0 +1,163 @@ +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; + bool _isInitialState = true; + + @override + void initState() { + super.initState(); + _setupSessionListener(); + } + + void _setupSessionListener() { + _sessionSubscription = DataConnectService.instance.onSessionStateChanged + .listen((SessionState state) { + _handleSessionChange(state); + }); + + debugPrint('[SessionListener] Initialized session listener'); + } + + void _handleSessionChange(SessionState state) { + if (!mounted) return; + + switch (state.type) { + case SessionStateType.unauthenticated: + debugPrint( + '[SessionListener] Unauthenticated: Session expired or user logged out', + ); + // On initial state (cold start), just proceed to login without dialog + // Only show dialog if user was previously authenticated (session expired) + if (_isInitialState) { + _isInitialState = false; + Modular.to.toGetStartedPage(); + } else if (!_sessionExpiredDialogShown) { + _sessionExpiredDialogShown = true; + _showSessionExpiredDialog(); + } + break; + + case SessionStateType.authenticated: + // Session restored or user authenticated + _isInitialState = false; + _sessionExpiredDialogShown = false; + debugPrint('[SessionListener] Authenticated: ${state.userId}'); + + // Navigate to the main app + Modular.to.toClientHome(); + break; + + case SessionStateType.error: + // Show error notification with option to retry or logout + // Only show if not initial state (avoid showing on cold start) + if (!_isInitialState) { + debugPrint('[SessionListener] Session error: ${state.errorMessage}'); + _showSessionErrorDialog(state.errorMessage ?? 'Session error occurred'); + } else { + _isInitialState = false; + Modular.to.toInitialPage(); + } + 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.toInitialPage(); + } + + @override + void dispose() { + _sessionSubscription.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => widget.child; +} diff --git a/apps/mobile/apps/client/pubspec.yaml b/apps/mobile/apps/client/pubspec.yaml index 101a2b77..e947f7b5 100644 --- a/apps/mobile/apps/client/pubspec.yaml +++ b/apps/mobile/apps/client/pubspec.yaml @@ -41,6 +41,7 @@ dependencies: flutter_localizations: sdk: flutter firebase_core: ^4.4.0 + krow_data_connect: ^0.0.1 dev_dependencies: flutter_test: diff --git a/apps/mobile/packages/features/client/authentication/lib/src/presentation/pages/client_intro_page.dart b/apps/mobile/packages/features/client/authentication/lib/src/presentation/pages/client_intro_page.dart index 5420a013..418533fd 100644 --- a/apps/mobile/packages/features/client/authentication/lib/src/presentation/pages/client_intro_page.dart +++ b/apps/mobile/packages/features/client/authentication/lib/src/presentation/pages/client_intro_page.dart @@ -1,56 +1,16 @@ -import 'dart:async'; -import 'package:client_authentication/src/domain/repositories/auth_repository_interface.dart'; +import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_modular/flutter_modular.dart'; -import 'package:krow_core/core.dart'; -class ClientIntroPage extends StatefulWidget { +class ClientIntroPage extends StatelessWidget { const ClientIntroPage({super.key}); - @override - State createState() => _ClientIntroPageState(); -} - -class _ClientIntroPageState extends State { - @override - void initState() { - super.initState(); - _checkSession(); - } - - Future _checkSession() async { - // Check session immediately without artificial delay - if (!mounted) return; - - try { - final AuthRepositoryInterface authRepo = - Modular.get(); - // Add a timeout to prevent infinite loading - final user = true; - - if (mounted) { - if (user != null) { - Modular.to.navigate(ClientPaths.home); - } else { - Modular.to.navigate(ClientPaths.getStarted); - } - } - } catch (e) { - debugPrint('ClientIntroPage: Session check error: $e'); - if (mounted) { - Modular.to.navigate(ClientPaths.getStarted); - } - } - } - @override Widget build(BuildContext context) { return Scaffold( backgroundColor: Theme.of(context).colorScheme.surface, body: Center( child: Image.asset( - 'assets/logo-blue.png', - package: 'design_system', + UiImageAssets.logoBlue, width: 120, ), ), diff --git a/apps/mobile/packages/features/staff/authentication/lib/src/data/repositories_impl/auth_repository_impl.dart b/apps/mobile/packages/features/staff/authentication/lib/src/data/repositories_impl/auth_repository_impl.dart index b247880e..e2dab61b 100644 --- a/apps/mobile/packages/features/staff/authentication/lib/src/data/repositories_impl/auth_repository_impl.dart +++ b/apps/mobile/packages/features/staff/authentication/lib/src/data/repositories_impl/auth_repository_impl.dart @@ -17,9 +17,8 @@ class AuthRepositoryImpl implements AuthRepositoryInterface { Completer? _pendingVerification; @override - Stream get currentUser => _service.auth - .authStateChanges() - .map((User? firebaseUser) { + Stream get currentUser => + _service.auth.authStateChanges().map((User? firebaseUser) { if (firebaseUser == null) { return null; } @@ -49,20 +48,24 @@ class AuthRepositoryImpl implements AuthRepositoryInterface { // For real numbers, we can support auto-verification if desired. // But since this method returns a verificationId for manual OTP entry, // we might not handle direct sign-in here unless the architecture changes. - // Currently, we just ignore it for the completer flow, + // Currently, we just ignore it for the completer flow, // or we could sign in directly if the credential is provided. }, verificationFailed: (FirebaseAuthException e) { if (!completer.isCompleted) { // Map Firebase network errors to NetworkException - if (e.code == 'network-request-failed' || + if (e.code == 'network-request-failed' || e.message?.contains('Unable to resolve host') == true) { completer.completeError( - const domain.NetworkException(technicalMessage: 'Auth network failure'), + const domain.NetworkException( + technicalMessage: 'Auth network failure', + ), ); } else { completer.completeError( - domain.SignInFailedException(technicalMessage: 'Firebase ${e.code}: ${e.message}'), + domain.SignInFailedException( + technicalMessage: 'Firebase ${e.code}: ${e.message}', + ), ); } } @@ -110,21 +113,18 @@ class AuthRepositoryImpl implements AuthRepositoryInterface { verificationId: verificationId, smsCode: smsCode, ); - final UserCredential userCredential = await _service.run( - () async { - try { - return await _service.auth.signInWithCredential(credential); - } on FirebaseAuthException catch (e) { - if (e.code == 'invalid-verification-code') { - throw const domain.InvalidCredentialsException( - technicalMessage: 'Invalid OTP code entered.', - ); - } - rethrow; + final UserCredential userCredential = await _service.run(() async { + try { + return await _service.auth.signInWithCredential(credential); + } on FirebaseAuthException catch (e) { + if (e.code == 'invalid-verification-code') { + throw const domain.InvalidCredentialsException( + technicalMessage: 'Invalid OTP code entered.', + ); } - }, - requiresAuthentication: false, - ); + rethrow; + } + }, requiresAuthentication: false); final User? firebaseUser = userCredential.user; if (firebaseUser == null) { throw const domain.SignInFailedException( @@ -135,13 +135,9 @@ class AuthRepositoryImpl implements AuthRepositoryInterface { final QueryResult response = await _service.run( - () => _service.connector - .getUserById( - id: firebaseUser.uid, - ) - .execute(), - requiresAuthentication: false, - ); + () => _service.connector.getUserById(id: firebaseUser.uid).execute(), + requiresAuthentication: false, + ); final GetUserByIdUser? user = response.data.user; GetStaffByUserIdStaffs? staffRecord; @@ -150,10 +146,7 @@ class AuthRepositoryImpl implements AuthRepositoryInterface { if (user == null) { await _service.run( () => _service.connector - .createUser( - id: firebaseUser.uid, - role: UserBaseRole.USER, - ) + .createUser(id: firebaseUser.uid, role: UserBaseRole.USER) .userRole('STAFF') .execute(), requiresAuthentication: false, @@ -161,11 +154,9 @@ class AuthRepositoryImpl implements AuthRepositoryInterface { } else { // User exists in PostgreSQL. Check if they have a STAFF profile. final QueryResult - staffResponse = await _service.run( + staffResponse = await _service.run( () => _service.connector - .getStaffByUserId( - userId: firebaseUser.uid, - ) + .getStaffByUserId(userId: firebaseUser.uid) .execute(), requiresAuthentication: false, ); @@ -208,11 +199,9 @@ class AuthRepositoryImpl implements AuthRepositoryInterface { } final QueryResult - staffResponse = await _service.run( + staffResponse = await _service.run( () => _service.connector - .getStaffByUserId( - userId: firebaseUser.uid, - ) + .getStaffByUserId(userId: firebaseUser.uid) .execute(), requiresAuthentication: false, );