Files
jamshalat-diary/lib/data/services/notification_service.dart
Dwindi Ramadhana 4badfb6521 Notification system audit: fix 6 defects, close 5 gaps, add rich notifications (v1.1.0)
Defects fixed:
- D1: Fix notification ID range collision (report reminders 700k→2M+)
- D2: Streak risk now checks both dzikir pagi & petang
- D3: _cancelPrayerPending no longer kills non-prayer notifications
- D4: Push notifications carry deeplink in payload for proper routing
- D5: Add reconfigureTimeZoneIfNeeded() for TZ change detection
- D6: Defer launch notification routing until widget tree is ready

Gaps closed:
- G1: Add streak risk + weekly summary toggles to settings UI
- G2: Verified boot reschedule already in place (flutter_local_notifications v21)
- G3: Remove unused mirrorAdzanToInbox field and legacy cleanup calls
- G4: Add notif_push_opened analytics tracking
- G5: Add notif_settings_changed analytics tracking

Enhancements:
- O1: Rich notification with Sudah Sholat action button on report reminders
- O2: Permission check on app resume via WidgetsBindingObserver (30s throttle)
- O2b: Fix stretched notification icon (white crescent moon vector drawable)
- O3: Expired inbox cleanup in background sync
- O4: Haptic feedback on notification bell quick actions

Bump version 1.0.8+9 → 1.1.0+10
2026-06-06 22:38:02 +07:00

986 lines
29 KiB
Dart

import 'dart:io' show Platform;
import 'package:flutter/widgets.dart' show Color, WidgetsFlutterBinding;
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:intl/intl.dart';
import 'package:timezone/data/latest.dart' as tz_data;
import 'package:timezone/timezone.dart' as tz;
import '../../app/router.dart';
import '../local/hive_boxes.dart';
import '../local/models/app_settings.dart';
import '../local/models/daily_worship_log.dart';
import '../local/models/shalat_log.dart';
import 'notification_analytics_service.dart';
import 'notification_runtime_service.dart';
@pragma('vm:entry-point')
void notificationTapBackgroundHandler(NotificationResponse response) {
final payload = response.payload ?? '';
final parts = payload.split('|');
final type = parts.first.trim().toLowerCase();
if (type == 'report' && response.actionId == 'action_prayed') {
_markPrayedFromBackground(payload);
}
}
@pragma('vm:entry-point')
Future<void> _markPrayedFromBackground(String payload) async {
final parts = payload.split('|');
if (parts.length < 2) return;
final prayerName = parts[1].trim().toLowerCase();
final prayerKey = _resolvePrayerKeyFromName(prayerName);
if (prayerKey == null) return;
WidgetsFlutterBinding.ensureInitialized();
await initHive();
final todayKey = DateFormat('yyyy-MM-dd').format(DateTime.now());
final worshipBox = Hive.box<DailyWorshipLog>(HiveBoxes.worshipLogs);
var log = worshipBox.get(todayKey);
if (log == null) {
log = DailyWorshipLog(
date: todayKey,
shalatLogs: {prayerKey: ShalatLog(completed: true)},
);
await worshipBox.put(todayKey, log);
} else {
log.shalatLogs[prayerKey] = ShalatLog(completed: true);
await log.save();
}
}
@pragma('vm:entry-point')
String? _resolvePrayerKeyFromName(String name) {
switch (name.toLowerCase()) {
case 'subuh':
case 'fajr':
return 'subuh';
case 'dzuhur':
case 'dhuhr':
return 'dzuhur';
case 'ashar':
case 'asr':
return 'ashar';
case 'maghrib':
return 'maghrib';
case 'isya':
case 'isha':
return 'isya';
default:
return null;
}
}
String? routeForNotificationPayload(String? payload) {
final parts = (payload ?? '').split('|');
final type = parts.first.trim().toLowerCase();
switch (type) {
case 'report':
case 'checklist':
return '/checklist';
case 'adhan':
case 'iqamah':
return '/';
case 'streak_risk':
// Payload format: streak_risk|<label>|<time>|<deeplink>
if (parts.length >= 4 && parts[3].trim().isNotEmpty) {
return parts[3].trim();
}
return '/notifications';
case 'remote':
case 'content':
case 'system':
return '/notifications';
default:
return null;
}
}
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,
icon: '@drawable/ic_notification',
),
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,
icon: '@drawable/ic_notification',
),
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,
icon: '@drawable/ic_notification',
),
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,
icon: '@drawable/ic_notification',
),
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('@drawable/ic_notification');
const darwinSettings = DarwinInitializationSettings(
requestAlertPermission: false,
requestBadgePermission: false,
requestSoundPermission: false,
);
const settings = InitializationSettings(
android: androidSettings,
iOS: darwinSettings,
macOS: darwinSettings,
);
await _plugin.initialize(
settings: settings,
onDidReceiveNotificationResponse: _handleNotificationResponse,
onDidReceiveBackgroundNotificationResponse:
notificationTapBackgroundHandler,
);
await _requestPermissions();
await _handleLaunchNotification();
_initialized = true;
}
String? _pendingLaunchRoute;
Future<void> _handleLaunchNotification() async {
final details = await _plugin.getNotificationAppLaunchDetails();
final response = details?.notificationResponse;
if (response == null) return;
_pendingLaunchRoute = routeForNotificationPayload(response.payload);
}
/// Navigate to pending launch route after widget tree is mounted.
void consumePendingLaunchRoute() {
final route = _pendingLaunchRoute;
_pendingLaunchRoute = null;
if (route == null) return;
NotificationAnalyticsService.instance.track(
'notif_push_opened',
dimensions: const <String, dynamic>{
'source': 'launch',
},
);
Future<void>.delayed(const Duration(milliseconds: 800), () {
appRouter.go(route);
});
}
void _handleNotificationResponse(NotificationResponse response) {
final payload = response.payload ?? '';
final type = payload.split('|').first.trim().toLowerCase();
NotificationAnalyticsService.instance.track(
'notif_push_opened',
dimensions: <String, dynamic>{
'event_type': type,
'source': 'foreground',
'action_id': response.actionId ?? '',
},
);
// Handle "Sudah Sholat" action button for foreground taps.
if (type == 'report' && response.actionId == 'action_prayed') {
_markPrayedFromForeground(payload);
return;
}
_routeFromPayload(response.payload);
}
/// Mark prayer as completed when user taps "Sudah Sholat" in foreground.
void _markPrayedFromForeground(String payload) {
final parts = payload.split('|');
if (parts.length < 2) return;
final prayerName = parts[1].trim().toLowerCase();
final prayerKey = _canonicalPrayerKey(prayerName) ?? prayerName;
final todayKey = DateFormat('yyyy-MM-dd').format(DateTime.now());
final worshipBox = Hive.box<DailyWorshipLog>(HiveBoxes.worshipLogs);
final log = worshipBox.get(todayKey);
if (log != null) {
log.shalatLogs[prayerKey] = ShalatLog(completed: true);
log.save();
}
}
void _routeFromPayload(String? payload) {
final route = routeForNotificationPayload(payload);
if (route == null) return;
Future<void>.delayed(const Duration(milliseconds: 500), () {
appRouter.go(route);
});
}
void _configureLocalTimeZone() {
final tzId = _resolveTimeZoneIdByOffset(DateTime.now().timeZoneOffset);
try {
tz.setLocalLocation(tz.getLocation(tzId));
} catch (_) {
tz.setLocalLocation(tz.UTC);
}
}
void reconfigureTimeZoneIfNeeded() {
final newOffset = DateTime.now().timeZoneOffset;
final tzId = _resolveTimeZoneIdByOffset(newOffset);
try {
final newLocation = tz.getLocation(tzId);
if (tz.local.name != newLocation.name) {
tz.setLocalLocation(newLocation);
_lastSyncSignature = null; // Force resync
}
} catch (_) {
// Ignore if timezone lookup fails.
}
}
// 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,
required bool reportReminderEnabled,
required int reportReminderDelayMinutes,
required int reportReminderRepeatCount,
required int reportReminderRepeatIntervalMinutes,
}) async {
await init();
final hasAnyEnabled = adhanEnabled.values.any((v) => v);
if (!hasAnyEnabled) {
await _cancelPrayerPending();
_lastSyncSignature = null;
return;
}
final signature = _buildSyncSignature(
cityId,
adhanEnabled,
iqamahOffset,
schedulesByDate,
reportReminderEnabled: reportReminderEnabled,
reportReminderDelayMinutes: reportReminderDelayMinutes,
reportReminderRepeatCount: reportReminderRepeatCount,
reportReminderRepeatIntervalMinutes: reportReminderRepeatIntervalMinutes,
);
if (_lastSyncSignature == signature) return;
await _cancelPrayerPending();
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,
);
if (!reportReminderEnabled || reportReminderDelayMinutes <= 0) continue;
final repeats =
reportReminderRepeatCount < 0 ? 0 : reportReminderRepeatCount;
final repeatGap = reportReminderRepeatIntervalMinutes <= 0
? 15
: reportReminderRepeatIntervalMinutes;
for (int index = 0; index <= repeats; index++) {
final reminderAt = prayerTime.add(
Duration(minutes: reportReminderDelayMinutes + (index * repeatGap)),
);
if (!reminderAt.isAfter(now)) continue;
await _scheduleShalatReportReminder(
id: _reportReminderId(
cityId: cityId,
dateKey: dateEntry.key,
prayerKey: canonicalPrayer,
reminderIndex: index,
),
prayerName: _localizedPrayerName(canonicalPrayer),
reminderTime: reminderAt,
reminderIndex: index,
);
}
}
}
_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;
}
int _reportReminderId({
required String cityId,
required String dateKey,
required String prayerKey,
required int reminderIndex,
}) {
final seed = '$cityId|$dateKey|$prayerKey|report|$reminderIndex';
var hash = 23;
for (final rune in seed.runes) {
hash = 43 * hash + rune;
}
return 2000000 + (hash.abs() % 90000);
}
String _buildSyncSignature(
String cityId,
Map<String, bool> adhanEnabled,
Map<String, int> iqamahOffset,
Map<String, Map<String, String>> schedulesByDate, {
required bool reportReminderEnabled,
required int reportReminderDelayMinutes,
required int reportReminderRepeatCount,
required int reportReminderRepeatIntervalMinutes,
}) {
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}');
}
buffer.write(
'|report:${reportReminderEnabled ? 1 : 0}:$reportReminderDelayMinutes:$reportReminderRepeatCount:$reportReminderRepeatIntervalMinutes',
);
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> _scheduleShalatReportReminder({
required int id,
required String prayerName,
required DateTime reminderTime,
required int reminderIndex,
}) async {
final attemptLabel = reminderIndex == 0 ? '' : ' (#${reminderIndex + 1})';
final reportDetails = NotificationDetails(
android: AndroidNotificationDetails(
'habit_channel',
'Pengingat Ibadah Harian',
channelDescription:
'Pengingat checklist, streak, dan kebiasaan ibadah',
importance: Importance.high,
priority: Priority.high,
playSound: true,
icon: '@drawable/ic_notification',
actions: <AndroidNotificationAction>[
AndroidNotificationAction(
'action_prayed',
'Sudah Sholat',
titleColor: const Color(0xFF4CAF50),
contextual: false,
showsUserInterface: false,
),
],
),
iOS: DarwinNotificationDetails(
presentAlert: true,
presentSound: true,
),
macOS: DarwinNotificationDetails(
presentAlert: true,
presentSound: true,
),
);
await _plugin.zonedSchedule(
id: id,
title: 'Lapor Shalat • $prayerName$attemptLabel',
body: 'Sudah sholat $prayerName? Yuk tandai di checklist sekarang.',
scheduledDate: tz.TZDateTime.from(reminderTime, tz.local),
notificationDetails: reportDetails,
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
payload: 'report|$prayerName|${reminderTime.toIso8601String()}',
);
}
Future<void> cancelShalatReportReminders({
required String cityId,
required String dateKey,
required String canonicalPrayer,
required int repeatCount,
}) async {
final repeats = repeatCount < 0 ? 0 : repeatCount;
for (int index = 0; index <= repeats; index++) {
await _plugin.cancel(
id: _reportReminderId(
cityId: cityId,
dateKey: dateKey,
prayerKey: canonicalPrayer,
reminderIndex: index,
),
);
}
}
Future<void> cancelAllPending() async {
try {
await _plugin.cancelAllPendingNotifications();
} catch (_) {
await _plugin.cancelAll();
}
}
Future<void> _cancelPrayerPending() async {
final pending = await _plugin.pendingNotificationRequests();
for (final request in pending) {
final id = request.id;
// Adhan IDs: 100000..799999
// Iqamah IDs: 800000..1499999
// Report IDs: 2000000..2089999
// Non-prayer IDs: 900000..979999 (DO NOT cancel these)
final isAdhanOrIqamah = id >= 100000 && id < 1500000 && !(id >= 900000 && id < 980000);
final isReport = id >= 2000000 && id < 2100000;
if (isAdhanOrIqamah || isReport) {
await _plugin.cancel(id: id);
}
}
}
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,
String? deeplink,
}) 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()}|${deeplink ?? ''}',
);
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);
}
}