Prepare Lunaris 1.1.2 experimental
This commit is contained in:
@@ -1,194 +1,117 @@
|
||||
# Lunaris
|
||||
# Lunaris
|
||||
|
||||
Lunaris — локальное Flutter-приложение для просмотра booru/gelbooru/rule34-подобных источников в Pinterest-стиле.
|
||||
Lunaris - локальное Flutter-приложение для просмотра booru/gelbooru/rule34-подобных источников в Pinterest-стиле.
|
||||
|
||||
Приложение не использует отдельный backend-сервер. Вся логика работает внутри Flutter: API-провайдеры, metadata-кэш, избранное, коллекции, история, настройки, диагностика и фильтры.
|
||||
Приложение не использует отдельный backend-сервер. Вся логика живет внутри Flutter: API-провайдеры, кэш metadata, избранное, коллекции, история просмотров, настройки, диагностика, фильтры и обновления.
|
||||
|
||||
## Возможности
|
||||
## Возможности
|
||||
|
||||
- Masonry feed с несколькими провайдерами и перемешанной выдачей All providers.
|
||||
- Поиск по тегам через chip-input: ввёл тег, нажал пробел или Enter, тег стал отдельным элементом.
|
||||
- Подсказки тегов из provider API.
|
||||
- Избранное, коллекции и история просмотренных постов.
|
||||
- Smart blacklist/whitelist с правилами по тегам, рейтингу, провайдеру, типу файла и score.
|
||||
- Детальный просмотр фото, GIF, видео и SWF на Windows.
|
||||
- Видео-плеер с fullscreen, repeat, mute, half-volume и сохранением выбранных настроек.
|
||||
- Управление провайдерами, health-check и provider diagnostics.
|
||||
- Экспорт и импорт настроек.
|
||||
- Автопроверка обновлений через Gitea releases.
|
||||
- Android и Windows сборки.
|
||||
- Masonry feed с несколькими провайдерами и перемешанной выдачей All providers.
|
||||
- Поиск тегов через chip-input: ввел тег, нажал пробел или Enter, тег стал отдельным элементом.
|
||||
- Подсказки тегов из API активных провайдеров.
|
||||
- Рзбранное, коллекции Рё отдельная вкладка Viewed.
|
||||
- Smart blacklist/whitelist по тегам, группам тегов, рейтингу, провайдеру, типу файла и score.
|
||||
- Details viewer для фото, GIF, видео и SWF на Windows.
|
||||
- Видео-плеер с fullscreen, repeat, mute, half-volume и сохранением выбранных настроек.
|
||||
- Отдельная вкладка Artists для Kemono/Coomer: поиск авторов и просмотр их работ без смешивания с обычным booru feed.
|
||||
- Управление провайдерами, health-check и provider diagnostics.
|
||||
- РРєСЃРїРѕСЂС‚ Рё РёРјРїРѕСЂС‚ настроек JSON.
|
||||
- Автопроверка обновлений через Gitea Releases, скачивание и открытие APK/Windows installer прямо из приложения.
|
||||
|
||||
## Скачать
|
||||
## Скачать
|
||||
|
||||
Готовые сборки лежат в Gitea Releases:
|
||||
Готовые сборки лежат в Gitea Releases:
|
||||
|
||||
[Lunaris v1.1.0](https://gitea.rardogsynapse.online/RarDog/RuleGelApp/releases/tag/v1.1.0)
|
||||
[Lunaris v1.1.2 Experimental](https://gitea.rardogsynapse.online/RarDog/RuleGelApp/releases/tag/v1.1.2-experimental)
|
||||
|
||||
- `Lunaris-v1.1.0.apk` — APK для Android.
|
||||
- `LunarisSetup-v1.1.0.exe` — установщик для Windows.
|
||||
- `LunarisPortable-v1.1.0.zip` — portable-версия для Windows.
|
||||
- `Lunaris-v1.1.2-experimental.apk` - APK для Android.
|
||||
- `LunarisSetup-v1.1.2-experimental.exe` - установщик для Windows.
|
||||
- `LunarisPortable-v1.1.2-experimental.zip` - portable-версия для Windows.
|
||||
|
||||
## Стек
|
||||
## Стек
|
||||
|
||||
- Flutter и Dart
|
||||
- Riverpod
|
||||
- go_router
|
||||
- Dio
|
||||
- Isar
|
||||
- cached_network_image
|
||||
- flutter_staggered_grid_view
|
||||
- media_kit
|
||||
- webview_windows для SWF/Ruffle на Windows
|
||||
- connectivity_plus
|
||||
- Flutter, Dart, Riverpod, go_router
|
||||
- Dio, Isar, connectivity_plus
|
||||
- cached_network_image, flutter_staggered_grid_view
|
||||
- media_kit для видео
|
||||
- webview_windows + Ruffle fallback для SWF на Windows
|
||||
|
||||
## Архитектура
|
||||
## Архитектура
|
||||
|
||||
Lunaris — Flutter-монолит. UI не дублирует backend-логику, а вызывает локальные сервисы через Riverpod.
|
||||
Lunaris - Flutter-монолит. UI не дублирует backend-логику, а вызывает локальные сервисы через Riverpod.
|
||||
|
||||
Основные слои:
|
||||
- `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` - приложение, роутер, тема и responsive helpers.
|
||||
- `lib/shared/widgets` - общие UI-компоненты.
|
||||
- `lib/features` - экраны и контроллеры фич.
|
||||
|
||||
- `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.
|
||||
- `lib/backend/di` — Riverpod providers для backend/core.
|
||||
- `lib/app` — приложение, роутер, тема и responsive helpers.
|
||||
- `lib/shared/widgets` — общие UI-компоненты.
|
||||
- `lib/features` — экраны и контроллеры фич.
|
||||
## Ркраны
|
||||
|
||||
## Экраны
|
||||
- `/` - главный feed.
|
||||
- `/search` - РїРѕРёСЃРє Рё recent searches.
|
||||
- `/post/:providerId/:postId` - details viewer.
|
||||
- `/favorites` - избранное.
|
||||
- `/viewed` - история просмотренных постов.
|
||||
- `/collections` - коллекции.
|
||||
- `/artists` - поиск авторов Kemono/Coomer.
|
||||
- `/artists/:providerId/:service/:artistId` - работы автора.
|
||||
- `/providers` - управление провайдерами.
|
||||
- `/providers/check` - диагностика провайдеров.
|
||||
- `/settings` - настройки.
|
||||
|
||||
- `/` — главный feed.
|
||||
- `/search` — поиск и recent searches.
|
||||
- `/post/:providerId/:postId` — детальный просмотр поста.
|
||||
- `/favorites` — избранное.
|
||||
- `/viewed` — история просмотренных постов.
|
||||
- `/collections` — коллекции.
|
||||
- `/collections/:collectionId` — посты внутри коллекции.
|
||||
- `/providers` — управление провайдерами.
|
||||
- `/providers/new` — добавление/редактирование провайдера.
|
||||
- `/providers/check` — проверка провайдеров и diagnostics.
|
||||
- `/settings` — настройки.
|
||||
## Провайдеры по умолчанию
|
||||
|
||||
## Провайдеры по умолчанию
|
||||
- Gelbooru - `https://gelbooru.com`
|
||||
- Rule34 - `https://api.rule34.xxx`
|
||||
- Safebooru - `https://safebooru.org`
|
||||
- Konachan - `https://konachan.com`
|
||||
- Yande.re - `https://yande.re`
|
||||
- e621 - `https://e621.net`
|
||||
- e926 - `https://e926.net`
|
||||
- Xbooru - `https://xbooru.com`
|
||||
- CosBooru - `https://cos.lycore.co`
|
||||
- Kemono - `https://kemono.su` (только Artists)
|
||||
- Coomer - `https://coomer.su` (только Artists, выключен по умолчанию)
|
||||
|
||||
- Gelbooru — `https://gelbooru.com`
|
||||
- Rule34 — `https://api.rule34.xxx`
|
||||
- Safebooru — `https://safebooru.org`
|
||||
- Konachan — `https://konachan.com`
|
||||
- Yande.re — `https://yande.re`
|
||||
- e621 — `https://e621.net`
|
||||
- e926 — `https://e926.net`
|
||||
- Xbooru — `https://xbooru.com`
|
||||
- CosBooru — `https://cos.lycore.co`
|
||||
Realbooru оставлен в списке, но выключен по умолчанию, потому что его публичный API сейчас нестабилен.
|
||||
|
||||
Realbooru оставлен в списке провайдеров, но выключен по умолчанию, потому что его публичный API сейчас нестабилен.
|
||||
Поддерживаемые `apiType`: `gelbooru`, `rule34`, `realbooru`, `danbooru`, `moebooru`, `e621`, `kemono`, `coomer`.
|
||||
|
||||
Поддерживаемые `apiType`:
|
||||
Один упавший провайдер не ломает общий feed: ошибка сохраняется в diagnostics, остальные источники продолжают отдавать посты.
|
||||
|
||||
- `gelbooru`
|
||||
- `rule34`
|
||||
- `realbooru`
|
||||
- `danbooru`
|
||||
- `moebooru`
|
||||
- `e621`
|
||||
## Как добавить провайдера
|
||||
|
||||
Один упавший провайдер не ломает общий feed: ошибки сохраняются в diagnostics, остальные провайдеры продолжают отдавать посты.
|
||||
1. Добавить mapper в `lib/backend/mappers`, если формат ответа отличается.
|
||||
2. Реализовать `ContentProvider` или artist-only `ArtistProvider` в `lib/backend/providers`.
|
||||
3. Зарегистрировать новый `apiType` в `ProviderFactory`.
|
||||
4. Добавить config через Providers UI или seed в `ProviderRepository`.
|
||||
|
||||
## Как добавить провайдера
|
||||
|
||||
1. Добавить mapper в `lib/backend/mappers`, если формат ответа отличается.
|
||||
2. Реализовать `ContentProvider` в `lib/backend/providers`.
|
||||
3. Зарегистрировать новый `apiType` в `ProviderFactory`.
|
||||
4. Добавить config через `ProviderManager` или UI Providers.
|
||||
|
||||
Интерфейс провайдера:
|
||||
|
||||
```dart
|
||||
abstract class ContentProvider {
|
||||
String get id;
|
||||
String get name;
|
||||
String get baseUrl;
|
||||
|
||||
Future<List<Post>> searchPosts({
|
||||
required List<String> tags,
|
||||
required int page,
|
||||
int limit = 50,
|
||||
String? rating,
|
||||
});
|
||||
|
||||
Future<Post?> getPost(String id);
|
||||
Future<ProviderHealth> checkHealth();
|
||||
}
|
||||
```
|
||||
|
||||
## Локальная база
|
||||
|
||||
Isar хранит:
|
||||
|
||||
- configs провайдеров;
|
||||
- health status и diagnostics;
|
||||
- cached metadata постов;
|
||||
- избранное;
|
||||
- коллекции и связи collection-post;
|
||||
- историю поиска;
|
||||
- историю просмотренных постов;
|
||||
- app settings.
|
||||
|
||||
Кэш хранит только metadata. Оригинальные медиафайлы не скачиваются автоматически.
|
||||
|
||||
## Сборка и запуск
|
||||
|
||||
Установить зависимости:
|
||||
## Запуск и сборка
|
||||
|
||||
```bash
|
||||
flutter pub get
|
||||
```
|
||||
|
||||
Запустить:
|
||||
|
||||
```bash
|
||||
flutter analyze
|
||||
flutter test
|
||||
flutter run -d windows
|
||||
flutter run -d android
|
||||
```
|
||||
|
||||
Собрать релиз:
|
||||
|
||||
```bash
|
||||
flutter build windows
|
||||
flutter build apk
|
||||
```
|
||||
|
||||
Собрать Windows installer:
|
||||
|
||||
```powershell
|
||||
& "$env:LOCALAPPDATA\Programs\Inno Setup 6\ISCC.exe" installer\Lunaris.iss
|
||||
```
|
||||
|
||||
## Важные заметки
|
||||
|
||||
- На Windows для Flutter plugins может понадобиться Developer Mode.
|
||||
- Android-сборка требует Android SDK и принятые `flutter doctor --android-licenses`.
|
||||
- Linux build нужно собирать на Linux-хосте.
|
||||
- Если путь проекта содержит апостроф, Flutter Windows/test tooling может ломаться. Для сборки можно использовать копию проекта в пути без спецсимволов.
|
||||
- SWF работает только на Windows через Ruffle/WebView. На Android он намеренно отключён.
|
||||
- Для SWF на Windows может понадобиться Microsoft WebView2 Runtime.
|
||||
|
||||
## Безопасность
|
||||
|
||||
- Lunaris использует публичные API и metadata.
|
||||
- Приложение не обходит ограничения сайтов.
|
||||
- Оригинальные файлы скачиваются только вручную по действию пользователя.
|
||||
- Dio использует нормальный User-Agent.
|
||||
- Retry/backoff ограничены.
|
||||
- NSFW/blur/filter настройки работают локально.
|
||||
|
||||
## Тесты
|
||||
Windows installer собирается через Inno Setup:
|
||||
|
||||
```bash
|
||||
flutter analyze
|
||||
flutter test
|
||||
ISCC installer/Lunaris.iss
|
||||
```
|
||||
|
||||
Покрыты backend-сервисы, provider parsing, smart blacklist, viewed history, settings migration и tag chip input.
|
||||
## Безопасность
|
||||
|
||||
- Feed работает с preview/sample metadata и не скачивает full files автоматически.
|
||||
- Скачивание фото/видео/SWF выполняется только по явному действию пользователя.
|
||||
- Приложение уважает rate limits и использует retry/backoff.
|
||||
- API keys/user data хранятся локально и попадают в export JSON только по действию пользователя.
|
||||
|
||||
|
||||
@@ -42,3 +42,7 @@ android {
|
||||
flutter {
|
||||
source = "../.."
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation("androidx.core:core-ktx:1.13.1")
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>
|
||||
|
||||
<application
|
||||
android:label="Lunaris"
|
||||
@@ -32,6 +33,15 @@
|
||||
<meta-data
|
||||
android:name="flutterEmbedding"
|
||||
android:value="2" />
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
</application>
|
||||
<!-- Required to query activities that can process text, see:
|
||||
https://developer.android.com/training/package-visibility and
|
||||
|
||||
@@ -2,10 +2,12 @@ package com.example.gel_rule_app
|
||||
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.BatteryManager
|
||||
import android.os.Environment
|
||||
import android.provider.MediaStore
|
||||
import androidx.core.content.FileProvider
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
@@ -18,17 +20,24 @@ class MainActivity : FlutterActivity() {
|
||||
super.configureFlutterEngine(flutterEngine)
|
||||
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "rulegel/downloads")
|
||||
.setMethodCallHandler { call, result ->
|
||||
if (call.method != "saveToDownloads") {
|
||||
result.notImplemented()
|
||||
return@setMethodCallHandler
|
||||
}
|
||||
try {
|
||||
val path = call.argument<String>("path") ?: error("Missing path")
|
||||
val fileName = call.argument<String>("fileName") ?: File(path).name
|
||||
val mimeType = call.argument<String>("mimeType") ?: "application/octet-stream"
|
||||
result.success(saveToDownloads(path, fileName, mimeType))
|
||||
when (call.method) {
|
||||
"saveToDownloads" -> {
|
||||
val path = call.argument<String>("path") ?: error("Missing path")
|
||||
val fileName = call.argument<String>("fileName") ?: File(path).name
|
||||
val mimeType = call.argument<String>("mimeType") ?: "application/octet-stream"
|
||||
result.success(saveToDownloads(path, fileName, mimeType))
|
||||
}
|
||||
"openFile" -> {
|
||||
val path = call.argument<String>("path") ?: error("Missing path")
|
||||
val mimeType = call.argument<String>("mimeType") ?: "application/octet-stream"
|
||||
openFile(path, mimeType)
|
||||
result.success(null)
|
||||
}
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
} catch (error: Throwable) {
|
||||
result.error("save_failed", error.message, null)
|
||||
result.error("download_failed", error.message, null)
|
||||
}
|
||||
}
|
||||
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "rulegel/device")
|
||||
@@ -89,6 +98,21 @@ class MainActivity : FlutterActivity() {
|
||||
return if (level in 0..100) level else null
|
||||
}
|
||||
|
||||
private fun openFile(path: String, mimeType: String) {
|
||||
val file = File(path)
|
||||
val uri = FileProvider.getUriForFile(
|
||||
this,
|
||||
"${applicationContext.packageName}.fileprovider",
|
||||
file
|
||||
)
|
||||
val intent = Intent(Intent.ACTION_VIEW).apply {
|
||||
setDataAndType(uri, mimeType)
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
startActivity(Intent.createChooser(intent, "Open update"))
|
||||
}
|
||||
|
||||
private fun supportedRefreshRates(): List<Double> {
|
||||
val display = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) display else windowManager.defaultDisplay
|
||||
return display?.supportedModes
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<cache-path name="cache" path="." />
|
||||
<external-cache-path name="external_cache" path="." />
|
||||
<files-path name="files" path="." />
|
||||
</paths>
|
||||
@@ -1,5 +1,5 @@
|
||||
#define MyAppName "Lunaris"
|
||||
#define MyAppVersion "1.1.0"
|
||||
#define MyAppVersion "1.1.2"
|
||||
#define MyAppPublisher "RarDog"
|
||||
#define MyAppExeName "gel_rule_app.exe"
|
||||
|
||||
|
||||
+20
-6
@@ -117,14 +117,11 @@ class _AppOverlayState extends ConsumerState<_AppOverlay> {
|
||||
),
|
||||
FilledButton.icon(
|
||||
onPressed: () async {
|
||||
final url = Uri.tryParse(info.htmlUrl);
|
||||
if (url != null) {
|
||||
await launchUrl(url, mode: LaunchMode.externalApplication);
|
||||
}
|
||||
await _downloadUpdate(info);
|
||||
if (context.mounted) Navigator.pop(context);
|
||||
},
|
||||
icon: const Icon(Icons.open_in_new_rounded),
|
||||
label: const Text('Update'),
|
||||
icon: const Icon(Icons.download_rounded),
|
||||
label: const Text('Download & open'),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -132,6 +129,23 @@ class _AppOverlayState extends ConsumerState<_AppOverlay> {
|
||||
ref.invalidate(appSettingsProvider);
|
||||
}
|
||||
|
||||
Future<void> _downloadUpdate(AppUpdateInfo info) async {
|
||||
final updateService = ref.read(updateServiceProvider);
|
||||
final assetUrl = updateService.assetUrlForCurrentPlatform(info);
|
||||
if (assetUrl == null) {
|
||||
final url = Uri.tryParse(info.htmlUrl);
|
||||
if (url != null) {
|
||||
await launchUrl(url, mode: LaunchMode.externalApplication);
|
||||
}
|
||||
return;
|
||||
}
|
||||
await ref.read(downloadManagerServiceProvider).startUrl(
|
||||
url: assetUrl,
|
||||
fileName: updateService.assetFileName(info, assetUrl),
|
||||
openAfterDownload: true,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final appSettings = ref.watch(appSettingsProvider).value;
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
const appDisplayVersion = '1.1.0';
|
||||
const appBuildNumber = 11;
|
||||
const appDisplayVersion = '1.1.2';
|
||||
const appBuildNumber = 13;
|
||||
const appReleaseChannel = 'Experimental';
|
||||
|
||||
@@ -1,4 +1,24 @@
|
||||
const lunarisChangelog = [
|
||||
LunarisChange(
|
||||
version: '1.1.2',
|
||||
title: 'Experimental tag and Artists update',
|
||||
bullets: [
|
||||
'Details tags now use category blocks with wrapping chips again.',
|
||||
'Gelbooru-compatible providers can enrich tag categories when their tag API exposes metadata.',
|
||||
'Kemono and Coomer Artists use improved creator search and public API fallbacks.',
|
||||
'This is an experimental release for checking the new tag and Artists behavior.',
|
||||
],
|
||||
),
|
||||
LunarisChange(
|
||||
version: '1.1.1',
|
||||
title: 'Artists and in-app updates',
|
||||
bullets: [
|
||||
'Disabled providers now disappear from feed chips, search suggestions and feed loading immediately.',
|
||||
'Kemono and Coomer are available as a separate Artists flow, without mixing into the normal booru feed.',
|
||||
'Update dialogs can download and open the APK or Windows installer directly from the app.',
|
||||
'Tag input keeps its scroll position steadier while typing and committing chips.',
|
||||
],
|
||||
),
|
||||
LunarisChange(
|
||||
version: '1.1.0',
|
||||
title: 'Lunaris rebrand and CosBooru',
|
||||
|
||||
@@ -2,6 +2,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../backend/backend.dart';
|
||||
import '../features/artists/presentation/artist_posts_screen.dart';
|
||||
import '../features/artists/presentation/artists_screen.dart';
|
||||
import '../features/collections/presentation/collection_details_screen.dart';
|
||||
import '../features/collections/presentation/collections_screen.dart';
|
||||
import '../features/favorites/presentation/favorites_screen.dart';
|
||||
@@ -67,6 +69,20 @@ final appRouterProvider = Provider<GoRouter>((ref) {
|
||||
collectionId: state.pathParameters['collectionId']!,
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/artists',
|
||||
builder: (context, state) => const ArtistsScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/artists/:providerId/:service/:artistId',
|
||||
builder: (context, state) => ArtistPostsScreen(
|
||||
providerId: state.pathParameters['providerId']!,
|
||||
service: state.pathParameters['service']!,
|
||||
artistId: state.pathParameters['artistId']!,
|
||||
artistName: state.uri.queryParameters['name'] ??
|
||||
state.pathParameters['artistId']!,
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/providers',
|
||||
builder: (context, state) => const ProvidersScreen(),
|
||||
|
||||
@@ -2,6 +2,8 @@ export 'di/backend_providers.dart';
|
||||
export 'models/collection.dart';
|
||||
export 'models/content_provider_config.dart';
|
||||
export 'models/app_update_info.dart';
|
||||
export 'models/artist_profile.dart';
|
||||
export 'models/artist_work_query.dart';
|
||||
export 'models/download_task.dart';
|
||||
export 'models/favorite.dart';
|
||||
export 'models/post.dart';
|
||||
|
||||
@@ -8,6 +8,7 @@ class AppUpdateInfo {
|
||||
required this.publishedAt,
|
||||
this.apkUrl,
|
||||
this.windowsInstallerUrl,
|
||||
this.portableZipUrl,
|
||||
});
|
||||
|
||||
final String version;
|
||||
@@ -18,6 +19,8 @@ class AppUpdateInfo {
|
||||
final DateTime? publishedAt;
|
||||
final String? apkUrl;
|
||||
final String? windowsInstallerUrl;
|
||||
final String? portableZipUrl;
|
||||
|
||||
bool get hasAssets => apkUrl != null || windowsInstallerUrl != null;
|
||||
bool get hasAssets =>
|
||||
apkUrl != null || windowsInstallerUrl != null || portableZipUrl != null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
class ArtistProfile {
|
||||
const ArtistProfile({
|
||||
required this.id,
|
||||
required this.providerId,
|
||||
required this.service,
|
||||
required this.name,
|
||||
required this.displayName,
|
||||
required this.url,
|
||||
this.avatarUrl,
|
||||
this.updatedAt,
|
||||
this.postCount,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String providerId;
|
||||
final String service;
|
||||
final String name;
|
||||
final String displayName;
|
||||
final String? avatarUrl;
|
||||
final DateTime? updatedAt;
|
||||
final int? postCount;
|
||||
final String url;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
class ArtistWorkQuery {
|
||||
const ArtistWorkQuery({
|
||||
required this.providerId,
|
||||
required this.service,
|
||||
required this.artistId,
|
||||
required this.artistName,
|
||||
});
|
||||
|
||||
final String providerId;
|
||||
final String service;
|
||||
final String artistId;
|
||||
final String artistName;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return other is ArtistWorkQuery &&
|
||||
other.providerId == providerId &&
|
||||
other.service == service &&
|
||||
other.artistId == artistId &&
|
||||
other.artistName == artistName;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(providerId, service, artistId, artistName);
|
||||
}
|
||||
@@ -11,19 +11,23 @@ enum DownloadTaskStatus {
|
||||
class DownloadTask {
|
||||
const DownloadTask({
|
||||
required this.id,
|
||||
required this.post,
|
||||
required this.fileName,
|
||||
required this.progress,
|
||||
required this.status,
|
||||
this.post,
|
||||
this.sourceUrl,
|
||||
this.openAfterDownload = false,
|
||||
this.savedPath,
|
||||
this.error,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final Post post;
|
||||
final Post? post;
|
||||
final String? sourceUrl;
|
||||
final String fileName;
|
||||
final double progress;
|
||||
final DownloadTaskStatus status;
|
||||
final bool openAfterDownload;
|
||||
final String? savedPath;
|
||||
final String? error;
|
||||
|
||||
@@ -36,9 +40,11 @@ class DownloadTask {
|
||||
return DownloadTask(
|
||||
id: id,
|
||||
post: post,
|
||||
sourceUrl: sourceUrl,
|
||||
fileName: fileName,
|
||||
progress: progress ?? this.progress,
|
||||
status: status ?? this.status,
|
||||
openAfterDownload: openAfterDownload,
|
||||
savedPath: savedPath ?? this.savedPath,
|
||||
error: error,
|
||||
);
|
||||
|
||||
@@ -35,6 +35,42 @@ class Post {
|
||||
|
||||
String get cacheKey => '$providerId:$id';
|
||||
|
||||
Post copyWith({
|
||||
String? id,
|
||||
String? providerId,
|
||||
String? providerName,
|
||||
String? previewUrl,
|
||||
String? sampleUrl,
|
||||
String? fileUrl,
|
||||
List<String>? tags,
|
||||
String? rating,
|
||||
int? width,
|
||||
int? height,
|
||||
String? source,
|
||||
DateTime? createdAt,
|
||||
String? fileType,
|
||||
int? score,
|
||||
Map<String, List<String>>? tagGroups,
|
||||
}) {
|
||||
return Post(
|
||||
id: id ?? this.id,
|
||||
providerId: providerId ?? this.providerId,
|
||||
providerName: providerName ?? this.providerName,
|
||||
previewUrl: previewUrl ?? this.previewUrl,
|
||||
sampleUrl: sampleUrl ?? this.sampleUrl,
|
||||
fileUrl: fileUrl ?? this.fileUrl,
|
||||
tags: tags ?? this.tags,
|
||||
rating: rating ?? this.rating,
|
||||
width: width ?? this.width,
|
||||
height: height ?? this.height,
|
||||
source: source ?? this.source,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
fileType: fileType ?? this.fileType,
|
||||
score: score ?? this.score,
|
||||
tagGroups: tagGroups ?? this.tagGroups,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'providerId': providerId,
|
||||
|
||||
@@ -3,6 +3,8 @@ import '../models/post_comment.dart';
|
||||
import '../models/provider_health.dart';
|
||||
import '../models/tag_suggestion.dart';
|
||||
import '../models/top_period_filter.dart';
|
||||
import '../models/artist_profile.dart';
|
||||
import '../models/artist_work_query.dart';
|
||||
|
||||
abstract class ContentProvider {
|
||||
String get id;
|
||||
@@ -29,3 +31,35 @@ abstract class CommentProvider {
|
||||
abstract class TagSuggestionProvider {
|
||||
Future<List<TagSuggestion>> suggestTags(String query, {int limit = 20});
|
||||
}
|
||||
|
||||
abstract class TagMetadataProvider {
|
||||
Future<Map<String, List<String>>> categorizeTags(List<String> tags);
|
||||
}
|
||||
|
||||
abstract class ArtistProvider {
|
||||
Future<List<ArtistProfile>> listArtists({
|
||||
String? service,
|
||||
String? query,
|
||||
int page = 1,
|
||||
int limit = 30,
|
||||
});
|
||||
|
||||
Future<List<ArtistProfile>> searchArtists(
|
||||
String query, {
|
||||
String? service,
|
||||
int page = 1,
|
||||
int limit = 30,
|
||||
});
|
||||
|
||||
Future<List<Post>> getArtistPosts({
|
||||
required ArtistWorkQuery query,
|
||||
int page = 0,
|
||||
int limit = 50,
|
||||
});
|
||||
|
||||
Future<List<PostComment>> getArtistPostComments(
|
||||
String service,
|
||||
String artistId,
|
||||
String postId,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,10 +6,16 @@ import '../mappers/gelbooru_mapper.dart';
|
||||
import '../models/post.dart';
|
||||
import '../models/post_comment.dart';
|
||||
import '../models/provider_health.dart';
|
||||
import '../models/tag_suggestion.dart';
|
||||
import '../models/top_period_filter.dart';
|
||||
import 'content_provider.dart';
|
||||
|
||||
class GelbooruProvider implements ContentProvider, CommentProvider {
|
||||
class GelbooruProvider
|
||||
implements
|
||||
ContentProvider,
|
||||
CommentProvider,
|
||||
TagSuggestionProvider,
|
||||
TagMetadataProvider {
|
||||
GelbooruProvider({
|
||||
required this.id,
|
||||
required this.name,
|
||||
@@ -134,6 +140,72 @@ class GelbooruProvider implements ContentProvider, CommentProvider {
|
||||
return _commentsFromResponse(response.data, postId);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<TagSuggestion>> suggestTags(String query,
|
||||
{int limit = 20}) async {
|
||||
final trimmed = query.trim();
|
||||
if (trimmed.isEmpty) return const [];
|
||||
final response = await _dio.get<dynamic>(
|
||||
'/index.php',
|
||||
queryParameters: {
|
||||
'page': 'dapi',
|
||||
's': 'tag',
|
||||
'q': 'index',
|
||||
'json': '1',
|
||||
'name_pattern': '$trimmed*',
|
||||
'limit': limit.clamp(1, 100),
|
||||
..._queryParameters,
|
||||
},
|
||||
);
|
||||
return _tagItems(response.data)
|
||||
.whereType<Map>()
|
||||
.map((item) {
|
||||
final json = Map<String, dynamic>.from(item);
|
||||
return TagSuggestion(
|
||||
name: (json['name'] ?? '').toString(),
|
||||
category: tagCategoryFromString(
|
||||
(json['type'] ?? json['tag_type'] ?? json['category']).toString(),
|
||||
),
|
||||
postCount: _int(json['count'] ?? json['post_count']),
|
||||
providerId: id,
|
||||
);
|
||||
})
|
||||
.where((tag) => tag.name.isNotEmpty)
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Map<String, List<String>>> categorizeTags(List<String> tags) async {
|
||||
final cleaned = tags
|
||||
.map((tag) => tag.trim())
|
||||
.where((tag) => tag.isNotEmpty)
|
||||
.toSet()
|
||||
.take(80)
|
||||
.toList(growable: false);
|
||||
if (cleaned.isEmpty) return const {};
|
||||
|
||||
final groups = <String, List<String>>{};
|
||||
for (final tag in cleaned) {
|
||||
try {
|
||||
final suggestions = await suggestTags(tag, limit: 3);
|
||||
final exact = suggestions.where((item) => item.name == tag).firstOrNull;
|
||||
final category = exact?.category ?? TagCategory.general;
|
||||
final key = switch (category) {
|
||||
TagCategory.artist => 'artist',
|
||||
TagCategory.copyright => 'copyright',
|
||||
TagCategory.character => 'character',
|
||||
TagCategory.meta => 'meta',
|
||||
TagCategory.species => 'species',
|
||||
TagCategory.general || TagCategory.unknown => 'general',
|
||||
};
|
||||
groups.putIfAbsent(key, () => <String>[]).add(tag);
|
||||
} catch (_) {
|
||||
groups.putIfAbsent('general', () => <String>[]).add(tag);
|
||||
}
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
Never unavailable(String message) =>
|
||||
throw ProviderUnavailableException(message);
|
||||
|
||||
@@ -193,6 +265,29 @@ class GelbooruProvider implements ContentProvider, CommentProvider {
|
||||
return const [];
|
||||
}
|
||||
|
||||
List<dynamic> _tagItems(dynamic data) {
|
||||
if (data is List) return data;
|
||||
if (data is String) return _tagItemsFromText(data);
|
||||
if (data is! Map) return const [];
|
||||
final json = Map<String, dynamic>.from(data);
|
||||
final tags = json['tag'] ?? json['tags'];
|
||||
if (tags is List) return tags;
|
||||
if (tags is Map && tags['tag'] is List) return tags['tag'] as List;
|
||||
if (tags is Map) return [tags];
|
||||
return const [];
|
||||
}
|
||||
|
||||
List<Map<String, dynamic>> _tagItemsFromText(String text) {
|
||||
final items = <Map<String, dynamic>>[];
|
||||
for (final match
|
||||
in RegExp(r'<tag\b([^>]*)/?>', caseSensitive: false, dotAll: true)
|
||||
.allMatches(text)) {
|
||||
final attrs = _attributes(match.group(1) ?? '');
|
||||
if (attrs.isNotEmpty) items.add(attrs);
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
List<Map<String, dynamic>> _commentItemsFromText(String text) {
|
||||
final items = <Map<String, dynamic>>[];
|
||||
for (final match in RegExp(r'<comment\b([^>]*)>(.*?)</comment>',
|
||||
@@ -240,4 +335,10 @@ class GelbooruProvider implements ContentProvider, CommentProvider {
|
||||
.replaceAll(''', "'")
|
||||
.replaceAll(''', "'");
|
||||
}
|
||||
|
||||
int _int(dynamic value) {
|
||||
if (value is int) return value;
|
||||
if (value is num) return value.toInt();
|
||||
return int.tryParse(value?.toString() ?? '') ?? 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,503 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../../core/http/dio_client.dart';
|
||||
import '../models/artist_profile.dart';
|
||||
import '../models/artist_work_query.dart';
|
||||
import '../models/post.dart';
|
||||
import '../models/post_comment.dart';
|
||||
import '../models/provider_health.dart';
|
||||
import '../models/top_period_filter.dart';
|
||||
import 'content_provider.dart';
|
||||
|
||||
class KemonoProvider
|
||||
implements ContentProvider, ArtistProvider, CommentProvider {
|
||||
KemonoProvider({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.baseUrl,
|
||||
required DioClient dioClient,
|
||||
Map<String, String> queryParameters = const {},
|
||||
}) : _dio = dioClient.dio,
|
||||
_creatorDio = Dio(
|
||||
BaseOptions(
|
||||
baseUrl: queryParameters['creator_api_base'] ??
|
||||
'https://kemono-api.mbaharip.com',
|
||||
connectTimeout: const Duration(seconds: 15),
|
||||
receiveTimeout: const Duration(seconds: 20),
|
||||
headers: const {
|
||||
'Accept': 'application/json',
|
||||
'User-Agent': 'Lunaris/1.1 Flutter local booru browser',
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
@override
|
||||
final String id;
|
||||
|
||||
@override
|
||||
final String name;
|
||||
|
||||
@override
|
||||
final String baseUrl;
|
||||
|
||||
final Dio _dio;
|
||||
final Dio _creatorDio;
|
||||
|
||||
bool get _isCoomer =>
|
||||
id.toLowerCase().contains('coomer') ||
|
||||
baseUrl.toLowerCase().contains('coomer');
|
||||
|
||||
@override
|
||||
Future<List<Post>> searchPosts({
|
||||
required List<String> tags,
|
||||
required int page,
|
||||
int limit = 50,
|
||||
String? rating,
|
||||
TopPeriodFilter topPeriod = TopPeriodFilter.none,
|
||||
}) async {
|
||||
return const [];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Post?> getPost(String id) async => null;
|
||||
|
||||
@override
|
||||
Future<ProviderHealth> checkHealth() async {
|
||||
final startedAt = DateTime.now();
|
||||
try {
|
||||
await _creatorDio.get<dynamic>(
|
||||
_creatorPath(),
|
||||
queryParameters: {'page': 1, 'itemsPerPage': 1},
|
||||
);
|
||||
return ProviderHealth(
|
||||
providerId: id,
|
||||
status: ProviderStatus.online,
|
||||
pingMs: DateTime.now().difference(startedAt).inMilliseconds,
|
||||
lastCheckedAt: DateTime.now(),
|
||||
apiVersion: _isCoomer ? 'coomer-artists' : 'kemono-artists',
|
||||
);
|
||||
} catch (error) {
|
||||
return ProviderHealth(
|
||||
providerId: id,
|
||||
status: ProviderStatus.offline,
|
||||
pingMs: DateTime.now().difference(startedAt).inMilliseconds,
|
||||
lastCheckedAt: DateTime.now(),
|
||||
errorMessage: error.toString(),
|
||||
apiVersion: _isCoomer ? 'coomer-artists' : 'kemono-artists',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<ArtistProfile>> listArtists({
|
||||
String? service,
|
||||
String? query,
|
||||
int page = 1,
|
||||
int limit = 30,
|
||||
}) async {
|
||||
final searchText = query?.trim() ?? '';
|
||||
if (searchText.isNotEmpty) {
|
||||
try {
|
||||
final response = await _creatorDio.get<dynamic>(
|
||||
'/search',
|
||||
queryParameters: {
|
||||
'q': searchText,
|
||||
'query': searchText,
|
||||
'site': _isCoomer ? 'coomer' : 'kemono',
|
||||
'page': page,
|
||||
'itemsPerPage': limit.clamp(1, 100),
|
||||
},
|
||||
);
|
||||
final artists = _artistsFromResponse(response.data)
|
||||
.where((artist) =>
|
||||
service == null || service.isEmpty || artist.service == service)
|
||||
.toList(growable: false);
|
||||
if (artists.isNotEmpty) return artists;
|
||||
} catch (_) {
|
||||
// Fall through to the indexed creator endpoints.
|
||||
}
|
||||
}
|
||||
try {
|
||||
final response = await _creatorDio.get<dynamic>(
|
||||
service == null || service.isEmpty
|
||||
? _creatorPath()
|
||||
: '${_creatorPath()}/$service',
|
||||
queryParameters: {
|
||||
'page': page,
|
||||
'itemsPerPage': limit.clamp(1, 100),
|
||||
if (query != null && query.trim().isNotEmpty) 'keyword': query.trim(),
|
||||
},
|
||||
);
|
||||
return _artistsFromResponse(response.data);
|
||||
} catch (_) {
|
||||
return _listArtistsFromPublicApi(
|
||||
service: service,
|
||||
query: query,
|
||||
page: page,
|
||||
limit: limit,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<ArtistProfile>> searchArtists(
|
||||
String query, {
|
||||
String? service,
|
||||
int page = 1,
|
||||
int limit = 30,
|
||||
}) {
|
||||
return listArtists(
|
||||
service: service, query: query, page: page, limit: limit);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<Post>> getArtistPosts({
|
||||
required ArtistWorkQuery query,
|
||||
int page = 0,
|
||||
int limit = 50,
|
||||
}) async {
|
||||
Object? lastError;
|
||||
final paths = [
|
||||
'/api/v1/${query.service}/user/${query.artistId}/posts',
|
||||
'/api/v1/${query.service}/user/${query.artistId}',
|
||||
];
|
||||
for (final apiBase in _apiBaseCandidates()) {
|
||||
for (final path in paths) {
|
||||
try {
|
||||
final response = await _getFromApiBase<dynamic>(
|
||||
apiBase,
|
||||
path,
|
||||
queryParameters: {'o': page * limit},
|
||||
);
|
||||
final posts = _postItems(response.data);
|
||||
final mapped = <Post>[];
|
||||
for (final post in posts.whereType<Map>()) {
|
||||
mapped.addAll(_postsFromArtistPost(
|
||||
Map<String, dynamic>.from(post),
|
||||
query: query,
|
||||
mediaBaseUrl: apiBase,
|
||||
));
|
||||
}
|
||||
if (mapped.isNotEmpty || posts.isEmpty) {
|
||||
return mapped.take(limit).toList(growable: false);
|
||||
}
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
}
|
||||
}
|
||||
throw StateError(
|
||||
'$name artist works are unavailable right now. Try again later. '
|
||||
'${_shortError(lastError)}',
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<PostComment>> getArtistPostComments(
|
||||
String service,
|
||||
String artistId,
|
||||
String postId,
|
||||
) async {
|
||||
Object? lastError;
|
||||
final path = '/api/v1/$service/user/$artistId/post/$postId/comments';
|
||||
for (final apiBase in _apiBaseCandidates()) {
|
||||
try {
|
||||
final response = await _getFromApiBase<dynamic>(apiBase, path);
|
||||
return _commentsFromResponse(response.data, postId);
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
}
|
||||
throw StateError(
|
||||
'$name comments are unavailable right now. ${_shortError(lastError)}',
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<PostComment>> getComments(String postId) async {
|
||||
final parts = postId.split(':');
|
||||
if (parts.length < 4) return const [];
|
||||
return getArtistPostComments(parts[0], parts[1], parts[2]);
|
||||
}
|
||||
|
||||
String _creatorPath() => _isCoomer ? '/coomer' : '/kemono';
|
||||
|
||||
List<ArtistProfile> _artistsFromResponse(dynamic data) {
|
||||
final items = data is Map ? data['data'] : data;
|
||||
if (items is! List) return const [];
|
||||
return items
|
||||
.whereType<Map>()
|
||||
.map((item) {
|
||||
final json = Map<String, dynamic>.from(item);
|
||||
final service = (json['service'] ?? '').toString();
|
||||
final artistId =
|
||||
(json['id'] ?? json['creator_id'] ?? json['user_id'] ?? '')
|
||||
.toString();
|
||||
final name = (json['name'] ??
|
||||
json['display_name'] ??
|
||||
json['username'] ??
|
||||
artistId)
|
||||
.toString();
|
||||
final rawAvatar = (json['avatar'] ??
|
||||
json['icon'] ??
|
||||
json['image'] ??
|
||||
json['avatar_url'] ??
|
||||
'')
|
||||
.toString();
|
||||
return ArtistProfile(
|
||||
id: artistId,
|
||||
providerId: id,
|
||||
service: service,
|
||||
name: name,
|
||||
displayName: name,
|
||||
avatarUrl: _absoluteUrl(rawAvatar) ?? _iconUrl(service, artistId),
|
||||
updatedAt: _dateFromAny(json['updated']),
|
||||
postCount: (json['post_count'] as num?)?.toInt(),
|
||||
url: '$baseUrl/$service/user/$artistId',
|
||||
);
|
||||
})
|
||||
.where((artist) => artist.id.isNotEmpty)
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
Future<List<ArtistProfile>> _listArtistsFromPublicApi({
|
||||
String? service,
|
||||
String? query,
|
||||
required int page,
|
||||
required int limit,
|
||||
}) async {
|
||||
Object? lastError;
|
||||
dynamic data;
|
||||
for (final apiBase in _apiBaseCandidates()) {
|
||||
try {
|
||||
final response =
|
||||
await _getFromApiBase<dynamic>(apiBase, '/api/v1/creators.txt');
|
||||
data = response.data;
|
||||
break;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
}
|
||||
if (data == null) {
|
||||
throw StateError(
|
||||
'$name artist index is unavailable right now. ${_shortError(lastError)}',
|
||||
);
|
||||
}
|
||||
final queryText = (query ?? '').trim().toLowerCase();
|
||||
final offset = (page - 1).clamp(0, 999999) * limit;
|
||||
final items = _artistsFromResponse({'data': data})
|
||||
.where((artist) =>
|
||||
(service == null || service.isEmpty || artist.service == service) &&
|
||||
(queryText.isEmpty ||
|
||||
artist.displayName.toLowerCase().contains(queryText) ||
|
||||
artist.id.toLowerCase().contains(queryText)))
|
||||
.toList(growable: false);
|
||||
return items.skip(offset).take(limit).toList(growable: false);
|
||||
}
|
||||
|
||||
List<dynamic> _postItems(dynamic data) {
|
||||
if (data is List) return data;
|
||||
if (data is Map) {
|
||||
final results = data['results'];
|
||||
if (results is List) return results;
|
||||
final posts = data['posts'];
|
||||
if (posts is List) return posts;
|
||||
if (posts is Map) return _postItems(posts);
|
||||
final dataItems = data['data'];
|
||||
if (dataItems is List) return dataItems;
|
||||
}
|
||||
return const [];
|
||||
}
|
||||
|
||||
List<Post> _postsFromArtistPost(
|
||||
Map<String, dynamic> json, {
|
||||
required ArtistWorkQuery query,
|
||||
required String mediaBaseUrl,
|
||||
}) {
|
||||
final postId = (json['id'] ?? json['post_id'] ?? '').toString();
|
||||
final title = (json['title'] ?? '').toString();
|
||||
final published = _dateFromAny(json['published']) ??
|
||||
_dateFromAny(json['added']) ??
|
||||
_dateFromAny(json['edited']) ??
|
||||
DateTime.fromMillisecondsSinceEpoch(0);
|
||||
final files = <Map<String, dynamic>>[];
|
||||
final file = json['file'];
|
||||
if (file is Map && file.isNotEmpty) {
|
||||
files.add(Map<String, dynamic>.from(file));
|
||||
}
|
||||
final attachments = json['attachments'];
|
||||
if (attachments is List) {
|
||||
files.addAll(attachments.whereType<Map>().map(Map<String, dynamic>.from));
|
||||
}
|
||||
var index = 0;
|
||||
return files
|
||||
.map((file) {
|
||||
final url = _mediaUrl(file, mediaBaseUrl: mediaBaseUrl);
|
||||
final fileName =
|
||||
(file['name'] ?? Uri.tryParse(url)?.pathSegments.last ?? '')
|
||||
.toString();
|
||||
final type = _fileType('$fileName $url');
|
||||
final stableId =
|
||||
'${query.service}:${query.artistId}:$postId:${index++}';
|
||||
final tags = [
|
||||
query.service,
|
||||
query.artistName,
|
||||
if (title.trim().isNotEmpty) title.trim(),
|
||||
];
|
||||
return Post(
|
||||
id: stableId,
|
||||
providerId: id,
|
||||
providerName: name,
|
||||
previewUrl: url,
|
||||
sampleUrl: url,
|
||||
fileUrl: url,
|
||||
tags: tags,
|
||||
rating: 'unknown',
|
||||
width: 0,
|
||||
height: 0,
|
||||
source:
|
||||
'$baseUrl/${query.service}/user/${query.artistId}/post/$postId',
|
||||
createdAt: published,
|
||||
fileType: type,
|
||||
score: 0,
|
||||
tagGroups: {
|
||||
'artist': [query.artistName],
|
||||
'meta': [query.service],
|
||||
if (title.trim().isNotEmpty) 'copyright': [title.trim()],
|
||||
},
|
||||
);
|
||||
})
|
||||
.where((post) => post.fileUrl.isNotEmpty)
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
List<PostComment> _commentsFromResponse(dynamic data, String postId) {
|
||||
final items = data is Map ? (data['comments'] ?? data['data']) : data;
|
||||
if (items is! List) return const [];
|
||||
return items
|
||||
.whereType<Map>()
|
||||
.map((item) {
|
||||
final json = Map<String, dynamic>.from(item);
|
||||
return PostComment(
|
||||
id: (json['id'] ?? json['comment_id'] ?? '').toString(),
|
||||
postId: postId,
|
||||
providerId: id,
|
||||
authorName: (json['author'] ??
|
||||
json['username'] ??
|
||||
json['user'] ??
|
||||
json['creator_name'] ??
|
||||
'user')
|
||||
.toString(),
|
||||
body: (json['content'] ?? json['body'] ?? json['comment'] ?? '')
|
||||
.toString(),
|
||||
createdAt: _dateFromAny(json['added']) ??
|
||||
_dateFromAny(json['created_at']) ??
|
||||
DateTime.fromMillisecondsSinceEpoch(0),
|
||||
);
|
||||
})
|
||||
.where((comment) => comment.body.trim().isNotEmpty)
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
String _mediaUrl(
|
||||
Map<String, dynamic> file, {
|
||||
required String mediaBaseUrl,
|
||||
}) {
|
||||
final path = (file['path'] ?? file['url'] ?? '').toString();
|
||||
if (path.isEmpty) return '';
|
||||
final uri = Uri.tryParse(path);
|
||||
if (uri != null && uri.hasScheme) return path;
|
||||
if (path.startsWith('/data/')) {
|
||||
return '$mediaBaseUrl$path';
|
||||
}
|
||||
if (path.startsWith('/')) {
|
||||
return '$mediaBaseUrl/data$path';
|
||||
}
|
||||
return '$mediaBaseUrl/data/$path';
|
||||
}
|
||||
|
||||
String? _iconUrl(String service, String artistId) {
|
||||
if (service.isEmpty || artistId.isEmpty) return null;
|
||||
final host = _isCoomer ? 'https://img.coomer.su' : 'https://img.kemono.su';
|
||||
return '$host/icons/$service/$artistId';
|
||||
}
|
||||
|
||||
String? _absoluteUrl(String value) {
|
||||
final trimmed = value.trim();
|
||||
if (trimmed.isEmpty) return null;
|
||||
final uri = Uri.tryParse(trimmed);
|
||||
if (uri != null && uri.hasScheme) return trimmed;
|
||||
if (trimmed.startsWith('/')) return '$baseUrl$trimmed';
|
||||
return '$baseUrl/$trimmed';
|
||||
}
|
||||
|
||||
List<String> _apiBaseCandidates() {
|
||||
final candidates = <String>[
|
||||
baseUrl,
|
||||
if (_isCoomer) ...[
|
||||
'https://coomer.st',
|
||||
'https://coomer.su',
|
||||
] else ...[
|
||||
'https://kemono.cr',
|
||||
'https://kemono.su',
|
||||
],
|
||||
];
|
||||
return candidates.map(_trimTrailingSlash).toSet().toList(growable: false);
|
||||
}
|
||||
|
||||
Future<Response<T>> _getFromApiBase<T>(
|
||||
String apiBase,
|
||||
String path, {
|
||||
Map<String, Object?> queryParameters = const {},
|
||||
}) {
|
||||
if (_trimTrailingSlash(apiBase) == _trimTrailingSlash(baseUrl)) {
|
||||
return _dio.get<T>(path, queryParameters: queryParameters);
|
||||
}
|
||||
final dio = Dio(
|
||||
BaseOptions(
|
||||
baseUrl: apiBase,
|
||||
connectTimeout: const Duration(seconds: 15),
|
||||
receiveTimeout: const Duration(seconds: 25),
|
||||
headers: const {
|
||||
'Accept': 'application/json',
|
||||
'User-Agent': 'Lunaris/1.1 Flutter local booru browser',
|
||||
},
|
||||
),
|
||||
);
|
||||
return dio.get<T>(path, queryParameters: queryParameters);
|
||||
}
|
||||
|
||||
String _trimTrailingSlash(String value) =>
|
||||
value.endsWith('/') ? value.substring(0, value.length - 1) : value;
|
||||
|
||||
String _shortError(Object? error) {
|
||||
if (error == null) return '';
|
||||
final message = error.toString();
|
||||
if (message.length <= 160) return message;
|
||||
return '${message.substring(0, 160)}...';
|
||||
}
|
||||
|
||||
String _fileType(String value) {
|
||||
final lower = value.toLowerCase().split('?').first;
|
||||
if (lower.endsWith('.webm') || lower.endsWith('.mp4')) return 'video';
|
||||
if (lower.endsWith('.gif')) return 'gif';
|
||||
if (lower.endsWith('.swf')) return 'swf';
|
||||
if (lower.endsWith('.png')) return 'png';
|
||||
if (lower.endsWith('.jpg') ||
|
||||
lower.endsWith('.jpeg') ||
|
||||
lower.endsWith('.webp')) {
|
||||
return 'photo';
|
||||
}
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
DateTime? _dateFromAny(dynamic value) {
|
||||
if (value == null) return null;
|
||||
if (value is num) {
|
||||
final raw = value.toInt();
|
||||
if (raw > 9999999999) return DateTime.fromMillisecondsSinceEpoch(raw);
|
||||
return DateTime.fromMillisecondsSinceEpoch(raw * 1000);
|
||||
}
|
||||
return DateTime.tryParse(value.toString());
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import 'custom_provider.dart';
|
||||
import 'danbooru_provider.dart';
|
||||
import 'e621_provider.dart';
|
||||
import 'gelbooru_provider.dart';
|
||||
import 'kemono_provider.dart';
|
||||
import 'moebooru_provider.dart';
|
||||
import 'realbooru_provider.dart';
|
||||
import 'rule34_provider.dart';
|
||||
@@ -72,6 +73,15 @@ class ProviderFactory {
|
||||
dioClient: client,
|
||||
queryParameters: queryParameters,
|
||||
);
|
||||
case 'kemono':
|
||||
case 'coomer':
|
||||
return KemonoProvider(
|
||||
id: config.id,
|
||||
name: config.name,
|
||||
baseUrl: config.baseUrl,
|
||||
dioClient: client,
|
||||
queryParameters: queryParameters,
|
||||
);
|
||||
default:
|
||||
return UnsupportedCustomProvider(
|
||||
id: config.id,
|
||||
|
||||
@@ -26,6 +26,36 @@ class ProviderManager {
|
||||
return _repository.getProviders(enabledOnly: enabledOnly);
|
||||
}
|
||||
|
||||
Future<Result<List<ContentProviderConfig>>> loadFeedConfigs({
|
||||
bool enabledOnly = true,
|
||||
}) async {
|
||||
final result = await loadConfigs(enabledOnly: enabledOnly);
|
||||
if (result is Error<List<ContentProviderConfig>>) {
|
||||
return Error(result.failure);
|
||||
}
|
||||
final configs = (result as Success<List<ContentProviderConfig>>)
|
||||
.data
|
||||
.where(_isFeedConfig)
|
||||
.toList()
|
||||
..sort((a, b) => a.priority.compareTo(b.priority));
|
||||
return Success(configs);
|
||||
}
|
||||
|
||||
Future<Result<List<ContentProviderConfig>>> loadArtistConfigs({
|
||||
bool enabledOnly = true,
|
||||
}) async {
|
||||
final result = await loadConfigs(enabledOnly: enabledOnly);
|
||||
if (result is Error<List<ContentProviderConfig>>) {
|
||||
return Error(result.failure);
|
||||
}
|
||||
final configs = (result as Success<List<ContentProviderConfig>>)
|
||||
.data
|
||||
.where(_isArtistConfig)
|
||||
.toList()
|
||||
..sort((a, b) => a.priority.compareTo(b.priority));
|
||||
return Success(configs);
|
||||
}
|
||||
|
||||
Future<Result<List<ContentProvider>>> activeProviders() async {
|
||||
final result = await loadConfigs();
|
||||
if (result is Error<List<ContentProviderConfig>>) {
|
||||
@@ -36,6 +66,28 @@ class ProviderManager {
|
||||
return Success(configs.map(_factory.create).toList());
|
||||
}
|
||||
|
||||
Future<Result<List<ContentProvider>>> activeFeedProviders() async {
|
||||
final result = await loadFeedConfigs();
|
||||
if (result is Error<List<ContentProviderConfig>>) {
|
||||
return Error(result.failure);
|
||||
}
|
||||
final configs = (result as Success<List<ContentProviderConfig>>).data;
|
||||
return Success(configs.map(_factory.create).toList());
|
||||
}
|
||||
|
||||
Future<Result<List<ArtistProvider>>> activeArtistProviders() async {
|
||||
final result = await loadArtistConfigs();
|
||||
if (result is Error<List<ContentProviderConfig>>) {
|
||||
return Error(result.failure);
|
||||
}
|
||||
final configs = (result as Success<List<ContentProviderConfig>>).data;
|
||||
final providers = configs
|
||||
.map(_factory.create)
|
||||
.whereType<ArtistProvider>()
|
||||
.toList(growable: false);
|
||||
return Success(providers);
|
||||
}
|
||||
|
||||
Future<Result<void>> enableProvider(String id, bool enabled) async {
|
||||
final result = await _repository.getProvider(id);
|
||||
return result.fold(
|
||||
@@ -87,7 +139,7 @@ class ProviderManager {
|
||||
String? providerId,
|
||||
TopPeriodFilter topPeriod = TopPeriodFilter.none,
|
||||
}) async {
|
||||
final providersResult = await activeProviders();
|
||||
final providersResult = await activeFeedProviders();
|
||||
if (providersResult is Error<List<ContentProvider>>) {
|
||||
return Error(providersResult.failure);
|
||||
}
|
||||
@@ -229,11 +281,38 @@ class ProviderManager {
|
||||
}
|
||||
}
|
||||
|
||||
Future<Result<Post>> enrichPostTags(Post post) async {
|
||||
final hasUsefulGroups = post.tagGroups.entries.any(
|
||||
(entry) => entry.key != 'general' && entry.value.isNotEmpty,
|
||||
);
|
||||
if (hasUsefulGroups || post.tags.isEmpty) return Success(post);
|
||||
|
||||
final providersResult = await activeFeedProviders();
|
||||
if (providersResult is Error<List<ContentProvider>>) {
|
||||
return Error(providersResult.failure);
|
||||
}
|
||||
final providers = (providersResult as Success<List<ContentProvider>>).data;
|
||||
final matches =
|
||||
providers.where((provider) => provider.id == post.providerId);
|
||||
if (matches.isEmpty || matches.first is! TagMetadataProvider) {
|
||||
return Success(post);
|
||||
}
|
||||
|
||||
try {
|
||||
final groups = await (matches.first as TagMetadataProvider)
|
||||
.categorizeTags(post.tags);
|
||||
if (groups.isEmpty) return Success(post);
|
||||
return Success(post.copyWith(tagGroups: groups));
|
||||
} catch (error) {
|
||||
return Success(post);
|
||||
}
|
||||
}
|
||||
|
||||
Future<Result<List<TagSuggestion>>> suggestTags(
|
||||
String query, {
|
||||
int limit = 20,
|
||||
}) async {
|
||||
final providersResult = await activeProviders();
|
||||
final providersResult = await activeFeedProviders();
|
||||
if (providersResult is Error<List<ContentProvider>>) {
|
||||
return Error(providersResult.failure);
|
||||
}
|
||||
@@ -279,4 +358,13 @@ class ProviderManager {
|
||||
);
|
||||
return results;
|
||||
}
|
||||
|
||||
static bool _isArtistConfig(ContentProviderConfig config) {
|
||||
final type = config.apiType.toLowerCase();
|
||||
return type == 'kemono' || type == 'coomer';
|
||||
}
|
||||
|
||||
static bool _isFeedConfig(ContentProviderConfig config) {
|
||||
return !_isArtistConfig(config);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,6 +143,34 @@ class ProviderRepository {
|
||||
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,
|
||||
updatedAt: now,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -151,9 +179,7 @@ class ProviderRepository {
|
||||
final existingProviders =
|
||||
await isar.providerConfigEntitys.where().findAll();
|
||||
for (final provider in existingProviders) {
|
||||
if (provider.providerId == 'kemono' ||
|
||||
provider.providerId == 'coomer' ||
|
||||
provider.providerId == 'paheal') {
|
||||
if (provider.providerId == 'paheal') {
|
||||
await isar.providerConfigEntitys.delete(provider.isarId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,25 @@ class DownloadManagerService {
|
||||
return task;
|
||||
}
|
||||
|
||||
Future<DownloadTask> startUrl({
|
||||
required String url,
|
||||
required String fileName,
|
||||
bool openAfterDownload = false,
|
||||
}) async {
|
||||
final id = 'url:$url:${DateTime.now().microsecondsSinceEpoch}';
|
||||
final task = DownloadTask(
|
||||
id: id,
|
||||
sourceUrl: url,
|
||||
fileName: fileName,
|
||||
progress: 0,
|
||||
status: DownloadTaskStatus.queued,
|
||||
openAfterDownload: openAfterDownload,
|
||||
);
|
||||
_set(task);
|
||||
unawaited(_run(task));
|
||||
return task;
|
||||
}
|
||||
|
||||
Future<void> retry(String taskId) async {
|
||||
final existing = _tasks[taskId];
|
||||
if (existing == null) return;
|
||||
@@ -51,18 +70,22 @@ class DownloadManagerService {
|
||||
if (_tasks[task.id]?.status == DownloadTaskStatus.canceled) return;
|
||||
_set(task.copyWith(status: DownloadTaskStatus.running));
|
||||
try {
|
||||
final saved = await _downloadService.downloadPost(
|
||||
task.post,
|
||||
onProgress: (received, total) {
|
||||
if (total <= 0) return;
|
||||
final current = _tasks[task.id];
|
||||
if (current == null ||
|
||||
current.status == DownloadTaskStatus.canceled) {
|
||||
return;
|
||||
}
|
||||
_set(current.copyWith(progress: received / total));
|
||||
},
|
||||
);
|
||||
final saved = task.sourceUrl == null
|
||||
? await _downloadService.downloadPost(
|
||||
task.post!,
|
||||
onProgress: (received, total) {
|
||||
_updateProgress(task.id, received, total);
|
||||
},
|
||||
)
|
||||
: await _downloadService.downloadUrl(
|
||||
task.sourceUrl!,
|
||||
fileName: task.fileName,
|
||||
mimeType: _mimeType(task.fileName),
|
||||
openAfterDownload: task.openAfterDownload,
|
||||
onProgress: (received, total) {
|
||||
_updateProgress(task.id, received, total);
|
||||
},
|
||||
);
|
||||
final current = _tasks[task.id];
|
||||
if (current == null || current.status == DownloadTaskStatus.canceled) {
|
||||
return;
|
||||
@@ -94,6 +117,15 @@ class DownloadManagerService {
|
||||
_controller.add(tasks);
|
||||
}
|
||||
|
||||
void _updateProgress(String taskId, int received, int total) {
|
||||
if (total <= 0) return;
|
||||
final current = _tasks[taskId];
|
||||
if (current == null || current.status == DownloadTaskStatus.canceled) {
|
||||
return;
|
||||
}
|
||||
_set(current.copyWith(progress: received / total));
|
||||
}
|
||||
|
||||
void _scheduleAutoRemove(String taskId, Duration delay) {
|
||||
Future<void>.delayed(delay, () {
|
||||
final current = _tasks[taskId];
|
||||
@@ -111,4 +143,18 @@ class DownloadManagerService {
|
||||
if (last.contains('.')) return last;
|
||||
return '${post.providerId}_${post.id}';
|
||||
}
|
||||
|
||||
String _mimeType(String fileName) {
|
||||
final lower = fileName.toLowerCase();
|
||||
if (lower.endsWith('.apk')) {
|
||||
return 'application/vnd.android.package-archive';
|
||||
}
|
||||
if (lower.endsWith('.exe')) {
|
||||
return 'application/vnd.microsoft.portable-executable';
|
||||
}
|
||||
if (lower.endsWith('.zip')) {
|
||||
return 'application/zip';
|
||||
}
|
||||
return 'application/octet-stream';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,54 @@ class DownloadService {
|
||||
return location.path;
|
||||
}
|
||||
|
||||
Future<String?> downloadUrl(
|
||||
String url, {
|
||||
required String fileName,
|
||||
String mimeType = 'application/octet-stream',
|
||||
bool openAfterDownload = false,
|
||||
void Function(int received, int total)? onProgress,
|
||||
}) async {
|
||||
if (Platform.isAndroid) {
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final tempPath = p.join(tempDir.path, fileName);
|
||||
await _dio.download(url, tempPath, onReceiveProgress: onProgress);
|
||||
if (openAfterDownload) {
|
||||
await openFile(tempPath, mimeType: mimeType);
|
||||
return tempPath;
|
||||
}
|
||||
final saved = await _channel.invokeMethod<String>('saveToDownloads', {
|
||||
'path': tempPath,
|
||||
'fileName': fileName,
|
||||
'mimeType': mimeType,
|
||||
});
|
||||
return saved ?? fileName;
|
||||
}
|
||||
|
||||
final downloads =
|
||||
await getDownloadsDirectory() ?? await getTemporaryDirectory();
|
||||
final path = p.join(downloads.path, fileName);
|
||||
await _dio.download(url, path, onReceiveProgress: onProgress);
|
||||
if (openAfterDownload) await openFile(path, mimeType: mimeType);
|
||||
return path;
|
||||
}
|
||||
|
||||
Future<void> openFile(String path, {String? mimeType}) async {
|
||||
if (Platform.isAndroid) {
|
||||
await _channel.invokeMethod<void>('openFile', {
|
||||
'path': path,
|
||||
'mimeType': mimeType ?? _mimeType(path, ''),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (Platform.isWindows) {
|
||||
await Process.start('explorer.exe', [path]);
|
||||
return;
|
||||
}
|
||||
if (Platform.isLinux) {
|
||||
await Process.start('xdg-open', [path]);
|
||||
}
|
||||
}
|
||||
|
||||
String? _downloadUrl(Post post) {
|
||||
return MediaUrlSelector.download(post);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'dart:io';
|
||||
|
||||
import '../../app/app_version.dart';
|
||||
import '../../core/errors/failure.dart';
|
||||
@@ -71,12 +72,14 @@ class UpdateService {
|
||||
final assets = (json['assets'] as List?) ?? const [];
|
||||
String? apkUrl;
|
||||
String? installerUrl;
|
||||
String? portableUrl;
|
||||
for (final item in assets.whereType<Map>()) {
|
||||
final name = (item['name'] ?? '').toString().toLowerCase();
|
||||
final url = (item['browser_download_url'] ?? '').toString();
|
||||
if (url.isEmpty) continue;
|
||||
if (name.endsWith('.apk')) apkUrl = url;
|
||||
if (name.endsWith('.exe')) installerUrl = url;
|
||||
if (name.endsWith('.zip')) portableUrl = url;
|
||||
}
|
||||
return AppUpdateInfo(
|
||||
version: _versionFromTag(tag),
|
||||
@@ -87,9 +90,30 @@ class UpdateService {
|
||||
publishedAt: DateTime.tryParse((json['published_at'] ?? '').toString()),
|
||||
apkUrl: apkUrl,
|
||||
windowsInstallerUrl: installerUrl,
|
||||
portableZipUrl: portableUrl,
|
||||
);
|
||||
}
|
||||
|
||||
String? assetUrlForCurrentPlatform(AppUpdateInfo info) {
|
||||
if (Platform.isAndroid) {
|
||||
return info.apkUrl;
|
||||
}
|
||||
if (Platform.isWindows) {
|
||||
return info.windowsInstallerUrl ?? info.portableZipUrl;
|
||||
}
|
||||
return info.portableZipUrl;
|
||||
}
|
||||
|
||||
String assetFileName(AppUpdateInfo info, String url) {
|
||||
final parsed = Uri.tryParse(url);
|
||||
final fromUrl =
|
||||
parsed?.pathSegments.isEmpty ?? true ? '' : parsed!.pathSegments.last;
|
||||
if (fromUrl.contains('.')) return fromUrl;
|
||||
if (Platform.isAndroid) return 'Lunaris-v${info.version}.apk';
|
||||
if (Platform.isWindows) return 'LunarisSetup-v${info.version}.exe';
|
||||
return 'LunarisPortable-v${info.version}.zip';
|
||||
}
|
||||
|
||||
static String _versionFromTag(String tag) {
|
||||
final match = RegExp(r'v?(\d+\.\d+\.\d+)').firstMatch(tag);
|
||||
return match?.group(1) ?? tag.replaceFirst(RegExp(r'^v'), '');
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
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 '../../../shared/widgets/post_masonry_grid.dart';
|
||||
import '../../favorites/presentation/favorites_controller.dart';
|
||||
|
||||
final artistPostsProvider =
|
||||
FutureProvider.family<List<Post>, ArtistWorkQuery>((ref, query) async {
|
||||
final providers =
|
||||
await ref.watch(providerManagerProvider).activeArtistProviders();
|
||||
if (providers is! Success<List<ArtistProvider>>) return const [];
|
||||
final provider = providers.data.firstWhere(
|
||||
(item) => (item as ContentProvider).id == query.providerId,
|
||||
);
|
||||
final posts = await provider.getArtistPosts(query: query, limit: 50);
|
||||
await ref.read(cacheServiceProvider).cachePosts(posts);
|
||||
return posts;
|
||||
});
|
||||
|
||||
class ArtistPostsScreen extends ConsumerWidget {
|
||||
const ArtistPostsScreen({
|
||||
required this.providerId,
|
||||
required this.service,
|
||||
required this.artistId,
|
||||
required this.artistName,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final String providerId;
|
||||
final String service;
|
||||
final String artistId;
|
||||
final String artistName;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final query = ArtistWorkQuery(
|
||||
providerId: providerId,
|
||||
service: service,
|
||||
artistId: artistId,
|
||||
artistName: artistName,
|
||||
);
|
||||
final posts = ref.watch(artistPostsProvider(query));
|
||||
final settings =
|
||||
ref.watch(appSettingsProvider).value ?? AppSettings.defaults;
|
||||
final favoriteKeys = ref.watch(favoriteKeysProvider).value ?? <String>{};
|
||||
final viewedKeys = ref.watch(viewedKeysProvider).value ?? <String>{};
|
||||
return AdaptiveScaffold(
|
||||
title: artistName,
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: 'Refresh',
|
||||
onPressed: () => ref.invalidate(artistPostsProvider(query)),
|
||||
icon: const Icon(Icons.refresh_rounded),
|
||||
),
|
||||
],
|
||||
body: posts.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (error, _) => ErrorView(
|
||||
message: _friendlyArtistPostError(error),
|
||||
onRetry: () => ref.invalidate(artistPostsProvider(query)),
|
||||
),
|
||||
data: (items) => items.isEmpty
|
||||
? const EmptyView(
|
||||
title: 'No works',
|
||||
message: 'This artist has no visible media yet.',
|
||||
)
|
||||
: PostMasonryGrid(
|
||||
posts: items,
|
||||
columns: Responsive.columnsFor(
|
||||
context,
|
||||
mobileColumns: settings.mobileColumns,
|
||||
desktopColumns: settings.desktopColumns,
|
||||
),
|
||||
blurExplicit: settings.blurExplicitContent,
|
||||
showBadges: settings.showPostBadges,
|
||||
nsfwEnabled: settings.nsfwEnabled,
|
||||
favoriteKeys: favoriteKeys,
|
||||
viewedKeys: viewedKeys,
|
||||
onOpen: (post) => context.push(
|
||||
'/post/${post.providerId}/${post.id}',
|
||||
extra: post,
|
||||
),
|
||||
onFavorite: (post) async {
|
||||
if (favoriteKeys.contains(post.cacheKey)) {
|
||||
await ref
|
||||
.read(favoriteServiceProvider)
|
||||
.removeFavorite(post.id, post.providerId);
|
||||
} else {
|
||||
await ref.read(favoriteServiceProvider).addFavorite(post);
|
||||
}
|
||||
ref.invalidate(favoriteKeysProvider);
|
||||
ref.invalidate(favoritesControllerProvider);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _friendlyArtistPostError(Object error) {
|
||||
final message = error.toString();
|
||||
if (message.contains('HandshakeException') ||
|
||||
message.contains('artist works are unavailable')) {
|
||||
return 'Artist works are unavailable from this provider right now. Try again later or choose another artist/provider.';
|
||||
}
|
||||
return message;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,435 @@
|
||||
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/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';
|
||||
|
||||
final artistProviderConfigsProvider =
|
||||
FutureProvider<List<ContentProviderConfig>>((ref) async {
|
||||
final result = await ref
|
||||
.watch(providerManagerProvider)
|
||||
.loadArtistConfigs(enabledOnly: true);
|
||||
return result is Success<List<ContentProviderConfig>>
|
||||
? result.data
|
||||
: const [];
|
||||
});
|
||||
|
||||
class ArtistsScreen extends ConsumerStatefulWidget {
|
||||
const ArtistsScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<ArtistsScreen> createState() => _ArtistsScreenState();
|
||||
}
|
||||
|
||||
class _ArtistsScreenState extends ConsumerState<ArtistsScreen> {
|
||||
final _search = TextEditingController();
|
||||
final _scroll = ScrollController();
|
||||
String? _providerId;
|
||||
var _page = 1;
|
||||
var _loading = false;
|
||||
var _hasMore = true;
|
||||
Object? _error;
|
||||
List<ArtistProfile> _artists = const [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_scroll.addListener(() {
|
||||
if (_scroll.position.extentAfter < 500) _loadMore();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_search.dispose();
|
||||
_scroll.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final configs = ref.watch(artistProviderConfigsProvider);
|
||||
return AdaptiveScaffold(
|
||||
title: 'Artists',
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: 'Refresh',
|
||||
onPressed: () => _refresh(configs.valueOrNull ?? const []),
|
||||
icon: const Icon(Icons.refresh_rounded),
|
||||
),
|
||||
],
|
||||
body: configs.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (error, _) => ErrorView(message: error.toString()),
|
||||
data: (items) {
|
||||
if (items.isEmpty) {
|
||||
return const EmptyView(
|
||||
title: 'No artist providers',
|
||||
message: 'Enable Kemono or Coomer in Providers.',
|
||||
);
|
||||
}
|
||||
_providerId ??= items.first.id;
|
||||
if (_artists.isEmpty && !_loading && _error == null) {
|
||||
WidgetsBinding.instance
|
||||
.addPostFrameCallback((_) => _refresh(items));
|
||||
}
|
||||
return Column(
|
||||
children: [
|
||||
_ArtistsHeader(
|
||||
searchController: _search,
|
||||
providers: items,
|
||||
selectedProviderId: _providerId,
|
||||
onProviderChanged: (value) {
|
||||
setState(() => _providerId = value);
|
||||
_refresh(items);
|
||||
},
|
||||
onSearch: () => _refresh(items),
|
||||
),
|
||||
Expanded(
|
||||
child: _error != null
|
||||
? ErrorView(
|
||||
message: _friendlyArtistError(_error),
|
||||
onRetry: () => _refresh(items),
|
||||
)
|
||||
: _artists.isEmpty && !_loading
|
||||
? const EmptyView(
|
||||
title: 'No artists',
|
||||
message: 'Try another name or provider.',
|
||||
)
|
||||
: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final columns = Responsive.isMobile(context)
|
||||
? 1
|
||||
: constraints.maxWidth >= 1100
|
||||
? 3
|
||||
: constraints.maxWidth >= 760
|
||||
? 2
|
||||
: 1;
|
||||
return GridView.builder(
|
||||
controller: _scroll,
|
||||
padding:
|
||||
const EdgeInsets.fromLTRB(16, 10, 16, 18),
|
||||
gridDelegate:
|
||||
SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: columns,
|
||||
crossAxisSpacing: 12,
|
||||
mainAxisSpacing: 12,
|
||||
mainAxisExtent:
|
||||
Responsive.isMobile(context) ? 88 : 104,
|
||||
),
|
||||
itemCount: _artists.length + (_loading ? 1 : 0),
|
||||
itemBuilder: (context, index) {
|
||||
if (index >= _artists.length) {
|
||||
return const _ArtistSkeletonCard();
|
||||
}
|
||||
final artist = _artists[index];
|
||||
return _ArtistCard(
|
||||
artist: artist,
|
||||
onTap: () => context.push(
|
||||
'/artists/${artist.providerId}/${artist.service}/${artist.id}?name=${Uri.encodeComponent(artist.displayName)}',
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _friendlyArtistError(Object? error) {
|
||||
final message = error.toString();
|
||||
if (message.contains('HandshakeException') ||
|
||||
message.contains('artist works are unavailable')) {
|
||||
return 'Artist API is unavailable right now. Try refresh or another provider.';
|
||||
}
|
||||
return message;
|
||||
}
|
||||
|
||||
Future<void> _refresh(List<ContentProviderConfig> configs) async {
|
||||
if (configs.isEmpty) return;
|
||||
setState(() {
|
||||
_page = 1;
|
||||
_hasMore = true;
|
||||
_artists = const [];
|
||||
_error = null;
|
||||
});
|
||||
await _loadMore();
|
||||
}
|
||||
|
||||
Future<void> _loadMore() async {
|
||||
if (_loading || !_hasMore || _providerId == null) return;
|
||||
setState(() => _loading = true);
|
||||
try {
|
||||
final manager = ref.read(providerManagerProvider);
|
||||
final providers = await manager.activeArtistProviders();
|
||||
if (providers is! Success<List<ArtistProvider>>) return;
|
||||
final provider = providers.data.firstWhere(
|
||||
(item) => (item as ContentProvider).id == _providerId,
|
||||
);
|
||||
final next = await provider.searchArtists(
|
||||
_search.text.trim(),
|
||||
page: _page,
|
||||
limit: 30,
|
||||
);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_artists = [..._artists, ...next];
|
||||
_page++;
|
||||
_hasMore = next.isNotEmpty;
|
||||
});
|
||||
} catch (error) {
|
||||
if (!mounted) return;
|
||||
setState(() => _error = error);
|
||||
} finally {
|
||||
if (mounted) setState(() => _loading = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _ArtistsHeader extends StatelessWidget {
|
||||
const _ArtistsHeader({
|
||||
required this.searchController,
|
||||
required this.providers,
|
||||
required this.selectedProviderId,
|
||||
required this.onProviderChanged,
|
||||
required this.onSearch,
|
||||
});
|
||||
|
||||
final TextEditingController searchController;
|
||||
final List<ContentProviderConfig> providers;
|
||||
final String? selectedProviderId;
|
||||
final ValueChanged<String?> onProviderChanged;
|
||||
final VoidCallback onSearch;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 6),
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: scheme.surfaceContainerHighest.withValues(alpha: 0.72),
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
border: Border.all(color: scheme.outlineVariant),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(10),
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: searchController,
|
||||
textInputAction: TextInputAction.search,
|
||||
decoration: const InputDecoration(
|
||||
prefixIcon: Icon(Icons.search_rounded),
|
||||
hintText: 'Search artists',
|
||||
border: InputBorder.none,
|
||||
filled: false,
|
||||
),
|
||||
onSubmitted: (_) => onSearch(),
|
||||
),
|
||||
),
|
||||
IconButton.filledTonal(
|
||||
tooltip: 'Search',
|
||||
onPressed: onSearch,
|
||||
icon: const Icon(Icons.arrow_forward_rounded),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
height: 36,
|
||||
child: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: providers.length,
|
||||
separatorBuilder: (_, __) => const SizedBox(width: 8),
|
||||
itemBuilder: (context, index) {
|
||||
final provider = providers[index];
|
||||
return ChoiceChip(
|
||||
selected: provider.id == selectedProviderId,
|
||||
avatar: const Icon(Icons.person_search_rounded, size: 16),
|
||||
label: Text(provider.name),
|
||||
onSelected: (_) => onProviderChanged(provider.id),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ArtistCard extends StatelessWidget {
|
||||
const _ArtistCard({required this.artist, required this.onTap});
|
||||
|
||||
final ArtistProfile artist;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
return Material(
|
||||
color: scheme.surfaceContainerHighest.withValues(alpha: 0.78),
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Row(
|
||||
children: [
|
||||
_ArtistAvatar(artist: artist),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
artist.displayName,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 4,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
_MiniBadge(artist.service),
|
||||
if (artist.postCount != null)
|
||||
_MiniBadge('${artist.postCount} posts'),
|
||||
],
|
||||
),
|
||||
if (artist.updatedAt != null) ...[
|
||||
const SizedBox(height: 5),
|
||||
Text(
|
||||
'Updated ${artist.updatedAt!.toLocal().toString().split('.').first}',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const Icon(Icons.chevron_right_rounded),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ArtistAvatar extends StatelessWidget {
|
||||
const _ArtistAvatar({required this.artist});
|
||||
|
||||
final ArtistProfile artist;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final name = artist.displayName.trim();
|
||||
final initials = name.isEmpty ? '?' : name.characters.first.toUpperCase();
|
||||
final fallback = DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
Theme.of(context).colorScheme.primaryContainer,
|
||||
Theme.of(context).colorScheme.tertiaryContainer,
|
||||
],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
initials,
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
return SizedBox(
|
||||
width: 56,
|
||||
height: 56,
|
||||
child: ClipOval(
|
||||
child: artist.avatarUrl == null
|
||||
? fallback
|
||||
: CachedNetworkImage(
|
||||
imageUrl: artist.avatarUrl!,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (_, __) => fallback,
|
||||
errorWidget: (_, __, ___) => fallback,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MiniBadge extends StatelessWidget {
|
||||
const _MiniBadge(this.label);
|
||||
|
||||
final String label;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
return DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: scheme.surface.withValues(alpha: 0.72),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
border: Border.all(color: scheme.outlineVariant),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
child: Text(
|
||||
label,
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ArtistSkeletonCard extends StatelessWidget {
|
||||
const _ArtistSkeletonCard();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
return DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: scheme.surfaceContainerHighest.withValues(alpha: 0.5),
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
),
|
||||
child: const Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -242,8 +242,9 @@ class FeedController extends AsyncNotifier<FeedState> {
|
||||
}
|
||||
|
||||
Future<List<ContentProviderConfig>> _loadProviders() async {
|
||||
final result =
|
||||
await ref.read(providerManagerProvider).loadConfigs(enabledOnly: true);
|
||||
final result = await ref
|
||||
.read(providerManagerProvider)
|
||||
.loadFeedConfigs(enabledOnly: true);
|
||||
return result is Success<List<ContentProviderConfig>>
|
||||
? result.data
|
||||
: const [];
|
||||
|
||||
@@ -139,6 +139,7 @@ class _FeedScreenState extends ConsumerState<FeedScreen> {
|
||||
},
|
||||
child: AdaptiveScaffold(
|
||||
title: 'Feed',
|
||||
titleWidget: const _FeedTitle(),
|
||||
body: feed.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (error, _) => ErrorView(
|
||||
@@ -431,6 +432,34 @@ class _RefreshIntent extends Intent {
|
||||
const _RefreshIntent();
|
||||
}
|
||||
|
||||
class _FeedTitle extends StatelessWidget {
|
||||
const _FeedTitle();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
'Lunaris',
|
||||
style: theme.textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
'Feed',
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ClearSelectionIntent extends Intent {
|
||||
const _ClearSelectionIntent();
|
||||
}
|
||||
|
||||
@@ -5,15 +5,31 @@ import '../../../core/utils/result.dart';
|
||||
|
||||
final postDetailsControllerProvider =
|
||||
FutureProvider.family<Post?, PostDetailsArgs>((ref, args) async {
|
||||
if (args.initialPost != null) return args.initialPost;
|
||||
Future<Post?> enrich(Post? post) async {
|
||||
if (post == null) return null;
|
||||
final shouldEnrich = post.tagGroups.isEmpty ||
|
||||
(post.tagGroups.length == 1 && post.tagGroups.containsKey('general'));
|
||||
if (!shouldEnrich) return post;
|
||||
final enriched =
|
||||
await ref.watch(providerManagerProvider).enrichPostTags(post);
|
||||
if (enriched is Success<Post>) {
|
||||
await ref.watch(cacheServiceProvider).cachePosts([enriched.data]);
|
||||
return enriched.data;
|
||||
}
|
||||
return post;
|
||||
}
|
||||
|
||||
if (args.initialPost != null) return enrich(args.initialPost);
|
||||
final cached = await ref
|
||||
.watch(postRepositoryProvider)
|
||||
.getCachedPost(args.postId, args.providerId);
|
||||
if (cached is Success<Post?> && cached.data != null) return cached.data;
|
||||
if (cached is Success<Post?> && cached.data != null) {
|
||||
return enrich(cached.data);
|
||||
}
|
||||
final remote = await ref
|
||||
.watch(providerManagerProvider)
|
||||
.getPost(args.providerId, args.postId);
|
||||
return remote is Success<Post?> ? remote.data : null;
|
||||
return remote is Success<Post?> ? enrich(remote.data) : null;
|
||||
});
|
||||
|
||||
class PostDetailsArgs {
|
||||
|
||||
@@ -400,7 +400,9 @@ class _PostMediaViewerState extends State<PostMediaViewer>
|
||||
coverVideo: _coverVideo,
|
||||
errorMessage: _videoError,
|
||||
onRetry: _retryVideo,
|
||||
onChanged: (_) => _emitPlaybackPreferences(),
|
||||
onChanged: (snapshot) {
|
||||
unawaited(_applyPlaybackSnapshot(snapshot));
|
||||
},
|
||||
),
|
||||
transitionsBuilder: (_, animation, __, child) => FadeTransition(
|
||||
opacity: animation,
|
||||
@@ -410,22 +412,26 @@ class _PostMediaViewerState extends State<PostMediaViewer>
|
||||
);
|
||||
if (mounted) setState(() => _inFullscreen = false);
|
||||
if (result != null) {
|
||||
_playbackMemory[widget.post.cacheKey] = result;
|
||||
setState(() {
|
||||
_muted = result.muted;
|
||||
_loopVideo = result.loopVideo;
|
||||
_coverVideo = result.coverVideo;
|
||||
_halfVolume = result.halfVolume;
|
||||
});
|
||||
await _applyVolume();
|
||||
await player.setPlaylistMode(
|
||||
_loopVideo ? PlaylistMode.single : PlaylistMode.none,
|
||||
);
|
||||
_emitPlaybackPreferences();
|
||||
await _applyPlaybackSnapshot(result);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _applyPlaybackSnapshot(VideoPlaybackSnapshot snapshot) async {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_muted = snapshot.muted;
|
||||
_loopVideo = snapshot.loopVideo;
|
||||
_coverVideo = snapshot.coverVideo;
|
||||
_halfVolume = snapshot.halfVolume;
|
||||
});
|
||||
await _applyVolume();
|
||||
await _player?.setPlaylistMode(
|
||||
_loopVideo ? PlaylistMode.single : PlaylistMode.none,
|
||||
);
|
||||
_emitPlaybackPreferences();
|
||||
}
|
||||
|
||||
VideoPlaybackSnapshot _snapshot() {
|
||||
final player = _player;
|
||||
return VideoPlaybackSnapshot(
|
||||
|
||||
@@ -1,34 +1,42 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../../backend/backend.dart';
|
||||
import '../../../../shared/widgets/tag_chip.dart';
|
||||
|
||||
class PostTagsPanel extends StatelessWidget {
|
||||
class PostTagsPanel extends StatefulWidget {
|
||||
const PostTagsPanel({required this.post, super.key});
|
||||
|
||||
final Post post;
|
||||
|
||||
@override
|
||||
State<PostTagsPanel> createState() => _PostTagsPanelState();
|
||||
}
|
||||
|
||||
class _PostTagsPanelState extends State<PostTagsPanel> {
|
||||
final _expandedGroups = <String>{};
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final groups = _groups(post);
|
||||
final groups = _groups(widget.post);
|
||||
if (groups.isEmpty) return const SizedBox.shrink();
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
for (final entry in groups.entries) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8, bottom: 6),
|
||||
child: Text(
|
||||
_label(entry.key),
|
||||
style: Theme.of(context).textTheme.labelLarge,
|
||||
),
|
||||
),
|
||||
_HorizontalTagRow(
|
||||
tags: entry.value.take(120).toList(),
|
||||
for (final entry in groups.entries)
|
||||
_TagGroupBlock(
|
||||
label: _label(entry.key),
|
||||
tags: entry.value,
|
||||
expanded: _expandedGroups.contains(entry.key),
|
||||
onToggleExpanded: () {
|
||||
setState(() {
|
||||
if (!_expandedGroups.add(entry.key)) {
|
||||
_expandedGroups.remove(entry.key);
|
||||
}
|
||||
});
|
||||
},
|
||||
onTap: (tag) => context.go('/?q=${Uri.encodeQueryComponent(tag)}'),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -48,11 +56,13 @@ class PostTagsPanel extends StatelessWidget {
|
||||
if (tags != null && tags.isNotEmpty) ordered[key] = tags;
|
||||
}
|
||||
for (final entry in post.tagGroups.entries) {
|
||||
ordered.putIfAbsent(entry.key, () => entry.value);
|
||||
if (entry.value.isNotEmpty) {
|
||||
ordered.putIfAbsent(entry.key, () => entry.value);
|
||||
}
|
||||
}
|
||||
return ordered;
|
||||
}
|
||||
return {'general': post.tags};
|
||||
return post.tags.isEmpty ? const {} : {'general': post.tags};
|
||||
}
|
||||
|
||||
String _label(String key) {
|
||||
@@ -68,51 +78,71 @@ class PostTagsPanel extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _HorizontalTagRow extends StatefulWidget {
|
||||
const _HorizontalTagRow({
|
||||
class _TagGroupBlock extends StatelessWidget {
|
||||
const _TagGroupBlock({
|
||||
required this.label,
|
||||
required this.tags,
|
||||
required this.expanded,
|
||||
required this.onToggleExpanded,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
static const _collapsedLimit = 36;
|
||||
|
||||
final String label;
|
||||
final List<String> tags;
|
||||
final bool expanded;
|
||||
final VoidCallback onToggleExpanded;
|
||||
final ValueChanged<String> onTap;
|
||||
|
||||
@override
|
||||
State<_HorizontalTagRow> createState() => _HorizontalTagRowState();
|
||||
}
|
||||
|
||||
class _HorizontalTagRowState extends State<_HorizontalTagRow> {
|
||||
final _controller = ScrollController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
height: 36,
|
||||
child: Listener(
|
||||
onPointerSignal: (event) {
|
||||
if (event is! PointerScrollEvent || !_controller.hasClients) return;
|
||||
final target = (_controller.offset + event.scrollDelta.dy)
|
||||
.clamp(0.0, _controller.position.maxScrollExtent)
|
||||
.toDouble();
|
||||
_controller.jumpTo(target);
|
||||
},
|
||||
child: ListView.separated(
|
||||
controller: _controller,
|
||||
scrollDirection: Axis.horizontal,
|
||||
primary: false,
|
||||
itemCount: widget.tags.length,
|
||||
separatorBuilder: (_, __) => const SizedBox(width: 8),
|
||||
itemBuilder: (context, index) => TagChip(
|
||||
tag: widget.tags[index],
|
||||
onTap: () => widget.onTap(widget.tags[index]),
|
||||
final visibleTags = expanded || tags.length <= _collapsedLimit
|
||||
? tags
|
||||
: tags.take(_collapsedLimit).toList(growable: false);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(label, style: Theme.of(context).textTheme.labelLarge),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'${tags.length}',
|
||||
style: Theme.of(context).textTheme.labelMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 7),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
for (final tag in visibleTags)
|
||||
TagChip(
|
||||
tag: tag,
|
||||
onTap: () => onTap(tag),
|
||||
),
|
||||
if (tags.length > _collapsedLimit)
|
||||
ActionChip(
|
||||
avatar: Icon(
|
||||
expanded
|
||||
? Icons.keyboard_arrow_up_rounded
|
||||
: Icons.keyboard_arrow_down_rounded,
|
||||
size: 18,
|
||||
),
|
||||
label: Text(
|
||||
expanded ? 'Collapse' : '+${tags.length - _collapsedLimit}',
|
||||
),
|
||||
onPressed: onToggleExpanded,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -95,6 +95,8 @@ class _ProviderFormScreenState extends ConsumerState<ProviderFormScreen> {
|
||||
DropdownMenuItem(
|
||||
value: 'moebooru', child: Text('moebooru-compatible')),
|
||||
DropdownMenuItem(value: 'e621', child: Text('e621/e926')),
|
||||
DropdownMenuItem(value: 'kemono', child: Text('Kemono artists')),
|
||||
DropdownMenuItem(value: 'coomer', child: Text('Coomer artists')),
|
||||
DropdownMenuItem(value: 'custom', child: Text('custom')),
|
||||
],
|
||||
onChanged: (value) =>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../app/app.dart';
|
||||
import '../../../backend/backend.dart';
|
||||
import '../../../core/utils/result.dart';
|
||||
import '../../feed/presentation/feed_controller.dart';
|
||||
|
||||
final providersControllerProvider =
|
||||
AsyncNotifierProvider<ProvidersController, List<ContentProviderConfig>>(
|
||||
@@ -14,17 +16,44 @@ class ProvidersController extends AsyncNotifier<List<ContentProviderConfig>> {
|
||||
|
||||
Future<void> toggle(String id, bool enabled) async {
|
||||
await ref.read(providerManagerProvider).enableProvider(id, enabled);
|
||||
if (!enabled) {
|
||||
final settingsResult =
|
||||
await ref.read(settingsServiceProvider).getSettings();
|
||||
if (settingsResult is Success<AppSettings>) {
|
||||
final settings = settingsResult.data;
|
||||
await ref.read(settingsServiceProvider).updateSettings(
|
||||
settings.copyWith(
|
||||
selectedFeedProviderIds: settings.selectedFeedProviderIds
|
||||
.where((providerId) => providerId != id)
|
||||
.toList(growable: false),
|
||||
lastFeedProviderIds: settings.lastFeedProviderIds
|
||||
.where((providerId) => providerId != id)
|
||||
.toList(growable: false),
|
||||
enabledProviderIds: settings.enabledProviderIds
|
||||
.where((providerId) => providerId != id)
|
||||
.toList(growable: false),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
state = AsyncData(await _load());
|
||||
ref.invalidate(feedControllerProvider);
|
||||
ref.invalidate(appSettingsProvider);
|
||||
ref.invalidate(providerDiagnosticsProvider);
|
||||
}
|
||||
|
||||
Future<void> save(ContentProviderConfig config) async {
|
||||
await ref.read(providerManagerProvider).addCustomProvider(config);
|
||||
state = AsyncData(await _load());
|
||||
ref.invalidate(feedControllerProvider);
|
||||
ref.invalidate(appSettingsProvider);
|
||||
}
|
||||
|
||||
Future<void> delete(String id) async {
|
||||
await ref.read(providerManagerProvider).deleteProvider(id);
|
||||
state = AsyncData(await _load());
|
||||
ref.invalidate(feedControllerProvider);
|
||||
ref.invalidate(appSettingsProvider);
|
||||
}
|
||||
|
||||
Future<List<ContentProviderConfig>> _load() async {
|
||||
|
||||
@@ -435,14 +435,11 @@ class _SettingsContent extends ConsumerWidget {
|
||||
),
|
||||
FilledButton.icon(
|
||||
onPressed: () async {
|
||||
final url = Uri.tryParse(info.htmlUrl);
|
||||
if (url != null) {
|
||||
await launchUrl(url, mode: LaunchMode.externalApplication);
|
||||
}
|
||||
await _downloadUpdate(ref, info);
|
||||
if (context.mounted) Navigator.pop(context);
|
||||
},
|
||||
icon: const Icon(Icons.open_in_new_rounded),
|
||||
label: const Text('Update'),
|
||||
icon: const Icon(Icons.download_rounded),
|
||||
label: const Text('Download & open'),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -450,6 +447,23 @@ class _SettingsContent extends ConsumerWidget {
|
||||
ref.invalidate(settingsControllerProvider);
|
||||
}
|
||||
|
||||
Future<void> _downloadUpdate(WidgetRef ref, AppUpdateInfo info) async {
|
||||
final updateService = ref.read(updateServiceProvider);
|
||||
final assetUrl = updateService.assetUrlForCurrentPlatform(info);
|
||||
if (assetUrl == null) {
|
||||
final url = Uri.tryParse(info.htmlUrl);
|
||||
if (url != null) {
|
||||
await launchUrl(url, mode: LaunchMode.externalApplication);
|
||||
}
|
||||
return;
|
||||
}
|
||||
await ref.read(downloadManagerServiceProvider).startUrl(
|
||||
url: assetUrl,
|
||||
fileName: updateService.assetFileName(info, assetUrl),
|
||||
openAfterDownload: true,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _showChangelog(BuildContext context) {
|
||||
return showDialog<void>(
|
||||
context: context,
|
||||
|
||||
@@ -6,12 +6,14 @@ class AdaptiveScaffold extends StatelessWidget {
|
||||
const AdaptiveScaffold({
|
||||
required this.title,
|
||||
required this.body,
|
||||
this.titleWidget,
|
||||
this.actions = const [],
|
||||
this.floatingActionButton,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final Widget? titleWidget;
|
||||
final Widget body;
|
||||
final List<Widget> actions;
|
||||
final Widget? floatingActionButton;
|
||||
@@ -21,7 +23,7 @@ class AdaptiveScaffold extends StatelessWidget {
|
||||
if (Responsive.isMobile(context)) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(title),
|
||||
title: titleWidget ?? Text(title),
|
||||
actions: actions,
|
||||
toolbarHeight: 52,
|
||||
titleTextStyle: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
@@ -33,7 +35,7 @@ class AdaptiveScaffold extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(title), actions: actions),
|
||||
appBar: AppBar(title: titleWidget ?? Text(title), actions: actions),
|
||||
body: body,
|
||||
floatingActionButton: floatingActionButton,
|
||||
);
|
||||
|
||||
@@ -3,7 +3,6 @@ import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../../app/motion.dart';
|
||||
import '../../backend/backend.dart';
|
||||
|
||||
class AppSearchBar extends StatefulWidget {
|
||||
@@ -253,7 +252,6 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
|
||||
void _commitDraft(String value) {
|
||||
final additions = _parseTags(value);
|
||||
if (additions.isEmpty) return;
|
||||
final keepInputVisible = _isInputNearEnd();
|
||||
setState(() {
|
||||
for (final tag in additions) {
|
||||
if (!_tags.contains(tag)) _tags.add(tag);
|
||||
@@ -261,7 +259,6 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
|
||||
_controller.clear();
|
||||
});
|
||||
_notifyChanged();
|
||||
if (keepInputVisible) _scrollTagsToEnd();
|
||||
}
|
||||
|
||||
void _submit() {
|
||||
@@ -301,7 +298,6 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
|
||||
|
||||
void _applySuggestion(String suggestion) {
|
||||
final draft = _controller.text.trim();
|
||||
final keepInputVisible = _isInputNearEnd();
|
||||
setState(() {
|
||||
if (draft.isNotEmpty) {
|
||||
final draftTags = _parseTags(draft);
|
||||
@@ -313,7 +309,6 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
|
||||
_notifyChanged();
|
||||
widget.onSuggestionApplied?.call(_query);
|
||||
_focusNode.requestFocus();
|
||||
if (keepInputVisible) _scrollTagsToEnd();
|
||||
}
|
||||
|
||||
void _notifyChangedDebounced() {
|
||||
@@ -341,22 +336,6 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
|
||||
final all = [..._tags, if (draft.isNotEmpty) draft];
|
||||
return all.join(' ');
|
||||
}
|
||||
|
||||
void _scrollTagsToEnd() {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted || !_tagScrollController.hasClients) return;
|
||||
_tagScrollController.animateTo(
|
||||
_tagScrollController.position.maxScrollExtent,
|
||||
duration: AppMotion.duration(context, 120),
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
bool _isInputNearEnd() {
|
||||
if (!_tagScrollController.hasClients) return true;
|
||||
return _tagScrollController.position.extentAfter < 96;
|
||||
}
|
||||
}
|
||||
|
||||
class _TagSuggestionDropdown extends StatelessWidget {
|
||||
|
||||
@@ -16,7 +16,12 @@ class AppShell extends StatelessWidget {
|
||||
_Destination('Favorites', Icons.favorite_rounded, '/favorites'),
|
||||
_Destination('Viewed', Icons.history_rounded, '/viewed'),
|
||||
_Destination(
|
||||
'Collections', Icons.collections_bookmark_rounded, '/collections'),
|
||||
'Collections',
|
||||
Icons.collections_bookmark_rounded,
|
||||
'/collections',
|
||||
mobileLabel: 'Boards',
|
||||
),
|
||||
_Destination('Artists', Icons.person_search_rounded, '/artists'),
|
||||
_Destination('Providers', Icons.hub_rounded, '/providers'),
|
||||
_Destination('Settings', Icons.settings_rounded, '/settings'),
|
||||
];
|
||||
@@ -38,8 +43,10 @@ class AppShell extends StatelessWidget {
|
||||
LogicalKeySet(LogicalKeyboardKey.digit5):
|
||||
const _NavigateIntent('/collections'),
|
||||
LogicalKeySet(LogicalKeyboardKey.digit6):
|
||||
const _NavigateIntent('/providers'),
|
||||
const _NavigateIntent('/artists'),
|
||||
LogicalKeySet(LogicalKeyboardKey.digit7):
|
||||
const _NavigateIntent('/providers'),
|
||||
LogicalKeySet(LogicalKeyboardKey.digit8):
|
||||
const _NavigateIntent('/settings'),
|
||||
},
|
||||
child: Actions(
|
||||
@@ -216,7 +223,11 @@ class _MobileShell extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final items = AppShell.destinations.take(5).toList();
|
||||
final items = AppShell.destinations
|
||||
.where((item) =>
|
||||
item.location != '/providers' && item.location != '/settings')
|
||||
.take(6)
|
||||
.toList();
|
||||
final location = GoRouterState.of(context).uri.path;
|
||||
final selected = items.indexWhere(
|
||||
(item) => item.location == '/'
|
||||
@@ -239,7 +250,10 @@ class _MobileShell extends StatelessWidget {
|
||||
onDestinationSelected: (index) => context.go(items[index].location),
|
||||
destinations: [
|
||||
for (final item in items)
|
||||
NavigationDestination(icon: Icon(item.icon), label: item.label),
|
||||
NavigationDestination(
|
||||
icon: Icon(item.icon),
|
||||
label: item.mobileLabel,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -247,8 +261,15 @@ class _MobileShell extends StatelessWidget {
|
||||
}
|
||||
|
||||
class _Destination {
|
||||
const _Destination(this.label, this.icon, this.location);
|
||||
const _Destination(
|
||||
this.label,
|
||||
this.icon,
|
||||
this.location, {
|
||||
String? mobileLabel,
|
||||
}) : mobileLabel = mobileLabel ?? label;
|
||||
|
||||
final String label;
|
||||
final String mobileLabel;
|
||||
final IconData icon;
|
||||
final String location;
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
name: gel_rule_app
|
||||
description: Local backend/core layer for a Flutter booru browser app.
|
||||
publish_to: "none"
|
||||
version: 1.1.0+11
|
||||
version: 1.1.2+13
|
||||
|
||||
environment:
|
||||
sdk: ">=3.3.0 <4.0.0"
|
||||
|
||||
@@ -76,6 +76,8 @@ void main() {
|
||||
'realbooru',
|
||||
'xbooru',
|
||||
'cosbooru',
|
||||
'kemono',
|
||||
'coomer',
|
||||
]);
|
||||
expect(
|
||||
providers.singleWhere((provider) => provider.id == 'realbooru').enabled,
|
||||
@@ -93,6 +95,12 @@ void main() {
|
||||
providers.singleWhere((provider) => provider.id == 'cosbooru');
|
||||
expect(cosbooru.baseUrl, 'https://cos.lycore.co');
|
||||
expect(cosbooru.apiType, 'danbooru');
|
||||
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 migrates old CosBooru config', () async {
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'package:gel_rule_app/backend/mappers/moebooru_mapper.dart';
|
||||
import 'package:gel_rule_app/backend/mappers/rule34_mapper.dart';
|
||||
import 'package:gel_rule_app/backend/models/content_provider_config.dart';
|
||||
import 'package:gel_rule_app/backend/providers/danbooru_provider.dart';
|
||||
import 'package:gel_rule_app/backend/providers/kemono_provider.dart';
|
||||
import 'package:gel_rule_app/backend/providers/provider_factory.dart';
|
||||
import 'package:gel_rule_app/backend/providers/realbooru_provider.dart';
|
||||
import 'package:gel_rule_app/backend/repositories/provider_repository.dart';
|
||||
@@ -68,6 +69,30 @@ void main() {
|
||||
expect(posts.single.rating, 'explicit');
|
||||
});
|
||||
|
||||
test('parses Gelbooru compatible tag category fields', () {
|
||||
final posts = GelbooruMapper.postsFromResponse(
|
||||
[
|
||||
{
|
||||
'id': '8',
|
||||
'file_url': 'https://example.test/cat.jpg',
|
||||
'tag_string_artist': 'artist_name',
|
||||
'tag_string_character': 'char_name',
|
||||
'tag_string_copyright': 'source_title',
|
||||
'tag_string_general': 'blue sky',
|
||||
'tag_string_meta': 'highres',
|
||||
}
|
||||
],
|
||||
providerId: 'gelbooru',
|
||||
providerName: 'Gelbooru',
|
||||
);
|
||||
|
||||
expect(posts.single.tagGroups['artist'], ['artist_name']);
|
||||
expect(posts.single.tagGroups['character'], ['char_name']);
|
||||
expect(posts.single.tagGroups['copyright'], ['source_title']);
|
||||
expect(posts.single.tagGroups['general'], ['blue', 'sky']);
|
||||
expect(posts.single.tagGroups['meta'], ['highres']);
|
||||
});
|
||||
|
||||
test('parses Rule34 compatible response', () {
|
||||
final posts = Rule34Mapper.postsFromResponse(
|
||||
{
|
||||
@@ -231,6 +256,16 @@ void main() {
|
||||
expect(cosbooru.enabled, isTrue);
|
||||
expect(cosbooru.apiType, 'danbooru');
|
||||
expect(cosbooru.baseUrl, 'https://cos.lycore.co');
|
||||
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');
|
||||
});
|
||||
|
||||
test('provider factory creates Realbooru provider', () {
|
||||
@@ -268,4 +303,24 @@ void main() {
|
||||
|
||||
expect(provider, isA<DanbooruProvider>());
|
||||
});
|
||||
|
||||
test('provider factory creates Kemono and Coomer artist providers', () {
|
||||
final now = DateTime(2026);
|
||||
for (final apiType in ['kemono', 'coomer']) {
|
||||
final provider = ProviderFactory().create(ContentProviderConfig(
|
||||
id: apiType,
|
||||
name: apiType,
|
||||
baseUrl: 'https://$apiType.su',
|
||||
apiType: apiType,
|
||||
enabled: true,
|
||||
priority: 10,
|
||||
timeoutSeconds: 20,
|
||||
customHeaders: const {},
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
));
|
||||
|
||||
expect(provider, isA<KemonoProvider>());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import 'package:gel_rule_app/features/post/presentation/widgets/post_tags_panel.
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('post tags panel renders category rows compactly',
|
||||
testWidgets('post tags panel renders category blocks with wrapping chips',
|
||||
(tester) async {
|
||||
final router = GoRouter(
|
||||
routes: [
|
||||
@@ -31,7 +31,10 @@ void main() {
|
||||
expect(find.text('Artist'), findsOneWidget);
|
||||
expect(find.text('Character'), findsOneWidget);
|
||||
expect(find.text('General'), findsOneWidget);
|
||||
expect(find.text('artist_name'), findsOneWidget);
|
||||
expect(find.text('hero'), findsOneWidget);
|
||||
expect(find.text('tag_0'), findsOneWidget);
|
||||
expect(find.text('+'), findsNothing);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user