feat: Implement session management with SessionListener and integrate krow_data_connect
This commit is contained in:
@@ -14,8 +14,10 @@ import 'package:flutter_bloc/flutter_bloc.dart';
|
|||||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||||
import 'package:flutter_modular/flutter_modular.dart';
|
import 'package:flutter_modular/flutter_modular.dart';
|
||||||
import 'package:krow_core/core.dart';
|
import 'package:krow_core/core.dart';
|
||||||
|
import 'package:krow_data_connect/krow_data_connect.dart';
|
||||||
|
|
||||||
import 'firebase_options.dart';
|
import 'firebase_options.dart';
|
||||||
|
import 'src/widgets/session_listener.dart';
|
||||||
|
|
||||||
void main() async {
|
void main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
@@ -29,7 +31,17 @@ void main() async {
|
|||||||
logStateChanges: false, // Set to true for verbose debugging
|
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(
|
||||||
|
allowedRoles: <String>['CLIENT', 'BUSINESS', 'BOTH'], // Only allow users with CLIENT, BUSINESS, or BOTH roles
|
||||||
|
);
|
||||||
|
|
||||||
|
runApp(
|
||||||
|
ModularApp(
|
||||||
|
module: AppModule(),
|
||||||
|
child: const SessionListener(child: AppWidget()),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The main application module for the Client app.
|
/// The main application module for the Client app.
|
||||||
|
|||||||
163
apps/mobile/apps/client/lib/src/widgets/session_listener.dart
Normal file
163
apps/mobile/apps/client/lib/src/widgets/session_listener.dart
Normal 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;
|
||||||
|
}
|
||||||
@@ -41,6 +41,7 @@ dependencies:
|
|||||||
flutter_localizations:
|
flutter_localizations:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
firebase_core: ^4.4.0
|
firebase_core: ^4.4.0
|
||||||
|
krow_data_connect: ^0.0.1
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|||||||
@@ -1,56 +1,16 @@
|
|||||||
import 'dart:async';
|
import 'package:design_system/design_system.dart';
|
||||||
import 'package:client_authentication/src/domain/repositories/auth_repository_interface.dart';
|
|
||||||
import 'package:flutter/material.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});
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: Theme.of(context).colorScheme.surface,
|
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||||
body: Center(
|
body: Center(
|
||||||
child: Image.asset(
|
child: Image.asset(
|
||||||
'assets/logo-blue.png',
|
UiImageAssets.logoBlue,
|
||||||
package: 'design_system',
|
|
||||||
width: 120,
|
width: 120,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -17,9 +17,8 @@ class AuthRepositoryImpl implements AuthRepositoryInterface {
|
|||||||
Completer<String?>? _pendingVerification;
|
Completer<String?>? _pendingVerification;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Stream<domain.User?> get currentUser => _service.auth
|
Stream<domain.User?> get currentUser =>
|
||||||
.authStateChanges()
|
_service.auth.authStateChanges().map((User? firebaseUser) {
|
||||||
.map((User? firebaseUser) {
|
|
||||||
if (firebaseUser == null) {
|
if (firebaseUser == null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -58,11 +57,15 @@ class AuthRepositoryImpl implements AuthRepositoryInterface {
|
|||||||
if (e.code == 'network-request-failed' ||
|
if (e.code == 'network-request-failed' ||
|
||||||
e.message?.contains('Unable to resolve host') == true) {
|
e.message?.contains('Unable to resolve host') == true) {
|
||||||
completer.completeError(
|
completer.completeError(
|
||||||
const domain.NetworkException(technicalMessage: 'Auth network failure'),
|
const domain.NetworkException(
|
||||||
|
technicalMessage: 'Auth network failure',
|
||||||
|
),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
completer.completeError(
|
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,
|
verificationId: verificationId,
|
||||||
smsCode: smsCode,
|
smsCode: smsCode,
|
||||||
);
|
);
|
||||||
final UserCredential userCredential = await _service.run(
|
final UserCredential userCredential = await _service.run(() async {
|
||||||
() async {
|
try {
|
||||||
try {
|
return await _service.auth.signInWithCredential(credential);
|
||||||
return await _service.auth.signInWithCredential(credential);
|
} on FirebaseAuthException catch (e) {
|
||||||
} on FirebaseAuthException catch (e) {
|
if (e.code == 'invalid-verification-code') {
|
||||||
if (e.code == 'invalid-verification-code') {
|
throw const domain.InvalidCredentialsException(
|
||||||
throw const domain.InvalidCredentialsException(
|
technicalMessage: 'Invalid OTP code entered.',
|
||||||
technicalMessage: 'Invalid OTP code entered.',
|
);
|
||||||
);
|
|
||||||
}
|
|
||||||
rethrow;
|
|
||||||
}
|
}
|
||||||
},
|
rethrow;
|
||||||
requiresAuthentication: false,
|
}
|
||||||
);
|
}, requiresAuthentication: false);
|
||||||
final User? firebaseUser = userCredential.user;
|
final User? firebaseUser = userCredential.user;
|
||||||
if (firebaseUser == null) {
|
if (firebaseUser == null) {
|
||||||
throw const domain.SignInFailedException(
|
throw const domain.SignInFailedException(
|
||||||
@@ -135,13 +135,9 @@ class AuthRepositoryImpl implements AuthRepositoryInterface {
|
|||||||
|
|
||||||
final QueryResult<GetUserByIdData, GetUserByIdVariables> response =
|
final QueryResult<GetUserByIdData, GetUserByIdVariables> response =
|
||||||
await _service.run(
|
await _service.run(
|
||||||
() => _service.connector
|
() => _service.connector.getUserById(id: firebaseUser.uid).execute(),
|
||||||
.getUserById(
|
requiresAuthentication: false,
|
||||||
id: firebaseUser.uid,
|
);
|
||||||
)
|
|
||||||
.execute(),
|
|
||||||
requiresAuthentication: false,
|
|
||||||
);
|
|
||||||
final GetUserByIdUser? user = response.data.user;
|
final GetUserByIdUser? user = response.data.user;
|
||||||
|
|
||||||
GetStaffByUserIdStaffs? staffRecord;
|
GetStaffByUserIdStaffs? staffRecord;
|
||||||
@@ -150,10 +146,7 @@ class AuthRepositoryImpl implements AuthRepositoryInterface {
|
|||||||
if (user == null) {
|
if (user == null) {
|
||||||
await _service.run(
|
await _service.run(
|
||||||
() => _service.connector
|
() => _service.connector
|
||||||
.createUser(
|
.createUser(id: firebaseUser.uid, role: UserBaseRole.USER)
|
||||||
id: firebaseUser.uid,
|
|
||||||
role: UserBaseRole.USER,
|
|
||||||
)
|
|
||||||
.userRole('STAFF')
|
.userRole('STAFF')
|
||||||
.execute(),
|
.execute(),
|
||||||
requiresAuthentication: false,
|
requiresAuthentication: false,
|
||||||
@@ -161,11 +154,9 @@ class AuthRepositoryImpl implements AuthRepositoryInterface {
|
|||||||
} else {
|
} else {
|
||||||
// User exists in PostgreSQL. Check if they have a STAFF profile.
|
// User exists in PostgreSQL. Check if they have a STAFF profile.
|
||||||
final QueryResult<GetStaffByUserIdData, GetStaffByUserIdVariables>
|
final QueryResult<GetStaffByUserIdData, GetStaffByUserIdVariables>
|
||||||
staffResponse = await _service.run(
|
staffResponse = await _service.run(
|
||||||
() => _service.connector
|
() => _service.connector
|
||||||
.getStaffByUserId(
|
.getStaffByUserId(userId: firebaseUser.uid)
|
||||||
userId: firebaseUser.uid,
|
|
||||||
)
|
|
||||||
.execute(),
|
.execute(),
|
||||||
requiresAuthentication: false,
|
requiresAuthentication: false,
|
||||||
);
|
);
|
||||||
@@ -208,11 +199,9 @@ class AuthRepositoryImpl implements AuthRepositoryInterface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final QueryResult<GetStaffByUserIdData, GetStaffByUserIdVariables>
|
final QueryResult<GetStaffByUserIdData, GetStaffByUserIdVariables>
|
||||||
staffResponse = await _service.run(
|
staffResponse = await _service.run(
|
||||||
() => _service.connector
|
() => _service.connector
|
||||||
.getStaffByUserId(
|
.getStaffByUserId(userId: firebaseUser.uid)
|
||||||
userId: firebaseUser.uid,
|
|
||||||
)
|
|
||||||
.execute(),
|
.execute(),
|
||||||
requiresAuthentication: false,
|
requiresAuthentication: false,
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user