From 5f239f05a540fbeaca25af3ab6da8ee8d702efb7 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sat, 9 May 2026 13:41:16 -0400 Subject: [PATCH] feat(flutter/track-actions): TrackActionsButton + TrackActionsSheet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../track_actions/track_actions_button.dart | 35 +++ .../track_actions/track_actions_sheet.dart | 221 ++++++++++++++++++ .../track_actions_sheet_test.dart | 94 ++++++++ 3 files changed, 350 insertions(+) create mode 100644 flutter_client/lib/shared/widgets/track_actions/track_actions_button.dart create mode 100644 flutter_client/lib/shared/widgets/track_actions/track_actions_sheet.dart create mode 100644 flutter_client/test/shared/widgets/track_actions/track_actions_sheet_test.dart diff --git a/flutter_client/lib/shared/widgets/track_actions/track_actions_button.dart b/flutter_client/lib/shared/widgets/track_actions/track_actions_button.dart new file mode 100644 index 00000000..cd293209 --- /dev/null +++ b/flutter_client/lib/shared/widgets/track_actions/track_actions_button.dart @@ -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()!; + return IconButton( + icon: Icon(Icons.more_vert, color: fs.ash, size: 18), + tooltip: 'Track actions', + onPressed: () => TrackActionsSheet.show( + context, + track, + hideQueueActions: hideQueueActions, + ), + ); + } +} diff --git a/flutter_client/lib/shared/widgets/track_actions/track_actions_sheet.dart b/flutter_client/lib/shared/widgets/track_actions/track_actions_sheet.dart new file mode 100644 index 00000000..88f56c76 --- /dev/null +++ b/flutter_client/lib/shared/widgets/track_actions/track_actions_sheet.dart @@ -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 show( + BuildContext context, + TrackRef track, { + bool hideQueueActions = false, + }) { + return showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (_) => TrackActionsSheet( + track: track, + hideQueueActions: hideQueueActions, + ), + ); + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final fs = Theme.of(context).extension()!; + 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()!; + 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()!; + 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 Function(String playlistId, String trackId); + +final addToPlaylistActionProvider = Provider((ref) { + return (playlistId, trackId) async { + final api = await ref.read(playlistsApiProvider.future); + await api.appendTracks(playlistId, [trackId]); + }; +}); diff --git a/flutter_client/test/shared/widgets/track_actions/track_actions_sheet_test.dart b/flutter_client/test/shared/widgets/track_actions/track_actions_sheet_test.dart new file mode 100644 index 00000000..403a14c7 --- /dev/null +++ b/flutter_client/test/shared/widgets/track_actions/track_actions_sheet_test.dart @@ -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 {}}); + final Set trackIds; + @override + Future build() async => + LikedIds(artists: const {}, albums: const {}, tracks: trackIds); +} + +class _StubQuarantine extends MyQuarantineController { + @override + Future> build() async => const []; +} + +Widget _harness({bool hideQueueActions = false, Set? 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); + }); +}