Improve tag suggestions overlay

This commit is contained in:
2026-06-03 13:36:52 +03:00
parent f0bf2ea515
commit 26cc1b321b
4 changed files with 273 additions and 90 deletions
@@ -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));
+85 -15
View File
@@ -46,9 +46,12 @@ class TagInputSearchBar extends StatefulWidget {
}
class _TagInputSearchBarState extends State<TagInputSearchBar> {
final _fieldKey = GlobalKey();
final _layerLink = LayerLink();
late final TextEditingController _controller;
late final FocusNode _focusNode;
late final ScrollController _tagScrollController;
OverlayEntry? _suggestionsOverlay;
Timer? _debounce;
List<String> _tags = [];
@@ -69,11 +72,15 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
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,16 +89,21 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
}
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(
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),
@@ -149,13 +161,11 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
filled: false,
contentPadding:
const EdgeInsets.symmetric(
contentPadding: const EdgeInsets.symmetric(
vertical: 8,
),
hintText: _tags.isEmpty
? widget.hintText
: 'tag',
hintText:
_tags.isEmpty ? widget.hintText : 'tag',
prefixIcon: null,
suffixIcon: null,
),
@@ -179,15 +189,39 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
),
),
),
if (widget.suggestions.isNotEmpty) ...[
const SizedBox(height: 8),
_TagSuggestionDropdown(
);
}
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) {
@@ -264,6 +298,7 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
void _submit() {
_debounce?.cancel();
_commitDraft(_controller.text);
_removeSuggestionsOverlay();
widget.onSubmitted(_query);
}
@@ -307,6 +342,7 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
_controller.clear();
});
_notifyChanged();
_removeSuggestionsOverlay();
widget.onSuggestionApplied?.call(_query);
_focusNode.requestFocus();
}
@@ -338,6 +374,40 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
}
}
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<TagSuggestion> suggestions;
final ValueChanged<String> 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,
+81 -2
View File
@@ -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<Post> posts;
final bool failSearch;
final List<TagSuggestion> suggestions;
@override
Future<ProviderHealth> checkHealth() async => ProviderHealth(
@@ -111,6 +119,15 @@ class FakeProvider implements ContentProvider {
if (failSearch) throw Exception('fail');
return posts;
}
@override
Future<List<TagSuggestion>> 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<List<TagSuggestion>>;
expect(result.data.map((item) => item.providerId).toSet(), {
'gelbooru',
'e621',
'e926',
});
});
}
@@ -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(