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