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:
2026-05-09 13:41:16 -04:00
parent 2499449c0b
commit 5f239f05a5
3 changed files with 350 additions and 0 deletions
@@ -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]);
};
});