Harden app for 24-7 offline-first operation

This commit is contained in:
dwindown
2026-03-31 14:37:14 +07:00
parent 49f130b5ea
commit 081ed9f695
9 changed files with 289 additions and 17 deletions

View File

@@ -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<String> 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<String>? 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<AppSettings> {
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<String>() ?? const [],
textScaleIndex: fields[21] as int? ?? 1,
useUnsplashBackground: fields[22] as bool? ?? false,
@@ -284,7 +293,7 @@ class AppSettingsAdapter extends TypeAdapter<AppSettings> {
@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<AppSettings> {
..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)

View File

@@ -77,16 +77,75 @@ class ScheduleCacheStatus {
class SyncService {
SyncService._();
static final SyncService instance = SyncService._();
static const Duration _autoRefreshCooldown = Duration(hours: 12);
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 = 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<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() async {
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 = 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<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;
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<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 =
@@ -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);
}

View File

@@ -1628,6 +1628,17 @@ class _AdminScreenState extends ConsumerState<AdminScreen> {
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<AdminScreen> {
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<AdminScreen> {
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<AdminScreen> {
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<AdminScreen> {
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<AdminScreen> {
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),

View File

@@ -44,12 +44,15 @@ class _HomeViewState extends ConsumerState<HomeView> {
final List<LogicalKeyboardKey> _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<HomeView> {
void dispose() {
_comboResetTimer?.cancel();
_simulationShortcutTimer?.cancel();
_autoRefreshTimer?.cancel();
_homeFocusNode.dispose();
super.dispose();
}
Future<void> _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.');
ref.invalidate(todayScheduleProvider);
} else {
debugPrint('[AutoSync] Failed or data remained empty.');
void _startAutoRefreshMonitor() {
_autoRefreshTimer?.cancel();
_autoRefreshTimer = Timer.periodic(
const Duration(hours: 6),
(_) => _checkAutoSync(),
);
}
Future<void> _checkAutoSync() async {
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);
ref.invalidate(scheduleCacheStatusProvider);
return;
}
if (result.attempted) {
debugPrint('[AutoSync] Refresh attempt failed. Staying on local cache.');
}
} finally {
_isAutoRefreshRunning = false;
}
}

View File

@@ -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

View File

@@ -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

View File

@@ -96,6 +96,7 @@ class _UnsplashBackgroundState extends ConsumerState<UnsplashBackground> {
// Soft opacity behind the MainScreen's dark glass vignette
color: Colors.black.withValues(alpha: 0.5),
colorBlendMode: BlendMode.darken,
errorBuilder: (_, __, ___) => const SizedBox.shrink(),
),
);
}

View File

@@ -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<void> _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<void> _sanitizeMediaSettings(Box<AppSettings> 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});

View File

@@ -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(<String>['2026-02-28', '2026-05-01', 'invalid-key']),
);
expect(staleKeys, isNot(contains('2026-03-01')));
expect(staleKeys, isNot(contains('2026-04-30')));
});
});
}