From c673581c32434f9e2cd99b8e1188aacfe33f2eeb Mon Sep 17 00:00:00 2001 From: zarzet Date: Mon, 12 Jan 2026 06:18:32 +0700 Subject: [PATCH] feat: multi-select batch delete and album grouping in history - Add multi-select mode with long-press to select tracks - Add bottom action bar for selection (Material 3 style) - Add filter tabs: All/Albums/Singles - Add album grouping view when Albums filter selected - Add DownloadedAlbumScreen for viewing tracks in an album - Reactive UI updates when tracks deleted - Auto-pop when album has <2 tracks - Update issue templates with (Stable Version) text - Bump version to 2.2.8 --- .github/ISSUE_TEMPLATE/bug_report.yml | 2 +- .github/ISSUE_TEMPLATE/download_issue.yml | 2 +- CHANGELOG.md | 24 + lib/constants/app_info.dart | 4 +- lib/models/settings.dart | 4 + lib/models/settings.g.dart | 2 + lib/providers/settings_provider.dart | 5 + lib/screens/downloaded_album_screen.dart | 573 +++++++ lib/screens/queue_tab.dart | 1376 ++++++++++++----- .../settings/download_settings_page.dart | 19 +- pubspec.yaml | 2 +- pubspec_ios.yaml | 2 +- 12 files changed, 1598 insertions(+), 417 deletions(-) create mode 100644 lib/screens/downloaded_album_screen.dart diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 1b7855d3..e37d4b5d 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -16,7 +16,7 @@ body: options: - label: I have searched existing issues and this bug hasn't been reported yet required: true - - label: I am using the latest version of SpotiFLAC + - label: I am using the latest version of SpotiFLAC (Stable Version) required: true - type: textarea diff --git a/.github/ISSUE_TEMPLATE/download_issue.yml b/.github/ISSUE_TEMPLATE/download_issue.yml index 11cb7e33..aa5973ef 100644 --- a/.github/ISSUE_TEMPLATE/download_issue.yml +++ b/.github/ISSUE_TEMPLATE/download_issue.yml @@ -16,7 +16,7 @@ body: options: - label: I have tried downloading with a different service (Tidal/Qobuz/Amazon) required: true - - label: I am using the latest version of SpotiFLAC + - label: I am using the latest version of SpotiFLAC (Stable Version) required: true - type: dropdown diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e13a7fa..4ba7038b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,29 @@ # Changelog +## [2.2.8] - 2026-01-12 + +### Added + +- **Multi-Select Batch Delete**: Long-press tracks in History to enter selection mode + - Select multiple tracks at once + - "Select All" and "Delete Selected" actions + - Modern Material 3 bottom action bar (slides up from bottom) + - Works in both grid and list view modes +- **History Filter Tabs**: Filter history by All/Albums/Singles + - Album = tracks where album has >1 track in history + - Single = tracks where album has only 1 track in history + - Filter chips show counts for each category +- **Album Grouping View**: When "Albums" filter is selected, tracks are grouped by album + - Album cards displayed in 2-column grid with cover art and track count badge + - Tap album to open dedicated album detail screen + - Album detail shows all downloaded tracks from that album + - Multi-select delete support within album view + - Auto-navigates back when album has <2 tracks remaining + +### Changed + +- **Issue Templates**: Updated version confirmation checkbox to specify "(Stable Version)" + ## [2.2.7] - 2026-01-11 ### Added diff --git a/lib/constants/app_info.dart b/lib/constants/app_info.dart index 0d51fd7d..add92180 100644 --- a/lib/constants/app_info.dart +++ b/lib/constants/app_info.dart @@ -1,8 +1,8 @@ /// App version and info constants /// Update version here only - all other files will reference this class AppInfo { - static const String version = '2.2.7'; - static const String buildNumber = '49'; + static const String version = '2.2.8'; + static const String buildNumber = '50'; static const String fullVersion = '$version+$buildNumber'; diff --git a/lib/models/settings.dart b/lib/models/settings.dart index efab2835..d3accfff 100644 --- a/lib/models/settings.dart +++ b/lib/models/settings.dart @@ -18,6 +18,7 @@ class AppSettings { final bool hasSearchedBefore; // Hide helper text after first search final String folderOrganization; // none, artist, album, artist_album final String historyViewMode; // list, grid + final String historyFilterMode; // all, albums, singles final bool askQualityBeforeDownload; // Show quality picker before each download final String spotifyClientId; // Custom Spotify client ID (empty = use default) final String spotifyClientSecret; // Custom Spotify client secret (empty = use default) @@ -40,6 +41,7 @@ class AppSettings { this.hasSearchedBefore = false, // Default: show helper text this.folderOrganization = 'none', // Default: no folder organization this.historyViewMode = 'grid', // Default: grid view + this.historyFilterMode = 'all', // Default: show all this.askQualityBeforeDownload = true, // Default: ask quality before download this.spotifyClientId = '', // Default: use built-in credentials this.spotifyClientSecret = '', // Default: use built-in credentials @@ -63,6 +65,7 @@ class AppSettings { bool? hasSearchedBefore, String? folderOrganization, String? historyViewMode, + String? historyFilterMode, bool? askQualityBeforeDownload, String? spotifyClientId, String? spotifyClientSecret, @@ -85,6 +88,7 @@ class AppSettings { hasSearchedBefore: hasSearchedBefore ?? this.hasSearchedBefore, folderOrganization: folderOrganization ?? this.folderOrganization, historyViewMode: historyViewMode ?? this.historyViewMode, + historyFilterMode: historyFilterMode ?? this.historyFilterMode, askQualityBeforeDownload: askQualityBeforeDownload ?? this.askQualityBeforeDownload, spotifyClientId: spotifyClientId ?? this.spotifyClientId, spotifyClientSecret: spotifyClientSecret ?? this.spotifyClientSecret, diff --git a/lib/models/settings.g.dart b/lib/models/settings.g.dart index 1446f8a3..6f2d172f 100644 --- a/lib/models/settings.g.dart +++ b/lib/models/settings.g.dart @@ -21,6 +21,7 @@ AppSettings _$AppSettingsFromJson(Map json) => AppSettings( hasSearchedBefore: json['hasSearchedBefore'] as bool? ?? false, folderOrganization: json['folderOrganization'] as String? ?? 'none', historyViewMode: json['historyViewMode'] as String? ?? 'grid', + historyFilterMode: json['historyFilterMode'] as String? ?? 'all', askQualityBeforeDownload: json['askQualityBeforeDownload'] as bool? ?? true, spotifyClientId: json['spotifyClientId'] as String? ?? '', spotifyClientSecret: json['spotifyClientSecret'] as String? ?? '', @@ -46,6 +47,7 @@ Map _$AppSettingsToJson(AppSettings instance) => 'hasSearchedBefore': instance.hasSearchedBefore, 'folderOrganization': instance.folderOrganization, 'historyViewMode': instance.historyViewMode, + 'historyFilterMode': instance.historyFilterMode, 'askQualityBeforeDownload': instance.askQualityBeforeDownload, 'spotifyClientId': instance.spotifyClientId, 'spotifyClientSecret': instance.spotifyClientSecret, diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart index 2020adc0..f162413f 100644 --- a/lib/providers/settings_provider.dart +++ b/lib/providers/settings_provider.dart @@ -148,6 +148,11 @@ class SettingsNotifier extends Notifier { _saveSettings(); } + void setHistoryFilterMode(String mode) { + state = state.copyWith(historyFilterMode: mode); + _saveSettings(); + } + void setAskQualityBeforeDownload(bool enabled) { state = state.copyWith(askQualityBeforeDownload: enabled); _saveSettings(); diff --git a/lib/screens/downloaded_album_screen.dart b/lib/screens/downloaded_album_screen.dart new file mode 100644 index 00000000..3380cda3 --- /dev/null +++ b/lib/screens/downloaded_album_screen.dart @@ -0,0 +1,573 @@ +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:open_filex/open_filex.dart'; +import 'package:spotiflac_android/providers/download_queue_provider.dart'; +import 'package:spotiflac_android/screens/track_metadata_screen.dart'; + +/// Screen to display downloaded tracks from a specific album +class DownloadedAlbumScreen extends ConsumerStatefulWidget { + final String albumName; + final String artistName; + final String? coverUrl; + + const DownloadedAlbumScreen({ + super.key, + required this.albumName, + required this.artistName, + this.coverUrl, + }); + + @override + ConsumerState createState() => _DownloadedAlbumScreenState(); +} + +class _DownloadedAlbumScreenState extends ConsumerState { + // Multi-select state + bool _isSelectionMode = false; + final Set _selectedIds = {}; + + /// Get tracks for this album from history provider (reactive) + List _getAlbumTracks(List allItems) { + return allItems.where((item) { + final itemKey = '${item.albumName}|${item.albumArtist ?? item.artistName}'; + final albumKey = '${widget.albumName}|${widget.artistName}'; + return itemKey == albumKey; + }).toList() + ..sort((a, b) { + final aNum = a.trackNumber ?? 999; + final bNum = b.trackNumber ?? 999; + if (aNum != bNum) return aNum.compareTo(bNum); + return a.trackName.compareTo(b.trackName); + }); + } + + void _enterSelectionMode(String itemId) { + HapticFeedback.mediumImpact(); + setState(() { + _isSelectionMode = true; + _selectedIds.add(itemId); + }); + } + + void _exitSelectionMode() { + setState(() { + _isSelectionMode = false; + _selectedIds.clear(); + }); + } + + void _toggleSelection(String itemId) { + setState(() { + if (_selectedIds.contains(itemId)) { + _selectedIds.remove(itemId); + if (_selectedIds.isEmpty) { + _isSelectionMode = false; + } + } else { + _selectedIds.add(itemId); + } + }); + } + + void _selectAll(List tracks) { + setState(() { + _selectedIds.addAll(tracks.map((e) => e.id)); + }); + } + + Future _deleteSelected(List currentTracks) async { + final count = _selectedIds.length; + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Delete Selected'), + content: Text('Delete $count ${count == 1 ? 'track' : 'tracks'} from this album?\n\nThis will also delete the files from storage.'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.pop(ctx, true), + style: FilledButton.styleFrom( + backgroundColor: Theme.of(context).colorScheme.error, + ), + child: const Text('Delete'), + ), + ], + ), + ); + + if (confirmed == true && mounted) { + final historyNotifier = ref.read(downloadHistoryProvider.notifier); + final idsToDelete = _selectedIds.toList(); + + int deletedCount = 0; + for (final id in idsToDelete) { + final item = currentTracks.where((e) => e.id == id).firstOrNull; + if (item != null) { + try { + final file = File(item.filePath); + if (await file.exists()) { + await file.delete(); + } + } catch (_) {} + historyNotifier.removeFromHistory(id); + deletedCount++; + } + } + + _exitSelectionMode(); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Deleted $deletedCount ${deletedCount == 1 ? 'track' : 'tracks'}')), + ); + } + } + } + + Future _openFile(String filePath) async { + try { + await OpenFilex.open(filePath); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Cannot open file: $e')), + ); + } + } + } + + void _navigateToMetadataScreen(DownloadHistoryItem item) { + Navigator.push(context, PageRouteBuilder( + transitionDuration: const Duration(milliseconds: 300), + reverseTransitionDuration: const Duration(milliseconds: 250), + pageBuilder: (context, animation, secondaryAnimation) => TrackMetadataScreen(item: item), + transitionsBuilder: (context, animation, secondaryAnimation, child) => FadeTransition(opacity: animation, child: child), + )); + } + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final bottomPadding = MediaQuery.of(context).padding.bottom; + + // Watch history and get tracks for this album (reactive!) + final allHistoryItems = ref.watch(downloadHistoryProvider.select((s) => s.items)); + final tracks = _getAlbumTracks(allHistoryItems); + + // Auto-pop if album has less than 2 tracks (no longer an "album") + if (tracks.length < 2) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) Navigator.pop(context); + }); + return const SizedBox.shrink(); + } + + // Clean up selected IDs that no longer exist + final validIds = tracks.map((t) => t.id).toSet(); + _selectedIds.removeWhere((id) => !validIds.contains(id)); + if (_selectedIds.isEmpty && _isSelectionMode) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) setState(() => _isSelectionMode = false); + }); + } + + return PopScope( + canPop: !_isSelectionMode, + onPopInvokedWithResult: (didPop, result) { + if (!didPop && _isSelectionMode) { + _exitSelectionMode(); + } + }, + child: Scaffold( + body: Stack( + children: [ + CustomScrollView( + slivers: [ + _buildAppBar(context, colorScheme), + _buildInfoCard(context, colorScheme, tracks), + _buildTrackListHeader(context, colorScheme, tracks), + _buildTrackList(context, colorScheme, tracks), + SliverToBoxAdapter(child: SizedBox(height: _isSelectionMode ? 120 : 32)), + ], + ), + + // Bottom Selection Action Bar + AnimatedPositioned( + duration: const Duration(milliseconds: 250), + curve: Curves.easeOutCubic, + left: 0, + right: 0, + bottom: _isSelectionMode ? 0 : -(200 + bottomPadding), + child: _buildSelectionBottomBar(context, colorScheme, tracks, bottomPadding), + ), + ], + ), + ), + ); + } + + Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) { + return SliverAppBar( + expandedHeight: 280, + pinned: true, + stretch: true, + backgroundColor: colorScheme.surface, + surfaceTintColor: Colors.transparent, + flexibleSpace: FlexibleSpaceBar( + background: Stack( + fit: StackFit.expand, + children: [ + if (widget.coverUrl != null) + CachedNetworkImage( + imageUrl: widget.coverUrl!, + fit: BoxFit.cover, + color: Colors.black.withValues(alpha: 0.5), + colorBlendMode: BlendMode.darken, + memCacheWidth: 600, + ), + Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.transparent, + colorScheme.surface.withValues(alpha: 0.8), + colorScheme.surface, + ], + stops: const [0.0, 0.7, 1.0], + ), + ), + ), + Center( + child: Padding( + padding: const EdgeInsets.only(top: 60), + child: Container( + width: 140, + height: 140, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.3), + blurRadius: 20, + offset: const Offset(0, 10), + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(16), + child: widget.coverUrl != null + ? CachedNetworkImage(imageUrl: widget.coverUrl!, fit: BoxFit.cover, memCacheWidth: 280) + : Container( + color: colorScheme.surfaceContainerHighest, + child: Icon(Icons.album, size: 48, color: colorScheme.onSurfaceVariant), + ), + ), + ), + ), + ), + ], + ), + stretchModes: const [StretchMode.zoomBackground, StretchMode.blurBackground], + ), + leading: IconButton( + icon: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration(color: colorScheme.surface.withValues(alpha: 0.8), shape: BoxShape.circle), + child: Icon(Icons.arrow_back, color: colorScheme.onSurface), + ), + onPressed: () => Navigator.pop(context), + ), + ); + } + + Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme, List tracks) { + return SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.all(16), + child: Card( + elevation: 0, + color: colorScheme.surfaceContainerLow, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.albumName, + style: Theme.of(context).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold, color: colorScheme.onSurface), + ), + const SizedBox(height: 4), + Text( + widget.artistName, + style: Theme.of(context).textTheme.bodyLarge?.copyWith(color: colorScheme.onSurfaceVariant), + ), + const SizedBox(height: 12), + Row( + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration(color: colorScheme.primaryContainer, borderRadius: BorderRadius.circular(20)), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.download_done, size: 14, color: colorScheme.onPrimaryContainer), + const SizedBox(width: 4), + Text('${tracks.length} downloaded', style: TextStyle(color: colorScheme.onPrimaryContainer, fontWeight: FontWeight.w600, fontSize: 12)), + ], + ), + ), + const SizedBox(width: 8), + if (_getCommonQuality(tracks) != null) + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: _getCommonQuality(tracks)!.startsWith('24') + ? colorScheme.tertiaryContainer + : colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(20), + ), + child: Text( + _getCommonQuality(tracks)!, + style: TextStyle( + color: _getCommonQuality(tracks)!.startsWith('24') + ? colorScheme.onTertiaryContainer + : colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w600, + fontSize: 12, + ), + ), + ), + ], + ), + ], + ), + ), + ), + ), + ); + } + + String? _getCommonQuality(List tracks) { + if (tracks.isEmpty) return null; + final firstQuality = tracks.first.quality; + if (firstQuality == null) return null; + for (final track in tracks) { + if (track.quality != firstQuality) return null; + } + return firstQuality; + } + + Widget _buildTrackListHeader(BuildContext context, ColorScheme colorScheme, List tracks) { + return SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 8, 20, 8), + child: Row( + children: [ + Icon(Icons.queue_music, size: 20, color: colorScheme.primary), + const SizedBox(width: 8), + Text('Tracks', style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600, color: colorScheme.onSurface)), + const Spacer(), + if (!_isSelectionMode) + TextButton.icon( + onPressed: tracks.isNotEmpty ? () => _enterSelectionMode(tracks.first.id) : null, + icon: const Icon(Icons.checklist, size: 18), + label: const Text('Select'), + style: TextButton.styleFrom(visualDensity: VisualDensity.compact), + ), + ], + ), + ), + ); + } + + Widget _buildTrackList(BuildContext context, ColorScheme colorScheme, List tracks) { + return SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) { + final track = tracks[index]; + return KeyedSubtree( + key: ValueKey(track.id), + child: _buildTrackItem(context, colorScheme, track), + ); + }, + childCount: tracks.length, + ), + ); + } + + Widget _buildTrackItem(BuildContext context, ColorScheme colorScheme, DownloadHistoryItem track) { + final isSelected = _selectedIds.contains(track.id); + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Card( + elevation: 0, + color: isSelected ? colorScheme.primaryContainer.withValues(alpha: 0.3) : Colors.transparent, + margin: const EdgeInsets.symmetric(vertical: 2), + child: ListTile( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + onTap: _isSelectionMode + ? () => _toggleSelection(track.id) + : () => _navigateToMetadataScreen(track), + onLongPress: _isSelectionMode ? null : () => _enterSelectionMode(track.id), + leading: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (_isSelectionMode) ...[ + Container( + width: 24, + height: 24, + 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, color: colorScheme.onPrimary, size: 16) + : null, + ), + const SizedBox(width: 12), + ], + SizedBox( + width: 24, + child: Text( + track.trackNumber?.toString() ?? '-', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w500, + ), + textAlign: TextAlign.center, + ), + ), + ], + ), + title: Text( + track.trackName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500), + ), + subtitle: Text( + track.artistName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: colorScheme.onSurfaceVariant), + ), + trailing: _isSelectionMode ? null : IconButton( + onPressed: () => _openFile(track.filePath), + icon: Icon(Icons.play_arrow, color: colorScheme.primary), + style: IconButton.styleFrom( + backgroundColor: colorScheme.primaryContainer.withValues(alpha: 0.3), + ), + ), + ), + ), + ); + } + + Widget _buildSelectionBottomBar(BuildContext context, ColorScheme colorScheme, List tracks, double bottomPadding) { + final selectedCount = _selectedIds.length; + final allSelected = selectedCount == tracks.length && tracks.isNotEmpty; + + return Container( + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHigh, + borderRadius: const BorderRadius.vertical(top: Radius.circular(28)), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.15), + blurRadius: 12, + offset: const Offset(0, -4), + ), + ], + ), + child: SafeArea( + top: false, + child: Padding( + padding: EdgeInsets.fromLTRB(16, 16, 16, bottomPadding > 0 ? 8 : 16), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 32, + height: 4, + margin: const EdgeInsets.only(bottom: 16), + decoration: BoxDecoration( + color: colorScheme.outlineVariant, + borderRadius: BorderRadius.circular(2), + ), + ), + Row( + children: [ + IconButton.filledTonal( + onPressed: _exitSelectionMode, + icon: const Icon(Icons.close), + style: IconButton.styleFrom( + backgroundColor: colorScheme.surfaceContainerHighest, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '$selectedCount selected', + style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), + ), + Text( + allSelected ? 'All tracks selected' : 'Tap tracks to select', + style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant), + ), + ], + ), + ), + TextButton.icon( + onPressed: () { + if (allSelected) { + _exitSelectionMode(); + } else { + _selectAll(tracks); + } + }, + icon: Icon(allSelected ? Icons.deselect : Icons.select_all, size: 20), + label: Text(allSelected ? 'Deselect' : 'Select All'), + style: TextButton.styleFrom(foregroundColor: colorScheme.primary), + ), + ], + ), + const SizedBox(height: 16), + SizedBox( + width: double.infinity, + child: FilledButton.icon( + onPressed: selectedCount > 0 ? () => _deleteSelected(tracks) : null, + icon: const Icon(Icons.delete_outline), + label: Text( + selectedCount > 0 + ? 'Delete $selectedCount ${selectedCount == 1 ? 'track' : 'tracks'}' + : 'Select tracks to delete', + ), + style: FilledButton.styleFrom( + backgroundColor: selectedCount > 0 ? colorScheme.error : colorScheme.surfaceContainerHighest, + foregroundColor: selectedCount > 0 ? colorScheme.onError : colorScheme.onSurfaceVariant, + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + ), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/screens/queue_tab.dart b/lib/screens/queue_tab.dart index cbd10da6..fca21484 100644 --- a/lib/screens/queue_tab.dart +++ b/lib/screens/queue_tab.dart @@ -1,5 +1,6 @@ import 'dart:io'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:open_filex/open_filex.dart'; @@ -7,6 +8,26 @@ import 'package:spotiflac_android/models/download_item.dart'; import 'package:spotiflac_android/providers/download_queue_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/screens/track_metadata_screen.dart'; +import 'package:spotiflac_android/screens/downloaded_album_screen.dart'; + +/// Grouped album data for history display +class _GroupedAlbum { + final String albumName; + final String artistName; + final String? coverUrl; + final List tracks; + final DateTime latestDownload; + + _GroupedAlbum({ + required this.albumName, + required this.artistName, + this.coverUrl, + required this.tracks, + required this.latestDownload, + }); + + String get key => '$albumName|$artistName'; +} class QueueTab extends ConsumerStatefulWidget { const QueueTab({super.key}); @@ -16,30 +37,115 @@ class QueueTab extends ConsumerStatefulWidget { class _QueueTabState extends ConsumerState { final Map _fileExistsCache = {}; - final Set _pendingChecks = {}; // Track pending async checks - static const int _maxCacheSize = 500; // Limit cache size to prevent memory leak + final Set _pendingChecks = {}; + static const int _maxCacheSize = 500; + + // Multi-select state + bool _isSelectionMode = false; + final Set _selectedIds = {}; + + /// Enter selection mode with initial item + void _enterSelectionMode(String itemId) { + HapticFeedback.mediumImpact(); + setState(() { + _isSelectionMode = true; + _selectedIds.add(itemId); + }); + } + + /// Exit selection mode + void _exitSelectionMode() { + setState(() { + _isSelectionMode = false; + _selectedIds.clear(); + }); + } + + /// Toggle item selection + void _toggleSelection(String itemId) { + setState(() { + if (_selectedIds.contains(itemId)) { + _selectedIds.remove(itemId); + if (_selectedIds.isEmpty) { + _isSelectionMode = false; + } + } else { + _selectedIds.add(itemId); + } + }); + } + + /// Select all visible items + void _selectAll(List items) { + setState(() { + _selectedIds.addAll(items.map((e) => e.id)); + }); + } + + /// Delete selected items + Future _deleteSelected() async { + final count = _selectedIds.length; + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Delete Selected'), + content: Text('Delete $count ${count == 1 ? 'track' : 'tracks'} from history?\n\nThis will also delete the files from storage.'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.pop(ctx, true), + style: FilledButton.styleFrom( + backgroundColor: Theme.of(context).colorScheme.error, + ), + child: const Text('Delete'), + ), + ], + ), + ); + + if (confirmed == true && mounted) { + final historyNotifier = ref.read(downloadHistoryProvider.notifier); + final items = ref.read(downloadHistoryProvider).items; + + int deletedCount = 0; + for (final id in _selectedIds) { + final item = items.where((e) => e.id == id).firstOrNull; + if (item != null) { + try { + final file = File(item.filePath); + if (await file.exists()) { + await file.delete(); + } + } catch (_) {} + historyNotifier.removeFromHistory(id); + deletedCount++; + } + } + + _exitSelectionMode(); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Deleted $deletedCount ${deletedCount == 1 ? 'track' : 'tracks'}')), + ); + } + } + } - /// Check if file exists - returns true optimistically while checking - /// This prevents the "red flash" on app start bool _checkFileExists(String? filePath) { if (filePath == null) return false; - - // If already cached, return cached value if (_fileExistsCache.containsKey(filePath)) { return _fileExistsCache[filePath]!; } - - // If check is pending, return true optimistically (assume file exists) if (_pendingChecks.contains(filePath)) { return true; } - - // Limit cache size - remove oldest entry if full if (_fileExistsCache.length >= _maxCacheSize) { _fileExistsCache.remove(_fileExistsCache.keys.first); } - - // Mark as pending and start async check _pendingChecks.add(filePath); Future.microtask(() async { final exists = await File(filePath).exists(); @@ -48,8 +154,6 @@ class _QueueTabState extends ConsumerState { setState(() => _fileExistsCache[filePath] = exists); } }); - - // Return true optimistically while checking return true; } @@ -88,198 +192,657 @@ class _QueueTabState extends ConsumerState { )); } + void _navigateToHistoryMetadataScreen(DownloadHistoryItem item) { + Navigator.push(context, PageRouteBuilder( + transitionDuration: const Duration(milliseconds: 300), + reverseTransitionDuration: const Duration(milliseconds: 250), + pageBuilder: (context, animation, secondaryAnimation) => TrackMetadataScreen(item: item), + transitionsBuilder: (context, animation, secondaryAnimation, child) => FadeTransition(opacity: animation, child: child), + )); + } + + /// Filter history items based on current filter mode + /// Album = track yang albumnya punya >1 track di history + /// Single = track yang albumnya cuma 1 track di history + List _filterHistoryItems(List items, String filterMode) { + if (filterMode == 'all') return items; + + // Count tracks per album + final albumCounts = {}; + for (final item in items) { + final key = '${item.albumName}|${item.albumArtist ?? item.artistName}'; + albumCounts[key] = (albumCounts[key] ?? 0) + 1; + } + + switch (filterMode) { + case 'albums': + // Album = more than 1 track from same album in history + return items.where((item) { + final key = '${item.albumName}|${item.albumArtist ?? item.artistName}'; + return (albumCounts[key] ?? 0) > 1; + }).toList(); + case 'singles': + // Single = only 1 track from that album in history + return items.where((item) { + final key = '${item.albumName}|${item.albumArtist ?? item.artistName}'; + return (albumCounts[key] ?? 0) == 1; + }).toList(); + default: + return items; + } + } + + /// Count albums vs singles for filter chips + Map _countAlbumsAndSingles(List items) { + // Count tracks per album + final albumCounts = {}; + for (final item in items) { + final key = '${item.albumName}|${item.albumArtist ?? item.artistName}'; + albumCounts[key] = (albumCounts[key] ?? 0) + 1; + } + + int albumTracks = 0; + int singleTracks = 0; + + for (final item in items) { + final key = '${item.albumName}|${item.albumArtist ?? item.artistName}'; + if ((albumCounts[key] ?? 0) > 1) { + albumTracks++; + } else { + singleTracks++; + } + } + + return {'albums': albumTracks, 'singles': singleTracks}; + } + + /// Group history items by album (for Albums filter view) + List<_GroupedAlbum> _groupByAlbum(List items) { + final albumMap = >{}; + + for (final item in items) { + final key = '${item.albumName}|${item.albumArtist ?? item.artistName}'; + albumMap.putIfAbsent(key, () => []).add(item); + } + + // Only include albums with more than 1 track + final groupedAlbums = albumMap.entries + .where((e) => e.value.length > 1) + .map((e) { + final tracks = e.value; + // Sort tracks by track number + tracks.sort((a, b) { + final aNum = a.trackNumber ?? 999; + final bNum = b.trackNumber ?? 999; + return aNum.compareTo(bNum); + }); + + return _GroupedAlbum( + albumName: tracks.first.albumName, + artistName: tracks.first.albumArtist ?? tracks.first.artistName, + coverUrl: tracks.first.coverUrl, + tracks: tracks, + latestDownload: tracks.map((t) => t.downloadedAt).reduce((a, b) => a.isAfter(b) ? a : b), + ); + }) + .toList(); + + // Sort by latest download + groupedAlbums.sort((a, b) => b.latestDownload.compareTo(a.latestDownload)); + + return groupedAlbums; + } + + /// Count unique albums (for filter chip badge) + int _countUniqueAlbums(List items) { + final albumKeys = {}; + for (final item in items) { + final key = '${item.albumName}|${item.albumArtist ?? item.artistName}'; + albumKeys.add(key); + } + + // Count albums with more than 1 track + int count = 0; + for (final key in albumKeys) { + final trackCount = items.where((i) => '${i.albumName}|${i.albumArtist ?? i.artistName}' == key).length; + if (trackCount > 1) count++; + } + return count; + } + + void _navigateToDownloadedAlbum(_GroupedAlbum album) { + Navigator.push(context, PageRouteBuilder( + transitionDuration: const Duration(milliseconds: 300), + reverseTransitionDuration: const Duration(milliseconds: 250), + pageBuilder: (context, animation, secondaryAnimation) => DownloadedAlbumScreen( + albumName: album.albumName, + artistName: album.artistName, + coverUrl: album.coverUrl, + ), + transitionsBuilder: (context, animation, secondaryAnimation, child) => FadeTransition(opacity: animation, child: child), + )); + } + @override Widget build(BuildContext context) { - // Use select() to only rebuild when specific fields change final queueItems = ref.watch(downloadQueueProvider.select((s) => s.items)); final isProcessing = ref.watch(downloadQueueProvider.select((s) => s.isProcessing)); final isPaused = ref.watch(downloadQueueProvider.select((s) => s.isPaused)); final queuedCount = ref.watch(downloadQueueProvider.select((s) => s.queuedCount)); final completedCount = ref.watch(downloadQueueProvider.select((s) => s.completedCount)); - final historyItems = ref.watch(downloadHistoryProvider.select((s) => s.items)); + final allHistoryItems = ref.watch(downloadHistoryProvider.select((s) => s.items)); final historyViewMode = ref.watch(settingsProvider.select((s) => s.historyViewMode)); + final historyFilterMode = ref.watch(settingsProvider.select((s) => s.historyFilterMode)); final colorScheme = Theme.of(context).colorScheme; final topPadding = MediaQuery.of(context).padding.top; - return CustomScrollView( - slivers: [ - // Collapsing App Bar - Simplified for performance - SliverAppBar( - expandedHeight: 120 + topPadding, - collapsedHeight: kToolbarHeight, - floating: false, - pinned: true, - backgroundColor: colorScheme.surface, - surfaceTintColor: Colors.transparent, - automaticallyImplyLeading: false, - 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); - - return FlexibleSpaceBar( - expandedTitleScale: 1.0, - titlePadding: const EdgeInsets.only(left: 24, bottom: 16), - title: Text( - 'History', - style: TextStyle( - fontSize: 20 + (14 * expandRatio), // 20 -> 34 - fontWeight: FontWeight.bold, - color: colorScheme.onSurface, - ), - ), - ); - }, - ), - ), + // Filter history items + final historyItems = _filterHistoryItems(allHistoryItems, historyFilterMode); + + // Group albums for Albums filter view + final groupedAlbums = _groupByAlbum(allHistoryItems); + + // Count for filter chips + final counts = _countAlbumsAndSingles(allHistoryItems); + final albumCount = _countUniqueAlbums(allHistoryItems); // Show unique album count + final singleCount = counts['singles'] ?? 0; - // Pause/Resume controls - only show when multiple items or paused - if ((isProcessing || queuedCount > 0) && (queueItems.length > 1 || isPaused)) - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), - child: Card( - child: Padding( - padding: const EdgeInsets.all(12), - child: Row( - children: [ - // Status icon - Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: isPaused - ? colorScheme.errorContainer - : colorScheme.primaryContainer, - borderRadius: BorderRadius.circular(12), - ), - child: Icon( - isPaused ? Icons.pause : Icons.downloading, - color: isPaused - ? colorScheme.onErrorContainer - : colorScheme.onPrimaryContainer, + final bottomPadding = MediaQuery.of(context).padding.bottom; + + return PopScope( + canPop: !_isSelectionMode, + onPopInvokedWithResult: (didPop, result) { + if (!didPop && _isSelectionMode) { + _exitSelectionMode(); + } + }, + child: Stack( + children: [ + CustomScrollView( + slivers: [ + // App Bar - always normal style + SliverAppBar( + expandedHeight: 120 + topPadding, + collapsedHeight: kToolbarHeight, + floating: false, + pinned: true, + backgroundColor: colorScheme.surface, + surfaceTintColor: Colors.transparent, + automaticallyImplyLeading: false, + 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); + + return FlexibleSpaceBar( + expandedTitleScale: 1.0, + titlePadding: const EdgeInsets.only(left: 24, bottom: 16), + title: Text( + 'History', + style: TextStyle( + fontSize: 20 + (14 * expandRatio), + fontWeight: FontWeight.bold, + color: colorScheme.onSurface, ), ), - const SizedBox(width: 12), - // Status text - simplified - Expanded( - child: Text( - isPaused - ? 'Paused' - : '$completedCount/${queueItems.length}', - style: Theme.of(context).textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.bold, + ); + }, + ), + ), + + // Pause/Resume controls + if ((isProcessing || queuedCount > 0) && (queueItems.length > 1 || isPaused)) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), + child: Card( + child: Padding( + padding: const EdgeInsets.all(12), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: isPaused ? colorScheme.errorContainer : colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + isPaused ? Icons.pause : Icons.downloading, + color: isPaused ? colorScheme.onErrorContainer : colorScheme.onPrimaryContainer, ), ), + const SizedBox(width: 12), + Expanded( + child: Text( + isPaused ? 'Paused' : '$completedCount/${queueItems.length}', + style: Theme.of(context).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold), + ), + ), + FilledButton.tonal( + onPressed: () => ref.read(downloadQueueProvider.notifier).togglePause(), + child: Text(isPaused ? 'Resume' : 'Pause'), + ), + ], + ), + ), + ), + ), + ), + + // Queue header + if (queueItems.isNotEmpty) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), + child: Text('Downloading (${queueItems.length})', + style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)), + ), + ), + + // Queue list + if (queueItems.isNotEmpty) + SliverList(delegate: SliverChildBuilderDelegate( + (context, index) { + final item = queueItems[index]; + return KeyedSubtree( + key: ValueKey(item.id), + child: _buildQueueItem(context, item, colorScheme), + ); + }, + childCount: queueItems.length, + )), + + // Filter chips (only show when history has items) + if (allHistoryItems.isNotEmpty) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 4), + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + _FilterChip( + label: 'All', + count: allHistoryItems.length, + isSelected: historyFilterMode == 'all', + onTap: () => ref.read(settingsProvider.notifier).setHistoryFilterMode('all'), ), - // Pause/Resume button - FilledButton.tonal( - onPressed: () => ref.read(downloadQueueProvider.notifier).togglePause(), - child: Text(isPaused ? 'Resume' : 'Pause'), + const SizedBox(width: 8), + _FilterChip( + label: 'Albums', + count: albumCount, + isSelected: historyFilterMode == 'albums', + onTap: () => ref.read(settingsProvider.notifier).setHistoryFilterMode('albums'), + ), + const SizedBox(width: 8), + _FilterChip( + label: 'Singles', + count: singleCount, + isSelected: historyFilterMode == 'singles', + onTap: () => ref.read(settingsProvider.notifier).setHistoryFilterMode('singles'), ), ], ), ), ), ), - ), - // Queue header - if (queueItems.isNotEmpty) - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), - child: Text('Downloading (${queueItems.length})', - style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)), + // History section header + if (historyItems.isNotEmpty && queueItems.isEmpty && historyFilterMode != 'albums') + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), + child: Row( + children: [ + Text('${historyItems.length} ${historyItems.length == 1 ? 'track' : 'tracks'}', + style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)), + const Spacer(), + if (!_isSelectionMode) + TextButton.icon( + onPressed: historyItems.isNotEmpty ? () => _enterSelectionMode(historyItems.first.id) : null, + icon: const Icon(Icons.checklist, size: 18), + label: const Text('Select'), + style: TextButton.styleFrom( + visualDensity: VisualDensity.compact, + ), + ), + ], + ), + ), ), - ), - // Queue list with keys for efficient updates - if (queueItems.isNotEmpty) - SliverList(delegate: SliverChildBuilderDelegate( - (context, index) { - final item = queueItems[index]; - return KeyedSubtree( - key: ValueKey(item.id), - child: _buildQueueItem(context, item, colorScheme), - ); - }, - childCount: queueItems.length, - )), - - // History section header - show count only - if (historyItems.isNotEmpty && queueItems.isEmpty) - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), - child: Text('${historyItems.length} ${historyItems.length == 1 ? 'track' : 'tracks'}', - style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)), + // Albums section header (when Albums filter is selected) + if (groupedAlbums.isNotEmpty && queueItems.isEmpty && historyFilterMode == 'albums') + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), + child: Text('${groupedAlbums.length} ${groupedAlbums.length == 1 ? 'album' : 'albums'}', + style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)), + ), ), - ), - // History section header when queue has items (show "Downloaded" label) - if (historyItems.isNotEmpty && queueItems.isNotEmpty) - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), - child: Text('Downloaded', - style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)), + // History section header when queue has items + if (historyItems.isNotEmpty && queueItems.isNotEmpty) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), + child: Text('Downloaded', + style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)), + ), ), - ), - // History - Grid or List based on setting (with keys) - if (historyItems.isNotEmpty) - historyViewMode == 'grid' - ? SliverPadding( - padding: const EdgeInsets.symmetric(horizontal: 16), - sliver: SliverGrid( - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 3, - mainAxisSpacing: 8, - crossAxisSpacing: 8, - childAspectRatio: 0.75, - ), - delegate: SliverChildBuilderDelegate( - (context, index) { - final item = historyItems[index]; - return KeyedSubtree( - key: ValueKey(item.id), - child: _buildHistoryGridItem(context, item, colorScheme), - ); - }, - childCount: historyItems.length, - ), - ), - ) - : SliverList(delegate: SliverChildBuilderDelegate( + // Albums Grid (when Albums filter is selected) + if (historyFilterMode == 'albums' && groupedAlbums.isNotEmpty) + SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 16), + sliver: SliverGrid( + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + mainAxisSpacing: 12, + crossAxisSpacing: 12, + childAspectRatio: 0.75, + ), + delegate: SliverChildBuilderDelegate( (context, index) { - final item = historyItems[index]; + final album = groupedAlbums[index]; return KeyedSubtree( - key: ValueKey(item.id), - child: _buildHistoryItem(context, item, colorScheme), + key: ValueKey(album.key), + child: _buildAlbumGridItem(context, album, colorScheme), ); }, - childCount: historyItems.length, - )), + childCount: groupedAlbums.length, + ), + ), + ), - // Empty state when both queue and history are empty - if (queueItems.isEmpty && historyItems.isEmpty) - SliverFillRemaining(hasScrollBody: false, child: _buildEmptyState(context, colorScheme)) - else - const SliverToBoxAdapter(child: SizedBox(height: 16)), - ], + // History - Grid or List (for All and Singles filter) + if (historyItems.isNotEmpty && historyFilterMode != 'albums') + historyViewMode == 'grid' + ? SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 16), + sliver: SliverGrid( + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + mainAxisSpacing: 8, + crossAxisSpacing: 8, + childAspectRatio: 0.75, + ), + delegate: SliverChildBuilderDelegate( + (context, index) { + final item = historyItems[index]; + return KeyedSubtree( + key: ValueKey(item.id), + child: _buildHistoryGridItem(context, item, colorScheme), + ); + }, + childCount: historyItems.length, + ), + ), + ) + : SliverList(delegate: SliverChildBuilderDelegate( + (context, index) { + final item = historyItems[index]; + return KeyedSubtree( + key: ValueKey(item.id), + child: _buildHistoryItem(context, item, colorScheme), + ); + }, + childCount: historyItems.length, + )), + + // Empty state + if (queueItems.isEmpty && historyItems.isEmpty && (historyFilterMode != 'albums' || groupedAlbums.isEmpty)) + SliverFillRemaining(hasScrollBody: false, child: _buildEmptyState(context, colorScheme, historyFilterMode)) + else + // Add bottom padding when selection mode is active to avoid overlap with bottom bar + SliverToBoxAdapter(child: SizedBox(height: _isSelectionMode ? 100 : 16)), + ], + ), + + // Bottom Selection Action Bar + AnimatedPositioned( + duration: const Duration(milliseconds: 250), + curve: Curves.easeOutCubic, + left: 0, + right: 0, + bottom: _isSelectionMode ? 0 : -(200 + bottomPadding), + child: _buildSelectionBottomBar(context, colorScheme, historyItems, bottomPadding), + ), + ], + ), +); + } + + Widget _buildEmptyState(BuildContext context, ColorScheme colorScheme, String filterMode) { + String message; + String subtitle; + IconData icon; + + switch (filterMode) { + case 'albums': + message = 'No album downloads'; + subtitle = 'Download multiple tracks from an album to see them here'; + icon = Icons.album; + break; + case 'singles': + message = 'No single downloads'; + subtitle = 'Single track downloads will appear here'; + icon = Icons.music_note; + break; + default: + message = 'No download history'; + subtitle = 'Downloaded tracks will appear here'; + icon = Icons.history; + } + + return Center( + child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [ + Icon(icon, size: 64, color: colorScheme.onSurfaceVariant), + const SizedBox(height: 16), + Text(message, style: Theme.of(context).textTheme.bodyLarge?.copyWith(color: colorScheme.onSurfaceVariant)), + const SizedBox(height: 8), + Text(subtitle, style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7))), + ]), ); } - Widget _buildEmptyState(BuildContext context, ColorScheme colorScheme) => Center( - child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon(Icons.history, size: 64, color: colorScheme.onSurfaceVariant), - const SizedBox(height: 16), - Text('No download history', style: Theme.of(context).textTheme.bodyLarge?.copyWith(color: colorScheme.onSurfaceVariant)), - const SizedBox(height: 8), - Text('Downloaded tracks will appear here', style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7))), - ]), - ); + /// Build album grid item for grouped albums view + Widget _buildAlbumGridItem(BuildContext context, _GroupedAlbum album, ColorScheme colorScheme) { + return GestureDetector( + onTap: () => _navigateToDownloadedAlbum(album), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Album cover with track count badge + Expanded( + child: Stack( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(12), + child: album.coverUrl != null + ? CachedNetworkImage( + imageUrl: album.coverUrl!, + fit: BoxFit.cover, + width: double.infinity, + height: double.infinity, + memCacheWidth: 300, + memCacheHeight: 300, + ) + : Container( + color: colorScheme.surfaceContainerHighest, + child: Center(child: Icon(Icons.album, color: colorScheme.onSurfaceVariant, size: 48)), + ), + ), + // Track count badge + Positioned( + right: 8, + bottom: 8, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.music_note, size: 12, color: colorScheme.onPrimaryContainer), + const SizedBox(width: 4), + Text( + '${album.tracks.length}', + style: TextStyle( + color: colorScheme.onPrimaryContainer, + fontSize: 12, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ), + ), + ], + ), + ), + const SizedBox(height: 8), + // Album name + Text( + album.albumName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600), + ), + // Artist name + Text( + album.artistName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant), + ), + ], + ), + ); + } + + /// Bottom action bar for selection mode (Material Design 3 style) + Widget _buildSelectionBottomBar(BuildContext context, ColorScheme colorScheme, List historyItems, double bottomPadding) { + final selectedCount = _selectedIds.length; + final allSelected = selectedCount == historyItems.length && historyItems.isNotEmpty; + + return Container( + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHigh, + borderRadius: const BorderRadius.vertical(top: Radius.circular(28)), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.15), + blurRadius: 12, + offset: const Offset(0, -4), + ), + ], + ), + child: SafeArea( + top: false, + child: Padding( + padding: EdgeInsets.fromLTRB(16, 16, 16, bottomPadding > 0 ? 8 : 16), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Handle bar + Container( + width: 32, + height: 4, + margin: const EdgeInsets.only(bottom: 16), + decoration: BoxDecoration( + color: colorScheme.outlineVariant, + borderRadius: BorderRadius.circular(2), + ), + ), + + // Selection info row + Row( + children: [ + // Close button + IconButton.filledTonal( + onPressed: _exitSelectionMode, + icon: const Icon(Icons.close), + style: IconButton.styleFrom( + backgroundColor: colorScheme.surfaceContainerHighest, + ), + ), + const SizedBox(width: 12), + + // Selection count + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '$selectedCount selected', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + Text( + allSelected ? 'All tracks selected' : 'Tap tracks to select', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + + // Select all toggle + TextButton.icon( + onPressed: () { + if (allSelected) { + _exitSelectionMode(); + } else { + _selectAll(historyItems); + } + }, + icon: Icon( + allSelected ? Icons.deselect : Icons.select_all, + size: 20, + ), + label: Text(allSelected ? 'Deselect' : 'Select All'), + style: TextButton.styleFrom( + foregroundColor: colorScheme.primary, + ), + ), + ], + ), + + const SizedBox(height: 16), + + // Delete button + SizedBox( + width: double.infinity, + child: FilledButton.icon( + onPressed: selectedCount > 0 ? _deleteSelected : null, + icon: const Icon(Icons.delete_outline), + label: Text( + selectedCount > 0 + ? 'Delete $selectedCount ${selectedCount == 1 ? 'track' : 'tracks'}' + : 'Select tracks to delete', + ), + style: FilledButton.styleFrom( + backgroundColor: selectedCount > 0 ? colorScheme.error : colorScheme.surfaceContainerHighest, + foregroundColor: selectedCount > 0 ? colorScheme.onError : colorScheme.onSurfaceVariant, + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + ), + ), + ), + ], + ), + ), + ), + ); + } Widget _buildQueueItem(BuildContext context, DownloadItem item, ColorScheme colorScheme) { final isCompleted = item.status == DownloadStatus.completed; @@ -293,16 +856,10 @@ class _QueueTabState extends ConsumerState { padding: const EdgeInsets.all(12), child: Row( children: [ - // Cover art with Hero for completed items isCompleted - ? Hero( - tag: 'cover_${item.id}', - child: _buildCoverArt(item, colorScheme), - ) + ? Hero(tag: 'cover_${item.id}', child: _buildCoverArt(item, colorScheme)) : _buildCoverArt(item, colorScheme), const SizedBox(width: 12), - - // Track info Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -311,18 +868,14 @@ class _QueueTabState extends ConsumerState { item.track.name, maxLines: 1, overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w600, - ), + style: Theme.of(context).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600), ), const SizedBox(height: 2), Text( item.track.artistName, maxLines: 1, overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), + style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant), ), if (item.status == DownloadStatus.downloading) ...[ const SizedBox(height: 8), @@ -340,7 +893,6 @@ class _QueueTabState extends ConsumerState { ), ), const SizedBox(width: 8), - // Show percentage and speed Text( item.speedMBps > 0 ? '${(item.progress * 100).toStringAsFixed(0)}% • ${item.speedMBps.toStringAsFixed(1)} MB/s' @@ -356,20 +908,16 @@ class _QueueTabState extends ConsumerState { if (item.status == DownloadStatus.failed) ...[ const SizedBox(height: 4), Text( - item.errorMessage, // Use user-friendly error message + item.errorMessage, maxLines: 1, overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: colorScheme.error, - ), + style: Theme.of(context).textTheme.labelSmall?.copyWith(color: colorScheme.error), ), ], ], ), ), const SizedBox(width: 8), - - // Action buttons based on status _buildActionButtons(context, item, colorScheme), ], ), @@ -405,58 +953,32 @@ class _QueueTabState extends ConsumerState { Widget _buildActionButtons(BuildContext context, DownloadItem item, ColorScheme colorScheme) { switch (item.status) { case DownloadStatus.queued: - // Queued: Show cancel button - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - onPressed: () => ref.read(downloadQueueProvider.notifier).cancelItem(item.id), - icon: Icon(Icons.close, color: colorScheme.error), - tooltip: 'Cancel', - style: IconButton.styleFrom( - backgroundColor: colorScheme.errorContainer.withValues(alpha: 0.3), - ), - ), - ], + return IconButton( + onPressed: () => ref.read(downloadQueueProvider.notifier).cancelItem(item.id), + icon: Icon(Icons.close, color: colorScheme.error), + tooltip: 'Cancel', + style: IconButton.styleFrom(backgroundColor: colorScheme.errorContainer.withValues(alpha: 0.3)), ); - case DownloadStatus.downloading: - // Downloading: Show stop button - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - onPressed: () => ref.read(downloadQueueProvider.notifier).cancelItem(item.id), - icon: Icon(Icons.stop, color: colorScheme.error), - tooltip: 'Stop', - style: IconButton.styleFrom( - backgroundColor: colorScheme.errorContainer.withValues(alpha: 0.3), - ), - ), - ], + return IconButton( + onPressed: () => ref.read(downloadQueueProvider.notifier).cancelItem(item.id), + icon: Icon(Icons.stop, color: colorScheme.error), + tooltip: 'Stop', + style: IconButton.styleFrom(backgroundColor: colorScheme.errorContainer.withValues(alpha: 0.3)), ); - case DownloadStatus.finalizing: - // Finalizing: Show spinner with edit icon (embedding metadata) - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - SizedBox( - width: 40, - height: 40, - child: Stack( - alignment: Alignment.center, - children: [ - CircularProgressIndicator(strokeWidth: 3, color: colorScheme.tertiary), - Icon(Icons.edit_note, color: colorScheme.tertiary, size: 16), - ], - ), - ), - ], + return SizedBox( + width: 40, + height: 40, + child: Stack( + alignment: Alignment.center, + children: [ + CircularProgressIndicator(strokeWidth: 3, color: colorScheme.tertiary), + Icon(Icons.edit_note, color: colorScheme.tertiary, size: 16), + ], + ), ); - case DownloadStatus.completed: - // Completed: Show play button and check icon final fileExists = _checkFileExists(item.filePath); return Row( mainAxisSize: MainAxisSize.min, @@ -466,51 +988,20 @@ class _QueueTabState extends ConsumerState { onPressed: () => _openFile(item.filePath!), icon: Icon(Icons.play_arrow, color: colorScheme.primary), tooltip: 'Play', - style: IconButton.styleFrom( - backgroundColor: colorScheme.primaryContainer.withValues(alpha: 0.3), - ), + style: IconButton.styleFrom(backgroundColor: colorScheme.primaryContainer.withValues(alpha: 0.3)), ) else Icon(Icons.error_outline, color: colorScheme.error, size: 20), const SizedBox(width: 4), Container( padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: colorScheme.primaryContainer, - shape: BoxShape.circle, - ), + decoration: BoxDecoration(color: colorScheme.primaryContainer, shape: BoxShape.circle), child: Icon(Icons.check, color: colorScheme.onPrimaryContainer, size: 20), ), ], ); - case DownloadStatus.failed: - // Failed: Show retry and remove buttons - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - onPressed: () => ref.read(downloadQueueProvider.notifier).retryItem(item.id), - icon: Icon(Icons.refresh, color: colorScheme.primary), - tooltip: 'Retry', - style: IconButton.styleFrom( - backgroundColor: colorScheme.primaryContainer.withValues(alpha: 0.3), - ), - ), - const SizedBox(width: 4), - IconButton( - onPressed: () => ref.read(downloadQueueProvider.notifier).removeItem(item.id), - icon: Icon(Icons.close, color: colorScheme.error), - tooltip: 'Remove', - style: IconButton.styleFrom( - backgroundColor: colorScheme.errorContainer.withValues(alpha: 0.3), - ), - ), - ], - ); - case DownloadStatus.skipped: - // Skipped: Show retry and remove buttons return Row( mainAxisSize: MainAxisSize.min, children: [ @@ -518,155 +1009,186 @@ class _QueueTabState extends ConsumerState { onPressed: () => ref.read(downloadQueueProvider.notifier).retryItem(item.id), icon: Icon(Icons.refresh, color: colorScheme.primary), tooltip: 'Retry', - style: IconButton.styleFrom( - backgroundColor: colorScheme.primaryContainer.withValues(alpha: 0.3), - ), + style: IconButton.styleFrom(backgroundColor: colorScheme.primaryContainer.withValues(alpha: 0.3)), ), const SizedBox(width: 4), IconButton( onPressed: () => ref.read(downloadQueueProvider.notifier).removeItem(item.id), - icon: Icon(Icons.close, color: colorScheme.onSurfaceVariant), + icon: Icon(Icons.close, color: item.status == DownloadStatus.failed ? colorScheme.error : colorScheme.onSurfaceVariant), tooltip: 'Remove', + style: item.status == DownloadStatus.failed + ? IconButton.styleFrom(backgroundColor: colorScheme.errorContainer.withValues(alpha: 0.3)) + : null, ), ], ); } } - void _navigateToHistoryMetadataScreen(DownloadHistoryItem item) { - Navigator.push(context, PageRouteBuilder( - transitionDuration: const Duration(milliseconds: 300), - reverseTransitionDuration: const Duration(milliseconds: 250), - pageBuilder: (context, animation, secondaryAnimation) => TrackMetadataScreen(item: item), - transitionsBuilder: (context, animation, secondaryAnimation, child) => FadeTransition(opacity: animation, child: child), - )); - } - Widget _buildHistoryGridItem(BuildContext context, DownloadHistoryItem item, ColorScheme colorScheme) { final fileExists = _checkFileExists(item.filePath); + final isSelected = _selectedIds.contains(item.id); return GestureDetector( - onTap: () => _navigateToHistoryMetadataScreen(item), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + onTap: _isSelectionMode + ? () => _toggleSelection(item.id) + : () => _navigateToHistoryMetadataScreen(item), + onLongPress: _isSelectionMode ? null : () => _enterSelectionMode(item.id), + child: Stack( children: [ - // Cover art with play button overlay - Stack( + Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - AspectRatio( - aspectRatio: 1, - child: ClipRRect( - borderRadius: BorderRadius.circular(8), - child: item.coverUrl != null - ? CachedNetworkImage( - imageUrl: item.coverUrl!, - fit: BoxFit.cover, - memCacheWidth: 200, - memCacheHeight: 200, - ) - : Container( - color: colorScheme.surfaceContainerHighest, - child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant, size: 32), + Stack( + children: [ + AspectRatio( + aspectRatio: 1, + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: item.coverUrl != null + ? CachedNetworkImage( + imageUrl: item.coverUrl!, + fit: BoxFit.cover, + memCacheWidth: 200, + memCacheHeight: 200, + ) + : Container( + color: colorScheme.surfaceContainerHighest, + child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant, size: 32), + ), + ), + ), + // Quality badge + if (item.quality != null && item.quality!.contains('bit')) + Positioned( + left: 4, + top: 4, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), + decoration: BoxDecoration( + color: item.quality!.startsWith('24') ? colorScheme.tertiary : colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(4), ), - ), + child: Text( + item.quality!.split('/').first, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: item.quality!.startsWith('24') ? colorScheme.onTertiary : colorScheme.onSurfaceVariant, + fontSize: 9, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + // Play button + if (fileExists && !_isSelectionMode) + Positioned( + right: 4, + bottom: 4, + child: GestureDetector( + onTap: () => _openFile(item.filePath), + child: Container( + padding: const EdgeInsets.all(6), + decoration: BoxDecoration(color: colorScheme.primary, shape: BoxShape.circle), + child: Icon(Icons.play_arrow, color: colorScheme.onPrimary, size: 16), + ), + ), + ), + // Error indicator + if (!fileExists && !_isSelectionMode) + Positioned( + right: 4, + bottom: 4, + child: Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration(color: colorScheme.errorContainer, shape: BoxShape.circle), + child: Icon(Icons.error_outline, color: colorScheme.error, size: 14), + ), + ), + // Selection overlay + if (_isSelectionMode) + Positioned.fill( + child: Container( + decoration: BoxDecoration( + color: isSelected ? colorScheme.primary.withValues(alpha: 0.3) : Colors.transparent, + borderRadius: BorderRadius.circular(8), + ), + ), + ), + ], + ), + const SizedBox(height: 6), + Text( + item.trackName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall?.copyWith(fontWeight: FontWeight.w500), + ), + Text( + item.artistName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.labelSmall?.copyWith(color: colorScheme.onSurfaceVariant), ), - // Quality badge (top-left) - if (item.quality != null && item.quality!.contains('bit')) - Positioned( - left: 4, - top: 4, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), - decoration: BoxDecoration( - color: item.quality!.startsWith('24') - ? colorScheme.tertiary - : colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(4), - ), - child: Text( - item.quality!.split('/').first, // Just show "24-bit" or "16-bit" - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: item.quality!.startsWith('24') - ? colorScheme.onTertiary - : colorScheme.onSurfaceVariant, - fontSize: 9, - fontWeight: FontWeight.w600, - ), - ), - ), - ), - // Play button overlay - if (fileExists) - Positioned( - right: 4, - bottom: 4, - child: GestureDetector( - onTap: () => _openFile(item.filePath), - child: Container( - padding: const EdgeInsets.all(6), - decoration: BoxDecoration( - color: colorScheme.primary, - shape: BoxShape.circle, - ), - child: Icon(Icons.play_arrow, color: colorScheme.onPrimary, size: 16), - ), - ), - ), - // Error indicator if file missing - if (!fileExists) - Positioned( - right: 4, - bottom: 4, - child: Container( - padding: const EdgeInsets.all(4), - decoration: BoxDecoration( - color: colorScheme.errorContainer, - shape: BoxShape.circle, - ), - child: Icon(Icons.error_outline, color: colorScheme.error, size: 14), - ), - ), ], ), - const SizedBox(height: 6), - // Track name - Text( - item.trackName, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - fontWeight: FontWeight.w500, + // Selection checkbox + if (_isSelectionMode) + Positioned( + right: 4, + top: 4, + child: Container( + decoration: BoxDecoration( + color: isSelected ? colorScheme.primary : colorScheme.surface, + shape: BoxShape.circle, + border: Border.all(color: isSelected ? colorScheme.primary : colorScheme.outline, width: 2), + ), + child: isSelected + ? Icon(Icons.check, color: colorScheme.onPrimary, size: 16) + : const SizedBox(width: 16, height: 16), + ), ), - ), - // Artist name - Text( - item.artistName, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), ], ), ); } + Widget _buildHistoryItem(BuildContext context, DownloadHistoryItem item, ColorScheme colorScheme) { final fileExists = _checkFileExists(item.filePath); + final isSelected = _selectedIds.contains(item.id); final date = item.downloadedAt; final months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; final dateStr = '${months[date.month - 1]} ${date.day}, ${date.hour.toString().padLeft(2, '0')}:${date.minute.toString().padLeft(2, '0')}'; return Card( margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + color: isSelected ? colorScheme.primaryContainer.withValues(alpha: 0.3) : null, child: InkWell( - onTap: () => _navigateToHistoryMetadataScreen(item), + onTap: _isSelectionMode + ? () => _toggleSelection(item.id) + : () => _navigateToHistoryMetadataScreen(item), + onLongPress: _isSelectionMode ? null : () => _enterSelectionMode(item.id), borderRadius: BorderRadius.circular(12), child: Padding( padding: const EdgeInsets.all(12), child: Row( children: [ + // Selection checkbox + if (_isSelectionMode) ...[ + Container( + width: 24, + height: 24, + 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, color: colorScheme.onPrimary, size: 16) + : null, + ), + const SizedBox(width: 12), + ], // Cover art item.coverUrl != null ? ClipRRect( @@ -700,18 +1222,14 @@ class _QueueTabState extends ConsumerState { item.trackName, maxLines: 1, overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w600, - ), + style: Theme.of(context).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600), ), const SizedBox(height: 2), Text( item.artistName, maxLines: 1, overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), + style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant), ), const SizedBox(height: 2), Row( @@ -722,7 +1240,6 @@ class _QueueTabState extends ConsumerState { color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7), ), ), - // Quality badge if (item.quality != null && item.quality!.contains('bit')) ...[ const SizedBox(width: 8), Container( @@ -752,22 +1269,85 @@ class _QueueTabState extends ConsumerState { ), const SizedBox(width: 8), - // Action buttons - Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (fileExists) - IconButton( - onPressed: () => _openFile(item.filePath), - icon: Icon(Icons.play_arrow, color: colorScheme.primary), - tooltip: 'Play', - style: IconButton.styleFrom( - backgroundColor: colorScheme.primaryContainer.withValues(alpha: 0.3), - ), - ) - else - Icon(Icons.error_outline, color: colorScheme.error, size: 20), - ], + // Action buttons (hide in selection mode) + if (!_isSelectionMode) + Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (fileExists) + IconButton( + onPressed: () => _openFile(item.filePath), + icon: Icon(Icons.play_arrow, color: colorScheme.primary), + tooltip: 'Play', + style: IconButton.styleFrom( + backgroundColor: colorScheme.primaryContainer.withValues(alpha: 0.3), + ), + ) + else + Icon(Icons.error_outline, color: colorScheme.error, size: 20), + ], + ), + ], + ), + ), + ), + ); + } +} + +/// Filter chip widget for history filtering +class _FilterChip extends StatelessWidget { + final String label; + final int count; + final bool isSelected; + final VoidCallback onTap; + + const _FilterChip({ + required this.label, + required this.count, + required this.isSelected, + required this.onTap, + }); + + @override + 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, + ), + ), + 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, + ), + ), ), ], ), diff --git a/lib/screens/settings/download_settings_page.dart b/lib/screens/settings/download_settings_page.dart index c08fdadd..055278dd 100644 --- a/lib/screens/settings/download_settings_page.dart +++ b/lib/screens/settings/download_settings_page.dart @@ -359,8 +359,9 @@ class DownloadSettingsPage extends ConsumerWidget { } else { // Android: Use file picker final result = await FilePicker.platform.getDirectoryPath(); - if (result != null) + if (result != null) { ref.read(settingsProvider.notifier).setDownloadDirectory(result); + } } } @@ -512,9 +513,7 @@ class DownloadSettingsPage extends ConsumerWidget { example: 'SpotiFLAC/Track.flac', isSelected: current == 'none', onTap: () { - ref - .read(settingsProvider.notifier) - .setFolderOrganization('none'); + ref.read(settingsProvider.notifier).setFolderOrganization('none'); Navigator.pop(context); }, ), @@ -524,9 +523,7 @@ class DownloadSettingsPage extends ConsumerWidget { example: 'SpotiFLAC/Artist Name/Track.flac', isSelected: current == 'artist', onTap: () { - ref - .read(settingsProvider.notifier) - .setFolderOrganization('artist'); + ref.read(settingsProvider.notifier).setFolderOrganization('artist'); Navigator.pop(context); }, ), @@ -536,9 +533,7 @@ class DownloadSettingsPage extends ConsumerWidget { example: 'SpotiFLAC/Album Name/Track.flac', isSelected: current == 'album', onTap: () { - ref - .read(settingsProvider.notifier) - .setFolderOrganization('album'); + ref.read(settingsProvider.notifier).setFolderOrganization('album'); Navigator.pop(context); }, ), @@ -548,9 +543,7 @@ class DownloadSettingsPage extends ConsumerWidget { example: 'SpotiFLAC/Artist/Album/Track.flac', isSelected: current == 'artist_album', onTap: () { - ref - .read(settingsProvider.notifier) - .setFolderOrganization('artist_album'); + ref.read(settingsProvider.notifier).setFolderOrganization('artist_album'); Navigator.pop(context); }, ), diff --git a/pubspec.yaml b/pubspec.yaml index 3eec9e85..60513023 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: spotiflac_android description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music publish_to: "none" -version: 2.2.7+49 +version: 2.2.8+50 environment: sdk: ^3.10.0 diff --git a/pubspec_ios.yaml b/pubspec_ios.yaml index 8e383a2e..1c08e30c 100644 --- a/pubspec_ios.yaml +++ b/pubspec_ios.yaml @@ -1,7 +1,7 @@ name: spotiflac_android description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music publish_to: "none" -version: 2.2.7+49 +version: 2.2.8+50 environment: sdk: ^3.10.0