From c8de09b83b91e47da87fe0096a8368036b10a291 Mon Sep 17 00:00:00 2001 From: jouel88 Date: Tue, 7 Apr 2026 10:46:50 +0700 Subject: [PATCH] sedikit penyesuaian --- lib/core/constants/api_url.dart | 1 - lib/core/services/api_config_service.dart | 2 +- lib/models/presensi_model.dart | 7 +- lib/providers/face_provider.dart | 91 ++++++++++++++----- lib/providers/setup_check_provider.dart | 21 +++-- lib/repositories/signature_repository.dart | 10 +- .../notification/notification_screen.dart | 2 + .../onboarding/face_enrollment_screen.dart | 85 +++++++++-------- lib/screens/profile/face_test_screen.dart | 59 ++++++------ lib/screens/profile/profile_screen.dart | 22 +++-- lib/screens/profile/signature_screen.dart | 37 ++++---- lib/services/fcm_service.dart | 12 ++- 12 files changed, 222 insertions(+), 127 deletions(-) diff --git a/lib/core/constants/api_url.dart b/lib/core/constants/api_url.dart index e6f650e..7b218ce 100644 --- a/lib/core/constants/api_url.dart +++ b/lib/core/constants/api_url.dart @@ -20,7 +20,6 @@ class ApiUrl { static String get imageBaseUrl => _cachedImageBaseUrl ?? baseUrl.replaceAll('/api', '/storage/'); static Future initialize() async { - await ApiConfigService.clearConfig(); await getBaseUrl(); await getImageBaseUrl(); } diff --git a/lib/core/services/api_config_service.dart b/lib/core/services/api_config_service.dart index a7e0cc3..d3e5f80 100644 --- a/lib/core/services/api_config_service.dart +++ b/lib/core/services/api_config_service.dart @@ -6,7 +6,7 @@ class ApiConfigService { static const String _selectedPresetKey = 'selected_preset'; static const Map 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', 'emulator': 'http://10.0.2.2:8000/api', 'custom': '', diff --git a/lib/models/presensi_model.dart b/lib/models/presensi_model.dart index 516617a..940c127 100644 --- a/lib/models/presensi_model.dart +++ b/lib/models/presensi_model.dart @@ -105,7 +105,12 @@ class PresensiHistoryModel { if (json['jam_masuk'] != null && json['jam_pulang'] != null) { try { 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); hitungTotalJam = '${selisih.inHours}j ${selisih.inMinutes.remainder(60)}m'; } catch (e) { diff --git a/lib/providers/face_provider.dart b/lib/providers/face_provider.dart index f86025d..c9b1d4e 100644 --- a/lib/providers/face_provider.dart +++ b/lib/providers/face_provider.dart @@ -6,7 +6,7 @@ import 'package:google_mlkit_face_detection/google_mlkit_face_detection.dart'; import '../repositories/face_repository.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 } @@ -17,10 +17,10 @@ class FaceProvider with ChangeNotifier { final FaceDetector _faceDetector = FaceDetector( options: FaceDetectorOptions( enableClassification: false, - enableLandmarks: false, + enableLandmarks: true, enableContours: false, - enableTracking: false, - performanceMode: FaceDetectorMode.fast, + enableTracking: true, + performanceMode: FaceDetectorMode.accurate, ), ); @@ -40,10 +40,12 @@ class FaceProvider with ChangeNotifier { double _brightness = 0.0; bool _isBrightnessOk = false; double _stepProgress = 0.0; + double _rightAngleSign = 0.0; + int _consecutiveValidPosedFrames = 0; Timer? _progressTimer; 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; VideoRecordingState get recordingState => _recordingState; @@ -66,31 +68,46 @@ class FaceProvider with ChangeNotifier { isFaceDetected && _isPoseValid && _isDistanceOk && _isBrightnessOk; 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 (_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 (!_isBrightnessOk) return 'Pencahayaan kurang, cari terang'; if (!_isPoseValid) { switch (_currentStep) { case EnrollmentStep.front: - return 'Hadapkan wajah ke depan'; + return 'UJI: Hadap depan (Ideal: <12, Skg: ${_headEulerAngleY.toStringAsFixed(0)})'; case EnrollmentStep.right: - return 'Toleh ke kanan'; + return 'UJI: Toleh kanan/kiri (Minimal 12 derajat, Skg: ${_headEulerAngleY.toStringAsFixed(0)})'; case EnrollmentStep.left: - return 'Toleh ke kiri'; + return 'UJI: Toleh arah sebaliknya (Minimal 12 derajat, Skg: ${_headEulerAngleY.toStringAsFixed(0)})'; } } return ''; } 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) { case EnrollmentStep.front: - return 'Hadap Depan'; + return 'Merekam: Hadap Depan'; case EnrollmentStep.right: - return 'Toleh Kanan'; + return 'Merekam: Toleh Kanan'; case EnrollmentStep.left: - return 'Toleh Kiri'; + return 'Merekam: Toleh Kiri'; } } @@ -109,6 +126,8 @@ class FaceProvider with ChangeNotifier { _brightness = 0.0; _isBrightnessOk = false; _stepProgress = 0.0; + _rightAngleSign = 0.0; + _consecutiveValidPosedFrames = 0; _progressTimer?.cancel(); _progressTimer = null; notifyListeners(); @@ -143,13 +162,14 @@ class FaceProvider with ChangeNotifier { return; } - if (allChecksValid) { - final duration = _currentStep == EnrollmentStep.front - ? _frontDuration - : _sideDuration; - final increment = (_tickInterval / 1000) / duration; - _stepProgress += increment; - } + // Fase 2: Hardware Android Camera2 pada banyak device mematikan aliran Stream Gambar + // ketika Stream Video berjalan (freeze). Bypass strict MLKit, andalkan jeda waktu murni. + final duration = _currentStep == EnrollmentStep.front + ? _frontDuration + : _sideDuration; + final increment = (_tickInterval / 1000) / duration; + + _stepProgress += increment; if (_stepProgress >= 1.0) { _advanceStep(controller); @@ -166,6 +186,7 @@ class FaceProvider with ChangeNotifier { _currentStep = EnrollmentStep.right; _stepProgress = 0.0; _isPoseValid = false; + _rightAngleSign = 0.0; _message = 'Toleh Kanan'; break; case EnrollmentStep.right: @@ -256,15 +277,41 @@ class FaceProvider with ChangeNotifier { switch (_currentStep) { case EnrollmentStep.front: - _isPoseValid = _headEulerAngleY.abs() < 15; + _isPoseValid = _headEulerAngleY.abs() < 12; break; case EnrollmentStep.right: - _isPoseValid = _headEulerAngleY < -20; + _isPoseValid = _headEulerAngleY.abs() >= 12; + if (_isPoseValid) { + _rightAngleSign = _headEulerAngleY.sign; + } break; case EnrollmentStep.left: - _isPoseValid = _headEulerAngleY > 20; + _isPoseValid = _headEulerAngleY.abs() >= 12 && + (_rightAngleSign == 0.0 || _headEulerAngleY.sign != _rightAngleSign); 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 { _faceDetectionStatus = FaceDetectionStatus.noFace; _isPoseValid = false; diff --git a/lib/providers/setup_check_provider.dart b/lib/providers/setup_check_provider.dart index 1c9fec6..d4ffd6d 100644 --- a/lib/providers/setup_check_provider.dart +++ b/lib/providers/setup_check_provider.dart @@ -9,13 +9,16 @@ class SetupCheckProvider extends ChangeNotifier { bool _isLoading = false; bool _hasFace = false; bool _hasSignature = false; + bool _hasError = false; bool get isLoading => _isLoading; bool get hasFace => _hasFace; bool get hasSignature => _hasSignature; + bool get hasError => _hasError; Future checkSetup() async { _isLoading = true; + _hasError = false; notifyListeners(); try { @@ -27,13 +30,18 @@ class SetupCheckProvider extends ChangeNotifier { final faceData = results[0]; final signatureData = results[1]; - // Backend mengembalikan: { is_registered: bool, status: 'verified'|'pending'|'not_registered' } - // Dianggap sudah punya wajah jika is_registered = true (sudah upload foto, meski pending approval) - _hasFace = faceData['is_registered'] == true; - _hasSignature = signatureData['success'] == true && signatureData['data'] != null; + _hasFace = faceData['is_registered'] == true && faceData['status'] != 'rejected'; + + if (signatureData['error'] == true) { + _hasError = true; + _hasSignature = true; + } else { + _hasSignature = signatureData['success'] == true && signatureData['data'] != null; + } } catch (e) { - _hasFace = false; - _hasSignature = false; + _hasError = true; + _hasFace = true; + _hasSignature = true; } finally { _isLoading = false; notifyListeners(); @@ -44,5 +52,6 @@ class SetupCheckProvider extends ChangeNotifier { _isLoading = false; _hasFace = false; _hasSignature = false; + _hasError = false; } } diff --git a/lib/repositories/signature_repository.dart b/lib/repositories/signature_repository.dart index cea1ca7..6779185 100644 --- a/lib/repositories/signature_repository.dart +++ b/lib/repositories/signature_repository.dart @@ -1,5 +1,6 @@ import 'dart:convert'; import 'dart:typed_data'; +import 'package:dio/dio.dart'; import '../services/api_client.dart'; class SignatureRepository { @@ -11,9 +12,14 @@ class SignatureRepository { if (response.statusCode == 200 && response.data['success'] == true) { 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) { - return {'success': false, 'message': e.toString()}; + return {'success': false, 'error': true, 'message': e.toString()}; } } diff --git a/lib/screens/notification/notification_screen.dart b/lib/screens/notification/notification_screen.dart index 90e0bd7..a45ab6e 100644 --- a/lib/screens/notification/notification_screen.dart +++ b/lib/screens/notification/notification_screen.dart @@ -30,6 +30,7 @@ class _NotificationScreenState extends State { if (tipe.contains('lembur')) return Icons.schedule_rounded; if (tipe.contains('presensi')) return Icons.fingerprint_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; return Icons.notifications_outlined; } @@ -41,6 +42,7 @@ class _NotificationScreenState extends State { if (tipe.contains('lembur')) return const Color(0xFFf59e0b); 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); } diff --git a/lib/screens/onboarding/face_enrollment_screen.dart b/lib/screens/onboarding/face_enrollment_screen.dart index cefc481..ed8029b 100644 --- a/lib/screens/onboarding/face_enrollment_screen.dart +++ b/lib/screens/onboarding/face_enrollment_screen.dart @@ -26,6 +26,7 @@ class _FaceEnrollmentScreenState extends State CameraController? _cameraController; bool _isCameraInitialized = false; CameraDescription? _frontCamera; + bool _successHandled = false; late AnimationController _pulseController; late Animation _pulseAnimation; @@ -58,7 +59,7 @@ class _FaceEnrollmentScreenState extends State _cameraController = CameraController( _frontCamera!, - ResolutionPreset.medium, + ResolutionPreset.high, enableAudio: false, imageFormatGroup: Platform.isAndroid ? ImageFormatGroup.nv21 @@ -72,8 +73,10 @@ class _FaceEnrollmentScreenState extends State _cameraController!.startImageStream((CameraImage image) { if (!mounted) return; final provider = context.read(); - if (provider.recordingState == VideoRecordingState.idle || - provider.recordingState == VideoRecordingState.recording) { + var isIdle = provider.recordingState == VideoRecordingState.idle; + var isReady = provider.recordingState == VideoRecordingState.ready; + var isRecording = provider.recordingState == VideoRecordingState.recording; + if (isIdle || isReady || isRecording) { provider.processCameraImage( image, _frontCamera!, @@ -100,25 +103,6 @@ class _FaceEnrollmentScreenState extends State return; } - try { - await _cameraController!.stopImageStream(); - } catch (_) {} - - await Future.delayed(const Duration(milliseconds: 100)); - - _cameraController!.startImageStream((CameraImage image) { - if (!mounted) return; - final p = context.read(); - if (p.recordingState == VideoRecordingState.recording) { - p.processCameraImage( - image, - _frontCamera!, - CameraUtils.rotationIntToImageRotation( - _frontCamera!.sensorOrientation), - ); - } - }); - HapticFeedback.mediumImpact(); await provider.startRecording(_cameraController!); } @@ -146,8 +130,19 @@ class _FaceEnrollmentScreenState extends State ), body: Consumer( 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().checkSetup(); + } + }); + } final bool isIdle = provider.recordingState == VideoRecordingState.idle; + final bool isReady = + provider.recordingState == VideoRecordingState.ready; final bool isRecording = provider.recordingState == VideoRecordingState.recording; final bool isUploading = @@ -487,8 +482,8 @@ class _FaceEnrollmentScreenState extends State ), ), - // Idle state — tombol mulai rekam - if (isIdle) + // Idle state — tombol mulai rekam (hanya muncul/aktif saat ready) + if (isIdle || isReady) Column( children: [ if (provider.qualityMessage.isNotEmpty) @@ -497,7 +492,7 @@ class _FaceEnrollmentScreenState extends State child: Text( provider.qualityMessage, style: AppTheme.bodySmall.copyWith( - color: provider.isFaceDetected + color: provider.isFaceDetected || isReady ? AppTheme.statusYellow : Colors.white70, ), @@ -505,12 +500,12 @@ class _FaceEnrollmentScreenState extends State ), ), CustomButton( - text: "Mulai Rekam", + text: isReady ? "Mulai Rekam Dataset" : "Selesaikan Tes Anti-Palsu", icon: Icons.videocam, - backgroundColor: provider.allChecksValid + backgroundColor: isReady ? AppTheme.statusGreen : AppTheme.primaryDark.withOpacity(0.5), - onPressed: provider.allChecksValid + onPressed: isReady ? () => _handleStartRecording(provider) : null, ), @@ -523,23 +518,33 @@ class _FaceEnrollmentScreenState extends State padding: const EdgeInsets.only(top: 24), child: CustomButton( text: widget.isOnboarding - ? "Lanjut: Tanda Tangan" + ? (context.read().hasSignature + ? "Mulai Gunakan Aplikasi" + : "Lanjut: Tanda Tangan") : "Selesai", icon: widget.isOnboarding - ? Icons.arrow_forward + ? (context.read().hasSignature + ? Icons.home + : Icons.arrow_forward) : Icons.check, backgroundColor: AppTheme.statusGreen, onPressed: () { HapticFeedback.mediumImpact(); if (widget.isOnboarding) { + final hasSignature = context.read().hasSignature; context.read().reset(); - Navigator.pushReplacement( - context, - MaterialPageRoute( - builder: (_) => - const SignatureScreen(isOnboarding: true), - ), - ); + + if (hasSignature) { + Navigator.pushReplacementNamed(context, '/home'); + } else { + Navigator.pushReplacement( + context, + MaterialPageRoute( + builder: (_) => + const SignatureScreen(isOnboarding: true), + ), + ); + } } else { Navigator.pop(context); } @@ -580,6 +585,7 @@ class _FaceEnrollmentScreenState extends State icon: Icons.refresh, backgroundColor: AppTheme.primaryOrange, onPressed: () { + setState(() => _successHandled = false); provider.reset(); _restartCameraStream(); }, @@ -765,6 +771,7 @@ class _FaceEnrollmentScreenState extends State String _getStatusText(FaceProvider provider) { switch (provider.recordingState) { case VideoRecordingState.idle: + case VideoRecordingState.ready: return ''; case VideoRecordingState.recording: return ''; @@ -781,6 +788,8 @@ class _FaceEnrollmentScreenState extends State switch (provider.recordingState) { case VideoRecordingState.idle: return Icons.face; + case VideoRecordingState.ready: + return Icons.check_circle_outline; case VideoRecordingState.recording: return Icons.videocam; case VideoRecordingState.uploading: @@ -796,6 +805,8 @@ class _FaceEnrollmentScreenState extends State switch (provider.recordingState) { case VideoRecordingState.idle: return Colors.white; + case VideoRecordingState.ready: + return AppTheme.statusGreen; case VideoRecordingState.recording: return Colors.white; case VideoRecordingState.uploading: diff --git a/lib/screens/profile/face_test_screen.dart b/lib/screens/profile/face_test_screen.dart index fa56f49..014f8b4 100644 --- a/lib/screens/profile/face_test_screen.dart +++ b/lib/screens/profile/face_test_screen.dart @@ -51,6 +51,12 @@ class _FaceTestScreenState extends State { return 'Siap — mulai verifikasi'; } + Color get _resultColor { + if (_statusText == 'APPROVED') return AppTheme.statusGreen; + if (_statusText == 'PENDING') return AppTheme.statusYellow; + return AppTheme.statusRed; + } + @override void initState() { super.initState(); @@ -261,9 +267,7 @@ class _FaceTestScreenState extends State { ScannerOverlay( borderColor: _showResult - ? (_verified == true - ? AppTheme.statusGreen - : AppTheme.statusRed) + ? _resultColor : _allChecksValid ? AppTheme.statusGreen : _isFaceDetected @@ -300,16 +304,16 @@ class _FaceTestScreenState extends State { _isVerifying ? "Memverifikasi wajah..." : _showResult - ? (_verified == true + ? (_statusText == 'APPROVED' ? "Wajah Terverifikasi ✓" - : "Wajah Tidak Cocok ✗") + : _statusText == 'PENDING' + ? "Verifikasi Meragukan ⚠️" + : "Wajah Tidak Cocok ✗") : _qualityMessage, textAlign: TextAlign.center, style: AppTheme.bodyLarge.copyWith( color: _showResult - ? (_verified == true - ? AppTheme.statusGreen - : AppTheme.statusRed) + ? _resultColor : _allChecksValid ? AppTheme.statusGreen : Colors.white, @@ -373,31 +377,28 @@ class _FaceTestScreenState extends State { Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( - color: (_verified == true - ? AppTheme.statusGreen - : AppTheme.statusRed) - .withOpacity(0.1), + color: _resultColor.withOpacity(0.1), shape: BoxShape.circle, ), child: Icon( - _verified == true + _statusText == 'APPROVED' ? Icons.check_circle - : Icons.cancel, - color: _verified == true - ? AppTheme.statusGreen - : AppTheme.statusRed, + : _statusText == 'PENDING' + ? Icons.warning_amber_rounded + : Icons.cancel, + color: _resultColor, size: 40, ), ), const SizedBox(height: 12), Text( - _verified == true + _statusText == 'APPROVED' ? "Verifikasi Berhasil" - : "Verifikasi Gagal", + : _statusText == 'PENDING' + ? "Verifikasi Diragukan" + : "Verifikasi Gagal", style: AppTheme.heading3.copyWith( - color: _verified == true - ? AppTheme.statusGreen - : AppTheme.statusRed, + color: _resultColor, ), ), const SizedBox(height: 4), @@ -422,15 +423,15 @@ class _FaceTestScreenState extends State { ), const SizedBox(height: 2), Text( - _confidence! >= 0.85 - ? "Sangat cocok (≥ 85%)" - : _confidence! >= 0.65 - ? "Cukup cocok (65-85%)" - : "Tidak cocok (< 65%)", + _confidence! >= 0.75 + ? "Sangat cocok (≥ 75%)" + : _confidence! >= 0.55 + ? "Meragukan (55-74%)" + : "Tidak cocok (< 55%)", style: AppTheme.bodySmall.copyWith( - color: _confidence! >= 0.85 + color: _confidence! >= 0.75 ? AppTheme.statusGreen - : _confidence! >= 0.65 + : _confidence! >= 0.55 ? AppTheme.statusYellow : AppTheme.statusRed, ), diff --git a/lib/screens/profile/profile_screen.dart b/lib/screens/profile/profile_screen.dart index ee8f1f4..ae4963a 100644 --- a/lib/screens/profile/profile_screen.dart +++ b/lib/screens/profile/profile_screen.dart @@ -325,14 +325,22 @@ class _ProfileScreenState extends State { icon: Icons.face, label: "Registrasi Wajah", onTap: () => Navigator.pushNamed(context, '/onboarding/face-enrollment'), - trailingText: context.watch().faceStatus2['is_registered'] == true - ? "Terdaftar" - : "Belum Terdaftar", - trailingColor: context.watch().faceStatus2['is_registered'] == true - ? AppTheme.statusGreen - : AppTheme.statusRed, + trailingText: (() { + final status = context.watch().faceStatus2['status']; + if (status == 'verified') return 'Terverifikasi'; + if (status == 'pending') return 'Menunggu HRD'; + if (status == 'rejected') return 'Ditolak (Ulangi)'; + return 'Belum Terdaftar'; + })(), + trailingColor: (() { + final status = context.watch().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().faceStatus2['is_registered'] == true) + if (context.watch().faceStatus2['status'] == 'verified') _buildActionItem( icon: Icons.face_retouching_natural, label: "Test Pengenalan Wajah", diff --git a/lib/screens/profile/signature_screen.dart b/lib/screens/profile/signature_screen.dart index 237d340..5354c10 100644 --- a/lib/screens/profile/signature_screen.dart +++ b/lib/screens/profile/signature_screen.dart @@ -23,6 +23,7 @@ class SignatureScreen extends StatefulWidget { class _SignatureScreenState extends State { late HandSignatureControl _signatureController; bool _isScrollable = true; + final GlobalKey _signatureKey = GlobalKey(); @override @@ -57,16 +58,12 @@ class _SignatureScreenState extends State { Future _captureCanvas() async { try { - final byteData = await _signatureController.toImage( - width: 1080, - height: 720, - color: const Color(0xFF1E293B), - background: Colors.white, - fit: true, - border: 20, - format: ImageByteFormat.png, - ); - + final boundary = _signatureKey.currentContext?.findRenderObject() as RenderRepaintBoundary?; + if (boundary == null) return null; + + final image = await boundary.toImage(pixelRatio: 3.0); + final byteData = await image.toByteData(format: ImageByteFormat.png); + if (byteData == null) return null; return byteData.buffer.asUint8List(); } catch (e) { @@ -279,14 +276,18 @@ class _SignatureScreenState extends State { child: ClipRRect( borderRadius: BorderRadius.circular(AppTheme.radiusSm - 1), child: RepaintBoundary( - child: HandSignature( - control: _signatureController, - color: const Color(0xFF1E293B), - width: 2.5, - maxWidth: 5.5, - type: SignatureDrawType.shape, - onPointerDown: () => setState(() => _isScrollable = false), - onPointerUp: () => setState(() => _isScrollable = true), + key: _signatureKey, + child: Container( + color: Colors.white, + child: HandSignature( + control: _signatureController, + color: const Color(0xFF1E293B), + width: 2.5, + maxWidth: 5.5, + type: SignatureDrawType.shape, + onPointerDown: () => setState(() => _isScrollable = false), + onPointerUp: () => setState(() => _isScrollable = true), + ), ), ), diff --git a/lib/services/fcm_service.dart b/lib/services/fcm_service.dart index 036e6dc..ee0dc4d 100644 --- a/lib/services/fcm_service.dart +++ b/lib/services/fcm_service.dart @@ -46,9 +46,10 @@ class FcmService { _messaging.onTokenRefresh.listen((t) => _repository.saveDeviceToken(t)); FirebaseMessaging.onMessage.listen((msg) { - if (context.mounted) { - _showBanner(context, msg); - context.read().fetchUnreadCount(); + final currentContext = navigatorKey.currentContext; + if (currentContext != null && currentContext.mounted) { + _showBanner(currentContext, msg); + currentContext.read().fetchUnreadCount(); } _showSystemNotification(msg); }); @@ -81,10 +82,12 @@ class FcmService { if (tipe == null) return Icons.notifications_outlined; if (tipe.contains('disetujui')) return Icons.check_circle_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('lembur')) return Icons.schedule_rounded; if (tipe.contains('presensi')) return Icons.fingerprint_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; return Icons.notifications_outlined; } @@ -96,6 +99,9 @@ class FcmService { if (tipe.contains('pengumuman')) return AppTheme.primaryBlue; if (tipe.contains('lembur')) return const Color(0xFFf59e0b); 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); }