Improve notifications, tilawah flow, and dzikir structure
This commit is contained in:
@@ -104,6 +104,21 @@ class AppSettings extends HiveObject {
|
||||
@HiveField(32)
|
||||
bool mirrorAdzanToInbox;
|
||||
|
||||
@HiveField(33)
|
||||
bool tilawahAutoContinueNextSurah;
|
||||
|
||||
@HiveField(34)
|
||||
bool shalatReportReminderEnabled;
|
||||
|
||||
@HiveField(35)
|
||||
int shalatReportReminderDelayMinutes;
|
||||
|
||||
@HiveField(36)
|
||||
int shalatReportReminderRepeatCount;
|
||||
|
||||
@HiveField(37)
|
||||
int shalatReportReminderRepeatIntervalMinutes;
|
||||
|
||||
AppSettings({
|
||||
this.userName = 'User',
|
||||
this.userEmail = '',
|
||||
@@ -138,6 +153,11 @@ class AppSettings extends HiveObject {
|
||||
this.quietHoursEnd = '05:00',
|
||||
this.maxNonPrayerPushPerDay = 2,
|
||||
this.mirrorAdzanToInbox = false,
|
||||
this.tilawahAutoContinueNextSurah = true,
|
||||
this.shalatReportReminderEnabled = true,
|
||||
this.shalatReportReminderDelayMinutes = 30,
|
||||
this.shalatReportReminderRepeatCount = 1,
|
||||
this.shalatReportReminderRepeatIntervalMinutes = 15,
|
||||
}) : adhanEnabled = adhanEnabled ??
|
||||
{
|
||||
'fajr': true,
|
||||
|
||||
@@ -72,13 +72,23 @@ class AppSettingsAdapter extends TypeAdapter<AppSettings> {
|
||||
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:
|
||||
fields.containsKey(34) ? fields[34] as bool? ?? true : true,
|
||||
shalatReportReminderDelayMinutes:
|
||||
fields.containsKey(35) ? fields[35] as int? ?? 30 : 30,
|
||||
shalatReportReminderRepeatCount:
|
||||
fields.containsKey(36) ? fields[36] as int? ?? 1 : 1,
|
||||
shalatReportReminderRepeatIntervalMinutes:
|
||||
fields.containsKey(37) ? fields[37] as int? ?? 15 : 15,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void write(BinaryWriter writer, AppSettings obj) {
|
||||
writer
|
||||
..writeByte(33)
|
||||
..writeByte(38)
|
||||
..writeByte(0)
|
||||
..write(obj.userName)
|
||||
..writeByte(1)
|
||||
@@ -144,7 +154,17 @@ class AppSettingsAdapter extends TypeAdapter<AppSettings> {
|
||||
..writeByte(31)
|
||||
..write(obj.maxNonPrayerPushPerDay)
|
||||
..writeByte(32)
|
||||
..write(obj.mirrorAdzanToInbox);
|
||||
..write(obj.mirrorAdzanToInbox)
|
||||
..writeByte(33)
|
||||
..write(obj.tilawahAutoContinueNextSurah)
|
||||
..writeByte(34)
|
||||
..write(obj.shalatReportReminderEnabled)
|
||||
..writeByte(35)
|
||||
..write(obj.shalatReportReminderDelayMinutes)
|
||||
..writeByte(36)
|
||||
..write(obj.shalatReportReminderRepeatCount)
|
||||
..writeByte(37)
|
||||
..write(obj.shalatReportReminderRepeatIntervalMinutes);
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
117
lib/data/services/background_sync_service.dart
Normal file
117
lib/data/services/background_sync_service.dart
Normal 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;
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user