Harden app for 24-7 offline-first operation
This commit is contained in:
@@ -77,6 +77,10 @@ class AppSettings extends HiveObject {
|
|||||||
@HiveField(19)
|
@HiveField(19)
|
||||||
String? lastSyncDate;
|
String? lastSyncDate;
|
||||||
|
|
||||||
|
// Last automatic sync attempt timestamp (ISO8601).
|
||||||
|
@HiveField(32)
|
||||||
|
String? lastAutoSyncAttemptDate;
|
||||||
|
|
||||||
// Slideshow image paths (local)
|
// Slideshow image paths (local)
|
||||||
@HiveField(20)
|
@HiveField(20)
|
||||||
List<String> slideshowImages;
|
List<String> slideshowImages;
|
||||||
@@ -148,6 +152,7 @@ class AppSettings extends HiveObject {
|
|||||||
this.mainScreenDurationSec = 15,
|
this.mainScreenDurationSec = 15,
|
||||||
this.slideDurationSec = 10,
|
this.slideDurationSec = 10,
|
||||||
this.lastSyncDate,
|
this.lastSyncDate,
|
||||||
|
this.lastAutoSyncAttemptDate,
|
||||||
this.slideshowImages = const [],
|
this.slideshowImages = const [],
|
||||||
this.textScaleIndex = 1,
|
this.textScaleIndex = 1,
|
||||||
this.useUnsplashBackground = false,
|
this.useUnsplashBackground = false,
|
||||||
@@ -183,6 +188,7 @@ class AppSettings extends HiveObject {
|
|||||||
int? mainScreenDurationSec,
|
int? mainScreenDurationSec,
|
||||||
int? slideDurationSec,
|
int? slideDurationSec,
|
||||||
String? lastSyncDate,
|
String? lastSyncDate,
|
||||||
|
String? lastAutoSyncAttemptDate,
|
||||||
List<String>? slideshowImages,
|
List<String>? slideshowImages,
|
||||||
int? textScaleIndex,
|
int? textScaleIndex,
|
||||||
bool? useUnsplashBackground,
|
bool? useUnsplashBackground,
|
||||||
@@ -217,6 +223,8 @@ class AppSettings extends HiveObject {
|
|||||||
mainScreenDurationSec: mainScreenDurationSec ?? this.mainScreenDurationSec,
|
mainScreenDurationSec: mainScreenDurationSec ?? this.mainScreenDurationSec,
|
||||||
slideDurationSec: slideDurationSec ?? this.slideDurationSec,
|
slideDurationSec: slideDurationSec ?? this.slideDurationSec,
|
||||||
lastSyncDate: lastSyncDate ?? this.lastSyncDate,
|
lastSyncDate: lastSyncDate ?? this.lastSyncDate,
|
||||||
|
lastAutoSyncAttemptDate:
|
||||||
|
lastAutoSyncAttemptDate ?? this.lastAutoSyncAttemptDate,
|
||||||
slideshowImages: slideshowImages ?? this.slideshowImages,
|
slideshowImages: slideshowImages ?? this.slideshowImages,
|
||||||
textScaleIndex: textScaleIndex ?? this.textScaleIndex,
|
textScaleIndex: textScaleIndex ?? this.textScaleIndex,
|
||||||
useUnsplashBackground: useUnsplashBackground ?? this.useUnsplashBackground,
|
useUnsplashBackground: useUnsplashBackground ?? this.useUnsplashBackground,
|
||||||
@@ -266,6 +274,7 @@ class AppSettingsAdapter extends TypeAdapter<AppSettings> {
|
|||||||
mainScreenDurationSec: fields[17] as int? ?? 15,
|
mainScreenDurationSec: fields[17] as int? ?? 15,
|
||||||
slideDurationSec: fields[18] as int? ?? 10,
|
slideDurationSec: fields[18] as int? ?? 10,
|
||||||
lastSyncDate: fields[19] as String?,
|
lastSyncDate: fields[19] as String?,
|
||||||
|
lastAutoSyncAttemptDate: fields[32] as String?,
|
||||||
slideshowImages: (fields[20] as List?)?.cast<String>() ?? const [],
|
slideshowImages: (fields[20] as List?)?.cast<String>() ?? const [],
|
||||||
textScaleIndex: fields[21] as int? ?? 1,
|
textScaleIndex: fields[21] as int? ?? 1,
|
||||||
useUnsplashBackground: fields[22] as bool? ?? false,
|
useUnsplashBackground: fields[22] as bool? ?? false,
|
||||||
@@ -284,7 +293,7 @@ class AppSettingsAdapter extends TypeAdapter<AppSettings> {
|
|||||||
@override
|
@override
|
||||||
void write(BinaryWriter writer, AppSettings obj) {
|
void write(BinaryWriter writer, AppSettings obj) {
|
||||||
writer
|
writer
|
||||||
..writeByte(32)
|
..writeByte(33)
|
||||||
..writeByte(0)..write(obj.masjidName)
|
..writeByte(0)..write(obj.masjidName)
|
||||||
..writeByte(1)..write(obj.masjidAddress)
|
..writeByte(1)..write(obj.masjidAddress)
|
||||||
..writeByte(2)..write(obj.cityIdApi)
|
..writeByte(2)..write(obj.cityIdApi)
|
||||||
@@ -305,6 +314,7 @@ class AppSettingsAdapter extends TypeAdapter<AppSettings> {
|
|||||||
..writeByte(17)..write(obj.mainScreenDurationSec)
|
..writeByte(17)..write(obj.mainScreenDurationSec)
|
||||||
..writeByte(18)..write(obj.slideDurationSec)
|
..writeByte(18)..write(obj.slideDurationSec)
|
||||||
..writeByte(19)..write(obj.lastSyncDate)
|
..writeByte(19)..write(obj.lastSyncDate)
|
||||||
|
..writeByte(32)..write(obj.lastAutoSyncAttemptDate)
|
||||||
..writeByte(20)..write(obj.slideshowImages)
|
..writeByte(20)..write(obj.slideshowImages)
|
||||||
..writeByte(21)..write(obj.textScaleIndex)
|
..writeByte(21)..write(obj.textScaleIndex)
|
||||||
..writeByte(22)..write(obj.useUnsplashBackground)
|
..writeByte(22)..write(obj.useUnsplashBackground)
|
||||||
|
|||||||
@@ -77,16 +77,75 @@ class ScheduleCacheStatus {
|
|||||||
class SyncService {
|
class SyncService {
|
||||||
SyncService._();
|
SyncService._();
|
||||||
static final SyncService instance = 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.
|
/// Sync current month + next month prayer data for the configured city.
|
||||||
/// Returns true on success.
|
/// Returns true on success.
|
||||||
Future<bool> syncMonthlyData() async {
|
Future<bool> syncMonthlyData({DateTime? referenceDate}) async {
|
||||||
final settingsBox = Hive.box<AppSettings>(HiveBoxes.settings);
|
final settingsBox = Hive.box<AppSettings>(HiveBoxes.settings);
|
||||||
final settings = settingsBox.get('default');
|
final settings = settingsBox.get('default');
|
||||||
if (settings == null) return false;
|
if (settings == null) return false;
|
||||||
|
|
||||||
final cityId = settings.cityIdApi;
|
final cityId = settings.cityIdApi;
|
||||||
final now = DateTime.now();
|
final now = referenceDate ?? DateTime.now();
|
||||||
final currentMonth = DateFormat('yyyy-MM').format(now);
|
final currentMonth = DateFormat('yyyy-MM').format(now);
|
||||||
|
|
||||||
// Also fetch next month for continuity
|
// Also fetch next month for continuity
|
||||||
@@ -97,10 +156,13 @@ class SyncService {
|
|||||||
final scheduleBox = Hive.box<DailyPrayerSchedule>(HiveBoxes.prayerSchedule);
|
final scheduleBox = Hive.box<DailyPrayerSchedule>(HiveBoxes.prayerSchedule);
|
||||||
|
|
||||||
var success = false;
|
var success = false;
|
||||||
|
var hasCurrentMonth = false;
|
||||||
|
var hasNextMonth = false;
|
||||||
|
|
||||||
// Fetch current month
|
// Fetch current month
|
||||||
final currentData = await api.getMonthlySchedule(cityId, currentMonth);
|
final currentData = await api.getMonthlySchedule(cityId, currentMonth);
|
||||||
if (currentData.isNotEmpty) {
|
if (currentData.isNotEmpty) {
|
||||||
|
hasCurrentMonth = true;
|
||||||
for (final entry in currentData.entries) {
|
for (final entry in currentData.entries) {
|
||||||
final jadwal = entry.value;
|
final jadwal = entry.value;
|
||||||
scheduleBox.put(
|
scheduleBox.put(
|
||||||
@@ -124,6 +186,7 @@ class SyncService {
|
|||||||
// Fetch next month
|
// Fetch next month
|
||||||
final nextData = await api.getMonthlySchedule(cityId, nextMonth);
|
final nextData = await api.getMonthlySchedule(cityId, nextMonth);
|
||||||
if (nextData.isNotEmpty) {
|
if (nextData.isNotEmpty) {
|
||||||
|
hasNextMonth = true;
|
||||||
for (final entry in nextData.entries) {
|
for (final entry in nextData.entries) {
|
||||||
final jadwal = entry.value;
|
final jadwal = entry.value;
|
||||||
scheduleBox.put(
|
scheduleBox.put(
|
||||||
@@ -144,6 +207,9 @@ class SyncService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (success) {
|
if (success) {
|
||||||
|
if (hasCurrentMonth && hasNextMonth) {
|
||||||
|
await _pruneScheduleCache(scheduleBox, now);
|
||||||
|
}
|
||||||
settings.lastSyncDate = DateFormat('yyyy-MM-dd HH:mm').format(now);
|
settings.lastSyncDate = DateFormat('yyyy-MM-dd HH:mm').format(now);
|
||||||
await settings.save();
|
await settings.save();
|
||||||
}
|
}
|
||||||
@@ -151,6 +217,38 @@ class SyncService {
|
|||||||
return success;
|
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.
|
/// Get today's prayer schedule from local Hive cache.
|
||||||
DailyPrayerSchedule? getTodaySchedule([DateTime? targetDate]) {
|
DailyPrayerSchedule? getTodaySchedule([DateTime? targetDate]) {
|
||||||
final scheduleBox =
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1628,6 +1628,17 @@ class _AdminScreenState extends ConsumerState<AdminScreen> {
|
|||||||
height: 180 * s,
|
height: 180 * s,
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
fit: BoxFit.cover,
|
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),
|
SizedBox(height: 12 * s),
|
||||||
@@ -1673,8 +1684,9 @@ class _AdminScreenState extends ConsumerState<AdminScreen> {
|
|||||||
s: s,
|
s: s,
|
||||||
onActivate: () async {
|
onActivate: () async {
|
||||||
final res = await FilePicker.platform.pickFiles(type: FileType.image);
|
final res = await FilePicker.platform.pickFiles(type: FileType.image);
|
||||||
if (res != null && res.files.single.path != null) {
|
final selectedPath = res?.files.single.path;
|
||||||
setState(() => _brandedBgImage = res.files.single.path);
|
if (selectedPath != null && File(selectedPath).existsSync()) {
|
||||||
|
setState(() => _brandedBgImage = selectedPath);
|
||||||
_queueTampilanAutoSave(
|
_queueTampilanAutoSave(
|
||||||
message: 'Foto latar otomatis tersimpan',
|
message: 'Foto latar otomatis tersimpan',
|
||||||
);
|
);
|
||||||
@@ -1683,8 +1695,10 @@ class _AdminScreenState extends ConsumerState<AdminScreen> {
|
|||||||
child: ElevatedButton.icon(
|
child: ElevatedButton.icon(
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
final res = await FilePicker.platform.pickFiles(type: FileType.image);
|
final res = await FilePicker.platform.pickFiles(type: FileType.image);
|
||||||
if (res != null && res.files.single.path != null) {
|
final selectedPath = res?.files.single.path;
|
||||||
setState(() => _brandedBgImage = res.files.single.path);
|
if (selectedPath != null &&
|
||||||
|
File(selectedPath).existsSync()) {
|
||||||
|
setState(() => _brandedBgImage = selectedPath);
|
||||||
_queueTampilanAutoSave(
|
_queueTampilanAutoSave(
|
||||||
message: 'Foto latar otomatis tersimpan',
|
message: 'Foto latar otomatis tersimpan',
|
||||||
);
|
);
|
||||||
@@ -1720,7 +1734,9 @@ class _AdminScreenState extends ConsumerState<AdminScreen> {
|
|||||||
if (res != null) {
|
if (res != null) {
|
||||||
setState(() {
|
setState(() {
|
||||||
for (var path in res.paths) {
|
for (var path in res.paths) {
|
||||||
if (path != null && !_slideshowImages.contains(path)) {
|
if (path != null &&
|
||||||
|
File(path).existsSync() &&
|
||||||
|
!_slideshowImages.contains(path)) {
|
||||||
_slideshowImages.add(path);
|
_slideshowImages.add(path);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1736,7 +1752,9 @@ class _AdminScreenState extends ConsumerState<AdminScreen> {
|
|||||||
if (res != null) {
|
if (res != null) {
|
||||||
setState(() {
|
setState(() {
|
||||||
for (var path in res.paths) {
|
for (var path in res.paths) {
|
||||||
if (path != null && !_slideshowImages.contains(path)) {
|
if (path != null &&
|
||||||
|
File(path).existsSync() &&
|
||||||
|
!_slideshowImages.contains(path)) {
|
||||||
_slideshowImages.add(path);
|
_slideshowImages.add(path);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1785,6 +1803,17 @@ class _AdminScreenState extends ConsumerState<AdminScreen> {
|
|||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
height: 120 * s,
|
height: 120 * s,
|
||||||
fit: BoxFit.cover,
|
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),
|
SizedBox(height: 10 * s),
|
||||||
|
|||||||
@@ -44,12 +44,15 @@ class _HomeViewState extends ConsumerState<HomeView> {
|
|||||||
final List<LogicalKeyboardKey> _simulationShortcutKeys = [];
|
final List<LogicalKeyboardKey> _simulationShortcutKeys = [];
|
||||||
Timer? _comboResetTimer;
|
Timer? _comboResetTimer;
|
||||||
Timer? _simulationShortcutTimer;
|
Timer? _simulationShortcutTimer;
|
||||||
|
Timer? _autoRefreshTimer;
|
||||||
|
bool _isAutoRefreshRunning = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
_checkAutoSync();
|
_checkAutoSync();
|
||||||
|
_startAutoRefreshMonitor();
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
_homeFocusNode.requestFocus();
|
_homeFocusNode.requestFocus();
|
||||||
}
|
}
|
||||||
@@ -60,21 +63,38 @@ class _HomeViewState extends ConsumerState<HomeView> {
|
|||||||
void dispose() {
|
void dispose() {
|
||||||
_comboResetTimer?.cancel();
|
_comboResetTimer?.cancel();
|
||||||
_simulationShortcutTimer?.cancel();
|
_simulationShortcutTimer?.cancel();
|
||||||
|
_autoRefreshTimer?.cancel();
|
||||||
_homeFocusNode.dispose();
|
_homeFocusNode.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _startAutoRefreshMonitor() {
|
||||||
|
_autoRefreshTimer?.cancel();
|
||||||
|
_autoRefreshTimer = Timer.periodic(
|
||||||
|
const Duration(hours: 6),
|
||||||
|
(_) => _checkAutoSync(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _checkAutoSync() async {
|
Future<void> _checkAutoSync() async {
|
||||||
final schedule = ref.read(todayScheduleProvider);
|
if (_isAutoRefreshRunning || !mounted) return;
|
||||||
if (schedule == null) {
|
_isAutoRefreshRunning = true;
|
||||||
debugPrint('[AutoSync] No schedule found for today! Starting auto-sync...');
|
try {
|
||||||
final success = await SyncService.instance.syncMonthlyData();
|
final result = await SyncService.instance.autoRefreshIfNeeded();
|
||||||
if (success && mounted) {
|
if (!mounted) return;
|
||||||
debugPrint('[AutoSync] Success! Invalidating todayScheduleProvider.');
|
|
||||||
|
if (result.synced) {
|
||||||
|
debugPrint('[AutoSync] Cache refreshed successfully.');
|
||||||
ref.invalidate(todayScheduleProvider);
|
ref.invalidate(todayScheduleProvider);
|
||||||
} else {
|
ref.invalidate(scheduleCacheStatusProvider);
|
||||||
debugPrint('[AutoSync] Failed or data remained empty.');
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (result.attempted) {
|
||||||
|
debugPrint('[AutoSync] Refresh attempt failed. Staying on local cache.');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
_isAutoRefreshRunning = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ class JumatScreen extends ConsumerWidget {
|
|||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
color: Colors.black.withValues(alpha: 0.55),
|
color: Colors.black.withValues(alpha: 0.55),
|
||||||
colorBlendMode: BlendMode.darken,
|
colorBlendMode: BlendMode.darken,
|
||||||
|
errorBuilder: (_, __, ___) => const UnsplashBackground(),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ class MainScreen extends ConsumerWidget {
|
|||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
color: Colors.black.withValues(alpha: 0.55),
|
color: Colors.black.withValues(alpha: 0.55),
|
||||||
colorBlendMode: BlendMode.darken,
|
colorBlendMode: BlendMode.darken,
|
||||||
|
errorBuilder: (_, __, ___) => const UnsplashBackground(),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -96,6 +96,7 @@ class _UnsplashBackgroundState extends ConsumerState<UnsplashBackground> {
|
|||||||
// Soft opacity behind the MainScreen's dark glass vignette
|
// Soft opacity behind the MainScreen's dark glass vignette
|
||||||
color: Colors.black.withValues(alpha: 0.5),
|
color: Colors.black.withValues(alpha: 0.5),
|
||||||
colorBlendMode: BlendMode.darken,
|
colorBlendMode: BlendMode.darken,
|
||||||
|
errorBuilder: (_, __, ___) => const SizedBox.shrink(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
|
import 'dart:ui';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
@@ -11,7 +15,44 @@ import 'features/home/home_view.dart';
|
|||||||
|
|
||||||
void main() async {
|
void main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
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
|
// Landscape-only for TV
|
||||||
await SystemChrome.setPreferredOrientations([
|
await SystemChrome.setPreferredOrientations([
|
||||||
DeviceOrientation.landscapeLeft,
|
DeviceOrientation.landscapeLeft,
|
||||||
@@ -33,6 +74,7 @@ void main() async {
|
|||||||
if (settingsBox.get('default') == null) {
|
if (settingsBox.get('default') == null) {
|
||||||
await settingsBox.put('default', AppSettings());
|
await settingsBox.put('default', AppSettings());
|
||||||
}
|
}
|
||||||
|
await _sanitizeMediaSettings(settingsBox);
|
||||||
|
|
||||||
// Initialize date formatting for Indonesian locale
|
// Initialize date formatting for Indonesian locale
|
||||||
await initializeDateFormatting('id_ID');
|
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 {
|
class JamShalatApp extends ConsumerWidget {
|
||||||
const JamShalatApp({super.key});
|
const JamShalatApp({super.key});
|
||||||
|
|
||||||
|
|||||||
@@ -30,11 +30,13 @@ void main() {
|
|||||||
masjidName: 'Masjid Raya',
|
masjidName: 'Masjid Raya',
|
||||||
cityIdApi: '1301',
|
cityIdApi: '1301',
|
||||||
iqomahDzuhur: 12,
|
iqomahDzuhur: 12,
|
||||||
|
lastAutoSyncAttemptDate: '2026-03-31T09:00:00.000',
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(updated.masjidName, 'Masjid Raya');
|
expect(updated.masjidName, 'Masjid Raya');
|
||||||
expect(updated.cityIdApi, '1301');
|
expect(updated.cityIdApi, '1301');
|
||||||
expect(updated.iqomahDzuhur, 12);
|
expect(updated.iqomahDzuhur, 12);
|
||||||
|
expect(updated.lastAutoSyncAttemptDate, '2026-03-31T09:00:00.000');
|
||||||
expect(updated.masjidAddress, settings.masjidAddress);
|
expect(updated.masjidAddress, settings.masjidAddress);
|
||||||
expect(updated.runningTexts, settings.runningTexts);
|
expect(updated.runningTexts, settings.runningTexts);
|
||||||
});
|
});
|
||||||
@@ -94,5 +96,25 @@ void main() {
|
|||||||
expect(status.cachedDays, 2);
|
expect(status.cachedDays, 2);
|
||||||
expect(status.daysUntilRefresh, 31);
|
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')));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user