feat: Implement session management with SessionListener and SessionHandlerMixin
This commit is contained in:
@@ -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());
|
||||
}
|
||||
|
||||
149
apps/mobile/apps/staff/lib/src/widgets/session_listener.dart
Normal file
149
apps/mobile/apps/staff/lib/src/widgets/session_listener.dart
Normal file
@@ -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<SessionListener> createState() => _SessionListenerState();
|
||||
}
|
||||
|
||||
class _SessionListenerState extends State<SessionListener> {
|
||||
late StreamSubscription<SessionState> _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<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.toGetStarted();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_sessionSubscription.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => widget.child;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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<dc.GetStaffByUserIdData, dc.GetStaffByUserIdVariables>
|
||||
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<T> run<T>(
|
||||
Future<T> 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<void> dispose() async {
|
||||
await disposeSessionHandler();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<SessionState> _sessionStateController =
|
||||
StreamController<SessionState>.broadcast();
|
||||
|
||||
/// Public stream for listening to session state changes.
|
||||
Stream<SessionState> get onSessionStateChanged =>
|
||||
_sessionStateController.stream;
|
||||
|
||||
/// Last token refresh timestamp to avoid excessive checks.
|
||||
DateTime? _lastTokenRefreshTime;
|
||||
|
||||
/// Subscription to auth state changes.
|
||||
StreamSubscription<firebase_auth.User?>? _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<void> 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<void>.delayed(backoffDuration);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle user sign-in event.
|
||||
Future<void> _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<void> disposeSessionHandler() async {
|
||||
await _authStateSubscription?.cancel();
|
||||
await _sessionStateController.close();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user