235 lines
6.2 KiB
Dart
235 lines
6.2 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
|
|
import '../../providers/verification_session_provider.dart';
|
|
import '../../services/face_verification.dart' as face;
|
|
|
|
/// Screen opened on mobile when the user scans a QR code for cross-device
|
|
/// face verification. Performs liveness detection and uploads the result.
|
|
class MobileVerificationScreen extends ConsumerStatefulWidget {
|
|
const MobileVerificationScreen({super.key, required this.sessionId});
|
|
|
|
final String sessionId;
|
|
|
|
@override
|
|
ConsumerState<MobileVerificationScreen> createState() =>
|
|
_MobileVerificationScreenState();
|
|
}
|
|
|
|
class _MobileVerificationScreenState
|
|
extends ConsumerState<MobileVerificationScreen> {
|
|
bool _loading = true;
|
|
bool _verifying = false;
|
|
bool _done = false;
|
|
String? _error;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_loadSession();
|
|
}
|
|
|
|
Future<void> _loadSession() async {
|
|
try {
|
|
final controller = ref.read(verificationSessionControllerProvider);
|
|
final session = await controller.getSession(widget.sessionId);
|
|
if (session == null) {
|
|
setState(() {
|
|
_error = 'Verification session not found.';
|
|
_loading = false;
|
|
});
|
|
return;
|
|
}
|
|
if (session.isExpired) {
|
|
setState(() {
|
|
_error = 'This verification session has expired.';
|
|
_loading = false;
|
|
});
|
|
return;
|
|
}
|
|
if (session.isCompleted) {
|
|
setState(() {
|
|
_error = 'This session has already been completed.';
|
|
_loading = false;
|
|
});
|
|
return;
|
|
}
|
|
setState(() => _loading = false);
|
|
|
|
// Automatically start liveness detection
|
|
_startLiveness();
|
|
} catch (e) {
|
|
if (mounted) {
|
|
setState(() {
|
|
_error = 'Failed to load session: $e';
|
|
_loading = false;
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> _startLiveness() async {
|
|
if (_verifying) return;
|
|
setState(() {
|
|
_verifying = true;
|
|
_error = null;
|
|
});
|
|
|
|
try {
|
|
final result = await face.runFaceLiveness(context);
|
|
|
|
if (result == null) {
|
|
if (mounted) {
|
|
setState(() {
|
|
_error = 'Liveness check cancelled.';
|
|
_verifying = false;
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Upload the photo and complete the session
|
|
final controller = ref.read(verificationSessionControllerProvider);
|
|
await controller.completeSession(
|
|
sessionId: widget.sessionId,
|
|
bytes: result.imageBytes,
|
|
fileName: 'verify.jpg',
|
|
);
|
|
|
|
if (mounted) {
|
|
setState(() {
|
|
_done = true;
|
|
_verifying = false;
|
|
});
|
|
}
|
|
} catch (e) {
|
|
if (mounted) {
|
|
setState(() {
|
|
_error = 'Verification failed: $e';
|
|
_verifying = false;
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
final colors = theme.colorScheme;
|
|
|
|
return Scaffold(
|
|
appBar: AppBar(title: const Text('Face Verification')),
|
|
body: Center(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(24),
|
|
child: _buildContent(theme, colors),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildContent(ThemeData theme, ColorScheme colors) {
|
|
if (_loading) {
|
|
return const Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
CircularProgressIndicator(),
|
|
SizedBox(height: 16),
|
|
Text('Loading verification session...'),
|
|
],
|
|
);
|
|
}
|
|
|
|
if (_done) {
|
|
return Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(Icons.check_circle, size: 64, color: Colors.green),
|
|
const SizedBox(height: 16),
|
|
Text('Verification Complete', style: theme.textTheme.headlineSmall),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
'You can close this screen and return to the web app.',
|
|
style: theme.textTheme.bodyMedium?.copyWith(
|
|
color: colors.onSurfaceVariant,
|
|
),
|
|
textAlign: TextAlign.center,
|
|
),
|
|
const SizedBox(height: 24),
|
|
FilledButton(
|
|
onPressed: () => Navigator.of(context).maybePop(),
|
|
child: const Text('Close'),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
if (_error != null) {
|
|
return Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(Icons.error_outline, size: 64, color: colors.error),
|
|
const SizedBox(height: 16),
|
|
Text(
|
|
'Verification Error',
|
|
style: theme.textTheme.headlineSmall?.copyWith(color: colors.error),
|
|
),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
_error!,
|
|
style: theme.textTheme.bodyMedium?.copyWith(
|
|
color: colors.onSurfaceVariant,
|
|
),
|
|
textAlign: TextAlign.center,
|
|
),
|
|
const SizedBox(height: 24),
|
|
FilledButton(
|
|
onPressed: _startLiveness,
|
|
child: const Text('Try Again'),
|
|
),
|
|
const SizedBox(height: 12),
|
|
TextButton(
|
|
onPressed: () => Navigator.of(context).maybePop(),
|
|
child: const Text('Cancel'),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
if (_verifying) {
|
|
return const Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
CircularProgressIndicator(),
|
|
SizedBox(height: 16),
|
|
Text('Processing verification...'),
|
|
],
|
|
);
|
|
}
|
|
|
|
// Fallback: prompt to start
|
|
return Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(Icons.face, size: 64, color: colors.primary),
|
|
const SizedBox(height: 16),
|
|
Text('Ready to Verify', style: theme.textTheme.headlineSmall),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
'Tap the button below to start face liveness detection.',
|
|
style: theme.textTheme.bodyMedium?.copyWith(
|
|
color: colors.onSurfaceVariant,
|
|
),
|
|
textAlign: TextAlign.center,
|
|
),
|
|
const SizedBox(height: 24),
|
|
FilledButton.icon(
|
|
onPressed: _startLiveness,
|
|
icon: const Icon(Icons.face),
|
|
label: const Text('Start Verification'),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|