Fix video controls, tag input and collection deletion
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user