fix(offline-first): harden local schedule cache lookup and key normalization
This commit is contained in:
@@ -26,6 +26,26 @@ class ScheduleCacheStatus {
|
|||||||
bool get hasData => startDate != null && endDate != null && cachedDays > 0;
|
bool get hasData => startDate != null && endDate != null && cachedDays > 0;
|
||||||
bool get isExpired => hasData && daysUntilRefresh < 0;
|
bool get isExpired => hasData && daysUntilRefresh < 0;
|
||||||
bool get needsRefreshSoon => hasData && daysUntilRefresh <= 3;
|
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(
|
static ScheduleCacheStatus fromSchedules(
|
||||||
Iterable<DailyPrayerSchedule> schedules,
|
Iterable<DailyPrayerSchedule> schedules,
|
||||||
@@ -33,10 +53,10 @@ class ScheduleCacheStatus {
|
|||||||
) {
|
) {
|
||||||
DateTime? startDate;
|
DateTime? startDate;
|
||||||
DateTime? endDate;
|
DateTime? endDate;
|
||||||
var cachedDays = 0;
|
final uniqueDayKeys = <String>{};
|
||||||
|
|
||||||
for (final schedule in schedules) {
|
for (final schedule in schedules) {
|
||||||
final parsedDate = DateTime.tryParse(schedule.date);
|
final parsedDate = _parseScheduleDate(schedule.date);
|
||||||
if (parsedDate == null) continue;
|
if (parsedDate == null) continue;
|
||||||
|
|
||||||
final normalized = DateTime(
|
final normalized = DateTime(
|
||||||
@@ -45,7 +65,9 @@ class ScheduleCacheStatus {
|
|||||||
parsedDate.day,
|
parsedDate.day,
|
||||||
);
|
);
|
||||||
|
|
||||||
cachedDays++;
|
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)
|
startDate = startDate == null || normalized.isBefore(startDate)
|
||||||
? normalized
|
? normalized
|
||||||
: startDate;
|
: startDate;
|
||||||
@@ -54,6 +76,7 @@ class ScheduleCacheStatus {
|
|||||||
: endDate;
|
: endDate;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final cachedDays = uniqueDayKeys.length;
|
||||||
if (startDate == null || endDate == null || cachedDays == 0) {
|
if (startDate == null || endDate == null || cachedDays == 0) {
|
||||||
return const ScheduleCacheStatus.empty();
|
return const ScheduleCacheStatus.empty();
|
||||||
}
|
}
|
||||||
@@ -78,6 +101,54 @@ class SyncService {
|
|||||||
SyncService._();
|
SyncService._();
|
||||||
static final SyncService instance = SyncService._();
|
static final SyncService instance = SyncService._();
|
||||||
static const Duration _autoRefreshCooldown = Duration(hours: 12);
|
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) {
|
static Set<String> rollingWindowMonths(DateTime referenceDate) {
|
||||||
final currentMonth = DateTime(referenceDate.year, referenceDate.month, 1);
|
final currentMonth = DateTime(referenceDate.year, referenceDate.month, 1);
|
||||||
@@ -96,7 +167,7 @@ class SyncService {
|
|||||||
final staleKeys = <String>[];
|
final staleKeys = <String>[];
|
||||||
|
|
||||||
for (final key in keys) {
|
for (final key in keys) {
|
||||||
final parsed = DateTime.tryParse(key);
|
final parsed = instance._parseScheduleDate(key);
|
||||||
if (parsed == null) {
|
if (parsed == null) {
|
||||||
staleKeys.add(key);
|
staleKeys.add(key);
|
||||||
continue;
|
continue;
|
||||||
@@ -165,10 +236,11 @@ class SyncService {
|
|||||||
hasCurrentMonth = true;
|
hasCurrentMonth = true;
|
||||||
for (final entry in currentData.entries) {
|
for (final entry in currentData.entries) {
|
||||||
final jadwal = entry.value;
|
final jadwal = entry.value;
|
||||||
|
final canonicalKey = _canonicalDateKeyFromRaw(entry.key) ?? entry.key;
|
||||||
scheduleBox.put(
|
scheduleBox.put(
|
||||||
entry.key,
|
canonicalKey,
|
||||||
DailyPrayerSchedule(
|
DailyPrayerSchedule(
|
||||||
date: entry.key,
|
date: canonicalKey,
|
||||||
imsak: jadwal['imsak'] ?? '00:00',
|
imsak: jadwal['imsak'] ?? '00:00',
|
||||||
subuh: jadwal['subuh'] ?? '00:00',
|
subuh: jadwal['subuh'] ?? '00:00',
|
||||||
terbit: jadwal['terbit'] ?? '00:00',
|
terbit: jadwal['terbit'] ?? '00:00',
|
||||||
@@ -179,6 +251,9 @@ class SyncService {
|
|||||||
isya: jadwal['isya'] ?? '00:00',
|
isya: jadwal['isya'] ?? '00:00',
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
if (canonicalKey != entry.key && scheduleBox.containsKey(entry.key)) {
|
||||||
|
await scheduleBox.delete(entry.key);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
success = true;
|
success = true;
|
||||||
}
|
}
|
||||||
@@ -189,10 +264,11 @@ class SyncService {
|
|||||||
hasNextMonth = true;
|
hasNextMonth = true;
|
||||||
for (final entry in nextData.entries) {
|
for (final entry in nextData.entries) {
|
||||||
final jadwal = entry.value;
|
final jadwal = entry.value;
|
||||||
|
final canonicalKey = _canonicalDateKeyFromRaw(entry.key) ?? entry.key;
|
||||||
scheduleBox.put(
|
scheduleBox.put(
|
||||||
entry.key,
|
canonicalKey,
|
||||||
DailyPrayerSchedule(
|
DailyPrayerSchedule(
|
||||||
date: entry.key,
|
date: canonicalKey,
|
||||||
imsak: jadwal['imsak'] ?? '00:00',
|
imsak: jadwal['imsak'] ?? '00:00',
|
||||||
subuh: jadwal['subuh'] ?? '00:00',
|
subuh: jadwal['subuh'] ?? '00:00',
|
||||||
terbit: jadwal['terbit'] ?? '00:00',
|
terbit: jadwal['terbit'] ?? '00:00',
|
||||||
@@ -203,6 +279,9 @@ class SyncService {
|
|||||||
isya: jadwal['isya'] ?? '00:00',
|
isya: jadwal['isya'] ?? '00:00',
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
if (canonicalKey != entry.key && scheduleBox.containsKey(entry.key)) {
|
||||||
|
await scheduleBox.delete(entry.key);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -253,9 +332,20 @@ class SyncService {
|
|||||||
DailyPrayerSchedule? getTodaySchedule([DateTime? targetDate]) {
|
DailyPrayerSchedule? getTodaySchedule([DateTime? targetDate]) {
|
||||||
final scheduleBox =
|
final scheduleBox =
|
||||||
Hive.box<DailyPrayerSchedule>(HiveBoxes.prayerSchedule);
|
Hive.box<DailyPrayerSchedule>(HiveBoxes.prayerSchedule);
|
||||||
final dateToFetch = targetDate ?? DateTime.now();
|
final dateToFetch = _dateOnly(targetDate ?? DateTime.now());
|
||||||
final dateStr = DateFormat('yyyy-MM-dd').format(dateToFetch);
|
for (final key in _lookupKeysForDate(dateToFetch)) {
|
||||||
return scheduleBox.get(dateStr);
|
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]) {
|
ScheduleCacheStatus getCacheStatus([DateTime? referenceDate]) {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
name: jamshalat_masjid_screen
|
name: jamshalat_masjid_screen
|
||||||
description: Smart Digital Prayer Clock for Android TV Box
|
description: Smart Digital Prayer Clock for Android TV Box
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
version: 1.0.11+12
|
version: 1.0.12+13
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: '>=3.0.0 <4.0.0'
|
sdk: '>=3.0.0 <4.0.0'
|
||||||
|
|||||||
@@ -117,6 +117,67 @@ void main() {
|
|||||||
expect(staleKeys, isNot(contains('2026-03-01')));
|
expect(staleKeys, isNot(contains('2026-03-01')));
|
||||||
expect(staleKeys, isNot(contains('2026-04-30')));
|
expect(staleKeys, isNot(contains('2026-04-30')));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('normalizes mixed date formats and de-duplicates same day rows', () {
|
||||||
|
final status = ScheduleCacheStatus.fromSchedules(
|
||||||
|
[
|
||||||
|
DailyPrayerSchedule(
|
||||||
|
date: '2026-4-6',
|
||||||
|
imsak: '04:20',
|
||||||
|
subuh: '04:30',
|
||||||
|
terbit: '05:45',
|
||||||
|
dhuha: '06:10',
|
||||||
|
dzuhur: '11:55',
|
||||||
|
ashar: '15:10',
|
||||||
|
maghrib: '17:58',
|
||||||
|
isya: '19:05',
|
||||||
|
),
|
||||||
|
DailyPrayerSchedule(
|
||||||
|
date: '2026-04-06',
|
||||||
|
imsak: '04:20',
|
||||||
|
subuh: '04:30',
|
||||||
|
terbit: '05:45',
|
||||||
|
dhuha: '06:10',
|
||||||
|
dzuhur: '11:55',
|
||||||
|
ashar: '15:10',
|
||||||
|
maghrib: '17:58',
|
||||||
|
isya: '19:05',
|
||||||
|
),
|
||||||
|
DailyPrayerSchedule(
|
||||||
|
date: '2026-04-07',
|
||||||
|
imsak: '04:21',
|
||||||
|
subuh: '04:31',
|
||||||
|
terbit: '05:46',
|
||||||
|
dhuha: '06:11',
|
||||||
|
dzuhur: '11:56',
|
||||||
|
ashar: '15:11',
|
||||||
|
maghrib: '17:59',
|
||||||
|
isya: '19:06',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
DateTime(2026, 4, 6),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(status.startDate, DateTime(2026, 4, 6));
|
||||||
|
expect(status.endDate, DateTime(2026, 4, 7));
|
||||||
|
expect(status.cachedDays, 2);
|
||||||
|
expect(status.daysUntilRefresh, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('stale key helper accepts non-padded legacy keys', () {
|
||||||
|
final staleKeys = SyncService.staleScheduleKeys(
|
||||||
|
[
|
||||||
|
'2026-4-6',
|
||||||
|
'2026-5-1',
|
||||||
|
'2026-6-1',
|
||||||
|
],
|
||||||
|
DateTime(2026, 4, 6),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(staleKeys, contains('2026-6-1'));
|
||||||
|
expect(staleKeys, isNot(contains('2026-4-6')));
|
||||||
|
expect(staleKeys, isNot(contains('2026-5-1')));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
group('AppUpdateInfo parsing', () {
|
group('AppUpdateInfo parsing', () {
|
||||||
|
|||||||
Reference in New Issue
Block a user