feat(flutter/library): artist + album detail screens

Headers carry placeholder play buttons (wired in Task 18) + room for
LikeButton (Task 16). Artist screen has a 2-column album grid; album
screen has a track list with mm:ss durations. Routes :id-parameterized
under the shell so the PlayerBar persists.
This commit is contained in:
2026-05-02 17:32:34 -04:00
parent 5ef782905e
commit 6ec7cea362
5 changed files with 198 additions and 0 deletions
@@ -0,0 +1,55 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../theme/theme_extension.dart';
import 'library_providers.dart';
import 'widgets/track_row.dart';
class AlbumDetailScreen extends ConsumerWidget {
const AlbumDetailScreen({required this.id, super.key});
final String id;
@override
Widget build(BuildContext context, WidgetRef ref) {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
return Scaffold(
appBar: AppBar(),
backgroundColor: fs.obsidian,
body: ref.watch(albumProvider(id)).when(
error: (e, _) => Center(child: Text('$e', style: TextStyle(color: fs.error))),
loading: () => const Center(child: CircularProgressIndicator()),
data: (r) => ListView(children: [
Padding(
padding: const EdgeInsets.all(16),
child: Row(children: [
Container(width: 96, height: 96, color: fs.slate),
const SizedBox(width: 16),
Expanded(child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(r.album.title, style: TextStyle(
color: fs.parchment,
fontFamily: fs.display.fontFamily,
fontSize: 22,
)),
Text(r.album.artistName, style: TextStyle(color: fs.ash)),
],
)),
Container(
width: 48, height: 48,
decoration: BoxDecoration(color: fs.accent, shape: BoxShape.circle),
child: IconButton(
icon: Icon(Icons.play_arrow, color: fs.parchment),
onPressed: () { /* play wired in Task 18 */ },
),
),
// LikeButton goes here in Task 16.
]),
),
for (final t in r.tracks)
TrackRow(track: t, onTap: () { /* play wired in Task 18 */ }),
]),
),
);
}
}
@@ -0,0 +1,82 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../models/album.dart';
import '../theme/theme_extension.dart';
import 'library_providers.dart';
import 'widgets/album_card.dart';
class ArtistDetailScreen extends ConsumerWidget {
const ArtistDetailScreen({required this.id, super.key});
final String id;
@override
Widget build(BuildContext context, WidgetRef ref) {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
final artist = ref.watch(artistProvider(id));
final albums = ref.watch(artistAlbumsProvider(id));
return Scaffold(
appBar: AppBar(),
backgroundColor: fs.obsidian,
body: artist.when(
error: (e, _) => Center(child: Text('$e', style: TextStyle(color: fs.error))),
loading: () => const Center(child: CircularProgressIndicator()),
data: (a) => ListView(children: [
Padding(
padding: const EdgeInsets.all(16),
child: Row(children: [
Container(
width: 96, height: 96,
decoration: BoxDecoration(color: fs.slate, shape: BoxShape.circle),
),
const SizedBox(width: 16),
Expanded(
child: Text(
a.name,
style: TextStyle(
color: fs.parchment,
fontFamily: fs.display.fontFamily,
fontSize: 24,
),
),
),
Container(
width: 48, height: 48,
decoration: BoxDecoration(color: fs.accent, shape: BoxShape.circle),
child: IconButton(
icon: Icon(Icons.play_arrow, color: fs.parchment),
onPressed: () { /* play wired in Task 18 */ },
),
),
// LikeButton goes here in Task 16.
]),
),
const Padding(
padding: EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Text('Albums', style: TextStyle(fontSize: 16)),
),
albums.when(
error: (e, _) => Center(child: Text('$e', style: TextStyle(color: fs.error))),
loading: () => const Padding(padding: EdgeInsets.all(16), child: Center(child: CircularProgressIndicator())),
data: (list) => GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
padding: const EdgeInsets.all(8),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
childAspectRatio: 0.8,
),
itemCount: list.length,
itemBuilder: (_, i) {
final AlbumRef album = list[i];
return AlbumCard(album: album, onTap: () => context.push('/albums/${album.id}'));
},
),
),
]),
),
);
}
}
+10
View File
@@ -5,6 +5,8 @@ import 'package:go_router/go_router.dart';
import '../auth/auth_provider.dart';
import '../auth/login_screen.dart';
import '../auth/server_url_screen.dart';
import '../library/album_detail_screen.dart';
import '../library/artist_detail_screen.dart';
import '../library/home_screen.dart';
import 'widgets/version_gate.dart';
@@ -32,6 +34,14 @@ GoRouter buildRouter(Ref ref) {
builder: (ctx, state, child) => VersionGate(child: _ShellWithPlayerBar(child: child)),
routes: [
GoRoute(path: '/home', builder: (_, __) => const HomeScreen()),
GoRoute(
path: '/artists/:id',
builder: (_, s) => ArtistDetailScreen(id: s.pathParameters['id']!),
),
GoRoute(
path: '/albums/:id',
builder: (_, s) => AlbumDetailScreen(id: s.pathParameters['id']!),
),
],
),
],
@@ -0,0 +1,28 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:minstrel/library/album_detail_screen.dart';
import 'package:minstrel/library/library_providers.dart';
import 'package:minstrel/models/album.dart';
import 'package:minstrel/models/track.dart';
import 'package:minstrel/theme/theme_data.dart';
void main() {
testWidgets('renders album header + track list', (tester) async {
await tester.pumpWidget(ProviderScope(
overrides: [
albumProvider('al-1').overrideWith((ref) async => (
album: const AlbumRef(id: 'al-1', title: 'Drukqs', artistId: 'a-1', artistName: 'Aphex Twin'),
tracks: const [
TrackRef(id: 't-1', title: 'Avril 14th', albumId: 'al-1', artistId: 'a-1', durationSec: 121, trackNumber: 4),
],
)),
],
child: MaterialApp(theme: buildThemeData(), home: const AlbumDetailScreen(id: 'al-1')),
));
await tester.pumpAndSettle();
expect(find.text('Drukqs'), findsOneWidget);
expect(find.text('Avril 14th'), findsOneWidget);
});
}
@@ -0,0 +1,23 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:minstrel/library/artist_detail_screen.dart';
import 'package:minstrel/library/library_providers.dart';
import 'package:minstrel/models/artist.dart';
import 'package:minstrel/theme/theme_data.dart';
void main() {
testWidgets('renders artist name and Albums header', (tester) async {
await tester.pumpWidget(ProviderScope(
overrides: [
artistProvider('a-1').overrideWith((ref) async => const ArtistRef(id: 'a-1', name: 'Aphex Twin')),
artistAlbumsProvider('a-1').overrideWith((ref) async => const []),
],
child: MaterialApp(theme: buildThemeData(), home: const ArtistDetailScreen(id: 'a-1')),
));
await tester.pumpAndSettle();
expect(find.text('Aphex Twin'), findsOneWidget);
expect(find.text('Albums'), findsOneWidget);
});
}