feat(flutter/widgets): CompactTrackCard for dense home Most-played row

Mirrors the web CompactTrackCard — small horizontal cell with cover
art (48dp), title, artist. Tap plays from this track in the section.
Width 176dp matches web's compact density so 25 tracks per row scroll
naturally on phone widths.

Cover URL is derived from track.albumId (/api/albums/<id>/cover) since
TrackRef itself doesn't carry a cover field — same pattern as web's
coverUrl() helper.

Used in the next commit's home Most-played section (3 rows × 25);
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:37:40 -04:00
parent 078520f504
commit dccdb00bce
2 changed files with 120 additions and 0 deletions
@@ -0,0 +1,82 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../models/track.dart';
import '../../player/player_provider.dart';
import '../../shared/widgets/server_image.dart';
import '../../theme/theme_extension.dart';
/// Small horizontal track cell used by the home Most-played section.
/// Mirrors the web CompactTrackCard sizing (~176dp wide, ~56dp tall).
/// Tap plays from this track within [sectionTracks] starting at [index].
class CompactTrackCard extends ConsumerWidget {
const CompactTrackCard({
super.key,
required this.track,
required this.sectionTracks,
required this.index,
});
final TrackRef track;
/// All tracks in the home section so play-from-here can queue them
/// in order.
final List<TrackRef> sectionTracks;
final int index;
@override
Widget build(BuildContext context, WidgetRef ref) {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
// Cover URL derives from the track's album. Mirrors web
// CompactTrackCard which calls coverUrl(track.album_id).
final coverUrl = track.albumId.isNotEmpty
? '/api/albums/${track.albumId}/cover'
: '';
return SizedBox(
width: 176,
child: InkWell(
onTap: () => ref
.read(playerActionsProvider)
.playTracks(sectionTracks, initialIndex: index),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: Row(children: [
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: SizedBox(
width: 48,
height: 48,
child: ServerImage(
url: coverUrl,
fit: BoxFit.cover,
fallback: Container(color: fs.slate),
),
),
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
track.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: fs.parchment, fontSize: 13),
),
Text(
track.artistName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: fs.ash, fontSize: 11),
),
],
),
),
]),
),
),
);
}
}
@@ -0,0 +1,38 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:minstrel/library/widgets/compact_track_card.dart';
import 'package:minstrel/models/track.dart';
import 'package:minstrel/theme/theme_data.dart';
const _track = TrackRef(
id: 't1',
title: 'Roygbiv',
albumId: 'a1',
albumTitle: 'Geogaddi',
artistId: 'ar1',
artistName: 'Boards of Canada',
durationSec: 137,
trackNumber: 4,
streamUrl: '',
);
void main() {
testWidgets('renders title and artist', (tester) async {
await tester.pumpWidget(ProviderScope(
child: MaterialApp(
theme: buildThemeData(),
home: Scaffold(
body: CompactTrackCard(
track: _track,
sectionTracks: const [_track],
index: 0,
),
),
),
));
expect(find.text('Roygbiv'), findsOneWidget);
expect(find.text('Boards of Canada'), findsOneWidget);
});
}