diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8ad6345c..8fd429e8 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,64 @@
# Changelog
+## [1.5.0] - 2026-01-02
+
+### Added
+- **Download Progress Notification**: Shows notification with download progress percentage while downloading
+ - Progress bar in notification during download
+ - Completion notification when track finishes
+ - Summary notification when all downloads complete
+- **Notification Permission in Setup**: Android 13+ users will be prompted for notification permission during initial setup
+ - New step in setup wizard for notification permission
+ - Option to skip if user doesn't want notifications
+- **Per-Item Queue Controls**: Each track in download queue now has individual controls
+ - Cancel button for queued items
+ - Stop button for currently downloading items
+ - Retry and Remove buttons for failed/skipped items
+ - Visual progress bar with percentage for each downloading track
+- **Pull-to-Refresh on Home**: Swipe down to clear URL input and fetched tracks
+ - No need to exit app to clear current search/fetch
+- **Multi-Progress Tracking for Concurrent Downloads**: Each concurrent download now shows individual progress percentage
+ - Previously concurrent downloads jumped from 0% to 100%
+ - Now each track shows real-time progress when downloading in parallel
+
+### Changed
+- **Recent Downloads**: Now shows up to 10 items (was 5) for better scrolling
+- **Queue UI Redesign**: Card-based layout with clearer status indicators
+ - Removed global pause/resume in favor of per-item controls
+ - Better visual hierarchy with cover art, track info, and action buttons
+- **Settings UI**: Redesigned with category-based navigation (One UI style)
+ - Main settings tab with 4 categories: Appearance, Download, Options, About
+ - Each category opens a detail page
+ - Large title at top with menu items below
+ - One-handed friendly layout
+- **Collapsing Toolbar**: Implemented One UI style collapsing header for all tabs
+ - Title animates from 28px (expanded) to 20px (collapsed)
+ - Back button only on settings detail pages
+ - Consistent across Home, Downloads, and Settings tabs
+- **Home Search Bar Redesign**: More prominent and user-friendly input
+ - Larger card-style search bar with border outline
+ - Tap to open bottom sheet with full input experience
+ - Paste and Search buttons clearly visible
+ - Helper text showing supported URL types
+- **Empty State Improved**: Better onboarding for new users
+ - "Ready to Download" title with icon
+ - Clear instructions on how to use the app
+ - "Add Music" button for quick access
+
+### Technical
+- Added `flutter_local_notifications` package for notifications
+- Added notification permission request in setup screen for Android 13+
+- Enabled core library desugaring for all Android subprojects
+- Added multi-progress tracking in Go backend (`ItemProgress`, `ItemProgressWriter`)
+- Added `GetAllDownloadProgress`, `InitItemProgress`, `FinishItemProgress`, `ClearItemProgress` exports
+- Updated platform channel handlers for both Android (Kotlin) and iOS (Swift)
+
+### Performance
+- Optimized SliverAppBar: Removed LayoutBuilder that was called every frame during scroll
+- Optimized image caching: Added `memCacheWidth/Height` to CachedNetworkImage for memory efficiency
+- Optimized state management: Use `select()` to only rebuild when specific state changes
+- Smoother animations: Changed to `BouncingScrollPhysics` and `Curves.easeOutCubic`
+
## [1.2.0] - 2026-01-02
### Added
diff --git a/README.md b/README.md
index e0f5d38d..1ecb3348 100644
--- a/README.md
+++ b/README.md
@@ -13,13 +13,23 @@ Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music — no account
### [Download](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
+## Features
+
+- Download tracks, albums, and playlists from Spotify links
+- True lossless FLAC quality from Tidal, Qobuz & Amazon Music
+- Material Expressive 3 design with dynamic colors
+- High performance rendering with Impeller (Vulkan)
+- Concurrent downloads up to 3 simultaneous
+- Real-time download progress tracking
+- Download notifications
+
## Screenshots
-
-
-
-
+
+
+
+
## Other project
diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
index 93fc0cb7..edebe50e 100644
--- a/android/app/build.gradle.kts
+++ b/android/app/build.gradle.kts
@@ -72,7 +72,7 @@ repositories {
}
dependencies {
- coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.4")
+ coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")
implementation(files("libs/gobackend.aar"))
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0")
diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt
index f329285e..2b8063a1 100644
--- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt
+++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt
@@ -71,6 +71,33 @@ class MainActivity: FlutterActivity() {
}
result.success(response)
}
+ "getAllDownloadProgress" -> {
+ val response = withContext(Dispatchers.IO) {
+ Gobackend.getAllDownloadProgress()
+ }
+ result.success(response)
+ }
+ "initItemProgress" -> {
+ val itemId = call.argument("item_id") ?: ""
+ withContext(Dispatchers.IO) {
+ Gobackend.initItemProgress(itemId)
+ }
+ result.success(null)
+ }
+ "finishItemProgress" -> {
+ val itemId = call.argument("item_id") ?: ""
+ withContext(Dispatchers.IO) {
+ Gobackend.finishItemProgress(itemId)
+ }
+ result.success(null)
+ }
+ "clearItemProgress" -> {
+ val itemId = call.argument("item_id") ?: ""
+ withContext(Dispatchers.IO) {
+ Gobackend.clearItemProgress(itemId)
+ }
+ result.success(null)
+ }
"setDownloadDirectory" -> {
val path = call.argument("path") ?: ""
withContext(Dispatchers.IO) {
diff --git a/android/build.gradle.kts b/android/build.gradle.kts
index 4dda8a4c..3ad8a0f0 100644
--- a/android/build.gradle.kts
+++ b/android/build.gradle.kts
@@ -10,10 +10,19 @@ subprojects {
if (project.hasProperty("android")) {
project.extensions.configure("android") {
compileOptions {
+ isCoreLibraryDesugaringEnabled = true
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
+
+ // Enable multidex for all subprojects
+ defaultConfig {
+ multiDexEnabled = true
+ }
}
+
+ // Add desugaring dependency to all Android subprojects
+ project.dependencies.add("coreLibraryDesugaring", "com.android.tools:desugar_jdk_libs:2.1.4")
}
tasks.withType().configureEach {
diff --git a/assets/images/Screenshot_20260101-210622_SpotiFLAC.png b/assets/images/Screenshot_20260101-210622_SpotiFLAC.png
deleted file mode 100644
index ad6a13e2..00000000
Binary files a/assets/images/Screenshot_20260101-210622_SpotiFLAC.png and /dev/null differ
diff --git a/assets/images/Screenshot_20260101-210653_SpotiFLAC.png b/assets/images/Screenshot_20260101-210653_SpotiFLAC.png
deleted file mode 100644
index c5d0a0fe..00000000
Binary files a/assets/images/Screenshot_20260101-210653_SpotiFLAC.png and /dev/null differ
diff --git a/assets/images/photo_2026-01-01_23-44-06.jpg b/assets/images/photo_2026-01-01_23-44-06.jpg
deleted file mode 100644
index 71a582fc..00000000
Binary files a/assets/images/photo_2026-01-01_23-44-06.jpg and /dev/null differ
diff --git a/assets/images/photo_2026-01-01_23-56-11.jpg b/assets/images/photo_2026-01-01_23-56-11.jpg
deleted file mode 100644
index cdefe051..00000000
Binary files a/assets/images/photo_2026-01-01_23-56-11.jpg and /dev/null differ
diff --git a/assets/images/photo_2026-01-02_02-35-09.jpg b/assets/images/photo_2026-01-02_02-35-09.jpg
new file mode 100644
index 00000000..bb4001bd
Binary files /dev/null and b/assets/images/photo_2026-01-02_02-35-09.jpg differ
diff --git a/assets/images/photo_2026-01-02_02-35-34.jpg b/assets/images/photo_2026-01-02_02-35-34.jpg
new file mode 100644
index 00000000..b2816252
Binary files /dev/null and b/assets/images/photo_2026-01-02_02-35-34.jpg differ
diff --git a/assets/images/photo_2026-01-02_02-35-37.jpg b/assets/images/photo_2026-01-02_02-35-37.jpg
new file mode 100644
index 00000000..852e2777
Binary files /dev/null and b/assets/images/photo_2026-01-02_02-35-37.jpg differ
diff --git a/assets/images/photo_2026-01-02_02-36-23.jpg b/assets/images/photo_2026-01-02_02-36-23.jpg
new file mode 100644
index 00000000..59a75d94
Binary files /dev/null and b/assets/images/photo_2026-01-02_02-36-23.jpg differ
diff --git a/go_backend/amazon.go b/go_backend/amazon.go
index b2199bff..9148e2f9 100644
--- a/go_backend/amazon.go
+++ b/go_backend/amazon.go
@@ -202,12 +202,18 @@ func (a *AmazonDownloader) downloadFromDoubleDoubleService(amazonURL, outputDir
// DownloadFile downloads a file from URL with User-Agent and progress tracking
-func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath string) error {
- // Set current file being downloaded
+func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string) error {
+ // Set current file being downloaded (legacy)
SetCurrentFile(filepath.Base(outputPath))
SetDownloading(true)
defer SetDownloading(false)
+ // Initialize item progress if itemID provided
+ if itemID != "" {
+ StartItemProgress(itemID)
+ defer CompleteItemProgress(itemID)
+ }
+
req, err := http.NewRequest("GET", downloadURL, nil)
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
@@ -228,6 +234,9 @@ func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath string) error {
// Set total bytes if available
if resp.ContentLength > 0 {
SetBytesTotal(resp.ContentLength)
+ if itemID != "" {
+ SetItemBytesTotal(itemID, resp.ContentLength)
+ }
}
out, err := os.Create(outputPath)
@@ -236,14 +245,20 @@ func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath string) error {
}
defer out.Close()
- // Track download progress
- pw := NewProgressWriter(out)
- _, err = io.Copy(pw, resp.Body)
+ // Use appropriate progress writer
+ var bytesWritten int64
+ if itemID != "" {
+ pw := NewItemProgressWriter(out, itemID)
+ bytesWritten, err = io.Copy(pw, resp.Body)
+ } else {
+ pw := NewProgressWriter(out)
+ bytesWritten, err = io.Copy(pw, resp.Body)
+ }
if err != nil {
return fmt.Errorf("failed to write file: %w", err)
}
- fmt.Printf("\r[Amazon] Downloaded: %.2f MB (Complete)\n", float64(pw.GetTotal())/(1024*1024))
+ fmt.Printf("\r[Amazon] Downloaded: %.2f MB (Complete)\n", float64(bytesWritten)/(1024*1024))
return nil
}
@@ -298,8 +313,8 @@ func downloadFromAmazon(req DownloadRequest) (string, error) {
return "EXISTS:" + outputPath, nil
}
- // Download file
- if err := downloader.DownloadFile(downloadURL, outputPath); err != nil {
+ // Download file with item ID for progress tracking
+ if err := downloader.DownloadFile(downloadURL, outputPath, req.ItemID); err != nil {
return "", fmt.Errorf("download failed: %w", err)
}
diff --git a/go_backend/exports.go b/go_backend/exports.go
index d5c4ec1d..1e81b92f 100644
--- a/go_backend/exports.go
+++ b/go_backend/exports.go
@@ -106,6 +106,7 @@ type DownloadRequest struct {
DiscNumber int `json:"disc_number"`
TotalTracks int `json:"total_tracks"`
ReleaseDate string `json:"release_date"`
+ ItemID string `json:"item_id"` // Unique ID for progress tracking
}
// DownloadResponse represents the result of a download
@@ -255,6 +256,27 @@ func GetDownloadProgress() string {
return string(jsonBytes)
}
+// GetAllDownloadProgress returns progress for all active downloads (concurrent mode)
+func GetAllDownloadProgress() string {
+ return GetMultiProgress()
+}
+
+// InitItemProgress initializes progress tracking for a download item
+func InitItemProgress(itemID string) {
+ StartItemProgress(itemID)
+}
+
+// FinishItemProgress marks a download item as complete and removes tracking
+func FinishItemProgress(itemID string) {
+ CompleteItemProgress(itemID)
+ // Don't remove immediately - let Flutter poll one more time to see 100%
+}
+
+// ClearItemProgress removes progress tracking for a specific item
+func ClearItemProgress(itemID string) {
+ RemoveItemProgress(itemID)
+}
+
// CleanupConnections closes idle HTTP connections
// Call this periodically during large batch downloads to prevent TCP exhaustion
func CleanupConnections() {
diff --git a/go_backend/progress.go b/go_backend/progress.go
index 49088fbf..0cf546ce 100644
--- a/go_backend/progress.go
+++ b/go_backend/progress.go
@@ -1,10 +1,11 @@
package gobackend
import (
+ "encoding/json"
"sync"
)
-// DownloadProgress represents current download progress
+// DownloadProgress represents current download progress (legacy single download)
type DownloadProgress struct {
CurrentFile string `json:"current_file"`
Progress float64 `json:"progress"`
@@ -14,20 +15,128 @@ type DownloadProgress struct {
IsDownloading bool `json:"is_downloading"`
}
+// ItemProgress represents progress for a single download item
+type ItemProgress struct {
+ ItemID string `json:"item_id"`
+ BytesTotal int64 `json:"bytes_total"`
+ BytesReceived int64 `json:"bytes_received"`
+ Progress float64 `json:"progress"` // 0.0 to 1.0
+ IsDownloading bool `json:"is_downloading"`
+}
+
+// MultiProgress holds progress for multiple concurrent downloads
+type MultiProgress struct {
+ Items map[string]*ItemProgress `json:"items"`
+}
+
var (
currentProgress DownloadProgress
progressMu sync.RWMutex
downloadDir string
downloadDirMu sync.RWMutex
+
+ // Multi-download progress tracking
+ multiProgress = MultiProgress{Items: make(map[string]*ItemProgress)}
+ multiMu sync.RWMutex
)
-// getProgress returns current download progress
+// getProgress returns current download progress (legacy)
func getProgress() DownloadProgress {
progressMu.RLock()
defer progressMu.RUnlock()
return currentProgress
}
+// GetMultiProgress returns progress for all active downloads as JSON
+func GetMultiProgress() string {
+ multiMu.RLock()
+ defer multiMu.RUnlock()
+
+ jsonBytes, err := json.Marshal(multiProgress)
+ if err != nil {
+ return "{\"items\":{}}"
+ }
+ return string(jsonBytes)
+}
+
+// GetItemProgress returns progress for a specific item as JSON
+func GetItemProgress(itemID string) string {
+ multiMu.RLock()
+ defer multiMu.RUnlock()
+
+ if item, ok := multiProgress.Items[itemID]; ok {
+ jsonBytes, _ := json.Marshal(item)
+ return string(jsonBytes)
+ }
+ return "{}"
+}
+
+// StartItemProgress initializes progress tracking for an item
+func StartItemProgress(itemID string) {
+ multiMu.Lock()
+ defer multiMu.Unlock()
+
+ multiProgress.Items[itemID] = &ItemProgress{
+ ItemID: itemID,
+ BytesTotal: 0,
+ BytesReceived: 0,
+ Progress: 0,
+ IsDownloading: true,
+ }
+}
+
+// SetItemBytesTotal sets total bytes for an item
+func SetItemBytesTotal(itemID string, total int64) {
+ multiMu.Lock()
+ defer multiMu.Unlock()
+
+ if item, ok := multiProgress.Items[itemID]; ok {
+ item.BytesTotal = total
+ }
+}
+
+// SetItemBytesReceived sets bytes received for an item
+func SetItemBytesReceived(itemID string, received int64) {
+ multiMu.Lock()
+ defer multiMu.Unlock()
+
+ if item, ok := multiProgress.Items[itemID]; ok {
+ item.BytesReceived = received
+ if item.BytesTotal > 0 {
+ item.Progress = float64(received) / float64(item.BytesTotal)
+ }
+ }
+}
+
+// CompleteItemProgress marks an item as complete
+func CompleteItemProgress(itemID string) {
+ multiMu.Lock()
+ defer multiMu.Unlock()
+
+ if item, ok := multiProgress.Items[itemID]; ok {
+ item.Progress = 1.0
+ item.IsDownloading = false
+ }
+}
+
+// RemoveItemProgress removes progress tracking for an item
+func RemoveItemProgress(itemID string) {
+ multiMu.Lock()
+ defer multiMu.Unlock()
+
+ delete(multiProgress.Items, itemID)
+}
+
+// ClearAllItemProgress clears all item progress
+func ClearAllItemProgress() {
+ multiMu.Lock()
+ defer multiMu.Unlock()
+
+ multiProgress.Items = make(map[string]*ItemProgress)
+}
+
+// Legacy functions for backward compatibility
+
// SetDownloadProgress sets the current download progress (MB downloaded)
func SetDownloadProgress(mbDownloaded float64) {
progressMu.Lock()
@@ -47,7 +156,6 @@ func SetDownloadSpeed(speedMBps float64) {
func SetCurrentFile(filename string) {
progressMu.Lock()
defer progressMu.Unlock()
- // Reset progress for new file
currentProgress.BytesReceived = 0
currentProgress.BytesTotal = 0
currentProgress.Progress = 0
@@ -101,7 +209,7 @@ func SetBytesReceived(received int64) {
}
}
-// ProgressWriter wraps io.Writer to track download progress
+// ProgressWriter wraps io.Writer to track download progress (legacy single)
type ProgressWriter struct {
writer interface{ Write([]byte) (int, error) }
total int64
@@ -110,7 +218,6 @@ type ProgressWriter struct {
// NewProgressWriter creates a new progress writer wrapping an io.Writer
func NewProgressWriter(w interface{ Write([]byte) (int, error) }) *ProgressWriter {
- // Reset bytes received when starting new download
SetBytesReceived(0)
return &ProgressWriter{
writer: w,
@@ -135,3 +242,30 @@ func (pw *ProgressWriter) Write(p []byte) (int, error) {
func (pw *ProgressWriter) GetTotal() int64 {
return pw.total
}
+
+// ItemProgressWriter wraps io.Writer to track download progress for a specific item
+type ItemProgressWriter struct {
+ writer interface{ Write([]byte) (int, error) }
+ itemID string
+ current int64
+}
+
+// NewItemProgressWriter creates a new progress writer for a specific item
+func NewItemProgressWriter(w interface{ Write([]byte) (int, error) }, itemID string) *ItemProgressWriter {
+ return &ItemProgressWriter{
+ writer: w,
+ itemID: itemID,
+ current: 0,
+ }
+}
+
+// Write implements io.Writer
+func (pw *ItemProgressWriter) Write(p []byte) (int, error) {
+ n, err := pw.writer.Write(p)
+ if err != nil {
+ return n, err
+ }
+ pw.current += int64(n)
+ SetItemBytesReceived(pw.itemID, pw.current)
+ return n, nil
+}
diff --git a/go_backend/qobuz.go b/go_backend/qobuz.go
index 0034c448..b8b4ddb7 100644
--- a/go_backend/qobuz.go
+++ b/go_backend/qobuz.go
@@ -261,12 +261,18 @@ func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string) (string,
}
// DownloadFile downloads a file from URL with User-Agent and progress tracking
-func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath string) error {
- // Set current file being downloaded
+func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath, itemID string) error {
+ // Set current file being downloaded (legacy)
SetCurrentFile(filepath.Base(outputPath))
SetDownloading(true)
defer SetDownloading(false)
+ // Initialize item progress if itemID provided
+ if itemID != "" {
+ StartItemProgress(itemID)
+ defer CompleteItemProgress(itemID)
+ }
+
req, err := http.NewRequest("GET", downloadURL, nil)
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
@@ -285,6 +291,9 @@ func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath string) error {
// Set total bytes if available
if resp.ContentLength > 0 {
SetBytesTotal(resp.ContentLength)
+ if itemID != "" {
+ SetItemBytesTotal(itemID, resp.ContentLength)
+ }
}
out, err := os.Create(outputPath)
@@ -293,9 +302,14 @@ func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath string) error {
}
defer out.Close()
- // Use ProgressWriter for tracking
- progressWriter := NewProgressWriter(out)
- _, err = io.Copy(progressWriter, resp.Body)
+ // Use appropriate progress writer
+ if itemID != "" {
+ progressWriter := NewItemProgressWriter(out, itemID)
+ _, err = io.Copy(progressWriter, resp.Body)
+ } else {
+ progressWriter := NewProgressWriter(out)
+ _, err = io.Copy(progressWriter, resp.Body)
+ }
return err
}
@@ -366,8 +380,8 @@ func downloadFromQobuz(req DownloadRequest) (string, error) {
return "", fmt.Errorf("failed to get download URL: %w", err)
}
- // Download file
- if err := downloader.DownloadFile(downloadURL, outputPath); err != nil {
+ // Download file with item ID for progress tracking
+ if err := downloader.DownloadFile(downloadURL, outputPath, req.ItemID); err != nil {
return "", fmt.Errorf("download failed: %w", err)
}
diff --git a/go_backend/tidal.go b/go_backend/tidal.go
index 8d3df9f6..c6b216f5 100644
--- a/go_backend/tidal.go
+++ b/go_backend/tidal.go
@@ -640,17 +640,23 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
// DownloadFile downloads a file from URL with progress tracking
-func (t *TidalDownloader) DownloadFile(downloadURL, outputPath string) error {
+func (t *TidalDownloader) DownloadFile(downloadURL, outputPath, itemID string) error {
// Handle manifest-based download
if strings.HasPrefix(downloadURL, "MANIFEST:") {
- return t.downloadFromManifest(strings.TrimPrefix(downloadURL, "MANIFEST:"), outputPath)
+ return t.downloadFromManifest(strings.TrimPrefix(downloadURL, "MANIFEST:"), outputPath, itemID)
}
- // Set current file being downloaded
+ // Set current file being downloaded (legacy)
SetCurrentFile(filepath.Base(outputPath))
SetDownloading(true)
defer SetDownloading(false)
+ // Initialize item progress if itemID provided
+ if itemID != "" {
+ StartItemProgress(itemID)
+ defer CompleteItemProgress(itemID)
+ }
+
req, err := http.NewRequest("GET", downloadURL, nil)
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
@@ -669,6 +675,9 @@ func (t *TidalDownloader) DownloadFile(downloadURL, outputPath string) error {
// Set total bytes if available
if resp.ContentLength > 0 {
SetBytesTotal(resp.ContentLength)
+ if itemID != "" {
+ SetItemBytesTotal(itemID, resp.ContentLength)
+ }
}
out, err := os.Create(outputPath)
@@ -677,13 +686,18 @@ func (t *TidalDownloader) DownloadFile(downloadURL, outputPath string) error {
}
defer out.Close()
- // Use ProgressWriter for tracking
- progressWriter := NewProgressWriter(out)
- _, err = io.Copy(progressWriter, resp.Body)
+ // Use appropriate progress writer
+ if itemID != "" {
+ progressWriter := NewItemProgressWriter(out, itemID)
+ _, err = io.Copy(progressWriter, resp.Body)
+ } else {
+ progressWriter := NewProgressWriter(out)
+ _, err = io.Copy(progressWriter, resp.Body)
+ }
return err
}
-func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath string) error {
+func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath, itemID string) error {
directURL, initURL, mediaURLs, err := parseManifest(manifestB64)
if err != nil {
return fmt.Errorf("failed to parse manifest: %w", err)
@@ -695,11 +709,17 @@ func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath string) e
// If we have a direct URL (BTS format), download directly with progress tracking
if directURL != "" {
- // Set current file being downloaded
+ // Set current file being downloaded (legacy)
SetCurrentFile(filepath.Base(outputPath))
SetDownloading(true)
defer SetDownloading(false)
+ // Initialize item progress if itemID provided
+ if itemID != "" {
+ StartItemProgress(itemID)
+ defer CompleteItemProgress(itemID)
+ }
+
req, err := http.NewRequest("GET", directURL, nil)
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
@@ -718,6 +738,9 @@ func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath string) e
// Set total bytes for progress tracking
if resp.ContentLength > 0 {
SetBytesTotal(resp.ContentLength)
+ if itemID != "" {
+ SetItemBytesTotal(itemID, resp.ContentLength)
+ }
}
out, err := os.Create(outputPath)
@@ -726,9 +749,14 @@ func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath string) e
}
defer out.Close()
- // Use ProgressWriter for tracking
- progressWriter := NewProgressWriter(out)
- _, err = io.Copy(progressWriter, resp.Body)
+ // Use appropriate progress writer
+ if itemID != "" {
+ progressWriter := NewItemProgressWriter(out, itemID)
+ _, err = io.Copy(progressWriter, resp.Body)
+ } else {
+ progressWriter := NewProgressWriter(out)
+ _, err = io.Copy(progressWriter, resp.Body)
+ }
return err
}
@@ -872,8 +900,8 @@ func downloadFromTidal(req DownloadRequest) (string, error) {
return "", fmt.Errorf("failed to get download URL: %w", err)
}
- // Download file
- if err := downloader.DownloadFile(downloadURL, outputPath); err != nil {
+ // Download file with item ID for progress tracking
+ if err := downloader.DownloadFile(downloadURL, outputPath, req.ItemID); err != nil {
return "", fmt.Errorf("download failed: %w", err)
}
diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift
index fc40d262..330ea5c8 100644
--- a/ios/Runner/AppDelegate.swift
+++ b/ios/Runner/AppDelegate.swift
@@ -90,6 +90,28 @@ import Gobackend // Import Go framework
let response = GobackendGetDownloadProgress()
return response
+ case "getAllDownloadProgress":
+ let response = GobackendGetAllDownloadProgress()
+ return response
+
+ case "initItemProgress":
+ let args = call.arguments as! [String: Any]
+ let itemId = args["item_id"] as! String
+ GobackendInitItemProgress(itemId)
+ return nil
+
+ case "finishItemProgress":
+ let args = call.arguments as! [String: Any]
+ let itemId = args["item_id"] as! String
+ GobackendFinishItemProgress(itemId)
+ return nil
+
+ case "clearItemProgress":
+ let args = call.arguments as! [String: Any]
+ let itemId = args["item_id"] as! String
+ GobackendClearItemProgress(itemId)
+ return nil
+
case "setDownloadDirectory":
let args = call.arguments as! [String: Any]
let path = args["path"] as! String
@@ -145,6 +167,10 @@ import Gobackend // Import Go framework
if let error = error { throw error }
return response
+ case "cleanupConnections":
+ GobackendCleanupConnections()
+ return nil
+
default:
throw NSError(
domain: "SpotiFLAC",
diff --git a/lib/constants/app_info.dart b/lib/constants/app_info.dart
index 25f4723d..71423010 100644
--- a/lib/constants/app_info.dart
+++ b/lib/constants/app_info.dart
@@ -1,8 +1,8 @@
/// App version and info constants
/// Update version here only - all other files will reference this
class AppInfo {
- static const String version = '1.2.0';
- static const String buildNumber = '10';
+ static const String version = '1.5.0';
+ static const String buildNumber = '14';
static const String fullVersion = '$version+$buildNumber';
static const String appName = 'SpotiFLAC';
diff --git a/lib/main.dart b/lib/main.dart
index 12be91d9..4e8fe0d6 100644
--- a/lib/main.dart
+++ b/lib/main.dart
@@ -2,9 +2,14 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/app.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
+import 'package:spotiflac_android/services/notification_service.dart';
-void main() {
+void main() async {
WidgetsFlutterBinding.ensureInitialized();
+
+ // Initialize notification service
+ await NotificationService().initialize();
+
runApp(
ProviderScope(
child: const _EagerInitialization(
diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart
index 5cefccb2..4f8b3ac2 100644
--- a/lib/providers/download_queue_provider.dart
+++ b/lib/providers/download_queue_provider.dart
@@ -12,6 +12,7 @@ import 'package:spotiflac_android/models/track.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/services/ffmpeg_service.dart';
+import 'package:spotiflac_android/services/notification_service.dart';
// Download History Item model
class DownloadHistoryItem {
@@ -183,6 +184,7 @@ class DownloadQueueState {
final List items;
final DownloadItem? currentDownload;
final bool isProcessing;
+ final bool isPaused; // NEW: pause state
final String outputDir;
final String filenameFormat;
final String audioQuality; // LOSSLESS, HI_RES, HI_RES_LOSSLESS
@@ -193,6 +195,7 @@ class DownloadQueueState {
this.items = const [],
this.currentDownload,
this.isProcessing = false,
+ this.isPaused = false,
this.outputDir = '',
this.filenameFormat = '{artist} - {title}',
this.audioQuality = 'LOSSLESS',
@@ -204,6 +207,7 @@ class DownloadQueueState {
List? items,
DownloadItem? currentDownload,
bool? isProcessing,
+ bool? isPaused,
String? outputDir,
String? filenameFormat,
String? audioQuality,
@@ -214,6 +218,7 @@ class DownloadQueueState {
items: items ?? this.items,
currentDownload: currentDownload ?? this.currentDownload,
isProcessing: isProcessing ?? this.isProcessing,
+ isPaused: isPaused ?? this.isPaused,
outputDir: outputDir ?? this.outputDir,
filenameFormat: filenameFormat ?? this.filenameFormat,
audioQuality: audioQuality ?? this.audioQuality,
@@ -233,6 +238,8 @@ class DownloadQueueNotifier extends Notifier {
Timer? _progressTimer;
int _downloadCount = 0; // Counter for connection cleanup
static const _cleanupInterval = 50; // Cleanup every 50 downloads
+ final NotificationService _notificationService = NotificationService();
+ int _totalQueuedAtStart = 0; // Track total items when queue started
@override
DownloadQueueState build() {
@@ -256,6 +263,17 @@ class DownloadQueueNotifier extends Notifier {
final percentage = bytesReceived / bytesTotal;
updateProgress(itemId, percentage);
+ // Update notification with progress
+ final currentItem = state.currentDownload;
+ if (currentItem != null) {
+ _notificationService.showDownloadProgress(
+ trackName: currentItem.track.name,
+ artistName: currentItem.track.artistName,
+ progress: bytesReceived,
+ total: bytesTotal,
+ );
+ }
+
// Log progress
final mbReceived = bytesReceived / (1024 * 1024);
final mbTotal = bytesTotal / (1024 * 1024);
@@ -267,6 +285,56 @@ class DownloadQueueNotifier extends Notifier {
});
}
+ /// Start multi-progress polling for concurrent downloads
+ void _startMultiProgressPolling() {
+ _progressTimer?.cancel();
+ _progressTimer = Timer.periodic(const Duration(milliseconds: 500), (timer) async {
+ try {
+ final allProgress = await PlatformBridge.getAllDownloadProgress();
+ final items = allProgress['items'] as Map? ?? {};
+
+ for (final entry in items.entries) {
+ final itemId = entry.key;
+ final itemProgress = entry.value as Map;
+ final bytesReceived = itemProgress['bytes_received'] as int? ?? 0;
+ final bytesTotal = itemProgress['bytes_total'] as int? ?? 0;
+ final isDownloading = itemProgress['is_downloading'] as bool? ?? false;
+
+ if (isDownloading && bytesTotal > 0) {
+ final percentage = bytesReceived / bytesTotal;
+ updateProgress(itemId, percentage);
+
+ // Log progress for each item
+ final mbReceived = bytesReceived / (1024 * 1024);
+ final mbTotal = bytesTotal / (1024 * 1024);
+ print('[DownloadQueue] Progress [$itemId]: ${(percentage * 100).toStringAsFixed(1)}% (${mbReceived.toStringAsFixed(2)}/${mbTotal.toStringAsFixed(2)} MB)');
+ }
+ }
+
+ // Update notification with first active download
+ if (items.isNotEmpty) {
+ final firstEntry = items.entries.first;
+ final firstProgress = firstEntry.value as Map;
+ final bytesReceived = firstProgress['bytes_received'] as int? ?? 0;
+ 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();
+ if (downloadingItems.isNotEmpty) {
+ _notificationService.showDownloadProgress(
+ trackName: '${downloadingItems.length} downloads',
+ artistName: 'Downloading...',
+ progress: bytesReceived,
+ total: bytesTotal > 0 ? bytesTotal : 1,
+ );
+ }
+ }
+ } catch (e) {
+ // Ignore polling errors
+ }
+ });
+ }
+
void _stopProgressPolling() {
_progressTimer?.cancel();
_progressTimer = null;
@@ -409,7 +477,59 @@ class DownloadQueueNotifier extends Notifier {
}
void clearAll() {
- state = const DownloadQueueState();
+ state = state.copyWith(items: [], isPaused: false);
+ }
+
+ /// Pause the download queue
+ void pauseQueue() {
+ if (state.isProcessing && !state.isPaused) {
+ state = state.copyWith(isPaused: true);
+ _notificationService.cancelDownloadNotification();
+ print('[DownloadQueue] Queue paused');
+ }
+ }
+
+ /// Resume the download queue
+ void resumeQueue() {
+ if (state.isPaused) {
+ state = state.copyWith(isPaused: false);
+ print('[DownloadQueue] Queue resumed');
+ // If there are still queued items, continue processing
+ if (state.queuedCount > 0 && !state.isProcessing) {
+ Future.microtask(() => _processQueue());
+ }
+ }
+ }
+
+ /// Toggle pause/resume
+ void togglePause() {
+ if (state.isPaused) {
+ resumeQueue();
+ } else {
+ pauseQueue();
+ }
+ }
+
+ /// Retry a failed download
+ void retryItem(String id) {
+ final items = state.items.map((item) {
+ if (item.id == id && item.status == DownloadStatus.failed) {
+ return item.copyWith(status: DownloadStatus.queued, progress: 0, error: null);
+ }
+ return item;
+ }).toList();
+ state = state.copyWith(items: items);
+
+ // Start processing if not already
+ if (!state.isProcessing) {
+ Future.microtask(() => _processQueue());
+ }
+ }
+
+ /// Remove a specific item from queue
+ void removeItem(String id) {
+ final items = state.items.where((item) => item.id != id).toList();
+ state = state.copyWith(items: items);
}
/// Embed metadata and cover to a FLAC file after M4A conversion
@@ -482,6 +602,9 @@ class DownloadQueueNotifier extends Notifier {
state = state.copyWith(isProcessing: true);
print('[DownloadQueue] Starting queue processing...');
+ // Track total items at start for notification
+ _totalQueuedAtStart = state.items.where((i) => i.status == DownloadStatus.queued).length;
+
// Ensure output directory is initialized before processing
if (state.outputDir.isEmpty) {
print('[DownloadQueue] Output dir empty, initializing...');
@@ -522,6 +645,16 @@ class DownloadQueueNotifier extends Notifier {
_downloadCount = 0;
}
+ // Show queue completion notification
+ final completedCount = state.completedCount;
+ final failedCount = state.failedCount;
+ if (_totalQueuedAtStart > 0) {
+ await _notificationService.showQueueComplete(
+ completedCount: completedCount,
+ failedCount: failedCount,
+ );
+ }
+
print('[DownloadQueue] Queue processing finished');
state = state.copyWith(isProcessing: false, currentDownload: null);
}
@@ -529,6 +662,13 @@ class DownloadQueueNotifier extends Notifier {
/// Sequential download processing (original behavior)
Future _processQueueSequential() async {
while (true) {
+ // Check if paused
+ if (state.isPaused) {
+ print('[DownloadQueue] Queue is paused, waiting...');
+ await Future.delayed(const Duration(milliseconds: 500));
+ continue;
+ }
+
final nextItem = state.items.firstWhere(
(item) => item.status == DownloadStatus.queued,
orElse: () => DownloadItem(
@@ -553,7 +693,21 @@ class DownloadQueueNotifier extends Notifier {
final maxConcurrent = state.concurrentDownloads;
final activeDownloads = >{}; // Map item ID to future
+ // Start multi-progress polling for concurrent downloads
+ _startMultiProgressPolling();
+
while (true) {
+ // Check if paused - don't start new downloads but let active ones finish
+ if (state.isPaused) {
+ print('[DownloadQueue] Queue is paused, waiting for active downloads...');
+ if (activeDownloads.isNotEmpty) {
+ await Future.any(activeDownloads.values);
+ } else {
+ await Future.delayed(const Duration(milliseconds: 500));
+ }
+ continue;
+ }
+
// Get queued items
final queuedItems = state.items.where((item) => item.status == DownloadStatus.queued).toList();
@@ -563,7 +717,7 @@ class DownloadQueueNotifier extends Notifier {
}
// Start new downloads up to max concurrent limit
- while (activeDownloads.length < maxConcurrent && queuedItems.isNotEmpty) {
+ while (activeDownloads.length < maxConcurrent && queuedItems.isNotEmpty && !state.isPaused) {
final item = queuedItems.removeAt(0);
// Mark as downloading immediately to prevent double-processing
@@ -572,6 +726,8 @@ class DownloadQueueNotifier extends Notifier {
// Create the download future
final future = _downloadSingleItem(item).whenComplete(() {
activeDownloads.remove(item.id);
+ // Clear item progress after download completes
+ PlatformBridge.clearItemProgress(item.id).catchError((_) {});
});
activeDownloads[item.id] = future;
@@ -624,6 +780,7 @@ class DownloadQueueNotifier extends Notifier {
discNumber: item.track.discNumber ?? 1,
releaseDate: item.track.releaseDate,
preferredService: item.service,
+ itemId: item.id, // Pass item ID for progress tracking
);
} else {
result = await PlatformBridge.downloadTrack(
@@ -641,6 +798,7 @@ class DownloadQueueNotifier extends Notifier {
trackNumber: item.track.trackNumber ?? 1,
discNumber: item.track.discNumber ?? 1,
releaseDate: item.track.releaseDate,
+ itemId: item.id, // Pass item ID for progress tracking
);
}
@@ -685,6 +843,14 @@ class DownloadQueueNotifier extends Notifier {
filePath: filePath,
);
+ // Show completion notification for this track
+ await _notificationService.showDownloadComplete(
+ trackName: item.track.name,
+ artistName: item.track.artistName,
+ completedCount: state.completedCount,
+ totalCount: _totalQueuedAtStart,
+ );
+
if (filePath != null) {
ref.read(downloadHistoryProvider.notifier).addToHistory(
DownloadHistoryItem(
diff --git a/lib/screens/home_tab.dart b/lib/screens/home_tab.dart
index 0d4255c0..1ca44057 100644
--- a/lib/screens/home_tab.dart
+++ b/lib/screens/home_tab.dart
@@ -11,34 +11,49 @@ import 'package:spotiflac_android/screens/track_metadata_screen.dart';
class HomeTab extends ConsumerStatefulWidget {
const HomeTab({super.key});
-
@override
ConsumerState createState() => _HomeTabState();
}
class _HomeTabState extends ConsumerState with AutomaticKeepAliveClientMixin {
final _urlController = TextEditingController();
-
+ final Map _fileExistsCache = {}; // Cache file existence
+
@override
bool get wantKeepAlive => true;
-
@override
- void dispose() {
- _urlController.dispose();
- super.dispose();
+ void dispose() { _urlController.dispose(); super.dispose(); }
+
+ /// Check if file exists with caching to avoid blocking main thread
+ bool _checkFileExists(String filePath) {
+ if (_fileExistsCache.containsKey(filePath)) {
+ return _fileExistsCache[filePath]!;
+ }
+ // Schedule async check and return false for now
+ Future.microtask(() async {
+ final exists = await File(filePath).exists();
+ if (mounted && _fileExistsCache[filePath] != exists) {
+ setState(() => _fileExistsCache[filePath] = exists);
+ }
+ });
+ _fileExistsCache[filePath] = false; // Assume false until checked
+ return false;
}
Future _pasteFromClipboard() async {
final data = await Clipboard.getData(Clipboard.kTextPlain);
- if (data?.text != null) {
- _urlController.text = data!.text!;
- }
+ if (data?.text != null) _urlController.text = data!.text!;
+ }
+
+ Future _clearAndRefresh() async {
+ _urlController.clear();
+ ref.read(trackProvider.notifier).clear();
+ await Future.delayed(const Duration(milliseconds: 300));
}
Future _fetchMetadata() async {
final url = _urlController.text.trim();
if (url.isEmpty) return;
-
if (url.startsWith('http') || url.startsWith('spotify:')) {
await ref.read(trackProvider.notifier).fetchFromUrl(url);
} else {
@@ -52,35 +67,21 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient
final track = trackState.tracks[index];
final settings = ref.read(settingsProvider);
ref.read(downloadQueueProvider.notifier).addToQueue(track, settings.defaultService);
- ScaffoldMessenger.of(context).showSnackBar(
- SnackBar(content: Text('Added "${track.name}" to queue')),
- );
+ ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue')));
}
}
void _downloadAll() {
final trackState = ref.read(trackProvider);
if (trackState.tracks.isEmpty) return;
-
final settings = ref.read(settingsProvider);
- ref.read(downloadQueueProvider.notifier).addMultipleToQueue(
- trackState.tracks,
- settings.defaultService,
- );
- ScaffoldMessenger.of(context).showSnackBar(
- SnackBar(content: Text('Added ${trackState.tracks.length} tracks to queue')),
- );
+ ref.read(downloadQueueProvider.notifier).addMultipleToQueue(trackState.tracks, settings.defaultService);
+ ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added ${trackState.tracks.length} tracks to queue')));
}
Future _openFile(String filePath) async {
- try {
- await OpenFilex.open(filePath);
- } catch (e) {
- if (mounted) {
- ScaffoldMessenger.of(context).showSnackBar(
- SnackBar(content: Text('Cannot open file: $e')),
- );
- }
+ try { await OpenFilex.open(filePath); } catch (e) {
+ if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Cannot open file: $e')));
}
}
@@ -91,138 +92,173 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient
final historyState = ref.watch(downloadHistoryProvider);
final colorScheme = Theme.of(context).colorScheme;
- return CustomScrollView(
- slivers: [
- // Search bar
+ return RefreshIndicator(
+ onRefresh: _clearAndRefresh,
+ displacement: 100,
+ child: CustomScrollView(
+ physics: const AlwaysScrollableScrollPhysics(),
+ slivers: [
+ // Collapsing App Bar - Simplified for performance
+ SliverAppBar(
+ expandedHeight: 100,
+ collapsedHeight: kToolbarHeight,
+ floating: false,
+ pinned: true,
+ backgroundColor: colorScheme.surface,
+ surfaceTintColor: Colors.transparent,
+ automaticallyImplyLeading: false,
+ flexibleSpace: FlexibleSpaceBar(
+ expandedTitleScale: 1.4,
+ titlePadding: const EdgeInsets.only(left: 24, bottom: 16),
+ title: Text(
+ 'Home',
+ style: TextStyle(
+ fontSize: 20,
+ fontWeight: FontWeight.bold,
+ color: colorScheme.onSurface,
+ ),
+ ),
+ ),
+ ),
+
+ // Search bar - Simple TextField with border
SliverToBoxAdapter(
child: Padding(
- padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
+ padding: const EdgeInsets.fromLTRB(16, 8, 16, 4),
child: TextField(
controller: _urlController,
decoration: InputDecoration(
hintText: 'Paste Spotify URL or search...',
+ filled: true,
+ fillColor: colorScheme.surfaceContainerHighest,
+ border: OutlineInputBorder(
+ borderRadius: BorderRadius.circular(28),
+ borderSide: BorderSide(color: colorScheme.outline.withOpacity(0.5)),
+ ),
+ enabledBorder: OutlineInputBorder(
+ borderRadius: BorderRadius.circular(28),
+ borderSide: BorderSide(color: colorScheme.outline.withOpacity(0.5)),
+ ),
+ focusedBorder: OutlineInputBorder(
+ borderRadius: BorderRadius.circular(28),
+ borderSide: BorderSide(color: colorScheme.primary, width: 2),
+ ),
prefixIcon: const Icon(Icons.link),
suffixIcon: Row(
mainAxisSize: MainAxisSize.min,
children: [
- IconButton(icon: const Icon(Icons.paste), onPressed: _pasteFromClipboard),
- IconButton(icon: const Icon(Icons.search), onPressed: _fetchMetadata),
+ IconButton(
+ icon: const Icon(Icons.paste),
+ onPressed: _pasteFromClipboard,
+ tooltip: 'Paste',
+ ),
+ Padding(
+ padding: const EdgeInsets.only(right: 8),
+ child: IconButton(
+ icon: Container(
+ padding: const EdgeInsets.all(8),
+ decoration: BoxDecoration(
+ color: colorScheme.primary,
+ borderRadius: BorderRadius.circular(12),
+ ),
+ child: Icon(Icons.search, color: colorScheme.onPrimary, size: 20),
+ ),
+ onPressed: _fetchMetadata,
+ tooltip: 'Search',
+ ),
+ ),
],
),
+ contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
),
onSubmitted: (_) => _fetchMetadata(),
),
),
),
+
+ // Helper text
+ SliverToBoxAdapter(
+ child: Padding(
+ padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
+ child: Text(
+ 'Supports: Track, Album, Playlist URLs',
+ style: Theme.of(context).textTheme.bodySmall?.copyWith(
+ color: colorScheme.onSurfaceVariant,
+ ),
+ textAlign: TextAlign.center,
+ ),
+ ),
+ ),
// Error message
if (trackState.error != null)
- SliverToBoxAdapter(
- child: Padding(
- padding: const EdgeInsets.symmetric(horizontal: 16.0),
- child: Text(
- trackState.error!,
- style: TextStyle(color: colorScheme.error),
- ),
- ),
- ),
+ SliverToBoxAdapter(child: Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 16),
+ child: Text(trackState.error!, style: TextStyle(color: colorScheme.error)),
+ )),
// Loading indicator
if (trackState.isLoading)
- const SliverToBoxAdapter(
- child: LinearProgressIndicator(),
- ),
+ const SliverToBoxAdapter(child: Padding(padding: EdgeInsets.symmetric(horizontal: 16), child: LinearProgressIndicator())),
// Album/Playlist header
if (trackState.albumName != null || trackState.playlistName != null)
SliverToBoxAdapter(child: _buildHeader(trackState, colorScheme)),
- // Download All button (when no header)
+ // Download All button
if (trackState.tracks.length > 1 && trackState.albumName == null && trackState.playlistName == null)
- SliverToBoxAdapter(
- child: Padding(
- padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
- child: FilledButton.icon(
- onPressed: _downloadAll,
- icon: const Icon(Icons.download),
- label: Text('Download All (${trackState.tracks.length})'),
- style: FilledButton.styleFrom(
- minimumSize: const Size.fromHeight(48),
- ),
- ),
- ),
- ),
+ 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 (${trackState.tracks.length})'),
+ style: FilledButton.styleFrom(minimumSize: const Size.fromHeight(48))),
+ )),
// Track list
- if (trackState.tracks.isNotEmpty)
- SliverList(
- delegate: SliverChildBuilderDelegate(
- (context, index) => _buildTrackTile(index, colorScheme),
- childCount: trackState.tracks.length,
- ),
- ),
+ SliverList(delegate: SliverChildBuilderDelegate(
+ (context, index) => _buildTrackTile(index, colorScheme),
+ childCount: trackState.tracks.length,
+ )),
- // Divider between search results and history
+ // Divider
if (trackState.tracks.isNotEmpty && historyState.items.isNotEmpty)
- const SliverToBoxAdapter(
- child: Divider(height: 32),
- ),
+ const SliverToBoxAdapter(child: Divider(height: 32)),
- // Recent Downloads section header
+ // Recent Downloads header
if (historyState.items.isNotEmpty)
- SliverToBoxAdapter(
- child: Padding(
- padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
- child: Row(
- mainAxisAlignment: MainAxisAlignment.spaceBetween,
- children: [
- Text(
- 'Recent Downloads',
- style: Theme.of(context).textTheme.titleMedium?.copyWith(
- fontWeight: FontWeight.bold,
- ),
- ),
- TextButton(
- onPressed: () => _showClearHistoryDialog(colorScheme),
- child: const Text('Clear'),
- ),
- ],
- ),
+ SliverToBoxAdapter(child: Padding(
+ padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
+ child: Row(
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
+ children: [
+ Text('Recent Downloads', style: Theme.of(context).textTheme.titleSmall?.copyWith(
+ color: colorScheme.primary, fontWeight: FontWeight.w600)),
+ TextButton(onPressed: () => _showClearHistoryDialog(colorScheme), child: const Text('Clear')),
+ ],
),
- ),
+ )),
// Recent Downloads list
- if (historyState.items.isNotEmpty)
- SliverList(
- delegate: SliverChildBuilderDelegate(
- (context, index) => _buildHistoryTile(historyState.items[index], colorScheme),
- childCount: historyState.items.length > 5 ? 5 : historyState.items.length,
- ),
- ),
+ SliverList(delegate: SliverChildBuilderDelegate(
+ (context, index) => _buildHistoryTile(historyState.items[index], colorScheme),
+ childCount: historyState.items.length > 10 ? 10 : historyState.items.length,
+ )),
- // Show more history button
- if (historyState.items.length > 5)
- SliverToBoxAdapter(
- child: Padding(
- padding: const EdgeInsets.all(16),
- child: OutlinedButton(
- onPressed: () => _showAllHistory(colorScheme),
- child: Text('Show all ${historyState.items.length} downloads'),
- ),
- ),
- ),
+ // Show more button
+ if (historyState.items.length > 10)
+ SliverToBoxAdapter(child: Padding(
+ padding: const EdgeInsets.all(16),
+ child: OutlinedButton(onPressed: () => _showAllHistory(colorScheme),
+ child: Text('Show all ${historyState.items.length} downloads')),
+ )),
- // Empty state (when no tracks and no history)
+ // Empty state or fill remaining for scroll
if (trackState.tracks.isEmpty && historyState.items.isEmpty)
- SliverFillRemaining(
- child: _buildEmptyState(colorScheme),
- ),
-
- // Bottom padding
- const SliverToBoxAdapter(
- child: SizedBox(height: 16),
- ),
+ SliverFillRemaining(hasScrollBody: false, child: _buildEmptyState(colorScheme))
+ else
+ const SliverFillRemaining(hasScrollBody: false, child: SizedBox()),
],
+ ),
);
}
@@ -234,52 +270,21 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient
child: Row(
children: [
if (state.coverUrl != null)
- ClipRRect(
- borderRadius: BorderRadius.circular(8),
- child: CachedNetworkImage(
- imageUrl: state.coverUrl!,
- width: 80,
- height: 80,
- fit: BoxFit.cover,
- placeholder: (_, __) => Container(
- width: 80,
- height: 80,
- color: colorScheme.surfaceContainerHighest,
- ),
- ),
- ),
+ ClipRRect(borderRadius: BorderRadius.circular(12),
+ child: CachedNetworkImage(imageUrl: state.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(
- state.albumName ?? state.playlistName ?? '',
- style: Theme.of(context).textTheme.titleMedium?.copyWith(
- fontWeight: FontWeight.bold,
- ),
- maxLines: 2,
- overflow: TextOverflow.ellipsis,
- ),
- const SizedBox(height: 4),
- Text(
- '${state.tracks.length} tracks',
- style: Theme.of(context).textTheme.bodyMedium?.copyWith(
- color: colorScheme.onSurfaceVariant,
- ),
- ),
- ],
- ),
- ),
- // Download all button
- FilledButton.tonal(
- onPressed: _downloadAll,
- style: FilledButton.styleFrom(
- shape: const CircleBorder(),
- padding: const EdgeInsets.all(16),
- ),
- child: const Icon(Icons.download),
- ),
+ Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
+ Text(state.albumName ?? state.playlistName ?? '',
+ style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
+ maxLines: 2, overflow: TextOverflow.ellipsis),
+ const SizedBox(height: 4),
+ Text('${state.tracks.length} 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)),
],
),
),
@@ -290,231 +295,149 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient
final track = ref.watch(trackProvider).tracks[index];
return ListTile(
leading: track.coverUrl != null
- ? ClipRRect(
- borderRadius: BorderRadius.circular(8),
+ ? ClipRRect(borderRadius: BorderRadius.circular(8),
child: CachedNetworkImage(
- imageUrl: track.coverUrl!,
- width: 48,
- height: 48,
+ imageUrl: track.coverUrl!,
+ width: 48,
+ height: 48,
fit: BoxFit.cover,
- ),
- )
- : Container(
- width: 48,
- height: 48,
- decoration: BoxDecoration(
- color: colorScheme.surfaceContainerHighest,
- borderRadius: BorderRadius.circular(8),
- ),
- child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant),
- ),
+ 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),
- ),
+ 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),
);
}
Widget _buildHistoryTile(DownloadHistoryItem item, ColorScheme colorScheme) {
- final fileExists = File(item.filePath).existsSync();
-
+ final fileExists = _checkFileExists(item.filePath);
return ListTile(
- leading: Hero(
- tag: 'cover_${item.id}',
+ leading: Hero(tag: 'cover_${item.id}',
child: item.coverUrl != null
- ? ClipRRect(
- borderRadius: BorderRadius.circular(8),
+ ? ClipRRect(borderRadius: BorderRadius.circular(8),
child: CachedNetworkImage(
- imageUrl: item.coverUrl!,
- width: 48,
- height: 48,
+ imageUrl: item.coverUrl!,
+ width: 48,
+ height: 48,
fit: BoxFit.cover,
- ),
- )
- : Container(
- width: 48,
- height: 48,
- decoration: BoxDecoration(
- color: colorScheme.surfaceContainerHighest,
- borderRadius: BorderRadius.circular(8),
- ),
- child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant),
- ),
- ),
+ 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(item.trackName, maxLines: 1, overflow: TextOverflow.ellipsis),
- subtitle: Text(
- item.artistName,
- maxLines: 1,
- overflow: TextOverflow.ellipsis,
- style: TextStyle(color: colorScheme.onSurfaceVariant),
- ),
+ subtitle: Text(item.artistName, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(color: colorScheme.onSurfaceVariant)),
trailing: fileExists
- ? IconButton(
- icon: Icon(Icons.play_arrow, color: colorScheme.primary),
- onPressed: () => _openFile(item.filePath),
- )
+ ? IconButton(icon: Icon(Icons.play_arrow, color: colorScheme.primary), onPressed: () => _openFile(item.filePath))
: Icon(Icons.error_outline, color: colorScheme.error, size: 20),
- // Tap to show metadata details
onTap: () => _navigateToMetadataScreen(item),
);
}
- void _navigateToMetadataScreen(DownloadHistoryItem item) {
- Navigator.push(
- context,
- PageRouteBuilder(
- transitionDuration: const Duration(milliseconds: 300),
- reverseTransitionDuration: const Duration(milliseconds: 250),
- pageBuilder: (context, animation, secondaryAnimation) =>
- TrackMetadataScreen(item: item),
- transitionsBuilder: (context, animation, secondaryAnimation, child) {
- return FadeTransition(
- opacity: animation,
- child: child,
- );
- },
- ),
- );
- }
-
- Widget _buildEmptyState(ColorScheme colorScheme) {
- return Center(
- child: Column(
- mainAxisAlignment: MainAxisAlignment.center,
- children: [
- Icon(
- Icons.music_note,
- size: 64,
+ Widget _buildEmptyState(ColorScheme colorScheme) => Center(child: Padding(
+ padding: const EdgeInsets.all(32),
+ child: Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ Container(
+ padding: const EdgeInsets.all(24),
+ decoration: BoxDecoration(
+ color: colorScheme.primaryContainer.withOpacity(0.3),
+ shape: BoxShape.circle,
+ ),
+ child: Icon(Icons.music_note, size: 48, color: colorScheme.primary),
+ ),
+ const SizedBox(height: 24),
+ Text(
+ 'Ready to Download',
+ style: Theme.of(context).textTheme.titleLarge?.copyWith(
+ fontWeight: FontWeight.bold,
+ ),
+ ),
+ const SizedBox(height: 8),
+ Text(
+ 'Paste a Spotify link in the search bar above to get started',
+ textAlign: TextAlign.center,
+ style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
- const SizedBox(height: 16),
- Text(
- 'Paste a Spotify URL to get started',
- style: Theme.of(context).textTheme.bodyLarge?.copyWith(
- color: colorScheme.onSurfaceVariant,
- ),
- ),
- ],
- ),
- );
+ ),
+ ],
+ ),
+ ));
+
+ void _navigateToMetadataScreen(DownloadHistoryItem item) {
+ Navigator.push(context, PageRouteBuilder(
+ transitionDuration: const Duration(milliseconds: 300),
+ reverseTransitionDuration: const Duration(milliseconds: 250),
+ pageBuilder: (context, animation, secondaryAnimation) => TrackMetadataScreen(item: item),
+ transitionsBuilder: (context, animation, secondaryAnimation, child) => FadeTransition(opacity: animation, child: child),
+ ));
}
void _showClearHistoryDialog(ColorScheme colorScheme) {
- showDialog(
- context: context,
- builder: (context) => AlertDialog(
- title: const Text('Clear History'),
- content: const Text('Clear all download history?'),
- actions: [
- TextButton(
- onPressed: () => Navigator.pop(context),
- child: const Text('Cancel'),
- ),
- TextButton(
- onPressed: () {
- ref.read(downloadHistoryProvider.notifier).clearHistory();
- Navigator.pop(context);
- },
- child: Text('Clear', style: TextStyle(color: colorScheme.error)),
- ),
- ],
- ),
- );
+ showDialog(context: context, builder: (context) => AlertDialog(
+ title: const Text('Clear History'),
+ content: const Text('Clear all download history?'),
+ actions: [
+ TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')),
+ TextButton(onPressed: () { ref.read(downloadHistoryProvider.notifier).clearHistory(); Navigator.pop(context); },
+ child: Text('Clear', style: TextStyle(color: colorScheme.error))),
+ ],
+ ));
}
void _showAllHistory(ColorScheme colorScheme) {
final historyState = ref.read(downloadHistoryProvider);
-
- showModalBottomSheet(
- context: context,
- isScrollControlled: true,
+ showModalBottomSheet(context: context, isScrollControlled: true,
builder: (context) => DraggableScrollableSheet(
- initialChildSize: 0.7,
- minChildSize: 0.5,
- maxChildSize: 0.95,
- expand: false,
- builder: (context, scrollController) => Column(
- children: [
- Padding(
- padding: const EdgeInsets.all(16),
- child: Row(
- mainAxisAlignment: MainAxisAlignment.spaceBetween,
- children: [
- Text(
- 'All Downloads (${historyState.items.length})',
- style: Theme.of(context).textTheme.titleMedium?.copyWith(
- fontWeight: FontWeight.bold,
- ),
- ),
- IconButton(
- icon: const Icon(Icons.close),
- onPressed: () => Navigator.pop(context),
- ),
- ],
- ),
- ),
- const Divider(height: 1),
- Expanded(
- child: ListView.builder(
- controller: scrollController,
- itemCount: historyState.items.length,
- itemBuilder: (context, index) {
- final item = historyState.items[index];
- final fileExists = File(item.filePath).existsSync();
-
- return ListTile(
- leading: item.coverUrl != null
- ? ClipRRect(
- borderRadius: BorderRadius.circular(8),
- child: CachedNetworkImage(
- imageUrl: item.coverUrl!,
- width: 48,
- height: 48,
- fit: BoxFit.cover,
- ),
- )
- : Container(
- width: 48,
- height: 48,
- decoration: BoxDecoration(
- color: colorScheme.surfaceContainerHighest,
- borderRadius: BorderRadius.circular(8),
- ),
- child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant),
- ),
- title: Text(item.trackName, maxLines: 1, overflow: TextOverflow.ellipsis),
- subtitle: Text(
- item.artistName,
- maxLines: 1,
- overflow: TextOverflow.ellipsis,
- style: TextStyle(color: colorScheme.onSurfaceVariant),
- ),
- trailing: fileExists
- ? IconButton(
- icon: Icon(Icons.play_arrow, color: colorScheme.primary),
- onPressed: () => _openFile(item.filePath),
- )
- : Icon(Icons.error_outline, color: colorScheme.error, size: 20),
- onTap: () {
- Navigator.pop(context); // Close bottom sheet first
- Future.delayed(const Duration(milliseconds: 100), () {
- _navigateToMetadataScreen(item);
- });
- },
- );
- },
- ),
- ),
- ],
- ),
+ initialChildSize: 0.7, minChildSize: 0.5, maxChildSize: 0.95, expand: false,
+ builder: (context, scrollController) => Column(children: [
+ Padding(padding: const EdgeInsets.all(16), child: Row(
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
+ children: [
+ Text('All Downloads (${historyState.items.length})',
+ style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)),
+ IconButton(icon: const Icon(Icons.close), onPressed: () => Navigator.pop(context)),
+ ],
+ )),
+ const Divider(height: 1),
+ Expanded(child: ListView.builder(
+ controller: scrollController,
+ itemCount: historyState.items.length,
+ itemBuilder: (context, index) {
+ final item = historyState.items[index];
+ final fileExists = _checkFileExists(item.filePath);
+ return ListTile(
+ leading: item.coverUrl != null
+ ? ClipRRect(borderRadius: BorderRadius.circular(8),
+ child: CachedNetworkImage(
+ imageUrl: item.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(item.trackName, maxLines: 1, overflow: TextOverflow.ellipsis),
+ subtitle: Text(item.artistName, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(color: colorScheme.onSurfaceVariant)),
+ trailing: fileExists
+ ? IconButton(icon: Icon(Icons.play_arrow, color: colorScheme.primary), onPressed: () => _openFile(item.filePath))
+ : Icon(Icons.error_outline, color: colorScheme.error, size: 20),
+ onTap: () { Navigator.pop(context); Future.delayed(const Duration(milliseconds: 100), () => _navigateToMetadataScreen(item)); },
+ );
+ },
+ )),
+ ]),
),
);
}
diff --git a/lib/screens/main_shell.dart b/lib/screens/main_shell.dart
index 695d2a47..23a4cb4c 100644
--- a/lib/screens/main_shell.dart
+++ b/lib/screens/main_shell.dart
@@ -4,7 +4,7 @@ import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/screens/home_tab.dart';
import 'package:spotiflac_android/screens/queue_tab.dart';
-import 'package:spotiflac_android/screens/settings_tab.dart';
+import 'package:spotiflac_android/screens/settings/settings_tab.dart';
import 'package:spotiflac_android/services/update_checker.dart';
import 'package:spotiflac_android/widgets/update_dialog.dart';
@@ -19,14 +19,6 @@ class _MainShellState extends ConsumerState {
int _currentIndex = 0;
late PageController _pageController;
bool _hasCheckedUpdate = false;
- bool _isAnimating = false;
-
- // Cache tab widgets to prevent rebuilds
- final List _tabs = const [
- HomeTab(),
- QueueTab(),
- SettingsTab(),
- ];
@override
void initState() {
@@ -64,14 +56,13 @@ class _MainShellState extends ConsumerState {
}
void _onNavTap(int index) {
- if (_currentIndex != index && !_isAnimating) {
- _isAnimating = true;
+ if (_currentIndex != index) {
setState(() => _currentIndex = index);
_pageController.animateToPage(
index,
- duration: const Duration(milliseconds: 200),
- curve: Curves.easeOut,
- ).then((_) => _isAnimating = false);
+ duration: const Duration(milliseconds: 250),
+ curve: Curves.easeOutCubic,
+ );
}
}
@@ -83,33 +74,23 @@ class _MainShellState extends ConsumerState {
@override
Widget build(BuildContext context) {
- final queueState = ref.watch(downloadQueueProvider);
+ final queueState = ref.watch(downloadQueueProvider.select((s) => s.queuedCount));
return Scaffold(
- appBar: AppBar(
- leading: Padding(
- padding: const EdgeInsets.all(8.0),
- child: ClipRRect(
- borderRadius: BorderRadius.circular(20),
- child: Image.asset(
- 'assets/images/logo.png',
- width: 40,
- height: 40,
- ),
- ),
- ),
- title: const Text('SpotiFLAC'),
- ),
body: PageView(
controller: _pageController,
onPageChanged: _onPageChanged,
- physics: const ClampingScrollPhysics(),
- children: _tabs,
+ physics: const BouncingScrollPhysics(),
+ children: const [
+ HomeTab(),
+ QueueTab(),
+ SettingsTab(),
+ ],
),
bottomNavigationBar: NavigationBar(
selectedIndex: _currentIndex,
onDestinationSelected: _onNavTap,
- animationDuration: const Duration(milliseconds: 300),
+ animationDuration: const Duration(milliseconds: 200),
destinations: [
const NavigationDestination(
icon: Icon(Icons.home_outlined),
@@ -118,13 +99,13 @@ class _MainShellState extends ConsumerState {
),
NavigationDestination(
icon: Badge(
- isLabelVisible: queueState.queuedCount > 0,
- label: Text('${queueState.queuedCount}'),
+ isLabelVisible: queueState > 0,
+ label: Text('$queueState'),
child: const Icon(Icons.download_outlined),
),
selectedIcon: Badge(
- isLabelVisible: queueState.queuedCount > 0,
- label: Text('${queueState.queuedCount}'),
+ isLabelVisible: queueState > 0,
+ label: Text('$queueState'),
child: const Icon(Icons.download),
),
label: 'Downloads',
diff --git a/lib/screens/queue_tab.dart b/lib/screens/queue_tab.dart
index ea49b8d4..08c56160 100644
--- a/lib/screens/queue_tab.dart
+++ b/lib/screens/queue_tab.dart
@@ -12,23 +12,108 @@ class QueueTab extends ConsumerWidget {
final queueState = ref.watch(downloadQueueProvider);
final colorScheme = Theme.of(context).colorScheme;
- return Column(
- children: [
- // Header with actions
- if (queueState.items.isNotEmpty)
- Padding(
- padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
- child: Row(
- mainAxisAlignment: MainAxisAlignment.spaceBetween,
- children: [
- Text(
- '${queueState.items.length} items',
- style: Theme.of(context).textTheme.titleSmall?.copyWith(
- color: colorScheme.onSurfaceVariant,
+ return CustomScrollView(
+ slivers: [
+ // Collapsing App Bar - Simplified for performance
+ SliverAppBar(
+ expandedHeight: 100,
+ collapsedHeight: kToolbarHeight,
+ floating: false,
+ pinned: true,
+ backgroundColor: colorScheme.surface,
+ surfaceTintColor: Colors.transparent,
+ automaticallyImplyLeading: false,
+ flexibleSpace: FlexibleSpaceBar(
+ expandedTitleScale: 1.4,
+ titlePadding: const EdgeInsets.only(left: 24, bottom: 16),
+ title: Text(
+ 'Downloads',
+ style: TextStyle(
+ fontSize: 20,
+ fontWeight: FontWeight.bold,
+ color: colorScheme.onSurface,
+ ),
+ ),
+ ),
+ ),
+
+ // Pause/Resume controls when downloading
+ if (queueState.isProcessing || queueState.queuedCount > 0)
+ SliverToBoxAdapter(
+ child: Padding(
+ padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
+ child: Card(
+ child: Padding(
+ padding: const EdgeInsets.all(12),
+ child: Row(
+ children: [
+ // Status icon
+ Container(
+ padding: const EdgeInsets.all(8),
+ decoration: BoxDecoration(
+ color: queueState.isPaused
+ ? colorScheme.errorContainer
+ : colorScheme.primaryContainer,
+ borderRadius: BorderRadius.circular(12),
+ ),
+ child: Icon(
+ queueState.isPaused ? Icons.pause : Icons.downloading,
+ color: queueState.isPaused
+ ? colorScheme.onErrorContainer
+ : colorScheme.onPrimaryContainer,
+ ),
+ ),
+ const SizedBox(width: 12),
+ // Status text
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ queueState.isPaused ? 'Queue Paused' : 'Downloading...',
+ style: Theme.of(context).textTheme.titleSmall?.copyWith(
+ fontWeight: FontWeight.bold,
+ ),
+ ),
+ Text(
+ '${queueState.completedCount}/${queueState.items.length} completed',
+ style: Theme.of(context).textTheme.bodySmall?.copyWith(
+ color: colorScheme.onSurfaceVariant,
+ ),
+ ),
+ ],
+ ),
+ ),
+ // Pause/Resume button
+ FilledButton.tonal(
+ onPressed: () => ref.read(downloadQueueProvider.notifier).togglePause(),
+ child: Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Icon(queueState.isPaused ? Icons.play_arrow : Icons.pause, size: 20),
+ const SizedBox(width: 4),
+ Text(queueState.isPaused ? 'Resume' : 'Pause'),
+ ],
+ ),
+ ),
+ ],
),
),
- Row(
- children: [
+ ),
+ ),
+ ),
+
+ // Header with actions
+ if (queueState.items.isNotEmpty)
+ SliverToBoxAdapter(
+ child: Padding(
+ padding: const EdgeInsets.fromLTRB(16, 8, 8, 8),
+ child: Row(
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
+ children: [
+ Text('${queueState.items.length} items',
+ style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)),
+ Row(children: [
TextButton.icon(
onPressed: () => ref.read(downloadQueueProvider.notifier).clearCompleted(),
icon: const Icon(Icons.done_all, size: 18),
@@ -39,213 +124,247 @@ class QueueTab extends ConsumerWidget {
icon: Icon(Icons.clear_all, size: 18, color: colorScheme.error),
label: Text('Clear all', style: TextStyle(color: colorScheme.error)),
),
- ],
- ),
- ],
+ ]),
+ ],
+ ),
),
),
-
+
// Queue list
- Expanded(
- child: queueState.items.isEmpty
- ? _buildEmptyState(context, colorScheme)
- : ListView.builder(
- itemCount: queueState.items.length,
- itemBuilder: (context, index) => _buildQueueItem(context, ref, queueState.items[index], colorScheme),
- ),
- ),
+ if (queueState.items.isNotEmpty)
+ SliverList(delegate: SliverChildBuilderDelegate(
+ (context, index) => _buildQueueItem(context, ref, queueState.items[index], colorScheme),
+ childCount: queueState.items.length,
+ )),
+
+ // Empty state or fill remaining for scroll
+ if (queueState.items.isEmpty)
+ SliverFillRemaining(hasScrollBody: false, child: _buildEmptyState(context, colorScheme))
+ else
+ const SliverFillRemaining(hasScrollBody: false, child: SizedBox()),
],
);
}
- Widget _buildEmptyState(BuildContext context, ColorScheme colorScheme) {
- return Center(
- child: Column(
- mainAxisAlignment: MainAxisAlignment.center,
- children: [
- Icon(
- Icons.queue_music,
- size: 64,
- color: colorScheme.onSurfaceVariant,
- ),
- const SizedBox(height: 16),
- Text(
- 'No downloads in queue',
- style: Theme.of(context).textTheme.bodyLarge?.copyWith(
- color: colorScheme.onSurfaceVariant,
- ),
- ),
- const SizedBox(height: 8),
- Text(
- 'Add tracks from the Home tab',
- style: Theme.of(context).textTheme.bodyMedium?.copyWith(
- color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7),
- ),
- ),
- ],
- ),
- );
- }
+ Widget _buildEmptyState(BuildContext context, ColorScheme colorScheme) => Center(
+ child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
+ Icon(Icons.queue_music, size: 64, color: colorScheme.onSurfaceVariant),
+ const SizedBox(height: 16),
+ Text('No downloads in queue', style: Theme.of(context).textTheme.bodyLarge?.copyWith(color: colorScheme.onSurfaceVariant)),
+ const SizedBox(height: 8),
+ Text('Add tracks from the Home tab', style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7))),
+ ]),
+ );
Widget _buildQueueItem(BuildContext context, WidgetRef ref, DownloadItem item, ColorScheme colorScheme) {
- return ListTile(
- leading: item.track.coverUrl != null
- ? ClipRRect(
- borderRadius: BorderRadius.circular(8),
- child: CachedNetworkImage(
- imageUrl: item.track.coverUrl!,
- width: 48,
- height: 48,
- fit: BoxFit.cover,
- ),
- )
- : Container(
- width: 48,
- height: 48,
- decoration: BoxDecoration(
- color: colorScheme.surfaceContainerHighest,
- borderRadius: BorderRadius.circular(8),
- ),
- child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant),
- ),
- title: Text(item.track.name, maxLines: 1, overflow: TextOverflow.ellipsis),
- subtitle: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Text(
- item.track.artistName,
- maxLines: 1,
- overflow: TextOverflow.ellipsis,
- style: TextStyle(color: colorScheme.onSurfaceVariant),
- ),
- if (item.status == DownloadStatus.downloading) ...[
- const SizedBox(height: 4),
- Row(
- children: [
- Expanded(
- child: LinearProgressIndicator(
- value: item.progress > 0 ? item.progress : null,
- backgroundColor: colorScheme.surfaceContainerHighest,
- color: colorScheme.primary,
+ return Card(
+ margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
+ child: Padding(
+ padding: const EdgeInsets.all(12),
+ child: Row(
+ children: [
+ // Cover art
+ item.track.coverUrl != null
+ ? ClipRRect(
+ borderRadius: BorderRadius.circular(8),
+ child: CachedNetworkImage(
+ imageUrl: item.track.coverUrl!,
+ width: 56,
+ height: 56,
+ fit: BoxFit.cover,
+ memCacheWidth: 112,
+ memCacheHeight: 112,
+ ),
+ )
+ : Container(
+ width: 56,
+ height: 56,
+ decoration: BoxDecoration(
+ color: colorScheme.surfaceContainerHighest,
+ borderRadius: BorderRadius.circular(8),
+ ),
+ child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant),
),
- ),
- const SizedBox(width: 8),
- Text(
- '${(item.progress * 100).toStringAsFixed(0)}%',
- style: Theme.of(context).textTheme.labelSmall?.copyWith(
- color: colorScheme.onSurfaceVariant,
- fontWeight: FontWeight.bold,
+ const SizedBox(width: 12),
+
+ // Track info
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ item.track.name,
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ style: Theme.of(context).textTheme.titleSmall?.copyWith(
+ fontWeight: FontWeight.w600,
+ ),
),
- ),
- ],
+ const SizedBox(height: 2),
+ Text(
+ item.track.artistName,
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ style: Theme.of(context).textTheme.bodySmall?.copyWith(
+ color: colorScheme.onSurfaceVariant,
+ ),
+ ),
+ if (item.status == DownloadStatus.downloading) ...[
+ const SizedBox(height: 8),
+ Row(
+ children: [
+ Expanded(
+ child: ClipRRect(
+ borderRadius: BorderRadius.circular(4),
+ child: LinearProgressIndicator(
+ value: item.progress > 0 ? item.progress : null,
+ backgroundColor: colorScheme.surfaceContainerHighest,
+ color: colorScheme.primary,
+ minHeight: 6,
+ ),
+ ),
+ ),
+ const SizedBox(width: 8),
+ Text(
+ '${(item.progress * 100).toStringAsFixed(0)}%',
+ style: Theme.of(context).textTheme.labelSmall?.copyWith(
+ color: colorScheme.primary,
+ fontWeight: FontWeight.bold,
+ ),
+ ),
+ ],
+ ),
+ ],
+ if (item.status == DownloadStatus.failed) ...[
+ const SizedBox(height: 4),
+ Text(
+ item.error ?? 'Download failed',
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ style: Theme.of(context).textTheme.labelSmall?.copyWith(
+ color: colorScheme.error,
+ ),
+ ),
+ ],
+ ],
+ ),
),
+ const SizedBox(width: 8),
+
+ // Action buttons based on status
+ _buildActionButtons(context, ref, item, colorScheme),
],
- ],
+ ),
),
- trailing: _buildStatusIcon(context, item, colorScheme),
- onTap: item.status == DownloadStatus.queued
- ? () => ref.read(downloadQueueProvider.notifier).cancelItem(item.id)
- : null,
);
}
- Widget _buildStatusIcon(BuildContext context, DownloadItem item, ColorScheme colorScheme) {
+ Widget _buildActionButtons(BuildContext context, WidgetRef ref, DownloadItem item, ColorScheme colorScheme) {
switch (item.status) {
case DownloadStatus.queued:
- return Icon(Icons.hourglass_empty, color: colorScheme.onSurfaceVariant);
- case DownloadStatus.downloading:
- return SizedBox(
- width: 24,
- height: 24,
- child: CircularProgressIndicator(
- value: item.progress,
- strokeWidth: 2,
- color: colorScheme.primary,
- ),
- );
- case DownloadStatus.completed:
- return Icon(Icons.check_circle, color: colorScheme.primary);
- case DownloadStatus.failed:
- return IconButton(
- icon: Icon(Icons.error, color: colorScheme.error),
- onPressed: () => _showErrorDialog(context, item, colorScheme),
- tooltip: 'Tap to see error details',
- );
- case DownloadStatus.skipped:
- return Icon(Icons.skip_next, color: colorScheme.primary);
- }
- }
-
- void _showErrorDialog(BuildContext context, DownloadItem item, ColorScheme colorScheme) {
- showDialog(
- context: context,
- builder: (context) => AlertDialog(
- title: Row(
+ // Queued: Show play (start) and cancel buttons
+ return Row(
+ mainAxisSize: MainAxisSize.min,
children: [
- Icon(Icons.error, color: colorScheme.error),
- const SizedBox(width: 8),
- const Text('Download Failed'),
- ],
- ),
- content: SingleChildScrollView(
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- mainAxisSize: MainAxisSize.min,
- children: [
- Text('Track: ${item.track.name}', style: const TextStyle(fontWeight: FontWeight.bold)),
- Text('Artist: ${item.track.artistName}'),
- const SizedBox(height: 16),
- const Text('Error:', style: TextStyle(fontWeight: FontWeight.bold)),
- const SizedBox(height: 4),
- Container(
- padding: const EdgeInsets.all(8),
- decoration: BoxDecoration(
- color: colorScheme.errorContainer,
- borderRadius: BorderRadius.circular(8),
- ),
- child: Text(
- item.error ?? 'Unknown error',
- style: TextStyle(
- fontFamily: 'monospace',
- fontSize: 12,
- color: colorScheme.onErrorContainer,
- ),
- ),
+ // Cancel button
+ IconButton(
+ onPressed: () => ref.read(downloadQueueProvider.notifier).cancelItem(item.id),
+ icon: Icon(Icons.close, color: colorScheme.error),
+ tooltip: 'Cancel',
+ style: IconButton.styleFrom(
+ backgroundColor: colorScheme.errorContainer.withValues(alpha: 0.3),
),
- ],
+ ),
+ ],
+ );
+
+ case DownloadStatus.downloading:
+ // Downloading: Show progress indicator and cancel button
+ return Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ // Cancel button (skip this download)
+ IconButton(
+ onPressed: () => ref.read(downloadQueueProvider.notifier).cancelItem(item.id),
+ icon: Icon(Icons.stop, color: colorScheme.error),
+ tooltip: 'Stop',
+ style: IconButton.styleFrom(
+ backgroundColor: colorScheme.errorContainer.withValues(alpha: 0.3),
+ ),
+ ),
+ ],
+ );
+
+ case DownloadStatus.completed:
+ // Completed: Show check icon
+ return Container(
+ padding: const EdgeInsets.all(8),
+ decoration: BoxDecoration(
+ color: colorScheme.primaryContainer,
+ shape: BoxShape.circle,
),
- ),
- actions: [
- TextButton(
- onPressed: () => Navigator.pop(context),
- child: const Text('Close'),
- ),
- ],
- ),
- );
+ child: Icon(Icons.check, color: colorScheme.onPrimaryContainer, size: 20),
+ );
+
+ case DownloadStatus.failed:
+ // Failed: Show retry and remove buttons
+ return Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ IconButton(
+ onPressed: () => ref.read(downloadQueueProvider.notifier).retryItem(item.id),
+ icon: Icon(Icons.refresh, color: colorScheme.primary),
+ tooltip: 'Retry',
+ style: IconButton.styleFrom(
+ backgroundColor: colorScheme.primaryContainer.withValues(alpha: 0.3),
+ ),
+ ),
+ const SizedBox(width: 4),
+ IconButton(
+ onPressed: () => ref.read(downloadQueueProvider.notifier).removeItem(item.id),
+ icon: Icon(Icons.close, color: colorScheme.error),
+ tooltip: 'Remove',
+ style: IconButton.styleFrom(
+ backgroundColor: colorScheme.errorContainer.withValues(alpha: 0.3),
+ ),
+ ),
+ ],
+ );
+
+ case DownloadStatus.skipped:
+ // Skipped: Show retry and remove buttons
+ return Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ IconButton(
+ onPressed: () => ref.read(downloadQueueProvider.notifier).retryItem(item.id),
+ icon: Icon(Icons.refresh, color: colorScheme.primary),
+ tooltip: 'Retry',
+ style: IconButton.styleFrom(
+ backgroundColor: colorScheme.primaryContainer.withValues(alpha: 0.3),
+ ),
+ ),
+ const SizedBox(width: 4),
+ IconButton(
+ onPressed: () => ref.read(downloadQueueProvider.notifier).removeItem(item.id),
+ icon: Icon(Icons.close, color: colorScheme.onSurfaceVariant),
+ tooltip: 'Remove',
+ ),
+ ],
+ );
+ }
}
void _showClearAllDialog(BuildContext context, WidgetRef ref) {
final colorScheme = Theme.of(context).colorScheme;
- showDialog(
- context: context,
- builder: (context) => AlertDialog(
- title: const Text('Clear All'),
- content: const Text('Are you sure you want to clear all downloads?'),
- actions: [
- TextButton(
- onPressed: () => Navigator.pop(context),
- child: const Text('Cancel'),
- ),
- TextButton(
- onPressed: () {
- ref.read(downloadQueueProvider.notifier).clearAll();
- Navigator.pop(context);
- },
- child: Text('Clear', style: TextStyle(color: colorScheme.error)),
- ),
- ],
- ),
- );
+ showDialog(context: context, builder: (context) => AlertDialog(
+ title: const Text('Clear All'),
+ content: const Text('Are you sure you want to clear all downloads?'),
+ actions: [
+ TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')),
+ TextButton(onPressed: () { ref.read(downloadQueueProvider.notifier).clearAll(); Navigator.pop(context); },
+ child: Text('Clear', style: TextStyle(color: colorScheme.error))),
+ ],
+ ));
}
}
diff --git a/lib/screens/settings/about_page.dart b/lib/screens/settings/about_page.dart
new file mode 100644
index 00000000..2fbdfb74
--- /dev/null
+++ b/lib/screens/settings/about_page.dart
@@ -0,0 +1,167 @@
+import 'package:flutter/material.dart';
+import 'package:url_launcher/url_launcher.dart';
+import 'package:spotiflac_android/constants/app_info.dart';
+
+class AboutPage extends StatelessWidget {
+ const AboutPage({super.key});
+
+ @override
+ Widget build(BuildContext context) {
+ final colorScheme = Theme.of(context).colorScheme;
+ final topPadding = MediaQuery.of(context).padding.top;
+
+ return Scaffold(
+ body: CustomScrollView(
+ slivers: [
+ // Collapsing App Bar with back button
+ SliverAppBar(
+ expandedHeight: 120 + topPadding,
+ collapsedHeight: kToolbarHeight,
+ floating: false,
+ pinned: true,
+ backgroundColor: colorScheme.surface,
+ surfaceTintColor: Colors.transparent,
+ leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => Navigator.pop(context)),
+ flexibleSpace: LayoutBuilder(
+ builder: (context, constraints) {
+ final maxHeight = 120 + topPadding;
+ final minHeight = kToolbarHeight + topPadding;
+ final expandRatio = ((constraints.maxHeight - minHeight) / (maxHeight - minHeight)).clamp(0.0, 1.0);
+ final animation = AlwaysStoppedAnimation(expandRatio);
+ return FlexibleSpaceBar(
+ expandedTitleScale: 1.0,
+ titlePadding: EdgeInsets.zero,
+ title: SafeArea(
+ child: Container(
+ alignment: Alignment.bottomLeft,
+ padding: EdgeInsets.only(
+ left: Tween(begin: 56, end: 24).evaluate(animation),
+ bottom: Tween(begin: 12, end: 16).evaluate(animation),
+ ),
+ child: Text('About',
+ style: TextStyle(
+ fontSize: Tween(begin: 20, end: 28).evaluate(animation),
+ fontWeight: FontWeight.bold,
+ color: colorScheme.onSurface,
+ ),
+ ),
+ ),
+ ),
+ );
+ },
+ ),
+ ),
+
+ // App info card
+ SliverToBoxAdapter(
+ child: Padding(
+ padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
+ child: Card(
+ elevation: 0,
+ color: colorScheme.surfaceContainerHigh,
+ shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
+ child: Padding(
+ padding: const EdgeInsets.all(16),
+ child: Row(children: [
+ Container(
+ width: 56, height: 56,
+ decoration: BoxDecoration(color: colorScheme.primaryContainer, borderRadius: BorderRadius.circular(16)),
+ child: ClipRRect(
+ borderRadius: BorderRadius.circular(16),
+ child: Image.asset('assets/images/logo.png', fit: BoxFit.cover,
+ errorBuilder: (_, __, ___) => Icon(Icons.music_note, size: 32, color: colorScheme.onPrimaryContainer)),
+ ),
+ ),
+ const SizedBox(width: 16),
+ Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
+ Text(AppInfo.appName, style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)),
+ const SizedBox(height: 4),
+ Container(
+ padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
+ decoration: BoxDecoration(color: colorScheme.secondaryContainer, borderRadius: BorderRadius.circular(12)),
+ child: Text('v${AppInfo.version}', style: Theme.of(context).textTheme.labelMedium?.copyWith(color: colorScheme.onSecondaryContainer)),
+ ),
+ ]),
+ ]),
+ ),
+ ),
+ ),
+ ),
+
+ // GitHub section
+ SliverToBoxAdapter(child: _SectionHeader(title: 'GitHub')),
+ SliverList(delegate: SliverChildListDelegate([
+ ListTile(
+ contentPadding: const EdgeInsets.symmetric(horizontal: 24),
+ leading: Icon(Icons.phone_android, color: colorScheme.onSurfaceVariant),
+ title: Text('${AppInfo.appName} Mobile'),
+ subtitle: Text('github.com/${AppInfo.githubRepo}'),
+ trailing: const Icon(Icons.open_in_new, size: 20),
+ onTap: () => _launchUrl(AppInfo.githubUrl),
+ ),
+ ListTile(
+ contentPadding: const EdgeInsets.symmetric(horizontal: 24),
+ leading: Icon(Icons.computer, color: colorScheme.onSurfaceVariant),
+ title: Text('Original ${AppInfo.appName}'),
+ subtitle: Text('github.com/${AppInfo.originalAuthor}/SpotiFLAC'),
+ trailing: const Icon(Icons.open_in_new, size: 20),
+ onTap: () => _launchUrl(AppInfo.originalGithubUrl),
+ ),
+ ])),
+
+ // Credits section
+ SliverToBoxAdapter(child: _SectionHeader(title: 'Credits')),
+ SliverToBoxAdapter(
+ child: Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 24),
+ child: Column(children: [
+ _CreditRow(label: 'Mobile Version', value: AppInfo.mobileAuthor),
+ const SizedBox(height: 12),
+ _CreditRow(label: 'Original Project', value: AppInfo.originalAuthor),
+ ]),
+ ),
+ ),
+
+ // Copyright
+ SliverToBoxAdapter(
+ child: Padding(
+ padding: const EdgeInsets.all(24),
+ child: Center(child: Text(AppInfo.copyright,
+ style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant))),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+
+ Future _launchUrl(String url) async {
+ final uri = Uri.parse(url);
+ if (await canLaunchUrl(uri)) await launchUrl(uri, mode: LaunchMode.externalApplication);
+ }
+}
+
+class _SectionHeader extends StatelessWidget {
+ final String title;
+ const _SectionHeader({required this.title});
+ @override
+ Widget build(BuildContext context) => Padding(
+ padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
+ child: Text(title, style: Theme.of(context).textTheme.titleSmall?.copyWith(
+ color: Theme.of(context).colorScheme.primary, fontWeight: FontWeight.w600)),
+ );
+}
+
+class _CreditRow extends StatelessWidget {
+ final String label;
+ final String value;
+ const _CreditRow({required this.label, required this.value});
+ @override
+ Widget build(BuildContext context) {
+ final colorScheme = Theme.of(context).colorScheme;
+ return Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [
+ Text(label, style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)),
+ Text(value, style: Theme.of(context).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600)),
+ ]);
+ }
+}
diff --git a/lib/screens/settings/appearance_settings_page.dart b/lib/screens/settings/appearance_settings_page.dart
new file mode 100644
index 00000000..1a0325de
--- /dev/null
+++ b/lib/screens/settings/appearance_settings_page.dart
@@ -0,0 +1,203 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:spotiflac_android/providers/theme_provider.dart';
+
+class AppearanceSettingsPage extends ConsumerWidget {
+ const AppearanceSettingsPage({super.key});
+
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ final themeSettings = ref.watch(themeProvider);
+ final colorScheme = Theme.of(context).colorScheme;
+ final topPadding = MediaQuery.of(context).padding.top;
+
+ return Scaffold(
+ body: CustomScrollView(
+ slivers: [
+ // Collapsing App Bar with back button
+ SliverAppBar(
+ expandedHeight: 120 + topPadding,
+ collapsedHeight: kToolbarHeight,
+ floating: false,
+ pinned: true,
+ backgroundColor: colorScheme.surface,
+ surfaceTintColor: Colors.transparent,
+ leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => Navigator.pop(context)),
+ flexibleSpace: LayoutBuilder(
+ builder: (context, constraints) {
+ final maxHeight = 120 + topPadding;
+ final minHeight = kToolbarHeight + topPadding;
+ final expandRatio = ((constraints.maxHeight - minHeight) / (maxHeight - minHeight)).clamp(0.0, 1.0);
+ final animation = AlwaysStoppedAnimation(expandRatio);
+ return FlexibleSpaceBar(
+ expandedTitleScale: 1.0,
+ titlePadding: EdgeInsets.zero,
+ title: SafeArea(
+ child: Container(
+ alignment: Alignment.bottomLeft,
+ padding: EdgeInsets.only(
+ left: Tween(begin: 56, end: 24).evaluate(animation),
+ bottom: Tween(begin: 12, end: 16).evaluate(animation),
+ ),
+ child: Text('Appearance',
+ style: TextStyle(
+ fontSize: Tween(begin: 20, end: 28).evaluate(animation),
+ fontWeight: FontWeight.bold,
+ color: colorScheme.onSurface,
+ ),
+ ),
+ ),
+ ),
+ );
+ },
+ ),
+ ),
+
+ // Theme section
+ SliverToBoxAdapter(child: _SectionHeader(title: 'Theme')),
+ SliverToBoxAdapter(
+ child: Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 16),
+ child: _ThemeModeSelector(
+ currentMode: themeSettings.themeMode,
+ onChanged: (mode) => ref.read(themeProvider.notifier).setThemeMode(mode),
+ ),
+ ),
+ ),
+
+ // Color section
+ SliverToBoxAdapter(child: _SectionHeader(title: 'Color')),
+ SliverToBoxAdapter(
+ child: SwitchListTile(
+ contentPadding: const EdgeInsets.symmetric(horizontal: 24),
+ title: const Text('Dynamic Color'),
+ subtitle: const Text('Use colors from your wallpaper'),
+ value: themeSettings.useDynamicColor,
+ onChanged: (value) => ref.read(themeProvider.notifier).setUseDynamicColor(value),
+ ),
+ ),
+
+ if (!themeSettings.useDynamicColor)
+ SliverToBoxAdapter(
+ child: _ColorPicker(
+ currentColor: themeSettings.seedColorValue,
+ onColorSelected: (color) => ref.read(themeProvider.notifier).setSeedColor(color),
+ ),
+ ),
+
+ // Fill remaining for scroll
+ const SliverFillRemaining(hasScrollBody: false, child: SizedBox()),
+ ],
+ ),
+ );
+ }
+}
+
+class _SectionHeader extends StatelessWidget {
+ final String title;
+ const _SectionHeader({required this.title});
+ @override
+ Widget build(BuildContext context) => Padding(
+ padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
+ child: Text(title, style: Theme.of(context).textTheme.titleSmall?.copyWith(
+ color: Theme.of(context).colorScheme.primary, fontWeight: FontWeight.w600)),
+ );
+}
+
+class _ThemeModeSelector extends StatelessWidget {
+ final ThemeMode currentMode;
+ final ValueChanged onChanged;
+ const _ThemeModeSelector({required this.currentMode, required this.onChanged});
+
+ @override
+ Widget build(BuildContext context) {
+ final colorScheme = Theme.of(context).colorScheme;
+ return Card(
+ elevation: 0,
+ color: colorScheme.surfaceContainerHigh,
+ shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
+ child: Padding(
+ padding: const EdgeInsets.all(8),
+ child: Row(children: [
+ _ThemeModeChip(icon: Icons.brightness_auto, label: 'System', isSelected: currentMode == ThemeMode.system, onTap: () => onChanged(ThemeMode.system)),
+ const SizedBox(width: 8),
+ _ThemeModeChip(icon: Icons.light_mode, label: 'Light', isSelected: currentMode == ThemeMode.light, onTap: () => onChanged(ThemeMode.light)),
+ const SizedBox(width: 8),
+ _ThemeModeChip(icon: Icons.dark_mode, label: 'Dark', isSelected: currentMode == ThemeMode.dark, onTap: () => onChanged(ThemeMode.dark)),
+ ]),
+ ),
+ );
+ }
+}
+
+class _ThemeModeChip extends StatelessWidget {
+ final IconData icon;
+ final String label;
+ final bool isSelected;
+ final VoidCallback onTap;
+ const _ThemeModeChip({required this.icon, required this.label, required this.isSelected, required this.onTap});
+
+ @override
+ Widget build(BuildContext context) {
+ final colorScheme = Theme.of(context).colorScheme;
+ return Expanded(
+ child: Material(
+ color: isSelected ? colorScheme.primaryContainer : 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)),
+ ]),
+ ),
+ ),
+ ),
+ );
+ }
+}
+
+class _ColorPicker extends StatelessWidget {
+ final int currentColor;
+ final ValueChanged onColorSelected;
+ const _ColorPicker({required this.currentColor, required this.onColorSelected});
+
+ static const _colors = [
+ Color(0xFF1DB954), Color(0xFF6750A4), Color(0xFF0061A4), Color(0xFF006E1C),
+ Color(0xFFBA1A1A), Color(0xFF984061), Color(0xFF7D5260), Color(0xFF006874), Color(0xFFFF6F00),
+ ];
+
+ @override
+ Widget build(BuildContext context) {
+ final colorScheme = Theme.of(context).colorScheme;
+ return Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8),
+ child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
+ Text('Accent Color', style: Theme.of(context).textTheme.titleSmall),
+ const SizedBox(height: 12),
+ Wrap(spacing: 12, runSpacing: 12, children: _colors.map((color) {
+ final isSelected = color.toARGB32() == currentColor;
+ return GestureDetector(
+ onTap: () => onColorSelected(color),
+ child: AnimatedContainer(
+ duration: const Duration(milliseconds: 200),
+ width: 44, height: 44,
+ decoration: BoxDecoration(
+ color: color, shape: BoxShape.circle,
+ border: isSelected ? Border.all(color: colorScheme.onSurface, width: 3) : null,
+ boxShadow: isSelected ? [BoxShadow(color: color.withValues(alpha: 0.4), blurRadius: 8, spreadRadius: 2)] : null,
+ ),
+ child: isSelected ? const Icon(Icons.check, color: Colors.white, size: 20) : null,
+ ),
+ );
+ }).toList()),
+ ]),
+ );
+ }
+}
diff --git a/lib/screens/settings/download_settings_page.dart b/lib/screens/settings/download_settings_page.dart
new file mode 100644
index 00000000..fe4ed5fb
--- /dev/null
+++ b/lib/screens/settings/download_settings_page.dart
@@ -0,0 +1,232 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:file_picker/file_picker.dart';
+import 'package:spotiflac_android/providers/settings_provider.dart';
+
+class DownloadSettingsPage extends ConsumerWidget {
+ const DownloadSettingsPage({super.key});
+
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ final settings = ref.watch(settingsProvider);
+ final colorScheme = Theme.of(context).colorScheme;
+ final topPadding = MediaQuery.of(context).padding.top;
+
+ return Scaffold(
+ body: CustomScrollView(
+ slivers: [
+ // Collapsing App Bar with back button
+ SliverAppBar(
+ expandedHeight: 120 + topPadding,
+ collapsedHeight: kToolbarHeight,
+ floating: false,
+ pinned: true,
+ backgroundColor: colorScheme.surface,
+ surfaceTintColor: Colors.transparent,
+ leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => Navigator.pop(context)),
+ flexibleSpace: LayoutBuilder(
+ builder: (context, constraints) {
+ final maxHeight = 120 + topPadding;
+ final minHeight = kToolbarHeight + topPadding;
+ final expandRatio = ((constraints.maxHeight - minHeight) / (maxHeight - minHeight)).clamp(0.0, 1.0);
+ final animation = AlwaysStoppedAnimation(expandRatio);
+ return FlexibleSpaceBar(
+ expandedTitleScale: 1.0,
+ titlePadding: EdgeInsets.zero,
+ title: SafeArea(
+ child: Container(
+ alignment: Alignment.bottomLeft,
+ padding: EdgeInsets.only(
+ left: Tween(begin: 56, end: 24).evaluate(animation),
+ bottom: Tween(begin: 12, end: 16).evaluate(animation),
+ ),
+ child: Text('Download',
+ style: TextStyle(
+ fontSize: Tween(begin: 20, end: 28).evaluate(animation),
+ fontWeight: FontWeight.bold,
+ color: colorScheme.onSurface,
+ ),
+ ),
+ ),
+ ),
+ );
+ },
+ ),
+ ),
+
+ // Service section
+ SliverToBoxAdapter(child: _SectionHeader(title: 'Service')),
+ SliverToBoxAdapter(
+ child: Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 16),
+ child: _ServiceSelector(
+ currentService: settings.defaultService,
+ onChanged: (service) => ref.read(settingsProvider.notifier).setDefaultService(service),
+ ),
+ ),
+ ),
+
+ // Quality section
+ SliverToBoxAdapter(child: _SectionHeader(title: 'Audio Quality')),
+ SliverList(delegate: SliverChildListDelegate([
+ _QualityOption(title: 'FLAC Lossless', subtitle: '16-bit / 44.1kHz', value: 'LOSSLESS',
+ isSelected: settings.audioQuality == 'LOSSLESS',
+ onTap: () => ref.read(settingsProvider.notifier).setAudioQuality('LOSSLESS')),
+ _QualityOption(title: 'Hi-Res FLAC', subtitle: '24-bit / up to 96kHz', value: 'HI_RES',
+ isSelected: settings.audioQuality == 'HI_RES',
+ onTap: () => ref.read(settingsProvider.notifier).setAudioQuality('HI_RES')),
+ _QualityOption(title: 'Hi-Res FLAC Max', subtitle: '24-bit / up to 192kHz', value: 'HI_RES_LOSSLESS',
+ isSelected: settings.audioQuality == 'HI_RES_LOSSLESS',
+ onTap: () => ref.read(settingsProvider.notifier).setAudioQuality('HI_RES_LOSSLESS')),
+ ])),
+
+ // File settings section
+ SliverToBoxAdapter(child: _SectionHeader(title: 'File Settings')),
+ SliverList(delegate: SliverChildListDelegate([
+ ListTile(
+ contentPadding: const EdgeInsets.symmetric(horizontal: 24),
+ leading: Icon(Icons.text_fields, color: colorScheme.onSurfaceVariant),
+ title: const Text('Filename Format'),
+ subtitle: Text(settings.filenameFormat),
+ trailing: const Icon(Icons.chevron_right),
+ onTap: () => _showFormatEditor(context, ref, settings.filenameFormat),
+ ),
+ ListTile(
+ contentPadding: const EdgeInsets.symmetric(horizontal: 24),
+ leading: Icon(Icons.folder_outlined, color: colorScheme.onSurfaceVariant),
+ title: const Text('Download Directory'),
+ subtitle: Text(settings.downloadDirectory.isEmpty ? 'Music/SpotiFLAC' : settings.downloadDirectory, maxLines: 1, overflow: TextOverflow.ellipsis),
+ trailing: const Icon(Icons.chevron_right),
+ onTap: () => _pickDirectory(ref),
+ ),
+ ])),
+
+ const SliverToBoxAdapter(child: SizedBox(height: 32)),
+ ],
+ ),
+ );
+ }
+
+ void _showFormatEditor(BuildContext context, WidgetRef ref, String current) {
+ final controller = TextEditingController(text: current);
+ final colorScheme = Theme.of(context).colorScheme;
+ showModalBottomSheet(
+ context: context, isScrollControlled: true,
+ backgroundColor: colorScheme.surfaceContainerHigh,
+ shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(28))),
+ builder: (context) => Padding(
+ padding: EdgeInsets.fromLTRB(24, 24, 24, MediaQuery.of(context).viewInsets.bottom + 24),
+ child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
+ Text('Filename Format', style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)),
+ const SizedBox(height: 16),
+ TextField(controller: controller, decoration: const InputDecoration(hintText: '{artist} - {title}'), autofocus: true),
+ const SizedBox(height: 16),
+ Text('Available: {title}, {artist}, {album}, {track}, {year}, {disc}',
+ style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant)),
+ const SizedBox(height: 24),
+ Row(mainAxisAlignment: MainAxisAlignment.end, children: [
+ TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')),
+ const SizedBox(width: 8),
+ FilledButton(onPressed: () { ref.read(settingsProvider.notifier).setFilenameFormat(controller.text); Navigator.pop(context); }, child: const Text('Save')),
+ ]),
+ ]),
+ ),
+ );
+ }
+
+ Future _pickDirectory(WidgetRef ref) async {
+ final result = await FilePicker.platform.getDirectoryPath();
+ if (result != null) ref.read(settingsProvider.notifier).setDownloadDirectory(result);
+ }
+}
+
+class _SectionHeader extends StatelessWidget {
+ final String title;
+ const _SectionHeader({required this.title});
+ @override
+ Widget build(BuildContext context) => Padding(
+ padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
+ child: Text(title, style: Theme.of(context).textTheme.titleSmall?.copyWith(
+ color: Theme.of(context).colorScheme.primary, fontWeight: FontWeight.w600)),
+ );
+}
+
+class _ServiceSelector extends StatelessWidget {
+ final String currentService;
+ final ValueChanged onChanged;
+ const _ServiceSelector({required this.currentService, required this.onChanged});
+
+ @override
+ Widget build(BuildContext context) {
+ final colorScheme = Theme.of(context).colorScheme;
+ return Card(
+ elevation: 0,
+ color: colorScheme.surfaceContainerHigh,
+ shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
+ child: Padding(
+ padding: const EdgeInsets.all(8),
+ child: Row(children: [
+ _ServiceChip(icon: Icons.music_note, label: 'Tidal', isSelected: currentService == 'tidal', onTap: () => onChanged('tidal')),
+ const SizedBox(width: 8),
+ _ServiceChip(icon: Icons.album, label: 'Qobuz', isSelected: currentService == 'qobuz', onTap: () => onChanged('qobuz')),
+ const SizedBox(width: 8),
+ _ServiceChip(icon: Icons.shopping_bag, label: 'Amazon', isSelected: currentService == 'amazon', onTap: () => onChanged('amazon')),
+ ]),
+ ),
+ );
+ }
+}
+
+class _ServiceChip extends StatelessWidget {
+ final IconData icon;
+ final String label;
+ final bool isSelected;
+ final VoidCallback onTap;
+ const _ServiceChip({required this.icon, required this.label, required this.isSelected, required this.onTap});
+
+ @override
+ Widget build(BuildContext context) {
+ final colorScheme = Theme.of(context).colorScheme;
+ return Expanded(
+ child: Material(
+ color: isSelected ? colorScheme.primaryContainer : 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)),
+ ]),
+ ),
+ ),
+ ),
+ );
+ }
+}
+
+class _QualityOption extends StatelessWidget {
+ final String title;
+ final String subtitle;
+ final String value;
+ final bool isSelected;
+ final VoidCallback onTap;
+ const _QualityOption({required this.title, required this.subtitle, required this.value, required this.isSelected, required this.onTap});
+
+ @override
+ Widget build(BuildContext context) {
+ final colorScheme = Theme.of(context).colorScheme;
+ return ListTile(
+ contentPadding: const EdgeInsets.symmetric(horizontal: 24),
+ title: Text(title),
+ subtitle: Text(subtitle),
+ trailing: isSelected ? Icon(Icons.check_circle, color: colorScheme.primary) : Icon(Icons.circle_outlined, color: colorScheme.outline),
+ onTap: onTap,
+ );
+ }
+}
diff --git a/lib/screens/settings/options_settings_page.dart b/lib/screens/settings/options_settings_page.dart
new file mode 100644
index 00000000..37a2a8b5
--- /dev/null
+++ b/lib/screens/settings/options_settings_page.dart
@@ -0,0 +1,191 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:spotiflac_android/providers/settings_provider.dart';
+
+class OptionsSettingsPage extends ConsumerWidget {
+ const OptionsSettingsPage({super.key});
+
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ final settings = ref.watch(settingsProvider);
+ final colorScheme = Theme.of(context).colorScheme;
+ final topPadding = MediaQuery.of(context).padding.top;
+
+ return Scaffold(
+ body: CustomScrollView(
+ slivers: [
+ // Collapsing App Bar with back button
+ SliverAppBar(
+ expandedHeight: 120 + topPadding,
+ collapsedHeight: kToolbarHeight,
+ floating: false,
+ pinned: true,
+ backgroundColor: colorScheme.surface,
+ surfaceTintColor: Colors.transparent,
+ leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => Navigator.pop(context)),
+ flexibleSpace: LayoutBuilder(
+ builder: (context, constraints) {
+ final maxHeight = 120 + topPadding;
+ final minHeight = kToolbarHeight + topPadding;
+ final expandRatio = ((constraints.maxHeight - minHeight) / (maxHeight - minHeight)).clamp(0.0, 1.0);
+ final animation = AlwaysStoppedAnimation(expandRatio);
+ return FlexibleSpaceBar(
+ expandedTitleScale: 1.0,
+ titlePadding: EdgeInsets.zero,
+ title: SafeArea(
+ child: Container(
+ alignment: Alignment.bottomLeft,
+ padding: EdgeInsets.only(
+ left: Tween(begin: 56, end: 24).evaluate(animation),
+ bottom: Tween(begin: 12, end: 16).evaluate(animation),
+ ),
+ child: Text('Options',
+ style: TextStyle(
+ fontSize: Tween(begin: 20, end: 28).evaluate(animation),
+ fontWeight: FontWeight.bold,
+ color: colorScheme.onSurface,
+ ),
+ ),
+ ),
+ ),
+ );
+ },
+ ),
+ ),
+
+ // Download options section
+ SliverToBoxAdapter(child: _SectionHeader(title: 'Download')),
+ SliverList(delegate: SliverChildListDelegate([
+ SwitchListTile(
+ contentPadding: const EdgeInsets.symmetric(horizontal: 24),
+ secondary: Icon(Icons.sync, color: colorScheme.onSurfaceVariant),
+ title: const Text('Auto Fallback'),
+ subtitle: const Text('Try other services if download fails'),
+ value: settings.autoFallback,
+ onChanged: (v) => ref.read(settingsProvider.notifier).setAutoFallback(v),
+ ),
+ SwitchListTile(
+ contentPadding: const EdgeInsets.symmetric(horizontal: 24),
+ secondary: Icon(Icons.lyrics, color: colorScheme.onSurfaceVariant),
+ title: const Text('Embed Lyrics'),
+ subtitle: const Text('Embed synced lyrics into FLAC files'),
+ value: settings.embedLyrics,
+ onChanged: (v) => ref.read(settingsProvider.notifier).setEmbedLyrics(v),
+ ),
+ SwitchListTile(
+ contentPadding: const EdgeInsets.symmetric(horizontal: 24),
+ secondary: Icon(Icons.image, color: colorScheme.onSurfaceVariant),
+ title: const Text('Max Quality Cover'),
+ subtitle: const Text('Download highest resolution cover art'),
+ value: settings.maxQualityCover,
+ onChanged: (v) => ref.read(settingsProvider.notifier).setMaxQualityCover(v),
+ ),
+ ])),
+
+ // Performance section
+ SliverToBoxAdapter(child: _SectionHeader(title: 'Performance')),
+ SliverToBoxAdapter(
+ child: _ConcurrentDownloadsSelector(
+ currentValue: settings.concurrentDownloads,
+ onChanged: (v) => ref.read(settingsProvider.notifier).setConcurrentDownloads(v),
+ ),
+ ),
+
+ // App section
+ SliverToBoxAdapter(child: _SectionHeader(title: 'App')),
+ SliverToBoxAdapter(
+ child: SwitchListTile(
+ contentPadding: const EdgeInsets.symmetric(horizontal: 24),
+ secondary: Icon(Icons.system_update, color: colorScheme.onSurfaceVariant),
+ title: const Text('Check for Updates'),
+ subtitle: const Text('Notify when new version is available'),
+ value: settings.checkForUpdates,
+ onChanged: (v) => ref.read(settingsProvider.notifier).setCheckForUpdates(v),
+ ),
+ ),
+
+ const SliverToBoxAdapter(child: SizedBox(height: 32)),
+ ],
+ ),
+ );
+ }
+}
+
+class _SectionHeader extends StatelessWidget {
+ final String title;
+ const _SectionHeader({required this.title});
+ @override
+ Widget build(BuildContext context) => Padding(
+ padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
+ child: Text(title, style: Theme.of(context).textTheme.titleSmall?.copyWith(
+ color: Theme.of(context).colorScheme.primary, fontWeight: FontWeight.w600)),
+ );
+}
+
+class _ConcurrentDownloadsSelector extends StatelessWidget {
+ final int currentValue;
+ final ValueChanged onChanged;
+ const _ConcurrentDownloadsSelector({required this.currentValue, required this.onChanged});
+
+ @override
+ Widget build(BuildContext context) {
+ final colorScheme = Theme.of(context).colorScheme;
+ return Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 24),
+ child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
+ Row(children: [
+ Icon(Icons.download_for_offline, color: colorScheme.onSurfaceVariant),
+ const SizedBox(width: 16),
+ Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
+ const Text('Concurrent Downloads'),
+ Text(currentValue == 1 ? 'Sequential (1 at a time)' : '$currentValue parallel downloads',
+ style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant)),
+ ])),
+ ]),
+ const SizedBox(height: 16),
+ Row(children: [
+ _ConcurrentChip(label: '1', isSelected: currentValue == 1, onTap: () => onChanged(1)),
+ const SizedBox(width: 8),
+ _ConcurrentChip(label: '2', isSelected: currentValue == 2, onTap: () => onChanged(2)),
+ const SizedBox(width: 8),
+ _ConcurrentChip(label: '3', isSelected: currentValue == 3, onTap: () => onChanged(3)),
+ ]),
+ const SizedBox(height: 12),
+ Row(children: [
+ Icon(Icons.warning_amber_rounded, size: 16, color: colorScheme.error),
+ const SizedBox(width: 8),
+ Expanded(child: Text('Parallel downloads may trigger rate limiting',
+ style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.error))),
+ ]),
+ ]),
+ );
+ }
+}
+
+class _ConcurrentChip extends StatelessWidget {
+ final String label;
+ final bool isSelected;
+ final VoidCallback onTap;
+ const _ConcurrentChip({required this.label, required this.isSelected, required this.onTap});
+
+ @override
+ Widget build(BuildContext context) {
+ final colorScheme = Theme.of(context).colorScheme;
+ return Expanded(
+ child: Material(
+ color: isSelected ? colorScheme.primaryContainer : colorScheme.surfaceContainerHighest,
+ borderRadius: BorderRadius.circular(12),
+ child: InkWell(
+ onTap: onTap,
+ borderRadius: BorderRadius.circular(12),
+ child: Padding(
+ padding: const EdgeInsets.symmetric(vertical: 12),
+ child: Center(child: Text(label, style: TextStyle(
+ fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
+ color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant))),
+ ),
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/screens/settings/settings_tab.dart b/lib/screens/settings/settings_tab.dart
new file mode 100644
index 00000000..e2a3f662
--- /dev/null
+++ b/lib/screens/settings/settings_tab.dart
@@ -0,0 +1,112 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:spotiflac_android/constants/app_info.dart';
+import 'package:spotiflac_android/screens/settings/appearance_settings_page.dart';
+import 'package:spotiflac_android/screens/settings/download_settings_page.dart';
+import 'package:spotiflac_android/screens/settings/options_settings_page.dart';
+import 'package:spotiflac_android/screens/settings/about_page.dart';
+
+class SettingsTab extends ConsumerWidget {
+ const SettingsTab({super.key});
+
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ final colorScheme = Theme.of(context).colorScheme;
+
+ return CustomScrollView(
+ slivers: [
+ // Collapsing App Bar - Simplified for performance
+ SliverAppBar(
+ expandedHeight: 100,
+ collapsedHeight: kToolbarHeight,
+ floating: false,
+ pinned: true,
+ backgroundColor: colorScheme.surface,
+ surfaceTintColor: Colors.transparent,
+ automaticallyImplyLeading: false,
+ flexibleSpace: FlexibleSpaceBar(
+ expandedTitleScale: 1.4,
+ titlePadding: const EdgeInsets.only(left: 24, bottom: 16),
+ title: Text(
+ 'Settings',
+ style: TextStyle(
+ fontSize: 20,
+ fontWeight: FontWeight.bold,
+ color: colorScheme.onSurface,
+ ),
+ ),
+ ),
+ ),
+
+ // Menu items
+ SliverList(delegate: SliverChildListDelegate([
+ _SettingsMenuItem(
+ icon: Icons.palette_outlined,
+ title: 'Appearance',
+ subtitle: 'Theme, colors, display',
+ onTap: () => _navigateTo(context, const AppearanceSettingsPage()),
+ ),
+ _SettingsMenuItem(
+ icon: Icons.download_outlined,
+ title: 'Download',
+ subtitle: 'Service, quality, filename format',
+ onTap: () => _navigateTo(context, const DownloadSettingsPage()),
+ ),
+ _SettingsMenuItem(
+ icon: Icons.tune_outlined,
+ title: 'Options',
+ subtitle: 'Fallback, lyrics, cover art, updates',
+ onTap: () => _navigateTo(context, const OptionsSettingsPage()),
+ ),
+ _SettingsMenuItem(
+ icon: Icons.info_outline,
+ title: 'About',
+ subtitle: 'Version ${AppInfo.version}, credits, GitHub',
+ onTap: () => _navigateTo(context, const AboutPage()),
+ ),
+ ])),
+
+ // Fill remaining space to enable scroll
+ const SliverFillRemaining(hasScrollBody: false, child: SizedBox()),
+ ],
+ );
+ }
+
+ void _navigateTo(BuildContext context, Widget page) {
+ Navigator.of(context).push(MaterialPageRoute(builder: (_) => page));
+ }
+}
+
+class _SettingsMenuItem extends StatelessWidget {
+ final IconData icon;
+ final String title;
+ final String subtitle;
+ final VoidCallback onTap;
+
+ const _SettingsMenuItem({required this.icon, required this.title, required this.subtitle, required this.onTap});
+
+ @override
+ Widget build(BuildContext context) {
+ final colorScheme = Theme.of(context).colorScheme;
+ return InkWell(
+ onTap: onTap,
+ child: Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
+ child: Row(children: [
+ Container(
+ width: 44, height: 44,
+ decoration: BoxDecoration(color: colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(12)),
+ child: Icon(icon, color: colorScheme.onSurfaceVariant, size: 22),
+ ),
+ const SizedBox(width: 16),
+ Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
+ Text(title, style: Theme.of(context).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500)),
+ const SizedBox(height: 2),
+ Text(subtitle, style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)),
+ ])),
+ Icon(Icons.chevron_right, color: colorScheme.onSurfaceVariant, size: 24),
+ ]),
+ ),
+ );
+ }
+}
diff --git a/lib/screens/setup_screen.dart b/lib/screens/setup_screen.dart
index d6dab7aa..d6041381 100644
--- a/lib/screens/setup_screen.dart
+++ b/lib/screens/setup_screen.dart
@@ -1,4 +1,5 @@
import 'dart:io';
+import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:permission_handler/permission_handler.dart';
@@ -17,11 +18,15 @@ class SetupScreen extends ConsumerStatefulWidget {
class _SetupScreenState extends ConsumerState {
int _currentStep = 0;
- bool _permissionGranted = false;
+ bool _storagePermissionGranted = false;
+ bool _notificationPermissionGranted = false;
String? _selectedDirectory;
bool _isLoading = false;
int _androidSdkVersion = 0;
+ // Total steps: Storage -> Notification (Android 13+) -> Folder
+ int get _totalSteps => _androidSdkVersion >= 33 ? 3 : 2;
+
@override
void initState() {
super.initState();
@@ -35,47 +40,54 @@ class _SetupScreenState extends ConsumerState {
_androidSdkVersion = androidInfo.version.sdkInt;
debugPrint('Android SDK Version: $_androidSdkVersion');
}
- await _checkInitialPermission();
+ await _checkInitialPermissions();
}
- Future _checkInitialPermission() async {
+ Future _checkInitialPermissions() async {
if (Platform.isIOS) {
- // iOS doesn't need storage permission - app uses its own Documents directory
if (mounted) {
- setState(() => _permissionGranted = true);
+ setState(() {
+ _storagePermissionGranted = true;
+ _notificationPermissionGranted = true;
+ });
}
} else if (Platform.isAndroid) {
- PermissionStatus status;
-
+ // Check storage permission
+ PermissionStatus storageStatus;
if (_androidSdkVersion >= 33) {
- status = await Permission.audio.status;
+ storageStatus = await Permission.audio.status;
} else if (_androidSdkVersion >= 30) {
- status = await Permission.manageExternalStorage.status;
+ storageStatus = await Permission.manageExternalStorage.status;
} else {
- status = await Permission.storage.status;
+ storageStatus = await Permission.storage.status;
}
- if (status.isGranted && mounted) {
- setState(() => _permissionGranted = true);
+ // Check notification permission (Android 13+)
+ PermissionStatus notificationStatus = PermissionStatus.granted;
+ if (_androidSdkVersion >= 33) {
+ notificationStatus = await Permission.notification.status;
+ }
+
+ if (mounted) {
+ setState(() {
+ _storagePermissionGranted = storageStatus.isGranted;
+ _notificationPermissionGranted = notificationStatus.isGranted;
+ });
}
}
}
- Future _requestPermission() async {
+ Future _requestStoragePermission() async {
setState(() => _isLoading = true);
try {
if (Platform.isIOS) {
- // iOS doesn't need storage permission - app uses its own Documents directory
- setState(() => _permissionGranted = true);
+ setState(() => _storagePermissionGranted = true);
} else if (Platform.isAndroid) {
PermissionStatus status;
if (_androidSdkVersion >= 33) {
status = await Permission.audio.request();
- if (!status.isGranted) {
- await Permission.notification.request();
- }
} else if (_androidSdkVersion >= 30) {
status = await Permission.manageExternalStorage.request();
} else {
@@ -83,15 +95,13 @@ class _SetupScreenState extends ConsumerState {
}
if (status.isGranted) {
- setState(() => _permissionGranted = true);
+ setState(() => _storagePermissionGranted = true);
} else if (status.isPermanentlyDenied) {
- _showPermissionDeniedDialog();
+ _showPermissionDeniedDialog('Storage');
} else {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
- const SnackBar(
- content: Text('Permission denied. Please grant permission to continue.'),
- ),
+ const SnackBar(content: Text('Permission denied. Please grant permission to continue.')),
);
}
}
@@ -99,22 +109,46 @@ class _SetupScreenState extends ConsumerState {
} catch (e) {
debugPrint('Permission error: $e');
if (mounted) {
- ScaffoldMessenger.of(context).showSnackBar(
- SnackBar(content: Text('Error: $e')),
- );
+ ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error: $e')));
}
} finally {
setState(() => _isLoading = false);
}
}
- void _showPermissionDeniedDialog() {
+ Future _requestNotificationPermission() async {
+ setState(() => _isLoading = true);
+
+ try {
+ if (_androidSdkVersion >= 33) {
+ final status = await Permission.notification.request();
+ if (status.isGranted) {
+ setState(() => _notificationPermissionGranted = true);
+ } else if (status.isPermanentlyDenied) {
+ _showPermissionDeniedDialog('Notification');
+ }
+ } else {
+ // Notification permission not needed for older Android
+ setState(() => _notificationPermissionGranted = true);
+ }
+ } catch (e) {
+ debugPrint('Notification permission error: $e');
+ } finally {
+ setState(() => _isLoading = false);
+ }
+ }
+
+ void _skipNotificationPermission() {
+ setState(() => _notificationPermissionGranted = true);
+ }
+
+ void _showPermissionDeniedDialog(String permissionType) {
showDialog(
context: context,
builder: (context) => AlertDialog(
- title: const Text('Permission Required'),
- content: const Text(
- 'Storage permission is required to save downloaded music files. '
+ title: Text('$permissionType Permission Required'),
+ content: Text(
+ '$permissionType permission is required for the best experience. '
'Please grant permission in app settings.',
),
actions: [
@@ -151,18 +185,10 @@ class _SetupScreenState extends ConsumerState {
context: context,
builder: (context) => AlertDialog(
title: const Text('Use Default Folder?'),
- content: Text(
- 'No folder selected. Would you like to use the default Music folder?\n\n$defaultDir',
- ),
+ content: Text('No folder selected. Would you like to use the default Music folder?\n\n$defaultDir'),
actions: [
- TextButton(
- onPressed: () => Navigator.pop(context, false),
- child: const Text('Cancel'),
- ),
- TextButton(
- onPressed: () => Navigator.pop(context, true),
- child: const Text('Use Default'),
- ),
+ TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Cancel')),
+ TextButton(onPressed: () => Navigator.pop(context, true), child: const Text('Use Default')),
],
),
);
@@ -179,7 +205,6 @@ class _SetupScreenState extends ConsumerState {
Future _getDefaultDirectory() async {
if (Platform.isIOS) {
- // iOS: Use Documents directory (accessible via Files app)
final appDir = await getApplicationDocumentsDirectory();
final musicDir = Directory('${appDir.path}/SpotiFLAC');
try {
@@ -225,9 +250,7 @@ class _SetupScreenState extends ConsumerState {
}
} catch (e) {
if (mounted) {
- ScaffoldMessenger.of(context).showSnackBar(
- SnackBar(content: Text('Error: $e')),
- );
+ ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error: $e')));
}
} finally {
setState(() => _isLoading = false);
@@ -244,9 +267,9 @@ class _SetupScreenState extends ConsumerState {
padding: const EdgeInsets.all(24.0),
child: ConstrainedBox(
constraints: BoxConstraints(
- minHeight: MediaQuery.of(context).size.height -
+ minHeight: math.max(0, MediaQuery.of(context).size.height -
MediaQuery.of(context).padding.top -
- MediaQuery.of(context).padding.bottom - 48,
+ MediaQuery.of(context).padding.bottom - 48),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
@@ -257,27 +280,16 @@ class _SetupScreenState extends ConsumerState {
const SizedBox(height: 24),
ClipRRect(
borderRadius: BorderRadius.circular(24),
- child: Image.asset(
- 'assets/images/logo.png',
- width: 96,
- height: 96,
- ),
+ child: Image.asset('assets/images/logo.png', width: 96, height: 96),
),
const SizedBox(height: 12),
- Text(
- 'SpotiFLAC',
+ Text('SpotiFLAC',
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
- fontWeight: FontWeight.bold,
- color: colorScheme.primary,
- ),
- ),
+ fontWeight: FontWeight.bold, color: colorScheme.primary)),
const SizedBox(height: 4),
- Text(
- 'Download Spotify tracks in FLAC',
+ Text('Download Spotify tracks in FLAC',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
- color: colorScheme.onSurfaceVariant,
- ),
- ),
+ color: colorScheme.onSurfaceVariant)),
],
),
@@ -287,9 +299,7 @@ class _SetupScreenState extends ConsumerState {
const SizedBox(height: 24),
_buildStepIndicator(colorScheme),
const SizedBox(height: 24),
- _currentStep == 0
- ? _buildPermissionStep(colorScheme)
- : _buildDirectoryStep(colorScheme),
+ _buildCurrentStepContent(colorScheme),
],
),
@@ -310,27 +320,32 @@ class _SetupScreenState extends ConsumerState {
}
Widget _buildStepIndicator(ColorScheme colorScheme) {
+ final steps = _androidSdkVersion >= 33
+ ? ['Storage', 'Notification', 'Folder']
+ : ['Permission', 'Folder'];
+
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
- _buildStepDot(0, 'Permission', colorScheme),
- Padding(
- padding: const EdgeInsets.only(bottom: 20), // Offset for label height
- child: Container(
- width: 40,
- height: 2,
- color: _currentStep >= 1 ? colorScheme.primary : colorScheme.surfaceContainerHighest,
- ),
- ),
- _buildStepDot(1, 'Folder', colorScheme),
+ for (int i = 0; i < steps.length; i++) ...[
+ if (i > 0)
+ Padding(
+ padding: const EdgeInsets.only(bottom: 20),
+ child: Container(
+ width: 32,
+ height: 2,
+ color: _currentStep >= i ? colorScheme.primary : colorScheme.surfaceContainerHighest,
+ ),
+ ),
+ _buildStepDot(i, steps[i], colorScheme),
+ ],
],
);
}
Widget _buildStepDot(int step, String label, ColorScheme colorScheme) {
final isActive = _currentStep >= step;
- final isCompleted = (step == 0 && _permissionGranted) ||
- (step == 1 && _selectedDirectory != null);
+ final isCompleted = _isStepCompleted(step);
return Column(
children: [
@@ -341,86 +356,143 @@ class _SetupScreenState extends ConsumerState {
shape: BoxShape.circle,
color: isCompleted
? colorScheme.primary
- : isActive
- ? colorScheme.primaryContainer
- : colorScheme.surfaceContainerHighest,
+ : isActive ? colorScheme.primaryContainer : colorScheme.surfaceContainerHighest,
),
child: Center(
child: isCompleted
? Icon(Icons.check, size: 18, color: colorScheme.onPrimary)
- : Text(
- '${step + 1}',
+ : Text('${step + 1}',
style: TextStyle(
color: isActive ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant,
- fontWeight: FontWeight.bold,
- ),
- ),
+ fontWeight: FontWeight.bold)),
),
),
const SizedBox(height: 4),
- Text(
- label,
+ Text(label,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
- color: isActive ? colorScheme.onSurface : colorScheme.onSurfaceVariant,
- ),
- ),
+ color: isActive ? colorScheme.onSurface : colorScheme.onSurfaceVariant)),
],
);
}
- Widget _buildPermissionStep(ColorScheme colorScheme) {
+ bool _isStepCompleted(int step) {
+ if (_androidSdkVersion >= 33) {
+ // 3 steps: Storage, Notification, Folder
+ switch (step) {
+ case 0: return _storagePermissionGranted;
+ case 1: return _notificationPermissionGranted;
+ case 2: return _selectedDirectory != null;
+ }
+ } else {
+ // 2 steps: Permission, Folder
+ switch (step) {
+ case 0: return _storagePermissionGranted;
+ case 1: return _selectedDirectory != null;
+ }
+ }
+ return false;
+ }
+
+ Widget _buildCurrentStepContent(ColorScheme colorScheme) {
+ if (_androidSdkVersion >= 33) {
+ switch (_currentStep) {
+ case 0: return _buildStoragePermissionStep(colorScheme);
+ case 1: return _buildNotificationPermissionStep(colorScheme);
+ case 2: return _buildDirectoryStep(colorScheme);
+ }
+ } else {
+ switch (_currentStep) {
+ case 0: return _buildStoragePermissionStep(colorScheme);
+ case 1: return _buildDirectoryStep(colorScheme);
+ }
+ }
+ return const SizedBox();
+ }
+
+ Widget _buildStoragePermissionStep(ColorScheme colorScheme) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
Icon(
- _permissionGranted ? Icons.check_circle : Icons.folder_open,
+ _storagePermissionGranted ? Icons.check_circle : Icons.folder_open,
size: 56,
- color: _permissionGranted ? colorScheme.primary : colorScheme.onSurfaceVariant,
+ color: _storagePermissionGranted ? colorScheme.primary : colorScheme.onSurfaceVariant,
),
const SizedBox(height: 16),
Text(
- _permissionGranted
- ? 'Storage Permission Granted!'
- : 'Storage Permission Required',
- style: Theme.of(context).textTheme.titleMedium?.copyWith(
- fontWeight: FontWeight.bold,
- ),
+ _storagePermissionGranted ? 'Storage Permission Granted!' : 'Storage Permission Required',
+ style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
- _permissionGranted
- ? 'You can now select where to save your music files.'
+ _storagePermissionGranted
+ ? 'You can now proceed to the next step.'
: 'SpotiFLAC needs storage access to save downloaded music files to your device.',
- style: Theme.of(context).textTheme.bodyMedium?.copyWith(
- color: colorScheme.onSurfaceVariant,
- ),
+ style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant),
textAlign: TextAlign.center,
),
const SizedBox(height: 20),
- if (!_permissionGranted)
+ if (!_storagePermissionGranted)
FilledButton.icon(
- onPressed: _isLoading ? null : _requestPermission,
+ onPressed: _isLoading ? null : _requestStoragePermission,
icon: _isLoading
- ? SizedBox(
- width: 20,
- height: 20,
- child: CircularProgressIndicator(
- strokeWidth: 2,
- color: colorScheme.onPrimary,
- ),
- )
+ ? SizedBox(width: 20, height: 20,
+ child: CircularProgressIndicator(strokeWidth: 2, color: colorScheme.onPrimary))
: const Icon(Icons.security),
label: const Text('Grant Permission'),
- style: FilledButton.styleFrom(
- padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
- ),
+ style: FilledButton.styleFrom(padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12)),
),
],
);
}
+ Widget _buildNotificationPermissionStep(ColorScheme colorScheme) {
+ return Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Icon(
+ _notificationPermissionGranted ? Icons.check_circle : Icons.notifications_outlined,
+ size: 56,
+ color: _notificationPermissionGranted ? colorScheme.primary : colorScheme.onSurfaceVariant,
+ ),
+ const SizedBox(height: 16),
+ Text(
+ _notificationPermissionGranted ? 'Notification Permission Granted!' : 'Enable Notifications',
+ style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
+ textAlign: TextAlign.center,
+ ),
+ const SizedBox(height: 8),
+ Text(
+ _notificationPermissionGranted
+ ? 'You will receive download progress notifications.'
+ : 'Get notified about download progress and completion. This helps you track downloads when the app is in background.',
+ style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant),
+ textAlign: TextAlign.center,
+ ),
+ const SizedBox(height: 20),
+ if (!_notificationPermissionGranted) ...[
+ FilledButton.icon(
+ onPressed: _isLoading ? null : _requestNotificationPermission,
+ icon: _isLoading
+ ? SizedBox(width: 20, height: 20,
+ child: CircularProgressIndicator(strokeWidth: 2, color: colorScheme.onPrimary))
+ : const Icon(Icons.notifications_active),
+ label: const Text('Enable Notifications'),
+ style: FilledButton.styleFrom(padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12)),
+ ),
+ const SizedBox(height: 12),
+ TextButton(
+ onPressed: _skipNotificationPermission,
+ child: const Text('Skip for now'),
+ ),
+ ],
+ ],
+ );
+ }
+
Widget _buildDirectoryStep(ColorScheme colorScheme) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
@@ -433,12 +505,8 @@ class _SetupScreenState extends ConsumerState {
),
const SizedBox(height: 16),
Text(
- _selectedDirectory != null
- ? 'Download Folder Selected!'
- : 'Choose Download Folder',
- style: Theme.of(context).textTheme.titleMedium?.copyWith(
- fontWeight: FontWeight.bold,
- ),
+ _selectedDirectory != null ? 'Download Folder Selected!' : 'Choose Download Folder',
+ style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
@@ -455,46 +523,35 @@ class _SetupScreenState extends ConsumerState {
Icon(Icons.folder, color: colorScheme.primary, size: 20),
const SizedBox(width: 8),
Flexible(
- child: Text(
- _selectedDirectory!,
+ child: Text(_selectedDirectory!,
style: Theme.of(context).textTheme.bodySmall,
- overflow: TextOverflow.ellipsis,
- ),
+ overflow: TextOverflow.ellipsis),
),
],
),
)
else
- Text(
- 'Select a folder where your downloaded music will be saved.',
- style: Theme.of(context).textTheme.bodyMedium?.copyWith(
- color: colorScheme.onSurfaceVariant,
- ),
- textAlign: TextAlign.center,
- ),
+ Text('Select a folder where your downloaded music will be saved.',
+ style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant),
+ textAlign: TextAlign.center),
const SizedBox(height: 20),
FilledButton.icon(
onPressed: _isLoading ? null : _selectDirectory,
icon: _isLoading
- ? SizedBox(
- width: 20,
- height: 20,
- child: CircularProgressIndicator(
- strokeWidth: 2,
- color: colorScheme.onPrimary,
- ),
- )
+ ? SizedBox(width: 20, height: 20,
+ child: CircularProgressIndicator(strokeWidth: 2, color: colorScheme.onPrimary))
: Icon(_selectedDirectory != null ? Icons.edit : Icons.folder_open),
label: Text(_selectedDirectory != null ? 'Change Folder' : 'Select Folder'),
- style: FilledButton.styleFrom(
- padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
- ),
+ style: FilledButton.styleFrom(padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12)),
),
],
);
}
Widget _buildNavigationButtons(ColorScheme colorScheme) {
+ final isLastStep = _currentStep == _totalSteps - 1;
+ final canProceed = _isStepCompleted(_currentStep);
+
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
@@ -509,41 +566,23 @@ class _SetupScreenState extends ConsumerState {
const SizedBox(width: 100),
// Next/Finish button
- if (_currentStep == 0)
+ if (!isLastStep)
FilledButton(
- onPressed: _permissionGranted
- ? () => setState(() => _currentStep++)
- : null,
+ onPressed: canProceed ? () => setState(() => _currentStep++) : null,
child: const Row(
mainAxisSize: MainAxisSize.min,
- children: [
- Text('Next'),
- SizedBox(width: 8),
- Icon(Icons.arrow_forward, size: 18),
- ],
+ children: [Text('Next'), SizedBox(width: 8), Icon(Icons.arrow_forward, size: 18)],
),
)
else
FilledButton(
- onPressed: _selectedDirectory != null && !_isLoading
- ? _completeSetup
- : null,
+ onPressed: _selectedDirectory != null && !_isLoading ? _completeSetup : null,
child: _isLoading
- ? SizedBox(
- width: 20,
- height: 20,
- child: CircularProgressIndicator(
- strokeWidth: 2,
- color: colorScheme.onPrimary,
- ),
- )
+ ? SizedBox(width: 20, height: 20,
+ child: CircularProgressIndicator(strokeWidth: 2, color: colorScheme.onPrimary))
: const Row(
mainAxisSize: MainAxisSize.min,
- children: [
- Text('Get Started'),
- SizedBox(width: 8),
- Icon(Icons.check, size: 18),
- ],
+ children: [Text('Get Started'), SizedBox(width: 8), Icon(Icons.check, size: 18)],
),
),
],
diff --git a/lib/screens/track_metadata_screen.dart b/lib/screens/track_metadata_screen.dart
index ee0e7e17..9c6320d7 100644
--- a/lib/screens/track_metadata_screen.dart
+++ b/lib/screens/track_metadata_screen.dart
@@ -9,23 +9,47 @@ import 'package:spotiflac_android/providers/download_queue_provider.dart';
/// Screen to display detailed metadata for a downloaded track
/// Designed with Material Expressive 3 style
-class TrackMetadataScreen extends ConsumerWidget {
+class TrackMetadataScreen extends ConsumerStatefulWidget {
final DownloadHistoryItem item;
const TrackMetadataScreen({super.key, required this.item});
@override
- Widget build(BuildContext context, WidgetRef ref) {
- final colorScheme = Theme.of(context).colorScheme;
- final fileExists = File(item.filePath).existsSync();
-
- // Get file info
- int? fileSize;
- if (fileExists) {
+ ConsumerState createState() => _TrackMetadataScreenState();
+}
+
+class _TrackMetadataScreenState extends ConsumerState {
+ bool _fileExists = false;
+ int? _fileSize;
+
+ @override
+ void initState() {
+ super.initState();
+ _checkFile();
+ }
+
+ Future _checkFile() async {
+ final file = File(widget.item.filePath);
+ final exists = await file.exists();
+ int? size;
+ if (exists) {
try {
- fileSize = File(item.filePath).lengthSync();
+ size = await file.length();
} catch (_) {}
}
+ if (mounted) {
+ setState(() {
+ _fileExists = exists;
+ _fileSize = size;
+ });
+ }
+ }
+
+ DownloadHistoryItem get item => widget.item;
+
+ @override
+ Widget build(BuildContext context) {
+ final colorScheme = Theme.of(context).colorScheme;
return Scaffold(
body: CustomScrollView(
@@ -77,22 +101,22 @@ class TrackMetadataScreen extends ConsumerWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Track info card
- _buildTrackInfoCard(context, colorScheme, fileExists),
+ _buildTrackInfoCard(context, colorScheme, _fileExists),
const SizedBox(height: 16),
// Metadata card
- _buildMetadataCard(context, colorScheme, fileSize),
+ _buildMetadataCard(context, colorScheme, _fileSize),
const SizedBox(height: 16),
// File info card
- _buildFileInfoCard(context, colorScheme, fileExists, fileSize),
+ _buildFileInfoCard(context, colorScheme, _fileExists, _fileSize),
const SizedBox(height: 24),
// Action buttons
- _buildActionButtons(context, ref, colorScheme, fileExists),
+ _buildActionButtons(context, ref, colorScheme, _fileExists),
const SizedBox(height: 32),
],
diff --git a/lib/services/notification_service.dart b/lib/services/notification_service.dart
new file mode 100644
index 00000000..0e610203
--- /dev/null
+++ b/lib/services/notification_service.dart
@@ -0,0 +1,185 @@
+import 'dart:io';
+import 'package:flutter_local_notifications/flutter_local_notifications.dart';
+
+class NotificationService {
+ static final NotificationService _instance = NotificationService._internal();
+ factory NotificationService() => _instance;
+ NotificationService._internal();
+
+ final FlutterLocalNotificationsPlugin _notifications = FlutterLocalNotificationsPlugin();
+ bool _isInitialized = false;
+
+ static const int downloadProgressId = 1;
+ static const String channelId = 'download_progress';
+ static const String channelName = 'Download Progress';
+ static const String channelDescription = 'Shows download progress for tracks';
+
+ Future initialize() async {
+ if (_isInitialized) return;
+
+ const androidSettings = AndroidInitializationSettings('@mipmap/ic_launcher');
+ const iosSettings = DarwinInitializationSettings(
+ requestAlertPermission: true,
+ requestBadgePermission: true,
+ requestSoundPermission: false,
+ );
+
+ const initSettings = InitializationSettings(
+ android: androidSettings,
+ iOS: iosSettings,
+ );
+
+ await _notifications.initialize(initSettings);
+
+ // Create notification channel for Android
+ if (Platform.isAndroid) {
+ await _notifications
+ .resolvePlatformSpecificImplementation()
+ ?.createNotificationChannel(
+ const AndroidNotificationChannel(
+ channelId,
+ channelName,
+ description: channelDescription,
+ importance: Importance.low,
+ showBadge: false,
+ playSound: false,
+ enableVibration: false,
+ ),
+ );
+ }
+
+ _isInitialized = true;
+ }
+
+ Future showDownloadProgress({
+ required String trackName,
+ required String artistName,
+ required int progress,
+ required int total,
+ }) async {
+ if (!_isInitialized) await initialize();
+
+ final percentage = total > 0 ? (progress * 100 ~/ total) : 0;
+
+ final androidDetails = AndroidNotificationDetails(
+ channelId,
+ channelName,
+ channelDescription: channelDescription,
+ importance: Importance.low,
+ priority: Priority.low,
+ showProgress: true,
+ maxProgress: 100,
+ progress: percentage,
+ 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,
+ 'Downloading $trackName',
+ '$artistName • $percentage%',
+ details,
+ );
+ }
+
+ Future showDownloadComplete({
+ required String trackName,
+ required String artistName,
+ int? completedCount,
+ int? totalCount,
+ }) async {
+ if (!_isInitialized) await initialize();
+
+ final title = completedCount != null && totalCount != null
+ ? 'Download Complete ($completedCount/$totalCount)'
+ : 'Download Complete';
+
+ const androidDetails = AndroidNotificationDetails(
+ channelId,
+ channelName,
+ channelDescription: channelDescription,
+ importance: Importance.defaultImportance,
+ priority: Priority.defaultPriority,
+ autoCancel: true,
+ playSound: false,
+ icon: '@mipmap/ic_launcher',
+ );
+
+ const iosDetails = DarwinNotificationDetails(
+ presentAlert: true,
+ presentBadge: true,
+ presentSound: false,
+ );
+
+ const details = NotificationDetails(
+ android: androidDetails,
+ iOS: iosDetails,
+ );
+
+ await _notifications.show(
+ downloadProgressId,
+ title,
+ '$trackName - $artistName',
+ details,
+ );
+ }
+
+ Future showQueueComplete({
+ required int completedCount,
+ required int failedCount,
+ }) async {
+ if (!_isInitialized) await initialize();
+
+ final title = failedCount > 0
+ ? 'Downloads Finished ($completedCount done, $failedCount failed)'
+ : 'All Downloads Complete';
+
+ const androidDetails = AndroidNotificationDetails(
+ channelId,
+ channelName,
+ channelDescription: channelDescription,
+ importance: Importance.defaultImportance,
+ priority: Priority.defaultPriority,
+ autoCancel: true,
+ playSound: true,
+ icon: '@mipmap/ic_launcher',
+ );
+
+ const iosDetails = DarwinNotificationDetails(
+ presentAlert: true,
+ presentBadge: true,
+ presentSound: true,
+ );
+
+ const details = NotificationDetails(
+ android: androidDetails,
+ iOS: iosDetails,
+ );
+
+ await _notifications.show(
+ downloadProgressId,
+ title,
+ '$completedCount tracks downloaded successfully',
+ details,
+ );
+ }
+
+ Future cancelDownloadNotification() async {
+ await _notifications.cancel(downloadProgressId);
+ }
+}
diff --git a/lib/services/platform_bridge.dart b/lib/services/platform_bridge.dart
index c3fe92e1..911cf8c1 100644
--- a/lib/services/platform_bridge.dart
+++ b/lib/services/platform_bridge.dart
@@ -54,6 +54,7 @@ class PlatformBridge {
int discNumber = 1,
int totalTracks = 1,
String? releaseDate,
+ String? itemId,
}) async {
final request = jsonEncode({
'isrc': isrc,
@@ -73,6 +74,7 @@ class PlatformBridge {
'disc_number': discNumber,
'total_tracks': totalTracks,
'release_date': releaseDate ?? '',
+ 'item_id': itemId ?? '',
});
final result = await _channel.invokeMethod('downloadTrack', request);
@@ -98,6 +100,7 @@ class PlatformBridge {
int totalTracks = 1,
String? releaseDate,
String preferredService = 'tidal',
+ String? itemId,
}) async {
final request = jsonEncode({
'isrc': isrc,
@@ -117,18 +120,40 @@ class PlatformBridge {
'disc_number': discNumber,
'total_tracks': totalTracks,
'release_date': releaseDate ?? '',
+ 'item_id': itemId ?? '',
});
final result = await _channel.invokeMethod('downloadWithFallback', request);
return jsonDecode(result as String) as Map;
}
- /// Get download progress
+ /// Get download progress (legacy single download)
static Future