feat: instant tag suggestions, persistent disk cache, history limit settings, match highlighting and category badges (v2.0.9)
This commit is contained in:
@@ -10,7 +10,7 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="https://img.shields.io/badge/Version-2.0.8-blueviolet?style=for-the-badge" alt="Version 2.0.8" />
|
<img src="https://img.shields.io/badge/Version-2.0.9-blueviolet?style=for-the-badge" alt="Version 2.0.9" />
|
||||||
<img src="https://img.shields.io/badge/Flutter-3.47+-02569B?style=for-the-badge&logo=flutter&logoColor=white" alt="Flutter" />
|
<img src="https://img.shields.io/badge/Flutter-3.47+-02569B?style=for-the-badge&logo=flutter&logoColor=white" alt="Flutter" />
|
||||||
<img src="https://img.shields.io/badge/Platform-Android%20%7C%20Linux%20%7C%20Windows-brightgreen?style=for-the-badge" alt="Platforms" />
|
<img src="https://img.shields.io/badge/Platform-Android%20%7C%20Linux%20%7C%20Windows-brightgreen?style=for-the-badge" alt="Platforms" />
|
||||||
<img src="https://img.shields.io/badge/Gitea-RarDog%2FLunaris-orange?style=for-the-badge&logo=gitea&logoColor=white" alt="Gitea" />
|
<img src="https://img.shields.io/badge/Gitea-RarDog%2FLunaris-orange?style=for-the-badge&logo=gitea&logoColor=white" alt="Gitea" />
|
||||||
@@ -20,6 +20,9 @@
|
|||||||
|
|
||||||
## ✨ Основные возможности
|
## ✨ Основные возможности
|
||||||
|
|
||||||
|
- **⚡ Мгновенные подсказки тегов (0 мс) и постоянный файловый кэш**: Локальный JSON-кэш на телефоне и ПК обеспечивает мгновенный отклик при наборе, а Danbooru, Gelbooru, Moebooru и e621 динамически дополняют базу тегов.
|
||||||
|
- **🎨 Цветные бейджи категорий и подсветка совпадений**: Выделение введенных букв жирным цветом акцента и четкая цветовая палитра категорий (художник — красный, персонаж — зеленый, копирайт — фиолетовый, вид — голубой).
|
||||||
|
- **📚 Расширенная история поиска и гибкие настройки**: До 2000 записей истории и до 20000 закэшированных тегов с ручным управлением и кнопками очистки в Настройках.
|
||||||
- **🎯 Общие модификаторы в мультипоиске**: При вводе `cat and dog anthro` модификатор `anthro` автоматически применится к обоим тегам (`cat anthro` + `dog anthro`).
|
- **🎯 Общие модификаторы в мультипоиске**: При вводе `cat and dog anthro` модификатор `anthro` автоматически применится к обоим тегам (`cat anthro` + `dog anthro`).
|
||||||
- **🔀 Раздельный мульти-поиск по тегам через `and`**: Оператор `and` опрашивает серверную базу по каждому тегу независимо и плавно чередует посты обоих тегов в общей ленте.
|
- **🔀 Раздельный мульти-поиск по тегам через `and`**: Оператор `and` опрашивает серверную базу по каждому тегу независимо и плавно чередует посты обоих тегов в общей ленте.
|
||||||
- **🌐 Pawchive в главной ленте**: Глобальная лента недавних постов с Pawchive подключена прямо в общий Feed наравне с Rule34, Gelbooru, e621 и Realbooru.
|
- **🌐 Pawchive в главной ленте**: Глобальная лента недавних постов с Pawchive подключена прямо в общий Feed наравне с Rule34, Gelbooru, e621 и Realbooru.
|
||||||
@@ -66,9 +69,9 @@
|
|||||||
|
|
||||||
🔗 **[Gitea Releases (RarDog/Lunaris)](https://gitea.rardogsynapse.online/RarDog/Lunaris/releases)**
|
🔗 **[Gitea Releases (RarDog/Lunaris)](https://gitea.rardogsynapse.online/RarDog/Lunaris/releases)**
|
||||||
|
|
||||||
- **Android**: `Lunaris-v2.0.8.apk`
|
- **Android**: `Lunaris-v2.0.9.apk`
|
||||||
- **Linux (x64)**: `Lunaris-v2.0.8-linux-x64.tar.gz`
|
- **Linux (x64)**: `Lunaris-v2.0.9-linux-x64.tar.gz`
|
||||||
- **Windows**: `LunarisSetup-v2.0.8.exe` / `LunarisPortable-v2.0.8.zip`
|
- **Windows**: `LunarisSetup-v2.0.9.exe` / `LunarisPortable-v2.0.9.zip`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
const appDisplayVersion = '2.0.8';
|
const appDisplayVersion = '2.0.9';
|
||||||
const appBuildNumber = 30;
|
const appBuildNumber = 31;
|
||||||
const appReleaseChannel = 'Stable';
|
const appReleaseChannel = 'Stable';
|
||||||
|
|||||||
@@ -1,4 +1,18 @@
|
|||||||
const lunarisChangelog = [
|
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(
|
LunarisChange(
|
||||||
version: '2.0.8',
|
version: '2.0.8',
|
||||||
title: 'Shared Context Modifiers for Multi-Tag Search & Clean Tag Filtering',
|
title: 'Shared Context Modifiers for Multi-Tag Search & Clean Tag Filtering',
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export 'services/favorite_service.dart';
|
|||||||
export 'services/feed_service.dart';
|
export 'services/feed_service.dart';
|
||||||
export 'services/provider_check_service.dart';
|
export 'services/provider_check_service.dart';
|
||||||
export 'services/search_service.dart';
|
export 'services/search_service.dart';
|
||||||
|
export 'services/tag_cache_service.dart';
|
||||||
export 'services/settings_service.dart';
|
export 'services/settings_service.dart';
|
||||||
export 'services/update_service.dart';
|
export 'services/update_service.dart';
|
||||||
export 'services/viewed_history_service.dart';
|
export 'services/viewed_history_service.dart';
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
@@ -28,6 +30,7 @@ import '../services/favorite_service.dart';
|
|||||||
import '../services/feed_service.dart';
|
import '../services/feed_service.dart';
|
||||||
import '../services/provider_check_service.dart';
|
import '../services/provider_check_service.dart';
|
||||||
import '../services/search_service.dart';
|
import '../services/search_service.dart';
|
||||||
|
import '../services/tag_cache_service.dart';
|
||||||
import '../services/settings_service.dart';
|
import '../services/settings_service.dart';
|
||||||
import '../services/update_service.dart';
|
import '../services/update_service.dart';
|
||||||
import '../services/viewed_history_service.dart';
|
import '../services/viewed_history_service.dart';
|
||||||
@@ -125,10 +128,17 @@ final viewedKeysProvider = FutureProvider<Set<String>>((ref) async {
|
|||||||
return result is Success<Set<String>> ? result.data : <String>{};
|
return result is Success<Set<String>> ? result.data : <String>{};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
final tagCacheServiceProvider = Provider<TagCacheService>((ref) {
|
||||||
|
final service = TagCacheService();
|
||||||
|
unawaited(service.init());
|
||||||
|
return service;
|
||||||
|
});
|
||||||
|
|
||||||
final searchServiceProvider = Provider<SearchService>((ref) {
|
final searchServiceProvider = Provider<SearchService>((ref) {
|
||||||
return SearchService(
|
return SearchService(
|
||||||
ref.watch(searchRepositoryProvider),
|
ref.watch(searchRepositoryProvider),
|
||||||
ref.watch(providerManagerProvider),
|
ref.watch(providerManagerProvider),
|
||||||
|
ref.watch(tagCacheServiceProvider),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,30 @@ class TagSuggestion {
|
|||||||
final String providerId;
|
final String providerId;
|
||||||
|
|
||||||
String get categoryLabel => category.name;
|
String get categoryLabel => category.name;
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'name': name,
|
||||||
|
'category': category.name,
|
||||||
|
'postCount': postCount,
|
||||||
|
'providerId': providerId,
|
||||||
|
};
|
||||||
|
|
||||||
|
factory TagSuggestion.fromJson(Map<String, dynamic> 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) {
|
TagCategory tagCategoryFromString(String? value) {
|
||||||
|
|||||||
@@ -5,11 +5,16 @@ import '../mappers/danbooru_mapper.dart';
|
|||||||
import '../models/post.dart';
|
import '../models/post.dart';
|
||||||
import '../models/post_comment.dart';
|
import '../models/post_comment.dart';
|
||||||
import '../models/provider_health.dart';
|
import '../models/provider_health.dart';
|
||||||
|
import '../models/tag_suggestion.dart';
|
||||||
import '../models/top_period_filter.dart';
|
import '../models/top_period_filter.dart';
|
||||||
import 'content_provider.dart';
|
import 'content_provider.dart';
|
||||||
|
|
||||||
class DanbooruProvider
|
class DanbooruProvider
|
||||||
implements ContentProvider, CommentProvider, PostPageProvider {
|
implements
|
||||||
|
ContentProvider,
|
||||||
|
CommentProvider,
|
||||||
|
PostPageProvider,
|
||||||
|
TagSuggestionProvider {
|
||||||
DanbooruProvider({
|
DanbooruProvider({
|
||||||
required this.id,
|
required this.id,
|
||||||
required this.name,
|
required this.name,
|
||||||
@@ -171,4 +176,49 @@ class DanbooruProvider
|
|||||||
'${value.month.toString().padLeft(2, '0')}-'
|
'${value.month.toString().padLeft(2, '0')}-'
|
||||||
'${value.day.toString().padLeft(2, '0')}';
|
'${value.day.toString().padLeft(2, '0')}';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<TagSuggestion>> 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<dynamic>(
|
||||||
|
'/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>()
|
||||||
|
.map((item) {
|
||||||
|
final json = Map<String, dynamic>.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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ class SearchRepository {
|
|||||||
|
|
||||||
final DatabaseService _databaseService;
|
final DatabaseService _databaseService;
|
||||||
|
|
||||||
Future<Result<void>> save(SearchHistory history) {
|
Future<Result<void>> save(SearchHistory history, {int maxItems = 500}) {
|
||||||
return _databaseService.safeWrite((isar) async {
|
return _databaseService.safeWrite((isar) async {
|
||||||
await isar.searchHistoryEntitys.put(
|
await isar.searchHistoryEntitys.put(
|
||||||
SearchHistoryEntity()
|
SearchHistoryEntity()
|
||||||
@@ -20,6 +20,21 @@ class SearchRepository {
|
|||||||
..searchedAt = history.searchedAt
|
..searchedAt = history.searchedAt
|
||||||
..resultCount = history.resultCount,
|
..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<Result<int>> count() {
|
||||||
|
return _databaseService.safeRead((isar) async {
|
||||||
|
return await isar.searchHistoryEntitys.count();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,43 +1,99 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import '../../core/utils/result.dart';
|
import '../../core/utils/result.dart';
|
||||||
import '../models/search_history.dart';
|
import '../models/search_history.dart';
|
||||||
import '../models/tag_suggestion.dart';
|
import '../models/tag_suggestion.dart';
|
||||||
import '../providers/provider_manager.dart';
|
import '../providers/provider_manager.dart';
|
||||||
import '../repositories/search_repository.dart';
|
import '../repositories/search_repository.dart';
|
||||||
|
import 'tag_cache_service.dart';
|
||||||
|
|
||||||
class SearchService {
|
class SearchService {
|
||||||
SearchService(this._repository, [this._providerManager]);
|
SearchService(
|
||||||
|
this._repository, [
|
||||||
|
this._providerManager,
|
||||||
|
this._tagCacheService,
|
||||||
|
]);
|
||||||
|
|
||||||
final SearchRepository _repository;
|
final SearchRepository _repository;
|
||||||
final ProviderManager? _providerManager;
|
final ProviderManager? _providerManager;
|
||||||
|
final TagCacheService? _tagCacheService;
|
||||||
|
|
||||||
|
TagCacheService? get tagCacheService => _tagCacheService;
|
||||||
|
|
||||||
|
static String sanitizeToken(String raw) {
|
||||||
|
return raw
|
||||||
|
.replaceAll(RegExp(r'^[\(\)]+|[\(\)]+$'), '')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
List<String> parseTags(String query) {
|
List<String> parseTags(String query) {
|
||||||
return query
|
return query
|
||||||
.trim()
|
.trim()
|
||||||
.split(RegExp(r'\s+'))
|
.split(RegExp(r'\s+'))
|
||||||
.where((tag) => tag.isNotEmpty)
|
.map(sanitizeToken)
|
||||||
|
.where((tag) => tag.isNotEmpty && tag != 'and')
|
||||||
.toSet()
|
.toSet()
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<Result<void>> saveSearch(String query, int resultCount) {
|
Future<Result<void>> saveSearch(
|
||||||
|
String query,
|
||||||
|
int resultCount, {
|
||||||
|
int maxHistory = 500,
|
||||||
|
}) async {
|
||||||
final tags = parseTags(query);
|
final tags = parseTags(query);
|
||||||
return _repository.save(
|
final history = SearchHistory(
|
||||||
SearchHistory(
|
id: '${DateTime.now().microsecondsSinceEpoch}:${tags.join(',')}',
|
||||||
id: '${DateTime.now().microsecondsSinceEpoch}:${tags.join(',')}',
|
query: query.trim(),
|
||||||
query: query.trim(),
|
tags: tags,
|
||||||
tags: tags,
|
searchedAt: DateTime.now(),
|
||||||
searchedAt: DateTime.now(),
|
resultCount: resultCount,
|
||||||
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<Result<List<SearchHistory>>> recentSearches({int? limit}) {
|
Future<Result<List<SearchHistory>>> recentSearches({int? limit}) {
|
||||||
return _repository.recent(limit: limit);
|
return _repository.recent(limit: limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<Result<List<String>>> autocomplete(String prefix,
|
/// Instant local tag suggestions (0 ms) from disk cache & recent search history.
|
||||||
{int limit = 10}) async {
|
List<TagSuggestion> instantSuggestions(
|
||||||
|
String prefix, {
|
||||||
|
int limit = 16,
|
||||||
|
Iterable<String>? priorityTags,
|
||||||
|
}) {
|
||||||
|
final clean = sanitizeToken(prefix);
|
||||||
|
if (clean.isEmpty || clean == 'and') return const [];
|
||||||
|
return _tagCacheService?.findLocal(
|
||||||
|
clean,
|
||||||
|
limit: limit,
|
||||||
|
priorityTags: priorityTags,
|
||||||
|
) ??
|
||||||
|
const [];
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Result<List<String>>> autocomplete(
|
||||||
|
String prefix, {
|
||||||
|
int limit = 10,
|
||||||
|
}) async {
|
||||||
final result = await autocompleteDetailed(prefix, limit: limit);
|
final result = await autocompleteDetailed(prefix, limit: limit);
|
||||||
return result.fold(
|
return result.fold(
|
||||||
onSuccess: (items) => Success(items.map((item) => item.name).toList()),
|
onSuccess: (items) => Success(items.map((item) => item.name).toList()),
|
||||||
@@ -47,12 +103,65 @@ class SearchService {
|
|||||||
|
|
||||||
Future<Result<List<TagSuggestion>>> autocompleteDetailed(
|
Future<Result<List<TagSuggestion>>> autocompleteDetailed(
|
||||||
String prefix, {
|
String prefix, {
|
||||||
int limit = 10,
|
int limit = 16,
|
||||||
|
int tagCacheLimit = 5000,
|
||||||
|
Iterable<String>? priorityTags,
|
||||||
}) async {
|
}) async {
|
||||||
|
final clean = sanitizeToken(prefix);
|
||||||
|
if (clean.isEmpty || clean == 'and') return const Success([]);
|
||||||
|
|
||||||
final manager = _providerManager;
|
final manager = _providerManager;
|
||||||
if (manager == null || prefix.trim().isEmpty) return const Success([]);
|
if (manager == null) {
|
||||||
return manager.suggestTags(prefix.trim(), limit: limit);
|
final local = instantSuggestions(clean, limit: limit, priorityTags: priorityTags);
|
||||||
|
return Success(local);
|
||||||
|
}
|
||||||
|
|
||||||
|
final netResult = await manager.suggestTags(clean, limit: limit);
|
||||||
|
if (netResult is Error<List<TagSuggestion>>) {
|
||||||
|
// Fallback to local cached suggestions if network fails
|
||||||
|
final local = instantSuggestions(clean, limit: limit, priorityTags: priorityTags);
|
||||||
|
return Success(local);
|
||||||
|
}
|
||||||
|
|
||||||
|
final remoteItems = (netResult as Success<List<TagSuggestion>>).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 = <String, TagSuggestion>{};
|
||||||
|
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<Result<void>> clearHistory() => _repository.clear();
|
Future<Result<void>> clearHistory() => _repository.clear();
|
||||||
|
|
||||||
|
Future<void> clearTagCache() async {
|
||||||
|
await _tagCacheService?.clear();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,6 +58,8 @@ class AppSettings {
|
|||||||
this.gridMode = 'masonry',
|
this.gridMode = 'masonry',
|
||||||
this.searchPresets = const [],
|
this.searchPresets = const [],
|
||||||
this.downloadPathTemplate = '{Artist}/{ID}',
|
this.downloadPathTemplate = '{Artist}/{ID}',
|
||||||
|
this.searchHistoryLimit = 500,
|
||||||
|
this.tagCacheLimit = 5000,
|
||||||
});
|
});
|
||||||
|
|
||||||
final List<String> favoriteArtists;
|
final List<String> favoriteArtists;
|
||||||
@@ -106,6 +108,8 @@ class AppSettings {
|
|||||||
final String gridMode;
|
final String gridMode;
|
||||||
final List<String> searchPresets;
|
final List<String> searchPresets;
|
||||||
final String downloadPathTemplate;
|
final String downloadPathTemplate;
|
||||||
|
final int searchHistoryLimit;
|
||||||
|
final int tagCacheLimit;
|
||||||
|
|
||||||
static const defaults = AppSettings(
|
static const defaults = AppSettings(
|
||||||
enabledProviderIds: ['gelbooru', 'rule34', 'realbooru'],
|
enabledProviderIds: ['gelbooru', 'rule34', 'realbooru'],
|
||||||
@@ -149,6 +153,8 @@ class AppSettings {
|
|||||||
lastUpdateCheckAt: null,
|
lastUpdateCheckAt: null,
|
||||||
defaultRatingFilter: null,
|
defaultRatingFilter: null,
|
||||||
lastFeedRating: null,
|
lastFeedRating: null,
|
||||||
|
searchHistoryLimit: 500,
|
||||||
|
tagCacheLimit: 5000,
|
||||||
);
|
);
|
||||||
|
|
||||||
AppSettings copyWith({
|
AppSettings copyWith({
|
||||||
@@ -199,6 +205,8 @@ class AppSettings {
|
|||||||
String? gridMode,
|
String? gridMode,
|
||||||
List<String>? searchPresets,
|
List<String>? searchPresets,
|
||||||
String? downloadPathTemplate,
|
String? downloadPathTemplate,
|
||||||
|
int? searchHistoryLimit,
|
||||||
|
int? tagCacheLimit,
|
||||||
}) {
|
}) {
|
||||||
return AppSettings(
|
return AppSettings(
|
||||||
favoriteArtists: favoriteArtists ?? this.favoriteArtists,
|
favoriteArtists: favoriteArtists ?? this.favoriteArtists,
|
||||||
@@ -254,6 +262,8 @@ class AppSettings {
|
|||||||
gridMode: gridMode ?? this.gridMode,
|
gridMode: gridMode ?? this.gridMode,
|
||||||
searchPresets: searchPresets ?? this.searchPresets,
|
searchPresets: searchPresets ?? this.searchPresets,
|
||||||
downloadPathTemplate: downloadPathTemplate ?? this.downloadPathTemplate,
|
downloadPathTemplate: downloadPathTemplate ?? this.downloadPathTemplate,
|
||||||
|
searchHistoryLimit: searchHistoryLimit ?? this.searchHistoryLimit,
|
||||||
|
tagCacheLimit: tagCacheLimit ?? this.tagCacheLimit,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -304,6 +314,8 @@ class AppSettings {
|
|||||||
'gridMode': gridMode,
|
'gridMode': gridMode,
|
||||||
'searchPresets': searchPresets,
|
'searchPresets': searchPresets,
|
||||||
'downloadPathTemplate': downloadPathTemplate,
|
'downloadPathTemplate': downloadPathTemplate,
|
||||||
|
'searchHistoryLimit': searchHistoryLimit,
|
||||||
|
'tagCacheLimit': tagCacheLimit,
|
||||||
};
|
};
|
||||||
|
|
||||||
factory AppSettings.fromJson(Map<String, dynamic> json) => AppSettings(
|
factory AppSettings.fromJson(Map<String, dynamic> json) => AppSettings(
|
||||||
@@ -411,6 +423,10 @@ class AppSettings {
|
|||||||
),
|
),
|
||||||
downloadPathTemplate:
|
downloadPathTemplate:
|
||||||
(json['downloadPathTemplate'] as String?) ?? '{Artist}/{ID}',
|
(json['downloadPathTemplate'] as String?) ?? '{Artist}/{ID}',
|
||||||
|
searchHistoryLimit: (json['searchHistoryLimit'] as num?)?.toInt() ??
|
||||||
|
defaults.searchHistoryLimit,
|
||||||
|
tagCacheLimit: (json['tagCacheLimit'] as num?)?.toInt() ??
|
||||||
|
defaults.tagCacheLimit,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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<String, TagSuggestion> _tags = {};
|
||||||
|
bool _isInitialized = false;
|
||||||
|
Timer? _saveDebounce;
|
||||||
|
String? _resolvedPath;
|
||||||
|
|
||||||
|
bool get isInitialized => _isInitialized;
|
||||||
|
int get tagCount => _tags.length;
|
||||||
|
|
||||||
|
Future<String> _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<void> 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<String, dynamic>.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<TagSuggestion> findLocal(
|
||||||
|
String prefix, {
|
||||||
|
int limit = 16,
|
||||||
|
Iterable<String>? 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 <String>{};
|
||||||
|
|
||||||
|
final matches = <TagSuggestion>[];
|
||||||
|
final seen = <String>{};
|
||||||
|
|
||||||
|
// 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<void> cacheTags(
|
||||||
|
Iterable<TagSuggestion> 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<void> 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<int> getFileSizeBytes() async {
|
||||||
|
try {
|
||||||
|
final path = await _getCacheFilePath();
|
||||||
|
final file = File(path);
|
||||||
|
if (await file.exists()) {
|
||||||
|
return await file.length();
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> clear() async {
|
||||||
|
_saveDebounce?.cancel();
|
||||||
|
_tags.clear();
|
||||||
|
try {
|
||||||
|
final path = await _getCacheFilePath();
|
||||||
|
final file = File(path);
|
||||||
|
if (await file.exists()) {
|
||||||
|
await file.delete();
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -120,35 +120,56 @@ class FeedController extends AsyncNotifier<FeedState> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
await saveSession(scrollOffset: 0);
|
await saveSession(scrollOffset: 0);
|
||||||
await ref.read(searchServiceProvider).saveSearch(query, 0);
|
final settingsRes = await ref.read(settingsServiceProvider).getSettings();
|
||||||
|
final settings =
|
||||||
|
settingsRes is Success<AppSettings> ? settingsRes.data : AppSettings.defaults;
|
||||||
|
await ref
|
||||||
|
.read(searchServiceProvider)
|
||||||
|
.saveSearch(query, 0, maxHistory: settings.searchHistoryLimit);
|
||||||
await refresh();
|
await refresh();
|
||||||
final count = state.value?.posts.length ?? 0;
|
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<void> updateTagSuggestions(String query) async {
|
Future<void> updateTagSuggestions(String query) async {
|
||||||
final current = state.value ?? const FeedState();
|
final current = state.value ?? const FeedState();
|
||||||
final lastToken =
|
final rawLast =
|
||||||
query.trim().isEmpty ? '' : query.trim().split(RegExp(r'\s+')).last;
|
query.trim().isEmpty ? '' : query.trim().split(RegExp(r'\s+')).last;
|
||||||
|
final cleanToken = SearchService.sanitizeToken(rawLast);
|
||||||
final requestId = ++_suggestionRequestId;
|
final requestId = ++_suggestionRequestId;
|
||||||
_suggestionDebounce?.cancel();
|
_suggestionDebounce?.cancel();
|
||||||
if (lastToken.trim().isEmpty) {
|
|
||||||
|
if (cleanToken.isEmpty || cleanToken == 'and') {
|
||||||
state = AsyncData(current.copyWith(tagSuggestions: []));
|
state = AsyncData(current.copyWith(tagSuggestions: []));
|
||||||
return;
|
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<AppSettings>
|
||||||
|
? settingsRes.data
|
||||||
|
: AppSettings.defaults;
|
||||||
final result = await ref
|
final result = await ref
|
||||||
.read(searchServiceProvider)
|
.read(searchServiceProvider)
|
||||||
.autocompleteDetailed(lastToken, limit: 16);
|
.autocompleteDetailed(
|
||||||
|
cleanToken,
|
||||||
|
limit: 16,
|
||||||
|
tagCacheLimit: appSettings.tagCacheLimit,
|
||||||
|
);
|
||||||
if (requestId != _suggestionRequestId) return;
|
if (requestId != _suggestionRequestId) return;
|
||||||
final normalizedToken = lastToken.trim().toLowerCase();
|
|
||||||
final suggestions = result is Success<List<TagSuggestion>>
|
final suggestions = result is Success<List<TagSuggestion>>
|
||||||
? result.data
|
? result.data
|
||||||
.where((item) => item.name.toLowerCase().startsWith(
|
: localMatches;
|
||||||
normalizedToken,
|
|
||||||
))
|
|
||||||
.toList(growable: false)
|
|
||||||
: const <TagSuggestion>[];
|
|
||||||
final latest = state.value ?? current;
|
final latest = state.value ?? current;
|
||||||
state = AsyncData(latest.copyWith(tagSuggestions: suggestions));
|
state = AsyncData(latest.copyWith(tagSuggestions: suggestions));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -21,11 +21,13 @@ class SearchController extends AsyncNotifier<SearchState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> updateQuery(String query) async {
|
Future<void> updateQuery(String query) async {
|
||||||
final lastToken =
|
final rawLast =
|
||||||
query.trim().isEmpty ? '' : query.trim().split(RegExp(r'\s+')).last;
|
query.trim().isEmpty ? '' : query.trim().split(RegExp(r'\s+')).last;
|
||||||
|
final cleanToken = SearchService.sanitizeToken(rawLast);
|
||||||
final requestId = ++_suggestionRequestId;
|
final requestId = ++_suggestionRequestId;
|
||||||
_suggestionDebounce?.cancel();
|
_suggestionDebounce?.cancel();
|
||||||
if (lastToken.isEmpty) {
|
|
||||||
|
if (cleanToken.isEmpty || cleanToken == 'and') {
|
||||||
state = AsyncData(
|
state = AsyncData(
|
||||||
(state.value ?? const SearchState()).copyWith(
|
(state.value ?? const SearchState()).copyWith(
|
||||||
query: query,
|
query: query,
|
||||||
@@ -34,24 +36,35 @@ class SearchController extends AsyncNotifier<SearchState> {
|
|||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Instant local suggestions from disk cache & history (0 ms)
|
||||||
|
final localMatches = ref
|
||||||
|
.read(searchServiceProvider)
|
||||||
|
.instantSuggestions(cleanToken, limit: 16);
|
||||||
|
|
||||||
state = AsyncData(
|
state = AsyncData(
|
||||||
(state.value ?? const SearchState()).copyWith(
|
(state.value ?? const SearchState()).copyWith(
|
||||||
query: query,
|
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
|
final suggestionsResult = await ref
|
||||||
.read(searchServiceProvider)
|
.read(searchServiceProvider)
|
||||||
.autocompleteDetailed(lastToken, limit: 16);
|
.autocompleteDetailed(
|
||||||
|
cleanToken,
|
||||||
|
limit: 16,
|
||||||
|
tagCacheLimit: appSettings.tagCacheLimit,
|
||||||
|
);
|
||||||
if (requestId != _suggestionRequestId) return;
|
if (requestId != _suggestionRequestId) return;
|
||||||
final normalizedToken = lastToken.trim().toLowerCase();
|
|
||||||
final suggestions = suggestionsResult is Success<List<TagSuggestion>>
|
final suggestions = suggestionsResult is Success<List<TagSuggestion>>
|
||||||
? suggestionsResult.data
|
? suggestionsResult.data
|
||||||
.where((item) => item.name.toLowerCase().startsWith(
|
: localMatches;
|
||||||
normalizedToken,
|
|
||||||
))
|
|
||||||
.toList(growable: false)
|
|
||||||
: <TagSuggestion>[];
|
|
||||||
state = AsyncData(
|
state = AsyncData(
|
||||||
(state.value ?? const SearchState()).copyWith(
|
(state.value ?? const SearchState()).copyWith(
|
||||||
query: query,
|
query: query,
|
||||||
|
|||||||
@@ -3,8 +3,11 @@ import 'package:flutter/material.dart';
|
|||||||
import '../../../../backend/backend.dart';
|
import '../../../../backend/backend.dart';
|
||||||
|
|
||||||
class SearchSuggestions extends StatelessWidget {
|
class SearchSuggestions extends StatelessWidget {
|
||||||
const SearchSuggestions(
|
const SearchSuggestions({
|
||||||
{required this.suggestions, required this.onTap, super.key});
|
required this.suggestions,
|
||||||
|
required this.onTap,
|
||||||
|
super.key,
|
||||||
|
});
|
||||||
|
|
||||||
final List<TagSuggestion> suggestions;
|
final List<TagSuggestion> suggestions;
|
||||||
final ValueChanged<String> onTap;
|
final ValueChanged<String> onTap;
|
||||||
@@ -12,16 +15,43 @@ class SearchSuggestions extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
if (suggestions.isEmpty) return const SizedBox.shrink();
|
if (suggestions.isEmpty) return const SizedBox.shrink();
|
||||||
|
final scheme = Theme.of(context).colorScheme;
|
||||||
return Wrap(
|
return Wrap(
|
||||||
spacing: 8,
|
spacing: 8,
|
||||||
runSpacing: 8,
|
runSpacing: 8,
|
||||||
children: [
|
children: [
|
||||||
for (final suggestion in suggestions)
|
for (final suggestion in suggestions)
|
||||||
ActionChip(
|
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),
|
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';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ class CacheStats {
|
|||||||
required this.imageMemoryCount,
|
required this.imageMemoryCount,
|
||||||
required this.tempDirectoryBytes,
|
required this.tempDirectoryBytes,
|
||||||
required this.downloadedCount,
|
required this.downloadedCount,
|
||||||
|
this.tagCacheCount = 0,
|
||||||
|
this.tagCacheBytes = 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
final int cachedPostsCount;
|
final int cachedPostsCount;
|
||||||
@@ -23,9 +25,14 @@ class CacheStats {
|
|||||||
final int imageMemoryCount;
|
final int imageMemoryCount;
|
||||||
final int tempDirectoryBytes;
|
final int tempDirectoryBytes;
|
||||||
final int downloadedCount;
|
final int downloadedCount;
|
||||||
|
final int tagCacheCount;
|
||||||
|
final int tagCacheBytes;
|
||||||
|
|
||||||
int get totalEstimatedBytes =>
|
int get totalEstimatedBytes =>
|
||||||
imageMemoryBytes + tempDirectoryBytes + (cachedPostsCount * 2048);
|
imageMemoryBytes +
|
||||||
|
tempDirectoryBytes +
|
||||||
|
tagCacheBytes +
|
||||||
|
(cachedPostsCount * 2048);
|
||||||
|
|
||||||
static const empty = CacheStats(
|
static const empty = CacheStats(
|
||||||
cachedPostsCount: 0,
|
cachedPostsCount: 0,
|
||||||
@@ -33,6 +40,8 @@ class CacheStats {
|
|||||||
imageMemoryCount: 0,
|
imageMemoryCount: 0,
|
||||||
tempDirectoryBytes: 0,
|
tempDirectoryBytes: 0,
|
||||||
downloadedCount: 0,
|
downloadedCount: 0,
|
||||||
|
tagCacheCount: 0,
|
||||||
|
tagCacheBytes: 0,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,14 +73,21 @@ final cacheStatsProvider = FutureProvider.autoDispose<CacheStats>((ref) async {
|
|||||||
}
|
}
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
|
|
||||||
return CacheStats(
|
final tagCache = ref.watch(tagCacheServiceProvider);
|
||||||
cachedPostsCount: postsCount,
|
if (!tagCache.isInitialized) await tagCache.init();
|
||||||
imageMemoryBytes: memBytes,
|
final tagCount = tagCache.tagCount;
|
||||||
imageMemoryCount: memCount,
|
final tagBytes = await tagCache.getFileSizeBytes();
|
||||||
tempDirectoryBytes: tempBytes,
|
|
||||||
downloadedCount: downloadedCount,
|
return CacheStats(
|
||||||
);
|
cachedPostsCount: postsCount,
|
||||||
});
|
imageMemoryBytes: memBytes,
|
||||||
|
imageMemoryCount: memCount,
|
||||||
|
tempDirectoryBytes: tempBytes,
|
||||||
|
downloadedCount: downloadedCount,
|
||||||
|
tagCacheCount: tagCount,
|
||||||
|
tagCacheBytes: tagBytes,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
class CacheManagerScreen extends ConsumerStatefulWidget {
|
class CacheManagerScreen extends ConsumerStatefulWidget {
|
||||||
const CacheManagerScreen({super.key});
|
const CacheManagerScreen({super.key});
|
||||||
@@ -127,9 +143,17 @@ class _CacheManagerScreenState extends ConsumerState<CacheManagerScreen> {
|
|||||||
if (mounted) setState(() => _isClearing = false);
|
if (mounted) setState(() => _isClearing = false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _clearTagCache() async {
|
||||||
|
setState(() => _isClearing = true);
|
||||||
|
await ref.read(tagCacheServiceProvider).clear();
|
||||||
|
ref.invalidate(cacheStatsProvider);
|
||||||
|
if (mounted) setState(() => _isClearing = false);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _clearAllCache() async {
|
Future<void> _clearAllCache() async {
|
||||||
setState(() => _isClearing = true);
|
setState(() => _isClearing = true);
|
||||||
await ref.read(cacheServiceProvider).clear();
|
await ref.read(cacheServiceProvider).clear();
|
||||||
|
await ref.read(tagCacheServiceProvider).clear();
|
||||||
PaintingBinding.instance.imageCache.clear();
|
PaintingBinding.instance.imageCache.clear();
|
||||||
PaintingBinding.instance.imageCache.clearLiveImages();
|
PaintingBinding.instance.imageCache.clearLiveImages();
|
||||||
try {
|
try {
|
||||||
@@ -244,6 +268,17 @@ class _CacheManagerScreenState extends ConsumerState<CacheManagerScreen> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
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(
|
_CacheCategoryTile(
|
||||||
icon: Icons.download_done_rounded,
|
icon: Icons.download_done_rounded,
|
||||||
title: strings.ru ? 'Скачанные медиа' : 'Downloaded media',
|
title: strings.ru ? 'Скачанные медиа' : 'Downloaded media',
|
||||||
|
|||||||
@@ -38,6 +38,14 @@ class SettingsController extends AsyncNotifier<AppSettings> {
|
|||||||
ref.invalidateSelf();
|
ref.invalidateSelf();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> clearSearchHistory() async {
|
||||||
|
await ref.read(searchServiceProvider).clearHistory();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> clearTagCache() async {
|
||||||
|
await ref.read(searchServiceProvider).clearTagCache();
|
||||||
|
}
|
||||||
|
|
||||||
Future<String> diagnosticsReport() async {
|
Future<String> diagnosticsReport() async {
|
||||||
final result =
|
final result =
|
||||||
await ref.read(settingsServiceProvider).buildDiagnosticsReport(
|
await ref.read(settingsServiceProvider).buildDiagnosticsReport(
|
||||||
|
|||||||
@@ -392,6 +392,28 @@ class _SettingsContent extends ConsumerWidget {
|
|||||||
onChanged: (value) =>
|
onChanged: (value) =>
|
||||||
_update(ref, settings.copyWith(cacheMaxItems: 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(
|
_ActionGrid(
|
||||||
children: [
|
children: [
|
||||||
FilledButton.tonalIcon(
|
FilledButton.tonalIcon(
|
||||||
@@ -408,11 +430,59 @@ class _SettingsContent extends ConsumerWidget {
|
|||||||
? 'Менеджер кэша'
|
? 'Менеджер кэша'
|
||||||
: 'Cache Manager'),
|
: '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(
|
FilledButton.tonalIcon(
|
||||||
onPressed: () => ref
|
onPressed: () => ref
|
||||||
.read(settingsControllerProvider.notifier)
|
.read(settingsControllerProvider.notifier)
|
||||||
.clearViewedHistory(),
|
.clearViewedHistory(),
|
||||||
icon: const Icon(Icons.history_toggle_off_rounded),
|
icon: const Icon(Icons.visibility_off_rounded),
|
||||||
label: Text(strings.clearViewed),
|
label: Text(strings.clearViewed),
|
||||||
),
|
),
|
||||||
FilledButton.tonalIcon(
|
FilledButton.tonalIcon(
|
||||||
@@ -426,7 +496,7 @@ class _SettingsContent extends ConsumerWidget {
|
|||||||
onPressed: () => ref
|
onPressed: () => ref
|
||||||
.read(settingsControllerProvider.notifier)
|
.read(settingsControllerProvider.notifier)
|
||||||
.clearHiddenPosts(),
|
.clearHiddenPosts(),
|
||||||
icon: const Icon(Icons.visibility_off_rounded),
|
icon: const Icon(Icons.delete_sweep_rounded),
|
||||||
label:
|
label:
|
||||||
Text('Clear hidden (${settings.hiddenPostKeys.length})'),
|
Text('Clear hidden (${settings.hiddenPostKeys.length})'),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -228,6 +228,7 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
|
|||||||
width: size.width,
|
width: size.width,
|
||||||
yOffset: size.height + 8,
|
yOffset: size.height + 8,
|
||||||
suggestions: suggestions,
|
suggestions: suggestions,
|
||||||
|
query: token,
|
||||||
onSelected: _applySuggestion,
|
onSelected: _applySuggestion,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -372,7 +373,7 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
|
|||||||
|
|
||||||
void _notifyChangedDebounced() {
|
void _notifyChangedDebounced() {
|
||||||
_debounce?.cancel();
|
_debounce?.cancel();
|
||||||
_debounce = Timer(const Duration(milliseconds: 250), _notifyChanged);
|
_debounce = Timer(const Duration(milliseconds: 100), _notifyChanged);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _notifyChanged() {
|
void _notifyChanged() {
|
||||||
@@ -401,7 +402,8 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
|
|||||||
String get _activeToken {
|
String get _activeToken {
|
||||||
final draft = _controller.text.trim().toLowerCase();
|
final draft = _controller.text.trim().toLowerCase();
|
||||||
if (draft.isEmpty) return '';
|
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;
|
bool get _isEditing => _focusNode.hasFocus || _localDirty;
|
||||||
@@ -413,6 +415,7 @@ class _TagSuggestionOverlay extends StatelessWidget {
|
|||||||
required this.width,
|
required this.width,
|
||||||
required this.yOffset,
|
required this.yOffset,
|
||||||
required this.suggestions,
|
required this.suggestions,
|
||||||
|
required this.query,
|
||||||
required this.onSelected,
|
required this.onSelected,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -420,6 +423,7 @@ class _TagSuggestionOverlay extends StatelessWidget {
|
|||||||
final double width;
|
final double width;
|
||||||
final double yOffset;
|
final double yOffset;
|
||||||
final List<TagSuggestion> suggestions;
|
final List<TagSuggestion> suggestions;
|
||||||
|
final String query;
|
||||||
final ValueChanged<String> onSelected;
|
final ValueChanged<String> onSelected;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -435,6 +439,7 @@ class _TagSuggestionOverlay extends StatelessWidget {
|
|||||||
width: width.clamp(280, 620).toDouble(),
|
width: width.clamp(280, 620).toDouble(),
|
||||||
child: _TagSuggestionDropdown(
|
child: _TagSuggestionDropdown(
|
||||||
suggestions: suggestions,
|
suggestions: suggestions,
|
||||||
|
query: query,
|
||||||
onSelected: onSelected,
|
onSelected: onSelected,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -447,10 +452,12 @@ class _TagSuggestionOverlay extends StatelessWidget {
|
|||||||
class _TagSuggestionDropdown extends StatelessWidget {
|
class _TagSuggestionDropdown extends StatelessWidget {
|
||||||
const _TagSuggestionDropdown({
|
const _TagSuggestionDropdown({
|
||||||
required this.suggestions,
|
required this.suggestions,
|
||||||
|
required this.query,
|
||||||
required this.onSelected,
|
required this.onSelected,
|
||||||
});
|
});
|
||||||
|
|
||||||
final List<TagSuggestion> suggestions;
|
final List<TagSuggestion> suggestions;
|
||||||
|
final String query;
|
||||||
final ValueChanged<String> onSelected;
|
final ValueChanged<String> onSelected;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -482,18 +489,24 @@ class _TagSuggestionDropdown extends StatelessWidget {
|
|||||||
const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
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),
|
const SizedBox(width: 14),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: _buildHighlightedText(
|
||||||
|
context,
|
||||||
suggestion.name,
|
suggestion.name,
|
||||||
maxLines: 1,
|
query,
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: Theme.of(context).textTheme.bodyLarge,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 10),
|
const SizedBox(width: 10),
|
||||||
_TagSuggestionBadge(suggestion.categoryLabel),
|
_TagSuggestionBadge(
|
||||||
|
category: suggestion.category,
|
||||||
|
label: suggestion.categoryLabel,
|
||||||
|
),
|
||||||
if (suggestion.postCount > 0) ...[
|
if (suggestion.postCount > 0) ...[
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Text(
|
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) {
|
String _compactCount(int value) {
|
||||||
if (value >= 1000000) return '${(value / 1000000).toStringAsFixed(1)}M';
|
if (value >= 1000000) return '${(value / 1000000).toStringAsFixed(1)}M';
|
||||||
if (value >= 1000) return '${(value / 1000).toStringAsFixed(1)}K';
|
if (value >= 1000) return '${(value / 1000).toStringAsFixed(1)}K';
|
||||||
@@ -522,16 +601,22 @@ class _TagSuggestionDropdown extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _TagSuggestionBadge extends StatelessWidget {
|
class _TagSuggestionBadge extends StatelessWidget {
|
||||||
const _TagSuggestionBadge(this.label);
|
const _TagSuggestionBadge({
|
||||||
|
required this.category,
|
||||||
|
required this.label,
|
||||||
|
});
|
||||||
|
|
||||||
|
final TagCategory category;
|
||||||
final String label;
|
final String label;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final scheme = Theme.of(context).colorScheme;
|
final scheme = Theme.of(context).colorScheme;
|
||||||
|
final (bgColor, fgColor) = _badgeColors(scheme);
|
||||||
|
|
||||||
return DecoratedBox(
|
return DecoratedBox(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: scheme.primaryContainer.withValues(alpha: 0.55),
|
color: bgColor,
|
||||||
borderRadius: BorderRadius.circular(999),
|
borderRadius: BorderRadius.circular(999),
|
||||||
),
|
),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
@@ -539,13 +624,42 @@ class _TagSuggestionBadge extends StatelessWidget {
|
|||||||
child: Text(
|
child: Text(
|
||||||
label,
|
label,
|
||||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||||
color: scheme.onPrimaryContainer,
|
color: fgColor,
|
||||||
fontWeight: FontWeight.w700,
|
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<AppSearchBar> {
|
class _AppSearchBarState extends State<AppSearchBar> {
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
name: gel_rule_app
|
name: gel_rule_app
|
||||||
description: Local backend/core layer for a Flutter booru browser app.
|
description: Local backend/core layer for a Flutter booru browser app.
|
||||||
publish_to: "none"
|
publish_to: "none"
|
||||||
version: 2.0.8+30
|
version: 2.0.9+31
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ">=3.3.0 <4.0.0"
|
sdk: ">=3.3.0 <4.0.0"
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ class FakeSearchRepository implements SearchRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<Result<void>> save(SearchHistory history) async {
|
Future<Result<void>> save(SearchHistory history, {int maxItems = 500}) async {
|
||||||
items.add(history);
|
items.add(history);
|
||||||
return const Success(null);
|
return const Success(null);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user