From 7a0437087a06a8f9c813f0c7df20e704cf32881d Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 15 May 2026 06:35:55 -0400 Subject: [PATCH] feat(flutter): refresh kebab on system playlist cards (#416 parity) Closes the Flutter half of Fable #416. Web got a generalized system-playlist refresh kebab in d12afda; this brings Flutter to parity instead of leaving the affordance web-only. - PlaylistsApi.refreshSystem(variant): POST /api/playlists/system/{for-you|discover}/refresh, maps the underscore model variant to the hyphenated route segment, returns the rotated playlist id. - PlaylistCard: top-right PopupMenuButton on system playlists with a context-labelled "Refresh For You" / "Refresh Discover" item. Calls refreshSystem, invalidates playlistsListProvider (which reconciles the rotated UUID + new tracks), snackbars the result. ScaffoldMessenger captured pre-await. - Tests: kebab present for system, absent for user playlists. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../lib/api/endpoints/playlists.dart | 16 ++++++ .../lib/playlists/widgets/playlist_card.dart | 51 +++++++++++++++++++ .../playlists/widgets/playlist_card_test.dart | 20 ++++++++ 3 files changed, 87 insertions(+) diff --git a/flutter_client/lib/api/endpoints/playlists.dart b/flutter_client/lib/api/endpoints/playlists.dart index 9d772dd8..4e841b0f 100644 --- a/flutter_client/lib/api/endpoints/playlists.dart +++ b/flutter_client/lib/api/endpoints/playlists.dart @@ -53,4 +53,20 @@ class PlaylistsApi { data: {'track_ids': trackIds}, ); } + + /// POST /api/playlists/system/{variant}/refresh. Synchronously + /// rebuilds the caller's system playlist for the given variant + /// ("for_you" | "discover") and returns the new playlist id, or + /// null when the library is empty so there's nothing to build. + /// + /// The variant uses underscores in the model (system_variant) but + /// the route segment is hyphenated ("for-you"), so map here. + Future refreshSystem(String variant) async { + final segment = variant == 'for_you' ? 'for-you' : variant; + final r = await _dio.post>( + '/api/playlists/system/$segment/refresh', + data: const {}, + ); + return (r.data ?? const {})['playlist_id'] as String?; + } } diff --git a/flutter_client/lib/playlists/widgets/playlist_card.dart b/flutter_client/lib/playlists/widgets/playlist_card.dart index 66566558..ccb25613 100644 --- a/flutter_client/lib/playlists/widgets/playlist_card.dart +++ b/flutter_client/lib/playlists/widgets/playlist_card.dart @@ -66,6 +66,31 @@ class PlaylistCard extends ConsumerWidget { onPressed: () => _playPlaylist(ref), ), ), + // System playlists get a refresh affordance so the + // user can force a fresh mix instead of waiting for + // the daily 03:00 rebuild. Mirrors the web kebab. + if (playlist.isSystem) + Positioned( + top: 2, + right: 2, + child: PopupMenuButton( + icon: Icon(Icons.more_vert, color: fs.parchment, size: 18), + tooltip: 'Playlist actions', + color: fs.iron, + onSelected: (_) => _refresh(context, ref), + itemBuilder: (_) => [ + PopupMenuItem( + value: 'refresh', + child: Row(children: [ + Icon(Icons.refresh, color: fs.parchment, size: 18), + const SizedBox(width: 8), + Text(_refreshLabel, + style: TextStyle(color: fs.parchment)), + ]), + ), + ], + ), + ), ], ), const SizedBox(height: 8), @@ -98,6 +123,32 @@ class PlaylistCard extends ConsumerWidget { ); } + String get _refreshLabel => switch (playlist.systemVariant) { + 'for_you' => 'Refresh For You', + 'discover' => 'Refresh Discover', + _ => 'Refresh', + }; + + /// Forces a server-side rebuild of this system playlist, then + /// invalidates the aggregate list so the rotated UUID + new tracks + /// land on the next read. ScaffoldMessenger is captured before the + /// await so we don't touch a stale BuildContext afterward. + Future _refresh(BuildContext context, WidgetRef ref) async { + final messenger = ScaffoldMessenger.of(context); + try { + final api = await ref.read(playlistsApiProvider.future); + await api.refreshSystem(playlist.systemVariant!); + ref.invalidate(playlistsListProvider); + messenger.showSnackBar( + SnackBar(content: Text('${playlist.name} refreshed')), + ); + } catch (e) { + messenger.showSnackBar( + SnackBar(content: Text("Couldn't refresh: $e")), + ); + } + } + /// Fetches the playlist via /api/playlists/{id}, materializes each /// PlaylistTrack into a TrackRef (filtering out unavailable rows /// whose `trackId` is null after a track-delete), and plays from diff --git a/flutter_client/test/playlists/widgets/playlist_card_test.dart b/flutter_client/test/playlists/widgets/playlist_card_test.dart index 2d04a589..228a9707 100644 --- a/flutter_client/test/playlists/widgets/playlist_card_test.dart +++ b/flutter_client/test/playlists/widgets/playlist_card_test.dart @@ -55,4 +55,24 @@ void main() { expect(find.text('For You'), findsOneWidget); expect(find.text('for you'), findsOneWidget); }); + + testWidgets('shows refresh kebab on system playlists', (tester) async { + await tester.pumpWidget(MaterialApp( + theme: buildThemeData(), + home: const Scaffold( + body: PlaylistCard(playlist: _forYou), + ), + )); + expect(find.byIcon(Icons.more_vert), findsOneWidget); + }); + + testWidgets('no refresh kebab on user playlists', (tester) async { + await tester.pumpWidget(MaterialApp( + theme: buildThemeData(), + home: const Scaffold( + body: PlaylistCard(playlist: _userPlaylist), + ), + )); + expect(find.byIcon(Icons.more_vert), findsNothing); + }); }