Compare commits

...
23 Commits
Author SHA1 Message Date
RarDog dff2cd806d feat: improve recent searches and tags visibility, deduplication and UI (v3.0.6)
Build and Release / Build Windows x64 (push) Has been cancelled
2026-09-04 18:06:46 +03:00
RarDog 6f902740c1 feat: clickable social and creator links in announcements and commentary (v3.0.5)
Build and Release / Build Windows x64 (push) Has been cancelled
2026-09-04 17:46:32 +03:00
RarDog bae64cd8c2 feat: widen tag suggestions dropdown and allow 2-line wrap (v3.0.4)
Build and Release / Build Windows x64 (push) Has been cancelled
2026-09-04 17:18:50 +03:00
RarDog 5586fea6b3 feat: recommend GitHub for speed in update sheet, Gitea as neutral (v3.0.3)
Build and Release / Build Windows x64 (push) Has been cancelled
2026-09-04 17:04:46 +03:00
RarDog d0419fdf80 docs: update version badge to 3.0.2 in README 2026-09-04 17:00:50 +03:00
RarDog f60c767a0d feat: add update source picker (Gitea recommended vs GitHub) (v3.0.2)
Build and Release / Build Windows x64 (push) Has been cancelled
2026-09-04 16:56:19 +03:00
RarDog 40ac7cd055 chore: sync repository metadata 2026-09-04 16:45:41 +03:00
RarDog 0d0eac4481 feat(updates): switch auto-updates to GitHub releases and remove Gitea references (v3.0.1)
Build and Release / Build Windows x64 (push) Has been cancelled
2026-09-04 16:37:15 +03:00
RarDog 4c9813b37b docs: add comprehensive wiki documentation and guides 2026-09-04 16:32:21 +03:00
RarDog 105b32bf76 docs: add developers section to README 2026-09-04 16:26:08 +03:00
RarDog 23b73bd290 docs: overhaul README with Lunaris 3.0 and add GitHub Actions release workflow
Build and Release / Build Windows x64 (push) Has been cancelled
2026-09-04 16:11:46 +03:00
RarDog 725980ed3a feat: Lunaris 3.0 — Design 2.0, Pawchive integration & responsive UI fixes 2026-09-04 16:01:59 +03:00
RarDog 96affe60e7 feat(pawchive): export local favorites to Pawchive and two-way sync (v2.3.10) 2026-09-04 15:54:27 +03:00
RarDog 402a5436bc fix(pawchive): fix favorites API endpoints and login 404 error (v2.3.9) 2026-09-04 15:42:03 +03:00
RarDog 80ca59d19f feat: ABI-aware auto-update — pick correct per-ABI APK from Gitea
- Android: expose getSupportedAbis/getPrimaryAbi via rulegel/device channel (Build.SUPPORTED_ABIS)
- AppUpdateInfo: add apkArm64Url, apkArmv7Url, apkX86_64Url fields + apkUrlForAbi() selector
- UpdateService: parse per-ABI APK assets by filename pattern, async assetUrlForCurrentPlatform
- app.dart + settings_screen.dart: await new async URL resolver
- Bump version to 2.3.8+48
2026-09-04 14:55:50 +03:00
RarDog 848fee6ff1 fix: lazy-resolve image dimensions for providers without width/height metadata (Pawchive)
- Convert _PostInfoCard to StatefulWidget
- Lazily decode real pixel dimensions (e.g. 1920×1080) from network when post.width==0 && post.height==0
- Show '… × …' while resolving, actual size once decoded
- Hide resolution badge for video/link posts where dimensions can't be resolved
- In-memory cache prevents redundant re-downloads
- Bump version to 2.3.7+47
2026-09-04 14:42:07 +03:00
RarDog d4e52ff429 feat: Pawchive account support and favorites synchronization (v2.3.6) 2026-09-04 14:17:09 +03:00
RarDog 10b02b2d6f feat: smart auto-rotation & orientation controls in fullscreen video (v2.3.5) 2026-09-04 13:42:07 +03:00
RarDog fe4777c4b3 fix: migrate search suggestions to OverlayPortal, unblock toolbar & chip touches (v2.3.4) 2026-09-04 13:23:17 +03:00
RarDog ce78a1f47f fix: tag suggestions overlay hanging bug, integrate Rule34 autocomplete & API alignment (v2.3.3) 2026-09-04 13:08:10 +03:00
RarDog 576bd53487 feat: align with Gelbooru DAPI spec, optimize tag queries & batch categorization (v2.3.2) 2026-09-04 12:54:41 +03:00
RarDog 87e126ec4d fix: collapsible cloud mirrors, tag panel sanitization, and clean announcement HTML (v2.3.1) 2026-09-04 12:26:15 +03:00
RarDog 639bfc50af feat: artist cloud drives & external video mirrors (v2.3.0) 2026-09-04 12:08:01 +03:00
57 changed files with 6546 additions and 539 deletions
+48
View File
@@ -0,0 +1,48 @@
name: Build and Release
on:
push:
tags:
- 'v*'
workflow_dispatch:
permissions:
contents: write
jobs:
build-windows:
name: Build Windows x64
runs-on: windows-latest
steps:
- name: Checkout Code
uses: actions/checkout@v4
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
flutter-version: '3.27.4'
channel: 'stable'
cache: true
- name: Install Dependencies
run: flutter pub get
- name: Build Windows Release
run: flutter build windows --release
- name: Package Windows Zip
shell: pwsh
run: |
$tag = "${{ github.ref_name }}"
if (-not $tag) { $tag = "v3.0.0" }
$zipName = "Lunaris-$tag-windows-x64.zip"
Compress-Archive -Path "build\windows\x64\runner\Release\*" -DestinationPath $zipName
echo "ZIP_NAME=$zipName" >> $env:GITHUB_ENV
- name: Upload to GitHub Release
uses: softprops/action-gh-release@v2
with:
files: ${{ env.ZIP_NAME }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+107 -84
View File
@@ -1,149 +1,172 @@
# 🌙 Lunaris # 🌙 Lunaris
<p align="center"> <p align="center">
<img src="assets/branding/app_icon.png" width="128" height="128" alt="Lunaris Logo" /> <img src="android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png" width="128" height="128" alt="Lunaris Logo" style="border-radius: 28px; box-shadow: 0 10px 30px rgba(0,0,0,0.35);" />
</p> </p>
<p align="center"> <p align="center">
<strong>Современный кроссплатформенный клиент для Booru и Creator-архивов</strong><br> <strong>Современный кроссплатформенный клиент для Booru и Creator-архивов нового поколения</strong><br>
Плавный Pinterest-стиль, поддержка фото, видео и GIF, умный черный список, встроенный менеджер загрузок и автообновление. Плавный Pinterest-стиль, интеграция Pawchive 2.0, двусторонняя синхронизация, интеллектуальное автообновление и воспроизведение медиа без задержек.
</p> </p>
<p align="center"> <p align="center">
<img src="https://img.shields.io/badge/Version-2.2.0-blueviolet?style=for-the-badge" alt="Version 2.2.0" /> <a href="https://github.com/RarDog/Lunaris/releases"><img src="https://img.shields.io/badge/Version-3.0.6-8A2BE2?style=for-the-badge&logo=semver&logoColor=white" alt="Version 3.0.6" /></a>
<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-00C853?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" /> <a href="https://github.com/RarDog/Lunaris/wiki"><img src="https://img.shields.io/badge/Wiki-Documentation-FF6F00?style=for-the-badge&logo=gitbook&logoColor=white" alt="Wiki" /></a>
<img src="https://img.shields.io/badge/License-MIT-blue?style=for-the-badge" alt="License MIT" />
</p> </p>
--- ---
## ✨ Основные возможности ## 🚀 Что нового в Lunaris 3.0 (Major Release)
- **💾 Офлайн 2.0 и Sync Hub**: 100% честное воспроизведение видео и просмотр фото прямо с диска без единого запроса в сеть, карточка аналитики хранилища (`💾 X.X ГБ на устройстве`), кнопка пакетного скачивания недостающих в 1 тап и удобная сортировка по дате, размеру и авторам. ### 🎨 Дизайн 2.0
- **🖼️ Экран поста 2.0**: Action Dock 2.0 (чистый док из 5 главных действий + модальный лист вторичных функций), современная карточка инфо с автором, провайдером, разрешением, форматом и оценкой, лента соседних постов на мобильных и ПК, стильные блоки тегов и комментарии в виде чат-бабблов. - **Современный визуальный стиль**: Полный переход на squircle-карточки, адаптивную цветовую палитру и улучшенную Material You динамическую тему.
- **🔍 Поиск 2.0**: Hero-панель поиска, быстрые чипы операторов (`and`, `type:video`, `rating:safe`), облако недавних поисков с быстрым удалением крестиком `✕`, карточки популярных категорий и шпаргалка по синтаксису запросов. - **Интеллектуальный Action Dock 2.0**: Компактный док ключевых действий под контентом с быстрым доступом к скачиванию, шерингу, тегам и инфо.
- **❤️ Избранное 2.0**: Живые счетчики в сегментах (Все, Офлайн, Авторы), быстрый фильтр по типу медиа (Все / Видео / Фото), мгновенный поиск среди сохраненных работ и карточки авторов в стиле Spotify с мини-коллажем из 3 превью. - **Плавные анимации и микро-взаимодействия**: Открытие медиа с эффектом Hero, интерактивное листание свайпами и анимированный лайк по двойному тапу.
- **🕒 История 2.0**: Группировка по дням (Сегодня, Вчера, даты), переключение между подробным списком (с 68x68 миниатюрами, бэйджами провайдеров и быстрым удалением из истории) и галерейной Masonry-сеткой, а также диалог подтверждения очистки.
- **📁 Коллекции 2.0**: Мозаичные обложки 2x2 с превью постов, счетчики работ со стеклянным бейджем, современный экран деталей коллекции и обновленный диалог создания.
- **⚙️ Settings 2.0**: Полный редизайн настроек с современными squircle-карточками, горизонтальной быстрой навигацией по разделам, мгновенными Segmented-кнопками выбора темы/языка/качества и стильной карточкой о приложении.
- **⚡ Мгновенные подсказки тегов (0 мс) и постоянный файловый кэш**: Локальный JSON-кэш на телефоне и ПК обеспечивает мгновенный отклик при наборе, а Danbooru, Gelbooru, Moebooru и e621 динамически дополняют базу тегов. ### 🐾 Глубокая интеграция Pawchive 2.0
- **🎨 Цветные бейджи категорий и подсветка совпадений**: Выделение введенных букв жирным цветом акцента и четкая цветовая палитра категорий (художник — красный, персонаж — зеленый, копирайт — фиолетовый, вид — голубой). - **Мульти-аккаунты**: Поддержка авторизации по логину и паролю, а также прямому вводу `Session Cookie`.
- **📚 Расширенная история поиска и гибкие настройки**: До 2000 записей истории и до 20000 закэшированных тегов с ручным управлением и кнопками очистки в Настройках. - **Двусторонняя синхронизация (Two-Way Sync)**: Автоматическая синхронизация избранного между локальной базой Lunaris и профилем Pawchive. Новые авторы с сервера добавляются в приложение, а локальные авторы выгружаются на сервер в один тап.
- **🎯 Общие модификаторы в мультипоиске**: При вводе `cat and dog anthro` модификатор `anthro` автоматически применится к обоим тегам (`cat anthro` + `dog anthro`). - **Моментальная синхронизация**: При нажатии кнопки «В избранное» у любого автора (Fanbox, Patreon, Fantia, Boosty, Gumroad, DLsite, Discord) статус синхронизируется с сервером на лету.
- **🔀 Раздельный мульти-поиск по тегам через `and`**: Оператор `and` опрашивает серверную базу по каждому тегу независимо и плавно чередует посты обоих тегов в общей ленте.
- **🌐 Pawchive в главной ленте**: Глобальная лента недавних постов с Pawchive подключена прямо в общий Feed наравне с Rule34, Gelbooru, e621 и Realbooru. ### 🎬 Умный медиаплеер и определение разрешения
- **🏷️ Интерактивные теги автора**: Быстрая фильтрация по темам с живыми счётчиками постов (`#tag (count)`). - **Ленивое вычисление габаритов (1920×1080 и др.)**: Определение точного разрешения картинок и видео без полной предварительной загрузки тяжелых файлов.
- **📢 Анонсы и новости авторов**: Всплывающее окно анонсов автора с датами и текстом обращений к аудитории. - **Автоповорот в полноэкранном режиме**: Видеоплеер автоматически ориентирует экран (альбомный или портретный режим) в зависимости от аспектного соотношения медиа.
- **⚡ Мгновенный фильтр фото, видео и GIF**: Локальная быстрая фильтрация постов автора без задержек.
- **📱 Плавная лента в стиле Pinterest**: Masonry-сетка с переключением режима (Masonry, 1:1 Сетка, Список) в шапке ленты. ### 📦 Разделение APK и умное автообновление по ABI
- **⚡ Сверхбыстрые видео-превью**: Мгновенная загрузка превью без фризов и лишней нагрузки на процессор и память смартфона. - Сборка облегчённых APK с разделением по архитектурам процессора (`arm64-v8a`, `armeabi-v7a`, `x86_64`).
- **↔️ Листание постов влево и вправо**: Полноэкранный пейджер с плавными свайпами между фото и видео во всех разделах. - Встроенная система обновлений автоматически определяет архитектуру вашего Android-устройства и скачивает только нужный APK, экономя трафик и ускоряя загрузку.
- **❤️ Двойной тап для лайка и свайп вниз для закрытия**: Анимированное сердечко по двойному тапу и закрытие экрана просмотра свайпом вниз.
- **📤 Прямая отправка файла (Share)**: Мгновенный экспорт оригинальных медиафайлов через системное меню «Поделиться».
- **🔖 Сохраненные поисковые пресеты**: Быстрое сохранение комбинаций тегов и фильтров в закладки.
- **📁 Умная структура папок скачивания**: Настраиваемые шаблоны поддиректорий (`{Artist}`, `{Provider}`, `{Service}`, `{ID}`, `{Date}`).
- **🗑️ Менеджер кэша**: Детальная визуализация занятой памяти и выборочная очистка.
- **📦 Бэкап и синхронизация**: Экспорт и импорт полной конфигурации в JSON-файл для переноса на другие устройства.
- **🌑 AMOLED Pure Black тема**: Истинно черный фон (`#000000`) для экономии батареи на OLED дисплеях.
- **🎨 Динамические цвета Material You**: Поддержка системной палитры Android из обоев устройства.
- **⭐ Любимые авторы**: Компактная кнопка-звёздочка сверху справа для быстрого доступа ко всем любимым авторам.
- **🏷️ Мультивыбор платформ**: Выбор нескольких платформ (Patreon, Fanbox, Fantia, Boosty, Discord).
- **📜 Бесконечная подгрузка постов автора**: Умная пагинация при прокрутке вниз загружает всю историю работ.
- **🔍 Умный поиск и автодополнение**: Интерактивные чипсы тегов с подсказками от активных API.
- **🛡️ Продвинутый Blacklist / Whitelist**: Фильтрация по тегам, категориям, рейтингу контента и минимальному score.
- **🎬 Встроенный плеер**: Удобное воспроизведение WebM / MP4, запоминание звука, fullscreen и повтор.
- **💾 Менеджер загрузок и коллекции**: Скачивание оригиналов в высоком качестве и тематические папки-коллекции.
- **🔄 Встроенное автообновление**: Проверка и скачивание свежих релизов напрямую из личного репозитория Gitea.
--- ---
## 🌐 Активные провайдеры ## 📚 Документация и Вики (Руководство пользователя)
| Провайдер | Тип API | Назначение | Подробные иллюстрированные руководства доступны в **[Lunaris Wiki](https://github.com/RarDog/Lunaris/wiki)** и прямо в репозитории:
- 🚀 **[Быстрый старт и установка](docs/wiki/Getting-Started.md)** — выбор правильного APK под процессор, запуск на Linux.
- 🌐 **[Провайдеры контента](docs/wiki/Providers.md)** — особенности Booru (Gelbooru, Rule34, Realbooru, e621) и архивов Pawchive.
- 🔍 **[Умный поиск и теги](docs/wiki/Search-and-Tags.md)** — оператор `and`, общие модификаторы и подсказки за 0 мс.
- 🐾 **[Интеграция с Pawchive](docs/wiki/Pawchive-Sync.md)** — вход по паролю/куки, двусторонняя синхронизация и выгрузка авторов.
- 🎬 **[Медиаплеер и просмотр](docs/wiki/Media-Viewer.md)** — жесты, автоповорот видео, разрешение 1920×1080 и Action Dock 2.0.
- 💾 **[Офлайн-режим и загрузки](docs/wiki/Offline-and-Downloads.md)** — Sync Hub, шаблоны папок и сохранение оригиналов.
- 🛡️ **[Черный список и фильтрация](docs/wiki/Safety-and-Blacklist.md)** — настройка Blacklist, Safe Mode и очистка кэша.
- ❓ **[Часто задаваемые вопросы (FAQ)](docs/wiki/FAQ.md)** — автообновление, звук в видео и решение проблем.
---
## ✨ Ключевые возможности
- **📱 Плавная лента в стиле Pinterest**: Masonry-сетка с переключением режима отображения (Masonry, 1:1 Сетка, Компактный список).
- **💾 Офлайн 2.0 и Sync Hub**: Просмотр фото и видео прямо из локального хранилища без запросов в сеть, аналитика занятого места и пакетная выгрузка.
- **🔍 Поиск 2.0 с операторами**: Быстрые подсказки тегов со скоростью отклика 0 мс, оператор `and` для мульти-поиска, фильтры `type:video`, `rating:safe`.
- **🛡️ Продвинутый Blacklist / Whitelist**: Фильтрация нежелательного контента по тегам, категориям, рейтингу и минимальному score.
- **📁 Умный менеджер загрузок**: Шаблоны поддиректорий (`{Artist}`, `{Provider}`, `{Service}`, `{ID}`, `{Date}`) для аккуратного архива.
- **🌑 AMOLED Pure Black**: Истинно черный фон для максимальной энергоэффективности на OLED/AMOLED дисплеях.
---
## 🌐 Поддерживаемые провайдеры
| Провайдер | Тип API | Поддерживаемый контент |
|---|---|---| |---|---|---|
| **Gelbooru** | XML / JSON API | Основная база аниме-артов | | **Pawchive** | REST API v1 | Patreon, Pixiv Fanbox, Fantia, Boosty, Gumroad, DLsite, Discord |
| **Rule34** | Booru API | Популярный booru-источник | | **Gelbooru** | JSON / XML API | Аниме-арты, теги, авторы, рейтинги |
| **Realbooru** | HTML Scraper | Фото и реалистичный контент | | **Rule34** | Booru API | Огромная база booru-контента, видео и GIF |
| **e621 / e926** | JSON API | Фурри- и антро-арт с богатой системой тегов | | **Realbooru** | Scraper / API | Фотосессии и реалистичный контент |
| **Pawchive** | REST API v1 | Архивы авторов Patreon, Pixiv Fanbox, Fantia и др. | | **e621 / e926** | REST JSON API | Фурри- и антро-арт с богатейшей таксономией тегов |
--- ---
## 📥 Скачать приложение ## 📥 Загрузка (Скачать сборки)
Готовые сборки для всех поддерживаемых платформ доступны на странице релизов: Релизы доступны на странице **[GitHub Releases](https://github.com/RarDog/Lunaris/releases)**.
🔗 **[Gitea Releases (RarDog/Lunaris)](https://gitea.rardogsynapse.online/RarDog/Lunaris/releases)** | Платформа | Архитектура | Формат | Описание |
|---|---|---|---|
- **Android**: `Lunaris-v2.0.10.apk` | **Android** | `arm64-v8a` | `.apk` | Оптимизировано для современных смартфонов (64-bit) |
- **Linux (x64)**: `Lunaris-v2.0.10-linux-x64.tar.gz` | **Android** | `armeabi-v7a` | `.apk` | Для более старых Android-устройств (32-bit) |
- **Windows**: `LunarisSetup-v2.0.10.exe` / `LunarisPortable-v2.0.10.zip` | **Android** | `x86_64` | `.apk` | Для эмуляторов Android и x86-планшетов |
| **Linux** | `x86_64` | `.tar.gz` | Портативный архив для Linux-дистрибутивов |
| **Windows** | `x64` | `.zip` / `.exe` | Портативная и установочная сборка для Windows 10/11 |
--- ---
## 🛠️ Сборка из исходников ## 🛠️ Сборка из исходников
### Требования ### Требования
- Flutter SDK `>= 3.19.0` - **Flutter SDK**: `>= 3.22.0` (Dart `>= 3.4.0`)
- OpenJDK 17 - **Android SDK**: Build-Tools & Platform-Tools (для Android)
- Android SDK (для сборки APK) - **Linux**: `clang`, `cmake`, `ninja-build`, `pkg-config`, `libgtk-3-dev`
- Clang / CMake / GTK-3 (для сборки под Linux) - **Windows**: Visual Studio 2022 с компонентом «Desktop development with C++»
### Команды разработки ### Быстрый старт
```bash ```bash
# Клонирование репозитория # Клонирование репозитория
git clone https://gitea.rardogsynapse.online/RarDog/Lunaris.git git clone https://github.com/RarDog/Lunaris.git
cd Lunaris cd Lunaris
# Установка зависимостей # Установка зависимостей
flutter pub get flutter pub get
# Запуск тестов и анализатора # Проверка анализатора и запуск тестов
flutter analyze flutter analyze
flutter test flutter test
# Запуск на подключенном устройстве # Запуск в режиме разработки
flutter run flutter run
``` ```
### Сборка релизов ### Сборка релизных пакетов
```bash ```bash
# Android APK # Android (раздельные APK по архитектурам)
flutter build apk --release flutter build apk --release --split-per-abi
# Linux x64 Bundle # Linux (Desktop bundle)
flutter build linux --release flutter build linux --release
# Windows Bundle # Windows (Desktop bundle)
flutter build windows --release flutter build windows --release
``` ```
--- ---
## 🏛️ Архитектура ## 🏛️ Архитектура проекта
``` ```text
lib/ lib/
├── app/ # Роутер, тема, локализация (RU/EN), анимации ├── app/ # Роутер (GoRouter), темы, локализация (RU/EN), анимации
├── backend/ ├── backend/
│ ├── di/ # Riverpod providers для сервисов │ ├── di/ # Внедрение зависимостей (Riverpod providers)
│ ├── models/ # Post, ArtistProfile, AppSettings, AppUpdateInfo │ ├── models/ # Post, ArtistProfile, PawchiveAccount, AppSettings
│ ├── providers/ # Реализации API (Gelbooru, Rule34, Pawchive и др.) │ ├── providers/ # Клиенты API (Gelbooru, Rule34, Pawchive, e621)
│ ├── repositories/ # Доступ к локальной БД Isar │ ├── repositories/ # Локальная база данных Isar и кэширование
│ └── services/ # Бизнес-логика (Feed, Search, Favorites, Downloads, Updates) │ └── services/ # Сервисы: PawchiveSync, Feed, Search, Downloads, Updates
├── core/ # Сеть (Dio), база данных (Isar), ошибки и кеши ├── core/ # Сетевой клиент (Dio), база данных, утилиты
├── features/ # Модули экранов (feed, post, artists, favorites, settings) ├── features/ # Модули экранов: feed, post, artists, favorites, settings
└── shared/widgets/ # Переиспользуемые виджеты (PostCard, MasonryGrid, TagChip) └── shared/widgets/ # Переиспользуемые компоненты (PostCard, MasonryGrid, Dock)
``` ```
--- ---
## 🔒 Приватность и безопасность ## 🔒 Безопасность и конфиденциальность
- **Полная автономность**: Приложение не отправляет телеметрию и работает без внешних серверов-посредников. - **100% локальное хранение**: Вся история, закладки, локальные авторы и конфигурации хранятся только в вашей локальной базе на устройстве.
- **Локальное хранение**: Все настройки, история поиска, черный список и избранное хранятся исключительно на вашем устройстве в локальной базе Isar. - **Никакой телеметрии**: Lunaris не собирает аналитику, не отправляет логи и уважает вашу приватность.
- **Бережное отношение к трафику**: В ленте загружаются только оптимизированные превью. Тяжелые оригиналы и видео загружаются только при открытии или скачивании. - **Прямое соединение**: Запросы отправляются напрямую к выбранным API без прокси-серверов посредников.
---
## 👥 Разработчики
<p align="center">
<strong>Разработчики:</strong> <a href="https://github.com/RarDog">RarDog</a> & <a href="https://deepmind.google/technologies/gemini/">Antigravity (Gemini)</a>
</p>
<p align="center">
<a href="https://github.com/RarDog"><img src="https://img.shields.io/badge/Developer-RarDog-blue?style=for-the-badge&logo=github&logoColor=white" alt="RarDog" /></a>
<a href="https://deepmind.google/technologies/gemini/"><img src="https://img.shields.io/badge/AI%20Pair-Antigravity%20(Gemini)-8A2BE2?style=for-the-badge&logo=google&logoColor=white" alt="Antigravity" /></a>
</p>
@@ -54,6 +54,8 @@ class MainActivity : FlutterActivity() {
setPreferredRefreshRate(hz.toFloat()) setPreferredRefreshRate(hz.toFloat())
result.success(null) result.success(null)
} }
"getSupportedAbis" -> result.success(Build.SUPPORTED_ABIS.toList())
"getPrimaryAbi" -> result.success(Build.SUPPORTED_ABIS.firstOrNull() ?: "armeabi-v7a")
else -> result.notImplemented() else -> result.notImplemented()
} }
} catch (error: Throwable) { } catch (error: Throwable) {
+29
View File
@@ -0,0 +1,29 @@
# ❓ Часто задаваемые вопросы (FAQ)
### 1. Как обновить приложение?
Lunaris проверяет наличие свежих версий автоматически при запуске.
Вы также можете нажать **«Проверить обновления»** в Настройках.
Приложение определит архитектуру вашего Android-устройства и скачает оптимальный APK напрямую из репозитория.
---
### 2. Почему видео воспроизводится без звука?
По умолчанию звук в плеере приглушен, чтобы избежать неожиданно громкого воспроизведения. Нажмите на значок динамика в плеере, чтобы включить звук. Приложение запомнит ваш выбор для текущей сессии.
---
### 3. Где сохраняются скачанные файлы?
- **На Android**: В стандартную папку `Pictures/Lunaris` или `Download/Lunaris` (в зависимости от настроек системы). Файлы сразу видны в стандартной Галерее телефона.
- **На Linux**: В директорию `~/Pictures/Lunaris` или папку загрузок.
---
### 4. Безопасно ли вводить логин и пароль от Pawchive?
Абсолютно безопасно. Lunaris не отправляет ваши учетные данные никуда, кроме официального защищенного API Pawchive по HTTPS. Пароли не логируются и сохраняются в зашифрованном системном хранилище. Для дополнительного спокойствия вы можете использовать вход через **Session Cookie**.
---
### 5. Что делать, если посты перестали загружаться?
1. Проверьте стабильность интернет-соединения или работу VPN (некоторые провайдеры Booru могут быть заблокированы региональными провайдерами).
2. Зайдите в **«Настройки» ➔ «Хранилище и кэш»** и нажмите **«Очистить кэш сети»**.
3. Проверьте доступность источника через браузер (иногда Booru-серверы уходят на техническое обслуживание).
+53
View File
@@ -0,0 +1,53 @@
# 🚀 Быстрый старт и установка
### 📥 1. Установка на Android
Релизы приложения публикуются на странице **[GitHub Releases](https://github.com/RarDog/Lunaris/releases)**.
Начиная с версии **Lunaris 3.0**, APK разделены по архитектуре процессора, чтобы уменьшить размер скачиваемого файла:
| Файл APK | Для каких устройств |
|---|---|
| **`app-arm64-v8a-release.apk`** | **Рекомендуется.** 99% современных смартфонов и планшетов (64-битные процессоры ARM). |
| **`app-armeabi-v7a-release.apk`** | Старые 32-битные Android-смартфоны или бюджетные ТВ-приставки. |
| **`app-x86_64-release.apk`** | Эмуляторы Android на ПК (Bluestacks, Android Studio) или планшеты с процессорами Intel/AMD. |
> [!TIP]
> **Умное автообновление:** При проверке обновлений прямо внутри Lunaris приложение **самостоятельно определит архитектуру** вашего процессора и автоматически скачает правильный файл!
#### Шаги установки:
1. Скачайте нужный `.apk` файл на устройство.
2. Откройте файл через менеджер файлов и подтвердите установку («Разрешить установку из этого источника»).
3. Запустите **Lunaris**.
---
### 🐧 2. Установка на Linux
Lunaris распространяется для 64-битных Linux в виде готового автономного архива:
1. Скачайте **`Lunaris-v3.0.0-linux-x64.tar.gz`**.
2. Распакуйте архив в любую удобную папку:
```bash
tar -xzf Lunaris-v3.0.0-linux-x64.tar.gz -C ~/Lunaris
```
3. Запустите приложение:
```bash
cd ~/Lunaris
./lunaris
```
*Системные требования Linux:* GTK-3, GLibc 2.31+, поддержка OpenGL/Vulkan для аппаратного ускорения видео.
---
### 🎨 3. Первый запуск и базовая настройка
После первого запуска рекомендуем заглянуть в раздел **Настройки**:
- **Тема оформления**:
- *Системная*: следует за темой устройства.
- *Светлая / Тёмная*.
- *AMOLED Pure Black*: истинно черный фон (`#000000`) для экономии заряда на экранах OLED.
- *Динамические цвета (Material You)*: адаптация акцентных цветов под ваши системные обои Android.
- **Интерфейс**: выбор языка (Русский / Английский).
- **Провайдер по умолчанию**: выберите сервис, лента которого будет открываться первой при старте.
+28
View File
@@ -0,0 +1,28 @@
# 🌙 Добро пожаловать в Lunaris Wiki!
**Lunaris** — это современный, быстрый и функциональный кроссплатформенный клиент для Booru-борд и архивов независимых авторов (Creator Archives).
Приложение разработано на Flutter для **Android**, **Linux** и **Windows** с акцентом на плавный интерфейс в стиле Pinterest, глубокую кастомизацию, приватность и удобную работу с медиа.
---
## 🧭 Разделы документации
| Раздел | Описание |
|---|---|
| 🚀 **[Быстрый старт и установка](Getting-Started)** | Как выбрать правильный APK под ваш процессор Android, установка на Linux и первый запуск. |
| 🌐 **[Провайдеры контента](Providers)** | Подключение Booru (Gelbooru, Rule34, Realbooru, e621) и архивов Pawchive. |
| 🔍 **[Умный поиск и теги](Search-and-Tags)** | Оператор `and`, мультипоиск, общие модификаторы, подсказки тегов со скоростью отклика 0 мс. |
| 🐾 **[Синхронизация Pawchive](Pawchive-Sync)** | Вход по логину или куки, двусторонняя синхронизация избранного, экспорт авторов. |
| 🎬 **[Медиаплеер и просмотр](Media-Viewer)** | Управление жестами, автоповорот в полный экран, определение разрешения 1920x1080. |
| 💾 **[Офлайн-режим и загрузки](Offline-and-Downloads)** | Локальный архив, шаблоны папок скачивания, пакетный экспорт. |
| 🛡️ **[Черный список и фильтрация](Safety-and-Blacklist)** | Защита от нежелательного контента по тегам, категориям и рейтингу. |
| ❓ **[Частые вопросы (FAQ)](FAQ)** | Автообновление приложения, решение проблем с сетью и кэшем. |
---
## 💡 Философия проекта
1. **Никакой телеметрии и слежки**: Все ваши закладки, история поиска и избранные авторы хранятся локально на вашем устройстве.
2. **Максимальная скорость**: Локальное кэширование метаданных и тегов позволяет интерфейсу летать без сетевых задержек.
3. **Современная эстетика**: Дизайн 2.0 с динамическими цветами Material You, истинно черной AMOLED-темой и мягкими squircle-формами.
+41
View File
@@ -0,0 +1,41 @@
# 🎬 Медиаплеер и просмотр
Экран просмотра в Lunaris оптимизирован для быстрого, плавного и естественного взаимодействия с контентом.
---
## 👆 Управление жестами
| Жест | Действие |
|---|---|
| **Свайп влево / вправо** | Листание к следующему или предыдущему посту в текущей ленте. |
| **Двойной тап по экрану** | Быстрое добавление в «Любимое» с красивой всплывающей анимацией сердца. |
| **Свайп вниз (Pull down)** | Быстрое и плавное закрытие экрана просмотра и возврат в ленту. |
| **Pinch-to-zoom** | Плавное масштабирование картинки двумя пальцами с автовозвратом границ. |
---
## 📐 Определение разрешения медиа (1920×1080 и др.)
В Lunaris 3.0 внедрено **интеллектуальное ленивое определение разрешения**:
- Для изображений и видео в инфо-панели поста отображаются точные физические размеры (например, `1920 × 1080`, `3840 × 2160`).
- Определение происходит динамически через заголовки и потоковый декодер — приложению не требуется скачивать многомегабайтный файл целиком только ради того, чтобы узнать его габариты.
---
## 🔄 Автоповорот видео в полноэкранном режиме
- При нажатии кнопки разворота на весь экран видеоплеер анализирует соотношение сторон (`aspect ratio`).
- **Широкоформатные видео (16:9, 21:9)** автоматически ориентируют смартфон в альбомный режим (горизонтально).
- **Вертикальные видео (9:16, Shorts, Reels)** остаются в удобном портретном режиме.
---
## ⚓ Action Dock 2.0
Под медиафайлом расположен чистый, компактный док с 5 главными действиями:
1. **❤️ Избранное**: добавить работу в локальные закладки.
2. **💾 Скачать**: сохранить оригинал файла в высоком качестве.
3. **📤 Поделиться**: отправка оригинального файла напрямую в Telegram, Discord, мессенджеры через системное меню Share.
4. **🏷️ Теги**: открытие всплывающего окна со всеми тегами поста (с подсветкой категорий и возможностью скопировать или начать поиск по тегу).
5. **ℹ️ Подробнее**: метаданные, автор, провайдер, дата публикации, рейтинг и комментарии.
+39
View File
@@ -0,0 +1,39 @@
# 💾 Офлайн-режим и загрузки
Lunaris предоставляет полноценный оффлайн-хаб для работы с вашей личной коллекцией без подключения к интернету.
---
## 📴 Офлайн 2.0 (Sync Hub)
В разделе **«Любимое»** переключитесь на вкладку **«Офлайн»**:
- **Честное воспроизведение**: Видеоролики и фотографии загружаются напрямую из памяти вашего смартфона без единого сетевого запроса.
- **Аналитика хранилища**: Информационная карточка в шапке отображает точный объем памяти, занятый сохранённым контентом (например, `💾 4.2 ГБ на устройстве`).
- **Пакетная выгрузка**: Кнопка скачивания недостающих постов в 1 тап проверяет избранное и загружает все еще не сохраненные оригиналы в фоновом режиме.
---
## 📁 Настройка шаблонов папок для скачивания
В разделе **«Настройки» ➔ «Загрузки»** можно настроить структуру каталогов на диске:
Доступные теги шаблона:
- `{Artist}` — имя автора работы
- `{Provider}` — название источника (`gelbooru`, `rule34`, `pawchive`)
- `{Service}` — сервис автора (`patreon`, `fanbox`)
- `{ID}` — уникальный номер поста
- `{Date}` — дата сохранения
*Пример шаблона:*
```text
Lunaris/{Provider}/{Artist}/{ID}.{ext}
```
Файлы будут аккуратно раскладываться по папкам авторов и провайдеров, сохраняя порядок в вашей галерее.
---
## 📦 Бэкап и перенос данных
В настройках доступна функция **«Резервное копирование»**:
- **Экспорт в JSON**: выгружает все настройки, избранные посты, любимых авторов и черный список в один файл.
- **Импорт из JSON**: быстрое восстановление всей вашей коллекции на новом смартфоне или ПК за считанные секунды.
+42
View File
@@ -0,0 +1,42 @@
# 🐾 Интеграция и синхронизация с Pawchive
Начиная с версии **Lunaris 3.0**, в приложении реализована глубокая интеграция с личным профилем Pawchive.
---
## 🔑 Вход в аккаунт
1. Перейдите во вкладку **«Артисты»** в нижнем баре.
2. В верхнем правом углу нажмите на иконку профиля / облака.
3. Откроется шторка **«Аккаунты Pawchive»**.
4. Доступно два способа авторизации:
- **Логин и пароль**: введите свои учётные данные от Pawchive.
- **Session Cookie**: если используется сложная авторизация через провайдеров или включена двухфакторная аутентификация, вставьте значение куки `session` из браузера.
---
## 🔄 Двусторонняя синхронизация (Two-Way Sync)
В шторке управления аккаунтами расположен переключатель **«Двусторонняя синхронизация»**:
- **Выключена (по умолчанию)**: При обычной синхронизации список авторов импортируется с сервера Pawchive в приложение. Локальные изменения остаются нетронутыми.
- **Включена**: При синхронизации приложение не только загружает новых авторов с сервера, но и **автоматически выгружает всех ваших локальных любимых авторов** на сервер Pawchive!
---
## 📤 Ручной экспорт локального избранного
Если у вас уже накоплена коллекция авторов на устройстве и вы хотите добавить их в свой аккаунт Pawchive:
1. Откройте шторку **«Аккаунты Pawchive»**.
2. Нажмите кнопку:
**«Выгрузить избранное (N) в @username»**
3. Приложение проверит текущий список на сервере и в фоновом режиме отправит недостающих авторов.
---
## ⭐ Мгновенная синхронизация на лету
Когда у вас подключен хотя бы один активный аккаунт Pawchive:
- Нажатие на звёздочку/сердечко автора в любом месте приложения (в ленте, профиле автора, карточке поста) **моментально отправляет запрос на сервер Pawchive**.
- Автор добавляется в ваш профиль без необходимости ручной синхронизации.
- Поддерживаются авторы с любых платформ: `fanbox`, `patreon`, `fantia`, `boosty`, `subscribestar`, `gumroad`, `dlsite`, `discord`.
+47
View File
@@ -0,0 +1,47 @@
# 🌐 Провайдеры контента
Lunaris объединяет два фундаментальных класса источников: классические **Booru-доски** и современные **архивы авторов (Pawchive)**.
---
## 📋 Список поддерживаемых провайдеров
### 1. 🐾 Pawchive (REST API v1)
Крупнейший агрегатор контента независимых авторов с платных подписочных сервисов:
- **Поддерживаемые сервисы авторов:**
- `fanbox` (Pixiv Fanbox)
- `patreon` (Patreon)
- `fantia` (Fantia)
- `boosty` (Boosty.to)
- `subscribestar` (SubscribeStar)
- `gumroad` (Gumroad)
- `dlsite` (DLsite)
- `discord` (Серверы Discord)
- **Возможности в Lunaris:**
- Просмотр глобальной ленты свежих постов.
- Персональные карточки авторов с аватаром, баннером, анонсами и ссылками на оригинальные профили.
- Полноценная авторизация в профиль Pawchive (логин/пароль или Session Cookie).
- Двусторонняя синхронизация любимых авторов.
### 2. 🌸 Gelbooru
Популярнейшая база аниме-иллюстраций с подробной системой классификации:
- Теги художников, персонажей, авторских прав (copyright), общие описания.
- Высокое разрешение оригиналов.
- Полнотекстовый поиск и рекомендации.
### 3. 🔞 Rule34 & Realbooru
- **Rule34**: Огромное сообщество, поддержка анимированных GIF и видеофайлов WebM/MP4.
- **Realbooru**: Специализируется на косплее, 3D-рендерах и реалистичном контенте.
### 4. 🐺 e621 / e926
Крупнейший архив фурри-, антро- и тематического арта:
- Богатейшая иерархическая система тегов (виды существ, анатомия, стили).
- Полная поддержка `e926` (безопасный режим с фильтром безопасного рейтинга).
---
## 🔄 Как переключаться между провайдерами
1. В верхней панели ленты (**Feed**) нажмите на чип с текущим названием провайдера (например, `Gelbooru`).
2. В появившемся меню выберите желаемый источник.
3. Лента мгновенно перезагрузится с сохранением выбранного режима отображения (Masonry / Сетка / Список).
+31
View File
@@ -0,0 +1,31 @@
# 🛡️ Черный список и фильтрация
Lunaris оснащен многоуровневой системой защиты от нежелательного контента, работающей прямо на устройстве.
---
## 🚫 Как настроить Blacklist (Черный список)
1. Откройте раздел **«Настройки» ➔ «Фильтрация и Blacklist»**.
2. Введите тег или комбинацию тегов, которую вы хотите скрыть из выдачи.
3. Посты с указанными тегами будут **автоматически исключены** из всех лент, поисковых запросов и рекомендаций.
---
## 🔒 Безопасный режим (SFW)
Для комфортного использования приложения в общественных местах доступен переключатель **«Безопасный режим (Safe Mode)»**:
- Автоматически скрывает работы с рейтингами `questionable` и `explicit`.
- Включает безопасное зеркало `e926` при переключении на провайдер e621.
- Фильтрует обложки и превью в ленте.
---
## 🧹 Управление кэшем
В настройках приложения предусмотрен детальный мониторинг занятой памяти:
- **Кэш изображений**: временные миниатюры ленты.
- **Кэш видео**: фрагменты буферизации видеоплеера.
- **База тегов**: локальный словарь поисковых подсказок.
Вы можете очистить любой из разделов по отдельности нажатием одной кнопки.
+58
View File
@@ -0,0 +1,58 @@
# 🔍 Умный поиск и теги
Поиск в Lunaris спроектирован для мгновенного отклика и максимальной гибкости в построении сложных запросов.
---
## ⚡ Мгновенные подсказки тегов (0 мс)
- Приложение сохраняет локальный кэш десятков тысяч популярных тегов на вашем устройстве.
- При начале ввода в поисковую строку подсказки появляются **мгновенно (0 мс)** без ожидания ответа сервера.
- Категории тегов подсвечиваются фирменными цветами:
- 🔴 **Художник / Автор** (`artist`) — красный
- 🟢 **Персонаж** (`character`) — зеленый
- 🟣 **Копирайт / Произведение** (`copyright`) — фиолетовый
- 🔵 **Вид / Существо** (`species`) — синий
- ⚪ **Общие теги** (`general`) — нейтральный
---
## 🔀 Оператор мультипоиска `and`
Классические Booru-серверы при поиске нескольких тегов (`tag1 tag2`) возвращают только посты, где **присутствуют оба тега одновременно**.
Lunaris поддерживает специальный оператор **`and`**:
```text
genshin_impact and honkai_star_rail
```
**Как это работает:**
1. Приложение одновременно делает независимые запросы по каждому из тегов.
2. Результаты плавно чередуются в единой бесконечной ленте.
3. Вы получаете сбалансированную выборку сразу по нескольким интересующим вас темам.
---
## 🎯 Общие модификаторы в мультипоиске
При составлении сложных запросов модификатор, указанный в конце, автоматически распространяется на все части запроса:
```text
cat and dog anthro
```
Автоматически преобразуется в поиск:
- `cat anthro`
- `dog anthro`
---
## 🎛️ Поисковые фильтры и операторы
Вы можете уточнять выдачу с помощью операторов:
| Фильтр | Назначение | Пример |
|---|---|---|
| `type:video` | Показывать только видеоролики и WebM | `cat type:video` |
| `type:image` | Показывать только статичные изображения | `landscape type:image` |
| `rating:safe` | Только безопасный контент (SFW) | `wallpaper rating:safe` |
| `rating:explicit` | Контент 18+ | `rating:explicit` |
| `score:>50` | Работы с рейтингом выше 50 лайков | `dragon score:>100` |
| `-tag` | Исключить тег из результатов | `solo -comic` |
+16
View File
@@ -0,0 +1,16 @@
### 🌙 Lunaris Wiki
- **[Главная](Home)**
- **[Быстрый старт и установка](Getting-Started)**
- **[Провайдеры контента](Providers)**
- **[Умный поиск и теги](Search-and-Tags)**
- **[Интеграция и синхронизация с Pawchive](Pawchive-Sync)**
- **[Медиаплеер и просмотр](Media-Viewer)**
- **[Офлайн-режим и загрузки](Offline-and-Downloads)**
- **[Черный список и фильтрация](Safety-and-Blacklist)**
- **[Частые вопросы (FAQ)](FAQ)**
---
**[Скачать Lunaris](https://github.com/RarDog/Lunaris/releases)**
**[Репозиторий GitHub](https://github.com/RarDog/Lunaris)**
+1 -1
View File
@@ -149,7 +149,7 @@ class _AppOverlayState extends ConsumerState<_AppOverlay> {
Future<void> _downloadUpdate(AppUpdateInfo info) async { Future<void> _downloadUpdate(AppUpdateInfo info) async {
final updateService = ref.read(updateServiceProvider); final updateService = ref.read(updateServiceProvider);
final assetUrl = updateService.assetUrlForCurrentPlatform(info); final assetUrl = await updateService.assetUrlForCurrentPlatform(info);
if (assetUrl == null) { if (assetUrl == null) {
final url = Uri.tryParse(info.htmlUrl); final url = Uri.tryParse(info.htmlUrl);
if (url != null) { if (url != null) {
+1
View File
@@ -36,6 +36,7 @@ class AppStrings {
String get downloading => ru ? 'Скачивается' : 'Downloading'; String get downloading => ru ? 'Скачивается' : 'Downloading';
String get failed => ru ? 'Ошибка' : 'Failed'; String get failed => ru ? 'Ошибка' : 'Failed';
String get open => ru ? 'Открыть' : 'Open'; String get open => ru ? 'Открыть' : 'Open';
String get copyLink => ru ? 'Копировать ссылку' : 'Copy link';
String get hidePost => ru ? 'Скрыть пост' : 'Hide post'; String get hidePost => ru ? 'Скрыть пост' : 'Hide post';
String get hiddenLocally => String get hiddenLocally =>
ru ? 'Пост скрыт локально' : 'Post hidden locally'; ru ? 'Пост скрыт локально' : 'Post hidden locally';
+2 -2
View File
@@ -1,3 +1,3 @@
const appDisplayVersion = '2.2.0'; const appDisplayVersion = '3.0.6';
const appBuildNumber = 39; const appBuildNumber = 57;
const appReleaseChannel = 'Stable'; const appReleaseChannel = 'Stable';
+145 -3
View File
@@ -1,4 +1,146 @@
const lunarisChangelog = [ const lunarisChangelog = [
LunarisChange(
version: '3.0.6',
title: 'Улучшение недавних запросов и отображения тегов',
bullets: [
'Полная видимость тегов и запросов: сняты тесные ограничения ширины (140px), теги и поисковые запросы теперь отображаются целиком без усечения.',
'Устранение дубликатов в истории: автоматическая дедупликация поисковых запросов в базе данных и интерфейсе. Устранены повторяющиеся пары запросов с пустыми счетчиками.',
'Исправление заголовка карточки поиска: заголовок «Недавние запросы» и счетчик больше не обрезаются на экранах смартфонов.',
'Удобное удаление: увеличенная область нажатия для кнопки удаления (крестика) предотвращает случайные нажатия на сам запрос.',
],
),
LunarisChange(
version: '3.0.5',
title: 'Интерактивные ссылки на соцсети и авторов в объявлениях и описаниях',
bullets: [
'Кликабельные ссылки в объявлениях автора и описании постов: все URL в тексте подсвечиваются и открываются во внешнем браузере по клику.',
'Красивые бейджи соцсетей (CreatorLinkChips): автоматическое распознавание и стилизация ссылок на YouTube, X (Twitter), Instagram, TikTok, Discord, Patreon, Fanbox, Fantia, Boosty, Pixiv, Twitch, Telegram, Gumroad и другие платформы с фирменными иконками и цветами.',
'Корректная очистка пунктуации: знаки препинания в конце URL (!, ?, :, ., ,, ;, >) больше не ломают ссылки.',
],
),
LunarisChange(
version: '3.0.0',
title: 'Lunaris 3.0 — Глобальное обновление: Дизайн 2.0, Интеграция Pawchive и умная синхронизация',
bullets: [
'Дизайн 2.0 (Modern Booru Experience): обновлённый современный интерфейс в стиле Material 3, адаптивные сетки галереи (Masonry / Staggered), улучшенная типографика и плавные микроанимации.',
'Полноэкранный видеоплеер нового поколения: умный автоматический поворот экрана в ландшафтную ориентацию для горизонтальных видео и быстрый ручной переключатель ориентации в одно касание.',
'Новая архитектура поиска (OverlayPortal): подсказки тегов отображаются через нативный портал Flutter без блокировки кнопок панели, очистки и сброса фильтров.',
'Pawchive 2.0 & Двусторонняя синхронизация: подключение аккаунтов через логин/пароль или Session Cookie, импорт избранного с сервера, экспорт локальных коллекций в аккаунт на сервере и сквозная синхронизация со всех поддерживаемых платформ.',
'Ленивое автоопределение разрешения: фоновый декодер автоматически рассчитывает и выводит точные размеры медиа (например, 1920 × 1080) для постов Pawchive без серверных метаданных.',
'Умные per-ABI обновления: автообновление через GitHub скачивает точный APK под архитектуру вашего процессора (arm64-v8a, armeabi-v7a, x86_64), сокращая вес загрузки.',
'Исправление вёрстки: устранены переносы текста в заголовке окна аккаунтов и карточках пользователей на экранах смартфонов.',
],
),
LunarisChange(
version: '2.3.10',
title: 'Выгрузка локального избранного в Pawchive и двусторонняя синхронизация',
bullets: [
'Экспорт локального избранного: добавлена возможность выгрузить всех ваших локальных авторов из приложения прямо в аккаунт Pawchive на сервере в 1 клик.',
'Двусторонняя синхронизация: переключатель двусторонней синхронизации объединяет коллекции — скачивает новых авторов с сервера и отправляет локальных на сервер Pawchive.',
'Кнопка выгрузки в карточке аккаунта: быстрая кнопка со стрелкой вверх и пункт контекстного меню «Выгрузить в Pawchive» для любого подключённого аккаунта.',
'Синхронизация в реальном времени с любых провайдеров: при добавлении автора в избранное в лентах Kemono, Coomer или Pawchive изменение мгновенно передаётся на сервер Pawchive.',
],
),
LunarisChange(
version: '2.3.9',
title: 'Исправление входа и синхронизации аккаунтов Pawchive',
bullets: [
'Исправлены API эндпоинты Pawchive: пути запросов избранных авторов и управления подписками приведены в соответствие со спецификацией API v1 (/api/v1/account/favorites, /api/v1/favorites/creator/...).',
'Устранена ошибка 404: авторизация по логину и паролю, а также по session cookie теперь успешно завершается и сразу синхронизирует всех авторов.',
'Информативные ошибки: понятные сообщения на русском при неверном пароле или сбое сети вместо сырых исключений Dio.',
],
),
LunarisChange(
version: '2.3.8',
title: 'ABI-Aware Auto-Update — правильный APK для вашего процессора',
bullets: [
'Определение архитектуры Android: приложение читает Build.SUPPORTED_ABIS (arm64-v8a, armeabi-v7a, x86_64) через нативный канал.',
'Точный APK из GitHub: при обновлении скачивается per-ABI APK (app-arm64-v8a-release.apk и т.д.), а не один общий файл.',
'Fallback: нет per-ABI APK → универсальный APK → страница релиза в браузере.',
'Кеш ABI: архитектура определяется один раз за сеанс.',
],
),
LunarisChange(
version: '2.3.7',
title: 'Lazy Media Dimensions for Pawchive & Unknown-Size Providers',
bullets: [
'Auto-Resolve Image Dimensions: when a provider (such as Pawchive) does not supply width/height metadata, the app now lazily resolves the real pixel dimensions by decoding the image in the background and displays them as soon as they are ready.',
'No More "0 × 0": the resolution badge now shows "… × …" while dimensions are being fetched, and the correct resolution (e.g. 1920 × 1080) once decoded. The badge is hidden entirely for video/link posts where dimensions cannot be resolved.',
'Session Cache: resolved image dimensions are cached in memory for the lifetime of the app so repeated visits to the same post do not trigger re-downloads.',
],
),
LunarisChange(
version: '2.3.6',
title: 'Pawchive Accounts Integration & Favorites Synchronization',
bullets: [
'Pawchive Account Support: users can now log into Pawchive with their username and password or paste a direct session cookie.',
'Multi-Account Synchronization: add and manage multiple Pawchive accounts; synchronize favorite artists from all connected accounts into a consolidated local favorites list with 1 tap.',
'Real-Time Remote Favorites Sync: favoriting or unfavoriting any Pawchive artist in the app automatically pushes the change to the active Pawchive account in real time.',
'Artist Posts Direct Favorite: added a star toggle directly in the artist posts screen top bar to add or remove creators from favorites without leaving their gallery.',
'Dedicated UI & Quick Access: manage connected accounts, view sync stats, and trigger synchronization from the Artists screen, Favorites modal, and Settings screen.',
],
),
LunarisChange(
version: '2.3.5',
title: 'Fullscreen Video Auto-Rotation & Orientation Controls',
bullets: [
'Smart Fullscreen Auto-Rotation: entering fullscreen mode on horizontal/landscape videos automatically snaps the device into landscape orientation for maximum viewing area, while vertical/portrait videos stay in portrait mode.',
'Manual Orientation Toggle: added a quick-access rotation button (Icons.screen_lock_portrait / landscape) directly in the fullscreen top bar next to the close button, allowing 1-tap switching between landscape and portrait.',
'Clean Orientation Recovery: exiting fullscreen seamlessly restores system navigation bars (Edge-to-Edge) and restores unrestricted device sensor orientations.',
],
),
LunarisChange(
version: '2.3.4',
title: 'OverlayPortal Architecture & Search UI Unblocking',
bullets: [
'OverlayPortal Migration: replaced manual root OverlayEntry and translucent backdrop with Flutter native OverlayPortal and TapRegion. Completely eliminates ghost overlay leaks and touch interception.',
'Unblocked Toolbar & Search Bar GUI: buttons (Refresh / Обновить, Clear Filters / Сбросить, random post, filter chips) and search chip crosses now respond immediately to taps without being swallowed by overlay backdrops.',
'Instant Chip Removal: deleting a tag chip (via its X cross icon) now updates the search immediately in real time, synchronizing the feed without restoring deleted tags on rebuild.',
'Search Clear Synchronization: tapping the search bar clear button (X) resets the query and clears active filters immediately.',
'Non-Blocking Outside Tap: tapping outside the suggestion dropdown smoothly dismisses suggestions while allowing the touch event to pass through directly to the underlying button or post.',
],
),
LunarisChange(
version: '2.3.3',
title: 'Tag Suggestions Overlay Fix & Rule34 API Alignment',
bullets: [
'Tag Suggestions Hanging Fix: eliminated the bug where the tag suggestion card remained stuck on screen after clearing text or tapping outside. Added immediate dismissal on empty input, outside-tap backdrop dismissal, controller listener, and unfocus on submission.',
'Rule34 Dedicated Autocomplete: connected Rule34 directly to its official fast /autocomplete.php?q= endpoint, displaying real-time tag suggestions with accurate post counts (e.g. braid (216,984)).',
'Rule34 Tag Types (fields=tag_info): added tag_info field to post query for accurate tag categories per post.',
'Rule34 Authentication & Outage Handling: added detection for "missing authentication", rate limits, and server overload/search down ("success": false) with informative guidance to enter API Key & User ID.',
],
),
LunarisChange(
version: '2.3.2',
title: 'Gelbooru DAPI Compliance & Tag Query Optimization',
bullets: [
'Official Gelbooru DAPI Alignment: fixed tag suggestions pattern to use SQL LIKE wildcard (%) instead of literal asterisk (*), restoring accurate tag autocompletion.',
'Popularity Tag Sorting: added orderby=count and order=DESC to Gelbooru tag search so the most relevant and widely used tags appear first.',
'Batch Tag Categorization: migrated post tag metadata queries to the official "names" parameter, resolving categories for up to 80 tags in a single batch request instead of dozens of sequential requests.',
'Throttling & Auth Guidance: enhanced API credentials helper text and informative error messages with direct instructions to configure API Key & User ID from Gelbooru account options.',
],
),
LunarisChange(
version: '2.3.1',
title: 'Cloud Mirrors & Author Announcements Fixes',
bullets: [
'Collapsible Cloud Mirrors: the cloud drives section is now folded by default into a sleek header card matching the Tags and Comments cards, expandable on tap.',
'Tag Panel Sanitation: filtered out cloud drive links, JSON metadata, and URLs from appearing in the post tags panel and tag counters.',
'Author Announcements HTML Cleaning: stripped all raw <p style="">, <strong>, and formatting artifacts from artist announcements, rendering clean readable text and clickable service link chips.',
],
),
LunarisChange(
version: '2.3.0',
title: 'Artist Cloud Drives & External Video Mirrors',
bullets: [
'Cloud Mirrors & Drives: automatic detection and parsing of external links to Google Drive, MEGA, Dropbox, Pixeldrain, Catbox, MediaFire, GoFile, Bunkr, and direct video files from post descriptions, HTML, and embed data.',
'Direct Video Playback & Download: resolves direct streaming URLs for Dropbox (?raw=1), Google Drive, Pixeldrain API, and Catbox with 1-tap playback and direct downloads.',
'Cloud Mirrors Card: styled cards with service branding colors (MEGA Red, Google Drive Blue, Dropbox Blue, Pixeldrain Cyan) showing link type, direct stream button, external open, and copy link.',
'Link-Only Posts Support: artist posts that only contain cloud drive links without direct server files are now displayed with an elegant CloudMediaHero banner instead of an empty view.',
'Archive Password Detection: automatically extracts and displays archive passwords (Pass: ..., Password: ...) with 1-tap copy button.',
'Artist Commentary Card: formatted display of author notes, instructions, and text below post details.',
],
),
LunarisChange( LunarisChange(
version: '2.2.0', version: '2.2.0',
title: 'Offline Mode Overhaul & Post Details Screen Redesign', title: 'Offline Mode Overhaul & Post Details Screen Redesign',
@@ -40,7 +182,7 @@ const lunarisChangelog = [
'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).', '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.', '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.', '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.', 'Hero Brand Banner: polished app presentation card in About section with glowing icon, version build badge, update checker, and GitHub repository link.',
], ],
), ),
LunarisChange( LunarisChange(
@@ -178,7 +320,7 @@ const lunarisChangelog = [
'Added favorite artists bar at the top of the Artists tab with quick access and recent photos/videos stream.', 'Added favorite artists bar at the top of the Artists tab with quick access and recent photos/videos stream.',
'Multi-image posts now feature an in-post navigation indicator with image switching.', 'Multi-image posts now feature an in-post navigation indicator with image switching.',
'New Pawchive provider support and cleaned up deprecated providers.', 'New Pawchive provider support and cleaned up deprecated providers.',
'In-app update checks and downloads connected directly to RarDog Gitea releases.', 'In-app update checks and downloads connected directly to RarDog GitHub releases.',
], ],
), ),
LunarisChange( LunarisChange(
@@ -246,7 +388,7 @@ const lunarisChangelog = [
version: '1.0.1', version: '1.0.1',
title: 'Mobile polish', title: 'Mobile polish',
bullets: [ bullets: [
'Startup update checks now look for new Gitea releases every launch.', 'Startup update checks now look for new GitHub releases every launch.',
'Refresh-rate controls are Android-only; desktop keeps stable app motion.', 'Refresh-rate controls are Android-only; desktop keeps stable app motion.',
'Tag chip input no longer jumps while typing.', 'Tag chip input no longer jumps while typing.',
], ],
+6
View File
@@ -7,9 +7,13 @@ export 'models/artist_work_query.dart';
export 'models/artist_tag.dart'; export 'models/artist_tag.dart';
export 'models/artist_link.dart'; export 'models/artist_link.dart';
export 'models/artist_announcement.dart'; export 'models/artist_announcement.dart';
export 'models/cloud_media_link.dart';
export 'models/creator_link.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';
export 'models/favorite_artist_item.dart';
export 'models/pawchive_account.dart';
export 'models/post.dart'; export 'models/post.dart';
export 'models/post_comment.dart'; export 'models/post_comment.dart';
export 'models/provider_diagnostics.dart'; export 'models/provider_diagnostics.dart';
@@ -26,6 +30,7 @@ export 'services/download_manager_service.dart';
export 'services/downloaded_media_service.dart'; export 'services/downloaded_media_service.dart';
export 'services/favorite_service.dart'; export 'services/favorite_service.dart';
export 'services/feed_service.dart'; export 'services/feed_service.dart';
export 'services/pawchive_sync_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/tag_cache_service.dart';
@@ -33,5 +38,6 @@ 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 'services/backup_service.dart';
export 'services/cloud_link_extractor.dart';
export 'utils/smart_blacklist.dart'; export 'utils/smart_blacklist.dart';
export 'utils/media_quality.dart'; export 'utils/media_quality.dart';
+26
View File
@@ -7,6 +7,9 @@ class AppUpdateInfo {
required this.htmlUrl, required this.htmlUrl,
required this.publishedAt, required this.publishedAt,
this.apkUrl, this.apkUrl,
this.apkArm64Url,
this.apkArmv7Url,
this.apkX86_64Url,
this.windowsInstallerUrl, this.windowsInstallerUrl,
this.portableZipUrl, this.portableZipUrl,
this.linuxTarGzUrl, this.linuxTarGzUrl,
@@ -18,13 +21,36 @@ class AppUpdateInfo {
final String body; final String body;
final String htmlUrl; final String htmlUrl;
final DateTime? publishedAt; final DateTime? publishedAt;
/// Generic APK (fat / universal build). Used as a fallback.
final String? apkUrl; final String? apkUrl;
/// Per-ABI APKs produced by `flutter build apk --split-per-abi`.
final String? apkArm64Url; // app-arm64-v8a-release.apk
final String? apkArmv7Url; // app-armeabi-v7a-release.apk
final String? apkX86_64Url; // app-x86_64-release.apk
final String? windowsInstallerUrl; final String? windowsInstallerUrl;
final String? portableZipUrl; final String? portableZipUrl;
final String? linuxTarGzUrl; final String? linuxTarGzUrl;
/// Returns the best APK URL for [abi], falling back to the generic APK.
String? apkUrlForAbi(String abi) {
if (abi.contains('arm64') || abi == 'arm64-v8a') {
return apkArm64Url ?? apkUrl;
}
if (abi.contains('x86_64')) {
return apkX86_64Url ?? apkUrl;
}
// armeabi-v7a and everything else
return apkArmv7Url ?? apkArm64Url ?? apkUrl;
}
bool get hasAssets => bool get hasAssets =>
apkUrl != null || apkUrl != null ||
apkArm64Url != null ||
apkArmv7Url != null ||
apkX86_64Url != null ||
windowsInstallerUrl != null || windowsInstallerUrl != null ||
portableZipUrl != null || portableZipUrl != null ||
linuxTarGzUrl != null; linuxTarGzUrl != null;
+155
View File
@@ -0,0 +1,155 @@
import 'dart:convert';
import 'package:flutter/material.dart';
enum CloudServiceType {
googleDrive,
mega,
dropbox,
pixeldrain,
catbox,
mediafire,
bunkr,
gofile,
terabox,
genericVideo,
other,
}
class CloudMediaLink {
const CloudMediaLink({
required this.url,
required this.service,
required this.title,
this.directStreamUrl,
this.isFolder = false,
this.isStreamable = false,
this.detectedPassword,
});
final String url;
final CloudServiceType service;
final String title;
final String? directStreamUrl;
final bool isFolder;
final bool isStreamable;
final String? detectedPassword;
String get serviceName {
switch (service) {
case CloudServiceType.googleDrive:
return 'Google Drive';
case CloudServiceType.mega:
return 'MEGA';
case CloudServiceType.dropbox:
return 'Dropbox';
case CloudServiceType.pixeldrain:
return 'Pixeldrain';
case CloudServiceType.catbox:
return 'Catbox';
case CloudServiceType.mediafire:
return 'MediaFire';
case CloudServiceType.bunkr:
return 'Bunkr';
case CloudServiceType.gofile:
return 'GoFile';
case CloudServiceType.terabox:
return 'TeraBox';
case CloudServiceType.genericVideo:
return 'Direct Video';
case CloudServiceType.other:
return 'External Mirror';
}
}
Color get brandColor {
switch (service) {
case CloudServiceType.googleDrive:
return const Color(0xFF4285F4); // Google Blue
case CloudServiceType.mega:
return const Color(0xFFD9272E); // Mega Red
case CloudServiceType.dropbox:
return const Color(0xFF0061FF); // Dropbox Blue
case CloudServiceType.pixeldrain:
return const Color(0xFF00B4D8); // Cyan
case CloudServiceType.catbox:
return const Color(0xFFFF6F00); // Amber/Orange
case CloudServiceType.mediafire:
return const Color(0xFF1E88E5); // MediaFire Blue
case CloudServiceType.bunkr:
return const Color(0xFF9C27B0); // Purple
case CloudServiceType.gofile:
return const Color(0xFF26A69A); // Teal
case CloudServiceType.terabox:
return const Color(0xFF29B6F6); // Light Blue
case CloudServiceType.genericVideo:
return const Color(0xFFE91E63); // Pink
case CloudServiceType.other:
return const Color(0xFF78909C); // Slate
}
}
IconData get iconData {
if (isFolder) return Icons.folder_zip_rounded;
if (isStreamable || service == CloudServiceType.genericVideo) {
return Icons.play_circle_filled_rounded;
}
switch (service) {
case CloudServiceType.googleDrive:
return Icons.add_to_drive_rounded;
case CloudServiceType.mega:
return Icons.cloud_circle_rounded;
case CloudServiceType.dropbox:
return Icons.cloud_done_rounded;
case CloudServiceType.pixeldrain:
case CloudServiceType.catbox:
case CloudServiceType.mediafire:
case CloudServiceType.bunkr:
case CloudServiceType.gofile:
case CloudServiceType.terabox:
return Icons.cloud_download_rounded;
case CloudServiceType.genericVideo:
return Icons.videocam_rounded;
case CloudServiceType.other:
return Icons.link_rounded;
}
}
Map<String, dynamic> toJson() => {
'url': url,
'service': service.name,
'title': title,
if (directStreamUrl != null) 'directStreamUrl': directStreamUrl,
'isFolder': isFolder,
'isStreamable': isStreamable,
if (detectedPassword != null) 'detectedPassword': detectedPassword,
};
factory CloudMediaLink.fromJson(Map<String, dynamic> json) {
final serviceStr = (json['service'] ?? '').toString();
final service = CloudServiceType.values.firstWhere(
(e) => e.name == serviceStr,
orElse: () => CloudServiceType.other,
);
return CloudMediaLink(
url: (json['url'] ?? '').toString(),
service: service,
title: (json['title'] ?? '').toString(),
directStreamUrl: json['directStreamUrl']?.toString(),
isFolder: json['isFolder'] == true,
isStreamable: json['isStreamable'] == true,
detectedPassword: json['detectedPassword']?.toString(),
);
}
String encode() => jsonEncode(toJson());
static CloudMediaLink? tryDecode(String str) {
try {
final decoded = jsonDecode(str);
if (decoded is Map<String, dynamic>) {
return CloudMediaLink.fromJson(decoded);
}
} catch (_) {}
return null;
}
}
+332
View File
@@ -0,0 +1,332 @@
import 'package:flutter/material.dart';
enum CreatorLinkType {
youtube,
twitter,
instagram,
tiktok,
discord,
pixiv,
fanbox,
patreon,
fantia,
boosty,
twitch,
telegram,
gumroad,
subscribestar,
artstation,
deviantart,
linkhub,
other,
}
class CreatorLink {
const CreatorLink({
required this.url,
required this.type,
required this.title,
this.customLabel,
});
final String url;
final CreatorLinkType type;
final String title;
final String? customLabel;
String get serviceName {
switch (type) {
case CreatorLinkType.youtube:
return 'YouTube';
case CreatorLinkType.twitter:
return 'X (Twitter)';
case CreatorLinkType.instagram:
return 'Instagram';
case CreatorLinkType.tiktok:
return 'TikTok';
case CreatorLinkType.discord:
return 'Discord';
case CreatorLinkType.pixiv:
return 'Pixiv';
case CreatorLinkType.fanbox:
return 'Fanbox';
case CreatorLinkType.patreon:
return 'Patreon';
case CreatorLinkType.fantia:
return 'Fantia';
case CreatorLinkType.boosty:
return 'Boosty';
case CreatorLinkType.twitch:
return 'Twitch';
case CreatorLinkType.telegram:
return 'Telegram';
case CreatorLinkType.gumroad:
return 'Gumroad';
case CreatorLinkType.subscribestar:
return 'SubscribeStar';
case CreatorLinkType.artstation:
return 'ArtStation';
case CreatorLinkType.deviantart:
return 'DeviantArt';
case CreatorLinkType.linkhub:
return 'Links Hub';
case CreatorLinkType.other:
return title.isNotEmpty ? title : 'Web Link';
}
}
Color get brandColor {
switch (type) {
case CreatorLinkType.youtube:
return const Color(0xFFFF0000); // YouTube Red
case CreatorLinkType.twitter:
return const Color(0xFF1D9BF0); // Twitter Blue
case CreatorLinkType.instagram:
return const Color(0xFFE1306C); // Instagram Gradient Magenta
case CreatorLinkType.tiktok:
return const Color(0xFF00F2FE); // TikTok Cyan
case CreatorLinkType.discord:
return const Color(0xFF5865F2); // Discord Blurple
case CreatorLinkType.pixiv:
return const Color(0xFF0096FA); // Pixiv Blue
case CreatorLinkType.fanbox:
return const Color(0xFFF1A12F); // Fanbox Orange
case CreatorLinkType.patreon:
return const Color(0xFFFF424D); // Patreon Coral
case CreatorLinkType.fantia:
return const Color(0xFFE0245E); // Fantia Pink
case CreatorLinkType.boosty:
return const Color(0xFFF15A24); // Boosty Orange
case CreatorLinkType.twitch:
return const Color(0xFF9146FF); // Twitch Purple
case CreatorLinkType.telegram:
return const Color(0xFF2AABEE); // Telegram Blue
case CreatorLinkType.gumroad:
return const Color(0xFFFF90E8); // Gumroad Pink
case CreatorLinkType.subscribestar:
return const Color(0xFF00ADB5); // SubscribeStar Teal
case CreatorLinkType.artstation:
return const Color(0xFF13AFF0); // ArtStation Blue
case CreatorLinkType.deviantart:
return const Color(0xFF05CC47); // DeviantArt Green
case CreatorLinkType.linkhub:
return const Color(0xFF43E660); // Linktree Green
case CreatorLinkType.other:
return const Color(0xFF5C6BC0); // Indigo
}
}
IconData get iconData {
switch (type) {
case CreatorLinkType.youtube:
return Icons.smart_display_rounded;
case CreatorLinkType.twitter:
return Icons.alternate_email_rounded;
case CreatorLinkType.instagram:
return Icons.camera_alt_rounded;
case CreatorLinkType.tiktok:
return Icons.music_note_rounded;
case CreatorLinkType.discord:
return Icons.forum_rounded;
case CreatorLinkType.pixiv:
return Icons.palette_rounded;
case CreatorLinkType.fanbox:
return Icons.favorite_rounded;
case CreatorLinkType.patreon:
return Icons.volunteer_activism_rounded;
case CreatorLinkType.fantia:
return Icons.stars_rounded;
case CreatorLinkType.boosty:
return Icons.rocket_launch_rounded;
case CreatorLinkType.twitch:
return Icons.live_tv_rounded;
case CreatorLinkType.telegram:
return Icons.send_rounded;
case CreatorLinkType.gumroad:
return Icons.shopping_bag_rounded;
case CreatorLinkType.subscribestar:
return Icons.star_rounded;
case CreatorLinkType.artstation:
case CreatorLinkType.deviantart:
return Icons.brush_rounded;
case CreatorLinkType.linkhub:
return Icons.link_rounded;
case CreatorLinkType.other:
return Icons.language_rounded;
}
}
static final _urlRegex = RegExp(
r'''https?://[^\s<>"{}|\\^`\[\]]+''',
caseSensitive: false,
);
static final _htmlLinkRegex = RegExp(
r"""<a\s+[^>]*href=["']([^"']+)["'][^>]*>(.*?)</a>""",
caseSensitive: false,
dotAll: true,
);
static const _cloudDriveHosts = {
'drive.google.com',
'mega.nz',
'mega.co.nz',
'dropbox.com',
'pixeldrain.com',
'mediafire.com',
'catbox.moe',
'bunkr.',
'gofile.io',
'terabox.com',
'1024tera.com',
'dubox.com',
};
/// Extracts all social media, creator profiles and external web links from text.
static List<CreatorLink> extractLinks(
String? text, {
bool excludeCloudDrives = true,
}) {
if (text == null || text.trim().isEmpty) return const [];
final results = <CreatorLink>[];
final seenUrls = <String>{};
void processUrl(String rawUrl, {String? customTitle}) {
var url = rawUrl.trim();
while (url.isNotEmpty &&
(url.endsWith(')') ||
url.endsWith('.') ||
url.endsWith(',') ||
url.endsWith(';') ||
url.endsWith('!') ||
url.endsWith('?') ||
url.endsWith(':') ||
url.endsWith('>'))) {
url = url.substring(0, url.length - 1);
}
final uri = Uri.tryParse(url);
if (uri == null || !uri.hasScheme || !uri.scheme.startsWith('http')) {
return;
}
if (seenUrls.contains(url)) return;
seenUrls.add(url);
final host = uri.host.toLowerCase();
if (excludeCloudDrives) {
if (_cloudDriveHosts.any((ch) => host.contains(ch))) {
return;
}
}
final (type, defaultTitle) = _classifyHost(uri);
results.add(CreatorLink(
url: url,
type: type,
title: (customTitle != null && customTitle.isNotEmpty && customTitle != url)
? customTitle
: defaultTitle,
));
}
// 1. Process HTML anchors
if (text.contains('<a ')) {
for (final match in _htmlLinkRegex.allMatches(text)) {
final href = match.group(1);
final rawTitle = match.group(2);
final cleanTitle =
rawTitle?.replaceAll(RegExp(r'<[^>]*>'), '').trim() ?? '';
if (href != null && href.isNotEmpty) {
processUrl(href, customTitle: cleanTitle.isNotEmpty ? cleanTitle : null);
}
}
}
// 2. Process all plain URLs
for (final match in _urlRegex.allMatches(text)) {
final url = match.group(0);
if (url != null) processUrl(url);
}
return results;
}
static (CreatorLinkType, String) _classifyHost(Uri uri) {
final host = uri.host.toLowerCase();
// YouTube
if (host.contains('youtube.com') || host.contains('youtu.be')) {
return (CreatorLinkType.youtube, 'YouTube');
}
// X / Twitter
if (host.contains('twitter.com') || host.contains('x.com') || host == 't.co') {
return (CreatorLinkType.twitter, 'X (Twitter)');
}
// Instagram
if (host.contains('instagram.com') || host.contains('instagr.am')) {
return (CreatorLinkType.instagram, 'Instagram');
}
// TikTok
if (host.contains('tiktok.com')) {
return (CreatorLinkType.tiktok, 'TikTok');
}
// Discord
if (host.contains('discord.gg') || host.contains('discord.com')) {
return (CreatorLinkType.discord, 'Discord');
}
// Pixiv
if (host.contains('pixiv.net')) {
return (CreatorLinkType.pixiv, 'Pixiv');
}
// Fanbox
if (host.contains('fanbox.cc')) {
return (CreatorLinkType.fanbox, 'Fanbox');
}
// Patreon
if (host.contains('patreon.com')) {
return (CreatorLinkType.patreon, 'Patreon');
}
// Fantia
if (host.contains('fantia.jp')) {
return (CreatorLinkType.fantia, 'Fantia');
}
// Boosty
if (host.contains('boosty.to')) {
return (CreatorLinkType.boosty, 'Boosty');
}
// Twitch
if (host.contains('twitch.tv')) {
return (CreatorLinkType.twitch, 'Twitch');
}
// Telegram
if (host.contains('t.me') || host.contains('telegram.me')) {
return (CreatorLinkType.telegram, 'Telegram');
}
// Gumroad
if (host.contains('gumroad.com')) {
return (CreatorLinkType.gumroad, 'Gumroad');
}
// SubscribeStar
if (host.contains('subscribestar.adult') || host.contains('subscribestar.com')) {
return (CreatorLinkType.subscribestar, 'SubscribeStar');
}
// ArtStation
if (host.contains('artstation.com')) {
return (CreatorLinkType.artstation, 'ArtStation');
}
// DeviantArt
if (host.contains('deviantart.com')) {
return (CreatorLinkType.deviantart, 'DeviantArt');
}
// Link hubs
if (host.contains('linktr.ee') || host.contains('carrd.co') || host.contains('lit.link')) {
return (CreatorLinkType.linkhub, 'Links Hub');
}
// Default clean host name
var cleanHost = host.replaceFirst(RegExp(r'^www\.'), '');
if (cleanHost.length > 25) {
cleanHost = cleanHost.substring(0, 25);
}
return (CreatorLinkType.other, cleanHost.isNotEmpty ? cleanHost : 'Web Link');
}
}
@@ -0,0 +1,68 @@
class FavoriteArtistItem {
const FavoriteArtistItem({
required this.id,
required this.service,
required this.providerId,
required this.name,
this.avatarUrl,
});
const FavoriteArtistItem.empty()
: id = '',
service = '',
providerId = '',
name = '',
avatarUrl = null;
final String id;
final String service;
final String providerId;
final String name;
final String? avatarUrl;
bool get isEmpty => id.isEmpty;
String get key => '$providerId:$service:$id';
Map<String, dynamic> toJson() => {
'id': id,
'service': service,
'providerId': providerId,
'name': name,
'avatarUrl': avatarUrl,
};
factory FavoriteArtistItem.fromJson(Map<String, dynamic> json) =>
FavoriteArtistItem(
id: (json['id'] ?? '').toString(),
service: (json['service'] ?? '').toString(),
providerId: (json['providerId'] ?? '').toString(),
name: (json['name'] ?? '').toString(),
avatarUrl: json['avatarUrl'] as String?,
);
FavoriteArtistItem copyWith({
String? id,
String? service,
String? providerId,
String? name,
String? avatarUrl,
}) {
return FavoriteArtistItem(
id: id ?? this.id,
service: service ?? this.service,
providerId: providerId ?? this.providerId,
name: name ?? this.name,
avatarUrl: avatarUrl ?? this.avatarUrl,
);
}
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is FavoriteArtistItem &&
runtimeType == other.runtimeType &&
key == other.key;
@override
int get hashCode => key.hashCode;
}
+81
View File
@@ -0,0 +1,81 @@
class PawchiveAccount {
const PawchiveAccount({
required this.id,
required this.username,
required this.sessionCookie,
this.baseUrl = 'https://pawchive.pw',
required this.createdAt,
this.lastSyncedAt,
this.isActive = true,
this.syncedArtistsCount = 0,
});
final String id;
final String username;
final String sessionCookie;
final String baseUrl;
final DateTime createdAt;
final DateTime? lastSyncedAt;
final bool isActive;
final int syncedArtistsCount;
PawchiveAccount copyWith({
String? id,
String? username,
String? sessionCookie,
String? baseUrl,
DateTime? createdAt,
DateTime? lastSyncedAt,
bool? isActive,
int? syncedArtistsCount,
}) {
return PawchiveAccount(
id: id ?? this.id,
username: username ?? this.username,
sessionCookie: sessionCookie ?? this.sessionCookie,
baseUrl: baseUrl ?? this.baseUrl,
createdAt: createdAt ?? this.createdAt,
lastSyncedAt: lastSyncedAt ?? this.lastSyncedAt,
isActive: isActive ?? this.isActive,
syncedArtistsCount: syncedArtistsCount ?? this.syncedArtistsCount,
);
}
Map<String, dynamic> toJson() => {
'id': id,
'username': username,
'sessionCookie': sessionCookie,
'baseUrl': baseUrl,
'createdAt': createdAt.toIso8601String(),
'lastSyncedAt': lastSyncedAt?.toIso8601String(),
'isActive': isActive,
'syncedArtistsCount': syncedArtistsCount,
};
factory PawchiveAccount.fromJson(Map<String, dynamic> json) {
return PawchiveAccount(
id: (json['id'] ?? '').toString(),
username: (json['username'] ?? '').toString(),
sessionCookie: (json['sessionCookie'] ?? '').toString(),
baseUrl: (json['baseUrl'] ?? 'https://pawchive.pw').toString(),
createdAt: json['createdAt'] != null
? DateTime.tryParse(json['createdAt'].toString()) ?? DateTime.now()
: DateTime.now(),
lastSyncedAt: json['lastSyncedAt'] != null
? DateTime.tryParse(json['lastSyncedAt'].toString())
: null,
isActive: json['isActive'] as bool? ?? true,
syncedArtistsCount: (json['syncedArtistsCount'] as num?)?.toInt() ?? 0,
);
}
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is PawchiveAccount &&
runtimeType == other.runtimeType &&
id == other.id;
@override
int get hashCode => id.hashCode;
}
+45
View File
@@ -1,3 +1,6 @@
import 'cloud_media_link.dart';
import '../services/cloud_link_extractor.dart';
class Post { class Post {
const Post({ const Post({
required this.id, required this.id,
@@ -35,6 +38,48 @@ class Post {
String get cacheKey => '$providerId:$id'; String get cacheKey => '$providerId:$id';
List<CloudMediaLink> get cloudLinks {
final list = tagGroups['cloud_links'];
if (list != null && list.isNotEmpty) {
return list
.map((e) => CloudMediaLink.tryDecode(e))
.whereType<CloudMediaLink>()
.toList(growable: false);
}
if (source != null && source!.isNotEmpty) {
return CloudLinkExtractor.extractLinks(source: source);
}
return const [];
}
String? get commentary {
final list = tagGroups['description'];
if (list != null && list.isNotEmpty) {
return list.first;
}
return null;
}
List<String> get cleanTags {
return tags.where((tag) {
final t = tag.trim();
if (t.isEmpty) return false;
if (t == 'cloud_mirror') return false;
if (t.startsWith('{') && t.endsWith('}')) return false;
if (t.contains('"url"') || t.contains('"service"')) return false;
if (t.startsWith('http://') || t.startsWith('https://')) return false;
if (t.contains('mega.nz') ||
t.contains('drive.google.com') ||
t.contains('dropbox.com') ||
t.contains('pixeldrain.com') ||
t.contains('catbox.moe') ||
t.contains('mediafire.com')) {
return false;
}
return true;
}).toList(growable: false);
}
Post copyWith({ Post copyWith({
String? id, String? id,
String? providerId, String? providerId,
+82 -8
View File
@@ -65,6 +65,7 @@ class GelbooruProvider
if (rating != null && rating.isNotEmpty) 'rating:$rating', if (rating != null && rating.isNotEmpty) 'rating:$rating',
..._topTags(topPeriod), ..._topTags(topPeriod),
]; ];
try {
final response = await _dio.get<dynamic>( final response = await _dio.get<dynamic>(
'/index.php', '/index.php',
queryParameters: { queryParameters: {
@@ -78,15 +79,20 @@ class GelbooruProvider
..._queryParameters, ..._queryParameters,
}, },
); );
_checkResponse(response);
return GelbooruMapper.postsFromResponse( return GelbooruMapper.postsFromResponse(
response.data, response.data,
providerId: id, providerId: id,
providerName: name, providerName: name,
); );
} on DioException catch (e) {
_handleDioError(e);
}
} }
@override @override
Future<Post?> getPost(String id) async { Future<Post?> getPost(String id) async {
try {
final response = await _dio.get<dynamic>( final response = await _dio.get<dynamic>(
'/index.php', '/index.php',
queryParameters: { queryParameters: {
@@ -98,12 +104,16 @@ class GelbooruProvider
..._queryParameters, ..._queryParameters,
}, },
); );
_checkResponse(response);
final posts = GelbooruMapper.postsFromResponse( final posts = GelbooruMapper.postsFromResponse(
response.data, response.data,
providerId: this.id, providerId: this.id,
providerName: name, providerName: name,
); );
return posts.isEmpty ? null : posts.first; return posts.isEmpty ? null : posts.first;
} on DioException catch (e) {
_handleDioError(e);
}
} }
@override @override
@@ -142,6 +152,7 @@ class GelbooruProvider
@override @override
Future<List<PostComment>> getComments(String postId) async { Future<List<PostComment>> getComments(String postId) async {
try {
final response = await _dio.get<dynamic>( final response = await _dio.get<dynamic>(
'/index.php', '/index.php',
queryParameters: { queryParameters: {
@@ -154,6 +165,9 @@ class GelbooruProvider
}, },
); );
return _commentsFromResponse(response.data, postId); return _commentsFromResponse(response.data, postId);
} catch (_) {
return const [];
}
} }
@override @override
@@ -161,6 +175,7 @@ class GelbooruProvider
{int limit = 20}) async { {int limit = 20}) async {
final trimmed = query.trim(); final trimmed = query.trim();
if (trimmed.isEmpty) return const []; if (trimmed.isEmpty) return const [];
try {
final response = await _dio.get<dynamic>( final response = await _dio.get<dynamic>(
'/index.php', '/index.php',
queryParameters: { queryParameters: {
@@ -168,7 +183,9 @@ class GelbooruProvider
's': 'tag', 's': 'tag',
'q': 'index', 'q': 'index',
'json': '1', 'json': '1',
'name_pattern': '$trimmed*', 'name_pattern': '$trimmed%',
'orderby': 'count',
'order': 'DESC',
'limit': limit.clamp(1, 100), 'limit': limit.clamp(1, 100),
..._queryParameters, ..._queryParameters,
}, },
@@ -188,6 +205,9 @@ class GelbooruProvider
}) })
.where((tag) => tag.name.isNotEmpty) .where((tag) => tag.name.isNotEmpty)
.toList(growable: false); .toList(growable: false);
} catch (_) {
return const [];
}
} }
@override @override
@@ -201,11 +221,43 @@ class GelbooruProvider
if (cleaned.isEmpty) return const {}; if (cleaned.isEmpty) return const {};
final groups = <String, List<String>>{}; final groups = <String, List<String>>{};
for (final tag in cleaned) { final tagToCategory = <String, TagCategory>{};
// Batch query tag metadata using Gelbooru DAPI "names" parameter
try { try {
final suggestions = await suggestTags(tag, limit: 3); for (var i = 0; i < cleaned.length; i += 50) {
final exact = suggestions.where((item) => item.name == tag).firstOrNull; final chunk = cleaned.sublist(
final category = exact?.category ?? TagCategory.general; i,
(i + 50 > cleaned.length) ? cleaned.length : i + 50,
);
final response = await _dio.get<dynamic>(
'/index.php',
queryParameters: {
'page': 'dapi',
's': 'tag',
'q': 'index',
'json': '1',
'names': chunk.join(' '),
'limit': chunk.length.clamp(1, 100),
..._queryParameters,
},
);
for (final item in _tagItems(response.data).whereType<Map>()) {
final json = Map<String, dynamic>.from(item);
final name = (json['name'] ?? '').toString().trim();
if (name.isNotEmpty) {
tagToCategory[name] = tagCategoryFromString(
(json['type'] ?? json['tag_type'] ?? json['category']).toString(),
);
}
}
}
} catch (_) {
// Gracefully continue to fallback categorization
}
for (final tag in cleaned) {
final category = tagToCategory[tag] ?? TagCategory.general;
final key = switch (category) { final key = switch (category) {
TagCategory.artist => 'artist', TagCategory.artist => 'artist',
TagCategory.copyright => 'copyright', TagCategory.copyright => 'copyright',
@@ -215,13 +267,35 @@ class GelbooruProvider
TagCategory.general || TagCategory.unknown => 'general', TagCategory.general || TagCategory.unknown => 'general',
}; };
groups.putIfAbsent(key, () => <String>[]).add(tag); groups.putIfAbsent(key, () => <String>[]).add(tag);
} catch (_) {
groups.putIfAbsent('general', () => <String>[]).add(tag);
}
} }
return groups; return groups;
} }
void _checkResponse(Response<dynamic> response) {
if (response.data is String) {
final text = (response.data as String).toLowerCase();
if (text.contains('throttled') ||
text.contains('authentication required') ||
text.contains('requires authentication')) {
unavailable(
'$name requires authentication or is throttled. '
'Please configure API Key & User ID in Settings -> Providers.',
);
}
}
}
Never _handleDioError(DioException error) {
final status = error.response?.statusCode;
if (status == 401 || status == 403 || status == 429) {
unavailable(
'$name requires authentication or rate limit exceeded ($status). '
'Please enter your API Key and User ID in Settings -> Providers.',
);
}
throw error;
}
Never unavailable(String message) => Never unavailable(String message) =>
throw ProviderUnavailableException(message); throw ProviderUnavailableException(message);
+57 -2
View File
@@ -10,6 +10,7 @@ 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/top_period_filter.dart'; import '../models/top_period_filter.dart';
import '../services/cloud_link_extractor.dart';
import 'content_provider.dart'; import 'content_provider.dart';
class KemonoProvider class KemonoProvider
@@ -361,6 +362,18 @@ class KemonoProvider
_dateFromAny(json['added']) ?? _dateFromAny(json['added']) ??
_dateFromAny(json['edited']) ?? _dateFromAny(json['edited']) ??
DateTime.fromMillisecondsSinceEpoch(0); DateTime.fromMillisecondsSinceEpoch(0);
final postSource =
'$baseUrl/${query.service}/user/${query.artistId}/post/$postId';
final rawContent = (json['content'] ?? '').toString();
final embed = json['embed'] is Map ? Map<String, dynamic>.from(json['embed']) : null;
final cloudLinks = CloudLinkExtractor.extractLinks(
content: rawContent,
source: postSource,
embed: embed,
);
final cloudLinksJson = cloudLinks.map((e) => e.encode()).toList(growable: false);
final cleanDesc = CloudLinkExtractor.cleanCommentary(rawContent);
final files = <Map<String, dynamic>>[]; final files = <Map<String, dynamic>>[];
final file = json['file']; final file = json['file'];
if (file is Map && file.isNotEmpty) { if (file is Map && file.isNotEmpty) {
@@ -370,6 +383,47 @@ class KemonoProvider
if (attachments is List) { if (attachments is List) {
files.addAll(attachments.whereType<Map>().map(Map<String, dynamic>.from)); files.addAll(attachments.whereType<Map>().map(Map<String, dynamic>.from));
} }
// Support link-only posts where author uploaded files to cloud drives (MEGA, GDrive, etc.)
if (files.isEmpty && (cloudLinks.isNotEmpty || cleanDesc.isNotEmpty)) {
final stableId = '${query.service}:${query.artistId}:$postId:0';
final firstStreamable =
cloudLinks.where((l) => l.isStreamable).firstOrNull;
final firstLink = cloudLinks.isNotEmpty ? cloudLinks.first : null;
final fileUrl = firstStreamable?.directStreamUrl ?? firstLink?.url ?? '';
final tags = [
query.service,
query.artistName,
if (title.trim().isNotEmpty) title.trim(),
'cloud_mirror',
];
return [
Post(
id: stableId,
providerId: id,
providerName: name,
previewUrl: '',
sampleUrl: '',
fileUrl: fileUrl,
tags: tags,
rating: 'unknown',
width: 0,
height: 0,
source: postSource,
createdAt: published,
fileType: firstStreamable != null ? 'video' : 'link',
score: 0,
tagGroups: {
'artist': [query.artistName],
'meta': [query.service],
if (title.trim().isNotEmpty) 'copyright': [title.trim()],
if (cloudLinksJson.isNotEmpty) 'cloud_links': cloudLinksJson,
if (cleanDesc.isNotEmpty) 'description': [cleanDesc],
},
),
];
}
var index = 0; var index = 0;
return files return files
.map((file) { .map((file) {
@@ -396,8 +450,7 @@ class KemonoProvider
rating: 'unknown', rating: 'unknown',
width: 0, width: 0,
height: 0, height: 0,
source: source: postSource,
'$baseUrl/${query.service}/user/${query.artistId}/post/$postId',
createdAt: published, createdAt: published,
fileType: type, fileType: type,
score: 0, score: 0,
@@ -405,6 +458,8 @@ class KemonoProvider
'artist': [query.artistName], 'artist': [query.artistName],
'meta': [query.service], 'meta': [query.service],
if (title.trim().isNotEmpty) 'copyright': [title.trim()], if (title.trim().isNotEmpty) 'copyright': [title.trim()],
if (cloudLinksJson.isNotEmpty) 'cloud_links': cloudLinksJson,
if (cleanDesc.isNotEmpty) 'description': [cleanDesc],
}, },
); );
}) })
+56 -2
View File
@@ -12,6 +12,7 @@ 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/top_period_filter.dart'; import '../models/top_period_filter.dart';
import '../services/cloud_link_extractor.dart';
import 'content_provider.dart'; import 'content_provider.dart';
class PawchiveProvider class PawchiveProvider
@@ -394,6 +395,18 @@ class PawchiveProvider
_dateFromAny(json['added']) ?? _dateFromAny(json['added']) ??
_dateFromAny(json['edited']) ?? _dateFromAny(json['edited']) ??
DateTime.fromMillisecondsSinceEpoch(0); DateTime.fromMillisecondsSinceEpoch(0);
final postSource =
'$baseUrl/${query.service}/user/${query.artistId}/post/$postId';
final rawContent = (json['content'] ?? '').toString();
final embed = json['embed'] is Map ? Map<String, dynamic>.from(json['embed']) : null;
final cloudLinks = CloudLinkExtractor.extractLinks(
content: rawContent,
source: postSource,
embed: embed,
);
final cloudLinksJson = cloudLinks.map((e) => e.encode()).toList(growable: false);
final cleanDesc = CloudLinkExtractor.cleanCommentary(rawContent);
final files = <Map<String, dynamic>>[]; final files = <Map<String, dynamic>>[];
final file = json['file']; final file = json['file'];
if (file is Map && file.isNotEmpty) { if (file is Map && file.isNotEmpty) {
@@ -404,6 +417,46 @@ class PawchiveProvider
files.addAll(attachments.whereType<Map>().map(Map<String, dynamic>.from)); files.addAll(attachments.whereType<Map>().map(Map<String, dynamic>.from));
} }
// Support link-only posts where author uploaded files to cloud drives (MEGA, GDrive, etc.)
if (files.isEmpty && (cloudLinks.isNotEmpty || cleanDesc.isNotEmpty)) {
final stableId = '${query.service}:${query.artistId}:$postId:0';
final firstStreamable =
cloudLinks.where((l) => l.isStreamable).firstOrNull;
final firstLink = cloudLinks.isNotEmpty ? cloudLinks.first : null;
final fileUrl = firstStreamable?.directStreamUrl ?? firstLink?.url ?? '';
final tags = [
query.service,
query.artistName,
if (title.trim().isNotEmpty) title.trim(),
'cloud_mirror',
];
return [
Post(
id: stableId,
providerId: id,
providerName: name,
previewUrl: '',
sampleUrl: '',
fileUrl: fileUrl,
tags: tags,
rating: 'unknown',
width: 0,
height: 0,
source: postSource,
createdAt: published,
fileType: firstStreamable != null ? 'video' : 'link',
score: 0,
tagGroups: {
'artist': [query.artistName],
'meta': [query.service],
if (title.trim().isNotEmpty) 'copyright': [title.trim()],
if (cloudLinksJson.isNotEmpty) 'cloud_links': cloudLinksJson,
if (cleanDesc.isNotEmpty) 'description': [cleanDesc],
},
),
];
}
var index = 0; var index = 0;
return files return files
.map((fileMap) { .map((fileMap) {
@@ -436,8 +489,7 @@ class PawchiveProvider
rating: 'unknown', rating: 'unknown',
width: 0, width: 0,
height: 0, height: 0,
source: source: postSource,
'$baseUrl/${query.service}/user/${query.artistId}/post/$postId',
createdAt: published, createdAt: published,
fileType: type, fileType: type,
score: 0, score: 0,
@@ -445,6 +497,8 @@ class PawchiveProvider
'artist': [query.artistName], 'artist': [query.artistName],
'meta': [query.service], 'meta': [query.service],
if (title.trim().isNotEmpty) 'copyright': [title.trim()], if (title.trim().isNotEmpty) 'copyright': [title.trim()],
if (cloudLinksJson.isNotEmpty) 'cloud_links': cloudLinksJson,
if (cleanDesc.isNotEmpty) 'description': [cleanDesc],
}, },
); );
}) })
+118
View File
@@ -1,5 +1,8 @@
import 'package:dio/dio.dart';
import '../mappers/rule34_mapper.dart'; import '../mappers/rule34_mapper.dart';
import '../models/post.dart'; import '../models/post.dart';
import '../models/tag_suggestion.dart';
import '../models/top_period_filter.dart'; import '../models/top_period_filter.dart';
import 'gelbooru_provider.dart'; import 'gelbooru_provider.dart';
@@ -36,6 +39,7 @@ class Rule34Provider extends GelbooruProvider {
TopPeriodFilter.month || TopPeriodFilter.year => const <String>[], TopPeriodFilter.month || TopPeriodFilter.year => const <String>[],
TopPeriodFilter.allTime => const ['sort:score:desc'], TopPeriodFilter.allTime => const ['sort:score:desc'],
}; };
try {
final response = await dio.get<dynamic>( final response = await dio.get<dynamic>(
'/index.php', '/index.php',
queryParameters: { queryParameters: {
@@ -45,6 +49,7 @@ class Rule34Provider extends GelbooruProvider {
'json': '1', 'json': '1',
'pid': page, 'pid': page,
'limit': limit, 'limit': limit,
'fields': 'tag_info',
'tags': [ 'tags': [
...tags, ...tags,
if (rating != null) 'rating:$rating', if (rating != null) 'rating:$rating',
@@ -53,10 +58,123 @@ class Rule34Provider extends GelbooruProvider {
...queryParameters, ...queryParameters,
}, },
); );
_checkRule34Response(response);
return Rule34Mapper.postsFromResponse( return Rule34Mapper.postsFromResponse(
response.data, response.data,
providerId: id, providerId: id,
providerName: name, providerName: name,
); );
} on DioException catch (e) {
_handleRule34DioError(e);
}
}
@override
Future<Post?> getPost(String id) async {
try {
final response = await dio.get<dynamic>(
'/index.php',
queryParameters: {
'page': 'dapi',
's': 'post',
'q': 'index',
'json': '1',
'id': id,
'fields': 'tag_info',
...queryParameters,
},
);
_checkRule34Response(response);
final posts = Rule34Mapper.postsFromResponse(
response.data,
providerId: this.id,
providerName: name,
);
return posts.isEmpty ? null : posts.first;
} on DioException catch (e) {
_handleRule34DioError(e);
}
}
@override
Future<List<TagSuggestion>> suggestTags(String query,
{int limit = 20}) async {
final trimmed = query.trim();
if (trimmed.isEmpty) return const [];
try {
// Official Rule34 autocomplete endpoint (fast, public, with exact post count)
final response = await dio.get<dynamic>(
'/autocomplete.php',
queryParameters: {
'q': trimmed,
},
);
if (response.data is List) {
final list = response.data as List;
final suggestions = list
.whereType<Map>()
.map((item) {
final json = Map<String, dynamic>.from(item);
final rawLabel = (json['label'] ?? '').toString();
final rawValue = (json['value'] ?? '').toString();
// Parse label "braid (216984)" -> name: "braid", postCount: 216984
final match =
RegExp(r'^(.*?)\s*\((\d+)\)$').firstMatch(rawLabel);
final name = (match?.group(1) ?? rawValue).trim();
final postCount = int.tryParse(match?.group(2) ?? '') ?? 0;
return TagSuggestion(
name: name.isNotEmpty ? name : rawValue,
category: TagCategory.general,
postCount: postCount,
providerId: id,
);
})
.where((tag) => tag.name.isNotEmpty)
.take(limit)
.toList(growable: false);
if (suggestions.isNotEmpty) return suggestions;
}
} catch (_) {
// Gracefully fall back to standard DAPI tag search
}
return super.suggestTags(query, limit: limit);
}
void _checkRule34Response(Response<dynamic> response) {
if (response.data is String) {
final text = (response.data as String).toLowerCase();
if (text.contains('missing authentication') ||
text.contains('throttled') ||
text.contains('authentication required')) {
unavailable(
'$name requires authentication. '
'Please configure your API Key and User ID in Settings -> Providers '
'(obtain from https://rule34.xxx/index.php?page=account&s=options).',
);
}
if (text.contains('search down') ||
text.contains('server is overloaded')) {
unavailable(
'$name searcher is temporarily down or overloaded. Please try again in a few minutes.',
);
}
} else if (response.data is Map) {
final map = response.data as Map;
if (map['success'] == false || map['success'] == 'false') {
final msg = map['message'] ?? 'search down';
unavailable('$name error: $msg');
}
}
}
Never _handleRule34DioError(DioException error) {
final status = error.response?.statusCode;
if (status == 401 || status == 403 || status == 429) {
unavailable(
'$name requires authentication or rate limit reached ($status). '
'Please enter your API Key and User ID in Settings -> Providers.',
);
}
throw error;
} }
} }
@@ -12,6 +12,15 @@ class SearchRepository {
Future<Result<void>> save(SearchHistory history, {int maxItems = 500}) { Future<Result<void>> save(SearchHistory history, {int maxItems = 500}) {
return _databaseService.safeWrite((isar) async { return _databaseService.safeWrite((isar) async {
final norm = history.query.trim().toLowerCase();
// Remove any existing duplicate records with same query
final all = await isar.searchHistoryEntitys.where().findAll();
for (final item in all) {
if (item.query.trim().toLowerCase() == norm) {
await isar.searchHistoryEntitys.delete(item.isarId);
}
}
await isar.searchHistoryEntitys.put( await isar.searchHistoryEntitys.put(
SearchHistoryEntity() SearchHistoryEntity()
..historyId = history.id ..historyId = history.id
@@ -42,13 +51,40 @@ class SearchRepository {
return _databaseService.safeRead((isar) async { return _databaseService.safeRead((isar) async {
final items = await isar.searchHistoryEntitys.where().findAll(); final items = await isar.searchHistoryEntitys.where().findAll();
items.sort((a, b) => b.searchedAt.compareTo(a.searchedAt)); items.sort((a, b) => b.searchedAt.compareTo(a.searchedAt));
final limited = limit == null ? items : items.take(limit);
final seen = <String>{};
final deduplicated = <SearchHistoryEntity>[];
for (final item in items) {
final key = item.query.trim().toLowerCase();
if (key.isEmpty) continue;
if (seen.add(key)) {
deduplicated.add(item);
}
}
final limited = limit == null ? deduplicated : deduplicated.take(limit);
return limited.map((entity) => entity.toModel()).toList(); return limited.map((entity) => entity.toModel()).toList();
}); });
} }
Future<Result<bool>> delete(String historyId) { Future<Result<bool>> delete(String historyId) {
return _databaseService.safeWrite((isar) async { return _databaseService.safeWrite((isar) async {
final target = await isar.searchHistoryEntitys
.where()
.filter()
.historyIdEqualTo(historyId)
.findFirst();
if (target != null) {
final norm = target.query.trim().toLowerCase();
final all = await isar.searchHistoryEntitys.where().findAll();
for (final item in all) {
if (item.query.trim().toLowerCase() == norm ||
item.historyId == historyId) {
await isar.searchHistoryEntitys.delete(item.isarId);
}
}
return true;
}
return await isar.searchHistoryEntitys.deleteByHistoryId(historyId); return await isar.searchHistoryEntitys.deleteByHistoryId(historyId);
}); });
} }
@@ -0,0 +1,340 @@
import '../models/cloud_media_link.dart';
class CloudLinkExtractor {
static final _urlRegex = RegExp(
r'''https?://[^\s<>"{}|\\^`\[\]]+''',
caseSensitive: false,
);
static final _htmlLinkRegex = RegExp(
r"""<a\s+[^>]*href=["']([^"']+)["'][^>]*>(.*?)</a>""",
caseSensitive: false,
dotAll: true,
);
static final _passwordRegex = RegExp(
r'(?:pass(?:word)?|pw|пароль|пас)(?:[\s:=_-]+)([^\s,;<>"\n\r]{2,50})',
caseSensitive: false,
);
/// Extracts any archive password mentioned in the raw text/HTML.
static String? extractPassword(String? text) {
if (text == null || text.trim().isEmpty) return null;
final match = _passwordRegex.firstMatch(text);
if (match != null && match.groupCount >= 1) {
final pass = match.group(1)?.trim();
if (pass != null &&
pass.isNotEmpty &&
!pass.startsWith('http') &&
pass.toLowerCase() != 'none') {
return pass;
}
}
return null;
}
/// Extracts all cloud media and video mirror links from text, HTML, and embeds.
static List<CloudMediaLink> extractLinks({
String? content,
String? source,
Map<String, dynamic>? embed,
}) {
final results = <CloudMediaLink>[];
final seenUrls = <String>{};
final detectedPass = extractPassword(content);
void addLink(String rawUrl, {String? customTitle}) {
final cleanUrl = _cleanUrl(rawUrl);
if (cleanUrl == null || seenUrls.contains(cleanUrl)) return;
seenUrls.add(cleanUrl);
final link = _classifyLink(cleanUrl, customTitle: customTitle, password: detectedPass);
if (link != null) {
results.add(link);
}
}
// 1. Process HTML anchors first to preserve anchor text / custom title
if (content != null && content.contains('<a ')) {
for (final match in _htmlLinkRegex.allMatches(content)) {
final href = match.group(1);
final rawTitle = match.group(2);
final cleanTitle = _stripHtml(rawTitle);
if (href != null && href.isNotEmpty) {
addLink(href, customTitle: cleanTitle.isNotEmpty ? cleanTitle : null);
}
}
}
// 2. Process all URLs in content text
if (content != null) {
for (final match in _urlRegex.allMatches(content)) {
final url = match.group(0);
if (url != null) addLink(url);
}
}
// 3. Process source
if (source != null && source.trim().isNotEmpty) {
for (final match in _urlRegex.allMatches(source)) {
final url = match.group(0);
if (url != null) addLink(url);
}
}
// 4. Process embed map
if (embed != null) {
final embedUrl = embed['url']?.toString();
if (embedUrl != null && embedUrl.isNotEmpty) {
final title = embed['title']?.toString();
addLink(embedUrl, customTitle: title);
}
}
return results;
}
static String? _cleanUrl(String raw) {
var url = raw.trim();
// Trim trailing punctuation like ), ., ,, ;, etc.
while (url.isNotEmpty && (url.endsWith(')') || url.endsWith('.') || url.endsWith(',') || url.endsWith(';') || url.endsWith('>'))) {
url = url.substring(0, url.length - 1);
}
final parsed = Uri.tryParse(url);
if (parsed == null || !parsed.hasScheme || (!parsed.scheme.startsWith('http'))) {
return null;
}
return url;
}
static String _stripHtml(String? html) {
if (html == null) return '';
return html.replaceAll(RegExp(r'<[^>]*>'), '').trim();
}
static CloudMediaLink? _classifyLink(
String url, {
String? customTitle,
String? password,
}) {
final uri = Uri.tryParse(url);
if (uri == null) return null;
final host = uri.host.toLowerCase();
final path = uri.path.toLowerCase();
// Google Drive
if (host.contains('drive.google.com')) {
final isFolder = path.contains('/folders/') || uri.queryParameters.containsKey('folder');
String? directStreamUrl;
final fileIdMatch = RegExp(r'/file/d/([a-zA-Z0-9_-]+)').firstMatch(uri.path);
final idParam = fileIdMatch?.group(1) ?? uri.queryParameters['id'];
if (idParam != null && !isFolder) {
directStreamUrl = 'https://drive.google.com/uc?export=download&id=$idParam';
}
return CloudMediaLink(
url: url,
service: CloudServiceType.googleDrive,
title: customTitle ?? (isFolder ? 'Google Drive Folder' : 'Google Drive File'),
directStreamUrl: directStreamUrl,
isFolder: isFolder,
isStreamable: directStreamUrl != null,
detectedPassword: password,
);
}
// MEGA
if (host.contains('mega.nz') || host.contains('mega.co.nz')) {
final isFolder = url.contains('/folder/') || url.contains('/#F!') || url.contains('#F!');
return CloudMediaLink(
url: url,
service: CloudServiceType.mega,
title: customTitle ?? (isFolder ? 'MEGA Cloud Folder' : 'MEGA Video / Archive'),
directStreamUrl: null, // End-to-end encrypted; launches in MEGA app/browser
isFolder: isFolder,
isStreamable: false,
detectedPassword: password,
);
}
// Dropbox
if (host.contains('dropbox.com')) {
final isFolder = path.contains('/sh/') || path.contains('/folder');
var direct = url;
if (direct.contains('?dl=0')) {
direct = direct.replaceAll('?dl=0', '?raw=1');
} else if (!direct.contains('?raw=1') && !isFolder) {
direct = direct.contains('?') ? '$direct&raw=1' : '$direct?raw=1';
}
return CloudMediaLink(
url: url,
service: CloudServiceType.dropbox,
title: customTitle ?? (isFolder ? 'Dropbox Folder' : 'Dropbox Media'),
directStreamUrl: isFolder ? null : direct,
isFolder: isFolder,
isStreamable: !isFolder,
detectedPassword: password,
);
}
// Pixeldrain
if (host.contains('pixeldrain.com')) {
final fileMatch = RegExp(r'/u/([a-zA-Z0-9_-]+)').firstMatch(uri.path);
final isList = path.contains('/l/');
final fileId = fileMatch?.group(1);
final direct = fileId != null ? 'https://pixeldrain.com/api/file/$fileId' : null;
return CloudMediaLink(
url: url,
service: CloudServiceType.pixeldrain,
title: customTitle ?? (isList ? 'Pixeldrain Gallery/List' : 'Pixeldrain Video'),
directStreamUrl: direct,
isFolder: isList,
isStreamable: direct != null,
detectedPassword: password,
);
}
// Catbox / Litterbox
if (host.contains('catbox.moe')) {
final isVideo = path.endsWith('.mp4') || path.endsWith('.webm') || path.endsWith('.mov') || path.endsWith('.m4v');
return CloudMediaLink(
url: url,
service: CloudServiceType.catbox,
title: customTitle ?? 'Catbox Upload',
directStreamUrl: url,
isFolder: false,
isStreamable: isVideo,
detectedPassword: password,
);
}
// MediaFire
if (host.contains('mediafire.com')) {
final isFolder = path.contains('/folder/') || path.contains('/?');
return CloudMediaLink(
url: url,
service: CloudServiceType.mediafire,
title: customTitle ?? (isFolder ? 'MediaFire Folder' : 'MediaFire Download'),
directStreamUrl: null,
isFolder: isFolder,
isStreamable: false,
detectedPassword: password,
);
}
// Bunkr
if (host.contains('bunkr.')) {
final isAlbum = path.contains('/a/') || path.contains('/v/');
return CloudMediaLink(
url: url,
service: CloudServiceType.bunkr,
title: customTitle ?? (isAlbum ? 'Bunkr Album' : 'Bunkr Media'),
directStreamUrl: null,
isFolder: isAlbum,
isStreamable: false,
detectedPassword: password,
);
}
// GoFile
if (host.contains('gofile.io')) {
return CloudMediaLink(
url: url,
service: CloudServiceType.gofile,
title: customTitle ?? 'GoFile Folder / Archive',
directStreamUrl: null,
isFolder: true,
isStreamable: false,
detectedPassword: password,
);
}
// TeraBox
if (host.contains('terabox') || host.contains('1024tera') || host.contains('dubox')) {
return CloudMediaLink(
url: url,
service: CloudServiceType.terabox,
title: customTitle ?? 'TeraBox Cloud Share',
directStreamUrl: null,
isFolder: true,
isStreamable: false,
detectedPassword: password,
);
}
// Direct Video Link (MP4, WEBM, MKV, MOV)
if (path.endsWith('.mp4') || path.endsWith('.webm') || path.endsWith('.mov') || path.endsWith('.m4v') || path.endsWith('.mkv')) {
final filename = uri.pathSegments.isNotEmpty ? uri.pathSegments.last : 'Video Stream';
return CloudMediaLink(
url: url,
service: CloudServiceType.genericVideo,
title: customTitle ?? filename,
directStreamUrl: url,
isFolder: false,
isStreamable: true,
detectedPassword: password,
);
}
// Ignore known booru and search engine domains
if (host.contains('kemono') ||
host.contains('coomer') ||
host.contains('danbooru') ||
host.contains('gelbooru') ||
host.contains('google.com') && !host.contains('drive') ||
host.contains('twitter.com') ||
host.contains('x.com') ||
host.contains('patreon.com') ||
host.contains('fanbox.cc') ||
host.contains('pixiv.net')) {
return null;
}
return null;
}
/// Extracts clean, readable text (stripping HTML tags and formatting links) suitable for author commentary and announcements.
static String cleanCommentary(String? rawHtml) {
if (rawHtml == null || rawHtml.trim().isEmpty) return '';
var text = rawHtml;
// Format anchor tags: <a href="URL">TEXT</a>
text = text.replaceAllMapped(
RegExp(r"""<a\s+[^>]*href=["']([^"']+)["'][^>]*>(.*?)</a>""",
caseSensitive: false, dotAll: true),
(match) {
final href = match.group(1)?.trim() ?? '';
final label = match.group(2)?.replaceAll(RegExp(r'<[^>]*>'), '').trim() ?? '';
if (label.isEmpty || label == href || href.contains(label)) {
return href;
}
return '$label ($href)';
},
);
// Convert line breaks and paragraph endings
text = text
.replaceAll(RegExp(r'<br\s*\/?>', caseSensitive: false), '\n')
.replaceAll(RegExp(r'<\/p\s*>', caseSensitive: false), '\n\n')
.replaceAll(RegExp(r'<\/div\s*>', caseSensitive: false), '\n')
.replaceAll(RegExp(r'<li\s*>', caseSensitive: false), '• ')
.replaceAll(RegExp(r'<\/li\s*>', caseSensitive: false), '\n');
// Strip remaining HTML tags
text = text.replaceAll(RegExp(r'<[^>]*>'), '');
// Decode HTML entities
text = text
.replaceAll('&nbsp;', ' ')
.replaceAll('&amp;', '&')
.replaceAll('&lt;', '<')
.replaceAll('&gt;', '>')
.replaceAll('&quot;', '"')
.replaceAll('&#39;', "'")
.replaceAll('&apos;', "'");
// Normalize multiple consecutive blank lines
text = text.replaceAll(RegExp(r'\n{3,}'), '\n\n');
return text.trim();
}
}
@@ -0,0 +1,530 @@
import 'dart:convert';
import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/http/dio_client.dart';
import '../models/favorite_artist_item.dart';
import '../models/pawchive_account.dart';
import 'settings_service.dart';
final pawchiveSyncServiceProvider = Provider<PawchiveSyncService>((ref) {
return PawchiveSyncService(dio: DioClient().dio);
});
class PawchiveSyncResult {
const PawchiveSyncResult({
required this.updatedSettings,
required this.newlyAddedToLocal,
required this.totalSyncedCount,
this.pushedToRemoteCount = 0,
this.errorMessage,
});
final AppSettings updatedSettings;
final int newlyAddedToLocal;
final int totalSyncedCount;
final int pushedToRemoteCount;
final String? errorMessage;
bool get isSuccess => errorMessage == null;
}
class PawchivePushResult {
const PawchivePushResult({
required this.pushedCount,
required this.totalRemoteCount,
required this.totalLocalCandidates,
this.updatedSettings,
this.errorMessage,
});
final int pushedCount;
final int totalRemoteCount;
final int totalLocalCandidates;
final AppSettings? updatedSettings;
final String? errorMessage;
bool get isSuccess => errorMessage == null;
}
class PawchiveSyncService {
PawchiveSyncService({Dio? dio}) : _dio = dio ?? Dio();
final Dio _dio;
static String _normalizeCookie(String rawCookie) {
var c = rawCookie.trim();
if (c.startsWith('session=')) {
c = c.substring('session='.length);
}
// strip semicolons if trailing
final semiIdx = c.indexOf(';');
if (semiIdx != -1) {
c = c.substring(0, semiIdx);
}
return c.trim();
}
/// Logs into Pawchive with username & password, returns new PawchiveAccount on success.
Future<PawchiveAccount> loginWithCredentials({
required String username,
required String password,
String baseUrl = 'https://pawchive.pw',
}) async {
final cleanUser = username.trim();
final cleanPass = password.trim();
if (cleanUser.isEmpty || cleanPass.isEmpty) {
throw ArgumentError('Логин и пароль не могут быть пустыми');
}
final normalizedBase = baseUrl.endsWith('/')
? baseUrl.substring(0, baseUrl.length - 1)
: baseUrl;
try {
final response = await _dio.post<dynamic>(
'$normalizedBase/account/login',
data: {
'username': cleanUser,
'password': cleanPass,
'location': '/artists',
},
options: Options(
contentType: Headers.formUrlEncodedContentType,
followRedirects: false,
validateStatus: (status) => status != null && status < 500,
headers: const {
'User-Agent': 'Lunaris/2.0.1 Flutter local booru browser',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
},
),
);
final redirectLocation = response.headers.value('location') ?? '';
final setCookies = response.headers['set-cookie'] ?? const [];
String? sessionValue;
for (final cookie in setCookies) {
final match = RegExp(r'session=([^;]+)').firstMatch(cookie);
if (match != null) {
sessionValue = match.group(1);
break;
}
}
if (sessionValue == null || sessionValue.isEmpty) {
throw StateError('Не удалось получить сессию. Проверьте данные для входа.');
}
// Check if redirect points back to login with flash error
if (redirectLocation.contains('/account/login') ||
redirectLocation.contains('/login')) {
throw StateError('Неверный логин или пароль');
}
// Check for flash errors in session cookie if present
try {
final dotIdx = sessionValue.indexOf('.');
final payloadBase64 =
dotIdx != -1 ? sessionValue.substring(0, dotIdx) : sessionValue;
final normalizedB64 = base64Url.normalize(payloadBase64);
final decoded = utf8.decode(base64Url.decode(normalizedB64));
if (decoded.contains('is incorrect') || decoded.contains('_flashes')) {
throw StateError('Неверный логин или пароль');
}
} catch (e) {
if (e is StateError) rethrow;
}
// Verify session and fetch initial artists
final remoteArtists = await fetchRemoteFavoriteArtists(
baseUrl: normalizedBase,
sessionCookie: sessionValue,
);
return PawchiveAccount(
id: cleanUser.toLowerCase(),
username: cleanUser,
sessionCookie: sessionValue,
baseUrl: normalizedBase,
createdAt: DateTime.now(),
lastSyncedAt: DateTime.now(),
isActive: true,
syncedArtistsCount: remoteArtists.length,
);
} on DioException catch (e) {
final code = e.response?.statusCode;
if (code == 401 || code == 403) {
throw StateError('Неверный логин или пароль');
} else if (code != null) {
throw StateError('Ошибка сервера Pawchive (HTTP $code)');
} else {
throw StateError('Ошибка сети: ${e.message ?? e.type.name}');
}
} catch (e) {
if (e is StateError || e is ArgumentError) rethrow;
throw StateError('Ошибка входа в Pawchive: $e');
}
}
/// Validates session cookie directly and creates PawchiveAccount.
Future<PawchiveAccount> loginWithSession({
required String sessionCookie,
required String username,
String baseUrl = 'https://pawchive.pw',
}) async {
final cleanUser = username.trim().isEmpty ? 'Pawchive User' : username.trim();
final cleanCookie = _normalizeCookie(sessionCookie);
if (cleanCookie.isEmpty) {
throw ArgumentError('Сессионная кука не может быть пустой');
}
final normalizedBase = baseUrl.endsWith('/')
? baseUrl.substring(0, baseUrl.length - 1)
: baseUrl;
try {
final remoteArtists = await fetchRemoteFavoriteArtists(
baseUrl: normalizedBase,
sessionCookie: cleanCookie,
);
return PawchiveAccount(
id: cleanUser.toLowerCase(),
username: cleanUser,
sessionCookie: cleanCookie,
baseUrl: normalizedBase,
createdAt: DateTime.now(),
lastSyncedAt: DateTime.now(),
isActive: true,
syncedArtistsCount: remoteArtists.length,
);
} on DioException catch (e) {
final code = e.response?.statusCode;
if (code == 401 || code == 403) {
throw StateError('Сессия недействительна или истекла');
} else if (code != null) {
throw StateError('Ошибка сервера Pawchive (HTTP $code)');
} else {
throw StateError('Ошибка сети: ${e.message ?? e.type.name}');
}
} catch (e) {
if (e is StateError || e is ArgumentError) rethrow;
throw StateError('Сессия недействительна или истекла: $e');
}
}
/// Fetches favorite creators from Pawchive account.
Future<List<FavoriteArtistItem>> fetchRemoteFavoriteArtists({
required String baseUrl,
required String sessionCookie,
}) async {
final cleanCookie = _normalizeCookie(sessionCookie);
final normalizedBase = baseUrl.endsWith('/')
? baseUrl.substring(0, baseUrl.length - 1)
: baseUrl;
final response = await _dio.get<dynamic>(
'$normalizedBase/api/v1/account/favorites',
queryParameters: {'type': 'artist'},
options: Options(
headers: {
'Cookie': 'session=$cleanCookie',
'Accept': 'application/json',
'User-Agent': 'Lunaris/2.0.1 Flutter local booru browser',
},
validateStatus: (status) => status != null && status < 500,
),
);
if (response.statusCode == 401 || response.statusCode == 403) {
throw StateError('Сессия истекла или недействительна');
}
if (response.statusCode != 200) {
throw StateError('Ошибка сервера Pawchive (HTTP ${response.statusCode})');
}
final data = response.data;
if (data is! List) return const [];
final list = <FavoriteArtistItem>[];
for (final raw in data) {
if (raw is! Map) continue;
final item = Map<String, dynamic>.from(raw);
final id = (item['id'] ?? '').toString();
final service = (item['service'] ?? '').toString();
final name = (item['name'] ?? id).toString();
if (id.isEmpty || service.isEmpty) continue;
final avatarUrl = '$normalizedBase/icons/$service/$id';
list.add(FavoriteArtistItem(
id: id,
service: service,
providerId: 'pawchive',
name: name,
avatarUrl: avatarUrl,
));
}
return list;
}
/// Synchronizes a specific Pawchive account with current local AppSettings.
/// If [pushLocalPawchiveArtists] is true (or [settings.pawchiveBidirectionalSync] is enabled),
/// local favorites are also pushed to the Pawchive server.
Future<PawchiveSyncResult> syncAccountFavorites({
required PawchiveAccount account,
required AppSettings settings,
bool? pushLocalPawchiveArtists,
}) async {
try {
final remoteArtists = await fetchRemoteFavoriteArtists(
baseUrl: account.baseUrl,
sessionCookie: account.sessionCookie,
);
final currentFavorites = List<String>.from(settings.favoriteArtists);
final existingKeys = <String>{};
final existingServiceId = <String>{};
for (final raw in currentFavorites) {
try {
final parsed = FavoriteArtistItem.fromJson(
jsonDecode(raw) as Map<String, dynamic>,
);
existingKeys.add(parsed.key);
if (parsed.service.isNotEmpty && parsed.id.isNotEmpty) {
existingServiceId.add('${parsed.service}:${parsed.id}'.toLowerCase());
}
} catch (_) {}
}
var addedCount = 0;
for (final artist in remoteArtists) {
final pair = '${artist.service}:${artist.id}'.toLowerCase();
if (!existingKeys.contains(artist.key) && !existingServiceId.contains(pair)) {
currentFavorites.insert(0, jsonEncode(artist.toJson()));
existingKeys.add(artist.key);
existingServiceId.add(pair);
addedCount++;
}
}
var pushedCount = 0;
final shouldPush =
pushLocalPawchiveArtists ?? settings.pawchiveBidirectionalSync;
if (shouldPush) {
final remotePairSet = remoteArtists
.map((a) => '${a.service}:${a.id}'.toLowerCase())
.toSet();
for (final raw in currentFavorites) {
try {
final parsed = FavoriteArtistItem.fromJson(
jsonDecode(raw) as Map<String, dynamic>,
);
if (parsed.service.isNotEmpty && parsed.id.isNotEmpty) {
final pair = '${parsed.service}:${parsed.id}'.toLowerCase();
if (!remotePairSet.contains(pair)) {
final ok = await toggleRemoteFavorite(
account: account,
service: parsed.service,
artistId: parsed.id,
isFavorite: true,
);
if (ok) {
pushedCount++;
remotePairSet.add(pair);
}
}
}
} catch (_) {}
}
}
// Update account sync stats in settings
final updatedAccount = account.copyWith(
lastSyncedAt: DateTime.now(),
syncedArtistsCount: remoteArtists.length + pushedCount,
);
final updatedAccountsList = settings.parsedPawchiveAccounts.map((a) {
return a.id == updatedAccount.id ? updatedAccount : a;
}).toList();
final newSettings = settings.copyWith(
favoriteArtists: currentFavorites,
pawchiveAccounts:
updatedAccountsList.map((a) => jsonEncode(a.toJson())).toList(),
);
return PawchiveSyncResult(
updatedSettings: newSettings,
newlyAddedToLocal: addedCount,
pushedToRemoteCount: pushedCount,
totalSyncedCount: remoteArtists.length + pushedCount,
);
} catch (e) {
return PawchiveSyncResult(
updatedSettings: settings,
newlyAddedToLocal: 0,
pushedToRemoteCount: 0,
totalSyncedCount: 0,
errorMessage: e.toString(),
);
}
}
/// Explicitly pushes all local favorite artists into a specified Pawchive account.
Future<PawchivePushResult> pushLocalFavoritesToAccount({
required PawchiveAccount account,
required AppSettings settings,
}) async {
try {
final remoteArtists = await fetchRemoteFavoriteArtists(
baseUrl: account.baseUrl,
sessionCookie: account.sessionCookie,
);
final remotePairSet = remoteArtists
.map((a) => '${a.service}:${a.id}'.toLowerCase())
.toSet();
var pushedCount = 0;
var totalLocalCandidates = 0;
for (final raw in settings.favoriteArtists) {
try {
final parsed = FavoriteArtistItem.fromJson(
jsonDecode(raw) as Map<String, dynamic>,
);
if (parsed.service.isNotEmpty && parsed.id.isNotEmpty) {
totalLocalCandidates++;
final pair = '${parsed.service}:${parsed.id}'.toLowerCase();
if (!remotePairSet.contains(pair)) {
final ok = await toggleRemoteFavorite(
account: account,
service: parsed.service,
artistId: parsed.id,
isFavorite: true,
);
if (ok) {
pushedCount++;
remotePairSet.add(pair);
}
}
}
} catch (_) {}
}
final updatedAccount = account.copyWith(
lastSyncedAt: DateTime.now(),
syncedArtistsCount: remoteArtists.length + pushedCount,
);
final updatedAccountsList = settings.parsedPawchiveAccounts.map((a) {
return a.id == updatedAccount.id ? updatedAccount : a;
}).toList();
final newSettings = settings.copyWith(
pawchiveAccounts:
updatedAccountsList.map((a) => jsonEncode(a.toJson())).toList(),
);
return PawchivePushResult(
pushedCount: pushedCount,
totalRemoteCount: remoteArtists.length + pushedCount,
totalLocalCandidates: totalLocalCandidates,
updatedSettings: newSettings,
);
} catch (e) {
return PawchivePushResult(
pushedCount: 0,
totalRemoteCount: 0,
totalLocalCandidates: 0,
errorMessage: e.toString(),
);
}
}
/// Synchronizes ALL connected Pawchive accounts with local AppSettings.
Future<PawchiveSyncResult> syncAllAccounts({
required AppSettings settings,
bool? pushLocalArtists,
}) async {
final accounts = settings.parsedPawchiveAccounts;
if (accounts.isEmpty) {
return PawchiveSyncResult(
updatedSettings: settings,
newlyAddedToLocal: 0,
pushedToRemoteCount: 0,
totalSyncedCount: 0,
errorMessage: 'Нет добавленных аккаунтов Pawchive',
);
}
final doPush = pushLocalArtists ?? settings.pawchiveBidirectionalSync;
var runningSettings = settings;
var totalAdded = 0;
var totalSynced = 0;
var totalPushed = 0;
String? firstError;
for (final account in accounts) {
final res = await syncAccountFavorites(
account: account,
settings: runningSettings,
pushLocalPawchiveArtists: doPush,
);
if (res.isSuccess) {
runningSettings = res.updatedSettings;
totalAdded += res.newlyAddedToLocal;
totalPushed += res.pushedToRemoteCount;
totalSynced += res.totalSyncedCount;
} else {
firstError ??= res.errorMessage;
}
}
return PawchiveSyncResult(
updatedSettings: runningSettings,
newlyAddedToLocal: totalAdded,
pushedToRemoteCount: totalPushed,
totalSyncedCount: totalSynced,
errorMessage: firstError,
);
}
/// Toggles favorite creator on Pawchive server (POST or DELETE).
Future<bool> toggleRemoteFavorite({
required PawchiveAccount account,
required String service,
required String artistId,
required bool isFavorite,
}) async {
final cleanCookie = _normalizeCookie(account.sessionCookie);
final normalizedBase = account.baseUrl.endsWith('/')
? account.baseUrl.substring(0, account.baseUrl.length - 1)
: account.baseUrl;
final path = '$normalizedBase/api/v1/favorites/creator/$service/$artistId';
final options = Options(
headers: {
'Cookie': 'session=$cleanCookie',
'User-Agent': 'Lunaris/2.0.1 Flutter local booru browser',
},
validateStatus: (status) =>
status != null && (status == 200 || status == 201 || status == 204),
);
try {
if (isFavorite) {
final response = await _dio.post<dynamic>(path, options: options);
return response.statusCode != null && response.statusCode! < 300;
} else {
final response = await _dio.delete<dynamic>(path, options: options);
return response.statusCode != null && response.statusCode! < 300;
}
} catch (_) {
return false;
}
}
}
@@ -7,6 +7,7 @@ import '../../core/database/database_service.dart';
import '../../core/errors/failure.dart'; import '../../core/errors/failure.dart';
import '../../core/utils/result.dart'; import '../../core/utils/result.dart';
import '../models/content_provider_config.dart'; import '../models/content_provider_config.dart';
import '../models/pawchive_account.dart';
import '../models/provider_diagnostics.dart'; import '../models/provider_diagnostics.dart';
import '../repositories/provider_repository.dart'; import '../repositories/provider_repository.dart';
@@ -49,6 +50,8 @@ class AppSettings {
required this.lastFeedTopPeriod, required this.lastFeedTopPeriod,
required this.lastFeedScrollOffset, required this.lastFeedScrollOffset,
this.favoriteArtists = const [], this.favoriteArtists = const [],
this.pawchiveAccounts = const [],
this.pawchiveBidirectionalSync = false,
this.skippedUpdateVersion, this.skippedUpdateVersion,
this.lastUpdateCheckAt, this.lastUpdateCheckAt,
this.defaultRatingFilter, this.defaultRatingFilter,
@@ -63,6 +66,8 @@ class AppSettings {
}); });
final List<String> favoriteArtists; final List<String> favoriteArtists;
final List<String> pawchiveAccounts;
final bool pawchiveBidirectionalSync;
final List<String> enabledProviderIds; final List<String> enabledProviderIds;
final bool nsfwEnabled; final bool nsfwEnabled;
final int cacheTtlHours; final int cacheTtlHours;
@@ -118,6 +123,8 @@ class AppSettings {
cacheMaxItems: 2000, cacheMaxItems: 2000,
providerPriority: {'gelbooru': 0, 'rule34': 1, 'realbooru': 2}, providerPriority: {'gelbooru': 0, 'rule34': 1, 'realbooru': 2},
favoriteArtists: [], favoriteArtists: [],
pawchiveAccounts: [],
pawchiveBidirectionalSync: false,
themeMode: 'dark', themeMode: 'dark',
languageCode: 'ru', languageCode: 'ru',
appSeedColor: 0xFFE84D8A, appSeedColor: 0xFFE84D8A,
@@ -200,6 +207,8 @@ class AppSettings {
String? lastFeedRating, String? lastFeedRating,
bool clearLastFeedRating = false, bool clearLastFeedRating = false,
List<String>? favoriteArtists, List<String>? favoriteArtists,
List<String>? pawchiveAccounts,
bool? pawchiveBidirectionalSync,
bool? amoledMode, bool? amoledMode,
bool? useDynamicColor, bool? useDynamicColor,
String? gridMode, String? gridMode,
@@ -210,6 +219,9 @@ class AppSettings {
}) { }) {
return AppSettings( return AppSettings(
favoriteArtists: favoriteArtists ?? this.favoriteArtists, favoriteArtists: favoriteArtists ?? this.favoriteArtists,
pawchiveAccounts: pawchiveAccounts ?? this.pawchiveAccounts,
pawchiveBidirectionalSync:
pawchiveBidirectionalSync ?? this.pawchiveBidirectionalSync,
enabledProviderIds: enabledProviderIds ?? this.enabledProviderIds, enabledProviderIds: enabledProviderIds ?? this.enabledProviderIds,
nsfwEnabled: nsfwEnabled ?? this.nsfwEnabled, nsfwEnabled: nsfwEnabled ?? this.nsfwEnabled,
cacheTtlHours: cacheTtlHours ?? this.cacheTtlHours, cacheTtlHours: cacheTtlHours ?? this.cacheTtlHours,
@@ -269,6 +281,8 @@ class AppSettings {
Map<String, dynamic> toJson() => { Map<String, dynamic> toJson() => {
'favoriteArtists': favoriteArtists, 'favoriteArtists': favoriteArtists,
'pawchiveAccounts': pawchiveAccounts,
'pawchiveBidirectionalSync': pawchiveBidirectionalSync,
'enabledProviderIds': enabledProviderIds, 'enabledProviderIds': enabledProviderIds,
'nsfwEnabled': nsfwEnabled, 'nsfwEnabled': nsfwEnabled,
'cacheTtlHours': cacheTtlHours, 'cacheTtlHours': cacheTtlHours,
@@ -411,6 +425,12 @@ class AppSettings {
favoriteArtists: List<String>.from( favoriteArtists: List<String>.from(
(json['favoriteArtists'] as List?) ?? defaults.favoriteArtists, (json['favoriteArtists'] as List?) ?? defaults.favoriteArtists,
), ),
pawchiveAccounts: List<String>.from(
(json['pawchiveAccounts'] as List?) ?? defaults.pawchiveAccounts,
),
pawchiveBidirectionalSync:
(json['pawchiveBidirectionalSync'] as bool?) ??
defaults.pawchiveBidirectionalSync,
skippedUpdateVersion: json['skippedUpdateVersion'] as String?, skippedUpdateVersion: json['skippedUpdateVersion'] as String?,
lastUpdateCheckAt: json['lastUpdateCheckAt'] as String?, lastUpdateCheckAt: json['lastUpdateCheckAt'] as String?,
defaultRatingFilter: json['defaultRatingFilter'] as String?, defaultRatingFilter: json['defaultRatingFilter'] as String?,
@@ -428,6 +448,25 @@ class AppSettings {
tagCacheLimit: (json['tagCacheLimit'] as num?)?.toInt() ?? tagCacheLimit: (json['tagCacheLimit'] as num?)?.toInt() ??
defaults.tagCacheLimit, defaults.tagCacheLimit,
); );
List<PawchiveAccount> get parsedPawchiveAccounts {
final list = <PawchiveAccount>[];
for (final raw in pawchiveAccounts) {
try {
final decoded = jsonDecode(raw);
if (decoded is Map<String, dynamic>) {
list.add(PawchiveAccount.fromJson(decoded));
}
} catch (_) {}
}
return list;
}
PawchiveAccount? get activePawchiveAccount {
final accounts = parsedPawchiveAccounts;
if (accounts.isEmpty) return null;
return accounts.firstWhere((a) => a.isActive, orElse: () => accounts.first);
}
} }
class SettingsService { class SettingsService {
+106 -11
View File
@@ -1,31 +1,79 @@
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'dart:io'; import 'dart:io';
import 'package:flutter/services.dart';
import '../../app/app_version.dart'; import '../../app/app_version.dart';
import '../../core/errors/failure.dart'; import '../../core/errors/failure.dart';
import '../../core/utils/result.dart'; import '../../core/utils/result.dart';
import '../models/app_update_info.dart'; import '../models/app_update_info.dart';
import 'settings_service.dart'; import 'settings_service.dart';
enum UpdateSource {
gitea,
github,
}
class UpdateService { class UpdateService {
UpdateService(this._dio, this._settingsService); UpdateService(this._dio, this._settingsService);
static const latestReleaseUrl = static const giteaLatestReleaseUrl =
'https://gitea.rardogsynapse.online/api/v1/repos/RarDog/Lunaris/releases/latest'; 'https://gitea.rardogsynapse.online/api/v1/repos/RarDog/Lunaris/releases/latest';
static const releasesUrl = static const giteaReleasesUrl =
'https://gitea.rardogsynapse.online/api/v1/repos/RarDog/Lunaris/releases'; 'https://gitea.rardogsynapse.online/api/v1/repos/RarDog/Lunaris/releases';
static const githubLatestReleaseUrl =
'https://api.github.com/repos/RarDog/Lunaris/releases/latest';
static const githubReleasesUrl =
'https://api.github.com/repos/RarDog/Lunaris/releases';
static const latestReleaseUrl = githubLatestReleaseUrl;
static const releasesUrl = githubReleasesUrl;
static const _deviceChannel = MethodChannel('rulegel/device');
final Dio _dio; final Dio _dio;
final SettingsService _settingsService; final SettingsService _settingsService;
Future<Result<AppUpdateInfo?>> checkForUpdates({bool force = false}) async { // Cached primary ABI (e.g. "arm64-v8a").
String? _cachedPrimaryAbi;
/// Returns the primary ABI of the running device.
/// On non-Android platforms returns an empty string.
Future<String> getPrimaryAbi() async {
if (_cachedPrimaryAbi != null) return _cachedPrimaryAbi!;
if (!Platform.isAndroid) return '';
try {
final abi = await _deviceChannel.invokeMethod<String>('getPrimaryAbi');
_cachedPrimaryAbi = abi ?? 'armeabi-v7a';
} catch (_) {
_cachedPrimaryAbi = 'armeabi-v7a';
}
return _cachedPrimaryAbi!;
}
/// Returns all supported ABIs of the running device.
Future<List<String>> getSupportedAbis() async {
if (!Platform.isAndroid) return const [];
try {
final abis = await _deviceChannel.invokeMethod<List<Object?>>('getSupportedAbis');
return abis?.whereType<String>().toList() ?? const [];
} catch (_) {
return const [];
}
}
Future<Result<AppUpdateInfo?>> checkForUpdates({
bool force = false,
UpdateSource source = UpdateSource.gitea,
}) async {
final settingsResult = await _settingsService.getSettings(); final settingsResult = await _settingsService.getSettings();
final settings = settingsResult is Success<AppSettings> final settings = settingsResult is Success<AppSettings>
? settingsResult.data ? settingsResult.data
: AppSettings.defaults; : AppSettings.defaults;
try { try {
final info = await _latestAllowedRelease(settings); final info = await _latestAllowedRelease(settings, source: source);
final nextSettings = settings.copyWith( final nextSettings = settings.copyWith(
lastUpdateCheckAt: DateTime.now().toIso8601String(), lastUpdateCheckAt: DateTime.now().toIso8601String(),
); );
@@ -49,10 +97,29 @@ class UpdateService {
} }
} }
Future<AppUpdateInfo> _latestAllowedRelease(AppSettings settings) async { Future<AppUpdateInfo> _latestAllowedRelease(
AppSettings settings, {
UpdateSource source = UpdateSource.gitea,
}) async {
final isGitea = source == UpdateSource.gitea;
final rUrl = isGitea ? giteaReleasesUrl : githubReleasesUrl;
final latestUrl = isGitea ? giteaLatestReleaseUrl : githubLatestReleaseUrl;
final options = Options(
headers: isGitea
? {
'Authorization': 'token 1c744d6044d756759d7b1f693c94c80cf70d75fa',
}
: {
'User-Agent': 'Lunaris-App',
'Accept': 'application/vnd.github+json',
},
);
final response = await _dio.get<dynamic>( final response = await _dio.get<dynamic>(
releasesUrl, rUrl,
queryParameters: {'limit': 30}, queryParameters: isGitea ? {'limit': 30} : {'per_page': 30},
options: options,
); );
final items = (response.data as List?) ?? const []; final items = (response.data as List?) ?? const [];
final releases = items final releases = items
@@ -63,7 +130,10 @@ class UpdateService {
.map((item) => _releaseFromJson(Map<String, dynamic>.from(item))) .map((item) => _releaseFromJson(Map<String, dynamic>.from(item)))
.toList(growable: false); .toList(growable: false);
if (releases.isEmpty) { if (releases.isEmpty) {
final latest = await _dio.get<dynamic>(latestReleaseUrl); final latest = await _dio.get<dynamic>(
latestUrl,
options: options,
);
return _releaseFromJson(Map<String, dynamic>.from(latest.data as Map)); return _releaseFromJson(Map<String, dynamic>.from(latest.data as Map));
} }
releases.sort((a, b) { releases.sort((a, b) {
@@ -97,21 +167,40 @@ class UpdateService {
AppUpdateInfo _releaseFromJson(Map<String, dynamic> json) { AppUpdateInfo _releaseFromJson(Map<String, dynamic> json) {
final tag = (json['tag_name'] ?? '').toString(); final tag = (json['tag_name'] ?? '').toString();
final assets = (json['assets'] as List?) ?? const []; final assets = (json['assets'] as List?) ?? const [];
String? apkUrl; String? apkUrl;
String? apkArm64Url;
String? apkArmv7Url;
String? apkX86_64Url;
String? installerUrl; String? installerUrl;
String? portableUrl; String? portableUrl;
String? linuxTarGzUrl; String? linuxTarGzUrl;
for (final item in assets.whereType<Map>()) { for (final item in assets.whereType<Map>()) {
final name = (item['name'] ?? '').toString().toLowerCase(); final name = (item['name'] ?? '').toString().toLowerCase();
final url = (item['browser_download_url'] ?? '').toString(); final url = (item['browser_download_url'] ?? '').toString();
if (url.isEmpty) continue; if (url.isEmpty) continue;
if (name.endsWith('.apk')) apkUrl = url;
if (name.endsWith('.apk')) {
// Detect per-ABI APKs by name pattern produced by --split-per-abi.
if (name.contains('arm64-v8a') || name.contains('arm64_v8a')) {
apkArm64Url = url;
} else if (name.contains('armeabi-v7a') || name.contains('armeabi_v7a')) {
apkArmv7Url = url;
} else if (name.contains('x86_64')) {
apkX86_64Url = url;
} else {
// Universal / fat APK
apkUrl = url;
}
}
if (name.endsWith('.exe')) installerUrl = url; if (name.endsWith('.exe')) installerUrl = url;
if (name.endsWith('.zip')) portableUrl = url; if (name.endsWith('.zip')) portableUrl = url;
if (name.endsWith('.tar.gz') || name.endsWith('.tgz')) { if (name.endsWith('.tar.gz') || name.endsWith('.tgz')) {
linuxTarGzUrl = url; linuxTarGzUrl = url;
} }
} }
return AppUpdateInfo( return AppUpdateInfo(
version: _versionFromTag(tag), version: _versionFromTag(tag),
tagName: tag, tagName: tag,
@@ -120,15 +209,21 @@ class UpdateService {
htmlUrl: (json['html_url'] ?? '').toString(), htmlUrl: (json['html_url'] ?? '').toString(),
publishedAt: DateTime.tryParse((json['published_at'] ?? '').toString()), publishedAt: DateTime.tryParse((json['published_at'] ?? '').toString()),
apkUrl: apkUrl, apkUrl: apkUrl,
apkArm64Url: apkArm64Url,
apkArmv7Url: apkArmv7Url,
apkX86_64Url: apkX86_64Url,
windowsInstallerUrl: installerUrl, windowsInstallerUrl: installerUrl,
portableZipUrl: portableUrl, portableZipUrl: portableUrl,
linuxTarGzUrl: linuxTarGzUrl, linuxTarGzUrl: linuxTarGzUrl,
); );
} }
String? assetUrlForCurrentPlatform(AppUpdateInfo info) { /// Returns the download URL for the current platform/ABI.
/// Pass [abi] (e.g. from [getPrimaryAbi]) on Android for per-ABI selection.
Future<String?> assetUrlForCurrentPlatform(AppUpdateInfo info) async {
if (Platform.isAndroid) { if (Platform.isAndroid) {
return info.apkUrl; final abi = await getPrimaryAbi();
return info.apkUrlForAbi(abi);
} }
if (Platform.isLinux) { if (Platform.isLinux) {
return info.linuxTarGzUrl ?? info.portableZipUrl; return info.linuxTarGzUrl ?? info.portableZipUrl;
@@ -1,6 +1,9 @@
import 'dart:async';
import 'dart:convert';
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 'package:url_launcher/url_launcher.dart';
import '../../../app/app.dart'; import '../../../app/app.dart';
import '../../../app/responsive.dart'; import '../../../app/responsive.dart';
@@ -9,8 +12,10 @@ import '../../../core/utils/result.dart';
import '../../../shared/widgets/adaptive_scaffold.dart'; import '../../../shared/widgets/adaptive_scaffold.dart';
import '../../../shared/widgets/empty_view.dart'; import '../../../shared/widgets/empty_view.dart';
import '../../../shared/widgets/error_view.dart'; import '../../../shared/widgets/error_view.dart';
import '../../../shared/widgets/formatted_content_text.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 '../../settings/presentation/settings_controller.dart';
final artistPostsProvider = final artistPostsProvider =
FutureProvider.family<List<Post>, ArtistWorkQuery>((ref, query) async { FutureProvider.family<List<Post>, ArtistWorkQuery>((ref, query) async {
@@ -230,6 +235,10 @@ class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
separatorBuilder: (_, __) => const Divider(height: 24), separatorBuilder: (_, __) => const Divider(height: 24),
itemBuilder: (context, index) { itemBuilder: (context, index) {
final a = _announcements[index]; final a = _announcements[index];
final cleanContent = CloudLinkExtractor.cleanCommentary(a.content);
final detectedCloudLinks = CloudLinkExtractor.extractLinks(content: a.content);
final creatorLinks = CreatorLink.extractLinks(a.content);
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@@ -243,10 +252,43 @@ class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
), ),
), ),
const SizedBox(height: 6), const SizedBox(height: 6),
SelectableText( FormattedContentText(
a.content, text: cleanContent.isNotEmpty ? cleanContent : a.content,
style: theme.textTheme.bodyMedium, style: theme.textTheme.bodyMedium?.copyWith(
height: 1.45,
), ),
),
if (creatorLinks.isNotEmpty) ...[
const SizedBox(height: 10),
CreatorLinkChips(
links: creatorLinks,
title: 'Ссылки автора',
),
],
if (detectedCloudLinks.isNotEmpty) ...[
const SizedBox(height: 10),
Wrap(
spacing: 8,
runSpacing: 6,
children: detectedCloudLinks.map((link) {
return ActionChip(
avatar: Icon(
link.iconData,
size: 16,
color: link.brandColor,
),
label: Text(
link.title.isNotEmpty ? link.title : link.serviceName,
style: const TextStyle(fontSize: 12),
),
onPressed: () => launchUrl(
Uri.parse(link.url),
mode: LaunchMode.externalApplication,
),
);
}).toList(),
),
],
], ],
); );
}, },
@@ -371,6 +413,54 @@ class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
); );
} }
Future<void> _toggleArtistFavorite(
AppSettings settings,
bool isCurrentlyFav,
) async {
final current = List<String>.from(settings.favoriteArtists);
final artistKey =
'${widget.providerId}:${widget.service}:${widget.artistId}';
if (isCurrentlyFav) {
current.removeWhere((e) {
try {
return FavoriteArtistItem.fromJson(
jsonDecode(e) as Map<String, dynamic>)
.key ==
artistKey;
} catch (_) {
return false;
}
});
} else {
final avatarUrl = widget.providerId == 'pawchive'
? 'https://pawchive.pw/icons/${widget.service}/${widget.artistId}'
: null;
final item = FavoriteArtistItem(
id: widget.artistId,
service: widget.service,
providerId: widget.providerId,
name: widget.artistName,
avatarUrl: avatarUrl,
);
current.insert(0, jsonEncode(item.toJson()));
}
await ref.read(settingsControllerProvider.notifier).saveSettings(
settings.copyWith(favoriteArtists: current),
);
if (widget.service.isNotEmpty && widget.artistId.isNotEmpty) {
final activeAcc = settings.activePawchiveAccount;
if (activeAcc != null) {
unawaited(ref.read(pawchiveSyncServiceProvider).toggleRemoteFavorite(
account: activeAcc,
service: widget.service,
artistId: widget.artistId,
isFavorite: !isCurrentlyFav,
));
}
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final settings = final settings =
@@ -378,12 +468,33 @@ class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
final favoriteKeys = ref.watch(favoriteKeysProvider).value ?? <String>{}; final favoriteKeys = ref.watch(favoriteKeysProvider).value ?? <String>{};
final viewedKeys = ref.watch(viewedKeysProvider).value ?? <String>{}; final viewedKeys = ref.watch(viewedKeysProvider).value ?? <String>{};
final artistKey =
'${widget.providerId}:${widget.service}:${widget.artistId}';
final isArtistFavorite = settings.favoriteArtists.any((e) {
try {
return FavoriteArtistItem.fromJson(
jsonDecode(e) as Map<String, dynamic>)
.key ==
artistKey;
} catch (_) {
return false;
}
});
final displayedPosts = final displayedPosts =
_posts.where(_matchesTypeFilter).toList(growable: false); _posts.where(_matchesTypeFilter).toList(growable: false);
return AdaptiveScaffold( return AdaptiveScaffold(
title: widget.artistName, title: widget.artistName,
actions: [ actions: [
IconButton(
tooltip: isArtistFavorite ? 'В избранном' : 'В избранное',
icon: Icon(
isArtistFavorite ? Icons.star_rounded : Icons.star_outline_rounded,
color: isArtistFavorite ? Colors.amber : null,
),
onPressed: () => _toggleArtistFavorite(settings, isArtistFavorite),
),
if (_announcements.isNotEmpty) if (_announcements.isNotEmpty)
IconButton( IconButton(
tooltip: 'Анонсы автора', tooltip: 'Анонсы автора',
@@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@@ -13,6 +14,7 @@ import '../../../shared/widgets/empty_view.dart';
import '../../../shared/widgets/error_view.dart'; import '../../../shared/widgets/error_view.dart';
import '../../settings/presentation/settings_controller.dart'; import '../../settings/presentation/settings_controller.dart';
import 'artist_posts_screen.dart'; import 'artist_posts_screen.dart';
import 'widgets/pawchive_accounts_sheet.dart';
final artistProviderConfigsProvider = final artistProviderConfigsProvider =
FutureProvider<List<ContentProviderConfig>>((ref) async { FutureProvider<List<ContentProviderConfig>>((ref) async {
@@ -49,7 +51,7 @@ class _ArtistsScreenState extends ConsumerState<ArtistsScreen> {
ArtistProfile artist, ArtistProfile artist,
) async { ) async {
final current = List<String>.from(settings.favoriteArtists); final current = List<String>.from(settings.favoriteArtists);
final item = _FavoriteArtistItem( final item = FavoriteArtistItem(
id: artist.id, id: artist.id,
service: artist.service, service: artist.service,
providerId: artist.providerId, providerId: artist.providerId,
@@ -60,13 +62,14 @@ class _ArtistsScreenState extends ConsumerState<ArtistsScreen> {
final index = current.indexWhere((e) { final index = current.indexWhere((e) {
try { try {
final parsed = final parsed =
_FavoriteArtistItem.fromJson(jsonDecode(e) as Map<String, dynamic>); FavoriteArtistItem.fromJson(jsonDecode(e) as Map<String, dynamic>);
return parsed.key == key; return parsed.key == key;
} catch (_) { } catch (_) {
return false; return false;
} }
}); });
if (index >= 0) { final isAdding = index < 0;
if (!isAdding) {
current.removeAt(index); current.removeAt(index);
} else { } else {
current.insert(0, jsonEncode(item.toJson())); current.insert(0, jsonEncode(item.toJson()));
@@ -74,6 +77,19 @@ class _ArtistsScreenState extends ConsumerState<ArtistsScreen> {
await ref.read(settingsControllerProvider.notifier).saveSettings( await ref.read(settingsControllerProvider.notifier).saveSettings(
settings.copyWith(favoriteArtists: current), settings.copyWith(favoriteArtists: current),
); );
// Sync with active Pawchive account if available
if (artist.service.isNotEmpty && artist.id.isNotEmpty) {
final activeAcc = settings.activePawchiveAccount;
if (activeAcc != null) {
unawaited(ref.read(pawchiveSyncServiceProvider).toggleRemoteFavorite(
account: activeAcc,
service: artist.service,
artistId: artist.id,
isFavorite: isAdding,
));
}
}
} }
@override @override
@@ -99,19 +115,29 @@ class _ArtistsScreenState extends ConsumerState<ArtistsScreen> {
final favoriteItems = settings.favoriteArtists final favoriteItems = settings.favoriteArtists
.map((e) { .map((e) {
try { try {
return _FavoriteArtistItem.fromJson( return FavoriteArtistItem.fromJson(
jsonDecode(e) as Map<String, dynamic>); jsonDecode(e) as Map<String, dynamic>);
} catch (_) { } catch (_) {
return null; return null;
} }
}) })
.whereType<_FavoriteArtistItem>() .whereType<FavoriteArtistItem>()
.toList(); .toList();
final favoriteKeys = favoriteItems.map((e) => e.key).toSet(); final favoriteKeys = favoriteItems.map((e) => e.key).toSet();
return AdaptiveScaffold( return AdaptiveScaffold(
title: 'Artists', title: 'Artists',
actions: [ actions: [
IconButton(
tooltip: 'Синхронизация Pawchive',
icon: Badge(
isLabelVisible: settings.parsedPawchiveAccounts.isNotEmpty,
backgroundColor: Colors.green,
smallSize: 8,
child: const Icon(Icons.cloud_sync_rounded),
),
onPressed: () => PawchiveAccountsSheet.show(context),
),
IconButton( IconButton(
tooltip: 'Любимые авторы', tooltip: 'Любимые авторы',
icon: Badge( icon: Badge(
@@ -318,7 +344,7 @@ class _ArtistsScreenState extends ConsumerState<ArtistsScreen> {
void _showFavoriteArtistsModal( void _showFavoriteArtistsModal(
BuildContext context, BuildContext context,
AppSettings settings, AppSettings settings,
List<_FavoriteArtistItem> favorites, List<FavoriteArtistItem> favorites,
) { ) {
showModalBottomSheet<void>( showModalBottomSheet<void>(
context: context, context: context,
@@ -341,7 +367,7 @@ class _ArtistsScreenState extends ConsumerState<ArtistsScreen> {
final updated = List<String>.from(settings.favoriteArtists) final updated = List<String>.from(settings.favoriteArtists)
..removeWhere((e) { ..removeWhere((e) {
try { try {
return _FavoriteArtistItem.fromJson( return FavoriteArtistItem.fromJson(
jsonDecode(e) as Map<String, dynamic>) jsonDecode(e) as Map<String, dynamic>)
.key == .key ==
fav.key; fav.key;
@@ -352,6 +378,18 @@ class _ArtistsScreenState extends ConsumerState<ArtistsScreen> {
await ref await ref
.read(settingsControllerProvider.notifier) .read(settingsControllerProvider.notifier)
.saveSettings(settings.copyWith(favoriteArtists: updated)); .saveSettings(settings.copyWith(favoriteArtists: updated));
if (fav.providerId == 'pawchive') {
final activeAcc = settings.activePawchiveAccount;
if (activeAcc != null) {
unawaited(ref.read(pawchiveSyncServiceProvider).toggleRemoteFavorite(
account: activeAcc,
service: fav.service,
artistId: fav.id,
isFavorite: false,
));
}
}
setState(() {}); setState(() {});
}, },
); );
@@ -570,49 +608,6 @@ class _ArtistCard extends StatelessWidget {
} }
} }
class _FavoriteArtistItem {
const _FavoriteArtistItem({
required this.id,
required this.service,
required this.providerId,
required this.name,
this.avatarUrl,
});
const _FavoriteArtistItem.empty()
: id = '',
service = '',
providerId = '',
name = '',
avatarUrl = null;
final String id;
final String service;
final String providerId;
final String name;
final String? avatarUrl;
bool get isEmpty => id.isEmpty;
String get key => '$providerId:$service:$id';
Map<String, dynamic> toJson() => {
'id': id,
'service': service,
'providerId': providerId,
'name': name,
'avatarUrl': avatarUrl,
};
factory _FavoriteArtistItem.fromJson(Map<String, dynamic> json) =>
_FavoriteArtistItem(
id: (json['id'] ?? '').toString(),
service: (json['service'] ?? '').toString(),
providerId: (json['providerId'] ?? '').toString(),
name: (json['name'] ?? '').toString(),
avatarUrl: json['avatarUrl'] as String?,
);
}
class _FavoriteArtistsModal extends StatefulWidget { class _FavoriteArtistsModal extends StatefulWidget {
const _FavoriteArtistsModal({ const _FavoriteArtistsModal({
required this.favorites, required this.favorites,
@@ -622,11 +617,11 @@ class _FavoriteArtistsModal extends StatefulWidget {
required this.onRemoveFavorite, required this.onRemoveFavorite,
}); });
final List<_FavoriteArtistItem> favorites; final List<FavoriteArtistItem> favorites;
final String? initialSelectedKey; final String? initialSelectedKey;
final ValueChanged<_FavoriteArtistItem> onSelect; final ValueChanged<FavoriteArtistItem> onSelect;
final ValueChanged<_FavoriteArtistItem> onOpenArtist; final ValueChanged<FavoriteArtistItem> onOpenArtist;
final ValueChanged<_FavoriteArtistItem> onRemoveFavorite; final ValueChanged<FavoriteArtistItem> onRemoveFavorite;
@override @override
State<_FavoriteArtistsModal> createState() => _FavoriteArtistsModalState(); State<_FavoriteArtistsModal> createState() => _FavoriteArtistsModalState();
@@ -648,7 +643,7 @@ class _FavoriteArtistsModalState extends State<_FavoriteArtistsModal> {
(e) => e.key == _selectedKey, (e) => e.key == _selectedKey,
orElse: () => widget.favorites.isNotEmpty orElse: () => widget.favorites.isNotEmpty
? widget.favorites.first ? widget.favorites.first
: const _FavoriteArtistItem.empty(), : const FavoriteArtistItem.empty(),
); );
return DraggableScrollableSheet( return DraggableScrollableSheet(
@@ -716,6 +711,77 @@ class _FavoriteArtistsModalState extends State<_FavoriteArtistsModal> {
), ),
), ),
const Divider(height: 1), const Divider(height: 1),
Consumer(
builder: (context, ref, _) {
final settings =
ref.watch(appSettingsProvider).value ?? AppSettings.defaults;
final activeAcc = settings.activePawchiveAccount;
final accounts = settings.parsedPawchiveAccounts;
return Container(
margin: const EdgeInsets.fromLTRB(16, 8, 16, 6),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: scheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: activeAcc != null
? scheme.primary.withValues(alpha: 0.35)
: scheme.outlineVariant.withValues(alpha: 0.25),
),
),
child: Row(
children: [
Icon(
Icons.cloud_sync_rounded,
size: 20,
color: activeAcc != null
? scheme.primary
: scheme.onSurfaceVariant,
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
activeAcc != null
? 'Pawchive: @${activeAcc.username}'
: 'Pawchive не подключён',
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 12,
),
),
Text(
activeAcc != null
? '${activeAcc.syncedArtistsCount} авторов в облаке'
: 'Синхронизируйте избранное с сервером',
style: TextStyle(
color: scheme.onSurfaceVariant,
fontSize: 11,
),
),
],
),
),
FilledButton.tonal(
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 4,
),
visualDensity: VisualDensity.compact,
),
onPressed: () => PawchiveAccountsSheet.show(context),
child: Text(accounts.isEmpty ? 'Войти' : 'Аккаунты'),
),
],
),
);
},
),
if (widget.favorites.isEmpty) if (widget.favorites.isEmpty)
Padding( Padding(
padding: const EdgeInsets.fromLTRB(24, 36, 24, 40), padding: const EdgeInsets.fromLTRB(24, 36, 24, 40),
@@ -792,11 +858,11 @@ class _FavoriteArtistsSection extends ConsumerWidget {
required this.onRemoveFavorite, required this.onRemoveFavorite,
}); });
final List<_FavoriteArtistItem> favorites; final List<FavoriteArtistItem> favorites;
final String? selectedKey; final String? selectedKey;
final ValueChanged<_FavoriteArtistItem> onSelect; final ValueChanged<FavoriteArtistItem> onSelect;
final ValueChanged<_FavoriteArtistItem> onOpenArtist; final ValueChanged<FavoriteArtistItem> onOpenArtist;
final ValueChanged<_FavoriteArtistItem> onRemoveFavorite; final ValueChanged<FavoriteArtistItem> onRemoveFavorite;
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
@@ -986,7 +1052,7 @@ class _FavoriteArtistsSection extends ConsumerWidget {
class _FavoriteArtistMediaStrip extends ConsumerWidget { class _FavoriteArtistMediaStrip extends ConsumerWidget {
const _FavoriteArtistMediaStrip({required this.artist}); const _FavoriteArtistMediaStrip({required this.artist});
final _FavoriteArtistItem artist; final FavoriteArtistItem artist;
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
@@ -0,0 +1,958 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../app/app.dart';
import '../../../../backend/backend.dart';
import '../../../settings/presentation/settings_controller.dart';
class PawchiveAccountsSheet extends ConsumerStatefulWidget {
const PawchiveAccountsSheet({super.key});
static Future<void> show(BuildContext context) {
return showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (_) => const PawchiveAccountsSheet(),
);
}
@override
ConsumerState<PawchiveAccountsSheet> createState() =>
_PawchiveAccountsSheetState();
}
class _PawchiveAccountsSheetState extends ConsumerState<PawchiveAccountsSheet>
with SingleTickerProviderStateMixin {
late final TabController _addTabController;
final _usernameController = TextEditingController();
final _passwordController = TextEditingController();
final _cookieController = TextEditingController();
bool _isAdding = false;
bool _obscurePassword = true;
String? _syncingAccountId;
String? _pushingAccountId;
bool _syncingAll = false;
String? _errorMessage;
@override
void initState() {
super.initState();
_addTabController = TabController(length: 2, vsync: this);
}
@override
void dispose() {
_addTabController.dispose();
_usernameController.dispose();
_passwordController.dispose();
_cookieController.dispose();
super.dispose();
}
Future<void> _handleLoginCredentials() async {
final username = _usernameController.text.trim();
final password = _passwordController.text.trim();
if (username.isEmpty || password.isEmpty) {
setState(() => _errorMessage = 'Введите логин и пароль');
return;
}
setState(() {
_isAdding = true;
_errorMessage = null;
});
try {
final syncService = ref.read(pawchiveSyncServiceProvider);
final account = await syncService.loginWithCredentials(
username: username,
password: password,
);
final settings =
ref.read(appSettingsProvider).value ?? AppSettings.defaults;
final existingAccounts = settings.parsedPawchiveAccounts.where((a) => a.id != account.id).toList();
// Set as active
final updatedList = [
account.copyWith(isActive: true),
...existingAccounts.map((a) => a.copyWith(isActive: false)),
];
var newSettings = settings.copyWith(
pawchiveAccounts:
updatedList.map((a) => jsonEncode(a.toJson())).toList(),
);
// Perform initial sync
final syncResult = await syncService.syncAccountFavorites(
account: account,
settings: newSettings,
);
if (syncResult.isSuccess) {
newSettings = syncResult.updatedSettings;
}
await ref
.read(settingsControllerProvider.notifier)
.saveSettings(newSettings);
_usernameController.clear();
_passwordController.clear();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'Аккаунт @${account.username} добавлен! Синхронизировано ${account.syncedArtistsCount} авторов.',
),
backgroundColor: Colors.green.shade700,
),
);
}
} catch (e) {
if (mounted) {
setState(() => _errorMessage = e.toString().replaceFirst('Exception: ', '').replaceFirst('StateError: ', ''));
}
} finally {
if (mounted) {
setState(() => _isAdding = false);
}
}
}
Future<void> _handleLoginCookie() async {
final cookie = _cookieController.text.trim();
final username = _usernameController.text.trim();
if (cookie.isEmpty) {
setState(() => _errorMessage = 'Введите сессионную куку');
return;
}
setState(() {
_isAdding = true;
_errorMessage = null;
});
try {
final syncService = ref.read(pawchiveSyncServiceProvider);
final account = await syncService.loginWithSession(
sessionCookie: cookie,
username: username.isEmpty ? 'Pawchive User' : username,
);
final settings =
ref.read(appSettingsProvider).value ?? AppSettings.defaults;
final existingAccounts = settings.parsedPawchiveAccounts.where((a) => a.id != account.id).toList();
final updatedList = [
account.copyWith(isActive: true),
...existingAccounts.map((a) => a.copyWith(isActive: false)),
];
var newSettings = settings.copyWith(
pawchiveAccounts:
updatedList.map((a) => jsonEncode(a.toJson())).toList(),
);
final syncResult = await syncService.syncAccountFavorites(
account: account,
settings: newSettings,
);
if (syncResult.isSuccess) {
newSettings = syncResult.updatedSettings;
}
await ref
.read(settingsControllerProvider.notifier)
.saveSettings(newSettings);
_cookieController.clear();
_usernameController.clear();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'Кука сохранена! Синхронизировано ${account.syncedArtistsCount} авторов.',
),
backgroundColor: Colors.green.shade700,
),
);
}
} catch (e) {
if (mounted) {
setState(() => _errorMessage = e.toString().replaceFirst('Exception: ', '').replaceFirst('StateError: ', ''));
}
} finally {
if (mounted) {
setState(() => _isAdding = false);
}
}
}
Future<void> _syncSingleAccount(PawchiveAccount account) async {
setState(() => _syncingAccountId = account.id);
try {
final syncService = ref.read(pawchiveSyncServiceProvider);
final settings =
ref.read(appSettingsProvider).value ?? AppSettings.defaults;
final result = await syncService.syncAccountFavorites(
account: account,
settings: settings,
);
if (result.isSuccess) {
await ref
.read(settingsControllerProvider.notifier)
.saveSettings(result.updatedSettings);
if (mounted) {
final pushedMsg = result.pushedToRemoteCount > 0
? ', выгружено в Pawchive: +${result.pushedToRemoteCount}'
: '';
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'Синхронизировано: ${result.totalSyncedCount} авторов (+${result.newlyAddedToLocal} новых в приложении$pushedMsg)',
),
),
);
}
} else {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Ошибка синхронизации: ${result.errorMessage}'),
backgroundColor: Colors.red.shade700,
),
);
}
}
} finally {
if (mounted) {
setState(() => _syncingAccountId = null);
}
}
}
Future<void> _pushFavoritesToAccount(PawchiveAccount account) async {
final settings =
ref.read(appSettingsProvider).value ?? AppSettings.defaults;
final totalLocal = settings.favoriteArtists.length;
if (totalLocal == 0) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('В локальном избранном пока нет авторов')),
);
return;
}
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Выгрузить избранное в Pawchive?'),
content: Text(
'Все локальные авторы из приложения ($totalLocal) будут добавлены в избранное аккаунта @${account.username} на сервере Pawchive.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Отмена'),
),
FilledButton.icon(
onPressed: () => Navigator.pop(ctx, true),
icon: const Icon(Icons.cloud_upload_rounded),
label: const Text('Выгрузить'),
),
],
),
);
if (confirmed != true) return;
setState(() => _pushingAccountId = account.id);
try {
final syncService = ref.read(pawchiveSyncServiceProvider);
final result = await syncService.pushLocalFavoritesToAccount(
account: account,
settings: settings,
);
if (result.isSuccess) {
if (result.updatedSettings != null) {
await ref
.read(settingsControllerProvider.notifier)
.saveSettings(result.updatedSettings!);
}
if (mounted) {
if (result.pushedCount > 0) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'Выгружено ${result.pushedCount} авторов в @${account.username}! Всего в аккаунте: ${result.totalRemoteCount}.',
),
backgroundColor: Colors.green.shade700,
),
);
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'Все авторы (${result.totalLocalCandidates}) уже есть в аккаунте @${account.username}.',
),
),
);
}
}
} else {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Ошибка выгрузки: ${result.errorMessage}'),
backgroundColor: Colors.red.shade700,
),
);
}
}
} finally {
if (mounted) {
setState(() => _pushingAccountId = null);
}
}
}
Future<void> _syncAllAccounts() async {
setState(() => _syncingAll = true);
try {
final syncService = ref.read(pawchiveSyncServiceProvider);
final settings =
ref.read(appSettingsProvider).value ?? AppSettings.defaults;
final result = await syncService.syncAllAccounts(settings: settings);
if (result.isSuccess) {
await ref
.read(settingsControllerProvider.notifier)
.saveSettings(result.updatedSettings);
if (mounted) {
final pushedMsg = result.pushedToRemoteCount > 0
? ', выгружено в Pawchive: +${result.pushedToRemoteCount}'
: '';
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'Все аккаунты синхронизированы! Всего: ${result.totalSyncedCount} (+${result.newlyAddedToLocal} новых в приложении$pushedMsg)',
),
backgroundColor: Colors.green.shade700,
),
);
}
} else {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Ошибка синхронизации: ${result.errorMessage}'),
backgroundColor: Colors.red.shade700,
),
);
}
}
} finally {
if (mounted) {
setState(() => _syncingAll = false);
}
}
}
Future<void> _setActiveAccount(PawchiveAccount target) async {
final settings =
ref.read(appSettingsProvider).value ?? AppSettings.defaults;
final updatedList = settings.parsedPawchiveAccounts.map((a) {
return a.copyWith(isActive: a.id == target.id);
}).toList();
await ref.read(settingsControllerProvider.notifier).saveSettings(
settings.copyWith(
pawchiveAccounts:
updatedList.map((a) => jsonEncode(a.toJson())).toList(),
),
);
}
Future<void> _removeAccount(PawchiveAccount target) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Удалить аккаунт Pawchive?'),
content: Text(
'Вы уверены, что хотите удалить аккаунт @${target.username}? Локальные авторы останутся в приложении.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Отмена'),
),
FilledButton(
onPressed: () => Navigator.pop(ctx, true),
style: FilledButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.error,
),
child: const Text('Удалить'),
),
],
),
);
if (confirmed != true) return;
final settings =
ref.read(appSettingsProvider).value ?? AppSettings.defaults;
final filtered =
settings.parsedPawchiveAccounts.where((a) => a.id != target.id).toList();
// If active was removed, make first remaining active
if (filtered.isNotEmpty && !filtered.any((a) => a.isActive)) {
filtered[0] = filtered[0].copyWith(isActive: true);
}
await ref.read(settingsControllerProvider.notifier).saveSettings(
settings.copyWith(
pawchiveAccounts:
filtered.map((a) => jsonEncode(a.toJson())).toList(),
),
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final scheme = theme.colorScheme;
final settings =
ref.watch(appSettingsProvider).value ?? AppSettings.defaults;
final accounts = settings.parsedPawchiveAccounts;
final keyboardPadding = MediaQuery.of(context).viewInsets.bottom;
return DraggableScrollableSheet(
initialChildSize: accounts.isEmpty ? 0.8 : 0.75,
minChildSize: 0.4,
maxChildSize: 0.95,
builder: (context, scrollController) {
return Container(
decoration: BoxDecoration(
color: scheme.surface,
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.35),
blurRadius: 18,
offset: const Offset(0, -2),
),
],
),
child: Column(
children: [
// Drag handle
Center(
child: Container(
margin: const EdgeInsets.only(top: 10, bottom: 6),
width: 44,
height: 5,
decoration: BoxDecoration(
color: scheme.onSurfaceVariant.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(10),
),
),
),
// Header
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: scheme.primaryContainer,
borderRadius: BorderRadius.circular(12),
),
child: Icon(
Icons.cloud_sync_rounded,
color: scheme.onPrimaryContainer,
size: 22,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Аккаунты Pawchive',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
fontSize: 18,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
Text(
'Синхронизация авторов и избранного',
style: theme.textTheme.bodySmall?.copyWith(
color: scheme.onSurfaceVariant,
fontSize: 12,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
if (accounts.isNotEmpty) ...[
const SizedBox(width: 8),
IconButton.filledTonal(
onPressed: _syncingAll ? null : _syncAllAccounts,
icon: _syncingAll
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.sync_rounded, size: 20),
tooltip: 'Синхронизировать все аккаунты',
),
],
],
),
),
const Divider(height: 1),
// Body
Expanded(
child: ListView(
controller: scrollController,
padding: EdgeInsets.fromLTRB(16, 12, 16, 16 + keyboardPadding),
children: [
// Connected accounts section
if (accounts.isNotEmpty) ...[
Padding(
padding: const EdgeInsets.only(bottom: 8, left: 4),
child: Text(
'ПОДКЛЮЧЁННЫЕ АККАУНТЫ (${accounts.length})',
style: theme.textTheme.labelSmall?.copyWith(
color: scheme.primary,
fontWeight: FontWeight.bold,
letterSpacing: 0.8,
),
),
),
...accounts.map((acc) => _buildAccountCard(acc, scheme, theme)),
const SizedBox(height: 12),
// Two-way sync & Export to Pawchive panel
Card(
elevation: 0,
color: scheme.surfaceContainerHigh.withValues(alpha: 0.6),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(
color: scheme.outlineVariant.withValues(alpha: 0.3),
),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Icon(Icons.swap_vert_rounded, size: 20, color: scheme.primary),
const SizedBox(width: 8),
Expanded(
child: Text(
'Двусторонняя синхронизация',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
Switch(
value: settings.pawchiveBidirectionalSync,
onChanged: (val) async {
final newSettings = settings.copyWith(
pawchiveBidirectionalSync: val,
);
await ref
.read(settingsControllerProvider.notifier)
.saveSettings(newSettings);
},
),
],
),
Text(
'При обычной синхронизации также выгружать всех локальных авторов на сервер Pawchive.',
style: TextStyle(
color: scheme.onSurfaceVariant,
fontSize: 12,
),
),
const SizedBox(height: 10),
OutlinedButton.icon(
onPressed: (_syncingAll || _pushingAccountId != null)
? null
: () {
final target = settings.activePawchiveAccount ?? accounts.first;
_pushFavoritesToAccount(target);
},
icon: _pushingAccountId != null
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.cloud_upload_rounded, size: 18),
label: Text(
'Выгрузить избранное (${settings.favoriteArtists.length}) в @${(settings.activePawchiveAccount ?? accounts.first).username}',
),
),
],
),
),
),
const SizedBox(height: 16),
],
// Add account section
Padding(
padding: const EdgeInsets.only(bottom: 8, left: 4),
child: Text(
accounts.isEmpty ? 'ДОБАВИТЬ АККАУНТ' : 'ДОБАВИТЬ ЕЩЁ АККАУНТ',
style: theme.textTheme.labelSmall?.copyWith(
color: scheme.primary,
fontWeight: FontWeight.bold,
letterSpacing: 0.8,
),
),
),
Card(
elevation: 0,
color: scheme.surfaceContainerHigh,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(
color: scheme.outlineVariant.withValues(alpha: 0.4),
),
),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TabBar(
controller: _addTabController,
tabs: const [
Tab(text: 'Логин и пароль'),
Tab(text: 'Session Cookie'),
],
),
const SizedBox(height: 16),
if (_errorMessage != null) ...[
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: scheme.errorContainer,
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Icon(
Icons.error_outline_rounded,
size: 18,
color: scheme.onErrorContainer,
),
const SizedBox(width: 8),
Expanded(
child: Text(
_errorMessage!,
style: TextStyle(
color: scheme.onErrorContainer,
fontSize: 13,
),
),
),
],
),
),
const SizedBox(height: 12),
],
SizedBox(
height: 190,
child: TabBarView(
controller: _addTabController,
children: [
// Tab 1: Username & Password
Column(
children: [
TextField(
controller: _usernameController,
decoration: const InputDecoration(
labelText: 'Имя пользователя',
prefixIcon: Icon(Icons.person_outline_rounded),
isDense: true,
),
),
const SizedBox(height: 12),
TextField(
controller: _passwordController,
obscureText: _obscurePassword,
decoration: InputDecoration(
labelText: 'Пароль',
prefixIcon: const Icon(Icons.lock_outline_rounded),
isDense: true,
suffixIcon: IconButton(
icon: Icon(
_obscurePassword
? Icons.visibility_outlined
: Icons.visibility_off_outlined,
),
onPressed: () => setState(
() => _obscurePassword = !_obscurePassword,
),
),
),
),
const Spacer(),
FilledButton.icon(
onPressed: _isAdding ? null : _handleLoginCredentials,
icon: _isAdding
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const Icon(Icons.login_rounded),
label: const Text('Войти и синхронизировать'),
),
],
),
// Tab 2: Session Cookie
Column(
children: [
TextField(
controller: _usernameController,
decoration: const InputDecoration(
labelText: 'Никнейм (необязательно)',
prefixIcon: Icon(Icons.badge_outlined),
isDense: true,
),
),
const SizedBox(height: 12),
TextField(
controller: _cookieController,
maxLines: 2,
decoration: const InputDecoration(
labelText: 'Сессионная кука',
hintText: 'session=ey... или значение куки',
prefixIcon: Icon(Icons.cookie_outlined),
isDense: true,
),
),
const Spacer(),
FilledButton.icon(
onPressed: _isAdding ? null : _handleLoginCookie,
icon: _isAdding
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const Icon(Icons.check_circle_outline_rounded),
label: const Text('Сохранить куку'),
),
],
),
],
),
),
],
),
),
),
],
),
),
],
),
);
},
);
}
Widget _buildAccountCard(PawchiveAccount acc, ColorScheme scheme, ThemeData theme) {
final isSyncing = _syncingAccountId == acc.id;
final isPushing = _pushingAccountId == acc.id;
final isBusy = isSyncing || isPushing;
return Card(
margin: const EdgeInsets.only(bottom: 8),
elevation: 0,
color: acc.isActive
? scheme.primaryContainer.withValues(alpha: 0.35)
: scheme.surfaceContainerHigh,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(
color: acc.isActive
? scheme.primary.withValues(alpha: 0.6)
: scheme.outlineVariant.withValues(alpha: 0.3),
width: acc.isActive ? 1.5 : 1,
),
),
child: ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 2),
leading: CircleAvatar(
backgroundColor: acc.isActive ? scheme.primary : scheme.surfaceContainerHighest,
foregroundColor: acc.isActive ? scheme.onPrimary : scheme.onSurfaceVariant,
child: Text(
acc.username.isNotEmpty ? acc.username[0].toUpperCase() : 'P',
style: const TextStyle(fontWeight: FontWeight.bold),
),
),
title: Row(
children: [
Flexible(
child: Text(
acc.username,
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 15),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
if (acc.isActive) ...[
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2),
decoration: BoxDecoration(
color: scheme.primary.withValues(alpha: 0.18),
borderRadius: BorderRadius.circular(8),
),
child: Text(
'Активен',
style: TextStyle(
color: scheme.primary,
fontSize: 10,
fontWeight: FontWeight.bold,
),
),
),
],
],
),
subtitle: Padding(
padding: const EdgeInsets.only(top: 2),
child: Text(
'Авторов: ${acc.syncedArtistsCount} • ${acc.lastSyncedAt != null ? _formatDate(acc.lastSyncedAt!) : "не синхр."}',
style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
visualDensity: VisualDensity.compact,
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
padding: const EdgeInsets.all(6),
iconSize: 18,
icon: isPushing
? const SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.cloud_upload_outlined),
tooltip: 'Выгрузить локальное избранное в этот аккаунт',
onPressed: isBusy ? null : () => _pushFavoritesToAccount(acc),
),
IconButton(
visualDensity: VisualDensity.compact,
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
padding: const EdgeInsets.all(6),
iconSize: 18,
icon: isSyncing
? const SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.sync_rounded),
tooltip: 'Синхронизировать этот аккаунт',
onPressed: isBusy ? null : () => _syncSingleAccount(acc),
),
PopupMenuButton<String>(
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 28, minHeight: 32),
iconSize: 18,
icon: const Icon(Icons.more_vert_rounded),
onSelected: (val) {
if (val == 'active') {
_setActiveAccount(acc);
} else if (val == 'push') {
_pushFavoritesToAccount(acc);
} else if (val == 'delete') {
_removeAccount(acc);
}
},
itemBuilder: (ctx) => [
if (!acc.isActive)
const PopupMenuItem(
value: 'active',
child: Row(
children: [
Icon(Icons.check_circle_outline_rounded, size: 20),
SizedBox(width: 8),
Text('Сделать активным'),
],
),
),
const PopupMenuItem(
value: 'push',
child: Row(
children: [
Icon(Icons.cloud_upload_outlined, size: 20),
SizedBox(width: 8),
Text('Выгрузить в Pawchive'),
],
),
),
const PopupMenuItem(
value: 'delete',
child: Row(
children: [
Icon(Icons.delete_outline_rounded, color: Colors.red, size: 20),
SizedBox(width: 8),
Text('Удалить аккаунт', style: TextStyle(color: Colors.red)),
],
),
),
],
),
],
),
),
);
}
String _formatDate(DateTime dt) {
final now = DateTime.now();
if (now.difference(dt).inMinutes < 1) return 'только что';
if (now.difference(dt).inHours < 1) return '${now.difference(dt).inMinutes} мин назад';
return '${dt.day.toString().padLeft(2, '0')}.${dt.month.toString().padLeft(2, '0')} ${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}';
}
}
@@ -123,14 +123,18 @@ class FeedController extends AsyncNotifier<FeedState> {
final settingsRes = await ref.read(settingsServiceProvider).getSettings(); final settingsRes = await ref.read(settingsServiceProvider).getSettings();
final settings = final settings =
settingsRes is Success<AppSettings> ? settingsRes.data : AppSettings.defaults; settingsRes is Success<AppSettings> ? settingsRes.data : AppSettings.defaults;
await ref try {
.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 await ref
.read(searchServiceProvider) .read(searchServiceProvider)
.saveSearch(query, count, maxHistory: settings.searchHistoryLimit); .saveSearch(query, count, maxHistory: settings.searchHistoryLimit);
} catch (_) {
await ref
.read(searchServiceProvider)
.saveSearch(query, 0, maxHistory: settings.searchHistoryLimit);
rethrow;
}
} }
Future<void> updateTagSuggestions(String query) async { Future<void> updateTagSuggestions(String query) async {
@@ -98,6 +98,14 @@ class _FeedToolbarState extends State<FeedToolbar> {
_query = value; _query = value;
widget.onSuggestionTap(value); widget.onSuggestionTap(value);
}, },
onTagRemoved: (value) {
_query = value;
widget.onSearch(value);
},
onCleared: () {
_query = '';
widget.onClearFilters();
},
), ),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
@@ -221,6 +229,14 @@ class _FeedToolbarState extends State<FeedToolbar> {
_query = value; _query = value;
widget.onSuggestionTap(value); widget.onSuggestionTap(value);
}, },
onTagRemoved: (value) {
_query = value;
widget.onSearch(value);
},
onCleared: () {
_query = '';
widget.onClearFilters();
},
), ),
), ),
const SizedBox(width: 6), const SizedBox(width: 6),
@@ -23,6 +23,7 @@ import '../../favorites/presentation/favorites_controller.dart';
import '../../feed/presentation/feed_controller.dart'; import '../../feed/presentation/feed_controller.dart';
import '../../viewed/presentation/viewed_controller.dart'; import '../../viewed/presentation/viewed_controller.dart';
import 'post_details_controller.dart'; import 'post_details_controller.dart';
import 'widgets/cloud_mirrors_card.dart';
import 'widgets/post_action_bar.dart'; import 'widgets/post_action_bar.dart';
import 'widgets/post_media_viewer.dart'; import 'widgets/post_media_viewer.dart';
import 'widgets/post_tags_panel.dart'; import 'widgets/post_tags_panel.dart';
@@ -294,6 +295,24 @@ class PostDetailsScreen extends ConsumerWidget {
localMedia: localMedia, localMedia: localMedia,
fileSizeBytes: fileSizeBytes, fileSizeBytes: fileSizeBytes,
), ),
if (post.cloudLinks.isNotEmpty ||
(post.commentary != null &&
post.commentary!.trim().isNotEmpty)) ...[
const SizedBox(height: 16),
CloudMirrorsCard(
links: post.cloudLinks,
strings: strings,
commentary: post.commentary,
onPlayStream: (streamUrl) => launchUrl(
Uri.parse(streamUrl),
mode: LaunchMode.externalApplication,
),
onDownloadStream: settings.allowDownloads
? (streamUrl) =>
_downloadUrl(context, ref, post, streamUrl)
: null,
),
],
const SizedBox(height: 16), const SizedBox(height: 16),
Container( Container(
decoration: BoxDecoration( decoration: BoxDecoration(
@@ -320,7 +339,7 @@ class PostDetailsScreen extends ConsumerWidget {
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
Text( Text(
'${strings.tags} (${post.tags.length})', '${strings.tags} (${post.cleanTags.length})',
style: Theme.of(context) style: Theme.of(context)
.textTheme .textTheme
.titleMedium .titleMedium
@@ -443,6 +462,23 @@ class PostDetailsScreen extends ConsumerWidget {
localMedia: localMedia, localMedia: localMedia,
fileSizeBytes: fileSizeBytes, fileSizeBytes: fileSizeBytes,
), ),
if (post.cloudLinks.isNotEmpty ||
(post.commentary != null &&
post.commentary!.trim().isNotEmpty)) ...[
const SizedBox(height: 12),
CloudMirrorsCard(
links: post.cloudLinks,
strings: strings,
commentary: post.commentary,
onPlayStream: (streamUrl) => launchUrl(
Uri.parse(streamUrl),
mode: LaunchMode.externalApplication,
),
onDownloadStream: settings.allowDownloads
? (streamUrl) => _downloadUrl(context, ref, post, streamUrl)
: null,
),
],
if (feedPosts != null && feedPosts.length > 1) ...[ if (feedPosts != null && feedPosts.length > 1) ...[
const SizedBox(height: 12), const SizedBox(height: 12),
_NeighborStrip( _NeighborStrip(
@@ -479,7 +515,7 @@ class PostDetailsScreen extends ConsumerWidget {
color: Theme.of(context).colorScheme.primary, color: Theme.of(context).colorScheme.primary,
), ),
title: Text( title: Text(
'${strings.tags} (${post.tags.length})', '${strings.tags} (${post.cleanTags.length})',
style: Theme.of(context).textTheme.titleSmall?.copyWith( style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
@@ -535,6 +571,16 @@ class PostDetailsScreen extends ConsumerWidget {
}); });
} }
Future<void> _downloadUrl(
BuildContext context,
WidgetRef ref,
Post post,
String targetUrl,
) async {
final customPost = post.copyWith(fileUrl: targetUrl, sampleUrl: targetUrl);
await _download(context, ref, customPost);
}
Future<void> _sharePost(BuildContext context, WidgetRef ref, Post post) async { Future<void> _sharePost(BuildContext context, WidgetRef ref, Post post) async {
final strings = ref.read(appStringsProvider); final strings = ref.read(appStringsProvider);
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
@@ -1139,7 +1185,7 @@ class _CommentsSectionState extends ConsumerState<_CommentsSection> {
} }
} }
class _PostInfoCard extends StatelessWidget { class _PostInfoCard extends StatefulWidget {
const _PostInfoCard({ const _PostInfoCard({
required this.post, required this.post,
required this.strings, required this.strings,
@@ -1152,8 +1198,90 @@ class _PostInfoCard extends StatelessWidget {
final DownloadedMedia? localMedia; final DownloadedMedia? localMedia;
final int? fileSizeBytes; final int? fileSizeBytes;
@override
State<_PostInfoCard> createState() => _PostInfoCardState();
}
class _PostInfoCardState extends State<_PostInfoCard> {
// Lazily resolved dimensions (used when post.width/height == 0).
int? _resolvedWidth;
int? _resolvedHeight;
bool _resolvingDimensions = false;
static final _dimensionCache = <String, (int, int)>{};
static bool _isImageType(String fileType) {
const imageTypes = {'image', 'jpeg', 'jpg', 'png', 'gif', 'webp', 'avif'};
return imageTypes.contains(fileType.toLowerCase());
}
@override
void initState() {
super.initState();
_maybeResolveDimensions();
}
@override
void didUpdateWidget(_PostInfoCard old) {
super.didUpdateWidget(old);
if (old.post.fileUrl != widget.post.fileUrl) {
_resolvedWidth = null;
_resolvedHeight = null;
_resolvingDimensions = false;
_maybeResolveDimensions();
}
}
void _maybeResolveDimensions() {
final post = widget.post;
// Only attempt lazy resolution when dimensions are unknown (0) and it's
// an image (not video/link which we can't resolve this way).
if (post.width != 0 || post.height != 0) return;
if (!_isImageType(post.fileType)) return;
final url = post.fileUrl.isNotEmpty ? post.fileUrl : post.sampleUrl;
if (url.isEmpty) return;
// Check cache first.
if (_dimensionCache.containsKey(url)) {
final cached = _dimensionCache[url]!;
_resolvedWidth = cached.$1;
_resolvedHeight = cached.$2;
return;
}
if (_resolvingDimensions) return;
_resolvingDimensions = true;
final imageProvider = NetworkImage(url);
final completer = imageProvider.resolve(ImageConfiguration.empty);
completer.addListener(
ImageStreamListener(
(info, _) {
final w = info.image.width;
final h = info.image.height;
_dimensionCache[url] = (w, h);
if (mounted) {
setState(() {
_resolvedWidth = w;
_resolvedHeight = h;
_resolvingDimensions = false;
});
}
},
onError: (_, __) {
if (mounted) setState(() => _resolvingDimensions = false);
},
),
);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final post = widget.post;
final strings = widget.strings;
final localMedia = widget.localMedia;
final fileSizeBytes = widget.fileSizeBytes;
final theme = Theme.of(context); final theme = Theme.of(context);
final scheme = theme.colorScheme; final scheme = theme.colorScheme;
final artists = post.tagGroups['artist'] ?? final artists = post.tagGroups['artist'] ??
@@ -1162,6 +1290,14 @@ class _PostInfoCard extends StatelessWidget {
.map((t) => t.replaceFirst('artist:', '')) .map((t) => t.replaceFirst('artist:', ''))
.toList(); .toList();
// Determine which dimensions to display.
final displayWidth = post.width != 0 ? post.width : _resolvedWidth;
final displayHeight = post.height != 0 ? post.height : _resolvedHeight;
final hasDimensions = displayWidth != null && displayHeight != null;
final dimensionLabel = hasDimensions
? '$displayWidth × $displayHeight'
: (_resolvingDimensions ? '… × …' : null);
return Container( return Container(
padding: const EdgeInsets.all(14), padding: const EdgeInsets.all(14),
decoration: BoxDecoration( decoration: BoxDecoration(
@@ -1247,7 +1383,7 @@ class _PostInfoCard extends StatelessWidget {
), ),
), ),
Text( Text(
_formatDate(post.createdAt), _formatPostDate(post.createdAt),
style: theme.textTheme.bodySmall?.copyWith( style: theme.textTheme.bodySmall?.copyWith(
color: scheme.onSurfaceVariant, color: scheme.onSurfaceVariant,
), ),
@@ -1275,8 +1411,8 @@ class _PostInfoCard extends StatelessWidget {
), ),
const SizedBox(width: 4), const SizedBox(width: 4),
Text( Text(
fileSizeBytes != null && fileSizeBytes! > 0 fileSizeBytes != null && fileSizeBytes > 0
? DownloadedMediaService.formatBytes(fileSizeBytes!) ? DownloadedMediaService.formatBytes(fileSizeBytes)
: (strings.ru ? 'Офлайн' : 'Offline'), : (strings.ru ? 'Офлайн' : 'Offline'),
style: theme.textTheme.labelSmall?.copyWith( style: theme.textTheme.labelSmall?.copyWith(
color: scheme.onPrimaryContainer, color: scheme.onPrimaryContainer,
@@ -1295,9 +1431,10 @@ class _PostInfoCard extends StatelessWidget {
runSpacing: 8, runSpacing: 8,
crossAxisAlignment: WrapCrossAlignment.center, crossAxisAlignment: WrapCrossAlignment.center,
children: [ children: [
if (dimensionLabel != null)
_SpecBadge( _SpecBadge(
icon: Icons.aspect_ratio_rounded, icon: Icons.aspect_ratio_rounded,
label: '${post.width} × ${post.height}', label: dimensionLabel,
), ),
if (post.fileType.isNotEmpty) if (post.fileType.isNotEmpty)
_SpecBadge( _SpecBadge(
@@ -1327,10 +1464,10 @@ class _PostInfoCard extends StatelessWidget {
), ),
); );
} }
}
static String _formatDate(DateTime date) { String _formatPostDate(DateTime date) {
return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}'; return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
}
} }
class _SpecBadge extends StatelessWidget { class _SpecBadge extends StatelessWidget {
@@ -0,0 +1,375 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../../../app/app_strings.dart';
import '../../../../backend/models/cloud_media_link.dart';
import '../../../../backend/models/creator_link.dart';
import '../../../../shared/widgets/formatted_content_text.dart';
class CloudMirrorsCard extends StatelessWidget {
const CloudMirrorsCard({
super.key,
required this.links,
required this.strings,
this.commentary,
this.onPlayStream,
this.onDownloadStream,
this.initiallyExpanded = false,
});
final List<CloudMediaLink> links;
final AppStrings strings;
final String? commentary;
final ValueChanged<String>? onPlayStream;
final ValueChanged<String>? onDownloadStream;
final bool initiallyExpanded;
@override
Widget build(BuildContext context) {
if (links.isEmpty && (commentary == null || commentary!.trim().isEmpty)) {
return const SizedBox.shrink();
}
final theme = Theme.of(context);
final detectedPassword = links
.map((l) => l.detectedPassword)
.whereType<String>()
.firstWhere((p) => p.isNotEmpty, orElse: () => '');
return Container(
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(18),
border: Border.all(
color: theme.colorScheme.outlineVariant.withValues(alpha: 0.35),
),
),
clipBehavior: Clip.antiAlias,
child: ExpansionTile(
tilePadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 2),
childrenPadding: const EdgeInsets.fromLTRB(14, 0, 14, 14),
initiallyExpanded: initiallyExpanded,
shape: const Border(),
collapsedShape: const Border(),
leading: Icon(
Icons.cloud_sync_rounded,
color: theme.colorScheme.primary,
),
title: Row(
children: [
Expanded(
child: Text(
strings.ru
? 'Облачные диски и зеркала (${links.length})'
: 'Cloud Mirrors & Drives (${links.length})',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
if (links.any((l) => l.isStreamable)) ...[
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: Colors.green.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.green.withValues(alpha: 0.4)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.play_circle_fill_rounded,
size: 13, color: Colors.green),
const SizedBox(width: 4),
Text(
strings.ru ? 'Плеер' : 'Stream',
style: theme.textTheme.labelSmall?.copyWith(
color: Colors.green,
fontWeight: FontWeight.bold,
fontSize: 10,
),
),
],
),
),
],
],
),
children: [
// Archive Password Banner
if (detectedPassword.isNotEmpty) ...[
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: Colors.amber.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.amber.withValues(alpha: 0.4)),
),
child: Row(
children: [
const Icon(Icons.key_rounded, size: 16, color: Colors.amber),
const SizedBox(width: 8),
Expanded(
child: Text.rich(
TextSpan(
text: strings.ru ? 'Пароль к архиву: ' : 'Archive Password: ',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurface,
),
children: [
TextSpan(
text: detectedPassword,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontFamily: 'monospace',
),
),
],
),
),
),
IconButton(
icon: const Icon(Icons.copy_rounded, size: 16),
visualDensity: VisualDensity.compact,
tooltip: strings.ru ? 'Копировать пароль' : 'Copy Password',
onPressed: () {
Clipboard.setData(ClipboardData(text: detectedPassword));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
strings.ru
? 'Пароль скопирован: $detectedPassword'
: 'Password copied: $detectedPassword',
),
behavior: SnackBarBehavior.floating,
duration: const Duration(seconds: 2),
),
);
},
),
],
),
),
const SizedBox(height: 10),
],
// Links list
if (links.isNotEmpty) ...[
...links.map((link) => _buildLinkTile(context, link, strings)),
],
// Commentary / Description
if (commentary != null && commentary!.trim().isNotEmpty) ...[
const SizedBox(height: 10),
Divider(
color: theme.colorScheme.outlineVariant.withValues(alpha: 0.4),
height: 1,
),
const SizedBox(height: 10),
Row(
children: [
Icon(
Icons.notes_rounded,
size: 16,
color: theme.colorScheme.primary,
),
const SizedBox(width: 6),
Text(
strings.ru ? 'Описание от автора' : 'Artist Commentary',
style: theme.textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.bold,
color: theme.colorScheme.primary,
),
),
],
),
const SizedBox(height: 8),
FormattedContentText(
text: commentary!.trim(),
style: theme.textTheme.bodySmall?.copyWith(
height: 1.45,
color: theme.colorScheme.onSurfaceVariant,
),
),
Builder(
builder: (context) {
final creatorLinks = CreatorLink.extractLinks(commentary);
if (creatorLinks.isEmpty) return const SizedBox.shrink();
return Padding(
padding: const EdgeInsets.only(top: 10),
child: CreatorLinkChips(
links: creatorLinks,
title: strings.ru ? 'Ссылки автора' : 'Author Links',
),
);
},
),
],
],
),
);
}
Widget _buildLinkTile(
BuildContext context,
CloudMediaLink link,
AppStrings strings,
) {
final theme = Theme.of(context);
final brandColor = link.brandColor;
return Container(
margin: const EdgeInsets.only(bottom: 10),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: theme.colorScheme.surface,
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: brandColor.withValues(alpha: 0.25),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: brandColor.withValues(alpha: 0.15),
shape: BoxShape.circle,
border: Border.all(color: brandColor.withValues(alpha: 0.35)),
),
child: Icon(link.iconData, size: 20, color: brandColor),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Flexible(
child: Text(
link.serviceName,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 6),
if (link.isFolder)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: theme.colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(6),
),
child: Text(
strings.ru ? 'Папка' : 'Folder',
style: theme.textTheme.labelSmall?.copyWith(
fontSize: 10,
color: theme.colorScheme.onSecondaryContainer,
),
),
),
],
),
const SizedBox(height: 2),
Text(
link.title,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
],
),
const SizedBox(height: 10),
// Action buttons row
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
// Play button (if streamable)
if (link.isStreamable && onPlayStream != null) ...[
FilledButton.tonalIcon(
style: FilledButton.styleFrom(
visualDensity: VisualDensity.compact,
padding: const EdgeInsets.symmetric(horizontal: 10),
),
icon: const Icon(Icons.play_arrow_rounded, size: 16),
label: Text(strings.ru ? 'Смотреть' : 'Play'),
onPressed: () {
final target = link.directStreamUrl ?? link.url;
onPlayStream!(target);
},
),
const SizedBox(width: 8),
],
// Download button (if direct streamable and callback exists)
if (link.isStreamable &&
link.directStreamUrl != null &&
onDownloadStream != null) ...[
IconButton.filledTonal(
visualDensity: VisualDensity.compact,
tooltip: strings.download,
icon: const Icon(Icons.download_rounded, size: 16),
onPressed: () => onDownloadStream!(link.directStreamUrl!),
),
const SizedBox(width: 8),
],
// Copy link button
IconButton.outlined(
visualDensity: VisualDensity.compact,
tooltip: strings.copyLink,
icon: const Icon(Icons.copy_rounded, size: 16),
onPressed: () {
Clipboard.setData(ClipboardData(text: link.url));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
strings.ru
? 'Ссылка скопирована: ${link.serviceName}'
: 'Link copied: ${link.serviceName}',
),
behavior: SnackBarBehavior.floating,
duration: const Duration(seconds: 2),
),
);
},
),
const SizedBox(width: 8),
// Open in browser / app button
FilledButton.icon(
style: FilledButton.styleFrom(
backgroundColor: brandColor,
foregroundColor: Colors.white,
visualDensity: VisualDensity.compact,
padding: const EdgeInsets.symmetric(horizontal: 12),
),
icon: const Icon(Icons.open_in_new_rounded, size: 15),
label: Text(strings.ru ? 'Открыть' : 'Open'),
onPressed: () => launchUrl(
Uri.parse(link.url),
mode: LaunchMode.externalApplication,
),
),
],
),
],
),
);
}
}
@@ -8,6 +8,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:media_kit/media_kit.dart'; import 'package:media_kit/media_kit.dart';
import 'package:media_kit_video/media_kit_video.dart'; import 'package:media_kit_video/media_kit_video.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../../../app/motion.dart'; import '../../../../app/motion.dart';
import '../../../../backend/backend.dart'; import '../../../../backend/backend.dart';
@@ -210,7 +211,21 @@ class _PostMediaViewerState extends State<PostMediaViewer>
); );
} }
final url = _imageUrls.isEmpty ? '' : _imageUrls[_imageIndex]; if (_imageUrls.isEmpty) {
return _CloudMediaHero(
post: widget.post,
onOpenPrimary: () {
final links = widget.post.cloudLinks;
final first = links.isNotEmpty ? links.first : null;
if (first != null) {
launchUrl(Uri.parse(first.url),
mode: LaunchMode.externalApplication);
}
},
);
}
final url = _imageUrls[_imageIndex];
final isLocal = url.startsWith('/') || url.startsWith('file://'); final isLocal = url.startsWith('/') || url.startsWith('file://');
final headers = _headersFor(widget.post); final headers = _headersFor(widget.post);
final Widget image; final Widget image;
@@ -419,7 +434,13 @@ class _PostMediaViewerState extends State<PostMediaViewer>
} }
List<String> _buildVideoUrls(Post post) { List<String> _buildVideoUrls(Post post) {
final list = MediaUrlSelector.video(post); final list = List<String>.from(MediaUrlSelector.video(post));
final cloudStreams = post.cloudLinks
.where((l) => l.isStreamable && l.directStreamUrl != null)
.map((l) => l.directStreamUrl!);
for (final stream in cloudStreams) {
if (!list.contains(stream)) list.add(stream);
}
final local = widget.localFilePath; final local = widget.localFilePath;
if (local != null && local.isNotEmpty && File(local).existsSync()) { if (local != null && local.isNotEmpty && File(local).existsSync()) {
return [local, ...list]; return [local, ...list];
@@ -428,6 +449,7 @@ class _PostMediaViewerState extends State<PostMediaViewer>
} }
bool _isVideo(Post post) { bool _isVideo(Post post) {
if (post.cloudLinks.any((l) => l.isStreamable)) return true;
final value = '${post.fileType} ${post.fileUrl}'.toLowerCase(); final value = '${post.fileType} ${post.fileUrl}'.toLowerCase();
return value.contains('video') || return value.contains('video') ||
value.contains('.webm') || value.contains('.webm') ||
@@ -679,6 +701,7 @@ class _FullscreenVideoPageState extends State<_FullscreenVideoPage> {
late bool _muted; late bool _muted;
late bool _loopVideo; late bool _loopVideo;
late bool _halfVolume; late bool _halfVolume;
late bool _isLandscape;
Timer? _hideTimer; Timer? _hideTimer;
@override @override
@@ -688,13 +711,30 @@ class _FullscreenVideoPageState extends State<_FullscreenVideoPage> {
_muted = widget.muted; _muted = widget.muted;
_loopVideo = widget.loopVideo; _loopVideo = widget.loopVideo;
_halfVolume = widget.halfVolume; _halfVolume = widget.halfVolume;
_isLandscape = widget.aspectRatio > 1.05;
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky); SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
_applyOrientation();
_scheduleControlsHide();
}
void _applyOrientation() {
if (_isLandscape) {
SystemChrome.setPreferredOrientations([ SystemChrome.setPreferredOrientations([
DeviceOrientation.landscapeLeft, DeviceOrientation.landscapeLeft,
DeviceOrientation.landscapeRight, DeviceOrientation.landscapeRight,
DeviceOrientation.portraitUp,
]); ]);
_scheduleControlsHide(); } else {
SystemChrome.setPreferredOrientations([
DeviceOrientation.portraitUp,
DeviceOrientation.portraitDown,
]);
}
}
void _toggleOrientation() {
setState(() => _isLandscape = !_isLandscape);
_applyOrientation();
_showControls();
} }
@override @override
@@ -792,7 +832,20 @@ class _FullscreenVideoPageState extends State<_FullscreenVideoPage> {
top: 12, top: 12,
right: 12, right: 12,
child: SafeArea( child: SafeArea(
child: IconButton.filledTonal( child: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton.filledTonal(
tooltip: _isLandscape
? 'Портретная ориентация'
: 'Альбомная ориентация',
onPressed: _toggleOrientation,
icon: Icon(_isLandscape
? Icons.screen_lock_portrait_rounded
: Icons.screen_lock_landscape_rounded),
),
const SizedBox(width: 8),
IconButton.filledTonal(
tooltip: 'Close fullscreen', tooltip: 'Close fullscreen',
onPressed: () => Navigator.of( onPressed: () => Navigator.of(
context, context,
@@ -800,6 +853,8 @@ class _FullscreenVideoPageState extends State<_FullscreenVideoPage> {
).pop(_snapshot()), ).pop(_snapshot()),
icon: const Icon(Icons.close_rounded), icon: const Icon(Icons.close_rounded),
), ),
],
),
), ),
), ),
], ],
@@ -2004,3 +2059,117 @@ class _VideoControls extends StatelessWidget {
return '$minutes:$seconds'; return '$minutes:$seconds';
} }
} }
class _CloudMediaHero extends StatelessWidget {
const _CloudMediaHero({
required this.post,
this.onOpenPrimary,
});
final Post post;
final VoidCallback? onOpenPrimary;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final isRu = Localizations.maybeLocaleOf(context)?.languageCode == 'ru';
final links = post.cloudLinks;
final primaryColor = links.isNotEmpty
? links.first.brandColor
: theme.colorScheme.primary;
return Center(
child: Container(
margin: const EdgeInsets.all(24),
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 28),
constraints: const BoxConstraints(maxWidth: 460),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(24),
border: Border.all(
color: primaryColor.withValues(alpha: 0.35),
width: 1.5,
),
boxShadow: [
BoxShadow(
color: primaryColor.withValues(alpha: 0.12),
blurRadius: 28,
spreadRadius: 2,
),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 68,
height: 68,
decoration: BoxDecoration(
color: primaryColor.withValues(alpha: 0.15),
shape: BoxShape.circle,
border: Border.all(
color: primaryColor.withValues(alpha: 0.4),
width: 2,
),
),
child: Icon(
links.isNotEmpty ? links.first.iconData : Icons.cloud_queue_rounded,
size: 34,
color: primaryColor,
),
),
const SizedBox(height: 16),
Text(
isRu
? 'Контент на внешнем диске'
: 'Cloud Drive Media',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
links.isNotEmpty
? (isRu
? 'Автор опубликовал медиа на ${links.map((l) => l.serviceName).toSet().join(', ')}.'
: 'Author hosted media on ${links.map((l) => l.serviceName).toSet().join(', ')}.')
: (isRu
? 'В данном посте нет медиафайла на сервере.'
: 'No media file hosted directly on the server.'),
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
if (links.isNotEmpty) ...[
const SizedBox(height: 20),
FilledButton.icon(
style: FilledButton.styleFrom(
backgroundColor: primaryColor,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(
horizontal: 22,
vertical: 12,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
),
icon: const Icon(Icons.open_in_new_rounded, size: 16),
label: Text(
isRu
? 'Открыть ${links.first.serviceName}'
: 'Open ${links.first.serviceName}',
style: const TextStyle(fontWeight: FontWeight.bold),
),
onPressed: onOpenPrimary,
),
],
],
),
),
);
}
}
@@ -219,6 +219,14 @@ class _PostTagsPanelState extends ConsumerState<PostTagsPanel> {
); );
} }
static const _ignoredTagGroupKeys = {
'cloud_links',
'description',
'content',
'links',
'external_links',
};
Map<String, List<String>> _groups(Post post) { Map<String, List<String>> _groups(Post post) {
if (post.tagGroups.isNotEmpty) { if (post.tagGroups.isNotEmpty) {
final ordered = <String, List<String>>{}; final ordered = <String, List<String>>{};
@@ -230,17 +238,40 @@ class _PostTagsPanelState extends ConsumerState<PostTagsPanel> {
'meta', 'meta',
'general', 'general',
]) { ]) {
final tags = post.tagGroups[key]; final tags = _filterValidTags(post.tagGroups[key]);
if (tags != null && tags.isNotEmpty) ordered[key] = tags; if (tags.isNotEmpty) ordered[key] = tags;
} }
for (final entry in post.tagGroups.entries) { for (final entry in post.tagGroups.entries) {
if (entry.value.isNotEmpty) { if (_ignoredTagGroupKeys.contains(entry.key.toLowerCase())) continue;
ordered.putIfAbsent(entry.key, () => entry.value); final tags = _filterValidTags(entry.value);
if (tags.isNotEmpty) {
ordered.putIfAbsent(entry.key, () => tags);
} }
} }
return ordered; return ordered;
} }
return post.tags.isEmpty ? const {} : {'general': post.tags}; final filtered = _filterValidTags(post.tags);
return filtered.isEmpty ? const {} : {'general': filtered};
}
List<String> _filterValidTags(List<String>? rawTags) {
if (rawTags == null || rawTags.isEmpty) return const [];
return rawTags.where((tag) {
final t = tag.trim();
if (t.isEmpty) return false;
if (t.startsWith('{') && t.endsWith('}')) return false;
if (t.contains('"url"') || t.contains('"service"')) return false;
if (t.startsWith('http://') || t.startsWith('https://')) return false;
if (t.contains('mega.nz') ||
t.contains('drive.google.com') ||
t.contains('dropbox.com') ||
t.contains('pixeldrain.com') ||
t.contains('catbox.moe') ||
t.contains('mediafire.com')) {
return false;
}
return true;
}).toList(growable: false);
} }
String _label(String key) { String _label(String key) {
@@ -129,7 +129,8 @@ class _ProviderFormScreenState extends ConsumerState<ProviderFormScreen> {
controller: _apiKey, controller: _apiKey,
decoration: const InputDecoration( decoration: const InputDecoration(
labelText: 'API key', labelText: 'API key',
helperText: 'Gelbooru: api_key, Danbooru: api_key', helperText:
'Gelbooru/Rule34/Danbooru API key (Account -> Options)',
), ),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
@@ -137,7 +138,8 @@ class _ProviderFormScreenState extends ConsumerState<ProviderFormScreen> {
controller: _userId, controller: _userId,
decoration: const InputDecoration( decoration: const InputDecoration(
labelText: 'User ID', labelText: 'User ID',
helperText: 'Gelbooru-compatible APIs may require user_id', helperText:
'Gelbooru/Rule34 User ID (required for API access, see Options)',
), ),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
@@ -105,6 +105,12 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
onSuggestionApplied: (query) => ref onSuggestionApplied: (query) => ref
.read(searchControllerProvider.notifier) .read(searchControllerProvider.notifier)
.updateQuery(query), .updateQuery(query),
onTagRemoved: (query) => ref
.read(searchControllerProvider.notifier)
.updateQuery(query),
onCleared: () => ref
.read(searchControllerProvider.notifier)
.updateQuery(''),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
SingleChildScrollView( SingleChildScrollView(
@@ -169,10 +175,21 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
icon: Icons.history_rounded, icon: Icons.history_rounded,
accentColor: const Color(0xFF6366F1), accentColor: const Color(0xFF6366F1),
title: isRu ? 'Недавние запросы' : 'Recent searches', title: isRu ? 'Недавние запросы' : 'Recent searches',
badgeCount: data.recent.length, badgeCount: data.recent
.map((e) => e.query.trim().toLowerCase())
.where((q) => q.isNotEmpty)
.toSet()
.length,
trailing: data.recent.isEmpty trailing: data.recent.isEmpty
? null ? null
: TextButton( : TextButton(
style: TextButton.styleFrom(
visualDensity: VisualDensity.compact,
padding: const EdgeInsets.symmetric(
horizontal: 10, vertical: 4),
minimumSize: Size.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
onPressed: () => ref onPressed: () => ref
.read(searchControllerProvider.notifier) .read(searchControllerProvider.notifier)
.clearHistory(), .clearHistory(),
@@ -384,9 +401,10 @@ class _SearchSectionCard extends StatelessWidget {
), ),
child: Icon(icon, size: 18, color: accentColor), child: Icon(icon, size: 18, color: accentColor),
), ),
const SizedBox(width: 12), const SizedBox(width: 10),
Expanded( Expanded(
child: Row( child: Row(
mainAxisSize: MainAxisSize.min,
children: [ children: [
Flexible( Flexible(
child: Text( child: Text(
@@ -395,6 +413,7 @@ class _SearchSectionCard extends StatelessWidget {
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: theme.textTheme.titleMedium?.copyWith( style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
fontSize: 15,
), ),
), ),
), ),
@@ -402,7 +421,7 @@ class _SearchSectionCard extends StatelessWidget {
const SizedBox(width: 8), const SizedBox(width: 8),
Container( Container(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 2), horizontal: 7, vertical: 2),
decoration: BoxDecoration( decoration: BoxDecoration(
color: accentColor.withValues(alpha: 0.15), color: accentColor.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(10),
@@ -47,8 +47,44 @@ class _RecentSearchesState extends State<RecentSearches> {
); );
} }
// Deduplicate items by normalized query
final seen = <String>{};
final uniqueItems = <SearchHistory>[];
for (final item in widget.items) {
final key = item.query.trim().toLowerCase();
if (key.isEmpty) continue;
if (seen.add(key)) {
uniqueItems.add(item);
}
}
if (uniqueItems.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(20).toList(); _expanded ? uniqueItems : uniqueItems.take(20).toList();
final screenW = MediaQuery.sizeOf(context).width;
final maxQueryWidth = (screenW - 200).clamp(180.0, 480.0);
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -66,7 +102,7 @@ class _RecentSearchesState extends State<RecentSearches> {
onTap: () => widget.onTap(item.query), onTap: () => widget.onTap(item.query),
child: Container( child: Container(
padding: padding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 6), const EdgeInsets.only(left: 10, right: 6, top: 6, bottom: 6),
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
border: Border.all( border: Border.all(
@@ -79,18 +115,22 @@ class _RecentSearchesState extends State<RecentSearches> {
children: [ children: [
Icon( Icon(
Icons.history_rounded, Icons.history_rounded,
size: 14, size: 15,
color: theme.colorScheme.primary, color: theme.colorScheme.primary,
), ),
const SizedBox(width: 6), const SizedBox(width: 6),
ConstrainedBox( ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 140), constraints: BoxConstraints(maxWidth: maxQueryWidth),
child: Tooltip(
message: item.query,
waitDuration: const Duration(milliseconds: 600),
child: Text( child: Text(
item.query, item.query,
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodyMedium?.copyWith( style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w500, fontWeight: FontWeight.w600,
),
), ),
), ),
), ),
@@ -100,14 +140,14 @@ class _RecentSearchesState extends State<RecentSearches> {
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
horizontal: 6, vertical: 2), horizontal: 6, vertical: 2),
decoration: BoxDecoration( decoration: BoxDecoration(
color: theme.colorScheme.secondaryContainer, color: theme.colorScheme.primary.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
), ),
child: Text( child: Text(
'${item.resultCount}', '${item.resultCount}',
style: theme.textTheme.labelSmall?.copyWith( style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSecondaryContainer, color: theme.colorScheme.primary,
fontSize: 10, fontSize: 11,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
), ),
@@ -115,14 +155,21 @@ class _RecentSearchesState extends State<RecentSearches> {
], ],
if (widget.onDelete != null) ...[ if (widget.onDelete != null) ...[
const SizedBox(width: 4), const SizedBox(width: 4),
GestureDetector( Material(
color: Colors.transparent,
shape: const CircleBorder(),
clipBehavior: Clip.antiAlias,
child: InkWell(
customBorder: const CircleBorder(),
onTap: () => widget.onDelete!(item.id), onTap: () => widget.onDelete!(item.id),
child: Padding( child: Padding(
padding: const EdgeInsets.all(2), padding: const EdgeInsets.all(4),
child: Icon( child: Icon(
Icons.close_rounded, Icons.close_rounded,
size: 14, size: 15,
color: theme.colorScheme.onSurfaceVariant, color: theme.colorScheme.onSurfaceVariant
.withValues(alpha: 0.75),
),
), ),
), ),
), ),
@@ -134,7 +181,7 @@ class _RecentSearchesState extends State<RecentSearches> {
), ),
], ],
), ),
if (widget.items.length > 20) ...[ if (uniqueItems.length > 20) ...[
const SizedBox(height: 10), const SizedBox(height: 10),
Center( Center(
child: TextButton.icon( child: TextButton.icon(
@@ -148,7 +195,7 @@ class _RecentSearchesState extends State<RecentSearches> {
label: Text( label: Text(
_expanded _expanded
? 'Свернуть' ? 'Свернуть'
: 'Показать все (${widget.items.length})', : 'Показать все (${uniqueItems.length})',
), ),
), ),
), ),
@@ -15,6 +15,7 @@ import '../../../backend/backend.dart';
import '../../../core/utils/result.dart'; import '../../../core/utils/result.dart';
import '../../../shared/widgets/adaptive_scaffold.dart'; import '../../../shared/widgets/adaptive_scaffold.dart';
import '../../../shared/widgets/error_view.dart'; import '../../../shared/widgets/error_view.dart';
import '../../artists/presentation/widgets/pawchive_accounts_sheet.dart';
import 'settings_controller.dart'; import 'settings_controller.dart';
class SettingsScreen extends ConsumerWidget { class SettingsScreen extends ConsumerWidget {
@@ -54,6 +55,7 @@ class _SettingsContentState extends ConsumerState<_SettingsContent> {
final _feedKey = GlobalKey(); final _feedKey = GlobalKey();
final _filtersKey = GlobalKey(); final _filtersKey = GlobalKey();
final _storageKey = GlobalKey(); final _storageKey = GlobalKey();
final _accountsKey = GlobalKey();
final _diagnosticsKey = GlobalKey(); final _diagnosticsKey = GlobalKey();
final _aboutKey = GlobalKey(); final _aboutKey = GlobalKey();
@@ -126,6 +128,12 @@ class _SettingsContentState extends ConsumerState<_SettingsContent> {
icon: Icons.storage_rounded, icon: Icons.storage_rounded,
color: const Color(0xFF06B6D4), color: const Color(0xFF06B6D4),
), ),
(
key: _accountsKey,
label: isRu ? 'Аккаунты' : 'Accounts',
icon: Icons.cloud_sync_rounded,
color: const Color(0xFF0EA5E9),
),
( (
key: _diagnosticsKey, key: _diagnosticsKey,
label: isRu ? 'Диагностика' : 'Diagnostics', label: isRu ? 'Диагностика' : 'Diagnostics',
@@ -751,6 +759,42 @@ class _SettingsContentState extends ConsumerState<_SettingsContent> {
], ],
), ),
// Accounts & Sync Section
_SettingsCardGroup(
sectionKey: _accountsKey,
title: isRu ? 'Аккаунты и синхронизация' : 'Accounts & Sync',
icon: Icons.cloud_sync_rounded,
accentColor: const Color(0xFF0EA5E9),
children: [
ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
leading: const CircleAvatar(
backgroundColor: Color(0xFF0EA5E9),
foregroundColor: Colors.white,
child: Icon(Icons.cloud_sync_rounded),
),
title: Text(
isRu ? 'Аккаунты Pawchive' : 'Pawchive Accounts',
style: const TextStyle(fontWeight: FontWeight.bold),
),
subtitle: Text(
settings.parsedPawchiveAccounts.isEmpty
? (isRu
? 'Вход для синхронизации избранных авторов'
: 'Sign in to sync favorite creators')
: '${settings.parsedPawchiveAccounts.length} ${isRu ? "аккаунт(ов)" : "account(s)"}'
'${settings.activePawchiveAccount != null ? " • @${settings.activePawchiveAccount!.username}" : ""}',
),
trailing: FilledButton.tonal(
onPressed: () => PawchiveAccountsSheet.show(context),
child: Text(settings.parsedPawchiveAccounts.isEmpty
? (isRu ? 'Войти' : 'Login')
: (isRu ? 'Управление' : 'Manage')),
),
),
],
),
// 6. Providers & Diagnostics Section // 6. Providers & Diagnostics Section
_SettingsCardGroup( _SettingsCardGroup(
sectionKey: _diagnosticsKey, sectionKey: _diagnosticsKey,
@@ -914,12 +958,216 @@ class _SettingsContentState extends ConsumerState<_SettingsContent> {
} }
Future<void> _checkUpdates(BuildContext context, WidgetRef ref) async { Future<void> _checkUpdates(BuildContext context, WidgetRef ref) async {
final isRu = settings.languageCode == 'ru';
final theme = Theme.of(context);
final selectedSource = await showModalBottomSheet<UpdateSource>(
context: context,
backgroundColor: Colors.transparent,
builder: (sheetContext) {
return Container(
decoration: BoxDecoration(
color: theme.colorScheme.surface,
borderRadius: const BorderRadius.vertical(top: Radius.circular(28)),
),
padding: const EdgeInsets.fromLTRB(20, 12, 20, 28),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Center(
child: Container(
width: 36,
height: 4,
margin: const EdgeInsets.only(bottom: 16),
decoration: BoxDecoration(
color: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(2),
),
),
),
Text(
isRu ? 'Источник обновлений' : 'Update Source',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 6),
Text(
isRu
? 'Выберите сервер для загрузки обновлений'
: 'Choose a server to download updates from',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 20),
// Option 1: GitHub (Recommended for speed)
Material(
color: theme.colorScheme.primaryContainer.withValues(alpha: 0.35),
borderRadius: BorderRadius.circular(16),
child: InkWell(
borderRadius: BorderRadius.circular(16),
onTap: () => Navigator.pop(sheetContext, UpdateSource.github),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: theme.colorScheme.primary.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(12),
),
child: Icon(
Icons.bolt_rounded,
color: theme.colorScheme.primary,
size: 24,
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
'GitHub',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
decoration: BoxDecoration(
color: Colors.green.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(6),
),
child: Text(
isRu ? 'Рекомендуется для скорости' : 'Recommended for speed',
style: const TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
color: Colors.green,
),
),
),
],
),
const SizedBox(height: 3),
Text(
isRu
? 'Прямая быстрая загрузка без ограничений'
: 'Direct fast download without limits',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
),
Icon(
Icons.arrow_forward_ios_rounded,
size: 14,
color: theme.colorScheme.onSurfaceVariant,
),
],
),
),
),
),
const SizedBox(height: 12),
// Option 2: Gitea (Muted / neutral grey)
Material(
color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.25),
borderRadius: BorderRadius.circular(16),
child: InkWell(
borderRadius: BorderRadius.circular(16),
onTap: () => Navigator.pop(sheetContext, UpdateSource.gitea),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: theme.colorScheme.onSurface.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(12),
),
child: Icon(
Icons.dns_rounded,
color: theme.colorScheme.onSurfaceVariant,
size: 22,
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Gitea',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 3),
Text(
isRu
? 'Сервер релизов'
: 'Release server',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.7),
),
),
],
),
),
Icon(
Icons.arrow_forward_ios_rounded,
size: 14,
color: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.5),
),
],
),
),
),
),
],
),
);
},
);
if (selectedSource == null) return;
if (!context.mounted) return;
final messenger = ScaffoldMessenger.of(context); final messenger = ScaffoldMessenger.of(context);
messenger.showSnackBar( messenger.showSnackBar(
const SnackBar(content: Text('Checking for updates...')), SnackBar(
content: Text(
selectedSource == UpdateSource.gitea
? (isRu ? 'Проверка обновлений на Gitea...' : 'Checking updates on Gitea...')
: (isRu ? 'Проверка обновлений на GitHub...' : 'Checking updates on GitHub...'),
),
),
);
final result = await ref.read(updateServiceProvider).checkForUpdates(
force: true,
source: selectedSource,
); );
final result =
await ref.read(updateServiceProvider).checkForUpdates(force: true);
if (!context.mounted) return; if (!context.mounted) return;
messenger.hideCurrentSnackBar(); messenger.hideCurrentSnackBar();
if (result is Error<AppUpdateInfo?>) { if (result is Error<AppUpdateInfo?>) {
@@ -929,7 +1177,11 @@ class _SettingsContentState extends ConsumerState<_SettingsContent> {
final update = (result as Success<AppUpdateInfo?>).data; final update = (result as Success<AppUpdateInfo?>).data;
if (update == null) { if (update == null) {
messenger.showSnackBar( messenger.showSnackBar(
const SnackBar(content: Text('Lunaris is up to date')), SnackBar(
content: Text(
isRu ? 'У вас установлена последняя версия Lunaris' : 'Lunaris is up to date',
),
),
); );
return; return;
} }
@@ -986,7 +1238,7 @@ class _SettingsContentState extends ConsumerState<_SettingsContent> {
Future<void> _downloadUpdate(WidgetRef ref, AppUpdateInfo info) async { Future<void> _downloadUpdate(WidgetRef ref, AppUpdateInfo info) async {
final updateService = ref.read(updateServiceProvider); final updateService = ref.read(updateServiceProvider);
final assetUrl = updateService.assetUrlForCurrentPlatform(info); final assetUrl = await updateService.assetUrlForCurrentPlatform(info);
if (assetUrl == null) { if (assetUrl == null) {
final url = Uri.tryParse(info.htmlUrl); final url = Uri.tryParse(info.htmlUrl);
if (url != null) { if (url != null) {
@@ -2139,12 +2391,12 @@ class _HeroBrandBanner extends StatelessWidget {
OutlinedButton.icon( OutlinedButton.icon(
onPressed: () async { onPressed: () async {
final url = Uri.parse( final url = Uri.parse(
'https://gitea.rardogsynapse.online/RarDog/Lunaris', 'https://github.com/RarDog/Lunaris',
); );
await launchUrl(url, mode: LaunchMode.externalApplication); await launchUrl(url, mode: LaunchMode.externalApplication);
}, },
icon: const Icon(Icons.code_rounded, size: 18), icon: const Icon(Icons.code_rounded, size: 18),
label: const Text('Gitea'), label: const Text('GitHub'),
), ),
], ],
), ),
+195 -100
View File
@@ -28,6 +28,8 @@ class TagInputSearchBar extends StatefulWidget {
required this.onSubmitted, required this.onSubmitted,
this.onChanged, this.onChanged,
this.onSuggestionApplied, this.onSuggestionApplied,
this.onTagRemoved,
this.onCleared,
this.initialValue, this.initialValue,
this.hintText = 'Search tags', this.hintText = 'Search tags',
this.suggestions = const [], this.suggestions = const [],
@@ -37,6 +39,8 @@ class TagInputSearchBar extends StatefulWidget {
final ValueChanged<String> onSubmitted; final ValueChanged<String> onSubmitted;
final ValueChanged<String>? onChanged; final ValueChanged<String>? onChanged;
final ValueChanged<String>? onSuggestionApplied; final ValueChanged<String>? onSuggestionApplied;
final ValueChanged<String>? onTagRemoved;
final VoidCallback? onCleared;
final String? initialValue; final String? initialValue;
final String hintText; final String hintText;
final List<TagSuggestion> suggestions; final List<TagSuggestion> suggestions;
@@ -48,10 +52,10 @@ class TagInputSearchBar extends StatefulWidget {
class _TagInputSearchBarState extends State<TagInputSearchBar> { class _TagInputSearchBarState extends State<TagInputSearchBar> {
final _fieldKey = GlobalKey(); final _fieldKey = GlobalKey();
final _layerLink = LayerLink(); final _layerLink = LayerLink();
final _portalController = OverlayPortalController();
late final TextEditingController _controller; late final TextEditingController _controller;
late final FocusNode _focusNode; late final FocusNode _focusNode;
late final ScrollController _tagScrollController; late final ScrollController _tagScrollController;
OverlayEntry? _suggestionsOverlay;
Timer? _debounce; Timer? _debounce;
List<String> _tags = []; List<String> _tags = [];
String _lastExternalValue = ''; String _lastExternalValue = '';
@@ -64,10 +68,17 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
_focusNode = FocusNode(); _focusNode = FocusNode();
_tagScrollController = ScrollController(); _tagScrollController = ScrollController();
_focusNode.addListener(_handleFocusChanged); _focusNode.addListener(_handleFocusChanged);
_controller.addListener(_handleControllerChanged);
_lastExternalValue = widget.initialValue ?? ''; _lastExternalValue = widget.initialValue ?? '';
_setFromQuery(_lastExternalValue); _setFromQuery(_lastExternalValue);
} }
void _handleControllerChanged() {
if (_controller.text.trim().isEmpty && _portalController.isShowing) {
_portalController.hide();
}
}
@override @override
void didUpdateWidget(covariant TagInputSearchBar oldWidget) { void didUpdateWidget(covariant TagInputSearchBar oldWidget) {
super.didUpdateWidget(oldWidget); super.didUpdateWidget(oldWidget);
@@ -80,15 +91,18 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
_localDirty = false; _localDirty = false;
} }
if (oldWidget.suggestions != widget.suggestions) { if (oldWidget.suggestions != widget.suggestions) {
_syncSuggestionsOverlay(); _syncSuggestions();
} }
} }
@override @override
void dispose() { void dispose() {
_debounce?.cancel(); _debounce?.cancel();
_removeSuggestionsOverlay(); if (_portalController.isShowing) {
_portalController.hide();
}
_tagScrollController.dispose(); _tagScrollController.dispose();
_controller.removeListener(_handleControllerChanged);
_focusNode.removeListener(_handleFocusChanged); _focusNode.removeListener(_handleFocusChanged);
_focusNode.dispose(); _focusNode.dispose();
_controller.dispose(); _controller.dispose();
@@ -97,24 +111,138 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
void _handleFocusChanged() { void _handleFocusChanged() {
if (!mounted) return; if (!mounted) return;
if (!_focusNode.hasFocus && !_localDirty) { if (!_focusNode.hasFocus) {
if (_portalController.isShowing) {
_portalController.hide();
}
if (!_localDirty) {
final next = widget.initialValue ?? ''; final next = widget.initialValue ?? '';
if (next != _lastExternalValue) { if (next != _lastExternalValue) {
_setFromQuery(next); _setFromQuery(next);
_lastExternalValue = next; _lastExternalValue = next;
} }
} }
} else {
_syncSuggestions();
}
setState(() {}); setState(() {});
_syncSuggestionsOverlay(); }
void _syncSuggestions() {
if (!mounted) return;
final token = _activeToken;
if (!_focusNode.hasFocus || token.isEmpty) {
if (_portalController.isShowing) {
_portalController.hide();
}
return;
}
final matches = _matchingSuggestions;
if (matches.isEmpty) {
if (_portalController.isShowing) {
_portalController.hide();
}
return;
}
if (!_portalController.isShowing) {
_portalController.show();
}
}
List<TagSuggestion> get _matchingSuggestions {
final token = _activeToken;
if (token.isEmpty) return const [];
return widget.suggestions
.where((item) => item.name.toLowerCase().startsWith(token))
.toList(growable: false);
}
void _hideSuggestions() {
if (_portalController.isShowing) {
_portalController.hide();
}
_focusNode.unfocus();
}
void _scrollToEnd() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_tagScrollController.hasClients) {
_tagScrollController.animateTo(
_tagScrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: 200),
curve: Curves.easeOut,
);
}
});
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme; final scheme = Theme.of(context).colorScheme;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _syncSuggestionsOverlay(); return TapRegion(
}); groupId: _fieldKey,
return CompositedTransformTarget( onTapOutside: (_) => _hideSuggestions(),
child: OverlayPortal(
controller: _portalController,
overlayChildBuilder: (context) {
final token = _activeToken;
final suggestions = _matchingSuggestions;
if (suggestions.isEmpty || token.isEmpty) {
return const SizedBox.shrink();
}
final screenSize = MediaQuery.sizeOf(context);
final renderBox =
_fieldKey.currentContext?.findRenderObject() as RenderBox?;
final fieldWidth =
renderBox?.hasSize == true ? renderBox!.size.width : 360.0;
final fieldHeight =
renderBox?.hasSize == true ? renderBox!.size.height : 48.0;
final screenW = screenSize.width;
// Substantially wider dropdown:
// Mobile (< 600px): use full screen width minus comfortable 24px margins.
// Desktop/Tablet (>= 600px): expand to 1.35x field width, up to 780px.
final dropdownWidth = screenW < 600
? (screenW - 24.0).clamp(fieldWidth, 580.0)
: (fieldWidth * 1.35).clamp(fieldWidth, (screenW - 32.0).clamp(fieldWidth, 780.0));
// Ensure dropdown stays fully on screen and doesn't get clipped
double dxOffset = 0.0;
if (renderBox?.hasSize == true && renderBox?.attached == true) {
final globalPos = renderBox!.localToGlobal(Offset.zero);
final globalRight = globalPos.dx + dropdownWidth;
final maxRight = screenW - 12.0;
if (globalRight > maxRight) {
dxOffset = maxRight - globalRight;
}
if (globalPos.dx + dxOffset < 12.0) {
dxOffset = 12.0 - globalPos.dx;
}
}
return CompositedTransformFollower(
link: _layerLink,
showWhenUnlinked: false,
offset: Offset(dxOffset, fieldHeight + 6),
child: Align(
alignment: Alignment.topLeft,
child: TapRegion(
groupId: _fieldKey,
child: SizedBox(
width: dropdownWidth,
child: _TagSuggestionDropdown(
suggestions: suggestions,
query: token,
onSelected: _applySuggestion,
),
),
),
),
);
},
child: CompositedTransformTarget(
link: _layerLink, link: _layerLink,
child: DecoratedBox( child: DecoratedBox(
key: _fieldKey, key: _fieldKey,
@@ -133,11 +261,13 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
borderRadius: BorderRadius.circular(22), 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),
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
Icon(Icons.search_rounded, color: scheme.onSurfaceVariant), Icon(Icons.search_rounded,
color: scheme.onSurfaceVariant),
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded( Expanded(
child: SizedBox( child: SizedBox(
@@ -167,7 +297,8 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
focusNode: _focusNode, focusNode: _focusNode,
autocorrect: false, autocorrect: false,
enableSuggestions: false, enableSuggestions: false,
textInputAction: TextInputAction.search, textInputAction:
TextInputAction.search,
onSubmitted: (_) => _submit(), onSubmitted: (_) => _submit(),
onChanged: _handleDraftChanged, onChanged: _handleDraftChanged,
decoration: InputDecoration( decoration: InputDecoration(
@@ -176,11 +307,13 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
enabledBorder: InputBorder.none, enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none, focusedBorder: InputBorder.none,
filled: false, filled: false,
contentPadding: const EdgeInsets.symmetric( contentPadding:
const EdgeInsets.symmetric(
vertical: 8, vertical: 8,
), ),
hintText: hintText: _tags.isEmpty
_tags.isEmpty ? widget.hintText : 'tag', ? widget.hintText
: 'tag',
prefixIcon: null, prefixIcon: null,
suffixIcon: null, suffixIcon: null,
), ),
@@ -204,41 +337,9 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
), ),
), ),
), ),
); ),
}
void _syncSuggestionsOverlay() {
final token = _activeToken;
final suggestions = widget.suggestions
.where((item) => item.name.toLowerCase().startsWith(token))
.toList(growable: false);
if (!_focusNode.hasFocus || token.isEmpty || suggestions.isEmpty) {
_removeSuggestionsOverlay();
return;
}
final overlay = Overlay.maybeOf(context);
final renderBox =
_fieldKey.currentContext?.findRenderObject() as RenderBox?;
if (overlay == null || renderBox == null || !renderBox.hasSize) return;
final size = renderBox.size;
_removeSuggestionsOverlay();
_suggestionsOverlay = OverlayEntry(
builder: (context) => _TagSuggestionOverlay(
link: _layerLink,
width: size.width,
yOffset: size.height + 8,
suggestions: suggestions,
query: token,
onSelected: _applySuggestion,
), ),
); );
overlay.insert(_suggestionsOverlay!);
}
void _removeSuggestionsOverlay() {
_suggestionsOverlay?.remove();
_suggestionsOverlay = null;
} }
Widget _buildTagChip(BuildContext context, String tag) { Widget _buildTagChip(BuildContext context, String tag) {
@@ -302,10 +403,17 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
void _handleDraftChanged(String value) { void _handleDraftChanged(String value) {
_localDirty = true; _localDirty = true;
if (value.trim().isEmpty) {
_debounce?.cancel();
if (_portalController.isShowing) {
_portalController.hide();
}
setState(() {});
_notifyChanged();
return;
}
final composing = _controller.value.composing; final composing = _controller.value.composing;
if (!composing.isValid && if (!composing.isValid && RegExp(r'\s$').hasMatch(value)) {
value.isNotEmpty &&
RegExp(r'\s$').hasMatch(value)) {
_commitDraft(value); _commitDraft(value);
return; return;
} }
@@ -329,31 +437,47 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
} }
_controller.clear(); _controller.clear();
}); });
if (_portalController.isShowing) {
_portalController.hide();
}
_scrollToEnd();
_notifyChanged(); _notifyChanged();
} }
void _submit() { void _submit() {
_debounce?.cancel(); _debounce?.cancel();
if (_portalController.isShowing) {
_portalController.hide();
}
_commitDraft(_controller.text); _commitDraft(_controller.text);
_removeSuggestionsOverlay();
_lastExternalValue = _query; _lastExternalValue = _query;
_localDirty = false; _localDirty = false;
_focusNode.unfocus();
widget.onSubmitted(_query); widget.onSubmitted(_query);
} }
void _clear() { void _clear() {
_debounce?.cancel(); _debounce?.cancel();
_localDirty = true; if (_portalController.isShowing) {
_portalController.hide();
}
_localDirty = false;
setState(() { setState(() {
_tags = []; _tags = [];
_controller.clear(); _controller.clear();
}); });
_lastExternalValue = '';
_notifyChanged(); _notifyChanged();
_focusNode.requestFocus(); _focusNode.unfocus();
widget.onCleared?.call();
widget.onSubmitted('');
} }
void _editTag(String tag) { void _editTag(String tag) {
_localDirty = true; _localDirty = true;
if (_portalController.isShowing) {
_portalController.hide();
}
setState(() { setState(() {
_tags = _tags.where((item) => item != tag).toList(growable: true); _tags = _tags.where((item) => item != tag).toList(growable: true);
_controller.value = TextEditingValue( _controller.value = TextEditingValue(
@@ -367,10 +491,17 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
void _removeTag(String tag) { void _removeTag(String tag) {
_localDirty = true; _localDirty = true;
if (_portalController.isShowing) {
_portalController.hide();
}
setState(() { setState(() {
_tags = _tags.where((item) => item != tag).toList(growable: true); _tags = _tags.where((item) => item != tag).toList(growable: true);
}); });
_lastExternalValue = _query;
_notifyChanged(); _notifyChanged();
if (widget.onTagRemoved != null) {
widget.onTagRemoved!(_query);
}
} }
void _applySuggestion(String suggestion) { void _applySuggestion(String suggestion) {
@@ -393,8 +524,12 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
} }
_controller.clear(); _controller.clear();
}); });
if (_portalController.isShowing) {
_portalController.hide();
}
_lastExternalValue = _query;
_scrollToEnd();
_notifyChanged(); _notifyChanged();
_removeSuggestionsOverlay();
widget.onSuggestionApplied?.call(_query); widget.onSuggestionApplied?.call(_query);
_focusNode.requestFocus(); _focusNode.requestFocus();
} }
@@ -407,7 +542,7 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
void _notifyChanged() { void _notifyChanged() {
_localDirty = true; _localDirty = true;
widget.onChanged?.call(_query); widget.onChanged?.call(_query);
_syncSuggestionsOverlay(); _syncSuggestions();
} }
List<String> _parseTags(String query) { List<String> _parseTags(String query) {
@@ -438,46 +573,6 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
bool get _isEditing => _focusNode.hasFocus || _localDirty; bool get _isEditing => _focusNode.hasFocus || _localDirty;
} }
class _TagSuggestionOverlay extends StatelessWidget {
const _TagSuggestionOverlay({
required this.link,
required this.width,
required this.yOffset,
required this.suggestions,
required this.query,
required this.onSelected,
});
final LayerLink link;
final double width;
final double yOffset;
final List<TagSuggestion> suggestions;
final String query;
final ValueChanged<String> onSelected;
@override
Widget build(BuildContext context) {
return Positioned.fill(
child: CompositedTransformFollower(
link: link,
showWhenUnlinked: false,
offset: Offset(0, yOffset),
child: Align(
alignment: Alignment.topLeft,
child: SizedBox(
width: width.clamp(280, 620).toDouble(),
child: _TagSuggestionDropdown(
suggestions: suggestions,
query: query,
onSelected: onSelected,
),
),
),
),
);
}
}
class _TagSuggestionDropdown extends StatelessWidget { class _TagSuggestionDropdown extends StatelessWidget {
const _TagSuggestionDropdown({ const _TagSuggestionDropdown({
required this.suggestions, required this.suggestions,
@@ -493,7 +588,7 @@ class _TagSuggestionDropdown extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme; final scheme = Theme.of(context).colorScheme;
return ConstrainedBox( return ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 620), constraints: const BoxConstraints(maxWidth: 820),
child: Material( child: Material(
elevation: 8, elevation: 8,
shadowColor: Colors.black.withValues(alpha: 0.18), shadowColor: Colors.black.withValues(alpha: 0.18),
@@ -569,7 +664,7 @@ class _TagSuggestionDropdown extends StatelessWidget {
if (cleanToken.isEmpty) { if (cleanToken.isEmpty) {
return Text( return Text(
text, text,
maxLines: 1, maxLines: 2,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: defaultStyle, style: defaultStyle,
); );
@@ -581,7 +676,7 @@ class _TagSuggestionDropdown extends StatelessWidget {
if (matchIndex < 0) { if (matchIndex < 0) {
return Text( return Text(
text, text,
maxLines: 1, maxLines: 2,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: defaultStyle, style: defaultStyle,
); );
@@ -606,7 +701,7 @@ class _TagSuggestionDropdown extends StatelessWidget {
if (after.isNotEmpty) TextSpan(text: after), if (after.isNotEmpty) TextSpan(text: after),
], ],
), ),
maxLines: 1, maxLines: 2,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
); );
} }
@@ -0,0 +1,261 @@
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../backend/models/creator_link.dart';
class FormattedContentText extends StatefulWidget {
const FormattedContentText({
super.key,
required this.text,
this.style,
this.linkColor,
this.selectable = true,
});
final String text;
final TextStyle? style;
final Color? linkColor;
final bool selectable;
@override
State<FormattedContentText> createState() => _FormattedContentTextState();
}
class _FormattedContentTextState extends State<FormattedContentText> {
final List<TapGestureRecognizer> _recognizers = [];
@override
void dispose() {
for (final r in _recognizers) {
r.dispose();
}
_recognizers.clear();
super.dispose();
}
void _clearRecognizers() {
for (final r in _recognizers) {
r.dispose();
}
_recognizers.clear();
}
Future<void> _launch(String url) async {
final uri = Uri.tryParse(url);
if (uri != null) {
try {
await launchUrl(uri, mode: LaunchMode.externalApplication);
} catch (_) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Не удалось открыть ссылку: $url')),
);
}
}
}
}
@override
Widget build(BuildContext context) {
_clearRecognizers();
final theme = Theme.of(context);
final scheme = theme.colorScheme;
final defaultStyle = widget.style ?? theme.textTheme.bodyMedium ?? const TextStyle();
final linkStyle = defaultStyle.copyWith(
color: widget.linkColor ?? scheme.primary,
fontWeight: FontWeight.w600,
decoration: TextDecoration.underline,
decorationColor: (widget.linkColor ?? scheme.primary).withValues(alpha: 0.45),
);
final text = widget.text;
if (text.isEmpty) return const SizedBox.shrink();
final urlRegex = RegExp(r'''https?://[^\s<>"{}|\\^`\[\]]+''', caseSensitive: false);
final spans = <InlineSpan>[];
var lastIndex = 0;
for (final match in urlRegex.allMatches(text)) {
if (match.start > lastIndex) {
spans.add(TextSpan(
text: text.substring(lastIndex, match.start),
style: defaultStyle,
));
}
var rawUrl = match.group(0)!;
var cleanUrl = rawUrl;
var trailing = '';
while (cleanUrl.isNotEmpty &&
(cleanUrl.endsWith(')') ||
cleanUrl.endsWith('.') ||
cleanUrl.endsWith(',') ||
cleanUrl.endsWith(';') ||
cleanUrl.endsWith('!') ||
cleanUrl.endsWith('?') ||
cleanUrl.endsWith(':') ||
cleanUrl.endsWith('>'))) {
trailing = cleanUrl[cleanUrl.length - 1] + trailing;
cleanUrl = cleanUrl.substring(0, cleanUrl.length - 1);
}
final recognizer = TapGestureRecognizer()
..onTap = () => _launch(cleanUrl);
_recognizers.add(recognizer);
spans.add(TextSpan(
text: cleanUrl,
style: linkStyle,
recognizer: recognizer,
));
if (trailing.isNotEmpty) {
spans.add(TextSpan(
text: trailing,
style: defaultStyle,
));
}
lastIndex = match.end;
}
if (lastIndex < text.length) {
spans.add(TextSpan(
text: text.substring(lastIndex),
style: defaultStyle,
));
}
if (widget.selectable) {
return SelectableText.rich(
TextSpan(children: spans),
style: defaultStyle,
);
}
return Text.rich(
TextSpan(children: spans),
style: defaultStyle,
);
}
}
class CreatorLinkChips extends StatelessWidget {
const CreatorLinkChips({
super.key,
required this.links,
this.title,
});
final List<CreatorLink> links;
final String? title;
Future<void> _openLink(BuildContext context, String url) async {
final uri = Uri.tryParse(url);
if (uri != null) {
try {
await launchUrl(uri, mode: LaunchMode.externalApplication);
} catch (_) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Не удалось открыть ссылку: $url')),
);
}
}
}
}
@override
Widget build(BuildContext context) {
if (links.isEmpty) return const SizedBox.shrink();
final theme = Theme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
if (title != null && title!.isNotEmpty) ...[
Padding(
padding: const EdgeInsets.only(bottom: 6),
child: Row(
children: [
Icon(
Icons.link_rounded,
size: 15,
color: theme.colorScheme.primary,
),
const SizedBox(width: 6),
Text(
title!,
style: theme.textTheme.labelMedium?.copyWith(
fontWeight: FontWeight.bold,
color: theme.colorScheme.primary,
),
),
],
),
),
],
Wrap(
spacing: 8,
runSpacing: 6,
children: links.map((link) {
final brand = link.brandColor;
final labelText = link.title.isNotEmpty ? link.title : link.serviceName;
return Material(
color: brand.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(10),
child: InkWell(
borderRadius: BorderRadius.circular(10),
onTap: () => _openLink(context, link.url),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: brand.withValues(alpha: 0.35),
width: 1,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
link.iconData,
size: 16,
color: brand,
),
const SizedBox(width: 6),
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 240),
child: Text(
labelText,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: theme.colorScheme.onSurface,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 4),
Icon(
Icons.arrow_outward_rounded,
size: 12,
color: brand.withValues(alpha: 0.75),
),
],
),
),
),
);
}).toList(),
),
],
);
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
name: gel_rule_app name: gel_rule_app
description: Local backend/core layer for a Flutter booru browser app. description: Local backend/core layer for a Flutter booru browser app.
publish_to: "none" publish_to: "none"
version: 2.2.0+39 version: 3.0.6+57
environment: environment:
sdk: ">=3.3.0 <4.0.0" sdk: ">=3.3.0 <4.0.0"
+118
View File
@@ -0,0 +1,118 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:gel_rule_app/backend/models/cloud_media_link.dart';
import 'package:gel_rule_app/backend/services/cloud_link_extractor.dart';
void main() {
group('CloudLinkExtractor tests', () {
test('extracts and parses Google Drive links with direct stream URLs', () {
const html = '''
<p>New animation release!</p>
<p>Download here: <a href="https://drive.google.com/file/d/1AbC-DeFgHiJkLmNoPqRsTuVwXyZ123/view?usp=sharing">Google Drive 4K</a></p>
<p>Or browse the full folder: https://drive.google.com/drive/folders/9ZyX-WvUtSrQpOnMlKjIhGfEdCbA456</p>
''';
final links = CloudLinkExtractor.extractLinks(content: html);
expect(links.length, 2);
final fileLink = links.firstWhere((l) => l.service == CloudServiceType.googleDrive && !l.isFolder);
expect(fileLink.title, 'Google Drive 4K');
expect(fileLink.isStreamable, isTrue);
expect(fileLink.directStreamUrl, 'https://drive.google.com/uc?export=download&id=1AbC-DeFgHiJkLmNoPqRsTuVwXyZ123');
final folderLink = links.firstWhere((l) => l.service == CloudServiceType.googleDrive && l.isFolder);
expect(folderLink.isFolder, isTrue);
expect(folderLink.isStreamable, isFalse);
});
test('extracts MEGA links and detects folders', () {
const content = '''
Archive mirrors:
Mega file: https://mega.nz/file/xyz123#abcdefg
Mega folder: https://mega.nz/folder/abc456#1234567
''';
final links = CloudLinkExtractor.extractLinks(content: content);
expect(links.length, 2);
final fileLink = links.firstWhere((l) => !l.isFolder);
expect(fileLink.service, CloudServiceType.mega);
expect(fileLink.serviceName, 'MEGA');
expect(fileLink.isFolder, isFalse);
final folderLink = links.firstWhere((l) => l.isFolder);
expect(folderLink.service, CloudServiceType.mega);
expect(folderLink.isFolder, isTrue);
});
test('transforms Dropbox links into direct stream URLs with ?raw=1', () {
const content = 'https://www.dropbox.com/s/sample123/video.mp4?dl=0';
final links = CloudLinkExtractor.extractLinks(content: content);
expect(links.length, 1);
final link = links.first;
expect(link.service, CloudServiceType.dropbox);
expect(link.isStreamable, isTrue);
expect(link.directStreamUrl, 'https://www.dropbox.com/s/sample123/video.mp4?raw=1');
});
test('extracts Pixeldrain file IDs and generates API endpoints', () {
const content = 'Pixeldrain mirror: https://pixeldrain.com/u/abc123xy';
final links = CloudLinkExtractor.extractLinks(content: content);
expect(links.length, 1);
final link = links.first;
expect(link.service, CloudServiceType.pixeldrain);
expect(link.isStreamable, isTrue);
expect(link.directStreamUrl, 'https://pixeldrain.com/api/file/abc123xy');
});
test('extracts archive passwords from content', () {
expect(
CloudLinkExtractor.extractPassword('Here is the zip! Password: SuperSecret123'),
'SuperSecret123',
);
expect(
CloudLinkExtractor.extractPassword('Pass = pass_word_2026!'),
'pass_word_2026!',
);
expect(
CloudLinkExtractor.extractPassword('Пароль: mypass777'),
'mypass777',
);
});
test('cleans HTML commentary correctly', () {
const html = '<p>Hello world!<br/>Check out the new video.</p><p>&amp; enjoy!</p>';
final clean = CloudLinkExtractor.cleanCommentary(html);
expect(clean, 'Hello world!\nCheck out the new video.\n\n& enjoy!');
});
test('cleans HTML commentary and announcements correctly without style artifacts', () {
const html =
'<p style=""><strong>Thank you!</strong> </p><p style="">Here\'s the link to the archive</p><p style=""><a href="https://www.patreon.com/posts/full-fugtrup-14581297" rel="noopener noreferrer nofollow">https://www.patreon.com/posts/full-fugtrup-14581297</a></p><p style="">and the link to my <strong>discord server</strong>, if you\'d like to join it <a href="https://discord.com/invite/xR2AbkX" rel="noopener noreferrer nofollow">https://discord.com/invite/xR2AbkX</a></p>';
final clean = CloudLinkExtractor.cleanCommentary(html);
expect(clean.contains('<p'), isFalse);
expect(clean.contains('style='), isFalse);
expect(clean.contains('<strong>'), isFalse);
expect(clean.contains('Thank you!'), isTrue);
expect(clean.contains('https://discord.com/invite/xR2AbkX'), isTrue);
});
test('encodes and decodes CloudMediaLink to and from JSON', () {
const link = CloudMediaLink(
url: 'https://mega.nz/file/123#abc',
service: CloudServiceType.mega,
title: 'Full 4K Video',
isFolder: false,
isStreamable: false,
detectedPassword: 'pass',
);
final encoded = link.encode();
final decoded = CloudMediaLink.tryDecode(encoded);
expect(decoded, isNotNull);
expect(decoded!.url, link.url);
expect(decoded.service, CloudServiceType.mega);
expect(decoded.title, 'Full 4K Video');
expect(decoded.detectedPassword, 'pass');
});
});
}
+67
View File
@@ -0,0 +1,67 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:gel_rule_app/backend/models/creator_link.dart';
void main() {
group('CreatorLink extractor and classification tests', () {
test('extracts and classifies social and creator links accurately', () {
const text = '''
Check out my latest speedpaint on YouTube: https://youtu.be/dQw4w9WgXcQ
Also follow my X account https://x.com/artist_handle and Instagram: https://instagram.com/p/C1234567/!
Support me on Patreon: https://www.patreon.com/my_art or Fanbox: https://artist.fanbox.cc
Join my Discord: https://discord.gg/invitelink
Other links: https://carrd.co/myportfolio and https://example.com/blog.
Cloud links like https://drive.google.com/file/d/123/view and https://mega.nz/file/abc should be excluded by default.
''';
final links = CreatorLink.extractLinks(text);
expect(links.length, 8);
final yt = links.firstWhere((l) => l.type == CreatorLinkType.youtube);
expect(yt.serviceName, 'YouTube');
expect(yt.url, 'https://youtu.be/dQw4w9WgXcQ');
final twitter = links.firstWhere((l) => l.type == CreatorLinkType.twitter);
expect(twitter.serviceName, 'X (Twitter)');
expect(twitter.url, 'https://x.com/artist_handle');
final insta = links.firstWhere((l) => l.type == CreatorLinkType.instagram);
expect(insta.serviceName, 'Instagram');
expect(insta.url, 'https://instagram.com/p/C1234567/');
final patreon = links.firstWhere((l) => l.type == CreatorLinkType.patreon);
expect(patreon.serviceName, 'Patreon');
final fanbox = links.firstWhere((l) => l.type == CreatorLinkType.fanbox);
expect(fanbox.serviceName, 'Fanbox');
final discord = links.firstWhere((l) => l.type == CreatorLinkType.discord);
expect(discord.serviceName, 'Discord');
final hub = links.firstWhere((l) => l.type == CreatorLinkType.linkhub);
expect(hub.serviceName, 'Links Hub');
final web = links.firstWhere((l) => l.type == CreatorLinkType.other);
expect(web.url, 'https://example.com/blog');
// Verify cloud storage was excluded
expect(links.any((l) => l.url.contains('drive.google.com')), isFalse);
expect(links.any((l) => l.url.contains('mega.nz')), isFalse);
});
test('handles HTML anchor links with custom titles', () {
const html = '''
<p>Visit my <a href="https://twitter.com/draws">Twitter Profile</a> and watch on <a href="https://www.youtube.com/channel/123">YouTube Stream</a>!</p>
''';
final links = CreatorLink.extractLinks(html);
expect(links.length, 2);
final tw = links.firstWhere((l) => l.type == CreatorLinkType.twitter);
expect(tw.title, 'Twitter Profile');
final yt = links.firstWhere((l) => l.type == CreatorLinkType.youtube);
expect(yt.title, 'YouTube Stream');
});
});
}
+45
View File
@@ -0,0 +1,45 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:gel_rule_app/backend/models/content_provider_config.dart';
import 'package:gel_rule_app/backend/providers/gelbooru_provider.dart';
import 'package:gel_rule_app/backend/providers/provider_factory.dart';
import 'package:gel_rule_app/core/http/dio_client.dart';
void main() {
test('ProviderFactory creates GelbooruProvider with query credentials', () {
final factory = ProviderFactory();
final config = ContentProviderConfig(
id: 'gelbooru',
name: 'Gelbooru',
baseUrl: 'https://gelbooru.com',
apiType: 'gelbooru',
enabled: true,
priority: 10,
timeoutSeconds: 20,
customHeaders: const {
'query.api_key': 'test_api_key',
'query.user_id': '123456',
},
createdAt: DateTime(2026),
updatedAt: DateTime(2026),
);
final provider = factory.create(config);
expect(provider, isA<GelbooruProvider>());
final gelbooru = provider as GelbooruProvider;
expect(gelbooru.id, 'gelbooru');
expect(gelbooru.queryParameters['api_key'], 'test_api_key');
expect(gelbooru.queryParameters['user_id'], '123456');
});
test('GelbooruProvider mediaHeaders contains Referer and User-Agent', () {
final client = DioClient(baseUrl: 'https://gelbooru.com');
final provider = GelbooruProvider(
id: 'gelbooru',
name: 'Gelbooru',
baseUrl: 'https://gelbooru.com',
dioClient: client,
);
expect(provider.baseUrl, 'https://gelbooru.com');
});
}
+347
View File
@@ -0,0 +1,347 @@
import 'dart:convert';
import 'package:dio/dio.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:gel_rule_app/backend/models/favorite_artist_item.dart';
import 'package:gel_rule_app/backend/models/pawchive_account.dart';
import 'package:gel_rule_app/backend/services/pawchive_sync_service.dart';
import 'package:gel_rule_app/backend/services/settings_service.dart';
class _FakeAdapter implements HttpClientAdapter {
_FakeAdapter(this.handler);
final Future<ResponseBody> Function(RequestOptions options) handler;
@override
Future<ResponseBody> fetch(
RequestOptions options,
Stream<dynamic>? requestStream,
Future<void>? cancelFuture,
) {
return handler(options);
}
@override
void close({bool force = false}) {}
}
void main() {
group('FavoriteArtistItem Tests', () {
test('roundtrip serialization and key generation', () {
const item = FavoriteArtistItem(
id: '12345',
service: 'fanbox',
providerId: 'pawchive',
name: 'CoolArtist',
avatarUrl: 'https://pawchive.pw/icons/fanbox/12345',
);
expect(item.key, equals('pawchive:fanbox:12345'));
expect(item.isEmpty, isFalse);
final json = item.toJson();
final restored = FavoriteArtistItem.fromJson(json);
expect(restored.key, equals(item.key));
expect(restored.name, equals('CoolArtist'));
expect(restored.avatarUrl, equals('https://pawchive.pw/icons/fanbox/12345'));
expect(restored == item, isTrue);
});
test('empty factory behaves correctly', () {
const empty = FavoriteArtistItem.empty();
expect(empty.isEmpty, isTrue);
expect(empty.key, equals('::'));
});
});
group('PawchiveAccount Tests', () {
test('roundtrip serialization and copyWith', () {
final now = DateTime(2026, 9, 4, 12, 0, 0);
final account = PawchiveAccount(
id: 'tester',
username: 'Tester',
sessionCookie: 'eySessionToken123',
baseUrl: 'https://pawchive.pw',
createdAt: now,
lastSyncedAt: now,
isActive: true,
syncedArtistsCount: 15,
);
final json = account.toJson();
final restored = PawchiveAccount.fromJson(json);
expect(restored.id, equals('tester'));
expect(restored.username, equals('Tester'));
expect(restored.sessionCookie, equals('eySessionToken123'));
expect(restored.isActive, isTrue);
expect(restored.syncedArtistsCount, equals(15));
expect(restored == account, isTrue);
final updated = account.copyWith(isActive: false, syncedArtistsCount: 20);
expect(updated.isActive, isFalse);
expect(updated.syncedArtistsCount, equals(20));
expect(updated.id, equals('tester'));
});
});
group('AppSettings Pawchive integration tests', () {
test('serializes and parses pawchive accounts correctly', () {
final acc1 = PawchiveAccount(
id: 'user1',
username: 'User1',
sessionCookie: 'token1',
createdAt: DateTime(2026, 9, 1),
isActive: true,
syncedArtistsCount: 5,
);
final acc2 = PawchiveAccount(
id: 'user2',
username: 'User2',
sessionCookie: 'token2',
createdAt: DateTime(2026, 9, 2),
isActive: false,
syncedArtistsCount: 10,
);
final settings = AppSettings.defaults.copyWith(
pawchiveAccounts: [
jsonEncode(acc1.toJson()),
jsonEncode(acc2.toJson()),
],
);
expect(settings.parsedPawchiveAccounts.length, equals(2));
expect(settings.activePawchiveAccount?.id, equals('user1'));
final json = settings.toJson();
final restored = AppSettings.fromJson(json);
expect(restored.parsedPawchiveAccounts.length, equals(2));
expect(restored.activePawchiveAccount?.username, equals('User1'));
});
});
group('PawchiveSyncService Tests', () {
test('fetchRemoteFavoriteArtists parses server response correctly', () async {
final dio = Dio();
dio.httpClientAdapter = _FakeAdapter((options) async {
if (options.path.endsWith('/account/favorites') &&
options.queryParameters['type'] == 'artist') {
final cookie = options.headers['Cookie'] as String?;
expect(cookie, contains('session=test_cookie_123'));
final payload = jsonEncode([
{
'id': '98765',
'service': 'fanbox',
'name': 'AlphaCreator',
'updated': '2026-09-04T10:00:00.000Z',
},
{
'id': '43210',
'service': 'patreon',
'name': 'BetaCreator',
'updated': '2026-09-03T15:00:00.000Z',
},
]);
return ResponseBody.fromString(
payload,
200,
headers: {
Headers.contentTypeHeader: [Headers.jsonContentType],
},
);
}
return ResponseBody.fromString('Not found', 404);
});
final service = PawchiveSyncService(dio: dio);
final items = await service.fetchRemoteFavoriteArtists(
baseUrl: 'https://pawchive.pw',
sessionCookie: 'session=test_cookie_123;',
);
expect(items.length, equals(2));
expect(items[0].id, equals('98765'));
expect(items[0].service, equals('fanbox'));
expect(items[0].name, equals('AlphaCreator'));
expect(items[0].avatarUrl, equals('https://pawchive.pw/icons/fanbox/98765'));
expect(items[1].id, equals('43210'));
expect(items[1].service, equals('patreon'));
expect(items[1].name, equals('BetaCreator'));
});
test('syncAccountFavorites merges remote artists into AppSettings', () async {
final dio = Dio();
dio.httpClientAdapter = _FakeAdapter((options) async {
final payload = jsonEncode([
{
'id': 'remote_1',
'service': 'fanbox',
'name': 'RemoteArtistOne',
},
{
'id': 'existing_local',
'service': 'patreon',
'name': 'AlreadyPresent',
},
]);
return ResponseBody.fromString(
payload,
200,
headers: {
Headers.contentTypeHeader: [Headers.jsonContentType],
},
);
});
final service = PawchiveSyncService(dio: dio);
const existingItem = FavoriteArtistItem(
id: 'existing_local',
service: 'patreon',
providerId: 'pawchive',
name: 'AlreadyPresent',
);
final initialSettings = AppSettings.defaults.copyWith(
favoriteArtists: [jsonEncode(existingItem.toJson())],
);
final account = PawchiveAccount(
id: 'myuser',
username: 'myuser',
sessionCookie: 'cookie123',
createdAt: DateTime.now(),
);
final result = await service.syncAccountFavorites(
account: account,
settings: initialSettings,
);
expect(result.isSuccess, isTrue);
expect(result.totalSyncedCount, equals(2));
expect(result.newlyAddedToLocal, equals(1)); // remote_1 added, existing_local skipped
expect(result.updatedSettings.favoriteArtists.length, equals(2));
});
test('toggleRemoteFavorite sends POST and DELETE successfully', () async {
var lastMethod = '';
var lastPath = '';
final dio = Dio();
dio.httpClientAdapter = _FakeAdapter((options) async {
lastMethod = options.method;
lastPath = options.path;
return ResponseBody.fromString('ok', 200);
});
final service = PawchiveSyncService(dio: dio);
final account = PawchiveAccount(
id: 'user1',
username: 'user1',
sessionCookie: 'session=abc',
createdAt: DateTime.now(),
);
final addResult = await service.toggleRemoteFavorite(
account: account,
service: 'fanbox',
artistId: '12345',
isFavorite: true,
);
expect(addResult, isTrue);
expect(lastMethod, equals('POST'));
expect(lastPath, equals('https://pawchive.pw/api/v1/favorites/creator/fanbox/12345'));
final removeResult = await service.toggleRemoteFavorite(
account: account,
service: 'fanbox',
artistId: '12345',
isFavorite: false,
);
expect(removeResult, isTrue);
expect(lastMethod, equals('DELETE'));
expect(lastPath, equals('https://pawchive.pw/api/v1/favorites/creator/fanbox/12345'));
});
test('pushLocalFavoritesToAccount exports local artists to Pawchive', () async {
final postedPaths = <String>[];
final dio = Dio();
dio.httpClientAdapter = _FakeAdapter((options) async {
if (options.path.endsWith('/api/v1/account/favorites')) {
final payload = jsonEncode([
{
'id': 'already_remote_1',
'service': 'fanbox',
'name': 'AlreadyRemote',
},
]);
return ResponseBody.fromString(
payload,
200,
headers: {
Headers.contentTypeHeader: [Headers.jsonContentType],
},
);
}
if (options.method == 'POST' &&
options.path.contains('/api/v1/favorites/creator/')) {
postedPaths.add(options.path);
return ResponseBody.fromString('ok', 204);
}
return ResponseBody.fromString('Not found', 404);
});
final service = PawchiveSyncService(dio: dio);
const itemAlreadyRemote = FavoriteArtistItem(
id: 'already_remote_1',
service: 'fanbox',
providerId: 'pawchive',
name: 'AlreadyRemote',
);
const itemToUpload1 = FavoriteArtistItem(
id: 'local_creator_99',
service: 'patreon',
providerId: 'kemono',
name: 'LocalPatreonCreator',
);
const itemToUpload2 = FavoriteArtistItem(
id: 'local_creator_88',
service: 'subscribestar',
providerId: 'coomer',
name: 'LocalSubscribeStarCreator',
);
final settings = AppSettings.defaults.copyWith(
favoriteArtists: [
jsonEncode(itemAlreadyRemote.toJson()),
jsonEncode(itemToUpload1.toJson()),
jsonEncode(itemToUpload2.toJson()),
],
);
final account = PawchiveAccount(
id: 'myacc',
username: 'myacc',
sessionCookie: 'session=123',
createdAt: DateTime.now(),
);
final pushResult = await service.pushLocalFavoritesToAccount(
account: account,
settings: settings,
);
expect(pushResult.isSuccess, isTrue);
expect(pushResult.pushedCount, equals(2));
expect(pushResult.totalLocalCandidates, equals(3));
expect(pushResult.totalRemoteCount, equals(3)); // 1 existing + 2 pushed
expect(postedPaths.length, equals(2));
expect(postedPaths[0], contains('/api/v1/favorites/creator/patreon/local_creator_99'));
expect(postedPaths[1], contains('/api/v1/favorites/creator/subscribestar/local_creator_88'));
});
});
}
+33
View File
@@ -0,0 +1,33 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:gel_rule_app/backend/models/content_provider_config.dart';
import 'package:gel_rule_app/backend/providers/provider_factory.dart';
import 'package:gel_rule_app/backend/providers/rule34_provider.dart';
void main() {
test('ProviderFactory creates Rule34Provider with credentials', () {
final factory = ProviderFactory();
final config = ContentProviderConfig(
id: 'rule34',
name: 'Rule34',
baseUrl: 'https://api.rule34.xxx',
apiType: 'rule34',
enabled: true,
priority: 1,
timeoutSeconds: 20,
customHeaders: const {
'query.api_key': 'test_key',
'query.user_id': '99999',
},
createdAt: DateTime(2026),
updatedAt: DateTime(2026),
);
final provider = factory.create(config);
expect(provider, isA<Rule34Provider>());
final r34 = provider as Rule34Provider;
expect(r34.id, 'rule34');
expect(r34.baseUrl, 'https://api.rule34.xxx');
expect(r34.queryParameters['api_key'], 'test_key');
expect(r34.queryParameters['user_id'], '99999');
});
}
+6 -6
View File
@@ -6,7 +6,7 @@ import 'package:gel_rule_app/backend/services/update_service.dart';
import 'package:gel_rule_app/core/database/database_service.dart'; import 'package:gel_rule_app/core/database/database_service.dart';
void main() { void main() {
test('UpdateService parses Gitea release JSON correctly', () { test('UpdateService parses GitHub release JSON correctly', () {
final service = UpdateService(Dio(), SettingsService(_MockDatabaseService())); final service = UpdateService(Dio(), SettingsService(_MockDatabaseService()));
// Use reflection or constructor to test info // Use reflection or constructor to test info
@@ -15,21 +15,21 @@ void main() {
tagName: 'v2.0.2', tagName: 'v2.0.2',
name: 'Lunaris 2.0.2', name: 'Lunaris 2.0.2',
body: 'Release notes', body: 'Release notes',
htmlUrl: 'https://gitea.rardogsynapse.online/RarDog/Lunaris/releases/tag/v2.0.2', htmlUrl: 'https://github.com/RarDog/Lunaris/releases/tag/v2.0.2',
publishedAt: DateTime.parse('2026-09-03T18:00:00Z'), publishedAt: DateTime.parse('2026-09-03T18:00:00Z'),
apkUrl: 'https://gitea.rardogsynapse.online/attachments/apk', apkUrl: 'https://github.com/RarDog/Lunaris/releases/download/v2.0.2/Lunaris.apk',
linuxTarGzUrl: 'https://gitea.rardogsynapse.online/attachments/linux', linuxTarGzUrl: 'https://github.com/RarDog/Lunaris/releases/download/v2.0.2/linux.tar.gz',
); );
expect(info.hasAssets, isTrue); expect(info.hasAssets, isTrue);
expect(info.apkUrl, contains('apk')); expect(info.apkUrl, contains('apk'));
expect(info.linuxTarGzUrl, contains('linux')); expect(info.linuxTarGzUrl, contains('linux'));
expect( expect(
service.assetFileName(info, 'https://gitea.rardogsynapse.online/attachments/Lunaris-v2.0.2.apk'), service.assetFileName(info, 'https://github.com/RarDog/Lunaris/releases/download/v2.0.2/Lunaris-v2.0.2.apk'),
'Lunaris-v2.0.2.apk', 'Lunaris-v2.0.2.apk',
); );
expect( expect(
service.assetFileName(info, info.apkUrl!), service.assetFileName(info, 'https://github.com/RarDog/Lunaris/releases/download/v2.0.2/unknown-linux'),
'Lunaris-v2.0.2-linux-x64.tar.gz', 'Lunaris-v2.0.2-linux-x64.tar.gz',
); );
}); });
@@ -0,0 +1,83 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:gel_rule_app/app/app_strings.dart';
import 'package:gel_rule_app/backend/models/cloud_media_link.dart';
import 'package:gel_rule_app/features/post/presentation/widgets/cloud_mirrors_card.dart';
void main() {
testWidgets('CloudMirrorsCard renders mirrors, password banner, and commentary', (tester) async {
const links = [
CloudMediaLink(
url: 'https://mega.nz/file/xyz123#abc',
service: CloudServiceType.mega,
title: 'MEGA 4K Archive',
isFolder: false,
isStreamable: false,
detectedPassword: 'archive_pass_42',
),
CloudMediaLink(
url: 'https://drive.google.com/file/d/123/view',
service: CloudServiceType.googleDrive,
title: 'Google Drive Stream',
directStreamUrl: 'https://drive.google.com/uc?export=download&id=123',
isFolder: false,
isStreamable: true,
),
];
var playedStream = '';
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: SingleChildScrollView(
child: CloudMirrorsCard(
links: links,
strings: const AppStrings('ru'),
commentary: 'Special Patreon bonus animation for all patrons.',
onPlayStream: (stream) => playedStream = stream,
initiallyExpanded: true,
),
),
),
),
);
// Verify header and counts
expect(find.text('Облачные диски и зеркала (2)'), findsOneWidget);
expect(find.text('Плеер'), findsOneWidget);
// Verify password banner
expect(find.textContaining('archive_pass_42'), findsOneWidget);
// Verify link titles and service names
expect(find.text('MEGA'), findsOneWidget);
expect(find.text('MEGA 4K Archive'), findsOneWidget);
expect(find.text('Google Drive'), findsOneWidget);
expect(find.text('Google Drive Stream'), findsOneWidget);
// Verify commentary
expect(find.text('Описание от автора'), findsOneWidget);
expect(find.text('Special Patreon bonus animation for all patrons.'), findsOneWidget);
// Verify play button triggers callback
final playBtn = find.text('Смотреть');
expect(playBtn, findsOneWidget);
await tester.tap(playBtn);
expect(playedStream, 'https://drive.google.com/uc?export=download&id=123');
});
testWidgets('CloudMirrorsCard renders nothing if no links and no commentary', (tester) async {
await tester.pumpWidget(
const MaterialApp(
home: Scaffold(
body: CloudMirrorsCard(
links: [],
strings: AppStrings('ru'),
),
),
),
);
expect(find.byType(Container), findsNothing);
});
}
+117
View File
@@ -339,6 +339,123 @@ void main() {
expect(find.text('AND'), findsOneWidget); expect(find.text('AND'), findsOneWidget);
expect(find.text('dog'), findsOneWidget); expect(find.text('dog'), findsOneWidget);
}); });
testWidgets('deleting chip triggers onTagRemoved', (tester) async {
String? removedQuery;
await tester.pumpWidget(_Harness(
child: TagInputSearchBar(
initialValue: 'cat dog',
onSubmitted: (_) {},
onTagRemoved: (query) => removedQuery = query,
),
));
expect(find.text('cat'), findsOneWidget);
expect(find.text('dog'), findsOneWidget);
await tester.tap(find.byIcon(Icons.close_rounded).first);
await tester.pump();
expect(removedQuery, 'dog');
expect(find.text('cat'), findsNothing);
expect(find.text('dog'), findsOneWidget);
});
testWidgets('clear button triggers onCleared and onSubmitted with empty string',
(tester) async {
bool cleared = false;
String? submitted;
await tester.pumpWidget(_Harness(
child: TagInputSearchBar(
initialValue: 'cat dog',
onSubmitted: (query) => submitted = query,
onCleared: () => cleared = true,
),
));
await tester.tap(find.byTooltip('Clear'));
await tester.pump();
expect(cleared, isTrue);
expect(submitted, '');
expect(find.text('cat'), findsNothing);
expect(find.text('dog'), findsNothing);
});
testWidgets(
'tapping external button while suggestions are visible triggers button and hides suggestions',
(tester) async {
bool externalButtonPressed = false;
await tester.pumpWidget(MaterialApp(
home: Scaffold(
body: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Expanded(
child: TagInputSearchBar(
suggestions: const [
TagSuggestion(
name: 'genshin',
category: TagCategory.copyright,
postCount: 100,
providerId: 'test',
),
],
onSubmitted: (_) {},
),
),
const SizedBox(width: 8),
ElevatedButton(
onPressed: () => externalButtonPressed = true,
child: const Text('Обновить'),
),
],
),
),
),
));
await tester.tap(find.byType(TextField));
await tester.enterText(find.byType(TextField), 'gen');
await tester.pumpAndSettle();
expect(find.text('genshin'), findsOneWidget);
// Tap external 'Обновить' button directly while suggestions are up
await tester.tap(find.text('Обновить'));
await tester.pumpAndSettle();
expect(externalButtonPressed, isTrue);
expect(find.text('genshin'), findsNothing);
});
testWidgets('erasing draft immediately hides suggestions', (tester) async {
await tester.pumpWidget(_Harness(
child: TagInputSearchBar(
suggestions: const [
TagSuggestion(
name: 'genshin',
category: TagCategory.copyright,
postCount: 100,
providerId: 'test',
),
],
onSubmitted: (_) {},
),
));
await tester.tap(find.byType(TextField));
await tester.enterText(find.byType(TextField), 'gen');
await tester.pumpAndSettle();
expect(find.text('genshin'), findsOneWidget);
await tester.enterText(find.byType(TextField), '');
await tester.pumpAndSettle();
expect(find.text('genshin'), findsNothing);
});
} }
class _Harness extends StatelessWidget { class _Harness extends StatelessWidget {