update
This commit is contained in:
parent
0fbbc1915d
commit
53255d8d43
|
|
@ -16,10 +16,11 @@ class ApiUrl {
|
||||||
return _cachedImageBaseUrl!;
|
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 String get imageBaseUrl => _cachedImageBaseUrl ?? baseUrl.replaceAll('/api', '/storage/');
|
||||||
|
|
||||||
static Future<void> initialize() async {
|
static Future<void> initialize() async {
|
||||||
|
await ApiConfigService.clearConfig();
|
||||||
await getBaseUrl();
|
await getBaseUrl();
|
||||||
await getImageBaseUrl();
|
await getImageBaseUrl();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ class ApiConfigService {
|
||||||
static const String _selectedPresetKey = 'selected_preset';
|
static const String _selectedPresetKey = 'selected_preset';
|
||||||
|
|
||||||
static const Map<String, String> presets = {
|
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',
|
'hostname': 'http://LAPTOP-I0SUKSKL:8000/api',
|
||||||
'emulator': 'http://10.0.2.2:8000/api',
|
'emulator': 'http://10.0.2.2:8000/api',
|
||||||
'custom': '',
|
'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:io';
|
||||||
import 'dart:ui' show Rect;
|
|
||||||
import 'package:camera/camera.dart';
|
import 'package:camera/camera.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:google_mlkit_face_detection/google_mlkit_face_detection.dart';
|
import 'package:google_mlkit_face_detection/google_mlkit_face_detection.dart';
|
||||||
import '../repositories/face_repository.dart';
|
import '../repositories/face_repository.dart';
|
||||||
import '../core/utils/camera_utils.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 FaceDetectionStatus { noFace, detected }
|
||||||
enum FaceStatus {
|
|
||||||
noFace, // Tidak ada wajah terdeteksi
|
enum EnrollmentStep { front, right, left }
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
class FaceProvider with ChangeNotifier {
|
class FaceProvider with ChangeNotifier {
|
||||||
final FaceRepository _repository = FaceRepository();
|
final FaceRepository _repository = FaceRepository();
|
||||||
|
|
@ -26,63 +19,222 @@ class FaceProvider with ChangeNotifier {
|
||||||
enableClassification: false,
|
enableClassification: false,
|
||||||
enableLandmarks: false,
|
enableLandmarks: false,
|
||||||
enableContours: false,
|
enableContours: false,
|
||||||
enableTracking: true,
|
enableTracking: false,
|
||||||
performanceMode: FaceDetectorMode.accurate,
|
performanceMode: FaceDetectorMode.fast,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
EnrollmentStep _currentStep = EnrollmentStep.depan;
|
VideoRecordingState _recordingState = VideoRecordingState.idle;
|
||||||
bool _isProcessing = false;
|
FaceDetectionStatus _faceDetectionStatus = FaceDetectionStatus.noFace;
|
||||||
bool _isUploading = false;
|
File? _videoFile;
|
||||||
String _instructionText = "Hadapkan wajah lurus ke depan";
|
String _message = '';
|
||||||
FaceStatus _faceStatus = FaceStatus.noFace;
|
String _errorMessage = '';
|
||||||
|
bool _isProcessingFrame = false;
|
||||||
|
|
||||||
// Hold timer: pose harus dipertahankan selama ini sebelum capture
|
// Guided enrollment
|
||||||
static const int _holdDurationMs = 1500;
|
EnrollmentStep _currentStep = EnrollmentStep.front;
|
||||||
DateTime? _poseValidSince;
|
double _headEulerAngleY = 0.0;
|
||||||
double _holdProgress = 0.0; // 0.0 - 1.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;
|
static const double _frontDuration = 4.0;
|
||||||
File? _fotoKanan;
|
static const double _sideDuration = 3.0;
|
||||||
File? _fotoKiri;
|
static const double _tickInterval = 100;
|
||||||
File? _fotoBawah;
|
|
||||||
|
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;
|
EnrollmentStep get currentStep => _currentStep;
|
||||||
bool get isUploading => _isUploading;
|
double get headEulerAngleY => _headEulerAngleY;
|
||||||
String get instructionText => _instructionText;
|
bool get isPoseValid => _isPoseValid;
|
||||||
FaceStatus get faceStatus => _faceStatus;
|
double get faceRatio => _faceRatio;
|
||||||
double get holdProgress => _holdProgress;
|
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() {
|
void reset() {
|
||||||
_currentStep = EnrollmentStep.depan;
|
_recordingState = VideoRecordingState.idle;
|
||||||
_instructionText = "Hadapkan wajah lurus ke depan";
|
_faceDetectionStatus = FaceDetectionStatus.noFace;
|
||||||
_fotoDepan = null;
|
_videoFile = null;
|
||||||
_fotoKanan = null;
|
_message = '';
|
||||||
_fotoKiri = null;
|
_errorMessage = '';
|
||||||
_fotoBawah = null;
|
_isProcessingFrame = false;
|
||||||
_isProcessing = false;
|
_currentStep = EnrollmentStep.front;
|
||||||
_isUploading = false;
|
_headEulerAngleY = 0.0;
|
||||||
_faceStatus = FaceStatus.noFace;
|
_isPoseValid = false;
|
||||||
_poseValidSince = null;
|
_faceRatio = 0.0;
|
||||||
_holdProgress = 0.0;
|
_isDistanceOk = false;
|
||||||
|
_brightness = 0.0;
|
||||||
|
_isBrightnessOk = false;
|
||||||
|
_stepProgress = 0.0;
|
||||||
|
_progressTimer?.cancel();
|
||||||
|
_progressTimer = null;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> processCameraImage(
|
Future<void> startRecording(CameraController controller) async {
|
||||||
CameraImage image,
|
if (_recordingState == VideoRecordingState.recording) return;
|
||||||
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;
|
|
||||||
|
|
||||||
try {
|
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(
|
final inputImage = CameraUtils.inputImageFromCameraImage(
|
||||||
image: image,
|
image: image,
|
||||||
camera: camera,
|
camera: camera,
|
||||||
|
|
@ -91,217 +243,40 @@ class FaceProvider with ChangeNotifier {
|
||||||
|
|
||||||
final List<Face> faces = await _faceDetector.processImage(inputImage);
|
final List<Face> faces = await _faceDetector.processImage(inputImage);
|
||||||
|
|
||||||
if (faces.isEmpty) {
|
if (faces.isNotEmpty) {
|
||||||
_updateFaceStatus(FaceStatus.noFace);
|
final face = faces.first;
|
||||||
_resetHoldTimer();
|
_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 {
|
} else {
|
||||||
final Face face = faces.first;
|
_faceDetectionStatus = FaceDetectionStatus.noFace;
|
||||||
_validateAndCheckPose(face, image, scanBoxLeft, scanBoxRight, scanBoxTop, scanBoxBottom);
|
_isPoseValid = false;
|
||||||
|
_isDistanceOk = false;
|
||||||
|
_faceRatio = 0.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
notifyListeners();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint("Error processing face: $e");
|
debugPrint("Error processing face: $e");
|
||||||
} finally {
|
} finally {
|
||||||
_isProcessing = false;
|
_isProcessingFrame = 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();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -320,6 +295,7 @@ class FaceProvider with ChangeNotifier {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
_progressTimer?.cancel();
|
||||||
_faceDetector.close();
|
_faceDetector.close();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,13 @@
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'package:http_parser/http_parser.dart';
|
||||||
import '../core/constants/api_url.dart';
|
import '../core/constants/api_url.dart';
|
||||||
import '../core/cache_manager.dart';
|
import '../core/cache_manager.dart';
|
||||||
|
|
||||||
class FaceRepository {
|
class FaceRepository {
|
||||||
Future<Map<String, dynamic>> enrollFace({
|
Future<Map<String, dynamic>> enrollFace({
|
||||||
required File fotoDepan,
|
required File videoFile,
|
||||||
required File fotoKanan,
|
|
||||||
required File fotoKiri,
|
|
||||||
required File fotoBawah,
|
|
||||||
}) async {
|
}) async {
|
||||||
try {
|
try {
|
||||||
final token = CacheManager.authBox.get('auth_token');
|
final token = CacheManager.authBox.get('auth_token');
|
||||||
|
|
@ -24,25 +22,34 @@ class FaceRepository {
|
||||||
'Accept': 'application/json',
|
'Accept': 'application/json',
|
||||||
});
|
});
|
||||||
|
|
||||||
request.files.add(await http.MultipartFile.fromPath('foto_depan', fotoDepan.path));
|
request.files.add(await http.MultipartFile.fromPath(
|
||||||
request.files.add(await http.MultipartFile.fromPath('foto_kanan', fotoKanan.path));
|
'video_wajah',
|
||||||
request.files.add(await http.MultipartFile.fromPath('foto_kiri', fotoKiri.path));
|
videoFile.path,
|
||||||
request.files.add(await http.MultipartFile.fromPath('foto_bawah', fotoBawah.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);
|
var response = await http.Response.fromStream(streamedResponse);
|
||||||
|
|
||||||
|
final body = jsonDecode(response.body);
|
||||||
|
|
||||||
if (response.statusCode == 200 || response.statusCode == 201) {
|
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 {
|
} else {
|
||||||
throw Exception('Gagal mendaftarkan wajah: ${response.statusCode}');
|
throw Exception(body['message'] ?? 'Gagal mendaftarkan wajah: ${response.statusCode}');
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
rethrow;
|
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');
|
final token = CacheManager.authBox.get('auth_token');
|
||||||
var request = http.MultipartRequest(
|
var request = http.MultipartRequest(
|
||||||
'POST',
|
'POST',
|
||||||
|
|
@ -55,6 +62,7 @@ class FaceRepository {
|
||||||
});
|
});
|
||||||
|
|
||||||
request.files.add(await http.MultipartFile.fromPath('foto', imageFile.path));
|
request.files.add(await http.MultipartFile.fromPath('foto', imageFile.path));
|
||||||
|
request.fields['tipe'] = tipe;
|
||||||
|
|
||||||
var streamedResponse = await request.send();
|
var streamedResponse = await request.send();
|
||||||
var response = await http.Response.fromStream(streamedResponse);
|
var response = await http.Response.fromStream(streamedResponse);
|
||||||
|
|
|
||||||
|
|
@ -21,23 +21,29 @@ class FaceEnrollmentScreen extends StatefulWidget {
|
||||||
State<FaceEnrollmentScreen> createState() => _FaceEnrollmentScreenState();
|
State<FaceEnrollmentScreen> createState() => _FaceEnrollmentScreenState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _FaceEnrollmentScreenState extends State<FaceEnrollmentScreen> {
|
class _FaceEnrollmentScreenState extends State<FaceEnrollmentScreen>
|
||||||
|
with TickerProviderStateMixin {
|
||||||
CameraController? _cameraController;
|
CameraController? _cameraController;
|
||||||
bool _isCameraInitialized = false;
|
bool _isCameraInitialized = false;
|
||||||
CameraDescription? _frontCamera;
|
CameraDescription? _frontCamera;
|
||||||
int _lastStepIndex = -1;
|
|
||||||
|
|
||||||
// Koordinat kotak scan (0.0 - 1.0) — harus cocok dengan _ScannerPainter
|
late AnimationController _pulseController;
|
||||||
// ScannerOverlay: lebar 75% (0.125 - 0.875), tinggi 45% dari tengah (0.275 - 0.725)
|
late Animation<double> _pulseAnimation;
|
||||||
static const double _boxLeft = 0.125;
|
|
||||||
static const double _boxRight = 0.875;
|
|
||||||
static const double _boxTop = 0.275;
|
|
||||||
static const double _boxBottom = 0.725;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_initializeCamera();
|
_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((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
context.read<FaceProvider>().reset();
|
context.read<FaceProvider>().reset();
|
||||||
});
|
});
|
||||||
|
|
@ -65,18 +71,16 @@ class _FaceEnrollmentScreenState extends State<FaceEnrollmentScreen> {
|
||||||
|
|
||||||
_cameraController!.startImageStream((CameraImage image) {
|
_cameraController!.startImageStream((CameraImage image) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
||||||
final provider = context.read<FaceProvider>();
|
final provider = context.read<FaceProvider>();
|
||||||
|
if (provider.recordingState == VideoRecordingState.idle ||
|
||||||
provider.processCameraImage(
|
provider.recordingState == VideoRecordingState.recording) {
|
||||||
image,
|
provider.processCameraImage(
|
||||||
_frontCamera!,
|
image,
|
||||||
CameraUtils.rotationIntToImageRotation(_frontCamera!.sensorOrientation),
|
_frontCamera!,
|
||||||
scanBoxLeft: _boxLeft,
|
CameraUtils.rotationIntToImageRotation(
|
||||||
scanBoxRight: _boxRight,
|
_frontCamera!.sensorOrientation),
|
||||||
scanBoxTop: _boxTop,
|
);
|
||||||
scanBoxBottom: _boxBottom,
|
}
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
|
|
@ -87,44 +91,36 @@ class _FaceEnrollmentScreenState extends State<FaceEnrollmentScreen> {
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_cameraController?.dispose();
|
_cameraController?.dispose();
|
||||||
|
_pulseController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Teks panduan berdasarkan FaceStatus
|
Future<void> _handleStartRecording(FaceProvider provider) async {
|
||||||
String _getStatusText(FaceProvider provider) {
|
if (_cameraController == null || !_cameraController!.value.isInitialized) {
|
||||||
switch (provider.faceStatus) {
|
return;
|
||||||
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...";
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Warna border kotak berdasarkan status
|
try {
|
||||||
Color _getBorderColor(FaceProvider provider) {
|
await _cameraController!.stopImageStream();
|
||||||
if (provider.currentStep == EnrollmentStep.selesai) {
|
} catch (_) {}
|
||||||
return AppTheme.statusGreen;
|
|
||||||
}
|
await Future.delayed(const Duration(milliseconds: 100));
|
||||||
switch (provider.faceStatus) {
|
|
||||||
case FaceStatus.holding:
|
_cameraController!.startImageStream((CameraImage image) {
|
||||||
return AppTheme.statusGreen;
|
if (!mounted) return;
|
||||||
case FaceStatus.outOfFrame:
|
final p = context.read<FaceProvider>();
|
||||||
case FaceStatus.tooFar:
|
if (p.recordingState == VideoRecordingState.recording) {
|
||||||
case FaceStatus.tooClose:
|
p.processCameraImage(
|
||||||
return AppTheme.statusRed;
|
image,
|
||||||
default:
|
_frontCamera!,
|
||||||
return AppTheme.primaryOrange;
|
CameraUtils.rotationIntToImageRotation(
|
||||||
}
|
_frontCamera!.sensorOrientation),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
HapticFeedback.mediumImpact();
|
||||||
|
await provider.startRecording(_cameraController!);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -142,214 +138,396 @@ class _FaceEnrollmentScreenState extends State<FaceEnrollmentScreen> {
|
||||||
color: Colors.black.withOpacity(0.5),
|
color: Colors.black.withOpacity(0.5),
|
||||||
shape: BoxShape.circle,
|
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),
|
onPressed: () => Navigator.pop(context),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
body: Consumer<FaceProvider>(
|
body: Consumer<FaceProvider>(
|
||||||
builder: (context, provider, child) {
|
builder: (context, provider, child) {
|
||||||
// Deteksi step baru → ambil foto
|
final bool isIdle =
|
||||||
if (provider.currentStep.index > _lastStepIndex && _lastStepIndex != -1) {
|
provider.recordingState == VideoRecordingState.idle;
|
||||||
HapticFeedback.lightImpact();
|
final bool isRecording =
|
||||||
_takePictureAndSave(provider);
|
provider.recordingState == VideoRecordingState.recording;
|
||||||
}
|
final bool isUploading =
|
||||||
if (_lastStepIndex != provider.currentStep.index) {
|
provider.recordingState == VideoRecordingState.uploading;
|
||||||
_lastStepIndex = provider.currentStep.index;
|
final bool isSuccess =
|
||||||
}
|
provider.recordingState == VideoRecordingState.success;
|
||||||
|
final bool isError =
|
||||||
|
provider.recordingState == VideoRecordingState.error;
|
||||||
|
|
||||||
final bool isComplete = provider.currentStep == EnrollmentStep.selesai;
|
Color scanColor = AppTheme.primaryOrange;
|
||||||
final bool isHolding = provider.faceStatus == FaceStatus.holding;
|
if (isIdle || isRecording) {
|
||||||
final Color scanColor = _getBorderColor(provider);
|
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(
|
return Stack(
|
||||||
children: [
|
children: [
|
||||||
// --- Preview Kamera ---
|
|
||||||
if (_isCameraInitialized)
|
if (_isCameraInitialized)
|
||||||
SizedBox.expand(
|
SizedBox.expand(
|
||||||
child: FittedBox(
|
child: FittedBox(
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
width: _cameraController!.value.previewSize?.height ?? MediaQuery.of(context).size.width,
|
width: _cameraController!.value.previewSize?.height ??
|
||||||
height: _cameraController!.value.previewSize?.width ?? MediaQuery.of(context).size.height,
|
MediaQuery.of(context).size.width,
|
||||||
|
height: _cameraController!.value.previewSize?.width ??
|
||||||
|
MediaQuery.of(context).size.height,
|
||||||
child: CameraPreview(_cameraController!),
|
child: CameraPreview(_cameraController!),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
const Center(child: CircularProgressIndicator(color: Colors.white)),
|
const Center(
|
||||||
|
child:
|
||||||
|
CircularProgressIndicator(color: Colors.white)),
|
||||||
|
|
||||||
// --- Overlay Kotak Scan ---
|
|
||||||
ScannerOverlay(
|
ScannerOverlay(
|
||||||
borderColor: scanColor,
|
borderColor: scanColor,
|
||||||
isScanning: !isComplete && !provider.isUploading && !isHolding,
|
isScanning: isIdle || isRecording,
|
||||||
),
|
),
|
||||||
|
|
||||||
// --- Hold Progress Ring (di tengah kotak scan) ---
|
// --- REC indicator ---
|
||||||
if (isHolding && !isComplete)
|
if (isRecording)
|
||||||
Center(
|
Positioned(
|
||||||
child: Container(
|
top: MediaQuery.of(context).padding.top + kToolbarHeight + 10,
|
||||||
margin: const EdgeInsets.only(top: 0),
|
right: 20,
|
||||||
child: SizedBox(
|
child: AnimatedBuilder(
|
||||||
width: 80,
|
animation: _pulseAnimation,
|
||||||
height: 80,
|
builder: (context, child) {
|
||||||
child: CircularProgressIndicator(
|
return Opacity(
|
||||||
value: provider.holdProgress,
|
opacity: _pulseAnimation.value,
|
||||||
strokeWidth: 5,
|
child: Container(
|
||||||
backgroundColor: Colors.white24,
|
padding: const EdgeInsets.symmetric(
|
||||||
valueColor: const AlwaysStoppedAnimation<Color>(AppTheme.statusGreen),
|
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(
|
Positioned(
|
||||||
top: MediaQuery.of(context).padding.top + kToolbarHeight + 20,
|
top: MediaQuery.of(context).padding.top + kToolbarHeight + 20,
|
||||||
left: 20,
|
left: 20,
|
||||||
right: 20,
|
right: 70,
|
||||||
child: Column(
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
"Pendaftaran Wajah",
|
"Pendaftaran Wajah",
|
||||||
style: AppTheme.heading3.copyWith(color: Colors.white),
|
style:
|
||||||
|
AppTheme.heading3.copyWith(color: Colors.white),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
AnimatedSwitcher(
|
|
||||||
duration: const Duration(milliseconds: 250),
|
// Quality checklist chips
|
||||||
child: ClipRRect(
|
if (isIdle || isRecording)
|
||||||
key: ValueKey(provider.faceStatus),
|
Wrap(
|
||||||
borderRadius: BorderRadius.circular(20),
|
spacing: 6,
|
||||||
child: BackdropFilter(
|
runSpacing: 6,
|
||||||
filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10),
|
children: [
|
||||||
child: Container(
|
_buildQualityChip(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
icon: Icons.straighten,
|
||||||
decoration: BoxDecoration(
|
label: 'Jarak',
|
||||||
color: isHolding
|
isOk: provider.isDistanceOk,
|
||||||
? AppTheme.statusGreen.withOpacity(0.25)
|
isDetected: provider.isFaceDetected,
|
||||||
: Colors.white.withOpacity(0.15),
|
),
|
||||||
borderRadius: BorderRadius.circular(20),
|
_buildQualityChip(
|
||||||
border: Border.all(
|
icon: Icons.wb_sunny_outlined,
|
||||||
color: isHolding
|
label: 'Cahaya',
|
||||||
? AppTheme.statusGreen.withOpacity(0.6)
|
isOk: provider.isBrightnessOk,
|
||||||
: Colors.white.withOpacity(0.3),
|
isDetected: provider.isFaceDetected,
|
||||||
),
|
),
|
||||||
),
|
_buildQualityChip(
|
||||||
child: Row(
|
icon: Icons.face,
|
||||||
mainAxisSize: MainAxisSize.min,
|
label: provider.stepLabel,
|
||||||
children: [
|
isOk: provider.isPoseValid,
|
||||||
Icon(
|
isDetected: provider.isFaceDetected,
|
||||||
isComplete
|
),
|
||||||
? Icons.check_circle
|
],
|
||||||
: isHolding
|
),
|
||||||
? Icons.timer
|
|
||||||
: Icons.face,
|
// Status banner for uploading/success/error
|
||||||
color: isComplete
|
if (!isIdle && !isRecording)
|
||||||
? AppTheme.statusGreen
|
AnimatedSwitcher(
|
||||||
: isHolding
|
duration: const Duration(milliseconds: 250),
|
||||||
? AppTheme.statusGreen
|
child: ClipRRect(
|
||||||
: AppTheme.primaryOrange,
|
key: ValueKey(provider.recordingState),
|
||||||
size: 20,
|
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: Row(
|
||||||
child: Text(
|
mainAxisSize: MainAxisSize.min,
|
||||||
isComplete
|
children: [
|
||||||
? provider.instructionText
|
Icon(
|
||||||
: _getStatusText(provider),
|
_getStatusIcon(provider),
|
||||||
textAlign: TextAlign.center,
|
color: _getStatusColor(provider),
|
||||||
style: AppTheme.bodyLarge.copyWith(
|
size: 20,
|
||||||
color: isComplete
|
),
|
||||||
? AppTheme.statusGreen
|
const SizedBox(width: 8),
|
||||||
: isHolding
|
Flexible(
|
||||||
? AppTheme.statusGreen
|
child: Text(
|
||||||
: Colors.white,
|
_getStatusText(provider),
|
||||||
fontWeight: FontWeight.bold,
|
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(
|
Positioned(
|
||||||
bottom: 40,
|
bottom: 40,
|
||||||
left: 20,
|
left: 20,
|
||||||
right: 20,
|
right: 20,
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
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(
|
Container(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(24),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.black54,
|
color: Colors.black54,
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
const CircularProgressIndicator(color: AppTheme.primaryOrange),
|
const CircularProgressIndicator(
|
||||||
const SizedBox(height: 12),
|
color: AppTheme.primaryOrange),
|
||||||
Text("Mengunggah data wajah...", style: AppTheme.bodySmall.copyWith(color: Colors.white)),
|
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(
|
||||||
padding: const EdgeInsets.only(top: 24),
|
padding: const EdgeInsets.only(top: 24),
|
||||||
child: CustomButton(
|
child: CustomButton(
|
||||||
text: widget.isOnboarding ? "Lanjut: Tanda Tangan" : "Selesai",
|
text: widget.isOnboarding
|
||||||
icon: widget.isOnboarding ? Icons.arrow_forward : Icons.check,
|
? "Lanjut: Tanda Tangan"
|
||||||
|
: "Selesai",
|
||||||
|
icon: widget.isOnboarding
|
||||||
|
? Icons.arrow_forward
|
||||||
|
: Icons.check,
|
||||||
backgroundColor: AppTheme.statusGreen,
|
backgroundColor: AppTheme.statusGreen,
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
HapticFeedback.mediumImpact();
|
HapticFeedback.mediumImpact();
|
||||||
|
|
@ -358,7 +536,8 @@ class _FaceEnrollmentScreenState extends State<FaceEnrollmentScreen> {
|
||||||
Navigator.pushReplacement(
|
Navigator.pushReplacement(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
builder: (_) => const SignatureScreen(isOnboarding: true),
|
builder: (_) =>
|
||||||
|
const SignatureScreen(isOnboarding: true),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
} else {
|
} 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 {
|
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();
|
String _getStatusText(FaceProvider provider) {
|
||||||
provider.saveCapturedFile(File(file.path));
|
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) {
|
IconData _getStatusIcon(FaceProvider provider) {
|
||||||
if (!mounted) return;
|
switch (provider.recordingState) {
|
||||||
provider.processCameraImage(
|
case VideoRecordingState.idle:
|
||||||
image,
|
return Icons.face;
|
||||||
_frontCamera!,
|
case VideoRecordingState.recording:
|
||||||
CameraUtils.rotationIntToImageRotation(_frontCamera!.sensorOrientation),
|
return Icons.videocam;
|
||||||
scanBoxLeft: _boxLeft,
|
case VideoRecordingState.uploading:
|
||||||
scanBoxRight: _boxRight,
|
return Icons.cloud_upload;
|
||||||
scanBoxTop: _boxTop,
|
case VideoRecordingState.success:
|
||||||
scanBoxBottom: _boxBottom,
|
return Icons.check_circle;
|
||||||
);
|
case VideoRecordingState.error:
|
||||||
});
|
return Icons.error;
|
||||||
} catch (e) {
|
}
|
||||||
debugPrint("Error capturing image: $e");
|
}
|
||||||
|
|
||||||
|
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:camera/camera.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:google_mlkit_face_detection/google_mlkit_face_detection.dart';
|
||||||
import '../../core/theme.dart';
|
import '../../core/theme.dart';
|
||||||
|
import '../../core/utils/camera_utils.dart';
|
||||||
import '../../widgets/atoms/custom_button.dart';
|
import '../../widgets/atoms/custom_button.dart';
|
||||||
import '../../widgets/atoms/scanner_overlay.dart';
|
import '../../widgets/atoms/scanner_overlay.dart';
|
||||||
|
|
||||||
|
|
@ -16,48 +18,127 @@ class CameraCaptureScreen extends StatefulWidget {
|
||||||
|
|
||||||
class _CameraCaptureScreenState extends State<CameraCaptureScreen> {
|
class _CameraCaptureScreenState extends State<CameraCaptureScreen> {
|
||||||
CameraController? _controller;
|
CameraController? _controller;
|
||||||
Future<void>? _initializeControllerFuture;
|
CameraDescription? _frontCamera;
|
||||||
XFile? _capturedImage;
|
XFile? _capturedImage;
|
||||||
bool _isCameraInitialized = false;
|
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
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
_faceDetector = FaceDetector(
|
||||||
|
options: FaceDetectorOptions(
|
||||||
|
enableClassification: false,
|
||||||
|
enableLandmarks: false,
|
||||||
|
enableContours: false,
|
||||||
|
enableTracking: false,
|
||||||
|
performanceMode: FaceDetectorMode.fast,
|
||||||
|
),
|
||||||
|
);
|
||||||
_initializeCamera();
|
_initializeCamera();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _initializeCamera() async {
|
Future<void> _initializeCamera() async {
|
||||||
final cameras = await availableCameras();
|
final cameras = await availableCameras();
|
||||||
final firstCamera = cameras.firstWhere(
|
_frontCamera = cameras.firstWhere(
|
||||||
(camera) => camera.lensDirection == CameraLensDirection.front,
|
(camera) => camera.lensDirection == CameraLensDirection.front,
|
||||||
orElse: () => cameras.first,
|
orElse: () => cameras.first,
|
||||||
);
|
);
|
||||||
|
|
||||||
_controller = CameraController(
|
_controller = CameraController(
|
||||||
firstCamera,
|
_frontCamera!,
|
||||||
ResolutionPreset.medium,
|
ResolutionPreset.medium,
|
||||||
enableAudio: false,
|
enableAudio: false,
|
||||||
|
imageFormatGroup: Platform.isAndroid
|
||||||
|
? ImageFormatGroup.nv21
|
||||||
|
: ImageFormatGroup.bgra8888,
|
||||||
);
|
);
|
||||||
|
|
||||||
_initializeControllerFuture = _controller!.initialize();
|
await _controller!.initialize();
|
||||||
await _initializeControllerFuture;
|
|
||||||
|
|
||||||
if (mounted) {
|
if (!mounted) return;
|
||||||
setState(() {
|
|
||||||
_isCameraInitialized = true;
|
_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
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_controller?.dispose();
|
_controller?.dispose();
|
||||||
|
_faceDetector.close();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _takePicture() async {
|
Future<void> _takePicture() async {
|
||||||
|
if (!_allChecksValid) return;
|
||||||
try {
|
try {
|
||||||
await _initializeControllerFuture;
|
try {
|
||||||
|
await _controller!.stopImageStream();
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
final image = await _controller!.takePicture();
|
final image = await _controller!.takePicture();
|
||||||
HapticFeedback.lightImpact();
|
HapticFeedback.lightImpact();
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
@ -66,13 +147,21 @@ class _CameraCaptureScreenState extends State<CameraCaptureScreen> {
|
||||||
_capturedImage = image;
|
_capturedImage = image;
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print(e);
|
debugPrint("Error taking picture: $e");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _retakePicture() {
|
void _retakePicture() {
|
||||||
setState(() {
|
setState(() {
|
||||||
_capturedImage = null;
|
_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(
|
child: FittedBox(
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
width: _controller!.value.previewSize?.height ?? MediaQuery.of(context).size.width,
|
width: _controller!.value.previewSize?.height ??
|
||||||
height: _controller!.value.previewSize?.width ?? MediaQuery.of(context).size.height,
|
MediaQuery.of(context).size.width,
|
||||||
|
height: _controller!.value.previewSize?.width ??
|
||||||
|
MediaQuery.of(context).size.height,
|
||||||
child: CameraPreview(_controller!),
|
child: CameraPreview(_controller!),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
const Center(child: CircularProgressIndicator(color: Colors.white)),
|
const Center(
|
||||||
|
child: CircularProgressIndicator(color: Colors.white)),
|
||||||
|
|
||||||
if (_capturedImage != null)
|
if (_capturedImage != null)
|
||||||
SizedBox.expand(
|
SizedBox.expand(
|
||||||
child: Image.file(
|
child: Image.file(
|
||||||
File(_capturedImage!.path),
|
File(_capturedImage!.path),
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
|
|
@ -127,7 +219,13 @@ class _CameraCaptureScreenState extends State<CameraCaptureScreen> {
|
||||||
),
|
),
|
||||||
|
|
||||||
ScannerOverlay(
|
ScannerOverlay(
|
||||||
borderColor: _capturedImage != null ? AppTheme.statusGreen : AppTheme.primaryBlue,
|
borderColor: _capturedImage != null
|
||||||
|
? AppTheme.statusGreen
|
||||||
|
: _allChecksValid
|
||||||
|
? AppTheme.statusGreen
|
||||||
|
: _isFaceDetected
|
||||||
|
? AppTheme.statusYellow
|
||||||
|
: AppTheme.primaryBlue,
|
||||||
isScanning: _capturedImage == null,
|
isScanning: _capturedImage == null,
|
||||||
),
|
),
|
||||||
|
|
||||||
|
|
@ -147,25 +245,47 @@ class _CameraCaptureScreenState extends State<CameraCaptureScreen> {
|
||||||
child: BackdropFilter(
|
child: BackdropFilter(
|
||||||
filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10),
|
filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10),
|
||||||
child: Container(
|
child: Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 20, vertical: 10),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white.withOpacity(0.15),
|
color: Colors.white.withOpacity(0.15),
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(20),
|
||||||
border: Border.all(color: Colors.white.withOpacity(0.3)),
|
border: Border.all(
|
||||||
|
color: Colors.white.withOpacity(0.3)),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
_capturedImage != null
|
_capturedImage != null
|
||||||
? "Foto berhasil diambil ✓"
|
? "Foto berhasil diambil ✓"
|
||||||
: "Posisikan wajah di dalam bingkai",
|
: _qualityMessage,
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: AppTheme.bodyLarge.copyWith(
|
style: AppTheme.bodyLarge.copyWith(
|
||||||
color: _capturedImage != null ? AppTheme.statusGreen : Colors.white,
|
color: _capturedImage != null
|
||||||
|
? AppTheme.statusGreen
|
||||||
|
: _allChecksValid
|
||||||
|
? AppTheme.statusGreen
|
||||||
|
: Colors.white,
|
||||||
fontWeight: FontWeight.bold,
|
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(
|
: Center(
|
||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
onTap: _takePicture,
|
onTap: _allChecksValid ? _takePicture : null,
|
||||||
child: Container(
|
child: Container(
|
||||||
width: 76,
|
width: 76,
|
||||||
height: 76,
|
height: 76,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
border: Border.all(color: Colors.white.withOpacity(0.8), width: 6),
|
border: Border.all(
|
||||||
color: Colors.white.withOpacity(0.2),
|
color: _allChecksValid
|
||||||
boxShadow: [
|
? Colors.white.withOpacity(0.8)
|
||||||
BoxShadow(
|
: Colors.white.withOpacity(0.3),
|
||||||
color: Colors.black.withOpacity(0.2),
|
width: 6,
|
||||||
blurRadius: 10,
|
),
|
||||||
)
|
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(
|
child: Container(
|
||||||
margin: const EdgeInsets.all(6),
|
margin: const EdgeInsets.all(6),
|
||||||
decoration: const BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
shape: BoxShape.circle,
|
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,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(message, style: AppTheme.bodyMedium),
|
Text(message, style: AppTheme.bodyMedium),
|
||||||
if (confidence != null && confidence < 900) ...[
|
if (confidence != null && confidence < 1.0) ...[
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Text(
|
Text(
|
||||||
"Jarak LBPH: ${confidence.toStringAsFixed(1)}",
|
"Confidence: ${(confidence * 100).toStringAsFixed(1)}%",
|
||||||
style: AppTheme.bodySmall.copyWith(color: AppTheme.textSecondary),
|
style: AppTheme.bodySmall.copyWith(color: AppTheme.textSecondary),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,9 @@ import 'dart:ui';
|
||||||
import 'package:camera/camera.dart';
|
import 'package:camera/camera.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:google_mlkit_face_detection/google_mlkit_face_detection.dart';
|
||||||
import '../../core/theme.dart';
|
import '../../core/theme.dart';
|
||||||
|
import '../../core/utils/camera_utils.dart';
|
||||||
import '../../repositories/face_repository.dart';
|
import '../../repositories/face_repository.dart';
|
||||||
import '../../widgets/atoms/custom_button.dart';
|
import '../../widgets/atoms/custom_button.dart';
|
||||||
import '../../widgets/atoms/scanner_overlay.dart';
|
import '../../widgets/atoms/scanner_overlay.dart';
|
||||||
|
|
@ -17,49 +19,130 @@ class FaceTestScreen extends StatefulWidget {
|
||||||
|
|
||||||
class _FaceTestScreenState extends State<FaceTestScreen> {
|
class _FaceTestScreenState extends State<FaceTestScreen> {
|
||||||
CameraController? _cameraController;
|
CameraController? _cameraController;
|
||||||
|
CameraDescription? _frontCamera;
|
||||||
bool _isCameraInitialized = false;
|
bool _isCameraInitialized = false;
|
||||||
bool _isVerifying = false;
|
bool _isVerifying = false;
|
||||||
bool _showResult = 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;
|
bool? _verified;
|
||||||
double? _confidence;
|
double? _confidence;
|
||||||
|
double? _svmConfidence;
|
||||||
String? _statusText;
|
String? _statusText;
|
||||||
String? _errorMessage;
|
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
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
_faceDetector = FaceDetector(
|
||||||
|
options: FaceDetectorOptions(
|
||||||
|
enableClassification: false,
|
||||||
|
enableLandmarks: false,
|
||||||
|
enableContours: false,
|
||||||
|
enableTracking: false,
|
||||||
|
performanceMode: FaceDetectorMode.fast,
|
||||||
|
),
|
||||||
|
);
|
||||||
_initializeCamera();
|
_initializeCamera();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _initializeCamera() async {
|
Future<void> _initializeCamera() async {
|
||||||
try {
|
try {
|
||||||
final cameras = await availableCameras();
|
final cameras = await availableCameras();
|
||||||
final frontCamera = cameras.firstWhere(
|
_frontCamera = cameras.firstWhere(
|
||||||
(camera) => camera.lensDirection == CameraLensDirection.front,
|
(camera) => camera.lensDirection == CameraLensDirection.front,
|
||||||
orElse: () => cameras.first,
|
orElse: () => cameras.first,
|
||||||
);
|
);
|
||||||
|
|
||||||
_cameraController = CameraController(
|
_cameraController = CameraController(
|
||||||
frontCamera,
|
_frontCamera!,
|
||||||
ResolutionPreset.medium,
|
ResolutionPreset.medium,
|
||||||
enableAudio: false,
|
enableAudio: false,
|
||||||
|
imageFormatGroup: Platform.isAndroid
|
||||||
|
? ImageFormatGroup.nv21
|
||||||
|
: ImageFormatGroup.bgra8888,
|
||||||
);
|
);
|
||||||
|
|
||||||
await _cameraController!.initialize();
|
await _cameraController!.initialize();
|
||||||
|
|
||||||
if (mounted) {
|
if (!mounted) return;
|
||||||
setState(() {
|
|
||||||
_isCameraInitialized = true;
|
_startQualityStream();
|
||||||
});
|
|
||||||
}
|
setState(() {
|
||||||
|
_isCameraInitialized = true;
|
||||||
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint("Error initializing camera: $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 {
|
Future<void> _captureAndVerify() async {
|
||||||
if (_cameraController == null || _isVerifying) return;
|
if (_cameraController == null || _isVerifying || !_allChecksValid) return;
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_isVerifying = true;
|
_isVerifying = true;
|
||||||
|
|
@ -68,10 +151,15 @@ class _FaceTestScreenState extends State<FaceTestScreen> {
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
try {
|
||||||
|
await _cameraController!.stopImageStream();
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
final XFile photo = await _cameraController!.takePicture();
|
final XFile photo = await _cameraController!.takePicture();
|
||||||
final File imageFile = File(photo.path);
|
final File imageFile = File(photo.path);
|
||||||
|
|
||||||
final result = await FaceRepository().verifyFace(imageFile);
|
final result =
|
||||||
|
await FaceRepository().verifyFace(imageFile, tipe: 'test');
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
|
|
@ -79,8 +167,11 @@ class _FaceTestScreenState extends State<FaceTestScreen> {
|
||||||
_confidence = (result['confidence'] is num)
|
_confidence = (result['confidence'] is num)
|
||||||
? (result['confidence'] as num).toDouble()
|
? (result['confidence'] as num).toDouble()
|
||||||
: null;
|
: null;
|
||||||
_statusText = result['verification_status']?.toString()
|
_svmConfidence = (result['svm_confidence'] is num)
|
||||||
?? result['status']?.toString();
|
? (result['svm_confidence'] as num).toDouble()
|
||||||
|
: null;
|
||||||
|
_statusText = result['verification_status']?.toString() ??
|
||||||
|
result['status']?.toString();
|
||||||
_showResult = true;
|
_showResult = true;
|
||||||
_isVerifying = false;
|
_isVerifying = false;
|
||||||
});
|
});
|
||||||
|
|
@ -109,14 +200,21 @@ class _FaceTestScreenState extends State<FaceTestScreen> {
|
||||||
_showResult = false;
|
_showResult = false;
|
||||||
_verified = null;
|
_verified = null;
|
||||||
_confidence = null;
|
_confidence = null;
|
||||||
|
_svmConfidence = null;
|
||||||
_statusText = null;
|
_statusText = null;
|
||||||
_errorMessage = null;
|
_errorMessage = null;
|
||||||
|
_isFaceDetected = false;
|
||||||
|
_isDistanceOk = false;
|
||||||
|
_isBrightnessOk = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
_startQualityStream();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_cameraController?.dispose();
|
_cameraController?.dispose();
|
||||||
|
_faceDetector.close();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -135,7 +233,8 @@ class _FaceTestScreenState extends State<FaceTestScreen> {
|
||||||
color: Colors.black.withOpacity(0.5),
|
color: Colors.black.withOpacity(0.5),
|
||||||
shape: BoxShape.circle,
|
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),
|
onPressed: () => Navigator.pop(context),
|
||||||
),
|
),
|
||||||
|
|
@ -147,8 +246,10 @@ class _FaceTestScreenState extends State<FaceTestScreen> {
|
||||||
child: FittedBox(
|
child: FittedBox(
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
width: _cameraController!.value.previewSize?.height ?? MediaQuery.of(context).size.width,
|
width: _cameraController!.value.previewSize?.height ??
|
||||||
height: _cameraController!.value.previewSize?.width ?? MediaQuery.of(context).size.height,
|
MediaQuery.of(context).size.width,
|
||||||
|
height: _cameraController!.value.previewSize?.width ??
|
||||||
|
MediaQuery.of(context).size.height,
|
||||||
child: CameraPreview(_cameraController!),
|
child: CameraPreview(_cameraController!),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -160,8 +261,14 @@ class _FaceTestScreenState extends State<FaceTestScreen> {
|
||||||
|
|
||||||
ScannerOverlay(
|
ScannerOverlay(
|
||||||
borderColor: _showResult
|
borderColor: _showResult
|
||||||
? (_verified == true ? AppTheme.statusGreen : AppTheme.statusRed)
|
? (_verified == true
|
||||||
: AppTheme.primaryBlue,
|
? AppTheme.statusGreen
|
||||||
|
: AppTheme.statusRed)
|
||||||
|
: _allChecksValid
|
||||||
|
? AppTheme.statusGreen
|
||||||
|
: _isFaceDetected
|
||||||
|
? AppTheme.statusYellow
|
||||||
|
: AppTheme.primaryBlue,
|
||||||
isScanning: _isVerifying || (!_showResult && !_isVerifying),
|
isScanning: _isVerifying || (!_showResult && !_isVerifying),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
|
@ -181,29 +288,53 @@ class _FaceTestScreenState extends State<FaceTestScreen> {
|
||||||
child: BackdropFilter(
|
child: BackdropFilter(
|
||||||
filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10),
|
filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10),
|
||||||
child: Container(
|
child: Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 20, vertical: 10),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white.withOpacity(0.15),
|
color: Colors.white.withOpacity(0.15),
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(20),
|
||||||
border: Border.all(color: Colors.white.withOpacity(0.3)),
|
border: Border.all(
|
||||||
|
color: Colors.white.withOpacity(0.3)),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
_isVerifying
|
_isVerifying
|
||||||
? "Memverifikasi wajah..."
|
? "Memverifikasi wajah..."
|
||||||
: _showResult
|
: _showResult
|
||||||
? (_verified == true ? "Wajah Terverifikasi ✓" : "Wajah Tidak Cocok ✗")
|
? (_verified == true
|
||||||
: "Posisikan wajah di dalam bingkai",
|
? "Wajah Terverifikasi ✓"
|
||||||
|
: "Wajah Tidak Cocok ✗")
|
||||||
|
: _qualityMessage,
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: AppTheme.bodyLarge.copyWith(
|
style: AppTheme.bodyLarge.copyWith(
|
||||||
color: _showResult
|
color: _showResult
|
||||||
? (_verified == true ? AppTheme.statusGreen : AppTheme.statusRed)
|
? (_verified == true
|
||||||
: Colors.white,
|
? AppTheme.statusGreen
|
||||||
|
: AppTheme.statusRed)
|
||||||
|
: _allChecksValid
|
||||||
|
? AppTheme.statusGreen
|
||||||
|
: Colors.white,
|
||||||
fontWeight: FontWeight.bold,
|
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),
|
padding: const EdgeInsets.all(AppTheme.spacingLg),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white.withOpacity(0.9),
|
color: Colors.white.withOpacity(0.9),
|
||||||
borderRadius: BorderRadius.circular(AppTheme.radiusLg),
|
borderRadius:
|
||||||
border: Border.all(color: Colors.white.withOpacity(0.4)),
|
BorderRadius.circular(AppTheme.radiusLg),
|
||||||
|
border: Border.all(
|
||||||
|
color: Colors.white.withOpacity(0.4)),
|
||||||
boxShadow: [
|
boxShadow: [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: Colors.black.withOpacity(0.1),
|
color: Colors.black.withOpacity(0.1),
|
||||||
blurRadius: 20,
|
blurRadius: 20,
|
||||||
offset: const Offset(0, 10),
|
offset: const Offset(0, 10),
|
||||||
)
|
)
|
||||||
]
|
],
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
|
@ -240,37 +373,48 @@ class _FaceTestScreenState extends State<FaceTestScreen> {
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.all(12),
|
padding: const EdgeInsets.all(12),
|
||||||
decoration: BoxDecoration(
|
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,
|
shape: BoxShape.circle,
|
||||||
),
|
),
|
||||||
child: Icon(
|
child: Icon(
|
||||||
_verified == true ? Icons.check_circle : Icons.cancel,
|
_verified == true
|
||||||
color: _verified == true ? AppTheme.statusGreen : AppTheme.statusRed,
|
? Icons.check_circle
|
||||||
|
: Icons.cancel,
|
||||||
|
color: _verified == true
|
||||||
|
? AppTheme.statusGreen
|
||||||
|
: AppTheme.statusRed,
|
||||||
size: 40,
|
size: 40,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
_verified == true ? "Verifikasi Berhasil" : "Verifikasi Gagal",
|
_verified == true
|
||||||
|
? "Verifikasi Berhasil"
|
||||||
|
: "Verifikasi Gagal",
|
||||||
style: AppTheme.heading3.copyWith(
|
style: AppTheme.heading3.copyWith(
|
||||||
color: _verified == true ? AppTheme.statusGreen : AppTheme.statusRed,
|
color: _verified == true
|
||||||
|
? AppTheme.statusGreen
|
||||||
|
: AppTheme.statusRed,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
|
if (_confidence != null)
|
||||||
if (_confidence != null && _confidence! < 900)
|
|
||||||
Container(
|
Container(
|
||||||
margin: const EdgeInsets.only(top: 8),
|
margin: const EdgeInsets.only(top: 8),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 16, vertical: 8),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppTheme.bgLight,
|
color: AppTheme.bgLight,
|
||||||
borderRadius: BorderRadius.circular(AppTheme.radiusMd),
|
borderRadius: BorderRadius.circular(
|
||||||
|
AppTheme.radiusMd),
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
"Jarak LBPH: ${_confidence!.toStringAsFixed(1)}",
|
"Confidence: ${(_confidence! * 100).toStringAsFixed(1)}%",
|
||||||
style: AppTheme.bodyMedium.copyWith(
|
style: AppTheme.bodyMedium.copyWith(
|
||||||
color: AppTheme.textSecondary,
|
color: AppTheme.textSecondary,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
|
|
@ -278,31 +422,46 @@ class _FaceTestScreenState extends State<FaceTestScreen> {
|
||||||
),
|
),
|
||||||
const SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
Text(
|
Text(
|
||||||
_confidence! < 80
|
_confidence! >= 0.85
|
||||||
? "Sangat mirip (< 80)"
|
? "Sangat cocok (≥ 85%)"
|
||||||
: _confidence! < 100
|
: _confidence! >= 0.65
|
||||||
? "Cukup mirip (80-100)"
|
? "Cukup cocok (65-85%)"
|
||||||
: "Tidak mirip (> 100)",
|
: "Tidak cocok (< 65%)",
|
||||||
style: AppTheme.bodySmall.copyWith(
|
style: AppTheme.bodySmall.copyWith(
|
||||||
color: _confidence! < 80
|
color: _confidence! >= 0.85
|
||||||
? AppTheme.statusGreen
|
? AppTheme.statusGreen
|
||||||
: _confidence! < 100
|
: _confidence! >= 0.65
|
||||||
? AppTheme.statusYellow
|
? AppTheme.statusYellow
|
||||||
: AppTheme.statusRed,
|
: 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)
|
if (_statusText != null)
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(top: 12),
|
padding: const EdgeInsets.only(top: 12),
|
||||||
child: Container(
|
child: Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6),
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 14, vertical: 6),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppTheme.primaryBlue.withOpacity(0.1),
|
color:
|
||||||
borderRadius: BorderRadius.circular(AppTheme.radiusFull),
|
AppTheme.primaryBlue.withOpacity(0.1),
|
||||||
|
borderRadius: BorderRadius.circular(
|
||||||
|
AppTheme.radiusFull),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
"Status: $_statusText",
|
"Status: $_statusText",
|
||||||
|
|
@ -312,7 +471,6 @@ class _FaceTestScreenState extends State<FaceTestScreen> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
if (_errorMessage != null)
|
if (_errorMessage != null)
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(top: 12),
|
padding: const EdgeInsets.only(top: 12),
|
||||||
|
|
@ -344,11 +502,16 @@ class _FaceTestScreenState extends State<FaceTestScreen> {
|
||||||
child: CustomButton(
|
child: CustomButton(
|
||||||
text: "Mulai Verifikasi",
|
text: "Mulai Verifikasi",
|
||||||
icon: Icons.face_rounded,
|
icon: Icons.face_rounded,
|
||||||
onPressed: _isCameraInitialized ? _captureAndVerify : null,
|
onPressed: _allChecksValid && _isCameraInitialized
|
||||||
backgroundColor: AppTheme.primaryBlue,
|
? _captureAndVerify
|
||||||
|
: null,
|
||||||
|
backgroundColor: _allChecksValid
|
||||||
|
? AppTheme.primaryBlue
|
||||||
|
: AppTheme.primaryDark.withOpacity(0.5),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
if (_isVerifying)
|
||||||
|
const CircularProgressIndicator(color: Colors.white),
|
||||||
if (_showResult)
|
if (_showResult)
|
||||||
Row(
|
Row(
|
||||||
children: [
|
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