Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c6a9dcb4d5 | ||
|
|
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.1.0-blueviolet?style=for-the-badge" alt="Version 2.1.0" />
|
||||||
<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,39 @@
|
|||||||
|
|
||||||
## ✨ Основные возможности
|
## ✨ Основные возможности
|
||||||
|
|
||||||
- **📱 Плавная лента в стиле Pinterest**: Masonry-сетка с адаптивным количеством колонок для мобильных и ПК.
|
- **🔍 Поиск 2.0**: Hero-панель поиска, быстрые чипы операторов (`and`, `type:video`, `rating:safe`), облако недавних поисков с быстрым удалением крестиком `✕`, карточки популярных категорий и шпаргалка по синтаксису запросов.
|
||||||
|
- **❤️ Избранное 2.0**: Живые счетчики в сегментах (Все, Офлайн, Авторы), быстрый фильтр по типу медиа (Все / Видео / Фото), мгновенный поиск среди сохраненных работ и карточки авторов в стиле Spotify с мини-коллажем из 3 превью.
|
||||||
|
- **🕒 История 2.0**: Группировка по дням (Сегодня, Вчера, даты), переключение между подробным списком (с 68x68 миниатюрами, бэйджами провайдеров и быстрым удалением из истории) и галерейной Masonry-сеткой, а также диалог подтверждения очистки.
|
||||||
|
- **📁 Коллекции 2.0**: Мозаичные обложки 2x2 с превью постов, счетчики работ со стеклянным бейджем, современный экран деталей коллекции и обновленный диалог создания.
|
||||||
|
- **⚙️ 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 +75,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) }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,16 +35,23 @@ class GelRuleApp extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
data: (_) {
|
data: (_) {
|
||||||
final appSettings = settings.value ?? AppSettings.defaults;
|
final appSettings = settings.value ?? AppSettings.defaults;
|
||||||
|
final amoled = appSettings.amoledMode;
|
||||||
|
final useDynamic = appSettings.useDynamicColor;
|
||||||
|
return DynamicColorBuilder(
|
||||||
|
builder: (lightDynamic, darkDynamic) {
|
||||||
return MaterialApp.router(
|
return MaterialApp.router(
|
||||||
title: 'Lunaris',
|
title: 'Lunaris',
|
||||||
debugShowCheckedModeBanner: false,
|
debugShowCheckedModeBanner: false,
|
||||||
theme: buildTheme(
|
theme: buildTheme(
|
||||||
Brightness.light,
|
Brightness.light,
|
||||||
seedColor: appSettings.appSeedColor,
|
seedColor: appSettings.appSeedColor,
|
||||||
|
dynamicColorScheme: useDynamic ? lightDynamic : null,
|
||||||
),
|
),
|
||||||
darkTheme: buildTheme(
|
darkTheme: buildTheme(
|
||||||
Brightness.dark,
|
Brightness.dark,
|
||||||
seedColor: appSettings.appSeedColor,
|
seedColor: appSettings.appSeedColor,
|
||||||
|
amoled: amoled,
|
||||||
|
dynamicColorScheme: useDynamic ? darkDynamic : null,
|
||||||
),
|
),
|
||||||
themeMode: parseThemeMode(appSettings.themeMode),
|
themeMode: parseThemeMode(appSettings.themeMode),
|
||||||
routerConfig: router,
|
routerConfig: router,
|
||||||
@@ -51,6 +59,8 @@ class GelRuleApp extends ConsumerWidget {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
const appDisplayVersion = '2.0.2';
|
const appDisplayVersion = '2.1.0';
|
||||||
const appBuildNumber = 24;
|
const appBuildNumber = 38;
|
||||||
const appReleaseChannel = 'Stable';
|
const appReleaseChannel = 'Stable';
|
||||||
|
|||||||
@@ -1,4 +1,163 @@
|
|||||||
const lunarisChangelog = [
|
const lunarisChangelog = [
|
||||||
|
LunarisChange(
|
||||||
|
version: '2.1.0',
|
||||||
|
title: '4-Tab Major Redesign: Search, Favorites, History & Collections',
|
||||||
|
bullets: [
|
||||||
|
'Search Tab Redesign: hero search container, quick search operator chips (and, type:video, rating:safe, etc.), interactive recent search cloud with 1-tap delete (x), popular exploratory category cards, and search tips cheat sheet.',
|
||||||
|
'Favorites Tab Redesign: live counters on segment tabs (All, Offline, Artists), quick media filter chips (All, Videos, Images), in-favorites search bar, and Spotify/Apple Music-style artist album cards with 3-thumbnail preview collages.',
|
||||||
|
'History Tab Redesign: timeline grouped by time (Today, Yesterday, Date), view mode switcher (rich list with 68x68 rounded thumbnails, provider chips, timestamps, and delete from history vs full visual Masonry gallery grid), and confirmation dialog before clearing.',
|
||||||
|
'Collections Tab Redesign: Pinterest-style collection cards with 2x2 thumbnail mosaic covers and glassmorphism post counters, modernized collection details screen with description banner, and elegant squircle creation dialog.',
|
||||||
|
],
|
||||||
|
),
|
||||||
|
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(),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
+34
-7
@@ -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 ??
|
||||||
|
ColorScheme.fromSeed(
|
||||||
seedColor: Color(seedColor),
|
seedColor: Color(seedColor),
|
||||||
brightness: brightness,
|
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;
|
||||||
|
List<ArtistProfile> list;
|
||||||
try {
|
try {
|
||||||
final response = await _creatorDio.get<dynamic>(
|
final response = await _creatorDio.get<dynamic>(
|
||||||
_creatorPath(),
|
effectiveService == null || effectiveService.isEmpty
|
||||||
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 {
|
|
||||||
final response = await _creatorDio.get<dynamic>(
|
|
||||||
service == null || service.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,8 +45,35 @@ class PawchiveProvider
|
|||||||
String? rating,
|
String? rating,
|
||||||
TopPeriodFilter topPeriod = TopPeriodFilter.none,
|
TopPeriodFilter topPeriod = TopPeriodFilter.none,
|
||||||
}) async {
|
}) async {
|
||||||
|
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 [];
|
return const [];
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<Post?> getPost(String id) async {
|
Future<Post?> getPost(String id) async {
|
||||||
@@ -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();
|
||||||
|
final matchService = activeServices != null
|
||||||
|
? activeServices.contains(artistService)
|
||||||
|
: service == null ||
|
||||||
service.isEmpty ||
|
service.isEmpty ||
|
||||||
artist.service.toLowerCase() == service.toLowerCase();
|
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();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,6 +47,12 @@ class SearchRepository {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<Result<bool>> delete(String historyId) {
|
||||||
|
return _databaseService.safeWrite((isar) async {
|
||||||
|
return await isar.searchHistoryEntitys.deleteByHistoryId(historyId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
Future<Result<void>> clear() {
|
Future<Result<void>> clear() {
|
||||||
return _databaseService.safeWrite((isar) async {
|
return _databaseService.safeWrite((isar) async {
|
||||||
await isar.searchHistoryEntitys.clear();
|
await isar.searchHistoryEntitys.clear();
|
||||||
|
|||||||
@@ -51,6 +51,14 @@ class ViewedPostRepository {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<Result<void>> deleteItem(String providerId, String postId) {
|
||||||
|
return _databaseService.safeWrite((isar) async {
|
||||||
|
await isar.viewedPostEntitys.deleteByViewedKey(
|
||||||
|
ViewedPost.keyFor(providerId, postId),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
Future<Result<void>> clear() {
|
Future<Result<void>> clear() {
|
||||||
return _databaseService.safeWrite((isar) async {
|
return _databaseService.safeWrite((isar) async {
|
||||||
await isar.viewedPostEntitys.clear();
|
await isar.viewedPostEntitys.clear();
|
||||||
|
|||||||
@@ -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,13 +112,21 @@ 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;
|
||||||
|
|
||||||
|
return groups.any((group) {
|
||||||
|
final cleanGroup = group
|
||||||
|
.map((tag) => tag.trim().toLowerCase())
|
||||||
|
.where((tag) => tag.isNotEmpty && !tag.startsWith('-') && tag != 'and')
|
||||||
|
.toList(growable: false);
|
||||||
|
if (cleanGroup.isEmpty) return true;
|
||||||
|
return cleanGroup.every(
|
||||||
(requestedTag) => postTags.any(
|
(requestedTag) => postTags.any(
|
||||||
(postTag) =>
|
(postTag) =>
|
||||||
postTag == requestedTag ||
|
postTag == requestedTag ||
|
||||||
@@ -126,6 +134,7 @@ bool postMatchesRequestedTags(Post post, List<String> requestedTags) {
|
|||||||
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,68 @@ 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<bool>> deleteHistoryItem(String historyId) =>
|
||||||
|
_repository.delete(historyId);
|
||||||
|
|
||||||
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 (_) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -59,6 +59,10 @@ class ViewedHistoryService {
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<Result<void>> deleteItem(String providerId, String postId) {
|
||||||
|
return _repository.deleteItem(providerId, postId);
|
||||||
|
}
|
||||||
|
|
||||||
Future<Result<void>> clearHistory() {
|
Future<Result<void>> clearHistory() {
|
||||||
return _repository.clear();
|
return _repository.clear();
|
||||||
}
|
}
|
||||||
|
|||||||
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,40 +40,424 @@ class ArtistPostsScreen extends ConsumerWidget {
|
|||||||
final String artistName;
|
final String artistName;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
ConsumerState<ArtistPostsScreen> createState() => _ArtistPostsScreenState();
|
||||||
final query = ArtistWorkQuery(
|
}
|
||||||
providerId: providerId,
|
|
||||||
service: service,
|
class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
|
||||||
artistId: artistId,
|
final _scrollController = ScrollController();
|
||||||
artistName: artistName,
|
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 posts = ref.watch(artistPostsProvider(query));
|
|
||||||
|
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(
|
||||||
|
providerId: widget.providerId,
|
||||||
|
service: widget.service,
|
||||||
|
artistId: widget.artistId,
|
||||||
|
artistName: widget.artistName,
|
||||||
|
tagFilter: _selectedTag,
|
||||||
|
);
|
||||||
|
|
||||||
|
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(
|
IconButton(
|
||||||
tooltip: 'Refresh',
|
tooltip: 'Анонсы автора',
|
||||||
onPressed: () => ref.invalidate(artistPostsProvider(query)),
|
onPressed: _showAnnouncements,
|
||||||
|
icon: Badge.count(
|
||||||
|
count: _announcements.length,
|
||||||
|
child: const Icon(Icons.campaign_rounded),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
tooltip: 'Обновить',
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
: _posts.isEmpty
|
||||||
|
? Column(
|
||||||
|
children: [
|
||||||
|
_buildTagsBar(),
|
||||||
|
const Expanded(
|
||||||
|
child: EmptyView(
|
||||||
|
title: 'Нет работ',
|
||||||
|
message: 'У этого автора пока нет видимых постов.',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
: Column(
|
||||||
|
children: [
|
||||||
|
_buildMediaTypeFilters(),
|
||||||
|
_buildTagsBar(),
|
||||||
|
Expanded(
|
||||||
|
child: displayedPosts.isEmpty
|
||||||
|
? Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Icon(
|
||||||
|
Icons.filter_alt_off_rounded,
|
||||||
|
size: 48),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
const Text(
|
||||||
|
'Нет медиа по выбранным фильтрам',
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
FilledButton.tonal(
|
||||||
|
onPressed: () {
|
||||||
|
setState(() {
|
||||||
|
_selectedTypes.clear();
|
||||||
|
_selectedTag = null;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
child: const Text('Сбросить фильтры'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
data: (items) => items.isEmpty
|
|
||||||
? const EmptyView(
|
|
||||||
title: 'No works',
|
|
||||||
message: 'This artist has no visible media yet.',
|
|
||||||
)
|
)
|
||||||
: PostMasonryGrid(
|
: PostMasonryGrid(
|
||||||
posts: items,
|
posts: displayedPosts,
|
||||||
|
controller: _scrollController,
|
||||||
|
loading: _loading,
|
||||||
|
gridMode: settings.gridMode,
|
||||||
columns: Responsive.columnsFor(
|
columns: Responsive.columnsFor(
|
||||||
context,
|
context,
|
||||||
mobileColumns: settings.mobileColumns,
|
mobileColumns: settings.mobileColumns,
|
||||||
@@ -82,35 +466,41 @@ class ArtistPostsScreen extends ConsumerWidget {
|
|||||||
blurExplicit: settings.blurExplicitContent,
|
blurExplicit: settings.blurExplicitContent,
|
||||||
showBadges: settings.showPostBadges,
|
showBadges: settings.showPostBadges,
|
||||||
nsfwEnabled: settings.nsfwEnabled,
|
nsfwEnabled: settings.nsfwEnabled,
|
||||||
mediaQualityMode:
|
mediaQualityMode: MediaQualityMode.fromName(
|
||||||
MediaQualityMode.fromName(settings.mediaQualityMode),
|
settings.mediaQualityMode),
|
||||||
favoriteKeys: favoriteKeys,
|
favoriteKeys: favoriteKeys,
|
||||||
viewedKeys: viewedKeys,
|
viewedKeys: viewedKeys,
|
||||||
onOpen: (post) => context.push(
|
onOpen: (post) => context.push(
|
||||||
'/post/${post.providerId}/${post.id}',
|
'/post/${post.providerId}/${post.id}',
|
||||||
extra: PostNavigationContext(
|
extra: PostNavigationContext(
|
||||||
currentPost: post,
|
currentPost: post,
|
||||||
posts: items,
|
posts: displayedPosts,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
onFavorite: (post) async {
|
onFavorite: (post) async {
|
||||||
if (favoriteKeys.contains(post.cacheKey)) {
|
if (favoriteKeys.contains(post.cacheKey)) {
|
||||||
await ref
|
await ref
|
||||||
.read(favoriteServiceProvider)
|
.read(favoriteServiceProvider)
|
||||||
.removeFavorite(post.id, post.providerId);
|
.removeFavorite(
|
||||||
|
post.id, post.providerId);
|
||||||
} else {
|
} else {
|
||||||
await ref.read(favoriteServiceProvider).addFavorite(
|
await ref
|
||||||
|
.read(favoriteServiceProvider)
|
||||||
|
.addFavorite(
|
||||||
post,
|
post,
|
||||||
settings: settings,
|
settings: settings,
|
||||||
downloadManager:
|
downloadManager: ref.read(
|
||||||
ref.read(downloadManagerServiceProvider),
|
downloadManagerServiceProvider),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
ref.invalidate(favoriteKeysProvider);
|
ref.invalidate(favoriteKeysProvider);
|
||||||
ref.invalidate(favoritesControllerProvider);
|
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)}',
|
|
||||||
),
|
|
||||||
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
|
_refresh(items);
|
||||||
.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,
|
||||||
|
|||||||
@@ -6,10 +6,11 @@ import '../../../app/app.dart';
|
|||||||
import '../../../app/responsive.dart';
|
import '../../../app/responsive.dart';
|
||||||
import '../../../backend/backend.dart';
|
import '../../../backend/backend.dart';
|
||||||
import '../../../shared/widgets/adaptive_scaffold.dart';
|
import '../../../shared/widgets/adaptive_scaffold.dart';
|
||||||
import '../../../shared/widgets/empty_view.dart';
|
import '../../../shared/widgets/confirm_dialog.dart';
|
||||||
import '../../../shared/widgets/error_view.dart';
|
import '../../../shared/widgets/error_view.dart';
|
||||||
import '../../../shared/widgets/post_masonry_grid.dart';
|
import '../../../shared/widgets/post_masonry_grid.dart';
|
||||||
import '../../favorites/presentation/favorites_controller.dart';
|
import '../../favorites/presentation/favorites_controller.dart';
|
||||||
|
import 'collection_form_dialog.dart';
|
||||||
import 'collections_controller.dart';
|
import 'collections_controller.dart';
|
||||||
|
|
||||||
class CollectionDetailsScreen extends ConsumerWidget {
|
class CollectionDetailsScreen extends ConsumerWidget {
|
||||||
@@ -19,24 +20,141 @@ class CollectionDetailsScreen extends ConsumerWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final posts = ref.watch(collectionPostsProvider(collectionId));
|
final postsAsync = ref.watch(collectionPostsProvider(collectionId));
|
||||||
final collections = ref.watch(collectionsControllerProvider).valueOrNull;
|
final collections = ref.watch(collectionsControllerProvider).valueOrNull;
|
||||||
final title = collections
|
final collection = collections
|
||||||
?.where((collection) => collection.id == collectionId)
|
?.where((c) => c.id == collectionId)
|
||||||
.firstOrNull
|
.firstOrNull;
|
||||||
?.name ??
|
|
||||||
'Collection';
|
|
||||||
final settings =
|
final settings =
|
||||||
ref.watch(appSettingsProvider).value ?? AppSettings.defaults;
|
ref.watch(appSettingsProvider).value ?? AppSettings.defaults;
|
||||||
|
final isRu = settings.languageCode == 'ru';
|
||||||
final favoriteKeys = ref.watch(favoriteKeysProvider).value ?? <String>{};
|
final favoriteKeys = ref.watch(favoriteKeysProvider).value ?? <String>{};
|
||||||
|
|
||||||
return AdaptiveScaffold(
|
return AdaptiveScaffold(
|
||||||
title: title,
|
title: collection?.name ?? (isRu ? 'Коллекция' : 'Collection'),
|
||||||
body: posts.when(
|
actions: [
|
||||||
|
if (collection != null) ...[
|
||||||
|
IconButton(
|
||||||
|
tooltip: isRu ? 'Редактировать' : 'Edit',
|
||||||
|
icon: const Icon(Icons.edit_rounded),
|
||||||
|
onPressed: () => showCollectionFormDialog(
|
||||||
|
context,
|
||||||
|
ref,
|
||||||
|
collection: collection,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
tooltip: isRu ? 'Удалить' : 'Delete',
|
||||||
|
icon: const Icon(Icons.delete_outline_rounded),
|
||||||
|
onPressed: () async {
|
||||||
|
final ok = await showConfirmDialog(
|
||||||
|
context,
|
||||||
|
title: isRu ? 'Удалить коллекцию' : 'Delete collection',
|
||||||
|
message: isRu
|
||||||
|
? 'Удалить подборку "${collection.name}"?'
|
||||||
|
: 'Remove "${collection.name}"?',
|
||||||
|
);
|
||||||
|
if (ok) {
|
||||||
|
await ref
|
||||||
|
.read(collectionsControllerProvider.notifier)
|
||||||
|
.delete(collection.id);
|
||||||
|
if (context.mounted) context.pop();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
body: postsAsync.when(
|
||||||
loading: () => const Center(child: CircularProgressIndicator()),
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
error: (error, _) => ErrorView(message: error.toString()),
|
error: (error, _) => ErrorView(message: error.toString()),
|
||||||
data: (items) => items.isEmpty
|
data: (items) {
|
||||||
? const EmptyView(title: 'Collection is empty')
|
if (items.isEmpty) {
|
||||||
: PostMasonryGrid(
|
return Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Theme.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.primaryContainer
|
||||||
|
.withValues(alpha: 0.3),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
Icons.collections_bookmark_rounded,
|
||||||
|
size: 56,
|
||||||
|
color: Theme.of(context).colorScheme.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
isRu ? 'В коллекции пока нет постов' : 'Collection is empty',
|
||||||
|
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
isRu
|
||||||
|
? 'Добавляйте посты из ленты через меню «Добавить в коллекцию» на карточке поста'
|
||||||
|
: 'Add posts to this collection using "Add to collection" from post cards in the feed',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
color: Theme.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
FilledButton.icon(
|
||||||
|
onPressed: () => context.go('/feed'),
|
||||||
|
icon: const Icon(Icons.explore_rounded),
|
||||||
|
label: Text(isRu ? 'Перейти в ленту' : 'Explore Feed'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
if (collection?.description != null &&
|
||||||
|
collection!.description!.isNotEmpty)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 4),
|
||||||
|
child: Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 14,
|
||||||
|
vertical: 10,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Theme.of(context).colorScheme.surfaceContainerLow,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(
|
||||||
|
color: Theme.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.outlineVariant
|
||||||
|
.withValues(alpha: 0.3),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
collection.description!,
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
color: Theme.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: PostMasonryGrid(
|
||||||
posts: items,
|
posts: items,
|
||||||
columns: Responsive.columnsFor(
|
columns: Responsive.columnsFor(
|
||||||
context,
|
context,
|
||||||
@@ -74,6 +192,10 @@ class CollectionDetailsScreen extends ConsumerWidget {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
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 '../../../app/app.dart';
|
||||||
import '../../../backend/backend.dart';
|
import '../../../backend/backend.dart';
|
||||||
import 'collections_controller.dart';
|
import 'collections_controller.dart';
|
||||||
|
|
||||||
@@ -9,31 +10,91 @@ Future<void> showCollectionFormDialog(
|
|||||||
WidgetRef ref, {
|
WidgetRef ref, {
|
||||||
Collection? collection,
|
Collection? collection,
|
||||||
}) async {
|
}) async {
|
||||||
|
final settings =
|
||||||
|
ref.read(appSettingsProvider).value ?? AppSettings.defaults;
|
||||||
|
final isRu = settings.languageCode == 'ru';
|
||||||
final nameController = TextEditingController(text: collection?.name);
|
final nameController = TextEditingController(text: collection?.name);
|
||||||
final descriptionController =
|
final descriptionController =
|
||||||
TextEditingController(text: collection?.description);
|
TextEditingController(text: collection?.description);
|
||||||
|
|
||||||
await showDialog<void>(
|
await showDialog<void>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => AlertDialog(
|
builder: (context) {
|
||||||
title: Text(collection == null ? 'New collection' : 'Edit collection'),
|
final scheme = Theme.of(context).colorScheme;
|
||||||
|
return AlertDialog(
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(24),
|
||||||
|
),
|
||||||
|
titlePadding: const EdgeInsets.fromLTRB(24, 24, 24, 12),
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||||
|
actionsPadding: const EdgeInsets.fromLTRB(24, 16, 24, 20),
|
||||||
|
title: Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: scheme.primaryContainer,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
collection == null
|
||||||
|
? Icons.create_new_folder_rounded
|
||||||
|
: Icons.edit_note_rounded,
|
||||||
|
color: scheme.onPrimaryContainer,
|
||||||
|
size: 24,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 14),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
collection == null
|
||||||
|
? (isRu ? 'Новая коллекция' : 'New collection')
|
||||||
|
: (isRu ? 'Редактировать' : 'Edit collection'),
|
||||||
|
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
content: Column(
|
content: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
|
const SizedBox(height: 8),
|
||||||
TextField(
|
TextField(
|
||||||
controller: nameController,
|
controller: nameController,
|
||||||
decoration: const InputDecoration(labelText: 'Name'),
|
autofocus: collection == null,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: isRu ? 'Название' : 'Name',
|
||||||
|
hintText: isRu ? 'Например, Избранные обои' : 'e.g. Best Wallpapers',
|
||||||
|
prefixIcon: const Icon(Icons.bookmark_border_rounded),
|
||||||
|
filled: true,
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
TextField(
|
TextField(
|
||||||
controller: descriptionController,
|
controller: descriptionController,
|
||||||
decoration: const InputDecoration(labelText: 'Description'),
|
maxLines: 2,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: isRu ? 'Описание (необязательно)' : 'Description (optional)',
|
||||||
|
hintText: isRu ? 'О чем эта подборка' : 'What is this collection about',
|
||||||
|
prefixIcon: const Icon(Icons.description_outlined),
|
||||||
|
filled: true,
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.pop(context),
|
onPressed: () => Navigator.pop(context),
|
||||||
child: const Text('Cancel'),
|
child: Text(isRu ? 'Отмена' : 'Cancel'),
|
||||||
),
|
),
|
||||||
FilledButton(
|
FilledButton(
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
@@ -55,10 +116,11 @@ Future<void> showCollectionFormDialog(
|
|||||||
}
|
}
|
||||||
if (context.mounted) Navigator.pop(context);
|
if (context.mounted) Navigator.pop(context);
|
||||||
},
|
},
|
||||||
child: const Text('Save'),
|
child: Text(isRu ? 'Сохранить' : 'Save'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
);
|
||||||
|
},
|
||||||
);
|
);
|
||||||
nameController.dispose();
|
nameController.dispose();
|
||||||
descriptionController.dispose();
|
descriptionController.dispose();
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
|
import 'package:cached_network_image/cached_network_image.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:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
import '../../../backend/models/collection.dart';
|
import '../../../app/app.dart';
|
||||||
|
import '../../../backend/backend.dart';
|
||||||
import '../../../shared/widgets/adaptive_scaffold.dart';
|
import '../../../shared/widgets/adaptive_scaffold.dart';
|
||||||
import '../../../shared/widgets/confirm_dialog.dart';
|
import '../../../shared/widgets/confirm_dialog.dart';
|
||||||
import '../../../shared/widgets/empty_view.dart';
|
|
||||||
import '../../../shared/widgets/error_view.dart';
|
import '../../../shared/widgets/error_view.dart';
|
||||||
import 'collection_form_dialog.dart';
|
import 'collection_form_dialog.dart';
|
||||||
import 'collections_controller.dart';
|
import 'collections_controller.dart';
|
||||||
@@ -16,11 +17,15 @@ class CollectionsScreen extends ConsumerWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final collections = ref.watch(collectionsControllerProvider);
|
final collections = ref.watch(collectionsControllerProvider);
|
||||||
|
final settings =
|
||||||
|
ref.watch(appSettingsProvider).value ?? AppSettings.defaults;
|
||||||
|
final isRu = settings.languageCode == 'ru';
|
||||||
|
|
||||||
return AdaptiveScaffold(
|
return AdaptiveScaffold(
|
||||||
title: 'Collections',
|
title: isRu ? 'Коллекции' : 'Collections',
|
||||||
actions: [
|
actions: [
|
||||||
IconButton(
|
IconButton(
|
||||||
tooltip: 'New collection',
|
tooltip: isRu ? 'Новая коллекция' : 'New collection',
|
||||||
icon: const Icon(Icons.add_rounded),
|
icon: const Icon(Icons.add_rounded),
|
||||||
onPressed: () => showCollectionFormDialog(context, ref),
|
onPressed: () => showCollectionFormDialog(context, ref),
|
||||||
),
|
),
|
||||||
@@ -28,21 +33,76 @@ class CollectionsScreen extends ConsumerWidget {
|
|||||||
body: collections.when(
|
body: collections.when(
|
||||||
loading: () => const Center(child: CircularProgressIndicator()),
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
error: (error, _) => ErrorView(message: error.toString()),
|
error: (error, _) => ErrorView(message: error.toString()),
|
||||||
data: (items) => items.isEmpty
|
data: (items) {
|
||||||
? const EmptyView(title: 'No collections yet')
|
if (items.isEmpty) {
|
||||||
: GridView.builder(
|
return Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Theme.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.primaryContainer
|
||||||
|
.withValues(alpha: 0.3),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
Icons.collections_bookmark_rounded,
|
||||||
|
size: 56,
|
||||||
|
color: Theme.of(context).colorScheme.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
isRu ? 'Нет коллекций' : 'No collections yet',
|
||||||
|
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
isRu
|
||||||
|
? 'Создавайте тематические коллекции и объединяйте в них понравившиеся работы'
|
||||||
|
: 'Create themed collections and organize your favorite posts',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
color: Theme.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
FilledButton.icon(
|
||||||
|
onPressed: () => showCollectionFormDialog(context, ref),
|
||||||
|
icon: const Icon(Icons.add_rounded),
|
||||||
|
label: Text(
|
||||||
|
isRu ? 'Создать коллекцию' : 'Create collection',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return GridView.builder(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
|
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
|
||||||
maxCrossAxisExtent: 320,
|
maxCrossAxisExtent: 320,
|
||||||
mainAxisSpacing: 12,
|
mainAxisSpacing: 14,
|
||||||
crossAxisSpacing: 12,
|
crossAxisSpacing: 14,
|
||||||
childAspectRatio: 1.35,
|
childAspectRatio: 0.88,
|
||||||
),
|
),
|
||||||
itemCount: items.length,
|
itemCount: items.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final item = items[index];
|
final item = items[index];
|
||||||
return _CollectionCard(
|
return _CollectionCard(
|
||||||
collection: item,
|
collection: item,
|
||||||
|
isRu: isRu,
|
||||||
onOpen: () => context.go('/collections/${item.id}'),
|
onOpen: () => context.go('/collections/${item.id}'),
|
||||||
onEdit: () => showCollectionFormDialog(
|
onEdit: () => showCollectionFormDialog(
|
||||||
context,
|
context,
|
||||||
@@ -52,8 +112,10 @@ class CollectionsScreen extends ConsumerWidget {
|
|||||||
onDelete: () async {
|
onDelete: () async {
|
||||||
final ok = await showConfirmDialog(
|
final ok = await showConfirmDialog(
|
||||||
context,
|
context,
|
||||||
title: 'Delete collection',
|
title: isRu ? 'Удалить коллекцию' : 'Delete collection',
|
||||||
message: 'Remove ${item.name}?',
|
message: isRu
|
||||||
|
? 'Удалить подборку "${item.name}"?'
|
||||||
|
: 'Remove "${item.name}"?',
|
||||||
);
|
);
|
||||||
if (ok) {
|
if (ok) {
|
||||||
await ref
|
await ref
|
||||||
@@ -63,113 +125,225 @@ class CollectionsScreen extends ConsumerWidget {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _CollectionCard extends StatelessWidget {
|
class _CollectionCard extends ConsumerWidget {
|
||||||
const _CollectionCard({
|
const _CollectionCard({
|
||||||
required this.collection,
|
required this.collection,
|
||||||
|
required this.isRu,
|
||||||
required this.onOpen,
|
required this.onOpen,
|
||||||
required this.onEdit,
|
required this.onEdit,
|
||||||
required this.onDelete,
|
required this.onDelete,
|
||||||
});
|
});
|
||||||
|
|
||||||
final Collection collection;
|
final Collection collection;
|
||||||
|
final bool isRu;
|
||||||
final VoidCallback onOpen;
|
final VoidCallback onOpen;
|
||||||
final VoidCallback onEdit;
|
final VoidCallback onEdit;
|
||||||
final VoidCallback onDelete;
|
final VoidCallback onDelete;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final scheme = Theme.of(context).colorScheme;
|
final scheme = Theme.of(context).colorScheme;
|
||||||
|
final postsAsync = ref.watch(collectionPostsProvider(collection.id));
|
||||||
|
final posts = postsAsync.value ?? const <Post>[];
|
||||||
|
final previewUrls = posts
|
||||||
|
.map((p) => MediaUrlSelector.preview(p).firstOrNull)
|
||||||
|
.whereType<String>()
|
||||||
|
.take(4)
|
||||||
|
.toList();
|
||||||
|
|
||||||
return Card(
|
return Card(
|
||||||
|
elevation: 0,
|
||||||
|
color: scheme.surfaceContainerLow,
|
||||||
clipBehavior: Clip.antiAlias,
|
clipBehavior: Clip.antiAlias,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(18),
|
||||||
|
side: BorderSide(
|
||||||
|
color: scheme.outlineVariant.withValues(alpha: 0.3),
|
||||||
|
),
|
||||||
|
),
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
onTap: onOpen,
|
onTap: onOpen,
|
||||||
child: Stack(
|
|
||||||
children: [
|
|
||||||
Positioned.fill(
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 14),
|
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
SizedBox(
|
// Cover Collage (2x2 or Hero)
|
||||||
height: 54,
|
Expanded(
|
||||||
child: Align(
|
child: Stack(
|
||||||
alignment: Alignment.centerLeft,
|
fit: StackFit.expand,
|
||||||
child: DecoratedBox(
|
children: [
|
||||||
|
_buildCoverMosaic(previewUrls, scheme),
|
||||||
|
// Glass count badge
|
||||||
|
Positioned(
|
||||||
|
bottom: 8,
|
||||||
|
right: 8,
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 8,
|
||||||
|
vertical: 4,
|
||||||
|
),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color:
|
color: Colors.black.withValues(alpha: 0.6),
|
||||||
scheme.primaryContainer.withValues(alpha: 0.56),
|
borderRadius: BorderRadius.circular(8),
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
),
|
),
|
||||||
child: const Padding(
|
child: Row(
|
||||||
padding: EdgeInsets.all(12),
|
mainAxisSize: MainAxisSize.min,
|
||||||
child: Icon(
|
children: [
|
||||||
Icons.collections_bookmark_rounded,
|
const Icon(
|
||||||
size: 28,
|
Icons.photo_library_outlined,
|
||||||
|
size: 12,
|
||||||
|
color: Colors.white,
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(width: 4),
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const Spacer(),
|
|
||||||
Text(
|
Text(
|
||||||
collection.name,
|
'${posts.length}',
|
||||||
maxLines: 1,
|
style: const TextStyle(
|
||||||
overflow: TextOverflow.ellipsis,
|
color: Colors.white,
|
||||||
style: Theme.of(context).textTheme.titleLarge,
|
fontSize: 11,
|
||||||
),
|
fontWeight: FontWeight.w600,
|
||||||
const SizedBox(height: 4),
|
|
||||||
Text(
|
|
||||||
collection.description ?? 'No description',
|
|
||||||
maxLines: 2,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
|
||||||
color: scheme.onSurfaceVariant,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Positioned(
|
],
|
||||||
top: 6,
|
),
|
||||||
right: 6,
|
),
|
||||||
child: PopupMenuButton<String>(
|
// Info Row
|
||||||
tooltip: 'Collection actions',
|
Padding(
|
||||||
icon: const Icon(Icons.more_horiz_rounded),
|
padding: const EdgeInsets.fromLTRB(12, 10, 8, 10),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
collection.name,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (collection.description != null &&
|
||||||
|
collection.description!.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
collection.description!,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: scheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
PopupMenuButton<String>(
|
||||||
|
tooltip: isRu ? 'Действия' : 'Actions',
|
||||||
|
icon: Icon(
|
||||||
|
Icons.more_vert_rounded,
|
||||||
|
size: 20,
|
||||||
|
color: scheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
onSelected: (value) {
|
onSelected: (value) {
|
||||||
if (value == 'edit') onEdit();
|
if (value == 'edit') onEdit();
|
||||||
if (value == 'delete') onDelete();
|
if (value == 'delete') onDelete();
|
||||||
},
|
},
|
||||||
itemBuilder: (context) => const [
|
itemBuilder: (context) => [
|
||||||
PopupMenuItem(
|
PopupMenuItem(
|
||||||
value: 'edit',
|
value: 'edit',
|
||||||
child: ListTile(
|
child: ListTile(
|
||||||
dense: true,
|
dense: true,
|
||||||
leading: Icon(Icons.edit_rounded),
|
leading: const Icon(Icons.edit_rounded, size: 20),
|
||||||
title: Text('Edit'),
|
title: Text(isRu ? 'Редактировать' : 'Edit'),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
PopupMenuItem(
|
PopupMenuItem(
|
||||||
value: 'delete',
|
value: 'delete',
|
||||||
child: ListTile(
|
child: ListTile(
|
||||||
dense: true,
|
dense: true,
|
||||||
leading: Icon(Icons.delete_rounded),
|
leading: const Icon(Icons.delete_rounded, size: 20),
|
||||||
title: Text('Delete'),
|
title: Text(isRu ? 'Удалить' : 'Delete'),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildCoverMosaic(List<String> urls, ColorScheme scheme) {
|
||||||
|
if (urls.isEmpty) {
|
||||||
|
return Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
colors: [
|
||||||
|
scheme.primaryContainer.withValues(alpha: 0.6),
|
||||||
|
scheme.tertiaryContainer.withValues(alpha: 0.6),
|
||||||
|
],
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Center(
|
||||||
|
child: Icon(
|
||||||
|
Icons.collections_bookmark_rounded,
|
||||||
|
size: 40,
|
||||||
|
color: scheme.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (urls.length == 1) {
|
||||||
|
return CachedNetworkImage(
|
||||||
|
imageUrl: urls[0],
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
placeholder: (_, __) => Container(color: scheme.surfaceContainerHighest),
|
||||||
|
errorWidget: (_, __, ___) => Container(
|
||||||
|
color: scheme.surfaceContainerHighest,
|
||||||
|
child: const Icon(Icons.broken_image_rounded),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mosaic grid (up to 4)
|
||||||
|
return GridView.count(
|
||||||
|
crossAxisCount: 2,
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
|
children: [
|
||||||
|
for (int i = 0; i < 4; i++)
|
||||||
|
if (i < urls.length)
|
||||||
|
CachedNetworkImage(
|
||||||
|
imageUrl: urls[i],
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
placeholder: (_, __) =>
|
||||||
|
Container(color: scheme.surfaceContainerHighest),
|
||||||
|
errorWidget: (_, __, ___) => Container(
|
||||||
|
color: scheme.surfaceContainerHighest,
|
||||||
|
child: const Icon(Icons.image_not_supported_rounded, size: 16),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
Container(color: scheme.surfaceContainerHighest),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'package:cached_network_image/cached_network_image.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:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
@@ -11,6 +12,8 @@ import '../../../shared/widgets/error_view.dart';
|
|||||||
import '../../../shared/widgets/post_masonry_grid.dart';
|
import '../../../shared/widgets/post_masonry_grid.dart';
|
||||||
import 'favorites_controller.dart';
|
import 'favorites_controller.dart';
|
||||||
|
|
||||||
|
enum FavoriteMediaType { all, video, image }
|
||||||
|
|
||||||
class FavoritesScreen extends ConsumerStatefulWidget {
|
class FavoritesScreen extends ConsumerStatefulWidget {
|
||||||
const FavoritesScreen({super.key});
|
const FavoritesScreen({super.key});
|
||||||
|
|
||||||
@@ -21,46 +24,212 @@ class FavoritesScreen extends ConsumerStatefulWidget {
|
|||||||
class _FavoritesScreenState extends ConsumerState<FavoritesScreen> {
|
class _FavoritesScreenState extends ConsumerState<FavoritesScreen> {
|
||||||
int _tabIndex = 0;
|
int _tabIndex = 0;
|
||||||
String? _selectedArtist;
|
String? _selectedArtist;
|
||||||
|
FavoriteMediaType _mediaFilter = FavoriteMediaType.all;
|
||||||
|
bool _isSearching = false;
|
||||||
|
final TextEditingController _searchController = TextEditingController();
|
||||||
|
String _searchQuery = '';
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_searchController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _isVideoPost(Post post) {
|
||||||
|
final type = post.fileType.toLowerCase();
|
||||||
|
final url = post.fileUrl.toLowerCase();
|
||||||
|
return type == 'mp4' ||
|
||||||
|
type == 'webm' ||
|
||||||
|
url.endsWith('.mp4') ||
|
||||||
|
url.endsWith('.webm');
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Post> _applyFilters(List<Post> posts) {
|
||||||
|
var result = posts;
|
||||||
|
if (_mediaFilter == FavoriteMediaType.video) {
|
||||||
|
result = result.where(_isVideoPost).toList();
|
||||||
|
} else if (_mediaFilter == FavoriteMediaType.image) {
|
||||||
|
result = result.where((p) => !_isVideoPost(p)).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_searchQuery.isNotEmpty) {
|
||||||
|
final query = _searchQuery.toLowerCase();
|
||||||
|
result = result.where((post) {
|
||||||
|
return post.tags.any((t) => t.toLowerCase().contains(query)) ||
|
||||||
|
post.providerName.toLowerCase().contains(query);
|
||||||
|
}).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final state = ref.watch(favoritesControllerProvider);
|
final state = ref.watch(favoritesControllerProvider);
|
||||||
final settings =
|
final settings =
|
||||||
ref.watch(appSettingsProvider).value ?? AppSettings.defaults;
|
ref.watch(appSettingsProvider).value ?? AppSettings.defaults;
|
||||||
final posts = state.value?.posts ?? const <Post>[];
|
final isRu = settings.languageCode == 'ru';
|
||||||
|
final allPosts = state.value?.posts ?? const <Post>[];
|
||||||
final downloaded = ref
|
final downloaded = ref
|
||||||
.watch(downloadedMediaByKeysProvider(
|
.watch(downloadedMediaByKeysProvider(
|
||||||
posts.map((post) => post.cacheKey).toList(growable: false),
|
allPosts.map((post) => post.cacheKey).toList(growable: false),
|
||||||
))
|
))
|
||||||
.value ??
|
.value ??
|
||||||
const <String, DownloadedMedia>{};
|
const <String, DownloadedMedia>{};
|
||||||
|
|
||||||
|
final offlinePosts = allPosts
|
||||||
|
.where((post) => downloaded.containsKey(post.cacheKey))
|
||||||
|
.toList();
|
||||||
|
final artistGroups = favoriteArtistAlbums(allPosts);
|
||||||
|
|
||||||
return AdaptiveScaffold(
|
return AdaptiveScaffold(
|
||||||
title: settings.languageCode == 'ru' ? 'Избранное' : 'Favorites',
|
title: isRu ? 'Избранное' : 'Favorites',
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
tooltip: _isSearching
|
||||||
|
? (isRu ? 'Закрыть поиск' : 'Close search')
|
||||||
|
: (isRu ? 'Поиск в избранном' : 'Search favorites'),
|
||||||
|
icon: Icon(_isSearching ? Icons.close_rounded : Icons.search_rounded),
|
||||||
|
onPressed: () {
|
||||||
|
setState(() {
|
||||||
|
_isSearching = !_isSearching;
|
||||||
|
if (!_isSearching) {
|
||||||
|
_searchController.clear();
|
||||||
|
_searchQuery = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
body: state.when(
|
body: state.when(
|
||||||
loading: () => const Center(child: CircularProgressIndicator()),
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
error: (error, _) => ErrorView(message: error.toString()),
|
error: (error, _) => ErrorView(message: error.toString()),
|
||||||
data: (data) => data.posts.isEmpty
|
data: (data) {
|
||||||
? const EmptyView(title: 'No favorites yet')
|
if (data.posts.isEmpty) {
|
||||||
: Column(
|
return Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Theme.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.primaryContainer
|
||||||
|
.withValues(alpha: 0.3),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
Icons.favorite_border_rounded,
|
||||||
|
size: 56,
|
||||||
|
color: Theme.of(context).colorScheme.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
isRu ? 'В избранном пока пусто' : 'No favorites yet',
|
||||||
|
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
isRu
|
||||||
|
? 'Нажимайте ❤️ на постах в ленте, чтобы сохранить их'
|
||||||
|
: 'Tap ❤️ on posts in the feed to save them here',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
color: Theme.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
FilledButton.icon(
|
||||||
|
onPressed: () => context.go('/feed'),
|
||||||
|
icon: const Icon(Icons.explore_rounded),
|
||||||
|
label: Text(isRu ? 'Перейти в ленту' : 'Explore Feed'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
// Search input bar if active
|
||||||
|
if (_isSearching)
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(12, 8, 12, 6),
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 4),
|
||||||
|
child: TextField(
|
||||||
|
controller: _searchController,
|
||||||
|
autofocus: true,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: isRu
|
||||||
|
? 'Поиск по тегам и авторам...'
|
||||||
|
: 'Search by tags and artists...',
|
||||||
|
prefixIcon: const Icon(Icons.search_rounded),
|
||||||
|
suffixIcon: _searchQuery.isNotEmpty
|
||||||
|
? IconButton(
|
||||||
|
icon: const Icon(Icons.clear_rounded),
|
||||||
|
onPressed: () {
|
||||||
|
_searchController.clear();
|
||||||
|
setState(() => _searchQuery = '');
|
||||||
|
},
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
isDense: true,
|
||||||
|
filled: true,
|
||||||
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 16,
|
||||||
|
vertical: 10,
|
||||||
|
),
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
borderSide: BorderSide.none,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onChanged: (val) => setState(() => _searchQuery = val.trim()),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Navigation Tabs
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 6),
|
||||||
|
child: SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
child: SegmentedButton<int>(
|
child: SegmentedButton<int>(
|
||||||
segments: const [
|
showSelectedIcon: false,
|
||||||
|
segments: [
|
||||||
ButtonSegment(
|
ButtonSegment(
|
||||||
value: 0,
|
value: 0,
|
||||||
icon: Icon(Icons.favorite_rounded),
|
icon: const Icon(Icons.favorite_rounded, size: 18),
|
||||||
label: Text('All'),
|
label: Text(
|
||||||
|
'${isRu ? 'Все' : 'All'} (${allPosts.length})',
|
||||||
|
style: const TextStyle(fontSize: 12),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
ButtonSegment(
|
ButtonSegment(
|
||||||
value: 1,
|
value: 1,
|
||||||
icon: Icon(Icons.offline_pin_rounded),
|
icon: const Icon(Icons.offline_pin_rounded, size: 18),
|
||||||
label: Text('Offline'),
|
label: Text(
|
||||||
|
'${isRu ? 'Офлайн' : 'Offline'} (${offlinePosts.length})',
|
||||||
|
style: const TextStyle(fontSize: 12),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
ButtonSegment(
|
ButtonSegment(
|
||||||
value: 2,
|
value: 2,
|
||||||
icon: Icon(Icons.groups_rounded),
|
icon: const Icon(Icons.groups_rounded, size: 18),
|
||||||
label: Text('Artists'),
|
label: Text(
|
||||||
|
'${isRu ? 'Авторы' : 'Artists'} (${artistGroups.length})',
|
||||||
|
style: const TextStyle(fontSize: 12),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
selected: {_tabIndex},
|
selected: {_tabIndex},
|
||||||
@@ -72,31 +241,308 @@ class _FavoritesScreenState extends ConsumerState<FavoritesScreen> {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Expanded(
|
|
||||||
child: switch (_tabIndex) {
|
|
||||||
1 => _postsGrid(
|
|
||||||
data.posts
|
|
||||||
.where((post) =>
|
|
||||||
downloaded.containsKey(post.cacheKey))
|
|
||||||
.toList(),
|
|
||||||
settings,
|
|
||||||
downloaded.keys.toSet(),
|
|
||||||
),
|
),
|
||||||
2 => _artistAlbums(
|
|
||||||
data.posts,
|
// Media Type Filter Chips (for Tab 0 and Tab 1)
|
||||||
settings,
|
if (_tabIndex != 2)
|
||||||
downloaded.keys.toSet(),
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
FilterChip(
|
||||||
|
label: Text(isRu ? 'Все' : 'All'),
|
||||||
|
selected: _mediaFilter == FavoriteMediaType.all,
|
||||||
|
onSelected: (_) => setState(
|
||||||
|
() => _mediaFilter = FavoriteMediaType.all,
|
||||||
),
|
),
|
||||||
_ => _postsGrid(
|
visualDensity: VisualDensity.compact,
|
||||||
data.posts,
|
|
||||||
settings,
|
|
||||||
downloaded.keys.toSet(),
|
|
||||||
),
|
),
|
||||||
},
|
const SizedBox(width: 8),
|
||||||
|
FilterChip(
|
||||||
|
avatar: const Icon(Icons.videocam_rounded, size: 16),
|
||||||
|
label: Text(isRu ? 'Видео' : 'Videos'),
|
||||||
|
selected: _mediaFilter == FavoriteMediaType.video,
|
||||||
|
onSelected: (_) => setState(
|
||||||
|
() => _mediaFilter = FavoriteMediaType.video,
|
||||||
|
),
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
FilterChip(
|
||||||
|
avatar: const Icon(Icons.image_rounded, size: 16),
|
||||||
|
label: Text(isRu ? 'Фото' : 'Images'),
|
||||||
|
selected: _mediaFilter == FavoriteMediaType.image,
|
||||||
|
onSelected: (_) => setState(
|
||||||
|
() => _mediaFilter = FavoriteMediaType.image,
|
||||||
|
),
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
|
||||||
|
// Main Content
|
||||||
|
Expanded(
|
||||||
|
child: switch (_tabIndex) {
|
||||||
|
1 => _buildOfflineView(
|
||||||
|
offlinePosts,
|
||||||
|
settings,
|
||||||
|
downloaded.keys.toSet(),
|
||||||
|
isRu,
|
||||||
|
),
|
||||||
|
2 => _buildArtistsView(
|
||||||
|
allPosts,
|
||||||
|
settings,
|
||||||
|
downloaded.keys.toSet(),
|
||||||
|
isRu,
|
||||||
|
),
|
||||||
|
_ => _buildAllView(
|
||||||
|
allPosts,
|
||||||
|
settings,
|
||||||
|
downloaded.keys.toSet(),
|
||||||
|
isRu,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildAllView(
|
||||||
|
List<Post> posts,
|
||||||
|
AppSettings settings,
|
||||||
|
Set<String> downloadedKeys,
|
||||||
|
bool isRu,
|
||||||
|
) {
|
||||||
|
final filtered = _applyFilters(posts);
|
||||||
|
if (filtered.isEmpty) {
|
||||||
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.filter_list_off_rounded,
|
||||||
|
size: 48,
|
||||||
|
color: Theme.of(context).colorScheme.outline,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
isRu ? 'Ничего не найдено' : 'No matching posts',
|
||||||
|
style: Theme.of(context).textTheme.titleMedium,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
setState(() {
|
||||||
|
_mediaFilter = FavoriteMediaType.all;
|
||||||
|
_searchController.clear();
|
||||||
|
_searchQuery = '';
|
||||||
|
});
|
||||||
|
},
|
||||||
|
child: Text(isRu ? 'Сбросить фильтры' : 'Reset filters'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return _postsGrid(filtered, settings, downloadedKeys);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildOfflineView(
|
||||||
|
List<Post> posts,
|
||||||
|
AppSettings settings,
|
||||||
|
Set<String> downloadedKeys,
|
||||||
|
bool isRu,
|
||||||
|
) {
|
||||||
|
if (posts.isEmpty) {
|
||||||
|
return Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(18),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Theme.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.secondaryContainer
|
||||||
|
.withValues(alpha: 0.35),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
Icons.cloud_off_rounded,
|
||||||
|
size: 48,
|
||||||
|
color: Theme.of(context).colorScheme.secondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
isRu ? 'Нет офлайн постов' : 'No offline posts',
|
||||||
|
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
isRu
|
||||||
|
? 'Загружайте понравившиеся работы для просмотра без подключения к сети'
|
||||||
|
: 'Download posts to view them anytime without an internet connection',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final filtered = _applyFilters(posts);
|
||||||
|
if (filtered.isEmpty) {
|
||||||
|
return Center(
|
||||||
|
child: Text(isRu ? 'Ничего не найдено' : 'No matching offline posts'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return _postsGrid(filtered, settings, downloadedKeys);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildArtistsView(
|
||||||
|
List<Post> posts,
|
||||||
|
AppSettings settings,
|
||||||
|
Set<String> downloadedKeys,
|
||||||
|
bool isRu,
|
||||||
|
) {
|
||||||
|
final groups = favoriteArtistAlbums(posts);
|
||||||
|
final selected = _selectedArtist;
|
||||||
|
|
||||||
|
if (selected != null) {
|
||||||
|
final artistPosts = groups[selected] ?? const [];
|
||||||
|
final filtered = _applyFilters(artistPosts);
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
// Artist Header Banner
|
||||||
|
Container(
|
||||||
|
margin: const EdgeInsets.fromLTRB(16, 4, 16, 8),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Theme.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.surfaceContainerLow,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
border: Border.all(
|
||||||
|
color: Theme.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.outlineVariant
|
||||||
|
.withValues(alpha: 0.3),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
onPressed: () => setState(() => _selectedArtist = null),
|
||||||
|
icon: const Icon(Icons.arrow_back_rounded),
|
||||||
|
tooltip: isRu ? 'Все авторы' : 'All artists',
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
CircleAvatar(
|
||||||
|
radius: 18,
|
||||||
|
backgroundColor: Theme.of(context).colorScheme.primaryContainer,
|
||||||
|
child: Text(
|
||||||
|
selected.isNotEmpty ? selected[0].toUpperCase() : '?',
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Theme.of(context).colorScheme.onPrimaryContainer,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
selected,
|
||||||
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
'${artistPosts.length} ${isRu ? 'работ' : 'posts'}',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: Theme.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
FilledButton.tonalIcon(
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
),
|
||||||
|
onPressed: () {
|
||||||
|
context.push('/search?q=${Uri.encodeComponent('artist:$selected')}');
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.search_rounded, size: 16),
|
||||||
|
label: Text(isRu ? 'В сети' : 'Search'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: filtered.isEmpty
|
||||||
|
? Center(
|
||||||
|
child: Text(isRu ? 'Ничего не найдено' : 'No posts found'),
|
||||||
|
)
|
||||||
|
: _postsGrid(filtered, settings, downloadedKeys),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final query = _searchQuery.toLowerCase();
|
||||||
|
final entries = groups.entries.where((e) {
|
||||||
|
if (query.isEmpty) return true;
|
||||||
|
return e.key.toLowerCase().contains(query);
|
||||||
|
}).toList();
|
||||||
|
|
||||||
|
if (entries.isEmpty) {
|
||||||
|
return Center(
|
||||||
|
child: Text(isRu ? 'Авторы не найдены' : 'No artists found'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ListView.separated(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 24),
|
||||||
|
itemCount: entries.length,
|
||||||
|
separatorBuilder: (_, __) => const SizedBox(height: 10),
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final entry = entries[index];
|
||||||
|
final artistPosts = entry.value;
|
||||||
|
final previewUrls = artistPosts
|
||||||
|
.map((p) => MediaUrlSelector.preview(p).firstOrNull)
|
||||||
|
.whereType<String>()
|
||||||
|
.take(3)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
return _ArtistCard(
|
||||||
|
artistName: entry.key,
|
||||||
|
postsCount: artistPosts.length,
|
||||||
|
previewUrls: previewUrls,
|
||||||
|
isRu: isRu,
|
||||||
|
onTap: () => setState(() => _selectedArtist = entry.key),
|
||||||
|
);
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,49 +576,146 @@ class _FavoritesScreenState extends ConsumerState<FavoritesScreen> {
|
|||||||
ref.read(favoritesControllerProvider.notifier).remove(post),
|
ref.read(favoritesControllerProvider.notifier).remove(post),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _artistAlbums(
|
|
||||||
List<Post> posts,
|
|
||||||
AppSettings settings,
|
|
||||||
Set<String> downloadedKeys,
|
|
||||||
) {
|
|
||||||
final groups = favoriteArtistAlbums(posts);
|
|
||||||
final selected = _selectedArtist;
|
|
||||||
if (selected != null) {
|
|
||||||
return Column(
|
|
||||||
children: [
|
|
||||||
Align(
|
|
||||||
alignment: Alignment.centerLeft,
|
|
||||||
child: TextButton.icon(
|
|
||||||
onPressed: () => setState(() => _selectedArtist = null),
|
|
||||||
icon: const Icon(Icons.arrow_back_rounded),
|
|
||||||
label: Text(selected),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Expanded(
|
|
||||||
child: _postsGrid(
|
|
||||||
groups[selected] ?? const [],
|
|
||||||
settings,
|
|
||||||
downloadedKeys,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return ListView(
|
|
||||||
padding: const EdgeInsets.fromLTRB(12, 8, 12, 24),
|
class _ArtistCard extends StatelessWidget {
|
||||||
|
const _ArtistCard({
|
||||||
|
required this.artistName,
|
||||||
|
required this.postsCount,
|
||||||
|
required this.previewUrls,
|
||||||
|
required this.isRu,
|
||||||
|
required this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String artistName;
|
||||||
|
final int postsCount;
|
||||||
|
final List<String> previewUrls;
|
||||||
|
final bool isRu;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final scheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
|
return Card(
|
||||||
|
elevation: 0,
|
||||||
|
color: scheme.surfaceContainerLow,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
side: BorderSide(
|
||||||
|
color: scheme.outlineVariant.withValues(alpha: 0.3),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
|
child: InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
for (final entry in groups.entries)
|
// Artist Avatar
|
||||||
Card(
|
Container(
|
||||||
child: ListTile(
|
width: 48,
|
||||||
leading: const Icon(Icons.person_rounded),
|
height: 48,
|
||||||
title: Text(entry.key),
|
decoration: BoxDecoration(
|
||||||
subtitle: Text('${entry.value.length} posts'),
|
gradient: LinearGradient(
|
||||||
trailing: const Icon(Icons.chevron_right_rounded),
|
colors: [
|
||||||
onTap: () => setState(() => _selectedArtist = entry.key),
|
scheme.primaryContainer,
|
||||||
|
scheme.tertiaryContainer,
|
||||||
|
],
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
),
|
||||||
|
child: Center(
|
||||||
|
child: Text(
|
||||||
|
artistName.isNotEmpty ? artistName[0].toUpperCase() : '?',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: scheme.onPrimaryContainer,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
// Artist Name & Count
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
artistName,
|
||||||
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 8,
|
||||||
|
vertical: 2,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: scheme.secondaryContainer.withValues(alpha: 0.5),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'$postsCount ${isRu ? 'работ' : 'posts'}',
|
||||||
|
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||||
|
color: scheme.onSecondaryContainer,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Thumbnails preview
|
||||||
|
if (previewUrls.isNotEmpty) ...[
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
for (final url in previewUrls)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(left: 4),
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: SizedBox(
|
||||||
|
width: 44,
|
||||||
|
height: 44,
|
||||||
|
child: CachedNetworkImage(
|
||||||
|
imageUrl: url,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
placeholder: (_, __) => Container(
|
||||||
|
color: scheme.surfaceContainerHighest,
|
||||||
|
),
|
||||||
|
errorWidget: (_, __, ___) => Container(
|
||||||
|
color: scheme.surfaceContainerHighest,
|
||||||
|
child: const Icon(
|
||||||
|
Icons.image_not_supported_rounded,
|
||||||
|
size: 16,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Icon(
|
||||||
|
Icons.chevron_right_rounded,
|
||||||
|
color: scheme.onSurfaceVariant.withValues(alpha: 0.6),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
isLabelVisible: hasActiveFilters,
|
||||||
|
child: IconButton.filledTonal(
|
||||||
|
tooltip: 'Провайдеры',
|
||||||
onPressed: widget.onProviderFilter,
|
onPressed: widget.onProviderFilter,
|
||||||
icon: const Icon(Icons.hub_rounded),
|
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,11 +200,16 @@ 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(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: TagInputSearchBar(
|
||||||
initialValue: _query,
|
initialValue: _query,
|
||||||
suggestions: widget.tagSuggestions,
|
suggestions: widget.tagSuggestions,
|
||||||
onChanged: (value) {
|
onChanged: (value) {
|
||||||
@@ -205,8 +222,40 @@ class _FeedToolbarState extends State<FeedToolbar> {
|
|||||||
widget.onSuggestionTap(value);
|
widget.onSuggestionTap(value);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
),
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
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
|
|
||||||
: Icons.check_box_outline_blank_rounded),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 6),
|
|
||||||
IconButton.filledTonal(
|
|
||||||
tooltip: 'Rating',
|
|
||||||
onPressed: widget.onRatingFilter,
|
|
||||||
icon: const Icon(Icons.tune_rounded),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 6),
|
|
||||||
IconButton(
|
|
||||||
tooltip: 'Clear filters',
|
|
||||||
onPressed: widget.onClearFilters,
|
onPressed: widget.onClearFilters,
|
||||||
icon: const Icon(Icons.filter_alt_off_rounded),
|
|
||||||
),
|
),
|
||||||
IconButton(
|
const SizedBox(width: 6),
|
||||||
tooltip: 'Refresh',
|
],
|
||||||
|
ActionChip(
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
avatar: const Icon(Icons.refresh_rounded, size: 15),
|
||||||
|
label: const Text('Обновить'),
|
||||||
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,
|
||||||
@@ -61,6 +74,14 @@ class SearchController extends AsyncNotifier<SearchState> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> deleteHistory(String historyId) async {
|
||||||
|
await ref.read(searchServiceProvider).deleteHistoryItem(historyId);
|
||||||
|
final recent = await _recent();
|
||||||
|
state = AsyncData(
|
||||||
|
(state.value ?? const SearchState()).copyWith(recent: recent),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> clearHistory() async {
|
Future<void> clearHistory() async {
|
||||||
await ref.read(searchServiceProvider).clearHistory();
|
await ref.read(searchServiceProvider).clearHistory();
|
||||||
state = const AsyncData(SearchState());
|
state = const AsyncData(SearchState());
|
||||||
|
|||||||
@@ -2,39 +2,103 @@ import 'package:flutter/material.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 '../../../app/app.dart';
|
||||||
|
import '../../../app/app_strings.dart';
|
||||||
|
import '../../../backend/backend.dart';
|
||||||
import '../../../shared/widgets/adaptive_scaffold.dart';
|
import '../../../shared/widgets/adaptive_scaffold.dart';
|
||||||
import '../../../shared/widgets/app_search_bar.dart';
|
import '../../../shared/widgets/app_search_bar.dart';
|
||||||
|
import '../../../shared/widgets/confirm_dialog.dart';
|
||||||
import '../../../shared/widgets/error_view.dart';
|
import '../../../shared/widgets/error_view.dart';
|
||||||
import 'search_controller.dart';
|
import 'search_controller.dart';
|
||||||
import 'widgets/recent_searches.dart';
|
import 'widgets/recent_searches.dart';
|
||||||
|
|
||||||
class SearchScreen extends ConsumerWidget {
|
class SearchScreen extends ConsumerStatefulWidget {
|
||||||
const SearchScreen({super.key});
|
const SearchScreen({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
ConsumerState<SearchScreen> createState() => _SearchScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SearchScreenState extends ConsumerState<SearchScreen> {
|
||||||
|
bool _showTips = false;
|
||||||
|
|
||||||
|
void _onTagTap(String tag, String currentQuery) {
|
||||||
|
final trimmed = currentQuery.trim();
|
||||||
|
if (trimmed.isEmpty) {
|
||||||
|
context.go('/?q=${Uri.encodeQueryComponent(tag)}');
|
||||||
|
} else {
|
||||||
|
final newQuery = '$trimmed $tag';
|
||||||
|
ref.read(searchControllerProvider.notifier).updateQuery(newQuery);
|
||||||
|
context.go('/?q=${Uri.encodeQueryComponent(newQuery)}');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _appendToken(String token, String currentQuery) {
|
||||||
|
final trimmed = currentQuery.trim();
|
||||||
|
final newQuery = trimmed.isEmpty ? token : '$trimmed $token';
|
||||||
|
ref.read(searchControllerProvider.notifier).updateQuery(newQuery);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
final state = ref.watch(searchControllerProvider);
|
final state = ref.watch(searchControllerProvider);
|
||||||
|
final settings =
|
||||||
|
ref.watch(appSettingsProvider).value ?? AppSettings.defaults;
|
||||||
|
final strings = AppStrings(settings.languageCode);
|
||||||
|
final isRu = strings.ru;
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
return AdaptiveScaffold(
|
return AdaptiveScaffold(
|
||||||
title: 'Search',
|
title: strings.search,
|
||||||
actions: [
|
actions: [
|
||||||
IconButton(
|
IconButton(
|
||||||
tooltip: 'Clear history',
|
tooltip: isRu ? 'Очистить историю' : 'Clear history',
|
||||||
onPressed: () =>
|
onPressed: () async {
|
||||||
ref.read(searchControllerProvider.notifier).clearHistory(),
|
final ok = await showConfirmDialog(
|
||||||
|
context,
|
||||||
|
title: isRu ? 'Очистить историю поиска?' : 'Clear search history?',
|
||||||
|
message: isRu
|
||||||
|
? 'Все недавние поисковые запросы будут удалены.'
|
||||||
|
: 'All recent search queries will be removed.',
|
||||||
|
);
|
||||||
|
if (ok) {
|
||||||
|
await ref
|
||||||
|
.read(searchControllerProvider.notifier)
|
||||||
|
.clearHistory();
|
||||||
|
}
|
||||||
|
},
|
||||||
icon: const Icon(Icons.delete_sweep_rounded),
|
icon: const Icon(Icons.delete_sweep_rounded),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
body: state.when(
|
body: state.when(
|
||||||
loading: () => const Center(child: CircularProgressIndicator()),
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
error: (error, _) => ErrorView(message: error.toString()),
|
error: (error, _) => ErrorView(message: error.toString()),
|
||||||
data: (data) => ListView(
|
data: (data) => Align(
|
||||||
|
alignment: Alignment.topCenter,
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 860),
|
||||||
|
child: ListView(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||||
|
children: [
|
||||||
|
// 1. Search Bar with Quick Operators
|
||||||
|
Container(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.colorScheme.surfaceContainerLow,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
border: Border.all(
|
||||||
|
color: theme.colorScheme.outlineVariant
|
||||||
|
.withValues(alpha: 0.3),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
TagInputSearchBar(
|
TagInputSearchBar(
|
||||||
initialValue: data.query,
|
initialValue: data.query,
|
||||||
suggestions: data.suggestions,
|
suggestions: data.suggestions,
|
||||||
onSubmitted: (query) =>
|
onSubmitted: (query) => context
|
||||||
context.go('/?q=${Uri.encodeQueryComponent(query)}'),
|
.go('/?q=${Uri.encodeQueryComponent(query)}'),
|
||||||
onChanged: (query) => ref
|
onChanged: (query) => ref
|
||||||
.read(searchControllerProvider.notifier)
|
.read(searchControllerProvider.notifier)
|
||||||
.updateQuery(query),
|
.updateQuery(query),
|
||||||
@@ -42,18 +106,497 @@ class SearchScreen extends ConsumerWidget {
|
|||||||
.read(searchControllerProvider.notifier)
|
.read(searchControllerProvider.notifier)
|
||||||
.updateQuery(query),
|
.updateQuery(query),
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
SingleChildScrollView(
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
_QuickOperatorChip(
|
||||||
|
label: 'and',
|
||||||
|
icon: Icons.alt_route_rounded,
|
||||||
|
color: const Color(0xFFF59E0B),
|
||||||
|
onTap: () => _appendToken('and', data.query),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
_QuickOperatorChip(
|
||||||
|
label: 'type:video',
|
||||||
|
icon: Icons.videocam_rounded,
|
||||||
|
color: const Color(0xFF3B82F6),
|
||||||
|
onTap: () =>
|
||||||
|
_appendToken('type:video', data.query),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
_QuickOperatorChip(
|
||||||
|
label: 'type:gif',
|
||||||
|
icon: Icons.gif_rounded,
|
||||||
|
color: const Color(0xFF8B5CF6),
|
||||||
|
onTap: () => _appendToken('type:gif', data.query),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
_QuickOperatorChip(
|
||||||
|
label: 'rating:safe',
|
||||||
|
icon: Icons.verified_user_rounded,
|
||||||
|
color: const Color(0xFF10B981),
|
||||||
|
onTap: () =>
|
||||||
|
_appendToken('rating:safe', data.query),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
_QuickOperatorChip(
|
||||||
|
label: 'rating:explicit',
|
||||||
|
icon: Icons.explicit_rounded,
|
||||||
|
color: const Color(0xFFEF4444),
|
||||||
|
onTap: () =>
|
||||||
|
_appendToken('rating:explicit', data.query),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
_QuickOperatorChip(
|
||||||
|
label: 'score:>100',
|
||||||
|
icon: Icons.star_rounded,
|
||||||
|
color: const Color(0xFFF97316),
|
||||||
|
onTap: () =>
|
||||||
|
_appendToken('score:>100', data.query),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
const SizedBox(height: 8),
|
|
||||||
Text('Recent', style: Theme.of(context).textTheme.titleLarge),
|
// 2. Recent Searches Card
|
||||||
const SizedBox(height: 8),
|
_SearchSectionCard(
|
||||||
RecentSearches(
|
icon: Icons.history_rounded,
|
||||||
|
accentColor: const Color(0xFF6366F1),
|
||||||
|
title: isRu ? 'Недавние запросы' : 'Recent searches',
|
||||||
|
badgeCount: data.recent.length,
|
||||||
|
trailing: data.recent.isEmpty
|
||||||
|
? null
|
||||||
|
: TextButton(
|
||||||
|
onPressed: () => ref
|
||||||
|
.read(searchControllerProvider.notifier)
|
||||||
|
.clearHistory(),
|
||||||
|
child: Text(
|
||||||
|
isRu ? 'Очистить' : 'Clear',
|
||||||
|
style: TextStyle(
|
||||||
|
color: theme.colorScheme.primary,
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: RecentSearches(
|
||||||
items: data.recent,
|
items: data.recent,
|
||||||
onTap: (query) =>
|
onTap: (query) =>
|
||||||
context.go('/?q=${Uri.encodeQueryComponent(query)}'),
|
context.go('/?q=${Uri.encodeQueryComponent(query)}'),
|
||||||
|
onDelete: (id) => ref
|
||||||
|
.read(searchControllerProvider.notifier)
|
||||||
|
.deleteHistory(id),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// 3. Explore Popular Categories & Tags
|
||||||
|
_SearchSectionCard(
|
||||||
|
icon: Icons.explore_rounded,
|
||||||
|
accentColor: const Color(0xFF10B981),
|
||||||
|
title: isRu ? 'Популярные категории' : 'Explore categories',
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
_CategoryTagRow(
|
||||||
|
categoryName: isRu ? 'Тематика' : 'Theme & Ambience',
|
||||||
|
color: const Color(0xFF0EA5E9),
|
||||||
|
tags: const [
|
||||||
|
'scenery',
|
||||||
|
'cyberpunk',
|
||||||
|
'monochrome',
|
||||||
|
'night',
|
||||||
|
'fantasy',
|
||||||
|
'original',
|
||||||
|
],
|
||||||
|
onTap: (tag) => _onTagTap(tag, data.query),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
_CategoryTagRow(
|
||||||
|
categoryName:
|
||||||
|
isRu ? 'Персонажи и детали' : 'Characters & Attire',
|
||||||
|
color: const Color(0xFFEC4899),
|
||||||
|
tags: const [
|
||||||
|
'1girl',
|
||||||
|
'solo',
|
||||||
|
'cat_ears',
|
||||||
|
'short_hair',
|
||||||
|
'long_hair',
|
||||||
|
'maid',
|
||||||
|
'uniform',
|
||||||
|
],
|
||||||
|
onTap: (tag) => _onTagTap(tag, data.query),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
_CategoryTagRow(
|
||||||
|
categoryName: isRu ? 'Провайдеры' : 'Providers',
|
||||||
|
color: const Color(0xFF8B5CF6),
|
||||||
|
tags: const [
|
||||||
|
'provider:gelbooru',
|
||||||
|
'provider:danbooru',
|
||||||
|
'provider:e621',
|
||||||
|
'provider:rule34',
|
||||||
|
'provider:pawchive',
|
||||||
|
],
|
||||||
|
onTap: (tag) => _onTagTap(tag, data.query),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// 4. Search Tips / Cheat Sheet Card
|
||||||
|
Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.colorScheme.surfaceContainerLow,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
border: Border.all(
|
||||||
|
color: theme.colorScheme.outlineVariant
|
||||||
|
.withValues(alpha: 0.3),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: ExpansionTile(
|
||||||
|
shape: const Border(),
|
||||||
|
collapsedShape: const Border(),
|
||||||
|
leading: Container(
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFFF59E0B).withValues(alpha: 0.15),
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
child: const Icon(
|
||||||
|
Icons.lightbulb_outline_rounded,
|
||||||
|
size: 18,
|
||||||
|
color: Color(0xFFF59E0B),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
title: Text(
|
||||||
|
isRu ? 'Шпаргалка по поиску' : 'Search tips & operators',
|
||||||
|
style: theme.textTheme.titleSmall?.copyWith(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
initiallyExpanded: _showTips,
|
||||||
|
onExpansionChanged: (exp) => setState(() => _showTips = exp),
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
_TipRow(
|
||||||
|
code: 'tag_a and tag_b',
|
||||||
|
description: isRu
|
||||||
|
? 'Независимый опрос обоих тегов с чередованием постов в ленте.'
|
||||||
|
: 'Interleaves results from both queries independently.',
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
_TipRow(
|
||||||
|
code: 'type:video / type:gif',
|
||||||
|
description: isRu
|
||||||
|
? 'Показывает только видео или анимированные GIF.'
|
||||||
|
: 'Filters results to only videos or animated GIFs.',
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
_TipRow(
|
||||||
|
code: 'rating:safe / rating:explicit',
|
||||||
|
description: isRu
|
||||||
|
? 'Фильтр по возрастному рейтингу медиа.'
|
||||||
|
: 'Filter by age rating classification.',
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
_TipRow(
|
||||||
|
code: 'score:>50 / score:>100',
|
||||||
|
description: isRu
|
||||||
|
? 'Посты с оценкой пользователей выше указанной.'
|
||||||
|
: 'Posts with community score greater than threshold.',
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
_TipRow(
|
||||||
|
code: 'provider:gelbooru',
|
||||||
|
description: isRu
|
||||||
|
? 'Поиск исключительно в указанном источнике.'
|
||||||
|
: 'Search specifically on the given booru provider.',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SearchSectionCard extends StatelessWidget {
|
||||||
|
const _SearchSectionCard({
|
||||||
|
required this.icon,
|
||||||
|
required this.accentColor,
|
||||||
|
required this.title,
|
||||||
|
required this.child,
|
||||||
|
this.badgeCount,
|
||||||
|
this.trailing,
|
||||||
|
});
|
||||||
|
|
||||||
|
final IconData icon;
|
||||||
|
final Color accentColor;
|
||||||
|
final String title;
|
||||||
|
final Widget child;
|
||||||
|
final int? badgeCount;
|
||||||
|
final Widget? trailing;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
return Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.colorScheme.surfaceContainerLow,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
border: Border.all(
|
||||||
|
color: theme.colorScheme.outlineVariant.withValues(alpha: 0.3),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: accentColor.withValues(alpha: 0.14),
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
child: Icon(icon, size: 18, color: accentColor),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Flexible(
|
||||||
|
child: Text(
|
||||||
|
title,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: theme.textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (badgeCount != null && badgeCount! > 0) ...[
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 8, vertical: 2),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: accentColor.withValues(alpha: 0.15),
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'$badgeCount',
|
||||||
|
style: theme.textTheme.labelSmall?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: accentColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (trailing != null) trailing!,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
child,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _QuickOperatorChip extends StatelessWidget {
|
||||||
|
const _QuickOperatorChip({
|
||||||
|
required this.label,
|
||||||
|
required this.icon,
|
||||||
|
required this.color,
|
||||||
|
required this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String label;
|
||||||
|
final IconData icon;
|
||||||
|
final Color color;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
return Material(
|
||||||
|
color: color.withValues(alpha: 0.12),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
child: InkWell(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
onTap: onTap,
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(
|
||||||
|
color: color.withValues(alpha: 0.35),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(icon, size: 14, color: color),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: theme.textTheme.labelMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: color,
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CategoryTagRow extends StatelessWidget {
|
||||||
|
const _CategoryTagRow({
|
||||||
|
required this.categoryName,
|
||||||
|
required this.color,
|
||||||
|
required this.tags,
|
||||||
|
required this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String categoryName;
|
||||||
|
final Color color;
|
||||||
|
final List<String> tags;
|
||||||
|
final ValueChanged<String> onTap;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
categoryName,
|
||||||
|
style: theme.textTheme.labelLarge?.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: [
|
||||||
|
for (final tag in tags)
|
||||||
|
Material(
|
||||||
|
color: theme.colorScheme.surfaceContainer,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
child: InkWell(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
onTap: () => onTap(tag),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 10, vertical: 5),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(
|
||||||
|
color: theme.colorScheme.outlineVariant
|
||||||
|
.withValues(alpha: 0.3),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 6,
|
||||||
|
height: 6,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: color,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Text(
|
||||||
|
tag,
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TipRow extends StatelessWidget {
|
||||||
|
const _TipRow({required this.code, required this.description});
|
||||||
|
|
||||||
|
final String code;
|
||||||
|
final String description;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
return Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.colorScheme.surfaceContainerHighest,
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
code,
|
||||||
|
style: TextStyle(
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: theme.colorScheme.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
description,
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,10 +3,16 @@ import 'package:flutter/material.dart';
|
|||||||
import '../../../../backend/backend.dart';
|
import '../../../../backend/backend.dart';
|
||||||
|
|
||||||
class RecentSearches extends StatefulWidget {
|
class RecentSearches extends StatefulWidget {
|
||||||
const RecentSearches({required this.items, required this.onTap, super.key});
|
const RecentSearches({
|
||||||
|
required this.items,
|
||||||
|
required this.onTap,
|
||||||
|
this.onDelete,
|
||||||
|
super.key,
|
||||||
|
});
|
||||||
|
|
||||||
final List<SearchHistory> items;
|
final List<SearchHistory> items;
|
||||||
final ValueChanged<String> onTap;
|
final ValueChanged<String> onTap;
|
||||||
|
final ValueChanged<String>? onDelete;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<RecentSearches> createState() => _RecentSearchesState();
|
State<RecentSearches> createState() => _RecentSearchesState();
|
||||||
@@ -17,31 +23,136 @@ class _RecentSearchesState extends State<RecentSearches> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
if (widget.items.isEmpty) return const Text('No recent searches');
|
final theme = Theme.of(context);
|
||||||
|
if (widget.items.isEmpty) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 24, horizontal: 16),
|
||||||
|
alignment: Alignment.center,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.history_toggle_off_rounded,
|
||||||
|
size: 40,
|
||||||
|
color: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.5),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
Text(
|
||||||
|
'История поиска пуста',
|
||||||
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
final visibleItems =
|
final visibleItems =
|
||||||
_expanded ? widget.items : widget.items.take(30).toList();
|
_expanded ? widget.items : widget.items.take(20).toList();
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
children: [
|
children: [
|
||||||
for (final item in visibleItems)
|
for (final item in visibleItems)
|
||||||
ListTile(
|
Material(
|
||||||
leading: const Icon(Icons.history_rounded),
|
color: theme.colorScheme.surfaceContainer,
|
||||||
title: Text(item.query),
|
borderRadius: BorderRadius.circular(16),
|
||||||
subtitle:
|
child: InkWell(
|
||||||
Text('${item.tags.join(' ')} - ${item.resultCount} results'),
|
borderRadius: BorderRadius.circular(16),
|
||||||
onTap: () => widget.onTap(item.query),
|
onTap: () => widget.onTap(item.query),
|
||||||
|
child: Container(
|
||||||
|
padding:
|
||||||
|
const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
border: Border.all(
|
||||||
|
color: theme.colorScheme.outlineVariant
|
||||||
|
.withValues(alpha: 0.35),
|
||||||
),
|
),
|
||||||
if (widget.items.length > 30)
|
),
|
||||||
TextButton.icon(
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.history_rounded,
|
||||||
|
size: 14,
|
||||||
|
color: theme.colorScheme.primary,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 140),
|
||||||
|
child: Text(
|
||||||
|
item.query,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (item.resultCount > 0) ...[
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 6, vertical: 2),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.colorScheme.secondaryContainer,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'${item.resultCount}',
|
||||||
|
style: theme.textTheme.labelSmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSecondaryContainer,
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
if (widget.onDelete != null) ...[
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () => widget.onDelete!(item.id),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(2),
|
||||||
|
child: Icon(
|
||||||
|
Icons.close_rounded,
|
||||||
|
size: 14,
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (widget.items.length > 20) ...[
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
Center(
|
||||||
|
child: TextButton.icon(
|
||||||
onPressed: () => setState(() => _expanded = !_expanded),
|
onPressed: () => setState(() => _expanded = !_expanded),
|
||||||
icon: Icon(
|
icon: Icon(
|
||||||
_expanded ? Icons.unfold_less_rounded : Icons.unfold_more_rounded,
|
_expanded
|
||||||
|
? Icons.unfold_less_rounded
|
||||||
|
: Icons.unfold_more_rounded,
|
||||||
|
size: 16,
|
||||||
),
|
),
|
||||||
label: Text(
|
label: Text(
|
||||||
_expanded
|
_expanded
|
||||||
? 'Show latest 30'
|
? 'Свернуть'
|
||||||
: 'Show all ${widget.items.length} searches',
|
: 'Показать все (${widget.items.length})',
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -16,6 +16,14 @@ class ViewedController extends AsyncNotifier<List<ViewedTimelineGroup>> {
|
|||||||
state = AsyncData(await _load());
|
state = AsyncData(await _load());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> deleteItem(String providerId, String postId) async {
|
||||||
|
await ref
|
||||||
|
.read(viewedHistoryServiceProvider)
|
||||||
|
.deleteItem(providerId, postId);
|
||||||
|
ref.invalidate(viewedKeysProvider);
|
||||||
|
state = AsyncData(await _load());
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> clear() async {
|
Future<void> clear() async {
|
||||||
await ref.read(viewedHistoryServiceProvider).clearHistory();
|
await ref.read(viewedHistoryServiceProvider).clearHistory();
|
||||||
ref.invalidate(viewedKeysProvider);
|
ref.invalidate(viewedKeysProvider);
|
||||||
|
|||||||
@@ -1,50 +1,208 @@
|
|||||||
|
import 'package:cached_network_image/cached_network_image.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:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
import '../../../app/app.dart';
|
import '../../../app/app.dart';
|
||||||
|
import '../../../app/responsive.dart';
|
||||||
import '../../../backend/backend.dart';
|
import '../../../backend/backend.dart';
|
||||||
import '../../../shared/widgets/adaptive_scaffold.dart';
|
import '../../../shared/widgets/adaptive_scaffold.dart';
|
||||||
import '../../../shared/widgets/empty_view.dart';
|
import '../../../shared/widgets/confirm_dialog.dart';
|
||||||
import '../../../shared/widgets/error_view.dart';
|
import '../../../shared/widgets/error_view.dart';
|
||||||
|
import '../../../shared/widgets/post_masonry_grid.dart';
|
||||||
import '../../favorites/presentation/favorites_controller.dart';
|
import '../../favorites/presentation/favorites_controller.dart';
|
||||||
import 'viewed_controller.dart';
|
import 'viewed_controller.dart';
|
||||||
|
|
||||||
class ViewedScreen extends ConsumerWidget {
|
class ViewedScreen extends ConsumerStatefulWidget {
|
||||||
const ViewedScreen({super.key});
|
const ViewedScreen({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
ConsumerState<ViewedScreen> createState() => _ViewedScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ViewedScreenState extends ConsumerState<ViewedScreen> {
|
||||||
|
bool _isGridView = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
final state = ref.watch(viewedControllerProvider);
|
final state = ref.watch(viewedControllerProvider);
|
||||||
final settings =
|
final settings =
|
||||||
ref.watch(appSettingsProvider).value ?? AppSettings.defaults;
|
ref.watch(appSettingsProvider).value ?? AppSettings.defaults;
|
||||||
|
final isRu = settings.languageCode == 'ru';
|
||||||
final favoriteKeys = ref.watch(favoriteKeysProvider).value ?? <String>{};
|
final favoriteKeys = ref.watch(favoriteKeysProvider).value ?? <String>{};
|
||||||
|
|
||||||
return AdaptiveScaffold(
|
return AdaptiveScaffold(
|
||||||
title: settings.languageCode == 'ru' ? 'История' : 'Viewed',
|
title: isRu ? 'История' : 'History',
|
||||||
actions: [
|
actions: [
|
||||||
IconButton(
|
IconButton(
|
||||||
tooltip: 'Clear viewed history',
|
tooltip: _isGridView
|
||||||
onPressed: () => ref.read(viewedControllerProvider.notifier).clear(),
|
? (isRu ? 'Список' : 'List view')
|
||||||
icon: const Icon(Icons.history_toggle_off_rounded),
|
: (isRu ? 'Сетка' : 'Grid view'),
|
||||||
|
icon: Icon(_isGridView
|
||||||
|
? Icons.view_agenda_rounded
|
||||||
|
: Icons.grid_view_rounded),
|
||||||
|
onPressed: () => setState(() => _isGridView = !_isGridView),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
tooltip: isRu ? 'Очистить историю' : 'Clear history',
|
||||||
|
onPressed: () async {
|
||||||
|
final confirm = await showConfirmDialog(
|
||||||
|
context,
|
||||||
|
title: isRu ? 'Очистить историю' : 'Clear history',
|
||||||
|
message: isRu
|
||||||
|
? 'Вы действительно хотите очистить всю историю просмотров?'
|
||||||
|
: 'Are you sure you want to clear your entire viewing history?',
|
||||||
|
);
|
||||||
|
if (confirm) {
|
||||||
|
await ref.read(viewedControllerProvider.notifier).clear();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.delete_sweep_rounded),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
body: state.when(
|
body: state.when(
|
||||||
loading: () => const Center(child: CircularProgressIndicator()),
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
error: (error, _) => ErrorView(message: error.toString()),
|
error: (error, _) => ErrorView(message: error.toString()),
|
||||||
data: (groups) => groups.isEmpty
|
data: (groups) {
|
||||||
? const EmptyView(title: 'No viewed posts yet')
|
if (groups.isEmpty) {
|
||||||
: RefreshIndicator(
|
return Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Theme.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.primaryContainer
|
||||||
|
.withValues(alpha: 0.3),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
Icons.history_rounded,
|
||||||
|
size: 56,
|
||||||
|
color: Theme.of(context).colorScheme.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
isRu ? 'История просмотров пуста' : 'No viewed posts yet',
|
||||||
|
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
isRu
|
||||||
|
? 'Посты, которые вы открываете в ленте, будут сохраняться здесь'
|
||||||
|
: 'Posts you open from the feed will appear here',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
color: Theme.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
FilledButton.icon(
|
||||||
|
onPressed: () => context.go('/feed'),
|
||||||
|
icon: const Icon(Icons.explore_rounded),
|
||||||
|
label: Text(isRu ? 'Перейти в ленту' : 'Explore Feed'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final allPosts = [
|
||||||
|
for (final g in groups)
|
||||||
|
for (final i in g.items) i.post
|
||||||
|
];
|
||||||
|
|
||||||
|
if (_isGridView) {
|
||||||
|
return RefreshIndicator(
|
||||||
|
onRefresh: () =>
|
||||||
|
ref.read(viewedControllerProvider.notifier).refresh(),
|
||||||
|
child: PostMasonryGrid(
|
||||||
|
posts: allPosts,
|
||||||
|
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,
|
||||||
|
onOpen: (post) => context.push(
|
||||||
|
'/post/${post.providerId}/${post.id}',
|
||||||
|
extra: PostNavigationContext(
|
||||||
|
currentPost: post,
|
||||||
|
posts: allPosts,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onFavorite: (post) => _toggleFavorite(
|
||||||
|
ref,
|
||||||
|
post,
|
||||||
|
settings,
|
||||||
|
favoriteKeys,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return RefreshIndicator(
|
||||||
onRefresh: () =>
|
onRefresh: () =>
|
||||||
ref.read(viewedControllerProvider.notifier).refresh(),
|
ref.read(viewedControllerProvider.notifier).refresh(),
|
||||||
child: ListView(
|
child: ListView(
|
||||||
padding: const EdgeInsets.fromLTRB(12, 8, 12, 24),
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 24),
|
||||||
children: [
|
children: [
|
||||||
for (final group in groups) ...[
|
for (final group in groups) ...[
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(4, 16, 4, 8),
|
padding: const EdgeInsets.fromLTRB(4, 16, 4, 10),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.calendar_today_rounded,
|
||||||
|
size: 16,
|
||||||
|
color: Theme.of(context).colorScheme.primary,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
_localizedGroupLabel(group.label, isRu),
|
||||||
|
style:
|
||||||
|
Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 8,
|
||||||
|
vertical: 2,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Theme.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.surfaceContainerHighest,
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
group.label,
|
'${group.items.length}',
|
||||||
style: Theme.of(context).textTheme.titleMedium,
|
style: Theme.of(context)
|
||||||
|
.textTheme
|
||||||
|
.labelSmall
|
||||||
|
?.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
for (final item in group.items)
|
for (final item in group.items)
|
||||||
@@ -52,11 +210,8 @@ class ViewedScreen extends ConsumerWidget {
|
|||||||
post: item.post,
|
post: item.post,
|
||||||
viewedAt: item.viewedAt,
|
viewedAt: item.viewedAt,
|
||||||
isFavorite: favoriteKeys.contains(item.post.cacheKey),
|
isFavorite: favoriteKeys.contains(item.post.cacheKey),
|
||||||
|
isRu: isRu,
|
||||||
onOpen: () {
|
onOpen: () {
|
||||||
final allPosts = [
|
|
||||||
for (final g in groups)
|
|
||||||
for (final i in g.items) i.post
|
|
||||||
];
|
|
||||||
context.push(
|
context.push(
|
||||||
'/post/${item.post.providerId}/${item.post.id}',
|
'/post/${item.post.providerId}/${item.post.id}',
|
||||||
extra: PostNavigationContext(
|
extra: PostNavigationContext(
|
||||||
@@ -71,13 +226,24 @@ class ViewedScreen extends ConsumerWidget {
|
|||||||
settings,
|
settings,
|
||||||
favoriteKeys,
|
favoriteKeys,
|
||||||
),
|
),
|
||||||
|
onDelete: () => ref
|
||||||
|
.read(viewedControllerProvider.notifier)
|
||||||
|
.deleteItem(item.post.providerId, item.post.id),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _localizedGroupLabel(String label, bool isRu) {
|
||||||
|
if (!isRu) return label;
|
||||||
|
if (label == 'Today') return 'Сегодня';
|
||||||
|
if (label == 'Yesterday') return 'Вчера';
|
||||||
|
return label;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _toggleFavorite(
|
Future<void> _toggleFavorite(
|
||||||
@@ -98,6 +264,7 @@ class ViewedScreen extends ConsumerWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
ref.invalidate(favoriteKeysProvider);
|
ref.invalidate(favoriteKeysProvider);
|
||||||
|
ref.invalidate(favoritesControllerProvider);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,43 +273,211 @@ class _ViewedPostTile extends StatelessWidget {
|
|||||||
required this.post,
|
required this.post,
|
||||||
required this.viewedAt,
|
required this.viewedAt,
|
||||||
required this.isFavorite,
|
required this.isFavorite,
|
||||||
|
required this.isRu,
|
||||||
required this.onOpen,
|
required this.onOpen,
|
||||||
required this.onFavorite,
|
required this.onFavorite,
|
||||||
|
required this.onDelete,
|
||||||
});
|
});
|
||||||
|
|
||||||
final Post post;
|
final Post post;
|
||||||
final DateTime viewedAt;
|
final DateTime viewedAt;
|
||||||
final bool isFavorite;
|
final bool isFavorite;
|
||||||
|
final bool isRu;
|
||||||
final VoidCallback onOpen;
|
final VoidCallback onOpen;
|
||||||
final VoidCallback onFavorite;
|
final VoidCallback onFavorite;
|
||||||
|
final VoidCallback onDelete;
|
||||||
|
|
||||||
|
bool get _isVideo {
|
||||||
|
final type = post.fileType.toLowerCase();
|
||||||
|
final url = post.fileUrl.toLowerCase();
|
||||||
|
return type == 'mp4' ||
|
||||||
|
type == 'webm' ||
|
||||||
|
url.endsWith('.mp4') ||
|
||||||
|
url.endsWith('.webm');
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final scheme = Theme.of(context).colorScheme;
|
||||||
final image = MediaUrlSelector.preview(post).firstOrNull;
|
final image = MediaUrlSelector.preview(post).firstOrNull;
|
||||||
|
final timeStr =
|
||||||
|
'${viewedAt.hour.toString().padLeft(2, '0')}:${viewedAt.minute.toString().padLeft(2, '0')}';
|
||||||
|
|
||||||
return Card(
|
return Card(
|
||||||
|
elevation: 0,
|
||||||
|
color: scheme.surfaceContainerLow,
|
||||||
margin: const EdgeInsets.only(bottom: 8),
|
margin: const EdgeInsets.only(bottom: 8),
|
||||||
child: ListTile(
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
side: BorderSide(
|
||||||
|
color: scheme.outlineVariant.withValues(alpha: 0.3),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
|
child: InkWell(
|
||||||
onTap: onOpen,
|
onTap: onOpen,
|
||||||
leading: ClipRRect(
|
child: Padding(
|
||||||
borderRadius: BorderRadius.circular(6),
|
padding: const EdgeInsets.all(10),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
// Squircle Thumbnail
|
||||||
|
ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
width: 56,
|
width: 68,
|
||||||
height: 56,
|
height: 68,
|
||||||
child: image == null
|
child: Stack(
|
||||||
? const Icon(Icons.image_not_supported_rounded)
|
fit: StackFit.expand,
|
||||||
: Image.network(image, fit: BoxFit.cover),
|
children: [
|
||||||
|
image == null
|
||||||
|
? Container(
|
||||||
|
color: scheme.surfaceContainerHighest,
|
||||||
|
child: const Icon(Icons.image_not_supported_rounded),
|
||||||
|
)
|
||||||
|
: CachedNetworkImage(
|
||||||
|
imageUrl: image,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
placeholder: (_, __) => Container(
|
||||||
|
color: scheme.surfaceContainerHighest,
|
||||||
|
),
|
||||||
|
errorWidget: (_, __, ___) => Container(
|
||||||
|
color: scheme.surfaceContainerHighest,
|
||||||
|
child: const Icon(
|
||||||
|
Icons.broken_image_rounded,
|
||||||
|
size: 20,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
title: Text(post.tags.take(4).join(' '), maxLines: 1),
|
|
||||||
subtitle: Text(
|
|
||||||
'${post.providerName} • ${viewedAt.hour.toString().padLeft(2, '0')}:${viewedAt.minute.toString().padLeft(2, '0')}',
|
|
||||||
),
|
),
|
||||||
trailing: IconButton(
|
if (_isVideo)
|
||||||
tooltip: isFavorite ? 'Remove favorite' : 'Favorite',
|
Positioned(
|
||||||
|
right: 4,
|
||||||
|
bottom: 4,
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.all(3),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.black.withValues(alpha: 0.65),
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
),
|
||||||
|
child: const Icon(
|
||||||
|
Icons.play_arrow_rounded,
|
||||||
|
size: 14,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
// Post details
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Flexible(
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 6,
|
||||||
|
vertical: 2,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color:
|
||||||
|
scheme.primaryContainer.withValues(alpha: 0.5),
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
post.providerName,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: Theme.of(context)
|
||||||
|
.textTheme
|
||||||
|
.labelSmall
|
||||||
|
?.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: scheme.onPrimaryContainer,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Text(
|
||||||
|
timeStr,
|
||||||
|
style: Theme.of(context)
|
||||||
|
.textTheme
|
||||||
|
.labelSmall
|
||||||
|
?.copyWith(
|
||||||
|
color: scheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
post.tags.isEmpty ? '#${post.id}' : post.tags.take(4).join(' '),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (post.score != 0) ...[
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.thumb_up_rounded,
|
||||||
|
size: 12,
|
||||||
|
color: scheme.outline,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(
|
||||||
|
'${post.score}',
|
||||||
|
style: Theme.of(context)
|
||||||
|
.textTheme
|
||||||
|
.labelSmall
|
||||||
|
?.copyWith(
|
||||||
|
color: scheme.outline,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Action buttons
|
||||||
|
IconButton(
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
|
||||||
|
tooltip: isFavorite
|
||||||
|
? (isRu ? 'В избранном' : 'In favorites')
|
||||||
|
: (isRu ? 'В избранное' : 'Add to favorites'),
|
||||||
onPressed: onFavorite,
|
onPressed: onFavorite,
|
||||||
icon: Icon(isFavorite
|
icon: Icon(
|
||||||
|
isFavorite
|
||||||
? Icons.favorite_rounded
|
? Icons.favorite_rounded
|
||||||
: Icons.favorite_border_rounded),
|
: Icons.favorite_border_rounded,
|
||||||
|
color: isFavorite ? Colors.redAccent : scheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
|
||||||
|
tooltip: isRu ? 'Удалить из истории' : 'Delete from history',
|
||||||
|
onPressed: onDelete,
|
||||||
|
icon: Icon(
|
||||||
|
Icons.close_rounded,
|
||||||
|
size: 20,
|
||||||
|
color: scheme.onSurfaceVariant.withValues(alpha: 0.6),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
@@ -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,8 +177,23 @@ class _PostCardState extends ConsumerState<PostCard> {
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
child: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
border: Border.all(
|
||||||
|
color: Colors.white.withValues(alpha: 0.09),
|
||||||
|
width: 0.8,
|
||||||
|
),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withValues(alpha: 0.18),
|
||||||
|
blurRadius: 8,
|
||||||
|
offset: const Offset(0, 2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
child: ClipRRect(
|
child: ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(13.2),
|
||||||
child: Stack(
|
child: Stack(
|
||||||
children: [
|
children: [
|
||||||
AspectRatio(
|
AspectRatio(
|
||||||
@@ -186,33 +239,65 @@ class _PostCardState extends ConsumerState<PostCard> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
// Bottom cinematic gradient overlay for badges and favorite
|
||||||
|
Positioned(
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
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,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
if (widget.showBadges) ...[
|
if (widget.showBadges) ...[
|
||||||
Positioned(
|
Positioned(
|
||||||
left: 8,
|
left: 8,
|
||||||
top: 8,
|
top: 8,
|
||||||
child: _ProviderBadge(name: post.providerName),
|
child: _ProviderBadge(name: post.providerName),
|
||||||
),
|
),
|
||||||
|
if (post.fileType.toLowerCase().contains('video') ||
|
||||||
|
post.fileType.toLowerCase().contains('webm') ||
|
||||||
|
post.fileType.toLowerCase().contains('mp4') ||
|
||||||
|
post.fileType.toLowerCase().contains('gif'))
|
||||||
Positioned(
|
Positioned(
|
||||||
right: 8,
|
right: 8,
|
||||||
top: 8,
|
top: 8,
|
||||||
child: RatingBadge(rating: post.rating),
|
child: _MediaBadge(fileType: post.fileType),
|
||||||
),
|
),
|
||||||
Positioned(
|
Positioned(
|
||||||
left: 8,
|
left: 8,
|
||||||
bottom: 8,
|
bottom: 8,
|
||||||
child: _MediaBadge(fileType: post.fileType),
|
child: Column(
|
||||||
),
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
if (widget.isViewed)
|
mainAxisSize: MainAxisSize.min,
|
||||||
const Positioned(
|
children: [
|
||||||
right: 8,
|
if (widget.isViewed || widget.isDownloaded) ...[
|
||||||
bottom: 8,
|
Row(
|
||||||
child: _SeenBadge(),
|
mainAxisSize: MainAxisSize.min,
|
||||||
),
|
children: [
|
||||||
|
if (widget.isViewed) const _SeenBadge(),
|
||||||
|
if (widget.isViewed && widget.isDownloaded)
|
||||||
|
const SizedBox(width: 4),
|
||||||
if (widget.isDownloaded)
|
if (widget.isDownloaded)
|
||||||
Positioned(
|
const _DownloadedBadge(),
|
||||||
right: widget.isViewed ? 72 : 8,
|
],
|
||||||
bottom: 8,
|
),
|
||||||
child: const _DownloadedBadge(),
|
const SizedBox(height: 4),
|
||||||
|
],
|
||||||
|
RatingBadge(rating: post.rating),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
if (widget.selected)
|
if (widget.selected)
|
||||||
@@ -227,7 +312,7 @@ class _PostCardState extends ConsumerState<PostCard> {
|
|||||||
color: Theme.of(context).colorScheme.primary,
|
color: Theme.of(context).colorScheme.primary,
|
||||||
width: 3,
|
width: 3,
|
||||||
),
|
),
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(13),
|
||||||
),
|
),
|
||||||
child: const Align(
|
child: const Align(
|
||||||
alignment: Alignment.topRight,
|
alignment: Alignment.topRight,
|
||||||
@@ -247,6 +332,32 @@ class _PostCardState extends ConsumerState<PostCard> {
|
|||||||
onPressed: widget.onFavorite,
|
onPressed: widget.onFavorite,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
// Double-tap heart animation overlay
|
||||||
|
if (_showHeart)
|
||||||
|
Positioned.fill(
|
||||||
|
child: Center(
|
||||||
|
child: AnimatedBuilder(
|
||||||
|
animation: _heartController,
|
||||||
|
builder: (context, _) => Opacity(
|
||||||
|
opacity: _heartOpacity.value,
|
||||||
|
child: Transform.scale(
|
||||||
|
scale: _heartScale.value,
|
||||||
|
child: const Icon(
|
||||||
|
Icons.favorite_rounded,
|
||||||
|
color: Colors.white,
|
||||||
|
size: 72,
|
||||||
|
shadows: [
|
||||||
|
Shadow(
|
||||||
|
color: Colors.black54,
|
||||||
|
blurRadius: 12,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
Positioned.fill(
|
Positioned.fill(
|
||||||
child: IgnorePointer(
|
child: IgnorePointer(
|
||||||
ignoring: !_hovered || mobile,
|
ignoring: !_hovered || mobile,
|
||||||
@@ -309,6 +420,7 @@ class _PostCardState extends ConsumerState<PostCard> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -571,14 +683,38 @@ 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),
|
||||||
|
width: 34,
|
||||||
|
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,
|
isFavorite ? Icons.favorite_rounded : Icons.favorite_border_rounded,
|
||||||
|
color: isFavorite ? Colors.redAccent : Colors.white,
|
||||||
size: 18,
|
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(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 3),
|
|
||||||
child: Text(
|
child: Text(
|
||||||
name,
|
name,
|
||||||
style: const TextStyle(fontSize: 11, color: Colors.white),
|
style: const TextStyle(
|
||||||
|
fontSize: 10.5,
|
||||||
|
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(
|
||||||
|
color: Colors.white.withValues(alpha: 0.12),
|
||||||
|
width: 0.7,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 3),
|
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Icon(_icon(type), size: 12, color: Colors.white),
|
Icon(_icon(type), size: 13, color: Colors.white),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 3.5),
|
||||||
Text(
|
Text(
|
||||||
type,
|
type,
|
||||||
style: const TextStyle(fontSize: 11, color: Colors.white),
|
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(
|
||||||
|
color: Colors.white.withValues(alpha: 0.12),
|
||||||
|
width: 0.7,
|
||||||
),
|
),
|
||||||
child: const Padding(
|
),
|
||||||
padding: EdgeInsets.symmetric(horizontal: 7, vertical: 3),
|
child: const Row(
|
||||||
child: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Icon(Icons.visibility_rounded, size: 12, color: Colors.white),
|
Icon(Icons.visibility_rounded, size: 12, color: Colors.white70),
|
||||||
SizedBox(width: 4),
|
SizedBox(width: 3.5),
|
||||||
Text(
|
Text(
|
||||||
'seen',
|
'seen',
|
||||||
style: TextStyle(fontSize: 11, color: Colors.white),
|
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(
|
),
|
||||||
padding: EdgeInsets.symmetric(horizontal: 7, vertical: 3),
|
child: const Icon(
|
||||||
child: Icon(Icons.download_done_rounded, size: 13, color: Colors.white),
|
Icons.download_done_rounded,
|
||||||
|
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,20 +50,15 @@ 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);
|
||||||
return MasonryGridView.count(
|
final pad = EdgeInsets.all(mobile ? 8 : 16);
|
||||||
controller: controller,
|
final spacing = mobile ? 8.0 : 12.0;
|
||||||
padding: EdgeInsets.all(mobile ? 8 : 16),
|
|
||||||
crossAxisCount: columns,
|
Widget buildCard(Post post) {
|
||||||
mainAxisSpacing: mobile ? 8 : 12,
|
|
||||||
crossAxisSpacing: mobile ? 8 : 12,
|
|
||||||
itemCount: posts.length + (loading ? columns : 0),
|
|
||||||
itemBuilder: (context, index) {
|
|
||||||
if (index >= posts.length) return const LoadingSkeleton();
|
|
||||||
final post = posts[index];
|
|
||||||
return PostCard(
|
return PostCard(
|
||||||
post: post,
|
post: post,
|
||||||
blurExplicit: blurExplicit && !nsfwEnabled,
|
blurExplicit: blurExplicit && !nsfwEnabled,
|
||||||
@@ -82,6 +78,61 @@ class PostMasonryGrid extends StatelessWidget {
|
|||||||
onAddToCollection:
|
onAddToCollection:
|
||||||
onAddToCollection == null ? null : () => onAddToCollection!(post),
|
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(
|
||||||
|
controller: controller,
|
||||||
|
padding: pad,
|
||||||
|
crossAxisCount: columns,
|
||||||
|
mainAxisSpacing: spacing,
|
||||||
|
crossAxisSpacing: spacing,
|
||||||
|
itemCount: posts.length + (loading ? columns : 0),
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
if (index >= posts.length) return const LoadingSkeleton();
|
||||||
|
return buildCard(posts[index]);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 3),
|
|
||||||
child: Text(
|
child: Text(
|
||||||
rating.isEmpty ? 'unknown' : rating,
|
rating.isEmpty ? 'unknown' : rating,
|
||||||
style: const TextStyle(fontSize: 11, color: Colors.white),
|
style: const TextStyle(
|
||||||
|
fontSize: 10.5,
|
||||||
|
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.1.0+38
|
||||||
|
|
||||||
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,290 @@
|
|||||||
|
import 'package:flutter/material.dart' hide SearchController;
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:gel_rule_app/app/app.dart';
|
||||||
|
import 'package:gel_rule_app/backend/backend.dart';
|
||||||
|
import 'package:gel_rule_app/features/collections/presentation/collections_controller.dart';
|
||||||
|
import 'package:gel_rule_app/features/collections/presentation/collections_screen.dart';
|
||||||
|
import 'package:gel_rule_app/features/favorites/presentation/favorites_controller.dart';
|
||||||
|
import 'package:gel_rule_app/features/favorites/presentation/favorites_screen.dart';
|
||||||
|
import 'package:gel_rule_app/features/favorites/presentation/favorites_state.dart';
|
||||||
|
import 'package:gel_rule_app/features/search/presentation/search_controller.dart'
|
||||||
|
as search_feature;
|
||||||
|
import 'package:gel_rule_app/features/search/presentation/search_screen.dart';
|
||||||
|
import 'package:gel_rule_app/features/search/presentation/search_state.dart';
|
||||||
|
import 'package:gel_rule_app/features/viewed/presentation/viewed_controller.dart';
|
||||||
|
import 'package:gel_rule_app/features/viewed/presentation/viewed_screen.dart';
|
||||||
|
|
||||||
|
Post _samplePost({
|
||||||
|
required String id,
|
||||||
|
String providerId = 'danbooru',
|
||||||
|
String providerName = 'Danbooru',
|
||||||
|
List<String> tags = const ['tag1', 'artist:test_artist'],
|
||||||
|
String fileType = 'jpg',
|
||||||
|
}) {
|
||||||
|
return Post(
|
||||||
|
id: id,
|
||||||
|
providerId: providerId,
|
||||||
|
providerName: providerName,
|
||||||
|
previewUrl: 'https://example.com/$id.jpg',
|
||||||
|
sampleUrl: 'https://example.com/$id.jpg',
|
||||||
|
fileUrl: 'https://example.com/$id.jpg',
|
||||||
|
tags: tags,
|
||||||
|
rating: 'safe',
|
||||||
|
width: 800,
|
||||||
|
height: 600,
|
||||||
|
createdAt: DateTime.now(),
|
||||||
|
fileType: fileType,
|
||||||
|
score: 42,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
setUp(() {
|
||||||
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('FavoritesScreen renders segments, counters and filter chips',
|
||||||
|
(tester) async {
|
||||||
|
tester.view.physicalSize = const Size(390, 844);
|
||||||
|
tester.view.devicePixelRatio = 1.0;
|
||||||
|
addTearDown(() {
|
||||||
|
tester.view.resetPhysicalSize();
|
||||||
|
tester.view.resetDevicePixelRatio();
|
||||||
|
});
|
||||||
|
|
||||||
|
final post = _samplePost(id: '1');
|
||||||
|
final post2 = _samplePost(
|
||||||
|
id: '2', fileType: 'mp4', tags: ['artist:animator', 'video']);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
ProviderScope(
|
||||||
|
overrides: [
|
||||||
|
favoritesControllerProvider.overrideWith(
|
||||||
|
() => _FakeFavoritesController([post, post2]),
|
||||||
|
),
|
||||||
|
appSettingsProvider.overrideWith(
|
||||||
|
(ref) => AppSettings.defaults.copyWith(languageCode: 'ru'),
|
||||||
|
),
|
||||||
|
downloadedMediaByKeysProvider([post.cacheKey, post2.cacheKey])
|
||||||
|
.overrideWith((ref) => <String, DownloadedMedia>{}),
|
||||||
|
],
|
||||||
|
child: const MaterialApp(
|
||||||
|
home: FavoritesScreen(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(milliseconds: 200));
|
||||||
|
|
||||||
|
// Verify segmented button labels with live counts
|
||||||
|
expect(find.text('Все (2)'), findsOneWidget);
|
||||||
|
expect(find.text('Офлайн (0)'), findsOneWidget);
|
||||||
|
expect(find.text('Авторы (2)'), findsOneWidget);
|
||||||
|
|
||||||
|
// Verify media filter chips
|
||||||
|
expect(find.text('Все'), findsWidgets);
|
||||||
|
expect(find.text('Видео'), findsOneWidget);
|
||||||
|
expect(find.text('Фото'), findsOneWidget);
|
||||||
|
|
||||||
|
// Switch to Artists tab
|
||||||
|
await tester.tap(find.text('Авторы (2)'));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(milliseconds: 200));
|
||||||
|
|
||||||
|
expect(find.text('test_artist'), findsOneWidget);
|
||||||
|
expect(find.text('animator'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('ViewedScreen renders timeline and switches view mode',
|
||||||
|
(tester) async {
|
||||||
|
tester.view.physicalSize = const Size(390, 844);
|
||||||
|
tester.view.devicePixelRatio = 1.0;
|
||||||
|
addTearDown(() {
|
||||||
|
tester.view.resetPhysicalSize();
|
||||||
|
tester.view.resetDevicePixelRatio();
|
||||||
|
});
|
||||||
|
|
||||||
|
final post = _samplePost(id: '10', tags: ['landscape', 'sunset']);
|
||||||
|
final group = ViewedTimelineGroup(
|
||||||
|
label: 'Today',
|
||||||
|
items: [
|
||||||
|
ViewedPostEntry(post: post, viewedAt: DateTime.now()),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
ProviderScope(
|
||||||
|
overrides: [
|
||||||
|
viewedControllerProvider.overrideWith(
|
||||||
|
() => _FakeViewedController([group]),
|
||||||
|
),
|
||||||
|
favoriteKeysProvider.overrideWith(
|
||||||
|
(ref) => <String>{},
|
||||||
|
),
|
||||||
|
appSettingsProvider.overrideWith(
|
||||||
|
(ref) => AppSettings.defaults.copyWith(languageCode: 'ru'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
child: const MaterialApp(
|
||||||
|
home: ViewedScreen(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(milliseconds: 200));
|
||||||
|
|
||||||
|
// Verify timeline group
|
||||||
|
expect(find.text('Сегодня'), findsOneWidget);
|
||||||
|
expect(find.text('landscape sunset'), findsOneWidget);
|
||||||
|
|
||||||
|
// Switch to Grid mode
|
||||||
|
final gridBtn = find.byIcon(Icons.grid_view_rounded);
|
||||||
|
expect(gridBtn, findsOneWidget);
|
||||||
|
await tester.tap(gridBtn);
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(milliseconds: 200));
|
||||||
|
|
||||||
|
// Now icon should switch to list mode
|
||||||
|
expect(find.byIcon(Icons.view_agenda_rounded), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'CollectionsScreen renders collection cards and new collection button',
|
||||||
|
(tester) async {
|
||||||
|
tester.view.physicalSize = const Size(390, 844);
|
||||||
|
tester.view.devicePixelRatio = 1.0;
|
||||||
|
addTearDown(() {
|
||||||
|
tester.view.resetPhysicalSize();
|
||||||
|
tester.view.resetDevicePixelRatio();
|
||||||
|
});
|
||||||
|
|
||||||
|
final collection = Collection(
|
||||||
|
id: 'c1',
|
||||||
|
name: 'Cyberpunk Art',
|
||||||
|
description: 'Futuristic neon cities',
|
||||||
|
createdAt: DateTime.now(),
|
||||||
|
updatedAt: DateTime.now(),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
ProviderScope(
|
||||||
|
overrides: [
|
||||||
|
collectionsControllerProvider.overrideWith(
|
||||||
|
() => _FakeCollectionsController([collection]),
|
||||||
|
),
|
||||||
|
collectionPostsProvider('c1').overrideWith(
|
||||||
|
(ref) => Future.value([_samplePost(id: '1')]),
|
||||||
|
),
|
||||||
|
appSettingsProvider.overrideWith(
|
||||||
|
(ref) => AppSettings.defaults.copyWith(languageCode: 'ru'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
child: const MaterialApp(
|
||||||
|
home: CollectionsScreen(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(milliseconds: 200));
|
||||||
|
|
||||||
|
expect(find.text('Cyberpunk Art'), findsOneWidget);
|
||||||
|
expect(find.text('Futuristic neon cities'), findsOneWidget);
|
||||||
|
expect(find.byIcon(Icons.add_rounded), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'SearchScreen renders hero search bar, quick chips, and categories',
|
||||||
|
(tester) async {
|
||||||
|
tester.view.physicalSize = const Size(390, 844);
|
||||||
|
tester.view.devicePixelRatio = 1.0;
|
||||||
|
addTearDown(() {
|
||||||
|
tester.view.resetPhysicalSize();
|
||||||
|
tester.view.resetDevicePixelRatio();
|
||||||
|
});
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
ProviderScope(
|
||||||
|
overrides: [
|
||||||
|
search_feature.searchControllerProvider.overrideWith(
|
||||||
|
() => _FakeSearchController([
|
||||||
|
SearchHistory(
|
||||||
|
id: 'h1',
|
||||||
|
query: 'genshin_impact',
|
||||||
|
tags: const ['genshin_impact'],
|
||||||
|
searchedAt: DateTime.now(),
|
||||||
|
resultCount: 1500,
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
appSettingsProvider.overrideWith(
|
||||||
|
(ref) => AppSettings.defaults.copyWith(languageCode: 'ru'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
child: const MaterialApp(
|
||||||
|
home: SearchScreen(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(milliseconds: 200));
|
||||||
|
|
||||||
|
// Verify Search Bar and Quick Operator Chips
|
||||||
|
expect(find.text('type:video'), findsOneWidget);
|
||||||
|
expect(find.text('rating:safe'), findsOneWidget);
|
||||||
|
|
||||||
|
// Verify Recent Searches chip
|
||||||
|
expect(find.text('genshin_impact'), findsOneWidget);
|
||||||
|
|
||||||
|
// Verify Popular Categories
|
||||||
|
expect(find.text('Тематика'), findsOneWidget);
|
||||||
|
expect(find.text('Персонажи и детали'), findsOneWidget);
|
||||||
|
expect(find.text('Провайдеры'), findsOneWidget);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FakeFavoritesController extends FavoritesController {
|
||||||
|
_FakeFavoritesController(this._posts);
|
||||||
|
|
||||||
|
final List<Post> _posts;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<FavoritesState> build() async {
|
||||||
|
return FavoritesState(posts: _posts);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FakeViewedController extends ViewedController {
|
||||||
|
_FakeViewedController(this._groups);
|
||||||
|
|
||||||
|
final List<ViewedTimelineGroup> _groups;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<ViewedTimelineGroup>> build() async => _groups;
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FakeCollectionsController extends CollectionsController {
|
||||||
|
_FakeCollectionsController(this._collections);
|
||||||
|
|
||||||
|
final List<Collection> _collections;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<Collection>> build() async => _collections;
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FakeSearchController extends search_feature.SearchController {
|
||||||
|
_FakeSearchController(this._history);
|
||||||
|
|
||||||
|
final List<SearchHistory> _history;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<SearchState> build() async {
|
||||||
|
return SearchState(
|
||||||
|
recent: _history,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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