From 36006d703d001f57ed0eae9b790f012ccda78138 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sat, 9 May 2026 13:42:19 -0400 Subject: [PATCH] feat(flutter/track-actions): AddToPlaylistSheet picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Modal bottom sheet listing the caller's user-created playlists (system playlists like For-You are filtered out — they're not user-mutable). Tap a row to pop with the playlist id; consumer (TrackActionsSheet) then calls PlaylistsApi.appendTracks. Empty-state copy: "You haven't created any playlists yet." Co-Authored-By: Claude Opus 4.7 (1M context) --- .../track_actions/add_to_playlist_sheet.dart | 94 +++++++++++++++ .../add_to_playlist_sheet_test.dart | 114 ++++++++++++++++++ 2 files changed, 208 insertions(+) create mode 100644 flutter_client/lib/shared/widgets/track_actions/add_to_playlist_sheet.dart create mode 100644 flutter_client/test/shared/widgets/track_actions/add_to_playlist_sheet_test.dart diff --git a/flutter_client/lib/shared/widgets/track_actions/add_to_playlist_sheet.dart b/flutter_client/lib/shared/widgets/track_actions/add_to_playlist_sheet.dart new file mode 100644 index 00000000..28814fb4 --- /dev/null +++ b/flutter_client/lib/shared/widgets/track_actions/add_to_playlist_sheet.dart @@ -0,0 +1,94 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../playlists/playlists_provider.dart'; +import '../../../theme/theme_extension.dart'; + +/// Modal bottom sheet listing the caller's user-created playlists. +/// Returns the picked playlistId via Navigator.pop, or null on cancel. +class AddToPlaylistSheet extends ConsumerWidget { + const AddToPlaylistSheet({super.key}); + + static Future show(BuildContext context) { + return showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (_) => const AddToPlaylistSheet(), + ); + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final fs = Theme.of(context).extension()!; + final lists = ref.watch(playlistsListProvider('user')); + return SafeArea( + child: Container( + color: fs.iron, + padding: const EdgeInsets.symmetric(vertical: 8), + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.6, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(20, 8, 20, 8), + child: Text( + 'Add to playlist', + style: TextStyle( + color: fs.parchment, + fontFamily: 'Fraunces', + fontSize: 20, + ), + ), + ), + Flexible( + child: lists.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => Padding( + padding: const EdgeInsets.all(16), + child: Text('$e', style: TextStyle(color: fs.error)), + ), + data: (data) { + final owned = data.owned + .where((p) => p.systemVariant == null) + .toList(); + if (owned.isEmpty) { + return Padding( + padding: const EdgeInsets.all(20), + child: Text( + "You haven't created any playlists yet.", + style: TextStyle(color: fs.ash), + ), + ); + } + return ListView.builder( + shrinkWrap: true, + itemCount: owned.length, + itemBuilder: (_, i) { + final p = owned[i]; + return ListTile( + key: Key('add_to_playlist_${p.id}'), + leading: Icon(Icons.queue_music, color: fs.parchment), + title: Text( + p.name, + style: TextStyle(color: fs.parchment), + ), + subtitle: Text( + '${p.trackCount} ${p.trackCount == 1 ? "track" : "tracks"}', + style: TextStyle(color: fs.ash, fontSize: 12), + ), + onTap: () => Navigator.pop(context, p.id), + ); + }, + ); + }, + ), + ), + ], + ), + ), + ); + } +} diff --git a/flutter_client/test/shared/widgets/track_actions/add_to_playlist_sheet_test.dart b/flutter_client/test/shared/widgets/track_actions/add_to_playlist_sheet_test.dart new file mode 100644 index 00000000..1b13c465 --- /dev/null +++ b/flutter_client/test/shared/widgets/track_actions/add_to_playlist_sheet_test.dart @@ -0,0 +1,114 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:minstrel/api/endpoints/playlists.dart'; +import 'package:minstrel/models/playlist.dart'; +import 'package:minstrel/playlists/playlists_provider.dart'; +import 'package:minstrel/shared/widgets/track_actions/add_to_playlist_sheet.dart'; +import 'package:minstrel/theme/theme_data.dart'; + +const _userPlaylist = Playlist( + id: 'p1', + userId: 'u1', + name: 'Road trip', + description: '', + isPublic: false, + systemVariant: null, + trackCount: 12, + coverUrl: '', + ownerUsername: 'alice', + createdAt: '2026-05-01T00:00:00Z', + updatedAt: '2026-05-01T00:00:00Z', +); + +const _systemPlaylist = Playlist( + id: 'p2', + userId: 'u1', + name: 'For You', + description: '', + isPublic: false, + systemVariant: 'for_you', + trackCount: 75, + coverUrl: '', + ownerUsername: 'alice', + createdAt: '2026-05-01T00:00:00Z', + updatedAt: '2026-05-01T00:00:00Z', +); + +Widget _harness(PlaylistsList lists) { + return ProviderScope( + overrides: [ + playlistsListProvider('user').overrideWith((ref) async => lists), + ], + child: MaterialApp( + theme: buildThemeData(), + home: Builder(builder: (ctx) { + return Scaffold( + body: Center( + child: ElevatedButton( + onPressed: () => AddToPlaylistSheet.show(ctx), + child: const Text('open'), + ), + ), + ); + }), + ), + ); +} + +void main() { + testWidgets('renders user playlists, hides system ones', (t) async { + await t.pumpWidget(_harness( + const PlaylistsList(owned: [_userPlaylist, _systemPlaylist], public: []), + )); + await t.tap(find.text('open')); + await t.pumpAndSettle(); + expect(find.text('Road trip'), findsOneWidget); + expect(find.text('For You'), findsNothing); + }); + + testWidgets('empty state when no user playlists', (t) async { + await t.pumpWidget(_harness( + const PlaylistsList(owned: [_systemPlaylist], public: []), + )); + await t.tap(find.text('open')); + await t.pumpAndSettle(); + expect( + find.text("You haven't created any playlists yet."), + findsOneWidget, + ); + }); + + testWidgets('tapping a row pops with playlist id', (t) async { + String? picked; + await t.pumpWidget(ProviderScope( + overrides: [ + playlistsListProvider('user').overrideWith( + (ref) async => + const PlaylistsList(owned: [_userPlaylist], public: []), + ), + ], + child: MaterialApp( + theme: buildThemeData(), + home: Builder(builder: (ctx) { + return Scaffold( + body: Center( + child: ElevatedButton( + onPressed: () async { + picked = await AddToPlaylistSheet.show(ctx); + }, + child: const Text('open'), + ), + ), + ); + }), + ), + )); + await t.tap(find.text('open')); + await t.pumpAndSettle(); + await t.tap(find.byKey(const Key('add_to_playlist_p1'))); + await t.pumpAndSettle(); + expect(picked, 'p1'); + }); +}