From c47deb3c87fe747fcd470d7c37e7b79ddd892538 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sat, 2 May 2026 17:29:03 -0400 Subject: [PATCH] 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. --- .../lib/library/widgets/album_card.dart | 51 ++++++++++++++++ .../lib/library/widgets/artist_card.dart | 44 +++++++++++++ .../widgets/horizontal_scroll_row.dart | 48 +++++++++++++++ .../lib/library/widgets/track_row.dart | 61 +++++++++++++++++++ .../test/library/widgets_smoke_test.dart | 49 +++++++++++++++ 5 files changed, 253 insertions(+) create mode 100644 flutter_client/lib/library/widgets/album_card.dart create mode 100644 flutter_client/lib/library/widgets/artist_card.dart create mode 100644 flutter_client/lib/library/widgets/horizontal_scroll_row.dart create mode 100644 flutter_client/lib/library/widgets/track_row.dart create mode 100644 flutter_client/test/library/widgets_smoke_test.dart diff --git a/flutter_client/lib/library/widgets/album_card.dart b/flutter_client/lib/library/widgets/album_card.dart new file mode 100644 index 00000000..84915b29 --- /dev/null +++ b/flutter_client/lib/library/widgets/album_card.dart @@ -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()!; + 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), + ), + ]), + ), + ), + ); + } +} diff --git a/flutter_client/lib/library/widgets/artist_card.dart b/flutter_client/lib/library/widgets/artist_card.dart new file mode 100644 index 00000000..8c17b5ef --- /dev/null +++ b/flutter_client/lib/library/widgets/artist_card.dart @@ -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()!; + 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), + ), + ]), + ), + ), + ); + } +} diff --git a/flutter_client/lib/library/widgets/horizontal_scroll_row.dart b/flutter_client/lib/library/widgets/horizontal_scroll_row.dart new file mode 100644 index 00000000..dccc4427 --- /dev/null +++ b/flutter_client/lib/library/widgets/horizontal_scroll_row.dart @@ -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 children; + final double height; + final ScrollController? controller; + + @override + Widget build(BuildContext context) { + final fs = Theme.of(context).extension()!; + 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, + ), + ), + ]); + } +} diff --git a/flutter_client/lib/library/widgets/track_row.dart b/flutter_client/lib/library/widgets/track_row.dart new file mode 100644 index 00000000..371d7857 --- /dev/null +++ b/flutter_client/lib/library/widgets/track_row.dart @@ -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()!; + 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!, + ), + ]), + ), + ); + } +} diff --git a/flutter_client/test/library/widgets_smoke_test.dart b/flutter_client/test/library/widgets_smoke_test.dart new file mode 100644 index 00000000..2283f71d --- /dev/null +++ b/flutter_client/test/library/widgets_smoke_test.dart @@ -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); + }); +}