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:
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user