import 'dart:io' show Platform; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:timezone/data/latest.dart' as tz_data; import 'package:timezone/timezone.dart' as tz; import '../local/models/app_settings.dart'; import 'notification_analytics_service.dart'; import 'notification_runtime_service.dart'; class NotificationPermissionStatus { const NotificationPermissionStatus({ required this.notificationsAllowed, required this.exactAlarmAllowed, }); final bool notificationsAllowed; final bool exactAlarmAllowed; } class NotificationPendingAlert { const NotificationPendingAlert({ required this.id, required this.type, required this.title, required this.body, required this.scheduledAt, }); final int id; final String type; final String title; final String body; final DateTime? scheduledAt; } /// Notification service for Adzan and Iqamah reminders. /// /// This service owns the local notifications setup, permission requests, /// timezone setup, and scheduling lifecycle for prayer notifications. class NotificationService { NotificationService._(); static final NotificationService instance = NotificationService._(); final FlutterLocalNotificationsPlugin _plugin = FlutterLocalNotificationsPlugin(); bool _initialized = false; String? _lastSyncSignature; static const int _checklistReminderId = 920001; static const _adhanDetails = NotificationDetails( android: AndroidNotificationDetails( 'adhan_channel', 'Adzan Notifications', channelDescription: 'Pengingat waktu adzan', importance: Importance.max, priority: Priority.high, playSound: true, ), iOS: DarwinNotificationDetails( presentAlert: true, presentBadge: true, presentSound: true, ), macOS: DarwinNotificationDetails( presentAlert: true, presentBadge: true, presentSound: true, ), ); static const _iqamahDetails = NotificationDetails( android: AndroidNotificationDetails( 'iqamah_channel', 'Iqamah Reminders', channelDescription: 'Pengingat waktu iqamah', importance: Importance.high, priority: Priority.high, playSound: true, ), iOS: DarwinNotificationDetails( presentAlert: true, presentSound: true, ), macOS: DarwinNotificationDetails( presentAlert: true, presentSound: true, ), ); static const _habitDetails = NotificationDetails( android: AndroidNotificationDetails( 'habit_channel', 'Pengingat Ibadah Harian', channelDescription: 'Pengingat checklist, streak, dan kebiasaan ibadah', importance: Importance.high, priority: Priority.high, playSound: true, ), iOS: DarwinNotificationDetails( presentAlert: true, presentSound: true, ), macOS: DarwinNotificationDetails( presentAlert: true, presentSound: true, ), ); static const _systemDetails = NotificationDetails( android: AndroidNotificationDetails( 'system_channel', 'Peringatan Sistem', channelDescription: 'Peringatan status izin dan sinkronisasi jadwal', importance: Importance.defaultImportance, priority: Priority.defaultPriority, playSound: false, ), iOS: DarwinNotificationDetails( presentAlert: true, presentSound: false, ), macOS: DarwinNotificationDetails( presentAlert: true, presentSound: false, ), ); /// Initialize plugin, permissions, and timezone once. Future init() async { if (_initialized) return; tz_data.initializeTimeZones(); _configureLocalTimeZone(); const androidSettings = AndroidInitializationSettings('@mipmap/ic_launcher'); const darwinSettings = DarwinInitializationSettings( requestAlertPermission: false, requestBadgePermission: false, requestSoundPermission: false, ); const settings = InitializationSettings( android: androidSettings, iOS: darwinSettings, macOS: darwinSettings, ); await _plugin.initialize(settings: settings); await _requestPermissions(); _initialized = true; } void _configureLocalTimeZone() { final tzId = _resolveTimeZoneIdByOffset(DateTime.now().timeZoneOffset); try { tz.setLocalLocation(tz.getLocation(tzId)); } catch (_) { tz.setLocalLocation(tz.UTC); } } // We prioritize Indonesian zones for better prayer scheduling defaults. String _resolveTimeZoneIdByOffset(Duration offset) { switch (offset.inMinutes) { case 420: return 'Asia/Jakarta'; case 480: return 'Asia/Makassar'; case 540: return 'Asia/Jayapura'; default: if (offset.inMinutes % 60 == 0) { final etcHours = -(offset.inMinutes ~/ 60); final sign = etcHours >= 0 ? '+' : ''; return 'Etc/GMT$sign$etcHours'; } return 'UTC'; } } Future _requestPermissions() async { if (Platform.isAndroid) { final androidPlugin = _plugin.resolvePlatformSpecificImplementation< AndroidFlutterLocalNotificationsPlugin>(); await androidPlugin?.requestNotificationsPermission(); await androidPlugin?.requestExactAlarmsPermission(); return; } if (Platform.isIOS) { await _plugin .resolvePlatformSpecificImplementation< IOSFlutterLocalNotificationsPlugin>() ?.requestPermissions(alert: true, badge: true, sound: true); return; } if (Platform.isMacOS) { await _plugin .resolvePlatformSpecificImplementation< MacOSFlutterLocalNotificationsPlugin>() ?.requestPermissions(alert: true, badge: true, sound: true); } } Future syncPrayerNotifications({ required String cityId, required Map adhanEnabled, required Map iqamahOffset, required Map> schedulesByDate, }) async { await init(); final hasAnyEnabled = adhanEnabled.values.any((v) => v); if (!hasAnyEnabled) { await cancelAllPending(); _lastSyncSignature = null; return; } final signature = _buildSyncSignature( cityId, adhanEnabled, iqamahOffset, schedulesByDate); if (_lastSyncSignature == signature) return; await cancelAllPending(); final now = DateTime.now(); final dateEntries = schedulesByDate.entries.toList() ..sort((a, b) => a.key.compareTo(b.key)); for (final dateEntry in dateEntries) { final date = DateTime.tryParse(dateEntry.key); if (date == null) continue; for (final prayerKey in const [ 'subuh', 'dzuhur', 'ashar', 'maghrib', 'isya', ]) { final canonicalPrayer = _canonicalPrayerKey(prayerKey); if (canonicalPrayer == null) continue; if (!(adhanEnabled[canonicalPrayer] ?? false)) continue; final rawTime = (dateEntry.value[prayerKey] ?? '').trim(); final prayerTime = _parseScheduleDateTime(date, rawTime); if (prayerTime == null || !prayerTime.isAfter(now)) continue; await _scheduleAdhan( id: _notificationId( cityId: cityId, dateKey: dateEntry.key, prayerKey: canonicalPrayer, isIqamah: false, ), prayerName: _localizedPrayerName(canonicalPrayer), time: prayerTime, ); final offsetMinutes = iqamahOffset[canonicalPrayer] ?? 0; if (offsetMinutes <= 0) continue; final iqamahTime = prayerTime.add(Duration(minutes: offsetMinutes)); if (!iqamahTime.isAfter(now)) continue; await _scheduleIqamah( id: _notificationId( cityId: cityId, dateKey: dateEntry.key, prayerKey: canonicalPrayer, isIqamah: true, ), prayerName: _localizedPrayerName(canonicalPrayer), iqamahTime: iqamahTime, offsetMinutes: offsetMinutes, ); } } _lastSyncSignature = signature; } Future _scheduleAdhan({ required int id, required String prayerName, required DateTime time, }) async { await _plugin.zonedSchedule( id: id, title: 'Adzan • $prayerName', body: 'Waktu sholat $prayerName telah masuk.', scheduledDate: tz.TZDateTime.from(time, tz.local), notificationDetails: _adhanDetails, androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle, payload: 'adhan|$prayerName|${time.toIso8601String()}', ); await NotificationAnalyticsService.instance.track( 'notif_push_scheduled', dimensions: { 'event_type': 'adhan', 'prayer': prayerName, }, ); } Future _scheduleIqamah({ required int id, required String prayerName, required DateTime iqamahTime, required int offsetMinutes, }) async { await _plugin.zonedSchedule( id: id, title: 'Iqamah • $prayerName', body: 'Iqamah $prayerName dalam $offsetMinutes menit.', scheduledDate: tz.TZDateTime.from(iqamahTime, tz.local), notificationDetails: _iqamahDetails, androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle, payload: 'iqamah|$prayerName|${iqamahTime.toIso8601String()}', ); await NotificationAnalyticsService.instance.track( 'notif_push_scheduled', dimensions: { 'event_type': 'iqamah', 'prayer': prayerName, }, ); } DateTime? _parseScheduleDateTime(DateTime date, String hhmm) { final match = RegExp(r'^(\d{1,2}):(\d{2})').firstMatch(hhmm); if (match == null) return null; final hour = int.tryParse(match.group(1) ?? ''); final minute = int.tryParse(match.group(2) ?? ''); if (hour == null || minute == null) return null; if (hour < 0 || hour > 23 || minute < 0 || minute > 59) return null; return DateTime(date.year, date.month, date.day, hour, minute); } String? _canonicalPrayerKey(String scheduleKey) { switch (scheduleKey) { case 'subuh': case 'fajr': return 'fajr'; case 'dzuhur': case 'dhuhr': return 'dhuhr'; case 'ashar': case 'asr': return 'asr'; case 'maghrib': return 'maghrib'; case 'isya': case 'isha': return 'isha'; default: return null; } } String _localizedPrayerName(String canonicalPrayerKey) { switch (canonicalPrayerKey) { case 'fajr': return 'Subuh'; case 'dhuhr': return 'Dzuhur'; case 'asr': return 'Ashar'; case 'maghrib': return 'Maghrib'; case 'isha': return 'Isya'; default: return canonicalPrayerKey; } } int _notificationId({ required String cityId, required String dateKey, required String prayerKey, required bool isIqamah, }) { final seed = '$cityId|$dateKey|$prayerKey|${isIqamah ? 'iqamah' : 'adhan'}'; var hash = 17; for (final rune in seed.runes) { hash = 37 * hash + rune; } final bounded = hash.abs() % 700000; return isIqamah ? bounded + 800000 : bounded + 100000; } String _buildSyncSignature( String cityId, Map adhanEnabled, Map iqamahOffset, Map> schedulesByDate, ) { final sortedAdhan = adhanEnabled.entries.toList() ..sort((a, b) => a.key.compareTo(b.key)); final sortedIqamah = iqamahOffset.entries.toList() ..sort((a, b) => a.key.compareTo(b.key)); final sortedDates = schedulesByDate.entries.toList() ..sort((a, b) => a.key.compareTo(b.key)); final buffer = StringBuffer(cityId); for (final e in sortedAdhan) { buffer.write('|${e.key}:${e.value ? 1 : 0}'); } for (final e in sortedIqamah) { buffer.write('|${e.key}:${e.value}'); } for (final dateEntry in sortedDates) { buffer.write('|${dateEntry.key}'); final times = dateEntry.value.entries.toList() ..sort((a, b) => a.key.compareTo(b.key)); for (final t in times) { buffer.write('|${t.key}:${t.value}'); } } return buffer.toString(); } Future cancelAllPending() async { try { await _plugin.cancelAllPendingNotifications(); } catch (_) { await _plugin.cancelAll(); } } Future pendingCount() async { final pending = await _plugin.pendingNotificationRequests(); return pending.length; } Future syncHabitNotifications({ required AppSettings settings, }) async { await init(); if (!settings.alertsEnabled || !settings.dailyChecklistReminderEnabled) { await cancelChecklistReminder(); return; } final reminderTime = settings.checklistReminderTime ?? '09:00'; final parts = _parseHourMinute(reminderTime); if (parts == null) { await cancelChecklistReminder(); return; } final now = DateTime.now(); var target = DateTime( now.year, now.month, now.day, parts.$1, parts.$2, ); if (!target.isAfter(now)) { target = target.add(const Duration(days: 1)); } await _plugin.zonedSchedule( id: _checklistReminderId, title: 'Checklist Ibadah Harian', body: 'Jangan lupa perbarui progres ibadah hari ini.', scheduledDate: tz.TZDateTime.from(target, tz.local), notificationDetails: _habitDetails, androidScheduleMode: AndroidScheduleMode.inexactAllowWhileIdle, matchDateTimeComponents: DateTimeComponents.time, payload: 'checklist|daily|${target.toIso8601String()}', ); } Future cancelChecklistReminder() async { await _plugin.cancel(id: _checklistReminderId); } int nonPrayerNotificationId(String seed) { var hash = 17; for (final rune in seed.runes) { hash = 41 * hash + rune; } return 900000 + (hash.abs() % 80000); } Future showNonPrayerAlert({ required AppSettings settings, required int id, required String title, required String body, String payloadType = 'system', bool silent = false, bool bypassQuietHours = false, bool bypassDailyCap = false, }) async { await init(); final runtime = NotificationRuntimeService.instance; if (!settings.alertsEnabled) return false; if (!bypassQuietHours && runtime.isWithinQuietHours(settings)) return false; if (!bypassDailyCap && runtime.nonPrayerPushCountToday() >= settings.maxNonPrayerPushPerDay) { return false; } await _plugin.show( id: id, title: title, body: body, notificationDetails: silent ? _systemDetails : _habitDetails, payload: '$payloadType|non_prayer|${DateTime.now().toIso8601String()}', ); if (!bypassDailyCap) { await runtime.incrementNonPrayerPushCount(); } await NotificationAnalyticsService.instance.track( 'notif_push_fired', dimensions: { 'event_type': payloadType, 'channel': 'push', }, ); return true; } Future getPermissionStatus() async { await init(); try { if (Platform.isAndroid) { final androidPlugin = _plugin.resolvePlatformSpecificImplementation< AndroidFlutterLocalNotificationsPlugin>(); final notificationsAllowed = await androidPlugin?.areNotificationsEnabled() ?? true; final exactAlarmAllowed = await androidPlugin?.canScheduleExactNotifications() ?? true; return NotificationPermissionStatus( notificationsAllowed: notificationsAllowed, exactAlarmAllowed: exactAlarmAllowed, ); } if (Platform.isIOS) { final iosPlugin = _plugin.resolvePlatformSpecificImplementation< IOSFlutterLocalNotificationsPlugin>(); final options = await iosPlugin?.checkPermissions(); return NotificationPermissionStatus( notificationsAllowed: options?.isEnabled ?? true, exactAlarmAllowed: true, ); } if (Platform.isMacOS) { final macPlugin = _plugin.resolvePlatformSpecificImplementation< MacOSFlutterLocalNotificationsPlugin>(); final options = await macPlugin?.checkPermissions(); return NotificationPermissionStatus( notificationsAllowed: options?.isEnabled ?? true, exactAlarmAllowed: true, ); } } catch (_) { // Fallback to non-blocking defaults if platform query fails. } return const NotificationPermissionStatus( notificationsAllowed: true, exactAlarmAllowed: true, ); } Future> pendingAlerts() async { final pending = await _plugin.pendingNotificationRequests(); final alerts = pending.map(_mapPendingRequest).toList() ..sort((a, b) { final aTime = a.scheduledAt; final bTime = b.scheduledAt; if (aTime == null && bTime == null) return a.id.compareTo(b.id); if (aTime == null) return 1; if (bTime == null) return -1; return aTime.compareTo(bTime); }); return alerts; } NotificationPendingAlert _mapPendingRequest(PendingNotificationRequest raw) { final payload = raw.payload ?? ''; final parts = payload.split('|'); if (parts.length >= 3) { final type = parts[0].trim().toLowerCase(); final title = raw.title ?? '${_labelForType(type)} • ${parts[1].trim()}'; final body = raw.body ?? ''; final scheduledAt = DateTime.tryParse(parts[2].trim()); return NotificationPendingAlert( id: raw.id, type: type, title: title, body: body, scheduledAt: scheduledAt, ); } final fallbackType = _inferTypeFromTitle(raw.title ?? ''); return NotificationPendingAlert( id: raw.id, type: fallbackType, title: raw.title ?? 'Pengingat', body: raw.body ?? '', scheduledAt: null, ); } String _inferTypeFromTitle(String title) { final normalized = title.toLowerCase(); if (normalized.contains('iqamah')) return 'iqamah'; if (normalized.contains('adzan')) return 'adhan'; return 'alert'; } String _labelForType(String type) { switch (type) { case 'adhan': return 'Adzan'; case 'iqamah': return 'Iqamah'; case 'checklist': return 'Checklist'; case 'streak_risk': return 'Streak'; case 'system': return 'Sistem'; default: return 'Pengingat'; } } (int, int)? _parseHourMinute(String hhmm) { final match = RegExp(r'^(\d{1,2}):(\d{2})$').firstMatch(hhmm.trim()); if (match == null) return null; final hour = int.tryParse(match.group(1) ?? ''); final minute = int.tryParse(match.group(2) ?? ''); if (hour == null || minute == null) return null; if (hour < 0 || hour > 23 || minute < 0 || minute > 59) return null; return (hour, minute); } }