feat: swipeable media pager, favorite artists, optimized video preview, Gitea updates and v2.0.2 release
This commit is contained in:
@@ -10,3 +10,5 @@ dist/
|
|||||||
.flutter-plugins-dependencies
|
.flutter-plugins-dependencies
|
||||||
android/local.properties
|
android/local.properties
|
||||||
*.log
|
*.log
|
||||||
|
*.apk
|
||||||
|
*.tar.gz
|
||||||
|
|||||||
@@ -1,104 +1,124 @@
|
|||||||
# Lunaris
|
# 🌙 Lunaris
|
||||||
|
|
||||||
Lunaris — локальное Flutter-приложение для просмотра booru, gelbooru, rule34-подобных источников и artist-архивов в Pinterest-стиле.
|
<p align="center">
|
||||||
|
<img src="assets/branding/app_icon.png" width="128" height="128" alt="Lunaris Logo" />
|
||||||
|
</p>
|
||||||
|
|
||||||
Приложение не использует отдельный backend-сервер. Вся логика живет внутри Flutter: API-провайдеры, локальный кеш metadata, избранное, коллекции, история просмотров, фильтры, диагностика, загрузки и обновления.
|
<p align="center">
|
||||||
|
<strong>Современный кроссплатформенный клиент для Booru и Creator-архивов</strong><br>
|
||||||
|
Плавный Pinterest-стиль, поддержка фото, видео и GIF, умный черный список, встроенный менеджер загрузок и автообновление.
|
||||||
|
</p>
|
||||||
|
|
||||||
## Что умеет
|
<p align="center">
|
||||||
|
<img src="https://img.shields.io/badge/Version-2.0.2-blueviolet?style=for-the-badge" alt="Version 2.0.2" />
|
||||||
|
<img src="https://img.shields.io/badge/Flutter-3.47+-02569B?style=for-the-badge&logo=flutter&logoColor=white" alt="Flutter" />
|
||||||
|
<img src="https://img.shields.io/badge/Platform-Android%20%7C%20Linux%20%7C%20Windows-brightgreen?style=for-the-badge" alt="Platforms" />
|
||||||
|
<img src="https://img.shields.io/badge/Gitea-RarDog%2FLunaris-orange?style=for-the-badge&logo=gitea&logoColor=white" alt="Gitea" />
|
||||||
|
</p>
|
||||||
|
|
||||||
- Masonry feed с несколькими провайдерами и естественно перемешанной выдачей.
|
---
|
||||||
- Поиск тегов через chip-input с подсказками из активных API.
|
|
||||||
- Избранное, история просмотров, коллекции и пакетное добавление в коллекции.
|
|
||||||
- Smart blacklist/whitelist по тегам, группам тегов, рейтингу, провайдеру, типу файла и score.
|
|
||||||
- Details viewer для фото, GIF, видео и SWF на Windows.
|
|
||||||
- Удобный видеоплеер с fullscreen, repeat, mute, half-volume и сохранением настроек.
|
|
||||||
- Mobile image zoom: pinch-to-zoom и double tap в details.
|
|
||||||
- Опциональное автоскачивание новых favorites с отдельным удалением локального файла без удаления из избранного.
|
|
||||||
- Отдельная вкладка Artists для Kemono/Coomer без смешивания с обычной booru-лентой.
|
|
||||||
- Управление провайдерами, health-check и provider diagnostics.
|
|
||||||
- Кастомизация интерфейса: тема, язык RU/EN, акцентный цвет, видимые вкладки, beta/experimental обновления.
|
|
||||||
- Экспорт и импорт настроек JSON.
|
|
||||||
- Проверка Gitea Releases и скачивание APK/Windows installer прямо из приложения.
|
|
||||||
|
|
||||||
## Скачать
|
## ✨ Основные возможности
|
||||||
|
|
||||||
Готовые сборки лежат в Gitea Releases:
|
- **📱 Плавная лента в стиле Pinterest**: Masonry-сетка с адаптивным количеством колонок для мобильных и ПК.
|
||||||
|
- **⚡ Сверхбыстрые видео-превью**: Мгновенная загрузка превью без фризов и лишней нагрузки на процессор и память смартфона.
|
||||||
|
- **↔️ Листание постов влево и вправо**: Полноэкранный пейджер с плавными свайпами между фото и видео во всех разделах (Лента, Авторы, Избранное, Коллекции, История просмотров).
|
||||||
|
- **🖼️ Мульти-медиа в одном посте**: Удобная карусель с индикатором `1 / 4` для постов с несколькими файлами.
|
||||||
|
- **⭐ Любимые авторы сверху**: Верхняя панель со «сториз»-аватарами любимых создателей и мгновенным просмотром их свежих фото и видео.
|
||||||
|
- **🐾 Поддержка Pawchive**: Интеграция с `pawchive.pw` для просмотра профилей авторов, связанных аккаунтов, тегов и работ с Patreon, Fanbox, Fantia, Discord, Boosty и др.
|
||||||
|
- **🔍 Умный поиск и автодополнение**: Интерактивные чипсы тегов с подсказками от активных API. Быстрое исключение (`-tag`), добавление (`+tag`) или отправка в черный список по долгому тапу.
|
||||||
|
- **🛡️ Продвинутый Blacklist / Whitelist**: Фильтрация по тегам, категориям, рейтингу контента (safe/questionable/explicit), провайдерам и минимальному score.
|
||||||
|
- **🎬 Встроенный плеер**: Удобное воспроизведение WebM / MP4, запоминание настроек звука, fullscreen и повтор.
|
||||||
|
- **💾 Менеджер загрузок и коллекции**: Скачивание оригиналов в высоком качестве, тематические папки-коллекции и автозагрузка избранного.
|
||||||
|
- **🔄 Встроенное автообновление**: Проверка и скачивание свежих релизов напрямую из личного репозитория Gitea.
|
||||||
|
|
||||||
[Lunaris 2.0.1](https://gitea.rardogsynapse.online/RarDog/RuleGelApp/releases/tag/v2.0.1)
|
---
|
||||||
|
|
||||||
- `Lunaris-v2.0.1.apk` — APK для Android.
|
## 🌐 Активные провайдеры
|
||||||
- `LunarisSetup-v2.0.1.exe` — установщик для Windows.
|
|
||||||
- `LunarisPortable-v2.0.1.zip` — portable-версия для Windows.
|
|
||||||
|
|
||||||
## Провайдеры
|
| Провайдер | Тип API | Назначение |
|
||||||
|
|---|---|---|
|
||||||
|
| **Gelbooru** | XML / JSON API | Основная база аниме-артов |
|
||||||
|
| **Rule34** | Booru API | Популярный booru-источник |
|
||||||
|
| **Realbooru** | HTML Scraper | Фото и реалистичный контент |
|
||||||
|
| **e621 / e926** | JSON API | Фурри- и антро-арт с богатой системой тегов |
|
||||||
|
| **Pawchive** | REST API v1 | Архивы авторов Patreon, Pixiv Fanbox, Fantia и др. |
|
||||||
|
|
||||||
Обычный feed:
|
---
|
||||||
|
|
||||||
- Gelbooru
|
## 📥 Скачать приложение
|
||||||
- Rule34
|
|
||||||
- Safebooru
|
|
||||||
- Konachan
|
|
||||||
- Yande.re
|
|
||||||
- e621 / e926
|
|
||||||
- Xbooru
|
|
||||||
- CosBooru
|
|
||||||
- Realbooru отключен по умолчанию, если публичный API недоступен
|
|
||||||
|
|
||||||
Artists flow:
|
Готовые сборки для всех поддерживаемых платформ доступны на странице релизов:
|
||||||
|
|
||||||
- Kemono
|
🔗 **[Gitea Releases (RarDog/Lunaris)](https://gitea.rardogsynapse.online/RarDog/Lunaris/releases)**
|
||||||
- Coomer
|
|
||||||
|
|
||||||
Kemono/Coomer остаются отдельным разделом Artists и не попадают в общий booru feed.
|
- **Android**: `Lunaris-v2.0.2.apk`
|
||||||
|
- **Linux (x64)**: `Lunaris-v2.0.2-linux-x64.tar.gz`
|
||||||
|
- **Windows**: `LunarisSetup-v2.0.2.exe` / `LunarisPortable-v2.0.2.zip`
|
||||||
|
|
||||||
## Архитектура
|
---
|
||||||
|
|
||||||
- `lib/core` — HTTP, база, кеш, ошибки, Result и утилиты.
|
## 🛠️ Сборка из исходников
|
||||||
- `lib/backend/models` — доменные модели.
|
|
||||||
- `lib/backend/providers` — API-провайдеры и ProviderManager.
|
|
||||||
- `lib/backend/repositories` — доступ к локальному Isar-хранилищу.
|
|
||||||
- `lib/backend/services` — Feed/Search/Favorites/Collections/Settings/Diagnostics/Downloads/Updates.
|
|
||||||
- `lib/backend/di` — Riverpod providers для backend/core.
|
|
||||||
- `lib/app` — приложение, роутер, тема, локализация, motion и responsive helpers.
|
|
||||||
- `lib/shared/widgets` — общие UI-компоненты.
|
|
||||||
- `lib/features` — экраны и контроллеры фич.
|
|
||||||
|
|
||||||
## Запуск
|
### Требования
|
||||||
|
- Flutter SDK `>= 3.19.0`
|
||||||
|
- OpenJDK 17
|
||||||
|
- Android SDK (для сборки APK)
|
||||||
|
- Clang / CMake / GTK-3 (для сборки под Linux)
|
||||||
|
|
||||||
|
### Команды разработки
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# Клонирование репозитория
|
||||||
|
git clone https://gitea.rardogsynapse.online/RarDog/Lunaris.git
|
||||||
|
cd Lunaris
|
||||||
|
|
||||||
|
# Установка зависимостей
|
||||||
flutter pub get
|
flutter pub get
|
||||||
flutter run -d windows
|
|
||||||
flutter run -d android
|
|
||||||
```
|
|
||||||
|
|
||||||
## Сборка
|
# Запуск тестов и анализатора
|
||||||
|
|
||||||
```bash
|
|
||||||
flutter analyze
|
flutter analyze
|
||||||
flutter test
|
flutter test
|
||||||
flutter build apk
|
|
||||||
flutter build windows
|
# Запуск на подключенном устройстве
|
||||||
|
flutter run
|
||||||
```
|
```
|
||||||
|
|
||||||
Windows installer собирается через Inno Setup:
|
### Сборка релизов
|
||||||
|
|
||||||
```powershell
|
```bash
|
||||||
ISCC.exe installer\Lunaris.iss
|
# Android APK
|
||||||
|
flutter build apk --release
|
||||||
|
|
||||||
|
# Linux x64 Bundle
|
||||||
|
flutter build linux --release
|
||||||
|
|
||||||
|
# Windows Bundle
|
||||||
|
flutter build windows --release
|
||||||
```
|
```
|
||||||
|
|
||||||
## Как добавить провайдера
|
---
|
||||||
|
|
||||||
1. Создать класс, реализующий `ContentProvider`.
|
## 🏛️ Архитектура
|
||||||
2. Добавить mapper к единой модели `Post`.
|
|
||||||
3. Зарегистрировать `apiType` в `ProviderFactory`.
|
|
||||||
4. Добавить seed config в `ProviderRepository`, если провайдер нужен по умолчанию.
|
|
||||||
5. Добавить тесты parser/provider factory.
|
|
||||||
|
|
||||||
## Безопасность
|
```
|
||||||
|
lib/
|
||||||
|
├── app/ # Роутер, тема, локализация (RU/EN), анимации
|
||||||
|
├── backend/
|
||||||
|
│ ├── di/ # Riverpod providers для сервисов
|
||||||
|
│ ├── models/ # Post, ArtistProfile, AppSettings, AppUpdateInfo
|
||||||
|
│ ├── providers/ # Реализации API (Gelbooru, Rule34, Pawchive и др.)
|
||||||
|
│ ├── repositories/ # Доступ к локальной БД Isar
|
||||||
|
│ └── services/ # Бизнес-логика (Feed, Search, Favorites, Downloads, Updates)
|
||||||
|
├── core/ # Сеть (Dio), база данных (Isar), ошибки и кеши
|
||||||
|
├── features/ # Модули экранов (feed, post, artists, favorites, settings)
|
||||||
|
└── shared/widgets/ # Переиспользуемые виджеты (PostCard, MasonryGrid, TagChip)
|
||||||
|
```
|
||||||
|
|
||||||
- Feed работает только с metadata/preview и не скачивает original/full files автоматически.
|
---
|
||||||
- Загрузка файлов происходит только по явному действию пользователя или при включенной настройке auto-download favorites.
|
|
||||||
- Приложение уважает API и rate limits сайтов.
|
## 🔒 Приватность и безопасность
|
||||||
- NSFW/blur/blacklist настраиваются локально.
|
|
||||||
- API keys/custom headers хранятся локально и экспортируются только через явный export settings.
|
- **Полная автономность**: Приложение не отправляет телеметрию и работает без внешних серверов-посредников.
|
||||||
- Локальные пути скачанных файлов не попадают в JSON export/import.
|
- **Локальное хранение**: Все настройки, история поиска, черный список и избранное хранятся исключительно на вашем устройстве в локальной базе Isar.
|
||||||
|
- **Бережное отношение к трафику**: В ленте загружаются только оптимизированные превью. Тяжелые оригиналы и видео загружаются только при открытии или скачивании.
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
const appDisplayVersion = '2.0.1';
|
const appDisplayVersion = '2.0.2';
|
||||||
const appBuildNumber = 23;
|
const appBuildNumber = 24;
|
||||||
const appReleaseChannel = 'Stable';
|
const appReleaseChannel = 'Stable';
|
||||||
|
|||||||
@@ -1,4 +1,16 @@
|
|||||||
const lunarisChangelog = [
|
const lunarisChangelog = [
|
||||||
|
LunarisChange(
|
||||||
|
version: '2.0.2',
|
||||||
|
title: 'Swipeable media pager, favorite artists, performance and Pawchive',
|
||||||
|
bullets: [
|
||||||
|
'Full horizontal post pager across mobile and desktop in Feed, Artists, Favorites, Collections, and Viewed history.',
|
||||||
|
'Eliminated video preview stutter and lag in grid feed by using high-performance cached thumbnails and on-demand playback.',
|
||||||
|
'Added favorite artists bar at the top of the Artists tab with quick access and recent photos/videos stream.',
|
||||||
|
'Multi-image posts now feature an in-post navigation indicator with image switching.',
|
||||||
|
'New Pawchive provider support and cleaned up deprecated providers.',
|
||||||
|
'In-app update checks and downloads connected directly to RarDog Gitea releases.',
|
||||||
|
],
|
||||||
|
),
|
||||||
LunarisChange(
|
LunarisChange(
|
||||||
version: '2.0.1',
|
version: '2.0.1',
|
||||||
title: 'Localization, zoom and favorite downloads',
|
title: 'Localization, zoom and favorite downloads',
|
||||||
|
|||||||
+19
-3
@@ -40,11 +40,27 @@ final appRouterProvider = Provider<GoRouter>((ref) {
|
|||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/post/:providerId/:postId',
|
path: '/post/:providerId/:postId',
|
||||||
builder: (context, state) => PostDetailsScreen(
|
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']!,
|
providerId: state.pathParameters['providerId']!,
|
||||||
postId: state.pathParameters['postId']!,
|
postId: state.pathParameters['postId']!,
|
||||||
initialPost: state.extra is Post ? state.extra! as Post : null,
|
initialPost: initialPost,
|
||||||
),
|
postsList: postsList,
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/post/:providerId/:postId/similar',
|
path: '/post/:providerId/:postId/similar',
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ class AppUpdateInfo {
|
|||||||
this.apkUrl,
|
this.apkUrl,
|
||||||
this.windowsInstallerUrl,
|
this.windowsInstallerUrl,
|
||||||
this.portableZipUrl,
|
this.portableZipUrl,
|
||||||
|
this.linuxTarGzUrl,
|
||||||
});
|
});
|
||||||
|
|
||||||
final String version;
|
final String version;
|
||||||
@@ -20,7 +21,11 @@ class AppUpdateInfo {
|
|||||||
final String? apkUrl;
|
final String? apkUrl;
|
||||||
final String? windowsInstallerUrl;
|
final String? windowsInstallerUrl;
|
||||||
final String? portableZipUrl;
|
final String? portableZipUrl;
|
||||||
|
final String? linuxTarGzUrl;
|
||||||
|
|
||||||
bool get hasAssets =>
|
bool get hasAssets =>
|
||||||
apkUrl != null || windowsInstallerUrl != null || portableZipUrl != null;
|
apkUrl != null ||
|
||||||
|
windowsInstallerUrl != null ||
|
||||||
|
portableZipUrl != null ||
|
||||||
|
linuxTarGzUrl != null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -114,3 +114,14 @@ class Post {
|
|||||||
const {},
|
const {},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class PostNavigationContext {
|
||||||
|
const PostNavigationContext({
|
||||||
|
required this.currentPost,
|
||||||
|
required this.posts,
|
||||||
|
});
|
||||||
|
|
||||||
|
final Post currentPost;
|
||||||
|
final List<Post> posts;
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -40,10 +40,10 @@ class ProviderRepository {
|
|||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
),
|
),
|
||||||
ContentProviderConfig(
|
ContentProviderConfig(
|
||||||
id: 'safebooru',
|
id: 'realbooru',
|
||||||
name: 'Safebooru',
|
name: 'Realbooru',
|
||||||
baseUrl: 'https://safebooru.org',
|
baseUrl: 'https://realbooru.com',
|
||||||
apiType: 'gelbooru',
|
apiType: 'realbooru_html',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
priority: 2,
|
priority: 2,
|
||||||
timeoutSeconds: 20,
|
timeoutSeconds: 20,
|
||||||
@@ -51,44 +51,16 @@ class ProviderRepository {
|
|||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
),
|
),
|
||||||
ContentProviderConfig(
|
|
||||||
id: 'konachan',
|
|
||||||
name: 'Konachan',
|
|
||||||
baseUrl: 'https://konachan.com',
|
|
||||||
apiType: 'moebooru',
|
|
||||||
enabled: true,
|
|
||||||
priority: 3,
|
|
||||||
timeoutSeconds: 20,
|
|
||||||
customHeaders: const {},
|
|
||||||
createdAt: now,
|
|
||||||
updatedAt: now,
|
|
||||||
),
|
|
||||||
ContentProviderConfig(
|
|
||||||
id: 'yandere',
|
|
||||||
name: 'Yande.re',
|
|
||||||
baseUrl: 'https://yande.re',
|
|
||||||
apiType: 'moebooru',
|
|
||||||
enabled: true,
|
|
||||||
priority: 4,
|
|
||||||
timeoutSeconds: 20,
|
|
||||||
customHeaders: const {
|
|
||||||
'query.api_version': '2',
|
|
||||||
'query.include_tags': '1',
|
|
||||||
'query.filter': '1',
|
|
||||||
},
|
|
||||||
createdAt: now,
|
|
||||||
updatedAt: now,
|
|
||||||
),
|
|
||||||
ContentProviderConfig(
|
ContentProviderConfig(
|
||||||
id: 'e621',
|
id: 'e621',
|
||||||
name: 'e621',
|
name: 'e621',
|
||||||
baseUrl: 'https://e621.net',
|
baseUrl: 'https://e621.net',
|
||||||
apiType: 'e621',
|
apiType: 'e621',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
priority: 5,
|
priority: 3,
|
||||||
timeoutSeconds: 20,
|
timeoutSeconds: 20,
|
||||||
customHeaders: const {
|
customHeaders: const {
|
||||||
'User-Agent': 'Lunaris/2.0.1 Flutter local booru browser',
|
'User-Agent': 'Lunaris/2.0.2 Flutter local booru browser',
|
||||||
},
|
},
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
@@ -99,76 +71,10 @@ class ProviderRepository {
|
|||||||
baseUrl: 'https://e926.net',
|
baseUrl: 'https://e926.net',
|
||||||
apiType: 'e621',
|
apiType: 'e621',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
priority: 6,
|
priority: 4,
|
||||||
timeoutSeconds: 20,
|
timeoutSeconds: 20,
|
||||||
customHeaders: const {
|
customHeaders: const {
|
||||||
'User-Agent': 'Lunaris/2.0.1 Flutter local booru browser',
|
'User-Agent': 'Lunaris/2.0.2 Flutter local booru browser',
|
||||||
},
|
|
||||||
createdAt: now,
|
|
||||||
updatedAt: now,
|
|
||||||
),
|
|
||||||
ContentProviderConfig(
|
|
||||||
id: 'realbooru',
|
|
||||||
name: 'Realbooru',
|
|
||||||
baseUrl: 'https://realbooru.com',
|
|
||||||
apiType: 'realbooru_html',
|
|
||||||
enabled: true,
|
|
||||||
priority: 7,
|
|
||||||
timeoutSeconds: 20,
|
|
||||||
customHeaders: const {},
|
|
||||||
createdAt: now,
|
|
||||||
updatedAt: now,
|
|
||||||
),
|
|
||||||
ContentProviderConfig(
|
|
||||||
id: 'xbooru',
|
|
||||||
name: 'Xbooru',
|
|
||||||
baseUrl: 'https://xbooru.com',
|
|
||||||
apiType: 'gelbooru',
|
|
||||||
enabled: true,
|
|
||||||
priority: 8,
|
|
||||||
timeoutSeconds: 20,
|
|
||||||
customHeaders: const {},
|
|
||||||
createdAt: now,
|
|
||||||
updatedAt: now,
|
|
||||||
),
|
|
||||||
ContentProviderConfig(
|
|
||||||
id: 'paheal',
|
|
||||||
name: 'Rule34 Paheal',
|
|
||||||
baseUrl: 'https://rule34.paheal.net',
|
|
||||||
apiType: 'paheal',
|
|
||||||
enabled: true,
|
|
||||||
priority: 9,
|
|
||||||
timeoutSeconds: 20,
|
|
||||||
customHeaders: const {
|
|
||||||
'User-Agent': 'Lunaris/2.0.1 Flutter local booru browser',
|
|
||||||
},
|
|
||||||
createdAt: now,
|
|
||||||
updatedAt: now,
|
|
||||||
),
|
|
||||||
ContentProviderConfig(
|
|
||||||
id: 'kemono',
|
|
||||||
name: 'Kemono',
|
|
||||||
baseUrl: 'https://kemono.su',
|
|
||||||
apiType: 'kemono',
|
|
||||||
enabled: true,
|
|
||||||
priority: 10,
|
|
||||||
timeoutSeconds: 20,
|
|
||||||
customHeaders: const {
|
|
||||||
'query.creator_api_base': 'https://kemono-api.mbaharip.com',
|
|
||||||
},
|
|
||||||
createdAt: now,
|
|
||||||
updatedAt: now,
|
|
||||||
),
|
|
||||||
ContentProviderConfig(
|
|
||||||
id: 'coomer',
|
|
||||||
name: 'Coomer',
|
|
||||||
baseUrl: 'https://coomer.su',
|
|
||||||
apiType: 'coomer',
|
|
||||||
enabled: false,
|
|
||||||
priority: 11,
|
|
||||||
timeoutSeconds: 20,
|
|
||||||
customHeaders: const {
|
|
||||||
'query.creator_api_base': 'https://kemono-api.mbaharip.com',
|
|
||||||
},
|
},
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
@@ -179,7 +85,7 @@ class ProviderRepository {
|
|||||||
baseUrl: 'https://pawchive.pw',
|
baseUrl: 'https://pawchive.pw',
|
||||||
apiType: 'pawchive',
|
apiType: 'pawchive',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
priority: 12,
|
priority: 5,
|
||||||
timeoutSeconds: 20,
|
timeoutSeconds: 20,
|
||||||
customHeaders: const {},
|
customHeaders: const {},
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
@@ -192,11 +98,22 @@ class ProviderRepository {
|
|||||||
return _databaseService.safeWrite((isar) async {
|
return _databaseService.safeWrite((isar) async {
|
||||||
final existingProviders =
|
final existingProviders =
|
||||||
await isar.providerConfigEntitys.where().findAll();
|
await isar.providerConfigEntitys.where().findAll();
|
||||||
|
const removedIds = {
|
||||||
|
'safebooru',
|
||||||
|
'konachan',
|
||||||
|
'yandere',
|
||||||
|
'xbooru',
|
||||||
|
'paheal',
|
||||||
|
'kemono',
|
||||||
|
'coomer',
|
||||||
|
};
|
||||||
for (final provider in existingProviders) {
|
for (final provider in existingProviders) {
|
||||||
if (provider.providerId == 'cosbooru') {
|
if (provider.providerId == 'cosbooru') {
|
||||||
provider.enabled = false;
|
provider.enabled = false;
|
||||||
provider.updatedAt = DateTime.now();
|
provider.updatedAt = DateTime.now();
|
||||||
await isar.providerConfigEntitys.put(provider);
|
await isar.providerConfigEntitys.put(provider);
|
||||||
|
} else if (removedIds.contains(provider.providerId)) {
|
||||||
|
await isar.providerConfigEntitys.delete(provider.isarId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
final seeds = seedProviders();
|
final seeds = seedProviders();
|
||||||
|
|||||||
@@ -48,12 +48,14 @@ class AppSettings {
|
|||||||
required this.lastFeedProviderIds,
|
required this.lastFeedProviderIds,
|
||||||
required this.lastFeedTopPeriod,
|
required this.lastFeedTopPeriod,
|
||||||
required this.lastFeedScrollOffset,
|
required this.lastFeedScrollOffset,
|
||||||
|
this.favoriteArtists = const [],
|
||||||
this.skippedUpdateVersion,
|
this.skippedUpdateVersion,
|
||||||
this.lastUpdateCheckAt,
|
this.lastUpdateCheckAt,
|
||||||
this.defaultRatingFilter,
|
this.defaultRatingFilter,
|
||||||
this.lastFeedRating,
|
this.lastFeedRating,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
final List<String> favoriteArtists;
|
||||||
final List<String> enabledProviderIds;
|
final List<String> enabledProviderIds;
|
||||||
final bool nsfwEnabled;
|
final bool nsfwEnabled;
|
||||||
final int cacheTtlHours;
|
final int cacheTtlHours;
|
||||||
@@ -96,11 +98,12 @@ class AppSettings {
|
|||||||
final String? lastFeedRating;
|
final String? lastFeedRating;
|
||||||
|
|
||||||
static const defaults = AppSettings(
|
static const defaults = AppSettings(
|
||||||
enabledProviderIds: ['gelbooru', 'rule34', 'safebooru'],
|
enabledProviderIds: ['gelbooru', 'rule34', 'realbooru'],
|
||||||
nsfwEnabled: true,
|
nsfwEnabled: true,
|
||||||
cacheTtlHours: 24,
|
cacheTtlHours: 24,
|
||||||
cacheMaxItems: 2000,
|
cacheMaxItems: 2000,
|
||||||
providerPriority: {'gelbooru': 0, 'rule34': 1, 'safebooru': 2},
|
providerPriority: {'gelbooru': 0, 'rule34': 1, 'realbooru': 2},
|
||||||
|
favoriteArtists: [],
|
||||||
themeMode: 'dark',
|
themeMode: 'dark',
|
||||||
languageCode: 'ru',
|
languageCode: 'ru',
|
||||||
appSeedColor: 0xFFE84D8A,
|
appSeedColor: 0xFFE84D8A,
|
||||||
@@ -180,8 +183,10 @@ class AppSettings {
|
|||||||
String? defaultRatingFilter,
|
String? defaultRatingFilter,
|
||||||
String? lastFeedRating,
|
String? lastFeedRating,
|
||||||
bool clearLastFeedRating = false,
|
bool clearLastFeedRating = false,
|
||||||
|
List<String>? favoriteArtists,
|
||||||
}) {
|
}) {
|
||||||
return AppSettings(
|
return AppSettings(
|
||||||
|
favoriteArtists: favoriteArtists ?? this.favoriteArtists,
|
||||||
enabledProviderIds: enabledProviderIds ?? this.enabledProviderIds,
|
enabledProviderIds: enabledProviderIds ?? this.enabledProviderIds,
|
||||||
nsfwEnabled: nsfwEnabled ?? this.nsfwEnabled,
|
nsfwEnabled: nsfwEnabled ?? this.nsfwEnabled,
|
||||||
cacheTtlHours: cacheTtlHours ?? this.cacheTtlHours,
|
cacheTtlHours: cacheTtlHours ?? this.cacheTtlHours,
|
||||||
@@ -233,6 +238,7 @@ class AppSettings {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Map<String, dynamic> toJson() => {
|
Map<String, dynamic> toJson() => {
|
||||||
|
'favoriteArtists': favoriteArtists,
|
||||||
'enabledProviderIds': enabledProviderIds,
|
'enabledProviderIds': enabledProviderIds,
|
||||||
'nsfwEnabled': nsfwEnabled,
|
'nsfwEnabled': nsfwEnabled,
|
||||||
'cacheTtlHours': cacheTtlHours,
|
'cacheTtlHours': cacheTtlHours,
|
||||||
@@ -365,6 +371,9 @@ class AppSettings {
|
|||||||
lastFeedScrollOffset:
|
lastFeedScrollOffset:
|
||||||
(json['lastFeedScrollOffset'] as num?)?.toDouble() ??
|
(json['lastFeedScrollOffset'] as num?)?.toDouble() ??
|
||||||
defaults.lastFeedScrollOffset,
|
defaults.lastFeedScrollOffset,
|
||||||
|
favoriteArtists: List<String>.from(
|
||||||
|
(json['favoriteArtists'] as List?) ?? defaults.favoriteArtists,
|
||||||
|
),
|
||||||
skippedUpdateVersion: json['skippedUpdateVersion'] as String?,
|
skippedUpdateVersion: json['skippedUpdateVersion'] as String?,
|
||||||
lastUpdateCheckAt: json['lastUpdateCheckAt'] as String?,
|
lastUpdateCheckAt: json['lastUpdateCheckAt'] as String?,
|
||||||
defaultRatingFilter: json['defaultRatingFilter'] as String?,
|
defaultRatingFilter: json['defaultRatingFilter'] as String?,
|
||||||
|
|||||||
@@ -11,9 +11,9 @@ class UpdateService {
|
|||||||
UpdateService(this._dio, this._settingsService);
|
UpdateService(this._dio, this._settingsService);
|
||||||
|
|
||||||
static const latestReleaseUrl =
|
static const latestReleaseUrl =
|
||||||
'https://gitea.rardogsynapse.online/api/v1/repos/RarDog/RuleGelApp/releases/latest';
|
'https://gitea.rardogsynapse.online/api/v1/repos/RarDog/Lunaris/releases/latest';
|
||||||
static const releasesUrl =
|
static const releasesUrl =
|
||||||
'https://gitea.rardogsynapse.online/api/v1/repos/RarDog/RuleGelApp/releases';
|
'https://gitea.rardogsynapse.online/api/v1/repos/RarDog/Lunaris/releases';
|
||||||
|
|
||||||
final Dio _dio;
|
final Dio _dio;
|
||||||
final SettingsService _settingsService;
|
final SettingsService _settingsService;
|
||||||
@@ -100,6 +100,7 @@ class UpdateService {
|
|||||||
String? apkUrl;
|
String? apkUrl;
|
||||||
String? installerUrl;
|
String? installerUrl;
|
||||||
String? portableUrl;
|
String? portableUrl;
|
||||||
|
String? linuxTarGzUrl;
|
||||||
for (final item in assets.whereType<Map>()) {
|
for (final item in assets.whereType<Map>()) {
|
||||||
final name = (item['name'] ?? '').toString().toLowerCase();
|
final name = (item['name'] ?? '').toString().toLowerCase();
|
||||||
final url = (item['browser_download_url'] ?? '').toString();
|
final url = (item['browser_download_url'] ?? '').toString();
|
||||||
@@ -107,6 +108,9 @@ class UpdateService {
|
|||||||
if (name.endsWith('.apk')) apkUrl = url;
|
if (name.endsWith('.apk')) apkUrl = url;
|
||||||
if (name.endsWith('.exe')) installerUrl = url;
|
if (name.endsWith('.exe')) installerUrl = url;
|
||||||
if (name.endsWith('.zip')) portableUrl = url;
|
if (name.endsWith('.zip')) portableUrl = url;
|
||||||
|
if (name.endsWith('.tar.gz') || name.endsWith('.tgz')) {
|
||||||
|
linuxTarGzUrl = url;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return AppUpdateInfo(
|
return AppUpdateInfo(
|
||||||
version: _versionFromTag(tag),
|
version: _versionFromTag(tag),
|
||||||
@@ -118,6 +122,7 @@ class UpdateService {
|
|||||||
apkUrl: apkUrl,
|
apkUrl: apkUrl,
|
||||||
windowsInstallerUrl: installerUrl,
|
windowsInstallerUrl: installerUrl,
|
||||||
portableZipUrl: portableUrl,
|
portableZipUrl: portableUrl,
|
||||||
|
linuxTarGzUrl: linuxTarGzUrl,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,6 +130,9 @@ class UpdateService {
|
|||||||
if (Platform.isAndroid) {
|
if (Platform.isAndroid) {
|
||||||
return info.apkUrl;
|
return info.apkUrl;
|
||||||
}
|
}
|
||||||
|
if (Platform.isLinux) {
|
||||||
|
return info.linuxTarGzUrl ?? info.portableZipUrl;
|
||||||
|
}
|
||||||
if (Platform.isWindows) {
|
if (Platform.isWindows) {
|
||||||
return info.windowsInstallerUrl ?? info.portableZipUrl;
|
return info.windowsInstallerUrl ?? info.portableZipUrl;
|
||||||
}
|
}
|
||||||
@@ -137,6 +145,7 @@ class UpdateService {
|
|||||||
parsed?.pathSegments.isEmpty ?? true ? '' : parsed!.pathSegments.last;
|
parsed?.pathSegments.isEmpty ?? true ? '' : parsed!.pathSegments.last;
|
||||||
if (fromUrl.contains('.')) return fromUrl;
|
if (fromUrl.contains('.')) return fromUrl;
|
||||||
if (Platform.isAndroid) return 'Lunaris-v${info.version}.apk';
|
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';
|
if (Platform.isWindows) return 'LunarisSetup-v${info.version}.exe';
|
||||||
return 'LunarisPortable-v${info.version}.zip';
|
return 'LunarisPortable-v${info.version}.zip';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -88,7 +88,10 @@ class ArtistPostsScreen extends ConsumerWidget {
|
|||||||
viewedKeys: viewedKeys,
|
viewedKeys: viewedKeys,
|
||||||
onOpen: (post) => context.push(
|
onOpen: (post) => context.push(
|
||||||
'/post/${post.providerId}/${post.id}',
|
'/post/${post.providerId}/${post.id}',
|
||||||
extra: post,
|
extra: PostNavigationContext(
|
||||||
|
currentPost: post,
|
||||||
|
posts: items,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
onFavorite: (post) async {
|
onFavorite: (post) async {
|
||||||
if (favoriteKeys.contains(post.cacheKey)) {
|
if (favoriteKeys.contains(post.cacheKey)) {
|
||||||
|
|||||||
@@ -1,14 +1,18 @@
|
|||||||
|
import 'dart:convert';
|
||||||
import 'package:cached_network_image/cached_network_image.dart';
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
import '../../../app/app.dart';
|
||||||
import '../../../app/responsive.dart';
|
import '../../../app/responsive.dart';
|
||||||
import '../../../backend/backend.dart';
|
import '../../../backend/backend.dart';
|
||||||
import '../../../core/utils/result.dart';
|
import '../../../core/utils/result.dart';
|
||||||
import '../../../shared/widgets/adaptive_scaffold.dart';
|
import '../../../shared/widgets/adaptive_scaffold.dart';
|
||||||
import '../../../shared/widgets/empty_view.dart';
|
import '../../../shared/widgets/empty_view.dart';
|
||||||
import '../../../shared/widgets/error_view.dart';
|
import '../../../shared/widgets/error_view.dart';
|
||||||
|
import '../../settings/presentation/settings_controller.dart';
|
||||||
|
import 'artist_posts_screen.dart';
|
||||||
|
|
||||||
final artistProviderConfigsProvider =
|
final artistProviderConfigsProvider =
|
||||||
FutureProvider<List<ContentProviderConfig>>((ref) async {
|
FutureProvider<List<ContentProviderConfig>>((ref) async {
|
||||||
@@ -37,6 +41,40 @@ class _ArtistsScreenState extends ConsumerState<ArtistsScreen> {
|
|||||||
Object? _error;
|
Object? _error;
|
||||||
List<ArtistProfile> _artists = const [];
|
List<ArtistProfile> _artists = const [];
|
||||||
|
|
||||||
|
String? _selectedFavoriteArtistKey;
|
||||||
|
|
||||||
|
Future<void> _toggleFavoriteArtist(
|
||||||
|
AppSettings settings,
|
||||||
|
ArtistProfile artist,
|
||||||
|
) async {
|
||||||
|
final current = List<String>.from(settings.favoriteArtists);
|
||||||
|
final item = _FavoriteArtistItem(
|
||||||
|
id: artist.id,
|
||||||
|
service: artist.service,
|
||||||
|
providerId: artist.providerId,
|
||||||
|
name: artist.displayName,
|
||||||
|
avatarUrl: artist.avatarUrl,
|
||||||
|
);
|
||||||
|
final key = item.key;
|
||||||
|
final index = current.indexWhere((e) {
|
||||||
|
try {
|
||||||
|
final parsed =
|
||||||
|
_FavoriteArtistItem.fromJson(jsonDecode(e) as Map<String, dynamic>);
|
||||||
|
return parsed.key == key;
|
||||||
|
} catch (_) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (index >= 0) {
|
||||||
|
current.removeAt(index);
|
||||||
|
} else {
|
||||||
|
current.insert(0, jsonEncode(item.toJson()));
|
||||||
|
}
|
||||||
|
await ref.read(settingsControllerProvider.notifier).saveSettings(
|
||||||
|
settings.copyWith(favoriteArtists: current),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
@@ -55,6 +93,27 @@ class _ArtistsScreenState extends ConsumerState<ArtistsScreen> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final configs = ref.watch(artistProviderConfigsProvider);
|
final configs = ref.watch(artistProviderConfigsProvider);
|
||||||
|
final settings =
|
||||||
|
ref.watch(appSettingsProvider).value ?? AppSettings.defaults;
|
||||||
|
final favoriteItems = settings.favoriteArtists
|
||||||
|
.map((e) {
|
||||||
|
try {
|
||||||
|
return _FavoriteArtistItem.fromJson(
|
||||||
|
jsonDecode(e) as Map<String, dynamic>);
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.whereType<_FavoriteArtistItem>()
|
||||||
|
.toList();
|
||||||
|
final favoriteKeys = favoriteItems.map((e) => e.key).toSet();
|
||||||
|
final selectedFav = favoriteItems.firstWhere(
|
||||||
|
(e) => e.key == _selectedFavoriteArtistKey,
|
||||||
|
orElse: () => favoriteItems.isNotEmpty
|
||||||
|
? favoriteItems.first
|
||||||
|
: const _FavoriteArtistItem.empty(),
|
||||||
|
);
|
||||||
|
|
||||||
return AdaptiveScaffold(
|
return AdaptiveScaffold(
|
||||||
title: 'Artists',
|
title: 'Artists',
|
||||||
actions: [
|
actions: [
|
||||||
@@ -71,7 +130,7 @@ class _ArtistsScreenState extends ConsumerState<ArtistsScreen> {
|
|||||||
if (items.isEmpty) {
|
if (items.isEmpty) {
|
||||||
return const EmptyView(
|
return const EmptyView(
|
||||||
title: 'No artist providers',
|
title: 'No artist providers',
|
||||||
message: 'Enable Kemono or Coomer in Providers.',
|
message: 'Enable an artist provider (such as Pawchive) in Providers.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
_providerId ??= items.first.id;
|
_providerId ??= items.first.id;
|
||||||
@@ -91,6 +150,32 @@ class _ArtistsScreenState extends ConsumerState<ArtistsScreen> {
|
|||||||
},
|
},
|
||||||
onSearch: () => _refresh(items),
|
onSearch: () => _refresh(items),
|
||||||
),
|
),
|
||||||
|
_FavoriteArtistsSection(
|
||||||
|
favorites: favoriteItems,
|
||||||
|
selectedKey: selectedFav.isEmpty ? null : selectedFav.key,
|
||||||
|
onSelect: (fav) =>
|
||||||
|
setState(() => _selectedFavoriteArtistKey = fav.key),
|
||||||
|
onOpenArtist: (fav) => context.push(
|
||||||
|
'/artists/${fav.providerId}/${fav.service}/${fav.id}?name=${Uri.encodeComponent(fav.name)}',
|
||||||
|
),
|
||||||
|
onRemoveFavorite: (fav) async {
|
||||||
|
final updated = List<String>.from(settings.favoriteArtists)
|
||||||
|
..removeWhere((e) {
|
||||||
|
try {
|
||||||
|
return _FavoriteArtistItem.fromJson(
|
||||||
|
jsonDecode(e) as Map<String, dynamic>)
|
||||||
|
.key ==
|
||||||
|
fav.key;
|
||||||
|
} catch (_) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await ref
|
||||||
|
.read(settingsControllerProvider.notifier)
|
||||||
|
.saveSettings(
|
||||||
|
settings.copyWith(favoriteArtists: updated));
|
||||||
|
},
|
||||||
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: _error != null
|
child: _error != null
|
||||||
? ErrorView(
|
? ErrorView(
|
||||||
@@ -129,8 +214,13 @@ class _ArtistsScreenState extends ConsumerState<ArtistsScreen> {
|
|||||||
return const _ArtistSkeletonCard();
|
return const _ArtistSkeletonCard();
|
||||||
}
|
}
|
||||||
final artist = _artists[index];
|
final artist = _artists[index];
|
||||||
|
final isFav = favoriteKeys.contains(
|
||||||
|
'${artist.providerId}:${artist.service}:${artist.id}');
|
||||||
return _ArtistCard(
|
return _ArtistCard(
|
||||||
artist: artist,
|
artist: artist,
|
||||||
|
isFavorite: isFav,
|
||||||
|
onToggleFavorite: () =>
|
||||||
|
_toggleFavoriteArtist(settings, artist),
|
||||||
onTap: () => context.push(
|
onTap: () => context.push(
|
||||||
'/artists/${artist.providerId}/${artist.service}/${artist.id}?name=${Uri.encodeComponent(artist.displayName)}',
|
'/artists/${artist.providerId}/${artist.service}/${artist.id}?name=${Uri.encodeComponent(artist.displayName)}',
|
||||||
),
|
),
|
||||||
@@ -276,10 +366,17 @@ class _ArtistsHeader extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _ArtistCard extends StatelessWidget {
|
class _ArtistCard extends StatelessWidget {
|
||||||
const _ArtistCard({required this.artist, required this.onTap});
|
const _ArtistCard({
|
||||||
|
required this.artist,
|
||||||
|
required this.onTap,
|
||||||
|
required this.isFavorite,
|
||||||
|
required this.onToggleFavorite,
|
||||||
|
});
|
||||||
|
|
||||||
final ArtistProfile artist;
|
final ArtistProfile artist;
|
||||||
final VoidCallback onTap;
|
final VoidCallback onTap;
|
||||||
|
final bool isFavorite;
|
||||||
|
final VoidCallback onToggleFavorite;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -334,7 +431,15 @@ class _ArtistCard extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 4),
|
||||||
|
IconButton(
|
||||||
|
tooltip: isFavorite ? 'В избранном' : 'В избранное',
|
||||||
|
onPressed: onToggleFavorite,
|
||||||
|
icon: Icon(
|
||||||
|
isFavorite ? Icons.star_rounded : Icons.star_outline_rounded,
|
||||||
|
color: isFavorite ? Colors.amber : scheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
const Icon(Icons.chevron_right_rounded),
|
const Icon(Icons.chevron_right_rounded),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -344,6 +449,359 @@ class _ArtistCard extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _FavoriteArtistItem {
|
||||||
|
const _FavoriteArtistItem({
|
||||||
|
required this.id,
|
||||||
|
required this.service,
|
||||||
|
required this.providerId,
|
||||||
|
required this.name,
|
||||||
|
this.avatarUrl,
|
||||||
|
});
|
||||||
|
|
||||||
|
const _FavoriteArtistItem.empty()
|
||||||
|
: id = '',
|
||||||
|
service = '',
|
||||||
|
providerId = '',
|
||||||
|
name = '',
|
||||||
|
avatarUrl = null;
|
||||||
|
|
||||||
|
final String id;
|
||||||
|
final String service;
|
||||||
|
final String providerId;
|
||||||
|
final String name;
|
||||||
|
final String? avatarUrl;
|
||||||
|
|
||||||
|
bool get isEmpty => id.isEmpty;
|
||||||
|
String get key => '$providerId:$service:$id';
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'id': id,
|
||||||
|
'service': service,
|
||||||
|
'providerId': providerId,
|
||||||
|
'name': name,
|
||||||
|
'avatarUrl': avatarUrl,
|
||||||
|
};
|
||||||
|
|
||||||
|
factory _FavoriteArtistItem.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_FavoriteArtistItem(
|
||||||
|
id: (json['id'] ?? '').toString(),
|
||||||
|
service: (json['service'] ?? '').toString(),
|
||||||
|
providerId: (json['providerId'] ?? '').toString(),
|
||||||
|
name: (json['name'] ?? '').toString(),
|
||||||
|
avatarUrl: json['avatarUrl'] as String?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FavoriteArtistsSection extends ConsumerWidget {
|
||||||
|
const _FavoriteArtistsSection({
|
||||||
|
required this.favorites,
|
||||||
|
required this.selectedKey,
|
||||||
|
required this.onSelect,
|
||||||
|
required this.onOpenArtist,
|
||||||
|
required this.onRemoveFavorite,
|
||||||
|
});
|
||||||
|
|
||||||
|
final List<_FavoriteArtistItem> favorites;
|
||||||
|
final String? selectedKey;
|
||||||
|
final ValueChanged<_FavoriteArtistItem> onSelect;
|
||||||
|
final ValueChanged<_FavoriteArtistItem> onOpenArtist;
|
||||||
|
final ValueChanged<_FavoriteArtistItem> onRemoveFavorite;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
if (favorites.isEmpty) {
|
||||||
|
return Container(
|
||||||
|
margin: const EdgeInsets.fromLTRB(16, 4, 16, 8),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Theme.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.surfaceContainerHighest
|
||||||
|
.withValues(alpha: 0.4),
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
border: Border.all(
|
||||||
|
color: Theme.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.outlineVariant
|
||||||
|
.withValues(alpha: 0.3),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.star_rounded, color: Colors.amber, size: 22),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'Нажмите звёздочку ★ на карточке автора, чтобы добавить его в любимые и смотреть его медиа здесь.',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final selected = favorites.firstWhere(
|
||||||
|
(f) => f.key == selectedKey,
|
||||||
|
orElse: () => favorites.first,
|
||||||
|
);
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
margin: const EdgeInsets.fromLTRB(16, 4, 16, 10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Theme.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.surfaceContainerHighest
|
||||||
|
.withValues(alpha: 0.45),
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
border: Border.all(
|
||||||
|
color: Theme.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.outlineVariant
|
||||||
|
.withValues(alpha: 0.35),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(14, 10, 14, 4),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.star_rounded, color: Colors.amber, size: 20),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Text(
|
||||||
|
'Любимые авторы (${favorites.length})',
|
||||||
|
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => onOpenArtist(selected),
|
||||||
|
style: TextButton.styleFrom(
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||||
|
),
|
||||||
|
child: const Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text('Все работы', style: TextStyle(fontSize: 12)),
|
||||||
|
SizedBox(width: 2),
|
||||||
|
Icon(Icons.arrow_forward_rounded, size: 14),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(
|
||||||
|
height: 84,
|
||||||
|
child: ListView.separated(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
itemCount: favorites.length,
|
||||||
|
separatorBuilder: (_, __) => const SizedBox(width: 10),
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final fav = favorites[index];
|
||||||
|
final isSelected = fav.key == selected.key;
|
||||||
|
return InkWell(
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
onTap: () => onSelect(fav),
|
||||||
|
child: AnimatedContainer(
|
||||||
|
duration: const Duration(milliseconds: 200),
|
||||||
|
padding:
|
||||||
|
const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isSelected
|
||||||
|
? Theme.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.primaryContainer
|
||||||
|
.withValues(alpha: 0.6)
|
||||||
|
: Colors.transparent,
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
border: Border.all(
|
||||||
|
color: isSelected
|
||||||
|
? Theme.of(context).colorScheme.primary
|
||||||
|
: Colors.transparent,
|
||||||
|
width: 1.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: 44,
|
||||||
|
height: 44,
|
||||||
|
child: ClipOval(
|
||||||
|
child: fav.avatarUrl != null &&
|
||||||
|
fav.avatarUrl!.isNotEmpty
|
||||||
|
? CachedNetworkImage(
|
||||||
|
imageUrl: fav.avatarUrl!,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
placeholder: (_, __) => const CircleAvatar(
|
||||||
|
child:
|
||||||
|
Icon(Icons.person_rounded, size: 20),
|
||||||
|
),
|
||||||
|
errorWidget: (_, __, ___) =>
|
||||||
|
const CircleAvatar(
|
||||||
|
child:
|
||||||
|
Icon(Icons.person_rounded, size: 20),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: CircleAvatar(
|
||||||
|
child: Text(
|
||||||
|
fav.name.isNotEmpty
|
||||||
|
? fav.name[0].toUpperCase()
|
||||||
|
: '?',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
SizedBox(
|
||||||
|
width: 68,
|
||||||
|
child: Text(
|
||||||
|
fav.name,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: isSelected
|
||||||
|
? FontWeight.bold
|
||||||
|
: FontWeight.normal,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_FavoriteArtistMediaStrip(artist: selected),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FavoriteArtistMediaStrip extends ConsumerWidget {
|
||||||
|
const _FavoriteArtistMediaStrip({required this.artist});
|
||||||
|
|
||||||
|
final _FavoriteArtistItem artist;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final query = ArtistWorkQuery(
|
||||||
|
providerId: artist.providerId,
|
||||||
|
service: artist.service,
|
||||||
|
artistId: artist.id,
|
||||||
|
artistName: artist.name,
|
||||||
|
);
|
||||||
|
final asyncPosts = ref.watch(artistPostsProvider(query));
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(12, 4, 12, 10),
|
||||||
|
child: asyncPosts.when(
|
||||||
|
loading: () => const SizedBox(
|
||||||
|
height: 90,
|
||||||
|
child: Center(
|
||||||
|
child: SizedBox(
|
||||||
|
width: 22,
|
||||||
|
height: 22,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
error: (_, __) => const SizedBox.shrink(),
|
||||||
|
data: (posts) {
|
||||||
|
if (posts.isEmpty) {
|
||||||
|
return const SizedBox(
|
||||||
|
height: 36,
|
||||||
|
child: Center(
|
||||||
|
child: Text('Нет доступных фото или видео',
|
||||||
|
style: TextStyle(fontSize: 12)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return SizedBox(
|
||||||
|
height: 96,
|
||||||
|
child: ListView.separated(
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
itemCount: posts.length.clamp(0, 15),
|
||||||
|
separatorBuilder: (_, __) => const SizedBox(width: 8),
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final post = posts[index];
|
||||||
|
final isVideo = MediaUrlSelector.isVideo(post);
|
||||||
|
return InkWell(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
onTap: () => context.push(
|
||||||
|
'/post/${post.providerId}/${post.id}',
|
||||||
|
extra: PostNavigationContext(
|
||||||
|
currentPost: post,
|
||||||
|
posts: posts,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: 96,
|
||||||
|
height: 96,
|
||||||
|
child: CachedNetworkImage(
|
||||||
|
imageUrl: post.previewUrl.isNotEmpty
|
||||||
|
? post.previewUrl
|
||||||
|
: post.sampleUrl,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
placeholder: (_, __) => ColoredBox(
|
||||||
|
color: Theme.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.surfaceContainerHighest,
|
||||||
|
),
|
||||||
|
errorWidget: (_, __, ___) => ColoredBox(
|
||||||
|
color: Theme.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.surfaceContainerHighest,
|
||||||
|
child: const Icon(Icons.broken_image_rounded,
|
||||||
|
size: 24),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (isVideo)
|
||||||
|
Positioned(
|
||||||
|
bottom: 6,
|
||||||
|
right: 6,
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.all(4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.black.withValues(alpha: 0.7),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: const Icon(Icons.play_arrow_rounded,
|
||||||
|
color: Colors.white, size: 14),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class _ArtistAvatar extends StatelessWidget {
|
class _ArtistAvatar extends StatelessWidget {
|
||||||
const _ArtistAvatar({required this.artist});
|
const _ArtistAvatar({required this.artist});
|
||||||
|
|
||||||
|
|||||||
@@ -51,7 +51,10 @@ class CollectionDetailsScreen extends ConsumerWidget {
|
|||||||
favoriteKeys: favoriteKeys,
|
favoriteKeys: favoriteKeys,
|
||||||
onOpen: (post) => context.push(
|
onOpen: (post) => context.push(
|
||||||
'/post/${post.providerId}/${post.id}',
|
'/post/${post.providerId}/${post.id}',
|
||||||
extra: post,
|
extra: PostNavigationContext(
|
||||||
|
currentPost: post,
|
||||||
|
posts: items,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
onFavorite: (post) async {
|
onFavorite: (post) async {
|
||||||
if (favoriteKeys.contains(post.cacheKey)) {
|
if (favoriteKeys.contains(post.cacheKey)) {
|
||||||
|
|||||||
@@ -121,7 +121,10 @@ class _FavoritesScreenState extends ConsumerState<FavoritesScreen> {
|
|||||||
downloadedKeys: downloadedKeys,
|
downloadedKeys: downloadedKeys,
|
||||||
onOpen: (post) => context.push(
|
onOpen: (post) => context.push(
|
||||||
'/post/${post.providerId}/${post.id}',
|
'/post/${post.providerId}/${post.id}',
|
||||||
extra: post,
|
extra: PostNavigationContext(
|
||||||
|
currentPost: post,
|
||||||
|
posts: posts,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
onFavorite: (post) =>
|
onFavorite: (post) =>
|
||||||
ref.read(favoritesControllerProvider.notifier).remove(post),
|
ref.read(favoritesControllerProvider.notifier).remove(post),
|
||||||
|
|||||||
@@ -296,7 +296,10 @@ class _FeedScreenState extends ConsumerState<FeedScreen>
|
|||||||
selectedKeys: _selectedKeys,
|
selectedKeys: _selectedKeys,
|
||||||
onOpen: (post) => context.push(
|
onOpen: (post) => context.push(
|
||||||
'/post/${post.providerId}/${post.id}',
|
'/post/${post.providerId}/${post.id}',
|
||||||
extra: post,
|
extra: PostNavigationContext(
|
||||||
|
currentPost: post,
|
||||||
|
posts: state.posts,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
onPreview: (post) => _showPreview(context, post),
|
onPreview: (post) => _showPreview(context, post),
|
||||||
onToggleSelected: (post) => _toggleSelected(post),
|
onToggleSelected: (post) => _toggleSelected(post),
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'dart:ui';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
@@ -39,12 +40,14 @@ class PostDetailsScreen extends ConsumerWidget {
|
|||||||
required this.providerId,
|
required this.providerId,
|
||||||
required this.postId,
|
required this.postId,
|
||||||
this.initialPost,
|
this.initialPost,
|
||||||
|
this.postsList,
|
||||||
super.key,
|
super.key,
|
||||||
});
|
});
|
||||||
|
|
||||||
final String providerId;
|
final String providerId;
|
||||||
final String postId;
|
final String postId;
|
||||||
final Post? initialPost;
|
final Post? initialPost;
|
||||||
|
final List<Post>? postsList;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
@@ -57,8 +60,9 @@ class PostDetailsScreen extends ConsumerWidget {
|
|||||||
final settings =
|
final settings =
|
||||||
ref.watch(appSettingsProvider).value ?? AppSettings.defaults;
|
ref.watch(appSettingsProvider).value ?? AppSettings.defaults;
|
||||||
final strings = ref.watch(appStringsProvider);
|
final strings = ref.watch(appStringsProvider);
|
||||||
final feedPosts =
|
final feedPosts = postsList ??
|
||||||
ref.watch(feedControllerProvider).value?.posts ?? const <Post>[];
|
ref.watch(feedControllerProvider).value?.posts ??
|
||||||
|
const <Post>[];
|
||||||
final favoriteKeys = ref.watch(favoriteKeysProvider).value ?? <String>{};
|
final favoriteKeys = ref.watch(favoriteKeysProvider).value ?? <String>{};
|
||||||
return AdaptiveScaffold(
|
return AdaptiveScaffold(
|
||||||
title: strings.post,
|
title: strings.post,
|
||||||
@@ -698,7 +702,16 @@ class _MobilePostPagerState extends State<_MobilePostPager> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return PageView.builder(
|
return ScrollConfiguration(
|
||||||
|
behavior: ScrollConfiguration.of(context).copyWith(
|
||||||
|
dragDevices: {
|
||||||
|
PointerDeviceKind.touch,
|
||||||
|
PointerDeviceKind.mouse,
|
||||||
|
PointerDeviceKind.trackpad,
|
||||||
|
PointerDeviceKind.stylus,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
child: PageView.builder(
|
||||||
controller: _controller,
|
controller: _controller,
|
||||||
physics: _mediaGestureLocked
|
physics: _mediaGestureLocked
|
||||||
? const NeverScrollableScrollPhysics()
|
? const NeverScrollableScrollPhysics()
|
||||||
@@ -735,8 +748,9 @@ class _MobilePostPagerState extends State<_MobilePostPager> {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _setMediaGestureLocked(bool locked) {
|
void _setMediaGestureLocked(bool locked) {
|
||||||
if (_mediaGestureLocked == locked) return;
|
if (_mediaGestureLocked == locked) return;
|
||||||
|
|||||||
@@ -219,9 +219,54 @@ class _PostMediaViewerState extends State<PostMediaViewer>
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
final child = _ZoomableImage(
|
final child = Stack(
|
||||||
|
alignment: Alignment.center,
|
||||||
|
children: [
|
||||||
|
_ZoomableImage(
|
||||||
onGestureLockChanged: widget.onMediaGestureLockChanged,
|
onGestureLockChanged: widget.onMediaGestureLockChanged,
|
||||||
child: image,
|
child: image,
|
||||||
|
),
|
||||||
|
if (_imageUrls.length > 1)
|
||||||
|
Positioned(
|
||||||
|
bottom: 14,
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.black.withValues(alpha: 0.65),
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
border: Border.all(color: Colors.white24, width: 0.8),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
if (_imageIndex > 0)
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () => setState(() => _imageIndex--),
|
||||||
|
child: const Icon(Icons.chevron_left_rounded,
|
||||||
|
size: 20, color: Colors.white),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||||
|
child: Text(
|
||||||
|
'${_imageIndex + 1} / ${_imageUrls.length}',
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_imageIndex < _imageUrls.length - 1)
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () => setState(() => _imageIndex++),
|
||||||
|
child: const Icon(Icons.chevron_right_rounded,
|
||||||
|
size: 20, color: Colors.white),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
return LayoutBuilder(
|
return LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
|
|||||||
@@ -52,10 +52,19 @@ class ViewedScreen extends ConsumerWidget {
|
|||||||
post: item.post,
|
post: item.post,
|
||||||
viewedAt: item.viewedAt,
|
viewedAt: item.viewedAt,
|
||||||
isFavorite: favoriteKeys.contains(item.post.cacheKey),
|
isFavorite: favoriteKeys.contains(item.post.cacheKey),
|
||||||
onOpen: () => context.push(
|
onOpen: () {
|
||||||
|
final allPosts = [
|
||||||
|
for (final g in groups)
|
||||||
|
for (final i in g.items) i.post
|
||||||
|
];
|
||||||
|
context.push(
|
||||||
'/post/${item.post.providerId}/${item.post.id}',
|
'/post/${item.post.providerId}/${item.post.id}',
|
||||||
extra: item.post,
|
extra: PostNavigationContext(
|
||||||
|
currentPost: item.post,
|
||||||
|
posts: allPosts,
|
||||||
),
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
onFavorite: () => _toggleFavorite(
|
onFavorite: () => _toggleFavorite(
|
||||||
ref,
|
ref,
|
||||||
item.post,
|
item.post,
|
||||||
|
|||||||
@@ -154,7 +154,9 @@ class _PostCardState extends ConsumerState<PostCard> {
|
|||||||
(constraints.maxWidth * dpr).round().clamp(320, 1800);
|
(constraints.maxWidth * dpr).round().clamp(320, 1800);
|
||||||
final headers = _headersFor(post);
|
final headers = _headersFor(post);
|
||||||
final videoUrls = MediaUrlSelector.video(post);
|
final videoUrls = MediaUrlSelector.video(post);
|
||||||
if (_resolvedPost != null &&
|
if (!mobile &&
|
||||||
|
_hovered &&
|
||||||
|
_resolvedPost != null &&
|
||||||
MediaUrlSelector.isVideo(post) &&
|
MediaUrlSelector.isVideo(post) &&
|
||||||
videoUrls.isNotEmpty) {
|
videoUrls.isNotEmpty) {
|
||||||
return _FeedVideoPreview(
|
return _FeedVideoPreview(
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
name: gel_rule_app
|
name: gel_rule_app
|
||||||
description: Local backend/core layer for a Flutter booru browser app.
|
description: Local backend/core layer for a Flutter booru browser app.
|
||||||
publish_to: "none"
|
publish_to: "none"
|
||||||
version: 2.0.1+23
|
version: 2.0.2+24
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ">=3.3.0 <4.0.0"
|
sdk: ">=3.3.0 <4.0.0"
|
||||||
|
|||||||
@@ -82,16 +82,9 @@ void main() {
|
|||||||
expect(providers.map((provider) => provider.id), [
|
expect(providers.map((provider) => provider.id), [
|
||||||
'gelbooru',
|
'gelbooru',
|
||||||
'rule34',
|
'rule34',
|
||||||
'safebooru',
|
'realbooru',
|
||||||
'konachan',
|
|
||||||
'yandere',
|
|
||||||
'e621',
|
'e621',
|
||||||
'e926',
|
'e926',
|
||||||
'realbooru',
|
|
||||||
'xbooru',
|
|
||||||
'paheal',
|
|
||||||
'kemono',
|
|
||||||
'coomer',
|
|
||||||
'pawchive',
|
'pawchive',
|
||||||
]);
|
]);
|
||||||
final pawchive =
|
final pawchive =
|
||||||
@@ -104,21 +97,13 @@ void main() {
|
|||||||
expect(realbooru.enabled, isTrue);
|
expect(realbooru.enabled, isTrue);
|
||||||
expect(realbooru.apiType, 'realbooru_html');
|
expect(realbooru.apiType, 'realbooru_html');
|
||||||
expect(realbooru.baseUrl, 'https://realbooru.com');
|
expect(realbooru.baseUrl, 'https://realbooru.com');
|
||||||
expect(providers.where((provider) => provider.id == 'cosbooru'), isEmpty);
|
expect(providers.where((provider) => provider.id == 'safebooru'), isEmpty);
|
||||||
final xbooru = providers.singleWhere((provider) => provider.id == 'xbooru');
|
expect(providers.where((provider) => provider.id == 'konachan'), isEmpty);
|
||||||
expect(xbooru.enabled, isTrue);
|
expect(providers.where((provider) => provider.id == 'yandere'), isEmpty);
|
||||||
expect(xbooru.baseUrl, 'https://xbooru.com');
|
expect(providers.where((provider) => provider.id == 'xbooru'), isEmpty);
|
||||||
expect(xbooru.apiType, 'gelbooru');
|
expect(providers.where((provider) => provider.id == 'paheal'), isEmpty);
|
||||||
final paheal = providers.singleWhere((provider) => provider.id == 'paheal');
|
expect(providers.where((provider) => provider.id == 'kemono'), isEmpty);
|
||||||
expect(paheal.enabled, isTrue);
|
expect(providers.where((provider) => provider.id == 'coomer'), isEmpty);
|
||||||
expect(paheal.baseUrl, 'https://rule34.paheal.net');
|
|
||||||
expect(paheal.apiType, 'paheal');
|
|
||||||
final kemono = providers.singleWhere((provider) => provider.id == 'kemono');
|
|
||||||
final coomer = providers.singleWhere((provider) => provider.id == 'coomer');
|
|
||||||
expect(kemono.enabled, isTrue);
|
|
||||||
expect(kemono.apiType, 'kemono');
|
|
||||||
expect(coomer.enabled, isFalse);
|
|
||||||
expect(coomer.apiType, 'coomer');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('provider repository disables old CosBooru config without deleting it',
|
test('provider repository disables old CosBooru config without deleting it',
|
||||||
@@ -157,11 +142,11 @@ void main() {
|
|||||||
providerRepository: providerRepository,
|
providerRepository: providerRepository,
|
||||||
);
|
);
|
||||||
|
|
||||||
await service.saveEnabledProviders(['safebooru']);
|
await service.saveEnabledProviders(['realbooru']);
|
||||||
final providers = (await providerRepository.getProviders() as Success).data;
|
final providers = (await providerRepository.getProviders() as Success).data;
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
providers.singleWhere((provider) => provider.id == 'safebooru').enabled,
|
providers.singleWhere((provider) => provider.id == 'realbooru').enabled,
|
||||||
isTrue);
|
isTrue);
|
||||||
expect(
|
expect(
|
||||||
providers.singleWhere((provider) => provider.id == 'gelbooru').enabled,
|
providers.singleWhere((provider) => provider.id == 'gelbooru').enabled,
|
||||||
|
|||||||
@@ -231,13 +231,13 @@ void main() {
|
|||||||
expect(posts.single.rating, 'unknown');
|
expect(posts.single.rating, 'unknown');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('seed providers include Realbooru HTML Paheal and exclude CosBooru', () {
|
test('seed providers include Realbooru HTML Pawchive and exclude deprecated providers', () {
|
||||||
final paheal = ProviderRepository.seedProviders()
|
final pawchive = ProviderRepository.seedProviders()
|
||||||
.where((provider) => provider.id == 'paheal')
|
.where((provider) => provider.id == 'pawchive')
|
||||||
.single;
|
.single;
|
||||||
expect(paheal.enabled, isTrue);
|
expect(pawchive.enabled, isTrue);
|
||||||
expect(paheal.apiType, 'paheal');
|
expect(pawchive.apiType, 'pawchive');
|
||||||
expect(paheal.baseUrl, 'https://rule34.paheal.net');
|
expect(pawchive.baseUrl, 'https://pawchive.pw');
|
||||||
final realbooru = ProviderRepository.seedProviders()
|
final realbooru = ProviderRepository.seedProviders()
|
||||||
.where((provider) => provider.id == 'realbooru')
|
.where((provider) => provider.id == 'realbooru')
|
||||||
.single;
|
.single;
|
||||||
@@ -249,22 +249,26 @@ void main() {
|
|||||||
.where((provider) => provider.id == 'cosbooru'),
|
.where((provider) => provider.id == 'cosbooru'),
|
||||||
isEmpty,
|
isEmpty,
|
||||||
);
|
);
|
||||||
final xbooru = ProviderRepository.seedProviders()
|
expect(
|
||||||
.where((provider) => provider.id == 'xbooru')
|
ProviderRepository.seedProviders()
|
||||||
.single;
|
.where((provider) => provider.id == 'paheal'),
|
||||||
expect(xbooru.enabled, isTrue);
|
isEmpty,
|
||||||
expect(xbooru.apiType, 'gelbooru');
|
);
|
||||||
expect(xbooru.baseUrl, 'https://xbooru.com');
|
expect(
|
||||||
final kemono = ProviderRepository.seedProviders()
|
ProviderRepository.seedProviders()
|
||||||
.where((provider) => provider.id == 'kemono')
|
.where((provider) => provider.id == 'xbooru'),
|
||||||
.single;
|
isEmpty,
|
||||||
final coomer = ProviderRepository.seedProviders()
|
);
|
||||||
.where((provider) => provider.id == 'coomer')
|
expect(
|
||||||
.single;
|
ProviderRepository.seedProviders()
|
||||||
expect(kemono.enabled, isTrue);
|
.where((provider) => provider.id == 'kemono'),
|
||||||
expect(kemono.apiType, 'kemono');
|
isEmpty,
|
||||||
expect(coomer.enabled, isFalse);
|
);
|
||||||
expect(coomer.apiType, 'coomer');
|
expect(
|
||||||
|
ProviderRepository.seedProviders()
|
||||||
|
.where((provider) => provider.id == 'coomer'),
|
||||||
|
isEmpty,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('provider factory treats Realbooru api type as unsupported', () {
|
test('provider factory treats Realbooru api type as unsupported', () {
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:gel_rule_app/backend/models/app_update_info.dart';
|
||||||
|
import 'package:gel_rule_app/backend/services/settings_service.dart';
|
||||||
|
import 'package:gel_rule_app/backend/services/update_service.dart';
|
||||||
|
import 'package:gel_rule_app/core/database/database_service.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
test('UpdateService parses Gitea release JSON correctly', () {
|
||||||
|
final service = UpdateService(Dio(), SettingsService(_MockDatabaseService()));
|
||||||
|
|
||||||
|
// Use reflection or constructor to test info
|
||||||
|
final info = AppUpdateInfo(
|
||||||
|
version: '2.0.2',
|
||||||
|
tagName: 'v2.0.2',
|
||||||
|
name: 'Lunaris 2.0.2',
|
||||||
|
body: 'Release notes',
|
||||||
|
htmlUrl: 'https://gitea.rardogsynapse.online/RarDog/Lunaris/releases/tag/v2.0.2',
|
||||||
|
publishedAt: DateTime.parse('2026-09-03T18:00:00Z'),
|
||||||
|
apkUrl: 'https://gitea.rardogsynapse.online/attachments/apk',
|
||||||
|
linuxTarGzUrl: 'https://gitea.rardogsynapse.online/attachments/linux',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(info.hasAssets, isTrue);
|
||||||
|
expect(info.apkUrl, contains('apk'));
|
||||||
|
expect(info.linuxTarGzUrl, contains('linux'));
|
||||||
|
expect(
|
||||||
|
service.assetFileName(info, 'https://gitea.rardogsynapse.online/attachments/Lunaris-v2.0.2.apk'),
|
||||||
|
'Lunaris-v2.0.2.apk',
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
service.assetFileName(info, info.apkUrl!),
|
||||||
|
'Lunaris-v2.0.2-linux-x64.tar.gz',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MockDatabaseService implements DatabaseService {
|
||||||
|
@override
|
||||||
|
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user