diff --git a/android/app/src/main/res/drawable/ic_notification.xml b/android/app/src/main/res/drawable/ic_notification.xml
new file mode 100644
index 0000000..23e6766
--- /dev/null
+++ b/android/app/src/main/res/drawable/ic_notification.xml
@@ -0,0 +1,10 @@
+
+
+
+
diff --git a/docs/notification-audit-tasklist.md b/docs/notification-audit-tasklist.md
new file mode 100644
index 0000000..e6570ba
--- /dev/null
+++ b/docs/notification-audit-tasklist.md
@@ -0,0 +1,111 @@
+# Notification Feature Audit — Tasklist
+
+**Source:** Full codebase trace of notification system
+**Date:** June 2026
+**Status legend:** `[ ]` Not started · `[~]` In progress · `[x]` Done · `[-]` Skipped
+
+---
+
+## Defects (Bugs)
+
+### D1. Notification ID Range Collision — Adhan vs Report Reminders `[SEVERITY: High]`
+- [x] **D1.1** Move report reminder ID range from `700000+` to `2000000+` in `_reportReminderId()`
+- [x] **D1.2** Update `_cancelPrayerPending()` range guard to exclude the new report range explicitly
+- [x] **D1.3** Verify adhan IDs (100k–800k), iqamah IDs (800k–1.5M), report IDs (2M+), non-prayer IDs (900k–980k) are all disjoint
+
+### D2. Streak Risk Only Checks Dzikir Petang, Not Pagi `[SEVERITY: Medium]`
+- [x] **D2.1** Fix `emitStreakRiskIfNeeded` to check both `!pagi` and `!petang` in dzikir risk logic
+- [x] **D2.2** Emit separate inbox items for pagi vs petang dzikir risk with correct deeplinks
+
+### D3. `_cancelPrayerPending` Cancels Non-Prayer Notifications Too `[SEVERITY: Medium]`
+- [x] **D3.1** Narrow the ID range filter to only cancel adhan (100k–799k), iqamah (800k–1.5M), and report (2M+) IDs
+- [x] **D3.2** Exclude non-prayer range (900k–980k) from cancellation
+
+### D4. Notification Tap Routes All Non-Prayer to `/notifications` Instead of Deep Link `[SEVERITY: Medium]`
+- [x] **D4.1** Update `routeForNotificationPayload` to parse deeplink from payload for `streak_risk` type
+- [x] **D4.2** Include deeplink in notification payload through `_pushNonPrayer` → `showNonPrayerAlert` chain
+
+### D5. Timezone Not Updated on Device TZ Change `[SEVERITY: Medium]`
+- [x] **D5.1** Add `reconfigureTimeZoneIfNeeded()` method to detect and apply timezone changes
+- [x] **D5.2** Reset `_lastSyncSignature` on TZ change to force prayer notification resync
+
+### D6. `_handleLaunchNotification` May Fire Before Router is Ready `[SEVERITY: Low]`
+- [x] **D6.1** Defer launch notification routing — store pending route, consume from `AppState.initState` with 800ms delay
+
+---
+
+## Gaps (Missing or Incomplete)
+
+### G1. No Settings UI for Notification Preferences `[SEVERITY: High]`
+- [x] **G1.1** Settings UI already existed — added missing `streakRiskEnabled` toggle to notification group
+- [x] **G1.2** Added `weeklySummaryEnabled` toggle to notification group
+- [x] **G1.3** All other notification settings (alerts, inbox, checklist reminder, quiet hours, push cap) were already present
+
+### G2. No Device Reboot Reschedule `[SEVERITY: High]`
+- [x] **G2.1** Verified `RECEIVE_BOOT_COMPLETED` permission in AndroidManifest.xml — already present
+- [x] **G2.2** Verified `ScheduledNotificationReceiver` and `ScheduledNotificationBootReceiver` — already declared
+- [x] **G2.3** `flutter_local_notifications` v21 handles reboot natively; `workmanager` periodic task resumes via `ExistingPeriodicWorkPolicy.update`
+
+### G3. `mirrorAdzanToInbox` Setting Exists But Never Used `[SEVERITY: Medium]`
+- [x] **G3.1** Removed unused `mirrorAdzanToInbox` field from `AppSettings` and generated adapter
+- [x] **G3.2** Removed legacy `removeByType('prayer')` calls from `main.dart` and `notification_center_screen.dart`
+
+### G4. No Analytics for `notif_push_opened` `[SEVERITY: Low]`
+- [x] **G4.1** Added `notif_push_opened` tracking in `_handleNotificationResponse` (foreground) and `consumePendingLaunchRoute` (launch)
+
+### G5. No Analytics for `notif_settings_changed` `[SEVERITY: Low]`
+- [x] **G5.1** Added `notif_settings_changed` tracking in notification bell quick actions toggle
+
+---
+
+## Opportunities (Enhancements)
+
+### O1. Rich Notification Actions — "Sudah Sholat" Button on Report Reminders
+- [x] **O1.1** Added `AndroidNotificationAction` with `action_prayed` / "Sudah Sholat" button to `_scheduleShalatReportReminder`
+- [x] **O1.2** Background handler (`notificationTapBackgroundHandler`) opens Hive and logs `ShalatLog(completed: true)` via `_markPrayedFromBackground`
+- [x] **O1.3** Foreground handler (`_handleNotificationResponse`) logs prayer via `_markPrayedFromForeground`
+- [x] **O1.4** Added `_resolvePrayerKeyFromName` to map display names back to canonical keys in background isolate
+
+### O2. Notification Permission Check on App Resume via WidgetsBindingObserver
+- [x] **O2.1** Added `_checkNotificationPermissionOnResume()` with 30-second throttle to `_AppState.didChangeAppLifecycleState`
+- [x] **O2.2** Re-checks notification permissions and emits warnings via `emitPermissionWarningsIfNeeded` on resume
+
+### O2b. Fix Stretched Notification Icon
+- [x] **O2b.1** Created `@drawable/ic_notification` — white crescent moon vector drawable (Android notification-safe)
+- [x] **O2b.2** Changed `AndroidInitializationSettings` from `@mipmap/ic_launcher` to `@drawable/ic_notification`
+- [x] **O2b.3** Added `icon: '@drawable/ic_notification'` to all 4 notification channels
+
+### O3. Add Expired Item Cleanup to Background Sync
+- [x] **O3.1** Added `removeExpired()` call in `BackgroundSyncService.runSyncPass()`
+
+### O4. Haptic Feedback on Quick Actions
+- [x] **O4.1** Added `HapticFeedback.selectionClick()` to all three notification bell quick action taps
+
+---
+
+## Progress Tracker
+
+| Category | Total | Done | Skipped | Remaining |
+|----------|-------|------|---------|------------|
+| Defects (D1–D6) | 11 | 11 | 0 | 0 |
+| Gaps (G1–G5) | 10 | 10 | 0 | 0 |
+| Opportunities (O1–O4) | 9 | 9 | 0 | 0 |
+| **TOTAL** | **30** | **30** | **0** | **0** |
+
+---
+
+## Files Changed
+
+| File | Changes |
+|------|---------|
+| `lib/data/services/notification_service.dart` | D1: ID range fix, D3: cancel range fix, D4: payload routing with deeplink, D5: TZ reconfig, D6: deferred launch routing, O1: rich notification action + background handler, O2b: icon fix |
+| `lib/data/services/notification_event_producer_service.dart` | D2: pagi+petang dzikir streak risk, D4: deeplink threading |
+| `lib/core/widgets/notification_bell_button.dart` | G5: analytics tracking, O4: haptic feedback |
+| `lib/data/services/background_sync_service.dart` | O3: expired inbox cleanup |
+| `lib/features/settings/presentation/settings_screen.dart` | G1: streak risk + weekly summary toggles |
+| `lib/data/local/models/app_settings.dart` | G3: removed `mirrorAdzanToInbox` field |
+| `lib/data/local/models/app_settings.g.dart` | G3: removed field 32 from adapter |
+| `lib/main.dart` | G3: removed legacy `removeByType('prayer')` |
+| `lib/features/notifications/presentation/notification_center_screen.dart` | G3: removed legacy cleanup |
+| `lib/app/app.dart` | D6: consume pending launch route on init, O2: permission check on resume |
+| `android/app/src/main/res/drawable/ic_notification.xml` | O2b: white crescent moon vector drawable for notification icon |
diff --git a/lib/app/app.dart b/lib/app/app.dart
index e1eba96..2699101 100644
--- a/lib/app/app.dart
+++ b/lib/app/app.dart
@@ -4,8 +4,13 @@ import 'dart:ui' show ViewFocusEvent, ViewFocusState;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:hive_flutter/hive_flutter.dart';
import '../core/providers/theme_provider.dart';
+import '../data/local/hive_boxes.dart';
+import '../data/local/models/app_settings.dart';
+import '../data/services/notification_event_producer_service.dart';
+import '../data/services/notification_service.dart';
import '../features/dashboard/data/prayer_times_provider.dart';
import 'router.dart';
import 'theme/app_theme.dart';
@@ -20,6 +25,7 @@ class App extends ConsumerStatefulWidget {
class _AppState extends ConsumerState with WidgetsBindingObserver {
Timer? _midnightResyncTimer;
+ DateTime? _lastPermissionCheckAt;
@override
void initState() {
@@ -29,6 +35,7 @@ class _AppState extends ConsumerState with WidgetsBindingObserver {
HardwareKeyboard.instance.syncKeyboardState();
});
_scheduleMidnightResync();
+ NotificationService.instance.consumePendingLaunchRoute();
}
@override
@@ -49,6 +56,7 @@ class _AppState extends ConsumerState with WidgetsBindingObserver {
ref.invalidate(prayerTimesProvider);
unawaited(ref.read(prayerTimesProvider.future));
_scheduleMidnightResync();
+ _checkNotificationPermissionOnResume();
}
}
@@ -73,6 +81,32 @@ class _AppState extends ConsumerState with WidgetsBindingObserver {
});
}
+ Future _checkNotificationPermissionOnResume() async {
+ final now = DateTime.now();
+ if (_lastPermissionCheckAt != null &&
+ now.difference(_lastPermissionCheckAt!).inSeconds < 30) {
+ return; // Throttle: max once per 30 seconds.
+ }
+ _lastPermissionCheckAt = now;
+
+ try {
+ final settings = Hive.box(HiveBoxes.settings)
+ .get('default') ??
+ AppSettings();
+ if (!settings.adhanEnabled.values.any((v) => v)) return;
+
+ final permissionStatus =
+ await NotificationService.instance.getPermissionStatus();
+ await NotificationEventProducerService.instance
+ .emitPermissionWarningsIfNeeded(
+ settings: settings,
+ permissionStatus: permissionStatus,
+ );
+ } catch (_) {
+ // Non-blocking: permission check on resume is best-effort.
+ }
+ }
+
@override
Widget build(BuildContext context) {
final themeMode = ref.watch(themeProvider);
diff --git a/lib/core/widgets/notification_bell_button.dart b/lib/core/widgets/notification_bell_button.dart
index 63748ed..4f9d5e7 100644
--- a/lib/core/widgets/notification_bell_button.dart
+++ b/lib/core/widgets/notification_bell_button.dart
@@ -1,6 +1,7 @@
import 'dart:async';
import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:hive_flutter/hive_flutter.dart';
@@ -11,6 +12,7 @@ import '../../data/local/hive_boxes.dart';
import '../../data/local/models/app_settings.dart';
import '../../data/services/notification_service.dart';
import '../../data/services/notification_inbox_service.dart';
+import '../../data/services/notification_analytics_service.dart';
import '../../features/dashboard/data/prayer_times_provider.dart';
class NotificationBellButton extends StatelessWidget {
@@ -125,6 +127,7 @@ class NotificationBellButton extends StatelessWidget {
? 'Nonaktifkan Alarm Sholat'
: 'Aktifkan Alarm Sholat'),
onTap: () async {
+ HapticFeedback.selectionClick();
final container =
ProviderScope.containerOf(context, listen: false);
settings.adhanEnabled.updateAll((key, _) => !alarmsOn);
@@ -134,6 +137,13 @@ class NotificationBellButton extends StatelessWidget {
}
container.invalidate(prayerTimesProvider);
unawaited(container.read(prayerTimesProvider.future));
+ await NotificationAnalyticsService.instance.track(
+ 'notif_settings_changed',
+ dimensions: {
+ 'setting': 'adhan_all',
+ 'value': !alarmsOn,
+ },
+ );
if (sheetContext.mounted) Navigator.pop(sheetContext);
},
),
@@ -141,6 +151,7 @@ class NotificationBellButton extends StatelessWidget {
leading: const Icon(Icons.sync_rounded),
title: const Text('Sinkronkan Sekarang'),
onTap: () {
+ HapticFeedback.selectionClick();
final container =
ProviderScope.containerOf(context, listen: false);
container.invalidate(prayerTimesProvider);
@@ -152,6 +163,7 @@ class NotificationBellButton extends StatelessWidget {
leading: const Icon(Icons.settings_outlined),
title: const Text('Buka Pengaturan'),
onTap: () {
+ HapticFeedback.selectionClick();
if (sheetContext.mounted) Navigator.pop(sheetContext);
context.push('/settings');
},
diff --git a/lib/data/local/models/app_settings.dart b/lib/data/local/models/app_settings.dart
index c2b3e2f..c679710 100644
--- a/lib/data/local/models/app_settings.dart
+++ b/lib/data/local/models/app_settings.dart
@@ -101,9 +101,6 @@ class AppSettings extends HiveObject {
@HiveField(31)
int maxNonPrayerPushPerDay;
- @HiveField(32)
- bool mirrorAdzanToInbox;
-
@HiveField(33)
bool tilawahAutoContinueNextSurah;
@@ -152,7 +149,6 @@ class AppSettings extends HiveObject {
this.quietHoursStart = '22:00',
this.quietHoursEnd = '05:00',
this.maxNonPrayerPushPerDay = 2,
- this.mirrorAdzanToInbox = false,
this.tilawahAutoContinueNextSurah = true,
this.shalatReportReminderEnabled = true,
this.shalatReportReminderDelayMinutes = 30,
diff --git a/lib/data/local/models/app_settings.g.dart b/lib/data/local/models/app_settings.g.dart
index 336f9f5..d62421f 100644
--- a/lib/data/local/models/app_settings.g.dart
+++ b/lib/data/local/models/app_settings.g.dart
@@ -70,8 +70,6 @@ class AppSettingsAdapter extends TypeAdapter {
fields.containsKey(30) ? fields[30] as String? ?? '05:00' : '05:00',
maxNonPrayerPushPerDay:
fields.containsKey(31) ? fields[31] as int? ?? 2 : 2,
- mirrorAdzanToInbox:
- fields.containsKey(32) ? fields[32] as bool? ?? false : false,
tilawahAutoContinueNextSurah:
fields.containsKey(33) ? fields[33] as bool? ?? true : true,
shalatReportReminderEnabled:
@@ -88,7 +86,7 @@ class AppSettingsAdapter extends TypeAdapter {
@override
void write(BinaryWriter writer, AppSettings obj) {
writer
- ..writeByte(38)
+ ..writeByte(37)
..writeByte(0)
..write(obj.userName)
..writeByte(1)
@@ -153,8 +151,6 @@ class AppSettingsAdapter extends TypeAdapter {
..write(obj.quietHoursEnd)
..writeByte(31)
..write(obj.maxNonPrayerPushPerDay)
- ..writeByte(32)
- ..write(obj.mirrorAdzanToInbox)
..writeByte(33)
..write(obj.tilawahAutoContinueNextSurah)
..writeByte(34)
diff --git a/lib/data/services/background_sync_service.dart b/lib/data/services/background_sync_service.dart
index c459100..e4d9111 100644
--- a/lib/data/services/background_sync_service.dart
+++ b/lib/data/services/background_sync_service.dart
@@ -8,6 +8,7 @@ import 'package:workmanager/workmanager.dart';
import '../local/hive_boxes.dart';
import '../local/models/app_settings.dart';
import 'myquran_sholat_service.dart';
+import 'notification_inbox_service.dart';
import 'notification_orchestrator_service.dart';
import 'notification_service.dart';
@@ -45,6 +46,8 @@ class BackgroundSyncService {
final settings = settingsBox.get('default') ?? AppSettings();
final cityId = _resolveCityId(settings);
+ await NotificationInboxService.instance.removeExpired();
+
final schedulesByDate = await _buildWindowSchedules(cityId);
if (schedulesByDate.isNotEmpty) {
await NotificationService.instance.syncPrayerNotifications(
diff --git a/lib/data/services/notification_event_producer_service.dart b/lib/data/services/notification_event_producer_service.dart
index 24fc918..7149d3e 100644
--- a/lib/data/services/notification_event_producer_service.dart
+++ b/lib/data/services/notification_event_producer_service.dart
@@ -158,8 +158,6 @@ class NotificationEventProducerService {
if (log == null) return;
final tilawahRisk = log.tilawahLog != null && !log.tilawahLog!.isCompleted;
- final dzikirRisk =
- settings.trackDzikir && log.dzikirLog != null && !log.dzikirLog!.petang;
if (tilawahRisk) {
final title = 'Streak Tilawah berisiko terputus';
@@ -180,13 +178,14 @@ class NotificationEventProducerService {
dedupeSeed: 'push.$dedupe',
title: title,
body: body,
+ deeplink: '/quran',
);
}
- if (dzikirRisk) {
- final title = 'Dzikir petang belum tercatat';
- const body = 'Lengkapi dzikir petang untuk menjaga streak amalan harian.';
- final dedupe = 'streak.dzikir.$dateKey';
+ if (settings.trackDzikir && log.dzikirLog != null && !log.dzikirLog!.pagi) {
+ final title = 'Dzikir pagi belum tercatat';
+ const body = 'Lengkapi dzikir pagi untuk menjaga streak amalan harian.';
+ final dedupe = 'streak.dzikir.pagi.$dateKey';
await _inbox.addItem(
title: title,
body: body,
@@ -201,6 +200,29 @@ class NotificationEventProducerService {
dedupeSeed: 'push.$dedupe',
title: title,
body: body,
+ deeplink: '/tools/dzikir',
+ );
+ }
+
+ if (settings.trackDzikir && log.dzikirLog != null && !log.dzikirLog!.petang) {
+ final title = 'Dzikir petang belum tercatat';
+ const body = 'Lengkapi dzikir petang untuk menjaga streak amalan harian.';
+ final dedupe = 'streak.dzikir.petang.$dateKey';
+ await _inbox.addItem(
+ title: title,
+ body: body,
+ type: 'streak_risk',
+ source: 'local',
+ deeplink: '/tools/dzikir',
+ dedupeKey: dedupe,
+ expiresAt: DateTime(now.year, now.month, now.day, 23, 59),
+ );
+ await _pushHabitIfAllowed(
+ settings: settings,
+ dedupeSeed: 'push.$dedupe',
+ title: title,
+ body: body,
+ deeplink: '/tools/dzikir',
);
}
}
@@ -266,6 +288,7 @@ class NotificationEventProducerService {
required String dedupeSeed,
required String title,
required String body,
+ String? deeplink,
}) async {
await _pushNonPrayer(
settings: settings,
@@ -274,6 +297,7 @@ class NotificationEventProducerService {
body: body,
payloadType: 'streak_risk',
silent: false,
+ deeplink: deeplink,
);
}
@@ -284,6 +308,7 @@ class NotificationEventProducerService {
required String body,
required String payloadType,
required bool silent,
+ String? deeplink,
}) async {
if (!settings.alertsEnabled) return;
final notif = NotificationService.instance;
@@ -294,6 +319,7 @@ class NotificationEventProducerService {
body: body,
payloadType: payloadType,
silent: silent,
+ deeplink: deeplink,
);
}
}
diff --git a/lib/data/services/notification_service.dart b/lib/data/services/notification_service.dart
index f3f50a6..aed41a3 100644
--- a/lib/data/services/notification_service.dart
+++ b/lib/data/services/notification_service.dart
@@ -1,22 +1,84 @@
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) {
- // Background isolates cannot safely drive GoRouter. Foreground/cold-start
- // taps are handled by NotificationService after the app is initialized.
+ 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 _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(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 type = (payload ?? '').split('|').first.trim().toLowerCase();
+ final parts = (payload ?? '').split('|');
+ final type = parts.first.trim().toLowerCase();
switch (type) {
case 'report':
case 'checklist':
@@ -24,9 +86,14 @@ String? routeForNotificationPayload(String? payload) {
case 'adhan':
case 'iqamah':
return '/';
+ case 'streak_risk':
+ // Payload format: streak_risk|