update
This commit is contained in:
parent
0fbbc1915d
commit
53255d8d43
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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': '',
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue