feat: infinite artist pagination, favorite artists star modal, card layout polish and v2.0.3 release
This commit is contained in:
@@ -20,12 +20,12 @@ final artistPostsProvider =
|
||||
final provider = providers.data.firstWhere(
|
||||
(item) => (item as ContentProvider).id == query.providerId,
|
||||
);
|
||||
final posts = await provider.getArtistPosts(query: query, limit: 50);
|
||||
final posts = await provider.getArtistPosts(query: query, page: 0, limit: 50);
|
||||
await ref.read(cacheServiceProvider).cachePosts(posts);
|
||||
return posts;
|
||||
});
|
||||
|
||||
class ArtistPostsScreen extends ConsumerWidget {
|
||||
class ArtistPostsScreen extends ConsumerStatefulWidget {
|
||||
const ArtistPostsScreen({
|
||||
required this.providerId,
|
||||
required this.service,
|
||||
@@ -40,77 +40,172 @@ class ArtistPostsScreen extends ConsumerWidget {
|
||||
final String artistName;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
ConsumerState<ArtistPostsScreen> createState() => _ArtistPostsScreenState();
|
||||
}
|
||||
|
||||
class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
|
||||
final _scrollController = ScrollController();
|
||||
final List<Post> _posts = [];
|
||||
int _page = 0;
|
||||
bool _loading = false;
|
||||
bool _hasMore = true;
|
||||
Object? _error;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_scrollController.addListener(() {
|
||||
if (_scrollController.position.extentAfter < 600) {
|
||||
_loadMore();
|
||||
}
|
||||
});
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _loadMore());
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _refresh() async {
|
||||
setState(() {
|
||||
_posts.clear();
|
||||
_page = 0;
|
||||
_hasMore = true;
|
||||
_error = null;
|
||||
});
|
||||
await _loadMore();
|
||||
}
|
||||
|
||||
Future<void> _loadMore() async {
|
||||
if (_loading || !_hasMore) return;
|
||||
setState(() {
|
||||
_loading = true;
|
||||
_error = null;
|
||||
});
|
||||
|
||||
final query = ArtistWorkQuery(
|
||||
providerId: providerId,
|
||||
service: service,
|
||||
artistId: artistId,
|
||||
artistName: artistName,
|
||||
providerId: widget.providerId,
|
||||
service: widget.service,
|
||||
artistId: widget.artistId,
|
||||
artistName: widget.artistName,
|
||||
);
|
||||
final posts = ref.watch(artistPostsProvider(query));
|
||||
|
||||
try {
|
||||
final providers =
|
||||
await ref.read(providerManagerProvider).activeArtistProviders();
|
||||
if (providers is! Success<List<ArtistProvider>>) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_loading = false;
|
||||
_error = 'Could not load artist providers';
|
||||
});
|
||||
return;
|
||||
}
|
||||
final provider = providers.data.firstWhere(
|
||||
(item) => (item as ContentProvider).id == query.providerId,
|
||||
);
|
||||
final newPosts = await provider.getArtistPosts(
|
||||
query: query,
|
||||
page: _page,
|
||||
limit: 50,
|
||||
);
|
||||
await ref.read(cacheServiceProvider).cachePosts(newPosts);
|
||||
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_loading = false;
|
||||
if (newPosts.isEmpty) {
|
||||
_hasMore = false;
|
||||
} else {
|
||||
final existingKeys = _posts.map((p) => p.cacheKey).toSet();
|
||||
final uniqueNew =
|
||||
newPosts.where((p) => !existingKeys.contains(p.cacheKey)).toList();
|
||||
_posts.addAll(uniqueNew);
|
||||
_page++;
|
||||
if (newPosts.length < 10) {
|
||||
_hasMore = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_loading = false;
|
||||
_error = error;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final settings =
|
||||
ref.watch(appSettingsProvider).value ?? AppSettings.defaults;
|
||||
final favoriteKeys = ref.watch(favoriteKeysProvider).value ?? <String>{};
|
||||
final viewedKeys = ref.watch(viewedKeysProvider).value ?? <String>{};
|
||||
|
||||
return AdaptiveScaffold(
|
||||
title: artistName,
|
||||
title: widget.artistName,
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: 'Refresh',
|
||||
onPressed: () => ref.invalidate(artistPostsProvider(query)),
|
||||
onPressed: _refresh,
|
||||
icon: const Icon(Icons.refresh_rounded),
|
||||
),
|
||||
],
|
||||
body: posts.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (error, _) => ErrorView(
|
||||
message: _friendlyArtistPostError(error),
|
||||
onRetry: () => ref.invalidate(artistPostsProvider(query)),
|
||||
),
|
||||
data: (items) => items.isEmpty
|
||||
? const EmptyView(
|
||||
title: 'No works',
|
||||
message: 'This artist has no visible media yet.',
|
||||
)
|
||||
: PostMasonryGrid(
|
||||
posts: items,
|
||||
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: items,
|
||||
),
|
||||
),
|
||||
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);
|
||||
},
|
||||
),
|
||||
),
|
||||
body: _posts.isEmpty && _loading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _posts.isEmpty && _error != null
|
||||
? ErrorView(
|
||||
message: _friendlyArtistPostError(_error!),
|
||||
onRetry: _refresh,
|
||||
)
|
||||
: _posts.isEmpty
|
||||
? const EmptyView(
|
||||
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,
|
||||
),
|
||||
),
|
||||
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);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -107,16 +107,23 @@ class _ArtistsScreenState extends ConsumerState<ArtistsScreen> {
|
||||
.whereType<_FavoriteArtistItem>()
|
||||
.toList();
|
||||
final favoriteKeys = favoriteItems.map((e) => e.key).toSet();
|
||||
final selectedFav = favoriteItems.firstWhere(
|
||||
(e) => e.key == _selectedFavoriteArtistKey,
|
||||
orElse: () => favoriteItems.isNotEmpty
|
||||
? favoriteItems.first
|
||||
: const _FavoriteArtistItem.empty(),
|
||||
);
|
||||
|
||||
return AdaptiveScaffold(
|
||||
title: 'Artists',
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: 'Любимые авторы',
|
||||
icon: Badge(
|
||||
isLabelVisible: favoriteItems.isNotEmpty,
|
||||
label: Text('${favoriteItems.length}'),
|
||||
child: const Icon(Icons.star_rounded, color: Colors.amber),
|
||||
),
|
||||
onPressed: () => _showFavoriteArtistsModal(
|
||||
context,
|
||||
settings,
|
||||
favoriteItems,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Refresh',
|
||||
onPressed: () => _refresh(configs.valueOrNull ?? const []),
|
||||
@@ -150,32 +157,6 @@ class _ArtistsScreenState extends ConsumerState<ArtistsScreen> {
|
||||
},
|
||||
onSearch: () => _refresh(items),
|
||||
),
|
||||
_FavoriteArtistsSection(
|
||||
favorites: favoriteItems,
|
||||
selectedKey: selectedFav.isEmpty ? null : selectedFav.key,
|
||||
onSelect: (fav) =>
|
||||
setState(() => _selectedFavoriteArtistKey = fav.key),
|
||||
onOpenArtist: (fav) => context.push(
|
||||
'/artists/${fav.providerId}/${fav.service}/${fav.id}?name=${Uri.encodeComponent(fav.name)}',
|
||||
),
|
||||
onRemoveFavorite: (fav) async {
|
||||
final updated = List<String>.from(settings.favoriteArtists)
|
||||
..removeWhere((e) {
|
||||
try {
|
||||
return _FavoriteArtistItem.fromJson(
|
||||
jsonDecode(e) as Map<String, dynamic>)
|
||||
.key ==
|
||||
fav.key;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
await ref
|
||||
.read(settingsControllerProvider.notifier)
|
||||
.saveSettings(
|
||||
settings.copyWith(favoriteArtists: updated));
|
||||
},
|
||||
),
|
||||
Expanded(
|
||||
child: _error != null
|
||||
? ErrorView(
|
||||
@@ -206,7 +187,7 @@ class _ArtistsScreenState extends ConsumerState<ArtistsScreen> {
|
||||
crossAxisSpacing: 12,
|
||||
mainAxisSpacing: 12,
|
||||
mainAxisExtent:
|
||||
Responsive.isMobile(context) ? 88 : 104,
|
||||
Responsive.isMobile(context) ? 104 : 116,
|
||||
),
|
||||
itemCount: _artists.length + (_loading ? 1 : 0),
|
||||
itemBuilder: (context, index) {
|
||||
@@ -285,6 +266,50 @@ class _ArtistsScreenState extends ConsumerState<ArtistsScreen> {
|
||||
if (mounted) setState(() => _loading = false);
|
||||
}
|
||||
}
|
||||
|
||||
void _showFavoriteArtistsModal(
|
||||
BuildContext context,
|
||||
AppSettings settings,
|
||||
List<_FavoriteArtistItem> favorites,
|
||||
) {
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
useSafeArea: true,
|
||||
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
builder: (ctx) {
|
||||
return _FavoriteArtistsModal(
|
||||
favorites: favorites,
|
||||
initialSelectedKey: _selectedFavoriteArtistKey,
|
||||
onSelect: (fav) =>
|
||||
setState(() => _selectedFavoriteArtistKey = fav.key),
|
||||
onOpenArtist: (fav) => context.push(
|
||||
'/artists/${fav.providerId}/${fav.service}/${fav.id}?name=${Uri.encodeComponent(fav.name)}',
|
||||
),
|
||||
onRemoveFavorite: (fav) async {
|
||||
final updated = List<String>.from(settings.favoriteArtists)
|
||||
..removeWhere((e) {
|
||||
try {
|
||||
return _FavoriteArtistItem.fromJson(
|
||||
jsonDecode(e) as Map<String, dynamic>)
|
||||
.key ==
|
||||
fav.key;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
await ref
|
||||
.read(settingsControllerProvider.notifier)
|
||||
.saveSettings(settings.copyWith(favoriteArtists: updated));
|
||||
setState(() {});
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ArtistsHeader extends StatelessWidget {
|
||||
@@ -388,7 +413,7 @@ class _ArtistCard extends StatelessWidget {
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
child: Row(
|
||||
children: [
|
||||
_ArtistAvatar(artist: artist),
|
||||
@@ -492,6 +517,176 @@ class _FavoriteArtistItem {
|
||||
);
|
||||
}
|
||||
|
||||
class _FavoriteArtistsModal extends StatefulWidget {
|
||||
const _FavoriteArtistsModal({
|
||||
required this.favorites,
|
||||
required this.initialSelectedKey,
|
||||
required this.onSelect,
|
||||
required this.onOpenArtist,
|
||||
required this.onRemoveFavorite,
|
||||
});
|
||||
|
||||
final List<_FavoriteArtistItem> favorites;
|
||||
final String? initialSelectedKey;
|
||||
final ValueChanged<_FavoriteArtistItem> onSelect;
|
||||
final ValueChanged<_FavoriteArtistItem> onOpenArtist;
|
||||
final ValueChanged<_FavoriteArtistItem> onRemoveFavorite;
|
||||
|
||||
@override
|
||||
State<_FavoriteArtistsModal> createState() => _FavoriteArtistsModalState();
|
||||
}
|
||||
|
||||
class _FavoriteArtistsModalState extends State<_FavoriteArtistsModal> {
|
||||
late String? _selectedKey;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_selectedKey = widget.initialSelectedKey;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final selectedFav = widget.favorites.firstWhere(
|
||||
(e) => e.key == _selectedKey,
|
||||
orElse: () => widget.favorites.isNotEmpty
|
||||
? widget.favorites.first
|
||||
: const _FavoriteArtistItem.empty(),
|
||||
);
|
||||
|
||||
return DraggableScrollableSheet(
|
||||
expand: false,
|
||||
initialChildSize: widget.favorites.isEmpty ? 0.35 : 0.65,
|
||||
minChildSize: 0.25,
|
||||
maxChildSize: 0.92,
|
||||
builder: (context, scroll) {
|
||||
return SingleChildScrollView(
|
||||
controller: scroll,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Center(
|
||||
child: Container(
|
||||
width: 40,
|
||||
height: 4,
|
||||
margin: const EdgeInsets.symmetric(vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: scheme.onSurfaceVariant.withValues(alpha: 0.4),
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.star_rounded,
|
||||
color: Colors.amber, size: 26),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Любимые авторы',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
if (widget.favorites.isNotEmpty) ...[
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.amber.withValues(alpha: 0.2),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
'${widget.favorites.length}',
|
||||
style:
|
||||
Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: Colors.amber.shade900,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close_rounded),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
if (widget.favorites.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 36, 24, 40),
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.star_border_rounded,
|
||||
size: 52,
|
||||
color: scheme.onSurfaceVariant.withValues(alpha: 0.4),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Список пуст',
|
||||
style:
|
||||
Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Нажмите на иконку звёздочки ★ на карточке любого автора, чтобы добавить его в избранное и быстро смотреть свежие работы.',
|
||||
textAlign: TextAlign.center,
|
||||
style:
|
||||
Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
else ...[
|
||||
_FavoriteArtistsSection(
|
||||
favorites: widget.favorites,
|
||||
selectedKey: selectedFav.isEmpty ? null : selectedFav.key,
|
||||
onSelect: (fav) {
|
||||
setState(() => _selectedKey = fav.key);
|
||||
widget.onSelect(fav);
|
||||
},
|
||||
onOpenArtist: (fav) {
|
||||
Navigator.of(context).pop();
|
||||
widget.onOpenArtist(fav);
|
||||
},
|
||||
onRemoveFavorite: (fav) {
|
||||
widget.onRemoveFavorite(fav);
|
||||
if (_selectedKey == fav.key) {
|
||||
setState(() {
|
||||
_selectedKey = widget.favorites
|
||||
.where((e) => e.key != fav.key)
|
||||
.firstOrNull
|
||||
?.key;
|
||||
});
|
||||
}
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _FavoriteArtistsSection extends ConsumerWidget {
|
||||
const _FavoriteArtistsSection({
|
||||
required this.favorites,
|
||||
|
||||
Reference in New Issue
Block a user