feat: Implement session management with SessionListener and integrate krow_data_connect

This commit is contained in:
Achintha Isuru
2026-02-17 15:19:08 -05:00
parent 8ce37d2306
commit 5b78f339a1
5 changed files with 209 additions and 84 deletions

View File

@@ -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: <String>['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.

View File

@@ -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<SessionListener> createState() => _SessionListenerState();
}
class _SessionListenerState extends State<SessionListener> {
late StreamSubscription<SessionState> _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<void>(
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: <Widget>[
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<void>(
context: context,
barrierDismissible: false,
builder: (BuildContext context) {
return AlertDialog(
title: const Text('Session Error'),
content: Text(errorMessage),
actions: <Widget>[
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;
}

View File

@@ -41,6 +41,7 @@ dependencies:
flutter_localizations:
sdk: flutter
firebase_core: ^4.4.0
krow_data_connect: ^0.0.1
dev_dependencies:
flutter_test:

View File

@@ -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<ClientIntroPage> createState() => _ClientIntroPageState();
}
class _ClientIntroPageState extends State<ClientIntroPage> {
@override
void initState() {
super.initState();
_checkSession();
}
Future<void> _checkSession() async {
// Check session immediately without artificial delay
if (!mounted) return;
try {
final AuthRepositoryInterface authRepo =
Modular.get<AuthRepositoryInterface>();
// 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,
),
),

View File

@@ -17,9 +17,8 @@ class AuthRepositoryImpl implements AuthRepositoryInterface {
Completer<String?>? _pendingVerification;
@override
Stream<domain.User?> get currentUser => _service.auth
.authStateChanges()
.map((User? firebaseUser) {
Stream<domain.User?> 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<GetUserByIdData, GetUserByIdVariables> 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<GetStaffByUserIdData, GetStaffByUserIdVariables>
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<GetStaffByUserIdData, GetStaffByUserIdVariables>
staffResponse = await _service.run(
staffResponse = await _service.run(
() => _service.connector
.getStaffByUserId(
userId: firebaseUser.uid,
)
.getStaffByUserId(userId: firebaseUser.uid)
.execute(),
requiresAuthentication: false,
);