diff --git a/README.md b/README.md
index 56a54d3..6f70c55 100644
--- a/README.md
+++ b/README.md
@@ -10,7 +10,7 @@
-
+
@@ -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 []]));
+ });
+ });
+}