Release Lunaris 2.0 customization update
This commit is contained in:
+29
-16
@@ -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);
|
||||
|
||||
@@ -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 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,
|
||||
|
||||
@@ -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 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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user