MIF_E31230910_MP-HRIS-MOBILE/lib/providers/face_provider.dart

413 lines
14 KiB
Dart

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<void> 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<void> 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<void> 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<void> 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<Face> 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<String, dynamic> _faceStatus2 = {};
Map<String, dynamic> get faceStatus2 => _faceStatus2;
Future<void> 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();
}
}