diff --git a/CHANGELOG.md b/CHANGELOG.md index a2cbf055..71e18c34 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,34 @@ ### Added +- **Recent Access History**: Quick access to recently visited content when tapping the search bar + - Shows recently visited artists, albums, playlists, and downloaded tracks + - Merged view combining navigation history and download history + - Tap to quickly navigate back to previously accessed content + - X button to remove individual items from history + - "Clear All" button to clear entire history + - Persists across app restarts (stored in SharedPreferences) + - Max 20 items stored, sorted by most recent + - Multi-language support (Artist/Album/Song/Playlist labels localized) + +- **Artist Screen Redesign** + - Full-width header image (380px) with gradient overlay + - Artist name displayed at bottom of header with text shadow + - Monthly listeners count display (formatted with compact notation) + - "Popular" section showing top 5 tracks with download status indicators + - Dynamic download button states (queued, downloading, completed) + - Header image and top tracks fetched from extension metadata + - Image alignment set to top-center to show faces properly + +- **Extension Store Update Badge**: Badge indicator on Store tab icon showing number of available updates + - Users can see extension updates are available without opening Store tab + - Badge shows count of extensions with updates + +- **Extension Compatibility Warning**: Warning badge for extensions requiring newer app version + - Extensions with `minAppVersion` higher than current app show warning label + - Label displays "Requires vX.X.X+" to encourage users to upgrade + - Users can still install the extension (not blocked) + - **Year in Album Folder Name** ([#50](https://github.com/zarzet/SpotiFLAC-Mobile/issues/50)): New album folder structure options with release year - `Artist / [Year] Album`: Albums/Coldplay/[2005] X&Y/ @@ -23,11 +51,18 @@ - **Odesli (song.link) Integration for YouTube Music Extension** - New `enrichTrack()` function to fetch ISRC and external service links - - Uses Odesli API to convert YouTube Music tracks to Deezer/Tidal/Qobuz/Spotify + - Uses Odesli API to convert YouTube Music tracks to Deezer/Tidal/Qobuz - Enables built-in service fallback for high-quality audio downloads - Extension version updated to 1.4.0 with `api.song.link` and `odesli.io` network permissions - **Download Cancel**: Canceling a download now stops in-flight built-in provider downloads (Tidal/Qobuz/Amazon) and clears backend progress tracking. +### Changed + +- **Search Bar Behavior**: Tapping search bar now immediately moves it to top position + - Logo and subtitle hide when search bar is focused + - Recent access history appears in the content area below + - More space for recent items, not blocked by keyboard + ### Fixed - Fixed search source chips still referencing removed badge props. @@ -54,6 +89,9 @@ - Fixed search results mixing extension and built-in artists when using default provider. - Fixed audio files opening with non-music apps by passing audio MIME type on open. - Fixed album artist showing null/blank by normalizing empty metadata and using artist fallback for tags. +- Fixed `use_build_context_synchronously` lint warnings in `home_tab.dart` +- Fixed `unnecessary_underscores` lint warnings in error widget callbacks +- Fixed duplicate artist entries in recent history (recording now only happens in screen's initState) - **Go Backend: Missing `item_type` and `album_type` fields** - Added `ItemType` and `AlbumType` fields to `ExtTrackMetadata` struct - Fixed `CustomSearchWithExtensionJSON` - now includes `item_type` and `album_type` in response @@ -62,6 +100,36 @@ - Fixed `GetPlaylistWithExtensionJSON` - now includes `item_type` and `album_type` for playlist tracks - **Album/Playlist Track Thumbnails**: Tracks inside albums/playlists now use album/playlist cover as fallback when no individual cover exists - **YouTube Music Extension getArtist**: Fixed `getArtist()` function not being registered in extension, causing artist pages to fail with "returned null" error +- **Recent Access UI**: Fixed recent access list disappearing when keyboard is dismissed - now stays visible until user presses Back button +- **Extension Artist Top Tracks**: Fixed top tracks not appearing when opening artist from extension search results + - YT Music extension `getArtist()` now returns `top_tracks` array with up to 10 popular songs + - Go backend `GetArtistWithExtensionJSON` now forwards `top_tracks`, `header_image`, and `listeners` to Flutter + - `ExtensionArtistScreen` now parses and passes top tracks to `ArtistScreen` + - `ArtistScreen` with `extensionId` skips Spotify/Deezer fetch, uses extension data only (fixes "Rate Limited" errors) +- **Search Bar Unfocus**: Fixed search bar not unfocusing when tapping outside - now properly dismisses keyboard and unfocus when tapping anywhere outside the search field +- **Keyboard Appearing on Settings Navigation**: Fixed keyboard randomly appearing when returning from Settings sub-pages (e.g., Appearance) - now uses `FocusManager.instance.primaryFocus?.unfocus()` for more aggressive unfocus +- **Recent Access Artist Navigation**: Fixed opening artist from recent access using wrong screen - now correctly uses `ExtensionArtistScreen` for extension artists (YT Music, Spotify Web) instead of trying to fetch from Spotify API + +### Extensions + +- **YouTube Music Extension**: Updated to v1.5.0 + - `getArtist()` now returns `top_tracks` array with popular songs + - Added `header_image` and `listeners` to artist response +- **Spotify Web Extension**: Updated to v1.6.0 + +### Localization + +- **Multi-Language Support**: App now supports multiple languages with community contributions via Crowdin + - Available languages: English, Indonesian (Bahasa Indonesia) + - More languages coming soon with community translations + - Contribute translations at [Crowdin](https://crowdin.com/project/spotiflac-mobile) +- Added new localization strings for recent access types: + - `recentTypeArtist` - "Artist" / "Artis" + - `recentTypeAlbum` - "Album" / "Album" + - `recentTypeSong` - "Song" / "Lagu" + - `recentTypePlaylist` - "Playlist" / "Playlist" + - `recentPlaylistInfo` - "Playlist: {name}" + - `errorGeneric` - "Error: {message}" --- diff --git a/go_backend/exports.go b/go_backend/exports.go index 9c9c15ed..76972991 100644 --- a/go_backend/exports.go +++ b/go_backend/exports.go @@ -1657,10 +1657,12 @@ func HandleURLWithExtensionJSON(url string) (string, error) { // Add artist info if present if result.Artist != nil { artistResponse := map[string]interface{}{ - "id": result.Artist.ID, - "name": result.Artist.Name, - "image_url": result.Artist.ImageURL, - "provider_id": result.Artist.ProviderID, + "id": result.Artist.ID, + "name": result.Artist.Name, + "image_url": result.Artist.ImageURL, + "header_image": result.Artist.HeaderImage, + "listeners": result.Artist.Listeners, + "provider_id": result.Artist.ProviderID, } // Add albums if present @@ -1686,6 +1688,29 @@ func HandleURLWithExtensionJSON(url string) (string, error) { artistResponse["albums"] = albums } + // Add top tracks if present + if len(result.Artist.TopTracks) > 0 { + topTracks := make([]map[string]interface{}, len(result.Artist.TopTracks)) + for i, track := range result.Artist.TopTracks { + topTracks[i] = map[string]interface{}{ + "id": track.ID, + "name": track.Name, + "artists": track.Artists, + "album_name": track.AlbumName, + "album_artist": track.AlbumArtist, + "duration_ms": track.DurationMS, + "images": track.ResolvedCoverURL(), + "release_date": track.ReleaseDate, + "track_number": track.TrackNumber, + "disc_number": track.DiscNumber, + "isrc": track.ISRC, + "provider_id": track.ProviderID, + "spotify_id": track.SpotifyID, + } + } + artistResponse["top_tracks"] = topTracks + } + response["artist"] = artistResponse } @@ -1920,6 +1945,39 @@ func GetArtistWithExtensionJSON(extensionID, artistID string) (string, error) { "provider_id": artist.ProviderID, } + // Add header image if present + if artist.HeaderImage != "" { + response["header_image"] = artist.HeaderImage + } + + // Add listeners if present + if artist.Listeners > 0 { + response["listeners"] = artist.Listeners + } + + // Add top tracks if present + if len(artist.TopTracks) > 0 { + topTracks := make([]map[string]interface{}, len(artist.TopTracks)) + for i, track := range artist.TopTracks { + topTracks[i] = map[string]interface{}{ + "id": track.ID, + "name": track.Name, + "artists": track.Artists, + "album_name": track.AlbumName, + "album_artist": track.AlbumArtist, + "duration_ms": track.DurationMS, + "images": track.ResolvedCoverURL(), + "release_date": track.ReleaseDate, + "track_number": track.TrackNumber, + "disc_number": track.DiscNumber, + "isrc": track.ISRC, + "provider_id": track.ProviderID, + "spotify_id": track.SpotifyID, + } + } + response["top_tracks"] = topTracks + } + jsonBytes, err := json.Marshal(response) if err != nil { return "", err diff --git a/go_backend/extension_providers.go b/go_backend/extension_providers.go index 57939053..688bbf31 100644 --- a/go_backend/extension_providers.go +++ b/go_backend/extension_providers.go @@ -30,7 +30,7 @@ type ExtTrackMetadata struct { DiscNumber int `json:"disc_number,omitempty"` ISRC string `json:"isrc,omitempty"` ProviderID string `json:"provider_id"` - ItemType string `json:"item_type,omitempty"` // track, album, or playlist - for extension search results + ItemType string `json:"item_type,omitempty"` // track, album, or playlist - for extension search results AlbumType string `json:"album_type,omitempty"` // album, single, ep, compilation // Enrichment fields from Odesli/song.link TidalID string `json:"tidal_id,omitempty"` @@ -63,11 +63,14 @@ type ExtAlbumMetadata struct { // ExtArtistMetadata represents artist metadata from an extension type ExtArtistMetadata struct { - ID string `json:"id"` - Name string `json:"name"` - ImageURL string `json:"image_url,omitempty"` - Albums []ExtAlbumMetadata `json:"albums,omitempty"` - ProviderID string `json:"provider_id"` + ID string `json:"id"` + Name string `json:"name"` + ImageURL string `json:"image_url,omitempty"` + HeaderImage string `json:"header_image,omitempty"` // Header image for artist page background + Listeners int `json:"listeners,omitempty"` // Monthly listeners + Albums []ExtAlbumMetadata `json:"albums,omitempty"` + TopTracks []ExtTrackMetadata `json:"top_tracks,omitempty"` // Popular tracks + ProviderID string `json:"provider_id"` } // ExtSearchResult represents search results from an extension @@ -1252,6 +1255,10 @@ func (p *ExtensionProviderWrapper) HandleURL(url string) (*ExtURLHandleResult, e handleResult.Artist.Albums[i].Tracks[j].ProviderID = p.extension.ID } } + // Set provider ID on top tracks + for i := range handleResult.Artist.TopTracks { + handleResult.Artist.TopTracks[i].ProviderID = p.extension.ID + } } return &handleResult, nil diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 3cd163a3..2fafa4b0 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -995,6 +995,18 @@ abstract class AppLocalizations { /// **'{count, plural, =1{1 release} other{{count} releases}}'** String artistReleases(int count); + /// Section header for popular/top tracks + /// + /// In en, this message translates to: + /// **'Popular'** + String get artistPopular; + + /// Monthly listener count display + /// + /// In en, this message translates to: + /// **'{count} monthly listeners'** + String artistMonthlyListeners(String count); + /// Track metadata screen title /// /// In en, this message translates to: @@ -3598,6 +3610,42 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Utility Functions'** String get utilityFunctions; + + /// Recent access item type - artist + /// + /// In en, this message translates to: + /// **'Artist'** + String get recentTypeArtist; + + /// Recent access item type - album + /// + /// In en, this message translates to: + /// **'Album'** + String get recentTypeAlbum; + + /// Recent access item type - song/track + /// + /// In en, this message translates to: + /// **'Song'** + String get recentTypeSong; + + /// Recent access item type - playlist + /// + /// In en, this message translates to: + /// **'Playlist'** + String get recentTypePlaylist; + + /// Snackbar message when tapping playlist in recent access + /// + /// In en, this message translates to: + /// **'Playlist: {name}'** + String recentPlaylistInfo(String name); + + /// Generic error message format + /// + /// In en, this message translates to: + /// **'Error: {message}'** + String errorGeneric(String message); } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 9ed80cb2..06e66f17 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -513,6 +513,14 @@ class AppLocalizationsDe extends AppLocalizations { return '$_temp0'; } + @override + String get artistPopular => 'Popular'; + + @override + String artistMonthlyListeners(String count) { + return '$count monthly listeners'; + } + @override String get trackMetadataTitle => 'Track Info'; @@ -1976,4 +1984,26 @@ class AppLocalizationsDe extends AppLocalizations { @override String get utilityFunctions => 'Utility Functions'; + + @override + String get recentTypeArtist => 'Artist'; + + @override + String get recentTypeAlbum => 'Album'; + + @override + String get recentTypeSong => 'Song'; + + @override + String get recentTypePlaylist => 'Playlist'; + + @override + String recentPlaylistInfo(String name) { + return 'Playlist: $name'; + } + + @override + String errorGeneric(String message) { + return 'Error: $message'; + } } diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index edc750fc..48220eb7 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -513,6 +513,14 @@ class AppLocalizationsEn extends AppLocalizations { return '$_temp0'; } + @override + String get artistPopular => 'Popular'; + + @override + String artistMonthlyListeners(String count) { + return '$count monthly listeners'; + } + @override String get trackMetadataTitle => 'Track Info'; @@ -1976,4 +1984,26 @@ class AppLocalizationsEn extends AppLocalizations { @override String get utilityFunctions => 'Utility Functions'; + + @override + String get recentTypeArtist => 'Artist'; + + @override + String get recentTypeAlbum => 'Album'; + + @override + String get recentTypeSong => 'Song'; + + @override + String get recentTypePlaylist => 'Playlist'; + + @override + String recentPlaylistInfo(String name) { + return 'Playlist: $name'; + } + + @override + String errorGeneric(String message) { + return 'Error: $message'; + } } diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index ffd4d0e4..2ca999d0 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -513,6 +513,14 @@ class AppLocalizationsEs extends AppLocalizations { return '$_temp0'; } + @override + String get artistPopular => 'Popular'; + + @override + String artistMonthlyListeners(String count) { + return '$count monthly listeners'; + } + @override String get trackMetadataTitle => 'Track Info'; @@ -1976,4 +1984,26 @@ class AppLocalizationsEs extends AppLocalizations { @override String get utilityFunctions => 'Utility Functions'; + + @override + String get recentTypeArtist => 'Artist'; + + @override + String get recentTypeAlbum => 'Album'; + + @override + String get recentTypeSong => 'Song'; + + @override + String get recentTypePlaylist => 'Playlist'; + + @override + String recentPlaylistInfo(String name) { + return 'Playlist: $name'; + } + + @override + String errorGeneric(String message) { + return 'Error: $message'; + } } diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index 9ac7a9ce..3139492a 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -513,6 +513,14 @@ class AppLocalizationsFr extends AppLocalizations { return '$_temp0'; } + @override + String get artistPopular => 'Popular'; + + @override + String artistMonthlyListeners(String count) { + return '$count monthly listeners'; + } + @override String get trackMetadataTitle => 'Track Info'; @@ -1976,4 +1984,26 @@ class AppLocalizationsFr extends AppLocalizations { @override String get utilityFunctions => 'Utility Functions'; + + @override + String get recentTypeArtist => 'Artist'; + + @override + String get recentTypeAlbum => 'Album'; + + @override + String get recentTypeSong => 'Song'; + + @override + String get recentTypePlaylist => 'Playlist'; + + @override + String recentPlaylistInfo(String name) { + return 'Playlist: $name'; + } + + @override + String errorGeneric(String message) { + return 'Error: $message'; + } } diff --git a/lib/l10n/app_localizations_hi.dart b/lib/l10n/app_localizations_hi.dart index 9a872a1c..affa7609 100644 --- a/lib/l10n/app_localizations_hi.dart +++ b/lib/l10n/app_localizations_hi.dart @@ -513,6 +513,14 @@ class AppLocalizationsHi extends AppLocalizations { return '$_temp0'; } + @override + String get artistPopular => 'Popular'; + + @override + String artistMonthlyListeners(String count) { + return '$count monthly listeners'; + } + @override String get trackMetadataTitle => 'Track Info'; @@ -1976,4 +1984,26 @@ class AppLocalizationsHi extends AppLocalizations { @override String get utilityFunctions => 'Utility Functions'; + + @override + String get recentTypeArtist => 'Artist'; + + @override + String get recentTypeAlbum => 'Album'; + + @override + String get recentTypeSong => 'Song'; + + @override + String get recentTypePlaylist => 'Playlist'; + + @override + String recentPlaylistInfo(String name) { + return 'Playlist: $name'; + } + + @override + String errorGeneric(String message) { + return 'Error: $message'; + } } diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index 9df94cd8..5f13f1ab 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -518,6 +518,14 @@ class AppLocalizationsId extends AppLocalizations { return '$_temp0'; } + @override + String get artistPopular => 'Populer'; + + @override + String artistMonthlyListeners(String count) { + return '$count pendengar bulanan'; + } + @override String get trackMetadataTitle => 'Info Lagu'; @@ -1989,4 +1997,26 @@ class AppLocalizationsId extends AppLocalizations { @override String get utilityFunctions => 'Fungsi Utilitas'; + + @override + String get recentTypeArtist => 'Artis'; + + @override + String get recentTypeAlbum => 'Album'; + + @override + String get recentTypeSong => 'Lagu'; + + @override + String get recentTypePlaylist => 'Playlist'; + + @override + String recentPlaylistInfo(String name) { + return 'Playlist: $name'; + } + + @override + String errorGeneric(String message) { + return 'Error: $message'; + } } diff --git a/lib/l10n/app_localizations_ja.dart b/lib/l10n/app_localizations_ja.dart index e3b511ac..aaa2cc21 100644 --- a/lib/l10n/app_localizations_ja.dart +++ b/lib/l10n/app_localizations_ja.dart @@ -513,6 +513,14 @@ class AppLocalizationsJa extends AppLocalizations { return '$_temp0'; } + @override + String get artistPopular => 'Popular'; + + @override + String artistMonthlyListeners(String count) { + return '$count monthly listeners'; + } + @override String get trackMetadataTitle => 'Track Info'; @@ -1976,4 +1984,26 @@ class AppLocalizationsJa extends AppLocalizations { @override String get utilityFunctions => 'Utility Functions'; + + @override + String get recentTypeArtist => 'Artist'; + + @override + String get recentTypeAlbum => 'Album'; + + @override + String get recentTypeSong => 'Song'; + + @override + String get recentTypePlaylist => 'Playlist'; + + @override + String recentPlaylistInfo(String name) { + return 'Playlist: $name'; + } + + @override + String errorGeneric(String message) { + return 'Error: $message'; + } } diff --git a/lib/l10n/app_localizations_ko.dart b/lib/l10n/app_localizations_ko.dart index d0948473..9e8d4d22 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -513,6 +513,14 @@ class AppLocalizationsKo extends AppLocalizations { return '$_temp0'; } + @override + String get artistPopular => 'Popular'; + + @override + String artistMonthlyListeners(String count) { + return '$count monthly listeners'; + } + @override String get trackMetadataTitle => 'Track Info'; @@ -1976,4 +1984,26 @@ class AppLocalizationsKo extends AppLocalizations { @override String get utilityFunctions => 'Utility Functions'; + + @override + String get recentTypeArtist => 'Artist'; + + @override + String get recentTypeAlbum => 'Album'; + + @override + String get recentTypeSong => 'Song'; + + @override + String get recentTypePlaylist => 'Playlist'; + + @override + String recentPlaylistInfo(String name) { + return 'Playlist: $name'; + } + + @override + String errorGeneric(String message) { + return 'Error: $message'; + } } diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index da893b6f..6dea9681 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -513,6 +513,14 @@ class AppLocalizationsNl extends AppLocalizations { return '$_temp0'; } + @override + String get artistPopular => 'Popular'; + + @override + String artistMonthlyListeners(String count) { + return '$count monthly listeners'; + } + @override String get trackMetadataTitle => 'Track Info'; @@ -1976,4 +1984,26 @@ class AppLocalizationsNl extends AppLocalizations { @override String get utilityFunctions => 'Utility Functions'; + + @override + String get recentTypeArtist => 'Artist'; + + @override + String get recentTypeAlbum => 'Album'; + + @override + String get recentTypeSong => 'Song'; + + @override + String get recentTypePlaylist => 'Playlist'; + + @override + String recentPlaylistInfo(String name) { + return 'Playlist: $name'; + } + + @override + String errorGeneric(String message) { + return 'Error: $message'; + } } diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index 4e554a25..8b423485 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -513,6 +513,14 @@ class AppLocalizationsPt extends AppLocalizations { return '$_temp0'; } + @override + String get artistPopular => 'Popular'; + + @override + String artistMonthlyListeners(String count) { + return '$count monthly listeners'; + } + @override String get trackMetadataTitle => 'Track Info'; @@ -1976,4 +1984,26 @@ class AppLocalizationsPt extends AppLocalizations { @override String get utilityFunctions => 'Utility Functions'; + + @override + String get recentTypeArtist => 'Artist'; + + @override + String get recentTypeAlbum => 'Album'; + + @override + String get recentTypeSong => 'Song'; + + @override + String get recentTypePlaylist => 'Playlist'; + + @override + String recentPlaylistInfo(String name) { + return 'Playlist: $name'; + } + + @override + String errorGeneric(String message) { + return 'Error: $message'; + } } diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 38610c5f..dee18edc 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -513,6 +513,14 @@ class AppLocalizationsRu extends AppLocalizations { return '$_temp0'; } + @override + String get artistPopular => 'Popular'; + + @override + String artistMonthlyListeners(String count) { + return '$count monthly listeners'; + } + @override String get trackMetadataTitle => 'Track Info'; @@ -1976,4 +1984,26 @@ class AppLocalizationsRu extends AppLocalizations { @override String get utilityFunctions => 'Utility Functions'; + + @override + String get recentTypeArtist => 'Artist'; + + @override + String get recentTypeAlbum => 'Album'; + + @override + String get recentTypeSong => 'Song'; + + @override + String get recentTypePlaylist => 'Playlist'; + + @override + String recentPlaylistInfo(String name) { + return 'Playlist: $name'; + } + + @override + String errorGeneric(String message) { + return 'Error: $message'; + } } diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 8378c7ea..f8d7d4a4 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -513,6 +513,14 @@ class AppLocalizationsZh extends AppLocalizations { return '$_temp0'; } + @override + String get artistPopular => 'Popular'; + + @override + String artistMonthlyListeners(String count) { + return '$count monthly listeners'; + } + @override String get trackMetadataTitle => 'Track Info'; @@ -1976,6 +1984,28 @@ class AppLocalizationsZh extends AppLocalizations { @override String get utilityFunctions => 'Utility Functions'; + + @override + String get recentTypeArtist => 'Artist'; + + @override + String get recentTypeAlbum => 'Album'; + + @override + String get recentTypeSong => 'Song'; + + @override + String get recentTypePlaylist => 'Playlist'; + + @override + String recentPlaylistInfo(String name) { + return 'Playlist: $name'; + } + + @override + String errorGeneric(String message) { + return 'Error: $message'; + } } /// The translations for Chinese, as used in Taiwan (`zh_TW`). diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 45e244b5..f7b834cf 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -362,6 +362,15 @@ "count": {"type": "int"} } }, + "artistPopular": "Popular", + "@artistPopular": {"description": "Section header for popular/top tracks"}, + "artistMonthlyListeners": "{count} monthly listeners", + "@artistMonthlyListeners": { + "description": "Monthly listener count display", + "placeholders": { + "count": {"type": "String", "description": "Formatted listener count"} + } + }, "trackMetadataTitle": "Track Info", "@trackMetadataTitle": {"description": "Track metadata screen title"}, @@ -1459,5 +1468,29 @@ "@downloadedAlbumSelectToDelete": {"description": "Placeholder when nothing selected"}, "utilityFunctions": "Utility Functions", - "@utilityFunctions": {"description": "Extension capability - utility functions"} + "@utilityFunctions": {"description": "Extension capability - utility functions"}, + + "recentTypeArtist": "Artist", + "@recentTypeArtist": {"description": "Recent access item type - artist"}, + "recentTypeAlbum": "Album", + "@recentTypeAlbum": {"description": "Recent access item type - album"}, + "recentTypeSong": "Song", + "@recentTypeSong": {"description": "Recent access item type - song/track"}, + "recentTypePlaylist": "Playlist", + "@recentTypePlaylist": {"description": "Recent access item type - playlist"}, + + "recentPlaylistInfo": "Playlist: {name}", + "@recentPlaylistInfo": { + "description": "Snackbar message when tapping playlist in recent access", + "placeholders": { + "name": {"type": "String", "description": "Playlist name"} + } + }, + "errorGeneric": "Error: {message}", + "@errorGeneric": { + "description": "Generic error message format", + "placeholders": { + "message": {"type": "String", "description": "Error message"} + } + } } diff --git a/lib/l10n/arb/app_id.arb b/lib/l10n/arb/app_id.arb index 23837ca6..577f7a41 100644 --- a/lib/l10n/arb/app_id.arb +++ b/lib/l10n/arb/app_id.arb @@ -324,6 +324,8 @@ "artistReleases": "{count, plural, =1{1 rilis} other{{count} rilis}}", "artistCompilations": "Kompilasi", + "artistPopular": "Populer", + "artistMonthlyListeners": "{count} pendengar bulanan", "tracksHeader": "Lagu", "downloadAllCount": "Unduh Semua ({count})", @@ -667,5 +669,13 @@ "folderOrganizationByAlbum": "Berdasarkan Album", "folderOrganizationByAlbumSubtitle": "Folder terpisah untuk setiap album", "folderOrganizationByArtistAlbum": "Berdasarkan Artis & Album", - "folderOrganizationByArtistAlbumSubtitle": "Folder bersarang untuk artis dan album" + "folderOrganizationByArtistAlbumSubtitle": "Folder bersarang untuk artis dan album", + + "recentTypeArtist": "Artis", + "recentTypeAlbum": "Album", + "recentTypeSong": "Lagu", + "recentTypePlaylist": "Playlist", + + "recentPlaylistInfo": "Playlist: {name}", + "errorGeneric": "Error: {message}" } diff --git a/lib/l10n/supported_locales.dart b/lib/l10n/supported_locales.dart new file mode 100644 index 00000000..65a59b24 --- /dev/null +++ b/lib/l10n/supported_locales.dart @@ -0,0 +1,24 @@ +// GENERATED FILE - DO NOT EDIT +// Generated by: dart run tool/check_translations.dart 70 +// Only languages with >= 70% translation completion are included. +// Translation is measured by comparing VALUES (not just key existence). +// +// To regenerate, run: dart run tool/check_translations.dart 70 + +import 'package:flutter/widgets.dart'; + +/// Minimum translation completion threshold used to filter languages. +const int translationThreshold = 70; + +/// List of locales that meet the translation threshold. +/// Only these languages will be available in the app. +const List filteredSupportedLocales = [ + Locale('en'), + Locale('id'), +]; + +/// Set of locale codes for quick lookup. +const Set filteredLocaleCodes = { + 'en', + 'id', +}; diff --git a/lib/providers/recent_access_provider.dart b/lib/providers/recent_access_provider.dart new file mode 100644 index 00000000..0882b39f --- /dev/null +++ b/lib/providers/recent_access_provider.dart @@ -0,0 +1,248 @@ +import 'dart:convert'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +const _recentAccessKey = 'recent_access_history'; +const _maxRecentItems = 20; + +/// Types of items that can be accessed +enum RecentAccessType { + artist, + album, + track, + playlist, +} + +/// Represents a recently accessed item +class RecentAccessItem { + final String id; + final String name; + final String? subtitle; // Artist name for tracks/albums, null for artists + final String? imageUrl; + final RecentAccessType type; + final DateTime accessedAt; + final String? providerId; // Extension ID or 'deezer' for built-in + + const RecentAccessItem({ + required this.id, + required this.name, + this.subtitle, + this.imageUrl, + required this.type, + required this.accessedAt, + this.providerId, + }); + + Map toJson() => { + 'id': id, + 'name': name, + 'subtitle': subtitle, + 'imageUrl': imageUrl, + 'type': type.name, + 'accessedAt': accessedAt.toIso8601String(), + 'providerId': providerId, + }; + + factory RecentAccessItem.fromJson(Map json) { + return RecentAccessItem( + id: json['id'] as String, + name: json['name'] as String, + subtitle: json['subtitle'] as String?, + imageUrl: json['imageUrl'] as String?, + type: RecentAccessType.values.firstWhere( + (e) => e.name == json['type'], + orElse: () => RecentAccessType.track, + ), + accessedAt: DateTime.parse(json['accessedAt'] as String), + providerId: json['providerId'] as String?, + ); + } + + /// Create a unique key for deduplication + String get uniqueKey => '${type.name}:${providerId ?? 'default'}:$id'; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is RecentAccessItem && + runtimeType == other.runtimeType && + uniqueKey == other.uniqueKey; + + @override + int get hashCode => uniqueKey.hashCode; +} + +/// State for recent access history +class RecentAccessState { + final List items; + final bool isLoaded; + + const RecentAccessState({ + this.items = const [], + this.isLoaded = false, + }); + + RecentAccessState copyWith({ + List? items, + bool? isLoaded, + }) { + return RecentAccessState( + items: items ?? this.items, + isLoaded: isLoaded ?? this.isLoaded, + ); + } +} + +/// Provider for managing recent access history +class RecentAccessNotifier extends Notifier { + @override + RecentAccessState build() { + _loadHistory(); + return const RecentAccessState(); + } + + Future _loadHistory() async { + final prefs = await SharedPreferences.getInstance(); + final json = prefs.getString(_recentAccessKey); + if (json != null) { + try { + final List decoded = jsonDecode(json); + final items = decoded + .map((e) => RecentAccessItem.fromJson(e as Map)) + .toList(); + state = state.copyWith(items: items, isLoaded: true); + } catch (e) { + // Invalid JSON, start fresh + state = state.copyWith(isLoaded: true); + } + } else { + state = state.copyWith(isLoaded: true); + } + } + + Future _saveHistory() async { + final prefs = await SharedPreferences.getInstance(); + final json = jsonEncode(state.items.map((e) => e.toJson()).toList()); + await prefs.setString(_recentAccessKey, json); + } + + /// Record an access to an artist + void recordArtistAccess({ + required String id, + required String name, + String? imageUrl, + String? providerId, + }) { + _recordAccess(RecentAccessItem( + id: id, + name: name, + imageUrl: imageUrl, + type: RecentAccessType.artist, + accessedAt: DateTime.now(), + providerId: providerId, + )); + } + + /// Record an access to an album + void recordAlbumAccess({ + required String id, + required String name, + String? artistName, + String? imageUrl, + String? providerId, + }) { + _recordAccess(RecentAccessItem( + id: id, + name: name, + subtitle: artistName, + imageUrl: imageUrl, + type: RecentAccessType.album, + accessedAt: DateTime.now(), + providerId: providerId, + )); + } + + /// Record an access to a track + void recordTrackAccess({ + required String id, + required String name, + String? artistName, + String? imageUrl, + String? providerId, + }) { + _recordAccess(RecentAccessItem( + id: id, + name: name, + subtitle: artistName, + imageUrl: imageUrl, + type: RecentAccessType.track, + accessedAt: DateTime.now(), + providerId: providerId, + )); + } + + /// Record an access to a playlist + void recordPlaylistAccess({ + required String id, + required String name, + String? ownerName, + String? imageUrl, + String? providerId, + }) { + _recordAccess(RecentAccessItem( + id: id, + name: name, + subtitle: ownerName, + imageUrl: imageUrl, + type: RecentAccessType.playlist, + accessedAt: DateTime.now(), + providerId: providerId, + )); + } + + void _recordAccess(RecentAccessItem item) { + // Debug log + // ignore: avoid_print + print('[RecentAccess] Recording: ${item.type.name} - ${item.name} (${item.id})'); + + // Remove any existing entry with same unique key + final updatedItems = state.items + .where((e) => e.uniqueKey != item.uniqueKey) + .toList(); + + // Add new item at the beginning + updatedItems.insert(0, item); + + // Limit to max items + if (updatedItems.length > _maxRecentItems) { + updatedItems.removeRange(_maxRecentItems, updatedItems.length); + } + + state = state.copyWith(items: updatedItems); + _saveHistory(); + + // Debug log + // ignore: avoid_print + print('[RecentAccess] Total items now: ${updatedItems.length}'); + } + + /// Remove a specific item from history + void removeItem(RecentAccessItem item) { + final updatedItems = state.items + .where((e) => e.uniqueKey != item.uniqueKey) + .toList(); + state = state.copyWith(items: updatedItems); + _saveHistory(); + } + + /// Clear all history + void clearHistory() { + state = state.copyWith(items: []); + _saveHistory(); + } +} + +/// Provider instance +final recentAccessProvider = NotifierProvider( + RecentAccessNotifier.new, +); diff --git a/lib/providers/store_provider.dart b/lib/providers/store_provider.dart index eec5e7f6..fe067e5e 100644 --- a/lib/providers/store_provider.dart +++ b/lib/providers/store_provider.dart @@ -1,10 +1,29 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:spotiflac_android/constants/app_info.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/utils/logger.dart'; import 'package:spotiflac_android/providers/extension_provider.dart'; final _log = AppLogger('StoreProvider'); +/// Compare two semantic version strings +/// Returns: -1 if v1 < v2, 0 if equal, 1 if v1 > v2 +int compareVersions(String v1, String v2) { + final parts1 = v1.replaceAll(RegExp(r'^v'), '').split('.'); + final parts2 = v2.replaceAll(RegExp(r'^v'), '').split('.'); + + final maxLen = parts1.length > parts2.length ? parts1.length : parts2.length; + + for (var i = 0; i < maxLen; i++) { + final n1 = i < parts1.length ? (int.tryParse(parts1[i]) ?? 0) : 0; + final n2 = i < parts2.length ? (int.tryParse(parts2[i]) ?? 0) : 0; + + if (n1 < n2) return -1; + if (n1 > n2) return 1; + } + return 0; +} + /// Extension categories class StoreCategory { static const String metadata = 'metadata'; @@ -91,6 +110,12 @@ class StoreExtension { hasUpdate: json['has_update'] as bool? ?? false, ); } + + /// Check if this extension requires a higher app version than current + bool get requiresNewerApp { + if (minAppVersion == null || minAppVersion!.isEmpty) return false; + return compareVersions(minAppVersion!, AppInfo.version) > 0; + } } /// State for extension store @@ -161,6 +186,11 @@ class StoreState { return result; } + + /// Count of extensions with updates available + int get updatesAvailableCount { + return extensions.where((e) => e.hasUpdate).length; + } } /// Provider for managing extension store diff --git a/lib/providers/track_provider.dart b/lib/providers/track_provider.dart index 272a8dc8..83848520 100644 --- a/lib/providers/track_provider.dart +++ b/lib/providers/track_provider.dart @@ -17,9 +17,13 @@ class TrackState { final String? artistId; final String? artistName; final String? coverUrl; + final String? headerImageUrl; // Artist header image for background + final int? monthlyListeners; // Artist monthly listeners final List? artistAlbums; // For artist page + final List? artistTopTracks; // Artist's popular tracks final List? searchArtists; // For search results final bool hasSearchText; // For back button handling + final bool isShowingRecentAccess; // For recent access mode final String? searchExtensionId; // Extension ID used for current search results const TrackState({ @@ -32,9 +36,13 @@ class TrackState { this.artistId, this.artistName, this.coverUrl, + this.headerImageUrl, + this.monthlyListeners, this.artistAlbums, + this.artistTopTracks, this.searchArtists, this.hasSearchText = false, + this.isShowingRecentAccess = false, this.searchExtensionId, }); @@ -50,9 +58,13 @@ class TrackState { String? artistId, String? artistName, String? coverUrl, + String? headerImageUrl, + int? monthlyListeners, List? artistAlbums, + List? artistTopTracks, List? searchArtists, bool? hasSearchText, + bool? isShowingRecentAccess, String? searchExtensionId, }) { return TrackState( @@ -65,9 +77,13 @@ class TrackState { artistId: artistId ?? this.artistId, artistName: artistName ?? this.artistName, coverUrl: coverUrl ?? this.coverUrl, + headerImageUrl: headerImageUrl ?? this.headerImageUrl, + monthlyListeners: monthlyListeners ?? this.monthlyListeners, artistAlbums: artistAlbums ?? this.artistAlbums, + artistTopTracks: artistTopTracks ?? this.artistTopTracks, searchArtists: searchArtists ?? this.searchArtists, hasSearchText: hasSearchText ?? this.hasSearchText, + isShowingRecentAccess: isShowingRecentAccess ?? this.isShowingRecentAccess, searchExtensionId: searchExtensionId, ); } @@ -171,13 +187,21 @@ class TrackNotifier extends Notifier { final artistData = result['artist'] as Map; final albumsList = artistData['albums'] as List? ?? []; final albums = albumsList.map((a) => _parseArtistAlbum(a as Map)).toList(); + + // Parse top tracks if available + final topTracksList = artistData['top_tracks'] as List? ?? []; + final topTracks = topTracksList.map((t) => _parseSearchTrack(t as Map, source: extensionId)).toList(); + state = TrackState( tracks: [], isLoading: false, artistId: artistData['id'] as String?, artistName: artistData['name'] as String?, coverUrl: artistData['image_url'] as String? ?? artistData['images'] as String?, + headerImageUrl: artistData['header_image'] as String?, + monthlyListeners: artistData['listeners'] as int?, artistAlbums: albums, + artistTopTracks: topTracks.isNotEmpty ? topTracks : null, searchExtensionId: extensionId, ); return; @@ -491,6 +515,11 @@ class TrackNotifier extends Notifier { state = state.copyWith(hasSearchText: hasText); } + /// Set recent access mode state + void setShowingRecentAccess(bool showing) { + state = state.copyWith(isShowingRecentAccess: showing); + } + /// Set tracks from a collection (album/playlist) opened from search results void setTracksFromCollection({ required List tracks, diff --git a/lib/screens/album_screen.dart b/lib/screens/album_screen.dart index f221e796..c58f7b78 100644 --- a/lib/screens/album_screen.dart +++ b/lib/screens/album_screen.dart @@ -7,6 +7,7 @@ import 'package:spotiflac_android/models/track.dart'; import 'package:spotiflac_android/models/download_item.dart'; import 'package:spotiflac_android/providers/download_queue_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; +import 'package:spotiflac_android/providers/recent_access_provider.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/widgets/download_service_picker.dart'; @@ -63,6 +64,19 @@ class _AlbumScreenState extends ConsumerState { @override void initState() { super.initState(); + + // Record access for recent history + WidgetsBinding.instance.addPostFrameCallback((_) { + final providerId = widget.albumId.startsWith('deezer:') ? 'deezer' : 'spotify'; + ref.read(recentAccessProvider.notifier).recordAlbumAccess( + id: widget.albumId, + name: widget.albumName, + artistName: widget.tracks?.firstOrNull?.artistName, + imageUrl: widget.coverUrl, + providerId: providerId, + ); + }); + // Priority: widget.tracks > cache > fetch _tracks = widget.tracks ?? _AlbumCache.get(widget.albumId); if (_tracks == null) { diff --git a/lib/screens/artist_screen.dart b/lib/screens/artist_screen.dart index f60b162b..d16b5008 100644 --- a/lib/screens/artist_screen.dart +++ b/lib/screens/artist_screen.dart @@ -1,52 +1,87 @@ +import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:cached_network_image/cached_network_image.dart'; +import 'package:intl/intl.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; +import 'package:spotiflac_android/models/track.dart'; +import 'package:spotiflac_android/models/download_item.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/recent_access_provider.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/screens/album_screen.dart'; import 'package:spotiflac_android/screens/home_tab.dart' show ExtensionAlbumScreen; -/// Simple in-memory cache for artist discography +/// Simple in-memory cache for artist data class _ArtistCache { static final Map _cache = {}; static const Duration _ttl = Duration(minutes: 10); - static List? get(String artistId) { + static _CacheEntry? get(String artistId) { final entry = _cache[artistId]; if (entry == null) return null; if (DateTime.now().isAfter(entry.expiresAt)) { _cache.remove(artistId); return null; } - return entry.albums; + return entry; } - static void set(String artistId, List albums) { - _cache[artistId] = _CacheEntry(albums, DateTime.now().add(_ttl)); + static void set(String artistId, { + required List albums, + List? topTracks, + String? headerImageUrl, + int? monthlyListeners, + }) { + _cache[artistId] = _CacheEntry( + albums: albums, + topTracks: topTracks, + headerImageUrl: headerImageUrl, + monthlyListeners: monthlyListeners, + expiresAt: DateTime.now().add(_ttl), + ); } } class _CacheEntry { final List albums; + final List? topTracks; + final String? headerImageUrl; + final int? monthlyListeners; final DateTime expiresAt; - _CacheEntry(this.albums, this.expiresAt); + + _CacheEntry({ + required this.albums, + this.topTracks, + this.headerImageUrl, + this.monthlyListeners, + required this.expiresAt, + }); } -/// Artist screen with Material Expressive 3 design - shows discography +/// Artist screen with Spotify-like design class ArtistScreen extends ConsumerStatefulWidget { final String artistId; final String artistName; final String? coverUrl; - final List? albums; // Optional - will fetch if null + final String? headerImageUrl; + final int? monthlyListeners; + final List? albums; + final List? topTracks; + final String? extensionId; // If set, skip fetching from Spotify/Deezer const ArtistScreen({ super.key, required this.artistId, required this.artistName, this.coverUrl, + this.headerImageUrl, + this.monthlyListeners, this.albums, + this.topTracks, + this.extensionId, }); @override @@ -56,14 +91,62 @@ class ArtistScreen extends ConsumerStatefulWidget { class _ArtistScreenState extends ConsumerState { bool _isLoadingDiscography = false; List? _albums; + List? _topTracks; + String? _headerImageUrl; + int? _monthlyListeners; String? _error; @override void initState() { super.initState(); - // Priority: widget.albums > cache > fetch - _albums = widget.albums ?? _ArtistCache.get(widget.artistId); - if (_albums == null) { + + // Record access for recent history + WidgetsBinding.instance.addPostFrameCallback((_) { + final providerId = widget.extensionId ?? + (widget.artistId.startsWith('deezer:') ? 'deezer' : 'spotify'); + ref.read(recentAccessProvider.notifier).recordArtistAccess( + id: widget.artistId, + name: widget.artistName, + imageUrl: widget.coverUrl, + providerId: providerId, + ); + }); + + // If this is an extension artist, use provided data only - don't fetch from Spotify/Deezer + if (widget.extensionId != null) { + _albums = widget.albums; + _topTracks = widget.topTracks; + _headerImageUrl = widget.headerImageUrl; + _monthlyListeners = widget.monthlyListeners; + // Extension artists don't need additional fetching + return; + } + + // Priority: widget data > cache > fetch + // But always fetch if topTracks is missing (to get popular tracks) + final cached = _ArtistCache.get(widget.artistId); + + if (widget.albums != null) { + _albums = widget.albums; + _topTracks = widget.topTracks; + _headerImageUrl = widget.headerImageUrl; + _monthlyListeners = widget.monthlyListeners; + + // If we have albums but no top tracks, fetch to get them + if (_topTracks == null || _topTracks!.isEmpty) { + _fetchDiscography(); + } + } else if (cached != null) { + _albums = cached.albums; + _topTracks = cached.topTracks; + _headerImageUrl = cached.headerImageUrl; + _monthlyListeners = cached.monthlyListeners; + + // If cache has no top tracks, fetch + if (_topTracks == null || _topTracks!.isEmpty) { + _fetchDiscography(); + } + } else { _fetchDiscography(); } } @@ -72,31 +155,60 @@ class _ArtistScreenState extends ConsumerState { setState(() => _isLoadingDiscography = true); try { List albums; + List? topTracks; + String? headerImage; + int? listeners; // Check if this is a Deezer artist ID (format: "deezer:123456") if (widget.artistId.startsWith('deezer:')) { final deezerArtistId = widget.artistId.replaceFirst('deezer:', ''); - // ignore: avoid_print - print('[ArtistScreen] Fetching from Deezer: $deezerArtistId'); final metadata = await PlatformBridge.getDeezerMetadata('artist', deezerArtistId); final albumsList = metadata['albums'] as List; albums = albumsList.map((a) => _parseArtistAlbum(a as Map)).toList(); } else { - // Spotify artist - use fallback method - // ignore: avoid_print - print('[ArtistScreen] Fetching from Spotify with fallback: ${widget.artistId}'); + // Spotify artist - use extension handler via URL final url = 'https://open.spotify.com/artist/${widget.artistId}'; - final metadata = await PlatformBridge.getSpotifyMetadataWithFallback(url); - final albumsList = metadata['albums'] as List; - albums = albumsList.map((a) => _parseArtistAlbum(a as Map)).toList(); + final result = await PlatformBridge.handleURLWithExtension(url); + + if (result != null && result['artist'] != null) { + final artistData = result['artist'] as Map; + final albumsList = artistData['albums'] as List? ?? []; + albums = albumsList.map((a) => _parseArtistAlbum(a as Map)).toList(); + + // Parse top tracks if available + final topTracksList = artistData['top_tracks'] as List? ?? []; + if (topTracksList.isNotEmpty) { + topTracks = topTracksList.map((t) => _parseTrack(t as Map)).toList(); + } + + headerImage = artistData['header_image'] as String?; + listeners = artistData['listeners'] as int?; + } else { + // Fallback to Spotify API metadata + final metadata = await PlatformBridge.getSpotifyMetadataWithFallback(url); + final albumsList = metadata['albums'] as List; + albums = albumsList.map((a) => _parseArtistAlbum(a as Map)).toList(); + } } - // Store in cache - _ArtistCache.set(widget.artistId, albums); + // Store in cache (preserve existing values if new ones are null) + final finalHeaderImage = headerImage ?? _headerImageUrl ?? widget.headerImageUrl; + final finalListeners = listeners ?? _monthlyListeners ?? widget.monthlyListeners; + + _ArtistCache.set( + widget.artistId, + albums: albums, + topTracks: topTracks, + headerImageUrl: finalHeaderImage, + monthlyListeners: finalListeners, + ); if (mounted) { setState(() { _albums = albums; + _topTracks = topTracks; + _headerImageUrl = finalHeaderImage; + _monthlyListeners = finalListeners; _isLoadingDiscography = false; }); } @@ -110,15 +222,41 @@ class _ArtistScreenState extends ConsumerState { } } + Track _parseTrack(Map data) { + int durationMs = 0; + final durationValue = data['duration_ms']; + if (durationValue is int) { + durationMs = durationValue; + } else if (durationValue is double) { + durationMs = durationValue.toInt(); + } + + return Track( + id: (data['spotify_id'] ?? data['id'] ?? '').toString(), + name: (data['name'] ?? '').toString(), + artistName: (data['artists'] ?? data['artist'] ?? '').toString(), + albumName: (data['album_name'] ?? data['album'] ?? '').toString(), + albumArtist: data['album_artist']?.toString(), + coverUrl: (data['cover_url'] ?? data['images'])?.toString(), + isrc: data['isrc']?.toString(), + duration: (durationMs / 1000).round(), + trackNumber: data['track_number'] as int?, + discNumber: data['disc_number'] as int?, + releaseDate: data['release_date']?.toString(), + source: data['provider_id']?.toString(), + ); + } + ArtistAlbum _parseArtistAlbum(Map data) { return ArtistAlbum( id: data['id'] as String? ?? '', name: data['name'] as String? ?? '', releaseDate: data['release_date'] as String? ?? '', totalTracks: data['total_tracks'] as int? ?? 0, - coverUrl: data['images'] as String?, + coverUrl: (data['cover_url'] ?? data['images'])?.toString(), albumType: data['album_type'] as String? ?? 'album', artists: data['artists'] as String? ?? '', + providerId: data['provider_id']?.toString(), ); } @@ -131,43 +269,63 @@ class _ArtistScreenState extends ConsumerState { final compilations = albums.where((a) => a.albumType == 'compilation').toList(); return Scaffold( - body: Stack( - children: [ - CustomScrollView( - slivers: [ - _buildAppBar(context, colorScheme), - _buildInfoCard(context, colorScheme), - if (_isLoadingDiscography) - const SliverToBoxAdapter(child: Padding( - padding: EdgeInsets.all(32), - child: Center(child: CircularProgressIndicator()), - )), - if (_error != null) - SliverToBoxAdapter(child: Padding( - padding: const EdgeInsets.all(16), - child: _buildErrorWidget(_error!, colorScheme), - )), - if (!_isLoadingDiscography && _error == null) ...[ - if (albumsOnly.isNotEmpty) SliverToBoxAdapter(child: _buildAlbumSection(context.l10n.artistAlbums, albumsOnly, colorScheme)), - if (singles.isNotEmpty) SliverToBoxAdapter(child: _buildAlbumSection(context.l10n.artistSingles, singles, colorScheme)), - if (compilations.isNotEmpty) SliverToBoxAdapter(child: _buildAlbumSection(context.l10n.artistCompilations, compilations, colorScheme)), - ], - const SliverToBoxAdapter(child: SizedBox(height: 32)), - ], - ), + body: CustomScrollView( + slivers: [ + _buildHeader(context, colorScheme), + if (_isLoadingDiscography) + const SliverToBoxAdapter(child: Padding( + padding: EdgeInsets.all(32), + child: Center(child: CircularProgressIndicator()), + )), + if (_error != null) + SliverToBoxAdapter(child: Padding( + padding: const EdgeInsets.all(16), + child: _buildErrorWidget(_error!, colorScheme), + )), + if (!_isLoadingDiscography && _error == null) ...[ + // Popular tracks section + if (_topTracks != null && _topTracks!.isNotEmpty) + SliverToBoxAdapter(child: _buildPopularSection(colorScheme)), + // Discography sections + if (albumsOnly.isNotEmpty) + SliverToBoxAdapter(child: _buildAlbumSection(context.l10n.artistAlbums, albumsOnly, colorScheme)), + if (singles.isNotEmpty) + SliverToBoxAdapter(child: _buildAlbumSection(context.l10n.artistSingles, singles, colorScheme)), + if (compilations.isNotEmpty) + SliverToBoxAdapter(child: _buildAlbumSection(context.l10n.artistCompilations, compilations, colorScheme)), + ], + const SliverToBoxAdapter(child: SizedBox(height: 32)), ], ), ); } - Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) { - // Validate image URL - must be non-null, non-empty, and have a valid host - final hasValidImage = widget.coverUrl != null && - widget.coverUrl!.isNotEmpty && - Uri.tryParse(widget.coverUrl!)?.hasAuthority == true; + /// Build Spotify-style header with full-width image and artist name overlay + Widget _buildHeader(BuildContext context, ColorScheme colorScheme) { + // Use header image if available, otherwise fall back to cover URL + // Prefer: fetched header > widget header > widget cover + String? imageUrl = _headerImageUrl; + if (imageUrl == null || imageUrl.isEmpty) { + imageUrl = widget.headerImageUrl; + } + if (imageUrl == null || imageUrl.isEmpty) { + imageUrl = widget.coverUrl; + } + + final hasValidImage = imageUrl != null && + imageUrl.isNotEmpty && + Uri.tryParse(imageUrl)?.hasAuthority == true; + + // Format monthly listeners + String? listenersText; + final listeners = _monthlyListeners ?? widget.monthlyListeners; + if (listeners != null && listeners > 0) { + final formatter = NumberFormat.compact(); + listenersText = context.l10n.artistMonthlyListeners(formatter.format(listeners)); + } return SliverAppBar( - expandedHeight: 280, + expandedHeight: 380, pinned: true, stretch: true, backgroundColor: colorScheme.surface, @@ -176,49 +334,84 @@ class _ArtistScreenState extends ConsumerState { background: Stack( fit: StackFit.expand, children: [ + // Background image - full width, no circular crop if (hasValidImage) CachedNetworkImage( - imageUrl: widget.coverUrl!, - fit: BoxFit.cover, - color: Colors.black.withValues(alpha: 0.5), - colorBlendMode: BlendMode.darken, - memCacheWidth: 600, - errorWidget: (context, url, error) => Container(color: colorScheme.surfaceContainerHighest), + imageUrl: imageUrl, + fit: BoxFit.cover, + alignment: Alignment.topCenter, // Show top of image (faces) + memCacheWidth: 800, + placeholder: (context, url) => Container( + color: colorScheme.surfaceContainerHighest, + ), + errorWidget: (context, url, error) => Container( + color: colorScheme.surfaceContainerHighest, + child: Icon(Icons.person, size: 80, color: colorScheme.onSurfaceVariant), + ), + ) + else + Container( + color: colorScheme.surfaceContainerHighest, + child: Icon(Icons.person, size: 80, color: colorScheme.onSurfaceVariant), ), + // Gradient overlay for text readability Container( decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, - colors: [Colors.transparent, colorScheme.surface.withValues(alpha: 0.8), colorScheme.surface], - stops: const [0.0, 0.7, 1.0], + colors: [ + Colors.transparent, + Colors.black.withValues(alpha: 0.3), + Colors.black.withValues(alpha: 0.7), + colorScheme.surface, + ], + stops: const [0.0, 0.5, 0.75, 1.0], ), ), ), - Center( - child: Padding( - padding: const EdgeInsets.only(top: 60), - child: Container( - width: 140, - height: 140, - decoration: BoxDecoration( - shape: BoxShape.circle, - boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.3), blurRadius: 20, offset: const Offset(0, 10))], + // Artist name and listeners at bottom + Positioned( + left: 16, + right: 16, + bottom: 16, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + widget.artistName, + style: Theme.of(context).textTheme.headlineLarge?.copyWith( + fontWeight: FontWeight.bold, + color: Colors.white, + shadows: [ + Shadow( + offset: const Offset(0, 1), + blurRadius: 4, + color: Colors.black.withValues(alpha: 0.5), + ), + ], + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, ), - child: ClipOval( - child: hasValidImage - ? CachedNetworkImage( - imageUrl: widget.coverUrl!, - fit: BoxFit.cover, - memCacheWidth: 280, - errorWidget: (context, url, error) => Container( - color: colorScheme.surfaceContainerHighest, - child: Icon(Icons.person, size: 48, color: colorScheme.onSurfaceVariant), - ), - ) - : Container(color: colorScheme.surfaceContainerHighest, child: Icon(Icons.person, size: 48, color: colorScheme.onSurfaceVariant)), - ), - ), + if (listenersText != null) ...[ + const SizedBox(height: 4), + Text( + listenersText, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Colors.white.withValues(alpha: 0.8), + shadows: [ + Shadow( + offset: const Offset(0, 1), + blurRadius: 2, + color: Colors.black.withValues(alpha: 0.5), + ), + ], + ), + ), + ], + ], ), ), ], @@ -226,44 +419,280 @@ class _ArtistScreenState extends ConsumerState { stretchModes: const [StretchMode.zoomBackground, StretchMode.blurBackground], ), leading: IconButton( - icon: Container(padding: const EdgeInsets.all(8), decoration: BoxDecoration(color: colorScheme.surface.withValues(alpha: 0.8), shape: BoxShape.circle), child: Icon(Icons.arrow_back, color: colorScheme.onSurface)), + icon: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.4), + shape: BoxShape.circle, + ), + child: const Icon(Icons.arrow_back, color: Colors.white), + ), onPressed: () => Navigator.pop(context), ), ); } - Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme) { - return SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.all(16), - child: Card( - elevation: 0, - color: colorScheme.surfaceContainerLow, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), - child: Padding( - padding: const EdgeInsets.all(20), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(widget.artistName, style: Theme.of(context).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold, color: colorScheme.onSurface)), - const SizedBox(height: 8), - if (_albums != null) - Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), - decoration: BoxDecoration(color: colorScheme.primaryContainer, borderRadius: BorderRadius.circular(20)), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.album, size: 14, color: colorScheme.onPrimaryContainer), - const SizedBox(width: 4), - Text(context.l10n.artistReleases(_albums!.length), style: TextStyle(color: colorScheme.onPrimaryContainer, fontWeight: FontWeight.w600, fontSize: 12)), - ], - ), - ), - ], + /// Build Popular tracks section like Spotify + Widget _buildPopularSection(ColorScheme colorScheme) { + if (_topTracks == null || _topTracks!.isEmpty) return const SizedBox.shrink(); + + // Show max 5 tracks + final tracks = _topTracks!.take(5).toList(); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 24, 16, 12), + child: Text( + context.l10n.artistPopular, + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, ), ), ), + ...tracks.asMap().entries.map((entry) { + final index = entry.key; + final track = entry.value; + return _buildPopularTrackItem(index + 1, track, colorScheme); + }), + ], + ); + } + + /// Build a single popular track item with dynamic download status + Widget _buildPopularTrackItem(int rank, Track track, ColorScheme colorScheme) { + // Watch download queue for this track's status + final queueItem = ref.watch(downloadQueueProvider.select((state) { + return state.items.where((item) => item.track.id == track.id).firstOrNull; + })); + + // Check if track is in history (already downloaded before) + final isInHistory = ref.watch(downloadHistoryProvider.select((state) { + return state.isDownloaded(track.id); + })); + + final isQueued = queueItem != null; + final isDownloading = queueItem?.status == DownloadStatus.downloading; + final isFinalizing = queueItem?.status == DownloadStatus.finalizing; + final isCompleted = queueItem?.status == DownloadStatus.completed; + final progress = queueItem?.progress ?? 0.0; + + // Show as downloaded if in queue completed OR in history + final showAsDownloaded = isCompleted || (!isQueued && isInHistory); + + return InkWell( + onTap: () => _handlePopularTrackTap(track, isQueued: isQueued, isInHistory: isInHistory), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + children: [ + // Rank number + SizedBox( + width: 24, + child: Text( + '$rank', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + textAlign: TextAlign.center, + ), + ), + const SizedBox(width: 12), + // Album art + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: track.coverUrl != null + ? CachedNetworkImage( + imageUrl: track.coverUrl!, + width: 48, + height: 48, + fit: BoxFit.cover, + memCacheWidth: 96, + placeholder: (context, url) => Container( + width: 48, + height: 48, + color: colorScheme.surfaceContainerHighest, + ), + errorWidget: (context, url, error) => Container( + width: 48, + height: 48, + color: colorScheme.surfaceContainerHighest, + child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant, size: 24), + ), + ) + : Container( + width: 48, + height: 48, + color: colorScheme.surfaceContainerHighest, + child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant, size: 24), + ), + ), + const SizedBox(width: 12), + // Track info + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + track.name, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w500, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if (track.albumName.isNotEmpty) + Text( + track.albumName, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + // Download button with status + _buildPopularDownloadButton( + track: track, + colorScheme: colorScheme, + isQueued: isQueued, + isDownloading: isDownloading, + isFinalizing: isFinalizing, + showAsDownloaded: showAsDownloaded, + isInHistory: isInHistory, + progress: progress, + ), + ], + ), + ), + ); + } + + /// Handle tap on popular track item + void _handlePopularTrackTap(Track track, {required bool isQueued, required bool isInHistory}) async { + if (isQueued) return; + + if (isInHistory) { + final historyItem = ref.read(downloadHistoryProvider.notifier).getBySpotifyId(track.id); + if (historyItem != null) { + final fileExists = await File(historyItem.filePath).exists(); + if (fileExists) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.snackbarAlreadyDownloaded(track.name))), + ); + } + return; + } else { + ref.read(downloadHistoryProvider.notifier).removeBySpotifyId(track.id); + } + } + } + + _downloadTrack(track); + } + + /// Build download button with status indicator for popular tracks + Widget _buildPopularDownloadButton({ + required Track track, + required ColorScheme colorScheme, + required bool isQueued, + required bool isDownloading, + required bool isFinalizing, + required bool showAsDownloaded, + required bool isInHistory, + required double progress, + }) { + const double size = 40.0; + const double iconSize = 20.0; + + if (showAsDownloaded) { + return GestureDetector( + onTap: () => _handlePopularTrackTap(track, isQueued: isQueued, isInHistory: isInHistory), + child: Container( + width: size, + height: size, + decoration: BoxDecoration( + color: colorScheme.primaryContainer, + shape: BoxShape.circle, + ), + child: Icon(Icons.check, color: colorScheme.onPrimaryContainer, size: iconSize), + ), + ); + } else if (isFinalizing) { + return SizedBox( + width: size, + height: size, + child: Stack( + alignment: Alignment.center, + children: [ + CircularProgressIndicator( + strokeWidth: 2.5, + color: colorScheme.tertiary, + backgroundColor: colorScheme.surfaceContainerHighest, + ), + Icon(Icons.edit_note, color: colorScheme.tertiary, size: 14), + ], + ), + ); + } else if (isDownloading) { + return SizedBox( + width: size, + height: size, + child: Stack( + alignment: Alignment.center, + children: [ + CircularProgressIndicator( + value: progress > 0 ? progress : null, + strokeWidth: 2.5, + color: colorScheme.primary, + backgroundColor: colorScheme.surfaceContainerHighest, + ), + if (progress > 0) + Text( + '${(progress * 100).toInt()}', + style: TextStyle(fontSize: 9, fontWeight: FontWeight.bold, color: colorScheme.primary), + ), + ], + ), + ); + } else if (isQueued) { + return Container( + width: size, + height: size, + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest, + shape: BoxShape.circle, + ), + child: Icon(Icons.hourglass_empty, color: colorScheme.onSurfaceVariant, size: iconSize), + ); + } else { + return GestureDetector( + onTap: () => _downloadTrack(track), + child: Container( + width: size, + height: size, + decoration: BoxDecoration( + color: colorScheme.secondaryContainer, + shape: BoxShape.circle, + ), + child: Icon(Icons.download, color: colorScheme.onSecondaryContainer, size: iconSize), + ), + ); + } + } + + void _downloadTrack(Track track) { + final settings = ref.read(settingsProvider); + ref.read(settingsProvider.notifier).setHasSearchedBefore(); + ref.read(downloadQueueProvider.notifier).addToQueue(track, settings.defaultService); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(context.l10n.snackbarAddedToQueue(track.name)), + duration: const Duration(seconds: 2), ), ); } @@ -273,24 +702,26 @@ class _ArtistScreenState extends ConsumerState { crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( - padding: const EdgeInsets.fromLTRB(20, 16, 20, 8), - child: Row( - children: [ - Icon(Icons.album, size: 20, color: colorScheme.primary), - const SizedBox(width: 8), - Text('$title (${albums.length})', style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600, color: colorScheme.primary)), - ], + padding: const EdgeInsets.fromLTRB(16, 24, 16, 12), + child: Text( + '$title (${albums.length})', + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + ), ), ), SizedBox( - height: 210, + height: 220, child: ListView.builder( scrollDirection: Axis.horizontal, padding: const EdgeInsets.symmetric(horizontal: 12), itemCount: albums.length, itemBuilder: (context, index) { final album = albums[index]; - return KeyedSubtree(key: ValueKey(album.id), child: _buildAlbumCard(album, colorScheme)); + return KeyedSubtree( + key: ValueKey(album.id), + child: _buildAlbumCard(album, colorScheme), + ); }, ), ), @@ -303,55 +734,71 @@ class _ArtistScreenState extends ConsumerState { onTap: () => _navigateToAlbum(album), child: Container( width: 140, - margin: const EdgeInsets.symmetric(horizontal: 6), - child: Card( - elevation: 0, - color: colorScheme.surfaceContainerLow, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), - child: Padding( - padding: const EdgeInsets.all(8), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(12), - child: album.coverUrl != null - ? CachedNetworkImage(imageUrl: album.coverUrl!, width: 124, height: 124, fit: BoxFit.cover, memCacheWidth: 248) - : Container(width: 124, height: 124, color: colorScheme.surfaceContainerHighest, child: Icon(Icons.album, color: colorScheme.onSurfaceVariant, size: 40)), - ), - const SizedBox(height: 6), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(album.name, style: Theme.of(context).textTheme.bodySmall?.copyWith(fontWeight: FontWeight.w600), maxLines: 2, overflow: TextOverflow.ellipsis), - const Spacer(), - Text( - album.totalTracks > 0 - ? '${album.releaseDate.length >= 4 ? album.releaseDate.substring(0, 4) : album.releaseDate} • ${context.l10n.tracksCount(album.totalTracks)}' - : album.releaseDate.length >= 4 ? album.releaseDate.substring(0, 4) : album.releaseDate, - style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant, fontSize: 11), - maxLines: 1, - overflow: TextOverflow.ellipsis, + margin: const EdgeInsets.symmetric(horizontal: 4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Album cover + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: album.coverUrl != null + ? CachedNetworkImage( + imageUrl: album.coverUrl!, + width: 140, + height: 140, + fit: BoxFit.cover, + memCacheWidth: 280, + placeholder: (context, url) => Container( + width: 140, + height: 140, + color: colorScheme.surfaceContainerHighest, ), - ], - ), - ), - ], + errorWidget: (context, url, error) => Container( + width: 140, + height: 140, + color: colorScheme.surfaceContainerHighest, + child: Icon(Icons.album, color: colorScheme.onSurfaceVariant, size: 40), + ), + ) + : Container( + width: 140, + height: 140, + color: colorScheme.surfaceContainerHighest, + child: Icon(Icons.album, color: colorScheme.onSurfaceVariant, size: 40), + ), ), - ), + const SizedBox(height: 8), + // Album name + Text( + album.name, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w500, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 2), + // Year and track count + Text( + album.totalTracks > 0 + ? '${album.releaseDate.length >= 4 ? album.releaseDate.substring(0, 4) : album.releaseDate} ${context.l10n.tracksCount(album.totalTracks)}' + : album.releaseDate.length >= 4 ? album.releaseDate.substring(0, 4) : album.releaseDate, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], ), ), ); } void _navigateToAlbum(ArtistAlbum album) { - // Navigate immediately with data from artist discography, fetch tracks in AlbumScreen ref.read(settingsProvider.notifier).setHasSearchedBefore(); - // Check if this album is from an extension (has providerId) if (album.providerId != null && album.providerId!.isNotEmpty) { - // Use ExtensionAlbumScreen for extension albums Navigator.push(context, MaterialPageRoute( builder: (context) => ExtensionAlbumScreen( extensionId: album.providerId!, @@ -361,19 +808,16 @@ class _ArtistScreenState extends ConsumerState { ), )); } else { - // Use regular AlbumScreen for Spotify/Deezer albums Navigator.push(context, MaterialPageRoute( builder: (context) => AlbumScreen( albumId: album.id, albumName: album.name, coverUrl: album.coverUrl, - // tracks: null - will be fetched in AlbumScreen ), )); } } - /// Build error widget with special handling for rate limit (429) Widget _buildErrorWidget(String error, ColorScheme colorScheme) { final isRateLimit = error.contains('429') || error.toLowerCase().contains('rate limit') || @@ -383,7 +827,7 @@ class _ArtistScreenState extends ConsumerState { return Card( elevation: 0, color: colorScheme.errorContainer, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), child: Padding( padding: const EdgeInsets.all(16), child: Row( @@ -418,11 +862,10 @@ class _ArtistScreenState extends ConsumerState { ); } - // Default error display return Card( elevation: 0, color: colorScheme.errorContainer.withValues(alpha: 0.5), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), child: Padding( padding: const EdgeInsets.all(16), child: Row( diff --git a/lib/screens/home_tab.dart b/lib/screens/home_tab.dart index 55e76f79..960224a5 100644 --- a/lib/screens/home_tab.dart +++ b/lib/screens/home_tab.dart @@ -10,6 +10,7 @@ import 'package:spotiflac_android/providers/track_provider.dart'; import 'package:spotiflac_android/providers/download_queue_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/providers/extension_provider.dart'; +import 'package:spotiflac_android/providers/recent_access_provider.dart'; import 'package:spotiflac_android/screens/track_metadata_screen.dart'; import 'package:spotiflac_android/screens/album_screen.dart'; import 'package:spotiflac_android/screens/artist_screen.dart'; @@ -38,16 +39,27 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient void initState() { super.initState(); _urlController.addListener(_onSearchChanged); + _searchFocusNode.addListener(_onSearchFocusChanged); } @override void dispose() { _urlController.removeListener(_onSearchChanged); + _searchFocusNode.removeListener(_onSearchFocusChanged); _urlController.dispose(); _searchFocusNode.dispose(); super.dispose(); } + void _onSearchFocusChanged() { + // When focused, enter recent access mode + // When unfocused (keyboard dismissed), keep recent access mode visible + // User must press back button to exit recent access mode + if (_searchFocusNode.hasFocus) { + ref.read(trackProvider.notifier).setShowingRecentAccess(true); + } + } + /// Called when trackState changes - used to sync search bar with state void _onTrackStateChanged(TrackState? previous, TrackState next) { // If state was cleared (no content, no search text, not loading), clear the search bar @@ -147,7 +159,7 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient void _navigateToDetailIfNeeded() { final trackState = ref.read(trackProvider); - // Navigate to Album screen + // Navigate to Album screen (recording is done in AlbumScreen.initState) if (trackState.albumId != null && trackState.albumName != null && trackState.tracks.isNotEmpty) { Navigator.push(context, MaterialPageRoute(builder: (context) => AlbumScreen( albumId: trackState.albumId!, @@ -163,6 +175,14 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient // Navigate to Playlist screen if (trackState.playlistName != null && trackState.tracks.isNotEmpty) { + // Record access for playlist (no separate screen to record in) + ref.read(recentAccessProvider.notifier).recordPlaylistAccess( + id: trackState.playlistName!, + name: trackState.playlistName!, + imageUrl: trackState.coverUrl, + providerId: 'spotify', + ); + Navigator.push(context, MaterialPageRoute(builder: (context) => PlaylistScreen( playlistName: trackState.playlistName!, coverUrl: trackState.coverUrl, @@ -174,7 +194,7 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient return; } - // Navigate to Artist screen + // Navigate to Artist screen (recording is done in ArtistScreen.initState) if (trackState.artistId != null && trackState.artistName != null && trackState.artistAlbums != null) { Navigator.push(context, MaterialPageRoute(builder: (context) => ArtistScreen( artistId: trackState.artistId!, @@ -271,20 +291,23 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient if (!mounted) return; + // ignore: use_build_context_synchronously + final l10n = context.l10n; + // Optionally show confirmation dialog final confirmed = await showDialog( context: this.context, builder: (dialogCtx) => AlertDialog( - title: Text(context.l10n.dialogImportPlaylistTitle), - content: Text(context.l10n.dialogImportPlaylistMessage(tracks.length)), + title: Text(l10n.dialogImportPlaylistTitle), + content: Text(l10n.dialogImportPlaylistMessage(tracks.length)), actions: [ TextButton( onPressed: () => Navigator.pop(dialogCtx, false), - child: Text(context.l10n.dialogCancel), + child: Text(l10n.dialogCancel), ), FilledButton( onPressed: () => Navigator.pop(dialogCtx, true), - child: Text(context.l10n.dialogImport), + child: Text(l10n.dialogImport), ), ], ), @@ -295,9 +318,9 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient if (mounted) { ScaffoldMessenger.of(this.context).showSnackBar( SnackBar( - content: Text(context.l10n.snackbarAddedTracksToQueue(tracks.length)), + content: Text(l10n.snackbarAddedTracksToQueue(tracks.length)), action: SnackBarAction( - label: context.l10n.snackbarViewQueue, + label: l10n.snackbarViewQueue, onPressed: () { // Navigate to queue tab (handled by main_shell index) // We don't have direct access to set index here easily without provider @@ -337,39 +360,62 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient ref.watch(extensionProvider.select((s) => s.extensions)); final colorScheme = Theme.of(context).colorScheme; - final hasResults = _isTyping || tracks.isNotEmpty || (searchArtists != null && searchArtists.isNotEmpty) || isLoading; + final hasActualResults = tracks.isNotEmpty || (searchArtists != null && searchArtists.isNotEmpty); + final isShowingRecentAccess = ref.watch(trackProvider.select((s) => s.isShowingRecentAccess)); + // Move search bar up when in recent access mode or has results + final hasResults = isShowingRecentAccess || hasActualResults || isLoading; final screenHeight = MediaQuery.of(context).size.height; final topPadding = MediaQuery.of(context).padding.top; final historyItems = ref.watch(downloadHistoryProvider.select((s) => s.items)); + final recentAccessItems = ref.watch(recentAccessProvider.select((s) => s.items)); + + // Show recent access when in mode but no actual results yet (includes download history) + final hasRecentItems = recentAccessItems.isNotEmpty || historyItems.isNotEmpty; + final showRecentAccess = isShowingRecentAccess && hasRecentItems && !hasActualResults && !isLoading; + + // Exit recent access mode when results appear + if (hasActualResults && isShowingRecentAccess) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) ref.read(trackProvider.notifier).setShowingRecentAccess(false); + }); + } - return Scaffold( - body: CustomScrollView( - keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, - slivers: [ - // App Bar - always present - SliverAppBar( - expandedHeight: 120 + topPadding, - collapsedHeight: kToolbarHeight, - floating: false, - pinned: true, - backgroundColor: colorScheme.surface, - surfaceTintColor: Colors.transparent, - automaticallyImplyLeading: false, - 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); - - return FlexibleSpaceBar( - expandedTitleScale: 1.0, - titlePadding: const EdgeInsets.only(left: 24, bottom: 16), - title: Text( - context.l10n.homeTitle, - style: TextStyle( - fontSize: 20 + (14 * expandRatio), // 20 -> 34 - fontWeight: FontWeight.bold, - color: colorScheme.onSurface, + return GestureDetector( + onTap: () { + // Unfocus search bar when tapping outside + if (_searchFocusNode.hasFocus) { + _searchFocusNode.unfocus(); + } + }, + behavior: HitTestBehavior.translucent, + child: Scaffold( + body: CustomScrollView( + keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, + slivers: [ + // App Bar - always present + SliverAppBar( + expandedHeight: 120 + topPadding, + collapsedHeight: kToolbarHeight, + floating: false, + pinned: true, + backgroundColor: colorScheme.surface, + surfaceTintColor: Colors.transparent, + automaticallyImplyLeading: false, + 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); + + return FlexibleSpaceBar( + expandedTitleScale: 1.0, + titlePadding: const EdgeInsets.only(left: 24, bottom: 16), + title: Text( + context.l10n.homeTitle, + style: TextStyle( + fontSize: 20 + (14 * expandRatio), // 20 -> 34 + fontWeight: FontWeight.bold, + color: colorScheme.onSurface, ), ), ); @@ -438,12 +484,19 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient ), ), + // Recent access history - shown when in recent access mode (persists after keyboard dismissed) + // User can exit by pressing back button + if (showRecentAccess) + SliverToBoxAdapter( + child: _buildRecentAccess(recentAccessItems, colorScheme), + ), + // Idle content below search bar - always in tree SliverToBoxAdapter( child: AnimatedSize( duration: const Duration(milliseconds: 250), curve: Curves.easeOut, - child: hasResults + child: (hasResults || showRecentAccess) ? const SizedBox.shrink() : Column( children: [ @@ -479,7 +532,8 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient ), ], ), - ); + ), // Close GestureDetector + ); } Widget _buildRecentDownloads(List items, ColorScheme colorScheme) { @@ -553,6 +607,224 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient ); } + /// Build recent access history section (shown when search focused) + Widget _buildRecentAccess(List items, ColorScheme colorScheme) { + // Merge with recent downloads to make the list more populated + final historyItems = ref.read(downloadHistoryProvider).items; + + // Convert download history to RecentAccessItem format + final downloadItems = historyItems.take(10).where((h) => h.spotifyId != null && h.spotifyId!.isNotEmpty).map((h) => RecentAccessItem( + id: h.spotifyId!, + name: h.trackName, + subtitle: h.artistName, + imageUrl: h.coverUrl, + type: RecentAccessType.track, + accessedAt: h.downloadedAt, + providerId: 'download', + )).toList(); + + // Merge and sort by accessedAt (most recent first) + final allItems = [...items, ...downloadItems]; + allItems.sort((a, b) => b.accessedAt.compareTo(a.accessedAt)); + + // Remove duplicates (keep the most recent one) + final seen = {}; + final uniqueItems = allItems.where((item) { + final key = '${item.type.name}:${item.id}'; + if (seen.contains(key)) return false; + seen.add(key); + return true; + }).take(10).toList(); + + return Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header with clear button + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + context.l10n.homeRecent, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + TextButton( + onPressed: () { + ref.read(recentAccessProvider.notifier).clearHistory(); + }, + child: Text( + context.l10n.dialogClearAll, + style: TextStyle(color: colorScheme.primary, fontSize: 12), + ), + ), + ], + ), + const SizedBox(height: 8), + // List of recent items + ...uniqueItems.map((item) => _buildRecentAccessItem(item, colorScheme)), + ], + ), + ); + } + + Widget _buildRecentAccessItem(RecentAccessItem item, ColorScheme colorScheme) { + // Icon and label based on type + IconData typeIcon; + String typeLabel; + switch (item.type) { + case RecentAccessType.artist: + typeIcon = Icons.person; + typeLabel = context.l10n.recentTypeArtist; + case RecentAccessType.album: + typeIcon = Icons.album; + typeLabel = context.l10n.recentTypeAlbum; + case RecentAccessType.track: + typeIcon = Icons.music_note; + typeLabel = context.l10n.recentTypeSong; + case RecentAccessType.playlist: + typeIcon = Icons.playlist_play; + typeLabel = context.l10n.recentTypePlaylist; + } + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: InkWell( + onTap: () => _navigateToRecentItem(item), + borderRadius: BorderRadius.circular(8), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8), + child: Row( + children: [ + // Image + ClipRRect( + borderRadius: BorderRadius.circular(item.type == RecentAccessType.artist ? 28 : 4), + child: item.imageUrl != null && item.imageUrl!.isNotEmpty + ? CachedNetworkImage( + imageUrl: item.imageUrl!, + width: 56, + height: 56, + fit: BoxFit.cover, + memCacheWidth: 112, + errorWidget: (context, url, error) => Container( + width: 56, + height: 56, + color: colorScheme.surfaceContainerHighest, + child: Icon(typeIcon, color: colorScheme.onSurfaceVariant), + ), + ) + : Container( + width: 56, + height: 56, + color: colorScheme.surfaceContainerHighest, + child: Icon(typeIcon, color: colorScheme.onSurfaceVariant), + ), + ), + const SizedBox(width: 12), + // Text content + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 4), + Text( + item.subtitle != null ? '$typeLabel • ${item.subtitle}' : typeLabel, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + // Delete button (like Spotify's X) + IconButton( + icon: Icon(Icons.close, size: 20, color: colorScheme.onSurfaceVariant), + onPressed: () { + ref.read(recentAccessProvider.notifier).removeItem(item); + }, + ), + ], + ), + ), + ), + ); + } + + void _navigateToRecentItem(RecentAccessItem item) { + _searchFocusNode.unfocus(); + + switch (item.type) { + case RecentAccessType.artist: + // Check if artist is from extension (not spotify/deezer) + if (item.providerId != null && item.providerId!.isNotEmpty && item.providerId != 'deezer' && item.providerId != 'spotify') { + Navigator.push(context, MaterialPageRoute( + builder: (context) => ExtensionArtistScreen( + extensionId: item.providerId!, + artistId: item.id, + artistName: item.name, + coverUrl: item.imageUrl, + ), + )); + } else { + Navigator.push(context, MaterialPageRoute( + builder: (context) => ArtistScreen( + artistId: item.id, + artistName: item.name, + coverUrl: item.imageUrl, + ), + )); + } + case RecentAccessType.album: + if (item.providerId != null && item.providerId!.isNotEmpty && item.providerId != 'deezer' && item.providerId != 'spotify') { + Navigator.push(context, MaterialPageRoute( + builder: (context) => ExtensionAlbumScreen( + extensionId: item.providerId!, + albumId: item.id, + albumName: item.name, + coverUrl: item.imageUrl, + ), + )); + } else { + Navigator.push(context, MaterialPageRoute( + builder: (context) => AlbumScreen( + albumId: item.id, + albumName: item.name, + coverUrl: item.imageUrl, + ), + )); + } + case RecentAccessType.track: + // For tracks from download history, navigate to metadata screen + final historyItem = ref.read(downloadHistoryProvider.notifier).getBySpotifyId(item.id); + if (historyItem != null) { + _navigateToMetadataScreen(historyItem); + } else { + // Track not in history anymore + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(item.name)), + ); + } + case RecentAccessType.playlist: + // Playlist needs tracks, so we just show info + // Could potentially re-fetch using URL handler if we stored URL + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.recentPlaylistInfo(item.name))), + ); + } + } + void _navigateToMetadataScreen(DownloadHistoryItem item) { Navigator.push(context, PageRouteBuilder( transitionDuration: const Duration(milliseconds: 300), @@ -888,6 +1160,9 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient void _navigateToArtist(String artistId, String artistName, String? imageUrl) { // Navigate immediately with data from search, fetch albums in ArtistScreen ref.read(settingsProvider.notifier).setHasSearchedBefore(); + + // Recording is done in ArtistScreen.initState to avoid duplicates + Navigator.push(context, MaterialPageRoute( builder: (context) => ArtistScreen( artistId: artistId, @@ -909,6 +1184,15 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient ref.read(settingsProvider.notifier).setHasSearchedBefore(); + // Record access for recent history + ref.read(recentAccessProvider.notifier).recordAlbumAccess( + id: albumItem.id, + name: albumItem.name, + artistName: albumItem.artistName, + imageUrl: albumItem.coverUrl, + providerId: extensionId, + ); + // Navigate to AlbumScreen - it will fetch tracks via extension Navigator.push(context, MaterialPageRoute( builder: (context) => ExtensionAlbumScreen( @@ -931,6 +1215,15 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient ref.read(settingsProvider.notifier).setHasSearchedBefore(); + // Record access for recent history + ref.read(recentAccessProvider.notifier).recordPlaylistAccess( + id: playlistItem.id, + name: playlistItem.name, + ownerName: playlistItem.artistName, + imageUrl: playlistItem.coverUrl, + providerId: extensionId, + ); + // Navigate to ExtensionPlaylistScreen - it will fetch tracks via extension Navigator.push(context, MaterialPageRoute( builder: (context) => ExtensionPlaylistScreen( @@ -953,6 +1246,14 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient ref.read(settingsProvider.notifier).setHasSearchedBefore(); + // Record access for recent history + ref.read(recentAccessProvider.notifier).recordArtistAccess( + id: artistItem.id, + name: artistItem.name, + imageUrl: artistItem.coverUrl, + providerId: extensionId, + ); + // Navigate to ExtensionArtistScreen - it will fetch albums via extension Navigator.push(context, MaterialPageRoute( builder: (context) => ExtensionArtistScreen( @@ -1687,6 +1988,9 @@ class ExtensionArtistScreen extends ConsumerStatefulWidget { class _ExtensionArtistScreenState extends ConsumerState { List? _albums; + List? _topTracks; + String? _headerImageUrl; + int? _monthlyListeners; bool _isLoading = true; String? _error; @@ -1719,18 +2023,24 @@ class _ExtensionArtistScreenState extends ConsumerState { // Parse albums from result final albumList = result['albums'] as List?; - if (albumList == null) { - setState(() { - _albums = []; - _isLoading = false; - }); - return; + final albums = albumList?.map((a) => _parseAlbum(a as Map)).toList() ?? []; + + // Parse top tracks from result + final topTracksList = result['top_tracks'] as List?; + List? topTracks; + if (topTracksList != null && topTracksList.isNotEmpty) { + topTracks = topTracksList.map((t) => _parseTrack(t as Map)).toList(); } - final albums = albumList.map((a) => _parseAlbum(a as Map)).toList(); + // Parse additional artist info + final headerImage = result['header_image'] as String?; + final listeners = result['listeners'] as int?; setState(() { _albums = albums; + _topTracks = topTracks; + _headerImageUrl = headerImage; + _monthlyListeners = listeners; _isLoading = false; }); } catch (e) { @@ -1755,6 +2065,31 @@ class _ExtensionArtistScreenState extends ConsumerState { ); } + Track _parseTrack(Map data) { + int durationMs = 0; + final durationValue = data['duration_ms']; + if (durationValue is int) { + durationMs = durationValue; + } else if (durationValue is double) { + durationMs = durationValue.toInt(); + } + + return Track( + id: (data['id'] ?? data['spotify_id'] ?? '').toString(), + name: (data['name'] ?? '').toString(), + artistName: (data['artists'] ?? data['artist'] ?? '').toString(), + albumName: (data['album_name'] ?? data['album'] ?? '').toString(), + albumArtist: data['album_artist']?.toString(), + coverUrl: (data['cover_url'] ?? data['images'])?.toString(), + isrc: data['isrc']?.toString(), + duration: (durationMs / 1000).round(), + trackNumber: data['track_number'] as int?, + discNumber: data['disc_number'] as int?, + releaseDate: data['release_date']?.toString(), + source: (data['provider_id'] ?? widget.extensionId).toString(), + ); + } + @override Widget build(BuildContext context) { if (_isLoading) { @@ -1780,12 +2115,16 @@ class _ExtensionArtistScreenState extends ConsumerState { ); } - // Navigate to ArtistScreen with fetched albums + // Navigate to ArtistScreen with fetched albums and top tracks return ArtistScreen( artistId: widget.artistId, artistName: widget.artistName, coverUrl: widget.coverUrl, + headerImageUrl: _headerImageUrl, + monthlyListeners: _monthlyListeners, albums: _albums, + topTracks: _topTracks, + extensionId: widget.extensionId, // Skip Spotify/Deezer fetch ); } } diff --git a/lib/screens/main_shell.dart b/lib/screens/main_shell.dart index edd2d226..65a72da3 100644 --- a/lib/screens/main_shell.dart +++ b/lib/screens/main_shell.dart @@ -5,6 +5,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/providers/download_queue_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; +import 'package:spotiflac_android/providers/store_provider.dart'; import 'package:spotiflac_android/providers/track_provider.dart'; import 'package:spotiflac_android/screens/home_tab.dart'; import 'package:spotiflac_android/screens/store_tab.dart'; @@ -124,7 +125,8 @@ class _MainShellState extends ConsumerState { if (_currentIndex != index) { setState(() => _currentIndex = index); // Unfocus any text field when switching tabs to prevent keyboard from appearing - FocusScope.of(context).unfocus(); + // Use primaryFocus for more aggressive unfocus that works with keep-alive widgets + FocusManager.instance.primaryFocus?.unfocus(); } } @@ -135,7 +137,15 @@ class _MainShellState extends ConsumerState { // Check if keyboard is visible - if so, just dismiss keyboard, don't clear search final isKeyboardVisible = MediaQuery.of(context).viewInsets.bottom > 0; if (isKeyboardVisible) { - FocusScope.of(context).unfocus(); + FocusManager.instance.primaryFocus?.unfocus(); + return; + } + + // If on Home tab and showing recent access mode, exit it + if (_currentIndex == 0 && trackState.isShowingRecentAccess) { + ref.read(trackProvider.notifier).setShowingRecentAccess(false); + // Also unfocus search bar when exiting recent access mode + FocusManager.instance.primaryFocus?.unfocus(); return; } @@ -177,6 +187,7 @@ class _MainShellState extends ConsumerState { final queueState = ref.watch(downloadQueueProvider.select((s) => s.queuedCount)); final trackState = ref.watch(trackProvider); final showStore = ref.watch(settingsProvider.select((s) => s.showExtensionStore)); + final storeUpdatesCount = ref.watch(storeProvider.select((s) => s.updatesAvailableCount)); // Check if keyboard is visible (bottom inset > 0 means keyboard is showing) final isKeyboardVisible = MediaQuery.of(context).viewInsets.bottom > 0; @@ -188,6 +199,7 @@ class _MainShellState extends ConsumerState { !trackState.hasSearchText && !trackState.hasContent && !trackState.isLoading && + !trackState.isShowingRecentAccess && !isKeyboardVisible; // Build tabs and destinations based on settings @@ -224,8 +236,16 @@ class _MainShellState extends ConsumerState { ), if (showStore) NavigationDestination( - icon: const Icon(Icons.store_outlined), - selectedIcon: const Icon(Icons.store), + icon: Badge( + isLabelVisible: storeUpdatesCount > 0, + label: Text('$storeUpdatesCount'), + child: const Icon(Icons.store_outlined), + ), + selectedIcon: Badge( + isLabelVisible: storeUpdatesCount > 0, + label: Text('$storeUpdatesCount'), + child: const Icon(Icons.store), + ), label: l10n.navStore, ), NavigationDestination( diff --git a/lib/screens/settings/appearance_settings_page.dart b/lib/screens/settings/appearance_settings_page.dart index 8646d647..ac1f55bf 100644 --- a/lib/screens/settings/appearance_settings_page.dart +++ b/lib/screens/settings/appearance_settings_page.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; +import 'package:spotiflac_android/l10n/supported_locales.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/providers/theme_provider.dart'; import 'package:spotiflac_android/widgets/settings_group.dart'; @@ -709,7 +710,8 @@ class _LanguageSelector extends StatelessWidget { required this.onChanged, }); - static const _languages = [ + // All available languages (code, displayName, icon) + static const _allLanguages = [ ('system', 'System Default', Icons.phone_android), ('en', 'English', Icons.language), ('id', 'Bahasa Indonesia', Icons.language), @@ -726,8 +728,20 @@ class _LanguageSelector extends StatelessWidget { ('zh_TW', '繁體中文', Icons.language), ]; + /// Get only languages that meet the translation threshold. + /// Uses filteredLocaleCodes from supported_locales.dart (generated file). + List<(String, String, IconData)> get _languages { + return _allLanguages.where((lang) { + // Always include 'system' option + if (lang.$1 == 'system') return true; + // Only include languages in the filtered set + return filteredLocaleCodes.contains(lang.$1); + }).toList(); + } + String _getLanguageName(String code) { - for (final lang in _languages) { + // Search in all languages (not just filtered) for display name fallback + for (final lang in _allLanguages) { if (lang.$1 == code) return lang.$2; } return code; diff --git a/lib/screens/settings/settings_tab.dart b/lib/screens/settings/settings_tab.dart index 835ca93c..b6421a8d 100644 --- a/lib/screens/settings/settings_tab.dart +++ b/lib/screens/settings/settings_tab.dart @@ -127,6 +127,9 @@ class SettingsTab extends ConsumerWidget { } void _navigateTo(BuildContext context, Widget page) { + // Unfocus any focused widget before navigating to prevent keyboard from appearing on return + FocusManager.instance.primaryFocus?.unfocus(); + Navigator.of(context).push( // Use PageRouteBuilder for better predictive back gesture support // MaterialPageRoute can cause freeze on some devices with gesture navigation diff --git a/lib/screens/store_tab.dart b/lib/screens/store_tab.dart index b5ecad78..eab625cb 100644 --- a/lib/screens/store_tab.dart +++ b/lib/screens/store_tab.dart @@ -548,15 +548,41 @@ class _ExtensionItem extends StatelessWidget { color: colorScheme.onSurfaceVariant, ), ), - const SizedBox(height: 4), - Text( - extension.description, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, + // Warning badge for incompatible extensions + if (extension.requiresNewerApp) ...[ + const SizedBox(height: 4), + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: colorScheme.errorContainer, + borderRadius: BorderRadius.circular(4), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.warning_amber_rounded, size: 12, color: colorScheme.onErrorContainer), + const SizedBox(width: 4), + Text( + 'Requires v${extension.minAppVersion}+', + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: colorScheme.onErrorContainer, + fontWeight: FontWeight.w500, + ), + ), + ], + ), ), - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), + ] else ...[ + const SizedBox(height: 4), + Text( + extension.description, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], ], ), ),