feat: Implement session management with SessionListener and SessionHandlerMixin

This commit is contained in:
Achintha Isuru
2026-02-17 14:03:24 -05:00
parent 506da5e26f
commit be40614274
8 changed files with 410 additions and 14 deletions

View File

@@ -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';

View File

@@ -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();
}
}

View File

@@ -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();
}
}