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|