Welcome to the REL-ID Additional Device Activation codelab! This tutorial builds upon the foundational MFA implementation to add sophisticated device onboarding capabilities using REL-ID Verify's push notification system.
In this codelab, you'll enhance your existing MFA application with:
By completing this codelab, you'll master:
addNewDeviceOptions events and device activation flowsBefore starting this codelab, ensure you have:
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-additional-device-activation folder in the repository you cloned earlier
This codelab extends your MFA application with three core device activation components:
addNewDeviceOptions event processing and navigation coordinationBefore implementing device activation screens, let's understand the key plugin events and APIs that power the additional device activation workflow.
The device activation process follows this event-driven pattern:
User Completes MFA on Primary Device → SDK Detects New Device On Secondary Device → addNewDeviceOptions Event → VerifyAuthScreen →
Push Notifications Sent → User Approves the Notification On Primary Device → Continue MFA Flow -> Device Activated
Add these Dart definitions to understand device activation data structures:
// rdna_client/lib/rdna_struct.dart (device activation type definitions)
/**
* Device activation options data structure
* Triggered when SDK detects unregistered device during authentication
*/
class RDNAAddNewDeviceOptions {
String? userId; //user name
List<String>? newDeviceOptions; //list of authentication type for new device activation
List<RDNAChallengeInfo>? challengeInfo;
RDNAAddNewDeviceOptions({
this.userId,
this.newDeviceOptions,
this.challengeInfo,
});
factory RDNAAddNewDeviceOptions.fromJson(Map<String, dynamic> json) =>
RDNAAddNewDeviceOptions(
userId: json["userID"] == null ? null : json["userID"],
newDeviceOptions: json["newDeviceOptions"] == null
? null
: List<String>.from(json["newDeviceOptions"].map((x) => x)),
challengeInfo: json["challengeInfo"] == null
? null
: List<RDNAChallengeInfo>.from(json["challengeInfo"]
.map((x) => RDNAChallengeInfo.fromJson(x))),
);
Map<String, dynamic> toJson() => {
"userID": userId == null ? null : userId,
"newDeviceOptions": newDeviceOptions == null
? null
: List<dynamic>.from(newDeviceOptions!.map((x) => x)),
"challengeInfo": challengeInfo == null
? null
: List<dynamic>.from(challengeInfo!.map((x) => x.toJson())),
};
}
/**
* Notification Body Structure
* Localized content for notification
*/
class RDNANotificationBody {
String? lng;
String? subject;
String? message;
Map<String, dynamic>? label;
RDNANotificationBody({this.lng, this.subject, this.message, this.label});
factory RDNANotificationBody.fromJson(Map<String, dynamic> json) =>
RDNANotificationBody(
lng: json['lng'],
subject: json['subject'],
message: json['message'],
label: json['label'] != null
? Map<String, dynamic>.from(json['label'])
: null,
);
Map<String, dynamic> toJson() => {
'lng': lng,
'subject': subject,
'message': message,
'label': label,
};
}
/**
* Notification Action Structure
* Available actions for notification
*/
class RDNANotificationAction {
String? label;
String? action;
String? authlevel;
RDNANotificationAction({this.label, this.action, this.authlevel});
factory RDNANotificationAction.fromJson(Map<String, dynamic> json) =>
RDNANotificationAction(
label: json['label'],
action: json['action'],
authlevel: json['authlevel'],
);
Map<String, dynamic> toJson() => {
'label': label,
'action': action,
'authlevel': authlevel,
};
}
/**
* Notification Item Structure
* Individual notification structure from API response
*/
class RDNANotification {
String? notificationUuid;
String? createTs;
String? expiryTimestamp;
int? createTsEpoch;
int? expiryTimestampEpoch;
List<RDNANotificationBody>? body;
List<RDNANotificationAction>? actions;
String? actionPerformed;
bool? dsRequired;
RDNANotification({
this.notificationUuid,
this.createTs,
this.expiryTimestamp,
this.createTsEpoch,
this.expiryTimestampEpoch,
this.body,
this.actions,
this.actionPerformed,
this.dsRequired,
});
factory RDNANotification.fromJson(Map<String, dynamic> json) =>
RDNANotification(
notificationUuid: json['notification_uuid'],
createTs: json['create_ts'],
expiryTimestamp: json['expiry_timestamp'],
createTsEpoch: json['create_ts_epoch'],
expiryTimestampEpoch: json['expiry_timestamp_epoch'],
body: json['body'] != null
? List<RDNANotificationBody>.from(
json['body'].map((x) => RDNANotificationBody.fromJson(x)))
: null,
actions: json['actions'] != null
? List<RDNANotificationAction>.from(
json['actions'].map((x) => RDNANotificationAction.fromJson(x)))
: null,
actionPerformed: json['action_performed'],
dsRequired: json['ds_required'],
);
Map<String, dynamic> toJson() => {
'notification_uuid': notificationUuid,
'create_ts': createTs,
'expiry_timestamp': expiryTimestamp,
'create_ts_epoch': createTsEpoch,
'expiry_timestamp_epoch': expiryTimestampEpoch,
'body': body?.map((x) => x.toJson()).toList(),
'actions': actions?.map((x) => x.toJson()).toList(),
'action_performed': actionPerformed,
'ds_required': dsRequired,
};
}
/**
* Get Notifications Response Structure
* Response structure for notifications API
*/
class RDNAGetNotificationsResponse {
List<RDNANotification>? notifications;
String? start;
String? count;
String? total;
RDNAGetNotificationsResponse({
this.notifications,
this.start,
this.count,
this.total,
});
factory RDNAGetNotificationsResponse.fromJson(Map<String, dynamic> json) =>
RDNAGetNotificationsResponse(
notifications: json['notifications'] != null
? List<RDNANotification>.from(
json['notifications'].map((x) => RDNANotification.fromJson(x)))
: null,
start: json['start'],
count: json['count'],
total: json['total'],
);
Map<String, dynamic> toJson() => {
'notifications': notifications?.map((v) => v.toJson()).toList(),
'start': start,
'count': count,
'total': total,
};
}
/**
* Update Notification Response Structure
* Response data structure for notification update
*/
class RDNAUpdateNotificationResponse {
int? statusCode;
String? message;
String? notificationUuid;
bool? isDsVerified;
RDNAUpdateNotificationResponse({
this.statusCode,
this.message,
this.notificationUuid,
this.isDsVerified,
});
factory RDNAUpdateNotificationResponse.fromJson(Map<String, dynamic> json) =>
RDNAUpdateNotificationResponse(
statusCode: json["status_code"],
message: json["message"],
notificationUuid: json["notification_uuid"],
isDsVerified: json["is_ds_verified"],
);
Map<String, dynamic> toJson() => {
"status_code": statusCode,
"message": message,
"notification_uuid": notificationUuid,
"is_ds_verified": isDsVerified,
};
}
Define callback types for device activation events:
// Callback type definitions for device activation events
typedef RDNAAddNewDeviceOptionsCallback = void Function(RDNAAddNewDeviceOptions);
typedef RDNAGetNotificationsCallback = void Function(RDNAResponseMetaData);
typedef RDNAUpdateNotificationCallback = void Function(RDNAResponseMetaData);
The addNewDeviceOptions event is the cornerstone of device activation:
REL-ID Verify enables secure device-to-device approval:
Enhance your existing RdnaService with device activation APIs. These methods handle REL-ID Verify workflows and notification management.
Extend your RdnaService class with these device activation methods:
// lib/uniken/services/rdna_service.dart (device activation additions)
/**
* Performs REL-ID Verify authentication for device activation
* Sends push notifications to registered devices for approval
* @param verifyAuthStatus User's decision (true = proceed with verification, false = cancel)
* @returns Future<RDNASyncResponse>
*/
Future<RDNASyncResponse> performVerifyAuth(bool verifyAuthStatus) async {
try {
print('RdnaService - Performing verify auth with status: $verifyAuthStatus');
final response = await _rdnaClient.performVerifyAuth(verifyAuthStatus);
if (response.error?.longErrorCode == 0) {
print('RdnaService - PerformVerifyAuth sync response success, waiting for async events');
return response;
} else {
print('RdnaService - PerformVerifyAuth sync response error: ${response.error}');
throw response;
}
} catch (error) {
print('RdnaService - PerformVerifyAuth error: $error');
rethrow;
}
}
/**
* Initiates fallback device activation flow
* Alternative method when REL-ID Verify is not available/accessible
* @returns Future<RDNASyncResponse>
*/
Future<RDNASyncResponse> fallbackNewDeviceActivationFlow() async {
try {
print('RdnaService - Starting fallback new device activation flow');
final response = await _rdnaClient.fallbackNewDeviceActivationFlow();
if (response.error?.longErrorCode == 0) {
print('RdnaService - FallbackNewDeviceActivationFlow sync response success, alternative activation started');
return response;
} else {
print('RdnaService - FallbackNewDeviceActivationFlow sync response error: ${response.error}');
throw response;
}
} catch (error) {
print('RdnaService - FallbackNewDeviceActivationFlow error: $error');
rethrow;
}
}
/**
* Retrieves server notifications for the current user
* Loads all pending notifications with actions
* @param recordCount Number of records to fetch (0 = all active notifications)
* @param startIndex Index to begin fetching from (must be >= 1)
* @param startDate Start date filter (optional)
* @param endDate End date filter (optional)
* @returns Future<RDNASyncResponse>
*/
Future<RDNASyncResponse> getNotifications({
int recordCount = 0,
int startIndex = 1,
String startDate = '',
String endDate = '',
}) async {
try {
print('RdnaService - Fetching notifications with recordCount: $recordCount, startIndex: $startIndex');
final response = await _rdnaClient.getNotifications(
recordCount, // recordCount
'', // enterpriseID (optional)
startIndex, // startIndex
startDate, // startDate (optional)
endDate, // endDate (optional)
);
if (response.error?.longErrorCode == 0) {
print('RdnaService - GetNotifications sync response success, waiting for onGetNotifications event');
return response;
} else {
print('RdnaService - GetNotifications sync response error: ${response.error}');
throw response;
}
} catch (error) {
print('RdnaService - GetNotifications error: $error');
rethrow;
}
}
/**
* Updates a notification with user action
* Processes user decision on notification actions
* @param notificationId Notification identifier (UUID)
* @param response Action response value selected by user
* @returns Future<RDNASyncResponse>
*/
Future<RDNASyncResponse> updateNotification(
String notificationId,
String response,
) async {
try {
print('RdnaService - Updating notification: $notificationId with response: $response');
final syncResponse = await _rdnaClient.updateNotification(
notificationId, // notificationId
response, // response
);
if (syncResponse.error?.longErrorCode == 0) {
print('RdnaService - UpdateNotification sync response success, waiting for onUpdateNotification event');
return syncResponse;
} else {
print('RdnaService - UpdateNotification sync response error: ${syncResponse.error}');
throw syncResponse;
}
} catch (error) {
print('RdnaService - UpdateNotification error: $error');
rethrow;
}
}
verifyAuthStatus (boolean) - automatically start verificationAll device activation APIs follow the established REL-ID SDK pattern:
longErrorCode == 0 means API call succeededEnhance your existing event manager to handle device activation events. Add support for addNewDeviceOptions, notification retrieval, and notification updates.
Extend your RdnaEventManager class with device activation event handling:
// lib/uniken/services/rdna_event_manager.dart (device activation additions)
class RdnaEventManager {
// Add device activation event handlers
RDNAAddNewDeviceOptionsCallback? _addNewDeviceOptionsHandler;
RDNAGetNotificationsCallback? _getNotificationsHandler;
RDNAUpdateNotificationCallback? _updateNotificationHandler;
void registerEventListeners() {
// ... existing MFA and MTD listeners ...
// Register device activation event listeners
_listeners.add(
_rdnaClient.on('addNewDeviceOptions', _onAddNewDeviceOptions),
);
_listeners.add(
_rdnaClient.on('getNotifications', _onGetNotifications),
);
_listeners.add(
_rdnaClient.on('updateNotification', _onUpdateNotification),
);
}
}
Add these event handler methods to your event manager:
/**
* Handles device activation options event
* Triggered when SDK detects unregistered device during authentication
*/
void _onAddNewDeviceOptions(dynamic response) {
print("RdnaEventManager - Add new device options event received");
try {
final addNewDeviceOptionsData = response as RDNAAddNewDeviceOptions;
print("RdnaEventManager - UserID: ${addNewDeviceOptionsData.userId}");
print("RdnaEventManager - Available options: ${addNewDeviceOptionsData.newDeviceOptions?.length ?? 0}");
print("RdnaEventManager - Challenge info count: ${addNewDeviceOptionsData.challengeInfo?.length ?? 0}");
// Log each activation option for debugging
addNewDeviceOptionsData.newDeviceOptions?.asMap().forEach((index, option) {
print("RdnaEventManager - Option ${index + 1}: $option");
});
if (_addNewDeviceOptionsHandler != null) {
_addNewDeviceOptionsHandler!(addNewDeviceOptionsData);
}
} catch (error) {
print("RdnaEventManager - Failed to parse add new device options: $error");
}
}
/**
* Handles get notifications response
* Triggered after getNotifications API call completes
*/
void _onGetNotifications(dynamic response) {
print("RdnaEventManager - Get notifications event received");
try {
final getNotificationsData = response as RDNAResponseMetaData;
print("RdnaEventManager - Get notifications data: errCode=${getNotificationsData.error?.longErrorCode}");
if (_getNotificationsHandler != null) {
_getNotificationsHandler!(getNotificationsData);
}
} catch (error) {
print("RdnaEventManager - Failed to parse get notifications: $error");
}
}
/**
* Handles update notification response
* Triggered after updateNotification API call completes
*/
void _onUpdateNotification(dynamic response) {
print("RdnaEventManager - Update notification event received");
try {
final updateNotificationData = response as RDNAResponseMetaData;
print("RdnaEventManager - Update notification data: errCode=${updateNotificationData.error?.longErrorCode}");
if (_updateNotificationHandler != null) {
_updateNotificationHandler!(updateNotificationData);
}
} catch (error) {
print("RdnaEventManager - Failed to parse update notification: $error");
}
}
Add public methods for setting device activation event handlers:
// Public setter methods for device activation event handlers
void setAddNewDeviceOptionsHandler(RDNAAddNewDeviceOptionsCallback? callback) {
_addNewDeviceOptionsHandler = callback;
}
void setGetNotificationsHandler(RDNAGetNotificationsCallback? callback) {
_getNotificationsHandler = callback;
}
void setUpdateNotificationHandler(RDNAUpdateNotificationCallback? callback) {
_updateNotificationHandler = callback;
}
// Enhanced cleanup method to clear device activation handlers
void clearDeviceActivationHandlers() {
_addNewDeviceOptionsHandler = null;
_getNotificationsHandler = null;
_updateNotificationHandler = null;
}
// Enhanced cleanup method to clear all handlers
void cleanup() {
// Clear existing MFA handlers
clearActivationHandlers();
// Clear device activation handlers
clearDeviceActivationHandlers();
// Clear existing MTD handlers
clearMTDHandlers();
// Remove all event listeners
for (final listener in _listeners) {
listener.cancel();
}
_listeners.clear();
}
getNotifications() API callupdateNotification() API callThe device activation events integrate with existing event management:
// Example of comprehensive event setup in SDKEventProvider
@override
void initState() {
super.initState();
final eventManager = ref.read(rdnaServiceProvider).getEventManager();
// Existing MFA event handlers
eventManager.setGetUserHandler(_handleGetUser);
eventManager.setGetPasswordHandler(_handleGetPassword);
// ... other MFA handlers ...
// Device activation event handlers
eventManager.setAddNewDeviceOptionsHandler(_handleAddNewDeviceOptions);
eventManager.setGetNotificationsHandler(_handleGetNotifications);
eventManager.setUpdateNotificationHandler(_handleUpdateNotification);
}
@override
void dispose() {
final eventManager = ref.read(rdnaServiceProvider).getEventManager();
eventManager.cleanup();
super.dispose();
}
Create the VerifyAuthScreen that handles REL-ID Verify device activation with automatic push notification processing and fallback options.
// lib/tutorial/screens/mfa/verify_auth_screen.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../navigation/app_router.dart';
import '../../../uniken/services/rdna_service.dart';
import '../../../rdna_client/lib/rdna_struct.dart';
import '../components/status_banner.dart';
import '../components/custom_button.dart';
import '../components/close_button.dart' as custom;
/**
* Verify Auth Screen Component
*/
class VerifyAuthScreen extends ConsumerStatefulWidget {
final RDNAAddNewDeviceOptions? eventData;
final String? title;
final String? subtitle;
final RDNAAddNewDeviceOptions? responseData;
const VerifyAuthScreen({
Key? key,
this.eventData,
this.title,
this.subtitle,
this.responseData,
}) : super(key: key);
@override
ConsumerState<VerifyAuthScreen> createState() => _VerifyAuthScreenState();
}
class _VerifyAuthScreenState extends ConsumerState<VerifyAuthScreen> {
bool _isProcessing = false;
String? _error;
Map<String, dynamic>? _activationData;
@override
void initState() {
super.initState();
// Auto-call performVerifyAuth after frame renders
if (widget.responseData != null) {
WidgetsBinding.instance.addPostFrameCallback((_) {
_processActivationData();
});
}
}
/**
* Handle close button - direct resetAuthState call
*/
Future<void> _handleClose() async {
try {
print('VerifyAuthScreen - Calling resetAuthState');
final rdnaService = ref.read(rdnaServiceProvider);
await rdnaService.resetAuthState();
print('VerifyAuthScreen - ResetAuthState successful');
} catch (error) {
print('VerifyAuthScreen - ResetAuthState error: $error');
}
}
/**
* Process activation data and auto-start verify auth
*/
Future<void> _processActivationData() async {
if (widget.responseData == null) return;
try {
final data = widget.responseData!;
setState(() {
_activationData = {
'userID': data.userId,
'options': data.newDeviceOptions ?? [],
};
});
print('VerifyAuthScreen - Processed activation data: $_activationData');
// Automatically call performVerifyAuth(true) when data is processed
await _handleVerifyAuth(true);
} catch (error) {
print('VerifyAuthScreen - Failed to process activation data: $error');
setState(() {
_error = 'Failed to process activation data';
});
}
}
/**
* Handle REL-ID Verify authentication
*/
Future<void> _handleVerifyAuth(bool proceed) async {
if (_isProcessing) return;
setState(() {
_isProcessing = true;
_error = null;
});
try {
print('VerifyAuthScreen - Performing verify auth: $proceed');
final rdnaService = ref.read(rdnaServiceProvider);
final syncResponse = await rdnaService.performVerifyAuth(proceed);
print('VerifyAuthScreen - PerformVerifyAuth sync response successful, waiting for async events');
print('VerifyAuthScreen - Sync response received: ${syncResponse.error?.longErrorCode}');
if (proceed) {
// Log success message for approval
print('VerifyAuthScreen - REL-ID Verify notification has been sent to registered devices');
}
} catch (error) {
// This catch block handles sync response errors (rejected promises)
print('VerifyAuthScreen - PerformVerifyAuth sync error: $error');
final result = error as RDNASyncResponse;
final errorMessage = result.error?.errorString ?? 'Verification failed';
setState(() {
_error = errorMessage;
});
} finally {
if (mounted) {
setState(() {
_isProcessing = false;
});
}
}
}
/**
* Handle fallback new device activation flow
*/
Future<void> _handleFallbackFlow() async {
if (_isProcessing) return;
setState(() {
_isProcessing = true;
_error = null;
});
try {
print('VerifyAuthScreen - Initiating fallback new device activation flow');
final rdnaService = ref.read(rdnaServiceProvider);
final syncResponse = await rdnaService.fallbackNewDeviceActivationFlow();
print('VerifyAuthScreen - FallbackNewDeviceActivationFlow sync response successful, waiting for async events');
print('VerifyAuthScreen - Sync response received: ${syncResponse.error?.longErrorCode}');
// Log success message for fallback initiation
print('VerifyAuthScreen - Alternative device activation process has been initiated');
} catch (error) {
// This catch block handles sync response errors (rejected promises)
print('VerifyAuthScreen - FallbackNewDeviceActivationFlow sync error: $error');
final result = error as RDNASyncResponse;
final errorMessage = result.error?.errorString ?? 'Fallback activation failed';
setState(() {
_error = errorMessage;
});
} finally {
if (mounted) {
setState(() {
_isProcessing = false;
});
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFFF8F9FA),
body: SafeArea(
child: SingleChildScrollView(
child: Column(
children: [
// Close Button
Align(
alignment: Alignment.topRight,
child: Padding(
padding: const EdgeInsets.all(16.0),
child: custom.CloseButton(
onPressed: _handleClose,
disabled: _isProcessing,
),
),
),
Padding(
padding: const EdgeInsets.all(20.0),
child: Column(
children: [
const SizedBox(height: 40),
Text(
widget.title ?? 'Additional Device Activation',
style: const TextStyle(
fontSize: 28,
fontWeight: FontWeight.bold,
color: Color(0xFF2C3E50),
),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
widget.subtitle ?? 'Activate this device for secure access',
style: const TextStyle(
fontSize: 16,
color: Color(0xFF7F8C8D),
),
textAlign: TextAlign.center,
),
const SizedBox(height: 30),
// Error Display
if (_error != null)
Padding(
padding: const EdgeInsets.only(bottom: 20.0),
child: StatusBanner(
type: StatusBannerType.error,
message: _error!,
),
),
// Processing Status
if (_isProcessing)
const Padding(
padding: EdgeInsets.only(bottom: 20.0),
child: StatusBanner(
type: StatusBannerType.info,
message: 'Processing device activation...',
),
),
// Activation Information
if (_activationData != null) ...[
// Processing Message
Container(
decoration: BoxDecoration(
color: const Color(0xFFE3F2FD),
borderRadius: BorderRadius.circular(12),
border: const Border(
left: BorderSide(
color: Color(0xFF2196F3),
width: 4,
),
),
),
padding: const EdgeInsets.all(20),
margin: const EdgeInsets.only(bottom: 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'REL-ID Verify Authentication',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: Color(0xFF1976D2),
),
),
const SizedBox(height: 8),
const Text(
'REL-ID Verify notification has been sent to your registered devices. Please approve it to activate this device.',
style: TextStyle(
fontSize: 16,
color: Color(0xFF1565C0),
height: 1.5,
),
),
],
),
),
// Fallback Option
Container(
decoration: BoxDecoration(
color: const Color(0xFFF5F5F5),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: const Color(0xFFE0E0E0),
),
),
padding: const EdgeInsets.all(20),
child: Column(
children: [
const Text(
'Device Not Handy?',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: Color(0xFF2C3E50),
),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
const Text(
'If you don\'t have access to your registered devices, you can use an alternative activation method.',
style: TextStyle(
fontSize: 14,
color: Color(0xFF7F8C8D),
height: 1.4,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
CustomButton(
title: 'Activate using fallback method',
onPressed: _handleFallbackFlow,
loading: _isProcessing,
variant: ButtonVariant.outline,
),
],
),
),
],
],
),
),
],
),
),
),
);
}
}
performVerifyAuth(true) using WidgetsBinding.instance.addPostFrameCallback()The following image showcases screen from the sample application:

Create the GetNotificationsScreen that automatically loads server notifications and provides interactive action modals for user responses.
Due to the complexity and size of GetNotificationsScreen, I'll show the key implementation patterns here. The complete file follows Flutter's StatefulWidget pattern with Riverpod for state management.
// lib/tutorial/screens/notification/get_notifications_screen.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../uniken/services/rdna_service.dart';
import '../../../rdna_client/lib/rdna_struct.dart';
import '../components/status_banner.dart';
import '../components/custom_button.dart';
/**
* Get Notifications Screen Component
*/
class GetNotificationsScreen extends ConsumerStatefulWidget {
final String userID;
final String sessionID;
final int sessionType;
final String jwtToken;
final String? loginTime;
final String? userRole;
final String? currentWorkFlow;
const GetNotificationsScreen({
Key? key,
required this.userID,
required this.sessionID,
required this.sessionType,
required this.jwtToken,
this.loginTime,
this.userRole,
this.currentWorkFlow,
}) : super(key: key);
@override
ConsumerState<GetNotificationsScreen> createState() => _GetNotificationsScreenState();
}
class _GetNotificationsScreenState extends ConsumerState<GetNotificationsScreen> {
List<RDNANotification> _notifications = [];
bool _isLoading = true;
bool _isRefreshing = false;
String? _error;
RDNANotification? _selectedNotification;
String? _selectedAction;
bool _isProcessingAction = false;
bool _showActionModal = false;
@override
void initState() {
super.initState();
// Set up notification event handlers
final eventManager = ref.read(rdnaServiceProvider).getEventManager();
eventManager.setGetNotificationsHandler(_handleGetNotificationsResponse);
eventManager.setUpdateNotificationHandler(_handleUpdateNotificationResponse);
// Auto-load notifications when screen loads
WidgetsBinding.instance.addPostFrameCallback((_) {
_loadNotifications();
});
}
@override
void dispose() {
// Cleanup handlers on unmount
final eventManager = ref.read(rdnaServiceProvider).getEventManager();
eventManager.setGetNotificationsHandler(null);
eventManager.setUpdateNotificationHandler(null);
super.dispose();
}
/**
* Handle notifications received from onGetNotifications event
*/
void _handleGetNotificationsResponse(RDNAResponseMetaData data) {
print('GetNotificationsScreen - Received notifications event');
setState(() {
_isLoading = false;
_isRefreshing = false;
});
// Check if this is the standard response format
if (data.error?.longErrorCode == 0 && data.responseData?.response != null) {
final response = data.responseData!.response as RDNAGetNotificationsResponse;
final notificationList = response.notifications ?? [];
print('GetNotificationsScreen - Received notifications: ${notificationList.length}');
setState(() {
_notifications = notificationList;
});
} else {
// Error or empty response
print('GetNotificationsScreen - Error or empty response: ${data.error?.errorString}');
setState(() {
_notifications = [];
_error = data.error?.errorString ?? 'Failed to load notifications';
});
}
}
/**
* Handle update notification response from onUpdateNotification event
*/
void _handleUpdateNotificationResponse(RDNAResponseMetaData data) {
print('GetNotificationsScreen - Received update notification event');
setState(() {
_isProcessingAction = false;
});
// Check for errors first
if (data.error?.longErrorCode != 0) {
final errorMessage = data.error?.errorString ?? 'Failed to update notification';
print('GetNotificationsScreen - Update notification error: ${data.error}');
_showAlert(
'Update Failed',
errorMessage,
);
return;
}
// Check response status
if (data.responseData?.response != null) {
final response = data.responseData!.response as RDNAUpdateNotificationResponse;
if (response.statusCode == 100) {
print('GetNotificationsScreen - Update notification success');
setState(() {
_showActionModal = false;
});
_loadNotifications();
} else {
final statusMessage = response.message ?? 'Unknown error occurred';
print('GetNotificationsScreen - Update notification status error: $statusMessage');
_showAlert(
'Update Failed',
statusMessage,
onDismiss: () {
setState(() {
_showActionModal = false;
});
_loadNotifications();
},
);
}
}
}
/**
* Load notifications from server
*/
Future<void> _loadNotifications() async {
try {
setState(() {
_error = null;
});
print('GetNotificationsScreen - Loading notifications for user: ${widget.userID}');
final rdnaService = ref.read(rdnaServiceProvider);
await rdnaService.getNotifications();
print('GetNotificationsScreen - GetNotifications API called, waiting for response');
} catch (error) {
print('GetNotificationsScreen - Error loading notifications: $error');
setState(() {
_isLoading = false;
_isRefreshing = false;
_error = 'Failed to load notifications. Please try again.';
});
}
}
/**
* Handle pull-to-refresh
*/
Future<void> _handleRefresh() async {
setState(() {
_isRefreshing = true;
});
await _loadNotifications();
}
/**
* Open action modal for notification
*/
void _openActionModal(RDNANotification notification) {
if (notification.actions == null || notification.actions!.isEmpty) {
_showAlert('No Actions', 'This notification has no available actions.');
return;
}
if (notification.actionPerformed != null && notification.actionPerformed!.isNotEmpty) {
_showAlert('Already Processed', 'This notification has already been processed.');
return;
}
setState(() {
_selectedNotification = notification;
_selectedAction = null;
_showActionModal = true;
});
}
/**
* Execute the notification action
*/
Future<void> _executeNotificationAction() async {
if (_selectedNotification == null || _selectedAction == null) return;
setState(() {
_isProcessingAction = true;
});
try {
print('GetNotificationsScreen - Processing notification action: ${_selectedNotification!.notificationUuid}');
final rdnaService = ref.read(rdnaServiceProvider);
await rdnaService.updateNotification(
_selectedNotification!.notificationUuid!,
_selectedAction!,
);
print('GetNotificationsScreen - UpdateNotification API called, waiting for response');
} catch (error) {
print('GetNotificationsScreen - Error processing action: $error');
setState(() {
_isProcessingAction = false;
});
_showAlert(
'Error',
'Failed to process notification action. Please try again.',
);
}
}
/**
* Show alert dialog
*/
void _showAlert(String title, String message, {VoidCallback? onDismiss}) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(title),
content: Text(message),
actions: [
TextButton(
onPressed: () {
Navigator.of(context).pop();
if (onDismiss != null) {
onDismiss();
}
},
child: const Text('OK'),
),
],
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFFF5F5F5),
body: Column(
children: [
// Header
Container(
color: Colors.white,
padding: const EdgeInsets.only(
left: 20,
right: 20,
top: 60,
bottom: 20,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Notifications',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: Color(0xFF333333),
),
),
const SizedBox(height: 8),
const Text(
'Manage your REL-ID notifications',
style: TextStyle(
fontSize: 16,
color: Color(0xFF666666),
),
),
const SizedBox(height: 8),
Text(
'User: ${widget.userID}',
style: const TextStyle(
fontSize: 14,
color: Color(0xFF007AFF),
fontWeight: FontWeight.w500,
),
),
],
),
),
// Content
Expanded(
child: _isLoading && !_isRefreshing
? const Center(
child: StatusBanner(
type: StatusBannerType.info,
message: 'Loading notifications...',
),
)
: RefreshIndicator(
onRefresh: _handleRefresh,
child: _notifications.isEmpty
? _buildEmptyState()
: ListView.builder(
padding: const EdgeInsets.all(20),
itemCount: _notifications.length,
itemBuilder: (context, index) {
return _buildNotificationItem(_notifications[index]);
},
),
),
),
// Action Modal
if (_showActionModal) _buildActionModal(),
],
),
);
}
Widget _buildEmptyState() {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text(
'No Notifications',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: Color(0xFF333333),
),
),
const SizedBox(height: 8),
const Text(
'You don\'t have any notifications at the moment.',
style: TextStyle(
fontSize: 14,
color: Color(0xFF666666),
),
textAlign: TextAlign.center,
),
const SizedBox(height: 20),
CustomButton(
title: 'Refresh',
onPressed: _loadNotifications,
variant: ButtonVariant.outline,
),
],
),
);
}
Widget _buildNotificationItem(RDNANotification notification) {
final primaryBody = notification.body?.first;
final subject = primaryBody?.subject ?? 'No Subject';
final message = primaryBody?.message ?? 'No Message';
final actionCount = notification.actions?.length ?? 0;
final actionStatus = notification.actionPerformed ?? 'Pending';
return GestureDetector(
onTap: () => _openActionModal(notification),
child: Container(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 2,
offset: const Offset(0, 1),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Text(
subject,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Color(0xFF333333),
),
),
),
Text(
_formatTimestamp(notification.createTs ?? ''),
style: const TextStyle(
fontSize: 12,
color: Color(0xFF8E8E93),
),
),
],
),
const SizedBox(height: 8),
Text(
message,
style: const TextStyle(
fontSize: 14,
color: Color(0xFF666666),
height: 1.4,
),
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'$actionCount action${actionCount != 1 ? 's' : ''} available',
style: const TextStyle(
fontSize: 12,
color: Color(0xFF8E8E93),
),
),
Text(
actionStatus,
style: const TextStyle(
fontSize: 12,
color: Color(0xFF8E8E93),
),
),
],
),
if (notification.expiryTimestamp != null)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
'Expires: ${_formatTimestamp(notification.expiryTimestamp!)}',
style: const TextStyle(
fontSize: 12,
color: Color(0xFF8E8E93),
),
),
),
],
),
),
);
}
Widget _buildActionModal() {
return Container(
color: Colors.black.withOpacity(0.5),
child: Center(
child: Container(
margin: const EdgeInsets.all(20),
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'Notification Actions',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: Color(0xFF333333),
),
),
if (!_isProcessingAction)
GestureDetector(
onTap: () {
setState(() {
_showActionModal = false;
});
},
child: Container(
width: 30,
height: 30,
decoration: BoxDecoration(
color: const Color(0xFFF0F0F0),
borderRadius: BorderRadius.circular(15),
),
child: const Center(
child: Text(
'×',
style: TextStyle(
fontSize: 20,
color: Color(0xFF666666),
fontWeight: FontWeight.bold,
),
),
),
),
),
],
),
const SizedBox(height: 20),
if (_selectedNotification != null) ...[
Text(
_selectedNotification!.body?.first?.subject ?? 'No Subject',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Color(0xFF333333),
),
),
const SizedBox(height: 8),
Text(
_selectedNotification!.body?.first?.message ?? 'No Message',
style: const TextStyle(
fontSize: 14,
color: Color(0xFF666666),
height: 1.4,
),
),
const SizedBox(height: 20),
const Text(
'Select an action:',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Color(0xFF333333),
),
),
const SizedBox(height: 12),
...(_selectedNotification!.actions ?? []).map((action) {
final isSelected = _selectedAction == action.action;
return GestureDetector(
onTap: _isProcessingAction
? null
: () {
setState(() {
_selectedAction = action.action;
});
},
child: Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: isSelected ? const Color(0xFFF0F8FF) : null,
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: isSelected ? const Color(0xFF007AFF) : const Color(0xFFE0E0E0),
),
),
child: Row(
children: [
Container(
width: 20,
height: 20,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(
color: isSelected ? const Color(0xFF007AFF) : const Color(0xFFCCCCCC),
width: 2,
),
),
child: isSelected
? Center(
child: Container(
width: 10,
height: 10,
decoration: const BoxDecoration(
shape: BoxShape.circle,
color: Color(0xFF007AFF),
),
),
)
: null,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
action.label ?? 'Action',
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: Color(0xFF333333),
),
),
if (action.authlevel != null)
Text(
action.authlevel!,
style: const TextStyle(
fontSize: 12,
color: Color(0xFF666666),
),
),
],
),
),
],
),
),
);
}).toList(),
const SizedBox(height: 20),
CustomButton(
title: _isProcessingAction ? 'Processing...' : 'Submit Action',
onPressed: _selectedAction == null || _isProcessingAction
? null
: _executeNotificationAction,
variant: ButtonVariant.primary,
),
if (!_isProcessingAction)
Padding(
padding: const EdgeInsets.only(top: 10),
child: CustomButton(
title: 'Cancel',
onPressed: () {
setState(() {
_showActionModal = false;
});
},
variant: ButtonVariant.outline,
),
),
],
],
),
),
),
);
}
String _formatTimestamp(String timestamp) {
try {
final date = DateTime.parse(timestamp.replaceAll('UTC', 'Z'));
return '${date.day}/${date.month}/${date.year} ${date.hour}:${date.minute.toString().padLeft(2, '0')}';
} catch (e) {
return timestamp;
}
}
}
getNotifications() when screen loads using WidgetsBinding.instance.addPostFrameCallback()getNotifications and updateNotification events via event managerRefreshIndicatordispose() methodConsumerStatefulWidget for Riverpod integrationListView.builderThe following images showcase screens from the sample application:
|
|
|
Extend your existing SDKEventProvider to handle device activation events and coordinate navigation for the additional device activation workflow.
Enhance your SDKEventProvider with device activation event handling:
// lib/uniken/providers/sdk_event_provider.dart (device activation additions)
class SDKEventProviderWidget extends ConsumerStatefulWidget {
final Widget child;
const SDKEventProviderWidget({Key? key, required this.child}) : super(key: key);
@override
ConsumerState<SDKEventProviderWidget> createState() => _SDKEventProviderWidgetState();
}
class _SDKEventProviderWidgetState extends ConsumerState<SDKEventProviderWidget> {
@override
void initState() {
super.initState();
_registerEventHandlers();
}
void _registerEventHandlers() {
final eventManager = ref.read(rdnaServiceProvider).getEventManager();
// Existing MFA event handlers
eventManager.setGetUserHandler(_handleGetUser);
eventManager.setGetPasswordHandler(_handleGetPassword);
eventManager.setGetActivationCodeHandler(_handleGetActivationCode);
eventManager.setGetUserConsentForLDAHandler(_handleGetUserConsentForLDA);
eventManager.setOnUserLoggedInHandler(_handleUserLoggedIn);
eventManager.setCredentialsAvailableForUpdateHandler(_handleCredentialsAvailableForUpdate);
eventManager.setOnUserLoggedOffHandler(_handleUserLoggedOff);
// Device activation event handlers
eventManager.setAddNewDeviceOptionsHandler(_handleAddNewDeviceOptions);
eventManager.setGetNotificationsHandler(_handleGetNotifications);
eventManager.setUpdateNotificationHandler(_handleUpdateNotification);
}
/**
* Event handler for device activation options
* Triggered when SDK detects unregistered device during authentication
*/
void _handleAddNewDeviceOptions(RDNAAddNewDeviceOptions data) {
print('SDKEventProvider - Add new device options event received for user: ${data.userId}');
print('SDKEventProvider - Available options: ${data.newDeviceOptions}');
print('SDKEventProvider - Challenge info count: ${data.challengeInfo?.length ?? 0}');
// Navigate to VerifyAuthScreen with activation options
appRouter.goNamed(
'verifyAuthScreen',
extra: {
'eventName': 'addNewDeviceOptions',
'eventData': data,
'title': 'Additional Device Activation',
'subtitle': 'Activate this device for user: ${data.userId}',
'responseData': data,
},
);
}
/**
* Event handler for get notifications response
* Triggered after getNotifications API call completes
*/
void _handleGetNotifications(RDNAResponseMetaData data) {
print('SDKEventProvider - Get notifications event received');
// The GetNotificationsScreen handles this event directly through its own event subscription
// No navigation needed here - this is handled by the screen itself
print('SDKEventProvider - Get notifications event handled by GetNotificationsScreen');
}
/**
* Event handler for update notification response
* Triggered after updateNotification API call completes
*/
void _handleUpdateNotification(RDNAResponseMetaData data) {
print('SDKEventProvider - Update notification event received');
// The GetNotificationsScreen handles this event directly through its own event subscription
// No navigation needed here - this is handled by the screen itself
print('SDKEventProvider - Update notification event handled by GetNotificationsScreen');
}
/**
* Enhanced handleUserLoggedIn for device activation support
* Updated to handle drawer navigation with GetNotifications
*/
void _handleUserLoggedIn(RDNAUserLoggedIn data) {
print('SDKEventProvider - User logged in event received for user: ${data.challengeResponse?.userId}');
print('SDKEventProvider - Session ID: ${data.challengeResponse?.session?.sessionID}');
// Extract session and JWT information
final sessionID = data.challengeResponse?.session?.sessionID ?? '';
final sessionType = data.challengeResponse?.session?.sessionType ?? 0;
final additionalInfo = data.challengeResponse?.additionalInfo;
final jwtToken = additionalInfo?.jwtJsonTokenInfo ?? '';
final userRole = additionalInfo?.idvUserRole;
final currentWorkFlow = additionalInfo?.currentWorkFlow;
// Navigate to DrawerNavigator with all session data
// This now includes access to GetNotifications screen
appRouter.goNamed(
'dashboard',
extra: {
'userID': data.challengeResponse?.userId ?? '',
'sessionID': sessionID,
'sessionType': sessionType,
'jwtToken': jwtToken,
'loginTime': DateTime.now().toString(),
'userRole': userRole,
'currentWorkFlow': currentWorkFlow,
},
);
}
@override
void dispose() {
final eventManager = ref.read(rdnaServiceProvider).getEventManager();
eventManager.cleanup();
super.dispose();
}
@override
Widget build(BuildContext context) {
return widget.child;
}
}
Update your navigation types to support the new device activation screens:
// lib/tutorial/navigation/app_router.dart (route additions)
final appRouter = GoRouter(
initialLocation: '/',
routes: [
// ... existing routes ...
// Device Activation Screens
GoRoute(
path: '/verify-auth',
name: 'verifyAuthScreen',
builder: (context, state) {
final extra = state.extra as Map<String, dynamic>?;
return VerifyAuthScreen(
eventData: extra?['eventData'] as RDNAAddNewDeviceOptions?,
title: extra?['title'] as String?,
subtitle: extra?['subtitle'] as String?,
responseData: extra?['responseData'] as RDNAAddNewDeviceOptions?,
);
},
),
// Drawer Navigator (enhanced with GetNotifications)
GoRoute(
path: '/dashboard',
name: 'dashboard',
builder: (context, state) {
final extra = state.extra as Map<String, dynamic>?;
return DashboardScreen(
userID: extra?['userID'] as String? ?? '',
sessionID: extra?['sessionID'] as String? ?? '',
sessionType: extra?['sessionType'] as int? ?? 0,
jwtToken: extra?['jwtToken'] as String? ?? '',
loginTime: extra?['loginTime'] as String?,
userRole: extra?['userRole'] as String?,
currentWorkFlow: extra?['currentWorkFlow'] as String?,
);
},
),
],
);
The enhanced SDKEventProvider coordinates these device activation flows:
addNewDeviceOptionsThe provider uses a layered event handling approach:
addNewDeviceOptionsgetNotificationsTest your device activation implementation to ensure REL-ID Verify workflows, fallback methods, and notification management work correctly across different scenarios.
Test the complete automatic device activation flow:
# Ensure you have multiple physical devices
# Device A: Already registered with REL-ID
# Device B: New device for activation testing
# Build and deploy to both devices
flutter run -d <device-a-id>
flutter run -d <device-b-id>
addNewDeviceOptions eventperformVerifyAuth(true) called automaticallySDKEventProvider - Add new device options event received for user: testuser@example.com
SDKEventProvider - Available options: [verify-auth, fallback]
VerifyAuthScreen - Auto-starting REL-ID Verify for user: testuser@example.com
VerifyAuthScreen - PerformVerifyAuth sync response successful
Test the fallback activation when REL-ID Verify is not accessible:
fallbackNewDeviceActivationFlow() calledVerifyAuthScreen - Starting fallback activation for user: testuser@example.com
VerifyAuthScreen - FallbackNewDeviceActivationFlow sync response successful
Test the GetNotificationsScreen functionality:
getNotifications() API called again// Check if device is already registered
// Verify MFA flow completion before device detection
// Ensure proper connection profile configuration
// Check GetNotificationsScreen event handler setup
eventManager.setGetNotificationsHandler(_handleGetNotificationsResponse);
// Verify API call execution
await rdnaService.getNotifications();
addNewDeviceOptions event triggers during MFAperformVerifyAuth(true) executes automatically using WidgetsBinding.instance.addPostFrameCallback()Congratulations! You've successfully implemented a comprehensive Additional Device Activation system with REL-ID Verify push notifications, fallback methods, and notification management.
✅ REL-ID Verify Integration: Automatic push notification-based device activation ✅ VerifyAuthScreen Implementation: Auto-starting activation with real-time status updates ✅ Fallback Activation Methods: Alternative activation when registered devices aren't accessible ✅ GetNotificationsScreen: Server notification management with interactive action processing ✅ Enhanced Navigation: Seamless access to notifications via go_router integration
Your implementation now handles these production scenarios:
You've mastered Advanced Device Activation with REL-ID Verify and built a production-ready system that provides:
Your application now provides enterprise-grade device activation capabilities that enhance security while maintaining user convenience. You're ready to deploy this solution in production environments and scale to support thousands of users across multiple devices.
🚀 You're now equipped to build sophisticated device activation workflows that combine security, usability, and reliability!