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
|
||||
android/local.properties
|
||||
*.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
|
||||
- Coomer
|
||||
🔗 **[Gitea Releases (RarDog/Lunaris)](https://gitea.rardogsynapse.online/RarDog/Lunaris/releases)**
|
||||
|
||||
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
|
||||
# Клонирование репозитория
|
||||
git clone https://gitea.rardogsynapse.online/RarDog/Lunaris.git
|
||||
cd Lunaris
|
||||
|
||||
# Установка зависимостей
|
||||
flutter pub get
|
||||
flutter run -d windows
|
||||
flutter run -d android
|
||||
```
|
||||
|
||||
## Сборка
|
||||
|
||||
```bash
|
||||
# Запуск тестов и анализатора
|
||||
flutter analyze
|
||||
flutter test
|
||||
flutter build apk
|
||||
flutter build windows
|
||||
|
||||
# Запуск на подключенном устройстве
|
||||
flutter run
|
||||
```
|
||||
|
||||
Windows installer собирается через Inno Setup:
|
||||
### Сборка релизов
|
||||
|
||||
```powershell
|
||||
ISCC.exe installer\Lunaris.iss
|
||||
```bash
|
||||
# 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 appBuildNumber = 23;
|
||||
const appDisplayVersion = '2.0.2';
|
||||
const appBuildNumber = 24;
|
||||
const appReleaseChannel = 'Stable';
|
||||
|
||||
@@ -1,4 +1,16 @@
|
||||
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(
|
||||
version: '2.0.1',
|
||||
title: 'Localization, zoom and favorite downloads',
|
||||
|
||||
+21
-5
@@ -40,11 +40,27 @@ final appRouterProvider = Provider<GoRouter>((ref) {
|
||||
),
|
||||
GoRoute(
|
||||
path: '/post/:providerId/:postId',
|
||||
builder: (context, state) => PostDetailsScreen(
|
||||
providerId: state.pathParameters['providerId']!,
|
||||
postId: state.pathParameters['postId']!,
|
||||
initialPost: state.extra is Post ? state.extra! as Post : null,
|
||||
),
|
||||
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',
|
||||
|
||||
@@ -9,6 +9,7 @@ class AppUpdateInfo {
|
||||
this.apkUrl,
|
||||
this.windowsInstallerUrl,
|
||||
this.portableZipUrl,
|
||||
this.linuxTarGzUrl,
|
||||
});
|
||||
|
||||
final String version;
|
||||
@@ -20,7 +21,11 @@ class AppUpdateInfo {
|
||||
final String? apkUrl;
|
||||
final String? windowsInstallerUrl;
|
||||
final String? portableZipUrl;
|
||||
final String? linuxTarGzUrl;
|
||||
|
||||
bool get hasAssets =>
|
||||
apkUrl != null || windowsInstallerUrl != null || portableZipUrl != null;
|
||||
apkUrl != null ||
|
||||
windowsInstallerUrl != null ||
|
||||
portableZipUrl != null ||
|
||||
linuxTarGzUrl != null;
|
||||
}
|
||||
|
||||
@@ -114,3 +114,14 @@ class Post {
|
||||
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,
|
||||
),
|
||||
ContentProviderConfig(
|
||||
id: 'safebooru',
|
||||
name: 'Safebooru',
|
||||
baseUrl: 'https://safebooru.org',
|
||||
apiType: 'gelbooru',
|
||||
id: 'realbooru',
|
||||
name: 'Realbooru',
|
||||
baseUrl: 'https://realbooru.com',
|
||||
apiType: 'realbooru_html',
|
||||
enabled: true,
|
||||
priority: 2,
|
||||
timeoutSeconds: 20,
|
||||
@@ -51,44 +51,16 @@ class ProviderRepository {
|
||||
createdAt: 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(
|
||||
id: 'e621',
|
||||
name: 'e621',
|
||||
baseUrl: 'https://e621.net',
|
||||
apiType: 'e621',
|
||||
enabled: true,
|
||||
priority: 5,
|
||||
priority: 3,
|
||||
timeoutSeconds: 20,
|
||||
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,
|
||||
@@ -99,76 +71,10 @@ class ProviderRepository {
|
||||
baseUrl: 'https://e926.net',
|
||||
apiType: 'e621',
|
||||
enabled: true,
|
||||
priority: 6,
|
||||
priority: 4,
|
||||
timeoutSeconds: 20,
|
||||
customHeaders: const {
|
||||
'User-Agent': 'Lunaris/2.0.1 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',
|
||||
'User-Agent': 'Lunaris/2.0.2 Flutter local booru browser',
|
||||
},
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
@@ -179,7 +85,7 @@ class ProviderRepository {
|
||||
baseUrl: 'https://pawchive.pw',
|
||||
apiType: 'pawchive',
|
||||
enabled: true,
|
||||
priority: 12,
|
||||
priority: 5,
|
||||
timeoutSeconds: 20,
|
||||
customHeaders: const {},
|
||||
createdAt: now,
|
||||
@@ -192,11 +98,22 @@ class ProviderRepository {
|
||||
return _databaseService.safeWrite((isar) async {
|
||||
final existingProviders =
|
||||
await isar.providerConfigEntitys.where().findAll();
|
||||
const removedIds = {
|
||||
'safebooru',
|
||||
'konachan',
|
||||
'yandere',
|
||||
'xbooru',
|
||||
'paheal',
|
||||
'kemono',
|
||||
'coomer',
|
||||
};
|
||||
for (final provider in existingProviders) {
|
||||
if (provider.providerId == 'cosbooru') {
|
||||
provider.enabled = false;
|
||||
provider.updatedAt = DateTime.now();
|
||||
await isar.providerConfigEntitys.put(provider);
|
||||
} else if (removedIds.contains(provider.providerId)) {
|
||||
await isar.providerConfigEntitys.delete(provider.isarId);
|
||||
}
|
||||
}
|
||||
final seeds = seedProviders();
|
||||
|
||||
@@ -48,12 +48,14 @@ class AppSettings {
|
||||
required this.lastFeedProviderIds,
|
||||
required this.lastFeedTopPeriod,
|
||||
required this.lastFeedScrollOffset,
|
||||
this.favoriteArtists = const [],
|
||||
this.skippedUpdateVersion,
|
||||
this.lastUpdateCheckAt,
|
||||
this.defaultRatingFilter,
|
||||
this.lastFeedRating,
|
||||
});
|
||||
|
||||
final List<String> favoriteArtists;
|
||||
final List<String> enabledProviderIds;
|
||||
final bool nsfwEnabled;
|
||||
final int cacheTtlHours;
|
||||
@@ -96,11 +98,12 @@ class AppSettings {
|
||||
final String? lastFeedRating;
|
||||
|
||||
static const defaults = AppSettings(
|
||||
enabledProviderIds: ['gelbooru', 'rule34', 'safebooru'],
|
||||
enabledProviderIds: ['gelbooru', 'rule34', 'realbooru'],
|
||||
nsfwEnabled: true,
|
||||
cacheTtlHours: 24,
|
||||
cacheMaxItems: 2000,
|
||||
providerPriority: {'gelbooru': 0, 'rule34': 1, 'safebooru': 2},
|
||||
providerPriority: {'gelbooru': 0, 'rule34': 1, 'realbooru': 2},
|
||||
favoriteArtists: [],
|
||||
themeMode: 'dark',
|
||||
languageCode: 'ru',
|
||||
appSeedColor: 0xFFE84D8A,
|
||||
@@ -180,8 +183,10 @@ class AppSettings {
|
||||
String? defaultRatingFilter,
|
||||
String? lastFeedRating,
|
||||
bool clearLastFeedRating = false,
|
||||
List<String>? favoriteArtists,
|
||||
}) {
|
||||
return AppSettings(
|
||||
favoriteArtists: favoriteArtists ?? this.favoriteArtists,
|
||||
enabledProviderIds: enabledProviderIds ?? this.enabledProviderIds,
|
||||
nsfwEnabled: nsfwEnabled ?? this.nsfwEnabled,
|
||||
cacheTtlHours: cacheTtlHours ?? this.cacheTtlHours,
|
||||
@@ -233,6 +238,7 @@ class AppSettings {
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'favoriteArtists': favoriteArtists,
|
||||
'enabledProviderIds': enabledProviderIds,
|
||||
'nsfwEnabled': nsfwEnabled,
|
||||
'cacheTtlHours': cacheTtlHours,
|
||||
@@ -365,6 +371,9 @@ class AppSettings {
|
||||
lastFeedScrollOffset:
|
||||
(json['lastFeedScrollOffset'] as num?)?.toDouble() ??
|
||||
defaults.lastFeedScrollOffset,
|
||||
favoriteArtists: List<String>.from(
|
||||
(json['favoriteArtists'] as List?) ?? defaults.favoriteArtists,
|
||||
),
|
||||
skippedUpdateVersion: json['skippedUpdateVersion'] as String?,
|
||||
lastUpdateCheckAt: json['lastUpdateCheckAt'] as String?,
|
||||
defaultRatingFilter: json['defaultRatingFilter'] as String?,
|
||||
|
||||
@@ -11,9 +11,9 @@ class UpdateService {
|
||||
UpdateService(this._dio, this._settingsService);
|
||||
|
||||
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 =
|
||||
'https://gitea.rardogsynapse.online/api/v1/repos/RarDog/RuleGelApp/releases';
|
||||
'https://gitea.rardogsynapse.online/api/v1/repos/RarDog/Lunaris/releases';
|
||||
|
||||
final Dio _dio;
|
||||
final SettingsService _settingsService;
|
||||
@@ -100,6 +100,7 @@ class UpdateService {
|
||||
String? apkUrl;
|
||||
String? installerUrl;
|
||||
String? portableUrl;
|
||||
String? linuxTarGzUrl;
|
||||
for (final item in assets.whereType<Map>()) {
|
||||
final name = (item['name'] ?? '').toString().toLowerCase();
|
||||
final url = (item['browser_download_url'] ?? '').toString();
|
||||
@@ -107,6 +108,9 @@ class UpdateService {
|
||||
if (name.endsWith('.apk')) apkUrl = url;
|
||||
if (name.endsWith('.exe')) installerUrl = url;
|
||||
if (name.endsWith('.zip')) portableUrl = url;
|
||||
if (name.endsWith('.tar.gz') || name.endsWith('.tgz')) {
|
||||
linuxTarGzUrl = url;
|
||||
}
|
||||
}
|
||||
return AppUpdateInfo(
|
||||
version: _versionFromTag(tag),
|
||||
@@ -118,6 +122,7 @@ class UpdateService {
|
||||
apkUrl: apkUrl,
|
||||
windowsInstallerUrl: installerUrl,
|
||||
portableZipUrl: portableUrl,
|
||||
linuxTarGzUrl: linuxTarGzUrl,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -125,6 +130,9 @@ class UpdateService {
|
||||
if (Platform.isAndroid) {
|
||||
return info.apkUrl;
|
||||
}
|
||||
if (Platform.isLinux) {
|
||||
return info.linuxTarGzUrl ?? info.portableZipUrl;
|
||||
}
|
||||
if (Platform.isWindows) {
|
||||
return info.windowsInstallerUrl ?? info.portableZipUrl;
|
||||
}
|
||||
@@ -137,6 +145,7 @@ class UpdateService {
|
||||
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';
|
||||
}
|
||||
|
||||
@@ -88,7 +88,10 @@ class ArtistPostsScreen extends ConsumerWidget {
|
||||
viewedKeys: viewedKeys,
|
||||
onOpen: (post) => context.push(
|
||||
'/post/${post.providerId}/${post.id}',
|
||||
extra: post,
|
||||
extra: PostNavigationContext(
|
||||
currentPost: post,
|
||||
posts: items,
|
||||
),
|
||||
),
|
||||
onFavorite: (post) async {
|
||||
if (favoriteKeys.contains(post.cacheKey)) {
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
import 'dart:convert';
|
||||
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/responsive.dart';
|
||||
import '../../../backend/backend.dart';
|
||||
import '../../../core/utils/result.dart';
|
||||
import '../../../shared/widgets/adaptive_scaffold.dart';
|
||||
import '../../../shared/widgets/empty_view.dart';
|
||||
import '../../../shared/widgets/error_view.dart';
|
||||
import '../../settings/presentation/settings_controller.dart';
|
||||
import 'artist_posts_screen.dart';
|
||||
|
||||
final artistProviderConfigsProvider =
|
||||
FutureProvider<List<ContentProviderConfig>>((ref) async {
|
||||
@@ -37,6 +41,40 @@ class _ArtistsScreenState extends ConsumerState<ArtistsScreen> {
|
||||
Object? _error;
|
||||
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
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -55,6 +93,27 @@ class _ArtistsScreenState extends ConsumerState<ArtistsScreen> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
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(
|
||||
title: 'Artists',
|
||||
actions: [
|
||||
@@ -71,7 +130,7 @@ class _ArtistsScreenState extends ConsumerState<ArtistsScreen> {
|
||||
if (items.isEmpty) {
|
||||
return const EmptyView(
|
||||
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;
|
||||
@@ -91,6 +150,32 @@ class _ArtistsScreenState extends ConsumerState<ArtistsScreen> {
|
||||
},
|
||||
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(
|
||||
child: _error != null
|
||||
? ErrorView(
|
||||
@@ -129,8 +214,13 @@ class _ArtistsScreenState extends ConsumerState<ArtistsScreen> {
|
||||
return const _ArtistSkeletonCard();
|
||||
}
|
||||
final artist = _artists[index];
|
||||
final isFav = favoriteKeys.contains(
|
||||
'${artist.providerId}:${artist.service}:${artist.id}');
|
||||
return _ArtistCard(
|
||||
artist: artist,
|
||||
isFavorite: isFav,
|
||||
onToggleFavorite: () =>
|
||||
_toggleFavoriteArtist(settings, artist),
|
||||
onTap: () => context.push(
|
||||
'/artists/${artist.providerId}/${artist.service}/${artist.id}?name=${Uri.encodeComponent(artist.displayName)}',
|
||||
),
|
||||
@@ -276,10 +366,17 @@ class _ArtistsHeader 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 VoidCallback onTap;
|
||||
final bool isFavorite;
|
||||
final VoidCallback onToggleFavorite;
|
||||
|
||||
@override
|
||||
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),
|
||||
],
|
||||
),
|
||||
@@ -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 {
|
||||
const _ArtistAvatar({required this.artist});
|
||||
|
||||
|
||||
@@ -51,7 +51,10 @@ class CollectionDetailsScreen extends ConsumerWidget {
|
||||
favoriteKeys: favoriteKeys,
|
||||
onOpen: (post) => context.push(
|
||||
'/post/${post.providerId}/${post.id}',
|
||||
extra: post,
|
||||
extra: PostNavigationContext(
|
||||
currentPost: post,
|
||||
posts: items,
|
||||
),
|
||||
),
|
||||
onFavorite: (post) async {
|
||||
if (favoriteKeys.contains(post.cacheKey)) {
|
||||
|
||||
@@ -121,7 +121,10 @@ class _FavoritesScreenState extends ConsumerState<FavoritesScreen> {
|
||||
downloadedKeys: downloadedKeys,
|
||||
onOpen: (post) => context.push(
|
||||
'/post/${post.providerId}/${post.id}',
|
||||
extra: post,
|
||||
extra: PostNavigationContext(
|
||||
currentPost: post,
|
||||
posts: posts,
|
||||
),
|
||||
),
|
||||
onFavorite: (post) =>
|
||||
ref.read(favoritesControllerProvider.notifier).remove(post),
|
||||
|
||||
@@ -296,7 +296,10 @@ class _FeedScreenState extends ConsumerState<FeedScreen>
|
||||
selectedKeys: _selectedKeys,
|
||||
onOpen: (post) => context.push(
|
||||
'/post/${post.providerId}/${post.id}',
|
||||
extra: post,
|
||||
extra: PostNavigationContext(
|
||||
currentPost: post,
|
||||
posts: state.posts,
|
||||
),
|
||||
),
|
||||
onPreview: (post) => _showPreview(context, post),
|
||||
onToggleSelected: (post) => _toggleSelected(post),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'dart:ui';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
@@ -39,12 +40,14 @@ class PostDetailsScreen extends ConsumerWidget {
|
||||
required this.providerId,
|
||||
required this.postId,
|
||||
this.initialPost,
|
||||
this.postsList,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final String providerId;
|
||||
final String postId;
|
||||
final Post? initialPost;
|
||||
final List<Post>? postsList;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
@@ -57,8 +60,9 @@ class PostDetailsScreen extends ConsumerWidget {
|
||||
final settings =
|
||||
ref.watch(appSettingsProvider).value ?? AppSettings.defaults;
|
||||
final strings = ref.watch(appStringsProvider);
|
||||
final feedPosts =
|
||||
ref.watch(feedControllerProvider).value?.posts ?? const <Post>[];
|
||||
final feedPosts = postsList ??
|
||||
ref.watch(feedControllerProvider).value?.posts ??
|
||||
const <Post>[];
|
||||
final favoriteKeys = ref.watch(favoriteKeysProvider).value ?? <String>{};
|
||||
return AdaptiveScaffold(
|
||||
title: strings.post,
|
||||
@@ -698,17 +702,26 @@ class _MobilePostPagerState extends State<_MobilePostPager> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return PageView.builder(
|
||||
controller: _controller,
|
||||
physics: _mediaGestureLocked
|
||||
? const NeverScrollableScrollPhysics()
|
||||
: const PageScrollPhysics(),
|
||||
itemCount: widget.posts.length,
|
||||
onPageChanged: (index) {
|
||||
_prefetchAround(index);
|
||||
},
|
||||
itemBuilder: (context, index) {
|
||||
final initialPost = widget.posts[index];
|
||||
return ScrollConfiguration(
|
||||
behavior: ScrollConfiguration.of(context).copyWith(
|
||||
dragDevices: {
|
||||
PointerDeviceKind.touch,
|
||||
PointerDeviceKind.mouse,
|
||||
PointerDeviceKind.trackpad,
|
||||
PointerDeviceKind.stylus,
|
||||
},
|
||||
),
|
||||
child: PageView.builder(
|
||||
controller: _controller,
|
||||
physics: _mediaGestureLocked
|
||||
? const NeverScrollableScrollPhysics()
|
||||
: const PageScrollPhysics(),
|
||||
itemCount: widget.posts.length,
|
||||
onPageChanged: (index) {
|
||||
_prefetchAround(index);
|
||||
},
|
||||
itemBuilder: (context, index) {
|
||||
final initialPost = widget.posts[index];
|
||||
return Consumer(
|
||||
builder: (context, ref, _) {
|
||||
final args = PostDetailsArgs(
|
||||
@@ -735,8 +748,9 @@ class _MobilePostPagerState extends State<_MobilePostPager> {
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _setMediaGestureLocked(bool locked) {
|
||||
if (_mediaGestureLocked == locked) return;
|
||||
|
||||
@@ -219,9 +219,54 @@ class _PostMediaViewerState extends State<PostMediaViewer>
|
||||
);
|
||||
},
|
||||
);
|
||||
final child = _ZoomableImage(
|
||||
onGestureLockChanged: widget.onMediaGestureLockChanged,
|
||||
child: image,
|
||||
final child = Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
_ZoomableImage(
|
||||
onGestureLockChanged: widget.onMediaGestureLockChanged,
|
||||
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(
|
||||
builder: (context, constraints) {
|
||||
|
||||
@@ -52,10 +52,19 @@ class ViewedScreen extends ConsumerWidget {
|
||||
post: item.post,
|
||||
viewedAt: item.viewedAt,
|
||||
isFavorite: favoriteKeys.contains(item.post.cacheKey),
|
||||
onOpen: () => context.push(
|
||||
'/post/${item.post.providerId}/${item.post.id}',
|
||||
extra: item.post,
|
||||
),
|
||||
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}',
|
||||
extra: PostNavigationContext(
|
||||
currentPost: item.post,
|
||||
posts: allPosts,
|
||||
),
|
||||
);
|
||||
},
|
||||
onFavorite: () => _toggleFavorite(
|
||||
ref,
|
||||
item.post,
|
||||
|
||||
@@ -154,7 +154,9 @@ class _PostCardState extends ConsumerState<PostCard> {
|
||||
(constraints.maxWidth * dpr).round().clamp(320, 1800);
|
||||
final headers = _headersFor(post);
|
||||
final videoUrls = MediaUrlSelector.video(post);
|
||||
if (_resolvedPost != null &&
|
||||
if (!mobile &&
|
||||
_hovered &&
|
||||
_resolvedPost != null &&
|
||||
MediaUrlSelector.isVideo(post) &&
|
||||
videoUrls.isNotEmpty) {
|
||||
return _FeedVideoPreview(
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
name: gel_rule_app
|
||||
description: Local backend/core layer for a Flutter booru browser app.
|
||||
publish_to: "none"
|
||||
version: 2.0.1+23
|
||||
version: 2.0.2+24
|
||||
|
||||
environment:
|
||||
sdk: ">=3.3.0 <4.0.0"
|
||||
|
||||
@@ -82,16 +82,9 @@ void main() {
|
||||
expect(providers.map((provider) => provider.id), [
|
||||
'gelbooru',
|
||||
'rule34',
|
||||
'safebooru',
|
||||
'konachan',
|
||||
'yandere',
|
||||
'realbooru',
|
||||
'e621',
|
||||
'e926',
|
||||
'realbooru',
|
||||
'xbooru',
|
||||
'paheal',
|
||||
'kemono',
|
||||
'coomer',
|
||||
'pawchive',
|
||||
]);
|
||||
final pawchive =
|
||||
@@ -104,21 +97,13 @@ void main() {
|
||||
expect(realbooru.enabled, isTrue);
|
||||
expect(realbooru.apiType, 'realbooru_html');
|
||||
expect(realbooru.baseUrl, 'https://realbooru.com');
|
||||
expect(providers.where((provider) => provider.id == 'cosbooru'), isEmpty);
|
||||
final xbooru = providers.singleWhere((provider) => provider.id == 'xbooru');
|
||||
expect(xbooru.enabled, isTrue);
|
||||
expect(xbooru.baseUrl, 'https://xbooru.com');
|
||||
expect(xbooru.apiType, 'gelbooru');
|
||||
final paheal = providers.singleWhere((provider) => provider.id == 'paheal');
|
||||
expect(paheal.enabled, isTrue);
|
||||
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');
|
||||
expect(providers.where((provider) => provider.id == 'safebooru'), isEmpty);
|
||||
expect(providers.where((provider) => provider.id == 'konachan'), isEmpty);
|
||||
expect(providers.where((provider) => provider.id == 'yandere'), isEmpty);
|
||||
expect(providers.where((provider) => provider.id == 'xbooru'), isEmpty);
|
||||
expect(providers.where((provider) => provider.id == 'paheal'), isEmpty);
|
||||
expect(providers.where((provider) => provider.id == 'kemono'), isEmpty);
|
||||
expect(providers.where((provider) => provider.id == 'coomer'), isEmpty);
|
||||
});
|
||||
|
||||
test('provider repository disables old CosBooru config without deleting it',
|
||||
@@ -157,11 +142,11 @@ void main() {
|
||||
providerRepository: providerRepository,
|
||||
);
|
||||
|
||||
await service.saveEnabledProviders(['safebooru']);
|
||||
await service.saveEnabledProviders(['realbooru']);
|
||||
final providers = (await providerRepository.getProviders() as Success).data;
|
||||
|
||||
expect(
|
||||
providers.singleWhere((provider) => provider.id == 'safebooru').enabled,
|
||||
providers.singleWhere((provider) => provider.id == 'realbooru').enabled,
|
||||
isTrue);
|
||||
expect(
|
||||
providers.singleWhere((provider) => provider.id == 'gelbooru').enabled,
|
||||
|
||||
@@ -231,13 +231,13 @@ void main() {
|
||||
expect(posts.single.rating, 'unknown');
|
||||
});
|
||||
|
||||
test('seed providers include Realbooru HTML Paheal and exclude CosBooru', () {
|
||||
final paheal = ProviderRepository.seedProviders()
|
||||
.where((provider) => provider.id == 'paheal')
|
||||
test('seed providers include Realbooru HTML Pawchive and exclude deprecated providers', () {
|
||||
final pawchive = ProviderRepository.seedProviders()
|
||||
.where((provider) => provider.id == 'pawchive')
|
||||
.single;
|
||||
expect(paheal.enabled, isTrue);
|
||||
expect(paheal.apiType, 'paheal');
|
||||
expect(paheal.baseUrl, 'https://rule34.paheal.net');
|
||||
expect(pawchive.enabled, isTrue);
|
||||
expect(pawchive.apiType, 'pawchive');
|
||||
expect(pawchive.baseUrl, 'https://pawchive.pw');
|
||||
final realbooru = ProviderRepository.seedProviders()
|
||||
.where((provider) => provider.id == 'realbooru')
|
||||
.single;
|
||||
@@ -249,22 +249,26 @@ void main() {
|
||||
.where((provider) => provider.id == 'cosbooru'),
|
||||
isEmpty,
|
||||
);
|
||||
final xbooru = ProviderRepository.seedProviders()
|
||||
.where((provider) => provider.id == 'xbooru')
|
||||
.single;
|
||||
expect(xbooru.enabled, isTrue);
|
||||
expect(xbooru.apiType, 'gelbooru');
|
||||
expect(xbooru.baseUrl, 'https://xbooru.com');
|
||||
final kemono = ProviderRepository.seedProviders()
|
||||
.where((provider) => provider.id == 'kemono')
|
||||
.single;
|
||||
final coomer = ProviderRepository.seedProviders()
|
||||
.where((provider) => provider.id == 'coomer')
|
||||
.single;
|
||||
expect(kemono.enabled, isTrue);
|
||||
expect(kemono.apiType, 'kemono');
|
||||
expect(coomer.enabled, isFalse);
|
||||
expect(coomer.apiType, 'coomer');
|
||||
expect(
|
||||
ProviderRepository.seedProviders()
|
||||
.where((provider) => provider.id == 'paheal'),
|
||||
isEmpty,
|
||||
);
|
||||
expect(
|
||||
ProviderRepository.seedProviders()
|
||||
.where((provider) => provider.id == 'xbooru'),
|
||||
isEmpty,
|
||||
);
|
||||
expect(
|
||||
ProviderRepository.seedProviders()
|
||||
.where((provider) => provider.id == 'kemono'),
|
||||
isEmpty,
|
||||
);
|
||||
expect(
|
||||
ProviderRepository.seedProviders()
|
||||
.where((provider) => provider.id == 'coomer'),
|
||||
isEmpty,
|
||||
);
|
||||
});
|
||||
|
||||
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