Release RuleGel 1.0.1 with Realbooru
This commit is contained in:
@@ -1,7 +1,9 @@
|
||||
package com.example.gel_rule_app
|
||||
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.os.BatteryManager
|
||||
import android.os.Environment
|
||||
import android.provider.MediaStore
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
@@ -29,6 +31,25 @@ class MainActivity : FlutterActivity() {
|
||||
result.error("save_failed", error.message, null)
|
||||
}
|
||||
}
|
||||
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "rulegel/device")
|
||||
.setMethodCallHandler { call, result ->
|
||||
try {
|
||||
when (call.method) {
|
||||
"getBatteryLevel" -> result.success(batteryLevel())
|
||||
"getSupportedRefreshRates" -> result.success(supportedRefreshRates())
|
||||
"setPreferredRefreshRate" -> {
|
||||
val hz = call.argument<Double>("hz")
|
||||
?: call.argument<Int>("hz")?.toDouble()
|
||||
?: 60.0
|
||||
setPreferredRefreshRate(hz.toFloat())
|
||||
result.success(null)
|
||||
}
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
} catch (error: Throwable) {
|
||||
result.error("device_failed", error.message, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveToDownloads(path: String, fileName: String, mimeType: String): String {
|
||||
@@ -61,4 +82,34 @@ class MainActivity : FlutterActivity() {
|
||||
}
|
||||
return destination.absolutePath
|
||||
}
|
||||
|
||||
private fun batteryLevel(): Int? {
|
||||
val manager = getSystemService(Context.BATTERY_SERVICE) as? BatteryManager ?: return null
|
||||
val level = manager.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY)
|
||||
return if (level in 0..100) level else null
|
||||
}
|
||||
|
||||
private fun supportedRefreshRates(): List<Double> {
|
||||
val display = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) display else windowManager.defaultDisplay
|
||||
return display?.supportedModes
|
||||
?.map { it.refreshRate.toDouble() }
|
||||
?.distinct()
|
||||
?.sorted()
|
||||
?: emptyList()
|
||||
}
|
||||
|
||||
private fun setPreferredRefreshRate(targetHz: Float) {
|
||||
val display = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) display else windowManager.defaultDisplay
|
||||
val mode = display?.supportedModes?.minByOrNull {
|
||||
kotlin.math.abs(it.refreshRate - targetHz)
|
||||
} ?: return
|
||||
val attrs = window.attributes
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
attrs.preferredDisplayModeId = mode.modeId
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
attrs.preferredRefreshRate = mode.refreshRate
|
||||
}
|
||||
window.attributes = attrs
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#define MyAppName "RuleGel"
|
||||
#define MyAppVersion "1.0.0"
|
||||
#define MyAppVersion "1.0.1"
|
||||
#define MyAppPublisher "RarDog"
|
||||
#define MyAppExeName "gel_rule_app.exe"
|
||||
|
||||
|
||||
+25
-1
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
const appDisplayVersion = '1.0.0';
|
||||
const appBuildNumber = 8;
|
||||
const appDisplayVersion = '1.0.1';
|
||||
const appBuildNumber = 9;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll(''', "'")
|
||||
.replaceAll(''', "'");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
name: gel_rule_app
|
||||
description: Local backend/core layer for a Flutter booru browser app.
|
||||
publish_to: "none"
|
||||
version: 1.0.0+8
|
||||
version: 1.0.1+9
|
||||
|
||||
environment:
|
||||
sdk: ">=3.3.0 <4.0.0"
|
||||
|
||||
@@ -72,6 +72,7 @@ void main() {
|
||||
'yandere',
|
||||
'e621',
|
||||
'e926',
|
||||
'realbooru',
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:gel_rule_app/app/motion.dart';
|
||||
import 'package:gel_rule_app/backend/services/settings_service.dart';
|
||||
|
||||
void main() {
|
||||
test('app settings json roundtrip keeps motion settings', () {
|
||||
final settings = AppSettings.defaults.copyWith(
|
||||
motionRefreshMode: MotionRefreshMode.hz144.name,
|
||||
autoBatterySaver60Hz: false,
|
||||
);
|
||||
|
||||
final restored = AppSettings.fromJson(settings.toJson());
|
||||
|
||||
expect(restored.motionRefreshMode, MotionRefreshMode.hz144.name);
|
||||
expect(restored.autoBatterySaver60Hz, isFalse);
|
||||
});
|
||||
|
||||
test('battery saver forces effective 60hz below 20 percent', () {
|
||||
final motion = resolveMotionSettings(
|
||||
settings: AppSettings.defaults.copyWith(
|
||||
motionRefreshMode: MotionRefreshMode.hz165.name,
|
||||
autoBatterySaver60Hz: true,
|
||||
),
|
||||
detectedHz: 165,
|
||||
device: const DeviceMotionInfo(
|
||||
batteryLevel: 12,
|
||||
supportedRefreshRates: [60, 120, 165],
|
||||
),
|
||||
);
|
||||
|
||||
expect(motion.batterySaverActive, isTrue);
|
||||
expect(motion.effectiveHz, 60);
|
||||
});
|
||||
|
||||
test('unsupported requested refresh uses nearest supported mode', () {
|
||||
final motion = resolveMotionSettings(
|
||||
settings: AppSettings.defaults.copyWith(
|
||||
motionRefreshMode: MotionRefreshMode.hz144.name,
|
||||
),
|
||||
detectedHz: 144,
|
||||
device: const DeviceMotionInfo(
|
||||
supportedRefreshRates: [60, 120],
|
||||
),
|
||||
);
|
||||
|
||||
expect(motion.effectiveHz, 120);
|
||||
});
|
||||
}
|
||||
@@ -4,6 +4,10 @@ import 'package:gel_rule_app/backend/mappers/e621_mapper.dart';
|
||||
import 'package:gel_rule_app/backend/mappers/gelbooru_mapper.dart';
|
||||
import 'package:gel_rule_app/backend/mappers/moebooru_mapper.dart';
|
||||
import 'package:gel_rule_app/backend/mappers/rule34_mapper.dart';
|
||||
import 'package:gel_rule_app/backend/models/content_provider_config.dart';
|
||||
import 'package:gel_rule_app/backend/providers/provider_factory.dart';
|
||||
import 'package:gel_rule_app/backend/providers/realbooru_provider.dart';
|
||||
import 'package:gel_rule_app/backend/repositories/provider_repository.dart';
|
||||
|
||||
void main() {
|
||||
test('parses Gelbooru array response', () {
|
||||
@@ -46,6 +50,23 @@ void main() {
|
||||
expect(posts.single.fileType, 'gif');
|
||||
});
|
||||
|
||||
test('parses Gelbooru compatible XML post response', () {
|
||||
final posts = GelbooruMapper.postsFromResponse(
|
||||
'''
|
||||
<posts count="1">
|
||||
<post id="7" file_url="https://example.test/r.jpg" preview_url="https://example.test/r-preview.jpg" tags="real booru" rating="explicit" width="300" height="400" score="12" />
|
||||
</posts>
|
||||
''',
|
||||
providerId: 'realbooru',
|
||||
providerName: 'Realbooru',
|
||||
);
|
||||
|
||||
expect(posts.single.id, '7');
|
||||
expect(posts.single.providerId, 'realbooru');
|
||||
expect(posts.single.tags, ['real', 'booru']);
|
||||
expect(posts.single.rating, 'explicit');
|
||||
});
|
||||
|
||||
test('parses Rule34 compatible response', () {
|
||||
final posts = Rule34Mapper.postsFromResponse(
|
||||
{
|
||||
@@ -183,4 +204,32 @@ void main() {
|
||||
expect(posts.single.fileUrl, '');
|
||||
expect(posts.single.rating, 'unknown');
|
||||
});
|
||||
|
||||
test('seed providers include enabled Realbooru', () {
|
||||
final realbooru = ProviderRepository.seedProviders()
|
||||
.where((provider) => provider.id == 'realbooru')
|
||||
.single;
|
||||
|
||||
expect(realbooru.enabled, isTrue);
|
||||
expect(realbooru.apiType, 'realbooru');
|
||||
expect(realbooru.baseUrl, 'https://realbooru.com');
|
||||
});
|
||||
|
||||
test('provider factory creates Realbooru provider', () {
|
||||
final now = DateTime(2026);
|
||||
final provider = ProviderFactory().create(ContentProviderConfig(
|
||||
id: 'realbooru',
|
||||
name: 'Realbooru',
|
||||
baseUrl: 'https://realbooru.com',
|
||||
apiType: 'realbooru',
|
||||
enabled: true,
|
||||
priority: 7,
|
||||
timeoutSeconds: 20,
|
||||
customHeaders: const {},
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
));
|
||||
|
||||
expect(provider, isA<RealbooruProvider>());
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:gel_rule_app/backend/models/post.dart';
|
||||
import 'package:gel_rule_app/features/post/presentation/widgets/post_tags_panel.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('post tags panel renders category rows compactly',
|
||||
(tester) async {
|
||||
final router = GoRouter(
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: '/',
|
||||
builder: (_, __) => Scaffold(
|
||||
body: PostTagsPanel(
|
||||
post: _post(
|
||||
tagGroups: {
|
||||
'artist': ['artist_name'],
|
||||
'character': ['hero'],
|
||||
'general': List.generate(30, (index) => 'tag_$index'),
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
await tester.pumpWidget(MaterialApp.router(routerConfig: router));
|
||||
|
||||
expect(find.text('Artist'), findsOneWidget);
|
||||
expect(find.text('Character'), findsOneWidget);
|
||||
expect(find.text('General'), findsOneWidget);
|
||||
expect(find.text('tag_0'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
|
||||
Post _post({required Map<String, List<String>> tagGroups}) {
|
||||
return Post(
|
||||
id: '1',
|
||||
providerId: 'test',
|
||||
providerName: 'Test',
|
||||
previewUrl: 'https://example.test/preview.jpg',
|
||||
sampleUrl: 'https://example.test/sample.jpg',
|
||||
fileUrl: 'https://example.test/file.jpg',
|
||||
tags: const [],
|
||||
rating: 'general',
|
||||
width: 100,
|
||||
height: 100,
|
||||
createdAt: DateTime(2026),
|
||||
fileType: 'photo',
|
||||
score: 1,
|
||||
tagGroups: tagGroups,
|
||||
);
|
||||
}
|
||||
@@ -84,6 +84,24 @@ void main() {
|
||||
expect(find.text('touhou'), findsWidgets);
|
||||
expect(applied, 'touhou');
|
||||
});
|
||||
|
||||
testWidgets('many chips stay in a single compact row', (tester) async {
|
||||
await tester.pumpWidget(_Harness(
|
||||
child: SizedBox(
|
||||
width: 320,
|
||||
child: TagInputSearchBar(
|
||||
initialValue:
|
||||
'touhou video hakurei_reimu kirisame_marisa kochiya_sanae cirno',
|
||||
onSubmitted: (_) {},
|
||||
),
|
||||
),
|
||||
));
|
||||
|
||||
final size = tester.getSize(find.byType(TagInputSearchBar));
|
||||
|
||||
expect(size.height, lessThan(96));
|
||||
expect(find.text('touhou'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
|
||||
class _Harness extends StatelessWidget {
|
||||
|
||||
Reference in New Issue
Block a user