diff --git a/.gitignore b/.gitignore index dcb652d..372b9ea 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,5 @@ dist/ .flutter-plugins-dependencies android/local.properties *.log +*.apk +*.tar.gz diff --git a/README.md b/README.md index 15ff7cf..5682597 100644 --- a/README.md +++ b/README.md @@ -1,104 +1,124 @@ -# Lunaris +# 🌙 Lunaris -Lunaris — локальное Flutter-приложение для просмотра booru, gelbooru, rule34-подобных источников и artist-архивов в Pinterest-стиле. +

+ Lunaris Logo +

-Приложение не использует отдельный backend-сервер. Вся логика живет внутри Flutter: API-провайдеры, локальный кеш metadata, избранное, коллекции, история просмотров, фильтры, диагностика, загрузки и обновления. +

+ Современный кроссплатформенный клиент для Booru и Creator-архивов
+ Плавный Pinterest-стиль, поддержка фото, видео и GIF, умный черный список, встроенный менеджер загрузок и автообновление. +

-## Что умеет +

+ Version 2.0.2 + Flutter + Platforms + Gitea +

-- 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. +- **Бережное отношение к трафику**: В ленте загружаются только оптимизированные превью. Тяжелые оригиналы и видео загружаются только при открытии или скачивании. diff --git a/lib/app/app_version.dart b/lib/app/app_version.dart index d0cb62d..dd09079 100644 --- a/lib/app/app_version.dart +++ b/lib/app/app_version.dart @@ -1,3 +1,3 @@ -const appDisplayVersion = '2.0.1'; -const appBuildNumber = 23; +const appDisplayVersion = '2.0.2'; +const appBuildNumber = 24; const appReleaseChannel = 'Stable'; diff --git a/lib/app/changelog.dart b/lib/app/changelog.dart index 8c8fe20..94cdc78 100644 --- a/lib/app/changelog.dart +++ b/lib/app/changelog.dart @@ -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', diff --git a/lib/app/router.dart b/lib/app/router.dart index 751ca49..f0aece9 100644 --- a/lib/app/router.dart +++ b/lib/app/router.dart @@ -40,11 +40,27 @@ final appRouterProvider = Provider((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? 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', diff --git a/lib/backend/models/app_update_info.dart b/lib/backend/models/app_update_info.dart index bb8ef70..d042408 100644 --- a/lib/backend/models/app_update_info.dart +++ b/lib/backend/models/app_update_info.dart @@ -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; } diff --git a/lib/backend/models/post.dart b/lib/backend/models/post.dart index dc10c02..10e63dd 100644 --- a/lib/backend/models/post.dart +++ b/lib/backend/models/post.dart @@ -114,3 +114,14 @@ class Post { const {}, ); } + +class PostNavigationContext { + const PostNavigationContext({ + required this.currentPost, + required this.posts, + }); + + final Post currentPost; + final List posts; +} + diff --git a/lib/backend/repositories/provider_repository.dart b/lib/backend/repositories/provider_repository.dart index c8c943f..c50cb87 100644 --- a/lib/backend/repositories/provider_repository.dart +++ b/lib/backend/repositories/provider_repository.dart @@ -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(); diff --git a/lib/backend/services/settings_service.dart b/lib/backend/services/settings_service.dart index c6e3ec4..625aa15 100644 --- a/lib/backend/services/settings_service.dart +++ b/lib/backend/services/settings_service.dart @@ -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 favoriteArtists; final List 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? 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 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.from( + (json['favoriteArtists'] as List?) ?? defaults.favoriteArtists, + ), skippedUpdateVersion: json['skippedUpdateVersion'] as String?, lastUpdateCheckAt: json['lastUpdateCheckAt'] as String?, defaultRatingFilter: json['defaultRatingFilter'] as String?, diff --git a/lib/backend/services/update_service.dart b/lib/backend/services/update_service.dart index 2eb1068..aa5e305 100644 --- a/lib/backend/services/update_service.dart +++ b/lib/backend/services/update_service.dart @@ -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()) { 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'; } diff --git a/lib/features/artists/presentation/artist_posts_screen.dart b/lib/features/artists/presentation/artist_posts_screen.dart index b1d612a..50ddd86 100644 --- a/lib/features/artists/presentation/artist_posts_screen.dart +++ b/lib/features/artists/presentation/artist_posts_screen.dart @@ -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)) { diff --git a/lib/features/artists/presentation/artists_screen.dart b/lib/features/artists/presentation/artists_screen.dart index c385230..b2de701 100644 --- a/lib/features/artists/presentation/artists_screen.dart +++ b/lib/features/artists/presentation/artists_screen.dart @@ -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>((ref) async { @@ -37,6 +41,40 @@ class _ArtistsScreenState extends ConsumerState { Object? _error; List _artists = const []; + String? _selectedFavoriteArtistKey; + + Future _toggleFavoriteArtist( + AppSettings settings, + ArtistProfile artist, + ) async { + final current = List.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); + 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 { @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); + } 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 { 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 { }, 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.from(settings.favoriteArtists) + ..removeWhere((e) { + try { + return _FavoriteArtistItem.fromJson( + jsonDecode(e) as Map) + .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 { 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 toJson() => { + 'id': id, + 'service': service, + 'providerId': providerId, + 'name': name, + 'avatarUrl': avatarUrl, + }; + + factory _FavoriteArtistItem.fromJson(Map 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}); diff --git a/lib/features/collections/presentation/collection_details_screen.dart b/lib/features/collections/presentation/collection_details_screen.dart index 987b09a..831d56e 100644 --- a/lib/features/collections/presentation/collection_details_screen.dart +++ b/lib/features/collections/presentation/collection_details_screen.dart @@ -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)) { diff --git a/lib/features/favorites/presentation/favorites_screen.dart b/lib/features/favorites/presentation/favorites_screen.dart index 238b84b..87eedd4 100644 --- a/lib/features/favorites/presentation/favorites_screen.dart +++ b/lib/features/favorites/presentation/favorites_screen.dart @@ -121,7 +121,10 @@ class _FavoritesScreenState extends ConsumerState { 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), diff --git a/lib/features/feed/presentation/feed_screen.dart b/lib/features/feed/presentation/feed_screen.dart index 22a2e45..3698eba 100644 --- a/lib/features/feed/presentation/feed_screen.dart +++ b/lib/features/feed/presentation/feed_screen.dart @@ -296,7 +296,10 @@ class _FeedScreenState extends ConsumerState 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), diff --git a/lib/features/post/presentation/post_details_screen.dart b/lib/features/post/presentation/post_details_screen.dart index 8a07697..f7799a2 100644 --- a/lib/features/post/presentation/post_details_screen.dart +++ b/lib/features/post/presentation/post_details_screen.dart @@ -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? 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 []; + final feedPosts = postsList ?? + ref.watch(feedControllerProvider).value?.posts ?? + const []; final favoriteKeys = ref.watch(favoriteKeysProvider).value ?? {}; 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; diff --git a/lib/features/post/presentation/widgets/post_media_viewer.dart b/lib/features/post/presentation/widgets/post_media_viewer.dart index 23e0bbe..8851e79 100644 --- a/lib/features/post/presentation/widgets/post_media_viewer.dart +++ b/lib/features/post/presentation/widgets/post_media_viewer.dart @@ -219,9 +219,54 @@ class _PostMediaViewerState extends State ); }, ); - 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) { diff --git a/lib/features/viewed/presentation/viewed_screen.dart b/lib/features/viewed/presentation/viewed_screen.dart index df4a39e..cc49a00 100644 --- a/lib/features/viewed/presentation/viewed_screen.dart +++ b/lib/features/viewed/presentation/viewed_screen.dart @@ -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, diff --git a/lib/shared/widgets/post_card.dart b/lib/shared/widgets/post_card.dart index af02e37..d83dfdd 100644 --- a/lib/shared/widgets/post_card.dart +++ b/lib/shared/widgets/post_card.dart @@ -154,7 +154,9 @@ class _PostCardState extends ConsumerState { (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( diff --git a/pubspec.yaml b/pubspec.yaml index 3cfabee..046e567 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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" diff --git a/test/backend/isar_integration_test.dart b/test/backend/isar_integration_test.dart index 46d8483..e51dab3 100644 --- a/test/backend/isar_integration_test.dart +++ b/test/backend/isar_integration_test.dart @@ -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, diff --git a/test/backend/provider_parser_test.dart b/test/backend/provider_parser_test.dart index 1e56673..7160899 100644 --- a/test/backend/provider_parser_test.dart +++ b/test/backend/provider_parser_test.dart @@ -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', () { diff --git a/test/backend/update_service_test.dart b/test/backend/update_service_test.dart new file mode 100644 index 0000000..813147c --- /dev/null +++ b/test/backend/update_service_test.dart @@ -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); +}