Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7e384b1be7 | ||
|
|
5560332b66 | ||
|
|
4acd6c29e0 | ||
|
|
fb31a303ff | ||
|
|
a8b778146d | ||
|
|
69d8bfc6bf | ||
|
|
b82d87161e |
@@ -10,7 +10,7 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/Version-2.0.5-blueviolet?style=for-the-badge" alt="Version 2.0.5" />
|
||||
<img src="https://img.shields.io/badge/Version-2.0.10-blueviolet?style=for-the-badge" alt="Version 2.0.10" />
|
||||
<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,26 +20,33 @@
|
||||
|
||||
## ✨ Основные возможности
|
||||
|
||||
- **⚡ Мгновенные подсказки тегов (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)`).
|
||||
- **📢 Анонсы и новости авторов**: Всплывающее окно анонсов автора с датами и текстом обращений к аудитории.
|
||||
- **⚡ Мгновенный фильтр фото, видео и GIF**: Локальная быстрая фильтрация постов автора без задержек.
|
||||
- **📱 Плавная лента в стиле Pinterest**: Masonry-сетка с переключением режима (Masonry, 1:1 Сетка, Список) в шапке ленты.
|
||||
- **⚡ Сверхбыстрые видео-превью**: Мгновенная загрузка превью без фризов и лишней нагрузки на процессор и память смартфона.
|
||||
- **↔️ Листание постов влево и вправо**: Полноэкранный пейджер с плавными свайпами между фото и видео во всех разделах (Лента, Авторы, Избранное, Коллекции, История просмотров).
|
||||
- **↔️ Листание постов влево и вправо**: Полноэкранный пейджер с плавными свайпами между фото и видео во всех разделах.
|
||||
- **❤️ Двойной тап для лайка и свайп вниз для закрытия**: Анимированное сердечко по двойному тапу и закрытие экрана просмотра свайпом вниз.
|
||||
- **📤 Прямая отправка файла (Share)**: Мгновенный экспорт оригинальных медиафайлов через системное меню «Поделиться».
|
||||
- **🔖 Сохраненные поисковые пресеты**: Быстрое сохранение комбинаций тегов и фильтров в закладки с мгновенным переключением в один клик.
|
||||
- **🔖 Сохраненные поисковые пресеты**: Быстрое сохранение комбинаций тегов и фильтров в закладки.
|
||||
- **📁 Умная структура папок скачивания**: Настраиваемые шаблоны поддиректорий (`{Artist}`, `{Provider}`, `{Service}`, `{ID}`, `{Date}`).
|
||||
- **🗑️ Менеджер кэша**: Детальная визуализация занятой памяти (база данных, ОЗУ, временные файлы) и выборочная очистка.
|
||||
- **🗑️ Менеджер кэша**: Детальная визуализация занятой памяти и выборочная очистка.
|
||||
- **📦 Бэкап и синхронизация**: Экспорт и импорт полной конфигурации в JSON-файл для переноса на другие устройства.
|
||||
- **🌑 AMOLED Pure Black тема**: Истинно черный фон (`#000000`) для экономии батареи на OLED дисплеях.
|
||||
- **🎨 Динамические цвета Material You**: Поддержка системной палитры Android из обоев устройства.
|
||||
- **⭐ Любимые авторы**: Компактная кнопка-звёздочка сверху справа для быстрого доступа ко всем любимым авторам и их свежему медиапотоку.
|
||||
- **🏷️ Мультивыбор платформ**: Быстрый выбор одной или нескольких платформ авторов (Patreon, Fanbox, Fantia, Boosty, Discord).
|
||||
- **🎬 Фильтр фото, видео и GIF у автора**: Удобные чипсы с мультивыбором типа контента и количеством найденных медиа.
|
||||
- **📜 Бесконечная подгрузка постов автора**: Умная пагинация при прокрутке вниз загружает всю историю работ без ограничений.
|
||||
- **🐾 Поддержка Pawchive**: Интеграция с `pawchive.pw` для просмотра профилей авторов, связанных аккаунтов, тегов и работ.
|
||||
- **🔍 Умный поиск и автодополнение**: Интерактивные чипсы тегов с подсказками от активных API. Быстрое исключение (`-tag`), добавление (`+tag`) или отправка в черный список по долгому тапу.
|
||||
- **🛡️ Продвинутый Blacklist / Whitelist**: Фильтрация по тегам, категориям, рейтингу контента (safe/questionable/explicit), провайдерам и минимальному score.
|
||||
- **🎬 Встроенный плеер**: Удобное воспроизведение WebM / MP4, запоминание настроек звука, fullscreen и повтор.
|
||||
- **💾 Менеджер загрузок и коллекции**: Скачивание оригиналов в высоком качестве, тематические папки-коллекции и автозагрузка избранного.
|
||||
- **⭐ Любимые авторы**: Компактная кнопка-звёздочка сверху справа для быстрого доступа ко всем любимым авторам.
|
||||
- **🏷️ Мультивыбор платформ**: Выбор нескольких платформ (Patreon, Fanbox, Fantia, Boosty, Discord).
|
||||
- **📜 Бесконечная подгрузка постов автора**: Умная пагинация при прокрутке вниз загружает всю историю работ.
|
||||
- **🔍 Умный поиск и автодополнение**: Интерактивные чипсы тегов с подсказками от активных API.
|
||||
- **🛡️ Продвинутый Blacklist / Whitelist**: Фильтрация по тегам, категориям, рейтингу контента и минимальному score.
|
||||
- **🎬 Встроенный плеер**: Удобное воспроизведение WebM / MP4, запоминание звука, fullscreen и повтор.
|
||||
- **💾 Менеджер загрузок и коллекции**: Скачивание оригиналов в высоком качестве и тематические папки-коллекции.
|
||||
- **🔄 Встроенное автообновление**: Проверка и скачивание свежих релизов напрямую из личного репозитория Gitea.
|
||||
|
||||
---
|
||||
@@ -62,9 +69,9 @@
|
||||
|
||||
🔗 **[Gitea Releases (RarDog/Lunaris)](https://gitea.rardogsynapse.online/RarDog/Lunaris/releases)**
|
||||
|
||||
- **Android**: `Lunaris-v2.0.5.apk`
|
||||
- **Linux (x64)**: `Lunaris-v2.0.5-linux-x64.tar.gz`
|
||||
- **Windows**: `LunarisSetup-v2.0.5.exe` / `LunarisPortable-v2.0.5.zip`
|
||||
- **Android**: `Lunaris-v2.0.10.apk`
|
||||
- **Linux (x64)**: `Lunaris-v2.0.10-linux-x64.tar.gz`
|
||||
- **Windows**: `LunarisSetup-v2.0.10.exe` / `LunarisPortable-v2.0.10.zip`
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
const appDisplayVersion = '2.0.5';
|
||||
const appBuildNumber = 27;
|
||||
const appDisplayVersion = '2.0.10';
|
||||
const appBuildNumber = 32;
|
||||
const appReleaseChannel = 'Stable';
|
||||
|
||||
@@ -1,4 +1,59 @@
|
||||
const lunarisChangelog = [
|
||||
LunarisChange(
|
||||
version: '2.0.10',
|
||||
title: 'Fix "and" Tag Operator Retention, Suggestion Preceding Drafts & AND Chip Styling',
|
||||
bullets: [
|
||||
'Fixed "and" operator disappearing when submitting search or applying autocompletions.',
|
||||
'Preserved preceding draft tokens (e.g. typing "cat and d" then picking "dog" now retains "cat", "AND", and "dog").',
|
||||
'Supported multiple "and" separators without accidental deduplication.',
|
||||
'Distinct visual styling for AND separator chips with secondary theme container and alt-route icon.',
|
||||
],
|
||||
),
|
||||
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',
|
||||
bullets: [
|
||||
'Connected Pawchive global recent posts (/api/v1/posts) to the main Feed screen with search.',
|
||||
'Interactive artist tags bar (/tags) with live post counts (#tag (N)) and instant topic filtering.',
|
||||
'Linked accounts bar (/links) with direct navigation to author profiles across platforms.',
|
||||
'Artist announcements modal (/announcements) showing creator news, dates and status updates.',
|
||||
'Server-side media filtering (Photos, Videos, GIFs) querying the entire author archive through the API.',
|
||||
],
|
||||
),
|
||||
LunarisChange(
|
||||
version: '2.0.5',
|
||||
title: '10 QoL Super-Update: AMOLED, Dynamic Colors, Grid switch, Share, Presets, Cache & Backup',
|
||||
|
||||
@@ -4,6 +4,9 @@ export 'models/content_provider_config.dart';
|
||||
export 'models/app_update_info.dart';
|
||||
export 'models/artist_profile.dart';
|
||||
export 'models/artist_work_query.dart';
|
||||
export 'models/artist_tag.dart';
|
||||
export 'models/artist_link.dart';
|
||||
export 'models/artist_announcement.dart';
|
||||
export 'models/download_task.dart';
|
||||
export 'models/downloaded_media.dart';
|
||||
export 'models/favorite.dart';
|
||||
@@ -25,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';
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
class ArtistAnnouncement {
|
||||
const ArtistAnnouncement({
|
||||
required this.service,
|
||||
required this.userId,
|
||||
required this.content,
|
||||
this.hash,
|
||||
this.added,
|
||||
});
|
||||
|
||||
final String service;
|
||||
final String userId;
|
||||
final String content;
|
||||
final String? hash;
|
||||
final DateTime? added;
|
||||
|
||||
factory ArtistAnnouncement.fromJson(Map<String, dynamic> json) {
|
||||
return ArtistAnnouncement(
|
||||
service: (json['service'] ?? '').toString(),
|
||||
userId: (json['user_id'] ?? '').toString(),
|
||||
content: (json['content'] ?? '').toString(),
|
||||
hash: json['hash']?.toString(),
|
||||
added: json['added'] != null
|
||||
? DateTime.tryParse(json['added'].toString())
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'service': service,
|
||||
'user_id': userId,
|
||||
'content': content,
|
||||
if (hash != null) 'hash': hash,
|
||||
if (added != null) 'added': added?.toIso8601String(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
class ArtistLink {
|
||||
const ArtistLink({
|
||||
required this.id,
|
||||
required this.service,
|
||||
required this.name,
|
||||
this.publicId,
|
||||
this.indexed,
|
||||
this.updated,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String service;
|
||||
final String name;
|
||||
final String? publicId;
|
||||
final DateTime? indexed;
|
||||
final DateTime? updated;
|
||||
|
||||
factory ArtistLink.fromJson(Map<String, dynamic> json) {
|
||||
return ArtistLink(
|
||||
id: (json['id'] ?? '').toString(),
|
||||
service: (json['service'] ?? '').toString(),
|
||||
name: (json['name'] ?? '').toString(),
|
||||
publicId: json['public_id']?.toString(),
|
||||
indexed: json['indexed'] != null
|
||||
? DateTime.tryParse(json['indexed'].toString())
|
||||
: null,
|
||||
updated: json['updated'] != null
|
||||
? DateTime.tryParse(json['updated'].toString())
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'service': service,
|
||||
'name': name,
|
||||
if (publicId != null) 'public_id': publicId,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
class ArtistTag {
|
||||
const ArtistTag({
|
||||
required this.tag,
|
||||
required this.postCount,
|
||||
});
|
||||
|
||||
final String tag;
|
||||
final int postCount;
|
||||
|
||||
factory ArtistTag.fromJson(Map<String, dynamic> json) {
|
||||
return ArtistTag(
|
||||
tag: (json['tag'] ?? '').toString(),
|
||||
postCount: (json['post_count'] as num?)?.toInt() ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'tag': tag,
|
||||
'post_count': postCount,
|
||||
};
|
||||
}
|
||||
@@ -4,12 +4,34 @@ class ArtistWorkQuery {
|
||||
required this.service,
|
||||
required this.artistId,
|
||||
required this.artistName,
|
||||
this.queryText,
|
||||
this.tagFilter,
|
||||
});
|
||||
|
||||
final String providerId;
|
||||
final String service;
|
||||
final String artistId;
|
||||
final String artistName;
|
||||
final String? queryText;
|
||||
final String? tagFilter;
|
||||
|
||||
ArtistWorkQuery copyWith({
|
||||
String? providerId,
|
||||
String? service,
|
||||
String? artistId,
|
||||
String? artistName,
|
||||
String? queryText,
|
||||
String? tagFilter,
|
||||
}) {
|
||||
return ArtistWorkQuery(
|
||||
providerId: providerId ?? this.providerId,
|
||||
service: service ?? this.service,
|
||||
artistId: artistId ?? this.artistId,
|
||||
artistName: artistName ?? this.artistName,
|
||||
queryText: queryText ?? this.queryText,
|
||||
tagFilter: tagFilter ?? this.tagFilter,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
@@ -17,9 +39,18 @@ class ArtistWorkQuery {
|
||||
other.providerId == providerId &&
|
||||
other.service == service &&
|
||||
other.artistId == artistId &&
|
||||
other.artistName == artistName;
|
||||
other.artistName == artistName &&
|
||||
other.queryText == queryText &&
|
||||
other.tagFilter == tagFilter;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(providerId, service, artistId, artistName);
|
||||
int get hashCode => Object.hash(
|
||||
providerId,
|
||||
service,
|
||||
artistId,
|
||||
artistName,
|
||||
queryText,
|
||||
tagFilter,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import '../models/artist_announcement.dart';
|
||||
import '../models/artist_link.dart';
|
||||
import '../models/artist_profile.dart';
|
||||
import '../models/artist_tag.dart';
|
||||
import '../models/artist_work_query.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 '../models/artist_profile.dart';
|
||||
import '../models/artist_work_query.dart';
|
||||
|
||||
abstract class ContentProvider {
|
||||
String get id;
|
||||
@@ -72,4 +75,15 @@ abstract class ArtistProvider {
|
||||
String artistId,
|
||||
String postId,
|
||||
);
|
||||
|
||||
Future<List<ArtistTag>> getArtistTags(String service, String artistId) async =>
|
||||
const [];
|
||||
|
||||
Future<List<ArtistLink>> getArtistLinks(
|
||||
String service, String artistId) async =>
|
||||
const [];
|
||||
|
||||
Future<List<ArtistAnnouncement>> getArtistAnnouncements(
|
||||
String service, String artistId) async =>
|
||||
const [];
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../../core/http/dio_client.dart';
|
||||
import '../models/artist_announcement.dart';
|
||||
import '../models/artist_link.dart';
|
||||
import '../models/artist_profile.dart';
|
||||
import '../models/artist_tag.dart';
|
||||
import '../models/artist_work_query.dart';
|
||||
import '../models/post.dart';
|
||||
import '../models/post_comment.dart';
|
||||
@@ -214,6 +217,21 @@ class KemonoProvider
|
||||
return getArtistPostComments(parts[0], parts[1], parts[2]);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<ArtistTag>> getArtistTags(
|
||||
String service, String artistId) async =>
|
||||
const [];
|
||||
|
||||
@override
|
||||
Future<List<ArtistLink>> getArtistLinks(
|
||||
String service, String artistId) async =>
|
||||
const [];
|
||||
|
||||
@override
|
||||
Future<List<ArtistAnnouncement>> getArtistAnnouncements(
|
||||
String service, String artistId) async =>
|
||||
const [];
|
||||
|
||||
String _creatorPath() => _isCoomer ? '/coomer' : '/kemono';
|
||||
|
||||
List<ArtistProfile> _artistsFromResponse(dynamic data) {
|
||||
|
||||
@@ -3,7 +3,10 @@ import 'dart:async';
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../../core/http/dio_client.dart';
|
||||
import '../models/artist_announcement.dart';
|
||||
import '../models/artist_link.dart';
|
||||
import '../models/artist_profile.dart';
|
||||
import '../models/artist_tag.dart';
|
||||
import '../models/artist_work_query.dart';
|
||||
import '../models/post.dart';
|
||||
import '../models/post_comment.dart';
|
||||
@@ -42,8 +45,35 @@ class PawchiveProvider
|
||||
String? rating,
|
||||
TopPeriodFilter topPeriod = TopPeriodFilter.none,
|
||||
}) async {
|
||||
final offset = (page - 1).clamp(0, 999999) * 50;
|
||||
final queryText = tags.join(' ').trim();
|
||||
try {
|
||||
final response = await _dio.get<dynamic>(
|
||||
'/api/v1/posts',
|
||||
queryParameters: {
|
||||
if (queryText.isNotEmpty) 'q': queryText,
|
||||
'o': offset,
|
||||
},
|
||||
);
|
||||
final posts = _postItems(response.data);
|
||||
final mapped = <Post>[];
|
||||
for (final post in posts.whereType<Map>()) {
|
||||
final json = Map<String, dynamic>.from(post);
|
||||
final service = (json['service'] ?? 'fanbox').toString();
|
||||
final userId = (json['user'] ?? '').toString();
|
||||
final dummyQuery = ArtistWorkQuery(
|
||||
providerId: id,
|
||||
service: service,
|
||||
artistId: userId,
|
||||
artistName: userId,
|
||||
);
|
||||
mapped.addAll(_postsFromArtistPost(json, query: dummyQuery));
|
||||
}
|
||||
return mapped;
|
||||
} catch (_) {
|
||||
return const [];
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Post?> getPost(String id) async {
|
||||
@@ -178,7 +208,13 @@ class PawchiveProvider
|
||||
try {
|
||||
final response = await _dio.get<dynamic>(
|
||||
path,
|
||||
queryParameters: {'o': offset},
|
||||
queryParameters: {
|
||||
'o': offset,
|
||||
if (query.tagFilter != null && query.tagFilter!.isNotEmpty)
|
||||
'tag': query.tagFilter,
|
||||
if (query.queryText != null && query.queryText!.isNotEmpty)
|
||||
'q': query.queryText,
|
||||
},
|
||||
);
|
||||
final posts = _postItems(response.data);
|
||||
final mapped = <Post>[];
|
||||
@@ -216,6 +252,72 @@ class PawchiveProvider
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<ArtistTag>> getArtistTags(String service, String artistId) async {
|
||||
try {
|
||||
final response = await _dio.get<dynamic>(
|
||||
'/api/v1/$service/user/$artistId/tags',
|
||||
options: Options(
|
||||
receiveTimeout: const Duration(seconds: 15),
|
||||
sendTimeout: const Duration(seconds: 10),
|
||||
),
|
||||
);
|
||||
final items = response.data;
|
||||
if (items is List) {
|
||||
return items
|
||||
.whereType<Map>()
|
||||
.map((item) => ArtistTag.fromJson(Map<String, dynamic>.from(item)))
|
||||
.toList(growable: false);
|
||||
}
|
||||
} catch (_) {}
|
||||
return const [];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<ArtistLink>> getArtistLinks(
|
||||
String service, String artistId) async {
|
||||
try {
|
||||
final response = await _dio.get<dynamic>(
|
||||
'/api/v1/$service/user/$artistId/links',
|
||||
options: Options(
|
||||
receiveTimeout: const Duration(seconds: 15),
|
||||
sendTimeout: const Duration(seconds: 10),
|
||||
),
|
||||
);
|
||||
final items = response.data;
|
||||
if (items is List) {
|
||||
return items
|
||||
.whereType<Map>()
|
||||
.map((item) => ArtistLink.fromJson(Map<String, dynamic>.from(item)))
|
||||
.toList(growable: false);
|
||||
}
|
||||
} catch (_) {}
|
||||
return const [];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<ArtistAnnouncement>> getArtistAnnouncements(
|
||||
String service, String artistId) async {
|
||||
try {
|
||||
final response = await _dio.get<dynamic>(
|
||||
'/api/v1/$service/user/$artistId/announcements',
|
||||
options: Options(
|
||||
receiveTimeout: const Duration(seconds: 15),
|
||||
sendTimeout: const Duration(seconds: 10),
|
||||
),
|
||||
);
|
||||
final items = response.data;
|
||||
if (items is List) {
|
||||
return items
|
||||
.whereType<Map>()
|
||||
.map((item) =>
|
||||
ArtistAnnouncement.fromJson(Map<String, dynamic>.from(item)))
|
||||
.toList(growable: false);
|
||||
}
|
||||
} catch (_) {}
|
||||
return const [];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<PostComment>> getComments(String postId) async {
|
||||
final parts = postId.split(':');
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -112,13 +112,21 @@ 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(
|
||||
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 ||
|
||||
@@ -126,6 +134,7 @@ bool postMatchesRequestedTags(Post post, List<String> requestedTags) {
|
||||
postTag.startsWith('$requestedTag-'),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
bool postPassesTagFilters(Post post, AppSettings settings) {
|
||||
|
||||
@@ -1,43 +1,101 @@
|
||||
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;
|
||||
|
||||
List<String> parseTags(String query) {
|
||||
return query
|
||||
TagCacheService? get tagCacheService => _tagCacheService;
|
||||
|
||||
static String sanitizeToken(String raw) {
|
||||
return raw
|
||||
.replaceAll(RegExp(r'^[\(\)]+|[\(\)]+$'), '')
|
||||
.trim()
|
||||
.split(RegExp(r'\s+'))
|
||||
.where((tag) => tag.isNotEmpty)
|
||||
.toSet()
|
||||
.toList();
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
Future<Result<void>> saveSearch(String query, int resultCount) {
|
||||
List<String> parseTags(String query) {
|
||||
final seen = <String>{};
|
||||
final result = <String>[];
|
||||
for (final raw in query.trim().split(RegExp(r'\s+'))) {
|
||||
final tag = raw.trim();
|
||||
if (tag.isEmpty) continue;
|
||||
if (tag.toLowerCase() != 'and' && !seen.add(tag.toLowerCase())) continue;
|
||||
result.add(tag);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<Result<void>> saveSearch(
|
||||
String query,
|
||||
int resultCount, {
|
||||
int maxHistory = 500,
|
||||
}) async {
|
||||
final tags = parseTags(query);
|
||||
return _repository.save(
|
||||
SearchHistory(
|
||||
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 +105,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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 (_) {}
|
||||
}
|
||||
}
|
||||
@@ -47,6 +47,11 @@ class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
|
||||
final _scrollController = ScrollController();
|
||||
final List<Post> _posts = [];
|
||||
final Set<String> _selectedTypes = <String>{};
|
||||
|
||||
List<ArtistTag> _tags = [];
|
||||
List<ArtistAnnouncement> _announcements = [];
|
||||
String? _selectedTag;
|
||||
|
||||
int _page = 0;
|
||||
bool _loading = false;
|
||||
bool _hasMore = true;
|
||||
@@ -60,7 +65,10 @@ class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
|
||||
_loadMore();
|
||||
}
|
||||
});
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _loadMore());
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_loadMore();
|
||||
_loadMetadata();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -69,6 +77,28 @@ class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _loadMetadata() async {
|
||||
try {
|
||||
final providers =
|
||||
await ref.read(providerManagerProvider).activeArtistProviders();
|
||||
if (providers is! Success<List<ArtistProvider>>) return;
|
||||
final provider = providers.data.firstWhere(
|
||||
(item) => (item as ContentProvider).id == widget.providerId,
|
||||
);
|
||||
|
||||
final tagsRes =
|
||||
await provider.getArtistTags(widget.service, widget.artistId);
|
||||
final annRes =
|
||||
await provider.getArtistAnnouncements(widget.service, widget.artistId);
|
||||
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_tags = tagsRes;
|
||||
_announcements = annRes;
|
||||
});
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
Future<void> _refresh() async {
|
||||
setState(() {
|
||||
_posts.clear();
|
||||
@@ -76,7 +106,7 @@ class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
|
||||
_hasMore = true;
|
||||
_error = null;
|
||||
});
|
||||
await _loadMore();
|
||||
await Future.wait([_loadMore(), _loadMetadata()]);
|
||||
}
|
||||
|
||||
Future<void> _loadMore() async {
|
||||
@@ -91,6 +121,7 @@ class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
|
||||
service: widget.service,
|
||||
artistId: widget.artistId,
|
||||
artistName: widget.artistName,
|
||||
tagFilter: _selectedTag,
|
||||
);
|
||||
|
||||
try {
|
||||
@@ -139,6 +170,18 @@ class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
void _onTagSelected(String? tag) {
|
||||
if (_selectedTag == tag) return;
|
||||
setState(() {
|
||||
_selectedTag = tag;
|
||||
_posts.clear();
|
||||
_page = 0;
|
||||
_hasMore = true;
|
||||
_error = null;
|
||||
});
|
||||
_loadMore();
|
||||
}
|
||||
|
||||
bool _matchesTypeFilter(Post post) {
|
||||
if (_selectedTypes.isEmpty) return true;
|
||||
final isVid = MediaUrlSelector.isVideo(post);
|
||||
@@ -151,6 +194,70 @@ class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
|
||||
return false;
|
||||
}
|
||||
|
||||
void _showAnnouncements() {
|
||||
final theme = Theme.of(context);
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
showDragHandle: true,
|
||||
builder: (ctx) => DraggableScrollableSheet(
|
||||
expand: false,
|
||||
initialChildSize: 0.6,
|
||||
maxChildSize: 0.9,
|
||||
builder: (_, scroll) => Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.campaign_rounded, color: theme.colorScheme.primary),
|
||||
const SizedBox(width: 10),
|
||||
Text(
|
||||
'Анонсы автора (${_announcements.length})',
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(),
|
||||
Expanded(
|
||||
child: ListView.separated(
|
||||
controller: scroll,
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: _announcements.length,
|
||||
separatorBuilder: (_, __) => const Divider(height: 24),
|
||||
itemBuilder: (context, index) {
|
||||
final a = _announcements[index];
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (a.added != null)
|
||||
Text(
|
||||
'${a.added!.year}-${a.added!.month.toString().padLeft(2, '0')}-${a.added!.day.toString().padLeft(2, '0')}',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: theme.colorScheme.primary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
SelectableText(
|
||||
a.content,
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMediaTypeFilters() {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final photoCount = _posts
|
||||
@@ -160,7 +267,7 @@ class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
|
||||
final gifCount = _posts.where(MediaUrlSelector.isGif).length;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 6, 16, 6),
|
||||
padding: const EdgeInsets.fromLTRB(16, 6, 16, 4),
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
@@ -233,6 +340,37 @@ class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTagsBar() {
|
||||
if (_tags.isEmpty) return const SizedBox.shrink();
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 2, 16, 6),
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: [
|
||||
if (_selectedTag != null) ...[
|
||||
InputChip(
|
||||
selected: true,
|
||||
avatar: const Icon(Icons.close_rounded, size: 16),
|
||||
label: Text('#$_selectedTag'),
|
||||
onSelected: (_) => _onTagSelected(null),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
for (final tag in _tags)
|
||||
if (tag.tag != _selectedTag) ...[
|
||||
ActionChip(
|
||||
label: Text('#${tag.tag} (${tag.postCount})'),
|
||||
onPressed: () => _onTagSelected(tag.tag),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final settings =
|
||||
@@ -240,13 +378,23 @@ class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
|
||||
final favoriteKeys = ref.watch(favoriteKeysProvider).value ?? <String>{};
|
||||
final viewedKeys = ref.watch(viewedKeysProvider).value ?? <String>{};
|
||||
|
||||
final displayedPosts = _posts.where(_matchesTypeFilter).toList(growable: false);
|
||||
final displayedPosts =
|
||||
_posts.where(_matchesTypeFilter).toList(growable: false);
|
||||
|
||||
return AdaptiveScaffold(
|
||||
title: widget.artistName,
|
||||
actions: [
|
||||
if (_announcements.isNotEmpty)
|
||||
IconButton(
|
||||
tooltip: 'Refresh',
|
||||
tooltip: 'Анонсы автора',
|
||||
onPressed: _showAnnouncements,
|
||||
icon: Badge.count(
|
||||
count: _announcements.length,
|
||||
child: const Icon(Icons.campaign_rounded),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Обновить',
|
||||
onPressed: _refresh,
|
||||
icon: const Icon(Icons.refresh_rounded),
|
||||
),
|
||||
@@ -259,13 +407,21 @@ class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
|
||||
onRetry: _refresh,
|
||||
)
|
||||
: _posts.isEmpty
|
||||
? const EmptyView(
|
||||
title: 'No works',
|
||||
message: 'This artist has no visible media yet.',
|
||||
? Column(
|
||||
children: [
|
||||
_buildTagsBar(),
|
||||
const Expanded(
|
||||
child: EmptyView(
|
||||
title: 'Нет работ',
|
||||
message: 'У этого автора пока нет видимых постов.',
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
: Column(
|
||||
children: [
|
||||
_buildMediaTypeFilters(),
|
||||
_buildTagsBar(),
|
||||
Expanded(
|
||||
child: displayedPosts.isEmpty
|
||||
? Center(
|
||||
@@ -274,7 +430,8 @@ class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.filter_alt_off_rounded,
|
||||
const Icon(
|
||||
Icons.filter_alt_off_rounded,
|
||||
size: 48),
|
||||
const SizedBox(height: 12),
|
||||
const Text(
|
||||
@@ -284,9 +441,13 @@ class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
FilledButton.tonal(
|
||||
onPressed: () => setState(
|
||||
() => _selectedTypes.clear()),
|
||||
child: const Text('Показать все'),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_selectedTypes.clear();
|
||||
_selectedTag = null;
|
||||
});
|
||||
},
|
||||
child: const Text('Сбросить фильтры'),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -296,6 +457,7 @@ class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
|
||||
posts: displayedPosts,
|
||||
controller: _scrollController,
|
||||
loading: _loading,
|
||||
gridMode: settings.gridMode,
|
||||
columns: Responsive.columnsFor(
|
||||
context,
|
||||
mobileColumns: settings.mobileColumns,
|
||||
@@ -332,7 +494,8 @@ class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
|
||||
);
|
||||
}
|
||||
ref.invalidate(favoriteKeysProvider);
|
||||
ref.invalidate(favoritesControllerProvider);
|
||||
ref.invalidate(
|
||||
favoritesControllerProvider);
|
||||
},
|
||||
),
|
||||
),
|
||||
@@ -345,7 +508,7 @@ class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
|
||||
final message = error.toString();
|
||||
if (message.contains('HandshakeException') ||
|
||||
message.contains('artist works are unavailable')) {
|
||||
return 'Artist works are unavailable from this provider right now. Try again later or choose another artist/provider.';
|
||||
return 'Работы автора временно недоступны. Попробуйте обновить позже.';
|
||||
}
|
||||
return message;
|
||||
}
|
||||
|
||||
@@ -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 (_) {}
|
||||
|
||||
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})'),
|
||||
),
|
||||
|
||||
@@ -228,6 +228,7 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
|
||||
width: size.width,
|
||||
yOffset: size.height + 8,
|
||||
suggestions: suggestions,
|
||||
query: token,
|
||||
onSelected: _applySuggestion,
|
||||
),
|
||||
);
|
||||
@@ -241,18 +242,34 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
|
||||
|
||||
Widget _buildTagChip(BuildContext context, String tag) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final isAnd = tag.toLowerCase() == 'and';
|
||||
return InputChip(
|
||||
label: Text(tag),
|
||||
avatar: const Icon(Icons.tag_rounded, size: 16),
|
||||
label: Text(
|
||||
isAnd ? 'AND' : tag,
|
||||
style: TextStyle(
|
||||
color: isAnd
|
||||
? scheme.onSecondaryContainer
|
||||
: scheme.onPrimaryContainer,
|
||||
fontWeight: isAnd ? FontWeight.w900 : FontWeight.w700,
|
||||
letterSpacing: isAnd ? 0.8 : 0,
|
||||
),
|
||||
),
|
||||
avatar: Icon(
|
||||
isAnd ? Icons.alt_route_rounded : Icons.tag_rounded,
|
||||
size: 16,
|
||||
color: isAnd ? scheme.secondary : scheme.primary,
|
||||
),
|
||||
deleteIcon: const Icon(Icons.close_rounded, size: 16),
|
||||
onPressed: () => _editTag(tag),
|
||||
onDeleted: () => _removeTag(tag),
|
||||
backgroundColor: scheme.primaryContainer.withValues(alpha: 0.54),
|
||||
labelStyle: TextStyle(
|
||||
color: scheme.onPrimaryContainer,
|
||||
fontWeight: FontWeight.w700,
|
||||
backgroundColor: isAnd
|
||||
? scheme.secondaryContainer.withValues(alpha: 0.7)
|
||||
: scheme.primaryContainer.withValues(alpha: 0.54),
|
||||
side: BorderSide(
|
||||
color: isAnd
|
||||
? scheme.secondary.withValues(alpha: 0.4)
|
||||
: scheme.primary.withValues(alpha: 0.18),
|
||||
),
|
||||
side: BorderSide(color: scheme.primary.withValues(alpha: 0.18)),
|
||||
visualDensity: VisualDensity.compact,
|
||||
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
);
|
||||
@@ -305,7 +322,9 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
|
||||
_localDirty = true;
|
||||
setState(() {
|
||||
for (final tag in additions) {
|
||||
if (!_tags.contains(tag)) _tags.add(tag);
|
||||
if (tag.toLowerCase() == 'and' || !_tags.contains(tag)) {
|
||||
_tags.add(tag);
|
||||
}
|
||||
}
|
||||
_controller.clear();
|
||||
});
|
||||
@@ -358,10 +377,19 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
|
||||
_localDirty = true;
|
||||
setState(() {
|
||||
if (draft.isNotEmpty) {
|
||||
final draftTags = _parseTags(draft);
|
||||
_tags.removeWhere(draftTags.contains);
|
||||
final tokens = _parseTags(draft);
|
||||
if (tokens.isNotEmpty) {
|
||||
final prefixTokens = tokens.sublist(0, tokens.length - 1);
|
||||
for (final token in prefixTokens) {
|
||||
if (token.toLowerCase() == 'and' || !_tags.contains(token)) {
|
||||
_tags.add(token);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!_tags.contains(suggestion)) {
|
||||
_tags.add(suggestion);
|
||||
}
|
||||
if (!_tags.contains(suggestion)) _tags.add(suggestion);
|
||||
_controller.clear();
|
||||
});
|
||||
_notifyChanged();
|
||||
@@ -372,7 +400,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() {
|
||||
@@ -386,7 +414,8 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
|
||||
final result = <String>[];
|
||||
for (final raw in query.trim().split(RegExp(r'\s+'))) {
|
||||
final tag = raw.trim();
|
||||
if (tag.isEmpty || !seen.add(tag)) continue;
|
||||
if (tag.isEmpty) continue;
|
||||
if (tag.toLowerCase() != 'and' && !seen.add(tag.toLowerCase())) continue;
|
||||
result.add(tag);
|
||||
}
|
||||
return result;
|
||||
@@ -401,7 +430,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 +443,7 @@ class _TagSuggestionOverlay extends StatelessWidget {
|
||||
required this.width,
|
||||
required this.yOffset,
|
||||
required this.suggestions,
|
||||
required this.query,
|
||||
required this.onSelected,
|
||||
});
|
||||
|
||||
@@ -420,6 +451,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 +467,7 @@ class _TagSuggestionOverlay extends StatelessWidget {
|
||||
width: width.clamp(280, 620).toDouble(),
|
||||
child: _TagSuggestionDropdown(
|
||||
suggestions: suggestions,
|
||||
query: query,
|
||||
onSelected: onSelected,
|
||||
),
|
||||
),
|
||||
@@ -447,10 +480,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 +517,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 +555,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 +629,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 +652,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
@@ -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.5+27
|
||||
version: 2.0.10+32
|
||||
|
||||
environment:
|
||||
sdk: ">=3.3.0 <4.0.0"
|
||||
|
||||
@@ -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', () {
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:gel_rule_app/backend/models/artist_announcement.dart';
|
||||
import 'package:gel_rule_app/backend/models/artist_link.dart';
|
||||
import 'package:gel_rule_app/backend/models/artist_tag.dart';
|
||||
import 'package:gel_rule_app/backend/models/artist_work_query.dart';
|
||||
|
||||
void main() {
|
||||
group('Pawchive API Features Tests', () {
|
||||
test('ArtistTag model fromJson and toJson roundtrip', () {
|
||||
final json = {
|
||||
'tag': 'genshin_impact',
|
||||
'post_count': 42,
|
||||
};
|
||||
|
||||
final tag = ArtistTag.fromJson(json);
|
||||
expect(tag.tag, equals('genshin_impact'));
|
||||
expect(tag.postCount, equals(42));
|
||||
expect(tag.toJson(), equals(json));
|
||||
});
|
||||
|
||||
test('ArtistLink model parses all fields correctly', () {
|
||||
final json = {
|
||||
'id': '123456',
|
||||
'service': 'fanbox',
|
||||
'name': 'CoolArtist',
|
||||
'public_id': 'cool_pub_id',
|
||||
'indexed': '2026-09-03T12:00:00.000Z',
|
||||
'updated': '2026-09-03T14:00:00.000Z',
|
||||
};
|
||||
|
||||
final link = ArtistLink.fromJson(json);
|
||||
expect(link.id, equals('123456'));
|
||||
expect(link.service, equals('fanbox'));
|
||||
expect(link.name, equals('CoolArtist'));
|
||||
expect(link.publicId, equals('cool_pub_id'));
|
||||
expect(link.indexed, isNotNull);
|
||||
expect(link.updated, isNotNull);
|
||||
});
|
||||
|
||||
test('ArtistAnnouncement model parses content and date', () {
|
||||
final json = {
|
||||
'service': 'patreon',
|
||||
'user_id': '778899',
|
||||
'content': 'Taking a vacation next week!',
|
||||
'added': '2026-08-01T10:00:00.000Z',
|
||||
'hash': 'abc123hash',
|
||||
};
|
||||
|
||||
final announcement = ArtistAnnouncement.fromJson(json);
|
||||
expect(announcement.service, equals('patreon'));
|
||||
expect(announcement.userId, equals('778899'));
|
||||
expect(announcement.content, contains('vacation'));
|
||||
expect(announcement.added, isNotNull);
|
||||
expect(announcement.hash, equals('abc123hash'));
|
||||
});
|
||||
|
||||
test('ArtistWorkQuery supports queryText and tagFilter', () {
|
||||
const query = ArtistWorkQuery(
|
||||
providerId: 'pawchive',
|
||||
service: 'fanbox',
|
||||
artistId: '112233',
|
||||
artistName: 'TestArtist',
|
||||
queryText: 'mp4',
|
||||
tagFilter: 'cyberpunk',
|
||||
);
|
||||
|
||||
expect(query.queryText, equals('mp4'));
|
||||
expect(query.tagFilter, equals('cyberpunk'));
|
||||
|
||||
final modified = query.copyWith(queryText: 'gif');
|
||||
expect(modified.queryText, equals('gif'));
|
||||
expect(modified.tagFilter, equals('cyberpunk'));
|
||||
expect(modified == query, isFalse);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -43,6 +43,17 @@ void main() {
|
||||
expect(service.parseTags(' cat cute cat '), ['cat', 'cute']);
|
||||
});
|
||||
|
||||
test('parses query preserving and operators and grouping', () {
|
||||
expect(
|
||||
service.parseTags('cat and dog and fox'),
|
||||
['cat', 'and', 'dog', 'and', 'fox'],
|
||||
);
|
||||
expect(
|
||||
service.parseTags('(cat and dog) anthro'),
|
||||
['(cat', 'and', 'dog)', 'anthro'],
|
||||
);
|
||||
});
|
||||
|
||||
test('recent searches are ordered by searchedAt', () async {
|
||||
await service.saveSearch('old', 1);
|
||||
await Future<void>.delayed(const Duration(milliseconds: 1));
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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>[]]));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -288,6 +288,57 @@ void main() {
|
||||
expect(size.height, lessThan(96));
|
||||
expect(find.text('touhou'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('preserves AND operator chips when submitted', (tester) async {
|
||||
String submitted = '';
|
||||
await tester.pumpWidget(_Harness(
|
||||
child: TagInputSearchBar(
|
||||
initialValue: 'cat and dog',
|
||||
onSubmitted: (query) => submitted = query,
|
||||
),
|
||||
));
|
||||
|
||||
expect(find.text('cat'), findsOneWidget);
|
||||
expect(find.text('AND'), findsOneWidget);
|
||||
expect(find.text('dog'), findsOneWidget);
|
||||
|
||||
await tester.showKeyboard(find.byType(TextField));
|
||||
await tester.testTextInput.receiveAction(TextInputAction.search);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(submitted, 'cat and dog');
|
||||
});
|
||||
|
||||
testWidgets('applying suggestion preserves preceding tokens and AND operator',
|
||||
(tester) async {
|
||||
String applied = '';
|
||||
await tester.pumpWidget(_Harness(
|
||||
child: TagInputSearchBar(
|
||||
suggestions: const [
|
||||
TagSuggestion(
|
||||
name: 'dog',
|
||||
category: TagCategory.general,
|
||||
postCount: 50,
|
||||
providerId: 'test',
|
||||
),
|
||||
],
|
||||
onSuggestionApplied: (query) => applied = query,
|
||||
onSubmitted: (_) {},
|
||||
),
|
||||
));
|
||||
|
||||
await tester.enterText(find.byType(TextField), 'cat and d');
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('dog'), findsOneWidget);
|
||||
await tester.tap(find.text('dog'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(applied, 'cat and dog');
|
||||
expect(find.text('cat'), findsOneWidget);
|
||||
expect(find.text('AND'), findsOneWidget);
|
||||
expect(find.text('dog'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
|
||||
class _Harness extends StatelessWidget {
|
||||
|
||||
Reference in New Issue
Block a user