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:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user