657 lines
19 KiB
Dart
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);
|
|
}
|
|
}
|