Improve tag suggestions overlay
This commit is contained in:
@@ -332,7 +332,6 @@ class ProviderManager {
|
|||||||
} catch (_) {
|
} catch (_) {
|
||||||
// Suggestions are non-critical; a failed provider should not affect UI.
|
// Suggestions are non-critical; a failed provider should not affect UI.
|
||||||
}
|
}
|
||||||
if (suggestions.length >= limit) break;
|
|
||||||
}
|
}
|
||||||
final values = suggestions.values.toList()
|
final values = suggestions.values.toList()
|
||||||
..sort((a, b) => b.postCount.compareTo(a.postCount));
|
..sort((a, b) => b.postCount.compareTo(a.postCount));
|
||||||
|
|||||||
@@ -46,9 +46,12 @@ class TagInputSearchBar extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _TagInputSearchBarState extends State<TagInputSearchBar> {
|
class _TagInputSearchBarState extends State<TagInputSearchBar> {
|
||||||
|
final _fieldKey = GlobalKey();
|
||||||
|
final _layerLink = LayerLink();
|
||||||
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 = [];
|
||||||
|
|
||||||
@@ -69,11 +72,15 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
|
|||||||
if (!_focusNode.hasFocus && oldWidget.initialValue != next) {
|
if (!_focusNode.hasFocus && oldWidget.initialValue != next) {
|
||||||
_setFromQuery(next);
|
_setFromQuery(next);
|
||||||
}
|
}
|
||||||
|
if (oldWidget.suggestions != widget.suggestions) {
|
||||||
|
_syncSuggestionsOverlay();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_debounce?.cancel();
|
_debounce?.cancel();
|
||||||
|
_removeSuggestionsOverlay();
|
||||||
_tagScrollController.dispose();
|
_tagScrollController.dispose();
|
||||||
_focusNode.removeListener(_handleFocusChanged);
|
_focusNode.removeListener(_handleFocusChanged);
|
||||||
_focusNode.dispose();
|
_focusNode.dispose();
|
||||||
@@ -82,16 +89,21 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _handleFocusChanged() {
|
void _handleFocusChanged() {
|
||||||
if (mounted) setState(() {});
|
if (!mounted) return;
|
||||||
|
setState(() {});
|
||||||
|
_syncSuggestionsOverlay();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final scheme = Theme.of(context).colorScheme;
|
final scheme = Theme.of(context).colorScheme;
|
||||||
return Column(
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
mainAxisSize: MainAxisSize.min,
|
if (mounted) _syncSuggestionsOverlay();
|
||||||
children: [
|
});
|
||||||
DecoratedBox(
|
return CompositedTransformTarget(
|
||||||
|
link: _layerLink,
|
||||||
|
child: DecoratedBox(
|
||||||
|
key: _fieldKey,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Theme.of(context).inputDecorationTheme.fillColor ??
|
color: Theme.of(context).inputDecorationTheme.fillColor ??
|
||||||
scheme.surfaceContainerHighest.withValues(alpha: 0.38),
|
scheme.surfaceContainerHighest.withValues(alpha: 0.38),
|
||||||
@@ -149,13 +161,11 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
|
|||||||
enabledBorder: InputBorder.none,
|
enabledBorder: InputBorder.none,
|
||||||
focusedBorder: InputBorder.none,
|
focusedBorder: InputBorder.none,
|
||||||
filled: false,
|
filled: false,
|
||||||
contentPadding:
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
const EdgeInsets.symmetric(
|
|
||||||
vertical: 8,
|
vertical: 8,
|
||||||
),
|
),
|
||||||
hintText: _tags.isEmpty
|
hintText:
|
||||||
? widget.hintText
|
_tags.isEmpty ? widget.hintText : 'tag',
|
||||||
: 'tag',
|
|
||||||
prefixIcon: null,
|
prefixIcon: null,
|
||||||
suffixIcon: 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,
|
suggestions: widget.suggestions,
|
||||||
onSelected: _applySuggestion,
|
onSelected: _applySuggestion,
|
||||||
),
|
),
|
||||||
],
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
|
overlay.insert(_suggestionsOverlay!);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_suggestionsOverlay?.markNeedsBuild();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _removeSuggestionsOverlay() {
|
||||||
|
_suggestionsOverlay?.remove();
|
||||||
|
_suggestionsOverlay = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildTagChip(BuildContext context, String tag) {
|
Widget _buildTagChip(BuildContext context, String tag) {
|
||||||
@@ -264,6 +298,7 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
|
|||||||
void _submit() {
|
void _submit() {
|
||||||
_debounce?.cancel();
|
_debounce?.cancel();
|
||||||
_commitDraft(_controller.text);
|
_commitDraft(_controller.text);
|
||||||
|
_removeSuggestionsOverlay();
|
||||||
widget.onSubmitted(_query);
|
widget.onSubmitted(_query);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -307,6 +342,7 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
|
|||||||
_controller.clear();
|
_controller.clear();
|
||||||
});
|
});
|
||||||
_notifyChanged();
|
_notifyChanged();
|
||||||
|
_removeSuggestionsOverlay();
|
||||||
widget.onSuggestionApplied?.call(_query);
|
widget.onSuggestionApplied?.call(_query);
|
||||||
_focusNode.requestFocus();
|
_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 {
|
class _TagSuggestionDropdown extends StatelessWidget {
|
||||||
const _TagSuggestionDropdown({
|
const _TagSuggestionDropdown({
|
||||||
required this.suggestions,
|
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/post.dart';
|
||||||
import 'package:gel_rule_app/backend/models/provider_diagnostics.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/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/models/top_period_filter.dart';
|
||||||
import 'package:gel_rule_app/backend/providers/content_provider.dart';
|
import 'package:gel_rule_app/backend/providers/content_provider.dart';
|
||||||
import 'package:gel_rule_app/backend/providers/provider_factory.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]!;
|
ContentProvider create(ContentProviderConfig config) => providers[config.id]!;
|
||||||
}
|
}
|
||||||
|
|
||||||
class FakeProvider implements ContentProvider {
|
class FakeProvider implements ContentProvider, TagSuggestionProvider {
|
||||||
FakeProvider(this.id, this.name, this.posts, {this.failSearch = false});
|
FakeProvider(
|
||||||
|
this.id,
|
||||||
|
this.name,
|
||||||
|
this.posts, {
|
||||||
|
this.failSearch = false,
|
||||||
|
this.suggestions = const [],
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
final String id;
|
final String id;
|
||||||
@@ -87,6 +94,7 @@ class FakeProvider implements ContentProvider {
|
|||||||
String get baseUrl => 'https://example.test';
|
String get baseUrl => 'https://example.test';
|
||||||
final List<Post> posts;
|
final List<Post> posts;
|
||||||
final bool failSearch;
|
final bool failSearch;
|
||||||
|
final List<TagSuggestion> suggestions;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<ProviderHealth> checkHealth() async => ProviderHealth(
|
Future<ProviderHealth> checkHealth() async => ProviderHealth(
|
||||||
@@ -111,6 +119,15 @@ class FakeProvider implements ContentProvider {
|
|||||||
if (failSearch) throw Exception('fail');
|
if (failSearch) throw Exception('fail');
|
||||||
return posts;
|
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}) {
|
ContentProviderConfig config(String id, int priority, {bool enabled = true}) {
|
||||||
@@ -224,4 +241,66 @@ void main() {
|
|||||||
await manager.enableProvider('a', false);
|
await manager.enableProvider('a', false);
|
||||||
expect(repository.configs['a']!.enabled, isFalse);
|
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');
|
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 {
|
testWidgets('many chips stay in a single compact row', (tester) async {
|
||||||
await tester.pumpWidget(_Harness(
|
await tester.pumpWidget(_Harness(
|
||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
|
|||||||
Reference in New Issue
Block a user