Release Lunaris 2.0 customization update
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
+24
-11
@@ -31,18 +31,24 @@ class GelRuleApp extends ConsumerWidget {
|
||||
themeMode: ThemeMode.dark,
|
||||
home: _StartupErrorScreen(error: error),
|
||||
),
|
||||
data: (_) => MaterialApp.router(
|
||||
data: (_) {
|
||||
final appSettings = settings.value ?? AppSettings.defaults;
|
||||
return 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,
|
||||
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<void>(
|
||||
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<void> _downloadUpdate(AppUpdateInfo info) async {
|
||||
final updateService = ref.read(updateServiceProvider);
|
||||
final assetUrl = updateService.assetUrlForCurrentPlatform(info);
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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',
|
||||
|
||||
+2
-2
@@ -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(
|
||||
|
||||
@@ -168,7 +168,7 @@ final updateServiceProvider = Provider<UpdateService>((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',
|
||||
},
|
||||
),
|
||||
|
||||
@@ -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<dynamic>(
|
||||
'/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',
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
@@ -210,23 +210,24 @@ class ProviderManager {
|
||||
Set<String> seen,
|
||||
) {
|
||||
final posts = <Post>[];
|
||||
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 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<Result<List<PostComment>>> getComments(
|
||||
String providerId,
|
||||
String postId,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<String, int> providerPriority;
|
||||
final String themeMode;
|
||||
final String languageCode;
|
||||
final int appSeedColor;
|
||||
final List<String> 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<String, int>? providerPriority,
|
||||
String? themeMode,
|
||||
String? languageCode,
|
||||
int? appSeedColor,
|
||||
List<String>? 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<String>.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,
|
||||
|
||||
@@ -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<dynamic>(latestReleaseUrl);
|
||||
final data = Map<String, dynamic>.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<AppUpdateInfo> _latestAllowedRelease(AppSettings settings) async {
|
||||
final response = await _dio.get<dynamic>(
|
||||
releasesUrl,
|
||||
queryParameters: {'limit': 30},
|
||||
);
|
||||
final items = (response.data as List?) ?? const [];
|
||||
final releases = items
|
||||
.whereType<Map>()
|
||||
.where((item) => item['draft'] != true)
|
||||
.where((item) =>
|
||||
settings.allowExperimentalUpdates || item['prerelease'] != true)
|
||||
.map((item) => _releaseFromJson(Map<String, dynamic>.from(item)))
|
||||
.toList(growable: false);
|
||||
if (releases.isEmpty) {
|
||||
final latest = await _dio.get<dynamic>(latestReleaseUrl);
|
||||
return _releaseFromJson(Map<String, dynamic>.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<Result<void>> remindLater() async {
|
||||
final result = await _settingsService.getSettings();
|
||||
if (result is Error<AppSettings>) 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<int> _parts(String value) {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -222,25 +222,24 @@ class FeedController extends AsyncNotifier<FeedState> {
|
||||
}
|
||||
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 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<List<ContentProviderConfig>> _loadProviders() async {
|
||||
final result = await ref
|
||||
.read(providerManagerProvider)
|
||||
|
||||
@@ -610,7 +610,7 @@ class _MobilePostPagerState extends State<_MobilePostPager> {
|
||||
|
||||
Map<String, String> _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') ||
|
||||
|
||||
@@ -348,7 +348,7 @@ class _PostMediaViewerState extends State<PostMediaViewer>
|
||||
|
||||
Map<String, String> _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') ||
|
||||
|
||||
@@ -60,7 +60,9 @@ class _SettingsContent extends ConsumerWidget {
|
||||
children: [
|
||||
DropdownButtonFormField<String>(
|
||||
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<String>(
|
||||
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<int> onChanged;
|
||||
|
||||
static const colors = <int>[
|
||||
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<String> hiddenTabs;
|
||||
final ValueChanged<List<String>> onChanged;
|
||||
|
||||
static const tabs = <String, String>{
|
||||
'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,
|
||||
|
||||
@@ -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<List<ContentProviderConfig>>((ref) async {
|
||||
ref.watch(appSettingsProvider);
|
||||
final result = await ref
|
||||
.watch(providerManagerProvider)
|
||||
.loadArtistConfigs(enabledOnly: true);
|
||||
return result is Success<List<ContentProviderConfig>>
|
||||
? result.data
|
||||
: const [];
|
||||
});
|
||||
|
||||
class _NavigateIntent extends Intent {
|
||||
const _NavigateIntent(this.location);
|
||||
final String location;
|
||||
|
||||
@@ -50,8 +50,9 @@ class _PostCardState extends State<PostCard> {
|
||||
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<PostCard> {
|
||||
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<PostCard> {
|
||||
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 {
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
name: gel_rule_app
|
||||
description: Local backend/core layer for a Flutter booru browser app.
|
||||
publish_to: "none"
|
||||
version: 1.1.2+13
|
||||
version: 2.0.0+20
|
||||
|
||||
environment:
|
||||
sdk: ">=3.3.0 <4.0.0"
|
||||
|
||||
@@ -185,13 +185,14 @@ void main() {
|
||||
|
||||
final result = await manager.searchAcrossProviders(tags: [], page: 0)
|
||||
as Success<List<Post>>;
|
||||
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<List<Post>>;
|
||||
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user