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
This commit is contained in:
Dwindi Ramadhana
2026-06-06 22:38:02 +07:00
parent 2bd8e3666a
commit 4badfb6521
13 changed files with 420 additions and 37 deletions

View File

@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<!-- Crescent moon silhouette (white, notification-safe) -->
<path
android:fillColor="#FFFFFF"
android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10c1.41,0 2.76,-0.29 3.99,-0.82C13.15,19.74 11,16.64 11,13c0,-3.64 2.15,-6.74 4.99,-8.18C14.76,2.29 13.41,2 12,2z" />
</vector>

View File

@@ -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 (100k800k), iqamah IDs (800k1.5M), report IDs (2M+), non-prayer IDs (900k980k) 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 (100k799k), iqamah (800k1.5M), and report (2M+) IDs
- [x] **D3.2** Exclude non-prayer range (900k980k) 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 (D1D6) | 11 | 11 | 0 | 0 |
| Gaps (G1G5) | 10 | 10 | 0 | 0 |
| Opportunities (O1O4) | 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 |

View File

@@ -4,8 +4,13 @@ import 'dart:ui' show ViewFocusEvent, ViewFocusState;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:hive_flutter/hive_flutter.dart';
import '../core/providers/theme_provider.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 '../features/dashboard/data/prayer_times_provider.dart';
import 'router.dart'; import 'router.dart';
import 'theme/app_theme.dart'; import 'theme/app_theme.dart';
@@ -20,6 +25,7 @@ class App extends ConsumerStatefulWidget {
class _AppState extends ConsumerState<App> with WidgetsBindingObserver { class _AppState extends ConsumerState<App> with WidgetsBindingObserver {
Timer? _midnightResyncTimer; Timer? _midnightResyncTimer;
DateTime? _lastPermissionCheckAt;
@override @override
void initState() { void initState() {
@@ -29,6 +35,7 @@ class _AppState extends ConsumerState<App> with WidgetsBindingObserver {
HardwareKeyboard.instance.syncKeyboardState(); HardwareKeyboard.instance.syncKeyboardState();
}); });
_scheduleMidnightResync(); _scheduleMidnightResync();
NotificationService.instance.consumePendingLaunchRoute();
} }
@override @override
@@ -49,6 +56,7 @@ class _AppState extends ConsumerState<App> with WidgetsBindingObserver {
ref.invalidate(prayerTimesProvider); ref.invalidate(prayerTimesProvider);
unawaited(ref.read(prayerTimesProvider.future)); unawaited(ref.read(prayerTimesProvider.future));
_scheduleMidnightResync(); _scheduleMidnightResync();
_checkNotificationPermissionOnResume();
} }
} }
@@ -73,6 +81,32 @@ class _AppState extends ConsumerState<App> with WidgetsBindingObserver {
}); });
} }
Future<void> _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<AppSettings>(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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final themeMode = ref.watch(themeProvider); final themeMode = ref.watch(themeProvider);

View File

@@ -1,6 +1,7 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:hive_flutter/hive_flutter.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/local/models/app_settings.dart';
import '../../data/services/notification_service.dart'; import '../../data/services/notification_service.dart';
import '../../data/services/notification_inbox_service.dart'; import '../../data/services/notification_inbox_service.dart';
import '../../data/services/notification_analytics_service.dart';
import '../../features/dashboard/data/prayer_times_provider.dart'; import '../../features/dashboard/data/prayer_times_provider.dart';
class NotificationBellButton extends StatelessWidget { class NotificationBellButton extends StatelessWidget {
@@ -125,6 +127,7 @@ class NotificationBellButton extends StatelessWidget {
? 'Nonaktifkan Alarm Sholat' ? 'Nonaktifkan Alarm Sholat'
: 'Aktifkan Alarm Sholat'), : 'Aktifkan Alarm Sholat'),
onTap: () async { onTap: () async {
HapticFeedback.selectionClick();
final container = final container =
ProviderScope.containerOf(context, listen: false); ProviderScope.containerOf(context, listen: false);
settings.adhanEnabled.updateAll((key, _) => !alarmsOn); settings.adhanEnabled.updateAll((key, _) => !alarmsOn);
@@ -134,6 +137,13 @@ class NotificationBellButton extends StatelessWidget {
} }
container.invalidate(prayerTimesProvider); container.invalidate(prayerTimesProvider);
unawaited(container.read(prayerTimesProvider.future)); unawaited(container.read(prayerTimesProvider.future));
await NotificationAnalyticsService.instance.track(
'notif_settings_changed',
dimensions: <String, dynamic>{
'setting': 'adhan_all',
'value': !alarmsOn,
},
);
if (sheetContext.mounted) Navigator.pop(sheetContext); if (sheetContext.mounted) Navigator.pop(sheetContext);
}, },
), ),
@@ -141,6 +151,7 @@ class NotificationBellButton extends StatelessWidget {
leading: const Icon(Icons.sync_rounded), leading: const Icon(Icons.sync_rounded),
title: const Text('Sinkronkan Sekarang'), title: const Text('Sinkronkan Sekarang'),
onTap: () { onTap: () {
HapticFeedback.selectionClick();
final container = final container =
ProviderScope.containerOf(context, listen: false); ProviderScope.containerOf(context, listen: false);
container.invalidate(prayerTimesProvider); container.invalidate(prayerTimesProvider);
@@ -152,6 +163,7 @@ class NotificationBellButton extends StatelessWidget {
leading: const Icon(Icons.settings_outlined), leading: const Icon(Icons.settings_outlined),
title: const Text('Buka Pengaturan'), title: const Text('Buka Pengaturan'),
onTap: () { onTap: () {
HapticFeedback.selectionClick();
if (sheetContext.mounted) Navigator.pop(sheetContext); if (sheetContext.mounted) Navigator.pop(sheetContext);
context.push('/settings'); context.push('/settings');
}, },

View File

@@ -101,9 +101,6 @@ class AppSettings extends HiveObject {
@HiveField(31) @HiveField(31)
int maxNonPrayerPushPerDay; int maxNonPrayerPushPerDay;
@HiveField(32)
bool mirrorAdzanToInbox;
@HiveField(33) @HiveField(33)
bool tilawahAutoContinueNextSurah; bool tilawahAutoContinueNextSurah;
@@ -152,7 +149,6 @@ class AppSettings extends HiveObject {
this.quietHoursStart = '22:00', this.quietHoursStart = '22:00',
this.quietHoursEnd = '05:00', this.quietHoursEnd = '05:00',
this.maxNonPrayerPushPerDay = 2, this.maxNonPrayerPushPerDay = 2,
this.mirrorAdzanToInbox = false,
this.tilawahAutoContinueNextSurah = true, this.tilawahAutoContinueNextSurah = true,
this.shalatReportReminderEnabled = true, this.shalatReportReminderEnabled = true,
this.shalatReportReminderDelayMinutes = 30, this.shalatReportReminderDelayMinutes = 30,

View File

@@ -70,8 +70,6 @@ class AppSettingsAdapter extends TypeAdapter<AppSettings> {
fields.containsKey(30) ? fields[30] as String? ?? '05:00' : '05:00', fields.containsKey(30) ? fields[30] as String? ?? '05:00' : '05:00',
maxNonPrayerPushPerDay: maxNonPrayerPushPerDay:
fields.containsKey(31) ? fields[31] as int? ?? 2 : 2, fields.containsKey(31) ? fields[31] as int? ?? 2 : 2,
mirrorAdzanToInbox:
fields.containsKey(32) ? fields[32] as bool? ?? false : false,
tilawahAutoContinueNextSurah: tilawahAutoContinueNextSurah:
fields.containsKey(33) ? fields[33] as bool? ?? true : true, fields.containsKey(33) ? fields[33] as bool? ?? true : true,
shalatReportReminderEnabled: shalatReportReminderEnabled:
@@ -88,7 +86,7 @@ class AppSettingsAdapter extends TypeAdapter<AppSettings> {
@override @override
void write(BinaryWriter writer, AppSettings obj) { void write(BinaryWriter writer, AppSettings obj) {
writer writer
..writeByte(38) ..writeByte(37)
..writeByte(0) ..writeByte(0)
..write(obj.userName) ..write(obj.userName)
..writeByte(1) ..writeByte(1)
@@ -153,8 +151,6 @@ class AppSettingsAdapter extends TypeAdapter<AppSettings> {
..write(obj.quietHoursEnd) ..write(obj.quietHoursEnd)
..writeByte(31) ..writeByte(31)
..write(obj.maxNonPrayerPushPerDay) ..write(obj.maxNonPrayerPushPerDay)
..writeByte(32)
..write(obj.mirrorAdzanToInbox)
..writeByte(33) ..writeByte(33)
..write(obj.tilawahAutoContinueNextSurah) ..write(obj.tilawahAutoContinueNextSurah)
..writeByte(34) ..writeByte(34)

View File

@@ -8,6 +8,7 @@ import 'package:workmanager/workmanager.dart';
import '../local/hive_boxes.dart'; import '../local/hive_boxes.dart';
import '../local/models/app_settings.dart'; import '../local/models/app_settings.dart';
import 'myquran_sholat_service.dart'; import 'myquran_sholat_service.dart';
import 'notification_inbox_service.dart';
import 'notification_orchestrator_service.dart'; import 'notification_orchestrator_service.dart';
import 'notification_service.dart'; import 'notification_service.dart';
@@ -45,6 +46,8 @@ class BackgroundSyncService {
final settings = settingsBox.get('default') ?? AppSettings(); final settings = settingsBox.get('default') ?? AppSettings();
final cityId = _resolveCityId(settings); final cityId = _resolveCityId(settings);
await NotificationInboxService.instance.removeExpired();
final schedulesByDate = await _buildWindowSchedules(cityId); final schedulesByDate = await _buildWindowSchedules(cityId);
if (schedulesByDate.isNotEmpty) { if (schedulesByDate.isNotEmpty) {
await NotificationService.instance.syncPrayerNotifications( await NotificationService.instance.syncPrayerNotifications(

View File

@@ -158,8 +158,6 @@ class NotificationEventProducerService {
if (log == null) return; if (log == null) return;
final tilawahRisk = log.tilawahLog != null && !log.tilawahLog!.isCompleted; final tilawahRisk = log.tilawahLog != null && !log.tilawahLog!.isCompleted;
final dzikirRisk =
settings.trackDzikir && log.dzikirLog != null && !log.dzikirLog!.petang;
if (tilawahRisk) { if (tilawahRisk) {
final title = 'Streak Tilawah berisiko terputus'; final title = 'Streak Tilawah berisiko terputus';
@@ -180,13 +178,14 @@ class NotificationEventProducerService {
dedupeSeed: 'push.$dedupe', dedupeSeed: 'push.$dedupe',
title: title, title: title,
body: body, body: body,
deeplink: '/quran',
); );
} }
if (dzikirRisk) { if (settings.trackDzikir && log.dzikirLog != null && !log.dzikirLog!.pagi) {
final title = 'Dzikir petang belum tercatat'; final title = 'Dzikir pagi belum tercatat';
const body = 'Lengkapi dzikir petang untuk menjaga streak amalan harian.'; const body = 'Lengkapi dzikir pagi untuk menjaga streak amalan harian.';
final dedupe = 'streak.dzikir.$dateKey'; final dedupe = 'streak.dzikir.pagi.$dateKey';
await _inbox.addItem( await _inbox.addItem(
title: title, title: title,
body: body, body: body,
@@ -201,6 +200,29 @@ class NotificationEventProducerService {
dedupeSeed: 'push.$dedupe', dedupeSeed: 'push.$dedupe',
title: title, title: title,
body: body, 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 dedupeSeed,
required String title, required String title,
required String body, required String body,
String? deeplink,
}) async { }) async {
await _pushNonPrayer( await _pushNonPrayer(
settings: settings, settings: settings,
@@ -274,6 +297,7 @@ class NotificationEventProducerService {
body: body, body: body,
payloadType: 'streak_risk', payloadType: 'streak_risk',
silent: false, silent: false,
deeplink: deeplink,
); );
} }
@@ -284,6 +308,7 @@ class NotificationEventProducerService {
required String body, required String body,
required String payloadType, required String payloadType,
required bool silent, required bool silent,
String? deeplink,
}) async { }) async {
if (!settings.alertsEnabled) return; if (!settings.alertsEnabled) return;
final notif = NotificationService.instance; final notif = NotificationService.instance;
@@ -294,6 +319,7 @@ class NotificationEventProducerService {
body: body, body: body,
payloadType: payloadType, payloadType: payloadType,
silent: silent, silent: silent,
deeplink: deeplink,
); );
} }
} }

View File

@@ -1,22 +1,84 @@
import 'dart:io' show Platform; import 'dart:io' show Platform;
import 'package:flutter/widgets.dart' show Color, WidgetsFlutterBinding;
import 'package:flutter_local_notifications/flutter_local_notifications.dart'; 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/data/latest.dart' as tz_data;
import 'package:timezone/timezone.dart' as tz; import 'package:timezone/timezone.dart' as tz;
import '../../app/router.dart'; import '../../app/router.dart';
import '../local/hive_boxes.dart';
import '../local/models/app_settings.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_analytics_service.dart';
import 'notification_runtime_service.dart'; import 'notification_runtime_service.dart';
@pragma('vm:entry-point') @pragma('vm:entry-point')
void notificationTapBackgroundHandler(NotificationResponse response) { void notificationTapBackgroundHandler(NotificationResponse response) {
// Background isolates cannot safely drive GoRouter. Foreground/cold-start final payload = response.payload ?? '';
// taps are handled by NotificationService after the app is initialized. 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) { String? routeForNotificationPayload(String? payload) {
final type = (payload ?? '').split('|').first.trim().toLowerCase(); final parts = (payload ?? '').split('|');
final type = parts.first.trim().toLowerCase();
switch (type) { switch (type) {
case 'report': case 'report':
case 'checklist': case 'checklist':
@@ -24,9 +86,14 @@ String? routeForNotificationPayload(String? payload) {
case 'adhan': case 'adhan':
case 'iqamah': case 'iqamah':
return '/'; 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 'remote':
case 'content': case 'content':
case 'streak_risk':
case 'system': case 'system':
return '/notifications'; return '/notifications';
default: default:
@@ -83,6 +150,7 @@ class NotificationService {
importance: Importance.max, importance: Importance.max,
priority: Priority.high, priority: Priority.high,
playSound: true, playSound: true,
icon: '@drawable/ic_notification',
), ),
iOS: DarwinNotificationDetails( iOS: DarwinNotificationDetails(
presentAlert: true, presentAlert: true,
@@ -104,6 +172,7 @@ class NotificationService {
importance: Importance.high, importance: Importance.high,
priority: Priority.high, priority: Priority.high,
playSound: true, playSound: true,
icon: '@drawable/ic_notification',
), ),
iOS: DarwinNotificationDetails( iOS: DarwinNotificationDetails(
presentAlert: true, presentAlert: true,
@@ -123,6 +192,7 @@ class NotificationService {
importance: Importance.high, importance: Importance.high,
priority: Priority.high, priority: Priority.high,
playSound: true, playSound: true,
icon: '@drawable/ic_notification',
), ),
iOS: DarwinNotificationDetails( iOS: DarwinNotificationDetails(
presentAlert: true, presentAlert: true,
@@ -142,6 +212,7 @@ class NotificationService {
importance: Importance.defaultImportance, importance: Importance.defaultImportance,
priority: Priority.defaultPriority, priority: Priority.defaultPriority,
playSound: false, playSound: false,
icon: '@drawable/ic_notification',
), ),
iOS: DarwinNotificationDetails( iOS: DarwinNotificationDetails(
presentAlert: true, presentAlert: true,
@@ -161,7 +232,7 @@ class NotificationService {
_configureLocalTimeZone(); _configureLocalTimeZone();
const androidSettings = const androidSettings =
AndroidInitializationSettings('@mipmap/ic_launcher'); AndroidInitializationSettings('@drawable/ic_notification');
const darwinSettings = DarwinInitializationSettings( const darwinSettings = DarwinInitializationSettings(
requestAlertPermission: false, requestAlertPermission: false,
requestBadgePermission: false, requestBadgePermission: false,
@@ -185,22 +256,74 @@ class NotificationService {
_initialized = true; _initialized = true;
} }
String? _pendingLaunchRoute;
Future<void> _handleLaunchNotification() async { Future<void> _handleLaunchNotification() async {
final details = await _plugin.getNotificationAppLaunchDetails(); final details = await _plugin.getNotificationAppLaunchDetails();
final response = details?.notificationResponse; final response = details?.notificationResponse;
if (response == null) return; if (response == null) return;
_routeFromPayload(response.payload); _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) { 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); _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) { void _routeFromPayload(String? payload) {
final route = routeForNotificationPayload(payload); final route = routeForNotificationPayload(payload);
if (route == null) return; if (route == null) return;
Future<void>.delayed(Duration.zero, () { Future<void>.delayed(const Duration(milliseconds: 500), () {
appRouter.go(route); appRouter.go(route);
}); });
} }
@@ -214,6 +337,20 @@ class NotificationService {
} }
} }
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. // We prioritize Indonesian zones for better prayer scheduling defaults.
String _resolveTimeZoneIdByOffset(Duration offset) { String _resolveTimeZoneIdByOffset(Duration offset) {
switch (offset.inMinutes) { switch (offset.inMinutes) {
@@ -494,7 +631,7 @@ class NotificationService {
for (final rune in seed.runes) { for (final rune in seed.runes) {
hash = 43 * hash + rune; hash = 43 * hash + rune;
} }
return 700000 + (hash.abs() % 90000); return 2000000 + (hash.abs() % 90000);
} }
String _buildSyncSignature( String _buildSyncSignature(
@@ -542,12 +679,41 @@ class NotificationService {
required int reminderIndex, required int reminderIndex,
}) async { }) async {
final attemptLabel = reminderIndex == 0 ? '' : ' (#${reminderIndex + 1})'; 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( await _plugin.zonedSchedule(
id: id, id: id,
title: 'Lapor Shalat • $prayerName$attemptLabel', title: 'Lapor Shalat • $prayerName$attemptLabel',
body: 'Sudah shalat $prayerName? Yuk tandai di checklist sekarang.', body: 'Sudah sholat $prayerName? Yuk tandai di checklist sekarang.',
scheduledDate: tz.TZDateTime.from(reminderTime, tz.local), scheduledDate: tz.TZDateTime.from(reminderTime, tz.local),
notificationDetails: _habitDetails, notificationDetails: reportDetails,
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle, androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
payload: 'report|$prayerName|${reminderTime.toIso8601String()}', payload: 'report|$prayerName|${reminderTime.toIso8601String()}',
); );
@@ -586,9 +752,11 @@ class NotificationService {
final id = request.id; final id = request.id;
// Adhan IDs: 100000..799999 // Adhan IDs: 100000..799999
// Iqamah IDs: 800000..1499999 // Iqamah IDs: 800000..1499999
// Report IDs: 700000..789999 // Report IDs: 2000000..2089999
final isPrayerSchedule = id >= 100000 && id < 1500000; // Non-prayer IDs: 900000..979999 (DO NOT cancel these)
if (isPrayerSchedule) { final isAdhanOrIqamah = id >= 100000 && id < 1500000 && !(id >= 900000 && id < 980000);
final isReport = id >= 2000000 && id < 2100000;
if (isAdhanOrIqamah || isReport) {
await _plugin.cancel(id: id); await _plugin.cancel(id: id);
} }
} }
@@ -661,6 +829,7 @@ class NotificationService {
bool silent = false, bool silent = false,
bool bypassQuietHours = false, bool bypassQuietHours = false,
bool bypassDailyCap = false, bool bypassDailyCap = false,
String? deeplink,
}) async { }) async {
await init(); await init();
@@ -677,7 +846,7 @@ class NotificationService {
title: title, title: title,
body: body, body: body,
notificationDetails: silent ? _systemDetails : _habitDetails, notificationDetails: silent ? _systemDetails : _habitDetails,
payload: '$payloadType|non_prayer|${DateTime.now().toIso8601String()}', payload: '$payloadType|non_prayer|${DateTime.now().toIso8601String()}|${deeplink ?? ''}',
); );
if (!bypassDailyCap) { if (!bypassDailyCap) {

View File

@@ -40,7 +40,6 @@ class _NotificationCenterScreenState extends State<NotificationCenterScreen>
void initState() { void initState() {
super.initState(); super.initState();
_tabController = TabController(length: 2, vsync: this); _tabController = TabController(length: 2, vsync: this);
unawaited(NotificationInboxService.instance.removeByType('prayer'));
_alarmsFuture = NotificationService.instance.pendingAlerts(); _alarmsFuture = NotificationService.instance.pendingAlerts();
NotificationAnalyticsService.instance.track( NotificationAnalyticsService.instance.track(
'notif_inbox_opened', 'notif_inbox_opened',

View File

@@ -749,6 +749,36 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
trailing: const Icon(LucideIcons.chevronRight, size: 20), trailing: const Icon(LucideIcons.chevronRight, size: 20),
onTap: () => _showPushCapDialog(context), onTap: () => _showPushCapDialog(context),
), ),
const SizedBox(height: 10),
_settingRow(
isDark,
icon: LucideIcons.flame,
iconColor: const Color(0xFFE17055),
title: 'Peringatan Streak',
subtitle: 'Tilawah & dzikir belum tercatat',
trailing: IosToggle(
value: _settings.streakRiskEnabled,
onChanged: (v) {
_settings.streakRiskEnabled = v;
_saveSettings();
},
),
),
const SizedBox(height: 10),
_settingRow(
isDark,
icon: LucideIcons.calendarDays,
iconColor: const Color(0xFF7B61FF),
title: 'Ringkasan Mingguan',
subtitle: 'Ringkasan ibadah setiap hari Senin',
trailing: IosToggle(
value: _settings.weeklySummaryEnabled,
onChanged: (v) {
_settings.weeklySummaryEnabled = v;
_saveSettings();
},
),
),
]; ];
} }
@@ -963,7 +993,7 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
icon: LucideIcons.info, icon: LucideIcons.info,
iconColor: AppColors.sage, iconColor: AppColors.sage,
title: 'Versi Aplikasi', title: 'Versi Aplikasi',
subtitle: '1.0.8', subtitle: '1.1.0',
), ),
const SizedBox(height: 10), const SizedBox(height: 10),
_settingRow( _settingRow(

View File

@@ -11,7 +11,6 @@ import 'package:just_audio_background/just_audio_background.dart';
import 'app/app.dart'; import 'app/app.dart';
import 'data/local/hive_boxes.dart'; import 'data/local/hive_boxes.dart';
import 'data/local/models/app_settings.dart'; import 'data/local/models/app_settings.dart';
import 'data/services/notification_inbox_service.dart';
import 'data/services/notification_orchestrator_service.dart'; import 'data/services/notification_orchestrator_service.dart';
import 'data/services/remote_push_service.dart'; import 'data/services/remote_push_service.dart';
import 'data/services/background_sync_service.dart'; import 'data/services/background_sync_service.dart';
@@ -43,8 +42,6 @@ void main() async {
// Run passive notification checks at startup (inbox cleanup/content sync). // Run passive notification checks at startup (inbox cleanup/content sync).
final settingsBox = Hive.box<AppSettings>(HiveBoxes.settings); final settingsBox = Hive.box<AppSettings>(HiveBoxes.settings);
final settings = settingsBox.get('default') ?? AppSettings(); final settings = settingsBox.get('default') ?? AppSettings();
// Cleanup legacy mirrored prayer inbox items.
await NotificationInboxService.instance.removeByType('prayer');
unawaited(NotificationService.instance.syncHabitNotifications( unawaited(NotificationService.instance.syncHabitNotifications(
settings: settings, settings: settings,
)); ));

View File

@@ -1,10 +1,10 @@
name: jamshalat_diary name: jamshalat_diary
description: Islamic worship companion app description: Islamic worship companion app
publish_to: 'none' publish_to: "none"
version: 1.0.8+9 version: 1.1.0+10
environment: environment:
sdk: '>=3.0.0 <4.0.0' sdk: ">=3.0.0 <4.0.0"
dependencies: dependencies:
flutter: flutter: