diff --git a/lib/screens/queue_tab.dart b/lib/screens/queue_tab.dart index 9cf6cd22..838f9328 100644 --- a/lib/screens/queue_tab.dart +++ b/lib/screens/queue_tab.dart @@ -16,6 +16,7 @@ import 'package:spotiflac_android/widgets/app_search_field.dart'; import 'package:spotiflac_android/widgets/app_snack_bar.dart'; import 'package:spotiflac_android/widgets/library_search_results.dart'; import 'package:spotiflac_android/widgets/library_track_selection_bar.dart'; +import 'package:spotiflac_android/widgets/library_playlist_drag_source.dart'; import 'package:spotiflac_android/widgets/mornye_chrome.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -238,6 +239,7 @@ class _QueueTabState extends ConsumerState { final ValueNotifier _embeddedCoverVersion = ValueNotifier(0); bool _isSelectionMode = false; + bool _isDraggingLibraryTrack = false; final Set _selectedIds = {}; String? _selectionAnchorId; final SelectionOverlayController _selectionOverlay = diff --git a/lib/screens/queue_tab_filter_widgets.dart b/lib/screens/queue_tab_filter_widgets.dart index eb730dd3..6e21202b 100644 --- a/lib/screens/queue_tab_filter_widgets.dart +++ b/lib/screens/queue_tab_filter_widgets.dart @@ -509,29 +509,14 @@ extension _QueueTabFilterWidgets on _QueueTabState { final item = filteredUnifiedItems[trackIndex]; return KeyedSubtree( key: ValueKey(item.id), - child: LongPressDraggable( + child: LibraryPlaylistDragSource( data: item, - feedback: _buildDragFeedback( - context, - item, - colorScheme, - ), - childWhenDragging: Opacity( - opacity: 0.4, - child: _buildUnifiedGridItem( - context, - item, - colorScheme, - downloadedNavigationItems: - downloadedNavigationItems, - downloadedNavigationIndex: - downloadedNavigationIndexByUnifiedId[item.id], - localNavigationItems: localNavigationItems, - localNavigationIndex: - localNavigationIndexByUnifiedId[item.id], - libraryItems: filteredUnifiedItems, - ), - ), + onSelect: () => + _selectDraggedTrack(item, filteredUnifiedItems), + onDragStarted: _startLibraryTrackDrag, + onDragEnd: _endLibraryTrackDrag, + feedbackBuilder: (context) => + _buildDragFeedback(context, item, colorScheme), child: _buildUnifiedGridItem( context, item, @@ -544,6 +529,7 @@ extension _QueueTabFilterWidgets on _QueueTabState { localNavigationIndex: localNavigationIndexByUnifiedId[item.id], libraryItems: filteredUnifiedItems, + playlistDragEnabled: true, ), ), ); @@ -579,29 +565,14 @@ extension _QueueTabFilterWidgets on _QueueTabState { final item = filteredUnifiedItems[trackIndex]; return KeyedSubtree( key: ValueKey(item.id), - child: LongPressDraggable( + child: LibraryPlaylistDragSource( data: item, - feedback: _buildDragFeedback( - context, - item, - colorScheme, - ), - childWhenDragging: Opacity( - opacity: 0.4, - child: _buildUnifiedLibraryItem( - context, - item, - colorScheme, - downloadedNavigationItems: - downloadedNavigationItems, - downloadedNavigationIndex: - downloadedNavigationIndexByUnifiedId[item.id], - localNavigationItems: localNavigationItems, - localNavigationIndex: - localNavigationIndexByUnifiedId[item.id], - libraryItems: filteredUnifiedItems, - ), - ), + onSelect: () => + _selectDraggedTrack(item, filteredUnifiedItems), + onDragStarted: _startLibraryTrackDrag, + onDragEnd: _endLibraryTrackDrag, + feedbackBuilder: (context) => + _buildDragFeedback(context, item, colorScheme), child: _buildUnifiedLibraryItem( context, item, @@ -614,6 +585,7 @@ extension _QueueTabFilterWidgets on _QueueTabState { localNavigationIndex: localNavigationIndexByUnifiedId[item.id], libraryItems: filteredUnifiedItems, + playlistDragEnabled: true, ), ), ); diff --git a/lib/screens/queue_tab_item_widgets.dart b/lib/screens/queue_tab_item_widgets.dart index 790e8224..14e7e95a 100644 --- a/lib/screens/queue_tab_item_widgets.dart +++ b/lib/screens/queue_tab_item_widgets.dart @@ -742,6 +742,7 @@ extension _QueueTabItemWidgets on _QueueTabState { required List localNavigationItems, required int? localNavigationIndex, required List libraryItems, + bool playlistDragEnabled = false, }) { final fileExistsListenable = _fileExistsListenable(item.filePath); final isSelected = _selectedIds.contains(item.id); @@ -788,7 +789,9 @@ extension _QueueTabItemWidgets on _QueueTabState { album: item.albumName, coverUrl: item.coverUrl ?? item.localCoverPath ?? '', ), - onLongPress: _isSelectionMode + onLongPress: playlistDragEnabled + ? null + : _isSelectionMode ? () => _selectRangeTo(item.id, libraryItems) : () => _enterSelectionMode(item.id), leading: Hero( @@ -892,6 +895,7 @@ extension _QueueTabItemWidgets on _QueueTabState { required List localNavigationItems, required int? localNavigationIndex, required List libraryItems, + bool playlistDragEnabled = false, }) { final fileExistsListenable = _fileExistsListenable(item.filePath); final isSelected = _selectedIds.contains(item.id); @@ -925,7 +929,9 @@ extension _QueueTabItemWidgets on _QueueTabState { album: item.albumName, coverUrl: item.coverUrl ?? item.localCoverPath ?? '', ), - onLongPress: _isSelectionMode + onLongPress: playlistDragEnabled + ? null + : _isSelectionMode ? () => _selectRangeTo(item.id, libraryItems) : () => _enterSelectionMode(item.id), cover: Hero( diff --git a/lib/screens/queue_tab_selection.dart b/lib/screens/queue_tab_selection.dart index 2ddbe643..f7aa5aa3 100644 --- a/lib/screens/queue_tab_selection.dart +++ b/lib/screens/queue_tab_selection.dart @@ -1,6 +1,30 @@ part of 'queue_tab.dart'; extension _QueueTabSelectionActions on _QueueTabState { + void _selectDraggedTrack( + UnifiedLibraryItem item, + List visibleItems, + ) { + // Holding an already-selected track drags the existing batch without + // extending the range back to the selection anchor. + if (_isSelectionMode && _selectedIds.contains(item.id)) return; + if (_isSelectionMode) { + _selectRangeTo(item.id, visibleItems); + } else { + _enterSelectionMode(item.id); + } + } + + void _startLibraryTrackDrag() { + _setState(() => _isDraggingLibraryTrack = true); + _hideSelectionOverlay(); + } + + void _endLibraryTrackDrag() { + if (!mounted) return; + _setState(() => _isDraggingLibraryTrack = false); + } + void _enterSelectionMode(String itemId) { HapticFeedback.mediumImpact(); _setState(() { @@ -71,6 +95,7 @@ extension _QueueTabSelectionActions on _QueueTabState { }) { if (!mounted) return; if (_suppressSelectionOverlay || + _isDraggingLibraryTrack || !_isSelectionMode || _isPlaylistSelectionMode) { _hideSelectionOverlay(); diff --git a/lib/widgets/library_playlist_drag_source.dart b/lib/widgets/library_playlist_drag_source.dart new file mode 100644 index 00000000..bdd797ff --- /dev/null +++ b/lib/widgets/library_playlist_drag_source.dart @@ -0,0 +1,41 @@ +import 'package:flutter/material.dart'; + +/// Owns the long press for both selection and playlist dragging. The card +/// inside must not register a competing onLongPress gesture. +class LibraryPlaylistDragSource extends StatelessWidget { + const LibraryPlaylistDragSource({ + super.key, + required this.data, + required this.onSelect, + required this.onDragStarted, + required this.onDragEnd, + required this.feedbackBuilder, + required this.child, + }); + + final T data; + final VoidCallback onSelect; + final VoidCallback onDragStarted; + final VoidCallback onDragEnd; + final WidgetBuilder feedbackBuilder; + final Widget child; + + @override + Widget build(BuildContext context) => Semantics( + onLongPress: onSelect, + child: LongPressDraggable( + data: data, + maxSimultaneousDrags: 1, + hapticFeedbackOnStart: false, + onDragStarted: () { + onDragStarted(); + onSelect(); + }, + onDragEnd: (_) => onDragEnd(), + // Build after onSelect so the feedback reflects the selected batch. + feedback: Builder(builder: feedbackBuilder), + childWhenDragging: Opacity(opacity: 0.4, child: child), + child: child, + ), + ); +} diff --git a/test/library_playlist_drag_test.dart b/test/library_playlist_drag_test.dart new file mode 100644 index 00000000..b5d5ed99 --- /dev/null +++ b/test/library_playlist_drag_test.dart @@ -0,0 +1,118 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:spotiflac_android/l10n/l10n.dart'; +import 'package:spotiflac_android/theme/app_theme.dart'; +import 'package:spotiflac_android/widgets/library_playlist_drag_source.dart'; +import 'package:spotiflac_android/widgets/track_card.dart'; + +void main() { + for (final grid in [false, true]) { + testWidgets('Material ${grid ? 'grid' : 'list'} selects and drops tracks', ( + tester, + ) async { + tester.view.physicalSize = const Size(800, 1000); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final selected = {}; + final playlist = []; + final dropped = []; + var taps = 0; + var dragging = false; + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.light(), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: StatefulBuilder( + builder: (context, setState) => Column( + children: [ + DragTarget( + onAcceptWithDetails: (details) { + dropped.add(details.data); + playlist.addAll(selected); + }, + builder: (_, candidates, _) => SizedBox( + key: const ValueKey('playlist'), + width: 240, + height: 100, + child: Text( + candidates.isEmpty ? 'Playlist' : 'Drop here', + ), + ), + ), + for (final id in ['a', 'b']) + SizedBox( + width: 240, + height: grid ? 280 : 100, + child: LibraryPlaylistDragSource( + key: ValueKey(id), + data: id, + onSelect: () => setState(() => selected.add(id)), + onDragStarted: () => setState(() => dragging = true), + onDragEnd: () => setState(() => dragging = false), + feedbackBuilder: (_) => Material( + child: Text('Dragging ${selected.length}'), + ), + child: grid + ? TrackGridCard( + cover: const ColoredBox(color: Colors.blue), + title: 'Song $id', + isSelected: selected.contains(id), + onTap: () => taps++, + ) + : TrackCard( + leading: const SizedBox.square(dimension: 56), + title: 'Song $id', + isSelectionMode: selected.isNotEmpty, + isSelected: selected.contains(id), + onTap: () => taps++, + ), + ), + ), + ], + ), + ), + ), + ), + ); + // A normal tap still opens track details. + await tester.tap(find.text('Song a')); + await tester.pumpAndSettle(); + expect(taps, 1); + + // Holding without moving selects; releasing outside a target retains it. + var gesture = await tester.startGesture( + tester.getCenter(find.text('Song a')), + ); + await tester.pump(const Duration(milliseconds: 600)); + expect(selected, {'a'}); + expect(dragging, isTrue); + expect(find.text('Dragging 1'), findsOneWidget); + await gesture.up(); + await tester.pumpAndSettle(); + expect(selected, {'a'}); + expect(playlist, isEmpty); + expect(dragging, isFalse); + + // Another hold can select and drag the batch directly onto its playlist. + gesture = await tester.startGesture( + tester.getCenter(find.text('Song b')), + ); + await tester.pump(const Duration(milliseconds: 600)); + expect(find.text('Dragging 2'), findsOneWidget); + await gesture.moveTo( + tester.getCenter(find.byKey(const ValueKey('playlist'))), + ); + await tester.pump(); + expect(find.text('Drop here'), findsOneWidget); + await gesture.up(); + await tester.pumpAndSettle(); + expect(playlist, ['a', 'b']); + expect(dropped, ['b']); + expect(dragging, isFalse); + expect(taps, 1); + expect(tester.takeException(), isNull); + }); + } +}