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