diff --git a/go_backend/deezer_download.go b/go_backend/deezer_download.go index fdd0850d..8e76ee91 100644 --- a/go_backend/deezer_download.go +++ b/go_backend/deezer_download.go @@ -204,7 +204,7 @@ func resolveDeezerTrackURL(req DownloadRequest) (string, error) { } if deezerID != "" { trackURL := fmt.Sprintf("https://www.deezer.com/track/%s", deezerID) - if err := verifyDeezerTrack(req, deezerID); err != nil { + if err := verifyDeezerTrack(req, deezerID, false); err != nil { GoLog("[Deezer] Direct ID %s verification failed: %v\n", deezerID, err) // Don't reject direct IDs from request payload — they're presumably correct. } @@ -219,7 +219,7 @@ func resolveDeezerTrackURL(req DownloadRequest) (string, error) { if err == nil && availability.Deezer && availability.DeezerURL != "" { resolvedID := extractDeezerIDFromURL(availability.DeezerURL) if resolvedID != "" { - if verifyErr := verifyDeezerTrack(req, resolvedID); verifyErr != nil { + if verifyErr := verifyDeezerTrack(req, resolvedID, true); verifyErr != nil { GoLog("[Deezer] SongLink ID %s rejected: %v\n", resolvedID, verifyErr) // Fall through to ISRC search instead of using wrong track. } else { @@ -240,7 +240,7 @@ func resolveDeezerTrackURL(req DownloadRequest) (string, error) { if err == nil && track != nil { resolvedID := songLinkExtractDeezerTrackID(track) if resolvedID != "" { - if verifyErr := verifyDeezerTrack(req, resolvedID); verifyErr != nil { + if verifyErr := verifyDeezerTrack(req, resolvedID, false); verifyErr != nil { GoLog("[Deezer] ISRC-resolved ID %s rejected: %v\n", resolvedID, verifyErr) return "", fmt.Errorf("deezer track resolved via ISRC does not match: %w", verifyErr) } @@ -252,7 +252,7 @@ func resolveDeezerTrackURL(req DownloadRequest) (string, error) { return "", fmt.Errorf("could not resolve Deezer track URL") } -func verifyDeezerTrack(req DownloadRequest, deezerID string) error { +func verifyDeezerTrack(req DownloadRequest, deezerID string, skipNameVerification bool) error { ctx, cancel := context.WithTimeout(context.Background(), SongLinkTimeout) defer cancel() trackResp, err := GetDeezerClient().GetTrack(ctx, deezerID) @@ -260,9 +260,11 @@ func verifyDeezerTrack(req DownloadRequest, deezerID string) error { return nil // Can't verify — don't block the download. } resolved := resolvedTrackInfo{ - Title: trackResp.Track.Name, - ArtistName: trackResp.Track.Artists, - Duration: trackResp.Track.DurationMS / 1000, + Title: trackResp.Track.Name, + ArtistName: trackResp.Track.Artists, + ISRC: trackResp.Track.ISRC, + Duration: trackResp.Track.DurationMS / 1000, + SkipNameVerification: skipNameVerification, } if !trackMatchesRequest(req, resolved, "Deezer") { return fmt.Errorf("expected '%s - %s', got '%s - %s'", diff --git a/go_backend/qobuz.go b/go_backend/qobuz.go index 23a24d75..40aefb00 100644 --- a/go_backend/qobuz.go +++ b/go_backend/qobuz.go @@ -1597,21 +1597,27 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam return nil, fmt.Errorf("no matching track found for: %s - %s", artistName, trackName) } -func qobuzTrackMatchesRequest(req DownloadRequest, track *QobuzTrack, logPrefix, source string) bool { +func qobuzTrackMatchesRequest(req DownloadRequest, track *QobuzTrack, logPrefix, source string, skipNameVerification bool) bool { if track == nil { return false } - if req.ArtistName != "" && !qobuzArtistsMatch(req.ArtistName, track.Performer.Name) { - GoLog("[%s] Artist mismatch from %s: expected '%s', got '%s'. Rejecting.\n", - logPrefix, source, req.ArtistName, track.Performer.Name) - return false - } + exactISRCMatch := req.ISRC != "" && + track.ISRC != "" && + strings.EqualFold(strings.TrimSpace(req.ISRC), strings.TrimSpace(track.ISRC)) - if req.TrackName != "" && !qobuzTitlesMatch(req.TrackName, track.Title) { - GoLog("[%s] Title mismatch from %s: expected '%s', got '%s'. Rejecting.\n", - logPrefix, source, req.TrackName, track.Title) - return false + if !exactISRCMatch && !skipNameVerification { + if req.ArtistName != "" && !qobuzArtistsMatch(req.ArtistName, track.Performer.Name) { + GoLog("[%s] Artist mismatch from %s: expected '%s', got '%s'. Rejecting.\n", + logPrefix, source, req.ArtistName, track.Performer.Name) + return false + } + + if req.TrackName != "" && !qobuzTitlesMatch(req.TrackName, track.Title) { + GoLog("[%s] Title mismatch from %s: expected '%s', got '%s'. Rejecting.\n", + logPrefix, source, req.TrackName, track.Title) + return false + } } expectedDurationSec := req.DurationMS / 1000 @@ -2125,7 +2131,7 @@ func resolveQobuzTrackForRequest(req DownloadRequest, downloader *QobuzDownloade GoLog("[%s] Failed to get track by request Qobuz ID %d: %v\n", logPrefix, trackID, err) track = nil } else if track != nil { - if qobuzTrackMatchesRequest(req, track, logPrefix, "request Qobuz ID") { + if qobuzTrackMatchesRequest(req, track, logPrefix, "request Qobuz ID", false) { GoLog("[%s] Successfully found track via request Qobuz ID: '%s' by '%s'\n", logPrefix, track.Title, track.Performer.Name) } else { track = nil @@ -2142,7 +2148,7 @@ func resolveQobuzTrackForRequest(req DownloadRequest, downloader *QobuzDownloade if err != nil { GoLog("[%s] Cache hit but GetTrackByID failed: %v\n", logPrefix, err) track = nil - } else if track != nil && !qobuzTrackMatchesRequest(req, track, logPrefix, "cached Qobuz ID") { + } else if track != nil && !qobuzTrackMatchesRequest(req, track, logPrefix, "cached Qobuz ID", false) { track = nil } } @@ -2162,7 +2168,7 @@ func resolveQobuzTrackForRequest(req DownloadRequest, downloader *QobuzDownloade GoLog("[%s] Failed to get track by SongLink ID %d: %v\n", logPrefix, trackID, err) track = nil } else if track != nil { - if qobuzTrackMatchesRequest(req, track, logPrefix, "SongLink Qobuz ID") { + if qobuzTrackMatchesRequest(req, track, logPrefix, "SongLink Qobuz ID", true) { GoLog("[%s] Successfully found track via SongLink ID: '%s' by '%s'\n", logPrefix, track.Title, track.Performer.Name) if req.ISRC != "" { GetTrackIDCache().SetQobuz(req.ISRC, track.ID) @@ -2179,7 +2185,7 @@ func resolveQobuzTrackForRequest(req DownloadRequest, downloader *QobuzDownloade if track == nil && req.ISRC != "" { GoLog("[%s] Trying ISRC search: %s\n", logPrefix, req.ISRC) track, err = qobuzSearchTrackByISRCWithDurationFunc(downloader, req.ISRC, expectedDurationSec) - if track != nil && !qobuzTrackMatchesRequest(req, track, logPrefix, "ISRC search") { + if track != nil && !qobuzTrackMatchesRequest(req, track, logPrefix, "ISRC search", false) { track = nil } } @@ -2188,7 +2194,7 @@ func resolveQobuzTrackForRequest(req DownloadRequest, downloader *QobuzDownloade if track == nil { GoLog("[%s] Trying metadata search: '%s' by '%s'\n", logPrefix, req.TrackName, req.ArtistName) track, err = qobuzSearchTrackByMetadataWithDurationFunc(downloader, req.TrackName, req.ArtistName, expectedDurationSec) - if track != nil && !qobuzTrackMatchesRequest(req, track, logPrefix, "metadata search") { + if track != nil && !qobuzTrackMatchesRequest(req, track, logPrefix, "metadata search", false) { track = nil } } diff --git a/go_backend/qobuz_test.go b/go_backend/qobuz_test.go index df3b84ac..572a055f 100644 --- a/go_backend/qobuz_test.go +++ b/go_backend/qobuz_test.go @@ -436,3 +436,20 @@ func TestResolveQobuzTrackForRequestUsesPrefixedQobuzIDWithoutSongLink(t *testin t.Fatalf("unexpected resolved track: %+v", track) } } + +func TestQobuzTrackMatchesRequest_SongLinkBypassesArtistAndTitle(t *testing.T) { + req := DownloadRequest{ + TrackName: "Ringišpil", + ArtistName: "Djordje Balasevic", + } + + track := &QobuzTrack{ + Title: "Different Title", + Duration: 0, + } + track.Performer.Name = "Different Artist" + + if !qobuzTrackMatchesRequest(req, track, "Qobuz", "SongLink Qobuz ID", true) { + t.Fatal("expected SongLink Qobuz source to bypass artist/title verification") + } +} diff --git a/go_backend/tidal.go b/go_backend/tidal.go index d9738e41..d7cd91a3 100644 --- a/go_backend/tidal.go +++ b/go_backend/tidal.go @@ -829,6 +829,7 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, a resolved := resolvedTrackInfo{ Title: strings.TrimSpace(track.Title), ArtistName: tidalTrackArtistsDisplay(track), + ISRC: strings.TrimSpace(track.ISRC), Duration: track.Duration, } if trackMatchesRequest(req, resolved, "Tidal search") { @@ -2035,6 +2036,7 @@ func resolveTidalTrackForRequest(req DownloadRequest, downloader *TidalDownloade expectedDurationSec := req.DurationMS / 1000 var trackID int64 var gotTidalID bool + var resolvedViaSongLink bool if req.TidalID != "" { GoLog("[%s] Using Tidal ID from request payload: %s\n", logPrefix, req.TidalID) @@ -2094,6 +2096,7 @@ func resolveTidalTrackForRequest(req DownloadRequest, downloader *TidalDownloade trackID = parsedTrackID GoLog("[%s] Got Tidal ID %d directly from SongLink\n", logPrefix, trackID) gotTidalID = true + resolvedViaSongLink = true return } } @@ -2103,6 +2106,7 @@ func resolveTidalTrackForRequest(req DownloadRequest, downloader *TidalDownloade if idErr == nil && trackID > 0 { GoLog("[%s] Got Tidal ID %d from URL parsing\n", logPrefix, trackID) gotTidalID = true + resolvedViaSongLink = true } } } @@ -2157,9 +2161,11 @@ func resolveTidalTrackForRequest(req DownloadRequest, downloader *TidalDownloade providerArtist = actualTrack.Artists[0].Name } resolved := resolvedTrackInfo{ - Title: actualTrack.Title, - ArtistName: providerArtist, - Duration: actualTrack.Duration, + Title: actualTrack.Title, + ArtistName: providerArtist, + ISRC: strings.TrimSpace(actualTrack.ISRC), + Duration: actualTrack.Duration, + SkipNameVerification: resolvedViaSongLink, } if !trackMatchesRequest(req, resolved, logPrefix) { // Invalidate the cached ID so future requests don't reuse it. diff --git a/go_backend/title_match_utils.go b/go_backend/title_match_utils.go index b5089065..662d62b4 100644 --- a/go_backend/title_match_utils.go +++ b/go_backend/title_match_utils.go @@ -7,6 +7,21 @@ import ( "golang.org/x/text/unicode/norm" ) +func writeNormalizedArtistRune(b *strings.Builder, r rune) { + switch r { + case 'đ': + b.WriteString("dj") + case 'ß': + b.WriteString("ss") + case 'æ': + b.WriteString("ae") + case 'œ': + b.WriteString("oe") + default: + b.WriteRune(r) + } +} + // normalizeLooseTitle collapses separators/punctuation so titles like // "Doctor / Cops" and "Doctor _ Cops" can still match. func normalizeLooseTitle(title string) string { @@ -51,7 +66,7 @@ func normalizeLooseArtistName(name string) string { case unicode.Is(unicode.Mn, r), unicode.Is(unicode.Mc, r), unicode.Is(unicode.Me, r): continue case unicode.IsLetter(r), unicode.IsNumber(r): - b.WriteRune(r) + writeNormalizedArtistRune(&b, r) case unicode.IsSpace(r): b.WriteByte(' ') case r == '/', r == '\\', r == '_', r == '-', r == '|', r == '.', r == '&', r == '+': @@ -101,26 +116,34 @@ func normalizeSymbolOnlyTitle(title string) string { // resolvedTrackInfo holds the metadata fetched from a provider for verification. type resolvedTrackInfo struct { - Title string - ArtistName string - Duration int + Title string + ArtistName string + ISRC string + Duration int + SkipNameVerification bool } // trackMatchesRequest checks whether a resolved track from a provider matches // the original download request. Returns true if the track is a plausible match. func trackMatchesRequest(req DownloadRequest, resolved resolvedTrackInfo, logPrefix string) bool { - if req.ArtistName != "" && resolved.ArtistName != "" && - !artistsMatch(req.ArtistName, resolved.ArtistName) { - GoLog("[%s] Verification failed: artist mismatch — expected '%s', got '%s'\n", - logPrefix, req.ArtistName, resolved.ArtistName) - return false - } + exactISRCMatch := req.ISRC != "" && + resolved.ISRC != "" && + strings.EqualFold(strings.TrimSpace(req.ISRC), strings.TrimSpace(resolved.ISRC)) - if req.TrackName != "" && resolved.Title != "" && - !titlesMatch(req.TrackName, resolved.Title) { - GoLog("[%s] Verification failed: title mismatch — expected '%s', got '%s'\n", - logPrefix, req.TrackName, resolved.Title) - return false + if !exactISRCMatch && !resolved.SkipNameVerification { + if req.ArtistName != "" && resolved.ArtistName != "" && + !artistsMatch(req.ArtistName, resolved.ArtistName) { + GoLog("[%s] Verification failed: artist mismatch — expected '%s', got '%s'\n", + logPrefix, req.ArtistName, resolved.ArtistName) + return false + } + + if req.TrackName != "" && resolved.Title != "" && + !titlesMatch(req.TrackName, resolved.Title) { + GoLog("[%s] Verification failed: title mismatch — expected '%s', got '%s'\n", + logPrefix, req.TrackName, resolved.Title) + return false + } } expectedDurationSec := req.DurationMS / 1000 diff --git a/go_backend/title_match_utils_test.go b/go_backend/title_match_utils_test.go index edc63058..cfbf59e9 100644 --- a/go_backend/title_match_utils_test.go +++ b/go_backend/title_match_utils_test.go @@ -21,6 +21,40 @@ func TestNormalizeLooseTitle_EmojiAndSymbols(t *testing.T) { } } +func TestTrackMatchesRequest_SongLinkBypassesArtistAndTitle(t *testing.T) { + req := DownloadRequest{ + TrackName: "Ringišpil", + ArtistName: "Djordje Balasevic", + } + resolved := resolvedTrackInfo{ + Title: "Completely Different Title", + ArtistName: "Totally Different Artist", + SkipNameVerification: true, + } + + if !trackMatchesRequest(req, resolved, "test") { + t.Fatal("expected SongLink-resolved track to bypass artist/title verification") + } +} + +func TestTrackMatchesRequest_SongLinkStillChecksDuration(t *testing.T) { + req := DownloadRequest{ + TrackName: "Ringišpil", + ArtistName: "Djordje Balasevic", + DurationMS: 180000, + } + resolved := resolvedTrackInfo{ + Title: "Completely Different Title", + ArtistName: "Totally Different Artist", + Duration: 240, + SkipNameVerification: true, + } + + if trackMatchesRequest(req, resolved, "test") { + t.Fatal("expected SongLink-resolved track with large duration mismatch to be rejected") + } +} + func TestTitlesMatch_SeparatorVariants(t *testing.T) { if !titlesMatch("Doctor / Cops", "Doctor _ Cops") { t.Fatal("expected tidal titlesMatch to accept / vs _ variant") diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index 123a0c97..daa6ef81 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -1559,7 +1559,7 @@ class DownloadQueueNotifier extends Notifier { final isDownloading = itemProgress['is_downloading'] as bool? ?? false; final status = itemProgress['status'] as String? ?? 'downloading'; - if (status == 'finalizing' && bytesTotal > 0) { + if (status == 'finalizing') { progressUpdates[itemId] = const _ProgressUpdate( status: DownloadStatus.finalizing, progress: 1.0, @@ -4358,7 +4358,7 @@ class DownloadQueueNotifier extends Notifier { if (!wasExisting && decryptionKey.isNotEmpty && filePath != null) { _log.i('Encrypted stream detected, decrypting via FFmpeg...'); - updateItemStatus(item.id, DownloadStatus.downloading, progress: 0.9); + updateItemStatus(item.id, DownloadStatus.finalizing, progress: 0.9); if (effectiveSafMode && isContentUri(filePath)) { final currentFilePath = filePath; @@ -4503,7 +4503,7 @@ class DownloadQueueNotifier extends Notifier { try { updateItemStatus( item.id, - DownloadStatus.downloading, + DownloadStatus.finalizing, progress: 0.95, ); @@ -4524,7 +4524,7 @@ class DownloadQueueNotifier extends Notifier { _log.i('Embedding metadata to $format...'); updateItemStatus( item.id, - DownloadStatus.downloading, + DownloadStatus.finalizing, progress: 0.99, ); @@ -4608,7 +4608,7 @@ class DownloadQueueNotifier extends Notifier { } else { updateItemStatus( item.id, - DownloadStatus.downloading, + DownloadStatus.finalizing, progress: 0.95, ); flacPath = await FFmpegService.convertM4aToFlac(tempPath); @@ -4684,7 +4684,7 @@ class DownloadQueueNotifier extends Notifier { try { updateItemStatus( item.id, - DownloadStatus.downloading, + DownloadStatus.finalizing, progress: 0.95, ); @@ -4711,7 +4711,7 @@ class DownloadQueueNotifier extends Notifier { _log.i('Embedding metadata to $format...'); updateItemStatus( item.id, - DownloadStatus.downloading, + DownloadStatus.finalizing, progress: 0.99, ); @@ -4765,7 +4765,7 @@ class DownloadQueueNotifier extends Notifier { } else { updateItemStatus( item.id, - DownloadStatus.downloading, + DownloadStatus.finalizing, progress: 0.95, ); final flacPath = await FFmpegService.convertM4aToFlac( @@ -4849,7 +4849,7 @@ class DownloadQueueNotifier extends Notifier { try { updateItemStatus( item.id, - DownloadStatus.downloading, + DownloadStatus.finalizing, progress: 0.99, ); @@ -4930,7 +4930,7 @@ class DownloadQueueNotifier extends Notifier { try { updateItemStatus( item.id, - DownloadStatus.downloading, + DownloadStatus.finalizing, progress: 0.99, ); diff --git a/lib/screens/main_shell.dart b/lib/screens/main_shell.dart index c6a8b768..72a1e516 100644 --- a/lib/screens/main_shell.dart +++ b/lib/screens/main_shell.dart @@ -301,11 +301,11 @@ class _MainShellState extends ConsumerState } } - void _handleBackPress() { + Future _handleBackPress() async { final rootNavigator = Navigator.of(context, rootNavigator: true); - if (rootNavigator.canPop()) { - _log.i('Back: step 1 - root navigator pop'); - rootNavigator.pop(); + final handledByRootNavigator = await rootNavigator.maybePop(); + if (handledByRootNavigator) { + _log.i('Back: step 1 - root navigator handled back'); _lastBackPress = null; return; } @@ -314,9 +314,10 @@ class _MainShellState extends ConsumerState settingsProvider.select((s) => s.showExtensionStore), ); final currentNavigator = _navigatorForTab(_currentIndex, showStore); - if (currentNavigator != null && currentNavigator.canPop()) { - _log.i('Back: step 2 - tab navigator pop (tab=$_currentIndex)'); - currentNavigator.pop(); + final handledByCurrentNavigator = + await currentNavigator?.maybePop() ?? false; + if (handledByCurrentNavigator) { + _log.i('Back: step 2 - tab navigator handled back (tab=$_currentIndex)'); _lastBackPress = null; return; } @@ -522,7 +523,7 @@ class _MainShellState extends ConsumerState return BackButtonListener( onBackButtonPressed: () async { - _handleBackPress(); + await _handleBackPress(); return true; }, child: Scaffold( diff --git a/lib/screens/queue_tab.dart b/lib/screens/queue_tab.dart index 676ea527..0f0f1d33 100644 --- a/lib/screens/queue_tab.dart +++ b/lib/screens/queue_tab.dart @@ -1220,16 +1220,21 @@ class _QueueTabState extends ConsumerState { } void _toggleSelection(String itemId) { + var shouldHideOverlay = false; setState(() { if (_selectedIds.contains(itemId)) { _selectedIds.remove(itemId); if (_selectedIds.isEmpty) { _isSelectionMode = false; + shouldHideOverlay = true; } } else { _selectedIds.add(itemId); } }); + if (shouldHideOverlay) { + _hideSelectionOverlay(); + } } void _selectAll(List items) { @@ -1269,13 +1274,15 @@ class _QueueTabState extends ConsumerState { left: 0, right: 0, bottom: 0, - child: Material( - color: Colors.transparent, - child: _buildSelectionBottomBar( - context, - colorScheme, - _selectionOverlayItems, - _selectionOverlayBottomPadding, + child: _AnimatedOverlayBottomBar( + child: Material( + color: Colors.transparent, + child: _buildSelectionBottomBar( + context, + colorScheme, + _selectionOverlayItems, + _selectionOverlayBottomPadding, + ), ), ), ); @@ -1315,13 +1322,15 @@ class _QueueTabState extends ConsumerState { left: 0, right: 0, bottom: 0, - child: Material( - color: Colors.transparent, - child: _buildPlaylistSelectionBottomBar( - context, - colorScheme, - _playlistSelectionOverlayItems, - _playlistSelectionOverlayBottomPadding, + child: _AnimatedOverlayBottomBar( + child: Material( + color: Colors.transparent, + child: _buildPlaylistSelectionBottomBar( + context, + colorScheme, + _playlistSelectionOverlayItems, + _playlistSelectionOverlayBottomPadding, + ), ), ), ); @@ -1350,16 +1359,21 @@ class _QueueTabState extends ConsumerState { } void _togglePlaylistSelection(String playlistId) { + var shouldHideOverlay = false; setState(() { if (_selectedPlaylistIds.contains(playlistId)) { _selectedPlaylistIds.remove(playlistId); if (_selectedPlaylistIds.isEmpty) { _isPlaylistSelectionMode = false; + shouldHideOverlay = true; } } else { _selectedPlaylistIds.add(playlistId); } }); + if (shouldHideOverlay) { + _hidePlaylistSelectionOverlay(); + } } void _selectAllPlaylists(List playlists) { @@ -5538,20 +5552,8 @@ class _QueueTabState extends ConsumerState { ), const SizedBox(width: 8), Text( - item.bytesTotal > 0 && item.bytesReceived > 0 - ? (() { - final receivedMB = - item.bytesReceived / (1024 * 1024); - final totalMB = - item.bytesTotal / (1024 * 1024); - final progressLabel = item.progress > 0 - ? '${(item.progress * 100).toStringAsFixed(0)}% • ' - : ''; - final speedLabel = item.speedMBps > 0 - ? ' • ${item.speedMBps.toStringAsFixed(1)} MB/s' - : ''; - return '$progressLabel${receivedMB.toStringAsFixed(1)} / ${totalMB.toStringAsFixed(1)} MB$speedLabel'; - })() + item.bytesTotal > 0 + ? '${(item.progress * 100).toStringAsFixed(0)}%' : (item.bytesReceived > 0 ? '${(item.bytesReceived / (1024 * 1024)).toStringAsFixed(1)} MB${item.speedMBps > 0 ? ' • ${item.speedMBps.toStringAsFixed(1)} MB/s' : ''}' : (item.progress > 0 @@ -6466,3 +6468,53 @@ class _SelectionActionButton extends StatelessWidget { ); } } + +class _AnimatedOverlayBottomBar extends StatefulWidget { + final Widget child; + + const _AnimatedOverlayBottomBar({required this.child}); + + @override + State<_AnimatedOverlayBottomBar> createState() => + _AnimatedOverlayBottomBarState(); +} + +class _AnimatedOverlayBottomBarState extends State<_AnimatedOverlayBottomBar> + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + late final Animation _slideAnimation; + late final Animation _fadeAnimation; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 240), + ); + final curve = CurvedAnimation( + parent: _controller, + curve: Curves.easeOutCubic, + ); + _slideAnimation = Tween( + begin: const Offset(0, 0.08), + end: Offset.zero, + ).animate(curve); + _fadeAnimation = Tween(begin: 0, end: 1).animate(curve); + _controller.forward(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return FadeTransition( + opacity: _fadeAnimation, + child: SlideTransition(position: _slideAnimation, child: widget.child), + ); + } +}