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 Future<void> initialize() async {
await ApiConfigService.clearConfig();
await getBaseUrl();
await getImageBaseUrl();
}

View File

@ -6,7 +6,7 @@ class ApiConfigService {
static const String _selectedPresetKey = 'selected_preset';
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',
'emulator': 'http://10.0.2.2:8000/api',
'custom': '',

View File

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

View File

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

View File

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

View File

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

View File

@ -30,6 +30,7 @@ class _NotificationScreenState extends State<NotificationScreen> {
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<NotificationScreen> {
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);
}

View File

@ -26,6 +26,7 @@ class _FaceEnrollmentScreenState extends State<FaceEnrollmentScreen>
CameraController? _cameraController;
bool _isCameraInitialized = false;
CameraDescription? _frontCamera;
bool _successHandled = false;
late AnimationController _pulseController;
late Animation<double> _pulseAnimation;
@ -58,7 +59,7 @@ class _FaceEnrollmentScreenState extends State<FaceEnrollmentScreen>
_cameraController = CameraController(
_frontCamera!,
ResolutionPreset.medium,
ResolutionPreset.high,
enableAudio: false,
imageFormatGroup: Platform.isAndroid
? ImageFormatGroup.nv21
@ -72,8 +73,10 @@ class _FaceEnrollmentScreenState extends State<FaceEnrollmentScreen>
_cameraController!.startImageStream((CameraImage image) {
if (!mounted) return;
final provider = context.read<FaceProvider>();
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<FaceEnrollmentScreen>
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();
await provider.startRecording(_cameraController!);
}
@ -146,8 +130,19 @@ class _FaceEnrollmentScreenState extends State<FaceEnrollmentScreen>
),
body: Consumer<FaceProvider>(
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 =
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<FaceEnrollmentScreen>
),
),
// 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<FaceEnrollmentScreen>
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<FaceEnrollmentScreen>
),
),
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<FaceEnrollmentScreen>
padding: const EdgeInsets.only(top: 24),
child: CustomButton(
text: widget.isOnboarding
? "Lanjut: Tanda Tangan"
? (context.read<SetupCheckProvider>().hasSignature
? "Mulai Gunakan Aplikasi"
: "Lanjut: Tanda Tangan")
: "Selesai",
icon: widget.isOnboarding
? Icons.arrow_forward
? (context.read<SetupCheckProvider>().hasSignature
? Icons.home
: Icons.arrow_forward)
: Icons.check,
backgroundColor: AppTheme.statusGreen,
onPressed: () {
HapticFeedback.mediumImpact();
if (widget.isOnboarding) {
final hasSignature = context.read<SetupCheckProvider>().hasSignature;
context.read<SetupCheckProvider>().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<FaceEnrollmentScreen>
icon: Icons.refresh,
backgroundColor: AppTheme.primaryOrange,
onPressed: () {
setState(() => _successHandled = false);
provider.reset();
_restartCameraStream();
},
@ -765,6 +771,7 @@ class _FaceEnrollmentScreenState extends State<FaceEnrollmentScreen>
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<FaceEnrollmentScreen>
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<FaceEnrollmentScreen>
switch (provider.recordingState) {
case VideoRecordingState.idle:
return Colors.white;
case VideoRecordingState.ready:
return AppTheme.statusGreen;
case VideoRecordingState.recording:
return Colors.white;
case VideoRecordingState.uploading:

View File

@ -51,6 +51,12 @@ class _FaceTestScreenState extends State<FaceTestScreen> {
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<FaceTestScreen> {
ScannerOverlay(
borderColor: _showResult
? (_verified == true
? AppTheme.statusGreen
: AppTheme.statusRed)
? _resultColor
: _allChecksValid
? AppTheme.statusGreen
: _isFaceDetected
@ -300,16 +304,16 @@ class _FaceTestScreenState extends State<FaceTestScreen> {
_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<FaceTestScreen> {
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<FaceTestScreen> {
),
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,
),

View File

@ -325,14 +325,22 @@ class _ProfileScreenState extends State<ProfileScreen> {
icon: Icons.face,
label: "Registrasi Wajah",
onTap: () => Navigator.pushNamed(context, '/onboarding/face-enrollment'),
trailingText: context.watch<FaceProvider>().faceStatus2['is_registered'] == true
? "Terdaftar"
: "Belum Terdaftar",
trailingColor: context.watch<FaceProvider>().faceStatus2['is_registered'] == true
? AppTheme.statusGreen
: AppTheme.statusRed,
trailingText: (() {
final status = context.watch<FaceProvider>().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<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(
icon: Icons.face_retouching_natural,
label: "Test Pengenalan Wajah",

View File

@ -23,6 +23,7 @@ class SignatureScreen extends StatefulWidget {
class _SignatureScreenState extends State<SignatureScreen> {
late HandSignatureControl _signatureController;
bool _isScrollable = true;
final GlobalKey _signatureKey = GlobalKey();
@override
@ -57,15 +58,11 @@ class _SignatureScreenState extends State<SignatureScreen> {
Future<Uint8List?> _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();
@ -279,14 +276,18 @@ class _SignatureScreenState extends State<SignatureScreen> {
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),
),
),
),

View File

@ -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<NotificationProvider>().fetchUnreadCount();
final currentContext = navigatorKey.currentContext;
if (currentContext != null && currentContext.mounted) {
_showBanner(currentContext, msg);
currentContext.read<NotificationProvider>().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);
}