fix(library): restore Material playlist drag and drop

This commit is contained in:
zarzet committed 2026-10-02 13:57:18 +07:00
1 parent 2ba4c46618
commit 26d713915f
6 files changed
+210 -46

No files matched your search

+2
View File
@@ -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<QueueTab> {
final ValueNotifier<int> _embeddedCoverVersion = ValueNotifier<int>(0);
bool _isSelectionMode = false;
bool _isDraggingLibraryTrack = false;
final Set<String> _selectedIds = {};
String? _selectionAnchorId;
final SelectionOverlayController _selectionOverlay =
+16 -44
View File
@@ -509,29 +509,14 @@ extension _QueueTabFilterWidgets on _QueueTabState {
final item = filteredUnifiedItems[trackIndex];
return KeyedSubtree(
key: ValueKey(item.id),
child: LongPressDraggable<UnifiedLibraryItem>(
child: LibraryPlaylistDragSource<UnifiedLibraryItem>(
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<UnifiedLibraryItem>(
child: LibraryPlaylistDragSource<UnifiedLibraryItem>(
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,
),
),
);
+8 -2
View File
@@ -742,6 +742,7 @@ extension _QueueTabItemWidgets on _QueueTabState {
required List<LocalLibraryItem> localNavigationItems,
required int? localNavigationIndex,
required List<UnifiedLibraryItem> 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<LocalLibraryItem> localNavigationItems,
required int? localNavigationIndex,
required List<UnifiedLibraryItem> 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(
+25
View File
@@ -1,6 +1,30 @@
part of 'queue_tab.dart';
extension _QueueTabSelectionActions on _QueueTabState {
void _selectDraggedTrack(
UnifiedLibraryItem item,
List<UnifiedLibraryItem> 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();
@@ -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<T extends Object> 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<T>(
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,
),
);
}
+118
View File
@@ -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 = <String>{};
final playlist = <String>[];
final dropped = <String>[];
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<String>(
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<String>(
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);
});
}
}