import 'dart:io'; import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:path_provider/path_provider.dart'; import 'package:path/path.dart' as path; import 'package:http/http.dart' as http; import 'package:cached_network_image/cached_network_image.dart'; import 'package:es_compression/lz4.dart'; class ImageCacheService { ImageCacheService._privateConstructor(); static final ImageCacheService instance = ImageCacheService._privateConstructor(); static const String _cacheDirectoryName = 'image_cache'; static const Duration _cacheExpiration = Duration( days: 7, ); // Кеш изображений на 7 дней late Directory _cacheDirectory; // LZ4 сжатие для экономии места (может быть null если библиотека недоступна) Lz4Codec? _lz4Codec; bool _lz4Available = false; Future initialize() async { final appDir = await getApplicationDocumentsDirectory(); _cacheDirectory = Directory(path.join(appDir.path, _cacheDirectoryName)); if (!_cacheDirectory.existsSync()) { await _cacheDirectory.create(recursive: true); } // Пытаемся инициализировать LZ4, если не получится - используем обычное кэширование try { _lz4Codec = Lz4Codec(); _lz4Available = true; print('✅ LZ4 compression доступна'); } catch (e) { _lz4Codec = null; _lz4Available = false; print('⚠️ LZ4 compression недоступна, используется обычное кэширование: $e'); } await _cleanupExpiredCache(); } String getCachedImagePath(String url) { final fileName = _generateFileName(url); return path.join(_cacheDirectory.path, fileName); } bool isImageCached(String url) { final file = File(getCachedImagePath(url)); return file.existsSync(); } Future loadImage(String url, {bool forceRefresh = false}) async { if (!forceRefresh && isImageCached(url)) { final cachedFile = File(getCachedImagePath(url)); if (await _isFileValid(cachedFile)) { return cachedFile; } else { await cachedFile.delete(); } } try { final response = await http.get(Uri.parse(url)); if (response.statusCode == 200) { final file = File(getCachedImagePath(url)); // Сжимаем данные перед сохранением, если LZ4 доступна if (_lz4Available && _lz4Codec != null) { try { final compressedData = _lz4Codec!.encode(response.bodyBytes); await file.writeAsBytes(compressedData); } catch (e) { // Если сжатие не удалось, сохраняем без сжатия print('⚠️ Ошибка сжатия изображения $url, сохраняем без сжатия: $e'); await file.writeAsBytes(response.bodyBytes); } } else { // LZ4 недоступна, сохраняем без сжатия await file.writeAsBytes(response.bodyBytes); } await _updateFileAccessTime(file); return file; } } catch (e) { print('Ошибка загрузки изображения $url: $e'); } return null; } Future loadImageAsBytes( String url, { bool forceRefresh = false, }) async { final file = await loadImage(url, forceRefresh: forceRefresh); if (file != null) { final fileData = await file.readAsBytes(); // Пытаемся декомпрессировать, если LZ4 доступна if (_lz4Available && _lz4Codec != null) { try { final decompressedData = _lz4Codec!.decode(fileData); return Uint8List.fromList(decompressedData); } catch (e) { // Если декомпрессия не удалась, возможно файл не сжат (старый формат или LZ4 недоступна) print( '⚠️ Ошибка декомпрессии изображения $url, пробуем прочитать как обычный файл: $e', ); return fileData; } } else { // LZ4 недоступна, возвращаем данные как есть return fileData; } } return null; } Future preloadImage(String url) async { await loadImage(url); } Future preloadContactAvatar(String? photoUrl) async { if (photoUrl != null && photoUrl.isNotEmpty) { await preloadImage(photoUrl); } } Future preloadProfileAvatar(String? photoUrl) async { if (photoUrl != null && photoUrl.isNotEmpty) { await preloadImage(photoUrl); } } Future preloadContactAvatars(List photoUrls) async { final futures = photoUrls .where((url) => url != null && url.isNotEmpty) .map((url) => preloadImage(url!)) .toList(); if (futures.isNotEmpty) { await Future.wait(futures); } } Future clearCache() async { if (_cacheDirectory.existsSync()) { await _clearDirectoryContents(_cacheDirectory); } } Future _clearDirectoryContents(Directory directory) async { try { // Очищаем содержимое директории, удаляя файлы по одному await for (final entity in directory.list(recursive: true)) { if (entity is File) { try { await entity.delete(); // Небольшая задержка между удалениями для избежания конфликтов await Future.delayed(const Duration(milliseconds: 5)); } catch (fileError) { // Игнорируем ошибки удаления отдельных файлов print('Не удалось удалить файл ${entity.path}: $fileError'); } } else if (entity is Directory) { try { // Рекурсивно очищаем поддиректории await _clearDirectoryContents(entity); try { await entity.delete(); } catch (dirError) { print( 'Не удалось удалить поддиректорию ${entity.path}: $dirError', ); } } catch (subDirError) { print('Ошибка очистки поддиректории ${entity.path}: $subDirError'); } } } print('Содержимое директории ${directory.path} очищено'); } catch (e) { print('Ошибка очистки содержимого директории ${directory.path}: $e'); } } Future getCacheSize() async { int totalSize = 0; if (_cacheDirectory.existsSync()) { await for (final entity in _cacheDirectory.list(recursive: true)) { if (entity is File) { totalSize += await entity.length(); } } } return totalSize; } Future getCacheFileCount() async { int count = 0; if (_cacheDirectory.existsSync()) { await for (final entity in _cacheDirectory.list(recursive: true)) { if (entity is File) { count++; } } } return count; } Future _cleanupExpiredCache() async { if (!_cacheDirectory.existsSync()) return; await for (final entity in _cacheDirectory.list(recursive: true)) { if (entity is File && await _isFileExpired(entity)) { await entity.delete(); } } } Future _isFileValid(File file) async { if (!file.existsSync()) return false; final stat = await file.stat(); final age = DateTime.now().difference(stat.modified); return age < _cacheExpiration; } Future _isFileExpired(File file) async { if (!file.existsSync()) return false; final stat = await file.stat(); final age = DateTime.now().difference(stat.modified); return age >= _cacheExpiration; } Future _updateFileAccessTime(File file) async { try { await file.setLastModified(DateTime.now()); } catch (e) {} } String _generateFileName(String url) { final hash = url.hashCode.abs().toString(); final extension = path.extension(url).isNotEmpty ? path.extension(url) : '.jpg'; // По умолчанию jpg return '$hash$extension'; } Future> getCacheStats() async { final size = await getCacheSize(); final fileCount = await getCacheFileCount(); return { 'cache_size_bytes': size, 'cache_size_mb': (size / (1024 * 1024)).toStringAsFixed(2), 'file_count': fileCount, 'cache_directory': _cacheDirectory.path, 'compression_enabled': _lz4Available, 'compression_algorithm': _lz4Available ? 'LZ4' : 'none', }; } } extension CachedImageExtension on String { Widget getCachedNetworkImage({ Key? key, double? width, double? height, BoxFit? fit, Widget? placeholder, Widget? errorWidget, Duration? fadeInDuration, bool useMemoryCache = true, }) { return CachedNetworkImage( key: key, imageUrl: this, width: width, height: height, fit: fit, placeholder: (context, url) => placeholder ?? Container( width: width, height: height, color: Colors.grey[300], child: const Icon(Icons.image, color: Colors.grey), ), errorWidget: (context, url, error) => errorWidget ?? Container( width: width, height: height, color: Colors.grey[300], child: const Icon(Icons.broken_image, color: Colors.grey), ), fadeInDuration: fadeInDuration ?? const Duration(milliseconds: 300), useOldImageOnUrlChange: true, memCacheWidth: useMemoryCache ? (width ?? 200).toInt() : null, memCacheHeight: useMemoryCache ? (height ?? 200).toInt() : null, ); } Future preloadImage() async { await ImageCacheService.instance.loadImage(this); } }