diff --git a/flutter_client/lib/library/album_detail_screen.dart b/flutter_client/lib/library/album_detail_screen.dart new file mode 100644 index 00000000..6aa29c0c --- /dev/null +++ b/flutter_client/lib/library/album_detail_screen.dart @@ -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()!; + 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 */ }), + ]), + ), + ); + } +} diff --git a/flutter_client/lib/library/artist_detail_screen.dart b/flutter_client/lib/library/artist_detail_screen.dart new file mode 100644 index 00000000..0cb7e291 --- /dev/null +++ b/flutter_client/lib/library/artist_detail_screen.dart @@ -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()!; + 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}')); + }, + ), + ), + ]), + ), + ); + } +} diff --git a/flutter_client/lib/shared/routing.dart b/flutter_client/lib/shared/routing.dart index ac9ab4e2..b9d6d870 100644 --- a/flutter_client/lib/shared/routing.dart +++ b/flutter_client/lib/shared/routing.dart @@ -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']!), + ), ], ), ], diff --git a/flutter_client/test/library/album_detail_screen_test.dart b/flutter_client/test/library/album_detail_screen_test.dart new file mode 100644 index 00000000..16ed20e7 --- /dev/null +++ b/flutter_client/test/library/album_detail_screen_test.dart @@ -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); + }); +} diff --git a/flutter_client/test/library/artist_detail_screen_test.dart b/flutter_client/test/library/artist_detail_screen_test.dart new file mode 100644 index 00000000..7727bd2d --- /dev/null +++ b/flutter_client/test/library/artist_detail_screen_test.dart @@ -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); + }); +}