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) <noreply@anthropic.com>
This commit is contained in:
2026-05-15 06:35:55 -04:00
parent d12afdad6e
commit 7a0437087a
3 changed files with 87 additions and 0 deletions
@@ -53,4 +53,20 @@ class PlaylistsApi {
data: {'track_ids': trackIds}, 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<String?> refreshSystem(String variant) async {
final segment = variant == 'for_you' ? 'for-you' : variant;
final r = await _dio.post<Map<String, dynamic>>(
'/api/playlists/system/$segment/refresh',
data: const <String, dynamic>{},
);
return (r.data ?? const {})['playlist_id'] as String?;
}
} }
@@ -66,6 +66,31 @@ class PlaylistCard extends ConsumerWidget {
onPressed: () => _playPlaylist(ref), 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<String>(
icon: Icon(Icons.more_vert, color: fs.parchment, size: 18),
tooltip: 'Playlist actions',
color: fs.iron,
onSelected: (_) => _refresh(context, ref),
itemBuilder: (_) => [
PopupMenuItem<String>(
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), 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<void> _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 /// Fetches the playlist via /api/playlists/{id}, materializes each
/// PlaylistTrack into a TrackRef (filtering out unavailable rows /// PlaylistTrack into a TrackRef (filtering out unavailable rows
/// whose `trackId` is null after a track-delete), and plays from /// whose `trackId` is null after a track-delete), and plays from
@@ -55,4 +55,24 @@ void main() {
expect(find.text('For You'), findsOneWidget); expect(find.text('For You'), findsOneWidget);
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);
});
} }