feat: release v2.0.7 with independent multi-tag search, local 'and' connector, Pawchive in feed, and reverted artist media filters/links
This commit is contained in:
@@ -10,7 +10,7 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<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/Version-2.0.7-blueviolet?style=for-the-badge" alt="Version 2.0.7" />
|
||||||
<img src="https://img.shields.io/badge/Flutter-3.47+-02569B?style=for-the-badge&logo=flutter&logoColor=white" alt="Flutter" />
|
<img src="https://img.shields.io/badge/Flutter-3.47+-02569B?style=for-the-badge&logo=flutter&logoColor=white" alt="Flutter" />
|
||||||
<img src="https://img.shields.io/badge/Platform-Android%20%7C%20Linux%20%7C%20Windows-brightgreen?style=for-the-badge" alt="Platforms" />
|
<img src="https://img.shields.io/badge/Platform-Android%20%7C%20Linux%20%7C%20Windows-brightgreen?style=for-the-badge" alt="Platforms" />
|
||||||
<img src="https://img.shields.io/badge/Gitea-RarDog%2FLunaris-orange?style=for-the-badge&logo=gitea&logoColor=white" alt="Gitea" />
|
<img src="https://img.shields.io/badge/Gitea-RarDog%2FLunaris-orange?style=for-the-badge&logo=gitea&logoColor=white" alt="Gitea" />
|
||||||
@@ -20,11 +20,12 @@
|
|||||||
|
|
||||||
## ✨ Основные возможности
|
## ✨ Основные возможности
|
||||||
|
|
||||||
- **🌐 Общая лента Pawchive**: Недавние посты со всех платформ прямо в главной ленте (Feed) с поддержкой поиска.
|
- **🔀 Раздельный мульти-поиск по тегам**: Ввод нескольких тегов через пробел обрабатывает каждый тег независимо, выдавая контент по обоим тегам в одной ленте.
|
||||||
|
- **➕ Локальный оператор `and`**: Для строгого объединения тегов на одном арте достаточно написать между ними `and` (например: `genshin and raiden`).
|
||||||
|
- **🌐 Pawchive в главной ленте**: Глобальная лента недавних постов с Pawchive подключена прямо в общий Feed наравне с Rule34, Gelbooru, e621 и Realbooru.
|
||||||
- **🏷️ Интерактивные теги автора**: Быстрая фильтрация по темам с живыми счётчиками постов (`#tag (count)`).
|
- **🏷️ Интерактивные теги автора**: Быстрая фильтрация по темам с живыми счётчиками постов (`#tag (count)`).
|
||||||
- **📢 Анонсы и новости авторов**: Всплывающее окно анонсов автора с датами и текстом обращений к аудитории.
|
- **📢 Анонсы и новости авторов**: Всплывающее окно анонсов автора с датами и текстом обращений к аудитории.
|
||||||
- **🔗 Связанные аккаунты**: Удобный переход между профилями автора на разных платформах (Patreon, Fanbox, Discord и др.).
|
- **⚡ Мгновенный фильтр фото, видео и GIF**: Локальная быстрая фильтрация постов автора без задержек.
|
||||||
- **🎬 Серверная фильтрация по типу медиа**: Поиск фото, видео и GIF по всей глубине архива автора через API.
|
|
||||||
- **📱 Плавная лента в стиле Pinterest**: Masonry-сетка с переключением режима (Masonry, 1:1 Сетка, Список) в шапке ленты.
|
- **📱 Плавная лента в стиле Pinterest**: Masonry-сетка с переключением режима (Masonry, 1:1 Сетка, Список) в шапке ленты.
|
||||||
- **⚡ Сверхбыстрые видео-превью**: Мгновенная загрузка превью без фризов и лишней нагрузки на процессор и память смартфона.
|
- **⚡ Сверхбыстрые видео-превью**: Мгновенная загрузка превью без фризов и лишней нагрузки на процессор и память смартфона.
|
||||||
- **↔️ Листание постов влево и вправо**: Полноэкранный пейджер с плавными свайпами между фото и видео во всех разделах.
|
- **↔️ Листание постов влево и вправо**: Полноэкранный пейджер с плавными свайпами между фото и видео во всех разделах.
|
||||||
@@ -65,9 +66,9 @@
|
|||||||
|
|
||||||
🔗 **[Gitea Releases (RarDog/Lunaris)](https://gitea.rardogsynapse.online/RarDog/Lunaris/releases)**
|
🔗 **[Gitea Releases (RarDog/Lunaris)](https://gitea.rardogsynapse.online/RarDog/Lunaris/releases)**
|
||||||
|
|
||||||
- **Android**: `Lunaris-v2.0.6.apk`
|
- **Android**: `Lunaris-v2.0.7.apk`
|
||||||
- **Linux (x64)**: `Lunaris-v2.0.6-linux-x64.tar.gz`
|
- **Linux (x64)**: `Lunaris-v2.0.7-linux-x64.tar.gz`
|
||||||
- **Windows**: `LunarisSetup-v2.0.6.exe` / `LunarisPortable-v2.0.6.zip`
|
- **Windows**: `LunarisSetup-v2.0.7.exe` / `LunarisPortable-v2.0.7.zip`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
const appDisplayVersion = '2.0.6';
|
const appDisplayVersion = '2.0.7';
|
||||||
const appBuildNumber = 28;
|
const appBuildNumber = 29;
|
||||||
const appReleaseChannel = 'Stable';
|
const appReleaseChannel = 'Stable';
|
||||||
|
|||||||
@@ -1,4 +1,15 @@
|
|||||||
const lunarisChangelog = [
|
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(
|
LunarisChange(
|
||||||
version: '2.0.6',
|
version: '2.0.6',
|
||||||
title: 'Pawchive Deep Integration: Global Posts Feed, Artist Tags, Announcements, Links & Server Media Filter',
|
title: 'Pawchive Deep Integration: Global Posts Feed, Artist Tags, Announcements, Links & Server Media Filter',
|
||||||
|
|||||||
@@ -163,6 +163,36 @@ class ProviderManager {
|
|||||||
return Success(results);
|
return Success(results);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static List<List<String>> splitTagGroups(List<String> rawTags) {
|
||||||
|
final groups = <List<String>>[];
|
||||||
|
List<String> 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<Result<List<Post>>> searchAcrossProviders({
|
Future<Result<List<Post>>> searchAcrossProviders({
|
||||||
required List<String> tags,
|
required List<String> tags,
|
||||||
required int page,
|
required int page,
|
||||||
@@ -181,6 +211,57 @@ class ProviderManager {
|
|||||||
providers.where((provider) => provider.id == providerId).toList();
|
providers.where((provider) => provider.id == providerId).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final tagGroups = splitTagGroups(tags);
|
||||||
|
|
||||||
|
if (tagGroups.length <= 1) {
|
||||||
|
final effectiveTags = tagGroups.isEmpty ? tags : tagGroups.first;
|
||||||
|
return _searchForTagGroup(
|
||||||
|
providers: providers,
|
||||||
|
tags: effectiveTags,
|
||||||
|
page: page,
|
||||||
|
limit: limit,
|
||||||
|
rating: rating,
|
||||||
|
providerId: providerId,
|
||||||
|
topPeriod: topPeriod,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Multiple independent tag groups: query each tag group as if other tags are not present, then interleave
|
||||||
|
final seen = <String>{};
|
||||||
|
final groupPerLimit = (limit / tagGroups.length).ceil().clamp(10, 50);
|
||||||
|
final groupFutures = tagGroups.map((groupTags) {
|
||||||
|
return _searchForTagGroup(
|
||||||
|
providers: providers,
|
||||||
|
tags: groupTags,
|
||||||
|
page: page,
|
||||||
|
limit: groupPerLimit,
|
||||||
|
rating: rating,
|
||||||
|
providerId: providerId,
|
||||||
|
topPeriod: topPeriod,
|
||||||
|
);
|
||||||
|
}).toList(growable: false);
|
||||||
|
|
||||||
|
final results = await Future.wait(groupFutures);
|
||||||
|
final validGroupPosts = <List<Post>>[];
|
||||||
|
for (final res in results) {
|
||||||
|
if (res is Success<List<Post>>) {
|
||||||
|
validGroupPosts.add(res.data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final interleaved = _interleaveTagGroupResults(validGroupPosts, seen);
|
||||||
|
return Success(interleaved);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Result<List<Post>>> _searchForTagGroup({
|
||||||
|
required List<ContentProvider> providers,
|
||||||
|
required List<String> tags,
|
||||||
|
required int page,
|
||||||
|
required int limit,
|
||||||
|
required String? rating,
|
||||||
|
required String? providerId,
|
||||||
|
required TopPeriodFilter topPeriod,
|
||||||
|
}) async {
|
||||||
final seen = <String>{};
|
final seen = <String>{};
|
||||||
final providerResults = <List<Post>>[];
|
final providerResults = <List<Post>>[];
|
||||||
for (final provider in providers) {
|
for (final provider in providers) {
|
||||||
@@ -234,6 +315,29 @@ class ProviderManager {
|
|||||||
return Success(posts);
|
return Success(posts);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
List<Post> _interleaveTagGroupResults(
|
||||||
|
List<List<Post>> groupResults, Set<String> seen) {
|
||||||
|
final combined = <Post>[];
|
||||||
|
var hasMore = true;
|
||||||
|
var index = 0;
|
||||||
|
|
||||||
|
while (hasMore) {
|
||||||
|
hasMore = false;
|
||||||
|
for (final group in groupResults) {
|
||||||
|
if (index < group.length) {
|
||||||
|
hasMore = true;
|
||||||
|
final post = group[index];
|
||||||
|
if (seen.add(post.cacheKey)) {
|
||||||
|
combined.add(post);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
index++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return combined;
|
||||||
|
}
|
||||||
|
|
||||||
List<Post> _flattenProviderResults(
|
List<Post> _flattenProviderResults(
|
||||||
List<List<Post>> results, Set<String> seen) {
|
List<List<Post>> results, Set<String> seen) {
|
||||||
final posts = <Post>[];
|
final posts = <Post>[];
|
||||||
@@ -584,7 +688,8 @@ class ProviderManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static bool _isFeedConfig(ContentProviderConfig config) {
|
static bool _isFeedConfig(ContentProviderConfig config) {
|
||||||
return !_isArtistConfig(config);
|
final type = config.apiType.toLowerCase();
|
||||||
|
return type != 'kemono' && type != 'coomer';
|
||||||
}
|
}
|
||||||
|
|
||||||
static bool _isLegacyRemovedConfig(ContentProviderConfig config) {
|
static bool _isLegacyRemovedConfig(ContentProviderConfig config) {
|
||||||
|
|||||||
@@ -49,7 +49,6 @@ class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
|
|||||||
final Set<String> _selectedTypes = <String>{};
|
final Set<String> _selectedTypes = <String>{};
|
||||||
|
|
||||||
List<ArtistTag> _tags = [];
|
List<ArtistTag> _tags = [];
|
||||||
List<ArtistLink> _links = [];
|
|
||||||
List<ArtistAnnouncement> _announcements = [];
|
List<ArtistAnnouncement> _announcements = [];
|
||||||
String? _selectedTag;
|
String? _selectedTag;
|
||||||
|
|
||||||
@@ -87,15 +86,14 @@ class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
|
|||||||
(item) => (item as ContentProvider).id == widget.providerId,
|
(item) => (item as ContentProvider).id == widget.providerId,
|
||||||
);
|
);
|
||||||
|
|
||||||
final tagsRes = await provider.getArtistTags(widget.service, widget.artistId);
|
final tagsRes =
|
||||||
final linksRes = await provider.getArtistLinks(widget.service, widget.artistId);
|
await provider.getArtistTags(widget.service, widget.artistId);
|
||||||
final annRes =
|
final annRes =
|
||||||
await provider.getArtistAnnouncements(widget.service, widget.artistId);
|
await provider.getArtistAnnouncements(widget.service, widget.artistId);
|
||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_tags = tagsRes;
|
_tags = tagsRes;
|
||||||
_links = linksRes;
|
|
||||||
_announcements = annRes;
|
_announcements = annRes;
|
||||||
});
|
});
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
@@ -118,18 +116,11 @@ class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
|
|||||||
_error = null;
|
_error = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
String? queryText;
|
|
||||||
if (_selectedTypes.length == 1) {
|
|
||||||
if (_selectedTypes.contains('video')) queryText = 'mp4';
|
|
||||||
if (_selectedTypes.contains('gif')) queryText = 'gif';
|
|
||||||
}
|
|
||||||
|
|
||||||
final query = ArtistWorkQuery(
|
final query = ArtistWorkQuery(
|
||||||
providerId: widget.providerId,
|
providerId: widget.providerId,
|
||||||
service: widget.service,
|
service: widget.service,
|
||||||
artistId: widget.artistId,
|
artistId: widget.artistId,
|
||||||
artistName: widget.artistName,
|
artistName: widget.artistName,
|
||||||
queryText: queryText,
|
|
||||||
tagFilter: _selectedTag,
|
tagFilter: _selectedTag,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -191,21 +182,6 @@ class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
|
|||||||
_loadMore();
|
_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) {
|
bool _matchesTypeFilter(Post post) {
|
||||||
if (_selectedTypes.isEmpty) return true;
|
if (_selectedTypes.isEmpty) return true;
|
||||||
final isVid = MediaUrlSelector.isVideo(post);
|
final isVid = MediaUrlSelector.isVideo(post);
|
||||||
@@ -299,18 +275,7 @@ class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
|
|||||||
FilterChip(
|
FilterChip(
|
||||||
selected: _selectedTypes.isEmpty,
|
selected: _selectedTypes.isEmpty,
|
||||||
label: Text('Все (${_posts.length})'),
|
label: Text('Все (${_posts.length})'),
|
||||||
onSelected: (_) {
|
onSelected: (_) => setState(() => _selectedTypes.clear()),
|
||||||
if (_selectedTypes.isNotEmpty) {
|
|
||||||
setState(() {
|
|
||||||
_selectedTypes.clear();
|
|
||||||
_posts.clear();
|
|
||||||
_page = 0;
|
|
||||||
_hasMore = true;
|
|
||||||
_error = null;
|
|
||||||
});
|
|
||||||
_loadMore();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
FilterChip(
|
FilterChip(
|
||||||
@@ -321,7 +286,15 @@ class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
|
|||||||
? scheme.onPrimaryContainer
|
? scheme.onPrimaryContainer
|
||||||
: scheme.onSurfaceVariant),
|
: scheme.onSurfaceVariant),
|
||||||
label: Text('Фото ($photoCount)'),
|
label: Text('Фото ($photoCount)'),
|
||||||
onSelected: (selected) => _onMediaTypeToggled('photo', selected),
|
onSelected: (selected) {
|
||||||
|
setState(() {
|
||||||
|
if (selected) {
|
||||||
|
_selectedTypes.add('photo');
|
||||||
|
} else {
|
||||||
|
_selectedTypes.remove('photo');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
FilterChip(
|
FilterChip(
|
||||||
@@ -332,7 +305,15 @@ class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
|
|||||||
? scheme.onPrimaryContainer
|
? scheme.onPrimaryContainer
|
||||||
: scheme.onSurfaceVariant),
|
: scheme.onSurfaceVariant),
|
||||||
label: Text('Видео ($videoCount)'),
|
label: Text('Видео ($videoCount)'),
|
||||||
onSelected: (selected) => _onMediaTypeToggled('video', selected),
|
onSelected: (selected) {
|
||||||
|
setState(() {
|
||||||
|
if (selected) {
|
||||||
|
_selectedTypes.add('video');
|
||||||
|
} else {
|
||||||
|
_selectedTypes.remove('video');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
FilterChip(
|
FilterChip(
|
||||||
@@ -343,7 +324,15 @@ class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
|
|||||||
? scheme.onPrimaryContainer
|
? scheme.onPrimaryContainer
|
||||||
: scheme.onSurfaceVariant),
|
: scheme.onSurfaceVariant),
|
||||||
label: Text('GIF ($gifCount)'),
|
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<ArtistPostsScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final settings =
|
final settings =
|
||||||
@@ -472,7 +409,6 @@ class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
|
|||||||
: _posts.isEmpty
|
: _posts.isEmpty
|
||||||
? Column(
|
? Column(
|
||||||
children: [
|
children: [
|
||||||
_buildLinksBar(),
|
|
||||||
_buildTagsBar(),
|
_buildTagsBar(),
|
||||||
const Expanded(
|
const Expanded(
|
||||||
child: EmptyView(
|
child: EmptyView(
|
||||||
@@ -486,7 +422,6 @@ class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
|
|||||||
children: [
|
children: [
|
||||||
_buildMediaTypeFilters(),
|
_buildMediaTypeFilters(),
|
||||||
_buildTagsBar(),
|
_buildTagsBar(),
|
||||||
_buildLinksBar(),
|
|
||||||
Expanded(
|
Expanded(
|
||||||
child: displayedPosts.isEmpty
|
child: displayedPosts.isEmpty
|
||||||
? Center(
|
? Center(
|
||||||
@@ -510,11 +445,7 @@ class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
|
|||||||
setState(() {
|
setState(() {
|
||||||
_selectedTypes.clear();
|
_selectedTypes.clear();
|
||||||
_selectedTag = null;
|
_selectedTag = null;
|
||||||
_posts.clear();
|
|
||||||
_page = 0;
|
|
||||||
_hasMore = true;
|
|
||||||
});
|
});
|
||||||
_loadMore();
|
|
||||||
},
|
},
|
||||||
child: const Text('Сбросить фильтры'),
|
child: const Text('Сбросить фильтры'),
|
||||||
),
|
),
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
name: gel_rule_app
|
name: gel_rule_app
|
||||||
description: Local backend/core layer for a Flutter booru browser app.
|
description: Local backend/core layer for a Flutter booru browser app.
|
||||||
publish_to: "none"
|
publish_to: "none"
|
||||||
version: 2.0.6+28
|
version: 2.0.7+29
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ">=3.3.0 <4.0.0"
|
sdk: ">=3.3.0 <4.0.0"
|
||||||
|
|||||||
@@ -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 <String>[]]));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user