feat(flutter/library): card + row widgets (artist/album/track)

HorizontalScrollRow takes an optional shared ScrollController so
multi-row sections (Most played: 3 rows) couple their scroll like the
web HorizontalScrollRow.svelte. Cards fall back to the synced
album-fallback.svg when coverUrl is empty.
This commit is contained in:
2026-05-02 17:29:03 -04:00
parent 3e267918b7
commit c47deb3c87
5 changed files with 253 additions and 0 deletions
@@ -0,0 +1,51 @@
import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
import '../../models/album.dart';
import '../../theme/theme_extension.dart';
class AlbumCard extends StatelessWidget {
const AlbumCard({required this.album, required this.onTap, super.key});
final AlbumRef album;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
return GestureDetector(
onTap: onTap,
child: SizedBox(
width: 140,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
ClipRRect(
borderRadius: BorderRadius.circular(6),
child: Container(
width: 124,
height: 124,
color: fs.slate,
child: album.coverUrl.isEmpty
? SvgPicture.asset('assets/svg/album-fallback.svg', fit: BoxFit.cover)
: Image.network(album.coverUrl, fit: BoxFit.cover),
),
),
const SizedBox(height: 8),
Text(
album.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: fs.parchment, fontSize: 14),
),
Text(
album.artistName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: fs.ash, fontSize: 12),
),
]),
),
),
);
}
}
@@ -0,0 +1,44 @@
import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
import '../../models/artist.dart';
import '../../theme/theme_extension.dart';
class ArtistCard extends StatelessWidget {
const ArtistCard({required this.artist, required this.onTap, super.key});
final ArtistRef artist;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
return GestureDetector(
onTap: onTap,
child: SizedBox(
width: 140,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
ClipOval(
child: Container(
width: 124,
height: 124,
color: fs.slate,
child: artist.coverUrl.isEmpty
? SvgPicture.asset('assets/svg/album-fallback.svg', fit: BoxFit.cover)
: Image.network(artist.coverUrl, fit: BoxFit.cover),
),
),
const SizedBox(height: 8),
Text(
artist.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: fs.parchment, fontSize: 14),
),
]),
),
),
);
}
}
@@ -0,0 +1,48 @@
import 'package:flutter/material.dart';
import '../../theme/theme_extension.dart';
/// Mirrors the web HorizontalScrollRow: a labeled section that scrolls
/// horizontally. Multi-row sections share one [controller] so they
/// scroll together.
class HorizontalScrollRow extends StatelessWidget {
const HorizontalScrollRow({
required this.title,
required this.children,
this.height = 200,
this.controller,
super.key,
});
final String title;
final List<Widget> children;
final double height;
final ScrollController? controller;
@override
Widget build(BuildContext context) {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: Text(
title,
style: TextStyle(
fontFamily: fs.display.fontFamily,
fontSize: 18,
color: fs.parchment,
),
),
),
SizedBox(
height: height,
child: ListView(
controller: controller,
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 16),
children: children,
),
),
]);
}
}
@@ -0,0 +1,61 @@
import 'package:flutter/material.dart';
import '../../models/track.dart';
import '../../theme/theme_extension.dart';
class TrackRow extends StatelessWidget {
const TrackRow({
required this.track,
required this.onTap,
this.trailing,
super.key,
});
final TrackRef track;
final VoidCallback onTap;
final Widget? trailing;
@override
Widget build(BuildContext context) {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
final mins = (track.durationSec ~/ 60).toString().padLeft(2, '0');
final secs = (track.durationSec % 60).toString().padLeft(2, '0');
return InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
child: Row(children: [
if (track.trackNumber != null)
SizedBox(
width: 28,
child: Text(
track.trackNumber.toString(),
style: TextStyle(color: fs.ash, fontSize: 13),
),
),
Expanded(
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(
track.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: fs.parchment, fontSize: 14),
),
Text(
track.artistName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: fs.ash, fontSize: 12),
),
]),
),
Text('$mins:$secs', style: TextStyle(color: fs.ash, fontSize: 12)),
if (trailing != null)
Padding(
padding: const EdgeInsets.only(left: 8),
child: trailing!,
),
]),
),
);
}
}
@@ -0,0 +1,49 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:minstrel/library/widgets/album_card.dart';
import 'package:minstrel/library/widgets/track_row.dart';
import 'package:minstrel/models/album.dart';
import 'package:minstrel/models/track.dart';
import 'package:minstrel/theme/theme_data.dart';
void main() {
testWidgets('AlbumCard renders title and artist', (tester) async {
await tester.pumpWidget(MaterialApp(
theme: buildThemeData(),
home: Scaffold(
body: AlbumCard(
album: const AlbumRef(
id: 'a',
title: 'Geogaddi',
artistId: 'x',
artistName: 'Boards of Canada',
),
onTap: () {},
),
),
));
expect(find.text('Geogaddi'), findsOneWidget);
expect(find.text('Boards of Canada'), findsOneWidget);
});
testWidgets('TrackRow shows mm:ss duration', (tester) async {
await tester.pumpWidget(MaterialApp(
theme: buildThemeData(),
home: Scaffold(
body: TrackRow(
track: const TrackRef(
id: 't',
title: 'Roygbiv',
albumId: 'a',
artistId: 'x',
durationSec: 137,
trackNumber: 4,
),
onTap: () {},
),
),
));
expect(find.text('02:17'), findsOneWidget);
});
}