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, recording, uploading, success, error } enum FaceDetectionStatus { noFace, detected } enum EnrollmentStep { front, right, left, done } 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; double _headEulerAngleY = 0.0; bool _isPoseValid = false; double _faceRatio = 0.0; bool _isDistanceOk = false; double _brightness = 0.0; bool _isBrightnessOk = false; double _lightingUniformity = 0.0; bool _isLightingUniform = false; EnrollmentStep _currentStep = EnrollmentStep.front; DateTime? _poseStartTime; bool _needsAbort = false; String _abortReason = ''; Timer? _maxDurationTimer; static const int _maxRecordingSeconds = 45; DateTime? _recordingStartTime; static const int _warmupMs = 2000; static const double _turnThreshold = 10.0; int _consecutiveBadFrames = 0; static const int _badFrameThreshold = 30; static const int _holdDurationMs = 4000; VideoRecordingState get recordingState => _recordingState; FaceDetectionStatus get faceDetectionStatus => _faceDetectionStatus; String get message => _message; String get errorMessage => _errorMessage; bool get isFaceDetected => _faceDetectionStatus == FaceDetectionStatus.detected; 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 lightingUniformity => _lightingUniformity; bool get isLightingUniform => _isLightingUniform; double get stepProgress { int completed = 0; if (_currentStep == EnrollmentStep.right) completed = 1; if (_currentStep == EnrollmentStep.left) completed = 2; if (_currentStep == EnrollmentStep.done) completed = 3; double holdProgress = 0.0; if (_poseStartTime != null && _currentStep != EnrollmentStep.done) { holdProgress = (DateTime.now().difference(_poseStartTime!).inMilliseconds / _holdDurationMs.toDouble()).clamp(0.0, 1.0) / 3.0; } return (completed / 3.0) + holdProgress; } bool get needsAbort => _needsAbort; String get abortReason => _abortReason; bool get isChecklistComplete => _currentStep == EnrollmentStep.done; bool get allChecksValid => isFaceDetected && _isPoseValid && _isDistanceOk && _isBrightnessOk && _isLightingUniform; String get qualityMessage { if (_recordingState == VideoRecordingState.recording) { int secondsLeft = (_holdDurationMs / 1000).ceil(); if (_poseStartTime != null) { int elapsed = DateTime.now().difference(_poseStartTime!).inMilliseconds; secondsLeft = ((_holdDurationMs / 1000).ceil() - (elapsed / 1000).floor()).clamp(1, (_holdDurationMs / 1000).ceil()); } switch (_currentStep) { case EnrollmentStep.front: if (_poseStartTime == null) return 'Tatap lurus ke kamera...'; return 'Tahan pose depan... ($secondsLeft detik)'; case EnrollmentStep.right: if (_poseStartTime == null) return 'Menoleh perlahan ke KANAN 👉'; return 'Tahan pose kanan... ($secondsLeft detik)'; case EnrollmentStep.left: if (_poseStartTime == null) return 'Menoleh perlahan ke KIRI 👈'; return 'Tahan pose kiri... ($secondsLeft detik)'; case EnrollmentStep.done: return 'Pose Selesai! Menyimpan...'; } } if (!isFaceDetected) return 'Arahkan wajah ke dalam bingkai'; if (_faceRatio < 0.25) return 'Terlalu jauh, dekatkan wajah ke kamera'; if (_faceRatio > 0.75) return 'Terlalu dekat, mundur sedikit'; if (!_isBrightnessOk) return 'Pencahayaan kurang, cari tempat terang'; if (!_isLightingUniform) return 'Ada bayangan di wajah, hadap langsung ke cahaya'; if (!_isPoseValid) return 'Hadap depan lurus ke kamera'; return ''; } void reset() { _recordingState = VideoRecordingState.idle; _faceDetectionStatus = FaceDetectionStatus.noFace; _videoFile = null; _message = ''; _errorMessage = ''; _currentStep = EnrollmentStep.front; _consecutiveBadFrames = 0; _poseStartTime = null; _recordingStartTime = null; _needsAbort = false; _abortReason = ''; _faceRatio = 0.0; _isDistanceOk = false; _brightness = 0.0; _isBrightnessOk = false; _lightingUniformity = 0.0; _isLightingUniform = false; _maxDurationTimer?.cancel(); _maxDurationTimer = null; notifyListeners(); } Future startRecording( CameraController controller, { void Function(CameraImage)? onAvailable, }) async { if (_recordingState == VideoRecordingState.recording) return; try { await controller.startVideoRecording(onAvailable: onAvailable); _recordingState = VideoRecordingState.recording; _currentStep = EnrollmentStep.front; _poseStartTime = null; _needsAbort = false; _consecutiveBadFrames = 0; _recordingStartTime = DateTime.now(); _message = 'Tatap lurus ke kamera...'; notifyListeners(); _startWatchdogTimer(controller); } catch (e) { _recordingState = VideoRecordingState.error; _errorMessage = 'Gagal memulai rekaman: $e'; notifyListeners(); } } void _startWatchdogTimer(CameraController controller) { _maxDurationTimer?.cancel(); _maxDurationTimer = Timer(const Duration(seconds: _maxRecordingSeconds), () { if (_recordingState == VideoRecordingState.recording) { _triggerAbort("Gagal menyelesaikan instruksi wajah."); } }); } void _triggerAbort(String reason) { _needsAbort = true; _abortReason = reason; notifyListeners(); } Future abortRecording(CameraController controller) async { _maxDurationTimer?.cancel(); if (controller.value.isRecordingVideo) { try { await controller.stopVideoRecording(); } catch (_) {} } await Future.delayed(const Duration(milliseconds: 500)); _recordingState = VideoRecordingState.error; _errorMessage = _abortReason.isNotEmpty ? _abortReason : "Perekaman dibatalkan."; _needsAbort = false; notifyListeners(); } Future stopRecording(CameraController controller) async { _maxDurationTimer?.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; final fileSize = await _videoFile!.length(); debugPrint('[FaceUpload] File: ${_videoFile!.path}, Size: ${(fileSize / 1024 / 1024).toStringAsFixed(2)} MB'); _message = 'Mengunggah video (${(fileSize / 1024 / 1024).toStringAsFixed(1)} MB)...'; notifyListeners(); try { debugPrint('[FaceUpload] Mulai upload...'); await _repository.enrollFace(videoFile: _videoFile!); debugPrint('[FaceUpload] Upload BERHASIL'); _recordingState = VideoRecordingState.success; _message = 'Pendaftaran Wajah Berhasil!'; } catch (e) { debugPrint('[FaceUpload] Upload GAGAL: $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, centerOnly: true); _isBrightnessOk = _brightness > 70; _lightingUniformity = BrightnessUtils.calculateFaceUniformity(image); _isLightingUniform = _lightingUniformity > 0.45; 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; // Tentukan dimensi efektif berdasarkan rotasi sensor // Sensor landscape (270°/90°) → dimensi display-nya DIBALIK final bool isRotated90or270 = rotation == InputImageRotation.rotation90deg || rotation == InputImageRotation.rotation270deg; final double effectiveWidth = isRotated90or270 ? image.height.toDouble() : image.width.toDouble(); final double effectiveHeight = isRotated90or270 ? image.width.toDouble() : image.height.toDouble(); final box = face.boundingBox; // faceRatio dihitung dari lebar display yang sebenarnya _faceRatio = box.width / effectiveWidth; // Validasi: Wajah harus berada SEPENUHNYA di dalam frame (tidak terpotong) final double marginW = effectiveWidth * 0.03; final double marginH = effectiveHeight * 0.03; final bool isFaceInsideFrame = box.left >= marginW && box.top >= marginH && box.right <= (effectiveWidth - marginW) && box.bottom <= (effectiveHeight - marginH); if (_recordingState == VideoRecordingState.recording) { _isDistanceOk = isFaceInsideFrame && _faceRatio >= 0.20 && _faceRatio <= 0.80; } else { _isDistanceOk = isFaceInsideFrame && _faceRatio >= 0.25 && _faceRatio <= 0.75; } _isPoseValid = _headEulerAngleY.abs() < 18.0; if (_recordingState == VideoRecordingState.recording) { final bool isWarmup = _recordingStartTime != null && DateTime.now().difference(_recordingStartTime!).inMilliseconds < _warmupMs; if (!_isDistanceOk || !_isBrightnessOk || !_isLightingUniform) { if (!isWarmup) { _consecutiveBadFrames++; if (_consecutiveBadFrames >= _badFrameThreshold) { _triggerAbort("Perekaman batal: Jarak wajah atau cahaya tidak sesuai."); } } _poseStartTime = null; } else { _consecutiveBadFrames = 0; bool isHoldingCorrectPose = false; switch (_currentStep) { case EnrollmentStep.front: isHoldingCorrectPose = _headEulerAngleY.abs() < 18.0; break; case EnrollmentStep.right: isHoldingCorrectPose = _headEulerAngleY < -_turnThreshold; break; case EnrollmentStep.left: isHoldingCorrectPose = _headEulerAngleY > _turnThreshold; break; case EnrollmentStep.done: isHoldingCorrectPose = true; break; } if (isHoldingCorrectPose) { if (_poseStartTime == null) { _poseStartTime = DateTime.now(); } else if (DateTime.now().difference(_poseStartTime!).inMilliseconds >= _holdDurationMs) { if (_currentStep == EnrollmentStep.front) { _currentStep = EnrollmentStep.right; _poseStartTime = null; } else if (_currentStep == EnrollmentStep.right) { _currentStep = EnrollmentStep.left; _poseStartTime = null; } else if (_currentStep == EnrollmentStep.left) { _currentStep = EnrollmentStep.done; _poseStartTime = null; notifyListeners(); } } } else { _poseStartTime = null; } } } } else { _faceDetectionStatus = FaceDetectionStatus.noFace; _isPoseValid = false; _isDistanceOk = false; _faceRatio = 0.0; if (_recordingState == VideoRecordingState.recording) { final bool isWarmup = _recordingStartTime != null && DateTime.now().difference(_recordingStartTime!).inMilliseconds < _warmupMs; if (!isWarmup) { _consecutiveBadFrames++; if (_consecutiveBadFrames >= _badFrameThreshold) { _triggerAbort("Perekaman batal: Wajah tidak ditemukan dalam bingkai."); } } } } 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() { _maxDurationTimer?.cancel(); _faceDetector.close(); super.dispose(); } }