From 84eeb909a1ffbe1bb3566357f0464b124d1f848b Mon Sep 17 00:00:00 2001 From: RarDog Date: Thu, 3 Sep 2026 20:20:58 +0300 Subject: [PATCH] feat: release v2.0.5 with 10 QoL features (AMOLED, Dynamic Color, Grid switch, Share, Presets, Cache Manager, Backup & Gestures) --- README.md | 19 +- .../com/example/gel_rule_app/MainActivity.kt | 20 +- lib/app/app.dart | 38 +- lib/app/app_version.dart | 4 +- lib/app/changelog.dart | 15 + lib/app/router.dart | 5 + lib/app/theme.dart | 47 ++- lib/backend/backend.dart | 1 + lib/backend/di/backend_providers.dart | 6 + .../downloaded_media_repository.dart | 6 + lib/backend/services/backup_service.dart | 87 +++++ .../services/download_manager_service.dart | 15 +- lib/backend/services/download_service.dart | 98 ++++- .../services/downloaded_media_service.dart | 2 + lib/backend/services/settings_service.dart | 33 ++ lib/core/cache/cache_service.dart | 6 + .../feed/presentation/feed_screen.dart | 189 +++++++++ .../presentation/post_details_screen.dart | 28 ++ .../presentation/widgets/post_action_bar.dart | 10 + .../presentation/cache_manager_screen.dart | 362 ++++++++++++++++++ .../presentation/settings_screen.dart | 169 ++++++++ lib/shared/widgets/post_card.dart | 78 +++- lib/shared/widgets/post_masonry_grid.dart | 97 +++-- linux/flutter/generated_plugin_registrant.cc | 4 + linux/flutter/generated_plugins.cmake | 1 + pubspec.lock | 48 ++- pubspec.yaml | 4 +- test/backend/qol_features_test.dart | 138 +++++++ .../flutter/generated_plugin_registrant.cc | 6 + windows/flutter/generated_plugins.cmake | 2 + 30 files changed, 1446 insertions(+), 92 deletions(-) create mode 100644 lib/backend/services/backup_service.dart create mode 100644 lib/features/settings/presentation/cache_manager_screen.dart create mode 100644 test/backend/qol_features_test.dart diff --git a/README.md b/README.md index df125b8..3d3c0d5 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@

- Version 2.0.4 + Version 2.0.5 Flutter Platforms Gitea @@ -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 )