diff --git a/README.md b/README.md
index df125b8..3d3c0d5 100644
--- a/README.md
+++ b/README.md
@@ -10,7 +10,7 @@
-
+
@@ -20,10 +20,17 @@
## ✨ Основные возможности
-- **📱 Плавная лента в стиле Pinterest**: Masonry-сетка с адаптивным количеством колонок для мобильных и ПК.
+- **📱 Плавная лента в стиле Pinterest**: Masonry-сетка с переключением режима (Masonry, 1:1 Сетка, Список) в шапке ленты.
- **⚡ Сверхбыстрые видео-превью**: Мгновенная загрузка превью без фризов и лишней нагрузки на процессор и память смартфона.
- **↔️ Листание постов влево и вправо**: Полноэкранный пейджер с плавными свайпами между фото и видео во всех разделах (Лента, Авторы, Избранное, Коллекции, История просмотров).
-- **🖼️ Мульти-медиа в одном посте**: Удобная карусель с индикатором `1 / 4` для постов с несколькими файлами.
+- **❤️ Двойной тап для лайка и свайп вниз для закрытия**: Анимированное сердечко по двойному тапу и закрытие экрана просмотра свайпом вниз.
+- **📤 Прямая отправка файла (Share)**: Мгновенный экспорт оригинальных медиафайлов через системное меню «Поделиться».
+- **🔖 Сохраненные поисковые пресеты**: Быстрое сохранение комбинаций тегов и фильтров в закладки с мгновенным переключением в один клик.
+- **📁 Умная структура папок скачивания**: Настраиваемые шаблоны поддиректорий (`{Artist}`, `{Provider}`, `{Service}`, `{ID}`, `{Date}`).
+- **🗑️ Менеджер кэша**: Детальная визуализация занятой памяти (база данных, ОЗУ, временные файлы) и выборочная очистка.
+- **📦 Бэкап и синхронизация**: Экспорт и импорт полной конфигурации в JSON-файл для переноса на другие устройства.
+- **🌑 AMOLED Pure Black тема**: Истинно черный фон (`#000000`) для экономии батареи на OLED дисплеях.
+- **🎨 Динамические цвета Material You**: Поддержка системной палитры Android из обоев устройства.
- **⭐ Любимые авторы**: Компактная кнопка-звёздочка сверху справа для быстрого доступа ко всем любимым авторам и их свежему медиапотоку.
- **🏷️ Мультивыбор платформ**: Быстрый выбор одной или нескольких платформ авторов (Patreon, Fanbox, Fantia, Boosty, Discord).
- **🎬 Фильтр фото, видео и GIF у автора**: Удобные чипсы с мультивыбором типа контента и количеством найденных медиа.
@@ -55,9 +62,9 @@
🔗 **[Gitea Releases (RarDog/Lunaris)](https://gitea.rardogsynapse.online/RarDog/Lunaris/releases)**
-- **Android**: `Lunaris-v2.0.4.apk`
-- **Linux (x64)**: `Lunaris-v2.0.4-linux-x64.tar.gz`
-- **Windows**: `LunarisSetup-v2.0.4.exe` / `LunarisPortable-v2.0.4.zip`
+- **Android**: `Lunaris-v2.0.5.apk`
+- **Linux (x64)**: `Lunaris-v2.0.5-linux-x64.tar.gz`
+- **Windows**: `LunarisSetup-v2.0.5.exe` / `LunarisPortable-v2.0.5.zip`
---
diff --git a/android/app/src/main/kotlin/com/example/gel_rule_app/MainActivity.kt b/android/app/src/main/kotlin/com/example/gel_rule_app/MainActivity.kt
index 8b86357..d9209a5 100644
--- a/android/app/src/main/kotlin/com/example/gel_rule_app/MainActivity.kt
+++ b/android/app/src/main/kotlin/com/example/gel_rule_app/MainActivity.kt
@@ -26,7 +26,8 @@ class MainActivity : FlutterActivity() {
val path = call.argument("path") ?: error("Missing path")
val fileName = call.argument("fileName") ?: File(path).name
val mimeType = call.argument("mimeType") ?: "application/octet-stream"
- result.success(saveToDownloads(path, fileName, mimeType))
+ val subDir = call.argument("subDir")
+ result.success(saveToDownloads(path, fileName, mimeType, subDir))
}
"openFile" -> {
val path = call.argument("path") ?: error("Missing path")
@@ -61,13 +62,19 @@ class MainActivity : FlutterActivity() {
}
}
- private fun saveToDownloads(path: String, fileName: String, mimeType: String): String {
+ private fun saveToDownloads(path: String, fileName: String, mimeType: String, subDir: String? = null): String {
val source = File(path)
+ val relPath = if (!subDir.isNullOrBlank()) {
+ val clean = subDir.trim().removePrefix("/").removeSuffix("/")
+ "${Environment.DIRECTORY_DOWNLOADS}/$clean"
+ } else {
+ Environment.DIRECTORY_DOWNLOADS
+ }
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val values = ContentValues().apply {
put(MediaStore.Downloads.DISPLAY_NAME, fileName)
put(MediaStore.Downloads.MIME_TYPE, mimeType)
- put(MediaStore.Downloads.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS)
+ put(MediaStore.Downloads.RELATIVE_PATH, relPath)
put(MediaStore.Downloads.IS_PENDING, 1)
}
val resolver = applicationContext.contentResolver
@@ -83,9 +90,10 @@ class MainActivity : FlutterActivity() {
}
@Suppress("DEPRECATION")
- val downloads = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
- downloads.mkdirs()
- val destination = File(downloads, fileName)
+ val baseDownloads = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
+ val targetDir = if (!subDir.isNullOrBlank()) File(baseDownloads, subDir.trim()) else baseDownloads
+ targetDir.mkdirs()
+ val destination = File(targetDir, fileName)
FileInputStream(source).use { input ->
FileOutputStream(destination).use { output -> input.copyTo(output) }
}
diff --git a/lib/app/app.dart b/lib/app/app.dart
index 9dc5e4a..1d5398b 100644
--- a/lib/app/app.dart
+++ b/lib/app/app.dart
@@ -1,5 +1,6 @@
import 'dart:io';
+import 'package:dynamic_color/dynamic_color.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:url_launcher/url_launcher.dart';
@@ -34,20 +35,29 @@ class GelRuleApp extends ConsumerWidget {
),
data: (_) {
final appSettings = settings.value ?? AppSettings.defaults;
- return MaterialApp.router(
- title: 'Lunaris',
- debugShowCheckedModeBanner: false,
- theme: buildTheme(
- Brightness.light,
- seedColor: appSettings.appSeedColor,
- ),
- darkTheme: buildTheme(
- Brightness.dark,
- seedColor: appSettings.appSeedColor,
- ),
- themeMode: parseThemeMode(appSettings.themeMode),
- routerConfig: router,
- builder: (context, child) => _AppOverlay(child: child),
+ final amoled = appSettings.amoledMode;
+ final useDynamic = appSettings.useDynamicColor;
+ return DynamicColorBuilder(
+ builder: (lightDynamic, darkDynamic) {
+ return MaterialApp.router(
+ title: 'Lunaris',
+ debugShowCheckedModeBanner: false,
+ theme: buildTheme(
+ Brightness.light,
+ seedColor: appSettings.appSeedColor,
+ dynamicColorScheme: useDynamic ? lightDynamic : null,
+ ),
+ darkTheme: buildTheme(
+ Brightness.dark,
+ seedColor: appSettings.appSeedColor,
+ amoled: amoled,
+ dynamicColorScheme: useDynamic ? darkDynamic : null,
+ ),
+ themeMode: parseThemeMode(appSettings.themeMode),
+ routerConfig: router,
+ builder: (context, child) => _AppOverlay(child: child),
+ );
+ },
);
},
);
diff --git a/lib/app/app_version.dart b/lib/app/app_version.dart
index 34f5d85..84ac377 100644
--- a/lib/app/app_version.dart
+++ b/lib/app/app_version.dart
@@ -1,3 +1,3 @@
-const appDisplayVersion = '2.0.4';
-const appBuildNumber = 26;
+const appDisplayVersion = '2.0.5';
+const appBuildNumber = 27;
const appReleaseChannel = 'Stable';
diff --git a/lib/app/changelog.dart b/lib/app/changelog.dart
index 6d3001d..95a05ca 100644
--- a/lib/app/changelog.dart
+++ b/lib/app/changelog.dart
@@ -1,4 +1,19 @@
const lunarisChangelog = [
+ LunarisChange(
+ version: '2.0.5',
+ title: '10 QoL Super-Update: AMOLED, Dynamic Colors, Grid switch, Share, Presets, Cache & Backup',
+ bullets: [
+ 'AMOLED Pure Black theme (#000000) for OLED battery saving.',
+ 'Material You dynamic color support on Android matching system wallpaper.',
+ 'Grid layout mode switcher in the feed toolbar: Masonry, 1:1 Square Grid, and List.',
+ 'Swipe down to dismiss and double-tap with animated heart overlay to favorite.',
+ 'Direct file sharing (Share sheet) from post details.',
+ 'Saved search presets with instant filtering and bookmark creation.',
+ 'Smart download folder template support ({Artist}, {Provider}, {Service}, {ID}, {Date}).',
+ 'Interactive Cache Manager screen with detailed storage breakdown and selective cleanup.',
+ 'Full JSON backup export and import for seamless configuration sync.',
+ ],
+ ),
LunarisChange(
version: '2.0.4',
title: 'Platform service selection & media type filtering in artist posts',
diff --git a/lib/app/router.dart b/lib/app/router.dart
index f0aece9..2b5e9d9 100644
--- a/lib/app/router.dart
+++ b/lib/app/router.dart
@@ -15,6 +15,7 @@ import '../features/providers/presentation/provider_check_screen.dart';
import '../features/providers/presentation/provider_form_screen.dart';
import '../features/providers/presentation/providers_screen.dart';
import '../features/search/presentation/search_screen.dart';
+import '../features/settings/presentation/cache_manager_screen.dart';
import '../features/settings/presentation/hidden_posts_screen.dart';
import '../features/settings/presentation/settings_screen.dart';
import '../features/viewed/presentation/viewed_screen.dart';
@@ -126,6 +127,10 @@ final appRouterProvider = Provider((ref) {
path: '/settings/hidden',
builder: (context, state) => const HiddenPostsScreen(),
),
+ GoRoute(
+ path: '/settings/cache',
+ builder: (context, state) => const CacheManagerScreen(),
+ ),
],
),
],
diff --git a/lib/app/theme.dart b/lib/app/theme.dart
index ec06043..8defb56 100644
--- a/lib/app/theme.dart
+++ b/lib/app/theme.dart
@@ -1,26 +1,53 @@
import 'package:flutter/material.dart';
-ThemeData buildTheme(Brightness brightness, {int seedColor = 0xFFE84D8A}) {
+ThemeData buildTheme(
+ Brightness brightness, {
+ int seedColor = 0xFFE84D8A,
+ bool amoled = false,
+ ColorScheme? dynamicColorScheme,
+}) {
final dark = brightness == Brightness.dark;
- final scheme = ColorScheme.fromSeed(
- seedColor: Color(seedColor),
- brightness: brightness,
- );
+ final scheme = dynamicColorScheme ??
+ ColorScheme.fromSeed(
+ seedColor: Color(seedColor),
+ brightness: brightness,
+ );
+
+ final scaffoldBg = amoled && dark
+ ? const Color(0xFF000000)
+ : dark
+ ? const Color(0xFF101114)
+ : const Color(0xFFF8F8FA);
+ final cardBg = amoled && dark
+ ? const Color(0xFF0A0A0A)
+ : dark
+ ? const Color(0xFF191B20)
+ : Colors.white;
+ final navBg = amoled && dark
+ ? const Color(0xFF000000)
+ : dark
+ ? const Color(0xFF15161A)
+ : Colors.white;
+ final appBarBg = amoled && dark
+ ? const Color(0xFF000000)
+ : dark
+ ? const Color(0xFF101114)
+ : const Color(0xFFF8F8FA);
+
return ThemeData(
useMaterial3: true,
brightness: brightness,
colorScheme: scheme,
- scaffoldBackgroundColor:
- dark ? const Color(0xFF101114) : const Color(0xFFF8F8FA),
- cardColor: dark ? const Color(0xFF191B20) : Colors.white,
+ scaffoldBackgroundColor: scaffoldBg,
+ cardColor: cardBg,
navigationRailTheme: NavigationRailThemeData(
- backgroundColor: dark ? const Color(0xFF15161A) : Colors.white,
+ backgroundColor: navBg,
selectedIconTheme: IconThemeData(color: scheme.primary),
),
appBarTheme: AppBarTheme(
centerTitle: false,
elevation: 0,
- backgroundColor: dark ? const Color(0xFF101114) : const Color(0xFFF8F8FA),
+ backgroundColor: appBarBg,
foregroundColor: scheme.onSurface,
),
cardTheme: CardThemeData(
diff --git a/lib/backend/backend.dart b/lib/backend/backend.dart
index dc24a91..0350a2d 100644
--- a/lib/backend/backend.dart
+++ b/lib/backend/backend.dart
@@ -28,5 +28,6 @@ export 'services/search_service.dart';
export 'services/settings_service.dart';
export 'services/update_service.dart';
export 'services/viewed_history_service.dart';
+export 'services/backup_service.dart';
export 'utils/smart_blacklist.dart';
export 'utils/media_quality.dart';
diff --git a/lib/backend/di/backend_providers.dart b/lib/backend/di/backend_providers.dart
index 931c6d3..2575b12 100644
--- a/lib/backend/di/backend_providers.dart
+++ b/lib/backend/di/backend_providers.dart
@@ -31,6 +31,7 @@ import '../services/search_service.dart';
import '../services/settings_service.dart';
import '../services/update_service.dart';
import '../services/viewed_history_service.dart';
+import '../services/backup_service.dart';
final appDatabaseProvider = FutureProvider((ref) async {
final database = await AppDatabase.open();
@@ -169,6 +170,7 @@ final downloadManagerServiceProvider = Provider((ref) {
return DownloadManagerService(
ref.watch(downloadServiceProvider),
downloadedMediaService: ref.watch(downloadedMediaServiceProvider),
+ settingsService: ref.watch(settingsServiceProvider),
);
});
@@ -215,3 +217,7 @@ final updateServiceProvider = Provider((ref) {
ref.watch(settingsServiceProvider),
);
});
+
+final backupServiceProvider = Provider((ref) {
+ return BackupService(ref.watch(settingsServiceProvider));
+});
diff --git a/lib/backend/repositories/downloaded_media_repository.dart b/lib/backend/repositories/downloaded_media_repository.dart
index 87b83b5..d3d8dcd 100644
--- a/lib/backend/repositories/downloaded_media_repository.dart
+++ b/lib/backend/repositories/downloaded_media_repository.dart
@@ -83,5 +83,11 @@ class DownloadedMediaRepository {
});
}
+ Future> count() {
+ return _databaseService.safeRead((isar) async {
+ return isar.downloadedMediaEntitys.count();
+ });
+ }
+
String fileNameForPath(String path) => p.basename(path);
}
diff --git a/lib/backend/services/backup_service.dart b/lib/backend/services/backup_service.dart
new file mode 100644
index 0000000..81a1f9e
--- /dev/null
+++ b/lib/backend/services/backup_service.dart
@@ -0,0 +1,87 @@
+import 'dart:convert';
+import 'dart:io';
+
+import 'package:file_selector/file_selector.dart';
+import 'package:path_provider/path_provider.dart';
+import 'package:share_plus/share_plus.dart';
+
+import '../../core/errors/failure.dart';
+import '../../core/utils/result.dart';
+import 'settings_service.dart';
+
+class BackupService {
+ BackupService(this._settingsService);
+
+ final SettingsService _settingsService;
+
+ Future createBackupJson() async {
+ final result = await _settingsService.getSettings();
+ final settings =
+ result is Success ? result.data : AppSettings.defaults;
+ const encoder = JsonEncoder.withIndent(' ');
+ return encoder.convert({
+ 'version': 1,
+ 'createdAt': DateTime.now().toIso8601String(),
+ 'settings': settings.toJson(),
+ });
+ }
+
+ Future> restoreFromJson(String jsonContent) async {
+ try {
+ final decoded = jsonDecode(jsonContent);
+ if (decoded is! Map) {
+ return const Error(Failure(
+ code: 'invalid_json',
+ message: 'Неверный формат JSON файла',
+ ));
+ }
+ final rawSettings = decoded['settings'] is Map
+ ? decoded['settings'] as Map
+ : decoded;
+ final settings = AppSettings.fromJson(rawSettings);
+ await _settingsService.updateSettings(settings);
+ return Success(settings);
+ } catch (e) {
+ return Error(Failure(
+ code: 'import_error',
+ message: 'Ошибка импорта настроек: $e',
+ ));
+ }
+ }
+
+ Future exportBackup() async {
+ final json = await createBackupJson();
+ final fileName =
+ 'lunaris_backup_${DateTime.now().millisecondsSinceEpoch}.json';
+
+ if (Platform.isAndroid) {
+ final tempDir = await getTemporaryDirectory();
+ final file = File('${tempDir.path}/$fileName');
+ await file.writeAsString(json);
+ await Share.shareXFiles([XFile(file.path)], text: 'Lunaris Backup');
+ return true;
+ }
+
+ final location = await getSaveLocation(
+ suggestedName: fileName,
+ acceptedTypeGroups: [
+ const XTypeGroup(label: 'JSON files', extensions: ['json']),
+ ],
+ );
+ if (location == null) return false;
+ final file = File(location.path);
+ await file.writeAsString(json);
+ return true;
+ }
+
+ Future?> importBackup() async {
+ const typeGroup = XTypeGroup(
+ label: 'JSON files',
+ extensions: ['json'],
+ );
+ final file = await openFile(acceptedTypeGroups: [typeGroup]);
+ if (file == null) return null;
+ final content = await file.readAsString();
+ return restoreFromJson(content);
+ }
+}
diff --git a/lib/backend/services/download_manager_service.dart b/lib/backend/services/download_manager_service.dart
index 867c97a..3e937bb 100644
--- a/lib/backend/services/download_manager_service.dart
+++ b/lib/backend/services/download_manager_service.dart
@@ -1,19 +1,24 @@
import 'dart:async';
+import '../../core/utils/result.dart';
import '../models/download_task.dart';
import '../models/post.dart';
import '../utils/media_quality.dart';
import 'download_service.dart';
import 'downloaded_media_service.dart';
+import 'settings_service.dart';
class DownloadManagerService {
DownloadManagerService(
this._downloadService, {
DownloadedMediaService? downloadedMediaService,
- }) : _downloadedMediaService = downloadedMediaService;
+ SettingsService? settingsService,
+ }) : _downloadedMediaService = downloadedMediaService,
+ _settingsService = settingsService;
final DownloadService _downloadService;
final DownloadedMediaService? _downloadedMediaService;
+ final SettingsService? _settingsService;
final _controller = StreamController>.broadcast();
final Map _tasks = {};
final Set _runningTaskIds = {};
@@ -100,9 +105,17 @@ class DownloadManagerService {
if (!_runningTaskIds.add(task.id)) return;
_set(task.copyWith(status: DownloadTaskStatus.running));
try {
+ String? folderTemplate;
+ if (_settingsService != null) {
+ final settingsRes = await _settingsService.getSettings();
+ if (settingsRes is Success) {
+ folderTemplate = settingsRes.data.downloadPathTemplate;
+ }
+ }
final saved = task.sourceUrl == null
? await _downloadService.downloadPost(
task.post!,
+ folderTemplate: folderTemplate,
onProgress: (received, total) {
_updateProgress(task.id, received, total);
},
diff --git a/lib/backend/services/download_service.dart b/lib/backend/services/download_service.dart
index 301c42d..5b19ac7 100644
--- a/lib/backend/services/download_service.dart
+++ b/lib/backend/services/download_service.dart
@@ -1,7 +1,6 @@
import 'dart:io';
import 'package:dio/dio.dart';
-import 'package:file_selector/file_selector.dart';
import 'package:flutter/services.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
@@ -16,7 +15,54 @@ class DownloadService {
final Dio _dio;
- Future downloadPost(
+ String resolveSubDir(Post post, String template) {
+ if (template.isEmpty) return '';
+ String artist = 'Unknown';
+ final artistGroup =
+ post.tagGroups['artist'] ?? post.tagGroups['creator'];
+ if (artistGroup != null && artistGroup.isNotEmpty) {
+ artist = artistGroup.first;
+ } else {
+ for (final tag in post.tags) {
+ final lower = tag.toLowerCase();
+ if (lower.startsWith('artist:') || lower.startsWith('creator:')) {
+ final parts = tag.split(':');
+ if (parts.length > 1 && parts[1].isNotEmpty) {
+ artist = parts.sublist(1).join(':');
+ break;
+ }
+ }
+ }
+ }
+ artist = artist.replaceAll(RegExp(r'[\\/:*?"<>|]'), '_');
+
+ final now = DateTime.now();
+ final dateStr =
+ '${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')}';
+
+ var cleanTemplate = template.trim();
+ if (cleanTemplate.toLowerCase().endsWith('/{id}')) {
+ cleanTemplate =
+ cleanTemplate.substring(0, cleanTemplate.length - 5).trim();
+ }
+
+ final resolved = cleanTemplate
+ .replaceAll(
+ '{Artist}', artist)
+ .replaceAll(
+ '{Provider}', post.providerName.replaceAll(RegExp(r'[\\/:*?"<>|]'), '_'))
+ .replaceAll(
+ '{Service}', post.providerId.replaceAll(RegExp(r'[\\/:*?"<>|]'), '_'))
+ .replaceAll(
+ '{Rating}', post.rating.replaceAll(RegExp(r'[\\/:*?"<>|]'), '_'))
+ .replaceAll(
+ '{ID}', post.id.replaceAll(RegExp(r'[\\/:*?"<>|]'), '_'))
+ .replaceAll('{Date}', dateStr);
+
+ return resolved;
+ }
+
+ Future prepareFileForShare(
Post post, {
void Function(int received, int total)? onProgress,
}) async {
@@ -25,6 +71,38 @@ class DownloadService {
throw StateError('No downloadable URL for this post.');
}
final fileName = _fileName(post, url);
+ final tempDir = await getTemporaryDirectory();
+ final shareDir = Directory(p.join(tempDir.path, 'shares'));
+ if (!shareDir.existsSync()) {
+ shareDir.createSync(recursive: true);
+ }
+ final filePath = p.join(shareDir.path, fileName);
+ if (File(filePath).existsSync() && File(filePath).lengthSync() > 0) {
+ return filePath;
+ }
+ await _dio.download(
+ url,
+ filePath,
+ onReceiveProgress: onProgress,
+ options: Options(headers: _headersFor(post)),
+ );
+ return filePath;
+ }
+
+ Future downloadPost(
+ Post post, {
+ String? folderTemplate,
+ void Function(int received, int total)? onProgress,
+ }) async {
+ final url = _downloadUrl(post);
+ if (url == null) {
+ throw StateError('No downloadable URL for this post.');
+ }
+ final fileName = _fileName(post, url);
+ final subDir = folderTemplate != null && folderTemplate.isNotEmpty
+ ? resolveSubDir(post, folderTemplate)
+ : null;
+
if (Platform.isAndroid) {
final tempDir = await getTemporaryDirectory();
final tempPath = p.join(tempDir.path, fileName);
@@ -38,19 +116,27 @@ class DownloadService {
'path': tempPath,
'fileName': fileName,
'mimeType': _mimeType(fileName, post.fileType),
+ if (subDir != null && subDir.isNotEmpty) 'subDir': subDir,
});
return saved ?? fileName;
}
- final location = await getSaveLocation(suggestedName: fileName);
- if (location == null) return null;
+ final downloads =
+ await getDownloadsDirectory() ?? await getTemporaryDirectory();
+ final targetDir = subDir != null && subDir.isNotEmpty
+ ? Directory(p.join(downloads.path, subDir))
+ : downloads;
+ if (!targetDir.existsSync()) {
+ targetDir.createSync(recursive: true);
+ }
+ final savePath = p.join(targetDir.path, fileName);
await _dio.download(
url,
- location.path,
+ savePath,
onReceiveProgress: onProgress,
options: Options(headers: _headersFor(post)),
);
- return location.path;
+ return savePath;
}
Future downloadUrl(
diff --git a/lib/backend/services/downloaded_media_service.dart b/lib/backend/services/downloaded_media_service.dart
index 730571f..8a3f98a 100644
--- a/lib/backend/services/downloaded_media_service.dart
+++ b/lib/backend/services/downloaded_media_service.dart
@@ -46,4 +46,6 @@ class DownloadedMediaService {
}
Future> clearMissingFiles() => _repository.clearMissingFiles();
+
+ Future> count() => _repository.count();
}
diff --git a/lib/backend/services/settings_service.dart b/lib/backend/services/settings_service.dart
index 625aa15..ef0eca9 100644
--- a/lib/backend/services/settings_service.dart
+++ b/lib/backend/services/settings_service.dart
@@ -53,6 +53,11 @@ class AppSettings {
this.lastUpdateCheckAt,
this.defaultRatingFilter,
this.lastFeedRating,
+ this.amoledMode = false,
+ this.useDynamicColor = false,
+ this.gridMode = 'masonry',
+ this.searchPresets = const [],
+ this.downloadPathTemplate = '{Artist}/{ID}',
});
final List favoriteArtists;
@@ -96,6 +101,11 @@ class AppSettings {
final String? lastUpdateCheckAt;
final String? defaultRatingFilter;
final String? lastFeedRating;
+ final bool amoledMode;
+ final bool useDynamicColor;
+ final String gridMode;
+ final List searchPresets;
+ final String downloadPathTemplate;
static const defaults = AppSettings(
enabledProviderIds: ['gelbooru', 'rule34', 'realbooru'],
@@ -184,6 +194,11 @@ class AppSettings {
String? lastFeedRating,
bool clearLastFeedRating = false,
List? favoriteArtists,
+ bool? amoledMode,
+ bool? useDynamicColor,
+ String? gridMode,
+ List? searchPresets,
+ String? downloadPathTemplate,
}) {
return AppSettings(
favoriteArtists: favoriteArtists ?? this.favoriteArtists,
@@ -234,6 +249,11 @@ class AppSettings {
defaultRatingFilter: defaultRatingFilter ?? this.defaultRatingFilter,
lastFeedRating:
clearLastFeedRating ? null : lastFeedRating ?? this.lastFeedRating,
+ amoledMode: amoledMode ?? this.amoledMode,
+ useDynamicColor: useDynamicColor ?? this.useDynamicColor,
+ gridMode: gridMode ?? this.gridMode,
+ searchPresets: searchPresets ?? this.searchPresets,
+ downloadPathTemplate: downloadPathTemplate ?? this.downloadPathTemplate,
);
}
@@ -279,6 +299,11 @@ class AppSettings {
'lastUpdateCheckAt': lastUpdateCheckAt,
'defaultRatingFilter': defaultRatingFilter,
'lastFeedRating': lastFeedRating,
+ 'amoledMode': amoledMode,
+ 'useDynamicColor': useDynamicColor,
+ 'gridMode': gridMode,
+ 'searchPresets': searchPresets,
+ 'downloadPathTemplate': downloadPathTemplate,
};
factory AppSettings.fromJson(Map json) => AppSettings(
@@ -378,6 +403,14 @@ class AppSettings {
lastUpdateCheckAt: json['lastUpdateCheckAt'] as String?,
defaultRatingFilter: json['defaultRatingFilter'] as String?,
lastFeedRating: json['lastFeedRating'] as String?,
+ amoledMode: (json['amoledMode'] as bool?) ?? false,
+ useDynamicColor: (json['useDynamicColor'] as bool?) ?? false,
+ gridMode: (json['gridMode'] as String?) ?? 'masonry',
+ searchPresets: List.from(
+ (json['searchPresets'] as List?) ?? const [],
+ ),
+ downloadPathTemplate:
+ (json['downloadPathTemplate'] as String?) ?? '{Artist}/{ID}',
);
}
diff --git a/lib/core/cache/cache_service.dart b/lib/core/cache/cache_service.dart
index 6108627..4709435 100644
--- a/lib/core/cache/cache_service.dart
+++ b/lib/core/cache/cache_service.dart
@@ -62,6 +62,12 @@ class CacheService {
});
}
+ Future> getCachedPostsCount() {
+ return _databaseService.safeRead((isar) async {
+ return isar.cachedPostEntitys.count();
+ });
+ }
+
Future _evictOverflow(Isar isar, int maxItems) async {
final entities = await isar.cachedPostEntitys.where().findAll();
if (entities.length <= maxItems) return;
diff --git a/lib/features/feed/presentation/feed_screen.dart b/lib/features/feed/presentation/feed_screen.dart
index 3698eba..ec5dd22 100644
--- a/lib/features/feed/presentation/feed_screen.dart
+++ b/lib/features/feed/presentation/feed_screen.dart
@@ -1,3 +1,4 @@
+import 'dart:convert';
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
@@ -17,6 +18,7 @@ import '../../../shared/widgets/post_card.dart';
import '../../../shared/widgets/post_masonry_grid.dart';
import '../../collections/presentation/collection_form_dialog.dart';
import '../../favorites/presentation/favorites_controller.dart';
+import '../../settings/presentation/settings_controller.dart';
import 'feed_controller.dart';
import 'widgets/feed_toolbar.dart';
@@ -179,6 +181,20 @@ class _FeedScreenState extends ConsumerState
child: AdaptiveScaffold(
title: 'Feed',
titleWidget: const _FeedTitle(),
+ actions: [
+ if (feed.value != null && feed.value!.selectedTags.isNotEmpty)
+ IconButton(
+ icon: const Icon(Icons.bookmark_add_rounded),
+ tooltip: 'Сохранить пресет',
+ onPressed: () => _saveCurrentPreset(
+ context, feed.value!.selectedTags, settings),
+ ),
+ IconButton(
+ icon: Icon(_gridModeIcon(settings.gridMode)),
+ tooltip: 'Вид сетки',
+ onPressed: () => _cycleGridMode(settings),
+ ),
+ ],
floatingActionButton: _showScrollToTop
? FloatingActionButton.small(
tooltip: 'Наверх',
@@ -201,6 +217,26 @@ class _FeedScreenState extends ConsumerState
),
data: (state) => Column(
children: [
+ // --- Search Presets Bar ---
+ if (settings.searchPresets.isNotEmpty)
+ _SearchPresetsBar(
+ presets: settings.searchPresets,
+ currentTags: state.selectedTags,
+ onApply: (preset) {
+ final decoded =
+ _decodePreset(preset);
+ _submitSearch(decoded['tags'] ?? '');
+ },
+ onDelete: (preset) async {
+ final updated = settings.searchPresets
+ .where((p) => p != preset)
+ .toList();
+ await ref
+ .read(settingsControllerProvider.notifier)
+ .saveSettings(
+ settings.copyWith(searchPresets: updated));
+ },
+ ),
FeedToolbar(
selectedTags: state.selectedTags,
selectedProviderIds: state.selectedProviderIds,
@@ -294,6 +330,7 @@ class _FeedScreenState extends ConsumerState
viewedKeys: viewedKeys,
selectionMode: _selectionMode,
selectedKeys: _selectedKeys,
+ gridMode: settings.gridMode,
onOpen: (post) => context.push(
'/post/${post.providerId}/${post.id}',
extra: PostNavigationContext(
@@ -527,6 +564,75 @@ class _FeedScreenState extends ConsumerState
if (!settings.autoDownloadFavorites || !settings.allowDownloads) return;
await ref.read(downloadManagerServiceProvider).start(post);
}
+
+ // ── Grid mode helpers ────────────────────────────────────────────
+ IconData _gridModeIcon(String mode) {
+ return switch (mode) {
+ 'grid' => Icons.grid_4x4_rounded,
+ 'list' => Icons.view_list_rounded,
+ _ => Icons.dashboard_rounded,
+ };
+ }
+
+ Future _cycleGridMode(AppSettings settings) async {
+ const modes = ['masonry', 'grid', 'list'];
+ final idx = modes.indexOf(settings.gridMode);
+ final next = modes[(idx + 1) % modes.length];
+ await ref
+ .read(settingsControllerProvider.notifier)
+ .saveSettings(settings.copyWith(gridMode: next));
+ }
+
+ // ── Search preset helpers ─────────────────────────────────────────
+ Map _decodePreset(String raw) {
+ try {
+ final map = jsonDecode(raw) as Map;
+ return {'name': map['name'] as String, 'tags': map['tags'] as String};
+ } catch (_) {
+ return {'name': raw, 'tags': raw};
+ }
+ }
+
+ Future _showSavePresetDialog(
+ BuildContext context, String tags) async {
+ final ctrl = TextEditingController(text: tags);
+ return showDialog(
+ context: context,
+ builder: (ctx) => AlertDialog(
+ title: const Text('Сохранить пресет'),
+ content: TextField(
+ controller: ctrl,
+ decoration: const InputDecoration(
+ labelText: 'Название пресета',
+ ),
+ autofocus: true,
+ ),
+ actions: [
+ TextButton(
+ onPressed: () => Navigator.pop(ctx),
+ child: const Text('Отмена'),
+ ),
+ FilledButton(
+ onPressed: () => Navigator.pop(ctx, ctrl.text.trim()),
+ child: const Text('Сохранить'),
+ ),
+ ],
+ ),
+ );
+ }
+
+ Future _saveCurrentPreset(
+ BuildContext context, List tags, AppSettings settings) async {
+ if (tags.isEmpty) return;
+ final tagsStr = tags.join(' ');
+ final name = await _showSavePresetDialog(context, tagsStr);
+ if (name == null || name.isEmpty || !context.mounted) return;
+ final preset = jsonEncode({'name': name, 'tags': tagsStr});
+ final updated = [...settings.searchPresets, preset];
+ await ref
+ .read(settingsControllerProvider.notifier)
+ .saveSettings(settings.copyWith(searchPresets: updated));
+ }
}
class _RefreshIntent extends Intent {
@@ -631,3 +737,86 @@ class _BatchActionBar extends StatelessWidget {
);
}
}
+
+// ── Search Presets Bar ─────────────────────────────────────────────────────
+class _SearchPresetsBar extends StatelessWidget {
+ const _SearchPresetsBar({
+ required this.presets,
+ required this.currentTags,
+ required this.onApply,
+ required this.onDelete,
+ });
+
+ final List presets;
+ final List currentTags;
+ final ValueChanged onApply;
+ final ValueChanged onDelete;
+
+ Map _decode(String raw) {
+ try {
+ final map = jsonDecode(raw) as Map;
+ return {'name': map['name'] as String, 'tags': map['tags'] as String};
+ } catch (_) {
+ return {'name': raw, 'tags': raw};
+ }
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final scheme = Theme.of(context).colorScheme;
+ return SizedBox(
+ height: 40,
+ child: ListView.separated(
+ padding: const EdgeInsets.symmetric(horizontal: 12),
+ scrollDirection: Axis.horizontal,
+ itemCount: presets.length,
+ separatorBuilder: (_, __) => const SizedBox(width: 8),
+ itemBuilder: (context, i) {
+ final raw = presets[i];
+ final decoded = _decode(raw);
+ final name = decoded['name'] ?? raw;
+ final tags = decoded['tags'] ?? raw;
+ final active = currentTags.join(' ') == tags;
+ return GestureDetector(
+ onLongPress: () {
+ showDialog(
+ context: context,
+ builder: (ctx) => AlertDialog(
+ title: Text('«$name»'),
+ content: Text('Теги: $tags'),
+ actions: [
+ TextButton(
+ onPressed: () {
+ Navigator.pop(ctx);
+ onDelete(raw);
+ },
+ child: const Text('Удалить',
+ style: TextStyle(color: Colors.red)),
+ ),
+ FilledButton(
+ onPressed: () => Navigator.pop(ctx),
+ child: const Text('Закрыть'),
+ ),
+ ],
+ ),
+ );
+ },
+ child: FilterChip(
+ label: Text(name),
+ selected: active,
+ onSelected: (_) => onApply(raw),
+ selectedColor: scheme.primaryContainer,
+ checkmarkColor: scheme.onPrimaryContainer,
+ labelStyle: TextStyle(
+ color: active ? scheme.onPrimaryContainer : scheme.onSurface,
+ fontWeight:
+ active ? FontWeight.w600 : FontWeight.normal,
+ ),
+ ),
+ );
+ },
+ ),
+ );
+ }
+}
+
diff --git a/lib/features/post/presentation/post_details_screen.dart b/lib/features/post/presentation/post_details_screen.dart
index f7799a2..324ef01 100644
--- a/lib/features/post/presentation/post_details_screen.dart
+++ b/lib/features/post/presentation/post_details_screen.dart
@@ -4,6 +4,7 @@ import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:cached_network_image/cached_network_image.dart';
+import 'package:share_plus/share_plus.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../../app/app.dart';
@@ -269,6 +270,7 @@ class PostDetailsScreen extends ConsumerWidget {
: null,
onDeleteLocalFile: () =>
_deleteLocalFile(context, ref, post),
+ onShare: () => _sharePost(context, ref, post),
),
const SizedBox(height: 16),
Wrap(
@@ -377,6 +379,7 @@ class PostDetailsScreen extends ConsumerWidget {
? () => _download(context, ref, post)
: null,
onDeleteLocalFile: () => _deleteLocalFile(context, ref, post),
+ onShare: () => _sharePost(context, ref, post),
),
const SizedBox(height: 12),
Wrap(
@@ -448,6 +451,30 @@ class PostDetailsScreen extends ConsumerWidget {
});
}
+ Future _sharePost(BuildContext context, WidgetRef ref, Post post) async {
+ final strings = ref.read(appStringsProvider);
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(
+ content: Text(
+ strings.ru
+ ? 'Подготовка файла к отправке...'
+ : 'Preparing file for sharing...',
+ ),
+ duration: const Duration(seconds: 1),
+ ),
+ );
+ try {
+ final path =
+ await ref.read(downloadServiceProvider).prepareFileForShare(post);
+ await Share.shareXFiles([XFile(path)], text: post.source ?? post.fileUrl);
+ } catch (e) {
+ if (!context.mounted) return;
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(content: Text('Ошибка отправки: $e')),
+ );
+ }
+ }
+
Future _showMobileQuickActions(
BuildContext context,
WidgetRef ref,
@@ -558,6 +585,7 @@ class PostDetailsScreen extends ConsumerWidget {
download: strings.download,
deleteLocalFile: strings.deleteLocalFile,
hidePost: strings.hidePost,
+ share: strings.ru ? 'Поделиться' : 'Share',
);
}
diff --git a/lib/features/post/presentation/widgets/post_action_bar.dart b/lib/features/post/presentation/widgets/post_action_bar.dart
index f7a3acb..fa2ed86 100644
--- a/lib/features/post/presentation/widgets/post_action_bar.dart
+++ b/lib/features/post/presentation/widgets/post_action_bar.dart
@@ -10,6 +10,7 @@ class PostActionBar extends StatelessWidget {
this.onOpenSource,
this.onDownload,
this.onDeleteLocalFile,
+ this.onShare,
this.downloaded = false,
this.labels,
this.onSimilar,
@@ -25,6 +26,7 @@ class PostActionBar extends StatelessWidget {
final bool isFavorite;
final VoidCallback? onDownload;
final VoidCallback? onDeleteLocalFile;
+ final VoidCallback? onShare;
final VoidCallback? onSimilar;
final VoidCallback? onHide;
final bool downloaded;
@@ -91,6 +93,12 @@ class PostActionBar extends StatelessWidget {
onPressed: onHide,
icon: const Icon(Icons.visibility_off_rounded),
),
+ if (onShare != null)
+ IconButton.filledTonal(
+ tooltip: text.share,
+ onPressed: onShare,
+ icon: const Icon(Icons.share_rounded),
+ ),
],
);
}
@@ -108,6 +116,7 @@ class PostActionLabels {
this.download = 'Download',
this.deleteLocalFile = 'Delete local file',
this.hidePost = 'Hide post locally',
+ this.share = 'Share',
});
final String favorite;
@@ -120,4 +129,5 @@ class PostActionLabels {
final String download;
final String deleteLocalFile;
final String hidePost;
+ final String share;
}
diff --git a/lib/features/settings/presentation/cache_manager_screen.dart b/lib/features/settings/presentation/cache_manager_screen.dart
new file mode 100644
index 0000000..a2d7ad3
--- /dev/null
+++ b/lib/features/settings/presentation/cache_manager_screen.dart
@@ -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((ref) async {
+ final cacheService = ref.watch(cacheServiceProvider);
+ final downloadedService = ref.watch(downloadedMediaServiceProvider);
+
+ final postsRes = await cacheService.getCachedPostsCount();
+ final postsCount = postsRes is Success ? postsRes.data : 0;
+
+ final downloadedRes = await downloadedService.count();
+ final downloadedCount =
+ downloadedRes is Success ? 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 createState() => _CacheManagerScreenState();
+}
+
+class _CacheManagerScreenState extends ConsumerState {
+ 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 _clearPostsCache() async {
+ setState(() => _isClearing = true);
+ await ref.read(cacheServiceProvider).clear();
+ ref.invalidate(cacheStatsProvider);
+ if (mounted) setState(() => _isClearing = false);
+ }
+
+ Future _clearImagesCache() async {
+ setState(() => _isClearing = true);
+ PaintingBinding.instance.imageCache.clear();
+ PaintingBinding.instance.imageCache.clearLiveImages();
+ ref.invalidate(cacheStatsProvider);
+ if (mounted) setState(() => _isClearing = false);
+ }
+
+ Future _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 _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),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/features/settings/presentation/settings_screen.dart b/lib/features/settings/presentation/settings_screen.dart
index 2d59584..d935bc8 100644
--- a/lib/features/settings/presentation/settings_screen.dart
+++ b/lib/features/settings/presentation/settings_screen.dart
@@ -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(
+ 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(
+ 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 _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 _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) {
+ ref.invalidate(appSettingsProvider);
+ ref.invalidate(settingsControllerProvider);
+ ScaffoldMessenger.of(context).showSnackBar(
+ const SnackBar(content: Text('Настройки успешно импортированы!')),
+ );
+ } else if (result is Error) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(content: Text('Ошибка импорта: ${result.failure.message}')),
+ );
+ }
+ }
+
Future _importDialog(BuildContext context, WidgetRef ref) async {
final controller = TextEditingController();
await showDialog(
diff --git a/lib/shared/widgets/post_card.dart b/lib/shared/widgets/post_card.dart
index d83dfdd..21f8bcb 100644
--- a/lib/shared/widgets/post_card.dart
+++ b/lib/shared/widgets/post_card.dart
@@ -54,26 +54,63 @@ class PostCard extends ConsumerStatefulWidget {
ConsumerState createState() => _PostCardState();
}
-class _PostCardState extends ConsumerState {
+class _PostCardState extends ConsumerState
+ with SingleTickerProviderStateMixin {
static final Map _resolvedRealbooruPosts = {};
bool _hovered = false;
Post? _resolvedPost;
+ bool _showHeart = false;
+ late final AnimationController _heartController;
+ late final Animation _heartScale;
+ late final Animation _heartOpacity;
@override
void initState() {
super.initState();
_resolvedPost = _resolvedRealbooruPosts[widget.post.cacheKey];
_maybeResolveRealbooruPost();
+ _heartController = AnimationController(
+ vsync: this,
+ duration: const Duration(milliseconds: 600),
+ );
+ _heartScale = TweenSequence([
+ TweenSequenceItem(
+ tween: Tween(begin: 0.4, end: 1.3)
+ .chain(CurveTween(curve: Curves.easeOut)),
+ weight: 40),
+ TweenSequenceItem(
+ tween: Tween(begin: 1.3, end: 1.0)
+ .chain(CurveTween(curve: Curves.easeIn)),
+ weight: 30),
+ TweenSequenceItem(
+ tween: Tween(begin: 1.0, end: 1.0), weight: 30),
+ ]).animate(_heartController);
+ _heartOpacity = TweenSequence([
+ TweenSequenceItem(tween: ConstantTween(1.0), weight: 60),
+ TweenSequenceItem(
+ tween: Tween(begin: 1.0, end: 0.0)
+ .chain(CurveTween(curve: Curves.easeIn)),
+ weight: 40),
+ ]).animate(_heartController);
+ _heartController.addStatusListener((status) {
+ if (status == AnimationStatus.completed && mounted) {
+ setState(() => _showHeart = false);
+ _heartController.reset();
+ }
+ });
}
@override
- void didUpdateWidget(covariant PostCard oldWidget) {
- super.didUpdateWidget(oldWidget);
- if (oldWidget.post.cacheKey != widget.post.cacheKey) {
- _resolvedPost = _resolvedRealbooruPosts[widget.post.cacheKey];
- _maybeResolveRealbooruPost();
- }
+ void dispose() {
+ _heartController.dispose();
+ super.dispose();
+ }
+
+ void _triggerDoubleTapFavorite() {
+ widget.onFavorite();
+ setState(() => _showHeart = true);
+ _heartController.forward();
}
@override
@@ -97,6 +134,7 @@ class _PostCardState extends ConsumerState {
}
widget.onOpen();
},
+ onDoubleTap: _triggerDoubleTapFavorite,
onLongPress: () {
if (widget.selectionMode && widget.onToggleSelected != null) {
widget.onToggleSelected!();
@@ -247,6 +285,32 @@ class _PostCardState extends ConsumerState {
onPressed: widget.onFavorite,
),
),
+ // Double-tap heart animation overlay
+ if (_showHeart)
+ Positioned.fill(
+ child: Center(
+ child: AnimatedBuilder(
+ animation: _heartController,
+ builder: (context, _) => Opacity(
+ opacity: _heartOpacity.value,
+ child: Transform.scale(
+ scale: _heartScale.value,
+ child: const Icon(
+ Icons.favorite_rounded,
+ color: Colors.white,
+ size: 72,
+ shadows: [
+ Shadow(
+ color: Colors.black54,
+ blurRadius: 12,
+ ),
+ ],
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
Positioned.fill(
child: IgnorePointer(
ignoring: !_hovered || mobile,
diff --git a/lib/shared/widgets/post_masonry_grid.dart b/lib/shared/widgets/post_masonry_grid.dart
index b9bbf2f..67af38f 100644
--- a/lib/shared/widgets/post_masonry_grid.dart
+++ b/lib/shared/widgets/post_masonry_grid.dart
@@ -27,6 +27,7 @@ class PostMasonryGrid extends StatelessWidget {
this.downloadedKeys = const {},
this.loading = false,
this.controller,
+ this.gridMode = 'masonry',
super.key,
});
@@ -49,39 +50,89 @@ class PostMasonryGrid extends StatelessWidget {
final Set downloadedKeys;
final bool loading;
final ScrollController? controller;
+ final String gridMode;
@override
Widget build(BuildContext context) {
final mobile = Responsive.isMobile(context);
+ final pad = EdgeInsets.all(mobile ? 8 : 16);
+ final spacing = mobile ? 8.0 : 12.0;
+
+ Widget buildCard(Post post) {
+ return PostCard(
+ post: post,
+ blurExplicit: blurExplicit && !nsfwEnabled,
+ showBadges: showBadges,
+ isFavorite: favoriteKeys.contains(post.cacheKey),
+ isViewed: viewedKeys.contains(post.cacheKey),
+ isDownloaded: downloadedKeys.contains(post.cacheKey),
+ mediaQualityMode: mediaQualityMode,
+ onOpen: () => onOpen(post),
+ onFavorite: () => onFavorite(post),
+ onPreview: onPreview == null ? null : () => onPreview!(post),
+ onHide: onHide == null ? null : () => onHide!(post),
+ selectionMode: selectionMode,
+ selected: selectedKeys.contains(post.cacheKey),
+ onToggleSelected:
+ onToggleSelected == null ? null : () => onToggleSelected!(post),
+ onAddToCollection:
+ onAddToCollection == null ? null : () => onAddToCollection!(post),
+ );
+ }
+
+ if (gridMode == 'grid') {
+ // Uniform square grid
+ return GridView.builder(
+ controller: controller,
+ padding: pad,
+ gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
+ crossAxisCount: columns,
+ mainAxisSpacing: spacing,
+ crossAxisSpacing: spacing,
+ childAspectRatio: 1.0,
+ ),
+ itemCount: posts.length + (loading ? columns : 0),
+ itemBuilder: (context, index) {
+ if (index >= posts.length) return const LoadingSkeleton();
+ return ClipRRect(
+ borderRadius: BorderRadius.circular(8),
+ child: SizedBox.expand(
+ child: FittedBox(
+ fit: BoxFit.cover,
+ clipBehavior: Clip.hardEdge,
+ child: buildCard(posts[index]),
+ ),
+ ),
+ );
+ },
+ );
+ }
+
+ if (gridMode == 'list') {
+ // Vertical list with larger cards
+ return ListView.separated(
+ controller: controller,
+ padding: pad,
+ separatorBuilder: (_, __) => SizedBox(height: spacing),
+ itemCount: posts.length + (loading ? 3 : 0),
+ itemBuilder: (context, index) {
+ if (index >= posts.length) return const LoadingSkeleton();
+ return buildCard(posts[index]);
+ },
+ );
+ }
+
+ // Default: masonry
return MasonryGridView.count(
controller: controller,
- padding: EdgeInsets.all(mobile ? 8 : 16),
+ padding: pad,
crossAxisCount: columns,
- mainAxisSpacing: mobile ? 8 : 12,
- crossAxisSpacing: mobile ? 8 : 12,
+ mainAxisSpacing: spacing,
+ crossAxisSpacing: spacing,
itemCount: posts.length + (loading ? columns : 0),
itemBuilder: (context, index) {
if (index >= posts.length) return const LoadingSkeleton();
- final post = posts[index];
- return PostCard(
- post: post,
- blurExplicit: blurExplicit && !nsfwEnabled,
- showBadges: showBadges,
- isFavorite: favoriteKeys.contains(post.cacheKey),
- isViewed: viewedKeys.contains(post.cacheKey),
- isDownloaded: downloadedKeys.contains(post.cacheKey),
- mediaQualityMode: mediaQualityMode,
- onOpen: () => onOpen(post),
- onFavorite: () => onFavorite(post),
- onPreview: onPreview == null ? null : () => onPreview!(post),
- onHide: onHide == null ? null : () => onHide!(post),
- selectionMode: selectionMode,
- selected: selectedKeys.contains(post.cacheKey),
- onToggleSelected:
- onToggleSelected == null ? null : () => onToggleSelected!(post),
- onAddToCollection:
- onAddToCollection == null ? null : () => onAddToCollection!(post),
- );
+ return buildCard(posts[index]);
},
);
}
diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc
index a51c5ea..619348c 100644
--- a/linux/flutter/generated_plugin_registrant.cc
+++ b/linux/flutter/generated_plugin_registrant.cc
@@ -6,6 +6,7 @@
#include "generated_plugin_registrant.h"
+#include
#include
#include
#include
@@ -14,6 +15,9 @@
#include
void fl_register_plugins(FlPluginRegistry* registry) {
+ g_autoptr(FlPluginRegistrar) dynamic_color_registrar =
+ fl_plugin_registry_get_registrar_for_plugin(registry, "DynamicColorPlugin");
+ dynamic_color_plugin_register_with_registrar(dynamic_color_registrar);
g_autoptr(FlPluginRegistrar) file_selector_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin");
file_selector_plugin_register_with_registrar(file_selector_linux_registrar);
diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake
index eaeb7cb..53d1448 100644
--- a/linux/flutter/generated_plugins.cmake
+++ b/linux/flutter/generated_plugins.cmake
@@ -3,6 +3,7 @@
#
list(APPEND FLUTTER_PLUGIN_LIST
+ dynamic_color
file_selector_linux
isar_flutter_libs
media_kit_libs_linux
diff --git a/pubspec.lock b/pubspec.lock
index 4743e5d..3a4095a 100644
--- a/pubspec.lock
+++ b/pubspec.lock
@@ -273,6 +273,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.2"
+ dynamic_color:
+ dependency: "direct main"
+ description:
+ name: dynamic_color
+ sha256: "4aeb76de323e8eb660bab5557d826ad7ebbf1434a598f0c6aa8a11a9696a6757"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.9.0"
fake_async:
dependency: transitive
description:
@@ -289,14 +297,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.2.0"
- ffi_leak_tracker:
- dependency: transitive
- description:
- name: ffi_leak_tracker
- sha256: "4093d4ef9ca06ffe2786e73bfb25e22aa92112b9bb4ec941f11e3e6b61489a97"
- url: "https://pub.dev"
- source: hosted
- version: "0.1.2"
file:
dependency: transitive
description:
@@ -739,18 +739,18 @@ packages:
dependency: transitive
description:
name: package_info_plus
- sha256: "4bf625947f6c7713ee242296a682e23e44823c09cf9d79e4f1238923c92db852"
+ sha256: "468c26b4254ab01979fa5e4a98cb343ea3631b9acee6f21028997419a80e1a20"
url: "https://pub.dev"
source: hosted
- version: "10.1.0"
+ version: "9.0.1"
package_info_plus_platform_interface:
dependency: transitive
description:
name: package_info_plus_platform_interface
- sha256: db762cb2f4f25ee60fb6359773861b0f199e00b90d237bd85a76a1e806b46ef4
+ sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086"
url: "https://pub.dev"
source: hosted
- version: "4.1.0"
+ version: "3.2.1"
path:
dependency: "direct main"
description:
@@ -911,6 +911,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.1"
+ share_plus:
+ dependency: "direct main"
+ description:
+ name: share_plus
+ sha256: fce43200aa03ea87b91ce4c3ac79f0cecd52e2a7a56c7a4185023c271fbfa6da
+ url: "https://pub.dev"
+ source: hosted
+ version: "10.1.4"
+ share_plus_platform_interface:
+ dependency: transitive
+ description:
+ name: share_plus_platform_interface
+ sha256: cc012a23fc2d479854e6c80150696c4a5f5bb62cb89af4de1c505cf78d0a5d0b
+ url: "https://pub.dev"
+ source: hosted
+ version: "5.0.2"
shelf:
dependency: transitive
description:
@@ -1208,10 +1224,10 @@ packages:
dependency: transitive
description:
name: wakelock_plus
- sha256: "824c5bba0f800e86d32e57d3d1843c531f090005cc89d9a837933e6601093d53"
+ sha256: ddf3db70eaa10c37558ff817519b85d527dbd21034fd5d8e1c2e85f31588f1c1
url: "https://pub.dev"
source: hosted
- version: "1.6.1"
+ version: "1.5.2"
wakelock_plus_platform_interface:
dependency: transitive
description:
@@ -1256,10 +1272,10 @@ packages:
dependency: transitive
description:
name: win32
- sha256: ba6f4bba816c8d7e3c1580e170f3786d216951cc6b94babc3b814c08d2cb2738
+ sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e
url: "https://pub.dev"
source: hosted
- version: "6.3.0"
+ version: "5.15.0"
xdg_directories:
dependency: transitive
description:
diff --git a/pubspec.yaml b/pubspec.yaml
index e86a194..e29aba5 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -1,7 +1,7 @@
name: gel_rule_app
description: Local backend/core layer for a Flutter booru browser app.
publish_to: "none"
-version: 2.0.4+26
+version: 2.0.5+27
environment:
sdk: ">=3.3.0 <4.0.0"
@@ -31,6 +31,8 @@ dependencies:
file_selector: ^1.0.3
path: ^1.9.0
media_kit_libs_android_video: ^1.3.8
+ share_plus: ^10.0.0
+ dynamic_color: ^1.7.0
dev_dependencies:
flutter_test:
diff --git a/test/backend/qol_features_test.dart b/test/backend/qol_features_test.dart
new file mode 100644
index 0000000..776a556
--- /dev/null
+++ b/test/backend/qol_features_test.dart
@@ -0,0 +1,138 @@
+import 'package:flutter_test/flutter_test.dart';
+import 'package:gel_rule_app/backend/models/post.dart';
+import 'package:gel_rule_app/backend/services/backup_service.dart';
+import 'package:gel_rule_app/backend/services/download_service.dart';
+import 'package:gel_rule_app/backend/services/settings_service.dart';
+import 'package:gel_rule_app/core/utils/result.dart';
+
+void main() {
+ group('QoL Features Unit Tests', () {
+ test('AppSettings JSON roundtrip retains new QoL fields', () {
+ const initial = AppSettings(
+ enabledProviderIds: ['gelbooru'],
+ nsfwEnabled: true,
+ cacheTtlHours: 12,
+ cacheMaxItems: 500,
+ providerPriority: {'gelbooru': 0},
+ themeMode: 'dark',
+ languageCode: 'ru',
+ appSeedColor: 0xFF112233,
+ hiddenTabs: [],
+ allowExperimentalUpdates: false,
+ desktopColumns: 4,
+ mobileColumns: 2,
+ blurExplicitContent: false,
+ allowDownloads: true,
+ autoDownloadFavorites: true,
+ selectedFeedProviderIds: [],
+ showPostBadges: true,
+ defaultTopPeriodFilter: 'none',
+ blacklistedTags: [],
+ whitelistedTags: [],
+ smartBlacklistRules: [],
+ hideViewedPosts: false,
+ mediaQualityMode: 'auto',
+ motionRefreshMode: 'auto',
+ autoBatterySaver60Hz: false,
+ videoPlayerMuted: true,
+ videoPlayerHalfVolume: false,
+ videoPlayerLoop: false,
+ videoPlayerCover: false,
+ videoPlaybackPositions: {},
+ hiddenPostKeys: [],
+ diagnosticLogLines: [],
+ lastFeedTags: [],
+ lastFeedProviderIds: [],
+ lastFeedTopPeriod: 'none',
+ lastFeedScrollOffset: 0,
+ amoledMode: true,
+ useDynamicColor: true,
+ gridMode: 'grid',
+ searchPresets: ['{"name":"Favs","tags":"cat"}'],
+ downloadPathTemplate: '{Provider}/{Artist}',
+ );
+
+ final json = initial.toJson();
+ final restored = AppSettings.fromJson(json);
+
+ expect(restored.amoledMode, isTrue);
+ expect(restored.useDynamicColor, isTrue);
+ expect(restored.gridMode, equals('grid'));
+ expect(restored.searchPresets, equals(['{"name":"Favs","tags":"cat"}']));
+ expect(restored.downloadPathTemplate, equals('{Provider}/{Artist}'));
+ });
+
+ test('DownloadService resolveSubDir replaces template variables correctly', () {
+ final downloadService = DownloadService();
+ final post = Post(
+ id: '12345',
+ providerId: 'pawchive',
+ providerName: 'Pawchive',
+ previewUrl: 'https://example.com/prev.jpg',
+ sampleUrl: 'https://example.com/sample.jpg',
+ fileUrl: 'https://example.com/file.jpg',
+ tags: ['artist:CoolArtist', 'cat', 'safe'],
+ rating: 'safe',
+ width: 800,
+ height: 600,
+ createdAt: DateTime.now(),
+ fileType: 'jpg',
+ score: 42,
+ tagGroups: {
+ 'artist': ['CoolArtist'],
+ },
+ );
+
+ final resolved = downloadService.resolveSubDir(post, '{Provider}/{Artist}');
+ expect(resolved, equals('Pawchive/CoolArtist'));
+
+ final singleDir = downloadService.resolveSubDir(post, '{Artist}');
+ expect(singleDir, equals('CoolArtist'));
+
+ final withId = downloadService.resolveSubDir(post, '{Artist}/{ID}');
+ expect(withId, equals('CoolArtist'));
+ });
+
+ test('BackupService exports and restores settings accurately', () async {
+ final fakeSettings = _FakeSettingsService();
+ final backupService = BackupService(fakeSettings);
+
+ fakeSettings.current = AppSettings.defaults.copyWith(
+ amoledMode: true,
+ useDynamicColor: true,
+ gridMode: 'list',
+ downloadPathTemplate: '{Provider}/{ID}',
+ );
+
+ final exportedJson = await backupService.createBackupJson();
+ expect(exportedJson, contains('version'));
+ expect(exportedJson, contains('settings'));
+
+ // Change settings to something else
+ fakeSettings.current = AppSettings.defaults.copyWith(
+ amoledMode: false,
+ gridMode: 'masonry',
+ );
+
+ // Restore from backup
+ final restoreResult = await backupService.restoreFromJson(exportedJson);
+ expect(restoreResult.isSuccess, isTrue);
+ expect(fakeSettings.current.amoledMode, isTrue);
+ expect(fakeSettings.current.gridMode, equals('list'));
+ expect(fakeSettings.current.downloadPathTemplate, equals('{Provider}/{ID}'));
+ });
+ });
+}
+
+class _FakeSettingsService extends Fake implements SettingsService {
+ AppSettings current = AppSettings.defaults;
+
+ @override
+ Future> getSettings() async => Success(current);
+
+ @override
+ Future> updateSettings(AppSettings settings) async {
+ current = settings;
+ return const Success(null);
+ }
+}
diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc
index 35b6858..045f898 100644
--- a/windows/flutter/generated_plugin_registrant.cc
+++ b/windows/flutter/generated_plugin_registrant.cc
@@ -7,16 +7,20 @@
#include "generated_plugin_registrant.h"
#include
+#include
#include
#include
#include
#include
+#include
#include
#include
void RegisterPlugins(flutter::PluginRegistry* registry) {
ConnectivityPlusWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("ConnectivityPlusWindowsPlugin"));
+ DynamicColorPluginCApiRegisterWithRegistrar(
+ registry->GetRegistrarForPlugin("DynamicColorPluginCApi"));
FileSelectorWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FileSelectorWindows"));
IsarFlutterLibsPluginRegisterWithRegistrar(
@@ -25,6 +29,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
registry->GetRegistrarForPlugin("MediaKitLibsWindowsVideoPluginCApi"));
MediaKitVideoPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("MediaKitVideoPluginCApi"));
+ SharePlusWindowsPluginCApiRegisterWithRegistrar(
+ registry->GetRegistrarForPlugin("SharePlusWindowsPluginCApi"));
UrlLauncherWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
VolumeControllerPluginCApiRegisterWithRegistrar(
diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake
index 2b8213a..1e2821f 100644
--- a/windows/flutter/generated_plugins.cmake
+++ b/windows/flutter/generated_plugins.cmake
@@ -4,10 +4,12 @@
list(APPEND FLUTTER_PLUGIN_LIST
connectivity_plus
+ dynamic_color
file_selector_windows
isar_flutter_libs
media_kit_libs_windows_video
media_kit_video
+ share_plus
url_launcher_windows
volume_controller
)