import 'dart:async'; import 'dart:io'; import 'package:camera/camera.dart'; import 'package:flutter/foundation.dart'; import 'package:google_mlkit_face_detection/google_mlkit_face_detection.dart'; import '../repositories/face_repository.dart'; import '../core/utils/camera_utils.dart'; enum VideoRecordingState { idle, ready, recording, uploading, success, error } enum FaceDetectionStatus { noFace, detected } enum EnrollmentStep { front, right, left } class FaceProvider with ChangeNotifier { final FaceRepository _repository = FaceRepository(); final FaceDetector _faceDetector = FaceDetector( options: FaceDetectorOptions( enableClassification: false, enableLandmarks: true, enableContours: false, enableTracking: true, performanceMode: FaceDetectorMode.accurate, ), ); VideoRecordingState _recordingState = VideoRecordingState.idle; FaceDetectionStatus _faceDetectionStatus = FaceDetectionStatus.noFace; File? _videoFile; String _message = ''; String _errorMessage = ''; bool _isProcessingFrame = false; // Guided enrollment EnrollmentStep _currentStep = EnrollmentStep.front; double _headEulerAngleY = 0.0; bool _isPoseValid = false; double _faceRatio = 0.0; bool _isDistanceOk = false; double _brightness = 0.0; bool _isBrightnessOk = false; double _stepProgress = 0.0; double _rightAngleSign = 0.0; int _consecutiveValidPosedFrames = 0; Timer? _progressTimer; static const double _frontDuration = 4.0; static const double _sideDuration = 6.0; // Tambahan jeda ekstra untuk menoleh yang rapi static const double _tickInterval = 100; VideoRecordingState get recordingState => _recordingState; FaceDetectionStatus get faceDetectionStatus => _faceDetectionStatus; String get message => _message; String get errorMessage => _errorMessage; bool get isFaceDetected => _faceDetectionStatus == FaceDetectionStatus.detected; EnrollmentStep get currentStep => _currentStep; double get headEulerAngleY => _headEulerAngleY; bool get isPoseValid => _isPoseValid; double get faceRatio => _faceRatio; bool get isDistanceOk => _isDistanceOk; double get brightness => _brightness; bool get isBrightnessOk => _isBrightnessOk; double get stepProgress => _stepProgress; bool get allChecksValid => isFaceDetected && _isPoseValid && _isDistanceOk && _isBrightnessOk; String get qualityMessage { if (_recordingState == VideoRecordingState.ready) return 'Liveliness Lulus! Tekan tombol Rekam di bawah.'; if (_recordingState == VideoRecordingState.recording) { if (_currentStep == EnrollmentStep.front) return 'Tahan posisi hadap depan...'; if (_currentStep == EnrollmentStep.right) return 'Mulai toleh ke kanan perlahan...'; return 'Mulai toleh arah sebaliknya perlahan...'; } if (!isFaceDetected) return 'Arahkan wajah ke dalam bingkai'; if (_faceRatio < 0.20) return 'Terlalu jauh, dekatkan wajah ke kamera'; if (_faceRatio > 0.70) return 'Terlalu dekat, mundur sedikit'; if (!_isBrightnessOk) return 'Pencahayaan kurang, cari terang'; if (!_isPoseValid) { switch (_currentStep) { case EnrollmentStep.front: return 'UJI: Hadap depan (Ideal: <12, Skg: ${_headEulerAngleY.toStringAsFixed(0)})'; case EnrollmentStep.right: return 'UJI: Toleh kanan/kiri (Minimal 12 derajat, Skg: ${_headEulerAngleY.toStringAsFixed(0)})'; case EnrollmentStep.left: return 'UJI: Toleh arah sebaliknya (Minimal 12 derajat, Skg: ${_headEulerAngleY.toStringAsFixed(0)})'; } } return ''; } String get stepLabel { if (_recordingState == VideoRecordingState.idle) { switch (_currentStep) { case EnrollmentStep.front: return 'Tes Liveliness: Depan'; case EnrollmentStep.right: return 'Tes Liveliness: Toleh Kanan'; case EnrollmentStep.left: return 'Tes Liveliness: Toleh Kiri'; } } switch (_currentStep) { case EnrollmentStep.front: return 'Merekam: Hadap Depan'; case EnrollmentStep.right: return 'Merekam: Toleh Kanan'; case EnrollmentStep.left: return 'Merekam: Toleh Kiri'; } } void reset() { _recordingState = VideoRecordingState.idle; _faceDetectionStatus = FaceDetectionStatus.noFace; _videoFile = null; _message = ''; _errorMessage = ''; _isProcessingFrame = false; _currentStep = EnrollmentStep.front; _headEulerAngleY = 0.0; _isPoseValid = false; _faceRatio = 0.0; _isDistanceOk = false; _brightness = 0.0; _isBrightnessOk = false; _stepProgress = 0.0; _rightAngleSign = 0.0; _consecutiveValidPosedFrames = 0; _progressTimer?.cancel(); _progressTimer = null; notifyListeners(); } Future startRecording(CameraController controller) async { if (_recordingState == VideoRecordingState.recording) return; try { await controller.startVideoRecording(); _recordingState = VideoRecordingState.recording; _currentStep = EnrollmentStep.front; _stepProgress = 0.0; _message = 'Hadap Depan'; notifyListeners(); _startProgressTimer(controller); } catch (e) { _recordingState = VideoRecordingState.error; _errorMessage = 'Gagal memulai rekaman: $e'; notifyListeners(); } } void _startProgressTimer(CameraController controller) { _progressTimer?.cancel(); _progressTimer = Timer.periodic( Duration(milliseconds: _tickInterval.toInt()), (timer) { if (_recordingState != VideoRecordingState.recording) { timer.cancel(); return; } // Fase 2: Hardware Android Camera2 pada banyak device mematikan aliran Stream Gambar // ketika Stream Video berjalan (freeze). Bypass strict MLKit, andalkan jeda waktu murni. final duration = _currentStep == EnrollmentStep.front ? _frontDuration : _sideDuration; final increment = (_tickInterval / 1000) / duration; _stepProgress += increment; if (_stepProgress >= 1.0) { _advanceStep(controller); } notifyListeners(); }, ); } void _advanceStep(CameraController controller) { switch (_currentStep) { case EnrollmentStep.front: _currentStep = EnrollmentStep.right; _stepProgress = 0.0; _isPoseValid = false; _rightAngleSign = 0.0; _message = 'Toleh Kanan'; break; case EnrollmentStep.right: _currentStep = EnrollmentStep.left; _stepProgress = 0.0; _isPoseValid = false; _message = 'Toleh Kiri'; break; case EnrollmentStep.left: _progressTimer?.cancel(); stopRecording(controller); break; } } Future stopRecording(CameraController controller) async { _progressTimer?.cancel(); if (!controller.value.isRecordingVideo) return; try { final XFile video = await controller.stopVideoRecording(); _videoFile = File(video.path); _message = 'Rekaman selesai. Mengunggah...'; notifyListeners(); await submitVideo(); } catch (e) { _recordingState = VideoRecordingState.error; _errorMessage = 'Gagal menghentikan rekaman: $e'; notifyListeners(); } } Future submitVideo() async { if (_videoFile == null) { _recordingState = VideoRecordingState.error; _errorMessage = 'File video tidak ditemukan.'; notifyListeners(); return; } _recordingState = VideoRecordingState.uploading; _message = 'Mengunggah & memproses video...'; notifyListeners(); try { await _repository.enrollFace(videoFile: _videoFile!); _recordingState = VideoRecordingState.success; _message = 'Pendaftaran Wajah Berhasil!'; } catch (e) { _recordingState = VideoRecordingState.error; _errorMessage = e.toString().replaceAll('Exception: ', ''); } notifyListeners(); } void processCameraImage( CameraImage image, CameraDescription camera, InputImageRotation rotation, ) async { if (_isProcessingFrame) return; _isProcessingFrame = true; try { _brightness = BrightnessUtils.calculateBrightness(image); _isBrightnessOk = _brightness > 60; final inputImage = CameraUtils.inputImageFromCameraImage( image: image, camera: camera, rotation: rotation, ); final List faces = await _faceDetector.processImage(inputImage); if (faces.isNotEmpty) { final face = faces.first; _faceDetectionStatus = FaceDetectionStatus.detected; _headEulerAngleY = face.headEulerAngleY ?? 0.0; final imageWidth = image.width.toDouble(); final box = face.boundingBox; _faceRatio = box.width / imageWidth; _isDistanceOk = _faceRatio >= 0.20 && _faceRatio <= 0.70; switch (_currentStep) { case EnrollmentStep.front: _isPoseValid = _headEulerAngleY.abs() < 12; break; case EnrollmentStep.right: _isPoseValid = _headEulerAngleY.abs() >= 12; if (_isPoseValid) { _rightAngleSign = _headEulerAngleY.sign; } break; case EnrollmentStep.left: _isPoseValid = _headEulerAngleY.abs() >= 12 && (_rightAngleSign == 0.0 || _headEulerAngleY.sign != _rightAngleSign); break; } // Auto Advance Phase 1 if (_recordingState == VideoRecordingState.idle && allChecksValid) { _consecutiveValidPosedFrames++; if (_consecutiveValidPosedFrames > 5) { _consecutiveValidPosedFrames = 0; if (_currentStep == EnrollmentStep.front) { _currentStep = EnrollmentStep.right; _isPoseValid = false; _rightAngleSign = 0.0; } else if (_currentStep == EnrollmentStep.right) { _currentStep = EnrollmentStep.left; _isPoseValid = false; } else if (_currentStep == EnrollmentStep.left) { _recordingState = VideoRecordingState.ready; _currentStep = EnrollmentStep.front; // Reset for recording phase _isPoseValid = true; } } } else { _consecutiveValidPosedFrames = 0; } } else { _faceDetectionStatus = FaceDetectionStatus.noFace; _isPoseValid = false; _isDistanceOk = false; _faceRatio = 0.0; } notifyListeners(); } catch (e) { debugPrint("Error processing face: $e"); } finally { _isProcessingFrame = false; } } Map _faceStatus2 = {}; Map get faceStatus2 => _faceStatus2; Future loadFaceStatus() async { try { final status = await _repository.getFaceStatus(); _faceStatus2 = status; notifyListeners(); } catch (e) { debugPrint("Error loading face status: $e"); } } @override void dispose() { _progressTimer?.cancel(); _faceDetector.close(); super.dispose(); } }