Compare commits

...
5 Commits
26 changed files with 1268 additions and 191 deletions
+11 -7
View File
@@ -10,7 +10,7 @@
</p>
<p align="center">
<img src="https://img.shields.io/badge/Version-2.0.6-blueviolet?style=for-the-badge" alt="Version 2.0.6" />
<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/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" />
@@ -20,11 +20,15 @@
## ✨ Основные возможности
- **🌐 Общая лента Pawchive**: Недавние посты со всех платформ прямо в главной ленте (Feed) с поддержкой поиска.
- **⚡ Мгновенные подсказки тегов (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.
- **🏷️ Интерактивные теги автора**: Быстрая фильтрация по темам с живыми счётчиками постов (`#tag (count)`).
- **📢 Анонсы и новости авторов**: Всплывающее окно анонсов автора с датами и текстом обращений к аудитории.
- **🔗 Связанные аккаунты**: Удобный переход между профилями автора на разных платформах (Patreon, Fanbox, Discord и др.).
- **🎬 Серверная фильтрация по типу медиа**: Поиск фото, видео и GIF по всей глубине архива автора через API.
- **⚡ Мгновенный фильтр фото, видео и GIF**: Локальная быстрая фильтрация постов автора без задержек.
- **📱 Плавная лента в стиле Pinterest**: Masonry-сетка с переключением режима (Masonry, 1:1 Сетка, Список) в шапке ленты.
- **⚡ Сверхбыстрые видео-превью**: Мгновенная загрузка превью без фризов и лишней нагрузки на процессор и память смартфона.
- **↔️ Листание постов влево и вправо**: Полноэкранный пейджер с плавными свайпами между фото и видео во всех разделах.
@@ -65,9 +69,9 @@
🔗 **[Gitea Releases (RarDog/Lunaris)](https://gitea.rardogsynapse.online/RarDog/Lunaris/releases)**
- **Android**: `Lunaris-v2.0.6.apk`
- **Linux (x64)**: `Lunaris-v2.0.6-linux-x64.tar.gz`
- **Windows**: `LunarisSetup-v2.0.6.exe` / `LunarisPortable-v2.0.6.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`
---
+2 -2
View File
@@ -1,3 +1,3 @@
const appDisplayVersion = '2.0.6';
const appBuildNumber = 28;
const appDisplayVersion = '2.0.9';
const appBuildNumber = 31;
const appReleaseChannel = 'Stable';
+34
View File
@@ -1,4 +1,38 @@
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',
bullets: [
'Trailing context modifiers: typing "cat and dog anthro" automatically distributes "anthro" to all "and" branches (cat anthro + dog anthro).',
'Optional grouping parenthesis support: e.g. "(cat and dog) anthro".',
'Resolved "No posts yet" bug by adapting client post validation to multi-tag stream groups and stripping operator keywords from booru queries.',
],
),
LunarisChange(
version: '2.0.7',
title: 'Independent Multi-Tag Search Streams, Local "and" Connector, Pawchive in Main Feed & Reverts',
bullets: [
'Independent multi-tag search: separate tags query providers individually and interleave results so each tag brings its own content without mutual exclusion.',
'Local "and" tag connector: typing "and" between tags groups them into a strict intersection query (e.g. "genshin and raiden").',
'Enabled Pawchive in the main feed alongside Rule34, Gelbooru, e621, and Realbooru.',
'Restored instantaneous client-side media type filtering in artist posts.',
'Removed external linked accounts bar from artist posts screen.',
],
),
LunarisChange(
version: '2.0.6',
title: 'Pawchive Deep Integration: Global Posts Feed, Artist Tags, Announcements, Links & Server Media Filter',
+1
View File
@@ -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';
+10
View File
@@ -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<Set<String>>((ref) async {
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) {
return SearchService(
ref.watch(searchRepositoryProvider),
ref.watch(providerManagerProvider),
ref.watch(tagCacheServiceProvider),
);
});
+24
View File
@@ -22,6 +22,30 @@ class TagSuggestion {
final String providerId;
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) {
+51 -1
View File
@@ -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<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;
}
}
+144 -2
View File
@@ -163,6 +163,68 @@ class ProviderManager {
return Success(results);
}
static List<List<String>> splitTagGroups(List<String> rawTags) {
final segments = <List<String>>[];
List<String> current = [];
for (final raw in rawTags) {
var token = raw.trim();
token = token.replaceAll(RegExp(r'^[\(\)]+|[\(\)]+$'), '').trim();
if (token.isEmpty) continue;
if (token.toLowerCase() == 'and') {
if (current.isNotEmpty) {
segments.add(current);
current = [];
}
} else {
current.add(token);
}
}
if (current.isNotEmpty) {
segments.add(current);
}
if (segments.isEmpty) {
return [const []];
}
if (segments.length == 1) {
return segments;
}
// Multiple segments separated by 'and'
// If the last segment has trailing tags after the branch tag (e.g. 'cat and dog anthro'):
// The trailing tags apply as common modifier tags to ALL branches!
final lastSegment = segments.last;
final prevSegment = segments[segments.length - 2];
final branchLen = prevSegment.length.clamp(1, lastSegment.length);
if (lastSegment.length > branchLen) {
final commonTail = lastSegment.sublist(branchLen);
final adjustedSegments = <List<String>>[];
for (int i = 0; i < segments.length; i++) {
final seg = i == segments.length - 1
? lastSegment
: [...segments[i], ...commonTail];
final seen = <String>{};
final deduped = <String>[];
for (final t in seg) {
if (seen.add(t.toLowerCase())) {
deduped.add(t);
}
}
adjustedSegments.add(deduped);
}
return adjustedSegments;
}
return segments;
}
Future<Result<List<Post>>> searchAcrossProviders({
required List<String> tags,
required int page,
@@ -181,12 +243,68 @@ class ProviderManager {
providers.where((provider) => provider.id == providerId).toList();
}
final tagGroups = splitTagGroups(tags);
if (tagGroups.length <= 1) {
final effectiveTags = tagGroups.isEmpty ? tags : tagGroups.first;
return _searchForTagGroup(
providers: providers,
tags: effectiveTags,
page: page,
limit: limit,
rating: rating,
providerId: providerId,
topPeriod: topPeriod,
);
}
// Multiple independent tag groups: query each tag group as if other tags are not present, then interleave
final seen = <String>{};
final groupPerLimit = (limit / tagGroups.length).ceil().clamp(10, 50);
final groupFutures = tagGroups.map((groupTags) {
return _searchForTagGroup(
providers: providers,
tags: groupTags,
page: page,
limit: groupPerLimit,
rating: rating,
providerId: providerId,
topPeriod: topPeriod,
);
}).toList(growable: false);
final results = await Future.wait(groupFutures);
final validGroupPosts = <List<Post>>[];
for (final res in results) {
if (res is Success<List<Post>>) {
validGroupPosts.add(res.data);
}
}
final interleaved = _interleaveTagGroupResults(validGroupPosts, seen);
return Success(interleaved);
}
Future<Result<List<Post>>> _searchForTagGroup({
required List<ContentProvider> providers,
required List<String> tags,
required int page,
required int limit,
required String? rating,
required String? providerId,
required TopPeriodFilter topPeriod,
}) async {
final cleanTags = tags
.map((t) => t.trim())
.where((t) => t.isNotEmpty && t.toLowerCase() != 'and')
.toList(growable: false);
final seen = <String>{};
final providerResults = <List<Post>>[];
for (final provider in providers) {
try {
final providerPosts = await provider.searchPosts(
tags: tags,
tags: cleanTags,
page: page,
limit: limit,
rating: rating,
@@ -234,6 +352,29 @@ class ProviderManager {
return Success(posts);
}
List<Post> _interleaveTagGroupResults(
List<List<Post>> groupResults, Set<String> seen) {
final combined = <Post>[];
var hasMore = true;
var index = 0;
while (hasMore) {
hasMore = false;
for (final group in groupResults) {
if (index < group.length) {
hasMore = true;
final post = group[index];
if (seen.add(post.cacheKey)) {
combined.add(post);
}
}
}
index++;
}
return combined;
}
List<Post> _flattenProviderResults(
List<List<Post>> results, Set<String> seen) {
final posts = <Post>[];
@@ -584,7 +725,8 @@ class ProviderManager {
}
static bool _isFeedConfig(ContentProviderConfig config) {
return !_isArtistConfig(config);
final type = config.apiType.toLowerCase();
return type != 'kemono' && type != 'coomer';
}
static bool _isLegacyRemovedConfig(ContentProviderConfig config) {
@@ -10,7 +10,7 @@ class SearchRepository {
final DatabaseService _databaseService;
Future<Result<void>> save(SearchHistory history) {
Future<Result<void>> 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<Result<int>> count() {
return _databaseService.safeRead((isar) async {
return await isar.searchHistoryEntitys.count();
});
}
+22 -13
View File
@@ -112,20 +112,29 @@ class FeedService {
}
bool postMatchesRequestedTags(Post post, List<String> requestedTags) {
final requested = requestedTags
.map((tag) => tag.trim().toLowerCase())
.where((tag) => tag.isNotEmpty && !tag.startsWith('-'))
.toList(growable: false);
if (requested.isEmpty) return true;
if (post.providerId == 'pawchive') return true;
final groups = ProviderManager.splitTagGroups(requestedTags);
if (groups.isEmpty) return true;
final postTags = _postTagSet(post);
return requested.every(
(requestedTag) => postTags.any(
(postTag) =>
postTag == requestedTag ||
postTag.startsWith('${requestedTag}_') ||
postTag.startsWith('$requestedTag-'),
),
);
if (postTags.isEmpty) return true;
return groups.any((group) {
final cleanGroup = group
.map((tag) => tag.trim().toLowerCase())
.where((tag) => tag.isNotEmpty && !tag.startsWith('-') && tag != 'and')
.toList(growable: false);
if (cleanGroup.isEmpty) return true;
return cleanGroup.every(
(requestedTag) => postTags.any(
(postTag) =>
postTag == requestedTag ||
postTag.startsWith('${requestedTag}_') ||
postTag.startsWith('$requestedTag-'),
),
);
});
}
bool postPassesTagFilters(Post post, AppSettings settings) {
+125 -16
View File
@@ -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<String> 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<Result<void>> saveSearch(String query, int resultCount) {
Future<Result<void>> 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<Result<List<SearchHistory>>> recentSearches({int? limit}) {
return _repository.recent(limit: limit);
}
Future<Result<List<String>>> autocomplete(String prefix,
{int limit = 10}) async {
/// Instant local tag suggestions (0 ms) from disk cache & recent search history.
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);
return result.fold(
onSuccess: (items) => Success(items.map((item) => item.name).toList()),
@@ -47,12 +103,65 @@ class SearchService {
Future<Result<List<TagSuggestion>>> autocompleteDetailed(
String prefix, {
int limit = 10,
int limit = 16,
int tagCacheLimit = 5000,
Iterable<String>? 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<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<void> clearTagCache() async {
await _tagCacheService?.clear();
}
}
@@ -58,6 +58,8 @@ class AppSettings {
this.gridMode = 'masonry',
this.searchPresets = const [],
this.downloadPathTemplate = '{Artist}/{ID}',
this.searchHistoryLimit = 500,
this.tagCacheLimit = 5000,
});
final List<String> favoriteArtists;
@@ -106,6 +108,8 @@ class AppSettings {
final String gridMode;
final List<String> 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<String>? 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<String, dynamic> 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,
);
}
+205
View File
@@ -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 (_) {}
}
}
@@ -49,7 +49,6 @@ class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
final Set<String> _selectedTypes = <String>{};
List<ArtistTag> _tags = [];
List<ArtistLink> _links = [];
List<ArtistAnnouncement> _announcements = [];
String? _selectedTag;
@@ -87,15 +86,14 @@ class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
(item) => (item as ContentProvider).id == widget.providerId,
);
final tagsRes = await provider.getArtistTags(widget.service, widget.artistId);
final linksRes = await provider.getArtistLinks(widget.service, widget.artistId);
final tagsRes =
await provider.getArtistTags(widget.service, widget.artistId);
final annRes =
await provider.getArtistAnnouncements(widget.service, widget.artistId);
if (!mounted) return;
setState(() {
_tags = tagsRes;
_links = linksRes;
_announcements = annRes;
});
} catch (_) {}
@@ -118,18 +116,11 @@ class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
_error = null;
});
String? queryText;
if (_selectedTypes.length == 1) {
if (_selectedTypes.contains('video')) queryText = 'mp4';
if (_selectedTypes.contains('gif')) queryText = 'gif';
}
final query = ArtistWorkQuery(
providerId: widget.providerId,
service: widget.service,
artistId: widget.artistId,
artistName: widget.artistName,
queryText: queryText,
tagFilter: _selectedTag,
);
@@ -191,21 +182,6 @@ class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
_loadMore();
}
void _onMediaTypeToggled(String type, bool selected) {
setState(() {
if (selected) {
_selectedTypes.add(type);
} else {
_selectedTypes.remove(type);
}
_posts.clear();
_page = 0;
_hasMore = true;
_error = null;
});
_loadMore();
}
bool _matchesTypeFilter(Post post) {
if (_selectedTypes.isEmpty) return true;
final isVid = MediaUrlSelector.isVideo(post);
@@ -299,18 +275,7 @@ class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
FilterChip(
selected: _selectedTypes.isEmpty,
label: Text('Все (${_posts.length})'),
onSelected: (_) {
if (_selectedTypes.isNotEmpty) {
setState(() {
_selectedTypes.clear();
_posts.clear();
_page = 0;
_hasMore = true;
_error = null;
});
_loadMore();
}
},
onSelected: (_) => setState(() => _selectedTypes.clear()),
),
const SizedBox(width: 8),
FilterChip(
@@ -321,7 +286,15 @@ class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
? scheme.onPrimaryContainer
: scheme.onSurfaceVariant),
label: Text('Фото ($photoCount)'),
onSelected: (selected) => _onMediaTypeToggled('photo', selected),
onSelected: (selected) {
setState(() {
if (selected) {
_selectedTypes.add('photo');
} else {
_selectedTypes.remove('photo');
}
});
},
),
const SizedBox(width: 8),
FilterChip(
@@ -332,7 +305,15 @@ class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
? scheme.onPrimaryContainer
: scheme.onSurfaceVariant),
label: Text('Видео ($videoCount)'),
onSelected: (selected) => _onMediaTypeToggled('video', selected),
onSelected: (selected) {
setState(() {
if (selected) {
_selectedTypes.add('video');
} else {
_selectedTypes.remove('video');
}
});
},
),
const SizedBox(width: 8),
FilterChip(
@@ -343,7 +324,15 @@ class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
? scheme.onPrimaryContainer
: scheme.onSurfaceVariant),
label: Text('GIF ($gifCount)'),
onSelected: (selected) => _onMediaTypeToggled('gif', selected),
onSelected: (selected) {
setState(() {
if (selected) {
_selectedTypes.add('gif');
} else {
_selectedTypes.remove('gif');
}
});
},
),
],
),
@@ -382,58 +371,6 @@ class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
);
}
Widget _buildLinksBar() {
if (_links.isEmpty) return const SizedBox.shrink();
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 6),
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
for (final link in _links) ...[
ActionChip(
avatar: Icon(
_serviceIcon(link.service),
size: 16,
color: theme.colorScheme.primary,
),
label: Text('${_serviceLabel(link.service)}: ${link.name}'),
onPressed: () {
context.push(
'/artists/${widget.providerId}/${link.service}/${link.id}?name=${Uri.encodeComponent(link.name)}',
);
},
),
const SizedBox(width: 8),
],
],
),
),
);
}
IconData _serviceIcon(String service) {
return switch (service.toLowerCase()) {
'patreon' => Icons.loyalty_rounded,
'fanbox' => Icons.favorite_border_rounded,
'discord' => Icons.chat_bubble_outline_rounded,
'boosty' => Icons.bolt_rounded,
_ => Icons.link_rounded,
};
}
String _serviceLabel(String service) {
return switch (service.toLowerCase()) {
'patreon' => 'Patreon',
'fanbox' => 'Pixiv Fanbox',
'discord' => 'Discord',
'boosty' => 'Boosty',
'fantia' => 'Fantia',
_ => service,
};
}
@override
Widget build(BuildContext context) {
final settings =
@@ -472,7 +409,6 @@ class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
: _posts.isEmpty
? Column(
children: [
_buildLinksBar(),
_buildTagsBar(),
const Expanded(
child: EmptyView(
@@ -486,7 +422,6 @@ class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
children: [
_buildMediaTypeFilters(),
_buildTagsBar(),
_buildLinksBar(),
Expanded(
child: displayedPosts.isEmpty
? Center(
@@ -510,11 +445,7 @@ class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
setState(() {
_selectedTypes.clear();
_selectedTag = null;
_posts.clear();
_page = 0;
_hasMore = true;
});
_loadMore();
},
child: const Text('Сбросить фильтры'),
),
@@ -120,35 +120,56 @@ class FeedController extends AsyncNotifier<FeedState> {
),
);
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();
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 {
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<AppSettings>
? 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<List<TagSuggestion>>
? result.data
.where((item) => item.name.toLowerCase().startsWith(
normalizedToken,
))
.toList(growable: false)
: const <TagSuggestion>[];
: localMatches;
final latest = state.value ?? current;
state = AsyncData(latest.copyWith(tagSuggestions: suggestions));
});
@@ -21,11 +21,13 @@ class SearchController extends AsyncNotifier<SearchState> {
}
Future<void> updateQuery(String query) async {
final lastToken =
final rawLast =
query.trim().isEmpty ? '' : query.trim().split(RegExp(r'\s+')).last;
final cleanToken = SearchService.sanitizeToken(rawLast);
final requestId = ++_suggestionRequestId;
_suggestionDebounce?.cancel();
if (lastToken.isEmpty) {
if (cleanToken.isEmpty || cleanToken == 'and') {
state = AsyncData(
(state.value ?? const SearchState()).copyWith(
query: query,
@@ -34,24 +36,35 @@ class SearchController extends AsyncNotifier<SearchState> {
);
return;
}
// Instant local suggestions from disk cache & history (0 ms)
final localMatches = ref
.read(searchServiceProvider)
.instantSuggestions(cleanToken, limit: 16);
state = AsyncData(
(state.value ?? const SearchState()).copyWith(
query: query,
suggestions: localMatches,
),
);
_suggestionDebounce = Timer(const Duration(milliseconds: 280), () async {
_suggestionDebounce = Timer(const Duration(milliseconds: 160), () async {
final settingsRes = await ref.read(settingsServiceProvider).getSettings();
final appSettings = settingsRes is Success<AppSettings>
? settingsRes.data
: AppSettings.defaults;
final suggestionsResult = await ref
.read(searchServiceProvider)
.autocompleteDetailed(lastToken, limit: 16);
.autocompleteDetailed(
cleanToken,
limit: 16,
tagCacheLimit: appSettings.tagCacheLimit,
);
if (requestId != _suggestionRequestId) return;
final normalizedToken = lastToken.trim().toLowerCase();
final suggestions = suggestionsResult is Success<List<TagSuggestion>>
? suggestionsResult.data
.where((item) => item.name.toLowerCase().startsWith(
normalizedToken,
))
.toList(growable: false)
: <TagSuggestion>[];
: localMatches;
state = AsyncData(
(state.value ?? const SearchState()).copyWith(
query: query,
@@ -3,8 +3,11 @@ import 'package:flutter/material.dart';
import '../../../../backend/backend.dart';
class SearchSuggestions extends StatelessWidget {
const SearchSuggestions(
{required this.suggestions, required this.onTap, super.key});
const SearchSuggestions({
required this.suggestions,
required this.onTap,
super.key,
});
final List<TagSuggestion> suggestions;
final ValueChanged<String> onTap;
@@ -12,16 +15,43 @@ class SearchSuggestions extends StatelessWidget {
@override
Widget build(BuildContext context) {
if (suggestions.isEmpty) return const SizedBox.shrink();
final scheme = Theme.of(context).colorScheme;
return Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final suggestion in suggestions)
ActionChip(
label: Text('${suggestion.name} · ${suggestion.categoryLabel}'),
avatar: Icon(
Icons.tag_rounded,
size: 16,
color: _categoryColor(suggestion.category, scheme),
),
label: Text(
suggestion.postCount > 0
? '${suggestion.name} (${_compactCount(suggestion.postCount)})'
: suggestion.name,
),
onPressed: () => onTap(suggestion.name),
),
],
);
}
Color _categoryColor(TagCategory category, ColorScheme scheme) {
return switch (category) {
TagCategory.artist => const Color(0xFFFF5252),
TagCategory.character => const Color(0xFF66BB6A),
TagCategory.copyright => const Color(0xFFBA68C8),
TagCategory.species => const Color(0xFF4FC3F7),
TagCategory.meta => const Color(0xFF90A4AE),
TagCategory.general || TagCategory.unknown => scheme.primary,
};
}
String _compactCount(int value) {
if (value >= 1000000) return '${(value / 1000000).toStringAsFixed(1)}M';
if (value >= 1000) return '${(value / 1000).toStringAsFixed(1)}K';
return '$value';
}
}
@@ -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<CacheStats>((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<CacheManagerScreen> {
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 {
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<CacheManagerScreen> {
),
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',
@@ -38,6 +38,14 @@ class SettingsController extends AsyncNotifier<AppSettings> {
ref.invalidateSelf();
}
Future<void> clearSearchHistory() async {
await ref.read(searchServiceProvider).clearHistory();
}
Future<void> clearTagCache() async {
await ref.read(searchServiceProvider).clearTagCache();
}
Future<String> diagnosticsReport() async {
final result =
await ref.read(settingsServiceProvider).buildDiagnosticsReport(
@@ -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})'),
),
+125 -11
View File
@@ -228,6 +228,7 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
width: size.width,
yOffset: size.height + 8,
suggestions: suggestions,
query: token,
onSelected: _applySuggestion,
),
);
@@ -372,7 +373,7 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
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<TagInputSearchBar> {
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<TagSuggestion> suggestions;
final String query;
final ValueChanged<String> 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<TagSuggestion> suggestions;
final String query;
final ValueChanged<String> 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<AppSearchBar> {
+1 -1
View File
@@ -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.6+28
version: 2.0.9+31
environment:
sdk: ">=3.3.0 <4.0.0"
+2 -1
View File
@@ -43,11 +43,12 @@ void main() {
expect(postPassesTagFilters(post, settings), isFalse);
});
test('requested search tags are all required', () {
test('requested search tags are all required unless separated by and', () {
final post = _post(tags: ['touhou', 'hakurei_reimu']);
expect(postMatchesRequestedTags(post, ['touhou']), isTrue);
expect(postMatchesRequestedTags(post, ['touhou', 'cirno']), isFalse);
expect(postMatchesRequestedTags(post, ['touhou', 'and', 'cirno']), isTrue);
});
test('requested search tags can match tag prefixes', () {
+1 -1
View File
@@ -21,7 +21,7 @@ class FakeSearchRepository implements SearchRepository {
}
@override
Future<Result<void>> save(SearchHistory history) async {
Future<Result<void>> save(SearchHistory history, {int maxItems = 500}) async {
items.add(history);
return const Success(null);
}
+149
View File
@@ -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);
});
}
+86
View File
@@ -0,0 +1,86 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:gel_rule_app/backend/providers/provider_manager.dart';
void main() {
group('Tag groups splitting: space groups together, and separates streams', () {
test('single tag returns one group', () {
final groups = ProviderManager.splitTagGroups(['cat']);
expect(groups, equals([['cat']]));
});
test('tags separated by space are grouped together in one query', () {
final groups = ProviderManager.splitTagGroups(['raiden', 'miku']);
expect(groups, equals([
['raiden', 'miku'],
]));
});
test('tags separated by and are split into independent query streams', () {
final groups = ProviderManager.splitTagGroups(['raiden', 'and', 'miku']);
expect(groups, equals([
['raiden'],
['miku'],
]));
});
test('cat and dog anthro applies trailing anthro tag to both branches', () {
final groups = ProviderManager.splitTagGroups(['cat', 'and', 'dog', 'anthro']);
expect(groups, equals([
['cat', 'anthro'],
['dog', 'anthro'],
]));
});
test('multiple trailing modifiers apply to all and-branches', () {
final groups = ProviderManager.splitTagGroups([
'cat',
'and',
'dog',
'and',
'fox',
'anthro',
'solo',
]);
expect(groups, equals([
['cat', 'anthro', 'solo'],
['dog', 'anthro', 'solo'],
['fox', 'anthro', 'solo'],
]));
});
test('supports optional parentheses around and branches', () {
final groups = ProviderManager.splitTagGroups(['(cat', 'and', 'dog)', 'anthro']);
expect(groups, equals([
['cat', 'anthro'],
['dog', 'anthro'],
]));
});
test('multiple multi-tag groups separated by and', () {
final groups = ProviderManager.splitTagGroups([
'genshin',
'raiden',
'and',
'vocaloid',
'miku',
]);
expect(groups, equals([
['genshin', 'raiden'],
['vocaloid', 'miku'],
]));
});
test('case-insensitive AND separator', () {
final groups = ProviderManager.splitTagGroups(['cat', 'AND', 'dog']);
expect(groups, equals([
['cat'],
['dog'],
]));
});
test('empty tags return empty group list', () {
final groups = ProviderManager.splitTagGroups([]);
expect(groups, equals([const <String>[]]));
});
});
}