From 66d714d368fbcd4cd0cad575c01329663b9faa68 Mon Sep 17 00:00:00 2001 From: zarzet Date: Wed, 25 Mar 2026 22:27:22 +0700 Subject: [PATCH] fix: unify search bar, filter chips, tab navigation, and clean up comments --- go_backend/deezer.go | 3 +- go_backend/exports.go | 2 - go_backend/httputil.go | 5 - go_backend/lyrics.go | 6 - go_backend/youtube.go | 5 - lib/constants/app_info.dart | 2 +- lib/main.dart | 3 - lib/providers/download_queue_provider.dart | 6 +- lib/screens/home_tab.dart | 39 +- lib/screens/main_shell.dart | 58 ++- lib/screens/queue_tab.dart | 577 +++++++++++---------- lib/screens/store_tab.dart | 61 ++- 12 files changed, 394 insertions(+), 373 deletions(-) diff --git a/go_backend/deezer.go b/go_backend/deezer.go index a389f3c8..3d76bcbf 100644 --- a/go_backend/deezer.go +++ b/go_backend/deezer.go @@ -1181,7 +1181,7 @@ func (c *DeezerClient) getJSON(ctx context.Context, endpoint string, dst interfa for attempt := 0; attempt <= deezerMaxRetries; attempt++ { if attempt > 0 { - delay := deezerRetryDelay * time.Duration(1<<(attempt-1)) // Exponential backoff + delay := deezerRetryDelay * time.Duration(1<<(attempt-1)) GoLog("[Deezer] Retry %d/%d after %v...\n", attempt, deezerMaxRetries, delay) time.Sleep(delay) } @@ -1194,7 +1194,6 @@ func (c *DeezerClient) getJSON(ctx context.Context, endpoint string, dst interfa lastErr = err errStr := err.Error() - // Check if error is retryable isRetryable := strings.Contains(errStr, "timeout") || strings.Contains(errStr, "connection reset") || strings.Contains(errStr, "connection refused") || diff --git a/go_backend/exports.go b/go_backend/exports.go index f26f263e..3c9ec59d 100644 --- a/go_backend/exports.go +++ b/go_backend/exports.go @@ -48,7 +48,6 @@ func CheckAvailability(spotifyID, isrc string) (string, error) { } // SetSongLinkNetworkOptions is kept for backward compatibility. -// It now applies global network compatibility options for all backend API requests. func SetSongLinkNetworkOptions(allowHTTP, insecureTLS bool) { SetNetworkCompatibilityOptions(allowHTTP, insecureTLS) } @@ -910,7 +909,6 @@ func EditFileMetadata(filePath, metadataJSON string) (string, error) { return string(jsonBytes), nil } - // MP3/Opus: return metadata for Dart-side FFmpeg embedding resp := map[string]any{ "success": true, "method": "ffmpeg", diff --git a/go_backend/httputil.go b/go_backend/httputil.go index 05e4af8f..b3a4c752 100644 --- a/go_backend/httputil.go +++ b/go_backend/httputil.go @@ -300,14 +300,11 @@ func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConf continue } - // Check for ISP blocking via HTTP status codes - // Some ISPs return 403 or 451 when blocking content if resp.StatusCode == 403 || resp.StatusCode == 451 { body, _ := io.ReadAll(resp.Body) resp.Body.Close() bodyStr := strings.ToLower(string(body)) - // Check if response looks like ISP blocking page ispBlockingIndicators := []string{ "blocked", "forbidden", "access denied", "not available in your", "restricted", "censored", "unavailable for legal", "blocked by", @@ -518,7 +515,6 @@ func IsISPBlocking(err error, requestURL string) *ISPBlockingError { return nil } -// Returns true if ISP blocking was detected func CheckAndLogISPBlocking(err error, requestURL string, tag string) bool { ispErr := IsISPBlocking(err, requestURL) if ispErr != nil { @@ -553,7 +549,6 @@ func extractDomain(rawURL string) string { return "unknown" } -// If ISP blocking is detected, returns a more descriptive error func WrapErrorWithISPCheck(err error, requestURL string, tag string) error { if err == nil { return nil diff --git a/go_backend/lyrics.go b/go_backend/lyrics.go index 60d3aa85..ef3d855e 100644 --- a/go_backend/lyrics.go +++ b/go_backend/lyrics.go @@ -83,7 +83,6 @@ func SetLyricsProviderOrder(providers []string) { return } - // Validate provider names validNames := map[string]bool{ LyricsProviderSpotifyAPI: true, LyricsProviderLRCLIB: true, @@ -105,7 +104,6 @@ func SetLyricsProviderOrder(providers []string) { GoLog("[Lyrics] Provider order set to: %v\n", valid) } -// GetLyricsProviderOrder returns the current lyrics provider order. func GetLyricsProviderOrder() []string { lyricsProvidersMu.RLock() defer lyricsProvidersMu.RUnlock() @@ -119,7 +117,6 @@ func GetLyricsProviderOrder() []string { return result } -// GetAvailableLyricsProviders returns metadata about all available providers. func GetAvailableLyricsProviders() []map[string]interface{} { return []map[string]interface{}{ {"id": LyricsProviderSpotifyAPI, "name": "Spotify Lyrics API", "has_proxy_dependency": true, "description": "Spotify-sourced lyrics via Paxsenix"}, @@ -140,7 +137,6 @@ func normalizeLyricsFetchOptions(opts LyricsFetchOptions) LyricsFetchOptions { return opts } -// SetLyricsFetchOptions sets provider-specific lyric fetch behavior. func SetLyricsFetchOptions(opts LyricsFetchOptions) { normalized := normalizeLyricsFetchOptions(opts) @@ -156,7 +152,6 @@ func SetLyricsFetchOptions(opts LyricsFetchOptions) { ) } -// GetLyricsFetchOptions returns current provider-specific lyric fetch behavior. func GetLyricsFetchOptions() LyricsFetchOptions { lyricsFetchOptionsMu.RLock() defer lyricsFetchOptionsMu.RUnlock() @@ -667,7 +662,6 @@ func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName st GoLog("[Lyrics] Searching for: %s - %s (providers: %v)\n", artistName, trackName, providerOrder) - // Cascade through all configured built-in providers for _, providerName := range providerOrder { GoLog("[Lyrics] Trying provider: %s\n", providerName) diff --git a/go_backend/youtube.go b/go_backend/youtube.go index 3bb65f5a..abffd2ff 100644 --- a/go_backend/youtube.go +++ b/go_backend/youtube.go @@ -542,7 +542,6 @@ func searchYouTubeMusicViaExtension(artistName, trackName string) string { extManager := GetExtensionManager() searchProviders := extManager.GetSearchProviders() - // Find the ytmusic-spotiflac extension var ytProvider *ExtensionProviderWrapper for _, p := range searchProviders { if p.extension.ID == "ytmusic-spotiflac" { @@ -569,7 +568,6 @@ func searchYouTubeMusicViaExtension(artistName, trackName string) string { return "" } - // Find the first track result (item_type == "track" with a valid video ID) for _, track := range results { if track.ItemType != "" && track.ItemType != "track" { continue @@ -610,7 +608,6 @@ func downloadFromYouTube(req DownloadRequest) (YouTubeDownloadResult, error) { } } - // Fallback: Try Spotify ID via SongLink if youtubeURL == "" && req.SpotifyID != "" && !isYouTubeVideoID(req.SpotifyID) { GoLog("[YouTube] Looking up YouTube URL via SongLink for Spotify ID: %s\n", req.SpotifyID) songlink := NewSongLinkClient() @@ -622,7 +619,6 @@ func downloadFromYouTube(req DownloadRequest) (YouTubeDownloadResult, error) { } } - // Fallback: Try Deezer ID via SongLink if youtubeURL == "" && req.DeezerID != "" { GoLog("[YouTube] Looking up YouTube URL via SongLink for Deezer ID: %s\n", req.DeezerID) songlink := NewSongLinkClient() @@ -634,7 +630,6 @@ func downloadFromYouTube(req DownloadRequest) (YouTubeDownloadResult, error) { } } - // Fallback: Try ISRC via SongLink if youtubeURL == "" && req.ISRC != "" { GoLog("[YouTube] Looking up YouTube URL via SongLink for ISRC: %s\n", req.ISRC) songlink := NewSongLinkClient() diff --git a/lib/constants/app_info.dart b/lib/constants/app_info.dart index 68006386..309147fc 100644 --- a/lib/constants/app_info.dart +++ b/lib/constants/app_info.dart @@ -10,7 +10,7 @@ class AppInfo { /// Shows "Internal" in debug builds, actual version in release. static String get displayVersion => kDebugMode ? 'Internal' : version; - static const String appName = 'SpotiFLAC'; + static const String appName = 'SpotiFLAC Mobile'; static const String copyright = '© 2026 SpotiFLAC'; static const String mobileAuthor = 'zarzet'; diff --git a/lib/main.dart b/lib/main.dart index 89d5d93c..56c94ae2 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -192,11 +192,9 @@ class _EagerInitializationState extends ConsumerState<_EagerInitialization> if (settings.localLibraryPath.isEmpty) return; if (settings.localLibraryAutoScan == 'off') return; - // Don't start a scan if one is already running. final libraryState = ref.read(localLibraryProvider); if (libraryState.isScanning) return; - // Determine cooldown based on auto-scan mode. final now = DateTime.now(); final prefs = await SharedPreferences.getInstance(); final lastScanned = readLocalLibraryLastScannedAt(prefs); @@ -220,7 +218,6 @@ class _EagerInitializationState extends ConsumerState<_EagerInitialization> } } - // All checks passed -- start an incremental scan. final iosBookmark = settings.localLibraryBookmark; ref .read(localLibraryProvider.notifier) diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index 97528c79..574b8256 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -874,8 +874,9 @@ class DownloadHistoryNotifier extends Notifier { await _db.upsert(updated.toJson()); } - /// Remove history entries where the file no longer exists on disk - /// Returns the number of orphaned entries removed + /// Remove history entries where the file no longer exists on disk. + /// Returns the number of orphaned entries removed. + /// Audio file extensions that the app commonly produces or converts between. static const _audioExtensions = [ '.flac', @@ -891,7 +892,6 @@ class DownloadHistoryNotifier extends Notifier { /// different audio extension exists (e.g. the user converted .flac → .opus). /// Returns the path of the first match found, or `null` if none exist. Future _findConvertedSibling(String originalPath) async { - // Strip the current extension to get the base path. final dotIndex = originalPath.lastIndexOf('.'); if (dotIndex < 0) return null; final basePath = originalPath.substring(0, dotIndex); diff --git a/lib/screens/home_tab.dart b/lib/screens/home_tab.dart index 65177c94..890fbd09 100644 --- a/lib/screens/home_tab.dart +++ b/lib/screens/home_tab.dart @@ -3124,16 +3124,6 @@ class _HomeTabState extends ConsumerState _triggerSearchWithFilter(null); }, showCheckmark: false, - selectedColor: colorScheme.primaryContainer, - backgroundColor: colorScheme.surfaceContainerHighest, - labelStyle: TextStyle( - color: selectedFilter == null - ? colorScheme.onPrimaryContainer - : colorScheme.onSurfaceVariant, - fontWeight: selectedFilter == null - ? FontWeight.w600 - : FontWeight.normal, - ), ), ), ...filters.map((filter) { @@ -3148,24 +3138,8 @@ class _HomeTabState extends ConsumerState _triggerSearchWithFilter(filter.id); }, showCheckmark: false, - selectedColor: colorScheme.primaryContainer, - backgroundColor: colorScheme.surfaceContainerHighest, - labelStyle: TextStyle( - color: isSelected - ? colorScheme.onPrimaryContainer - : colorScheme.onSurfaceVariant, - fontWeight: isSelected - ? FontWeight.w600 - : FontWeight.normal, - ), avatar: filter.icon != null - ? Icon( - _getFilterIcon(filter.icon!), - size: 18, - color: isSelected - ? colorScheme.onPrimaryContainer - : colorScheme.onSurfaceVariant, - ) + ? Icon(_getFilterIcon(filter.icon!), size: 18) : null, ), ); @@ -3220,15 +3194,11 @@ class _HomeTabState extends ConsumerState fillColor: colorScheme.surfaceContainerHighest, border: OutlineInputBorder( borderRadius: BorderRadius.circular(28), - borderSide: BorderSide( - color: colorScheme.outline.withValues(alpha: 0.5), - ), + borderSide: BorderSide(color: colorScheme.outlineVariant), ), enabledBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(28), - borderSide: BorderSide( - color: colorScheme.outline.withValues(alpha: 0.5), - ), + borderSide: BorderSide(color: colorScheme.outlineVariant), ), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(28), @@ -3276,6 +3246,9 @@ class _HomeTabState extends ConsumerState ), ), onSubmitted: (_) => _onSearchSubmitted(), + onTapOutside: (_) { + FocusScope.of(context).unfocus(); + }, ); } diff --git a/lib/screens/main_shell.dart b/lib/screens/main_shell.dart index 0440a08c..5e2a118a 100644 --- a/lib/screens/main_shell.dart +++ b/lib/screens/main_shell.dart @@ -31,9 +31,11 @@ class MainShell extends ConsumerStatefulWidget { ConsumerState createState() => _MainShellState(); } -class _MainShellState extends ConsumerState { +class _MainShellState extends ConsumerState + with SingleTickerProviderStateMixin { int _currentIndex = 0; late final PageController _pageController; + late final AnimationController _tabJumpTransitionController; bool _hasCheckedUpdate = false; StreamSubscription? _shareSubscription; DateTime? _lastBackPress; @@ -48,6 +50,11 @@ class _MainShellState extends ConsumerState { void initState() { super.initState(); _pageController = PageController(initialPage: _currentIndex); + _tabJumpTransitionController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 180), + value: 1, + ); ShellNavigationService.syncState( currentTabIndex: _currentIndex, showStoreTab: false, @@ -229,6 +236,7 @@ class _MainShellState extends ConsumerState { void dispose() { _shareSubscription?.cancel(); _pageController.dispose(); + _tabJumpTransitionController.dispose(); super.dispose(); } @@ -251,6 +259,8 @@ class _MainShellState extends ConsumerState { } if (_currentIndex != index) { + final previousIndex = _currentIndex; + final isNonAdjacentJump = (previousIndex - index).abs() > 1; final shouldResetHome = index == 0; HapticFeedback.selectionClick(); setState(() => _currentIndex = index); @@ -265,11 +275,19 @@ class _MainShellState extends ConsumerState { if (shouldResetHome) { _resetHomeToMain(); } - _pageController.animateToPage( - index, - duration: const Duration(milliseconds: 250), - curve: Curves.easeOutCubic, - ); + // Jump directly when skipping intermediate tabs to avoid + // sliding through them. For those jumps, keep a short fade-in + // so the transition still feels intentional. + if (isNonAdjacentJump) { + _pageController.jumpToPage(index); + _tabJumpTransitionController.forward(from: 0); + } else { + _pageController.animateToPage( + index, + duration: const Duration(milliseconds: 250), + curve: Curves.easeOutCubic, + ); + } } } @@ -504,15 +522,27 @@ class _MainShellState extends ConsumerState { return true; }, child: Scaffold( - body: PageView.builder( - controller: _pageController, - itemCount: tabs.length, - onPageChanged: _onPageChanged, - physics: const NeverScrollableScrollPhysics(), - itemBuilder: (context, index) => _KeepAliveTabPage( - key: ValueKey('page-$index'), - child: tabs[index], + body: AnimatedBuilder( + animation: _tabJumpTransitionController, + child: PageView.builder( + controller: _pageController, + itemCount: tabs.length, + onPageChanged: _onPageChanged, + physics: const NeverScrollableScrollPhysics(), + itemBuilder: (context, index) => _KeepAliveTabPage( + key: ValueKey('page-$index'), + child: tabs[index], + ), ), + builder: (context, child) { + final t = Curves.easeOutCubic.transform( + _tabJumpTransitionController.value, + ); + return Opacity( + opacity: t, + child: Transform.scale(scale: 0.985 + (0.015 * t), child: child), + ); + }, ), bottomNavigationBar: NavigationBar( selectedIndex: _currentIndex.clamp(0, maxIndex), diff --git a/lib/screens/queue_tab.dart b/lib/screens/queue_tab.dart index 0a32e550..21b58379 100644 --- a/lib/screens/queue_tab.dart +++ b/lib/screens/queue_tab.dart @@ -2898,6 +2898,7 @@ class _QueueTabState extends ConsumerState { prefixIcon: const Icon(Icons.search), suffixIcon: _searchQuery.isNotEmpty ? IconButton( + tooltip: 'Clear', icon: const Icon(Icons.clear), onPressed: () { _searchController.clear(); @@ -2912,26 +2913,24 @@ class _QueueTabState extends ConsumerState { borderRadius: BorderRadius.circular(28), borderSide: BorderSide( color: colorScheme.outlineVariant, - width: 1, ), ), enabledBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(28), borderSide: BorderSide( color: colorScheme.outlineVariant, - width: 1.5, ), ), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(28), borderSide: BorderSide( color: colorScheme.primary, - width: 2.5, + width: 2, ), ), contentPadding: const EdgeInsets.symmetric( horizontal: 20, - vertical: 12, + vertical: 16, ), ), onChanged: _onSearchChanged, @@ -3355,238 +3354,91 @@ class _QueueTabState extends ConsumerState { ); } - /// Build a collection item at [index] for the unified "All" tab grid view. - /// Index 0 = Wishlist, 1 = Loved, 2+ = individual playlists. + /// Returns the visible collection entries, hiding Wishlist/Loved when empty. + List<_CollectionEntry> _getVisibleCollectionEntries( + LibraryCollectionsState collectionState, + ) { + final entries = <_CollectionEntry>[]; + if (collectionState.wishlistCount > 0) { + entries.add(_CollectionEntry.wishlist); + } + if (collectionState.lovedCount > 0) { + entries.add(_CollectionEntry.loved); + } + for (var i = 0; i < collectionState.playlists.length; i++) { + entries.add(_CollectionEntry.playlist(i)); + } + return entries; + } + + /// Build a collection item for the unified "All" tab grid view. Widget _buildAllTabGridCollectionItem({ required BuildContext context, required ColorScheme colorScheme, - required int index, + required _CollectionEntry entry, required LibraryCollectionsState collectionState, List filteredUnifiedItems = const [], }) { - if (index == 0) { - return _buildCollectionGridItem( - context: context, - colorScheme: colorScheme, - icon: Icons.add_circle_outline, - iconColor: Colors.white, - iconBgColor: const Color(0xFF1DB954), - title: context.l10n.collectionWishlist, - count: collectionState.wishlistCount, - onTap: _openWishlistFolder, - ); - } else if (index == 1) { - return _buildCollectionGridItem( - context: context, - colorScheme: colorScheme, - icon: Icons.favorite, - iconColor: Colors.white, - iconBgColor: const Color(0xFF8C67AC), - title: context.l10n.collectionLoved, - count: collectionState.lovedCount, - onTap: _openLovedFolder, - ); - } else { - final playlist = collectionState.playlists[index - 2]; - final isSelected = _selectedPlaylistIds.contains(playlist.id); - return DragTarget( - onWillAcceptWithDetails: (_) => !_isPlaylistSelectionMode, - onAcceptWithDetails: (details) { - _onTrackDroppedOnPlaylist( - context, - details.data, - playlist.id, - playlist.name, - allItems: filteredUnifiedItems, - ); - }, - builder: (context, candidateData, rejectedData) { - final isHovering = candidateData.isNotEmpty; - return AnimatedContainer( - duration: const Duration(milliseconds: 150), - decoration: isHovering - ? BoxDecoration( - borderRadius: BorderRadius.circular(12), - border: Border.all(color: colorScheme.primary, width: 2), - color: colorScheme.primary.withValues(alpha: 0.1), - ) - : null, - child: Stack( - children: [ - _buildCollectionGridItem( - context: context, - colorScheme: colorScheme, - coverWidget: _buildPlaylistCover( - context, - playlist, - colorScheme, - ), - title: playlist.name, - count: playlist.tracks.length, - onTap: _isPlaylistSelectionMode - ? () => _togglePlaylistSelection(playlist.id) - : () => _openPlaylistById(playlist.id), - onLongPress: _isPlaylistSelectionMode - ? () => _togglePlaylistSelection(playlist.id) - : () => _enterPlaylistSelectionMode(playlist.id), - ), - if (_isPlaylistSelectionMode) - Positioned( - left: 0, - top: 0, - right: 0, - child: IgnorePointer( - child: AspectRatio( - aspectRatio: 1, - child: Container( - decoration: BoxDecoration( - color: isSelected - ? colorScheme.primary.withValues(alpha: 0.3) - : Colors.transparent, - borderRadius: BorderRadius.circular(8), - ), - ), - ), - ), - ), - if (_isPlaylistSelectionMode) - Positioned( - top: 4, - right: 4, - child: IgnorePointer( - child: Container( - decoration: BoxDecoration( - color: isSelected - ? colorScheme.primary - : colorScheme.surface.withValues(alpha: 0.85), - shape: BoxShape.circle, - border: Border.all( - color: isSelected - ? colorScheme.primary - : colorScheme.outline, - width: 2, - ), - ), - child: isSelected - ? Icon( - Icons.check, - size: 16, - color: colorScheme.onPrimary, - ) - : const SizedBox(width: 16, height: 16), - ), - ), - ), - ], - ), - ); - }, - ); - } - } - - /// Build a collection item at [index] for the unified "All" tab list view. - /// Index 0 = Wishlist, 1 = Loved, 2+ = individual playlists. - Widget _buildAllTabListCollectionItem({ - required BuildContext context, - required ColorScheme colorScheme, - required int index, - required LibraryCollectionsState collectionState, - List filteredUnifiedItems = const [], - }) { - if (index == 0) { - return _buildCollectionListItem( - context: context, - colorScheme: colorScheme, - icon: Icons.add_circle_outline, - iconColor: Colors.white, - iconBgColor: const Color(0xFF1DB954), - title: context.l10n.collectionWishlist, - subtitle: - '${context.l10n.collectionFoldersTitle} • ${collectionState.wishlistCount} ${collectionState.wishlistCount == 1 ? 'track' : 'tracks'}', - onTap: _openWishlistFolder, - ); - } else if (index == 1) { - return _buildCollectionListItem( - context: context, - colorScheme: colorScheme, - icon: Icons.favorite, - iconColor: Colors.white, - iconBgColor: const Color(0xFF8C67AC), - title: context.l10n.collectionLoved, - subtitle: - '${context.l10n.collectionFoldersTitle} • ${collectionState.lovedCount} ${collectionState.lovedCount == 1 ? 'track' : 'tracks'}', - onTap: _openLovedFolder, - ); - } else { - final playlist = collectionState.playlists[index - 2]; - final isSelected = _selectedPlaylistIds.contains(playlist.id); - return DragTarget( - onWillAcceptWithDetails: (_) => !_isPlaylistSelectionMode, - onAcceptWithDetails: (details) { - _onTrackDroppedOnPlaylist( - context, - details.data, - playlist.id, - playlist.name, - allItems: filteredUnifiedItems, - ); - }, - builder: (context, candidateData, rejectedData) { - final isHovering = candidateData.isNotEmpty; - return AnimatedContainer( - duration: const Duration(milliseconds: 150), - decoration: isHovering - ? BoxDecoration( - borderRadius: BorderRadius.circular(12), - border: Border.all(color: colorScheme.primary, width: 2), - color: colorScheme.primary.withValues(alpha: 0.1), - ) - : null, - child: Row( - children: [ - if (_isPlaylistSelectionMode) - GestureDetector( - onTap: () => _togglePlaylistSelection(playlist.id), - behavior: HitTestBehavior.opaque, - child: Padding( - padding: const EdgeInsets.only(left: 8), - child: Container( - decoration: BoxDecoration( - color: isSelected - ? colorScheme.primary - : Colors.transparent, - shape: BoxShape.circle, - border: Border.all( - color: isSelected - ? colorScheme.primary - : colorScheme.outline, - width: 2, - ), - ), - child: isSelected - ? Icon( - Icons.check, - size: 18, - color: colorScheme.onPrimary, - ) - : const SizedBox(width: 18, height: 18), - ), - ), - ), - Expanded( - child: _buildCollectionListItem( + switch (entry.type) { + case _CollectionEntryType.wishlist: + return _buildCollectionGridItem( + context: context, + colorScheme: colorScheme, + icon: Icons.add_circle_outline, + iconColor: Colors.white, + iconBgColor: const Color(0xFF1DB954), + title: context.l10n.collectionWishlist, + count: collectionState.wishlistCount, + onTap: _openWishlistFolder, + ); + case _CollectionEntryType.loved: + return _buildCollectionGridItem( + context: context, + colorScheme: colorScheme, + icon: Icons.favorite, + iconColor: Colors.white, + iconBgColor: const Color(0xFF8C67AC), + title: context.l10n.collectionLoved, + count: collectionState.lovedCount, + onTap: _openLovedFolder, + ); + case _CollectionEntryType.playlist: + final playlist = collectionState.playlists[entry.playlistIndex]; + final isSelected = _selectedPlaylistIds.contains(playlist.id); + return DragTarget( + onWillAcceptWithDetails: (_) => !_isPlaylistSelectionMode, + onAcceptWithDetails: (details) { + _onTrackDroppedOnPlaylist( + context, + details.data, + playlist.id, + playlist.name, + allItems: filteredUnifiedItems, + ); + }, + builder: (context, candidateData, rejectedData) { + final isHovering = candidateData.isNotEmpty; + return AnimatedContainer( + duration: const Duration(milliseconds: 150), + decoration: isHovering + ? BoxDecoration( + borderRadius: BorderRadius.circular(12), + border: Border.all(color: colorScheme.primary, width: 2), + color: colorScheme.primary.withValues(alpha: 0.1), + ) + : null, + child: Stack( + children: [ + _buildCollectionGridItem( context: context, colorScheme: colorScheme, coverWidget: _buildPlaylistCover( context, playlist, colorScheme, - 56, ), title: playlist.name, - subtitle: - '${playlist.tracks.length} ${playlist.tracks.length == 1 ? 'track' : 'tracks'}', + count: playlist.tracks.length, onTap: _isPlaylistSelectionMode ? () => _togglePlaylistSelection(playlist.id) : () => _openPlaylistById(playlist.id), @@ -3594,12 +3446,176 @@ class _QueueTabState extends ConsumerState { ? () => _togglePlaylistSelection(playlist.id) : () => _enterPlaylistSelectionMode(playlist.id), ), - ), - ], - ), - ); - }, - ); + if (_isPlaylistSelectionMode) + Positioned( + left: 0, + top: 0, + right: 0, + child: IgnorePointer( + child: AspectRatio( + aspectRatio: 1, + child: Container( + decoration: BoxDecoration( + color: isSelected + ? colorScheme.primary.withValues(alpha: 0.3) + : Colors.transparent, + borderRadius: BorderRadius.circular(8), + ), + ), + ), + ), + ), + if (_isPlaylistSelectionMode) + Positioned( + top: 4, + right: 4, + child: IgnorePointer( + child: Container( + decoration: BoxDecoration( + color: isSelected + ? colorScheme.primary + : colorScheme.surface.withValues(alpha: 0.85), + shape: BoxShape.circle, + border: Border.all( + color: isSelected + ? colorScheme.primary + : colorScheme.outline, + width: 2, + ), + ), + child: isSelected + ? Icon( + Icons.check, + size: 16, + color: colorScheme.onPrimary, + ) + : const SizedBox(width: 16, height: 16), + ), + ), + ), + ], + ), + ); + }, + ); + } + } + + /// Build a collection item for the unified "All" tab list view. + Widget _buildAllTabListCollectionItem({ + required BuildContext context, + required ColorScheme colorScheme, + required _CollectionEntry entry, + required LibraryCollectionsState collectionState, + List filteredUnifiedItems = const [], + }) { + switch (entry.type) { + case _CollectionEntryType.wishlist: + return _buildCollectionListItem( + context: context, + colorScheme: colorScheme, + icon: Icons.add_circle_outline, + iconColor: Colors.white, + iconBgColor: const Color(0xFF1DB954), + title: context.l10n.collectionWishlist, + subtitle: + '${context.l10n.collectionFoldersTitle} • ${collectionState.wishlistCount} ${collectionState.wishlistCount == 1 ? 'track' : 'tracks'}', + onTap: _openWishlistFolder, + ); + case _CollectionEntryType.loved: + return _buildCollectionListItem( + context: context, + colorScheme: colorScheme, + icon: Icons.favorite, + iconColor: Colors.white, + iconBgColor: const Color(0xFF8C67AC), + title: context.l10n.collectionLoved, + subtitle: + '${context.l10n.collectionFoldersTitle} • ${collectionState.lovedCount} ${collectionState.lovedCount == 1 ? 'track' : 'tracks'}', + onTap: _openLovedFolder, + ); + case _CollectionEntryType.playlist: + final playlist = collectionState.playlists[entry.playlistIndex]; + final isSelected = _selectedPlaylistIds.contains(playlist.id); + return DragTarget( + onWillAcceptWithDetails: (_) => !_isPlaylistSelectionMode, + onAcceptWithDetails: (details) { + _onTrackDroppedOnPlaylist( + context, + details.data, + playlist.id, + playlist.name, + allItems: filteredUnifiedItems, + ); + }, + builder: (context, candidateData, rejectedData) { + final isHovering = candidateData.isNotEmpty; + return AnimatedContainer( + duration: const Duration(milliseconds: 150), + decoration: isHovering + ? BoxDecoration( + borderRadius: BorderRadius.circular(12), + border: Border.all(color: colorScheme.primary, width: 2), + color: colorScheme.primary.withValues(alpha: 0.1), + ) + : null, + child: Row( + children: [ + if (_isPlaylistSelectionMode) + GestureDetector( + onTap: () => _togglePlaylistSelection(playlist.id), + behavior: HitTestBehavior.opaque, + child: Padding( + padding: const EdgeInsets.only(left: 8), + child: Container( + decoration: BoxDecoration( + color: isSelected + ? colorScheme.primary + : Colors.transparent, + shape: BoxShape.circle, + border: Border.all( + color: isSelected + ? colorScheme.primary + : colorScheme.outline, + width: 2, + ), + ), + child: isSelected + ? Icon( + Icons.check, + size: 18, + color: colorScheme.onPrimary, + ) + : const SizedBox(width: 18, height: 18), + ), + ), + ), + Expanded( + child: _buildCollectionListItem( + context: context, + colorScheme: colorScheme, + coverWidget: _buildPlaylistCover( + context, + playlist, + colorScheme, + 56, + ), + title: playlist.name, + subtitle: + '${playlist.tracks.length} ${playlist.tracks.length == 1 ? 'track' : 'tracks'}', + onTap: _isPlaylistSelectionMode + ? () => _togglePlaylistSelection(playlist.id) + : () => _openPlaylistById(playlist.id), + onLongPress: _isPlaylistSelectionMode + ? () => _togglePlaylistSelection(playlist.id) + : () => _enterPlaylistSelectionMode(playlist.id), + ), + ), + ], + ), + ); + }, + ); } } @@ -3789,13 +3805,15 @@ class _QueueTabState extends ConsumerState { ), delegate: SliverChildBuilderDelegate( (context, index) { - final collectionCount = - 2 + collectionState.playlists.length; + final collectionEntries = _getVisibleCollectionEntries( + collectionState, + ); + final collectionCount = collectionEntries.length; if (index < collectionCount) { return _buildAllTabGridCollectionItem( context: context, colorScheme: colorScheme, - index: index, + entry: collectionEntries[index], collectionState: collectionState, filteredUnifiedItems: filteredUnifiedItems, ); @@ -3831,8 +3849,7 @@ class _QueueTabState extends ConsumerState { return const SizedBox.shrink(); }, childCount: - 2 + - collectionState.playlists.length + + _getVisibleCollectionEntries(collectionState).length + filteredUnifiedItems.length, ), ), @@ -3841,12 +3858,15 @@ class _QueueTabState extends ConsumerState { SliverList( delegate: SliverChildBuilderDelegate( (context, index) { - final collectionCount = 2 + collectionState.playlists.length; + final collectionEntries = _getVisibleCollectionEntries( + collectionState, + ); + final collectionCount = collectionEntries.length; if (index < collectionCount) { return _buildAllTabListCollectionItem( context: context, colorScheme: colorScheme, - index: index, + entry: collectionEntries[index], collectionState: collectionState, filteredUnifiedItems: filteredUnifiedItems, ); @@ -3882,8 +3902,7 @@ class _QueueTabState extends ConsumerState { return const SizedBox.shrink(); }, childCount: - 2 + - collectionState.playlists.length + + _getVisibleCollectionEntries(collectionState).length + filteredUnifiedItems.length, ), ), @@ -6372,6 +6391,20 @@ class _QueueItemSliverRow extends ConsumerWidget { } } +enum _CollectionEntryType { wishlist, loved, playlist } + +class _CollectionEntry { + final _CollectionEntryType type; + final int playlistIndex; + + const _CollectionEntry._(this.type, [this.playlistIndex = -1]); + + static const wishlist = _CollectionEntry._(_CollectionEntryType.wishlist); + static const loved = _CollectionEntry._(_CollectionEntryType.loved); + static _CollectionEntry playlist(int index) => + _CollectionEntry._(_CollectionEntryType.playlist, index); +} + class _FilterChip extends StatelessWidget { final String label; final int count; @@ -6389,52 +6422,36 @@ class _FilterChip extends StatelessWidget { Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; - return Material( - color: isSelected - ? colorScheme.primaryContainer - : colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(20), - child: InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(20), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - label, - style: TextStyle( - color: isSelected - ? colorScheme.onPrimaryContainer - : colorScheme.onSurfaceVariant, - fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal, - ), + return FilterChip( + label: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text(label), + const SizedBox(width: 6), + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: isSelected + ? colorScheme.primary.withValues(alpha: 0.2) + : colorScheme.outline.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(10), + ), + child: Text( + count.toString(), + style: TextStyle( + fontSize: 11, + color: isSelected + ? colorScheme.onPrimaryContainer + : colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w500, ), - const SizedBox(width: 6), - Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - decoration: BoxDecoration( - color: isSelected - ? colorScheme.primary.withValues(alpha: 0.2) - : colorScheme.outline.withValues(alpha: 0.2), - borderRadius: BorderRadius.circular(10), - ), - child: Text( - count.toString(), - style: TextStyle( - fontSize: 11, - color: isSelected - ? colorScheme.onPrimaryContainer - : colorScheme.onSurfaceVariant, - fontWeight: FontWeight.w500, - ), - ), - ), - ], + ), ), - ), + ], ), + selected: isSelected, + onSelected: (_) => onTap(), + showCheckmark: false, ); } } diff --git a/lib/screens/store_tab.dart b/lib/screens/store_tab.dart index 63256832..41a9143c 100644 --- a/lib/screens/store_tab.dart +++ b/lib/screens/store_tab.dart @@ -58,7 +58,9 @@ class _StoreTabState extends ConsumerState { final downloadingId = ref.watch( storeProvider.select((s) => s.downloadingId), ); - final hasRegistryUrl = ref.watch(storeProvider.select((s) => s.hasRegistryUrl)); + final hasRegistryUrl = ref.watch( + storeProvider.select((s) => s.hasRegistryUrl), + ); final registryUrl = ref.watch(storeProvider.select((s) => s.registryUrl)); final filteredExtensions = StoreState( extensions: extensions, @@ -139,7 +141,7 @@ class _StoreTabState extends ConsumerState { prefixIcon: const Icon(Icons.search), suffixIcon: value.text.isNotEmpty ? IconButton( - tooltip: 'Clear search', + tooltip: 'Clear', icon: const Icon(Icons.clear), onPressed: () { _searchController.clear(); @@ -151,23 +153,37 @@ class _StoreTabState extends ConsumerState { : null, border: OutlineInputBorder( borderRadius: BorderRadius.circular(28), - borderSide: BorderSide.none, + borderSide: BorderSide( + color: colorScheme.outlineVariant, + ), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(28), + borderSide: BorderSide( + color: colorScheme.outlineVariant, + ), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(28), + borderSide: BorderSide( + color: colorScheme.primary, + width: 2, + ), ), filled: true, - fillColor: - Theme.of(context).brightness == Brightness.dark - ? Color.alphaBlend( - Colors.white.withValues(alpha: 0.08), - colorScheme.surface, - ) - : colorScheme.surfaceContainerHighest, + fillColor: colorScheme.surfaceContainerHighest, contentPadding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 12, + horizontal: 20, + vertical: 16, ), ), onChanged: (value) { - ref.read(storeProvider.notifier).setSearchQuery(value); + ref + .read(storeProvider.notifier) + .setSearchQuery(value); + }, + onTapOutside: (_) { + FocusScope.of(context).unfocus(); }, ); }, @@ -231,7 +247,8 @@ class _StoreTabState extends ConsumerState { _CategoryChip( label: context.l10n.storeFilterIntegration, icon: Icons.link, - isSelected: selectedCategory == StoreCategory.integration, + isSelected: + selectedCategory == StoreCategory.integration, onTap: () => ref .read(storeProvider.notifier) .setCategory(StoreCategory.integration), @@ -309,9 +326,9 @@ class _StoreTabState extends ConsumerState { const SizedBox(height: 24), Text( context.l10n.storeAddRepoTitle, - style: Theme.of(context).textTheme.headlineSmall?.copyWith( - fontWeight: FontWeight.bold, - ), + style: Theme.of( + context, + ).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold), textAlign: TextAlign.center, ), const SizedBox(height: 32), @@ -347,7 +364,11 @@ class _StoreTabState extends ConsumerState { ), child: Row( children: [ - Icon(Icons.error_outline, size: 20, color: colorScheme.onErrorContainer), + Icon( + Icons.error_outline, + size: 20, + color: colorScheme.onErrorContainer, + ), const SizedBox(width: 8), Expanded( child: Text( @@ -503,7 +524,9 @@ class _StoreTabState extends ConsumerState { ), const SizedBox(height: 16), Text( - hasFilters ? context.l10n.storeEmptyNoResults : context.l10n.storeEmptyNoExtensions, + hasFilters + ? context.l10n.storeEmptyNoResults + : context.l10n.storeEmptyNoExtensions, style: Theme.of(context).textTheme.titleMedium?.copyWith( color: colorScheme.onSurfaceVariant, ),