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