import 'dart:async'; import 'package:hive_flutter/hive_flutter.dart'; import 'package:intl/intl.dart'; import '../local/models.dart'; import 'hijri_service.dart'; import 'myquran_service.dart'; class ScheduleCacheStatus { final DateTime? startDate; final DateTime? endDate; final int cachedDays; final int daysUntilRefresh; const ScheduleCacheStatus({ required this.startDate, required this.endDate, required this.cachedDays, required this.daysUntilRefresh, }); const ScheduleCacheStatus.empty() : startDate = null, endDate = null, cachedDays = 0, daysUntilRefresh = -1; bool get hasData => startDate != null && endDate != null && cachedDays > 0; bool get isExpired => hasData && daysUntilRefresh < 0; bool get needsRefreshSoon => hasData && daysUntilRefresh <= 3; static final RegExp _flexDateKeyRegex = RegExp(r'^(\d{4})-(\d{1,2})-(\d{1,2})$'); static DateTime? _parseScheduleDate(String rawValue) { final trimmed = rawValue.trim(); if (trimmed.isEmpty) return null; final parsedIso = DateTime.tryParse(trimmed); if (parsedIso != null) { return DateTime(parsedIso.year, parsedIso.month, parsedIso.day); } final match = _flexDateKeyRegex.firstMatch(trimmed); if (match == null) return null; final year = int.tryParse(match.group(1) ?? ''); final month = int.tryParse(match.group(2) ?? ''); final day = int.tryParse(match.group(3) ?? ''); if (year == null || month == null || day == null) return null; if (month < 1 || month > 12 || day < 1 || day > 31) return null; return DateTime(year, month, day); } static ScheduleCacheStatus fromSchedules( Iterable schedules, DateTime referenceDate, ) { DateTime? startDate; DateTime? endDate; final uniqueDayKeys = {}; for (final schedule in schedules) { final parsedDate = _parseScheduleDate(schedule.date); if (parsedDate == null) continue; final normalized = DateTime( parsedDate.year, parsedDate.month, parsedDate.day, ); final dayKey = '${normalized.year}-${normalized.month.toString().padLeft(2, '0')}-${normalized.day.toString().padLeft(2, '0')}'; uniqueDayKeys.add(dayKey); startDate = startDate == null || normalized.isBefore(startDate) ? normalized : startDate; endDate = endDate == null || normalized.isAfter(endDate) ? normalized : endDate; } final cachedDays = uniqueDayKeys.length; if (startDate == null || endDate == null || cachedDays == 0) { return const ScheduleCacheStatus.empty(); } final today = DateTime( referenceDate.year, referenceDate.month, referenceDate.day, ); return ScheduleCacheStatus( startDate: startDate, endDate: endDate, cachedDays: cachedDays, daysUntilRefresh: endDate.difference(today).inDays, ); } } /// Service to sync monthly prayer data from MyQuran API → Hive. class SyncService { SyncService._(); static final SyncService instance = SyncService._(); static const Duration _autoRefreshCooldown = Duration(hours: 12); static final DateFormat _dateKeyFormat = DateFormat('yyyy-MM-dd'); static final RegExp _flexDateKeyRegex = RegExp(r'^(\d{4})-(\d{1,2})-(\d{1,2})$'); DateTime _dateOnly(DateTime value) => DateTime(value.year, value.month, value.day); String _canonicalDateKey(DateTime value) => _dateKeyFormat.format(_dateOnly(value)); DateTime? _parseScheduleDate(String rawValue) { final trimmed = rawValue.trim(); if (trimmed.isEmpty) return null; final parsedIso = DateTime.tryParse(trimmed); if (parsedIso != null) return _dateOnly(parsedIso); final match = _flexDateKeyRegex.firstMatch(trimmed); if (match == null) return null; final year = int.tryParse(match.group(1) ?? ''); final month = int.tryParse(match.group(2) ?? ''); final day = int.tryParse(match.group(3) ?? ''); if (year == null || month == null || day == null) return null; if (month < 1 || month > 12 || day < 1 || day > 31) return null; return DateTime(year, month, day); } String? _canonicalDateKeyFromRaw(String rawValue) { final parsed = _parseScheduleDate(rawValue); if (parsed == null) return null; return _canonicalDateKey(parsed); } List _lookupKeysForDate(DateTime value) { final date = _dateOnly(value); final canonical = _canonicalDateKey(date); final loose = '${date.year}-${date.month}-${date.day}'; final yearMonthLooseDayPadded = '${date.year}-${date.month}-${date.day.toString().padLeft(2, '0')}'; final yearMonthPaddedDayLoose = '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day}'; return { canonical, loose, yearMonthLooseDayPadded, yearMonthPaddedDayLoose, }.toList(); } bool _isSameDate(DateTime left, DateTime right) => left.year == right.year && left.month == right.month && left.day == right.day; static Set rollingWindowMonths(DateTime referenceDate) { final currentMonth = DateTime(referenceDate.year, referenceDate.month, 1); final nextMonth = DateTime(referenceDate.year, referenceDate.month + 1, 1); return { DateFormat('yyyy-MM').format(currentMonth), DateFormat('yyyy-MM').format(nextMonth), }; } static List staleScheduleKeys( Iterable keys, DateTime referenceDate, ) { final allowedMonths = rollingWindowMonths(referenceDate); final staleKeys = []; for (final key in keys) { final parsed = instance._parseScheduleDate(key); if (parsed == null) { staleKeys.add(key); continue; } final monthKey = DateFormat('yyyy-MM').format(parsed); if (!allowedMonths.contains(monthKey)) { staleKeys.add(key); } } return staleKeys; } Future _pruneScheduleCache( Box scheduleBox, DateTime referenceDate, ) async { final staleKeys = staleScheduleKeys( scheduleBox.keys.cast(), referenceDate, ); if (staleKeys.isNotEmpty) { await scheduleBox.deleteAll(staleKeys); await scheduleBox.compact(); } } Future _pruneHijriCache(DateTime referenceDate) async { if (!Hive.isBoxOpen(HiveBoxes.hijriCache)) return; final hijriBox = Hive.box(HiveBoxes.hijriCache); final staleKeys = staleScheduleKeys( hijriBox.keys.cast(), referenceDate, ); if (staleKeys.isNotEmpty) { await hijriBox.deleteAll(staleKeys); await hijriBox.compact(); } } Set _priorityHijriWarmupKeys( Set allDateKeys, DateTime referenceDate, ) { final prioritized = {}; for (var offset = 0; offset <= 7; offset++) { final key = _canonicalDateKey(referenceDate.add(Duration(days: offset))); if (allDateKeys.contains(key)) prioritized.add(key); } return prioritized; } Set _collectRollingWindowScheduleDateKeys( Box scheduleBox, DateTime referenceDate, ) { final allowedMonths = rollingWindowMonths(referenceDate); final keys = {}; for (final rawKey in scheduleBox.keys) { if (rawKey is! String) continue; final parsed = _parseScheduleDate(rawKey); if (parsed == null) continue; final monthKey = DateFormat('yyyy-MM').format(parsed); if (!allowedMonths.contains(monthKey)) continue; keys.add(_canonicalDateKey(parsed)); } return keys; } Future _warmHijriCacheForScheduleRange( Box scheduleBox, DateTime referenceDate, ) async { final scheduleDateKeys = _collectRollingWindowScheduleDateKeys( scheduleBox, referenceDate, ); if (scheduleDateKeys.isEmpty) return; final priorityKeys = _priorityHijriWarmupKeys(scheduleDateKeys, referenceDate); if (priorityKeys.isNotEmpty) { await HijriCalendarService.instance.warmCacheForDateKeys(priorityKeys); } final remainingKeys = scheduleDateKeys.difference(priorityKeys); if (remainingKeys.isNotEmpty) { unawaited(HijriCalendarService.instance.warmCacheForDateKeys(remainingKeys)); } } bool _shouldAttemptAutoRefresh({ required ScheduleCacheStatus status, required bool hasTodayData, }) { return !hasTodayData || !status.hasData || status.needsRefreshSoon; } DateTime? _parseAttemptTimestamp(String? value) { if (value == null || value.isEmpty) return null; return DateTime.tryParse(value); } /// Sync current month + next month prayer data for the configured city. /// Returns true on success. Future syncMonthlyData({DateTime? referenceDate}) async { final settingsBox = Hive.box(HiveBoxes.settings); final settings = settingsBox.get('default'); if (settings == null) return false; final cityId = settings.cityIdApi; final now = referenceDate ?? DateTime.now(); final currentMonth = DateFormat('yyyy-MM').format(now); // Also fetch next month for continuity final nextMonthDate = DateTime(now.year, now.month + 1, 1); final nextMonth = DateFormat('yyyy-MM').format(nextMonthDate); final api = MyQuranSholatService.instance; final scheduleBox = Hive.box(HiveBoxes.prayerSchedule); var success = false; var hasCurrentMonth = false; var hasNextMonth = false; // Fetch current month final currentData = await api.getMonthlySchedule(cityId, currentMonth); if (currentData.isNotEmpty) { hasCurrentMonth = true; for (final entry in currentData.entries) { final jadwal = entry.value; final canonicalKey = _canonicalDateKeyFromRaw(entry.key) ?? entry.key; scheduleBox.put( canonicalKey, DailyPrayerSchedule( date: canonicalKey, imsak: jadwal['imsak'] ?? '00:00', subuh: jadwal['subuh'] ?? '00:00', terbit: jadwal['terbit'] ?? '00:00', dhuha: jadwal['dhuha'] ?? '00:00', dzuhur: jadwal['dzuhur'] ?? '00:00', ashar: jadwal['ashar'] ?? '00:00', maghrib: jadwal['maghrib'] ?? '00:00', isya: jadwal['isya'] ?? '00:00', ), ); if (canonicalKey != entry.key && scheduleBox.containsKey(entry.key)) { await scheduleBox.delete(entry.key); } } success = true; } // Fetch next month final nextData = await api.getMonthlySchedule(cityId, nextMonth); if (nextData.isNotEmpty) { hasNextMonth = true; for (final entry in nextData.entries) { final jadwal = entry.value; final canonicalKey = _canonicalDateKeyFromRaw(entry.key) ?? entry.key; scheduleBox.put( canonicalKey, DailyPrayerSchedule( date: canonicalKey, imsak: jadwal['imsak'] ?? '00:00', subuh: jadwal['subuh'] ?? '00:00', terbit: jadwal['terbit'] ?? '00:00', dhuha: jadwal['dhuha'] ?? '00:00', dzuhur: jadwal['dzuhur'] ?? '00:00', ashar: jadwal['ashar'] ?? '00:00', maghrib: jadwal['maghrib'] ?? '00:00', isya: jadwal['isya'] ?? '00:00', ), ); if (canonicalKey != entry.key && scheduleBox.containsKey(entry.key)) { await scheduleBox.delete(entry.key); } } } if (success) { if (hasCurrentMonth && hasNextMonth) { await _pruneScheduleCache(scheduleBox, now); await _pruneHijriCache(now); } settings.lastSyncDate = DateFormat('yyyy-MM-dd HH:mm').format(now); await settings.save(); await _warmHijriCacheForScheduleRange(scheduleBox, now); } return success; } Future autoRefreshIfNeeded({ DateTime? referenceDate, }) async { final settingsBox = Hive.box(HiveBoxes.settings); final settings = settingsBox.get('default'); if (settings == null) { return const AutoRefreshResult.skipped('settings-missing'); } final now = referenceDate ?? DateTime.now(); final scheduleBox = Hive.box(HiveBoxes.prayerSchedule); final status = getCacheStatus(now); final hasTodayData = getTodaySchedule(now) != null; if (!_shouldAttemptAutoRefresh(status: status, hasTodayData: hasTodayData)) { unawaited(_warmHijriCacheForScheduleRange(scheduleBox, now)); return const AutoRefreshResult.skipped('cache-fresh'); } final lastAttempt = _parseAttemptTimestamp(settings.lastAutoSyncAttemptDate); if (lastAttempt != null && now.difference(lastAttempt) < _autoRefreshCooldown) { unawaited(_warmHijriCacheForScheduleRange(scheduleBox, now)); return const AutoRefreshResult.skipped('cooldown'); } settings.lastAutoSyncAttemptDate = now.toIso8601String(); await settings.save(); final synced = await syncMonthlyData(referenceDate: now); if (!synced) { unawaited(_warmHijriCacheForScheduleRange(scheduleBox, now)); } return synced ? const AutoRefreshResult.synced() : const AutoRefreshResult.failed('sync-failed'); } /// Get today's prayer schedule from local Hive cache. DailyPrayerSchedule? getTodaySchedule([DateTime? targetDate]) { final scheduleBox = Hive.box(HiveBoxes.prayerSchedule); final dateToFetch = _dateOnly(targetDate ?? DateTime.now()); for (final key in _lookupKeysForDate(dateToFetch)) { final direct = scheduleBox.get(key); if (direct != null) return direct; } // Legacy fallback: match by stored row date if cache keys were historical/mixed. for (final schedule in scheduleBox.values) { final parsed = _parseScheduleDate(schedule.date); if (parsed != null && _isSameDate(parsed, dateToFetch)) { return schedule; } } return null; } ScheduleCacheStatus getCacheStatus([DateTime? referenceDate]) { final scheduleBox = Hive.box(HiveBoxes.prayerSchedule); return ScheduleCacheStatus.fromSchedules( scheduleBox.values, referenceDate ?? DateTime.now(), ); } } class AutoRefreshResult { final bool attempted; final bool synced; final String reason; const AutoRefreshResult._({ required this.attempted, required this.synced, required this.reason, }); const AutoRefreshResult.skipped(String reason) : this._(attempted: false, synced: false, reason: reason); const AutoRefreshResult.synced() : this._(attempted: true, synced: true, reason: 'synced'); const AutoRefreshResult.failed(String reason) : this._(attempted: true, synced: false, reason: reason); }