From 53255d8d43843ac903d3483b367178114ee7b35e Mon Sep 17 00:00:00 2001 From: jouel88 Date: Sat, 4 Apr 2026 21:29:40 +0700 Subject: [PATCH] update --- lib/core/constants/api_url.dart | 3 +- lib/core/services/api_config_service.dart | 2 +- lib/core/utils/camera_utils.dart | 15 + lib/providers/face_provider.dart | 498 +++++------ lib/repositories/face_repository.dart | 32 +- .../onboarding/face_enrollment_screen.dart | 828 +++++++++++++----- .../presensi/camera_capture_screen.dart | 219 ++++- lib/screens/presensi/presensi_map_screen.dart | 4 +- lib/screens/profile/face_test_screen.dart | 289 ++++-- 9 files changed, 1317 insertions(+), 573 deletions(-) diff --git a/lib/core/constants/api_url.dart b/lib/core/constants/api_url.dart index 07089c6..e6f650e 100644 --- a/lib/core/constants/api_url.dart +++ b/lib/core/constants/api_url.dart @@ -16,10 +16,11 @@ class ApiUrl { return _cachedImageBaseUrl!; } - static String get baseUrl => _cachedBaseUrl ?? dotenv.env['API_BASE_URL'] ?? 'http://192.168.1.4:8000/api'; + static String get baseUrl => _cachedBaseUrl ?? dotenv.env['API_BASE_URL'] ?? 'http://10.10.4.20:8000/api'; static String get imageBaseUrl => _cachedImageBaseUrl ?? baseUrl.replaceAll('/api', '/storage/'); static Future initialize() async { + await ApiConfigService.clearConfig(); await getBaseUrl(); await getImageBaseUrl(); } diff --git a/lib/core/services/api_config_service.dart b/lib/core/services/api_config_service.dart index 8828346..a7e0cc3 100644 --- a/lib/core/services/api_config_service.dart +++ b/lib/core/services/api_config_service.dart @@ -6,7 +6,7 @@ class ApiConfigService { static const String _selectedPresetKey = 'selected_preset'; static const Map presets = { - 'current_ip': 'http://192.168.1.4:8000/api', + 'current_ip': 'http://192.168.110.17:8000/api', 'hostname': 'http://LAPTOP-I0SUKSKL:8000/api', 'emulator': 'http://10.0.2.2:8000/api', 'custom': '', diff --git a/lib/core/utils/camera_utils.dart b/lib/core/utils/camera_utils.dart index 60251ad..517cf76 100644 --- a/lib/core/utils/camera_utils.dart +++ b/lib/core/utils/camera_utils.dart @@ -57,3 +57,18 @@ class InputImageFormatUtils { } } } + +class BrightnessUtils { + static double calculateBrightness(CameraImage image) { + final yPlane = image.planes[0]; + final bytes = yPlane.bytes; + + int sum = 0; + int count = 0; + for (int i = 0; i < bytes.length; i += 10) { + sum += bytes[i]; + count++; + } + return count > 0 ? sum / count : 0.0; + } +} diff --git a/lib/providers/face_provider.dart b/lib/providers/face_provider.dart index 57f6c56..f86025d 100644 --- a/lib/providers/face_provider.dart +++ b/lib/providers/face_provider.dart @@ -1,23 +1,16 @@ +import 'dart:async'; import 'dart:io'; -import 'dart:ui' show Rect; 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 EnrollmentStep { depan, kanan, kiri, bawah, selesai } +enum VideoRecordingState { idle, recording, uploading, success, error } -// Status feedback real-time ke UI untuk panduan user -enum FaceStatus { - noFace, // Tidak ada wajah terdeteksi - tooFar, // Wajah terlalu jauh (kotak kecil) - tooClose, // Wajah terlalu dekat (kotak besar) - outOfFrame, // Wajah tidak di dalam area scan - wrongPose, // Wajah ada & di frame, tapi pose salah - holding, // Pose benar, sedang menghitung mundur - ready, // Siap capture (internal, langsung ambil foto) -} +enum FaceDetectionStatus { noFace, detected } + +enum EnrollmentStep { front, right, left } class FaceProvider with ChangeNotifier { final FaceRepository _repository = FaceRepository(); @@ -26,63 +19,222 @@ class FaceProvider with ChangeNotifier { enableClassification: false, enableLandmarks: false, enableContours: false, - enableTracking: true, - performanceMode: FaceDetectorMode.accurate, + enableTracking: false, + performanceMode: FaceDetectorMode.fast, ), ); - EnrollmentStep _currentStep = EnrollmentStep.depan; - bool _isProcessing = false; - bool _isUploading = false; - String _instructionText = "Hadapkan wajah lurus ke depan"; - FaceStatus _faceStatus = FaceStatus.noFace; + VideoRecordingState _recordingState = VideoRecordingState.idle; + FaceDetectionStatus _faceDetectionStatus = FaceDetectionStatus.noFace; + File? _videoFile; + String _message = ''; + String _errorMessage = ''; + bool _isProcessingFrame = false; - // Hold timer: pose harus dipertahankan selama ini sebelum capture - static const int _holdDurationMs = 1500; - DateTime? _poseValidSince; - double _holdProgress = 0.0; // 0.0 - 1.0 + // 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; + Timer? _progressTimer; - File? _fotoDepan; - File? _fotoKanan; - File? _fotoKiri; - File? _fotoBawah; + static const double _frontDuration = 4.0; + static const double _sideDuration = 3.0; + 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; - bool get isUploading => _isUploading; - String get instructionText => _instructionText; - FaceStatus get faceStatus => _faceStatus; - double get holdProgress => _holdProgress; + 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 (!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 tempat lebih terang'; + if (!_isPoseValid) { + switch (_currentStep) { + case EnrollmentStep.front: + return 'Hadapkan wajah ke depan'; + case EnrollmentStep.right: + return 'Toleh ke kanan'; + case EnrollmentStep.left: + return 'Toleh ke kiri'; + } + } + return ''; + } + + String get stepLabel { + switch (_currentStep) { + case EnrollmentStep.front: + return 'Hadap Depan'; + case EnrollmentStep.right: + return 'Toleh Kanan'; + case EnrollmentStep.left: + return 'Toleh Kiri'; + } + } void reset() { - _currentStep = EnrollmentStep.depan; - _instructionText = "Hadapkan wajah lurus ke depan"; - _fotoDepan = null; - _fotoKanan = null; - _fotoKiri = null; - _fotoBawah = null; - _isProcessing = false; - _isUploading = false; - _faceStatus = FaceStatus.noFace; - _poseValidSince = null; - _holdProgress = 0.0; + _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; + _progressTimer?.cancel(); + _progressTimer = null; notifyListeners(); } - Future processCameraImage( - CameraImage image, - CameraDescription camera, - InputImageRotation rotation, { - // Ukuran kotak scan dalam koordinat layar (0.0 - 1.0) - double scanBoxLeft = 0.125, - double scanBoxRight = 0.875, - double scanBoxTop = 0.275, - double scanBoxBottom = 0.725, - }) async { - if (_isProcessing || _currentStep == EnrollmentStep.selesai) return; - - _isProcessing = true; + 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; + } + + if (allChecksValid) { + 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; + _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, @@ -91,217 +243,40 @@ class FaceProvider with ChangeNotifier { final List faces = await _faceDetector.processImage(inputImage); - if (faces.isEmpty) { - _updateFaceStatus(FaceStatus.noFace); - _resetHoldTimer(); + 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() < 15; + break; + case EnrollmentStep.right: + _isPoseValid = _headEulerAngleY < -20; + break; + case EnrollmentStep.left: + _isPoseValid = _headEulerAngleY > 20; + break; + } } else { - final Face face = faces.first; - _validateAndCheckPose(face, image, scanBoxLeft, scanBoxRight, scanBoxTop, scanBoxBottom); + _faceDetectionStatus = FaceDetectionStatus.noFace; + _isPoseValid = false; + _isDistanceOk = false; + _faceRatio = 0.0; } + + notifyListeners(); } catch (e) { debugPrint("Error processing face: $e"); } finally { - _isProcessing = false; - } - } - - void _validateAndCheckPose( - Face face, - CameraImage image, - double boxLeft, - double boxRight, - double boxTop, - double boxBottom, - ) { - final imgW = image.width.toDouble(); - final imgH = image.height.toDouble(); - - // Bounding box dari ML Kit (dalam koordinat gambar - KAMERA DEPAN MIRROR) - final Rect bb = face.boundingBox; - - // Normalisasi ke 0.0 - 1.0 - // Untuk kamera depan di Android, X perlu di-flip karena kamera mirror - final double faceLeft = 1.0 - (bb.right / imgW); - final double faceRight = 1.0 - (bb.left / imgW); - final double faceTop = bb.top / imgH; - final double faceBottom = bb.bottom / imgH; - - final double faceWidth = faceRight - faceLeft; - - // --- Validasi Jarak (ukuran wajah) --- - // Wajah ideal: lebarnya 35% - 65% dari lebar frame - const double minFaceRatio = 0.30; - const double maxFaceRatio = 0.70; - - if (faceWidth < minFaceRatio) { - _updateFaceStatus(FaceStatus.tooFar); - _resetHoldTimer(); - return; - } - if (faceWidth > maxFaceRatio) { - _updateFaceStatus(FaceStatus.tooClose); - _resetHoldTimer(); - return; - } - - // --- Validasi Posisi dalam Kotak Scan --- - // Center wajah harus berada di dalam kotak scan (dengan toleransi 10%) - final double faceCenterX = (faceLeft + faceRight) / 2; - final double faceCenterY = (faceTop + faceBottom) / 2; - - const double tolerance = 0.10; - final bool inBox = faceCenterX >= (boxLeft - tolerance) && - faceCenterX <= (boxRight + tolerance) && - faceCenterY >= (boxTop - tolerance) && - faceCenterY <= (boxBottom + tolerance); - - if (!inBox) { - _updateFaceStatus(FaceStatus.outOfFrame); - _resetHoldTimer(); - return; - } - - // --- Validasi Pose --- - final bool isPoseValid = _isPoseValid(face); - - if (!isPoseValid) { - _updateFaceStatus(FaceStatus.wrongPose); - _resetHoldTimer(); - return; - } - - // --- Pose valid: jalankan hold timer --- - _tickHoldTimer(); - } - - bool _isPoseValid(Face face) { - const double thresholdFront = 10.0; - const double thresholdSide = 15.0; - const double thresholdDown = 5.0; - - final double? rotY = face.headEulerAngleY; - final double? rotX = face.headEulerAngleX; - - if (rotY == null || rotX == null) return false; - - switch (_currentStep) { - case EnrollmentStep.depan: - return rotY.abs() < thresholdFront && rotX.abs() < thresholdFront; - case EnrollmentStep.kanan: - return rotY < -thresholdSide; - case EnrollmentStep.kiri: - return rotY > thresholdSide; - case EnrollmentStep.bawah: - return rotX < -thresholdDown && rotY.abs() < thresholdFront; - default: - return false; - } - } - - void _tickHoldTimer() { - final now = DateTime.now(); - - if (_poseValidSince == null) { - _poseValidSince = now; - } - - final elapsed = now.difference(_poseValidSince!).inMilliseconds; - _holdProgress = (elapsed / _holdDurationMs).clamp(0.0, 1.0); - - _updateFaceStatus(FaceStatus.holding); - - if (elapsed >= _holdDurationMs) { - // Capture! - _poseValidSince = null; - _holdProgress = 0.0; - _captureImage(); - } - } - - void _resetHoldTimer() { - if (_poseValidSince != null || _holdProgress > 0) { - _poseValidSince = null; - _holdProgress = 0.0; - notifyListeners(); - } - } - - void _updateFaceStatus(FaceStatus status) { - if (_faceStatus != status) { - _faceStatus = status; - notifyListeners(); - } - } - - void _captureImage() { - _isProcessing = true; - - if (_currentStep == EnrollmentStep.depan) { - _currentStep = EnrollmentStep.kanan; - _instructionText = "Putar wajah perlahan ke KANAN"; - } else if (_currentStep == EnrollmentStep.kanan) { - _currentStep = EnrollmentStep.kiri; - _instructionText = "Putar wajah perlahan ke KIRI"; - } else if (_currentStep == EnrollmentStep.kiri) { - _currentStep = EnrollmentStep.bawah; - _instructionText = "Tundukkan kepala ke BAWAH"; - } else if (_currentStep == EnrollmentStep.bawah) { - _currentStep = EnrollmentStep.selesai; - _instructionText = "Data lengkap. Mengunggah..."; - } - - _faceStatus = FaceStatus.noFace; - notifyListeners(); - - // Cooldown setelah capture agar tidak langsung re-trigger - Future.delayed(const Duration(milliseconds: 800), () { - _isProcessing = false; - }); - } - - void saveCapturedFile(File file) { - if (_fotoDepan == null) { - _fotoDepan = file; - } else if (_fotoKanan == null) { - _fotoKanan = file; - } else if (_fotoKiri == null) { - _fotoKiri = file; - } else if (_fotoBawah == null) { - _fotoBawah = file; - if (_currentStep == EnrollmentStep.selesai) { - submitEnrollment(); - } - } - } - - Future submitEnrollment() async { - if (_fotoDepan == null || _fotoKanan == null || _fotoKiri == null || _fotoBawah == null) { - _instructionText = "Gagal: Foto tidak lengkap."; - notifyListeners(); - return; - } - - _isUploading = true; - notifyListeners(); - - try { - await _repository.enrollFace( - fotoDepan: _fotoDepan!, - fotoKanan: _fotoKanan!, - fotoKiri: _fotoKiri!, - fotoBawah: _fotoBawah!, - ); - _instructionText = "Pendaftaran Berhasil!"; - } catch (e) { - _instructionText = "Gagal Upload: ${e.toString()}"; - _currentStep = EnrollmentStep.depan; - _fotoDepan = null; - _fotoKanan = null; - _fotoKiri = null; - _fotoBawah = null; - } finally { - _isUploading = false; - notifyListeners(); + _isProcessingFrame = false; } } @@ -320,6 +295,7 @@ class FaceProvider with ChangeNotifier { @override void dispose() { + _progressTimer?.cancel(); _faceDetector.close(); super.dispose(); } diff --git a/lib/repositories/face_repository.dart b/lib/repositories/face_repository.dart index 0e5a000..2836db1 100644 --- a/lib/repositories/face_repository.dart +++ b/lib/repositories/face_repository.dart @@ -1,15 +1,13 @@ import 'dart:io'; import 'dart:convert'; import 'package:http/http.dart' as http; +import 'package:http_parser/http_parser.dart'; import '../core/constants/api_url.dart'; import '../core/cache_manager.dart'; class FaceRepository { Future> enrollFace({ - required File fotoDepan, - required File fotoKanan, - required File fotoKiri, - required File fotoBawah, + required File videoFile, }) async { try { final token = CacheManager.authBox.get('auth_token'); @@ -24,25 +22,34 @@ class FaceRepository { 'Accept': 'application/json', }); - request.files.add(await http.MultipartFile.fromPath('foto_depan', fotoDepan.path)); - request.files.add(await http.MultipartFile.fromPath('foto_kanan', fotoKanan.path)); - request.files.add(await http.MultipartFile.fromPath('foto_kiri', fotoKiri.path)); - request.files.add(await http.MultipartFile.fromPath('foto_bawah', fotoBawah.path)); + request.files.add(await http.MultipartFile.fromPath( + 'video_wajah', + videoFile.path, + contentType: MediaType('video', 'mp4'), + )); - var streamedResponse = await request.send(); + var streamedResponse = await request.send().timeout( + const Duration(minutes: 3), + ); var response = await http.Response.fromStream(streamedResponse); + final body = jsonDecode(response.body); + if (response.statusCode == 200 || response.statusCode == 201) { - return {'status': true, 'message': 'Pendaftaran wajah berhasil'}; + return { + 'status': true, + 'message': body['message'] ?? 'Pendaftaran wajah berhasil', + 'data': body['data'], + }; } else { - throw Exception('Gagal mendaftarkan wajah: ${response.statusCode}'); + throw Exception(body['message'] ?? 'Gagal mendaftarkan wajah: ${response.statusCode}'); } } catch (e) { rethrow; } } - Future> verifyFace(File imageFile) async { + Future> verifyFace(File imageFile, {String tipe = 'presensi'}) async { final token = CacheManager.authBox.get('auth_token'); var request = http.MultipartRequest( 'POST', @@ -55,6 +62,7 @@ class FaceRepository { }); request.files.add(await http.MultipartFile.fromPath('foto', imageFile.path)); + request.fields['tipe'] = tipe; var streamedResponse = await request.send(); var response = await http.Response.fromStream(streamedResponse); diff --git a/lib/screens/onboarding/face_enrollment_screen.dart b/lib/screens/onboarding/face_enrollment_screen.dart index 72b2bd7..cefc481 100644 --- a/lib/screens/onboarding/face_enrollment_screen.dart +++ b/lib/screens/onboarding/face_enrollment_screen.dart @@ -21,23 +21,29 @@ class FaceEnrollmentScreen extends StatefulWidget { State createState() => _FaceEnrollmentScreenState(); } -class _FaceEnrollmentScreenState extends State { +class _FaceEnrollmentScreenState extends State + with TickerProviderStateMixin { CameraController? _cameraController; bool _isCameraInitialized = false; CameraDescription? _frontCamera; - int _lastStepIndex = -1; - // Koordinat kotak scan (0.0 - 1.0) — harus cocok dengan _ScannerPainter - // ScannerOverlay: lebar 75% (0.125 - 0.875), tinggi 45% dari tengah (0.275 - 0.725) - static const double _boxLeft = 0.125; - static const double _boxRight = 0.875; - static const double _boxTop = 0.275; - static const double _boxBottom = 0.725; + late AnimationController _pulseController; + late Animation _pulseAnimation; @override void initState() { super.initState(); _initializeCamera(); + + _pulseController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 800), + )..repeat(reverse: true); + + _pulseAnimation = Tween(begin: 0.6, end: 1.0).animate( + CurvedAnimation(parent: _pulseController, curve: Curves.easeInOut), + ); + WidgetsBinding.instance.addPostFrameCallback((_) { context.read().reset(); }); @@ -65,18 +71,16 @@ class _FaceEnrollmentScreenState extends State { _cameraController!.startImageStream((CameraImage image) { if (!mounted) return; - final provider = context.read(); - - provider.processCameraImage( - image, - _frontCamera!, - CameraUtils.rotationIntToImageRotation(_frontCamera!.sensorOrientation), - scanBoxLeft: _boxLeft, - scanBoxRight: _boxRight, - scanBoxTop: _boxTop, - scanBoxBottom: _boxBottom, - ); + if (provider.recordingState == VideoRecordingState.idle || + provider.recordingState == VideoRecordingState.recording) { + provider.processCameraImage( + image, + _frontCamera!, + CameraUtils.rotationIntToImageRotation( + _frontCamera!.sensorOrientation), + ); + } }); setState(() { @@ -87,44 +91,36 @@ class _FaceEnrollmentScreenState extends State { @override void dispose() { _cameraController?.dispose(); + _pulseController.dispose(); super.dispose(); } - // Teks panduan berdasarkan FaceStatus - String _getStatusText(FaceProvider provider) { - switch (provider.faceStatus) { - case FaceStatus.noFace: - return provider.instructionText; - case FaceStatus.tooFar: - return "⬆️ Dekatkan wajah ke kamera"; - case FaceStatus.tooClose: - return "⬇️ Jauhkan wajah dari kamera"; - case FaceStatus.outOfFrame: - return "📦 Arahkan wajah ke dalam kotak"; - case FaceStatus.wrongPose: - return provider.instructionText; - case FaceStatus.holding: - return "✅ Tahan posisi..."; - case FaceStatus.ready: - return "📸 Mengambil foto..."; + Future _handleStartRecording(FaceProvider provider) async { + if (_cameraController == null || !_cameraController!.value.isInitialized) { + return; } - } - // Warna border kotak berdasarkan status - Color _getBorderColor(FaceProvider provider) { - if (provider.currentStep == EnrollmentStep.selesai) { - return AppTheme.statusGreen; - } - switch (provider.faceStatus) { - case FaceStatus.holding: - return AppTheme.statusGreen; - case FaceStatus.outOfFrame: - case FaceStatus.tooFar: - case FaceStatus.tooClose: - return AppTheme.statusRed; - default: - return AppTheme.primaryOrange; - } + try { + await _cameraController!.stopImageStream(); + } catch (_) {} + + await Future.delayed(const Duration(milliseconds: 100)); + + _cameraController!.startImageStream((CameraImage image) { + if (!mounted) return; + final p = context.read(); + if (p.recordingState == VideoRecordingState.recording) { + p.processCameraImage( + image, + _frontCamera!, + CameraUtils.rotationIntToImageRotation( + _frontCamera!.sensorOrientation), + ); + } + }); + + HapticFeedback.mediumImpact(); + await provider.startRecording(_cameraController!); } @override @@ -142,214 +138,396 @@ class _FaceEnrollmentScreenState extends State { color: Colors.black.withOpacity(0.5), shape: BoxShape.circle, ), - child: const Icon(Icons.arrow_back_ios_new, color: Colors.white, size: 16), + child: const Icon(Icons.arrow_back_ios_new, + color: Colors.white, size: 16), ), onPressed: () => Navigator.pop(context), ), ), body: Consumer( builder: (context, provider, child) { - // Deteksi step baru → ambil foto - if (provider.currentStep.index > _lastStepIndex && _lastStepIndex != -1) { - HapticFeedback.lightImpact(); - _takePictureAndSave(provider); - } - if (_lastStepIndex != provider.currentStep.index) { - _lastStepIndex = provider.currentStep.index; - } + final bool isIdle = + provider.recordingState == VideoRecordingState.idle; + final bool isRecording = + provider.recordingState == VideoRecordingState.recording; + final bool isUploading = + provider.recordingState == VideoRecordingState.uploading; + final bool isSuccess = + provider.recordingState == VideoRecordingState.success; + final bool isError = + provider.recordingState == VideoRecordingState.error; - final bool isComplete = provider.currentStep == EnrollmentStep.selesai; - final bool isHolding = provider.faceStatus == FaceStatus.holding; - final Color scanColor = _getBorderColor(provider); + Color scanColor = AppTheme.primaryOrange; + if (isIdle || isRecording) { + if (!provider.isFaceDetected) { + scanColor = AppTheme.statusRed; + } else if (provider.allChecksValid) { + scanColor = AppTheme.statusGreen; + } else { + scanColor = AppTheme.statusYellow; + } + } else if (isSuccess) { + scanColor = AppTheme.statusGreen; + } return Stack( children: [ - // --- Preview Kamera --- 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, + 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)), + const Center( + child: + CircularProgressIndicator(color: Colors.white)), - // --- Overlay Kotak Scan --- ScannerOverlay( borderColor: scanColor, - isScanning: !isComplete && !provider.isUploading && !isHolding, + isScanning: isIdle || isRecording, ), - // --- Hold Progress Ring (di tengah kotak scan) --- - if (isHolding && !isComplete) - Center( - child: Container( - margin: const EdgeInsets.only(top: 0), - child: SizedBox( - width: 80, - height: 80, - child: CircularProgressIndicator( - value: provider.holdProgress, - strokeWidth: 5, - backgroundColor: Colors.white24, - valueColor: const AlwaysStoppedAnimation(AppTheme.statusGreen), - ), - ), + // --- REC indicator --- + if (isRecording) + Positioned( + top: MediaQuery.of(context).padding.top + kToolbarHeight + 10, + right: 20, + child: AnimatedBuilder( + animation: _pulseAnimation, + builder: (context, child) { + return Opacity( + opacity: _pulseAnimation.value, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: Colors.red.withOpacity(0.8), + borderRadius: BorderRadius.circular(20), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 10, + height: 10, + decoration: const BoxDecoration( + color: Colors.white, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 6), + Text( + 'REC', + style: AppTheme.bodySmall + .copyWith(color: Colors.white, fontWeight: FontWeight.bold), + ), + ], + ), + ), + ); + }, ), ), - // --- Banner Instruksi Atas --- + // --- Header + Quality Checklist --- Positioned( top: MediaQuery.of(context).padding.top + kToolbarHeight + 20, left: 20, - right: 20, + right: 70, child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( "Pendaftaran Wajah", - style: AppTheme.heading3.copyWith(color: Colors.white), + style: + AppTheme.heading3.copyWith(color: Colors.white), ), const SizedBox(height: 10), - AnimatedSwitcher( - duration: const Duration(milliseconds: 250), - child: ClipRRect( - key: ValueKey(provider.faceStatus), - borderRadius: BorderRadius.circular(20), - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10), - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), - decoration: BoxDecoration( - color: isHolding - ? AppTheme.statusGreen.withOpacity(0.25) - : Colors.white.withOpacity(0.15), - borderRadius: BorderRadius.circular(20), - border: Border.all( - color: isHolding - ? AppTheme.statusGreen.withOpacity(0.6) - : Colors.white.withOpacity(0.3), - ), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - isComplete - ? Icons.check_circle - : isHolding - ? Icons.timer - : Icons.face, - color: isComplete - ? AppTheme.statusGreen - : isHolding - ? AppTheme.statusGreen - : AppTheme.primaryOrange, - size: 20, + + // Quality checklist chips + if (isIdle || isRecording) + Wrap( + spacing: 6, + runSpacing: 6, + children: [ + _buildQualityChip( + icon: Icons.straighten, + label: 'Jarak', + isOk: provider.isDistanceOk, + isDetected: provider.isFaceDetected, + ), + _buildQualityChip( + icon: Icons.wb_sunny_outlined, + label: 'Cahaya', + isOk: provider.isBrightnessOk, + isDetected: provider.isFaceDetected, + ), + _buildQualityChip( + icon: Icons.face, + label: provider.stepLabel, + isOk: provider.isPoseValid, + isDetected: provider.isFaceDetected, + ), + ], + ), + + // Status banner for uploading/success/error + if (!isIdle && !isRecording) + AnimatedSwitcher( + duration: const Duration(milliseconds: 250), + child: ClipRRect( + key: ValueKey(provider.recordingState), + borderRadius: BorderRadius.circular(20), + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 20, vertical: 12), + decoration: BoxDecoration( + color: isSuccess + ? AppTheme.statusGreen.withOpacity(0.25) + : isError + ? Colors.red.withOpacity(0.25) + : Colors.white.withOpacity(0.15), + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: isSuccess + ? AppTheme.statusGreen.withOpacity(0.6) + : isError + ? Colors.red.withOpacity(0.6) + : Colors.white.withOpacity(0.3), ), - const SizedBox(width: 8), - Flexible( - child: Text( - isComplete - ? provider.instructionText - : _getStatusText(provider), - textAlign: TextAlign.center, - style: AppTheme.bodyLarge.copyWith( - color: isComplete - ? AppTheme.statusGreen - : isHolding - ? AppTheme.statusGreen - : Colors.white, - fontWeight: FontWeight.bold, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + _getStatusIcon(provider), + color: _getStatusColor(provider), + size: 20, + ), + const SizedBox(width: 8), + Flexible( + child: Text( + _getStatusText(provider), + style: AppTheme.bodyLarge.copyWith( + color: _getStatusColor(provider), + fontWeight: FontWeight.bold, + ), ), ), - ), - ], + ], + ), ), ), ), ), - ), ], ), ), - // --- Panel Bawah: Progress Steps & Tombol Selesai --- + // --- Instruksi di tengah saat recording --- + if (isRecording) + Center( + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 300), + transitionBuilder: (child, animation) { + return FadeTransition( + opacity: animation, + child: SlideTransition( + position: Tween( + begin: const Offset(0, 0.2), + end: Offset.zero, + ).animate(animation), + child: child, + ), + ); + }, + child: Column( + key: ValueKey(provider.currentStep), + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + _getStepIcon(provider.currentStep), + size: 60, + color: provider.allChecksValid + ? Colors.white + : Colors.white.withOpacity(0.4), + ), + const SizedBox(height: 8), + Text( + provider.stepLabel, + style: TextStyle( + fontSize: 28, + fontWeight: FontWeight.w900, + color: provider.allChecksValid + ? Colors.white + : Colors.white.withOpacity(0.4), + ), + ), + if (!provider.allChecksValid && + provider.qualityMessage.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 16, vertical: 8), + decoration: BoxDecoration( + color: Colors.black.withOpacity(0.5), + borderRadius: BorderRadius.circular(20), + ), + child: Text( + provider.qualityMessage, + style: AppTheme.bodySmall.copyWith( + color: AppTheme.statusYellow, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ], + ), + ), + ), + + // --- Panel Bawah --- Positioned( bottom: 40, left: 20, right: 20, child: Column( children: [ - if (provider.isUploading) + // Step indicator + progress (saat recording) + if (isRecording) ...[ + _buildStepIndicator(provider), + const SizedBox(height: 16), + ClipRRect( + borderRadius: BorderRadius.circular(24), + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 8, sigmaY: 8), + child: Container( + padding: const EdgeInsets.symmetric( + vertical: 16, horizontal: 24), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.1), + borderRadius: BorderRadius.circular(24), + ), + child: Column( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: LinearProgressIndicator( + value: provider.stepProgress.clamp(0.0, 1.0), + minHeight: 8, + backgroundColor: + Colors.white.withOpacity(0.2), + valueColor: AlwaysStoppedAnimation( + provider.allChecksValid + ? AppTheme.statusGreen + : AppTheme.statusYellow, + ), + ), + ), + const SizedBox(height: 10), + Text( + provider.allChecksValid + ? 'Merekam...' + : 'Menunggu — ${provider.qualityMessage}', + style: AppTheme.bodySmall.copyWith( + color: provider.allChecksValid + ? Colors.white70 + : AppTheme.statusYellow, + ), + textAlign: TextAlign.center, + ), + ], + ), + ), + ), + ), + ], + + // Uploading state + if (isUploading) Container( - padding: const EdgeInsets.all(16), + padding: const EdgeInsets.all(24), decoration: BoxDecoration( color: Colors.black54, borderRadius: BorderRadius.circular(16), ), child: Column( children: [ - const CircularProgressIndicator(color: AppTheme.primaryOrange), - const SizedBox(height: 12), - Text("Mengunggah data wajah...", style: AppTheme.bodySmall.copyWith(color: Colors.white)), + const CircularProgressIndicator( + color: AppTheme.primaryOrange), + const SizedBox(height: 16), + Text( + provider.message, + style: AppTheme.bodySmall + .copyWith(color: Colors.white), + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + "Proses ini membutuhkan beberapa saat...", + style: AppTheme.bodySmall.copyWith( + color: Colors.white54, + fontSize: 11, + ), + textAlign: TextAlign.center, + ), ], ), - ) - else - ClipRRect( - borderRadius: BorderRadius.circular(24), - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 8, sigmaY: 8), - child: Container( - padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 24), - decoration: BoxDecoration( - color: Colors.white.withOpacity(0.1), - borderRadius: BorderRadius.circular(24), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - mainAxisSize: MainAxisSize.min, - children: List.generate(4, (index) { - final isActive = index == provider.currentStep.index; - final isPassed = index < provider.currentStep.index; - - return AnimatedContainer( - duration: const Duration(milliseconds: 300), - margin: const EdgeInsets.symmetric(horizontal: 6), - width: isActive ? 24 : 12, - height: 12, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(6), - color: isPassed - ? AppTheme.statusGreen - : (isActive ? AppTheme.statusYellow : Colors.white.withOpacity(0.3)), - boxShadow: isActive || isPassed - ? [ - BoxShadow( - color: (isPassed ? AppTheme.statusGreen : AppTheme.statusYellow).withOpacity(0.6), - blurRadius: 8, - ) - ] - : null, - ), - ); - }), - ), - ), - ), ), - if (isComplete) + // Idle state — tombol mulai rekam + if (isIdle) + Column( + children: [ + if (provider.qualityMessage.isNotEmpty) + Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Text( + provider.qualityMessage, + style: AppTheme.bodySmall.copyWith( + color: provider.isFaceDetected + ? AppTheme.statusYellow + : Colors.white70, + ), + textAlign: TextAlign.center, + ), + ), + CustomButton( + text: "Mulai Rekam", + icon: Icons.videocam, + backgroundColor: provider.allChecksValid + ? AppTheme.statusGreen + : AppTheme.primaryDark.withOpacity(0.5), + onPressed: provider.allChecksValid + ? () => _handleStartRecording(provider) + : null, + ), + ], + ), + + // Success state + if (isSuccess) Padding( padding: const EdgeInsets.only(top: 24), child: CustomButton( - text: widget.isOnboarding ? "Lanjut: Tanda Tangan" : "Selesai", - icon: widget.isOnboarding ? Icons.arrow_forward : Icons.check, + text: widget.isOnboarding + ? "Lanjut: Tanda Tangan" + : "Selesai", + icon: widget.isOnboarding + ? Icons.arrow_forward + : Icons.check, backgroundColor: AppTheme.statusGreen, onPressed: () { HapticFeedback.mediumImpact(); @@ -358,7 +536,8 @@ class _FaceEnrollmentScreenState extends State { Navigator.pushReplacement( context, MaterialPageRoute( - builder: (_) => const SignatureScreen(isOnboarding: true), + builder: (_) => + const SignatureScreen(isOnboarding: true), ), ); } else { @@ -367,6 +546,46 @@ class _FaceEnrollmentScreenState extends State { }, ), ), + + // Error state + if (isError) + Column( + children: [ + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.red.withOpacity(0.15), + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: Colors.red.withOpacity(0.3)), + ), + child: Row( + children: [ + const Icon(Icons.error_outline, + color: Colors.red, size: 24), + const SizedBox(width: 12), + Expanded( + child: Text( + provider.errorMessage, + style: AppTheme.bodySmall + .copyWith(color: Colors.red[200]), + ), + ), + ], + ), + ), + const SizedBox(height: 16), + CustomButton( + text: "Coba Lagi", + icon: Icons.refresh, + backgroundColor: AppTheme.primaryOrange, + onPressed: () { + provider.reset(); + _restartCameraStream(); + }, + ), + ], + ), ], ), ), @@ -377,27 +596,214 @@ class _FaceEnrollmentScreenState extends State { ); } - Future _takePictureAndSave(FaceProvider provider) async { + Widget _buildStepIndicator(FaceProvider provider) { + final steps = [ + (EnrollmentStep.front, 'Depan'), + (EnrollmentStep.right, 'Kanan'), + (EnrollmentStep.left, 'Kiri'), + ]; + + final currentIndex = EnrollmentStep.values.indexOf(provider.currentStep); + + return ClipRRect( + borderRadius: BorderRadius.circular(20), + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 8, sigmaY: 8), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.1), + borderRadius: BorderRadius.circular(20), + ), + child: Row( + children: List.generate(steps.length * 2 - 1, (index) { + if (index.isOdd) { + final lineIndex = index ~/ 2; + final isCompleted = lineIndex < currentIndex; + return Expanded( + child: Container( + height: 3, + margin: const EdgeInsets.symmetric(horizontal: 4), + decoration: BoxDecoration( + color: isCompleted + ? AppTheme.statusGreen + : Colors.white.withOpacity(0.2), + borderRadius: BorderRadius.circular(2), + ), + ), + ); + } + + final stepIndex = index ~/ 2; + final step = steps[stepIndex]; + final isCompleted = stepIndex < currentIndex; + final isCurrent = stepIndex == currentIndex; + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: isCurrent ? 28 : 22, + height: isCurrent ? 28 : 22, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: isCompleted + ? AppTheme.statusGreen + : isCurrent + ? AppTheme.primaryOrange + : Colors.white.withOpacity(0.2), + border: isCurrent + ? Border.all(color: Colors.white, width: 2) + : null, + ), + child: Center( + child: isCompleted + ? const Icon(Icons.check, color: Colors.white, size: 14) + : Text( + '${stepIndex + 1}', + style: TextStyle( + color: Colors.white, + fontSize: isCurrent ? 13 : 11, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + const SizedBox(height: 4), + Text( + step.$2, + style: TextStyle( + color: isCurrent + ? Colors.white + : Colors.white.withOpacity(0.5), + fontSize: 10, + fontWeight: + isCurrent ? FontWeight.bold : FontWeight.normal, + ), + ), + ], + ); + }), + ), + ), + ), + ); + } + + Widget _buildQualityChip({ + required IconData icon, + required String label, + required bool isOk, + required bool isDetected, + }) { + final color = !isDetected + ? Colors.white.withOpacity(0.4) + : isOk + ? AppTheme.statusGreen + : AppTheme.statusYellow; + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration( + color: color.withOpacity(0.15), + borderRadius: BorderRadius.circular(20), + border: Border.all(color: color.withOpacity(0.4)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + isOk && isDetected ? Icons.check_circle : icon, + color: color, + size: 14, + ), + const SizedBox(width: 4), + Text( + label, + style: TextStyle( + color: color, + fontSize: 11, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ); + } + + IconData _getStepIcon(EnrollmentStep step) { + switch (step) { + case EnrollmentStep.front: + return Icons.face; + case EnrollmentStep.right: + return Icons.turn_right; + case EnrollmentStep.left: + return Icons.turn_left; + } + } + + void _restartCameraStream() { + if (_cameraController == null || + !_cameraController!.value.isInitialized) return; + try { - await _cameraController?.stopImageStream(); + if (!_cameraController!.value.isStreamingImages) { + _cameraController!.startImageStream((CameraImage image) { + if (!mounted) return; + final provider = context.read(); + provider.processCameraImage( + image, + _frontCamera!, + CameraUtils.rotationIntToImageRotation( + _frontCamera!.sensorOrientation), + ); + }); + } + } catch (_) {} + } - final XFile file = await _cameraController!.takePicture(); - provider.saveCapturedFile(File(file.path)); + String _getStatusText(FaceProvider provider) { + switch (provider.recordingState) { + case VideoRecordingState.idle: + return ''; + case VideoRecordingState.recording: + return ''; + case VideoRecordingState.uploading: + return "⏳ Memproses..."; + case VideoRecordingState.success: + return "✅ ${provider.message}"; + case VideoRecordingState.error: + return "❌ Gagal"; + } + } - await _cameraController?.startImageStream((CameraImage image) { - if (!mounted) return; - provider.processCameraImage( - image, - _frontCamera!, - CameraUtils.rotationIntToImageRotation(_frontCamera!.sensorOrientation), - scanBoxLeft: _boxLeft, - scanBoxRight: _boxRight, - scanBoxTop: _boxTop, - scanBoxBottom: _boxBottom, - ); - }); - } catch (e) { - debugPrint("Error capturing image: $e"); + IconData _getStatusIcon(FaceProvider provider) { + switch (provider.recordingState) { + case VideoRecordingState.idle: + return Icons.face; + case VideoRecordingState.recording: + return Icons.videocam; + case VideoRecordingState.uploading: + return Icons.cloud_upload; + case VideoRecordingState.success: + return Icons.check_circle; + case VideoRecordingState.error: + return Icons.error; + } + } + + Color _getStatusColor(FaceProvider provider) { + switch (provider.recordingState) { + case VideoRecordingState.idle: + return Colors.white; + case VideoRecordingState.recording: + return Colors.white; + case VideoRecordingState.uploading: + return AppTheme.primaryOrange; + case VideoRecordingState.success: + return AppTheme.statusGreen; + case VideoRecordingState.error: + return Colors.red; } } } diff --git a/lib/screens/presensi/camera_capture_screen.dart b/lib/screens/presensi/camera_capture_screen.dart index 3d24c81..e6b9ebd 100644 --- a/lib/screens/presensi/camera_capture_screen.dart +++ b/lib/screens/presensi/camera_capture_screen.dart @@ -3,7 +3,9 @@ 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 '../../widgets/atoms/custom_button.dart'; import '../../widgets/atoms/scanner_overlay.dart'; @@ -16,48 +18,127 @@ class CameraCaptureScreen extends StatefulWidget { class _CameraCaptureScreenState extends State { CameraController? _controller; - Future? _initializeControllerFuture; + CameraDescription? _frontCamera; XFile? _capturedImage; bool _isCameraInitialized = false; + bool _isProcessingFrame = false; + + // Quality gate + bool _isFaceDetected = false; + bool _isDistanceOk = false; + bool _isBrightnessOk = false; + double _faceRatio = 0.0; + double _brightness = 0.0; + + late FaceDetector _faceDetector; + + bool get _allChecksValid => + _isFaceDetected && _isDistanceOk && _isBrightnessOk; + + String get _qualityMessage { + if (!_isFaceDetected) return 'Arahkan wajah ke dalam bingkai'; + if (_faceRatio < 0.20) return 'Terlalu jauh, dekatkan wajah'; + if (_faceRatio > 0.70) return 'Terlalu dekat, mundur sedikit'; + if (!_isBrightnessOk) return 'Pencahayaan kurang terang'; + return 'Siap — ambil foto'; + } @override void initState() { super.initState(); + _faceDetector = FaceDetector( + options: FaceDetectorOptions( + enableClassification: false, + enableLandmarks: false, + enableContours: false, + enableTracking: false, + performanceMode: FaceDetectorMode.fast, + ), + ); _initializeCamera(); } Future _initializeCamera() async { final cameras = await availableCameras(); - final firstCamera = cameras.firstWhere( + _frontCamera = cameras.firstWhere( (camera) => camera.lensDirection == CameraLensDirection.front, orElse: () => cameras.first, ); _controller = CameraController( - firstCamera, + _frontCamera!, ResolutionPreset.medium, enableAudio: false, + imageFormatGroup: Platform.isAndroid + ? ImageFormatGroup.nv21 + : ImageFormatGroup.bgra8888, ); - _initializeControllerFuture = _controller!.initialize(); - await _initializeControllerFuture; + await _controller!.initialize(); - if (mounted) { - setState(() { - _isCameraInitialized = true; - }); + if (!mounted) return; + + _controller!.startImageStream((CameraImage image) { + if (!mounted || _capturedImage != null) return; + _processFrame(image); + }); + + setState(() { + _isCameraInitialized = true; + }); + } + + void _processFrame(CameraImage image) async { + if (_isProcessingFrame) return; + _isProcessingFrame = true; + + try { + _brightness = BrightnessUtils.calculateBrightness(image); + _isBrightnessOk = _brightness > 60; + + 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; + final imageWidth = image.width.toDouble(); + _faceRatio = face.boundingBox.width / imageWidth; + _isFaceDetected = true; + _isDistanceOk = _faceRatio >= 0.20 && _faceRatio <= 0.70; + } else { + _isFaceDetected = false; + _isDistanceOk = false; + _faceRatio = 0.0; + } + + if (mounted) setState(() {}); + } catch (e) { + debugPrint("Error processing frame: $e"); + } finally { + _isProcessingFrame = false; } } @override void dispose() { _controller?.dispose(); + _faceDetector.close(); super.dispose(); } Future _takePicture() async { + if (!_allChecksValid) return; try { - await _initializeControllerFuture; + try { + await _controller!.stopImageStream(); + } catch (_) {} + final image = await _controller!.takePicture(); HapticFeedback.lightImpact(); if (!mounted) return; @@ -66,13 +147,21 @@ class _CameraCaptureScreenState extends State { _capturedImage = image; }); } catch (e) { - print(e); + debugPrint("Error taking picture: $e"); } } void _retakePicture() { setState(() { _capturedImage = null; + _isFaceDetected = false; + _isDistanceOk = false; + _isBrightnessOk = false; + }); + + _controller!.startImageStream((CameraImage image) { + if (!mounted || _capturedImage != null) return; + _processFrame(image); }); } @@ -109,17 +198,20 @@ class _CameraCaptureScreenState extends State { child: FittedBox( fit: BoxFit.cover, child: SizedBox( - width: _controller!.value.previewSize?.height ?? MediaQuery.of(context).size.width, - height: _controller!.value.previewSize?.width ?? MediaQuery.of(context).size.height, + width: _controller!.value.previewSize?.height ?? + MediaQuery.of(context).size.width, + height: _controller!.value.previewSize?.width ?? + MediaQuery.of(context).size.height, child: CameraPreview(_controller!), ), ), ) else - const Center(child: CircularProgressIndicator(color: Colors.white)), - + const Center( + child: CircularProgressIndicator(color: Colors.white)), + if (_capturedImage != null) - SizedBox.expand( + SizedBox.expand( child: Image.file( File(_capturedImage!.path), fit: BoxFit.cover, @@ -127,8 +219,14 @@ class _CameraCaptureScreenState extends State { ), ScannerOverlay( - borderColor: _capturedImage != null ? AppTheme.statusGreen : AppTheme.primaryBlue, - isScanning: _capturedImage == null, + borderColor: _capturedImage != null + ? AppTheme.statusGreen + : _allChecksValid + ? AppTheme.statusGreen + : _isFaceDetected + ? AppTheme.statusYellow + : AppTheme.primaryBlue, + isScanning: _capturedImage == null, ), Positioned( @@ -147,25 +245,47 @@ class _CameraCaptureScreenState extends State { child: BackdropFilter( filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10), child: Container( - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10), + 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)), + border: Border.all( + color: Colors.white.withOpacity(0.3)), ), child: Text( _capturedImage != null ? "Foto berhasil diambil ✓" - : "Posisikan wajah di dalam bingkai", + : _qualityMessage, textAlign: TextAlign.center, style: AppTheme.bodyLarge.copyWith( - color: _capturedImage != null ? AppTheme.statusGreen : Colors.white, + color: _capturedImage != null + ? AppTheme.statusGreen + : _allChecksValid + ? AppTheme.statusGreen + : Colors.white, fontWeight: FontWeight.bold, ), ), ), ), ), + + // Quality chips (saat belum capture) + if (_capturedImage == null) ...[ + const SizedBox(height: 10), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _buildChip(Icons.face, 'Wajah', _isFaceDetected), + const SizedBox(width: 6), + _buildChip(Icons.straighten, 'Jarak', _isDistanceOk), + const SizedBox(width: 6), + _buildChip( + Icons.wb_sunny_outlined, 'Cahaya', _isBrightnessOk), + ], + ), + ], ], ), ), @@ -200,26 +320,37 @@ class _CameraCaptureScreenState extends State { ) : Center( child: GestureDetector( - onTap: _takePicture, + onTap: _allChecksValid ? _takePicture : null, child: Container( width: 76, height: 76, decoration: BoxDecoration( shape: BoxShape.circle, - border: Border.all(color: Colors.white.withOpacity(0.8), width: 6), - color: Colors.white.withOpacity(0.2), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.2), - blurRadius: 10, - ) - ] + border: Border.all( + color: _allChecksValid + ? Colors.white.withOpacity(0.8) + : Colors.white.withOpacity(0.3), + width: 6, + ), + color: _allChecksValid + ? Colors.white.withOpacity(0.2) + : Colors.white.withOpacity(0.05), + boxShadow: _allChecksValid + ? [ + BoxShadow( + color: Colors.black.withOpacity(0.2), + blurRadius: 10, + ) + ] + : null, ), child: Container( margin: const EdgeInsets.all(6), - decoration: const BoxDecoration( + decoration: BoxDecoration( shape: BoxShape.circle, - color: Colors.white, + color: _allChecksValid + ? Colors.white + : Colors.white.withOpacity(0.3), ), ), ), @@ -230,4 +361,26 @@ class _CameraCaptureScreenState extends State { ), ); } + + 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)), + ], + ), + ); + } } diff --git a/lib/screens/presensi/presensi_map_screen.dart b/lib/screens/presensi/presensi_map_screen.dart index 29dd4d6..d8bafda 100644 --- a/lib/screens/presensi/presensi_map_screen.dart +++ b/lib/screens/presensi/presensi_map_screen.dart @@ -965,10 +965,10 @@ class _PresensiMapScreenState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(message, style: AppTheme.bodyMedium), - if (confidence != null && confidence < 900) ...[ + if (confidence != null && confidence < 1.0) ...[ const SizedBox(height: 12), Text( - "Jarak LBPH: ${confidence.toStringAsFixed(1)}", + "Confidence: ${(confidence * 100).toStringAsFixed(1)}%", style: AppTheme.bodySmall.copyWith(color: AppTheme.textSecondary), ), ], diff --git a/lib/screens/profile/face_test_screen.dart b/lib/screens/profile/face_test_screen.dart index be88470..fa56f49 100644 --- a/lib/screens/profile/face_test_screen.dart +++ b/lib/screens/profile/face_test_screen.dart @@ -3,7 +3,9 @@ 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'; @@ -17,49 +19,130 @@ class FaceTestScreen extends StatefulWidget { 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; + double _faceRatio = 0.0; + double _brightness = 0.0; bool? _verified; double? _confidence; + double? _svmConfidence; String? _statusText; String? _errorMessage; + late FaceDetector _faceDetector; + + bool get _allChecksValid => + _isFaceDetected && _isDistanceOk && _isBrightnessOk; + + String get _qualityMessage { + if (!_isFaceDetected) return 'Arahkan wajah ke dalam bingkai'; + if (_faceRatio < 0.20) return 'Terlalu jauh, dekatkan wajah'; + if (_faceRatio > 0.70) return 'Terlalu dekat, mundur sedikit'; + if (!_isBrightnessOk) return 'Pencahayaan kurang terang'; + return 'Siap — mulai verifikasi'; + } + @override void initState() { super.initState(); + _faceDetector = FaceDetector( + options: FaceDetectorOptions( + enableClassification: false, + enableLandmarks: false, + enableContours: false, + enableTracking: false, + performanceMode: FaceDetectorMode.fast, + ), + ); _initializeCamera(); } Future _initializeCamera() async { try { final cameras = await availableCameras(); - final frontCamera = cameras.firstWhere( + _frontCamera = cameras.firstWhere( (camera) => camera.lensDirection == CameraLensDirection.front, orElse: () => cameras.first, ); _cameraController = CameraController( - frontCamera, + _frontCamera!, ResolutionPreset.medium, enableAudio: false, + imageFormatGroup: Platform.isAndroid + ? ImageFormatGroup.nv21 + : ImageFormatGroup.bgra8888, ); await _cameraController!.initialize(); - if (mounted) { - setState(() { - _isCameraInitialized = true; - }); - } + 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); + _isBrightnessOk = _brightness > 60; + + 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; + final imageWidth = image.width.toDouble(); + _faceRatio = face.boundingBox.width / imageWidth; + _isFaceDetected = true; + _isDistanceOk = _faceRatio >= 0.20 && _faceRatio <= 0.70; + } else { + _isFaceDetected = false; + _isDistanceOk = false; + _faceRatio = 0.0; + } + + if (mounted) setState(() {}); + } catch (e) { + debugPrint("Error processing frame: $e"); + } finally { + _isProcessingFrame = false; + } + } + Future _captureAndVerify() async { - if (_cameraController == null || _isVerifying) return; + if (_cameraController == null || _isVerifying || !_allChecksValid) return; setState(() { _isVerifying = true; @@ -68,10 +151,15 @@ class _FaceTestScreenState extends State { }); try { + try { + await _cameraController!.stopImageStream(); + } catch (_) {} + final XFile photo = await _cameraController!.takePicture(); final File imageFile = File(photo.path); - final result = await FaceRepository().verifyFace(imageFile); + final result = + await FaceRepository().verifyFace(imageFile, tipe: 'test'); if (mounted) { setState(() { @@ -79,12 +167,15 @@ class _FaceTestScreenState extends State { _confidence = (result['confidence'] is num) ? (result['confidence'] as num).toDouble() : null; - _statusText = result['verification_status']?.toString() - ?? result['status']?.toString(); + _svmConfidence = (result['svm_confidence'] is num) + ? (result['svm_confidence'] as num).toDouble() + : null; + _statusText = result['verification_status']?.toString() ?? + result['status']?.toString(); _showResult = true; _isVerifying = false; }); - + if (_verified == true) { HapticFeedback.lightImpact(); } else { @@ -109,14 +200,21 @@ class _FaceTestScreenState extends State { _showResult = false; _verified = null; _confidence = null; + _svmConfidence = null; _statusText = null; _errorMessage = null; + _isFaceDetected = false; + _isDistanceOk = false; + _isBrightnessOk = false; }); + + _startQualityStream(); } @override void dispose() { _cameraController?.dispose(); + _faceDetector.close(); super.dispose(); } @@ -135,7 +233,8 @@ class _FaceTestScreenState extends State { color: Colors.black.withOpacity(0.5), shape: BoxShape.circle, ), - child: const Icon(Icons.arrow_back_ios_new, color: Colors.white, size: 16), + child: const Icon(Icons.arrow_back_ios_new, + color: Colors.white, size: 16), ), onPressed: () => Navigator.pop(context), ), @@ -147,8 +246,10 @@ class _FaceTestScreenState extends State { 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, + width: _cameraController!.value.previewSize?.height ?? + MediaQuery.of(context).size.width, + height: _cameraController!.value.previewSize?.width ?? + MediaQuery.of(context).size.height, child: CameraPreview(_cameraController!), ), ), @@ -160,8 +261,14 @@ class _FaceTestScreenState extends State { ScannerOverlay( borderColor: _showResult - ? (_verified == true ? AppTheme.statusGreen : AppTheme.statusRed) - : AppTheme.primaryBlue, + ? (_verified == true + ? AppTheme.statusGreen + : AppTheme.statusRed) + : _allChecksValid + ? AppTheme.statusGreen + : _isFaceDetected + ? AppTheme.statusYellow + : AppTheme.primaryBlue, isScanning: _isVerifying || (!_showResult && !_isVerifying), ), @@ -181,29 +288,53 @@ class _FaceTestScreenState extends State { child: BackdropFilter( filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10), child: Container( - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10), + 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)), + border: Border.all( + color: Colors.white.withOpacity(0.3)), ), child: Text( _isVerifying ? "Memverifikasi wajah..." : _showResult - ? (_verified == true ? "Wajah Terverifikasi ✓" : "Wajah Tidak Cocok ✗") - : "Posisikan wajah di dalam bingkai", + ? (_verified == true + ? "Wajah Terverifikasi ✓" + : "Wajah Tidak Cocok ✗") + : _qualityMessage, textAlign: TextAlign.center, style: AppTheme.bodyLarge.copyWith( color: _showResult - ? (_verified == true ? AppTheme.statusGreen : AppTheme.statusRed) - : Colors.white, + ? (_verified == true + ? AppTheme.statusGreen + : AppTheme.statusRed) + : _allChecksValid + ? AppTheme.statusGreen + : Colors.white, fontWeight: FontWeight.bold, ), ), ), ), ), + + // Quality chips + if (!_showResult && !_isVerifying) ...[ + const SizedBox(height: 10), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _buildChip(Icons.face, 'Wajah', _isFaceDetected), + const SizedBox(width: 6), + _buildChip(Icons.straighten, 'Jarak', _isDistanceOk), + const SizedBox(width: 6), + _buildChip( + Icons.wb_sunny_outlined, 'Cahaya', _isBrightnessOk), + ], + ), + ], ], ), ), @@ -224,15 +355,17 @@ class _FaceTestScreenState extends State { 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)), + 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, @@ -240,37 +373,48 @@ class _FaceTestScreenState extends State { Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( - color: (_verified == true ? AppTheme.statusGreen : AppTheme.statusRed).withOpacity(0.1), + color: (_verified == true + ? AppTheme.statusGreen + : AppTheme.statusRed) + .withOpacity(0.1), shape: BoxShape.circle, ), child: Icon( - _verified == true ? Icons.check_circle : Icons.cancel, - color: _verified == true ? AppTheme.statusGreen : AppTheme.statusRed, + _verified == true + ? Icons.check_circle + : Icons.cancel, + color: _verified == true + ? AppTheme.statusGreen + : AppTheme.statusRed, size: 40, ), ), const SizedBox(height: 12), - Text( - _verified == true ? "Verifikasi Berhasil" : "Verifikasi Gagal", + _verified == true + ? "Verifikasi Berhasil" + : "Verifikasi Gagal", style: AppTheme.heading3.copyWith( - color: _verified == true ? AppTheme.statusGreen : AppTheme.statusRed, + color: _verified == true + ? AppTheme.statusGreen + : AppTheme.statusRed, ), ), const SizedBox(height: 4), - - if (_confidence != null && _confidence! < 900) + if (_confidence != null) Container( margin: const EdgeInsets.only(top: 8), - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + padding: const EdgeInsets.symmetric( + horizontal: 16, vertical: 8), decoration: BoxDecoration( color: AppTheme.bgLight, - borderRadius: BorderRadius.circular(AppTheme.radiusMd), + borderRadius: BorderRadius.circular( + AppTheme.radiusMd), ), child: Column( children: [ Text( - "Jarak LBPH: ${_confidence!.toStringAsFixed(1)}", + "Confidence: ${(_confidence! * 100).toStringAsFixed(1)}%", style: AppTheme.bodyMedium.copyWith( color: AppTheme.textSecondary, fontWeight: FontWeight.w600, @@ -278,31 +422,46 @@ class _FaceTestScreenState extends State { ), const SizedBox(height: 2), Text( - _confidence! < 80 - ? "Sangat mirip (< 80)" - : _confidence! < 100 - ? "Cukup mirip (80-100)" - : "Tidak mirip (> 100)", + _confidence! >= 0.85 + ? "Sangat cocok (≥ 85%)" + : _confidence! >= 0.65 + ? "Cukup cocok (65-85%)" + : "Tidak cocok (< 65%)", style: AppTheme.bodySmall.copyWith( - color: _confidence! < 80 + color: _confidence! >= 0.85 ? AppTheme.statusGreen - : _confidence! < 100 + : _confidence! >= 0.65 ? AppTheme.statusYellow : AppTheme.statusRed, ), ), + if (_svmConfidence != null) ...[ + const SizedBox(height: 8), + const Divider(), + const SizedBox(height: 8), + Text( + "SVM Conf: ${(_svmConfidence! * 100).toStringAsFixed(1)}%", + style: + AppTheme.labelMedium.copyWith( + color: AppTheme.textSecondary, + fontFamily: 'monospace', + ), + ), + ], ], ), ), - if (_statusText != null) Padding( padding: const EdgeInsets.only(top: 12), child: Container( - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6), + padding: const EdgeInsets.symmetric( + horizontal: 14, vertical: 6), decoration: BoxDecoration( - color: AppTheme.primaryBlue.withOpacity(0.1), - borderRadius: BorderRadius.circular(AppTheme.radiusFull), + color: + AppTheme.primaryBlue.withOpacity(0.1), + borderRadius: BorderRadius.circular( + AppTheme.radiusFull), ), child: Text( "Status: $_statusText", @@ -312,7 +471,6 @@ class _FaceTestScreenState extends State { ), ), ), - if (_errorMessage != null) Padding( padding: const EdgeInsets.only(top: 12), @@ -344,11 +502,16 @@ class _FaceTestScreenState extends State { child: CustomButton( text: "Mulai Verifikasi", icon: Icons.face_rounded, - onPressed: _isCameraInitialized ? _captureAndVerify : null, - backgroundColor: AppTheme.primaryBlue, + onPressed: _allChecksValid && _isCameraInitialized + ? _captureAndVerify + : null, + backgroundColor: _allChecksValid + ? AppTheme.primaryBlue + : AppTheme.primaryDark.withOpacity(0.5), ), ), - + if (_isVerifying) + const CircularProgressIndicator(color: Colors.white), if (_showResult) Row( children: [ @@ -368,4 +531,26 @@ class _FaceTestScreenState extends State { ), ); } + + 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)), + ], + ), + ); + } }