import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../core/theme.dart'; import '../providers/notification_provider.dart'; import '../repositories/notifikasi_repository.dart'; import '../providers/pengajuan_provider.dart'; import '../providers/poin_provider.dart'; import '../providers/attendance_provider.dart'; import '../main.dart'; @pragma('vm:entry-point') Future firebaseMessagingBackgroundHandler(RemoteMessage message) async { if (message.notification != null) return; final title = message.data['title'] ?? ''; final body = message.data['body'] ?? ''; if (title.isEmpty) return; final plugin = FlutterLocalNotificationsPlugin(); const channel = AndroidNotificationChannel( 'hris_channel', 'HRIS Notifications', importance: Importance.max, ); final androidPlugin = plugin.resolvePlatformSpecificImplementation(); await androidPlugin?.createNotificationChannel(channel); await plugin.initialize( settings: const InitializationSettings( android: AndroidInitializationSettings('ic_launcher'), ), ); await plugin.show( id: message.hashCode, title: title, body: body, notificationDetails: const NotificationDetails( android: AndroidNotificationDetails( 'hris_channel', 'HRIS Notifications', importance: Importance.max, priority: Priority.high, icon: 'ic_launcher', ), ), // MB-07: Simpan payload agar onDidReceiveNotificationResponse bisa routing ke /notification payload: 'notification:${message.data['tipe'] ?? ''}', ); } class FcmService { static final FcmService _instance = FcmService._internal(); factory FcmService() => _instance; FcmService._internal(); final FirebaseMessaging _messaging = FirebaseMessaging.instance; final FlutterLocalNotificationsPlugin _localNotifications = FlutterLocalNotificationsPlugin(); final NotifikasiRepository _repository = NotifikasiRepository(); bool _initialized = false; Future initialize(BuildContext context) async { // Selalu refresh token FCM ke server setiap initialize (tidak diblokir _initialized) await _refreshAndSaveToken(); if (_initialized) return; _initialized = true; final settings = await _messaging.requestPermission( alert: true, badge: true, sound: true, ); const androidSettings = AndroidInitializationSettings('ic_launcher'); const initSettings = InitializationSettings(android: androidSettings); await _localNotifications.initialize( settings: initSettings, onDidReceiveNotificationResponse: _onNotificationTap, ); const AndroidNotificationChannel channel = AndroidNotificationChannel( 'hris_channel', 'HRIS Notifications', description: 'Notifikasi untuk sistem HRIS MP', importance: Importance.max, ); final androidImplementation = _localNotifications .resolvePlatformSpecificImplementation< AndroidFlutterLocalNotificationsPlugin>(); await androidImplementation?.createNotificationChannel(channel); // Explicitly request notification permission for Android 13+ await androidImplementation?.requestNotificationsPermission(); if (settings.authorizationStatus == AuthorizationStatus.denied) return; _messaging.onTokenRefresh.listen((t) => _repository.saveDeviceToken(t)); FirebaseMessaging.onMessage.listen((msg) { final currentContext = navigatorKey.currentContext; if (currentContext != null && currentContext.mounted) { _showBanner(currentContext, msg); // --- ⚡ AUTO-REFRESH REALTIME LOGIC ⚡ --- // 1. Selalu update badge dan list notifikasi currentContext.read().fetchUnreadCount(); currentContext.read().fetchNotifications(); final tipe = (msg.data['tipe'] as String?)?.toLowerCase() ?? ''; // 2. Jika notif ttg Izin/Cuti/Lembur, refresh tabel Riwayat Pengajuan if (tipe.contains('izin') || tipe.contains('cuti') || tipe.contains('lembur') || tipe.contains('pengajuan')) { currentContext.read().fetchPengajuan(); } // 3. Jika notif ttg Poin, refresh saldo Poin & Riwayat Poin if (tipe.contains('poin')) { currentContext.read().loadExpiringPoints(force: true); currentContext.read().loadPointHistory(); } // 4. Jika notif ttg Presensi, refresh history presensi if (tipe.contains('presensi') || tipe.contains('absen')) { currentContext.read().fetchHistory(force: true); } } _showSystemNotification(msg); }); FirebaseMessaging.onMessageOpenedApp.listen((msg) { _navigateToNotification(); }); final initial = await _messaging.getInitialMessage(); if (initial != null) { Future.delayed(const Duration(seconds: 1), () { _navigateToNotification(); }); } } void _onNotificationTap(NotificationResponse details) { _navigateToNotification(); } void _navigateToNotification() { final nav = navigatorKey.currentState; if (nav != null) { nav.pushNamedAndRemoveUntil('/home', (route) => false); nav.pushNamed('/notification'); } } /// Selalu ambil FCM token terbaru dan simpan ke server. /// Dipanggil setiap kali initialize() agar token tidak kadaluarsa. Future _refreshAndSaveToken() async { try { final token = await _messaging.getToken(); if (token != null) { await _repository.saveDeviceToken(token); } } catch (_) { // Abaikan error saat refresh token (misal: tidak ada koneksi) } } IconData _iconForTipe(String? tipe) { 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; } Color _colorForTipe(String? tipe) { if (tipe == null) return AppTheme.primaryBlue; if (tipe.contains('disetujui')) return const Color(0xFF10b981); if (tipe.contains('ditolak')) return const Color(0xFFef4444); 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); } void _showBanner(BuildContext context, RemoteMessage message) { final title = message.notification?.title ?? ''; final body = message.notification?.body ?? ''; final tipe = message.data['tipe'] as String?; final color = _colorForTipe(tipe); final icon = _iconForTipe(tipe); if (title.isEmpty) return; final overlay = Overlay.of(context); late OverlayEntry entry; entry = OverlayEntry( builder: (context) => _NotificationBanner( title: title, body: body, icon: icon, color: color, onTap: () { entry.remove(); _navigateToNotification(); }, onDismiss: () => entry.remove(), ), ); overlay.insert(entry); Future.delayed(const Duration(seconds: 5), () { if (entry.mounted) entry.remove(); }); } Future _showSystemNotification(RemoteMessage message) async { final title = message.notification?.title ?? ''; final body = message.notification?.body ?? ''; if (title.isEmpty) return; const androidDetails = AndroidNotificationDetails( 'hris_channel', 'HRIS Notifications', importance: Importance.max, priority: Priority.high, icon: 'ic_launcher', ); const details = NotificationDetails(android: androidDetails); await _localNotifications.show( id: message.hashCode, title: title, body: body, notificationDetails: details, // MB-07: Payload konsisten agar onDidReceiveNotificationResponse bisa routing ke /notification payload: 'notification:${message.data['tipe'] ?? ''}', ); } } class _NotificationBanner extends StatefulWidget { final String title; final String body; final IconData icon; final Color color; final VoidCallback onTap; final VoidCallback onDismiss; const _NotificationBanner({ required this.title, required this.body, required this.icon, required this.color, required this.onTap, required this.onDismiss, }); @override State<_NotificationBanner> createState() => _NotificationBannerState(); } class _NotificationBannerState extends State<_NotificationBanner> with SingleTickerProviderStateMixin { late AnimationController _controller; late Animation _slideAnimation; late Animation _fadeAnimation; @override void initState() { super.initState(); _controller = AnimationController( duration: const Duration(milliseconds: 400), vsync: this, ); _slideAnimation = Tween( begin: const Offset(0, -1), end: Offset.zero, ).animate(CurvedAnimation(parent: _controller, curve: Curves.easeOutCubic)); _fadeAnimation = Tween(begin: 0, end: 1).animate( CurvedAnimation(parent: _controller, curve: Curves.easeOut), ); _controller.forward(); } @override void dispose() { _controller.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final topPadding = MediaQuery.of(context).padding.top; return Positioned( top: topPadding + 8, left: 12, right: 12, child: SlideTransition( position: _slideAnimation, child: FadeTransition( opacity: _fadeAnimation, child: GestureDetector( onTap: widget.onTap, onVerticalDragEnd: (details) { if (details.primaryVelocity != null && details.primaryVelocity! < 0) { _controller.reverse().then((_) => widget.onDismiss()); } }, child: Material( elevation: 8, shadowColor: Colors.black26, borderRadius: BorderRadius.circular(16), child: Container( padding: const EdgeInsets.all(14), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(16), border: Border.all(color: widget.color.withValues(alpha: 0.15)), ), child: Row( children: [ Container( padding: const EdgeInsets.all(10), decoration: BoxDecoration( color: widget.color.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(12), ), child: Icon(widget.icon, color: widget.color, size: 22), ), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Text( widget.title, style: const TextStyle( fontSize: 13, fontWeight: FontWeight.w700, color: Color(0xFF1e293b), ), maxLines: 1, overflow: TextOverflow.ellipsis, ), const SizedBox(height: 2), Text( widget.body, style: TextStyle( fontSize: 12, color: const Color(0xFF64748b), height: 1.3, ), maxLines: 2, overflow: TextOverflow.ellipsis, ), ], ), ), const SizedBox(width: 8), Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), decoration: BoxDecoration( color: widget.color.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(8), ), child: Text( 'Lihat', style: TextStyle( fontSize: 11, fontWeight: FontWeight.w700, color: widget.color, ), ), ), ], ), ), ), ), ), ), ); } }