import 'dart:math'; import 'dart:typed_data'; import 'package:flutter/foundation.dart' show kIsWeb; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../providers/attendance_provider.dart'; import '../providers/profile_provider.dart'; import '../services/face_verification.dart' as face; import '../theme/m3_motion.dart'; import '../widgets/qr_verification_dialog.dart'; /// Phases of the full-screen face verification overlay. enum _Phase { liveness, downloading, matching, success, failed, cancelled } /// Result returned from the overlay. class FaceVerificationResult { final bool verified; final double? matchScore; FaceVerificationResult({required this.verified, this.matchScore}); } /// Shows a full-screen animated face verification overlay with up to /// [maxAttempts] retries. Returns whether the user was verified. Future showFaceVerificationOverlay({ required BuildContext context, required WidgetRef ref, required String attendanceLogId, int maxAttempts = 3, }) { return Navigator.of(context).push( PageRouteBuilder( opaque: true, pageBuilder: (ctx, anim, secAnim) => _FaceVerificationOverlay( attendanceLogId: attendanceLogId, maxAttempts: maxAttempts, ), transitionsBuilder: (ctx, anim, secAnim, child) { return FadeTransition(opacity: anim, child: child); }, transitionDuration: M3Motion.standard, reverseTransitionDuration: M3Motion.short, ), ); } class _FaceVerificationOverlay extends ConsumerStatefulWidget { const _FaceVerificationOverlay({ required this.attendanceLogId, required this.maxAttempts, }); final String attendanceLogId; final int maxAttempts; @override ConsumerState<_FaceVerificationOverlay> createState() => _FaceVerificationOverlayState(); } class _FaceVerificationOverlayState extends ConsumerState<_FaceVerificationOverlay> with TickerProviderStateMixin { _Phase _phase = _Phase.liveness; int _attempt = 1; String _statusText = 'Preparing liveness check...'; late AnimationController _scanCtrl; late AnimationController _pulseCtrl; late Animation _pulseAnim; @override void initState() { super.initState(); _scanCtrl = AnimationController( vsync: this, duration: const Duration(seconds: 2), )..repeat(); _pulseCtrl = AnimationController( vsync: this, duration: const Duration(milliseconds: 1200), )..repeat(reverse: true); _pulseAnim = Tween( begin: 0.85, end: 1.0, ).animate(CurvedAnimation(parent: _pulseCtrl, curve: Curves.easeInOut)); WidgetsBinding.instance.addPostFrameCallback((_) => _runAttempt()); } @override void dispose() { _scanCtrl.dispose(); _pulseCtrl.dispose(); super.dispose(); } Future _runAttempt() async { if (!mounted) return; setState(() { _phase = _Phase.liveness; _statusText = 'Attempt $_attempt of ${widget.maxAttempts}\nPerforming liveness check...'; }); // 1. Liveness check final result = await face.runFaceLiveness(context); if (result == null) { if (!mounted) return; // Cancelled: on web offer QR, otherwise mark cancelled if (kIsWeb) { final completed = await showQrVerificationDialog( context: context, ref: ref, type: 'verification', contextId: widget.attendanceLogId, ); if (mounted) { Navigator.of( context, ).pop(FaceVerificationResult(verified: completed)); } } else { setState(() { _phase = _Phase.cancelled; _statusText = 'Verification cancelled.'; }); await Future.delayed(const Duration(milliseconds: 800)); if (mounted) { Navigator.of(context).pop(FaceVerificationResult(verified: false)); } } return; } // 2. Download enrolled photo if (!mounted) return; setState(() { _phase = _Phase.downloading; _statusText = 'Fetching enrolled face data...'; }); final profile = ref.read(currentProfileProvider).valueOrNull; if (profile == null || !profile.hasFaceEnrolled) { _skipVerification('No enrolled face found.'); return; } final enrolledBytes = await ref .read(profileControllerProvider) .downloadFacePhoto(profile.id); if (enrolledBytes == null) { _skipVerification('Could not download enrolled face.'); return; } // 3. Face matching if (!mounted) return; setState(() { _phase = _Phase.matching; _statusText = 'Analyzing face match...'; }); final score = await face.compareFaces(result.imageBytes, enrolledBytes); if (score >= 0.60) { // Success! if (!mounted) return; setState(() { _phase = _Phase.success; _statusText = 'Face verified!\n${(score * 100).toStringAsFixed(0)}% match'; }); await _uploadResult(result.imageBytes, 'verified'); await Future.delayed(const Duration(milliseconds: 1200)); if (mounted) { Navigator.of( context, ).pop(FaceVerificationResult(verified: true, matchScore: score)); } } else { // Failed attempt if (_attempt < widget.maxAttempts) { if (!mounted) return; setState(() { _phase = _Phase.failed; _statusText = 'No match (${(score * 100).toStringAsFixed(0)}%)\n' 'Attempt $_attempt of ${widget.maxAttempts}\n' 'Retrying...'; }); await Future.delayed(const Duration(milliseconds: 1500)); _attempt++; _runAttempt(); } else { // All attempts exhausted if (!mounted) return; setState(() { _phase = _Phase.failed; _statusText = 'Face did not match after ${widget.maxAttempts} attempts\n' '${(score * 100).toStringAsFixed(0)}% similarity'; }); await _uploadResult(result.imageBytes, 'unverified'); await Future.delayed(const Duration(milliseconds: 1500)); if (mounted) { Navigator.of( context, ).pop(FaceVerificationResult(verified: false, matchScore: score)); } } } } Future _uploadResult(Uint8List bytes, String status) async { try { await ref .read(attendanceControllerProvider) .uploadVerification( attendanceId: widget.attendanceLogId, bytes: bytes, fileName: 'verification.jpg', status: status, ); } catch (_) {} } Future _skipVerification(String reason) async { try { await ref .read(attendanceControllerProvider) .skipVerification(widget.attendanceLogId); } catch (_) {} if (!mounted) return; setState(() { _phase = _Phase.cancelled; _statusText = reason; }); await Future.delayed(const Duration(milliseconds: 1200)); if (mounted) { Navigator.of(context).pop(FaceVerificationResult(verified: false)); } } @override Widget build(BuildContext context) { final colors = Theme.of(context).colorScheme; final textTheme = Theme.of(context).textTheme; return Scaffold( backgroundColor: colors.surface, body: SafeArea( child: Center( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 32), child: Column( mainAxisSize: MainAxisSize.min, children: [ _buildFaceIndicator(colors), const SizedBox(height: 32), AnimatedSwitcher( duration: M3Motion.short, child: Text( _statusText, key: ValueKey('$_phase-$_attempt'), textAlign: TextAlign.center, style: textTheme.titleMedium?.copyWith( color: _phase == _Phase.success ? Colors.green : _phase == _Phase.failed ? colors.error : colors.onSurface, fontWeight: FontWeight.w600, ), ), ), const SizedBox(height: 16), if (_phase != _Phase.success && _phase != _Phase.cancelled) _buildProgressIndicator(colors), const SizedBox(height: 24), _buildAttemptDots(colors), ], ), ), ), ), ); } /// Animated face icon with scanning line and pulse. Widget _buildFaceIndicator(ColorScheme colors) { final isActive = _phase == _Phase.liveness || _phase == _Phase.downloading || _phase == _Phase.matching; final isSuccess = _phase == _Phase.success; final isFailed = _phase == _Phase.failed; final Color ringColor; if (isSuccess) { ringColor = Colors.green; } else if (isFailed) { ringColor = colors.error; } else { ringColor = colors.primary; } return SizedBox( width: 200, height: 200, child: Stack( alignment: Alignment.center, children: [ // Pulsing ring ScaleTransition( scale: isActive ? _pulseAnim : const AlwaysStoppedAnimation(1.0), child: Container( width: 180, height: 180, decoration: BoxDecoration( shape: BoxShape.circle, border: Border.all( color: ringColor.withValues(alpha: 0.3), width: 3, ), ), ), ), // Inner circle with icon AnimatedContainer( duration: M3Motion.standard, curve: M3Motion.standard_, width: 140, height: 140, decoration: BoxDecoration( shape: BoxShape.circle, color: ringColor.withValues(alpha: 0.08), border: Border.all(color: ringColor, width: 2), ), child: AnimatedSwitcher( duration: M3Motion.short, child: Icon( isSuccess ? Icons.check_circle_rounded : isFailed ? Icons.error_rounded : Icons.face_rounded, key: ValueKey(_phase), size: 64, color: ringColor, ), ), ), // Scanning line (only during active phases) if (isActive) AnimatedBuilder( animation: _scanCtrl, builder: (context, child) { final yOffset = sin(_scanCtrl.value * 2 * pi) * 60; return Transform.translate( offset: Offset(0, yOffset), child: Container( width: 120, height: 2, decoration: BoxDecoration( gradient: LinearGradient( colors: [ colors.primary.withValues(alpha: 0.0), colors.primary.withValues(alpha: 0.6), colors.primary.withValues(alpha: 0.0), ], ), ), ), ); }, ), ], ), ); } Widget _buildProgressIndicator(ColorScheme colors) { if (_phase == _Phase.failed) { return LinearProgressIndicator( value: null, backgroundColor: colors.error.withValues(alpha: 0.12), color: colors.error, borderRadius: BorderRadius.circular(4), ); } return LinearProgressIndicator( value: null, backgroundColor: colors.primary.withValues(alpha: 0.12), borderRadius: BorderRadius.circular(4), ); } /// Dots showing attempt progress. Widget _buildAttemptDots(ColorScheme colors) { return Row( mainAxisAlignment: MainAxisAlignment.center, children: List.generate(widget.maxAttempts, (i) { final attemptNum = i + 1; final bool isDone = attemptNum < _attempt; final bool isCurrent = attemptNum == _attempt; final bool isSuccess_ = isCurrent && _phase == _Phase.success; Color dotColor; if (isSuccess_) { dotColor = Colors.green; } else if (isDone) { dotColor = colors.error.withValues(alpha: 0.5); } else if (isCurrent) { dotColor = colors.primary; } else { dotColor = colors.outlineVariant; } return AnimatedContainer( duration: M3Motion.short, curve: M3Motion.standard_, margin: const EdgeInsets.symmetric(horizontal: 4), width: isCurrent ? 12 : 8, height: isCurrent ? 12 : 8, decoration: BoxDecoration(shape: BoxShape.circle, color: dotColor), ); }), ); } }