mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-08-24 11:52:40 +02:00
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:
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user