From 26cc1b321b353d909ec972078531a11056c11491 Mon Sep 17 00:00:00 2001 From: RarDog Date: Wed, 3 Jun 2026 13:36:52 +0300 Subject: [PATCH] Improve tag suggestions overlay --- lib/backend/providers/provider_manager.dart | 1 - lib/shared/widgets/app_search_bar.dart | 244 +++++++++++++------- test/backend/provider_manager_test.dart | 83 ++++++- test/shared/tag_input_search_bar_test.dart | 35 +++ 4 files changed, 273 insertions(+), 90 deletions(-) diff --git a/lib/backend/providers/provider_manager.dart b/lib/backend/providers/provider_manager.dart index 27591de..ac2f2c9 100644 --- a/lib/backend/providers/provider_manager.dart +++ b/lib/backend/providers/provider_manager.dart @@ -332,7 +332,6 @@ class ProviderManager { } catch (_) { // Suggestions are non-critical; a failed provider should not affect UI. } - if (suggestions.length >= limit) break; } final values = suggestions.values.toList() ..sort((a, b) => b.postCount.compareTo(a.postCount)); diff --git a/lib/shared/widgets/app_search_bar.dart b/lib/shared/widgets/app_search_bar.dart index 396804a..bed5478 100644 --- a/lib/shared/widgets/app_search_bar.dart +++ b/lib/shared/widgets/app_search_bar.dart @@ -46,9 +46,12 @@ class TagInputSearchBar extends StatefulWidget { } class _TagInputSearchBarState extends State { + final _fieldKey = GlobalKey(); + final _layerLink = LayerLink(); late final TextEditingController _controller; late final FocusNode _focusNode; late final ScrollController _tagScrollController; + OverlayEntry? _suggestionsOverlay; Timer? _debounce; List _tags = []; @@ -69,11 +72,15 @@ class _TagInputSearchBarState extends State { if (!_focusNode.hasFocus && oldWidget.initialValue != next) { _setFromQuery(next); } + if (oldWidget.suggestions != widget.suggestions) { + _syncSuggestionsOverlay(); + } } @override void dispose() { _debounce?.cancel(); + _removeSuggestionsOverlay(); _tagScrollController.dispose(); _focusNode.removeListener(_handleFocusChanged); _focusNode.dispose(); @@ -82,114 +89,141 @@ class _TagInputSearchBarState extends State { } void _handleFocusChanged() { - if (mounted) setState(() {}); + if (!mounted) return; + setState(() {}); + _syncSuggestionsOverlay(); } @override Widget build(BuildContext context) { final scheme = Theme.of(context).colorScheme; - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - DecoratedBox( - decoration: BoxDecoration( - color: Theme.of(context).inputDecorationTheme.fillColor ?? - scheme.surfaceContainerHighest.withValues(alpha: 0.38), - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: _focusNode.hasFocus - ? scheme.primary.withValues(alpha: 0.45) - : Colors.transparent, - ), + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _syncSuggestionsOverlay(); + }); + return CompositedTransformTarget( + link: _layerLink, + child: DecoratedBox( + key: _fieldKey, + decoration: BoxDecoration( + color: Theme.of(context).inputDecorationTheme.fillColor ?? + scheme.surfaceContainerHighest.withValues(alpha: 0.38), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: _focusNode.hasFocus + ? scheme.primary.withValues(alpha: 0.45) + : Colors.transparent, ), - child: InkWell( - borderRadius: BorderRadius.circular(12), - onTap: _focusNode.requestFocus, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 7), - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Icon(Icons.search_rounded, color: scheme.onSurfaceVariant), - const SizedBox(width: 8), - Expanded( - child: SizedBox( - height: 38, - child: Scrollbar( + ), + child: InkWell( + borderRadius: BorderRadius.circular(12), + onTap: _focusNode.requestFocus, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 7), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Icon(Icons.search_rounded, color: scheme.onSurfaceVariant), + const SizedBox(width: 8), + Expanded( + child: SizedBox( + height: 38, + child: Scrollbar( + controller: _tagScrollController, + thumbVisibility: false, + child: SingleChildScrollView( 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, + 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, ), ), ), - ], - ), + ), + ], ), ), ), ), - 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, + ), + ], ), ), ), - if (widget.suggestions.isNotEmpty) ...[ - const SizedBox(height: 8), - _TagSuggestionDropdown( - suggestions: widget.suggestions, - onSelected: _applySuggestion, - ), - ], - ], + ), ); } + void _syncSuggestionsOverlay() { + if (!_focusNode.hasFocus || widget.suggestions.isEmpty) { + _removeSuggestionsOverlay(); + return; + } + final overlay = Overlay.maybeOf(context); + final renderBox = + _fieldKey.currentContext?.findRenderObject() as RenderBox?; + if (overlay == null || renderBox == null || !renderBox.hasSize) return; + + final size = renderBox.size; + if (_suggestionsOverlay == null) { + _suggestionsOverlay = OverlayEntry( + builder: (context) => _TagSuggestionOverlay( + link: _layerLink, + width: size.width, + yOffset: size.height + 8, + suggestions: widget.suggestions, + onSelected: _applySuggestion, + ), + ); + overlay.insert(_suggestionsOverlay!); + return; + } + _suggestionsOverlay?.markNeedsBuild(); + } + + void _removeSuggestionsOverlay() { + _suggestionsOverlay?.remove(); + _suggestionsOverlay = null; + } + Widget _buildTagChip(BuildContext context, String tag) { final scheme = Theme.of(context).colorScheme; return InputChip( @@ -264,6 +298,7 @@ class _TagInputSearchBarState extends State { void _submit() { _debounce?.cancel(); _commitDraft(_controller.text); + _removeSuggestionsOverlay(); widget.onSubmitted(_query); } @@ -307,6 +342,7 @@ class _TagInputSearchBarState extends State { _controller.clear(); }); _notifyChanged(); + _removeSuggestionsOverlay(); widget.onSuggestionApplied?.call(_query); _focusNode.requestFocus(); } @@ -338,6 +374,40 @@ class _TagInputSearchBarState extends State { } } +class _TagSuggestionOverlay extends StatelessWidget { + const _TagSuggestionOverlay({ + required this.link, + required this.width, + required this.yOffset, + required this.suggestions, + required this.onSelected, + }); + + final LayerLink link; + final double width; + final double yOffset; + final List suggestions; + final ValueChanged onSelected; + + @override + Widget build(BuildContext context) { + return Positioned.fill( + child: CompositedTransformFollower( + link: link, + showWhenUnlinked: false, + offset: Offset(0, yOffset), + child: SizedBox( + width: width.clamp(280, 620).toDouble(), + child: _TagSuggestionDropdown( + suggestions: suggestions, + onSelected: onSelected, + ), + ), + ), + ); + } +} + class _TagSuggestionDropdown extends StatelessWidget { const _TagSuggestionDropdown({ required this.suggestions, diff --git a/test/backend/provider_manager_test.dart b/test/backend/provider_manager_test.dart index a62e32f..62d8373 100644 --- a/test/backend/provider_manager_test.dart +++ b/test/backend/provider_manager_test.dart @@ -3,6 +3,7 @@ import 'package:gel_rule_app/backend/models/content_provider_config.dart'; import 'package:gel_rule_app/backend/models/post.dart'; import 'package:gel_rule_app/backend/models/provider_diagnostics.dart'; import 'package:gel_rule_app/backend/models/provider_health.dart'; +import 'package:gel_rule_app/backend/models/tag_suggestion.dart'; import 'package:gel_rule_app/backend/models/top_period_filter.dart'; import 'package:gel_rule_app/backend/providers/content_provider.dart'; import 'package:gel_rule_app/backend/providers/provider_factory.dart'; @@ -76,8 +77,14 @@ class FakeProviderFactory extends ProviderFactory { ContentProvider create(ContentProviderConfig config) => providers[config.id]!; } -class FakeProvider implements ContentProvider { - FakeProvider(this.id, this.name, this.posts, {this.failSearch = false}); +class FakeProvider implements ContentProvider, TagSuggestionProvider { + FakeProvider( + this.id, + this.name, + this.posts, { + this.failSearch = false, + this.suggestions = const [], + }); @override final String id; @@ -87,6 +94,7 @@ class FakeProvider implements ContentProvider { String get baseUrl => 'https://example.test'; final List posts; final bool failSearch; + final List suggestions; @override Future checkHealth() async => ProviderHealth( @@ -111,6 +119,15 @@ class FakeProvider implements ContentProvider { if (failSearch) throw Exception('fail'); return posts; } + + @override + Future> suggestTags(String query, + {int limit = 20}) async { + return suggestions + .where((suggestion) => suggestion.name.startsWith(query)) + .take(limit) + .toList(growable: false); + } } ContentProviderConfig config(String id, int priority, {bool enabled = true}) { @@ -224,4 +241,66 @@ void main() { await manager.enableProvider('a', false); expect(repository.configs['a']!.enabled, isFalse); }); + + test('tag suggestions query every active suggestion provider before limiting', + () async { + final repository = FakeProviderRepository() + ..configs['gelbooru'] = config('gelbooru', 0) + ..configs['e621'] = config('e621', 1) + ..configs['e926'] = config('e926', 2); + final manager = ProviderManager( + repository, + FakeProviderFactory({ + 'gelbooru': FakeProvider( + 'gelbooru', + 'Gelbooru', + [], + suggestions: [ + for (var i = 0; i < 4; i++) + TagSuggestion( + name: 'cat_$i', + category: TagCategory.general, + postCount: 100 - i, + providerId: 'gelbooru', + ), + ], + ), + 'e621': FakeProvider( + 'e621', + 'e621', + [], + suggestions: const [ + TagSuggestion( + name: 'cat_tail', + category: TagCategory.general, + postCount: 1000, + providerId: 'e621', + ), + ], + ), + 'e926': FakeProvider( + 'e926', + 'e926', + [], + suggestions: const [ + TagSuggestion( + name: 'cat_ears', + category: TagCategory.general, + postCount: 900, + providerId: 'e926', + ), + ], + ), + }), + ); + + final result = await manager.suggestTags('cat', limit: 4) + as Success>; + + expect(result.data.map((item) => item.providerId).toSet(), { + 'gelbooru', + 'e621', + 'e926', + }); + }); } diff --git a/test/shared/tag_input_search_bar_test.dart b/test/shared/tag_input_search_bar_test.dart index f75d7bb..680d45f 100644 --- a/test/shared/tag_input_search_bar_test.dart +++ b/test/shared/tag_input_search_bar_test.dart @@ -127,6 +127,41 @@ void main() { expect(applied, 'touhou'); }); + testWidgets('suggestions overlay does not resize search bar', (tester) async { + await tester.pumpWidget(_Harness( + child: SizedBox( + width: 360, + child: TagInputSearchBar( + suggestions: const [ + TagSuggestion( + name: 'touhou', + category: TagCategory.copyright, + postCount: 1200, + providerId: 'gelbooru', + ), + TagSuggestion( + name: 'tail', + category: TagCategory.general, + postCount: 900, + providerId: 'e621', + ), + ], + onSubmitted: (_) {}, + ), + ), + )); + + final initialHeight = tester.getSize(find.byType(TagInputSearchBar)).height; + await tester.tap(find.byType(TextField)); + await tester.pump(); + await tester.pump(); + + expect(find.text('touhou'), findsOneWidget); + expect(find.text('tail'), findsOneWidget); + expect( + tester.getSize(find.byType(TagInputSearchBar)).height, initialHeight); + }); + testWidgets('many chips stay in a single compact row', (tester) async { await tester.pumpWidget(_Harness( child: SizedBox(