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_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_data_connect/krow_data_connect.dart';
|
||||||
import 'package:krowwithus_staff/firebase_options.dart';
|
import 'package:krowwithus_staff/firebase_options.dart';
|
||||||
import 'package:staff_authentication/staff_authentication.dart'
|
import 'package:staff_authentication/staff_authentication.dart'
|
||||||
as staff_authentication;
|
as staff_authentication;
|
||||||
import 'package:staff_main/staff_main.dart' as staff_main;
|
import 'package:staff_main/staff_main.dart' as staff_main;
|
||||||
import 'package:krow_core/core.dart';
|
import 'package:krow_core/core.dart';
|
||||||
|
|
||||||
|
import 'src/widgets/session_listener.dart';
|
||||||
|
|
||||||
void main() async {
|
void main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
await Firebase.initializeApp(
|
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
|
||||||
options: DefaultFirebaseOptions.currentPlatform,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Register global BLoC observer for centralized error logging
|
// Register global BLoC observer for centralized error logging
|
||||||
Bloc.observer = CoreBlocObserver(
|
Bloc.observer = CoreBlocObserver(
|
||||||
logEvents: true,
|
logEvents: true,
|
||||||
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();
|
||||||
|
|
||||||
|
runApp(
|
||||||
|
ModularApp(
|
||||||
|
module: AppModule(),
|
||||||
|
child: const SessionListener(child: AppWidget()),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The main application module.
|
/// The main application module.
|
||||||
@@ -34,7 +43,10 @@ class AppModule extends Module {
|
|||||||
@override
|
@override
|
||||||
void routes(RouteManager r) {
|
void routes(RouteManager r) {
|
||||||
// Set the initial route to the authentication module
|
// 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());
|
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
|
path: ../../packages/features/staff/staff_main
|
||||||
krow_core:
|
krow_core:
|
||||||
path: ../../packages/core
|
path: ../../packages/core
|
||||||
|
krow_data_connect:
|
||||||
|
path: ../../packages/data_connect
|
||||||
cupertino_icons: ^1.0.8
|
cupertino_icons: ^1.0.8
|
||||||
flutter_modular: ^6.3.0
|
flutter_modular: ^6.3.0
|
||||||
firebase_core: ^4.4.0
|
firebase_core: ^4.4.0
|
||||||
|
|||||||
@@ -21,20 +21,27 @@ import 'route_paths.dart';
|
|||||||
///
|
///
|
||||||
/// See also:
|
/// See also:
|
||||||
/// * [ClientPaths] for route path constants
|
/// * [ClientPaths] for route path constants
|
||||||
/// * [StaffNavigator] for Staff app navigation
|
/// * [ClientNavigator] for Client app navigation
|
||||||
extension ClientNavigator on IModularNavigator {
|
extension ClientNavigator on IModularNavigator {
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
// AUTHENTICATION FLOWS
|
// AUTHENTICATION FLOWS
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
|
|
||||||
/// Navigate to the root authentication screen.
|
/// Navigate to the root authentication screen.
|
||||||
///
|
///
|
||||||
/// This effectively logs out the user by navigating to root.
|
/// This effectively logs out the user by navigating to root.
|
||||||
/// Used when signing out or session expires.
|
/// Used when signing out or session expires.
|
||||||
void toClientRoot() {
|
void toClientRoot() {
|
||||||
navigate(ClientPaths.root);
|
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.
|
/// Navigates to the client sign-in page.
|
||||||
///
|
///
|
||||||
/// This page allows existing clients to log in using email/password
|
/// This page allows existing clients to log in using email/password
|
||||||
|
|||||||
@@ -36,6 +36,13 @@ extension StaffNavigator on IModularNavigator {
|
|||||||
navigate(StaffPaths.root);
|
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.
|
/// Navigates to the phone verification page.
|
||||||
///
|
///
|
||||||
/// Used for both login and signup flows to verify phone numbers via OTP.
|
/// 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 the generated Data Connect SDK
|
||||||
export 'src/dataconnect_generated/generated.dart';
|
export 'src/dataconnect_generated/generated.dart';
|
||||||
export 'src/services/data_connect_service.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/session/staff_session_store.dart';
|
||||||
export 'src/services/mixins/data_error_handler.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 '../../krow_data_connect.dart' as dc;
|
||||||
import 'mixins/data_error_handler.dart';
|
import 'mixins/data_error_handler.dart';
|
||||||
|
import 'mixins/session_handler_mixin.dart';
|
||||||
|
|
||||||
/// A centralized service for interacting with Firebase Data Connect.
|
/// A centralized service for interacting with Firebase Data Connect.
|
||||||
///
|
///
|
||||||
/// This service provides common utilities and context management for all repositories.
|
/// This service provides common utilities and context management for all repositories.
|
||||||
class DataConnectService with DataErrorHandler {
|
class DataConnectService with DataErrorHandler, SessionHandlerMixin {
|
||||||
DataConnectService._();
|
DataConnectService._();
|
||||||
|
|
||||||
/// The singleton instance of the [DataConnectService].
|
/// The singleton instance of the [DataConnectService].
|
||||||
@@ -50,8 +51,11 @@ class DataConnectService with DataErrorHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final fdc.QueryResult<dc.GetStaffByUserIdData, dc.GetStaffByUserIdVariables>
|
final fdc.QueryResult<
|
||||||
response = await executeProtected(
|
dc.GetStaffByUserIdData,
|
||||||
|
dc.GetStaffByUserIdVariables
|
||||||
|
>
|
||||||
|
response = await executeProtected(
|
||||||
() => connector.getStaffByUserId(userId: user.uid).execute(),
|
() => connector.getStaffByUserId(userId: user.uid).execute(),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -130,13 +134,18 @@ class DataConnectService with DataErrorHandler {
|
|||||||
Future<T> run<T>(
|
Future<T> run<T>(
|
||||||
Future<T> Function() action, {
|
Future<T> Function() action, {
|
||||||
bool requiresAuthentication = true,
|
bool requiresAuthentication = true,
|
||||||
}) {
|
}) async {
|
||||||
if (requiresAuthentication && auth.currentUser == null) {
|
if (requiresAuthentication && auth.currentUser == null) {
|
||||||
throw const NotAuthenticatedException(
|
throw const NotAuthenticatedException(
|
||||||
technicalMessage: 'User must be authenticated to perform this action',
|
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).
|
/// Clears the internal cache (e.g., on logout).
|
||||||
@@ -144,4 +153,14 @@ class DataConnectService with DataErrorHandler {
|
|||||||
_cachedStaffId = null;
|
_cachedStaffId = null;
|
||||||
_cachedBusinessId = 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