Improve tag suggestions overlay
This commit is contained in:
@@ -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));
|
||||
|
||||
@@ -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,114 +89,141 @@ 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(
|
||||
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<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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user