439 lines
14 KiB
Dart
439 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;
|
|
Timer? _uiRefreshTimer;
|
|
static const int _maxRecordingSeconds = 45;
|
|
DateTime? _recordingStartTime;
|
|
static const int _warmupMs = 2000;
|
|
static const double _turnThreshold = 10.0;
|
|
static const int _holdDurationMs = 5000;
|
|
|
|
int _consecutiveBadFrames = 0;
|
|
static const int _badFrameThreshold = 30;
|
|
|
|
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 {
|
|
if (_poseStartTime == null || _currentStep == EnrollmentStep.done) {
|
|
return 0.0;
|
|
}
|
|
return (DateTime.now().difference(_poseStartTime!).inMilliseconds /
|
|
_holdDurationMs.toDouble())
|
|
.clamp(0.0, 1.0);
|
|
}
|
|
|
|
double get totalProgress {
|
|
int completed = 0;
|
|
if (_currentStep == EnrollmentStep.right) completed = 1;
|
|
if (_currentStep == EnrollmentStep.left) completed = 2;
|
|
if (_currentStep == EnrollmentStep.done) completed = 3;
|
|
return ((completed + stepProgress) / 3.0).clamp(0.0, 1.0);
|
|
}
|
|
|
|
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;
|
|
_uiRefreshTimer?.cancel();
|
|
_uiRefreshTimer = null;
|
|
notifyListeners();
|
|
}
|
|
|
|
Future<bool> startRecording(
|
|
CameraController controller, {
|
|
void Function(CameraImage)? onAvailable,
|
|
}) async {
|
|
if (_recordingState == VideoRecordingState.recording) return false;
|
|
|
|
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);
|
|
_startUiRefreshTimer();
|
|
return true;
|
|
} catch (e) {
|
|
debugPrint('[FaceProvider] startVideoRecording error: $e');
|
|
_recordingState = VideoRecordingState.error;
|
|
_errorMessage = 'Gagal memulai rekaman: $e';
|
|
notifyListeners();
|
|
return false;
|
|
}
|
|
}
|
|
|
|
void _startUiRefreshTimer() {
|
|
_uiRefreshTimer?.cancel();
|
|
_uiRefreshTimer = Timer.periodic(const Duration(seconds: 1), (_) {
|
|
if (_recordingState == VideoRecordingState.recording) {
|
|
notifyListeners();
|
|
} else {
|
|
_uiRefreshTimer?.cancel();
|
|
_uiRefreshTimer = null;
|
|
}
|
|
});
|
|
}
|
|
|
|
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();
|
|
_uiRefreshTimer?.cancel();
|
|
_uiRefreshTimer = null;
|
|
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();
|
|
_uiRefreshTimer?.cancel();
|
|
_uiRefreshTimer = null;
|
|
|
|
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;
|
|
|
|
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 = box.width / effectiveWidth;
|
|
|
|
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;
|
|
|
|
// === LOGIKA SELAMA RECORDING ===
|
|
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;
|
|
}
|
|
}
|
|
} else {
|
|
_poseStartTime = null;
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
_faceDetectionStatus = FaceDetectionStatus.noFace;
|
|
_isPoseValid = false;
|
|
_isDistanceOk = false;
|
|
_faceRatio = 0.0;
|
|
|
|
// Wajah hilang selama recording → hitung bad frames
|
|
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.");
|
|
}
|
|
}
|
|
_poseStartTime = null;
|
|
}
|
|
}
|
|
|
|
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();
|
|
_uiRefreshTimer?.cancel();
|
|
_faceDetector.close();
|
|
super.dispose();
|
|
}
|
|
}
|