Prepare Lunaris 1.1.2 experimental

This commit is contained in:
2026-05-26 11:43:25 +03:00
parent 369dee02be
commit 9cff9e5307
42 changed files with 2041 additions and 303 deletions
+82 -159
View File
@@ -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 только по действию пользователя.
+4
View File
@@ -42,3 +42,7 @@ android {
flutter {
source = "../.."
}
dependencies {
implementation("androidx.core:core-ktx:1.13.1")
}
+10
View File
@@ -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 -1
View File
@@ -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
View File
@@ -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;
+3 -2
View File
@@ -1,2 +1,3 @@
const appDisplayVersion = '1.1.0';
const appBuildNumber = 11;
const appDisplayVersion = '1.1.2';
const appBuildNumber = 13;
const appReleaseChannel = 'Experimental';
+20
View File
@@ -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',
+16
View File
@@ -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
View File
@@ -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';
+4 -1
View File
@@ -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;
}
+23
View File
@@ -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;
}
+25
View File
@@ -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);
}
+8 -2
View File
@@ -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,
);
+36
View File
@@ -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,
);
}
+102 -1
View File
@@ -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('&#039;', "'")
.replaceAll('&apos;', "'");
}
int _int(dynamic value) {
if (value is int) return value;
if (value is num) return value.toInt();
return int.tryParse(value?.toString() ?? '') ?? 0;
}
}
+503
View File
@@ -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,
+90 -2
View File
@@ -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);
}
+24
View File
@@ -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,
+4 -2
View File
@@ -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,
);
-21
View File
@@ -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 {
+26 -5
View File
@@ -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
View File
@@ -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"
+8
View File
@@ -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 {
+55
View File
@@ -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>());
}
});
}
+4 -1
View File
@@ -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);
});
}