feat: swipeable media pager, favorite artists, optimized video preview, Gitea updates and v2.0.2 release

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