Release RuleGel 1.0.1 with Realbooru
This commit is contained in:
@@ -1,7 +1,9 @@
|
|||||||
package com.example.gel_rule_app
|
package com.example.gel_rule_app
|
||||||
|
|
||||||
import android.content.ContentValues
|
import android.content.ContentValues
|
||||||
|
import android.content.Context
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
|
import android.os.BatteryManager
|
||||||
import android.os.Environment
|
import android.os.Environment
|
||||||
import android.provider.MediaStore
|
import android.provider.MediaStore
|
||||||
import io.flutter.embedding.android.FlutterActivity
|
import io.flutter.embedding.android.FlutterActivity
|
||||||
@@ -29,6 +31,25 @@ class MainActivity : FlutterActivity() {
|
|||||||
result.error("save_failed", error.message, null)
|
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 {
|
private fun saveToDownloads(path: String, fileName: String, mimeType: String): String {
|
||||||
@@ -61,4 +82,34 @@ class MainActivity : FlutterActivity() {
|
|||||||
}
|
}
|
||||||
return destination.absolutePath
|
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 MyAppName "RuleGel"
|
||||||
#define MyAppVersion "1.0.0"
|
#define MyAppVersion "1.0.1"
|
||||||
#define MyAppPublisher "RarDog"
|
#define MyAppPublisher "RarDog"
|
||||||
#define MyAppExeName "gel_rule_app.exe"
|
#define MyAppExeName "gel_rule_app.exe"
|
||||||
|
|
||||||
|
|||||||
+25
-1
@@ -1,9 +1,12 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
|
||||||
import '../backend/backend.dart';
|
import '../backend/backend.dart';
|
||||||
import '../core/utils/result.dart';
|
import '../core/utils/result.dart';
|
||||||
|
import 'motion.dart';
|
||||||
import 'router.dart';
|
import 'router.dart';
|
||||||
import 'theme.dart';
|
import 'theme.dart';
|
||||||
|
|
||||||
@@ -131,7 +134,28 @@ class _AppOverlayState extends ConsumerState<_AppOverlay> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
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 appDisplayVersion = '1.0.1';
|
||||||
const appBuildNumber = 8;
|
const appBuildNumber = 9;
|
||||||
|
|||||||
@@ -1,4 +1,14 @@
|
|||||||
const ruleGelChangelog = [
|
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(
|
RuleGelChange(
|
||||||
version: '1.0.0',
|
version: '1.0.0',
|
||||||
title: 'Stable 1.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) {
|
static List<dynamic> _extractItems(dynamic data) {
|
||||||
if (data is List) return data;
|
if (data is List) return data;
|
||||||
|
if (data is String) return _extractItemsFromText(data);
|
||||||
if (data is Map) {
|
if (data is Map) {
|
||||||
final posts = data['post'] ?? data['posts'];
|
final posts = data['post'] ?? data['posts'];
|
||||||
if (posts is List) return posts;
|
if (posts is List) return posts;
|
||||||
@@ -64,6 +65,26 @@ class GelbooruMapper {
|
|||||||
return const [];
|
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) {
|
static List<String> _tags(dynamic value) {
|
||||||
if (value is List) return value.map((tag) => tag.toString()).toList();
|
if (value is List) return value.map((tag) => tag.toString()).toList();
|
||||||
return _string(value)
|
return _string(value)
|
||||||
@@ -128,4 +149,14 @@ class GelbooruMapper {
|
|||||||
add('meta', json['tag_string_meta'] ?? json['tags_meta']);
|
add('meta', json['tag_string_meta'] ?? json['tags_meta']);
|
||||||
return groups;
|
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 'e621_provider.dart';
|
||||||
import 'gelbooru_provider.dart';
|
import 'gelbooru_provider.dart';
|
||||||
import 'moebooru_provider.dart';
|
import 'moebooru_provider.dart';
|
||||||
|
import 'realbooru_provider.dart';
|
||||||
import 'rule34_provider.dart';
|
import 'rule34_provider.dart';
|
||||||
|
|
||||||
class ProviderFactory {
|
class ProviderFactory {
|
||||||
@@ -39,6 +40,14 @@ class ProviderFactory {
|
|||||||
dioClient: client,
|
dioClient: client,
|
||||||
queryParameters: queryParameters,
|
queryParameters: queryParameters,
|
||||||
);
|
);
|
||||||
|
case 'realbooru':
|
||||||
|
return RealbooruProvider(
|
||||||
|
id: config.id,
|
||||||
|
name: config.name,
|
||||||
|
baseUrl: config.baseUrl,
|
||||||
|
dioClient: client,
|
||||||
|
queryParameters: queryParameters,
|
||||||
|
);
|
||||||
case 'danbooru':
|
case 'danbooru':
|
||||||
return DanbooruProvider(
|
return DanbooruProvider(
|
||||||
id: config.id,
|
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,
|
createdAt: now,
|
||||||
updatedAt: 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.smartBlacklistRules,
|
||||||
required this.hideViewedPosts,
|
required this.hideViewedPosts,
|
||||||
required this.mediaQualityMode,
|
required this.mediaQualityMode,
|
||||||
|
required this.motionRefreshMode,
|
||||||
|
required this.autoBatterySaver60Hz,
|
||||||
required this.hiddenPostKeys,
|
required this.hiddenPostKeys,
|
||||||
required this.diagnosticLogLines,
|
required this.diagnosticLogLines,
|
||||||
required this.lastFeedTags,
|
required this.lastFeedTags,
|
||||||
@@ -60,6 +62,8 @@ class AppSettings {
|
|||||||
final List<String> smartBlacklistRules;
|
final List<String> smartBlacklistRules;
|
||||||
final bool hideViewedPosts;
|
final bool hideViewedPosts;
|
||||||
final String mediaQualityMode;
|
final String mediaQualityMode;
|
||||||
|
final String motionRefreshMode;
|
||||||
|
final bool autoBatterySaver60Hz;
|
||||||
final List<String> hiddenPostKeys;
|
final List<String> hiddenPostKeys;
|
||||||
final List<String> diagnosticLogLines;
|
final List<String> diagnosticLogLines;
|
||||||
final List<String> lastFeedTags;
|
final List<String> lastFeedTags;
|
||||||
@@ -90,6 +94,8 @@ class AppSettings {
|
|||||||
smartBlacklistRules: [],
|
smartBlacklistRules: [],
|
||||||
hideViewedPosts: false,
|
hideViewedPosts: false,
|
||||||
mediaQualityMode: 'auto',
|
mediaQualityMode: 'auto',
|
||||||
|
motionRefreshMode: 'auto',
|
||||||
|
autoBatterySaver60Hz: true,
|
||||||
hiddenPostKeys: [],
|
hiddenPostKeys: [],
|
||||||
diagnosticLogLines: [],
|
diagnosticLogLines: [],
|
||||||
lastFeedTags: [],
|
lastFeedTags: [],
|
||||||
@@ -121,6 +127,8 @@ class AppSettings {
|
|||||||
List<String>? smartBlacklistRules,
|
List<String>? smartBlacklistRules,
|
||||||
bool? hideViewedPosts,
|
bool? hideViewedPosts,
|
||||||
String? mediaQualityMode,
|
String? mediaQualityMode,
|
||||||
|
String? motionRefreshMode,
|
||||||
|
bool? autoBatterySaver60Hz,
|
||||||
List<String>? hiddenPostKeys,
|
List<String>? hiddenPostKeys,
|
||||||
List<String>? diagnosticLogLines,
|
List<String>? diagnosticLogLines,
|
||||||
List<String>? lastFeedTags,
|
List<String>? lastFeedTags,
|
||||||
@@ -154,6 +162,8 @@ class AppSettings {
|
|||||||
smartBlacklistRules: smartBlacklistRules ?? this.smartBlacklistRules,
|
smartBlacklistRules: smartBlacklistRules ?? this.smartBlacklistRules,
|
||||||
hideViewedPosts: hideViewedPosts ?? this.hideViewedPosts,
|
hideViewedPosts: hideViewedPosts ?? this.hideViewedPosts,
|
||||||
mediaQualityMode: mediaQualityMode ?? this.mediaQualityMode,
|
mediaQualityMode: mediaQualityMode ?? this.mediaQualityMode,
|
||||||
|
motionRefreshMode: motionRefreshMode ?? this.motionRefreshMode,
|
||||||
|
autoBatterySaver60Hz: autoBatterySaver60Hz ?? this.autoBatterySaver60Hz,
|
||||||
hiddenPostKeys: hiddenPostKeys ?? this.hiddenPostKeys,
|
hiddenPostKeys: hiddenPostKeys ?? this.hiddenPostKeys,
|
||||||
diagnosticLogLines: diagnosticLogLines ?? this.diagnosticLogLines,
|
diagnosticLogLines: diagnosticLogLines ?? this.diagnosticLogLines,
|
||||||
lastFeedTags: lastFeedTags ?? this.lastFeedTags,
|
lastFeedTags: lastFeedTags ?? this.lastFeedTags,
|
||||||
@@ -187,6 +197,8 @@ class AppSettings {
|
|||||||
'smartBlacklistRules': smartBlacklistRules,
|
'smartBlacklistRules': smartBlacklistRules,
|
||||||
'hideViewedPosts': hideViewedPosts,
|
'hideViewedPosts': hideViewedPosts,
|
||||||
'mediaQualityMode': mediaQualityMode,
|
'mediaQualityMode': mediaQualityMode,
|
||||||
|
'motionRefreshMode': motionRefreshMode,
|
||||||
|
'autoBatterySaver60Hz': autoBatterySaver60Hz,
|
||||||
'hiddenPostKeys': hiddenPostKeys,
|
'hiddenPostKeys': hiddenPostKeys,
|
||||||
'diagnosticLogLines': diagnosticLogLines,
|
'diagnosticLogLines': diagnosticLogLines,
|
||||||
'lastFeedTags': lastFeedTags,
|
'lastFeedTags': lastFeedTags,
|
||||||
@@ -243,6 +255,10 @@ class AppSettings {
|
|||||||
(json['hideViewedPosts'] as bool?) ?? defaults.hideViewedPosts,
|
(json['hideViewedPosts'] as bool?) ?? defaults.hideViewedPosts,
|
||||||
mediaQualityMode:
|
mediaQualityMode:
|
||||||
(json['mediaQualityMode'] as String?) ?? defaults.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(
|
hiddenPostKeys: List<String>.from(
|
||||||
(json['hiddenPostKeys'] as List?) ?? defaults.hiddenPostKeys,
|
(json['hiddenPostKeys'] as List?) ?? defaults.hiddenPostKeys,
|
||||||
),
|
),
|
||||||
@@ -442,6 +458,8 @@ class SettingsService {
|
|||||||
'nsfwEnabled': settings.nsfwEnabled,
|
'nsfwEnabled': settings.nsfwEnabled,
|
||||||
'blurExplicitContent': settings.blurExplicitContent,
|
'blurExplicitContent': settings.blurExplicitContent,
|
||||||
'mediaQualityMode': settings.mediaQualityMode,
|
'mediaQualityMode': settings.mediaQualityMode,
|
||||||
|
'motionRefreshMode': settings.motionRefreshMode,
|
||||||
|
'autoBatterySaver60Hz': settings.autoBatterySaver60Hz,
|
||||||
'cacheTtlHours': settings.cacheTtlHours,
|
'cacheTtlHours': settings.cacheTtlHours,
|
||||||
'cacheMaxItems': settings.cacheMaxItems,
|
'cacheMaxItems': settings.cacheMaxItems,
|
||||||
'hiddenPosts': settings.hiddenPostKeys.length,
|
'hiddenPosts': settings.hiddenPostKeys.length,
|
||||||
@@ -506,6 +524,7 @@ class SettingsService {
|
|||||||
'blacklistedTags',
|
'blacklistedTags',
|
||||||
'whitelistedTags',
|
'whitelistedTags',
|
||||||
'smartBlacklistRules',
|
'smartBlacklistRules',
|
||||||
|
'hiddenPostKeys',
|
||||||
]) {
|
]) {
|
||||||
settingsJson.putIfAbsent(key, () => filtersJson[key]);
|
settingsJson.putIfAbsent(key, () => filtersJson[key]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,8 +21,6 @@ class UpdateService {
|
|||||||
? settingsResult.data
|
? settingsResult.data
|
||||||
: AppSettings.defaults;
|
: AppSettings.defaults;
|
||||||
|
|
||||||
if (!force && !_shouldCheck(settings)) return const Success(null);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final response = await _dio.get<dynamic>(latestReleaseUrl);
|
final response = await _dio.get<dynamic>(latestReleaseUrl);
|
||||||
final data = Map<String, dynamic>.from(response.data as Map);
|
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) {
|
AppUpdateInfo _releaseFromJson(Map<String, dynamic> json) {
|
||||||
final tag = (json['tag_name'] ?? '').toString();
|
final tag = (json['tag_name'] ?? '').toString();
|
||||||
final assets = (json['assets'] as List?) ?? const [];
|
final assets = (json['assets'] as List?) ?? const [];
|
||||||
|
|||||||
@@ -20,11 +20,17 @@ class CollectionDetailsScreen extends ConsumerWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final posts = ref.watch(collectionPostsProvider(collectionId));
|
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 =
|
final settings =
|
||||||
ref.watch(appSettingsProvider).value ?? AppSettings.defaults;
|
ref.watch(appSettingsProvider).value ?? AppSettings.defaults;
|
||||||
final favoriteKeys = ref.watch(favoriteKeysProvider).value ?? <String>{};
|
final favoriteKeys = ref.watch(favoriteKeysProvider).value ?? <String>{};
|
||||||
return AdaptiveScaffold(
|
return AdaptiveScaffold(
|
||||||
title: 'Collection',
|
title: title,
|
||||||
body: posts.when(
|
body: posts.when(
|
||||||
loading: () => const Center(child: CircularProgressIndicator()),
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
error: (error, _) => ErrorView(message: error.toString()),
|
error: (error, _) => ErrorView(message: error.toString()),
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
import '../../../backend/models/collection.dart';
|
||||||
import '../../../shared/widgets/adaptive_scaffold.dart';
|
import '../../../shared/widgets/adaptive_scaffold.dart';
|
||||||
import '../../../shared/widgets/confirm_dialog.dart';
|
import '../../../shared/widgets/confirm_dialog.dart';
|
||||||
import '../../../shared/widgets/empty_view.dart';
|
import '../../../shared/widgets/empty_view.dart';
|
||||||
@@ -40,35 +41,15 @@ class CollectionsScreen extends ConsumerWidget {
|
|||||||
itemCount: items.length,
|
itemCount: items.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final item = items[index];
|
final item = items[index];
|
||||||
return Card(
|
return _CollectionCard(
|
||||||
clipBehavior: Clip.antiAlias,
|
collection: item,
|
||||||
child: InkWell(
|
onOpen: () => context.go('/collections/${item.id}'),
|
||||||
onTap: () => context.go('/collections/${item.id}'),
|
onEdit: () => showCollectionFormDialog(
|
||||||
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,
|
context,
|
||||||
ref,
|
ref,
|
||||||
collection: item,
|
collection: item,
|
||||||
),
|
),
|
||||||
icon: const Icon(Icons.edit_rounded),
|
onDelete: () async {
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
tooltip: 'Delete',
|
|
||||||
onPressed: () async {
|
|
||||||
final ok = await showConfirmDialog(
|
final ok = await showConfirmDialog(
|
||||||
context,
|
context,
|
||||||
title: 'Delete collection',
|
title: 'Delete collection',
|
||||||
@@ -76,19 +57,10 @@ class CollectionsScreen extends ConsumerWidget {
|
|||||||
);
|
);
|
||||||
if (ok) {
|
if (ok) {
|
||||||
await ref
|
await ref
|
||||||
.read(collectionsControllerProvider
|
.read(collectionsControllerProvider.notifier)
|
||||||
.notifier)
|
|
||||||
.delete(item.id);
|
.delete(item.id);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
icon: const Icon(Icons.delete_rounded),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -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 'package:url_launcher/url_launcher.dart';
|
||||||
|
|
||||||
import '../../../app/app.dart';
|
import '../../../app/app.dart';
|
||||||
|
import '../../../app/motion.dart';
|
||||||
import '../../../app/responsive.dart';
|
import '../../../app/responsive.dart';
|
||||||
import '../../../backend/backend.dart';
|
import '../../../backend/backend.dart';
|
||||||
import '../../../core/utils/result.dart';
|
import '../../../core/utils/result.dart';
|
||||||
@@ -638,7 +639,7 @@ class _NeighborStrip extends StatelessWidget {
|
|||||||
onTap: () => onOpen(post),
|
onTap: () => onOpen(post),
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
child: AnimatedContainer(
|
child: AnimatedContainer(
|
||||||
duration: const Duration(milliseconds: 140),
|
duration: AppMotion.duration(context, 140),
|
||||||
width: 72,
|
width: 72,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
borderRadius: BorderRadius.circular(8),
|
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:url_launcher/url_launcher.dart';
|
||||||
import 'package:webview_windows/webview_windows.dart';
|
import 'package:webview_windows/webview_windows.dart';
|
||||||
|
|
||||||
|
import '../../../../app/motion.dart';
|
||||||
import '../../../../backend/backend.dart';
|
import '../../../../backend/backend.dart';
|
||||||
|
|
||||||
final Map<String, VideoPlaybackSnapshot> _playbackMemory =
|
final Map<String, VideoPlaybackSnapshot> _playbackMemory =
|
||||||
@@ -1010,10 +1011,10 @@ class _VideoSurface extends StatelessWidget {
|
|||||||
final playing = snapshot.data ?? false;
|
final playing = snapshot.data ?? false;
|
||||||
return AnimatedScale(
|
return AnimatedScale(
|
||||||
scale: controlsVisible || !playing ? 1 : 0.85,
|
scale: controlsVisible || !playing ? 1 : 0.85,
|
||||||
duration: const Duration(milliseconds: 160),
|
duration: AppMotion.duration(context, 160),
|
||||||
child: AnimatedOpacity(
|
child: AnimatedOpacity(
|
||||||
opacity: controlsVisible || !playing ? 1 : 0,
|
opacity: controlsVisible || !playing ? 1 : 0,
|
||||||
duration: const Duration(milliseconds: 160),
|
duration: AppMotion.duration(context, 160),
|
||||||
child: Center(
|
child: Center(
|
||||||
child: DecoratedBox(
|
child: DecoratedBox(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
@@ -1051,7 +1052,7 @@ class _VideoSurface extends StatelessWidget {
|
|||||||
ignoring: !controlsVisible || errorMessage != null,
|
ignoring: !controlsVisible || errorMessage != null,
|
||||||
child: AnimatedOpacity(
|
child: AnimatedOpacity(
|
||||||
opacity: controlsVisible || errorMessage != null ? 1 : 0,
|
opacity: controlsVisible || errorMessage != null ? 1 : 0,
|
||||||
duration: const Duration(milliseconds: 180),
|
duration: AppMotion.duration(context, 180),
|
||||||
child: _VideoControls(
|
child: _VideoControls(
|
||||||
player: player,
|
player: player,
|
||||||
muted: muted,
|
muted: muted,
|
||||||
@@ -1176,7 +1177,7 @@ class _RoundControlButton extends StatelessWidget {
|
|||||||
onTap: onPressed,
|
onTap: onPressed,
|
||||||
radius: emphasized ? 23 : 21,
|
radius: emphasized ? 23 : 21,
|
||||||
child: AnimatedContainer(
|
child: AnimatedContainer(
|
||||||
duration: const Duration(milliseconds: 140),
|
duration: AppMotion.duration(context, 140),
|
||||||
width: emphasized ? 38 : 34,
|
width: emphasized ? 38 : 34,
|
||||||
height: emphasized ? 38 : 34,
|
height: emphasized ? 38 : 34,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/gestures.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
import '../../../../backend/backend.dart';
|
import '../../../../backend/backend.dart';
|
||||||
@@ -23,17 +24,9 @@ class PostTagsPanel extends StatelessWidget {
|
|||||||
style: Theme.of(context).textTheme.labelLarge,
|
style: Theme.of(context).textTheme.labelLarge,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Wrap(
|
_HorizontalTagRow(
|
||||||
spacing: 8,
|
tags: entry.value.take(120).toList(),
|
||||||
runSpacing: 8,
|
onTap: (tag) => context.go('/?q=${Uri.encodeQueryComponent(tag)}'),
|
||||||
children: [
|
|
||||||
for (final tag in entry.value.take(80))
|
|
||||||
TagChip(
|
|
||||||
tag: tag,
|
|
||||||
onTap: () =>
|
|
||||||
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')),
|
value: 'gelbooru', child: Text('gelbooru-compatible')),
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
value: 'rule34', child: Text('rule34-compatible')),
|
value: 'rule34', child: Text('rule34-compatible')),
|
||||||
|
DropdownMenuItem(
|
||||||
|
value: 'realbooru', child: Text('realbooru-compatible')),
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
value: 'danbooru', child: Text('danbooru-compatible')),
|
value: 'danbooru', child: Text('danbooru-compatible')),
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.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/app_version.dart';
|
||||||
import '../../../app/changelog.dart';
|
import '../../../app/changelog.dart';
|
||||||
|
import '../../../app/motion.dart';
|
||||||
import '../../../backend/backend.dart';
|
import '../../../backend/backend.dart';
|
||||||
import '../../../core/utils/result.dart';
|
import '../../../core/utils/result.dart';
|
||||||
import '../../../shared/widgets/adaptive_scaffold.dart';
|
import '../../../shared/widgets/adaptive_scaffold.dart';
|
||||||
@@ -23,8 +26,37 @@ class SettingsScreen extends ConsumerWidget {
|
|||||||
body: settings.when(
|
body: settings.when(
|
||||||
loading: () => const Center(child: CircularProgressIndicator()),
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
error: (error, _) => ErrorView(message: error.toString()),
|
error: (error, _) => ErrorView(message: error.toString()),
|
||||||
data: (settings) => ListView(
|
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),
|
padding: const EdgeInsets.all(16),
|
||||||
|
children: [
|
||||||
|
_SettingsSection(
|
||||||
|
title: 'General',
|
||||||
|
icon: Icons.tune_rounded,
|
||||||
children: [
|
children: [
|
||||||
DropdownButtonFormField<String>(
|
DropdownButtonFormField<String>(
|
||||||
initialValue: settings.themeMode,
|
initialValue: settings.themeMode,
|
||||||
@@ -37,19 +69,18 @@ class SettingsScreen extends ConsumerWidget {
|
|||||||
onChanged: (value) =>
|
onChanged: (value) =>
|
||||||
_update(ref, settings.copyWith(themeMode: value ?? 'dark')),
|
_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(
|
SwitchListTile(
|
||||||
value: settings.nsfwEnabled,
|
value: settings.nsfwEnabled,
|
||||||
title: const Text('Allow NSFW content'),
|
title: const Text('Allow NSFW content'),
|
||||||
onChanged: (value) =>
|
onChanged: (value) =>
|
||||||
_update(ref, settings.copyWith(nsfwEnabled: 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(
|
SwitchListTile(
|
||||||
value: settings.showPostBadges,
|
value: settings.showPostBadges,
|
||||||
title: const Text('Show post badges'),
|
title: const Text('Show post badges'),
|
||||||
@@ -62,6 +93,12 @@ class SettingsScreen extends ConsumerWidget {
|
|||||||
onChanged: (value) =>
|
onChanged: (value) =>
|
||||||
_update(ref, settings.copyWith(allowDownloads: value)),
|
_update(ref, settings.copyWith(allowDownloads: value)),
|
||||||
),
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
_SettingsSection(
|
||||||
|
title: 'Feed & Layout',
|
||||||
|
icon: Icons.dashboard_customize_rounded,
|
||||||
|
children: [
|
||||||
DropdownButtonFormField<String>(
|
DropdownButtonFormField<String>(
|
||||||
initialValue: settings.mediaQualityMode,
|
initialValue: settings.mediaQualityMode,
|
||||||
decoration: const InputDecoration(labelText: 'Media quality'),
|
decoration: const InputDecoration(labelText: 'Media quality'),
|
||||||
@@ -74,25 +111,68 @@ class SettingsScreen extends ConsumerWidget {
|
|||||||
settings.copyWith(mediaQualityMode: value ?? 'auto'),
|
settings.copyWith(mediaQualityMode: value ?? 'auto'),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
if (isAndroid) ...[
|
||||||
FilledButton.tonalIcon(
|
const SizedBox(height: 12),
|
||||||
onPressed: () => context.go('/providers'),
|
DropdownButtonFormField<String>(
|
||||||
icon: const Icon(Icons.hub_rounded),
|
initialValue: settings.motionRefreshMode,
|
||||||
label: const Text('Providers'),
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Animation refresh profile',
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
items: [
|
||||||
FilledButton.tonalIcon(
|
for (final mode in MotionRefreshMode.values)
|
||||||
onPressed: () => context.go('/providers/check'),
|
DropdownMenuItem(value: mode.name, child: Text(mode.label)),
|
||||||
icon: const Icon(Icons.network_check_rounded),
|
],
|
||||||
label: const Text('Provider diagnostics'),
|
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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_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(
|
SwitchListTile(
|
||||||
value: settings.hideViewedPosts,
|
value: settings.hideViewedPosts,
|
||||||
title: const Text('Hide viewed posts'),
|
title: const Text('Hide viewed posts'),
|
||||||
onChanged: (value) =>
|
onChanged: (value) =>
|
||||||
_update(ref, settings.copyWith(hideViewedPosts: value)),
|
_update(ref, settings.copyWith(hideViewedPosts: value)),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
|
||||||
_TagListEditor(
|
_TagListEditor(
|
||||||
title: 'Smart blacklist',
|
title: 'Smart blacklist',
|
||||||
icon: Icons.visibility_off_rounded,
|
icon: Icons.visibility_off_rounded,
|
||||||
@@ -114,27 +194,12 @@ class SettingsScreen extends ConsumerWidget {
|
|||||||
settings.copyWith(whitelistedTags: tags),
|
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(
|
_StepperTile(
|
||||||
title: 'Cache max items',
|
title: 'Cache max items',
|
||||||
value: settings.cacheMaxItems,
|
value: settings.cacheMaxItems,
|
||||||
@@ -144,109 +209,104 @@ class SettingsScreen extends ConsumerWidget {
|
|||||||
onChanged: (value) =>
|
onChanged: (value) =>
|
||||||
_update(ref, settings.copyWith(cacheMaxItems: value)),
|
_update(ref, settings.copyWith(cacheMaxItems: value)),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
_ActionGrid(
|
||||||
FilledButton.tonalIcon(
|
children: [
|
||||||
onPressed: () =>
|
|
||||||
ref.read(settingsControllerProvider.notifier).clearCache(),
|
|
||||||
icon: const Icon(Icons.cleaning_services_rounded),
|
|
||||||
label: const Text('Clear cache'),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
FilledButton.tonalIcon(
|
FilledButton.tonalIcon(
|
||||||
onPressed: () => ref
|
onPressed: () => ref
|
||||||
.read(settingsControllerProvider.notifier)
|
.read(settingsControllerProvider.notifier)
|
||||||
.clearHiddenPosts(),
|
.clearCache(),
|
||||||
icon: const Icon(Icons.visibility_off_rounded),
|
icon: const Icon(Icons.cleaning_services_rounded),
|
||||||
label: Text(
|
label: const Text('Clear cache'),
|
||||||
'Clear hidden posts (${settings.hiddenPostKeys.length})'),
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
|
||||||
FilledButton.tonalIcon(
|
FilledButton.tonalIcon(
|
||||||
onPressed: () => ref
|
onPressed: () => ref
|
||||||
.read(settingsControllerProvider.notifier)
|
.read(settingsControllerProvider.notifier)
|
||||||
.clearViewedHistory(),
|
.clearViewedHistory(),
|
||||||
icon: const Icon(Icons.history_toggle_off_rounded),
|
icon: const Icon(Icons.history_toggle_off_rounded),
|
||||||
label: const Text('Clear viewed history'),
|
label: const Text('Clear viewed'),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
|
||||||
FilledButton.tonalIcon(
|
FilledButton.tonalIcon(
|
||||||
onPressed: () async {
|
onPressed: () => ref
|
||||||
final json = await ref
|
|
||||||
.read(settingsControllerProvider.notifier)
|
.read(settingsControllerProvider.notifier)
|
||||||
.exportJson();
|
.clearHiddenPosts(),
|
||||||
await Clipboard.setData(ClipboardData(text: json));
|
icon: const Icon(Icons.visibility_off_rounded),
|
||||||
if (context.mounted) {
|
label:
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
Text('Clear hidden (${settings.hiddenPostKeys.length})'),
|
||||||
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),
|
_SettingsSection(
|
||||||
|
title: 'Providers & Diagnostics',
|
||||||
|
icon: Icons.hub_rounded,
|
||||||
|
children: [
|
||||||
|
_ActionGrid(
|
||||||
|
children: [
|
||||||
FilledButton.tonalIcon(
|
FilledButton.tonalIcon(
|
||||||
onPressed: () async {
|
onPressed: () => context.go('/providers'),
|
||||||
final report = await ref
|
icon: const Icon(Icons.hub_rounded),
|
||||||
.read(settingsControllerProvider.notifier)
|
label: const Text('Providers'),
|
||||||
.diagnosticsReport();
|
),
|
||||||
await Clipboard.setData(ClipboardData(text: report));
|
FilledButton.tonalIcon(
|
||||||
if (context.mounted) {
|
onPressed: () => context.go('/providers/check'),
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
icon: const Icon(Icons.network_check_rounded),
|
||||||
const SnackBar(content: Text('Diagnostics copied')),
|
label: const Text('Diagnostics'),
|
||||||
);
|
),
|
||||||
}
|
FilledButton.tonalIcon(
|
||||||
},
|
onPressed: () => _copyDiagnostics(context, ref),
|
||||||
icon: const Icon(Icons.bug_report_rounded),
|
icon: const Icon(Icons.bug_report_rounded),
|
||||||
label: const Text('Copy diagnostics report'),
|
label: const Text('Copy report'),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
|
||||||
FilledButton.tonalIcon(
|
FilledButton.tonalIcon(
|
||||||
onPressed: () async {
|
onPressed: () => _copyLogs(context, ref),
|
||||||
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),
|
icon: const Icon(Icons.receipt_long_rounded),
|
||||||
label: const Text('Copy local logs'),
|
label: const Text('Copy logs'),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
|
||||||
FilledButton.tonalIcon(
|
FilledButton.tonalIcon(
|
||||||
onPressed: () => ref
|
onPressed: () => ref
|
||||||
.read(settingsControllerProvider.notifier)
|
.read(settingsControllerProvider.notifier)
|
||||||
.clearDiagnosticLogs(),
|
.clearDiagnosticLogs(),
|
||||||
icon: const Icon(Icons.delete_sweep_rounded),
|
icon: const Icon(Icons.delete_sweep_rounded),
|
||||||
label: const Text('Clear local logs'),
|
label: const Text('Clear logs'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
_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'),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
|
||||||
FilledButton.tonalIcon(
|
FilledButton.tonalIcon(
|
||||||
onPressed: () => _showChangelog(context),
|
onPressed: () => _showChangelog(context),
|
||||||
icon: const Icon(Icons.new_releases_rounded),
|
icon: const Icon(Icons.new_releases_rounded),
|
||||||
label: const Text('What changed'),
|
label: const Text('What changed'),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
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: 8),
|
||||||
const Text('Version $appDisplayVersion ($appBuildNumber)'),
|
const Text('Version $appDisplayVersion ($appBuildNumber)'),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -254,6 +314,36 @@ class SettingsScreen extends ConsumerWidget {
|
|||||||
return ref.read(settingsControllerProvider.notifier).saveSettings(settings);
|
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 {
|
Future<void> _importDialog(BuildContext context, WidgetRef ref) async {
|
||||||
final controller = TextEditingController();
|
final controller = TextEditingController();
|
||||||
await showDialog<void>(
|
await showDialog<void>(
|
||||||
@@ -296,9 +386,7 @@ class SettingsScreen extends ConsumerWidget {
|
|||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
messenger.hideCurrentSnackBar();
|
messenger.hideCurrentSnackBar();
|
||||||
if (result is Error<AppUpdateInfo?>) {
|
if (result is Error<AppUpdateInfo?>) {
|
||||||
messenger.showSnackBar(
|
messenger.showSnackBar(SnackBar(content: Text(result.failure.message)));
|
||||||
SnackBar(content: Text(result.failure.message)),
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final update = (result as Success<AppUpdateInfo?>).data;
|
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 {
|
class _TagListEditor extends StatefulWidget {
|
||||||
const _TagListEditor({
|
const _TagListEditor({
|
||||||
required this.title,
|
required this.title,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'dart:async';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
|
import '../../app/motion.dart';
|
||||||
import '../../backend/backend.dart';
|
import '../../backend/backend.dart';
|
||||||
|
|
||||||
class AppSearchBar extends StatefulWidget {
|
class AppSearchBar extends StatefulWidget {
|
||||||
@@ -48,6 +49,7 @@ class TagInputSearchBar extends StatefulWidget {
|
|||||||
class _TagInputSearchBarState extends State<TagInputSearchBar> {
|
class _TagInputSearchBarState extends State<TagInputSearchBar> {
|
||||||
late final TextEditingController _controller;
|
late final TextEditingController _controller;
|
||||||
late final FocusNode _focusNode;
|
late final FocusNode _focusNode;
|
||||||
|
late final ScrollController _tagScrollController;
|
||||||
Timer? _debounce;
|
Timer? _debounce;
|
||||||
List<String> _tags = [];
|
List<String> _tags = [];
|
||||||
|
|
||||||
@@ -56,6 +58,7 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
|
|||||||
super.initState();
|
super.initState();
|
||||||
_controller = TextEditingController();
|
_controller = TextEditingController();
|
||||||
_focusNode = FocusNode();
|
_focusNode = FocusNode();
|
||||||
|
_tagScrollController = ScrollController();
|
||||||
_focusNode.addListener(_handleFocusChanged);
|
_focusNode.addListener(_handleFocusChanged);
|
||||||
_setFromQuery(widget.initialValue ?? '');
|
_setFromQuery(widget.initialValue ?? '');
|
||||||
}
|
}
|
||||||
@@ -72,6 +75,7 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
|
|||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_debounce?.cancel();
|
_debounce?.cancel();
|
||||||
|
_tagScrollController.dispose();
|
||||||
_focusNode.removeListener(_handleFocusChanged);
|
_focusNode.removeListener(_handleFocusChanged);
|
||||||
_focusNode.dispose();
|
_focusNode.dispose();
|
||||||
_controller.dispose();
|
_controller.dispose();
|
||||||
@@ -105,25 +109,30 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
|
|||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 7),
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 7),
|
||||||
child: Row(
|
child: Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Padding(
|
Icon(Icons.search_rounded, color: scheme.onSurfaceVariant),
|
||||||
padding: const EdgeInsets.only(top: 7),
|
|
||||||
child: Icon(Icons.search_rounded,
|
|
||||||
color: scheme.onSurfaceVariant),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Wrap(
|
child: SizedBox(
|
||||||
spacing: 6,
|
height: 38,
|
||||||
runSpacing: 6,
|
child: Scrollbar(
|
||||||
crossAxisAlignment: WrapCrossAlignment.center,
|
controller: _tagScrollController,
|
||||||
|
thumbVisibility: false,
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
controller: _tagScrollController,
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
for (final tag in _tags) _buildTagChip(context, tag),
|
for (final tag in _tags) ...[
|
||||||
|
_buildTagChip(context, tag),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
],
|
||||||
ConstrainedBox(
|
ConstrainedBox(
|
||||||
constraints: const BoxConstraints(
|
constraints: const BoxConstraints(
|
||||||
minWidth: 120,
|
minWidth: 120,
|
||||||
maxWidth: 320,
|
maxWidth: 300,
|
||||||
),
|
),
|
||||||
child: Focus(
|
child: Focus(
|
||||||
onKeyEvent: _handleKeyEvent,
|
onKeyEvent: _handleKeyEvent,
|
||||||
@@ -142,9 +151,12 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
|
|||||||
focusedBorder: InputBorder.none,
|
focusedBorder: InputBorder.none,
|
||||||
filled: false,
|
filled: false,
|
||||||
contentPadding:
|
contentPadding:
|
||||||
const EdgeInsets.symmetric(vertical: 8),
|
const EdgeInsets.symmetric(
|
||||||
hintText:
|
vertical: 8,
|
||||||
_tags.isEmpty ? widget.hintText : 'tag',
|
),
|
||||||
|
hintText: _tags.isEmpty
|
||||||
|
? widget.hintText
|
||||||
|
: 'tag',
|
||||||
prefixIcon: null,
|
prefixIcon: null,
|
||||||
suffixIcon: null,
|
suffixIcon: null,
|
||||||
),
|
),
|
||||||
@@ -154,6 +166,9 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
tooltip: 'Clear',
|
tooltip: 'Clear',
|
||||||
visualDensity: VisualDensity.compact,
|
visualDensity: VisualDensity.compact,
|
||||||
@@ -238,6 +253,7 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
|
|||||||
void _commitDraft(String value) {
|
void _commitDraft(String value) {
|
||||||
final additions = _parseTags(value);
|
final additions = _parseTags(value);
|
||||||
if (additions.isEmpty) return;
|
if (additions.isEmpty) return;
|
||||||
|
final keepInputVisible = _isInputNearEnd();
|
||||||
setState(() {
|
setState(() {
|
||||||
for (final tag in additions) {
|
for (final tag in additions) {
|
||||||
if (!_tags.contains(tag)) _tags.add(tag);
|
if (!_tags.contains(tag)) _tags.add(tag);
|
||||||
@@ -245,6 +261,7 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
|
|||||||
_controller.clear();
|
_controller.clear();
|
||||||
});
|
});
|
||||||
_notifyChanged();
|
_notifyChanged();
|
||||||
|
if (keepInputVisible) _scrollTagsToEnd();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _submit() {
|
void _submit() {
|
||||||
@@ -284,6 +301,7 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
|
|||||||
|
|
||||||
void _applySuggestion(String suggestion) {
|
void _applySuggestion(String suggestion) {
|
||||||
final draft = _controller.text.trim();
|
final draft = _controller.text.trim();
|
||||||
|
final keepInputVisible = _isInputNearEnd();
|
||||||
setState(() {
|
setState(() {
|
||||||
if (draft.isNotEmpty) {
|
if (draft.isNotEmpty) {
|
||||||
final draftTags = _parseTags(draft);
|
final draftTags = _parseTags(draft);
|
||||||
@@ -295,6 +313,7 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
|
|||||||
_notifyChanged();
|
_notifyChanged();
|
||||||
widget.onSuggestionApplied?.call(_query);
|
widget.onSuggestionApplied?.call(_query);
|
||||||
_focusNode.requestFocus();
|
_focusNode.requestFocus();
|
||||||
|
if (keepInputVisible) _scrollTagsToEnd();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _notifyChangedDebounced() {
|
void _notifyChangedDebounced() {
|
||||||
@@ -322,6 +341,22 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
|
|||||||
final all = [..._tags, if (draft.isNotEmpty) draft];
|
final all = [..._tags, if (draft.isNotEmpty) draft];
|
||||||
return all.join(' ');
|
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 {
|
class _TagSuggestionDropdown extends StatelessWidget {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
import '../../app/motion.dart';
|
||||||
import '../../app/responsive.dart';
|
import '../../app/responsive.dart';
|
||||||
|
|
||||||
class AppShell extends StatelessWidget {
|
class AppShell extends StatelessWidget {
|
||||||
@@ -93,7 +94,7 @@ class _DesktopShellState extends State<_DesktopShell> {
|
|||||||
onEnter: (_) => setState(() => _hovered = true),
|
onEnter: (_) => setState(() => _hovered = true),
|
||||||
onExit: (_) => setState(() => _hovered = false),
|
onExit: (_) => setState(() => _hovered = false),
|
||||||
child: AnimatedContainer(
|
child: AnimatedContainer(
|
||||||
duration: const Duration(milliseconds: 180),
|
duration: AppMotion.duration(context, 180),
|
||||||
curve: Curves.easeOutCubic,
|
curve: Curves.easeOutCubic,
|
||||||
width: _hovered ? 256 : 72,
|
width: _hovered ? 256 : 72,
|
||||||
color: Theme.of(context).navigationRailTheme.backgroundColor,
|
color: Theme.of(context).navigationRailTheme.backgroundColor,
|
||||||
@@ -174,7 +175,7 @@ class _RailButton extends StatelessWidget {
|
|||||||
child: ClipRect(
|
child: ClipRect(
|
||||||
child: AnimatedOpacity(
|
child: AnimatedOpacity(
|
||||||
opacity: 1,
|
opacity: 1,
|
||||||
duration: const Duration(milliseconds: 120),
|
duration: AppMotion.duration(context, 120),
|
||||||
curve: Curves.easeOut,
|
curve: Curves.easeOut,
|
||||||
child: Align(
|
child: Align(
|
||||||
alignment: Alignment.centerLeft,
|
alignment: Alignment.centerLeft,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:cached_network_image/cached_network_image.dart';
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../../app/motion.dart';
|
||||||
import '../../backend/backend.dart';
|
import '../../backend/backend.dart';
|
||||||
import 'blur_content.dart';
|
import 'blur_content.dart';
|
||||||
import 'loading_skeleton.dart';
|
import 'loading_skeleton.dart';
|
||||||
@@ -188,7 +189,7 @@ class _PostCardState extends State<PostCard> {
|
|||||||
ignoring: !_hovered || mobile,
|
ignoring: !_hovered || mobile,
|
||||||
child: AnimatedOpacity(
|
child: AnimatedOpacity(
|
||||||
opacity: _hovered && !mobile ? 1 : 0,
|
opacity: _hovered && !mobile ? 1 : 0,
|
||||||
duration: const Duration(milliseconds: 140),
|
duration: AppMotion.duration(context, 140),
|
||||||
child: DecoratedBox(
|
child: DecoratedBox(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
gradient: LinearGradient(
|
gradient: LinearGradient(
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
name: gel_rule_app
|
name: gel_rule_app
|
||||||
description: Local backend/core layer for a Flutter booru browser app.
|
description: Local backend/core layer for a Flutter booru browser app.
|
||||||
publish_to: "none"
|
publish_to: "none"
|
||||||
version: 1.0.0+8
|
version: 1.0.1+9
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ">=3.3.0 <4.0.0"
|
sdk: ">=3.3.0 <4.0.0"
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ void main() {
|
|||||||
'yandere',
|
'yandere',
|
||||||
'e621',
|
'e621',
|
||||||
'e926',
|
'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/gelbooru_mapper.dart';
|
||||||
import 'package:gel_rule_app/backend/mappers/moebooru_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/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() {
|
void main() {
|
||||||
test('parses Gelbooru array response', () {
|
test('parses Gelbooru array response', () {
|
||||||
@@ -46,6 +50,23 @@ void main() {
|
|||||||
expect(posts.single.fileType, 'gif');
|
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', () {
|
test('parses Rule34 compatible response', () {
|
||||||
final posts = Rule34Mapper.postsFromResponse(
|
final posts = Rule34Mapper.postsFromResponse(
|
||||||
{
|
{
|
||||||
@@ -183,4 +204,32 @@ void main() {
|
|||||||
expect(posts.single.fileUrl, '');
|
expect(posts.single.fileUrl, '');
|
||||||
expect(posts.single.rating, 'unknown');
|
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(find.text('touhou'), findsWidgets);
|
||||||
expect(applied, 'touhou');
|
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 {
|
class _Harness extends StatelessWidget {
|
||||||
|
|||||||
Reference in New Issue
Block a user