From 3e841cef06d7a5e0aa4727fa69ee60ed661ec791 Mon Sep 17 00:00:00 2001 From: zarzet Date: Mon, 5 Jan 2026 10:30:57 +0700 Subject: [PATCH] fix: duration display, audio quality from file, artist verification, metadata case-sensitivity, settings navigation freeze - Fix duration showing incorrect values (ms to seconds conversion) - Read audio quality from FLAC file instead of trusting API - Add artist verification for Tidal/Qobuz/Amazon downloads - Fix FLAC metadata case-insensitive replacement - Fix settings navigation freeze on Android 14+ (PopScope handling) --- CHANGELOG.md | 19 ++ go_backend/amazon.go | 66 +++++++ go_backend/exports.go | 59 +++++- go_backend/metadata.go | 13 +- go_backend/qobuz.go | 87 ++++++-- go_backend/tidal.go | 150 +++++++++++--- lib/providers/track_provider.dart | 4 +- lib/screens/album_screen.dart | 2 +- lib/screens/settings/about_page.dart | 82 ++++---- .../settings/appearance_settings_page.dart | 187 +++++++++--------- .../settings/download_settings_page.dart | 71 ++++--- .../settings/options_settings_page.dart | 71 ++++--- 12 files changed, 550 insertions(+), 261 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 85122c8f..338d849d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,24 @@ # Changelog +## [2.0.6] - 2026-01-05 + +### Fixed +- **Duration Display Bug**: Fixed duration showing incorrect values like "4135:53" instead of "4:14" + - `duration_ms` (milliseconds) was being stored directly without conversion to seconds + - Now properly converts milliseconds to seconds before display +- **Audio Quality from File**: Quality info (bit depth/sample rate) now read from actual FLAC file instead of trusting API + - More accurate quality display for all services (Tidal, Qobuz, Amazon) + - Also reads quality from existing files when skipping duplicates +- **Artist Verification for Downloads**: Added artist name verification to prevent downloading wrong tracks + - Verifies artist matches between Spotify metadata and streaming service + - Handles different scripts (Japanese/Chinese vs Latin) as same artist with different transliteration + - Applied to Tidal, Qobuz, and Amazon downloads +- **Metadata Case-Sensitivity**: Fixed FLAC metadata not being properly overwritten when downloaded file has lowercase tags + - Now uses case-insensitive comparison when replacing existing Vorbis comments + - Fixes issue where Amazon downloads could have duplicate metadata tags +- **Settings Navigation Freeze**: Fixed app freezing when navigating back from settings sub-menus on some devices (e.g., OnePlus Nord 2T 5G) + - Added proper PopScope handling for predictive back gesture on Android 14+ + ## [2.0.5] - 2026-01-05 ### Added diff --git a/go_backend/amazon.go b/go_backend/amazon.go index af83f15c..163984f4 100644 --- a/go_backend/amazon.go +++ b/go_backend/amazon.go @@ -36,6 +36,63 @@ type DoubleDoubleStatusResponse struct { } `json:"current"` } +// amazonArtistsMatch checks if the artist names are similar enough +func amazonArtistsMatch(expectedArtist, foundArtist string) bool { + normExpected := strings.ToLower(strings.TrimSpace(expectedArtist)) + normFound := strings.ToLower(strings.TrimSpace(foundArtist)) + + // Exact match + if normExpected == normFound { + return true + } + + // Check if one contains the other + if strings.Contains(normExpected, normFound) || strings.Contains(normFound, normExpected) { + return true + } + + // Check first artist (before comma or feat) + expectedFirst := strings.Split(normExpected, ",")[0] + expectedFirst = strings.Split(expectedFirst, " feat")[0] + expectedFirst = strings.Split(expectedFirst, " ft.")[0] + expectedFirst = strings.TrimSpace(expectedFirst) + + foundFirst := strings.Split(normFound, ",")[0] + foundFirst = strings.Split(foundFirst, " feat")[0] + foundFirst = strings.Split(foundFirst, " ft.")[0] + foundFirst = strings.TrimSpace(foundFirst) + + if expectedFirst == foundFirst { + return true + } + + // Check if first artist is contained in the other + if strings.Contains(expectedFirst, foundFirst) || strings.Contains(foundFirst, expectedFirst) { + return true + } + + // If scripts are different (one is ASCII, one is non-ASCII like Japanese/Chinese/Korean), + // assume they're the same artist with different transliteration + expectedASCII := amazonIsASCIIString(expectedArtist) + foundASCII := amazonIsASCIIString(foundArtist) + if expectedASCII != foundASCII { + fmt.Printf("[Amazon] Artist names in different scripts, assuming match: '%s' vs '%s'\n", expectedArtist, foundArtist) + return true + } + + return false +} + +// amazonIsASCIIString checks if a string contains only ASCII characters +func amazonIsASCIIString(s string) bool { + for _, r := range s { + if r > 127 { + return false + } + } + return true +} + // NewAmazonDownloader creates a new Amazon downloader using DoubleDouble service func NewAmazonDownloader() *AmazonDownloader { return &AmazonDownloader{ @@ -295,6 +352,15 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) { return AmazonDownloadResult{}, fmt.Errorf("failed to get download URL: %w", err) } + // Verify artist matches + if artistName != "" && !amazonArtistsMatch(req.ArtistName, artistName) { + fmt.Printf("[Amazon] Artist mismatch: expected '%s', got '%s'. Rejecting.\n", req.ArtistName, artistName) + return AmazonDownloadResult{}, fmt.Errorf("artist mismatch: expected '%s', got '%s'", req.ArtistName, artistName) + } + + // Log match found + fmt.Printf("[Amazon] Match found: '%s' by '%s'\n", trackName, artistName) + // Build filename using Spotify metadata (more accurate) filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{ "title": req.TrackName, diff --git a/go_backend/exports.go b/go_backend/exports.go index b8971001..bf42f4c8 100644 --- a/go_backend/exports.go +++ b/go_backend/exports.go @@ -5,6 +5,7 @@ package gobackend import ( "context" "encoding/json" + "fmt" "strings" "time" ) @@ -216,17 +217,36 @@ func DownloadTrack(requestJSON string) (string, error) { // Check if file already exists if len(result.FilePath) > 7 && result.FilePath[:7] == "EXISTS:" { + actualPath := result.FilePath[7:] + // Read actual quality from existing file + quality, qErr := GetAudioQuality(actualPath) + if qErr == nil { + result.BitDepth = quality.BitDepth + result.SampleRate = quality.SampleRate + } resp := DownloadResponse{ - Success: true, - Message: "File already exists", - FilePath: result.FilePath[7:], - AlreadyExists: true, - Service: req.Service, + Success: true, + Message: "File already exists", + FilePath: actualPath, + AlreadyExists: true, + ActualBitDepth: result.BitDepth, + ActualSampleRate: result.SampleRate, + Service: req.Service, } jsonBytes, _ := json.Marshal(resp) return string(jsonBytes), nil } + // Read actual quality from downloaded file (more accurate than API) + quality, qErr := GetAudioQuality(result.FilePath) + if qErr == nil { + result.BitDepth = quality.BitDepth + result.SampleRate = quality.SampleRate + fmt.Printf("[Download] Actual quality from file: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate) + } else { + fmt.Printf("[Download] Could not read quality from file: %v\n", qErr) + } + resp := DownloadResponse{ Success: true, Message: "Download complete", @@ -314,17 +334,36 @@ func DownloadWithFallback(requestJSON string) (string, error) { if err == nil { // Check if file already exists if len(result.FilePath) > 7 && result.FilePath[:7] == "EXISTS:" { + actualPath := result.FilePath[7:] + // Read actual quality from existing file + quality, qErr := GetAudioQuality(actualPath) + if qErr == nil { + result.BitDepth = quality.BitDepth + result.SampleRate = quality.SampleRate + } resp := DownloadResponse{ - Success: true, - Message: "File already exists", - FilePath: result.FilePath[7:], - AlreadyExists: true, - Service: service, + Success: true, + Message: "File already exists", + FilePath: actualPath, + AlreadyExists: true, + ActualBitDepth: result.BitDepth, + ActualSampleRate: result.SampleRate, + Service: service, } jsonBytes, _ := json.Marshal(resp) return string(jsonBytes), nil } + // Read actual quality from downloaded file (more accurate than API) + quality, qErr := GetAudioQuality(result.FilePath) + if qErr == nil { + result.BitDepth = quality.BitDepth + result.SampleRate = quality.SampleRate + fmt.Printf("[Download] Actual quality from file: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate) + } else { + fmt.Printf("[Download] Could not read quality from file: %v\n", qErr) + } + resp := DownloadResponse{ Success: true, Message: "Downloaded from " + service, diff --git a/go_backend/metadata.go b/go_backend/metadata.go index a8537470..ff4a2f58 100644 --- a/go_backend/metadata.go +++ b/go_backend/metadata.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "strconv" + "strings" "github.com/go-flac/flacpicture" "github.com/go-flac/flacvorbis" @@ -273,10 +274,16 @@ func setComment(cmt *flacvorbis.MetaDataBlockVorbisComment, key, value string) { if value == "" { return } - // Remove existing + // Remove existing (case-insensitive comparison for Vorbis comments) + keyUpper := strings.ToUpper(key) for i := len(cmt.Comments) - 1; i >= 0; i-- { - if len(cmt.Comments[i]) > len(key)+1 && cmt.Comments[i][:len(key)+1] == key+"=" { - cmt.Comments = append(cmt.Comments[:i], cmt.Comments[i+1:]...) + comment := cmt.Comments[i] + eqIdx := strings.Index(comment, "=") + if eqIdx > 0 { + existingKey := strings.ToUpper(comment[:eqIdx]) + if existingKey == keyUpper { + cmt.Comments = append(cmt.Comments[:i], cmt.Comments[i+1:]...) + } } } // Add new diff --git a/go_backend/qobuz.go b/go_backend/qobuz.go index f3ebc802..9f860ebb 100644 --- a/go_backend/qobuz.go +++ b/go_backend/qobuz.go @@ -9,6 +9,7 @@ import ( "net/url" "os" "path/filepath" + "strings" ) // QobuzDownloader handles Qobuz downloads @@ -39,6 +40,63 @@ type QobuzTrack struct { } `json:"performer"` } +// qobuzArtistsMatch checks if the artist names are similar enough +func qobuzArtistsMatch(expectedArtist, foundArtist string) bool { + normExpected := strings.ToLower(strings.TrimSpace(expectedArtist)) + normFound := strings.ToLower(strings.TrimSpace(foundArtist)) + + // Exact match + if normExpected == normFound { + return true + } + + // Check if one contains the other + if strings.Contains(normExpected, normFound) || strings.Contains(normFound, normExpected) { + return true + } + + // Check first artist (before comma or feat) + expectedFirst := strings.Split(normExpected, ",")[0] + expectedFirst = strings.Split(expectedFirst, " feat")[0] + expectedFirst = strings.Split(expectedFirst, " ft.")[0] + expectedFirst = strings.TrimSpace(expectedFirst) + + foundFirst := strings.Split(normFound, ",")[0] + foundFirst = strings.Split(foundFirst, " feat")[0] + foundFirst = strings.Split(foundFirst, " ft.")[0] + foundFirst = strings.TrimSpace(foundFirst) + + if expectedFirst == foundFirst { + return true + } + + // Check if first artist is contained in the other + if strings.Contains(expectedFirst, foundFirst) || strings.Contains(foundFirst, expectedFirst) { + return true + } + + // If scripts are different (one is ASCII, one is non-ASCII like Japanese/Chinese/Korean), + // assume they're the same artist with different transliteration + expectedASCII := qobuzIsASCIIString(expectedArtist) + foundASCII := qobuzIsASCIIString(foundArtist) + if expectedASCII != foundASCII { + fmt.Printf("[Qobuz] Artist names in different scripts, assuming match: '%s' vs '%s'\n", expectedArtist, foundArtist) + return true + } + + return false +} + +// qobuzIsASCIIString checks if a string contains only ASCII characters +func qobuzIsASCIIString(s string) bool { + for _, r := range s { + if r > 127 { + return false + } + } + return true +} + // NewQobuzDownloader creates a new Qobuz downloader func NewQobuzDownloader() *QobuzDownloader { return &QobuzDownloader{ @@ -451,34 +509,35 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) { // Strategy 1: Search by ISRC with duration verification if req.ISRC != "" { track, err = downloader.SearchTrackByISRCWithDuration(req.ISRC, expectedDurationSec) + // Verify artist + if track != nil && !qobuzArtistsMatch(req.ArtistName, track.Performer.Name) { + fmt.Printf("[Qobuz] Artist mismatch from ISRC search: expected '%s', got '%s'. Rejecting.\n", + req.ArtistName, track.Performer.Name) + track = nil + } } // Strategy 2: Search by metadata with duration verification if track == nil { track, err = downloader.SearchTrackByMetadataWithDuration(req.TrackName, req.ArtistName, expectedDurationSec) + // Verify artist + if track != nil && !qobuzArtistsMatch(req.ArtistName, track.Performer.Name) { + fmt.Printf("[Qobuz] Artist mismatch from metadata search: expected '%s', got '%s'. Rejecting.\n", + req.ArtistName, track.Performer.Name) + track = nil + } } if track == nil { - errMsg := "could not find track on Qobuz" + errMsg := "could not find matching track on Qobuz (artist/duration mismatch)" if err != nil { errMsg = err.Error() } return QobuzDownloadResult{}, fmt.Errorf("qobuz search failed: %s", errMsg) } - // Final duration verification - if expectedDurationSec > 0 { - durationDiff := track.Duration - expectedDurationSec - if durationDiff < 0 { - durationDiff = -durationDiff - } - if durationDiff > 30 { - return QobuzDownloadResult{}, fmt.Errorf("duration mismatch: expected %ds, found %ds (diff: %ds). Track may be wrong version", - expectedDurationSec, track.Duration, durationDiff) - } - fmt.Printf("[Qobuz] Duration verified: expected %ds, found %ds (diff: %ds)\n", - expectedDurationSec, track.Duration, durationDiff) - } + // Log match found + fmt.Printf("[Qobuz] Match found: '%s' by '%s' (duration: %ds)\n", track.Title, track.Performer.Name, track.Duration) // Build filename filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{ diff --git a/go_backend/tidal.go b/go_backend/tidal.go index 95e92103..1c392c5e 100644 --- a/go_backend/tidal.go +++ b/go_backend/tidal.go @@ -869,6 +869,64 @@ type TidalDownloadResult struct { SampleRate int } +// artistsMatch checks if the artist names are similar enough +func artistsMatch(spotifyArtist, tidalArtist string) bool { + normSpotify := strings.ToLower(strings.TrimSpace(spotifyArtist)) + normTidal := strings.ToLower(strings.TrimSpace(tidalArtist)) + + // Exact match + if normSpotify == normTidal { + return true + } + + // Check if one contains the other (for cases like "Artist" vs "Artist feat. Someone") + if strings.Contains(normSpotify, normTidal) || strings.Contains(normTidal, normSpotify) { + return true + } + + // Check first artist (before comma or feat) + spotifyFirst := strings.Split(normSpotify, ",")[0] + spotifyFirst = strings.Split(spotifyFirst, " feat")[0] + spotifyFirst = strings.Split(spotifyFirst, " ft.")[0] + spotifyFirst = strings.TrimSpace(spotifyFirst) + + tidalFirst := strings.Split(normTidal, ",")[0] + tidalFirst = strings.Split(tidalFirst, " feat")[0] + tidalFirst = strings.Split(tidalFirst, " ft.")[0] + tidalFirst = strings.TrimSpace(tidalFirst) + + if spotifyFirst == tidalFirst { + return true + } + + // Check if first artist is contained in the other + if strings.Contains(spotifyFirst, tidalFirst) || strings.Contains(tidalFirst, spotifyFirst) { + return true + } + + // If scripts are different (one is ASCII, one is non-ASCII like Japanese/Chinese/Korean), + // assume they're the same artist with different transliteration + // This handles cases like "鈴木雅之" vs "Masayuki Suzuki" + spotifyASCII := isASCIIString(spotifyArtist) + tidalASCII := isASCIIString(tidalArtist) + if spotifyASCII != tidalASCII { + fmt.Printf("[Tidal] Artist names in different scripts, assuming match: '%s' vs '%s'\n", spotifyArtist, tidalArtist) + return true + } + + return false +} + +// isASCIIString checks if a string contains only ASCII characters +func isASCIIString(s string) bool { + for _, r := range s { + if r > 127 { + return false + } + } + return true +} + // downloadFromTidal downloads a track using the request parameters func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { downloader := NewTidalDownloader() @@ -892,17 +950,36 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { trackID, idErr := downloader.GetTrackIDFromURL(tidalURL) if idErr == nil { track, err = downloader.GetTrackInfoByID(trackID) - // Verify duration if we have expected duration - if track != nil && expectedDurationSec > 0 { - durationDiff := track.Duration - expectedDurationSec - if durationDiff < 0 { - durationDiff = -durationDiff + if track != nil { + // Get artist name from track + tidalArtist := track.Artist.Name + if len(track.Artists) > 0 { + var artistNames []string + for _, a := range track.Artists { + artistNames = append(artistNames, a.Name) + } + tidalArtist = strings.Join(artistNames, ", ") } - // Allow 30 seconds tolerance - if durationDiff > 30 { - fmt.Printf("[Tidal] Duration mismatch from SongLink: expected %ds, got %ds. Rejecting.\n", - expectedDurationSec, track.Duration) - track = nil // Reject this match + + // Verify artist matches + if !artistsMatch(req.ArtistName, tidalArtist) { + fmt.Printf("[Tidal] Artist mismatch from SongLink: expected '%s', got '%s'. Rejecting.\n", + req.ArtistName, tidalArtist) + track = nil + } + + // Verify duration if we have expected duration + if track != nil && expectedDurationSec > 0 { + durationDiff := track.Duration - expectedDurationSec + if durationDiff < 0 { + durationDiff = -durationDiff + } + // Allow 30 seconds tolerance + if durationDiff > 30 { + fmt.Printf("[Tidal] Duration mismatch from SongLink: expected %ds, got %ds. Rejecting.\n", + expectedDurationSec, track.Duration) + track = nil // Reject this match + } } } } @@ -912,34 +989,63 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { // Strategy 2: Search by ISRC with duration verification if track == nil && req.ISRC != "" { track, err = downloader.SearchTrackByMetadataWithISRC(req.TrackName, req.ArtistName, req.ISRC, expectedDurationSec) + // Verify artist for ISRC match too + if track != nil { + tidalArtist := track.Artist.Name + if len(track.Artists) > 0 { + var artistNames []string + for _, a := range track.Artists { + artistNames = append(artistNames, a.Name) + } + tidalArtist = strings.Join(artistNames, ", ") + } + if !artistsMatch(req.ArtistName, tidalArtist) { + fmt.Printf("[Tidal] Artist mismatch from ISRC search: expected '%s', got '%s'. Rejecting.\n", + req.ArtistName, tidalArtist) + track = nil + } + } } // Strategy 3: Search by metadata only (no ISRC requirement) if track == nil { track, err = downloader.SearchTrackByMetadataWithISRC(req.TrackName, req.ArtistName, "", expectedDurationSec) + // Verify artist for metadata search too + if track != nil { + tidalArtist := track.Artist.Name + if len(track.Artists) > 0 { + var artistNames []string + for _, a := range track.Artists { + artistNames = append(artistNames, a.Name) + } + tidalArtist = strings.Join(artistNames, ", ") + } + if !artistsMatch(req.ArtistName, tidalArtist) { + fmt.Printf("[Tidal] Artist mismatch from metadata search: expected '%s', got '%s'. Rejecting.\n", + req.ArtistName, tidalArtist) + track = nil + } + } } if track == nil { - errMsg := "could not find track on Tidal" + errMsg := "could not find matching track on Tidal (artist/duration mismatch)" if err != nil { errMsg = err.Error() } return TidalDownloadResult{}, fmt.Errorf("tidal search failed: %s", errMsg) } - // Final duration verification - if expectedDurationSec > 0 { - durationDiff := track.Duration - expectedDurationSec - if durationDiff < 0 { - durationDiff = -durationDiff + // Final verification logging + tidalArtist := track.Artist.Name + if len(track.Artists) > 0 { + var artistNames []string + for _, a := range track.Artists { + artistNames = append(artistNames, a.Name) } - if durationDiff > 30 { - return TidalDownloadResult{}, fmt.Errorf("duration mismatch: expected %ds, found %ds (diff: %ds). Track may be wrong version", - expectedDurationSec, track.Duration, durationDiff) - } - fmt.Printf("[Tidal] Duration verified: expected %ds, found %ds (diff: %ds)\n", - expectedDurationSec, track.Duration, durationDiff) + tidalArtist = strings.Join(artistNames, ", ") } + fmt.Printf("[Tidal] Match found: '%s' by '%s' (duration: %ds)\n", track.Title, tidalArtist, track.Duration) // Build filename filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{ diff --git a/lib/providers/track_provider.dart b/lib/providers/track_provider.dart index c8aa9a9d..af6f48ba 100644 --- a/lib/providers/track_provider.dart +++ b/lib/providers/track_provider.dart @@ -266,7 +266,7 @@ class TrackNotifier extends Notifier { albumArtist: data['album_artist'] as String?, coverUrl: data['images'] as String?, isrc: data['isrc'] as String?, - duration: data['duration_ms'] as int? ?? 0, + duration: ((data['duration_ms'] as int? ?? 0) / 1000).round(), trackNumber: data['track_number'] as int?, discNumber: data['disc_number'] as int?, releaseDate: data['release_date'] as String?, @@ -282,7 +282,7 @@ class TrackNotifier extends Notifier { albumArtist: data['album_artist'] as String?, coverUrl: data['images'] as String?, isrc: data['isrc'] as String?, - duration: data['duration_ms'] as int? ?? 0, + duration: ((data['duration_ms'] as int? ?? 0) / 1000).round(), trackNumber: data['track_number'] as int?, discNumber: data['disc_number'] as int?, releaseDate: data['release_date'] as String?, diff --git a/lib/screens/album_screen.dart b/lib/screens/album_screen.dart index ec3ff300..452d8ba5 100644 --- a/lib/screens/album_screen.dart +++ b/lib/screens/album_screen.dart @@ -104,7 +104,7 @@ class _AlbumScreenState extends ConsumerState { albumArtist: data['album_artist'] as String?, coverUrl: data['images'] as String?, isrc: data['isrc'] as String?, - duration: data['duration_ms'] as int? ?? 0, + duration: ((data['duration_ms'] as int? ?? 0) / 1000).round(), trackNumber: data['track_number'] as int?, discNumber: data['disc_number'] as int?, releaseDate: data['release_date'] as String?, diff --git a/lib/screens/settings/about_page.dart b/lib/screens/settings/about_page.dart index a05258e8..ca0c815f 100644 --- a/lib/screens/settings/about_page.dart +++ b/lib/screens/settings/about_page.dart @@ -12,53 +12,46 @@ class AboutPage extends StatelessWidget { 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( - // When collapsed (expandRatio=0): left=56 to align with back button - // When expanded (expandRatio=1): left=24 for normal padding - left: Tween(begin: 56, end: 24).evaluate(animation), - bottom: Tween(begin: 16, end: 16).evaluate(animation), - ), - child: Text( - 'About', - style: TextStyle( - fontSize: Tween(begin: 20, end: 28).evaluate(animation), - fontWeight: FontWeight.bold, - color: colorScheme.onSurface, - ), + return PopScope( + canPop: true, + child: 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); + // When collapsed (expandRatio=0): left=56 to avoid back button + // When expanded (expandRatio=1): left=24 for normal padding + final leftPadding = 56 - (32 * expandRatio); // 56 -> 24 + return FlexibleSpaceBar( + expandedTitleScale: 1.0, + titlePadding: EdgeInsets.only(left: leftPadding, bottom: 16), + title: Text( + 'About', + style: TextStyle( + fontSize: 20 + (8 * expandRatio), // 20 -> 28 + fontWeight: FontWeight.bold, + color: colorScheme.onSurface, ), ), - ), - ); - }, + ); + }, + ), ), - ), // App header card with logo and description SliverToBoxAdapter( @@ -166,6 +159,7 @@ class AboutPage extends StatelessWidget { const SliverToBoxAdapter(child: SizedBox(height: 16)), ], ), + ), ); } diff --git a/lib/screens/settings/appearance_settings_page.dart b/lib/screens/settings/appearance_settings_page.dart index 18d444f2..cef052ba 100644 --- a/lib/screens/settings/appearance_settings_page.dart +++ b/lib/screens/settings/appearance_settings_page.dart @@ -14,104 +14,113 @@ class AppearanceSettingsPage extends ConsumerWidget { 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: 16, end: 16).evaluate(animation), - ), - child: Text('Appearance', - style: TextStyle( - fontSize: Tween(begin: 20, end: 28).evaluate(animation), - fontWeight: FontWeight.bold, - color: colorScheme.onSurface, - ), - ), + return PopScope( + canPop: true, + child: 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: _AppBarTitle(title: 'Appearance', topPadding: topPadding), + ), + + // Theme section + const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'Theme')), + SliverToBoxAdapter( + child: SettingsGroup( + children: [ + _ThemeModeSelector( + currentMode: themeSettings.themeMode, + onChanged: (mode) => ref.read(themeProvider.notifier).setThemeMode(mode), + ), + ], + ), + ), + + // Color section + const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'Color')), + SliverToBoxAdapter( + child: SettingsGroup( + children: [ + SettingsSwitchItem( + icon: Icons.auto_awesome, + title: 'Dynamic Color', + subtitle: 'Use colors from your wallpaper', + value: themeSettings.useDynamicColor, + onChanged: (value) => ref.read(themeProvider.notifier).setUseDynamicColor(value), + showDivider: !themeSettings.useDynamicColor, + ), + if (!themeSettings.useDynamicColor) + _ColorPicker( + currentColor: themeSettings.seedColorValue, + onColorSelected: (color) => ref.read(themeProvider.notifier).setSeedColor(color), ), + ], + ), + ), + + // Layout section + const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'Layout')), + SliverToBoxAdapter( + child: SettingsGroup( + children: [ + _HistoryViewSelector( + currentMode: settings.historyViewMode, + onChanged: (mode) => ref.read(settingsProvider.notifier).setHistoryViewMode(mode), ), - ); - }, + ], + ), ), - ), - // Theme section - const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'Theme')), - SliverToBoxAdapter( - child: SettingsGroup( - children: [ - _ThemeModeSelector( - currentMode: themeSettings.themeMode, - onChanged: (mode) => ref.read(themeProvider.notifier).setThemeMode(mode), - ), - ], - ), - ), - - // Color section - const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'Color')), - SliverToBoxAdapter( - child: SettingsGroup( - children: [ - SettingsSwitchItem( - icon: Icons.auto_awesome, - title: 'Dynamic Color', - subtitle: 'Use colors from your wallpaper', - value: themeSettings.useDynamicColor, - onChanged: (value) => ref.read(themeProvider.notifier).setUseDynamicColor(value), - showDivider: !themeSettings.useDynamicColor, - ), - if (!themeSettings.useDynamicColor) - _ColorPicker( - currentColor: themeSettings.seedColorValue, - onColorSelected: (color) => ref.read(themeProvider.notifier).setSeedColor(color), - ), - ], - ), - ), - - // Layout section - const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'Layout')), - SliverToBoxAdapter( - child: SettingsGroup( - children: [ - _HistoryViewSelector( - currentMode: settings.historyViewMode, - onChanged: (mode) => ref.read(settingsProvider.notifier).setHistoryViewMode(mode), - ), - ], - ), - ), - - // Fill remaining for scroll - const SliverFillRemaining(hasScrollBody: false, child: SizedBox()), - ], + // Fill remaining for scroll + const SliverFillRemaining(hasScrollBody: false, child: SizedBox()), + ], + ), ), ); } } +/// Optimized app bar title with animation +class _AppBarTitle extends StatelessWidget { + final String title; + final double topPadding; + + const _AppBarTitle({required this.title, required this.topPadding}); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return 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 leftPadding = 56 - (32 * expandRatio); // 56 -> 24 + return FlexibleSpaceBar( + expandedTitleScale: 1.0, + titlePadding: EdgeInsets.only(left: leftPadding, bottom: 16), + title: Text( + title, + style: TextStyle( + fontSize: 20 + (8 * expandRatio), // 20 -> 28 + fontWeight: FontWeight.bold, + color: colorScheme.onSurface, + ), + ), + ); + }, + ); + } +} + class _ThemeModeSelector extends StatelessWidget { final ThemeMode currentMode; final ValueChanged onChanged; diff --git a/lib/screens/settings/download_settings_page.dart b/lib/screens/settings/download_settings_page.dart index 4e7c49de..4630139d 100644 --- a/lib/screens/settings/download_settings_page.dart +++ b/lib/screens/settings/download_settings_page.dart @@ -13,47 +13,41 @@ class DownloadSettingsPage extends ConsumerWidget { 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: 16, end: 16).evaluate(animation), - ), - child: Text('Download', - style: TextStyle( - fontSize: Tween(begin: 20, end: 28).evaluate(animation), - fontWeight: FontWeight.bold, - color: colorScheme.onSurface, - ), + return PopScope( + canPop: true, + child: 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 leftPadding = 56 - (32 * expandRatio); // 56 -> 24 + return FlexibleSpaceBar( + expandedTitleScale: 1.0, + titlePadding: EdgeInsets.only(left: leftPadding, bottom: 16), + title: Text( + 'Download', + style: TextStyle( + fontSize: 20 + (8 * expandRatio), // 20 -> 28 + fontWeight: FontWeight.bold, + color: colorScheme.onSurface, ), ), - ), - ); - }, + ); + }, + ), ), - ), // Service section const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'Service')), @@ -136,6 +130,7 @@ class DownloadSettingsPage extends ConsumerWidget { const SliverToBoxAdapter(child: SizedBox(height: 32)), ], ), + ), ); } diff --git a/lib/screens/settings/options_settings_page.dart b/lib/screens/settings/options_settings_page.dart index 30934225..4a634c9f 100644 --- a/lib/screens/settings/options_settings_page.dart +++ b/lib/screens/settings/options_settings_page.dart @@ -14,47 +14,41 @@ class OptionsSettingsPage extends ConsumerWidget { 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: 16, end: 16).evaluate(animation), - ), - child: Text('Options', - style: TextStyle( - fontSize: Tween(begin: 20, end: 28).evaluate(animation), - fontWeight: FontWeight.bold, - color: colorScheme.onSurface, - ), + return PopScope( + canPop: true, + child: 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 leftPadding = 56 - (32 * expandRatio); // 56 -> 24 + return FlexibleSpaceBar( + expandedTitleScale: 1.0, + titlePadding: EdgeInsets.only(left: leftPadding, bottom: 16), + title: Text( + 'Options', + style: TextStyle( + fontSize: 20 + (8 * expandRatio), // 20 -> 28 + fontWeight: FontWeight.bold, + color: colorScheme.onSurface, ), ), - ), - ); - }, + ); + }, + ), ), - ), // Download options section const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'Download')), @@ -168,6 +162,7 @@ class OptionsSettingsPage extends ConsumerWidget { const SliverToBoxAdapter(child: SizedBox(height: 32)), ], ), + ), ); }