mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-10 18:04:06 +02:00
feat: add recent access history, artist screen redesign, and extension improvements
Recent Access History: - Quick access to recently visited artists, albums, playlists, and tracks - Tap search bar to show recent access list - Stays visible after keyboard dismiss, exit with back button - Persists across app restarts (SharedPreferences) - X button to remove items, Clear All button for all Artist Screen Redesign: - Full-width header image with gradient overlay - Monthly listeners display with compact notation - Popular section with top 5 tracks and download status - Extension artists skip Spotify/Deezer fetch (no rate limit errors) Go Backend: - GetArtistWithExtensionJSON now returns top_tracks, header_image, listeners Bug Fixes: - Search bar unfocus when tapping outside - Keyboard not appearing on Settings navigation return - Recent access artist navigation uses correct screen for extensions - Extension artist screen correctly parses and forwards top tracks Localization: - Added recentPlaylistInfo, errorGeneric strings - Multi-language support via Crowdin Extensions: - YT Music: v1.5.0 (top_tracks in getArtist) - Spotify Web: v1.6.0
This commit is contained in:
29 files changed
+2014
-240
No files matched your search
+69
-1
@@ -4,6 +4,34 @@
|
|||||||
|
|
||||||
### Added
|
### 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
|
- **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/
|
- `Artist / [Year] Album`: Albums/Coldplay/[2005] X&Y/
|
||||||
@@ -23,11 +51,18 @@
|
|||||||
|
|
||||||
- **Odesli (song.link) Integration for YouTube Music Extension**
|
- **Odesli (song.link) Integration for YouTube Music Extension**
|
||||||
- New `enrichTrack()` function to fetch ISRC and external service links
|
- 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
|
- 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
|
- 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.
|
- **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
|
||||||
|
|
||||||
- Fixed search source chips still referencing removed badge props.
|
- 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 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 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 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**
|
- **Go Backend: Missing `item_type` and `album_type` fields**
|
||||||
- Added `ItemType` and `AlbumType` fields to `ExtTrackMetadata` struct
|
- Added `ItemType` and `AlbumType` fields to `ExtTrackMetadata` struct
|
||||||
- Fixed `CustomSearchWithExtensionJSON` - now includes `item_type` and `album_type` in response
|
- 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
|
- 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
|
- **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
|
- **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}"
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+62
-4
@@ -1657,10 +1657,12 @@ func HandleURLWithExtensionJSON(url string) (string, error) {
|
|||||||
// Add artist info if present
|
// Add artist info if present
|
||||||
if result.Artist != nil {
|
if result.Artist != nil {
|
||||||
artistResponse := map[string]interface{}{
|
artistResponse := map[string]interface{}{
|
||||||
"id": result.Artist.ID,
|
"id": result.Artist.ID,
|
||||||
"name": result.Artist.Name,
|
"name": result.Artist.Name,
|
||||||
"image_url": result.Artist.ImageURL,
|
"image_url": result.Artist.ImageURL,
|
||||||
"provider_id": result.Artist.ProviderID,
|
"header_image": result.Artist.HeaderImage,
|
||||||
|
"listeners": result.Artist.Listeners,
|
||||||
|
"provider_id": result.Artist.ProviderID,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add albums if present
|
// Add albums if present
|
||||||
@@ -1686,6 +1688,29 @@ func HandleURLWithExtensionJSON(url string) (string, error) {
|
|||||||
artistResponse["albums"] = albums
|
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
|
response["artist"] = artistResponse
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1920,6 +1945,39 @@ func GetArtistWithExtensionJSON(extensionID, artistID string) (string, error) {
|
|||||||
"provider_id": artist.ProviderID,
|
"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)
|
jsonBytes, err := json.Marshal(response)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ type ExtTrackMetadata struct {
|
|||||||
DiscNumber int `json:"disc_number,omitempty"`
|
DiscNumber int `json:"disc_number,omitempty"`
|
||||||
ISRC string `json:"isrc,omitempty"`
|
ISRC string `json:"isrc,omitempty"`
|
||||||
ProviderID string `json:"provider_id"`
|
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
|
AlbumType string `json:"album_type,omitempty"` // album, single, ep, compilation
|
||||||
// Enrichment fields from Odesli/song.link
|
// Enrichment fields from Odesli/song.link
|
||||||
TidalID string `json:"tidal_id,omitempty"`
|
TidalID string `json:"tidal_id,omitempty"`
|
||||||
@@ -63,11 +63,14 @@ type ExtAlbumMetadata struct {
|
|||||||
|
|
||||||
// ExtArtistMetadata represents artist metadata from an extension
|
// ExtArtistMetadata represents artist metadata from an extension
|
||||||
type ExtArtistMetadata struct {
|
type ExtArtistMetadata struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
ImageURL string `json:"image_url,omitempty"`
|
ImageURL string `json:"image_url,omitempty"`
|
||||||
Albums []ExtAlbumMetadata `json:"albums,omitempty"`
|
HeaderImage string `json:"header_image,omitempty"` // Header image for artist page background
|
||||||
ProviderID string `json:"provider_id"`
|
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
|
// 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
|
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
|
return &handleResult, nil
|
||||||
|
|||||||
@@ -995,6 +995,18 @@ abstract class AppLocalizations {
|
|||||||
/// **'{count, plural, =1{1 release} other{{count} releases}}'**
|
/// **'{count, plural, =1{1 release} other{{count} releases}}'**
|
||||||
String artistReleases(int count);
|
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
|
/// Track metadata screen title
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
@@ -3598,6 +3610,42 @@ abstract class AppLocalizations {
|
|||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'Utility Functions'**
|
/// **'Utility Functions'**
|
||||||
String get utilityFunctions;
|
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
|
class _AppLocalizationsDelegate
|
||||||
|
|||||||
@@ -513,6 +513,14 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
return '$_temp0';
|
return '$_temp0';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get artistPopular => 'Popular';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String artistMonthlyListeners(String count) {
|
||||||
|
return '$count monthly listeners';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackMetadataTitle => 'Track Info';
|
String get trackMetadataTitle => 'Track Info';
|
||||||
|
|
||||||
@@ -1976,4 +1984,26 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get utilityFunctions => 'Utility Functions';
|
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';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -513,6 +513,14 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
return '$_temp0';
|
return '$_temp0';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get artistPopular => 'Popular';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String artistMonthlyListeners(String count) {
|
||||||
|
return '$count monthly listeners';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackMetadataTitle => 'Track Info';
|
String get trackMetadataTitle => 'Track Info';
|
||||||
|
|
||||||
@@ -1976,4 +1984,26 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get utilityFunctions => 'Utility Functions';
|
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';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -513,6 +513,14 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
return '$_temp0';
|
return '$_temp0';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get artistPopular => 'Popular';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String artistMonthlyListeners(String count) {
|
||||||
|
return '$count monthly listeners';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackMetadataTitle => 'Track Info';
|
String get trackMetadataTitle => 'Track Info';
|
||||||
|
|
||||||
@@ -1976,4 +1984,26 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get utilityFunctions => 'Utility Functions';
|
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';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -513,6 +513,14 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
return '$_temp0';
|
return '$_temp0';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get artistPopular => 'Popular';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String artistMonthlyListeners(String count) {
|
||||||
|
return '$count monthly listeners';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackMetadataTitle => 'Track Info';
|
String get trackMetadataTitle => 'Track Info';
|
||||||
|
|
||||||
@@ -1976,4 +1984,26 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get utilityFunctions => 'Utility Functions';
|
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';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -513,6 +513,14 @@ class AppLocalizationsHi extends AppLocalizations {
|
|||||||
return '$_temp0';
|
return '$_temp0';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get artistPopular => 'Popular';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String artistMonthlyListeners(String count) {
|
||||||
|
return '$count monthly listeners';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackMetadataTitle => 'Track Info';
|
String get trackMetadataTitle => 'Track Info';
|
||||||
|
|
||||||
@@ -1976,4 +1984,26 @@ class AppLocalizationsHi extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get utilityFunctions => 'Utility Functions';
|
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';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -518,6 +518,14 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
return '$_temp0';
|
return '$_temp0';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get artistPopular => 'Populer';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String artistMonthlyListeners(String count) {
|
||||||
|
return '$count pendengar bulanan';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackMetadataTitle => 'Info Lagu';
|
String get trackMetadataTitle => 'Info Lagu';
|
||||||
|
|
||||||
@@ -1989,4 +1997,26 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get utilityFunctions => 'Fungsi Utilitas';
|
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';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -513,6 +513,14 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
return '$_temp0';
|
return '$_temp0';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get artistPopular => 'Popular';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String artistMonthlyListeners(String count) {
|
||||||
|
return '$count monthly listeners';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackMetadataTitle => 'Track Info';
|
String get trackMetadataTitle => 'Track Info';
|
||||||
|
|
||||||
@@ -1976,4 +1984,26 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get utilityFunctions => 'Utility Functions';
|
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';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -513,6 +513,14 @@ class AppLocalizationsKo extends AppLocalizations {
|
|||||||
return '$_temp0';
|
return '$_temp0';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get artistPopular => 'Popular';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String artistMonthlyListeners(String count) {
|
||||||
|
return '$count monthly listeners';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackMetadataTitle => 'Track Info';
|
String get trackMetadataTitle => 'Track Info';
|
||||||
|
|
||||||
@@ -1976,4 +1984,26 @@ class AppLocalizationsKo extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get utilityFunctions => 'Utility Functions';
|
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';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -513,6 +513,14 @@ class AppLocalizationsNl extends AppLocalizations {
|
|||||||
return '$_temp0';
|
return '$_temp0';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get artistPopular => 'Popular';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String artistMonthlyListeners(String count) {
|
||||||
|
return '$count monthly listeners';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackMetadataTitle => 'Track Info';
|
String get trackMetadataTitle => 'Track Info';
|
||||||
|
|
||||||
@@ -1976,4 +1984,26 @@ class AppLocalizationsNl extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get utilityFunctions => 'Utility Functions';
|
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';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -513,6 +513,14 @@ class AppLocalizationsPt extends AppLocalizations {
|
|||||||
return '$_temp0';
|
return '$_temp0';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get artistPopular => 'Popular';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String artistMonthlyListeners(String count) {
|
||||||
|
return '$count monthly listeners';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackMetadataTitle => 'Track Info';
|
String get trackMetadataTitle => 'Track Info';
|
||||||
|
|
||||||
@@ -1976,4 +1984,26 @@ class AppLocalizationsPt extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get utilityFunctions => 'Utility Functions';
|
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';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -513,6 +513,14 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
return '$_temp0';
|
return '$_temp0';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get artistPopular => 'Popular';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String artistMonthlyListeners(String count) {
|
||||||
|
return '$count monthly listeners';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackMetadataTitle => 'Track Info';
|
String get trackMetadataTitle => 'Track Info';
|
||||||
|
|
||||||
@@ -1976,4 +1984,26 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get utilityFunctions => 'Utility Functions';
|
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';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -513,6 +513,14 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
return '$_temp0';
|
return '$_temp0';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get artistPopular => 'Popular';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String artistMonthlyListeners(String count) {
|
||||||
|
return '$count monthly listeners';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackMetadataTitle => 'Track Info';
|
String get trackMetadataTitle => 'Track Info';
|
||||||
|
|
||||||
@@ -1976,6 +1984,28 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get utilityFunctions => 'Utility Functions';
|
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`).
|
/// The translations for Chinese, as used in Taiwan (`zh_TW`).
|
||||||
|
|||||||
+34
-1
@@ -362,6 +362,15 @@
|
|||||||
"count": {"type": "int"}
|
"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": "Track Info",
|
||||||
"@trackMetadataTitle": {"description": "Track metadata screen title"},
|
"@trackMetadataTitle": {"description": "Track metadata screen title"},
|
||||||
@@ -1459,5 +1468,29 @@
|
|||||||
"@downloadedAlbumSelectToDelete": {"description": "Placeholder when nothing selected"},
|
"@downloadedAlbumSelectToDelete": {"description": "Placeholder when nothing selected"},
|
||||||
|
|
||||||
"utilityFunctions": "Utility Functions",
|
"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"}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+11
-1
@@ -324,6 +324,8 @@
|
|||||||
|
|
||||||
"artistReleases": "{count, plural, =1{1 rilis} other{{count} rilis}}",
|
"artistReleases": "{count, plural, =1{1 rilis} other{{count} rilis}}",
|
||||||
"artistCompilations": "Kompilasi",
|
"artistCompilations": "Kompilasi",
|
||||||
|
"artistPopular": "Populer",
|
||||||
|
"artistMonthlyListeners": "{count} pendengar bulanan",
|
||||||
|
|
||||||
"tracksHeader": "Lagu",
|
"tracksHeader": "Lagu",
|
||||||
"downloadAllCount": "Unduh Semua ({count})",
|
"downloadAllCount": "Unduh Semua ({count})",
|
||||||
@@ -667,5 +669,13 @@
|
|||||||
"folderOrganizationByAlbum": "Berdasarkan Album",
|
"folderOrganizationByAlbum": "Berdasarkan Album",
|
||||||
"folderOrganizationByAlbumSubtitle": "Folder terpisah untuk setiap album",
|
"folderOrganizationByAlbumSubtitle": "Folder terpisah untuk setiap album",
|
||||||
"folderOrganizationByArtistAlbum": "Berdasarkan Artis & 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}"
|
||||||
}
|
}
|
||||||
@@ -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<Locale> filteredSupportedLocales = <Locale>[
|
||||||
|
Locale('en'),
|
||||||
|
Locale('id'),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Set of locale codes for quick lookup.
|
||||||
|
const Set<String> filteredLocaleCodes = <String>{
|
||||||
|
'en',
|
||||||
|
'id',
|
||||||
|
};
|
||||||
@@ -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<String, dynamic> toJson() => {
|
||||||
|
'id': id,
|
||||||
|
'name': name,
|
||||||
|
'subtitle': subtitle,
|
||||||
|
'imageUrl': imageUrl,
|
||||||
|
'type': type.name,
|
||||||
|
'accessedAt': accessedAt.toIso8601String(),
|
||||||
|
'providerId': providerId,
|
||||||
|
};
|
||||||
|
|
||||||
|
factory RecentAccessItem.fromJson(Map<String, dynamic> 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<RecentAccessItem> items;
|
||||||
|
final bool isLoaded;
|
||||||
|
|
||||||
|
const RecentAccessState({
|
||||||
|
this.items = const [],
|
||||||
|
this.isLoaded = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
RecentAccessState copyWith({
|
||||||
|
List<RecentAccessItem>? items,
|
||||||
|
bool? isLoaded,
|
||||||
|
}) {
|
||||||
|
return RecentAccessState(
|
||||||
|
items: items ?? this.items,
|
||||||
|
isLoaded: isLoaded ?? this.isLoaded,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Provider for managing recent access history
|
||||||
|
class RecentAccessNotifier extends Notifier<RecentAccessState> {
|
||||||
|
@override
|
||||||
|
RecentAccessState build() {
|
||||||
|
_loadHistory();
|
||||||
|
return const RecentAccessState();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadHistory() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final json = prefs.getString(_recentAccessKey);
|
||||||
|
if (json != null) {
|
||||||
|
try {
|
||||||
|
final List<dynamic> decoded = jsonDecode(json);
|
||||||
|
final items = decoded
|
||||||
|
.map((e) => RecentAccessItem.fromJson(e as Map<String, dynamic>))
|
||||||
|
.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<void> _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, RecentAccessState>(
|
||||||
|
RecentAccessNotifier.new,
|
||||||
|
);
|
||||||
@@ -1,10 +1,29 @@
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
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/services/platform_bridge.dart';
|
||||||
import 'package:spotiflac_android/utils/logger.dart';
|
import 'package:spotiflac_android/utils/logger.dart';
|
||||||
import 'package:spotiflac_android/providers/extension_provider.dart';
|
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||||
|
|
||||||
final _log = AppLogger('StoreProvider');
|
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
|
/// Extension categories
|
||||||
class StoreCategory {
|
class StoreCategory {
|
||||||
static const String metadata = 'metadata';
|
static const String metadata = 'metadata';
|
||||||
@@ -91,6 +110,12 @@ class StoreExtension {
|
|||||||
hasUpdate: json['has_update'] as bool? ?? false,
|
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
|
/// State for extension store
|
||||||
@@ -161,6 +186,11 @@ class StoreState {
|
|||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Count of extensions with updates available
|
||||||
|
int get updatesAvailableCount {
|
||||||
|
return extensions.where((e) => e.hasUpdate).length;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Provider for managing extension store
|
/// Provider for managing extension store
|
||||||
|
|||||||
@@ -17,9 +17,13 @@ class TrackState {
|
|||||||
final String? artistId;
|
final String? artistId;
|
||||||
final String? artistName;
|
final String? artistName;
|
||||||
final String? coverUrl;
|
final String? coverUrl;
|
||||||
|
final String? headerImageUrl; // Artist header image for background
|
||||||
|
final int? monthlyListeners; // Artist monthly listeners
|
||||||
final List<ArtistAlbum>? artistAlbums; // For artist page
|
final List<ArtistAlbum>? artistAlbums; // For artist page
|
||||||
|
final List<Track>? artistTopTracks; // Artist's popular tracks
|
||||||
final List<SearchArtist>? searchArtists; // For search results
|
final List<SearchArtist>? searchArtists; // For search results
|
||||||
final bool hasSearchText; // For back button handling
|
final bool hasSearchText; // For back button handling
|
||||||
|
final bool isShowingRecentAccess; // For recent access mode
|
||||||
final String? searchExtensionId; // Extension ID used for current search results
|
final String? searchExtensionId; // Extension ID used for current search results
|
||||||
|
|
||||||
const TrackState({
|
const TrackState({
|
||||||
@@ -32,9 +36,13 @@ class TrackState {
|
|||||||
this.artistId,
|
this.artistId,
|
||||||
this.artistName,
|
this.artistName,
|
||||||
this.coverUrl,
|
this.coverUrl,
|
||||||
|
this.headerImageUrl,
|
||||||
|
this.monthlyListeners,
|
||||||
this.artistAlbums,
|
this.artistAlbums,
|
||||||
|
this.artistTopTracks,
|
||||||
this.searchArtists,
|
this.searchArtists,
|
||||||
this.hasSearchText = false,
|
this.hasSearchText = false,
|
||||||
|
this.isShowingRecentAccess = false,
|
||||||
this.searchExtensionId,
|
this.searchExtensionId,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -50,9 +58,13 @@ class TrackState {
|
|||||||
String? artistId,
|
String? artistId,
|
||||||
String? artistName,
|
String? artistName,
|
||||||
String? coverUrl,
|
String? coverUrl,
|
||||||
|
String? headerImageUrl,
|
||||||
|
int? monthlyListeners,
|
||||||
List<ArtistAlbum>? artistAlbums,
|
List<ArtistAlbum>? artistAlbums,
|
||||||
|
List<Track>? artistTopTracks,
|
||||||
List<SearchArtist>? searchArtists,
|
List<SearchArtist>? searchArtists,
|
||||||
bool? hasSearchText,
|
bool? hasSearchText,
|
||||||
|
bool? isShowingRecentAccess,
|
||||||
String? searchExtensionId,
|
String? searchExtensionId,
|
||||||
}) {
|
}) {
|
||||||
return TrackState(
|
return TrackState(
|
||||||
@@ -65,9 +77,13 @@ class TrackState {
|
|||||||
artistId: artistId ?? this.artistId,
|
artistId: artistId ?? this.artistId,
|
||||||
artistName: artistName ?? this.artistName,
|
artistName: artistName ?? this.artistName,
|
||||||
coverUrl: coverUrl ?? this.coverUrl,
|
coverUrl: coverUrl ?? this.coverUrl,
|
||||||
|
headerImageUrl: headerImageUrl ?? this.headerImageUrl,
|
||||||
|
monthlyListeners: monthlyListeners ?? this.monthlyListeners,
|
||||||
artistAlbums: artistAlbums ?? this.artistAlbums,
|
artistAlbums: artistAlbums ?? this.artistAlbums,
|
||||||
|
artistTopTracks: artistTopTracks ?? this.artistTopTracks,
|
||||||
searchArtists: searchArtists ?? this.searchArtists,
|
searchArtists: searchArtists ?? this.searchArtists,
|
||||||
hasSearchText: hasSearchText ?? this.hasSearchText,
|
hasSearchText: hasSearchText ?? this.hasSearchText,
|
||||||
|
isShowingRecentAccess: isShowingRecentAccess ?? this.isShowingRecentAccess,
|
||||||
searchExtensionId: searchExtensionId,
|
searchExtensionId: searchExtensionId,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -171,13 +187,21 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
final artistData = result['artist'] as Map<String, dynamic>;
|
final artistData = result['artist'] as Map<String, dynamic>;
|
||||||
final albumsList = artistData['albums'] as List<dynamic>? ?? [];
|
final albumsList = artistData['albums'] as List<dynamic>? ?? [];
|
||||||
final albums = albumsList.map((a) => _parseArtistAlbum(a as Map<String, dynamic>)).toList();
|
final albums = albumsList.map((a) => _parseArtistAlbum(a as Map<String, dynamic>)).toList();
|
||||||
|
|
||||||
|
// Parse top tracks if available
|
||||||
|
final topTracksList = artistData['top_tracks'] as List<dynamic>? ?? [];
|
||||||
|
final topTracks = topTracksList.map((t) => _parseSearchTrack(t as Map<String, dynamic>, source: extensionId)).toList();
|
||||||
|
|
||||||
state = TrackState(
|
state = TrackState(
|
||||||
tracks: [],
|
tracks: [],
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
artistId: artistData['id'] as String?,
|
artistId: artistData['id'] as String?,
|
||||||
artistName: artistData['name'] as String?,
|
artistName: artistData['name'] as String?,
|
||||||
coverUrl: artistData['image_url'] as String? ?? artistData['images'] 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,
|
artistAlbums: albums,
|
||||||
|
artistTopTracks: topTracks.isNotEmpty ? topTracks : null,
|
||||||
searchExtensionId: extensionId,
|
searchExtensionId: extensionId,
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
@@ -491,6 +515,11 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
state = state.copyWith(hasSearchText: hasText);
|
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
|
/// Set tracks from a collection (album/playlist) opened from search results
|
||||||
void setTracksFromCollection({
|
void setTracksFromCollection({
|
||||||
required List<Track> tracks,
|
required List<Track> tracks,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import 'package:spotiflac_android/models/track.dart';
|
|||||||
import 'package:spotiflac_android/models/download_item.dart';
|
import 'package:spotiflac_android/models/download_item.dart';
|
||||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||||
import 'package:spotiflac_android/providers/settings_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/services/platform_bridge.dart';
|
||||||
import 'package:spotiflac_android/widgets/download_service_picker.dart';
|
import 'package:spotiflac_android/widgets/download_service_picker.dart';
|
||||||
|
|
||||||
@@ -63,6 +64,19 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.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
|
// Priority: widget.tracks > cache > fetch
|
||||||
_tracks = widget.tracks ?? _AlbumCache.get(widget.albumId);
|
_tracks = widget.tracks ?? _AlbumCache.get(widget.albumId);
|
||||||
if (_tracks == null) {
|
if (_tracks == null) {
|
||||||
|
|||||||
+609
-166
@@ -1,52 +1,87 @@
|
|||||||
|
import 'dart:io';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:cached_network_image/cached_network_image.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/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/track_provider.dart';
|
||||||
import 'package:spotiflac_android/providers/settings_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/services/platform_bridge.dart';
|
||||||
import 'package:spotiflac_android/screens/album_screen.dart';
|
import 'package:spotiflac_android/screens/album_screen.dart';
|
||||||
import 'package:spotiflac_android/screens/home_tab.dart' show ExtensionAlbumScreen;
|
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 {
|
class _ArtistCache {
|
||||||
static final Map<String, _CacheEntry> _cache = {};
|
static final Map<String, _CacheEntry> _cache = {};
|
||||||
static const Duration _ttl = Duration(minutes: 10);
|
static const Duration _ttl = Duration(minutes: 10);
|
||||||
|
|
||||||
static List<ArtistAlbum>? get(String artistId) {
|
static _CacheEntry? get(String artistId) {
|
||||||
final entry = _cache[artistId];
|
final entry = _cache[artistId];
|
||||||
if (entry == null) return null;
|
if (entry == null) return null;
|
||||||
if (DateTime.now().isAfter(entry.expiresAt)) {
|
if (DateTime.now().isAfter(entry.expiresAt)) {
|
||||||
_cache.remove(artistId);
|
_cache.remove(artistId);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return entry.albums;
|
return entry;
|
||||||
}
|
}
|
||||||
|
|
||||||
static void set(String artistId, List<ArtistAlbum> albums) {
|
static void set(String artistId, {
|
||||||
_cache[artistId] = _CacheEntry(albums, DateTime.now().add(_ttl));
|
required List<ArtistAlbum> albums,
|
||||||
|
List<Track>? topTracks,
|
||||||
|
String? headerImageUrl,
|
||||||
|
int? monthlyListeners,
|
||||||
|
}) {
|
||||||
|
_cache[artistId] = _CacheEntry(
|
||||||
|
albums: albums,
|
||||||
|
topTracks: topTracks,
|
||||||
|
headerImageUrl: headerImageUrl,
|
||||||
|
monthlyListeners: monthlyListeners,
|
||||||
|
expiresAt: DateTime.now().add(_ttl),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _CacheEntry {
|
class _CacheEntry {
|
||||||
final List<ArtistAlbum> albums;
|
final List<ArtistAlbum> albums;
|
||||||
|
final List<Track>? topTracks;
|
||||||
|
final String? headerImageUrl;
|
||||||
|
final int? monthlyListeners;
|
||||||
final DateTime expiresAt;
|
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 {
|
class ArtistScreen extends ConsumerStatefulWidget {
|
||||||
final String artistId;
|
final String artistId;
|
||||||
final String artistName;
|
final String artistName;
|
||||||
final String? coverUrl;
|
final String? coverUrl;
|
||||||
final List<ArtistAlbum>? albums; // Optional - will fetch if null
|
final String? headerImageUrl;
|
||||||
|
final int? monthlyListeners;
|
||||||
|
final List<ArtistAlbum>? albums;
|
||||||
|
final List<Track>? topTracks;
|
||||||
|
final String? extensionId; // If set, skip fetching from Spotify/Deezer
|
||||||
|
|
||||||
const ArtistScreen({
|
const ArtistScreen({
|
||||||
super.key,
|
super.key,
|
||||||
required this.artistId,
|
required this.artistId,
|
||||||
required this.artistName,
|
required this.artistName,
|
||||||
this.coverUrl,
|
this.coverUrl,
|
||||||
|
this.headerImageUrl,
|
||||||
|
this.monthlyListeners,
|
||||||
this.albums,
|
this.albums,
|
||||||
|
this.topTracks,
|
||||||
|
this.extensionId,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -56,14 +91,62 @@ class ArtistScreen extends ConsumerStatefulWidget {
|
|||||||
class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||||
bool _isLoadingDiscography = false;
|
bool _isLoadingDiscography = false;
|
||||||
List<ArtistAlbum>? _albums;
|
List<ArtistAlbum>? _albums;
|
||||||
|
List<Track>? _topTracks;
|
||||||
|
String? _headerImageUrl;
|
||||||
|
int? _monthlyListeners;
|
||||||
String? _error;
|
String? _error;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
// Priority: widget.albums > cache > fetch
|
|
||||||
_albums = widget.albums ?? _ArtistCache.get(widget.artistId);
|
// Record access for recent history
|
||||||
if (_albums == null) {
|
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();
|
_fetchDiscography();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -72,31 +155,60 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
setState(() => _isLoadingDiscography = true);
|
setState(() => _isLoadingDiscography = true);
|
||||||
try {
|
try {
|
||||||
List<ArtistAlbum> albums;
|
List<ArtistAlbum> albums;
|
||||||
|
List<Track>? topTracks;
|
||||||
|
String? headerImage;
|
||||||
|
int? listeners;
|
||||||
|
|
||||||
// Check if this is a Deezer artist ID (format: "deezer:123456")
|
// Check if this is a Deezer artist ID (format: "deezer:123456")
|
||||||
if (widget.artistId.startsWith('deezer:')) {
|
if (widget.artistId.startsWith('deezer:')) {
|
||||||
final deezerArtistId = widget.artistId.replaceFirst('deezer:', '');
|
final deezerArtistId = widget.artistId.replaceFirst('deezer:', '');
|
||||||
// ignore: avoid_print
|
|
||||||
print('[ArtistScreen] Fetching from Deezer: $deezerArtistId');
|
|
||||||
final metadata = await PlatformBridge.getDeezerMetadata('artist', deezerArtistId);
|
final metadata = await PlatformBridge.getDeezerMetadata('artist', deezerArtistId);
|
||||||
final albumsList = metadata['albums'] as List<dynamic>;
|
final albumsList = metadata['albums'] as List<dynamic>;
|
||||||
albums = albumsList.map((a) => _parseArtistAlbum(a as Map<String, dynamic>)).toList();
|
albums = albumsList.map((a) => _parseArtistAlbum(a as Map<String, dynamic>)).toList();
|
||||||
} else {
|
} else {
|
||||||
// Spotify artist - use fallback method
|
// Spotify artist - use extension handler via URL
|
||||||
// ignore: avoid_print
|
|
||||||
print('[ArtistScreen] Fetching from Spotify with fallback: ${widget.artistId}');
|
|
||||||
final url = 'https://open.spotify.com/artist/${widget.artistId}';
|
final url = 'https://open.spotify.com/artist/${widget.artistId}';
|
||||||
final metadata = await PlatformBridge.getSpotifyMetadataWithFallback(url);
|
final result = await PlatformBridge.handleURLWithExtension(url);
|
||||||
final albumsList = metadata['albums'] as List<dynamic>;
|
|
||||||
albums = albumsList.map((a) => _parseArtistAlbum(a as Map<String, dynamic>)).toList();
|
if (result != null && result['artist'] != null) {
|
||||||
|
final artistData = result['artist'] as Map<String, dynamic>;
|
||||||
|
final albumsList = artistData['albums'] as List<dynamic>? ?? [];
|
||||||
|
albums = albumsList.map((a) => _parseArtistAlbum(a as Map<String, dynamic>)).toList();
|
||||||
|
|
||||||
|
// Parse top tracks if available
|
||||||
|
final topTracksList = artistData['top_tracks'] as List<dynamic>? ?? [];
|
||||||
|
if (topTracksList.isNotEmpty) {
|
||||||
|
topTracks = topTracksList.map((t) => _parseTrack(t as Map<String, dynamic>)).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<dynamic>;
|
||||||
|
albums = albumsList.map((a) => _parseArtistAlbum(a as Map<String, dynamic>)).toList();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store in cache
|
// Store in cache (preserve existing values if new ones are null)
|
||||||
_ArtistCache.set(widget.artistId, albums);
|
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) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_albums = albums;
|
_albums = albums;
|
||||||
|
_topTracks = topTracks;
|
||||||
|
_headerImageUrl = finalHeaderImage;
|
||||||
|
_monthlyListeners = finalListeners;
|
||||||
_isLoadingDiscography = false;
|
_isLoadingDiscography = false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -110,15 +222,41 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Track _parseTrack(Map<String, dynamic> 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<String, dynamic> data) {
|
ArtistAlbum _parseArtistAlbum(Map<String, dynamic> data) {
|
||||||
return ArtistAlbum(
|
return ArtistAlbum(
|
||||||
id: data['id'] as String? ?? '',
|
id: data['id'] as String? ?? '',
|
||||||
name: data['name'] as String? ?? '',
|
name: data['name'] as String? ?? '',
|
||||||
releaseDate: data['release_date'] as String? ?? '',
|
releaseDate: data['release_date'] as String? ?? '',
|
||||||
totalTracks: data['total_tracks'] as int? ?? 0,
|
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',
|
albumType: data['album_type'] as String? ?? 'album',
|
||||||
artists: data['artists'] as String? ?? '',
|
artists: data['artists'] as String? ?? '',
|
||||||
|
providerId: data['provider_id']?.toString(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,43 +269,63 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
final compilations = albums.where((a) => a.albumType == 'compilation').toList();
|
final compilations = albums.where((a) => a.albumType == 'compilation').toList();
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
body: Stack(
|
body: CustomScrollView(
|
||||||
children: [
|
slivers: [
|
||||||
CustomScrollView(
|
_buildHeader(context, colorScheme),
|
||||||
slivers: [
|
if (_isLoadingDiscography)
|
||||||
_buildAppBar(context, colorScheme),
|
const SliverToBoxAdapter(child: Padding(
|
||||||
_buildInfoCard(context, colorScheme),
|
padding: EdgeInsets.all(32),
|
||||||
if (_isLoadingDiscography)
|
child: Center(child: CircularProgressIndicator()),
|
||||||
const SliverToBoxAdapter(child: Padding(
|
)),
|
||||||
padding: EdgeInsets.all(32),
|
if (_error != null)
|
||||||
child: Center(child: CircularProgressIndicator()),
|
SliverToBoxAdapter(child: Padding(
|
||||||
)),
|
padding: const EdgeInsets.all(16),
|
||||||
if (_error != null)
|
child: _buildErrorWidget(_error!, colorScheme),
|
||||||
SliverToBoxAdapter(child: Padding(
|
)),
|
||||||
padding: const EdgeInsets.all(16),
|
if (!_isLoadingDiscography && _error == null) ...[
|
||||||
child: _buildErrorWidget(_error!, colorScheme),
|
// Popular tracks section
|
||||||
)),
|
if (_topTracks != null && _topTracks!.isNotEmpty)
|
||||||
if (!_isLoadingDiscography && _error == null) ...[
|
SliverToBoxAdapter(child: _buildPopularSection(colorScheme)),
|
||||||
if (albumsOnly.isNotEmpty) SliverToBoxAdapter(child: _buildAlbumSection(context.l10n.artistAlbums, albumsOnly, colorScheme)),
|
// Discography sections
|
||||||
if (singles.isNotEmpty) SliverToBoxAdapter(child: _buildAlbumSection(context.l10n.artistSingles, singles, colorScheme)),
|
if (albumsOnly.isNotEmpty)
|
||||||
if (compilations.isNotEmpty) SliverToBoxAdapter(child: _buildAlbumSection(context.l10n.artistCompilations, compilations, colorScheme)),
|
SliverToBoxAdapter(child: _buildAlbumSection(context.l10n.artistAlbums, albumsOnly, colorScheme)),
|
||||||
],
|
if (singles.isNotEmpty)
|
||||||
const SliverToBoxAdapter(child: SizedBox(height: 32)),
|
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) {
|
/// Build Spotify-style header with full-width image and artist name overlay
|
||||||
// Validate image URL - must be non-null, non-empty, and have a valid host
|
Widget _buildHeader(BuildContext context, ColorScheme colorScheme) {
|
||||||
final hasValidImage = widget.coverUrl != null &&
|
// Use header image if available, otherwise fall back to cover URL
|
||||||
widget.coverUrl!.isNotEmpty &&
|
// Prefer: fetched header > widget header > widget cover
|
||||||
Uri.tryParse(widget.coverUrl!)?.hasAuthority == true;
|
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(
|
return SliverAppBar(
|
||||||
expandedHeight: 280,
|
expandedHeight: 380,
|
||||||
pinned: true,
|
pinned: true,
|
||||||
stretch: true,
|
stretch: true,
|
||||||
backgroundColor: colorScheme.surface,
|
backgroundColor: colorScheme.surface,
|
||||||
@@ -176,49 +334,84 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
background: Stack(
|
background: Stack(
|
||||||
fit: StackFit.expand,
|
fit: StackFit.expand,
|
||||||
children: [
|
children: [
|
||||||
|
// Background image - full width, no circular crop
|
||||||
if (hasValidImage)
|
if (hasValidImage)
|
||||||
CachedNetworkImage(
|
CachedNetworkImage(
|
||||||
imageUrl: widget.coverUrl!,
|
imageUrl: imageUrl,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
color: Colors.black.withValues(alpha: 0.5),
|
alignment: Alignment.topCenter, // Show top of image (faces)
|
||||||
colorBlendMode: BlendMode.darken,
|
memCacheWidth: 800,
|
||||||
memCacheWidth: 600,
|
placeholder: (context, url) => Container(
|
||||||
errorWidget: (context, url, error) => Container(color: colorScheme.surfaceContainerHighest),
|
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(
|
Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
gradient: LinearGradient(
|
gradient: LinearGradient(
|
||||||
begin: Alignment.topCenter,
|
begin: Alignment.topCenter,
|
||||||
end: Alignment.bottomCenter,
|
end: Alignment.bottomCenter,
|
||||||
colors: [Colors.transparent, colorScheme.surface.withValues(alpha: 0.8), colorScheme.surface],
|
colors: [
|
||||||
stops: const [0.0, 0.7, 1.0],
|
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(
|
// Artist name and listeners at bottom
|
||||||
child: Padding(
|
Positioned(
|
||||||
padding: const EdgeInsets.only(top: 60),
|
left: 16,
|
||||||
child: Container(
|
right: 16,
|
||||||
width: 140,
|
bottom: 16,
|
||||||
height: 140,
|
child: Column(
|
||||||
decoration: BoxDecoration(
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
shape: BoxShape.circle,
|
mainAxisSize: MainAxisSize.min,
|
||||||
boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.3), blurRadius: 20, offset: const Offset(0, 10))],
|
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(
|
if (listenersText != null) ...[
|
||||||
child: hasValidImage
|
const SizedBox(height: 4),
|
||||||
? CachedNetworkImage(
|
Text(
|
||||||
imageUrl: widget.coverUrl!,
|
listenersText,
|
||||||
fit: BoxFit.cover,
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
memCacheWidth: 280,
|
color: Colors.white.withValues(alpha: 0.8),
|
||||||
errorWidget: (context, url, error) => Container(
|
shadows: [
|
||||||
color: colorScheme.surfaceContainerHighest,
|
Shadow(
|
||||||
child: Icon(Icons.person, size: 48, color: colorScheme.onSurfaceVariant),
|
offset: const Offset(0, 1),
|
||||||
),
|
blurRadius: 2,
|
||||||
)
|
color: Colors.black.withValues(alpha: 0.5),
|
||||||
: Container(color: colorScheme.surfaceContainerHighest, child: Icon(Icons.person, size: 48, color: colorScheme.onSurfaceVariant)),
|
),
|
||||||
),
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -226,44 +419,280 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
stretchModes: const [StretchMode.zoomBackground, StretchMode.blurBackground],
|
stretchModes: const [StretchMode.zoomBackground, StretchMode.blurBackground],
|
||||||
),
|
),
|
||||||
leading: IconButton(
|
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),
|
onPressed: () => Navigator.pop(context),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme) {
|
/// Build Popular tracks section like Spotify
|
||||||
return SliverToBoxAdapter(
|
Widget _buildPopularSection(ColorScheme colorScheme) {
|
||||||
child: Padding(
|
if (_topTracks == null || _topTracks!.isEmpty) return const SizedBox.shrink();
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
child: Card(
|
// Show max 5 tracks
|
||||||
elevation: 0,
|
final tracks = _topTracks!.take(5).toList();
|
||||||
color: colorScheme.surfaceContainerLow,
|
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
return Column(
|
||||||
child: Padding(
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
padding: const EdgeInsets.all(20),
|
children: [
|
||||||
child: Column(
|
Padding(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
padding: const EdgeInsets.fromLTRB(16, 24, 16, 12),
|
||||||
children: [
|
child: Text(
|
||||||
Text(widget.artistName, style: Theme.of(context).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold, color: colorScheme.onSurface)),
|
context.l10n.artistPopular,
|
||||||
const SizedBox(height: 8),
|
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||||
if (_albums != null)
|
fontWeight: FontWeight.bold,
|
||||||
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)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
...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<ArtistScreen> {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(20, 16, 20, 8),
|
padding: const EdgeInsets.fromLTRB(16, 24, 16, 12),
|
||||||
child: Row(
|
child: Text(
|
||||||
children: [
|
'$title (${albums.length})',
|
||||||
Icon(Icons.album, size: 20, color: colorScheme.primary),
|
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||||
const SizedBox(width: 8),
|
fontWeight: FontWeight.bold,
|
||||||
Text('$title (${albums.length})', style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600, color: colorScheme.primary)),
|
),
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
height: 210,
|
height: 220,
|
||||||
child: ListView.builder(
|
child: ListView.builder(
|
||||||
scrollDirection: Axis.horizontal,
|
scrollDirection: Axis.horizontal,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
itemCount: albums.length,
|
itemCount: albums.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final album = albums[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<ArtistScreen> {
|
|||||||
onTap: () => _navigateToAlbum(album),
|
onTap: () => _navigateToAlbum(album),
|
||||||
child: Container(
|
child: Container(
|
||||||
width: 140,
|
width: 140,
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 6),
|
margin: const EdgeInsets.symmetric(horizontal: 4),
|
||||||
child: Card(
|
child: Column(
|
||||||
elevation: 0,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
color: colorScheme.surfaceContainerLow,
|
children: [
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
// Album cover
|
||||||
child: Padding(
|
ClipRRect(
|
||||||
padding: const EdgeInsets.all(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
child: Column(
|
child: album.coverUrl != null
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
? CachedNetworkImage(
|
||||||
children: [
|
imageUrl: album.coverUrl!,
|
||||||
ClipRRect(
|
width: 140,
|
||||||
borderRadius: BorderRadius.circular(12),
|
height: 140,
|
||||||
child: album.coverUrl != null
|
fit: BoxFit.cover,
|
||||||
? CachedNetworkImage(imageUrl: album.coverUrl!, width: 124, height: 124, fit: BoxFit.cover, memCacheWidth: 248)
|
memCacheWidth: 280,
|
||||||
: Container(width: 124, height: 124, color: colorScheme.surfaceContainerHighest, child: Icon(Icons.album, color: colorScheme.onSurfaceVariant, size: 40)),
|
placeholder: (context, url) => Container(
|
||||||
),
|
width: 140,
|
||||||
const SizedBox(height: 6),
|
height: 140,
|
||||||
Expanded(
|
color: colorScheme.surfaceContainerHighest,
|
||||||
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,
|
|
||||||
),
|
),
|
||||||
],
|
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) {
|
void _navigateToAlbum(ArtistAlbum album) {
|
||||||
// Navigate immediately with data from artist discography, fetch tracks in AlbumScreen
|
|
||||||
ref.read(settingsProvider.notifier).setHasSearchedBefore();
|
ref.read(settingsProvider.notifier).setHasSearchedBefore();
|
||||||
|
|
||||||
// Check if this album is from an extension (has providerId)
|
|
||||||
if (album.providerId != null && album.providerId!.isNotEmpty) {
|
if (album.providerId != null && album.providerId!.isNotEmpty) {
|
||||||
// Use ExtensionAlbumScreen for extension albums
|
|
||||||
Navigator.push(context, MaterialPageRoute(
|
Navigator.push(context, MaterialPageRoute(
|
||||||
builder: (context) => ExtensionAlbumScreen(
|
builder: (context) => ExtensionAlbumScreen(
|
||||||
extensionId: album.providerId!,
|
extensionId: album.providerId!,
|
||||||
@@ -361,19 +808,16 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
),
|
),
|
||||||
));
|
));
|
||||||
} else {
|
} else {
|
||||||
// Use regular AlbumScreen for Spotify/Deezer albums
|
|
||||||
Navigator.push(context, MaterialPageRoute(
|
Navigator.push(context, MaterialPageRoute(
|
||||||
builder: (context) => AlbumScreen(
|
builder: (context) => AlbumScreen(
|
||||||
albumId: album.id,
|
albumId: album.id,
|
||||||
albumName: album.name,
|
albumName: album.name,
|
||||||
coverUrl: album.coverUrl,
|
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) {
|
Widget _buildErrorWidget(String error, ColorScheme colorScheme) {
|
||||||
final isRateLimit = error.contains('429') ||
|
final isRateLimit = error.contains('429') ||
|
||||||
error.toLowerCase().contains('rate limit') ||
|
error.toLowerCase().contains('rate limit') ||
|
||||||
@@ -383,7 +827,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
return Card(
|
return Card(
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
color: colorScheme.errorContainer,
|
color: colorScheme.errorContainer,
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
child: Row(
|
child: Row(
|
||||||
@@ -418,11 +862,10 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default error display
|
|
||||||
return Card(
|
return Card(
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
color: colorScheme.errorContainer.withValues(alpha: 0.5),
|
color: colorScheme.errorContainer.withValues(alpha: 0.5),
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
child: Row(
|
child: Row(
|
||||||
|
|||||||
+386
-47
@@ -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/download_queue_provider.dart';
|
||||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||||
import 'package:spotiflac_android/providers/extension_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/track_metadata_screen.dart';
|
||||||
import 'package:spotiflac_android/screens/album_screen.dart';
|
import 'package:spotiflac_android/screens/album_screen.dart';
|
||||||
import 'package:spotiflac_android/screens/artist_screen.dart';
|
import 'package:spotiflac_android/screens/artist_screen.dart';
|
||||||
@@ -38,16 +39,27 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
|||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_urlController.addListener(_onSearchChanged);
|
_urlController.addListener(_onSearchChanged);
|
||||||
|
_searchFocusNode.addListener(_onSearchFocusChanged);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_urlController.removeListener(_onSearchChanged);
|
_urlController.removeListener(_onSearchChanged);
|
||||||
|
_searchFocusNode.removeListener(_onSearchFocusChanged);
|
||||||
_urlController.dispose();
|
_urlController.dispose();
|
||||||
_searchFocusNode.dispose();
|
_searchFocusNode.dispose();
|
||||||
super.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
|
/// Called when trackState changes - used to sync search bar with state
|
||||||
void _onTrackStateChanged(TrackState? previous, TrackState next) {
|
void _onTrackStateChanged(TrackState? previous, TrackState next) {
|
||||||
// If state was cleared (no content, no search text, not loading), clear the search bar
|
// If state was cleared (no content, no search text, not loading), clear the search bar
|
||||||
@@ -147,7 +159,7 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
|||||||
void _navigateToDetailIfNeeded() {
|
void _navigateToDetailIfNeeded() {
|
||||||
final trackState = ref.read(trackProvider);
|
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) {
|
if (trackState.albumId != null && trackState.albumName != null && trackState.tracks.isNotEmpty) {
|
||||||
Navigator.push(context, MaterialPageRoute(builder: (context) => AlbumScreen(
|
Navigator.push(context, MaterialPageRoute(builder: (context) => AlbumScreen(
|
||||||
albumId: trackState.albumId!,
|
albumId: trackState.albumId!,
|
||||||
@@ -163,6 +175,14 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
|||||||
|
|
||||||
// Navigate to Playlist screen
|
// Navigate to Playlist screen
|
||||||
if (trackState.playlistName != null && trackState.tracks.isNotEmpty) {
|
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(
|
Navigator.push(context, MaterialPageRoute(builder: (context) => PlaylistScreen(
|
||||||
playlistName: trackState.playlistName!,
|
playlistName: trackState.playlistName!,
|
||||||
coverUrl: trackState.coverUrl,
|
coverUrl: trackState.coverUrl,
|
||||||
@@ -174,7 +194,7 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
|||||||
return;
|
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) {
|
if (trackState.artistId != null && trackState.artistName != null && trackState.artistAlbums != null) {
|
||||||
Navigator.push(context, MaterialPageRoute(builder: (context) => ArtistScreen(
|
Navigator.push(context, MaterialPageRoute(builder: (context) => ArtistScreen(
|
||||||
artistId: trackState.artistId!,
|
artistId: trackState.artistId!,
|
||||||
@@ -271,20 +291,23 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
|||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
||||||
|
// ignore: use_build_context_synchronously
|
||||||
|
final l10n = context.l10n;
|
||||||
|
|
||||||
// Optionally show confirmation dialog
|
// Optionally show confirmation dialog
|
||||||
final confirmed = await showDialog<bool>(
|
final confirmed = await showDialog<bool>(
|
||||||
context: this.context,
|
context: this.context,
|
||||||
builder: (dialogCtx) => AlertDialog(
|
builder: (dialogCtx) => AlertDialog(
|
||||||
title: Text(context.l10n.dialogImportPlaylistTitle),
|
title: Text(l10n.dialogImportPlaylistTitle),
|
||||||
content: Text(context.l10n.dialogImportPlaylistMessage(tracks.length)),
|
content: Text(l10n.dialogImportPlaylistMessage(tracks.length)),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.pop(dialogCtx, false),
|
onPressed: () => Navigator.pop(dialogCtx, false),
|
||||||
child: Text(context.l10n.dialogCancel),
|
child: Text(l10n.dialogCancel),
|
||||||
),
|
),
|
||||||
FilledButton(
|
FilledButton(
|
||||||
onPressed: () => Navigator.pop(dialogCtx, true),
|
onPressed: () => Navigator.pop(dialogCtx, true),
|
||||||
child: Text(context.l10n.dialogImport),
|
child: Text(l10n.dialogImport),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -295,9 +318,9 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
|||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(this.context).showSnackBar(
|
ScaffoldMessenger.of(this.context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text(context.l10n.snackbarAddedTracksToQueue(tracks.length)),
|
content: Text(l10n.snackbarAddedTracksToQueue(tracks.length)),
|
||||||
action: SnackBarAction(
|
action: SnackBarAction(
|
||||||
label: context.l10n.snackbarViewQueue,
|
label: l10n.snackbarViewQueue,
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
// Navigate to queue tab (handled by main_shell index)
|
// Navigate to queue tab (handled by main_shell index)
|
||||||
// We don't have direct access to set index here easily without provider
|
// We don't have direct access to set index here easily without provider
|
||||||
@@ -337,39 +360,62 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
|||||||
ref.watch(extensionProvider.select((s) => s.extensions));
|
ref.watch(extensionProvider.select((s) => s.extensions));
|
||||||
|
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
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 screenHeight = MediaQuery.of(context).size.height;
|
||||||
final topPadding = MediaQuery.of(context).padding.top;
|
final topPadding = MediaQuery.of(context).padding.top;
|
||||||
final historyItems = ref.watch(downloadHistoryProvider.select((s) => s.items));
|
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(
|
return GestureDetector(
|
||||||
body: CustomScrollView(
|
onTap: () {
|
||||||
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
|
// Unfocus search bar when tapping outside
|
||||||
slivers: [
|
if (_searchFocusNode.hasFocus) {
|
||||||
// App Bar - always present
|
_searchFocusNode.unfocus();
|
||||||
SliverAppBar(
|
}
|
||||||
expandedHeight: 120 + topPadding,
|
},
|
||||||
collapsedHeight: kToolbarHeight,
|
behavior: HitTestBehavior.translucent,
|
||||||
floating: false,
|
child: Scaffold(
|
||||||
pinned: true,
|
body: CustomScrollView(
|
||||||
backgroundColor: colorScheme.surface,
|
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
|
||||||
surfaceTintColor: Colors.transparent,
|
slivers: [
|
||||||
automaticallyImplyLeading: false,
|
// App Bar - always present
|
||||||
flexibleSpace: LayoutBuilder(
|
SliverAppBar(
|
||||||
builder: (context, constraints) {
|
expandedHeight: 120 + topPadding,
|
||||||
final maxHeight = 120 + topPadding;
|
collapsedHeight: kToolbarHeight,
|
||||||
final minHeight = kToolbarHeight + topPadding;
|
floating: false,
|
||||||
final expandRatio = ((constraints.maxHeight - minHeight) / (maxHeight - minHeight)).clamp(0.0, 1.0);
|
pinned: true,
|
||||||
|
backgroundColor: colorScheme.surface,
|
||||||
return FlexibleSpaceBar(
|
surfaceTintColor: Colors.transparent,
|
||||||
expandedTitleScale: 1.0,
|
automaticallyImplyLeading: false,
|
||||||
titlePadding: const EdgeInsets.only(left: 24, bottom: 16),
|
flexibleSpace: LayoutBuilder(
|
||||||
title: Text(
|
builder: (context, constraints) {
|
||||||
context.l10n.homeTitle,
|
final maxHeight = 120 + topPadding;
|
||||||
style: TextStyle(
|
final minHeight = kToolbarHeight + topPadding;
|
||||||
fontSize: 20 + (14 * expandRatio), // 20 -> 34
|
final expandRatio = ((constraints.maxHeight - minHeight) / (maxHeight - minHeight)).clamp(0.0, 1.0);
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
color: colorScheme.onSurface,
|
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<HomeTab> 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
|
// Idle content below search bar - always in tree
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: AnimatedSize(
|
child: AnimatedSize(
|
||||||
duration: const Duration(milliseconds: 250),
|
duration: const Duration(milliseconds: 250),
|
||||||
curve: Curves.easeOut,
|
curve: Curves.easeOut,
|
||||||
child: hasResults
|
child: (hasResults || showRecentAccess)
|
||||||
? const SizedBox.shrink()
|
? const SizedBox.shrink()
|
||||||
: Column(
|
: Column(
|
||||||
children: [
|
children: [
|
||||||
@@ -479,7 +532,8 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
), // Close GestureDetector
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildRecentDownloads(List<DownloadHistoryItem> items, ColorScheme colorScheme) {
|
Widget _buildRecentDownloads(List<DownloadHistoryItem> items, ColorScheme colorScheme) {
|
||||||
@@ -553,6 +607,224 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Build recent access history section (shown when search focused)
|
||||||
|
Widget _buildRecentAccess(List<RecentAccessItem> 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 = <String>{};
|
||||||
|
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) {
|
void _navigateToMetadataScreen(DownloadHistoryItem item) {
|
||||||
Navigator.push(context, PageRouteBuilder(
|
Navigator.push(context, PageRouteBuilder(
|
||||||
transitionDuration: const Duration(milliseconds: 300),
|
transitionDuration: const Duration(milliseconds: 300),
|
||||||
@@ -888,6 +1160,9 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
|||||||
void _navigateToArtist(String artistId, String artistName, String? imageUrl) {
|
void _navigateToArtist(String artistId, String artistName, String? imageUrl) {
|
||||||
// Navigate immediately with data from search, fetch albums in ArtistScreen
|
// Navigate immediately with data from search, fetch albums in ArtistScreen
|
||||||
ref.read(settingsProvider.notifier).setHasSearchedBefore();
|
ref.read(settingsProvider.notifier).setHasSearchedBefore();
|
||||||
|
|
||||||
|
// Recording is done in ArtistScreen.initState to avoid duplicates
|
||||||
|
|
||||||
Navigator.push(context, MaterialPageRoute(
|
Navigator.push(context, MaterialPageRoute(
|
||||||
builder: (context) => ArtistScreen(
|
builder: (context) => ArtistScreen(
|
||||||
artistId: artistId,
|
artistId: artistId,
|
||||||
@@ -909,6 +1184,15 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
|||||||
|
|
||||||
ref.read(settingsProvider.notifier).setHasSearchedBefore();
|
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
|
// Navigate to AlbumScreen - it will fetch tracks via extension
|
||||||
Navigator.push(context, MaterialPageRoute(
|
Navigator.push(context, MaterialPageRoute(
|
||||||
builder: (context) => ExtensionAlbumScreen(
|
builder: (context) => ExtensionAlbumScreen(
|
||||||
@@ -931,6 +1215,15 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
|||||||
|
|
||||||
ref.read(settingsProvider.notifier).setHasSearchedBefore();
|
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
|
// Navigate to ExtensionPlaylistScreen - it will fetch tracks via extension
|
||||||
Navigator.push(context, MaterialPageRoute(
|
Navigator.push(context, MaterialPageRoute(
|
||||||
builder: (context) => ExtensionPlaylistScreen(
|
builder: (context) => ExtensionPlaylistScreen(
|
||||||
@@ -953,6 +1246,14 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
|||||||
|
|
||||||
ref.read(settingsProvider.notifier).setHasSearchedBefore();
|
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
|
// Navigate to ExtensionArtistScreen - it will fetch albums via extension
|
||||||
Navigator.push(context, MaterialPageRoute(
|
Navigator.push(context, MaterialPageRoute(
|
||||||
builder: (context) => ExtensionArtistScreen(
|
builder: (context) => ExtensionArtistScreen(
|
||||||
@@ -1687,6 +1988,9 @@ class ExtensionArtistScreen extends ConsumerStatefulWidget {
|
|||||||
|
|
||||||
class _ExtensionArtistScreenState extends ConsumerState<ExtensionArtistScreen> {
|
class _ExtensionArtistScreenState extends ConsumerState<ExtensionArtistScreen> {
|
||||||
List<ArtistAlbum>? _albums;
|
List<ArtistAlbum>? _albums;
|
||||||
|
List<Track>? _topTracks;
|
||||||
|
String? _headerImageUrl;
|
||||||
|
int? _monthlyListeners;
|
||||||
bool _isLoading = true;
|
bool _isLoading = true;
|
||||||
String? _error;
|
String? _error;
|
||||||
|
|
||||||
@@ -1719,18 +2023,24 @@ class _ExtensionArtistScreenState extends ConsumerState<ExtensionArtistScreen> {
|
|||||||
|
|
||||||
// Parse albums from result
|
// Parse albums from result
|
||||||
final albumList = result['albums'] as List<dynamic>?;
|
final albumList = result['albums'] as List<dynamic>?;
|
||||||
if (albumList == null) {
|
final albums = albumList?.map((a) => _parseAlbum(a as Map<String, dynamic>)).toList() ?? [];
|
||||||
setState(() {
|
|
||||||
_albums = [];
|
// Parse top tracks from result
|
||||||
_isLoading = false;
|
final topTracksList = result['top_tracks'] as List<dynamic>?;
|
||||||
});
|
List<Track>? topTracks;
|
||||||
return;
|
if (topTracksList != null && topTracksList.isNotEmpty) {
|
||||||
|
topTracks = topTracksList.map((t) => _parseTrack(t as Map<String, dynamic>)).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
final albums = albumList.map((a) => _parseAlbum(a as Map<String, dynamic>)).toList();
|
// Parse additional artist info
|
||||||
|
final headerImage = result['header_image'] as String?;
|
||||||
|
final listeners = result['listeners'] as int?;
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_albums = albums;
|
_albums = albums;
|
||||||
|
_topTracks = topTracks;
|
||||||
|
_headerImageUrl = headerImage;
|
||||||
|
_monthlyListeners = listeners;
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -1755,6 +2065,31 @@ class _ExtensionArtistScreenState extends ConsumerState<ExtensionArtistScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Track _parseTrack(Map<String, dynamic> 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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
if (_isLoading) {
|
if (_isLoading) {
|
||||||
@@ -1780,12 +2115,16 @@ class _ExtensionArtistScreenState extends ConsumerState<ExtensionArtistScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Navigate to ArtistScreen with fetched albums
|
// Navigate to ArtistScreen with fetched albums and top tracks
|
||||||
return ArtistScreen(
|
return ArtistScreen(
|
||||||
artistId: widget.artistId,
|
artistId: widget.artistId,
|
||||||
artistName: widget.artistName,
|
artistName: widget.artistName,
|
||||||
coverUrl: widget.coverUrl,
|
coverUrl: widget.coverUrl,
|
||||||
|
headerImageUrl: _headerImageUrl,
|
||||||
|
monthlyListeners: _monthlyListeners,
|
||||||
albums: _albums,
|
albums: _albums,
|
||||||
|
topTracks: _topTracks,
|
||||||
|
extensionId: widget.extensionId, // Skip Spotify/Deezer fetch
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -5,6 +5,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||||
import 'package:spotiflac_android/providers/settings_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/providers/track_provider.dart';
|
||||||
import 'package:spotiflac_android/screens/home_tab.dart';
|
import 'package:spotiflac_android/screens/home_tab.dart';
|
||||||
import 'package:spotiflac_android/screens/store_tab.dart';
|
import 'package:spotiflac_android/screens/store_tab.dart';
|
||||||
@@ -124,7 +125,8 @@ class _MainShellState extends ConsumerState<MainShell> {
|
|||||||
if (_currentIndex != index) {
|
if (_currentIndex != index) {
|
||||||
setState(() => _currentIndex = index);
|
setState(() => _currentIndex = index);
|
||||||
// Unfocus any text field when switching tabs to prevent keyboard from appearing
|
// 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<MainShell> {
|
|||||||
// Check if keyboard is visible - if so, just dismiss keyboard, don't clear search
|
// Check if keyboard is visible - if so, just dismiss keyboard, don't clear search
|
||||||
final isKeyboardVisible = MediaQuery.of(context).viewInsets.bottom > 0;
|
final isKeyboardVisible = MediaQuery.of(context).viewInsets.bottom > 0;
|
||||||
if (isKeyboardVisible) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,6 +187,7 @@ class _MainShellState extends ConsumerState<MainShell> {
|
|||||||
final queueState = ref.watch(downloadQueueProvider.select((s) => s.queuedCount));
|
final queueState = ref.watch(downloadQueueProvider.select((s) => s.queuedCount));
|
||||||
final trackState = ref.watch(trackProvider);
|
final trackState = ref.watch(trackProvider);
|
||||||
final showStore = ref.watch(settingsProvider.select((s) => s.showExtensionStore));
|
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)
|
// Check if keyboard is visible (bottom inset > 0 means keyboard is showing)
|
||||||
final isKeyboardVisible = MediaQuery.of(context).viewInsets.bottom > 0;
|
final isKeyboardVisible = MediaQuery.of(context).viewInsets.bottom > 0;
|
||||||
@@ -188,6 +199,7 @@ class _MainShellState extends ConsumerState<MainShell> {
|
|||||||
!trackState.hasSearchText &&
|
!trackState.hasSearchText &&
|
||||||
!trackState.hasContent &&
|
!trackState.hasContent &&
|
||||||
!trackState.isLoading &&
|
!trackState.isLoading &&
|
||||||
|
!trackState.isShowingRecentAccess &&
|
||||||
!isKeyboardVisible;
|
!isKeyboardVisible;
|
||||||
|
|
||||||
// Build tabs and destinations based on settings
|
// Build tabs and destinations based on settings
|
||||||
@@ -224,8 +236,16 @@ class _MainShellState extends ConsumerState<MainShell> {
|
|||||||
),
|
),
|
||||||
if (showStore)
|
if (showStore)
|
||||||
NavigationDestination(
|
NavigationDestination(
|
||||||
icon: const Icon(Icons.store_outlined),
|
icon: Badge(
|
||||||
selectedIcon: const Icon(Icons.store),
|
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,
|
label: l10n.navStore,
|
||||||
),
|
),
|
||||||
NavigationDestination(
|
NavigationDestination(
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:spotiflac_android/l10n/l10n.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/settings_provider.dart';
|
||||||
import 'package:spotiflac_android/providers/theme_provider.dart';
|
import 'package:spotiflac_android/providers/theme_provider.dart';
|
||||||
import 'package:spotiflac_android/widgets/settings_group.dart';
|
import 'package:spotiflac_android/widgets/settings_group.dart';
|
||||||
@@ -709,7 +710,8 @@ class _LanguageSelector extends StatelessWidget {
|
|||||||
required this.onChanged,
|
required this.onChanged,
|
||||||
});
|
});
|
||||||
|
|
||||||
static const _languages = [
|
// All available languages (code, displayName, icon)
|
||||||
|
static const _allLanguages = [
|
||||||
('system', 'System Default', Icons.phone_android),
|
('system', 'System Default', Icons.phone_android),
|
||||||
('en', 'English', Icons.language),
|
('en', 'English', Icons.language),
|
||||||
('id', 'Bahasa Indonesia', Icons.language),
|
('id', 'Bahasa Indonesia', Icons.language),
|
||||||
@@ -726,8 +728,20 @@ class _LanguageSelector extends StatelessWidget {
|
|||||||
('zh_TW', '繁體中文', Icons.language),
|
('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) {
|
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;
|
if (lang.$1 == code) return lang.$2;
|
||||||
}
|
}
|
||||||
return code;
|
return code;
|
||||||
|
|||||||
@@ -127,6 +127,9 @@ class SettingsTab extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _navigateTo(BuildContext context, Widget page) {
|
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(
|
Navigator.of(context).push(
|
||||||
// Use PageRouteBuilder for better predictive back gesture support
|
// Use PageRouteBuilder for better predictive back gesture support
|
||||||
// MaterialPageRoute can cause freeze on some devices with gesture navigation
|
// MaterialPageRoute can cause freeze on some devices with gesture navigation
|
||||||
|
|||||||
@@ -548,15 +548,41 @@ class _ExtensionItem extends StatelessWidget {
|
|||||||
color: colorScheme.onSurfaceVariant,
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
// Warning badge for incompatible extensions
|
||||||
Text(
|
if (extension.requiresNewerApp) ...[
|
||||||
extension.description,
|
const SizedBox(height: 4),
|
||||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
Container(
|
||||||
color: colorScheme.onSurfaceVariant,
|
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,
|
] else ...[
|
||||||
overflow: TextOverflow.ellipsis,
|
const SizedBox(height: 4),
|
||||||
),
|
Text(
|
||||||
|
extension.description,
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
Reference in new issue
Block a user