Release Lunaris 2.0 customization update

This commit is contained in:
2026-05-26 12:45:46 +03:00
parent 9cff9e5307
commit f65efe7cf7
21 changed files with 514 additions and 189 deletions
+74 -90
View File
@@ -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 -1
View File
@@ -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"
+29 -16
View File
@@ -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<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);
+3 -3
View File
@@ -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';
+11
View File
@@ -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
View File
@@ -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(
+1 -1
View File
@@ -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',
},
),
+4 -6
View File
@@ -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',
},
),
);
+13 -12
View File
@@ -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 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<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,
+37 -6
View File
@@ -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) {
+1 -1
View File
@@ -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 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<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,
+105 -22
View File
@@ -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;
+13 -3
View File
@@ -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
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.2+13
version: 2.0.0+20
environment:
sdk: ">=3.3.0 <4.0.0"
+6 -6
View File
@@ -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 {