381 lines
12 KiB
Dart
381 lines
12 KiB
Dart
import 'package:hive_flutter/hive_flutter.dart';
|
|
import 'package:intl/intl.dart';
|
|
|
|
import '../local/models.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<DailyPrayerSchedule> schedules,
|
|
DateTime referenceDate,
|
|
) {
|
|
DateTime? startDate;
|
|
DateTime? endDate;
|
|
final uniqueDayKeys = <String>{};
|
|
|
|
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<String> _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 <String>{
|
|
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<String> 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<String> staleScheduleKeys(
|
|
Iterable<String> keys,
|
|
DateTime referenceDate,
|
|
) {
|
|
final allowedMonths = rollingWindowMonths(referenceDate);
|
|
final staleKeys = <String>[];
|
|
|
|
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<void> _pruneScheduleCache(
|
|
Box<DailyPrayerSchedule> scheduleBox,
|
|
DateTime referenceDate,
|
|
) async {
|
|
final staleKeys = staleScheduleKeys(
|
|
scheduleBox.keys.cast<String>(),
|
|
referenceDate,
|
|
);
|
|
if (staleKeys.isNotEmpty) {
|
|
await scheduleBox.deleteAll(staleKeys);
|
|
await scheduleBox.compact();
|
|
}
|
|
}
|
|
|
|
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<bool> syncMonthlyData({DateTime? referenceDate}) async {
|
|
final settingsBox = Hive.box<AppSettings>(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<DailyPrayerSchedule>(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);
|
|
}
|
|
settings.lastSyncDate = DateFormat('yyyy-MM-dd HH:mm').format(now);
|
|
await settings.save();
|
|
}
|
|
|
|
return success;
|
|
}
|
|
|
|
Future<AutoRefreshResult> autoRefreshIfNeeded({
|
|
DateTime? referenceDate,
|
|
}) async {
|
|
final settingsBox = Hive.box<AppSettings>(HiveBoxes.settings);
|
|
final settings = settingsBox.get('default');
|
|
if (settings == null) {
|
|
return const AutoRefreshResult.skipped('settings-missing');
|
|
}
|
|
|
|
final now = referenceDate ?? DateTime.now();
|
|
final status = getCacheStatus(now);
|
|
final hasTodayData = getTodaySchedule(now) != null;
|
|
|
|
if (!_shouldAttemptAutoRefresh(status: status, hasTodayData: hasTodayData)) {
|
|
return const AutoRefreshResult.skipped('cache-fresh');
|
|
}
|
|
|
|
final lastAttempt = _parseAttemptTimestamp(settings.lastAutoSyncAttemptDate);
|
|
if (lastAttempt != null &&
|
|
now.difference(lastAttempt) < _autoRefreshCooldown) {
|
|
return const AutoRefreshResult.skipped('cooldown');
|
|
}
|
|
|
|
settings.lastAutoSyncAttemptDate = now.toIso8601String();
|
|
await settings.save();
|
|
|
|
final synced = await syncMonthlyData(referenceDate: 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<DailyPrayerSchedule>(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<DailyPrayerSchedule>(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);
|
|
}
|