Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c91af8790 | ||
|
|
5434c5dc7b | ||
|
|
cd1a9ccc0c | ||
|
|
cc6ad2d7bf | ||
|
|
54b77726cb | ||
|
|
8ad36e2f6b | ||
|
|
4aedde9b3f | ||
|
|
5b2c8eb53e | ||
|
|
d7d9cad423 | ||
|
|
7d3c4f964a | ||
|
|
877a044ff3 | ||
|
|
28a63483da | ||
|
|
af00cb8179 | ||
|
|
5e3efb0da9 | ||
|
|
2dad5e9c82 | ||
|
|
aba8ceb13c | ||
|
|
5fba08cd01 | ||
|
|
d5916e01b5 | ||
|
|
a535f2be39 | ||
|
|
774e11f312 | ||
|
|
d7cbcc3f19 | ||
|
|
46f8b2dff3 | ||
|
|
62b16d6deb | ||
|
|
70d5bad775 | ||
|
|
dd5e5b5d65 | ||
|
|
29d11cd848 | ||
|
|
7ec6d67738 | ||
|
|
1fb07b1247 | ||
|
|
8ff2efd84c | ||
|
|
583f60e706 | ||
|
|
f983e3c8c0 | ||
|
|
67c6994311 |
@@ -12,7 +12,7 @@ permissions:
|
||||
jobs:
|
||||
build-windows:
|
||||
name: Build Windows x64
|
||||
runs-on: windows-latest
|
||||
runs-on: windows-2022
|
||||
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
@@ -21,10 +21,12 @@ jobs:
|
||||
- name: Setup Flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
flutter-version: '3.27.4'
|
||||
channel: 'stable'
|
||||
cache: true
|
||||
|
||||
- name: Enable Windows Desktop
|
||||
run: flutter config --enable-windows-desktop
|
||||
|
||||
- name: Install Dependencies
|
||||
run: flutter pub get
|
||||
|
||||
@@ -35,13 +37,66 @@ jobs:
|
||||
shell: pwsh
|
||||
run: |
|
||||
$tag = "${{ github.ref_name }}"
|
||||
if (-not $tag) { $tag = "v3.0.0" }
|
||||
$zipName = "Lunaris-$tag-windows-x64.zip"
|
||||
if (-not $tag) { $tag = "v3.4.1" }
|
||||
$zipName = "Prisma-$tag-windows-x64.zip"
|
||||
Compress-Archive -Path "build\windows\x64\runner\Release\*" -DestinationPath $zipName
|
||||
echo "ZIP_NAME=$zipName" >> $env:GITHUB_ENV
|
||||
|
||||
- name: Upload Windows Artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: Prisma-windows-x64
|
||||
path: ${{ env.ZIP_NAME }}
|
||||
|
||||
- name: Upload to GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
with:
|
||||
files: ${{ env.ZIP_NAME }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
build-macos:
|
||||
name: Build macOS
|
||||
runs-on: macos-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: 'stable'
|
||||
cache: true
|
||||
|
||||
- name: Enable macOS Desktop
|
||||
run: flutter config --enable-macos-desktop
|
||||
|
||||
- name: Install Dependencies
|
||||
run: flutter pub get
|
||||
|
||||
- name: Build macOS Release
|
||||
run: flutter build macos --release
|
||||
|
||||
- name: Package macOS Zip
|
||||
run: |
|
||||
TAG="${{ github.ref_name }}"
|
||||
if [ -z "$TAG" ]; then TAG="v3.4.1"; fi
|
||||
ZIP_NAME="Prisma-$TAG-macos.zip"
|
||||
cd build/macos/Build/Products/Release
|
||||
zip -r -y "$GITHUB_WORKSPACE/$ZIP_NAME" Prisma.app
|
||||
echo "ZIP_NAME=$ZIP_NAME" >> $GITHUB_ENV
|
||||
|
||||
- name: Upload macOS Artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: Prisma-macos
|
||||
path: ${{ env.ZIP_NAME }}
|
||||
|
||||
- name: Upload to GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
with:
|
||||
files: ${{ env.ZIP_NAME }}
|
||||
env:
|
||||
|
||||
@@ -12,3 +12,4 @@ android/local.properties
|
||||
*.log
|
||||
*.apk
|
||||
*.tar.gz
|
||||
*.zip
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
# This file should be version controlled and should not be manually edited.
|
||||
|
||||
version:
|
||||
revision: "559ffa3f75e7402d65a8def9c28389a9b2e6fe42"
|
||||
revision: "d3b14c876900e553bc736ca19295fc09e3853e8e"
|
||||
channel: "stable"
|
||||
|
||||
project_type: app
|
||||
@@ -13,11 +13,11 @@ project_type: app
|
||||
migration:
|
||||
platforms:
|
||||
- platform: root
|
||||
create_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
|
||||
base_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
|
||||
- platform: linux
|
||||
create_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
|
||||
base_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
|
||||
create_revision: d3b14c876900e553bc736ca19295fc09e3853e8e
|
||||
base_revision: d3b14c876900e553bc736ca19295fc09e3853e8e
|
||||
- platform: macos
|
||||
create_revision: d3b14c876900e553bc736ca19295fc09e3853e8e
|
||||
base_revision: d3b14c876900e553bc736ca19295fc09e3853e8e
|
||||
|
||||
# User provided section
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 RarDog
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -1,128 +1,135 @@
|
||||
# 🌙 Lunaris
|
||||
<p align="center">
|
||||
<strong>English</strong> • <a href="README_RU.md">Русский</a>
|
||||
</p>
|
||||
|
||||
# 💎 Prisma
|
||||
|
||||
<p align="center">
|
||||
<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);" />
|
||||
<img src="android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png" width="128" height="128" alt="Prisma Logo" style="border-radius: 28px; box-shadow: 0 10px 30px rgba(0,0,0,0.35);" />
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<strong>Современный кроссплатформенный клиент для Booru и Creator-архивов нового поколения</strong><br>
|
||||
Плавный Pinterest-стиль, интеграция Pawchive 2.0, двусторонняя синхронизация, интеллектуальное автообновление и воспроизведение медиа без задержек.
|
||||
<strong>A modern, next-generation cross-platform client for Booru imageboards and Creator archives</strong><br>
|
||||
Smooth Pinterest-style masonry feed, Pawchive 2.0 two-way synchronization, intelligent in-app updates, dynamic aspect ratio detection, and buttery-smooth media playback.
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/RarDog/Lunaris/releases"><img src="https://img.shields.io/badge/Version-3.2.0-8A2BE2?style=for-the-badge&logo=semver&logoColor=white" alt="Version 3.2.0" /></a>
|
||||
<a href="https://github.com/RarDog/Prisma/releases"><img src="https://img.shields.io/badge/Version-3.5.5-8A2BE2?style=for-the-badge&logo=semver&logoColor=white" alt="Version 3.5.5" /></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/Platform-Android%20%7C%20Linux%20%7C%20Windows-00C853?style=for-the-badge" alt="Platforms" />
|
||||
<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/Platform-Android%20%7C%20Linux%20%7C%20Windows%20%7C%20macOS-00C853?style=for-the-badge" alt="Platforms" />
|
||||
<a href="https://github.com/RarDog/Prisma/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>
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Что нового в Lunaris 3.0 (Major Release)
|
||||
## 🚀 What's New in Prisma 3.5
|
||||
|
||||
### 🎨 Дизайн 2.0
|
||||
- **Современный визуальный стиль**: Полный переход на squircle-карточки, адаптивную цветовую палитру и улучшенную Material You динамическую тему.
|
||||
- **Интеллектуальный Action Dock 2.0**: Компактный док ключевых действий под контентом с быстрым доступом к скачиванию, шерингу, тегам и инфо.
|
||||
- **Плавные анимации и микро-взаимодействия**: Открытие медиа с эффектом Hero, интерактивное листание свайпами и анимированный лайк по двойному тапу.
|
||||
### 🎨 Design 2.0 & Interface Optimization
|
||||
- **Modern Visual Style**: Squircle cards, adaptive dynamic Material You color palette, and refined glassmorphic surfaces.
|
||||
- **Intelligent Action Dock 2.0**: Compact quick-action dock placed beneath posts for one-tap downloading, sharing, tag inspection, and source viewing.
|
||||
- **Smooth Animations & Micro-Interactions**: Hero media transitions, interactive drag gestures, and animated double-tap likes.
|
||||
- **Keyboard-Adaptive Layout**: Floating action buttons and navigation dock automatically minimize when the on-screen keyboard appears; search lists support dismiss-on-scroll.
|
||||
|
||||
### 🐾 Глубокая интеграция Pawchive 2.0
|
||||
- **Мульти-аккаунты**: Поддержка авторизации по логину и паролю, а также прямому вводу `Session Cookie`.
|
||||
- **Двусторонняя синхронизация (Two-Way Sync)**: Автоматическая синхронизация избранного между локальной базой Lunaris и профилем Pawchive. Новые авторы с сервера добавляются в приложение, а локальные авторы выгружаются на сервер в один тап.
|
||||
- **Моментальная синхронизация**: При нажатии кнопки «В избранное» у любого автора (Fanbox, Patreon, Fantia, Boosty, Gumroad, DLsite, Discord) статус синхронизируется с сервером на лету.
|
||||
### 🐾 Deep Pawchive 2.0 Integration
|
||||
- **Flexible Authentication**: Sign in via username/password or direct `Session Cookie`.
|
||||
- **Two-Way Synchronization**: Automatically synchronize your favorites between Prisma's local database and your Pawchive account. Local creators can be pushed to the server with a single tap.
|
||||
- **Instant Cloud Sync**: Favoriting any creator across Patreon, Fanbox, Fantia, Boosty, Gumroad, DLsite, or Discord synchronizes with the server in real-time.
|
||||
|
||||
### 🎬 Умный медиаплеер и определение разрешения
|
||||
- **Ленивое вычисление габаритов (1920×1080 и др.)**: Определение точного разрешения картинок и видео без полной предварительной загрузки тяжелых файлов.
|
||||
- **Автоповорот в полноэкранном режиме**: Видеоплеер автоматически ориентирует экран (альбомный или портретный режим) в зависимости от аспектного соотношения медиа.
|
||||
### 🎬 Smart Media Player & Dimension Detection
|
||||
- **Lazy Dimension Resolution**: Instant 1920×1080 (and custom) aspect-ratio discovery without needing to pre-download large media files.
|
||||
- **Fullscreen Auto-Orientation**: Video player automatically adjusts screen orientation (landscape or portrait) to match the media's native aspect ratio.
|
||||
|
||||
### 📦 Разделение APK и умное автообновление по ABI
|
||||
- Сборка облегчённых APK с разделением по архитектурам процессора (`arm64-v8a`, `armeabi-v7a`, `x86_64`).
|
||||
- Встроенная система обновлений автоматически определяет архитектуру вашего Android-устройства и скачивает только нужный APK, экономя трафик и ускоряя загрузку.
|
||||
### 📦 Split APKs & ABI-Aware Auto-Updates
|
||||
- Optimized per-ABI APK builds (`arm64-v8a`, `armeabi-v7a`, `x86_64`) for drastically reduced package sizes.
|
||||
- Built-in update engine automatically detects the device CPU architecture and fetches only the required binary.
|
||||
|
||||
---
|
||||
|
||||
## 📚 Документация и Вики (Руководство пользователя)
|
||||
## 📚 Documentation & User Guides
|
||||
|
||||
Подробные иллюстрированные руководства доступны в **[Lunaris Wiki](https://github.com/RarDog/Lunaris/wiki)** и прямо в репозитории:
|
||||
Comprehensive illustrated documentation is available in the **[Prisma Wiki](https://github.com/RarDog/Prisma/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)** — автообновление, звук в видео и решение проблем.
|
||||
- 🚀 **[Getting Started & Installation](docs/wiki/Getting-Started.md)** — Selecting the proper APK for your device architecture, running on Linux, Windows, or macOS.
|
||||
- 🌐 **[Content Providers](docs/wiki/Providers.md)** — Using Booru sources (Gelbooru, Rule34, Realbooru, e621) and Pawchive creator archives.
|
||||
- 🔍 **[Smart Search & Tags](docs/wiki/Search-and-Tags.md)** — `and` operators, syntax modifiers, and 0 ms autocomplete.
|
||||
- 🐾 **[Pawchive Integration](docs/wiki/Pawchive-Sync.md)** — Login, session cookies, two-way sync, and creator cloud exports.
|
||||
- 🎬 **[Media Viewer & Player](docs/wiki/Media-Viewer.md)** — Gesture navigation, automatic rotation, and Action Dock 2.0.
|
||||
- 💾 **[Offline Mode & Downloads](docs/wiki/Offline-and-Downloads.md)** — Sync Hub, directory naming templates, and original quality preservation.
|
||||
- 🛡️ **[Safety & Blacklist](docs/wiki/Safety-and-Blacklist.md)** — Custom blacklist rules, Safe Mode, and cache management.
|
||||
- ❓ **[Frequently Asked Questions (FAQ)](docs/wiki/FAQ.md)** — Troubleshooting, updater, audio playback, and common solutions.
|
||||
|
||||
---
|
||||
|
||||
## ✨ Ключевые возможности
|
||||
## ✨ Key Features
|
||||
|
||||
- **📱 Плавная лента в стиле 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 дисплеях.
|
||||
- **📱 Pinterest-Style Masonry Feed**: Staggered grid layout with instant mode switching (Masonry, 1:1 Square Grid, Compact List).
|
||||
- **💾 Offline 2.0 & Sync Hub**: Browse saved media directly from local storage with zero network requests, storage analytics, and batch export.
|
||||
- **🔍 Search 2.0**: Instant tag autocomplete, multi-tag queries with `and`, and modifiers such as `type:video` or `rating:safe`.
|
||||
- **🛡️ Advanced Blacklist / Whitelist**: Filter undesirable content by tags, categories, age ratings, or minimum score thresholds.
|
||||
- **📁 Smart Download Manager**: Flexible directory and filename templates (`{Artist}`, `{Provider}`, `{Service}`, `{ID}`, `{Date}`).
|
||||
- **🌑 AMOLED Pure Black**: True deep black background optimized for OLED/AMOLED display power efficiency.
|
||||
|
||||
---
|
||||
|
||||
## 🌐 Поддерживаемые провайдеры
|
||||
## 🌐 Supported Providers
|
||||
|
||||
| Провайдер | Тип API | Поддерживаемый контент |
|
||||
| Provider | API Type | Supported Content |
|
||||
|---|---|---|
|
||||
| **Pawchive** | REST API v1 | Patreon, Pixiv Fanbox, Fantia, Boosty, Gumroad, DLsite, Discord |
|
||||
| **Gelbooru** | JSON / XML API | Аниме-арты, теги, авторы, рейтинги |
|
||||
| **Rule34** | Booru API | Огромная база booru-контента, видео и GIF |
|
||||
| **Realbooru** | Scraper / API | Фотосессии и реалистичный контент |
|
||||
| **e621 / e926** | REST JSON API | Фурри- и антро-арт с богатейшей таксономией тегов |
|
||||
| **Gelbooru** | JSON / XML API | Anime artworks, tag taxonomy, creator profiles, ratings |
|
||||
| **Rule34** | Booru API | Expansive booru database, animated WebM/MP4 and GIF |
|
||||
| **Realbooru** | Scraper / API | Cosplay photography and realistic media |
|
||||
| **e621 / e926** | REST JSON API | Furry and anthropomorphic art with granular tag search |
|
||||
|
||||
---
|
||||
|
||||
## 📥 Загрузка (Скачать сборки)
|
||||
## 📥 Downloads & Releases
|
||||
|
||||
Релизы доступны на странице **[GitHub Releases](https://github.com/RarDog/Lunaris/releases)**.
|
||||
Pre-compiled production releases are available on the **[GitHub Releases](https://github.com/RarDog/Prisma/releases)** page.
|
||||
|
||||
| Платформа | Архитектура | Формат | Описание |
|
||||
| Platform | Architecture | Format | Notes |
|
||||
|---|---|---|---|
|
||||
| **Android** | `arm64-v8a` | `.apk` | Оптимизировано для современных смартфонов (64-bit) |
|
||||
| **Android** | `armeabi-v7a` | `.apk` | Для более старых Android-устройств (32-bit) |
|
||||
| **Android** | `x86_64` | `.apk` | Для эмуляторов Android и x86-планшетов |
|
||||
| **Linux** | `x86_64` | `.tar.gz` | Портативный архив для Linux-дистрибутивов |
|
||||
| **Windows** | `x64` | `.zip` / `.exe` | Портативная и установочная сборка для Windows 10/11 |
|
||||
| **Android** | `arm64-v8a` | `.apk` | Recommended for all modern 64-bit Android smartphones |
|
||||
| **Android** | `armeabi-v7a` | `.apk` | Legacy 32-bit Android devices |
|
||||
| **Android** | `x86_64` | `.apk` | Android emulators, Chromebooks & x86 tablets |
|
||||
| **Linux** | `x86_64` | `.tar.gz` | Portable standalone package for Linux distributions |
|
||||
| **Windows** | `x64` | `.zip` | Portable release for Windows 10 & 11 |
|
||||
| **macOS** | `Universal` | `.zip` | Portable build for macOS |
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Сборка из исходников
|
||||
## 🛠️ Building from Source
|
||||
|
||||
### Требования
|
||||
### Prerequisites
|
||||
- **Flutter SDK**: `>= 3.22.0` (Dart `>= 3.4.0`)
|
||||
- **Android SDK**: Build-Tools & Platform-Tools (для Android)
|
||||
- **Android SDK**: Build-Tools & Platform-Tools (for Android targets)
|
||||
- **Linux**: `clang`, `cmake`, `ninja-build`, `pkg-config`, `libgtk-3-dev`
|
||||
- **Windows**: Visual Studio 2022 с компонентом «Desktop development with C++»
|
||||
- **Windows**: Visual Studio 2022 with "Desktop development with C++"
|
||||
- **macOS**: Xcode 15+
|
||||
|
||||
### Быстрый старт
|
||||
### Quick Start
|
||||
|
||||
```bash
|
||||
# Клонирование репозитория
|
||||
git clone https://github.com/RarDog/Lunaris.git
|
||||
cd Lunaris
|
||||
# Clone repository
|
||||
git clone https://github.com/RarDog/Prisma.git
|
||||
cd Prisma
|
||||
|
||||
# Установка зависимостей
|
||||
# Fetch dependencies
|
||||
flutter pub get
|
||||
|
||||
# Проверка анализатора и запуск тестов
|
||||
# Run static analysis and tests
|
||||
flutter analyze
|
||||
flutter test
|
||||
|
||||
# Запуск в режиме разработки
|
||||
# Launch in debug mode
|
||||
flutter run
|
||||
```
|
||||
|
||||
### Сборка релизных пакетов
|
||||
### Building Release Packages
|
||||
|
||||
```bash
|
||||
# Android (раздельные APK по архитектурам)
|
||||
# Android (Split APKs by ABI)
|
||||
flutter build apk --release --split-per-abi
|
||||
|
||||
# Linux (Desktop bundle)
|
||||
@@ -130,43 +137,56 @@ flutter build linux --release
|
||||
|
||||
# Windows (Desktop bundle)
|
||||
flutter build windows --release
|
||||
|
||||
# macOS (Desktop bundle)
|
||||
flutter build macos --release
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🏛️ Архитектура проекта
|
||||
## 🏛️ Project Architecture
|
||||
|
||||
```text
|
||||
lib/
|
||||
├── app/ # Роутер (GoRouter), темы, локализация (RU/EN), анимации
|
||||
├── app/ # Routing (GoRouter), theme configuration, l10n (EN/RU), animations
|
||||
├── backend/
|
||||
│ ├── di/ # Внедрение зависимостей (Riverpod providers)
|
||||
│ ├── di/ # Riverpod dependency injection & providers
|
||||
│ ├── models/ # Post, ArtistProfile, PawchiveAccount, AppSettings
|
||||
│ ├── providers/ # Клиенты API (Gelbooru, Rule34, Pawchive, e621)
|
||||
│ ├── repositories/ # Локальная база данных Isar и кэширование
|
||||
│ └── services/ # Сервисы: PawchiveSync, Feed, Search, Downloads, Updates
|
||||
├── core/ # Сетевой клиент (Dio), база данных, утилиты
|
||||
├── features/ # Модули экранов: feed, post, artists, favorites, settings
|
||||
└── shared/widgets/ # Переиспользуемые компоненты (PostCard, MasonryGrid, Dock)
|
||||
│ ├── providers/ # API integrations (Gelbooru, Rule34, Pawchive, e621)
|
||||
│ ├── repositories/ # Local Isar database storage & cache layer
|
||||
│ └── services/ # Core services: PawchiveSync, Feed, Search, Downloads, Updates
|
||||
├── core/ # Network client (Dio), database engine, utilities
|
||||
├── features/ # Feature screens: feed, post, artists, favorites, settings
|
||||
└── shared/widgets/ # Reusable UI components (PostCard, MasonryGrid, Dock)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔒 Безопасность и конфиденциальность
|
||||
## 🔒 Privacy & Security
|
||||
|
||||
- **100% локальное хранение**: Вся история, закладки, локальные авторы и конфигурации хранятся только в вашей локальной базе на устройстве.
|
||||
- **Никакой телеметрии**: Lunaris не собирает аналитику, не отправляет логи и уважает вашу приватность.
|
||||
- **Прямое соединение**: Запросы отправляются напрямую к выбранным API без прокси-серверов посредников.
|
||||
- **100% On-Device Storage**: Your browsing history, bookmarks, favorite creators, and credentials stay strictly on your device.
|
||||
- **Zero Telemetry**: No background tracking, no third-party analytic SDKs, and no user profiling.
|
||||
- **Direct Network Requests**: Communication occurs directly between your client and selected provider endpoints without intermediary proxies.
|
||||
|
||||
---
|
||||
|
||||
## 👥 Разработчики
|
||||
## 👥 Authors & Credits
|
||||
|
||||
<p align="center">
|
||||
<strong>Разработчики:</strong> <a href="https://github.com/RarDog">RarDog</a> & <a href="https://deepmind.google/technologies/gemini/">Antigravity (Gemini)</a>
|
||||
<strong>Authors:</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>
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Disclaimer & Vibe Coding
|
||||
|
||||
> [!NOTE]
|
||||
> This project is 100% **vibe coding** built in tandem with an AI coding partner. I have zero background in Dart and Flutter — this project is built for personal use out of pure enthusiasm.
|
||||
>
|
||||
> Although every build is personally and thoroughly tested in daily use before being published, the codebase may still contain quirks, bugs, or sub-optimal patterns. This is a passion project built for personal fun and convenience, which means development could slow down or be abandoned at any moment without prior notice. Community contributions, advice, and pull requests are always welcome!
|
||||
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
<p align="center">
|
||||
<a href="README.md">English</a> • <strong>Русский</strong>
|
||||
</p>
|
||||
|
||||
# 💎 Prisma
|
||||
|
||||
<p align="center">
|
||||
<img src="android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png" width="128" height="128" alt="Prisma Logo" style="border-radius: 28px; box-shadow: 0 10px 30px rgba(0,0,0,0.35);" />
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<strong>Современный кроссплатформенный клиент для Booru и Creator-архивов нового поколения</strong><br>
|
||||
Плавный Pinterest-стиль, интеграция Pawchive 2.0, двусторонняя синхронизация, интеллектуальное автообновление и воспроизведение медиа без задержек.
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/RarDog/Prisma/releases"><img src="https://img.shields.io/badge/Version-3.5.5-8A2BE2?style=for-the-badge&logo=semver&logoColor=white" alt="Version 3.5.5" /></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/Platform-Android%20%7C%20Linux%20%7C%20Windows%20%7C%20macOS-00C853?style=for-the-badge" alt="Platforms" />
|
||||
<a href="https://github.com/RarDog/Prisma/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>
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Что нового в Prisma 3.5
|
||||
|
||||
### 🎨 Дизайн 2.0 и оптимизация интерфейса
|
||||
- **Современный визуальный стиль**: Полный переход на squircle-карточки, адаптивную цветовую палитру и улучшенную Material You динамическую тему.
|
||||
- **Интеллектуальный Action Dock 2.0**: Компактный док ключевых действий под контентом с быстрым доступом к скачиванию, шерингу, тегам и инфо.
|
||||
- **Плавные анимации и микро-взаимодействия**: Открытие медиа с эффектом Hero, интерактивное листание свайпами и анимированный лайк по двойному тапу.
|
||||
- **Умная работа с экранной клавиатурой**: Автоматическое скрытие плавающих элементов при вводе и закрытие клавиатуры при прокрутке списков.
|
||||
|
||||
### 🐾 Глубокая интеграция Pawchive 2.0
|
||||
- **Мульти-аккаунты**: Поддержка авторизации по логину и паролю, а также прямому вводу `Session Cookie`.
|
||||
- **Двусторонняя синхронизация (Two-Way Sync)**: Автоматическая синхронизация избранного между локальной базой Prisma и профилем Pawchive. Новые авторы с сервера добавляются в приложение, а локальные авторы выгружаются на сервер в один тап.
|
||||
- **Моментальная синхронизация**: При нажатии кнопки «В избранное» у любого автора (Fanbox, Patreon, Fantia, Boosty, Gumroad, DLsite, Discord) статус синхронизируется с сервером на лету.
|
||||
|
||||
### 🎬 Умный медиаплеер и определение разрешения
|
||||
- **Ленивое вычисление габаритов (1920×1080 и др.)**: Определение точного разрешения картинок и видео без полной предварительной загрузки тяжелых файлов.
|
||||
- **Автоповорот в полноэкранном режиме**: Видеоплеер автоматически ориентирует экран (альбомный или портретный режим) в зависимости от аспектного соотношения медиа.
|
||||
|
||||
### 📦 Разделение APK и умное автообновление по ABI
|
||||
- Сборка облегчённых APK с разделением по архитектурам процессора (`arm64-v8a`, `armeabi-v7a`, `x86_64`).
|
||||
- Встроенная система обновлений автоматически определяет архитектуру вашего Android-устройства и скачивает только нужный APK, экономя трафик и ускоряя загрузку.
|
||||
|
||||
---
|
||||
|
||||
## 📚 Документация и Вики (Руководство пользователя)
|
||||
|
||||
Подробные иллюстрированные руководства доступны в **[Prisma Wiki](https://github.com/RarDog/Prisma/wiki)** и прямо в репозитории:
|
||||
|
||||
- 🚀 **[Быстрый старт и установка](docs/wiki/Getting-Started.md)** — выбор правильного APK под процессор, запуск на Linux, Windows и macOS.
|
||||
- 🌐 **[Провайдеры контента](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 | Поддерживаемый контент |
|
||||
|---|---|---|
|
||||
| **Pawchive** | REST API v1 | Patreon, Pixiv Fanbox, Fantia, Boosty, Gumroad, DLsite, Discord |
|
||||
| **Gelbooru** | JSON / XML API | Аниме-арты, теги, авторы, рейтинги |
|
||||
| **Rule34** | Booru API | Огромная база booru-контента, видео и GIF |
|
||||
| **Realbooru** | Scraper / API | Фотосессии и реалистичный контент |
|
||||
| **e621 / e926** | REST JSON API | Фурри- и антро-арт с богатейшей таксономией тегов |
|
||||
|
||||
---
|
||||
|
||||
## 📥 Загрузка (Скачать сборки)
|
||||
|
||||
Релизы доступны на странице **[GitHub Releases](https://github.com/RarDog/Prisma/releases)**.
|
||||
|
||||
| Платформа | Архитектура | Формат | Описание |
|
||||
|---|---|---|---|
|
||||
| **Android** | `arm64-v8a` | `.apk` | Оптимизировано для современных смартфонов (64-bit) |
|
||||
| **Android** | `armeabi-v7a` | `.apk` | Для более старых Android-устройств (32-bit) |
|
||||
| **Android** | `x86_64` | `.apk` | Для эмуляторов Android и x86-планшетов |
|
||||
| **Linux** | `x86_64` | `.tar.gz` | Портативный архив для Linux-дистрибутивов |
|
||||
| **Windows** | `x64` | `.zip` | Портативная сборка для Windows 10/11 |
|
||||
| **macOS** | `Universal` | `.zip` | Сборка для macOS |
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Сборка из исходников
|
||||
|
||||
### Требования
|
||||
- **Flutter SDK**: `>= 3.22.0` (Dart `>= 3.4.0`)
|
||||
- **Android SDK**: Build-Tools & Platform-Tools (для Android)
|
||||
- **Linux**: `clang`, `cmake`, `ninja-build`, `pkg-config`, `libgtk-3-dev`
|
||||
- **Windows**: Visual Studio 2022 с компонентом «Desktop development with C++»
|
||||
- **macOS**: Xcode 15+
|
||||
|
||||
### Быстрый старт
|
||||
|
||||
```bash
|
||||
# Клонирование репозитория
|
||||
git clone https://github.com/RarDog/Prisma.git
|
||||
cd Prisma
|
||||
|
||||
# Установка зависимостей
|
||||
flutter pub get
|
||||
|
||||
# Проверка анализатора и запуск тестов
|
||||
flutter analyze
|
||||
flutter test
|
||||
|
||||
# Запуск в режиме разработки
|
||||
flutter run
|
||||
```
|
||||
|
||||
### Сборка релизных пакетов
|
||||
|
||||
```bash
|
||||
# Android (раздельные APK по архитектурам)
|
||||
flutter build apk --release --split-per-abi
|
||||
|
||||
# Linux (Desktop bundle)
|
||||
flutter build linux --release
|
||||
|
||||
# Windows (Desktop bundle)
|
||||
flutter build windows --release
|
||||
|
||||
# macOS (Desktop bundle)
|
||||
flutter build macos --release
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🏛️ Архитектура проекта
|
||||
|
||||
```text
|
||||
lib/
|
||||
├── app/ # Роутер (GoRouter), темы, локализация (RU/EN), анимации
|
||||
├── backend/
|
||||
│ ├── di/ # Внедрение зависимостей (Riverpod providers)
|
||||
│ ├── models/ # Post, ArtistProfile, PawchiveAccount, AppSettings
|
||||
│ ├── providers/ # Клиенты API (Gelbooru, Rule34, Pawchive, e621)
|
||||
│ ├── repositories/ # Локальная база данных Isar и кэширование
|
||||
│ └── services/ # Сервисы: PawchiveSync, Feed, Search, Downloads, Updates
|
||||
├── core/ # Сетевой клиент (Dio), база данных, утилиты
|
||||
├── features/ # Модули экранов: feed, post, artists, favorites, settings
|
||||
└── shared/widgets/ # Переиспользуемые компоненты (PostCard, MasonryGrid, Dock)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔒 Безопасность и конфиденциальность
|
||||
|
||||
- **100% локальное хранение**: Вся история, закладки, локальные авторы и конфигурации хранятся только в вашей локальной базе на устройстве.
|
||||
- **Никакой телеметрии**: Prisma не собирает аналитику, не отправляет логи и уважает вашу приватность.
|
||||
- **Прямое соединение**: Запросы отправляются напрямую к выбранным 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>
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Важное примечание / Vibe Coding
|
||||
|
||||
> [!NOTE]
|
||||
> Этот проект — чистый **vibe coding** в связке с AI-ассистентом. Я полный ноль в Dart и Flutter, проект пишется для себя на чистом энтузиазме.
|
||||
>
|
||||
> Несмотря на то, что каждое обновление лично и тщательно тестируется перед релизом, приложение может содержать баги, шероховатости или неоптимальные решения. Проект создаётся для души и личного удобства, поэтому разработка может быть замедлена или заброшена в любой момент. Пулл-реквесты, советы и помощь сообщества всегда приветствуются!
|
||||
|
||||
@@ -7,6 +7,7 @@ analyzer:
|
||||
- android/**
|
||||
- windows/**
|
||||
- linux/**
|
||||
- macos/**
|
||||
|
||||
linter:
|
||||
rules:
|
||||
|
||||
@@ -3,33 +3,34 @@
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>
|
||||
|
||||
<application
|
||||
android:label="Lunaris"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTop"
|
||||
android:taskAffinity=""
|
||||
android:theme="@style/LaunchTheme"
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||
android:hardwareAccelerated="true"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<!-- Specifies an Android theme to apply to this Activity as soon as
|
||||
the Android process has started. This theme is visible to the user
|
||||
while the Flutter UI initializes. After that, this theme continues
|
||||
to determine the Window background behind the Flutter UI. -->
|
||||
<meta-data
|
||||
android:name="io.flutter.embedding.android.NormalTheme"
|
||||
android:resource="@style/NormalTheme"
|
||||
/>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN"/>
|
||||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<!-- Don't delete the meta-data below.
|
||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||
android:label="Prisma"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:largeHeap="true">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTop"
|
||||
android:taskAffinity=""
|
||||
android:theme="@style/LaunchTheme"
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||
android:hardwareAccelerated="true"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<!-- Specifies an Android theme to apply to this Activity as soon as
|
||||
the Android process has started. This theme is visible to the user
|
||||
while the Flutter UI initializes. After that, this theme continues
|
||||
to determine the Window background behind the Flutter UI. -->
|
||||
<meta-data
|
||||
android:name="io.flutter.embedding.android.NormalTheme"
|
||||
android:resource="@style/NormalTheme"
|
||||
/>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN"/>
|
||||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<!-- Don't delete the meta-data below.
|
||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||
<meta-data
|
||||
android:name="flutterEmbedding"
|
||||
android:value="2" />
|
||||
@@ -43,15 +44,15 @@
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
</application>
|
||||
<!-- Required to query activities that can process text, see:
|
||||
https://developer.android.com/training/package-visibility and
|
||||
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
|
||||
|
||||
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
|
||||
<queries>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.PROCESS_TEXT"/>
|
||||
<data android:mimeType="text/plain"/>
|
||||
</intent>
|
||||
</queries>
|
||||
</manifest>
|
||||
<!-- Required to query activities that can process text, see:
|
||||
https://developer.android.com/training/package-visibility and
|
||||
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
|
||||
|
||||
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
|
||||
<queries>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.PROCESS_TEXT"/>
|
||||
<data android:mimeType="text/plain"/>
|
||||
</intent>
|
||||
</queries>
|
||||
</manifest>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# ❓ Часто задаваемые вопросы (FAQ)
|
||||
|
||||
### 1. Как обновить приложение?
|
||||
Lunaris проверяет наличие свежих версий автоматически при запуске.
|
||||
Prisma проверяет наличие свежих версий автоматически при запуске.
|
||||
Вы также можете нажать **«Проверить обновления»** в Настройках.
|
||||
Приложение определит архитектуру вашего Android-устройства и скачает оптимальный APK напрямую из репозитория.
|
||||
|
||||
@@ -13,13 +13,13 @@ Lunaris проверяет наличие свежих версий автома
|
||||
---
|
||||
|
||||
### 3. Где сохраняются скачанные файлы?
|
||||
- **На Android**: В стандартную папку `Pictures/Lunaris` или `Download/Lunaris` (в зависимости от настроек системы). Файлы сразу видны в стандартной Галерее телефона.
|
||||
- **На Linux**: В директорию `~/Pictures/Lunaris` или папку загрузок.
|
||||
- **На Android**: В стандартную папку `Pictures/Prisma` или `Download/Prisma` (в зависимости от настроек системы). Файлы сразу видны в стандартной Галерее телефона.
|
||||
- **На Linux**: В директорию `~/Pictures/Prisma` или папку загрузок.
|
||||
|
||||
---
|
||||
|
||||
### 4. Безопасно ли вводить логин и пароль от Pawchive?
|
||||
Абсолютно безопасно. Lunaris не отправляет ваши учетные данные никуда, кроме официального защищенного API Pawchive по HTTPS. Пароли не логируются и сохраняются в зашифрованном системном хранилище. Для дополнительного спокойствия вы можете использовать вход через **Session Cookie**.
|
||||
Абсолютно безопасно. Prisma не отправляет ваши учетные данные никуда, кроме официального защищенного API Pawchive по HTTPS. Пароли не логируются и сохраняются в зашифрованном системном хранилище. Для дополнительного спокойствия вы можете использовать вход через **Session Cookie**.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
### 📥 1. Установка на Android
|
||||
|
||||
Релизы приложения публикуются на странице **[GitHub Releases](https://github.com/RarDog/Lunaris/releases)**.
|
||||
Релизы приложения публикуются на странице **[GitHub Releases](https://github.com/RarDog/Prisma/releases)**.
|
||||
|
||||
Начиная с версии **Lunaris 3.0**, APK разделены по архитектуре процессора, чтобы уменьшить размер скачиваемого файла:
|
||||
Начиная с версии **Prisma 3.0**, APK разделены по архитектуре процессора, чтобы уменьшить размер скачиваемого файла:
|
||||
|
||||
| Файл APK | Для каких устройств |
|
||||
|---|---|
|
||||
@@ -13,27 +13,27 @@
|
||||
| **`app-x86_64-release.apk`** | Эмуляторы Android на ПК (Bluestacks, Android Studio) или планшеты с процессорами Intel/AMD. |
|
||||
|
||||
> [!TIP]
|
||||
> **Умное автообновление:** При проверке обновлений прямо внутри Lunaris приложение **самостоятельно определит архитектуру** вашего процессора и автоматически скачает правильный файл!
|
||||
> **Умное автообновление:** При проверке обновлений прямо внутри Prisma приложение **самостоятельно определит архитектуру** вашего процессора и автоматически скачает правильный файл!
|
||||
|
||||
#### Шаги установки:
|
||||
1. Скачайте нужный `.apk` файл на устройство.
|
||||
2. Откройте файл через менеджер файлов и подтвердите установку («Разрешить установку из этого источника»).
|
||||
3. Запустите **Lunaris**.
|
||||
3. Запустите **Prisma**.
|
||||
|
||||
---
|
||||
|
||||
### 🐧 2. Установка на Linux
|
||||
|
||||
Lunaris распространяется для 64-битных Linux в виде готового автономного архива:
|
||||
Prisma распространяется для 64-битных Linux в виде готового автономного архива:
|
||||
|
||||
1. Скачайте **`Lunaris-v3.0.0-linux-x64.tar.gz`**.
|
||||
1. Скачайте **`Prisma-v3.0.0-linux-x64.tar.gz`**.
|
||||
2. Распакуйте архив в любую удобную папку:
|
||||
```bash
|
||||
tar -xzf Lunaris-v3.0.0-linux-x64.tar.gz -C ~/Lunaris
|
||||
tar -xzf Prisma-v3.0.0-linux-x64.tar.gz -C ~/Prisma
|
||||
```
|
||||
3. Запустите приложение:
|
||||
```bash
|
||||
cd ~/Lunaris
|
||||
cd ~/Prisma
|
||||
./lunaris
|
||||
```
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 🌙 Добро пожаловать в Lunaris Wiki!
|
||||
# 🌙 Добро пожаловать в Prisma Wiki!
|
||||
|
||||
**Lunaris** — это современный, быстрый и функциональный кроссплатформенный клиент для Booru-борд и архивов независимых авторов (Creator Archives).
|
||||
**Prisma** — это современный, быстрый и функциональный кроссплатформенный клиент для Booru-борд и архивов независимых авторов (Creator Archives).
|
||||
|
||||
Приложение разработано на Flutter для **Android**, **Linux** и **Windows** с акцентом на плавный интерфейс в стиле Pinterest, глубокую кастомизацию, приватность и удобную работу с медиа.
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 🎬 Медиаплеер и просмотр
|
||||
|
||||
Экран просмотра в Lunaris оптимизирован для быстрого, плавного и естественного взаимодействия с контентом.
|
||||
Экран просмотра в Prisma оптимизирован для быстрого, плавного и естественного взаимодействия с контентом.
|
||||
|
||||
---
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
|
||||
## 📐 Определение разрешения медиа (1920×1080 и др.)
|
||||
|
||||
В Lunaris 3.0 внедрено **интеллектуальное ленивое определение разрешения**:
|
||||
В Prisma 3.0 внедрено **интеллектуальное ленивое определение разрешения**:
|
||||
- Для изображений и видео в инфо-панели поста отображаются точные физические размеры (например, `1920 × 1080`, `3840 × 2160`).
|
||||
- Определение происходит динамически через заголовки и потоковый декодер — приложению не требуется скачивать многомегабайтный файл целиком только ради того, чтобы узнать его габариты.
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 💾 Офлайн-режим и загрузки
|
||||
|
||||
Lunaris предоставляет полноценный оффлайн-хаб для работы с вашей личной коллекцией без подключения к интернету.
|
||||
Prisma предоставляет полноценный оффлайн-хаб для работы с вашей личной коллекцией без подключения к интернету.
|
||||
|
||||
---
|
||||
|
||||
@@ -26,7 +26,7 @@ Lunaris предоставляет полноценный оффлайн-хаб
|
||||
|
||||
*Пример шаблона:*
|
||||
```text
|
||||
Lunaris/{Provider}/{Artist}/{ID}.{ext}
|
||||
Prisma/{Provider}/{Artist}/{ID}.{ext}
|
||||
```
|
||||
Файлы будут аккуратно раскладываться по папкам авторов и провайдеров, сохраняя порядок в вашей галерее.
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 🐾 Интеграция и синхронизация с Pawchive
|
||||
|
||||
Начиная с версии **Lunaris 3.0**, в приложении реализована глубокая интеграция с личным профилем Pawchive.
|
||||
Начиная с версии **Prisma 3.0**, в приложении реализована глубокая интеграция с личным профилем Pawchive.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 🌐 Провайдеры контента
|
||||
|
||||
Lunaris объединяет два фундаментальных класса источников: классические **Booru-доски** и современные **архивы авторов (Pawchive)**.
|
||||
Prisma объединяет два фундаментальных класса источников: классические **Booru-доски** и современные **архивы авторов (Pawchive)**.
|
||||
|
||||
---
|
||||
|
||||
@@ -17,7 +17,7 @@ Lunaris объединяет два фундаментальных класса
|
||||
- `gumroad` (Gumroad)
|
||||
- `dlsite` (DLsite)
|
||||
- `discord` (Серверы Discord)
|
||||
- **Возможности в Lunaris:**
|
||||
- **Возможности в Prisma:**
|
||||
- Просмотр глобальной ленты свежих постов.
|
||||
- Персональные карточки авторов с аватаром, баннером, анонсами и ссылками на оригинальные профили.
|
||||
- Полноценная авторизация в профиль Pawchive (логин/пароль или Session Cookie).
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 🛡️ Черный список и фильтрация
|
||||
|
||||
Lunaris оснащен многоуровневой системой защиты от нежелательного контента, работающей прямо на устройстве.
|
||||
Prisma оснащен многоуровневой системой защиты от нежелательного контента, работающей прямо на устройстве.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 🔍 Умный поиск и теги
|
||||
|
||||
Поиск в Lunaris спроектирован для мгновенного отклика и максимальной гибкости в построении сложных запросов.
|
||||
Поиск в Prisma спроектирован для мгновенного отклика и максимальной гибкости в построении сложных запросов.
|
||||
|
||||
---
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
|
||||
Классические Booru-серверы при поиске нескольких тегов (`tag1 tag2`) возвращают только посты, где **присутствуют оба тега одновременно**.
|
||||
|
||||
Lunaris поддерживает специальный оператор **`and`**:
|
||||
Prisma поддерживает специальный оператор **`and`**:
|
||||
```text
|
||||
genshin_impact and honkai_star_rail
|
||||
```
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
### 🌙 Lunaris Wiki
|
||||
### 🌙 Prisma Wiki
|
||||
|
||||
- **[Главная](Home)**
|
||||
- **[Быстрый старт и установка](Getting-Started)**
|
||||
@@ -12,5 +12,5 @@
|
||||
|
||||
---
|
||||
|
||||
**[Скачать Lunaris](https://github.com/RarDog/Lunaris/releases)**
|
||||
**[Репозиторий GitHub](https://github.com/RarDog/Lunaris)**
|
||||
**[Скачать Prisma](https://github.com/RarDog/Prisma/releases)**
|
||||
**[Репозиторий GitHub](https://github.com/RarDog/Prisma)**
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#define MyAppName "Lunaris"
|
||||
#define MyAppName "Prisma"
|
||||
#define MyAppVersion "2.0.1"
|
||||
#define MyAppPublisher "RarDog"
|
||||
#define MyAppExeName "gel_rule_app.exe"
|
||||
@@ -13,7 +13,7 @@ DefaultGroupName={#MyAppName}
|
||||
DisableProgramGroupPage=yes
|
||||
PrivilegesRequired=lowest
|
||||
OutputDir=..\dist
|
||||
OutputBaseFilename=LunarisSetup
|
||||
OutputBaseFilename=PrismaSetup
|
||||
SetupIconFile=..\windows\runner\resources\app_icon.ico
|
||||
Compression=lzma2
|
||||
SolidCompression=yes
|
||||
@@ -0,0 +1,92 @@
|
||||
#!/usr/bin/env bash
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ROOT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||
|
||||
cd "${ROOT_DIR}"
|
||||
|
||||
VERSION=$(grep 'version:' pubspec.yaml | head -n 1 | awk '{print $2}' | cut -d'+' -f1)
|
||||
if [ -z "$VERSION" ]; then
|
||||
VERSION="3.5.5"
|
||||
fi
|
||||
|
||||
echo "Building AppImage for Prisma v${VERSION}..."
|
||||
|
||||
BUNDLE_DIR="${ROOT_DIR}/build/linux/x64/release/bundle"
|
||||
if [ ! -f "${BUNDLE_DIR}/gel_rule_app" ]; then
|
||||
echo "Release bundle not found. Running 'flutter build linux --release'..."
|
||||
flutter build linux --release
|
||||
fi
|
||||
|
||||
APP_DIR="${ROOT_DIR}/build/AppDir"
|
||||
OUTPUT_DIR="${ROOT_DIR}/build"
|
||||
APPIMAGE_NAME="Prisma-v${VERSION}-linux-x86_64.AppImage"
|
||||
OUTPUT_APPIMAGE="${OUTPUT_DIR}/${APPIMAGE_NAME}"
|
||||
|
||||
# Ensure appimagetool is available
|
||||
APPIMAGETOOL=""
|
||||
if command -v appimagetool >/dev/null 2>&1; then
|
||||
APPIMAGETOOL="appimagetool"
|
||||
elif [ -f "${ROOT_DIR}/build/tools/appimagetool" ]; then
|
||||
APPIMAGETOOL="${ROOT_DIR}/build/tools/appimagetool"
|
||||
else
|
||||
echo "Downloading appimagetool..."
|
||||
mkdir -p "${ROOT_DIR}/build/tools"
|
||||
curl -fsSL https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage -o "${ROOT_DIR}/build/tools/appimagetool"
|
||||
chmod +x "${ROOT_DIR}/build/tools/appimagetool"
|
||||
APPIMAGETOOL="${ROOT_DIR}/build/tools/appimagetool"
|
||||
fi
|
||||
|
||||
echo "Preparing AppDir at ${APP_DIR}..."
|
||||
rm -rf "${APP_DIR}"
|
||||
mkdir -p "${APP_DIR}"
|
||||
|
||||
# Copy bundle contents
|
||||
cp -r "${BUNDLE_DIR}"/* "${APP_DIR}/"
|
||||
|
||||
# AppRun script
|
||||
cat << 'EOF' > "${APP_DIR}/AppRun"
|
||||
#!/bin/sh
|
||||
HERE="$(dirname "$(readlink -f "${0}")")"
|
||||
export PATH="${HERE}/usr/bin:${PATH}"
|
||||
export LD_LIBRARY_PATH="${HERE}/lib:${HERE}/usr/lib:${LD_LIBRARY_PATH}"
|
||||
exec "${HERE}/gel_rule_app" "$@"
|
||||
EOF
|
||||
chmod +x "${APP_DIR}/AppRun"
|
||||
|
||||
# Desktop file
|
||||
cat << 'EOF' > "${APP_DIR}/prisma.desktop"
|
||||
[Desktop Entry]
|
||||
Name=Prisma
|
||||
GenericName=Booru Client
|
||||
Comment=Prisma - Modern Booru & Art Client
|
||||
Exec=gel_rule_app %u
|
||||
Icon=prisma
|
||||
Terminal=false
|
||||
Type=Application
|
||||
Categories=Network;Graphics;
|
||||
StartupWMClass=com.example.gel_rule_app
|
||||
EOF
|
||||
|
||||
mkdir -p "${APP_DIR}/usr/share/applications"
|
||||
cp "${APP_DIR}/prisma.desktop" "${APP_DIR}/usr/share/applications/prisma.desktop"
|
||||
|
||||
# Icons
|
||||
ICON_SRC="${ROOT_DIR}/macos/Runner/Assets.xcassets/AppIcon.appiconset"
|
||||
if [ -d "${ICON_SRC}" ]; then
|
||||
for size in 16 32 64 128 256 512 1024; do
|
||||
if [ -f "${ICON_SRC}/app_icon_${size}.png" ]; then
|
||||
mkdir -p "${APP_DIR}/usr/share/icons/hicolor/${size}x${size}/apps"
|
||||
cp "${ICON_SRC}/app_icon_${size}.png" "${APP_DIR}/usr/share/icons/hicolor/${size}x${size}/apps/prisma.png"
|
||||
fi
|
||||
done
|
||||
cp "${ICON_SRC}/app_icon_512.png" "${APP_DIR}/prisma.png"
|
||||
fi
|
||||
|
||||
# Build AppImage
|
||||
echo "Generating AppImage with ${APPIMAGETOOL}..."
|
||||
ARCH=x86_64 "${APPIMAGETOOL}" -n "${APP_DIR}" "${OUTPUT_APPIMAGE}"
|
||||
|
||||
echo "AppImage created successfully: ${OUTPUT_APPIMAGE}"
|
||||
ls -lh "${OUTPUT_APPIMAGE}"
|
||||
@@ -40,7 +40,7 @@ class GelRuleApp extends ConsumerWidget {
|
||||
return DynamicColorBuilder(
|
||||
builder: (lightDynamic, darkDynamic) {
|
||||
return MaterialApp.router(
|
||||
title: 'Lunaris',
|
||||
title: 'Prisma',
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: buildTheme(
|
||||
Brightness.light,
|
||||
@@ -97,7 +97,7 @@ class _AppOverlayState extends ConsumerState<_AppOverlay> {
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text('${strings.appUpdateAvailable}: Lunaris ${info.version}'),
|
||||
title: Text('${strings.appUpdateAvailable}: Prisma ${info.version}'),
|
||||
content: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 520),
|
||||
child: SingleChildScrollView(
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../backend/models/post.dart';
|
||||
import '../features/post/presentation/post_details_screen.dart';
|
||||
import '../features/post/presentation/similar_posts_screen.dart';
|
||||
|
||||
class AppNavigator {
|
||||
const AppNavigator._();
|
||||
|
||||
static void openPost(
|
||||
BuildContext context, {
|
||||
required Post post,
|
||||
List<Post>? postsList,
|
||||
}) {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (ctx) => PostDetailsScreen(
|
||||
providerId: post.providerId,
|
||||
postId: post.id,
|
||||
initialPost: post,
|
||||
postsList: postsList,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static void openSimilarPosts(
|
||||
BuildContext context, {
|
||||
required Post post,
|
||||
}) {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (ctx) => SimilarPostsScreen(
|
||||
providerId: post.providerId,
|
||||
postId: post.id,
|
||||
initialPost: post,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,3 @@
|
||||
const appDisplayVersion = '3.2.0';
|
||||
const appBuildNumber = 71;
|
||||
const appDisplayVersion = '3.5.5';
|
||||
const appBuildNumber = 81;
|
||||
const appReleaseChannel = 'Stable';
|
||||
|
||||
@@ -1,5 +1,185 @@
|
||||
const lunarisChangelog = [
|
||||
LunarisChange(
|
||||
const prismaChangelog = [
|
||||
PrismaChange(
|
||||
version: '3.7.2',
|
||||
title: 'Режим непрерывного чтения комиксов и серий e621',
|
||||
bullets: [
|
||||
'Мгновенное открытие комикса целиком: по нажатию «Читать весь комикс» загружаются все страницы серии в оригинальном порядке и открываются в полноэкранном ридере со свайпом (влево-вправо на мобильных, стрелками и кнопками на ПК).',
|
||||
'Плавающий бейдж страниц: во время чтения комикса отображается аккуратный стеклянный индикатор номера страницы («Комикс • Стр. X из Y»).',
|
||||
'Быстрое оглавление: отдельная кнопка сетки страниц для перехода на любую конкретную главу или страницу без потери последовательности чтения.',
|
||||
],
|
||||
),
|
||||
PrismaChange(
|
||||
version: '3.7.1',
|
||||
title: 'Вкладка «e621» в Избранном и исправление листания комиксов',
|
||||
bullets: [
|
||||
'Отдельная вкладка «e621» в разделе «Избранное»: прямое отображение серверного избранного привязанного аккаунта e621 (своего или друга), плавная подгрузка при скролле и кнопка импорта в локальные закладки.',
|
||||
'Исправление ошибки «Post not found» в комиксах: исправлен парсер ответов e621 для одиночных постов (/posts/<id>.json), теперь перелистывание страниц серий и переход по вариациям работают стабильно.',
|
||||
],
|
||||
),
|
||||
PrismaChange(
|
||||
version: '3.7.0',
|
||||
title: 'Большое обновление e621: авторизация по API-ключу, комиксы/пулы, вариации постов и синхронизация',
|
||||
bullets: [
|
||||
'Окно авторизации e621 (Username + API Key): красивое модальное окно прямо в карточке источника e621 с быстрой проверкой ключа, снятием жесткого лимита в 2 тега при поиске и персональным User-Agent.',
|
||||
'Комиксы и Пулы (Pools): удобная модальная панель для серий и комиксов с индикатором страниц, навигацией «Предыдущая/Следующая» и сеткой всех страниц серии для быстрого перехода.',
|
||||
'Связанные посты и вариации (Parent/Child relations): карточка версий с быстрым переходом к родительскому арту и альтернативным вариациям (позы, одежда, скетчи).',
|
||||
'Серверное голосование и синхронизация Избранного: возможность ставить лайки/дизлайки с отправкой на e621 и автоматическая синхронизация избранного при наличии API-ключа.',
|
||||
'Защита Rate Limiting: интеллектуальное ограничение частоты запросов (до 2 rps), предотвращающее ошибки 503 и блокировки по IP.',
|
||||
],
|
||||
),
|
||||
PrismaChange(
|
||||
version: '3.6.5',
|
||||
title: 'Исправление наложения превью на видеоплеер',
|
||||
bullets: [
|
||||
'Устранение наложения превью-картинки на воспроизводимое видео: убран фоновый слой постера под видеоплеером и восстановлен классический черный бэкграунд для правильного отображения границ и леттербоксинга видео любого формата.',
|
||||
'Чистый рендеринг видео в карточках ленты: предпросмотр видео в ленте также изолирован от просвечивания подложки.',
|
||||
],
|
||||
),
|
||||
PrismaChange(
|
||||
version: '3.6.4',
|
||||
title: 'Полное исправление воспроизведения видео на ПК (устранение синего экрана)',
|
||||
bullets: [
|
||||
'Программный рендеринг (S/W) по умолчанию для ПК: на десктопных платформах отключен проблемный OpenGL FBO мост аппаратного ускорения, вызывавший синий экран из-за отсутствия или конфликтов драйверов VAAPI/VDPAU в Linux/AppImage.',
|
||||
'Переключатель декодера H/W / S/W в плеере: на верхней панели управления видеоплеером добавлена кнопка быстрого переключения между аппаратным (H/W) и программным (S/W) декодированием на лету.',
|
||||
'Отображение постера-превью: под видеоплеером теперь отображается превью-постер на время буферизации, исключая появление пустых областей.',
|
||||
'Безопасное превью в ленте: предпросмотр видео в карточках постов на ПК также переведен на надежный программный рендерер.',
|
||||
],
|
||||
),
|
||||
PrismaChange(
|
||||
version: '3.6.3',
|
||||
title: 'Горизонтальный скролл колесиком мыши на ПК и исправление синего экрана в видеоплеере',
|
||||
bullets: [
|
||||
'Горизонтальная прокрутка колесиком мыши в настройках: верхняя панель категорий настроек теперь плавно прокручивается колесиком мыши строго при наведении курсора.',
|
||||
'Горизонтальная прокрутка колесиком мыши в авторах: полоса любимых авторов и лента их медиа теперь поддерживают быструю прокрутку колесиком мыши при наведении курсора.',
|
||||
'Устранение «синего экрана» в видеоплеере на ПК: исправлена нативная инициализация MediaKit перед запуском Flutter-движка и настроен оптимальный режим аппаратного ускорения для десктопных платформ.',
|
||||
],
|
||||
),
|
||||
PrismaChange(
|
||||
version: '3.6.2',
|
||||
title: 'Комплексная оптимизация памяти на телефоне и ПК, полное отключение зума колесиком',
|
||||
bullets: [
|
||||
'Полное отключение масштабирования колесиком мыши: перехвачен внутренний обработчик PointerScrollEvent в интерактивном вьюере, что навсегда исключило нежелательный зум при прокрутке.',
|
||||
'Предотвращение вылетов на смартфонах (OOM): включен флаг android:largeHeap="true", расширяющий доступный лимит оперативной памяти процесса до 512–1024 МБ.',
|
||||
'Ограничение аппетита кэша изображений: глобальный ImageCache в Flutter ограничен безопасным лимитом в 180 МБ и 120 изображений (вместо неограниченного роста до 1 ГБ+), неактивные растры своевременно выгружаются сборщиком мусора.',
|
||||
'Оптимизация декодирования тяжелых артов: полноразмерные изображения с экстремальным разрешением (до 10K+) теперь безопасно декодируются с ограничением memCacheWidth под экран устройства, снижая расход RAM на 80–90%.',
|
||||
'Оптимизация сетки и миниатюр: карточки ленты изолированы в RepaintBoundary для гладкого 120 FPS скролла без паразитных перерисовок, а всем каруселям, аватаркам и мозаикам заданы строгие границы кэширования.',
|
||||
],
|
||||
),
|
||||
PrismaChange(
|
||||
version: '3.6.1',
|
||||
title: 'Плавная боковая панель без лагов и отключение зума колесиком',
|
||||
bullets: [
|
||||
'Устранение лагов боковой панели: полностью переработана анимация наведения на боковую панель на ПК. Убран тяжелый шейдерный блюр при изменении ширины и зафиксирована верстка элементов — текст больше не сжимается, не дергается и раскрывается на 120 FPS максимально плавно.',
|
||||
'Отключение зума фото по колесику мыши: исключено случайное и резкое масштабирование изображений при прокрутке колесиком мыши. Масштабирование фото доступно через горячие клавиши (+, -, 0, F) или двойной клик.',
|
||||
],
|
||||
),
|
||||
PrismaChange(
|
||||
version: '3.6.0',
|
||||
title: 'Редизайн интерфейса для ПК и система горячих клавиш (хоткеи)',
|
||||
bullets: [
|
||||
'Горячие клавиши для всех вкладок: переключение разделов по Ctrl+1..8 (и Alt+1..8), циклический переход Ctrl+Tab / Ctrl+Shift+Tab, поиск Ctrl+F, обновление Ctrl+R / F5.',
|
||||
'Хоткеи видеоплеера: пауза/воспроизведение (Space, K), плавная перемотка на 5с (←/→, J/L) и 15с (Shift+←/→) с визуальными индикаторами, регулировка громкости ↑/↓, выключение звука M, полный экран F.',
|
||||
'Хоткеи просмотра фото и галереи: масштабирование клавишами + / - / 0 (сброс в 100%), переключение страниц и фото в постах стрелками ← / →, быстрое сохранение Ctrl+S.',
|
||||
'Окно-шпаргалка горячих клавиш: интерактивное модальное окно со всеми клавиатурными комбинациями по нажатию ? или F1, а также из меню панели.',
|
||||
'Редизайн боковой панели ПК: боковое меню в стиле Liquid Glass с эффектом матового стекла, логотипом Lunaris, бейджами хоткеев, закреплением панели и быстрыми действиями.',
|
||||
'Редизайн вкладок ленты: стильный сегментированный бар выбора периодов (Все время, 24ч, Неделя, Месяц, Год) с анимированными иконками и неоновые чипы источников.',
|
||||
],
|
||||
),
|
||||
PrismaChange(
|
||||
version: '3.5.5',
|
||||
title: 'Сохранение последней вкладки и состояния навигации между разделами',
|
||||
bullets: [
|
||||
'Сохранение последней вкладки: приложение запоминает активный раздел при выходе или закрытии и автоматически открывает его при следующем запуске.',
|
||||
'Независимая история и сохранение места: при переключении между вкладками состояние каждого раздела полностью сохраняется. Например, если открыть публикацию или карточку у артиста и переключиться в поиск или ленту, при возврате обратно вы останетесь ровно на том же посте.',
|
||||
'Бесшовные переходы между ветками навигации: улучшена обработка системной кнопки «Назад», переход по разделам стал еще быстрее и плавнее.',
|
||||
],
|
||||
),
|
||||
PrismaChange(
|
||||
version: '3.5.4',
|
||||
title: 'Исправление распознавания MP4-видео и отключение устаревших сервисов Kemono/Coomer',
|
||||
bullets: [
|
||||
'Устранена ошибка классификации видео: файлы MP4, содержащие слово «audio» в имени файла или параметрах запроса, больше не распознаются как аудио и корректно воспроизводятся как полноценное видео.',
|
||||
'Точное определение расширений: внедрён строгий парсинг расширений файлов, исключающий ложные срабатывания при определении типа медиа.',
|
||||
'Удаление устаревших провайдеров Kemono и Coomer: окончательно удалены неработающие сервисы Kemono и Coomer из списка провайдеров и настроек (основным источником артистов является Pawchive).',
|
||||
],
|
||||
),
|
||||
PrismaChange(
|
||||
version: '3.5.3',
|
||||
title: 'Поддержка аудиозаписей (MP3/WAV/FLAC) и исправление загрузки медиа у авторов',
|
||||
bullets: [
|
||||
'Полноценная поддержка аудио: добавлено воспроизведение MP3, M4A, WAV, FLAC, OGG, AAC и OPUS файлов авторов (Patreon, Fanbox и др.).',
|
||||
'Встроенный аудиопроигрыватель: карточка с анимированной пластинкой/обложкой, таймлайном, перемоткой на 10 сек, повтором и регулятором громкости.',
|
||||
'Устранение ошибки 404 у миниатюр: исправлена попытка загрузки несуществующих эскизов для аудио и видео с Pawchive, из-за чего файлы отображались как битые.',
|
||||
'Фильтр аудиозаписей: во вкладке артиста добавлен фильтр «Аудио», позволяющий мгновенно находить все треки и голосовые посты автора.',
|
||||
],
|
||||
),
|
||||
PrismaChange(
|
||||
version: '3.5.2',
|
||||
title: 'Исправление наложения клавиатуры и поиска авторов',
|
||||
bullets: [
|
||||
'Устранена ошибка выдвижения белого прямоугольника: исправлен конфликт двойного учета клавиатурного отступа (double viewInsets) во вложенных скаффолдах.',
|
||||
'Корректная видимость авторов: карточки артистов больше не сжимаются и остаются полностью видимыми и прокручиваемыми при вводе запроса.',
|
||||
'Улучшенное поведение интерфейса: плавающая кнопка настроек и нижняя панель скрываются при открытии клавиатуры, добавлен свайп для скрытия клавиатуры при прокрутке.',
|
||||
],
|
||||
),
|
||||
PrismaChange(
|
||||
version: '3.5.1',
|
||||
title: 'Улучшение панели поиска и фильтров авторов в Liquid Glass',
|
||||
bullets: [
|
||||
'Чистый дизайн поиска: убрана лишняя заливка поля ввода и тяжелые рамки, поиск авторов стал идеально прозрачным и монолитным.',
|
||||
'Единая компактная строка фильтров: провайдеры и платформы объединены в элегантную полосу прокрутки без нагромождения строк.',
|
||||
'Нейтральные бейджи и аккуратные даты: скорректированы цвета счетчиков постов и укорочен вывод времени обновления.',
|
||||
],
|
||||
),
|
||||
PrismaChange(
|
||||
version: '3.5.0',
|
||||
title: 'Редизайн вкладки «Артисты» в стиле Liquid Glass',
|
||||
bullets: [
|
||||
'Дизайн-код Liquid Glass: карточки авторов и панель поиска переведены на эстетику матового стекла с эффектом размытия BackdropFilter и глянцевыми окантовками.',
|
||||
'Цветовое кодирование сервисов: для платформ Patreon, Fanbox, Fantia, Boosty и Discord добавлены фирменные неоновые фильтры и светящиеся бейджи.',
|
||||
'Интерактивные карточки: squircle-аватары с деликатным свечением, анимированная кнопка избранного с эффектом золотого сияния.',
|
||||
'Стеклянное модальное окно избранного: обновленный диалог любимых авторов с полупрозрачным блюром и стильной панелью Pawchive-синхронизации.',
|
||||
],
|
||||
),
|
||||
PrismaChange(
|
||||
version: '3.4.1',
|
||||
title: 'Полный ребрендинг интерфейса: заголовок ленты, окно «О приложении» и системные службы',
|
||||
bullets: [
|
||||
'Полное обновление названия в интерфейсе: заголовок в шапке ленты заменен на Prisma, обновлен логотип и карточка в диалоге «О приложении».',
|
||||
'Окна обновлений и чейнджлогов: все диалоги проверки обновлений, чейнджлога и бета-версий теперь используют имя Prisma.',
|
||||
'Системные службы и User-Agent: сетевой стек, экспорт резервных копий и кэш тегов переведены на Prisma с автоматической миграцией сохраненных данных.',
|
||||
],
|
||||
),
|
||||
PrismaChange(
|
||||
version: '3.4.0',
|
||||
title: 'Ребрендинг в Prisma: новое имя, комфортная прокрутка анонсов и обновленные репозитории',
|
||||
bullets: [
|
||||
'Ребрендинг в Prisma: приложение официально переименовано в Prisma — обновлены названия окон, манифесты и системные идентификаторы.',
|
||||
'Улучшенная прокрутка анонсов: в диалоге личных сообщений и рассылок автора добавлен дополнительный нижний отступ, чтобы карточки не перекрывались нижней панелью навигации и системными элементами управления.',
|
||||
'Синхронизация репозиториев: репозитории на GitHub и Gitea переведены на Prisma, ссылки внутри приложения и служба автообновления переключены на новые адреса.',
|
||||
],
|
||||
),
|
||||
PrismaChange(
|
||||
version: '3.3.1',
|
||||
title: 'Улучшенное разделение текстовых постов и облачных зеркал',
|
||||
bullets: [
|
||||
'Исключение дублирования в текстовых публикациях: для текстовых постов (без медиа) убрана лишняя плашка «Облачные диски и зеркала», исключая повторный вывод того же текста внизу экрана.',
|
||||
'Вкладки «Зеркала» и «Описание»: для постов с фото, видео и GIF в блоке облачных дисков теперь доступны удобные переключаемые вкладки, позволяющие отдельно просматривать список ссылок на хранилища и авторское описание.',
|
||||
'Чистый и нативный вид карточек: если у медиапоста есть только ссылки или только описание, отображается аккуратная монолитная секция с соответствующим заголовком и иконкой без лишних переключателей.',
|
||||
],
|
||||
),
|
||||
PrismaChange(
|
||||
version: '3.3.0',
|
||||
title: 'Редизайн личных сообщений и рассылок артиста, нативная поддержка текстовых публикаций',
|
||||
bullets: [
|
||||
'Центр сообщений и рассылок автора: полностью переработан диалог по кнопке-рупору на экране артиста — аккуратные карточки сообщений в стиле мессенджера, бейджи сервисов, точное время публикации и кнопка быстрого копирования текста в буфер обмена.',
|
||||
'Нативная поддержка текстовых постов: публикации без медиафайлов больше не вызывают ошибок и пустых плейсхолдеров — в сетке они отображаются как стильные заметки с заголовком и выдержкой текста.',
|
||||
'Удобное чтение статей и заметок: при открытии текстового поста отображается комфортный ридер с форматированным текстом, ссылками автора и тегами.',
|
||||
'Фильтрация по типу контента: на экране автора добавлен фильтр «Текст» с подсчетом количества публикаций наряду с фото, видео и GIF.',
|
||||
'Поддержка анонсов в Kemono и Coomer: API анонсов и персональных сообщений теперь полноценно работает для всех поддерживаемых провайдеров.',
|
||||
],
|
||||
),
|
||||
PrismaChange(
|
||||
version: '3.2.0',
|
||||
title: 'Стабильный полноэкранный режим: сохранение воспроизведения, устранение вылетов плеера и чистый интерфейс',
|
||||
bullets: [
|
||||
@@ -9,7 +189,7 @@ const lunarisChangelog = [
|
||||
'Быстрое скрытие и показ контролов: касание свободной области видео плавно переключает видимость панели управления, а при нажатии на кнопки таймер автоскрытия автоматически обновляется.',
|
||||
],
|
||||
),
|
||||
LunarisChange(
|
||||
PrismaChange(
|
||||
version: '3.1.9',
|
||||
title: 'Полноэкранный плеер 2.0: мгновенный переход без задержек, жесты яркости и скраббинг с превью',
|
||||
bullets: [
|
||||
@@ -19,7 +199,7 @@ const lunarisChangelog = [
|
||||
'Сброс яркости: при выходе из полноэкранного режима системный уровень яркости экрана аккуратно восстанавливается.',
|
||||
],
|
||||
),
|
||||
LunarisChange(
|
||||
PrismaChange(
|
||||
version: '3.1.8',
|
||||
title: 'Безупречный полноэкранный плеер: устранение остановки при развертывании и отзывчивые кнопки управления',
|
||||
bullets: [
|
||||
@@ -29,7 +209,7 @@ const lunarisChangelog = [
|
||||
'Синхронизация состояния при сворачивании: плеер точно запоминает состояние паузы/воспроизведения при выходе из полноэкранного режима обратно в пост.',
|
||||
],
|
||||
),
|
||||
LunarisChange(
|
||||
PrismaChange(
|
||||
version: '3.1.7',
|
||||
title: 'Устранение критического сбоя при открытии видеопоста (SIGSEGV в libmpv)',
|
||||
bullets: [
|
||||
@@ -38,7 +218,7 @@ const lunarisChangelog = [
|
||||
'Надежный фоллбэк при редких ошибках кодека: при несовместимости аппаратного декодера плеер бесшовно переключается на программный FFmpeg-декодер без риска краша.',
|
||||
],
|
||||
),
|
||||
LunarisChange(
|
||||
PrismaChange(
|
||||
version: '3.1.6',
|
||||
title: 'Надежный полноэкранный плеер, сохранение провайдеров в поиске, громкость и устранение ошибки кодека',
|
||||
bullets: [
|
||||
@@ -48,7 +228,7 @@ const lunarisChangelog = [
|
||||
'Автоматический fallback для ошибки Could not open codec: видеоплеер получил безопасные профили декодирования и автопереключение на программный лавк-декодер (FFmpeg software fallback) при редких сбоях аппаратного ускорения.',
|
||||
],
|
||||
),
|
||||
LunarisChange(
|
||||
PrismaChange(
|
||||
version: '3.1.5',
|
||||
title: 'Сохранение громкости видео, исправление полноэкранного плеера, фильтрации и удобства комментариев',
|
||||
bullets: [
|
||||
@@ -59,7 +239,7 @@ const lunarisChangelog = [
|
||||
'Удобный доступ к комментариям в посте: увеличен отступ снизу в деталях поста и расширена зона клика блока комментариев, позволяя комфортно прокрутить их выше системных жестов навигации.',
|
||||
],
|
||||
),
|
||||
LunarisChange(
|
||||
PrismaChange(
|
||||
version: '3.1.4',
|
||||
title: 'Оптимизация видеоплеера — адаптивная высота и идеальное прилегание полосы воспроизведения',
|
||||
bullets: [
|
||||
@@ -68,7 +248,7 @@ const lunarisChangelog = [
|
||||
'Компактные элементы управления: выровнены буферный трек и слайдер прокрутки, оптимизирован градиент затенения для максимальной видимости и удобства управления.',
|
||||
],
|
||||
),
|
||||
LunarisChange(
|
||||
PrismaChange(
|
||||
version: '3.1.3',
|
||||
title: 'iPhone Liquid Glass — Источники, Проверка сети и Навигация назад по вкладкам',
|
||||
bullets: [
|
||||
@@ -78,7 +258,7 @@ const lunarisChangelog = [
|
||||
'Liquid Glass редизайн проверки состояния источников (Provider Check): карточка общих метрик сети (онлайн/офлайн, средний пинг), раскрывающиеся аккордеоны детальной диагностики, живые светодиодные LED-индикаторы статуса и кнопка одновременной проверки всех провайдеров.',
|
||||
],
|
||||
),
|
||||
LunarisChange(
|
||||
PrismaChange(
|
||||
version: '3.1.2',
|
||||
title: 'Оптимизация производительности: устранение лагов при скролле к тегам',
|
||||
bullets: [
|
||||
@@ -88,7 +268,7 @@ const lunarisChangelog = [
|
||||
'Интеллектуальный лимит начального отображения: оптимизировано первоначальное количество отображаемых тегов в огромных категориях с удобной кнопкой быстрого раскрытия.',
|
||||
],
|
||||
),
|
||||
LunarisChange(
|
||||
PrismaChange(
|
||||
version: '3.1.1',
|
||||
title: 'Деление тегов по стилю e621/Gelbooru/Rule34 и исправление поиска тегов со скобками',
|
||||
bullets: [
|
||||
@@ -100,7 +280,7 @@ const lunarisChangelog = [
|
||||
'Liquid Glass оформление блока тегов в детальном просмотре поста на мобильных и десктоп устройствах.',
|
||||
],
|
||||
),
|
||||
LunarisChange(
|
||||
PrismaChange(
|
||||
version: '3.1.0',
|
||||
title: 'iPhone Liquid Glass — Поиск, Избранное, История и исправление доступности',
|
||||
bullets: [
|
||||
@@ -110,7 +290,7 @@ const lunarisChangelog = [
|
||||
'Тактильный отклик и стеклянные пилюли: быстрые операторы, категории, недавние запросы, фильтры медиа и переключатели разделов получили матовое оформление и вибрационный отклик Haptic Feedback.',
|
||||
],
|
||||
),
|
||||
LunarisChange(
|
||||
PrismaChange(
|
||||
version: '3.0.9',
|
||||
title: 'Коррекция позиции кнопки настроек и отображения кнопок JSON и бэкапа',
|
||||
bullets: [
|
||||
@@ -119,7 +299,7 @@ const lunarisChangelog = [
|
||||
'Улучшенный дизайн и сетка кнопок экспорта/импорта: кнопки бэкапа и JSON организованы в симметричные аккуратные строки с яркими акцентными бейджами.',
|
||||
],
|
||||
),
|
||||
LunarisChange(
|
||||
PrismaChange(
|
||||
version: '3.0.8',
|
||||
title: 'iPhone Liquid Glass — раздел «Настройки» и парящая стеклянная кнопка',
|
||||
bullets: [
|
||||
@@ -129,7 +309,7 @@ const lunarisChangelog = [
|
||||
'Стеклянная быстрая навигация по категориям: зафиксированная панель категорий с размытием фона и тактильным откликом при переключении разделов.',
|
||||
],
|
||||
),
|
||||
LunarisChange(
|
||||
PrismaChange(
|
||||
version: '3.0.7',
|
||||
title: 'Liquid Glass Navigation Bar — парящая стеклянная панель и анимированная пилюля',
|
||||
bullets: [
|
||||
@@ -139,7 +319,7 @@ const lunarisChangelog = [
|
||||
'Глубина контента: контент ленты, поиска и галереи плавно просвечивает и размывается под стеклянной панелью при прокрутке.',
|
||||
],
|
||||
),
|
||||
LunarisChange(
|
||||
PrismaChange(
|
||||
version: '3.0.6',
|
||||
title: 'Улучшение недавних запросов и отображения тегов',
|
||||
bullets: [
|
||||
@@ -149,7 +329,7 @@ const lunarisChangelog = [
|
||||
'Удобное удаление: увеличенная область нажатия для кнопки удаления (крестика) предотвращает случайные нажатия на сам запрос.',
|
||||
],
|
||||
),
|
||||
LunarisChange(
|
||||
PrismaChange(
|
||||
version: '3.0.5',
|
||||
title: 'Интерактивные ссылки на соцсети и авторов в объявлениях и описаниях',
|
||||
bullets: [
|
||||
@@ -158,7 +338,7 @@ const lunarisChangelog = [
|
||||
'Корректная очистка пунктуации: знаки препинания в конце URL (!, ?, :, ., ,, ;, >) больше не ломают ссылки.',
|
||||
],
|
||||
),
|
||||
LunarisChange(
|
||||
PrismaChange(
|
||||
version: '3.0.0',
|
||||
title: 'Lunaris 3.0 — Глобальное обновление: Дизайн 2.0, Интеграция Pawchive и умная синхронизация',
|
||||
bullets: [
|
||||
@@ -171,7 +351,7 @@ const lunarisChangelog = [
|
||||
'Исправление вёрстки: устранены переносы текста в заголовке окна аккаунтов и карточках пользователей на экранах смартфонов.',
|
||||
],
|
||||
),
|
||||
LunarisChange(
|
||||
PrismaChange(
|
||||
version: '2.3.10',
|
||||
title: 'Выгрузка локального избранного в Pawchive и двусторонняя синхронизация',
|
||||
bullets: [
|
||||
@@ -181,7 +361,7 @@ const lunarisChangelog = [
|
||||
'Синхронизация в реальном времени с любых провайдеров: при добавлении автора в избранное в лентах Kemono, Coomer или Pawchive изменение мгновенно передаётся на сервер Pawchive.',
|
||||
],
|
||||
),
|
||||
LunarisChange(
|
||||
PrismaChange(
|
||||
version: '2.3.9',
|
||||
title: 'Исправление входа и синхронизации аккаунтов Pawchive',
|
||||
bullets: [
|
||||
@@ -190,7 +370,7 @@ const lunarisChangelog = [
|
||||
'Информативные ошибки: понятные сообщения на русском при неверном пароле или сбое сети вместо сырых исключений Dio.',
|
||||
],
|
||||
),
|
||||
LunarisChange(
|
||||
PrismaChange(
|
||||
version: '2.3.8',
|
||||
title: 'ABI-Aware Auto-Update — правильный APK для вашего процессора',
|
||||
bullets: [
|
||||
@@ -200,7 +380,7 @@ const lunarisChangelog = [
|
||||
'Кеш ABI: архитектура определяется один раз за сеанс.',
|
||||
],
|
||||
),
|
||||
LunarisChange(
|
||||
PrismaChange(
|
||||
version: '2.3.7',
|
||||
title: 'Lazy Media Dimensions for Pawchive & Unknown-Size Providers',
|
||||
bullets: [
|
||||
@@ -209,7 +389,7 @@ const lunarisChangelog = [
|
||||
'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(
|
||||
PrismaChange(
|
||||
version: '2.3.6',
|
||||
title: 'Pawchive Accounts Integration & Favorites Synchronization',
|
||||
bullets: [
|
||||
@@ -220,7 +400,7 @@ const lunarisChangelog = [
|
||||
'Dedicated UI & Quick Access: manage connected accounts, view sync stats, and trigger synchronization from the Artists screen, Favorites modal, and Settings screen.',
|
||||
],
|
||||
),
|
||||
LunarisChange(
|
||||
PrismaChange(
|
||||
version: '2.3.5',
|
||||
title: 'Fullscreen Video Auto-Rotation & Orientation Controls',
|
||||
bullets: [
|
||||
@@ -229,7 +409,7 @@ const lunarisChangelog = [
|
||||
'Clean Orientation Recovery: exiting fullscreen seamlessly restores system navigation bars (Edge-to-Edge) and restores unrestricted device sensor orientations.',
|
||||
],
|
||||
),
|
||||
LunarisChange(
|
||||
PrismaChange(
|
||||
version: '2.3.4',
|
||||
title: 'OverlayPortal Architecture & Search UI Unblocking',
|
||||
bullets: [
|
||||
@@ -240,7 +420,7 @@ const lunarisChangelog = [
|
||||
'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(
|
||||
PrismaChange(
|
||||
version: '2.3.3',
|
||||
title: 'Tag Suggestions Overlay Fix & Rule34 API Alignment',
|
||||
bullets: [
|
||||
@@ -250,7 +430,7 @@ const lunarisChangelog = [
|
||||
'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(
|
||||
PrismaChange(
|
||||
version: '2.3.2',
|
||||
title: 'Gelbooru DAPI Compliance & Tag Query Optimization',
|
||||
bullets: [
|
||||
@@ -260,7 +440,7 @@ const lunarisChangelog = [
|
||||
'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(
|
||||
PrismaChange(
|
||||
version: '2.3.1',
|
||||
title: 'Cloud Mirrors & Author Announcements Fixes',
|
||||
bullets: [
|
||||
@@ -269,7 +449,7 @@ const lunarisChangelog = [
|
||||
'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(
|
||||
PrismaChange(
|
||||
version: '2.3.0',
|
||||
title: 'Artist Cloud Drives & External Video Mirrors',
|
||||
bullets: [
|
||||
@@ -281,7 +461,7 @@ const lunarisChangelog = [
|
||||
'Artist Commentary Card: formatted display of author notes, instructions, and text below post details.',
|
||||
],
|
||||
),
|
||||
LunarisChange(
|
||||
PrismaChange(
|
||||
version: '2.2.0',
|
||||
title: 'Offline Mode Overhaul & Post Details Screen Redesign',
|
||||
bullets: [
|
||||
@@ -292,7 +472,7 @@ const lunarisChangelog = [
|
||||
'Unified Neighbor Strip & Tags Card: post neighbor thumbnail strip now available on both mobile and desktop, alongside modern rounded tag blocks and chat-bubble comments.',
|
||||
],
|
||||
),
|
||||
LunarisChange(
|
||||
PrismaChange(
|
||||
version: '2.1.0',
|
||||
title: '4-Tab Major Redesign: Search, Favorites, History & Collections',
|
||||
bullets: [
|
||||
@@ -302,7 +482,7 @@ const lunarisChangelog = [
|
||||
'Collections Tab Redesign: Pinterest-style collection cards with 2x2 thumbnail mosaic covers and glassmorphism post counters, modernized collection details screen with description banner, and elegant squircle creation dialog.',
|
||||
],
|
||||
),
|
||||
LunarisChange(
|
||||
PrismaChange(
|
||||
version: '2.0.15',
|
||||
title: 'Settings 2.0 Layout Fixes & Pinned Quick Navigation',
|
||||
bullets: [
|
||||
@@ -313,7 +493,7 @@ const lunarisChangelog = [
|
||||
'Header text overflow protections: wrapped title texts in Expanded across all settings cards.',
|
||||
],
|
||||
),
|
||||
LunarisChange(
|
||||
PrismaChange(
|
||||
version: '2.0.14',
|
||||
title: 'Settings 2.0: Modern Grouped Design & Quick Ergonomics',
|
||||
bullets: [
|
||||
@@ -325,7 +505,7 @@ const lunarisChangelog = [
|
||||
'Hero Brand Banner: polished app presentation card in About section with glowing icon, version build badge, update checker, and GitHub repository link.',
|
||||
],
|
||||
),
|
||||
LunarisChange(
|
||||
PrismaChange(
|
||||
version: '2.0.13',
|
||||
title: 'Toolbar Section Dividers & Post Card Badge Stacking Fix',
|
||||
bullets: [
|
||||
@@ -335,7 +515,7 @@ const lunarisChangelog = [
|
||||
'Expanded Bottom Gradient: increased bottom cinematic gradient height to 64dp for clean text and badge contrast.',
|
||||
],
|
||||
),
|
||||
LunarisChange(
|
||||
PrismaChange(
|
||||
version: '2.0.12',
|
||||
title: 'Feed 2.0: Pinterest Modern Redesign & Sleek Cards',
|
||||
bullets: [
|
||||
@@ -348,7 +528,7 @@ const lunarisChangelog = [
|
||||
'Polished Search Presets: stylish bookmark capsules for quick access to saved search queries.',
|
||||
],
|
||||
),
|
||||
LunarisChange(
|
||||
PrismaChange(
|
||||
version: '2.0.11',
|
||||
title: 'Mobile Video Player Redesign & Format Recognition Fix',
|
||||
bullets: [
|
||||
@@ -362,7 +542,7 @@ const lunarisChangelog = [
|
||||
'Sleek Neon Seekbar with live network buffer progress indicator.',
|
||||
],
|
||||
),
|
||||
LunarisChange(
|
||||
PrismaChange(
|
||||
version: '2.0.10',
|
||||
title: 'Fix "and" Tag Operator Retention, Suggestion Preceding Drafts & AND Chip Styling',
|
||||
bullets: [
|
||||
@@ -372,7 +552,7 @@ const lunarisChangelog = [
|
||||
'Distinct visual styling for AND separator chips with secondary theme container and alt-route icon.',
|
||||
],
|
||||
),
|
||||
LunarisChange(
|
||||
PrismaChange(
|
||||
version: '2.0.9',
|
||||
title: 'Instant Tag Suggestions, Persistent Disk Cache, Expanded History & Match Highlighting',
|
||||
bullets: [
|
||||
@@ -386,7 +566,7 @@ const lunarisChangelog = [
|
||||
'Dedicated Clear Tag Cache actions in Settings and Cache Manager.',
|
||||
],
|
||||
),
|
||||
LunarisChange(
|
||||
PrismaChange(
|
||||
version: '2.0.8',
|
||||
title: 'Shared Context Modifiers for Multi-Tag Search & Clean Tag Filtering',
|
||||
bullets: [
|
||||
@@ -395,7 +575,7 @@ const lunarisChangelog = [
|
||||
'Resolved "No posts yet" bug by adapting client post validation to multi-tag stream groups and stripping operator keywords from booru queries.',
|
||||
],
|
||||
),
|
||||
LunarisChange(
|
||||
PrismaChange(
|
||||
version: '2.0.7',
|
||||
title: 'Independent Multi-Tag Search Streams, Local "and" Connector, Pawchive in Main Feed & Reverts',
|
||||
bullets: [
|
||||
@@ -406,7 +586,7 @@ const lunarisChangelog = [
|
||||
'Removed external linked accounts bar from artist posts screen.',
|
||||
],
|
||||
),
|
||||
LunarisChange(
|
||||
PrismaChange(
|
||||
version: '2.0.6',
|
||||
title: 'Pawchive Deep Integration: Global Posts Feed, Artist Tags, Announcements, Links & Server Media Filter',
|
||||
bullets: [
|
||||
@@ -417,7 +597,7 @@ const lunarisChangelog = [
|
||||
'Server-side media filtering (Photos, Videos, GIFs) querying the entire author archive through the API.',
|
||||
],
|
||||
),
|
||||
LunarisChange(
|
||||
PrismaChange(
|
||||
version: '2.0.5',
|
||||
title: '10 QoL Super-Update: AMOLED, Dynamic Colors, Grid switch, Share, Presets, Cache & Backup',
|
||||
bullets: [
|
||||
@@ -432,7 +612,7 @@ const lunarisChangelog = [
|
||||
'Full JSON backup export and import for seamless configuration sync.',
|
||||
],
|
||||
),
|
||||
LunarisChange(
|
||||
PrismaChange(
|
||||
version: '2.0.4',
|
||||
title: 'Platform service selection & media type filtering in artist posts',
|
||||
bullets: [
|
||||
@@ -441,7 +621,7 @@ const lunarisChangelog = [
|
||||
'Full-screen post pager respects active media type filters during swiping.',
|
||||
],
|
||||
),
|
||||
LunarisChange(
|
||||
PrismaChange(
|
||||
version: '2.0.3',
|
||||
title: 'Infinite artist pagination, favorite artists modal, and card layout polish',
|
||||
bullets: [
|
||||
@@ -451,7 +631,7 @@ const lunarisChangelog = [
|
||||
'Removed mapping truncation in Pawchive provider to deliver all post attachments cleanly.',
|
||||
],
|
||||
),
|
||||
LunarisChange(
|
||||
PrismaChange(
|
||||
version: '2.0.2',
|
||||
title: 'Swipeable media pager, favorite artists, performance and Pawchive',
|
||||
bullets: [
|
||||
@@ -463,7 +643,7 @@ const lunarisChangelog = [
|
||||
'In-app update checks and downloads connected directly to RarDog GitHub releases.',
|
||||
],
|
||||
),
|
||||
LunarisChange(
|
||||
PrismaChange(
|
||||
version: '2.0.1',
|
||||
title: 'Localization, zoom and favorite downloads',
|
||||
bullets: [
|
||||
@@ -474,7 +654,7 @@ const lunarisChangelog = [
|
||||
'Favorites can optionally auto-download media in the background, with local files tracked separately from favorites.',
|
||||
],
|
||||
),
|
||||
LunarisChange(
|
||||
PrismaChange(
|
||||
version: '2.0.0',
|
||||
title: 'Customization and provider polish',
|
||||
bullets: [
|
||||
@@ -485,7 +665,7 @@ const lunarisChangelog = [
|
||||
'Kemono and Coomer creator search uses the current mbahArip keyword endpoints with public API fallbacks.',
|
||||
],
|
||||
),
|
||||
LunarisChange(
|
||||
PrismaChange(
|
||||
version: '1.1.2',
|
||||
title: 'Experimental tag and Artists update',
|
||||
bullets: [
|
||||
@@ -495,7 +675,7 @@ const lunarisChangelog = [
|
||||
'This is an experimental release for checking the new tag and Artists behavior.',
|
||||
],
|
||||
),
|
||||
LunarisChange(
|
||||
PrismaChange(
|
||||
version: '1.1.1',
|
||||
title: 'Artists and in-app updates',
|
||||
bullets: [
|
||||
@@ -505,7 +685,7 @@ const lunarisChangelog = [
|
||||
'Tag input keeps its scroll position steadier while typing and committing chips.',
|
||||
],
|
||||
),
|
||||
LunarisChange(
|
||||
PrismaChange(
|
||||
version: '1.1.0',
|
||||
title: 'Lunaris rebrand and providers',
|
||||
bullets: [
|
||||
@@ -515,7 +695,7 @@ const lunarisChangelog = [
|
||||
'Video player UI preferences are saved across fullscreen, details and restarts.',
|
||||
],
|
||||
),
|
||||
LunarisChange(
|
||||
PrismaChange(
|
||||
version: '1.0.2',
|
||||
title: 'Provider safety fix',
|
||||
bullets: [
|
||||
@@ -524,7 +704,7 @@ const lunarisChangelog = [
|
||||
'Existing enabled legacy configs are disabled so the main feed is not polluted by known-offline APIs.',
|
||||
],
|
||||
),
|
||||
LunarisChange(
|
||||
PrismaChange(
|
||||
version: '1.0.1',
|
||||
title: 'Mobile polish',
|
||||
bullets: [
|
||||
@@ -533,7 +713,7 @@ const lunarisChangelog = [
|
||||
'Tag chip input no longer jumps while typing.',
|
||||
],
|
||||
),
|
||||
LunarisChange(
|
||||
PrismaChange(
|
||||
version: '1.0.0',
|
||||
title: 'Stable 1.0',
|
||||
bullets: [
|
||||
@@ -543,7 +723,7 @@ const lunarisChangelog = [
|
||||
'Windows portable package is published alongside APK and installer.',
|
||||
],
|
||||
),
|
||||
LunarisChange(
|
||||
PrismaChange(
|
||||
version: '0.4.3',
|
||||
title: 'Bugfixes',
|
||||
bullets: [
|
||||
@@ -555,8 +735,8 @@ const lunarisChangelog = [
|
||||
),
|
||||
];
|
||||
|
||||
class LunarisChange {
|
||||
const LunarisChange({
|
||||
class PrismaChange {
|
||||
const PrismaChange({
|
||||
required this.version,
|
||||
required this.title,
|
||||
required this.bullets,
|
||||
@@ -566,3 +746,6 @@ class LunarisChange {
|
||||
final String title;
|
||||
final List<String> bullets;
|
||||
}
|
||||
|
||||
const lunarisChangelog = prismaChangelog;
|
||||
typedef LunarisChange = PrismaChange;
|
||||
|
||||
@@ -21,116 +21,171 @@ import '../features/settings/presentation/settings_screen.dart';
|
||||
import '../features/viewed/presentation/viewed_screen.dart';
|
||||
import '../shared/widgets/app_shell.dart';
|
||||
|
||||
final branchNavKeys = List.generate(8, (_) => GlobalKey<NavigatorState>());
|
||||
|
||||
final appRouterProvider = Provider<GoRouter>((ref) {
|
||||
return GoRouter(
|
||||
initialLocation: '/',
|
||||
routes: [
|
||||
ShellRoute(
|
||||
builder: (context, state, child) => AppShell(child: child),
|
||||
StatefulShellRoute.indexedStack(
|
||||
builder: (context, state, navigationShell) => AppShell(
|
||||
navigationShell: navigationShell,
|
||||
child: navigationShell,
|
||||
),
|
||||
branches: [
|
||||
StatefulShellBranch(
|
||||
navigatorKey: branchNavKeys[0],
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: '/',
|
||||
pageBuilder: (context, state) => _transitionPage(
|
||||
state,
|
||||
child: FeedScreen(initialQuery: state.uri.queryParameters['q']),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
StatefulShellBranch(
|
||||
navigatorKey: branchNavKeys[1],
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: '/search',
|
||||
builder: (context, state) => const SearchScreen(),
|
||||
),
|
||||
],
|
||||
),
|
||||
StatefulShellBranch(
|
||||
navigatorKey: branchNavKeys[2],
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: '/favorites',
|
||||
builder: (context, state) => const FavoritesScreen(),
|
||||
),
|
||||
],
|
||||
),
|
||||
StatefulShellBranch(
|
||||
navigatorKey: branchNavKeys[3],
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: '/viewed',
|
||||
builder: (context, state) => const ViewedScreen(),
|
||||
),
|
||||
],
|
||||
),
|
||||
StatefulShellBranch(
|
||||
navigatorKey: branchNavKeys[4],
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: '/collections',
|
||||
builder: (context, state) => const CollectionsScreen(),
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: ':collectionId',
|
||||
builder: (context, state) => CollectionDetailsScreen(
|
||||
collectionId: state.pathParameters['collectionId']!,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
StatefulShellBranch(
|
||||
navigatorKey: branchNavKeys[5],
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: '/artists',
|
||||
builder: (context, state) => const ArtistsScreen(),
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: ':providerId/:service/:artistId',
|
||||
builder: (context, state) => ArtistPostsScreen(
|
||||
providerId: state.pathParameters['providerId']!,
|
||||
service: state.pathParameters['service']!,
|
||||
artistId: state.pathParameters['artistId']!,
|
||||
artistName: state.uri.queryParameters['name'] ??
|
||||
state.pathParameters['artistId']!,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
StatefulShellBranch(
|
||||
navigatorKey: branchNavKeys[6],
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: '/providers',
|
||||
builder: (context, state) => const ProvidersScreen(),
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: 'new',
|
||||
builder: (context, state) => ProviderFormScreen(
|
||||
initialConfig: state.extra is ContentProviderConfig
|
||||
? state.extra! as ContentProviderConfig
|
||||
: null,
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: 'check',
|
||||
builder: (context, state) => const ProviderCheckScreen(),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
StatefulShellBranch(
|
||||
navigatorKey: branchNavKeys[7],
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: '/settings',
|
||||
builder: (context, state) => const SettingsScreen(),
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: 'hidden',
|
||||
builder: (context, state) => const HiddenPostsScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: 'cache',
|
||||
builder: (context, state) => const CacheManagerScreen(),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
GoRoute(
|
||||
path: '/post/:providerId/:postId',
|
||||
builder: (context, state) {
|
||||
final extra = state.extra;
|
||||
final Post? initialPost;
|
||||
final List<Post>? postsList;
|
||||
if (extra is PostNavigationContext) {
|
||||
initialPost = extra.currentPost;
|
||||
postsList = extra.posts;
|
||||
} else if (extra is Post) {
|
||||
initialPost = extra;
|
||||
postsList = null;
|
||||
} else {
|
||||
initialPost = null;
|
||||
postsList = null;
|
||||
}
|
||||
return PostDetailsScreen(
|
||||
providerId: state.pathParameters['providerId']!,
|
||||
postId: state.pathParameters['postId']!,
|
||||
initialPost: initialPost,
|
||||
postsList: postsList,
|
||||
);
|
||||
},
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: '/',
|
||||
pageBuilder: (context, state) => _transitionPage(
|
||||
state,
|
||||
child: FeedScreen(initialQuery: state.uri.queryParameters['q']),
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/search',
|
||||
builder: (context, state) => const SearchScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/post/:providerId/:postId',
|
||||
builder: (context, state) {
|
||||
final extra = state.extra;
|
||||
final Post? initialPost;
|
||||
final List<Post>? postsList;
|
||||
if (extra is PostNavigationContext) {
|
||||
initialPost = extra.currentPost;
|
||||
postsList = extra.posts;
|
||||
} else if (extra is Post) {
|
||||
initialPost = extra;
|
||||
postsList = null;
|
||||
} else {
|
||||
initialPost = null;
|
||||
postsList = null;
|
||||
}
|
||||
return PostDetailsScreen(
|
||||
providerId: state.pathParameters['providerId']!,
|
||||
postId: state.pathParameters['postId']!,
|
||||
initialPost: initialPost,
|
||||
postsList: postsList,
|
||||
);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: '/post/:providerId/:postId/similar',
|
||||
path: 'similar',
|
||||
builder: (context, state) => SimilarPostsScreen(
|
||||
providerId: state.pathParameters['providerId']!,
|
||||
postId: state.pathParameters['postId']!,
|
||||
initialPost: state.extra is Post ? state.extra! as Post : null,
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/favorites',
|
||||
builder: (context, state) => const FavoritesScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/viewed',
|
||||
builder: (context, state) => const ViewedScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/collections',
|
||||
builder: (context, state) => const CollectionsScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/collections/:collectionId',
|
||||
builder: (context, state) => CollectionDetailsScreen(
|
||||
collectionId: state.pathParameters['collectionId']!,
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/artists',
|
||||
builder: (context, state) => const ArtistsScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/artists/:providerId/:service/:artistId',
|
||||
builder: (context, state) => ArtistPostsScreen(
|
||||
providerId: state.pathParameters['providerId']!,
|
||||
service: state.pathParameters['service']!,
|
||||
artistId: state.pathParameters['artistId']!,
|
||||
artistName: state.uri.queryParameters['name'] ??
|
||||
state.pathParameters['artistId']!,
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/providers',
|
||||
builder: (context, state) => const ProvidersScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/providers/new',
|
||||
builder: (context, state) => ProviderFormScreen(
|
||||
initialConfig: state.extra is ContentProviderConfig
|
||||
? state.extra! as ContentProviderConfig
|
||||
: null,
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/providers/check',
|
||||
builder: (context, state) => const ProviderCheckScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/settings',
|
||||
builder: (context, state) => const SettingsScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/settings/hidden',
|
||||
builder: (context, state) => const HiddenPostsScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/settings/cache',
|
||||
builder: (context, state) => const CacheManagerScreen(),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
|
||||
@@ -224,7 +224,7 @@ final updateServiceProvider = Provider<UpdateService>((ref) {
|
||||
connectTimeout: const Duration(seconds: 12),
|
||||
receiveTimeout: const Duration(seconds: 12),
|
||||
headers: const {
|
||||
'User-Agent': 'Lunaris/2.0.1 Flutter local booru browser',
|
||||
'User-Agent': 'Prisma/2.0.1 Flutter local booru browser',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
),
|
||||
|
||||
@@ -6,8 +6,19 @@ class E621Mapper {
|
||||
required String providerId,
|
||||
required String providerName,
|
||||
}) {
|
||||
final posts = data is Map ? data['posts'] : data;
|
||||
final items = posts is List ? posts : const [];
|
||||
if (data is Map && data['post'] is Map) {
|
||||
return [
|
||||
postFromJson(
|
||||
Map<String, dynamic>.from(data['post'] as Map),
|
||||
providerId: providerId,
|
||||
providerName: providerName,
|
||||
),
|
||||
];
|
||||
}
|
||||
final posts = data is Map ? (data['posts'] ?? data['post']) : data;
|
||||
final items = posts is List
|
||||
? posts
|
||||
: (posts is Map ? [posts] : const []);
|
||||
return items
|
||||
.whereType<Map>()
|
||||
.map((item) => postFromJson(
|
||||
@@ -36,6 +47,38 @@ class E621Mapper {
|
||||
for (final entry in tagsMap.entries)
|
||||
entry.key: List<String>.from((entry.value as List?) ?? const []),
|
||||
}..removeWhere((_, value) => value.isEmpty);
|
||||
|
||||
final relationships = Map<String, dynamic>.from((json['relationships'] as Map?) ?? const {});
|
||||
final parentId = relationships['parent_id']?.toString();
|
||||
if (parentId != null && parentId.isNotEmpty && parentId != 'null') {
|
||||
tagGroups['parent_id'] = [parentId];
|
||||
}
|
||||
final children = (relationships['children'] as List?)
|
||||
?.map((e) => e?.toString() ?? '')
|
||||
.where((s) => s.isNotEmpty && s != 'null')
|
||||
.toList();
|
||||
if (children != null && children.isNotEmpty) {
|
||||
tagGroups['children'] = children;
|
||||
}
|
||||
|
||||
final rawPools = (json['pools'] as List?)
|
||||
?.map((e) => e?.toString() ?? '')
|
||||
.where((s) => s.isNotEmpty)
|
||||
.toList();
|
||||
if (rawPools != null && rawPools.isNotEmpty) {
|
||||
tagGroups['pools'] = rawPools;
|
||||
}
|
||||
|
||||
final favCount = json['fav_count'];
|
||||
if (favCount != null) {
|
||||
tagGroups['fav_count'] = [favCount.toString()];
|
||||
}
|
||||
|
||||
final desc = _string(json['description']);
|
||||
if (desc.isNotEmpty) {
|
||||
tagGroups['description'] = [desc];
|
||||
}
|
||||
|
||||
final tags = tagGroups.values.expand((items) => items).toSet().toList();
|
||||
final fileUrl = _string(file['url']);
|
||||
return Post(
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
class E621Pool {
|
||||
const E621Pool({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.description,
|
||||
required this.postIds,
|
||||
required this.postCount,
|
||||
required this.category,
|
||||
required this.isActive,
|
||||
this.creatorName,
|
||||
});
|
||||
|
||||
final int id;
|
||||
final String name;
|
||||
final String description;
|
||||
final List<String> postIds;
|
||||
final int postCount;
|
||||
final String category; // 'series' or 'collection'
|
||||
final bool isActive;
|
||||
final String? creatorName;
|
||||
|
||||
String get cleanTitle => name.replaceAll('_', ' ');
|
||||
|
||||
int pageOf(String postId) {
|
||||
final idx = postIds.indexOf(postId);
|
||||
return idx >= 0 ? idx + 1 : 0;
|
||||
}
|
||||
|
||||
String? nextPostId(String postId) {
|
||||
final idx = postIds.indexOf(postId);
|
||||
if (idx >= 0 && idx < postIds.length - 1) {
|
||||
return postIds[idx + 1];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String? previousPostId(String postId) {
|
||||
final idx = postIds.indexOf(postId);
|
||||
if (idx > 0) {
|
||||
return postIds[idx - 1];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
factory E621Pool.fromJson(Map<String, dynamic> json) {
|
||||
final rawPostIds = (json['post_ids'] as List?) ?? const [];
|
||||
return E621Pool(
|
||||
id: (json['id'] as num?)?.toInt() ?? 0,
|
||||
name: (json['name'] ?? '').toString(),
|
||||
description: (json['description'] ?? '').toString(),
|
||||
postIds: rawPostIds.map((e) => e.toString()).toList(),
|
||||
postCount: (json['post_count'] as num?)?.toInt() ?? rawPostIds.length,
|
||||
category: (json['category'] ?? 'series').toString(),
|
||||
isActive: (json['is_active'] as bool?) ?? true,
|
||||
creatorName: json['creator_name']?.toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -60,11 +60,42 @@ class Post {
|
||||
return null;
|
||||
}
|
||||
|
||||
String? get description => commentary;
|
||||
|
||||
String? get parentId {
|
||||
final list = tagGroups['parent_id'];
|
||||
return (list != null && list.isNotEmpty && list.first.trim().isNotEmpty)
|
||||
? list.first.trim()
|
||||
: null;
|
||||
}
|
||||
|
||||
List<String> get childrenIds => tagGroups['children'] ?? const [];
|
||||
|
||||
List<String> get poolIds => tagGroups['pools'] ?? const [];
|
||||
|
||||
bool get hasRelations =>
|
||||
(parentId != null && parentId!.isNotEmpty) || childrenIds.isNotEmpty;
|
||||
|
||||
bool get hasPools => poolIds.isNotEmpty;
|
||||
|
||||
int? get favCount {
|
||||
final raw = tagGroups['fav_count']?.firstOrNull;
|
||||
return raw != null ? int.tryParse(raw) : null;
|
||||
}
|
||||
|
||||
String? get title {
|
||||
final list = tagGroups['copyright'];
|
||||
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 == 'cloud_mirror' || t == 'text_post') 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;
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:dio/dio.dart';
|
||||
|
||||
import '../../core/http/dio_client.dart';
|
||||
import '../mappers/e621_mapper.dart';
|
||||
import '../models/e621_pool.dart';
|
||||
import '../models/post.dart';
|
||||
import '../models/post_comment.dart';
|
||||
import '../models/provider_health.dart';
|
||||
@@ -33,6 +34,34 @@ class E621Provider
|
||||
final Dio _dio;
|
||||
final Map<String, String> _queryParameters;
|
||||
|
||||
static DateTime _lastRequestTime = DateTime.fromMillisecondsSinceEpoch(0);
|
||||
|
||||
static Future<void> _throttle() async {
|
||||
final now = DateTime.now();
|
||||
final diff = now.difference(_lastRequestTime);
|
||||
if (diff.inMilliseconds < 500) {
|
||||
final waitMs = 500 - diff.inMilliseconds;
|
||||
await Future<void>.delayed(Duration(milliseconds: waitMs));
|
||||
}
|
||||
_lastRequestTime = DateTime.now();
|
||||
}
|
||||
|
||||
bool get isAuthorized =>
|
||||
_queryParameters.containsKey('login') ||
|
||||
_queryParameters.containsKey('query.login') ||
|
||||
_dio.options.headers.containsKey('Authorization');
|
||||
|
||||
String? get login =>
|
||||
_queryParameters['login'] ?? _queryParameters['query.login'];
|
||||
|
||||
Future<List<Post>> getFavorites({int page = 1, int limit = 50}) async {
|
||||
final currentLogin = login?.trim();
|
||||
final targetTag = (currentLogin != null && currentLogin.isNotEmpty)
|
||||
? 'fav:$currentLogin'
|
||||
: 'fav:me';
|
||||
return searchPosts(tags: [targetTag], page: page - 1, limit: limit);
|
||||
}
|
||||
|
||||
@override
|
||||
String postPageUrl(Post post) => '$baseUrl/posts/${post.id}';
|
||||
|
||||
@@ -44,6 +73,7 @@ class E621Provider
|
||||
String? rating,
|
||||
TopPeriodFilter topPeriod = TopPeriodFilter.none,
|
||||
}) async {
|
||||
await _throttle();
|
||||
final response = await _dio.get<dynamic>(
|
||||
'/posts.json',
|
||||
queryParameters: {
|
||||
@@ -66,6 +96,7 @@ class E621Provider
|
||||
|
||||
@override
|
||||
Future<Post?> getPost(String id) async {
|
||||
await _throttle();
|
||||
final response = await _dio.get<dynamic>(
|
||||
'/posts/$id.json',
|
||||
queryParameters: _queryParameters,
|
||||
@@ -78,6 +109,109 @@ class E621Provider
|
||||
return posts.isEmpty ? null : posts.first;
|
||||
}
|
||||
|
||||
Future<E621Pool?> getPool(String poolId) async {
|
||||
await _throttle();
|
||||
try {
|
||||
final response = await _dio.get<dynamic>(
|
||||
'/pools/$poolId.json',
|
||||
queryParameters: _queryParameters,
|
||||
);
|
||||
if (response.data is Map) {
|
||||
return E621Pool.fromJson(Map<String, dynamic>.from(response.data as Map));
|
||||
}
|
||||
} catch (_) {}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<List<Post>> getPoolPosts(String poolId) async {
|
||||
final pool = await getPool(poolId);
|
||||
if (pool == null || pool.postIds.isEmpty) return const [];
|
||||
|
||||
final fetchedMap = <String, Post>{};
|
||||
int page = 1;
|
||||
while (true) {
|
||||
await _throttle();
|
||||
try {
|
||||
final response = await _dio.get<dynamic>(
|
||||
'/posts.json',
|
||||
queryParameters: {
|
||||
'page': page,
|
||||
'limit': isAuthorized ? 320 : 75,
|
||||
'tags': 'pool:$poolId',
|
||||
..._queryParameters,
|
||||
},
|
||||
);
|
||||
final items = E621Mapper.postsFromResponse(
|
||||
response.data,
|
||||
providerId: id,
|
||||
providerName: name,
|
||||
);
|
||||
if (items.isEmpty) break;
|
||||
for (final item in items) {
|
||||
fetchedMap[item.id] = item;
|
||||
}
|
||||
if (items.length < (isAuthorized ? 320 : 75) ||
|
||||
fetchedMap.length >= pool.postIds.length) {
|
||||
break;
|
||||
}
|
||||
page++;
|
||||
} catch (_) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
final ordered = <Post>[];
|
||||
for (final id in pool.postIds) {
|
||||
if (fetchedMap.containsKey(id)) {
|
||||
ordered.add(fetchedMap[id]!);
|
||||
}
|
||||
}
|
||||
return ordered.isNotEmpty ? ordered : fetchedMap.values.toList();
|
||||
}
|
||||
|
||||
Future<bool> votePost(String postId, int score) async {
|
||||
await _throttle();
|
||||
try {
|
||||
final response = await _dio.post<dynamic>(
|
||||
'/posts/$postId/votes.json',
|
||||
data: {'score': score, 'no_unvote': 'true'},
|
||||
queryParameters: _queryParameters,
|
||||
options: Options(contentType: Headers.formUrlEncodedContentType),
|
||||
);
|
||||
return response.statusCode == 200 || response.statusCode == 201;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> addFavorite(String postId) async {
|
||||
await _throttle();
|
||||
try {
|
||||
final response = await _dio.post<dynamic>(
|
||||
'/favorites.json',
|
||||
data: {'post_id': postId},
|
||||
queryParameters: _queryParameters,
|
||||
options: Options(contentType: Headers.formUrlEncodedContentType),
|
||||
);
|
||||
return response.statusCode == 200 || response.statusCode == 201;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> removeFavorite(String postId) async {
|
||||
await _throttle();
|
||||
try {
|
||||
final response = await _dio.delete<dynamic>(
|
||||
'/favorites/$postId.json',
|
||||
queryParameters: _queryParameters,
|
||||
);
|
||||
return response.statusCode == 200 || response.statusCode == 204;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ProviderHealth> checkHealth() async {
|
||||
final startedAt = DateTime.now();
|
||||
@@ -109,6 +243,7 @@ class E621Provider
|
||||
Future<List<TagSuggestion>> suggestTags(String query,
|
||||
{int limit = 20}) async {
|
||||
if (query.trim().isEmpty) return const [];
|
||||
await _throttle();
|
||||
final response = await _dio.get<dynamic>(
|
||||
'/tags.json',
|
||||
queryParameters: {
|
||||
@@ -136,6 +271,7 @@ class E621Provider
|
||||
|
||||
@override
|
||||
Future<List<PostComment>> getComments(String postId) async {
|
||||
await _throttle();
|
||||
final response = await _dio.get<dynamic>(
|
||||
'/comments.json',
|
||||
queryParameters: {
|
||||
|
||||
@@ -46,7 +46,7 @@ class GelbooruProvider
|
||||
Map<String, String> mediaHeaders(Post post) {
|
||||
final host = Uri.tryParse(baseUrl)?.host;
|
||||
return {
|
||||
'User-Agent': 'Lunaris/2.0.1 Flutter local booru browser',
|
||||
'User-Agent': 'Prisma/2.0.1 Flutter local booru browser',
|
||||
'Accept': '*/*',
|
||||
if (host != null && host.isNotEmpty) 'Referer': '$baseUrl/',
|
||||
};
|
||||
|
||||
@@ -1,599 +0,0 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../../core/http/dio_client.dart';
|
||||
import '../models/artist_announcement.dart';
|
||||
import '../models/artist_link.dart';
|
||||
import '../models/artist_profile.dart';
|
||||
import '../models/artist_tag.dart';
|
||||
import '../models/artist_work_query.dart';
|
||||
import '../models/post.dart';
|
||||
import '../models/post_comment.dart';
|
||||
import '../models/provider_health.dart';
|
||||
import '../models/top_period_filter.dart';
|
||||
import '../services/cloud_link_extractor.dart';
|
||||
import 'content_provider.dart';
|
||||
|
||||
class KemonoProvider
|
||||
implements ContentProvider, ArtistProvider, CommentProvider {
|
||||
KemonoProvider({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.baseUrl,
|
||||
required DioClient dioClient,
|
||||
Map<String, String> queryParameters = const {},
|
||||
}) : _dio = dioClient.dio,
|
||||
_creatorDio = Dio(
|
||||
BaseOptions(
|
||||
baseUrl: queryParameters['creator_api_base'] ??
|
||||
'https://kemono-api.mbaharip.com',
|
||||
connectTimeout: const Duration(seconds: 15),
|
||||
receiveTimeout: const Duration(seconds: 20),
|
||||
headers: const {
|
||||
'Accept': 'application/json',
|
||||
'User-Agent': 'Lunaris/2.0.1 Flutter local booru browser',
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
@override
|
||||
final String id;
|
||||
|
||||
@override
|
||||
final String name;
|
||||
|
||||
@override
|
||||
final String baseUrl;
|
||||
|
||||
final Dio _dio;
|
||||
final Dio _creatorDio;
|
||||
|
||||
bool get _isCoomer =>
|
||||
id.toLowerCase().contains('coomer') ||
|
||||
baseUrl.toLowerCase().contains('coomer');
|
||||
|
||||
@override
|
||||
Future<List<Post>> searchPosts({
|
||||
required List<String> tags,
|
||||
required int page,
|
||||
int limit = 50,
|
||||
String? rating,
|
||||
TopPeriodFilter topPeriod = TopPeriodFilter.none,
|
||||
}) async {
|
||||
return const [];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Post?> getPost(String id) async => null;
|
||||
|
||||
@override
|
||||
Future<ProviderHealth> checkHealth() async {
|
||||
final startedAt = DateTime.now();
|
||||
try {
|
||||
await _creatorDio.get<dynamic>(
|
||||
_creatorPath(),
|
||||
queryParameters: {'page': 1, 'itemsPerPage': 1},
|
||||
);
|
||||
return ProviderHealth(
|
||||
providerId: id,
|
||||
status: ProviderStatus.online,
|
||||
pingMs: DateTime.now().difference(startedAt).inMilliseconds,
|
||||
lastCheckedAt: DateTime.now(),
|
||||
apiVersion: _isCoomer ? 'coomer-artists' : 'kemono-artists',
|
||||
);
|
||||
} catch (error) {
|
||||
return ProviderHealth(
|
||||
providerId: id,
|
||||
status: ProviderStatus.offline,
|
||||
pingMs: DateTime.now().difference(startedAt).inMilliseconds,
|
||||
lastCheckedAt: DateTime.now(),
|
||||
errorMessage: error.toString(),
|
||||
apiVersion: _isCoomer ? 'coomer-artists' : 'kemono-artists',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<ArtistProfile>> listArtists({
|
||||
String? service,
|
||||
List<String>? services,
|
||||
String? query,
|
||||
int page = 1,
|
||||
int limit = 30,
|
||||
}) async {
|
||||
final effectiveService =
|
||||
services != null && services.length == 1 ? services.first : service;
|
||||
List<ArtistProfile> list;
|
||||
try {
|
||||
final response = await _creatorDio.get<dynamic>(
|
||||
effectiveService == null || effectiveService.isEmpty
|
||||
? _creatorPath()
|
||||
: '${_creatorPath()}/$effectiveService',
|
||||
queryParameters: {
|
||||
'page': page,
|
||||
'itemsPerPage': limit.clamp(1, 100),
|
||||
if (query != null && query.trim().isNotEmpty) 'keyword': query.trim(),
|
||||
},
|
||||
);
|
||||
list = _rankArtists(_artistsFromResponse(response.data), query ?? '');
|
||||
} catch (_) {
|
||||
list = await _listArtistsFromPublicApi(
|
||||
service: effectiveService,
|
||||
query: query,
|
||||
page: page,
|
||||
limit: limit,
|
||||
);
|
||||
}
|
||||
if (services != null && services.isNotEmpty) {
|
||||
final sSet = services.map((s) => s.toLowerCase()).toSet();
|
||||
list = list.where((a) => sSet.contains(a.service.toLowerCase())).toList();
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<ArtistProfile>> searchArtists(
|
||||
String query, {
|
||||
String? service,
|
||||
List<String>? services,
|
||||
int page = 1,
|
||||
int limit = 30,
|
||||
}) {
|
||||
return listArtists(
|
||||
service: service,
|
||||
services: services,
|
||||
query: query,
|
||||
page: page,
|
||||
limit: limit,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<Post>> getArtistPosts({
|
||||
required ArtistWorkQuery query,
|
||||
int page = 0,
|
||||
int limit = 50,
|
||||
}) async {
|
||||
Object? lastError;
|
||||
final paths = [
|
||||
'/api/v1/${query.service}/user/${query.artistId}/posts',
|
||||
'/api/v1/${query.service}/user/${query.artistId}',
|
||||
];
|
||||
for (final apiBase in _apiBaseCandidates()) {
|
||||
for (final path in paths) {
|
||||
try {
|
||||
final response = await _getFromApiBase<dynamic>(
|
||||
apiBase,
|
||||
path,
|
||||
queryParameters: {'o': page * limit},
|
||||
);
|
||||
final posts = _postItems(response.data);
|
||||
final mapped = <Post>[];
|
||||
for (final post in posts.whereType<Map>()) {
|
||||
mapped.addAll(_postsFromArtistPost(
|
||||
Map<String, dynamic>.from(post),
|
||||
query: query,
|
||||
mediaBaseUrl: apiBase,
|
||||
));
|
||||
}
|
||||
if (mapped.isNotEmpty || posts.isEmpty) {
|
||||
mapped.sort((a, b) => b.createdAt.compareTo(a.createdAt));
|
||||
return mapped.take(limit).toList(growable: false);
|
||||
}
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
}
|
||||
}
|
||||
throw StateError(
|
||||
'$name artist works are unavailable right now. Try again later. '
|
||||
'${_shortError(lastError)}',
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<PostComment>> getArtistPostComments(
|
||||
String service,
|
||||
String artistId,
|
||||
String postId,
|
||||
) async {
|
||||
Object? lastError;
|
||||
final path = '/api/v1/$service/user/$artistId/post/$postId/comments';
|
||||
for (final apiBase in _apiBaseCandidates()) {
|
||||
try {
|
||||
final response = await _getFromApiBase<dynamic>(apiBase, path);
|
||||
return _commentsFromResponse(response.data, postId);
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
}
|
||||
throw StateError(
|
||||
'$name comments are unavailable right now. ${_shortError(lastError)}',
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<PostComment>> getComments(String postId) async {
|
||||
final parts = postId.split(':');
|
||||
if (parts.length < 4) return const [];
|
||||
return getArtistPostComments(parts[0], parts[1], parts[2]);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<ArtistTag>> getArtistTags(
|
||||
String service, String artistId) async =>
|
||||
const [];
|
||||
|
||||
@override
|
||||
Future<List<ArtistLink>> getArtistLinks(
|
||||
String service, String artistId) async =>
|
||||
const [];
|
||||
|
||||
@override
|
||||
Future<List<ArtistAnnouncement>> getArtistAnnouncements(
|
||||
String service, String artistId) async =>
|
||||
const [];
|
||||
|
||||
String _creatorPath() => _isCoomer ? '/coomer' : '/kemono';
|
||||
|
||||
List<ArtistProfile> _artistsFromResponse(dynamic data) {
|
||||
final items = data is Map ? data['data'] : data;
|
||||
if (items is! List) return const [];
|
||||
return items
|
||||
.whereType<Map>()
|
||||
.map((item) {
|
||||
final json = Map<String, dynamic>.from(item);
|
||||
final service = (json['service'] ?? '').toString();
|
||||
final artistId =
|
||||
(json['id'] ?? json['creator_id'] ?? json['user_id'] ?? '')
|
||||
.toString();
|
||||
final name = (json['name'] ??
|
||||
json['display_name'] ??
|
||||
json['username'] ??
|
||||
artistId)
|
||||
.toString();
|
||||
final rawAvatar = (json['avatar'] ??
|
||||
json['icon'] ??
|
||||
json['image'] ??
|
||||
json['avatar_url'] ??
|
||||
'')
|
||||
.toString();
|
||||
return ArtistProfile(
|
||||
id: artistId,
|
||||
providerId: id,
|
||||
service: service,
|
||||
name: name,
|
||||
displayName: name,
|
||||
avatarUrl: _absoluteUrl(rawAvatar) ?? _iconUrl(service, artistId),
|
||||
updatedAt: _dateFromAny(json['updated']),
|
||||
postCount: (json['post_count'] as num?)?.toInt(),
|
||||
url: '$baseUrl/$service/user/$artistId',
|
||||
);
|
||||
})
|
||||
.where((artist) => artist.id.isNotEmpty)
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
Future<List<ArtistProfile>> _listArtistsFromPublicApi({
|
||||
String? service,
|
||||
String? query,
|
||||
required int page,
|
||||
required int limit,
|
||||
}) async {
|
||||
Object? lastError;
|
||||
dynamic data;
|
||||
for (final apiBase in _apiBaseCandidates()) {
|
||||
try {
|
||||
final response =
|
||||
await _getFromApiBase<dynamic>(apiBase, '/api/v1/creators.txt');
|
||||
data = response.data;
|
||||
break;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
}
|
||||
if (data == null) {
|
||||
throw StateError(
|
||||
'$name artist index is unavailable right now. ${_shortError(lastError)}',
|
||||
);
|
||||
}
|
||||
final queryText = (query ?? '').trim().toLowerCase();
|
||||
final offset = (page - 1).clamp(0, 999999) * limit;
|
||||
final items = _artistsFromResponse({'data': data})
|
||||
.where((artist) =>
|
||||
(service == null || service.isEmpty || artist.service == service) &&
|
||||
(queryText.isEmpty ||
|
||||
artist.displayName.toLowerCase().contains(queryText) ||
|
||||
artist.id.toLowerCase().contains(queryText)))
|
||||
.toList(growable: false);
|
||||
final ranked = _rankArtists(items, queryText);
|
||||
return ranked.skip(offset).take(limit).toList(growable: false);
|
||||
}
|
||||
|
||||
List<ArtistProfile> _rankArtists(List<ArtistProfile> artists, String query) {
|
||||
final queryText = query.trim().toLowerCase();
|
||||
final ranked = [...artists];
|
||||
ranked.sort((a, b) {
|
||||
final scoreA = _artistSearchScore(a, queryText);
|
||||
final scoreB = _artistSearchScore(b, queryText);
|
||||
if (scoreA != scoreB) return scoreB.compareTo(scoreA);
|
||||
final updatedA = a.updatedAt ?? DateTime.fromMillisecondsSinceEpoch(0);
|
||||
final updatedB = b.updatedAt ?? DateTime.fromMillisecondsSinceEpoch(0);
|
||||
final updated = updatedB.compareTo(updatedA);
|
||||
if (updated != 0) return updated;
|
||||
final count = (b.postCount ?? 0).compareTo(a.postCount ?? 0);
|
||||
if (count != 0) return count;
|
||||
return a.displayName.compareTo(b.displayName);
|
||||
});
|
||||
return ranked;
|
||||
}
|
||||
|
||||
int _artistSearchScore(ArtistProfile artist, String query) {
|
||||
if (query.isEmpty) return 0;
|
||||
final name = artist.displayName.toLowerCase();
|
||||
final id = artist.id.toLowerCase();
|
||||
if (name == query || id == query) return 100;
|
||||
if (name.startsWith(query) || id.startsWith(query)) return 70;
|
||||
if (name.contains(query) || id.contains(query)) return 40;
|
||||
return 0;
|
||||
}
|
||||
|
||||
List<dynamic> _postItems(dynamic data) {
|
||||
if (data is List) return data;
|
||||
if (data is Map) {
|
||||
final results = data['results'];
|
||||
if (results is List) return results;
|
||||
final posts = data['posts'];
|
||||
if (posts is List) return posts;
|
||||
if (posts is Map) return _postItems(posts);
|
||||
final dataItems = data['data'];
|
||||
if (dataItems is List) return dataItems;
|
||||
}
|
||||
return const [];
|
||||
}
|
||||
|
||||
List<Post> _postsFromArtistPost(
|
||||
Map<String, dynamic> json, {
|
||||
required ArtistWorkQuery query,
|
||||
required String mediaBaseUrl,
|
||||
}) {
|
||||
final postId = (json['id'] ?? json['post_id'] ?? '').toString();
|
||||
final title = (json['title'] ?? '').toString();
|
||||
final published = _dateFromAny(json['published']) ??
|
||||
_dateFromAny(json['added']) ??
|
||||
_dateFromAny(json['edited']) ??
|
||||
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 file = json['file'];
|
||||
if (file is Map && file.isNotEmpty) {
|
||||
files.add(Map<String, dynamic>.from(file));
|
||||
}
|
||||
final attachments = json['attachments'];
|
||||
if (attachments is List) {
|
||||
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;
|
||||
return files
|
||||
.map((file) {
|
||||
final url = _mediaUrl(file, mediaBaseUrl: mediaBaseUrl);
|
||||
final fileName =
|
||||
(file['name'] ?? Uri.tryParse(url)?.pathSegments.last ?? '')
|
||||
.toString();
|
||||
final type = _fileType('$fileName $url');
|
||||
final stableId =
|
||||
'${query.service}:${query.artistId}:$postId:${index++}';
|
||||
final tags = [
|
||||
query.service,
|
||||
query.artistName,
|
||||
if (title.trim().isNotEmpty) title.trim(),
|
||||
];
|
||||
return Post(
|
||||
id: stableId,
|
||||
providerId: id,
|
||||
providerName: name,
|
||||
previewUrl: url,
|
||||
sampleUrl: url,
|
||||
fileUrl: url,
|
||||
tags: tags,
|
||||
rating: 'unknown',
|
||||
width: 0,
|
||||
height: 0,
|
||||
source: postSource,
|
||||
createdAt: published,
|
||||
fileType: type,
|
||||
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],
|
||||
},
|
||||
);
|
||||
})
|
||||
.where((post) => post.fileUrl.isNotEmpty)
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
List<PostComment> _commentsFromResponse(dynamic data, String postId) {
|
||||
final items = data is Map ? (data['comments'] ?? data['data']) : data;
|
||||
if (items is! List) return const [];
|
||||
return items
|
||||
.whereType<Map>()
|
||||
.map((item) {
|
||||
final json = Map<String, dynamic>.from(item);
|
||||
return PostComment(
|
||||
id: (json['id'] ?? json['comment_id'] ?? '').toString(),
|
||||
postId: postId,
|
||||
providerId: id,
|
||||
authorName: (json['author'] ??
|
||||
json['username'] ??
|
||||
json['user'] ??
|
||||
json['creator_name'] ??
|
||||
'user')
|
||||
.toString(),
|
||||
body: (json['content'] ?? json['body'] ?? json['comment'] ?? '')
|
||||
.toString(),
|
||||
createdAt: _dateFromAny(json['added']) ??
|
||||
_dateFromAny(json['created_at']) ??
|
||||
DateTime.fromMillisecondsSinceEpoch(0),
|
||||
);
|
||||
})
|
||||
.where((comment) => comment.body.trim().isNotEmpty)
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
String _mediaUrl(
|
||||
Map<String, dynamic> file, {
|
||||
required String mediaBaseUrl,
|
||||
}) {
|
||||
final path = (file['path'] ?? file['url'] ?? '').toString();
|
||||
if (path.isEmpty) return '';
|
||||
final uri = Uri.tryParse(path);
|
||||
if (uri != null && uri.hasScheme) return path;
|
||||
if (path.startsWith('/data/')) {
|
||||
return '$mediaBaseUrl$path';
|
||||
}
|
||||
if (path.startsWith('/')) {
|
||||
return '$mediaBaseUrl/data$path';
|
||||
}
|
||||
return '$mediaBaseUrl/data/$path';
|
||||
}
|
||||
|
||||
String? _iconUrl(String service, String artistId) {
|
||||
if (service.isEmpty || artistId.isEmpty) return null;
|
||||
final host = _isCoomer ? 'https://img.coomer.su' : 'https://img.kemono.su';
|
||||
return '$host/icons/$service/$artistId';
|
||||
}
|
||||
|
||||
String? _absoluteUrl(String value) {
|
||||
final trimmed = value.trim();
|
||||
if (trimmed.isEmpty) return null;
|
||||
final uri = Uri.tryParse(trimmed);
|
||||
if (uri != null && uri.hasScheme) return trimmed;
|
||||
if (trimmed.startsWith('/')) return '$baseUrl$trimmed';
|
||||
return '$baseUrl/$trimmed';
|
||||
}
|
||||
|
||||
List<String> _apiBaseCandidates() {
|
||||
final candidates = <String>[
|
||||
baseUrl,
|
||||
if (_isCoomer) ...[
|
||||
'https://coomer.st',
|
||||
'https://coomer.su',
|
||||
] else ...[
|
||||
'https://kemono.cr',
|
||||
'https://kemono.su',
|
||||
],
|
||||
];
|
||||
return candidates.map(_trimTrailingSlash).toSet().toList(growable: false);
|
||||
}
|
||||
|
||||
Future<Response<T>> _getFromApiBase<T>(
|
||||
String apiBase,
|
||||
String path, {
|
||||
Map<String, Object?> queryParameters = const {},
|
||||
}) {
|
||||
if (_trimTrailingSlash(apiBase) == _trimTrailingSlash(baseUrl)) {
|
||||
return _dio.get<T>(path, queryParameters: queryParameters);
|
||||
}
|
||||
final dio = Dio(
|
||||
BaseOptions(
|
||||
baseUrl: apiBase,
|
||||
connectTimeout: const Duration(seconds: 15),
|
||||
receiveTimeout: const Duration(seconds: 25),
|
||||
headers: const {
|
||||
'Accept': 'application/json',
|
||||
'User-Agent': 'Lunaris/2.0.1 Flutter local booru browser',
|
||||
},
|
||||
),
|
||||
);
|
||||
return dio.get<T>(path, queryParameters: queryParameters);
|
||||
}
|
||||
|
||||
String _trimTrailingSlash(String value) =>
|
||||
value.endsWith('/') ? value.substring(0, value.length - 1) : value;
|
||||
|
||||
String _shortError(Object? error) {
|
||||
if (error == null) return '';
|
||||
final message = error.toString();
|
||||
if (message.length <= 160) return message;
|
||||
return '${message.substring(0, 160)}...';
|
||||
}
|
||||
|
||||
String _fileType(String value) {
|
||||
final lower = value.toLowerCase().split('?').first;
|
||||
if (lower.endsWith('.webm') || lower.endsWith('.mp4')) return 'video';
|
||||
if (lower.endsWith('.gif')) return 'gif';
|
||||
if (lower.endsWith('.swf')) return 'swf';
|
||||
if (lower.endsWith('.png')) return 'png';
|
||||
if (lower.endsWith('.jpg') ||
|
||||
lower.endsWith('.jpeg') ||
|
||||
lower.endsWith('.webp')) {
|
||||
return 'photo';
|
||||
}
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
DateTime? _dateFromAny(dynamic value) {
|
||||
if (value == null) return null;
|
||||
if (value is num) {
|
||||
final raw = value.toInt();
|
||||
if (raw > 9999999999) return DateTime.fromMillisecondsSinceEpoch(raw);
|
||||
return DateTime.fromMillisecondsSinceEpoch(raw * 1000);
|
||||
}
|
||||
return DateTime.tryParse(value.toString());
|
||||
}
|
||||
}
|
||||
@@ -414,21 +414,35 @@ class PawchiveProvider
|
||||
}
|
||||
final attachments = json['attachments'];
|
||||
if (attachments is List) {
|
||||
files.addAll(attachments.whereType<Map>().map(Map<String, dynamic>.from));
|
||||
for (final att in attachments.whereType<Map>()) {
|
||||
final m = Map<String, dynamic>.from(att);
|
||||
final path = (m['path'] ?? '').toString();
|
||||
if (path.isNotEmpty &&
|
||||
files.any((existing) => (existing['path'] ?? '').toString() == path)) {
|
||||
continue;
|
||||
}
|
||||
files.add(m);
|
||||
}
|
||||
}
|
||||
|
||||
// Support link-only posts where author uploaded files to cloud drives (MEGA, GDrive, etc.)
|
||||
if (files.isEmpty && (cloudLinks.isNotEmpty || cleanDesc.isNotEmpty)) {
|
||||
// or text-only posts where author wrote an announcement, status or note
|
||||
if (files.isEmpty &&
|
||||
(cloudLinks.isNotEmpty ||
|
||||
cleanDesc.isNotEmpty ||
|
||||
rawContent.trim().isNotEmpty ||
|
||||
title.trim().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 isTextOnly = cloudLinks.isEmpty;
|
||||
final tags = [
|
||||
query.service,
|
||||
query.artistName,
|
||||
if (title.trim().isNotEmpty) title.trim(),
|
||||
'cloud_mirror',
|
||||
if (!isTextOnly) 'cloud_mirror' else 'text_post',
|
||||
];
|
||||
return [
|
||||
Post(
|
||||
@@ -444,19 +458,35 @@ class PawchiveProvider
|
||||
height: 0,
|
||||
source: postSource,
|
||||
createdAt: published,
|
||||
fileType: firstStreamable != null ? 'video' : 'link',
|
||||
fileType: firstStreamable != null
|
||||
? 'video'
|
||||
: (isTextOnly ? 'text' : '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],
|
||||
if (cleanDesc.isNotEmpty)
|
||||
'description': [cleanDesc]
|
||||
else if (rawContent.trim().isNotEmpty)
|
||||
'description': [rawContent.trim()],
|
||||
},
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
// Determine if there is a cover illustration for the post
|
||||
String? postCoverUrl;
|
||||
for (final f in files) {
|
||||
final p = (f['path'] ?? '').toString();
|
||||
final n = (f['name'] ?? '').toString();
|
||||
if (_isPhotoType(p, n) && p.isNotEmpty) {
|
||||
postCoverUrl = _thumbnailUrl(p);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
var index = 0;
|
||||
return files
|
||||
.map((fileMap) {
|
||||
@@ -464,18 +494,26 @@ class PawchiveProvider
|
||||
final fileName = (fileMap['name'] ?? '').toString();
|
||||
final previewOnly = fileMap['preview_only'] == true;
|
||||
|
||||
final previewUrl = _thumbnailUrl(rawPath);
|
||||
final type = _fileTypeFor(rawPath, fileName);
|
||||
final isPhoto = type == 'photo' || type == 'gif';
|
||||
|
||||
// img.pawchive.pw/thumbnail only generates thumbnails for images/gifs.
|
||||
// Non-image files (audio, video, archive) return 404 from thumbnail server.
|
||||
final previewUrl = isPhoto
|
||||
? _thumbnailUrl(rawPath)
|
||||
: (postCoverUrl ?? '');
|
||||
|
||||
final fileUrl = previewOnly
|
||||
? previewUrl
|
||||
? (isPhoto ? previewUrl : _fileDownloadUrl(rawPath, fileName))
|
||||
: _fileDownloadUrl(rawPath, fileName);
|
||||
|
||||
final type = _fileType('$fileName $rawPath');
|
||||
final stableId =
|
||||
'${query.service}:${query.artistId}:$postId:${index++}';
|
||||
final tags = [
|
||||
query.service,
|
||||
query.artistName,
|
||||
if (title.trim().isNotEmpty) title.trim(),
|
||||
if (type == 'audio') 'audio',
|
||||
];
|
||||
|
||||
return Post(
|
||||
@@ -588,25 +626,84 @@ class PawchiveProvider
|
||||
return '${message.substring(0, 160)}...';
|
||||
}
|
||||
|
||||
String _extractExtension(String value) {
|
||||
if (value.isEmpty) return '';
|
||||
final clean = value.split('?').first.split('#').first.trim();
|
||||
final dot = clean.lastIndexOf('.');
|
||||
if (dot != -1 && dot < clean.length - 1) {
|
||||
final ext = clean.substring(dot).toLowerCase();
|
||||
if (ext.length <= 6) return ext;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
String _fileType(String value) {
|
||||
final lower = value.toLowerCase().split('?').first;
|
||||
if (lower.endsWith('.webm') || lower.endsWith('.mp4') || lower.endsWith('.mov')) {
|
||||
final ext = _extractExtension(value);
|
||||
if (const {
|
||||
'.webm',
|
||||
'.mp4',
|
||||
'.mov',
|
||||
'.mkv',
|
||||
'.avi',
|
||||
'.flv',
|
||||
'.wmv',
|
||||
'.m4v',
|
||||
'.ts',
|
||||
}.contains(ext)) {
|
||||
return 'video';
|
||||
}
|
||||
if (lower.endsWith('.gif')) return 'gif';
|
||||
if (lower.endsWith('.swf')) return 'swf';
|
||||
if (lower.endsWith('.zip') || lower.endsWith('.rar') || lower.endsWith('.7z')) {
|
||||
if (const {
|
||||
'.mp3',
|
||||
'.m4a',
|
||||
'.wav',
|
||||
'.ogg',
|
||||
'.flac',
|
||||
'.aac',
|
||||
'.opus',
|
||||
'.wma',
|
||||
}.contains(ext)) {
|
||||
return 'audio';
|
||||
}
|
||||
if (ext == '.gif') return 'gif';
|
||||
if (ext == '.swf') return 'swf';
|
||||
if (const {
|
||||
'.zip',
|
||||
'.rar',
|
||||
'.7z',
|
||||
'.tar',
|
||||
'.gz',
|
||||
'.pdf',
|
||||
'.txt',
|
||||
}.contains(ext)) {
|
||||
return 'archive';
|
||||
}
|
||||
if (lower.endsWith('.png') ||
|
||||
lower.endsWith('.jpg') ||
|
||||
lower.endsWith('.jpeg') ||
|
||||
lower.endsWith('.webp')) {
|
||||
if (const {
|
||||
'.png',
|
||||
'.jpg',
|
||||
'.jpeg',
|
||||
'.webp',
|
||||
'.avif',
|
||||
'.bmp',
|
||||
'.heic',
|
||||
'.tiff',
|
||||
}.contains(ext)) {
|
||||
return 'photo';
|
||||
}
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
String _fileTypeFor(String path, String fileName) {
|
||||
final extName = _extractExtension(fileName);
|
||||
final extPath = _extractExtension(path);
|
||||
final ext = extName.isNotEmpty ? extName : extPath;
|
||||
return _fileType(ext);
|
||||
}
|
||||
|
||||
bool _isPhotoType(String path, [String fileName = '']) {
|
||||
final type = _fileTypeFor(path, fileName);
|
||||
return type == 'photo' || type == 'gif';
|
||||
}
|
||||
|
||||
DateTime? _dateFromAny(dynamic value) {
|
||||
if (value == null) return null;
|
||||
if (value is num) {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import '../../core/http/dio_client.dart';
|
||||
import '../models/content_provider_config.dart';
|
||||
import 'content_provider.dart';
|
||||
@@ -5,7 +7,6 @@ import 'custom_provider.dart';
|
||||
import 'danbooru_provider.dart';
|
||||
import 'e621_provider.dart';
|
||||
import 'gelbooru_provider.dart';
|
||||
import 'kemono_provider.dart';
|
||||
import 'moebooru_provider.dart';
|
||||
import 'pawchive_provider.dart';
|
||||
import 'realbooru_html_provider.dart';
|
||||
@@ -21,6 +22,17 @@ class ProviderFactory {
|
||||
if (entry.key.startsWith('query.') && entry.value.trim().isNotEmpty)
|
||||
entry.key.substring('query.'.length): entry.value.trim(),
|
||||
};
|
||||
|
||||
if (config.apiType.toLowerCase() == 'e621') {
|
||||
final login = queryParameters['login'] ?? headers['login'];
|
||||
final apiKey = queryParameters['api_key'] ?? headers['api_key'];
|
||||
if (login != null && apiKey != null && login.isNotEmpty && apiKey.isNotEmpty) {
|
||||
final basicAuth = base64Encode(utf8.encode('$login:$apiKey'));
|
||||
headers['Authorization'] = 'Basic $basicAuth';
|
||||
headers['User-Agent'] = 'Prisma/3.6.6 (by $login on e621)';
|
||||
}
|
||||
}
|
||||
|
||||
final client = DioClient(
|
||||
baseUrl: config.baseUrl,
|
||||
timeout: Duration(seconds: config.timeoutSeconds),
|
||||
@@ -82,15 +94,6 @@ class ProviderFactory {
|
||||
dioClient: client,
|
||||
queryParameters: queryParameters,
|
||||
);
|
||||
case 'kemono':
|
||||
case 'coomer':
|
||||
return KemonoProvider(
|
||||
id: config.id,
|
||||
name: config.name,
|
||||
baseUrl: config.baseUrl,
|
||||
dioClient: client,
|
||||
queryParameters: queryParameters,
|
||||
);
|
||||
case 'pawchive':
|
||||
return PawchiveProvider(
|
||||
id: config.id,
|
||||
|
||||
@@ -144,6 +144,14 @@ class ProviderManager {
|
||||
});
|
||||
}
|
||||
|
||||
Future<ContentProvider?> getProviderInstance(String id) async {
|
||||
final result = await _repository.getProvider(id);
|
||||
if (result is Success<ContentProviderConfig?> && result.data != null) {
|
||||
return _factory.create(result.data!);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<Result<List<ProviderHealth>>> checkAll({int concurrency = 3}) async {
|
||||
final providersResult = await activeProviders();
|
||||
if (providersResult is Error<List<ContentProvider>>) {
|
||||
@@ -569,7 +577,7 @@ class ProviderManager {
|
||||
);
|
||||
}
|
||||
return const Success({
|
||||
'User-Agent': 'Lunaris/2.0.1 Flutter local booru browser',
|
||||
'User-Agent': 'Prisma/2.0.1 Flutter local booru browser',
|
||||
'Accept': '*/*',
|
||||
});
|
||||
}
|
||||
@@ -730,18 +738,23 @@ class ProviderManager {
|
||||
|
||||
static bool _isArtistConfig(ContentProviderConfig config) {
|
||||
final type = config.apiType.toLowerCase();
|
||||
return type == 'kemono' || type == 'coomer' || type == 'pawchive';
|
||||
return type == 'pawchive';
|
||||
}
|
||||
|
||||
static bool _isFeedConfig(ContentProviderConfig config) {
|
||||
final type = config.apiType.toLowerCase();
|
||||
return type != 'kemono' && type != 'coomer';
|
||||
return type != 'pawchive';
|
||||
}
|
||||
|
||||
static bool _isLegacyRemovedConfig(ContentProviderConfig config) {
|
||||
final id = config.id.toLowerCase();
|
||||
final type = config.apiType.toLowerCase();
|
||||
return id == 'cosbooru' || type == 'realbooru';
|
||||
return id == 'cosbooru' ||
|
||||
type == 'realbooru' ||
|
||||
type == 'kemono' ||
|
||||
type == 'coomer' ||
|
||||
id == 'kemono' ||
|
||||
id == 'coomer';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ class Rule34Provider extends GelbooruProvider {
|
||||
|
||||
@override
|
||||
Map<String, String> mediaHeaders(Post post) => const {
|
||||
'User-Agent': 'Lunaris/2.0.1 Flutter local booru browser',
|
||||
'User-Agent': 'Prisma/2.0.1 Flutter local booru browser',
|
||||
'Accept': '*/*',
|
||||
'Referer': 'https://rule34.xxx/',
|
||||
};
|
||||
|
||||
@@ -60,7 +60,7 @@ class ProviderRepository {
|
||||
priority: 3,
|
||||
timeoutSeconds: 20,
|
||||
customHeaders: const {
|
||||
'User-Agent': 'Lunaris/2.0.2 Flutter local booru browser',
|
||||
'User-Agent': 'Prisma/2.0.2 Flutter local booru browser',
|
||||
},
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
@@ -74,7 +74,7 @@ class ProviderRepository {
|
||||
priority: 4,
|
||||
timeoutSeconds: 20,
|
||||
customHeaders: const {
|
||||
'User-Agent': 'Lunaris/2.0.2 Flutter local booru browser',
|
||||
'User-Agent': 'Prisma/2.0.2 Flutter local booru browser',
|
||||
},
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
|
||||
@@ -52,13 +52,13 @@ class BackupService {
|
||||
Future<bool> exportBackup() async {
|
||||
final json = await createBackupJson();
|
||||
final fileName =
|
||||
'lunaris_backup_${DateTime.now().millisecondsSinceEpoch}.json';
|
||||
'prisma_backup_${DateTime.now().millisecondsSinceEpoch}.json';
|
||||
|
||||
if (Platform.isAndroid) {
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final file = File('${tempDir.path}/$fileName');
|
||||
await file.writeAsString(json);
|
||||
await Share.shareXFiles([XFile(file.path)], text: 'Lunaris Backup');
|
||||
await Share.shareXFiles([XFile(file.path)], text: 'Prisma Backup');
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -224,7 +224,7 @@ class DownloadService {
|
||||
return {
|
||||
'User-Agent': lower.contains('realbooru') || lower.contains('paheal')
|
||||
? 'Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36 Chrome/125 Mobile Safari/537.36'
|
||||
: 'Lunaris/2.0.1 Flutter local booru browser',
|
||||
: 'Prisma/2.0.1 Flutter local booru browser',
|
||||
'Accept': lower.contains('realbooru') || lower.contains('paheal')
|
||||
? 'video/webm,video/mp4,image/avif,image/webp,image/apng,image/*,*/*;q=0.8'
|
||||
: '*/*',
|
||||
|
||||
@@ -94,7 +94,7 @@ class PawchiveSyncService {
|
||||
followRedirects: false,
|
||||
validateStatus: (status) => status != null && status < 500,
|
||||
headers: const {
|
||||
'User-Agent': 'Lunaris/2.0.1 Flutter local booru browser',
|
||||
'User-Agent': 'Prisma/2.0.1 Flutter local booru browser',
|
||||
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
||||
},
|
||||
),
|
||||
@@ -231,7 +231,7 @@ class PawchiveSyncService {
|
||||
headers: {
|
||||
'Cookie': 'session=$cleanCookie',
|
||||
'Accept': 'application/json',
|
||||
'User-Agent': 'Lunaris/2.0.1 Flutter local booru browser',
|
||||
'User-Agent': 'Prisma/2.0.1 Flutter local booru browser',
|
||||
},
|
||||
validateStatus: (status) => status != null && status < 500,
|
||||
),
|
||||
@@ -509,7 +509,7 @@ class PawchiveSyncService {
|
||||
final options = Options(
|
||||
headers: {
|
||||
'Cookie': 'session=$cleanCookie',
|
||||
'User-Agent': 'Lunaris/2.0.1 Flutter local booru browser',
|
||||
'User-Agent': 'Prisma/2.0.1 Flutter local booru browser',
|
||||
},
|
||||
validateStatus: (status) =>
|
||||
status != null && (status == 200 || status == 201 || status == 204),
|
||||
|
||||
@@ -64,8 +64,10 @@ class AppSettings {
|
||||
this.downloadPathTemplate = '{Artist}/{ID}',
|
||||
this.searchHistoryLimit = 500,
|
||||
this.tagCacheLimit = 5000,
|
||||
this.lastActiveLocation = '/',
|
||||
});
|
||||
|
||||
final String lastActiveLocation;
|
||||
final List<String> favoriteArtists;
|
||||
final List<String> pawchiveAccounts;
|
||||
final bool pawchiveBidirectionalSync;
|
||||
@@ -165,6 +167,7 @@ class AppSettings {
|
||||
lastFeedRating: null,
|
||||
searchHistoryLimit: 500,
|
||||
tagCacheLimit: 5000,
|
||||
lastActiveLocation: '/',
|
||||
);
|
||||
|
||||
AppSettings copyWith({
|
||||
@@ -220,8 +223,10 @@ class AppSettings {
|
||||
String? downloadPathTemplate,
|
||||
int? searchHistoryLimit,
|
||||
int? tagCacheLimit,
|
||||
String? lastActiveLocation,
|
||||
}) {
|
||||
return AppSettings(
|
||||
lastActiveLocation: lastActiveLocation ?? this.lastActiveLocation,
|
||||
favoriteArtists: favoriteArtists ?? this.favoriteArtists,
|
||||
pawchiveAccounts: pawchiveAccounts ?? this.pawchiveAccounts,
|
||||
pawchiveBidirectionalSync:
|
||||
@@ -336,6 +341,7 @@ class AppSettings {
|
||||
'downloadPathTemplate': downloadPathTemplate,
|
||||
'searchHistoryLimit': searchHistoryLimit,
|
||||
'tagCacheLimit': tagCacheLimit,
|
||||
'lastActiveLocation': lastActiveLocation,
|
||||
};
|
||||
|
||||
factory AppSettings.fromJson(Map<String, dynamic> json) => AppSettings(
|
||||
@@ -455,6 +461,8 @@ class AppSettings {
|
||||
defaults.searchHistoryLimit,
|
||||
tagCacheLimit: (json['tagCacheLimit'] as num?)?.toInt() ??
|
||||
defaults.tagCacheLimit,
|
||||
lastActiveLocation: (json['lastActiveLocation'] as String?) ??
|
||||
defaults.lastActiveLocation,
|
||||
);
|
||||
|
||||
List<PawchiveAccount> get parsedPawchiveAccounts {
|
||||
@@ -545,6 +553,14 @@ class SettingsService {
|
||||
return updateSettings(settings.copyWith(nsfwEnabled: enabled));
|
||||
}
|
||||
|
||||
Future<Result<void>> saveLastActiveLocation(String location) async {
|
||||
final result = await getSettings();
|
||||
if (result is Error<AppSettings>) return Error(result.failure);
|
||||
final settings = (result as Success<AppSettings>).data;
|
||||
if (settings.lastActiveLocation == location) return const Success(null);
|
||||
return updateSettings(settings.copyWith(lastActiveLocation: location));
|
||||
}
|
||||
|
||||
Future<Result<void>> saveCacheSettings({
|
||||
required int ttlHours,
|
||||
required int maxItems,
|
||||
@@ -674,7 +690,7 @@ class SettingsService {
|
||||
return Success(
|
||||
const JsonEncoder.withIndent(' ').convert({
|
||||
'app': {
|
||||
'name': 'Lunaris',
|
||||
'name': 'Prisma',
|
||||
'version': appVersion,
|
||||
'build': buildNumber,
|
||||
'generatedAt': DateTime.now().toIso8601String(),
|
||||
|
||||
@@ -25,7 +25,14 @@ class TagCacheService {
|
||||
return _resolvedPath!;
|
||||
}
|
||||
final dir = await getApplicationDocumentsDirectory();
|
||||
_resolvedPath = '${dir.path}/lunaris_tag_cache.json';
|
||||
final newFile = File('${dir.path}/prisma_tag_cache.json');
|
||||
final oldFile = File('${dir.path}/lunaris_tag_cache.json');
|
||||
if (!newFile.existsSync() && oldFile.existsSync()) {
|
||||
try {
|
||||
oldFile.copySync(newFile.path);
|
||||
} catch (_) {}
|
||||
}
|
||||
_resolvedPath = newFile.path;
|
||||
return _resolvedPath!;
|
||||
}
|
||||
|
||||
|
||||
@@ -18,14 +18,14 @@ class UpdateService {
|
||||
UpdateService(this._dio, this._settingsService);
|
||||
|
||||
static const giteaLatestReleaseUrl =
|
||||
'https://gitea.rardogsynapse.online/api/v1/repos/RarDog/Lunaris/releases/latest';
|
||||
'https://gitea.rardogsynapse.online/api/v1/repos/RarDog/Prisma/releases/latest';
|
||||
static const giteaReleasesUrl =
|
||||
'https://gitea.rardogsynapse.online/api/v1/repos/RarDog/Lunaris/releases';
|
||||
'https://gitea.rardogsynapse.online/api/v1/repos/RarDog/Prisma/releases';
|
||||
|
||||
static const githubLatestReleaseUrl =
|
||||
'https://api.github.com/repos/RarDog/Lunaris/releases/latest';
|
||||
'https://api.github.com/repos/RarDog/Prisma/releases/latest';
|
||||
static const githubReleasesUrl =
|
||||
'https://api.github.com/repos/RarDog/Lunaris/releases';
|
||||
'https://api.github.com/repos/RarDog/Prisma/releases';
|
||||
|
||||
static const latestReleaseUrl = githubLatestReleaseUrl;
|
||||
static const releasesUrl = githubReleasesUrl;
|
||||
@@ -111,7 +111,7 @@ class UpdateService {
|
||||
'Authorization': 'token 1c744d6044d756759d7b1f693c94c80cf70d75fa',
|
||||
}
|
||||
: {
|
||||
'User-Agent': 'Lunaris-App',
|
||||
'User-Agent': 'Prisma-App',
|
||||
'Accept': 'application/vnd.github+json',
|
||||
},
|
||||
);
|
||||
@@ -239,10 +239,10 @@ class UpdateService {
|
||||
final fromUrl =
|
||||
parsed?.pathSegments.isEmpty ?? true ? '' : parsed!.pathSegments.last;
|
||||
if (fromUrl.contains('.')) return fromUrl;
|
||||
if (Platform.isAndroid) return 'Lunaris-v${info.version}.apk';
|
||||
if (Platform.isLinux) return 'Lunaris-v${info.version}-linux-x64.tar.gz';
|
||||
if (Platform.isWindows) return 'LunarisSetup-v${info.version}.exe';
|
||||
return 'LunarisPortable-v${info.version}.zip';
|
||||
if (Platform.isAndroid) return 'Prisma-v${info.version}.apk';
|
||||
if (Platform.isLinux) return 'Prisma-v${info.version}-linux-x64.tar.gz';
|
||||
if (Platform.isWindows) return 'PrismaSetup-v${info.version}.exe';
|
||||
return 'PrismaPortable-v${info.version}.zip';
|
||||
}
|
||||
|
||||
static String _versionFromTag(String tag) {
|
||||
|
||||
@@ -80,7 +80,16 @@ class MediaUrlSelector {
|
||||
]);
|
||||
}
|
||||
|
||||
static List<String> audio(Post post) {
|
||||
return _compact([
|
||||
post.fileUrl,
|
||||
if (_looksLikeAudioUrl(post.sampleUrl)) post.sampleUrl,
|
||||
if (_looksLikeAudioUrl(post.previewUrl)) post.previewUrl,
|
||||
]);
|
||||
}
|
||||
|
||||
static bool isVideo(Post post) => _isVideo(post);
|
||||
static bool isAudio(Post post) => _isAudio(post);
|
||||
static bool isGif(Post post) => _isGif(post);
|
||||
|
||||
static List<String> _compact(Iterable<String> urls) {
|
||||
@@ -91,23 +100,115 @@ class MediaUrlSelector {
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
static const _videoExtensions = {
|
||||
'.mp4',
|
||||
'.webm',
|
||||
'.mov',
|
||||
'.mkv',
|
||||
'.avi',
|
||||
'.flv',
|
||||
'.wmv',
|
||||
'.m4v',
|
||||
'.ts',
|
||||
};
|
||||
|
||||
static const _audioExtensions = {
|
||||
'.mp3',
|
||||
'.m4a',
|
||||
'.wav',
|
||||
'.ogg',
|
||||
'.flac',
|
||||
'.aac',
|
||||
'.opus',
|
||||
'.wma',
|
||||
};
|
||||
|
||||
static String _extractExtension(String url) {
|
||||
if (url.isEmpty) return '';
|
||||
try {
|
||||
final uri = Uri.parse(url);
|
||||
final filenameParam =
|
||||
uri.queryParameters['f'] ?? uri.queryParameters['filename'];
|
||||
if (filenameParam != null && filenameParam.isNotEmpty) {
|
||||
final cleanParam = filenameParam.split('?').first.split('#').first;
|
||||
final dot = cleanParam.lastIndexOf('.');
|
||||
if (dot != -1 && dot < cleanParam.length - 1) {
|
||||
final ext = cleanParam.substring(dot).toLowerCase();
|
||||
if (ext.length <= 6) return ext;
|
||||
}
|
||||
}
|
||||
final path = uri.path;
|
||||
final dot = path.lastIndexOf('.');
|
||||
if (dot != -1 && dot < path.length - 1) {
|
||||
final ext = path.substring(dot).toLowerCase();
|
||||
if (ext.length <= 6) return ext;
|
||||
}
|
||||
} catch (_) {
|
||||
// Uri parsing failed, fallback below
|
||||
}
|
||||
|
||||
final clean = url.split('?').first.split('#').first;
|
||||
final dot = clean.lastIndexOf('.');
|
||||
if (dot != -1 && dot < clean.length - 1) {
|
||||
final ext = clean.substring(dot).toLowerCase();
|
||||
if (ext.length <= 6) return ext;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
static bool _isAudio(Post post) {
|
||||
final type = post.fileType.toLowerCase().trim();
|
||||
if (type == 'audio') return true;
|
||||
if (type == 'video' || type == 'photo' || type == 'archive' || type == 'gif') {
|
||||
return false;
|
||||
}
|
||||
|
||||
final fileExt = _extractExtension(post.fileUrl);
|
||||
if (_audioExtensions.contains(fileExt)) return true;
|
||||
if (_videoExtensions.contains(fileExt)) return false;
|
||||
|
||||
final sampleExt = _extractExtension(post.sampleUrl);
|
||||
if (_audioExtensions.contains(sampleExt)) return true;
|
||||
if (_videoExtensions.contains(sampleExt)) return false;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
static bool _isVideo(Post post) {
|
||||
final value = '${post.fileType} ${post.fileUrl}'.toLowerCase();
|
||||
return value.contains('video') ||
|
||||
value.contains('.webm') ||
|
||||
value.contains('.mp4') ||
|
||||
value.contains('.mov');
|
||||
final type = post.fileType.toLowerCase().trim();
|
||||
if (type == 'video') return true;
|
||||
if (type == 'audio' || type == 'photo' || type == 'archive' || type == 'gif') {
|
||||
return false;
|
||||
}
|
||||
|
||||
final fileExt = _extractExtension(post.fileUrl);
|
||||
if (_videoExtensions.contains(fileExt)) return true;
|
||||
if (_audioExtensions.contains(fileExt)) return false;
|
||||
|
||||
final sampleExt = _extractExtension(post.sampleUrl);
|
||||
if (_videoExtensions.contains(sampleExt)) return true;
|
||||
if (_audioExtensions.contains(sampleExt)) return false;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
static bool _isGif(Post post) {
|
||||
final value = '${post.fileType} ${post.fileUrl}'.toLowerCase();
|
||||
return value.contains('gif') || value.contains('.gif');
|
||||
final type = post.fileType.toLowerCase().trim();
|
||||
if (type == 'gif') return true;
|
||||
final fileExt = _extractExtension(post.fileUrl);
|
||||
if (fileExt == '.gif') return true;
|
||||
final sampleExt = _extractExtension(post.sampleUrl);
|
||||
return sampleExt == '.gif';
|
||||
}
|
||||
|
||||
static bool _looksLikeVideoUrl(String url) {
|
||||
final value = url.toLowerCase();
|
||||
return value.contains('.webm') ||
|
||||
value.contains('.mp4') ||
|
||||
value.contains('.mov');
|
||||
final ext = _extractExtension(url);
|
||||
return _videoExtensions.contains(ext);
|
||||
}
|
||||
|
||||
static bool _looksLikeAudioUrl(String url) {
|
||||
final ext = _extractExtension(url);
|
||||
return _audioExtensions.contains(ext);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ class DioClient {
|
||||
receiveTimeout: timeout,
|
||||
sendTimeout: timeout,
|
||||
headers: {
|
||||
'User-Agent': 'Lunaris/2.0.1 Flutter local booru browser',
|
||||
'User-Agent': 'Prisma/2.0.1 Flutter local booru browser',
|
||||
'Accept': 'application/json, text/xml;q=0.9, */*;q=0.8',
|
||||
...?headers,
|
||||
},
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import '../../../app/app.dart';
|
||||
import '../../../app/app_navigator.dart';
|
||||
import '../../../app/responsive.dart';
|
||||
import '../../../backend/backend.dart';
|
||||
import '../../../core/utils/result.dart';
|
||||
@@ -190,12 +191,16 @@ class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
|
||||
bool _matchesTypeFilter(Post post) {
|
||||
if (_selectedTypes.isEmpty) return true;
|
||||
final isVid = MediaUrlSelector.isVideo(post);
|
||||
final isAud = MediaUrlSelector.isAudio(post);
|
||||
final isGif = MediaUrlSelector.isGif(post);
|
||||
final isPhoto = !isVid && !isGif;
|
||||
final isText = post.fileType == 'text';
|
||||
final isPhoto = !isVid && !isAud && !isGif && !isText;
|
||||
|
||||
if (_selectedTypes.contains('video') && isVid) return true;
|
||||
if (_selectedTypes.contains('audio') && isAud) return true;
|
||||
if (_selectedTypes.contains('gif') && isGif) return true;
|
||||
if (_selectedTypes.contains('photo') && isPhoto) return true;
|
||||
if (_selectedTypes.contains('text') && isText) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -205,94 +210,251 @@ class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
showDragHandle: true,
|
||||
backgroundColor: theme.colorScheme.surface,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
|
||||
),
|
||||
builder: (ctx) => DraggableScrollableSheet(
|
||||
expand: false,
|
||||
initialChildSize: 0.6,
|
||||
maxChildSize: 0.9,
|
||||
initialChildSize: 0.65,
|
||||
maxChildSize: 0.92,
|
||||
builder: (_, scroll) => Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
|
||||
padding: const EdgeInsets.fromLTRB(20, 4, 16, 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.campaign_rounded, color: theme.colorScheme.primary),
|
||||
const SizedBox(width: 10),
|
||||
Text(
|
||||
'Анонсы автора (${_announcements.length})',
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.campaign_rounded,
|
||||
color: theme.colorScheme.onPrimaryContainer,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Личные сообщения и анонсы',
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
letterSpacing: -0.2,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'${widget.artistName} • ${widget.service.toUpperCase()}',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Container(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.secondaryContainer,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Text(
|
||||
'${_announcements.length}',
|
||||
style: TextStyle(
|
||||
color: theme.colorScheme.onSecondaryContainer,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(),
|
||||
Divider(
|
||||
height: 1,
|
||||
color: theme.colorScheme.outlineVariant.withValues(alpha: 0.4),
|
||||
),
|
||||
Expanded(
|
||||
child: ListView.separated(
|
||||
controller: scroll,
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: _announcements.length,
|
||||
separatorBuilder: (_, __) => const Divider(height: 24),
|
||||
itemBuilder: (context, index) {
|
||||
final a = _announcements[index];
|
||||
final cleanContent = CloudLinkExtractor.cleanCommentary(a.content);
|
||||
final detectedCloudLinks = CloudLinkExtractor.extractLinks(content: a.content);
|
||||
final creatorLinks = CreatorLink.extractLinks(a.content);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (a.added != null)
|
||||
Text(
|
||||
'${a.added!.year}-${a.added!.month.toString().padLeft(2, '0')}-${a.added!.day.toString().padLeft(2, '0')}',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: theme.colorScheme.primary,
|
||||
fontWeight: FontWeight.w600,
|
||||
child: _announcements.isEmpty
|
||||
? Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.mark_email_read_outlined,
|
||||
size: 48,
|
||||
color: theme.colorScheme.outline,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
FormattedContentText(
|
||||
text: cleanContent.isNotEmpty ? cleanContent : a.content,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
height: 1.45,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'Нет сообщений или рассылок',
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
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,
|
||||
)
|
||||
: ListView.separated(
|
||||
controller: scroll,
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
16,
|
||||
16,
|
||||
16,
|
||||
120 + MediaQuery.paddingOf(context).bottom,
|
||||
),
|
||||
itemCount: _announcements.length,
|
||||
separatorBuilder: (_, __) => const SizedBox(height: 12),
|
||||
itemBuilder: (context, index) {
|
||||
final a = _announcements[index];
|
||||
final cleanContent =
|
||||
CloudLinkExtractor.cleanCommentary(a.content);
|
||||
final detectedCloudLinks =
|
||||
CloudLinkExtractor.extractLinks(content: a.content);
|
||||
final creatorLinks =
|
||||
CreatorLink.extractLinks(a.content);
|
||||
final displayContent = cleanContent.isNotEmpty
|
||||
? cleanContent
|
||||
: a.content;
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surfaceContainer,
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
border: Border.all(
|
||||
color: theme.colorScheme.outlineVariant
|
||||
.withValues(alpha: 0.45),
|
||||
),
|
||||
),
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 3,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.primary
|
||||
.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
(a.service.isNotEmpty
|
||||
? a.service
|
||||
: widget.service)
|
||||
.toUpperCase(),
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: theme.colorScheme.primary,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (a.added != null) ...[
|
||||
const SizedBox(width: 8),
|
||||
Icon(
|
||||
Icons.access_time_rounded,
|
||||
size: 13,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'${a.added!.day.toString().padLeft(2, '0')}.${a.added!.month.toString().padLeft(2, '0')}.${a.added!.year} ${a.added!.hour.toString().padLeft(2, '0')}:${a.added!.minute.toString().padLeft(2, '0')}',
|
||||
style: TextStyle(
|
||||
fontSize: 11.5,
|
||||
color:
|
||||
theme.colorScheme.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.copy_rounded,
|
||||
size: 17),
|
||||
tooltip: 'Скопировать текст',
|
||||
visualDensity: VisualDensity.compact,
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
onPressed: () {
|
||||
Clipboard.setData(
|
||||
ClipboardData(text: displayContent),
|
||||
);
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(
|
||||
const SnackBar(
|
||||
content:
|
||||
Text('Текст сообщения скопирован'),
|
||||
duration: Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
label: Text(
|
||||
link.title.isNotEmpty ? link.title : link.serviceName,
|
||||
style: const TextStyle(fontSize: 12),
|
||||
const SizedBox(height: 10),
|
||||
FormattedContentText(
|
||||
text: displayContent,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
height: 1.5,
|
||||
letterSpacing: 0.15,
|
||||
),
|
||||
),
|
||||
onPressed: () => launchUrl(
|
||||
Uri.parse(link.url),
|
||||
mode: LaunchMode.externalApplication,
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
if (creatorLinks.isNotEmpty) ...[
|
||||
const SizedBox(height: 12),
|
||||
CreatorLinkChips(
|
||||
links: creatorLinks,
|
||||
title: 'Ссылки из сообщения',
|
||||
),
|
||||
],
|
||||
if (detectedCloudLinks.isNotEmpty) ...[
|
||||
const SizedBox(height: 12),
|
||||
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(),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -302,11 +464,17 @@ class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
|
||||
|
||||
Widget _buildMediaTypeFilters() {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final audioCount = _posts.where(MediaUrlSelector.isAudio).length;
|
||||
final photoCount = _posts
|
||||
.where((p) => !MediaUrlSelector.isVideo(p) && !MediaUrlSelector.isGif(p))
|
||||
.where((p) =>
|
||||
p.fileType != 'text' &&
|
||||
!MediaUrlSelector.isVideo(p) &&
|
||||
!MediaUrlSelector.isAudio(p) &&
|
||||
!MediaUrlSelector.isGif(p))
|
||||
.length;
|
||||
final videoCount = _posts.where(MediaUrlSelector.isVideo).length;
|
||||
final gifCount = _posts.where(MediaUrlSelector.isGif).length;
|
||||
final textCount = _posts.where((p) => p.fileType == 'text').length;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 6, 16, 4),
|
||||
@@ -325,8 +493,8 @@ class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
|
||||
avatar: Icon(Icons.photo_outlined,
|
||||
size: 16,
|
||||
color: _selectedTypes.contains('photo')
|
||||
? scheme.onPrimaryContainer
|
||||
: scheme.onSurfaceVariant),
|
||||
? scheme.onPrimaryContainer
|
||||
: scheme.onSurfaceVariant),
|
||||
label: Text('Фото ($photoCount)'),
|
||||
onSelected: (selected) {
|
||||
setState(() {
|
||||
@@ -344,8 +512,8 @@ class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
|
||||
avatar: Icon(Icons.videocam_outlined,
|
||||
size: 16,
|
||||
color: _selectedTypes.contains('video')
|
||||
? scheme.onPrimaryContainer
|
||||
: scheme.onSurfaceVariant),
|
||||
? scheme.onPrimaryContainer
|
||||
: scheme.onSurfaceVariant),
|
||||
label: Text('Видео ($videoCount)'),
|
||||
onSelected: (selected) {
|
||||
setState(() {
|
||||
@@ -357,14 +525,35 @@ class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
|
||||
});
|
||||
},
|
||||
),
|
||||
if (audioCount > 0) ...[
|
||||
const SizedBox(width: 8),
|
||||
FilterChip(
|
||||
selected: _selectedTypes.contains('audio'),
|
||||
avatar: Icon(Icons.audiotrack_rounded,
|
||||
size: 16,
|
||||
color: _selectedTypes.contains('audio')
|
||||
? scheme.onPrimaryContainer
|
||||
: scheme.onSurfaceVariant),
|
||||
label: Text('Аудио ($audioCount)'),
|
||||
onSelected: (selected) {
|
||||
setState(() {
|
||||
if (selected) {
|
||||
_selectedTypes.add('audio');
|
||||
} else {
|
||||
_selectedTypes.remove('audio');
|
||||
}
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
const SizedBox(width: 8),
|
||||
FilterChip(
|
||||
selected: _selectedTypes.contains('gif'),
|
||||
avatar: Icon(Icons.gif_rounded,
|
||||
size: 20,
|
||||
color: _selectedTypes.contains('gif')
|
||||
? scheme.onPrimaryContainer
|
||||
: scheme.onSurfaceVariant),
|
||||
? scheme.onPrimaryContainer
|
||||
: scheme.onSurfaceVariant),
|
||||
label: Text('GIF ($gifCount)'),
|
||||
onSelected: (selected) {
|
||||
setState(() {
|
||||
@@ -376,6 +565,27 @@ class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
|
||||
});
|
||||
},
|
||||
),
|
||||
if (textCount > 0) ...[
|
||||
const SizedBox(width: 8),
|
||||
FilterChip(
|
||||
selected: _selectedTypes.contains('text'),
|
||||
avatar: Icon(Icons.article_outlined,
|
||||
size: 16,
|
||||
color: _selectedTypes.contains('text')
|
||||
? scheme.onPrimaryContainer
|
||||
: scheme.onSurfaceVariant),
|
||||
label: Text('Текст ($textCount)'),
|
||||
onSelected: (selected) {
|
||||
setState(() {
|
||||
if (selected) {
|
||||
_selectedTypes.add('text');
|
||||
} else {
|
||||
_selectedTypes.remove('text');
|
||||
}
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -581,12 +791,10 @@ class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
|
||||
settings.mediaQualityMode),
|
||||
favoriteKeys: favoriteKeys,
|
||||
viewedKeys: viewedKeys,
|
||||
onOpen: (post) => context.push(
|
||||
'/post/${post.providerId}/${post.id}',
|
||||
extra: PostNavigationContext(
|
||||
currentPost: post,
|
||||
posts: displayedPosts,
|
||||
),
|
||||
onOpen: (post) => AppNavigator.openPost(
|
||||
context,
|
||||
post: post,
|
||||
postsList: displayedPosts,
|
||||
),
|
||||
onFavorite: (post) async {
|
||||
if (favoriteKeys.contains(post.cacheKey)) {
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../app/app.dart';
|
||||
import '../../../app/app_navigator.dart';
|
||||
import '../../../app/responsive.dart';
|
||||
import '../../../backend/backend.dart';
|
||||
import '../../../shared/widgets/adaptive_scaffold.dart';
|
||||
@@ -167,12 +168,10 @@ class CollectionDetailsScreen extends ConsumerWidget {
|
||||
mediaQualityMode:
|
||||
MediaQualityMode.fromName(settings.mediaQualityMode),
|
||||
favoriteKeys: favoriteKeys,
|
||||
onOpen: (post) => context.push(
|
||||
'/post/${post.providerId}/${post.id}',
|
||||
extra: PostNavigationContext(
|
||||
currentPost: post,
|
||||
posts: items,
|
||||
),
|
||||
onOpen: (post) => AppNavigator.openPost(
|
||||
context,
|
||||
post: post,
|
||||
postsList: items,
|
||||
),
|
||||
onFavorite: (post) async {
|
||||
if (favoriteKeys.contains(post.cacheKey)) {
|
||||
|
||||
@@ -314,6 +314,8 @@ class _CollectionCard extends ConsumerWidget {
|
||||
if (urls.length == 1) {
|
||||
return CachedNetworkImage(
|
||||
imageUrl: urls[0],
|
||||
memCacheWidth: 260,
|
||||
memCacheHeight: 260,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (_, __) => Container(color: scheme.surfaceContainerHighest),
|
||||
errorWidget: (_, __, ___) => Container(
|
||||
@@ -333,6 +335,8 @@ class _CollectionCard extends ConsumerWidget {
|
||||
if (i < urls.length)
|
||||
CachedNetworkImage(
|
||||
imageUrl: urls[i],
|
||||
memCacheWidth: 200,
|
||||
memCacheHeight: 200,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (_, __) =>
|
||||
Container(color: scheme.surfaceContainerHighest),
|
||||
|
||||
@@ -7,6 +7,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../app/app.dart';
|
||||
import '../../../app/app_navigator.dart';
|
||||
import '../../../app/responsive.dart';
|
||||
import '../../../backend/backend.dart';
|
||||
import '../../../shared/widgets/adaptive_scaffold.dart';
|
||||
@@ -14,6 +15,7 @@ import '../../../shared/widgets/empty_view.dart';
|
||||
import '../../../shared/widgets/error_view.dart';
|
||||
import '../../../shared/widgets/post_masonry_grid.dart';
|
||||
import 'favorites_controller.dart';
|
||||
import 'widgets/e621_favorites_view.dart';
|
||||
|
||||
enum FavoriteMediaType { all, video, image }
|
||||
enum OfflineSortOption { dateAddedDesc, sizeDesc, artist }
|
||||
@@ -314,6 +316,17 @@ class _FavoritesScreenState extends ConsumerState<FavoritesScreen> {
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
const ButtonSegment(
|
||||
value: 3,
|
||||
icon: Icon(Icons.hub_rounded,
|
||||
size: 18, color: Color(0xFF0077EE)),
|
||||
label: Text(
|
||||
'e621',
|
||||
style: TextStyle(
|
||||
fontSize: 12, fontWeight: FontWeight.w600),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
selected: {_tabIndex},
|
||||
onSelectionChanged: (value) {
|
||||
@@ -332,7 +345,7 @@ class _FavoritesScreenState extends ConsumerState<FavoritesScreen> {
|
||||
),
|
||||
|
||||
// Media Type Filter Chips (for Tab 0 and Tab 1)
|
||||
if (_tabIndex != 2)
|
||||
if (_tabIndex != 2 && _tabIndex != 3)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||
child: Row(
|
||||
@@ -398,6 +411,10 @@ class _FavoritesScreenState extends ConsumerState<FavoritesScreen> {
|
||||
downloaded.keys.toSet(),
|
||||
isRu,
|
||||
),
|
||||
3 => E621FavoritesView(
|
||||
settings: settings,
|
||||
isRu: isRu,
|
||||
),
|
||||
_ => _buildAllView(
|
||||
allPosts,
|
||||
settings,
|
||||
@@ -970,12 +987,10 @@ class _FavoritesScreenState extends ConsumerState<FavoritesScreen> {
|
||||
mediaQualityMode: MediaQualityMode.fromName(settings.mediaQualityMode),
|
||||
favoriteKeys: posts.map((post) => post.cacheKey).toSet(),
|
||||
downloadedKeys: downloadedKeys,
|
||||
onOpen: (post) => context.push(
|
||||
'/post/${post.providerId}/${post.id}',
|
||||
extra: PostNavigationContext(
|
||||
currentPost: post,
|
||||
posts: posts,
|
||||
),
|
||||
onOpen: (post) => AppNavigator.openPost(
|
||||
context,
|
||||
post: post,
|
||||
postsList: posts,
|
||||
),
|
||||
onFavorite: (post) =>
|
||||
ref.read(favoritesControllerProvider.notifier).remove(post),
|
||||
@@ -1226,6 +1241,8 @@ class _ArtistCard extends StatelessWidget {
|
||||
),
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: url,
|
||||
memCacheWidth: 128,
|
||||
memCacheHeight: 128,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (_, __) => Container(
|
||||
color: scheme.surfaceContainerHighest,
|
||||
|
||||
@@ -0,0 +1,464 @@
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../../app/app_navigator.dart';
|
||||
import '../../../../app/responsive.dart';
|
||||
import '../../../../backend/backend.dart';
|
||||
import '../../../../backend/providers/e621_provider.dart';
|
||||
import '../../../../shared/widgets/empty_view.dart';
|
||||
import '../../../../shared/widgets/post_masonry_grid.dart';
|
||||
import '../../../providers/presentation/providers_controller.dart';
|
||||
import '../../../providers/presentation/widgets/e621_auth_dialog.dart';
|
||||
import '../favorites_controller.dart';
|
||||
|
||||
class E621FavoritesView extends ConsumerStatefulWidget {
|
||||
const E621FavoritesView({
|
||||
required this.settings,
|
||||
required this.isRu,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final AppSettings settings;
|
||||
final bool isRu;
|
||||
|
||||
@override
|
||||
ConsumerState<E621FavoritesView> createState() => _E621FavoritesViewState();
|
||||
}
|
||||
|
||||
class _E621FavoritesViewState extends ConsumerState<E621FavoritesView> {
|
||||
final List<Post> _posts = [];
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
int _page = 1;
|
||||
bool _isLoading = false;
|
||||
bool _hasMore = true;
|
||||
String? _error;
|
||||
String? _currentLogin;
|
||||
bool _isImporting = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_scrollController.addListener(_onScroll);
|
||||
_loadInitial();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onScroll() {
|
||||
if (_scrollController.position.pixels >=
|
||||
_scrollController.position.maxScrollExtent - 400 &&
|
||||
!_isLoading &&
|
||||
_hasMore) {
|
||||
_loadNextPage();
|
||||
}
|
||||
}
|
||||
|
||||
Future<E621Provider?> _getE621Provider() async {
|
||||
final instance = await ref
|
||||
.read(providerManagerProvider)
|
||||
.getProviderInstance('e621');
|
||||
return instance is E621Provider ? instance : null;
|
||||
}
|
||||
|
||||
Future<void> _loadInitial() async {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_error = null;
|
||||
_posts.clear();
|
||||
_page = 1;
|
||||
_hasMore = true;
|
||||
});
|
||||
|
||||
try {
|
||||
final provider = await _getE621Provider();
|
||||
if (provider == null) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
_error = widget.isRu
|
||||
? 'Источник e621 не найден'
|
||||
: 'e621 provider not found';
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
final login = provider.login?.trim();
|
||||
setState(() => _currentLogin = login);
|
||||
|
||||
if (login == null || login.isEmpty) {
|
||||
setState(() => _isLoading = false);
|
||||
return;
|
||||
}
|
||||
|
||||
final items = await provider.getFavorites(page: 1, limit: 50);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_posts.addAll(items);
|
||||
_isLoading = false;
|
||||
_hasMore = items.length >= 25;
|
||||
_page = 2;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_error = widget.isRu ? 'Ошибка загрузки: $e' : 'Loading error: $e';
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadNextPage() async {
|
||||
if (_isLoading || !_hasMore) return;
|
||||
setState(() => _isLoading = true);
|
||||
|
||||
try {
|
||||
final provider = await _getE621Provider();
|
||||
if (provider == null) {
|
||||
setState(() => _isLoading = false);
|
||||
return;
|
||||
}
|
||||
|
||||
final items = await provider.getFavorites(page: _page, limit: 50);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
final existingIds = _posts.map((p) => p.id).toSet();
|
||||
final newItems =
|
||||
items.where((p) => !existingIds.contains(p.id)).toList();
|
||||
_posts.addAll(newItems);
|
||||
_isLoading = false;
|
||||
_hasMore = items.length >= 25;
|
||||
_page++;
|
||||
});
|
||||
}
|
||||
} catch (_) {
|
||||
if (mounted) {
|
||||
setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _importAllToLocal() async {
|
||||
if (_posts.isEmpty || _isImporting) return;
|
||||
setState(() => _isImporting = true);
|
||||
HapticFeedback.mediumImpact();
|
||||
|
||||
final favService = ref.read(favoriteServiceProvider);
|
||||
int imported = 0;
|
||||
for (final post in _posts) {
|
||||
await favService.addFavorite(post);
|
||||
imported++;
|
||||
}
|
||||
|
||||
ref.invalidate(favoriteKeysProvider);
|
||||
ref.invalidate(favoritesControllerProvider);
|
||||
|
||||
if (mounted) {
|
||||
setState(() => _isImporting = false);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
widget.isRu
|
||||
? 'Импортировано $imported постов в локальное избранное!'
|
||||
: 'Imported $imported posts to local favorites!',
|
||||
),
|
||||
duration: const Duration(seconds: 3),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final isDark = theme.brightness == Brightness.dark;
|
||||
final downloaded = ref.watch(
|
||||
downloadedMediaByKeysProvider(
|
||||
_posts.map((post) => post.cacheKey).toList(growable: false),
|
||||
),
|
||||
).value ??
|
||||
const <String, DownloadedMedia>{};
|
||||
|
||||
final favoriteKeys = ref.watch(favoriteKeysProvider).value ?? <String>{};
|
||||
|
||||
// If no login is set
|
||||
if (_currentLogin == null || _currentLogin!.isEmpty) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(28),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 68,
|
||||
height: 68,
|
||||
decoration: BoxDecoration(
|
||||
gradient: const LinearGradient(
|
||||
colors: [Color(0xFF0055AA), Color(0xFF0088FF)],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: const Color(0xFF0055AA).withValues(alpha: 0.35),
|
||||
blurRadius: 16,
|
||||
offset: const Offset(0, 6),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.hub_rounded,
|
||||
size: 34,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
Text(
|
||||
widget.isRu ? 'Аккаунт e621 не указан' : 'e621 account not set',
|
||||
style: theme.textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 380),
|
||||
child: Text(
|
||||
widget.isRu
|
||||
? 'Чтобы смотреть избранное (своё или друга) прямо здесь, укажите логин и API-ключ в настройках источника e621.'
|
||||
: 'To browse favorites directly here, provide your e621 login and API key.',
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 22),
|
||||
FilledButton.icon(
|
||||
onPressed: () async {
|
||||
final configs =
|
||||
ref.read(providersControllerProvider).value ?? [];
|
||||
final e621Config = configs.firstWhere(
|
||||
(p) => p.apiType.toLowerCase() == 'e621',
|
||||
orElse: () => configs.first,
|
||||
);
|
||||
await E621AuthDialog.show(
|
||||
context,
|
||||
config: e621Config,
|
||||
onSaved: (updated) {
|
||||
ref
|
||||
.read(providersControllerProvider.notifier)
|
||||
.save(updated);
|
||||
_loadInitial();
|
||||
},
|
||||
);
|
||||
},
|
||||
icon: const Icon(Icons.vpn_key_rounded, size: 18),
|
||||
label: Text(
|
||||
widget.isRu
|
||||
? 'Войти / Настроить API-ключ e621'
|
||||
: 'Set e621 API Key',
|
||||
),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: const Color(0xFF0055AA),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 20, vertical: 12),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return RefreshIndicator(
|
||||
onRefresh: _loadInitial,
|
||||
child: Column(
|
||||
children: [
|
||||
// Header Banner
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 4, 16, 8),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 14, sigmaY: 14),
|
||||
child: Container(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: isDark
|
||||
? const Color(0xFF131A26).withValues(alpha: 0.85)
|
||||
: Colors.white.withValues(alpha: 0.85),
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
border: Border.all(
|
||||
color: isDark
|
||||
? const Color(0xFF0055AA).withValues(alpha: 0.35)
|
||||
: const Color(0xFF0055AA).withValues(alpha: 0.20),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 38,
|
||||
height: 38,
|
||||
decoration: BoxDecoration(
|
||||
gradient: const LinearGradient(
|
||||
colors: [Color(0xFF0055AA), Color(0xFF0077EE)],
|
||||
),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.hub_rounded,
|
||||
color: Colors.white,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Избранное: @$_currentLogin',
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 13,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
Text(
|
||||
widget.isRu
|
||||
? '${_posts.length} постов на сервере e621'
|
||||
: '${_posts.length} posts on e621',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (_posts.isNotEmpty)
|
||||
IconButton.filledTonal(
|
||||
tooltip: widget.isRu
|
||||
? 'Импортировать в локальное избранное'
|
||||
: 'Import to local favorites',
|
||||
onPressed: _isImporting ? null : _importAllToLocal,
|
||||
icon: _isImporting
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.download_rounded, size: 18),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
IconButton.filledTonal(
|
||||
tooltip: widget.isRu ? 'Обновить' : 'Refresh',
|
||||
onPressed: _isLoading ? null : _loadInitial,
|
||||
icon: const Icon(Icons.refresh_rounded, size: 18),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Content
|
||||
Expanded(
|
||||
child: _isLoading && _posts.isEmpty
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _error != null && _posts.isEmpty
|
||||
? Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.error_outline_rounded,
|
||||
size: 40, color: Color(0xFFEF4444)),
|
||||
const SizedBox(height: 12),
|
||||
Text(_error!),
|
||||
const SizedBox(height: 12),
|
||||
FilledButton.tonal(
|
||||
onPressed: _loadInitial,
|
||||
child: Text(widget.isRu ? 'Повторить' : 'Retry'),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: _posts.isEmpty
|
||||
? Center(
|
||||
child: EmptyView(
|
||||
title: widget.isRu
|
||||
? 'У пользователя нет избранных постов'
|
||||
: 'No favorite posts for this user',
|
||||
),
|
||||
)
|
||||
: ListView(
|
||||
controller: _scrollController,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
children: [
|
||||
PostMasonryGrid(
|
||||
posts: _posts,
|
||||
columns: Responsive.columnsFor(
|
||||
context,
|
||||
mobileColumns: widget.settings.mobileColumns,
|
||||
desktopColumns:
|
||||
widget.settings.desktopColumns,
|
||||
),
|
||||
blurExplicit:
|
||||
widget.settings.blurExplicitContent,
|
||||
showBadges: widget.settings.showPostBadges,
|
||||
nsfwEnabled: widget.settings.nsfwEnabled,
|
||||
mediaQualityMode: MediaQualityMode.fromName(
|
||||
widget.settings.mediaQualityMode,
|
||||
),
|
||||
favoriteKeys: favoriteKeys,
|
||||
downloadedKeys: downloaded.keys.toSet(),
|
||||
onOpen: (post) => AppNavigator.openPost(
|
||||
context,
|
||||
post: post,
|
||||
postsList: _posts,
|
||||
),
|
||||
onFavorite: (post) async {
|
||||
final isFav =
|
||||
favoriteKeys.contains(post.cacheKey);
|
||||
final favService =
|
||||
ref.read(favoriteServiceProvider);
|
||||
if (isFav) {
|
||||
await favService.removeFavorite(
|
||||
post.id, post.providerId);
|
||||
} else {
|
||||
await favService.addFavorite(post);
|
||||
}
|
||||
ref.invalidate(favoriteKeysProvider);
|
||||
ref.invalidate(favoritesControllerProvider);
|
||||
},
|
||||
),
|
||||
if (_isLoading)
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(20),
|
||||
child: Center(
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import 'dart:async';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
|
||||
import '../../../app/app.dart';
|
||||
import '../../../app/app_navigator.dart';
|
||||
import '../../../app/responsive.dart';
|
||||
import '../../../backend/backend.dart';
|
||||
import '../../../core/utils/result.dart';
|
||||
@@ -143,7 +144,11 @@ class _FeedScreenState extends ConsumerState<FeedScreen>
|
||||
|
||||
return Shortcuts(
|
||||
shortcuts: {
|
||||
LogicalKeySet(LogicalKeyboardKey.keyR): const _RefreshIntent(),
|
||||
LogicalKeySet(LogicalKeyboardKey.keyR): const _RandomPostIntent(),
|
||||
LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.keyR):
|
||||
const _RefreshIntent(),
|
||||
LogicalKeySet(LogicalKeyboardKey.f5): const _RefreshIntent(),
|
||||
LogicalKeySet(LogicalKeyboardKey.keyV): const _ToggleSelectionIntent(),
|
||||
LogicalKeySet(LogicalKeyboardKey.escape): const _ClearSelectionIntent(),
|
||||
LogicalKeySet(LogicalKeyboardKey.delete): const _ClearSelectionIntent(),
|
||||
LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.keyA):
|
||||
@@ -157,6 +162,24 @@ class _FeedScreenState extends ConsumerState<FeedScreen>
|
||||
return null;
|
||||
},
|
||||
),
|
||||
_RandomPostIntent: CallbackAction<_RandomPostIntent>(
|
||||
onInvoke: (_) {
|
||||
final posts = feed.value?.posts;
|
||||
if (posts != null && posts.isNotEmpty) {
|
||||
_openRandom(posts);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
_ToggleSelectionIntent: CallbackAction<_ToggleSelectionIntent>(
|
||||
onInvoke: (_) {
|
||||
setState(() {
|
||||
_selectionMode = !_selectionMode;
|
||||
if (!_selectionMode) _selectedKeys.clear();
|
||||
});
|
||||
return null;
|
||||
},
|
||||
),
|
||||
_ClearSelectionIntent: CallbackAction<_ClearSelectionIntent>(
|
||||
onInvoke: (_) {
|
||||
_clearSelection();
|
||||
@@ -331,12 +354,10 @@ class _FeedScreenState extends ConsumerState<FeedScreen>
|
||||
selectionMode: _selectionMode,
|
||||
selectedKeys: _selectedKeys,
|
||||
gridMode: settings.gridMode,
|
||||
onOpen: (post) => context.push(
|
||||
'/post/${post.providerId}/${post.id}',
|
||||
extra: PostNavigationContext(
|
||||
currentPost: post,
|
||||
posts: state.posts,
|
||||
),
|
||||
onOpen: (post) => AppNavigator.openPost(
|
||||
context,
|
||||
post: post,
|
||||
postsList: state.posts,
|
||||
),
|
||||
onPreview: (post) => _showPreview(context, post),
|
||||
onToggleSelected: (post) => _toggleSelected(post),
|
||||
@@ -392,7 +413,7 @@ class _FeedScreenState extends ConsumerState<FeedScreen>
|
||||
void _openRandom(List<Post> posts) {
|
||||
if (posts.isEmpty) return;
|
||||
final post = posts[Random().nextInt(posts.length)];
|
||||
context.push('/post/${post.providerId}/${post.id}', extra: post);
|
||||
AppNavigator.openPost(context, post: post, postsList: posts);
|
||||
}
|
||||
|
||||
Future<void> _applySearchQuery(String query) async {
|
||||
@@ -653,7 +674,7 @@ class _FeedTitle extends StatelessWidget {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
'Lunaris',
|
||||
'Prisma',
|
||||
style: theme.textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
@@ -683,6 +704,14 @@ class _SelectVisibleIntent extends Intent {
|
||||
const _SelectVisibleIntent();
|
||||
}
|
||||
|
||||
class _RandomPostIntent extends Intent {
|
||||
const _RandomPostIntent();
|
||||
}
|
||||
|
||||
class _ToggleSelectionIntent extends Intent {
|
||||
const _ToggleSelectionIntent();
|
||||
}
|
||||
|
||||
class _BatchActionBar extends StatelessWidget {
|
||||
const _BatchActionBar({
|
||||
required this.count,
|
||||
|
||||
@@ -110,7 +110,7 @@ class _FeedToolbarState extends State<FeedToolbar> {
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
IconButton.filledTonal(
|
||||
tooltip: widget.selectionMode ? 'Выйти из выбора' : 'Выбрать посты',
|
||||
tooltip: widget.selectionMode ? 'Выйти из выбора (Esc)' : 'Выбрать посты (V)',
|
||||
onPressed: widget.onToggleSelectionMode,
|
||||
icon: Icon(widget.selectionMode
|
||||
? Icons.check_box_rounded
|
||||
@@ -139,13 +139,13 @@ class _FeedToolbarState extends State<FeedToolbar> {
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
IconButton(
|
||||
tooltip: 'Обновить',
|
||||
tooltip: 'Обновить (Ctrl+R / F5)',
|
||||
onPressed: widget.onRefresh,
|
||||
icon: const Icon(Icons.refresh_rounded),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
IconButton.filledTonal(
|
||||
tooltip: 'Случайный пост',
|
||||
tooltip: 'Случайный пост (R)',
|
||||
onPressed: widget.onRandom,
|
||||
icon: const Icon(Icons.casino_rounded),
|
||||
),
|
||||
@@ -163,41 +163,36 @@ class _FeedToolbarState extends State<FeedToolbar> {
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
_HorizontalWheelScroller(
|
||||
height: 36,
|
||||
height: 38,
|
||||
builder: (controller) => ListView(
|
||||
controller: controller,
|
||||
scrollDirection: Axis.horizontal,
|
||||
primary: false,
|
||||
children: [
|
||||
_LiquidPeriodTabs(
|
||||
selectedPeriod: widget.topPeriodFilter,
|
||||
onChanged: widget.onTopPeriodChanged,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const _ToolbarDivider(),
|
||||
const SizedBox(width: 8),
|
||||
if (widget.providers.isNotEmpty) ...[
|
||||
FilterChip(
|
||||
selected: widget.selectedProviderIds.isEmpty,
|
||||
label: const Text('Все источники'),
|
||||
onSelected: (_) => widget.onQuickProviderToggle('__all__'),
|
||||
_LiquidProviderPill(
|
||||
label: 'Все источники',
|
||||
isSelected: widget.selectedProviderIds.isEmpty,
|
||||
icon: Icons.all_inclusive_rounded,
|
||||
onTap: () => widget.onQuickProviderToggle('__all__'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const SizedBox(width: 6),
|
||||
for (final provider in widget.providers) ...[
|
||||
FilterChip(
|
||||
selected:
|
||||
widget.selectedProviderIds.contains(provider.id),
|
||||
avatar: const Icon(Icons.hub_rounded, size: 15),
|
||||
label: Text(provider.name),
|
||||
onSelected: (_) =>
|
||||
widget.onQuickProviderToggle(provider.id),
|
||||
_LiquidProviderPill(
|
||||
label: provider.name,
|
||||
isSelected: widget.selectedProviderIds.contains(provider.id),
|
||||
icon: Icons.hub_rounded,
|
||||
onTap: () => widget.onQuickProviderToggle(provider.id),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const SizedBox(width: 6),
|
||||
],
|
||||
const SizedBox(width: 4),
|
||||
const _ToolbarDivider(),
|
||||
const SizedBox(width: 10),
|
||||
],
|
||||
for (final period in TopPeriodFilter.values) ...[
|
||||
ChoiceChip(
|
||||
selected: widget.topPeriodFilter == period,
|
||||
label: Text(period.label),
|
||||
onSelected: (_) => widget.onTopPeriodChanged(period),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
],
|
||||
),
|
||||
@@ -289,64 +284,56 @@ class _FeedToolbarState extends State<FeedToolbar> {
|
||||
primary: false,
|
||||
children: [
|
||||
if (hasActiveFilters || widget.selectedTags.isNotEmpty) ...[
|
||||
ActionChip(
|
||||
visualDensity: VisualDensity.compact,
|
||||
avatar: const Icon(Icons.filter_alt_off_rounded, size: 15),
|
||||
label: const Text('Сбросить'),
|
||||
onPressed: widget.onClearFilters,
|
||||
_LiquidProviderPill(
|
||||
label: 'Сбросить',
|
||||
isSelected: false,
|
||||
icon: Icons.filter_alt_off_rounded,
|
||||
onTap: widget.onClearFilters,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
],
|
||||
ActionChip(
|
||||
visualDensity: VisualDensity.compact,
|
||||
avatar: const Icon(Icons.refresh_rounded, size: 15),
|
||||
label: const Text('Обновить'),
|
||||
onPressed: widget.onRefresh,
|
||||
_LiquidProviderPill(
|
||||
label: 'Обновить',
|
||||
isSelected: false,
|
||||
icon: Icons.refresh_rounded,
|
||||
onTap: widget.onRefresh,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
const _ToolbarDivider(),
|
||||
const SizedBox(width: 6),
|
||||
FilterChip(
|
||||
visualDensity: VisualDensity.compact,
|
||||
selected: widget.rating != null,
|
||||
avatar: const Icon(Icons.shield_outlined, size: 15),
|
||||
label: Text(widget.rating ?? 'Рейтинг'),
|
||||
onSelected: (_) => widget.onRatingFilter(),
|
||||
_LiquidProviderPill(
|
||||
label: widget.rating ?? 'Рейтинг',
|
||||
isSelected: widget.rating != null,
|
||||
icon: Icons.shield_outlined,
|
||||
onTap: widget.onRatingFilter,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
const _ToolbarDivider(),
|
||||
const SizedBox(width: 6),
|
||||
_LiquidPeriodTabs(
|
||||
selectedPeriod: widget.topPeriodFilter,
|
||||
onChanged: widget.onTopPeriodChanged,
|
||||
),
|
||||
if (widget.providers.isNotEmpty) ...[
|
||||
FilterChip(
|
||||
visualDensity: VisualDensity.compact,
|
||||
selected: widget.selectedProviderIds.isEmpty,
|
||||
label: const Text('Все'),
|
||||
onSelected: (_) => widget.onQuickProviderToggle('__all__'),
|
||||
const SizedBox(width: 6),
|
||||
const _ToolbarDivider(),
|
||||
const SizedBox(width: 6),
|
||||
_LiquidProviderPill(
|
||||
label: 'Все',
|
||||
isSelected: widget.selectedProviderIds.isEmpty,
|
||||
icon: Icons.all_inclusive_rounded,
|
||||
onTap: () => widget.onQuickProviderToggle('__all__'),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
for (final provider in widget.providers) ...[
|
||||
FilterChip(
|
||||
visualDensity: VisualDensity.compact,
|
||||
selected:
|
||||
widget.selectedProviderIds.contains(provider.id),
|
||||
label: Text(provider.name),
|
||||
onSelected: (_) =>
|
||||
widget.onQuickProviderToggle(provider.id),
|
||||
_LiquidProviderPill(
|
||||
label: provider.name,
|
||||
isSelected: widget.selectedProviderIds.contains(provider.id),
|
||||
icon: Icons.hub_rounded,
|
||||
onTap: () => widget.onQuickProviderToggle(provider.id),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
],
|
||||
const SizedBox(width: 2),
|
||||
const _ToolbarDivider(),
|
||||
const SizedBox(width: 6),
|
||||
],
|
||||
for (final period in TopPeriodFilter.values) ...[
|
||||
ChoiceChip(
|
||||
visualDensity: VisualDensity.compact,
|
||||
selected: widget.topPeriodFilter == period,
|
||||
label: Text(period.label),
|
||||
onSelected: (_) => widget.onTopPeriodChanged(period),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
],
|
||||
],
|
||||
),
|
||||
@@ -371,6 +358,227 @@ class _ToolbarDivider extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _LiquidPeriodTabs extends StatelessWidget {
|
||||
const _LiquidPeriodTabs({
|
||||
required this.selectedPeriod,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
final TopPeriodFilter selectedPeriod;
|
||||
final ValueChanged<TopPeriodFilter> onChanged;
|
||||
|
||||
IconData _iconFor(TopPeriodFilter period) {
|
||||
return switch (period) {
|
||||
TopPeriodFilter.none => Icons.auto_awesome_rounded,
|
||||
TopPeriodFilter.month => Icons.calendar_month_rounded,
|
||||
TopPeriodFilter.year => Icons.calendar_today_rounded,
|
||||
TopPeriodFilter.allTime => Icons.workspace_premium_rounded,
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final scheme = theme.colorScheme;
|
||||
final isDark = theme.brightness == Brightness.dark;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(2.5),
|
||||
decoration: BoxDecoration(
|
||||
color: isDark
|
||||
? scheme.surfaceContainerHigh.withValues(alpha: 0.55)
|
||||
: scheme.surfaceContainerHighest.withValues(alpha: 0.45),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: isDark
|
||||
? Colors.white.withValues(alpha: 0.1)
|
||||
: Colors.black.withValues(alpha: 0.06),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
for (final period in TopPeriodFilter.values) ...[
|
||||
_LiquidPeriodTabItem(
|
||||
period: period,
|
||||
icon: _iconFor(period),
|
||||
isSelected: selectedPeriod == period,
|
||||
onTap: () => onChanged(period),
|
||||
),
|
||||
if (period != TopPeriodFilter.values.last) const SizedBox(width: 3),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LiquidPeriodTabItem extends StatelessWidget {
|
||||
const _LiquidPeriodTabItem({
|
||||
required this.period,
|
||||
required this.icon,
|
||||
required this.isSelected,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final TopPeriodFilter period;
|
||||
final IconData icon;
|
||||
final bool isSelected;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final scheme = theme.colorScheme;
|
||||
final isDark = theme.brightness == Brightness.dark;
|
||||
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(13),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.easeOutCubic,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? (isDark
|
||||
? scheme.primary.withValues(alpha: 0.28)
|
||||
: scheme.primaryContainer.withValues(alpha: 0.85))
|
||||
: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(13),
|
||||
border: isSelected
|
||||
? Border.all(
|
||||
color: scheme.primary.withValues(alpha: isDark ? 0.45 : 0.3),
|
||||
width: 1,
|
||||
)
|
||||
: null,
|
||||
boxShadow: isSelected
|
||||
? [
|
||||
BoxShadow(
|
||||
color: scheme.primary.withValues(alpha: isDark ? 0.25 : 0.12),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
]
|
||||
: null,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: 14,
|
||||
color: isSelected
|
||||
? (isDark ? scheme.primary : scheme.onPrimaryContainer)
|
||||
: scheme.onSurfaceVariant.withValues(alpha: 0.85),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
period.label,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: isSelected ? FontWeight.w700 : FontWeight.w500,
|
||||
color: isSelected
|
||||
? (isDark ? scheme.primary : scheme.onPrimaryContainer)
|
||||
: scheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LiquidProviderPill extends StatelessWidget {
|
||||
const _LiquidProviderPill({
|
||||
required this.label,
|
||||
required this.isSelected,
|
||||
required this.onTap,
|
||||
this.icon,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final bool isSelected;
|
||||
final VoidCallback onTap;
|
||||
final IconData? icon;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final scheme = theme.colorScheme;
|
||||
final isDark = theme.brightness == Brightness.dark;
|
||||
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.easeOutCubic,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? (isDark
|
||||
? scheme.secondaryContainer.withValues(alpha: 0.35)
|
||||
: scheme.secondaryContainer.withValues(alpha: 0.7))
|
||||
: (isDark
|
||||
? scheme.surfaceContainerHigh.withValues(alpha: 0.4)
|
||||
: scheme.surfaceContainerHighest.withValues(alpha: 0.35)),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(
|
||||
color: isSelected
|
||||
? scheme.secondary.withValues(alpha: isDark ? 0.6 : 0.4)
|
||||
: (isDark
|
||||
? Colors.white.withValues(alpha: 0.08)
|
||||
: Colors.black.withValues(alpha: 0.05)),
|
||||
width: 1,
|
||||
),
|
||||
boxShadow: isSelected
|
||||
? [
|
||||
BoxShadow(
|
||||
color: scheme.secondary.withValues(alpha: isDark ? 0.2 : 0.08),
|
||||
blurRadius: 6,
|
||||
),
|
||||
]
|
||||
: null,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (icon != null) ...[
|
||||
Icon(
|
||||
icon,
|
||||
size: 14,
|
||||
color: isSelected
|
||||
? (isDark ? scheme.secondary : scheme.onSecondaryContainer)
|
||||
: scheme.onSurfaceVariant.withValues(alpha: 0.8),
|
||||
),
|
||||
const SizedBox(width: 5),
|
||||
],
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: isSelected ? FontWeight.w700 : FontWeight.w500,
|
||||
color: isSelected
|
||||
? (isDark ? scheme.secondary : scheme.onSecondaryContainer)
|
||||
: scheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _HorizontalWheelScroller extends StatefulWidget {
|
||||
const _HorizontalWheelScroller({
|
||||
required this.builder,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../app/app.dart';
|
||||
import '../../../app/app_navigator.dart';
|
||||
import '../../../app/responsive.dart';
|
||||
import '../../../backend/backend.dart';
|
||||
import '../../../core/utils/result.dart';
|
||||
@@ -98,9 +98,9 @@ class SimilarPostsScreen extends ConsumerWidget {
|
||||
MediaQualityMode.fromName(settings.mediaQualityMode),
|
||||
favoriteKeys: favorites,
|
||||
viewedKeys: viewed,
|
||||
onOpen: (post) => context.push(
|
||||
'/post/${post.providerId}/${post.id}',
|
||||
extra: post,
|
||||
onOpen: (post) => AppNavigator.openPost(
|
||||
context,
|
||||
post: post,
|
||||
),
|
||||
onFavorite: (post) =>
|
||||
_toggleFavorite(ref, post, favorites),
|
||||
|
||||
@@ -7,7 +7,7 @@ import '../../../../backend/models/cloud_media_link.dart';
|
||||
import '../../../../backend/models/creator_link.dart';
|
||||
import '../../../../shared/widgets/formatted_content_text.dart';
|
||||
|
||||
class CloudMirrorsCard extends StatelessWidget {
|
||||
class CloudMirrorsCard extends StatefulWidget {
|
||||
const CloudMirrorsCard({
|
||||
super.key,
|
||||
required this.links,
|
||||
@@ -25,9 +25,21 @@ class CloudMirrorsCard extends StatelessWidget {
|
||||
final ValueChanged<String>? onDownloadStream;
|
||||
final bool initiallyExpanded;
|
||||
|
||||
@override
|
||||
State<CloudMirrorsCard> createState() => _CloudMirrorsCardState();
|
||||
}
|
||||
|
||||
class _CloudMirrorsCardState extends State<CloudMirrorsCard> {
|
||||
int _selectedTab = 0; // 0: mirrors, 1: commentary
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (links.isEmpty && (commentary == null || commentary!.trim().isEmpty)) {
|
||||
final links = widget.links;
|
||||
final commentary = widget.commentary;
|
||||
final hasLinks = links.isNotEmpty;
|
||||
final hasCommentary = commentary != null && commentary.trim().isNotEmpty;
|
||||
|
||||
if (!hasLinks && !hasCommentary) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
@@ -37,6 +49,14 @@ class CloudMirrorsCard extends StatelessWidget {
|
||||
.whereType<String>()
|
||||
.firstWhere((p) => p.isNotEmpty, orElse: () => '');
|
||||
|
||||
final cardTitle = hasLinks
|
||||
? (widget.strings.ru
|
||||
? 'Облачные диски и зеркала (${links.length})'
|
||||
: 'Cloud Mirrors & Drives (${links.length})')
|
||||
: (widget.strings.ru ? 'Описание от автора' : 'Artist Commentary');
|
||||
|
||||
final cardIcon = hasLinks ? Icons.cloud_sync_rounded : Icons.notes_rounded;
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surfaceContainerLow,
|
||||
@@ -49,20 +69,18 @@ class CloudMirrorsCard extends StatelessWidget {
|
||||
child: ExpansionTile(
|
||||
tilePadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 2),
|
||||
childrenPadding: const EdgeInsets.fromLTRB(14, 0, 14, 14),
|
||||
initiallyExpanded: initiallyExpanded,
|
||||
initiallyExpanded: widget.initiallyExpanded,
|
||||
shape: const Border(),
|
||||
collapsedShape: const Border(),
|
||||
leading: Icon(
|
||||
Icons.cloud_sync_rounded,
|
||||
cardIcon,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
title: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
strings.ru
|
||||
? 'Облачные диски и зеркала (${links.length})'
|
||||
: 'Cloud Mirrors & Drives (${links.length})',
|
||||
cardTitle,
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
@@ -77,7 +95,8 @@ class CloudMirrorsCard extends StatelessWidget {
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.green.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.green.withValues(alpha: 0.4)),
|
||||
border:
|
||||
Border.all(color: Colors.green.withValues(alpha: 0.4)),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
@@ -86,7 +105,7 @@ class CloudMirrorsCard extends StatelessWidget {
|
||||
size: 13, color: Colors.green),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
strings.ru ? 'Плеер' : 'Stream',
|
||||
widget.strings.ru ? 'Плеер' : 'Stream',
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: Colors.green,
|
||||
fontWeight: FontWeight.bold,
|
||||
@@ -116,7 +135,9 @@ class CloudMirrorsCard extends StatelessWidget {
|
||||
Expanded(
|
||||
child: Text.rich(
|
||||
TextSpan(
|
||||
text: strings.ru ? 'Пароль к архиву: ' : 'Archive Password: ',
|
||||
text: widget.strings.ru
|
||||
? 'Пароль к архиву: '
|
||||
: 'Archive Password: ',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurface,
|
||||
),
|
||||
@@ -135,13 +156,15 @@ class CloudMirrorsCard extends StatelessWidget {
|
||||
IconButton(
|
||||
icon: const Icon(Icons.copy_rounded, size: 16),
|
||||
visualDensity: VisualDensity.compact,
|
||||
tooltip: strings.ru ? 'Копировать пароль' : 'Copy Password',
|
||||
tooltip: widget.strings.ru
|
||||
? 'Копировать пароль'
|
||||
: 'Copy Password',
|
||||
onPressed: () {
|
||||
Clipboard.setData(ClipboardData(text: detectedPassword));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
strings.ru
|
||||
widget.strings.ru
|
||||
? 'Пароль скопирован: $detectedPassword'
|
||||
: 'Password copied: $detectedPassword',
|
||||
),
|
||||
@@ -157,63 +180,111 @@ class CloudMirrorsCard extends StatelessWidget {
|
||||
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,
|
||||
if (hasLinks && hasCommentary) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: SegmentedButton<int>(
|
||||
segments: [
|
||||
ButtonSegment(
|
||||
value: 0,
|
||||
icon: const Icon(Icons.cloud_queue_rounded, size: 16),
|
||||
label: Text(
|
||||
widget.strings.ru
|
||||
? 'Зеркала (${links.length})'
|
||||
: 'Mirrors (${links.length})',
|
||||
),
|
||||
),
|
||||
ButtonSegment(
|
||||
value: 1,
|
||||
icon: const Icon(Icons.notes_rounded, size: 16),
|
||||
label: Text(
|
||||
widget.strings.ru ? 'Описание' : 'Description',
|
||||
),
|
||||
),
|
||||
],
|
||||
selected: {_selectedTab},
|
||||
onSelectionChanged: (set) =>
|
||||
setState(() => _selectedTab = set.first),
|
||||
),
|
||||
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',
|
||||
),
|
||||
);
|
||||
},
|
||||
IndexedStack(
|
||||
index: _selectedTab,
|
||||
children: [
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: links
|
||||
.map((link) => _buildLinkTile(
|
||||
context,
|
||||
link,
|
||||
widget.strings,
|
||||
))
|
||||
.toList(),
|
||||
),
|
||||
_buildCommentarySection(context, commentary, theme),
|
||||
],
|
||||
),
|
||||
] else if (hasLinks) ...[
|
||||
...links.map((link) => _buildLinkTile(
|
||||
context,
|
||||
link,
|
||||
widget.strings,
|
||||
)),
|
||||
] else if (hasCommentary) ...[
|
||||
_buildCommentarySection(context, commentary, theme),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCommentarySection(
|
||||
BuildContext context,
|
||||
String text,
|
||||
ThemeData theme,
|
||||
) {
|
||||
final creatorLinks = CreatorLink.extractLinks(text);
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.notes_rounded,
|
||||
size: 16,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
widget.strings.ru ? 'Описание от автора' : 'Artist Commentary',
|
||||
style: theme.textTheme.labelLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
FormattedContentText(
|
||||
text: text.trim(),
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
height: 1.45,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
if (creatorLinks.isNotEmpty) ...[
|
||||
const SizedBox(height: 10),
|
||||
CreatorLinkChips(
|
||||
links: creatorLinks,
|
||||
title: widget.strings.ru ? 'Ссылки автора' : 'Author Links',
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLinkTile(
|
||||
BuildContext context,
|
||||
CloudMediaLink link,
|
||||
@@ -303,7 +374,7 @@ class CloudMirrorsCard extends StatelessWidget {
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
// Play button (if streamable)
|
||||
if (link.isStreamable && onPlayStream != null) ...[
|
||||
if (link.isStreamable && widget.onPlayStream != null) ...[
|
||||
FilledButton.tonalIcon(
|
||||
style: FilledButton.styleFrom(
|
||||
visualDensity: VisualDensity.compact,
|
||||
@@ -313,7 +384,7 @@ class CloudMirrorsCard extends StatelessWidget {
|
||||
label: Text(strings.ru ? 'Смотреть' : 'Play'),
|
||||
onPressed: () {
|
||||
final target = link.directStreamUrl ?? link.url;
|
||||
onPlayStream!(target);
|
||||
widget.onPlayStream!(target);
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
@@ -321,12 +392,13 @@ class CloudMirrorsCard extends StatelessWidget {
|
||||
// Download button (if direct streamable and callback exists)
|
||||
if (link.isStreamable &&
|
||||
link.directStreamUrl != null &&
|
||||
onDownloadStream != null) ...[
|
||||
widget.onDownloadStream != null) ...[
|
||||
IconButton.filledTonal(
|
||||
visualDensity: VisualDensity.compact,
|
||||
tooltip: strings.download,
|
||||
icon: const Icon(Icons.download_rounded, size: 16),
|
||||
onPressed: () => onDownloadStream!(link.directStreamUrl!),
|
||||
onPressed: () =>
|
||||
widget.onDownloadStream!(link.directStreamUrl!),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
|
||||
@@ -0,0 +1,501 @@
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../../../../backend/models/e621_pool.dart';
|
||||
import '../../../../backend/models/post.dart';
|
||||
import '../../../../backend/providers/e621_provider.dart';
|
||||
|
||||
class E621PoolSheet extends StatefulWidget {
|
||||
const E621PoolSheet({
|
||||
required this.currentPost,
|
||||
required this.poolId,
|
||||
required this.provider,
|
||||
required this.onSelectPostId,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final Post currentPost;
|
||||
final String poolId;
|
||||
final E621Provider provider;
|
||||
final ValueChanged<String> onSelectPostId;
|
||||
|
||||
static Future<void> show(
|
||||
BuildContext context, {
|
||||
required Post currentPost,
|
||||
required String poolId,
|
||||
required E621Provider provider,
|
||||
required ValueChanged<String> onSelectPostId,
|
||||
}) {
|
||||
return showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: Colors.transparent,
|
||||
isScrollControlled: true,
|
||||
builder: (context) => E621PoolSheet(
|
||||
currentPost: currentPost,
|
||||
poolId: poolId,
|
||||
provider: provider,
|
||||
onSelectPostId: onSelectPostId,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
State<E621PoolSheet> createState() => _E621PoolSheetState();
|
||||
}
|
||||
|
||||
class _E621PoolSheetState extends State<E621PoolSheet> {
|
||||
E621Pool? _pool;
|
||||
bool _isLoading = true;
|
||||
String? _error;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadPool();
|
||||
}
|
||||
|
||||
Future<void> _loadPool() async {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_error = null;
|
||||
});
|
||||
try {
|
||||
final pool = await widget.provider.getPool(widget.poolId);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_pool = pool;
|
||||
_isLoading = false;
|
||||
if (pool == null) {
|
||||
_error = 'Не удалось загрузить информацию о серии';
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_error = 'Ошибка: $e';
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final isDark = theme.brightness == Brightness.dark;
|
||||
final isRu = Localizations.maybeLocaleOf(context)?.languageCode == 'ru';
|
||||
final pool = _pool;
|
||||
|
||||
final currentPage = pool?.pageOf(widget.currentPost.id) ?? 0;
|
||||
final prevId = pool?.previousPostId(widget.currentPost.id);
|
||||
final nextId = pool?.nextPostId(widget.currentPost.id);
|
||||
|
||||
return DraggableScrollableSheet(
|
||||
initialChildSize: 0.55,
|
||||
minChildSize: 0.35,
|
||||
maxChildSize: 0.88,
|
||||
builder: (context, scrollController) {
|
||||
return ClipRRect(
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(28)),
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: isDark
|
||||
? const Color(0xFF161B22).withValues(alpha: 0.92)
|
||||
: Colors.white.withValues(alpha: 0.94),
|
||||
borderRadius:
|
||||
const BorderRadius.vertical(top: Radius.circular(28)),
|
||||
border: Border.all(
|
||||
color: isDark
|
||||
? Colors.white.withValues(alpha: 0.12)
|
||||
: Colors.black.withValues(alpha: 0.08),
|
||||
width: 1.1,
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.35),
|
||||
blurRadius: 28,
|
||||
offset: const Offset(0, -6),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: ListView(
|
||||
controller: scrollController,
|
||||
padding: const EdgeInsets.fromLTRB(20, 12, 20, 32),
|
||||
children: [
|
||||
// Drag handle
|
||||
Center(
|
||||
child: Container(
|
||||
width: 44,
|
||||
height: 5,
|
||||
decoration: BoxDecoration(
|
||||
color: isDark
|
||||
? Colors.white.withValues(alpha: 0.25)
|
||||
: Colors.black.withValues(alpha: 0.20),
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
if (_isLoading)
|
||||
const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(40),
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
)
|
||||
else if (_error != null)
|
||||
Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Column(
|
||||
children: [
|
||||
const Icon(Icons.error_outline_rounded,
|
||||
size: 40, color: Color(0xFFEF4444)),
|
||||
const SizedBox(height: 12),
|
||||
Text(_error!),
|
||||
const SizedBox(height: 12),
|
||||
FilledButton.tonal(
|
||||
onPressed: _loadPool,
|
||||
child: const Text('Повторить'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
else if (pool != null) ...[
|
||||
// Pool header
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
gradient: const LinearGradient(
|
||||
colors: [Color(0xFF0055AA), Color(0xFF0088FF)],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: const Color(0xFF0055AA)
|
||||
.withValues(alpha: 0.35),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.menu_book_rounded,
|
||||
color: Colors.white,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
pool.cleanTitle,
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
letterSpacing: -0.2,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 4,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF0055AA)
|
||||
.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: const Color(0xFF0055AA)
|
||||
.withValues(alpha: 0.30),
|
||||
width: 0.8,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
pool.category == 'series'
|
||||
? (isRu ? 'Комикс / Серия' : 'Series')
|
||||
: (isRu
|
||||
? 'Коллекция'
|
||||
: 'Collection'),
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFF0077EE),
|
||||
),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: isDark
|
||||
? Colors.white.withValues(alpha: 0.08)
|
||||
: Colors.black.withValues(alpha: 0.05),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
'${pool.postCount} ${isRu ? 'страниц' : 'pages'}',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: theme
|
||||
.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
if (pool.description.trim().isNotEmpty) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
pool.description.trim(),
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
maxLines: 4,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Big "Read Full Comic" Button
|
||||
FilledButton.icon(
|
||||
onPressed: () {
|
||||
HapticFeedback.mediumImpact();
|
||||
Navigator.of(context).pop();
|
||||
final startId = pool.postIds.contains(widget.currentPost.id)
|
||||
? widget.currentPost.id
|
||||
: pool.postIds.first;
|
||||
widget.onSelectPostId(startId);
|
||||
},
|
||||
icon: const Icon(Icons.auto_stories_rounded, size: 18),
|
||||
label: Text(
|
||||
isRu
|
||||
? 'Читать весь комикс (${pool.postCount} стр.)'
|
||||
: 'Read Full Comic (${pool.postCount} pages)',
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: const Color(0xFF0055AA),
|
||||
foregroundColor: Colors.white,
|
||||
minimumSize: const Size(double.infinity, 46),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Quick Page Navigator Banner
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: isDark
|
||||
? Colors.black.withValues(alpha: 0.25)
|
||||
: theme.colorScheme.surfaceContainerHighest
|
||||
.withValues(alpha: 0.5),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: isDark
|
||||
? Colors.white.withValues(alpha: 0.08)
|
||||
: Colors.black.withValues(alpha: 0.06),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
IconButton.filledTonal(
|
||||
tooltip: isRu ? 'Предыдущая' : 'Previous',
|
||||
onPressed: prevId == null
|
||||
? null
|
||||
: () {
|
||||
HapticFeedback.lightImpact();
|
||||
Navigator.of(context).pop();
|
||||
widget.onSelectPostId(prevId);
|
||||
},
|
||||
icon: const Icon(Icons.arrow_back_rounded, size: 18),
|
||||
),
|
||||
Expanded(
|
||||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
currentPage > 0
|
||||
? (isRu
|
||||
? 'Страница $currentPage из ${pool.postCount}'
|
||||
: 'Page $currentPage of ${pool.postCount}')
|
||||
: (isRu
|
||||
? 'Страниц в пуле: ${pool.postCount}'
|
||||
: '${pool.postCount} pages in pool'),
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
if (currentPage > 0)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 6),
|
||||
child: LinearProgressIndicator(
|
||||
value: (currentPage / pool.postCount)
|
||||
.clamp(0.0, 1.0),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
minHeight: 4,
|
||||
backgroundColor: isDark
|
||||
? Colors.white.withValues(alpha: 0.1)
|
||||
: Colors.black.withValues(alpha: 0.08),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
IconButton.filledTonal(
|
||||
tooltip: isRu ? 'Следующая' : 'Next',
|
||||
onPressed: nextId == null
|
||||
? null
|
||||
: () {
|
||||
HapticFeedback.lightImpact();
|
||||
Navigator.of(context).pop();
|
||||
widget.onSelectPostId(nextId);
|
||||
},
|
||||
icon: const Icon(Icons.arrow_forward_rounded,
|
||||
size: 18),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 18),
|
||||
|
||||
Text(
|
||||
isRu ? 'Все страницы серии:' : 'All pages in pool:',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
|
||||
// Grid of page buttons
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
for (int i = 0; i < pool.postIds.length; i++) ...[
|
||||
_PageButton(
|
||||
pageNumber: i + 1,
|
||||
postId: pool.postIds[i],
|
||||
isCurrent: pool.postIds[i] == widget.currentPost.id,
|
||||
onTap: () {
|
||||
if (pool.postIds[i] == widget.currentPost.id) {
|
||||
return;
|
||||
}
|
||||
HapticFeedback.lightImpact();
|
||||
Navigator.of(context).pop();
|
||||
widget.onSelectPostId(pool.postIds[i]);
|
||||
},
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PageButton extends StatelessWidget {
|
||||
const _PageButton({
|
||||
required this.pageNumber,
|
||||
required this.postId,
|
||||
required this.isCurrent,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final int pageNumber;
|
||||
final String postId;
|
||||
final bool isCurrent;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final isDark = theme.brightness == Brightness.dark;
|
||||
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
constraints: const BoxConstraints(minWidth: 42, minHeight: 40),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: isCurrent
|
||||
? const Color(0xFF0055AA)
|
||||
: (isDark
|
||||
? Colors.white.withValues(alpha: 0.08)
|
||||
: Colors.black.withValues(alpha: 0.05)),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: isCurrent
|
||||
? const Color(0xFF0077EE)
|
||||
: (isDark
|
||||
? Colors.white.withValues(alpha: 0.12)
|
||||
: Colors.black.withValues(alpha: 0.08)),
|
||||
width: isCurrent ? 1.4 : 0.8,
|
||||
),
|
||||
boxShadow: isCurrent
|
||||
? [
|
||||
BoxShadow(
|
||||
color: const Color(0xFF0055AA).withValues(alpha: 0.40),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
]
|
||||
: null,
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'$pageNumber',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: isCurrent ? FontWeight.bold : FontWeight.w600,
|
||||
color: isCurrent
|
||||
? Colors.white
|
||||
: theme.colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../../../../backend/models/post.dart';
|
||||
import '../../../../backend/providers/e621_provider.dart';
|
||||
import 'e621_pool_sheet.dart';
|
||||
|
||||
class PostPoolsCard extends StatelessWidget {
|
||||
const PostPoolsCard({
|
||||
required this.post,
|
||||
required this.provider,
|
||||
required this.onOpenComic,
|
||||
required this.onOpenPostId,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final Post post;
|
||||
final E621Provider provider;
|
||||
final void Function(String poolId, Post currentPost, [String? targetId])
|
||||
onOpenComic;
|
||||
final ValueChanged<String> onOpenPostId;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!post.hasPools) return const SizedBox.shrink();
|
||||
|
||||
final theme = Theme.of(context);
|
||||
final isDark = theme.brightness == Brightness.dark;
|
||||
final isRu = Localizations.maybeLocaleOf(context)?.languageCode == 'ru';
|
||||
final pools = post.poolIds;
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: isDark
|
||||
? theme.colorScheme.surfaceContainerHigh.withValues(alpha: 0.70)
|
||||
: theme.colorScheme.surfaceContainerLow,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(
|
||||
color: isDark
|
||||
? Colors.white.withValues(alpha: 0.12)
|
||||
: theme.colorScheme.outlineVariant.withValues(alpha: 0.40),
|
||||
width: 1.1,
|
||||
),
|
||||
),
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.menu_book_rounded,
|
||||
size: 20,
|
||||
color: Color(0xFF0077EE),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
isRu ? 'Входит в комикс / серию' : 'Part of Series / Pool',
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF0055AA).withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
'${pools.length}',
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Color(0xFF0077EE),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
for (final poolId in pools)
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
HapticFeedback.lightImpact();
|
||||
onOpenComic(poolId, post);
|
||||
},
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 14, vertical: 9),
|
||||
decoration: BoxDecoration(
|
||||
gradient: const LinearGradient(
|
||||
colors: [Color(0xFF0055AA), Color(0xFF0077EE)],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: const Color(0xFF0055AA)
|
||||
.withValues(alpha: 0.25),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 3),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.auto_stories_rounded,
|
||||
size: 16, color: Colors.white),
|
||||
const SizedBox(width: 7),
|
||||
Text(
|
||||
isRu
|
||||
? 'Читать весь комикс (#$poolId)'
|
||||
: 'Read Full Comic (#$poolId)',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 5),
|
||||
const Icon(Icons.arrow_forward_rounded,
|
||||
size: 13, color: Colors.white),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
IconButton.filledTonal(
|
||||
tooltip: isRu ? 'Все страницы' : 'All pages',
|
||||
style: IconButton.styleFrom(
|
||||
visualDensity: VisualDensity.compact,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
),
|
||||
onPressed: () {
|
||||
HapticFeedback.lightImpact();
|
||||
E621PoolSheet.show(
|
||||
context,
|
||||
currentPost: post,
|
||||
poolId: poolId,
|
||||
provider: provider,
|
||||
onSelectPostId: (targetId) =>
|
||||
onOpenComic(poolId, post, targetId),
|
||||
);
|
||||
},
|
||||
icon: const Icon(Icons.grid_view_rounded, size: 16),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../../../../backend/models/post.dart';
|
||||
|
||||
class PostRelationsCard extends StatelessWidget {
|
||||
const PostRelationsCard({
|
||||
required this.post,
|
||||
required this.onOpenPostId,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final Post post;
|
||||
final ValueChanged<String> onOpenPostId;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!post.hasRelations) return const SizedBox.shrink();
|
||||
|
||||
final theme = Theme.of(context);
|
||||
final isDark = theme.brightness == Brightness.dark;
|
||||
final isRu = Localizations.maybeLocaleOf(context)?.languageCode == 'ru';
|
||||
|
||||
final parentId = post.parentId;
|
||||
final children = post.childrenIds;
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: isDark
|
||||
? theme.colorScheme.surfaceContainerHigh.withValues(alpha: 0.70)
|
||||
: theme.colorScheme.surfaceContainerLow,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(
|
||||
color: isDark
|
||||
? Colors.white.withValues(alpha: 0.12)
|
||||
: theme.colorScheme.outlineVariant.withValues(alpha: 0.40),
|
||||
width: 1.1,
|
||||
),
|
||||
),
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.copy_all_rounded,
|
||||
size: 20,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
isRu ? 'Связанные посты и версии' : 'Variations & Relations',
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.primary.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
'${(parentId != null ? 1 : 0) + children.length}',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
if (parentId != null)
|
||||
_RelationPill(
|
||||
icon: Icons.north_west_rounded,
|
||||
label: isRu ? 'Родительский арт #$parentId' : 'Parent #$parentId',
|
||||
color: const Color(0xFF0077EE),
|
||||
onTap: () {
|
||||
HapticFeedback.lightImpact();
|
||||
onOpenPostId(parentId);
|
||||
},
|
||||
),
|
||||
for (final childId in children)
|
||||
_RelationPill(
|
||||
icon: Icons.style_rounded,
|
||||
label: isRu ? 'Версия #$childId' : 'Variant #$childId',
|
||||
color: const Color(0xFF10B981),
|
||||
onTap: () {
|
||||
HapticFeedback.lightImpact();
|
||||
onOpenPostId(childId);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RelationPill extends StatelessWidget {
|
||||
const _RelationPill({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.color,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final Color color;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 7),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: isDark ? 0.15 : 0.10),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: color.withValues(alpha: isDark ? 0.35 : 0.25),
|
||||
width: 0.9,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 14, color: color),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Icon(Icons.arrow_forward_ios_rounded, size: 10, color: color),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -288,14 +288,6 @@ class _ProviderFormScreenState extends ConsumerState<ProviderFormScreen> {
|
||||
value: 'realbooru_html',
|
||||
child: Text('Realbooru HTML'),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: 'kemono',
|
||||
child: Text('Kemono Party artists'),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: 'coomer',
|
||||
child: Text('Coomer Party artists'),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: 'custom',
|
||||
child: Text('Custom REST API'),
|
||||
@@ -494,7 +486,7 @@ class _ProviderFormScreenState extends ConsumerState<ProviderFormScreen> {
|
||||
child: _GlassTextField(
|
||||
controller: _headers,
|
||||
label: isRu ? 'Пользовательские заголовки' : 'Headers JSON',
|
||||
hint: '{"User-Agent": "Lunaris/3.0"}',
|
||||
hint: '{"User-Agent": "Prisma/3.0"}',
|
||||
icon: Icons.code_rounded,
|
||||
minLines: 2,
|
||||
maxLines: 5,
|
||||
|
||||
@@ -358,6 +358,9 @@ class ProvidersScreen extends ConsumerWidget {
|
||||
'/providers/new',
|
||||
extra: item,
|
||||
),
|
||||
onSaveConfig: (updated) => ref
|
||||
.read(providersControllerProvider.notifier)
|
||||
.save(updated),
|
||||
onDelete: () async {
|
||||
final ok = await showConfirmDialog(
|
||||
context,
|
||||
|
||||
@@ -0,0 +1,490 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import '../../../../backend/models/content_provider_config.dart';
|
||||
|
||||
class E621AuthDialog extends StatefulWidget {
|
||||
const E621AuthDialog({
|
||||
required this.config,
|
||||
required this.onSaved,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final ContentProviderConfig config;
|
||||
final ValueChanged<ContentProviderConfig> onSaved;
|
||||
|
||||
static Future<void> show(
|
||||
BuildContext context, {
|
||||
required ContentProviderConfig config,
|
||||
required ValueChanged<ContentProviderConfig> onSaved,
|
||||
}) {
|
||||
return showDialog(
|
||||
context: context,
|
||||
barrierColor: Colors.black54,
|
||||
builder: (context) => E621AuthDialog(
|
||||
config: config,
|
||||
onSaved: onSaved,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
State<E621AuthDialog> createState() => _E621AuthDialogState();
|
||||
}
|
||||
|
||||
class _E621AuthDialogState extends State<E621AuthDialog> {
|
||||
late final TextEditingController _loginController;
|
||||
late final TextEditingController _apiKeyController;
|
||||
bool _obscureKey = true;
|
||||
bool _isLoading = false;
|
||||
String? _statusMessage;
|
||||
bool _isSuccess = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final login = widget.config.customHeaders['query.login'] ?? '';
|
||||
final apiKey = widget.config.customHeaders['query.api_key'] ?? '';
|
||||
_loginController = TextEditingController(text: login);
|
||||
_apiKeyController = TextEditingController(text: apiKey);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_loginController.dispose();
|
||||
_apiKeyController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _testAndSave() async {
|
||||
final login = _loginController.text.trim();
|
||||
final apiKey = _apiKeyController.text.trim();
|
||||
|
||||
if (login.isEmpty || apiKey.isEmpty) {
|
||||
setState(() {
|
||||
_statusMessage = 'Укажите логин и API ключ';
|
||||
_isSuccess = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_statusMessage = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final dio = Dio(
|
||||
BaseOptions(
|
||||
baseUrl: widget.config.baseUrl,
|
||||
connectTimeout: const Duration(seconds: 10),
|
||||
receiveTimeout: const Duration(seconds: 10),
|
||||
headers: {
|
||||
'Authorization':
|
||||
'Basic ${base64Encode(utf8.encode('$login:$apiKey'))}',
|
||||
'User-Agent': 'Prisma/3.6.6 (by $login on e621)',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
final response = await dio.get<dynamic>('/posts.json', queryParameters: {
|
||||
'limit': 1,
|
||||
});
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final updatedHeaders =
|
||||
Map<String, String>.from(widget.config.customHeaders)
|
||||
..['query.login'] = login
|
||||
..['query.api_key'] = apiKey;
|
||||
|
||||
final updatedConfig = widget.config.copyWith(
|
||||
customHeaders: updatedHeaders,
|
||||
updatedAt: DateTime.now(),
|
||||
);
|
||||
|
||||
widget.onSaved(updatedConfig);
|
||||
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
_isSuccess = true;
|
||||
_statusMessage = 'Успешно авторизован! Лимит тегов снят.';
|
||||
});
|
||||
|
||||
await Future<void>.delayed(const Duration(milliseconds: 650));
|
||||
if (mounted) Navigator.of(context).pop();
|
||||
} else {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
_isSuccess = false;
|
||||
_statusMessage =
|
||||
'Ошибка проверки (код ${response.statusCode}). Проверьте логин и ключ.';
|
||||
});
|
||||
}
|
||||
} on DioException catch (e) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
_isSuccess = false;
|
||||
if (e.response?.statusCode == 401) {
|
||||
_statusMessage = 'Неверный логин или API-ключ (401 Unauthorized)';
|
||||
} else if (e.response?.statusCode == 403) {
|
||||
_statusMessage = 'Доступ запрещен (403 Forbidden). Проверьте API access в профиле e621.';
|
||||
} else {
|
||||
_statusMessage = 'Ошибка подключения: ${e.message ?? e.toString()}';
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
_isSuccess = false;
|
||||
_statusMessage = 'Ошибка: $e';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _clearAuth() {
|
||||
final updatedHeaders =
|
||||
Map<String, String>.from(widget.config.customHeaders)
|
||||
..remove('query.login')
|
||||
..remove('query.api_key');
|
||||
|
||||
final updatedConfig = widget.config.copyWith(
|
||||
customHeaders: updatedHeaders,
|
||||
updatedAt: DateTime.now(),
|
||||
);
|
||||
|
||||
widget.onSaved(updatedConfig);
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final isDark = theme.brightness == Brightness.dark;
|
||||
final currentLogin = widget.config.customHeaders['query.login'];
|
||||
final isAlreadyAuthorized =
|
||||
currentLogin != null && currentLogin.trim().isNotEmpty;
|
||||
|
||||
return Dialog(
|
||||
backgroundColor: Colors.transparent,
|
||||
insetPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 24),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 480),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: BoxDecoration(
|
||||
color: isDark
|
||||
? const Color(0xFF1E2430).withValues(alpha: 0.90)
|
||||
: Colors.white.withValues(alpha: 0.94),
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
border: Border.all(
|
||||
color: isDark
|
||||
? const Color(0xFF0055AA).withValues(alpha: 0.40)
|
||||
: const Color(0xFF0055AA).withValues(alpha: 0.20),
|
||||
width: 1.2,
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: const Color(0xFF0055AA).withValues(alpha: 0.15),
|
||||
blurRadius: 30,
|
||||
offset: const Offset(0, 10),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Title row
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
gradient: const LinearGradient(
|
||||
colors: [Color(0xFF0055AA), Color(0xFF0077EE)],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: const Color(0xFF0055AA).withValues(alpha: 0.35),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.vpn_key_rounded,
|
||||
color: Colors.white,
|
||||
size: 22,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Авторизация e621',
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
letterSpacing: -0.2,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
isAlreadyAuthorized
|
||||
? 'Вход выполнен как $currentLogin'
|
||||
: 'Снятие ограничений поиска и функций',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: isAlreadyAuthorized
|
||||
? const Color(0xFF10B981)
|
||||
: theme.colorScheme.onSurfaceVariant,
|
||||
fontWeight: isAlreadyAuthorized
|
||||
? FontWeight.w600
|
||||
: FontWeight.normal,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close_rounded),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 18),
|
||||
|
||||
// Benefits badge
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF0055AA).withValues(alpha: isDark ? 0.15 : 0.08),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: const Color(0xFF0055AA).withValues(alpha: 0.25),
|
||||
),
|
||||
),
|
||||
child: const Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_BenefitRow(
|
||||
icon: Icons.check_circle_outline_rounded,
|
||||
text: 'Снятие лимита в 2 тега (поиск по 4–6+ тегам)',
|
||||
),
|
||||
SizedBox(height: 6),
|
||||
_BenefitRow(
|
||||
icon: Icons.favorite_border_rounded,
|
||||
text: 'Серверная синхронизация Избранного (Favorites)',
|
||||
),
|
||||
SizedBox(height: 6),
|
||||
_BenefitRow(
|
||||
icon: Icons.thumb_up_alt_outlined,
|
||||
text: 'Голосование за посты (Upvote / Downvote)',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Username field
|
||||
TextField(
|
||||
controller: _loginController,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Имя пользователя (Username на e621)',
|
||||
prefixIcon: const Icon(Icons.person_outline_rounded),
|
||||
filled: true,
|
||||
fillColor: isDark
|
||||
? Colors.black.withValues(alpha: 0.25)
|
||||
: Colors.grey.shade100,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// API Key field
|
||||
TextField(
|
||||
controller: _apiKeyController,
|
||||
obscureText: _obscureKey,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'API Ключ (API Key)',
|
||||
prefixIcon: const Icon(Icons.key_rounded),
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(
|
||||
_obscureKey
|
||||
? Icons.visibility_off_rounded
|
||||
: Icons.visibility_rounded,
|
||||
),
|
||||
onPressed: () =>
|
||||
setState(() => _obscureKey = !_obscureKey),
|
||||
),
|
||||
filled: true,
|
||||
fillColor: isDark
|
||||
? Colors.black.withValues(alpha: 0.25)
|
||||
: Colors.grey.shade100,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 10),
|
||||
|
||||
// How to get API key link
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: TextButton.icon(
|
||||
onPressed: () {
|
||||
launchUrl(
|
||||
Uri.parse('https://e621.net/users/home'),
|
||||
mode: LaunchMode.externalApplication,
|
||||
);
|
||||
},
|
||||
icon: const Icon(Icons.open_in_new_rounded, size: 14),
|
||||
label: const Text(
|
||||
'Как получить ключ (e621 -> Manage API Access)',
|
||||
style: TextStyle(fontSize: 12),
|
||||
),
|
||||
style: TextButton.styleFrom(
|
||||
padding: EdgeInsets.zero,
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
if (_statusMessage != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: _isSuccess
|
||||
? const Color(0xFF10B981).withValues(alpha: 0.15)
|
||||
: const Color(0xFFEF4444).withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: _isSuccess
|
||||
? const Color(0xFF10B981).withValues(alpha: 0.40)
|
||||
: const Color(0xFFEF4444).withValues(alpha: 0.40),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
_isSuccess
|
||||
? Icons.check_circle_rounded
|
||||
: Icons.error_outline_rounded,
|
||||
size: 16,
|
||||
color: _isSuccess
|
||||
? const Color(0xFF10B981)
|
||||
: const Color(0xFFEF4444),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
_statusMessage!,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: _isSuccess
|
||||
? const Color(0xFF10B981)
|
||||
: const Color(0xFFEF4444),
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// Action buttons
|
||||
Row(
|
||||
children: [
|
||||
if (isAlreadyAuthorized) ...[
|
||||
TextButton.icon(
|
||||
onPressed: _isLoading ? null : _clearAuth,
|
||||
icon: const Icon(Icons.logout_rounded,
|
||||
size: 16, color: Color(0xFFEF4444)),
|
||||
label: const Text('Выйти',
|
||||
style: TextStyle(color: Color(0xFFEF4444))),
|
||||
),
|
||||
const Spacer(),
|
||||
] else
|
||||
const Spacer(),
|
||||
FilledButton.icon(
|
||||
onPressed: _isLoading ? null : _testAndSave,
|
||||
icon: _isLoading
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Colors.white,
|
||||
),
|
||||
)
|
||||
: const Icon(Icons.check_rounded, size: 18),
|
||||
label: Text(_isLoading ? 'Проверка...' : 'Сохранить'),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: const Color(0xFF0055AA),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 20, vertical: 12),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _BenefitRow extends StatelessWidget {
|
||||
const _BenefitRow({required this.icon, required this.text});
|
||||
|
||||
final IconData icon;
|
||||
final String text;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
Icon(icon, size: 14, color: const Color(0xFF0077EE)),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
text,
|
||||
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w500),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../../../../backend/backend.dart';
|
||||
import 'e621_auth_dialog.dart';
|
||||
|
||||
class ProviderCard extends StatelessWidget {
|
||||
const ProviderCard({
|
||||
@@ -9,6 +10,7 @@ class ProviderCard extends StatelessWidget {
|
||||
required this.onToggle,
|
||||
required this.onEdit,
|
||||
required this.onDelete,
|
||||
this.onSaveConfig,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@@ -16,6 +18,7 @@ class ProviderCard extends StatelessWidget {
|
||||
final ValueChanged<bool> onToggle;
|
||||
final VoidCallback onEdit;
|
||||
final VoidCallback onDelete;
|
||||
final ValueChanged<ContentProviderConfig>? onSaveConfig;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -164,6 +167,16 @@ class ProviderCard extends StatelessWidget {
|
||||
color: theme.colorScheme.primary,
|
||||
isDark: isDark,
|
||||
),
|
||||
if (config.apiType.toLowerCase() == 'e621')
|
||||
_GlassPill(
|
||||
label: (config.customHeaders['query.login']?.isNotEmpty ?? false)
|
||||
? 'e621: ${config.customHeaders['query.login']}'
|
||||
: (isRu ? 'e621: Гость (макс 2 тега)' : 'e621: Guest (max 2 tags)'),
|
||||
color: (config.customHeaders['query.login']?.isNotEmpty ?? false)
|
||||
? const Color(0xFF10B981)
|
||||
: Colors.amber,
|
||||
isDark: isDark,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
@@ -231,10 +244,30 @@ class ProviderCard extends StatelessWidget {
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Row 3: Action Buttons (Edit & Delete)
|
||||
// Row 3: Action Buttons (Edit & Delete & e621 Auth)
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
if (config.apiType.toLowerCase() == 'e621' && onSaveConfig != null) ...[
|
||||
_GlassActionButton(
|
||||
icon: Icons.vpn_key_rounded,
|
||||
label: (config.customHeaders['query.login']?.isNotEmpty ?? false)
|
||||
? (isRu ? 'Аккаунт' : 'Account')
|
||||
: (isRu ? 'API Ключ' : 'API Key'),
|
||||
color: const Color(0xFF0077EE),
|
||||
isDark: isDark,
|
||||
onTap: () {
|
||||
HapticFeedback.lightImpact();
|
||||
E621AuthDialog.show(
|
||||
context,
|
||||
config: config,
|
||||
onSaved: onSaveConfig!,
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
|
||||
// Edit Action Button
|
||||
_GlassActionButton(
|
||||
icon: Icons.edit_rounded,
|
||||
@@ -305,16 +338,6 @@ class ProviderCard extends StatelessWidget {
|
||||
Icons.pets_rounded,
|
||||
'e621 / e926',
|
||||
),
|
||||
'kemono' => (
|
||||
const Color(0xFFEC4899),
|
||||
Icons.palette_rounded,
|
||||
'Kemono',
|
||||
),
|
||||
'coomer' => (
|
||||
const Color(0xFF06B6D4),
|
||||
Icons.collections_rounded,
|
||||
'Coomer',
|
||||
),
|
||||
_ => (
|
||||
const Color(0xFF6366F1),
|
||||
Icons.hub_rounded,
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../app/app.dart';
|
||||
import '../../../app/app_navigator.dart';
|
||||
import '../../../backend/backend.dart';
|
||||
import '../../../core/utils/result.dart';
|
||||
import '../../../shared/widgets/adaptive_scaffold.dart';
|
||||
@@ -62,9 +62,9 @@ class HiddenPostsScreen extends ConsumerWidget {
|
||||
item: item,
|
||||
onOpen: item.post == null
|
||||
? null
|
||||
: () => context.push(
|
||||
'/post/${item.post!.providerId}/${item.post!.id}',
|
||||
extra: item.post,
|
||||
: () => AppNavigator.openPost(
|
||||
context,
|
||||
post: item.post!,
|
||||
),
|
||||
onRestore: () async {
|
||||
await ref
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'dart:io';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart' hide appBuildNumber;
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
@@ -403,7 +404,7 @@ class _SettingsContentState extends ConsumerState<_SettingsContent> {
|
||||
? 'Beta и экспериментальные обновления'
|
||||
: 'Receive beta & experimental updates',
|
||||
subtitle: isRu
|
||||
? 'Проверка и уведомление о prerelease сборках Lunaris'
|
||||
? 'Проверка и уведомление о prerelease сборках Prisma'
|
||||
: 'Include prerelease builds when checking for updates',
|
||||
value: settings.allowExperimentalUpdates,
|
||||
onChanged: (val) => _update(
|
||||
@@ -1278,7 +1279,7 @@ class _SettingsContentState extends ConsumerState<_SettingsContent> {
|
||||
messenger.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
isRu ? 'У вас установлена последняя версия Lunaris' : 'Lunaris is up to date',
|
||||
isRu ? 'У вас установлена последняя версия Prisma' : 'Prisma is up to date',
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -1295,7 +1296,7 @@ class _SettingsContentState extends ConsumerState<_SettingsContent> {
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text('Lunaris ${info.version} is available'),
|
||||
title: Text('Prisma ${info.version} is available'),
|
||||
content: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 520),
|
||||
child: SingleChildScrollView(
|
||||
@@ -1356,7 +1357,7 @@ class _SettingsContentState extends ConsumerState<_SettingsContent> {
|
||||
return showDialog<void>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Lunaris changelog'),
|
||||
title: const Text('Prisma changelog'),
|
||||
content: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 560),
|
||||
child: SingleChildScrollView(
|
||||
@@ -1364,7 +1365,7 @@ class _SettingsContentState extends ConsumerState<_SettingsContent> {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
for (final change in lunarisChangelog) ...[
|
||||
for (final change in prismaChangelog) ...[
|
||||
Text(
|
||||
'${change.version} - ${change.title}',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
@@ -1439,7 +1440,7 @@ class _SettingsIconBadge extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _CategoryQuickNav extends StatelessWidget {
|
||||
class _CategoryQuickNav extends StatefulWidget {
|
||||
const _CategoryQuickNav({
|
||||
required this.categories,
|
||||
required this.onSelect,
|
||||
@@ -1449,103 +1450,143 @@ class _CategoryQuickNav extends StatelessWidget {
|
||||
categories;
|
||||
final ValueChanged<GlobalKey> onSelect;
|
||||
|
||||
@override
|
||||
State<_CategoryQuickNav> createState() => _CategoryQuickNavState();
|
||||
}
|
||||
|
||||
class _CategoryQuickNavState extends State<_CategoryQuickNav> {
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
bool _isHovered = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final isDark = theme.brightness == Brightness.dark;
|
||||
|
||||
return SizedBox(
|
||||
height: 44,
|
||||
child: ListView.separated(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: categories.length,
|
||||
separatorBuilder: (_, __) => const SizedBox(width: 8),
|
||||
itemBuilder: (context, index) {
|
||||
final cat = categories[index];
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: isDark
|
||||
? Colors.black.withValues(alpha: 0.25)
|
||||
: cat.color.withValues(alpha: 0.12),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2.5),
|
||||
return MouseRegion(
|
||||
onEnter: (_) => _isHovered = true,
|
||||
onExit: (_) => _isHovered = false,
|
||||
child: Listener(
|
||||
onPointerSignal: (event) {
|
||||
if (!_isHovered ||
|
||||
event is! PointerScrollEvent ||
|
||||
!_scrollController.hasClients) {
|
||||
return;
|
||||
}
|
||||
final delta = event.scrollDelta.dy != 0
|
||||
? event.scrollDelta.dy
|
||||
: event.scrollDelta.dx;
|
||||
if (delta == 0) return;
|
||||
final target = (_scrollController.offset + delta * 1.5).clamp(
|
||||
0.0,
|
||||
_scrollController.position.maxScrollExtent,
|
||||
);
|
||||
_scrollController.jumpTo(target);
|
||||
},
|
||||
child: SizedBox(
|
||||
height: 44,
|
||||
child: ListView.separated(
|
||||
controller: _scrollController,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: widget.categories.length,
|
||||
separatorBuilder: (_, __) => const SizedBox(width: 8),
|
||||
itemBuilder: (context, index) {
|
||||
final cat = widget.categories[index];
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: isDark
|
||||
? Colors.black.withValues(alpha: 0.25)
|
||||
: cat.color.withValues(alpha: 0.12),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2.5),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 16, sigmaY: 16),
|
||||
child: Material(
|
||||
color: isDark
|
||||
? theme.colorScheme.surfaceContainerHigh.withValues(alpha: 0.60)
|
||||
: Colors.white.withValues(alpha: 0.82),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
onTap: () {
|
||||
HapticFeedback.selectionClick();
|
||||
onSelect(cat.key);
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7),
|
||||
decoration: BoxDecoration(
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 16, sigmaY: 16),
|
||||
child: Material(
|
||||
color: isDark
|
||||
? theme.colorScheme.surfaceContainerHigh
|
||||
.withValues(alpha: 0.60)
|
||||
: Colors.white.withValues(alpha: 0.82),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
border: Border.all(
|
||||
color: isDark
|
||||
? Colors.white.withValues(alpha: 0.18)
|
||||
: Colors.white.withValues(alpha: 0.85),
|
||||
width: 1.1,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 24,
|
||||
height: 24,
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [
|
||||
cat.color,
|
||||
cat.color.withValues(alpha: 0.82),
|
||||
],
|
||||
),
|
||||
borderRadius: BorderRadius.circular(7),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: cat.color.withValues(alpha: 0.35),
|
||||
blurRadius: 5,
|
||||
offset: const Offset(0, 1.5),
|
||||
onTap: () {
|
||||
HapticFeedback.selectionClick();
|
||||
widget.onSelect(cat.key);
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12, vertical: 7),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
border: Border.all(
|
||||
color: isDark
|
||||
? Colors.white.withValues(alpha: 0.18)
|
||||
: Colors.white.withValues(alpha: 0.85),
|
||||
width: 1.1,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 24,
|
||||
height: 24,
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [
|
||||
cat.color,
|
||||
cat.color.withValues(alpha: 0.82),
|
||||
],
|
||||
),
|
||||
borderRadius: BorderRadius.circular(7),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: cat.color.withValues(alpha: 0.35),
|
||||
blurRadius: 5,
|
||||
offset: const Offset(0, 1.5),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Center(
|
||||
child: Icon(cat.icon, size: 14, color: Colors.white),
|
||||
),
|
||||
child: Center(
|
||||
child: Icon(cat.icon,
|
||||
size: 14, color: Colors.white),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
cat.label,
|
||||
style: theme.textTheme.labelLarge?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: -0.2,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
cat.label,
|
||||
style: theme.textTheme.labelLarge?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: -0.2,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -2341,7 +2382,7 @@ class _ColorSwatches extends StatelessWidget {
|
||||
final ValueChanged<int> onChanged;
|
||||
|
||||
static const colors = <(int, String)>[
|
||||
(0xFFE84D8A, 'Lunaris pink'),
|
||||
(0xFFE84D8A, 'Prisma pink'),
|
||||
(0xFF8B5CF6, 'Violet'),
|
||||
(0xFF3B82F6, 'Azure'),
|
||||
(0xFF0EA5E9, 'Sky'),
|
||||
@@ -2565,7 +2606,7 @@ class _HeroBrandBanner extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
Text(
|
||||
'Lunaris',
|
||||
'Prisma',
|
||||
style: theme.textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: -0.5,
|
||||
@@ -2619,7 +2660,7 @@ class _HeroBrandBanner extends StatelessWidget {
|
||||
OutlinedButton.icon(
|
||||
onPressed: () async {
|
||||
final url = Uri.parse(
|
||||
'https://github.com/RarDog/Lunaris',
|
||||
'https://github.com/RarDog/Prisma',
|
||||
);
|
||||
await launchUrl(url, mode: LaunchMode.externalApplication);
|
||||
},
|
||||
|
||||
@@ -7,6 +7,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../app/app.dart';
|
||||
import '../../../app/app_navigator.dart';
|
||||
import '../../../app/responsive.dart';
|
||||
import '../../../backend/backend.dart';
|
||||
import '../../../shared/widgets/adaptive_scaffold.dart';
|
||||
@@ -142,12 +143,10 @@ class _ViewedScreenState extends ConsumerState<ViewedScreen> {
|
||||
mediaQualityMode:
|
||||
MediaQualityMode.fromName(settings.mediaQualityMode),
|
||||
favoriteKeys: favoriteKeys,
|
||||
onOpen: (post) => context.push(
|
||||
'/post/${post.providerId}/${post.id}',
|
||||
extra: PostNavigationContext(
|
||||
currentPost: post,
|
||||
posts: allPosts,
|
||||
),
|
||||
onOpen: (post) => AppNavigator.openPost(
|
||||
context,
|
||||
post: post,
|
||||
postsList: allPosts,
|
||||
),
|
||||
onFavorite: (post) => _toggleFavorite(
|
||||
ref,
|
||||
@@ -260,12 +259,10 @@ class _ViewedScreenState extends ConsumerState<ViewedScreen> {
|
||||
isFavorite: favoriteKeys.contains(item.post.cacheKey),
|
||||
isRu: isRu,
|
||||
onOpen: () {
|
||||
context.push(
|
||||
'/post/${item.post.providerId}/${item.post.id}',
|
||||
extra: PostNavigationContext(
|
||||
currentPost: item.post,
|
||||
posts: allPosts,
|
||||
),
|
||||
AppNavigator.openPost(
|
||||
context,
|
||||
post: item.post,
|
||||
postsList: allPosts,
|
||||
);
|
||||
},
|
||||
onFavorite: () => _toggleFavorite(
|
||||
@@ -430,6 +427,8 @@ class _ViewedPostTile extends StatelessWidget {
|
||||
)
|
||||
: CachedNetworkImage(
|
||||
imageUrl: image,
|
||||
memCacheWidth: 320,
|
||||
memCacheHeight: 320,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (_, __) => Container(
|
||||
color: scheme.surfaceContainerHighest,
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:media_kit/media_kit.dart';
|
||||
|
||||
import 'app/app.dart';
|
||||
|
||||
void main() {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
MediaKit.ensureInitialized();
|
||||
// Optimize image cache to prevent memory bloat and OOM crashes
|
||||
PaintingBinding.instance.imageCache.maximumSizeBytes = 180 * 1024 * 1024; // 180 MB
|
||||
PaintingBinding.instance.imageCache.maximumSize = 120; // 120 images
|
||||
runApp(const ProviderScope(child: GelRuleApp()));
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ class AdaptiveScaffold extends StatelessWidget {
|
||||
this.titleWidget,
|
||||
this.actions = const [],
|
||||
this.floatingActionButton,
|
||||
this.resizeToAvoidBottomInset = false,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@@ -17,11 +18,13 @@ class AdaptiveScaffold extends StatelessWidget {
|
||||
final Widget body;
|
||||
final List<Widget> actions;
|
||||
final Widget? floatingActionButton;
|
||||
final bool resizeToAvoidBottomInset;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (Responsive.isMobile(context)) {
|
||||
return Scaffold(
|
||||
resizeToAvoidBottomInset: resizeToAvoidBottomInset,
|
||||
appBar: AppBar(
|
||||
title: titleWidget ?? Text(title),
|
||||
actions: actions,
|
||||
@@ -35,6 +38,7 @@ class AdaptiveScaffold extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
return Scaffold(
|
||||
resizeToAvoidBottomInset: resizeToAvoidBottomInset,
|
||||
appBar: AppBar(title: titleWidget ?? Text(title), actions: actions),
|
||||
body: body,
|
||||
floatingActionButton: floatingActionButton,
|
||||
|
||||
@@ -0,0 +1,321 @@
|
||||
import 'dart:ui';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class DesktopShortcutsDialog extends StatelessWidget {
|
||||
const DesktopShortcutsDialog({super.key});
|
||||
|
||||
static Future<void> show(BuildContext context) {
|
||||
return showDialog(
|
||||
context: context,
|
||||
barrierColor: Colors.black54,
|
||||
builder: (_) => const DesktopShortcutsDialog(),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final scheme = theme.colorScheme;
|
||||
final isDark = theme.brightness == Brightness.dark;
|
||||
|
||||
return Dialog(
|
||||
backgroundColor: Colors.transparent,
|
||||
insetPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 32),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 720, maxHeight: 680),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: isDark
|
||||
? scheme.surfaceContainerHigh.withValues(alpha: 0.88)
|
||||
: Colors.white.withValues(alpha: 0.94),
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
border: Border.all(
|
||||
color: isDark
|
||||
? Colors.white.withValues(alpha: 0.16)
|
||||
: scheme.outlineVariant.withValues(alpha: 0.4),
|
||||
width: 1.2,
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: isDark ? 0.45 : 0.12),
|
||||
blurRadius: 32,
|
||||
offset: const Offset(0, 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Header
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 20, 16, 16),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: scheme.primaryContainer.withValues(alpha: 0.7),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.keyboard_rounded,
|
||||
color: scheme.primary,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Горячие клавиши',
|
||||
style: theme.textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'Быстрое управление с клавиатуры для ПК',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
icon: const Icon(Icons.close_rounded),
|
||||
tooltip: 'Закрыть (Esc)',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
|
||||
// Shortcuts list
|
||||
Flexible(
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(24, 16, 24, 24),
|
||||
children: const [
|
||||
_ShortcutSection(
|
||||
icon: Icons.navigation_rounded,
|
||||
title: 'Общие и навигация',
|
||||
items: [
|
||||
_ShortcutRow(['Ctrl', '1…8'], 'Переключение между вкладками'),
|
||||
_ShortcutRow(['Ctrl', 'Tab'], 'Следующая вкладка'),
|
||||
_ShortcutRow(['Ctrl', 'Shift', 'Tab'], 'Предыдущая вкладка'),
|
||||
_ShortcutRow(['Ctrl', 'F'], 'Перейти к поиску / строка поиска'),
|
||||
_ShortcutRow(['Ctrl', 'R'], 'Обновить ленту / страницу'),
|
||||
_ShortcutRow(['F5'], 'Обновить страницу'),
|
||||
_ShortcutRow(['Esc'], 'Закрыть окно / назад / сбросить выбор'),
|
||||
_ShortcutRow(['?'], 'Открыть это окно горячих клавиш'),
|
||||
],
|
||||
),
|
||||
SizedBox(height: 20),
|
||||
_ShortcutSection(
|
||||
icon: Icons.smart_display_rounded,
|
||||
title: 'Видеоплеер',
|
||||
items: [
|
||||
_ShortcutRow(['Пробел'], 'Воспроизведение / Пауза'),
|
||||
_ShortcutRow(['←', '→'], 'Перемотка на 5 секунд назад / вперед'),
|
||||
_ShortcutRow(['Shift', '← / →'], 'Перемотка на 15 секунд'),
|
||||
_ShortcutRow(['↑', '↓'], 'Регулировка громкости (±5%)'),
|
||||
_ShortcutRow(['M'], 'Включить / выключить звук'),
|
||||
_ShortcutRow(['F'], 'Полноэкранный режим (Full Screen)'),
|
||||
_ShortcutRow(['L'], 'Повтор видео (Loop on/off)'),
|
||||
_ShortcutRow(['Esc'], 'Выйти из полноэкранного режима'),
|
||||
],
|
||||
),
|
||||
SizedBox(height: 20),
|
||||
_ShortcutSection(
|
||||
icon: Icons.photo_library_rounded,
|
||||
title: 'Фото и просмотр постов',
|
||||
items: [
|
||||
_ShortcutRow(['←', '→'], 'Предыдущее / следующее фото или пост'),
|
||||
_ShortcutRow(['+'], 'Увеличить масштаб (Zoom In)'),
|
||||
_ShortcutRow(['-'], 'Уменьшить масштаб (Zoom Out)'),
|
||||
_ShortcutRow(['0'], 'Сбросить масштаб (100%)'),
|
||||
_ShortcutRow(['F'], 'Подгонка по экрану / на весь экран'),
|
||||
_ShortcutRow(['D'], 'Скачать текущее изображение'),
|
||||
_ShortcutRow(['Ctrl', 'S'], 'Сохранить / Скачать файл'),
|
||||
_ShortcutRow(['Esc'], 'Сброс зума / назад'),
|
||||
],
|
||||
),
|
||||
SizedBox(height: 20),
|
||||
_ShortcutSection(
|
||||
icon: Icons.grid_view_rounded,
|
||||
title: 'Лента и посты',
|
||||
items: [
|
||||
_ShortcutRow(['R'], 'Открыть случайный пост'),
|
||||
_ShortcutRow(['V'], 'Включить/выключить режим выбора постов'),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ShortcutSection extends StatelessWidget {
|
||||
const _ShortcutSection({
|
||||
required this.icon,
|
||||
required this.title,
|
||||
required this.items,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final String title;
|
||||
final List<_ShortcutRow> items;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final scheme = theme.colorScheme;
|
||||
final isDark = theme.brightness == Brightness.dark;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(icon, size: 18, color: scheme.primary),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
title,
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: scheme.primary,
|
||||
letterSpacing: 0.3,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: isDark
|
||||
? scheme.surface.withValues(alpha: 0.6)
|
||||
: scheme.surfaceContainerHighest.withValues(alpha: 0.4),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: isDark
|
||||
? Colors.white.withValues(alpha: 0.08)
|
||||
: Colors.black.withValues(alpha: 0.04),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
for (var i = 0; i < items.length; i++) ...[
|
||||
if (i > 0)
|
||||
Divider(
|
||||
height: 1,
|
||||
thickness: 1,
|
||||
color: isDark
|
||||
? Colors.white.withValues(alpha: 0.05)
|
||||
: Colors.black.withValues(alpha: 0.04),
|
||||
),
|
||||
items[i],
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ShortcutRow extends StatelessWidget {
|
||||
const _ShortcutRow(this.keys, this.description);
|
||||
|
||||
final List<String> keys;
|
||||
final String description;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final scheme = theme.colorScheme;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
description,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: scheme.onSurface,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Wrap(
|
||||
spacing: 4,
|
||||
runSpacing: 4,
|
||||
children: keys.map((key) => _KeyCap(key)).toList(growable: false),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _KeyCap extends StatelessWidget {
|
||||
const _KeyCap(this.text);
|
||||
|
||||
final String text;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final isDark = theme.brightness == Brightness.dark;
|
||||
final scheme = theme.colorScheme;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: isDark
|
||||
? scheme.surfaceContainerHighest.withValues(alpha: 0.9)
|
||||
: Colors.white,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: isDark
|
||||
? Colors.white.withValues(alpha: 0.2)
|
||||
: Colors.black.withValues(alpha: 0.15),
|
||||
width: 1,
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: isDark ? 0.4 : 0.08),
|
||||
offset: const Offset(0, 1.5),
|
||||
blurRadius: 1,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Text(
|
||||
text,
|
||||
style: TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: scheme.onSurface,
|
||||
letterSpacing: 0.2,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'dart:async';
|
||||
import 'dart:collection';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
@@ -117,9 +118,19 @@ class _PostCardState extends ConsumerState<PostCard>
|
||||
Widget build(BuildContext context) {
|
||||
final post = _resolvedPost ?? widget.post;
|
||||
final sensitive = _isSensitive(post.rating);
|
||||
final isAudioPost =
|
||||
MediaUrlSelector.isAudio(post) || post.fileType == 'audio';
|
||||
final isVideoPost = MediaUrlSelector.isVideo(post);
|
||||
final isTextPost = post.fileType == 'text' ||
|
||||
(post.previewUrl.trim().isEmpty &&
|
||||
post.sampleUrl.trim().isEmpty &&
|
||||
post.fileUrl.trim().isEmpty &&
|
||||
!isVideoPost &&
|
||||
!isAudioPost);
|
||||
final aspect = post.width > 0 && post.height > 0
|
||||
? post.width / post.height
|
||||
: _fallbackAspect(post.fileType);
|
||||
: _fallbackAspect(
|
||||
isTextPost ? 'text' : (isAudioPost ? 'audio' : post.fileType));
|
||||
final mobile = MediaQuery.sizeOf(context).width < 700;
|
||||
final urls = _feedUrls(post, mobile: mobile);
|
||||
final imageUrl = urls.isEmpty ? post.previewUrl : urls.first;
|
||||
@@ -202,9 +213,27 @@ class _PostCardState extends ConsumerState<PostCard>
|
||||
enabled: widget.blurExplicit && sensitive,
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
if (isTextPost) {
|
||||
return _TextPostCardPreview(
|
||||
post: post,
|
||||
hovered: _hovered && !mobile,
|
||||
);
|
||||
}
|
||||
if (isAudioPost && (imageUrl.isEmpty || !imageUrl.startsWith('http'))) {
|
||||
return _AudioPostCardPreview(
|
||||
post: post,
|
||||
hovered: _hovered && !mobile,
|
||||
);
|
||||
}
|
||||
if (isVideoPost && (imageUrl.isEmpty || !imageUrl.startsWith('http'))) {
|
||||
return _VideoPostCardPreview(
|
||||
post: post,
|
||||
hovered: _hovered && !mobile,
|
||||
);
|
||||
}
|
||||
final dpr = MediaQuery.devicePixelRatioOf(context);
|
||||
final cacheWidth =
|
||||
(constraints.maxWidth * dpr).round().clamp(320, 1800);
|
||||
(constraints.maxWidth * dpr).round().clamp(280, 960);
|
||||
final headers = _headersFor(post);
|
||||
final videoUrls = MediaUrlSelector.video(post);
|
||||
if (!mobile &&
|
||||
@@ -230,8 +259,14 @@ class _PostCardState extends ConsumerState<PostCard>
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.surfaceContainerHighest,
|
||||
child: const Center(
|
||||
child: Icon(Icons.broken_image_rounded),
|
||||
child: Center(
|
||||
child: Icon(
|
||||
isVideoPost
|
||||
? Icons.videocam_outlined
|
||||
: (isAudioPost
|
||||
? Icons.music_note_rounded
|
||||
: Icons.broken_image_rounded),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -239,27 +274,28 @@ class _PostCardState extends ConsumerState<PostCard>
|
||||
),
|
||||
),
|
||||
),
|
||||
// Bottom cinematic gradient overlay for badges and favorite
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
height: 64,
|
||||
child: IgnorePointer(
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.bottomCenter,
|
||||
end: Alignment.topCenter,
|
||||
colors: [
|
||||
Colors.black.withValues(alpha: 0.72),
|
||||
Colors.transparent,
|
||||
],
|
||||
// Bottom cinematic gradient overlay for badges and favorite (only on media posts)
|
||||
if (!isTextPost)
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
height: 64,
|
||||
child: IgnorePointer(
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.bottomCenter,
|
||||
end: Alignment.topCenter,
|
||||
colors: [
|
||||
Colors.black.withValues(alpha: 0.72),
|
||||
Colors.transparent,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (widget.showBadges) ...[
|
||||
Positioned(
|
||||
left: 8,
|
||||
@@ -269,11 +305,17 @@ class _PostCardState extends ConsumerState<PostCard>
|
||||
if (post.fileType.toLowerCase().contains('video') ||
|
||||
post.fileType.toLowerCase().contains('webm') ||
|
||||
post.fileType.toLowerCase().contains('mp4') ||
|
||||
post.fileType.toLowerCase().contains('gif'))
|
||||
post.fileType.toLowerCase().contains('gif') ||
|
||||
isAudioPost ||
|
||||
isTextPost)
|
||||
Positioned(
|
||||
right: 8,
|
||||
top: 8,
|
||||
child: _MediaBadge(fileType: post.fileType),
|
||||
child: _MediaBadge(
|
||||
fileType: isAudioPost
|
||||
? 'audio'
|
||||
: (isTextPost ? 'text' : post.fileType),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: 8,
|
||||
@@ -434,6 +476,8 @@ class _PostCardState extends ConsumerState<PostCard>
|
||||
|
||||
double _fallbackAspect(String fileType) {
|
||||
final normalized = fileType.toLowerCase();
|
||||
if (normalized == 'text') return 0.95;
|
||||
if (normalized == 'audio' || normalized.contains('mp3')) return 1.25;
|
||||
if (normalized.contains('video') || normalized.contains('webm')) {
|
||||
return 16 / 9;
|
||||
}
|
||||
@@ -462,7 +506,7 @@ class _PostCardState extends ConsumerState<PostCard>
|
||||
'${post.previewUrl} ${post.sampleUrl} ${post.fileUrl}'
|
||||
.toLowerCase();
|
||||
return {
|
||||
'User-Agent': 'Lunaris/2.0.1 Flutter local booru browser',
|
||||
'User-Agent': 'Prisma/2.0.1 Flutter local booru browser',
|
||||
'Accept': '*/*',
|
||||
if (lower.contains('gelbooru')) 'Referer': 'https://gelbooru.com/',
|
||||
if (lower.contains('realbooru'))
|
||||
@@ -547,7 +591,15 @@ class _FeedVideoPreviewState extends State<_FeedVideoPreview> {
|
||||
super.initState();
|
||||
MediaKit.ensureInitialized();
|
||||
_player = Player();
|
||||
_controller = VideoController(_player);
|
||||
final isDesktop =
|
||||
Platform.isLinux || Platform.isWindows || Platform.isMacOS;
|
||||
_controller = VideoController(
|
||||
_player,
|
||||
configuration: VideoControllerConfiguration(
|
||||
enableHardwareAcceleration: !isDesktop,
|
||||
hwdec: isDesktop ? 'no' : 'auto-safe',
|
||||
),
|
||||
);
|
||||
_player.stream.error.listen((_) {
|
||||
if (mounted) setState(() => _failed = true);
|
||||
});
|
||||
@@ -775,7 +827,7 @@ class _MediaBadge extends StatelessWidget {
|
||||
Icon(_icon(type), size: 13, color: Colors.white),
|
||||
const SizedBox(width: 3.5),
|
||||
Text(
|
||||
type,
|
||||
_label(type),
|
||||
style: const TextStyle(
|
||||
fontSize: 10.5,
|
||||
color: Colors.white,
|
||||
@@ -790,20 +842,420 @@ class _MediaBadge extends StatelessWidget {
|
||||
|
||||
String _type(String raw) {
|
||||
final value = raw.toLowerCase();
|
||||
if (value.contains('audio') ||
|
||||
value.contains('mp3') ||
|
||||
value.contains('wav') ||
|
||||
value.contains('flac') ||
|
||||
value.contains('ogg') ||
|
||||
value.contains('m4a')) {
|
||||
return 'audio';
|
||||
}
|
||||
if (value.contains('video') ||
|
||||
value.contains('webm') ||
|
||||
value.contains('mp4')) {
|
||||
return 'video';
|
||||
}
|
||||
if (value.contains('gif')) return 'gif';
|
||||
if (value.contains('text')) return 'text';
|
||||
return 'photo';
|
||||
}
|
||||
|
||||
IconData _icon(String type) => switch (type) {
|
||||
'audio' => Icons.music_note_rounded,
|
||||
'video' => Icons.play_arrow_rounded,
|
||||
'gif' => Icons.gif_box_rounded,
|
||||
'text' => Icons.article_rounded,
|
||||
_ => Icons.image_rounded,
|
||||
};
|
||||
|
||||
String _label(String type) => switch (type) {
|
||||
'audio' => 'AUDIO',
|
||||
'video' => 'VIDEO',
|
||||
'gif' => 'GIF',
|
||||
'text' => 'ТЕКСТ',
|
||||
_ => 'PHOTO',
|
||||
};
|
||||
}
|
||||
|
||||
class _TextPostCardPreview extends StatelessWidget {
|
||||
const _TextPostCardPreview({
|
||||
required this.post,
|
||||
required this.hovered,
|
||||
});
|
||||
|
||||
final Post post;
|
||||
final bool hovered;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final title = (post.title ?? '').trim();
|
||||
final cleanContent =
|
||||
CloudLinkExtractor.cleanCommentary(post.description ?? '');
|
||||
final hasTitle = title.isNotEmpty;
|
||||
final snippet = cleanContent.isNotEmpty
|
||||
? cleanContent
|
||||
: (hasTitle ? '' : 'Текстовая публикация автора без вложений');
|
||||
final cloudCount = post.cloudLinks.length;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.fromLTRB(14, 14, 14, 14),
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [
|
||||
theme.colorScheme.surfaceContainerHigh,
|
||||
theme.colorScheme.surfaceContainer,
|
||||
],
|
||||
),
|
||||
border: Border.all(
|
||||
color: hovered
|
||||
? theme.colorScheme.primary.withValues(alpha: 0.5)
|
||||
: theme.colorScheme.outlineVariant.withValues(alpha: 0.35),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(5),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.primary.withValues(alpha: 0.14),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
Icons.notes_rounded,
|
||||
size: 15,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'${post.createdAt.day.toString().padLeft(2, '0')}.${post.createdAt.month.toString().padLeft(2, '0')}.${post.createdAt.year}',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
if (hasTitle) ...[
|
||||
Text(
|
||||
title,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
height: 1.25,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
],
|
||||
if (snippet.isNotEmpty)
|
||||
Expanded(
|
||||
child: Text(
|
||||
snippet,
|
||||
maxLines: hasTitle ? 5 : 7,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
height: 1.42,
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
const Spacer(),
|
||||
if (cloudCount > 0) ...[
|
||||
const SizedBox(height: 6),
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 7, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.cloud_download_outlined,
|
||||
size: 12,
|
||||
color: theme.colorScheme.onPrimaryContainer,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'Вложения ($cloudCount)',
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: theme.colorScheme.onPrimaryContainer,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AudioPostCardPreview extends StatelessWidget {
|
||||
const _AudioPostCardPreview({
|
||||
required this.post,
|
||||
required this.hovered,
|
||||
});
|
||||
|
||||
final Post post;
|
||||
final bool hovered;
|
||||
|
||||
String _deriveAudioTitle() {
|
||||
final title = (post.title ?? '').trim();
|
||||
if (title.isNotEmpty) return title;
|
||||
final uri = Uri.tryParse(post.fileUrl);
|
||||
if (uri != null) {
|
||||
final queryParam = uri.queryParameters['f'];
|
||||
if (queryParam != null && queryParam.trim().isNotEmpty) {
|
||||
return queryParam.trim();
|
||||
}
|
||||
final lastSeg = uri.pathSegments.lastOrNull;
|
||||
if (lastSeg != null && lastSeg.isNotEmpty) {
|
||||
return lastSeg;
|
||||
}
|
||||
}
|
||||
final artist = post.tagGroups['artist']?.firstOrNull;
|
||||
if (artist != null && artist.isNotEmpty) return 'Аудиозапись от $artist';
|
||||
return 'Аудиозапись';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final audioTitle = _deriveAudioTitle();
|
||||
final artistName = post.tagGroups['artist']?.firstOrNull ?? post.providerName;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [
|
||||
theme.colorScheme.primaryContainer.withValues(alpha: 0.85),
|
||||
theme.colorScheme.surfaceContainerHighest,
|
||||
],
|
||||
),
|
||||
border: Border.all(
|
||||
color: hovered
|
||||
? theme.colorScheme.primary.withValues(alpha: 0.6)
|
||||
: theme.colorScheme.outlineVariant.withValues(alpha: 0.35),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 38,
|
||||
height: 38,
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.primary,
|
||||
shape: BoxShape.circle,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: theme.colorScheme.primary.withValues(alpha: 0.3),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Icon(
|
||||
Icons.audiotrack_rounded,
|
||||
size: 20,
|
||||
color: theme.colorScheme.onPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
artistName,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${post.createdAt.day.toString().padLeft(2, '0')}.${post.createdAt.month.toString().padLeft(2, '0')}.${post.createdAt.year}',
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
color: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.7),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Spacer(),
|
||||
Text(
|
||||
audioTitle,
|
||||
maxLines: 3,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
height: 1.25,
|
||||
letterSpacing: -0.1,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surface.withValues(alpha: 0.6),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.play_circle_filled_rounded,
|
||||
size: 14,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'Воспроизвести',
|
||||
style: TextStyle(
|
||||
fontSize: 10.5,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: theme.colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _VideoPostCardPreview extends StatelessWidget {
|
||||
const _VideoPostCardPreview({
|
||||
required this.post,
|
||||
required this.hovered,
|
||||
});
|
||||
|
||||
final Post post;
|
||||
final bool hovered;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final title = (post.title ?? '').trim();
|
||||
final artistName = post.tagGroups['artist']?.firstOrNull ?? post.providerName;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [
|
||||
theme.colorScheme.surfaceContainerHighest,
|
||||
theme.colorScheme.surfaceContainer,
|
||||
],
|
||||
),
|
||||
border: Border.all(
|
||||
color: hovered
|
||||
? theme.colorScheme.primary.withValues(alpha: 0.6)
|
||||
: theme.colorScheme.outlineVariant.withValues(alpha: 0.35),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
Center(
|
||||
child: Container(
|
||||
width: 52,
|
||||
height: 52,
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.primary.withValues(alpha: 0.85),
|
||||
shape: BoxShape.circle,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.3),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 3),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.play_arrow_rounded,
|
||||
size: 32,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (title.isNotEmpty)
|
||||
Text(
|
||||
title,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
height: 1.2,
|
||||
),
|
||||
)
|
||||
else
|
||||
Text(
|
||||
artistName,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SeenBadge extends StatelessWidget {
|
||||
|
||||
@@ -68,24 +68,26 @@ class PostMasonryGrid extends StatelessWidget {
|
||||
final spacing = mobile ? 8.0 : 12.0;
|
||||
|
||||
Widget buildCard(Post post) {
|
||||
return PostCard(
|
||||
post: post,
|
||||
blurExplicit: blurExplicit && !nsfwEnabled,
|
||||
showBadges: showBadges,
|
||||
isFavorite: favoriteKeys.contains(post.cacheKey),
|
||||
isViewed: viewedKeys.contains(post.cacheKey),
|
||||
isDownloaded: downloadedKeys.contains(post.cacheKey),
|
||||
mediaQualityMode: mediaQualityMode,
|
||||
onOpen: () => onOpen(post),
|
||||
onFavorite: () => onFavorite(post),
|
||||
onPreview: onPreview == null ? null : () => onPreview!(post),
|
||||
onHide: onHide == null ? null : () => onHide!(post),
|
||||
selectionMode: selectionMode,
|
||||
selected: selectedKeys.contains(post.cacheKey),
|
||||
onToggleSelected:
|
||||
onToggleSelected == null ? null : () => onToggleSelected!(post),
|
||||
onAddToCollection:
|
||||
onAddToCollection == null ? null : () => onAddToCollection!(post),
|
||||
return RepaintBoundary(
|
||||
child: PostCard(
|
||||
post: post,
|
||||
blurExplicit: blurExplicit && !nsfwEnabled,
|
||||
showBadges: showBadges,
|
||||
isFavorite: favoriteKeys.contains(post.cacheKey),
|
||||
isViewed: viewedKeys.contains(post.cacheKey),
|
||||
isDownloaded: downloadedKeys.contains(post.cacheKey),
|
||||
mediaQualityMode: mediaQualityMode,
|
||||
onOpen: () => onOpen(post),
|
||||
onFavorite: () => onFavorite(post),
|
||||
onPreview: onPreview == null ? null : () => onPreview!(post),
|
||||
onHide: onHide == null ? null : () => onHide!(post),
|
||||
selectionMode: selectionMode,
|
||||
selected: selectedKeys.contains(post.cacheKey),
|
||||
onToggleSelected:
|
||||
onToggleSelected == null ? null : () => onToggleSelected!(post),
|
||||
onAddToCollection:
|
||||
onAddToCollection == null ? null : () => onAddToCollection!(post),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
# Project-level configuration.
|
||||
cmake_minimum_required(VERSION 3.13)
|
||||
project(runner LANGUAGES CXX)
|
||||
project(runner LANGUAGES CXX C)
|
||||
|
||||
set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -Wno-error=incompatible-pointer-types -Wno-incompatible-pointer-types")
|
||||
|
||||
# The name of the executable created for the application. Change this to change
|
||||
# the on-disk name of your application.
|
||||
@@ -41,7 +43,8 @@ endif()
|
||||
# of modifying this function.
|
||||
function(APPLY_STANDARD_SETTINGS TARGET)
|
||||
target_compile_features(${TARGET} PUBLIC cxx_std_14)
|
||||
target_compile_options(${TARGET} PRIVATE -Wall -Werror)
|
||||
target_compile_options(${TARGET} PRIVATE -Wall)
|
||||
target_compile_options(${TARGET} PRIVATE "$<$<COMPILE_LANGUAGE:C>:-Wno-error=incompatible-pointer-types;-Wno-incompatible-pointer-types>")
|
||||
target_compile_options(${TARGET} PRIVATE "$<$<NOT:$<CONFIG:Debug>>:-O3>")
|
||||
target_compile_definitions(${TARGET} PRIVATE "$<$<NOT:$<CONFIG:Debug>>:NDEBUG>")
|
||||
endfunction()
|
||||
|
||||
@@ -45,11 +45,11 @@ static void my_application_activate(GApplication* application) {
|
||||
if (use_header_bar) {
|
||||
GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new());
|
||||
gtk_widget_show(GTK_WIDGET(header_bar));
|
||||
gtk_header_bar_set_title(header_bar, "Lunaris");
|
||||
gtk_header_bar_set_title(header_bar, "Prisma");
|
||||
gtk_header_bar_set_show_close_button(header_bar, TRUE);
|
||||
gtk_window_set_titlebar(window, GTK_WIDGET(header_bar));
|
||||
} else {
|
||||
gtk_window_set_title(window, "Lunaris");
|
||||
gtk_window_set_title(window, "Prisma");
|
||||
}
|
||||
|
||||
gtk_window_set_default_size(window, 1280, 720);
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
# Flutter-related
|
||||
**/Flutter/ephemeral/
|
||||
**/Pods/
|
||||
|
||||
# Xcode-related
|
||||
**/dgph
|
||||
**/xcuserdata/
|
||||
@@ -0,0 +1 @@
|
||||
#include "ephemeral/Flutter-Generated.xcconfig"
|
||||
@@ -0,0 +1 @@
|
||||
#include "ephemeral/Flutter-Generated.xcconfig"
|
||||
@@ -0,0 +1,34 @@
|
||||
//
|
||||
// Generated file. Do not edit.
|
||||
//
|
||||
|
||||
import FlutterMacOS
|
||||
import Foundation
|
||||
|
||||
import connectivity_plus
|
||||
import dynamic_color
|
||||
import file_selector_macos
|
||||
import isar_flutter_libs
|
||||
import media_kit_libs_macos_video
|
||||
import media_kit_video
|
||||
import package_info_plus
|
||||
import share_plus
|
||||
import sqflite_darwin
|
||||
import url_launcher_macos
|
||||
import volume_controller
|
||||
import wakelock_plus
|
||||
|
||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin"))
|
||||
DynamicColorPlugin.register(with: registry.registrar(forPlugin: "DynamicColorPlugin"))
|
||||
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
|
||||
IsarFlutterLibsPlugin.register(with: registry.registrar(forPlugin: "IsarFlutterLibsPlugin"))
|
||||
MediaKitLibsMacosVideoPlugin.register(with: registry.registrar(forPlugin: "MediaKitLibsMacosVideoPlugin"))
|
||||
MediaKitVideoPlugin.register(with: registry.registrar(forPlugin: "MediaKitVideoPlugin"))
|
||||
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
|
||||
SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin"))
|
||||
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
|
||||
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
|
||||
VolumeControllerPlugin.register(with: registry.registrar(forPlugin: "VolumeControllerPlugin"))
|
||||
WakelockPlusMacosPlugin.register(with: registry.registrar(forPlugin: "WakelockPlusMacosPlugin"))
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
platform :osx, '10.15'
|
||||
|
||||
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
|
||||
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
|
||||
|
||||
project 'Runner', {
|
||||
'Debug' => :debug,
|
||||
'Profile' => :release,
|
||||
'Release' => :release,
|
||||
}
|
||||
|
||||
def flutter_root
|
||||
generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'ephemeral', 'Flutter-Generated.xcconfig'), __FILE__)
|
||||
unless File.exist?(generated_xcode_build_settings_path)
|
||||
raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first"
|
||||
end
|
||||
|
||||
File.foreach(generated_xcode_build_settings_path) do |line|
|
||||
matches = line.match(/FLUTTER_ROOT\=(.*)/)
|
||||
return matches[1].strip if matches
|
||||
end
|
||||
raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Flutter-Generated.xcconfig, then run flutter pub get"
|
||||
end
|
||||
|
||||
require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root)
|
||||
|
||||
flutter_macos_podfile_setup
|
||||
|
||||
target 'Runner' do
|
||||
use_frameworks!
|
||||
use_modular_headers!
|
||||
|
||||
flutter_install_all_macos_pods File.dirname(File.realpath(__FILE__))
|
||||
target 'RunnerTests' do
|
||||
inherit! :search_paths
|
||||
end
|
||||
end
|
||||
|
||||
post_install do |installer|
|
||||
installer.pods_project.targets.each do |target|
|
||||
flutter_additional_macos_build_settings(target)
|
||||
target.build_configurations.each do |config|
|
||||
config.build_settings['MACOSX_DEPLOYMENT_TARGET'] = '10.15'
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,729 @@
|
||||
// !$*UTF8*$!
|
||||
{
|
||||
archiveVersion = 1;
|
||||
classes = {
|
||||
};
|
||||
objectVersion = 54;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXAggregateTarget section */
|
||||
33CC111A2044C6BA0003C045 /* Flutter Assemble */ = {
|
||||
isa = PBXAggregateTarget;
|
||||
buildConfigurationList = 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */;
|
||||
buildPhases = (
|
||||
33CC111E2044C6BF0003C045 /* ShellScript */,
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
name = "Flutter Assemble";
|
||||
productName = FLX;
|
||||
};
|
||||
/* End PBXAggregateTarget section */
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C80D7294CF71000263BE5 /* RunnerTests.swift */; };
|
||||
335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */; };
|
||||
33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; };
|
||||
33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; };
|
||||
33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; };
|
||||
33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; };
|
||||
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
331C80D9294CF71000263BE5 /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = 33CC10E52044A3C60003C045 /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = 33CC10EC2044A3C60003C045;
|
||||
remoteInfo = Runner;
|
||||
};
|
||||
33CC111F2044C79F0003C045 /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = 33CC10E52044A3C60003C045 /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = 33CC111A2044C6BA0003C045;
|
||||
remoteInfo = FLX;
|
||||
};
|
||||
/* End PBXContainerItemProxy section */
|
||||
|
||||
/* Begin PBXCopyFilesBuildPhase section */
|
||||
33CC110E2044A8840003C045 /* Bundle Framework */ = {
|
||||
isa = PBXCopyFilesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
dstPath = "";
|
||||
dstSubfolderSpec = 10;
|
||||
files = (
|
||||
);
|
||||
name = "Bundle Framework";
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXCopyFilesBuildPhase section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
|
||||
333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = "<group>"; };
|
||||
335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = "<group>"; };
|
||||
33CC10ED2044A3C60003C045 /* gel_rule_app.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "gel_rule_app.app"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
||||
33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = "<group>"; };
|
||||
33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = "<group>"; };
|
||||
33CC10F72044A3C60003C045 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = Runner/Info.plist; sourceTree = "<group>"; };
|
||||
33CC11122044BFA00003C045 /* MainFlutterWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainFlutterWindow.swift; sourceTree = "<group>"; };
|
||||
33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Debug.xcconfig"; sourceTree = "<group>"; };
|
||||
33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Release.xcconfig"; sourceTree = "<group>"; };
|
||||
33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "Flutter-Generated.xcconfig"; path = "ephemeral/Flutter-Generated.xcconfig"; sourceTree = "<group>"; };
|
||||
33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = "<group>"; };
|
||||
33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = "<group>"; };
|
||||
33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = "<group>"; };
|
||||
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = "<group>"; };
|
||||
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = "<group>"; };
|
||||
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
331C80D2294CF70F00263BE5 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
33CC10EA2044A3C60003C045 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
331C80D6294CF71000263BE5 /* RunnerTests */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
331C80D7294CF71000263BE5 /* RunnerTests.swift */,
|
||||
);
|
||||
path = RunnerTests;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
33BA886A226E78AF003329D5 /* Configs */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
33E5194F232828860026EE4D /* AppInfo.xcconfig */,
|
||||
9740EEB21CF90195004384FC /* Debug.xcconfig */,
|
||||
7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
|
||||
333000ED22D3DE5D00554162 /* Warnings.xcconfig */,
|
||||
);
|
||||
path = Configs;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
33CC10E42044A3C60003C045 = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
33FAB671232836740065AC1E /* Runner */,
|
||||
33CEB47122A05771004F2AC0 /* Flutter */,
|
||||
331C80D6294CF71000263BE5 /* RunnerTests */,
|
||||
33CC10EE2044A3C60003C045 /* Products */,
|
||||
D73912EC22F37F3D000D13A0 /* Frameworks */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
33CC10EE2044A3C60003C045 /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
33CC10ED2044A3C60003C045 /* gel_rule_app.app */,
|
||||
331C80D5294CF71000263BE5 /* RunnerTests.xctest */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
33CC11242044D66E0003C045 /* Resources */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
33CC10F22044A3C60003C045 /* Assets.xcassets */,
|
||||
33CC10F42044A3C60003C045 /* MainMenu.xib */,
|
||||
33CC10F72044A3C60003C045 /* Info.plist */,
|
||||
);
|
||||
name = Resources;
|
||||
path = ..;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
33CEB47122A05771004F2AC0 /* Flutter */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */,
|
||||
335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */,
|
||||
33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */,
|
||||
33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */,
|
||||
33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */,
|
||||
);
|
||||
path = Flutter;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
33FAB671232836740065AC1E /* Runner */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
33CC10F02044A3C60003C045 /* AppDelegate.swift */,
|
||||
33CC11122044BFA00003C045 /* MainFlutterWindow.swift */,
|
||||
33E51913231747F40026EE4D /* DebugProfile.entitlements */,
|
||||
33E51914231749380026EE4D /* Release.entitlements */,
|
||||
33CC11242044D66E0003C045 /* Resources */,
|
||||
33BA886A226E78AF003329D5 /* Configs */,
|
||||
);
|
||||
path = Runner;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
D73912EC22F37F3D000D13A0 /* Frameworks */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
);
|
||||
name = Frameworks;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
331C80D4294CF70F00263BE5 /* RunnerTests */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
|
||||
buildPhases = (
|
||||
331C80D1294CF70F00263BE5 /* Sources */,
|
||||
331C80D2294CF70F00263BE5 /* Frameworks */,
|
||||
331C80D3294CF70F00263BE5 /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
331C80DA294CF71000263BE5 /* PBXTargetDependency */,
|
||||
);
|
||||
name = RunnerTests;
|
||||
productName = RunnerTests;
|
||||
productReference = 331C80D5294CF71000263BE5 /* RunnerTests.xctest */;
|
||||
productType = "com.apple.product-type.bundle.unit-test";
|
||||
};
|
||||
33CC10EC2044A3C60003C045 /* Runner */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */;
|
||||
buildPhases = (
|
||||
33CC10E92044A3C60003C045 /* Sources */,
|
||||
33CC10EA2044A3C60003C045 /* Frameworks */,
|
||||
33CC10EB2044A3C60003C045 /* Resources */,
|
||||
33CC110E2044A8840003C045 /* Bundle Framework */,
|
||||
3399D490228B24CF009A79C7 /* ShellScript */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
33CC11202044C79F0003C045 /* PBXTargetDependency */,
|
||||
);
|
||||
name = Runner;
|
||||
packageProductDependencies = (
|
||||
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */,
|
||||
);
|
||||
productName = Runner;
|
||||
productReference = 33CC10ED2044A3C60003C045 /* gel_rule_app.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
|
||||
/* Begin PBXProject section */
|
||||
33CC10E52044A3C60003C045 /* Project object */ = {
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
BuildIndependentTargetsInParallel = YES;
|
||||
LastSwiftUpdateCheck = 0920;
|
||||
LastUpgradeCheck = 1510;
|
||||
ORGANIZATIONNAME = "";
|
||||
TargetAttributes = {
|
||||
331C80D4294CF70F00263BE5 = {
|
||||
CreatedOnToolsVersion = 14.0;
|
||||
TestTargetID = 33CC10EC2044A3C60003C045;
|
||||
};
|
||||
33CC10EC2044A3C60003C045 = {
|
||||
CreatedOnToolsVersion = 9.2;
|
||||
LastSwiftMigration = 1100;
|
||||
ProvisioningStyle = Automatic;
|
||||
SystemCapabilities = {
|
||||
com.apple.Sandbox = {
|
||||
enabled = 1;
|
||||
};
|
||||
};
|
||||
};
|
||||
33CC111A2044C6BA0003C045 = {
|
||||
CreatedOnToolsVersion = 9.2;
|
||||
ProvisioningStyle = Manual;
|
||||
};
|
||||
};
|
||||
};
|
||||
buildConfigurationList = 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */;
|
||||
compatibilityVersion = "Xcode 9.3";
|
||||
developmentRegion = en;
|
||||
hasScannedForEncodings = 0;
|
||||
knownRegions = (
|
||||
en,
|
||||
Base,
|
||||
);
|
||||
mainGroup = 33CC10E42044A3C60003C045;
|
||||
packageReferences = (
|
||||
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */,
|
||||
);
|
||||
productRefGroup = 33CC10EE2044A3C60003C045 /* Products */;
|
||||
projectDirPath = "";
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
33CC10EC2044A3C60003C045 /* Runner */,
|
||||
331C80D4294CF70F00263BE5 /* RunnerTests */,
|
||||
33CC111A2044C6BA0003C045 /* Flutter Assemble */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
|
||||
/* Begin PBXResourcesBuildPhase section */
|
||||
331C80D3294CF70F00263BE5 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
33CC10EB2044A3C60003C045 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */,
|
||||
33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXShellScriptBuildPhase section */
|
||||
3399D490228B24CF009A79C7 /* ShellScript */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
alwaysOutOfDate = 1;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputFileListPaths = (
|
||||
);
|
||||
inputPaths = (
|
||||
);
|
||||
outputFileListPaths = (
|
||||
);
|
||||
outputPaths = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "echo \"$PRODUCT_NAME.app\" > \"$PROJECT_DIR\"/Flutter/ephemeral/.app_filename && \"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh embed\n";
|
||||
};
|
||||
33CC111E2044C6BF0003C045 /* ShellScript */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputFileListPaths = (
|
||||
Flutter/ephemeral/FlutterInputs.xcfilelist,
|
||||
);
|
||||
inputPaths = (
|
||||
Flutter/ephemeral/tripwire,
|
||||
);
|
||||
outputFileListPaths = (
|
||||
Flutter/ephemeral/FlutterOutputs.xcfilelist,
|
||||
);
|
||||
outputPaths = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire";
|
||||
};
|
||||
/* End PBXShellScriptBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
331C80D1294CF70F00263BE5 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
33CC10E92044A3C60003C045 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */,
|
||||
33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */,
|
||||
335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXTargetDependency section */
|
||||
331C80DA294CF71000263BE5 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = 33CC10EC2044A3C60003C045 /* Runner */;
|
||||
targetProxy = 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */;
|
||||
};
|
||||
33CC11202044C79F0003C045 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = 33CC111A2044C6BA0003C045 /* Flutter Assemble */;
|
||||
targetProxy = 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */;
|
||||
};
|
||||
/* End PBXTargetDependency section */
|
||||
|
||||
/* Begin PBXVariantGroup section */
|
||||
33CC10F42044A3C60003C045 /* MainMenu.xib */ = {
|
||||
isa = PBXVariantGroup;
|
||||
children = (
|
||||
33CC10F52044A3C60003C045 /* Base */,
|
||||
);
|
||||
name = MainMenu.xib;
|
||||
path = Runner;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXVariantGroup section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
331C80DB294CF71000263BE5 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.rardog.gelRuleApp.RunnerTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/gel_rule_app.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/gel_rule_app";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
331C80DC294CF71000263BE5 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.rardog.gelRuleApp.RunnerTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/gel_rule_app.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/gel_rule_app";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
331C80DD294CF71000263BE5 /* Profile */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.rardog.gelRuleApp.RunnerTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/gel_rule_app.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/gel_rule_app";
|
||||
};
|
||||
name = Profile;
|
||||
};
|
||||
338D0CE9231458BD00FA5F75 /* Profile */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
|
||||
CLANG_CXX_LIBRARY = "libc++";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CODE_SIGN_IDENTITY = "-";
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
ENABLE_NS_ASSERTIONS = NO;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
MACOSX_DEPLOYMENT_TARGET = 12.0;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
SDKROOT = macosx;
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
||||
};
|
||||
name = Profile;
|
||||
};
|
||||
338D0CEA231458BD00FA5F75 /* Profile */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
SWIFT_VERSION = 5.0;
|
||||
};
|
||||
name = Profile;
|
||||
};
|
||||
338D0CEB231458BD00FA5F75 /* Profile */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
};
|
||||
name = Profile;
|
||||
};
|
||||
33CC10F92044A3C60003C045 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
|
||||
CLANG_CXX_LIBRARY = "libc++";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CODE_SIGN_IDENTITY = "-";
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_TESTABILITY = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||
GCC_DYNAMIC_NO_PIC = NO;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_OPTIMIZATION_LEVEL = 0;
|
||||
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||
"DEBUG=1",
|
||||
"$(inherited)",
|
||||
);
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
MACOSX_DEPLOYMENT_TARGET = 12.0;
|
||||
MTL_ENABLE_DEBUG_INFO = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
SDKROOT = macosx;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
33CC10FA2044A3C60003C045 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
|
||||
CLANG_CXX_LIBRARY = "libc++";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CODE_SIGN_IDENTITY = "-";
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
ENABLE_NS_ASSERTIONS = NO;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
MACOSX_DEPLOYMENT_TARGET = 12.0;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
SDKROOT = macosx;
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
33CC10FC2044A3C60003C045 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
SWIFT_VERSION = 5.0;
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
33CC10FD2044A3C60003C045 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
SWIFT_VERSION = 5.0;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
33CC111C2044C6BA0003C045 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
33CC111D2044C6BA0003C045 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
331C80DB294CF71000263BE5 /* Debug */,
|
||||
331C80DC294CF71000263BE5 /* Release */,
|
||||
331C80DD294CF71000263BE5 /* Profile */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
33CC10F92044A3C60003C045 /* Debug */,
|
||||
33CC10FA2044A3C60003C045 /* Release */,
|
||||
338D0CE9231458BD00FA5F75 /* Profile */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
33CC10FC2044A3C60003C045 /* Debug */,
|
||||
33CC10FD2044A3C60003C045 /* Release */,
|
||||
338D0CEA231458BD00FA5F75 /* Profile */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
33CC111C2044C6BA0003C045 /* Debug */,
|
||||
33CC111D2044C6BA0003C045 /* Release */,
|
||||
338D0CEB231458BD00FA5F75 /* Profile */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
|
||||
/* Begin XCLocalSwiftPackageReference section */
|
||||
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = {
|
||||
isa = XCLocalSwiftPackageReference;
|
||||
relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage;
|
||||
};
|
||||
/* End XCLocalSwiftPackageReference section */
|
||||
|
||||
/* Begin XCSwiftPackageProductDependency section */
|
||||
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = FlutterGeneratedPluginSwiftPackage;
|
||||
};
|
||||
/* End XCSwiftPackageProductDependency section */
|
||||
};
|
||||
rootObject = 33CC10E52044A3C60003C045 /* Project object */;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>IDEDidComputeMac32BitWarning</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,117 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1510"
|
||||
version = "1.3">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES">
|
||||
<PreActions>
|
||||
<ExecutionAction
|
||||
ActionType = "Xcode.IDEStandardExecutionActionsCore.ExecutionActionType.ShellScriptAction">
|
||||
<ActionContent
|
||||
title = "Run Prepare Flutter Framework Script"
|
||||
scriptText = ""$FLUTTER_ROOT"/packages/flutter_tools/bin/macos_assemble.sh prepare ">
|
||||
<EnvironmentBuildable>
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "33CC10EC2044A3C60003C045"
|
||||
BuildableName = "gel_rule_app.app"
|
||||
BlueprintName = "Runner"
|
||||
ReferencedContainer = "container:Runner.xcodeproj">
|
||||
</BuildableReference>
|
||||
</EnvironmentBuildable>
|
||||
</ActionContent>
|
||||
</ExecutionAction>
|
||||
</PreActions>
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "33CC10EC2044A3C60003C045"
|
||||
BuildableName = "gel_rule_app.app"
|
||||
BlueprintName = "Runner"
|
||||
ReferencedContainer = "container:Runner.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES">
|
||||
<MacroExpansion>
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "33CC10EC2044A3C60003C045"
|
||||
BuildableName = "gel_rule_app.app"
|
||||
BlueprintName = "Runner"
|
||||
ReferencedContainer = "container:Runner.xcodeproj">
|
||||
</BuildableReference>
|
||||
</MacroExpansion>
|
||||
<Testables>
|
||||
<TestableReference
|
||||
skipped = "NO"
|
||||
parallelizable = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "331C80D4294CF70F00263BE5"
|
||||
BuildableName = "RunnerTests.xctest"
|
||||
BlueprintName = "RunnerTests"
|
||||
ReferencedContainer = "container:Runner.xcodeproj">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
</Testables>
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
enableGPUValidationMode = "1"
|
||||
allowLocationSimulation = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "33CC10EC2044A3C60003C045"
|
||||
BuildableName = "gel_rule_app.app"
|
||||
BlueprintName = "Runner"
|
||||
ReferencedContainer = "container:Runner.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Profile"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "33CC10EC2044A3C60003C045"
|
||||
BuildableName = "gel_rule_app.app"
|
||||
BlueprintName = "Runner"
|
||||
ReferencedContainer = "container:Runner.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "group:Runner.xcodeproj">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>IDEDidComputeMac32BitWarning</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,13 @@
|
||||
import Cocoa
|
||||
import FlutterMacOS
|
||||
|
||||
@main
|
||||
class AppDelegate: FlutterAppDelegate {
|
||||
override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
|
||||
return true
|
||||
}
|
||||
|
||||
override func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool {
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"size" : "16x16",
|
||||
"idiom" : "mac",
|
||||
"filename" : "app_icon_16.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "16x16",
|
||||
"idiom" : "mac",
|
||||
"filename" : "app_icon_32.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "32x32",
|
||||
"idiom" : "mac",
|
||||
"filename" : "app_icon_32.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "32x32",
|
||||
"idiom" : "mac",
|
||||
"filename" : "app_icon_64.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "128x128",
|
||||
"idiom" : "mac",
|
||||
"filename" : "app_icon_128.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "128x128",
|
||||
"idiom" : "mac",
|
||||
"filename" : "app_icon_256.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "256x256",
|
||||
"idiom" : "mac",
|
||||
"filename" : "app_icon_256.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "256x256",
|
||||
"idiom" : "mac",
|
||||
"filename" : "app_icon_512.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "512x512",
|
||||
"idiom" : "mac",
|
||||
"filename" : "app_icon_512.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "512x512",
|
||||
"idiom" : "mac",
|
||||
"filename" : "app_icon_1024.png",
|
||||
"scale" : "2x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"version" : 1,
|
||||
"author" : "xcode"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 101 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 520 B |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
@@ -0,0 +1,343 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<document type="com.apple.InterfaceBuilder3.Cocoa.XIB" version="3.0" toolsVersion="14490.70" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES" customObjectInstantitationMethod="direct">
|
||||
<dependencies>
|
||||
<deployment identifier="macosx"/>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="14490.70"/>
|
||||
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
||||
</dependencies>
|
||||
<objects>
|
||||
<customObject id="-2" userLabel="File's Owner" customClass="NSApplication">
|
||||
<connections>
|
||||
<outlet property="delegate" destination="Voe-Tx-rLC" id="GzC-gU-4Uq"/>
|
||||
</connections>
|
||||
</customObject>
|
||||
<customObject id="-1" userLabel="First Responder" customClass="FirstResponder"/>
|
||||
<customObject id="-3" userLabel="Application" customClass="NSObject"/>
|
||||
<customObject id="Voe-Tx-rLC" customClass="AppDelegate" customModule="Runner" customModuleProvider="target">
|
||||
<connections>
|
||||
<outlet property="applicationMenu" destination="uQy-DD-JDr" id="XBo-yE-nKs"/>
|
||||
<outlet property="mainFlutterWindow" destination="QvC-M9-y7g" id="gIp-Ho-8D9"/>
|
||||
</connections>
|
||||
</customObject>
|
||||
<customObject id="YLy-65-1bz" customClass="NSFontManager"/>
|
||||
<menu title="Main Menu" systemMenu="main" id="AYu-sK-qS6">
|
||||
<items>
|
||||
<menuItem title="APP_NAME" id="1Xt-HY-uBw">
|
||||
<modifierMask key="keyEquivalentModifierMask"/>
|
||||
<menu key="submenu" title="APP_NAME" systemMenu="apple" id="uQy-DD-JDr">
|
||||
<items>
|
||||
<menuItem title="About APP_NAME" id="5kV-Vb-QxS">
|
||||
<modifierMask key="keyEquivalentModifierMask"/>
|
||||
<connections>
|
||||
<action selector="orderFrontStandardAboutPanel:" target="-1" id="Exp-CZ-Vem"/>
|
||||
</connections>
|
||||
</menuItem>
|
||||
<menuItem isSeparatorItem="YES" id="VOq-y0-SEH"/>
|
||||
<menuItem title="Preferences…" keyEquivalent="," id="BOF-NM-1cW"/>
|
||||
<menuItem isSeparatorItem="YES" id="wFC-TO-SCJ"/>
|
||||
<menuItem title="Services" id="NMo-om-nkz">
|
||||
<modifierMask key="keyEquivalentModifierMask"/>
|
||||
<menu key="submenu" title="Services" systemMenu="services" id="hz9-B4-Xy5"/>
|
||||
</menuItem>
|
||||
<menuItem isSeparatorItem="YES" id="4je-JR-u6R"/>
|
||||
<menuItem title="Hide APP_NAME" keyEquivalent="h" id="Olw-nP-bQN">
|
||||
<connections>
|
||||
<action selector="hide:" target="-1" id="PnN-Uc-m68"/>
|
||||
</connections>
|
||||
</menuItem>
|
||||
<menuItem title="Hide Others" keyEquivalent="h" id="Vdr-fp-XzO">
|
||||
<modifierMask key="keyEquivalentModifierMask" option="YES" command="YES"/>
|
||||
<connections>
|
||||
<action selector="hideOtherApplications:" target="-1" id="VT4-aY-XCT"/>
|
||||
</connections>
|
||||
</menuItem>
|
||||
<menuItem title="Show All" id="Kd2-mp-pUS">
|
||||
<modifierMask key="keyEquivalentModifierMask"/>
|
||||
<connections>
|
||||
<action selector="unhideAllApplications:" target="-1" id="Dhg-Le-xox"/>
|
||||
</connections>
|
||||
</menuItem>
|
||||
<menuItem isSeparatorItem="YES" id="kCx-OE-vgT"/>
|
||||
<menuItem title="Quit APP_NAME" keyEquivalent="q" id="4sb-4s-VLi">
|
||||
<connections>
|
||||
<action selector="terminate:" target="-1" id="Te7-pn-YzF"/>
|
||||
</connections>
|
||||
</menuItem>
|
||||
</items>
|
||||
</menu>
|
||||
</menuItem>
|
||||
<menuItem title="Edit" id="5QF-Oa-p0T">
|
||||
<modifierMask key="keyEquivalentModifierMask"/>
|
||||
<menu key="submenu" title="Edit" id="W48-6f-4Dl">
|
||||
<items>
|
||||
<menuItem title="Undo" keyEquivalent="z" id="dRJ-4n-Yzg">
|
||||
<connections>
|
||||
<action selector="undo:" target="-1" id="M6e-cu-g7V"/>
|
||||
</connections>
|
||||
</menuItem>
|
||||
<menuItem title="Redo" keyEquivalent="Z" id="6dh-zS-Vam">
|
||||
<connections>
|
||||
<action selector="redo:" target="-1" id="oIA-Rs-6OD"/>
|
||||
</connections>
|
||||
</menuItem>
|
||||
<menuItem isSeparatorItem="YES" id="WRV-NI-Exz"/>
|
||||
<menuItem title="Cut" keyEquivalent="x" id="uRl-iY-unG">
|
||||
<connections>
|
||||
<action selector="cut:" target="-1" id="YJe-68-I9s"/>
|
||||
</connections>
|
||||
</menuItem>
|
||||
<menuItem title="Copy" keyEquivalent="c" id="x3v-GG-iWU">
|
||||
<connections>
|
||||
<action selector="copy:" target="-1" id="G1f-GL-Joy"/>
|
||||
</connections>
|
||||
</menuItem>
|
||||
<menuItem title="Paste" keyEquivalent="v" id="gVA-U4-sdL">
|
||||
<connections>
|
||||
<action selector="paste:" target="-1" id="UvS-8e-Qdg"/>
|
||||
</connections>
|
||||
</menuItem>
|
||||
<menuItem title="Paste and Match Style" keyEquivalent="V" id="WeT-3V-zwk">
|
||||
<modifierMask key="keyEquivalentModifierMask" option="YES" command="YES"/>
|
||||
<connections>
|
||||
<action selector="pasteAsPlainText:" target="-1" id="cEh-KX-wJQ"/>
|
||||
</connections>
|
||||
</menuItem>
|
||||
<menuItem title="Delete" id="pa3-QI-u2k">
|
||||
<modifierMask key="keyEquivalentModifierMask"/>
|
||||
<connections>
|
||||
<action selector="delete:" target="-1" id="0Mk-Ml-PaM"/>
|
||||
</connections>
|
||||
</menuItem>
|
||||
<menuItem title="Select All" keyEquivalent="a" id="Ruw-6m-B2m">
|
||||
<connections>
|
||||
<action selector="selectAll:" target="-1" id="VNm-Mi-diN"/>
|
||||
</connections>
|
||||
</menuItem>
|
||||
<menuItem isSeparatorItem="YES" id="uyl-h8-XO2"/>
|
||||
<menuItem title="Find" id="4EN-yA-p0u">
|
||||
<modifierMask key="keyEquivalentModifierMask"/>
|
||||
<menu key="submenu" title="Find" id="1b7-l0-nxx">
|
||||
<items>
|
||||
<menuItem title="Find…" tag="1" keyEquivalent="f" id="Xz5-n4-O0W">
|
||||
<connections>
|
||||
<action selector="performFindPanelAction:" target="-1" id="cD7-Qs-BN4"/>
|
||||
</connections>
|
||||
</menuItem>
|
||||
<menuItem title="Find and Replace…" tag="12" keyEquivalent="f" id="YEy-JH-Tfz">
|
||||
<modifierMask key="keyEquivalentModifierMask" option="YES" command="YES"/>
|
||||
<connections>
|
||||
<action selector="performFindPanelAction:" target="-1" id="WD3-Gg-5AJ"/>
|
||||
</connections>
|
||||
</menuItem>
|
||||
<menuItem title="Find Next" tag="2" keyEquivalent="g" id="q09-fT-Sye">
|
||||
<connections>
|
||||
<action selector="performFindPanelAction:" target="-1" id="NDo-RZ-v9R"/>
|
||||
</connections>
|
||||
</menuItem>
|
||||
<menuItem title="Find Previous" tag="3" keyEquivalent="G" id="OwM-mh-QMV">
|
||||
<connections>
|
||||
<action selector="performFindPanelAction:" target="-1" id="HOh-sY-3ay"/>
|
||||
</connections>
|
||||
</menuItem>
|
||||
<menuItem title="Use Selection for Find" tag="7" keyEquivalent="e" id="buJ-ug-pKt">
|
||||
<connections>
|
||||
<action selector="performFindPanelAction:" target="-1" id="U76-nv-p5D"/>
|
||||
</connections>
|
||||
</menuItem>
|
||||
<menuItem title="Jump to Selection" keyEquivalent="j" id="S0p-oC-mLd">
|
||||
<connections>
|
||||
<action selector="centerSelectionInVisibleArea:" target="-1" id="IOG-6D-g5B"/>
|
||||
</connections>
|
||||
</menuItem>
|
||||
</items>
|
||||
</menu>
|
||||
</menuItem>
|
||||
<menuItem title="Spelling and Grammar" id="Dv1-io-Yv7">
|
||||
<modifierMask key="keyEquivalentModifierMask"/>
|
||||
<menu key="submenu" title="Spelling" id="3IN-sU-3Bg">
|
||||
<items>
|
||||
<menuItem title="Show Spelling and Grammar" keyEquivalent=":" id="HFo-cy-zxI">
|
||||
<connections>
|
||||
<action selector="showGuessPanel:" target="-1" id="vFj-Ks-hy3"/>
|
||||
</connections>
|
||||
</menuItem>
|
||||
<menuItem title="Check Document Now" keyEquivalent=";" id="hz2-CU-CR7">
|
||||
<connections>
|
||||
<action selector="checkSpelling:" target="-1" id="fz7-VC-reM"/>
|
||||
</connections>
|
||||
</menuItem>
|
||||
<menuItem isSeparatorItem="YES" id="bNw-od-mp5"/>
|
||||
<menuItem title="Check Spelling While Typing" id="rbD-Rh-wIN">
|
||||
<modifierMask key="keyEquivalentModifierMask"/>
|
||||
<connections>
|
||||
<action selector="toggleContinuousSpellChecking:" target="-1" id="7w6-Qz-0kB"/>
|
||||
</connections>
|
||||
</menuItem>
|
||||
<menuItem title="Check Grammar With Spelling" id="mK6-2p-4JG">
|
||||
<modifierMask key="keyEquivalentModifierMask"/>
|
||||
<connections>
|
||||
<action selector="toggleGrammarChecking:" target="-1" id="muD-Qn-j4w"/>
|
||||
</connections>
|
||||
</menuItem>
|
||||
<menuItem title="Correct Spelling Automatically" id="78Y-hA-62v">
|
||||
<modifierMask key="keyEquivalentModifierMask"/>
|
||||
<connections>
|
||||
<action selector="toggleAutomaticSpellingCorrection:" target="-1" id="2lM-Qi-WAP"/>
|
||||
</connections>
|
||||
</menuItem>
|
||||
</items>
|
||||
</menu>
|
||||
</menuItem>
|
||||
<menuItem title="Substitutions" id="9ic-FL-obx">
|
||||
<modifierMask key="keyEquivalentModifierMask"/>
|
||||
<menu key="submenu" title="Substitutions" id="FeM-D8-WVr">
|
||||
<items>
|
||||
<menuItem title="Show Substitutions" id="z6F-FW-3nz">
|
||||
<modifierMask key="keyEquivalentModifierMask"/>
|
||||
<connections>
|
||||
<action selector="orderFrontSubstitutionsPanel:" target="-1" id="oku-mr-iSq"/>
|
||||
</connections>
|
||||
</menuItem>
|
||||
<menuItem isSeparatorItem="YES" id="gPx-C9-uUO"/>
|
||||
<menuItem title="Smart Copy/Paste" id="9yt-4B-nSM">
|
||||
<modifierMask key="keyEquivalentModifierMask"/>
|
||||
<connections>
|
||||
<action selector="toggleSmartInsertDelete:" target="-1" id="3IJ-Se-DZD"/>
|
||||
</connections>
|
||||
</menuItem>
|
||||
<menuItem title="Smart Quotes" id="hQb-2v-fYv">
|
||||
<modifierMask key="keyEquivalentModifierMask"/>
|
||||
<connections>
|
||||
<action selector="toggleAutomaticQuoteSubstitution:" target="-1" id="ptq-xd-QOA"/>
|
||||
</connections>
|
||||
</menuItem>
|
||||
<menuItem title="Smart Dashes" id="rgM-f4-ycn">
|
||||
<modifierMask key="keyEquivalentModifierMask"/>
|
||||
<connections>
|
||||
<action selector="toggleAutomaticDashSubstitution:" target="-1" id="oCt-pO-9gS"/>
|
||||
</connections>
|
||||
</menuItem>
|
||||
<menuItem title="Smart Links" id="cwL-P1-jid">
|
||||
<modifierMask key="keyEquivalentModifierMask"/>
|
||||
<connections>
|
||||
<action selector="toggleAutomaticLinkDetection:" target="-1" id="Gip-E3-Fov"/>
|
||||
</connections>
|
||||
</menuItem>
|
||||
<menuItem title="Data Detectors" id="tRr-pd-1PS">
|
||||
<modifierMask key="keyEquivalentModifierMask"/>
|
||||
<connections>
|
||||
<action selector="toggleAutomaticDataDetection:" target="-1" id="R1I-Nq-Kbl"/>
|
||||
</connections>
|
||||
</menuItem>
|
||||
<menuItem title="Text Replacement" id="HFQ-gK-NFA">
|
||||
<modifierMask key="keyEquivalentModifierMask"/>
|
||||
<connections>
|
||||
<action selector="toggleAutomaticTextReplacement:" target="-1" id="DvP-Fe-Py6"/>
|
||||
</connections>
|
||||
</menuItem>
|
||||
</items>
|
||||
</menu>
|
||||
</menuItem>
|
||||
<menuItem title="Transformations" id="2oI-Rn-ZJC">
|
||||
<modifierMask key="keyEquivalentModifierMask"/>
|
||||
<menu key="submenu" title="Transformations" id="c8a-y6-VQd">
|
||||
<items>
|
||||
<menuItem title="Make Upper Case" id="vmV-6d-7jI">
|
||||
<modifierMask key="keyEquivalentModifierMask"/>
|
||||
<connections>
|
||||
<action selector="uppercaseWord:" target="-1" id="sPh-Tk-edu"/>
|
||||
</connections>
|
||||
</menuItem>
|
||||
<menuItem title="Make Lower Case" id="d9M-CD-aMd">
|
||||
<modifierMask key="keyEquivalentModifierMask"/>
|
||||
<connections>
|
||||
<action selector="lowercaseWord:" target="-1" id="iUZ-b5-hil"/>
|
||||
</connections>
|
||||
</menuItem>
|
||||
<menuItem title="Capitalize" id="UEZ-Bs-lqG">
|
||||
<modifierMask key="keyEquivalentModifierMask"/>
|
||||
<connections>
|
||||
<action selector="capitalizeWord:" target="-1" id="26H-TL-nsh"/>
|
||||
</connections>
|
||||
</menuItem>
|
||||
</items>
|
||||
</menu>
|
||||
</menuItem>
|
||||
<menuItem title="Speech" id="xrE-MZ-jX0">
|
||||
<modifierMask key="keyEquivalentModifierMask"/>
|
||||
<menu key="submenu" title="Speech" id="3rS-ZA-NoH">
|
||||
<items>
|
||||
<menuItem title="Start Speaking" id="Ynk-f8-cLZ">
|
||||
<modifierMask key="keyEquivalentModifierMask"/>
|
||||
<connections>
|
||||
<action selector="startSpeaking:" target="-1" id="654-Ng-kyl"/>
|
||||
</connections>
|
||||
</menuItem>
|
||||
<menuItem title="Stop Speaking" id="Oyz-dy-DGm">
|
||||
<modifierMask key="keyEquivalentModifierMask"/>
|
||||
<connections>
|
||||
<action selector="stopSpeaking:" target="-1" id="dX8-6p-jy9"/>
|
||||
</connections>
|
||||
</menuItem>
|
||||
</items>
|
||||
</menu>
|
||||
</menuItem>
|
||||
</items>
|
||||
</menu>
|
||||
</menuItem>
|
||||
<menuItem title="View" id="H8h-7b-M4v">
|
||||
<modifierMask key="keyEquivalentModifierMask"/>
|
||||
<menu key="submenu" title="View" id="HyV-fh-RgO">
|
||||
<items>
|
||||
<menuItem title="Enter Full Screen" keyEquivalent="f" id="4J7-dP-txa">
|
||||
<modifierMask key="keyEquivalentModifierMask" control="YES" command="YES"/>
|
||||
<connections>
|
||||
<action selector="toggleFullScreen:" target="-1" id="dU3-MA-1Rq"/>
|
||||
</connections>
|
||||
</menuItem>
|
||||
</items>
|
||||
</menu>
|
||||
</menuItem>
|
||||
<menuItem title="Window" id="aUF-d1-5bR">
|
||||
<modifierMask key="keyEquivalentModifierMask"/>
|
||||
<menu key="submenu" title="Window" systemMenu="window" id="Td7-aD-5lo">
|
||||
<items>
|
||||
<menuItem title="Minimize" keyEquivalent="m" id="OY7-WF-poV">
|
||||
<connections>
|
||||
<action selector="performMiniaturize:" target="-1" id="VwT-WD-YPe"/>
|
||||
</connections>
|
||||
</menuItem>
|
||||
<menuItem title="Zoom" id="R4o-n2-Eq4">
|
||||
<modifierMask key="keyEquivalentModifierMask"/>
|
||||
<connections>
|
||||
<action selector="performZoom:" target="-1" id="DIl-cC-cCs"/>
|
||||
</connections>
|
||||
</menuItem>
|
||||
<menuItem isSeparatorItem="YES" id="eu3-7i-yIM"/>
|
||||
<menuItem title="Bring All to Front" id="LE2-aR-0XJ">
|
||||
<modifierMask key="keyEquivalentModifierMask"/>
|
||||
<connections>
|
||||
<action selector="arrangeInFront:" target="-1" id="DRN-fu-gQh"/>
|
||||
</connections>
|
||||
</menuItem>
|
||||
</items>
|
||||
</menu>
|
||||
</menuItem>
|
||||
<menuItem title="Help" id="EPT-qC-fAb">
|
||||
<modifierMask key="keyEquivalentModifierMask"/>
|
||||
<menu key="submenu" title="Help" systemMenu="help" id="rJ0-wn-3NY"/>
|
||||
</menuItem>
|
||||
</items>
|
||||
<point key="canvasLocation" x="142" y="-258"/>
|
||||
</menu>
|
||||
<window title="APP_NAME" allowsToolTipsWhenApplicationIsInactive="NO" autorecalculatesKeyViewLoop="NO" releasedWhenClosed="NO" animationBehavior="default" id="QvC-M9-y7g" customClass="MainFlutterWindow" customModule="Runner" customModuleProvider="target">
|
||||
<windowStyleMask key="styleMask" titled="YES" closable="YES" miniaturizable="YES" resizable="YES"/>
|
||||
<rect key="contentRect" x="335" y="390" width="800" height="600"/>
|
||||
<rect key="screenRect" x="0.0" y="0.0" width="2560" height="1577"/>
|
||||
<view key="contentView" wantsLayer="YES" id="EiT-Mj-1SZ">
|
||||
<rect key="frame" x="0.0" y="0.0" width="800" height="600"/>
|
||||
<autoresizingMask key="autoresizingMask"/>
|
||||
</view>
|
||||
</window>
|
||||
</objects>
|
||||
</document>
|
||||
@@ -0,0 +1,14 @@
|
||||
// Application-level settings for the Runner target.
|
||||
//
|
||||
// This may be replaced with something auto-generated from metadata (e.g., pubspec.yaml) in the
|
||||
// future. If not, the values below would default to using the project name when this becomes a
|
||||
// 'flutter create' template.
|
||||
|
||||
// The application's name. By default this is also the title of the Flutter window.
|
||||
PRODUCT_NAME = Prisma
|
||||
|
||||
// The application's bundle identifier
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.rardog.prisma
|
||||
|
||||
// The copyright displayed in application information
|
||||
PRODUCT_COPYRIGHT = Copyright © 2026 com.rardog. All rights reserved.
|
||||
@@ -0,0 +1,2 @@
|
||||
#include "../../Flutter/Flutter-Debug.xcconfig"
|
||||
#include "Warnings.xcconfig"
|
||||
@@ -0,0 +1,2 @@
|
||||
#include "../../Flutter/Flutter-Release.xcconfig"
|
||||
#include "Warnings.xcconfig"
|
||||
@@ -0,0 +1,13 @@
|
||||
WARNING_CFLAGS = -Wall -Wconditional-uninitialized -Wnullable-to-nonnull-conversion -Wmissing-method-return-type -Woverlength-strings
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES
|
||||
CLANG_UNDEFINED_BEHAVIOR_SANITIZER_NULLABILITY = YES
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES
|
||||
CLANG_WARN_PRAGMA_PACK = YES
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES
|
||||
CLANG_WARN_COMMA = YES
|
||||
GCC_WARN_STRICT_SELECTOR_MATCH = YES
|
||||
CLANG_WARN_OBJC_REPEATED_USE_OF_WEAK = YES
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES
|
||||
GCC_WARN_SHADOW = YES
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES
|
||||
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.app-sandbox</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-jit</key>
|
||||
<true/>
|
||||
<key>com.apple.security.network.server</key>
|
||||
<true/>
|
||||
<key>com.apple.security.network.client</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||