From f65efe7cf791b09080e3d337e0e7dc555de4c483 Mon Sep 17 00:00:00 2001 From: RarDog Date: Tue, 26 May 2026 12:45:46 +0300 Subject: [PATCH] Release Lunaris 2.0 customization update --- README.md | 164 ++++++++---------- installer/Lunaris.iss | 2 +- lib/app/app.dart | 45 +++-- lib/app/app_version.dart | 6 +- lib/app/changelog.dart | 11 ++ lib/app/theme.dart | 4 +- lib/backend/di/backend_providers.dart | 2 +- lib/backend/providers/kemono_provider.dart | 10 +- lib/backend/providers/provider_manager.dart | 25 +-- .../repositories/provider_repository.dart | 4 +- lib/backend/services/settings_service.dart | 37 ++++ lib/backend/services/update_service.dart | 43 ++++- lib/core/http/dio_client.dart | 2 +- .../feed/presentation/feed_controller.dart | 27 ++- .../presentation/post_details_screen.dart | 2 +- .../widgets/post_media_viewer.dart | 2 +- .../presentation/settings_screen.dart | 160 ++++++++++++++++- lib/shared/widgets/app_shell.dart | 127 +++++++++++--- lib/shared/widgets/post_card.dart | 16 +- pubspec.yaml | 2 +- test/backend/provider_manager_test.dart | 12 +- 21 files changed, 514 insertions(+), 189 deletions(-) diff --git a/README.md b/README.md index 34d32fe..cfa5ba1 100644 --- a/README.md +++ b/README.md @@ -1,117 +1,101 @@ -# Lunaris +# Lunaris -Lunaris - локальное Flutter-приложение для просмотра booru/gelbooru/rule34-подобных источников РІ Pinterest-стиле. +Lunaris - локальное Flutter-приложение для просмотра booru, gelbooru, rule34-подобных источников и artist-архивов в Pinterest-стиле. -Приложение РЅРµ использует отдельный backend-сервер. Р’СЃСЏ логика живет внутри Flutter: API-провайдеры, РєСЌС€ metadata, избранное, коллекции, история просмотров, настройки, диагностика, фильтры Рё обновления. +Приложение не использует отдельный backend-сервер. Вся логика живет внутри Flutter: API-провайдеры, локальный кеш metadata, избранное, коллекции, история просмотров, фильтры, диагностика, загрузки и обновления. -## Возможности +## Возможности -- 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 РїСЂСЏРјРѕ РёР· приложения. +- Masonry feed с несколькими провайдерами и перемешанной выдачей All providers. +- Поиск тегов через chip-input с подсказками из активных API. +- Избранное, Viewed history, коллекции и пакетное добавление в коллекции. +- Smart blacklist/whitelist по тегам, группам тегов, рейтингу, провайдеру, типу файла и score. +- Details viewer для фото, GIF, видео и SWF на Windows. +- Видеоплеер с fullscreen, repeat, mute, half-volume и сохранением настроек. +- Отдельная вкладка Artists для Kemono/Coomer без смешивания с обычным booru feed. +- Управление провайдерами, health-check и provider diagnostics. +- Кастомизация интерфейса: тема, язык, акцентный цвет, видимые вкладки, beta/experimental обновления. +- Экспорт и импорт настроек JSON. +- Автопроверка Gitea Releases, скачивание APK/Windows installer прямо из приложения. -## Скачать +## Скачать -Готовые СЃР±РѕСЂРєРё лежат РІ Gitea Releases: +Готовые сборки лежат в Gitea Releases: -[Lunaris v1.1.2 Experimental](https://gitea.rardogsynapse.online/RarDog/RuleGelApp/releases/tag/v1.1.2-experimental) +[Lunaris 2.0.0](https://gitea.rardogsynapse.online/RarDog/RuleGelApp/releases/tag/v2.0.0) -- `Lunaris-v1.1.2-experimental.apk` - APK для Android. -- `LunarisSetup-v1.1.2-experimental.exe` - установщик для Windows. -- `LunarisPortable-v1.1.2-experimental.zip` - portable-версия для Windows. +- `Lunaris-v2.0.0.apk` - APK для Android. +- `LunarisSetup-v2.0.0.exe` - установщик для Windows. +- `LunarisPortable-v2.0.0.zip` - portable-версия для Windows. -## Стек +## Провайдеры -- Flutter, Dart, Riverpod, go_router -- Dio, Isar, connectivity_plus -- cached_network_image, flutter_staggered_grid_view -- media_kit для видео -- webview_windows + Ruffle fallback для SWF РЅР° Windows +Обычный feed: -## Архитектура +- Gelbooru +- Rule34 +- Safebooru +- Konachan +- Yande.re +- e621 / e926 +- Xbooru +- CosBooru +- Realbooru отключен по умолчанию, если публичный API недоступен -Lunaris - Flutter-монолит. UI РЅРµ дублирует backend-логику, Р° вызывает локальные сервисы через Riverpod. +Artists flow: -- `lib/core` - HTTP, база, РєСЌС€, ошибки, Result Рё утилиты. -- `lib/backend/models` - доменные модели. -- `lib/backend/providers` - API-провайдеры Рё ProviderManager. -- `lib/backend/repositories` - доступ Рє локальному Isar-хранилищу. +- Kemono +- Coomer + +Kemono/Coomer остаются отдельным разделом Artists и не попадают в общий booru feed. + +## Архитектура + +- `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/backend/di` - Riverpod providers для backend/core. +- `lib/app` - приложение, роутер, тема, motion и 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` - настройки. - -## Провайдеры РїРѕ умолчанию - -- 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, выключен РїРѕ умолчанию) - -Realbooru оставлен РІ СЃРїРёСЃРєРµ, РЅРѕ выключен РїРѕ умолчанию, потому что его публичный API сейчас нестабилен. - -Поддерживаемые `apiType`: `gelbooru`, `rule34`, `realbooru`, `danbooru`, `moebooru`, `e621`, `kemono`, `coomer`. - -РћРґРёРЅ упавший провайдер РЅРµ ломает общий feed: ошибка сохраняется РІ diagnostics, остальные источники продолжают отдавать посты. - -## Как добавить провайдера - -1. Добавить mapper РІ `lib/backend/mappers`, если формат ответа отличается. -2. Реализовать `ContentProvider` или artist-only `ArtistProvider` РІ `lib/backend/providers`. -3. Зарегистрировать новый `apiType` РІ `ProviderFactory`. -4. Добавить config через Providers UI или seed РІ `ProviderRepository`. - -## Запуск Рё СЃР±РѕСЂРєР° +## Запуск ```bash flutter pub get -flutter analyze -flutter test flutter run -d windows -flutter build windows -flutter build apk +flutter run -d android ``` -Windows installer собирается через Inno Setup: +## Сборка ```bash -ISCC installer/Lunaris.iss +flutter analyze +flutter test +flutter build apk +flutter build windows ``` -## Безопасность +Windows installer собирается через Inno Setup: -- Feed работает СЃ preview/sample metadata Рё РЅРµ скачивает full files автоматически. -- Скачивание фото/видео/SWF выполняется только РїРѕ СЏРІРЅРѕРјСѓ действию пользователя. -- Приложение уважает rate limits Рё использует retry/backoff. -- API keys/user data хранятся локально Рё попадают РІ export JSON только РїРѕ действию пользователя. +```powershell +ISCC.exe installer\Lunaris.iss +``` +## Как добавить провайдера + +1. Создать класс, реализующий `ContentProvider`. +2. Добавить mapper к единой модели `Post`. +3. Зарегистрировать `apiType` в `ProviderFactory`. +4. Добавить seed config в `ProviderRepository`, если провайдер нужен по умолчанию. +5. Добавить тесты parser/provider factory. + +## Безопасность + +- Feed работает только с metadata/preview, full file не скачивается автоматически. +- Загрузка файлов происходит только по явному действию пользователя. +- Приложение уважает API и rate limits сайтов. +- NSFW/blur/blacklist настраиваются локально. +- API keys/custom headers хранятся локально и экспортируются только через явный export settings. diff --git a/installer/Lunaris.iss b/installer/Lunaris.iss index aa02f6d..e06b1bc 100644 --- a/installer/Lunaris.iss +++ b/installer/Lunaris.iss @@ -1,5 +1,5 @@ #define MyAppName "Lunaris" -#define MyAppVersion "1.1.2" +#define MyAppVersion "2.0.0" #define MyAppPublisher "RarDog" #define MyAppExeName "gel_rule_app.exe" diff --git a/lib/app/app.dart b/lib/app/app.dart index 1f50ac9..f877a04 100644 --- a/lib/app/app.dart +++ b/lib/app/app.dart @@ -31,18 +31,24 @@ class GelRuleApp extends ConsumerWidget { themeMode: ThemeMode.dark, home: _StartupErrorScreen(error: error), ), - data: (_) => MaterialApp.router( - title: 'Lunaris', - debugShowCheckedModeBanner: false, - theme: buildTheme(Brightness.light), - darkTheme: buildTheme(Brightness.dark), - themeMode: settings.maybeWhen( - data: (value) => parseThemeMode(value.themeMode), - orElse: () => ThemeMode.dark, - ), - routerConfig: router, - builder: (context, child) => _AppOverlay(child: child), - ), + data: (_) { + final appSettings = settings.value ?? AppSettings.defaults; + return MaterialApp.router( + title: 'Lunaris', + debugShowCheckedModeBanner: false, + theme: buildTheme( + Brightness.light, + seedColor: appSettings.appSeedColor, + ), + darkTheme: buildTheme( + Brightness.dark, + seedColor: appSettings.appSeedColor, + ), + themeMode: parseThemeMode(appSettings.themeMode), + routerConfig: router, + builder: (context, child) => _AppOverlay(child: child), + ); + }, ); } } @@ -79,7 +85,9 @@ class _AppOverlayState extends ConsumerState<_AppOverlay> { await showDialog( context: context, builder: (context) => AlertDialog( - title: Text('Lunaris ${info.version} is available'), + title: Text(_ru + ? 'Доступен Lunaris ${info.version}' + : 'Lunaris ${info.version} is available'), content: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 520), child: SingleChildScrollView( @@ -106,14 +114,14 @@ class _AppOverlayState extends ConsumerState<_AppOverlay> { await ref.read(updateServiceProvider).skipVersion(info); if (context.mounted) Navigator.pop(context); }, - child: const Text('Skip this version'), + child: Text(_ru ? 'Пропустить' : 'Skip this version'), ), TextButton( onPressed: () async { await ref.read(updateServiceProvider).remindLater(); if (context.mounted) Navigator.pop(context); }, - child: const Text('Later'), + child: Text(_ru ? 'Позже' : 'Later'), ), FilledButton.icon( onPressed: () async { @@ -121,7 +129,7 @@ class _AppOverlayState extends ConsumerState<_AppOverlay> { if (context.mounted) Navigator.pop(context); }, icon: const Icon(Icons.download_rounded), - label: const Text('Download & open'), + label: Text(_ru ? 'Скачать и открыть' : 'Download & open'), ), ], ), @@ -129,6 +137,11 @@ class _AppOverlayState extends ConsumerState<_AppOverlay> { ref.invalidate(appSettingsProvider); } + bool get _ru => + (ref.read(appSettingsProvider).value ?? AppSettings.defaults) + .languageCode == + 'ru'; + Future _downloadUpdate(AppUpdateInfo info) async { final updateService = ref.read(updateServiceProvider); final assetUrl = updateService.assetUrlForCurrentPlatform(info); diff --git a/lib/app/app_version.dart b/lib/app/app_version.dart index 9685476..9364ba0 100644 --- a/lib/app/app_version.dart +++ b/lib/app/app_version.dart @@ -1,3 +1,3 @@ -const appDisplayVersion = '1.1.2'; -const appBuildNumber = 13; -const appReleaseChannel = 'Experimental'; +const appDisplayVersion = '2.0.0'; +const appBuildNumber = 20; +const appReleaseChannel = 'Stable'; diff --git a/lib/app/changelog.dart b/lib/app/changelog.dart index fa8af00..759a0d0 100644 --- a/lib/app/changelog.dart +++ b/lib/app/changelog.dart @@ -1,4 +1,15 @@ const lunarisChangelog = [ + LunarisChange( + version: '2.0.0', + title: 'Customization and provider polish', + bullets: [ + 'Settings now include app customization: accent color, language, visible tabs and beta update channel.', + 'The Artists tab disappears automatically when Kemono and Coomer are disabled.', + 'All-provider feed mixing is more natural instead of provider-by-provider blocks.', + 'Post cards use wider aspect ranges so images, GIFs and videos leave less empty space.', + 'Kemono and Coomer creator search uses the current mbahArip keyword endpoints with public API fallbacks.', + ], + ), LunarisChange( version: '1.1.2', title: 'Experimental tag and Artists update', diff --git a/lib/app/theme.dart b/lib/app/theme.dart index 6a256be..ec06043 100644 --- a/lib/app/theme.dart +++ b/lib/app/theme.dart @@ -1,9 +1,9 @@ import 'package:flutter/material.dart'; -ThemeData buildTheme(Brightness brightness) { +ThemeData buildTheme(Brightness brightness, {int seedColor = 0xFFE84D8A}) { final dark = brightness == Brightness.dark; final scheme = ColorScheme.fromSeed( - seedColor: const Color(0xFFE84D8A), + seedColor: Color(seedColor), brightness: brightness, ); return ThemeData( diff --git a/lib/backend/di/backend_providers.dart b/lib/backend/di/backend_providers.dart index 30c923c..cf27c41 100644 --- a/lib/backend/di/backend_providers.dart +++ b/lib/backend/di/backend_providers.dart @@ -168,7 +168,7 @@ final updateServiceProvider = Provider((ref) { connectTimeout: const Duration(seconds: 12), receiveTimeout: const Duration(seconds: 12), headers: const { - 'User-Agent': 'Lunaris/1.1 Flutter local booru browser', + 'User-Agent': 'Lunaris/2.0 Flutter local booru browser', 'Accept': 'application/json', }, ), diff --git a/lib/backend/providers/kemono_provider.dart b/lib/backend/providers/kemono_provider.dart index 6f38512..704dc18 100644 --- a/lib/backend/providers/kemono_provider.dart +++ b/lib/backend/providers/kemono_provider.dart @@ -26,7 +26,7 @@ class KemonoProvider receiveTimeout: const Duration(seconds: 20), headers: const { 'Accept': 'application/json', - 'User-Agent': 'Lunaris/1.1 Flutter local booru browser', + 'User-Agent': 'Lunaris/2.0 Flutter local booru browser', }, ), ); @@ -99,11 +99,9 @@ class KemonoProvider if (searchText.isNotEmpty) { try { final response = await _creatorDio.get( - '/search', + _creatorPath(), queryParameters: { - 'q': searchText, - 'query': searchText, - 'site': _isCoomer ? 'coomer' : 'kemono', + 'keyword': searchText, 'page': page, 'itemsPerPage': limit.clamp(1, 100), }, @@ -460,7 +458,7 @@ class KemonoProvider receiveTimeout: const Duration(seconds: 25), headers: const { 'Accept': 'application/json', - 'User-Agent': 'Lunaris/1.1 Flutter local booru browser', + 'User-Agent': 'Lunaris/2.0 Flutter local booru browser', }, ), ); diff --git a/lib/backend/providers/provider_manager.dart b/lib/backend/providers/provider_manager.dart index 92f8434..27591de 100644 --- a/lib/backend/providers/provider_manager.dart +++ b/lib/backend/providers/provider_manager.dart @@ -210,23 +210,24 @@ class ProviderManager { Set seen, ) { final posts = []; - var index = 0; - while (true) { - var added = false; - for (final providerPosts in results) { - if (index >= providerPosts.length) continue; - final post = providerPosts[index]; - if (seen.add(post.cacheKey)) { - posts.add(post); - added = true; - } + for (final providerPosts in results) { + for (final post in providerPosts) { + if (seen.add(post.cacheKey)) posts.add(post); } - if (!added && results.every((items) => index >= items.length)) break; - index++; } + posts.sort((a, b) => _mixKey(a).compareTo(_mixKey(b))); return posts; } + int _mixKey(Post post) { + var hash = 0x811c9dc5; + for (final code in post.cacheKey.codeUnits) { + hash ^= code; + hash = (hash * 0x01000193) & 0x7fffffff; + } + return hash; + } + Future>> getComments( String providerId, String postId, diff --git a/lib/backend/repositories/provider_repository.dart b/lib/backend/repositories/provider_repository.dart index 83cdab5..4da11cc 100644 --- a/lib/backend/repositories/provider_repository.dart +++ b/lib/backend/repositories/provider_repository.dart @@ -88,7 +88,7 @@ class ProviderRepository { priority: 5, timeoutSeconds: 20, customHeaders: const { - 'User-Agent': 'Lunaris/1.1 Flutter local booru browser', + 'User-Agent': 'Lunaris/2.0 Flutter local booru browser', }, createdAt: now, updatedAt: now, @@ -102,7 +102,7 @@ class ProviderRepository { priority: 6, timeoutSeconds: 20, customHeaders: const { - 'User-Agent': 'Lunaris/1.1 Flutter local booru browser', + 'User-Agent': 'Lunaris/2.0 Flutter local booru browser', }, createdAt: now, updatedAt: now, diff --git a/lib/backend/services/settings_service.dart b/lib/backend/services/settings_service.dart index 7ef0752..be7d858 100644 --- a/lib/backend/services/settings_service.dart +++ b/lib/backend/services/settings_service.dart @@ -18,6 +18,10 @@ class AppSettings { required this.cacheMaxItems, required this.providerPriority, required this.themeMode, + required this.languageCode, + required this.appSeedColor, + required this.hiddenTabs, + required this.allowExperimentalUpdates, required this.desktopColumns, required this.mobileColumns, required this.blurExplicitContent, @@ -54,6 +58,10 @@ class AppSettings { final int cacheMaxItems; final Map providerPriority; final String themeMode; + final String languageCode; + final int appSeedColor; + final List hiddenTabs; + final bool allowExperimentalUpdates; final int desktopColumns; final int mobileColumns; final bool blurExplicitContent; @@ -90,6 +98,10 @@ class AppSettings { cacheMaxItems: 2000, providerPriority: {'gelbooru': 0, 'rule34': 1, 'safebooru': 2}, themeMode: 'dark', + languageCode: 'ru', + appSeedColor: 0xFFE84D8A, + hiddenTabs: [], + allowExperimentalUpdates: false, desktopColumns: 5, mobileColumns: 2, blurExplicitContent: true, @@ -127,6 +139,10 @@ class AppSettings { int? cacheMaxItems, Map? providerPriority, String? themeMode, + String? languageCode, + int? appSeedColor, + List? hiddenTabs, + bool? allowExperimentalUpdates, int? desktopColumns, int? mobileColumns, bool? blurExplicitContent, @@ -164,6 +180,11 @@ class AppSettings { cacheMaxItems: cacheMaxItems ?? this.cacheMaxItems, providerPriority: providerPriority ?? this.providerPriority, themeMode: themeMode ?? this.themeMode, + languageCode: languageCode ?? this.languageCode, + appSeedColor: appSeedColor ?? this.appSeedColor, + hiddenTabs: hiddenTabs ?? this.hiddenTabs, + allowExperimentalUpdates: + allowExperimentalUpdates ?? this.allowExperimentalUpdates, desktopColumns: desktopColumns ?? this.desktopColumns, mobileColumns: mobileColumns ?? this.mobileColumns, blurExplicitContent: blurExplicitContent ?? this.blurExplicitContent, @@ -206,6 +227,10 @@ class AppSettings { 'cacheMaxItems': cacheMaxItems, 'providerPriority': providerPriority, 'themeMode': themeMode, + 'languageCode': languageCode, + 'appSeedColor': appSeedColor, + 'hiddenTabs': hiddenTabs, + 'allowExperimentalUpdates': allowExperimentalUpdates, 'desktopColumns': desktopColumns, 'mobileColumns': mobileColumns, 'blurExplicitContent': blurExplicitContent, @@ -249,6 +274,14 @@ class AppSettings { (json['providerPriority'] as Map?) ?? defaults.providerPriority, ), themeMode: (json['themeMode'] as String?) ?? defaults.themeMode, + languageCode: + (json['languageCode'] as String?) ?? defaults.languageCode, + appSeedColor: + (json['appSeedColor'] as num?)?.toInt() ?? defaults.appSeedColor, + hiddenTabs: List.from( + (json['hiddenTabs'] as List?) ?? defaults.hiddenTabs), + allowExperimentalUpdates: (json['allowExperimentalUpdates'] as bool?) ?? + defaults.allowExperimentalUpdates, desktopColumns: (json['desktopColumns'] as num?)?.toInt() ?? defaults.desktopColumns, mobileColumns: @@ -488,6 +521,10 @@ class SettingsService { }, 'settingsSummary': { 'themeMode': settings.themeMode, + 'languageCode': settings.languageCode, + 'appSeedColor': settings.appSeedColor, + 'hiddenTabs': settings.hiddenTabs, + 'allowExperimentalUpdates': settings.allowExperimentalUpdates, 'nsfwEnabled': settings.nsfwEnabled, 'blurExplicitContent': settings.blurExplicitContent, 'mediaQualityMode': settings.mediaQualityMode, diff --git a/lib/backend/services/update_service.dart b/lib/backend/services/update_service.dart index 275e2c2..2eb1068 100644 --- a/lib/backend/services/update_service.dart +++ b/lib/backend/services/update_service.dart @@ -12,6 +12,8 @@ class UpdateService { static const latestReleaseUrl = 'https://gitea.rardogsynapse.online/api/v1/repos/RarDog/RuleGelApp/releases/latest'; + static const releasesUrl = + 'https://gitea.rardogsynapse.online/api/v1/repos/RarDog/RuleGelApp/releases'; final Dio _dio; final SettingsService _settingsService; @@ -23,9 +25,7 @@ class UpdateService { : AppSettings.defaults; try { - final response = await _dio.get(latestReleaseUrl); - final data = Map.from(response.data as Map); - final info = _releaseFromJson(data); + final info = await _latestAllowedRelease(settings); final nextSettings = settings.copyWith( lastUpdateCheckAt: DateTime.now().toIso8601String(), ); @@ -49,6 +49,33 @@ class UpdateService { } } + Future _latestAllowedRelease(AppSettings settings) async { + final response = await _dio.get( + releasesUrl, + queryParameters: {'limit': 30}, + ); + final items = (response.data as List?) ?? const []; + final releases = items + .whereType() + .where((item) => item['draft'] != true) + .where((item) => + settings.allowExperimentalUpdates || item['prerelease'] != true) + .map((item) => _releaseFromJson(Map.from(item))) + .toList(growable: false); + if (releases.isEmpty) { + final latest = await _dio.get(latestReleaseUrl); + return _releaseFromJson(Map.from(latest.data as Map)); + } + releases.sort((a, b) { + final versionCompare = _compareVersions(b.version, a.version); + if (versionCompare != 0) return versionCompare; + final left = b.publishedAt ?? DateTime.fromMillisecondsSinceEpoch(0); + final right = a.publishedAt ?? DateTime.fromMillisecondsSinceEpoch(0); + return left.compareTo(right); + }); + return releases.first; + } + Future> remindLater() async { final result = await _settingsService.getSettings(); if (result is Error) return Error(result.failure); @@ -120,13 +147,17 @@ class UpdateService { } static bool _isNewerVersion(String latest, String current) { + return _compareVersions(latest, current) > 0; + } + + static int _compareVersions(String latest, String current) { final left = _parts(latest); final right = _parts(current); for (var i = 0; i < 3; i++) { - if (left[i] > right[i]) return true; - if (left[i] < right[i]) return false; + if (left[i] > right[i]) return 1; + if (left[i] < right[i]) return -1; } - return false; + return 0; } static List _parts(String value) { diff --git a/lib/core/http/dio_client.dart b/lib/core/http/dio_client.dart index 62e4ec0..972233f 100644 --- a/lib/core/http/dio_client.dart +++ b/lib/core/http/dio_client.dart @@ -40,7 +40,7 @@ class DioClient { receiveTimeout: timeout, sendTimeout: timeout, headers: { - 'User-Agent': 'Lunaris/1.1 Flutter local booru browser', + 'User-Agent': 'Lunaris/2.0 Flutter local booru browser', 'Accept': 'application/json, text/xml;q=0.9, */*;q=0.8', ...?headers, }, diff --git a/lib/features/feed/presentation/feed_controller.dart b/lib/features/feed/presentation/feed_controller.dart index 221b32e..6e74bf5 100644 --- a/lib/features/feed/presentation/feed_controller.dart +++ b/lib/features/feed/presentation/feed_controller.dart @@ -222,25 +222,24 @@ class FeedController extends AsyncNotifier { } return posts; } - var index = 0; - while (true) { - var added = false; - for (final group in providerResults) { - if (index >= group.length) continue; - final post = group[index]; - if (seen.add(post.cacheKey)) { - posts.add(post); - added = true; - } + for (final group in providerResults) { + for (final post in group) { + if (seen.add(post.cacheKey)) posts.add(post); } - if (!added && providerResults.every((group) => index >= group.length)) { - break; - } - index++; } + posts.sort((a, b) => _mixKey(a).compareTo(_mixKey(b))); return posts; } + int _mixKey(Post post) { + var hash = 0x811c9dc5; + for (final code in post.cacheKey.codeUnits) { + hash ^= code; + hash = (hash * 0x01000193) & 0x7fffffff; + } + return hash; + } + Future> _loadProviders() async { final result = await ref .read(providerManagerProvider) diff --git a/lib/features/post/presentation/post_details_screen.dart b/lib/features/post/presentation/post_details_screen.dart index c3612c6..823719c 100644 --- a/lib/features/post/presentation/post_details_screen.dart +++ b/lib/features/post/presentation/post_details_screen.dart @@ -610,7 +610,7 @@ class _MobilePostPagerState extends State<_MobilePostPager> { Map _headersFor(Post post) { return { - 'User-Agent': 'Lunaris/1.1 Flutter local booru browser', + 'User-Agent': 'Lunaris/2.0 Flutter local booru browser', 'Accept': '*/*', if (post.providerName.toLowerCase().contains('gelbooru') || post.fileUrl.contains('gelbooru.com') || diff --git a/lib/features/post/presentation/widgets/post_media_viewer.dart b/lib/features/post/presentation/widgets/post_media_viewer.dart index fe0ea10..ba993b3 100644 --- a/lib/features/post/presentation/widgets/post_media_viewer.dart +++ b/lib/features/post/presentation/widgets/post_media_viewer.dart @@ -348,7 +348,7 @@ class _PostMediaViewerState extends State Map _headersFor(Post post) { return { - 'User-Agent': 'Lunaris/1.1 Flutter local booru browser', + 'User-Agent': 'Lunaris/2.0 Flutter local booru browser', 'Accept': '*/*', if (post.providerName.toLowerCase().contains('gelbooru') || post.fileUrl.contains('gelbooru.com') || diff --git a/lib/features/settings/presentation/settings_screen.dart b/lib/features/settings/presentation/settings_screen.dart index cb08d2e..79f70ad 100644 --- a/lib/features/settings/presentation/settings_screen.dart +++ b/lib/features/settings/presentation/settings_screen.dart @@ -60,7 +60,9 @@ class _SettingsContent extends ConsumerWidget { children: [ DropdownButtonFormField( initialValue: settings.themeMode, - decoration: const InputDecoration(labelText: 'Theme'), + decoration: InputDecoration( + labelText: settings.languageCode == 'ru' ? 'Тема' : 'Theme', + ), items: const [ DropdownMenuItem(value: 'dark', child: Text('Dark')), DropdownMenuItem(value: 'light', child: Text('Light')), @@ -95,6 +97,47 @@ class _SettingsContent extends ConsumerWidget { ), ], ), + _SettingsSection( + title: settings.languageCode == 'ru' ? 'Внешний вид' : 'Appearance', + icon: Icons.palette_rounded, + children: [ + DropdownButtonFormField( + initialValue: settings.languageCode, + decoration: const InputDecoration(labelText: 'Language / Язык'), + items: const [ + DropdownMenuItem(value: 'ru', child: Text('Русский')), + DropdownMenuItem(value: 'en', child: Text('English')), + ], + onChanged: (value) => _update( + ref, + settings.copyWith(languageCode: value ?? 'ru'), + ), + ), + const SizedBox(height: 12), + _ColorSwatches( + selected: settings.appSeedColor, + onChanged: (color) => + _update(ref, settings.copyWith(appSeedColor: color)), + ), + const SizedBox(height: 12), + _TabVisibilityEditor( + hiddenTabs: settings.hiddenTabs, + onChanged: (hiddenTabs) => + _update(ref, settings.copyWith(hiddenTabs: hiddenTabs)), + ), + SwitchListTile( + value: settings.allowExperimentalUpdates, + title: const Text('Receive beta / experimental updates'), + subtitle: const Text( + 'When enabled, the updater also sees prerelease Gitea builds.', + ), + onChanged: (value) => _update( + ref, + settings.copyWith(allowExperimentalUpdates: value), + ), + ), + ], + ), _SettingsSection( title: 'Feed & Layout', icon: Icons.dashboard_customize_rounded, @@ -674,6 +717,121 @@ class _TagListEditorState extends State<_TagListEditor> { } } +class _ColorSwatches extends StatelessWidget { + const _ColorSwatches({ + required this.selected, + required this.onChanged, + }); + + final int selected; + final ValueChanged onChanged; + + static const colors = [ + 0xFFE84D8A, + 0xFF8B5CF6, + 0xFF3B82F6, + 0xFF14B8A6, + 0xFFF97316, + 0xFFEF4444, + ]; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Accent color', style: Theme.of(context).textTheme.titleSmall), + const SizedBox(height: 8), + Wrap( + spacing: 10, + runSpacing: 10, + children: [ + for (final value in colors) + Tooltip( + message: + '#${value.toRadixString(16).substring(2).toUpperCase()}', + child: InkWell( + borderRadius: BorderRadius.circular(999), + onTap: () => onChanged(value), + child: DecoratedBox( + decoration: BoxDecoration( + color: Color(value), + shape: BoxShape.circle, + border: Border.all( + color: selected == value + ? Theme.of(context).colorScheme.onSurface + : Theme.of(context).colorScheme.outlineVariant, + width: selected == value ? 3 : 1, + ), + ), + child: SizedBox( + width: 38, + height: 38, + child: selected == value + ? const Icon(Icons.check_rounded, color: Colors.white) + : null, + ), + ), + ), + ), + ], + ), + ], + ); + } +} + +class _TabVisibilityEditor extends StatelessWidget { + const _TabVisibilityEditor({ + required this.hiddenTabs, + required this.onChanged, + }); + + final List hiddenTabs; + final ValueChanged> onChanged; + + static const tabs = { + 'feed': 'Feed', + 'search': 'Search', + 'favorites': 'Favorites', + 'viewed': 'Viewed', + 'collections': 'Collections', + 'artists': 'Artists', + }; + + @override + Widget build(BuildContext context) { + final hidden = hiddenTabs.toSet(); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Visible tabs', style: Theme.of(context).textTheme.titleSmall), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (final entry in tabs.entries) + FilterChip( + selected: !hidden.contains(entry.key), + label: Text(entry.value), + onSelected: (visible) { + final next = {...hidden}; + if (visible) { + next.remove(entry.key); + } else if (entry.key != 'feed') { + next.add(entry.key); + } + onChanged(next.toList()..sort()); + }, + ), + ], + ), + ], + ); + } +} + class _StepperTile extends StatelessWidget { const _StepperTile({ required this.title, diff --git a/lib/shared/widgets/app_shell.dart b/lib/shared/widgets/app_shell.dart index 2ceadf6..0ce6234 100644 --- a/lib/shared/widgets/app_shell.dart +++ b/lib/shared/widgets/app_shell.dart @@ -1,33 +1,46 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; +import '../../app/app.dart'; import '../../app/motion.dart'; import '../../app/responsive.dart'; +import '../../backend/backend.dart'; +import '../../core/utils/result.dart'; -class AppShell extends StatelessWidget { +class AppShell extends ConsumerWidget { const AppShell({required this.child, super.key}); final Widget child; static const destinations = [ - _Destination('Feed', Icons.dashboard_rounded, '/'), - _Destination('Search', Icons.search_rounded, '/search'), - _Destination('Favorites', Icons.favorite_rounded, '/favorites'), - _Destination('Viewed', Icons.history_rounded, '/viewed'), + _Destination('feed', 'Feed', Icons.dashboard_rounded, '/'), + _Destination('search', 'Search', Icons.search_rounded, '/search'), _Destination( + 'favorites', 'Favorites', Icons.favorite_rounded, '/favorites'), + _Destination('viewed', 'Viewed', Icons.history_rounded, '/viewed'), + _Destination( + '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'), + _Destination('artists', 'Artists', Icons.person_search_rounded, '/artists'), + _Destination('providers', 'Providers', Icons.hub_rounded, '/providers'), + _Destination('settings', 'Settings', Icons.settings_rounded, '/settings'), ]; @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { + final settings = + ref.watch(appSettingsProvider).value ?? AppSettings.defaults; + final artistConfigs = + ref.watch(_enabledArtistConfigsProvider).value ?? const []; + final destinations = + _visibleDestinations(settings, artistConfigs.isNotEmpty); + final ru = settings.languageCode == 'ru'; return Shortcuts( shortcuts: { LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.keyF): @@ -67,17 +80,42 @@ class AppShell extends StatelessWidget { child: Focus( autofocus: true, child: Responsive.isDesktop(context) - ? _DesktopShell(child: child) - : _MobileShell(child: child), + ? _DesktopShell( + destinations: destinations, + ru: ru, + child: child, + ) + : _MobileShell( + destinations: destinations, + ru: ru, + child: child, + ), ), ), ); } + + static List<_Destination> _visibleDestinations( + AppSettings settings, + bool hasArtists, + ) { + return destinations.where((item) { + if (settings.hiddenTabs.contains(item.id)) return false; + if (item.id == 'artists' && !hasArtists) return false; + return true; + }).toList(growable: false); + } } class _DesktopShell extends StatefulWidget { - const _DesktopShell({required this.child}); + const _DesktopShell({ + required this.child, + required this.destinations, + required this.ru, + }); final Widget child; + final List<_Destination> destinations; + final bool ru; @override State<_DesktopShell> createState() => _DesktopShellState(); @@ -89,7 +127,7 @@ class _DesktopShellState extends State<_DesktopShell> { @override Widget build(BuildContext context) { final location = GoRouterState.of(context).uri.path; - final selected = AppShell.destinations.indexWhere( + final selected = widget.destinations.indexWhere( (item) => item.location == '/' ? location == '/' : location.startsWith(item.location), @@ -112,14 +150,15 @@ class _DesktopShellState extends State<_DesktopShell> { child: Column( children: [ for (var index = 0; - index < AppShell.destinations.length; + index < widget.destinations.length; index++) _RailButton( - destination: AppShell.destinations[index], + destination: widget.destinations[index], + ru: widget.ru, selected: (selected < 0 ? 0 : selected) == index, expanded: _hovered, onTap: () { - context.go(AppShell.destinations[index].location); + context.go(widget.destinations[index].location); }, ), const Spacer(), @@ -142,12 +181,14 @@ class _RailButton extends StatelessWidget { required this.destination, required this.selected, required this.expanded, + required this.ru, required this.onTap, }); final _Destination destination; final bool selected; final bool expanded; + final bool ru; final VoidCallback onTap; @override @@ -156,7 +197,7 @@ class _RailButton extends StatelessWidget { return Padding( padding: const EdgeInsets.only(bottom: 8), child: Tooltip( - message: expanded ? '' : destination.label, + message: expanded ? '' : destination.labelFor(ru), waitDuration: const Duration(milliseconds: 450), child: Material( color: selected ? colors.primaryContainer : Colors.transparent, @@ -187,7 +228,7 @@ class _RailButton extends StatelessWidget { child: Align( alignment: Alignment.centerLeft, child: Text( - destination.label, + destination.labelFor(ru), maxLines: 1, overflow: TextOverflow.clip, softWrap: false, @@ -218,12 +259,18 @@ class _RailButton extends StatelessWidget { } class _MobileShell extends StatelessWidget { - const _MobileShell({required this.child}); + const _MobileShell({ + required this.child, + required this.destinations, + required this.ru, + }); final Widget child; + final List<_Destination> destinations; + final bool ru; @override Widget build(BuildContext context) { - final items = AppShell.destinations + final items = destinations .where((item) => item.location != '/providers' && item.location != '/settings') .take(6) @@ -240,7 +287,7 @@ class _MobileShell extends StatelessWidget { ? null : FloatingActionButton.small( heroTag: 'mobile-settings', - tooltip: 'Settings', + tooltip: ru ? 'Настройки' : 'Settings', onPressed: () => context.go('/settings'), child: const Icon(Icons.settings_rounded), ), @@ -252,7 +299,7 @@ class _MobileShell extends StatelessWidget { for (final item in items) NavigationDestination( icon: Icon(item.icon), - label: item.mobileLabel, + label: item.mobileLabelFor(ru), ), ], ), @@ -262,18 +309,54 @@ class _MobileShell extends StatelessWidget { class _Destination { const _Destination( + this.id, this.label, this.icon, this.location, { String? mobileLabel, }) : mobileLabel = mobileLabel ?? label; + final String id; final String label; final String mobileLabel; final IconData icon; final String location; + + String labelFor(bool ru) { + if (!ru) return label; + return switch (id) { + 'feed' => 'Лента', + 'search' => 'Поиск', + 'favorites' => 'Избранное', + 'viewed' => 'История', + 'collections' => 'Коллекции', + 'artists' => 'Авторы', + 'providers' => 'Провайдеры', + 'settings' => 'Настройки', + _ => label, + }; + } + + String mobileLabelFor(bool ru) { + if (!ru) return mobileLabel; + return switch (id) { + 'collections' => 'Колл.', + _ => labelFor(true), + }; + } } +final _enabledArtistConfigsProvider = + FutureProvider>((ref) async { + ref.watch(appSettingsProvider); + final result = await ref + .watch(providerManagerProvider) + .loadArtistConfigs(enabledOnly: true); + return result is Success> + ? result.data + : const []; +}); + class _NavigateIntent extends Intent { const _NavigateIntent(this.location); final String location; diff --git a/lib/shared/widgets/post_card.dart b/lib/shared/widgets/post_card.dart index cd20743..708a6da 100644 --- a/lib/shared/widgets/post_card.dart +++ b/lib/shared/widgets/post_card.dart @@ -50,8 +50,9 @@ class _PostCardState extends State { Widget build(BuildContext context) { final post = widget.post; final sensitive = _isSensitive(post.rating); - final aspect = - post.width > 0 && post.height > 0 ? post.width / post.height : 0.72; + final aspect = post.width > 0 && post.height > 0 + ? post.width / post.height + : _fallbackAspect(post.fileType); final mobile = MediaQuery.sizeOf(context).width < 700; return MouseRegion( onEnter: (_) => setState(() => _hovered = true), @@ -111,7 +112,7 @@ class _PostCardState extends State { child: Stack( children: [ AspectRatio( - aspectRatio: aspect.clamp(0.45, 1.6), + aspectRatio: aspect.clamp(0.28, 2.2), child: BlurContent( enabled: widget.blurExplicit && sensitive, child: CachedNetworkImage( @@ -256,6 +257,15 @@ class _PostCardState extends State { normalized.contains('explicit') || normalized.contains('questionable'); } + + double _fallbackAspect(String fileType) { + final normalized = fileType.toLowerCase(); + if (normalized.contains('video') || normalized.contains('webm')) { + return 16 / 9; + } + if (normalized.contains('gif')) return 1; + return 0.72; + } } class _FavoriteButton extends StatelessWidget { diff --git a/pubspec.yaml b/pubspec.yaml index a86acb7..d57fe69 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: gel_rule_app description: Local backend/core layer for a Flutter booru browser app. publish_to: "none" -version: 1.1.2+13 +version: 2.0.0+20 environment: sdk: ">=3.3.0 <4.0.0" diff --git a/test/backend/provider_manager_test.dart b/test/backend/provider_manager_test.dart index 1c54d56..a62e32f 100644 --- a/test/backend/provider_manager_test.dart +++ b/test/backend/provider_manager_test.dart @@ -185,13 +185,14 @@ void main() { final result = await manager.searchAcrossProviders(tags: [], page: 0) as Success>; - expect(result.data.map((item) => item.providerId), ['a', 'b']); + expect(result.data.map((item) => item.providerId).toSet(), {'a', 'b'}); expect(repository.health['c']?.status, ProviderStatus.offline); expect(repository.diagnostics['a']?.lastResultCount, 1); expect(repository.diagnostics['c']?.lastErrorMessage, 'Search failed'); }); - test('all providers results are interleaved by provider priority', () async { + test('all providers results are naturally mixed without provider blocks', + () async { final repository = FakeProviderRepository() ..configs['a'] = config('a', 0) ..configs['b'] = config('b', 1) @@ -208,10 +209,9 @@ void main() { final result = await manager.searchAcrossProviders(tags: [], page: 0) as Success>; - expect( - result.data.map((item) => item.cacheKey), - ['a:1', 'b:1', 'c:1', 'a:2', 'b:2'], - ); + final keys = result.data.map((item) => item.cacheKey).toList(); + expect(keys.toSet(), {'a:1', 'a:2', 'b:1', 'b:2', 'c:1'}); + expect(keys, isNot(['a:1', 'a:2', 'b:1', 'b:2', 'c:1'])); }); test('enable disable provider persists config', () async {