Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4acd6c29e0 | ||
|
|
fb31a303ff | ||
|
|
a8b778146d | ||
|
|
69d8bfc6bf | ||
|
|
b82d87161e |
@@ -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`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
class ArtistLink {
|
||||||
|
const ArtistLink({
|
||||||
|
required this.id,
|
||||||
|
required this.service,
|
||||||
|
required this.name,
|
||||||
|
this.publicId,
|
||||||
|
this.indexed,
|
||||||
|
this.updated,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String id;
|
||||||
|
final String service;
|
||||||
|
final String name;
|
||||||
|
final String? publicId;
|
||||||
|
final DateTime? indexed;
|
||||||
|
final DateTime? updated;
|
||||||
|
|
||||||
|
factory ArtistLink.fromJson(Map<String, dynamic> json) {
|
||||||
|
return ArtistLink(
|
||||||
|
id: (json['id'] ?? '').toString(),
|
||||||
|
service: (json['service'] ?? '').toString(),
|
||||||
|
name: (json['name'] ?? '').toString(),
|
||||||
|
publicId: json['public_id']?.toString(),
|
||||||
|
indexed: json['indexed'] != null
|
||||||
|
? DateTime.tryParse(json['indexed'].toString())
|
||||||
|
: null,
|
||||||
|
updated: json['updated'] != null
|
||||||
|
? DateTime.tryParse(json['updated'].toString())
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'id': id,
|
||||||
|
'service': service,
|
||||||
|
'name': name,
|
||||||
|
if (publicId != null) 'public_id': publicId,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
class ArtistTag {
|
||||||
|
const ArtistTag({
|
||||||
|
required this.tag,
|
||||||
|
required this.postCount,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String tag;
|
||||||
|
final int postCount;
|
||||||
|
|
||||||
|
factory ArtistTag.fromJson(Map<String, dynamic> json) {
|
||||||
|
return ArtistTag(
|
||||||
|
tag: (json['tag'] ?? '').toString(),
|
||||||
|
postCount: (json['post_count'] as num?)?.toInt() ?? 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'tag': tag,
|
||||||
|
'post_count': postCount,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -4,12 +4,34 @@ class ArtistWorkQuery {
|
|||||||
required this.service,
|
required this.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,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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(':');
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -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', () {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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>[]]));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user