feat: platform service selection and media type filters for artist posts (v2.0.4)
This commit is contained in:
@@ -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),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user