From 081ed9f6951d32c28732481b2716f02038602278 Mon Sep 17 00:00:00 2001 From: dwindown Date: Tue, 31 Mar 2026 14:37:14 +0700 Subject: [PATCH] Harden app for 24-7 offline-first operation --- lib/data/local/models.dart | 12 +- lib/data/services/sync_service.dart | 123 ++++++++++++++++++++- lib/features/admin/admin_screen.dart | 41 ++++++- lib/features/home/home_view.dart | 36 ++++-- lib/features/home/jumat_screen.dart | 1 + lib/features/home/main_screen.dart | 1 + lib/features/home/unsplash_background.dart | 1 + lib/main.dart | 69 ++++++++++++ test/widget_test.dart | 22 ++++ 9 files changed, 289 insertions(+), 17 deletions(-) diff --git a/lib/data/local/models.dart b/lib/data/local/models.dart index 77777c0..22c0b3a 100644 --- a/lib/data/local/models.dart +++ b/lib/data/local/models.dart @@ -77,6 +77,10 @@ class AppSettings extends HiveObject { @HiveField(19) String? lastSyncDate; + // Last automatic sync attempt timestamp (ISO8601). + @HiveField(32) + String? lastAutoSyncAttemptDate; + // Slideshow image paths (local) @HiveField(20) List slideshowImages; @@ -148,6 +152,7 @@ class AppSettings extends HiveObject { this.mainScreenDurationSec = 15, this.slideDurationSec = 10, this.lastSyncDate, + this.lastAutoSyncAttemptDate, this.slideshowImages = const [], this.textScaleIndex = 1, this.useUnsplashBackground = false, @@ -183,6 +188,7 @@ class AppSettings extends HiveObject { int? mainScreenDurationSec, int? slideDurationSec, String? lastSyncDate, + String? lastAutoSyncAttemptDate, List? slideshowImages, int? textScaleIndex, bool? useUnsplashBackground, @@ -217,6 +223,8 @@ class AppSettings extends HiveObject { mainScreenDurationSec: mainScreenDurationSec ?? this.mainScreenDurationSec, slideDurationSec: slideDurationSec ?? this.slideDurationSec, lastSyncDate: lastSyncDate ?? this.lastSyncDate, + lastAutoSyncAttemptDate: + lastAutoSyncAttemptDate ?? this.lastAutoSyncAttemptDate, slideshowImages: slideshowImages ?? this.slideshowImages, textScaleIndex: textScaleIndex ?? this.textScaleIndex, useUnsplashBackground: useUnsplashBackground ?? this.useUnsplashBackground, @@ -266,6 +274,7 @@ class AppSettingsAdapter extends TypeAdapter { mainScreenDurationSec: fields[17] as int? ?? 15, slideDurationSec: fields[18] as int? ?? 10, lastSyncDate: fields[19] as String?, + lastAutoSyncAttemptDate: fields[32] as String?, slideshowImages: (fields[20] as List?)?.cast() ?? const [], textScaleIndex: fields[21] as int? ?? 1, useUnsplashBackground: fields[22] as bool? ?? false, @@ -284,7 +293,7 @@ class AppSettingsAdapter extends TypeAdapter { @override void write(BinaryWriter writer, AppSettings obj) { writer - ..writeByte(32) + ..writeByte(33) ..writeByte(0)..write(obj.masjidName) ..writeByte(1)..write(obj.masjidAddress) ..writeByte(2)..write(obj.cityIdApi) @@ -305,6 +314,7 @@ class AppSettingsAdapter extends TypeAdapter { ..writeByte(17)..write(obj.mainScreenDurationSec) ..writeByte(18)..write(obj.slideDurationSec) ..writeByte(19)..write(obj.lastSyncDate) + ..writeByte(32)..write(obj.lastAutoSyncAttemptDate) ..writeByte(20)..write(obj.slideshowImages) ..writeByte(21)..write(obj.textScaleIndex) ..writeByte(22)..write(obj.useUnsplashBackground) diff --git a/lib/data/services/sync_service.dart b/lib/data/services/sync_service.dart index 5e2a878..281d3a1 100644 --- a/lib/data/services/sync_service.dart +++ b/lib/data/services/sync_service.dart @@ -77,16 +77,75 @@ class ScheduleCacheStatus { class SyncService { SyncService._(); static final SyncService instance = SyncService._(); + static const Duration _autoRefreshCooldown = Duration(hours: 12); + + 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 = DateTime.tryParse(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(); + } + } + + 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() async { + 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 = DateTime.now(); + final now = referenceDate ?? DateTime.now(); final currentMonth = DateFormat('yyyy-MM').format(now); // Also fetch next month for continuity @@ -97,10 +156,13 @@ class SyncService { 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; scheduleBox.put( @@ -124,6 +186,7 @@ class SyncService { // 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; scheduleBox.put( @@ -144,6 +207,9 @@ class SyncService { } if (success) { + if (hasCurrentMonth && hasNextMonth) { + await _pruneScheduleCache(scheduleBox, now); + } settings.lastSyncDate = DateFormat('yyyy-MM-dd HH:mm').format(now); await settings.save(); } @@ -151,6 +217,38 @@ class SyncService { 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 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 = @@ -169,3 +267,24 @@ class SyncService { ); } } + +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); +} diff --git a/lib/features/admin/admin_screen.dart b/lib/features/admin/admin_screen.dart index f7747f6..059172a 100644 --- a/lib/features/admin/admin_screen.dart +++ b/lib/features/admin/admin_screen.dart @@ -1628,6 +1628,17 @@ class _AdminScreenState extends ConsumerState { height: 180 * s, width: double.infinity, fit: BoxFit.cover, + errorBuilder: (_, __, ___) => Container( + height: 180 * s, + width: double.infinity, + color: SacredColors.surfaceContainerLowest, + alignment: Alignment.center, + child: Icon( + Icons.broken_image, + size: 36 * s, + color: SacredColors.onSurfaceVariant, + ), + ), ), ), SizedBox(height: 12 * s), @@ -1673,8 +1684,9 @@ class _AdminScreenState extends ConsumerState { s: s, onActivate: () async { final res = await FilePicker.platform.pickFiles(type: FileType.image); - if (res != null && res.files.single.path != null) { - setState(() => _brandedBgImage = res.files.single.path); + final selectedPath = res?.files.single.path; + if (selectedPath != null && File(selectedPath).existsSync()) { + setState(() => _brandedBgImage = selectedPath); _queueTampilanAutoSave( message: 'Foto latar otomatis tersimpan', ); @@ -1683,8 +1695,10 @@ class _AdminScreenState extends ConsumerState { child: ElevatedButton.icon( onPressed: () async { final res = await FilePicker.platform.pickFiles(type: FileType.image); - if (res != null && res.files.single.path != null) { - setState(() => _brandedBgImage = res.files.single.path); + final selectedPath = res?.files.single.path; + if (selectedPath != null && + File(selectedPath).existsSync()) { + setState(() => _brandedBgImage = selectedPath); _queueTampilanAutoSave( message: 'Foto latar otomatis tersimpan', ); @@ -1720,7 +1734,9 @@ class _AdminScreenState extends ConsumerState { if (res != null) { setState(() { for (var path in res.paths) { - if (path != null && !_slideshowImages.contains(path)) { + if (path != null && + File(path).existsSync() && + !_slideshowImages.contains(path)) { _slideshowImages.add(path); } } @@ -1736,7 +1752,9 @@ class _AdminScreenState extends ConsumerState { if (res != null) { setState(() { for (var path in res.paths) { - if (path != null && !_slideshowImages.contains(path)) { + if (path != null && + File(path).existsSync() && + !_slideshowImages.contains(path)) { _slideshowImages.add(path); } } @@ -1785,6 +1803,17 @@ class _AdminScreenState extends ConsumerState { width: double.infinity, height: 120 * s, fit: BoxFit.cover, + errorBuilder: (_, __, ___) => Container( + width: double.infinity, + height: 120 * s, + color: SacredColors.surfaceContainerHigh, + alignment: Alignment.center, + child: Icon( + Icons.broken_image, + size: 32 * s, + color: SacredColors.onSurfaceVariant, + ), + ), ), ), SizedBox(height: 10 * s), diff --git a/lib/features/home/home_view.dart b/lib/features/home/home_view.dart index 74306d9..7c446a0 100644 --- a/lib/features/home/home_view.dart +++ b/lib/features/home/home_view.dart @@ -44,12 +44,15 @@ class _HomeViewState extends ConsumerState { final List _simulationShortcutKeys = []; Timer? _comboResetTimer; Timer? _simulationShortcutTimer; + Timer? _autoRefreshTimer; + bool _isAutoRefreshRunning = false; @override void initState() { super.initState(); WidgetsBinding.instance.addPostFrameCallback((_) { _checkAutoSync(); + _startAutoRefreshMonitor(); if (mounted) { _homeFocusNode.requestFocus(); } @@ -60,21 +63,38 @@ class _HomeViewState extends ConsumerState { void dispose() { _comboResetTimer?.cancel(); _simulationShortcutTimer?.cancel(); + _autoRefreshTimer?.cancel(); _homeFocusNode.dispose(); super.dispose(); } + void _startAutoRefreshMonitor() { + _autoRefreshTimer?.cancel(); + _autoRefreshTimer = Timer.periodic( + const Duration(hours: 6), + (_) => _checkAutoSync(), + ); + } + Future _checkAutoSync() async { - final schedule = ref.read(todayScheduleProvider); - if (schedule == null) { - debugPrint('[AutoSync] No schedule found for today! Starting auto-sync...'); - final success = await SyncService.instance.syncMonthlyData(); - if (success && mounted) { - debugPrint('[AutoSync] Success! Invalidating todayScheduleProvider.'); + if (_isAutoRefreshRunning || !mounted) return; + _isAutoRefreshRunning = true; + try { + final result = await SyncService.instance.autoRefreshIfNeeded(); + if (!mounted) return; + + if (result.synced) { + debugPrint('[AutoSync] Cache refreshed successfully.'); ref.invalidate(todayScheduleProvider); - } else { - debugPrint('[AutoSync] Failed or data remained empty.'); + ref.invalidate(scheduleCacheStatusProvider); + return; } + + if (result.attempted) { + debugPrint('[AutoSync] Refresh attempt failed. Staying on local cache.'); + } + } finally { + _isAutoRefreshRunning = false; } } diff --git a/lib/features/home/jumat_screen.dart b/lib/features/home/jumat_screen.dart index 66f385a..2d22396 100644 --- a/lib/features/home/jumat_screen.dart +++ b/lib/features/home/jumat_screen.dart @@ -49,6 +49,7 @@ class JumatScreen extends ConsumerWidget { fit: BoxFit.cover, color: Colors.black.withValues(alpha: 0.55), colorBlendMode: BlendMode.darken, + errorBuilder: (_, __, ___) => const UnsplashBackground(), ), ) else diff --git a/lib/features/home/main_screen.dart b/lib/features/home/main_screen.dart index c01aead..21cf32e 100644 --- a/lib/features/home/main_screen.dart +++ b/lib/features/home/main_screen.dart @@ -53,6 +53,7 @@ class MainScreen extends ConsumerWidget { fit: BoxFit.cover, color: Colors.black.withValues(alpha: 0.55), colorBlendMode: BlendMode.darken, + errorBuilder: (_, __, ___) => const UnsplashBackground(), ), ) else diff --git a/lib/features/home/unsplash_background.dart b/lib/features/home/unsplash_background.dart index d1d6b52..3cadec2 100644 --- a/lib/features/home/unsplash_background.dart +++ b/lib/features/home/unsplash_background.dart @@ -96,6 +96,7 @@ class _UnsplashBackgroundState extends ConsumerState { // Soft opacity behind the MainScreen's dark glass vignette color: Colors.black.withValues(alpha: 0.5), colorBlendMode: BlendMode.darken, + errorBuilder: (_, __, ___) => const SizedBox.shrink(), ), ); } diff --git a/lib/main.dart b/lib/main.dart index e494ad1..bb16bc9 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,3 +1,7 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:ui'; + import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -11,7 +15,44 @@ import 'features/home/home_view.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); + FlutterError.onError = (details) { + FlutterError.presentError(details); + debugPrint('[Fatal][FlutterError] ${details.exceptionAsString()}'); + }; + PlatformDispatcher.instance.onError = (error, stack) { + debugPrint('[Fatal][PlatformDispatcher] $error'); + debugPrintStack(stackTrace: stack); + return true; + }; + ErrorWidget.builder = (details) { + return const Material( + color: SacredColors.background, + child: Center( + child: Padding( + padding: EdgeInsets.all(32), + child: Text( + 'Terjadi gangguan tampilan.\nAplikasi tetap berjalan dalam mode aman.', + textAlign: TextAlign.center, + style: TextStyle( + color: SacredColors.onSurface, + fontSize: 24, + fontWeight: FontWeight.w700, + ), + ), + ), + ), + ); + }; + await runZonedGuarded(() async { + await _bootstrapAndRun(); + }, (error, stack) { + debugPrint('[Fatal][Zone] $error'); + debugPrintStack(stackTrace: stack); + }); +} + +Future _bootstrapAndRun() async { // Landscape-only for TV await SystemChrome.setPreferredOrientations([ DeviceOrientation.landscapeLeft, @@ -33,6 +74,7 @@ void main() async { if (settingsBox.get('default') == null) { await settingsBox.put('default', AppSettings()); } + await _sanitizeMediaSettings(settingsBox); // Initialize date formatting for Indonesian locale await initializeDateFormatting('id_ID'); @@ -47,6 +89,33 @@ void main() async { ); } +Future _sanitizeMediaSettings(Box settingsBox) async { + final settings = settingsBox.get('default'); + if (settings == null) return; + + final validSlides = settings.slideshowImages + .where((path) => path.trim().isNotEmpty && File(path).existsSync()) + .toList(); + final brandedBg = (settings.brandedBgImage != null && + settings.brandedBgImage!.trim().isNotEmpty && + File(settings.brandedBgImage!).existsSync()) + ? settings.brandedBgImage + : null; + + final needsSave = + validSlides.length != settings.slideshowImages.length || + brandedBg != settings.brandedBgImage; + if (!needsSave) return; + + await settingsBox.put( + 'default', + settings.copyWith( + slideshowImages: validSlides, + brandedBgImage: brandedBg, + ), + ); +} + class JamShalatApp extends ConsumerWidget { const JamShalatApp({super.key}); diff --git a/test/widget_test.dart b/test/widget_test.dart index acd15c4..f16ad72 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -30,11 +30,13 @@ void main() { masjidName: 'Masjid Raya', cityIdApi: '1301', iqomahDzuhur: 12, + lastAutoSyncAttemptDate: '2026-03-31T09:00:00.000', ); expect(updated.masjidName, 'Masjid Raya'); expect(updated.cityIdApi, '1301'); expect(updated.iqomahDzuhur, 12); + expect(updated.lastAutoSyncAttemptDate, '2026-03-31T09:00:00.000'); expect(updated.masjidAddress, settings.masjidAddress); expect(updated.runningTexts, settings.runningTexts); }); @@ -94,5 +96,25 @@ void main() { expect(status.cachedDays, 2); expect(status.daysUntilRefresh, 31); }); + + test('rolling window stale key helper keeps only current and next month', () { + final staleKeys = SyncService.staleScheduleKeys( + [ + '2026-02-28', + '2026-03-01', + '2026-04-30', + '2026-05-01', + 'invalid-key', + ], + DateTime(2026, 3, 30), + ); + + expect( + staleKeys, + containsAll(['2026-02-28', '2026-05-01', 'invalid-key']), + ); + expect(staleKeys, isNot(contains('2026-03-01'))); + expect(staleKeys, isNot(contains('2026-04-30'))); + }); }); }