sotaid_pluging 0.0.2+1
sotaid_pluging: ^0.0.2+1 copied to clipboard
A Flutter plugin for integrating SotaBoost's identity verification service into Flutter applications
example/lib/main.dart
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:sotaid_pluging/sotaid_pluging.dart';
import 'package:sotaid_pluging/verification_flow.dart';
import 'package:sotaid_pluging/sotaid_plugin.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:image_picker/image_picker.dart';
import 'package:camera/camera.dart';
import 'package:google_mlkit_text_recognition/google_mlkit_text_recognition.dart';
import 'package:permission_handler/permission_handler.dart';
import 'dart:async';
import 'dart:convert';
import 'dart:io';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Sotaid',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
useMaterial3: true,
),
home: const MyHomePage(title: 'Sotaid'),
);
}
}
// Verification Status Page
class VerificationStatusPage extends StatelessWidget {
final String verificationType;
final String status;
final String? message;
final String? sessionId; // Add session ID parameter
final bool isSuccess;
const VerificationStatusPage({
Key? key,
required this.verificationType,
required this.status,
this.message,
this.sessionId, // Add session ID parameter
required this.isSuccess,
});
@override
Widget build(BuildContext context) {
final bool isPending = status == 'requires_manual_verification';
final Color statusColor = isSuccess
? Colors.green
: (isPending
? Colors.orange
: Colors.red);
final IconData overlayIcon = isSuccess
? Icons.check_circle
: (isPending ? Icons.schedule : Icons.cancel);
return Scaffold(
backgroundColor: Colors.white,
body: SafeArea(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
children: [
// Header with back button
Row(
children: [
IconButton(
onPressed: () => Navigator.of(context).pop(),
icon: Icon(Icons.arrow_back, color: Colors.black87),
),
SizedBox(width: 16),
Text(
'Status de Vérification',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Colors.black87,
),
),
],
),
SizedBox(height: 40),
// Status Icon
Container(
width: 200,
height: 200,
child: Stack(
alignment: Alignment.center,
children: [
Image.network(
'https://www.sotaid.com/img/verify/reconnaissance-de-visage.png',
width: 200,
height: 200,
errorBuilder: (context, error, stackTrace) => Icon(Icons.image, size: 80, color: Colors.grey.shade400),
),
Positioned(
bottom: 1,
left: 0,
right: 0,
child: Container(
padding: EdgeInsets.all(2),
decoration: BoxDecoration(
color: Colors.white,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.15),
blurRadius: 4,
offset: Offset(0, 2),
),
],
),
child: Icon(
overlayIcon,
size: 22,
color: statusColor,
),
),
),
],
),
),
SizedBox(height: 32),
// Status Title
Text(
"${isSuccess ? 'Identité vérifiée avec succès' : (status == 'requires_manual_verification' ? 'Vérification en cours' : 'Échec de la vérification')}",
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: statusColor,
),
textAlign: TextAlign.center,
),
SizedBox(height: 16),
// Verification Type
Container(
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: Colors.blue.shade50,
borderRadius: BorderRadius.circular(20),
border: Border.all(color: Colors.blue.shade200),
),
child: Text(
verificationType,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Colors.blue.shade700,
),
),
),
SizedBox(height: 24),
// Action Buttons
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.end,
children: [
SizedBox(
width: double.infinity,
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.blue,
foregroundColor: Colors.white,
padding: EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
onPressed: () {
Navigator.of(context).popUntil((route) => route.isFirst);
},
child: Text('Terminer'),
),
),
],
),
),
],
),
),
),
);
}
Widget _buildDetailRow(String label, String value) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 80,
child: Text(
label,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: Colors.grey.shade700,
),
),
),
Expanded(
child: Text(
value,
style: TextStyle(
fontSize: 14,
color: Colors.black87,
),
),
),
],
);
}
}
// MRZ Data Model
class MRZData {
final String documentType;
final String issuingCountry;
final String lastName;
final String firstName;
final String documentNumber;
final String nationality;
final String birthDate;
final String sex;
final String expiryDate;
final String personalNumber;
MRZData({
required this.documentType,
required this.issuingCountry,
required this.lastName,
required this.firstName,
required this.documentNumber,
required this.nationality,
required this.birthDate,
required this.sex,
required this.expiryDate,
required this.personalNumber,
});
Map<String, dynamic> toJson() => {
'documentType': documentType,
'issuingCountry': issuingCountry,
'lastName': lastName,
'firstName': firstName,
'documentNumber': documentNumber,
'nationality': nationality,
'birthDate': birthDate,
'sex': sex,
'expiryDate': expiryDate,
'personalNumber': personalNumber,
};
@override
String toString() => jsonEncode(toJson());
}
class MyHomePage extends StatefulWidget {
const MyHomePage({super.key, required this.title});
final String title;
@override
State<MyHomePage> createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
final SotaidPluging _plugin = SotaidPluging();
final SotaIdPlugin _sotaIdPlugin = SotaIdPlugin();
String _platformVersion = 'Unknown';
List<Map<String, dynamic>> _countries = [];
bool _isLoading = false;
String _bearerToken = 'sotaboost'; // Test token for sandbox
bool _hasPendingVerification = false; // Track pending verification state
@override
void initState() {
super.initState();
// Initialize plugin with bearer token (defaults to sandbox mode)
_plugin.initialize(bearerToken: _bearerToken);
_initPlatformState();
_checkPendingVerificationOnStartup();
}
Future<void> _initPlatformState() async {
String? platformVersion;
try {
platformVersion = await _plugin.getPlatformVersion();
} catch (e) {
platformVersion = 'Failed to get platform version: $e';
}
if (!mounted) return;
setState(() {
_platformVersion = platformVersion ?? 'Unknown';
});
}
Future<void> _loadCountries() async {
setState(() {
_isLoading = true;
});
try {
final countries = await _plugin.getCountries();
setState(() {
_countries = countries;
_isLoading = false;
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Successfully loaded ${countries.length} countries')),
);
} catch (e) {
setState(() {
_isLoading = false;
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Error loading countries: $e'),
backgroundColor: Colors.red,
),
);
}
}
void _showVerificationFlow() {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => Scaffold(
appBar: AppBar(
title: Text('SotaID Verification'),
backgroundColor: Colors.transparent,
elevation: 0,
),
body: SotaIdVerificationFlow(
verificationType: VerificationType.id, // App controls the verification type
bearerToken: _bearerToken,
onVerificationComplete: (response) async {
print('=== VERIFICATION RESPONSE (Full KYC) ===');
print('Status: ${response.status}');
print('Message: ${response.message}');
print('Session ID: ${response.sessionId}');
print('Success: ${response.status == 'success'}');
print('Full Response: $response');
print('=====================================');
// Handle different response types
if (response.status == 'check_pending_status') {
// Handle pending status check - this should be handled by the main app
print('Checking pending verification status for session: ${response.sessionId}');
if (response.sessionId != null) {
await _checkVerificationStatus(response.sessionId!);
}
return;
}
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => VerificationStatusPage(
verificationType: 'Full KYC',
status: response.status,
message: response.message,
sessionId: response.sessionId, // Pass session ID to status page
isSuccess: response.status == 'success',
),
),
);
},
onError: (error) {
print('=== VERIFICATION ERROR (Full KYC) ===');
print('Error: $error');
print('=====================================');
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => VerificationStatusPage(
verificationType: 'Full KYC',
status: 'error',
message: error,
isSuccess: false,
),
),
);
},
),
),
),
);
}
void _startCompleteVerification() async {
// Check for pending verification first
await _handlePendingVerificationCheck();
await _sotaIdPlugin.startCompleteVerification(
context: context,
bearerToken: _bearerToken,
onVerificationComplete: (response) async {
print('=== VERIFICATION RESPONSE (Complete Verification) ===');
print('Status: ${response.status}');
print('Message: ${response.message}');
print('Session ID: ${response.sessionId}');
print('Success: ${response.status == 'success'}');
print('Full Response: $response');
print('=====================================');
// Handle different response types
if (response.status == 'check_pending_status') {
// Handle pending status check - this should be handled by the main app
print('Checking pending verification status for session: ${response.sessionId}');
if (response.sessionId != null) {
await _checkVerificationStatus(response.sessionId!);
}
return;
}
// Handle requires_manual_verification status
if (response.status == 'requires_manual_verification') {
setState(() {
_hasPendingVerification = true;
});
print('Verification requires manual review. Session stored for later checking.');
}
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => VerificationStatusPage(
verificationType: 'Complete Verification',
status: response.status,
message: response.message,
sessionId: response.sessionId, // Pass session ID to status page
isSuccess: response.status == 'success',
),
),
);
},
onError: (error) {
print('=== VERIFICATION ERROR (Complete Verification) ===');
print('Error: $error');
print('=====================================');
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => VerificationStatusPage(
verificationType: 'Complete Verification',
status: 'error',
message: error,
isSuccess: false,
),
),
);
},
);
}
void _startFaceVerification() async {
// Check for pending verification first
await _handlePendingVerificationCheck();
// Navigate to image upload page first
final result = await Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => ImageUploadPage(
bearerToken: _bearerToken,
),
),
);
// Handle result from ImageUploadPage (for pending status check)
if (result != null && result is Map<String, dynamic>) {
if (result['action'] == 'check_pending_status') {
final sessionId = result['session_id'] as String?;
if (sessionId != null) {
await _checkVerificationStatus(sessionId);
}
} else if (result['action'] == 'requires_manual_verification') {
// Set pending verification state
setState(() {
_hasPendingVerification = true;
});
print('Face verification requires manual review. Session stored for later checking.');
}
}
}
void _startLivenessVerification() async {
// Check for pending verification first
await _handlePendingVerificationCheck();
await _sotaIdPlugin.startVerificationFlow(
context: context,
verificationType: VerificationType.liveness,
bearerToken: _bearerToken,
onVerificationComplete: (response) async {
print('=== VERIFICATION RESPONSE (Liveness) ===');
print('Status: ${response.status}');
print('Message: ${response.message}');
print('Session ID: ${response.sessionId}');
print('Success: ${response.status == 'success'}');
print('Full Response: $response');
print('=====================================');
// Handle different response types
if (response.status == 'check_pending_status') {
// Handle pending status check - this should be handled by the main app
print('Checking pending verification status for session: ${response.sessionId}');
if (response.sessionId != null) {
await _checkVerificationStatus(response.sessionId!);
}
return;
}
// Handle requires_manual_verification status
if (response.status == 'requires_manual_verification') {
setState(() {
_hasPendingVerification = true;
});
print('Verification requires manual review. Session stored for later checking.');
}
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => VerificationStatusPage(
verificationType: 'Liveness',
status: response.status,
message: response.message,
sessionId: response.sessionId, // Pass session ID to status page
isSuccess: response.status == 'success',
),
),
);
},
onError: (error) {
print('=== VERIFICATION ERROR (Liveness) ===');
print('Error: $error');
print('=====================================');
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => VerificationStatusPage(
verificationType: 'Liveness',
status: 'error',
message: error,
isSuccess: false,
),
),
);
},
);
}
void _startMRZVerification() async {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => MRZScannerPage(
bearerToken: _bearerToken,
),
),
);
}
void _showSnackBar(String message, {Color? backgroundColor}) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
backgroundColor: backgroundColor,
),
);
}
/// Check verification status for a pending session
Future<void> _checkVerificationStatus(String sessionId) async {
try {
setState(() {
_isLoading = true;
});
final status = await _sotaIdPlugin.getVerificationStatus(sessionId);
setState(() {
_isLoading = false;
});
// Show status result
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => VerificationStatusPage(
verificationType: 'Status Check',
status: status.status,
message: 'Status checked for session: $sessionId',
sessionId: sessionId,
isSuccess: status.status == 'verified',
),
),
);
// Clear pending state if verification is completed
if (status.status == 'verified' || status.status == 'failed' || status.status == 'success') {
await _sotaIdPlugin.clearPendingSession();
setState(() {
_hasPendingVerification = false;
});
}
} catch (e) {
setState(() {
_isLoading = false;
});
_showSnackBar('Error checking status: $e', backgroundColor: Colors.red);
}
}
/// Check for pending verification on app startup
Future<void> _checkPendingVerificationOnStartup() async {
try {
final pendingSession = await _sotaIdPlugin.getPendingSession();
if (pendingSession != null) {
setState(() {
_hasPendingVerification = true;
});
// Show a subtle notification about pending verification
_showSnackBar(
'Vous avez une vérification en cours. Cliquez sur l\'icône horloge ou la notification pour vérifier le statut.',
backgroundColor: Colors.orange,
);
}
} catch (e) {
print('Error checking pending verification on startup: $e');
}
}
/// Show dialog when user tries to start verification with pending manual verification
Future<bool> _showPendingVerificationDialog() async {
return await showDialog<bool>(
context: context,
barrierDismissible: false,
builder: (BuildContext context) {
return AlertDialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
title: Row(
children: [
Icon(
Icons.schedule,
color: Colors.orange,
size: 24,
),
SizedBox(width: 12),
Text(
'Vérification en cours',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
],
),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Vous avez une vérification en attente de traitement manuel.',
style: TextStyle(
fontSize: 16,
color: Colors.black87,
),
),
SizedBox(height: 16),
Text(
'Que souhaitez-vous faire ?',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Colors.grey.shade700,
),
),
],
),
actions: [
TextButton(
onPressed: () {
Navigator.of(context).pop(true); // Continue with new verification
},
child: Text(
'Nouvelle vérification',
style: TextStyle(
color: Colors.grey.shade600,
fontWeight: FontWeight.w600,
),
),
),
ElevatedButton(
onPressed: () async {
Navigator.of(context).pop(); // Close dialog first
// Get the pending session and check its status directly
final pendingSession = await _sotaIdPlugin.getPendingSession();
if (pendingSession != null && pendingSession['session_id'] != null) {
await _checkVerificationStatus(pendingSession['session_id']);
}
},
style: ElevatedButton.styleFrom(
backgroundColor: Colors.orange,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
child: Text(
'Vérifier le statut',
style: TextStyle(
fontWeight: FontWeight.w600,
),
),
),
],
);
},
) ?? false;
}
/// Check if there's a pending verification and handle accordingly
Future<void> _handlePendingVerificationCheck() async {
if (_hasPendingVerification) {
final result = await _showPendingVerificationDialog();
if (result == true) {
// User chose to continue with new verification, clear the pending state
await _sotaIdPlugin.clearPendingSession();
setState(() {
_hasPendingVerification = false;
});
}
// If result is false or null, the user either cancelled or chose to check status
// The status check is now handled directly in the button onPressed callback
}
}
Widget _buildVerificationTypeInfo({
required IconData icon,
required String title,
required String description,
required Color color,
}) {
return Row(
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: color.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: Icon(icon, color: color, size: 20),
),
SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: TextStyle(
fontWeight: FontWeight.w600,
fontSize: 14,
),
),
Text(
description,
style: TextStyle(
color: Colors.grey.shade600,
fontSize: 12,
),
),
],
),
),
],
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.white,
appBar: AppBar(
backgroundColor: Colors.transparent,
elevation: 0,
actions: [
if (_hasPendingVerification)
IconButton(
onPressed: () async {
final pendingSession = await _sotaIdPlugin.getPendingSession();
if (pendingSession != null && pendingSession['session_id'] != null) {
await _checkVerificationStatus(pendingSession['session_id']);
}
},
icon: Icon(
Icons.schedule,
color: Colors.orange,
),
tooltip: 'Vérifier le statut en cours',
),
],
),
body: SafeArea(
child: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Column(
children: [
// Logo with better styling
Container(
padding: EdgeInsets.all(16),
child: Image.asset(
'assets/sotaid_logo.png',
height: 40,
fit: BoxFit.contain,
),
),
SizedBox(height: 5),
// Instructional text
Text(
'Veuillez sélectionner l\'un des trois processus pour commencer',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 14,
color: Colors.black87,
height: 1.4,
fontWeight: FontWeight.w500,
),
),
SizedBox(height: 20),
// Middle section with verification options
// Full KYC Option
_buildVerificationOption(
title: 'Full KYC',
description: 'Vous permet de réaliser notre processus entier de KYC. Acquisition et validation d\'authenticité de document, facematching avec le titulaire de la pièce avec la réalisation de la détection du vivant.',
onTap: _startCompleteVerification,
customIconPath: 'assets/full_kyc_icon.svg',
),
SizedBox(height: 10),
// Face Matching Option
_buildVerificationOption(
title: 'Face Matching',
description: 'Réaliser le facematch entre un document de référence et une nouvelle vidéo acquise. La détection du vivant y est intégrée également.',
onTap: _startFaceVerification,
customIconPath: 'assets/face_macthin_icon.svg',
),
SizedBox(height: 10),
// Liveness Option
_buildVerificationOption(
title: 'Liveness',
description: 'Preuve de vie, détection de vivant sur une vidéo reçue.',
onTap: _startLivenessVerification,
customIconPath: 'assets/liveness_icon.svg',
),
// SizedBox(height: 10),
// // MRZ Verification Option
// _buildVerificationOption(
// title: 'MRZ Scanner',
// description: 'Scanner les documents d\'identité avec MRZ (Machine Readable Zone) pour extraction automatique des données.',
// onTap: _startMRZVerification,
// icon: Icons.document_scanner,
// ),
SizedBox(height: 40),
// Bottom section with powered by text
Text(
'Powered by SotaBoost',
style: TextStyle(
fontSize: 14,
color: Colors.grey.shade500,
),
),
SizedBox(height: 40),
],
),
),
),
),
);
}
Widget _buildVerificationOption({
IconData? icon,
required String title,
required String description,
required VoidCallback onTap,
String? customIconPath,
}) {
return GestureDetector(
onTap: onTap,
child: Container(
width: double.infinity,
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Colors.grey.shade200,
width: 1,
),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 8,
offset: Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
// Icon with title badge
Container(
width: 100,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Icon container
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: Colors.grey.shade50,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.grey.shade200),
),
child: Stack(
children: [
Center(
child: customIconPath != null
? SvgPicture.asset(
customIconPath,
width: 28,
height: 28,
fit: BoxFit.contain,
)
: Icon(
icon ?? Icons.person,
size: 28,
color: Colors.black87,
),
),
// Pending verification indicator
if (_hasPendingVerification)
Positioned(
top: -2,
right: -2,
child: Container(
width: 12,
height: 12,
decoration: BoxDecoration(
color: Colors.orange,
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 2),
),
child: Icon(
Icons.schedule,
size: 6,
color: Colors.white,
),
),
),
],
),
),
SizedBox(height: 8),
// Title badge
Container(
padding: EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Colors.blue.shade400,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.blue.shade300, width: 1),
),
child: Text(
title,
style: TextStyle(
fontSize: 8,
fontWeight: FontWeight.w700,
color: Colors.white,
),
textAlign: TextAlign.center,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
),
SizedBox(width: 14),
],
),
SizedBox(height: 4),
// Description with info icon
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
Icons.info_outline,
size: 16,
color: Colors.grey.shade600,
),
SizedBox(width: 8),
Expanded(
child: Text(
description,
style: TextStyle(
fontSize: 12,
color: Colors.grey.shade700,
height: 1.4,
),
),
),
],
),
],
),
),
);
}
}
// Image Upload Page for Face Matching
class ImageUploadPage extends StatefulWidget {
final String bearerToken;
const ImageUploadPage({
super.key,
required this.bearerToken,
});
@override
State<ImageUploadPage> createState() => _ImageUploadPageState();
}
class _ImageUploadPageState extends State<ImageUploadPage> {
File? _selectedImage;
bool _isLoading = false;
final ImagePicker _picker = ImagePicker();
final SotaIdPlugin _sotaIdPlugin = SotaIdPlugin();
Future<void> _pickImage() async {
try {
setState(() {
_isLoading = true;
});
// Show option to pick from gallery or camera
final XFile? image = await showModalBottomSheet<XFile?>(
context: context,
builder: (BuildContext context) {
return SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
ListTile(
leading: Icon(Icons.photo_library),
title: Text('Galerie'),
onTap: () async {
Navigator.pop(context, await _picker.pickImage(
source: ImageSource.gallery,
imageQuality: 80,
));
},
),
ListTile(
leading: Icon(Icons.camera_alt),
title: Text('Caméra'),
onTap: () async {
Navigator.pop(context, await _picker.pickImage(
source: ImageSource.camera,
imageQuality: 80,
));
},
),
ListTile(
leading: Icon(Icons.close),
title: Text('Annuler'),
onTap: () => Navigator.pop(context),
),
],
),
);
},
);
if (image != null) {
setState(() {
_selectedImage = File(image.path);
_isLoading = false;
});
_showSnackBar('Image sélectionnée avec succès', backgroundColor: Colors.green);
} else {
setState(() {
_isLoading = false;
});
}
} catch (e) {
setState(() {
_isLoading = false;
});
_showSnackBar('Erreur lors de la sélection de l\'image: $e', backgroundColor: Colors.red);
}
}
void _showSnackBar(String message, {Color? backgroundColor}) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
backgroundColor: backgroundColor,
),
);
}
void _startFaceVerification() async {
if (_selectedImage == null) {
_showSnackBar('Veuillez d\'abord sélectionner une image de référence', backgroundColor: Colors.orange);
return;
}
await _sotaIdPlugin.startFaceVerification(
context: context,
documentFile: _selectedImage!,
bearerToken: widget.bearerToken,
onVerificationComplete: (response) async {
print('=== VERIFICATION RESPONSE (Face Matching) ===');
print('Status: ${response.status}');
print('Message: ${response.message}');
print('Session ID: ${response.sessionId}');
print('Success: ${response.status == 'success'}');
print('Full Response: $response');
print('=====================================');
// Handle different response types
if (response.status == 'check_pending_status') {
// Handle pending status check - this should be handled by the main app
print('Checking pending verification status for session: ${response.sessionId}');
if (response.sessionId != null) {
// Navigate back to main page to handle status check
Navigator.of(context).pop({
'action': 'check_pending_status',
'session_id': response.sessionId,
});
return;
}
return;
}
// Handle requires_manual_verification status
if (response.status == 'requires_manual_verification') {
// Navigate back to main page with pending verification info
Navigator.of(context).pop({
'action': 'requires_manual_verification',
'session_id': response.sessionId,
});
return;
}
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => VerificationStatusPage(
verificationType: 'Face Matching',
status: response.status,
message: response.message,
sessionId: response.sessionId, // Pass session ID to status page
isSuccess: response.status == 'success',
),
),
);
},
onError: (error) {
print('=== VERIFICATION ERROR (Face Matching) ===');
print('Error: $error');
print('=====================================');
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => VerificationStatusPage(
verificationType: 'Face Matching',
status: 'error',
message: error,
isSuccess: false,
),
),
);
},
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.white,
body: SafeArea(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
children: [
// Header
Row(
children: [
IconButton(
onPressed: () => Navigator.of(context).pop(),
icon: Icon(Icons.arrow_back, color: Colors.black87),
),
SizedBox(width: 16),
Text(
'Upload Image de Référence',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Colors.black87,
),
),
],
),
SizedBox(height: 32),
// Instructions
Container(
width: double.infinity,
padding: EdgeInsets.all(20),
decoration: BoxDecoration(
color: Colors.blue.shade50,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.blue.shade200),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.info_outline, color: Colors.blue.shade700, size: 20),
SizedBox(width: 8),
Text(
'Instructions',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Colors.blue.shade700,
),
),
],
),
SizedBox(height: 12),
Text(
'Pour le Face Matching, vous devez d\'abord fournir une image de référence (document d\'identité) qui sera comparée avec votre visage lors de la vérification.',
style: TextStyle(
fontSize: 14,
color: Colors.blue.shade700,
height: 1.4,
),
),
],
),
),
SizedBox(height: 32),
// Image Upload Area
Expanded(
child: Container(
width: double.infinity,
decoration: BoxDecoration(
color: Colors.grey.shade50,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: _selectedImage != null ? Colors.green.shade300 : Colors.grey.shade300,
width: 2,
),
),
child: _isLoading
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(),
SizedBox(height: 16),
Text(
'Chargement...',
style: TextStyle(
fontSize: 16,
color: Colors.grey.shade600,
),
),
],
),
)
: _selectedImage != null
? Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.check_circle,
color: Colors.green,
size: 60,
),
SizedBox(height: 16),
Text(
'Image sélectionnée',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: Colors.green,
),
),
SizedBox(height: 8),
Text(
'Document de référence prêt',
style: TextStyle(
fontSize: 14,
color: Colors.grey.shade600,
),
),
SizedBox(height: 24),
TextButton(
onPressed: _pickImage,
child: Text(
'Changer l\'image',
style: TextStyle(
color: Colors.blue,
fontWeight: FontWeight.w600,
),
),
),
],
)
: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.cloud_upload_outlined,
size: 80,
color: Colors.grey.shade400,
),
SizedBox(height: 16),
Text(
'Aucune image sélectionnée',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: Colors.grey.shade600,
),
),
SizedBox(height: 8),
Text(
'Tapez pour sélectionner une image',
style: TextStyle(
fontSize: 14,
color: Colors.grey.shade500,
),
),
SizedBox(height: 24),
ElevatedButton.icon(
onPressed: _pickImage,
icon: Icon(Icons.upload_file),
label: Text('Sélectionner Image'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.blue,
foregroundColor: Colors.white,
padding: EdgeInsets.symmetric(horizontal: 24, vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
),
],
),
),
),
SizedBox(height: 32),
// Start Verification Button
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: _selectedImage != null ? _startFaceVerification : null,
style: ElevatedButton.styleFrom(
backgroundColor: Colors.blue,
foregroundColor: Colors.white,
padding: EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
disabledBackgroundColor: Colors.grey.shade300,
),
child: Text(
'Commencer la Vérification',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
),
),
],
),
),
),
);
}
}
// MRZ Scanner Page
class MRZScannerPage extends StatefulWidget {
final String bearerToken;
const MRZScannerPage({
super.key,
required this.bearerToken,
});
@override
State<MRZScannerPage> createState() => _MRZScannerPageState();
}
class _MRZScannerPageState extends State<MRZScannerPage> {
bool _cameraGranted = false;
CameraController? _cameraController;
List<CameraDescription>? _cameras;
bool _isProcessing = false;
MRZData? _lastMrz;
final TextRecognizer _textRecognizer = TextRecognizer();
// Debug variables
bool _showDebugInfo = false;
String _lastRecognizedText = '';
String _debugInfo = '';
List<String> _detectedLines = [];
List<String> _mrzCandidateLines = [];
@override
void initState() {
super.initState();
_initializeCamera();
}
@override
void dispose() {
_cameraController?.dispose();
_textRecognizer.close();
super.dispose();
}
Future<void> _initializeCamera() async {
final status = await Permission.camera.request();
if (status != PermissionStatus.granted) {
setState(() {
_cameraGranted = false;
});
return;
}
try {
_cameras = await availableCameras();
if (_cameras!.isNotEmpty) {
_cameraController = CameraController(
_cameras![0],
ResolutionPreset.high,
enableAudio: false,
);
await _cameraController!.initialize();
setState(() {
_cameraGranted = true;
});
}
} catch (e) {
print('Camera initialization error: $e');
setState(() {
_cameraGranted = false;
});
}
}
Future<void> _captureAndAnalyze() async {
if (_isProcessing || _cameraController == null || !_cameraController!.value.isInitialized) {
return;
}
try {
setState(() {
_isProcessing = true;
});
final image = await _cameraController!.takePicture();
final inputImage = InputImage.fromFilePath(image.path);
final recognizedText = await _textRecognizer.processImage(inputImage);
_handleTextResult(recognizedText.text);
// Clean up the temporary image
final file = File(image.path);
if (await file.exists()) {
await file.delete();
}
} catch (e) {
print('Scanning error: $e');
_showSnackBar('Erreur lors de la capture: $e', backgroundColor: Colors.red);
} finally {
if (mounted) {
setState(() {
_isProcessing = false;
});
}
}
}
Future<void> _checkCameraPermission() async {
final status = await Permission.camera.request();
if (status == PermissionStatus.granted) {
_initializeCamera();
} else {
setState(() {
_cameraGranted = false;
});
}
}
void _handleTextResult(String recognizedText) {
setState(() {
_lastRecognizedText = recognizedText;
_detectedLines = recognizedText.split('\n').map((s) => s.trim()).where((s) => s.isNotEmpty).toList();
});
print('=== MRZ RECOGNITION DEBUG ===');
print('Raw recognized text:');
print(recognizedText);
print('Detected lines (${_detectedLines.length}):');
for (int i = 0; i < _detectedLines.length; i++) {
print('Line $i: "${_detectedLines[i]}" (length: ${_detectedLines[i].length})');
}
// Clean and process the text for MRZ detection
final lines = recognizedText.split('\n')
.map((s) => s.trim())
.where((s) => s.isNotEmpty)
.map((line) => _cleanTextForMRZ(line))
.where((line) => line != null && line.isNotEmpty)
.cast<String>()
.toList();
print('Cleaned lines (${lines.length}):');
for (int i = 0; i < lines.length; i++) {
print('Cleaned Line $i: "${lines[i]}" (length: ${lines[i].length})');
}
// Find lines that look like MRZ (contain mostly uppercase letters, numbers, and < symbols)
final mrzLines = lines.where((line) =>
line.length >= 30 &&
RegExp(r'^[A-Z0-9<]+$').hasMatch(line)
).toList();
setState(() {
_mrzCandidateLines = mrzLines;
});
print('MRZ candidate lines (${mrzLines.length}):');
for (int i = 0; i < mrzLines.length; i++) {
print('MRZ Line $i: "${mrzLines[i]}" (length: ${mrzLines[i].length})');
}
String debugMessage = '';
if (mrzLines.length >= 2) {
print('Attempting to parse MRZ from ${mrzLines.length} candidate lines...');
final parsed = _parseMrzFromLines(mrzLines);
if (parsed != null) {
print('MRZ parsed successfully!');
setState(() => _lastMrz = parsed);
_showMrzResults(parsed);
return;
} else {
debugMessage = 'MRZ candidat trouvé mais échec du parsing. ${mrzLines.length} lignes détectées mais format invalide.';
print('MRZ parsing failed despite having candidate lines');
}
} else if (mrzLines.length == 1) {
debugMessage = 'Une seule ligne MRZ détectée (2 requis). Ligne: "${mrzLines[0]}"';
print('Only 1 MRZ line found, need 2');
} else if (lines.isNotEmpty) {
debugMessage = 'Aucune ligne MRZ valide détectée. ${lines.length} lignes de texte trouvées mais aucune ne correspond au pattern MRZ.';
print('No MRZ lines found in ${lines.length} detected text lines');
// Check what's wrong with each line
for (int i = 0; i < lines.length; i++) {
final line = lines[i];
if (line.length < 30) {
print('Line $i too short: "${line}" (${line.length} chars, need 30+)');
} else if (!RegExp(r'^[A-Z0-9<]+$').hasMatch(line)) {
print('Line $i invalid chars: "${line}" (contains non-MRZ characters)');
}
}
} else {
debugMessage = 'Aucun texte détecté dans l\'image.';
print('No text detected at all');
}
setState(() {
_debugInfo = debugMessage;
});
print('===========================');
// Show user-friendly error with debug option
_showSnackBar(
_showDebugInfo ? debugMessage : 'Aucune zone MRZ détectée. Appuyez sur Debug pour plus d\'infos.',
backgroundColor: Colors.orange
);
}
MRZData? _parseMrzFromLines(List<String> mrzLines) {
try {
if (mrzLines.length >= 2) {
final line1 = mrzLines[0];
final line2 = mrzLines[1];
print('Parsing MRZ lines:');
print('Line 1: "$line1" (${line1.length} chars)');
print('Line 2: "$line2" (${line2.length} chars)');
// Basic validation for MRZ format
if (line1.length < 30 || line2.length < 30) {
print('MRZ parsing failed: Lines too short (need 30+ chars each)');
return null;
}
final docType = line1.length > 0 ? line1.substring(0, 1) : '';
final issuing = line1.length >= 5 ? line1.substring(2, 5) : '';
final names = line1.length > 5 ? line1.substring(5).replaceAll('<', ' ').trim() : '';
final nameParts = names.split(RegExp(r'\s{2,}'));
String last = '';
String first = '';
if (nameParts.isNotEmpty) last = nameParts[0].trim();
if (nameParts.length > 1) first = nameParts.sublist(1).join(' ').trim();
print('Extracted from line 1:');
print(' Document Type: "$docType"');
print(' Issuing Country: "$issuing"');
print(' Last Name: "$last"');
print(' First Name: "$first"');
final docNum = line2.length >= 9 ? line2.substring(0, 9).replaceAll('<', '').trim() : '';
final nationality = line2.length >= 13 ? line2.substring(10, 13) : '';
final dob = line2.length >= 19 ? line2.substring(13, 19) : '';
final sex = line2.length >= 21 ? line2.substring(20, 21) : '';
final expiry = line2.length >= 27 ? line2.substring(21, 27) : '';
final personal = line2.length >= 42 ? line2.substring(28, 42).replaceAll('<', '').trim() : '';
print('Extracted from line 2:');
print(' Document Number: "$docNum"');
print(' Nationality: "$nationality"');
print(' Date of Birth: "$dob"');
print(' Sex: "$sex"');
print(' Expiry Date: "$expiry"');
print(' Personal Number: "$personal"');
// Validate that we have meaningful data
if (docType.isNotEmpty && issuing.isNotEmpty && last.isNotEmpty && docNum.isNotEmpty) {
print('MRZ validation passed - creating MRZData object');
return MRZData(
documentType: docType,
issuingCountry: issuing,
lastName: last,
firstName: first,
documentNumber: docNum,
nationality: nationality,
birthDate: dob,
sex: sex,
expiryDate: expiry,
personalNumber: personal,
);
} else {
print('MRZ validation failed:');
print(' Document Type empty: ${docType.isEmpty}');
print(' Issuing Country empty: ${issuing.isEmpty}');
print(' Last Name empty: ${last.isEmpty}');
print(' Document Number empty: ${docNum.isEmpty}');
}
}
} catch (e) {
print('MRZ parsing error: $e');
}
return null;
}
String? _cleanTextForMRZ(String text) {
if (text.isEmpty) return null;
// Remove common OCR artifacts and non-MRZ characters
String cleaned = text
// Remove common OCR artifacts
.replaceAll(RegExp(r'[|]'), 'I') // Replace | with I
.replaceAll(RegExp(r'[`]'), '') // Remove backticks
.replaceAll(RegExp(r'[""]'), '"') // Normalize quotes
.replaceAll(RegExp(r"[''']"), "'") // Normalize apostrophes
.replaceAll(RegExp(r'[–—]'), '-') // Normalize dashes
.replaceAll(RegExp(r'[…]'), '...') // Normalize ellipsis
// Remove extra spaces and normalize
.replaceAll(RegExp(r'\s+'), ' ')
.trim();
// Convert to uppercase for MRZ format
cleaned = cleaned.toUpperCase();
// Remove any remaining non-MRZ characters (keep only A-Z, 0-9, <, and space)
cleaned = cleaned.replaceAll(RegExp(r'[^A-Z0-9<\s]'), '');
// Remove extra spaces again
cleaned = cleaned.replaceAll(RegExp(r'\s+'), ' ').trim();
// If the result is too short or contains too many spaces, it's probably not MRZ
if (cleaned.length < 10 || cleaned.split(' ').length > 5) {
return null;
}
return cleaned;
}
void _showMrzResults(MRZData mrzData) {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => MRZResultPage(
mrzData: mrzData,
bearerToken: widget.bearerToken,
),
),
);
}
void _showSnackBar(String message, {Color? backgroundColor}) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
backgroundColor: backgroundColor,
),
);
}
Widget _buildScanner() {
if (!_cameraGranted) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.camera_alt, size: 80, color: Colors.grey.shade400),
SizedBox(height: 24),
Text(
'Permission caméra requise',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: Colors.grey.shade600,
),
),
SizedBox(height: 8),
Text(
'Pour scanner le MRZ des documents',
style: TextStyle(
fontSize: 14,
color: Colors.grey.shade500,
),
),
SizedBox(height: 24),
ElevatedButton(
onPressed: _checkCameraPermission,
style: ElevatedButton.styleFrom(
backgroundColor: Colors.blue,
foregroundColor: Colors.white,
padding: EdgeInsets.symmetric(horizontal: 24, vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
child: Text('Accorder Permission'),
),
],
),
);
}
return Column(
children: [
// Instructions
Container(
width: double.infinity,
padding: EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.blue.shade50,
border: Border.all(color: Colors.blue.shade200),
),
child: Column(
children: [
Icon(Icons.info_outline, color: Colors.blue.shade700, size: 20),
SizedBox(height: 8),
Text(
'Positionnez la zone MRZ du document dans le cadre',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Colors.blue.shade700,
),
textAlign: TextAlign.center,
),
SizedBox(height: 4),
Text(
'Les lignes de texte en bas du passeport ou carte d\'identité',
style: TextStyle(
fontSize: 12,
color: Colors.blue.shade600,
),
textAlign: TextAlign.center,
),
],
),
),
// Scanner
Expanded(
child: Stack(
children: [
_cameraController != null && _cameraController!.value.isInitialized
? ClipRect(
child: Transform.scale(
scale: 1.0,
child: Center(
child: CameraPreview(_cameraController!),
),
),
)
: Container(
color: Colors.black,
child: Center(
child: CircularProgressIndicator(color: Colors.white),
),
),
],
),
),
// Capture Button Area
Container(
padding: const EdgeInsets.all(24),
color: Colors.black.withOpacity(0.8),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'Positionnez la zone MRZ dans le cadre et appuyez pour capturer',
style: TextStyle(
fontSize: 14,
color: Colors.white,
fontWeight: FontWeight.w500,
),
textAlign: TextAlign.center,
),
SizedBox(height: 20),
GestureDetector(
onTap: _isProcessing ? null : _captureAndAnalyze,
child: Container(
width: 80,
height: 80,
decoration: BoxDecoration(
color: _isProcessing ? Colors.grey.shade400 : Colors.white,
shape: BoxShape.circle,
border: Border.all(
color: _isProcessing ? Colors.grey.shade600 : Colors.blue,
width: 4,
),
),
child: _isProcessing
? Padding(
padding: EdgeInsets.all(20),
child: CircularProgressIndicator(
strokeWidth: 3,
valueColor: AlwaysStoppedAnimation<Color>(Colors.grey.shade600),
),
)
: Icon(
Icons.camera_alt,
size: 36,
color: Colors.blue,
),
),
),
if (_lastMrz != null) ...[
SizedBox(height: 16),
Container(
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: Colors.green.withOpacity(0.2),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: Colors.green, width: 1),
),
child: Text(
'Document détecté: ${_lastMrz!.documentType} - ${_lastMrz!.lastName}',
style: TextStyle(
fontSize: 12,
color: Colors.green.shade100,
fontWeight: FontWeight.w600,
),
textAlign: TextAlign.center,
),
),
],
],
),
),
],
);
}
Widget _buildDebugPanel() {
return Container(
width: double.infinity,
padding: EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.red.shade50,
border: Border(
bottom: BorderSide(color: Colors.red.shade200, width: 2),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.bug_report, color: Colors.red, size: 20),
SizedBox(width: 8),
Text(
'Debug Information',
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
color: Colors.red.shade700,
),
),
],
),
SizedBox(height: 12),
if (_lastRecognizedText.isNotEmpty) ...[
Text(
'Texte reconnu brut:',
style: TextStyle(fontWeight: FontWeight.w600, fontSize: 14),
),
SizedBox(height: 4),
Container(
height: 60,
padding: EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.grey.shade100,
borderRadius: BorderRadius.circular(4),
border: Border.all(color: Colors.grey.shade300),
),
child: SingleChildScrollView(
child: Text(
_lastRecognizedText,
style: TextStyle(fontSize: 10, fontFamily: 'monospace'),
),
),
),
SizedBox(height: 12),
],
if (_debugInfo.isNotEmpty) ...[
Text(
'Dernière erreur:',
style: TextStyle(fontWeight: FontWeight.w600, fontSize: 14),
),
SizedBox(height: 4),
Text(
_debugInfo,
style: TextStyle(fontSize: 12, color: Colors.red.shade700),
),
SizedBox(height: 12),
],
if (_detectedLines.isNotEmpty) ...[
Text(
'Lignes détectées (${_detectedLines.length}):',
style: TextStyle(fontWeight: FontWeight.w600, fontSize: 14),
),
SizedBox(height: 4),
Container(
height: 80,
child: ListView.builder(
itemCount: _detectedLines.length,
itemBuilder: (context, index) {
final line = _detectedLines[index];
final isValidLength = line.length >= 30;
final isValidChars = RegExp(r'^[A-Z0-9<]+$').hasMatch(line);
final isMrzCandidate = _mrzCandidateLines.contains(line);
return Container(
margin: EdgeInsets.only(bottom: 2),
padding: EdgeInsets.all(4),
decoration: BoxDecoration(
color: isMrzCandidate ? Colors.green.shade100 : Colors.grey.shade100,
borderRadius: BorderRadius.circular(4),
border: Border.all(
color: isMrzCandidate ? Colors.green.shade300 : Colors.grey.shade300,
width: 1,
),
),
child: Row(
children: [
Text(
'$index:',
style: TextStyle(fontSize: 10, fontWeight: FontWeight.bold),
),
SizedBox(width: 4),
Expanded(
child: Text(
line,
style: TextStyle(
fontSize: 10,
fontFamily: 'monospace',
color: isMrzCandidate ? Colors.green.shade800 : Colors.black87,
),
overflow: TextOverflow.ellipsis,
),
),
if (!isValidLength)
Icon(Icons.short_text, size: 12, color: Colors.orange),
if (!isValidChars && isValidLength)
Icon(Icons.text_format, size: 12, color: Colors.red),
if (isMrzCandidate)
Icon(Icons.check_circle, size: 12, color: Colors.green),
],
),
);
},
),
),
SizedBox(height: 12),
],
if (_mrzCandidateLines.isNotEmpty) ...[
Text(
'Lignes MRZ candidates (${_mrzCandidateLines.length}):',
style: TextStyle(fontWeight: FontWeight.w600, fontSize: 14, color: Colors.green.shade700),
),
SizedBox(height: 4),
for (int i = 0; i < _mrzCandidateLines.length; i++)
Text(
'MRZ $i: ${_mrzCandidateLines[i]}',
style: TextStyle(fontSize: 10, fontFamily: 'monospace', color: Colors.green.shade800),
),
],
],
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.white,
appBar: AppBar(
title: Text('Scanner MRZ'),
backgroundColor: Colors.transparent,
elevation: 0,
leading: IconButton(
onPressed: () => Navigator.of(context).pop(),
icon: Icon(Icons.arrow_back, color: Colors.black87),
),
actions: [
IconButton(
onPressed: () {
setState(() {
_showDebugInfo = !_showDebugInfo;
});
},
icon: Icon(
_showDebugInfo ? Icons.bug_report : Icons.bug_report_outlined,
color: _showDebugInfo ? Colors.red : Colors.grey,
),
tooltip: 'Toggle Debug Info',
),
],
),
body: Column(
children: [
if (_showDebugInfo) _buildDebugPanel(),
Expanded(child: _buildScanner()),
],
),
);
}
}
// MRZ Result Page
class MRZResultPage extends StatelessWidget {
final MRZData mrzData;
final String bearerToken;
const MRZResultPage({
super.key,
required this.mrzData,
required this.bearerToken,
});
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.white,
body: SafeArea(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
children: [
// Header
Row(
children: [
IconButton(
onPressed: () => Navigator.of(context).pop(),
icon: Icon(Icons.arrow_back, color: Colors.black87),
),
SizedBox(width: 16),
Text(
'Données MRZ Extraites',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Colors.black87,
),
),
],
),
SizedBox(height: 32),
// Success icon
Container(
width: 80,
height: 80,
decoration: BoxDecoration(
color: Colors.green.shade50,
shape: BoxShape.circle,
border: Border.all(color: Colors.green.shade200, width: 2),
),
child: Icon(
Icons.check_circle,
size: 40,
color: Colors.green,
),
),
SizedBox(height: 24),
Text(
'Scan MRZ Réussi',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: Colors.green,
),
textAlign: TextAlign.center,
),
SizedBox(height: 32),
// Data display
Expanded(
child: SingleChildScrollView(
child: Column(
children: [
_buildDataCard('Informations du Document', [
_buildDataRow('Type de document', mrzData.documentType),
_buildDataRow('Pays émetteur', mrzData.issuingCountry),
_buildDataRow('Numéro de document', mrzData.documentNumber),
]),
SizedBox(height: 16),
_buildDataCard('Informations Personnelles', [
_buildDataRow('Nom', mrzData.lastName),
_buildDataRow('Prénom(s)', mrzData.firstName),
_buildDataRow('Nationalité', mrzData.nationality),
_buildDataRow('Sexe', mrzData.sex),
_buildDataRow('Date de naissance', _formatDate(mrzData.birthDate)),
_buildDataRow('Date d\'expiration', _formatDate(mrzData.expiryDate)),
]),
if (mrzData.personalNumber.isNotEmpty) ...[
SizedBox(height: 16),
_buildDataCard('Informations Supplémentaires', [
_buildDataRow('Numéro personnel', mrzData.personalNumber),
]),
],
],
),
),
),
SizedBox(height: 24),
// Action buttons
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: () {
// Navigate back to main page
Navigator.of(context).popUntil((route) => route.isFirst);
},
style: ElevatedButton.styleFrom(
backgroundColor: Colors.blue,
foregroundColor: Colors.white,
padding: EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: Text(
'Retour à l\'accueil',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w800,
),
),
),
),
],
),
),
),
);
}
Widget _buildDataCard(String title, List<Widget> children) {
return Container(
width: double.infinity,
padding: EdgeInsets.all(20),
decoration: BoxDecoration(
color: Colors.grey.shade50,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.grey.shade200),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Colors.black87,
),
),
SizedBox(height: 16),
...children,
],
),
);
}
Widget _buildDataRow(String label, String value) {
return Padding(
padding: const EdgeInsets.only(bottom: 12.0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 120,
child: Text(
'$label:',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: Colors.grey.shade700,
),
),
),
Expanded(
child: Text(
value.isNotEmpty ? value : 'Non disponible',
style: TextStyle(
fontSize: 14,
color: value.isNotEmpty ? Colors.black87 : Colors.grey.shade500,
fontWeight: value.isNotEmpty ? FontWeight.w500 : FontWeight.normal,
),
),
),
],
),
);
}
String _formatDate(String dateStr) {
if (dateStr.length == 6) {
// Format YYMMDD to DD/MM/20YY
try {
final year = int.parse(dateStr.substring(0, 2));
final month = dateStr.substring(2, 4);
final day = dateStr.substring(4, 6);
final fullYear = year + (year < 30 ? 2000 : 1900); // Assumption for century
return '$day/$month/$fullYear';
} catch (e) {
return dateStr;
}
}
return dateStr;
}
}