This codelab demonstrates how to implement Session Management flow using the rdna_client Flutter plugin. Session management provides critical security features including automatic session timeout handling, idle session warnings with extension capabilities, and seamless session lifecycle management to prevent unexpected user logouts.
The code to get started is stored in a GitHub repository.
You can clone the repository using the following command:
git clone https://github.com/uniken-public/codelab-flutter.git
Navigate to the relid-MFA-session-management folder in the repository you cloned earlier
rdna_client plugin installed and configuredThe sample app provides a complete session management implementation. Let's examine the key components:
Component | Purpose | Sample App Reference |
Session Provider | Global session state management |
|
Session Modal | UI with countdown and extension |
|
Event Handling | Extended event manager |
|
Session Types | Dart class definitions |
|
The RELID SDK triggers three main session management events:
Event Type | Description | User Action Required |
Hard session timeout - session already expired | User must acknowledge and app navigates to home | |
Idle session warning - session will expire soon | User can extend session or let it expire | |
Response from session extension API call | Handle success/failure of extension attempt |
The session management flow follows this pattern:
onSessionTimeOutNotification triggers with countdown and extension optionextendSessionIdleTimeout() APIonSessionExtensionResponse provides success/failure resultonSessionTimeout forces app navigation when session expiresDefine Dart classes for comprehensive session timeout handling:
// rdna_client/lib/rdna_struct.dart (excerpts)
/// RDNA Session Response Data
/// Used for both session timeout notifications and extension responses
class SessionResponse {
final String userID;
final String message; // Session timeout/extension message
final int timeLeftInSeconds; // Countdown timer value
final int sessionCanBeExtended; // 1 = can extend, 0 = cannot extend
final Info info; // Additional session information
SessionResponse({
required this.userID,
required this.message,
required this.timeLeftInSeconds,
required this.sessionCanBeExtended,
required this.info,
});
factory SessionResponse.fromJson(Map<String, dynamic> json) {
return SessionResponse(
userID: json['userID'] ?? '',
message: json['message'] ?? '',
timeLeftInSeconds: json['timeLeftInSeconds'] ?? 0,
sessionCanBeExtended: json['sessionCanBeExtended'] ?? 0,
info: Info.fromJson(json['info'] ?? {}),
);
}
Map<String, dynamic> toJson() {
return {
'userID': userID,
'message': message,
'timeLeftInSeconds': timeLeftInSeconds,
'sessionCanBeExtended': sessionCanBeExtended,
'info': info.toJson(),
};
}
}
class Info {
final int sessionType;
final String currentWorkFlow;
Info({
required this.sessionType,
required this.currentWorkFlow,
});
factory Info.fromJson(Map<String, dynamic> json) {
return Info(
sessionType: json['sessionType'] ?? 0,
currentWorkFlow: json['currentWorkFlow'] ?? '',
);
}
Map<String, dynamic> toJson() {
return {
'sessionType': sessionType,
'currentWorkFlow': currentWorkFlow,
};
}
}
// Session Management Callback Types
typedef RDNASessionTimeoutCallback = void Function(String);
typedef RDNASessionTimeoutNotificationCallback = void Function(SessionResponse);
typedef RDNASessionExtensionResponseCallback = void Function(SessionResponse);
Session management handles two distinct scenarios:
Session Type | Trigger | User Options | Implementation |
Hard Timeout | Session already expired | Close button only | Navigate to home screen |
Idle Warning | Session expiring soon | Extend or Close | API call or natural expiry |
Extend your existing event manager to handle session management events:
// lib/uniken/services/rdna_event_manager.dart (additions)
class RdnaEventManager {
// Add session management callback properties
RDNASessionTimeoutCallback? _sessionTimeoutHandler;
RDNASessionTimeoutNotificationCallback? _sessionTimeoutNotificationHandler;
RDNASessionExtensionResponseCallback? _sessionExtensionResponseHandler;
void _registerEventListeners() {
// ... existing listeners ...
// Add session management event listeners
_listeners.add(
_rdnaClient.on(RdnaClient.onSessionTimeout, _onSessionTimeout),
);
_listeners.add(
_rdnaClient.on(RdnaClient.onSessionTimeOutNotification, _onSessionTimeOutNotification),
);
_listeners.add(
_rdnaClient.on(RdnaClient.onSessionExtensionResponse, _onSessionExtensionResponse),
);
}
/// Handles session timeout events for mandatory sessions
void _onSessionTimeout(dynamic sessionData) {
print('RdnaEventManager - Session timeout event received');
// The session timeout data is a plain string message
final message = sessionData as String;
print('RdnaEventManager - Session timeout message: $message');
if (_sessionTimeoutHandler != null) {
_sessionTimeoutHandler!(message);
}
}
/// Handles session timeout notification events for idle sessions
void _onSessionTimeOutNotification(dynamic notificationData) {
print('RdnaEventManager - Session timeout notification event received');
// Cast to SessionResponse
final sessionResponse = notificationData as SessionResponse;
print('RdnaEventManager - Notification data: ${sessionResponse.userID}, TimeLeft: ${sessionResponse.timeLeftInSeconds}s');
if (_sessionTimeoutNotificationHandler != null) {
_sessionTimeoutNotificationHandler!(sessionResponse);
}
}
/// Handles session extension response events
void _onSessionExtensionResponse(dynamic extensionData) {
print('RdnaEventManager - Session extension response event received');
// Cast to SessionResponse
final sessionResponse = extensionData as SessionResponse;
print('RdnaEventManager - Extension data: ${sessionResponse.userID}, TimeLeft: ${sessionResponse.timeLeftInSeconds}s');
if (_sessionExtensionResponseHandler != null) {
_sessionExtensionResponseHandler!(sessionResponse);
}
}
// Handler setter methods
/// Sets the handler for session timeout events (hard timeout)
void setSessionTimeoutHandler(RDNASessionTimeoutCallback? callback) {
_sessionTimeoutHandler = callback;
}
/// Sets the handler for session timeout notification events (idle timeout warning)
void setSessionTimeoutNotificationHandler(RDNASessionTimeoutNotificationCallback? callback) {
_sessionTimeoutNotificationHandler = callback;
}
/// Sets the handler for session extension response events
void setSessionExtensionResponseHandler(RDNASessionExtensionResponseCallback? callback) {
_sessionExtensionResponseHandler = callback;
}
}
Key features of session event handling:
Add session extension capability to your RELID service:
// lib/uniken/services/rdna_service.dart (addition)
class RdnaService {
/// Extends the idle session timeout
///
/// Extends the current idle session timeout when the session is eligible for extension.
/// Should be called in response to onSessionTimeOutNotification events when sessionCanBeExtended = 1.
/// After calling this method, the SDK will trigger an onSessionExtensionResponse event with the result.
///
/// ## Returns
/// RDNASyncResponse containing sync response (check error.longErrorCode for immediate feedback)
///
/// ## Events Triggered
/// - `onSessionExtensionResponse`: Triggered after API call
///
/// ## Response Validation Logic
/// 1. Check error.longErrorCode: 0 = success, > 0 = error
/// 2. An onSessionExtensionResponse event will be triggered with detailed response
/// 3. The extension success/failure will be determined by the async event response
///
/// ## Example
/// ```dart
/// final response = await rdnaService.extendSessionIdleTimeout();
/// if (response.error?.longErrorCode == 0) {
/// print('Extension successful');
/// }
/// ```
Future<RDNASyncResponse> extendSessionIdleTimeout() async {
print('RdnaService - Extending session idle timeout');
final responseString = await _rdnaClient.extendSessionIdleTimeout();
print('RdnaService - ExtendSessionIdleTimeout response received: $responseString');
// Parse JSON response to extract error
Map<String, dynamic> responseMap;
if (responseString is String) {
responseMap = json.decode(responseString) as Map<String, dynamic>;
} else {
responseMap = responseString as Map<String, dynamic>;
}
// Parse nested error JSON string
RDNAError? error;
if (responseMap.containsKey('error') && responseMap['error'] is String) {
final errorJson = responseMap['error'] as String;
final errorMap = json.decode(errorJson) as Map<String, dynamic>;
error = RDNAError.fromJson(errorMap);
} else {
error = RDNAError(longErrorCode: 0, shortErrorCode: 0, errorString: 'Success');
}
final response = RDNASyncResponse(
error: error,
response: responseMap['response'],
);
print('RdnaService - ExtendSessionIdleTimeout sync response:');
print(' Long Error Code: ${response.error?.longErrorCode}');
print(' Short Error Code: ${response.error?.shortErrorCode}');
return response;
}
}
When handling session extension, two response layers must be considered:
Response Layer | Purpose | Success Criteria | Failure Handling |
Sync Response | API call validation |
| Immediate rejection |
Async Event | Extension result |
| Display error message |
Note: The sync response only indicates the API call was accepted. The actual extension success/failure is communicated through the onSessionExtensionResponse event.
Create a Riverpod StateNotifier to manage session state across your application:
// lib/uniken/providers/session_provider.dart
/// Session state managed by SessionProvider
class SessionState {
final bool isSessionModalVisible;
final String? sessionTimeoutMessage; // Hard timeout message
final SessionResponse? sessionTimeoutNotificationData; // Idle timeout data
final bool isProcessing;
const SessionState({
this.isSessionModalVisible = false,
this.sessionTimeoutMessage,
this.sessionTimeoutNotificationData,
this.isProcessing = false,
});
SessionState copyWith({
bool? isSessionModalVisible,
String? sessionTimeoutMessage,
SessionResponse? sessionTimeoutNotificationData,
bool? isProcessing,
bool clearTimeoutMessage = false,
bool clearNotificationData = false,
}) {
return SessionState(
isSessionModalVisible: isSessionModalVisible ?? this.isSessionModalVisible,
sessionTimeoutMessage: clearTimeoutMessage ? null : (sessionTimeoutMessage ?? this.sessionTimeoutMessage),
sessionTimeoutNotificationData: clearNotificationData ? null : (sessionTimeoutNotificationData ?? this.sessionTimeoutNotificationData),
isProcessing: isProcessing ?? this.isProcessing,
);
}
}
/// Session Management Provider
///
/// Manages global session timeout state and provides session management
/// functionality using Riverpod.
class SessionNotifier extends StateNotifier<SessionState> {
final RdnaService _rdnaService;
final RdnaEventManager _eventManager;
String _currentOperation = 'none'; // Operation tracking
SessionNotifier(this._rdnaService, this._eventManager)
: super(const SessionState()) {
_setupEventHandlers();
}
/// Sets up session event handlers
void _setupEventHandlers() {
print('SessionProvider - Setting up session event handlers');
// Session timeout event (hard timeout - mandatory)
_eventManager.setSessionTimeoutHandler((String message) {
print('SessionProvider - Session timeout received: $message');
_showSessionTimeout(message);
});
// Session timeout notification event (idle timeout warning)
_eventManager.setSessionTimeoutNotificationHandler((dynamic data) {
print('SessionProvider - Session timeout notification received');
final sessionResponse = data as SessionResponse;
_showSessionTimeoutNotification(sessionResponse);
});
// Session extension response event
_eventManager.setSessionExtensionResponseHandler((dynamic data) {
print('SessionProvider - Session extension response received');
final sessionResponse = data as SessionResponse;
_handleSessionExtensionResponse(sessionResponse);
});
}
/// Shows session timeout modal (hard timeout)
void _showSessionTimeout(String message) {
print('SessionProvider - Session timed out, showing modal');
state = state.copyWith(
isSessionModalVisible: true,
sessionTimeoutMessage: message,
clearNotificationData: true,
isProcessing: false,
);
_currentOperation = 'none';
}
/// Shows session timeout notification modal (idle timeout warning)
void _showSessionTimeoutNotification(SessionResponse data) {
print('SessionProvider - Showing session timeout notification modal');
state = state.copyWith(
isSessionModalVisible: true,
sessionTimeoutNotificationData: data,
clearTimeoutMessage: true,
isProcessing: false,
);
_currentOperation = 'none';
}
/// Hides session modal
void hideSessionModal() {
print('SessionProvider - Hiding session modal');
state = state.copyWith(
isSessionModalVisible: false,
clearTimeoutMessage: true,
clearNotificationData: true,
isProcessing: false,
);
_currentOperation = 'none';
}
}
/// Global session provider
final sessionProvider = StateNotifierProvider<SessionNotifier, SessionState>((ref) {
final rdnaService = RdnaService.getInstance();
final eventManager = rdnaService.getEventManager();
return SessionNotifier(rdnaService, eventManager);
});
The provider handles session extension with comprehensive state management:
/// Handles extend session button press
Future<void> handleExtendSession(BuildContext context) async {
print('SessionProvider - User chose to extend session');
if (_currentOperation != 'none') {
print('SessionProvider - Operation already in progress, ignoring extend request');
return;
}
state = state.copyWith(isProcessing: true);
_currentOperation = 'extend';
// Call extend session API and check sync response
final response = await _rdnaService.extendSessionIdleTimeout();
print('SessionProvider - ExtendSessionIdleTimeout sync response received');
print(' Long Error Code: ${response.error?.longErrorCode}');
// Check sync response for immediate errors
if (response.error?.longErrorCode != 0) {
// Sync error - stop and show error
print('SessionProvider - Extension sync error: ${response.error?.errorString}');
state = state.copyWith(isProcessing: false);
_currentOperation = 'none';
// Show error alert to user
if (context.mounted) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Extension Failed'),
content: Text(
'Failed to extend session:\n\n${response.error?.errorString}\n\nError Code: ${response.error?.longErrorCode}',
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('OK'),
),
],
),
);
}
return;
}
// Sync success - wait for onSessionExtensionResponse event
print('SessionProvider - Extension sync success, waiting for async event...');
// Note: Event handler (_handleSessionExtensionResponse) will hide modal
}
/// Handles session extension response event from SDK
void _handleSessionExtensionResponse(SessionResponse data) {
print('SessionProvider - Extension response event received');
// Only process if we're currently extending
if (_currentOperation != 'extend') {
print('SessionProvider - Extension response received but no extend operation in progress, ignoring');
return;
}
// Event firing indicates success - hide the modal
print('SessionProvider - Session extension successful (event received)');
hideSessionModal();
}
/// Handles dismiss button press
void handleDismiss(BuildContext context) {
print('SessionProvider - User dismissed session modal');
// Check timeout type BEFORE hiding modal (state will be cleared)
final hasTimeoutMessage = state.sessionTimeoutMessage != null;
// Hide modal first
hideSessionModal();
// For hard session timeout (mandatory), navigate to home screen
if (hasTimeoutMessage) {
print('SessionProvider - Hard session timeout - navigating to home screen');
appRouter.go('/');
}
}
Key features of the session provider:
Create a modal component to display session information and handle user interactions:
// lib/uniken/components/modals/session_modal.dart
class SessionModal extends ConsumerStatefulWidget {
const SessionModal({Key? key}) : super(key: key);
@override
ConsumerState<SessionModal> createState() => _SessionModalState();
}
class _SessionModalState extends ConsumerState<SessionModal> with WidgetsBindingObserver {
Timer? _countdownTimer;
int _countdown = 0;
DateTime? _backgroundTime;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this); // Register lifecycle observer
}
@override
void dispose() {
_countdownTimer?.cancel();
WidgetsBinding.instance.removeObserver(this); // Unregister
super.dispose();
}
void _startCountdown(int timeLeftInSeconds) {
_countdown = timeLeftInSeconds;
_countdownTimer?.cancel();
_countdownTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
if (_countdown > 0) {
setState(() {
_countdown--;
});
} else {
timer.cancel();
}
});
}
@override
Widget build(BuildContext context) {
final sessionState = ref.watch(sessionProvider);
final sessionNotifier = ref.read(sessionProvider.notifier);
// Don't show modal if not visible
if (!sessionState.isSessionModalVisible) {
_countdownTimer?.cancel();
return const SizedBox.shrink();
}
// Determine session type
final isMandatoryTimeout = sessionState.sessionTimeoutMessage != null;
final isIdleTimeout = sessionState.sessionTimeoutNotificationData != null;
// Initialize countdown for idle timeout
if (isIdleTimeout && _countdownTimer == null) {
final data = sessionState.sessionTimeoutNotificationData;
if (data != null) {
_startCountdown(data.timeLeftInSeconds);
}
}
// Get display message
String displayMessage = 'Session timeout occurred.';
if (isMandatoryTimeout) {
displayMessage = sessionState.sessionTimeoutMessage!;
} else if (isIdleTimeout && sessionState.sessionTimeoutNotificationData != null) {
displayMessage = sessionState.sessionTimeoutNotificationData!.message;
}
// Check if session can be extended
bool canExtendSession = false;
if (isIdleTimeout && sessionState.sessionTimeoutNotificationData != null) {
canExtendSession = sessionState.sessionTimeoutNotificationData!.sessionCanBeExtended == 1;
}
// Get modal configuration
final config = _getModalConfig(isMandatoryTimeout, isIdleTimeout, canExtendSession);
return PopScope(
canPop: false, // Prevent back button dismissal
child: GestureDetector(
onTap: () {}, // Absorb taps - prevent background interaction
child: Material(
color: Colors.black.withOpacity(0.8),
child: Center(
child: Padding(
padding: const EdgeInsets.all(20),
child: Container(
constraints: const BoxConstraints(maxWidth: 400),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.3),
blurRadius: 8,
offset: const Offset(0, 4),
),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Header with dynamic color based on timeout type
Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: config.headerColor, // Red or Orange
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(16),
topRight: Radius.circular(16),
),
),
child: Column(
children: [
Text(
config.title,
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Colors.white,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
config.subtitle,
style: TextStyle(
fontSize: 14,
color: Colors.white.withOpacity(0.8),
),
textAlign: TextAlign.center,
),
],
),
),
// Content with message and countdown
Padding(
padding: const EdgeInsets.all(20),
child: Column(
children: [
// Icon and Message
Text(
config.icon,
style: const TextStyle(fontSize: 48),
),
const SizedBox(height: 16),
Text(
displayMessage,
style: const TextStyle(
fontSize: 16,
color: Color(0xFF1f2937),
height: 1.5,
),
textAlign: TextAlign.center,
),
// Countdown display for idle timeout
if (isIdleTimeout && _countdown > 0) ...[
const SizedBox(height: 20),
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFFfef3c7),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: const Color(0xFFf59e0b),
width: 2,
),
),
child: Column(
children: [
const Text(
'Time Remaining:',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Color(0xFF92400e),
),
),
const SizedBox(height: 8),
Text(
'${(_countdown / 60).floor()}:${(_countdown % 60).toString().padLeft(2, '0')}',
style: const TextStyle(
fontSize: 32,
fontWeight: FontWeight.bold,
color: Color(0xFFd97706),
fontFamily: 'monospace',
),
),
],
),
),
],
],
),
),
// Action Buttons
Container(
padding: const EdgeInsets.all(20),
decoration: const BoxDecoration(
border: Border(
top: BorderSide(color: Color(0xFFf3f4f6)),
),
),
child: Column(
children: [
// Hard timeout - only Close option
if (isMandatoryTimeout)
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: () => sessionNotifier.handleDismiss(context),
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF6b7280),
padding: const EdgeInsets.all(16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
elevation: 2,
),
child: const Text(
'Close',
style: TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
),
),
// Idle timeout - extend or dismiss options
if (isIdleTimeout) ...[
if (canExtendSession)
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: sessionState.isProcessing
? null
: () => sessionNotifier.handleExtendSession(context),
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF3b82f6),
padding: const EdgeInsets.all(16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
elevation: 2,
),
child: sessionState.isProcessing
? Row(
mainAxisAlignment: MainAxisAlignment.center,
children: const [
SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
color: Colors.white,
strokeWidth: 2,
),
),
SizedBox(width: 8),
Text(
'Extending...',
style: TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
],
)
: const Text(
'Extend Session',
style: TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
),
),
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: sessionState.isProcessing
? null
: () => sessionNotifier.handleDismiss(context),
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF6b7280),
padding: const EdgeInsets.all(16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
elevation: 2,
),
child: const Text(
'Close',
style: TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
),
),
],
],
),
),
],
),
),
),
),
),
),
);
}
/// Get modal configuration based on session type
_ModalConfig _getModalConfig(bool isMandatoryTimeout, bool isIdleTimeout, bool canExtendSession) {
if (isMandatoryTimeout) {
return _ModalConfig(
title: '🔐 Session Expired',
subtitle: 'Your session has expired. You will be redirected to the home screen.',
headerColor: const Color(0xFFdc2626), // Red for hard timeout
icon: '🔐',
);
}
if (isIdleTimeout) {
return _ModalConfig(
title: '⚠️ Session Timeout Warning',
subtitle: canExtendSession
? 'Your session will expire soon. You can extend it or let it timeout.'
: 'Your session will expire soon.',
headerColor: const Color(0xFFf59e0b), // Orange for idle timeout
icon: '⏱️',
);
}
return _ModalConfig(
title: '⏰ Session Management',
subtitle: 'Session timeout notification',
headerColor: const Color(0xFF6b7280),
icon: '🔐',
);
}
}
class _ModalConfig {
final String title;
final String subtitle;
final Color headerColor;
final String icon;
_ModalConfig({
required this.title,
required this.subtitle,
required this.headerColor,
required this.icon,
});
}
Critical feature for accurate countdown when app goes to background:
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
// Handle app state changes for accurate countdown when app goes to background/foreground
if (state == AppLifecycleState.paused || state == AppLifecycleState.inactive) {
// App going to background - record the time
_backgroundTime = DateTime.now();
print('SessionModal - App going to background, recording time');
} else if (state == AppLifecycleState.resumed && _backgroundTime != null) {
// App returning to foreground - calculate elapsed time
final elapsedSeconds = DateTime.now().difference(_backgroundTime!).inSeconds;
print('SessionModal - App returning to foreground, elapsed: ${elapsedSeconds}s');
// Update countdown based on actual elapsed time
setState(() {
_countdown = (_countdown - elapsedSeconds).clamp(0, double.infinity).toInt();
print('SessionModal - Countdown updated to: $_countdown');
});
_backgroundTime = null;
}
}
Key features of the session modal:
The following images showcase screens from the sample application:
|
|
Wrap your application with the Riverpod provider scope and session modal overlay:
// lib/main.dart
void main() {
runApp(
const ProviderScope(
child: MyApp(),
),
);
}
class MyApp extends ConsumerWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final sessionState = ref.watch(sessionProvider);
return SDKEventProviderWidget(
child: MaterialApp.router(
title: 'REL-ID Session Management Tutorial',
debugShowCheckedModeBanner: false,
theme: ThemeData(
primarySwatch: Colors.blue,
visualDensity: VisualDensity.adaptivePlatformDensity,
),
routerConfig: appRouter,
builder: (context, child) {
return Stack(
children: [
// Main app content
child ?? const SizedBox.shrink(),
// Global Session Management Modal (overlay)
if (sessionState.isSessionModalVisible)
const SessionModal(),
],
);
},
),
);
}
}
The Riverpod provider approach offers several advantages:
ref.watch(sessionProvider)Session Type | Test Case | Expected Behavior | Validation Points |
Hard Timeout | Session expires | Modal with Close button only | Navigate to home screen |
Idle Warning | Session expiring soon | Modal with countdown and Extend button | Extension API call works |
Extension Success | Extend session API succeeds | Modal dismisses, session continues | No navigation occurs |
Extension Failure | Extend session API fails | Error alert, modal remains | User can retry or close |
Background Timer | App goes to background during countdown | Timer accurately reflects elapsed time | Countdown resumes correctly |
Critical test for production reliability:
// Test background/foreground timer accuracy
void testBackgroundTimer() {
// 1. Trigger idle session timeout notification
// 2. Note the countdown time (e.g., 60 seconds)
// 3. Background the app for 30 seconds (press home button)
// 4. Foreground the app (tap app icon)
// 5. Verify countdown shows ~30 seconds remaining
print('Testing background timer accuracy');
print('Initial countdown: $initialCountdown');
print('Time spent in background: $backgroundTime');
print('Expected remaining time: ${initialCountdown - backgroundTime}');
print('Actual remaining time: $currentCountdown');
}
Use these debugging techniques to verify session functionality:
// Verify callback registration
print('Session callbacks:');
print(' timeout: ${eventManager.sessionTimeoutHandler != null}');
print(' notification: ${eventManager.sessionTimeoutNotificationHandler != null}');
print(' extension: ${eventManager.sessionExtensionResponseHandler != null}');
// Log session event data
print('Session timeout notification:');
print(' userID: ${data.userID}');
print(' timeLeft: ${data.timeLeftInSeconds}s');
print(' canExtend: ${data.sessionCanBeExtended == 1}');
print(' message: ${data.message}');
Cause: Session callbacks not properly registered Solution: Verify ProviderScope wraps your app and provider is initialized
// Correct setup in main.dart
void main() {
runApp(
const ProviderScope( // ✅ Required for Riverpod
child: MyApp(),
),
);
}
Cause: Event listeners not attached Solution: Check that event registration succeeds in _setupEventHandlers()
Cause: Countdown doesn't account for background time Solution: Implement WidgetsBindingObserver lifecycle handling
// Correct background/foreground handling
class _SessionModalState extends ConsumerState<SessionModal>
with WidgetsBindingObserver {
DateTime? _backgroundTime;
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.paused || state == AppLifecycleState.inactive) {
_backgroundTime = DateTime.now();
} else if (state == AppLifecycleState.resumed && _backgroundTime != null) {
final elapsedSeconds = DateTime.now().difference(_backgroundTime!).inSeconds;
setState(() {
_countdown = (_countdown - elapsedSeconds).clamp(0, double.infinity).toInt();
});
_backgroundTime = null;
}
}
}
Cause: Calling extension API when sessionCanBeExtended is false Solution: Check extension eligibility before API call
Future<void> handleExtendSession(BuildContext context) async {
final data = state.sessionTimeoutNotificationData;
if (data?.sessionCanBeExtended != 1) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Extension Not Available'),
content: const Text('This session cannot be extended.'),
),
);
return;
}
// Proceed with extension API call
await _rdnaService.extendSessionIdleTimeout();
}
Cause: Multiple concurrent extension requests Solution: Use operation tracking to prevent duplicates
String _currentOperation = 'none';
Future<void> handleExtendSession(BuildContext context) async {
if (_currentOperation != 'none') {
print('Extension already in progress');
return;
}
_currentOperation = 'extend';
// ... perform extension
}
Cause: Back button dismissing modal on Android Solution: Use PopScope with canPop: false
return PopScope(
canPop: false, // ✅ Prevent default back action
child: Material(
// Modal content
),
);
Best Practice: Test session management behavior on both iOS and Android devices with different timeout scenarios.
Extension Scenario | Recommended Action | Implementation |
Frequent Extensions | Set reasonable limits | Track extension count per session |
Critical Operations | Allow extensions during important tasks | Context-aware extension logic |
Inactive Sessions | Enforce timeouts | Don't extend completely idle sessions |
// Proper cleanup in SessionNotifier
@override
void dispose() {
print('SessionProvider - Disposing session provider');
_eventManager.setSessionTimeoutHandler(null);
_eventManager.setSessionTimeoutNotificationHandler(null);
_eventManager.setSessionExtensionResponseHandler(null);
super.dispose();
}
// Proper cleanup in SessionModal
@override
void dispose() {
_countdownTimer?.cancel();
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
Congratulations! You've successfully learned how to implement comprehensive session management functionality with:
Your session management implementation now provides: