Release RuleGel 1.0.1 with Realbooru

This commit is contained in:
2026-05-25 14:33:59 +03:00
parent 16e86fb9d5
commit ea39cf8567
28 changed files with 1217 additions and 263 deletions
+25 -1
View File
@@ -1,9 +1,12 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:url_launcher/url_launcher.dart';
import '../backend/backend.dart';
import '../core/utils/result.dart';
import 'motion.dart';
import 'router.dart';
import 'theme.dart';
@@ -131,7 +134,28 @@ class _AppOverlayState extends ConsumerState<_AppOverlay> {
@override
Widget build(BuildContext context) {
return _DownloadOverlay(child: widget.child);
final appSettings = ref.watch(appSettingsProvider).value;
final isAndroid = Platform.isAndroid;
final deviceInfo =
isAndroid ? ref.watch(motionDeviceInfoProvider).value : null;
final view = isAndroid ? View.maybeOf(context) : null;
final detectedHz = isAndroid ? view?.display.refreshRate ?? 60.0 : 60.0;
final motion = resolveMotionSettings(
settings: appSettings ?? AppSettings.defaults,
detectedHz: detectedHz,
device: deviceInfo,
);
if (isAndroid) {
WidgetsBinding.instance.addPostFrameCallback((_) {
ref
.read(deviceMotionServiceProvider)
.setPreferredRefreshRate(motion.effectiveHz);
});
}
return AppMotionScope(
settings: motion,
child: _DownloadOverlay(child: widget.child),
);
}
}
+2 -2
View File
@@ -1,2 +1,2 @@
const appDisplayVersion = '1.0.0';
const appBuildNumber = 8;
const appDisplayVersion = '1.0.1';
const appBuildNumber = 9;
+10
View File
@@ -1,4 +1,14 @@
const ruleGelChangelog = [
RuleGelChange(
version: '1.0.1',
title: 'Realbooru and mobile polish',
bullets: [
'Added Realbooru as a default enabled provider.',
'Startup update checks now look for new Gitea releases every launch.',
'Refresh-rate controls are Android-only; desktop keeps stable app motion.',
'Tag chip input no longer jumps while typing.',
],
),
RuleGelChange(
version: '1.0.0',
title: 'Stable 1.0',
+193
View File
@@ -0,0 +1,193 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../backend/backend.dart';
enum MotionRefreshMode {
auto('Auto'),
hz60('60 Hz'),
hz120('120 Hz'),
hz144('144 Hz'),
hz165('165 Hz');
const MotionRefreshMode(this.label);
final String label;
int? get targetHz => switch (this) {
MotionRefreshMode.auto => null,
MotionRefreshMode.hz60 => 60,
MotionRefreshMode.hz120 => 120,
MotionRefreshMode.hz144 => 144,
MotionRefreshMode.hz165 => 165,
};
static MotionRefreshMode fromName(String value) {
return MotionRefreshMode.values.firstWhere(
(mode) => mode.name == value,
orElse: () => MotionRefreshMode.auto,
);
}
}
class DeviceMotionInfo {
const DeviceMotionInfo({
this.batteryLevel,
this.supportedRefreshRates = const [],
});
final int? batteryLevel;
final List<double> supportedRefreshRates;
}
class EffectiveMotionSettings {
const EffectiveMotionSettings({
required this.mode,
required this.effectiveHz,
required this.detectedHz,
required this.batteryLevel,
required this.batterySaverActive,
required this.supportedRefreshRates,
});
final MotionRefreshMode mode;
final double effectiveHz;
final double detectedHz;
final int? batteryLevel;
final bool batterySaverActive;
final List<double> supportedRefreshRates;
double get durationScale => (60 / effectiveHz).clamp(0.72, 1.18).toDouble();
Duration scale(int milliseconds) {
return Duration(milliseconds: (milliseconds * durationScale).round());
}
}
class DeviceMotionService {
DeviceMotionService();
static const _channel = MethodChannel('rulegel/device');
Future<DeviceMotionInfo> readInfo() async {
final battery = await _safe<int>('getBatteryLevel');
final rates = await _safe<List<dynamic>>('getSupportedRefreshRates');
final supported = rates
?.map((value) => (value as num).toDouble())
.where((value) => value > 0)
.toSet()
.toList() ??
<double>[];
supported.sort();
return DeviceMotionInfo(
batteryLevel: battery,
supportedRefreshRates: supported,
);
}
Future<void> setPreferredRefreshRate(double hz) async {
if (!Platform.isAndroid) return;
await _safe<void>('setPreferredRefreshRate', {'hz': hz});
}
Future<T?> _safe<T>(String method, [Object? arguments]) async {
try {
return await _channel.invokeMethod<T>(method, arguments);
} on MissingPluginException {
return null;
} on PlatformException {
return null;
}
}
}
final deviceMotionServiceProvider = Provider<DeviceMotionService>((ref) {
return DeviceMotionService();
});
final motionDeviceInfoProvider = FutureProvider<DeviceMotionInfo>((ref) {
return ref.watch(deviceMotionServiceProvider).readInfo();
});
EffectiveMotionSettings resolveMotionSettings({
required AppSettings settings,
required double detectedHz,
DeviceMotionInfo? device,
}) {
final mode = MotionRefreshMode.fromName(settings.motionRefreshMode);
final batteryLevel = device?.batteryLevel;
final batterySaverActive = settings.autoBatterySaver60Hz &&
batteryLevel != null &&
batteryLevel < 20;
final requestedHz = batterySaverActive
? 60.0
: mode.targetHz?.toDouble() ??
(detectedHz.isFinite && detectedHz > 0 ? detectedHz : 60.0);
final supported = device?.supportedRefreshRates ?? const <double>[];
final effectiveHz = supported.isEmpty
? requestedHz
: _nearestSupported(requestedHz, supported);
return EffectiveMotionSettings(
mode: mode,
effectiveHz: effectiveHz,
detectedHz: detectedHz.isFinite && detectedHz > 0 ? detectedHz : 60.0,
batteryLevel: batteryLevel,
batterySaverActive: batterySaverActive,
supportedRefreshRates: supported,
);
}
double _nearestSupported(double target, List<double> supported) {
var best = supported.first;
var bestDistance = (best - target).abs();
for (final value in supported.skip(1)) {
final distance = (value - target).abs();
if (distance < bestDistance) {
best = value;
bestDistance = distance;
}
}
return best;
}
class AppMotionScope extends InheritedWidget {
const AppMotionScope({
required this.settings,
required super.child,
super.key,
});
final EffectiveMotionSettings settings;
static EffectiveMotionSettings of(BuildContext context) {
return context
.dependOnInheritedWidgetOfExactType<AppMotionScope>()
?.settings ??
const EffectiveMotionSettings(
mode: MotionRefreshMode.auto,
effectiveHz: 60,
detectedHz: 60,
batteryLevel: null,
batterySaverActive: false,
supportedRefreshRates: [],
);
}
@override
bool updateShouldNotify(covariant AppMotionScope oldWidget) {
return settings.effectiveHz != oldWidget.settings.effectiveHz ||
settings.batterySaverActive != oldWidget.settings.batterySaverActive ||
settings.mode != oldWidget.settings.mode;
}
}
class AppMotion {
const AppMotion._();
static Duration duration(BuildContext context, int milliseconds) {
return AppMotionScope.of(context).scale(milliseconds);
}
}
+31
View File
@@ -55,6 +55,7 @@ class GelbooruMapper {
static List<dynamic> _extractItems(dynamic data) {
if (data is List) return data;
if (data is String) return _extractItemsFromText(data);
if (data is Map) {
final posts = data['post'] ?? data['posts'];
if (posts is List) return posts;
@@ -64,6 +65,26 @@ class GelbooruMapper {
return const [];
}
static List<Map<String, dynamic>> _extractItemsFromText(String text) {
final items = <Map<String, dynamic>>[];
for (final match
in RegExp(r'<post\b([^>]*)/?>', caseSensitive: false, dotAll: true)
.allMatches(text)) {
final attrs = _attributes(match.group(1) ?? '');
if (attrs.isNotEmpty) items.add(attrs);
}
return items;
}
static Map<String, dynamic> _attributes(String source) {
final result = <String, dynamic>{};
for (final match in RegExp(r'''([A-Za-z0-9_:-]+)\s*=\s*["']([^"']*)["']''')
.allMatches(source)) {
result[match.group(1)!] = _decodeEntities(match.group(2) ?? '');
}
return result;
}
static List<String> _tags(dynamic value) {
if (value is List) return value.map((tag) => tag.toString()).toList();
return _string(value)
@@ -128,4 +149,14 @@ class GelbooruMapper {
add('meta', json['tag_string_meta'] ?? json['tags_meta']);
return groups;
}
static String _decodeEntities(String value) {
return value
.replaceAll('&amp;', '&')
.replaceAll('&lt;', '<')
.replaceAll('&gt;', '>')
.replaceAll('&quot;', '"')
.replaceAll('&#039;', "'")
.replaceAll('&apos;', "'");
}
}
@@ -6,6 +6,7 @@ import 'danbooru_provider.dart';
import 'e621_provider.dart';
import 'gelbooru_provider.dart';
import 'moebooru_provider.dart';
import 'realbooru_provider.dart';
import 'rule34_provider.dart';
class ProviderFactory {
@@ -39,6 +40,14 @@ class ProviderFactory {
dioClient: client,
queryParameters: queryParameters,
);
case 'realbooru':
return RealbooruProvider(
id: config.id,
name: config.name,
baseUrl: config.baseUrl,
dioClient: client,
queryParameters: queryParameters,
);
case 'danbooru':
return DanbooruProvider(
id: config.id,
@@ -0,0 +1,123 @@
import '../../core/errors/app_exception.dart';
import '../mappers/gelbooru_mapper.dart';
import '../models/post.dart';
import '../models/provider_health.dart';
import '../models/top_period_filter.dart';
import 'gelbooru_provider.dart';
class RealbooruProvider extends GelbooruProvider {
RealbooruProvider({
required super.id,
required super.name,
required super.baseUrl,
required super.dioClient,
super.queryParameters,
});
@override
Future<List<Post>> searchPosts({
required List<String> tags,
required int page,
int limit = 50,
String? rating,
TopPeriodFilter topPeriod = TopPeriodFilter.none,
}) async {
final queryTags = [
...tags,
if (rating != null && rating.isNotEmpty) 'rating:$rating',
if (topPeriod == TopPeriodFilter.allTime) 'sort:score:desc',
];
final response = await dio.get<dynamic>(
'/index.php',
queryParameters: {
'page': 'dapi',
's': 'post',
'q': 'index',
'pid': page,
'limit': limit.clamp(1, 100),
'tags': queryTags.join(' '),
...queryParameters,
},
);
_throwIfSearchFailed(response.data);
return GelbooruMapper.postsFromResponse(
response.data,
providerId: id,
providerName: name,
);
}
@override
Future<Post?> getPost(String id) async {
final response = await dio.get<dynamic>(
'/index.php',
queryParameters: {
'page': 'dapi',
's': 'post',
'q': 'index',
'id': id,
...queryParameters,
},
);
_throwIfSearchFailed(response.data);
final posts = GelbooruMapper.postsFromResponse(
response.data,
providerId: this.id,
providerName: name,
);
return posts.isEmpty ? null : posts.first;
}
@override
Future<ProviderHealth> checkHealth() async {
final startedAt = DateTime.now();
try {
final response = await dio.get<dynamic>(
'/index.php',
queryParameters: {
'page': 'dapi',
's': 'post',
'q': 'index',
'limit': 1,
...queryParameters,
},
);
_throwIfSearchFailed(response.data);
return ProviderHealth(
providerId: id,
status: ProviderStatus.online,
pingMs: DateTime.now().difference(startedAt).inMilliseconds,
lastCheckedAt: DateTime.now(),
apiVersion: 'realbooru-dapi',
);
} catch (error) {
return ProviderHealth(
providerId: id,
status: ProviderStatus.offline,
pingMs: DateTime.now().difference(startedAt).inMilliseconds,
lastCheckedAt: DateTime.now(),
errorMessage: error.toString(),
apiVersion: 'realbooru-dapi',
);
}
}
void _throwIfSearchFailed(dynamic data) {
if (data is Map) {
final success = data['success']?.toString().toLowerCase();
if (success == 'false') {
throw ProviderUnavailableException(
(data['message'] ?? 'Realbooru search is unavailable').toString(),
);
}
}
final text = data?.toString().toLowerCase() ?? '';
if (text.contains('success="false"') || text.contains("success='false'")) {
final message =
RegExp(r'''message\s*=\s*["']([^"']+)["']''').firstMatch(text);
throw ProviderUnavailableException(
message?.group(1) ?? 'Realbooru search is unavailable',
);
}
}
}
@@ -107,6 +107,18 @@ class ProviderRepository {
createdAt: now,
updatedAt: now,
),
ContentProviderConfig(
id: 'realbooru',
name: 'Realbooru',
baseUrl: 'https://realbooru.com',
apiType: 'realbooru',
enabled: true,
priority: 7,
timeoutSeconds: 20,
customHeaders: const {},
createdAt: now,
updatedAt: now,
),
];
}
@@ -30,6 +30,8 @@ class AppSettings {
required this.smartBlacklistRules,
required this.hideViewedPosts,
required this.mediaQualityMode,
required this.motionRefreshMode,
required this.autoBatterySaver60Hz,
required this.hiddenPostKeys,
required this.diagnosticLogLines,
required this.lastFeedTags,
@@ -60,6 +62,8 @@ class AppSettings {
final List<String> smartBlacklistRules;
final bool hideViewedPosts;
final String mediaQualityMode;
final String motionRefreshMode;
final bool autoBatterySaver60Hz;
final List<String> hiddenPostKeys;
final List<String> diagnosticLogLines;
final List<String> lastFeedTags;
@@ -90,6 +94,8 @@ class AppSettings {
smartBlacklistRules: [],
hideViewedPosts: false,
mediaQualityMode: 'auto',
motionRefreshMode: 'auto',
autoBatterySaver60Hz: true,
hiddenPostKeys: [],
diagnosticLogLines: [],
lastFeedTags: [],
@@ -121,6 +127,8 @@ class AppSettings {
List<String>? smartBlacklistRules,
bool? hideViewedPosts,
String? mediaQualityMode,
String? motionRefreshMode,
bool? autoBatterySaver60Hz,
List<String>? hiddenPostKeys,
List<String>? diagnosticLogLines,
List<String>? lastFeedTags,
@@ -154,6 +162,8 @@ class AppSettings {
smartBlacklistRules: smartBlacklistRules ?? this.smartBlacklistRules,
hideViewedPosts: hideViewedPosts ?? this.hideViewedPosts,
mediaQualityMode: mediaQualityMode ?? this.mediaQualityMode,
motionRefreshMode: motionRefreshMode ?? this.motionRefreshMode,
autoBatterySaver60Hz: autoBatterySaver60Hz ?? this.autoBatterySaver60Hz,
hiddenPostKeys: hiddenPostKeys ?? this.hiddenPostKeys,
diagnosticLogLines: diagnosticLogLines ?? this.diagnosticLogLines,
lastFeedTags: lastFeedTags ?? this.lastFeedTags,
@@ -187,6 +197,8 @@ class AppSettings {
'smartBlacklistRules': smartBlacklistRules,
'hideViewedPosts': hideViewedPosts,
'mediaQualityMode': mediaQualityMode,
'motionRefreshMode': motionRefreshMode,
'autoBatterySaver60Hz': autoBatterySaver60Hz,
'hiddenPostKeys': hiddenPostKeys,
'diagnosticLogLines': diagnosticLogLines,
'lastFeedTags': lastFeedTags,
@@ -243,6 +255,10 @@ class AppSettings {
(json['hideViewedPosts'] as bool?) ?? defaults.hideViewedPosts,
mediaQualityMode:
(json['mediaQualityMode'] as String?) ?? defaults.mediaQualityMode,
motionRefreshMode: (json['motionRefreshMode'] as String?) ??
defaults.motionRefreshMode,
autoBatterySaver60Hz: (json['autoBatterySaver60Hz'] as bool?) ??
defaults.autoBatterySaver60Hz,
hiddenPostKeys: List<String>.from(
(json['hiddenPostKeys'] as List?) ?? defaults.hiddenPostKeys,
),
@@ -442,6 +458,8 @@ class SettingsService {
'nsfwEnabled': settings.nsfwEnabled,
'blurExplicitContent': settings.blurExplicitContent,
'mediaQualityMode': settings.mediaQualityMode,
'motionRefreshMode': settings.motionRefreshMode,
'autoBatterySaver60Hz': settings.autoBatterySaver60Hz,
'cacheTtlHours': settings.cacheTtlHours,
'cacheMaxItems': settings.cacheMaxItems,
'hiddenPosts': settings.hiddenPostKeys.length,
@@ -506,6 +524,7 @@ class SettingsService {
'blacklistedTags',
'whitelistedTags',
'smartBlacklistRules',
'hiddenPostKeys',
]) {
settingsJson.putIfAbsent(key, () => filtersJson[key]);
}
-8
View File
@@ -21,8 +21,6 @@ class UpdateService {
? settingsResult.data
: AppSettings.defaults;
if (!force && !_shouldCheck(settings)) return const Success(null);
try {
final response = await _dio.get<dynamic>(latestReleaseUrl);
final data = Map<String, dynamic>.from(response.data as Map);
@@ -68,12 +66,6 @@ class UpdateService {
);
}
bool _shouldCheck(AppSettings settings) {
final last = DateTime.tryParse(settings.lastUpdateCheckAt ?? '');
if (last == null) return true;
return DateTime.now().difference(last) >= const Duration(hours: 24);
}
AppUpdateInfo _releaseFromJson(Map<String, dynamic> json) {
final tag = (json['tag_name'] ?? '').toString();
final assets = (json['assets'] as List?) ?? const [];
@@ -20,11 +20,17 @@ class CollectionDetailsScreen extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final posts = ref.watch(collectionPostsProvider(collectionId));
final collections = ref.watch(collectionsControllerProvider).valueOrNull;
final title = collections
?.where((collection) => collection.id == collectionId)
.firstOrNull
?.name ??
'Collection';
final settings =
ref.watch(appSettingsProvider).value ?? AppSettings.defaults;
final favoriteKeys = ref.watch(favoriteKeysProvider).value ?? <String>{};
return AdaptiveScaffold(
title: 'Collection',
title: title,
body: posts.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, _) => ErrorView(message: error.toString()),
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../backend/models/collection.dart';
import '../../../shared/widgets/adaptive_scaffold.dart';
import '../../../shared/widgets/confirm_dialog.dart';
import '../../../shared/widgets/empty_view.dart';
@@ -40,55 +41,26 @@ class CollectionsScreen extends ConsumerWidget {
itemCount: items.length,
itemBuilder: (context, index) {
final item = items[index];
return Card(
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: () => context.go('/collections/${item.id}'),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(Icons.collections_bookmark_rounded,
size: 32),
const Spacer(),
Text(item.name,
style: Theme.of(context).textTheme.titleLarge),
Text(item.description ?? ''),
Row(
children: [
IconButton(
tooltip: 'Edit',
onPressed: () => showCollectionFormDialog(
context,
ref,
collection: item,
),
icon: const Icon(Icons.edit_rounded),
),
IconButton(
tooltip: 'Delete',
onPressed: () async {
final ok = await showConfirmDialog(
context,
title: 'Delete collection',
message: 'Remove ${item.name}?',
);
if (ok) {
await ref
.read(collectionsControllerProvider
.notifier)
.delete(item.id);
}
},
icon: const Icon(Icons.delete_rounded),
),
],
),
],
),
),
return _CollectionCard(
collection: item,
onOpen: () => context.go('/collections/${item.id}'),
onEdit: () => showCollectionFormDialog(
context,
ref,
collection: item,
),
onDelete: () async {
final ok = await showConfirmDialog(
context,
title: 'Delete collection',
message: 'Remove ${item.name}?',
);
if (ok) {
await ref
.read(collectionsControllerProvider.notifier)
.delete(item.id);
}
},
);
},
),
@@ -96,3 +68,108 @@ class CollectionsScreen extends ConsumerWidget {
);
}
}
class _CollectionCard extends StatelessWidget {
const _CollectionCard({
required this.collection,
required this.onOpen,
required this.onEdit,
required this.onDelete,
});
final Collection collection;
final VoidCallback onOpen;
final VoidCallback onEdit;
final VoidCallback onDelete;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return Card(
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onOpen,
child: Stack(
children: [
Positioned.fill(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
height: 54,
child: Align(
alignment: Alignment.centerLeft,
child: DecoratedBox(
decoration: BoxDecoration(
color:
scheme.primaryContainer.withValues(alpha: 0.56),
borderRadius: BorderRadius.circular(12),
),
child: const Padding(
padding: EdgeInsets.all(12),
child: Icon(
Icons.collections_bookmark_rounded,
size: 28,
),
),
),
),
),
const Spacer(),
Text(
collection.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 4),
Text(
collection.description ?? 'No description',
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: scheme.onSurfaceVariant,
),
),
],
),
),
),
Positioned(
top: 6,
right: 6,
child: PopupMenuButton<String>(
tooltip: 'Collection actions',
icon: const Icon(Icons.more_horiz_rounded),
onSelected: (value) {
if (value == 'edit') onEdit();
if (value == 'delete') onDelete();
},
itemBuilder: (context) => const [
PopupMenuItem(
value: 'edit',
child: ListTile(
dense: true,
leading: Icon(Icons.edit_rounded),
title: Text('Edit'),
),
),
PopupMenuItem(
value: 'delete',
child: ListTile(
dense: true,
leading: Icon(Icons.delete_rounded),
title: Text('Delete'),
),
),
],
),
),
],
),
),
);
}
}
@@ -6,6 +6,7 @@ import 'package:cached_network_image/cached_network_image.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../../app/app.dart';
import '../../../app/motion.dart';
import '../../../app/responsive.dart';
import '../../../backend/backend.dart';
import '../../../core/utils/result.dart';
@@ -638,7 +639,7 @@ class _NeighborStrip extends StatelessWidget {
onTap: () => onOpen(post),
borderRadius: BorderRadius.circular(8),
child: AnimatedContainer(
duration: const Duration(milliseconds: 140),
duration: AppMotion.duration(context, 140),
width: 72,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
@@ -9,6 +9,7 @@ import 'package:media_kit_video/media_kit_video.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:webview_windows/webview_windows.dart';
import '../../../../app/motion.dart';
import '../../../../backend/backend.dart';
final Map<String, VideoPlaybackSnapshot> _playbackMemory =
@@ -1010,10 +1011,10 @@ class _VideoSurface extends StatelessWidget {
final playing = snapshot.data ?? false;
return AnimatedScale(
scale: controlsVisible || !playing ? 1 : 0.85,
duration: const Duration(milliseconds: 160),
duration: AppMotion.duration(context, 160),
child: AnimatedOpacity(
opacity: controlsVisible || !playing ? 1 : 0,
duration: const Duration(milliseconds: 160),
duration: AppMotion.duration(context, 160),
child: Center(
child: DecoratedBox(
decoration: BoxDecoration(
@@ -1051,7 +1052,7 @@ class _VideoSurface extends StatelessWidget {
ignoring: !controlsVisible || errorMessage != null,
child: AnimatedOpacity(
opacity: controlsVisible || errorMessage != null ? 1 : 0,
duration: const Duration(milliseconds: 180),
duration: AppMotion.duration(context, 180),
child: _VideoControls(
player: player,
muted: muted,
@@ -1176,7 +1177,7 @@ class _RoundControlButton extends StatelessWidget {
onTap: onPressed,
radius: emphasized ? 23 : 21,
child: AnimatedContainer(
duration: const Duration(milliseconds: 140),
duration: AppMotion.duration(context, 140),
width: emphasized ? 38 : 34,
height: emphasized ? 38 : 34,
decoration: BoxDecoration(
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter/gestures.dart';
import 'package:go_router/go_router.dart';
import '../../../../backend/backend.dart';
@@ -23,17 +24,9 @@ class PostTagsPanel extends StatelessWidget {
style: Theme.of(context).textTheme.labelLarge,
),
),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final tag in entry.value.take(80))
TagChip(
tag: tag,
onTap: () =>
context.go('/?q=${Uri.encodeQueryComponent(tag)}'),
),
],
_HorizontalTagRow(
tags: entry.value.take(120).toList(),
onTap: (tag) => context.go('/?q=${Uri.encodeQueryComponent(tag)}'),
),
],
],
@@ -74,3 +67,53 @@ class PostTagsPanel extends StatelessWidget {
};
}
}
class _HorizontalTagRow extends StatefulWidget {
const _HorizontalTagRow({
required this.tags,
required this.onTap,
});
final List<String> tags;
final ValueChanged<String> onTap;
@override
State<_HorizontalTagRow> createState() => _HorizontalTagRowState();
}
class _HorizontalTagRowState extends State<_HorizontalTagRow> {
final _controller = ScrollController();
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return SizedBox(
height: 36,
child: Listener(
onPointerSignal: (event) {
if (event is! PointerScrollEvent || !_controller.hasClients) return;
final target = (_controller.offset + event.scrollDelta.dy)
.clamp(0.0, _controller.position.maxScrollExtent)
.toDouble();
_controller.jumpTo(target);
},
child: ListView.separated(
controller: _controller,
scrollDirection: Axis.horizontal,
primary: false,
itemCount: widget.tags.length,
separatorBuilder: (_, __) => const SizedBox(width: 8),
itemBuilder: (context, index) => TagChip(
tag: widget.tags[index],
onTap: () => widget.onTap(widget.tags[index]),
),
),
),
);
}
}
@@ -88,6 +88,8 @@ class _ProviderFormScreenState extends ConsumerState<ProviderFormScreen> {
value: 'gelbooru', child: Text('gelbooru-compatible')),
DropdownMenuItem(
value: 'rule34', child: Text('rule34-compatible')),
DropdownMenuItem(
value: 'realbooru', child: Text('realbooru-compatible')),
DropdownMenuItem(
value: 'danbooru', child: Text('danbooru-compatible')),
DropdownMenuItem(
@@ -1,3 +1,5 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -6,6 +8,7 @@ import 'package:url_launcher/url_launcher.dart';
import '../../../app/app_version.dart';
import '../../../app/changelog.dart';
import '../../../app/motion.dart';
import '../../../backend/backend.dart';
import '../../../core/utils/result.dart';
import '../../../shared/widgets/adaptive_scaffold.dart';
@@ -23,8 +26,37 @@ class SettingsScreen extends ConsumerWidget {
body: settings.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, _) => ErrorView(message: error.toString()),
data: (settings) => ListView(
padding: const EdgeInsets.all(16),
data: (settings) => _SettingsContent(settings: settings),
),
);
}
}
class _SettingsContent extends ConsumerWidget {
const _SettingsContent({required this.settings});
final AppSettings settings;
@override
Widget build(BuildContext context, WidgetRef ref) {
final isAndroid = Platform.isAndroid;
final deviceInfo =
isAndroid ? ref.watch(motionDeviceInfoProvider).value : null;
final detectedHz =
isAndroid ? View.maybeOf(context)?.display.refreshRate ?? 60.0 : 60.0;
final motion = isAndroid
? resolveMotionSettings(
settings: settings,
detectedHz: detectedHz,
device: deviceInfo,
)
: null;
return ListView(
padding: const EdgeInsets.all(16),
children: [
_SettingsSection(
title: 'General',
icon: Icons.tune_rounded,
children: [
DropdownButtonFormField<String>(
initialValue: settings.themeMode,
@@ -37,19 +69,18 @@ class SettingsScreen extends ConsumerWidget {
onChanged: (value) =>
_update(ref, settings.copyWith(themeMode: value ?? 'dark')),
),
const SizedBox(height: 12),
SwitchListTile(
value: settings.blurExplicitContent,
title: const Text('Blur sensitive previews'),
onChanged: (value) =>
_update(ref, settings.copyWith(blurExplicitContent: value)),
),
SwitchListTile(
value: settings.nsfwEnabled,
title: const Text('Allow NSFW content'),
onChanged: (value) =>
_update(ref, settings.copyWith(nsfwEnabled: value)),
),
SwitchListTile(
value: settings.blurExplicitContent,
title: const Text('Blur sensitive previews'),
onChanged: (value) =>
_update(ref, settings.copyWith(blurExplicitContent: value)),
),
SwitchListTile(
value: settings.showPostBadges,
title: const Text('Show post badges'),
@@ -62,6 +93,12 @@ class SettingsScreen extends ConsumerWidget {
onChanged: (value) =>
_update(ref, settings.copyWith(allowDownloads: value)),
),
],
),
_SettingsSection(
title: 'Feed & Layout',
icon: Icons.dashboard_customize_rounded,
children: [
DropdownButtonFormField<String>(
initialValue: settings.mediaQualityMode,
decoration: const InputDecoration(labelText: 'Media quality'),
@@ -74,25 +111,68 @@ class SettingsScreen extends ConsumerWidget {
settings.copyWith(mediaQualityMode: value ?? 'auto'),
),
),
const SizedBox(height: 8),
FilledButton.tonalIcon(
onPressed: () => context.go('/providers'),
icon: const Icon(Icons.hub_rounded),
label: const Text('Providers'),
if (isAndroid) ...[
const SizedBox(height: 12),
DropdownButtonFormField<String>(
initialValue: settings.motionRefreshMode,
decoration: const InputDecoration(
labelText: 'Animation refresh profile',
),
items: [
for (final mode in MotionRefreshMode.values)
DropdownMenuItem(value: mode.name, child: Text(mode.label)),
],
onChanged: (value) => _update(
ref,
settings.copyWith(motionRefreshMode: value ?? 'auto'),
),
),
SwitchListTile(
value: settings.autoBatterySaver60Hz,
title: const Text('Auto 60 Hz below 20% battery'),
subtitle: Text(
'Detected ${motion!.detectedHz.toStringAsFixed(0)} Hz'
'${motion.batteryLevel == null ? '' : ', battery ${motion.batteryLevel}%'}'
'${motion.batterySaverActive ? ', saver active' : ''}',
),
onChanged: (value) => _update(
ref,
settings.copyWith(autoBatterySaver60Hz: value),
),
),
],
_StepperTile(
title: 'Desktop columns',
value: settings.desktopColumns,
min: 3,
max: 8,
onChanged: (value) => _update(
ref,
settings.copyWith(desktopColumns: value),
),
),
const SizedBox(height: 8),
FilledButton.tonalIcon(
onPressed: () => context.go('/providers/check'),
icon: const Icon(Icons.network_check_rounded),
label: const Text('Provider diagnostics'),
_StepperTile(
title: 'Mobile columns',
value: settings.mobileColumns,
min: 1,
max: 3,
onChanged: (value) => _update(
ref,
settings.copyWith(mobileColumns: value),
),
),
],
),
_SettingsSection(
title: 'Filters',
icon: Icons.filter_alt_rounded,
children: [
SwitchListTile(
value: settings.hideViewedPosts,
title: const Text('Hide viewed posts'),
onChanged: (value) =>
_update(ref, settings.copyWith(hideViewedPosts: value)),
),
const SizedBox(height: 12),
_TagListEditor(
title: 'Smart blacklist',
icon: Icons.visibility_off_rounded,
@@ -114,27 +194,12 @@ class SettingsScreen extends ConsumerWidget {
settings.copyWith(whitelistedTags: tags),
),
),
const SizedBox(height: 12),
_StepperTile(
title: 'Desktop columns',
value: settings.desktopColumns,
min: 3,
max: 8,
onChanged: (value) => _update(
ref,
settings.copyWith(desktopColumns: value),
),
),
_StepperTile(
title: 'Mobile columns',
value: settings.mobileColumns,
min: 1,
max: 3,
onChanged: (value) => _update(
ref,
settings.copyWith(mobileColumns: value),
),
),
],
),
_SettingsSection(
title: 'Storage',
icon: Icons.storage_rounded,
children: [
_StepperTile(
title: 'Cache max items',
value: settings.cacheMaxItems,
@@ -144,109 +209,104 @@ class SettingsScreen extends ConsumerWidget {
onChanged: (value) =>
_update(ref, settings.copyWith(cacheMaxItems: value)),
),
const SizedBox(height: 12),
FilledButton.tonalIcon(
onPressed: () =>
ref.read(settingsControllerProvider.notifier).clearCache(),
icon: const Icon(Icons.cleaning_services_rounded),
label: const Text('Clear cache'),
_ActionGrid(
children: [
FilledButton.tonalIcon(
onPressed: () => ref
.read(settingsControllerProvider.notifier)
.clearCache(),
icon: const Icon(Icons.cleaning_services_rounded),
label: const Text('Clear cache'),
),
FilledButton.tonalIcon(
onPressed: () => ref
.read(settingsControllerProvider.notifier)
.clearViewedHistory(),
icon: const Icon(Icons.history_toggle_off_rounded),
label: const Text('Clear viewed'),
),
FilledButton.tonalIcon(
onPressed: () => ref
.read(settingsControllerProvider.notifier)
.clearHiddenPosts(),
icon: const Icon(Icons.visibility_off_rounded),
label:
Text('Clear hidden (${settings.hiddenPostKeys.length})'),
),
],
),
const SizedBox(height: 12),
FilledButton.tonalIcon(
onPressed: () => ref
.read(settingsControllerProvider.notifier)
.clearHiddenPosts(),
icon: const Icon(Icons.visibility_off_rounded),
label: Text(
'Clear hidden posts (${settings.hiddenPostKeys.length})'),
],
),
_SettingsSection(
title: 'Providers & Diagnostics',
icon: Icons.hub_rounded,
children: [
_ActionGrid(
children: [
FilledButton.tonalIcon(
onPressed: () => context.go('/providers'),
icon: const Icon(Icons.hub_rounded),
label: const Text('Providers'),
),
FilledButton.tonalIcon(
onPressed: () => context.go('/providers/check'),
icon: const Icon(Icons.network_check_rounded),
label: const Text('Diagnostics'),
),
FilledButton.tonalIcon(
onPressed: () => _copyDiagnostics(context, ref),
icon: const Icon(Icons.bug_report_rounded),
label: const Text('Copy report'),
),
FilledButton.tonalIcon(
onPressed: () => _copyLogs(context, ref),
icon: const Icon(Icons.receipt_long_rounded),
label: const Text('Copy logs'),
),
FilledButton.tonalIcon(
onPressed: () => ref
.read(settingsControllerProvider.notifier)
.clearDiagnosticLogs(),
icon: const Icon(Icons.delete_sweep_rounded),
label: const Text('Clear logs'),
),
],
),
const SizedBox(height: 12),
FilledButton.tonalIcon(
onPressed: () => ref
.read(settingsControllerProvider.notifier)
.clearViewedHistory(),
icon: const Icon(Icons.history_toggle_off_rounded),
label: const Text('Clear viewed history'),
],
),
_SettingsSection(
title: 'About',
icon: Icons.info_rounded,
children: [
_ActionGrid(
children: [
FilledButton.tonalIcon(
onPressed: () => _checkUpdates(context, ref),
icon: const Icon(Icons.system_update_alt_rounded),
label: const Text('Check updates'),
),
FilledButton.tonalIcon(
onPressed: () => _showChangelog(context),
icon: const Icon(Icons.new_releases_rounded),
label: const Text('What changed'),
),
FilledButton.tonalIcon(
onPressed: () => _exportJson(context, ref),
icon: const Icon(Icons.upload_file_rounded),
label: const Text('Export JSON'),
),
FilledButton.tonalIcon(
onPressed: () => _importDialog(context, ref),
icon: const Icon(Icons.download_for_offline_rounded),
label: const Text('Import JSON'),
),
],
),
const SizedBox(height: 12),
FilledButton.tonalIcon(
onPressed: () async {
final json = await ref
.read(settingsControllerProvider.notifier)
.exportJson();
await Clipboard.setData(ClipboardData(text: json));
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Settings JSON copied')),
);
}
},
icon: const Icon(Icons.upload_file_rounded),
label: const Text('Export settings and providers JSON'),
),
const SizedBox(height: 12),
FilledButton.tonalIcon(
onPressed: () => _importDialog(context, ref),
icon: const Icon(Icons.download_for_offline_rounded),
label: const Text('Import settings and providers JSON'),
),
const SizedBox(height: 12),
FilledButton.tonalIcon(
onPressed: () => _checkUpdates(context, ref),
icon: const Icon(Icons.system_update_alt_rounded),
label: const Text('Check for updates'),
),
const SizedBox(height: 12),
FilledButton.tonalIcon(
onPressed: () async {
final report = await ref
.read(settingsControllerProvider.notifier)
.diagnosticsReport();
await Clipboard.setData(ClipboardData(text: report));
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Diagnostics copied')),
);
}
},
icon: const Icon(Icons.bug_report_rounded),
label: const Text('Copy diagnostics report'),
),
const SizedBox(height: 12),
FilledButton.tonalIcon(
onPressed: () async {
final logs = await ref
.read(settingsControllerProvider.notifier)
.diagnosticLogs();
await Clipboard.setData(ClipboardData(text: logs));
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Diagnostic logs copied')),
);
}
},
icon: const Icon(Icons.receipt_long_rounded),
label: const Text('Copy local logs'),
),
const SizedBox(height: 12),
FilledButton.tonalIcon(
onPressed: () => ref
.read(settingsControllerProvider.notifier)
.clearDiagnosticLogs(),
icon: const Icon(Icons.delete_sweep_rounded),
label: const Text('Clear local logs'),
),
const SizedBox(height: 12),
FilledButton.tonalIcon(
onPressed: () => _showChangelog(context),
icon: const Icon(Icons.new_releases_rounded),
label: const Text('What changed'),
),
const SizedBox(height: 24),
const SizedBox(height: 8),
const Text('Version $appDisplayVersion ($appBuildNumber)'),
],
),
),
],
);
}
@@ -254,6 +314,36 @@ class SettingsScreen extends ConsumerWidget {
return ref.read(settingsControllerProvider.notifier).saveSettings(settings);
}
Future<void> _copyDiagnostics(BuildContext context, WidgetRef ref) async {
final report =
await ref.read(settingsControllerProvider.notifier).diagnosticsReport();
await Clipboard.setData(ClipboardData(text: report));
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Diagnostics copied')),
);
}
Future<void> _copyLogs(BuildContext context, WidgetRef ref) async {
final logs =
await ref.read(settingsControllerProvider.notifier).diagnosticLogs();
await Clipboard.setData(ClipboardData(text: logs));
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Diagnostic logs copied')),
);
}
Future<void> _exportJson(BuildContext context, WidgetRef ref) async {
final json =
await ref.read(settingsControllerProvider.notifier).exportJson();
await Clipboard.setData(ClipboardData(text: json));
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Settings JSON copied')),
);
}
Future<void> _importDialog(BuildContext context, WidgetRef ref) async {
final controller = TextEditingController();
await showDialog<void>(
@@ -296,9 +386,7 @@ class SettingsScreen extends ConsumerWidget {
if (!context.mounted) return;
messenger.hideCurrentSnackBar();
if (result is Error<AppUpdateInfo?>) {
messenger.showSnackBar(
SnackBar(content: Text(result.failure.message)),
);
messenger.showSnackBar(SnackBar(content: Text(result.failure.message)));
return;
}
final update = (result as Success<AppUpdateInfo?>).data;
@@ -402,6 +490,70 @@ class SettingsScreen extends ConsumerWidget {
}
}
class _SettingsSection extends StatelessWidget {
const _SettingsSection({
required this.title,
required this.icon,
required this.children,
});
final String title;
final IconData icon;
final List<Widget> children;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 14),
child: DecoratedBox(
decoration: BoxDecoration(
border:
Border.all(color: Theme.of(context).colorScheme.outlineVariant),
borderRadius: BorderRadius.circular(12),
),
child: Padding(
padding: const EdgeInsets.all(14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Icon(icon, size: 20),
const SizedBox(width: 8),
Text(title, style: Theme.of(context).textTheme.titleMedium),
],
),
const SizedBox(height: 12),
...children,
],
),
),
),
);
}
}
class _ActionGrid extends StatelessWidget {
const _ActionGrid({required this.children});
final List<Widget> children;
@override
Widget build(BuildContext context) {
return Wrap(
spacing: 10,
runSpacing: 10,
children: [
for (final child in children)
ConstrainedBox(
constraints: const BoxConstraints(minWidth: 180),
child: child,
),
],
);
}
}
class _TagListEditor extends StatefulWidget {
const _TagListEditor({
required this.title,
+76 -41
View File
@@ -3,6 +3,7 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../../app/motion.dart';
import '../../backend/backend.dart';
class AppSearchBar extends StatefulWidget {
@@ -48,6 +49,7 @@ class TagInputSearchBar extends StatefulWidget {
class _TagInputSearchBarState extends State<TagInputSearchBar> {
late final TextEditingController _controller;
late final FocusNode _focusNode;
late final ScrollController _tagScrollController;
Timer? _debounce;
List<String> _tags = [];
@@ -56,6 +58,7 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
super.initState();
_controller = TextEditingController();
_focusNode = FocusNode();
_tagScrollController = ScrollController();
_focusNode.addListener(_handleFocusChanged);
_setFromQuery(widget.initialValue ?? '');
}
@@ -72,6 +75,7 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
@override
void dispose() {
_debounce?.cancel();
_tagScrollController.dispose();
_focusNode.removeListener(_handleFocusChanged);
_focusNode.dispose();
_controller.dispose();
@@ -105,53 +109,64 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 7),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Padding(
padding: const EdgeInsets.only(top: 7),
child: Icon(Icons.search_rounded,
color: scheme.onSurfaceVariant),
),
Icon(Icons.search_rounded, color: scheme.onSurfaceVariant),
const SizedBox(width: 8),
Expanded(
child: Wrap(
spacing: 6,
runSpacing: 6,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
for (final tag in _tags) _buildTagChip(context, tag),
ConstrainedBox(
constraints: const BoxConstraints(
minWidth: 120,
maxWidth: 320,
),
child: Focus(
onKeyEvent: _handleKeyEvent,
child: TextField(
controller: _controller,
focusNode: _focusNode,
autocorrect: false,
enableSuggestions: false,
textInputAction: TextInputAction.search,
onSubmitted: (_) => _submit(),
onChanged: _handleDraftChanged,
decoration: InputDecoration(
isDense: true,
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
filled: false,
contentPadding:
const EdgeInsets.symmetric(vertical: 8),
hintText:
_tags.isEmpty ? widget.hintText : 'tag',
prefixIcon: null,
suffixIcon: null,
child: SizedBox(
height: 38,
child: Scrollbar(
controller: _tagScrollController,
thumbVisibility: false,
child: SingleChildScrollView(
controller: _tagScrollController,
scrollDirection: Axis.horizontal,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
for (final tag in _tags) ...[
_buildTagChip(context, tag),
const SizedBox(width: 6),
],
ConstrainedBox(
constraints: const BoxConstraints(
minWidth: 120,
maxWidth: 300,
),
child: Focus(
onKeyEvent: _handleKeyEvent,
child: TextField(
controller: _controller,
focusNode: _focusNode,
autocorrect: false,
enableSuggestions: false,
textInputAction: TextInputAction.search,
onSubmitted: (_) => _submit(),
onChanged: _handleDraftChanged,
decoration: InputDecoration(
isDense: true,
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
filled: false,
contentPadding:
const EdgeInsets.symmetric(
vertical: 8,
),
hintText: _tags.isEmpty
? widget.hintText
: 'tag',
prefixIcon: null,
suffixIcon: null,
),
),
),
),
),
],
),
),
],
),
),
),
IconButton(
@@ -238,6 +253,7 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
void _commitDraft(String value) {
final additions = _parseTags(value);
if (additions.isEmpty) return;
final keepInputVisible = _isInputNearEnd();
setState(() {
for (final tag in additions) {
if (!_tags.contains(tag)) _tags.add(tag);
@@ -245,6 +261,7 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
_controller.clear();
});
_notifyChanged();
if (keepInputVisible) _scrollTagsToEnd();
}
void _submit() {
@@ -284,6 +301,7 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
void _applySuggestion(String suggestion) {
final draft = _controller.text.trim();
final keepInputVisible = _isInputNearEnd();
setState(() {
if (draft.isNotEmpty) {
final draftTags = _parseTags(draft);
@@ -295,6 +313,7 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
_notifyChanged();
widget.onSuggestionApplied?.call(_query);
_focusNode.requestFocus();
if (keepInputVisible) _scrollTagsToEnd();
}
void _notifyChangedDebounced() {
@@ -322,6 +341,22 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
final all = [..._tags, if (draft.isNotEmpty) draft];
return all.join(' ');
}
void _scrollTagsToEnd() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || !_tagScrollController.hasClients) return;
_tagScrollController.animateTo(
_tagScrollController.position.maxScrollExtent,
duration: AppMotion.duration(context, 120),
curve: Curves.easeOut,
);
});
}
bool _isInputNearEnd() {
if (!_tagScrollController.hasClients) return true;
return _tagScrollController.position.extentAfter < 96;
}
}
class _TagSuggestionDropdown extends StatelessWidget {
+3 -2
View File
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:go_router/go_router.dart';
import '../../app/motion.dart';
import '../../app/responsive.dart';
class AppShell extends StatelessWidget {
@@ -93,7 +94,7 @@ class _DesktopShellState extends State<_DesktopShell> {
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: AnimatedContainer(
duration: const Duration(milliseconds: 180),
duration: AppMotion.duration(context, 180),
curve: Curves.easeOutCubic,
width: _hovered ? 256 : 72,
color: Theme.of(context).navigationRailTheme.backgroundColor,
@@ -174,7 +175,7 @@ class _RailButton extends StatelessWidget {
child: ClipRect(
child: AnimatedOpacity(
opacity: 1,
duration: const Duration(milliseconds: 120),
duration: AppMotion.duration(context, 120),
curve: Curves.easeOut,
child: Align(
alignment: Alignment.centerLeft,
+2 -1
View File
@@ -1,6 +1,7 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import '../../app/motion.dart';
import '../../backend/backend.dart';
import 'blur_content.dart';
import 'loading_skeleton.dart';
@@ -188,7 +189,7 @@ class _PostCardState extends State<PostCard> {
ignoring: !_hovered || mobile,
child: AnimatedOpacity(
opacity: _hovered && !mobile ? 1 : 0,
duration: const Duration(milliseconds: 140),
duration: AppMotion.duration(context, 140),
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(