Add provider tag suggestions and tag categories

This commit is contained in:
2026-05-23 15:41:23 +03:00
parent 1b763c4d62
commit b02c776628
30 changed files with 1119 additions and 77 deletions
@@ -73,10 +73,12 @@ class FeedController extends AsyncNotifier<FeedState> {
return;
}
final lastToken = query.trim().split(RegExp(r'\s+')).last;
final result =
await ref.read(searchServiceProvider).autocomplete(lastToken);
final suggestions =
result is Success<List<String>> ? result.data : const <String>[];
final result = await ref
.read(searchServiceProvider)
.autocompleteDetailed(lastToken, limit: 16);
final suggestions = result is Success<List<TagSuggestion>>
? result.data
: const <TagSuggestion>[];
state = AsyncData(current.copyWith(tagSuggestions: suggestions));
}
@@ -19,7 +19,7 @@ class FeedState {
final List<String> selectedProviderIds;
final String? ratingFilter;
final TopPeriodFilter topPeriodFilter;
final List<String> tagSuggestions;
final List<TagSuggestion> tagSuggestions;
final List<ContentProviderConfig> providers;
final bool isLoadingMore;
final bool hasMore;
@@ -31,7 +31,7 @@ class FeedState {
List<String>? selectedProviderIds,
String? ratingFilter,
TopPeriodFilter? topPeriodFilter,
List<String>? tagSuggestions,
List<TagSuggestion>? tagSuggestions,
bool clearRating = false,
List<ContentProviderConfig>? providers,
bool? isLoadingMore,
@@ -33,7 +33,7 @@ class FeedToolbar extends StatefulWidget {
final List<String> selectedTags;
final List<String> selectedProviderIds;
final TopPeriodFilter topPeriodFilter;
final List<String> tagSuggestions;
final List<TagSuggestion> tagSuggestions;
final String? rating;
final ValueChanged<String> onQuickProviderToggle;
final ValueChanged<String> onSearchChanged;
@@ -154,9 +154,9 @@ class _FeedToolbarState extends State<FeedToolbar> {
separatorBuilder: (_, __) => const SizedBox(width: 8),
itemBuilder: (context, index) => ActionChip(
avatar: const Icon(Icons.tag_rounded, size: 15),
label: Text(widget.tagSuggestions[index]),
label: _SuggestionLabel(widget.tagSuggestions[index]),
onPressed: () =>
_applySuggestion(widget.tagSuggestions[index]),
_applySuggestion(widget.tagSuggestions[index].name),
),
),
),
@@ -255,9 +255,9 @@ class _FeedToolbarState extends State<FeedToolbar> {
itemCount: widget.tagSuggestions.length,
separatorBuilder: (_, __) => const SizedBox(width: 6),
itemBuilder: (context, index) => ActionChip(
label: Text(widget.tagSuggestions[index]),
label: _SuggestionLabel(widget.tagSuggestions[index]),
onPressed: () =>
_applySuggestion(widget.tagSuggestions[index]),
_applySuggestion(widget.tagSuggestions[index].name),
),
),
),
@@ -282,6 +282,27 @@ class _FeedToolbarState extends State<FeedToolbar> {
}
}
class _SuggestionLabel extends StatelessWidget {
const _SuggestionLabel(this.suggestion);
final TagSuggestion suggestion;
@override
Widget build(BuildContext context) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(suggestion.name),
const SizedBox(width: 6),
Text(
suggestion.categoryLabel,
style: Theme.of(context).textTheme.labelSmall,
),
],
);
}
}
Future<List<String>?> showProviderFilterSheet(
BuildContext context, {
required List<ContentProviderConfig> providers,
@@ -325,6 +325,9 @@ class _MobilePostPagerState extends State<_MobilePostPager> {
super.initState();
_currentIndex = widget.initialIndex;
_controller = PageController(initialPage: widget.initialIndex);
WidgetsBinding.instance.addPostFrameCallback((_) {
_prefetchAround(widget.initialIndex);
});
}
@override
@@ -337,6 +340,9 @@ class _MobilePostPagerState extends State<_MobilePostPager> {
if (_controller.hasClients) {
_controller.jumpToPage(widget.initialIndex);
}
WidgetsBinding.instance.addPostFrameCallback((_) {
_prefetchAround(widget.initialIndex);
});
}
}
@@ -353,6 +359,7 @@ class _MobilePostPagerState extends State<_MobilePostPager> {
itemCount: widget.posts.length,
onPageChanged: (index) {
_currentIndex = index;
_prefetchAround(index);
final post = widget.posts[index];
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
@@ -363,13 +370,52 @@ class _MobilePostPagerState extends State<_MobilePostPager> {
});
},
itemBuilder: (context, index) {
return KeyedSubtree(
key: ValueKey(widget.posts[index].cacheKey),
child: widget.buildDetails(context, widget.posts[index]),
return _KeepAlivePostPage(
child: KeyedSubtree(
key: ValueKey(widget.posts[index].cacheKey),
child: widget.buildDetails(context, widget.posts[index]),
),
);
},
);
}
void _prefetchAround(int index) {
for (final offset in [-1, 0, 1]) {
final target = index + offset;
if (target < 0 || target >= widget.posts.length) continue;
final post = widget.posts[target];
final urls = [
post.previewUrl,
post.sampleUrl,
if (post.fileType.toLowerCase().contains('gif')) post.fileUrl,
].where((url) => url.trim().isNotEmpty).toSet();
for (final url in urls) {
precacheImage(NetworkImage(url), context);
}
}
}
}
class _KeepAlivePostPage extends StatefulWidget {
const _KeepAlivePostPage({required this.child});
final Widget child;
@override
State<_KeepAlivePostPage> createState() => _KeepAlivePostPageState();
}
class _KeepAlivePostPageState extends State<_KeepAlivePostPage>
with AutomaticKeepAliveClientMixin {
@override
bool get wantKeepAlive => true;
@override
Widget build(BuildContext context) {
super.build(context);
return widget.child;
}
}
class _CommentsSection extends ConsumerStatefulWidget {
@@ -214,11 +214,10 @@ class _PostMediaViewerState extends State<PostMediaViewer> {
}
List<String> _buildImageUrls(Post post) {
return {
post.sampleUrl,
post.fileUrl,
post.previewUrl,
}.where((url) => url.trim().isNotEmpty).toList();
final urls = post.fileType.toLowerCase().contains('gif')
? [post.fileUrl, post.sampleUrl, post.previewUrl]
: [post.sampleUrl, post.fileUrl, post.previewUrl];
return urls.where((url) => url.trim().isNotEmpty).toSet().toList();
}
List<String> _buildVideoUrls(Post post) {
@@ -11,16 +11,66 @@ class PostTagsPanel extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Wrap(
spacing: 8,
runSpacing: 8,
final groups = _groups(post);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (final tag in post.tags.take(80))
TagChip(
tag: tag,
onTap: () => context.go('/?q=${Uri.encodeQueryComponent(tag)}'),
for (final entry in groups.entries) ...[
Padding(
padding: const EdgeInsets.only(top: 8, bottom: 6),
child: Text(
_label(entry.key),
style: Theme.of(context).textTheme.labelLarge,
),
),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final tag in entry.value.take(80))
TagChip(
tag: tag,
onTap: () =>
context.go('/?q=${Uri.encodeQueryComponent(tag)}'),
),
],
),
],
],
);
}
Map<String, List<String>> _groups(Post post) {
if (post.tagGroups.isNotEmpty) {
final ordered = <String, List<String>>{};
for (final key in [
'artist',
'character',
'copyright',
'species',
'meta',
'general',
]) {
final tags = post.tagGroups[key];
if (tags != null && tags.isNotEmpty) ordered[key] = tags;
}
for (final entry in post.tagGroups.entries) {
ordered.putIfAbsent(entry.key, () => entry.value);
}
return ordered;
}
return {'general': post.tags};
}
String _label(String key) {
return switch (key) {
'artist' => 'Artist',
'character' => 'Character',
'copyright' => 'Copyright / Title',
'species' => 'Species',
'meta' => 'Meta',
'general' => 'General',
_ => 'Other',
};
}
}
@@ -17,11 +17,12 @@ class SearchController extends AsyncNotifier<SearchState> {
Future<void> updateQuery(String query) async {
final lastToken =
query.trim().isEmpty ? '' : query.trim().split(RegExp(r'\s+')).last;
final suggestionsResult =
await ref.read(searchServiceProvider).autocomplete(lastToken);
final suggestions = suggestionsResult is Success<List<String>>
final suggestionsResult = await ref
.read(searchServiceProvider)
.autocompleteDetailed(lastToken, limit: 16);
final suggestions = suggestionsResult is Success<List<TagSuggestion>>
? suggestionsResult.data
: <String>[];
: <TagSuggestion>[];
state = AsyncData(
(state.value ?? const SearchState()).copyWith(
query: query,
@@ -8,12 +8,12 @@ class SearchState {
});
final List<SearchHistory> recent;
final List<String> suggestions;
final List<TagSuggestion> suggestions;
final String query;
SearchState copyWith({
List<SearchHistory>? recent,
List<String>? suggestions,
List<TagSuggestion>? suggestions,
String? query,
}) {
return SearchState(
@@ -1,10 +1,12 @@
import 'package:flutter/material.dart';
import '../../../../backend/backend.dart';
class SearchSuggestions extends StatelessWidget {
const SearchSuggestions(
{required this.suggestions, required this.onTap, super.key});
final List<String> suggestions;
final List<TagSuggestion> suggestions;
final ValueChanged<String> onTap;
@override
@@ -16,8 +18,8 @@ class SearchSuggestions extends StatelessWidget {
children: [
for (final suggestion in suggestions)
ActionChip(
label: Text(suggestion),
onPressed: () => onTap(suggestion),
label: Text('${suggestion.name} · ${suggestion.categoryLabel}'),
onPressed: () => onTap(suggestion.name),
),
],
);