Files
jamshalat-diary/lib/data/services/notification_service.dart
2026-03-18 00:07:10 +07:00

657 lines
19 KiB
Dart

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<void> 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<void> _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<void> syncPrayerNotifications({
required String cityId,
required Map<String, bool> adhanEnabled,
required Map<String, int> iqamahOffset,
required Map<String, Map<String, String>> 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<void> _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: <String, dynamic>{
'event_type': 'adhan',
'prayer': prayerName,
},
);
}
Future<void> _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: <String, dynamic>{
'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<String, bool> adhanEnabled,
Map<String, int> iqamahOffset,
Map<String, Map<String, String>> 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<void> cancelAllPending() async {
try {
await _plugin.cancelAllPendingNotifications();
} catch (_) {
await _plugin.cancelAll();
}
}
Future<int> pendingCount() async {
final pending = await _plugin.pendingNotificationRequests();
return pending.length;
}
Future<void> 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<void> 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<bool> 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: <String, dynamic>{
'event_type': payloadType,
'channel': 'push',
},
);
return true;
}
Future<NotificationPermissionStatus> 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<List<NotificationPendingAlert>> 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);
}
}