Compare commits

...
7 Commits
55 changed files with 3211 additions and 195 deletions
+30 -16
View File
@@ -10,7 +10,7 @@
</p> </p>
<p align="center"> <p align="center">
<img src="https://img.shields.io/badge/Version-2.0.4-blueviolet?style=for-the-badge" alt="Version 2.0.4" /> <img src="https://img.shields.io/badge/Version-2.0.9-blueviolet?style=for-the-badge" alt="Version 2.0.9" />
<img src="https://img.shields.io/badge/Flutter-3.47+-02569B?style=for-the-badge&logo=flutter&logoColor=white" alt="Flutter" /> <img src="https://img.shields.io/badge/Flutter-3.47+-02569B?style=for-the-badge&logo=flutter&logoColor=white" alt="Flutter" />
<img src="https://img.shields.io/badge/Platform-Android%20%7C%20Linux%20%7C%20Windows-brightgreen?style=for-the-badge" alt="Platforms" /> <img src="https://img.shields.io/badge/Platform-Android%20%7C%20Linux%20%7C%20Windows-brightgreen?style=for-the-badge" alt="Platforms" />
<img src="https://img.shields.io/badge/Gitea-RarDog%2FLunaris-orange?style=for-the-badge&logo=gitea&logoColor=white" alt="Gitea" /> <img src="https://img.shields.io/badge/Gitea-RarDog%2FLunaris-orange?style=for-the-badge&logo=gitea&logoColor=white" alt="Gitea" />
@@ -20,19 +20,33 @@
## ✨ Основные возможности ## ✨ Основные возможности
- **📱 Плавная лента в стиле Pinterest**: Masonry-сетка с адаптивным количеством колонок для мобильных и ПК. - **⚡ Мгновенные подсказки тегов (0 мс) и постоянный файловый кэш**: Локальный JSON-кэш на телефоне и ПК обеспечивает мгновенный отклик при наборе, а Danbooru, Gelbooru, Moebooru и e621 динамически дополняют базу тегов.
- **🎨 Цветные бейджи категорий и подсветка совпадений**: Выделение введенных букв жирным цветом акцента и четкая цветовая палитра категорий (художник — красный, персонаж — зеленый, копирайт — фиолетовый, вид — голубой).
- **📚 Расширенная история поиска и гибкие настройки**: До 2000 записей истории и до 20000 закэшированных тегов с ручным управлением и кнопками очистки в Настройках.
- **🎯 Общие модификаторы в мультипоиске**: При вводе `cat and dog anthro` модификатор `anthro` автоматически применится к обоим тегам (`cat anthro` + `dog anthro`).
- **🔀 Раздельный мульти-поиск по тегам через `and`**: Оператор `and` опрашивает серверную базу по каждому тегу независимо и плавно чередует посты обоих тегов в общей ленте.
- **🌐 Pawchive в главной ленте**: Глобальная лента недавних постов с Pawchive подключена прямо в общий Feed наравне с Rule34, Gelbooru, e621 и Realbooru.
- **🏷️ Интерактивные теги автора**: Быстрая фильтрация по темам с живыми счётчиками постов (`#tag (count)`).
- **📢 Анонсы и новости авторов**: Всплывающее окно анонсов автора с датами и текстом обращений к аудитории.
- **⚡ Мгновенный фильтр фото, видео и GIF**: Локальная быстрая фильтрация постов автора без задержек.
- **📱 Плавная лента в стиле Pinterest**: Masonry-сетка с переключением режима (Masonry, 1:1 Сетка, Список) в шапке ленты.
- **⚡ Сверхбыстрые видео-превью**: Мгновенная загрузка превью без фризов и лишней нагрузки на процессор и память смартфона. - **⚡ Сверхбыстрые видео-превью**: Мгновенная загрузка превью без фризов и лишней нагрузки на процессор и память смартфона.
- **↔️ Листание постов влево и вправо**: Полноэкранный пейджер с плавными свайпами между фото и видео во всех разделах (Лента, Авторы, Избранное, Коллекции, История просмотров). - **↔️ Листание постов влево и вправо**: Полноэкранный пейджер с плавными свайпами между фото и видео во всех разделах.
- **🖼️ Мульти-медиа в одном посте**: Удобная карусель с индикатором `1 / 4` для постов с несколькими файлами. - **❤️ Двойной тап для лайка и свайп вниз для закрытия**: Анимированное сердечко по двойному тапу и закрытие экрана просмотра свайпом вниз.
- **⭐ Любимые авторы**: Компактная кнопка-звёздочка сверху справа для быстрого доступа ко всем любимым авторам и их свежему медиапотоку. - **📤 Прямая отправка файла (Share)**: Мгновенный экспорт оригинальных медиафайлов через системное меню «Поделиться».
- **🏷️ Мультивыбор платформ**: Быстрый выбор одной или нескольких платформ авторов (Patreon, Fanbox, Fantia, Boosty, Discord). - **🔖 Сохраненные поисковые пресеты**: Быстрое сохранение комбинаций тегов и фильтров в закладки.
- **🎬 Фильтр фото, видео и GIF у автора**: Удобные чипсы с мультивыбором типа контента и количеством найденных медиа. - **📁 Умная структура папок скачивания**: Настраиваемые шаблоны поддиректорий (`{Artist}`, `{Provider}`, `{Service}`, `{ID}`, `{Date}`).
- **📜 Бесконечная подгрузка постов автора**: Умная пагинация при прокрутке вниз загружает всю историю работ без ограничений. - **🗑️ Менеджер кэша**: Детальная визуализация занятой памяти и выборочная очистка.
- **🐾 Поддержка Pawchive**: Интеграция с `pawchive.pw` для просмотра профилей авторов, связанных аккаунтов, тегов и работ. - **📦 Бэкап и синхронизация**: Экспорт и импорт полной конфигурации в JSON-файл для переноса на другие устройства.
- **🔍 Умный поиск и автодополнение**: Интерактивные чипсы тегов с подсказками от активных API. Быстрое исключение (`-tag`), добавление (`+tag`) или отправка в черный список по долгому тапу. - **🌑 AMOLED Pure Black тема**: Истинно черный фон (`#000000`) для экономии батареи на OLED дисплеях.
- **🛡️ Продвинутый Blacklist / Whitelist**: Фильтрация по тегам, категориям, рейтингу контента (safe/questionable/explicit), провайдерам и минимальному score. - **🎨 Динамические цвета Material You**: Поддержка системной палитры Android из обоев устройства.
- **🎬 Встроенный плеер**: Удобное воспроизведение WebM / MP4, запоминание настроек звука, fullscreen и повтор. - **⭐ Любимые авторы**: Компактная кнопка-звёздочка сверху справа для быстрого доступа ко всем любимым авторам.
- **💾 Менеджер загрузок и коллекции**: Скачивание оригиналов в высоком качестве, тематические папки-коллекции и автозагрузка избранного. - **🏷️ Мультивыбор платформ**: Выбор нескольких платформ (Patreon, Fanbox, Fantia, Boosty, Discord).
- **📜 Бесконечная подгрузка постов автора**: Умная пагинация при прокрутке вниз загружает всю историю работ.
- **🔍 Умный поиск и автодополнение**: Интерактивные чипсы тегов с подсказками от активных API.
- **🛡️ Продвинутый Blacklist / Whitelist**: Фильтрация по тегам, категориям, рейтингу контента и минимальному score.
- **🎬 Встроенный плеер**: Удобное воспроизведение WebM / MP4, запоминание звука, fullscreen и повтор.
- **💾 Менеджер загрузок и коллекции**: Скачивание оригиналов в высоком качестве и тематические папки-коллекции.
- **🔄 Встроенное автообновление**: Проверка и скачивание свежих релизов напрямую из личного репозитория Gitea. - **🔄 Встроенное автообновление**: Проверка и скачивание свежих релизов напрямую из личного репозитория Gitea.
--- ---
@@ -55,9 +69,9 @@
🔗 **[Gitea Releases (RarDog/Lunaris)](https://gitea.rardogsynapse.online/RarDog/Lunaris/releases)** 🔗 **[Gitea Releases (RarDog/Lunaris)](https://gitea.rardogsynapse.online/RarDog/Lunaris/releases)**
- **Android**: `Lunaris-v2.0.4.apk` - **Android**: `Lunaris-v2.0.9.apk`
- **Linux (x64)**: `Lunaris-v2.0.4-linux-x64.tar.gz` - **Linux (x64)**: `Lunaris-v2.0.9-linux-x64.tar.gz`
- **Windows**: `LunarisSetup-v2.0.4.exe` / `LunarisPortable-v2.0.4.zip` - **Windows**: `LunarisSetup-v2.0.9.exe` / `LunarisPortable-v2.0.9.zip`
--- ---
@@ -26,7 +26,8 @@ class MainActivity : FlutterActivity() {
val path = call.argument<String>("path") ?: error("Missing path") val path = call.argument<String>("path") ?: error("Missing path")
val fileName = call.argument<String>("fileName") ?: File(path).name val fileName = call.argument<String>("fileName") ?: File(path).name
val mimeType = call.argument<String>("mimeType") ?: "application/octet-stream" val mimeType = call.argument<String>("mimeType") ?: "application/octet-stream"
result.success(saveToDownloads(path, fileName, mimeType)) val subDir = call.argument<String>("subDir")
result.success(saveToDownloads(path, fileName, mimeType, subDir))
} }
"openFile" -> { "openFile" -> {
val path = call.argument<String>("path") ?: error("Missing path") val path = call.argument<String>("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 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) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val values = ContentValues().apply { val values = ContentValues().apply {
put(MediaStore.Downloads.DISPLAY_NAME, fileName) put(MediaStore.Downloads.DISPLAY_NAME, fileName)
put(MediaStore.Downloads.MIME_TYPE, mimeType) 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) put(MediaStore.Downloads.IS_PENDING, 1)
} }
val resolver = applicationContext.contentResolver val resolver = applicationContext.contentResolver
@@ -83,9 +90,10 @@ class MainActivity : FlutterActivity() {
} }
@Suppress("DEPRECATION") @Suppress("DEPRECATION")
val downloads = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS) val baseDownloads = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
downloads.mkdirs() val targetDir = if (!subDir.isNullOrBlank()) File(baseDownloads, subDir.trim()) else baseDownloads
val destination = File(downloads, fileName) targetDir.mkdirs()
val destination = File(targetDir, fileName)
FileInputStream(source).use { input -> FileInputStream(source).use { input ->
FileOutputStream(destination).use { output -> input.copyTo(output) } FileOutputStream(destination).use { output -> input.copyTo(output) }
} }
+24 -14
View File
@@ -1,5 +1,6 @@
import 'dart:io'; import 'dart:io';
import 'package:dynamic_color/dynamic_color.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
@@ -34,20 +35,29 @@ class GelRuleApp extends ConsumerWidget {
), ),
data: (_) { data: (_) {
final appSettings = settings.value ?? AppSettings.defaults; final appSettings = settings.value ?? AppSettings.defaults;
return MaterialApp.router( final amoled = appSettings.amoledMode;
title: 'Lunaris', final useDynamic = appSettings.useDynamicColor;
debugShowCheckedModeBanner: false, return DynamicColorBuilder(
theme: buildTheme( builder: (lightDynamic, darkDynamic) {
Brightness.light, return MaterialApp.router(
seedColor: appSettings.appSeedColor, title: 'Lunaris',
), debugShowCheckedModeBanner: false,
darkTheme: buildTheme( theme: buildTheme(
Brightness.dark, Brightness.light,
seedColor: appSettings.appSeedColor, seedColor: appSettings.appSeedColor,
), dynamicColorScheme: useDynamic ? lightDynamic : null,
themeMode: parseThemeMode(appSettings.themeMode), ),
routerConfig: router, darkTheme: buildTheme(
builder: (context, child) => _AppOverlay(child: child), Brightness.dark,
seedColor: appSettings.appSeedColor,
amoled: amoled,
dynamicColorScheme: useDynamic ? darkDynamic : null,
),
themeMode: parseThemeMode(appSettings.themeMode),
routerConfig: router,
builder: (context, child) => _AppOverlay(child: child),
);
},
); );
}, },
); );
+2 -2
View File
@@ -1,3 +1,3 @@
const appDisplayVersion = '2.0.4'; const appDisplayVersion = '2.0.9';
const appBuildNumber = 26; const appBuildNumber = 31;
const appReleaseChannel = 'Stable'; const appReleaseChannel = 'Stable';
+60
View File
@@ -1,4 +1,64 @@
const lunarisChangelog = [ const lunarisChangelog = [
LunarisChange(
version: '2.0.9',
title: 'Instant Tag Suggestions, Persistent Disk Cache, Expanded History & Match Highlighting',
bullets: [
'Instant 0ms tag suggestions: cached tags and search history are served immediately without network delay.',
'Persistent disk cache: tag suggestions are preserved in lunaris_tag_cache.json across app restarts on Android and PC.',
'Debounce reduced from 530ms to 100ms+160ms for ultra-responsive typing.',
'Match highlighting: matched characters are bolded and highlighted with the primary theme color in the dropdown.',
'Vibrant category badges: distinct colors for Artist (red), Character (green), Copyright (purple), Species (blue), and Meta (grey).',
'Danbooru tag autocomplete support connected (/tags/autocomplete.json).',
'Configurable search history limit (up to 2000 items) and tag cache limit (up to 20000 tags) in Settings.',
'Dedicated Clear Tag Cache actions in Settings and Cache Manager.',
],
),
LunarisChange(
version: '2.0.8',
title: 'Shared Context Modifiers for Multi-Tag Search & Clean Tag Filtering',
bullets: [
'Trailing context modifiers: typing "cat and dog anthro" automatically distributes "anthro" to all "and" branches (cat anthro + dog anthro).',
'Optional grouping parenthesis support: e.g. "(cat and dog) anthro".',
'Resolved "No posts yet" bug by adapting client post validation to multi-tag stream groups and stripping operator keywords from booru queries.',
],
),
LunarisChange(
version: '2.0.7',
title: 'Independent Multi-Tag Search Streams, Local "and" Connector, Pawchive in Main Feed & Reverts',
bullets: [
'Independent multi-tag search: separate tags query providers individually and interleave results so each tag brings its own content without mutual exclusion.',
'Local "and" tag connector: typing "and" between tags groups them into a strict intersection query (e.g. "genshin and raiden").',
'Enabled Pawchive in the main feed alongside Rule34, Gelbooru, e621, and Realbooru.',
'Restored instantaneous client-side media type filtering in artist posts.',
'Removed external linked accounts bar from artist posts screen.',
],
),
LunarisChange(
version: '2.0.6',
title: 'Pawchive Deep Integration: Global Posts Feed, Artist Tags, Announcements, Links & Server Media Filter',
bullets: [
'Connected Pawchive global recent posts (/api/v1/posts) to the main Feed screen with search.',
'Interactive artist tags bar (/tags) with live post counts (#tag (N)) and instant topic filtering.',
'Linked accounts bar (/links) with direct navigation to author profiles across platforms.',
'Artist announcements modal (/announcements) showing creator news, dates and status updates.',
'Server-side media filtering (Photos, Videos, GIFs) querying the entire author archive through the API.',
],
),
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( LunarisChange(
version: '2.0.4', version: '2.0.4',
title: 'Platform service selection & media type filtering in artist posts', title: 'Platform service selection & media type filtering in artist posts',
+5
View File
@@ -15,6 +15,7 @@ import '../features/providers/presentation/provider_check_screen.dart';
import '../features/providers/presentation/provider_form_screen.dart'; import '../features/providers/presentation/provider_form_screen.dart';
import '../features/providers/presentation/providers_screen.dart'; import '../features/providers/presentation/providers_screen.dart';
import '../features/search/presentation/search_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/hidden_posts_screen.dart';
import '../features/settings/presentation/settings_screen.dart'; import '../features/settings/presentation/settings_screen.dart';
import '../features/viewed/presentation/viewed_screen.dart'; import '../features/viewed/presentation/viewed_screen.dart';
@@ -126,6 +127,10 @@ final appRouterProvider = Provider<GoRouter>((ref) {
path: '/settings/hidden', path: '/settings/hidden',
builder: (context, state) => const HiddenPostsScreen(), builder: (context, state) => const HiddenPostsScreen(),
), ),
GoRoute(
path: '/settings/cache',
builder: (context, state) => const CacheManagerScreen(),
),
], ],
), ),
], ],
+37 -10
View File
@@ -1,26 +1,53 @@
import 'package:flutter/material.dart'; 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 dark = brightness == Brightness.dark;
final scheme = ColorScheme.fromSeed( final scheme = dynamicColorScheme ??
seedColor: Color(seedColor), ColorScheme.fromSeed(
brightness: brightness, 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( return ThemeData(
useMaterial3: true, useMaterial3: true,
brightness: brightness, brightness: brightness,
colorScheme: scheme, colorScheme: scheme,
scaffoldBackgroundColor: scaffoldBackgroundColor: scaffoldBg,
dark ? const Color(0xFF101114) : const Color(0xFFF8F8FA), cardColor: cardBg,
cardColor: dark ? const Color(0xFF191B20) : Colors.white,
navigationRailTheme: NavigationRailThemeData( navigationRailTheme: NavigationRailThemeData(
backgroundColor: dark ? const Color(0xFF15161A) : Colors.white, backgroundColor: navBg,
selectedIconTheme: IconThemeData(color: scheme.primary), selectedIconTheme: IconThemeData(color: scheme.primary),
), ),
appBarTheme: AppBarTheme( appBarTheme: AppBarTheme(
centerTitle: false, centerTitle: false,
elevation: 0, elevation: 0,
backgroundColor: dark ? const Color(0xFF101114) : const Color(0xFFF8F8FA), backgroundColor: appBarBg,
foregroundColor: scheme.onSurface, foregroundColor: scheme.onSurface,
), ),
cardTheme: CardThemeData( cardTheme: CardThemeData(
+5
View File
@@ -4,6 +4,9 @@ export 'models/content_provider_config.dart';
export 'models/app_update_info.dart'; export 'models/app_update_info.dart';
export 'models/artist_profile.dart'; export 'models/artist_profile.dart';
export 'models/artist_work_query.dart'; export 'models/artist_work_query.dart';
export 'models/artist_tag.dart';
export 'models/artist_link.dart';
export 'models/artist_announcement.dart';
export 'models/download_task.dart'; export 'models/download_task.dart';
export 'models/downloaded_media.dart'; export 'models/downloaded_media.dart';
export 'models/favorite.dart'; export 'models/favorite.dart';
@@ -25,8 +28,10 @@ export 'services/favorite_service.dart';
export 'services/feed_service.dart'; export 'services/feed_service.dart';
export 'services/provider_check_service.dart'; export 'services/provider_check_service.dart';
export 'services/search_service.dart'; export 'services/search_service.dart';
export 'services/tag_cache_service.dart';
export 'services/settings_service.dart'; export 'services/settings_service.dart';
export 'services/update_service.dart'; export 'services/update_service.dart';
export 'services/viewed_history_service.dart'; export 'services/viewed_history_service.dart';
export 'services/backup_service.dart';
export 'utils/smart_blacklist.dart'; export 'utils/smart_blacklist.dart';
export 'utils/media_quality.dart'; export 'utils/media_quality.dart';
+16
View File
@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -28,9 +30,11 @@ import '../services/favorite_service.dart';
import '../services/feed_service.dart'; import '../services/feed_service.dart';
import '../services/provider_check_service.dart'; import '../services/provider_check_service.dart';
import '../services/search_service.dart'; import '../services/search_service.dart';
import '../services/tag_cache_service.dart';
import '../services/settings_service.dart'; import '../services/settings_service.dart';
import '../services/update_service.dart'; import '../services/update_service.dart';
import '../services/viewed_history_service.dart'; import '../services/viewed_history_service.dart';
import '../services/backup_service.dart';
final appDatabaseProvider = FutureProvider<AppDatabase>((ref) async { final appDatabaseProvider = FutureProvider<AppDatabase>((ref) async {
final database = await AppDatabase.open(); final database = await AppDatabase.open();
@@ -124,10 +128,17 @@ final viewedKeysProvider = FutureProvider<Set<String>>((ref) async {
return result is Success<Set<String>> ? result.data : <String>{}; return result is Success<Set<String>> ? result.data : <String>{};
}); });
final tagCacheServiceProvider = Provider<TagCacheService>((ref) {
final service = TagCacheService();
unawaited(service.init());
return service;
});
final searchServiceProvider = Provider<SearchService>((ref) { final searchServiceProvider = Provider<SearchService>((ref) {
return SearchService( return SearchService(
ref.watch(searchRepositoryProvider), ref.watch(searchRepositoryProvider),
ref.watch(providerManagerProvider), ref.watch(providerManagerProvider),
ref.watch(tagCacheServiceProvider),
); );
}); });
@@ -169,6 +180,7 @@ final downloadManagerServiceProvider = Provider<DownloadManagerService>((ref) {
return DownloadManagerService( return DownloadManagerService(
ref.watch(downloadServiceProvider), ref.watch(downloadServiceProvider),
downloadedMediaService: ref.watch(downloadedMediaServiceProvider), downloadedMediaService: ref.watch(downloadedMediaServiceProvider),
settingsService: ref.watch(settingsServiceProvider),
); );
}); });
@@ -215,3 +227,7 @@ final updateServiceProvider = Provider<UpdateService>((ref) {
ref.watch(settingsServiceProvider), ref.watch(settingsServiceProvider),
); );
}); });
final backupServiceProvider = Provider<BackupService>((ref) {
return BackupService(ref.watch(settingsServiceProvider));
});
@@ -0,0 +1,35 @@
class ArtistAnnouncement {
const ArtistAnnouncement({
required this.service,
required this.userId,
required this.content,
this.hash,
this.added,
});
final String service;
final String userId;
final String content;
final String? hash;
final DateTime? added;
factory ArtistAnnouncement.fromJson(Map<String, dynamic> json) {
return ArtistAnnouncement(
service: (json['service'] ?? '').toString(),
userId: (json['user_id'] ?? '').toString(),
content: (json['content'] ?? '').toString(),
hash: json['hash']?.toString(),
added: json['added'] != null
? DateTime.tryParse(json['added'].toString())
: null,
);
}
Map<String, dynamic> toJson() => {
'service': service,
'user_id': userId,
'content': content,
if (hash != null) 'hash': hash,
if (added != null) 'added': added?.toIso8601String(),
};
}
+39
View File
@@ -0,0 +1,39 @@
class ArtistLink {
const ArtistLink({
required this.id,
required this.service,
required this.name,
this.publicId,
this.indexed,
this.updated,
});
final String id;
final String service;
final String name;
final String? publicId;
final DateTime? indexed;
final DateTime? updated;
factory ArtistLink.fromJson(Map<String, dynamic> json) {
return ArtistLink(
id: (json['id'] ?? '').toString(),
service: (json['service'] ?? '').toString(),
name: (json['name'] ?? '').toString(),
publicId: json['public_id']?.toString(),
indexed: json['indexed'] != null
? DateTime.tryParse(json['indexed'].toString())
: null,
updated: json['updated'] != null
? DateTime.tryParse(json['updated'].toString())
: null,
);
}
Map<String, dynamic> toJson() => {
'id': id,
'service': service,
'name': name,
if (publicId != null) 'public_id': publicId,
};
}
+21
View File
@@ -0,0 +1,21 @@
class ArtistTag {
const ArtistTag({
required this.tag,
required this.postCount,
});
final String tag;
final int postCount;
factory ArtistTag.fromJson(Map<String, dynamic> json) {
return ArtistTag(
tag: (json['tag'] ?? '').toString(),
postCount: (json['post_count'] as num?)?.toInt() ?? 0,
);
}
Map<String, dynamic> toJson() => {
'tag': tag,
'post_count': postCount,
};
}
+33 -2
View File
@@ -4,12 +4,34 @@ class ArtistWorkQuery {
required this.service, required this.service,
required this.artistId, required this.artistId,
required this.artistName, required this.artistName,
this.queryText,
this.tagFilter,
}); });
final String providerId; final String providerId;
final String service; final String service;
final String artistId; final String artistId;
final String artistName; final String artistName;
final String? queryText;
final String? tagFilter;
ArtistWorkQuery copyWith({
String? providerId,
String? service,
String? artistId,
String? artistName,
String? queryText,
String? tagFilter,
}) {
return ArtistWorkQuery(
providerId: providerId ?? this.providerId,
service: service ?? this.service,
artistId: artistId ?? this.artistId,
artistName: artistName ?? this.artistName,
queryText: queryText ?? this.queryText,
tagFilter: tagFilter ?? this.tagFilter,
);
}
@override @override
bool operator ==(Object other) { bool operator ==(Object other) {
@@ -17,9 +39,18 @@ class ArtistWorkQuery {
other.providerId == providerId && other.providerId == providerId &&
other.service == service && other.service == service &&
other.artistId == artistId && other.artistId == artistId &&
other.artistName == artistName; other.artistName == artistName &&
other.queryText == queryText &&
other.tagFilter == tagFilter;
} }
@override @override
int get hashCode => Object.hash(providerId, service, artistId, artistName); int get hashCode => Object.hash(
providerId,
service,
artistId,
artistName,
queryText,
tagFilter,
);
} }
+24
View File
@@ -22,6 +22,30 @@ class TagSuggestion {
final String providerId; final String providerId;
String get categoryLabel => category.name; String get categoryLabel => category.name;
Map<String, dynamic> toJson() => {
'name': name,
'category': category.name,
'postCount': postCount,
'providerId': providerId,
};
factory TagSuggestion.fromJson(Map<String, dynamic> json) => TagSuggestion(
name: (json['name'] ?? '').toString(),
category: tagCategoryFromString((json['category'] ?? '').toString()),
postCount: (json['postCount'] as num?)?.toInt() ?? 0,
providerId: (json['providerId'] ?? '').toString(),
);
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is TagSuggestion &&
runtimeType == other.runtimeType &&
name.toLowerCase() == other.name.toLowerCase();
@override
int get hashCode => name.toLowerCase().hashCode;
} }
TagCategory tagCategoryFromString(String? value) { TagCategory tagCategoryFromString(String? value) {
+16 -2
View File
@@ -1,10 +1,13 @@
import '../models/artist_announcement.dart';
import '../models/artist_link.dart';
import '../models/artist_profile.dart';
import '../models/artist_tag.dart';
import '../models/artist_work_query.dart';
import '../models/post.dart'; import '../models/post.dart';
import '../models/post_comment.dart'; import '../models/post_comment.dart';
import '../models/provider_health.dart'; import '../models/provider_health.dart';
import '../models/tag_suggestion.dart'; import '../models/tag_suggestion.dart';
import '../models/top_period_filter.dart'; import '../models/top_period_filter.dart';
import '../models/artist_profile.dart';
import '../models/artist_work_query.dart';
abstract class ContentProvider { abstract class ContentProvider {
String get id; String get id;
@@ -72,4 +75,15 @@ abstract class ArtistProvider {
String artistId, String artistId,
String postId, String postId,
); );
Future<List<ArtistTag>> getArtistTags(String service, String artistId) async =>
const [];
Future<List<ArtistLink>> getArtistLinks(
String service, String artistId) async =>
const [];
Future<List<ArtistAnnouncement>> getArtistAnnouncements(
String service, String artistId) async =>
const [];
} }
+51 -1
View File
@@ -5,11 +5,16 @@ import '../mappers/danbooru_mapper.dart';
import '../models/post.dart'; import '../models/post.dart';
import '../models/post_comment.dart'; import '../models/post_comment.dart';
import '../models/provider_health.dart'; import '../models/provider_health.dart';
import '../models/tag_suggestion.dart';
import '../models/top_period_filter.dart'; import '../models/top_period_filter.dart';
import 'content_provider.dart'; import 'content_provider.dart';
class DanbooruProvider class DanbooruProvider
implements ContentProvider, CommentProvider, PostPageProvider { implements
ContentProvider,
CommentProvider,
PostPageProvider,
TagSuggestionProvider {
DanbooruProvider({ DanbooruProvider({
required this.id, required this.id,
required this.name, required this.name,
@@ -171,4 +176,49 @@ class DanbooruProvider
'${value.month.toString().padLeft(2, '0')}-' '${value.month.toString().padLeft(2, '0')}-'
'${value.day.toString().padLeft(2, '0')}'; '${value.day.toString().padLeft(2, '0')}';
} }
@override
Future<List<TagSuggestion>> suggestTags(
String query, {
int limit = 20,
}) async {
final cleanQuery = query
.replaceAll(RegExp(r'^[\(\)]+|[\(\)]+$'), '')
.trim()
.toLowerCase();
if (cleanQuery.isEmpty) return const [];
try {
final response = await _dio.get<dynamic>(
'/tags/autocomplete.json',
queryParameters: {
'search[name_matches]': cleanQuery,
'limit': limit.clamp(1, 50),
..._queryParameters,
},
);
final items = response.data is List ? response.data as List : const [];
return items
.whereType<Map>()
.map((item) {
final json = Map<String, dynamic>.from(item);
final name = (json['value'] ?? json['name'] ?? '').toString();
return TagSuggestion(
name: name,
category: tagCategoryFromString(json['category']?.toString()),
postCount: _int(json['post_count']),
providerId: id,
);
})
.where((tag) => tag.name.isNotEmpty)
.toList(growable: false);
} catch (_) {
return const [];
}
}
int _int(dynamic value) {
if (value is int) return value;
if (value is num) return value.toInt();
return int.tryParse(value?.toString() ?? '') ?? 0;
}
} }
@@ -1,7 +1,10 @@
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import '../../core/http/dio_client.dart'; import '../../core/http/dio_client.dart';
import '../models/artist_announcement.dart';
import '../models/artist_link.dart';
import '../models/artist_profile.dart'; import '../models/artist_profile.dart';
import '../models/artist_tag.dart';
import '../models/artist_work_query.dart'; import '../models/artist_work_query.dart';
import '../models/post.dart'; import '../models/post.dart';
import '../models/post_comment.dart'; import '../models/post_comment.dart';
@@ -214,6 +217,21 @@ class KemonoProvider
return getArtistPostComments(parts[0], parts[1], parts[2]); return getArtistPostComments(parts[0], parts[1], parts[2]);
} }
@override
Future<List<ArtistTag>> getArtistTags(
String service, String artistId) async =>
const [];
@override
Future<List<ArtistLink>> getArtistLinks(
String service, String artistId) async =>
const [];
@override
Future<List<ArtistAnnouncement>> getArtistAnnouncements(
String service, String artistId) async =>
const [];
String _creatorPath() => _isCoomer ? '/coomer' : '/kemono'; String _creatorPath() => _isCoomer ? '/coomer' : '/kemono';
List<ArtistProfile> _artistsFromResponse(dynamic data) { List<ArtistProfile> _artistsFromResponse(dynamic data) {
+104 -2
View File
@@ -3,7 +3,10 @@ import 'dart:async';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import '../../core/http/dio_client.dart'; import '../../core/http/dio_client.dart';
import '../models/artist_announcement.dart';
import '../models/artist_link.dart';
import '../models/artist_profile.dart'; import '../models/artist_profile.dart';
import '../models/artist_tag.dart';
import '../models/artist_work_query.dart'; import '../models/artist_work_query.dart';
import '../models/post.dart'; import '../models/post.dart';
import '../models/post_comment.dart'; import '../models/post_comment.dart';
@@ -42,7 +45,34 @@ class PawchiveProvider
String? rating, String? rating,
TopPeriodFilter topPeriod = TopPeriodFilter.none, TopPeriodFilter topPeriod = TopPeriodFilter.none,
}) async { }) async {
return const []; final offset = (page - 1).clamp(0, 999999) * 50;
final queryText = tags.join(' ').trim();
try {
final response = await _dio.get<dynamic>(
'/api/v1/posts',
queryParameters: {
if (queryText.isNotEmpty) 'q': queryText,
'o': offset,
},
);
final posts = _postItems(response.data);
final mapped = <Post>[];
for (final post in posts.whereType<Map>()) {
final json = Map<String, dynamic>.from(post);
final service = (json['service'] ?? 'fanbox').toString();
final userId = (json['user'] ?? '').toString();
final dummyQuery = ArtistWorkQuery(
providerId: id,
service: service,
artistId: userId,
artistName: userId,
);
mapped.addAll(_postsFromArtistPost(json, query: dummyQuery));
}
return mapped;
} catch (_) {
return const [];
}
} }
@override @override
@@ -178,7 +208,13 @@ class PawchiveProvider
try { try {
final response = await _dio.get<dynamic>( final response = await _dio.get<dynamic>(
path, path,
queryParameters: {'o': offset}, queryParameters: {
'o': offset,
if (query.tagFilter != null && query.tagFilter!.isNotEmpty)
'tag': query.tagFilter,
if (query.queryText != null && query.queryText!.isNotEmpty)
'q': query.queryText,
},
); );
final posts = _postItems(response.data); final posts = _postItems(response.data);
final mapped = <Post>[]; final mapped = <Post>[];
@@ -216,6 +252,72 @@ class PawchiveProvider
} }
} }
@override
Future<List<ArtistTag>> getArtistTags(String service, String artistId) async {
try {
final response = await _dio.get<dynamic>(
'/api/v1/$service/user/$artistId/tags',
options: Options(
receiveTimeout: const Duration(seconds: 15),
sendTimeout: const Duration(seconds: 10),
),
);
final items = response.data;
if (items is List) {
return items
.whereType<Map>()
.map((item) => ArtistTag.fromJson(Map<String, dynamic>.from(item)))
.toList(growable: false);
}
} catch (_) {}
return const [];
}
@override
Future<List<ArtistLink>> getArtistLinks(
String service, String artistId) async {
try {
final response = await _dio.get<dynamic>(
'/api/v1/$service/user/$artistId/links',
options: Options(
receiveTimeout: const Duration(seconds: 15),
sendTimeout: const Duration(seconds: 10),
),
);
final items = response.data;
if (items is List) {
return items
.whereType<Map>()
.map((item) => ArtistLink.fromJson(Map<String, dynamic>.from(item)))
.toList(growable: false);
}
} catch (_) {}
return const [];
}
@override
Future<List<ArtistAnnouncement>> getArtistAnnouncements(
String service, String artistId) async {
try {
final response = await _dio.get<dynamic>(
'/api/v1/$service/user/$artistId/announcements',
options: Options(
receiveTimeout: const Duration(seconds: 15),
sendTimeout: const Duration(seconds: 10),
),
);
final items = response.data;
if (items is List) {
return items
.whereType<Map>()
.map((item) =>
ArtistAnnouncement.fromJson(Map<String, dynamic>.from(item)))
.toList(growable: false);
}
} catch (_) {}
return const [];
}
@override @override
Future<List<PostComment>> getComments(String postId) async { Future<List<PostComment>> getComments(String postId) async {
final parts = postId.split(':'); final parts = postId.split(':');
+144 -2
View File
@@ -163,6 +163,68 @@ class ProviderManager {
return Success(results); return Success(results);
} }
static List<List<String>> splitTagGroups(List<String> rawTags) {
final segments = <List<String>>[];
List<String> current = [];
for (final raw in rawTags) {
var token = raw.trim();
token = token.replaceAll(RegExp(r'^[\(\)]+|[\(\)]+$'), '').trim();
if (token.isEmpty) continue;
if (token.toLowerCase() == 'and') {
if (current.isNotEmpty) {
segments.add(current);
current = [];
}
} else {
current.add(token);
}
}
if (current.isNotEmpty) {
segments.add(current);
}
if (segments.isEmpty) {
return [const []];
}
if (segments.length == 1) {
return segments;
}
// Multiple segments separated by 'and'
// If the last segment has trailing tags after the branch tag (e.g. 'cat and dog anthro'):
// The trailing tags apply as common modifier tags to ALL branches!
final lastSegment = segments.last;
final prevSegment = segments[segments.length - 2];
final branchLen = prevSegment.length.clamp(1, lastSegment.length);
if (lastSegment.length > branchLen) {
final commonTail = lastSegment.sublist(branchLen);
final adjustedSegments = <List<String>>[];
for (int i = 0; i < segments.length; i++) {
final seg = i == segments.length - 1
? lastSegment
: [...segments[i], ...commonTail];
final seen = <String>{};
final deduped = <String>[];
for (final t in seg) {
if (seen.add(t.toLowerCase())) {
deduped.add(t);
}
}
adjustedSegments.add(deduped);
}
return adjustedSegments;
}
return segments;
}
Future<Result<List<Post>>> searchAcrossProviders({ Future<Result<List<Post>>> searchAcrossProviders({
required List<String> tags, required List<String> tags,
required int page, required int page,
@@ -181,12 +243,68 @@ class ProviderManager {
providers.where((provider) => provider.id == providerId).toList(); providers.where((provider) => provider.id == providerId).toList();
} }
final tagGroups = splitTagGroups(tags);
if (tagGroups.length <= 1) {
final effectiveTags = tagGroups.isEmpty ? tags : tagGroups.first;
return _searchForTagGroup(
providers: providers,
tags: effectiveTags,
page: page,
limit: limit,
rating: rating,
providerId: providerId,
topPeriod: topPeriod,
);
}
// Multiple independent tag groups: query each tag group as if other tags are not present, then interleave
final seen = <String>{};
final groupPerLimit = (limit / tagGroups.length).ceil().clamp(10, 50);
final groupFutures = tagGroups.map((groupTags) {
return _searchForTagGroup(
providers: providers,
tags: groupTags,
page: page,
limit: groupPerLimit,
rating: rating,
providerId: providerId,
topPeriod: topPeriod,
);
}).toList(growable: false);
final results = await Future.wait(groupFutures);
final validGroupPosts = <List<Post>>[];
for (final res in results) {
if (res is Success<List<Post>>) {
validGroupPosts.add(res.data);
}
}
final interleaved = _interleaveTagGroupResults(validGroupPosts, seen);
return Success(interleaved);
}
Future<Result<List<Post>>> _searchForTagGroup({
required List<ContentProvider> providers,
required List<String> tags,
required int page,
required int limit,
required String? rating,
required String? providerId,
required TopPeriodFilter topPeriod,
}) async {
final cleanTags = tags
.map((t) => t.trim())
.where((t) => t.isNotEmpty && t.toLowerCase() != 'and')
.toList(growable: false);
final seen = <String>{}; final seen = <String>{};
final providerResults = <List<Post>>[]; final providerResults = <List<Post>>[];
for (final provider in providers) { for (final provider in providers) {
try { try {
final providerPosts = await provider.searchPosts( final providerPosts = await provider.searchPosts(
tags: tags, tags: cleanTags,
page: page, page: page,
limit: limit, limit: limit,
rating: rating, rating: rating,
@@ -234,6 +352,29 @@ class ProviderManager {
return Success(posts); return Success(posts);
} }
List<Post> _interleaveTagGroupResults(
List<List<Post>> groupResults, Set<String> seen) {
final combined = <Post>[];
var hasMore = true;
var index = 0;
while (hasMore) {
hasMore = false;
for (final group in groupResults) {
if (index < group.length) {
hasMore = true;
final post = group[index];
if (seen.add(post.cacheKey)) {
combined.add(post);
}
}
}
index++;
}
return combined;
}
List<Post> _flattenProviderResults( List<Post> _flattenProviderResults(
List<List<Post>> results, Set<String> seen) { List<List<Post>> results, Set<String> seen) {
final posts = <Post>[]; final posts = <Post>[];
@@ -584,7 +725,8 @@ class ProviderManager {
} }
static bool _isFeedConfig(ContentProviderConfig config) { static bool _isFeedConfig(ContentProviderConfig config) {
return !_isArtistConfig(config); final type = config.apiType.toLowerCase();
return type != 'kemono' && type != 'coomer';
} }
static bool _isLegacyRemovedConfig(ContentProviderConfig config) { static bool _isLegacyRemovedConfig(ContentProviderConfig config) {
@@ -83,5 +83,11 @@ class DownloadedMediaRepository {
}); });
} }
Future<Result<int>> count() {
return _databaseService.safeRead((isar) async {
return isar.downloadedMediaEntitys.count();
});
}
String fileNameForPath(String path) => p.basename(path); String fileNameForPath(String path) => p.basename(path);
} }
@@ -10,7 +10,7 @@ class SearchRepository {
final DatabaseService _databaseService; final DatabaseService _databaseService;
Future<Result<void>> save(SearchHistory history) { Future<Result<void>> save(SearchHistory history, {int maxItems = 500}) {
return _databaseService.safeWrite((isar) async { return _databaseService.safeWrite((isar) async {
await isar.searchHistoryEntitys.put( await isar.searchHistoryEntitys.put(
SearchHistoryEntity() SearchHistoryEntity()
@@ -20,6 +20,21 @@ class SearchRepository {
..searchedAt = history.searchedAt ..searchedAt = history.searchedAt
..resultCount = history.resultCount, ..resultCount = history.resultCount,
); );
final count = await isar.searchHistoryEntitys.count();
if (count > maxItems) {
final items = await isar.searchHistoryEntitys.where().findAll();
items.sort((a, b) => b.searchedAt.compareTo(a.searchedAt));
final toRemove = items.skip(maxItems);
for (final item in toRemove) {
await isar.searchHistoryEntitys.delete(item.isarId);
}
}
});
}
Future<Result<int>> count() {
return _databaseService.safeRead((isar) async {
return await isar.searchHistoryEntitys.count();
}); });
} }
+87
View File
@@ -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<String> createBackupJson() async {
final result = await _settingsService.getSettings();
final settings =
result is Success<AppSettings> ? result.data : AppSettings.defaults;
const encoder = JsonEncoder.withIndent(' ');
return encoder.convert({
'version': 1,
'createdAt': DateTime.now().toIso8601String(),
'settings': settings.toJson(),
});
}
Future<Result<AppSettings>> restoreFromJson(String jsonContent) async {
try {
final decoded = jsonDecode(jsonContent);
if (decoded is! Map<String, dynamic>) {
return const Error(Failure(
code: 'invalid_json',
message: 'Неверный формат JSON файла',
));
}
final rawSettings = decoded['settings'] is Map<String, dynamic>
? decoded['settings'] as Map<String, dynamic>
: decoded;
final settings = AppSettings.fromJson(rawSettings);
await _settingsService.updateSettings(settings);
return Success(settings);
} catch (e) {
return Error(Failure(
code: 'import_error',
message: 'Ошибка импорта настроек: $e',
));
}
}
Future<bool> 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<Result<AppSettings>?> 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);
}
}
@@ -1,19 +1,24 @@
import 'dart:async'; import 'dart:async';
import '../../core/utils/result.dart';
import '../models/download_task.dart'; import '../models/download_task.dart';
import '../models/post.dart'; import '../models/post.dart';
import '../utils/media_quality.dart'; import '../utils/media_quality.dart';
import 'download_service.dart'; import 'download_service.dart';
import 'downloaded_media_service.dart'; import 'downloaded_media_service.dart';
import 'settings_service.dart';
class DownloadManagerService { class DownloadManagerService {
DownloadManagerService( DownloadManagerService(
this._downloadService, { this._downloadService, {
DownloadedMediaService? downloadedMediaService, DownloadedMediaService? downloadedMediaService,
}) : _downloadedMediaService = downloadedMediaService; SettingsService? settingsService,
}) : _downloadedMediaService = downloadedMediaService,
_settingsService = settingsService;
final DownloadService _downloadService; final DownloadService _downloadService;
final DownloadedMediaService? _downloadedMediaService; final DownloadedMediaService? _downloadedMediaService;
final SettingsService? _settingsService;
final _controller = StreamController<List<DownloadTask>>.broadcast(); final _controller = StreamController<List<DownloadTask>>.broadcast();
final Map<String, DownloadTask> _tasks = {}; final Map<String, DownloadTask> _tasks = {};
final Set<String> _runningTaskIds = {}; final Set<String> _runningTaskIds = {};
@@ -100,9 +105,17 @@ class DownloadManagerService {
if (!_runningTaskIds.add(task.id)) return; if (!_runningTaskIds.add(task.id)) return;
_set(task.copyWith(status: DownloadTaskStatus.running)); _set(task.copyWith(status: DownloadTaskStatus.running));
try { try {
String? folderTemplate;
if (_settingsService != null) {
final settingsRes = await _settingsService.getSettings();
if (settingsRes is Success<AppSettings>) {
folderTemplate = settingsRes.data.downloadPathTemplate;
}
}
final saved = task.sourceUrl == null final saved = task.sourceUrl == null
? await _downloadService.downloadPost( ? await _downloadService.downloadPost(
task.post!, task.post!,
folderTemplate: folderTemplate,
onProgress: (received, total) { onProgress: (received, total) {
_updateProgress(task.id, received, total); _updateProgress(task.id, received, total);
}, },
+92 -6
View File
@@ -1,7 +1,6 @@
import 'dart:io'; import 'dart:io';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:file_selector/file_selector.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
@@ -16,7 +15,54 @@ class DownloadService {
final Dio _dio; final Dio _dio;
Future<String?> 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<String> prepareFileForShare(
Post post, { Post post, {
void Function(int received, int total)? onProgress, void Function(int received, int total)? onProgress,
}) async { }) async {
@@ -25,6 +71,38 @@ class DownloadService {
throw StateError('No downloadable URL for this post.'); throw StateError('No downloadable URL for this post.');
} }
final fileName = _fileName(post, url); 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<String?> 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) { if (Platform.isAndroid) {
final tempDir = await getTemporaryDirectory(); final tempDir = await getTemporaryDirectory();
final tempPath = p.join(tempDir.path, fileName); final tempPath = p.join(tempDir.path, fileName);
@@ -38,19 +116,27 @@ class DownloadService {
'path': tempPath, 'path': tempPath,
'fileName': fileName, 'fileName': fileName,
'mimeType': _mimeType(fileName, post.fileType), 'mimeType': _mimeType(fileName, post.fileType),
if (subDir != null && subDir.isNotEmpty) 'subDir': subDir,
}); });
return saved ?? fileName; return saved ?? fileName;
} }
final location = await getSaveLocation(suggestedName: fileName); final downloads =
if (location == null) return null; 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( await _dio.download(
url, url,
location.path, savePath,
onReceiveProgress: onProgress, onReceiveProgress: onProgress,
options: Options(headers: _headersFor(post)), options: Options(headers: _headersFor(post)),
); );
return location.path; return savePath;
} }
Future<String?> downloadUrl( Future<String?> downloadUrl(
@@ -46,4 +46,6 @@ class DownloadedMediaService {
} }
Future<Result<void>> clearMissingFiles() => _repository.clearMissingFiles(); Future<Result<void>> clearMissingFiles() => _repository.clearMissingFiles();
Future<Result<int>> count() => _repository.count();
} }
+22 -13
View File
@@ -112,20 +112,29 @@ class FeedService {
} }
bool postMatchesRequestedTags(Post post, List<String> requestedTags) { bool postMatchesRequestedTags(Post post, List<String> requestedTags) {
final requested = requestedTags if (post.providerId == 'pawchive') return true;
.map((tag) => tag.trim().toLowerCase())
.where((tag) => tag.isNotEmpty && !tag.startsWith('-')) final groups = ProviderManager.splitTagGroups(requestedTags);
.toList(growable: false); if (groups.isEmpty) return true;
if (requested.isEmpty) return true;
final postTags = _postTagSet(post); final postTags = _postTagSet(post);
return requested.every( if (postTags.isEmpty) return true;
(requestedTag) => postTags.any(
(postTag) => return groups.any((group) {
postTag == requestedTag || final cleanGroup = group
postTag.startsWith('${requestedTag}_') || .map((tag) => tag.trim().toLowerCase())
postTag.startsWith('$requestedTag-'), .where((tag) => tag.isNotEmpty && !tag.startsWith('-') && tag != 'and')
), .toList(growable: false);
); if (cleanGroup.isEmpty) return true;
return cleanGroup.every(
(requestedTag) => postTags.any(
(postTag) =>
postTag == requestedTag ||
postTag.startsWith('${requestedTag}_') ||
postTag.startsWith('$requestedTag-'),
),
);
});
} }
bool postPassesTagFilters(Post post, AppSettings settings) { bool postPassesTagFilters(Post post, AppSettings settings) {
+125 -16
View File
@@ -1,43 +1,99 @@
import 'dart:async';
import '../../core/utils/result.dart'; import '../../core/utils/result.dart';
import '../models/search_history.dart'; import '../models/search_history.dart';
import '../models/tag_suggestion.dart'; import '../models/tag_suggestion.dart';
import '../providers/provider_manager.dart'; import '../providers/provider_manager.dart';
import '../repositories/search_repository.dart'; import '../repositories/search_repository.dart';
import 'tag_cache_service.dart';
class SearchService { class SearchService {
SearchService(this._repository, [this._providerManager]); SearchService(
this._repository, [
this._providerManager,
this._tagCacheService,
]);
final SearchRepository _repository; final SearchRepository _repository;
final ProviderManager? _providerManager; final ProviderManager? _providerManager;
final TagCacheService? _tagCacheService;
TagCacheService? get tagCacheService => _tagCacheService;
static String sanitizeToken(String raw) {
return raw
.replaceAll(RegExp(r'^[\(\)]+|[\(\)]+$'), '')
.trim()
.toLowerCase();
}
List<String> parseTags(String query) { List<String> parseTags(String query) {
return query return query
.trim() .trim()
.split(RegExp(r'\s+')) .split(RegExp(r'\s+'))
.where((tag) => tag.isNotEmpty) .map(sanitizeToken)
.where((tag) => tag.isNotEmpty && tag != 'and')
.toSet() .toSet()
.toList(); .toList();
} }
Future<Result<void>> saveSearch(String query, int resultCount) { Future<Result<void>> saveSearch(
String query,
int resultCount, {
int maxHistory = 500,
}) async {
final tags = parseTags(query); final tags = parseTags(query);
return _repository.save( final history = SearchHistory(
SearchHistory( id: '${DateTime.now().microsecondsSinceEpoch}:${tags.join(',')}',
id: '${DateTime.now().microsecondsSinceEpoch}:${tags.join(',')}', query: query.trim(),
query: query.trim(), tags: tags,
tags: tags, searchedAt: DateTime.now(),
searchedAt: DateTime.now(), resultCount: resultCount,
resultCount: resultCount,
),
); );
// Also register user-searched tags in persistent tag cache
if (_tagCacheService != null && tags.isNotEmpty) {
unawaited(
_tagCacheService.cacheTags(
tags.map(
(tag) => TagSuggestion(
name: tag,
category: TagCategory.general,
postCount: resultCount,
providerId: 'history',
),
),
),
);
}
return _repository.save(history, maxItems: maxHistory);
} }
Future<Result<List<SearchHistory>>> recentSearches({int? limit}) { Future<Result<List<SearchHistory>>> recentSearches({int? limit}) {
return _repository.recent(limit: limit); return _repository.recent(limit: limit);
} }
Future<Result<List<String>>> autocomplete(String prefix, /// Instant local tag suggestions (0 ms) from disk cache & recent search history.
{int limit = 10}) async { List<TagSuggestion> instantSuggestions(
String prefix, {
int limit = 16,
Iterable<String>? priorityTags,
}) {
final clean = sanitizeToken(prefix);
if (clean.isEmpty || clean == 'and') return const [];
return _tagCacheService?.findLocal(
clean,
limit: limit,
priorityTags: priorityTags,
) ??
const [];
}
Future<Result<List<String>>> autocomplete(
String prefix, {
int limit = 10,
}) async {
final result = await autocompleteDetailed(prefix, limit: limit); final result = await autocompleteDetailed(prefix, limit: limit);
return result.fold( return result.fold(
onSuccess: (items) => Success(items.map((item) => item.name).toList()), onSuccess: (items) => Success(items.map((item) => item.name).toList()),
@@ -47,12 +103,65 @@ class SearchService {
Future<Result<List<TagSuggestion>>> autocompleteDetailed( Future<Result<List<TagSuggestion>>> autocompleteDetailed(
String prefix, { String prefix, {
int limit = 10, int limit = 16,
int tagCacheLimit = 5000,
Iterable<String>? priorityTags,
}) async { }) async {
final clean = sanitizeToken(prefix);
if (clean.isEmpty || clean == 'and') return const Success([]);
final manager = _providerManager; final manager = _providerManager;
if (manager == null || prefix.trim().isEmpty) return const Success([]); if (manager == null) {
return manager.suggestTags(prefix.trim(), limit: limit); final local = instantSuggestions(clean, limit: limit, priorityTags: priorityTags);
return Success(local);
}
final netResult = await manager.suggestTags(clean, limit: limit);
if (netResult is Error<List<TagSuggestion>>) {
// Fallback to local cached suggestions if network fails
final local = instantSuggestions(clean, limit: limit, priorityTags: priorityTags);
return Success(local);
}
final remoteItems = (netResult as Success<List<TagSuggestion>>).data;
// Cache remote suggestions to disk
if (_tagCacheService != null && remoteItems.isNotEmpty) {
unawaited(_tagCacheService.cacheTags(remoteItems, maxLimit: tagCacheLimit));
}
// Merge local matches with remote results
final merged = <String, TagSuggestion>{};
for (final item in instantSuggestions(clean, limit: limit, priorityTags: priorityTags)) {
merged[item.name.toLowerCase()] = item;
}
for (final item in remoteItems) {
final key = item.name.toLowerCase();
final existing = merged[key];
if (existing == null || item.postCount > existing.postCount) {
merged[key] = item;
}
}
final sorted = merged.values.toList()
..sort((a, b) {
final aKey = a.name.toLowerCase();
final bKey = b.name.toLowerCase();
final aExact = aKey == clean;
final bExact = bKey == clean;
if (aExact != bExact) return aExact ? -1 : 1;
final countCmp = b.postCount.compareTo(a.postCount);
if (countCmp != 0) return countCmp;
return aKey.compareTo(bKey);
});
return Success(sorted.take(limit).toList(growable: false));
} }
Future<Result<void>> clearHistory() => _repository.clear(); Future<Result<void>> clearHistory() => _repository.clear();
Future<void> clearTagCache() async {
await _tagCacheService?.clear();
}
} }
@@ -53,6 +53,13 @@ class AppSettings {
this.lastUpdateCheckAt, this.lastUpdateCheckAt,
this.defaultRatingFilter, this.defaultRatingFilter,
this.lastFeedRating, this.lastFeedRating,
this.amoledMode = false,
this.useDynamicColor = false,
this.gridMode = 'masonry',
this.searchPresets = const [],
this.downloadPathTemplate = '{Artist}/{ID}',
this.searchHistoryLimit = 500,
this.tagCacheLimit = 5000,
}); });
final List<String> favoriteArtists; final List<String> favoriteArtists;
@@ -96,6 +103,13 @@ class AppSettings {
final String? lastUpdateCheckAt; final String? lastUpdateCheckAt;
final String? defaultRatingFilter; final String? defaultRatingFilter;
final String? lastFeedRating; final String? lastFeedRating;
final bool amoledMode;
final bool useDynamicColor;
final String gridMode;
final List<String> searchPresets;
final String downloadPathTemplate;
final int searchHistoryLimit;
final int tagCacheLimit;
static const defaults = AppSettings( static const defaults = AppSettings(
enabledProviderIds: ['gelbooru', 'rule34', 'realbooru'], enabledProviderIds: ['gelbooru', 'rule34', 'realbooru'],
@@ -139,6 +153,8 @@ class AppSettings {
lastUpdateCheckAt: null, lastUpdateCheckAt: null,
defaultRatingFilter: null, defaultRatingFilter: null,
lastFeedRating: null, lastFeedRating: null,
searchHistoryLimit: 500,
tagCacheLimit: 5000,
); );
AppSettings copyWith({ AppSettings copyWith({
@@ -184,6 +200,13 @@ class AppSettings {
String? lastFeedRating, String? lastFeedRating,
bool clearLastFeedRating = false, bool clearLastFeedRating = false,
List<String>? favoriteArtists, List<String>? favoriteArtists,
bool? amoledMode,
bool? useDynamicColor,
String? gridMode,
List<String>? searchPresets,
String? downloadPathTemplate,
int? searchHistoryLimit,
int? tagCacheLimit,
}) { }) {
return AppSettings( return AppSettings(
favoriteArtists: favoriteArtists ?? this.favoriteArtists, favoriteArtists: favoriteArtists ?? this.favoriteArtists,
@@ -234,6 +257,13 @@ class AppSettings {
defaultRatingFilter: defaultRatingFilter ?? this.defaultRatingFilter, defaultRatingFilter: defaultRatingFilter ?? this.defaultRatingFilter,
lastFeedRating: lastFeedRating:
clearLastFeedRating ? null : lastFeedRating ?? this.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,
searchHistoryLimit: searchHistoryLimit ?? this.searchHistoryLimit,
tagCacheLimit: tagCacheLimit ?? this.tagCacheLimit,
); );
} }
@@ -279,6 +309,13 @@ class AppSettings {
'lastUpdateCheckAt': lastUpdateCheckAt, 'lastUpdateCheckAt': lastUpdateCheckAt,
'defaultRatingFilter': defaultRatingFilter, 'defaultRatingFilter': defaultRatingFilter,
'lastFeedRating': lastFeedRating, 'lastFeedRating': lastFeedRating,
'amoledMode': amoledMode,
'useDynamicColor': useDynamicColor,
'gridMode': gridMode,
'searchPresets': searchPresets,
'downloadPathTemplate': downloadPathTemplate,
'searchHistoryLimit': searchHistoryLimit,
'tagCacheLimit': tagCacheLimit,
}; };
factory AppSettings.fromJson(Map<String, dynamic> json) => AppSettings( factory AppSettings.fromJson(Map<String, dynamic> json) => AppSettings(
@@ -378,6 +415,18 @@ class AppSettings {
lastUpdateCheckAt: json['lastUpdateCheckAt'] as String?, lastUpdateCheckAt: json['lastUpdateCheckAt'] as String?,
defaultRatingFilter: json['defaultRatingFilter'] as String?, defaultRatingFilter: json['defaultRatingFilter'] as String?,
lastFeedRating: json['lastFeedRating'] 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<String>.from(
(json['searchPresets'] as List?) ?? const [],
),
downloadPathTemplate:
(json['downloadPathTemplate'] as String?) ?? '{Artist}/{ID}',
searchHistoryLimit: (json['searchHistoryLimit'] as num?)?.toInt() ??
defaults.searchHistoryLimit,
tagCacheLimit: (json['tagCacheLimit'] as num?)?.toInt() ??
defaults.tagCacheLimit,
); );
} }
+205
View File
@@ -0,0 +1,205 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:path_provider/path_provider.dart';
import '../models/tag_suggestion.dart';
class TagCacheService {
TagCacheService({String? customPath}) : _customPath = customPath;
final String? _customPath;
final Map<String, TagSuggestion> _tags = {};
bool _isInitialized = false;
Timer? _saveDebounce;
String? _resolvedPath;
bool get isInitialized => _isInitialized;
int get tagCount => _tags.length;
Future<String> _getCacheFilePath() async {
if (_resolvedPath != null) return _resolvedPath!;
if (_customPath != null) {
_resolvedPath = _customPath;
return _resolvedPath!;
}
final dir = await getApplicationDocumentsDirectory();
_resolvedPath = '${dir.path}/lunaris_tag_cache.json';
return _resolvedPath!;
}
Future<void> init() async {
if (_isInitialized) return;
try {
final path = await _getCacheFilePath();
final file = File(path);
if (await file.exists()) {
final content = await file.readAsString();
if (content.trim().isNotEmpty) {
final decoded = jsonDecode(content);
if (decoded is List) {
for (final item in decoded) {
if (item is Map) {
final suggestion = TagSuggestion.fromJson(
Map<String, dynamic>.from(item),
);
final key = suggestion.name.trim().toLowerCase();
if (key.isNotEmpty) {
_tags[key] = suggestion;
}
}
}
}
}
}
} catch (_) {
// Non-critical: corrupted cache file can be rebuilt seamlessly
} finally {
_isInitialized = true;
}
}
/// Finds suggestions locally in memory (0 ms response).
List<TagSuggestion> findLocal(
String prefix, {
int limit = 16,
Iterable<String>? priorityTags,
}) {
final cleanPrefix = prefix
.replaceAll(RegExp(r'^[\(\)]+|[\(\)]+$'), '')
.trim()
.toLowerCase();
if (cleanPrefix.isEmpty) return const [];
final prioritySet = priorityTags
?.map((t) => t.trim().toLowerCase())
.where((t) => t.isNotEmpty)
.toSet() ??
const <String>{};
final matches = <TagSuggestion>[];
final seen = <String>{};
// First check priority tags (e.g. from recent search history)
for (final tag in prioritySet) {
if (tag.startsWith(cleanPrefix) && !seen.contains(tag)) {
seen.add(tag);
final existing = _tags[tag];
matches.add(
existing ??
TagSuggestion(
name: tag,
category: TagCategory.general,
postCount: 0,
providerId: 'history',
),
);
}
}
// Then check all cached tags
for (final entry in _tags.entries) {
if (seen.contains(entry.key)) continue;
if (entry.key.startsWith(cleanPrefix)) {
seen.add(entry.key);
matches.add(entry.value);
}
}
matches.sort((a, b) {
final aKey = a.name.toLowerCase();
final bKey = b.name.toLowerCase();
final aExact = aKey == cleanPrefix;
final bExact = bKey == cleanPrefix;
if (aExact != bExact) return aExact ? -1 : 1;
final aPriority = prioritySet.contains(aKey);
final bPriority = prioritySet.contains(bKey);
if (aPriority != bPriority) return aPriority ? -1 : 1;
final countCmp = b.postCount.compareTo(a.postCount);
if (countCmp != 0) return countCmp;
return aKey.compareTo(bKey);
});
return matches.take(limit.clamp(1, 50)).toList(growable: false);
}
/// Caches newly discovered suggestions and debounces saving to disk.
Future<void> cacheTags(
Iterable<TagSuggestion> suggestions, {
int maxLimit = 5000,
}) async {
if (!_isInitialized) await init();
var changed = false;
for (final item in suggestions) {
final key = item.name.trim().toLowerCase();
if (key.isEmpty) continue;
final existing = _tags[key];
if (existing == null || item.postCount > existing.postCount) {
_tags[key] = item;
changed = true;
}
}
if (!changed) return;
// Prune least popular tags if exceeding max limit
if (_tags.length > maxLimit) {
final sorted = _tags.values.toList()
..sort((a, b) => b.postCount.compareTo(a.postCount));
_tags.clear();
for (final item in sorted.take(maxLimit)) {
_tags[item.name.toLowerCase()] = item;
}
}
_scheduleSave();
}
void _scheduleSave() {
_saveDebounce?.cancel();
_saveDebounce = Timer(const Duration(milliseconds: 1200), () {
flush();
});
}
Future<void> flush() async {
_saveDebounce?.cancel();
try {
final path = await _getCacheFilePath();
final file = File(path);
final list = _tags.values.map((t) => t.toJson()).toList(growable: false);
final jsonString = jsonEncode(list);
await file.writeAsString(jsonString, flush: true);
} catch (_) {
// Silent error on persistence failure
}
}
Future<int> getFileSizeBytes() async {
try {
final path = await _getCacheFilePath();
final file = File(path);
if (await file.exists()) {
return await file.length();
}
} catch (_) {}
return 0;
}
Future<void> clear() async {
_saveDebounce?.cancel();
_tags.clear();
try {
final path = await _getCacheFilePath();
final file = File(path);
if (await file.exists()) {
await file.delete();
}
} catch (_) {}
}
}
+6
View File
@@ -62,6 +62,12 @@ class CacheService {
}); });
} }
Future<Result<int>> getCachedPostsCount() {
return _databaseService.safeRead((isar) async {
return isar.cachedPostEntitys.count();
});
}
Future<void> _evictOverflow(Isar isar, int maxItems) async { Future<void> _evictOverflow(Isar isar, int maxItems) async {
final entities = await isar.cachedPostEntitys.where().findAll(); final entities = await isar.cachedPostEntitys.where().findAll();
if (entities.length <= maxItems) return; if (entities.length <= maxItems) return;
@@ -47,6 +47,11 @@ class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
final _scrollController = ScrollController(); final _scrollController = ScrollController();
final List<Post> _posts = []; final List<Post> _posts = [];
final Set<String> _selectedTypes = <String>{}; final Set<String> _selectedTypes = <String>{};
List<ArtistTag> _tags = [];
List<ArtistAnnouncement> _announcements = [];
String? _selectedTag;
int _page = 0; int _page = 0;
bool _loading = false; bool _loading = false;
bool _hasMore = true; bool _hasMore = true;
@@ -60,7 +65,10 @@ class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
_loadMore(); _loadMore();
} }
}); });
WidgetsBinding.instance.addPostFrameCallback((_) => _loadMore()); WidgetsBinding.instance.addPostFrameCallback((_) {
_loadMore();
_loadMetadata();
});
} }
@override @override
@@ -69,6 +77,28 @@ class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
super.dispose(); super.dispose();
} }
Future<void> _loadMetadata() async {
try {
final providers =
await ref.read(providerManagerProvider).activeArtistProviders();
if (providers is! Success<List<ArtistProvider>>) return;
final provider = providers.data.firstWhere(
(item) => (item as ContentProvider).id == widget.providerId,
);
final tagsRes =
await provider.getArtistTags(widget.service, widget.artistId);
final annRes =
await provider.getArtistAnnouncements(widget.service, widget.artistId);
if (!mounted) return;
setState(() {
_tags = tagsRes;
_announcements = annRes;
});
} catch (_) {}
}
Future<void> _refresh() async { Future<void> _refresh() async {
setState(() { setState(() {
_posts.clear(); _posts.clear();
@@ -76,7 +106,7 @@ class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
_hasMore = true; _hasMore = true;
_error = null; _error = null;
}); });
await _loadMore(); await Future.wait([_loadMore(), _loadMetadata()]);
} }
Future<void> _loadMore() async { Future<void> _loadMore() async {
@@ -91,6 +121,7 @@ class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
service: widget.service, service: widget.service,
artistId: widget.artistId, artistId: widget.artistId,
artistName: widget.artistName, artistName: widget.artistName,
tagFilter: _selectedTag,
); );
try { try {
@@ -139,6 +170,18 @@ class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
} }
} }
void _onTagSelected(String? tag) {
if (_selectedTag == tag) return;
setState(() {
_selectedTag = tag;
_posts.clear();
_page = 0;
_hasMore = true;
_error = null;
});
_loadMore();
}
bool _matchesTypeFilter(Post post) { bool _matchesTypeFilter(Post post) {
if (_selectedTypes.isEmpty) return true; if (_selectedTypes.isEmpty) return true;
final isVid = MediaUrlSelector.isVideo(post); final isVid = MediaUrlSelector.isVideo(post);
@@ -151,6 +194,70 @@ class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
return false; return false;
} }
void _showAnnouncements() {
final theme = Theme.of(context);
showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
showDragHandle: true,
builder: (ctx) => DraggableScrollableSheet(
expand: false,
initialChildSize: 0.6,
maxChildSize: 0.9,
builder: (_, scroll) => Column(
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
child: Row(
children: [
Icon(Icons.campaign_rounded, color: theme.colorScheme.primary),
const SizedBox(width: 10),
Text(
'Анонсы автора (${_announcements.length})',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
],
),
),
const Divider(),
Expanded(
child: ListView.separated(
controller: scroll,
padding: const EdgeInsets.all(16),
itemCount: _announcements.length,
separatorBuilder: (_, __) => const Divider(height: 24),
itemBuilder: (context, index) {
final a = _announcements[index];
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (a.added != null)
Text(
'${a.added!.year}-${a.added!.month.toString().padLeft(2, '0')}-${a.added!.day.toString().padLeft(2, '0')}',
style: TextStyle(
fontSize: 12,
color: theme.colorScheme.primary,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 6),
SelectableText(
a.content,
style: theme.textTheme.bodyMedium,
),
],
);
},
),
),
],
),
),
);
}
Widget _buildMediaTypeFilters() { Widget _buildMediaTypeFilters() {
final scheme = Theme.of(context).colorScheme; final scheme = Theme.of(context).colorScheme;
final photoCount = _posts final photoCount = _posts
@@ -160,7 +267,7 @@ class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
final gifCount = _posts.where(MediaUrlSelector.isGif).length; final gifCount = _posts.where(MediaUrlSelector.isGif).length;
return Padding( return Padding(
padding: const EdgeInsets.fromLTRB(16, 6, 16, 6), padding: const EdgeInsets.fromLTRB(16, 6, 16, 4),
child: SingleChildScrollView( child: SingleChildScrollView(
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
child: Row( child: Row(
@@ -233,6 +340,37 @@ class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
); );
} }
Widget _buildTagsBar() {
if (_tags.isEmpty) return const SizedBox.shrink();
return Padding(
padding: const EdgeInsets.fromLTRB(16, 2, 16, 6),
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
if (_selectedTag != null) ...[
InputChip(
selected: true,
avatar: const Icon(Icons.close_rounded, size: 16),
label: Text('#$_selectedTag'),
onSelected: (_) => _onTagSelected(null),
),
const SizedBox(width: 8),
],
for (final tag in _tags)
if (tag.tag != _selectedTag) ...[
ActionChip(
label: Text('#${tag.tag} (${tag.postCount})'),
onPressed: () => _onTagSelected(tag.tag),
),
const SizedBox(width: 8),
],
],
),
),
);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final settings = final settings =
@@ -240,13 +378,23 @@ class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
final favoriteKeys = ref.watch(favoriteKeysProvider).value ?? <String>{}; final favoriteKeys = ref.watch(favoriteKeysProvider).value ?? <String>{};
final viewedKeys = ref.watch(viewedKeysProvider).value ?? <String>{}; final viewedKeys = ref.watch(viewedKeysProvider).value ?? <String>{};
final displayedPosts = _posts.where(_matchesTypeFilter).toList(growable: false); final displayedPosts =
_posts.where(_matchesTypeFilter).toList(growable: false);
return AdaptiveScaffold( return AdaptiveScaffold(
title: widget.artistName, title: widget.artistName,
actions: [ actions: [
if (_announcements.isNotEmpty)
IconButton(
tooltip: 'Анонсы автора',
onPressed: _showAnnouncements,
icon: Badge.count(
count: _announcements.length,
child: const Icon(Icons.campaign_rounded),
),
),
IconButton( IconButton(
tooltip: 'Refresh', tooltip: 'Обновить',
onPressed: _refresh, onPressed: _refresh,
icon: const Icon(Icons.refresh_rounded), icon: const Icon(Icons.refresh_rounded),
), ),
@@ -259,13 +407,21 @@ class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
onRetry: _refresh, onRetry: _refresh,
) )
: _posts.isEmpty : _posts.isEmpty
? const EmptyView( ? Column(
title: 'No works', children: [
message: 'This artist has no visible media yet.', _buildTagsBar(),
const Expanded(
child: EmptyView(
title: 'Нет работ',
message: 'У этого автора пока нет видимых постов.',
),
),
],
) )
: Column( : Column(
children: [ children: [
_buildMediaTypeFilters(), _buildMediaTypeFilters(),
_buildTagsBar(),
Expanded( Expanded(
child: displayedPosts.isEmpty child: displayedPosts.isEmpty
? Center( ? Center(
@@ -274,7 +430,8 @@ class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
const Icon(Icons.filter_alt_off_rounded, const Icon(
Icons.filter_alt_off_rounded,
size: 48), size: 48),
const SizedBox(height: 12), const SizedBox(height: 12),
const Text( const Text(
@@ -284,9 +441,13 @@ class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
FilledButton.tonal( FilledButton.tonal(
onPressed: () => setState( onPressed: () {
() => _selectedTypes.clear()), setState(() {
child: const Text('Показать все'), _selectedTypes.clear();
_selectedTag = null;
});
},
child: const Text('Сбросить фильтры'),
), ),
], ],
), ),
@@ -296,6 +457,7 @@ class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
posts: displayedPosts, posts: displayedPosts,
controller: _scrollController, controller: _scrollController,
loading: _loading, loading: _loading,
gridMode: settings.gridMode,
columns: Responsive.columnsFor( columns: Responsive.columnsFor(
context, context,
mobileColumns: settings.mobileColumns, mobileColumns: settings.mobileColumns,
@@ -332,7 +494,8 @@ class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
); );
} }
ref.invalidate(favoriteKeysProvider); ref.invalidate(favoriteKeysProvider);
ref.invalidate(favoritesControllerProvider); ref.invalidate(
favoritesControllerProvider);
}, },
), ),
), ),
@@ -345,7 +508,7 @@ class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
final message = error.toString(); final message = error.toString();
if (message.contains('HandshakeException') || if (message.contains('HandshakeException') ||
message.contains('artist works are unavailable')) { message.contains('artist works are unavailable')) {
return 'Artist works are unavailable from this provider right now. Try again later or choose another artist/provider.'; return 'Работы автора временно недоступны. Попробуйте обновить позже.';
} }
return message; return message;
} }
@@ -120,35 +120,56 @@ class FeedController extends AsyncNotifier<FeedState> {
), ),
); );
await saveSession(scrollOffset: 0); await saveSession(scrollOffset: 0);
await ref.read(searchServiceProvider).saveSearch(query, 0); final settingsRes = await ref.read(settingsServiceProvider).getSettings();
final settings =
settingsRes is Success<AppSettings> ? settingsRes.data : AppSettings.defaults;
await ref
.read(searchServiceProvider)
.saveSearch(query, 0, maxHistory: settings.searchHistoryLimit);
await refresh(); await refresh();
final count = state.value?.posts.length ?? 0; final count = state.value?.posts.length ?? 0;
await ref.read(searchServiceProvider).saveSearch(query, count); await ref
.read(searchServiceProvider)
.saveSearch(query, count, maxHistory: settings.searchHistoryLimit);
} }
Future<void> updateTagSuggestions(String query) async { Future<void> updateTagSuggestions(String query) async {
final current = state.value ?? const FeedState(); final current = state.value ?? const FeedState();
final lastToken = final rawLast =
query.trim().isEmpty ? '' : query.trim().split(RegExp(r'\s+')).last; query.trim().isEmpty ? '' : query.trim().split(RegExp(r'\s+')).last;
final cleanToken = SearchService.sanitizeToken(rawLast);
final requestId = ++_suggestionRequestId; final requestId = ++_suggestionRequestId;
_suggestionDebounce?.cancel(); _suggestionDebounce?.cancel();
if (lastToken.trim().isEmpty) {
if (cleanToken.isEmpty || cleanToken == 'and') {
state = AsyncData(current.copyWith(tagSuggestions: [])); state = AsyncData(current.copyWith(tagSuggestions: []));
return; return;
} }
_suggestionDebounce = Timer(const Duration(milliseconds: 280), () async {
// Instant local suggestions from disk cache & history (0 ms)
final localMatches = ref
.read(searchServiceProvider)
.instantSuggestions(cleanToken, limit: 16);
if (localMatches.isNotEmpty) {
state = AsyncData(current.copyWith(tagSuggestions: localMatches));
}
_suggestionDebounce = Timer(const Duration(milliseconds: 160), () async {
final settingsRes = await ref.read(settingsServiceProvider).getSettings();
final appSettings = settingsRes is Success<AppSettings>
? settingsRes.data
: AppSettings.defaults;
final result = await ref final result = await ref
.read(searchServiceProvider) .read(searchServiceProvider)
.autocompleteDetailed(lastToken, limit: 16); .autocompleteDetailed(
cleanToken,
limit: 16,
tagCacheLimit: appSettings.tagCacheLimit,
);
if (requestId != _suggestionRequestId) return; if (requestId != _suggestionRequestId) return;
final normalizedToken = lastToken.trim().toLowerCase();
final suggestions = result is Success<List<TagSuggestion>> final suggestions = result is Success<List<TagSuggestion>>
? result.data ? result.data
.where((item) => item.name.toLowerCase().startsWith( : localMatches;
normalizedToken,
))
.toList(growable: false)
: const <TagSuggestion>[];
final latest = state.value ?? current; final latest = state.value ?? current;
state = AsyncData(latest.copyWith(tagSuggestions: suggestions)); state = AsyncData(latest.copyWith(tagSuggestions: suggestions));
}); });
@@ -1,3 +1,4 @@
import 'dart:convert';
import 'dart:math'; import 'dart:math';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
@@ -17,6 +18,7 @@ import '../../../shared/widgets/post_card.dart';
import '../../../shared/widgets/post_masonry_grid.dart'; import '../../../shared/widgets/post_masonry_grid.dart';
import '../../collections/presentation/collection_form_dialog.dart'; import '../../collections/presentation/collection_form_dialog.dart';
import '../../favorites/presentation/favorites_controller.dart'; import '../../favorites/presentation/favorites_controller.dart';
import '../../settings/presentation/settings_controller.dart';
import 'feed_controller.dart'; import 'feed_controller.dart';
import 'widgets/feed_toolbar.dart'; import 'widgets/feed_toolbar.dart';
@@ -179,6 +181,20 @@ class _FeedScreenState extends ConsumerState<FeedScreen>
child: AdaptiveScaffold( child: AdaptiveScaffold(
title: 'Feed', title: 'Feed',
titleWidget: const _FeedTitle(), 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: _showScrollToTop
? FloatingActionButton.small( ? FloatingActionButton.small(
tooltip: 'Наверх', tooltip: 'Наверх',
@@ -201,6 +217,26 @@ class _FeedScreenState extends ConsumerState<FeedScreen>
), ),
data: (state) => Column( data: (state) => Column(
children: [ 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( FeedToolbar(
selectedTags: state.selectedTags, selectedTags: state.selectedTags,
selectedProviderIds: state.selectedProviderIds, selectedProviderIds: state.selectedProviderIds,
@@ -294,6 +330,7 @@ class _FeedScreenState extends ConsumerState<FeedScreen>
viewedKeys: viewedKeys, viewedKeys: viewedKeys,
selectionMode: _selectionMode, selectionMode: _selectionMode,
selectedKeys: _selectedKeys, selectedKeys: _selectedKeys,
gridMode: settings.gridMode,
onOpen: (post) => context.push( onOpen: (post) => context.push(
'/post/${post.providerId}/${post.id}', '/post/${post.providerId}/${post.id}',
extra: PostNavigationContext( extra: PostNavigationContext(
@@ -527,6 +564,75 @@ class _FeedScreenState extends ConsumerState<FeedScreen>
if (!settings.autoDownloadFavorites || !settings.allowDownloads) return; if (!settings.autoDownloadFavorites || !settings.allowDownloads) return;
await ref.read(downloadManagerServiceProvider).start(post); 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<void> _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<String, String> _decodePreset(String raw) {
try {
final map = jsonDecode(raw) as Map<String, dynamic>;
return {'name': map['name'] as String, 'tags': map['tags'] as String};
} catch (_) {
return {'name': raw, 'tags': raw};
}
}
Future<String?> _showSavePresetDialog(
BuildContext context, String tags) async {
final ctrl = TextEditingController(text: tags);
return showDialog<String>(
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<void> _saveCurrentPreset(
BuildContext context, List<String> 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 { 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<String> presets;
final List<String> currentTags;
final ValueChanged<String> onApply;
final ValueChanged<String> onDelete;
Map<String, String> _decode(String raw) {
try {
final map = jsonDecode(raw) as Map<String, dynamic>;
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<void>(
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,
),
),
);
},
),
);
}
}
@@ -4,6 +4,7 @@ import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:cached_network_image/cached_network_image.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 'package:url_launcher/url_launcher.dart';
import '../../../app/app.dart'; import '../../../app/app.dart';
@@ -269,6 +270,7 @@ class PostDetailsScreen extends ConsumerWidget {
: null, : null,
onDeleteLocalFile: () => onDeleteLocalFile: () =>
_deleteLocalFile(context, ref, post), _deleteLocalFile(context, ref, post),
onShare: () => _sharePost(context, ref, post),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
Wrap( Wrap(
@@ -377,6 +379,7 @@ class PostDetailsScreen extends ConsumerWidget {
? () => _download(context, ref, post) ? () => _download(context, ref, post)
: null, : null,
onDeleteLocalFile: () => _deleteLocalFile(context, ref, post), onDeleteLocalFile: () => _deleteLocalFile(context, ref, post),
onShare: () => _sharePost(context, ref, post),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
Wrap( Wrap(
@@ -448,6 +451,30 @@ class PostDetailsScreen extends ConsumerWidget {
}); });
} }
Future<void> _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<void> _showMobileQuickActions( Future<void> _showMobileQuickActions(
BuildContext context, BuildContext context,
WidgetRef ref, WidgetRef ref,
@@ -558,6 +585,7 @@ class PostDetailsScreen extends ConsumerWidget {
download: strings.download, download: strings.download,
deleteLocalFile: strings.deleteLocalFile, deleteLocalFile: strings.deleteLocalFile,
hidePost: strings.hidePost, hidePost: strings.hidePost,
share: strings.ru ? 'Поделиться' : 'Share',
); );
} }
@@ -10,6 +10,7 @@ class PostActionBar extends StatelessWidget {
this.onOpenSource, this.onOpenSource,
this.onDownload, this.onDownload,
this.onDeleteLocalFile, this.onDeleteLocalFile,
this.onShare,
this.downloaded = false, this.downloaded = false,
this.labels, this.labels,
this.onSimilar, this.onSimilar,
@@ -25,6 +26,7 @@ class PostActionBar extends StatelessWidget {
final bool isFavorite; final bool isFavorite;
final VoidCallback? onDownload; final VoidCallback? onDownload;
final VoidCallback? onDeleteLocalFile; final VoidCallback? onDeleteLocalFile;
final VoidCallback? onShare;
final VoidCallback? onSimilar; final VoidCallback? onSimilar;
final VoidCallback? onHide; final VoidCallback? onHide;
final bool downloaded; final bool downloaded;
@@ -91,6 +93,12 @@ class PostActionBar extends StatelessWidget {
onPressed: onHide, onPressed: onHide,
icon: const Icon(Icons.visibility_off_rounded), 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.download = 'Download',
this.deleteLocalFile = 'Delete local file', this.deleteLocalFile = 'Delete local file',
this.hidePost = 'Hide post locally', this.hidePost = 'Hide post locally',
this.share = 'Share',
}); });
final String favorite; final String favorite;
@@ -120,4 +129,5 @@ class PostActionLabels {
final String download; final String download;
final String deleteLocalFile; final String deleteLocalFile;
final String hidePost; final String hidePost;
final String share;
} }
@@ -21,11 +21,13 @@ class SearchController extends AsyncNotifier<SearchState> {
} }
Future<void> updateQuery(String query) async { Future<void> updateQuery(String query) async {
final lastToken = final rawLast =
query.trim().isEmpty ? '' : query.trim().split(RegExp(r'\s+')).last; query.trim().isEmpty ? '' : query.trim().split(RegExp(r'\s+')).last;
final cleanToken = SearchService.sanitizeToken(rawLast);
final requestId = ++_suggestionRequestId; final requestId = ++_suggestionRequestId;
_suggestionDebounce?.cancel(); _suggestionDebounce?.cancel();
if (lastToken.isEmpty) {
if (cleanToken.isEmpty || cleanToken == 'and') {
state = AsyncData( state = AsyncData(
(state.value ?? const SearchState()).copyWith( (state.value ?? const SearchState()).copyWith(
query: query, query: query,
@@ -34,24 +36,35 @@ class SearchController extends AsyncNotifier<SearchState> {
); );
return; return;
} }
// Instant local suggestions from disk cache & history (0 ms)
final localMatches = ref
.read(searchServiceProvider)
.instantSuggestions(cleanToken, limit: 16);
state = AsyncData( state = AsyncData(
(state.value ?? const SearchState()).copyWith( (state.value ?? const SearchState()).copyWith(
query: query, query: query,
suggestions: localMatches,
), ),
); );
_suggestionDebounce = Timer(const Duration(milliseconds: 280), () async {
_suggestionDebounce = Timer(const Duration(milliseconds: 160), () async {
final settingsRes = await ref.read(settingsServiceProvider).getSettings();
final appSettings = settingsRes is Success<AppSettings>
? settingsRes.data
: AppSettings.defaults;
final suggestionsResult = await ref final suggestionsResult = await ref
.read(searchServiceProvider) .read(searchServiceProvider)
.autocompleteDetailed(lastToken, limit: 16); .autocompleteDetailed(
cleanToken,
limit: 16,
tagCacheLimit: appSettings.tagCacheLimit,
);
if (requestId != _suggestionRequestId) return; if (requestId != _suggestionRequestId) return;
final normalizedToken = lastToken.trim().toLowerCase();
final suggestions = suggestionsResult is Success<List<TagSuggestion>> final suggestions = suggestionsResult is Success<List<TagSuggestion>>
? suggestionsResult.data ? suggestionsResult.data
.where((item) => item.name.toLowerCase().startsWith( : localMatches;
normalizedToken,
))
.toList(growable: false)
: <TagSuggestion>[];
state = AsyncData( state = AsyncData(
(state.value ?? const SearchState()).copyWith( (state.value ?? const SearchState()).copyWith(
query: query, query: query,
@@ -3,8 +3,11 @@ import 'package:flutter/material.dart';
import '../../../../backend/backend.dart'; import '../../../../backend/backend.dart';
class SearchSuggestions extends StatelessWidget { class SearchSuggestions extends StatelessWidget {
const SearchSuggestions( const SearchSuggestions({
{required this.suggestions, required this.onTap, super.key}); required this.suggestions,
required this.onTap,
super.key,
});
final List<TagSuggestion> suggestions; final List<TagSuggestion> suggestions;
final ValueChanged<String> onTap; final ValueChanged<String> onTap;
@@ -12,16 +15,43 @@ class SearchSuggestions extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (suggestions.isEmpty) return const SizedBox.shrink(); if (suggestions.isEmpty) return const SizedBox.shrink();
final scheme = Theme.of(context).colorScheme;
return Wrap( return Wrap(
spacing: 8, spacing: 8,
runSpacing: 8, runSpacing: 8,
children: [ children: [
for (final suggestion in suggestions) for (final suggestion in suggestions)
ActionChip( ActionChip(
label: Text('${suggestion.name} · ${suggestion.categoryLabel}'), avatar: Icon(
Icons.tag_rounded,
size: 16,
color: _categoryColor(suggestion.category, scheme),
),
label: Text(
suggestion.postCount > 0
? '${suggestion.name} (${_compactCount(suggestion.postCount)})'
: suggestion.name,
),
onPressed: () => onTap(suggestion.name), onPressed: () => onTap(suggestion.name),
), ),
], ],
); );
} }
Color _categoryColor(TagCategory category, ColorScheme scheme) {
return switch (category) {
TagCategory.artist => const Color(0xFFFF5252),
TagCategory.character => const Color(0xFF66BB6A),
TagCategory.copyright => const Color(0xFFBA68C8),
TagCategory.species => const Color(0xFF4FC3F7),
TagCategory.meta => const Color(0xFF90A4AE),
TagCategory.general || TagCategory.unknown => scheme.primary,
};
}
String _compactCount(int value) {
if (value >= 1000000) return '${(value / 1000000).toStringAsFixed(1)}M';
if (value >= 1000) return '${(value / 1000).toStringAsFixed(1)}K';
return '$value';
}
} }
@@ -0,0 +1,397 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:path_provider/path_provider.dart';
import '../../../app/app.dart';
import '../../../backend/backend.dart';
import '../../../core/utils/result.dart';
import '../../../shared/widgets/adaptive_scaffold.dart';
class CacheStats {
const CacheStats({
required this.cachedPostsCount,
required this.imageMemoryBytes,
required this.imageMemoryCount,
required this.tempDirectoryBytes,
required this.downloadedCount,
this.tagCacheCount = 0,
this.tagCacheBytes = 0,
});
final int cachedPostsCount;
final int imageMemoryBytes;
final int imageMemoryCount;
final int tempDirectoryBytes;
final int downloadedCount;
final int tagCacheCount;
final int tagCacheBytes;
int get totalEstimatedBytes =>
imageMemoryBytes +
tempDirectoryBytes +
tagCacheBytes +
(cachedPostsCount * 2048);
static const empty = CacheStats(
cachedPostsCount: 0,
imageMemoryBytes: 0,
imageMemoryCount: 0,
tempDirectoryBytes: 0,
downloadedCount: 0,
tagCacheCount: 0,
tagCacheBytes: 0,
);
}
final cacheStatsProvider = FutureProvider.autoDispose<CacheStats>((ref) async {
final cacheService = ref.watch(cacheServiceProvider);
final downloadedService = ref.watch(downloadedMediaServiceProvider);
final postsRes = await cacheService.getCachedPostsCount();
final postsCount = postsRes is Success<int> ? postsRes.data : 0;
final downloadedRes = await downloadedService.count();
final downloadedCount =
downloadedRes is Success<int> ? downloadedRes.data : 0;
final imageCache = PaintingBinding.instance.imageCache;
final memBytes = imageCache.currentSizeBytes;
final memCount = imageCache.currentSize;
var tempBytes = 0;
try {
final tempDir = await getTemporaryDirectory();
if (tempDir.existsSync()) {
await for (final file
in tempDir.list(recursive: true, followLinks: false)) {
if (file is File) {
tempBytes += await file.length();
}
}
}
} catch (_) {}
final tagCache = ref.watch(tagCacheServiceProvider);
if (!tagCache.isInitialized) await tagCache.init();
final tagCount = tagCache.tagCount;
final tagBytes = await tagCache.getFileSizeBytes();
return CacheStats(
cachedPostsCount: postsCount,
imageMemoryBytes: memBytes,
imageMemoryCount: memCount,
tempDirectoryBytes: tempBytes,
downloadedCount: downloadedCount,
tagCacheCount: tagCount,
tagCacheBytes: tagBytes,
);
});
class CacheManagerScreen extends ConsumerStatefulWidget {
const CacheManagerScreen({super.key});
@override
ConsumerState<CacheManagerScreen> createState() => _CacheManagerScreenState();
}
class _CacheManagerScreenState extends ConsumerState<CacheManagerScreen> {
bool _isClearing = false;
String _formatBytes(int bytes) {
if (bytes <= 0) return '0 B';
if (bytes < 1024) return '$bytes B';
if (bytes < 1024 * 1024) {
return '${(bytes / 1024).toStringAsFixed(1)} KB';
}
if (bytes < 1024 * 1024 * 1024) {
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
}
return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(2)} GB';
}
Future<void> _clearPostsCache() async {
setState(() => _isClearing = true);
await ref.read(cacheServiceProvider).clear();
ref.invalidate(cacheStatsProvider);
if (mounted) setState(() => _isClearing = false);
}
Future<void> _clearImagesCache() async {
setState(() => _isClearing = true);
PaintingBinding.instance.imageCache.clear();
PaintingBinding.instance.imageCache.clearLiveImages();
ref.invalidate(cacheStatsProvider);
if (mounted) setState(() => _isClearing = false);
}
Future<void> _clearTempFiles() async {
setState(() => _isClearing = true);
try {
final tempDir = await getTemporaryDirectory();
if (tempDir.existsSync()) {
final entities = tempDir.listSync(recursive: false);
for (final entity in entities) {
try {
entity.deleteSync(recursive: true);
} catch (_) {}
}
}
} catch (_) {}
ref.invalidate(cacheStatsProvider);
if (mounted) setState(() => _isClearing = false);
}
Future<void> _clearTagCache() async {
setState(() => _isClearing = true);
await ref.read(tagCacheServiceProvider).clear();
ref.invalidate(cacheStatsProvider);
if (mounted) setState(() => _isClearing = false);
}
Future<void> _clearAllCache() async {
setState(() => _isClearing = true);
await ref.read(cacheServiceProvider).clear();
await ref.read(tagCacheServiceProvider).clear();
PaintingBinding.instance.imageCache.clear();
PaintingBinding.instance.imageCache.clearLiveImages();
try {
final tempDir = await getTemporaryDirectory();
if (tempDir.existsSync()) {
final entities = tempDir.listSync(recursive: false);
for (final entity in entities) {
try {
entity.deleteSync(recursive: true);
} catch (_) {}
}
}
} catch (_) {}
ref.invalidate(cacheStatsProvider);
if (mounted) setState(() => _isClearing = false);
}
@override
Widget build(BuildContext context) {
final statsAsync = ref.watch(cacheStatsProvider);
final theme = Theme.of(context);
final strings = ref.watch(appStringsProvider);
return AdaptiveScaffold(
title: strings.ru ? 'Менеджер кэша' : 'Cache Manager',
actions: [
IconButton(
tooltip: strings.ru ? 'Обновить' : 'Refresh',
onPressed: () => ref.invalidate(cacheStatsProvider),
icon: const Icon(Icons.refresh_rounded),
),
],
body: statsAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(child: Text('Ошибка: $e')),
data: (stats) {
return ListView(
padding: const EdgeInsets.all(16),
children: [
// Summary card
Card(
color: theme.colorScheme.primaryContainer.withValues(alpha: 0.35),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(
color: theme.colorScheme.primary.withValues(alpha: 0.2),
),
),
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
children: [
Icon(
Icons.pie_chart_rounded,
size: 48,
color: theme.colorScheme.primary,
),
const SizedBox(height: 12),
Text(
_formatBytes(stats.totalEstimatedBytes),
style: theme.textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.bold,
color: theme.colorScheme.primary,
),
),
const SizedBox(height: 4),
Text(
strings.ru
? 'Общий предполагаемый объем кэша'
: 'Total estimated cache size',
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
),
),
const SizedBox(height: 16),
// Detail cards
_CacheCategoryTile(
icon: Icons.article_rounded,
title: strings.ru ? 'Кэш постов (база данных)' : 'Posts cache (DB)',
subtitle: strings.ru
? '${stats.cachedPostsCount} сохраненных постов'
: '${stats.cachedPostsCount} cached posts',
sizeText: _formatBytes(stats.cachedPostsCount * 2048),
onClear: _isClearing ? null : _clearPostsCache,
),
const SizedBox(height: 8),
_CacheCategoryTile(
icon: Icons.image_rounded,
title: strings.ru ? 'Кэш картинок в памяти' : 'Image memory cache',
subtitle: strings.ru
? '${stats.imageMemoryCount} картинок в ОЗУ'
: '${stats.imageMemoryCount} images in RAM',
sizeText: _formatBytes(stats.imageMemoryBytes),
onClear: _isClearing ? null : _clearImagesCache,
),
const SizedBox(height: 8),
_CacheCategoryTile(
icon: Icons.folder_open_rounded,
title: strings.ru ? 'Временные файлы и медиа' : 'Temp files & media',
subtitle: strings.ru
? 'Файлы для отправки и временные буферы'
: 'Share exports & temporary buffers',
sizeText: _formatBytes(stats.tempDirectoryBytes),
onClear: _isClearing ? null : _clearTempFiles,
),
const SizedBox(height: 8),
_CacheCategoryTile(
icon: Icons.label_rounded,
title: strings.ru ? 'Кэш подсказок тегов' : 'Tag suggestions cache',
subtitle: strings.ru
? '${stats.tagCacheCount} закэшированных тегов на диске'
: '${stats.tagCacheCount} cached tags on disk',
sizeText: _formatBytes(stats.tagCacheBytes),
onClear: _isClearing ? null : _clearTagCache,
),
const SizedBox(height: 8),
_CacheCategoryTile(
icon: Icons.download_done_rounded,
title: strings.ru ? 'Скачанные медиа' : 'Downloaded media',
subtitle: strings.ru
? '${stats.downloadedCount} файлов в истории скачиваний'
: '${stats.downloadedCount} files in download history',
sizeText: '',
showClear: false,
),
const SizedBox(height: 24),
// Global action button
FilledButton.tonalIcon(
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
onPressed: _isClearing ? null : _clearAllCache,
icon: _isClearing
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.delete_sweep_rounded),
label: Text(
strings.ru ? 'Очистить весь кэш' : 'Clear all cache',
style: const TextStyle(fontWeight: FontWeight.w600),
),
),
],
);
},
),
);
}
}
class _CacheCategoryTile extends StatelessWidget {
const _CacheCategoryTile({
required this.icon,
required this.title,
required this.subtitle,
required this.sizeText,
this.onClear,
this.showClear = true,
});
final IconData icon;
final String title;
final String subtitle;
final String sizeText;
final VoidCallback? onClear;
final bool showClear;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Card(
elevation: 0,
color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
CircleAvatar(
backgroundColor: theme.colorScheme.primaryContainer,
foregroundColor: theme.colorScheme.onPrimaryContainer,
child: Icon(icon, size: 22),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 2),
Text(
subtitle,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
),
if (sizeText.isNotEmpty) ...[
Text(
sizeText,
style: theme.textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.bold,
color: theme.colorScheme.primary,
),
),
const SizedBox(width: 8),
],
if (showClear && onClear != null)
IconButton(
tooltip: 'Очистить',
onPressed: onClear,
icon: const Icon(Icons.delete_outline_rounded, size: 20),
),
],
),
),
);
}
}
@@ -38,6 +38,14 @@ class SettingsController extends AsyncNotifier<AppSettings> {
ref.invalidateSelf(); ref.invalidateSelf();
} }
Future<void> clearSearchHistory() async {
await ref.read(searchServiceProvider).clearHistory();
}
Future<void> clearTagCache() async {
await ref.read(searchServiceProvider).clearTagCache();
}
Future<String> diagnosticsReport() async { Future<String> diagnosticsReport() async {
final result = final result =
await ref.read(settingsServiceProvider).buildDiagnosticsReport( await ref.read(settingsServiceProvider).buildDiagnosticsReport(
@@ -6,6 +6,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
import '../../../app/app.dart';
import '../../../app/app_version.dart'; import '../../../app/app_version.dart';
import '../../../app/changelog.dart'; import '../../../app/changelog.dart';
import '../../../app/app_strings.dart'; import '../../../app/app_strings.dart';
@@ -112,6 +113,99 @@ class _SettingsContent extends ConsumerWidget {
settings.copyWith(autoDownloadFavorites: value), settings.copyWith(autoDownloadFavorites: value),
), ),
), ),
if (settings.allowDownloads) ...[
const SizedBox(height: 8),
DropdownButtonFormField<String>(
initialValue: const [
'{Artist}/{ID}',
'{Artist}',
'{Provider}/{Artist}',
'{Service}/{ID}',
'{Date}/{Artist}',
].contains(settings.downloadPathTemplate)
? settings.downloadPathTemplate
: 'custom',
decoration: InputDecoration(
labelText: settings.languageCode == 'ru'
? 'Структура папок скачивания'
: 'Download folder structure',
helperText: '{Artist}, {Provider}, {Service}, {ID}, {Date}',
),
items: [
const DropdownMenuItem(
value: '{Artist}/{ID}',
child: Text('{Artist}/{ID}'),
),
const DropdownMenuItem(
value: '{Artist}',
child: Text('{Artist}'),
),
const DropdownMenuItem(
value: '{Provider}/{Artist}',
child: Text('{Provider}/{Artist}'),
),
const DropdownMenuItem(
value: '{Service}/{ID}',
child: Text('{Service}/{ID}'),
),
const DropdownMenuItem(
value: '{Date}/{Artist}',
child: Text('{Date}/{Artist}'),
),
if (![
'{Artist}/{ID}',
'{Artist}',
'{Provider}/{Artist}',
'{Service}/{ID}',
'{Date}/{Artist}',
].contains(settings.downloadPathTemplate))
DropdownMenuItem(
value: settings.downloadPathTemplate,
child: Text(settings.downloadPathTemplate),
),
const DropdownMenuItem(
value: 'custom',
child: Text('Пользовательский шаблон...'),
),
],
onChanged: (value) async {
if (value == 'custom') {
final ctrl = TextEditingController(
text: settings.downloadPathTemplate);
final custom = await showDialog<String>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Шаблон пути скачивания'),
content: TextField(
controller: ctrl,
decoration: const InputDecoration(
labelText: 'Шаблон',
helperText:
'Доступно: {Artist}, {Provider}, {Service}, {ID}, {Date}',
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Отмена')),
FilledButton(
onPressed: () =>
Navigator.pop(ctx, ctrl.text.trim()),
child: const Text('Сохранить')),
],
),
);
if (custom != null && custom.isNotEmpty) {
_update(ref,
settings.copyWith(downloadPathTemplate: custom));
}
} else if (value != null) {
_update(ref,
settings.copyWith(downloadPathTemplate: value));
}
},
),
],
], ],
), ),
_SettingsSection( _SettingsSection(
@@ -130,6 +224,33 @@ class _SettingsContent extends ConsumerWidget {
settings.copyWith(languageCode: value ?? 'ru'), 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), const SizedBox(height: 12),
_ColorSwatches( _ColorSwatches(
selected: settings.appSeedColor, selected: settings.appSeedColor,
@@ -271,6 +392,28 @@ class _SettingsContent extends ConsumerWidget {
onChanged: (value) => onChanged: (value) =>
_update(ref, settings.copyWith(cacheMaxItems: value)), _update(ref, settings.copyWith(cacheMaxItems: value)),
), ),
_StepperTile(
title: settings.languageCode == 'ru'
? 'Лимит истории поиска'
: 'Search history limit',
value: settings.searchHistoryLimit,
min: 50,
max: 2000,
step: 50,
onChanged: (value) =>
_update(ref, settings.copyWith(searchHistoryLimit: value)),
),
_StepperTile(
title: settings.languageCode == 'ru'
? 'Лимит кэша тегов (диск)'
: 'Tag cache limit (disk)',
value: settings.tagCacheLimit,
min: 500,
max: 20000,
step: 500,
onChanged: (value) =>
_update(ref, settings.copyWith(tagCacheLimit: value)),
),
_ActionGrid( _ActionGrid(
children: [ children: [
FilledButton.tonalIcon( FilledButton.tonalIcon(
@@ -280,11 +423,66 @@ class _SettingsContent extends ConsumerWidget {
icon: const Icon(Icons.cleaning_services_rounded), icon: const Icon(Icons.cleaning_services_rounded),
label: Text(strings.clearCache), 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: () async {
await ref
.read(settingsControllerProvider.notifier)
.clearTagCache();
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
settings.languageCode == 'ru'
? 'Кэш подсказок тегов очищен'
: 'Tag suggestions cache cleared',
),
),
);
}
},
icon: const Icon(Icons.label_off_rounded),
label: Text(
settings.languageCode == 'ru'
? 'Очистить кэш тегов'
: 'Clear tag cache',
),
),
FilledButton.tonalIcon(
onPressed: () async {
await ref
.read(settingsControllerProvider.notifier)
.clearSearchHistory();
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
settings.languageCode == 'ru'
? 'История поиска очищена'
: 'Search history cleared',
),
),
);
}
},
icon: const Icon(Icons.history_toggle_off_rounded),
label: Text(
settings.languageCode == 'ru'
? 'Очистить историю'
: 'Clear search history',
),
),
FilledButton.tonalIcon( FilledButton.tonalIcon(
onPressed: () => ref onPressed: () => ref
.read(settingsControllerProvider.notifier) .read(settingsControllerProvider.notifier)
.clearViewedHistory(), .clearViewedHistory(),
icon: const Icon(Icons.history_toggle_off_rounded), icon: const Icon(Icons.visibility_off_rounded),
label: Text(strings.clearViewed), label: Text(strings.clearViewed),
), ),
FilledButton.tonalIcon( FilledButton.tonalIcon(
@@ -298,7 +496,7 @@ class _SettingsContent extends ConsumerWidget {
onPressed: () => ref onPressed: () => ref
.read(settingsControllerProvider.notifier) .read(settingsControllerProvider.notifier)
.clearHiddenPosts(), .clearHiddenPosts(),
icon: const Icon(Icons.visibility_off_rounded), icon: const Icon(Icons.delete_sweep_rounded),
label: label:
Text('Clear hidden (${settings.hiddenPostKeys.length})'), Text('Clear hidden (${settings.hiddenPostKeys.length})'),
), ),
@@ -369,6 +567,20 @@ class _SettingsContent extends ConsumerWidget {
icon: const Icon(Icons.download_for_offline_rounded), icon: const Icon(Icons.download_for_offline_rounded),
label: Text(strings.importJson), 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), const SizedBox(height: 8),
@@ -413,6 +625,33 @@ class _SettingsContent extends ConsumerWidget {
); );
} }
Future<void> _exportBackupFile(BuildContext context, WidgetRef ref) async {
final success = await ref.read(backupServiceProvider).exportBackup();
if (!context.mounted) return;
if (success) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Бэкап сохранен / отправлен')),
);
}
}
Future<void> _importBackupFile(BuildContext context, WidgetRef ref) async {
final result = await ref.read(backupServiceProvider).importBackup();
if (!context.mounted) return;
if (result == null) return;
if (result is Success<AppSettings>) {
ref.invalidate(appSettingsProvider);
ref.invalidate(settingsControllerProvider);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Настройки успешно импортированы!')),
);
} else if (result is Error<AppSettings>) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Ошибка импорта: ${result.failure.message}')),
);
}
}
Future<void> _importDialog(BuildContext context, WidgetRef ref) async { Future<void> _importDialog(BuildContext context, WidgetRef ref) async {
final controller = TextEditingController(); final controller = TextEditingController();
await showDialog<void>( await showDialog<void>(
+125 -11
View File
@@ -228,6 +228,7 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
width: size.width, width: size.width,
yOffset: size.height + 8, yOffset: size.height + 8,
suggestions: suggestions, suggestions: suggestions,
query: token,
onSelected: _applySuggestion, onSelected: _applySuggestion,
), ),
); );
@@ -372,7 +373,7 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
void _notifyChangedDebounced() { void _notifyChangedDebounced() {
_debounce?.cancel(); _debounce?.cancel();
_debounce = Timer(const Duration(milliseconds: 250), _notifyChanged); _debounce = Timer(const Duration(milliseconds: 100), _notifyChanged);
} }
void _notifyChanged() { void _notifyChanged() {
@@ -401,7 +402,8 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
String get _activeToken { String get _activeToken {
final draft = _controller.text.trim().toLowerCase(); final draft = _controller.text.trim().toLowerCase();
if (draft.isEmpty) return ''; if (draft.isEmpty) return '';
return draft.split(RegExp(r'\s+')).last; final raw = draft.split(RegExp(r'\s+')).last;
return raw.replaceAll(RegExp(r'^[\(\)]+|[\(\)]+$'), '').trim();
} }
bool get _isEditing => _focusNode.hasFocus || _localDirty; bool get _isEditing => _focusNode.hasFocus || _localDirty;
@@ -413,6 +415,7 @@ class _TagSuggestionOverlay extends StatelessWidget {
required this.width, required this.width,
required this.yOffset, required this.yOffset,
required this.suggestions, required this.suggestions,
required this.query,
required this.onSelected, required this.onSelected,
}); });
@@ -420,6 +423,7 @@ class _TagSuggestionOverlay extends StatelessWidget {
final double width; final double width;
final double yOffset; final double yOffset;
final List<TagSuggestion> suggestions; final List<TagSuggestion> suggestions;
final String query;
final ValueChanged<String> onSelected; final ValueChanged<String> onSelected;
@override @override
@@ -435,6 +439,7 @@ class _TagSuggestionOverlay extends StatelessWidget {
width: width.clamp(280, 620).toDouble(), width: width.clamp(280, 620).toDouble(),
child: _TagSuggestionDropdown( child: _TagSuggestionDropdown(
suggestions: suggestions, suggestions: suggestions,
query: query,
onSelected: onSelected, onSelected: onSelected,
), ),
), ),
@@ -447,10 +452,12 @@ class _TagSuggestionOverlay extends StatelessWidget {
class _TagSuggestionDropdown extends StatelessWidget { class _TagSuggestionDropdown extends StatelessWidget {
const _TagSuggestionDropdown({ const _TagSuggestionDropdown({
required this.suggestions, required this.suggestions,
required this.query,
required this.onSelected, required this.onSelected,
}); });
final List<TagSuggestion> suggestions; final List<TagSuggestion> suggestions;
final String query;
final ValueChanged<String> onSelected; final ValueChanged<String> onSelected;
@override @override
@@ -482,18 +489,24 @@ class _TagSuggestionDropdown extends StatelessWidget {
const EdgeInsets.symmetric(horizontal: 14, vertical: 10), const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
child: Row( child: Row(
children: [ children: [
Icon(Icons.tag_rounded, color: scheme.primary, size: 20), Icon(
Icons.tag_rounded,
color: _categoryAccentColor(suggestion.category, scheme),
size: 20,
),
const SizedBox(width: 14), const SizedBox(width: 14),
Expanded( Expanded(
child: Text( child: _buildHighlightedText(
context,
suggestion.name, suggestion.name,
maxLines: 1, query,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodyLarge,
), ),
), ),
const SizedBox(width: 10), const SizedBox(width: 10),
_TagSuggestionBadge(suggestion.categoryLabel), _TagSuggestionBadge(
category: suggestion.category,
label: suggestion.categoryLabel,
),
if (suggestion.postCount > 0) ...[ if (suggestion.postCount > 0) ...[
const SizedBox(width: 8), const SizedBox(width: 8),
Text( Text(
@@ -514,6 +527,72 @@ class _TagSuggestionDropdown extends StatelessWidget {
); );
} }
Widget _buildHighlightedText(
BuildContext context,
String text,
String token,
) {
final theme = Theme.of(context);
final scheme = theme.colorScheme;
final defaultStyle = theme.textTheme.bodyLarge ?? const TextStyle();
final cleanToken = token.trim().toLowerCase();
if (cleanToken.isEmpty) {
return Text(
text,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: defaultStyle,
);
}
final lowerText = text.toLowerCase();
final matchIndex = lowerText.indexOf(cleanToken);
if (matchIndex < 0) {
return Text(
text,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: defaultStyle,
);
}
final before = text.substring(0, matchIndex);
final match = text.substring(matchIndex, matchIndex + cleanToken.length);
final after = text.substring(matchIndex + cleanToken.length);
return Text.rich(
TextSpan(
style: defaultStyle,
children: [
if (before.isNotEmpty) TextSpan(text: before),
TextSpan(
text: match,
style: defaultStyle.copyWith(
color: scheme.primary,
fontWeight: FontWeight.w900,
),
),
if (after.isNotEmpty) TextSpan(text: after),
],
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
);
}
Color _categoryAccentColor(TagCategory category, ColorScheme scheme) {
return switch (category) {
TagCategory.artist => const Color(0xFFFF5252),
TagCategory.character => const Color(0xFF66BB6A),
TagCategory.copyright => const Color(0xFFBA68C8),
TagCategory.species => const Color(0xFF4FC3F7),
TagCategory.meta => const Color(0xFF90A4AE),
TagCategory.general || TagCategory.unknown => scheme.primary,
};
}
String _compactCount(int value) { String _compactCount(int value) {
if (value >= 1000000) return '${(value / 1000000).toStringAsFixed(1)}M'; if (value >= 1000000) return '${(value / 1000000).toStringAsFixed(1)}M';
if (value >= 1000) return '${(value / 1000).toStringAsFixed(1)}K'; if (value >= 1000) return '${(value / 1000).toStringAsFixed(1)}K';
@@ -522,16 +601,22 @@ class _TagSuggestionDropdown extends StatelessWidget {
} }
class _TagSuggestionBadge extends StatelessWidget { class _TagSuggestionBadge extends StatelessWidget {
const _TagSuggestionBadge(this.label); const _TagSuggestionBadge({
required this.category,
required this.label,
});
final TagCategory category;
final String label; final String label;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme; final scheme = Theme.of(context).colorScheme;
final (bgColor, fgColor) = _badgeColors(scheme);
return DecoratedBox( return DecoratedBox(
decoration: BoxDecoration( decoration: BoxDecoration(
color: scheme.primaryContainer.withValues(alpha: 0.55), color: bgColor,
borderRadius: BorderRadius.circular(999), borderRadius: BorderRadius.circular(999),
), ),
child: Padding( child: Padding(
@@ -539,13 +624,42 @@ class _TagSuggestionBadge extends StatelessWidget {
child: Text( child: Text(
label, label,
style: Theme.of(context).textTheme.labelSmall?.copyWith( style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: scheme.onPrimaryContainer, color: fgColor,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
), ),
), ),
), ),
); );
} }
(Color, Color) _badgeColors(ColorScheme scheme) {
return switch (category) {
TagCategory.artist => (
const Color(0xFFFF5252).withValues(alpha: 0.16),
const Color(0xFFFF5252),
),
TagCategory.character => (
const Color(0xFF4CAF50).withValues(alpha: 0.16),
const Color(0xFF43A047),
),
TagCategory.copyright => (
const Color(0xFFAB47BC).withValues(alpha: 0.16),
const Color(0xFFAB47BC),
),
TagCategory.species => (
const Color(0xFF29B6F6).withValues(alpha: 0.16),
const Color(0xFF0288D1),
),
TagCategory.meta => (
const Color(0xFF78909C).withValues(alpha: 0.16),
const Color(0xFF607D8B),
),
TagCategory.general || TagCategory.unknown => (
scheme.primaryContainer.withValues(alpha: 0.55),
scheme.onPrimaryContainer,
),
};
}
} }
class _AppSearchBarState extends State<AppSearchBar> { class _AppSearchBarState extends State<AppSearchBar> {
+71 -7
View File
@@ -54,26 +54,63 @@ class PostCard extends ConsumerStatefulWidget {
ConsumerState<PostCard> createState() => _PostCardState(); ConsumerState<PostCard> createState() => _PostCardState();
} }
class _PostCardState extends ConsumerState<PostCard> { class _PostCardState extends ConsumerState<PostCard>
with SingleTickerProviderStateMixin {
static final Map<String, Post> _resolvedRealbooruPosts = {}; static final Map<String, Post> _resolvedRealbooruPosts = {};
bool _hovered = false; bool _hovered = false;
Post? _resolvedPost; Post? _resolvedPost;
bool _showHeart = false;
late final AnimationController _heartController;
late final Animation<double> _heartScale;
late final Animation<double> _heartOpacity;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_resolvedPost = _resolvedRealbooruPosts[widget.post.cacheKey]; _resolvedPost = _resolvedRealbooruPosts[widget.post.cacheKey];
_maybeResolveRealbooruPost(); _maybeResolveRealbooruPost();
_heartController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 600),
);
_heartScale = TweenSequence<double>([
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<double>([
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 @override
void didUpdateWidget(covariant PostCard oldWidget) { void dispose() {
super.didUpdateWidget(oldWidget); _heartController.dispose();
if (oldWidget.post.cacheKey != widget.post.cacheKey) { super.dispose();
_resolvedPost = _resolvedRealbooruPosts[widget.post.cacheKey]; }
_maybeResolveRealbooruPost();
} void _triggerDoubleTapFavorite() {
widget.onFavorite();
setState(() => _showHeart = true);
_heartController.forward();
} }
@override @override
@@ -97,6 +134,7 @@ class _PostCardState extends ConsumerState<PostCard> {
} }
widget.onOpen(); widget.onOpen();
}, },
onDoubleTap: _triggerDoubleTapFavorite,
onLongPress: () { onLongPress: () {
if (widget.selectionMode && widget.onToggleSelected != null) { if (widget.selectionMode && widget.onToggleSelected != null) {
widget.onToggleSelected!(); widget.onToggleSelected!();
@@ -247,6 +285,32 @@ class _PostCardState extends ConsumerState<PostCard> {
onPressed: widget.onFavorite, 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( Positioned.fill(
child: IgnorePointer( child: IgnorePointer(
ignoring: !_hovered || mobile, ignoring: !_hovered || mobile,
+74 -23
View File
@@ -27,6 +27,7 @@ class PostMasonryGrid extends StatelessWidget {
this.downloadedKeys = const {}, this.downloadedKeys = const {},
this.loading = false, this.loading = false,
this.controller, this.controller,
this.gridMode = 'masonry',
super.key, super.key,
}); });
@@ -49,39 +50,89 @@ class PostMasonryGrid extends StatelessWidget {
final Set<String> downloadedKeys; final Set<String> downloadedKeys;
final bool loading; final bool loading;
final ScrollController? controller; final ScrollController? controller;
final String gridMode;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final mobile = Responsive.isMobile(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( return MasonryGridView.count(
controller: controller, controller: controller,
padding: EdgeInsets.all(mobile ? 8 : 16), padding: pad,
crossAxisCount: columns, crossAxisCount: columns,
mainAxisSpacing: mobile ? 8 : 12, mainAxisSpacing: spacing,
crossAxisSpacing: mobile ? 8 : 12, crossAxisSpacing: spacing,
itemCount: posts.length + (loading ? columns : 0), itemCount: posts.length + (loading ? columns : 0),
itemBuilder: (context, index) { itemBuilder: (context, index) {
if (index >= posts.length) return const LoadingSkeleton(); if (index >= posts.length) return const LoadingSkeleton();
final post = posts[index]; return buildCard(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),
);
}, },
); );
} }
@@ -6,6 +6,7 @@
#include "generated_plugin_registrant.h" #include "generated_plugin_registrant.h"
#include <dynamic_color/dynamic_color_plugin.h>
#include <file_selector_linux/file_selector_plugin.h> #include <file_selector_linux/file_selector_plugin.h>
#include <isar_flutter_libs/isar_flutter_libs_plugin.h> #include <isar_flutter_libs/isar_flutter_libs_plugin.h>
#include <media_kit_libs_linux/media_kit_libs_linux_plugin.h> #include <media_kit_libs_linux/media_kit_libs_linux_plugin.h>
@@ -14,6 +15,9 @@
#include <volume_controller/volume_controller_plugin.h> #include <volume_controller/volume_controller_plugin.h>
void fl_register_plugins(FlPluginRegistry* registry) { 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 = g_autoptr(FlPluginRegistrar) file_selector_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin"); fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin");
file_selector_plugin_register_with_registrar(file_selector_linux_registrar); file_selector_plugin_register_with_registrar(file_selector_linux_registrar);
+1
View File
@@ -3,6 +3,7 @@
# #
list(APPEND FLUTTER_PLUGIN_LIST list(APPEND FLUTTER_PLUGIN_LIST
dynamic_color
file_selector_linux file_selector_linux
isar_flutter_libs isar_flutter_libs
media_kit_libs_linux media_kit_libs_linux
+32 -16
View File
@@ -273,6 +273,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.2" 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: fake_async:
dependency: transitive dependency: transitive
description: description:
@@ -289,14 +297,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.2.0" 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: file:
dependency: transitive dependency: transitive
description: description:
@@ -739,18 +739,18 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: package_info_plus name: package_info_plus
sha256: "4bf625947f6c7713ee242296a682e23e44823c09cf9d79e4f1238923c92db852" sha256: "468c26b4254ab01979fa5e4a98cb343ea3631b9acee6f21028997419a80e1a20"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "10.1.0" version: "9.0.1"
package_info_plus_platform_interface: package_info_plus_platform_interface:
dependency: transitive dependency: transitive
description: description:
name: package_info_plus_platform_interface name: package_info_plus_platform_interface
sha256: db762cb2f4f25ee60fb6359773861b0f199e00b90d237bd85a76a1e806b46ef4 sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.1.0" version: "3.2.1"
path: path:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -911,6 +911,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.1" 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: shelf:
dependency: transitive dependency: transitive
description: description:
@@ -1208,10 +1224,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: wakelock_plus name: wakelock_plus
sha256: "824c5bba0f800e86d32e57d3d1843c531f090005cc89d9a837933e6601093d53" sha256: ddf3db70eaa10c37558ff817519b85d527dbd21034fd5d8e1c2e85f31588f1c1
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.6.1" version: "1.5.2"
wakelock_plus_platform_interface: wakelock_plus_platform_interface:
dependency: transitive dependency: transitive
description: description:
@@ -1256,10 +1272,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: win32 name: win32
sha256: ba6f4bba816c8d7e3c1580e170f3786d216951cc6b94babc3b814c08d2cb2738 sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.3.0" version: "5.15.0"
xdg_directories: xdg_directories:
dependency: transitive dependency: transitive
description: description:
+3 -1
View File
@@ -1,7 +1,7 @@
name: gel_rule_app name: gel_rule_app
description: Local backend/core layer for a Flutter booru browser app. description: Local backend/core layer for a Flutter booru browser app.
publish_to: "none" publish_to: "none"
version: 2.0.4+26 version: 2.0.9+31
environment: environment:
sdk: ">=3.3.0 <4.0.0" sdk: ">=3.3.0 <4.0.0"
@@ -31,6 +31,8 @@ dependencies:
file_selector: ^1.0.3 file_selector: ^1.0.3
path: ^1.9.0 path: ^1.9.0
media_kit_libs_android_video: ^1.3.8 media_kit_libs_android_video: ^1.3.8
share_plus: ^10.0.0
dynamic_color: ^1.7.0
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
+2 -1
View File
@@ -43,11 +43,12 @@ void main() {
expect(postPassesTagFilters(post, settings), isFalse); expect(postPassesTagFilters(post, settings), isFalse);
}); });
test('requested search tags are all required', () { test('requested search tags are all required unless separated by and', () {
final post = _post(tags: ['touhou', 'hakurei_reimu']); final post = _post(tags: ['touhou', 'hakurei_reimu']);
expect(postMatchesRequestedTags(post, ['touhou']), isTrue); expect(postMatchesRequestedTags(post, ['touhou']), isTrue);
expect(postMatchesRequestedTags(post, ['touhou', 'cirno']), isFalse); expect(postMatchesRequestedTags(post, ['touhou', 'cirno']), isFalse);
expect(postMatchesRequestedTags(post, ['touhou', 'and', 'cirno']), isTrue);
}); });
test('requested search tags can match tag prefixes', () { test('requested search tags can match tag prefixes', () {
+76
View File
@@ -0,0 +1,76 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:gel_rule_app/backend/models/artist_announcement.dart';
import 'package:gel_rule_app/backend/models/artist_link.dart';
import 'package:gel_rule_app/backend/models/artist_tag.dart';
import 'package:gel_rule_app/backend/models/artist_work_query.dart';
void main() {
group('Pawchive API Features Tests', () {
test('ArtistTag model fromJson and toJson roundtrip', () {
final json = {
'tag': 'genshin_impact',
'post_count': 42,
};
final tag = ArtistTag.fromJson(json);
expect(tag.tag, equals('genshin_impact'));
expect(tag.postCount, equals(42));
expect(tag.toJson(), equals(json));
});
test('ArtistLink model parses all fields correctly', () {
final json = {
'id': '123456',
'service': 'fanbox',
'name': 'CoolArtist',
'public_id': 'cool_pub_id',
'indexed': '2026-09-03T12:00:00.000Z',
'updated': '2026-09-03T14:00:00.000Z',
};
final link = ArtistLink.fromJson(json);
expect(link.id, equals('123456'));
expect(link.service, equals('fanbox'));
expect(link.name, equals('CoolArtist'));
expect(link.publicId, equals('cool_pub_id'));
expect(link.indexed, isNotNull);
expect(link.updated, isNotNull);
});
test('ArtistAnnouncement model parses content and date', () {
final json = {
'service': 'patreon',
'user_id': '778899',
'content': 'Taking a vacation next week!',
'added': '2026-08-01T10:00:00.000Z',
'hash': 'abc123hash',
};
final announcement = ArtistAnnouncement.fromJson(json);
expect(announcement.service, equals('patreon'));
expect(announcement.userId, equals('778899'));
expect(announcement.content, contains('vacation'));
expect(announcement.added, isNotNull);
expect(announcement.hash, equals('abc123hash'));
});
test('ArtistWorkQuery supports queryText and tagFilter', () {
const query = ArtistWorkQuery(
providerId: 'pawchive',
service: 'fanbox',
artistId: '112233',
artistName: 'TestArtist',
queryText: 'mp4',
tagFilter: 'cyberpunk',
);
expect(query.queryText, equals('mp4'));
expect(query.tagFilter, equals('cyberpunk'));
final modified = query.copyWith(queryText: 'gif');
expect(modified.queryText, equals('gif'));
expect(modified.tagFilter, equals('cyberpunk'));
expect(modified == query, isFalse);
});
});
}
+138
View File
@@ -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<Result<AppSettings>> getSettings() async => Success(current);
@override
Future<Result<void>> updateSettings(AppSettings settings) async {
current = settings;
return const Success(null);
}
}
+1 -1
View File
@@ -21,7 +21,7 @@ class FakeSearchRepository implements SearchRepository {
} }
@override @override
Future<Result<void>> save(SearchHistory history) async { Future<Result<void>> save(SearchHistory history, {int maxItems = 500}) async {
items.add(history); items.add(history);
return const Success(null); return const Success(null);
} }
+149
View File
@@ -0,0 +1,149 @@
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:gel_rule_app/backend/models/tag_suggestion.dart';
import 'package:gel_rule_app/backend/services/tag_cache_service.dart';
void main() {
late Directory tempDir;
late String cacheFilePath;
late TagCacheService cacheService;
setUp(() async {
tempDir = await Directory.systemTemp.createTemp('lunaris_test_tag_cache');
cacheFilePath = '${tempDir.path}/test_tags.json';
cacheService = TagCacheService(customPath: cacheFilePath);
await cacheService.init();
});
tearDown(() async {
if (await tempDir.exists()) {
await tempDir.delete(recursive: true);
}
});
test('TagSuggestion toJson and fromJson work correctly', () {
const item = TagSuggestion(
name: 'hatsune_miku',
category: TagCategory.character,
postCount: 154200,
providerId: 'danbooru',
);
final json = item.toJson();
final restored = TagSuggestion.fromJson(json);
expect(restored.name, 'hatsune_miku');
expect(restored.category, TagCategory.character);
expect(restored.postCount, 154200);
expect(restored.providerId, 'danbooru');
});
test('findLocal returns matching tags instantaneously', () async {
await cacheService.cacheTags([
const TagSuggestion(
name: 'cat_ears',
category: TagCategory.general,
postCount: 1000,
providerId: 'gelbooru',
),
const TagSuggestion(
name: 'caterpillar',
category: TagCategory.general,
postCount: 200,
providerId: 'gelbooru',
),
const TagSuggestion(
name: 'dog_tail',
category: TagCategory.general,
postCount: 800,
providerId: 'gelbooru',
),
]);
final matches = cacheService.findLocal('cat');
expect(matches.length, 2);
expect(matches.first.name, 'cat_ears');
expect(matches[1].name, 'caterpillar');
final dogMatches = cacheService.findLocal('dog');
expect(dogMatches.length, 1);
expect(dogMatches.first.name, 'dog_tail');
});
test('findLocal sanitizes parentheses in prefix', () async {
await cacheService.cacheTags([
const TagSuggestion(
name: 'cat_ears',
category: TagCategory.general,
postCount: 1000,
providerId: 'gelbooru',
),
]);
final matches = cacheService.findLocal('(cat');
expect(matches.length, 1);
expect(matches.first.name, 'cat_ears');
});
test('findLocal respects priority tags from search history', () async {
await cacheService.cacheTags([
const TagSuggestion(
name: 'cat_girl',
category: TagCategory.general,
postCount: 5000,
providerId: 'gelbooru',
),
const TagSuggestion(
name: 'cat_costume',
category: TagCategory.general,
postCount: 50,
providerId: 'gelbooru',
),
]);
// Priority tag 'cat_costume' should appear first despite lower postCount
final matches = cacheService.findLocal(
'cat',
priorityTags: ['cat_costume'],
);
expect(matches.first.name, 'cat_costume');
expect(matches[1].name, 'cat_girl');
});
test('flush saves tags to file and reloading restores them', () async {
await cacheService.cacheTags([
const TagSuggestion(
name: 'genshin_impact',
category: TagCategory.copyright,
postCount: 300000,
providerId: 'danbooru',
),
]);
await cacheService.flush();
final reloadedService = TagCacheService(customPath: cacheFilePath);
await reloadedService.init();
final results = reloadedService.findLocal('genshin');
expect(results.length, 1);
expect(results.first.name, 'genshin_impact');
expect(results.first.category, TagCategory.copyright);
});
test('clear removes tags from memory and disk', () async {
await cacheService.cacheTags([
const TagSuggestion(
name: 'raiden_shogun',
category: TagCategory.character,
postCount: 45000,
providerId: 'danbooru',
),
]);
await cacheService.flush();
expect(File(cacheFilePath).existsSync(), isTrue);
await cacheService.clear();
expect(cacheService.tagCount, 0);
expect(File(cacheFilePath).existsSync(), isFalse);
});
}
+86
View File
@@ -0,0 +1,86 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:gel_rule_app/backend/providers/provider_manager.dart';
void main() {
group('Tag groups splitting: space groups together, and separates streams', () {
test('single tag returns one group', () {
final groups = ProviderManager.splitTagGroups(['cat']);
expect(groups, equals([['cat']]));
});
test('tags separated by space are grouped together in one query', () {
final groups = ProviderManager.splitTagGroups(['raiden', 'miku']);
expect(groups, equals([
['raiden', 'miku'],
]));
});
test('tags separated by and are split into independent query streams', () {
final groups = ProviderManager.splitTagGroups(['raiden', 'and', 'miku']);
expect(groups, equals([
['raiden'],
['miku'],
]));
});
test('cat and dog anthro applies trailing anthro tag to both branches', () {
final groups = ProviderManager.splitTagGroups(['cat', 'and', 'dog', 'anthro']);
expect(groups, equals([
['cat', 'anthro'],
['dog', 'anthro'],
]));
});
test('multiple trailing modifiers apply to all and-branches', () {
final groups = ProviderManager.splitTagGroups([
'cat',
'and',
'dog',
'and',
'fox',
'anthro',
'solo',
]);
expect(groups, equals([
['cat', 'anthro', 'solo'],
['dog', 'anthro', 'solo'],
['fox', 'anthro', 'solo'],
]));
});
test('supports optional parentheses around and branches', () {
final groups = ProviderManager.splitTagGroups(['(cat', 'and', 'dog)', 'anthro']);
expect(groups, equals([
['cat', 'anthro'],
['dog', 'anthro'],
]));
});
test('multiple multi-tag groups separated by and', () {
final groups = ProviderManager.splitTagGroups([
'genshin',
'raiden',
'and',
'vocaloid',
'miku',
]);
expect(groups, equals([
['genshin', 'raiden'],
['vocaloid', 'miku'],
]));
});
test('case-insensitive AND separator', () {
final groups = ProviderManager.splitTagGroups(['cat', 'AND', 'dog']);
expect(groups, equals([
['cat'],
['dog'],
]));
});
test('empty tags return empty group list', () {
final groups = ProviderManager.splitTagGroups([]);
expect(groups, equals([const <String>[]]));
});
});
}
@@ -7,16 +7,20 @@
#include "generated_plugin_registrant.h" #include "generated_plugin_registrant.h"
#include <connectivity_plus/connectivity_plus_windows_plugin.h> #include <connectivity_plus/connectivity_plus_windows_plugin.h>
#include <dynamic_color/dynamic_color_plugin_c_api.h>
#include <file_selector_windows/file_selector_windows.h> #include <file_selector_windows/file_selector_windows.h>
#include <isar_flutter_libs/isar_flutter_libs_plugin.h> #include <isar_flutter_libs/isar_flutter_libs_plugin.h>
#include <media_kit_libs_windows_video/media_kit_libs_windows_video_plugin_c_api.h> #include <media_kit_libs_windows_video/media_kit_libs_windows_video_plugin_c_api.h>
#include <media_kit_video/media_kit_video_plugin_c_api.h> #include <media_kit_video/media_kit_video_plugin_c_api.h>
#include <share_plus/share_plus_windows_plugin_c_api.h>
#include <url_launcher_windows/url_launcher_windows.h> #include <url_launcher_windows/url_launcher_windows.h>
#include <volume_controller/volume_controller_plugin_c_api.h> #include <volume_controller/volume_controller_plugin_c_api.h>
void RegisterPlugins(flutter::PluginRegistry* registry) { void RegisterPlugins(flutter::PluginRegistry* registry) {
ConnectivityPlusWindowsPluginRegisterWithRegistrar( ConnectivityPlusWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("ConnectivityPlusWindowsPlugin")); registry->GetRegistrarForPlugin("ConnectivityPlusWindowsPlugin"));
DynamicColorPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("DynamicColorPluginCApi"));
FileSelectorWindowsRegisterWithRegistrar( FileSelectorWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FileSelectorWindows")); registry->GetRegistrarForPlugin("FileSelectorWindows"));
IsarFlutterLibsPluginRegisterWithRegistrar( IsarFlutterLibsPluginRegisterWithRegistrar(
@@ -25,6 +29,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
registry->GetRegistrarForPlugin("MediaKitLibsWindowsVideoPluginCApi")); registry->GetRegistrarForPlugin("MediaKitLibsWindowsVideoPluginCApi"));
MediaKitVideoPluginCApiRegisterWithRegistrar( MediaKitVideoPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("MediaKitVideoPluginCApi")); registry->GetRegistrarForPlugin("MediaKitVideoPluginCApi"));
SharePlusWindowsPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("SharePlusWindowsPluginCApi"));
UrlLauncherWindowsRegisterWithRegistrar( UrlLauncherWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("UrlLauncherWindows")); registry->GetRegistrarForPlugin("UrlLauncherWindows"));
VolumeControllerPluginCApiRegisterWithRegistrar( VolumeControllerPluginCApiRegisterWithRegistrar(
+2
View File
@@ -4,10 +4,12 @@
list(APPEND FLUTTER_PLUGIN_LIST list(APPEND FLUTTER_PLUGIN_LIST
connectivity_plus connectivity_plus
dynamic_color
file_selector_windows file_selector_windows
isar_flutter_libs isar_flutter_libs
media_kit_libs_windows_video media_kit_libs_windows_video
media_kit_video media_kit_video
share_plus
url_launcher_windows url_launcher_windows
volume_controller volume_controller
) )