feat: add Favorite Artists collection

- Add CollectionArtistEntry model with toJson/fromJson and artistCollectionKey helper
- Create favorite_artists table in SQLite with DB migration v1→v2
- Implement toggleFavoriteArtist/removeFavoriteArtist in LibraryCollectionsNotifier
- Add FavoriteArtistsScreen with list view, empty state, and artist navigation
- Add heart toggle button on ArtistScreen header (reactive via Riverpod selector)
- Integrate favorite artists folder in queue_tab collection grid/list views
- Add 8 new localization strings across all 13 locale files
This commit is contained in:
zarzet
2026-05-02 00:50:02 +07:00
parent 148e5c1231
commit 64dbf4441c
21 changed files with 1112 additions and 2 deletions
+64
View File
@@ -10,6 +10,7 @@ import 'package:spotiflac_android/providers/extension_provider.dart';
import 'package:spotiflac_android/providers/track_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/providers/library_collections_provider.dart';
import 'package:spotiflac_android/providers/recent_access_provider.dart';
import 'package:spotiflac_android/providers/local_library_provider.dart';
import 'package:spotiflac_android/providers/playback_provider.dart';
@@ -912,6 +913,32 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
await _downloadAlbums(context, albums);
}
Future<void> _toggleFavoriteArtist(BuildContext context) async {
final providerId = _directMetadataProviderId();
final imageUrl =
_headerImageUrl ?? widget.headerImageUrl ?? widget.coverUrl;
final added = await ref
.read(libraryCollectionsProvider.notifier)
.toggleFavoriteArtist(
artistId: _metadataResourceId(providerId ?? ''),
providerId: providerId,
name: widget.artistName,
imageUrl: imageUrl,
);
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
added
? context.l10n.collectionAddedToFavoriteArtists(widget.artistName)
: context.l10n.collectionRemovedFromFavoriteArtists(
widget.artistName,
),
),
),
);
}
Future<void> _fetchAndQueueAlbums(
List<ArtistAlbum> albums,
String service,
@@ -1100,6 +1127,17 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
);
}
final favoriteProviderId = _directMetadataProviderId();
final favoriteArtistId = _metadataResourceId(favoriteProviderId ?? '');
final isFavoriteArtist = ref.watch(
libraryCollectionsProvider.select(
(state) => state.isFavoriteArtist(
artistId: favoriteArtistId,
providerId: favoriteProviderId,
),
),
);
return SliverAppBar(
expandedHeight: hasDiscography ? 420 : 380,
pinned: true,
@@ -1220,6 +1258,32 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
],
),
),
if (!_isSelectionMode) ...[
const SizedBox(width: 12),
Container(
width: 52,
height: 52,
decoration: const BoxDecoration(
color: Colors.white,
shape: BoxShape.circle,
),
child: IconButton(
onPressed: () => _toggleFavoriteArtist(context),
icon: Icon(
isFavoriteArtist
? Icons.favorite
: Icons.favorite_border,
size: 26,
),
color: isFavoriteArtist
? colorScheme.error
: Colors.black87,
tooltip: isFavoriteArtist
? context.l10n.artistOptionRemoveFromFavorites
: context.l10n.artistOptionAddToFavorites,
),
),
],
if (hasDiscography && !_isSelectionMode) ...[
const SizedBox(width: 12),
Container(
+197
View File
@@ -0,0 +1,197 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/providers/library_collections_provider.dart';
import 'package:spotiflac_android/screens/artist_screen.dart';
import 'package:spotiflac_android/services/cover_cache_manager.dart';
import 'package:spotiflac_android/utils/app_bar_layout.dart';
import 'package:spotiflac_android/widgets/animation_utils.dart';
class FavoriteArtistsScreen extends ConsumerWidget {
const FavoriteArtistsScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final artists = ref.watch(
libraryCollectionsProvider.select((state) => state.favoriteArtists),
);
final colorScheme = Theme.of(context).colorScheme;
final topPadding = normalizedHeaderTopPadding(context);
return Scaffold(
body: CustomScrollView(
slivers: [
SliverAppBar(
expandedHeight: 120 + topPadding,
collapsedHeight: kToolbarHeight,
floating: false,
pinned: true,
backgroundColor: colorScheme.surface,
surfaceTintColor: Colors.transparent,
leading: IconButton(
tooltip: MaterialLocalizations.of(context).backButtonTooltip,
icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.pop(context),
),
flexibleSpace: LayoutBuilder(
builder: (context, constraints) {
final maxHeight = 120 + topPadding;
final minHeight = kToolbarHeight + topPadding;
final expandRatio =
((constraints.maxHeight - minHeight) /
(maxHeight - minHeight))
.clamp(0.0, 1.0);
final leftPadding = 56 - (32 * expandRatio);
return FlexibleSpaceBar(
expandedTitleScale: 1.0,
titlePadding: EdgeInsets.only(left: leftPadding, bottom: 16),
title: Text(
context.l10n.collectionFavoriteArtists,
style: TextStyle(
fontSize: 20 + (8 * expandRatio),
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
),
);
},
),
),
if (artists.isEmpty)
SliverFillRemaining(
hasScrollBody: false,
child: Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.favorite,
size: 60,
color: colorScheme.onSurfaceVariant,
),
const SizedBox(height: 12),
Text(
context.l10n.collectionFavoriteArtistsEmptyTitle,
style: Theme.of(context).textTheme.titleMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
context.l10n.collectionFavoriteArtistsEmptySubtitle,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
),
)
else
SliverList.separated(
itemCount: artists.length,
separatorBuilder: (_, _) => const Divider(height: 1),
itemBuilder: (context, index) {
final artist = artists[index];
return ListTile(
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 4,
),
leading: _ArtistThumbnail(artist: artist),
title: Text(
artist.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle:
artist.providerId == null || artist.providerId!.isEmpty
? null
: Text(
artist.providerId!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
trailing: IconButton(
tooltip: context.l10n.artistOptionRemoveFromFavorites,
icon: Icon(Icons.favorite, color: colorScheme.error),
onPressed: () async {
await ref
.read(libraryCollectionsProvider.notifier)
.removeFavoriteArtist(artist.key);
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
context.l10n.collectionRemovedFromFavoriteArtists(
artist.name,
),
),
),
);
},
),
onTap: () {
Navigator.of(context).push(
slidePageRoute<void>(
page: ArtistScreen(
artistId: artist.artistId,
artistName: artist.name,
coverUrl: artist.imageUrl,
extensionId:
artist.providerId != null &&
artist.providerId!.isNotEmpty
? artist.providerId
: null,
),
),
);
},
);
},
),
],
),
);
}
}
class _ArtistThumbnail extends StatelessWidget {
final CollectionArtistEntry artist;
const _ArtistThumbnail({required this.artist});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final imageUrl = artist.imageUrl;
return ClipOval(
child: imageUrl != null && imageUrl.isNotEmpty
? CachedNetworkImage(
imageUrl: imageUrl,
width: 56,
height: 56,
fit: BoxFit.cover,
memCacheWidth: 112,
memCacheHeight: 112,
cacheManager: CoverCacheManager.instance,
errorWidget: (_, _, _) => _placeholder(colorScheme),
)
: _placeholder(colorScheme),
);
}
Widget _placeholder(ColorScheme colorScheme) {
return Container(
width: 56,
height: 56,
color: colorScheme.surfaceContainerHighest,
child: Icon(Icons.person, color: colorScheme.onSurfaceVariant),
);
}
}
+34
View File
@@ -27,6 +27,7 @@ import 'package:spotiflac_android/services/local_track_redownload_service.dart';
import 'package:spotiflac_android/services/history_database.dart';
import 'package:spotiflac_android/services/downloaded_embedded_cover_resolver.dart';
import 'package:spotiflac_android/screens/track_metadata_screen.dart';
import 'package:spotiflac_android/screens/favorite_artists_screen.dart';
import 'package:spotiflac_android/screens/downloaded_album_screen.dart';
import 'package:spotiflac_android/widgets/re_enrich_field_dialog.dart';
import 'package:spotiflac_android/widgets/batch_progress_dialog.dart';
@@ -2029,6 +2030,12 @@ class _QueueTabState extends ConsumerState<QueueTab> {
);
}
void _openFavoriteArtistsFolder() {
_navigateWithUnfocus(
MaterialPageRoute(builder: (_) => const FavoriteArtistsScreen()),
);
}
void _openPlaylistById(String playlistId) {
_navigateWithUnfocus(
MaterialPageRoute(
@@ -2315,6 +2322,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
(s) => (
s.wishlistCount,
s.lovedCount,
s.favoriteArtistCount,
s.playlistCount,
s.hasPlaylistTracks,
s.isLoaded,
@@ -2932,6 +2940,9 @@ class _QueueTabState extends ConsumerState<QueueTab> {
if (collectionState.lovedCount > 0) {
entries.add(_CollectionEntry.loved);
}
if (collectionState.favoriteArtistCount > 0) {
entries.add(_CollectionEntry.favoriteArtists);
}
for (var i = 0; i < collectionState.playlists.length; i++) {
entries.add(_CollectionEntry.playlist(i));
}
@@ -2968,6 +2979,17 @@ class _QueueTabState extends ConsumerState<QueueTab> {
count: collectionState.lovedCount,
onTap: _openLovedFolder,
);
case _CollectionEntryType.favoriteArtists:
return _buildCollectionGridItem(
context: context,
colorScheme: colorScheme,
icon: Icons.person,
iconColor: Colors.white,
iconBgColor: const Color(0xFFE91E63),
title: context.l10n.collectionFavoriteArtists,
count: collectionState.favoriteArtistCount,
onTap: _openFavoriteArtistsFolder,
);
case _CollectionEntryType.playlist:
final playlist = collectionState.playlists[entry.playlistIndex];
final isSelected = _selectedPlaylistIds.contains(playlist.id);
@@ -3087,6 +3109,18 @@ class _QueueTabState extends ConsumerState<QueueTab> {
'${context.l10n.collectionFoldersTitle}${collectionState.lovedCount} ${collectionState.lovedCount == 1 ? 'track' : 'tracks'}',
onTap: _openLovedFolder,
);
case _CollectionEntryType.favoriteArtists:
return _buildCollectionListItem(
context: context,
colorScheme: colorScheme,
icon: Icons.person,
iconColor: Colors.white,
iconBgColor: const Color(0xFFE91E63),
title: context.l10n.collectionFavoriteArtists,
subtitle:
'${context.l10n.collectionFoldersTitle}${context.l10n.collectionArtistCount(collectionState.favoriteArtistCount)}',
onTap: _openFavoriteArtistsFolder,
);
case _CollectionEntryType.playlist:
final playlist = collectionState.playlists[entry.playlistIndex];
final isSelected = _selectedPlaylistIds.contains(playlist.id);
+4 -1
View File
@@ -25,7 +25,7 @@ class _QueueItemSliverRow extends ConsumerWidget {
}
}
enum _CollectionEntryType { wishlist, loved, playlist }
enum _CollectionEntryType { wishlist, loved, favoriteArtists, playlist }
class _CollectionEntry {
final _CollectionEntryType type;
@@ -35,6 +35,9 @@ class _CollectionEntry {
static const wishlist = _CollectionEntry._(_CollectionEntryType.wishlist);
static const loved = _CollectionEntry._(_CollectionEntryType.loved);
static const favoriteArtists = _CollectionEntry._(
_CollectionEntryType.favoriteArtists,
);
static _CollectionEntry playlist(int index) =>
_CollectionEntry._(_CollectionEntryType.playlist, index);
}