diff --git a/flutter_client/lib/playlists/widgets/playlist_card.dart b/flutter_client/lib/playlists/widgets/playlist_card.dart new file mode 100644 index 00000000..3e622e73 --- /dev/null +++ b/flutter_client/lib/playlists/widgets/playlist_card.dart @@ -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/. +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()!; + 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), + ), + ), + ), + ]), + ), + ), + ); + } +} diff --git a/flutter_client/lib/playlists/widgets/playlist_placeholder_card.dart b/flutter_client/lib/playlists/widgets/playlist_placeholder_card.dart new file mode 100644 index 00000000..59db7b92 --- /dev/null +++ b/flutter_client/lib/playlists/widgets/playlist_placeholder_card.dart @@ -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()!; + 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'; + } + } +} diff --git a/flutter_client/test/playlists/widgets/playlist_card_test.dart b/flutter_client/test/playlists/widgets/playlist_card_test.dart new file mode 100644 index 00000000..2d04a589 --- /dev/null +++ b/flutter_client/test/playlists/widgets/playlist_card_test.dart @@ -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); + }); +} diff --git a/flutter_client/test/playlists/widgets/playlist_placeholder_card_test.dart b/flutter_client/test/playlists/widgets/playlist_placeholder_card_test.dart new file mode 100644 index 00000000..e7c43eba --- /dev/null +++ b/flutter_client/test/playlists/widgets/playlist_placeholder_card_test.dart @@ -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); + }); +}