diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7885c2c..14d4655 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -17,14 +17,26 @@ jobs: runs-on: ubuntu-latest outputs: version: ${{ steps.version.outputs.version }} + is_prerelease: ${{ steps.version.outputs.is_prerelease }} steps: - name: Get version id: version run: | if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then - echo "version=${{ github.event.inputs.version }}" >> $GITHUB_OUTPUT + VERSION="${{ github.event.inputs.version }}" else - echo "version=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT + VERSION="${GITHUB_REF#refs/tags/}" + fi + echo "version=$VERSION" >> $GITHUB_OUTPUT + + # Check if version contains -preview, -beta, -rc, or -alpha (NOT -hotfix) + VERSION_LOWER=$(echo "$VERSION" | tr '[:upper:]' '[:lower:]') + if [[ "$VERSION_LOWER" == *"-preview"* ]] || [[ "$VERSION_LOWER" == *"-beta"* ]] || [[ "$VERSION_LOWER" == *"-rc"* ]] || [[ "$VERSION_LOWER" == *"-alpha"* ]]; then + echo "is_prerelease=true" >> $GITHUB_OUTPUT + echo "Detected pre-release version: $VERSION" + else + echo "is_prerelease=false" >> $GITHUB_OUTPUT + echo "Detected stable version: $VERSION" fi # Android and iOS build in PARALLEL @@ -316,6 +328,6 @@ jobs: body_path: /tmp/release_body.txt files: ./release/* draft: false - prerelease: false + prerelease: ${{ needs.get-version.outputs.is_prerelease == 'true' }} env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/CHANGELOG.md b/CHANGELOG.md index bbc6004..fcf21a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,90 @@ # Changelog +## [2.0.0] - 2026-01-03 + +### Added +- **Artist Search Results**: Search now shows artists alongside tracks + - Horizontal scrollable artist cards with circular avatars + - Tap artist to view their discography +- **Multi-Layer Caching System**: Aggressive caching to minimize API calls + - Go backend cache: Artist (10 min), Album (10 min), Search (5 min) + - Flutter memory cache: Instant navigation for previously viewed artists/albums + - Duplicate search prevention: Same query won't trigger new API call +- **Real-time Download Status**: Track items show live download progress + - Queued: Hourglass icon + - Downloading: Circular progress with percentage + - Completed: Check icon + - Works in Home search, Album, and Playlist screens +- **Downloaded Track Indicator**: Tracks already in history show check mark + - Lazy file verification: Only checks file existence when tapped + - Auto-removes from history if file was deleted, allowing re-download + - Prevents accidental duplicate downloads +- **Pre-release Support**: GitHub Actions auto-detects preview/beta/rc/alpha tags + - Stable users won't receive update notifications for preview versions + +### Changed +- **Instant Navigation UX**: Navigate to Artist/Album screens immediately + - Header (name, cover) shows instantly from available data + - Content (albums/tracks) loads in background inside the screen + - Second visit to same artist/album is instant from Flutter cache +- **Search Results UI Redesign**: + - Removed "Download All" button from search results + - Added "Songs" section header (matches "Artists" header style) + - Track list now in grouped card with rounded corners (like Settings) + - Track items with dividers and InkWell ripple effect +- **Larger UI Elements**: Improved touch targets and visual hierarchy + - Recent downloads: Album art 56→100px, section height 80→130px + - Artist cards: Avatar 72→88px, container 90→100px + - Track items: Album art 48→56px +- **Optimized Search**: Pressing Enter with same query no longer triggers duplicate search +- **Smoother Progress Animation**: Progress jumps to 100% after download completes + - Embedding (cover, metadata, lyrics) happens in background without blocking UI +- **Finalizing Status**: Shows "Finalizing" indicator while embedding metadata + - Distinct icon (edit_note) with tertiary color + - User knows download is complete, just processing metadata +- **Consistent Download Button Sizes**: All download/status buttons now 44x44px +- **Better Dynamic Color Contrast**: Improved visibility for cards and chips with dynamic color + - Settings cards use overlay colors for better contrast + - Theme/view mode chips have visible borders in light mode +- **Navigation Bar Styling**: Distinct background color from content area +- **Ask Before Download Default**: Now enabled by default for better UX +- **Quality Picker Track Info**: Shows track name, artist, and cover in quality picker + - Tap to expand long track titles + - Expand icon only shows when title is truncated + - Ripple effect follows rounded corners including drag handle +- **Update Dialog Redesign**: Material Expressive 3 style + - Icon header with container + - Version chips with "Current" and "New" labels + - Changelog in rounded card + - Download progress with percentage indicator + - Cleaner button layout + +### Fixed +- **Artist Profile Images**: Fixed artist images not showing in search results (field name mismatch) +- **Album Card Overflow**: Fixed 5px overflow in artist discography album cards +- **Optimized Rebuilds**: Each track item only rebuilds when its own status changes + - Uses Riverpod `select()` for granular state watching + - Prevents entire list rebuild on progress updates +- **Update Notification Stuck**: Fixed notification staying at 100% after download complete + +## [1.6.3] - 2026-01-03 + +### Added +- **Predictive Back Navigation**: Support for Android 14+ predictive back gesture with smooth animations +- **Separate Detail Screens**: Album, Artist, and Playlist now open as dedicated screens with Material Expressive 3 design + - Collapsing header with cover art and gradient overlay + - Card-based info section with rounded corners (20px radius) + - Tonal download buttons with circular shape + - Quality picker bottom sheet with drag handle +- **Double-Tap to Exit**: Press back twice to exit app when at home screen (replaces exit dialog) + +### Changed +- **Navigation Architecture**: Refactored from state-based to screen-based navigation + - Album/Artist/Playlist URLs navigate to dedicated screens via `Navigator.push()` + - Enables native predictive back gesture animations + - Search results stay on Home tab for quick downloads +- **Simplified State Management**: Removed `previousState` chain from TrackProvider since Navigator handles back navigation + ## [1.6.2] - 2026-01-02 ### Added diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 217348d..710042c 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -19,7 +19,8 @@ android:name="${applicationName}" android:icon="@mipmap/ic_launcher" android:requestLegacyExternalStorage="true" - android:usesCleartextTraffic="true"> + android:usesCleartextTraffic="true" + android:enableOnBackInvokedCallback="true"> { + val query = call.argument("query") ?: "" + val trackLimit = call.argument("track_limit") ?: 15 + val artistLimit = call.argument("artist_limit") ?: 3 + val response = withContext(Dispatchers.IO) { + Gobackend.searchSpotifyAll(query, trackLimit.toLong(), artistLimit.toLong()) + } + result.success(response) + } "checkAvailability" -> { val spotifyId = call.argument("spotify_id") ?: "" val isrc = call.argument("isrc") ?: "" diff --git a/go_backend/amazon.go b/go_backend/amazon.go index 3270b71..10d48fb 100644 --- a/go_backend/amazon.go +++ b/go_backend/amazon.go @@ -318,6 +318,13 @@ func downloadFromAmazon(req DownloadRequest) (string, error) { return "", fmt.Errorf("download failed: %w", err) } + // Set progress to 100% and status to finalizing (before embedding) + // This makes the UI show "Finalizing..." while embedding happens + if req.ItemID != "" { + SetItemProgress(req.ItemID, 1.0, 0, 0) + SetItemFinalizing(req.ItemID) + } + // Log track info from DoubleDouble (for debugging) if trackName != "" && artistName != "" { fmt.Printf("[Amazon] DoubleDouble returned: %s - %s\n", artistName, trackName) diff --git a/go_backend/exports.go b/go_backend/exports.go index a472fbb..a1654f4 100644 --- a/go_backend/exports.go +++ b/go_backend/exports.go @@ -70,6 +70,26 @@ func SearchSpotify(query string, limit int) (string, error) { return string(jsonBytes), nil } +// SearchSpotifyAll searches for tracks and artists on Spotify +// Returns JSON with tracks and artists arrays +func SearchSpotifyAll(query string, trackLimit, artistLimit int) (string, error) { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + client := NewSpotifyMetadataClient() + results, err := client.SearchAll(ctx, query, trackLimit, artistLimit) + if err != nil { + return "", err + } + + jsonBytes, err := json.Marshal(results) + if err != nil { + return "", err + } + + return string(jsonBytes), nil +} + // CheckAvailability checks track availability on streaming services // Returns JSON with availability info for Tidal, Qobuz, Amazon func CheckAvailability(spotifyID, isrc string) (string, error) { diff --git a/go_backend/progress.go b/go_backend/progress.go index 67975fb..94a0d3e 100644 --- a/go_backend/progress.go +++ b/go_backend/progress.go @@ -13,6 +13,7 @@ type DownloadProgress struct { BytesTotal int64 `json:"bytes_total"` BytesReceived int64 `json:"bytes_received"` IsDownloading bool `json:"is_downloading"` + Status string `json:"status"` // "downloading", "finalizing", "completed" } // ItemProgress represents progress for a single download item @@ -22,6 +23,7 @@ type ItemProgress struct { BytesReceived int64 `json:"bytes_received"` Progress float64 `json:"progress"` // 0.0 to 1.0 IsDownloading bool `json:"is_downloading"` + Status string `json:"status"` // "downloading", "finalizing", "completed" } // MultiProgress holds progress for multiple concurrent downloads @@ -82,6 +84,7 @@ func StartItemProgress(itemID string) { BytesReceived: 0, Progress: 0, IsDownloading: true, + Status: "downloading", } } @@ -119,6 +122,46 @@ func CompleteItemProgress(itemID string) { } } +// SetItemProgress sets progress for an item directly (used to force 100% before embedding) +func SetItemProgress(itemID string, progress float64, bytesReceived, bytesTotal int64) { + multiMu.Lock() + if item, ok := multiProgress.Items[itemID]; ok { + item.Progress = progress + if bytesReceived > 0 { + item.BytesReceived = bytesReceived + } + if bytesTotal > 0 { + item.BytesTotal = bytesTotal + } + } + multiMu.Unlock() + + // Also update legacy progress for backward compatibility + progressMu.Lock() + if progress >= 1.0 { + currentProgress.Progress = 100.0 + } else { + currentProgress.Progress = progress * 100.0 + } + progressMu.Unlock() +} + +// SetItemFinalizing marks an item as finalizing (embedding metadata) +func SetItemFinalizing(itemID string) { + multiMu.Lock() + if item, ok := multiProgress.Items[itemID]; ok { + item.Progress = 1.0 + item.Status = "finalizing" + } + multiMu.Unlock() + + // Also update legacy progress + progressMu.Lock() + currentProgress.Progress = 100.0 + currentProgress.Status = "finalizing" + progressMu.Unlock() +} + // RemoveItemProgress removes progress tracking for an item func RemoveItemProgress(itemID string) { multiMu.Lock() @@ -161,6 +204,7 @@ func SetCurrentFile(filename string) { currentProgress.Progress = 0 currentProgress.CurrentFile = filename currentProgress.IsDownloading = true + currentProgress.Status = "downloading" } // ResetProgress resets the download progress diff --git a/go_backend/qobuz.go b/go_backend/qobuz.go index be1e975..338c195 100644 --- a/go_backend/qobuz.go +++ b/go_backend/qobuz.go @@ -385,6 +385,13 @@ func downloadFromQobuz(req DownloadRequest) (string, error) { return "", fmt.Errorf("download failed: %w", err) } + // Set progress to 100% and status to finalizing (before embedding) + // This makes the UI show "Finalizing..." while embedding happens + if req.ItemID != "" { + SetItemProgress(req.ItemID, 1.0, 0, 0) + SetItemFinalizing(req.ItemID) + } + // Embed metadata metadata := Metadata{ Title: req.TrackName, diff --git a/go_backend/spotify.go b/go_backend/spotify.go index ca55328..9c91063 100644 --- a/go_backend/spotify.go +++ b/go_backend/spotify.go @@ -24,10 +24,25 @@ const ( artistBaseURL = "https://api.spotify.com/v1/artists/%s" artistAlbumsURL = "https://api.spotify.com/v1/artists/%s/albums" searchBaseURL = "https://api.spotify.com/v1/search" + + // Cache TTL settings + artistCacheTTL = 10 * time.Minute + searchCacheTTL = 5 * time.Minute + albumCacheTTL = 10 * time.Minute ) var errInvalidSpotifyURL = errors.New("invalid or unsupported Spotify URL") +// cacheEntry holds cached data with expiration +type cacheEntry struct { + data interface{} + expiresAt time.Time +} + +func (e *cacheEntry) isExpired() bool { + return time.Now().After(e.expiresAt) +} + // SpotifyMetadataClient handles Spotify API interactions type SpotifyMetadataClient struct { httpClient *http.Client @@ -39,6 +54,12 @@ type SpotifyMetadataClient struct { rng *rand.Rand rngMu sync.Mutex userAgent string + + // Caches to reduce API calls + artistCache map[string]*cacheEntry // key: artistID + searchCache map[string]*cacheEntry // key: query+type + albumCache map[string]*cacheEntry // key: albumID + cacheMu sync.RWMutex } // NewSpotifyMetadataClient creates a new Spotify client @@ -65,6 +86,9 @@ func NewSpotifyMetadataClient() *SpotifyMetadataClient { clientID: clientID, clientSecret: clientSecret, rng: rand.New(src), + artistCache: make(map[string]*cacheEntry), + searchCache: make(map[string]*cacheEntry), + albumCache: make(map[string]*cacheEntry), } c.userAgent = c.randomUserAgent() return c @@ -176,6 +200,21 @@ type SearchResult struct { Total int `json:"total"` } +// SearchArtistResult represents an artist in search results +type SearchArtistResult struct { + ID string `json:"id"` + Name string `json:"name"` + Images string `json:"images"` + Followers int `json:"followers"` + Popularity int `json:"popularity"` +} + +// SearchAllResult represents combined search results for tracks and artists +type SearchAllResult struct { + Tracks []TrackMetadata `json:"tracks"` + Artists []SearchArtistResult `json:"artists"` +} + type spotifyURI struct { Type string ID string @@ -299,6 +338,98 @@ func (c *SpotifyMetadataClient) SearchTracks(ctx context.Context, query string, return result, nil } +// SearchAll searches for tracks and artists on Spotify +func (c *SpotifyMetadataClient) SearchAll(ctx context.Context, query string, trackLimit, artistLimit int) (*SearchAllResult, error) { + // Create cache key + cacheKey := fmt.Sprintf("all:%s:%d:%d", query, trackLimit, artistLimit) + + // Check cache first + c.cacheMu.RLock() + if entry, ok := c.searchCache[cacheKey]; ok && !entry.isExpired() { + c.cacheMu.RUnlock() + return entry.data.(*SearchAllResult), nil + } + c.cacheMu.RUnlock() + + token, err := c.getAccessToken(ctx) + if err != nil { + return nil, err + } + + searchURL := fmt.Sprintf("%s?q=%s&type=track,artist&limit=%d", searchBaseURL, url.QueryEscape(query), trackLimit) + + var response struct { + Tracks struct { + Items []trackFull `json:"items"` + } `json:"tracks"` + Artists struct { + Items []struct { + ID string `json:"id"` + Name string `json:"name"` + Images []image `json:"images"` + Followers struct { + Total int `json:"total"` + } `json:"followers"` + Popularity int `json:"popularity"` + } `json:"items"` + } `json:"artists"` + } + + if err := c.getJSON(ctx, searchURL, token, &response); err != nil { + return nil, err + } + + result := &SearchAllResult{ + Tracks: make([]TrackMetadata, 0, len(response.Tracks.Items)), + Artists: make([]SearchArtistResult, 0, len(response.Artists.Items)), + } + + for _, track := range response.Tracks.Items { + result.Tracks = append(result.Tracks, TrackMetadata{ + SpotifyID: track.ID, + Artists: joinArtists(track.Artists), + Name: track.Name, + AlbumName: track.Album.Name, + AlbumArtist: joinArtists(track.Album.Artists), + DurationMS: track.DurationMS, + Images: firstImageURL(track.Album.Images), + ReleaseDate: track.Album.ReleaseDate, + TrackNumber: track.TrackNumber, + TotalTracks: track.Album.TotalTracks, + DiscNumber: track.DiscNumber, + ExternalURL: track.ExternalURL.Spotify, + ISRC: track.ExternalID.ISRC, + }) + } + + // Limit artists to artistLimit + artistCount := len(response.Artists.Items) + if artistCount > artistLimit { + artistCount = artistLimit + } + + for i := 0; i < artistCount; i++ { + artist := response.Artists.Items[i] + result.Artists = append(result.Artists, SearchArtistResult{ + ID: artist.ID, + Name: artist.Name, + Images: firstImageURL(artist.Images), + Followers: artist.Followers.Total, + Popularity: artist.Popularity, + }) + } + + // Store in cache + c.cacheMu.Lock() + c.searchCache[cacheKey] = &cacheEntry{ + data: result, + expiresAt: time.Now().Add(searchCacheTTL), + } + c.cacheMu.Unlock() + + return result, nil +} + func (c *SpotifyMetadataClient) fetchTrack(ctx context.Context, trackID, token string) (*TrackResponse, error) { var data trackFull if err := c.getJSON(ctx, fmt.Sprintf(trackBaseURL, trackID), token, &data); err != nil { @@ -325,6 +456,14 @@ func (c *SpotifyMetadataClient) fetchTrack(ctx context.Context, trackID, token s } func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token string) (*AlbumResponsePayload, error) { + // Check cache first + c.cacheMu.RLock() + if entry, ok := c.albumCache[albumID]; ok && !entry.isExpired() { + c.cacheMu.RUnlock() + return entry.data.(*AlbumResponsePayload), nil + } + c.cacheMu.RUnlock() + var data struct { Name string `json:"name"` ReleaseDate string `json:"release_date"` @@ -380,10 +519,20 @@ func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token s }) } - return &AlbumResponsePayload{ + result := &AlbumResponsePayload{ AlbumInfo: info, TrackList: tracks, - }, nil + } + + // Store in cache + c.cacheMu.Lock() + c.albumCache[albumID] = &cacheEntry{ + data: result, + expiresAt: time.Now().Add(albumCacheTTL), + } + c.cacheMu.Unlock() + + return result, nil } func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, token string) (*PlaylistResponsePayload, error) { @@ -442,6 +591,14 @@ func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, t } func (c *SpotifyMetadataClient) fetchArtist(ctx context.Context, artistID, token string) (*ArtistResponsePayload, error) { + // Check cache first + c.cacheMu.RLock() + if entry, ok := c.artistCache[artistID]; ok && !entry.isExpired() { + c.cacheMu.RUnlock() + return entry.data.(*ArtistResponsePayload), nil + } + c.cacheMu.RUnlock() + // Fetch artist info var artistData struct { ID string `json:"id"` @@ -517,10 +674,20 @@ func (c *SpotifyMetadataClient) fetchArtist(ctx context.Context, artistID, token } } - return &ArtistResponsePayload{ + result := &ArtistResponsePayload{ ArtistInfo: artistInfo, Albums: albums, - }, nil + } + + // Store in cache + c.cacheMu.Lock() + c.artistCache[artistID] = &cacheEntry{ + data: result, + expiresAt: time.Now().Add(artistCacheTTL), + } + c.cacheMu.Unlock() + + return result, nil } func (c *SpotifyMetadataClient) fetchTrackISRC(ctx context.Context, trackID, token string) string { diff --git a/go_backend/tidal.go b/go_backend/tidal.go index c28cf9c..1ad3728 100644 --- a/go_backend/tidal.go +++ b/go_backend/tidal.go @@ -905,6 +905,13 @@ func downloadFromTidal(req DownloadRequest) (string, error) { return "", fmt.Errorf("download failed: %w", err) } + // Set progress to 100% and status to finalizing (before embedding) + // This makes the UI show "Finalizing..." while embedding happens + if req.ItemID != "" { + SetItemProgress(req.ItemID, 1.0, 0, 0) + SetItemFinalizing(req.ItemID) + } + // Check if file was saved as M4A (DASH stream) instead of FLAC // downloadFromManifest saves DASH streams as .m4a actualOutputPath := outputPath diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 330ea5c..e637c7a 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -66,6 +66,15 @@ import Gobackend // Import Go framework if let error = error { throw error } return response + case "searchSpotifyAll": + let args = call.arguments as! [String: Any] + let query = args["query"] as! String + let trackLimit = args["track_limit"] as? Int ?? 15 + let artistLimit = args["artist_limit"] as? Int ?? 3 + let response = GobackendSearchSpotifyAll(query, Int(trackLimit), Int(artistLimit), &error) + if let error = error { throw error } + return response + case "checkAvailability": let args = call.arguments as! [String: Any] let spotifyId = args["spotify_id"] as! String diff --git a/lib/constants/app_info.dart b/lib/constants/app_info.dart index 23bbbad..f1f8245 100644 --- a/lib/constants/app_info.dart +++ b/lib/constants/app_info.dart @@ -1,10 +1,11 @@ /// App version and info constants /// Update version here only - all other files will reference this class AppInfo { - static const String version = '1.6.2'; - static const String buildNumber = '27'; + static const String version = '2.0.0'; + static const String buildNumber = '30'; static const String fullVersion = '$version+$buildNumber'; + static const String appName = 'SpotiFLAC'; static const String copyright = '© 2026 SpotiFLAC'; diff --git a/lib/models/download_item.dart b/lib/models/download_item.dart index 3273e8e..4c021e0 100644 --- a/lib/models/download_item.dart +++ b/lib/models/download_item.dart @@ -7,6 +7,7 @@ part 'download_item.g.dart'; enum DownloadStatus { queued, downloading, + finalizing, // Embedding metadata, cover, lyrics completed, failed, skipped, diff --git a/lib/models/download_item.g.dart b/lib/models/download_item.g.dart index 2dbdec6..1586b8d 100644 --- a/lib/models/download_item.g.dart +++ b/lib/models/download_item.g.dart @@ -36,6 +36,7 @@ Map _$DownloadItemToJson(DownloadItem instance) => const _$DownloadStatusEnumMap = { DownloadStatus.queued: 'queued', DownloadStatus.downloading: 'downloading', + DownloadStatus.finalizing: 'finalizing', DownloadStatus.completed: 'completed', DownloadStatus.failed: 'failed', DownloadStatus.skipped: 'skipped', diff --git a/lib/models/settings.dart b/lib/models/settings.dart index a3a8e15..410a1c7 100644 --- a/lib/models/settings.dart +++ b/lib/models/settings.dart @@ -35,7 +35,7 @@ class AppSettings { this.folderOrganization = 'none', // Default: no folder organization this.convertLyricsToRomaji = false, // Default: keep original Japanese this.historyViewMode = 'grid', // Default: grid view - this.askQualityBeforeDownload = false, // Default: use preset quality + this.askQualityBeforeDownload = true, // Default: ask quality before download }); AppSettings copyWith({ diff --git a/lib/models/settings.g.dart b/lib/models/settings.g.dart index 35d474c..bc855a2 100644 --- a/lib/models/settings.g.dart +++ b/lib/models/settings.g.dart @@ -21,7 +21,7 @@ AppSettings _$AppSettingsFromJson(Map json) => AppSettings( folderOrganization: json['folderOrganization'] as String? ?? 'none', convertLyricsToRomaji: json['convertLyricsToRomaji'] as bool? ?? false, historyViewMode: json['historyViewMode'] as String? ?? 'grid', - askQualityBeforeDownload: json['askQualityBeforeDownload'] as bool? ?? false, + askQualityBeforeDownload: json['askQualityBeforeDownload'] as bool? ?? true, ); Map _$AppSettingsToJson(AppSettings instance) => diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index ac22244..0509ae1 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -99,8 +99,16 @@ class DownloadHistoryItem { // Download History State class DownloadHistoryState { final List items; + final Set _downloadedSpotifyIds; // Cache for O(1) lookup - const DownloadHistoryState({this.items = const []}); + DownloadHistoryState({this.items = const []}) + : _downloadedSpotifyIds = items + .where((item) => item.spotifyId != null && item.spotifyId!.isNotEmpty) + .map((item) => item.spotifyId!) + .toSet(); + + /// Check if a track has been downloaded (by Spotify ID) + bool isDownloaded(String spotifyId) => _downloadedSpotifyIds.contains(spotifyId); DownloadHistoryState copyWith({List? items}) { return DownloadHistoryState(items: items ?? this.items); @@ -116,7 +124,7 @@ class DownloadHistoryNotifier extends Notifier { DownloadHistoryState build() { // Load history from storage on init _loadFromStorageSync(); - return const DownloadHistoryState(); + return DownloadHistoryState(); } /// Synchronously schedule load - ensures it runs before any UI renders @@ -173,8 +181,22 @@ class DownloadHistoryNotifier extends Notifier { _saveToStorage(); } + /// Remove item from history by Spotify ID + void removeBySpotifyId(String spotifyId) { + state = state.copyWith( + items: state.items.where((item) => item.spotifyId != spotifyId).toList(), + ); + _saveToStorage(); + _historyLog.d('Removed item with spotifyId: $spotifyId'); + } + + /// Get history item by Spotify ID + DownloadHistoryItem? getBySpotifyId(String spotifyId) { + return state.items.where((item) => item.spotifyId == spotifyId).firstOrNull; + } + void clearHistory() { - state = const DownloadHistoryState(); + state = DownloadHistoryState(); _saveToStorage(); } } @@ -340,6 +362,22 @@ class DownloadQueueNotifier extends Notifier { final bytesReceived = progress['bytes_received'] as int? ?? 0; final bytesTotal = progress['bytes_total'] as int? ?? 0; final isDownloading = progress['is_downloading'] as bool? ?? false; + final status = progress['status'] as String? ?? 'downloading'; + + // Check if status is "finalizing" (embedding metadata) + if (status == 'finalizing') { + updateItemStatus(itemId, DownloadStatus.finalizing, progress: 1.0); + + // Update notification to show finalizing + final currentItem = state.items.where((i) => i.id == itemId).firstOrNull; + if (currentItem != null) { + _notificationService.showDownloadFinalizing( + trackName: currentItem.track.name, + artistName: currentItem.track.artistName, + ); + } + return; + } if (isDownloading && bytesTotal > 0) { final percentage = bytesReceived / bytesTotal; @@ -392,6 +430,22 @@ class DownloadQueueNotifier extends Notifier { final bytesReceived = itemProgress['bytes_received'] as int? ?? 0; final bytesTotal = itemProgress['bytes_total'] as int? ?? 0; final isDownloading = itemProgress['is_downloading'] as bool? ?? false; + final status = itemProgress['status'] as String? ?? 'downloading'; + + // Check if status is "finalizing" (embedding metadata) + if (status == 'finalizing') { + updateItemStatus(itemId, DownloadStatus.finalizing, progress: 1.0); + + // Update notification to show finalizing + final currentItem = state.items.where((i) => i.id == itemId).firstOrNull; + if (currentItem != null) { + _notificationService.showDownloadFinalizing( + trackName: currentItem.track.name, + artistName: currentItem.track.artistName, + ); + } + continue; + } if (isDownloading && bytesTotal > 0) { final percentage = bytesReceived / bytesTotal; @@ -412,7 +466,7 @@ class DownloadQueueNotifier extends Notifier { final bytesTotal = firstProgress['bytes_total'] as int? ?? 0; // Find the item to get track info - final downloadingItems = state.items.where((i) => i.status == DownloadStatus.downloading).toList(); + final downloadingItems = state.items.where((i) => i.status == DownloadStatus.downloading || i.status == DownloadStatus.finalizing).toList(); if (downloadingItems.isNotEmpty) { _notificationService.showDownloadProgress( trackName: '${downloadingItems.length} downloads', diff --git a/lib/providers/track_provider.dart b/lib/providers/track_provider.dart index d2b4b3a..5c4254c 100644 --- a/lib/providers/track_provider.dart +++ b/lib/providers/track_provider.dart @@ -6,54 +6,59 @@ class TrackState { final List tracks; final bool isLoading; final String? error; + final String? albumId; final String? albumName; final String? playlistName; + final String? artistId; final String? artistName; final String? coverUrl; final List? artistAlbums; // For artist page - final TrackState? previousState; // For back navigation + final List? searchArtists; // For search results final bool hasSearchText; // For back button handling const TrackState({ this.tracks = const [], this.isLoading = false, this.error, + this.albumId, this.albumName, this.playlistName, + this.artistId, this.artistName, this.coverUrl, this.artistAlbums, - this.previousState, + this.searchArtists, this.hasSearchText = false, }); - bool get canGoBack => previousState != null; - - bool get hasContent => tracks.isNotEmpty || artistAlbums != null; + bool get hasContent => tracks.isNotEmpty || artistAlbums != null || (searchArtists != null && searchArtists!.isNotEmpty); TrackState copyWith({ List? tracks, bool? isLoading, String? error, + String? albumId, String? albumName, String? playlistName, + String? artistId, String? artistName, String? coverUrl, List? artistAlbums, - TrackState? previousState, - bool clearPreviousState = false, + List? searchArtists, bool? hasSearchText, }) { return TrackState( tracks: tracks ?? this.tracks, isLoading: isLoading ?? this.isLoading, error: error, + albumId: albumId ?? this.albumId, albumName: albumName ?? this.albumName, playlistName: playlistName ?? this.playlistName, + artistId: artistId ?? this.artistId, artistName: artistName ?? this.artistName, coverUrl: coverUrl ?? this.coverUrl, artistAlbums: artistAlbums ?? this.artistAlbums, - previousState: clearPreviousState ? null : (previousState ?? this.previousState), + searchArtists: searchArtists ?? this.searchArtists, hasSearchText: hasSearchText ?? this.hasSearchText, ); } @@ -80,6 +85,23 @@ class ArtistAlbum { }); } +/// Represents an artist in search results +class SearchArtist { + final String id; + final String name; + final String? imageUrl; + final int followers; + final int popularity; + + const SearchArtist({ + required this.id, + required this.name, + this.imageUrl, + required this.followers, + required this.popularity, + }); +} + class TrackNotifier extends Notifier { /// Request ID to track and cancel outdated requests int _currentRequestId = 0; @@ -95,19 +117,8 @@ class TrackNotifier extends Notifier { Future fetchFromUrl(String url) async { // Increment request ID to cancel any pending requests final requestId = ++_currentRequestId; - - // Save current state for back navigation (only if we have content or it's empty) - final savedState = state.hasContent ? TrackState( - tracks: state.tracks, - albumName: state.albumName, - playlistName: state.playlistName, - artistName: state.artistName, - coverUrl: state.coverUrl, - artistAlbums: state.artistAlbums, - previousState: state.previousState, - ) : const TrackState(); // Empty state for back to home - state = TrackState(isLoading: true, previousState: savedState); + state = const TrackState(isLoading: true); try { final parsed = await PlatformBridge.parseSpotifyUrl(url); @@ -125,7 +136,6 @@ class TrackNotifier extends Notifier { tracks: [track], isLoading: false, coverUrl: track.coverUrl, - previousState: savedState, ); } else if (type == 'album') { final albumInfo = metadata['album_info'] as Map; @@ -134,9 +144,9 @@ class TrackNotifier extends Notifier { state = TrackState( tracks: tracks, isLoading: false, + albumId: parsed['id'] as String?, albumName: albumInfo['name'] as String?, coverUrl: albumInfo['images'] as String?, - previousState: savedState, ); } else if (type == 'playlist') { final playlistInfo = metadata['playlist_info'] as Map; @@ -148,7 +158,6 @@ class TrackNotifier extends Notifier { isLoading: false, playlistName: owner?['name'] as String?, coverUrl: owner?['images'] as String?, - previousState: savedState, ); } else if (type == 'artist') { final artistInfo = metadata['artist_info'] as Map; @@ -157,49 +166,42 @@ class TrackNotifier extends Notifier { state = TrackState( tracks: [], // No tracks for artist view isLoading: false, + artistId: artistInfo['id'] as String?, artistName: artistInfo['name'] as String?, coverUrl: artistInfo['images'] as String?, artistAlbums: albums, - previousState: savedState, ); } } catch (e) { if (!_isRequestValid(requestId)) return; // Request cancelled - state = TrackState(isLoading: false, error: e.toString(), previousState: savedState); + state = TrackState(isLoading: false, error: e.toString()); } } Future search(String query) async { // Increment request ID to cancel any pending requests final requestId = ++_currentRequestId; - - // Save current state for back navigation - final savedState = state.hasContent ? TrackState( - tracks: state.tracks, - albumName: state.albumName, - playlistName: state.playlistName, - artistName: state.artistName, - coverUrl: state.coverUrl, - artistAlbums: state.artistAlbums, - previousState: state.previousState, - ) : const TrackState(); - state = TrackState(isLoading: true, previousState: savedState); + state = const TrackState(isLoading: true); try { - final results = await PlatformBridge.searchSpotify(query, limit: 20); + final results = await PlatformBridge.searchSpotifyAll(query, trackLimit: 20, artistLimit: 5); if (!_isRequestValid(requestId)) return; // Request cancelled final trackList = results['tracks'] as List? ?? []; + final artistList = results['artists'] as List? ?? []; + final tracks = trackList.map((t) => _parseSearchTrack(t as Map)).toList(); + final artists = artistList.map((a) => _parseSearchArtist(a as Map)).toList(); + state = TrackState( tracks: tracks, + searchArtists: artists, isLoading: false, - previousState: savedState, ); } catch (e) { if (!_isRequestValid(requestId)) return; // Request cancelled - state = TrackState(isLoading: false, error: e.toString(), previousState: savedState); + state = TrackState(isLoading: false, error: e.toString()); } } @@ -250,59 +252,6 @@ class TrackNotifier extends Notifier { state = state.copyWith(hasSearchText: hasText); } - /// Go back to previous state (if available) - bool goBack() { - if (state.previousState != null) { - state = state.previousState!; - return true; - } - return false; - } - - /// Fetch album from artist view - saves current artist state for back navigation - Future fetchAlbumFromArtist(String albumId) async { - // Increment request ID to cancel any pending requests - final requestId = ++_currentRequestId; - - // Save current artist state before fetching album - final savedState = TrackState( - artistName: state.artistName, - coverUrl: state.coverUrl, - artistAlbums: state.artistAlbums, - previousState: state.previousState, // Keep the chain - ); - - state = TrackState( - isLoading: true, - previousState: savedState, - ); - - try { - final url = 'https://open.spotify.com/album/$albumId'; - final metadata = await PlatformBridge.getSpotifyMetadata(url); - if (!_isRequestValid(requestId)) return; // Request cancelled - - final albumInfo = metadata['album_info'] as Map; - final trackList = metadata['track_list'] as List; - final tracks = trackList.map((t) => _parseTrack(t as Map)).toList(); - - state = TrackState( - tracks: tracks, - isLoading: false, - albumName: albumInfo['name'] as String?, - coverUrl: albumInfo['images'] as String?, - previousState: savedState, - ); - } catch (e) { - if (!_isRequestValid(requestId)) return; // Request cancelled - state = TrackState( - isLoading: false, - error: e.toString(), - previousState: savedState, - ); - } - } - Track _parseTrack(Map data) { return Track( id: data['spotify_id'] as String? ?? '', @@ -346,6 +295,16 @@ class TrackNotifier extends Notifier { artists: data['artists'] as String? ?? '', ); } + + SearchArtist _parseSearchArtist(Map data) { + return SearchArtist( + id: data['id'] as String? ?? '', + name: data['name'] as String? ?? '', + imageUrl: data['images'] as String?, + followers: data['followers'] as int? ?? 0, + popularity: data['popularity'] as int? ?? 0, + ); + } } final trackProvider = NotifierProvider( diff --git a/lib/screens/album_screen.dart b/lib/screens/album_screen.dart new file mode 100644 index 0000000..d6e0ebb --- /dev/null +++ b/lib/screens/album_screen.dart @@ -0,0 +1,591 @@ +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: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/services/platform_bridge.dart'; + +/// Simple in-memory cache for album tracks +class _AlbumCache { + static final Map _cache = {}; + static const Duration _ttl = Duration(minutes: 10); + + static List? get(String albumId) { + final entry = _cache[albumId]; + if (entry == null) return null; + if (DateTime.now().isAfter(entry.expiresAt)) { + _cache.remove(albumId); + return null; + } + return entry.tracks; + } + + static void set(String albumId, List tracks) { + _cache[albumId] = _CacheEntry(tracks, DateTime.now().add(_ttl)); + } +} + +class _CacheEntry { + final List tracks; + final DateTime expiresAt; + _CacheEntry(this.tracks, this.expiresAt); +} + +/// Album detail screen with Material Expressive 3 design +class AlbumScreen extends ConsumerStatefulWidget { + final String albumId; + final String albumName; + final String? coverUrl; + final List? tracks; // Optional - will fetch if null + + const AlbumScreen({ + super.key, + required this.albumId, + required this.albumName, + this.coverUrl, + this.tracks, + }); + + @override + ConsumerState createState() => _AlbumScreenState(); +} + +class _AlbumScreenState extends ConsumerState { + List? _tracks; + bool _isLoading = false; + String? _error; + + @override + void initState() { + super.initState(); + // Priority: widget.tracks > cache > fetch + _tracks = widget.tracks ?? _AlbumCache.get(widget.albumId); + if (_tracks == null) { + _fetchTracks(); + } + } + + Future _fetchTracks() async { + setState(() => _isLoading = true); + try { + final url = 'https://open.spotify.com/album/${widget.albumId}'; + final metadata = await PlatformBridge.getSpotifyMetadata(url); + final trackList = metadata['track_list'] as List; + final tracks = trackList.map((t) => _parseTrack(t as Map)).toList(); + + // Store in cache + _AlbumCache.set(widget.albumId, tracks); + + if (mounted) { + setState(() { + _tracks = tracks; + _isLoading = false; + }); + } + } catch (e) { + if (mounted) { + setState(() { + _error = e.toString(); + _isLoading = false; + }); + } + } + } + + Track _parseTrack(Map data) { + return Track( + id: data['spotify_id'] as String? ?? '', + name: data['name'] as String? ?? '', + artistName: data['artists'] as String? ?? '', + albumName: data['album_name'] as String? ?? '', + albumArtist: data['album_artist'] as String?, + coverUrl: data['images'] as String?, + isrc: data['isrc'] as String?, + duration: data['duration_ms'] as int? ?? 0, + trackNumber: data['track_number'] as int?, + discNumber: data['disc_number'] as int?, + releaseDate: data['release_date'] as String?, + ); + } + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final tracks = _tracks ?? []; + + return Scaffold( + body: CustomScrollView( + slivers: [ + _buildAppBar(context, colorScheme), + _buildInfoCard(context, colorScheme), + if (_isLoading) + const SliverToBoxAdapter(child: Padding( + padding: EdgeInsets.all(32), + child: Center(child: CircularProgressIndicator()), + )), + if (_error != null) + SliverToBoxAdapter(child: Padding( + padding: const EdgeInsets.all(16), + child: Text(_error!, style: TextStyle(color: colorScheme.error)), + )), + if (!_isLoading && _error == null && tracks.isNotEmpty) ...[ + _buildTrackListHeader(context, colorScheme), + _buildTrackList(context, colorScheme, tracks), + ], + const SliverToBoxAdapter(child: SizedBox(height: 32)), + ], + ), + ); + } + + Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) { + return SliverAppBar( + expandedHeight: 280, + pinned: true, + stretch: true, + backgroundColor: colorScheme.surface, + surfaceTintColor: Colors.transparent, + flexibleSpace: FlexibleSpaceBar( + background: Stack( + fit: StackFit.expand, + children: [ + if (widget.coverUrl != null) + CachedNetworkImage( + imageUrl: widget.coverUrl!, + fit: BoxFit.cover, + color: Colors.black.withValues(alpha: 0.5), + colorBlendMode: BlendMode.darken, + memCacheWidth: 600, + ), + 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], + ), + ), + ), + Center( + child: Padding( + padding: const EdgeInsets.only(top: 60), + child: Container( + width: 140, + height: 140, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.3), + blurRadius: 20, + offset: const Offset(0, 10), + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(16), + child: widget.coverUrl != null + ? CachedNetworkImage(imageUrl: widget.coverUrl!, fit: BoxFit.cover, memCacheWidth: 280) + : Container( + color: colorScheme.surfaceContainerHighest, + child: Icon(Icons.album, size: 48, color: colorScheme.onSurfaceVariant), + ), + ), + ), + ), + ), + ], + ), + 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), + ), + onPressed: () => Navigator.pop(context), + ), + ); + } + + Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme) { + final tracks = _tracks ?? []; + 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.albumName, + style: Theme.of(context).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold, color: colorScheme.onSurface), + ), + const SizedBox(height: 8), + if (tracks.isNotEmpty) + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration(color: colorScheme.secondaryContainer, borderRadius: BorderRadius.circular(20)), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.music_note, size: 14, color: colorScheme.onSecondaryContainer), + const SizedBox(width: 4), + Text('${tracks.length} tracks', style: TextStyle(color: colorScheme.onSecondaryContainer, fontWeight: FontWeight.w600, fontSize: 12)), + ], + ), + ), + if (tracks.isNotEmpty) ...[ + const SizedBox(height: 16), + FilledButton.icon( + onPressed: () => _downloadAll(context), + icon: const Icon(Icons.download), + label: Text('Download All (${tracks.length})'), + style: FilledButton.styleFrom(minimumSize: const Size.fromHeight(52), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16))), + ), + ], + ], + ), + ), + ), + ), + ); + } + + Widget _buildTrackListHeader(BuildContext context, ColorScheme colorScheme) { + return SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 8, 20, 8), + child: Row( + children: [ + Icon(Icons.queue_music, size: 20, color: colorScheme.primary), + const SizedBox(width: 8), + Text('Tracks', style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600, color: colorScheme.onSurface)), + ], + ), + ), + ); + } + + Widget _buildTrackList(BuildContext context, ColorScheme colorScheme, List tracks) { + return SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) { + final track = tracks[index]; + return KeyedSubtree( + key: ValueKey(track.id), + child: _AlbumTrackItem( + track: track, + onDownload: () => _downloadTrack(context, track), + ), + ); + }, + childCount: tracks.length, + ), + ); + } + + void _downloadTrack(BuildContext context, Track track) { + final settings = ref.read(settingsProvider); + if (settings.askQualityBeforeDownload) { + _showQualityPicker(context, (quality) { + ref.read(downloadQueueProvider.notifier).addToQueue(track, settings.defaultService, qualityOverride: quality); + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue'))); + }, trackName: track.name, artistName: track.artistName, coverUrl: track.coverUrl); + } else { + ref.read(downloadQueueProvider.notifier).addToQueue(track, settings.defaultService); + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue'))); + } + } + + void _downloadAll(BuildContext context) { + final tracks = _tracks; + if (tracks == null || tracks.isEmpty) return; + final settings = ref.read(settingsProvider); + if (settings.askQualityBeforeDownload) { + _showQualityPicker(context, (quality) { + ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, settings.defaultService, qualityOverride: quality); + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added ${tracks.length} tracks to queue'))); + }, trackName: '${tracks.length} tracks', artistName: widget.albumName); + } else { + ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, settings.defaultService); + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added ${tracks.length} tracks to queue'))); + } + } + + void _showQualityPicker(BuildContext context, void Function(String quality) onSelect, {String? trackName, String? artistName, String? coverUrl}) { + final colorScheme = Theme.of(context).colorScheme; + showModalBottomSheet( + context: context, + backgroundColor: colorScheme.surfaceContainerHigh, + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(28))), + builder: (context) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (trackName != null) ...[ + _TrackInfoHeader(trackName: trackName, artistName: artistName, coverUrl: coverUrl), + Divider(height: 1, color: colorScheme.outlineVariant.withValues(alpha: 0.5)), + ] else ...[ + const SizedBox(height: 8), + Center(child: Container(width: 40, height: 4, decoration: BoxDecoration(color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), borderRadius: BorderRadius.circular(2)))), + ], + Padding( + padding: const EdgeInsets.fromLTRB(24, 16, 24, 8), + child: Text('Select Quality', style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)), + ), + _QualityOption(title: 'FLAC Lossless', subtitle: '16-bit / 44.1kHz', icon: Icons.music_note, onTap: () { Navigator.pop(context); onSelect('LOSSLESS'); }), + _QualityOption(title: 'Hi-Res FLAC', subtitle: '24-bit / up to 96kHz', icon: Icons.high_quality, onTap: () { Navigator.pop(context); onSelect('HI_RES'); }), + _QualityOption(title: 'Hi-Res FLAC Max', subtitle: '24-bit / up to 192kHz', icon: Icons.hd, onTap: () { Navigator.pop(context); onSelect('HI_RES_LOSSLESS'); }), + const SizedBox(height: 16), + ], + ), + ), + ); + } +} + +class _QualityOption extends StatelessWidget { + final String title; + final String subtitle; + final IconData icon; + final VoidCallback onTap; + + const _QualityOption({required this.title, required this.subtitle, required this.icon, required this.onTap}); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 4), + leading: Container(padding: const EdgeInsets.all(10), decoration: BoxDecoration(color: colorScheme.primaryContainer, borderRadius: BorderRadius.circular(12)), child: Icon(icon, color: colorScheme.onPrimaryContainer, size: 20)), + title: Text(title, style: const TextStyle(fontWeight: FontWeight.w500)), + subtitle: Text(subtitle, style: TextStyle(color: colorScheme.onSurfaceVariant)), + onTap: onTap, + ); + } +} + +class _TrackInfoHeader extends StatefulWidget { + final String trackName; + final String? artistName; + final String? coverUrl; + const _TrackInfoHeader({required this.trackName, this.artistName, this.coverUrl}); + + @override + State<_TrackInfoHeader> createState() => _TrackInfoHeaderState(); +} + +class _TrackInfoHeaderState extends State<_TrackInfoHeader> { + bool _expanded = false; + bool _isOverflowing = false; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return Material( + color: Colors.transparent, + child: InkWell( + onTap: _isOverflowing ? () => setState(() => _expanded = !_expanded) : null, + borderRadius: const BorderRadius.only(topLeft: Radius.circular(28), topRight: Radius.circular(28)), + child: Column( + children: [ + const SizedBox(height: 8), + Container(width: 40, height: 4, decoration: BoxDecoration(color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), borderRadius: BorderRadius.circular(2))), + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 12), + child: Row( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: widget.coverUrl != null + ? Image.network(widget.coverUrl!, width: 56, height: 56, fit: BoxFit.cover, + errorBuilder: (_, e, s) => Container(width: 56, height: 56, color: colorScheme.surfaceContainerHighest, child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant))) + : Container(width: 56, height: 56, color: colorScheme.surfaceContainerHighest, child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant)), + ), + const SizedBox(width: 12), + Expanded( + child: LayoutBuilder( + builder: (context, constraints) { + final titleStyle = Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600); + final titleSpan = TextSpan(text: widget.trackName, style: titleStyle); + final titlePainter = TextPainter(text: titleSpan, maxLines: 1, textDirection: TextDirection.ltr)..layout(maxWidth: constraints.maxWidth); + final titleOverflows = titlePainter.didExceedMaxLines; + + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && _isOverflowing != titleOverflows) { + setState(() => _isOverflowing = titleOverflows); + } + }); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.trackName, + style: titleStyle, + maxLines: _expanded ? 10 : 1, + overflow: _expanded ? TextOverflow.visible : TextOverflow.ellipsis, + ), + if (widget.artistName != null) ...[ + const SizedBox(height: 2), + Text( + widget.artistName!, + style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant), + maxLines: _expanded ? 3 : 1, + overflow: _expanded ? TextOverflow.visible : TextOverflow.ellipsis, + ), + ], + ], + ); + }, + ), + ), + if (_isOverflowing || _expanded) + Icon(_expanded ? Icons.expand_less : Icons.expand_more, color: colorScheme.onSurfaceVariant, size: 20), + ], + ), + ), + ], + ), + ), + ); + } +} + +/// Separate Consumer widget for each track - only rebuilds when this specific track's status changes +class _AlbumTrackItem extends ConsumerWidget { + final Track track; + final VoidCallback onDownload; + + const _AlbumTrackItem({required this.track, required this.onDownload}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final colorScheme = Theme.of(context).colorScheme; + + // Only watch the specific item for this track + 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 Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Card( + elevation: 0, + color: Colors.transparent, + margin: const EdgeInsets.symmetric(vertical: 2), + child: ListTile( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + leading: track.coverUrl != null + ? ClipRRect(borderRadius: BorderRadius.circular(8), child: CachedNetworkImage(imageUrl: track.coverUrl!, width: 48, height: 48, fit: BoxFit.cover, memCacheWidth: 96)) + : Container(width: 48, height: 48, decoration: BoxDecoration(color: colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(8)), child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant)), + title: Text(track.name, maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500)), + subtitle: Text(track.artistName, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(color: colorScheme.onSurfaceVariant)), + trailing: _buildDownloadButton(context, ref, colorScheme, isQueued: isQueued, isDownloading: isDownloading, isFinalizing: isFinalizing, showAsDownloaded: showAsDownloaded, isInHistory: isInHistory, progress: progress), + onTap: () => _handleTap(context, ref, isQueued: isQueued, isInHistory: isInHistory), + ), + ), + ); + } + + void _handleTap(BuildContext context, WidgetRef ref, {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 (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('"${track.name}" already downloaded'))); + } + return; + } else { + ref.read(downloadHistoryProvider.notifier).removeBySpotifyId(track.id); + } + } + } + + onDownload(); + } + + Widget _buildDownloadButton(BuildContext context, WidgetRef ref, ColorScheme colorScheme, { + required bool isQueued, + required bool isDownloading, + required bool isFinalizing, + required bool showAsDownloaded, + required bool isInHistory, + required double progress, + }) { + const double size = 44.0; + const double iconSize = 20.0; + + if (showAsDownloaded) { + return GestureDetector( + onTap: () => _handleTap(context, ref, 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: 3, color: colorScheme.tertiary, backgroundColor: colorScheme.surfaceContainerHighest), + Icon(Icons.edit_note, color: colorScheme.tertiary, size: 16), + ], + ), + ); + } else if (isDownloading) { + return SizedBox( + width: size, + height: size, + child: Stack( + alignment: Alignment.center, + children: [ + CircularProgressIndicator(value: progress > 0 ? progress : null, strokeWidth: 3, color: colorScheme.primary, backgroundColor: colorScheme.surfaceContainerHighest), + if (progress > 0) Text('${(progress * 100).toInt()}', style: TextStyle(fontSize: 10, 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: onDownload, + child: Container(width: size, height: size, decoration: BoxDecoration(color: colorScheme.secondaryContainer, shape: BoxShape.circle), child: Icon(Icons.download, color: colorScheme.onSecondaryContainer, size: iconSize)), + ); + } + } +} diff --git a/lib/screens/artist_screen.dart b/lib/screens/artist_screen.dart new file mode 100644 index 0000000..40b8de1 --- /dev/null +++ b/lib/screens/artist_screen.dart @@ -0,0 +1,321 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:spotiflac_android/providers/track_provider.dart'; +import 'package:spotiflac_android/providers/settings_provider.dart'; +import 'package:spotiflac_android/services/platform_bridge.dart'; +import 'package:spotiflac_android/screens/album_screen.dart'; + +/// Simple in-memory cache for artist discography +class _ArtistCache { + static final Map _cache = {}; + static const Duration _ttl = Duration(minutes: 10); + + static List? 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; + } + + static void set(String artistId, List albums) { + _cache[artistId] = _CacheEntry(albums, DateTime.now().add(_ttl)); + } +} + +class _CacheEntry { + final List albums; + final DateTime expiresAt; + _CacheEntry(this.albums, this.expiresAt); +} + +/// Artist screen with Material Expressive 3 design - shows discography +class ArtistScreen extends ConsumerStatefulWidget { + final String artistId; + final String artistName; + final String? coverUrl; + final List? albums; // Optional - will fetch if null + + const ArtistScreen({ + super.key, + required this.artistId, + required this.artistName, + this.coverUrl, + this.albums, + }); + + @override + ConsumerState createState() => _ArtistScreenState(); +} + +class _ArtistScreenState extends ConsumerState { + bool _isLoadingDiscography = false; + List? _albums; + String? _error; + + @override + void initState() { + super.initState(); + // Priority: widget.albums > cache > fetch + _albums = widget.albums ?? _ArtistCache.get(widget.artistId); + if (_albums == null) { + _fetchDiscography(); + } + } + + Future _fetchDiscography() async { + setState(() => _isLoadingDiscography = true); + try { + final url = 'https://open.spotify.com/artist/${widget.artistId}'; + final metadata = await PlatformBridge.getSpotifyMetadata(url); + final albumsList = metadata['albums'] as List; + final albums = albumsList.map((a) => _parseArtistAlbum(a as Map)).toList(); + + // Store in cache + _ArtistCache.set(widget.artistId, albums); + + if (mounted) { + setState(() { + _albums = albums; + _isLoadingDiscography = false; + }); + } + } catch (e) { + if (mounted) { + setState(() { + _error = e.toString(); + _isLoadingDiscography = false; + }); + } + } + } + + ArtistAlbum _parseArtistAlbum(Map data) { + return ArtistAlbum( + id: data['id'] as String? ?? '', + name: data['name'] as String? ?? '', + releaseDate: data['release_date'] as String? ?? '', + totalTracks: data['total_tracks'] as int? ?? 0, + coverUrl: data['images'] as String?, + albumType: data['album_type'] as String? ?? 'album', + artists: data['artists'] as String? ?? '', + ); + } + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final albums = _albums ?? []; + final albumsOnly = albums.where((a) => a.albumType == 'album').toList(); + final singles = albums.where((a) => a.albumType == 'single').toList(); + 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: Text(_error!, style: TextStyle(color: colorScheme.error)), + )), + if (!_isLoadingDiscography && _error == null) ...[ + if (albumsOnly.isNotEmpty) SliverToBoxAdapter(child: _buildAlbumSection('Albums', albumsOnly, colorScheme)), + if (singles.isNotEmpty) SliverToBoxAdapter(child: _buildAlbumSection('Singles & EPs', singles, colorScheme)), + if (compilations.isNotEmpty) SliverToBoxAdapter(child: _buildAlbumSection('Compilations', compilations, colorScheme)), + ], + const SliverToBoxAdapter(child: SizedBox(height: 32)), + ], + ), + ], + ), + ); + } + + Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) { + return SliverAppBar( + expandedHeight: 280, + pinned: true, + stretch: true, + backgroundColor: colorScheme.surface, + surfaceTintColor: Colors.transparent, + flexibleSpace: FlexibleSpaceBar( + background: Stack( + fit: StackFit.expand, + children: [ + if (widget.coverUrl != null) + CachedNetworkImage(imageUrl: widget.coverUrl!, fit: BoxFit.cover, color: Colors.black.withValues(alpha: 0.5), colorBlendMode: BlendMode.darken, memCacheWidth: 600), + 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], + ), + ), + ), + 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))], + ), + child: ClipOval( + child: widget.coverUrl != null + ? CachedNetworkImage(imageUrl: widget.coverUrl!, fit: BoxFit.cover, memCacheWidth: 280) + : Container(color: colorScheme.surfaceContainerHighest, child: Icon(Icons.person, size: 48, color: colorScheme.onSurfaceVariant)), + ), + ), + ), + ), + ], + ), + 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)), + 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('${_albums!.length} releases', style: TextStyle(color: colorScheme.onPrimaryContainer, fontWeight: FontWeight.w600, fontSize: 12)), + ], + ), + ), + ], + ), + ), + ), + ), + ); + } + + Widget _buildAlbumSection(String title, List albums, ColorScheme colorScheme) { + return Column( + 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)), + ], + ), + ), + SizedBox( + height: 210, + 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)); + }, + ), + ), + ], + ); + } + + Widget _buildAlbumCard(ArtistAlbum album, ColorScheme colorScheme) { + return GestureDetector( + 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.releaseDate.length >= 4 ? album.releaseDate.substring(0, 4) : album.releaseDate} • ${album.totalTracks} tracks', + style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant, fontSize: 11), + 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(); + Navigator.push(context, MaterialPageRoute( + builder: (context) => AlbumScreen( + albumId: album.id, + albumName: album.name, + coverUrl: album.coverUrl, + // tracks: null - will be fetched in AlbumScreen + ), + )); + } +} diff --git a/lib/screens/home_tab.dart b/lib/screens/home_tab.dart index 7e8e099..f45caef 100644 --- a/lib/screens/home_tab.dart +++ b/lib/screens/home_tab.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -8,6 +9,10 @@ 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/screens/track_metadata_screen.dart'; +import 'package:spotiflac_android/screens/album_screen.dart'; +import 'package:spotiflac_android/screens/artist_screen.dart'; +import 'package:spotiflac_android/screens/playlist_screen.dart'; +import 'package:spotiflac_android/models/download_item.dart'; class HomeTab extends ConsumerStatefulWidget { const HomeTab({super.key}); @@ -20,6 +25,7 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient Timer? _debounce; bool _isTyping = false; final FocusNode _searchFocusNode = FocusNode(); + String? _lastSearchQuery; // Track last searched query to avoid duplicate searches @override bool get wantKeepAlive => true; @@ -89,6 +95,10 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient } Future _performSearch(String query) async { + // Skip if same query already searched + if (_lastSearchQuery == query) return; + _lastSearchQuery = query; + await ref.read(trackProvider.notifier).search(query); ref.read(settingsProvider.notifier).setHasSearchedBefore(); } @@ -109,6 +119,7 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient _debounce?.cancel(); _urlController.clear(); _searchFocusNode.unfocus(); + _lastSearchQuery = null; // Reset last query setState(() => _isTyping = false); ref.read(trackProvider.notifier).clear(); } @@ -118,12 +129,59 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient if (url.isEmpty) return; if (url.startsWith('http') || url.startsWith('spotify:')) { await ref.read(trackProvider.notifier).fetchFromUrl(url); + _navigateToDetailIfNeeded(); } else { await ref.read(trackProvider.notifier).search(url); } ref.read(settingsProvider.notifier).setHasSearchedBefore(); } + /// Navigate to detail screen based on fetched content type + void _navigateToDetailIfNeeded() { + final trackState = ref.read(trackProvider); + + // Navigate to Album screen + if (trackState.albumId != null && trackState.albumName != null && trackState.tracks.isNotEmpty) { + Navigator.push(context, MaterialPageRoute(builder: (context) => AlbumScreen( + albumId: trackState.albumId!, + albumName: trackState.albumName!, + coverUrl: trackState.coverUrl, + tracks: trackState.tracks, + ))); + ref.read(trackProvider.notifier).clear(); + _urlController.clear(); + setState(() => _isTyping = false); + return; + } + + // Navigate to Playlist screen + if (trackState.playlistName != null && trackState.tracks.isNotEmpty) { + Navigator.push(context, MaterialPageRoute(builder: (context) => PlaylistScreen( + playlistName: trackState.playlistName!, + coverUrl: trackState.coverUrl, + tracks: trackState.tracks, + ))); + ref.read(trackProvider.notifier).clear(); + _urlController.clear(); + setState(() => _isTyping = false); + return; + } + + // Navigate to Artist screen + if (trackState.artistId != null && trackState.artistName != null && trackState.artistAlbums != null) { + Navigator.push(context, MaterialPageRoute(builder: (context) => ArtistScreen( + artistId: trackState.artistId!, + artistName: trackState.artistName!, + coverUrl: trackState.coverUrl, + albums: trackState.artistAlbums!, + ))); + ref.read(trackProvider.notifier).clear(); + _urlController.clear(); + setState(() => _isTyping = false); + return; + } + } + void _downloadTrack(int index) { final trackState = ref.read(trackProvider); if (index >= 0 && index < trackState.tracks.length) { @@ -134,7 +192,7 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient _showQualityPicker(context, (quality) { ref.read(downloadQueueProvider.notifier).addToQueue(track, settings.defaultService, qualityOverride: quality); ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue'))); - }); + }, trackName: track.name, artistName: track.artistName, coverUrl: track.coverUrl); } else { ref.read(downloadQueueProvider.notifier).addToQueue(track, settings.defaultService); ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue'))); @@ -142,23 +200,7 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient } } - void _downloadAll() { - final trackState = ref.read(trackProvider); - if (trackState.tracks.isEmpty) return; - final settings = ref.read(settingsProvider); - - if (settings.askQualityBeforeDownload) { - _showQualityPicker(context, (quality) { - ref.read(downloadQueueProvider.notifier).addMultipleToQueue(trackState.tracks, settings.defaultService, qualityOverride: quality); - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added ${trackState.tracks.length} tracks to queue'))); - }); - } else { - ref.read(downloadQueueProvider.notifier).addMultipleToQueue(trackState.tracks, settings.defaultService); - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added ${trackState.tracks.length} tracks to queue'))); - } - } - - void _showQualityPicker(BuildContext context, void Function(String quality) onSelect) { + void _showQualityPicker(BuildContext context, void Function(String quality) onSelect, {String? trackName, String? artistName, String? coverUrl}) { final colorScheme = Theme.of(context).colorScheme; showModalBottomSheet( context: context, @@ -169,8 +211,15 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ + if (trackName != null) ...[ + _TrackInfoHeader(trackName: trackName, artistName: artistName, coverUrl: coverUrl), + Divider(height: 1, color: colorScheme.outlineVariant.withValues(alpha: 0.5)), + ] else ...[ + const SizedBox(height: 8), + Center(child: Container(width: 40, height: 4, decoration: BoxDecoration(color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), borderRadius: BorderRadius.circular(2)))), + ], Padding( - padding: const EdgeInsets.fromLTRB(24, 24, 24, 8), + padding: const EdgeInsets.fromLTRB(24, 16, 24, 8), child: Text('Select Quality', style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)), ), _QualityPickerOption( @@ -199,22 +248,24 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient Widget build(BuildContext context) { super.build(context); - // Listen for state changes to sync search bar - ref.listen(trackProvider, _onTrackStateChanged); + // Listen for state changes to sync search bar and auto-navigate + ref.listen(trackProvider, (previous, next) { + _onTrackStateChanged(previous, next); + // Auto-navigate when URL fetch completes + if (previous != null && previous.isLoading && !next.isLoading && next.error == null) { + _navigateToDetailIfNeeded(); + } + }); // Use select() to only rebuild when specific fields change final tracks = ref.watch(trackProvider.select((s) => s.tracks)); + final searchArtists = ref.watch(trackProvider.select((s) => s.searchArtists)); final isLoading = ref.watch(trackProvider.select((s) => s.isLoading)); final error = ref.watch(trackProvider.select((s) => s.error)); - final albumName = ref.watch(trackProvider.select((s) => s.albumName)); - final playlistName = ref.watch(trackProvider.select((s) => s.playlistName)); - final artistName = ref.watch(trackProvider.select((s) => s.artistName)); - final coverUrl = ref.watch(trackProvider.select((s) => s.coverUrl)); - final artistAlbums = ref.watch(trackProvider.select((s) => s.artistAlbums)); final hasSearchedBefore = ref.watch(settingsProvider.select((s) => s.hasSearchedBefore)); final colorScheme = Theme.of(context).colorScheme; - final hasResults = _isTyping || tracks.isNotEmpty || artistAlbums != null || isLoading; + final hasResults = _isTyping || tracks.isNotEmpty || (searchArtists != null && searchArtists.isNotEmpty) || isLoading; final screenHeight = MediaQuery.of(context).size.height; final historyItems = ref.watch(downloadHistoryProvider.select((s) => s.items)); @@ -320,16 +371,12 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient ), ), - // Results content - always in tree - ..._buildResultsContentOptimized( + // Results content - search results only (albums/artists/playlists navigate to separate screens) + ..._buildSearchResults( tracks: tracks, + searchArtists: searchArtists, isLoading: isLoading, error: error, - albumName: albumName, - playlistName: playlistName, - artistName: artistName, - coverUrl: coverUrl, - artistAlbums: artistAlbums, colorScheme: colorScheme, hasResults: hasResults, ), @@ -354,7 +401,7 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient ), ), SizedBox( - height: 80, + height: 130, child: ListView.builder( scrollDirection: Axis.horizontal, itemCount: displayItems.length, @@ -365,32 +412,32 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient child: GestureDetector( onTap: () => _navigateToMetadataScreen(item), child: Container( - width: 60, + width: 100, margin: const EdgeInsets.only(right: 12), child: Column( children: [ ClipRRect( - borderRadius: BorderRadius.circular(8), + borderRadius: BorderRadius.circular(12), child: item.coverUrl != null ? CachedNetworkImage( imageUrl: item.coverUrl!, - width: 56, - height: 56, + width: 100, + height: 100, fit: BoxFit.cover, - memCacheWidth: 112, - memCacheHeight: 112, + memCacheWidth: 200, + memCacheHeight: 200, ) : Container( - width: 56, - height: 56, + width: 100, + height: 100, color: colorScheme.surfaceContainerHighest, - child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant, size: 24), + child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant, size: 32), ), ), - const SizedBox(height: 4), + const SizedBox(height: 6), Text( item.trackName, - style: Theme.of(context).textTheme.labelSmall?.copyWith( + style: Theme.of(context).textTheme.bodySmall?.copyWith( color: colorScheme.onSurfaceVariant, ), maxLines: 1, @@ -418,20 +465,15 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient )); } - // Results content slivers (without app bar and search bar) - optimized version - List _buildResultsContentOptimized({ + // Search results slivers - only shows search results (track list) + List _buildSearchResults({ required List tracks, + required List? searchArtists, required bool isLoading, required String? error, - required String? albumName, - required String? playlistName, - required String? artistName, - required String? coverUrl, - required List? artistAlbums, required ColorScheme colorScheme, required bool hasResults, }) { - // Return empty slivers when no results to keep tree structure stable if (!hasResults) { return [const SliverToBoxAdapter(child: SizedBox.shrink())]; } @@ -448,177 +490,131 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient if (isLoading) const SliverToBoxAdapter(child: Padding(padding: EdgeInsets.symmetric(horizontal: 16), child: LinearProgressIndicator())), - // Album/Playlist header - if (albumName != null || playlistName != null) - SliverToBoxAdapter(child: _buildHeaderOptimized( - albumName: albumName, - playlistName: playlistName, - coverUrl: coverUrl, - trackCount: tracks.length, - colorScheme: colorScheme, - )), + // Artist search results (horizontal scroll) + if (searchArtists != null && searchArtists.isNotEmpty) + SliverToBoxAdapter(child: _buildArtistSearchResults(searchArtists, colorScheme)), - // Artist header and discography - if (artistName != null && artistAlbums != null) - SliverToBoxAdapter(child: _buildArtistHeaderOptimized( - artistName: artistName, - coverUrl: coverUrl, - albumCount: artistAlbums.length, - colorScheme: colorScheme, - )), - - if (artistAlbums != null && artistAlbums.isNotEmpty) - SliverToBoxAdapter(child: _buildArtistDiscographyOptimized(artistAlbums, colorScheme)), - - // Download All button - if (tracks.length > 1 && albumName == null && playlistName == null && artistAlbums == null) + // Songs section header + if (tracks.isNotEmpty) SliverToBoxAdapter(child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: FilledButton.icon(onPressed: _downloadAll, icon: const Icon(Icons.download), - label: Text('Download All (${tracks.length})'), - style: FilledButton.styleFrom(minimumSize: const Size.fromHeight(48))), + padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), + child: Text('Songs', style: Theme.of(context).textTheme.titleSmall?.copyWith(color: colorScheme.onSurfaceVariant)), )), - // Track list with keys for efficient updates - SliverList(delegate: SliverChildBuilderDelegate( - (context, index) { - final track = tracks[index]; - return KeyedSubtree( - key: ValueKey(track.id), - child: _buildTrackTileOptimized(track, index, colorScheme), - ); - }, - childCount: tracks.length, - )), + // Track list in grouped card + if (tracks.isNotEmpty) + SliverToBoxAdapter( + child: Container( + margin: const EdgeInsets.symmetric(horizontal: 16), + decoration: BoxDecoration( + color: Theme.of(context).brightness == Brightness.dark + ? Color.alphaBlend(Colors.white.withValues(alpha: 0.08), colorScheme.surface) + : colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(20), + ), + clipBehavior: Clip.antiAlias, + child: Material( + color: Colors.transparent, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (int i = 0; i < tracks.length; i++) + _TrackItemWithStatus( + key: ValueKey(tracks[i].id), + track: tracks[i], + index: i, + showDivider: i < tracks.length - 1, + onDownload: () => _downloadTrack(i), + ), + ], + ), + ), + ), + ), // Bottom padding const SliverToBoxAdapter(child: SizedBox(height: 16)), ]; } - Widget _buildHeaderOptimized({ - required String? albumName, - required String? playlistName, - required String? coverUrl, - required int trackCount, - required ColorScheme colorScheme, - }) { - return Card( - margin: const EdgeInsets.all(16), - child: Padding( - padding: const EdgeInsets.all(16), - child: Row( - children: [ - if (coverUrl != null) - ClipRRect(borderRadius: BorderRadius.circular(12), - child: CachedNetworkImage(imageUrl: coverUrl, width: 80, height: 80, fit: BoxFit.cover, - placeholder: (_, _) => Container(width: 80, height: 80, color: colorScheme.surfaceContainerHighest))), - const SizedBox(width: 16), - Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(albumName ?? playlistName ?? '', - style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), - maxLines: 2, overflow: TextOverflow.ellipsis), - const SizedBox(height: 4), - Text('$trackCount tracks', - style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)), - ])), - FilledButton.tonal(onPressed: _downloadAll, - style: FilledButton.styleFrom(shape: const CircleBorder(), padding: const EdgeInsets.all(16)), - child: const Icon(Icons.download)), - ], + Widget _buildArtistSearchResults(List artists, ColorScheme colorScheme) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), + child: Text('Artists', style: Theme.of(context).textTheme.titleSmall?.copyWith(color: colorScheme.onSurfaceVariant)), ), - ), + SizedBox( + height: 160, + child: ListView.builder( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 12), + itemCount: artists.length, + itemBuilder: (context, index) { + final artist = artists[index]; + return KeyedSubtree( + key: ValueKey(artist.id), + child: _buildArtistCard(artist, colorScheme), + ); + }, + ), + ), + ], ); } - Widget _buildArtistHeaderOptimized({ - required String? artistName, - required String? coverUrl, - required int albumCount, - required ColorScheme colorScheme, - }) { - return Card( - margin: const EdgeInsets.all(16), - child: Padding( - padding: const EdgeInsets.all(16), - child: Row( + Widget _buildArtistCard(SearchArtist artist, ColorScheme colorScheme) { + return GestureDetector( + onTap: () => _navigateToArtist(artist.id, artist.name, artist.imageUrl), + child: Container( + width: 110, + margin: const EdgeInsets.symmetric(horizontal: 6), + child: Column( children: [ - if (coverUrl != null) - ClipRRect( - borderRadius: BorderRadius.circular(40), - child: CachedNetworkImage( - imageUrl: coverUrl, - width: 80, - height: 80, - fit: BoxFit.cover, - placeholder: (_, _) => Container( - width: 80, - height: 80, - color: colorScheme.surfaceContainerHighest, - child: Icon(Icons.person, color: colorScheme.onSurfaceVariant), - ), - ), + Container( + width: 100, + height: 100, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: colorScheme.surfaceContainerHighest, ), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - artistName ?? '', - style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: 4), - Text( - '$albumCount releases', - style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant), - ), - ], + child: ClipOval( + child: artist.imageUrl != null + ? CachedNetworkImage( + imageUrl: artist.imageUrl!, + fit: BoxFit.cover, + memCacheWidth: 200, + memCacheHeight: 200, + ) + : Icon(Icons.person, color: colorScheme.onSurfaceVariant, size: 44), ), ), + const SizedBox(height: 8), + Text( + artist.name, + style: Theme.of(context).textTheme.bodySmall?.copyWith(fontWeight: FontWeight.w500), + maxLines: 2, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + ), ], ), ), ); } - Widget _buildArtistDiscographyOptimized(List albums, ColorScheme colorScheme) { - final albumsOnly = albums.where((a) => a.albumType == 'album').toList(); - final singles = albums.where((a) => a.albumType == 'single').toList(); - final compilations = albums.where((a) => a.albumType == 'compilation').toList(); - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (albumsOnly.isNotEmpty) _buildAlbumSection('Albums', albumsOnly, colorScheme), - if (singles.isNotEmpty) _buildAlbumSection('Singles & EPs', singles, colorScheme), - if (compilations.isNotEmpty) _buildAlbumSection('Compilations', compilations, colorScheme), - ], - ); - } - - Widget _buildTrackTileOptimized(Track track, int index, ColorScheme colorScheme) { - return ListTile( - leading: track.coverUrl != null - ? ClipRRect(borderRadius: BorderRadius.circular(8), - child: CachedNetworkImage( - imageUrl: track.coverUrl!, - width: 48, - height: 48, - fit: BoxFit.cover, - memCacheWidth: 96, - memCacheHeight: 96, - )) - : Container(width: 48, height: 48, - decoration: BoxDecoration(color: colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(8)), - child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant)), - title: Text(track.name, maxLines: 1, overflow: TextOverflow.ellipsis), - subtitle: Text(track.artistName, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(color: colorScheme.onSurfaceVariant)), - trailing: IconButton(icon: Icon(Icons.download, color: colorScheme.primary), onPressed: () => _downloadTrack(index)), - onTap: () => _downloadTrack(index), - ); + void _navigateToArtist(String artistId, String artistName, String? imageUrl) { + // Navigate immediately with data from search, fetch albums in ArtistScreen + ref.read(settingsProvider.notifier).setHasSearchedBefore(); + Navigator.push(context, MaterialPageRoute( + builder: (context) => ArtistScreen( + artistId: artistId, + artistName: artistName, + coverUrl: imageUrl, + // albums: null - will be fetched in ArtistScreen + ), + )); } Widget _buildSearchBar(ColorScheme colorScheme) { @@ -668,93 +664,6 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient ); } - Widget _buildAlbumSection(String title, List albums, ColorScheme colorScheme) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), - child: Text( - '$title (${albums.length})', - style: Theme.of(context).textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w600, - color: colorScheme.primary, - ), - ), - ), - SizedBox( - height: 180, - 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), - ); - }, - ), - ), - ], - ); - } - - Widget _buildAlbumCard(ArtistAlbum album, ColorScheme colorScheme) { - return GestureDetector( - onTap: () => _fetchAlbum(album.id), - child: Container( - width: 130, - margin: const EdgeInsets.symmetric(horizontal: 4), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(8), - child: album.coverUrl != null - ? CachedNetworkImage( - imageUrl: album.coverUrl!, - width: 130, - height: 130, - fit: BoxFit.cover, - memCacheWidth: 260, - memCacheHeight: 260, - ) - : Container( - width: 130, - height: 130, - color: colorScheme.surfaceContainerHighest, - child: Icon(Icons.album, color: colorScheme.onSurfaceVariant), - ), - ), - const SizedBox(height: 8), - Text( - album.name, - style: Theme.of(context).textTheme.bodySmall?.copyWith(fontWeight: FontWeight.w500), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - Text( - '${album.releaseDate.length >= 4 ? album.releaseDate.substring(0, 4) : album.releaseDate} • ${album.totalTracks} tracks', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - fontSize: 11, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ], - ), - ), - ); - } - - void _fetchAlbum(String albumId) { - // Use fetchAlbumFromArtist to save artist state for back navigation - ref.read(trackProvider.notifier).fetchAlbumFromArtist(albumId); - ref.read(settingsProvider.notifier).setHasSearchedBefore(); - } } class _QualityPickerOption extends StatelessWidget { @@ -775,3 +684,293 @@ class _QualityPickerOption extends StatelessWidget { ); } } + +class _TrackInfoHeader extends StatefulWidget { + final String trackName; + final String? artistName; + final String? coverUrl; + const _TrackInfoHeader({required this.trackName, this.artistName, this.coverUrl}); + + @override + State<_TrackInfoHeader> createState() => _TrackInfoHeaderState(); +} + +class _TrackInfoHeaderState extends State<_TrackInfoHeader> { + bool _expanded = false; + bool _isOverflowing = false; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return Material( + color: Colors.transparent, + child: InkWell( + onTap: _isOverflowing ? () => setState(() => _expanded = !_expanded) : null, + borderRadius: const BorderRadius.only(topLeft: Radius.circular(28), topRight: Radius.circular(28)), + child: Column( + children: [ + const SizedBox(height: 8), + Container(width: 40, height: 4, decoration: BoxDecoration(color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), borderRadius: BorderRadius.circular(2))), + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 12), + child: Row( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: widget.coverUrl != null + ? Image.network(widget.coverUrl!, width: 56, height: 56, fit: BoxFit.cover, + errorBuilder: (_, e, s) => Container(width: 56, height: 56, color: colorScheme.surfaceContainerHighest, child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant))) + : Container(width: 56, height: 56, color: colorScheme.surfaceContainerHighest, child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant)), + ), + const SizedBox(width: 12), + Expanded( + child: LayoutBuilder( + builder: (context, constraints) { + final titleStyle = Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600); + final titleSpan = TextSpan(text: widget.trackName, style: titleStyle); + final titlePainter = TextPainter(text: titleSpan, maxLines: 1, textDirection: TextDirection.ltr)..layout(maxWidth: constraints.maxWidth); + final titleOverflows = titlePainter.didExceedMaxLines; + + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && _isOverflowing != titleOverflows) { + setState(() => _isOverflowing = titleOverflows); + } + }); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.trackName, + style: titleStyle, + maxLines: _expanded ? 10 : 1, + overflow: _expanded ? TextOverflow.visible : TextOverflow.ellipsis, + ), + if (widget.artistName != null) ...[ + const SizedBox(height: 2), + Text( + widget.artistName!, + style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant), + maxLines: _expanded ? 3 : 1, + overflow: _expanded ? TextOverflow.visible : TextOverflow.ellipsis, + ), + ], + ], + ); + }, + ), + ), + if (_isOverflowing || _expanded) + Icon(_expanded ? Icons.expand_less : Icons.expand_more, color: colorScheme.onSurfaceVariant, size: 20), + ], + ), + ), + ], + ), + ), + ); + } +} + +/// Separate Consumer widget for each track item - only rebuilds when this specific track's status changes +class _TrackItemWithStatus extends ConsumerWidget { + final Track track; + final int index; + final bool showDivider; + final VoidCallback onDownload; + + const _TrackItemWithStatus({ + super.key, + required this.track, + required this.index, + required this.showDivider, + required this.onDownload, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final colorScheme = Theme.of(context).colorScheme; + + // Only watch the specific item for this track using select() + 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 Column( + mainAxisSize: MainAxisSize.min, + children: [ + InkWell( + onTap: () => _handleTap(context, ref, isQueued: isQueued, isInHistory: isInHistory), + splashColor: colorScheme.primary.withValues(alpha: 0.12), + highlightColor: colorScheme.primary.withValues(alpha: 0.08), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + child: Row( + children: [ + // Album art + ClipRRect( + borderRadius: BorderRadius.circular(10), + child: track.coverUrl != null + ? CachedNetworkImage( + imageUrl: track.coverUrl!, + width: 56, + height: 56, + fit: BoxFit.cover, + memCacheWidth: 112, + memCacheHeight: 112, + ) + : Container( + width: 56, + height: 56, + color: colorScheme.surfaceContainerHighest, + child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant), + ), + ), + 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, + ), + const SizedBox(height: 2), + Text( + track.artistName, + style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + // Download button / status indicator + _buildDownloadButton(context, ref, colorScheme, isQueued: isQueued, isDownloading: isDownloading, isFinalizing: isFinalizing, showAsDownloaded: showAsDownloaded, isInHistory: isInHistory, progress: progress), + ], + ), + ), + ), + if (showDivider) + Divider( + height: 1, + thickness: 1, + indent: 80, + endIndent: 12, + color: colorScheme.outlineVariant.withValues(alpha: 0.3), + ), + ], + ); + } + + void _handleTap(BuildContext context, WidgetRef ref, {required bool isQueued, required bool isInHistory}) async { + // If already in queue, do nothing + if (isQueued) return; + + // If in history, check if file still exists + if (isInHistory) { + final historyItem = ref.read(downloadHistoryProvider.notifier).getBySpotifyId(track.id); + if (historyItem != null) { + final fileExists = await File(historyItem.filePath).exists(); + if (fileExists) { + // File exists, show snackbar + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('"${track.name}" already downloaded')), + ); + } + return; + } else { + // File doesn't exist, remove from history and allow download + ref.read(downloadHistoryProvider.notifier).removeBySpotifyId(track.id); + } + } + } + + // Proceed with download + onDownload(); + } + + Widget _buildDownloadButton(BuildContext context, WidgetRef ref, ColorScheme colorScheme, { + required bool isQueued, + required bool isDownloading, + required bool isFinalizing, + required bool showAsDownloaded, + required bool isInHistory, + required double progress, + }) { + const double size = 44.0; + const double iconSize = 20.0; + + if (showAsDownloaded) { + return GestureDetector( + onTap: () => _handleTap(context, ref, 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) { + // Show finalizing status (embedding metadata) + return SizedBox( + width: size, + height: size, + child: Stack( + alignment: Alignment.center, + children: [ + CircularProgressIndicator(strokeWidth: 3, color: colorScheme.tertiary, backgroundColor: colorScheme.surfaceContainerHighest), + Icon(Icons.edit_note, color: colorScheme.tertiary, size: 16), + ], + ), + ); + } else if (isDownloading) { + return SizedBox( + width: size, + height: size, + child: Stack( + alignment: Alignment.center, + children: [ + CircularProgressIndicator(value: progress > 0 ? progress : null, strokeWidth: 3, color: colorScheme.primary, backgroundColor: colorScheme.surfaceContainerHighest), + if (progress > 0) Text('${(progress * 100).toInt()}', style: TextStyle(fontSize: 10, 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: onDownload, + child: Container( + width: size, + height: size, + decoration: BoxDecoration(color: colorScheme.secondaryContainer, shape: BoxShape.circle), + child: Icon(Icons.download, color: colorScheme.onSecondaryContainer, size: iconSize), + ), + ); + } + } +} diff --git a/lib/screens/main_shell.dart b/lib/screens/main_shell.dart index 0acc0de..6fb0559 100644 --- a/lib/screens/main_shell.dart +++ b/lib/screens/main_shell.dart @@ -27,6 +27,7 @@ class _MainShellState extends ConsumerState { late PageController _pageController; bool _hasCheckedUpdate = false; StreamSubscription? _shareSubscription; + DateTime? _lastBackPress; // For double-tap to exit @override void initState() { @@ -120,65 +121,66 @@ class _MainShellState extends ConsumerState { } } - Future _showExitDialog() async { - return await showDialog( - context: context, - builder: (context) => AlertDialog( - title: const Text('Exit App'), - content: const Text('Are you sure you want to exit?'), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context, false), - child: const Text('No'), - ), - TextButton( - onPressed: () => Navigator.pop(context, true), - child: const Text('Yes'), - ), - ], - ), - ) ?? false; + /// Handle back press with double-tap to exit + void _handleBackPress() { + final trackState = ref.read(trackProvider); + + // If on Home tab and has text in search bar or has content (but not loading), clear it + if (_currentIndex == 0 && !trackState.isLoading && (trackState.hasSearchText || trackState.hasContent)) { + ref.read(trackProvider.notifier).clear(); + return; + } + + // If not on Home tab, go to Home tab first + if (_currentIndex != 0) { + _onNavTap(0); + return; + } + + // If loading, ignore back press + if (trackState.isLoading) { + return; + } + + // Double-tap to exit + final now = DateTime.now(); + if (_lastBackPress != null && now.difference(_lastBackPress!) < const Duration(seconds: 2)) { + SystemNavigator.pop(); + } else { + _lastBackPress = now; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Press back again to exit'), + duration: Duration(seconds: 2), + behavior: SnackBarBehavior.floating, + ), + ); + } } @override Widget build(BuildContext context) { final queueState = ref.watch(downloadQueueProvider.select((s) => s.queuedCount)); final trackState = ref.watch(trackProvider); + + // Determine if we can pop (for predictive back animation) + // canPop is true when we're at root with no content - enables predictive back gesture + final canPop = _currentIndex == 0 && + !trackState.hasSearchText && + !trackState.hasContent && + !trackState.isLoading; return PopScope( - canPop: false, + canPop: canPop, onPopInvokedWithResult: (didPop, result) async { - if (didPop) return; - - // If on Search tab and can go back in track history, go back - if (_currentIndex == 0 && trackState.canGoBack) { - ref.read(trackProvider.notifier).goBack(); + if (didPop) { + // System handled the pop - this means predictive back completed + // We need to handle double-tap to exit here return; } - // If on Search tab and has text in search bar or has content (but not loading), clear it - // Don't clear while loading - this prevents clearing during share intent processing - if (_currentIndex == 0 && !trackState.isLoading && (trackState.hasSearchText || trackState.hasContent)) { - ref.read(trackProvider.notifier).clear(); - return; - } - - // If not on Search tab, go to Search tab first - if (_currentIndex != 0) { - _onNavTap(0); - return; - } - - // If loading, ignore back press - if (trackState.isLoading) { - return; - } - - // Already at root, show exit dialog - final shouldPop = await _showExitDialog(); - if (shouldPop && context.mounted) { - SystemNavigator.pop(); - } + // Handle back press manually when canPop is false + _handleBackPress(); }, child: Scaffold( body: PageView( @@ -195,6 +197,9 @@ class _MainShellState extends ConsumerState { selectedIndex: _currentIndex, onDestinationSelected: _onNavTap, animationDuration: const Duration(milliseconds: 200), + backgroundColor: Theme.of(context).brightness == Brightness.dark + ? Color.alphaBlend(Colors.white.withValues(alpha: 0.05), Theme.of(context).colorScheme.surface) + : Color.alphaBlend(Colors.black.withValues(alpha: 0.03), Theme.of(context).colorScheme.surface), destinations: [ const NavigationDestination( icon: Icon(Icons.home_outlined), diff --git a/lib/screens/playlist_screen.dart b/lib/screens/playlist_screen.dart new file mode 100644 index 0000000..cbbfb3b --- /dev/null +++ b/lib/screens/playlist_screen.dart @@ -0,0 +1,453 @@ +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: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'; + +/// Playlist detail screen with Material Expressive 3 design +class PlaylistScreen extends ConsumerWidget { + final String playlistName; + final String? coverUrl; + final List tracks; + + const PlaylistScreen({ + super.key, + required this.playlistName, + this.coverUrl, + required this.tracks, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final colorScheme = Theme.of(context).colorScheme; + + return Scaffold( + body: CustomScrollView( + slivers: [ + _buildAppBar(context, colorScheme), + _buildInfoCard(context, ref, colorScheme), + _buildTrackListHeader(context, colorScheme), + _buildTrackList(context, ref, colorScheme), + const SliverToBoxAdapter(child: SizedBox(height: 32)), + ], + ), + ); + } + + Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) { + return SliverAppBar( + expandedHeight: 280, + pinned: true, + stretch: true, + backgroundColor: colorScheme.surface, + surfaceTintColor: Colors.transparent, + flexibleSpace: FlexibleSpaceBar( + background: Stack( + fit: StackFit.expand, + children: [ + if (coverUrl != null) + CachedNetworkImage(imageUrl: coverUrl!, fit: BoxFit.cover, color: Colors.black.withValues(alpha: 0.5), colorBlendMode: BlendMode.darken, memCacheWidth: 600), + 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], + ), + ), + ), + Center( + child: Padding( + padding: const EdgeInsets.only(top: 60), + child: Container( + width: 140, + height: 140, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.3), blurRadius: 20, offset: const Offset(0, 10))], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(16), + child: coverUrl != null + ? CachedNetworkImage(imageUrl: coverUrl!, fit: BoxFit.cover, memCacheWidth: 280) + : Container(color: colorScheme.surfaceContainerHighest, child: Icon(Icons.playlist_play, size: 48, color: colorScheme.onSurfaceVariant)), + ), + ), + ), + ), + ], + ), + 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)), + onPressed: () => Navigator.pop(context), + ), + ); + } + + Widget _buildInfoCard(BuildContext context, WidgetRef ref, 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(playlistName, style: Theme.of(context).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold, color: colorScheme.onSurface)), + const SizedBox(height: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration(color: colorScheme.tertiaryContainer, borderRadius: BorderRadius.circular(20)), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.playlist_play, size: 14, color: colorScheme.onTertiaryContainer), + const SizedBox(width: 4), + Text('${tracks.length} tracks', style: TextStyle(color: colorScheme.onTertiaryContainer, fontWeight: FontWeight.w600, fontSize: 12)), + ], + ), + ), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: () => _downloadAll(context, ref), + icon: const Icon(Icons.download), + label: Text('Download All (${tracks.length})'), + style: FilledButton.styleFrom(minimumSize: const Size.fromHeight(52), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16))), + ), + ], + ), + ), + ), + ), + ); + } + + Widget _buildTrackListHeader(BuildContext context, ColorScheme colorScheme) { + return SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 8, 20, 8), + child: Row( + children: [ + Icon(Icons.queue_music, size: 20, color: colorScheme.primary), + const SizedBox(width: 8), + Text('Tracks', style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600, color: colorScheme.onSurface)), + ], + ), + ), + ); + } + + Widget _buildTrackList(BuildContext context, WidgetRef ref, ColorScheme colorScheme) { + return SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) { + final track = tracks[index]; + return KeyedSubtree( + key: ValueKey(track.id), + child: _PlaylistTrackItem( + track: track, + onDownload: () => _downloadTrack(context, ref, track), + ), + ); + }, + childCount: tracks.length, + ), + ); + } + + void _downloadTrack(BuildContext context, WidgetRef ref, Track track) { + final settings = ref.read(settingsProvider); + if (settings.askQualityBeforeDownload) { + _showQualityPicker(context, (quality) { + ref.read(downloadQueueProvider.notifier).addToQueue(track, settings.defaultService, qualityOverride: quality); + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue'))); + }, trackName: track.name, artistName: track.artistName, coverUrl: track.coverUrl); + } else { + ref.read(downloadQueueProvider.notifier).addToQueue(track, settings.defaultService); + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue'))); + } + } + + void _downloadAll(BuildContext context, WidgetRef ref) { + if (tracks.isEmpty) return; + final settings = ref.read(settingsProvider); + if (settings.askQualityBeforeDownload) { + _showQualityPicker(context, (quality) { + ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, settings.defaultService, qualityOverride: quality); + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added ${tracks.length} tracks to queue'))); + }, trackName: '${tracks.length} tracks', artistName: playlistName); + } else { + ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, settings.defaultService); + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added ${tracks.length} tracks to queue'))); + } + } + + void _showQualityPicker(BuildContext context, void Function(String quality) onSelect, {String? trackName, String? artistName, String? coverUrl}) { + final colorScheme = Theme.of(context).colorScheme; + showModalBottomSheet( + context: context, + backgroundColor: colorScheme.surfaceContainerHigh, + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(28))), + builder: (context) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (trackName != null) ...[ + _TrackInfoHeader(trackName: trackName, artistName: artistName, coverUrl: coverUrl), + Divider(height: 1, color: colorScheme.outlineVariant.withValues(alpha: 0.5)), + ] else ...[ + const SizedBox(height: 8), + Center(child: Container(width: 40, height: 4, decoration: BoxDecoration(color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), borderRadius: BorderRadius.circular(2)))), + ], + Padding(padding: const EdgeInsets.fromLTRB(24, 16, 24, 8), child: Text('Select Quality', style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold))), + _QualityOption(title: 'FLAC Lossless', subtitle: '16-bit / 44.1kHz', icon: Icons.music_note, onTap: () { Navigator.pop(context); onSelect('LOSSLESS'); }), + _QualityOption(title: 'Hi-Res FLAC', subtitle: '24-bit / up to 96kHz', icon: Icons.high_quality, onTap: () { Navigator.pop(context); onSelect('HI_RES'); }), + _QualityOption(title: 'Hi-Res FLAC Max', subtitle: '24-bit / up to 192kHz', icon: Icons.hd, onTap: () { Navigator.pop(context); onSelect('HI_RES_LOSSLESS'); }), + const SizedBox(height: 16), + ], + ), + ), + ); + } +} + +class _QualityOption extends StatelessWidget { + final String title; + final String subtitle; + final IconData icon; + final VoidCallback onTap; + + const _QualityOption({required this.title, required this.subtitle, required this.icon, required this.onTap}); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 4), + leading: Container(padding: const EdgeInsets.all(10), decoration: BoxDecoration(color: colorScheme.primaryContainer, borderRadius: BorderRadius.circular(12)), child: Icon(icon, color: colorScheme.onPrimaryContainer, size: 20)), + title: Text(title, style: const TextStyle(fontWeight: FontWeight.w500)), + subtitle: Text(subtitle, style: TextStyle(color: colorScheme.onSurfaceVariant)), + onTap: onTap, + ); + } +} + +class _TrackInfoHeader extends StatefulWidget { + final String trackName; + final String? artistName; + final String? coverUrl; + const _TrackInfoHeader({required this.trackName, this.artistName, this.coverUrl}); + + @override + State<_TrackInfoHeader> createState() => _TrackInfoHeaderState(); +} + +class _TrackInfoHeaderState extends State<_TrackInfoHeader> { + bool _expanded = false; + bool _isOverflowing = false; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return Material( + color: Colors.transparent, + child: InkWell( + onTap: _isOverflowing ? () => setState(() => _expanded = !_expanded) : null, + borderRadius: const BorderRadius.only(topLeft: Radius.circular(28), topRight: Radius.circular(28)), + child: Column( + children: [ + const SizedBox(height: 8), + Container(width: 40, height: 4, decoration: BoxDecoration(color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), borderRadius: BorderRadius.circular(2))), + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 12), + child: Row( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: widget.coverUrl != null + ? Image.network(widget.coverUrl!, width: 56, height: 56, fit: BoxFit.cover, + errorBuilder: (_, e, s) => Container(width: 56, height: 56, color: colorScheme.surfaceContainerHighest, child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant))) + : Container(width: 56, height: 56, color: colorScheme.surfaceContainerHighest, child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant)), + ), + const SizedBox(width: 12), + Expanded( + child: LayoutBuilder( + builder: (context, constraints) { + final titleStyle = Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600); + final titleSpan = TextSpan(text: widget.trackName, style: titleStyle); + final titlePainter = TextPainter(text: titleSpan, maxLines: 1, textDirection: TextDirection.ltr)..layout(maxWidth: constraints.maxWidth); + final titleOverflows = titlePainter.didExceedMaxLines; + + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && _isOverflowing != titleOverflows) { + setState(() => _isOverflowing = titleOverflows); + } + }); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.trackName, + style: titleStyle, + maxLines: _expanded ? 10 : 1, + overflow: _expanded ? TextOverflow.visible : TextOverflow.ellipsis, + ), + if (widget.artistName != null) ...[ + const SizedBox(height: 2), + Text( + widget.artistName!, + style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant), + maxLines: _expanded ? 3 : 1, + overflow: _expanded ? TextOverflow.visible : TextOverflow.ellipsis, + ), + ], + ], + ); + }, + ), + ), + if (_isOverflowing || _expanded) + Icon(_expanded ? Icons.expand_less : Icons.expand_more, color: colorScheme.onSurfaceVariant, size: 20), + ], + ), + ), + ], + ), + ), + ); + } +} + +/// Separate Consumer widget for each track - only rebuilds when this specific track's status changes +class _PlaylistTrackItem extends ConsumerWidget { + final Track track; + final VoidCallback onDownload; + + const _PlaylistTrackItem({required this.track, required this.onDownload}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final colorScheme = Theme.of(context).colorScheme; + + // Only watch the specific item for this track + 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 Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Card( + elevation: 0, + color: Colors.transparent, + margin: const EdgeInsets.symmetric(vertical: 2), + child: ListTile( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + leading: track.coverUrl != null + ? ClipRRect(borderRadius: BorderRadius.circular(8), child: CachedNetworkImage(imageUrl: track.coverUrl!, width: 48, height: 48, fit: BoxFit.cover, memCacheWidth: 96)) + : Container(width: 48, height: 48, decoration: BoxDecoration(color: colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(8)), child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant)), + title: Text(track.name, maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500)), + subtitle: Text(track.artistName, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(color: colorScheme.onSurfaceVariant)), + trailing: _buildDownloadButton(context, ref, colorScheme, isQueued: isQueued, isDownloading: isDownloading, isFinalizing: isFinalizing, showAsDownloaded: showAsDownloaded, isInHistory: isInHistory, progress: progress), + onTap: () => _handleTap(context, ref, isQueued: isQueued, isInHistory: isInHistory), + ), + ), + ); + } + + void _handleTap(BuildContext context, WidgetRef ref, {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 (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('"${track.name}" already downloaded'))); + } + return; + } else { + ref.read(downloadHistoryProvider.notifier).removeBySpotifyId(track.id); + } + } + } + + onDownload(); + } + + Widget _buildDownloadButton(BuildContext context, WidgetRef ref, ColorScheme colorScheme, { + required bool isQueued, + required bool isDownloading, + required bool isFinalizing, + required bool showAsDownloaded, + required bool isInHistory, + required double progress, + }) { + const double size = 44.0; + const double iconSize = 20.0; + + if (showAsDownloaded) { + return GestureDetector( + onTap: () => _handleTap(context, ref, 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: 3, color: colorScheme.tertiary, backgroundColor: colorScheme.surfaceContainerHighest), + Icon(Icons.edit_note, color: colorScheme.tertiary, size: 16), + ], + ), + ); + } else if (isDownloading) { + return SizedBox( + width: size, + height: size, + child: Stack( + alignment: Alignment.center, + children: [ + CircularProgressIndicator(value: progress > 0 ? progress : null, strokeWidth: 3, color: colorScheme.primary, backgroundColor: colorScheme.surfaceContainerHighest), + if (progress > 0) Text('${(progress * 100).toInt()}', style: TextStyle(fontSize: 10, 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: onDownload, + child: Container(width: size, height: size, decoration: BoxDecoration(color: colorScheme.secondaryContainer, shape: BoxShape.circle), child: Icon(Icons.download, color: colorScheme.onSecondaryContainer, size: iconSize)), + ); + } + } +} diff --git a/lib/screens/queue_screen.dart b/lib/screens/queue_screen.dart index 39d1548..63506c4 100644 --- a/lib/screens/queue_screen.dart +++ b/lib/screens/queue_screen.dart @@ -144,6 +144,18 @@ class QueueScreen extends ConsumerWidget { color: colorScheme.primary, ), ); + case DownloadStatus.finalizing: + return SizedBox( + width: 24, + height: 24, + child: Stack( + alignment: Alignment.center, + children: [ + CircularProgressIndicator(strokeWidth: 2, color: colorScheme.tertiary), + Icon(Icons.edit_note, color: colorScheme.tertiary, size: 12), + ], + ), + ); case DownloadStatus.completed: return Icon(Icons.check_circle, color: colorScheme.primary); case DownloadStatus.failed: diff --git a/lib/screens/queue_tab.dart b/lib/screens/queue_tab.dart index 46537f0..0b4aadb 100644 --- a/lib/screens/queue_tab.dart +++ b/lib/screens/queue_tab.dart @@ -424,6 +424,25 @@ class _QueueTabState extends ConsumerState { ], ); + case DownloadStatus.finalizing: + // Finalizing: Show spinner with edit icon (embedding metadata) + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: 40, + height: 40, + child: Stack( + alignment: Alignment.center, + children: [ + CircularProgressIndicator(strokeWidth: 3, color: colorScheme.tertiary), + Icon(Icons.edit_note, color: colorScheme.tertiary, size: 16), + ], + ), + ), + ], + ); + case DownloadStatus.completed: // Completed: Show play button and check icon final fileExists = _checkFileExists(item.filePath); diff --git a/lib/screens/settings/appearance_settings_page.dart b/lib/screens/settings/appearance_settings_page.dart index 3bd7612..18d444f 100644 --- a/lib/screens/settings/appearance_settings_page.dart +++ b/lib/screens/settings/appearance_settings_page.dart @@ -144,27 +144,38 @@ class _ThemeModeChip extends StatelessWidget { final colorScheme = Theme.of(context).colorScheme; final isDark = Theme.of(context).brightness == Brightness.dark; - // Unselected chips need to be darker than the card background + // Unselected chips need contrast with card background + // Card uses: dark = white 8% overlay, light = surfaceContainerHighest + // So chips use: dark = white 5% overlay (darker), light = black 5% overlay (darker than card) final unselectedColor = isDark ? Color.alphaBlend(Colors.white.withValues(alpha: 0.05), colorScheme.surface) - : colorScheme.surfaceContainerHigh; + : Color.alphaBlend(Colors.black.withValues(alpha: 0.05), colorScheme.surfaceContainerHighest); return Expanded( - child: Material( - color: isSelected ? colorScheme.primaryContainer : unselectedColor, - borderRadius: BorderRadius.circular(12), - child: InkWell( - onTap: onTap, + child: Container( + decoration: BoxDecoration( + color: isSelected ? colorScheme.primaryContainer : unselectedColor, borderRadius: BorderRadius.circular(12), - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 14), - child: Column(children: [ - Icon(icon, color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant), - const SizedBox(height: 6), - Text(label, style: TextStyle(fontSize: 12, - fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal, - color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant)), - ]), + border: !isDark && !isSelected + ? Border.all(color: colorScheme.outlineVariant.withValues(alpha: 0.5), width: 1) + : null, + ), + child: Material( + color: Colors.transparent, + borderRadius: BorderRadius.circular(12), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 14), + child: Column(children: [ + Icon(icon, color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant), + const SizedBox(height: 6), + Text(label, style: TextStyle(fontSize: 12, + fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal, + color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant)), + ]), + ), ), ), ), @@ -251,26 +262,36 @@ class _ViewModeChip extends StatelessWidget { final colorScheme = Theme.of(context).colorScheme; final isDark = Theme.of(context).brightness == Brightness.dark; + // Unselected chips need contrast with card background final unselectedColor = isDark ? Color.alphaBlend(Colors.white.withValues(alpha: 0.05), colorScheme.surface) - : colorScheme.surfaceContainerHigh; + : Color.alphaBlend(Colors.black.withValues(alpha: 0.05), colorScheme.surfaceContainerHighest); return Expanded( - child: Material( - color: isSelected ? colorScheme.primaryContainer : unselectedColor, - borderRadius: BorderRadius.circular(12), - child: InkWell( - onTap: onTap, + child: Container( + decoration: BoxDecoration( + color: isSelected ? colorScheme.primaryContainer : unselectedColor, borderRadius: BorderRadius.circular(12), - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 14), - child: Column(children: [ - Icon(icon, color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant), - const SizedBox(height: 6), - Text(label, style: TextStyle(fontSize: 12, - fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal, - color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant)), - ]), + border: !isDark && !isSelected + ? Border.all(color: colorScheme.outlineVariant.withValues(alpha: 0.5), width: 1) + : null, + ), + child: Material( + color: Colors.transparent, + borderRadius: BorderRadius.circular(12), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 14), + child: Column(children: [ + Icon(icon, color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant), + const SizedBox(height: 6), + Text(label, style: TextStyle(fontSize: 12, + fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal, + color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant)), + ]), + ), ), ), ), diff --git a/lib/services/notification_service.dart b/lib/services/notification_service.dart index 276313d..0463ba4 100644 --- a/lib/services/notification_service.dart +++ b/lib/services/notification_service.dart @@ -98,6 +98,49 @@ class NotificationService { ); } + Future showDownloadFinalizing({ + required String trackName, + required String artistName, + }) async { + if (!_isInitialized) await initialize(); + + final androidDetails = AndroidNotificationDetails( + channelId, + channelName, + channelDescription: channelDescription, + importance: Importance.low, + priority: Priority.low, + showProgress: true, + maxProgress: 100, + progress: 100, + indeterminate: false, + ongoing: true, + autoCancel: false, + playSound: false, + enableVibration: false, + onlyAlertOnce: true, + icon: '@mipmap/ic_launcher', + ); + + const iosDetails = DarwinNotificationDetails( + presentAlert: false, + presentBadge: false, + presentSound: false, + ); + + final details = NotificationDetails( + android: androidDetails, + iOS: iosDetails, + ); + + await _notifications.show( + downloadProgressId, + 'Finalizing $trackName', + '$artistName • Embedding metadata...', + details, + ); + } + Future showDownloadComplete({ required String trackName, required String artistName, diff --git a/lib/services/platform_bridge.dart b/lib/services/platform_bridge.dart index 7b1b26d..8597c3e 100644 --- a/lib/services/platform_bridge.dart +++ b/lib/services/platform_bridge.dart @@ -26,6 +26,16 @@ class PlatformBridge { return jsonDecode(result as String) as Map; } + /// Search Spotify for tracks and artists + static Future> searchSpotifyAll(String query, {int trackLimit = 15, int artistLimit = 3}) async { + final result = await _channel.invokeMethod('searchSpotifyAll', { + 'query': query, + 'track_limit': trackLimit, + 'artist_limit': artistLimit, + }); + return jsonDecode(result as String) as Map; + } + /// Check track availability on streaming services static Future> checkAvailability(String spotifyId, String isrc) async { final result = await _channel.invokeMethod('checkAvailability', { diff --git a/lib/widgets/settings_group.dart b/lib/widgets/settings_group.dart index 4782c1b..3170688 100644 --- a/lib/widgets/settings_group.dart +++ b/lib/widgets/settings_group.dart @@ -20,9 +20,10 @@ class SettingsGroup extends StatelessWidget { // Use a more contrasting color for cards // In dark mode with dynamic color, surfaceContainerHighest can be too similar to surface // So we add a slight white overlay to make it more visible + // In light mode with dynamic color, we add a slight black overlay for the same reason final cardColor = isDark ? Color.alphaBlend(Colors.white.withValues(alpha: 0.08), colorScheme.surface) - : colorScheme.surfaceContainerHighest; + : Color.alphaBlend(Colors.black.withValues(alpha: 0.04), colorScheme.surface); return Container( margin: margin ?? const EdgeInsets.symmetric(horizontal: 16, vertical: 4), diff --git a/lib/widgets/update_dialog.dart b/lib/widgets/update_dialog.dart index 01c9618..c59ef64 100644 --- a/lib/widgets/update_dialog.dart +++ b/lib/widgets/update_dialog.dart @@ -69,6 +69,9 @@ class _UpdateDialogState extends State { ); if (filePath != null) { + // Cancel progress notification first + await notificationService.cancelUpdateNotification(); + await notificationService.showUpdateDownloadComplete( version: widget.updateInfo.version, ); @@ -80,6 +83,9 @@ class _UpdateDialogState extends State { // Open APK for installation await ApkDownloader.installApk(filePath); } else { + // Cancel progress notification first + await notificationService.cancelUpdateNotification(); + await notificationService.showUpdateDownloadFailed(); if (mounted) { @@ -98,129 +104,202 @@ class _UpdateDialogState extends State { @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; + final isDark = Theme.of(context).brightness == Brightness.dark; - return AlertDialog( - title: Row( - children: [ - Icon(Icons.system_update, color: colorScheme.primary), - const SizedBox(width: 12), - const Text('Update Available'), - ], - ), - content: SizedBox( - width: double.maxFinite, + return Dialog( + backgroundColor: colorScheme.surfaceContainerHigh, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(28)), + child: Padding( + padding: const EdgeInsets.all(24), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Version info + // Header with icon + Row( + children: [ + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(16), + ), + child: Icon(Icons.system_update_rounded, color: colorScheme.onPrimaryContainer, size: 28), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Update Available', style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)), + const SizedBox(height: 2), + Text('A new version is ready', style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)), + ], + ), + ), + ], + ), + const SizedBox(height: 20), + + // Version badge Container( - padding: const EdgeInsets.all(12), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), decoration: BoxDecoration( - color: colorScheme.primaryContainer, - borderRadius: BorderRadius.circular(12), + color: isDark + ? Color.alphaBlend(Colors.white.withValues(alpha: 0.08), colorScheme.surface) + : Color.alphaBlend(Colors.black.withValues(alpha: 0.04), colorScheme.surface), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: colorScheme.outlineVariant.withValues(alpha: 0.5)), ), child: Row( + mainAxisAlignment: MainAxisAlignment.center, children: [ - Text( - 'v${AppInfo.version}', - style: TextStyle(color: colorScheme.onPrimaryContainer), - ), - const SizedBox(width: 8), - Icon(Icons.arrow_forward, size: 16, color: colorScheme.onPrimaryContainer), - const SizedBox(width: 8), - Text( - 'v${widget.updateInfo.version}', - style: TextStyle( - color: colorScheme.onPrimaryContainer, - fontWeight: FontWeight.bold, - ), - ), + _VersionChip(version: AppInfo.version, label: 'Current', colorScheme: colorScheme), + const SizedBox(width: 12), + Icon(Icons.arrow_forward_rounded, size: 20, color: colorScheme.primary), + const SizedBox(width: 12), + _VersionChip(version: widget.updateInfo.version, label: 'New', colorScheme: colorScheme, isNew: true), ], ), ), - const SizedBox(height: 16), + const SizedBox(height: 20), - // Changelog header - Text( - 'What\'s New:', - style: Theme.of(context).textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 8), - - // Changelog content (scrollable) - hide when downloading - if (!_isDownloading) - Flexible( - child: Container( - constraints: const BoxConstraints(maxHeight: 200), - decoration: BoxDecoration( - color: colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(8), - ), - child: SingleChildScrollView( - padding: const EdgeInsets.all(12), - child: Text( - _formatChangelog(widget.updateInfo.changelog), - style: Theme.of(context).textTheme.bodySmall, + // Download progress (when downloading) + if (_isDownloading) ...[ + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: isDark + ? Color.alphaBlend(Colors.white.withValues(alpha: 0.05), colorScheme.surface) + : Color.alphaBlend(Colors.black.withValues(alpha: 0.03), colorScheme.surface), + borderRadius: BorderRadius.circular(16), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + SizedBox( + width: 20, height: 20, + child: CircularProgressIndicator(strokeWidth: 2, color: colorScheme.primary), + ), + const SizedBox(width: 12), + Text('Downloading...', style: Theme.of(context).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600)), + ], ), + const SizedBox(height: 12), + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: LinearProgressIndicator( + value: _progress, + minHeight: 6, + backgroundColor: colorScheme.surfaceContainerHighest, + ), + ), + const SizedBox(height: 8), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(_statusText, style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant)), + Text('${(_progress * 100).toInt()}%', style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.primary, fontWeight: FontWeight.w600)), + ], + ), + ], + ), + ), + ] else ...[ + // Changelog section + Text("What's New", style: Theme.of(context).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold)), + const SizedBox(height: 8), + Container( + constraints: const BoxConstraints(maxHeight: 180), + decoration: BoxDecoration( + color: isDark + ? Color.alphaBlend(Colors.white.withValues(alpha: 0.05), colorScheme.surface) + : Color.alphaBlend(Colors.black.withValues(alpha: 0.03), colorScheme.surface), + borderRadius: BorderRadius.circular(16), + ), + child: SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Text( + _formatChangelog(widget.updateInfo.changelog), + style: Theme.of(context).textTheme.bodySmall?.copyWith(height: 1.5), ), ), ), - - // Download progress - if (_isDownloading) ...[ - const SizedBox(height: 8), - LinearProgressIndicator(value: _progress), - const SizedBox(height: 8), - Text( - _statusText, - style: Theme.of(context).textTheme.bodySmall, - ), ], + const SizedBox(height: 24), + + // Action buttons + if (_isDownloading) + SizedBox( + width: double.infinity, + child: OutlinedButton( + onPressed: () => Navigator.pop(context), + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + child: const Text('Cancel'), + ), + ) + else + Column( + children: [ + SizedBox( + width: double.infinity, + child: FilledButton.icon( + onPressed: _downloadAndInstall, + icon: const Icon(Icons.download_rounded, size: 20), + label: const Text('Download & Install'), + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + ), + ), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: TextButton( + onPressed: () { + widget.onDisableUpdates(); + Navigator.pop(context); + }, + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + child: Text("Don't remind", style: TextStyle(color: colorScheme.onSurfaceVariant)), + ), + ), + const SizedBox(width: 8), + Expanded( + child: OutlinedButton( + onPressed: () { + widget.onDismiss(); + Navigator.pop(context); + }, + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + child: const Text('Later'), + ), + ), + ], + ), + ], + ), ], ), ), - actions: _isDownloading - ? [ - TextButton( - onPressed: () => Navigator.pop(context), - child: const Text('Cancel'), - ), - ] - : [ - // Don't remind again button - TextButton( - onPressed: () { - widget.onDisableUpdates(); - Navigator.pop(context); - }, - child: Text( - 'Don\'t remind', - style: TextStyle(color: colorScheme.onSurfaceVariant), - ), - ), - // Later button - TextButton( - onPressed: () { - widget.onDismiss(); - Navigator.pop(context); - }, - child: const Text('Later'), - ), - // Download button - FilledButton( - onPressed: _downloadAndInstall, - child: const Text('Install'), - ), - ], ); } /// Format changelog - clean up markdown and extract relevant content String _formatChangelog(String changelog) { - // Try to extract just the changelog section (between "What's New" and "Downloads" or "---") var content = changelog; // Find content after "What's New" header @@ -238,19 +317,18 @@ class _UpdateDialogState extends State { // Process line by line for better formatting final lines = content.split('\n'); final formattedLines = []; - String? currentSection; for (var line in lines) { line = line.trim(); if (line.isEmpty) continue; - // Check if it's a section header (### Added, ### Fixed, etc.) + // Check if it's a section header final sectionMatch = RegExp(r'^#{1,3}\s*(.+)$').firstMatch(line); if (sectionMatch != null) { - currentSection = sectionMatch.group(1)?.trim(); - if (currentSection != null && currentSection.isNotEmpty) { + final section = sectionMatch.group(1)?.trim(); + if (section != null && section.isNotEmpty) { if (formattedLines.isNotEmpty) formattedLines.add(''); - formattedLines.add('$currentSection:'); + formattedLines.add(section); } continue; } @@ -259,36 +337,23 @@ class _UpdateDialogState extends State { final listMatch = RegExp(r'^[-*]\s+(.+)$').firstMatch(line); if (listMatch != null) { var itemText = listMatch.group(1) ?? ''; - // Remove bold markdown - itemText = itemText.replaceAllMapped( - RegExp(r'\*\*([^*]+)\*\*'), - (m) => m.group(1) ?? '' - ); - // Remove code markdown - itemText = itemText.replaceAllMapped( - RegExp(r'`([^`]+)`'), - (m) => m.group(1) ?? '' - ); + itemText = itemText.replaceAllMapped(RegExp(r'\*\*([^*]+)\*\*'), (m) => m.group(1) ?? ''); + itemText = itemText.replaceAllMapped(RegExp(r'`([^`]+)`'), (m) => m.group(1) ?? ''); formattedLines.add('• $itemText'); continue; } - // Check if it's a sub-item (indented list) + // Check if it's a sub-item final subListMatch = RegExp(r'^\s+[-*]\s+(.+)$').firstMatch(line); if (subListMatch != null) { var itemText = subListMatch.group(1) ?? ''; - itemText = itemText.replaceAllMapped( - RegExp(r'\*\*([^*]+)\*\*'), - (m) => m.group(1) ?? '' - ); + itemText = itemText.replaceAllMapped(RegExp(r'\*\*([^*]+)\*\*'), (m) => m.group(1) ?? ''); formattedLines.add(' - $itemText'); continue; } } var formatted = formattedLines.join('\n').trim(); - - // Limit length if (formatted.length > 2000) { formatted = '${formatted.substring(0, 2000)}...'; } @@ -297,6 +362,44 @@ class _UpdateDialogState extends State { } } +class _VersionChip extends StatelessWidget { + final String version; + final String label; + final ColorScheme colorScheme; + final bool isNew; + + const _VersionChip({ + required this.version, + required this.label, + required this.colorScheme, + this.isNew = false, + }); + + @override + Widget build(BuildContext context) { + return Column( + children: [ + Text(label, style: Theme.of(context).textTheme.labelSmall?.copyWith(color: colorScheme.onSurfaceVariant)), + const SizedBox(height: 4), + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: isNew ? colorScheme.primaryContainer : colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(20), + ), + child: Text( + 'v$version', + style: Theme.of(context).textTheme.labelLarge?.copyWith( + color: isNew ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant, + fontWeight: isNew ? FontWeight.bold : FontWeight.w500, + ), + ), + ), + ], + ); + } +} + /// Show update dialog Future showUpdateDialog( BuildContext context, { diff --git a/pubspec.yaml b/pubspec.yaml index a2437c2..8b7d624 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: spotiflac_android description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music publish_to: 'none' -version: 1.6.2+27 +version: 2.0.0-preview1+29 environment: sdk: ^3.10.0