feat(offline-first): persist hijri+unsplash cache and scale secondary times

This commit is contained in:
dwindown
2026-04-06 07:53:14 +07:00
parent 4062db77e4
commit 185c55a143
8 changed files with 371 additions and 41 deletions

View 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);
}
}