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