diff --git a/README.md b/README.md index 7b99484..df125b8 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@

- Version 2.0.3 + Version 2.0.4 Flutter Platforms Gitea @@ -25,8 +25,10 @@ - **↔️ Листание постов влево и вправо**: Полноэкранный пейджер с плавными свайпами между фото и видео во всех разделах (Лента, Авторы, Избранное, Коллекции, История просмотров). - **🖼️ Мульти-медиа в одном посте**: Удобная карусель с индикатором `1 / 4` для постов с несколькими файлами. - **⭐ Любимые авторы**: Компактная кнопка-звёздочка сверху справа для быстрого доступа ко всем любимым авторам и их свежему медиапотоку. +- **🏷️ Мультивыбор платформ**: Быстрый выбор одной или нескольких платформ авторов (Patreon, Fanbox, Fantia, Boosty, Discord). +- **🎬 Фильтр фото, видео и GIF у автора**: Удобные чипсы с мультивыбором типа контента и количеством найденных медиа. - **📜 Бесконечная подгрузка постов автора**: Умная пагинация при прокрутке вниз загружает всю историю работ без ограничений. -- **🐾 Поддержка Pawchive**: Интеграция с `pawchive.pw` для просмотра профилей авторов, связанных аккаунтов, тегов и работ с Patreon, Fanbox, Fantia, Discord, Boosty и др. +- **🐾 Поддержка Pawchive**: Интеграция с `pawchive.pw` для просмотра профилей авторов, связанных аккаунтов, тегов и работ. - **🔍 Умный поиск и автодополнение**: Интерактивные чипсы тегов с подсказками от активных API. Быстрое исключение (`-tag`), добавление (`+tag`) или отправка в черный список по долгому тапу. - **🛡️ Продвинутый Blacklist / Whitelist**: Фильтрация по тегам, категориям, рейтингу контента (safe/questionable/explicit), провайдерам и минимальному score. - **🎬 Встроенный плеер**: Удобное воспроизведение WebM / MP4, запоминание настроек звука, fullscreen и повтор. @@ -53,9 +55,9 @@ 🔗 **[Gitea Releases (RarDog/Lunaris)](https://gitea.rardogsynapse.online/RarDog/Lunaris/releases)** -- **Android**: `Lunaris-v2.0.3.apk` -- **Linux (x64)**: `Lunaris-v2.0.3-linux-x64.tar.gz` -- **Windows**: `LunarisSetup-v2.0.3.exe` / `LunarisPortable-v2.0.3.zip` +- **Android**: `Lunaris-v2.0.4.apk` +- **Linux (x64)**: `Lunaris-v2.0.4-linux-x64.tar.gz` +- **Windows**: `LunarisSetup-v2.0.4.exe` / `LunarisPortable-v2.0.4.zip` --- diff --git a/lib/app/app_version.dart b/lib/app/app_version.dart index e4e8056..34f5d85 100644 --- a/lib/app/app_version.dart +++ b/lib/app/app_version.dart @@ -1,3 +1,3 @@ -const appDisplayVersion = '2.0.3'; -const appBuildNumber = 25; +const appDisplayVersion = '2.0.4'; +const appBuildNumber = 26; const appReleaseChannel = 'Stable'; diff --git a/lib/app/changelog.dart b/lib/app/changelog.dart index 444fe9f..6d3001d 100644 --- a/lib/app/changelog.dart +++ b/lib/app/changelog.dart @@ -1,4 +1,13 @@ const lunarisChangelog = [ + LunarisChange( + version: '2.0.4', + title: 'Platform service selection & media type filtering in artist posts', + bullets: [ + 'Added multi-select platform filters (Patreon, Pixiv Fanbox, Fantia, Boosty, Discord) in the Artists tab.', + 'Added multi-select media type filter chips (Photos, Videos, GIFs) in artist posts with counts.', + 'Full-screen post pager respects active media type filters during swiping.', + ], + ), LunarisChange( version: '2.0.3', title: 'Infinite artist pagination, favorite artists modal, and card layout polish', diff --git a/lib/backend/providers/content_provider.dart b/lib/backend/providers/content_provider.dart index 3a8a12d..375ee15 100644 --- a/lib/backend/providers/content_provider.dart +++ b/lib/backend/providers/content_provider.dart @@ -47,6 +47,7 @@ abstract class TagMetadataProvider { abstract class ArtistProvider { Future> listArtists({ String? service, + List? services, String? query, int page = 1, int limit = 30, @@ -55,6 +56,7 @@ abstract class ArtistProvider { Future> searchArtists( String query, { String? service, + List? services, int page = 1, int limit = 30, }); diff --git a/lib/backend/providers/kemono_provider.dart b/lib/backend/providers/kemono_provider.dart index 0e4e381..57f3fce 100644 --- a/lib/backend/providers/kemono_provider.dart +++ b/lib/backend/providers/kemono_provider.dart @@ -91,66 +91,56 @@ class KemonoProvider @override Future> listArtists({ String? service, + List? services, String? query, int page = 1, int limit = 30, }) async { - final searchText = query?.trim() ?? ''; - if (searchText.isNotEmpty) { - try { - final response = await _creatorDio.get( - _creatorPath(), - queryParameters: { - 'keyword': searchText, - 'page': page, - 'itemsPerPage': limit.clamp(1, 100), - }, - ); - final artists = _rankArtists( - _artistsFromResponse(response.data) - .where((artist) => - service == null || - service.isEmpty || - artist.service == service) - .toList(growable: false), - searchText, - ); - if (artists.isNotEmpty) return artists; - } catch (_) { - // Fall through to the indexed creator endpoints. - } - } + final effectiveService = + services != null && services.length == 1 ? services.first : service; + List list; try { final response = await _creatorDio.get( - service == null || service.isEmpty + effectiveService == null || effectiveService.isEmpty ? _creatorPath() - : '${_creatorPath()}/$service', + : '${_creatorPath()}/$effectiveService', queryParameters: { 'page': page, 'itemsPerPage': limit.clamp(1, 100), if (query != null && query.trim().isNotEmpty) 'keyword': query.trim(), }, ); - return _rankArtists(_artistsFromResponse(response.data), query ?? ''); + list = _rankArtists(_artistsFromResponse(response.data), query ?? ''); } catch (_) { - return _listArtistsFromPublicApi( - service: service, + list = await _listArtistsFromPublicApi( + service: effectiveService, query: query, page: page, limit: limit, ); } + if (services != null && services.isNotEmpty) { + final sSet = services.map((s) => s.toLowerCase()).toSet(); + list = list.where((a) => sSet.contains(a.service.toLowerCase())).toList(); + } + return list; } @override Future> searchArtists( String query, { String? service, + List? services, int page = 1, int limit = 30, }) { return listArtists( - service: service, query: query, page: page, limit: limit); + service: service, + services: services, + query: query, + page: page, + limit: limit, + ); } @override diff --git a/lib/backend/providers/pawchive_provider.dart b/lib/backend/providers/pawchive_provider.dart index 223a666..774201d 100644 --- a/lib/backend/providers/pawchive_provider.dart +++ b/lib/backend/providers/pawchive_provider.dart @@ -122,16 +122,23 @@ class PawchiveProvider @override Future> listArtists({ String? service, + List? services, String? query, int page = 1, int limit = 30, }) async { final allArtists = await _ensureArtistsLoaded(); final queryText = (query ?? '').trim().toLowerCase(); + final activeServices = services != null && services.isNotEmpty + ? services.map((s) => s.trim().toLowerCase()).toSet() + : null; final filtered = allArtists.where((artist) { - final matchService = service == null || - service.isEmpty || - artist.service.toLowerCase() == service.toLowerCase(); + final artistService = artist.service.toLowerCase(); + final matchService = activeServices != null + ? activeServices.contains(artistService) + : service == null || + service.isEmpty || + artistService == service.toLowerCase(); if (!matchService) return false; if (queryText.isEmpty) return true; return artist.displayName.toLowerCase().contains(queryText) || @@ -147,11 +154,13 @@ class PawchiveProvider Future> searchArtists( String query, { String? service, + List? services, int page = 1, int limit = 30, }) { return listArtists( service: service, + services: services, query: query, page: page, limit: limit, diff --git a/lib/features/artists/presentation/artist_posts_screen.dart b/lib/features/artists/presentation/artist_posts_screen.dart index 30fbc69..5e3b0c5 100644 --- a/lib/features/artists/presentation/artist_posts_screen.dart +++ b/lib/features/artists/presentation/artist_posts_screen.dart @@ -46,6 +46,7 @@ class ArtistPostsScreen extends ConsumerStatefulWidget { class _ArtistPostsScreenState extends ConsumerState { final _scrollController = ScrollController(); final List _posts = []; + final Set _selectedTypes = {}; int _page = 0; bool _loading = false; bool _hasMore = true; @@ -138,6 +139,100 @@ class _ArtistPostsScreenState extends ConsumerState { } } + bool _matchesTypeFilter(Post post) { + if (_selectedTypes.isEmpty) return true; + final isVid = MediaUrlSelector.isVideo(post); + final isGif = MediaUrlSelector.isGif(post); + final isPhoto = !isVid && !isGif; + + if (_selectedTypes.contains('video') && isVid) return true; + if (_selectedTypes.contains('gif') && isGif) return true; + if (_selectedTypes.contains('photo') && isPhoto) return true; + return false; + } + + Widget _buildMediaTypeFilters() { + final scheme = Theme.of(context).colorScheme; + final photoCount = _posts + .where((p) => !MediaUrlSelector.isVideo(p) && !MediaUrlSelector.isGif(p)) + .length; + final videoCount = _posts.where(MediaUrlSelector.isVideo).length; + final gifCount = _posts.where(MediaUrlSelector.isGif).length; + + return Padding( + padding: const EdgeInsets.fromLTRB(16, 6, 16, 6), + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + FilterChip( + selected: _selectedTypes.isEmpty, + label: Text('Все (${_posts.length})'), + onSelected: (_) => setState(() => _selectedTypes.clear()), + ), + const SizedBox(width: 8), + FilterChip( + selected: _selectedTypes.contains('photo'), + avatar: Icon(Icons.photo_outlined, + size: 16, + color: _selectedTypes.contains('photo') + ? scheme.onPrimaryContainer + : scheme.onSurfaceVariant), + label: Text('Фото ($photoCount)'), + onSelected: (selected) { + setState(() { + if (selected) { + _selectedTypes.add('photo'); + } else { + _selectedTypes.remove('photo'); + } + }); + }, + ), + const SizedBox(width: 8), + FilterChip( + selected: _selectedTypes.contains('video'), + avatar: Icon(Icons.videocam_outlined, + size: 16, + color: _selectedTypes.contains('video') + ? scheme.onPrimaryContainer + : scheme.onSurfaceVariant), + label: Text('Видео ($videoCount)'), + onSelected: (selected) { + setState(() { + if (selected) { + _selectedTypes.add('video'); + } else { + _selectedTypes.remove('video'); + } + }); + }, + ), + const SizedBox(width: 8), + FilterChip( + selected: _selectedTypes.contains('gif'), + avatar: Icon(Icons.gif_rounded, + size: 20, + color: _selectedTypes.contains('gif') + ? scheme.onPrimaryContainer + : scheme.onSurfaceVariant), + label: Text('GIF ($gifCount)'), + onSelected: (selected) { + setState(() { + if (selected) { + _selectedTypes.add('gif'); + } else { + _selectedTypes.remove('gif'); + } + }); + }, + ), + ], + ), + ), + ); + } + @override Widget build(BuildContext context) { final settings = @@ -145,6 +240,8 @@ class _ArtistPostsScreenState extends ConsumerState { final favoriteKeys = ref.watch(favoriteKeysProvider).value ?? {}; final viewedKeys = ref.watch(viewedKeysProvider).value ?? {}; + final displayedPosts = _posts.where(_matchesTypeFilter).toList(growable: false); + return AdaptiveScaffold( title: widget.artistName, actions: [ @@ -166,45 +263,80 @@ class _ArtistPostsScreenState extends ConsumerState { title: 'No works', message: 'This artist has no visible media yet.', ) - : PostMasonryGrid( - posts: _posts, - controller: _scrollController, - loading: _loading, - columns: Responsive.columnsFor( - context, - mobileColumns: settings.mobileColumns, - desktopColumns: settings.desktopColumns, - ), - blurExplicit: settings.blurExplicitContent, - showBadges: settings.showPostBadges, - nsfwEnabled: settings.nsfwEnabled, - mediaQualityMode: - MediaQualityMode.fromName(settings.mediaQualityMode), - favoriteKeys: favoriteKeys, - viewedKeys: viewedKeys, - onOpen: (post) => context.push( - '/post/${post.providerId}/${post.id}', - extra: PostNavigationContext( - currentPost: post, - posts: _posts, + : Column( + children: [ + _buildMediaTypeFilters(), + Expanded( + child: displayedPosts.isEmpty + ? Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.filter_alt_off_rounded, + size: 48), + const SizedBox(height: 12), + const Text( + 'Нет медиа по выбранным фильтрам', + style: TextStyle( + fontWeight: FontWeight.w600), + ), + const SizedBox(height: 8), + FilledButton.tonal( + onPressed: () => setState( + () => _selectedTypes.clear()), + child: const Text('Показать все'), + ), + ], + ), + ), + ) + : PostMasonryGrid( + posts: displayedPosts, + controller: _scrollController, + loading: _loading, + columns: Responsive.columnsFor( + context, + mobileColumns: settings.mobileColumns, + desktopColumns: settings.desktopColumns, + ), + blurExplicit: settings.blurExplicitContent, + showBadges: settings.showPostBadges, + nsfwEnabled: settings.nsfwEnabled, + mediaQualityMode: MediaQualityMode.fromName( + settings.mediaQualityMode), + favoriteKeys: favoriteKeys, + viewedKeys: viewedKeys, + onOpen: (post) => context.push( + '/post/${post.providerId}/${post.id}', + extra: PostNavigationContext( + currentPost: post, + posts: displayedPosts, + ), + ), + onFavorite: (post) async { + if (favoriteKeys.contains(post.cacheKey)) { + await ref + .read(favoriteServiceProvider) + .removeFavorite( + post.id, post.providerId); + } else { + await ref + .read(favoriteServiceProvider) + .addFavorite( + post, + settings: settings, + downloadManager: ref.read( + downloadManagerServiceProvider), + ); + } + ref.invalidate(favoriteKeysProvider); + ref.invalidate(favoritesControllerProvider); + }, + ), ), - ), - onFavorite: (post) async { - if (favoriteKeys.contains(post.cacheKey)) { - await ref - .read(favoriteServiceProvider) - .removeFavorite(post.id, post.providerId); - } else { - await ref.read(favoriteServiceProvider).addFavorite( - post, - settings: settings, - downloadManager: - ref.read(downloadManagerServiceProvider), - ); - } - ref.invalidate(favoriteKeysProvider); - ref.invalidate(favoritesControllerProvider); - }, + ], ), ); } diff --git a/lib/features/artists/presentation/artists_screen.dart b/lib/features/artists/presentation/artists_screen.dart index eee8e9f..323694f 100644 --- a/lib/features/artists/presentation/artists_screen.dart +++ b/lib/features/artists/presentation/artists_screen.dart @@ -34,6 +34,7 @@ class ArtistsScreen extends ConsumerStatefulWidget { class _ArtistsScreenState extends ConsumerState { final _search = TextEditingController(); final _scroll = ScrollController(); + final Set _selectedServices = {}; String? _providerId; var _page = 1; var _loading = false; @@ -145,16 +146,38 @@ class _ArtistsScreenState extends ConsumerState { WidgetsBinding.instance .addPostFrameCallback((_) => _refresh(items)); } + final displayedArtists = _selectedServices.isEmpty + ? _artists + : _artists + .where((a) => + _selectedServices.contains(a.service.toLowerCase())) + .toList(growable: false); + return Column( children: [ _ArtistsHeader( searchController: _search, providers: items, selectedProviderId: _providerId, + selectedServices: _selectedServices, onProviderChanged: (value) { setState(() => _providerId = value); _refresh(items); }, + onToggleService: (service) { + setState(() { + if (_selectedServices.contains(service)) { + _selectedServices.remove(service); + } else { + _selectedServices.add(service); + } + }); + _refresh(items); + }, + onClearServices: () { + setState(() => _selectedServices.clear()); + _refresh(items); + }, onSearch: () => _refresh(items), ), Expanded( @@ -163,10 +186,33 @@ class _ArtistsScreenState extends ConsumerState { message: _friendlyArtistError(_error), onRetry: () => _refresh(items), ) - : _artists.isEmpty && !_loading - ? const EmptyView( - title: 'No artists', - message: 'Try another name or provider.', + : displayedArtists.isEmpty && !_loading + ? Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.person_search_outlined, + size: 48), + const SizedBox(height: 12), + const Text( + 'Нет авторов по выбранным фильтрам', + style: + TextStyle(fontWeight: FontWeight.w600), + ), + const SizedBox(height: 8), + FilledButton.tonal( + onPressed: () { + setState( + () => _selectedServices.clear()); + _refresh(items); + }, + child: const Text('Показать все сервисы'), + ), + ], + ), + ), ) : LayoutBuilder( builder: (context, constraints) { @@ -189,12 +235,13 @@ class _ArtistsScreenState extends ConsumerState { mainAxisExtent: Responsive.isMobile(context) ? 104 : 116, ), - itemCount: _artists.length + (_loading ? 1 : 0), + itemCount: + displayedArtists.length + (_loading ? 1 : 0), itemBuilder: (context, index) { - if (index >= _artists.length) { + if (index >= displayedArtists.length) { return const _ArtistSkeletonCard(); } - final artist = _artists[index]; + final artist = displayedArtists[index]; final isFav = favoriteKeys.contains( '${artist.providerId}:${artist.service}:${artist.id}'); return _ArtistCard( @@ -250,6 +297,7 @@ class _ArtistsScreenState extends ConsumerState { ); final next = await provider.searchArtists( _search.text.trim(), + services: _selectedServices.isEmpty ? null : _selectedServices.toList(), page: _page, limit: 30, ); @@ -317,16 +365,41 @@ class _ArtistsHeader extends StatelessWidget { required this.searchController, required this.providers, required this.selectedProviderId, + required this.selectedServices, required this.onProviderChanged, + required this.onToggleService, + required this.onClearServices, required this.onSearch, }); final TextEditingController searchController; final List providers; final String? selectedProviderId; + final Set selectedServices; final ValueChanged onProviderChanged; + final ValueChanged onToggleService; + final VoidCallback onClearServices; final VoidCallback onSearch; + static const _availableServices = ['patreon', 'fanbox', 'fantia', 'boosty', 'discord']; + + String _formatServiceName(String s) { + switch (s.toLowerCase()) { + case 'patreon': + return 'Patreon'; + case 'fanbox': + return 'Pixiv Fanbox'; + case 'fantia': + return 'Fantia'; + case 'boosty': + return 'Boosty'; + case 'discord': + return 'Discord'; + default: + return s.isEmpty ? s : s[0].toUpperCase() + s.substring(1); + } + } + @override Widget build(BuildContext context) { final scheme = Theme.of(context).colorScheme; @@ -382,6 +455,29 @@ class _ArtistsHeader extends StatelessWidget { }, ), ), + const SizedBox(height: 8), + SizedBox( + height: 36, + child: ListView( + scrollDirection: Axis.horizontal, + children: [ + FilterChip( + selected: selectedServices.isEmpty, + label: const Text('Все платформы'), + onSelected: (_) => onClearServices(), + ), + const SizedBox(width: 8), + for (final service in _availableServices) ...[ + FilterChip( + selected: selectedServices.contains(service), + label: Text(_formatServiceName(service)), + onSelected: (_) => onToggleService(service), + ), + const SizedBox(width: 8), + ], + ], + ), + ), ], ), ), diff --git a/pubspec.yaml b/pubspec.yaml index 698b2b0..e86a194 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.3+25 +version: 2.0.4+26 environment: sdk: ">=3.3.0 <4.0.0"