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:
10
android/app/src/main/res/drawable/ic_notification.xml
Normal file
10
android/app/src/main/res/drawable/ic_notification.xml
Normal 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>
|
||||
111
docs/notification-audit-tasklist.md
Normal file
111
docs/notification-audit-tasklist.md
Normal 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 (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 |
|
||||
@@ -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<App> with WidgetsBindingObserver {
|
||||
Timer? _midnightResyncTimer;
|
||||
DateTime? _lastPermissionCheckAt;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -29,6 +35,7 @@ class _AppState extends ConsumerState<App> with WidgetsBindingObserver {
|
||||
HardwareKeyboard.instance.syncKeyboardState();
|
||||
});
|
||||
_scheduleMidnightResync();
|
||||
NotificationService.instance.consumePendingLaunchRoute();
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -49,6 +56,7 @@ class _AppState extends ConsumerState<App> with WidgetsBindingObserver {
|
||||
ref.invalidate(prayerTimesProvider);
|
||||
unawaited(ref.read(prayerTimesProvider.future));
|
||||
_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
|
||||
Widget build(BuildContext context) {
|
||||
final themeMode = ref.watch(themeProvider);
|
||||
|
||||
@@ -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: <String, dynamic>{
|
||||
'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');
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -70,8 +70,6 @@ class AppSettingsAdapter extends TypeAdapter<AppSettings> {
|
||||
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<AppSettings> {
|
||||
@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<AppSettings> {
|
||||
..write(obj.quietHoursEnd)
|
||||
..writeByte(31)
|
||||
..write(obj.maxNonPrayerPushPerDay)
|
||||
..writeByte(32)
|
||||
..write(obj.mirrorAdzanToInbox)
|
||||
..writeByte(33)
|
||||
..write(obj.tilawahAutoContinueNextSurah)
|
||||
..writeByte(34)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<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 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|<label>|<time>|<deeplink>
|
||||
if (parts.length >= 4 && parts[3].trim().isNotEmpty) {
|
||||
return parts[3].trim();
|
||||
}
|
||||
return '/notifications';
|
||||
case 'remote':
|
||||
case 'content':
|
||||
case 'streak_risk':
|
||||
case 'system':
|
||||
return '/notifications';
|
||||
default:
|
||||
@@ -83,6 +150,7 @@ class NotificationService {
|
||||
importance: Importance.max,
|
||||
priority: Priority.high,
|
||||
playSound: true,
|
||||
icon: '@drawable/ic_notification',
|
||||
),
|
||||
iOS: DarwinNotificationDetails(
|
||||
presentAlert: true,
|
||||
@@ -104,6 +172,7 @@ class NotificationService {
|
||||
importance: Importance.high,
|
||||
priority: Priority.high,
|
||||
playSound: true,
|
||||
icon: '@drawable/ic_notification',
|
||||
),
|
||||
iOS: DarwinNotificationDetails(
|
||||
presentAlert: true,
|
||||
@@ -123,6 +192,7 @@ class NotificationService {
|
||||
importance: Importance.high,
|
||||
priority: Priority.high,
|
||||
playSound: true,
|
||||
icon: '@drawable/ic_notification',
|
||||
),
|
||||
iOS: DarwinNotificationDetails(
|
||||
presentAlert: true,
|
||||
@@ -142,6 +212,7 @@ class NotificationService {
|
||||
importance: Importance.defaultImportance,
|
||||
priority: Priority.defaultPriority,
|
||||
playSound: false,
|
||||
icon: '@drawable/ic_notification',
|
||||
),
|
||||
iOS: DarwinNotificationDetails(
|
||||
presentAlert: true,
|
||||
@@ -161,7 +232,7 @@ class NotificationService {
|
||||
_configureLocalTimeZone();
|
||||
|
||||
const androidSettings =
|
||||
AndroidInitializationSettings('@mipmap/ic_launcher');
|
||||
AndroidInitializationSettings('@drawable/ic_notification');
|
||||
const darwinSettings = DarwinInitializationSettings(
|
||||
requestAlertPermission: false,
|
||||
requestBadgePermission: false,
|
||||
@@ -185,22 +256,74 @@ class NotificationService {
|
||||
_initialized = true;
|
||||
}
|
||||
|
||||
String? _pendingLaunchRoute;
|
||||
|
||||
Future<void> _handleLaunchNotification() async {
|
||||
final details = await _plugin.getNotificationAppLaunchDetails();
|
||||
final response = details?.notificationResponse;
|
||||
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) {
|
||||
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(Duration.zero, () {
|
||||
Future<void>.delayed(const Duration(milliseconds: 500), () {
|
||||
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.
|
||||
String _resolveTimeZoneIdByOffset(Duration offset) {
|
||||
switch (offset.inMinutes) {
|
||||
@@ -494,7 +631,7 @@ class NotificationService {
|
||||
for (final rune in seed.runes) {
|
||||
hash = 43 * hash + rune;
|
||||
}
|
||||
return 700000 + (hash.abs() % 90000);
|
||||
return 2000000 + (hash.abs() % 90000);
|
||||
}
|
||||
|
||||
String _buildSyncSignature(
|
||||
@@ -542,12 +679,41 @@ class NotificationService {
|
||||
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 shalat $prayerName? Yuk tandai di checklist sekarang.',
|
||||
body: 'Sudah sholat $prayerName? Yuk tandai di checklist sekarang.',
|
||||
scheduledDate: tz.TZDateTime.from(reminderTime, tz.local),
|
||||
notificationDetails: _habitDetails,
|
||||
notificationDetails: reportDetails,
|
||||
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
|
||||
payload: 'report|$prayerName|${reminderTime.toIso8601String()}',
|
||||
);
|
||||
@@ -586,9 +752,11 @@ class NotificationService {
|
||||
final id = request.id;
|
||||
// Adhan IDs: 100000..799999
|
||||
// Iqamah IDs: 800000..1499999
|
||||
// Report IDs: 700000..789999
|
||||
final isPrayerSchedule = id >= 100000 && id < 1500000;
|
||||
if (isPrayerSchedule) {
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
@@ -661,6 +829,7 @@ class NotificationService {
|
||||
bool silent = false,
|
||||
bool bypassQuietHours = false,
|
||||
bool bypassDailyCap = false,
|
||||
String? deeplink,
|
||||
}) async {
|
||||
await init();
|
||||
|
||||
@@ -677,7 +846,7 @@ class NotificationService {
|
||||
title: title,
|
||||
body: body,
|
||||
notificationDetails: silent ? _systemDetails : _habitDetails,
|
||||
payload: '$payloadType|non_prayer|${DateTime.now().toIso8601String()}',
|
||||
payload: '$payloadType|non_prayer|${DateTime.now().toIso8601String()}|${deeplink ?? ''}',
|
||||
);
|
||||
|
||||
if (!bypassDailyCap) {
|
||||
|
||||
@@ -40,7 +40,6 @@ class _NotificationCenterScreenState extends State<NotificationCenterScreen>
|
||||
void initState() {
|
||||
super.initState();
|
||||
_tabController = TabController(length: 2, vsync: this);
|
||||
unawaited(NotificationInboxService.instance.removeByType('prayer'));
|
||||
_alarmsFuture = NotificationService.instance.pendingAlerts();
|
||||
NotificationAnalyticsService.instance.track(
|
||||
'notif_inbox_opened',
|
||||
|
||||
@@ -749,6 +749,36 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||
trailing: const Icon(LucideIcons.chevronRight, size: 20),
|
||||
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,
|
||||
iconColor: AppColors.sage,
|
||||
title: 'Versi Aplikasi',
|
||||
subtitle: '1.0.8',
|
||||
subtitle: '1.1.0',
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
_settingRow(
|
||||
|
||||
@@ -11,7 +11,6 @@ import 'package:just_audio_background/just_audio_background.dart';
|
||||
import 'app/app.dart';
|
||||
import 'data/local/hive_boxes.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/remote_push_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).
|
||||
final settingsBox = Hive.box<AppSettings>(HiveBoxes.settings);
|
||||
final settings = settingsBox.get('default') ?? AppSettings();
|
||||
// Cleanup legacy mirrored prayer inbox items.
|
||||
await NotificationInboxService.instance.removeByType('prayer');
|
||||
unawaited(NotificationService.instance.syncHabitNotifications(
|
||||
settings: settings,
|
||||
));
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
name: jamshalat_diary
|
||||
description: Islamic worship companion app
|
||||
publish_to: 'none'
|
||||
version: 1.0.8+9
|
||||
publish_to: "none"
|
||||
version: 1.1.0+10
|
||||
|
||||
environment:
|
||||
sdk: '>=3.0.0 <4.0.0'
|
||||
sdk: ">=3.0.0 <4.0.0"
|
||||
|
||||
dependencies:
|
||||
flutter:
|
||||
|
||||
Reference in New Issue
Block a user