sedikit penyesuaian
This commit is contained in:
parent
53255d8d43
commit
c8de09b83b
|
|
@ -20,7 +20,6 @@ class ApiUrl {
|
||||||
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.110.17:8000/api',
|
'current_ip': 'http://192.168.1.3: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': '',
|
||||||
|
|
|
||||||
|
|
@ -105,7 +105,12 @@ class PresensiHistoryModel {
|
||||||
if (json['jam_masuk'] != null && json['jam_pulang'] != null) {
|
if (json['jam_masuk'] != null && json['jam_pulang'] != null) {
|
||||||
try {
|
try {
|
||||||
final masuk = DateTime.parse("2000-01-01 ${json['jam_masuk']}");
|
final masuk = DateTime.parse("2000-01-01 ${json['jam_masuk']}");
|
||||||
final pulang = DateTime.parse("2000-01-01 ${json['jam_pulang']}");
|
DateTime pulang = DateTime.parse("2000-01-01 ${json['jam_pulang']}");
|
||||||
|
|
||||||
|
if (pulang.isBefore(masuk)) {
|
||||||
|
pulang = pulang.add(const Duration(days: 1));
|
||||||
|
}
|
||||||
|
|
||||||
final selisih = pulang.difference(masuk);
|
final selisih = pulang.difference(masuk);
|
||||||
hitungTotalJam = '${selisih.inHours}j ${selisih.inMinutes.remainder(60)}m';
|
hitungTotalJam = '${selisih.inHours}j ${selisih.inMinutes.remainder(60)}m';
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ 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 VideoRecordingState { idle, recording, uploading, success, error }
|
enum VideoRecordingState { idle, ready, recording, uploading, success, error }
|
||||||
|
|
||||||
enum FaceDetectionStatus { noFace, detected }
|
enum FaceDetectionStatus { noFace, detected }
|
||||||
|
|
||||||
|
|
@ -17,10 +17,10 @@ class FaceProvider with ChangeNotifier {
|
||||||
final FaceDetector _faceDetector = FaceDetector(
|
final FaceDetector _faceDetector = FaceDetector(
|
||||||
options: FaceDetectorOptions(
|
options: FaceDetectorOptions(
|
||||||
enableClassification: false,
|
enableClassification: false,
|
||||||
enableLandmarks: false,
|
enableLandmarks: true,
|
||||||
enableContours: false,
|
enableContours: false,
|
||||||
enableTracking: false,
|
enableTracking: true,
|
||||||
performanceMode: FaceDetectorMode.fast,
|
performanceMode: FaceDetectorMode.accurate,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -40,10 +40,12 @@ class FaceProvider with ChangeNotifier {
|
||||||
double _brightness = 0.0;
|
double _brightness = 0.0;
|
||||||
bool _isBrightnessOk = false;
|
bool _isBrightnessOk = false;
|
||||||
double _stepProgress = 0.0;
|
double _stepProgress = 0.0;
|
||||||
|
double _rightAngleSign = 0.0;
|
||||||
|
int _consecutiveValidPosedFrames = 0;
|
||||||
Timer? _progressTimer;
|
Timer? _progressTimer;
|
||||||
|
|
||||||
static const double _frontDuration = 4.0;
|
static const double _frontDuration = 4.0;
|
||||||
static const double _sideDuration = 3.0;
|
static const double _sideDuration = 6.0; // Tambahan jeda ekstra untuk menoleh yang rapi
|
||||||
static const double _tickInterval = 100;
|
static const double _tickInterval = 100;
|
||||||
|
|
||||||
VideoRecordingState get recordingState => _recordingState;
|
VideoRecordingState get recordingState => _recordingState;
|
||||||
|
|
@ -66,31 +68,46 @@ class FaceProvider with ChangeNotifier {
|
||||||
isFaceDetected && _isPoseValid && _isDistanceOk && _isBrightnessOk;
|
isFaceDetected && _isPoseValid && _isDistanceOk && _isBrightnessOk;
|
||||||
|
|
||||||
String get qualityMessage {
|
String get qualityMessage {
|
||||||
|
if (_recordingState == VideoRecordingState.ready) return 'Liveliness Lulus! Tekan tombol Rekam di bawah.';
|
||||||
|
if (_recordingState == VideoRecordingState.recording) {
|
||||||
|
if (_currentStep == EnrollmentStep.front) return 'Tahan posisi hadap depan...';
|
||||||
|
if (_currentStep == EnrollmentStep.right) return 'Mulai toleh ke kanan perlahan...';
|
||||||
|
return 'Mulai toleh arah sebaliknya perlahan...';
|
||||||
|
}
|
||||||
|
|
||||||
if (!isFaceDetected) return 'Arahkan wajah ke dalam bingkai';
|
if (!isFaceDetected) return 'Arahkan wajah ke dalam bingkai';
|
||||||
if (_faceRatio < 0.20) return 'Terlalu jauh, dekatkan wajah ke kamera';
|
if (_faceRatio < 0.20) return 'Terlalu jauh, dekatkan wajah ke kamera';
|
||||||
if (_faceRatio > 0.70) return 'Terlalu dekat, mundur sedikit';
|
if (_faceRatio > 0.70) return 'Terlalu dekat, mundur sedikit';
|
||||||
if (!_isBrightnessOk) return 'Pencahayaan kurang, cari tempat lebih terang';
|
if (!_isBrightnessOk) return 'Pencahayaan kurang, cari terang';
|
||||||
if (!_isPoseValid) {
|
if (!_isPoseValid) {
|
||||||
switch (_currentStep) {
|
switch (_currentStep) {
|
||||||
case EnrollmentStep.front:
|
case EnrollmentStep.front:
|
||||||
return 'Hadapkan wajah ke depan';
|
return 'UJI: Hadap depan (Ideal: <12, Skg: ${_headEulerAngleY.toStringAsFixed(0)})';
|
||||||
case EnrollmentStep.right:
|
case EnrollmentStep.right:
|
||||||
return 'Toleh ke kanan';
|
return 'UJI: Toleh kanan/kiri (Minimal 12 derajat, Skg: ${_headEulerAngleY.toStringAsFixed(0)})';
|
||||||
case EnrollmentStep.left:
|
case EnrollmentStep.left:
|
||||||
return 'Toleh ke kiri';
|
return 'UJI: Toleh arah sebaliknya (Minimal 12 derajat, Skg: ${_headEulerAngleY.toStringAsFixed(0)})';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
String get stepLabel {
|
String get stepLabel {
|
||||||
|
if (_recordingState == VideoRecordingState.idle) {
|
||||||
|
switch (_currentStep) {
|
||||||
|
case EnrollmentStep.front: return 'Tes Liveliness: Depan';
|
||||||
|
case EnrollmentStep.right: return 'Tes Liveliness: Toleh Kanan';
|
||||||
|
case EnrollmentStep.left: return 'Tes Liveliness: Toleh Kiri';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
switch (_currentStep) {
|
switch (_currentStep) {
|
||||||
case EnrollmentStep.front:
|
case EnrollmentStep.front:
|
||||||
return 'Hadap Depan';
|
return 'Merekam: Hadap Depan';
|
||||||
case EnrollmentStep.right:
|
case EnrollmentStep.right:
|
||||||
return 'Toleh Kanan';
|
return 'Merekam: Toleh Kanan';
|
||||||
case EnrollmentStep.left:
|
case EnrollmentStep.left:
|
||||||
return 'Toleh Kiri';
|
return 'Merekam: Toleh Kiri';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -109,6 +126,8 @@ class FaceProvider with ChangeNotifier {
|
||||||
_brightness = 0.0;
|
_brightness = 0.0;
|
||||||
_isBrightnessOk = false;
|
_isBrightnessOk = false;
|
||||||
_stepProgress = 0.0;
|
_stepProgress = 0.0;
|
||||||
|
_rightAngleSign = 0.0;
|
||||||
|
_consecutiveValidPosedFrames = 0;
|
||||||
_progressTimer?.cancel();
|
_progressTimer?.cancel();
|
||||||
_progressTimer = null;
|
_progressTimer = null;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|
@ -143,13 +162,14 @@ class FaceProvider with ChangeNotifier {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (allChecksValid) {
|
// Fase 2: Hardware Android Camera2 pada banyak device mematikan aliran Stream Gambar
|
||||||
final duration = _currentStep == EnrollmentStep.front
|
// ketika Stream Video berjalan (freeze). Bypass strict MLKit, andalkan jeda waktu murni.
|
||||||
? _frontDuration
|
final duration = _currentStep == EnrollmentStep.front
|
||||||
: _sideDuration;
|
? _frontDuration
|
||||||
final increment = (_tickInterval / 1000) / duration;
|
: _sideDuration;
|
||||||
_stepProgress += increment;
|
final increment = (_tickInterval / 1000) / duration;
|
||||||
}
|
|
||||||
|
_stepProgress += increment;
|
||||||
|
|
||||||
if (_stepProgress >= 1.0) {
|
if (_stepProgress >= 1.0) {
|
||||||
_advanceStep(controller);
|
_advanceStep(controller);
|
||||||
|
|
@ -166,6 +186,7 @@ class FaceProvider with ChangeNotifier {
|
||||||
_currentStep = EnrollmentStep.right;
|
_currentStep = EnrollmentStep.right;
|
||||||
_stepProgress = 0.0;
|
_stepProgress = 0.0;
|
||||||
_isPoseValid = false;
|
_isPoseValid = false;
|
||||||
|
_rightAngleSign = 0.0;
|
||||||
_message = 'Toleh Kanan';
|
_message = 'Toleh Kanan';
|
||||||
break;
|
break;
|
||||||
case EnrollmentStep.right:
|
case EnrollmentStep.right:
|
||||||
|
|
@ -256,15 +277,41 @@ class FaceProvider with ChangeNotifier {
|
||||||
|
|
||||||
switch (_currentStep) {
|
switch (_currentStep) {
|
||||||
case EnrollmentStep.front:
|
case EnrollmentStep.front:
|
||||||
_isPoseValid = _headEulerAngleY.abs() < 15;
|
_isPoseValid = _headEulerAngleY.abs() < 12;
|
||||||
break;
|
break;
|
||||||
case EnrollmentStep.right:
|
case EnrollmentStep.right:
|
||||||
_isPoseValid = _headEulerAngleY < -20;
|
_isPoseValid = _headEulerAngleY.abs() >= 12;
|
||||||
|
if (_isPoseValid) {
|
||||||
|
_rightAngleSign = _headEulerAngleY.sign;
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
case EnrollmentStep.left:
|
case EnrollmentStep.left:
|
||||||
_isPoseValid = _headEulerAngleY > 20;
|
_isPoseValid = _headEulerAngleY.abs() >= 12 &&
|
||||||
|
(_rightAngleSign == 0.0 || _headEulerAngleY.sign != _rightAngleSign);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Auto Advance Phase 1
|
||||||
|
if (_recordingState == VideoRecordingState.idle && allChecksValid) {
|
||||||
|
_consecutiveValidPosedFrames++;
|
||||||
|
if (_consecutiveValidPosedFrames > 5) {
|
||||||
|
_consecutiveValidPosedFrames = 0;
|
||||||
|
if (_currentStep == EnrollmentStep.front) {
|
||||||
|
_currentStep = EnrollmentStep.right;
|
||||||
|
_isPoseValid = false;
|
||||||
|
_rightAngleSign = 0.0;
|
||||||
|
} else if (_currentStep == EnrollmentStep.right) {
|
||||||
|
_currentStep = EnrollmentStep.left;
|
||||||
|
_isPoseValid = false;
|
||||||
|
} else if (_currentStep == EnrollmentStep.left) {
|
||||||
|
_recordingState = VideoRecordingState.ready;
|
||||||
|
_currentStep = EnrollmentStep.front; // Reset for recording phase
|
||||||
|
_isPoseValid = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
_consecutiveValidPosedFrames = 0;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
_faceDetectionStatus = FaceDetectionStatus.noFace;
|
_faceDetectionStatus = FaceDetectionStatus.noFace;
|
||||||
_isPoseValid = false;
|
_isPoseValid = false;
|
||||||
|
|
|
||||||
|
|
@ -9,13 +9,16 @@ class SetupCheckProvider extends ChangeNotifier {
|
||||||
bool _isLoading = false;
|
bool _isLoading = false;
|
||||||
bool _hasFace = false;
|
bool _hasFace = false;
|
||||||
bool _hasSignature = false;
|
bool _hasSignature = false;
|
||||||
|
bool _hasError = false;
|
||||||
|
|
||||||
bool get isLoading => _isLoading;
|
bool get isLoading => _isLoading;
|
||||||
bool get hasFace => _hasFace;
|
bool get hasFace => _hasFace;
|
||||||
bool get hasSignature => _hasSignature;
|
bool get hasSignature => _hasSignature;
|
||||||
|
bool get hasError => _hasError;
|
||||||
|
|
||||||
Future<void> checkSetup() async {
|
Future<void> checkSetup() async {
|
||||||
_isLoading = true;
|
_isLoading = true;
|
||||||
|
_hasError = false;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -27,13 +30,18 @@ class SetupCheckProvider extends ChangeNotifier {
|
||||||
final faceData = results[0];
|
final faceData = results[0];
|
||||||
final signatureData = results[1];
|
final signatureData = results[1];
|
||||||
|
|
||||||
// Backend mengembalikan: { is_registered: bool, status: 'verified'|'pending'|'not_registered' }
|
_hasFace = faceData['is_registered'] == true && faceData['status'] != 'rejected';
|
||||||
// Dianggap sudah punya wajah jika is_registered = true (sudah upload foto, meski pending approval)
|
|
||||||
_hasFace = faceData['is_registered'] == true;
|
if (signatureData['error'] == true) {
|
||||||
_hasSignature = signatureData['success'] == true && signatureData['data'] != null;
|
_hasError = true;
|
||||||
|
_hasSignature = true;
|
||||||
|
} else {
|
||||||
|
_hasSignature = signatureData['success'] == true && signatureData['data'] != null;
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_hasFace = false;
|
_hasError = true;
|
||||||
_hasSignature = false;
|
_hasFace = true;
|
||||||
|
_hasSignature = true;
|
||||||
} finally {
|
} finally {
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|
@ -44,5 +52,6 @@ class SetupCheckProvider extends ChangeNotifier {
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
_hasFace = false;
|
_hasFace = false;
|
||||||
_hasSignature = false;
|
_hasSignature = false;
|
||||||
|
_hasError = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
|
import 'package:dio/dio.dart';
|
||||||
import '../services/api_client.dart';
|
import '../services/api_client.dart';
|
||||||
|
|
||||||
class SignatureRepository {
|
class SignatureRepository {
|
||||||
|
|
@ -11,9 +12,14 @@ class SignatureRepository {
|
||||||
if (response.statusCode == 200 && response.data['success'] == true) {
|
if (response.statusCode == 200 && response.data['success'] == true) {
|
||||||
return {'success': true, 'data': response.data['data']};
|
return {'success': true, 'data': response.data['data']};
|
||||||
}
|
}
|
||||||
return {'success': false, 'message': response.data['message'] ?? 'Gagal'};
|
return {'success': false, 'data': null, 'message': response.data['message'] ?? 'Gagal'};
|
||||||
|
} on DioException catch (e) {
|
||||||
|
if (e.response?.statusCode == 404) {
|
||||||
|
return {'success': false, 'data': null, 'message': 'Belum ada tanda tangan'};
|
||||||
|
}
|
||||||
|
return {'success': false, 'error': true, 'message': e.message ?? 'Gagal koneksi ke server'};
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return {'success': false, 'message': e.toString()};
|
return {'success': false, 'error': true, 'message': e.toString()};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ class _NotificationScreenState extends State<NotificationScreen> {
|
||||||
if (tipe.contains('lembur')) return Icons.schedule_rounded;
|
if (tipe.contains('lembur')) return Icons.schedule_rounded;
|
||||||
if (tipe.contains('presensi')) return Icons.fingerprint_rounded;
|
if (tipe.contains('presensi')) return Icons.fingerprint_rounded;
|
||||||
if (tipe.contains('izin')) return Icons.description_rounded;
|
if (tipe.contains('izin')) return Icons.description_rounded;
|
||||||
|
if (tipe.contains('cuti')) return Icons.beach_access_rounded;
|
||||||
if (tipe.contains('poin')) return Icons.stars_rounded;
|
if (tipe.contains('poin')) return Icons.stars_rounded;
|
||||||
return Icons.notifications_outlined;
|
return Icons.notifications_outlined;
|
||||||
}
|
}
|
||||||
|
|
@ -41,6 +42,7 @@ class _NotificationScreenState extends State<NotificationScreen> {
|
||||||
if (tipe.contains('lembur')) return const Color(0xFFf59e0b);
|
if (tipe.contains('lembur')) return const Color(0xFFf59e0b);
|
||||||
if (tipe.contains('poin')) return const Color(0xFFe11d48);
|
if (tipe.contains('poin')) return const Color(0xFFe11d48);
|
||||||
if (tipe.contains('izin')) return const Color(0xFF8b5cf6);
|
if (tipe.contains('izin')) return const Color(0xFF8b5cf6);
|
||||||
|
if (tipe.contains('cuti')) return const Color(0xFF06b6d4);
|
||||||
if (tipe.contains('presensi')) return const Color(0xFF06b6d4);
|
if (tipe.contains('presensi')) return const Color(0xFF06b6d4);
|
||||||
return const Color(0xFF6366f1);
|
return const Color(0xFF6366f1);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ class _FaceEnrollmentScreenState extends State<FaceEnrollmentScreen>
|
||||||
CameraController? _cameraController;
|
CameraController? _cameraController;
|
||||||
bool _isCameraInitialized = false;
|
bool _isCameraInitialized = false;
|
||||||
CameraDescription? _frontCamera;
|
CameraDescription? _frontCamera;
|
||||||
|
bool _successHandled = false;
|
||||||
|
|
||||||
late AnimationController _pulseController;
|
late AnimationController _pulseController;
|
||||||
late Animation<double> _pulseAnimation;
|
late Animation<double> _pulseAnimation;
|
||||||
|
|
@ -58,7 +59,7 @@ class _FaceEnrollmentScreenState extends State<FaceEnrollmentScreen>
|
||||||
|
|
||||||
_cameraController = CameraController(
|
_cameraController = CameraController(
|
||||||
_frontCamera!,
|
_frontCamera!,
|
||||||
ResolutionPreset.medium,
|
ResolutionPreset.high,
|
||||||
enableAudio: false,
|
enableAudio: false,
|
||||||
imageFormatGroup: Platform.isAndroid
|
imageFormatGroup: Platform.isAndroid
|
||||||
? ImageFormatGroup.nv21
|
? ImageFormatGroup.nv21
|
||||||
|
|
@ -72,8 +73,10 @@ 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 ||
|
var isIdle = provider.recordingState == VideoRecordingState.idle;
|
||||||
provider.recordingState == VideoRecordingState.recording) {
|
var isReady = provider.recordingState == VideoRecordingState.ready;
|
||||||
|
var isRecording = provider.recordingState == VideoRecordingState.recording;
|
||||||
|
if (isIdle || isReady || isRecording) {
|
||||||
provider.processCameraImage(
|
provider.processCameraImage(
|
||||||
image,
|
image,
|
||||||
_frontCamera!,
|
_frontCamera!,
|
||||||
|
|
@ -100,25 +103,6 @@ class _FaceEnrollmentScreenState extends State<FaceEnrollmentScreen>
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
|
||||||
await _cameraController!.stopImageStream();
|
|
||||||
} catch (_) {}
|
|
||||||
|
|
||||||
await Future.delayed(const Duration(milliseconds: 100));
|
|
||||||
|
|
||||||
_cameraController!.startImageStream((CameraImage image) {
|
|
||||||
if (!mounted) return;
|
|
||||||
final p = context.read<FaceProvider>();
|
|
||||||
if (p.recordingState == VideoRecordingState.recording) {
|
|
||||||
p.processCameraImage(
|
|
||||||
image,
|
|
||||||
_frontCamera!,
|
|
||||||
CameraUtils.rotationIntToImageRotation(
|
|
||||||
_frontCamera!.sensorOrientation),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
HapticFeedback.mediumImpact();
|
HapticFeedback.mediumImpact();
|
||||||
await provider.startRecording(_cameraController!);
|
await provider.startRecording(_cameraController!);
|
||||||
}
|
}
|
||||||
|
|
@ -146,8 +130,19 @@ class _FaceEnrollmentScreenState extends State<FaceEnrollmentScreen>
|
||||||
),
|
),
|
||||||
body: Consumer<FaceProvider>(
|
body: Consumer<FaceProvider>(
|
||||||
builder: (context, provider, child) {
|
builder: (context, provider, child) {
|
||||||
|
// Refresh status dari server saat enrollment berhasil
|
||||||
|
if (provider.recordingState == VideoRecordingState.success && !_successHandled) {
|
||||||
|
_successHandled = true;
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (mounted) {
|
||||||
|
context.read<SetupCheckProvider>().checkSetup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
final bool isIdle =
|
final bool isIdle =
|
||||||
provider.recordingState == VideoRecordingState.idle;
|
provider.recordingState == VideoRecordingState.idle;
|
||||||
|
final bool isReady =
|
||||||
|
provider.recordingState == VideoRecordingState.ready;
|
||||||
final bool isRecording =
|
final bool isRecording =
|
||||||
provider.recordingState == VideoRecordingState.recording;
|
provider.recordingState == VideoRecordingState.recording;
|
||||||
final bool isUploading =
|
final bool isUploading =
|
||||||
|
|
@ -487,8 +482,8 @@ class _FaceEnrollmentScreenState extends State<FaceEnrollmentScreen>
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Idle state — tombol mulai rekam
|
// Idle state — tombol mulai rekam (hanya muncul/aktif saat ready)
|
||||||
if (isIdle)
|
if (isIdle || isReady)
|
||||||
Column(
|
Column(
|
||||||
children: [
|
children: [
|
||||||
if (provider.qualityMessage.isNotEmpty)
|
if (provider.qualityMessage.isNotEmpty)
|
||||||
|
|
@ -497,7 +492,7 @@ class _FaceEnrollmentScreenState extends State<FaceEnrollmentScreen>
|
||||||
child: Text(
|
child: Text(
|
||||||
provider.qualityMessage,
|
provider.qualityMessage,
|
||||||
style: AppTheme.bodySmall.copyWith(
|
style: AppTheme.bodySmall.copyWith(
|
||||||
color: provider.isFaceDetected
|
color: provider.isFaceDetected || isReady
|
||||||
? AppTheme.statusYellow
|
? AppTheme.statusYellow
|
||||||
: Colors.white70,
|
: Colors.white70,
|
||||||
),
|
),
|
||||||
|
|
@ -505,12 +500,12 @@ class _FaceEnrollmentScreenState extends State<FaceEnrollmentScreen>
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
CustomButton(
|
CustomButton(
|
||||||
text: "Mulai Rekam",
|
text: isReady ? "Mulai Rekam Dataset" : "Selesaikan Tes Anti-Palsu",
|
||||||
icon: Icons.videocam,
|
icon: Icons.videocam,
|
||||||
backgroundColor: provider.allChecksValid
|
backgroundColor: isReady
|
||||||
? AppTheme.statusGreen
|
? AppTheme.statusGreen
|
||||||
: AppTheme.primaryDark.withOpacity(0.5),
|
: AppTheme.primaryDark.withOpacity(0.5),
|
||||||
onPressed: provider.allChecksValid
|
onPressed: isReady
|
||||||
? () => _handleStartRecording(provider)
|
? () => _handleStartRecording(provider)
|
||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
|
|
@ -523,23 +518,33 @@ class _FaceEnrollmentScreenState extends State<FaceEnrollmentScreen>
|
||||||
padding: const EdgeInsets.only(top: 24),
|
padding: const EdgeInsets.only(top: 24),
|
||||||
child: CustomButton(
|
child: CustomButton(
|
||||||
text: widget.isOnboarding
|
text: widget.isOnboarding
|
||||||
? "Lanjut: Tanda Tangan"
|
? (context.read<SetupCheckProvider>().hasSignature
|
||||||
|
? "Mulai Gunakan Aplikasi"
|
||||||
|
: "Lanjut: Tanda Tangan")
|
||||||
: "Selesai",
|
: "Selesai",
|
||||||
icon: widget.isOnboarding
|
icon: widget.isOnboarding
|
||||||
? Icons.arrow_forward
|
? (context.read<SetupCheckProvider>().hasSignature
|
||||||
|
? Icons.home
|
||||||
|
: Icons.arrow_forward)
|
||||||
: Icons.check,
|
: Icons.check,
|
||||||
backgroundColor: AppTheme.statusGreen,
|
backgroundColor: AppTheme.statusGreen,
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
HapticFeedback.mediumImpact();
|
HapticFeedback.mediumImpact();
|
||||||
if (widget.isOnboarding) {
|
if (widget.isOnboarding) {
|
||||||
|
final hasSignature = context.read<SetupCheckProvider>().hasSignature;
|
||||||
context.read<SetupCheckProvider>().reset();
|
context.read<SetupCheckProvider>().reset();
|
||||||
Navigator.pushReplacement(
|
|
||||||
context,
|
if (hasSignature) {
|
||||||
MaterialPageRoute(
|
Navigator.pushReplacementNamed(context, '/home');
|
||||||
builder: (_) =>
|
} else {
|
||||||
const SignatureScreen(isOnboarding: true),
|
Navigator.pushReplacement(
|
||||||
),
|
context,
|
||||||
);
|
MaterialPageRoute(
|
||||||
|
builder: (_) =>
|
||||||
|
const SignatureScreen(isOnboarding: true),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
}
|
}
|
||||||
|
|
@ -580,6 +585,7 @@ class _FaceEnrollmentScreenState extends State<FaceEnrollmentScreen>
|
||||||
icon: Icons.refresh,
|
icon: Icons.refresh,
|
||||||
backgroundColor: AppTheme.primaryOrange,
|
backgroundColor: AppTheme.primaryOrange,
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
|
setState(() => _successHandled = false);
|
||||||
provider.reset();
|
provider.reset();
|
||||||
_restartCameraStream();
|
_restartCameraStream();
|
||||||
},
|
},
|
||||||
|
|
@ -765,6 +771,7 @@ class _FaceEnrollmentScreenState extends State<FaceEnrollmentScreen>
|
||||||
String _getStatusText(FaceProvider provider) {
|
String _getStatusText(FaceProvider provider) {
|
||||||
switch (provider.recordingState) {
|
switch (provider.recordingState) {
|
||||||
case VideoRecordingState.idle:
|
case VideoRecordingState.idle:
|
||||||
|
case VideoRecordingState.ready:
|
||||||
return '';
|
return '';
|
||||||
case VideoRecordingState.recording:
|
case VideoRecordingState.recording:
|
||||||
return '';
|
return '';
|
||||||
|
|
@ -781,6 +788,8 @@ class _FaceEnrollmentScreenState extends State<FaceEnrollmentScreen>
|
||||||
switch (provider.recordingState) {
|
switch (provider.recordingState) {
|
||||||
case VideoRecordingState.idle:
|
case VideoRecordingState.idle:
|
||||||
return Icons.face;
|
return Icons.face;
|
||||||
|
case VideoRecordingState.ready:
|
||||||
|
return Icons.check_circle_outline;
|
||||||
case VideoRecordingState.recording:
|
case VideoRecordingState.recording:
|
||||||
return Icons.videocam;
|
return Icons.videocam;
|
||||||
case VideoRecordingState.uploading:
|
case VideoRecordingState.uploading:
|
||||||
|
|
@ -796,6 +805,8 @@ class _FaceEnrollmentScreenState extends State<FaceEnrollmentScreen>
|
||||||
switch (provider.recordingState) {
|
switch (provider.recordingState) {
|
||||||
case VideoRecordingState.idle:
|
case VideoRecordingState.idle:
|
||||||
return Colors.white;
|
return Colors.white;
|
||||||
|
case VideoRecordingState.ready:
|
||||||
|
return AppTheme.statusGreen;
|
||||||
case VideoRecordingState.recording:
|
case VideoRecordingState.recording:
|
||||||
return Colors.white;
|
return Colors.white;
|
||||||
case VideoRecordingState.uploading:
|
case VideoRecordingState.uploading:
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,12 @@ class _FaceTestScreenState extends State<FaceTestScreen> {
|
||||||
return 'Siap — mulai verifikasi';
|
return 'Siap — mulai verifikasi';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Color get _resultColor {
|
||||||
|
if (_statusText == 'APPROVED') return AppTheme.statusGreen;
|
||||||
|
if (_statusText == 'PENDING') return AppTheme.statusYellow;
|
||||||
|
return AppTheme.statusRed;
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
|
@ -261,9 +267,7 @@ class _FaceTestScreenState extends State<FaceTestScreen> {
|
||||||
|
|
||||||
ScannerOverlay(
|
ScannerOverlay(
|
||||||
borderColor: _showResult
|
borderColor: _showResult
|
||||||
? (_verified == true
|
? _resultColor
|
||||||
? AppTheme.statusGreen
|
|
||||||
: AppTheme.statusRed)
|
|
||||||
: _allChecksValid
|
: _allChecksValid
|
||||||
? AppTheme.statusGreen
|
? AppTheme.statusGreen
|
||||||
: _isFaceDetected
|
: _isFaceDetected
|
||||||
|
|
@ -300,16 +304,16 @@ class _FaceTestScreenState extends State<FaceTestScreen> {
|
||||||
_isVerifying
|
_isVerifying
|
||||||
? "Memverifikasi wajah..."
|
? "Memverifikasi wajah..."
|
||||||
: _showResult
|
: _showResult
|
||||||
? (_verified == true
|
? (_statusText == 'APPROVED'
|
||||||
? "Wajah Terverifikasi ✓"
|
? "Wajah Terverifikasi ✓"
|
||||||
: "Wajah Tidak Cocok ✗")
|
: _statusText == 'PENDING'
|
||||||
|
? "Verifikasi Meragukan ⚠️"
|
||||||
|
: "Wajah Tidak Cocok ✗")
|
||||||
: _qualityMessage,
|
: _qualityMessage,
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: AppTheme.bodyLarge.copyWith(
|
style: AppTheme.bodyLarge.copyWith(
|
||||||
color: _showResult
|
color: _showResult
|
||||||
? (_verified == true
|
? _resultColor
|
||||||
? AppTheme.statusGreen
|
|
||||||
: AppTheme.statusRed)
|
|
||||||
: _allChecksValid
|
: _allChecksValid
|
||||||
? AppTheme.statusGreen
|
? AppTheme.statusGreen
|
||||||
: Colors.white,
|
: Colors.white,
|
||||||
|
|
@ -373,31 +377,28 @@ class _FaceTestScreenState extends State<FaceTestScreen> {
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.all(12),
|
padding: const EdgeInsets.all(12),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: (_verified == true
|
color: _resultColor.withOpacity(0.1),
|
||||||
? AppTheme.statusGreen
|
|
||||||
: AppTheme.statusRed)
|
|
||||||
.withOpacity(0.1),
|
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
),
|
),
|
||||||
child: Icon(
|
child: Icon(
|
||||||
_verified == true
|
_statusText == 'APPROVED'
|
||||||
? Icons.check_circle
|
? Icons.check_circle
|
||||||
: Icons.cancel,
|
: _statusText == 'PENDING'
|
||||||
color: _verified == true
|
? Icons.warning_amber_rounded
|
||||||
? AppTheme.statusGreen
|
: Icons.cancel,
|
||||||
: AppTheme.statusRed,
|
color: _resultColor,
|
||||||
size: 40,
|
size: 40,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Text(
|
Text(
|
||||||
_verified == true
|
_statusText == 'APPROVED'
|
||||||
? "Verifikasi Berhasil"
|
? "Verifikasi Berhasil"
|
||||||
: "Verifikasi Gagal",
|
: _statusText == 'PENDING'
|
||||||
|
? "Verifikasi Diragukan"
|
||||||
|
: "Verifikasi Gagal",
|
||||||
style: AppTheme.heading3.copyWith(
|
style: AppTheme.heading3.copyWith(
|
||||||
color: _verified == true
|
color: _resultColor,
|
||||||
? AppTheme.statusGreen
|
|
||||||
: AppTheme.statusRed,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
|
|
@ -422,15 +423,15 @@ class _FaceTestScreenState extends State<FaceTestScreen> {
|
||||||
),
|
),
|
||||||
const SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
Text(
|
Text(
|
||||||
_confidence! >= 0.85
|
_confidence! >= 0.75
|
||||||
? "Sangat cocok (≥ 85%)"
|
? "Sangat cocok (≥ 75%)"
|
||||||
: _confidence! >= 0.65
|
: _confidence! >= 0.55
|
||||||
? "Cukup cocok (65-85%)"
|
? "Meragukan (55-74%)"
|
||||||
: "Tidak cocok (< 65%)",
|
: "Tidak cocok (< 55%)",
|
||||||
style: AppTheme.bodySmall.copyWith(
|
style: AppTheme.bodySmall.copyWith(
|
||||||
color: _confidence! >= 0.85
|
color: _confidence! >= 0.75
|
||||||
? AppTheme.statusGreen
|
? AppTheme.statusGreen
|
||||||
: _confidence! >= 0.65
|
: _confidence! >= 0.55
|
||||||
? AppTheme.statusYellow
|
? AppTheme.statusYellow
|
||||||
: AppTheme.statusRed,
|
: AppTheme.statusRed,
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -325,14 +325,22 @@ class _ProfileScreenState extends State<ProfileScreen> {
|
||||||
icon: Icons.face,
|
icon: Icons.face,
|
||||||
label: "Registrasi Wajah",
|
label: "Registrasi Wajah",
|
||||||
onTap: () => Navigator.pushNamed(context, '/onboarding/face-enrollment'),
|
onTap: () => Navigator.pushNamed(context, '/onboarding/face-enrollment'),
|
||||||
trailingText: context.watch<FaceProvider>().faceStatus2['is_registered'] == true
|
trailingText: (() {
|
||||||
? "Terdaftar"
|
final status = context.watch<FaceProvider>().faceStatus2['status'];
|
||||||
: "Belum Terdaftar",
|
if (status == 'verified') return 'Terverifikasi';
|
||||||
trailingColor: context.watch<FaceProvider>().faceStatus2['is_registered'] == true
|
if (status == 'pending') return 'Menunggu HRD';
|
||||||
? AppTheme.statusGreen
|
if (status == 'rejected') return 'Ditolak (Ulangi)';
|
||||||
: AppTheme.statusRed,
|
return 'Belum Terdaftar';
|
||||||
|
})(),
|
||||||
|
trailingColor: (() {
|
||||||
|
final status = context.watch<FaceProvider>().faceStatus2['status'];
|
||||||
|
if (status == 'verified') return AppTheme.statusGreen;
|
||||||
|
if (status == 'pending') return AppTheme.statusYellow;
|
||||||
|
if (status == 'rejected') return AppTheme.statusRed;
|
||||||
|
return AppTheme.textSecondary;
|
||||||
|
})(),
|
||||||
),
|
),
|
||||||
if (context.watch<FaceProvider>().faceStatus2['is_registered'] == true)
|
if (context.watch<FaceProvider>().faceStatus2['status'] == 'verified')
|
||||||
_buildActionItem(
|
_buildActionItem(
|
||||||
icon: Icons.face_retouching_natural,
|
icon: Icons.face_retouching_natural,
|
||||||
label: "Test Pengenalan Wajah",
|
label: "Test Pengenalan Wajah",
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ class SignatureScreen extends StatefulWidget {
|
||||||
class _SignatureScreenState extends State<SignatureScreen> {
|
class _SignatureScreenState extends State<SignatureScreen> {
|
||||||
late HandSignatureControl _signatureController;
|
late HandSignatureControl _signatureController;
|
||||||
bool _isScrollable = true;
|
bool _isScrollable = true;
|
||||||
|
final GlobalKey _signatureKey = GlobalKey();
|
||||||
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -57,16 +58,12 @@ class _SignatureScreenState extends State<SignatureScreen> {
|
||||||
|
|
||||||
Future<Uint8List?> _captureCanvas() async {
|
Future<Uint8List?> _captureCanvas() async {
|
||||||
try {
|
try {
|
||||||
final byteData = await _signatureController.toImage(
|
final boundary = _signatureKey.currentContext?.findRenderObject() as RenderRepaintBoundary?;
|
||||||
width: 1080,
|
if (boundary == null) return null;
|
||||||
height: 720,
|
|
||||||
color: const Color(0xFF1E293B),
|
final image = await boundary.toImage(pixelRatio: 3.0);
|
||||||
background: Colors.white,
|
final byteData = await image.toByteData(format: ImageByteFormat.png);
|
||||||
fit: true,
|
|
||||||
border: 20,
|
|
||||||
format: ImageByteFormat.png,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (byteData == null) return null;
|
if (byteData == null) return null;
|
||||||
return byteData.buffer.asUint8List();
|
return byteData.buffer.asUint8List();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -279,14 +276,18 @@ class _SignatureScreenState extends State<SignatureScreen> {
|
||||||
child: ClipRRect(
|
child: ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(AppTheme.radiusSm - 1),
|
borderRadius: BorderRadius.circular(AppTheme.radiusSm - 1),
|
||||||
child: RepaintBoundary(
|
child: RepaintBoundary(
|
||||||
child: HandSignature(
|
key: _signatureKey,
|
||||||
control: _signatureController,
|
child: Container(
|
||||||
color: const Color(0xFF1E293B),
|
color: Colors.white,
|
||||||
width: 2.5,
|
child: HandSignature(
|
||||||
maxWidth: 5.5,
|
control: _signatureController,
|
||||||
type: SignatureDrawType.shape,
|
color: const Color(0xFF1E293B),
|
||||||
onPointerDown: () => setState(() => _isScrollable = false),
|
width: 2.5,
|
||||||
onPointerUp: () => setState(() => _isScrollable = true),
|
maxWidth: 5.5,
|
||||||
|
type: SignatureDrawType.shape,
|
||||||
|
onPointerDown: () => setState(() => _isScrollable = false),
|
||||||
|
onPointerUp: () => setState(() => _isScrollable = true),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -46,9 +46,10 @@ class FcmService {
|
||||||
_messaging.onTokenRefresh.listen((t) => _repository.saveDeviceToken(t));
|
_messaging.onTokenRefresh.listen((t) => _repository.saveDeviceToken(t));
|
||||||
|
|
||||||
FirebaseMessaging.onMessage.listen((msg) {
|
FirebaseMessaging.onMessage.listen((msg) {
|
||||||
if (context.mounted) {
|
final currentContext = navigatorKey.currentContext;
|
||||||
_showBanner(context, msg);
|
if (currentContext != null && currentContext.mounted) {
|
||||||
context.read<NotificationProvider>().fetchUnreadCount();
|
_showBanner(currentContext, msg);
|
||||||
|
currentContext.read<NotificationProvider>().fetchUnreadCount();
|
||||||
}
|
}
|
||||||
_showSystemNotification(msg);
|
_showSystemNotification(msg);
|
||||||
});
|
});
|
||||||
|
|
@ -81,10 +82,12 @@ class FcmService {
|
||||||
if (tipe == null) return Icons.notifications_outlined;
|
if (tipe == null) return Icons.notifications_outlined;
|
||||||
if (tipe.contains('disetujui')) return Icons.check_circle_rounded;
|
if (tipe.contains('disetujui')) return Icons.check_circle_rounded;
|
||||||
if (tipe.contains('ditolak')) return Icons.cancel_rounded;
|
if (tipe.contains('ditolak')) return Icons.cancel_rounded;
|
||||||
|
if (tipe.contains('proses')) return Icons.hourglass_top_rounded;
|
||||||
if (tipe.contains('pengumuman')) return Icons.campaign_rounded;
|
if (tipe.contains('pengumuman')) return Icons.campaign_rounded;
|
||||||
if (tipe.contains('lembur')) return Icons.schedule_rounded;
|
if (tipe.contains('lembur')) return Icons.schedule_rounded;
|
||||||
if (tipe.contains('presensi')) return Icons.fingerprint_rounded;
|
if (tipe.contains('presensi')) return Icons.fingerprint_rounded;
|
||||||
if (tipe.contains('izin')) return Icons.description_rounded;
|
if (tipe.contains('izin')) return Icons.description_rounded;
|
||||||
|
if (tipe.contains('cuti')) return Icons.beach_access_rounded;
|
||||||
if (tipe.contains('poin')) return Icons.stars_rounded;
|
if (tipe.contains('poin')) return Icons.stars_rounded;
|
||||||
return Icons.notifications_outlined;
|
return Icons.notifications_outlined;
|
||||||
}
|
}
|
||||||
|
|
@ -96,6 +99,9 @@ class FcmService {
|
||||||
if (tipe.contains('pengumuman')) return AppTheme.primaryBlue;
|
if (tipe.contains('pengumuman')) return AppTheme.primaryBlue;
|
||||||
if (tipe.contains('lembur')) return const Color(0xFFf59e0b);
|
if (tipe.contains('lembur')) return const Color(0xFFf59e0b);
|
||||||
if (tipe.contains('poin')) return const Color(0xFFe11d48);
|
if (tipe.contains('poin')) return const Color(0xFFe11d48);
|
||||||
|
if (tipe.contains('izin')) return const Color(0xFF8b5cf6);
|
||||||
|
if (tipe.contains('cuti')) return const Color(0xFF06b6d4);
|
||||||
|
if (tipe.contains('presensi')) return const Color(0xFF06b6d4);
|
||||||
return const Color(0xFF6366f1);
|
return const Color(0xFF6366f1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue