sedikit penyesuaian

This commit is contained in:
jouel88 2026-04-07 10:46:50 +07:00
parent 53255d8d43
commit c8de09b83b
12 changed files with 222 additions and 127 deletions

View File

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

View File

@ -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': '',

View File

@ -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) {

View File

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

View File

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

View File

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

View File

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

View File

@ -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:

View File

@ -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,
), ),

View File

@ -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",

View File

@ -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),
),
), ),
), ),

View File

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