diff --git a/installer/RuleGel.iss b/installer/RuleGel.iss index 05377b8..f0ab8c5 100644 --- a/installer/RuleGel.iss +++ b/installer/RuleGel.iss @@ -1,5 +1,5 @@ #define MyAppName "RuleGel" -#define MyAppVersion "0.2.0" +#define MyAppVersion "0.4.3" #define MyAppPublisher "RarDog" #define MyAppExeName "gel_rule_app.exe" @@ -32,9 +32,13 @@ Name: "desktopicon"; Description: "Create a desktop shortcut"; GroupDescription: [Files] 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] -Name: "{group}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}" -Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon +Name: "{group}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; IconFilename: "{app}\{#MyAppExeName}" +Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; IconFilename: "{app}\{#MyAppExeName}"; Tasks: desktopicon [Run] Filename: "{app}\{#MyAppExeName}"; Description: "Launch {#MyAppName}"; Flags: nowait postinstall skipifsilent diff --git a/lib/app/app_version.dart b/lib/app/app_version.dart index ba7c8f2..18e7bd3 100644 --- a/lib/app/app_version.dart +++ b/lib/app/app_version.dart @@ -1,2 +1,2 @@ -const appDisplayVersion = '0.4.2'; -const appBuildNumber = 6; +const appDisplayVersion = '0.4.3'; +const appBuildNumber = 7; diff --git a/lib/backend/repositories/collection_repository.dart b/lib/backend/repositories/collection_repository.dart index 5db2a94..5bd0f54 100644 --- a/lib/backend/repositories/collection_repository.dart +++ b/lib/backend/repositories/collection_repository.dart @@ -34,7 +34,7 @@ class CollectionRepository { .filter() .collectionIdEqualTo(id) .deleteAll(); - await isar.collectionEntitys.filter().collectionIdEqualTo(id).deleteAll(); + await isar.collectionEntitys.deleteByCollectionId(id); }); } diff --git a/lib/backend/services/settings_service.dart b/lib/backend/services/settings_service.dart index 3ee87f2..fbcc222 100644 --- a/lib/backend/services/settings_service.dart +++ b/lib/backend/services/settings_service.dart @@ -347,6 +347,11 @@ class SettingsService { const JsonEncoder.withIndent(' ').convert({ 'schemaVersion': 2, 'settings': settings.toJson(), + 'filters': { + 'blacklistedTags': settings.blacklistedTags, + 'whitelistedTags': settings.whitelistedTags, + 'smartBlacklistRules': settings.smartBlacklistRules, + }, 'providers': providers.map((provider) => provider.toJson()).toList(), }), ); @@ -357,6 +362,15 @@ class SettingsService { final decoded = jsonDecode(json) as Map; final settingsJson = (decoded['settings'] as Map?)?.cast() ?? decoded; + final filtersJson = + (decoded['filters'] as Map?)?.cast() ?? const {}; + for (final key in [ + 'blacklistedTags', + 'whitelistedTags', + 'smartBlacklistRules', + ]) { + settingsJson.putIfAbsent(key, () => filtersJson[key]); + } final settings = AppSettings.fromJson(settingsJson); final result = await updateSettings(settings); if (result is Error) return result; diff --git a/lib/features/collections/presentation/collections_controller.dart b/lib/features/collections/presentation/collections_controller.dart index 94546b3..cf32c8f 100644 --- a/lib/features/collections/presentation/collections_controller.dart +++ b/lib/features/collections/presentation/collections_controller.dart @@ -36,6 +36,7 @@ class CollectionsController extends AsyncNotifier> { Future delete(String id) async { await ref.read(collectionServiceProvider).deleteCollection(id); + ref.invalidate(collectionPostsProvider(id)); state = AsyncData(await _load()); } diff --git a/lib/features/feed/presentation/feed_screen.dart b/lib/features/feed/presentation/feed_screen.dart index 5941cc0..f144d5b 100644 --- a/lib/features/feed/presentation/feed_screen.dart +++ b/lib/features/feed/presentation/feed_screen.dart @@ -63,6 +63,21 @@ class _FeedScreenState extends ConsumerState { }); } + @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 void dispose() { _scrollSaveDebounce?.cancel(); diff --git a/lib/features/post/presentation/widgets/post_media_viewer.dart b/lib/features/post/presentation/widgets/post_media_viewer.dart index aa00031..d946c0f 100644 --- a/lib/features/post/presentation/widgets/post_media_viewer.dart +++ b/lib/features/post/presentation/widgets/post_media_viewer.dart @@ -279,7 +279,14 @@ class _PostMediaViewerState extends State } Future _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); _showControls(); } @@ -994,7 +1001,7 @@ class _VideoSurface extends StatelessWidget { }, ), if (errorMessage != null) - _VideoErrorOverlay(message: errorMessage!, onRetry: onRetry), + VideoErrorOverlay(message: errorMessage!, onRetry: onRetry), if (errorMessage == null) StreamBuilder( stream: player.stream.playing, @@ -1040,21 +1047,24 @@ class _VideoSurface extends StatelessWidget { ); }, ), - AnimatedOpacity( - opacity: controlsVisible || errorMessage != null ? 1 : 0, - duration: const Duration(milliseconds: 180), - child: _VideoControls( - player: player, - muted: muted, - halfVolume: halfVolume, - loopVideo: loopVideo, - coverVideo: coverVideo, - fullscreen: fullscreen, - onToggleFit: onToggleFit, - onToggleMute: onToggleMute, - onToggleHalfVolume: onToggleHalfVolume, - onToggleLoop: onToggleLoop, - onFullscreen: onFullscreen, + IgnorePointer( + ignoring: !controlsVisible || errorMessage != null, + child: AnimatedOpacity( + opacity: controlsVisible || errorMessage != null ? 1 : 0, + duration: const Duration(milliseconds: 180), + child: _VideoControls( + player: player, + muted: muted, + halfVolume: halfVolume, + loopVideo: loopVideo, + coverVideo: coverVideo, + fullscreen: fullscreen, + onToggleFit: onToggleFit, + onToggleMute: onToggleMute, + onToggleHalfVolume: onToggleHalfVolume, + onToggleLoop: onToggleLoop, + onFullscreen: onFullscreen, + ), ), ), ], @@ -1080,10 +1090,11 @@ class _VideoSurface extends StatelessWidget { } } -class _VideoErrorOverlay extends StatelessWidget { - const _VideoErrorOverlay({ +class VideoErrorOverlay extends StatelessWidget { + const VideoErrorOverlay({ required this.message, required this.onRetry, + super.key, }); final String message; @@ -1210,186 +1221,184 @@ class _VideoControls extends StatelessWidget { @override Widget build(BuildContext context) { final scheme = Theme.of(context).colorScheme; - return Container( - 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, - ], + return Align( + alignment: Alignment.bottomCenter, + child: SafeArea( + minimum: EdgeInsets.fromLTRB( + fullscreen ? 14 : 8, + 0, + fullscreen ? 14 : 8, + fullscreen ? 10 : 8, ), - ), - child: Align( - alignment: Alignment.bottomCenter, - child: SafeArea( - minimum: EdgeInsets.fromLTRB( - fullscreen ? 14 : 8, - 0, - fullscreen ? 14 : 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: StreamBuilder( - stream: player.stream.playing, - initialData: player.state.playing, - builder: (context, playingSnapshot) { - final playing = playingSnapshot.data ?? false; - return StreamBuilder( - stream: player.stream.duration, - initialData: player.state.duration, - builder: (context, durationSnapshot) { - final duration = durationSnapshot.data ?? Duration.zero; - return StreamBuilder( - stream: player.stream.position, - initialData: player.state.position, - builder: (context, positionSnapshot) { - final position = positionSnapshot.data ?? Duration.zero; - final maxMs = - duration.inMilliseconds.clamp(1, 1 << 31).toDouble(); - final valueMs = position.inMilliseconds - .clamp(0, maxMs.toInt()) - .toDouble(); - return LayoutBuilder( - builder: (context, constraints) { - final compact = constraints.maxWidth < 560; - final tiny = constraints.maxWidth < 390; - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - SliderTheme( - data: SliderTheme.of(context).copyWith( - activeTrackColor: scheme.primary, - inactiveTrackColor: - Colors.white.withValues(alpha: 0.24), - trackHeight: 3, - thumbColor: scheme.primary, - overlayColor: - scheme.primary.withValues(alpha: 0.18), - thumbShape: const RoundSliderThumbShape( - enabledThumbRadius: 5, + child: Padding( + padding: const EdgeInsets.fromLTRB(10, 4, 10, 8), + child: StreamBuilder( + stream: player.stream.playing, + initialData: player.state.playing, + builder: (context, playingSnapshot) { + final playing = playingSnapshot.data ?? false; + return StreamBuilder( + stream: player.stream.duration, + initialData: player.state.duration, + builder: (context, durationSnapshot) { + final duration = durationSnapshot.data ?? Duration.zero; + return StreamBuilder( + stream: player.stream.position, + initialData: player.state.position, + builder: (context, positionSnapshot) { + final position = positionSnapshot.data ?? Duration.zero; + final maxMs = duration.inMilliseconds + .clamp(1, 1 << 31) + .toDouble(); + final valueMs = position.inMilliseconds + .clamp(0, maxMs.toInt()) + .toDouble(); + return LayoutBuilder( + builder: (context, constraints) { + final compact = constraints.maxWidth < 560; + final tiny = constraints.maxWidth < 390; + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + SliderTheme( + data: SliderTheme.of(context).copyWith( + activeTrackColor: scheme.primary, + inactiveTrackColor: + Colors.white.withValues(alpha: 0.24), + trackHeight: 3, + thumbColor: scheme.primary, + overlayColor: + scheme.primary.withValues(alpha: 0.18), + thumbShape: const RoundSliderThumbShape( + enabledThumbRadius: 5, + ), ), - ), - child: Slider( - value: valueMs, - max: maxMs, - onChanged: duration == Duration.zero - ? null - : (value) => player.seek( - Duration( - milliseconds: value.round(), + child: Slider( + value: valueMs, + max: maxMs, + onChanged: duration == Duration.zero + ? null + : (value) => player.seek( + Duration( + milliseconds: value.round(), + ), ), - ), + ), ), - ), - Row( - 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) ...[ + Row( + children: [ _RoundControlButton( - tooltip: 'Back 10s', - onPressed: () => player.seek( - _clampSeek( - position - - const Duration(seconds: 10), - duration, - ), + 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, ), - icon: Icons.replay_10_rounded, ), - const SizedBox(width: 5), - _RoundControlButton( - tooltip: 'Forward 10s', - onPressed: () => player.seek( - _clampSeek( - position + - const Duration(seconds: 10), - duration, + const Spacer(), + if (!compact) ...[ + _RoundControlButton( + tooltip: 'Back 10s', + onPressed: () => player.seek( + _clampSeek( + position - + const Duration(seconds: 10), + duration, + ), ), + icon: Icons.replay_10_rounded, ), - icon: Icons.forward_10_rounded, - ), - const SizedBox(width: 5), - ], - _RoundControlButton( - tooltip: muted ? 'Unmute' : 'Mute', - onPressed: onToggleMute, - icon: muted - ? Icons.volume_off_rounded - : Icons.volume_up_rounded, - ), - 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) ...[ + const SizedBox(width: 5), + _RoundControlButton( + tooltip: 'Forward 10s', + onPressed: () => player.seek( + _clampSeek( + position + + const Duration(seconds: 10), + duration, + ), + ), + icon: Icons.forward_10_rounded, + ), + const SizedBox(width: 5), + ], _RoundControlButton( - tooltip: coverVideo ? 'Fit' : 'Fill', - selected: coverVideo, - onPressed: onToggleFit, - icon: coverVideo - ? Icons.fit_screen_rounded - : Icons.crop_free_rounded, + tooltip: muted ? 'Unmute' : 'Mute', + onPressed: onToggleMute, + icon: muted + ? Icons.volume_off_rounded + : Icons.volume_up_rounded, ), 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, - ), - ], - ), - ], - ); - }, - ); - }, - ); - }, - ); - }, + ), + ], + ); + }, + ); + }, + ); + }, + ); + }, + ), ), ), ), diff --git a/lib/shared/widgets/app_search_bar.dart b/lib/shared/widgets/app_search_bar.dart index 1c6cf88..9c599f4 100644 --- a/lib/shared/widgets/app_search_bar.dart +++ b/lib/shared/widgets/app_search_bar.dart @@ -133,11 +133,8 @@ class _TagInputSearchBarState extends State { autocorrect: false, enableSuggestions: false, textInputAction: TextInputAction.search, - inputFormatters: [ - _TagCommitFormatter(onCommit: _commitDraft), - ], onSubmitted: (_) => _submit(), - onChanged: (_) => _notifyChangedDebounced(), + onChanged: _handleDraftChanged, decoration: InputDecoration( isDense: true, border: InputBorder.none, @@ -200,6 +197,12 @@ class _TagInputSearchBarState extends State { KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) { 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) { return KeyEventResult.ignored; } @@ -216,6 +219,17 @@ class _TagInputSearchBarState extends State { 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) { _tags = _parseTags(query); _controller.clear(); @@ -310,25 +324,6 @@ class _TagInputSearchBarState extends State { } } -class _TagCommitFormatter extends TextInputFormatter { - _TagCommitFormatter({required this.onCommit}); - - final ValueChanged 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 { const _TagSuggestionDropdown({ required this.suggestions, diff --git a/lib/shared/widgets/tag_chip.dart b/lib/shared/widgets/tag_chip.dart index c2d1831..302ca62 100644 --- a/lib/shared/widgets/tag_chip.dart +++ b/lib/shared/widgets/tag_chip.dart @@ -8,9 +8,16 @@ class TagChip extends StatelessWidget { @override Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; return ActionChip( onPressed: onTap, 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, ); } diff --git a/pubspec.yaml b/pubspec.yaml index ce54309..353a5ff 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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.2+6 +version: 0.4.3+7 environment: sdk: ">=3.3.0 <4.0.0" diff --git a/test/backend/isar_integration_test.dart b/test/backend/isar_integration_test.dart index 19eebc1..6c0b7b6 100644 --- a/test/backend/isar_integration_test.dart +++ b/test/backend/isar_integration_test.dart @@ -139,6 +139,47 @@ void main() { '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; + + 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 { final service = CacheService(databaseService); diff --git a/test/shared/video_error_overlay_test.dart b/test/shared/video_error_overlay_test.dart new file mode 100644 index 0000000..15a5436 --- /dev/null +++ b/test/shared/video_error_overlay_test.dart @@ -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); + }); +} diff --git a/windows/CMakeLists.txt b/windows/CMakeLists.txt index c959211..3351b18 100644 --- a/windows/CMakeLists.txt +++ b/windows/CMakeLists.txt @@ -66,7 +66,7 @@ include(flutter/generated_plugins.cmake) set(BUILD_BUNDLE_DIR "$") # Make the "install" step default, as it's required to run. 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) endif()