feat: instant tag suggestions, persistent disk cache, history limit settings, match highlighting and category badges (v2.0.9)

This commit is contained in:
2026-09-03 21:56:52 +03:00
parent 4acd6c29e0
commit 5560332b66
21 changed files with 960 additions and 73 deletions
@@ -21,11 +21,13 @@ class SearchController extends AsyncNotifier<SearchState> {
}
Future<void> updateQuery(String query) async {
final lastToken =
final rawLast =
query.trim().isEmpty ? '' : query.trim().split(RegExp(r'\s+')).last;
final cleanToken = SearchService.sanitizeToken(rawLast);
final requestId = ++_suggestionRequestId;
_suggestionDebounce?.cancel();
if (lastToken.isEmpty) {
if (cleanToken.isEmpty || cleanToken == 'and') {
state = AsyncData(
(state.value ?? const SearchState()).copyWith(
query: query,
@@ -34,24 +36,35 @@ class SearchController extends AsyncNotifier<SearchState> {
);
return;
}
// Instant local suggestions from disk cache & history (0 ms)
final localMatches = ref
.read(searchServiceProvider)
.instantSuggestions(cleanToken, limit: 16);
state = AsyncData(
(state.value ?? const SearchState()).copyWith(
query: query,
suggestions: localMatches,
),
);
_suggestionDebounce = Timer(const Duration(milliseconds: 280), () async {
_suggestionDebounce = Timer(const Duration(milliseconds: 160), () async {
final settingsRes = await ref.read(settingsServiceProvider).getSettings();
final appSettings = settingsRes is Success<AppSettings>
? settingsRes.data
: AppSettings.defaults;
final suggestionsResult = await ref
.read(searchServiceProvider)
.autocompleteDetailed(lastToken, limit: 16);
.autocompleteDetailed(
cleanToken,
limit: 16,
tagCacheLimit: appSettings.tagCacheLimit,
);
if (requestId != _suggestionRequestId) return;
final normalizedToken = lastToken.trim().toLowerCase();
final suggestions = suggestionsResult is Success<List<TagSuggestion>>
? suggestionsResult.data
.where((item) => item.name.toLowerCase().startsWith(
normalizedToken,
))
.toList(growable: false)
: <TagSuggestion>[];
: localMatches;
state = AsyncData(
(state.value ?? const SearchState()).copyWith(
query: query,
@@ -3,8 +3,11 @@ import 'package:flutter/material.dart';
import '../../../../backend/backend.dart';
class SearchSuggestions extends StatelessWidget {
const SearchSuggestions(
{required this.suggestions, required this.onTap, super.key});
const SearchSuggestions({
required this.suggestions,
required this.onTap,
super.key,
});
final List<TagSuggestion> suggestions;
final ValueChanged<String> onTap;
@@ -12,16 +15,43 @@ class SearchSuggestions extends StatelessWidget {
@override
Widget build(BuildContext context) {
if (suggestions.isEmpty) return const SizedBox.shrink();
final scheme = Theme.of(context).colorScheme;
return Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final suggestion in suggestions)
ActionChip(
label: Text('${suggestion.name} · ${suggestion.categoryLabel}'),
avatar: Icon(
Icons.tag_rounded,
size: 16,
color: _categoryColor(suggestion.category, scheme),
),
label: Text(
suggestion.postCount > 0
? '${suggestion.name} (${_compactCount(suggestion.postCount)})'
: suggestion.name,
),
onPressed: () => onTap(suggestion.name),
),
],
);
}
Color _categoryColor(TagCategory category, ColorScheme scheme) {
return switch (category) {
TagCategory.artist => const Color(0xFFFF5252),
TagCategory.character => const Color(0xFF66BB6A),
TagCategory.copyright => const Color(0xFFBA68C8),
TagCategory.species => const Color(0xFF4FC3F7),
TagCategory.meta => const Color(0xFF90A4AE),
TagCategory.general || TagCategory.unknown => scheme.primary,
};
}
String _compactCount(int value) {
if (value >= 1000000) return '${(value / 1000000).toStringAsFixed(1)}M';
if (value >= 1000) return '${(value / 1000).toStringAsFixed(1)}K';
return '$value';
}
}