diff --git a/README.md b/README.md index 56a54d3..6f70c55 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@

- Version 2.0.6 + Version 2.0.7 Flutter Platforms Gitea @@ -20,11 +20,12 @@ ## ✨ Основные возможности -- **🌐 Общая лента Pawchive**: Недавние посты со всех платформ прямо в главной ленте (Feed) с поддержкой поиска. +- **🔀 Раздельный мульти-поиск по тегам**: Ввод нескольких тегов через пробел обрабатывает каждый тег независимо, выдавая контент по обоим тегам в одной ленте. +- **➕ Локальный оператор `and`**: Для строгого объединения тегов на одном арте достаточно написать между ними `and` (например: `genshin and raiden`). +- **🌐 Pawchive в главной ленте**: Глобальная лента недавних постов с Pawchive подключена прямо в общий Feed наравне с Rule34, Gelbooru, e621 и Realbooru. - **🏷️ Интерактивные теги автора**: Быстрая фильтрация по темам с живыми счётчиками постов (`#tag (count)`). - **📢 Анонсы и новости авторов**: Всплывающее окно анонсов автора с датами и текстом обращений к аудитории. -- **🔗 Связанные аккаунты**: Удобный переход между профилями автора на разных платформах (Patreon, Fanbox, Discord и др.). -- **🎬 Серверная фильтрация по типу медиа**: Поиск фото, видео и GIF по всей глубине архива автора через API. +- **⚡ Мгновенный фильтр фото, видео и GIF**: Локальная быстрая фильтрация постов автора без задержек. - **📱 Плавная лента в стиле Pinterest**: Masonry-сетка с переключением режима (Masonry, 1:1 Сетка, Список) в шапке ленты. - **⚡ Сверхбыстрые видео-превью**: Мгновенная загрузка превью без фризов и лишней нагрузки на процессор и память смартфона. - **↔️ Листание постов влево и вправо**: Полноэкранный пейджер с плавными свайпами между фото и видео во всех разделах. @@ -65,9 +66,9 @@ 🔗 **[Gitea Releases (RarDog/Lunaris)](https://gitea.rardogsynapse.online/RarDog/Lunaris/releases)** -- **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` +- **Android**: `Lunaris-v2.0.7.apk` +- **Linux (x64)**: `Lunaris-v2.0.7-linux-x64.tar.gz` +- **Windows**: `LunarisSetup-v2.0.7.exe` / `LunarisPortable-v2.0.7.zip` --- diff --git a/lib/app/app_version.dart b/lib/app/app_version.dart index d406262..0b9a82a 100644 --- a/lib/app/app_version.dart +++ b/lib/app/app_version.dart @@ -1,3 +1,3 @@ -const appDisplayVersion = '2.0.6'; -const appBuildNumber = 28; +const appDisplayVersion = '2.0.7'; +const appBuildNumber = 29; const appReleaseChannel = 'Stable'; diff --git a/lib/app/changelog.dart b/lib/app/changelog.dart index f48f5d0..92848a9 100644 --- a/lib/app/changelog.dart +++ b/lib/app/changelog.dart @@ -1,4 +1,15 @@ const lunarisChangelog = [ + 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', diff --git a/lib/backend/providers/provider_manager.dart b/lib/backend/providers/provider_manager.dart index fb0f822..bcb5ca0 100644 --- a/lib/backend/providers/provider_manager.dart +++ b/lib/backend/providers/provider_manager.dart @@ -163,6 +163,36 @@ class ProviderManager { return Success(results); } + static List> splitTagGroups(List rawTags) { + final groups = >[]; + List currentAndGroup = []; + + for (int i = 0; i < rawTags.length; i++) { + final token = rawTags[i].trim(); + if (token.isEmpty) continue; + + if (token.toLowerCase() == 'and') { + continue; + } + + final prevWasAnd = i > 0 && rawTags[i - 1].trim().toLowerCase() == 'and'; + if (prevWasAnd) { + currentAndGroup.add(token); + } else { + if (currentAndGroup.isNotEmpty) { + groups.add(currentAndGroup); + } + currentAndGroup = [token]; + } + } + + if (currentAndGroup.isNotEmpty) { + groups.add(currentAndGroup); + } + + return groups.isEmpty ? [const []] : groups; + } + Future>> searchAcrossProviders({ required List tags, required int page, @@ -181,6 +211,57 @@ class ProviderManager { providers.where((provider) => provider.id == providerId).toList(); } + final tagGroups = splitTagGroups(tags); + + if (tagGroups.length <= 1) { + final effectiveTags = tagGroups.isEmpty ? tags : tagGroups.first; + return _searchForTagGroup( + providers: providers, + tags: effectiveTags, + page: page, + limit: limit, + rating: rating, + providerId: providerId, + topPeriod: topPeriod, + ); + } + + // Multiple independent tag groups: query each tag group as if other tags are not present, then interleave + final seen = {}; + 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 = >[]; + for (final res in results) { + if (res is Success>) { + validGroupPosts.add(res.data); + } + } + + final interleaved = _interleaveTagGroupResults(validGroupPosts, seen); + return Success(interleaved); + } + + Future>> _searchForTagGroup({ + required List providers, + required List tags, + required int page, + required int limit, + required String? rating, + required String? providerId, + required TopPeriodFilter topPeriod, + }) async { final seen = {}; final providerResults = >[]; for (final provider in providers) { @@ -234,6 +315,29 @@ class ProviderManager { return Success(posts); } + List _interleaveTagGroupResults( + List> groupResults, Set seen) { + final combined = []; + 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 _flattenProviderResults( List> results, Set seen) { final posts = []; @@ -584,7 +688,8 @@ class ProviderManager { } static bool _isFeedConfig(ContentProviderConfig config) { - return !_isArtistConfig(config); + final type = config.apiType.toLowerCase(); + return type != 'kemono' && type != 'coomer'; } static bool _isLegacyRemovedConfig(ContentProviderConfig config) { diff --git a/lib/features/artists/presentation/artist_posts_screen.dart b/lib/features/artists/presentation/artist_posts_screen.dart index 30c01dc..8730d4b 100644 --- a/lib/features/artists/presentation/artist_posts_screen.dart +++ b/lib/features/artists/presentation/artist_posts_screen.dart @@ -49,7 +49,6 @@ class _ArtistPostsScreenState extends ConsumerState { final Set _selectedTypes = {}; List _tags = []; - List _links = []; List _announcements = []; String? _selectedTag; @@ -87,15 +86,14 @@ class _ArtistPostsScreenState extends ConsumerState { (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 tagsRes = + await provider.getArtistTags(widget.service, widget.artistId); final annRes = await provider.getArtistAnnouncements(widget.service, widget.artistId); if (!mounted) return; setState(() { _tags = tagsRes; - _links = linksRes; _announcements = annRes; }); } catch (_) {} @@ -118,18 +116,11 @@ 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, ); @@ -191,21 +182,6 @@ class _ArtistPostsScreenState extends ConsumerState { _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); @@ -299,18 +275,7 @@ class _ArtistPostsScreenState extends ConsumerState { FilterChip( selected: _selectedTypes.isEmpty, label: Text('Все (${_posts.length})'), - onSelected: (_) { - if (_selectedTypes.isNotEmpty) { - setState(() { - _selectedTypes.clear(); - _posts.clear(); - _page = 0; - _hasMore = true; - _error = null; - }); - _loadMore(); - } - }, + onSelected: (_) => setState(() => _selectedTypes.clear()), ), const SizedBox(width: 8), FilterChip( @@ -321,7 +286,15 @@ class _ArtistPostsScreenState extends ConsumerState { ? scheme.onPrimaryContainer : scheme.onSurfaceVariant), label: Text('Фото ($photoCount)'), - onSelected: (selected) => _onMediaTypeToggled('photo', selected), + onSelected: (selected) { + setState(() { + if (selected) { + _selectedTypes.add('photo'); + } else { + _selectedTypes.remove('photo'); + } + }); + }, ), const SizedBox(width: 8), FilterChip( @@ -332,7 +305,15 @@ class _ArtistPostsScreenState extends ConsumerState { ? scheme.onPrimaryContainer : scheme.onSurfaceVariant), label: Text('Видео ($videoCount)'), - onSelected: (selected) => _onMediaTypeToggled('video', selected), + onSelected: (selected) { + setState(() { + if (selected) { + _selectedTypes.add('video'); + } else { + _selectedTypes.remove('video'); + } + }); + }, ), const SizedBox(width: 8), FilterChip( @@ -343,7 +324,15 @@ class _ArtistPostsScreenState extends ConsumerState { ? scheme.onPrimaryContainer : scheme.onSurfaceVariant), label: Text('GIF ($gifCount)'), - onSelected: (selected) => _onMediaTypeToggled('gif', selected), + onSelected: (selected) { + setState(() { + if (selected) { + _selectedTypes.add('gif'); + } else { + _selectedTypes.remove('gif'); + } + }); + }, ), ], ), @@ -382,58 +371,6 @@ class _ArtistPostsScreenState extends ConsumerState { ); } - 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 = @@ -472,7 +409,6 @@ class _ArtistPostsScreenState extends ConsumerState { : _posts.isEmpty ? Column( children: [ - _buildLinksBar(), _buildTagsBar(), const Expanded( child: EmptyView( @@ -486,7 +422,6 @@ class _ArtistPostsScreenState extends ConsumerState { children: [ _buildMediaTypeFilters(), _buildTagsBar(), - _buildLinksBar(), Expanded( child: displayedPosts.isEmpty ? Center( @@ -510,11 +445,7 @@ class _ArtistPostsScreenState extends ConsumerState { setState(() { _selectedTypes.clear(); _selectedTag = null; - _posts.clear(); - _page = 0; - _hasMore = true; }); - _loadMore(); }, child: const Text('Сбросить фильтры'), ), diff --git a/pubspec.yaml b/pubspec.yaml index 20dcadd..5484e53 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.6+28 +version: 2.0.7+29 environment: sdk: ">=3.3.0 <4.0.0" diff --git a/test/backend/tag_groups_search_test.dart b/test/backend/tag_groups_search_test.dart new file mode 100644 index 0000000..2c89b8a --- /dev/null +++ b/test/backend/tag_groups_search_test.dart @@ -0,0 +1,56 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:gel_rule_app/backend/providers/provider_manager.dart'; + +void main() { + group('Tag groups splitting with local and connector', () { + test('single tag returns one group', () { + final groups = ProviderManager.splitTagGroups(['cat']); + expect(groups, equals([['cat']])); + }); + + test('multiple tags without and are split into separate independent groups', () { + final groups = ProviderManager.splitTagGroups(['cat', 'dog', 'bird']); + expect(groups, equals([ + ['cat'], + ['dog'], + ['bird'], + ])); + }); + + test('tags connected by and are merged into the same group', () { + final groups = ProviderManager.splitTagGroups(['cat', 'and', 'dog']); + expect(groups, equals([ + ['cat', 'dog'], + ])); + }); + + test('mixed independent tags and and-connected tags', () { + final groups = ProviderManager.splitTagGroups([ + 'genshin', + 'and', + 'raiden', + 'miku', + 'vocaloid', + 'and', + 'hatsune', + ]); + expect(groups, equals([ + ['genshin', 'raiden'], + ['miku'], + ['vocaloid', 'hatsune'], + ])); + }); + + test('case-insensitive AND handling', () { + final groups = ProviderManager.splitTagGroups(['2girls', 'AND', 'yuri']); + expect(groups, equals([ + ['2girls', 'yuri'], + ])); + }); + + test('empty tags return empty group list', () { + final groups = ProviderManager.splitTagGroups([]); + expect(groups, equals([const []])); + }); + }); +}