diff --git a/flutter_client/lib/api/endpoints/library_lists.dart b/flutter_client/lib/api/endpoints/library_lists.dart new file mode 100644 index 00000000..f06dc6bd --- /dev/null +++ b/flutter_client/lib/api/endpoints/library_lists.dart @@ -0,0 +1,29 @@ +import 'package:dio/dio.dart'; + +import '../../models/album.dart'; +import '../../models/artist.dart'; +import '../../models/page.dart'; + +/// Paged library browse endpoints. Distinct from LibraryApi (which +/// fetches Home + entity detail) so screens that just need a flat +/// list don't drag in detail-page providers. +class LibraryListsApi { + LibraryListsApi(this._dio); + final Dio _dio; + + Future> listArtists({int limit = 50, int offset = 0}) async { + final r = await _dio.get>( + '/api/library/artists', + queryParameters: {'limit': limit, 'offset': offset}, + ); + return Paged.fromJson(r.data ?? const {}, ArtistRef.fromJson); + } + + Future> listAlbums({int limit = 50, int offset = 0}) async { + final r = await _dio.get>( + '/api/library/albums', + queryParameters: {'limit': limit, 'offset': offset}, + ); + return Paged.fromJson(r.data ?? const {}, AlbumRef.fromJson); + } +} diff --git a/flutter_client/lib/api/endpoints/likes.dart b/flutter_client/lib/api/endpoints/likes.dart index 03771e47..e4732369 100644 --- a/flutter_client/lib/api/endpoints/likes.dart +++ b/flutter_client/lib/api/endpoints/likes.dart @@ -1,5 +1,10 @@ import 'package:dio/dio.dart'; +import '../../models/album.dart'; +import '../../models/artist.dart'; +import '../../models/page.dart'; +import '../../models/track.dart'; + enum LikeKind { artist, album, track } extension LikeKindPath on LikeKind { @@ -22,6 +27,31 @@ class LikesApi { await _dio.delete('/api/likes/${kind.path}/$id'); } + /// GET /api/likes/tracks?limit=N&offset=N. Paged. + Future> listTracks({int limit = 50, int offset = 0}) async { + final r = await _dio.get>( + '/api/likes/tracks', + queryParameters: {'limit': limit, 'offset': offset}, + ); + return Paged.fromJson(r.data ?? const {}, TrackRef.fromJson); + } + + Future> listAlbums({int limit = 50, int offset = 0}) async { + final r = await _dio.get>( + '/api/likes/albums', + queryParameters: {'limit': limit, 'offset': offset}, + ); + return Paged.fromJson(r.data ?? const {}, AlbumRef.fromJson); + } + + Future> listArtists({int limit = 50, int offset = 0}) async { + final r = await _dio.get>( + '/api/likes/artists', + queryParameters: {'limit': limit, 'offset': offset}, + ); + return Paged.fromJson(r.data ?? const {}, ArtistRef.fromJson); + } + /// Returns sets of {artists, albums, tracks} the user has liked. /// Server response keys (verified in internal/api/likes.go /// `likedIDsResponse`): `artist_ids`, `album_ids`, `track_ids`. diff --git a/flutter_client/lib/api/endpoints/me.dart b/flutter_client/lib/api/endpoints/me.dart new file mode 100644 index 00000000..dd98bb06 --- /dev/null +++ b/flutter_client/lib/api/endpoints/me.dart @@ -0,0 +1,30 @@ +import 'package:dio/dio.dart'; + +import '../../models/history_event.dart'; +import '../../models/quarantine_mine.dart'; + +/// /api/me/* endpoints โ€” caller-scoped data (history, profile, etc.). +class MeApi { + MeApi(this._dio); + final Dio _dio; + + /// GET /api/me/history. Paginated via offset/limit; server emits + /// `{events, has_more}` rather than the standard `Page` envelope. + Future history({int limit = 50, int offset = 0}) async { + final r = await _dio.get>( + '/api/me/history', + queryParameters: {'limit': limit, 'offset': offset}, + ); + return HistoryPage.fromJson(r.data ?? const {}); + } + + /// GET /api/quarantine/mine โ€” flat list (no envelope). + Future> quarantineMine() async { + final r = await _dio.get>('/api/quarantine/mine'); + final raw = r.data ?? const []; + return raw + .map((e) => + QuarantineMineRow.fromJson((e as Map).cast())) + .toList(growable: false); + } +} diff --git a/flutter_client/lib/library/library_screen.dart b/flutter_client/lib/library/library_screen.dart new file mode 100644 index 00000000..4a9decb3 --- /dev/null +++ b/flutter_client/lib/library/library_screen.dart @@ -0,0 +1,424 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../api/endpoints/library_lists.dart'; +import '../api/endpoints/likes.dart'; +import '../api/endpoints/me.dart'; +import '../library/library_providers.dart' show dioProvider; +import '../models/album.dart'; +import '../models/artist.dart'; +import '../models/history_event.dart'; +// Aliased: Flutter's Material exports a `Page` class (Navigator 2.0 +// route descriptor) that conflicts with our wire-format Page. +import '../models/page.dart' as wire; +import '../models/quarantine_mine.dart'; +import '../models/track.dart'; +import '../player/player_provider.dart'; +import '../theme/theme_extension.dart'; +import 'widgets/album_card.dart'; +import 'widgets/artist_card.dart'; +import 'widgets/track_row.dart'; + +// Providers scoped to this screen. Each tab gets its own first page. +// Infinite scroll is a future enhancement; for v1 phone testing the +// first 50 rows per tab is enough. + +final _libraryListsApiProvider = FutureProvider((ref) async { + return LibraryListsApi(await ref.watch(dioProvider.future)); +}); + +final _likesApiProvider = FutureProvider((ref) async { + return LikesApi(await ref.watch(dioProvider.future)); +}); + +final _meApiProvider = FutureProvider((ref) async { + return MeApi(await ref.watch(dioProvider.future)); +}); + +final _libraryArtistsProvider = FutureProvider>((ref) async { + return (await ref.watch(_libraryListsApiProvider.future)).listArtists(); +}); + +final _libraryAlbumsProvider = FutureProvider>((ref) async { + return (await ref.watch(_libraryListsApiProvider.future)).listAlbums(); +}); + +final _historyProvider = FutureProvider((ref) async { + return (await ref.watch(_meApiProvider.future)).history(); +}); + +final _likedTracksProvider = FutureProvider>((ref) async { + return (await ref.watch(_likesApiProvider.future)).listTracks(); +}); + +final _likedAlbumsProvider = FutureProvider>((ref) async { + return (await ref.watch(_likesApiProvider.future)).listAlbums(); +}); + +final _likedArtistsProvider = FutureProvider>((ref) async { + return (await ref.watch(_likesApiProvider.future)).listArtists(); +}); + +final _quarantineProvider = FutureProvider>((ref) async { + return (await ref.watch(_meApiProvider.future)).quarantineMine(); +}); + +class LibraryScreen extends ConsumerStatefulWidget { + const LibraryScreen({super.key}); + + @override + ConsumerState createState() => _LibraryScreenState(); +} + +class _LibraryScreenState extends ConsumerState + with TickerProviderStateMixin { + late final TabController _ctrl = TabController(length: 5, vsync: this); + + @override + void dispose() { + _ctrl.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final fs = Theme.of(context).extension()!; + return Scaffold( + backgroundColor: fs.obsidian, + appBar: AppBar( + backgroundColor: fs.obsidian, + elevation: 0, + title: Text('Library', style: TextStyle(color: fs.parchment)), + bottom: TabBar( + controller: _ctrl, + isScrollable: true, + tabAlignment: TabAlignment.start, + labelColor: fs.parchment, + unselectedLabelColor: fs.ash, + indicatorColor: fs.accent, + tabs: const [ + Tab(text: 'Artists'), + Tab(text: 'Albums'), + Tab(text: 'History'), + Tab(text: 'Liked'), + Tab(text: 'Hidden'), + ], + ), + ), + body: TabBarView( + controller: _ctrl, + children: const [ + _ArtistsTab(), + _AlbumsTab(), + _HistoryTab(), + _LikedTab(), + _HiddenTab(), + ], + ), + ); + } +} + +class _ArtistsTab extends ConsumerWidget { + const _ArtistsTab(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final fs = Theme.of(context).extension()!; + return ref.watch(_libraryArtistsProvider).when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => Center(child: Text('$e', style: TextStyle(color: fs.error))), + data: (page) => page.items.isEmpty + ? Center(child: Text('No artists.', style: TextStyle(color: fs.ash))) + : RefreshIndicator( + onRefresh: () async => ref.refresh(_libraryArtistsProvider.future), + child: GridView.builder( + padding: const EdgeInsets.all(8), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + mainAxisSpacing: 8, + crossAxisSpacing: 8, + childAspectRatio: 0.78, + ), + itemCount: page.items.length, + itemBuilder: (ctx, i) => ArtistCard( + artist: page.items[i], + onTap: () => ctx.push('/artists/${page.items[i].id}'), + ), + ), + ), + ); + } +} + +class _AlbumsTab extends ConsumerWidget { + const _AlbumsTab(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final fs = Theme.of(context).extension()!; + return ref.watch(_libraryAlbumsProvider).when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => Center(child: Text('$e', style: TextStyle(color: fs.error))), + data: (page) => page.items.isEmpty + ? Center(child: Text('No albums.', style: TextStyle(color: fs.ash))) + : RefreshIndicator( + onRefresh: () async => ref.refresh(_libraryAlbumsProvider.future), + child: GridView.builder( + padding: const EdgeInsets.all(8), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + mainAxisSpacing: 8, + crossAxisSpacing: 8, + childAspectRatio: 0.78, + ), + itemCount: page.items.length, + itemBuilder: (ctx, i) => AlbumCard( + album: page.items[i], + onTap: () => ctx.push('/albums/${page.items[i].id}'), + ), + ), + ), + ); + } +} + +class _HistoryTab extends ConsumerWidget { + const _HistoryTab(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final fs = Theme.of(context).extension()!; + return ref.watch(_historyProvider).when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => Center(child: Text('$e', style: TextStyle(color: fs.error))), + data: (page) => page.events.isEmpty + ? Center(child: Text('No listening history yet.', style: TextStyle(color: fs.ash))) + : RefreshIndicator( + onRefresh: () async => ref.refresh(_historyProvider.future), + child: ListView.separated( + itemCount: page.events.length, + separatorBuilder: (_, __) => Divider(height: 1, color: fs.iron), + itemBuilder: (ctx, i) { + final event = page.events[i]; + return TrackRow( + track: event.track, + onTap: () => ref + .read(playerActionsProvider) + .playTracks([event.track]), + trailing: Text( + _relativeTime(event.playedAt), + style: TextStyle(color: fs.ash, fontSize: 12), + ), + ); + }, + ), + ), + ); + } +} + +class _LikedTab extends ConsumerWidget { + const _LikedTab(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final fs = Theme.of(context).extension()!; + final tracksA = ref.watch(_likedTracksProvider); + final albumsA = ref.watch(_likedAlbumsProvider); + final artistsA = ref.watch(_likedArtistsProvider); + + if (tracksA.isLoading || albumsA.isLoading || artistsA.isLoading) { + return const Center(child: CircularProgressIndicator()); + } + final t = tracksA.value; + final al = albumsA.value; + final ar = artistsA.value; + if (t == null || al == null || ar == null) { + return Center(child: Text("Couldn't load liked items.", style: TextStyle(color: fs.error))); + } + if (t.items.isEmpty && al.items.isEmpty && ar.items.isEmpty) { + return Center(child: Text('Nothing liked yet.', style: TextStyle(color: fs.ash))); + } + return RefreshIndicator( + onRefresh: () async { + await Future.wait([ + ref.refresh(_likedTracksProvider.future), + ref.refresh(_likedAlbumsProvider.future), + ref.refresh(_likedArtistsProvider.future), + ]); + }, + child: ListView(children: [ + if (ar.items.isNotEmpty) ...[ + _SectionHeader(label: 'Artists', count: ar.total), + SizedBox( + height: 168, + child: ListView.builder( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 8), + itemCount: ar.items.length, + itemBuilder: (ctx, i) => ArtistCard( + artist: ar.items[i], + onTap: () => ctx.push('/artists/${ar.items[i].id}'), + ), + ), + ), + ], + if (al.items.isNotEmpty) ...[ + _SectionHeader(label: 'Albums', count: al.total), + SizedBox( + height: 200, + child: ListView.builder( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 8), + itemCount: al.items.length, + itemBuilder: (ctx, i) => AlbumCard( + album: al.items[i], + onTap: () => ctx.push('/albums/${al.items[i].id}'), + ), + ), + ), + ], + if (t.items.isNotEmpty) ...[ + _SectionHeader(label: 'Tracks', count: t.total), + ...t.items.asMap().entries.map((e) { + return TrackRow( + track: e.value, + onTap: () => ref + .read(playerActionsProvider) + .playTracks(t.items, initialIndex: e.key), + ); + }), + ], + const SizedBox(height: 96), + ]), + ); + } +} + +class _HiddenTab extends ConsumerWidget { + const _HiddenTab(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final fs = Theme.of(context).extension()!; + return ref.watch(_quarantineProvider).when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => Center(child: Text('$e', style: TextStyle(color: fs.error))), + data: (rows) => rows.isEmpty + ? Center( + child: Text( + 'No hidden tracks.\nUse a track\'s menu to flag bad rips or wrong tags.', + textAlign: TextAlign.center, + style: TextStyle(color: fs.ash), + ), + ) + : RefreshIndicator( + onRefresh: () async => ref.refresh(_quarantineProvider.future), + child: ListView.separated( + itemCount: rows.length, + separatorBuilder: (_, __) => Divider(height: 1, color: fs.iron), + itemBuilder: (ctx, i) => _QuarantineTile(row: rows[i]), + ), + ), + ); + } +} + +class _QuarantineTile extends StatelessWidget { + const _QuarantineTile({required this.row}); + final QuarantineMineRow row; + + @override + Widget build(BuildContext context) { + final fs = Theme.of(context).extension()!; + final reasonLabel = switch (row.reason) { + 'bad_rip' => 'Bad rip', + 'wrong_file' => 'Wrong file', + 'wrong_tags' => 'Wrong tags', + 'duplicate' => 'Duplicate', + _ => 'Other', + }; + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Expanded( + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text( + row.trackTitle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: fs.parchment, fontSize: 14), + ), + Text( + '${row.artistName} ยท ${row.albumTitle}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: fs.ash, fontSize: 12), + ), + Padding( + padding: const EdgeInsets.only(top: 4), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: fs.iron, + borderRadius: BorderRadius.circular(4), + ), + child: Text(reasonLabel, style: TextStyle(color: fs.ash, fontSize: 11)), + ), + ), + ]), + ), + ]), + ); + } +} + +class _SectionHeader extends StatelessWidget { + const _SectionHeader({required this.label, required this.count}); + final String label; + final int count; + + @override + Widget build(BuildContext context) { + final fs = Theme.of(context).extension()!; + return Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), + child: Row(children: [ + Text(label, + style: TextStyle( + color: fs.parchment, fontSize: 18, fontWeight: FontWeight.w500)), + const SizedBox(width: 8), + Text('$count', style: TextStyle(color: fs.ash, fontSize: 13)), + ]), + ); + } +} + +/// Lightweight relative-time formatter: <1h "23m ago" / <24h "3h ago" +/// / <7d "Tue 14:32" / older "May 1" / older diff-year "May 1, 2025". +/// Mirrors web/src/lib/components/HistoryRow.svelte's relativeTime. +String _relativeTime(String iso) { + final t = DateTime.tryParse(iso); + if (t == null) return ''; + final now = DateTime.now(); + final diff = now.difference(t); + if (diff.inMinutes < 60) { + final m = diff.inMinutes < 1 ? 1 : diff.inMinutes; + return '${m}m ago'; + } + if (diff.inHours < 24) return '${diff.inHours}h ago'; + if (diff.inDays < 7) { + const days = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; + final hh = t.hour.toString().padLeft(2, '0'); + final mm = t.minute.toString().padLeft(2, '0'); + return '${days[t.weekday - 1]} $hh:$mm'; + } + const months = [ + 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', + 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec', + ]; + if (t.year == now.year) return '${months[t.month - 1]} ${t.day}'; + return '${months[t.month - 1]} ${t.day}, ${t.year}'; +} diff --git a/flutter_client/lib/models/history_event.dart b/flutter_client/lib/models/history_event.dart new file mode 100644 index 00000000..5482eb00 --- /dev/null +++ b/flutter_client/lib/models/history_event.dart @@ -0,0 +1,44 @@ +import 'track.dart'; + +/// Mirrors web/src/lib/api/history.ts HistoryEvent. Server emits +/// `{events, has_more}` rather than the standard `Page` envelope โ€” +/// history is timestamp-keyed so total isn't meaningful, only "more +/// pages exist below." +class HistoryEvent { + const HistoryEvent({ + required this.id, + required this.playedAt, + required this.track, + }); + + final String id; + /// RFC3339 timestamp; UI formats relative ("3h ago" / "Tue 14:32" / + /// "May 1, 2025") via formatRelative helper. + final String playedAt; + final TrackRef track; + + factory HistoryEvent.fromJson(Map j) => HistoryEvent( + id: j['id'] as String? ?? '', + playedAt: j['played_at'] as String? ?? '', + track: TrackRef.fromJson( + (j['track'] as Map?)?.cast() ?? const {}, + ), + ); +} + +class HistoryPage { + const HistoryPage({required this.events, required this.hasMore}); + + final List events; + final bool hasMore; + + factory HistoryPage.fromJson(Map j) { + final raw = (j['events'] as List?) ?? const []; + return HistoryPage( + events: raw + .map((e) => HistoryEvent.fromJson((e as Map).cast())) + .toList(growable: false), + hasMore: j['has_more'] as bool? ?? false, + ); + } +} diff --git a/flutter_client/lib/models/page.dart b/flutter_client/lib/models/page.dart index eb38a464..29df33db 100644 --- a/flutter_client/lib/models/page.dart +++ b/flutter_client/lib/models/page.dart @@ -1,6 +1,8 @@ -// Mirrors the server's Page envelope used by paged endpoints +// Mirrors the server's Page[T] envelope used by paged endpoints // (/api/search, /api/library/artists, /api/library/albums, -// /api/library/history, /api/me/likes/*). +// /api/likes/*). Class is named `Paged` rather than `Page` +// to avoid ambiguous imports with Flutter Material's Navigator-2.0 +// `Page` class. // // Wire shape from internal/api/types.go Page[T]: // { items: T[], total: int, limit: int, offset: int } @@ -8,8 +10,8 @@ // Dart generics can't auto-infer T from JSON, so callers pass an // itemFromJson function alongside the raw map. Parse logic stays in // each entity's own fromJson; this class only handles the envelope. -class Page { - const Page({ +class Paged { + const Paged({ required this.items, required this.total, required this.limit, @@ -21,12 +23,12 @@ class Page { final int limit; final int offset; - factory Page.fromJson( + factory Paged.fromJson( Map j, T Function(Map) itemFromJson, ) { final raw = (j['items'] as List?) ?? const []; - return Page( + return Paged( items: raw .map((e) => itemFromJson((e as Map).cast())) .toList(growable: false), diff --git a/flutter_client/lib/models/quarantine_mine.dart b/flutter_client/lib/models/quarantine_mine.dart new file mode 100644 index 00000000..d9358d6b --- /dev/null +++ b/flutter_client/lib/models/quarantine_mine.dart @@ -0,0 +1,46 @@ +/// Mirrors web/src/lib/api/types.ts LidarrQuarantineMineRow. One row +/// per quarantined track owned by the caller. Server returns a flat +/// list (no Page envelope) at /api/quarantine/mine. +class QuarantineMineRow { + const QuarantineMineRow({ + required this.trackId, + required this.reason, + this.notes, + required this.createdAt, + required this.trackTitle, + required this.trackDurationMs, + required this.albumId, + required this.albumTitle, + this.albumCoverArtPath, + required this.artistId, + required this.artistName, + }); + + final String trackId; + /// One of: bad_rip / wrong_file / wrong_tags / duplicate / other. + final String reason; + final String? notes; + final String createdAt; + final String trackTitle; + final int trackDurationMs; + final String albumId; + final String albumTitle; + final String? albumCoverArtPath; + final String artistId; + final String artistName; + + factory QuarantineMineRow.fromJson(Map j) => + QuarantineMineRow( + trackId: j['track_id'] as String? ?? '', + reason: j['reason'] as String? ?? 'other', + notes: j['notes'] as String?, + createdAt: j['created_at'] as String? ?? '', + trackTitle: j['track_title'] as String? ?? '', + trackDurationMs: (j['track_duration_ms'] as num?)?.toInt() ?? 0, + albumId: j['album_id'] as String? ?? '', + albumTitle: j['album_title'] as String? ?? '', + albumCoverArtPath: j['album_cover_art_path'] as String?, + artistId: j['artist_id'] as String? ?? '', + artistName: j['artist_name'] as String? ?? '', + ); +} diff --git a/flutter_client/lib/models/search_response.dart b/flutter_client/lib/models/search_response.dart index cf61e5a0..2d1e505b 100644 --- a/flutter_client/lib/models/search_response.dart +++ b/flutter_client/lib/models/search_response.dart @@ -14,20 +14,20 @@ class SearchResponse { required this.tracks, }); - final Page artists; - final Page albums; - final Page tracks; + final Paged artists; + final Paged albums; + final Paged tracks; factory SearchResponse.fromJson(Map j) => SearchResponse( - artists: Page.fromJson( + artists: Paged.fromJson( (j['artists'] as Map?)?.cast() ?? const {}, ArtistRef.fromJson, ), - albums: Page.fromJson( + albums: Paged.fromJson( (j['albums'] as Map?)?.cast() ?? const {}, AlbumRef.fromJson, ), - tracks: Page.fromJson( + tracks: Paged.fromJson( (j['tracks'] as Map?)?.cast() ?? const {}, TrackRef.fromJson, ), diff --git a/flutter_client/lib/shared/routing.dart b/flutter_client/lib/shared/routing.dart index 75a8423a..7631f309 100644 --- a/flutter_client/lib/shared/routing.dart +++ b/flutter_client/lib/shared/routing.dart @@ -8,6 +8,7 @@ import '../auth/server_url_screen.dart'; import '../library/album_detail_screen.dart'; import '../library/artist_detail_screen.dart'; import '../library/home_screen.dart'; +import '../library/library_screen.dart'; import '../player/now_playing_screen.dart'; import '../player/player_bar.dart'; import '../playlists/playlist_detail_screen.dart'; @@ -55,6 +56,7 @@ GoRouter buildRouter(Ref ref) { ), GoRoute(path: '/now-playing', builder: (_, __) => const NowPlayingScreen()), GoRoute(path: '/search', builder: (_, __) => const SearchScreen()), + GoRoute(path: '/library', builder: (_, __) => const LibraryScreen()), GoRoute(path: '/playlists', builder: (_, __) => const PlaylistsListScreen()), GoRoute( path: '/playlists/:id', diff --git a/flutter_client/test/likes/like_button_test.dart b/flutter_client/test/likes/like_button_test.dart index e499626a..6a0b0cb6 100644 --- a/flutter_client/test/likes/like_button_test.dart +++ b/flutter_client/test/likes/like_button_test.dart @@ -5,6 +5,10 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:minstrel/api/endpoints/likes.dart'; import 'package:minstrel/likes/like_button.dart'; import 'package:minstrel/likes/likes_provider.dart'; +import 'package:minstrel/models/album.dart'; +import 'package:minstrel/models/artist.dart'; +import 'package:minstrel/models/page.dart'; +import 'package:minstrel/models/track.dart'; import 'package:minstrel/theme/theme_data.dart'; class _ThrowingLikesApi implements LikesApi { @@ -24,6 +28,19 @@ class _ThrowingLikesApi implements LikesApi { Future<({Set artists, Set albums, Set tracks})> ids() async => (artists: {}, albums: {}, tracks: {}); + + // The list-* methods aren't exercised by this test; return empty pages. + @override + Future> listTracks({int limit = 50, int offset = 0}) async => + const Paged(items: [], total: 0, limit: 50, offset: 0); + + @override + Future> listAlbums({int limit = 50, int offset = 0}) async => + const Paged(items: [], total: 0, limit: 50, offset: 0); + + @override + Future> listArtists({int limit = 50, int offset = 0}) async => + const Paged(items: [], total: 0, limit: 50, offset: 0); } void main() {