Fix video controls, tag input and collection deletion

This commit is contained in:
2026-05-24 19:02:34 +03:00
parent 2868fdf314
commit 1adfc1fa0c
13 changed files with 327 additions and 216 deletions
+7 -3
View File
@@ -1,5 +1,5 @@
#define MyAppName "RuleGel" #define MyAppName "RuleGel"
#define MyAppVersion "0.2.0" #define MyAppVersion "0.4.3"
#define MyAppPublisher "RarDog" #define MyAppPublisher "RarDog"
#define MyAppExeName "gel_rule_app.exe" #define MyAppExeName "gel_rule_app.exe"
@@ -32,9 +32,13 @@ Name: "desktopicon"; Description: "Create a desktop shortcut"; GroupDescription:
[Files] [Files]
Source: "..\build\windows\x64\runner\Release\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs; Excludes: "*.lnk" Source: "..\build\windows\x64\runner\Release\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs; Excludes: "*.lnk"
[InstallDelete]
Type: files; Name: "{group}\{#MyAppName}.lnk"
Type: files; Name: "{autodesktop}\{#MyAppName}.lnk"
[Icons] [Icons]
Name: "{group}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}" Name: "{group}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; IconFilename: "{app}\{#MyAppExeName}"
Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; IconFilename: "{app}\{#MyAppExeName}"; Tasks: desktopicon
[Run] [Run]
Filename: "{app}\{#MyAppExeName}"; Description: "Launch {#MyAppName}"; Flags: nowait postinstall skipifsilent Filename: "{app}\{#MyAppExeName}"; Description: "Launch {#MyAppName}"; Flags: nowait postinstall skipifsilent
+2 -2
View File
@@ -1,2 +1,2 @@
const appDisplayVersion = '0.4.2'; const appDisplayVersion = '0.4.3';
const appBuildNumber = 6; const appBuildNumber = 7;
@@ -34,7 +34,7 @@ class CollectionRepository {
.filter() .filter()
.collectionIdEqualTo(id) .collectionIdEqualTo(id)
.deleteAll(); .deleteAll();
await isar.collectionEntitys.filter().collectionIdEqualTo(id).deleteAll(); await isar.collectionEntitys.deleteByCollectionId(id);
}); });
} }
@@ -347,6 +347,11 @@ class SettingsService {
const JsonEncoder.withIndent(' ').convert({ const JsonEncoder.withIndent(' ').convert({
'schemaVersion': 2, 'schemaVersion': 2,
'settings': settings.toJson(), 'settings': settings.toJson(),
'filters': {
'blacklistedTags': settings.blacklistedTags,
'whitelistedTags': settings.whitelistedTags,
'smartBlacklistRules': settings.smartBlacklistRules,
},
'providers': providers.map((provider) => provider.toJson()).toList(), 'providers': providers.map((provider) => provider.toJson()).toList(),
}), }),
); );
@@ -357,6 +362,15 @@ class SettingsService {
final decoded = jsonDecode(json) as Map<String, dynamic>; final decoded = jsonDecode(json) as Map<String, dynamic>;
final settingsJson = final settingsJson =
(decoded['settings'] as Map?)?.cast<String, dynamic>() ?? decoded; (decoded['settings'] as Map?)?.cast<String, dynamic>() ?? decoded;
final filtersJson =
(decoded['filters'] as Map?)?.cast<String, dynamic>() ?? const {};
for (final key in [
'blacklistedTags',
'whitelistedTags',
'smartBlacklistRules',
]) {
settingsJson.putIfAbsent(key, () => filtersJson[key]);
}
final settings = AppSettings.fromJson(settingsJson); final settings = AppSettings.fromJson(settingsJson);
final result = await updateSettings(settings); final result = await updateSettings(settings);
if (result is Error<void>) return result; if (result is Error<void>) return result;
@@ -36,6 +36,7 @@ class CollectionsController extends AsyncNotifier<List<Collection>> {
Future<void> delete(String id) async { Future<void> delete(String id) async {
await ref.read(collectionServiceProvider).deleteCollection(id); await ref.read(collectionServiceProvider).deleteCollection(id);
ref.invalidate(collectionPostsProvider(id));
state = AsyncData(await _load()); state = AsyncData(await _load());
} }
@@ -63,6 +63,21 @@ class _FeedScreenState extends ConsumerState<FeedScreen> {
}); });
} }
@override
void didUpdateWidget(covariant FeedScreen oldWidget) {
super.didUpdateWidget(oldWidget);
final nextQuery = widget.initialQuery;
if (nextQuery != null &&
nextQuery.isNotEmpty &&
nextQuery != oldWidget.initialQuery) {
_usedInitialQuery = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
ref.read(feedControllerProvider.notifier).search(nextQuery);
});
}
}
@override @override
void dispose() { void dispose() {
_scrollSaveDebounce?.cancel(); _scrollSaveDebounce?.cancel();
@@ -279,7 +279,14 @@ class _PostMediaViewerState extends State<PostMediaViewer>
} }
Future<void> _retryVideo() async { Future<void> _retryVideo() async {
final player = _player;
if (player == null) return;
setState(() {
_videoError = null;
_videoIndex = 0; _videoIndex = 0;
_controlsVisible = true;
});
await player.stop();
await _openVideo(play: true); await _openVideo(play: true);
_showControls(); _showControls();
} }
@@ -994,7 +1001,7 @@ class _VideoSurface extends StatelessWidget {
}, },
), ),
if (errorMessage != null) if (errorMessage != null)
_VideoErrorOverlay(message: errorMessage!, onRetry: onRetry), VideoErrorOverlay(message: errorMessage!, onRetry: onRetry),
if (errorMessage == null) if (errorMessage == null)
StreamBuilder<bool>( StreamBuilder<bool>(
stream: player.stream.playing, stream: player.stream.playing,
@@ -1040,7 +1047,9 @@ class _VideoSurface extends StatelessWidget {
); );
}, },
), ),
AnimatedOpacity( IgnorePointer(
ignoring: !controlsVisible || errorMessage != null,
child: AnimatedOpacity(
opacity: controlsVisible || errorMessage != null ? 1 : 0, opacity: controlsVisible || errorMessage != null ? 1 : 0,
duration: const Duration(milliseconds: 180), duration: const Duration(milliseconds: 180),
child: _VideoControls( child: _VideoControls(
@@ -1057,6 +1066,7 @@ class _VideoSurface extends StatelessWidget {
onFullscreen: onFullscreen, onFullscreen: onFullscreen,
), ),
), ),
),
], ],
); );
@@ -1080,10 +1090,11 @@ class _VideoSurface extends StatelessWidget {
} }
} }
class _VideoErrorOverlay extends StatelessWidget { class VideoErrorOverlay extends StatelessWidget {
const _VideoErrorOverlay({ const VideoErrorOverlay({
required this.message, required this.message,
required this.onRetry, required this.onRetry,
super.key,
}); });
final String message; final String message;
@@ -1210,19 +1221,7 @@ class _VideoControls extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme; final scheme = Theme.of(context).colorScheme;
return Container( return Align(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.bottomCenter,
end: Alignment.topCenter,
colors: [
Colors.black.withValues(alpha: 0.72),
Colors.black.withValues(alpha: 0.12),
Colors.transparent,
],
),
),
child: Align(
alignment: Alignment.bottomCenter, alignment: Alignment.bottomCenter,
child: SafeArea( child: SafeArea(
minimum: EdgeInsets.fromLTRB( minimum: EdgeInsets.fromLTRB(
@@ -1231,6 +1230,14 @@ class _VideoControls extends StatelessWidget {
fullscreen ? 14 : 8, fullscreen ? 14 : 8,
fullscreen ? 10 : 8, fullscreen ? 10 : 8,
), ),
child: DecoratedBox(
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.58),
borderRadius: BorderRadius.circular(fullscreen ? 18 : 14),
border: Border.all(color: Colors.white.withValues(alpha: 0.08)),
),
child: Padding(
padding: const EdgeInsets.fromLTRB(10, 4, 10, 8),
child: StreamBuilder<bool>( child: StreamBuilder<bool>(
stream: player.stream.playing, stream: player.stream.playing,
initialData: player.state.playing, initialData: player.state.playing,
@@ -1246,8 +1253,9 @@ class _VideoControls extends StatelessWidget {
initialData: player.state.position, initialData: player.state.position,
builder: (context, positionSnapshot) { builder: (context, positionSnapshot) {
final position = positionSnapshot.data ?? Duration.zero; final position = positionSnapshot.data ?? Duration.zero;
final maxMs = final maxMs = duration.inMilliseconds
duration.inMilliseconds.clamp(1, 1 << 31).toDouble(); .clamp(1, 1 << 31)
.toDouble();
final valueMs = position.inMilliseconds final valueMs = position.inMilliseconds
.clamp(0, maxMs.toInt()) .clamp(0, maxMs.toInt())
.toDouble(); .toDouble();
@@ -1393,6 +1401,7 @@ class _VideoControls extends StatelessWidget {
), ),
), ),
), ),
),
); );
} }
+18 -23
View File
@@ -133,11 +133,8 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
autocorrect: false, autocorrect: false,
enableSuggestions: false, enableSuggestions: false,
textInputAction: TextInputAction.search, textInputAction: TextInputAction.search,
inputFormatters: [
_TagCommitFormatter(onCommit: _commitDraft),
],
onSubmitted: (_) => _submit(), onSubmitted: (_) => _submit(),
onChanged: (_) => _notifyChangedDebounced(), onChanged: _handleDraftChanged,
decoration: InputDecoration( decoration: InputDecoration(
isDense: true, isDense: true,
border: InputBorder.none, border: InputBorder.none,
@@ -200,6 +197,12 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) { KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) {
if (event is! KeyDownEvent) return KeyEventResult.ignored; if (event is! KeyDownEvent) return KeyEventResult.ignored;
if (event.logicalKey == LogicalKeyboardKey.enter ||
event.logicalKey == LogicalKeyboardKey.numpadEnter) {
_commitDraft(_controller.text);
_submit();
return KeyEventResult.handled;
}
if (event.logicalKey != LogicalKeyboardKey.backspace) { if (event.logicalKey != LogicalKeyboardKey.backspace) {
return KeyEventResult.ignored; return KeyEventResult.ignored;
} }
@@ -216,6 +219,17 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
return KeyEventResult.handled; return KeyEventResult.handled;
} }
void _handleDraftChanged(String value) {
final composing = _controller.value.composing;
if (!composing.isValid &&
value.isNotEmpty &&
RegExp(r'\s$').hasMatch(value)) {
_commitDraft(value);
return;
}
_notifyChangedDebounced();
}
void _setFromQuery(String query) { void _setFromQuery(String query) {
_tags = _parseTags(query); _tags = _parseTags(query);
_controller.clear(); _controller.clear();
@@ -310,25 +324,6 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
} }
} }
class _TagCommitFormatter extends TextInputFormatter {
_TagCommitFormatter({required this.onCommit});
final ValueChanged<String> onCommit;
@override
TextEditingValue formatEditUpdate(
TextEditingValue oldValue,
TextEditingValue newValue,
) {
if (newValue.text.endsWith(' ') || newValue.text.endsWith('\n')) {
final value = newValue.text.trim();
WidgetsBinding.instance.addPostFrameCallback((_) => onCommit(value));
return const TextEditingValue();
}
return newValue;
}
}
class _TagSuggestionDropdown extends StatelessWidget { class _TagSuggestionDropdown extends StatelessWidget {
const _TagSuggestionDropdown({ const _TagSuggestionDropdown({
required this.suggestions, required this.suggestions,
+7
View File
@@ -8,9 +8,16 @@ class TagChip extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return ActionChip( return ActionChip(
onPressed: onTap, onPressed: onTap,
label: Text(tag), label: Text(tag),
labelStyle: TextStyle(
color: scheme.onSecondaryContainer,
fontWeight: FontWeight.w700,
),
backgroundColor: scheme.secondaryContainer.withValues(alpha: 0.62),
side: BorderSide(color: scheme.outlineVariant.withValues(alpha: 0.65)),
visualDensity: VisualDensity.compact, visualDensity: VisualDensity.compact,
); );
} }
+1 -1
View File
@@ -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: 0.4.2+6 version: 0.4.3+7
environment: environment:
sdk: ">=3.3.0 <4.0.0" sdk: ">=3.3.0 <4.0.0"
+41
View File
@@ -139,6 +139,47 @@ void main() {
'2'); '2');
}); });
test('deleted collection stays deleted after repository reload', () async {
final repository = CollectionRepository(databaseService, postRepository);
final service = CollectionService(repository);
final created = await service.createCollection('Temp', null) as Success;
final item = post('collection-delete');
await service.addPostToCollection(created.data.id, item);
await service.deleteCollection(created.data.id);
final reloadedService = CollectionService(
CollectionRepository(databaseService, postRepository),
);
expect((await reloadedService.getCollections() as Success).data, isEmpty);
expect(
(await reloadedService.getCollectionPosts(created.data.id) as Success)
.data,
isEmpty,
);
});
test('settings export keeps blacklist whitelist and smart rules', () async {
final service = SettingsService(databaseService);
await service.updateSettings(
AppSettings.defaults.copyWith(
blacklistedTags: ['blocked'],
whitelistedTags: ['allowed'],
smartBlacklistRules: ['provider:e621 score:<10'],
),
);
final exported = await service.exportSettingsToJson() as Success<String>;
expect(exported.data, contains('"blacklistedTags"'));
expect(exported.data, contains('"blocked"'));
expect(exported.data, contains('"whitelistedTags"'));
expect(exported.data, contains('"allowed"'));
expect(exported.data, contains('"smartBlacklistRules"'));
expect(exported.data, contains('provider:e621 score:<10'));
});
test('cache service prunes to max item count', () async { test('cache service prunes to max item count', () async {
final service = CacheService(databaseService); final service = CacheService(databaseService);
+25
View File
@@ -0,0 +1,25 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:gel_rule_app/features/post/presentation/widgets/post_media_viewer.dart';
void main() {
testWidgets('video error retry button invokes callback', (tester) async {
var retried = false;
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: VideoErrorOverlay(
message: 'network failed',
onRetry: () => retried = true,
),
),
),
);
await tester.tap(find.text('Retry'));
await tester.pump();
expect(retried, isTrue);
});
}
+1 -1
View File
@@ -66,7 +66,7 @@ include(flutter/generated_plugins.cmake)
set(BUILD_BUNDLE_DIR "$<TARGET_FILE_DIR:${BINARY_NAME}>") set(BUILD_BUNDLE_DIR "$<TARGET_FILE_DIR:${BINARY_NAME}>")
# Make the "install" step default, as it's required to run. # Make the "install" step default, as it's required to run.
set(CMAKE_VS_INCLUDE_INSTALL_TO_DEFAULT_BUILD 1) set(CMAKE_VS_INCLUDE_INSTALL_TO_DEFAULT_BUILD 1)
if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT OR CMAKE_INSTALL_PREFIX MATCHES "Program Files")
set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE)
endif() endif()