feat: platform service selection and media type filters for artist posts (v2.0.4)

This commit is contained in:
2026-09-03 18:36:23 +03:00
parent 5ae802f89c
commit 1f624fc020
9 changed files with 327 additions and 87 deletions
@@ -46,6 +46,7 @@ class ArtistPostsScreen extends ConsumerStatefulWidget {
class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
final _scrollController = ScrollController();
final List<Post> _posts = [];
final Set<String> _selectedTypes = <String>{};
int _page = 0;
bool _loading = false;
bool _hasMore = true;
@@ -138,6 +139,100 @@ class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
}
}
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<ArtistPostsScreen> {
final favoriteKeys = ref.watch(favoriteKeysProvider).value ?? <String>{};
final viewedKeys = ref.watch(viewedKeysProvider).value ?? <String>{};
final displayedPosts = _posts.where(_matchesTypeFilter).toList(growable: false);
return AdaptiveScaffold(
title: widget.artistName,
actions: [
@@ -166,45 +263,80 @@ class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
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);
},
],
),
);
}
@@ -34,6 +34,7 @@ class ArtistsScreen extends ConsumerStatefulWidget {
class _ArtistsScreenState extends ConsumerState<ArtistsScreen> {
final _search = TextEditingController();
final _scroll = ScrollController();
final Set<String> _selectedServices = <String>{};
String? _providerId;
var _page = 1;
var _loading = false;
@@ -145,16 +146,38 @@ class _ArtistsScreenState extends ConsumerState<ArtistsScreen> {
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<ArtistsScreen> {
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<ArtistsScreen> {
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<ArtistsScreen> {
);
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<ContentProviderConfig> providers;
final String? selectedProviderId;
final Set<String> selectedServices;
final ValueChanged<String?> onProviderChanged;
final ValueChanged<String> 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),
],
],
),
),
],
),
),