feat(flutter/widgets): PlaylistCard + PlaylistPlaceholderCard

Mirrors the web equivalents at the same ~176dp width so the home
Playlists row keeps visual rhythm whether the slot is filled with a
real playlist or a placeholder.

PlaylistPlaceholderCard variants:
- building: spinner + "Building…"
- failed: warning + "Couldn't generate"
- seed-needed: muted icon + "Like more music"
- pending: muted icon + "Coming soon"

Used in the next commit's home Playlists row; otherwise unused this
slice.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-09 11:38:19 -04:00
parent dccdb00bce
commit 3ac8cc9363
4 changed files with 257 additions and 0 deletions
@@ -0,0 +1,64 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../../models/playlist.dart';
import '../../shared/widgets/server_image.dart';
import '../../theme/theme_extension.dart';
/// Mirrors the web PlaylistCard. ~176dp wide, square cover (~144dp),
/// name + optional system-variant badge below. Tap pushes
/// /playlists/<id>.
class PlaylistCard extends StatelessWidget {
const PlaylistCard({super.key, required this.playlist});
final Playlist playlist;
@override
Widget build(BuildContext context) {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
return SizedBox(
width: 176,
child: GestureDetector(
onTap: () => context.push('/playlists/${playlist.id}'),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
ClipRRect(
borderRadius: BorderRadius.circular(6),
child: Container(
width: 144,
height: 144,
color: fs.slate,
child: playlist.coverUrl.isEmpty
? Icon(Icons.queue_music, color: fs.ash, size: 56)
: ServerImage(url: playlist.coverUrl, fit: BoxFit.cover),
),
),
const SizedBox(height: 8),
Text(
playlist.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: fs.parchment, fontSize: 14),
),
if (playlist.isSystem)
Padding(
padding: const EdgeInsets.only(top: 2),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: fs.iron,
borderRadius: BorderRadius.circular(4),
),
child: Text(
playlist.systemVariant!.replaceAll('_', ' '),
style: TextStyle(color: fs.accent, fontSize: 11),
),
),
),
]),
),
),
);
}
}
@@ -0,0 +1,86 @@
import 'package:flutter/material.dart';
import '../../theme/theme_extension.dart';
/// Same dimensions as PlaylistCard so the home Playlists row keeps
/// visual rhythm whether real or placeholder. Mirrors the web
/// PlaylistPlaceholderCard. Variant decides the state copy below
/// the label.
class PlaylistPlaceholderCard extends StatelessWidget {
const PlaylistPlaceholderCard({
super.key,
required this.label,
required this.variant,
});
/// "For You" or "Songs like…" — what the slot is reserved for.
final String label;
/// One of: 'building' | 'failed' | 'pending' | 'seed-needed'.
final String variant;
@override
Widget build(BuildContext context) {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
return SizedBox(
width: 176,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Container(
width: 144,
height: 144,
decoration: BoxDecoration(
color: fs.iron,
borderRadius: BorderRadius.circular(6),
border: Border.all(color: fs.slate, width: 1),
),
child: Center(child: _stateIcon(fs)),
),
const SizedBox(height: 8),
Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: fs.parchment, fontSize: 14),
),
Padding(
padding: const EdgeInsets.only(top: 2),
child: Text(
_stateText(),
style: TextStyle(color: fs.ash, fontSize: 11),
),
),
]),
),
);
}
Widget _stateIcon(FabledSwordTheme fs) {
switch (variant) {
case 'building':
return SizedBox(
width: 28,
height: 28,
child: CircularProgressIndicator(strokeWidth: 2, color: fs.accent),
);
case 'failed':
return Icon(Icons.warning_amber, color: fs.error, size: 32);
default:
return Icon(Icons.queue_music, color: fs.ash, size: 40);
}
}
String _stateText() {
switch (variant) {
case 'building':
return 'Building…';
case 'failed':
return "Couldn't generate";
case 'seed-needed':
return 'Like more music';
default:
return 'Coming soon';
}
}
}
@@ -0,0 +1,58 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:minstrel/models/playlist.dart';
import 'package:minstrel/playlists/widgets/playlist_card.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 _forYou = 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',
);
void main() {
testWidgets('renders name and no badge for user playlist', (tester) async {
await tester.pumpWidget(MaterialApp(
theme: buildThemeData(),
home: const Scaffold(
body: PlaylistCard(playlist: _userPlaylist),
),
));
expect(find.text('Road trip'), findsOneWidget);
expect(find.text('for you'), findsNothing);
});
testWidgets('renders system badge for system playlist', (tester) async {
await tester.pumpWidget(MaterialApp(
theme: buildThemeData(),
home: const Scaffold(
body: PlaylistCard(playlist: _forYou),
),
));
expect(find.text('For You'), findsOneWidget);
expect(find.text('for you'), findsOneWidget);
});
}
@@ -0,0 +1,49 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:minstrel/playlists/widgets/playlist_placeholder_card.dart';
import 'package:minstrel/theme/theme_data.dart';
void main() {
testWidgets('building variant renders spinner + label', (tester) async {
await tester.pumpWidget(MaterialApp(
theme: buildThemeData(),
home: const Scaffold(
body: PlaylistPlaceholderCard(label: 'For You', variant: 'building'),
),
));
expect(find.text('For You'), findsOneWidget);
expect(find.text('Building…'), findsOneWidget);
expect(find.byType(CircularProgressIndicator), findsOneWidget);
});
testWidgets('failed variant shows warning + copy', (tester) async {
await tester.pumpWidget(MaterialApp(
theme: buildThemeData(),
home: const Scaffold(
body: PlaylistPlaceholderCard(label: 'Songs like…', variant: 'failed'),
),
));
expect(find.text("Couldn't generate"), findsOneWidget);
});
testWidgets('seed-needed variant copy', (tester) async {
await tester.pumpWidget(MaterialApp(
theme: buildThemeData(),
home: const Scaffold(
body: PlaylistPlaceholderCard(label: 'Songs like…', variant: 'seed-needed'),
),
));
expect(find.text('Like more music'), findsOneWidget);
});
testWidgets('pending variant copy', (tester) async {
await tester.pumpWidget(MaterialApp(
theme: buildThemeData(),
home: const Scaffold(
body: PlaylistPlaceholderCard(label: 'For You', variant: 'pending'),
),
));
expect(find.text('Coming soon'), findsOneWidget);
});
}