Compare commits

..
1 Commits
6 changed files with 372 additions and 196 deletions
+11
View File
@@ -1,4 +1,15 @@
const lunarisChangelog = [ const lunarisChangelog = [
LunarisChange(
version: '2.3.4',
title: 'OverlayPortal Architecture & Search UI Unblocking',
bullets: [
'OverlayPortal Migration: replaced manual root OverlayEntry and translucent backdrop with Flutter native OverlayPortal and TapRegion. Completely eliminates ghost overlay leaks and touch interception.',
'Unblocked Toolbar & Search Bar GUI: buttons (Refresh / Обновить, Clear Filters / Сбросить, random post, filter chips) and search chip crosses now respond immediately to taps without being swallowed by overlay backdrops.',
'Instant Chip Removal: deleting a tag chip (via its X cross icon) now updates the search immediately in real time, synchronizing the feed without restoring deleted tags on rebuild.',
'Search Clear Synchronization: tapping the search bar clear button (X) resets the query and clears active filters immediately.',
'Non-Blocking Outside Tap: tapping outside the suggestion dropdown smoothly dismisses suggestions while allowing the touch event to pass through directly to the underlying button or post.',
],
),
LunarisChange( LunarisChange(
version: '2.3.3', version: '2.3.3',
title: 'Tag Suggestions Overlay Fix & Rule34 API Alignment', title: 'Tag Suggestions Overlay Fix & Rule34 API Alignment',
@@ -98,6 +98,14 @@ class _FeedToolbarState extends State<FeedToolbar> {
_query = value; _query = value;
widget.onSuggestionTap(value); widget.onSuggestionTap(value);
}, },
onTagRemoved: (value) {
_query = value;
widget.onSearch(value);
},
onCleared: () {
_query = '';
widget.onClearFilters();
},
), ),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
@@ -221,6 +229,14 @@ class _FeedToolbarState extends State<FeedToolbar> {
_query = value; _query = value;
widget.onSuggestionTap(value); widget.onSuggestionTap(value);
}, },
onTagRemoved: (value) {
_query = value;
widget.onSearch(value);
},
onCleared: () {
_query = '';
widget.onClearFilters();
},
), ),
), ),
const SizedBox(width: 6), const SizedBox(width: 6),
@@ -105,6 +105,12 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
onSuggestionApplied: (query) => ref onSuggestionApplied: (query) => ref
.read(searchControllerProvider.notifier) .read(searchControllerProvider.notifier)
.updateQuery(query), .updateQuery(query),
onTagRemoved: (query) => ref
.read(searchControllerProvider.notifier)
.updateQuery(query),
onCleared: () => ref
.read(searchControllerProvider.notifier)
.updateQuery(''),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
SingleChildScrollView( SingleChildScrollView(
+221 -195
View File
@@ -28,6 +28,8 @@ class TagInputSearchBar extends StatefulWidget {
required this.onSubmitted, required this.onSubmitted,
this.onChanged, this.onChanged,
this.onSuggestionApplied, this.onSuggestionApplied,
this.onTagRemoved,
this.onCleared,
this.initialValue, this.initialValue,
this.hintText = 'Search tags', this.hintText = 'Search tags',
this.suggestions = const [], this.suggestions = const [],
@@ -37,6 +39,8 @@ class TagInputSearchBar extends StatefulWidget {
final ValueChanged<String> onSubmitted; final ValueChanged<String> onSubmitted;
final ValueChanged<String>? onChanged; final ValueChanged<String>? onChanged;
final ValueChanged<String>? onSuggestionApplied; final ValueChanged<String>? onSuggestionApplied;
final ValueChanged<String>? onTagRemoved;
final VoidCallback? onCleared;
final String? initialValue; final String? initialValue;
final String hintText; final String hintText;
final List<TagSuggestion> suggestions; final List<TagSuggestion> suggestions;
@@ -48,10 +52,10 @@ class TagInputSearchBar extends StatefulWidget {
class _TagInputSearchBarState extends State<TagInputSearchBar> { class _TagInputSearchBarState extends State<TagInputSearchBar> {
final _fieldKey = GlobalKey(); final _fieldKey = GlobalKey();
final _layerLink = LayerLink(); final _layerLink = LayerLink();
final _portalController = OverlayPortalController();
late final TextEditingController _controller; late final TextEditingController _controller;
late final FocusNode _focusNode; late final FocusNode _focusNode;
late final ScrollController _tagScrollController; late final ScrollController _tagScrollController;
OverlayEntry? _suggestionsOverlay;
Timer? _debounce; Timer? _debounce;
List<String> _tags = []; List<String> _tags = [];
String _lastExternalValue = ''; String _lastExternalValue = '';
@@ -70,8 +74,8 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
} }
void _handleControllerChanged() { void _handleControllerChanged() {
if (_controller.text.trim().isEmpty && _suggestionsOverlay != null) { if (_controller.text.trim().isEmpty && _portalController.isShowing) {
_removeSuggestionsOverlay(); _portalController.hide();
} }
} }
@@ -87,14 +91,16 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
_localDirty = false; _localDirty = false;
} }
if (oldWidget.suggestions != widget.suggestions) { if (oldWidget.suggestions != widget.suggestions) {
_syncSuggestionsOverlay(); _syncSuggestions();
} }
} }
@override @override
void dispose() { void dispose() {
_debounce?.cancel(); _debounce?.cancel();
_removeSuggestionsOverlay(); if (_portalController.isShowing) {
_portalController.hide();
}
_tagScrollController.dispose(); _tagScrollController.dispose();
_controller.removeListener(_handleControllerChanged); _controller.removeListener(_handleControllerChanged);
_focusNode.removeListener(_handleFocusChanged); _focusNode.removeListener(_handleFocusChanged);
@@ -106,7 +112,9 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
void _handleFocusChanged() { void _handleFocusChanged() {
if (!mounted) return; if (!mounted) return;
if (!_focusNode.hasFocus) { if (!_focusNode.hasFocus) {
_removeSuggestionsOverlay(); if (_portalController.isShowing) {
_portalController.hide();
}
if (!_localDirty) { if (!_localDirty) {
final next = widget.initialValue ?? ''; final next = widget.initialValue ?? '';
if (next != _lastExternalValue) { if (next != _lastExternalValue) {
@@ -115,103 +123,195 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
} }
} }
} else { } else {
_syncSuggestionsOverlay(); _syncSuggestions();
} }
setState(() {}); setState(() {});
} }
void _syncSuggestions() {
if (!mounted) return;
final token = _activeToken;
if (!_focusNode.hasFocus || token.isEmpty) {
if (_portalController.isShowing) {
_portalController.hide();
}
return;
}
final matches = _matchingSuggestions;
if (matches.isEmpty) {
if (_portalController.isShowing) {
_portalController.hide();
}
return;
}
if (!_portalController.isShowing) {
_portalController.show();
}
}
List<TagSuggestion> get _matchingSuggestions {
final token = _activeToken;
if (token.isEmpty) return const [];
return widget.suggestions
.where((item) => item.name.toLowerCase().startsWith(token))
.toList(growable: false);
}
void _hideSuggestions() {
if (_portalController.isShowing) {
_portalController.hide();
}
_focusNode.unfocus();
}
void _scrollToEnd() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_tagScrollController.hasClients) {
_tagScrollController.animateTo(
_tagScrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: 200),
curve: Curves.easeOut,
);
}
});
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme; final scheme = Theme.of(context).colorScheme;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _syncSuggestionsOverlay(); return TapRegion(
}); groupId: _fieldKey,
return CompositedTransformTarget( onTapOutside: (_) => _hideSuggestions(),
link: _layerLink, child: OverlayPortal(
child: DecoratedBox( controller: _portalController,
key: _fieldKey, overlayChildBuilder: (context) {
decoration: BoxDecoration( final token = _activeToken;
color: Theme.of(context).inputDecorationTheme.fillColor ?? final suggestions = _matchingSuggestions;
scheme.surfaceContainerHighest.withValues(alpha: 0.38), if (suggestions.isEmpty || token.isEmpty) {
borderRadius: BorderRadius.circular(22), return const SizedBox.shrink();
border: Border.all( }
color: _focusNode.hasFocus
? scheme.primary.withValues(alpha: 0.6) final renderBox =
: Colors.white.withValues(alpha: 0.08), _fieldKey.currentContext?.findRenderObject() as RenderBox?;
width: 1.0, final width =
), renderBox?.hasSize == true ? renderBox!.size.width : 360.0;
), final height =
child: InkWell( renderBox?.hasSize == true ? renderBox!.size.height : 48.0;
borderRadius: BorderRadius.circular(22),
onTap: _focusNode.requestFocus, return CompositedTransformFollower(
child: Padding( link: _layerLink,
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 7), showWhenUnlinked: false,
child: Row( offset: Offset(0, height + 6),
crossAxisAlignment: CrossAxisAlignment.center, child: Align(
children: [ alignment: Alignment.topLeft,
Icon(Icons.search_rounded, color: scheme.onSurfaceVariant), child: TapRegion(
const SizedBox(width: 8), groupId: _fieldKey,
Expanded( child: SizedBox(
child: SizedBox( width: width.clamp(280.0, 620.0),
height: 38, child: _TagSuggestionDropdown(
child: Scrollbar( suggestions: suggestions,
controller: _tagScrollController, query: token,
thumbVisibility: false, onSelected: _applySuggestion,
child: SingleChildScrollView( ),
controller: _tagScrollController, ),
scrollDirection: Axis.horizontal, ),
child: Row( ),
mainAxisSize: MainAxisSize.min, );
children: [ },
for (final tag in _tags) ...[ child: CompositedTransformTarget(
_buildTagChip(context, tag), link: _layerLink,
const SizedBox(width: 6), child: DecoratedBox(
], key: _fieldKey,
ConstrainedBox( decoration: BoxDecoration(
constraints: const BoxConstraints( color: Theme.of(context).inputDecorationTheme.fillColor ??
minWidth: 120, scheme.surfaceContainerHighest.withValues(alpha: 0.38),
maxWidth: 300, borderRadius: BorderRadius.circular(22),
), border: Border.all(
child: Focus( color: _focusNode.hasFocus
onKeyEvent: _handleKeyEvent, ? scheme.primary.withValues(alpha: 0.6)
child: TextField( : Colors.white.withValues(alpha: 0.08),
controller: _controller, width: 1.0,
focusNode: _focusNode, ),
autocorrect: false, ),
enableSuggestions: false, child: InkWell(
textInputAction: TextInputAction.search, borderRadius: BorderRadius.circular(22),
onSubmitted: (_) => _submit(), onTap: _focusNode.requestFocus,
onChanged: _handleDraftChanged, child: Padding(
decoration: InputDecoration( padding:
isDense: true, const EdgeInsets.symmetric(horizontal: 10, vertical: 7),
border: InputBorder.none, child: Row(
enabledBorder: InputBorder.none, crossAxisAlignment: CrossAxisAlignment.center,
focusedBorder: InputBorder.none, children: [
filled: false, Icon(Icons.search_rounded,
contentPadding: const EdgeInsets.symmetric( color: scheme.onSurfaceVariant),
vertical: 8, const SizedBox(width: 8),
Expanded(
child: SizedBox(
height: 38,
child: Scrollbar(
controller: _tagScrollController,
thumbVisibility: false,
child: SingleChildScrollView(
controller: _tagScrollController,
scrollDirection: Axis.horizontal,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
for (final tag in _tags) ...[
_buildTagChip(context, tag),
const SizedBox(width: 6),
],
ConstrainedBox(
constraints: const BoxConstraints(
minWidth: 120,
maxWidth: 300,
),
child: Focus(
onKeyEvent: _handleKeyEvent,
child: TextField(
controller: _controller,
focusNode: _focusNode,
autocorrect: false,
enableSuggestions: false,
textInputAction:
TextInputAction.search,
onSubmitted: (_) => _submit(),
onChanged: _handleDraftChanged,
decoration: InputDecoration(
isDense: true,
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
filled: false,
contentPadding:
const EdgeInsets.symmetric(
vertical: 8,
),
hintText: _tags.isEmpty
? widget.hintText
: 'tag',
prefixIcon: null,
suffixIcon: null,
),
), ),
hintText:
_tags.isEmpty ? widget.hintText : 'tag',
prefixIcon: null,
suffixIcon: null,
), ),
), ),
), ],
), ),
], ),
), ),
), ),
), ),
), IconButton(
tooltip: 'Clear',
visualDensity: VisualDensity.compact,
icon: const Icon(Icons.close_rounded),
onPressed: _clear,
),
],
), ),
IconButton( ),
tooltip: 'Clear',
visualDensity: VisualDensity.compact,
icon: const Icon(Icons.close_rounded),
onPressed: _clear,
),
],
), ),
), ),
), ),
@@ -219,55 +319,6 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
); );
} }
void _syncSuggestionsOverlay() {
final token = _activeToken;
if (!_focusNode.hasFocus || token.isEmpty) {
_removeSuggestionsOverlay();
return;
}
final suggestions = widget.suggestions
.where((item) => item.name.toLowerCase().startsWith(token))
.toList(growable: false);
if (suggestions.isEmpty) {
_removeSuggestionsOverlay();
return;
}
final overlay = Overlay.maybeOf(context);
final renderBox =
_fieldKey.currentContext?.findRenderObject() as RenderBox?;
if (overlay == null || renderBox == null || !renderBox.hasSize) {
_removeSuggestionsOverlay();
return;
}
final size = renderBox.size;
_removeSuggestionsOverlay();
_suggestionsOverlay = OverlayEntry(
builder: (context) => _TagSuggestionOverlay(
link: _layerLink,
width: size.width,
yOffset: size.height + 8,
suggestions: suggestions,
query: token,
onSelected: _applySuggestion,
onDismiss: () {
_removeSuggestionsOverlay();
_focusNode.unfocus();
},
),
);
overlay.insert(_suggestionsOverlay!);
}
void _removeSuggestionsOverlay() {
if (_suggestionsOverlay != null) {
if (_suggestionsOverlay!.mounted) {
_suggestionsOverlay!.remove();
}
_suggestionsOverlay = null;
}
}
Widget _buildTagChip(BuildContext context, String tag) { Widget _buildTagChip(BuildContext context, String tag) {
final scheme = Theme.of(context).colorScheme; final scheme = Theme.of(context).colorScheme;
final isAnd = tag.toLowerCase() == 'and'; final isAnd = tag.toLowerCase() == 'and';
@@ -331,7 +382,9 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
_localDirty = true; _localDirty = true;
if (value.trim().isEmpty) { if (value.trim().isEmpty) {
_debounce?.cancel(); _debounce?.cancel();
_removeSuggestionsOverlay(); if (_portalController.isShowing) {
_portalController.hide();
}
setState(() {}); setState(() {});
_notifyChanged(); _notifyChanged();
return; return;
@@ -361,12 +414,18 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
} }
_controller.clear(); _controller.clear();
}); });
if (_portalController.isShowing) {
_portalController.hide();
}
_scrollToEnd();
_notifyChanged(); _notifyChanged();
} }
void _submit() { void _submit() {
_debounce?.cancel(); _debounce?.cancel();
_removeSuggestionsOverlay(); if (_portalController.isShowing) {
_portalController.hide();
}
_commitDraft(_controller.text); _commitDraft(_controller.text);
_lastExternalValue = _query; _lastExternalValue = _query;
_localDirty = false; _localDirty = false;
@@ -376,18 +435,26 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
void _clear() { void _clear() {
_debounce?.cancel(); _debounce?.cancel();
_removeSuggestionsOverlay(); if (_portalController.isShowing) {
_localDirty = true; _portalController.hide();
}
_localDirty = false;
setState(() { setState(() {
_tags = []; _tags = [];
_controller.clear(); _controller.clear();
}); });
_lastExternalValue = '';
_notifyChanged(); _notifyChanged();
_focusNode.unfocus(); _focusNode.unfocus();
widget.onCleared?.call();
widget.onSubmitted('');
} }
void _editTag(String tag) { void _editTag(String tag) {
_localDirty = true; _localDirty = true;
if (_portalController.isShowing) {
_portalController.hide();
}
setState(() { setState(() {
_tags = _tags.where((item) => item != tag).toList(growable: true); _tags = _tags.where((item) => item != tag).toList(growable: true);
_controller.value = TextEditingValue( _controller.value = TextEditingValue(
@@ -401,10 +468,17 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
void _removeTag(String tag) { void _removeTag(String tag) {
_localDirty = true; _localDirty = true;
if (_portalController.isShowing) {
_portalController.hide();
}
setState(() { setState(() {
_tags = _tags.where((item) => item != tag).toList(growable: true); _tags = _tags.where((item) => item != tag).toList(growable: true);
}); });
_lastExternalValue = _query;
_notifyChanged(); _notifyChanged();
if (widget.onTagRemoved != null) {
widget.onTagRemoved!(_query);
}
} }
void _applySuggestion(String suggestion) { void _applySuggestion(String suggestion) {
@@ -427,8 +501,12 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
} }
_controller.clear(); _controller.clear();
}); });
if (_portalController.isShowing) {
_portalController.hide();
}
_lastExternalValue = _query;
_scrollToEnd();
_notifyChanged(); _notifyChanged();
_removeSuggestionsOverlay();
widget.onSuggestionApplied?.call(_query); widget.onSuggestionApplied?.call(_query);
_focusNode.requestFocus(); _focusNode.requestFocus();
} }
@@ -441,7 +519,7 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
void _notifyChanged() { void _notifyChanged() {
_localDirty = true; _localDirty = true;
widget.onChanged?.call(_query); widget.onChanged?.call(_query);
_syncSuggestionsOverlay(); _syncSuggestions();
} }
List<String> _parseTags(String query) { List<String> _parseTags(String query) {
@@ -472,58 +550,6 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
bool get _isEditing => _focusNode.hasFocus || _localDirty; bool get _isEditing => _focusNode.hasFocus || _localDirty;
} }
class _TagSuggestionOverlay extends StatelessWidget {
const _TagSuggestionOverlay({
required this.link,
required this.width,
required this.yOffset,
required this.suggestions,
required this.query,
required this.onSelected,
required this.onDismiss,
});
final LayerLink link;
final double width;
final double yOffset;
final List<TagSuggestion> suggestions;
final String query;
final ValueChanged<String> onSelected;
final VoidCallback onDismiss;
@override
Widget build(BuildContext context) {
return Stack(
children: [
Positioned.fill(
child: GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: onDismiss,
),
),
Positioned.fill(
child: CompositedTransformFollower(
link: link,
showWhenUnlinked: false,
offset: Offset(0, yOffset),
child: Align(
alignment: Alignment.topLeft,
child: SizedBox(
width: width.clamp(280, 620).toDouble(),
child: _TagSuggestionDropdown(
suggestions: suggestions,
query: query,
onSelected: onSelected,
),
),
),
),
),
],
);
}
}
class _TagSuggestionDropdown extends StatelessWidget { class _TagSuggestionDropdown extends StatelessWidget {
const _TagSuggestionDropdown({ const _TagSuggestionDropdown({
required this.suggestions, required this.suggestions,
+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: 2.3.3+43 version: 2.3.4+44
environment: environment:
sdk: ">=3.3.0 <4.0.0" sdk: ">=3.3.0 <4.0.0"
+117
View File
@@ -339,6 +339,123 @@ void main() {
expect(find.text('AND'), findsOneWidget); expect(find.text('AND'), findsOneWidget);
expect(find.text('dog'), findsOneWidget); expect(find.text('dog'), findsOneWidget);
}); });
testWidgets('deleting chip triggers onTagRemoved', (tester) async {
String? removedQuery;
await tester.pumpWidget(_Harness(
child: TagInputSearchBar(
initialValue: 'cat dog',
onSubmitted: (_) {},
onTagRemoved: (query) => removedQuery = query,
),
));
expect(find.text('cat'), findsOneWidget);
expect(find.text('dog'), findsOneWidget);
await tester.tap(find.byIcon(Icons.close_rounded).first);
await tester.pump();
expect(removedQuery, 'dog');
expect(find.text('cat'), findsNothing);
expect(find.text('dog'), findsOneWidget);
});
testWidgets('clear button triggers onCleared and onSubmitted with empty string',
(tester) async {
bool cleared = false;
String? submitted;
await tester.pumpWidget(_Harness(
child: TagInputSearchBar(
initialValue: 'cat dog',
onSubmitted: (query) => submitted = query,
onCleared: () => cleared = true,
),
));
await tester.tap(find.byTooltip('Clear'));
await tester.pump();
expect(cleared, isTrue);
expect(submitted, '');
expect(find.text('cat'), findsNothing);
expect(find.text('dog'), findsNothing);
});
testWidgets(
'tapping external button while suggestions are visible triggers button and hides suggestions',
(tester) async {
bool externalButtonPressed = false;
await tester.pumpWidget(MaterialApp(
home: Scaffold(
body: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Expanded(
child: TagInputSearchBar(
suggestions: const [
TagSuggestion(
name: 'genshin',
category: TagCategory.copyright,
postCount: 100,
providerId: 'test',
),
],
onSubmitted: (_) {},
),
),
const SizedBox(width: 8),
ElevatedButton(
onPressed: () => externalButtonPressed = true,
child: const Text('Обновить'),
),
],
),
),
),
));
await tester.tap(find.byType(TextField));
await tester.enterText(find.byType(TextField), 'gen');
await tester.pumpAndSettle();
expect(find.text('genshin'), findsOneWidget);
// Tap external 'Обновить' button directly while suggestions are up
await tester.tap(find.text('Обновить'));
await tester.pumpAndSettle();
expect(externalButtonPressed, isTrue);
expect(find.text('genshin'), findsNothing);
});
testWidgets('erasing draft immediately hides suggestions', (tester) async {
await tester.pumpWidget(_Harness(
child: TagInputSearchBar(
suggestions: const [
TagSuggestion(
name: 'genshin',
category: TagCategory.copyright,
postCount: 100,
providerId: 'test',
),
],
onSubmitted: (_) {},
),
));
await tester.tap(find.byType(TextField));
await tester.enterText(find.byType(TextField), 'gen');
await tester.pumpAndSettle();
expect(find.text('genshin'), findsOneWidget);
await tester.enterText(find.byType(TextField), '');
await tester.pumpAndSettle();
expect(find.text('genshin'), findsNothing);
});
} }
class _Harness extends StatelessWidget { class _Harness extends StatelessWidget {