diff --git a/.gitignore b/.gitignore
index 8a5e5209..72bdf220 100644
--- a/.gitignore
+++ b/.gitignore
@@ -56,6 +56,9 @@ android/app/libs/gobackend-sources.jar
# Extension folder
extension/
+
+# Agent instructions
AGENTS.md
+
+# Temp/misc
nul
-/extension
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9999deda..a2cbf055 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -80,6 +80,13 @@ SpotiFLAC 3.0 introduces a powerful extension system that allows third-party int
- One-tap install, update, and uninstall
- Offline cache for browsing without internet
+#### Spotify Web Extension
+
+- Available in Extension Store - install and enable in Settings > Extensions
+- Metadata provider using Spotify's internal web player API
+- Download tracks from Daily Mix, Discover Weekly, and other personalized playlists
+- Useful when official Spotify API is rate-limited or unavailable
+
#### Extension Capabilities
- **Custom Search Providers**
@@ -128,8 +135,31 @@ SpotiFLAC 3.0 introduces a powerful extension system that allows third-party int
- Qobuz: Both APIs requested simultaneously, first success wins
- Significantly reduces download URL fetch time
+### UI/UX Improvements
+
+- **Swipeable History Filters**: History tab now supports swipe gestures between All, Albums, and Singles filters
+
+ - Swipe left/right to switch between filter tabs
+ - Filter chips sync with swipe position
+ - Smooth edge-to-edge transition: swipe past Singles to go to Store, swipe past All to go to Home
+ - Natural gesture feel - drag connects to parent navigation
+
+ - **Improved File Open Intent**: Play button in History now correctly opens music players only
+ - Added proper MIME type (`audio/flac`, `audio/mpeg`, etc.) when opening downloaded files
+ - Prevents system from showing unrelated apps in the "Open with" dialog
+
### Fixed
+- **Fixed Tab Edge Overscroll**: Home and Settings tabs now stop at edges instead of bouncing into empty space
+
+- **Fixed Extension Duplicate Load Error**: Extension loading now silently skips already-loaded extensions instead of throwing error
+
+- **Fixed Settings Item Highlight on Swipe**: Settings items no longer highlight when swiping at page edge
+
+- **Fixed Keyboard Appearing on Tab Switch**: Keyboard now auto-dismisses when swiping between tabs
+
+- **Removed Search Source Badges**: Removed "Free" and "API Key" labels from Deezer/Spotify selector in Options
+
- **Back Gesture Freeze on Android 13+**: Fixed app freeze when using back gesture in settings
- Added `PopScope` with `canPop: true` to all settings pages
diff --git a/README.md b/README.md
index a77e2ffd..f702b75a 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,5 @@
[](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
-[](https://www.virustotal.com/gui/file/09c6260e9ebaf2ff0d15f30deda939642f41887f11aad602ac697cb37fa0308c/)
+[](https://www.virustotal.com/gui/file/e1c527eacb6f5ce527af214a75aab8da060c2afc629825fff24af858439e7e6b)
@@ -23,59 +23,58 @@ Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music — no account

-## Metadata Source
+## Search Source
-SpotiFLAC supports two metadata sources for searching tracks:
+SpotiFLAC supports two search sources:
-| Source | Pros | Cons |
-|--------|------|------|
-| **Deezer** (Default) | No developer account needed, rate limit per user IP | Slightly less comprehensive catalog |
-| **Spotify** | More comprehensive catalog, better search results | Requires developer API credentials to avoid rate limiting |
+| Source | Setup |
+|--------|-------|
+| **Deezer** (Default) | No setup required |
+| **Spotify** | Install **Spotify Web** extension from the Store, or use your own [Spotify Developer](https://developer.spotify.com) Client ID & Secret in Settings |
-### Using Spotify
-To use Spotify as your search source without hitting rate limits:
-1. Create a Spotify Developer account at [developer.spotify.com](https://developer.spotify.com)
-2. Create an app to get your Client ID and Client Secret
-3. Go to **Settings > Options > Spotify API > Change from Deezer to Spotify > Input Custom Credentials**
-4. Enter your Client ID and Secret
-5. Change **Search Source** to Spotify
+## Extensions
-## Extensions (Alpha)
-
-> **Alpha Feature**: Extensions are now available in alpha. Some features may be unstable or change in future releases.
-
-SpotiFLAC supports extensions to add custom metadata and download providers. Extensions are written in JavaScript and run in a secure sandbox.
-
-### Features
-- **Metadata Providers**: Add new sources for track/album/artist search
-- **Download Providers**: Add new sources for audio downloads
-- **Custom Settings**: Extensions can have user-configurable settings
-- **Provider Priority**: Set the order in which providers are tried
+Extensions allow the community to add new music sources and features without waiting for app updates. When a streaming service API changes or a new source becomes available, extensions can be updated independently.
### Installing Extensions
-1. Download a `.spotiflac-ext` file
-2. Go to **Settings > Extensions**
-3. Tap **Install Extension** and select the file
+1. Go to **Store** tab in the app
+2. Browse and install extensions with one tap
+3. Or download a `.spotiflac-ext` file and install manually via **Settings > Extensions**
4. Configure extension settings if needed
5. Set provider priority in **Settings > Extensions > Provider Priority**
### Developing Extensions
-Want to create your own extension? Check out the [Extension Development Guide](docs/EXTENSION_DEVELOPMENT.md) for complete documentation.
-
-### Example Extensions
-Sample extensions are available in the [docs/extensions_example](docs/extensions_example) folder:
+Want to create your own extension? Check out the [Extension Development Guide](https://zarz.moe/docs) for complete documentation.
## Other project
### [SpotiFLAC (Desktop)](https://github.com/afkarxyz/SpotiFLAC)
Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music for Windows, macOS & Linux
+## FAQ
+
+**Q: Why is my download failing with "Song not found"?**
+A: The track may not be available on Tidal, Qobuz, or Amazon Music. Try enabling more download services in Settings > Download > Provider Priority, or install additional extensions from the Store.
+
+**Q: Why are some tracks downloading in lower quality?**
+A: Quality depends on what's available from the streaming service. Tidal offers up to 24-bit/192kHz, Qobuz up to 24-bit/192kHz, and Amazon up to 24-bit/48kHz. The app automatically selects the best available quality.
+
+**Q: Can I download my Spotify playlists?**
+A: Yes! Just paste the Spotify playlist URL in the search bar. The app will fetch all tracks and queue them for download.
+
+**Q: Why do I need to grant storage permission?**
+A: The app needs permission to save downloaded files to your device. On Android 13+, you may need to grant "All files access" in Settings > Apps > SpotiFLAC > Permissions.
+
+**Q: How do I download Daily Mix or Discover Weekly?**
+A: Install the **Spotify Web** extension from the Store. This extension can access personalized playlists that aren't available through the public API.
+
+**Q: Is this app safe?**
+A: Yes, the app is open source and you can verify the code yourself. Each release is scanned with VirusTotal (see badge at top of README).
+
[](https://ko-fi.com/zarzet)
## Disclaimer
-> **iOS Support**: This app is primarily tested on Android. iOS support is experimental and may have bugs — the developer is too poor to afford an iPhone for proper testing. If you encounter issues on iOS, please report them!
-
This project is for **educational and private use only**. The developer does not condone or encourage copyright infringement.
**SpotiFLAC** is a third-party tool and is not affiliated with, endorsed by, or connected to Spotify, Tidal, Qobuz, Amazon Music, or any other streaming service.
diff --git a/assets/images/1.jpg b/assets/images/1.jpg
index b5d15588..6413c6ff 100644
Binary files a/assets/images/1.jpg and b/assets/images/1.jpg differ
diff --git a/assets/images/2.jpg b/assets/images/2.jpg
index 156af83b..e5b487ff 100644
Binary files a/assets/images/2.jpg and b/assets/images/2.jpg differ
diff --git a/assets/images/3.jpg b/assets/images/3.jpg
index 6c5e3ee8..2de6e443 100644
Binary files a/assets/images/3.jpg and b/assets/images/3.jpg differ
diff --git a/assets/images/4.jpg b/assets/images/4.jpg
index 62755089..2b5b5888 100644
Binary files a/assets/images/4.jpg and b/assets/images/4.jpg differ
diff --git a/go_backend/extension_manager.go b/go_backend/extension_manager.go
index 826569ad..df64b8dc 100644
--- a/go_backend/extension_manager.go
+++ b/go_backend/extension_manager.go
@@ -456,9 +456,10 @@ func (m *ExtensionManager) loadExtensionFromDirectory(dirPath string) (*LoadedEx
return nil, fmt.Errorf("Extension is missing index.js file")
}
- // Check if extension already loaded - skip if already exists (for directory loading on startup)
- if _, exists := m.extensions[manifest.Name]; exists {
- return nil, fmt.Errorf("Extension '%s' is already loaded", manifest.DisplayName)
+ // Check if extension already loaded - skip silently (for directory loading on startup)
+ if existing, exists := m.extensions[manifest.Name]; exists {
+ GoLog("[Extension] Extension '%s' already loaded, skipping\n", manifest.DisplayName)
+ return existing, nil
}
// Create data directory for extension
diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift
index cb01b712..43ed4e34 100644
--- a/ios/Runner/AppDelegate.swift
+++ b/ios/Runner/AppDelegate.swift
@@ -547,6 +547,47 @@ import Gobackend // Import Go framework
if let error = error { throw error }
return response
+ // Extension Store
+ case "initExtensionStore":
+ let args = call.arguments as! [String: Any]
+ let cacheDir = args["cache_dir"] as! String
+ GobackendInitExtensionStoreJSON(cacheDir, &error)
+ if let error = error { throw error }
+ return nil
+
+ case "getStoreExtensions":
+ let args = call.arguments as! [String: Any]
+ let forceRefresh = args["force_refresh"] as? Bool ?? false
+ let response = GobackendGetStoreExtensionsJSON(forceRefresh, &error)
+ if let error = error { throw error }
+ return response
+
+ case "searchStoreExtensions":
+ let args = call.arguments as! [String: Any]
+ let query = args["query"] as? String ?? ""
+ let category = args["category"] as? String ?? ""
+ let response = GobackendSearchStoreExtensionsJSON(query, category, &error)
+ if let error = error { throw error }
+ return response
+
+ case "getStoreCategories":
+ let response = GobackendGetStoreCategoriesJSON(&error)
+ if let error = error { throw error }
+ return response
+
+ case "downloadStoreExtension":
+ let args = call.arguments as! [String: Any]
+ let extensionId = args["extension_id"] as! String
+ let destDir = args["dest_dir"] as! String
+ let response = GobackendDownloadStoreExtensionJSON(extensionId, destDir, &error)
+ if let error = error { throw error }
+ return response
+
+ case "clearStoreCache":
+ GobackendClearStoreCacheJSON(&error)
+ if let error = error { throw error }
+ return nil
+
default:
throw NSError(
domain: "SpotiFLAC",
diff --git a/lib/screens/main_shell.dart b/lib/screens/main_shell.dart
index d2b927da..9b7932ed 100644
--- a/lib/screens/main_shell.dart
+++ b/lib/screens/main_shell.dart
@@ -122,6 +122,8 @@ class _MainShellState extends ConsumerState
{
void _onPageChanged(int index) {
if (_currentIndex != index) {
setState(() => _currentIndex = index);
+ // Unfocus any text field when switching tabs to prevent keyboard from appearing
+ FocusScope.of(context).unfocus();
}
}
@@ -190,7 +192,11 @@ class _MainShellState extends ConsumerState {
// Build tabs and destinations based on settings
final tabs = [
const HomeTab(),
- const QueueTab(),
+ QueueTab(
+ parentPageController: _pageController,
+ parentPageIndex: 1,
+ nextPageIndex: showStore ? 2 : 3,
+ ),
if (showStore) const StoreTab(),
const SettingsTab(),
];
@@ -254,7 +260,7 @@ class _MainShellState extends ConsumerState {
body: PageView(
controller: _pageController,
onPageChanged: _onPageChanged,
- physics: const BouncingScrollPhysics(),
+ physics: const ClampingScrollPhysics(),
children: tabs,
),
bottomNavigationBar: NavigationBar(
diff --git a/lib/screens/queue_tab.dart b/lib/screens/queue_tab.dart
index c69fc1d1..666fb18d 100644
--- a/lib/screens/queue_tab.dart
+++ b/lib/screens/queue_tab.dart
@@ -31,7 +31,17 @@ class _GroupedAlbum {
}
class QueueTab extends ConsumerStatefulWidget {
- const QueueTab({super.key});
+ final PageController? parentPageController;
+ final int parentPageIndex;
+ final int? nextPageIndex;
+
+ const QueueTab({
+ super.key,
+ this.parentPageController,
+ this.parentPageIndex = 1,
+ this.nextPageIndex,
+ });
+
@override
ConsumerState createState() => _QueueTabState();
}
@@ -45,6 +55,46 @@ class _QueueTabState extends ConsumerState {
bool _isSelectionMode = false;
final Set _selectedIds = {};
+ // Filter page controller for swipe between All/Albums/Singles
+ PageController? _filterPageController;
+ final List _filterModes = ['all', 'albums', 'singles'];
+ bool _isPageControllerInitialized = false;
+
+
+
+ @override
+ void initState() {
+ super.initState();
+ // Will be initialized in build when we have access to ref
+ }
+
+ void _initializePageController() {
+ if (_isPageControllerInitialized) return;
+ _isPageControllerInitialized = true;
+ final currentFilter = ref.read(settingsProvider).historyFilterMode;
+ final initialPage = _filterModes.indexOf(currentFilter).clamp(0, 2);
+ _filterPageController = PageController(initialPage: initialPage);
+ }
+
+ @override
+ void dispose() {
+ _filterPageController?.dispose();
+ super.dispose();
+ }
+
+ void _onFilterPageChanged(int index) {
+ final filterMode = _filterModes[index];
+ ref.read(settingsProvider.notifier).setHistoryFilterMode(filterMode);
+ }
+
+ void _animateToFilterPage(int index) {
+ _filterPageController?.animateToPage(
+ index,
+ duration: const Duration(milliseconds: 300),
+ curve: Curves.easeOutCubic,
+ );
+ }
+
/// Enter selection mode with initial item
void _enterSelectionMode(String itemId) {
HapticFeedback.mediumImpact();
@@ -90,7 +140,9 @@ class _QueueTabState extends ConsumerState {
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.'),
+ 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),
@@ -110,7 +162,7 @@ class _QueueTabState extends ConsumerState {
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;
@@ -126,12 +178,16 @@ class _QueueTabState extends ConsumerState {
deletedCount++;
}
}
-
+
_exitSelectionMode();
-
+
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
- SnackBar(content: Text('Deleted $deletedCount ${deletedCount == 1 ? 'track' : 'tracks'}')),
+ SnackBar(
+ content: Text(
+ 'Deleted $deletedCount ${deletedCount == 1 ? 'track' : 'tracks'}',
+ ),
+ ),
);
}
}
@@ -177,69 +233,87 @@ class _QueueTabState extends ConsumerState {
await OpenFilex.open(cleanPath, type: mimeType);
} catch (e) {
if (mounted) {
- ScaffoldMessenger.of(context).showSnackBar(
- SnackBar(content: Text('Cannot open file: $e')),
- );
+ ScaffoldMessenger.of(
+ context,
+ ).showSnackBar(SnackBar(content: Text('Cannot open file: $e')));
}
}
}
void _navigateToMetadataScreen(DownloadItem item) {
- final historyItem = ref.read(downloadHistoryProvider).items.firstWhere(
- (h) => h.filePath == item.filePath,
- orElse: () => DownloadHistoryItem(
- id: item.id,
- trackName: item.track.name,
- artistName: item.track.artistName,
- albumName: item.track.albumName,
- coverUrl: item.track.coverUrl,
- filePath: item.filePath ?? '',
- downloadedAt: DateTime.now(),
- service: item.service,
+ final historyItem = ref
+ .read(downloadHistoryProvider)
+ .items
+ .firstWhere(
+ (h) => h.filePath == item.filePath,
+ orElse: () => DownloadHistoryItem(
+ id: item.id,
+ trackName: item.track.name,
+ artistName: item.track.artistName,
+ albumName: item.track.albumName,
+ coverUrl: item.track.coverUrl,
+ filePath: item.filePath ?? '',
+ downloadedAt: DateTime.now(),
+ service: item.service,
+ ),
+ );
+
+ Navigator.push(
+ context,
+ PageRouteBuilder(
+ transitionDuration: const Duration(milliseconds: 300),
+ reverseTransitionDuration: const Duration(milliseconds: 250),
+ pageBuilder: (context, animation, secondaryAnimation) =>
+ TrackMetadataScreen(item: historyItem),
+ transitionsBuilder: (context, animation, secondaryAnimation, child) =>
+ FadeTransition(opacity: animation, child: child),
),
);
-
- Navigator.push(context, PageRouteBuilder(
- transitionDuration: const Duration(milliseconds: 300),
- reverseTransitionDuration: const Duration(milliseconds: 250),
- pageBuilder: (context, animation, secondaryAnimation) => TrackMetadataScreen(item: historyItem),
- transitionsBuilder: (context, animation, secondaryAnimation, child) => FadeTransition(opacity: animation, child: child),
- ));
}
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),
- ));
+ 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) {
+ 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}';
+ 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}';
+ final key =
+ '${item.albumName}|${item.albumArtist ?? item.artistName}';
return (albumCounts[key] ?? 0) == 1;
}).toList();
default:
@@ -255,10 +329,10 @@ class _QueueTabState extends ConsumerState {
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) {
@@ -267,44 +341,45 @@ class _QueueTabState extends ConsumerState {
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();
-
+ 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;
}
@@ -315,51 +390,72 @@ class _QueueTabState extends ConsumerState {
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;
+ 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,
+ 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),
),
- transitionsBuilder: (context, animation, secondaryAnimation, child) => FadeTransition(opacity: animation, child: child),
- ));
+ );
}
@override
Widget build(BuildContext context) {
+ // Initialize page controller on first build
+ _initializePageController();
+
final queueItems = ref.watch(downloadQueueProvider.select((s) => s.items));
- final isProcessing = ref.watch(downloadQueueProvider.select((s) => s.isProcessing));
+ 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 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 queuedCount = ref.watch(
+ downloadQueueProvider.select((s) => s.queuedCount),
+ );
+ final completedCount = ref.watch(
+ downloadQueueProvider.select((s) => s.completedCount),
+ );
+ 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;
- // 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 albumCount = _countUniqueAlbums(allHistoryItems);
final singleCount = counts['singles'] ?? 0;
final bottomPadding = MediaQuery.of(context).padding.bottom;
@@ -373,8 +469,8 @@ class _QueueTabState extends ConsumerState {
},
child: Stack(
children: [
- CustomScrollView(
- slivers: [
+ NestedScrollView(
+ headerSliverBuilder: (context, innerBoxIsScrolled) => [
// App Bar - always normal style
SliverAppBar(
expandedHeight: 120 + topPadding,
@@ -388,8 +484,11 @@ class _QueueTabState extends ConsumerState {
builder: (context, constraints) {
final maxHeight = 120 + topPadding;
final minHeight = kToolbarHeight + topPadding;
- final expandRatio = ((constraints.maxHeight - minHeight) / (maxHeight - minHeight)).clamp(0.0, 1.0);
-
+ 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),
@@ -406,234 +505,420 @@ class _QueueTabState extends ConsumerState {
),
),
- // Pause/Resume controls
- if ((isProcessing || queuedCount > 0) && (queueItems.length > 1 || isPaused))
- SliverToBoxAdapter(
- child: Padding(
- padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
- child: Card(
+ // Pause/Resume controls
+ if ((isProcessing || queuedCount > 0) &&
+ (queueItems.length > 1 || isPaused))
+ SliverToBoxAdapter(
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,
- ),
+ 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'),
+ ),
+ ],
),
- 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'),
+ // 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,
),
- 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'),
- ),
- ],
+ ),
),
),
- ),
- ),
- // 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,
- ),
- ),
- ],
- ),
- ),
- ),
-
- // 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
- 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)),
- ),
- ),
-
- // 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 album = groupedAlbums[index];
+ // Queue list
+ if (queueItems.isNotEmpty)
+ SliverList(
+ delegate: SliverChildBuilderDelegate((context, index) {
+ final item = queueItems[index];
return KeyedSubtree(
- key: ValueKey(album.key),
- child: _buildAlbumGridItem(context, album, colorScheme),
+ key: ValueKey(item.id),
+ child: _buildQueueItem(context, item, colorScheme),
);
- },
- childCount: groupedAlbums.length,
+ }, 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: () {
+ _animateToFilterPage(0);
+ },
+ ),
+ const SizedBox(width: 8),
+ _FilterChip(
+ label: 'Albums',
+ count: albumCount,
+ isSelected: historyFilterMode == 'albums',
+ onTap: () {
+ _animateToFilterPage(1);
+ },
+ ),
+ const SizedBox(width: 8),
+ _FilterChip(
+ label: 'Singles',
+ count: singleCount,
+ isSelected: historyFilterMode == 'singles',
+ onTap: () {
+ _animateToFilterPage(2);
+ },
+ ),
+ ],
+ ),
+ ),
+ ),
+ ),
+ ],
+ body: NotificationListener(
+ onNotification: (notification) {
+ final parentController = widget.parentPageController;
+ if (parentController == null || !parentController.hasClients) {
+ return false;
+ }
+
+ final page = _filterPageController!.page?.round() ?? 0;
+
+ if (notification is OverscrollNotification) {
+ final overscroll = notification.overscroll;
+
+ // At first page and overscrolling to the left -> push parent toward Home
+ if (page == 0 && overscroll < 0) {
+ final currentOffset = parentController.offset;
+ final targetOffset = (currentOffset + overscroll).clamp(
+ 0.0,
+ parentController.position.maxScrollExtent,
+ );
+ parentController.jumpTo(targetOffset);
+ return true;
+ }
+
+ // At last page and overscrolling to the right -> push parent toward next tab
+ if (page == 2 && overscroll > 0) {
+ final currentOffset = parentController.offset;
+ final targetOffset = (currentOffset + overscroll).clamp(
+ 0.0,
+ parentController.position.maxScrollExtent,
+ );
+ parentController.jumpTo(targetOffset);
+ return true;
+ }
+ }
+
+ // Snap parent to nearest page when scroll ends
+ if (notification is ScrollEndNotification) {
+ if (page == 0 || page == 2) {
+ final currentPage = parentController.page ?? widget.parentPageIndex.toDouble();
+ final historyPage = widget.parentPageIndex.toDouble();
+ final offset = currentPage - historyPage;
+
+ // Only snap if we've moved the parent
+ if (offset.abs() > 0.01) {
+ // Use 0.3 threshold (30%)
+ if (offset < -0.3) {
+ // Swiped enough toward Home - animate to Home
+ parentController.animateToPage(
+ widget.parentPageIndex - 1,
+ duration: const Duration(milliseconds: 250),
+ curve: Curves.easeOutCubic,
+ );
+ } else if (offset > 0.3) {
+ // Swiped enough toward next tab - animate to next
+ parentController.animateToPage(
+ widget.nextPageIndex ?? (widget.parentPageIndex + 1),
+ duration: const Duration(milliseconds: 250),
+ curve: Curves.easeOutCubic,
+ );
+ } else {
+ // Not enough - instant jump back (no animation)
+ parentController.jumpToPage(widget.parentPageIndex);
+ }
+ }
+ }
+ }
+
+ return false;
+ },
+ child: PageView(
+ controller: _filterPageController!,
+ physics: const ClampingScrollPhysics(),
+ onPageChanged: _onFilterPageChanged,
+ children: [
+ // All tab
+ _buildFilterContent(
+ context: context,
+ colorScheme: colorScheme,
+ filterMode: 'all',
+ allHistoryItems: allHistoryItems,
+ historyViewMode: historyViewMode,
+ queueItems: queueItems,
+ groupedAlbums: groupedAlbums,
+ ),
+ // Albums tab
+ _buildFilterContent(
+ context: context,
+ colorScheme: colorScheme,
+ filterMode: 'albums',
+ allHistoryItems: allHistoryItems,
+ historyViewMode: historyViewMode,
+ queueItems: queueItems,
+ groupedAlbums: groupedAlbums,
+ ),
+ // Singles tab
+ _buildFilterContent(
+ context: context,
+ colorScheme: colorScheme,
+ filterMode: 'singles',
+ allHistoryItems: allHistoryItems,
+ historyViewMode: historyViewMode,
+ queueItems: queueItems,
+ groupedAlbums: groupedAlbums,
+ ),
+ ],
+ ),
+ ),
+ ),
+
+ // 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,
+ _filterHistoryItems(allHistoryItems, historyFilterMode),
+ bottomPadding,
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+
+ /// Build content for each filter tab
+ Widget _buildFilterContent({
+ required BuildContext context,
+ required ColorScheme colorScheme,
+ required String filterMode,
+ required List allHistoryItems,
+ required String historyViewMode,
+ required List queueItems,
+ required List<_GroupedAlbum> groupedAlbums,
+ }) {
+ final historyItems = _filterHistoryItems(allHistoryItems, filterMode);
+
+ return CustomScrollView(
+ slivers: [
+ // History section header
+ if (historyItems.isNotEmpty &&
+ queueItems.isEmpty &&
+ filterMode != '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,
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+
+ // Albums section header (when Albums filter is selected)
+ if (groupedAlbums.isNotEmpty &&
+ queueItems.isEmpty &&
+ filterMode == '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 - 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) {
+ // 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,
+ ),
+ ),
+ ),
+ ),
+
+ // Albums Grid (when Albums filter is selected)
+ if (filterMode == '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 album = groupedAlbums[index];
+ return KeyedSubtree(
+ key: ValueKey(album.key),
+ child: _buildAlbumGridItem(context, album, colorScheme),
+ );
+ }, childCount: groupedAlbums.length),
+ ),
+ ),
+
+ // History - Grid or List (for All and Singles filter)
+ if (historyItems.isNotEmpty && filterMode != '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: _buildHistoryItem(context, item, colorScheme),
+ child: _buildHistoryGridItem(
+ context,
+ item,
+ colorScheme,
+ ),
);
- },
- childCount: historyItems.length,
- )),
+ }, 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),
- ),
- ],
- ),
-);
+ // Empty state
+ if (queueItems.isEmpty &&
+ historyItems.isEmpty &&
+ (filterMode != 'albums' || groupedAlbums.isEmpty))
+ SliverFillRemaining(
+ hasScrollBody: false,
+ child: _buildEmptyState(
+ context,
+ colorScheme,
+ filterMode,
+ ),
+ )
+ else
+ // Add bottom padding when selection mode is active to avoid overlap with bottom bar
+ SliverToBoxAdapter(
+ child: SizedBox(height: _isSelectionMode ? 100 : 16),
+ ),
+ ],
+ );
}
- Widget _buildEmptyState(BuildContext context, ColorScheme colorScheme, String filterMode) {
+ Widget _buildEmptyState(
+ BuildContext context,
+ ColorScheme colorScheme,
+ String filterMode,
+ ) {
String message;
String subtitle;
IconData icon;
-
+
switch (filterMode) {
case 'albums':
message = 'No album downloads';
@@ -650,20 +935,37 @@ class _QueueTabState extends ConsumerState {
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))),
- ]),
+ 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),
+ ),
+ ),
+ ],
+ ),
);
}
/// Build album grid item for grouped albums view
- Widget _buildAlbumGridItem(BuildContext context, _GroupedAlbum album, ColorScheme colorScheme) {
+ Widget _buildAlbumGridItem(
+ BuildContext context,
+ _GroupedAlbum album,
+ ColorScheme colorScheme,
+ ) {
return GestureDetector(
onTap: () => _navigateToDownloadedAlbum(album),
child: Column(
@@ -686,7 +988,13 @@ class _QueueTabState extends ConsumerState {
)
: Container(
color: colorScheme.surfaceContainerHighest,
- child: Center(child: Icon(Icons.album, color: colorScheme.onSurfaceVariant, size: 48)),
+ child: Center(
+ child: Icon(
+ Icons.album,
+ color: colorScheme.onSurfaceVariant,
+ size: 48,
+ ),
+ ),
),
),
// Track count badge
@@ -694,7 +1002,10 @@ class _QueueTabState extends ConsumerState {
right: 8,
bottom: 8,
child: Container(
- padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
+ padding: const EdgeInsets.symmetric(
+ horizontal: 8,
+ vertical: 4,
+ ),
decoration: BoxDecoration(
color: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(12),
@@ -702,7 +1013,11 @@ class _QueueTabState extends ConsumerState {
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
- Icon(Icons.music_note, size: 12, color: colorScheme.onPrimaryContainer),
+ Icon(
+ Icons.music_note,
+ size: 12,
+ color: colorScheme.onPrimaryContainer,
+ ),
const SizedBox(width: 4),
Text(
'${album.tracks.length}',
@@ -725,14 +1040,18 @@ class _QueueTabState extends ConsumerState {
album.albumName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
- style: Theme.of(context).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600),
+ 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),
+ style: Theme.of(context).textTheme.bodySmall?.copyWith(
+ color: colorScheme.onSurfaceVariant,
+ ),
),
],
),
@@ -740,10 +1059,16 @@ class _QueueTabState extends ConsumerState {
}
/// Bottom action bar for selection mode (Material Design 3 style)
- Widget _buildSelectionBottomBar(BuildContext context, ColorScheme colorScheme, List historyItems, double bottomPadding) {
+ Widget _buildSelectionBottomBar(
+ BuildContext context,
+ ColorScheme colorScheme,
+ List historyItems,
+ double bottomPadding,
+ ) {
final selectedCount = _selectedIds.length;
- final allSelected = selectedCount == historyItems.length && historyItems.isNotEmpty;
-
+ final allSelected =
+ selectedCount == historyItems.length && historyItems.isNotEmpty;
+
return Container(
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHigh,
@@ -773,7 +1098,7 @@ class _QueueTabState extends ConsumerState {
borderRadius: BorderRadius.circular(2),
),
),
-
+
// Selection info row
Row(
children: [
@@ -786,7 +1111,7 @@ class _QueueTabState extends ConsumerState {
),
),
const SizedBox(width: 12),
-
+
// Selection count
Expanded(
child: Column(
@@ -794,20 +1119,20 @@ class _QueueTabState extends ConsumerState {
children: [
Text(
'$selectedCount selected',
- style: Theme.of(context).textTheme.titleMedium?.copyWith(
- fontWeight: FontWeight.bold,
- ),
+ 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,
- ),
+ allSelected
+ ? 'All tracks selected'
+ : 'Tap tracks to select',
+ style: Theme.of(context).textTheme.bodySmall
+ ?.copyWith(color: colorScheme.onSurfaceVariant),
),
],
),
),
-
+
// Select all toggle
TextButton.icon(
onPressed: () {
@@ -828,9 +1153,9 @@ class _QueueTabState extends ConsumerState {
),
],
),
-
+
const SizedBox(height: 16),
-
+
// Delete button
SizedBox(
width: double.infinity,
@@ -838,13 +1163,17 @@ class _QueueTabState extends ConsumerState {
onPressed: selectedCount > 0 ? _deleteSelected : null,
icon: const Icon(Icons.delete_outline),
label: Text(
- selectedCount > 0
+ 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,
+ 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),
@@ -859,9 +1188,13 @@ class _QueueTabState extends ConsumerState {
);
}
- Widget _buildQueueItem(BuildContext context, DownloadItem item, ColorScheme colorScheme) {
+ Widget _buildQueueItem(
+ BuildContext context,
+ DownloadItem item,
+ ColorScheme colorScheme,
+ ) {
final isCompleted = item.status == DownloadStatus.completed;
-
+
return Card(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: InkWell(
@@ -872,7 +1205,10 @@ class _QueueTabState extends ConsumerState {
child: Row(
children: [
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),
Expanded(
@@ -883,14 +1219,18 @@ 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),
@@ -901,7 +1241,8 @@ class _QueueTabState extends ConsumerState {
borderRadius: BorderRadius.circular(4),
child: LinearProgressIndicator(
value: item.progress > 0 ? item.progress : null,
- backgroundColor: colorScheme.surfaceContainerHighest,
+ backgroundColor:
+ colorScheme.surfaceContainerHighest,
color: colorScheme.primary,
minHeight: 6,
),
@@ -909,13 +1250,14 @@ class _QueueTabState extends ConsumerState {
),
const SizedBox(width: 8),
Text(
- item.speedMBps > 0
+ item.speedMBps > 0
? '${(item.progress * 100).toStringAsFixed(0)}% • ${item.speedMBps.toStringAsFixed(1)} MB/s'
: '${(item.progress * 100).toStringAsFixed(0)}%',
- style: Theme.of(context).textTheme.labelSmall?.copyWith(
- color: colorScheme.primary,
- fontWeight: FontWeight.bold,
- ),
+ style: Theme.of(context).textTheme.labelSmall
+ ?.copyWith(
+ color: colorScheme.primary,
+ fontWeight: FontWeight.bold,
+ ),
),
],
),
@@ -926,7 +1268,9 @@ class _QueueTabState extends ConsumerState {
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,
+ ),
),
],
],
@@ -965,21 +1309,31 @@ class _QueueTabState extends ConsumerState {
);
}
- Widget _buildActionButtons(BuildContext context, DownloadItem item, ColorScheme colorScheme) {
+ Widget _buildActionButtons(
+ BuildContext context,
+ DownloadItem item,
+ ColorScheme colorScheme,
+ ) {
switch (item.status) {
case DownloadStatus.queued:
return IconButton(
- onPressed: () => ref.read(downloadQueueProvider.notifier).cancelItem(item.id),
+ 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)),
+ style: IconButton.styleFrom(
+ backgroundColor: colorScheme.errorContainer.withValues(alpha: 0.3),
+ ),
);
case DownloadStatus.downloading:
return IconButton(
- onPressed: () => ref.read(downloadQueueProvider.notifier).cancelItem(item.id),
+ 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)),
+ style: IconButton.styleFrom(
+ backgroundColor: colorScheme.errorContainer.withValues(alpha: 0.3),
+ ),
);
case DownloadStatus.finalizing:
return SizedBox(
@@ -988,7 +1342,10 @@ class _QueueTabState extends ConsumerState {
child: Stack(
alignment: Alignment.center,
children: [
- CircularProgressIndicator(strokeWidth: 3, color: colorScheme.tertiary),
+ CircularProgressIndicator(
+ strokeWidth: 3,
+ color: colorScheme.tertiary,
+ ),
Icon(Icons.edit_note, color: colorScheme.tertiary, size: 16),
],
),
@@ -1003,15 +1360,26 @@ 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),
- child: Icon(Icons.check, color: colorScheme.onPrimaryContainer, size: 20),
+ decoration: BoxDecoration(
+ color: colorScheme.primaryContainer,
+ shape: BoxShape.circle,
+ ),
+ child: Icon(
+ Icons.check,
+ color: colorScheme.onPrimaryContainer,
+ size: 20,
+ ),
),
],
);
@@ -1021,18 +1389,33 @@ class _QueueTabState extends ConsumerState {
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
- onPressed: () => ref.read(downloadQueueProvider.notifier).retryItem(item.id),
+ 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: item.status == DownloadStatus.failed ? colorScheme.error : colorScheme.onSurfaceVariant),
+ onPressed: () =>
+ ref.read(downloadQueueProvider.notifier).removeItem(item.id),
+ 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))
+ style: item.status == DownloadStatus.failed
+ ? IconButton.styleFrom(
+ backgroundColor: colorScheme.errorContainer.withValues(
+ alpha: 0.3,
+ ),
+ )
: null,
),
],
@@ -1040,12 +1423,16 @@ class _QueueTabState extends ConsumerState {
}
}
- Widget _buildHistoryGridItem(BuildContext context, DownloadHistoryItem item, ColorScheme colorScheme) {
+ Widget _buildHistoryGridItem(
+ BuildContext context,
+ DownloadHistoryItem item,
+ ColorScheme colorScheme,
+ ) {
final fileExists = _checkFileExists(item.filePath);
final isSelected = _selectedIds.contains(item.id);
-
+
return GestureDetector(
- onTap: _isSelectionMode
+ onTap: _isSelectionMode
? () => _toggleSelection(item.id)
: () => _navigateToHistoryMetadataScreen(item),
onLongPress: _isSelectionMode ? null : () => _enterSelectionMode(item.id),
@@ -1069,7 +1456,11 @@ class _QueueTabState extends ConsumerState {
)
: Container(
color: colorScheme.surfaceContainerHighest,
- child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant, size: 32),
+ child: Icon(
+ Icons.music_note,
+ color: colorScheme.onSurfaceVariant,
+ size: 32,
+ ),
),
),
),
@@ -1079,18 +1470,26 @@ class _QueueTabState extends ConsumerState {
left: 4,
top: 4,
child: Container(
- padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
+ padding: const EdgeInsets.symmetric(
+ horizontal: 4,
+ vertical: 2,
+ ),
decoration: BoxDecoration(
- color: item.quality!.startsWith('24') ? colorScheme.tertiary : colorScheme.surfaceContainerHighest,
+ 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,
- ),
+ style: Theme.of(context).textTheme.labelSmall
+ ?.copyWith(
+ color: item.quality!.startsWith('24')
+ ? colorScheme.onTertiary
+ : colorScheme.onSurfaceVariant,
+ fontSize: 9,
+ fontWeight: FontWeight.w600,
+ ),
),
),
),
@@ -1103,8 +1502,15 @@ class _QueueTabState extends ConsumerState {
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),
+ decoration: BoxDecoration(
+ color: colorScheme.primary,
+ shape: BoxShape.circle,
+ ),
+ child: Icon(
+ Icons.play_arrow,
+ color: colorScheme.onPrimary,
+ size: 16,
+ ),
),
),
),
@@ -1115,8 +1521,15 @@ class _QueueTabState extends ConsumerState {
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),
+ decoration: BoxDecoration(
+ color: colorScheme.errorContainer,
+ shape: BoxShape.circle,
+ ),
+ child: Icon(
+ Icons.error_outline,
+ color: colorScheme.error,
+ size: 14,
+ ),
),
),
// Selection overlay
@@ -1124,7 +1537,9 @@ class _QueueTabState extends ConsumerState {
Positioned.fill(
child: Container(
decoration: BoxDecoration(
- color: isSelected ? colorScheme.primary.withValues(alpha: 0.3) : Colors.transparent,
+ color: isSelected
+ ? colorScheme.primary.withValues(alpha: 0.3)
+ : Colors.transparent,
borderRadius: BorderRadius.circular(8),
),
),
@@ -1136,13 +1551,17 @@ class _QueueTabState extends ConsumerState {
item.trackName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
- style: Theme.of(context).textTheme.bodySmall?.copyWith(fontWeight: FontWeight.w500),
+ 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),
+ style: Theme.of(context).textTheme.labelSmall?.copyWith(
+ color: colorScheme.onSurfaceVariant,
+ ),
),
],
),
@@ -1155,9 +1574,14 @@ class _QueueTabState extends ConsumerState {
decoration: BoxDecoration(
color: isSelected ? colorScheme.primary : colorScheme.surface,
shape: BoxShape.circle,
- border: Border.all(color: isSelected ? colorScheme.primary : colorScheme.outline, width: 2),
+ border: Border.all(
+ color: isSelected
+ ? colorScheme.primary
+ : colorScheme.outline,
+ width: 2,
+ ),
),
- child: isSelected
+ child: isSelected
? Icon(Icons.check, color: colorScheme.onPrimary, size: 16)
: const SizedBox(width: 16, height: 16),
),
@@ -1167,22 +1591,43 @@ class _QueueTabState extends ConsumerState {
);
}
-
- Widget _buildHistoryItem(BuildContext context, DownloadHistoryItem item, ColorScheme colorScheme) {
+ 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')}';
-
+ 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,
+ color: isSelected
+ ? colorScheme.primaryContainer.withValues(alpha: 0.3)
+ : null,
child: InkWell(
- onTap: _isSelectionMode
+ onTap: _isSelectionMode
? () => _toggleSelection(item.id)
: () => _navigateToHistoryMetadataScreen(item),
- onLongPress: _isSelectionMode ? null : () => _enterSelectionMode(item.id),
+ onLongPress: _isSelectionMode
+ ? null
+ : () => _enterSelectionMode(item.id),
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.all(12),
@@ -1194,12 +1639,23 @@ class _QueueTabState extends ConsumerState {
width: 24,
height: 24,
decoration: BoxDecoration(
- color: isSelected ? colorScheme.primary : Colors.transparent,
+ color: isSelected
+ ? colorScheme.primary
+ : Colors.transparent,
shape: BoxShape.circle,
- border: Border.all(color: isSelected ? colorScheme.primary : colorScheme.outline, width: 2),
+ border: Border.all(
+ color: isSelected
+ ? colorScheme.primary
+ : colorScheme.outline,
+ width: 2,
+ ),
),
- child: isSelected
- ? Icon(Icons.check, color: colorScheme.onPrimary, size: 16)
+ child: isSelected
+ ? Icon(
+ Icons.check,
+ color: colorScheme.onPrimary,
+ size: 16,
+ )
: null,
),
const SizedBox(width: 12),
@@ -1224,10 +1680,13 @@ class _QueueTabState extends ConsumerState {
color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
),
- child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant),
+ child: Icon(
+ Icons.music_note,
+ color: colorScheme.onSurfaceVariant,
+ ),
),
const SizedBox(width: 12),
-
+
// Track info
Expanded(
child: Column(
@@ -1237,43 +1696,55 @@ 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(
children: [
Text(
dateStr,
- style: Theme.of(context).textTheme.labelSmall?.copyWith(
- color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7),
- ),
+ style: Theme.of(context).textTheme.labelSmall
+ ?.copyWith(
+ color: colorScheme.onSurfaceVariant.withValues(
+ alpha: 0.7,
+ ),
+ ),
),
- if (item.quality != null && item.quality!.contains('bit')) ...[
+ if (item.quality != null &&
+ item.quality!.contains('bit')) ...[
const SizedBox(width: 8),
Container(
- padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
+ padding: const EdgeInsets.symmetric(
+ horizontal: 6,
+ vertical: 2,
+ ),
decoration: BoxDecoration(
- color: item.quality!.startsWith('24')
- ? colorScheme.tertiaryContainer
+ color: item.quality!.startsWith('24')
+ ? colorScheme.tertiaryContainer
: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(4),
),
child: Text(
item.quality!,
- style: Theme.of(context).textTheme.labelSmall?.copyWith(
- color: item.quality!.startsWith('24')
- ? colorScheme.onTertiaryContainer
- : colorScheme.onSurfaceVariant,
- fontSize: 10,
- fontWeight: FontWeight.w500,
- ),
+ style: Theme.of(context).textTheme.labelSmall
+ ?.copyWith(
+ color: item.quality!.startsWith('24')
+ ? colorScheme.onTertiaryContainer
+ : colorScheme.onSurfaceVariant,
+ fontSize: 10,
+ fontWeight: FontWeight.w500,
+ ),
),
),
],
@@ -1283,7 +1754,7 @@ class _QueueTabState extends ConsumerState {
),
),
const SizedBox(width: 8),
-
+
// Action buttons (hide in selection mode)
if (!_isSelectionMode)
Row(
@@ -1292,14 +1763,22 @@ class _QueueTabState extends ConsumerState {
if (fileExists)
IconButton(
onPressed: () => _openFile(item.filePath),
- icon: Icon(Icons.play_arrow, color: colorScheme.primary),
+ icon: Icon(
+ Icons.play_arrow,
+ color: colorScheme.primary,
+ ),
tooltip: 'Play',
style: IconButton.styleFrom(
- backgroundColor: colorScheme.primaryContainer.withValues(alpha: 0.3),
+ backgroundColor: colorScheme.primaryContainer
+ .withValues(alpha: 0.3),
),
)
else
- Icon(Icons.error_outline, color: colorScheme.error, size: 20),
+ Icon(
+ Icons.error_outline,
+ color: colorScheme.error,
+ size: 20,
+ ),
],
),
],
@@ -1327,9 +1806,11 @@ class _FilterChip extends StatelessWidget {
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
-
+
return Material(
- color: isSelected ? colorScheme.primaryContainer : colorScheme.surfaceContainerHighest,
+ color: isSelected
+ ? colorScheme.primaryContainer
+ : colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(20),
child: InkWell(
onTap: onTap,
@@ -1342,7 +1823,9 @@ class _FilterChip extends StatelessWidget {
Text(
label,
style: TextStyle(
- color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant,
+ color: isSelected
+ ? colorScheme.onPrimaryContainer
+ : colorScheme.onSurfaceVariant,
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
),
),
@@ -1350,8 +1833,8 @@ class _FilterChip extends StatelessWidget {
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
- color: isSelected
- ? colorScheme.primary.withValues(alpha: 0.2)
+ color: isSelected
+ ? colorScheme.primary.withValues(alpha: 0.2)
: colorScheme.outline.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(10),
),
@@ -1359,7 +1842,9 @@ class _FilterChip extends StatelessWidget {
count.toString(),
style: TextStyle(
fontSize: 11,
- color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant,
+ color: isSelected
+ ? colorScheme.onPrimaryContainer
+ : colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w500,
),
),
diff --git a/lib/widgets/settings_group.dart b/lib/widgets/settings_group.dart
index f178f407..9d7eafb5 100644
--- a/lib/widgets/settings_group.dart
+++ b/lib/widgets/settings_group.dart
@@ -72,7 +72,7 @@ class SettingsItem extends StatelessWidget {
InkWell(
onTap: onTap,
splashColor: colorScheme.primary.withValues(alpha: 0.12),
- highlightColor: colorScheme.primary.withValues(alpha: 0.08),
+ highlightColor: Colors.transparent,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
child: Row(
@@ -159,7 +159,7 @@ class SettingsSwitchItem extends StatelessWidget {
child: InkWell(
onTap: isDisabled ? null : () => onChanged!(!value),
splashColor: colorScheme.primary.withValues(alpha: 0.12),
- highlightColor: colorScheme.primary.withValues(alpha: 0.08),
+ highlightColor: Colors.transparent,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
child: Row(