From b82d87161ebe502862715d6078e04d503ceefd07 Mon Sep 17 00:00:00 2001 From: RarDog Date: Thu, 3 Sep 2026 20:43:38 +0300 Subject: [PATCH] feat: release v2.0.6 with full Pawchive API integration (global feed, tags, links, announcements, server media filter) --- README.md | 35 +- lib/app/app_version.dart | 4 +- lib/app/changelog.dart | 11 + lib/backend/backend.dart | 3 + lib/backend/models/artist_announcement.dart | 35 ++ lib/backend/models/artist_link.dart | 39 +++ lib/backend/models/artist_tag.dart | 21 ++ lib/backend/models/artist_work_query.dart | 35 +- lib/backend/providers/content_provider.dart | 18 +- lib/backend/providers/kemono_provider.dart | 18 + lib/backend/providers/pawchive_provider.dart | 106 +++++- .../presentation/artist_posts_screen.dart | 316 +++++++++++++++--- pubspec.yaml | 2 +- test/backend/pawchive_features_test.dart | 76 +++++ 14 files changed, 652 insertions(+), 67 deletions(-) create mode 100644 lib/backend/models/artist_announcement.dart create mode 100644 lib/backend/models/artist_link.dart create mode 100644 lib/backend/models/artist_tag.dart create mode 100644 test/backend/pawchive_features_test.dart diff --git a/README.md b/README.md index 3d3c0d5..56a54d3 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@

- Version 2.0.5 + Version 2.0.6 Flutter Platforms 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` --- diff --git a/lib/app/app_version.dart b/lib/app/app_version.dart index 84ac377..d406262 100644 --- a/lib/app/app_version.dart +++ b/lib/app/app_version.dart @@ -1,3 +1,3 @@ -const appDisplayVersion = '2.0.5'; -const appBuildNumber = 27; +const appDisplayVersion = '2.0.6'; +const appBuildNumber = 28; const appReleaseChannel = 'Stable'; diff --git a/lib/app/changelog.dart b/lib/app/changelog.dart index 95a05ca..f48f5d0 100644 --- a/lib/app/changelog.dart +++ b/lib/app/changelog.dart @@ -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', diff --git a/lib/backend/backend.dart b/lib/backend/backend.dart index 0350a2d..409dacb 100644 --- a/lib/backend/backend.dart +++ b/lib/backend/backend.dart @@ -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'; diff --git a/lib/backend/models/artist_announcement.dart b/lib/backend/models/artist_announcement.dart new file mode 100644 index 0000000..0f8bb69 --- /dev/null +++ b/lib/backend/models/artist_announcement.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 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 toJson() => { + 'service': service, + 'user_id': userId, + 'content': content, + if (hash != null) 'hash': hash, + if (added != null) 'added': added?.toIso8601String(), + }; +} diff --git a/lib/backend/models/artist_link.dart b/lib/backend/models/artist_link.dart new file mode 100644 index 0000000..bc7c419 --- /dev/null +++ b/lib/backend/models/artist_link.dart @@ -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 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 toJson() => { + 'id': id, + 'service': service, + 'name': name, + if (publicId != null) 'public_id': publicId, + }; +} diff --git a/lib/backend/models/artist_tag.dart b/lib/backend/models/artist_tag.dart new file mode 100644 index 0000000..7efb0c7 --- /dev/null +++ b/lib/backend/models/artist_tag.dart @@ -0,0 +1,21 @@ +class ArtistTag { + const ArtistTag({ + required this.tag, + required this.postCount, + }); + + final String tag; + final int postCount; + + factory ArtistTag.fromJson(Map json) { + return ArtistTag( + tag: (json['tag'] ?? '').toString(), + postCount: (json['post_count'] as num?)?.toInt() ?? 0, + ); + } + + Map toJson() => { + 'tag': tag, + 'post_count': postCount, + }; +} diff --git a/lib/backend/models/artist_work_query.dart b/lib/backend/models/artist_work_query.dart index 495e4d7..90534a5 100644 --- a/lib/backend/models/artist_work_query.dart +++ b/lib/backend/models/artist_work_query.dart @@ -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, + ); } diff --git a/lib/backend/providers/content_provider.dart b/lib/backend/providers/content_provider.dart index 375ee15..ef0aba7 100644 --- a/lib/backend/providers/content_provider.dart +++ b/lib/backend/providers/content_provider.dart @@ -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> getArtistTags(String service, String artistId) async => + const []; + + Future> getArtistLinks( + String service, String artistId) async => + const []; + + Future> getArtistAnnouncements( + String service, String artistId) async => + const []; } diff --git a/lib/backend/providers/kemono_provider.dart b/lib/backend/providers/kemono_provider.dart index 57f3fce..b617614 100644 --- a/lib/backend/providers/kemono_provider.dart +++ b/lib/backend/providers/kemono_provider.dart @@ -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> getArtistTags( + String service, String artistId) async => + const []; + + @override + Future> getArtistLinks( + String service, String artistId) async => + const []; + + @override + Future> getArtistAnnouncements( + String service, String artistId) async => + const []; + String _creatorPath() => _isCoomer ? '/coomer' : '/kemono'; List _artistsFromResponse(dynamic data) { diff --git a/lib/backend/providers/pawchive_provider.dart b/lib/backend/providers/pawchive_provider.dart index 774201d..cdab8bc 100644 --- a/lib/backend/providers/pawchive_provider.dart +++ b/lib/backend/providers/pawchive_provider.dart @@ -3,7 +3,10 @@ import 'dart:async'; import 'package:dio/dio.dart'; import '../../core/http/dio_client.dart'; +import '../models/artist_announcement.dart'; +import '../models/artist_link.dart'; import '../models/artist_profile.dart'; +import '../models/artist_tag.dart'; import '../models/artist_work_query.dart'; import '../models/post.dart'; import '../models/post_comment.dart'; @@ -42,7 +45,34 @@ class PawchiveProvider String? rating, TopPeriodFilter topPeriod = TopPeriodFilter.none, }) async { - return const []; + final offset = (page - 1).clamp(0, 999999) * 50; + final queryText = tags.join(' ').trim(); + try { + final response = await _dio.get( + '/api/v1/posts', + queryParameters: { + if (queryText.isNotEmpty) 'q': queryText, + 'o': offset, + }, + ); + final posts = _postItems(response.data); + final mapped = []; + for (final post in posts.whereType()) { + final json = Map.from(post); + final service = (json['service'] ?? 'fanbox').toString(); + final userId = (json['user'] ?? '').toString(); + final dummyQuery = ArtistWorkQuery( + providerId: id, + service: service, + artistId: userId, + artistName: userId, + ); + mapped.addAll(_postsFromArtistPost(json, query: dummyQuery)); + } + return mapped; + } catch (_) { + return const []; + } } @override @@ -178,7 +208,13 @@ class PawchiveProvider try { final response = await _dio.get( 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 = []; @@ -216,6 +252,72 @@ class PawchiveProvider } } + @override + Future> getArtistTags(String service, String artistId) async { + try { + final response = await _dio.get( + '/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((item) => ArtistTag.fromJson(Map.from(item))) + .toList(growable: false); + } + } catch (_) {} + return const []; + } + + @override + Future> getArtistLinks( + String service, String artistId) async { + try { + final response = await _dio.get( + '/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((item) => ArtistLink.fromJson(Map.from(item))) + .toList(growable: false); + } + } catch (_) {} + return const []; + } + + @override + Future> getArtistAnnouncements( + String service, String artistId) async { + try { + final response = await _dio.get( + '/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((item) => + ArtistAnnouncement.fromJson(Map.from(item))) + .toList(growable: false); + } + } catch (_) {} + return const []; + } + @override Future> getComments(String postId) async { final parts = postId.split(':'); diff --git a/lib/features/artists/presentation/artist_posts_screen.dart b/lib/features/artists/presentation/artist_posts_screen.dart index 5e3b0c5..30c01dc 100644 --- a/lib/features/artists/presentation/artist_posts_screen.dart +++ b/lib/features/artists/presentation/artist_posts_screen.dart @@ -47,6 +47,12 @@ class _ArtistPostsScreenState extends ConsumerState { final _scrollController = ScrollController(); final List _posts = []; final Set _selectedTypes = {}; + + List _tags = []; + List _links = []; + List _announcements = []; + String? _selectedTag; + int _page = 0; bool _loading = false; bool _hasMore = true; @@ -60,7 +66,10 @@ class _ArtistPostsScreenState extends ConsumerState { _loadMore(); } }); - WidgetsBinding.instance.addPostFrameCallback((_) => _loadMore()); + WidgetsBinding.instance.addPostFrameCallback((_) { + _loadMore(); + _loadMetadata(); + }); } @override @@ -69,6 +78,29 @@ class _ArtistPostsScreenState extends ConsumerState { super.dispose(); } + Future _loadMetadata() async { + try { + final providers = + await ref.read(providerManagerProvider).activeArtistProviders(); + if (providers is! Success>) 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 _refresh() async { setState(() { _posts.clear(); @@ -76,7 +108,7 @@ class _ArtistPostsScreenState extends ConsumerState { _hasMore = true; _error = null; }); - await _loadMore(); + await Future.wait([_loadMore(), _loadMetadata()]); } Future _loadMore() async { @@ -86,11 +118,19 @@ class _ArtistPostsScreenState extends ConsumerState { _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 { } } + 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 { return false; } + void _showAnnouncements() { + final theme = Theme.of(context); + showModalBottomSheet( + 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 { 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 { 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 { ? 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 { ? 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 { ? 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 { ); } + 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 { final favoriteKeys = ref.watch(favoriteKeysProvider).value ?? {}; final viewedKeys = ref.watch(viewedKeysProvider).value ?? {}; - final displayedPosts = _posts.where(_matchesTypeFilter).toList(growable: false); + final displayedPosts = + _posts.where(_matchesTypeFilter).toList(growable: false); return AdaptiveScaffold( title: widget.artistName, actions: [ + if (_announcements.isNotEmpty) + IconButton( + tooltip: 'Анонсы автора', + onPressed: _showAnnouncements, + icon: Badge.count( + count: _announcements.length, + child: const Icon(Icons.campaign_rounded), + ), + ), IconButton( - tooltip: 'Refresh', + tooltip: 'Обновить', onPressed: _refresh, icon: const Icon(Icons.refresh_rounded), ), @@ -259,13 +470,23 @@ class _ArtistPostsScreenState extends ConsumerState { 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 { 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 { ), 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 { posts: displayedPosts, controller: _scrollController, loading: _loading, + gridMode: settings.gridMode, columns: Responsive.columnsFor( context, mobileColumns: settings.mobileColumns, @@ -332,7 +563,8 @@ class _ArtistPostsScreenState extends ConsumerState { ); } ref.invalidate(favoriteKeysProvider); - ref.invalidate(favoritesControllerProvider); + ref.invalidate( + favoritesControllerProvider); }, ), ), @@ -345,7 +577,7 @@ class _ArtistPostsScreenState extends ConsumerState { 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; } diff --git a/pubspec.yaml b/pubspec.yaml index e29aba5..20dcadd 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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" diff --git a/test/backend/pawchive_features_test.dart b/test/backend/pawchive_features_test.dart new file mode 100644 index 0000000..335a02e --- /dev/null +++ b/test/backend/pawchive_features_test.dart @@ -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); + }); + }); +}