Improve notifications, tilawah flow, and dzikir structure

This commit is contained in:
Dwindi Ramadhana
2026-05-20 19:52:15 +07:00
parent c32b56c00e
commit 5195ba19ad
19 changed files with 1056 additions and 318 deletions

View File

@@ -0,0 +1,117 @@
import 'dart:async';
import 'package:flutter/widgets.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:intl/intl.dart';
import 'package:workmanager/workmanager.dart';
import '../local/hive_boxes.dart';
import '../local/models/app_settings.dart';
import 'myquran_sholat_service.dart';
import 'notification_orchestrator_service.dart';
import 'notification_service.dart';
class BackgroundSyncService {
BackgroundSyncService._();
static final BackgroundSyncService instance = BackgroundSyncService._();
static const String periodicTaskName = 'jamshalat_periodic_sync';
static const String periodicUniqueName = 'jamshalat_periodic_sync_unique';
Future<void> init() async {
await Workmanager().initialize(_workmanagerCallbackDispatcher);
}
Future<void> registerPeriodicSync() async {
await Workmanager().registerPeriodicTask(
periodicUniqueName,
periodicTaskName,
existingWorkPolicy: ExistingPeriodicWorkPolicy.update,
frequency: const Duration(hours: 6),
initialDelay: const Duration(minutes: 15),
constraints: Constraints(
networkType: NetworkType.connected,
),
backoffPolicy: BackoffPolicy.exponential,
backoffPolicyDelay: const Duration(minutes: 10),
);
}
static Future<void> runSyncPass() async {
WidgetsFlutterBinding.ensureInitialized();
await initHive();
final settingsBox = Hive.box<AppSettings>(HiveBoxes.settings);
final settings = settingsBox.get('default') ?? AppSettings();
final cityId = _resolveCityId(settings);
final schedulesByDate = await _buildWindowSchedules(cityId);
if (schedulesByDate.isNotEmpty) {
await NotificationService.instance.syncPrayerNotifications(
cityId: cityId,
adhanEnabled: settings.adhanEnabled,
iqamahOffset: settings.iqamahOffset,
schedulesByDate: schedulesByDate,
reportReminderEnabled: settings.shalatReportReminderEnabled,
reportReminderDelayMinutes: settings.shalatReportReminderDelayMinutes,
reportReminderRepeatCount: settings.shalatReportReminderRepeatCount,
reportReminderRepeatIntervalMinutes:
settings.shalatReportReminderRepeatIntervalMinutes,
);
}
await NotificationService.instance.syncHabitNotifications(
settings: settings,
);
await NotificationOrchestratorService.instance.runPassivePass(
settings: settings,
);
}
static Future<Map<String, Map<String, String>>> _buildWindowSchedules(
String cityId) async {
final now = DateTime.now();
final startDate = DateTime(now.year, now.month, now.day);
final endDate = startDate.add(const Duration(days: 35));
final monthKeys = <String>{
DateFormat('yyyy-MM').format(startDate),
DateFormat('yyyy-MM').format(endDate),
};
final schedulesByDate = <String, Map<String, String>>{};
for (final monthKey in monthKeys) {
final monthly = await MyQuranSholatService.instance
.getMonthlySchedule(cityId, monthKey);
for (final entry in monthly.entries) {
final date = DateTime.tryParse(entry.key);
if (date == null) continue;
final normalized = DateTime(date.year, date.month, date.day);
if (normalized.isBefore(startDate) || normalized.isAfter(endDate)) {
continue;
}
schedulesByDate[entry.key] = entry.value;
}
}
return schedulesByDate;
}
static String _resolveCityId(AppSettings settings) {
final stored = settings.lastCityName ?? '';
if (stored.contains('|')) {
return stored.split('|').last;
}
return '58a2fc6ed39fd083f55d4182bf88826d';
}
}
@pragma('vm:entry-point')
void _workmanagerCallbackDispatcher() {
Workmanager().executeTask((task, inputData) async {
if (task == BackgroundSyncService.periodicTaskName) {
await BackgroundSyncService.runSyncPass();
return true;
}
return true;
});
}

View File

@@ -211,21 +211,33 @@ class NotificationService {
required Map<String, bool> adhanEnabled,
required Map<String, int> iqamahOffset,
required Map<String, Map<String, String>> schedulesByDate,
required bool reportReminderEnabled,
required int reportReminderDelayMinutes,
required int reportReminderRepeatCount,
required int reportReminderRepeatIntervalMinutes,
}) async {
await init();
final hasAnyEnabled = adhanEnabled.values.any((v) => v);
if (!hasAnyEnabled) {
await cancelAllPending();
await _cancelPrayerPending();
_lastSyncSignature = null;
return;
}
final signature = _buildSyncSignature(
cityId, adhanEnabled, iqamahOffset, schedulesByDate);
cityId,
adhanEnabled,
iqamahOffset,
schedulesByDate,
reportReminderEnabled: reportReminderEnabled,
reportReminderDelayMinutes: reportReminderDelayMinutes,
reportReminderRepeatCount: reportReminderRepeatCount,
reportReminderRepeatIntervalMinutes: reportReminderRepeatIntervalMinutes,
);
if (_lastSyncSignature == signature) return;
await cancelAllPending();
await _cancelPrayerPending();
final now = DateTime.now();
final dateEntries = schedulesByDate.entries.toList()
@@ -278,6 +290,30 @@ class NotificationService {
iqamahTime: iqamahTime,
offsetMinutes: offsetMinutes,
);
if (!reportReminderEnabled || reportReminderDelayMinutes <= 0) continue;
final repeats =
reportReminderRepeatCount < 0 ? 0 : reportReminderRepeatCount;
final repeatGap = reportReminderRepeatIntervalMinutes <= 0
? 15
: reportReminderRepeatIntervalMinutes;
for (int index = 0; index <= repeats; index++) {
final reminderAt = prayerTime.add(
Duration(minutes: reportReminderDelayMinutes + (index * repeatGap)),
);
if (!reminderAt.isAfter(now)) continue;
await _scheduleShalatReportReminder(
id: _reportReminderId(
cityId: cityId,
dateKey: dateEntry.key,
prayerKey: canonicalPrayer,
reminderIndex: index,
),
prayerName: _localizedPrayerName(canonicalPrayer),
reminderTime: reminderAt,
reminderIndex: index,
);
}
}
}
@@ -395,12 +431,30 @@ class NotificationService {
return isIqamah ? bounded + 800000 : bounded + 100000;
}
int _reportReminderId({
required String cityId,
required String dateKey,
required String prayerKey,
required int reminderIndex,
}) {
final seed = '$cityId|$dateKey|$prayerKey|report|$reminderIndex';
var hash = 23;
for (final rune in seed.runes) {
hash = 43 * hash + rune;
}
return 700000 + (hash.abs() % 90000);
}
String _buildSyncSignature(
String cityId,
Map<String, bool> adhanEnabled,
Map<String, int> iqamahOffset,
Map<String, Map<String, String>> schedulesByDate,
) {
Map<String, Map<String, String>> schedulesByDate, {
required bool reportReminderEnabled,
required int reportReminderDelayMinutes,
required int reportReminderRepeatCount,
required int reportReminderRepeatIntervalMinutes,
}) {
final sortedAdhan = adhanEnabled.entries.toList()
..sort((a, b) => a.key.compareTo(b.key));
final sortedIqamah = iqamahOffset.entries.toList()
@@ -415,6 +469,9 @@ class NotificationService {
for (final e in sortedIqamah) {
buffer.write('|${e.key}:${e.value}');
}
buffer.write(
'|report:${reportReminderEnabled ? 1 : 0}:$reportReminderDelayMinutes:$reportReminderRepeatCount:$reportReminderRepeatIntervalMinutes',
);
for (final dateEntry in sortedDates) {
buffer.write('|${dateEntry.key}');
final times = dateEntry.value.entries.toList()
@@ -426,6 +483,43 @@ class NotificationService {
return buffer.toString();
}
Future<void> _scheduleShalatReportReminder({
required int id,
required String prayerName,
required DateTime reminderTime,
required int reminderIndex,
}) async {
final attemptLabel = reminderIndex == 0 ? '' : ' (#${reminderIndex + 1})';
await _plugin.zonedSchedule(
id: id,
title: 'Lapor Shalat • $prayerName$attemptLabel',
body: 'Sudah shalat $prayerName? Yuk tandai di checklist sekarang.',
scheduledDate: tz.TZDateTime.from(reminderTime, tz.local),
notificationDetails: _habitDetails,
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
payload: 'report|$prayerName|${reminderTime.toIso8601String()}',
);
}
Future<void> cancelShalatReportReminders({
required String cityId,
required String dateKey,
required String canonicalPrayer,
required int repeatCount,
}) async {
final repeats = repeatCount < 0 ? 0 : repeatCount;
for (int index = 0; index <= repeats; index++) {
await _plugin.cancel(
id: _reportReminderId(
cityId: cityId,
dateKey: dateKey,
prayerKey: canonicalPrayer,
reminderIndex: index,
),
);
}
}
Future<void> cancelAllPending() async {
try {
await _plugin.cancelAllPendingNotifications();
@@ -434,6 +528,17 @@ class NotificationService {
}
}
Future<void> _cancelPrayerPending() async {
final pending = await _plugin.pendingNotificationRequests();
for (final request in pending) {
final id = request.id;
final isPrayerSchedule = id >= 100000 && id < 900000;
if (isPrayerSchedule) {
await _plugin.cancel(id: id);
}
}
}
Future<int> pendingCount() async {
final pending = await _plugin.pendingNotificationRequests();
return pending.length;

View File

@@ -1,5 +1,14 @@
import 'dart:async';
import 'package:flutter/widgets.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import '../local/models/app_settings.dart';
import '../local/hive_boxes.dart';
import 'notification_inbox_service.dart';
import 'notification_service.dart';
import 'package:hive_flutter/hive_flutter.dart';
/// Phase-4 bridge for future FCM/APNs wiring.
///
@@ -10,9 +19,30 @@ class RemotePushService {
static final RemotePushService instance = RemotePushService._();
final NotificationInboxService _inbox = NotificationInboxService.instance;
bool _initialized = false;
Future<void> init() async {
// Reserved for SDK wiring (FCM/APNs token registration, topic subscription).
if (_initialized) return;
try {
await Firebase.initializeApp();
final messaging = FirebaseMessaging.instance;
await messaging.requestPermission(
alert: true,
badge: true,
sound: true,
);
FirebaseMessaging.onBackgroundMessage(firebaseMessagingBackgroundHandler);
FirebaseMessaging.onMessage.listen((message) {
unawaited(_handleMessage(message, isForeground: true));
});
FirebaseMessaging.onMessageOpenedApp.listen((message) {
unawaited(_handleMessage(message, isForeground: false));
});
_initialized = true;
} catch (_) {
// Firebase may not be configured in all build variants yet.
}
}
Future<void> ingestPayload(
@@ -44,4 +74,60 @@ class RemotePushService {
meta: <String, dynamic>{'remoteId': id},
);
}
Future<void> _handleMessage(
RemoteMessage message, {
required bool isForeground,
}) async {
final payload = <String, dynamic>{
'id': message.messageId ?? message.data['id'] ?? '',
'title': message.notification?.title ?? message.data['title'] ?? '',
'body': message.notification?.body ?? message.data['body'] ?? '',
'type': message.data['type'] ?? 'content',
'deeplink': message.data['deeplink'] ?? '',
'expiresAt': message.data['expiresAt'] ?? '',
'isPinned': message.data['isPinned'] == 'true',
};
final settings = Hive.box<AppSettings>(HiveBoxes.settings).get('default') ??
AppSettings();
await ingestPayload(payload, settings: settings);
if (isForeground &&
settings.alertsEnabled &&
(payload['title'] as String).trim().isNotEmpty &&
(payload['body'] as String).trim().isNotEmpty) {
await NotificationService.instance.showNonPrayerAlert(
settings: settings,
id: NotificationService.instance
.nonPrayerNotificationId('remote.${payload['id']}'),
title: (payload['title'] as String).trim(),
body: (payload['body'] as String).trim(),
payloadType: 'remote',
bypassDailyCap: true,
);
}
}
}
@pragma('vm:entry-point')
Future<void> firebaseMessagingBackgroundHandler(RemoteMessage message) async {
WidgetsFlutterBinding.ensureInitialized();
await initHive();
try {
await Firebase.initializeApp();
} catch (_) {}
final payload = <String, dynamic>{
'id': message.messageId ?? message.data['id'] ?? '',
'title': message.notification?.title ?? message.data['title'] ?? '',
'body': message.notification?.body ?? message.data['body'] ?? '',
'type': message.data['type'] ?? 'content',
'deeplink': message.data['deeplink'] ?? '',
'expiresAt': message.data['expiresAt'] ?? '',
'isPinned': message.data['isPinned'] == 'true',
};
final settings =
Hive.box<AppSettings>(HiveBoxes.settings).get('default') ?? AppSettings();
await RemotePushService.instance.ingestPayload(payload, settings: settings);
}