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
326 lines
9.7 KiB
Dart
326 lines
9.7 KiB
Dart
import 'package:hive_flutter/hive_flutter.dart';
|
|
import 'package:intl/intl.dart';
|
|
|
|
import '../local/hive_boxes.dart';
|
|
import '../local/models/app_settings.dart';
|
|
import '../local/models/daily_worship_log.dart';
|
|
import 'notification_inbox_service.dart';
|
|
import 'notification_runtime_service.dart';
|
|
import 'notification_service.dart';
|
|
|
|
/// Creates in-app inbox events from runtime/system conditions.
|
|
class NotificationEventProducerService {
|
|
NotificationEventProducerService._();
|
|
static final NotificationEventProducerService instance =
|
|
NotificationEventProducerService._();
|
|
|
|
final NotificationInboxService _inbox = NotificationInboxService.instance;
|
|
final NotificationRuntimeService _runtime =
|
|
NotificationRuntimeService.instance;
|
|
|
|
Future<void> emitPermissionWarningsIfNeeded({
|
|
required AppSettings settings,
|
|
required NotificationPermissionStatus permissionStatus,
|
|
}) async {
|
|
if (!settings.adhanEnabled.values.any((v) => v)) return;
|
|
|
|
final dateKey = _todayKey();
|
|
|
|
if (!permissionStatus.notificationsAllowed) {
|
|
final title = 'Izin notifikasi dinonaktifkan';
|
|
final body =
|
|
'Aktifkan izin notifikasi agar pengingat adzan dan iqamah dapat muncul.';
|
|
if (settings.inboxEnabled) {
|
|
await _inbox.addItem(
|
|
title: title,
|
|
body: body,
|
|
type: 'system',
|
|
source: 'local',
|
|
deeplink: '/settings',
|
|
dedupeKey: 'system.permission.notifications.$dateKey',
|
|
expiresAt: DateTime.now().add(const Duration(days: 2)),
|
|
);
|
|
}
|
|
await _pushSystemIfAllowed(
|
|
settings: settings,
|
|
dedupeSeed: 'push.system.permission.notifications.$dateKey',
|
|
title: title,
|
|
body: body,
|
|
);
|
|
}
|
|
|
|
if (!permissionStatus.exactAlarmAllowed) {
|
|
final title = 'Izin alarm presisi belum aktif';
|
|
final body =
|
|
'Aktifkan alarm presisi agar pengingat adzan tepat waktu di perangkat Android.';
|
|
if (settings.inboxEnabled) {
|
|
await _inbox.addItem(
|
|
title: title,
|
|
body: body,
|
|
type: 'system',
|
|
source: 'local',
|
|
deeplink: '/settings',
|
|
dedupeKey: 'system.permission.exact_alarm.$dateKey',
|
|
expiresAt: DateTime.now().add(const Duration(days: 2)),
|
|
);
|
|
}
|
|
await _pushSystemIfAllowed(
|
|
settings: settings,
|
|
dedupeSeed: 'push.system.permission.exact_alarm.$dateKey',
|
|
title: title,
|
|
body: body,
|
|
);
|
|
}
|
|
}
|
|
|
|
Future<void> emitScheduleFallback({
|
|
required AppSettings settings,
|
|
required String cityId,
|
|
required bool locationUnavailable,
|
|
}) async {
|
|
final dateKey = _todayKey();
|
|
final title = locationUnavailable
|
|
? 'Lokasi belum tersedia'
|
|
: 'Jadwal online terganggu';
|
|
final body = locationUnavailable
|
|
? 'Lokasi perangkat belum aktif. Aplikasi menggunakan lokasi default sementara.'
|
|
: 'Aplikasi memakai perhitungan lokal sementara. Pastikan internet aktif untuk jadwal paling akurat.';
|
|
final scope = locationUnavailable ? 'loc' : 'net';
|
|
final dedupe = 'system.schedule.fallback.$cityId.$dateKey.$scope';
|
|
|
|
if (settings.inboxEnabled) {
|
|
await _inbox.addItem(
|
|
title: title,
|
|
body: body,
|
|
type: 'system',
|
|
source: 'local',
|
|
deeplink: '/imsakiyah',
|
|
dedupeKey: dedupe,
|
|
expiresAt: DateTime.now().add(const Duration(days: 1)),
|
|
meta: <String, dynamic>{
|
|
'cityId': cityId,
|
|
'date': dateKey,
|
|
'scope': scope,
|
|
},
|
|
);
|
|
}
|
|
await _pushSystemIfAllowed(
|
|
settings: settings,
|
|
dedupeSeed: 'push.$dedupe',
|
|
title: title,
|
|
body: body,
|
|
);
|
|
}
|
|
|
|
Future<void> emitNotificationSyncFailed({
|
|
required AppSettings settings,
|
|
required String cityId,
|
|
}) async {
|
|
final dateKey = _todayKey();
|
|
final title = 'Sinkronisasi alarm adzan gagal';
|
|
final body =
|
|
'Pengingat adzan belum tersinkron. Coba buka aplikasi lagi atau periksa pengaturan notifikasi.';
|
|
final dedupe = 'system.notification.sync_failed.$cityId.$dateKey';
|
|
|
|
if (settings.inboxEnabled) {
|
|
await _inbox.addItem(
|
|
title: title,
|
|
body: body,
|
|
type: 'system',
|
|
source: 'local',
|
|
deeplink: '/settings',
|
|
dedupeKey: dedupe,
|
|
expiresAt: DateTime.now().add(const Duration(days: 1)),
|
|
meta: <String, dynamic>{
|
|
'cityId': cityId,
|
|
'date': dateKey,
|
|
},
|
|
);
|
|
}
|
|
await _pushSystemIfAllowed(
|
|
settings: settings,
|
|
dedupeSeed: 'push.$dedupe',
|
|
title: title,
|
|
body: body,
|
|
);
|
|
}
|
|
|
|
Future<void> emitStreakRiskIfNeeded({
|
|
required AppSettings settings,
|
|
}) async {
|
|
if (!settings.inboxEnabled || !settings.streakRiskEnabled) return;
|
|
final now = DateTime.now();
|
|
if (now.hour < 18) return;
|
|
|
|
final dateKey = _todayKey();
|
|
final worshipBox = Hive.box<DailyWorshipLog>(HiveBoxes.worshipLogs);
|
|
final log = worshipBox.get(dateKey);
|
|
if (log == null) return;
|
|
|
|
final tilawahRisk = log.tilawahLog != null && !log.tilawahLog!.isCompleted;
|
|
|
|
if (tilawahRisk) {
|
|
final title = 'Streak Tilawah berisiko terputus';
|
|
const body =
|
|
'Selesaikan target tilawah hari ini untuk menjaga konsistensi.';
|
|
final dedupe = 'streak.tilawah.$dateKey';
|
|
await _inbox.addItem(
|
|
title: title,
|
|
body: body,
|
|
type: 'streak_risk',
|
|
source: 'local',
|
|
deeplink: '/quran',
|
|
dedupeKey: dedupe,
|
|
expiresAt: DateTime(now.year, now.month, now.day, 23, 59),
|
|
);
|
|
await _pushHabitIfAllowed(
|
|
settings: settings,
|
|
dedupeSeed: 'push.$dedupe',
|
|
title: title,
|
|
body: body,
|
|
deeplink: '/quran',
|
|
);
|
|
}
|
|
|
|
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,
|
|
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',
|
|
);
|
|
}
|
|
|
|
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',
|
|
);
|
|
}
|
|
}
|
|
|
|
Future<void> emitWeeklySummaryIfNeeded({
|
|
required AppSettings settings,
|
|
}) async {
|
|
if (!settings.inboxEnabled || !settings.weeklySummaryEnabled) return;
|
|
|
|
final now = DateTime.now();
|
|
if (now.weekday != DateTime.monday || now.hour < 6) return;
|
|
|
|
final monday = now.subtract(Duration(days: now.weekday - 1));
|
|
final weekKey = DateFormat('yyyy-MM-dd').format(monday);
|
|
if (_runtime.lastWeeklySummaryWeekKey() == weekKey) return;
|
|
|
|
final worshipBox = Hive.box<DailyWorshipLog>(HiveBoxes.worshipLogs);
|
|
var completionDays = 0;
|
|
var totalPoints = 0;
|
|
|
|
for (int i = 1; i <= 7; i++) {
|
|
final date = now.subtract(Duration(days: i));
|
|
final key = DateFormat('yyyy-MM-dd').format(date);
|
|
final log = worshipBox.get(key);
|
|
if (log == null) continue;
|
|
if (log.completionPercent >= 70) completionDays++;
|
|
totalPoints += log.totalPoints;
|
|
}
|
|
|
|
await _inbox.addItem(
|
|
title: 'Ringkasan Ibadah Mingguan',
|
|
body:
|
|
'7 hari terakhir: $completionDays hari konsisten, total $totalPoints poin. Lihat detail laporan.',
|
|
type: 'summary',
|
|
source: 'local',
|
|
deeplink: '/laporan',
|
|
dedupeKey: 'summary.weekly.$weekKey',
|
|
expiresAt: now.add(const Duration(days: 7)),
|
|
);
|
|
await _runtime.setLastWeeklySummaryWeekKey(weekKey);
|
|
}
|
|
|
|
String _todayKey() => DateFormat('yyyy-MM-dd').format(DateTime.now());
|
|
|
|
Future<void> _pushSystemIfAllowed({
|
|
required AppSettings settings,
|
|
required String dedupeSeed,
|
|
required String title,
|
|
required String body,
|
|
}) async {
|
|
await _pushNonPrayer(
|
|
settings: settings,
|
|
dedupeSeed: dedupeSeed,
|
|
title: title,
|
|
body: body,
|
|
payloadType: 'system',
|
|
silent: true,
|
|
);
|
|
}
|
|
|
|
Future<void> _pushHabitIfAllowed({
|
|
required AppSettings settings,
|
|
required String dedupeSeed,
|
|
required String title,
|
|
required String body,
|
|
String? deeplink,
|
|
}) async {
|
|
await _pushNonPrayer(
|
|
settings: settings,
|
|
dedupeSeed: dedupeSeed,
|
|
title: title,
|
|
body: body,
|
|
payloadType: 'streak_risk',
|
|
silent: false,
|
|
deeplink: deeplink,
|
|
);
|
|
}
|
|
|
|
Future<void> _pushNonPrayer({
|
|
required AppSettings settings,
|
|
required String dedupeSeed,
|
|
required String title,
|
|
required String body,
|
|
required String payloadType,
|
|
required bool silent,
|
|
String? deeplink,
|
|
}) async {
|
|
if (!settings.alertsEnabled) return;
|
|
final notif = NotificationService.instance;
|
|
await notif.showNonPrayerAlert(
|
|
settings: settings,
|
|
id: notif.nonPrayerNotificationId(dedupeSeed),
|
|
title: title,
|
|
body: body,
|
|
payloadType: payloadType,
|
|
silent: silent,
|
|
deeplink: deeplink,
|
|
);
|
|
}
|
|
}
|