feat(flutter/track-actions): TrackActionsButton + TrackActionsSheet
The 3-dot trigger and the modal-bottom-sheet menu it opens. 7 actions: Play next, Add to queue, Like/Unlike, Add to playlist, Go to album, Go to artist, Hide/Unhide. hideQueueActions: true suppresses the first two for the Now Playing surface. Sub-sheets (HideTrackSheet, AddToPlaylistSheet) land in subsequent commits — this commit imports them speculatively, so CI between this and the next two commits will fail. Resolved by Tasks 5 + 6. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,35 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../../../models/track.dart';
|
||||||
|
import '../../../theme/theme_extension.dart';
|
||||||
|
import 'track_actions_sheet.dart';
|
||||||
|
|
||||||
|
/// Small 3-dot trigger that opens TrackActionsSheet. Drop into any
|
||||||
|
/// track row / card to expose the canonical 7-action menu.
|
||||||
|
class TrackActionsButton extends StatelessWidget {
|
||||||
|
const TrackActionsButton({
|
||||||
|
super.key,
|
||||||
|
required this.track,
|
||||||
|
this.hideQueueActions = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
final TrackRef track;
|
||||||
|
|
||||||
|
/// Suppresses Play next / Add to queue. Set true on the Now Playing
|
||||||
|
/// screen where the menu's track IS the currently-playing one.
|
||||||
|
final bool hideQueueActions;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||||
|
return IconButton(
|
||||||
|
icon: Icon(Icons.more_vert, color: fs.ash, size: 18),
|
||||||
|
tooltip: 'Track actions',
|
||||||
|
onPressed: () => TrackActionsSheet.show(
|
||||||
|
context,
|
||||||
|
track,
|
||||||
|
hideQueueActions: hideQueueActions,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
import '../../../api/endpoints/likes.dart';
|
||||||
|
import '../../../likes/likes_provider.dart';
|
||||||
|
import '../../../models/track.dart';
|
||||||
|
import '../../../player/player_provider.dart';
|
||||||
|
import '../../../playlists/playlists_provider.dart';
|
||||||
|
import '../../../quarantine/quarantine_provider.dart';
|
||||||
|
import '../../../theme/theme_extension.dart';
|
||||||
|
import 'add_to_playlist_sheet.dart';
|
||||||
|
import 'hide_track_sheet.dart';
|
||||||
|
|
||||||
|
/// Modal bottom sheet that lists the 7 canonical track actions.
|
||||||
|
/// Pops first when an item is tapped (immediate visual feedback) and
|
||||||
|
/// then runs the action — for actions that open a sub-sheet, the
|
||||||
|
/// sub-sheet opens after the parent pops.
|
||||||
|
class TrackActionsSheet extends ConsumerWidget {
|
||||||
|
const TrackActionsSheet({
|
||||||
|
super.key,
|
||||||
|
required this.track,
|
||||||
|
required this.hideQueueActions,
|
||||||
|
});
|
||||||
|
|
||||||
|
final TrackRef track;
|
||||||
|
final bool hideQueueActions;
|
||||||
|
|
||||||
|
static Future<void> show(
|
||||||
|
BuildContext context,
|
||||||
|
TrackRef track, {
|
||||||
|
bool hideQueueActions = false,
|
||||||
|
}) {
|
||||||
|
return showModalBottomSheet<void>(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
builder: (_) => TrackActionsSheet(
|
||||||
|
track: track,
|
||||||
|
hideQueueActions: hideQueueActions,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||||
|
final liked = ref.watch(likedIdsProvider).value?.has(LikeKind.track, track.id) ?? false;
|
||||||
|
final hidden = ref.watch(myQuarantineProvider).value?.any((r) => r.trackId == track.id) ?? false;
|
||||||
|
return SafeArea(
|
||||||
|
child: Container(
|
||||||
|
color: fs.iron,
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
if (!hideQueueActions) ...[
|
||||||
|
_MenuItem(
|
||||||
|
key: const Key('track_actions_play_next'),
|
||||||
|
icon: Icons.playlist_play,
|
||||||
|
label: 'Play next',
|
||||||
|
onTap: () async {
|
||||||
|
Navigator.pop(context);
|
||||||
|
await ref.read(playerActionsProvider).playNext(track);
|
||||||
|
if (context.mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Added to queue (next)')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
_MenuItem(
|
||||||
|
key: const Key('track_actions_enqueue'),
|
||||||
|
icon: Icons.queue_music,
|
||||||
|
label: 'Add to queue',
|
||||||
|
onTap: () async {
|
||||||
|
Navigator.pop(context);
|
||||||
|
await ref.read(playerActionsProvider).enqueue(track);
|
||||||
|
if (context.mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Added to queue')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const _Divider(),
|
||||||
|
],
|
||||||
|
_MenuItem(
|
||||||
|
key: const Key('track_actions_like'),
|
||||||
|
icon: liked ? Icons.favorite : Icons.favorite_border,
|
||||||
|
label: liked ? 'Unlike' : 'Like',
|
||||||
|
onTap: () async {
|
||||||
|
Navigator.pop(context);
|
||||||
|
await ref.read(likedIdsProvider.notifier).toggle(LikeKind.track, track.id);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
_MenuItem(
|
||||||
|
key: const Key('track_actions_add_to_playlist'),
|
||||||
|
icon: Icons.playlist_add,
|
||||||
|
label: 'Add to playlist…',
|
||||||
|
onTap: () async {
|
||||||
|
Navigator.pop(context);
|
||||||
|
final playlistId = await AddToPlaylistSheet.show(context);
|
||||||
|
if (playlistId == null || !context.mounted) return;
|
||||||
|
try {
|
||||||
|
await ref.read(addToPlaylistActionProvider).call(playlistId, track.id);
|
||||||
|
if (context.mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Added to playlist')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (context.mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text("Couldn't add to playlist: $e")),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const _Divider(),
|
||||||
|
_MenuItem(
|
||||||
|
key: const Key('track_actions_go_to_album'),
|
||||||
|
icon: Icons.album,
|
||||||
|
label: 'Go to album',
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
context.push('/albums/${track.albumId}');
|
||||||
|
},
|
||||||
|
),
|
||||||
|
_MenuItem(
|
||||||
|
key: const Key('track_actions_go_to_artist'),
|
||||||
|
icon: Icons.person,
|
||||||
|
label: 'Go to artist',
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
context.push('/artists/${track.artistId}');
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const _Divider(),
|
||||||
|
_MenuItem(
|
||||||
|
key: const Key('track_actions_hide'),
|
||||||
|
icon: hidden ? Icons.visibility : Icons.visibility_off,
|
||||||
|
label: hidden ? 'Unhide' : 'Hide',
|
||||||
|
onTap: () async {
|
||||||
|
Navigator.pop(context);
|
||||||
|
if (hidden) {
|
||||||
|
try {
|
||||||
|
await ref.read(myQuarantineProvider.notifier).unflag(track.id);
|
||||||
|
} catch (e) {
|
||||||
|
if (context.mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text("Couldn't unhide: $e")),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final result = await HideTrackSheet.show(context);
|
||||||
|
if (result == null || !context.mounted) return;
|
||||||
|
try {
|
||||||
|
await ref
|
||||||
|
.read(myQuarantineProvider.notifier)
|
||||||
|
.flag(track, result.reason, result.notes);
|
||||||
|
} catch (e) {
|
||||||
|
if (context.mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text("Couldn't hide: $e")),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MenuItem extends StatelessWidget {
|
||||||
|
const _MenuItem({
|
||||||
|
super.key,
|
||||||
|
required this.icon,
|
||||||
|
required this.label,
|
||||||
|
required this.onTap,
|
||||||
|
});
|
||||||
|
final IconData icon;
|
||||||
|
final String label;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||||
|
return ListTile(
|
||||||
|
leading: Icon(icon, color: fs.parchment),
|
||||||
|
title: Text(label, style: TextStyle(color: fs.parchment)),
|
||||||
|
onTap: onTap,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Divider extends StatelessWidget {
|
||||||
|
const _Divider();
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||||
|
return Divider(height: 1, color: fs.slate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convenience callable for "append a single track to a playlist."
|
||||||
|
/// Lives here (not in playlists_provider) because it's purely a
|
||||||
|
/// menu-flow concern — no other caller.
|
||||||
|
typedef AddToPlaylistAction = Future<void> Function(String playlistId, String trackId);
|
||||||
|
|
||||||
|
final addToPlaylistActionProvider = Provider<AddToPlaylistAction>((ref) {
|
||||||
|
return (playlistId, trackId) async {
|
||||||
|
final api = await ref.read(playlistsApiProvider.future);
|
||||||
|
await api.appendTracks(playlistId, [trackId]);
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
import 'package:minstrel/likes/likes_provider.dart';
|
||||||
|
import 'package:minstrel/models/quarantine_mine.dart';
|
||||||
|
import 'package:minstrel/models/track.dart';
|
||||||
|
import 'package:minstrel/quarantine/quarantine_provider.dart';
|
||||||
|
import 'package:minstrel/shared/widgets/track_actions/track_actions_sheet.dart';
|
||||||
|
import 'package:minstrel/theme/theme_data.dart';
|
||||||
|
|
||||||
|
const _track = TrackRef(
|
||||||
|
id: 't1',
|
||||||
|
title: 'Roygbiv',
|
||||||
|
albumId: 'a1',
|
||||||
|
albumTitle: 'Geogaddi',
|
||||||
|
artistId: 'ar1',
|
||||||
|
artistName: 'Boards of Canada',
|
||||||
|
durationSec: 137,
|
||||||
|
trackNumber: 4,
|
||||||
|
streamUrl: '',
|
||||||
|
);
|
||||||
|
|
||||||
|
class _StubLiked extends LikedIdsController {
|
||||||
|
_StubLiked({this.trackIds = const <String>{}});
|
||||||
|
final Set<String> trackIds;
|
||||||
|
@override
|
||||||
|
Future<LikedIds> build() async =>
|
||||||
|
LikedIds(artists: const {}, albums: const {}, tracks: trackIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _StubQuarantine extends MyQuarantineController {
|
||||||
|
@override
|
||||||
|
Future<List<QuarantineMineRow>> build() async => const [];
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _harness({bool hideQueueActions = false, Set<String>? likedTracks}) {
|
||||||
|
return ProviderScope(
|
||||||
|
overrides: [
|
||||||
|
likedIdsProvider
|
||||||
|
.overrideWith(() => _StubLiked(trackIds: likedTracks ?? const {})),
|
||||||
|
myQuarantineProvider.overrideWith(() => _StubQuarantine()),
|
||||||
|
],
|
||||||
|
child: MaterialApp(
|
||||||
|
theme: buildThemeData(),
|
||||||
|
home: Builder(builder: (ctx) {
|
||||||
|
return Scaffold(
|
||||||
|
body: Center(
|
||||||
|
child: ElevatedButton(
|
||||||
|
onPressed: () => TrackActionsSheet.show(
|
||||||
|
ctx,
|
||||||
|
_track,
|
||||||
|
hideQueueActions: hideQueueActions,
|
||||||
|
),
|
||||||
|
child: const Text('open'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
testWidgets('renders all 7 items by default', (t) async {
|
||||||
|
await t.pumpWidget(_harness());
|
||||||
|
await t.tap(find.text('open'));
|
||||||
|
await t.pumpAndSettle();
|
||||||
|
expect(find.byKey(const Key('track_actions_play_next')), findsOneWidget);
|
||||||
|
expect(find.byKey(const Key('track_actions_enqueue')), findsOneWidget);
|
||||||
|
expect(find.byKey(const Key('track_actions_like')), findsOneWidget);
|
||||||
|
expect(find.byKey(const Key('track_actions_add_to_playlist')), findsOneWidget);
|
||||||
|
expect(find.byKey(const Key('track_actions_go_to_album')), findsOneWidget);
|
||||||
|
expect(find.byKey(const Key('track_actions_go_to_artist')), findsOneWidget);
|
||||||
|
expect(find.byKey(const Key('track_actions_hide')), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('hideQueueActions suppresses Play next + Add to queue', (t) async {
|
||||||
|
await t.pumpWidget(_harness(hideQueueActions: true));
|
||||||
|
await t.tap(find.text('open'));
|
||||||
|
await t.pumpAndSettle();
|
||||||
|
expect(find.byKey(const Key('track_actions_play_next')), findsNothing);
|
||||||
|
expect(find.byKey(const Key('track_actions_enqueue')), findsNothing);
|
||||||
|
expect(find.byKey(const Key('track_actions_like')), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('like label flips to Unlike when track is liked', (t) async {
|
||||||
|
await t.pumpWidget(_harness(likedTracks: const {'t1'}));
|
||||||
|
await t.tap(find.text('open'));
|
||||||
|
await t.pumpAndSettle();
|
||||||
|
expect(find.text('Unlike'), findsOneWidget);
|
||||||
|
expect(find.text('Like'), findsNothing);
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user