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
+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 {