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