feat: release v2.0.6 with full Pawchive API integration (global feed, tags, links, announcements, server media filter)
This commit is contained in:
@@ -10,7 +10,7 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/Version-2.0.5-blueviolet?style=for-the-badge" alt="Version 2.0.5" />
|
||||
<img src="https://img.shields.io/badge/Version-2.0.6-blueviolet?style=for-the-badge" alt="Version 2.0.6" />
|
||||
<img src="https://img.shields.io/badge/Flutter-3.47+-02569B?style=for-the-badge&logo=flutter&logoColor=white" alt="Flutter" />
|
||||
<img src="https://img.shields.io/badge/Platform-Android%20%7C%20Linux%20%7C%20Windows-brightgreen?style=for-the-badge" alt="Platforms" />
|
||||
<img src="https://img.shields.io/badge/Gitea-RarDog%2FLunaris-orange?style=for-the-badge&logo=gitea&logoColor=white" alt="Gitea" />
|
||||
@@ -20,26 +20,29 @@
|
||||
|
||||
## ✨ Основные возможности
|
||||
|
||||
- **🌐 Общая лента Pawchive**: Недавние посты со всех платформ прямо в главной ленте (Feed) с поддержкой поиска.
|
||||
- **🏷️ Интерактивные теги автора**: Быстрая фильтрация по темам с живыми счётчиками постов (`#tag (count)`).
|
||||
- **📢 Анонсы и новости авторов**: Всплывающее окно анонсов автора с датами и текстом обращений к аудитории.
|
||||
- **🔗 Связанные аккаунты**: Удобный переход между профилями автора на разных платформах (Patreon, Fanbox, Discord и др.).
|
||||
- **🎬 Серверная фильтрация по типу медиа**: Поиск фото, видео и GIF по всей глубине архива автора через API.
|
||||
- **📱 Плавная лента в стиле Pinterest**: Masonry-сетка с переключением режима (Masonry, 1:1 Сетка, Список) в шапке ленты.
|
||||
- **⚡ Сверхбыстрые видео-превью**: Мгновенная загрузка превью без фризов и лишней нагрузки на процессор и память смартфона.
|
||||
- **↔️ Листание постов влево и вправо**: Полноэкранный пейджер с плавными свайпами между фото и видео во всех разделах (Лента, Авторы, Избранное, Коллекции, История просмотров).
|
||||
- **↔️ Листание постов влево и вправо**: Полноэкранный пейджер с плавными свайпами между фото и видео во всех разделах.
|
||||
- **❤️ Двойной тап для лайка и свайп вниз для закрытия**: Анимированное сердечко по двойному тапу и закрытие экрана просмотра свайпом вниз.
|
||||
- **📤 Прямая отправка файла (Share)**: Мгновенный экспорт оригинальных медиафайлов через системное меню «Поделиться».
|
||||
- **🔖 Сохраненные поисковые пресеты**: Быстрое сохранение комбинаций тегов и фильтров в закладки с мгновенным переключением в один клик.
|
||||
- **🔖 Сохраненные поисковые пресеты**: Быстрое сохранение комбинаций тегов и фильтров в закладки.
|
||||
- **📁 Умная структура папок скачивания**: Настраиваемые шаблоны поддиректорий (`{Artist}`, `{Provider}`, `{Service}`, `{ID}`, `{Date}`).
|
||||
- **🗑️ Менеджер кэша**: Детальная визуализация занятой памяти (база данных, ОЗУ, временные файлы) и выборочная очистка.
|
||||
- **🗑️ Менеджер кэша**: Детальная визуализация занятой памяти и выборочная очистка.
|
||||
- **📦 Бэкап и синхронизация**: Экспорт и импорт полной конфигурации в JSON-файл для переноса на другие устройства.
|
||||
- **🌑 AMOLED Pure Black тема**: Истинно черный фон (`#000000`) для экономии батареи на OLED дисплеях.
|
||||
- **🎨 Динамические цвета Material You**: Поддержка системной палитры Android из обоев устройства.
|
||||
- **⭐ Любимые авторы**: Компактная кнопка-звёздочка сверху справа для быстрого доступа ко всем любимым авторам и их свежему медиапотоку.
|
||||
- **🏷️ Мультивыбор платформ**: Быстрый выбор одной или нескольких платформ авторов (Patreon, Fanbox, Fantia, Boosty, Discord).
|
||||
- **🎬 Фильтр фото, видео и GIF у автора**: Удобные чипсы с мультивыбором типа контента и количеством найденных медиа.
|
||||
- **📜 Бесконечная подгрузка постов автора**: Умная пагинация при прокрутке вниз загружает всю историю работ без ограничений.
|
||||
- **🐾 Поддержка Pawchive**: Интеграция с `pawchive.pw` для просмотра профилей авторов, связанных аккаунтов, тегов и работ.
|
||||
- **🔍 Умный поиск и автодополнение**: Интерактивные чипсы тегов с подсказками от активных API. Быстрое исключение (`-tag`), добавление (`+tag`) или отправка в черный список по долгому тапу.
|
||||
- **🛡️ Продвинутый Blacklist / Whitelist**: Фильтрация по тегам, категориям, рейтингу контента (safe/questionable/explicit), провайдерам и минимальному score.
|
||||
- **🎬 Встроенный плеер**: Удобное воспроизведение WebM / MP4, запоминание настроек звука, fullscreen и повтор.
|
||||
- **💾 Менеджер загрузок и коллекции**: Скачивание оригиналов в высоком качестве, тематические папки-коллекции и автозагрузка избранного.
|
||||
- **⭐ Любимые авторы**: Компактная кнопка-звёздочка сверху справа для быстрого доступа ко всем любимым авторам.
|
||||
- **🏷️ Мультивыбор платформ**: Выбор нескольких платформ (Patreon, Fanbox, Fantia, Boosty, Discord).
|
||||
- **📜 Бесконечная подгрузка постов автора**: Умная пагинация при прокрутке вниз загружает всю историю работ.
|
||||
- **🔍 Умный поиск и автодополнение**: Интерактивные чипсы тегов с подсказками от активных API.
|
||||
- **🛡️ Продвинутый Blacklist / Whitelist**: Фильтрация по тегам, категориям, рейтингу контента и минимальному score.
|
||||
- **🎬 Встроенный плеер**: Удобное воспроизведение WebM / MP4, запоминание звука, fullscreen и повтор.
|
||||
- **💾 Менеджер загрузок и коллекции**: Скачивание оригиналов в высоком качестве и тематические папки-коллекции.
|
||||
- **🔄 Встроенное автообновление**: Проверка и скачивание свежих релизов напрямую из личного репозитория Gitea.
|
||||
|
||||
---
|
||||
@@ -62,9 +65,9 @@
|
||||
|
||||
🔗 **[Gitea Releases (RarDog/Lunaris)](https://gitea.rardogsynapse.online/RarDog/Lunaris/releases)**
|
||||
|
||||
- **Android**: `Lunaris-v2.0.5.apk`
|
||||
- **Linux (x64)**: `Lunaris-v2.0.5-linux-x64.tar.gz`
|
||||
- **Windows**: `LunarisSetup-v2.0.5.exe` / `LunarisPortable-v2.0.5.zip`
|
||||
- **Android**: `Lunaris-v2.0.6.apk`
|
||||
- **Linux (x64)**: `Lunaris-v2.0.6-linux-x64.tar.gz`
|
||||
- **Windows**: `LunarisSetup-v2.0.6.exe` / `LunarisPortable-v2.0.6.zip`
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
const appDisplayVersion = '2.0.5';
|
||||
const appBuildNumber = 27;
|
||||
const appDisplayVersion = '2.0.6';
|
||||
const appBuildNumber = 28;
|
||||
const appReleaseChannel = 'Stable';
|
||||
|
||||
@@ -1,4 +1,15 @@
|
||||
const lunarisChangelog = [
|
||||
LunarisChange(
|
||||
version: '2.0.6',
|
||||
title: 'Pawchive Deep Integration: Global Posts Feed, Artist Tags, Announcements, Links & Server Media Filter',
|
||||
bullets: [
|
||||
'Connected Pawchive global recent posts (/api/v1/posts) to the main Feed screen with search.',
|
||||
'Interactive artist tags bar (/tags) with live post counts (#tag (N)) and instant topic filtering.',
|
||||
'Linked accounts bar (/links) with direct navigation to author profiles across platforms.',
|
||||
'Artist announcements modal (/announcements) showing creator news, dates and status updates.',
|
||||
'Server-side media filtering (Photos, Videos, GIFs) querying the entire author archive through the API.',
|
||||
],
|
||||
),
|
||||
LunarisChange(
|
||||
version: '2.0.5',
|
||||
title: '10 QoL Super-Update: AMOLED, Dynamic Colors, Grid switch, Share, Presets, Cache & Backup',
|
||||
|
||||
@@ -4,6 +4,9 @@ export 'models/content_provider_config.dart';
|
||||
export 'models/app_update_info.dart';
|
||||
export 'models/artist_profile.dart';
|
||||
export 'models/artist_work_query.dart';
|
||||
export 'models/artist_tag.dart';
|
||||
export 'models/artist_link.dart';
|
||||
export 'models/artist_announcement.dart';
|
||||
export 'models/download_task.dart';
|
||||
export 'models/downloaded_media.dart';
|
||||
export 'models/favorite.dart';
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
class ArtistAnnouncement {
|
||||
const ArtistAnnouncement({
|
||||
required this.service,
|
||||
required this.userId,
|
||||
required this.content,
|
||||
this.hash,
|
||||
this.added,
|
||||
});
|
||||
|
||||
final String service;
|
||||
final String userId;
|
||||
final String content;
|
||||
final String? hash;
|
||||
final DateTime? added;
|
||||
|
||||
factory ArtistAnnouncement.fromJson(Map<String, dynamic> json) {
|
||||
return ArtistAnnouncement(
|
||||
service: (json['service'] ?? '').toString(),
|
||||
userId: (json['user_id'] ?? '').toString(),
|
||||
content: (json['content'] ?? '').toString(),
|
||||
hash: json['hash']?.toString(),
|
||||
added: json['added'] != null
|
||||
? DateTime.tryParse(json['added'].toString())
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'service': service,
|
||||
'user_id': userId,
|
||||
'content': content,
|
||||
if (hash != null) 'hash': hash,
|
||||
if (added != null) 'added': added?.toIso8601String(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
class ArtistLink {
|
||||
const ArtistLink({
|
||||
required this.id,
|
||||
required this.service,
|
||||
required this.name,
|
||||
this.publicId,
|
||||
this.indexed,
|
||||
this.updated,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String service;
|
||||
final String name;
|
||||
final String? publicId;
|
||||
final DateTime? indexed;
|
||||
final DateTime? updated;
|
||||
|
||||
factory ArtistLink.fromJson(Map<String, dynamic> json) {
|
||||
return ArtistLink(
|
||||
id: (json['id'] ?? '').toString(),
|
||||
service: (json['service'] ?? '').toString(),
|
||||
name: (json['name'] ?? '').toString(),
|
||||
publicId: json['public_id']?.toString(),
|
||||
indexed: json['indexed'] != null
|
||||
? DateTime.tryParse(json['indexed'].toString())
|
||||
: null,
|
||||
updated: json['updated'] != null
|
||||
? DateTime.tryParse(json['updated'].toString())
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'service': service,
|
||||
'name': name,
|
||||
if (publicId != null) 'public_id': publicId,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
class ArtistTag {
|
||||
const ArtistTag({
|
||||
required this.tag,
|
||||
required this.postCount,
|
||||
});
|
||||
|
||||
final String tag;
|
||||
final int postCount;
|
||||
|
||||
factory ArtistTag.fromJson(Map<String, dynamic> json) {
|
||||
return ArtistTag(
|
||||
tag: (json['tag'] ?? '').toString(),
|
||||
postCount: (json['post_count'] as num?)?.toInt() ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'tag': tag,
|
||||
'post_count': postCount,
|
||||
};
|
||||
}
|
||||
@@ -4,12 +4,34 @@ class ArtistWorkQuery {
|
||||
required this.service,
|
||||
required this.artistId,
|
||||
required this.artistName,
|
||||
this.queryText,
|
||||
this.tagFilter,
|
||||
});
|
||||
|
||||
final String providerId;
|
||||
final String service;
|
||||
final String artistId;
|
||||
final String artistName;
|
||||
final String? queryText;
|
||||
final String? tagFilter;
|
||||
|
||||
ArtistWorkQuery copyWith({
|
||||
String? providerId,
|
||||
String? service,
|
||||
String? artistId,
|
||||
String? artistName,
|
||||
String? queryText,
|
||||
String? tagFilter,
|
||||
}) {
|
||||
return ArtistWorkQuery(
|
||||
providerId: providerId ?? this.providerId,
|
||||
service: service ?? this.service,
|
||||
artistId: artistId ?? this.artistId,
|
||||
artistName: artistName ?? this.artistName,
|
||||
queryText: queryText ?? this.queryText,
|
||||
tagFilter: tagFilter ?? this.tagFilter,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
@@ -17,9 +39,18 @@ class ArtistWorkQuery {
|
||||
other.providerId == providerId &&
|
||||
other.service == service &&
|
||||
other.artistId == artistId &&
|
||||
other.artistName == artistName;
|
||||
other.artistName == artistName &&
|
||||
other.queryText == queryText &&
|
||||
other.tagFilter == tagFilter;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(providerId, service, artistId, artistName);
|
||||
int get hashCode => Object.hash(
|
||||
providerId,
|
||||
service,
|
||||
artistId,
|
||||
artistName,
|
||||
queryText,
|
||||
tagFilter,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import '../models/artist_announcement.dart';
|
||||
import '../models/artist_link.dart';
|
||||
import '../models/artist_profile.dart';
|
||||
import '../models/artist_tag.dart';
|
||||
import '../models/artist_work_query.dart';
|
||||
import '../models/post.dart';
|
||||
import '../models/post_comment.dart';
|
||||
import '../models/provider_health.dart';
|
||||
import '../models/tag_suggestion.dart';
|
||||
import '../models/top_period_filter.dart';
|
||||
import '../models/artist_profile.dart';
|
||||
import '../models/artist_work_query.dart';
|
||||
|
||||
abstract class ContentProvider {
|
||||
String get id;
|
||||
@@ -72,4 +75,15 @@ abstract class ArtistProvider {
|
||||
String artistId,
|
||||
String postId,
|
||||
);
|
||||
|
||||
Future<List<ArtistTag>> getArtistTags(String service, String artistId) async =>
|
||||
const [];
|
||||
|
||||
Future<List<ArtistLink>> getArtistLinks(
|
||||
String service, String artistId) async =>
|
||||
const [];
|
||||
|
||||
Future<List<ArtistAnnouncement>> getArtistAnnouncements(
|
||||
String service, String artistId) async =>
|
||||
const [];
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../../core/http/dio_client.dart';
|
||||
import '../models/artist_announcement.dart';
|
||||
import '../models/artist_link.dart';
|
||||
import '../models/artist_profile.dart';
|
||||
import '../models/artist_tag.dart';
|
||||
import '../models/artist_work_query.dart';
|
||||
import '../models/post.dart';
|
||||
import '../models/post_comment.dart';
|
||||
@@ -214,6 +217,21 @@ class KemonoProvider
|
||||
return getArtistPostComments(parts[0], parts[1], parts[2]);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<ArtistTag>> getArtistTags(
|
||||
String service, String artistId) async =>
|
||||
const [];
|
||||
|
||||
@override
|
||||
Future<List<ArtistLink>> getArtistLinks(
|
||||
String service, String artistId) async =>
|
||||
const [];
|
||||
|
||||
@override
|
||||
Future<List<ArtistAnnouncement>> getArtistAnnouncements(
|
||||
String service, String artistId) async =>
|
||||
const [];
|
||||
|
||||
String _creatorPath() => _isCoomer ? '/coomer' : '/kemono';
|
||||
|
||||
List<ArtistProfile> _artistsFromResponse(dynamic data) {
|
||||
|
||||
@@ -3,7 +3,10 @@ import 'dart:async';
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../../core/http/dio_client.dart';
|
||||
import '../models/artist_announcement.dart';
|
||||
import '../models/artist_link.dart';
|
||||
import '../models/artist_profile.dart';
|
||||
import '../models/artist_tag.dart';
|
||||
import '../models/artist_work_query.dart';
|
||||
import '../models/post.dart';
|
||||
import '../models/post_comment.dart';
|
||||
@@ -42,8 +45,35 @@ class PawchiveProvider
|
||||
String? rating,
|
||||
TopPeriodFilter topPeriod = TopPeriodFilter.none,
|
||||
}) async {
|
||||
final offset = (page - 1).clamp(0, 999999) * 50;
|
||||
final queryText = tags.join(' ').trim();
|
||||
try {
|
||||
final response = await _dio.get<dynamic>(
|
||||
'/api/v1/posts',
|
||||
queryParameters: {
|
||||
if (queryText.isNotEmpty) 'q': queryText,
|
||||
'o': offset,
|
||||
},
|
||||
);
|
||||
final posts = _postItems(response.data);
|
||||
final mapped = <Post>[];
|
||||
for (final post in posts.whereType<Map>()) {
|
||||
final json = Map<String, dynamic>.from(post);
|
||||
final service = (json['service'] ?? 'fanbox').toString();
|
||||
final userId = (json['user'] ?? '').toString();
|
||||
final dummyQuery = ArtistWorkQuery(
|
||||
providerId: id,
|
||||
service: service,
|
||||
artistId: userId,
|
||||
artistName: userId,
|
||||
);
|
||||
mapped.addAll(_postsFromArtistPost(json, query: dummyQuery));
|
||||
}
|
||||
return mapped;
|
||||
} catch (_) {
|
||||
return const [];
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Post?> getPost(String id) async {
|
||||
@@ -178,7 +208,13 @@ class PawchiveProvider
|
||||
try {
|
||||
final response = await _dio.get<dynamic>(
|
||||
path,
|
||||
queryParameters: {'o': offset},
|
||||
queryParameters: {
|
||||
'o': offset,
|
||||
if (query.tagFilter != null && query.tagFilter!.isNotEmpty)
|
||||
'tag': query.tagFilter,
|
||||
if (query.queryText != null && query.queryText!.isNotEmpty)
|
||||
'q': query.queryText,
|
||||
},
|
||||
);
|
||||
final posts = _postItems(response.data);
|
||||
final mapped = <Post>[];
|
||||
@@ -216,6 +252,72 @@ class PawchiveProvider
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<ArtistTag>> getArtistTags(String service, String artistId) async {
|
||||
try {
|
||||
final response = await _dio.get<dynamic>(
|
||||
'/api/v1/$service/user/$artistId/tags',
|
||||
options: Options(
|
||||
receiveTimeout: const Duration(seconds: 15),
|
||||
sendTimeout: const Duration(seconds: 10),
|
||||
),
|
||||
);
|
||||
final items = response.data;
|
||||
if (items is List) {
|
||||
return items
|
||||
.whereType<Map>()
|
||||
.map((item) => ArtistTag.fromJson(Map<String, dynamic>.from(item)))
|
||||
.toList(growable: false);
|
||||
}
|
||||
} catch (_) {}
|
||||
return const [];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<ArtistLink>> getArtistLinks(
|
||||
String service, String artistId) async {
|
||||
try {
|
||||
final response = await _dio.get<dynamic>(
|
||||
'/api/v1/$service/user/$artistId/links',
|
||||
options: Options(
|
||||
receiveTimeout: const Duration(seconds: 15),
|
||||
sendTimeout: const Duration(seconds: 10),
|
||||
),
|
||||
);
|
||||
final items = response.data;
|
||||
if (items is List) {
|
||||
return items
|
||||
.whereType<Map>()
|
||||
.map((item) => ArtistLink.fromJson(Map<String, dynamic>.from(item)))
|
||||
.toList(growable: false);
|
||||
}
|
||||
} catch (_) {}
|
||||
return const [];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<ArtistAnnouncement>> getArtistAnnouncements(
|
||||
String service, String artistId) async {
|
||||
try {
|
||||
final response = await _dio.get<dynamic>(
|
||||
'/api/v1/$service/user/$artistId/announcements',
|
||||
options: Options(
|
||||
receiveTimeout: const Duration(seconds: 15),
|
||||
sendTimeout: const Duration(seconds: 10),
|
||||
),
|
||||
);
|
||||
final items = response.data;
|
||||
if (items is List) {
|
||||
return items
|
||||
.whereType<Map>()
|
||||
.map((item) =>
|
||||
ArtistAnnouncement.fromJson(Map<String, dynamic>.from(item)))
|
||||
.toList(growable: false);
|
||||
}
|
||||
} catch (_) {}
|
||||
return const [];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<PostComment>> getComments(String postId) async {
|
||||
final parts = postId.split(':');
|
||||
|
||||
@@ -47,6 +47,12 @@ class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
|
||||
final _scrollController = ScrollController();
|
||||
final List<Post> _posts = [];
|
||||
final Set<String> _selectedTypes = <String>{};
|
||||
|
||||
List<ArtistTag> _tags = [];
|
||||
List<ArtistLink> _links = [];
|
||||
List<ArtistAnnouncement> _announcements = [];
|
||||
String? _selectedTag;
|
||||
|
||||
int _page = 0;
|
||||
bool _loading = false;
|
||||
bool _hasMore = true;
|
||||
@@ -60,7 +66,10 @@ class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
|
||||
_loadMore();
|
||||
}
|
||||
});
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _loadMore());
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_loadMore();
|
||||
_loadMetadata();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -69,6 +78,29 @@ class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _loadMetadata() async {
|
||||
try {
|
||||
final providers =
|
||||
await ref.read(providerManagerProvider).activeArtistProviders();
|
||||
if (providers is! Success<List<ArtistProvider>>) return;
|
||||
final provider = providers.data.firstWhere(
|
||||
(item) => (item as ContentProvider).id == widget.providerId,
|
||||
);
|
||||
|
||||
final tagsRes = await provider.getArtistTags(widget.service, widget.artistId);
|
||||
final linksRes = await provider.getArtistLinks(widget.service, widget.artistId);
|
||||
final annRes =
|
||||
await provider.getArtistAnnouncements(widget.service, widget.artistId);
|
||||
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_tags = tagsRes;
|
||||
_links = linksRes;
|
||||
_announcements = annRes;
|
||||
});
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
Future<void> _refresh() async {
|
||||
setState(() {
|
||||
_posts.clear();
|
||||
@@ -76,7 +108,7 @@ class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
|
||||
_hasMore = true;
|
||||
_error = null;
|
||||
});
|
||||
await _loadMore();
|
||||
await Future.wait([_loadMore(), _loadMetadata()]);
|
||||
}
|
||||
|
||||
Future<void> _loadMore() async {
|
||||
@@ -86,11 +118,19 @@ class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
|
||||
_error = null;
|
||||
});
|
||||
|
||||
String? queryText;
|
||||
if (_selectedTypes.length == 1) {
|
||||
if (_selectedTypes.contains('video')) queryText = 'mp4';
|
||||
if (_selectedTypes.contains('gif')) queryText = 'gif';
|
||||
}
|
||||
|
||||
final query = ArtistWorkQuery(
|
||||
providerId: widget.providerId,
|
||||
service: widget.service,
|
||||
artistId: widget.artistId,
|
||||
artistName: widget.artistName,
|
||||
queryText: queryText,
|
||||
tagFilter: _selectedTag,
|
||||
);
|
||||
|
||||
try {
|
||||
@@ -139,6 +179,33 @@ 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();
|
||||
}
|
||||
|
||||
void _onMediaTypeToggled(String type, bool selected) {
|
||||
setState(() {
|
||||
if (selected) {
|
||||
_selectedTypes.add(type);
|
||||
} else {
|
||||
_selectedTypes.remove(type);
|
||||
}
|
||||
_posts.clear();
|
||||
_page = 0;
|
||||
_hasMore = true;
|
||||
_error = null;
|
||||
});
|
||||
_loadMore();
|
||||
}
|
||||
|
||||
bool _matchesTypeFilter(Post post) {
|
||||
if (_selectedTypes.isEmpty) return true;
|
||||
final isVid = MediaUrlSelector.isVideo(post);
|
||||
@@ -151,6 +218,70 @@ class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
|
||||
return false;
|
||||
}
|
||||
|
||||
void _showAnnouncements() {
|
||||
final theme = Theme.of(context);
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
showDragHandle: true,
|
||||
builder: (ctx) => DraggableScrollableSheet(
|
||||
expand: false,
|
||||
initialChildSize: 0.6,
|
||||
maxChildSize: 0.9,
|
||||
builder: (_, scroll) => Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.campaign_rounded, color: theme.colorScheme.primary),
|
||||
const SizedBox(width: 10),
|
||||
Text(
|
||||
'Анонсы автора (${_announcements.length})',
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(),
|
||||
Expanded(
|
||||
child: ListView.separated(
|
||||
controller: scroll,
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: _announcements.length,
|
||||
separatorBuilder: (_, __) => const Divider(height: 24),
|
||||
itemBuilder: (context, index) {
|
||||
final a = _announcements[index];
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (a.added != null)
|
||||
Text(
|
||||
'${a.added!.year}-${a.added!.month.toString().padLeft(2, '0')}-${a.added!.day.toString().padLeft(2, '0')}',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: theme.colorScheme.primary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
SelectableText(
|
||||
a.content,
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMediaTypeFilters() {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final photoCount = _posts
|
||||
@@ -160,7 +291,7 @@ class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
|
||||
final gifCount = _posts.where(MediaUrlSelector.isGif).length;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 6, 16, 6),
|
||||
padding: const EdgeInsets.fromLTRB(16, 6, 16, 4),
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
@@ -168,7 +299,18 @@ class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
|
||||
FilterChip(
|
||||
selected: _selectedTypes.isEmpty,
|
||||
label: Text('Все (${_posts.length})'),
|
||||
onSelected: (_) => setState(() => _selectedTypes.clear()),
|
||||
onSelected: (_) {
|
||||
if (_selectedTypes.isNotEmpty) {
|
||||
setState(() {
|
||||
_selectedTypes.clear();
|
||||
_posts.clear();
|
||||
_page = 0;
|
||||
_hasMore = true;
|
||||
_error = null;
|
||||
});
|
||||
_loadMore();
|
||||
}
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
FilterChip(
|
||||
@@ -179,15 +321,7 @@ class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
|
||||
? scheme.onPrimaryContainer
|
||||
: scheme.onSurfaceVariant),
|
||||
label: Text('Фото ($photoCount)'),
|
||||
onSelected: (selected) {
|
||||
setState(() {
|
||||
if (selected) {
|
||||
_selectedTypes.add('photo');
|
||||
} else {
|
||||
_selectedTypes.remove('photo');
|
||||
}
|
||||
});
|
||||
},
|
||||
onSelected: (selected) => _onMediaTypeToggled('photo', selected),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
FilterChip(
|
||||
@@ -198,15 +332,7 @@ class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
|
||||
? scheme.onPrimaryContainer
|
||||
: scheme.onSurfaceVariant),
|
||||
label: Text('Видео ($videoCount)'),
|
||||
onSelected: (selected) {
|
||||
setState(() {
|
||||
if (selected) {
|
||||
_selectedTypes.add('video');
|
||||
} else {
|
||||
_selectedTypes.remove('video');
|
||||
}
|
||||
});
|
||||
},
|
||||
onSelected: (selected) => _onMediaTypeToggled('video', selected),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
FilterChip(
|
||||
@@ -217,15 +343,7 @@ class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
|
||||
? scheme.onPrimaryContainer
|
||||
: scheme.onSurfaceVariant),
|
||||
label: Text('GIF ($gifCount)'),
|
||||
onSelected: (selected) {
|
||||
setState(() {
|
||||
if (selected) {
|
||||
_selectedTypes.add('gif');
|
||||
} else {
|
||||
_selectedTypes.remove('gif');
|
||||
}
|
||||
});
|
||||
},
|
||||
onSelected: (selected) => _onMediaTypeToggled('gif', selected),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -233,6 +351,89 @@ 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),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLinksBar() {
|
||||
if (_links.isEmpty) return const SizedBox.shrink();
|
||||
final theme = Theme.of(context);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 6),
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: [
|
||||
for (final link in _links) ...[
|
||||
ActionChip(
|
||||
avatar: Icon(
|
||||
_serviceIcon(link.service),
|
||||
size: 16,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
label: Text('${_serviceLabel(link.service)}: ${link.name}'),
|
||||
onPressed: () {
|
||||
context.push(
|
||||
'/artists/${widget.providerId}/${link.service}/${link.id}?name=${Uri.encodeComponent(link.name)}',
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
IconData _serviceIcon(String service) {
|
||||
return switch (service.toLowerCase()) {
|
||||
'patreon' => Icons.loyalty_rounded,
|
||||
'fanbox' => Icons.favorite_border_rounded,
|
||||
'discord' => Icons.chat_bubble_outline_rounded,
|
||||
'boosty' => Icons.bolt_rounded,
|
||||
_ => Icons.link_rounded,
|
||||
};
|
||||
}
|
||||
|
||||
String _serviceLabel(String service) {
|
||||
return switch (service.toLowerCase()) {
|
||||
'patreon' => 'Patreon',
|
||||
'fanbox' => 'Pixiv Fanbox',
|
||||
'discord' => 'Discord',
|
||||
'boosty' => 'Boosty',
|
||||
'fantia' => 'Fantia',
|
||||
_ => service,
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final settings =
|
||||
@@ -240,13 +441,23 @@ class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
|
||||
final favoriteKeys = ref.watch(favoriteKeysProvider).value ?? <String>{};
|
||||
final viewedKeys = ref.watch(viewedKeysProvider).value ?? <String>{};
|
||||
|
||||
final displayedPosts = _posts.where(_matchesTypeFilter).toList(growable: false);
|
||||
final displayedPosts =
|
||||
_posts.where(_matchesTypeFilter).toList(growable: false);
|
||||
|
||||
return AdaptiveScaffold(
|
||||
title: widget.artistName,
|
||||
actions: [
|
||||
if (_announcements.isNotEmpty)
|
||||
IconButton(
|
||||
tooltip: 'Refresh',
|
||||
tooltip: 'Анонсы автора',
|
||||
onPressed: _showAnnouncements,
|
||||
icon: Badge.count(
|
||||
count: _announcements.length,
|
||||
child: const Icon(Icons.campaign_rounded),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Обновить',
|
||||
onPressed: _refresh,
|
||||
icon: const Icon(Icons.refresh_rounded),
|
||||
),
|
||||
@@ -259,13 +470,23 @@ class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
|
||||
onRetry: _refresh,
|
||||
)
|
||||
: _posts.isEmpty
|
||||
? const EmptyView(
|
||||
title: 'No works',
|
||||
message: 'This artist has no visible media yet.',
|
||||
? Column(
|
||||
children: [
|
||||
_buildLinksBar(),
|
||||
_buildTagsBar(),
|
||||
const Expanded(
|
||||
child: EmptyView(
|
||||
title: 'Нет работ',
|
||||
message: 'У этого автора пока нет видимых постов.',
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
: Column(
|
||||
children: [
|
||||
_buildMediaTypeFilters(),
|
||||
_buildTagsBar(),
|
||||
_buildLinksBar(),
|
||||
Expanded(
|
||||
child: displayedPosts.isEmpty
|
||||
? Center(
|
||||
@@ -274,7 +495,8 @@ class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.filter_alt_off_rounded,
|
||||
const Icon(
|
||||
Icons.filter_alt_off_rounded,
|
||||
size: 48),
|
||||
const SizedBox(height: 12),
|
||||
const Text(
|
||||
@@ -284,9 +506,17 @@ class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
FilledButton.tonal(
|
||||
onPressed: () => setState(
|
||||
() => _selectedTypes.clear()),
|
||||
child: const Text('Показать все'),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_selectedTypes.clear();
|
||||
_selectedTag = null;
|
||||
_posts.clear();
|
||||
_page = 0;
|
||||
_hasMore = true;
|
||||
});
|
||||
_loadMore();
|
||||
},
|
||||
child: const Text('Сбросить фильтры'),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -296,6 +526,7 @@ class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
|
||||
posts: displayedPosts,
|
||||
controller: _scrollController,
|
||||
loading: _loading,
|
||||
gridMode: settings.gridMode,
|
||||
columns: Responsive.columnsFor(
|
||||
context,
|
||||
mobileColumns: settings.mobileColumns,
|
||||
@@ -332,7 +563,8 @@ class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
|
||||
);
|
||||
}
|
||||
ref.invalidate(favoriteKeysProvider);
|
||||
ref.invalidate(favoritesControllerProvider);
|
||||
ref.invalidate(
|
||||
favoritesControllerProvider);
|
||||
},
|
||||
),
|
||||
),
|
||||
@@ -345,7 +577,7 @@ class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
|
||||
final message = error.toString();
|
||||
if (message.contains('HandshakeException') ||
|
||||
message.contains('artist works are unavailable')) {
|
||||
return 'Artist works are unavailable from this provider right now. Try again later or choose another artist/provider.';
|
||||
return 'Работы автора временно недоступны. Попробуйте обновить позже.';
|
||||
}
|
||||
return message;
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
name: gel_rule_app
|
||||
description: Local backend/core layer for a Flutter booru browser app.
|
||||
publish_to: "none"
|
||||
version: 2.0.5+27
|
||||
version: 2.0.6+28
|
||||
|
||||
environment:
|
||||
sdk: ">=3.3.0 <4.0.0"
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user