diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index d7a3169..7c08f25 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -5,6 +5,7 @@ + ) +#import +#else +@import firebase_core; +#endif + +#if __has_include() +#import +#else +@import firebase_messaging; +#endif + #if __has_include() #import #else @@ -78,11 +90,19 @@ @import url_launcher_ios; #endif +#if __has_include() +#import +#else +@import workmanager_apple; +#endif + @implementation GeneratedPluginRegistrant + (void)registerWithRegistry:(NSObject*)registry { [AudioServicePlugin registerWithRegistrar:[registry registrarForPlugin:@"AudioServicePlugin"]]; [AudioSessionPlugin registerWithRegistrar:[registry registrarForPlugin:@"AudioSessionPlugin"]]; + [FLTFirebaseCorePlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTFirebaseCorePlugin"]]; + [FLTFirebaseMessagingPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTFirebaseMessagingPlugin"]]; [FlutterCompassPlugin registerWithRegistrar:[registry registrarForPlugin:@"FlutterCompassPlugin"]]; [FlutterLocalNotificationsPlugin registerWithRegistrar:[registry registrarForPlugin:@"FlutterLocalNotificationsPlugin"]]; [FlutterQiblahPlugin registerWithRegistrar:[registry registrarForPlugin:@"FlutterQiblahPlugin"]]; @@ -93,6 +113,7 @@ [FPPSharePlusPlugin registerWithRegistrar:[registry registrarForPlugin:@"FPPSharePlusPlugin"]]; [SqflitePlugin registerWithRegistrar:[registry registrarForPlugin:@"SqflitePlugin"]]; [URLLauncherPlugin registerWithRegistrar:[registry registrarForPlugin:@"URLLauncherPlugin"]]; + [WorkmanagerPlugin registerWithRegistrar:[registry registrarForPlugin:@"WorkmanagerPlugin"]]; } @end diff --git a/lib/data/local/models/app_settings.dart b/lib/data/local/models/app_settings.dart index 43bae76..c2b3e2f 100644 --- a/lib/data/local/models/app_settings.dart +++ b/lib/data/local/models/app_settings.dart @@ -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, diff --git a/lib/data/local/models/app_settings.g.dart b/lib/data/local/models/app_settings.g.dart index a64f890..336f9f5 100644 --- a/lib/data/local/models/app_settings.g.dart +++ b/lib/data/local/models/app_settings.g.dart @@ -72,13 +72,23 @@ class AppSettingsAdapter extends TypeAdapter { 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 { ..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 diff --git a/lib/data/services/background_sync_service.dart b/lib/data/services/background_sync_service.dart new file mode 100644 index 0000000..c459100 --- /dev/null +++ b/lib/data/services/background_sync_service.dart @@ -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 init() async { + await Workmanager().initialize(_workmanagerCallbackDispatcher); + } + + Future 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 runSyncPass() async { + WidgetsFlutterBinding.ensureInitialized(); + await initHive(); + + final settingsBox = Hive.box(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>> _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 = { + DateFormat('yyyy-MM').format(startDate), + DateFormat('yyyy-MM').format(endDate), + }; + + final schedulesByDate = >{}; + 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; + }); +} diff --git a/lib/data/services/notification_service.dart b/lib/data/services/notification_service.dart index 3392d9b..e8a887a 100644 --- a/lib/data/services/notification_service.dart +++ b/lib/data/services/notification_service.dart @@ -211,21 +211,33 @@ class NotificationService { required Map adhanEnabled, required Map iqamahOffset, required Map> 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 adhanEnabled, Map iqamahOffset, - Map> schedulesByDate, - ) { + Map> 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 _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 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 cancelAllPending() async { try { await _plugin.cancelAllPendingNotifications(); @@ -434,6 +528,17 @@ class NotificationService { } } + Future _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 pendingCount() async { final pending = await _plugin.pendingNotificationRequests(); return pending.length; diff --git a/lib/data/services/remote_push_service.dart b/lib/data/services/remote_push_service.dart index 2fb024b..8dd0087 100644 --- a/lib/data/services/remote_push_service.dart +++ b/lib/data/services/remote_push_service.dart @@ -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 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 ingestPayload( @@ -44,4 +74,60 @@ class RemotePushService { meta: {'remoteId': id}, ); } + + Future _handleMessage( + RemoteMessage message, { + required bool isForeground, + }) async { + final payload = { + '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(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 firebaseMessagingBackgroundHandler(RemoteMessage message) async { + WidgetsFlutterBinding.ensureInitialized(); + await initHive(); + try { + await Firebase.initializeApp(); + } catch (_) {} + + final payload = { + '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(HiveBoxes.settings).get('default') ?? AppSettings(); + await RemotePushService.instance.ingestPayload(payload, settings: settings); } diff --git a/lib/features/checklist/presentation/checklist_screen.dart b/lib/features/checklist/presentation/checklist_screen.dart index 81c36c8..aff6c9c 100644 --- a/lib/features/checklist/presentation/checklist_screen.dart +++ b/lib/features/checklist/presentation/checklist_screen.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:hive_flutter/hive_flutter.dart'; @@ -14,6 +16,7 @@ import '../../../data/local/models/shalat_log.dart'; import '../../../data/local/models/tilawah_log.dart'; import '../../../data/local/models/dzikir_log.dart'; import '../../../data/local/models/puasa_log.dart'; +import '../../../data/services/notification_service.dart'; class ChecklistScreen extends ConsumerStatefulWidget { const ChecklistScreen({super.key}); @@ -72,6 +75,47 @@ class _ChecklistScreenState extends ConsumerState { DailyWorshipLog get _todayLog => _logBox.get(_todayKey)!; + Future _cancelShalatReportReminderIfCompleted(String prayerKey) async { + final sLog = _todayLog.shalatLogs[prayerKey]; + if (sLog == null || !sLog.completed) return; + final cityId = _resolveCityId(); + final canonical = _canonicalPrayerKey(prayerKey); + if (canonical == null) return; + await NotificationService.instance.cancelShalatReportReminders( + cityId: cityId, + dateKey: _todayKey, + canonicalPrayer: canonical, + repeatCount: _settings.shalatReportReminderRepeatCount, + ); + } + + String _resolveCityId() { + final stored = _settings.lastCityName ?? ''; + if (stored.contains('|')) return stored.split('|').last; + return '58a2fc6ed39fd083f55d4182bf88826d'; + } + + String? _canonicalPrayerKey(String key) { + switch (key) { + case 'subuh': + case 'fajr': + return 'fajr'; + case 'dzuhur': + case 'dhuhr': + return 'dhuhr'; + case 'ashar': + case 'asr': + return 'asr'; + case 'maghrib': + return 'maghrib'; + case 'isya': + case 'isha': + return 'isha'; + default: + return null; + } + } + void _recalculateProgress() { final log = _todayLog; @@ -386,6 +430,9 @@ class _ChecklistScreenState extends ConsumerState { onChanged: (v) { log.completed = v ?? false; _recalculateProgress(); + if (log.completed) { + unawaited(_cancelShalatReportReminderIfCompleted(pKey)); + } }, ), childrenPadding: @@ -404,12 +451,14 @@ class _ChecklistScreenState extends ConsumerState { log.location = 'Masjid'; log.completed = true; // Auto-check parent _recalculateProgress(); + unawaited(_cancelShalatReportReminderIfCompleted(pKey)); }), const SizedBox(width: 16), _radioOption('Rumah', log, () { log.location = 'Rumah'; log.completed = true; // Auto-check parent _recalculateProgress(); + unawaited(_cancelShalatReportReminderIfCompleted(pKey)); }), ], ), diff --git a/lib/features/dashboard/data/prayer_times_provider.dart b/lib/features/dashboard/data/prayer_times_provider.dart index 9e35e79..c23c987 100644 --- a/lib/features/dashboard/data/prayer_times_provider.dart +++ b/lib/features/dashboard/data/prayer_times_provider.dart @@ -219,15 +219,36 @@ Future _syncAdhanNotifications( ); } - final schedulesByDate = >{ - schedule.date: schedule.times, + final schedulesByDate = >{}; + final today = DateTime.now(); + final startDate = DateTime(today.year, today.month, today.day); + final endDate = startDate.add(const Duration(days: 35)); + + final monthKeys = { + DateFormat('yyyy-MM').format(startDate), + DateFormat('yyyy-MM').format(endDate), }; - final baseDate = DateTime.tryParse(schedule.date); - if (baseDate != null) { - final nextDate = DateFormat('yyyy-MM-dd') - .format(baseDate.add(const Duration(days: 1))); - if (!schedulesByDate.containsKey(nextDate)) { + 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; + } + } + + if (schedulesByDate.isEmpty) { + schedulesByDate[schedule.date] = schedule.times; + final baseDate = DateTime.tryParse(schedule.date); + if (baseDate != null) { + final nextDate = DateFormat('yyyy-MM-dd') + .format(baseDate.add(const Duration(days: 1))); final nextSchedule = await MyQuranSholatService.instance .getDailySchedule(cityId, nextDate); if (nextSchedule != null) { @@ -241,6 +262,11 @@ Future _syncAdhanNotifications( 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, diff --git a/lib/features/dzikir/presentation/dzikir_screen.dart b/lib/features/dzikir/presentation/dzikir_screen.dart index f8a8070..95c19e7 100644 --- a/lib/features/dzikir/presentation/dzikir_screen.dart +++ b/lib/features/dzikir/presentation/dzikir_screen.dart @@ -17,11 +17,7 @@ import '../../../data/local/models/daily_worship_log.dart'; import '../../../data/local/models/dzikir_counter.dart'; import '../../../data/local/models/dzikir_log.dart'; import '../../../data/local/models/shalat_log.dart'; -import '../../../data/services/location_service.dart'; -import '../../../data/services/myquran_sholat_service.dart'; import '../../../data/services/muslim_api_service.dart'; -import '../../../data/services/prayer_service.dart'; -import '../../dashboard/data/prayer_times_provider.dart'; class DzikirScreen extends ConsumerStatefulWidget { final bool isSimpleModeTab; @@ -41,20 +37,17 @@ class _DzikirScreenState extends ConsumerState 'pagi': PageController(), 'petang': PageController(), 'harian': PageController(), - 'solat': PageController(), }; final Map _focusPageIndex = { 'pagi': 0, 'petang': 0, 'harian': 0, - 'solat': 0, }; List> _pagiItems = []; List> _petangItems = []; List> _harianItems = []; - List> _sesudahSholatItems = []; Map? _pagiIntroItem; Map? _petangIntroItem; bool _loading = true; @@ -63,29 +56,19 @@ class _DzikirScreenState extends ConsumerState late Box _counterBox; late String _todayKey; Timer? _dayResetTimer; - Timer? _solatResetTimer; - bool _refreshingSolatScope = false; - String _solatScopeKey = 'solat_bootstrap'; - String _solatScopeDateKey = ''; - DateTime? _nextSolatResetAt; @override void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); - _tabController = TabController(length: 4, vsync: this); + _tabController = TabController(length: 3, vsync: this); _tabController.addListener(() { if (!mounted) return; - if (_tabController.index == 0) { - unawaited(_refreshSolatScope()); - } setState(() {}); }); _counterBox = Hive.box(HiveBoxes.dzikirCounters); _todayKey = _currentTodayKey(); - _solatScopeDateKey = _todayKey; _scheduleDayResetTimer(); - unawaited(_refreshSolatScope(forceSetState: false)); _loadData(); } @@ -93,7 +76,6 @@ class _DzikirScreenState extends ConsumerState void dispose() { WidgetsBinding.instance.removeObserver(this); _dayResetTimer?.cancel(); - _solatResetTimer?.cancel(); _tabController.dispose(); for (final controller in _pageControllers.values) { controller.dispose(); @@ -106,7 +88,6 @@ class _DzikirScreenState extends ConsumerState if (state == AppLifecycleState.resumed) { _refreshTodayScope(); _scheduleDayResetTimer(); - unawaited(_refreshSolatScope()); } } @@ -131,161 +112,15 @@ class _DzikirScreenState extends ConsumerState if (_harianItems.isNotEmpty) { _seedHarianProgressFromLinkedDzikir(); } - unawaited(_refreshSolatScope()); if (!mounted) return; setState(() {}); } - void _scheduleSolatResetTimer() { - _solatResetTimer?.cancel(); - final nextResetAt = _nextSolatResetAt; - if (nextResetAt == null) return; - - var delay = - nextResetAt.difference(DateTime.now()) + const Duration(seconds: 1); - if (delay.isNegative) { - delay = const Duration(seconds: 1); - } - - _solatResetTimer = Timer(delay, () { - unawaited(_refreshSolatScope()); - }); - } - - Future _refreshSolatScope({bool forceSetState = true}) async { - if (_refreshingSolatScope) return; - _refreshingSolatScope = true; - - try { - final scope = await _loadCurrentSolatScope(); - if (scope == null) return; - - final scopeChanged = _solatScopeKey != scope.scopeKey; - _solatScopeKey = scope.scopeKey; - _solatScopeDateKey = scope.dateKey; - _nextSolatResetAt = scope.nextResetAt; - _scheduleSolatResetTimer(); - - if (scopeChanged) { - _focusPageIndex['solat'] = 0; - final controller = _pageControllers['solat']; - if (controller != null && controller.hasClients) { - controller.jumpToPage(0); - } - } - - if (!mounted) return; - if (scopeChanged || forceSetState) { - setState(() {}); - } - } finally { - _refreshingSolatScope = false; - } - } - - Future<({String scopeKey, String dateKey, DateTime nextResetAt})?> - _loadCurrentSolatScope() async { - final now = DateTime.now(); - final cityId = ref.read(selectedCityIdProvider); - final dates = [ - DateTime(now.year, now.month, now.day - 1), - DateTime(now.year, now.month, now.day), - DateTime(now.year, now.month, now.day + 1), - ]; - - final prayerEntries = - <({String prayerKey, String dateKey, DateTime time})>[]; - for (final date in dates) { - final schedule = await _loadPrayerScheduleForDate(cityId, date); - final dateKey = DateFormat('yyyy-MM-dd').format(date); - for (final prayerKey in const [ - 'subuh', - 'dzuhur', - 'ashar', - 'maghrib', - 'isya', - ]) { - final parsed = _parsePrayerDateTime(date, schedule[prayerKey]); - if (parsed == null) continue; - prayerEntries.add(( - prayerKey: prayerKey, - dateKey: dateKey, - time: parsed, - )); - } - } - - if (prayerEntries.isEmpty) return null; - prayerEntries.sort((a, b) => a.time.compareTo(b.time)); - - ({String prayerKey, String dateKey, DateTime time})? active; - ({String prayerKey, String dateKey, DateTime time})? next; - - for (final entry in prayerEntries) { - if (!entry.time.isAfter(now)) { - active = entry; - continue; - } - next = entry; - break; - } - - active ??= prayerEntries.first; - next ??= prayerEntries.last; - - return ( - scopeKey: '${active.dateKey}_${active.prayerKey}', - dateKey: active.dateKey, - nextResetAt: next.time, - ); - } - - Future> _loadPrayerScheduleForDate( - String cityId, - DateTime date, - ) async { - final dateKey = DateFormat('yyyy-MM-dd').format(date); - final jadwal = - await MyQuranSholatService.instance.getDailySchedule(cityId, dateKey); - if (jadwal != null) return jadwal; - return _buildFallbackPrayerSchedule(date); - } - - Map _buildFallbackPrayerSchedule(DateTime date) { - final lastKnown = LocationService.instance.getLastKnownLocation(); - final lat = lastKnown?.lat ?? -6.2088; - final lng = lastKnown?.lng ?? 106.8456; - final result = PrayerService.instance.getPrayerTimes(lat, lng, date); - final timeFormat = DateFormat('HH:mm'); - - return { - 'subuh': timeFormat.format(result.fajr), - 'dzuhur': timeFormat.format(result.dhuhr), - 'ashar': timeFormat.format(result.asr), - 'maghrib': timeFormat.format(result.maghrib), - 'isya': timeFormat.format(result.isha), - }; - } - - DateTime? _parsePrayerDateTime(DateTime date, String? rawTime) { - if (rawTime == null || rawTime.trim().isEmpty || rawTime == '-') { - return null; - } - final parts = rawTime.trim().split(':'); - if (parts.length != 2) return null; - final hour = int.tryParse(parts[0]); - final minute = int.tryParse(parts[1]); - if (hour == null || minute == null) return null; - return DateTime(date.year, date.month, date.day, hour, minute); - } - String _counterScopeKeyForPrefix(String prefix) { - if (prefix == 'solat') return _solatScopeKey; return _todayKey; } String _counterDateKeyForPrefix(String prefix) { - if (prefix == 'solat') return _solatScopeDateKey; return _todayKey; } @@ -305,10 +140,6 @@ class _DzikirScreenState extends ConsumerState 'petang', strict: true, ); - final solat = await MuslimApiService.instance.getDzikirByType( - 'solat', - strict: true, - ); if (!mounted) return; final pagiNormalized = _normalizeRumayshoDzikir('pagi', pagi); @@ -336,7 +167,6 @@ class _DzikirScreenState extends ConsumerState _pagiItems = pagiItems; _petangItems = petangItems; _harianItems = harianItems; - _sesudahSholatItems = solat; _loading = false; }); _ensureValidFocusPages(); @@ -691,7 +521,6 @@ class _DzikirScreenState extends ConsumerState _petangItems.length + (_petangIntroItem != null ? 1 : 0), ); _clampFocusPageForPrefix('harian', _harianItems.length); - _clampFocusPageForPrefix('solat', _sesudahSholatItems.length); } void _clampFocusPageForPrefix(String prefix, int itemLength) { @@ -866,7 +695,6 @@ class _DzikirScreenState extends ConsumerState labelStyle: const TextStyle(fontWeight: FontWeight.w700, fontSize: 13), tabs: const [ - Tab(text: 'Sesudah Sholat'), Tab(text: 'Pagi'), Tab(text: 'Petang'), Tab(text: 'Harian'), @@ -883,28 +711,6 @@ class _DzikirScreenState extends ConsumerState : TabBarView( controller: _tabController, children: [ - isFocusMode - ? _buildFocusModeTab( - context, - isDark, - settings, - items: _sesudahSholatItems, - introItem: null, - prefix: 'solat', - title: 'Dzikir Sesudah Sholat', - subtitle: - 'Dibaca setelah shalat fardhu. Hitungan akan dimulai ulang otomatis saat waktu shalat berikutnya masuk.', - ) - : _buildDzikirList( - context, - isDark, - settings, - _sesudahSholatItems, - null, - 'solat', - 'Dzikir Sesudah Sholat', - 'Dibaca setelah shalat fardhu. Hitungan akan dimulai ulang otomatis saat waktu shalat berikutnya masuk.', - ), isFocusMode ? _buildFocusModeTab( context, @@ -1157,9 +963,6 @@ class _DzikirScreenState extends ConsumerState const SizedBox(height: 16), GestureDetector( onTap: () async { - if (prefix == 'solat') { - await _refreshSolatScope(); - } final becameComplete = _increment( dzikirId, target, @@ -1760,10 +1563,6 @@ class _DzikirScreenState extends ConsumerState }) async { _refreshTodayScope(); if (items.isEmpty) return; - if (prefix == 'solat') { - await _refreshSolatScope(); - if (!context.mounted) return; - } final introOffset = introItem != null ? 1 : 0; final currentPage = diff --git a/lib/features/quran/presentation/quran_reading_screen.dart b/lib/features/quran/presentation/quran_reading_screen.dart index a396c02..5c4b2fd 100644 --- a/lib/features/quran/presentation/quran_reading_screen.dart +++ b/lib/features/quran/presentation/quran_reading_screen.dart @@ -71,6 +71,13 @@ class _QuranReadingScreenState extends ConsumerState { } } + void _navigateToSurah(int surahNumber, {int? startVerse}) { + final base = widget.isSimpleModeTab ? '/quran' : '/tools/quran'; + final verseQuery = + (startVerse != null && startVerse > 0) ? '?startVerse=$startVerse' : ''; + context.pushReplacement('$base/$surahNumber$verseQuery'); + } + @override void initState() { super.initState(); @@ -679,6 +686,7 @@ class _QuranReadingScreenState extends ConsumerState { TilawahSession session, int endVerseId) async { final endSurahId = _surah!['nomor'] ?? 1; final endSurahName = _surah!['namaLatin'] ?? ''; + final isLastAyat = endVerseId == _verses.length; int calculatedAyat = 0; @@ -723,115 +731,155 @@ class _QuranReadingScreenState extends ConsumerState { } if (!mounted) return; + bool saveAsLastRead = true; showDialog( context: context, barrierDismissible: false, - builder: (ctx) => AlertDialog( - title: const Text('Catat Sesi Tilawah', - style: TextStyle(fontWeight: FontWeight.bold)), - content: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: AppColors.primary.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(8), + builder: (ctx) => StatefulBuilder( + builder: (context, setModalState) => AlertDialog( + title: const Text('Catat Sesi Tilawah', + style: TextStyle(fontWeight: FontWeight.bold)), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.primary.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Column(children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text('Mulai:', style: TextStyle(fontSize: 13)), + Text( + '${session.startSurahName} : ${session.startVerseId}', + style: const TextStyle( + fontWeight: FontWeight.bold, fontSize: 13)), + ]), + const Padding( + padding: EdgeInsets.symmetric(vertical: 4), + child: Divider()), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text('Selesai:', + style: TextStyle(fontSize: 13)), + Text('$endSurahName : $endVerseId', + style: const TextStyle( + fontWeight: FontWeight.bold, fontSize: 13)), + ]), + ])), + const SizedBox(height: 16), + Row( + children: [ + const Icon(LucideIcons.bookOpen, + size: 20, color: AppColors.primary), + const SizedBox(width: 8), + Text('Total Dibaca: $calculatedAyat Ayat', + style: const TextStyle( + fontWeight: FontWeight.bold, fontSize: 15)), + ], + ), + const SizedBox(height: 8), + CheckboxListTile( + value: saveAsLastRead, + onChanged: (value) { + setModalState(() => saveAsLastRead = value ?? true); + }, + contentPadding: EdgeInsets.zero, + controlAffinity: ListTileControlAffinity.leading, + title: const Text( + 'Simpan juga sebagai Terakhir Dibaca', + style: TextStyle(fontSize: 13), ), - child: Column(children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - const Text('Mulai:', style: TextStyle(fontSize: 13)), - Text( - '${session.startSurahName} : ${session.startVerseId}', - style: const TextStyle( - fontWeight: FontWeight.bold, fontSize: 13)), - ]), - const Padding( - padding: EdgeInsets.symmetric(vertical: 4), - child: Divider()), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - const Text('Selesai:', style: TextStyle(fontSize: 13)), - Text('$endSurahName : $endVerseId', - style: const TextStyle( - fontWeight: FontWeight.bold, fontSize: 13)), - ]), - ])), - const SizedBox(height: 16), - Row( - children: [ - const Icon(LucideIcons.bookOpen, - size: 20, color: AppColors.primary), - const SizedBox(width: 8), - Text('Total Dibaca: $calculatedAyat Ayat', - style: const TextStyle( - fontWeight: FontWeight.bold, fontSize: 15)), - ], + ), + ], + ), + actions: [ + TextButton( + onPressed: () { + ref.invalidate(tilawahTrackingProvider); + Navigator.pop(ctx); + }, + child: const Text('Batal', style: TextStyle(color: Colors.red)), + ), + FilledButton( + onPressed: () async { + final settingsBox = Hive.box(HiveBoxes.settings); + final settings = settingsBox.get('default') ?? AppSettings(); + final todayKey = + DateFormat('yyyy-MM-dd').format(DateTime.now()); + final logBox = Hive.box(HiveBoxes.worshipLogs); + var log = logBox.get(todayKey); + + if (log == null) { + log = DailyWorshipLog( + date: todayKey, + shalatLogs: { + 'subuh': ShalatLog(), + 'dzuhur': ShalatLog(), + 'ashar': ShalatLog(), + 'maghrib': ShalatLog(), + 'isya': ShalatLog(), + }, + ); + logBox.put(todayKey, log); + } + + if (log.tilawahLog == null) { + log.tilawahLog = TilawahLog( + targetValue: settings.tilawahTargetValue, + targetUnit: settings.tilawahTargetUnit, + autoSync: _autoSyncEnabled, + ); + } + + log.tilawahLog!.rawAyatRead += calculatedAyat; + log.save(); + + if (saveAsLastRead) { + final verse = _verses.firstWhere( + (v) => (v['nomorAyat'] ?? 0) == endVerseId, + orElse: () => {}, + ); + if (verse.isNotEmpty) { + await _saveBookmark( + endSurahId, + endVerseId, + verse, + isLastRead: true, + ); + } + } + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('$calculatedAyat Ayat dicatat!'), + backgroundColor: AppColors.primary, + duration: const Duration(seconds: 2), + ), + ); + } + + ref.invalidate(tilawahTrackingProvider); + Navigator.pop(ctx); + + final isLastSurah = endSurahId >= 114; + if (settings.tilawahAutoContinueNextSurah && + isLastAyat && + !isLastSurah) { + _navigateToSurah(endSurahId + 1, startVerse: 1); + } + }, + child: const Text('Simpan'), ), ], ), - actions: [ - TextButton( - onPressed: () { - ref.invalidate(tilawahTrackingProvider); - Navigator.pop(ctx); - }, - child: const Text('Batal', style: TextStyle(color: Colors.red)), - ), - FilledButton( - onPressed: () { - final todayKey = DateFormat('yyyy-MM-dd').format(DateTime.now()); - final logBox = Hive.box(HiveBoxes.worshipLogs); - var log = logBox.get(todayKey); - - if (log == null) { - log = DailyWorshipLog( - date: todayKey, - shalatLogs: { - 'subuh': ShalatLog(), - 'dzuhur': ShalatLog(), - 'ashar': ShalatLog(), - 'maghrib': ShalatLog(), - 'isya': ShalatLog(), - }, - ); - logBox.put(todayKey, log); - } - - if (log.tilawahLog == null) { - final settingsBox = Hive.box(HiveBoxes.settings); - final settings = settingsBox.get('default') ?? AppSettings(); - log.tilawahLog = TilawahLog( - targetValue: settings.tilawahTargetValue, - targetUnit: settings.tilawahTargetUnit, - autoSync: _autoSyncEnabled, - ); - } - - log.tilawahLog!.rawAyatRead += calculatedAyat; - log.save(); - - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('$calculatedAyat Ayat dicatat!'), - backgroundColor: AppColors.primary, - duration: const Duration(seconds: 2), - ), - ); - } - - ref.invalidate(tilawahTrackingProvider); - Navigator.pop(ctx); - }, - child: const Text('Simpan'), - ), - ], ), ); } @@ -1156,6 +1204,42 @@ class _QuranReadingScreenState extends ConsumerState { ); } + Widget _buildTrackingSessionBanner({ + required bool isDark, + required TilawahSession session, + }) { + final currentSurah = _surah?['namaLatin']?.toString() ?? widget.surahId; + return Container( + margin: const EdgeInsets.fromLTRB(16, 12, 16, 4), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.primary.withValues(alpha: isDark ? 0.16 : 0.1), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: AppColors.primary.withValues(alpha: 0.25), + ), + ), + child: Row( + children: [ + const Icon(LucideIcons.flag, size: 16, color: AppColors.primary), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Sesi aktif: ${session.startSurahName}:${session.startVerseId} -> $currentSurah', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: isDark + ? AppColors.textPrimaryDark + : AppColors.textPrimaryLight, + ), + ), + ), + ], + ), + ); + } + @override Widget build(BuildContext context) { final trackingSession = ref.watch(tilawahTrackingProvider); @@ -1271,6 +1355,11 @@ class _QuranReadingScreenState extends ConsumerState { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + if (trackingSession != null) + _buildTrackingSessionBanner( + isDark: isDark, + session: trackingSession, + ), if (showBismillah) ...[ _buildBismillahSection(isDark: isDark), const SizedBox(height: 8), @@ -1292,6 +1381,25 @@ class _QuranReadingScreenState extends ConsumerState { ), ), ], + if ((_surah?['nomor'] as int? ?? 1) < 114) + Padding( + padding: const EdgeInsets.fromLTRB( + 16, 18, 16, 0), + child: FilledButton.icon( + onPressed: () { + final nextSurah = + (_surah?['nomor'] as int? ?? 1) + + 1; + _navigateToSurah(nextSurah, + startVerse: 1); + }, + icon: + const Icon(LucideIcons.arrowRight), + label: Text(trackingSession == null + ? 'Lanjut ke Surah Berikutnya' + : 'Lanjut Surah (Sesi Tetap Aktif)'), + ), + ), ], ), ); diff --git a/lib/features/settings/presentation/settings_screen.dart b/lib/features/settings/presentation/settings_screen.dart index 2bf6b1d..5b9234e 100644 --- a/lib/features/settings/presentation/settings_screen.dart +++ b/lib/features/settings/presentation/settings_screen.dart @@ -101,6 +101,11 @@ class _SettingsScreenState extends ConsumerState { _saveSettings(); } + void _resyncPrayerNotifications() { + ref.invalidate(prayerTimesProvider); + unawaited(ref.read(prayerTimesProvider.future)); + } + Future _showQuietHoursDialog(BuildContext context) async { final startController = TextEditingController(text: _settings.quietHoursStart); @@ -236,6 +241,97 @@ class _SettingsScreenState extends ConsumerState { ); } + Future _showShalatReportDelayDialog(BuildContext context) async { + final controller = TextEditingController( + text: _settings.shalatReportReminderDelayMinutes.toString(), + ); + await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Jeda Pengingat Lapor Shalat'), + content: TextField( + controller: controller, + keyboardType: TextInputType.number, + decoration: const InputDecoration( + labelText: 'Menit setelah waktu shalat', + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: const Text('Batal'), + ), + FilledButton( + onPressed: () { + final value = int.tryParse(controller.text.trim()); + if (value == null || value < 5 || value > 240) return; + _settings.shalatReportReminderDelayMinutes = value; + _saveSettings(); + _resyncPrayerNotifications(); + Navigator.pop(ctx); + }, + child: const Text('Simpan'), + ), + ], + ), + ); + } + + Future _showShalatReportRepeatDialog(BuildContext context) async { + final repeatController = TextEditingController( + text: _settings.shalatReportReminderRepeatCount.toString(), + ); + final intervalController = TextEditingController( + text: _settings.shalatReportReminderRepeatIntervalMinutes.toString(), + ); + await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Pengulangan Pengingat Lapor'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: repeatController, + keyboardType: TextInputType.number, + decoration: const InputDecoration( + labelText: 'Jumlah ulang (0-5)', + ), + ), + const SizedBox(height: 10), + TextField( + controller: intervalController, + keyboardType: TextInputType.number, + decoration: const InputDecoration( + labelText: 'Jeda antar ulang (menit)', + ), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: const Text('Batal'), + ), + FilledButton( + onPressed: () { + final repeats = int.tryParse(repeatController.text.trim()); + final interval = int.tryParse(intervalController.text.trim()); + if (repeats == null || repeats < 0 || repeats > 5) return; + if (interval == null || interval < 5 || interval > 180) return; + _settings.shalatReportReminderRepeatCount = repeats; + _settings.shalatReportReminderRepeatIntervalMinutes = interval; + _saveSettings(); + _resyncPrayerNotifications(); + Navigator.pop(ctx); + }, + child: const Text('Simpan'), + ), + ], + ), + ); + } + @override Widget build(BuildContext context) { final isDark = Theme.of(context).brightness == Brightness.dark; @@ -446,6 +542,46 @@ class _SettingsScreenState extends ConsumerState { ), ), const SizedBox(height: 10), + _settingRow( + isDark, + icon: LucideIcons.siren, + iconColor: const Color(0xFFC0392B), + title: 'Pengingat Lapor Shalat', + subtitle: _settings.shalatReportReminderEnabled + ? 'Aktif • ${_settings.shalatReportReminderDelayMinutes} menit setelah adzan' + : 'Nonaktif', + trailing: IosToggle( + value: _settings.shalatReportReminderEnabled, + onChanged: (v) { + _settings.shalatReportReminderEnabled = v; + _saveSettings(); + _resyncPrayerNotifications(); + }, + ), + ), + const SizedBox(height: 10), + _settingRow( + isDark, + icon: LucideIcons.clock3, + iconColor: const Color(0xFF16A085), + title: 'Jeda Pengingat Lapor', + subtitle: + '${_settings.shalatReportReminderDelayMinutes} menit setelah waktu shalat', + trailing: const Icon(LucideIcons.chevronRight, size: 20), + onTap: () => _showShalatReportDelayDialog(context), + ), + const SizedBox(height: 10), + _settingRow( + isDark, + icon: LucideIcons.repeat, + iconColor: const Color(0xFF8E44AD), + title: 'Ulangi Pengingat Lapor', + subtitle: + '${_settings.shalatReportReminderRepeatCount}x • tiap ${_settings.shalatReportReminderRepeatIntervalMinutes} menit', + trailing: const Icon(LucideIcons.chevronRight, size: 20), + onTap: () => _showShalatReportRepeatDialog(context), + ), + const SizedBox(height: 10), _settingRow( isDark, icon: LucideIcons.moonStar, @@ -521,6 +657,21 @@ class _SettingsScreenState extends ConsumerState { ), ), const SizedBox(height: 10), + _settingRow( + isDark, + icon: LucideIcons.arrowRightCircle, + iconColor: Colors.green, + title: 'Lanjut Surah Otomatis', + subtitle: 'Saat simpan sesi di ayat terakhir', + trailing: IosToggle( + value: _settings.tilawahAutoContinueNextSurah, + onChanged: (v) { + _settings.tilawahAutoContinueNextSurah = v; + _saveSettings(); + }, + ), + ), + const SizedBox(height: 10), _settingRow( isDark, icon: LucideIcons.listChecks, diff --git a/lib/main.dart b/lib/main.dart index 79cdf10..d497e7f 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:io' show Platform; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -13,6 +14,7 @@ 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'; import 'data/services/notification_service.dart'; void main() async { @@ -32,6 +34,10 @@ void main() async { // Initialize local notifications for adzan/iqamah scheduling await NotificationService.instance.init(); + if (Platform.isAndroid || Platform.isIOS) { + await BackgroundSyncService.instance.init(); + await BackgroundSyncService.instance.registerPeriodicSync(); + } await RemotePushService.instance.init(); // Run passive notification checks at startup (inbox cleanup/content sync). diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 533cd3a..de5f45f 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -7,6 +7,8 @@ import Foundation import audio_service import audio_session +import firebase_core +import firebase_messaging import flutter_local_notifications import geolocator_apple import just_audio @@ -18,6 +20,8 @@ import url_launcher_macos func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { AudioServicePlugin.register(with: registry.registrar(forPlugin: "AudioServicePlugin")) AudioSessionPlugin.register(with: registry.registrar(forPlugin: "AudioSessionPlugin")) + FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin")) + FLTFirebaseMessagingPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseMessagingPlugin")) FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin")) GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin")) JustAudioPlugin.register(with: registry.registrar(forPlugin: "JustAudioPlugin")) diff --git a/macos/Podfile.lock b/macos/Podfile.lock index 431a023..c873597 100644 --- a/macos/Podfile.lock +++ b/macos/Podfile.lock @@ -4,17 +4,85 @@ PODS: - FlutterMacOS - audio_session (0.0.1): - FlutterMacOS + - Firebase/CoreOnly (12.12.1): + - FirebaseCore (~> 12.12.1) + - Firebase/Messaging (12.12.1): + - Firebase/CoreOnly + - FirebaseMessaging (~> 12.12.0) + - firebase_core (4.7.0): + - Firebase/CoreOnly (~> 12.12.0) + - FlutterMacOS + - firebase_messaging (16.2.0): + - Firebase/CoreOnly (~> 12.12.0) + - Firebase/Messaging (~> 12.12.0) + - firebase_core + - FlutterMacOS + - FirebaseCore (12.12.1): + - FirebaseCoreInternal (~> 12.12.0) + - GoogleUtilities/Environment (~> 8.1) + - GoogleUtilities/Logger (~> 8.1) + - FirebaseCoreInternal (12.12.0): + - "GoogleUtilities/NSData+zlib (~> 8.1)" + - FirebaseInstallations (12.12.0): + - FirebaseCore (~> 12.12.0) + - GoogleUtilities/Environment (~> 8.1) + - GoogleUtilities/UserDefaults (~> 8.1) + - PromisesObjC (~> 2.4) + - FirebaseMessaging (12.12.0): + - FirebaseCore (~> 12.12.0) + - FirebaseInstallations (~> 12.12.0) + - GoogleDataTransport (~> 10.1) + - GoogleUtilities/AppDelegateSwizzler (~> 8.1) + - GoogleUtilities/Environment (~> 8.1) + - GoogleUtilities/Reachability (~> 8.1) + - GoogleUtilities/UserDefaults (~> 8.1) + - nanopb (~> 3.30910.0) - flutter_local_notifications (0.0.1): - FlutterMacOS - FlutterMacOS (1.0.0) - geolocator_apple (1.2.0): - Flutter - FlutterMacOS + - GoogleDataTransport (10.1.0): + - nanopb (~> 3.30910.0) + - PromisesObjC (~> 2.4) + - GoogleUtilities/AppDelegateSwizzler (8.1.0): + - GoogleUtilities/Environment + - GoogleUtilities/Logger + - GoogleUtilities/Network + - GoogleUtilities/Privacy + - GoogleUtilities/Environment (8.1.0): + - GoogleUtilities/Privacy + - GoogleUtilities/Logger (8.1.0): + - GoogleUtilities/Environment + - GoogleUtilities/Privacy + - GoogleUtilities/Network (8.1.0): + - GoogleUtilities/Logger + - "GoogleUtilities/NSData+zlib" + - GoogleUtilities/Privacy + - GoogleUtilities/Reachability + - "GoogleUtilities/NSData+zlib (8.1.0)": + - GoogleUtilities/Privacy + - GoogleUtilities/Privacy (8.1.0) + - GoogleUtilities/Reachability (8.1.0): + - GoogleUtilities/Logger + - GoogleUtilities/Privacy + - GoogleUtilities/UserDefaults (8.1.0): + - GoogleUtilities/Logger + - GoogleUtilities/Privacy - just_audio (0.0.1): - Flutter - FlutterMacOS + - nanopb (3.30910.0): + - nanopb/decode (= 3.30910.0) + - nanopb/encode (= 3.30910.0) + - nanopb/decode (3.30910.0) + - nanopb/encode (3.30910.0) - package_info_plus (0.0.1): - FlutterMacOS + - PromisesObjC (2.4.0) + - share_plus (0.0.1): + - FlutterMacOS - sqflite_darwin (0.0.4): - Flutter - FlutterMacOS @@ -24,19 +92,38 @@ PODS: DEPENDENCIES: - audio_service (from `Flutter/ephemeral/.symlinks/plugins/audio_service/darwin`) - audio_session (from `Flutter/ephemeral/.symlinks/plugins/audio_session/macos`) + - firebase_core (from `Flutter/ephemeral/.symlinks/plugins/firebase_core/macos`) + - firebase_messaging (from `Flutter/ephemeral/.symlinks/plugins/firebase_messaging/macos`) - flutter_local_notifications (from `Flutter/ephemeral/.symlinks/plugins/flutter_local_notifications/macos`) - FlutterMacOS (from `Flutter/ephemeral`) - geolocator_apple (from `Flutter/ephemeral/.symlinks/plugins/geolocator_apple/darwin`) - just_audio (from `Flutter/ephemeral/.symlinks/plugins/just_audio/darwin`) - package_info_plus (from `Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos`) + - share_plus (from `Flutter/ephemeral/.symlinks/plugins/share_plus/macos`) - sqflite_darwin (from `Flutter/ephemeral/.symlinks/plugins/sqflite_darwin/darwin`) - url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`) +SPEC REPOS: + trunk: + - Firebase + - FirebaseCore + - FirebaseCoreInternal + - FirebaseInstallations + - FirebaseMessaging + - GoogleDataTransport + - GoogleUtilities + - nanopb + - PromisesObjC + EXTERNAL SOURCES: audio_service: :path: Flutter/ephemeral/.symlinks/plugins/audio_service/darwin audio_session: :path: Flutter/ephemeral/.symlinks/plugins/audio_session/macos + firebase_core: + :path: Flutter/ephemeral/.symlinks/plugins/firebase_core/macos + firebase_messaging: + :path: Flutter/ephemeral/.symlinks/plugins/firebase_messaging/macos flutter_local_notifications: :path: Flutter/ephemeral/.symlinks/plugins/flutter_local_notifications/macos FlutterMacOS: @@ -47,6 +134,8 @@ EXTERNAL SOURCES: :path: Flutter/ephemeral/.symlinks/plugins/just_audio/darwin package_info_plus: :path: Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos + share_plus: + :path: Flutter/ephemeral/.symlinks/plugins/share_plus/macos sqflite_darwin: :path: Flutter/ephemeral/.symlinks/plugins/sqflite_darwin/darwin url_launcher_macos: @@ -55,11 +144,23 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: audio_service: aa99a6ba2ae7565996015322b0bb024e1d25c6fd audio_session: eaca2512cf2b39212d724f35d11f46180ad3a33e + Firebase: 14f11e91129d246a8a6166b4c1c2ea61b56806ec + firebase_core: 8022171e82601bac2c79cfa04d69977f12595682 + firebase_messaging: 5044cedfca0133cd38db45fc16c0d312bab00f1b + FirebaseCore: 86241206e656f5c80c995e370e6c975913b9b284 + FirebaseCoreInternal: 7c12fc3011d889085e765e317d7b9fd1cef97af9 + FirebaseInstallations: 4e6e162aa4abaaeeeb01dd00179dfc5ad9c2194e + FirebaseMessaging: 341004946fa7ffc741344b20f1b667514fc93e31 flutter_local_notifications: 1fc7ffb10a83d6a2eeeeddb152d43f1944b0aad0 FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1 geolocator_apple: ab36aa0e8b7d7a2d7639b3b4e48308394e8cef5e + GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7 + GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1 just_audio: 4e391f57b79cad2b0674030a00453ca5ce817eed + nanopb: fad817b59e0457d11a5dfbde799381cd727c1275 package_info_plus: f0052d280d17aa382b932f399edf32507174e870 + PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 + share_plus: 510bf0af1a42cd602274b4629920c9649c52f4cc sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0 url_launcher_macos: f87a979182d112f911de6820aefddaf56ee9fbfd diff --git a/macos/Runner.xcodeproj/project.pbxproj b/macos/Runner.xcodeproj/project.pbxproj index 6a95966..6ad08d4 100644 --- a/macos/Runner.xcodeproj/project.pbxproj +++ b/macos/Runner.xcodeproj/project.pbxproj @@ -241,6 +241,7 @@ 33CC110E2044A8840003C045 /* Bundle Framework */, 3399D490228B24CF009A79C7 /* ShellScript */, 080782848EF12E763C1C7A22 /* [CP] Embed Pods Frameworks */, + 235A577E0BFC8CBB113CF429 /* [CP] Copy Pods Resources */, ); buildRules = ( ); @@ -340,6 +341,23 @@ shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; showEnvVarsInLog = 0; }; + 235A577E0BFC8CBB113CF429 /* [CP] Copy Pods Resources */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Copy Pods Resources"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n"; + showEnvVarsInLog = 0; + }; 3399D490228B24CF009A79C7 /* ShellScript */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; diff --git a/pubspec.lock b/pubspec.lock index 4afd396..a157100 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -9,6 +9,14 @@ packages: url: "https://pub.dev" source: hosted version: "67.0.0" + _flutterfire_internals: + dependency: transitive + description: + name: _flutterfire_internals + sha256: bda3b7b55958bfd867addc40d067b4b11f7b8846d57671f5b5a6e7f9a56fe3ad + url: "https://pub.dev" + source: hosted + version: "1.3.69" adhan: dependency: "direct main" description: @@ -321,6 +329,54 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.1" + firebase_core: + dependency: "direct main" + description: + name: firebase_core + sha256: d5a94b884dcb1e6d3430298e94bfe002238094cdfd5e29202d536ee2120f9158 + url: "https://pub.dev" + source: hosted + version: "4.7.0" + firebase_core_platform_interface: + dependency: transitive + description: + name: firebase_core_platform_interface + sha256: "0ecda14c1bfc9ed8cac303dd0f8d04a320811b479362a9a4efb14fd331a473ce" + url: "https://pub.dev" + source: hosted + version: "6.0.3" + firebase_core_web: + dependency: transitive + description: + name: firebase_core_web + sha256: dc5096257cd67292d34d78ceeb90836f02a4be921b5f3934311a02bb2376118c + url: "https://pub.dev" + source: hosted + version: "3.6.0" + firebase_messaging: + dependency: "direct main" + description: + name: firebase_messaging + sha256: e5c93e8e7a9b0513f94bb684d2cf100e32e7dcdf2949574386b1955fc9a9b96a + url: "https://pub.dev" + source: hosted + version: "16.2.0" + firebase_messaging_platform_interface: + dependency: transitive + description: + name: firebase_messaging_platform_interface + sha256: "8cbb7d842e5071bba836452aff262f7db4b14bb3a0d00c1896cf176df886d65a" + url: "https://pub.dev" + source: hosted + version: "4.7.9" + firebase_messaging_web: + dependency: transitive + description: + name: firebase_messaging_web + sha256: "8750bacf50573c0383535fc3f9c58c6a2f9dff5320a16a82c30631b9dad894f1" + url: "https://pub.dev" + source: hosted + version: "4.1.5" fixnum: dependency: transitive description: @@ -1346,6 +1402,38 @@ packages: url: "https://pub.dev" source: hosted version: "5.15.0" + workmanager: + dependency: "direct main" + description: + name: workmanager + sha256: "065673b2a465865183093806925419d311a9a5e0995aa74ccf8920fd695e2d10" + url: "https://pub.dev" + source: hosted + version: "0.9.0+3" + workmanager_android: + dependency: transitive + description: + name: workmanager_android + sha256: "9ae744db4ef891f5fcd2fb8671fccc712f4f96489a487a1411e0c8675e5e8cb7" + url: "https://pub.dev" + source: hosted + version: "0.9.0+2" + workmanager_apple: + dependency: transitive + description: + name: workmanager_apple + sha256: "1cc12ae3cbf5535e72f7ba4fde0c12dd11b757caf493a28e22d684052701f2ca" + url: "https://pub.dev" + source: hosted + version: "0.9.1+2" + workmanager_platform_interface: + dependency: transitive + description: + name: workmanager_platform_interface + sha256: f40422f10b970c67abb84230b44da22b075147637532ac501729256fcea10a47 + url: "https://pub.dev" + source: hosted + version: "0.9.1+1" xdg_directories: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index a0f291f..a57fe69 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -33,6 +33,9 @@ dependencies: flutter_qiblah: ^3.0.0 # Notifications flutter_local_notifications: ^21.0.0 + workmanager: ^0.9.0+3 + firebase_core: ^4.1.1 + firebase_messaging: ^16.0.1 # Audio just_audio: ^0.10.5