feat: release v2.0.5 with 10 QoL features (AMOLED, Dynamic Color, Grid switch, Share, Presets, Cache Manager, Backup & Gestures)
This commit is contained in:
@@ -0,0 +1,362 @@
|
||||
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,
|
||||
});
|
||||
|
||||
final int cachedPostsCount;
|
||||
final int imageMemoryBytes;
|
||||
final int imageMemoryCount;
|
||||
final int tempDirectoryBytes;
|
||||
final int downloadedCount;
|
||||
|
||||
int get totalEstimatedBytes =>
|
||||
imageMemoryBytes + tempDirectoryBytes + (cachedPostsCount * 2048);
|
||||
|
||||
static const empty = CacheStats(
|
||||
cachedPostsCount: 0,
|
||||
imageMemoryBytes: 0,
|
||||
imageMemoryCount: 0,
|
||||
tempDirectoryBytes: 0,
|
||||
downloadedCount: 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 (_) {}
|
||||
|
||||
return CacheStats(
|
||||
cachedPostsCount: postsCount,
|
||||
imageMemoryBytes: memBytes,
|
||||
imageMemoryCount: memCount,
|
||||
tempDirectoryBytes: tempBytes,
|
||||
downloadedCount: downloadedCount,
|
||||
);
|
||||
});
|
||||
|
||||
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> _clearAllCache() async {
|
||||
setState(() => _isClearing = true);
|
||||
await ref.read(cacheServiceProvider).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.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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import '../../../app/app.dart';
|
||||
import '../../../app/app_version.dart';
|
||||
import '../../../app/changelog.dart';
|
||||
import '../../../app/app_strings.dart';
|
||||
@@ -112,6 +113,99 @@ class _SettingsContent extends ConsumerWidget {
|
||||
settings.copyWith(autoDownloadFavorites: value),
|
||||
),
|
||||
),
|
||||
if (settings.allowDownloads) ...[
|
||||
const SizedBox(height: 8),
|
||||
DropdownButtonFormField<String>(
|
||||
initialValue: const [
|
||||
'{Artist}/{ID}',
|
||||
'{Artist}',
|
||||
'{Provider}/{Artist}',
|
||||
'{Service}/{ID}',
|
||||
'{Date}/{Artist}',
|
||||
].contains(settings.downloadPathTemplate)
|
||||
? settings.downloadPathTemplate
|
||||
: 'custom',
|
||||
decoration: InputDecoration(
|
||||
labelText: settings.languageCode == 'ru'
|
||||
? 'Структура папок скачивания'
|
||||
: 'Download folder structure',
|
||||
helperText: '{Artist}, {Provider}, {Service}, {ID}, {Date}',
|
||||
),
|
||||
items: [
|
||||
const DropdownMenuItem(
|
||||
value: '{Artist}/{ID}',
|
||||
child: Text('{Artist}/{ID}'),
|
||||
),
|
||||
const DropdownMenuItem(
|
||||
value: '{Artist}',
|
||||
child: Text('{Artist}'),
|
||||
),
|
||||
const DropdownMenuItem(
|
||||
value: '{Provider}/{Artist}',
|
||||
child: Text('{Provider}/{Artist}'),
|
||||
),
|
||||
const DropdownMenuItem(
|
||||
value: '{Service}/{ID}',
|
||||
child: Text('{Service}/{ID}'),
|
||||
),
|
||||
const DropdownMenuItem(
|
||||
value: '{Date}/{Artist}',
|
||||
child: Text('{Date}/{Artist}'),
|
||||
),
|
||||
if (![
|
||||
'{Artist}/{ID}',
|
||||
'{Artist}',
|
||||
'{Provider}/{Artist}',
|
||||
'{Service}/{ID}',
|
||||
'{Date}/{Artist}',
|
||||
].contains(settings.downloadPathTemplate))
|
||||
DropdownMenuItem(
|
||||
value: settings.downloadPathTemplate,
|
||||
child: Text(settings.downloadPathTemplate),
|
||||
),
|
||||
const DropdownMenuItem(
|
||||
value: 'custom',
|
||||
child: Text('Пользовательский шаблон...'),
|
||||
),
|
||||
],
|
||||
onChanged: (value) async {
|
||||
if (value == 'custom') {
|
||||
final ctrl = TextEditingController(
|
||||
text: settings.downloadPathTemplate);
|
||||
final custom = await showDialog<String>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('Шаблон пути скачивания'),
|
||||
content: TextField(
|
||||
controller: ctrl,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Шаблон',
|
||||
helperText:
|
||||
'Доступно: {Artist}, {Provider}, {Service}, {ID}, {Date}',
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
child: const Text('Отмена')),
|
||||
FilledButton(
|
||||
onPressed: () =>
|
||||
Navigator.pop(ctx, ctrl.text.trim()),
|
||||
child: const Text('Сохранить')),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (custom != null && custom.isNotEmpty) {
|
||||
_update(ref,
|
||||
settings.copyWith(downloadPathTemplate: custom));
|
||||
}
|
||||
} else if (value != null) {
|
||||
_update(ref,
|
||||
settings.copyWith(downloadPathTemplate: value));
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
_SettingsSection(
|
||||
@@ -130,6 +224,33 @@ class _SettingsContent extends ConsumerWidget {
|
||||
settings.copyWith(languageCode: value ?? 'ru'),
|
||||
),
|
||||
),
|
||||
SwitchListTile(
|
||||
value: settings.amoledMode,
|
||||
title: Text(settings.languageCode == 'ru'
|
||||
? 'AMOLED Pure Black тема'
|
||||
: 'AMOLED Pure Black theme'),
|
||||
subtitle: Text(settings.languageCode == 'ru'
|
||||
? 'Абсолютно черный фон (#000000) для OLED дисплеев.'
|
||||
: 'Pitch-black background (#000000) for OLED displays.'),
|
||||
onChanged: (value) => _update(
|
||||
ref,
|
||||
settings.copyWith(amoledMode: value),
|
||||
),
|
||||
),
|
||||
if (isAndroid)
|
||||
SwitchListTile(
|
||||
value: settings.useDynamicColor,
|
||||
title: Text(settings.languageCode == 'ru'
|
||||
? 'Динамические цвета Material You'
|
||||
: 'Material You Dynamic Colors'),
|
||||
subtitle: Text(settings.languageCode == 'ru'
|
||||
? 'Палитра интерфейса подстраивается под обои системы.'
|
||||
: 'Palette adapts to device wallpaper colors.'),
|
||||
onChanged: (value) => _update(
|
||||
ref,
|
||||
settings.copyWith(useDynamicColor: value),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_ColorSwatches(
|
||||
selected: settings.appSeedColor,
|
||||
@@ -280,6 +401,13 @@ class _SettingsContent extends ConsumerWidget {
|
||||
icon: const Icon(Icons.cleaning_services_rounded),
|
||||
label: Text(strings.clearCache),
|
||||
),
|
||||
FilledButton.tonalIcon(
|
||||
onPressed: () => context.go('/settings/cache'),
|
||||
icon: const Icon(Icons.pie_chart_rounded),
|
||||
label: Text(settings.languageCode == 'ru'
|
||||
? 'Менеджер кэша'
|
||||
: 'Cache Manager'),
|
||||
),
|
||||
FilledButton.tonalIcon(
|
||||
onPressed: () => ref
|
||||
.read(settingsControllerProvider.notifier)
|
||||
@@ -369,6 +497,20 @@ class _SettingsContent extends ConsumerWidget {
|
||||
icon: const Icon(Icons.download_for_offline_rounded),
|
||||
label: Text(strings.importJson),
|
||||
),
|
||||
FilledButton.tonalIcon(
|
||||
onPressed: () => _exportBackupFile(context, ref),
|
||||
icon: const Icon(Icons.file_upload_rounded),
|
||||
label: Text(settings.languageCode == 'ru'
|
||||
? 'Экспорт бэкапа'
|
||||
: 'Export backup'),
|
||||
),
|
||||
FilledButton.tonalIcon(
|
||||
onPressed: () => _importBackupFile(context, ref),
|
||||
icon: const Icon(Icons.file_download_rounded),
|
||||
label: Text(settings.languageCode == 'ru'
|
||||
? 'Импорт бэкапа'
|
||||
: 'Import backup'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
@@ -413,6 +555,33 @@ class _SettingsContent extends ConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _exportBackupFile(BuildContext context, WidgetRef ref) async {
|
||||
final success = await ref.read(backupServiceProvider).exportBackup();
|
||||
if (!context.mounted) return;
|
||||
if (success) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Бэкап сохранен / отправлен')),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _importBackupFile(BuildContext context, WidgetRef ref) async {
|
||||
final result = await ref.read(backupServiceProvider).importBackup();
|
||||
if (!context.mounted) return;
|
||||
if (result == null) return;
|
||||
if (result is Success<AppSettings>) {
|
||||
ref.invalidate(appSettingsProvider);
|
||||
ref.invalidate(settingsControllerProvider);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Настройки успешно импортированы!')),
|
||||
);
|
||||
} else if (result is Error<AppSettings>) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Ошибка импорта: ${result.failure.message}')),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _importDialog(BuildContext context, WidgetRef ref) async {
|
||||
final controller = TextEditingController();
|
||||
await showDialog<void>(
|
||||
|
||||
Reference in New Issue
Block a user