feat(offline-first): persist hijri+unsplash cache and scale secondary times
This commit is contained in:
@@ -5,6 +5,7 @@ class HiveBoxes {
|
|||||||
HiveBoxes._();
|
HiveBoxes._();
|
||||||
static const String settings = 'app_settings';
|
static const String settings = 'app_settings';
|
||||||
static const String prayerSchedule = 'prayer_schedule';
|
static const String prayerSchedule = 'prayer_schedule';
|
||||||
|
static const String hijriCache = 'hijri_cache';
|
||||||
}
|
}
|
||||||
|
|
||||||
/// AppSettings stored in Hive.
|
/// AppSettings stored in Hive.
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
import 'dart:math';
|
||||||
|
|
||||||
|
import 'package:hive_flutter/hive_flutter.dart';
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
|
|
||||||
import '../../core/hijri_date.dart';
|
import '../../core/hijri_date.dart';
|
||||||
|
import '../local/models.dart';
|
||||||
|
|
||||||
class HijriCalendarService {
|
class HijriCalendarService {
|
||||||
HijriCalendarService._({http.Client? client})
|
HijriCalendarService._({http.Client? client})
|
||||||
@@ -11,35 +14,127 @@ class HijriCalendarService {
|
|||||||
|
|
||||||
static final HijriCalendarService instance = HijriCalendarService._();
|
static final HijriCalendarService instance = HijriCalendarService._();
|
||||||
static const String _baseUrl = 'https://api.myquran.com/v3/cal/hijr';
|
static const String _baseUrl = 'https://api.myquran.com/v3/cal/hijr';
|
||||||
|
static const Duration _requestTimeout = Duration(seconds: 5);
|
||||||
|
static final RegExp _flexDateKeyRegex = RegExp(r'^(\d{4})-(\d{1,2})-(\d{1,2})$');
|
||||||
|
|
||||||
final http.Client _client;
|
final http.Client _client;
|
||||||
final Map<String, String> _cache = {};
|
final Map<String, String> _cache = {};
|
||||||
|
final DateFormat _dateFormat = DateFormat('yyyy-MM-dd');
|
||||||
|
|
||||||
|
DateTime _dateOnly(DateTime value) => DateTime(value.year, value.month, value.day);
|
||||||
|
|
||||||
|
String _dateKeyFromDate(DateTime value) => _dateFormat.format(_dateOnly(value));
|
||||||
|
|
||||||
|
String? _normalizeDateKey(String rawValue) {
|
||||||
|
final trimmed = rawValue.trim();
|
||||||
|
if (trimmed.isEmpty) return null;
|
||||||
|
|
||||||
|
final parsedIso = DateTime.tryParse(trimmed);
|
||||||
|
if (parsedIso != null) return _dateKeyFromDate(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 _dateKeyFromDate(DateTime(year, month, day));
|
||||||
|
}
|
||||||
|
|
||||||
|
Box<String>? _diskCacheBoxOrNull() {
|
||||||
|
if (!Hive.isBoxOpen(HiveBoxes.hijriCache)) return null;
|
||||||
|
return Hive.box<String>(HiveBoxes.hijriCache);
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _readDiskCache(String dateKey) => _diskCacheBoxOrNull()?.get(dateKey);
|
||||||
|
|
||||||
|
Future<void> _writeDiskCache(String dateKey, String label) async {
|
||||||
|
final box = _diskCacheBoxOrNull();
|
||||||
|
if (box == null) return;
|
||||||
|
await box.put(dateKey, label);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String?> _fetchHijriLabelFromApi(String dateKey) async {
|
||||||
|
try {
|
||||||
|
final response = await _client
|
||||||
|
.get(Uri.parse('$_baseUrl/$dateKey'))
|
||||||
|
.timeout(_requestTimeout);
|
||||||
|
if (response.statusCode != 200) return null;
|
||||||
|
|
||||||
|
final payload = json.decode(response.body) as Map<String, dynamic>;
|
||||||
|
return parseHijriLabel(payload);
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<String> getHijriLabel(DateTime date) async {
|
Future<String> getHijriLabel(DateTime date) async {
|
||||||
final dateOnly = DateTime(date.year, date.month, date.day);
|
final dateOnly = _dateOnly(date);
|
||||||
final dateKey = DateFormat('yyyy-MM-dd').format(dateOnly);
|
final dateKey = _dateKeyFromDate(dateOnly);
|
||||||
final cached = _cache[dateKey];
|
final cached = _cache[dateKey];
|
||||||
if (cached != null) return cached;
|
if (cached != null) return cached;
|
||||||
|
|
||||||
try {
|
final diskCached = _readDiskCache(dateKey);
|
||||||
final response = await _client.get(Uri.parse('$_baseUrl/$dateKey'));
|
if (diskCached != null && diskCached.trim().isNotEmpty) {
|
||||||
if (response.statusCode == 200) {
|
_cache[dateKey] = diskCached;
|
||||||
final payload = json.decode(response.body) as Map<String, dynamic>;
|
return diskCached;
|
||||||
final label = parseHijriLabel(payload);
|
|
||||||
if (label != null) {
|
|
||||||
_cache[dateKey] = label;
|
|
||||||
return label;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (_) {
|
|
||||||
// Keep UI usable when the device is offline.
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final label = await _fetchHijriLabelFromApi(dateKey);
|
||||||
|
if (label != null && label.trim().isNotEmpty) {
|
||||||
|
_cache[dateKey] = label;
|
||||||
|
await _writeDiskCache(dateKey, label);
|
||||||
|
return label;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep UI usable when the device is offline.
|
||||||
final fallback = HijriDateFormatter.format(dateOnly);
|
final fallback = HijriDateFormatter.format(dateOnly);
|
||||||
_cache[dateKey] = fallback;
|
_cache[dateKey] = fallback;
|
||||||
return fallback;
|
return fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Prefetch and persist Hijri labels for a set of Gregorian date keys.
|
||||||
|
/// Date keys should be in `yyyy-MM-dd`, but this method normalizes inputs.
|
||||||
|
Future<void> warmCacheForDateKeys(
|
||||||
|
Iterable<String> rawDateKeys, {
|
||||||
|
int maxConcurrentRequests = 4,
|
||||||
|
}) async {
|
||||||
|
final uniqueKeys = <String>{};
|
||||||
|
for (final rawKey in rawDateKeys) {
|
||||||
|
final normalized = _normalizeDateKey(rawKey);
|
||||||
|
if (normalized != null) uniqueKeys.add(normalized);
|
||||||
|
}
|
||||||
|
if (uniqueKeys.isEmpty) return;
|
||||||
|
|
||||||
|
final pendingKeys = <String>[];
|
||||||
|
for (final dateKey in uniqueKeys) {
|
||||||
|
final memory = _cache[dateKey];
|
||||||
|
if (memory != null && memory.trim().isNotEmpty) continue;
|
||||||
|
|
||||||
|
final disk = _readDiskCache(dateKey);
|
||||||
|
if (disk != null && disk.trim().isNotEmpty) {
|
||||||
|
_cache[dateKey] = disk;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingKeys.add(dateKey);
|
||||||
|
}
|
||||||
|
if (pendingKeys.isEmpty) return;
|
||||||
|
|
||||||
|
final concurrency = maxConcurrentRequests.clamp(1, 8);
|
||||||
|
for (var index = 0; index < pendingKeys.length; index += concurrency) {
|
||||||
|
final end = min(index + concurrency, pendingKeys.length);
|
||||||
|
final chunk = pendingKeys.sublist(index, end);
|
||||||
|
await Future.wait(chunk.map((dateKey) async {
|
||||||
|
final label = await _fetchHijriLabelFromApi(dateKey);
|
||||||
|
if (label == null || label.trim().isEmpty) return;
|
||||||
|
_cache[dateKey] = label;
|
||||||
|
await _writeDiskCache(dateKey, label);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
static String? parseHijriLabel(Map<String, dynamic> payload) {
|
static String? parseHijriLabel(Map<String, dynamic> payload) {
|
||||||
if (payload['status'] != true) return null;
|
if (payload['status'] != true) return null;
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:hive_flutter/hive_flutter.dart';
|
import 'package:hive_flutter/hive_flutter.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
|
|
||||||
import '../local/models.dart';
|
import '../local/models.dart';
|
||||||
|
import 'hijri_service.dart';
|
||||||
import 'myquran_service.dart';
|
import 'myquran_service.dart';
|
||||||
|
|
||||||
class ScheduleCacheStatus {
|
class ScheduleCacheStatus {
|
||||||
@@ -196,6 +199,71 @@ class SyncService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _pruneHijriCache(DateTime referenceDate) async {
|
||||||
|
if (!Hive.isBoxOpen(HiveBoxes.hijriCache)) return;
|
||||||
|
final hijriBox = Hive.box<String>(HiveBoxes.hijriCache);
|
||||||
|
final staleKeys = staleScheduleKeys(
|
||||||
|
hijriBox.keys.cast<String>(),
|
||||||
|
referenceDate,
|
||||||
|
);
|
||||||
|
if (staleKeys.isNotEmpty) {
|
||||||
|
await hijriBox.deleteAll(staleKeys);
|
||||||
|
await hijriBox.compact();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Set<String> _priorityHijriWarmupKeys(
|
||||||
|
Set<String> allDateKeys,
|
||||||
|
DateTime referenceDate,
|
||||||
|
) {
|
||||||
|
final prioritized = <String>{};
|
||||||
|
for (var offset = 0; offset <= 7; offset++) {
|
||||||
|
final key = _canonicalDateKey(referenceDate.add(Duration(days: offset)));
|
||||||
|
if (allDateKeys.contains(key)) prioritized.add(key);
|
||||||
|
}
|
||||||
|
return prioritized;
|
||||||
|
}
|
||||||
|
|
||||||
|
Set<String> _collectRollingWindowScheduleDateKeys(
|
||||||
|
Box<DailyPrayerSchedule> scheduleBox,
|
||||||
|
DateTime referenceDate,
|
||||||
|
) {
|
||||||
|
final allowedMonths = rollingWindowMonths(referenceDate);
|
||||||
|
final keys = <String>{};
|
||||||
|
|
||||||
|
for (final rawKey in scheduleBox.keys) {
|
||||||
|
if (rawKey is! String) continue;
|
||||||
|
final parsed = _parseScheduleDate(rawKey);
|
||||||
|
if (parsed == null) continue;
|
||||||
|
final monthKey = DateFormat('yyyy-MM').format(parsed);
|
||||||
|
if (!allowedMonths.contains(monthKey)) continue;
|
||||||
|
keys.add(_canonicalDateKey(parsed));
|
||||||
|
}
|
||||||
|
|
||||||
|
return keys;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _warmHijriCacheForScheduleRange(
|
||||||
|
Box<DailyPrayerSchedule> scheduleBox,
|
||||||
|
DateTime referenceDate,
|
||||||
|
) async {
|
||||||
|
final scheduleDateKeys = _collectRollingWindowScheduleDateKeys(
|
||||||
|
scheduleBox,
|
||||||
|
referenceDate,
|
||||||
|
);
|
||||||
|
if (scheduleDateKeys.isEmpty) return;
|
||||||
|
|
||||||
|
final priorityKeys = _priorityHijriWarmupKeys(scheduleDateKeys, referenceDate);
|
||||||
|
if (priorityKeys.isNotEmpty) {
|
||||||
|
await HijriCalendarService.instance.warmCacheForDateKeys(priorityKeys);
|
||||||
|
}
|
||||||
|
|
||||||
|
final remainingKeys = scheduleDateKeys.difference(priorityKeys);
|
||||||
|
if (remainingKeys.isNotEmpty) {
|
||||||
|
unawaited(HijriCalendarService.instance.warmCacheForDateKeys(remainingKeys));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
bool _shouldAttemptAutoRefresh({
|
bool _shouldAttemptAutoRefresh({
|
||||||
required ScheduleCacheStatus status,
|
required ScheduleCacheStatus status,
|
||||||
required bool hasTodayData,
|
required bool hasTodayData,
|
||||||
@@ -288,9 +356,11 @@ class SyncService {
|
|||||||
if (success) {
|
if (success) {
|
||||||
if (hasCurrentMonth && hasNextMonth) {
|
if (hasCurrentMonth && hasNextMonth) {
|
||||||
await _pruneScheduleCache(scheduleBox, now);
|
await _pruneScheduleCache(scheduleBox, now);
|
||||||
|
await _pruneHijriCache(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();
|
||||||
|
await _warmHijriCacheForScheduleRange(scheduleBox, now);
|
||||||
}
|
}
|
||||||
|
|
||||||
return success;
|
return success;
|
||||||
@@ -306,16 +376,20 @@ class SyncService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final now = referenceDate ?? DateTime.now();
|
final now = referenceDate ?? DateTime.now();
|
||||||
|
final scheduleBox =
|
||||||
|
Hive.box<DailyPrayerSchedule>(HiveBoxes.prayerSchedule);
|
||||||
final status = getCacheStatus(now);
|
final status = getCacheStatus(now);
|
||||||
final hasTodayData = getTodaySchedule(now) != null;
|
final hasTodayData = getTodaySchedule(now) != null;
|
||||||
|
|
||||||
if (!_shouldAttemptAutoRefresh(status: status, hasTodayData: hasTodayData)) {
|
if (!_shouldAttemptAutoRefresh(status: status, hasTodayData: hasTodayData)) {
|
||||||
|
unawaited(_warmHijriCacheForScheduleRange(scheduleBox, now));
|
||||||
return const AutoRefreshResult.skipped('cache-fresh');
|
return const AutoRefreshResult.skipped('cache-fresh');
|
||||||
}
|
}
|
||||||
|
|
||||||
final lastAttempt = _parseAttemptTimestamp(settings.lastAutoSyncAttemptDate);
|
final lastAttempt = _parseAttemptTimestamp(settings.lastAutoSyncAttemptDate);
|
||||||
if (lastAttempt != null &&
|
if (lastAttempt != null &&
|
||||||
now.difference(lastAttempt) < _autoRefreshCooldown) {
|
now.difference(lastAttempt) < _autoRefreshCooldown) {
|
||||||
|
unawaited(_warmHijriCacheForScheduleRange(scheduleBox, now));
|
||||||
return const AutoRefreshResult.skipped('cooldown');
|
return const AutoRefreshResult.skipped('cooldown');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -323,6 +397,9 @@ class SyncService {
|
|||||||
await settings.save();
|
await settings.save();
|
||||||
|
|
||||||
final synced = await syncMonthlyData(referenceDate: now);
|
final synced = await syncMonthlyData(referenceDate: now);
|
||||||
|
if (!synced) {
|
||||||
|
unawaited(_warmHijriCacheForScheduleRange(scheduleBox, now));
|
||||||
|
}
|
||||||
return synced
|
return synced
|
||||||
? const AutoRefreshResult.synced()
|
? const AutoRefreshResult.synced()
|
||||||
: const AutoRefreshResult.failed('sync-failed');
|
: const AutoRefreshResult.failed('sync-failed');
|
||||||
|
|||||||
131
lib/data/services/unsplash_cache_service.dart
Normal file
131
lib/data/services/unsplash_cache_service.dart
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import 'dart:math';
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:crypto/crypto.dart';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
|
||||||
|
import 'unsplash_service.dart';
|
||||||
|
|
||||||
|
/// Persistent Unsplash cache.
|
||||||
|
/// Stores downloaded images by keyword so background can still render offline.
|
||||||
|
class UnsplashCacheService {
|
||||||
|
UnsplashCacheService._();
|
||||||
|
static final UnsplashCacheService instance = UnsplashCacheService._();
|
||||||
|
|
||||||
|
static const int _maxCachedImagesPerKeyword = 24;
|
||||||
|
static const Duration _requestTimeout = Duration(seconds: 10);
|
||||||
|
|
||||||
|
final Random _random = Random();
|
||||||
|
|
||||||
|
String _normalizeKeyword(String keyword) {
|
||||||
|
final normalized = keyword.trim().toLowerCase().replaceAll(RegExp(r'\s+'), '_');
|
||||||
|
final safe = normalized.replaceAll(RegExp(r'[^a-z0-9_\-]'), '');
|
||||||
|
return safe.isEmpty ? 'mosque' : safe;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Directory> _cacheRootDirectory() async {
|
||||||
|
final baseDir = await getApplicationSupportDirectory();
|
||||||
|
final cacheDir = Directory('${baseDir.path}/unsplash_cache');
|
||||||
|
if (!cacheDir.existsSync()) {
|
||||||
|
await cacheDir.create(recursive: true);
|
||||||
|
}
|
||||||
|
return cacheDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Directory> _keywordDirectory(String keyword) async {
|
||||||
|
final root = await _cacheRootDirectory();
|
||||||
|
final folderName = _normalizeKeyword(keyword);
|
||||||
|
final dir = Directory('${root.path}/$folderName');
|
||||||
|
if (!dir.existsSync()) {
|
||||||
|
await dir.create(recursive: true);
|
||||||
|
}
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
String _filenameForUrl(String url) {
|
||||||
|
final digest = sha1.convert(utf8.encode(url)).toString();
|
||||||
|
return '$digest.jpg';
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<File>> _listCachedFiles(String keyword) async {
|
||||||
|
final dir = await _keywordDirectory(keyword);
|
||||||
|
final entities = await dir.list().toList();
|
||||||
|
final files = entities.whereType<File>().where((file) {
|
||||||
|
final ext = file.path.split('.').last.toLowerCase();
|
||||||
|
return ext == 'jpg' || ext == 'jpeg' || ext == 'png' || ext == 'webp';
|
||||||
|
}).toList();
|
||||||
|
|
||||||
|
files.sort((a, b) {
|
||||||
|
final aTime = a.statSync().modified;
|
||||||
|
final bTime = b.statSync().modified;
|
||||||
|
return bTime.compareTo(aTime);
|
||||||
|
});
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _pruneKeywordCache(String keyword) async {
|
||||||
|
final files = await _listCachedFiles(keyword);
|
||||||
|
if (files.length <= _maxCachedImagesPerKeyword) return;
|
||||||
|
|
||||||
|
for (final file in files.skip(_maxCachedImagesPerKeyword)) {
|
||||||
|
try {
|
||||||
|
if (file.existsSync()) {
|
||||||
|
await file.delete();
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// Keep best effort only.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String?> _downloadImage(String url, Directory destinationDir) async {
|
||||||
|
try {
|
||||||
|
final uri = Uri.tryParse(url);
|
||||||
|
if (uri == null) return null;
|
||||||
|
|
||||||
|
final response = await http.get(uri).timeout(_requestTimeout);
|
||||||
|
if (response.statusCode != 200 || response.bodyBytes.isEmpty) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final file = File('${destinationDir.path}/${_filenameForUrl(url)}');
|
||||||
|
await file.writeAsBytes(response.bodyBytes, flush: true);
|
||||||
|
return file.path;
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns local cached image paths for the given keyword.
|
||||||
|
Future<List<String>> getCachedImagePaths(String keyword) async {
|
||||||
|
final files = await _listCachedFiles(keyword);
|
||||||
|
return files.map((file) => file.path).toList(growable: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetches fresh Unsplash URLs and persists them as local files.
|
||||||
|
/// If fetch fails (offline), returns existing local cache.
|
||||||
|
Future<List<String>> refreshKeywordCache(
|
||||||
|
String keyword, {
|
||||||
|
int downloadCount = 12,
|
||||||
|
}) async {
|
||||||
|
final urls = await UnsplashService.instance.fetchLandscapeBackgrounds(keyword);
|
||||||
|
if (urls.isEmpty) {
|
||||||
|
return getCachedImagePaths(keyword);
|
||||||
|
}
|
||||||
|
|
||||||
|
final dir = await _keywordDirectory(keyword);
|
||||||
|
final shuffled = List<String>.from(urls)..shuffle(_random);
|
||||||
|
final targetCount = downloadCount.clamp(1, urls.length);
|
||||||
|
|
||||||
|
for (final url in shuffled.take(targetCount)) {
|
||||||
|
final path = '${dir.path}/${_filenameForUrl(url)}';
|
||||||
|
if (File(path).existsSync()) continue;
|
||||||
|
await _downloadImage(url, dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
await _pruneKeywordCache(keyword);
|
||||||
|
return getCachedImagePaths(keyword);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -537,6 +537,8 @@ class MainScreen extends ConsumerWidget {
|
|||||||
items.add(_SecondaryTimeItem('Terbit', schedule.terbit));
|
items.add(_SecondaryTimeItem('Terbit', schedule.terbit));
|
||||||
}
|
}
|
||||||
items.add(_SecondaryTimeItem('Dhuha', schedule.dhuha));
|
items.add(_SecondaryTimeItem('Dhuha', schedule.dhuha));
|
||||||
|
final labelScale = settings.scaleCardLabel;
|
||||||
|
final bodyScale = settings.scaleCardBody;
|
||||||
|
|
||||||
return Row(
|
return Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
@@ -547,17 +549,19 @@ class MainScreen extends ConsumerWidget {
|
|||||||
Text(
|
Text(
|
||||||
items[i].label.toUpperCase(),
|
items[i].label.toUpperCase(),
|
||||||
style: GoogleFonts.manrope(
|
style: GoogleFonts.manrope(
|
||||||
fontSize: 10 * fs,
|
// Keep hierarchy smaller than bottom prayer cards, but follow the same scale percentage.
|
||||||
|
fontSize: 10 * fs * labelScale,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
color: SacredColors.onSurfaceVariant,
|
color: SacredColors.onSurfaceVariant,
|
||||||
letterSpacing: 3 * s,
|
letterSpacing: 3 * s,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
SizedBox(height: 4 * s),
|
SizedBox(height: 4 * s * bodyScale),
|
||||||
Text(
|
Text(
|
||||||
items[i].time,
|
items[i].time,
|
||||||
style: GoogleFonts.plusJakartaSans(
|
style: GoogleFonts.plusJakartaSans(
|
||||||
fontSize: 28 * fs,
|
// Keep hierarchy smaller than bottom prayer cards, but follow the same scale percentage.
|
||||||
|
fontSize: 28 * fs * bodyScale,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: SacredColors.onSurface,
|
color: SacredColors.onSurface,
|
||||||
),
|
),
|
||||||
@@ -569,7 +573,7 @@ class MainScreen extends ConsumerWidget {
|
|||||||
padding: EdgeInsets.symmetric(horizontal: 24 * s),
|
padding: EdgeInsets.symmetric(horizontal: 24 * s),
|
||||||
child: Container(
|
child: Container(
|
||||||
width: 1,
|
width: 1,
|
||||||
height: 40 * s,
|
height: 40 * s * bodyScale,
|
||||||
color: SacredColors.outlineVariant.withValues(alpha: 0.3)),
|
color: SacredColors.outlineVariant.withValues(alpha: 0.3)),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
import 'dart:math';
|
import 'dart:math';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
import '../../data/services/unsplash_service.dart';
|
import '../../data/services/unsplash_cache_service.dart';
|
||||||
import '../../providers.dart';
|
import '../../providers.dart';
|
||||||
|
|
||||||
/// Renders a securely rotating background using Unsplash API.
|
/// Renders a securely rotating background using Unsplash API.
|
||||||
@@ -16,7 +17,7 @@ class UnsplashBackground extends ConsumerStatefulWidget {
|
|||||||
|
|
||||||
class _UnsplashBackgroundState extends ConsumerState<UnsplashBackground> {
|
class _UnsplashBackgroundState extends ConsumerState<UnsplashBackground> {
|
||||||
final Random _rng = Random();
|
final Random _rng = Random();
|
||||||
List<String> _urls = [];
|
List<String> _imagePaths = [];
|
||||||
int _currentIndex = 0;
|
int _currentIndex = 0;
|
||||||
Timer? _rotationTimer;
|
Timer? _rotationTimer;
|
||||||
Timer? _keywordDebounceTimer;
|
Timer? _keywordDebounceTimer;
|
||||||
@@ -38,26 +39,46 @@ class _UnsplashBackgroundState extends ConsumerState<UnsplashBackground> {
|
|||||||
_lastUseUnsplash = settings.useUnsplashBackground;
|
_lastUseUnsplash = settings.useUnsplashBackground;
|
||||||
|
|
||||||
if (settings.useUnsplashBackground) {
|
if (settings.useUnsplashBackground) {
|
||||||
await _fetchImages(settings.unsplashKeyword, immediate: true);
|
await _hydrateAndRefresh(settings.unsplashKeyword, immediate: true);
|
||||||
}
|
}
|
||||||
_startTimer(settings.unsplashRotationHours);
|
_startTimer(settings.unsplashRotationHours);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _fetchImages(String keyword, {bool immediate = false}) async {
|
void _applyImagePaths(List<String> paths) {
|
||||||
|
final next = paths.where((path) => path.trim().isNotEmpty).toList();
|
||||||
|
if (next.isEmpty) return;
|
||||||
|
|
||||||
|
next.shuffle(_rng);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_imagePaths = next;
|
||||||
|
_currentIndex = _rng.nextInt(_imagePaths.length);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _hydrateAndRefresh(
|
||||||
|
String keyword, {
|
||||||
|
bool immediate = false,
|
||||||
|
}) async {
|
||||||
_keywordDebounceTimer?.cancel();
|
_keywordDebounceTimer?.cancel();
|
||||||
|
final requestId = ++_fetchNonce;
|
||||||
|
|
||||||
Future<void> runFetch() async {
|
Future<void> runFetch() async {
|
||||||
if (!ref.read(settingsProvider).useUnsplashBackground) return;
|
if (!ref.read(settingsProvider).useUnsplashBackground) return;
|
||||||
final requestId = ++_fetchNonce;
|
|
||||||
final urls = await UnsplashService.instance.fetchLandscapeBackgrounds(
|
final cachedPaths = await UnsplashCacheService.instance.getCachedImagePaths(
|
||||||
keyword,
|
keyword,
|
||||||
);
|
);
|
||||||
if (!mounted || requestId != _fetchNonce) return;
|
if (!mounted || requestId != _fetchNonce) return;
|
||||||
if (urls.isNotEmpty) {
|
if (cachedPaths.isNotEmpty) {
|
||||||
urls.shuffle(_rng);
|
_applyImagePaths(cachedPaths);
|
||||||
setState(() {
|
}
|
||||||
_urls = urls;
|
|
||||||
_currentIndex = _rng.nextInt(_urls.length);
|
final refreshedPaths = await UnsplashCacheService.instance
|
||||||
});
|
.refreshKeywordCache(keyword);
|
||||||
|
if (!mounted || requestId != _fetchNonce) return;
|
||||||
|
if (refreshedPaths.isNotEmpty) {
|
||||||
|
_applyImagePaths(refreshedPaths);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,11 +96,11 @@ class _UnsplashBackgroundState extends ConsumerState<UnsplashBackground> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _nextRandomImage() {
|
void _nextRandomImage() {
|
||||||
if (_urls.isNotEmpty && mounted) {
|
if (_imagePaths.isNotEmpty && mounted) {
|
||||||
if (_urls.length <= 1) return;
|
if (_imagePaths.length <= 1) return;
|
||||||
var nextIndex = _currentIndex;
|
var nextIndex = _currentIndex;
|
||||||
while (nextIndex == _currentIndex) {
|
while (nextIndex == _currentIndex) {
|
||||||
nextIndex = _rng.nextInt(_urls.length);
|
nextIndex = _rng.nextInt(_imagePaths.length);
|
||||||
}
|
}
|
||||||
setState(() => _currentIndex = nextIndex);
|
setState(() => _currentIndex = nextIndex);
|
||||||
}
|
}
|
||||||
@@ -108,15 +129,15 @@ class _UnsplashBackgroundState extends ConsumerState<UnsplashBackground> {
|
|||||||
// Watch for config changes
|
// Watch for config changes
|
||||||
if (settings.useUnsplashBackground != _lastUseUnsplash) {
|
if (settings.useUnsplashBackground != _lastUseUnsplash) {
|
||||||
_lastUseUnsplash = settings.useUnsplashBackground;
|
_lastUseUnsplash = settings.useUnsplashBackground;
|
||||||
if (settings.useUnsplashBackground && _urls.isEmpty) {
|
if (settings.useUnsplashBackground && _imagePaths.isEmpty) {
|
||||||
_fetchImages(settings.unsplashKeyword, immediate: true);
|
_hydrateAndRefresh(settings.unsplashKeyword, immediate: true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (settings.unsplashKeyword != _lastKeyword) {
|
if (settings.unsplashKeyword != _lastKeyword) {
|
||||||
_lastKeyword = settings.unsplashKeyword;
|
_lastKeyword = settings.unsplashKeyword;
|
||||||
if (settings.useUnsplashBackground) {
|
if (settings.useUnsplashBackground) {
|
||||||
_fetchImages(settings.unsplashKeyword);
|
_hydrateAndRefresh(settings.unsplashKeyword);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,16 +146,16 @@ class _UnsplashBackgroundState extends ConsumerState<UnsplashBackground> {
|
|||||||
_startTimer(settings.unsplashRotationHours);
|
_startTimer(settings.unsplashRotationHours);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!settings.useUnsplashBackground || _urls.isEmpty) {
|
if (!settings.useUnsplashBackground || _imagePaths.isEmpty) {
|
||||||
return const SizedBox.shrink(); // Fallback to flat background handled underneath
|
return const SizedBox.shrink(); // Fallback to flat background handled underneath
|
||||||
}
|
}
|
||||||
|
|
||||||
return AnimatedSwitcher(
|
return AnimatedSwitcher(
|
||||||
duration: const Duration(seconds: 3),
|
duration: const Duration(seconds: 3),
|
||||||
transitionBuilder: (child, animation) => FadeTransition(opacity: animation, child: child),
|
transitionBuilder: (child, animation) => FadeTransition(opacity: animation, child: child),
|
||||||
child: Image.network(
|
child: Image.file(
|
||||||
_urls[_currentIndex],
|
File(_imagePaths[_currentIndex]),
|
||||||
key: ValueKey(_urls[_currentIndex]),
|
key: ValueKey(_imagePaths[_currentIndex]),
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
height: double.infinity,
|
height: double.infinity,
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ Future<void> _bootstrapAndRun() async {
|
|||||||
Hive.registerAdapter(DailyPrayerScheduleAdapter());
|
Hive.registerAdapter(DailyPrayerScheduleAdapter());
|
||||||
await Hive.openBox<AppSettings>(HiveBoxes.settings);
|
await Hive.openBox<AppSettings>(HiveBoxes.settings);
|
||||||
await Hive.openBox<DailyPrayerSchedule>(HiveBoxes.prayerSchedule);
|
await Hive.openBox<DailyPrayerSchedule>(HiveBoxes.prayerSchedule);
|
||||||
|
await Hive.openBox<String>(HiveBoxes.hijriCache);
|
||||||
|
|
||||||
// Seed defaults if first launch
|
// Seed defaults if first launch
|
||||||
final settingsBox = Hive.box<AppSettings>(HiveBoxes.settings);
|
final settingsBox = Hive.box<AppSettings>(HiveBoxes.settings);
|
||||||
|
|||||||
@@ -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.12+13
|
version: 1.0.13+14
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: '>=3.0.0 <4.0.0'
|
sdk: '>=3.0.0 <4.0.0'
|
||||||
|
|||||||
Reference in New Issue
Block a user