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> getDownloadProgress() async { final result = await _channel.invokeMethod('getDownloadProgress'); return jsonDecode(result as String) as Map; } + /// Get progress for all active downloads (concurrent mode) + static Future> getAllDownloadProgress() async { + final result = await _channel.invokeMethod('getAllDownloadProgress'); + return jsonDecode(result as String) as Map; + } + + /// Initialize progress tracking for a download item + static Future initItemProgress(String itemId) async { + await _channel.invokeMethod('initItemProgress', {'item_id': itemId}); + } + + /// Finish progress tracking for a download item + static Future finishItemProgress(String itemId) async { + await _channel.invokeMethod('finishItemProgress', {'item_id': itemId}); + } + + /// Clear progress tracking for a download item + static Future clearItemProgress(String itemId) async { + await _channel.invokeMethod('clearItemProgress', {'item_id': itemId}); + } + /// Set download directory static Future setDownloadDirectory(String path) async { await _channel.invokeMethod('setDownloadDirectory', {'path': path}); diff --git a/lib/widgets/collapsing_header.dart b/lib/widgets/collapsing_header.dart new file mode 100644 index 00000000..175fe51d --- /dev/null +++ b/lib/widgets/collapsing_header.dart @@ -0,0 +1,156 @@ +import 'package:flutter/material.dart'; + +/// A collapsing header widget +/// Title collapses from large to small when scrolling +class CollapsingHeader extends StatelessWidget { + final String title; + final bool showBackButton; + final Widget? infoCard; + final List slivers; + + const CollapsingHeader({ + super.key, + required this.title, + this.showBackButton = false, + this.infoCard, + required this.slivers, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final topPadding = MediaQuery.of(context).padding.top; + + return CustomScrollView( + slivers: [ + SliverAppBar( + expandedHeight: 140, + floating: false, + pinned: true, + backgroundColor: colorScheme.surface, + surfaceTintColor: Colors.transparent, + leading: showBackButton + ? IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => Navigator.pop(context), + ) + : null, + automaticallyImplyLeading: false, + flexibleSpace: LayoutBuilder( + builder: (context, constraints) { + final expandRatio = _calculateExpandRatio(constraints, topPadding); + final animation = AlwaysStoppedAnimation(expandRatio); + + return FlexibleSpaceBar( + expandedTitleScale: 1.0, + titlePadding: EdgeInsets.zero, + title: Container( + alignment: Alignment.bottomLeft, + padding: EdgeInsets.only( + left: Tween(begin: showBackButton ? 56 : 24, end: 24).evaluate(animation), + bottom: Tween(begin: 16, end: 24).evaluate(animation), + ), + child: Text( + title, + style: TextStyle( + fontSize: Tween(begin: 20, end: 28).evaluate(animation), + fontWeight: FontWeight.bold, + color: colorScheme.onSurface, + ), + ), + ), + ); + }, + ), + ), + + // Info card if provided + if (infoCard != null) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), + child: infoCard, + ), + ), + + // Content slivers + ...slivers, + ], + ); + } + + double _calculateExpandRatio(BoxConstraints constraints, double topPadding) { + final maxHeight = 140; + final minHeight = kToolbarHeight + topPadding; + final currentHeight = constraints.maxHeight; + final expandRatio = (currentHeight - minHeight) / (maxHeight - minHeight); + return expandRatio.clamp(0.0, 1.0); + } +} + +/// Section header for settings +class SettingsSection extends StatelessWidget { + final String title; + const SettingsSection({super.key, required this.title}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), + child: Text( + title, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + color: Theme.of(context).colorScheme.primary, + fontWeight: FontWeight.w600, + ), + ), + ); + } +} + +/// Info card widget (like version info) +class InfoCard extends StatelessWidget { + final IconData icon; + final String title; + final String subtitle; + final VoidCallback? onTap; + + const InfoCard({ + super.key, + required this.icon, + required this.title, + required this.subtitle, + this.onTap, + }); + + @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: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(16), + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + Icon(icon, color: colorScheme.onSurfaceVariant), + const SizedBox(width: 16), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: Theme.of(context).textTheme.bodyLarge), + Text(subtitle, style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant)), + ], + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/widgets/update_dialog.dart b/lib/widgets/update_dialog.dart index 561c3720..64715022 100644 --- a/lib/widgets/update_dialog.dart +++ b/lib/widgets/update_dialog.dart @@ -127,21 +127,82 @@ class UpdateDialog extends StatelessWidget { ); } - /// Format changelog - clean up markdown + /// Format changelog - clean up markdown and extract relevant content String _formatChangelog(String changelog) { - // Remove markdown headers but keep content - var formatted = changelog - .replaceAll(RegExp(r'^#{1,6}\s*', multiLine: true), '') - .replaceAll(RegExp(r'\*\*([^*]+)\*\*'), r'$1') // Remove bold - .replaceAll(RegExp(r'`([^`]+)`'), r'$1') // Remove code - .trim(); + // Try to extract just the changelog section (between "What's New" and "Downloads" or "---") + var content = changelog; - // Limit length - if (formatted.length > 1000) { - formatted = '${formatted.substring(0, 1000)}...'; + // Find content after "What's New" header + final whatsNewMatch = RegExp(r"###?\s*What'?s\s*New\s*\n", caseSensitive: false).firstMatch(content); + if (whatsNewMatch != null) { + content = content.substring(whatsNewMatch.end); } - return formatted; + // Cut off at "Downloads" section or horizontal rule + final cutoffMatch = RegExp(r'\n---|\n###?\s*Downloads', caseSensitive: false).firstMatch(content); + if (cutoffMatch != null) { + content = content.substring(0, cutoffMatch.start); + } + + // Process line by line for better formatting + final lines = content.split('\n'); + final formattedLines = []; + String? currentSection; + + for (var line in lines) { + line = line.trim(); + if (line.isEmpty) continue; + + // Check if it's a section header (### Added, ### Fixed, etc.) + final sectionMatch = RegExp(r'^#{1,3}\s*(.+)$').firstMatch(line); + if (sectionMatch != null) { + currentSection = sectionMatch.group(1)?.trim(); + if (currentSection != null && currentSection.isNotEmpty) { + if (formattedLines.isNotEmpty) formattedLines.add(''); + formattedLines.add('$currentSection:'); + } + continue; + } + + // Check if it's a list item + final listMatch = RegExp(r'^[-*]\s+(.+)$').firstMatch(line); + if (listMatch != null) { + var itemText = listMatch.group(1) ?? ''; + // Remove bold markdown + itemText = itemText.replaceAllMapped( + RegExp(r'\*\*([^*]+)\*\*'), + (m) => m.group(1) ?? '' + ); + // Remove code markdown + itemText = itemText.replaceAllMapped( + RegExp(r'`([^`]+)`'), + (m) => m.group(1) ?? '' + ); + formattedLines.add('• $itemText'); + continue; + } + + // Check if it's a sub-item (indented list) + final subListMatch = RegExp(r'^\s+[-*]\s+(.+)$').firstMatch(line); + if (subListMatch != null) { + var itemText = subListMatch.group(1) ?? ''; + itemText = itemText.replaceAllMapped( + RegExp(r'\*\*([^*]+)\*\*'), + (m) => m.group(1) ?? '' + ); + formattedLines.add(' - $itemText'); + continue; + } + } + + var formatted = formattedLines.join('\n').trim(); + + // Limit length + if (formatted.length > 2000) { + formatted = '${formatted.substring(0, 2000)}...'; + } + + return formatted.isEmpty ? 'See release notes for details.' : formatted; } } diff --git a/pubspec.lock b/pubspec.lock index 7937d2f2..1f25ea5a 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -382,6 +382,30 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.0" + flutter_local_notifications: + dependency: "direct main" + description: + name: flutter_local_notifications + sha256: ef41ae901e7529e52934feba19ed82827b11baa67336829564aeab3129460610 + url: "https://pub.dev" + source: hosted + version: "18.0.1" + flutter_local_notifications_linux: + dependency: transitive + description: + name: flutter_local_notifications_linux + sha256: "8f685642876742c941b29c32030f6f4f6dacd0e4eaecb3efbb187d6a3812ca01" + url: "https://pub.dev" + source: hosted + version: "5.0.0" + flutter_local_notifications_platform_interface: + dependency: transitive + description: + name: flutter_local_notifications_platform_interface + sha256: "6c5b83c86bf819cdb177a9247a3722067dd8cc6313827ce7c77a4b238a26fd52" + url: "https://pub.dev" + source: hosted + version: "8.0.0" flutter_plugin_android_lifecycle: dependency: transitive description: @@ -1109,6 +1133,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.6.12" + timezone: + dependency: transitive + description: + name: timezone + sha256: dd14a3b83cfd7cb19e7888f1cbc20f258b8d71b54c06f79ac585f14093a287d1 + url: "https://pub.dev" + source: hosted + version: "0.10.1" timing: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 21e5e035..cf6904c2 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: spotiflac_android description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music publish_to: 'none' -version: 1.2.0+10 +version: 1.5.0+14 environment: sdk: ^3.10.0 @@ -51,6 +51,9 @@ dependencies: # FFmpeg for audio conversion (audio-only version - much smaller) ffmpeg_kit_flutter_new_audio: ^2.0.0 open_filex: ^4.7.0 + + # Notifications + flutter_local_notifications: ^18.0.1 dev_dependencies: flutter_test: