fix(flutter): PlaylistsApi.list parses {owned, public} envelope

The server has always returned an enveloped response from GET
/api/playlists; the existing client decoded it as a flat List which
fails at runtime against the actual Map shape. Existing playlists
list screen would have been broken on any production instance.

list() now returns PlaylistsList { owned, public }. The existing
list screen consumes lists.all (owned + public flattened) — same
visible output as the previous flat-list assumption.

This unblocks the home parity slice which needs the same endpoint
for the new Playlists row.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-09 11:35:34 -04:00
parent 28b07c7ef4
commit 5613fec5ef
3 changed files with 32 additions and 12 deletions
+29 -10
View File
@@ -2,23 +2,42 @@ import 'package:dio/dio.dart';
import '../../models/playlist.dart'; import '../../models/playlist.dart';
/// Wire shape for GET /api/playlists. Server splits owned vs. public so
/// the UI can present them as different sections; we preserve that split
/// to give the integrations page room to grow.
class PlaylistsList {
const PlaylistsList({required this.owned, required this.public});
final List<Playlist> owned;
final List<Playlist> public;
factory PlaylistsList.empty() =>
const PlaylistsList(owned: [], public: []);
/// Concatenated view for callers that don't care about the split.
List<Playlist> get all => [...owned, ...public];
}
class PlaylistsApi { class PlaylistsApi {
PlaylistsApi(this._dio); PlaylistsApi(this._dio);
final Dio _dio; final Dio _dio;
/// GET /api/playlists?kind=user|system|all. The server's default kind /// GET /api/playlists?kind=user|system|all. Server returns
/// is "user" — passing "all" returns user playlists + system mixes. /// `{"owned": [...], "public": [...]}`. Owned is the caller's own
/// "system" returns just for-you / discover. The mobile UI defaults /// playlists, filtered by the kind param (default "user"). Public
/// to "all" so users see their generated mixes alongside their own. /// is other users' shared playlists; not filtered by kind.
Future<List<Playlist>> list({String kind = 'all'}) async { Future<PlaylistsList> list({String kind = 'user'}) async {
final r = await _dio.get<List<dynamic>>( final r = await _dio.get<Map<String, dynamic>>(
'/api/playlists', '/api/playlists',
queryParameters: {'kind': kind}, queryParameters: {'kind': kind},
); );
final raw = r.data ?? const []; final body = r.data ?? const <String, dynamic>{};
return raw List<Playlist> parse(String key) {
.map((e) => Playlist.fromJson((e as Map).cast<String, dynamic>())) final raw = (body[key] as List?) ?? const [];
.toList(growable: false); return raw
.map((e) => Playlist.fromJson((e as Map).cast<String, dynamic>()))
.toList(growable: false);
}
return PlaylistsList(owned: parse('owned'), public: parse('public'));
} }
Future<PlaylistDetail> get(String id) async { Future<PlaylistDetail> get(String id) async {
@@ -31,7 +31,8 @@ class PlaylistsListScreen extends ConsumerWidget {
error: (e, _) => Center( error: (e, _) => Center(
child: Text('$e', style: TextStyle(color: fs.error)), child: Text('$e', style: TextStyle(color: fs.error)),
), ),
data: (items) { data: (lists) {
final items = lists.all;
if (items.isEmpty) { if (items.isEmpty) {
return Center( return Center(
child: Text( child: Text(
@@ -9,7 +9,7 @@ final playlistsApiProvider = FutureProvider<PlaylistsApi>((ref) async {
}); });
final playlistsListProvider = final playlistsListProvider =
FutureProvider.family<List<Playlist>, String>((ref, kind) async { FutureProvider.family<PlaylistsList, String>((ref, kind) async {
return (await ref.watch(playlistsApiProvider.future)).list(kind: kind); return (await ref.watch(playlistsApiProvider.future)).list(kind: kind);
}); });