import 'dart:io'; import 'dart:ui'; import 'package:camera/camera.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:google_mlkit_face_detection/google_mlkit_face_detection.dart'; import '../../core/theme.dart'; import '../../core/utils/camera_utils.dart'; import '../../repositories/face_repository.dart'; import '../../widgets/atoms/custom_button.dart'; import '../../widgets/atoms/scanner_overlay.dart'; class FaceTestScreen extends StatefulWidget { const FaceTestScreen({super.key}); @override State createState() => _FaceTestScreenState(); } class _FaceTestScreenState extends State { CameraController? _cameraController; CameraDescription? _frontCamera; bool _isCameraInitialized = false; bool _isVerifying = false; bool _showResult = false; bool _isProcessingFrame = false; // Quality gate bool _isFaceDetected = false; bool _isDistanceOk = false; bool _isBrightnessOk = false; bool _isLightingUniform = false; bool _isPoseValid = false; double _faceRatio = 0.0; double _brightness = 0.0; double _lightingUniformity = 0.0; double _headEulerAngleY = 0.0; bool? _verified; double? _confidence; double? _svmDf; int? _framesTotal; int? _framesApproved; String? _statusText; String? _errorMessage; String? _predictedName; late FaceDetector _faceDetector; bool get _allChecksValid => _isFaceDetected && _isDistanceOk && _isBrightnessOk && _isLightingUniform && _isPoseValid; // State recording bool _isRecording = false; int _recordingSecondsLeft = 1; String get _qualityMessage { if (_isRecording) return 'Tetap hadap kamera... ($_recordingSecondsLeft detik)'; if (!_isFaceDetected) return 'Arahkan wajah ke dalam bingkai'; if (_faceRatio < 0.25) return 'Terlalu jauh, dekatkan wajah'; if (_faceRatio > 0.75) return 'Terlalu dekat, mundur sedikit'; if (!_isBrightnessOk) return 'Pencahayaan kurang terang'; if (!_isLightingUniform) return 'Ada bayangan, hadap cahaya'; if (!_isPoseValid) return 'Hadap lurus ke kamera'; return 'Siap — mulai verifikasi'; } Color get _resultColor { final s = _statusText; if (s == 'APPROVED' || s == 'MATCH') return AppTheme.statusGreen; if (s == 'PENDING' || s == 'MISMATCH') return AppTheme.statusYellow; return AppTheme.statusRed; } @override void initState() { super.initState(); _faceDetector = FaceDetector( options: FaceDetectorOptions( enableClassification: false, enableLandmarks: true, enableContours: false, enableTracking: true, performanceMode: FaceDetectorMode.accurate, ), ); _initializeCamera(); } Future _initializeCamera() async { try { final cameras = await availableCameras(); _frontCamera = cameras.firstWhere( (camera) => camera.lensDirection == CameraLensDirection.front, orElse: () => cameras.first, ); _cameraController = CameraController( _frontCamera!, ResolutionPreset.medium, enableAudio: false, imageFormatGroup: Platform.isAndroid ? ImageFormatGroup.nv21 : ImageFormatGroup.bgra8888, ); await _cameraController!.initialize(); if (!mounted) return; _startQualityStream(); setState(() { _isCameraInitialized = true; }); } catch (e) { debugPrint("Error initializing camera: $e"); } } void _startQualityStream() { _cameraController!.startImageStream((CameraImage image) { if (!mounted || _showResult || _isVerifying) return; _processFrame(image); }); } void _processFrame(CameraImage image) async { if (_isProcessingFrame) return; _isProcessingFrame = true; try { _brightness = BrightnessUtils.calculateBrightness(image, centerOnly: true); _isBrightnessOk = _brightness > 70; _lightingUniformity = BrightnessUtils.calculateFaceUniformity(image); _isLightingUniform = _lightingUniformity > 0.45; final inputImage = CameraUtils.inputImageFromCameraImage( image: image, camera: _frontCamera!, rotation: CameraUtils.rotationIntToImageRotation( _frontCamera!.sensorOrientation), ); final faces = await _faceDetector.processImage(inputImage); if (faces.isNotEmpty) { final face = faces.first; _headEulerAngleY = face.headEulerAngleY ?? 0.0; _isPoseValid = _headEulerAngleY.abs() < 18.0; final rotation = CameraUtils.rotationIntToImageRotation(_frontCamera!.sensorOrientation); final bool isRotated90or270 = rotation == InputImageRotation.rotation90deg || rotation == InputImageRotation.rotation270deg; final double effectiveWidth = isRotated90or270 ? image.height.toDouble() : image.width.toDouble(); _faceRatio = face.boundingBox.width / effectiveWidth; _isFaceDetected = true; _isDistanceOk = _faceRatio >= 0.25 && _faceRatio <= 0.75; } else { _isFaceDetected = false; _isDistanceOk = false; _isPoseValid = false; _faceRatio = 0.0; } if (mounted) setState(() {}); } catch (e) { debugPrint("Error processing frame: $e"); } finally { _isProcessingFrame = false; } } Future _recordAndVerify() async { if (_cameraController == null || _isVerifying || !_allChecksValid) return; setState(() { _isRecording = true; _recordingSecondsLeft = 1; _showResult = false; _errorMessage = null; }); try { await _cameraController!.stopImageStream(); } catch (_) {} try { await _cameraController!.prepareForVideoRecording(); await _cameraController!.startVideoRecording(); for (int i = 1; i >= 1; i--) { if (!mounted) return; setState(() => _recordingSecondsLeft = i); await Future.delayed(const Duration(seconds: 1)); } if (!mounted) return; final XFile video = await _cameraController!.stopVideoRecording(); final File videoFile = File(video.path); setState(() { _isRecording = false; _isVerifying = true; }); final result = await FaceRepository().verifyFace(videoFile, tipe: 'test'); if (mounted) { setState(() { _verified = result['verified'] == true; final approvedRatio = (result['approved_ratio'] is num) ? (result['approved_ratio'] as num).toDouble() : null; final rawConf = (result['confidence'] is num) ? (result['confidence'] as num).toDouble() : null; _confidence = approvedRatio ?? rawConf; _svmDf = (result['svm_df'] is num) ? (result['svm_df'] as num).toDouble() : null; _framesTotal = (result['frames_total'] as num?)?.toInt(); _framesApproved = (result['frames_approved'] as num?)?.toInt(); _statusText = result['verification_status']?.toString() ?? result['status']?.toString(); _predictedName = result['predicted_name']?.toString(); _showResult = true; _isVerifying = false; }); if (_verified == true) { HapticFeedback.lightImpact(); } else { HapticFeedback.heavyImpact(); } } } catch (e) { if (mounted) { setState(() { _verified = false; _isRecording = false; _errorMessage = e.toString().replaceAll('Exception: ', ''); _showResult = true; _isVerifying = false; }); HapticFeedback.heavyImpact(); } } } void _resetTest() { setState(() { _showResult = false; _verified = null; _confidence = null; _svmDf = null; _framesTotal = null; _framesApproved = null; _statusText = null; _errorMessage = null; _predictedName = null; _isRecording = false; _isFaceDetected = false; _isDistanceOk = false; _isBrightnessOk = false; _isLightingUniform = false; _isPoseValid = false; }); _startQualityStream(); } @override void dispose() { _cameraController?.dispose(); _faceDetector.close(); super.dispose(); } @override Widget build(BuildContext context) { return Scaffold( backgroundColor: Colors.black, extendBodyBehindAppBar: true, appBar: AppBar( backgroundColor: Colors.transparent, elevation: 0, leading: IconButton( icon: Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( color: Colors.black.withOpacity(0.5), shape: BoxShape.circle, ), child: const Icon(Icons.arrow_back_ios_new, color: Colors.white, size: 16), ), onPressed: () => Navigator.pop(context), ), ), body: Stack( children: [ if (_isCameraInitialized) SizedBox.expand( child: FittedBox( fit: BoxFit.cover, child: SizedBox( width: _cameraController!.value.previewSize?.height ?? MediaQuery.of(context).size.width, height: _cameraController!.value.previewSize?.width ?? MediaQuery.of(context).size.height, child: CameraPreview(_cameraController!), ), ), ) else const Center( child: CircularProgressIndicator(color: Colors.white), ), ScannerOverlay( borderColor: _showResult ? _resultColor : _allChecksValid ? AppTheme.statusGreen : _isFaceDetected ? AppTheme.statusYellow : AppTheme.primaryBlue, isScanning: _isVerifying || (!_showResult && !_isVerifying), ), Positioned( top: MediaQuery.of(context).padding.top + kToolbarHeight + 20, left: 20, right: 20, child: Column( children: [ Text( "Test Pengenalan Wajah", style: AppTheme.heading3.copyWith(color: Colors.white), ), const SizedBox(height: 10), ClipRRect( borderRadius: BorderRadius.circular(20), child: BackdropFilter( filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10), child: Container( padding: const EdgeInsets.symmetric( horizontal: 20, vertical: 10), decoration: BoxDecoration( color: Colors.white.withOpacity(0.15), borderRadius: BorderRadius.circular(20), border: Border.all( color: Colors.white.withOpacity(0.3)), ), child: Text( _isVerifying ? "Memverifikasi wajah..." : _showResult ? (_statusText == 'MATCH' ? "Wajah Terverifikasi ✓" : _statusText == 'MISMATCH' ? "Verifikasi Meragukan ⚠️" : "Wajah Tidak Cocok ✗") : _qualityMessage, textAlign: TextAlign.center, style: AppTheme.bodyLarge.copyWith( color: _showResult ? _resultColor : _allChecksValid ? AppTheme.statusGreen : Colors.white, fontWeight: FontWeight.bold, ), ), ), ), ), // Quality chips if (!_showResult && !_isVerifying) ...[ const SizedBox(height: 10), Wrap( spacing: 6, runSpacing: 6, alignment: WrapAlignment.center, children: [ _buildChip(Icons.face, 'Wajah', _isFaceDetected), _buildChip(Icons.straighten, 'Jarak', _isDistanceOk), _buildChip(Icons.wb_sunny_outlined, 'Cahaya', _isBrightnessOk), _buildChip(Icons.blur_on, 'Bayangan', _isLightingUniform), _buildChip(Icons.face, 'Hadap Depan', _isPoseValid), ], ), ], ], ), ), if (_showResult) Positioned( bottom: 120, left: 20, right: 20, child: AnimatedOpacity( opacity: _showResult ? 1.0 : 0.0, duration: const Duration(milliseconds: 300), child: ClipRRect( borderRadius: BorderRadius.circular(AppTheme.radiusLg), child: BackdropFilter( filter: ImageFilter.blur(sigmaX: 15, sigmaY: 15), child: Container( padding: const EdgeInsets.all(AppTheme.spacingLg), decoration: BoxDecoration( color: Colors.white.withOpacity(0.9), borderRadius: BorderRadius.circular(AppTheme.radiusLg), border: Border.all( color: Colors.white.withOpacity(0.4)), boxShadow: [ BoxShadow( color: Colors.black.withOpacity(0.1), blurRadius: 20, offset: const Offset(0, 10), ) ], ), child: Column( mainAxisSize: MainAxisSize.min, children: [ Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: _resultColor.withOpacity(0.1), shape: BoxShape.circle, ), child: Icon( (_statusText == 'APPROVED' || _statusText == 'MATCH') ? Icons.check_circle : (_statusText == 'PENDING' || _statusText == 'MISMATCH') ? Icons.warning_amber_rounded : Icons.cancel, color: _resultColor, size: 40, ), ), const SizedBox(height: 12), Text( (_statusText == 'APPROVED' || _statusText == 'MATCH') ? "Verifikasi Berhasil" : (_statusText == 'PENDING' || _statusText == 'MISMATCH') ? "Verifikasi Diragukan" : "Verifikasi Gagal", style: AppTheme.heading3.copyWith( color: _resultColor, ), ), const SizedBox(height: 4), if (_confidence != null) Container( margin: const EdgeInsets.only(top: 8), padding: const EdgeInsets.symmetric( horizontal: 16, vertical: 8), decoration: BoxDecoration( color: AppTheme.bgLight, borderRadius: BorderRadius.circular( AppTheme.radiusMd), ), child: Column( children: [ // Mode video: tampilkan frame ratio if (_framesTotal != null) ...[ Text( "Frame Cocok: $_framesApproved/$_framesTotal", style: AppTheme.bodyMedium.copyWith( color: AppTheme.textSecondary, fontWeight: FontWeight.w600, ), ), const SizedBox(height: 4), // Progress bar frames ClipRRect( borderRadius: BorderRadius.circular(4), child: LinearProgressIndicator( value: _confidence ?? 0, backgroundColor: AppTheme.statusRed.withValues(alpha: 0.2), valueColor: AlwaysStoppedAnimation( (_confidence ?? 0) >= 0.8 ? AppTheme.statusGreen : (_confidence ?? 0) >= 0.5 ? AppTheme.statusYellow : AppTheme.statusRed, ), minHeight: 8, ), ), const SizedBox(height: 4), Text( "${((_confidence ?? 0) * 100).toStringAsFixed(0)}% frame terverifikasi", style: AppTheme.bodySmall.copyWith( color: (_confidence ?? 0) >= 0.8 ? AppTheme.statusGreen : (_confidence ?? 0) >= 0.5 ? AppTheme.statusYellow : AppTheme.statusRed, ), ), ] else ...[ // Mode foto: tampilkan SVM confidence biasa Text( "Confidence: ${(_confidence! * 100).toStringAsFixed(1)}%", style: AppTheme.bodyMedium.copyWith( color: AppTheme.textSecondary, fontWeight: FontWeight.w600, ), ), ], if (_svmDf != null) ...[ const SizedBox(height: 4), Text( "SVM Decision Function: ${_svmDf!.toStringAsFixed(3)}", style: AppTheme.bodySmall.copyWith( color: AppTheme.textSecondary, fontFamily: 'monospace', ), ), ], ], ), ), // Prediksi ML if (_predictedName != null) Container( margin: const EdgeInsets.only(top: 12), padding: const EdgeInsets.symmetric( horizontal: 16, vertical: 12), decoration: BoxDecoration( gradient: LinearGradient( colors: [ const Color(0xFF6366F1).withOpacity(0.08), const Color(0xFF8B5CF6).withOpacity(0.08), ], ), borderRadius: BorderRadius.circular(AppTheme.radiusMd), border: Border.all( color: const Color(0xFF6366F1).withOpacity(0.25), ), ), child: Row( children: [ Container( padding: const EdgeInsets.all(6), decoration: BoxDecoration( color: const Color(0xFF6366F1).withOpacity(0.15), shape: BoxShape.circle, ), child: const Icon( Icons.psychology_rounded, color: Color(0xFF6366F1), size: 16, ), ), const SizedBox(width: 10), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'Prediksi ML', style: AppTheme.labelMedium.copyWith( color: const Color(0xFF6366F1), fontWeight: FontWeight.w600, ), ), Text( _predictedName!, style: AppTheme.bodyMedium.copyWith( color: AppTheme.textPrimary, fontWeight: FontWeight.bold, ), ), ], ), ), ], ), ), if (_statusText != null) Padding( padding: const EdgeInsets.only(top: 12), child: Container( padding: const EdgeInsets.symmetric( horizontal: 14, vertical: 6), decoration: BoxDecoration( color: AppTheme.primaryBlue.withOpacity(0.1), borderRadius: BorderRadius.circular( AppTheme.radiusFull), ), child: Text( "Status: $_statusText", style: AppTheme.labelMedium.copyWith( color: AppTheme.primaryBlue, ), ), ), ), if (_errorMessage != null) Padding( padding: const EdgeInsets.only(top: 12), child: Text( _errorMessage!, textAlign: TextAlign.center, style: AppTheme.bodySmall.copyWith( color: AppTheme.statusRed, ), ), ), ], ), ), ), ), ), ), Positioned( bottom: 30, left: 20, right: 20, child: Column( children: [ if (!_showResult && !_isVerifying && !_isRecording) SizedBox( width: double.infinity, child: CustomButton( text: "Mulai Pindai & Verifikasi", icon: Icons.face_retouching_natural, onPressed: _allChecksValid && _isCameraInitialized ? _recordAndVerify : null, backgroundColor: _allChecksValid ? AppTheme.primaryBlue : AppTheme.primaryDark.withOpacity(0.5), ), ), if (_isRecording) Column( children: [ const SizedBox(height: 8), Container( padding: const EdgeInsets.symmetric( horizontal: 20, vertical: 10), decoration: BoxDecoration( color: AppTheme.primaryBlue.withOpacity(0.8), borderRadius: BorderRadius.circular(12), ), child: Text( 'Memindai... $_recordingSecondsLeft detik', style: const TextStyle( color: Colors.white, fontWeight: FontWeight.bold), ), ), ], ), if (_showResult) Row( children: [ Expanded( child: CustomButton( text: "Coba Lagi", type: ButtonType.primary, onPressed: _resetTest, ), ), ], ), ], ), ), ], ), ); } Widget _buildChip(IconData icon, String label, bool isOk) { final color = isOk ? AppTheme.statusGreen : Colors.white.withOpacity(0.4); return Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration( color: color.withOpacity(0.15), borderRadius: BorderRadius.circular(16), border: Border.all(color: color.withOpacity(0.4)), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon(isOk ? Icons.check_circle : icon, color: color, size: 12), const SizedBox(width: 4), Text(label, style: TextStyle( color: color, fontSize: 10, fontWeight: FontWeight.w600)), ], ), ); } }