feat: add persistent cover image cache

- Add CoverCacheManager service for persistent image caching
- Cache stored in app_flutter/cover_cache/ (not cleared by system)
- Maximum 1000 images cached for up to 365 days
- Update all 11 screens to use persistent cache manager
- Add flutter_cache_manager and path dependencies
- Update CHANGELOG.md with all changes for v3.1.3
This commit is contained in:
zarzet
2026-01-19 22:55:53 +07:00
parent 0119db094d
commit 77e4457244
21 changed files with 650 additions and 221 deletions
+114
View File
@@ -0,0 +1,114 @@
import 'dart:io';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:path_provider/path_provider.dart';
import 'package:path/path.dart' as p;
/// Persistent cache manager for album/track cover images.
///
/// Unlike the default cache manager which stores in temp directory
/// (can be cleared by system anytime), this stores in app support
/// directory which persists across app restarts.
class CoverCacheManager {
static const String _cacheKey = 'coverImageCache';
static const int _maxCacheObjects = 1000;
static const Duration _maxCacheAge = Duration(days: 365);
static CacheManager? _instance;
static bool _initialized = false;
/// Get the singleton cache manager instance.
/// Must call [initialize] before using this.
static CacheManager get instance {
if (!_initialized || _instance == null) {
throw StateError(
'CoverCacheManager not initialized. Call CoverCacheManager.initialize() first.',
);
}
return _instance!;
}
/// Check if cache manager is initialized
static bool get isInitialized => _initialized && _instance != null;
/// Initialize the cache manager with persistent storage path.
/// Call this once during app startup (in main.dart).
static Future<void> initialize() async {
if (_initialized) return;
final appDir = await getApplicationSupportDirectory();
final cachePath = p.join(appDir.path, 'cover_cache');
// Ensure cache directory exists
await Directory(cachePath).create(recursive: true);
_instance = CacheManager(
Config(
_cacheKey,
stalePeriod: _maxCacheAge,
maxNrOfCacheObjects: _maxCacheObjects,
repo: JsonCacheInfoRepository(databaseName: _cacheKey),
fileSystem: IOFileSystem(cachePath),
fileService: HttpFileService(),
),
);
_initialized = true;
}
/// Clear all cached cover images.
/// Returns the number of files deleted.
static Future<void> clearCache() async {
if (!_initialized || _instance == null) return;
await _instance!.emptyCache();
}
/// Get cache statistics
static Future<CacheStats> getStats() async {
if (!_initialized) {
return const CacheStats(fileCount: 0, totalSizeBytes: 0);
}
final appDir = await getApplicationSupportDirectory();
final cacheDir = Directory(p.join(appDir.path, 'cover_cache'));
if (!await cacheDir.exists()) {
return const CacheStats(fileCount: 0, totalSizeBytes: 0);
}
int fileCount = 0;
int totalSize = 0;
await for (final entity in cacheDir.list(recursive: true)) {
if (entity is File) {
fileCount++;
totalSize += await entity.length();
}
}
return CacheStats(fileCount: fileCount, totalSizeBytes: totalSize);
}
}
/// Statistics about the cover image cache
class CacheStats {
final int fileCount;
final int totalSizeBytes;
const CacheStats({
required this.fileCount,
required this.totalSizeBytes,
});
/// Get human-readable size string
String get formattedSize {
if (totalSizeBytes < 1024) {
return '$totalSizeBytes B';
} else if (totalSizeBytes < 1024 * 1024) {
return '${(totalSizeBytes / 1024).toStringAsFixed(1)} KB';
} else if (totalSizeBytes < 1024 * 1024 * 1024) {
return '${(totalSizeBytes / (1024 * 1024)).toStringAsFixed(1)} MB';
} else {
return '${(totalSizeBytes / (1024 * 1024 * 1024)).toStringAsFixed(2)} GB';
}
}
}