Compare commits

...
5 Commits
18 changed files with 830 additions and 55 deletions
+20 -16
View File
@@ -10,7 +10,7 @@
</p> </p>
<p align="center"> <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.8-blueviolet?style=for-the-badge" alt="Version 2.0.8" />
<img src="https://img.shields.io/badge/Flutter-3.47+-02569B?style=for-the-badge&logo=flutter&logoColor=white" alt="Flutter" /> <img src="https://img.shields.io/badge/Flutter-3.47+-02569B?style=for-the-badge&logo=flutter&logoColor=white" alt="Flutter" />
<img src="https://img.shields.io/badge/Platform-Android%20%7C%20Linux%20%7C%20Windows-brightgreen?style=for-the-badge" alt="Platforms" /> <img src="https://img.shields.io/badge/Platform-Android%20%7C%20Linux%20%7C%20Windows-brightgreen?style=for-the-badge" alt="Platforms" />
<img src="https://img.shields.io/badge/Gitea-RarDog%2FLunaris-orange?style=for-the-badge&logo=gitea&logoColor=white" alt="Gitea" /> <img src="https://img.shields.io/badge/Gitea-RarDog%2FLunaris-orange?style=for-the-badge&logo=gitea&logoColor=white" alt="Gitea" />
@@ -20,26 +20,30 @@
## ✨ Основные возможности ## ✨ Основные возможности
- **🎯 Общие модификаторы в мультипоиске**: При вводе `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 Сетка, Список) в шапке ленты. - **📱 Плавная лента в стиле Pinterest**: Masonry-сетка с переключением режима (Masonry, 1:1 Сетка, Список) в шапке ленты.
- **⚡ Сверхбыстрые видео-превью**: Мгновенная загрузка превью без фризов и лишней нагрузки на процессор и память смартфона. - **⚡ Сверхбыстрые видео-превью**: Мгновенная загрузка превью без фризов и лишней нагрузки на процессор и память смартфона.
- **↔️ Листание постов влево и вправо**: Полноэкранный пейджер с плавными свайпами между фото и видео во всех разделах (Лента, Авторы, Избранное, Коллекции, История просмотров). - **↔️ Листание постов влево и вправо**: Полноэкранный пейджер с плавными свайпами между фото и видео во всех разделах.
- **❤️ Двойной тап для лайка и свайп вниз для закрытия**: Анимированное сердечко по двойному тапу и закрытие экрана просмотра свайпом вниз. - **❤️ Двойной тап для лайка и свайп вниз для закрытия**: Анимированное сердечко по двойному тапу и закрытие экрана просмотра свайпом вниз.
- **📤 Прямая отправка файла (Share)**: Мгновенный экспорт оригинальных медиафайлов через системное меню «Поделиться». - **📤 Прямая отправка файла (Share)**: Мгновенный экспорт оригинальных медиафайлов через системное меню «Поделиться».
- **🔖 Сохраненные поисковые пресеты**: Быстрое сохранение комбинаций тегов и фильтров в закладки с мгновенным переключением в один клик. - **🔖 Сохраненные поисковые пресеты**: Быстрое сохранение комбинаций тегов и фильтров в закладки.
- **📁 Умная структура папок скачивания**: Настраиваемые шаблоны поддиректорий (`{Artist}`, `{Provider}`, `{Service}`, `{ID}`, `{Date}`). - **📁 Умная структура папок скачивания**: Настраиваемые шаблоны поддиректорий (`{Artist}`, `{Provider}`, `{Service}`, `{ID}`, `{Date}`).
- **🗑️ Менеджер кэша**: Детальная визуализация занятой памяти (база данных, ОЗУ, временные файлы) и выборочная очистка. - **🗑️ Менеджер кэша**: Детальная визуализация занятой памяти и выборочная очистка.
- **📦 Бэкап и синхронизация**: Экспорт и импорт полной конфигурации в JSON-файл для переноса на другие устройства. - **📦 Бэкап и синхронизация**: Экспорт и импорт полной конфигурации в JSON-файл для переноса на другие устройства.
- **🌑 AMOLED Pure Black тема**: Истинно черный фон (`#000000`) для экономии батареи на OLED дисплеях. - **🌑 AMOLED Pure Black тема**: Истинно черный фон (`#000000`) для экономии батареи на OLED дисплеях.
- **🎨 Динамические цвета Material You**: Поддержка системной палитры Android из обоев устройства. - **🎨 Динамические цвета Material You**: Поддержка системной палитры Android из обоев устройства.
- **⭐ Любимые авторы**: Компактная кнопка-звёздочка сверху справа для быстрого доступа ко всем любимым авторам и их свежему медиапотоку. - **⭐ Любимые авторы**: Компактная кнопка-звёздочка сверху справа для быстрого доступа ко всем любимым авторам.
- **🏷️ Мультивыбор платформ**: Быстрый выбор одной или нескольких платформ авторов (Patreon, Fanbox, Fantia, Boosty, Discord). - **🏷️ Мультивыбор платформ**: Выбор нескольких платформ (Patreon, Fanbox, Fantia, Boosty, Discord).
- **🎬 Фильтр фото, видео и GIF у автора**: Удобные чипсы с мультивыбором типа контента и количеством найденных медиа. - **📜 Бесконечная подгрузка постов автора**: Умная пагинация при прокрутке вниз загружает всю историю работ.
- **📜 Бесконечная подгрузка постов автора**: Умная пагинация при прокрутке вниз загружает всю историю работ без ограничений. - **🔍 Умный поиск и автодополнение**: Интерактивные чипсы тегов с подсказками от активных API.
- **🐾 Поддержка Pawchive**: Интеграция с `pawchive.pw` для просмотра профилей авторов, связанных аккаунтов, тегов и работ. - **🛡️ Продвинутый Blacklist / Whitelist**: Фильтрация по тегам, категориям, рейтингу контента и минимальному score.
- **🔍 Умный поиск и автодополнение**: Интерактивные чипсы тегов с подсказками от активных API. Быстрое исключение (`-tag`), добавление (`+tag`) или отправка в черный список по долгому тапу. - **🎬 Встроенный плеер**: Удобное воспроизведение WebM / MP4, запоминание звука, fullscreen и повтор.
- **🛡️ Продвинутый Blacklist / Whitelist**: Фильтрация по тегам, категориям, рейтингу контента (safe/questionable/explicit), провайдерам и минимальному score. - **💾 Менеджер загрузок и коллекции**: Скачивание оригиналов в высоком качестве и тематические папки-коллекции.
- **🎬 Встроенный плеер**: Удобное воспроизведение WebM / MP4, запоминание настроек звука, fullscreen и повтор.
- **💾 Менеджер загрузок и коллекции**: Скачивание оригиналов в высоком качестве, тематические папки-коллекции и автозагрузка избранного.
- **🔄 Встроенное автообновление**: Проверка и скачивание свежих релизов напрямую из личного репозитория Gitea. - **🔄 Встроенное автообновление**: Проверка и скачивание свежих релизов напрямую из личного репозитория Gitea.
--- ---
@@ -62,9 +66,9 @@
🔗 **[Gitea Releases (RarDog/Lunaris)](https://gitea.rardogsynapse.online/RarDog/Lunaris/releases)** 🔗 **[Gitea Releases (RarDog/Lunaris)](https://gitea.rardogsynapse.online/RarDog/Lunaris/releases)**
- **Android**: `Lunaris-v2.0.5.apk` - **Android**: `Lunaris-v2.0.8.apk`
- **Linux (x64)**: `Lunaris-v2.0.5-linux-x64.tar.gz` - **Linux (x64)**: `Lunaris-v2.0.8-linux-x64.tar.gz`
- **Windows**: `LunarisSetup-v2.0.5.exe` / `LunarisPortable-v2.0.5.zip` - **Windows**: `LunarisSetup-v2.0.8.exe` / `LunarisPortable-v2.0.8.zip`
--- ---
+2 -2
View File
@@ -1,3 +1,3 @@
const appDisplayVersion = '2.0.5'; const appDisplayVersion = '2.0.8';
const appBuildNumber = 27; const appBuildNumber = 30;
const appReleaseChannel = 'Stable'; const appReleaseChannel = 'Stable';
+31
View File
@@ -1,4 +1,35 @@
const lunarisChangelog = [ const lunarisChangelog = [
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( LunarisChange(
version: '2.0.5', version: '2.0.5',
title: '10 QoL Super-Update: AMOLED, Dynamic Colors, Grid switch, Share, Presets, Cache & Backup', title: '10 QoL Super-Update: AMOLED, Dynamic Colors, Grid switch, Share, Presets, Cache & Backup',
+3
View File
@@ -4,6 +4,9 @@ export 'models/content_provider_config.dart';
export 'models/app_update_info.dart'; export 'models/app_update_info.dart';
export 'models/artist_profile.dart'; export 'models/artist_profile.dart';
export 'models/artist_work_query.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/download_task.dart';
export 'models/downloaded_media.dart'; export 'models/downloaded_media.dart';
export 'models/favorite.dart'; export 'models/favorite.dart';
@@ -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.service,
required this.artistId, required this.artistId,
required this.artistName, required this.artistName,
this.queryText,
this.tagFilter,
}); });
final String providerId; final String providerId;
final String service; final String service;
final String artistId; final String artistId;
final String artistName; 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 @override
bool operator ==(Object other) { bool operator ==(Object other) {
@@ -17,9 +39,18 @@ class ArtistWorkQuery {
other.providerId == providerId && other.providerId == providerId &&
other.service == service && other.service == service &&
other.artistId == artistId && other.artistId == artistId &&
other.artistName == artistName; other.artistName == artistName &&
other.queryText == queryText &&
other.tagFilter == tagFilter;
} }
@override @override
int get hashCode => Object.hash(providerId, service, artistId, artistName); int get hashCode => Object.hash(
providerId,
service,
artistId,
artistName,
queryText,
tagFilter,
);
} }
+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.dart';
import '../models/post_comment.dart'; import '../models/post_comment.dart';
import '../models/provider_health.dart'; import '../models/provider_health.dart';
import '../models/tag_suggestion.dart'; import '../models/tag_suggestion.dart';
import '../models/top_period_filter.dart'; import '../models/top_period_filter.dart';
import '../models/artist_profile.dart';
import '../models/artist_work_query.dart';
abstract class ContentProvider { abstract class ContentProvider {
String get id; String get id;
@@ -72,4 +75,15 @@ abstract class ArtistProvider {
String artistId, String artistId,
String postId, 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 [];
} }
@@ -1,7 +1,10 @@
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import '../../core/http/dio_client.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_profile.dart';
import '../models/artist_tag.dart';
import '../models/artist_work_query.dart'; import '../models/artist_work_query.dart';
import '../models/post.dart'; import '../models/post.dart';
import '../models/post_comment.dart'; import '../models/post_comment.dart';
@@ -214,6 +217,21 @@ class KemonoProvider
return getArtistPostComments(parts[0], parts[1], parts[2]); 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'; String _creatorPath() => _isCoomer ? '/coomer' : '/kemono';
List<ArtistProfile> _artistsFromResponse(dynamic data) { List<ArtistProfile> _artistsFromResponse(dynamic data) {
+104 -2
View File
@@ -3,7 +3,10 @@ import 'dart:async';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import '../../core/http/dio_client.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_profile.dart';
import '../models/artist_tag.dart';
import '../models/artist_work_query.dart'; import '../models/artist_work_query.dart';
import '../models/post.dart'; import '../models/post.dart';
import '../models/post_comment.dart'; import '../models/post_comment.dart';
@@ -42,7 +45,34 @@ class PawchiveProvider
String? rating, String? rating,
TopPeriodFilter topPeriod = TopPeriodFilter.none, TopPeriodFilter topPeriod = TopPeriodFilter.none,
}) async { }) 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 @override
@@ -178,7 +208,13 @@ class PawchiveProvider
try { try {
final response = await _dio.get<dynamic>( final response = await _dio.get<dynamic>(
path, 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 posts = _postItems(response.data);
final mapped = <Post>[]; 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 @override
Future<List<PostComment>> getComments(String postId) async { Future<List<PostComment>> getComments(String postId) async {
final parts = postId.split(':'); final parts = postId.split(':');
+144 -2
View File
@@ -163,6 +163,68 @@ class ProviderManager {
return Success(results); 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({ Future<Result<List<Post>>> searchAcrossProviders({
required List<String> tags, required List<String> tags,
required int page, required int page,
@@ -181,12 +243,68 @@ class ProviderManager {
providers.where((provider) => provider.id == providerId).toList(); 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 seen = <String>{};
final providerResults = <List<Post>>[]; final providerResults = <List<Post>>[];
for (final provider in providers) { for (final provider in providers) {
try { try {
final providerPosts = await provider.searchPosts( final providerPosts = await provider.searchPosts(
tags: tags, tags: cleanTags,
page: page, page: page,
limit: limit, limit: limit,
rating: rating, rating: rating,
@@ -234,6 +352,29 @@ class ProviderManager {
return Success(posts); 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<Post> _flattenProviderResults(
List<List<Post>> results, Set<String> seen) { List<List<Post>> results, Set<String> seen) {
final posts = <Post>[]; final posts = <Post>[];
@@ -584,7 +725,8 @@ class ProviderManager {
} }
static bool _isFeedConfig(ContentProviderConfig config) { static bool _isFeedConfig(ContentProviderConfig config) {
return !_isArtistConfig(config); final type = config.apiType.toLowerCase();
return type != 'kemono' && type != 'coomer';
} }
static bool _isLegacyRemovedConfig(ContentProviderConfig config) { static bool _isLegacyRemovedConfig(ContentProviderConfig config) {
+22 -13
View File
@@ -112,20 +112,29 @@ class FeedService {
} }
bool postMatchesRequestedTags(Post post, List<String> requestedTags) { bool postMatchesRequestedTags(Post post, List<String> requestedTags) {
final requested = requestedTags if (post.providerId == 'pawchive') return true;
.map((tag) => tag.trim().toLowerCase())
.where((tag) => tag.isNotEmpty && !tag.startsWith('-')) final groups = ProviderManager.splitTagGroups(requestedTags);
.toList(growable: false); if (groups.isEmpty) return true;
if (requested.isEmpty) return true;
final postTags = _postTagSet(post); final postTags = _postTagSet(post);
return requested.every( if (postTags.isEmpty) return true;
(requestedTag) => postTags.any(
(postTag) => return groups.any((group) {
postTag == requestedTag || final cleanGroup = group
postTag.startsWith('${requestedTag}_') || .map((tag) => tag.trim().toLowerCase())
postTag.startsWith('$requestedTag-'), .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) { bool postPassesTagFilters(Post post, AppSettings settings) {
@@ -47,6 +47,11 @@ class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
final _scrollController = ScrollController(); final _scrollController = ScrollController();
final List<Post> _posts = []; final List<Post> _posts = [];
final Set<String> _selectedTypes = <String>{}; final Set<String> _selectedTypes = <String>{};
List<ArtistTag> _tags = [];
List<ArtistAnnouncement> _announcements = [];
String? _selectedTag;
int _page = 0; int _page = 0;
bool _loading = false; bool _loading = false;
bool _hasMore = true; bool _hasMore = true;
@@ -60,7 +65,10 @@ class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
_loadMore(); _loadMore();
} }
}); });
WidgetsBinding.instance.addPostFrameCallback((_) => _loadMore()); WidgetsBinding.instance.addPostFrameCallback((_) {
_loadMore();
_loadMetadata();
});
} }
@override @override
@@ -69,6 +77,28 @@ class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
super.dispose(); 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 { Future<void> _refresh() async {
setState(() { setState(() {
_posts.clear(); _posts.clear();
@@ -76,7 +106,7 @@ class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
_hasMore = true; _hasMore = true;
_error = null; _error = null;
}); });
await _loadMore(); await Future.wait([_loadMore(), _loadMetadata()]);
} }
Future<void> _loadMore() async { Future<void> _loadMore() async {
@@ -91,6 +121,7 @@ class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
service: widget.service, service: widget.service,
artistId: widget.artistId, artistId: widget.artistId,
artistName: widget.artistName, artistName: widget.artistName,
tagFilter: _selectedTag,
); );
try { 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) { bool _matchesTypeFilter(Post post) {
if (_selectedTypes.isEmpty) return true; if (_selectedTypes.isEmpty) return true;
final isVid = MediaUrlSelector.isVideo(post); final isVid = MediaUrlSelector.isVideo(post);
@@ -151,6 +194,70 @@ class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
return false; 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() { Widget _buildMediaTypeFilters() {
final scheme = Theme.of(context).colorScheme; final scheme = Theme.of(context).colorScheme;
final photoCount = _posts final photoCount = _posts
@@ -160,7 +267,7 @@ class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
final gifCount = _posts.where(MediaUrlSelector.isGif).length; final gifCount = _posts.where(MediaUrlSelector.isGif).length;
return Padding( return Padding(
padding: const EdgeInsets.fromLTRB(16, 6, 16, 6), padding: const EdgeInsets.fromLTRB(16, 6, 16, 4),
child: SingleChildScrollView( child: SingleChildScrollView(
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
child: Row( 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final settings = final settings =
@@ -240,13 +378,23 @@ class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
final favoriteKeys = ref.watch(favoriteKeysProvider).value ?? <String>{}; final favoriteKeys = ref.watch(favoriteKeysProvider).value ?? <String>{};
final viewedKeys = ref.watch(viewedKeysProvider).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( return AdaptiveScaffold(
title: widget.artistName, title: widget.artistName,
actions: [ actions: [
if (_announcements.isNotEmpty)
IconButton(
tooltip: 'Анонсы автора',
onPressed: _showAnnouncements,
icon: Badge.count(
count: _announcements.length,
child: const Icon(Icons.campaign_rounded),
),
),
IconButton( IconButton(
tooltip: 'Refresh', tooltip: 'Обновить',
onPressed: _refresh, onPressed: _refresh,
icon: const Icon(Icons.refresh_rounded), icon: const Icon(Icons.refresh_rounded),
), ),
@@ -259,13 +407,21 @@ class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
onRetry: _refresh, onRetry: _refresh,
) )
: _posts.isEmpty : _posts.isEmpty
? const EmptyView( ? Column(
title: 'No works', children: [
message: 'This artist has no visible media yet.', _buildTagsBar(),
const Expanded(
child: EmptyView(
title: 'Нет работ',
message: 'У этого автора пока нет видимых постов.',
),
),
],
) )
: Column( : Column(
children: [ children: [
_buildMediaTypeFilters(), _buildMediaTypeFilters(),
_buildTagsBar(),
Expanded( Expanded(
child: displayedPosts.isEmpty child: displayedPosts.isEmpty
? Center( ? Center(
@@ -274,7 +430,8 @@ class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
const Icon(Icons.filter_alt_off_rounded, const Icon(
Icons.filter_alt_off_rounded,
size: 48), size: 48),
const SizedBox(height: 12), const SizedBox(height: 12),
const Text( const Text(
@@ -284,9 +441,13 @@ class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
FilledButton.tonal( FilledButton.tonal(
onPressed: () => setState( onPressed: () {
() => _selectedTypes.clear()), setState(() {
child: const Text('Показать все'), _selectedTypes.clear();
_selectedTag = null;
});
},
child: const Text('Сбросить фильтры'),
), ),
], ],
), ),
@@ -296,6 +457,7 @@ class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
posts: displayedPosts, posts: displayedPosts,
controller: _scrollController, controller: _scrollController,
loading: _loading, loading: _loading,
gridMode: settings.gridMode,
columns: Responsive.columnsFor( columns: Responsive.columnsFor(
context, context,
mobileColumns: settings.mobileColumns, mobileColumns: settings.mobileColumns,
@@ -332,7 +494,8 @@ class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
); );
} }
ref.invalidate(favoriteKeysProvider); ref.invalidate(favoriteKeysProvider);
ref.invalidate(favoritesControllerProvider); ref.invalidate(
favoritesControllerProvider);
}, },
), ),
), ),
@@ -345,7 +508,7 @@ class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
final message = error.toString(); final message = error.toString();
if (message.contains('HandshakeException') || if (message.contains('HandshakeException') ||
message.contains('artist works are unavailable')) { 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; return message;
} }
+1 -1
View File
@@ -1,7 +1,7 @@
name: gel_rule_app name: gel_rule_app
description: Local backend/core layer for a Flutter booru browser app. description: Local backend/core layer for a Flutter booru browser app.
publish_to: "none" publish_to: "none"
version: 2.0.5+27 version: 2.0.8+30
environment: environment:
sdk: ">=3.3.0 <4.0.0" sdk: ">=3.3.0 <4.0.0"
+2 -1
View File
@@ -43,11 +43,12 @@ void main() {
expect(postPassesTagFilters(post, settings), isFalse); 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']); final post = _post(tags: ['touhou', 'hakurei_reimu']);
expect(postMatchesRequestedTags(post, ['touhou']), isTrue); expect(postMatchesRequestedTags(post, ['touhou']), isTrue);
expect(postMatchesRequestedTags(post, ['touhou', 'cirno']), isFalse); expect(postMatchesRequestedTags(post, ['touhou', 'cirno']), isFalse);
expect(postMatchesRequestedTags(post, ['touhou', 'and', 'cirno']), isTrue);
}); });
test('requested search tags can match tag prefixes', () { 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);
});
});
}
+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>[]]));
});
});
}