Files
Prisma/lib/features/settings/presentation/cache_manager_screen.dart
T

398 lines
14 KiB
Dart

import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:path_provider/path_provider.dart';
import '../../../app/app.dart';
import '../../../backend/backend.dart';
import '../../../core/utils/result.dart';
import '../../../shared/widgets/adaptive_scaffold.dart';
class CacheStats {
const CacheStats({
required this.cachedPostsCount,
required this.imageMemoryBytes,
required this.imageMemoryCount,
required this.tempDirectoryBytes,
required this.downloadedCount,
this.tagCacheCount = 0,
this.tagCacheBytes = 0,
});
final int cachedPostsCount;
final int imageMemoryBytes;
final int imageMemoryCount;
final int tempDirectoryBytes;
final int downloadedCount;
final int tagCacheCount;
final int tagCacheBytes;
int get totalEstimatedBytes =>
imageMemoryBytes +
tempDirectoryBytes +
tagCacheBytes +
(cachedPostsCount * 2048);
static const empty = CacheStats(
cachedPostsCount: 0,
imageMemoryBytes: 0,
imageMemoryCount: 0,
tempDirectoryBytes: 0,
downloadedCount: 0,
tagCacheCount: 0,
tagCacheBytes: 0,
);
}
final cacheStatsProvider = FutureProvider.autoDispose<CacheStats>((ref) async {
final cacheService = ref.watch(cacheServiceProvider);
final downloadedService = ref.watch(downloadedMediaServiceProvider);
final postsRes = await cacheService.getCachedPostsCount();
final postsCount = postsRes is Success<int> ? postsRes.data : 0;
final downloadedRes = await downloadedService.count();
final downloadedCount =
downloadedRes is Success<int> ? downloadedRes.data : 0;
final imageCache = PaintingBinding.instance.imageCache;
final memBytes = imageCache.currentSizeBytes;
final memCount = imageCache.currentSize;
var tempBytes = 0;
try {
final tempDir = await getTemporaryDirectory();
if (tempDir.existsSync()) {
await for (final file
in tempDir.list(recursive: true, followLinks: false)) {
if (file is File) {
tempBytes += await file.length();
}
}
}
} catch (_) {}
final tagCache = ref.watch(tagCacheServiceProvider);
if (!tagCache.isInitialized) await tagCache.init();
final tagCount = tagCache.tagCount;
final tagBytes = await tagCache.getFileSizeBytes();
return CacheStats(
cachedPostsCount: postsCount,
imageMemoryBytes: memBytes,
imageMemoryCount: memCount,
tempDirectoryBytes: tempBytes,
downloadedCount: downloadedCount,
tagCacheCount: tagCount,
tagCacheBytes: tagBytes,
);
});
class CacheManagerScreen extends ConsumerStatefulWidget {
const CacheManagerScreen({super.key});
@override
ConsumerState<CacheManagerScreen> createState() => _CacheManagerScreenState();
}
class _CacheManagerScreenState extends ConsumerState<CacheManagerScreen> {
bool _isClearing = false;
String _formatBytes(int bytes) {
if (bytes <= 0) return '0 B';
if (bytes < 1024) return '$bytes B';
if (bytes < 1024 * 1024) {
return '${(bytes / 1024).toStringAsFixed(1)} KB';
}
if (bytes < 1024 * 1024 * 1024) {
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
}
return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(2)} GB';
}
Future<void> _clearPostsCache() async {
setState(() => _isClearing = true);
await ref.read(cacheServiceProvider).clear();
ref.invalidate(cacheStatsProvider);
if (mounted) setState(() => _isClearing = false);
}
Future<void> _clearImagesCache() async {
setState(() => _isClearing = true);
PaintingBinding.instance.imageCache.clear();
PaintingBinding.instance.imageCache.clearLiveImages();
ref.invalidate(cacheStatsProvider);
if (mounted) setState(() => _isClearing = false);
}
Future<void> _clearTempFiles() async {
setState(() => _isClearing = true);
try {
final tempDir = await getTemporaryDirectory();
if (tempDir.existsSync()) {
final entities = tempDir.listSync(recursive: false);
for (final entity in entities) {
try {
entity.deleteSync(recursive: true);
} catch (_) {}
}
}
} catch (_) {}
ref.invalidate(cacheStatsProvider);
if (mounted) setState(() => _isClearing = false);
}
Future<void> _clearTagCache() async {
setState(() => _isClearing = true);
await ref.read(tagCacheServiceProvider).clear();
ref.invalidate(cacheStatsProvider);
if (mounted) setState(() => _isClearing = false);
}
Future<void> _clearAllCache() async {
setState(() => _isClearing = true);
await ref.read(cacheServiceProvider).clear();
await ref.read(tagCacheServiceProvider).clear();
PaintingBinding.instance.imageCache.clear();
PaintingBinding.instance.imageCache.clearLiveImages();
try {
final tempDir = await getTemporaryDirectory();
if (tempDir.existsSync()) {
final entities = tempDir.listSync(recursive: false);
for (final entity in entities) {
try {
entity.deleteSync(recursive: true);
} catch (_) {}
}
}
} catch (_) {}
ref.invalidate(cacheStatsProvider);
if (mounted) setState(() => _isClearing = false);
}
@override
Widget build(BuildContext context) {
final statsAsync = ref.watch(cacheStatsProvider);
final theme = Theme.of(context);
final strings = ref.watch(appStringsProvider);
return AdaptiveScaffold(
title: strings.ru ? 'Менеджер кэша' : 'Cache Manager',
actions: [
IconButton(
tooltip: strings.ru ? 'Обновить' : 'Refresh',
onPressed: () => ref.invalidate(cacheStatsProvider),
icon: const Icon(Icons.refresh_rounded),
),
],
body: statsAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(child: Text('Ошибка: $e')),
data: (stats) {
return ListView(
padding: const EdgeInsets.all(16),
children: [
// Summary card
Card(
color: theme.colorScheme.primaryContainer.withValues(alpha: 0.35),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(
color: theme.colorScheme.primary.withValues(alpha: 0.2),
),
),
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
children: [
Icon(
Icons.pie_chart_rounded,
size: 48,
color: theme.colorScheme.primary,
),
const SizedBox(height: 12),
Text(
_formatBytes(stats.totalEstimatedBytes),
style: theme.textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.bold,
color: theme.colorScheme.primary,
),
),
const SizedBox(height: 4),
Text(
strings.ru
? 'Общий предполагаемый объем кэша'
: 'Total estimated cache size',
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
),
),
const SizedBox(height: 16),
// Detail cards
_CacheCategoryTile(
icon: Icons.article_rounded,
title: strings.ru ? 'Кэш постов (база данных)' : 'Posts cache (DB)',
subtitle: strings.ru
? '${stats.cachedPostsCount} сохраненных постов'
: '${stats.cachedPostsCount} cached posts',
sizeText: _formatBytes(stats.cachedPostsCount * 2048),
onClear: _isClearing ? null : _clearPostsCache,
),
const SizedBox(height: 8),
_CacheCategoryTile(
icon: Icons.image_rounded,
title: strings.ru ? 'Кэш картинок в памяти' : 'Image memory cache',
subtitle: strings.ru
? '${stats.imageMemoryCount} картинок в ОЗУ'
: '${stats.imageMemoryCount} images in RAM',
sizeText: _formatBytes(stats.imageMemoryBytes),
onClear: _isClearing ? null : _clearImagesCache,
),
const SizedBox(height: 8),
_CacheCategoryTile(
icon: Icons.folder_open_rounded,
title: strings.ru ? 'Временные файлы и медиа' : 'Temp files & media',
subtitle: strings.ru
? 'Файлы для отправки и временные буферы'
: 'Share exports & temporary buffers',
sizeText: _formatBytes(stats.tempDirectoryBytes),
onClear: _isClearing ? null : _clearTempFiles,
),
const SizedBox(height: 8),
_CacheCategoryTile(
icon: Icons.label_rounded,
title: strings.ru ? 'Кэш подсказок тегов' : 'Tag suggestions cache',
subtitle: strings.ru
? '${stats.tagCacheCount} закэшированных тегов на диске'
: '${stats.tagCacheCount} cached tags on disk',
sizeText: _formatBytes(stats.tagCacheBytes),
onClear: _isClearing ? null : _clearTagCache,
),
const SizedBox(height: 8),
_CacheCategoryTile(
icon: Icons.download_done_rounded,
title: strings.ru ? 'Скачанные медиа' : 'Downloaded media',
subtitle: strings.ru
? '${stats.downloadedCount} файлов в истории скачиваний'
: '${stats.downloadedCount} files in download history',
sizeText: '',
showClear: false,
),
const SizedBox(height: 24),
// Global action button
FilledButton.tonalIcon(
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
onPressed: _isClearing ? null : _clearAllCache,
icon: _isClearing
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.delete_sweep_rounded),
label: Text(
strings.ru ? 'Очистить весь кэш' : 'Clear all cache',
style: const TextStyle(fontWeight: FontWeight.w600),
),
),
],
);
},
),
);
}
}
class _CacheCategoryTile extends StatelessWidget {
const _CacheCategoryTile({
required this.icon,
required this.title,
required this.subtitle,
required this.sizeText,
this.onClear,
this.showClear = true,
});
final IconData icon;
final String title;
final String subtitle;
final String sizeText;
final VoidCallback? onClear;
final bool showClear;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Card(
elevation: 0,
color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
CircleAvatar(
backgroundColor: theme.colorScheme.primaryContainer,
foregroundColor: theme.colorScheme.onPrimaryContainer,
child: Icon(icon, size: 22),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 2),
Text(
subtitle,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
),
if (sizeText.isNotEmpty) ...[
Text(
sizeText,
style: theme.textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.bold,
color: theme.colorScheme.primary,
),
),
const SizedBox(width: 8),
],
if (showClear && onClear != null)
IconButton(
tooltip: 'Очистить',
onPressed: onClear,
icon: const Icon(Icons.delete_outline_rounded, size: 20),
),
],
),
),
);
}
}