Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b33248ef9d | ||
|
|
1b08194666 | ||
|
|
37f2dbf47d | ||
|
|
4bbe04dfd8 | ||
|
|
e8448eb748 | ||
|
|
7e384b1be7 | ||
|
|
5560332b66 | ||
|
|
4acd6c29e0 | ||
|
|
fb31a303ff | ||
|
|
a8b778146d | ||
|
|
69d8bfc6bf | ||
|
|
b82d87161e | ||
|
|
84eeb909a1 | ||
|
|
1f624fc020 | ||
|
|
5ae802f89c |
@@ -10,7 +10,7 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="https://img.shields.io/badge/Version-2.0.2-blueviolet?style=for-the-badge" alt="Version 2.0.2" />
|
<img src="https://img.shields.io/badge/Version-2.0.15-blueviolet?style=for-the-badge" alt="Version 2.0.15" />
|
||||||
<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,16 +20,35 @@
|
|||||||
|
|
||||||
## ✨ Основные возможности
|
## ✨ Основные возможности
|
||||||
|
|
||||||
- **📱 Плавная лента в стиле Pinterest**: Masonry-сетка с адаптивным количеством колонок для мобильных и ПК.
|
- **⚙️ Settings 2.0**: Полный редизайн настроек с современными squircle-карточками, горизонтальной быстрой навигацией по разделам, мгновенными Segmented-кнопками выбора темы/языка/качества и стильной карточкой о приложении.
|
||||||
|
|
||||||
|
- **⚡ Мгновенные подсказки тегов (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)**: Мгновенный экспорт оригинальных медиафайлов через системное меню «Поделиться».
|
||||||
- **🐾 Поддержка Pawchive**: Интеграция с `pawchive.pw` для просмотра профилей авторов, связанных аккаунтов, тегов и работ с Patreon, Fanbox, Fantia, Discord, Boosty и др.
|
- **🔖 Сохраненные поисковые пресеты**: Быстрое сохранение комбинаций тегов и фильтров в закладки.
|
||||||
- **🔍 Умный поиск и автодополнение**: Интерактивные чипсы тегов с подсказками от активных API. Быстрое исключение (`-tag`), добавление (`+tag`) или отправка в черный список по долгому тапу.
|
- **📁 Умная структура папок скачивания**: Настраиваемые шаблоны поддиректорий (`{Artist}`, `{Provider}`, `{Service}`, `{ID}`, `{Date}`).
|
||||||
- **🛡️ Продвинутый Blacklist / Whitelist**: Фильтрация по тегам, категориям, рейтингу контента (safe/questionable/explicit), провайдерам и минимальному score.
|
- **🗑️ Менеджер кэша**: Детальная визуализация занятой памяти и выборочная очистка.
|
||||||
- **🎬 Встроенный плеер**: Удобное воспроизведение WebM / MP4, запоминание настроек звука, fullscreen и повтор.
|
- **📦 Бэкап и синхронизация**: Экспорт и импорт полной конфигурации в JSON-файл для переноса на другие устройства.
|
||||||
- **💾 Менеджер загрузок и коллекции**: Скачивание оригиналов в высоком качестве, тематические папки-коллекции и автозагрузка избранного.
|
- **🌑 AMOLED Pure Black тема**: Истинно черный фон (`#000000`) для экономии батареи на OLED дисплеях.
|
||||||
|
- **🎨 Динамические цвета Material You**: Поддержка системной палитры Android из обоев устройства.
|
||||||
|
- **⭐ Любимые авторы**: Компактная кнопка-звёздочка сверху справа для быстрого доступа ко всем любимым авторам.
|
||||||
|
- **🏷️ Мультивыбор платформ**: Выбор нескольких платформ (Patreon, Fanbox, Fantia, Boosty, Discord).
|
||||||
|
- **📜 Бесконечная подгрузка постов автора**: Умная пагинация при прокрутке вниз загружает всю историю работ.
|
||||||
|
- **🔍 Умный поиск и автодополнение**: Интерактивные чипсы тегов с подсказками от активных API.
|
||||||
|
- **🛡️ Продвинутый Blacklist / Whitelist**: Фильтрация по тегам, категориям, рейтингу контента и минимальному score.
|
||||||
|
- **🎬 Встроенный плеер**: Удобное воспроизведение WebM / MP4, запоминание звука, fullscreen и повтор.
|
||||||
|
- **💾 Менеджер загрузок и коллекции**: Скачивание оригиналов в высоком качестве и тематические папки-коллекции.
|
||||||
- **🔄 Встроенное автообновление**: Проверка и скачивание свежих релизов напрямую из личного репозитория Gitea.
|
- **🔄 Встроенное автообновление**: Проверка и скачивание свежих релизов напрямую из личного репозитория Gitea.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -52,9 +71,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.2.apk`
|
- **Android**: `Lunaris-v2.0.10.apk`
|
||||||
- **Linux (x64)**: `Lunaris-v2.0.2-linux-x64.tar.gz`
|
- **Linux (x64)**: `Lunaris-v2.0.10-linux-x64.tar.gz`
|
||||||
- **Windows**: `LunarisSetup-v2.0.2.exe` / `LunarisPortable-v2.0.2.zip`
|
- **Windows**: `LunarisSetup-v2.0.10.exe` / `LunarisPortable-v2.0.10.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
@@ -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),
|
||||||
|
);
|
||||||
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
const appDisplayVersion = '2.0.2';
|
const appDisplayVersion = '2.0.15';
|
||||||
const appBuildNumber = 24;
|
const appBuildNumber = 37;
|
||||||
const appReleaseChannel = 'Stable';
|
const appReleaseChannel = 'Stable';
|
||||||
|
|||||||
@@ -1,4 +1,153 @@
|
|||||||
const lunarisChangelog = [
|
const lunarisChangelog = [
|
||||||
|
LunarisChange(
|
||||||
|
version: '2.0.15',
|
||||||
|
title: 'Settings 2.0 Layout Fixes & Pinned Quick Navigation',
|
||||||
|
bullets: [
|
||||||
|
'Fixed vertical text wrapping on mobile: separated title and full-width SegmentedButtons for Theme, Language, and Media Quality so text never collapses.',
|
||||||
|
'Pinned Category Quick Navigation: bar now stays at the top of the screen and reliably scrolls to all 7 sections.',
|
||||||
|
'SingleChildScrollView Migration: ensures all section keys remain mounted and scrollable on demand.',
|
||||||
|
'Full-width Folder Structure Tile: added clean monospace path card with dedicated change button.',
|
||||||
|
'Header text overflow protections: wrapped title texts in Expanded across all settings cards.',
|
||||||
|
],
|
||||||
|
),
|
||||||
|
LunarisChange(
|
||||||
|
version: '2.0.14',
|
||||||
|
title: 'Settings 2.0: Modern Grouped Design & Quick Ergonomics',
|
||||||
|
bullets: [
|
||||||
|
'Settings 2.0 Layout: reimagined settings experience with modern squircle card groups, smooth borders, and curated accent badges.',
|
||||||
|
'Category Quick Navigation: horizontal capsule strip at the top to smoothly jump between General, Appearance, Feed, Filters, Storage, Diagnostics, and About.',
|
||||||
|
'Instant Segmented Controls: replaced tedious dropdowns with fast 1-tap SegmentedButtons for Theme (Dark/Light/Auto), Language (RU/EN), and Media Quality (Auto/Data saver/HQ).',
|
||||||
|
'Folder Structure Bottom Sheet: intuitive download directory template picker with preset chips and custom pattern editor.',
|
||||||
|
'Interactive Stepper & Limit Controls: modernized desktop & mobile column steppers and disk cache limits with accent pill badges.',
|
||||||
|
'Hero Brand Banner: polished app presentation card in About section with glowing icon, version build badge, update checker, and Gitea repository link.',
|
||||||
|
],
|
||||||
|
),
|
||||||
|
LunarisChange(
|
||||||
|
version: '2.0.13',
|
||||||
|
title: 'Toolbar Section Dividers & Post Card Badge Stacking Fix',
|
||||||
|
bullets: [
|
||||||
|
'Toolbar Section Dividers: added subtle vertical dividers between Actions (Reset/Refresh), Rating, Providers, and Top Periods to prevent visual clutter.',
|
||||||
|
'Post Card Badge Deconfliction: moved Rating badge (Explicit/Questionable/Safe) to the bottom-left beneath the Seen/Downloaded indicators.',
|
||||||
|
'Separated Provider & Media Badges: booru source (e.g. Gelbooru) remains top-left and Video/GIF badge remains top-right, preventing them from overlapping on narrow screens.',
|
||||||
|
'Expanded Bottom Gradient: increased bottom cinematic gradient height to 64dp for clean text and badge contrast.',
|
||||||
|
],
|
||||||
|
),
|
||||||
|
LunarisChange(
|
||||||
|
version: '2.0.12',
|
||||||
|
title: 'Feed 2.0: Pinterest Modern Redesign & Sleek Cards',
|
||||||
|
bullets: [
|
||||||
|
'Feed 2.0 Design: modern squircle post cards with 14dp border radius, subtle micro-borders, and depth shadows.',
|
||||||
|
'Unified Media Badges: sleek glassmorphism capsules for booru providers, ratings, video/GIF indicators, and status badges.',
|
||||||
|
'Cinematic Bottom Gradient: gentle shading on post cards for clean presentation without obscuring art.',
|
||||||
|
'Tactile Floating Favorite Button: circular glassmorphism heart button with neon glow and instant feedback.',
|
||||||
|
'Compact 2-Tier Feed Toolbar: 22dp search capsule, filter count badge, and single unified horizontal scroll for sources and time periods.',
|
||||||
|
'Quick Filter & Actions: direct access to random post, selection mode, rating picker, and active filter resets.',
|
||||||
|
'Polished Search Presets: stylish bookmark capsules for quick access to saved search queries.',
|
||||||
|
],
|
||||||
|
),
|
||||||
|
LunarisChange(
|
||||||
|
version: '2.0.11',
|
||||||
|
title: 'Mobile Video Player Redesign & Format Recognition Fix',
|
||||||
|
bullets: [
|
||||||
|
'Fixed "Failed to recognize file format" error on initial video load by providing synchronous Referer headers and automatic retry.',
|
||||||
|
'Modern Mobile Video Player: cinematic top and bottom gradient glassmorphism overlays with immersive controls.',
|
||||||
|
'Double-Tap Seek: double-tap left side to seek -10s, right side to seek +10s with glowing ripple animations.',
|
||||||
|
'Hold for 2X Speed Boost: press and hold video to temporarily play at 2.0x with glowing badge, release to resume regular speed.',
|
||||||
|
'Vertical Volume Swipe: swipe up/down on the right half of the screen to adjust player volume with a floating badge.',
|
||||||
|
'Playback Speed Selector: quick chip button supporting 0.5x, 0.75x, 1.0x, 1.25x, 1.5x, 2.0x speeds.',
|
||||||
|
'Screen Lock Mode: lock player controls with one tap to prevent accidental touches during video watching.',
|
||||||
|
'Sleek Neon Seekbar with live network buffer progress indicator.',
|
||||||
|
],
|
||||||
|
),
|
||||||
|
LunarisChange(
|
||||||
|
version: '2.0.10',
|
||||||
|
title: 'Fix "and" Tag Operator Retention, Suggestion Preceding Drafts & AND Chip Styling',
|
||||||
|
bullets: [
|
||||||
|
'Fixed "and" operator disappearing when submitting search or applying autocompletions.',
|
||||||
|
'Preserved preceding draft tokens (e.g. typing "cat and d" then picking "dog" now retains "cat", "AND", and "dog").',
|
||||||
|
'Supported multiple "and" separators without accidental deduplication.',
|
||||||
|
'Distinct visual styling for AND separator chips with secondary theme container and alt-route icon.',
|
||||||
|
],
|
||||||
|
),
|
||||||
|
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(
|
||||||
|
version: '2.0.4',
|
||||||
|
title: 'Platform service selection & media type filtering in artist posts',
|
||||||
|
bullets: [
|
||||||
|
'Added multi-select platform filters (Patreon, Pixiv Fanbox, Fantia, Boosty, Discord) in the Artists tab.',
|
||||||
|
'Added multi-select media type filter chips (Photos, Videos, GIFs) in artist posts with counts.',
|
||||||
|
'Full-screen post pager respects active media type filters during swiping.',
|
||||||
|
],
|
||||||
|
),
|
||||||
|
LunarisChange(
|
||||||
|
version: '2.0.3',
|
||||||
|
title: 'Infinite artist pagination, favorite artists modal, and card layout polish',
|
||||||
|
bullets: [
|
||||||
|
'Implemented infinite scroll pagination in the artist post feed — now loads all historical works endlessly as you scroll.',
|
||||||
|
'Moved favorite artists to a compact star action button in the top-right header that opens an interactive modal.',
|
||||||
|
'Expanded artist card vertical height so updated dates and statistics are never cut off.',
|
||||||
|
'Removed mapping truncation in Pawchive provider to deliver all post attachments cleanly.',
|
||||||
|
],
|
||||||
|
),
|
||||||
LunarisChange(
|
LunarisChange(
|
||||||
version: '2.0.2',
|
version: '2.0.2',
|
||||||
title: 'Swipeable media pager, favorite artists, performance and Pawchive',
|
title: 'Swipeable media pager, favorite artists, performance and Pawchive',
|
||||||
|
|||||||
@@ -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
@@ -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(
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -47,6 +50,7 @@ abstract class TagMetadataProvider {
|
|||||||
abstract class ArtistProvider {
|
abstract class ArtistProvider {
|
||||||
Future<List<ArtistProfile>> listArtists({
|
Future<List<ArtistProfile>> listArtists({
|
||||||
String? service,
|
String? service,
|
||||||
|
List<String>? services,
|
||||||
String? query,
|
String? query,
|
||||||
int page = 1,
|
int page = 1,
|
||||||
int limit = 30,
|
int limit = 30,
|
||||||
@@ -55,6 +59,7 @@ abstract class ArtistProvider {
|
|||||||
Future<List<ArtistProfile>> searchArtists(
|
Future<List<ArtistProfile>> searchArtists(
|
||||||
String query, {
|
String query, {
|
||||||
String? service,
|
String? service,
|
||||||
|
List<String>? services,
|
||||||
int page = 1,
|
int page = 1,
|
||||||
int limit = 30,
|
int limit = 30,
|
||||||
});
|
});
|
||||||
@@ -70,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 [];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -91,66 +94,56 @@ class KemonoProvider
|
|||||||
@override
|
@override
|
||||||
Future<List<ArtistProfile>> listArtists({
|
Future<List<ArtistProfile>> listArtists({
|
||||||
String? service,
|
String? service,
|
||||||
|
List<String>? services,
|
||||||
String? query,
|
String? query,
|
||||||
int page = 1,
|
int page = 1,
|
||||||
int limit = 30,
|
int limit = 30,
|
||||||
}) async {
|
}) async {
|
||||||
final searchText = query?.trim() ?? '';
|
final effectiveService =
|
||||||
if (searchText.isNotEmpty) {
|
services != null && services.length == 1 ? services.first : service;
|
||||||
try {
|
List<ArtistProfile> list;
|
||||||
final response = await _creatorDio.get<dynamic>(
|
|
||||||
_creatorPath(),
|
|
||||||
queryParameters: {
|
|
||||||
'keyword': searchText,
|
|
||||||
'page': page,
|
|
||||||
'itemsPerPage': limit.clamp(1, 100),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
final artists = _rankArtists(
|
|
||||||
_artistsFromResponse(response.data)
|
|
||||||
.where((artist) =>
|
|
||||||
service == null ||
|
|
||||||
service.isEmpty ||
|
|
||||||
artist.service == service)
|
|
||||||
.toList(growable: false),
|
|
||||||
searchText,
|
|
||||||
);
|
|
||||||
if (artists.isNotEmpty) return artists;
|
|
||||||
} catch (_) {
|
|
||||||
// Fall through to the indexed creator endpoints.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
final response = await _creatorDio.get<dynamic>(
|
final response = await _creatorDio.get<dynamic>(
|
||||||
service == null || service.isEmpty
|
effectiveService == null || effectiveService.isEmpty
|
||||||
? _creatorPath()
|
? _creatorPath()
|
||||||
: '${_creatorPath()}/$service',
|
: '${_creatorPath()}/$effectiveService',
|
||||||
queryParameters: {
|
queryParameters: {
|
||||||
'page': page,
|
'page': page,
|
||||||
'itemsPerPage': limit.clamp(1, 100),
|
'itemsPerPage': limit.clamp(1, 100),
|
||||||
if (query != null && query.trim().isNotEmpty) 'keyword': query.trim(),
|
if (query != null && query.trim().isNotEmpty) 'keyword': query.trim(),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
return _rankArtists(_artistsFromResponse(response.data), query ?? '');
|
list = _rankArtists(_artistsFromResponse(response.data), query ?? '');
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
return _listArtistsFromPublicApi(
|
list = await _listArtistsFromPublicApi(
|
||||||
service: service,
|
service: effectiveService,
|
||||||
query: query,
|
query: query,
|
||||||
page: page,
|
page: page,
|
||||||
limit: limit,
|
limit: limit,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (services != null && services.isNotEmpty) {
|
||||||
|
final sSet = services.map((s) => s.toLowerCase()).toSet();
|
||||||
|
list = list.where((a) => sSet.contains(a.service.toLowerCase())).toList();
|
||||||
|
}
|
||||||
|
return list;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<List<ArtistProfile>> searchArtists(
|
Future<List<ArtistProfile>> searchArtists(
|
||||||
String query, {
|
String query, {
|
||||||
String? service,
|
String? service,
|
||||||
|
List<String>? services,
|
||||||
int page = 1,
|
int page = 1,
|
||||||
int limit = 30,
|
int limit = 30,
|
||||||
}) {
|
}) {
|
||||||
return listArtists(
|
return listArtists(
|
||||||
service: service, query: query, page: page, limit: limit);
|
service: service,
|
||||||
|
services: services,
|
||||||
|
query: query,
|
||||||
|
page: page,
|
||||||
|
limit: limit,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -224,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) {
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -122,16 +152,23 @@ class PawchiveProvider
|
|||||||
@override
|
@override
|
||||||
Future<List<ArtistProfile>> listArtists({
|
Future<List<ArtistProfile>> listArtists({
|
||||||
String? service,
|
String? service,
|
||||||
|
List<String>? services,
|
||||||
String? query,
|
String? query,
|
||||||
int page = 1,
|
int page = 1,
|
||||||
int limit = 30,
|
int limit = 30,
|
||||||
}) async {
|
}) async {
|
||||||
final allArtists = await _ensureArtistsLoaded();
|
final allArtists = await _ensureArtistsLoaded();
|
||||||
final queryText = (query ?? '').trim().toLowerCase();
|
final queryText = (query ?? '').trim().toLowerCase();
|
||||||
|
final activeServices = services != null && services.isNotEmpty
|
||||||
|
? services.map((s) => s.trim().toLowerCase()).toSet()
|
||||||
|
: null;
|
||||||
final filtered = allArtists.where((artist) {
|
final filtered = allArtists.where((artist) {
|
||||||
final matchService = service == null ||
|
final artistService = artist.service.toLowerCase();
|
||||||
service.isEmpty ||
|
final matchService = activeServices != null
|
||||||
artist.service.toLowerCase() == service.toLowerCase();
|
? activeServices.contains(artistService)
|
||||||
|
: service == null ||
|
||||||
|
service.isEmpty ||
|
||||||
|
artistService == service.toLowerCase();
|
||||||
if (!matchService) return false;
|
if (!matchService) return false;
|
||||||
if (queryText.isEmpty) return true;
|
if (queryText.isEmpty) return true;
|
||||||
return artist.displayName.toLowerCase().contains(queryText) ||
|
return artist.displayName.toLowerCase().contains(queryText) ||
|
||||||
@@ -147,11 +184,13 @@ class PawchiveProvider
|
|||||||
Future<List<ArtistProfile>> searchArtists(
|
Future<List<ArtistProfile>> searchArtists(
|
||||||
String query, {
|
String query, {
|
||||||
String? service,
|
String? service,
|
||||||
|
List<String>? services,
|
||||||
int page = 1,
|
int page = 1,
|
||||||
int limit = 30,
|
int limit = 30,
|
||||||
}) {
|
}) {
|
||||||
return listArtists(
|
return listArtists(
|
||||||
service: service,
|
service: service,
|
||||||
|
services: services,
|
||||||
query: query,
|
query: query,
|
||||||
page: page,
|
page: page,
|
||||||
limit: limit,
|
limit: limit,
|
||||||
@@ -169,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>[];
|
||||||
@@ -180,7 +225,7 @@ class PawchiveProvider
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
mapped.sort((a, b) => b.createdAt.compareTo(a.createdAt));
|
mapped.sort((a, b) => b.createdAt.compareTo(a.createdAt));
|
||||||
return mapped.take(limit).toList(growable: false);
|
return mapped.toList(growable: false);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw StateError(
|
throw StateError(
|
||||||
'$name artist posts are unavailable right now. ${_shortError(error)}',
|
'$name artist posts are unavailable right now. ${_shortError(error)}',
|
||||||
@@ -207,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(':');
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -1,43 +1,101 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
List<String> parseTags(String query) {
|
TagCacheService? get tagCacheService => _tagCacheService;
|
||||||
return query
|
|
||||||
|
static String sanitizeToken(String raw) {
|
||||||
|
return raw
|
||||||
|
.replaceAll(RegExp(r'^[\(\)]+|[\(\)]+$'), '')
|
||||||
.trim()
|
.trim()
|
||||||
.split(RegExp(r'\s+'))
|
.toLowerCase();
|
||||||
.where((tag) => tag.isNotEmpty)
|
|
||||||
.toSet()
|
|
||||||
.toList();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<Result<void>> saveSearch(String query, int resultCount) {
|
List<String> parseTags(String query) {
|
||||||
|
final seen = <String>{};
|
||||||
|
final result = <String>[];
|
||||||
|
for (final raw in query.trim().split(RegExp(r'\s+'))) {
|
||||||
|
final tag = raw.trim();
|
||||||
|
if (tag.isEmpty) continue;
|
||||||
|
if (tag.toLowerCase() != 'and' && !seen.add(tag.toLowerCase())) continue;
|
||||||
|
result.add(tag);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 +105,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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 (_) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+6
@@ -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;
|
||||||
|
|||||||
@@ -20,12 +20,12 @@ final artistPostsProvider =
|
|||||||
final provider = providers.data.firstWhere(
|
final provider = providers.data.firstWhere(
|
||||||
(item) => (item as ContentProvider).id == query.providerId,
|
(item) => (item as ContentProvider).id == query.providerId,
|
||||||
);
|
);
|
||||||
final posts = await provider.getArtistPosts(query: query, limit: 50);
|
final posts = await provider.getArtistPosts(query: query, page: 0, limit: 50);
|
||||||
await ref.read(cacheServiceProvider).cachePosts(posts);
|
await ref.read(cacheServiceProvider).cachePosts(posts);
|
||||||
return posts;
|
return posts;
|
||||||
});
|
});
|
||||||
|
|
||||||
class ArtistPostsScreen extends ConsumerWidget {
|
class ArtistPostsScreen extends ConsumerStatefulWidget {
|
||||||
const ArtistPostsScreen({
|
const ArtistPostsScreen({
|
||||||
required this.providerId,
|
required this.providerId,
|
||||||
required this.service,
|
required this.service,
|
||||||
@@ -40,77 +40,467 @@ class ArtistPostsScreen extends ConsumerWidget {
|
|||||||
final String artistName;
|
final String artistName;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
ConsumerState<ArtistPostsScreen> createState() => _ArtistPostsScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
|
||||||
|
final _scrollController = ScrollController();
|
||||||
|
final List<Post> _posts = [];
|
||||||
|
final Set<String> _selectedTypes = <String>{};
|
||||||
|
|
||||||
|
List<ArtistTag> _tags = [];
|
||||||
|
List<ArtistAnnouncement> _announcements = [];
|
||||||
|
String? _selectedTag;
|
||||||
|
|
||||||
|
int _page = 0;
|
||||||
|
bool _loading = false;
|
||||||
|
bool _hasMore = true;
|
||||||
|
Object? _error;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_scrollController.addListener(() {
|
||||||
|
if (_scrollController.position.extentAfter < 600) {
|
||||||
|
_loadMore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
_loadMore();
|
||||||
|
_loadMetadata();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_scrollController.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 {
|
||||||
|
setState(() {
|
||||||
|
_posts.clear();
|
||||||
|
_page = 0;
|
||||||
|
_hasMore = true;
|
||||||
|
_error = null;
|
||||||
|
});
|
||||||
|
await Future.wait([_loadMore(), _loadMetadata()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadMore() async {
|
||||||
|
if (_loading || !_hasMore) return;
|
||||||
|
setState(() {
|
||||||
|
_loading = true;
|
||||||
|
_error = null;
|
||||||
|
});
|
||||||
|
|
||||||
final query = ArtistWorkQuery(
|
final query = ArtistWorkQuery(
|
||||||
providerId: providerId,
|
providerId: widget.providerId,
|
||||||
service: service,
|
service: widget.service,
|
||||||
artistId: artistId,
|
artistId: widget.artistId,
|
||||||
artistName: artistName,
|
artistName: widget.artistName,
|
||||||
|
tagFilter: _selectedTag,
|
||||||
);
|
);
|
||||||
final posts = ref.watch(artistPostsProvider(query));
|
|
||||||
|
try {
|
||||||
|
final providers =
|
||||||
|
await ref.read(providerManagerProvider).activeArtistProviders();
|
||||||
|
if (providers is! Success<List<ArtistProvider>>) {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_loading = false;
|
||||||
|
_error = 'Could not load artist providers';
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final provider = providers.data.firstWhere(
|
||||||
|
(item) => (item as ContentProvider).id == query.providerId,
|
||||||
|
);
|
||||||
|
final newPosts = await provider.getArtistPosts(
|
||||||
|
query: query,
|
||||||
|
page: _page,
|
||||||
|
limit: 50,
|
||||||
|
);
|
||||||
|
await ref.read(cacheServiceProvider).cachePosts(newPosts);
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_loading = false;
|
||||||
|
if (newPosts.isEmpty) {
|
||||||
|
_hasMore = false;
|
||||||
|
} else {
|
||||||
|
final existingKeys = _posts.map((p) => p.cacheKey).toSet();
|
||||||
|
final uniqueNew =
|
||||||
|
newPosts.where((p) => !existingKeys.contains(p.cacheKey)).toList();
|
||||||
|
_posts.addAll(uniqueNew);
|
||||||
|
_page++;
|
||||||
|
if (newPosts.length < 10) {
|
||||||
|
_hasMore = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_loading = false;
|
||||||
|
_error = error;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onTagSelected(String? tag) {
|
||||||
|
if (_selectedTag == tag) return;
|
||||||
|
setState(() {
|
||||||
|
_selectedTag = tag;
|
||||||
|
_posts.clear();
|
||||||
|
_page = 0;
|
||||||
|
_hasMore = true;
|
||||||
|
_error = null;
|
||||||
|
});
|
||||||
|
_loadMore();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _matchesTypeFilter(Post post) {
|
||||||
|
if (_selectedTypes.isEmpty) return true;
|
||||||
|
final isVid = MediaUrlSelector.isVideo(post);
|
||||||
|
final isGif = MediaUrlSelector.isGif(post);
|
||||||
|
final isPhoto = !isVid && !isGif;
|
||||||
|
|
||||||
|
if (_selectedTypes.contains('video') && isVid) return true;
|
||||||
|
if (_selectedTypes.contains('gif') && isGif) return true;
|
||||||
|
if (_selectedTypes.contains('photo') && isPhoto) return true;
|
||||||
|
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() {
|
||||||
|
final scheme = Theme.of(context).colorScheme;
|
||||||
|
final photoCount = _posts
|
||||||
|
.where((p) => !MediaUrlSelector.isVideo(p) && !MediaUrlSelector.isGif(p))
|
||||||
|
.length;
|
||||||
|
final videoCount = _posts.where(MediaUrlSelector.isVideo).length;
|
||||||
|
final gifCount = _posts.where(MediaUrlSelector.isGif).length;
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 6, 16, 4),
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
FilterChip(
|
||||||
|
selected: _selectedTypes.isEmpty,
|
||||||
|
label: Text('Все (${_posts.length})'),
|
||||||
|
onSelected: (_) => setState(() => _selectedTypes.clear()),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
FilterChip(
|
||||||
|
selected: _selectedTypes.contains('photo'),
|
||||||
|
avatar: Icon(Icons.photo_outlined,
|
||||||
|
size: 16,
|
||||||
|
color: _selectedTypes.contains('photo')
|
||||||
|
? scheme.onPrimaryContainer
|
||||||
|
: scheme.onSurfaceVariant),
|
||||||
|
label: Text('Фото ($photoCount)'),
|
||||||
|
onSelected: (selected) {
|
||||||
|
setState(() {
|
||||||
|
if (selected) {
|
||||||
|
_selectedTypes.add('photo');
|
||||||
|
} else {
|
||||||
|
_selectedTypes.remove('photo');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
FilterChip(
|
||||||
|
selected: _selectedTypes.contains('video'),
|
||||||
|
avatar: Icon(Icons.videocam_outlined,
|
||||||
|
size: 16,
|
||||||
|
color: _selectedTypes.contains('video')
|
||||||
|
? scheme.onPrimaryContainer
|
||||||
|
: scheme.onSurfaceVariant),
|
||||||
|
label: Text('Видео ($videoCount)'),
|
||||||
|
onSelected: (selected) {
|
||||||
|
setState(() {
|
||||||
|
if (selected) {
|
||||||
|
_selectedTypes.add('video');
|
||||||
|
} else {
|
||||||
|
_selectedTypes.remove('video');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
FilterChip(
|
||||||
|
selected: _selectedTypes.contains('gif'),
|
||||||
|
avatar: Icon(Icons.gif_rounded,
|
||||||
|
size: 20,
|
||||||
|
color: _selectedTypes.contains('gif')
|
||||||
|
? scheme.onPrimaryContainer
|
||||||
|
: scheme.onSurfaceVariant),
|
||||||
|
label: Text('GIF ($gifCount)'),
|
||||||
|
onSelected: (selected) {
|
||||||
|
setState(() {
|
||||||
|
if (selected) {
|
||||||
|
_selectedTypes.add('gif');
|
||||||
|
} else {
|
||||||
|
_selectedTypes.remove('gif');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
Widget build(BuildContext context) {
|
||||||
final settings =
|
final settings =
|
||||||
ref.watch(appSettingsProvider).value ?? AppSettings.defaults;
|
ref.watch(appSettingsProvider).value ?? AppSettings.defaults;
|
||||||
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);
|
||||||
|
|
||||||
return AdaptiveScaffold(
|
return AdaptiveScaffold(
|
||||||
title: 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: () => ref.invalidate(artistPostsProvider(query)),
|
onPressed: _refresh,
|
||||||
icon: const Icon(Icons.refresh_rounded),
|
icon: const Icon(Icons.refresh_rounded),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
body: posts.when(
|
body: _posts.isEmpty && _loading
|
||||||
loading: () => const Center(child: CircularProgressIndicator()),
|
? const Center(child: CircularProgressIndicator())
|
||||||
error: (error, _) => ErrorView(
|
: _posts.isEmpty && _error != null
|
||||||
message: _friendlyArtistPostError(error),
|
? ErrorView(
|
||||||
onRetry: () => ref.invalidate(artistPostsProvider(query)),
|
message: _friendlyArtistPostError(_error!),
|
||||||
),
|
onRetry: _refresh,
|
||||||
data: (items) => items.isEmpty
|
)
|
||||||
? const EmptyView(
|
: _posts.isEmpty
|
||||||
title: 'No works',
|
? Column(
|
||||||
message: 'This artist has no visible media yet.',
|
children: [
|
||||||
)
|
_buildTagsBar(),
|
||||||
: PostMasonryGrid(
|
const Expanded(
|
||||||
posts: items,
|
child: EmptyView(
|
||||||
columns: Responsive.columnsFor(
|
title: 'Нет работ',
|
||||||
context,
|
message: 'У этого автора пока нет видимых постов.',
|
||||||
mobileColumns: settings.mobileColumns,
|
),
|
||||||
desktopColumns: settings.desktopColumns,
|
),
|
||||||
),
|
],
|
||||||
blurExplicit: settings.blurExplicitContent,
|
)
|
||||||
showBadges: settings.showPostBadges,
|
: Column(
|
||||||
nsfwEnabled: settings.nsfwEnabled,
|
children: [
|
||||||
mediaQualityMode:
|
_buildMediaTypeFilters(),
|
||||||
MediaQualityMode.fromName(settings.mediaQualityMode),
|
_buildTagsBar(),
|
||||||
favoriteKeys: favoriteKeys,
|
Expanded(
|
||||||
viewedKeys: viewedKeys,
|
child: displayedPosts.isEmpty
|
||||||
onOpen: (post) => context.push(
|
? Center(
|
||||||
'/post/${post.providerId}/${post.id}',
|
child: Padding(
|
||||||
extra: PostNavigationContext(
|
padding: const EdgeInsets.all(24),
|
||||||
currentPost: post,
|
child: Column(
|
||||||
posts: items,
|
mainAxisSize: MainAxisSize.min,
|
||||||
),
|
children: [
|
||||||
),
|
const Icon(
|
||||||
onFavorite: (post) async {
|
Icons.filter_alt_off_rounded,
|
||||||
if (favoriteKeys.contains(post.cacheKey)) {
|
size: 48),
|
||||||
await ref
|
const SizedBox(height: 12),
|
||||||
.read(favoriteServiceProvider)
|
const Text(
|
||||||
.removeFavorite(post.id, post.providerId);
|
'Нет медиа по выбранным фильтрам',
|
||||||
} else {
|
style: TextStyle(
|
||||||
await ref.read(favoriteServiceProvider).addFavorite(
|
fontWeight: FontWeight.w600),
|
||||||
post,
|
),
|
||||||
settings: settings,
|
const SizedBox(height: 8),
|
||||||
downloadManager:
|
FilledButton.tonal(
|
||||||
ref.read(downloadManagerServiceProvider),
|
onPressed: () {
|
||||||
);
|
setState(() {
|
||||||
}
|
_selectedTypes.clear();
|
||||||
ref.invalidate(favoriteKeysProvider);
|
_selectedTag = null;
|
||||||
ref.invalidate(favoritesControllerProvider);
|
});
|
||||||
},
|
},
|
||||||
),
|
child: const Text('Сбросить фильтры'),
|
||||||
),
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: PostMasonryGrid(
|
||||||
|
posts: displayedPosts,
|
||||||
|
controller: _scrollController,
|
||||||
|
loading: _loading,
|
||||||
|
gridMode: settings.gridMode,
|
||||||
|
columns: Responsive.columnsFor(
|
||||||
|
context,
|
||||||
|
mobileColumns: settings.mobileColumns,
|
||||||
|
desktopColumns: settings.desktopColumns,
|
||||||
|
),
|
||||||
|
blurExplicit: settings.blurExplicitContent,
|
||||||
|
showBadges: settings.showPostBadges,
|
||||||
|
nsfwEnabled: settings.nsfwEnabled,
|
||||||
|
mediaQualityMode: MediaQualityMode.fromName(
|
||||||
|
settings.mediaQualityMode),
|
||||||
|
favoriteKeys: favoriteKeys,
|
||||||
|
viewedKeys: viewedKeys,
|
||||||
|
onOpen: (post) => context.push(
|
||||||
|
'/post/${post.providerId}/${post.id}',
|
||||||
|
extra: PostNavigationContext(
|
||||||
|
currentPost: post,
|
||||||
|
posts: displayedPosts,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onFavorite: (post) async {
|
||||||
|
if (favoriteKeys.contains(post.cacheKey)) {
|
||||||
|
await ref
|
||||||
|
.read(favoriteServiceProvider)
|
||||||
|
.removeFavorite(
|
||||||
|
post.id, post.providerId);
|
||||||
|
} else {
|
||||||
|
await ref
|
||||||
|
.read(favoriteServiceProvider)
|
||||||
|
.addFavorite(
|
||||||
|
post,
|
||||||
|
settings: settings,
|
||||||
|
downloadManager: ref.read(
|
||||||
|
downloadManagerServiceProvider),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
ref.invalidate(favoriteKeysProvider);
|
||||||
|
ref.invalidate(
|
||||||
|
favoritesControllerProvider);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,7 +508,7 @@ class ArtistPostsScreen extends ConsumerWidget {
|
|||||||
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ class ArtistsScreen extends ConsumerStatefulWidget {
|
|||||||
class _ArtistsScreenState extends ConsumerState<ArtistsScreen> {
|
class _ArtistsScreenState extends ConsumerState<ArtistsScreen> {
|
||||||
final _search = TextEditingController();
|
final _search = TextEditingController();
|
||||||
final _scroll = ScrollController();
|
final _scroll = ScrollController();
|
||||||
|
final Set<String> _selectedServices = <String>{};
|
||||||
String? _providerId;
|
String? _providerId;
|
||||||
var _page = 1;
|
var _page = 1;
|
||||||
var _loading = false;
|
var _loading = false;
|
||||||
@@ -107,16 +108,23 @@ class _ArtistsScreenState extends ConsumerState<ArtistsScreen> {
|
|||||||
.whereType<_FavoriteArtistItem>()
|
.whereType<_FavoriteArtistItem>()
|
||||||
.toList();
|
.toList();
|
||||||
final favoriteKeys = favoriteItems.map((e) => e.key).toSet();
|
final favoriteKeys = favoriteItems.map((e) => e.key).toSet();
|
||||||
final selectedFav = favoriteItems.firstWhere(
|
|
||||||
(e) => e.key == _selectedFavoriteArtistKey,
|
|
||||||
orElse: () => favoriteItems.isNotEmpty
|
|
||||||
? favoriteItems.first
|
|
||||||
: const _FavoriteArtistItem.empty(),
|
|
||||||
);
|
|
||||||
|
|
||||||
return AdaptiveScaffold(
|
return AdaptiveScaffold(
|
||||||
title: 'Artists',
|
title: 'Artists',
|
||||||
actions: [
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
tooltip: 'Любимые авторы',
|
||||||
|
icon: Badge(
|
||||||
|
isLabelVisible: favoriteItems.isNotEmpty,
|
||||||
|
label: Text('${favoriteItems.length}'),
|
||||||
|
child: const Icon(Icons.star_rounded, color: Colors.amber),
|
||||||
|
),
|
||||||
|
onPressed: () => _showFavoriteArtistsModal(
|
||||||
|
context,
|
||||||
|
settings,
|
||||||
|
favoriteItems,
|
||||||
|
),
|
||||||
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
tooltip: 'Refresh',
|
tooltip: 'Refresh',
|
||||||
onPressed: () => _refresh(configs.valueOrNull ?? const []),
|
onPressed: () => _refresh(configs.valueOrNull ?? const []),
|
||||||
@@ -138,43 +146,39 @@ class _ArtistsScreenState extends ConsumerState<ArtistsScreen> {
|
|||||||
WidgetsBinding.instance
|
WidgetsBinding.instance
|
||||||
.addPostFrameCallback((_) => _refresh(items));
|
.addPostFrameCallback((_) => _refresh(items));
|
||||||
}
|
}
|
||||||
|
final displayedArtists = _selectedServices.isEmpty
|
||||||
|
? _artists
|
||||||
|
: _artists
|
||||||
|
.where((a) =>
|
||||||
|
_selectedServices.contains(a.service.toLowerCase()))
|
||||||
|
.toList(growable: false);
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
_ArtistsHeader(
|
_ArtistsHeader(
|
||||||
searchController: _search,
|
searchController: _search,
|
||||||
providers: items,
|
providers: items,
|
||||||
selectedProviderId: _providerId,
|
selectedProviderId: _providerId,
|
||||||
|
selectedServices: _selectedServices,
|
||||||
onProviderChanged: (value) {
|
onProviderChanged: (value) {
|
||||||
setState(() => _providerId = value);
|
setState(() => _providerId = value);
|
||||||
_refresh(items);
|
_refresh(items);
|
||||||
},
|
},
|
||||||
onSearch: () => _refresh(items),
|
onToggleService: (service) {
|
||||||
),
|
setState(() {
|
||||||
_FavoriteArtistsSection(
|
if (_selectedServices.contains(service)) {
|
||||||
favorites: favoriteItems,
|
_selectedServices.remove(service);
|
||||||
selectedKey: selectedFav.isEmpty ? null : selectedFav.key,
|
} else {
|
||||||
onSelect: (fav) =>
|
_selectedServices.add(service);
|
||||||
setState(() => _selectedFavoriteArtistKey = fav.key),
|
}
|
||||||
onOpenArtist: (fav) => context.push(
|
});
|
||||||
'/artists/${fav.providerId}/${fav.service}/${fav.id}?name=${Uri.encodeComponent(fav.name)}',
|
_refresh(items);
|
||||||
),
|
|
||||||
onRemoveFavorite: (fav) async {
|
|
||||||
final updated = List<String>.from(settings.favoriteArtists)
|
|
||||||
..removeWhere((e) {
|
|
||||||
try {
|
|
||||||
return _FavoriteArtistItem.fromJson(
|
|
||||||
jsonDecode(e) as Map<String, dynamic>)
|
|
||||||
.key ==
|
|
||||||
fav.key;
|
|
||||||
} catch (_) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
await ref
|
|
||||||
.read(settingsControllerProvider.notifier)
|
|
||||||
.saveSettings(
|
|
||||||
settings.copyWith(favoriteArtists: updated));
|
|
||||||
},
|
},
|
||||||
|
onClearServices: () {
|
||||||
|
setState(() => _selectedServices.clear());
|
||||||
|
_refresh(items);
|
||||||
|
},
|
||||||
|
onSearch: () => _refresh(items),
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: _error != null
|
child: _error != null
|
||||||
@@ -182,10 +186,33 @@ class _ArtistsScreenState extends ConsumerState<ArtistsScreen> {
|
|||||||
message: _friendlyArtistError(_error),
|
message: _friendlyArtistError(_error),
|
||||||
onRetry: () => _refresh(items),
|
onRetry: () => _refresh(items),
|
||||||
)
|
)
|
||||||
: _artists.isEmpty && !_loading
|
: displayedArtists.isEmpty && !_loading
|
||||||
? const EmptyView(
|
? Center(
|
||||||
title: 'No artists',
|
child: Padding(
|
||||||
message: 'Try another name or provider.',
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.person_search_outlined,
|
||||||
|
size: 48),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
const Text(
|
||||||
|
'Нет авторов по выбранным фильтрам',
|
||||||
|
style:
|
||||||
|
TextStyle(fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
FilledButton.tonal(
|
||||||
|
onPressed: () {
|
||||||
|
setState(
|
||||||
|
() => _selectedServices.clear());
|
||||||
|
_refresh(items);
|
||||||
|
},
|
||||||
|
child: const Text('Показать все сервисы'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
: LayoutBuilder(
|
: LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
@@ -206,14 +233,15 @@ class _ArtistsScreenState extends ConsumerState<ArtistsScreen> {
|
|||||||
crossAxisSpacing: 12,
|
crossAxisSpacing: 12,
|
||||||
mainAxisSpacing: 12,
|
mainAxisSpacing: 12,
|
||||||
mainAxisExtent:
|
mainAxisExtent:
|
||||||
Responsive.isMobile(context) ? 88 : 104,
|
Responsive.isMobile(context) ? 104 : 116,
|
||||||
),
|
),
|
||||||
itemCount: _artists.length + (_loading ? 1 : 0),
|
itemCount:
|
||||||
|
displayedArtists.length + (_loading ? 1 : 0),
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
if (index >= _artists.length) {
|
if (index >= displayedArtists.length) {
|
||||||
return const _ArtistSkeletonCard();
|
return const _ArtistSkeletonCard();
|
||||||
}
|
}
|
||||||
final artist = _artists[index];
|
final artist = displayedArtists[index];
|
||||||
final isFav = favoriteKeys.contains(
|
final isFav = favoriteKeys.contains(
|
||||||
'${artist.providerId}:${artist.service}:${artist.id}');
|
'${artist.providerId}:${artist.service}:${artist.id}');
|
||||||
return _ArtistCard(
|
return _ArtistCard(
|
||||||
@@ -269,6 +297,7 @@ class _ArtistsScreenState extends ConsumerState<ArtistsScreen> {
|
|||||||
);
|
);
|
||||||
final next = await provider.searchArtists(
|
final next = await provider.searchArtists(
|
||||||
_search.text.trim(),
|
_search.text.trim(),
|
||||||
|
services: _selectedServices.isEmpty ? null : _selectedServices.toList(),
|
||||||
page: _page,
|
page: _page,
|
||||||
limit: 30,
|
limit: 30,
|
||||||
);
|
);
|
||||||
@@ -285,6 +314,50 @@ class _ArtistsScreenState extends ConsumerState<ArtistsScreen> {
|
|||||||
if (mounted) setState(() => _loading = false);
|
if (mounted) setState(() => _loading = false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _showFavoriteArtistsModal(
|
||||||
|
BuildContext context,
|
||||||
|
AppSettings settings,
|
||||||
|
List<_FavoriteArtistItem> favorites,
|
||||||
|
) {
|
||||||
|
showModalBottomSheet<void>(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
useSafeArea: true,
|
||||||
|
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||||
|
shape: const RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||||
|
),
|
||||||
|
builder: (ctx) {
|
||||||
|
return _FavoriteArtistsModal(
|
||||||
|
favorites: favorites,
|
||||||
|
initialSelectedKey: _selectedFavoriteArtistKey,
|
||||||
|
onSelect: (fav) =>
|
||||||
|
setState(() => _selectedFavoriteArtistKey = fav.key),
|
||||||
|
onOpenArtist: (fav) => context.push(
|
||||||
|
'/artists/${fav.providerId}/${fav.service}/${fav.id}?name=${Uri.encodeComponent(fav.name)}',
|
||||||
|
),
|
||||||
|
onRemoveFavorite: (fav) async {
|
||||||
|
final updated = List<String>.from(settings.favoriteArtists)
|
||||||
|
..removeWhere((e) {
|
||||||
|
try {
|
||||||
|
return _FavoriteArtistItem.fromJson(
|
||||||
|
jsonDecode(e) as Map<String, dynamic>)
|
||||||
|
.key ==
|
||||||
|
fav.key;
|
||||||
|
} catch (_) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await ref
|
||||||
|
.read(settingsControllerProvider.notifier)
|
||||||
|
.saveSettings(settings.copyWith(favoriteArtists: updated));
|
||||||
|
setState(() {});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _ArtistsHeader extends StatelessWidget {
|
class _ArtistsHeader extends StatelessWidget {
|
||||||
@@ -292,16 +365,41 @@ class _ArtistsHeader extends StatelessWidget {
|
|||||||
required this.searchController,
|
required this.searchController,
|
||||||
required this.providers,
|
required this.providers,
|
||||||
required this.selectedProviderId,
|
required this.selectedProviderId,
|
||||||
|
required this.selectedServices,
|
||||||
required this.onProviderChanged,
|
required this.onProviderChanged,
|
||||||
|
required this.onToggleService,
|
||||||
|
required this.onClearServices,
|
||||||
required this.onSearch,
|
required this.onSearch,
|
||||||
});
|
});
|
||||||
|
|
||||||
final TextEditingController searchController;
|
final TextEditingController searchController;
|
||||||
final List<ContentProviderConfig> providers;
|
final List<ContentProviderConfig> providers;
|
||||||
final String? selectedProviderId;
|
final String? selectedProviderId;
|
||||||
|
final Set<String> selectedServices;
|
||||||
final ValueChanged<String?> onProviderChanged;
|
final ValueChanged<String?> onProviderChanged;
|
||||||
|
final ValueChanged<String> onToggleService;
|
||||||
|
final VoidCallback onClearServices;
|
||||||
final VoidCallback onSearch;
|
final VoidCallback onSearch;
|
||||||
|
|
||||||
|
static const _availableServices = ['patreon', 'fanbox', 'fantia', 'boosty', 'discord'];
|
||||||
|
|
||||||
|
String _formatServiceName(String s) {
|
||||||
|
switch (s.toLowerCase()) {
|
||||||
|
case 'patreon':
|
||||||
|
return 'Patreon';
|
||||||
|
case 'fanbox':
|
||||||
|
return 'Pixiv Fanbox';
|
||||||
|
case 'fantia':
|
||||||
|
return 'Fantia';
|
||||||
|
case 'boosty':
|
||||||
|
return 'Boosty';
|
||||||
|
case 'discord':
|
||||||
|
return 'Discord';
|
||||||
|
default:
|
||||||
|
return s.isEmpty ? s : s[0].toUpperCase() + s.substring(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final scheme = Theme.of(context).colorScheme;
|
final scheme = Theme.of(context).colorScheme;
|
||||||
@@ -357,6 +455,29 @@ class _ArtistsHeader extends StatelessWidget {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
SizedBox(
|
||||||
|
height: 36,
|
||||||
|
child: ListView(
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
children: [
|
||||||
|
FilterChip(
|
||||||
|
selected: selectedServices.isEmpty,
|
||||||
|
label: const Text('Все платформы'),
|
||||||
|
onSelected: (_) => onClearServices(),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
for (final service in _availableServices) ...[
|
||||||
|
FilterChip(
|
||||||
|
selected: selectedServices.contains(service),
|
||||||
|
label: Text(_formatServiceName(service)),
|
||||||
|
onSelected: (_) => onToggleService(service),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -388,7 +509,7 @@ class _ArtistCard extends StatelessWidget {
|
|||||||
child: InkWell(
|
child: InkWell(
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(12),
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
_ArtistAvatar(artist: artist),
|
_ArtistAvatar(artist: artist),
|
||||||
@@ -492,6 +613,176 @@ class _FavoriteArtistItem {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _FavoriteArtistsModal extends StatefulWidget {
|
||||||
|
const _FavoriteArtistsModal({
|
||||||
|
required this.favorites,
|
||||||
|
required this.initialSelectedKey,
|
||||||
|
required this.onSelect,
|
||||||
|
required this.onOpenArtist,
|
||||||
|
required this.onRemoveFavorite,
|
||||||
|
});
|
||||||
|
|
||||||
|
final List<_FavoriteArtistItem> favorites;
|
||||||
|
final String? initialSelectedKey;
|
||||||
|
final ValueChanged<_FavoriteArtistItem> onSelect;
|
||||||
|
final ValueChanged<_FavoriteArtistItem> onOpenArtist;
|
||||||
|
final ValueChanged<_FavoriteArtistItem> onRemoveFavorite;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_FavoriteArtistsModal> createState() => _FavoriteArtistsModalState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FavoriteArtistsModalState extends State<_FavoriteArtistsModal> {
|
||||||
|
late String? _selectedKey;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_selectedKey = widget.initialSelectedKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final scheme = Theme.of(context).colorScheme;
|
||||||
|
final selectedFav = widget.favorites.firstWhere(
|
||||||
|
(e) => e.key == _selectedKey,
|
||||||
|
orElse: () => widget.favorites.isNotEmpty
|
||||||
|
? widget.favorites.first
|
||||||
|
: const _FavoriteArtistItem.empty(),
|
||||||
|
);
|
||||||
|
|
||||||
|
return DraggableScrollableSheet(
|
||||||
|
expand: false,
|
||||||
|
initialChildSize: widget.favorites.isEmpty ? 0.35 : 0.65,
|
||||||
|
minChildSize: 0.25,
|
||||||
|
maxChildSize: 0.92,
|
||||||
|
builder: (context, scroll) {
|
||||||
|
return SingleChildScrollView(
|
||||||
|
controller: scroll,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Center(
|
||||||
|
child: Container(
|
||||||
|
width: 40,
|
||||||
|
height: 4,
|
||||||
|
margin: const EdgeInsets.symmetric(vertical: 12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: scheme.onSurfaceVariant.withValues(alpha: 0.4),
|
||||||
|
borderRadius: BorderRadius.circular(2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding:
|
||||||
|
const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.star_rounded,
|
||||||
|
color: Colors.amber, size: 26),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
'Любимые авторы',
|
||||||
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (widget.favorites.isNotEmpty) ...[
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 8, vertical: 2),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.amber.withValues(alpha: 0.2),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'${widget.favorites.length}',
|
||||||
|
style:
|
||||||
|
Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||||
|
color: Colors.amber.shade900,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const Spacer(),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.close_rounded),
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Divider(height: 1),
|
||||||
|
if (widget.favorites.isEmpty)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(24, 36, 24, 40),
|
||||||
|
child: Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.star_border_rounded,
|
||||||
|
size: 52,
|
||||||
|
color: scheme.onSurfaceVariant.withValues(alpha: 0.4),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'Список пуст',
|
||||||
|
style:
|
||||||
|
Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Нажмите на иконку звёздочки ★ на карточке любого автора, чтобы добавить его в избранное и быстро смотреть свежие работы.',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style:
|
||||||
|
Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
color: scheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else ...[
|
||||||
|
_FavoriteArtistsSection(
|
||||||
|
favorites: widget.favorites,
|
||||||
|
selectedKey: selectedFav.isEmpty ? null : selectedFav.key,
|
||||||
|
onSelect: (fav) {
|
||||||
|
setState(() => _selectedKey = fav.key);
|
||||||
|
widget.onSelect(fav);
|
||||||
|
},
|
||||||
|
onOpenArtist: (fav) {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
widget.onOpenArtist(fav);
|
||||||
|
},
|
||||||
|
onRemoveFavorite: (fav) {
|
||||||
|
widget.onRemoveFavorite(fav);
|
||||||
|
if (_selectedKey == fav.key) {
|
||||||
|
setState(() {
|
||||||
|
_selectedKey = widget.favorites
|
||||||
|
.where((e) => e.key != fav.key)
|
||||||
|
.firstOrNull
|
||||||
|
?.key;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class _FavoriteArtistsSection extends ConsumerWidget {
|
class _FavoriteArtistsSection extends ConsumerWidget {
|
||||||
const _FavoriteArtistsSection({
|
const _FavoriteArtistsSection({
|
||||||
required this.favorites,
|
required this.favorites,
|
||||||
|
|||||||
@@ -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,93 @@ 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(
|
||||||
|
avatar: Icon(
|
||||||
|
active ? Icons.bookmark_rounded : Icons.bookmark_border_rounded,
|
||||||
|
size: 15,
|
||||||
|
color: active ? scheme.primary : scheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
label: Text(name),
|
||||||
|
selected: active,
|
||||||
|
onSelected: (_) => onApply(raw),
|
||||||
|
selectedColor: scheme.primaryContainer.withValues(alpha: 0.8),
|
||||||
|
checkmarkColor: scheme.onPrimaryContainer,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
|
labelStyle: TextStyle(
|
||||||
|
color: active ? scheme.onPrimaryContainer : scheme.onSurface,
|
||||||
|
fontWeight: active ? FontWeight.w700 : FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -77,6 +77,8 @@ class _FeedToolbarState extends State<FeedToolbar> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildDesktop(BuildContext context) {
|
Widget _buildDesktop(BuildContext context) {
|
||||||
|
final hasActiveFilters = widget.selectedProviderIds.isNotEmpty || widget.rating != null;
|
||||||
|
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -100,34 +102,42 @@ class _FeedToolbarState extends State<FeedToolbar> {
|
|||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
IconButton.filledTonal(
|
IconButton.filledTonal(
|
||||||
tooltip: widget.selectionMode ? 'Exit select' : 'Select posts',
|
tooltip: widget.selectionMode ? 'Выйти из выбора' : 'Выбрать посты',
|
||||||
onPressed: widget.onToggleSelectionMode,
|
onPressed: widget.onToggleSelectionMode,
|
||||||
icon: Icon(widget.selectionMode
|
icon: Icon(widget.selectionMode
|
||||||
? Icons.check_box_rounded
|
? Icons.check_box_rounded
|
||||||
: Icons.check_box_outline_blank_rounded),
|
: Icons.check_box_outline_blank_rounded),
|
||||||
),
|
),
|
||||||
IconButton.filledTonal(
|
const SizedBox(width: 4),
|
||||||
tooltip: 'Providers',
|
Badge(
|
||||||
onPressed: widget.onProviderFilter,
|
isLabelVisible: hasActiveFilters,
|
||||||
icon: const Icon(Icons.hub_rounded),
|
child: IconButton.filledTonal(
|
||||||
|
tooltip: 'Провайдеры',
|
||||||
|
onPressed: widget.onProviderFilter,
|
||||||
|
icon: const Icon(Icons.hub_rounded),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
IconButton.filledTonal(
|
IconButton.filledTonal(
|
||||||
tooltip: 'Rating',
|
tooltip: 'Рейтинг',
|
||||||
onPressed: widget.onRatingFilter,
|
onPressed: widget.onRatingFilter,
|
||||||
icon: const Icon(Icons.tune_rounded),
|
icon: const Icon(Icons.tune_rounded),
|
||||||
),
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
IconButton(
|
IconButton(
|
||||||
tooltip: 'Clear filters',
|
tooltip: 'Очистить фильтры',
|
||||||
onPressed: widget.onClearFilters,
|
onPressed: widget.onClearFilters,
|
||||||
icon: const Icon(Icons.filter_alt_off_rounded),
|
icon: const Icon(Icons.filter_alt_off_rounded),
|
||||||
),
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
IconButton(
|
IconButton(
|
||||||
tooltip: 'Refresh',
|
tooltip: 'Обновить',
|
||||||
onPressed: widget.onRefresh,
|
onPressed: widget.onRefresh,
|
||||||
icon: const Icon(Icons.refresh_rounded),
|
icon: const Icon(Icons.refresh_rounded),
|
||||||
),
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
IconButton.filledTonal(
|
IconButton.filledTonal(
|
||||||
tooltip: 'Random post',
|
tooltip: 'Случайный пост',
|
||||||
onPressed: widget.onRandom,
|
onPressed: widget.onRandom,
|
||||||
icon: const Icon(Icons.casino_rounded),
|
icon: const Icon(Icons.casino_rounded),
|
||||||
),
|
),
|
||||||
@@ -154,7 +164,7 @@ class _FeedToolbarState extends State<FeedToolbar> {
|
|||||||
if (widget.providers.isNotEmpty) ...[
|
if (widget.providers.isNotEmpty) ...[
|
||||||
FilterChip(
|
FilterChip(
|
||||||
selected: widget.selectedProviderIds.isEmpty,
|
selected: widget.selectedProviderIds.isEmpty,
|
||||||
label: const Text('All providers'),
|
label: const Text('Все источники'),
|
||||||
onSelected: (_) => widget.onQuickProviderToggle('__all__'),
|
onSelected: (_) => widget.onQuickProviderToggle('__all__'),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
@@ -162,14 +172,16 @@ class _FeedToolbarState extends State<FeedToolbar> {
|
|||||||
FilterChip(
|
FilterChip(
|
||||||
selected:
|
selected:
|
||||||
widget.selectedProviderIds.contains(provider.id),
|
widget.selectedProviderIds.contains(provider.id),
|
||||||
avatar: const Icon(Icons.hub_rounded, size: 16),
|
avatar: const Icon(Icons.hub_rounded, size: 15),
|
||||||
label: Text(provider.name),
|
label: Text(provider.name),
|
||||||
onSelected: (_) =>
|
onSelected: (_) =>
|
||||||
widget.onQuickProviderToggle(provider.id),
|
widget.onQuickProviderToggle(provider.id),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
],
|
],
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 4),
|
||||||
|
const _ToolbarDivider(),
|
||||||
|
const SizedBox(width: 10),
|
||||||
],
|
],
|
||||||
for (final period in TopPeriodFilter.values) ...[
|
for (final period in TopPeriodFilter.values) ...[
|
||||||
ChoiceChip(
|
ChoiceChip(
|
||||||
@@ -188,25 +200,62 @@ class _FeedToolbarState extends State<FeedToolbar> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildMobile(BuildContext context) {
|
Widget _buildMobile(BuildContext context) {
|
||||||
|
final hasActiveFilters = widget.selectedProviderIds.isNotEmpty || widget.rating != null;
|
||||||
|
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(8, 6, 8, 6),
|
padding: const EdgeInsets.fromLTRB(10, 8, 10, 6),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
TagInputSearchBar(
|
Row(
|
||||||
initialValue: _query,
|
children: [
|
||||||
suggestions: widget.tagSuggestions,
|
Expanded(
|
||||||
onChanged: (value) {
|
child: TagInputSearchBar(
|
||||||
_query = value;
|
initialValue: _query,
|
||||||
widget.onSearchChanged(value);
|
suggestions: widget.tagSuggestions,
|
||||||
},
|
onChanged: (value) {
|
||||||
onSubmitted: widget.onSearch,
|
_query = value;
|
||||||
onSuggestionApplied: (value) {
|
widget.onSearchChanged(value);
|
||||||
_query = value;
|
},
|
||||||
widget.onSuggestionTap(value);
|
onSubmitted: widget.onSearch,
|
||||||
},
|
onSuggestionApplied: (value) {
|
||||||
|
_query = value;
|
||||||
|
widget.onSuggestionTap(value);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Badge(
|
||||||
|
isLabelVisible: hasActiveFilters,
|
||||||
|
child: IconButton.filledTonal(
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
tooltip: 'Фильтры',
|
||||||
|
onPressed: widget.onProviderFilter,
|
||||||
|
icon: const Icon(Icons.tune_rounded, size: 20),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
IconButton.filledTonal(
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
tooltip: 'Случайный пост',
|
||||||
|
onPressed: widget.onRandom,
|
||||||
|
icon: const Icon(Icons.casino_rounded, size: 20),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
IconButton.filledTonal(
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
tooltip: widget.selectionMode ? 'Выйти из выбора' : 'Выбрать посты',
|
||||||
|
onPressed: widget.onToggleSelectionMode,
|
||||||
|
icon: Icon(
|
||||||
|
widget.selectionMode
|
||||||
|
? Icons.check_box_rounded
|
||||||
|
: Icons.check_box_outline_blank_rounded,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
|
||||||
if (widget.providerStatusMessage != null) ...[
|
if (widget.providerStatusMessage != null) ...[
|
||||||
|
const SizedBox(height: 6),
|
||||||
Align(
|
Align(
|
||||||
alignment: Alignment.centerLeft,
|
alignment: Alignment.centerLeft,
|
||||||
child: InputChip(
|
child: InputChip(
|
||||||
@@ -214,55 +263,54 @@ class _FeedToolbarState extends State<FeedToolbar> {
|
|||||||
label: Text(widget.providerStatusMessage!),
|
label: Text(widget.providerStatusMessage!),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 6),
|
|
||||||
],
|
],
|
||||||
|
const SizedBox(height: 7),
|
||||||
_HorizontalWheelScroller(
|
_HorizontalWheelScroller(
|
||||||
height: 40,
|
height: 38,
|
||||||
builder: (controller) => ListView(
|
builder: (controller) => ListView(
|
||||||
controller: controller,
|
controller: controller,
|
||||||
scrollDirection: Axis.horizontal,
|
scrollDirection: Axis.horizontal,
|
||||||
primary: false,
|
primary: false,
|
||||||
children: [
|
children: [
|
||||||
IconButton.filledTonal(
|
if (hasActiveFilters || widget.selectedTags.isNotEmpty) ...[
|
||||||
tooltip:
|
ActionChip(
|
||||||
widget.selectionMode ? 'Exit select' : 'Select posts',
|
visualDensity: VisualDensity.compact,
|
||||||
onPressed: widget.onToggleSelectionMode,
|
avatar: const Icon(Icons.filter_alt_off_rounded, size: 15),
|
||||||
icon: Icon(widget.selectionMode
|
label: const Text('Сбросить'),
|
||||||
? Icons.check_box_rounded
|
onPressed: widget.onClearFilters,
|
||||||
: Icons.check_box_outline_blank_rounded),
|
),
|
||||||
),
|
const SizedBox(width: 6),
|
||||||
const SizedBox(width: 6),
|
],
|
||||||
IconButton.filledTonal(
|
ActionChip(
|
||||||
tooltip: 'Rating',
|
visualDensity: VisualDensity.compact,
|
||||||
onPressed: widget.onRatingFilter,
|
avatar: const Icon(Icons.refresh_rounded, size: 15),
|
||||||
icon: const Icon(Icons.tune_rounded),
|
label: const Text('Обновить'),
|
||||||
),
|
|
||||||
const SizedBox(width: 6),
|
|
||||||
IconButton(
|
|
||||||
tooltip: 'Clear filters',
|
|
||||||
onPressed: widget.onClearFilters,
|
|
||||||
icon: const Icon(Icons.filter_alt_off_rounded),
|
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
tooltip: 'Refresh',
|
|
||||||
onPressed: widget.onRefresh,
|
onPressed: widget.onRefresh,
|
||||||
icon: const Icon(Icons.refresh_rounded),
|
|
||||||
),
|
),
|
||||||
IconButton.filledTonal(
|
const SizedBox(width: 6),
|
||||||
tooltip: 'Random post',
|
const _ToolbarDivider(),
|
||||||
onPressed: widget.onRandom,
|
const SizedBox(width: 6),
|
||||||
icon: const Icon(Icons.casino_rounded),
|
FilterChip(
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
selected: widget.rating != null,
|
||||||
|
avatar: const Icon(Icons.shield_outlined, size: 15),
|
||||||
|
label: Text(widget.rating ?? 'Рейтинг'),
|
||||||
|
onSelected: (_) => widget.onRatingFilter(),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 6),
|
||||||
|
const _ToolbarDivider(),
|
||||||
|
const SizedBox(width: 6),
|
||||||
if (widget.providers.isNotEmpty) ...[
|
if (widget.providers.isNotEmpty) ...[
|
||||||
FilterChip(
|
FilterChip(
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
selected: widget.selectedProviderIds.isEmpty,
|
selected: widget.selectedProviderIds.isEmpty,
|
||||||
label: const Text('All'),
|
label: const Text('Все'),
|
||||||
onSelected: (_) => widget.onQuickProviderToggle('__all__'),
|
onSelected: (_) => widget.onQuickProviderToggle('__all__'),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 6),
|
const SizedBox(width: 6),
|
||||||
for (final provider in widget.providers) ...[
|
for (final provider in widget.providers) ...[
|
||||||
FilterChip(
|
FilterChip(
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
selected:
|
selected:
|
||||||
widget.selectedProviderIds.contains(provider.id),
|
widget.selectedProviderIds.contains(provider.id),
|
||||||
label: Text(provider.name),
|
label: Text(provider.name),
|
||||||
@@ -271,20 +319,13 @@ class _FeedToolbarState extends State<FeedToolbar> {
|
|||||||
),
|
),
|
||||||
const SizedBox(width: 6),
|
const SizedBox(width: 6),
|
||||||
],
|
],
|
||||||
|
const SizedBox(width: 2),
|
||||||
|
const _ToolbarDivider(),
|
||||||
|
const SizedBox(width: 6),
|
||||||
],
|
],
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 6),
|
|
||||||
_HorizontalWheelScroller(
|
|
||||||
height: 36,
|
|
||||||
builder: (controller) => ListView(
|
|
||||||
controller: controller,
|
|
||||||
scrollDirection: Axis.horizontal,
|
|
||||||
primary: false,
|
|
||||||
children: [
|
|
||||||
for (final period in TopPeriodFilter.values) ...[
|
for (final period in TopPeriodFilter.values) ...[
|
||||||
ChoiceChip(
|
ChoiceChip(
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
selected: widget.topPeriodFilter == period,
|
selected: widget.topPeriodFilter == period,
|
||||||
label: Text(period.label),
|
label: Text(period.label),
|
||||||
onSelected: (_) => widget.onTopPeriodChanged(period),
|
onSelected: (_) => widget.onTopPeriodChanged(period),
|
||||||
@@ -300,6 +341,20 @@ class _FeedToolbarState extends State<FeedToolbar> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _ToolbarDivider extends StatelessWidget {
|
||||||
|
const _ToolbarDivider();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
width: 1,
|
||||||
|
height: 20,
|
||||||
|
margin: const EdgeInsets.symmetric(horizontal: 2, vertical: 8),
|
||||||
|
color: Theme.of(context).colorScheme.outlineVariant.withValues(alpha: 0.4),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class _HorizontalWheelScroller extends StatefulWidget {
|
class _HorizontalWheelScroller extends StatefulWidget {
|
||||||
const _HorizontalWheelScroller({
|
const _HorizontalWheelScroller({
|
||||||
required this.builder,
|
required this.builder,
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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(
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -121,15 +121,16 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
|
|||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Theme.of(context).inputDecorationTheme.fillColor ??
|
color: Theme.of(context).inputDecorationTheme.fillColor ??
|
||||||
scheme.surfaceContainerHighest.withValues(alpha: 0.38),
|
scheme.surfaceContainerHighest.withValues(alpha: 0.38),
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(22),
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: _focusNode.hasFocus
|
color: _focusNode.hasFocus
|
||||||
? scheme.primary.withValues(alpha: 0.45)
|
? scheme.primary.withValues(alpha: 0.6)
|
||||||
: Colors.transparent,
|
: Colors.white.withValues(alpha: 0.08),
|
||||||
|
width: 1.0,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(22),
|
||||||
onTap: _focusNode.requestFocus,
|
onTap: _focusNode.requestFocus,
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 7),
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 7),
|
||||||
@@ -228,6 +229,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,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -241,18 +243,34 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
|
|||||||
|
|
||||||
Widget _buildTagChip(BuildContext context, String tag) {
|
Widget _buildTagChip(BuildContext context, String tag) {
|
||||||
final scheme = Theme.of(context).colorScheme;
|
final scheme = Theme.of(context).colorScheme;
|
||||||
|
final isAnd = tag.toLowerCase() == 'and';
|
||||||
return InputChip(
|
return InputChip(
|
||||||
label: Text(tag),
|
label: Text(
|
||||||
avatar: const Icon(Icons.tag_rounded, size: 16),
|
isAnd ? 'AND' : tag,
|
||||||
|
style: TextStyle(
|
||||||
|
color: isAnd
|
||||||
|
? scheme.onSecondaryContainer
|
||||||
|
: scheme.onPrimaryContainer,
|
||||||
|
fontWeight: isAnd ? FontWeight.w900 : FontWeight.w700,
|
||||||
|
letterSpacing: isAnd ? 0.8 : 0,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
avatar: Icon(
|
||||||
|
isAnd ? Icons.alt_route_rounded : Icons.tag_rounded,
|
||||||
|
size: 16,
|
||||||
|
color: isAnd ? scheme.secondary : scheme.primary,
|
||||||
|
),
|
||||||
deleteIcon: const Icon(Icons.close_rounded, size: 16),
|
deleteIcon: const Icon(Icons.close_rounded, size: 16),
|
||||||
onPressed: () => _editTag(tag),
|
onPressed: () => _editTag(tag),
|
||||||
onDeleted: () => _removeTag(tag),
|
onDeleted: () => _removeTag(tag),
|
||||||
backgroundColor: scheme.primaryContainer.withValues(alpha: 0.54),
|
backgroundColor: isAnd
|
||||||
labelStyle: TextStyle(
|
? scheme.secondaryContainer.withValues(alpha: 0.7)
|
||||||
color: scheme.onPrimaryContainer,
|
: scheme.primaryContainer.withValues(alpha: 0.54),
|
||||||
fontWeight: FontWeight.w700,
|
side: BorderSide(
|
||||||
|
color: isAnd
|
||||||
|
? scheme.secondary.withValues(alpha: 0.4)
|
||||||
|
: scheme.primary.withValues(alpha: 0.18),
|
||||||
),
|
),
|
||||||
side: BorderSide(color: scheme.primary.withValues(alpha: 0.18)),
|
|
||||||
visualDensity: VisualDensity.compact,
|
visualDensity: VisualDensity.compact,
|
||||||
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||||
);
|
);
|
||||||
@@ -305,7 +323,9 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
|
|||||||
_localDirty = true;
|
_localDirty = true;
|
||||||
setState(() {
|
setState(() {
|
||||||
for (final tag in additions) {
|
for (final tag in additions) {
|
||||||
if (!_tags.contains(tag)) _tags.add(tag);
|
if (tag.toLowerCase() == 'and' || !_tags.contains(tag)) {
|
||||||
|
_tags.add(tag);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
_controller.clear();
|
_controller.clear();
|
||||||
});
|
});
|
||||||
@@ -358,10 +378,19 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
|
|||||||
_localDirty = true;
|
_localDirty = true;
|
||||||
setState(() {
|
setState(() {
|
||||||
if (draft.isNotEmpty) {
|
if (draft.isNotEmpty) {
|
||||||
final draftTags = _parseTags(draft);
|
final tokens = _parseTags(draft);
|
||||||
_tags.removeWhere(draftTags.contains);
|
if (tokens.isNotEmpty) {
|
||||||
|
final prefixTokens = tokens.sublist(0, tokens.length - 1);
|
||||||
|
for (final token in prefixTokens) {
|
||||||
|
if (token.toLowerCase() == 'and' || !_tags.contains(token)) {
|
||||||
|
_tags.add(token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!_tags.contains(suggestion)) {
|
||||||
|
_tags.add(suggestion);
|
||||||
}
|
}
|
||||||
if (!_tags.contains(suggestion)) _tags.add(suggestion);
|
|
||||||
_controller.clear();
|
_controller.clear();
|
||||||
});
|
});
|
||||||
_notifyChanged();
|
_notifyChanged();
|
||||||
@@ -372,7 +401,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() {
|
||||||
@@ -386,7 +415,8 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
|
|||||||
final result = <String>[];
|
final result = <String>[];
|
||||||
for (final raw in query.trim().split(RegExp(r'\s+'))) {
|
for (final raw in query.trim().split(RegExp(r'\s+'))) {
|
||||||
final tag = raw.trim();
|
final tag = raw.trim();
|
||||||
if (tag.isEmpty || !seen.add(tag)) continue;
|
if (tag.isEmpty) continue;
|
||||||
|
if (tag.toLowerCase() != 'and' && !seen.add(tag.toLowerCase())) continue;
|
||||||
result.add(tag);
|
result.add(tag);
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
@@ -401,7 +431,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 +444,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 +452,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 +468,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 +481,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 +518,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 +556,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 +630,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 +653,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> {
|
||||||
|
|||||||
+318
-156
@@ -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!();
|
||||||
@@ -139,114 +177,187 @@ class _PostCardState extends ConsumerState<PostCard> {
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
child: ClipRRect(
|
child: Container(
|
||||||
borderRadius: BorderRadius.circular(8),
|
decoration: BoxDecoration(
|
||||||
child: Stack(
|
borderRadius: BorderRadius.circular(14),
|
||||||
children: [
|
border: Border.all(
|
||||||
AspectRatio(
|
color: Colors.white.withValues(alpha: 0.09),
|
||||||
aspectRatio: aspect.clamp(0.28, 2.2),
|
width: 0.8,
|
||||||
child: BlurContent(
|
),
|
||||||
enabled: widget.blurExplicit && sensitive,
|
boxShadow: [
|
||||||
child: LayoutBuilder(
|
BoxShadow(
|
||||||
builder: (context, constraints) {
|
color: Colors.black.withValues(alpha: 0.18),
|
||||||
final dpr = MediaQuery.devicePixelRatioOf(context);
|
blurRadius: 8,
|
||||||
final cacheWidth =
|
offset: const Offset(0, 2),
|
||||||
(constraints.maxWidth * dpr).round().clamp(320, 1800);
|
),
|
||||||
final headers = _headersFor(post);
|
],
|
||||||
final videoUrls = MediaUrlSelector.video(post);
|
),
|
||||||
if (!mobile &&
|
child: ClipRRect(
|
||||||
_hovered &&
|
borderRadius: BorderRadius.circular(13.2),
|
||||||
_resolvedPost != null &&
|
child: Stack(
|
||||||
MediaUrlSelector.isVideo(post) &&
|
children: [
|
||||||
videoUrls.isNotEmpty) {
|
AspectRatio(
|
||||||
return _FeedVideoPreview(
|
aspectRatio: aspect.clamp(0.28, 2.2),
|
||||||
videoUrl: videoUrls.first,
|
child: BlurContent(
|
||||||
headers: headers,
|
enabled: widget.blurExplicit && sensitive,
|
||||||
fallbackImageUrl: imageUrl,
|
child: LayoutBuilder(
|
||||||
cacheWidth: cacheWidth,
|
builder: (context, constraints) {
|
||||||
);
|
final dpr = MediaQuery.devicePixelRatioOf(context);
|
||||||
}
|
final cacheWidth =
|
||||||
return CachedNetworkImage(
|
(constraints.maxWidth * dpr).round().clamp(320, 1800);
|
||||||
imageUrl: imageUrl,
|
final headers = _headersFor(post);
|
||||||
httpHeaders: headers,
|
final videoUrls = MediaUrlSelector.video(post);
|
||||||
memCacheWidth: cacheWidth,
|
if (!mobile &&
|
||||||
maxWidthDiskCache: cacheWidth,
|
_hovered &&
|
||||||
fit: BoxFit.cover,
|
_resolvedPost != null &&
|
||||||
placeholder: (context, url) => const LoadingSkeleton(),
|
MediaUrlSelector.isVideo(post) &&
|
||||||
errorWidget: (context, url, error) => ColoredBox(
|
videoUrls.isNotEmpty) {
|
||||||
color: Theme.of(context)
|
return _FeedVideoPreview(
|
||||||
.colorScheme
|
videoUrl: videoUrls.first,
|
||||||
.surfaceContainerHighest,
|
headers: headers,
|
||||||
child: const Center(
|
fallbackImageUrl: imageUrl,
|
||||||
child: Icon(Icons.broken_image_rounded),
|
cacheWidth: cacheWidth,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return CachedNetworkImage(
|
||||||
|
imageUrl: imageUrl,
|
||||||
|
httpHeaders: headers,
|
||||||
|
memCacheWidth: cacheWidth,
|
||||||
|
maxWidthDiskCache: cacheWidth,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
placeholder: (context, url) => const LoadingSkeleton(),
|
||||||
|
errorWidget: (context, url, error) => ColoredBox(
|
||||||
|
color: Theme.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.surfaceContainerHighest,
|
||||||
|
child: const Center(
|
||||||
|
child: Icon(Icons.broken_image_rounded),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
);
|
},
|
||||||
},
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
// Bottom cinematic gradient overlay for badges and favorite
|
||||||
if (widget.showBadges) ...[
|
|
||||||
Positioned(
|
Positioned(
|
||||||
left: 8,
|
left: 0,
|
||||||
top: 8,
|
right: 0,
|
||||||
child: _ProviderBadge(name: post.providerName),
|
bottom: 0,
|
||||||
|
height: 64,
|
||||||
|
child: IgnorePointer(
|
||||||
|
child: DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.bottomCenter,
|
||||||
|
end: Alignment.topCenter,
|
||||||
|
colors: [
|
||||||
|
Colors.black.withValues(alpha: 0.72),
|
||||||
|
Colors.transparent,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
Positioned(
|
if (widget.showBadges) ...[
|
||||||
right: 8,
|
Positioned(
|
||||||
top: 8,
|
left: 8,
|
||||||
child: RatingBadge(rating: post.rating),
|
top: 8,
|
||||||
),
|
child: _ProviderBadge(name: post.providerName),
|
||||||
Positioned(
|
),
|
||||||
left: 8,
|
if (post.fileType.toLowerCase().contains('video') ||
|
||||||
bottom: 8,
|
post.fileType.toLowerCase().contains('webm') ||
|
||||||
child: _MediaBadge(fileType: post.fileType),
|
post.fileType.toLowerCase().contains('mp4') ||
|
||||||
),
|
post.fileType.toLowerCase().contains('gif'))
|
||||||
if (widget.isViewed)
|
Positioned(
|
||||||
const Positioned(
|
right: 8,
|
||||||
|
top: 8,
|
||||||
|
child: _MediaBadge(fileType: post.fileType),
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
left: 8,
|
||||||
|
bottom: 8,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
if (widget.isViewed || widget.isDownloaded) ...[
|
||||||
|
Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
if (widget.isViewed) const _SeenBadge(),
|
||||||
|
if (widget.isViewed && widget.isDownloaded)
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
if (widget.isDownloaded)
|
||||||
|
const _DownloadedBadge(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
],
|
||||||
|
RatingBadge(rating: post.rating),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
if (widget.selected)
|
||||||
|
Positioned.fill(
|
||||||
|
child: DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Theme.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.primary
|
||||||
|
.withValues(alpha: 0.24),
|
||||||
|
border: Border.all(
|
||||||
|
color: Theme.of(context).colorScheme.primary,
|
||||||
|
width: 3,
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.circular(13),
|
||||||
|
),
|
||||||
|
child: const Align(
|
||||||
|
alignment: Alignment.topRight,
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.all(8),
|
||||||
|
child: Icon(Icons.check_circle_rounded),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (mobile)
|
||||||
|
Positioned(
|
||||||
right: 8,
|
right: 8,
|
||||||
bottom: 8,
|
bottom: 8,
|
||||||
child: _SeenBadge(),
|
child: _FavoriteButton(
|
||||||
|
isFavorite: widget.isFavorite,
|
||||||
|
onPressed: widget.onFavorite,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
if (widget.isDownloaded)
|
// Double-tap heart animation overlay
|
||||||
Positioned(
|
if (_showHeart)
|
||||||
right: widget.isViewed ? 72 : 8,
|
|
||||||
bottom: 8,
|
|
||||||
child: const _DownloadedBadge(),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
if (widget.selected)
|
|
||||||
Positioned.fill(
|
Positioned.fill(
|
||||||
child: DecoratedBox(
|
child: Center(
|
||||||
decoration: BoxDecoration(
|
child: AnimatedBuilder(
|
||||||
color: Theme.of(context)
|
animation: _heartController,
|
||||||
.colorScheme
|
builder: (context, _) => Opacity(
|
||||||
.primary
|
opacity: _heartOpacity.value,
|
||||||
.withValues(alpha: 0.24),
|
child: Transform.scale(
|
||||||
border: Border.all(
|
scale: _heartScale.value,
|
||||||
color: Theme.of(context).colorScheme.primary,
|
child: const Icon(
|
||||||
width: 3,
|
Icons.favorite_rounded,
|
||||||
),
|
color: Colors.white,
|
||||||
borderRadius: BorderRadius.circular(8),
|
size: 72,
|
||||||
),
|
shadows: [
|
||||||
child: const Align(
|
Shadow(
|
||||||
alignment: Alignment.topRight,
|
color: Colors.black54,
|
||||||
child: Padding(
|
blurRadius: 12,
|
||||||
padding: EdgeInsets.all(8),
|
),
|
||||||
child: Icon(Icons.check_circle_rounded),
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (mobile)
|
|
||||||
Positioned(
|
|
||||||
right: 8,
|
|
||||||
bottom: 8,
|
|
||||||
child: _FavoriteButton(
|
|
||||||
isFavorite: widget.isFavorite,
|
|
||||||
onPressed: widget.onFavorite,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Positioned.fill(
|
Positioned.fill(
|
||||||
child: IgnorePointer(
|
child: IgnorePointer(
|
||||||
ignoring: !_hovered || mobile,
|
ignoring: !_hovered || mobile,
|
||||||
@@ -305,7 +416,8 @@ class _PostCardState extends ConsumerState<PostCard> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -571,13 +683,37 @@ class _FavoriteButton extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return IconButton.filledTonal(
|
return InkResponse(
|
||||||
visualDensity: VisualDensity.compact,
|
onTap: onPressed,
|
||||||
tooltip: isFavorite ? 'Remove favorite' : 'Favorite',
|
radius: 20,
|
||||||
onPressed: onPressed,
|
child: AnimatedContainer(
|
||||||
icon: Icon(
|
duration: AppMotion.duration(context, 150),
|
||||||
isFavorite ? Icons.favorite_rounded : Icons.favorite_border_rounded,
|
width: 34,
|
||||||
size: 18,
|
height: 34,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.black.withValues(alpha: 0.58),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
border: Border.all(
|
||||||
|
color: isFavorite
|
||||||
|
? Colors.redAccent.withValues(alpha: 0.6)
|
||||||
|
: Colors.white.withValues(alpha: 0.16),
|
||||||
|
width: 0.9,
|
||||||
|
),
|
||||||
|
boxShadow: [
|
||||||
|
if (isFavorite)
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.redAccent.withValues(alpha: 0.35),
|
||||||
|
blurRadius: 10,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Center(
|
||||||
|
child: Icon(
|
||||||
|
isFavorite ? Icons.favorite_rounded : Icons.favorite_border_rounded,
|
||||||
|
color: isFavorite ? Colors.redAccent : Colors.white,
|
||||||
|
size: 18,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -590,16 +726,23 @@ class _ProviderBadge extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return DecoratedBox(
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 3.5),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.black.withValues(alpha: 0.55),
|
color: Colors.black.withValues(alpha: 0.62),
|
||||||
borderRadius: BorderRadius.circular(6),
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(
|
||||||
|
color: Colors.white.withValues(alpha: 0.12),
|
||||||
|
width: 0.7,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
child: Padding(
|
child: Text(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 3),
|
name,
|
||||||
child: Text(
|
style: const TextStyle(
|
||||||
name,
|
fontSize: 10.5,
|
||||||
style: const TextStyle(fontSize: 11, color: Colors.white),
|
color: Colors.white,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
letterSpacing: 0.2,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -614,25 +757,32 @@ class _MediaBadge extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final type = _type(fileType);
|
final type = _type(fileType);
|
||||||
return DecoratedBox(
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3.5),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.black.withValues(alpha: 0.55),
|
color: Colors.black.withValues(alpha: 0.62),
|
||||||
borderRadius: BorderRadius.circular(6),
|
borderRadius: BorderRadius.circular(8),
|
||||||
),
|
border: Border.all(
|
||||||
child: Padding(
|
color: Colors.white.withValues(alpha: 0.12),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 3),
|
width: 0.7,
|
||||||
child: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Icon(_icon(type), size: 12, color: Colors.white),
|
|
||||||
const SizedBox(width: 4),
|
|
||||||
Text(
|
|
||||||
type,
|
|
||||||
style: const TextStyle(fontSize: 11, color: Colors.white),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(_icon(type), size: 13, color: Colors.white),
|
||||||
|
const SizedBox(width: 3.5),
|
||||||
|
Text(
|
||||||
|
type,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 10.5,
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
letterSpacing: 0.3,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -659,25 +809,31 @@ class _SeenBadge extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return DecoratedBox(
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3.5),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.black.withValues(alpha: 0.55),
|
color: Colors.black.withValues(alpha: 0.62),
|
||||||
borderRadius: BorderRadius.circular(6),
|
borderRadius: BorderRadius.circular(8),
|
||||||
),
|
border: Border.all(
|
||||||
child: const Padding(
|
color: Colors.white.withValues(alpha: 0.12),
|
||||||
padding: EdgeInsets.symmetric(horizontal: 7, vertical: 3),
|
width: 0.7,
|
||||||
child: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Icon(Icons.visibility_rounded, size: 12, color: Colors.white),
|
|
||||||
SizedBox(width: 4),
|
|
||||||
Text(
|
|
||||||
'seen',
|
|
||||||
style: TextStyle(fontSize: 11, color: Colors.white),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
child: const Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.visibility_rounded, size: 12, color: Colors.white70),
|
||||||
|
SizedBox(width: 3.5),
|
||||||
|
Text(
|
||||||
|
'seen',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 10,
|
||||||
|
color: Colors.white70,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -687,14 +843,20 @@ class _DownloadedBadge extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return DecoratedBox(
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3.5),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.black.withValues(alpha: 0.55),
|
color: Colors.black.withValues(alpha: 0.62),
|
||||||
borderRadius: BorderRadius.circular(6),
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(
|
||||||
|
color: Colors.white.withValues(alpha: 0.12),
|
||||||
|
width: 0.7,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
child: const Padding(
|
child: const Icon(
|
||||||
padding: EdgeInsets.symmetric(horizontal: 7, vertical: 3),
|
Icons.download_done_rounded,
|
||||||
child: Icon(Icons.download_done_rounded, size: 13, color: Colors.white),
|
size: 13,
|
||||||
|
color: Colors.white,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(14),
|
||||||
|
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),
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,16 +14,23 @@ class RatingBadge extends StatelessWidget {
|
|||||||
'explicit' || 'e' => Colors.red,
|
'explicit' || 'e' => Colors.red,
|
||||||
_ => Colors.blueGrey,
|
_ => Colors.blueGrey,
|
||||||
};
|
};
|
||||||
return DecoratedBox(
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 6.5, vertical: 3),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: color.withValues(alpha: 0.85),
|
color: color.withValues(alpha: 0.88),
|
||||||
borderRadius: BorderRadius.circular(6),
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(
|
||||||
|
color: Colors.white.withValues(alpha: 0.18),
|
||||||
|
width: 0.7,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
child: Padding(
|
child: Text(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 3),
|
rating.isEmpty ? 'unknown' : rating,
|
||||||
child: Text(
|
style: const TextStyle(
|
||||||
rating.isEmpty ? 'unknown' : rating,
|
fontSize: 10.5,
|
||||||
style: const TextStyle(fontSize: 11, color: Colors.white),
|
color: Colors.white,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
letterSpacing: 0.2,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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.2+24
|
version: 2.0.15+37
|
||||||
|
|
||||||
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:
|
||||||
|
|||||||
@@ -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', () {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
@@ -43,6 +43,17 @@ void main() {
|
|||||||
expect(service.parseTags(' cat cute cat '), ['cat', 'cute']);
|
expect(service.parseTags(' cat cute cat '), ['cat', 'cute']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('parses query preserving and operators and grouping', () {
|
||||||
|
expect(
|
||||||
|
service.parseTags('cat and dog and fox'),
|
||||||
|
['cat', 'and', 'dog', 'and', 'fox'],
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
service.parseTags('(cat and dog) anthro'),
|
||||||
|
['(cat', 'and', 'dog)', 'anthro'],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('recent searches are ordered by searchedAt', () async {
|
test('recent searches are ordered by searchedAt', () async {
|
||||||
await service.saveSearch('old', 1);
|
await service.saveSearch('old', 1);
|
||||||
await Future<void>.delayed(const Duration(milliseconds: 1));
|
await Future<void>.delayed(const Duration(milliseconds: 1));
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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>[]]));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:gel_rule_app/backend/backend.dart';
|
||||||
|
import 'package:gel_rule_app/features/settings/presentation/settings_controller.dart';
|
||||||
|
import 'package:gel_rule_app/features/settings/presentation/settings_screen.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
testWidgets('SettingsScreen renders category quick nav and sections without overflow', (tester) async {
|
||||||
|
// Test on typical mobile viewport: 390x844
|
||||||
|
tester.view.physicalSize = const Size(390, 844);
|
||||||
|
tester.view.devicePixelRatio = 1.0;
|
||||||
|
addTearDown(() {
|
||||||
|
tester.view.resetPhysicalSize();
|
||||||
|
tester.view.resetDevicePixelRatio();
|
||||||
|
});
|
||||||
|
|
||||||
|
final settings = AppSettings.defaults.copyWith(
|
||||||
|
languageCode: 'ru',
|
||||||
|
themeMode: 'dark',
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
ProviderScope(
|
||||||
|
overrides: [
|
||||||
|
settingsControllerProvider.overrideWith(
|
||||||
|
() => _FakeSettingsController(settings),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
child: const MaterialApp(
|
||||||
|
home: SettingsScreen(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Verify pinned Category Quick Nav renders
|
||||||
|
expect(find.text('Основное'), findsWidgets);
|
||||||
|
expect(find.text('Внешний вид'), findsWidgets);
|
||||||
|
|
||||||
|
// Verify Theme mode segment buttons
|
||||||
|
expect(find.text('Темная'), findsOneWidget);
|
||||||
|
expect(find.text('Светлая'), findsOneWidget);
|
||||||
|
|
||||||
|
// Tap a quick nav chip to test scrolling
|
||||||
|
await tester.tap(find.text('Внешний вид').first);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Verify language segment is visible and tappable
|
||||||
|
expect(find.text('🇷🇺 Русский'), findsOneWidget);
|
||||||
|
expect(find.text('🇬🇧 English'), findsOneWidget);
|
||||||
|
|
||||||
|
// Tap English to test selection
|
||||||
|
await tester.tap(find.text('🇬🇧 English'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FakeSettingsController extends SettingsController {
|
||||||
|
_FakeSettingsController(this._initial);
|
||||||
|
|
||||||
|
final AppSettings _initial;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<AppSettings> build() async => _initial;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> saveSettings(AppSettings settings) async {
|
||||||
|
state = AsyncData(settings);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -288,6 +288,57 @@ void main() {
|
|||||||
expect(size.height, lessThan(96));
|
expect(size.height, lessThan(96));
|
||||||
expect(find.text('touhou'), findsOneWidget);
|
expect(find.text('touhou'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('preserves AND operator chips when submitted', (tester) async {
|
||||||
|
String submitted = '';
|
||||||
|
await tester.pumpWidget(_Harness(
|
||||||
|
child: TagInputSearchBar(
|
||||||
|
initialValue: 'cat and dog',
|
||||||
|
onSubmitted: (query) => submitted = query,
|
||||||
|
),
|
||||||
|
));
|
||||||
|
|
||||||
|
expect(find.text('cat'), findsOneWidget);
|
||||||
|
expect(find.text('AND'), findsOneWidget);
|
||||||
|
expect(find.text('dog'), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.showKeyboard(find.byType(TextField));
|
||||||
|
await tester.testTextInput.receiveAction(TextInputAction.search);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(submitted, 'cat and dog');
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('applying suggestion preserves preceding tokens and AND operator',
|
||||||
|
(tester) async {
|
||||||
|
String applied = '';
|
||||||
|
await tester.pumpWidget(_Harness(
|
||||||
|
child: TagInputSearchBar(
|
||||||
|
suggestions: const [
|
||||||
|
TagSuggestion(
|
||||||
|
name: 'dog',
|
||||||
|
category: TagCategory.general,
|
||||||
|
postCount: 50,
|
||||||
|
providerId: 'test',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
onSuggestionApplied: (query) => applied = query,
|
||||||
|
onSubmitted: (_) {},
|
||||||
|
),
|
||||||
|
));
|
||||||
|
|
||||||
|
await tester.enterText(find.byType(TextField), 'cat and d');
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('dog'), findsOneWidget);
|
||||||
|
await tester.tap(find.text('dog'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(applied, 'cat and dog');
|
||||||
|
expect(find.text('cat'), findsOneWidget);
|
||||||
|
expect(find.text('AND'), findsOneWidget);
|
||||||
|
expect(find.text('dog'), findsOneWidget);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
class _Harness extends StatelessWidget {
|
class _Harness extends StatelessWidget {
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user