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 {
_videoIndex = 0; final player = _player;
if (player == null) return;
setState(() {
_videoError = null;
_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,21 +1047,24 @@ class _VideoSurface extends StatelessWidget {
); );
}, },
), ),
AnimatedOpacity( IgnorePointer(
opacity: controlsVisible || errorMessage != null ? 1 : 0, ignoring: !controlsVisible || errorMessage != null,
duration: const Duration(milliseconds: 180), child: AnimatedOpacity(
child: _VideoControls( opacity: controlsVisible || errorMessage != null ? 1 : 0,
player: player, duration: const Duration(milliseconds: 180),
muted: muted, child: _VideoControls(
halfVolume: halfVolume, player: player,
loopVideo: loopVideo, muted: muted,
coverVideo: coverVideo, halfVolume: halfVolume,
fullscreen: fullscreen, loopVideo: loopVideo,
onToggleFit: onToggleFit, coverVideo: coverVideo,
onToggleMute: onToggleMute, fullscreen: fullscreen,
onToggleHalfVolume: onToggleHalfVolume, onToggleFit: onToggleFit,
onToggleLoop: onToggleLoop, onToggleMute: onToggleMute,
onFullscreen: onFullscreen, onToggleHalfVolume: onToggleHalfVolume,
onToggleLoop: onToggleLoop,
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,186 +1221,184 @@ 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( alignment: Alignment.bottomCenter,
gradient: LinearGradient( child: SafeArea(
begin: Alignment.bottomCenter, minimum: EdgeInsets.fromLTRB(
end: Alignment.topCenter, fullscreen ? 14 : 8,
colors: [ 0,
Colors.black.withValues(alpha: 0.72), fullscreen ? 14 : 8,
Colors.black.withValues(alpha: 0.12), fullscreen ? 10 : 8,
Colors.transparent,
],
), ),
), child: DecoratedBox(
child: Align( decoration: BoxDecoration(
alignment: Alignment.bottomCenter, color: Colors.black.withValues(alpha: 0.58),
child: SafeArea( borderRadius: BorderRadius.circular(fullscreen ? 18 : 14),
minimum: EdgeInsets.fromLTRB( border: Border.all(color: Colors.white.withValues(alpha: 0.08)),
fullscreen ? 14 : 8,
0,
fullscreen ? 14 : 8,
fullscreen ? 10 : 8,
), ),
child: StreamBuilder<bool>( child: Padding(
stream: player.stream.playing, padding: const EdgeInsets.fromLTRB(10, 4, 10, 8),
initialData: player.state.playing, child: StreamBuilder<bool>(
builder: (context, playingSnapshot) { stream: player.stream.playing,
final playing = playingSnapshot.data ?? false; initialData: player.state.playing,
return StreamBuilder<Duration>( builder: (context, playingSnapshot) {
stream: player.stream.duration, final playing = playingSnapshot.data ?? false;
initialData: player.state.duration, return StreamBuilder<Duration>(
builder: (context, durationSnapshot) { stream: player.stream.duration,
final duration = durationSnapshot.data ?? Duration.zero; initialData: player.state.duration,
return StreamBuilder<Duration>( builder: (context, durationSnapshot) {
stream: player.stream.position, final duration = durationSnapshot.data ?? Duration.zero;
initialData: player.state.position, return StreamBuilder<Duration>(
builder: (context, positionSnapshot) { stream: player.stream.position,
final position = positionSnapshot.data ?? Duration.zero; initialData: player.state.position,
final maxMs = builder: (context, positionSnapshot) {
duration.inMilliseconds.clamp(1, 1 << 31).toDouble(); final position = positionSnapshot.data ?? Duration.zero;
final valueMs = position.inMilliseconds final maxMs = duration.inMilliseconds
.clamp(0, maxMs.toInt()) .clamp(1, 1 << 31)
.toDouble(); .toDouble();
return LayoutBuilder( final valueMs = position.inMilliseconds
builder: (context, constraints) { .clamp(0, maxMs.toInt())
final compact = constraints.maxWidth < 560; .toDouble();
final tiny = constraints.maxWidth < 390; return LayoutBuilder(
return Column( builder: (context, constraints) {
mainAxisSize: MainAxisSize.min, final compact = constraints.maxWidth < 560;
children: [ final tiny = constraints.maxWidth < 390;
SliderTheme( return Column(
data: SliderTheme.of(context).copyWith( mainAxisSize: MainAxisSize.min,
activeTrackColor: scheme.primary, children: [
inactiveTrackColor: SliderTheme(
Colors.white.withValues(alpha: 0.24), data: SliderTheme.of(context).copyWith(
trackHeight: 3, activeTrackColor: scheme.primary,
thumbColor: scheme.primary, inactiveTrackColor:
overlayColor: Colors.white.withValues(alpha: 0.24),
scheme.primary.withValues(alpha: 0.18), trackHeight: 3,
thumbShape: const RoundSliderThumbShape( thumbColor: scheme.primary,
enabledThumbRadius: 5, overlayColor:
scheme.primary.withValues(alpha: 0.18),
thumbShape: const RoundSliderThumbShape(
enabledThumbRadius: 5,
),
), ),
), child: Slider(
child: Slider( value: valueMs,
value: valueMs, max: maxMs,
max: maxMs, onChanged: duration == Duration.zero
onChanged: duration == Duration.zero ? null
? null : (value) => player.seek(
: (value) => player.seek( Duration(
Duration( milliseconds: value.round(),
milliseconds: value.round(), ),
), ),
), ),
), ),
), Row(
Row( children: [
children: [
_RoundControlButton(
tooltip: playing ? 'Pause' : 'Play',
emphasized: true,
onPressed: player.playOrPause,
icon: playing
? Icons.pause_rounded
: Icons.play_arrow_rounded,
),
const SizedBox(width: 8),
Text(
tiny
? _format(position)
: '${_format(position)} / ${_format(duration)}',
style: const TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.w700,
),
),
const Spacer(),
if (!compact) ...[
_RoundControlButton( _RoundControlButton(
tooltip: 'Back 10s', tooltip: playing ? 'Pause' : 'Play',
onPressed: () => player.seek( emphasized: true,
_clampSeek( onPressed: player.playOrPause,
position - icon: playing
const Duration(seconds: 10), ? Icons.pause_rounded
duration, : Icons.play_arrow_rounded,
), ),
const SizedBox(width: 8),
Text(
tiny
? _format(position)
: '${_format(position)} / ${_format(duration)}',
style: const TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.w700,
), ),
icon: Icons.replay_10_rounded,
), ),
const SizedBox(width: 5), const Spacer(),
_RoundControlButton( if (!compact) ...[
tooltip: 'Forward 10s', _RoundControlButton(
onPressed: () => player.seek( tooltip: 'Back 10s',
_clampSeek( onPressed: () => player.seek(
position + _clampSeek(
const Duration(seconds: 10), position -
duration, const Duration(seconds: 10),
duration,
),
), ),
icon: Icons.replay_10_rounded,
), ),
icon: Icons.forward_10_rounded, const SizedBox(width: 5),
), _RoundControlButton(
const SizedBox(width: 5), tooltip: 'Forward 10s',
], onPressed: () => player.seek(
_RoundControlButton( _clampSeek(
tooltip: muted ? 'Unmute' : 'Mute', position +
onPressed: onToggleMute, const Duration(seconds: 10),
icon: muted duration,
? Icons.volume_off_rounded ),
: Icons.volume_up_rounded, ),
), icon: Icons.forward_10_rounded,
const SizedBox(width: 5), ),
_RoundControlButton( const SizedBox(width: 5),
tooltip: halfVolume ],
? 'Normal app volume'
: 'Half app volume',
selected: halfVolume,
onPressed: onToggleHalfVolume,
icon: Icons.volume_down_rounded,
),
const SizedBox(width: 5),
_RoundControlButton(
tooltip: loopVideo
? 'Disable repeat'
: 'Repeat video',
selected: loopVideo,
onPressed: onToggleLoop,
icon: loopVideo
? Icons.repeat_one_on_rounded
: Icons.repeat_one_rounded,
),
const SizedBox(width: 5),
if (!compact) ...[
_RoundControlButton( _RoundControlButton(
tooltip: coverVideo ? 'Fit' : 'Fill', tooltip: muted ? 'Unmute' : 'Mute',
selected: coverVideo, onPressed: onToggleMute,
onPressed: onToggleFit, icon: muted
icon: coverVideo ? Icons.volume_off_rounded
? Icons.fit_screen_rounded : Icons.volume_up_rounded,
: Icons.crop_free_rounded,
), ),
const SizedBox(width: 5), const SizedBox(width: 5),
_RoundControlButton(
tooltip: halfVolume
? 'Normal app volume'
: 'Half app volume',
selected: halfVolume,
onPressed: onToggleHalfVolume,
icon: Icons.volume_down_rounded,
),
const SizedBox(width: 5),
_RoundControlButton(
tooltip: loopVideo
? 'Disable repeat'
: 'Repeat video',
selected: loopVideo,
onPressed: onToggleLoop,
icon: loopVideo
? Icons.repeat_one_on_rounded
: Icons.repeat_one_rounded,
),
const SizedBox(width: 5),
if (!compact) ...[
_RoundControlButton(
tooltip: coverVideo ? 'Fit' : 'Fill',
selected: coverVideo,
onPressed: onToggleFit,
icon: coverVideo
? Icons.fit_screen_rounded
: Icons.crop_free_rounded,
),
const SizedBox(width: 5),
],
_RoundControlButton(
tooltip: fullscreen
? 'Exit fullscreen'
: 'Fullscreen',
onPressed: onFullscreen,
icon: fullscreen
? Icons.fullscreen_exit_rounded
: Icons.fullscreen_rounded,
),
], ],
_RoundControlButton( ),
tooltip: fullscreen ],
? 'Exit fullscreen' );
: 'Fullscreen', },
onPressed: onFullscreen, );
icon: fullscreen },
? Icons.fullscreen_exit_rounded );
: Icons.fullscreen_rounded, },
), );
], },
), ),
],
);
},
);
},
);
},
);
},
), ),
), ),
), ),
+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()