diff --git a/lib/data/local/models.dart b/lib/data/local/models.dart index a0886af..9dc3020 100644 --- a/lib/data/local/models.dart +++ b/lib/data/local/models.dart @@ -5,6 +5,7 @@ class HiveBoxes { HiveBoxes._(); static const String settings = 'app_settings'; static const String prayerSchedule = 'prayer_schedule'; + static const String hijriCache = 'hijri_cache'; } /// AppSettings stored in Hive. diff --git a/lib/data/services/hijri_service.dart b/lib/data/services/hijri_service.dart index ed81802..445ea78 100644 --- a/lib/data/services/hijri_service.dart +++ b/lib/data/services/hijri_service.dart @@ -1,9 +1,12 @@ import 'dart:convert'; +import 'dart:math'; +import 'package:hive_flutter/hive_flutter.dart'; import 'package:http/http.dart' as http; import 'package:intl/intl.dart'; import '../../core/hijri_date.dart'; +import '../local/models.dart'; class HijriCalendarService { HijriCalendarService._({http.Client? client}) @@ -11,35 +14,127 @@ class HijriCalendarService { static final HijriCalendarService instance = HijriCalendarService._(); 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 Map _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? _diskCacheBoxOrNull() { + if (!Hive.isBoxOpen(HiveBoxes.hijriCache)) return null; + return Hive.box(HiveBoxes.hijriCache); + } + + String? _readDiskCache(String dateKey) => _diskCacheBoxOrNull()?.get(dateKey); + + Future _writeDiskCache(String dateKey, String label) async { + final box = _diskCacheBoxOrNull(); + if (box == null) return; + await box.put(dateKey, label); + } + + Future _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; + return parseHijriLabel(payload); + } catch (_) { + return null; + } + } Future getHijriLabel(DateTime date) async { - final dateOnly = DateTime(date.year, date.month, date.day); - final dateKey = DateFormat('yyyy-MM-dd').format(dateOnly); + final dateOnly = _dateOnly(date); + final dateKey = _dateKeyFromDate(dateOnly); final cached = _cache[dateKey]; if (cached != null) return cached; - try { - final response = await _client.get(Uri.parse('$_baseUrl/$dateKey')); - if (response.statusCode == 200) { - final payload = json.decode(response.body) as Map; - final label = parseHijriLabel(payload); - if (label != null) { - _cache[dateKey] = label; - return label; - } - } - } catch (_) { - // Keep UI usable when the device is offline. + final diskCached = _readDiskCache(dateKey); + if (diskCached != null && diskCached.trim().isNotEmpty) { + _cache[dateKey] = diskCached; + return diskCached; } + 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); _cache[dateKey] = 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 warmCacheForDateKeys( + Iterable rawDateKeys, { + int maxConcurrentRequests = 4, + }) async { + final uniqueKeys = {}; + for (final rawKey in rawDateKeys) { + final normalized = _normalizeDateKey(rawKey); + if (normalized != null) uniqueKeys.add(normalized); + } + if (uniqueKeys.isEmpty) return; + + final pendingKeys = []; + 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 payload) { if (payload['status'] != true) return null; diff --git a/lib/data/services/sync_service.dart b/lib/data/services/sync_service.dart index 17975f8..e0a8916 100644 --- a/lib/data/services/sync_service.dart +++ b/lib/data/services/sync_service.dart @@ -1,7 +1,10 @@ +import 'dart:async'; + import 'package:hive_flutter/hive_flutter.dart'; import 'package:intl/intl.dart'; import '../local/models.dart'; +import 'hijri_service.dart'; import 'myquran_service.dart'; class ScheduleCacheStatus { @@ -196,6 +199,71 @@ class SyncService { } } + Future _pruneHijriCache(DateTime referenceDate) async { + if (!Hive.isBoxOpen(HiveBoxes.hijriCache)) return; + final hijriBox = Hive.box(HiveBoxes.hijriCache); + final staleKeys = staleScheduleKeys( + hijriBox.keys.cast(), + referenceDate, + ); + if (staleKeys.isNotEmpty) { + await hijriBox.deleteAll(staleKeys); + await hijriBox.compact(); + } + } + + Set _priorityHijriWarmupKeys( + Set allDateKeys, + DateTime referenceDate, + ) { + final prioritized = {}; + 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 _collectRollingWindowScheduleDateKeys( + Box scheduleBox, + DateTime referenceDate, + ) { + final allowedMonths = rollingWindowMonths(referenceDate); + final keys = {}; + + 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 _warmHijriCacheForScheduleRange( + Box 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({ required ScheduleCacheStatus status, required bool hasTodayData, @@ -288,9 +356,11 @@ class SyncService { if (success) { if (hasCurrentMonth && hasNextMonth) { await _pruneScheduleCache(scheduleBox, now); + await _pruneHijriCache(now); } settings.lastSyncDate = DateFormat('yyyy-MM-dd HH:mm').format(now); await settings.save(); + await _warmHijriCacheForScheduleRange(scheduleBox, now); } return success; @@ -306,16 +376,20 @@ class SyncService { } final now = referenceDate ?? DateTime.now(); + final scheduleBox = + Hive.box(HiveBoxes.prayerSchedule); final status = getCacheStatus(now); final hasTodayData = getTodaySchedule(now) != null; if (!_shouldAttemptAutoRefresh(status: status, hasTodayData: hasTodayData)) { + unawaited(_warmHijriCacheForScheduleRange(scheduleBox, now)); return const AutoRefreshResult.skipped('cache-fresh'); } final lastAttempt = _parseAttemptTimestamp(settings.lastAutoSyncAttemptDate); if (lastAttempt != null && now.difference(lastAttempt) < _autoRefreshCooldown) { + unawaited(_warmHijriCacheForScheduleRange(scheduleBox, now)); return const AutoRefreshResult.skipped('cooldown'); } @@ -323,6 +397,9 @@ class SyncService { await settings.save(); final synced = await syncMonthlyData(referenceDate: now); + if (!synced) { + unawaited(_warmHijriCacheForScheduleRange(scheduleBox, now)); + } return synced ? const AutoRefreshResult.synced() : const AutoRefreshResult.failed('sync-failed'); diff --git a/lib/data/services/unsplash_cache_service.dart b/lib/data/services/unsplash_cache_service.dart new file mode 100644 index 0000000..c3a401f --- /dev/null +++ b/lib/data/services/unsplash_cache_service.dart @@ -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 _cacheRootDirectory() async { + final baseDir = await getApplicationSupportDirectory(); + final cacheDir = Directory('${baseDir.path}/unsplash_cache'); + if (!cacheDir.existsSync()) { + await cacheDir.create(recursive: true); + } + return cacheDir; + } + + Future _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> _listCachedFiles(String keyword) async { + final dir = await _keywordDirectory(keyword); + final entities = await dir.list().toList(); + final files = entities.whereType().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 _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 _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> 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> 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.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); + } +} diff --git a/lib/features/home/main_screen.dart b/lib/features/home/main_screen.dart index 6df3f01..348a730 100644 --- a/lib/features/home/main_screen.dart +++ b/lib/features/home/main_screen.dart @@ -537,6 +537,8 @@ class MainScreen extends ConsumerWidget { items.add(_SecondaryTimeItem('Terbit', schedule.terbit)); } items.add(_SecondaryTimeItem('Dhuha', schedule.dhuha)); + final labelScale = settings.scaleCardLabel; + final bodyScale = settings.scaleCardBody; return Row( mainAxisSize: MainAxisSize.min, @@ -547,17 +549,19 @@ class MainScreen extends ConsumerWidget { Text( items[i].label.toUpperCase(), 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, color: SacredColors.onSurfaceVariant, letterSpacing: 3 * s, ), ), - SizedBox(height: 4 * s), + SizedBox(height: 4 * s * bodyScale), Text( items[i].time, 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, color: SacredColors.onSurface, ), @@ -569,7 +573,7 @@ class MainScreen extends ConsumerWidget { padding: EdgeInsets.symmetric(horizontal: 24 * s), child: Container( width: 1, - height: 40 * s, + height: 40 * s * bodyScale, color: SacredColors.outlineVariant.withValues(alpha: 0.3)), ), ], diff --git a/lib/features/home/unsplash_background.dart b/lib/features/home/unsplash_background.dart index 90e1654..3e9034e 100644 --- a/lib/features/home/unsplash_background.dart +++ b/lib/features/home/unsplash_background.dart @@ -1,9 +1,10 @@ import 'dart:async'; +import 'dart:io'; import 'dart:math'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import '../../data/services/unsplash_service.dart'; +import '../../data/services/unsplash_cache_service.dart'; import '../../providers.dart'; /// Renders a securely rotating background using Unsplash API. @@ -16,7 +17,7 @@ class UnsplashBackground extends ConsumerStatefulWidget { class _UnsplashBackgroundState extends ConsumerState { final Random _rng = Random(); - List _urls = []; + List _imagePaths = []; int _currentIndex = 0; Timer? _rotationTimer; Timer? _keywordDebounceTimer; @@ -38,26 +39,46 @@ class _UnsplashBackgroundState extends ConsumerState { _lastUseUnsplash = settings.useUnsplashBackground; if (settings.useUnsplashBackground) { - await _fetchImages(settings.unsplashKeyword, immediate: true); + await _hydrateAndRefresh(settings.unsplashKeyword, immediate: true); } _startTimer(settings.unsplashRotationHours); } - Future _fetchImages(String keyword, {bool immediate = false}) async { + void _applyImagePaths(List 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 _hydrateAndRefresh( + String keyword, { + bool immediate = false, + }) async { _keywordDebounceTimer?.cancel(); + final requestId = ++_fetchNonce; + Future runFetch() async { if (!ref.read(settingsProvider).useUnsplashBackground) return; - final requestId = ++_fetchNonce; - final urls = await UnsplashService.instance.fetchLandscapeBackgrounds( + + final cachedPaths = await UnsplashCacheService.instance.getCachedImagePaths( keyword, ); if (!mounted || requestId != _fetchNonce) return; - if (urls.isNotEmpty) { - urls.shuffle(_rng); - setState(() { - _urls = urls; - _currentIndex = _rng.nextInt(_urls.length); - }); + if (cachedPaths.isNotEmpty) { + _applyImagePaths(cachedPaths); + } + + 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 { } void _nextRandomImage() { - if (_urls.isNotEmpty && mounted) { - if (_urls.length <= 1) return; + if (_imagePaths.isNotEmpty && mounted) { + if (_imagePaths.length <= 1) return; var nextIndex = _currentIndex; while (nextIndex == _currentIndex) { - nextIndex = _rng.nextInt(_urls.length); + nextIndex = _rng.nextInt(_imagePaths.length); } setState(() => _currentIndex = nextIndex); } @@ -108,15 +129,15 @@ class _UnsplashBackgroundState extends ConsumerState { // Watch for config changes if (settings.useUnsplashBackground != _lastUseUnsplash) { _lastUseUnsplash = settings.useUnsplashBackground; - if (settings.useUnsplashBackground && _urls.isEmpty) { - _fetchImages(settings.unsplashKeyword, immediate: true); + if (settings.useUnsplashBackground && _imagePaths.isEmpty) { + _hydrateAndRefresh(settings.unsplashKeyword, immediate: true); } } if (settings.unsplashKeyword != _lastKeyword) { _lastKeyword = settings.unsplashKeyword; if (settings.useUnsplashBackground) { - _fetchImages(settings.unsplashKeyword); + _hydrateAndRefresh(settings.unsplashKeyword); } } @@ -125,16 +146,16 @@ class _UnsplashBackgroundState extends ConsumerState { _startTimer(settings.unsplashRotationHours); } - if (!settings.useUnsplashBackground || _urls.isEmpty) { + if (!settings.useUnsplashBackground || _imagePaths.isEmpty) { return const SizedBox.shrink(); // Fallback to flat background handled underneath } return AnimatedSwitcher( duration: const Duration(seconds: 3), transitionBuilder: (child, animation) => FadeTransition(opacity: animation, child: child), - child: Image.network( - _urls[_currentIndex], - key: ValueKey(_urls[_currentIndex]), + child: Image.file( + File(_imagePaths[_currentIndex]), + key: ValueKey(_imagePaths[_currentIndex]), fit: BoxFit.cover, width: double.infinity, height: double.infinity, diff --git a/lib/main.dart b/lib/main.dart index 3a866a1..afc8125 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -69,6 +69,7 @@ Future _bootstrapAndRun() async { Hive.registerAdapter(DailyPrayerScheduleAdapter()); await Hive.openBox(HiveBoxes.settings); await Hive.openBox(HiveBoxes.prayerSchedule); + await Hive.openBox(HiveBoxes.hijriCache); // Seed defaults if first launch final settingsBox = Hive.box(HiveBoxes.settings); diff --git a/pubspec.yaml b/pubspec.yaml index bff7e8c..04f691b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: jamshalat_masjid_screen description: Smart Digital Prayer Clock for Android TV Box publish_to: 'none' -version: 1.0.12+13 +version: 1.0.13+14 environment: sdk: '>=3.0.0 <4.0.0'