fix: migrate search suggestions to OverlayPortal, unblock toolbar & chip touches (v2.3.4)
This commit is contained in:
@@ -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(
|
||||||
|
|||||||
@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user