mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-19 14:07:26 +02:00
fix(library): restore Material playlist drag and drop
This commit is contained in:
1 parent
2ba4c46618
commit
26d713915f
6 files changed
+210
-46
No files matched your search
@@ -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 =
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user