This commit is contained in:
jouel88 2026-04-04 21:29:40 +07:00
parent 0fbbc1915d
commit 53255d8d43
9 changed files with 1317 additions and 573 deletions

View File

@ -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<void> initialize() async {
await ApiConfigService.clearConfig();
await getBaseUrl();
await getImageBaseUrl();
}

View File

@ -6,7 +6,7 @@ class ApiConfigService {
static const String _selectedPresetKey = 'selected_preset';
static const Map<String, String> 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': '',

View File

@ -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;
}
}

View File

@ -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<void> 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<void> 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<void> 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<void> 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<Face> 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<void> 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();
}

View File

@ -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<Map<String, dynamic>> 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<Map<String, dynamic>> verifyFace(File imageFile) async {
Future<Map<String, dynamic>> 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);

View File

@ -21,23 +21,29 @@ class FaceEnrollmentScreen extends StatefulWidget {
State<FaceEnrollmentScreen> createState() => _FaceEnrollmentScreenState();
}
class _FaceEnrollmentScreenState extends State<FaceEnrollmentScreen> {
class _FaceEnrollmentScreenState extends State<FaceEnrollmentScreen>
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<double> _pulseAnimation;
@override
void initState() {
super.initState();
_initializeCamera();
_pulseController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 800),
)..repeat(reverse: true);
_pulseAnimation = Tween<double>(begin: 0.6, end: 1.0).animate(
CurvedAnimation(parent: _pulseController, curve: Curves.easeInOut),
);
WidgetsBinding.instance.addPostFrameCallback((_) {
context.read<FaceProvider>().reset();
});
@ -65,18 +71,16 @@ class _FaceEnrollmentScreenState extends State<FaceEnrollmentScreen> {
_cameraController!.startImageStream((CameraImage image) {
if (!mounted) return;
final provider = context.read<FaceProvider>();
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<FaceEnrollmentScreen> {
@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<void> _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<FaceProvider>();
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<FaceEnrollmentScreen> {
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<FaceProvider>(
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<Color>(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<Offset>(
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<Color>(
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<FaceEnrollmentScreen> {
Navigator.pushReplacement(
context,
MaterialPageRoute(
builder: (_) => const SignatureScreen(isOnboarding: true),
builder: (_) =>
const SignatureScreen(isOnboarding: true),
),
);
} else {
@ -367,6 +546,46 @@ class _FaceEnrollmentScreenState extends State<FaceEnrollmentScreen> {
},
),
),
// 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<FaceEnrollmentScreen> {
);
}
Future<void> _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<FaceProvider>();
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;
}
}
}

View File

@ -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<CameraCaptureScreen> {
CameraController? _controller;
Future<void>? _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<void> _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<void> _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<CameraCaptureScreen> {
_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<CameraCaptureScreen> {
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<CameraCaptureScreen> {
),
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<CameraCaptureScreen> {
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<CameraCaptureScreen> {
)
: 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<CameraCaptureScreen> {
),
);
}
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)),
],
),
);
}
}

View File

@ -965,10 +965,10 @@ class _PresensiMapScreenState extends State<PresensiMapScreen> {
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),
),
],

View File

@ -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<FaceTestScreen> {
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<void> _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<void> _captureAndVerify() async {
if (_cameraController == null || _isVerifying) return;
if (_cameraController == null || _isVerifying || !_allChecksValid) return;
setState(() {
_isVerifying = true;
@ -68,10 +151,15 @@ class _FaceTestScreenState extends State<FaceTestScreen> {
});
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<FaceTestScreen> {
_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<FaceTestScreen> {
_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<FaceTestScreen> {
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<FaceTestScreen> {
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<FaceTestScreen> {
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<FaceTestScreen> {
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<FaceTestScreen> {
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<FaceTestScreen> {
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<FaceTestScreen> {
),
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<FaceTestScreen> {
),
),
),
if (_errorMessage != null)
Padding(
padding: const EdgeInsets.only(top: 12),
@ -344,11 +502,16 @@ class _FaceTestScreenState extends State<FaceTestScreen> {
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<FaceTestScreen> {
),
);
}
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)),
],
),
);
}
}