diff --git a/README.md b/README.md index b3b14dc..2371714 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@

- Version 2.0.8 + Version 2.0.9 Flutter Platforms Gitea @@ -20,6 +20,9 @@ ## ✨ Основные возможности +- **⚡ Мгновенные подсказки тегов (0 мс) и постоянный файловый кэш**: Локальный JSON-кэш на телефоне и ПК обеспечивает мгновенный отклик при наборе, а Danbooru, Gelbooru, Moebooru и e621 динамически дополняют базу тегов. +- **🎨 Цветные бейджи категорий и подсветка совпадений**: Выделение введенных букв жирным цветом акцента и четкая цветовая палитра категорий (художник — красный, персонаж — зеленый, копирайт — фиолетовый, вид — голубой). +- **📚 Расширенная история поиска и гибкие настройки**: До 2000 записей истории и до 20000 закэшированных тегов с ручным управлением и кнопками очистки в Настройках. - **🎯 Общие модификаторы в мультипоиске**: При вводе `cat and dog anthro` модификатор `anthro` автоматически применится к обоим тегам (`cat anthro` + `dog anthro`). - **🔀 Раздельный мульти-поиск по тегам через `and`**: Оператор `and` опрашивает серверную базу по каждому тегу независимо и плавно чередует посты обоих тегов в общей ленте. - **🌐 Pawchive в главной ленте**: Глобальная лента недавних постов с Pawchive подключена прямо в общий Feed наравне с Rule34, Gelbooru, e621 и Realbooru. @@ -66,9 +69,9 @@ 🔗 **[Gitea Releases (RarDog/Lunaris)](https://gitea.rardogsynapse.online/RarDog/Lunaris/releases)** -- **Android**: `Lunaris-v2.0.8.apk` -- **Linux (x64)**: `Lunaris-v2.0.8-linux-x64.tar.gz` -- **Windows**: `LunarisSetup-v2.0.8.exe` / `LunarisPortable-v2.0.8.zip` +- **Android**: `Lunaris-v2.0.9.apk` +- **Linux (x64)**: `Lunaris-v2.0.9-linux-x64.tar.gz` +- **Windows**: `LunarisSetup-v2.0.9.exe` / `LunarisPortable-v2.0.9.zip` --- diff --git a/lib/app/app_version.dart b/lib/app/app_version.dart index 93e2ecc..85e5d90 100644 --- a/lib/app/app_version.dart +++ b/lib/app/app_version.dart @@ -1,3 +1,3 @@ -const appDisplayVersion = '2.0.8'; -const appBuildNumber = 30; +const appDisplayVersion = '2.0.9'; +const appBuildNumber = 31; const appReleaseChannel = 'Stable'; diff --git a/lib/app/changelog.dart b/lib/app/changelog.dart index 74ed28b..9917a12 100644 --- a/lib/app/changelog.dart +++ b/lib/app/changelog.dart @@ -1,4 +1,18 @@ const lunarisChangelog = [ + LunarisChange( + version: '2.0.9', + title: 'Instant Tag Suggestions, Persistent Disk Cache, Expanded History & Match Highlighting', + bullets: [ + 'Instant 0ms tag suggestions: cached tags and search history are served immediately without network delay.', + 'Persistent disk cache: tag suggestions are preserved in lunaris_tag_cache.json across app restarts on Android and PC.', + 'Debounce reduced from 530ms to 100ms+160ms for ultra-responsive typing.', + 'Match highlighting: matched characters are bolded and highlighted with the primary theme color in the dropdown.', + 'Vibrant category badges: distinct colors for Artist (red), Character (green), Copyright (purple), Species (blue), and Meta (grey).', + 'Danbooru tag autocomplete support connected (/tags/autocomplete.json).', + 'Configurable search history limit (up to 2000 items) and tag cache limit (up to 20000 tags) in Settings.', + 'Dedicated Clear Tag Cache actions in Settings and Cache Manager.', + ], + ), LunarisChange( version: '2.0.8', title: 'Shared Context Modifiers for Multi-Tag Search & Clean Tag Filtering', diff --git a/lib/backend/backend.dart b/lib/backend/backend.dart index 409dacb..35b2821 100644 --- a/lib/backend/backend.dart +++ b/lib/backend/backend.dart @@ -28,6 +28,7 @@ export 'services/favorite_service.dart'; export 'services/feed_service.dart'; export 'services/provider_check_service.dart'; export 'services/search_service.dart'; +export 'services/tag_cache_service.dart'; export 'services/settings_service.dart'; export 'services/update_service.dart'; export 'services/viewed_history_service.dart'; diff --git a/lib/backend/di/backend_providers.dart b/lib/backend/di/backend_providers.dart index 2575b12..d9f2a99 100644 --- a/lib/backend/di/backend_providers.dart +++ b/lib/backend/di/backend_providers.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:dio/dio.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -28,6 +30,7 @@ import '../services/favorite_service.dart'; import '../services/feed_service.dart'; import '../services/provider_check_service.dart'; import '../services/search_service.dart'; +import '../services/tag_cache_service.dart'; import '../services/settings_service.dart'; import '../services/update_service.dart'; import '../services/viewed_history_service.dart'; @@ -125,10 +128,17 @@ final viewedKeysProvider = FutureProvider>((ref) async { return result is Success> ? result.data : {}; }); +final tagCacheServiceProvider = Provider((ref) { + final service = TagCacheService(); + unawaited(service.init()); + return service; +}); + final searchServiceProvider = Provider((ref) { return SearchService( ref.watch(searchRepositoryProvider), ref.watch(providerManagerProvider), + ref.watch(tagCacheServiceProvider), ); }); diff --git a/lib/backend/models/tag_suggestion.dart b/lib/backend/models/tag_suggestion.dart index a95c795..2b3c4bb 100644 --- a/lib/backend/models/tag_suggestion.dart +++ b/lib/backend/models/tag_suggestion.dart @@ -22,6 +22,30 @@ class TagSuggestion { final String providerId; String get categoryLabel => category.name; + + Map toJson() => { + 'name': name, + 'category': category.name, + 'postCount': postCount, + 'providerId': providerId, + }; + + factory TagSuggestion.fromJson(Map json) => TagSuggestion( + name: (json['name'] ?? '').toString(), + category: tagCategoryFromString((json['category'] ?? '').toString()), + postCount: (json['postCount'] as num?)?.toInt() ?? 0, + providerId: (json['providerId'] ?? '').toString(), + ); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is TagSuggestion && + runtimeType == other.runtimeType && + name.toLowerCase() == other.name.toLowerCase(); + + @override + int get hashCode => name.toLowerCase().hashCode; } TagCategory tagCategoryFromString(String? value) { diff --git a/lib/backend/providers/danbooru_provider.dart b/lib/backend/providers/danbooru_provider.dart index f6fb544..e1ed400 100644 --- a/lib/backend/providers/danbooru_provider.dart +++ b/lib/backend/providers/danbooru_provider.dart @@ -5,11 +5,16 @@ import '../mappers/danbooru_mapper.dart'; import '../models/post.dart'; import '../models/post_comment.dart'; import '../models/provider_health.dart'; +import '../models/tag_suggestion.dart'; import '../models/top_period_filter.dart'; import 'content_provider.dart'; class DanbooruProvider - implements ContentProvider, CommentProvider, PostPageProvider { + implements + ContentProvider, + CommentProvider, + PostPageProvider, + TagSuggestionProvider { DanbooruProvider({ required this.id, required this.name, @@ -171,4 +176,49 @@ class DanbooruProvider '${value.month.toString().padLeft(2, '0')}-' '${value.day.toString().padLeft(2, '0')}'; } + + @override + Future> suggestTags( + String query, { + int limit = 20, + }) async { + final cleanQuery = query + .replaceAll(RegExp(r'^[\(\)]+|[\(\)]+$'), '') + .trim() + .toLowerCase(); + if (cleanQuery.isEmpty) return const []; + try { + final response = await _dio.get( + '/tags/autocomplete.json', + queryParameters: { + 'search[name_matches]': cleanQuery, + 'limit': limit.clamp(1, 50), + ..._queryParameters, + }, + ); + final items = response.data is List ? response.data as List : const []; + return items + .whereType() + .map((item) { + final json = Map.from(item); + final name = (json['value'] ?? json['name'] ?? '').toString(); + return TagSuggestion( + name: name, + category: tagCategoryFromString(json['category']?.toString()), + postCount: _int(json['post_count']), + providerId: id, + ); + }) + .where((tag) => tag.name.isNotEmpty) + .toList(growable: false); + } catch (_) { + return const []; + } + } + + int _int(dynamic value) { + if (value is int) return value; + if (value is num) return value.toInt(); + return int.tryParse(value?.toString() ?? '') ?? 0; + } } diff --git a/lib/backend/repositories/search_repository.dart b/lib/backend/repositories/search_repository.dart index 2f114e0..66e5644 100644 --- a/lib/backend/repositories/search_repository.dart +++ b/lib/backend/repositories/search_repository.dart @@ -10,7 +10,7 @@ class SearchRepository { final DatabaseService _databaseService; - Future> save(SearchHistory history) { + Future> save(SearchHistory history, {int maxItems = 500}) { return _databaseService.safeWrite((isar) async { await isar.searchHistoryEntitys.put( SearchHistoryEntity() @@ -20,6 +20,21 @@ class SearchRepository { ..searchedAt = history.searchedAt ..resultCount = history.resultCount, ); + final count = await isar.searchHistoryEntitys.count(); + if (count > maxItems) { + final items = await isar.searchHistoryEntitys.where().findAll(); + items.sort((a, b) => b.searchedAt.compareTo(a.searchedAt)); + final toRemove = items.skip(maxItems); + for (final item in toRemove) { + await isar.searchHistoryEntitys.delete(item.isarId); + } + } + }); + } + + Future> count() { + return _databaseService.safeRead((isar) async { + return await isar.searchHistoryEntitys.count(); }); } diff --git a/lib/backend/services/search_service.dart b/lib/backend/services/search_service.dart index 496085e..62e73e8 100644 --- a/lib/backend/services/search_service.dart +++ b/lib/backend/services/search_service.dart @@ -1,43 +1,99 @@ +import 'dart:async'; + import '../../core/utils/result.dart'; import '../models/search_history.dart'; import '../models/tag_suggestion.dart'; import '../providers/provider_manager.dart'; import '../repositories/search_repository.dart'; +import 'tag_cache_service.dart'; class SearchService { - SearchService(this._repository, [this._providerManager]); + SearchService( + this._repository, [ + this._providerManager, + this._tagCacheService, + ]); final SearchRepository _repository; final ProviderManager? _providerManager; + final TagCacheService? _tagCacheService; + + TagCacheService? get tagCacheService => _tagCacheService; + + static String sanitizeToken(String raw) { + return raw + .replaceAll(RegExp(r'^[\(\)]+|[\(\)]+$'), '') + .trim() + .toLowerCase(); + } List parseTags(String query) { return query .trim() .split(RegExp(r'\s+')) - .where((tag) => tag.isNotEmpty) + .map(sanitizeToken) + .where((tag) => tag.isNotEmpty && tag != 'and') .toSet() .toList(); } - Future> saveSearch(String query, int resultCount) { + Future> saveSearch( + String query, + int resultCount, { + int maxHistory = 500, + }) async { final tags = parseTags(query); - return _repository.save( - SearchHistory( - id: '${DateTime.now().microsecondsSinceEpoch}:${tags.join(',')}', - query: query.trim(), - tags: tags, - searchedAt: DateTime.now(), - resultCount: resultCount, - ), + final history = SearchHistory( + id: '${DateTime.now().microsecondsSinceEpoch}:${tags.join(',')}', + query: query.trim(), + tags: tags, + searchedAt: DateTime.now(), + resultCount: resultCount, ); + + // Also register user-searched tags in persistent tag cache + if (_tagCacheService != null && tags.isNotEmpty) { + unawaited( + _tagCacheService.cacheTags( + tags.map( + (tag) => TagSuggestion( + name: tag, + category: TagCategory.general, + postCount: resultCount, + providerId: 'history', + ), + ), + ), + ); + } + + return _repository.save(history, maxItems: maxHistory); } Future>> recentSearches({int? limit}) { return _repository.recent(limit: limit); } - Future>> autocomplete(String prefix, - {int limit = 10}) async { + /// Instant local tag suggestions (0 ms) from disk cache & recent search history. + List instantSuggestions( + String prefix, { + int limit = 16, + Iterable? priorityTags, + }) { + final clean = sanitizeToken(prefix); + if (clean.isEmpty || clean == 'and') return const []; + return _tagCacheService?.findLocal( + clean, + limit: limit, + priorityTags: priorityTags, + ) ?? + const []; + } + + Future>> autocomplete( + String prefix, { + int limit = 10, + }) async { final result = await autocompleteDetailed(prefix, limit: limit); return result.fold( onSuccess: (items) => Success(items.map((item) => item.name).toList()), @@ -47,12 +103,65 @@ class SearchService { Future>> autocompleteDetailed( String prefix, { - int limit = 10, + int limit = 16, + int tagCacheLimit = 5000, + Iterable? priorityTags, }) async { + final clean = sanitizeToken(prefix); + if (clean.isEmpty || clean == 'and') return const Success([]); + final manager = _providerManager; - if (manager == null || prefix.trim().isEmpty) return const Success([]); - return manager.suggestTags(prefix.trim(), limit: limit); + if (manager == null) { + final local = instantSuggestions(clean, limit: limit, priorityTags: priorityTags); + return Success(local); + } + + final netResult = await manager.suggestTags(clean, limit: limit); + if (netResult is Error>) { + // Fallback to local cached suggestions if network fails + final local = instantSuggestions(clean, limit: limit, priorityTags: priorityTags); + return Success(local); + } + + final remoteItems = (netResult as Success>).data; + + // Cache remote suggestions to disk + if (_tagCacheService != null && remoteItems.isNotEmpty) { + unawaited(_tagCacheService.cacheTags(remoteItems, maxLimit: tagCacheLimit)); + } + + // Merge local matches with remote results + final merged = {}; + for (final item in instantSuggestions(clean, limit: limit, priorityTags: priorityTags)) { + merged[item.name.toLowerCase()] = item; + } + for (final item in remoteItems) { + final key = item.name.toLowerCase(); + final existing = merged[key]; + if (existing == null || item.postCount > existing.postCount) { + merged[key] = item; + } + } + + final sorted = merged.values.toList() + ..sort((a, b) { + final aKey = a.name.toLowerCase(); + final bKey = b.name.toLowerCase(); + final aExact = aKey == clean; + final bExact = bKey == clean; + if (aExact != bExact) return aExact ? -1 : 1; + + final countCmp = b.postCount.compareTo(a.postCount); + if (countCmp != 0) return countCmp; + return aKey.compareTo(bKey); + }); + + return Success(sorted.take(limit).toList(growable: false)); } Future> clearHistory() => _repository.clear(); + + Future clearTagCache() async { + await _tagCacheService?.clear(); + } } diff --git a/lib/backend/services/settings_service.dart b/lib/backend/services/settings_service.dart index ef0eca9..d3f31ee 100644 --- a/lib/backend/services/settings_service.dart +++ b/lib/backend/services/settings_service.dart @@ -58,6 +58,8 @@ class AppSettings { this.gridMode = 'masonry', this.searchPresets = const [], this.downloadPathTemplate = '{Artist}/{ID}', + this.searchHistoryLimit = 500, + this.tagCacheLimit = 5000, }); final List favoriteArtists; @@ -106,6 +108,8 @@ class AppSettings { final String gridMode; final List searchPresets; final String downloadPathTemplate; + final int searchHistoryLimit; + final int tagCacheLimit; static const defaults = AppSettings( enabledProviderIds: ['gelbooru', 'rule34', 'realbooru'], @@ -149,6 +153,8 @@ class AppSettings { lastUpdateCheckAt: null, defaultRatingFilter: null, lastFeedRating: null, + searchHistoryLimit: 500, + tagCacheLimit: 5000, ); AppSettings copyWith({ @@ -199,6 +205,8 @@ class AppSettings { String? gridMode, List? searchPresets, String? downloadPathTemplate, + int? searchHistoryLimit, + int? tagCacheLimit, }) { return AppSettings( favoriteArtists: favoriteArtists ?? this.favoriteArtists, @@ -254,6 +262,8 @@ class AppSettings { gridMode: gridMode ?? this.gridMode, searchPresets: searchPresets ?? this.searchPresets, downloadPathTemplate: downloadPathTemplate ?? this.downloadPathTemplate, + searchHistoryLimit: searchHistoryLimit ?? this.searchHistoryLimit, + tagCacheLimit: tagCacheLimit ?? this.tagCacheLimit, ); } @@ -304,6 +314,8 @@ class AppSettings { 'gridMode': gridMode, 'searchPresets': searchPresets, 'downloadPathTemplate': downloadPathTemplate, + 'searchHistoryLimit': searchHistoryLimit, + 'tagCacheLimit': tagCacheLimit, }; factory AppSettings.fromJson(Map json) => AppSettings( @@ -411,6 +423,10 @@ class AppSettings { ), downloadPathTemplate: (json['downloadPathTemplate'] as String?) ?? '{Artist}/{ID}', + searchHistoryLimit: (json['searchHistoryLimit'] as num?)?.toInt() ?? + defaults.searchHistoryLimit, + tagCacheLimit: (json['tagCacheLimit'] as num?)?.toInt() ?? + defaults.tagCacheLimit, ); } diff --git a/lib/backend/services/tag_cache_service.dart b/lib/backend/services/tag_cache_service.dart new file mode 100644 index 0000000..f419e7f --- /dev/null +++ b/lib/backend/services/tag_cache_service.dart @@ -0,0 +1,205 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:path_provider/path_provider.dart'; + +import '../models/tag_suggestion.dart'; + +class TagCacheService { + TagCacheService({String? customPath}) : _customPath = customPath; + + final String? _customPath; + final Map _tags = {}; + bool _isInitialized = false; + Timer? _saveDebounce; + String? _resolvedPath; + + bool get isInitialized => _isInitialized; + int get tagCount => _tags.length; + + Future _getCacheFilePath() async { + if (_resolvedPath != null) return _resolvedPath!; + if (_customPath != null) { + _resolvedPath = _customPath; + return _resolvedPath!; + } + final dir = await getApplicationDocumentsDirectory(); + _resolvedPath = '${dir.path}/lunaris_tag_cache.json'; + return _resolvedPath!; + } + + Future init() async { + if (_isInitialized) return; + try { + final path = await _getCacheFilePath(); + final file = File(path); + if (await file.exists()) { + final content = await file.readAsString(); + if (content.trim().isNotEmpty) { + final decoded = jsonDecode(content); + if (decoded is List) { + for (final item in decoded) { + if (item is Map) { + final suggestion = TagSuggestion.fromJson( + Map.from(item), + ); + final key = suggestion.name.trim().toLowerCase(); + if (key.isNotEmpty) { + _tags[key] = suggestion; + } + } + } + } + } + } + } catch (_) { + // Non-critical: corrupted cache file can be rebuilt seamlessly + } finally { + _isInitialized = true; + } + } + + /// Finds suggestions locally in memory (0 ms response). + List findLocal( + String prefix, { + int limit = 16, + Iterable? priorityTags, + }) { + final cleanPrefix = prefix + .replaceAll(RegExp(r'^[\(\)]+|[\(\)]+$'), '') + .trim() + .toLowerCase(); + if (cleanPrefix.isEmpty) return const []; + + final prioritySet = priorityTags + ?.map((t) => t.trim().toLowerCase()) + .where((t) => t.isNotEmpty) + .toSet() ?? + const {}; + + final matches = []; + final seen = {}; + + // First check priority tags (e.g. from recent search history) + for (final tag in prioritySet) { + if (tag.startsWith(cleanPrefix) && !seen.contains(tag)) { + seen.add(tag); + final existing = _tags[tag]; + matches.add( + existing ?? + TagSuggestion( + name: tag, + category: TagCategory.general, + postCount: 0, + providerId: 'history', + ), + ); + } + } + + // Then check all cached tags + for (final entry in _tags.entries) { + if (seen.contains(entry.key)) continue; + if (entry.key.startsWith(cleanPrefix)) { + seen.add(entry.key); + matches.add(entry.value); + } + } + + matches.sort((a, b) { + final aKey = a.name.toLowerCase(); + final bKey = b.name.toLowerCase(); + final aExact = aKey == cleanPrefix; + final bExact = bKey == cleanPrefix; + if (aExact != bExact) return aExact ? -1 : 1; + + final aPriority = prioritySet.contains(aKey); + final bPriority = prioritySet.contains(bKey); + if (aPriority != bPriority) return aPriority ? -1 : 1; + + final countCmp = b.postCount.compareTo(a.postCount); + if (countCmp != 0) return countCmp; + + return aKey.compareTo(bKey); + }); + + return matches.take(limit.clamp(1, 50)).toList(growable: false); + } + + /// Caches newly discovered suggestions and debounces saving to disk. + Future cacheTags( + Iterable suggestions, { + int maxLimit = 5000, + }) async { + if (!_isInitialized) await init(); + var changed = false; + + for (final item in suggestions) { + final key = item.name.trim().toLowerCase(); + if (key.isEmpty) continue; + final existing = _tags[key]; + if (existing == null || item.postCount > existing.postCount) { + _tags[key] = item; + changed = true; + } + } + + if (!changed) return; + + // Prune least popular tags if exceeding max limit + if (_tags.length > maxLimit) { + final sorted = _tags.values.toList() + ..sort((a, b) => b.postCount.compareTo(a.postCount)); + _tags.clear(); + for (final item in sorted.take(maxLimit)) { + _tags[item.name.toLowerCase()] = item; + } + } + + _scheduleSave(); + } + + void _scheduleSave() { + _saveDebounce?.cancel(); + _saveDebounce = Timer(const Duration(milliseconds: 1200), () { + flush(); + }); + } + + Future flush() async { + _saveDebounce?.cancel(); + try { + final path = await _getCacheFilePath(); + final file = File(path); + final list = _tags.values.map((t) => t.toJson()).toList(growable: false); + final jsonString = jsonEncode(list); + await file.writeAsString(jsonString, flush: true); + } catch (_) { + // Silent error on persistence failure + } + } + + Future getFileSizeBytes() async { + try { + final path = await _getCacheFilePath(); + final file = File(path); + if (await file.exists()) { + return await file.length(); + } + } catch (_) {} + return 0; + } + + Future clear() async { + _saveDebounce?.cancel(); + _tags.clear(); + try { + final path = await _getCacheFilePath(); + final file = File(path); + if (await file.exists()) { + await file.delete(); + } + } catch (_) {} + } +} diff --git a/lib/features/feed/presentation/feed_controller.dart b/lib/features/feed/presentation/feed_controller.dart index e3ad844..6f81b8e 100644 --- a/lib/features/feed/presentation/feed_controller.dart +++ b/lib/features/feed/presentation/feed_controller.dart @@ -120,35 +120,56 @@ class FeedController extends AsyncNotifier { ), ); await saveSession(scrollOffset: 0); - await ref.read(searchServiceProvider).saveSearch(query, 0); + final settingsRes = await ref.read(settingsServiceProvider).getSettings(); + final settings = + settingsRes is Success ? settingsRes.data : AppSettings.defaults; + await ref + .read(searchServiceProvider) + .saveSearch(query, 0, maxHistory: settings.searchHistoryLimit); await refresh(); final count = state.value?.posts.length ?? 0; - await ref.read(searchServiceProvider).saveSearch(query, count); + await ref + .read(searchServiceProvider) + .saveSearch(query, count, maxHistory: settings.searchHistoryLimit); } Future updateTagSuggestions(String query) async { final current = state.value ?? const FeedState(); - 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.trim().isEmpty) { + + if (cleanToken.isEmpty || cleanToken == 'and') { state = AsyncData(current.copyWith(tagSuggestions: [])); return; } - _suggestionDebounce = Timer(const Duration(milliseconds: 280), () async { + + // Instant local suggestions from disk cache & history (0 ms) + final localMatches = ref + .read(searchServiceProvider) + .instantSuggestions(cleanToken, limit: 16); + if (localMatches.isNotEmpty) { + state = AsyncData(current.copyWith(tagSuggestions: localMatches)); + } + + _suggestionDebounce = Timer(const Duration(milliseconds: 160), () async { + final settingsRes = await ref.read(settingsServiceProvider).getSettings(); + final appSettings = settingsRes is Success + ? settingsRes.data + : AppSettings.defaults; final result = 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 = result is Success> ? result.data - .where((item) => item.name.toLowerCase().startsWith( - normalizedToken, - )) - .toList(growable: false) - : const []; + : localMatches; final latest = state.value ?? current; state = AsyncData(latest.copyWith(tagSuggestions: suggestions)); }); diff --git a/lib/features/search/presentation/search_controller.dart b/lib/features/search/presentation/search_controller.dart index 5fa718d..c79126f 100644 --- a/lib/features/search/presentation/search_controller.dart +++ b/lib/features/search/presentation/search_controller.dart @@ -21,11 +21,13 @@ class SearchController extends AsyncNotifier { } Future 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 { ); 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 + ? 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> ? suggestionsResult.data - .where((item) => item.name.toLowerCase().startsWith( - normalizedToken, - )) - .toList(growable: false) - : []; + : localMatches; state = AsyncData( (state.value ?? const SearchState()).copyWith( query: query, diff --git a/lib/features/search/presentation/widgets/search_suggestions.dart b/lib/features/search/presentation/widgets/search_suggestions.dart index f61d4d4..ac3fef1 100644 --- a/lib/features/search/presentation/widgets/search_suggestions.dart +++ b/lib/features/search/presentation/widgets/search_suggestions.dart @@ -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 suggestions; final ValueChanged 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'; + } } diff --git a/lib/features/settings/presentation/cache_manager_screen.dart b/lib/features/settings/presentation/cache_manager_screen.dart index a2d7ad3..9494839 100644 --- a/lib/features/settings/presentation/cache_manager_screen.dart +++ b/lib/features/settings/presentation/cache_manager_screen.dart @@ -16,6 +16,8 @@ class CacheStats { required this.imageMemoryCount, required this.tempDirectoryBytes, required this.downloadedCount, + this.tagCacheCount = 0, + this.tagCacheBytes = 0, }); final int cachedPostsCount; @@ -23,9 +25,14 @@ class CacheStats { final int imageMemoryCount; final int tempDirectoryBytes; final int downloadedCount; + final int tagCacheCount; + final int tagCacheBytes; int get totalEstimatedBytes => - imageMemoryBytes + tempDirectoryBytes + (cachedPostsCount * 2048); + imageMemoryBytes + + tempDirectoryBytes + + tagCacheBytes + + (cachedPostsCount * 2048); static const empty = CacheStats( cachedPostsCount: 0, @@ -33,6 +40,8 @@ class CacheStats { imageMemoryCount: 0, tempDirectoryBytes: 0, downloadedCount: 0, + tagCacheCount: 0, + tagCacheBytes: 0, ); } @@ -64,14 +73,21 @@ final cacheStatsProvider = FutureProvider.autoDispose((ref) async { } } catch (_) {} - return CacheStats( - cachedPostsCount: postsCount, - imageMemoryBytes: memBytes, - imageMemoryCount: memCount, - tempDirectoryBytes: tempBytes, - downloadedCount: downloadedCount, - ); -}); + final tagCache = ref.watch(tagCacheServiceProvider); + if (!tagCache.isInitialized) await tagCache.init(); + final tagCount = tagCache.tagCount; + final tagBytes = await tagCache.getFileSizeBytes(); + + return CacheStats( + cachedPostsCount: postsCount, + imageMemoryBytes: memBytes, + imageMemoryCount: memCount, + tempDirectoryBytes: tempBytes, + downloadedCount: downloadedCount, + tagCacheCount: tagCount, + tagCacheBytes: tagBytes, + ); + }); class CacheManagerScreen extends ConsumerStatefulWidget { const CacheManagerScreen({super.key}); @@ -127,9 +143,17 @@ class _CacheManagerScreenState extends ConsumerState { if (mounted) setState(() => _isClearing = false); } + Future _clearTagCache() async { + setState(() => _isClearing = true); + await ref.read(tagCacheServiceProvider).clear(); + ref.invalidate(cacheStatsProvider); + if (mounted) setState(() => _isClearing = false); + } + Future _clearAllCache() async { setState(() => _isClearing = true); await ref.read(cacheServiceProvider).clear(); + await ref.read(tagCacheServiceProvider).clear(); PaintingBinding.instance.imageCache.clear(); PaintingBinding.instance.imageCache.clearLiveImages(); try { @@ -244,6 +268,17 @@ class _CacheManagerScreenState extends ConsumerState { ), const SizedBox(height: 8), + _CacheCategoryTile( + icon: Icons.label_rounded, + title: strings.ru ? 'Кэш подсказок тегов' : 'Tag suggestions cache', + subtitle: strings.ru + ? '${stats.tagCacheCount} закэшированных тегов на диске' + : '${stats.tagCacheCount} cached tags on disk', + sizeText: _formatBytes(stats.tagCacheBytes), + onClear: _isClearing ? null : _clearTagCache, + ), + const SizedBox(height: 8), + _CacheCategoryTile( icon: Icons.download_done_rounded, title: strings.ru ? 'Скачанные медиа' : 'Downloaded media', diff --git a/lib/features/settings/presentation/settings_controller.dart b/lib/features/settings/presentation/settings_controller.dart index 4552912..9514318 100644 --- a/lib/features/settings/presentation/settings_controller.dart +++ b/lib/features/settings/presentation/settings_controller.dart @@ -38,6 +38,14 @@ class SettingsController extends AsyncNotifier { ref.invalidateSelf(); } + Future clearSearchHistory() async { + await ref.read(searchServiceProvider).clearHistory(); + } + + Future clearTagCache() async { + await ref.read(searchServiceProvider).clearTagCache(); + } + Future diagnosticsReport() async { final result = await ref.read(settingsServiceProvider).buildDiagnosticsReport( diff --git a/lib/features/settings/presentation/settings_screen.dart b/lib/features/settings/presentation/settings_screen.dart index d935bc8..cdb2cfc 100644 --- a/lib/features/settings/presentation/settings_screen.dart +++ b/lib/features/settings/presentation/settings_screen.dart @@ -392,6 +392,28 @@ class _SettingsContent extends ConsumerWidget { onChanged: (value) => _update(ref, settings.copyWith(cacheMaxItems: value)), ), + _StepperTile( + title: settings.languageCode == 'ru' + ? 'Лимит истории поиска' + : 'Search history limit', + value: settings.searchHistoryLimit, + min: 50, + max: 2000, + step: 50, + onChanged: (value) => + _update(ref, settings.copyWith(searchHistoryLimit: value)), + ), + _StepperTile( + title: settings.languageCode == 'ru' + ? 'Лимит кэша тегов (диск)' + : 'Tag cache limit (disk)', + value: settings.tagCacheLimit, + min: 500, + max: 20000, + step: 500, + onChanged: (value) => + _update(ref, settings.copyWith(tagCacheLimit: value)), + ), _ActionGrid( children: [ FilledButton.tonalIcon( @@ -408,11 +430,59 @@ class _SettingsContent extends ConsumerWidget { ? 'Менеджер кэша' : 'Cache Manager'), ), + FilledButton.tonalIcon( + onPressed: () async { + await ref + .read(settingsControllerProvider.notifier) + .clearTagCache(); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + settings.languageCode == 'ru' + ? 'Кэш подсказок тегов очищен' + : 'Tag suggestions cache cleared', + ), + ), + ); + } + }, + icon: const Icon(Icons.label_off_rounded), + label: Text( + settings.languageCode == 'ru' + ? 'Очистить кэш тегов' + : 'Clear tag cache', + ), + ), + FilledButton.tonalIcon( + onPressed: () async { + await ref + .read(settingsControllerProvider.notifier) + .clearSearchHistory(); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + settings.languageCode == 'ru' + ? 'История поиска очищена' + : 'Search history cleared', + ), + ), + ); + } + }, + icon: const Icon(Icons.history_toggle_off_rounded), + label: Text( + settings.languageCode == 'ru' + ? 'Очистить историю' + : 'Clear search history', + ), + ), FilledButton.tonalIcon( onPressed: () => ref .read(settingsControllerProvider.notifier) .clearViewedHistory(), - icon: const Icon(Icons.history_toggle_off_rounded), + icon: const Icon(Icons.visibility_off_rounded), label: Text(strings.clearViewed), ), FilledButton.tonalIcon( @@ -426,7 +496,7 @@ class _SettingsContent extends ConsumerWidget { onPressed: () => ref .read(settingsControllerProvider.notifier) .clearHiddenPosts(), - icon: const Icon(Icons.visibility_off_rounded), + icon: const Icon(Icons.delete_sweep_rounded), label: Text('Clear hidden (${settings.hiddenPostKeys.length})'), ), diff --git a/lib/shared/widgets/app_search_bar.dart b/lib/shared/widgets/app_search_bar.dart index 80af18e..56c9f60 100644 --- a/lib/shared/widgets/app_search_bar.dart +++ b/lib/shared/widgets/app_search_bar.dart @@ -228,6 +228,7 @@ class _TagInputSearchBarState extends State { width: size.width, yOffset: size.height + 8, suggestions: suggestions, + query: token, onSelected: _applySuggestion, ), ); @@ -372,7 +373,7 @@ class _TagInputSearchBarState extends State { void _notifyChangedDebounced() { _debounce?.cancel(); - _debounce = Timer(const Duration(milliseconds: 250), _notifyChanged); + _debounce = Timer(const Duration(milliseconds: 100), _notifyChanged); } void _notifyChanged() { @@ -401,7 +402,8 @@ class _TagInputSearchBarState extends State { String get _activeToken { final draft = _controller.text.trim().toLowerCase(); if (draft.isEmpty) return ''; - return draft.split(RegExp(r'\s+')).last; + final raw = draft.split(RegExp(r'\s+')).last; + return raw.replaceAll(RegExp(r'^[\(\)]+|[\(\)]+$'), '').trim(); } bool get _isEditing => _focusNode.hasFocus || _localDirty; @@ -413,6 +415,7 @@ class _TagSuggestionOverlay extends StatelessWidget { required this.width, required this.yOffset, required this.suggestions, + required this.query, required this.onSelected, }); @@ -420,6 +423,7 @@ class _TagSuggestionOverlay extends StatelessWidget { final double width; final double yOffset; final List suggestions; + final String query; final ValueChanged onSelected; @override @@ -435,6 +439,7 @@ class _TagSuggestionOverlay extends StatelessWidget { width: width.clamp(280, 620).toDouble(), child: _TagSuggestionDropdown( suggestions: suggestions, + query: query, onSelected: onSelected, ), ), @@ -447,10 +452,12 @@ class _TagSuggestionOverlay extends StatelessWidget { class _TagSuggestionDropdown extends StatelessWidget { const _TagSuggestionDropdown({ required this.suggestions, + required this.query, required this.onSelected, }); final List suggestions; + final String query; final ValueChanged onSelected; @override @@ -482,18 +489,24 @@ class _TagSuggestionDropdown extends StatelessWidget { const EdgeInsets.symmetric(horizontal: 14, vertical: 10), child: Row( children: [ - Icon(Icons.tag_rounded, color: scheme.primary, size: 20), + Icon( + Icons.tag_rounded, + color: _categoryAccentColor(suggestion.category, scheme), + size: 20, + ), const SizedBox(width: 14), Expanded( - child: Text( + child: _buildHighlightedText( + context, suggestion.name, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodyLarge, + query, ), ), const SizedBox(width: 10), - _TagSuggestionBadge(suggestion.categoryLabel), + _TagSuggestionBadge( + category: suggestion.category, + label: suggestion.categoryLabel, + ), if (suggestion.postCount > 0) ...[ const SizedBox(width: 8), Text( @@ -514,6 +527,72 @@ class _TagSuggestionDropdown extends StatelessWidget { ); } + Widget _buildHighlightedText( + BuildContext context, + String text, + String token, + ) { + final theme = Theme.of(context); + final scheme = theme.colorScheme; + final defaultStyle = theme.textTheme.bodyLarge ?? const TextStyle(); + + final cleanToken = token.trim().toLowerCase(); + if (cleanToken.isEmpty) { + return Text( + text, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: defaultStyle, + ); + } + + final lowerText = text.toLowerCase(); + final matchIndex = lowerText.indexOf(cleanToken); + + if (matchIndex < 0) { + return Text( + text, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: defaultStyle, + ); + } + + final before = text.substring(0, matchIndex); + final match = text.substring(matchIndex, matchIndex + cleanToken.length); + final after = text.substring(matchIndex + cleanToken.length); + + return Text.rich( + TextSpan( + style: defaultStyle, + children: [ + if (before.isNotEmpty) TextSpan(text: before), + TextSpan( + text: match, + style: defaultStyle.copyWith( + color: scheme.primary, + fontWeight: FontWeight.w900, + ), + ), + if (after.isNotEmpty) TextSpan(text: after), + ], + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ); + } + + Color _categoryAccentColor(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'; @@ -522,16 +601,22 @@ class _TagSuggestionDropdown extends StatelessWidget { } class _TagSuggestionBadge extends StatelessWidget { - const _TagSuggestionBadge(this.label); + const _TagSuggestionBadge({ + required this.category, + required this.label, + }); + final TagCategory category; final String label; @override Widget build(BuildContext context) { final scheme = Theme.of(context).colorScheme; + final (bgColor, fgColor) = _badgeColors(scheme); + return DecoratedBox( decoration: BoxDecoration( - color: scheme.primaryContainer.withValues(alpha: 0.55), + color: bgColor, borderRadius: BorderRadius.circular(999), ), child: Padding( @@ -539,13 +624,42 @@ class _TagSuggestionBadge extends StatelessWidget { child: Text( label, style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: scheme.onPrimaryContainer, + color: fgColor, fontWeight: FontWeight.w700, ), ), ), ); } + + (Color, Color) _badgeColors(ColorScheme scheme) { + return switch (category) { + TagCategory.artist => ( + const Color(0xFFFF5252).withValues(alpha: 0.16), + const Color(0xFFFF5252), + ), + TagCategory.character => ( + const Color(0xFF4CAF50).withValues(alpha: 0.16), + const Color(0xFF43A047), + ), + TagCategory.copyright => ( + const Color(0xFFAB47BC).withValues(alpha: 0.16), + const Color(0xFFAB47BC), + ), + TagCategory.species => ( + const Color(0xFF29B6F6).withValues(alpha: 0.16), + const Color(0xFF0288D1), + ), + TagCategory.meta => ( + const Color(0xFF78909C).withValues(alpha: 0.16), + const Color(0xFF607D8B), + ), + TagCategory.general || TagCategory.unknown => ( + scheme.primaryContainer.withValues(alpha: 0.55), + scheme.onPrimaryContainer, + ), + }; + } } class _AppSearchBarState extends State { diff --git a/pubspec.yaml b/pubspec.yaml index 53e389a..92c30f0 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: gel_rule_app description: Local backend/core layer for a Flutter booru browser app. publish_to: "none" -version: 2.0.8+30 +version: 2.0.9+31 environment: sdk: ">=3.3.0 <4.0.0" diff --git a/test/backend/search_service_test.dart b/test/backend/search_service_test.dart index ce9915b..89a5af6 100644 --- a/test/backend/search_service_test.dart +++ b/test/backend/search_service_test.dart @@ -21,7 +21,7 @@ class FakeSearchRepository implements SearchRepository { } @override - Future> save(SearchHistory history) async { + Future> save(SearchHistory history, {int maxItems = 500}) async { items.add(history); return const Success(null); } diff --git a/test/backend/tag_cache_service_test.dart b/test/backend/tag_cache_service_test.dart new file mode 100644 index 0000000..28c4358 --- /dev/null +++ b/test/backend/tag_cache_service_test.dart @@ -0,0 +1,149 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:gel_rule_app/backend/models/tag_suggestion.dart'; +import 'package:gel_rule_app/backend/services/tag_cache_service.dart'; + +void main() { + late Directory tempDir; + late String cacheFilePath; + late TagCacheService cacheService; + + setUp(() async { + tempDir = await Directory.systemTemp.createTemp('lunaris_test_tag_cache'); + cacheFilePath = '${tempDir.path}/test_tags.json'; + cacheService = TagCacheService(customPath: cacheFilePath); + await cacheService.init(); + }); + + tearDown(() async { + if (await tempDir.exists()) { + await tempDir.delete(recursive: true); + } + }); + + test('TagSuggestion toJson and fromJson work correctly', () { + const item = TagSuggestion( + name: 'hatsune_miku', + category: TagCategory.character, + postCount: 154200, + providerId: 'danbooru', + ); + final json = item.toJson(); + final restored = TagSuggestion.fromJson(json); + + expect(restored.name, 'hatsune_miku'); + expect(restored.category, TagCategory.character); + expect(restored.postCount, 154200); + expect(restored.providerId, 'danbooru'); + }); + + test('findLocal returns matching tags instantaneously', () async { + await cacheService.cacheTags([ + const TagSuggestion( + name: 'cat_ears', + category: TagCategory.general, + postCount: 1000, + providerId: 'gelbooru', + ), + const TagSuggestion( + name: 'caterpillar', + category: TagCategory.general, + postCount: 200, + providerId: 'gelbooru', + ), + const TagSuggestion( + name: 'dog_tail', + category: TagCategory.general, + postCount: 800, + providerId: 'gelbooru', + ), + ]); + + final matches = cacheService.findLocal('cat'); + expect(matches.length, 2); + expect(matches.first.name, 'cat_ears'); + expect(matches[1].name, 'caterpillar'); + + final dogMatches = cacheService.findLocal('dog'); + expect(dogMatches.length, 1); + expect(dogMatches.first.name, 'dog_tail'); + }); + + test('findLocal sanitizes parentheses in prefix', () async { + await cacheService.cacheTags([ + const TagSuggestion( + name: 'cat_ears', + category: TagCategory.general, + postCount: 1000, + providerId: 'gelbooru', + ), + ]); + + final matches = cacheService.findLocal('(cat'); + expect(matches.length, 1); + expect(matches.first.name, 'cat_ears'); + }); + + test('findLocal respects priority tags from search history', () async { + await cacheService.cacheTags([ + const TagSuggestion( + name: 'cat_girl', + category: TagCategory.general, + postCount: 5000, + providerId: 'gelbooru', + ), + const TagSuggestion( + name: 'cat_costume', + category: TagCategory.general, + postCount: 50, + providerId: 'gelbooru', + ), + ]); + + // Priority tag 'cat_costume' should appear first despite lower postCount + final matches = cacheService.findLocal( + 'cat', + priorityTags: ['cat_costume'], + ); + expect(matches.first.name, 'cat_costume'); + expect(matches[1].name, 'cat_girl'); + }); + + test('flush saves tags to file and reloading restores them', () async { + await cacheService.cacheTags([ + const TagSuggestion( + name: 'genshin_impact', + category: TagCategory.copyright, + postCount: 300000, + providerId: 'danbooru', + ), + ]); + await cacheService.flush(); + + final reloadedService = TagCacheService(customPath: cacheFilePath); + await reloadedService.init(); + + final results = reloadedService.findLocal('genshin'); + expect(results.length, 1); + expect(results.first.name, 'genshin_impact'); + expect(results.first.category, TagCategory.copyright); + }); + + test('clear removes tags from memory and disk', () async { + await cacheService.cacheTags([ + const TagSuggestion( + name: 'raiden_shogun', + category: TagCategory.character, + postCount: 45000, + providerId: 'danbooru', + ), + ]); + await cacheService.flush(); + expect(File(cacheFilePath).existsSync(), isTrue); + + await cacheService.clear(); + expect(cacheService.tagCount, 0); + expect(File(cacheFilePath).existsSync(), isFalse); + }); +}