Compare commits

..
9 Commits
40 changed files with 3063 additions and 651 deletions
+23 -16
View File
@@ -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.12-blueviolet?style=for-the-badge" alt="Version 2.0.12" />
<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`
---
+2 -2
View File
@@ -1,3 +1,3 @@
const appDisplayVersion = '2.0.5';
const appBuildNumber = 27;
const appDisplayVersion = '2.0.12';
const appBuildNumber = 34;
const appReleaseChannel = 'Stable';
+82
View File
@@ -1,4 +1,86 @@
const lunarisChangelog = [
LunarisChange(
version: '2.0.12',
title: 'Feed 2.0: Pinterest Modern Redesign & Sleek Cards',
bullets: [
'Feed 2.0 Design: modern squircle post cards with 14dp border radius, subtle micro-borders, and depth shadows.',
'Unified Media Badges: sleek glassmorphism capsules for booru providers, ratings, video/GIF indicators, and status badges.',
'Cinematic Bottom Gradient: gentle shading on post cards for clean presentation without obscuring art.',
'Tactile Floating Favorite Button: circular glassmorphism heart button with neon glow and instant feedback.',
'Compact 2-Tier Feed Toolbar: 22dp search capsule, filter count badge, and single unified horizontal scroll for sources and time periods.',
'Quick Filter & Actions: direct access to random post, selection mode, rating picker, and active filter resets.',
'Polished Search Presets: stylish bookmark capsules for quick access to saved search queries.',
],
),
LunarisChange(
version: '2.0.11',
title: 'Mobile Video Player Redesign & Format Recognition Fix',
bullets: [
'Fixed "Failed to recognize file format" error on initial video load by providing synchronous Referer headers and automatic retry.',
'Modern Mobile Video Player: cinematic top and bottom gradient glassmorphism overlays with immersive controls.',
'Double-Tap Seek: double-tap left side to seek -10s, right side to seek +10s with glowing ripple animations.',
'Hold for 2X Speed Boost: press and hold video to temporarily play at 2.0x with glowing badge, release to resume regular speed.',
'Vertical Volume Swipe: swipe up/down on the right half of the screen to adjust player volume with a floating badge.',
'Playback Speed Selector: quick chip button supporting 0.5x, 0.75x, 1.0x, 1.25x, 1.5x, 2.0x speeds.',
'Screen Lock Mode: lock player controls with one tap to prevent accidental touches during video watching.',
'Sleek Neon Seekbar with live network buffer progress indicator.',
],
),
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
View File
@@ -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';
+10
View File
@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -28,6 +30,7 @@ import '../services/favorite_service.dart';
import '../services/feed_service.dart';
import '../services/provider_check_service.dart';
import '../services/search_service.dart';
import '../services/tag_cache_service.dart';
import '../services/settings_service.dart';
import '../services/update_service.dart';
import '../services/viewed_history_service.dart';
@@ -125,10 +128,17 @@ final viewedKeysProvider = FutureProvider<Set<String>>((ref) async {
return result is Success<Set<String>> ? result.data : <String>{};
});
final tagCacheServiceProvider = Provider<TagCacheService>((ref) {
final service = TagCacheService();
unawaited(service.init());
return service;
});
final searchServiceProvider = Provider<SearchService>((ref) {
return SearchService(
ref.watch(searchRepositoryProvider),
ref.watch(providerManagerProvider),
ref.watch(tagCacheServiceProvider),
);
});
@@ -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(),
};
}
+39
View File
@@ -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,
};
}
+21
View File
@@ -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,
};
}
+33 -2
View File
@@ -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,
);
}
+24
View File
@@ -22,6 +22,30 @@ class TagSuggestion {
final String providerId;
String get categoryLabel => category.name;
Map<String, dynamic> toJson() => {
'name': name,
'category': category.name,
'postCount': postCount,
'providerId': providerId,
};
factory TagSuggestion.fromJson(Map<String, dynamic> json) => TagSuggestion(
name: (json['name'] ?? '').toString(),
category: tagCategoryFromString((json['category'] ?? '').toString()),
postCount: (json['postCount'] as num?)?.toInt() ?? 0,
providerId: (json['providerId'] ?? '').toString(),
);
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is TagSuggestion &&
runtimeType == other.runtimeType &&
name.toLowerCase() == other.name.toLowerCase();
@override
int get hashCode => name.toLowerCase().hashCode;
}
TagCategory tagCategoryFromString(String? value) {
+16 -2
View File
@@ -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 [];
}
+51 -1
View File
@@ -5,11 +5,16 @@ import '../mappers/danbooru_mapper.dart';
import '../models/post.dart';
import '../models/post_comment.dart';
import '../models/provider_health.dart';
import '../models/tag_suggestion.dart';
import '../models/top_period_filter.dart';
import 'content_provider.dart';
class DanbooruProvider
implements ContentProvider, CommentProvider, PostPageProvider {
implements
ContentProvider,
CommentProvider,
PostPageProvider,
TagSuggestionProvider {
DanbooruProvider({
required this.id,
required this.name,
@@ -171,4 +176,49 @@ class DanbooruProvider
'${value.month.toString().padLeft(2, '0')}-'
'${value.day.toString().padLeft(2, '0')}';
}
@override
Future<List<TagSuggestion>> suggestTags(
String query, {
int limit = 20,
}) async {
final cleanQuery = query
.replaceAll(RegExp(r'^[\(\)]+|[\(\)]+$'), '')
.trim()
.toLowerCase();
if (cleanQuery.isEmpty) return const [];
try {
final response = await _dio.get<dynamic>(
'/tags/autocomplete.json',
queryParameters: {
'search[name_matches]': cleanQuery,
'limit': limit.clamp(1, 50),
..._queryParameters,
},
);
final items = response.data is List ? response.data as List : const [];
return items
.whereType<Map>()
.map((item) {
final json = Map<String, dynamic>.from(item);
final name = (json['value'] ?? json['name'] ?? '').toString();
return TagSuggestion(
name: name,
category: tagCategoryFromString(json['category']?.toString()),
postCount: _int(json['post_count']),
providerId: id,
);
})
.where((tag) => tag.name.isNotEmpty)
.toList(growable: false);
} catch (_) {
return const [];
}
}
int _int(dynamic value) {
if (value is int) return value;
if (value is num) return value.toInt();
return int.tryParse(value?.toString() ?? '') ?? 0;
}
}
@@ -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) {
+104 -2
View File
@@ -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,7 +45,34 @@ class PawchiveProvider
String? rating,
TopPeriodFilter topPeriod = TopPeriodFilter.none,
}) async {
return const [];
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
@@ -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(':');
+144 -2
View File
@@ -163,6 +163,68 @@ class ProviderManager {
return Success(results);
}
static List<List<String>> splitTagGroups(List<String> rawTags) {
final segments = <List<String>>[];
List<String> current = [];
for (final raw in rawTags) {
var token = raw.trim();
token = token.replaceAll(RegExp(r'^[\(\)]+|[\(\)]+$'), '').trim();
if (token.isEmpty) continue;
if (token.toLowerCase() == 'and') {
if (current.isNotEmpty) {
segments.add(current);
current = [];
}
} else {
current.add(token);
}
}
if (current.isNotEmpty) {
segments.add(current);
}
if (segments.isEmpty) {
return [const []];
}
if (segments.length == 1) {
return segments;
}
// Multiple segments separated by 'and'
// If the last segment has trailing tags after the branch tag (e.g. 'cat and dog anthro'):
// The trailing tags apply as common modifier tags to ALL branches!
final lastSegment = segments.last;
final prevSegment = segments[segments.length - 2];
final branchLen = prevSegment.length.clamp(1, lastSegment.length);
if (lastSegment.length > branchLen) {
final commonTail = lastSegment.sublist(branchLen);
final adjustedSegments = <List<String>>[];
for (int i = 0; i < segments.length; i++) {
final seg = i == segments.length - 1
? lastSegment
: [...segments[i], ...commonTail];
final seen = <String>{};
final deduped = <String>[];
for (final t in seg) {
if (seen.add(t.toLowerCase())) {
deduped.add(t);
}
}
adjustedSegments.add(deduped);
}
return adjustedSegments;
}
return segments;
}
Future<Result<List<Post>>> searchAcrossProviders({
required List<String> tags,
required int page,
@@ -181,12 +243,68 @@ class ProviderManager {
providers.where((provider) => provider.id == providerId).toList();
}
final tagGroups = splitTagGroups(tags);
if (tagGroups.length <= 1) {
final effectiveTags = tagGroups.isEmpty ? tags : tagGroups.first;
return _searchForTagGroup(
providers: providers,
tags: effectiveTags,
page: page,
limit: limit,
rating: rating,
providerId: providerId,
topPeriod: topPeriod,
);
}
// Multiple independent tag groups: query each tag group as if other tags are not present, then interleave
final seen = <String>{};
final groupPerLimit = (limit / tagGroups.length).ceil().clamp(10, 50);
final groupFutures = tagGroups.map((groupTags) {
return _searchForTagGroup(
providers: providers,
tags: groupTags,
page: page,
limit: groupPerLimit,
rating: rating,
providerId: providerId,
topPeriod: topPeriod,
);
}).toList(growable: false);
final results = await Future.wait(groupFutures);
final validGroupPosts = <List<Post>>[];
for (final res in results) {
if (res is Success<List<Post>>) {
validGroupPosts.add(res.data);
}
}
final interleaved = _interleaveTagGroupResults(validGroupPosts, seen);
return Success(interleaved);
}
Future<Result<List<Post>>> _searchForTagGroup({
required List<ContentProvider> providers,
required List<String> tags,
required int page,
required int limit,
required String? rating,
required String? providerId,
required TopPeriodFilter topPeriod,
}) async {
final cleanTags = tags
.map((t) => t.trim())
.where((t) => t.isNotEmpty && t.toLowerCase() != 'and')
.toList(growable: false);
final seen = <String>{};
final providerResults = <List<Post>>[];
for (final provider in providers) {
try {
final providerPosts = await provider.searchPosts(
tags: tags,
tags: cleanTags,
page: page,
limit: limit,
rating: rating,
@@ -234,6 +352,29 @@ class ProviderManager {
return Success(posts);
}
List<Post> _interleaveTagGroupResults(
List<List<Post>> groupResults, Set<String> seen) {
final combined = <Post>[];
var hasMore = true;
var index = 0;
while (hasMore) {
hasMore = false;
for (final group in groupResults) {
if (index < group.length) {
hasMore = true;
final post = group[index];
if (seen.add(post.cacheKey)) {
combined.add(post);
}
}
}
index++;
}
return combined;
}
List<Post> _flattenProviderResults(
List<List<Post>> results, Set<String> seen) {
final posts = <Post>[];
@@ -584,7 +725,8 @@ class ProviderManager {
}
static bool _isFeedConfig(ContentProviderConfig config) {
return !_isArtistConfig(config);
final type = config.apiType.toLowerCase();
return type != 'kemono' && type != 'coomer';
}
static bool _isLegacyRemovedConfig(ContentProviderConfig config) {
@@ -10,7 +10,7 @@ class SearchRepository {
final DatabaseService _databaseService;
Future<Result<void>> save(SearchHistory history) {
Future<Result<void>> save(SearchHistory history, {int maxItems = 500}) {
return _databaseService.safeWrite((isar) async {
await isar.searchHistoryEntitys.put(
SearchHistoryEntity()
@@ -20,6 +20,21 @@ class SearchRepository {
..searchedAt = history.searchedAt
..resultCount = history.resultCount,
);
final count = await isar.searchHistoryEntitys.count();
if (count > maxItems) {
final items = await isar.searchHistoryEntitys.where().findAll();
items.sort((a, b) => b.searchedAt.compareTo(a.searchedAt));
final toRemove = items.skip(maxItems);
for (final item in toRemove) {
await isar.searchHistoryEntitys.delete(item.isarId);
}
}
});
}
Future<Result<int>> count() {
return _databaseService.safeRead((isar) async {
return await isar.searchHistoryEntitys.count();
});
}
+22 -13
View File
@@ -112,20 +112,29 @@ class FeedService {
}
bool postMatchesRequestedTags(Post post, List<String> requestedTags) {
final requested = requestedTags
.map((tag) => tag.trim().toLowerCase())
.where((tag) => tag.isNotEmpty && !tag.startsWith('-'))
.toList(growable: false);
if (requested.isEmpty) return true;
if (post.providerId == 'pawchive') return true;
final groups = ProviderManager.splitTagGroups(requestedTags);
if (groups.isEmpty) return true;
final postTags = _postTagSet(post);
return requested.every(
(requestedTag) => postTags.any(
(postTag) =>
postTag == requestedTag ||
postTag.startsWith('${requestedTag}_') ||
postTag.startsWith('$requestedTag-'),
),
);
if (postTags.isEmpty) return true;
return groups.any((group) {
final cleanGroup = group
.map((tag) => tag.trim().toLowerCase())
.where((tag) => tag.isNotEmpty && !tag.startsWith('-') && tag != 'and')
.toList(growable: false);
if (cleanGroup.isEmpty) return true;
return cleanGroup.every(
(requestedTag) => postTags.any(
(postTag) =>
postTag == requestedTag ||
postTag.startsWith('${requestedTag}_') ||
postTag.startsWith('$requestedTag-'),
),
);
});
}
bool postPassesTagFilters(Post post, AppSettings settings) {
+132 -21
View File
@@ -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(
id: '${DateTime.now().microsecondsSinceEpoch}:${tags.join(',')}',
query: query.trim(),
tags: tags,
searchedAt: DateTime.now(),
resultCount: resultCount,
),
final history = SearchHistory(
id: '${DateTime.now().microsecondsSinceEpoch}:${tags.join(',')}',
query: query.trim(),
tags: tags,
searchedAt: DateTime.now(),
resultCount: resultCount,
);
// Also register user-searched tags in persistent tag cache
if (_tagCacheService != null && tags.isNotEmpty) {
unawaited(
_tagCacheService.cacheTags(
tags.map(
(tag) => TagSuggestion(
name: tag,
category: TagCategory.general,
postCount: resultCount,
providerId: 'history',
),
),
),
);
}
return _repository.save(history, maxItems: maxHistory);
}
Future<Result<List<SearchHistory>>> recentSearches({int? limit}) {
return _repository.recent(limit: limit);
}
Future<Result<List<String>>> autocomplete(String prefix,
{int limit = 10}) async {
/// Instant local tag suggestions (0 ms) from disk cache & recent search history.
List<TagSuggestion> instantSuggestions(
String prefix, {
int limit = 16,
Iterable<String>? priorityTags,
}) {
final clean = sanitizeToken(prefix);
if (clean.isEmpty || clean == 'and') return const [];
return _tagCacheService?.findLocal(
clean,
limit: limit,
priorityTags: priorityTags,
) ??
const [];
}
Future<Result<List<String>>> autocomplete(
String prefix, {
int limit = 10,
}) async {
final result = await autocompleteDetailed(prefix, limit: limit);
return result.fold(
onSuccess: (items) => Success(items.map((item) => item.name).toList()),
@@ -47,12 +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,
);
}
+205
View File
@@ -0,0 +1,205 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:path_provider/path_provider.dart';
import '../models/tag_suggestion.dart';
class TagCacheService {
TagCacheService({String? customPath}) : _customPath = customPath;
final String? _customPath;
final Map<String, TagSuggestion> _tags = {};
bool _isInitialized = false;
Timer? _saveDebounce;
String? _resolvedPath;
bool get isInitialized => _isInitialized;
int get tagCount => _tags.length;
Future<String> _getCacheFilePath() async {
if (_resolvedPath != null) return _resolvedPath!;
if (_customPath != null) {
_resolvedPath = _customPath;
return _resolvedPath!;
}
final dir = await getApplicationDocumentsDirectory();
_resolvedPath = '${dir.path}/lunaris_tag_cache.json';
return _resolvedPath!;
}
Future<void> init() async {
if (_isInitialized) return;
try {
final path = await _getCacheFilePath();
final file = File(path);
if (await file.exists()) {
final content = await file.readAsString();
if (content.trim().isNotEmpty) {
final decoded = jsonDecode(content);
if (decoded is List) {
for (final item in decoded) {
if (item is Map) {
final suggestion = TagSuggestion.fromJson(
Map<String, dynamic>.from(item),
);
final key = suggestion.name.trim().toLowerCase();
if (key.isNotEmpty) {
_tags[key] = suggestion;
}
}
}
}
}
}
} catch (_) {
// Non-critical: corrupted cache file can be rebuilt seamlessly
} finally {
_isInitialized = true;
}
}
/// Finds suggestions locally in memory (0 ms response).
List<TagSuggestion> findLocal(
String prefix, {
int limit = 16,
Iterable<String>? priorityTags,
}) {
final cleanPrefix = prefix
.replaceAll(RegExp(r'^[\(\)]+|[\(\)]+$'), '')
.trim()
.toLowerCase();
if (cleanPrefix.isEmpty) return const [];
final prioritySet = priorityTags
?.map((t) => t.trim().toLowerCase())
.where((t) => t.isNotEmpty)
.toSet() ??
const <String>{};
final matches = <TagSuggestion>[];
final seen = <String>{};
// First check priority tags (e.g. from recent search history)
for (final tag in prioritySet) {
if (tag.startsWith(cleanPrefix) && !seen.contains(tag)) {
seen.add(tag);
final existing = _tags[tag];
matches.add(
existing ??
TagSuggestion(
name: tag,
category: TagCategory.general,
postCount: 0,
providerId: 'history',
),
);
}
}
// Then check all cached tags
for (final entry in _tags.entries) {
if (seen.contains(entry.key)) continue;
if (entry.key.startsWith(cleanPrefix)) {
seen.add(entry.key);
matches.add(entry.value);
}
}
matches.sort((a, b) {
final aKey = a.name.toLowerCase();
final bKey = b.name.toLowerCase();
final aExact = aKey == cleanPrefix;
final bExact = bKey == cleanPrefix;
if (aExact != bExact) return aExact ? -1 : 1;
final aPriority = prioritySet.contains(aKey);
final bPriority = prioritySet.contains(bKey);
if (aPriority != bPriority) return aPriority ? -1 : 1;
final countCmp = b.postCount.compareTo(a.postCount);
if (countCmp != 0) return countCmp;
return aKey.compareTo(bKey);
});
return matches.take(limit.clamp(1, 50)).toList(growable: false);
}
/// Caches newly discovered suggestions and debounces saving to disk.
Future<void> cacheTags(
Iterable<TagSuggestion> suggestions, {
int maxLimit = 5000,
}) async {
if (!_isInitialized) await init();
var changed = false;
for (final item in suggestions) {
final key = item.name.trim().toLowerCase();
if (key.isEmpty) continue;
final existing = _tags[key];
if (existing == null || item.postCount > existing.postCount) {
_tags[key] = item;
changed = true;
}
}
if (!changed) return;
// Prune least popular tags if exceeding max limit
if (_tags.length > maxLimit) {
final sorted = _tags.values.toList()
..sort((a, b) => b.postCount.compareTo(a.postCount));
_tags.clear();
for (final item in sorted.take(maxLimit)) {
_tags[item.name.toLowerCase()] = item;
}
}
_scheduleSave();
}
void _scheduleSave() {
_saveDebounce?.cancel();
_saveDebounce = Timer(const Duration(milliseconds: 1200), () {
flush();
});
}
Future<void> flush() async {
_saveDebounce?.cancel();
try {
final path = await _getCacheFilePath();
final file = File(path);
final list = _tags.values.map((t) => t.toJson()).toList(growable: false);
final jsonString = jsonEncode(list);
await file.writeAsString(jsonString, flush: true);
} catch (_) {
// Silent error on persistence failure
}
}
Future<int> getFileSizeBytes() async {
try {
final path = await _getCacheFilePath();
final file = File(path);
if (await file.exists()) {
return await file.length();
}
} catch (_) {}
return 0;
}
Future<void> clear() async {
_saveDebounce?.cancel();
_tags.clear();
try {
final path = await _getCacheFilePath();
final file = File(path);
if (await file.exists()) {
await file.delete();
}
} catch (_) {}
}
}
@@ -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: 'Анонсы автора',
onPressed: _showAnnouncements,
icon: Badge.count(
count: _announcements.length,
child: const Icon(Icons.campaign_rounded),
),
),
IconButton(
tooltip: 'Refresh',
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));
});
@@ -802,15 +802,22 @@ class _SearchPresetsBar extends StatelessWidget {
);
},
child: FilterChip(
avatar: Icon(
active ? Icons.bookmark_rounded : Icons.bookmark_border_rounded,
size: 15,
color: active ? scheme.primary : scheme.onSurfaceVariant,
),
label: Text(name),
selected: active,
onSelected: (_) => onApply(raw),
selectedColor: scheme.primaryContainer,
selectedColor: scheme.primaryContainer.withValues(alpha: 0.8),
checkmarkColor: scheme.onPrimaryContainer,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
labelStyle: TextStyle(
color: active ? scheme.onPrimaryContainer : scheme.onSurface,
fontWeight:
active ? FontWeight.w600 : FontWeight.normal,
fontWeight: active ? FontWeight.w700 : FontWeight.w500,
),
),
);
@@ -77,6 +77,8 @@ class _FeedToolbarState extends State<FeedToolbar> {
}
Widget _buildDesktop(BuildContext context) {
final hasActiveFilters = widget.selectedProviderIds.isNotEmpty || widget.rating != null;
return Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
child: Column(
@@ -100,34 +102,42 @@ class _FeedToolbarState extends State<FeedToolbar> {
),
const SizedBox(width: 8),
IconButton.filledTonal(
tooltip: widget.selectionMode ? 'Exit select' : 'Select posts',
tooltip: widget.selectionMode ? 'Выйти из выбора' : 'Выбрать посты',
onPressed: widget.onToggleSelectionMode,
icon: Icon(widget.selectionMode
? Icons.check_box_rounded
: Icons.check_box_outline_blank_rounded),
),
IconButton.filledTonal(
tooltip: 'Providers',
onPressed: widget.onProviderFilter,
icon: const Icon(Icons.hub_rounded),
const SizedBox(width: 4),
Badge(
isLabelVisible: hasActiveFilters,
child: IconButton.filledTonal(
tooltip: 'Провайдеры',
onPressed: widget.onProviderFilter,
icon: const Icon(Icons.hub_rounded),
),
),
const SizedBox(width: 4),
IconButton.filledTonal(
tooltip: 'Rating',
tooltip: 'Рейтинг',
onPressed: widget.onRatingFilter,
icon: const Icon(Icons.tune_rounded),
),
const SizedBox(width: 4),
IconButton(
tooltip: 'Clear filters',
tooltip: 'Очистить фильтры',
onPressed: widget.onClearFilters,
icon: const Icon(Icons.filter_alt_off_rounded),
),
const SizedBox(width: 4),
IconButton(
tooltip: 'Refresh',
tooltip: 'Обновить',
onPressed: widget.onRefresh,
icon: const Icon(Icons.refresh_rounded),
),
const SizedBox(width: 4),
IconButton.filledTonal(
tooltip: 'Random post',
tooltip: 'Случайный пост',
onPressed: widget.onRandom,
icon: const Icon(Icons.casino_rounded),
),
@@ -154,7 +164,7 @@ class _FeedToolbarState extends State<FeedToolbar> {
if (widget.providers.isNotEmpty) ...[
FilterChip(
selected: widget.selectedProviderIds.isEmpty,
label: const Text('All providers'),
label: const Text('Все источники'),
onSelected: (_) => widget.onQuickProviderToggle('__all__'),
),
const SizedBox(width: 8),
@@ -162,7 +172,7 @@ class _FeedToolbarState extends State<FeedToolbar> {
FilterChip(
selected:
widget.selectedProviderIds.contains(provider.id),
avatar: const Icon(Icons.hub_rounded, size: 16),
avatar: const Icon(Icons.hub_rounded, size: 15),
label: Text(provider.name),
onSelected: (_) =>
widget.onQuickProviderToggle(provider.id),
@@ -188,25 +198,62 @@ class _FeedToolbarState extends State<FeedToolbar> {
}
Widget _buildMobile(BuildContext context) {
final hasActiveFilters = widget.selectedProviderIds.isNotEmpty || widget.rating != null;
return Padding(
padding: const EdgeInsets.fromLTRB(8, 6, 8, 6),
padding: const EdgeInsets.fromLTRB(10, 8, 10, 6),
child: Column(
children: [
TagInputSearchBar(
initialValue: _query,
suggestions: widget.tagSuggestions,
onChanged: (value) {
_query = value;
widget.onSearchChanged(value);
},
onSubmitted: widget.onSearch,
onSuggestionApplied: (value) {
_query = value;
widget.onSuggestionTap(value);
},
Row(
children: [
Expanded(
child: TagInputSearchBar(
initialValue: _query,
suggestions: widget.tagSuggestions,
onChanged: (value) {
_query = value;
widget.onSearchChanged(value);
},
onSubmitted: widget.onSearch,
onSuggestionApplied: (value) {
_query = value;
widget.onSuggestionTap(value);
},
),
),
const SizedBox(width: 6),
Badge(
isLabelVisible: hasActiveFilters,
child: IconButton.filledTonal(
visualDensity: VisualDensity.compact,
tooltip: 'Фильтры',
onPressed: widget.onProviderFilter,
icon: const Icon(Icons.tune_rounded, size: 20),
),
),
const SizedBox(width: 4),
IconButton.filledTonal(
visualDensity: VisualDensity.compact,
tooltip: 'Случайный пост',
onPressed: widget.onRandom,
icon: const Icon(Icons.casino_rounded, size: 20),
),
const SizedBox(width: 4),
IconButton.filledTonal(
visualDensity: VisualDensity.compact,
tooltip: widget.selectionMode ? 'Выйти из выбора' : 'Выбрать посты',
onPressed: widget.onToggleSelectionMode,
icon: Icon(
widget.selectionMode
? Icons.check_box_rounded
: Icons.check_box_outline_blank_rounded,
size: 20,
),
),
],
),
const SizedBox(height: 8),
if (widget.providerStatusMessage != null) ...[
const SizedBox(height: 6),
Align(
alignment: Alignment.centerLeft,
child: InputChip(
@@ -214,55 +261,50 @@ class _FeedToolbarState extends State<FeedToolbar> {
label: Text(widget.providerStatusMessage!),
),
),
const SizedBox(height: 6),
],
const SizedBox(height: 7),
_HorizontalWheelScroller(
height: 40,
height: 38,
builder: (controller) => ListView(
controller: controller,
scrollDirection: Axis.horizontal,
primary: false,
children: [
IconButton.filledTonal(
tooltip:
widget.selectionMode ? 'Exit select' : 'Select posts',
onPressed: widget.onToggleSelectionMode,
icon: Icon(widget.selectionMode
? Icons.check_box_rounded
: Icons.check_box_outline_blank_rounded),
),
const SizedBox(width: 6),
IconButton.filledTonal(
tooltip: 'Rating',
onPressed: widget.onRatingFilter,
icon: const Icon(Icons.tune_rounded),
),
const SizedBox(width: 6),
IconButton(
tooltip: 'Clear filters',
onPressed: widget.onClearFilters,
icon: const Icon(Icons.filter_alt_off_rounded),
),
IconButton(
tooltip: 'Refresh',
if (hasActiveFilters || widget.selectedTags.isNotEmpty) ...[
ActionChip(
visualDensity: VisualDensity.compact,
avatar: const Icon(Icons.filter_alt_off_rounded, size: 15),
label: const Text('Сбросить'),
onPressed: widget.onClearFilters,
),
const SizedBox(width: 6),
],
ActionChip(
visualDensity: VisualDensity.compact,
avatar: const Icon(Icons.refresh_rounded, size: 15),
label: const Text('Обновить'),
onPressed: widget.onRefresh,
icon: const Icon(Icons.refresh_rounded),
),
IconButton.filledTonal(
tooltip: 'Random post',
onPressed: widget.onRandom,
icon: const Icon(Icons.casino_rounded),
const SizedBox(width: 6),
FilterChip(
visualDensity: VisualDensity.compact,
selected: widget.rating != null,
avatar: const Icon(Icons.shield_outlined, size: 15),
label: Text(widget.rating ?? 'Рейтинг'),
onSelected: (_) => widget.onRatingFilter(),
),
const SizedBox(width: 8),
const SizedBox(width: 6),
if (widget.providers.isNotEmpty) ...[
FilterChip(
visualDensity: VisualDensity.compact,
selected: widget.selectedProviderIds.isEmpty,
label: const Text('All'),
label: const Text('Все'),
onSelected: (_) => widget.onQuickProviderToggle('__all__'),
),
const SizedBox(width: 6),
for (final provider in widget.providers) ...[
FilterChip(
visualDensity: VisualDensity.compact,
selected:
widget.selectedProviderIds.contains(provider.id),
label: Text(provider.name),
@@ -272,19 +314,9 @@ class _FeedToolbarState extends State<FeedToolbar> {
const SizedBox(width: 6),
],
],
],
),
),
const SizedBox(height: 6),
_HorizontalWheelScroller(
height: 36,
builder: (controller) => ListView(
controller: controller,
scrollDirection: Axis.horizontal,
primary: false,
children: [
for (final period in TopPeriodFilter.values) ...[
ChoiceChip(
visualDensity: VisualDensity.compact,
selected: widget.topPeriodFilter == period,
label: Text(period.label),
onSelected: (_) => widget.onTopPeriodChanged(period),
File diff suppressed because it is too large Load Diff
@@ -21,11 +21,13 @@ class SearchController extends AsyncNotifier<SearchState> {
}
Future<void> updateQuery(String query) async {
final lastToken =
final rawLast =
query.trim().isEmpty ? '' : query.trim().split(RegExp(r'\s+')).last;
final cleanToken = SearchService.sanitizeToken(rawLast);
final requestId = ++_suggestionRequestId;
_suggestionDebounce?.cancel();
if (lastToken.isEmpty) {
if (cleanToken.isEmpty || cleanToken == 'and') {
state = AsyncData(
(state.value ?? const SearchState()).copyWith(
query: query,
@@ -34,24 +36,35 @@ class SearchController extends AsyncNotifier<SearchState> {
);
return;
}
// Instant local suggestions from disk cache & history (0 ms)
final localMatches = ref
.read(searchServiceProvider)
.instantSuggestions(cleanToken, limit: 16);
state = AsyncData(
(state.value ?? const SearchState()).copyWith(
query: query,
suggestions: localMatches,
),
);
_suggestionDebounce = Timer(const Duration(milliseconds: 280), () async {
_suggestionDebounce = Timer(const Duration(milliseconds: 160), () async {
final settingsRes = await ref.read(settingsServiceProvider).getSettings();
final appSettings = settingsRes is Success<AppSettings>
? settingsRes.data
: AppSettings.defaults;
final suggestionsResult = await ref
.read(searchServiceProvider)
.autocompleteDetailed(lastToken, limit: 16);
.autocompleteDetailed(
cleanToken,
limit: 16,
tagCacheLimit: appSettings.tagCacheLimit,
);
if (requestId != _suggestionRequestId) return;
final normalizedToken = lastToken.trim().toLowerCase();
final suggestions = suggestionsResult is Success<List<TagSuggestion>>
? suggestionsResult.data
.where((item) => item.name.toLowerCase().startsWith(
normalizedToken,
))
.toList(growable: false)
: <TagSuggestion>[];
: localMatches;
state = AsyncData(
(state.value ?? const SearchState()).copyWith(
query: query,
@@ -3,8 +3,11 @@ import 'package:flutter/material.dart';
import '../../../../backend/backend.dart';
class SearchSuggestions extends StatelessWidget {
const SearchSuggestions(
{required this.suggestions, required this.onTap, super.key});
const SearchSuggestions({
required this.suggestions,
required this.onTap,
super.key,
});
final List<TagSuggestion> suggestions;
final ValueChanged<String> onTap;
@@ -12,16 +15,43 @@ class SearchSuggestions extends StatelessWidget {
@override
Widget build(BuildContext context) {
if (suggestions.isEmpty) return const SizedBox.shrink();
final scheme = Theme.of(context).colorScheme;
return Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final suggestion in suggestions)
ActionChip(
label: Text('${suggestion.name} · ${suggestion.categoryLabel}'),
avatar: Icon(
Icons.tag_rounded,
size: 16,
color: _categoryColor(suggestion.category, scheme),
),
label: Text(
suggestion.postCount > 0
? '${suggestion.name} (${_compactCount(suggestion.postCount)})'
: suggestion.name,
),
onPressed: () => onTap(suggestion.name),
),
],
);
}
Color _categoryColor(TagCategory category, ColorScheme scheme) {
return switch (category) {
TagCategory.artist => const Color(0xFFFF5252),
TagCategory.character => const Color(0xFF66BB6A),
TagCategory.copyright => const Color(0xFFBA68C8),
TagCategory.species => const Color(0xFF4FC3F7),
TagCategory.meta => const Color(0xFF90A4AE),
TagCategory.general || TagCategory.unknown => scheme.primary,
};
}
String _compactCount(int value) {
if (value >= 1000000) return '${(value / 1000000).toStringAsFixed(1)}M';
if (value >= 1000) return '${(value / 1000).toStringAsFixed(1)}K';
return '$value';
}
}
@@ -16,6 +16,8 @@ class CacheStats {
required this.imageMemoryCount,
required this.tempDirectoryBytes,
required this.downloadedCount,
this.tagCacheCount = 0,
this.tagCacheBytes = 0,
});
final int cachedPostsCount;
@@ -23,9 +25,14 @@ class CacheStats {
final int imageMemoryCount;
final int tempDirectoryBytes;
final int downloadedCount;
final int tagCacheCount;
final int tagCacheBytes;
int get totalEstimatedBytes =>
imageMemoryBytes + tempDirectoryBytes + (cachedPostsCount * 2048);
imageMemoryBytes +
tempDirectoryBytes +
tagCacheBytes +
(cachedPostsCount * 2048);
static const empty = CacheStats(
cachedPostsCount: 0,
@@ -33,6 +40,8 @@ class CacheStats {
imageMemoryCount: 0,
tempDirectoryBytes: 0,
downloadedCount: 0,
tagCacheCount: 0,
tagCacheBytes: 0,
);
}
@@ -64,14 +73,21 @@ final cacheStatsProvider = FutureProvider.autoDispose<CacheStats>((ref) async {
}
} catch (_) {}
return CacheStats(
cachedPostsCount: postsCount,
imageMemoryBytes: memBytes,
imageMemoryCount: memCount,
tempDirectoryBytes: tempBytes,
downloadedCount: downloadedCount,
);
});
final tagCache = ref.watch(tagCacheServiceProvider);
if (!tagCache.isInitialized) await tagCache.init();
final tagCount = tagCache.tagCount;
final tagBytes = await tagCache.getFileSizeBytes();
return CacheStats(
cachedPostsCount: postsCount,
imageMemoryBytes: memBytes,
imageMemoryCount: memCount,
tempDirectoryBytes: tempBytes,
downloadedCount: downloadedCount,
tagCacheCount: tagCount,
tagCacheBytes: tagBytes,
);
});
class CacheManagerScreen extends ConsumerStatefulWidget {
const CacheManagerScreen({super.key});
@@ -127,9 +143,17 @@ class _CacheManagerScreenState extends ConsumerState<CacheManagerScreen> {
if (mounted) setState(() => _isClearing = false);
}
Future<void> _clearTagCache() async {
setState(() => _isClearing = true);
await ref.read(tagCacheServiceProvider).clear();
ref.invalidate(cacheStatsProvider);
if (mounted) setState(() => _isClearing = false);
}
Future<void> _clearAllCache() async {
setState(() => _isClearing = true);
await ref.read(cacheServiceProvider).clear();
await ref.read(tagCacheServiceProvider).clear();
PaintingBinding.instance.imageCache.clear();
PaintingBinding.instance.imageCache.clearLiveImages();
try {
@@ -244,6 +268,17 @@ class _CacheManagerScreenState extends ConsumerState<CacheManagerScreen> {
),
const SizedBox(height: 8),
_CacheCategoryTile(
icon: Icons.label_rounded,
title: strings.ru ? 'Кэш подсказок тегов' : 'Tag suggestions cache',
subtitle: strings.ru
? '${stats.tagCacheCount} закэшированных тегов на диске'
: '${stats.tagCacheCount} cached tags on disk',
sizeText: _formatBytes(stats.tagCacheBytes),
onClear: _isClearing ? null : _clearTagCache,
),
const SizedBox(height: 8),
_CacheCategoryTile(
icon: Icons.download_done_rounded,
title: strings.ru ? 'Скачанные медиа' : 'Downloaded media',
@@ -38,6 +38,14 @@ class SettingsController extends AsyncNotifier<AppSettings> {
ref.invalidateSelf();
}
Future<void> clearSearchHistory() async {
await ref.read(searchServiceProvider).clearHistory();
}
Future<void> clearTagCache() async {
await ref.read(searchServiceProvider).clearTagCache();
}
Future<String> diagnosticsReport() async {
final result =
await ref.read(settingsServiceProvider).buildDiagnosticsReport(
@@ -392,6 +392,28 @@ class _SettingsContent extends ConsumerWidget {
onChanged: (value) =>
_update(ref, settings.copyWith(cacheMaxItems: value)),
),
_StepperTile(
title: settings.languageCode == 'ru'
? 'Лимит истории поиска'
: 'Search history limit',
value: settings.searchHistoryLimit,
min: 50,
max: 2000,
step: 50,
onChanged: (value) =>
_update(ref, settings.copyWith(searchHistoryLimit: value)),
),
_StepperTile(
title: settings.languageCode == 'ru'
? 'Лимит кэша тегов (диск)'
: 'Tag cache limit (disk)',
value: settings.tagCacheLimit,
min: 500,
max: 20000,
step: 500,
onChanged: (value) =>
_update(ref, settings.copyWith(tagCacheLimit: value)),
),
_ActionGrid(
children: [
FilledButton.tonalIcon(
@@ -408,11 +430,59 @@ class _SettingsContent extends ConsumerWidget {
? 'Менеджер кэша'
: 'Cache Manager'),
),
FilledButton.tonalIcon(
onPressed: () async {
await ref
.read(settingsControllerProvider.notifier)
.clearTagCache();
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
settings.languageCode == 'ru'
? 'Кэш подсказок тегов очищен'
: 'Tag suggestions cache cleared',
),
),
);
}
},
icon: const Icon(Icons.label_off_rounded),
label: Text(
settings.languageCode == 'ru'
? 'Очистить кэш тегов'
: 'Clear tag cache',
),
),
FilledButton.tonalIcon(
onPressed: () async {
await ref
.read(settingsControllerProvider.notifier)
.clearSearchHistory();
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
settings.languageCode == 'ru'
? 'История поиска очищена'
: 'Search history cleared',
),
),
);
}
},
icon: const Icon(Icons.history_toggle_off_rounded),
label: Text(
settings.languageCode == 'ru'
? 'Очистить историю'
: 'Clear search history',
),
),
FilledButton.tonalIcon(
onPressed: () => ref
.read(settingsControllerProvider.notifier)
.clearViewedHistory(),
icon: const Icon(Icons.history_toggle_off_rounded),
icon: const Icon(Icons.visibility_off_rounded),
label: Text(strings.clearViewed),
),
FilledButton.tonalIcon(
@@ -426,7 +496,7 @@ class _SettingsContent extends ConsumerWidget {
onPressed: () => ref
.read(settingsControllerProvider.notifier)
.clearHiddenPosts(),
icon: const Icon(Icons.visibility_off_rounded),
icon: const Icon(Icons.delete_sweep_rounded),
label:
Text('Clear hidden (${settings.hiddenPostKeys.length})'),
),
+170 -27
View File
@@ -121,15 +121,16 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
decoration: BoxDecoration(
color: Theme.of(context).inputDecorationTheme.fillColor ??
scheme.surfaceContainerHighest.withValues(alpha: 0.38),
borderRadius: BorderRadius.circular(12),
borderRadius: BorderRadius.circular(22),
border: Border.all(
color: _focusNode.hasFocus
? scheme.primary.withValues(alpha: 0.45)
: Colors.transparent,
? scheme.primary.withValues(alpha: 0.6)
: Colors.white.withValues(alpha: 0.08),
width: 1.0,
),
),
child: InkWell(
borderRadius: BorderRadius.circular(12),
borderRadius: BorderRadius.circular(22),
onTap: _focusNode.requestFocus,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 7),
@@ -228,6 +229,7 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
width: size.width,
yOffset: size.height + 8,
suggestions: suggestions,
query: token,
onSelected: _applySuggestion,
),
);
@@ -241,18 +243,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 +323,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 +378,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 +401,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 +415,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 +431,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 +444,7 @@ class _TagSuggestionOverlay extends StatelessWidget {
required this.width,
required this.yOffset,
required this.suggestions,
required this.query,
required this.onSelected,
});
@@ -420,6 +452,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 +468,7 @@ class _TagSuggestionOverlay extends StatelessWidget {
width: width.clamp(280, 620).toDouble(),
child: _TagSuggestionDropdown(
suggestions: suggestions,
query: query,
onSelected: onSelected,
),
),
@@ -447,10 +481,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 +518,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 +556,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 +630,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 +653,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> {
+249 -153
View File
@@ -177,114 +177,159 @@ class _PostCardState extends ConsumerState<PostCard>
],
);
},
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Stack(
children: [
AspectRatio(
aspectRatio: aspect.clamp(0.28, 2.2),
child: BlurContent(
enabled: widget.blurExplicit && sensitive,
child: LayoutBuilder(
builder: (context, constraints) {
final dpr = MediaQuery.devicePixelRatioOf(context);
final cacheWidth =
(constraints.maxWidth * dpr).round().clamp(320, 1800);
final headers = _headersFor(post);
final videoUrls = MediaUrlSelector.video(post);
if (!mobile &&
_hovered &&
_resolvedPost != null &&
MediaUrlSelector.isVideo(post) &&
videoUrls.isNotEmpty) {
return _FeedVideoPreview(
videoUrl: videoUrls.first,
headers: headers,
fallbackImageUrl: imageUrl,
cacheWidth: cacheWidth,
);
}
return CachedNetworkImage(
imageUrl: imageUrl,
httpHeaders: headers,
memCacheWidth: cacheWidth,
maxWidthDiskCache: cacheWidth,
fit: BoxFit.cover,
placeholder: (context, url) => const LoadingSkeleton(),
errorWidget: (context, url, error) => ColoredBox(
color: Theme.of(context)
.colorScheme
.surfaceContainerHighest,
child: const Center(
child: Icon(Icons.broken_image_rounded),
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: Colors.white.withValues(alpha: 0.09),
width: 0.8,
),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.18),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(13.2),
child: Stack(
children: [
AspectRatio(
aspectRatio: aspect.clamp(0.28, 2.2),
child: BlurContent(
enabled: widget.blurExplicit && sensitive,
child: LayoutBuilder(
builder: (context, constraints) {
final dpr = MediaQuery.devicePixelRatioOf(context);
final cacheWidth =
(constraints.maxWidth * dpr).round().clamp(320, 1800);
final headers = _headersFor(post);
final videoUrls = MediaUrlSelector.video(post);
if (!mobile &&
_hovered &&
_resolvedPost != null &&
MediaUrlSelector.isVideo(post) &&
videoUrls.isNotEmpty) {
return _FeedVideoPreview(
videoUrl: videoUrls.first,
headers: headers,
fallbackImageUrl: imageUrl,
cacheWidth: cacheWidth,
);
}
return CachedNetworkImage(
imageUrl: imageUrl,
httpHeaders: headers,
memCacheWidth: cacheWidth,
maxWidthDiskCache: cacheWidth,
fit: BoxFit.cover,
placeholder: (context, url) => const LoadingSkeleton(),
errorWidget: (context, url, error) => ColoredBox(
color: Theme.of(context)
.colorScheme
.surfaceContainerHighest,
child: const Center(
child: Icon(Icons.broken_image_rounded),
),
),
),
);
},
);
},
),
),
),
),
if (widget.showBadges) ...[
// Bottom cinematic gradient overlay for badges and favorite
Positioned(
left: 8,
top: 8,
child: _ProviderBadge(name: post.providerName),
left: 0,
right: 0,
bottom: 0,
height: 48,
child: IgnorePointer(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.bottomCenter,
end: Alignment.topCenter,
colors: [
Colors.black.withValues(alpha: 0.65),
Colors.transparent,
],
),
),
),
),
),
Positioned(
right: 8,
top: 8,
child: RatingBadge(rating: post.rating),
),
Positioned(
left: 8,
bottom: 8,
child: _MediaBadge(fileType: post.fileType),
),
if (widget.isViewed)
const Positioned(
if (widget.showBadges) ...[
Positioned(
left: 8,
top: 8,
child: _ProviderBadge(name: post.providerName),
),
Positioned(
right: 8,
top: 8,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (post.fileType.toLowerCase().contains('video') ||
post.fileType.toLowerCase().contains('webm') ||
post.fileType.toLowerCase().contains('mp4') ||
post.fileType.toLowerCase().contains('gif')) ...[
_MediaBadge(fileType: post.fileType),
const SizedBox(width: 4),
],
RatingBadge(rating: post.rating),
],
),
),
Positioned(
left: 8,
bottom: 8,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (widget.isViewed) ...[
const _SeenBadge(),
const SizedBox(width: 4),
],
if (widget.isDownloaded) const _DownloadedBadge(),
],
),
),
],
if (widget.selected)
Positioned.fill(
child: DecoratedBox(
decoration: BoxDecoration(
color: Theme.of(context)
.colorScheme
.primary
.withValues(alpha: 0.24),
border: Border.all(
color: Theme.of(context).colorScheme.primary,
width: 3,
),
borderRadius: BorderRadius.circular(13),
),
child: const Align(
alignment: Alignment.topRight,
child: Padding(
padding: EdgeInsets.all(8),
child: Icon(Icons.check_circle_rounded),
),
),
),
),
if (mobile)
Positioned(
right: 8,
bottom: 8,
child: _SeenBadge(),
),
if (widget.isDownloaded)
Positioned(
right: widget.isViewed ? 72 : 8,
bottom: 8,
child: const _DownloadedBadge(),
),
],
if (widget.selected)
Positioned.fill(
child: DecoratedBox(
decoration: BoxDecoration(
color: Theme.of(context)
.colorScheme
.primary
.withValues(alpha: 0.24),
border: Border.all(
color: Theme.of(context).colorScheme.primary,
width: 3,
),
borderRadius: BorderRadius.circular(8),
),
child: const Align(
alignment: Alignment.topRight,
child: Padding(
padding: EdgeInsets.all(8),
child: Icon(Icons.check_circle_rounded),
),
child: _FavoriteButton(
isFavorite: widget.isFavorite,
onPressed: widget.onFavorite,
),
),
),
if (mobile)
Positioned(
right: 8,
bottom: 8,
child: _FavoriteButton(
isFavorite: widget.isFavorite,
onPressed: widget.onFavorite,
),
),
// Double-tap heart animation overlay
if (_showHeart)
Positioned.fill(
@@ -369,7 +414,8 @@ class _PostCardState extends ConsumerState<PostCard>
),
),
),
],
],
),
),
),
),
@@ -635,13 +681,37 @@ class _FavoriteButton extends StatelessWidget {
@override
Widget build(BuildContext context) {
return IconButton.filledTonal(
visualDensity: VisualDensity.compact,
tooltip: isFavorite ? 'Remove favorite' : 'Favorite',
onPressed: onPressed,
icon: Icon(
isFavorite ? Icons.favorite_rounded : Icons.favorite_border_rounded,
size: 18,
return InkResponse(
onTap: onPressed,
radius: 20,
child: AnimatedContainer(
duration: AppMotion.duration(context, 150),
width: 34,
height: 34,
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.58),
shape: BoxShape.circle,
border: Border.all(
color: isFavorite
? Colors.redAccent.withValues(alpha: 0.6)
: Colors.white.withValues(alpha: 0.16),
width: 0.9,
),
boxShadow: [
if (isFavorite)
BoxShadow(
color: Colors.redAccent.withValues(alpha: 0.35),
blurRadius: 10,
),
],
),
child: Center(
child: Icon(
isFavorite ? Icons.favorite_rounded : Icons.favorite_border_rounded,
color: isFavorite ? Colors.redAccent : Colors.white,
size: 18,
),
),
),
);
}
@@ -654,16 +724,23 @@ class _ProviderBadge extends StatelessWidget {
@override
Widget build(BuildContext context) {
return DecoratedBox(
return Container(
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 3.5),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.55),
borderRadius: BorderRadius.circular(6),
color: Colors.black.withValues(alpha: 0.62),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: Colors.white.withValues(alpha: 0.12),
width: 0.7,
),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 3),
child: Text(
name,
style: const TextStyle(fontSize: 11, color: Colors.white),
child: Text(
name,
style: const TextStyle(
fontSize: 10.5,
color: Colors.white,
fontWeight: FontWeight.w700,
letterSpacing: 0.2,
),
),
);
@@ -678,25 +755,32 @@ class _MediaBadge extends StatelessWidget {
@override
Widget build(BuildContext context) {
final type = _type(fileType);
return DecoratedBox(
return Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3.5),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.55),
borderRadius: BorderRadius.circular(6),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 3),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(_icon(type), size: 12, color: Colors.white),
const SizedBox(width: 4),
Text(
type,
style: const TextStyle(fontSize: 11, color: Colors.white),
),
],
color: Colors.black.withValues(alpha: 0.62),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: Colors.white.withValues(alpha: 0.12),
width: 0.7,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(_icon(type), size: 13, color: Colors.white),
const SizedBox(width: 3.5),
Text(
type,
style: const TextStyle(
fontSize: 10.5,
color: Colors.white,
fontWeight: FontWeight.w800,
letterSpacing: 0.3,
),
),
],
),
);
}
@@ -723,25 +807,31 @@ class _SeenBadge extends StatelessWidget {
@override
Widget build(BuildContext context) {
return DecoratedBox(
return Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3.5),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.55),
borderRadius: BorderRadius.circular(6),
),
child: const Padding(
padding: EdgeInsets.symmetric(horizontal: 7, vertical: 3),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.visibility_rounded, size: 12, color: Colors.white),
SizedBox(width: 4),
Text(
'seen',
style: TextStyle(fontSize: 11, color: Colors.white),
),
],
color: Colors.black.withValues(alpha: 0.62),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: Colors.white.withValues(alpha: 0.12),
width: 0.7,
),
),
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.visibility_rounded, size: 12, color: Colors.white70),
SizedBox(width: 3.5),
Text(
'seen',
style: TextStyle(
fontSize: 10,
color: Colors.white70,
fontWeight: FontWeight.w700,
),
),
],
),
);
}
}
@@ -751,14 +841,20 @@ class _DownloadedBadge extends StatelessWidget {
@override
Widget build(BuildContext context) {
return DecoratedBox(
return Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3.5),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.55),
borderRadius: BorderRadius.circular(6),
color: Colors.black.withValues(alpha: 0.62),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: Colors.white.withValues(alpha: 0.12),
width: 0.7,
),
),
child: const Padding(
padding: EdgeInsets.symmetric(horizontal: 7, vertical: 3),
child: Icon(Icons.download_done_rounded, size: 13, color: Colors.white),
child: const Icon(
Icons.download_done_rounded,
size: 13,
color: Colors.white,
),
);
}
+1 -1
View File
@@ -95,7 +95,7 @@ class PostMasonryGrid extends StatelessWidget {
itemBuilder: (context, index) {
if (index >= posts.length) return const LoadingSkeleton();
return ClipRRect(
borderRadius: BorderRadius.circular(8),
borderRadius: BorderRadius.circular(14),
child: SizedBox.expand(
child: FittedBox(
fit: BoxFit.cover,
+1 -1
View File
@@ -1,7 +1,7 @@
name: gel_rule_app
description: Local backend/core layer for a Flutter booru browser app.
publish_to: "none"
version: 2.0.5+27
version: 2.0.12+34
environment:
sdk: ">=3.3.0 <4.0.0"
+2 -1
View File
@@ -43,11 +43,12 @@ void main() {
expect(postPassesTagFilters(post, settings), isFalse);
});
test('requested search tags are all required', () {
test('requested search tags are all required unless separated by and', () {
final post = _post(tags: ['touhou', 'hakurei_reimu']);
expect(postMatchesRequestedTags(post, ['touhou']), isTrue);
expect(postMatchesRequestedTags(post, ['touhou', 'cirno']), isFalse);
expect(postMatchesRequestedTags(post, ['touhou', 'and', 'cirno']), isTrue);
});
test('requested search tags can match tag prefixes', () {
+76
View File
@@ -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);
});
});
}
+12 -1
View File
@@ -21,7 +21,7 @@ class FakeSearchRepository implements SearchRepository {
}
@override
Future<Result<void>> save(SearchHistory history) async {
Future<Result<void>> save(SearchHistory history, {int maxItems = 500}) async {
items.add(history);
return const Success(null);
}
@@ -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));
+149
View File
@@ -0,0 +1,149 @@
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:gel_rule_app/backend/models/tag_suggestion.dart';
import 'package:gel_rule_app/backend/services/tag_cache_service.dart';
void main() {
late Directory tempDir;
late String cacheFilePath;
late TagCacheService cacheService;
setUp(() async {
tempDir = await Directory.systemTemp.createTemp('lunaris_test_tag_cache');
cacheFilePath = '${tempDir.path}/test_tags.json';
cacheService = TagCacheService(customPath: cacheFilePath);
await cacheService.init();
});
tearDown(() async {
if (await tempDir.exists()) {
await tempDir.delete(recursive: true);
}
});
test('TagSuggestion toJson and fromJson work correctly', () {
const item = TagSuggestion(
name: 'hatsune_miku',
category: TagCategory.character,
postCount: 154200,
providerId: 'danbooru',
);
final json = item.toJson();
final restored = TagSuggestion.fromJson(json);
expect(restored.name, 'hatsune_miku');
expect(restored.category, TagCategory.character);
expect(restored.postCount, 154200);
expect(restored.providerId, 'danbooru');
});
test('findLocal returns matching tags instantaneously', () async {
await cacheService.cacheTags([
const TagSuggestion(
name: 'cat_ears',
category: TagCategory.general,
postCount: 1000,
providerId: 'gelbooru',
),
const TagSuggestion(
name: 'caterpillar',
category: TagCategory.general,
postCount: 200,
providerId: 'gelbooru',
),
const TagSuggestion(
name: 'dog_tail',
category: TagCategory.general,
postCount: 800,
providerId: 'gelbooru',
),
]);
final matches = cacheService.findLocal('cat');
expect(matches.length, 2);
expect(matches.first.name, 'cat_ears');
expect(matches[1].name, 'caterpillar');
final dogMatches = cacheService.findLocal('dog');
expect(dogMatches.length, 1);
expect(dogMatches.first.name, 'dog_tail');
});
test('findLocal sanitizes parentheses in prefix', () async {
await cacheService.cacheTags([
const TagSuggestion(
name: 'cat_ears',
category: TagCategory.general,
postCount: 1000,
providerId: 'gelbooru',
),
]);
final matches = cacheService.findLocal('(cat');
expect(matches.length, 1);
expect(matches.first.name, 'cat_ears');
});
test('findLocal respects priority tags from search history', () async {
await cacheService.cacheTags([
const TagSuggestion(
name: 'cat_girl',
category: TagCategory.general,
postCount: 5000,
providerId: 'gelbooru',
),
const TagSuggestion(
name: 'cat_costume',
category: TagCategory.general,
postCount: 50,
providerId: 'gelbooru',
),
]);
// Priority tag 'cat_costume' should appear first despite lower postCount
final matches = cacheService.findLocal(
'cat',
priorityTags: ['cat_costume'],
);
expect(matches.first.name, 'cat_costume');
expect(matches[1].name, 'cat_girl');
});
test('flush saves tags to file and reloading restores them', () async {
await cacheService.cacheTags([
const TagSuggestion(
name: 'genshin_impact',
category: TagCategory.copyright,
postCount: 300000,
providerId: 'danbooru',
),
]);
await cacheService.flush();
final reloadedService = TagCacheService(customPath: cacheFilePath);
await reloadedService.init();
final results = reloadedService.findLocal('genshin');
expect(results.length, 1);
expect(results.first.name, 'genshin_impact');
expect(results.first.category, TagCategory.copyright);
});
test('clear removes tags from memory and disk', () async {
await cacheService.cacheTags([
const TagSuggestion(
name: 'raiden_shogun',
category: TagCategory.character,
postCount: 45000,
providerId: 'danbooru',
),
]);
await cacheService.flush();
expect(File(cacheFilePath).existsSync(), isTrue);
await cacheService.clear();
expect(cacheService.tagCount, 0);
expect(File(cacheFilePath).existsSync(), isFalse);
});
}
+86
View File
@@ -0,0 +1,86 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:gel_rule_app/backend/providers/provider_manager.dart';
void main() {
group('Tag groups splitting: space groups together, and separates streams', () {
test('single tag returns one group', () {
final groups = ProviderManager.splitTagGroups(['cat']);
expect(groups, equals([['cat']]));
});
test('tags separated by space are grouped together in one query', () {
final groups = ProviderManager.splitTagGroups(['raiden', 'miku']);
expect(groups, equals([
['raiden', 'miku'],
]));
});
test('tags separated by and are split into independent query streams', () {
final groups = ProviderManager.splitTagGroups(['raiden', 'and', 'miku']);
expect(groups, equals([
['raiden'],
['miku'],
]));
});
test('cat and dog anthro applies trailing anthro tag to both branches', () {
final groups = ProviderManager.splitTagGroups(['cat', 'and', 'dog', 'anthro']);
expect(groups, equals([
['cat', 'anthro'],
['dog', 'anthro'],
]));
});
test('multiple trailing modifiers apply to all and-branches', () {
final groups = ProviderManager.splitTagGroups([
'cat',
'and',
'dog',
'and',
'fox',
'anthro',
'solo',
]);
expect(groups, equals([
['cat', 'anthro', 'solo'],
['dog', 'anthro', 'solo'],
['fox', 'anthro', 'solo'],
]));
});
test('supports optional parentheses around and branches', () {
final groups = ProviderManager.splitTagGroups(['(cat', 'and', 'dog)', 'anthro']);
expect(groups, equals([
['cat', 'anthro'],
['dog', 'anthro'],
]));
});
test('multiple multi-tag groups separated by and', () {
final groups = ProviderManager.splitTagGroups([
'genshin',
'raiden',
'and',
'vocaloid',
'miku',
]);
expect(groups, equals([
['genshin', 'raiden'],
['vocaloid', 'miku'],
]));
});
test('case-insensitive AND separator', () {
final groups = ProviderManager.splitTagGroups(['cat', 'AND', 'dog']);
expect(groups, equals([
['cat'],
['dog'],
]));
});
test('empty tags return empty group list', () {
final groups = ProviderManager.splitTagGroups([]);
expect(groups, equals([const <String>[]]));
});
});
}
@@ -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 {