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