Prepare RuleGel 1.0

This commit is contained in:
2026-05-24 22:06:15 +03:00
parent 1adfc1fa0c
commit 16e86fb9d5
18 changed files with 413 additions and 6 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
#define MyAppName "RuleGel"
#define MyAppVersion "0.4.3"
#define MyAppVersion "1.0.0"
#define MyAppPublisher "RarDog"
#define MyAppExeName "gel_rule_app.exe"
+2 -2
View File
@@ -1,2 +1,2 @@
const appDisplayVersion = '0.4.3';
const appBuildNumber = 7;
const appDisplayVersion = '1.0.0';
const appBuildNumber = 8;
+34
View File
@@ -0,0 +1,34 @@
const ruleGelChangelog = [
RuleGelChange(
version: '1.0.0',
title: 'Stable 1.0',
bullets: [
'Local hide-post organization for posts you do not want in the feed.',
'Diagnostics report and local log copy tools in Settings.',
'Cleaner release metadata and maintenance tools for troubleshooting.',
'Windows portable package is published alongside APK and installer.',
],
),
RuleGelChange(
version: '0.4.3',
title: 'Bugfixes',
bullets: [
'Fixed video play/retry controls.',
'Fixed tag chip input and tag navigation from details.',
'Fixed collection deletion persistence.',
'Settings export now includes blacklist and whitelist data.',
],
),
];
class RuleGelChange {
const RuleGelChange({
required this.version,
required this.title,
required this.bullets,
});
final String version;
final String title;
final List<String> bullets;
}
+1 -1
View File
@@ -168,7 +168,7 @@ final updateServiceProvider = Provider<UpdateService>((ref) {
connectTimeout: const Duration(seconds: 12),
receiveTimeout: const Duration(seconds: 12),
headers: const {
'User-Agent': 'RuleGel/0.4 Flutter local booru browser',
'User-Agent': 'RuleGel/1.0 Flutter local booru browser',
'Accept': 'application/json',
},
),
+2
View File
@@ -122,6 +122,8 @@ bool postMatchesRequestedTags(Post post, List<String> requestedTags) {
}
bool postPassesTagFilters(Post post, AppSettings settings) {
if (settings.hiddenPostKeys.contains(post.cacheKey)) return false;
final tags = _postTagSet(post);
if (tags.isEmpty) return settings.whitelistedTags.isEmpty;
+138
View File
@@ -7,6 +7,7 @@ import '../../core/database/database_service.dart';
import '../../core/errors/failure.dart';
import '../../core/utils/result.dart';
import '../models/content_provider_config.dart';
import '../models/provider_diagnostics.dart';
import '../repositories/provider_repository.dart';
class AppSettings {
@@ -29,6 +30,8 @@ class AppSettings {
required this.smartBlacklistRules,
required this.hideViewedPosts,
required this.mediaQualityMode,
required this.hiddenPostKeys,
required this.diagnosticLogLines,
required this.lastFeedTags,
required this.lastFeedProviderIds,
required this.lastFeedTopPeriod,
@@ -57,6 +60,8 @@ class AppSettings {
final List<String> smartBlacklistRules;
final bool hideViewedPosts;
final String mediaQualityMode;
final List<String> hiddenPostKeys;
final List<String> diagnosticLogLines;
final List<String> lastFeedTags;
final List<String> lastFeedProviderIds;
final String lastFeedTopPeriod;
@@ -85,6 +90,8 @@ class AppSettings {
smartBlacklistRules: [],
hideViewedPosts: false,
mediaQualityMode: 'auto',
hiddenPostKeys: [],
diagnosticLogLines: [],
lastFeedTags: [],
lastFeedProviderIds: [],
lastFeedTopPeriod: 'none',
@@ -114,6 +121,8 @@ class AppSettings {
List<String>? smartBlacklistRules,
bool? hideViewedPosts,
String? mediaQualityMode,
List<String>? hiddenPostKeys,
List<String>? diagnosticLogLines,
List<String>? lastFeedTags,
List<String>? lastFeedProviderIds,
String? lastFeedTopPeriod,
@@ -145,6 +154,8 @@ class AppSettings {
smartBlacklistRules: smartBlacklistRules ?? this.smartBlacklistRules,
hideViewedPosts: hideViewedPosts ?? this.hideViewedPosts,
mediaQualityMode: mediaQualityMode ?? this.mediaQualityMode,
hiddenPostKeys: hiddenPostKeys ?? this.hiddenPostKeys,
diagnosticLogLines: diagnosticLogLines ?? this.diagnosticLogLines,
lastFeedTags: lastFeedTags ?? this.lastFeedTags,
lastFeedProviderIds: lastFeedProviderIds ?? this.lastFeedProviderIds,
lastFeedTopPeriod: lastFeedTopPeriod ?? this.lastFeedTopPeriod,
@@ -176,6 +187,8 @@ class AppSettings {
'smartBlacklistRules': smartBlacklistRules,
'hideViewedPosts': hideViewedPosts,
'mediaQualityMode': mediaQualityMode,
'hiddenPostKeys': hiddenPostKeys,
'diagnosticLogLines': diagnosticLogLines,
'lastFeedTags': lastFeedTags,
'lastFeedProviderIds': lastFeedProviderIds,
'lastFeedTopPeriod': lastFeedTopPeriod,
@@ -230,6 +243,12 @@ class AppSettings {
(json['hideViewedPosts'] as bool?) ?? defaults.hideViewedPosts,
mediaQualityMode:
(json['mediaQualityMode'] as String?) ?? defaults.mediaQualityMode,
hiddenPostKeys: List<String>.from(
(json['hiddenPostKeys'] as List?) ?? defaults.hiddenPostKeys,
),
diagnosticLogLines: List<String>.from(
(json['diagnosticLogLines'] as List?) ?? defaults.diagnosticLogLines,
),
lastFeedTags: List<String>.from(
(json['lastFeedTags'] as List?) ?? defaults.lastFeedTags,
),
@@ -331,6 +350,124 @@ class SettingsService {
);
}
Future<Result<void>> hidePostKey(String cacheKey) async {
final result = await getSettings();
if (result is Error<AppSettings>) return Error(result.failure);
final settings = (result as Success<AppSettings>).data;
final hidden = <String>{...settings.hiddenPostKeys, cacheKey}.toList()
..sort();
return updateSettings(settings.copyWith(hiddenPostKeys: hidden));
}
Future<Result<void>> unhidePostKey(String cacheKey) async {
final result = await getSettings();
if (result is Error<AppSettings>) return Error(result.failure);
final settings = (result as Success<AppSettings>).data;
return updateSettings(
settings.copyWith(
hiddenPostKeys:
settings.hiddenPostKeys.where((key) => key != cacheKey).toList(),
),
);
}
Future<Result<void>> clearHiddenPosts() async {
final result = await getSettings();
if (result is Error<AppSettings>) return Error(result.failure);
final settings = (result as Success<AppSettings>).data;
return updateSettings(settings.copyWith(hiddenPostKeys: const []));
}
Future<Result<void>> appendDiagnosticLog(String message) async {
final result = await getSettings();
if (result is Error<AppSettings>) return Error(result.failure);
final settings = (result as Success<AppSettings>).data;
final line = '${DateTime.now().toIso8601String()} $message';
final lines = [...settings.diagnosticLogLines, line];
return updateSettings(
settings.copyWith(
diagnosticLogLines:
lines.length > 200 ? lines.sublist(lines.length - 200) : lines,
),
);
}
Future<Result<void>> clearDiagnosticLogs() async {
final result = await getSettings();
if (result is Error<AppSettings>) return Error(result.failure);
final settings = (result as Success<AppSettings>).data;
return updateSettings(settings.copyWith(diagnosticLogLines: const []));
}
Future<Result<String>> buildDiagnosticsReport({
required String appVersion,
required int buildNumber,
}) async {
final settingsResult = await getSettings();
if (settingsResult is Error<AppSettings>) {
return Error(settingsResult.failure);
}
final settings = (settingsResult as Success<AppSettings>).data;
final providers = <ContentProviderConfig>[];
final diagnostics = <String>[];
final repository = _providerRepository;
if (repository != null) {
final providersResult = await repository.getProviders(enabledOnly: false);
if (providersResult is Success<List<ContentProviderConfig>>) {
providers.addAll(providersResult.data);
}
final diagnosticsResult = await repository.getDiagnostics();
if (diagnosticsResult is Success<List<ProviderDiagnostics>>) {
for (final item in diagnosticsResult.data) {
diagnostics.add(
'${item.providerId}: ${item.lastResultCount} posts, '
'lastSearchAt=${item.lastSearchAt}, '
'error=${item.lastErrorMessage ?? 'none'}',
);
}
}
}
final enabledProviders =
providers.where((provider) => provider.enabled).map((p) => p.id);
return Success(
const JsonEncoder.withIndent(' ').convert({
'app': {
'name': 'RuleGel',
'version': appVersion,
'build': buildNumber,
'generatedAt': DateTime.now().toIso8601String(),
},
'settingsSummary': {
'themeMode': settings.themeMode,
'nsfwEnabled': settings.nsfwEnabled,
'blurExplicitContent': settings.blurExplicitContent,
'mediaQualityMode': settings.mediaQualityMode,
'cacheTtlHours': settings.cacheTtlHours,
'cacheMaxItems': settings.cacheMaxItems,
'hiddenPosts': settings.hiddenPostKeys.length,
'blacklistRules': settings.smartBlacklistRules.length,
'whitelistTags': settings.whitelistedTags.length,
'hideViewedPosts': settings.hideViewedPosts,
},
'providers': {
'enabled': enabledProviders.toList(),
'all': providers
.map((provider) => {
'id': provider.id,
'name': provider.name,
'apiType': provider.apiType,
'enabled': provider.enabled,
'priority': provider.priority,
'baseUrl': provider.baseUrl,
})
.toList(),
},
'recentLogs': settings.diagnosticLogLines.take(60).toList(),
'providerDiagnostics': diagnostics,
}),
);
}
Future<Result<String>> exportSettingsToJson() async {
final result = await getSettings();
if (result is Error<AppSettings>) return Error(result.failure);
@@ -351,6 +488,7 @@ class SettingsService {
'blacklistedTags': settings.blacklistedTags,
'whitelistedTags': settings.whitelistedTags,
'smartBlacklistRules': settings.smartBlacklistRules,
'hiddenPostKeys': settings.hiddenPostKeys,
},
'providers': providers.map((provider) => provider.toJson()).toList(),
}),
+10
View File
@@ -3,7 +3,17 @@ import 'package:flutter/foundation.dart';
class AppLogger {
const AppLogger();
static final List<String> _lines = <String>[];
static List<String> get lines => List.unmodifiable(_lines);
static void clear() => _lines.clear();
void debug(String message, [Object? error, StackTrace? stackTrace]) {
final line = '[${DateTime.now().toIso8601String()}] $message'
'${error == null ? '' : ' error=$error'}';
_lines.add(line);
if (_lines.length > 200) _lines.removeRange(0, _lines.length - 200);
if (kDebugMode) {
debugPrint('[GelRule] $message');
if (error != null) debugPrint(' error: $error');
@@ -1,5 +1,6 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../app/app.dart';
import '../../../backend/backend.dart';
import '../../../core/utils/result.dart';
import 'feed_state.dart';
@@ -153,6 +154,22 @@ class FeedController extends AsyncNotifier<FeedState> {
await refresh();
}
Future<void> hidePost(Post post) async {
final current = state.value;
if (current == null) return;
final result =
await ref.read(settingsServiceProvider).hidePostKey(post.cacheKey);
if (result is Error<void>) return;
state = AsyncData(
current.copyWith(
posts: current.posts
.where((item) => item.cacheKey != post.cacheKey)
.toList(growable: false),
),
);
ref.invalidate(appSettingsProvider);
}
Future<void> saveSession({double? scrollOffset}) async {
final current = state.value;
if (current == null) return;
@@ -249,6 +249,7 @@ class _FeedScreenState extends ConsumerState<FeedScreen> {
_toggleFavorite(ref, post, favoriteKeys),
onAddToCollection: (post) =>
_addToCollection(context, ref, post),
onHide: (post) => _hidePost(context, ref, post),
),
),
),
@@ -400,6 +401,30 @@ class _FeedScreenState extends ConsumerState<FeedScreen> {
ref.invalidate(favoriteKeysProvider);
ref.invalidate(favoritesControllerProvider);
}
Future<void> _hidePost(
BuildContext context,
WidgetRef ref,
Post post,
) async {
await ref.read(feedControllerProvider.notifier).hidePost(post);
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: const Text('Post hidden locally'),
action: SnackBarAction(
label: 'Undo',
onPressed: () async {
await ref
.read(settingsServiceProvider)
.unhidePostKey(post.cacheKey);
ref.invalidate(appSettingsProvider);
ref.read(feedControllerProvider.notifier).refresh();
},
),
),
);
}
}
class _RefreshIntent extends Intent {
@@ -218,6 +218,7 @@ class PostDetailsScreen extends ConsumerWidget {
onCopy: () =>
Clipboard.setData(ClipboardData(text: post.fileUrl)),
onSimilar: () => _openSimilar(context, ref, post),
onHide: () => _hidePost(context, ref, post),
onDownload: settings.allowDownloads
? () => _download(context, ref, post)
: null,
@@ -300,6 +301,7 @@ class PostDetailsScreen extends ConsumerWidget {
onOpen: () => launchUrl(Uri.parse(post.fileUrl)),
onCopy: () => Clipboard.setData(ClipboardData(text: post.fileUrl)),
onSimilar: () => _openSimilar(context, ref, post),
onHide: () => _hidePost(context, ref, post),
onDownload: settings.allowDownloads
? () => _download(context, ref, post)
: null,
@@ -442,6 +444,21 @@ class PostDetailsScreen extends ConsumerWidget {
ref.invalidate(favoritesControllerProvider);
}
Future<void> _hidePost(
BuildContext context,
WidgetRef ref,
Post post,
) async {
await ref.read(settingsServiceProvider).hidePostKey(post.cacheKey);
ref.invalidate(appSettingsProvider);
ref.read(feedControllerProvider.notifier).refresh();
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Post hidden locally')),
);
_close(context);
}
void _openPost(BuildContext context, Post post) {
_replacePost(context, post);
}
@@ -9,6 +9,7 @@ class PostActionBar extends StatelessWidget {
required this.isFavorite,
this.onDownload,
this.onSimilar,
this.onHide,
super.key,
});
@@ -19,6 +20,7 @@ class PostActionBar extends StatelessWidget {
final bool isFavorite;
final VoidCallback? onDownload;
final VoidCallback? onSimilar;
final VoidCallback? onHide;
@override
Widget build(BuildContext context) {
@@ -60,6 +62,12 @@ class PostActionBar extends StatelessWidget {
onPressed: onDownload,
icon: const Icon(Icons.download_rounded),
),
if (onHide != null)
IconButton.filledTonal(
tooltip: 'Hide post locally',
onPressed: onHide,
icon: const Icon(Icons.visibility_off_rounded),
),
],
);
}
@@ -1,7 +1,9 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../app/app.dart';
import '../../../app/app_version.dart';
import '../../../backend/backend.dart';
import '../../../core/utils/logger.dart';
import '../../../core/utils/result.dart';
final settingsControllerProvider =
@@ -30,6 +32,34 @@ class SettingsController extends AsyncNotifier<AppSettings> {
ref.invalidate(viewedKeysProvider);
}
Future<void> clearHiddenPosts() async {
await ref.read(settingsServiceProvider).clearHiddenPosts();
ref.invalidate(appSettingsProvider);
ref.invalidateSelf();
}
Future<String> diagnosticsReport() async {
final result =
await ref.read(settingsServiceProvider).buildDiagnosticsReport(
appVersion: appDisplayVersion,
buildNumber: appBuildNumber,
);
return result is Success<String> ? result.data : '{}';
}
Future<String> diagnosticLogs() async {
final settings = state.value ?? AppSettings.defaults;
final persistent = settings.diagnosticLogLines;
final runtime = AppLogger.lines;
return [...persistent, ...runtime].join('\n');
}
Future<void> clearDiagnosticLogs() async {
AppLogger.clear();
await ref.read(settingsServiceProvider).clearDiagnosticLogs();
ref.invalidateSelf();
}
Future<String> exportJson() async {
final result =
await ref.read(settingsServiceProvider).exportSettingsToJson();
@@ -5,6 +5,7 @@ import 'package:go_router/go_router.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../../app/app_version.dart';
import '../../../app/changelog.dart';
import '../../../backend/backend.dart';
import '../../../core/utils/result.dart';
import '../../../shared/widgets/adaptive_scaffold.dart';
@@ -151,6 +152,15 @@ class SettingsScreen extends ConsumerWidget {
label: const Text('Clear cache'),
),
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})'),
),
const SizedBox(height: 12),
FilledButton.tonalIcon(
onPressed: () => ref
.read(settingsControllerProvider.notifier)
@@ -186,8 +196,54 @@ class SettingsScreen extends ConsumerWidget {
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 Text('Version $appDisplayVersion'),
const Text('Version $appDisplayVersion ($appBuildNumber)'),
],
),
),
@@ -305,6 +361,45 @@ class SettingsScreen extends ConsumerWidget {
);
ref.invalidate(settingsControllerProvider);
}
Future<void> _showChangelog(BuildContext context) {
return showDialog<void>(
context: context,
builder: (context) => AlertDialog(
title: const Text('RuleGel changelog'),
content: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 560),
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
for (final change in ruleGelChangelog) ...[
Text(
'${change.version} - ${change.title}',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 6),
for (final bullet in change.bullets)
Padding(
padding: const EdgeInsets.only(left: 8, bottom: 4),
child: Text('- $bullet'),
),
const SizedBox(height: 12),
],
],
),
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Close'),
),
],
),
);
}
}
class _TagListEditor extends StatefulWidget {
+16
View File
@@ -17,6 +17,7 @@ class PostCard extends StatefulWidget {
required this.onFavorite,
this.onAddToCollection,
this.onPreview,
this.onHide,
this.onToggleSelected,
this.selectionMode = false,
this.selected = false,
@@ -32,6 +33,7 @@ class PostCard extends StatefulWidget {
final VoidCallback onFavorite;
final VoidCallback? onAddToCollection;
final VoidCallback? onPreview;
final VoidCallback? onHide;
final VoidCallback? onToggleSelected;
final bool selectionMode;
final bool selected;
@@ -95,6 +97,11 @@ class _PostCardState extends State<PostCard> {
onTap: widget.onAddToCollection,
child: const Text('Add to collection'),
),
if (widget.onHide != null)
PopupMenuItem(
onTap: widget.onHide,
child: const Text('Hide post'),
),
],
);
},
@@ -217,6 +224,15 @@ class _PostCardState extends State<PostCard> {
onPressed: widget.onAddToCollection,
icon: const Icon(Icons.add_rounded),
),
if (widget.onHide != null) ...[
const SizedBox(width: 6),
IconButton.filledTonal(
tooltip: 'Hide post',
onPressed: widget.onHide,
icon:
const Icon(Icons.visibility_off_rounded),
),
],
],
),
),
@@ -17,6 +17,7 @@ class PostMasonryGrid extends StatelessWidget {
required this.onFavorite,
this.onAddToCollection,
this.onPreview,
this.onHide,
this.onToggleSelected,
this.selectionMode = false,
this.selectedKeys = const {},
@@ -36,6 +37,7 @@ class PostMasonryGrid extends StatelessWidget {
final ValueChanged<Post> onFavorite;
final ValueChanged<Post>? onAddToCollection;
final ValueChanged<Post>? onPreview;
final ValueChanged<Post>? onHide;
final ValueChanged<Post>? onToggleSelected;
final bool selectionMode;
final Set<String> selectedKeys;
@@ -66,6 +68,7 @@ class PostMasonryGrid extends StatelessWidget {
onOpen: () => onOpen(post),
onFavorite: () => onFavorite(post),
onPreview: onPreview == null ? null : () => onPreview!(post),
onHide: onHide == null ? null : () => onHide!(post),
selectionMode: selectionMode,
selected: selectedKeys.contains(post.cacheKey),
onToggleSelected:
+1 -1
View File
@@ -1,7 +1,7 @@
name: gel_rule_app
description: Local backend/core layer for a Flutter booru browser app.
publish_to: "none"
version: 0.4.3+7
version: 1.0.0+8
environment:
sdk: ">=3.3.0 <4.0.0"
+9
View File
@@ -49,6 +49,15 @@ void main() {
expect(postMatchesRequestedTags(post, ['touhou']), isTrue);
expect(postMatchesRequestedTags(post, ['touhou', 'cirno']), isFalse);
});
test('hidden post keys are filtered locally', () {
final post = _post(tags: ['touhou']);
final settings = AppSettings.defaults.copyWith(
hiddenPostKeys: [post.cacheKey],
);
expect(postPassesTagFilters(post, settings), isFalse);
});
});
}
+3
View File
@@ -167,6 +167,7 @@ void main() {
blacklistedTags: ['blocked'],
whitelistedTags: ['allowed'],
smartBlacklistRules: ['provider:e621 score:<10'],
hiddenPostKeys: ['provider:post'],
),
);
@@ -178,6 +179,8 @@ void main() {
expect(exported.data, contains('"allowed"'));
expect(exported.data, contains('"smartBlacklistRules"'));
expect(exported.data, contains('provider:e621 score:<10'));
expect(exported.data, contains('"hiddenPostKeys"'));
expect(exported.data, contains('"provider:post"'));
});
test('cache service prunes to max item count', () async {