feat(offline-first): persist hijri+unsplash cache and scale secondary times
This commit is contained in:
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user