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:
@@ -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);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user