feat(flutter): home screen on per-item rendering (Slice C)

End-to-end pilot of the per-item architecture. Home now reads from
the new homeIndexProvider (drift-first over CachedHomeIndex with
/api/home/index discovery + SWR), then each tile is a small
ConsumerWidget watching its own albumTileProvider/artistTileProvider/
trackTileProvider. Tiles render a matched-dimension skeleton while
their entity is still hydrating, and swap in the real card once
drift emits the populated row.

Track hydration is wired up — /api/tracks/:id already existed so
the queue's case 'track' just calls api.getTrack(id) and persists.

The visible behavior:
* Cold visit: small /api/home/index round-trip (IDs only, ~10×
  smaller than /api/home), then sections appear shaped with
  skeleton tiles; each tile materializes as the hydration queue
  drains. No more "30s blank → everything pops in at once."
* Warm visit: drift index emits instantly, drift entity rows emit
  instantly, no network. Page paints fully in the first frame.
* Mid-state: scrolling through a partially-hydrated section sees
  real cards next to skeleton cards. Layout doesn't shift because
  skeletons match real-card dimensions exactly.

CachedHomeSnapshot (and the legacy homeProvider) stay in place but
unconsumed by Flutter — left in for now so revert is cheap if the
new path needs reworking. Cleanup follow-up in a later slice.

Old /api/home endpoint untouched, so the web client keeps working
unchanged.
This commit is contained in:
2026-05-13 21:05:43 -04:00
parent 0119eacf14
commit 03c13d21c6
7 changed files with 345 additions and 156 deletions
@@ -3,6 +3,7 @@ import 'package:dio/dio.dart';
import '../../models/album.dart';
import '../../models/artist.dart';
import '../../models/home_data.dart';
import '../../models/home_index.dart';
import '../../models/track.dart';
/// LibraryApi wraps the server's native /api/* library surface.
@@ -27,6 +28,24 @@ class LibraryApi {
return HomeData.fromJson(r.data ?? const {});
}
/// GET /api/home/index — per-item rendering variant. Returns just IDs
/// per section; client hydrates each tile via the per-entity
/// endpoints. ~10× smaller than /api/home on populated libraries so
/// the cold-visit round-trip is correspondingly short.
Future<HomeIndex> getHomeIndex() async {
final r = await _dio.get<Map<String, dynamic>>('/api/home/index');
return HomeIndex.fromJson(r.data ?? const {});
}
/// GET /api/tracks/{id}. Returns the canonical TrackRef. Used by
/// the HydrationQueue to populate cached_tracks on a per-tile miss
/// — the existing endpoint already joins album + artist so the
/// response carries everything TrackRef needs.
Future<TrackRef> getTrack(String id) async {
final r = await _dio.get<Map<String, dynamic>>('/api/tracks/$id');
return TrackRef.fromJson(r.data ?? const {});
}
/// GET /api/artists/{id}. Server returns ArtistDetail which embeds
/// ArtistRef inline; ArtistRef.fromJson already reads only the fields
/// it cares about, so passing the whole body is correct.
+8 -4
View File
@@ -78,10 +78,7 @@ class HydrationQueue {
case 'artist':
await _hydrateArtist(req.entityId);
case 'track':
// TODO(per-item slice B): GET /api/tracks/:id doesn't exist
// yet; tracks remain bulk-loaded until that endpoint lands.
// Tile providers for tracks read drift but never enqueue.
break;
await _hydrateTrack(req.entityId);
}
} catch (_) {
// Swallow — tile stays in skeleton. A retry-on-visit pass can
@@ -114,6 +111,13 @@ class HydrationQueue {
final db = _ref.read(appDbProvider);
await db.into(db.cachedArtists).insertOnConflictUpdate(fresh.toDrift());
}
Future<void> _hydrateTrack(String id) async {
final api = await _ref.read(libraryApiProvider.future);
final fresh = await api.getTrack(id);
final db = _ref.read(appDbProvider);
await db.into(db.cachedTracks).insertOnConflictUpdate(fresh.toDrift());
}
}
class _HydrationRequest {
+9 -5
View File
@@ -87,11 +87,8 @@ final artistTileProvider =
}
});
/// Watches the cached_tracks row for [id]. No hydration today — see
/// hydration_queue.dart's `case 'track':` note. The tile renders a
/// skeleton while drift is empty; once sync populates the row (or a
/// future GET /api/tracks/:id endpoint enables real hydration), the
/// stream emits the populated TrackRef.
/// Watches the cached_tracks row for [id]. Triggers background
/// hydration on miss; yields the row once it lands.
final trackTileProvider =
StreamProvider.family<TrackRef?, String>((ref, id) async* {
if (id.isEmpty) {
@@ -106,8 +103,15 @@ final trackTileProvider =
leftOuterJoin(db.cachedAlbums,
db.cachedAlbums.id.equalsExp(db.cachedTracks.albumId)),
]);
var enqueued = false;
await for (final rows in query.watch()) {
if (rows.isEmpty) {
if (!enqueued) {
enqueued = true;
ref
.read(hydrationQueueProvider)
.enqueue(entityType: 'track', entityId: id);
}
yield null;
continue;
}
+161 -102
View File
@@ -4,8 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../api/errors.dart';
import '../models/album.dart';
import '../models/artist.dart';
import '../cache/tile_providers.dart';
import '../models/playlist.dart';
import '../models/system_playlists_status.dart';
import '../models/track.dart';
@@ -14,6 +13,7 @@ import '../playlists/widgets/playlist_card.dart';
import '../playlists/widgets/playlist_placeholder_card.dart';
import '../shared/widgets/connection_error_banner.dart';
import '../shared/widgets/main_app_bar_actions.dart';
import '../shared/widgets/skeletons.dart';
import '../theme/theme_extension.dart';
import 'library_providers.dart';
import 'widgets/album_card.dart';
@@ -21,13 +21,18 @@ import 'widgets/artist_card.dart';
import 'widgets/compact_track_card.dart';
import 'widgets/horizontal_scroll_row.dart';
/// Home screen. Per-item rendering: a tiny /api/home/index discovery
/// fetch returns just section→IDs, then each tile hydrates itself
/// against the per-entity drift tables (sync-populated) with REST
/// fallback via the HydrationQueue. Cold-visit dead air shrinks to a
/// single small round-trip; tiles materialize as their data lands.
class HomeScreen extends ConsumerWidget {
const HomeScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
final home = ref.watch(homeProvider);
final index = ref.watch(homeIndexProvider);
final allPlaylists = ref.watch(playlistsListProvider('all'));
final status = ref.watch(systemPlaylistsStatusProvider);
return Scaffold(
@@ -39,40 +44,33 @@ class HomeScreen extends ConsumerWidget {
actions: const [MainAppBarActions(currentRoute: '/home')],
),
body: SafeArea(
child: home.when(
child: index.when(
error: (e, _) {
final code = e is DioException ? ApiError.fromDio(e).code : 'unknown';
if (code == 'connection_refused') {
return ConnectionErrorBanner(
onRetry: () => ref.refresh(homeProvider),
onRetry: () => ref.refresh(homeIndexProvider),
);
}
return Center(child: Text('$e', style: TextStyle(color: fs.error)));
},
loading: () => _HomeSkeleton(fs: fs),
data: (h) => RefreshIndicator(
onRefresh: () async => ref.refresh(homeProvider.future),
onRefresh: () async => ref.refresh(homeIndexProvider.future),
child: ListView(
// ClampingScrollPhysics: no bounce overscroll past the
// content. Combined with the bottom padding below, this
// makes "scroll to end" land the last item just above the
// player bar — no empty void.
physics: const ClampingScrollPhysics(),
children: [
_PlaylistsSection(
playlists: allPlaylists.value?.owned ?? const [],
status: status.value ?? SystemPlaylistsStatus.empty(),
),
_RecentlyAddedSection(albums: h.recentlyAddedAlbums),
_RecentlyAddedSection(ids: h.recentlyAddedAlbums),
_RediscoverSection(
albums: h.rediscoverAlbums,
artists: h.rediscoverArtists,
albumIds: h.rediscoverAlbums,
artistIds: h.rediscoverArtists,
),
_MostPlayedSection(tracks: h.mostPlayedTracks),
_LastPlayedSection(artists: h.lastPlayedArtists),
// Bottom padding ≈ player bar height (top row 80 + seek
// 30 + padding 16 ≈ ~140) so the last section's bottom
// edge lands right above the player when scrolled.
_MostPlayedSection(ids: h.mostPlayedTracks),
_LastPlayedSection(ids: h.lastPlayedArtists),
const SizedBox(height: 140),
],
),
@@ -83,6 +81,122 @@ class HomeScreen extends ConsumerWidget {
}
}
// ─── Per-tile widgets ────────────────────────────────────────────────
/// Album tile: skeleton until albumTileProvider yields a populated row.
class _AlbumTile extends ConsumerWidget {
const _AlbumTile({required this.id});
final String id;
@override
Widget build(BuildContext context, WidgetRef ref) {
final asyncAlbum = ref.watch(albumTileProvider(id));
final album = asyncAlbum.asData?.value;
if (album == null) return const SkeletonAlbumTile();
return AlbumCard(
album: album,
onTap: () => context.push('/albums/${album.id}', extra: album),
);
}
}
/// Artist tile: skeleton until artistTileProvider yields a populated row.
class _ArtistTile extends ConsumerWidget {
const _ArtistTile({required this.id});
final String id;
@override
Widget build(BuildContext context, WidgetRef ref) {
final asyncArtist = ref.watch(artistTileProvider(id));
final artist = asyncArtist.asData?.value;
if (artist == null) return const SkeletonArtistTile();
return ArtistCard(
artist: artist,
onTap: () => context.push('/artists/${artist.id}', extra: artist),
);
}
}
/// Track tile (used by Most-Played). On tap, gathers the currently-
/// hydrated TrackRefs for the whole section so playback flows like
/// it did with the bulk-loaded list. Tracks that haven't hydrated yet
/// are skipped from the play list — typically transient on a cold
/// visit since hydration runs in parallel and finishes quickly.
class _TrackTile extends ConsumerWidget {
const _TrackTile({required this.id, required this.sectionIds});
final String id;
final List<String> sectionIds;
@override
Widget build(BuildContext context, WidgetRef ref) {
final asyncTrack = ref.watch(trackTileProvider(id));
final track = asyncTrack.asData?.value;
if (track == null) {
// Compact-track placeholder: same 56dp footprint as the real card
// so the row's intrinsic width doesn't change as tiles hydrate.
return const _CompactTrackSkeleton();
}
return CompactTrackCard(
track: track,
sectionTracks: _resolveSectionTracks(ref, sectionIds),
index: sectionIds.indexOf(id).clamp(0, sectionIds.length - 1),
);
}
/// Snapshots the section's current track states. Used at tile
/// construction time — CompactTrackCard's onTap uses it as the play
/// queue. Tracks still hydrating are dropped; once they land, a
/// rebuild re-runs this lookup so the queue grows naturally.
static List<TrackRef> _resolveSectionTracks(WidgetRef ref, List<String> ids) {
final out = <TrackRef>[];
for (final i in ids) {
final v = ref.read(trackTileProvider(i)).asData?.value;
if (v != null) out.add(v);
}
return out;
}
}
/// Compact-track placeholder. 56dp cover + two text lines, matched to
/// CompactTrackCard so swapping in the real card doesn't shift the
/// row's height.
class _CompactTrackSkeleton extends StatelessWidget {
const _CompactTrackSkeleton();
@override
Widget build(BuildContext context) {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
return Container(
width: 240,
margin: const EdgeInsets.symmetric(horizontal: 4),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
child: Row(children: [
Container(
width: 56,
height: 56,
decoration: BoxDecoration(
color: fs.slate,
borderRadius: BorderRadius.circular(6),
),
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(width: 120, height: 12, color: fs.slate),
const SizedBox(height: 6),
Container(width: 80, height: 10, color: fs.slate),
],
),
),
]),
);
}
}
// ─── Sections ────────────────────────────────────────────────────────
class _PlaylistsSection extends StatelessWidget {
const _PlaylistsSection({required this.playlists, required this.status});
final List<Playlist> playlists;
@@ -129,22 +243,16 @@ List<_PlaylistRowItem> _buildPlaylistsRow(
return null;
}
// Slot 1: For-You.
final forYou = findFirst((p) => p.systemVariant == 'for_you');
out.add(forYou != null
? _RealPlaylist(forYou)
: _PlaceholderPlaylist('For You', _variantFor('for-you', status)));
// Slot 2: Discover. Server emits this with system_variant='discover'
// when the recommendation engine has eligible tracks; before then,
// show a placeholder in the same "building/pending" state machine
// as For-You.
final discover = findFirst((p) => p.systemVariant == 'discover');
out.add(discover != null
? _RealPlaylist(discover)
: _PlaceholderPlaylist('Discover', _variantFor('discover', status)));
// Slots 3-5: Songs-like (real first, padded to 3).
final songsLike = ownedAll
.where((p) => p.systemVariant == 'songs_like_artist')
.take(3)
@@ -155,7 +263,6 @@ List<_PlaylistRowItem> _buildPlaylistsRow(
: _PlaceholderPlaylist('Songs like…', _variantFor('songs-like', status)));
}
// User-created trail (server returns most-recently-updated first).
for (final p in ownedAll.where((p) => p.systemVariant == null)) {
out.add(_RealPlaylist(p));
}
@@ -171,44 +278,38 @@ String _variantFor(String slot, SystemPlaylistsStatus s) {
}
class _RecentlyAddedSection extends StatelessWidget {
const _RecentlyAddedSection({required this.albums});
final List<AlbumRef> albums;
const _RecentlyAddedSection({required this.ids});
final List<String> ids;
@override
Widget build(BuildContext context) {
if (albums.isEmpty) {
if (ids.isEmpty) {
return const _EmptySection(
title: 'Recently added',
message: "Nothing added yet. Scan a folder via the server's config.",
);
}
final controller = ScrollController();
final rows = _chunk(albums, 25);
final rows = _chunk(ids, 25);
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
for (var i = 0; i < rows.length; i++)
HorizontalScrollRow(
title: i == 0 ? 'Recently added' : '',
controller: controller,
children: rows[i]
.map((a) => AlbumCard(
album: a,
onTap: () =>
context.push('/albums/${a.id}', extra: a),
))
.toList(),
children: rows[i].map((id) => _AlbumTile(id: id)).toList(),
),
]);
}
}
class _RediscoverSection extends StatelessWidget {
const _RediscoverSection({required this.albums, required this.artists});
final List<AlbumRef> albums;
final List<ArtistRef> artists;
const _RediscoverSection({required this.albumIds, required this.artistIds});
final List<String> albumIds;
final List<String> artistIds;
@override
Widget build(BuildContext context) {
if (albums.isEmpty && artists.isEmpty) {
if (albumIds.isEmpty && artistIds.isEmpty) {
return const _EmptySection(
title: 'Rediscover',
message:
@@ -216,47 +317,35 @@ class _RediscoverSection extends StatelessWidget {
);
}
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
if (albums.isNotEmpty)
if (albumIds.isNotEmpty)
HorizontalScrollRow(
title: 'Rediscover',
children: albums
.map((a) => AlbumCard(
album: a,
onTap: () =>
context.push('/albums/${a.id}', extra: a),
))
.toList(),
children: albumIds.map((id) => _AlbumTile(id: id)).toList(),
),
if (artists.isNotEmpty)
if (artistIds.isNotEmpty)
HorizontalScrollRow(
title: albums.isEmpty ? 'Rediscover' : '',
title: albumIds.isEmpty ? 'Rediscover' : '',
height: 168,
children: artists
.map((ar) => ArtistCard(
artist: ar,
onTap: () =>
context.push('/artists/${ar.id}', extra: ar),
))
.toList(),
children: artistIds.map((id) => _ArtistTile(id: id)).toList(),
),
]);
}
}
class _MostPlayedSection extends StatelessWidget {
const _MostPlayedSection({required this.tracks});
final List<TrackRef> tracks;
const _MostPlayedSection({required this.ids});
final List<String> ids;
@override
Widget build(BuildContext context) {
if (tracks.isEmpty) {
if (ids.isEmpty) {
return const _EmptySection(
title: 'Most played',
message: 'No plays to draw from. Listen to something.',
);
}
final controller = ScrollController();
final rows = _chunk(tracks, 25);
final rows = _chunk(ids, 25);
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
for (var i = 0; i < rows.length; i++)
HorizontalScrollRow(
@@ -264,12 +353,7 @@ class _MostPlayedSection extends StatelessWidget {
height: 64,
controller: controller,
children: [
for (var j = 0; j < rows[i].length; j++)
CompactTrackCard(
track: rows[i][j],
sectionTracks: tracks,
index: i * 25 + j,
),
for (final id in rows[i]) _TrackTile(id: id, sectionIds: ids),
],
),
]);
@@ -277,12 +361,12 @@ class _MostPlayedSection extends StatelessWidget {
}
class _LastPlayedSection extends StatelessWidget {
const _LastPlayedSection({required this.artists});
final List<ArtistRef> artists;
const _LastPlayedSection({required this.ids});
final List<String> ids;
@override
Widget build(BuildContext context) {
if (artists.isEmpty) {
if (ids.isEmpty) {
return const _EmptySection(
title: 'Last played',
message: 'No recent plays.',
@@ -291,13 +375,7 @@ class _LastPlayedSection extends StatelessWidget {
return HorizontalScrollRow(
title: 'Last played',
height: 168,
children: artists
.map((ar) => ArtistCard(
artist: ar,
onTap: () =>
context.push('/artists/${ar.id}', extra: ar),
))
.toList(),
children: ids.map((id) => _ArtistTile(id: id)).toList(),
);
}
}
@@ -328,11 +406,11 @@ class _EmptySection extends StatelessWidget {
}
}
/// Cold-start skeleton. Renders the same shape as the real home (a few
/// section titles + card-sized placeholders) so the page feels alive
/// while /api/home is in flight, instead of a 30-second blank spinner.
/// Each section drops in independently as data arrives via the
/// individual providers.
/// Cold-start skeleton — shown only between mount and the first
/// homeIndexProvider emission (typically a single drift query + small
/// REST round-trip). Each section then renders its own per-tile
/// skeletons internally, so this widget's role is just the very first
/// pre-discovery frame.
class _HomeSkeleton extends StatelessWidget {
const _HomeSkeleton({required this.fs});
final FabledSwordTheme fs;
@@ -385,26 +463,7 @@ class _SkeletonSection extends StatelessWidget {
padding: const EdgeInsets.symmetric(horizontal: 16),
physics: const NeverScrollableScrollPhysics(),
itemCount: 6,
itemBuilder: (_, __) => Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: coverSize,
height: coverSize,
decoration: BoxDecoration(
color: fs.slate,
borderRadius: BorderRadius.circular(6),
),
),
const SizedBox(height: 8),
Container(width: coverSize * 0.7, height: 12, color: fs.slate),
const SizedBox(height: 4),
Container(width: coverSize * 0.4, height: 10, color: fs.iron),
],
),
),
itemBuilder: (_, __) => SkeletonAlbumTile(width: cardWidth),
),
),
]);
@@ -17,6 +17,7 @@ import '../cache/db.dart';
import '../models/album.dart';
import '../models/artist.dart';
import '../models/home_data.dart';
import '../models/home_index.dart';
import '../models/track.dart';
/// Shared authenticated dio. This is the ONLY place tokenResolver is wired
@@ -94,6 +95,95 @@ final homeProvider = StreamProvider<HomeData>((ref) {
);
});
/// Drift-first per-item home index. Reads from cached_home_index
/// (populated by /api/home/index discovery) and yields a HomeIndex
/// the screen consumes to lay out section→tile slots. Each tile is
/// rendered by a per-entity tile provider that hydrates itself.
///
/// Section keys mirror the server's response shape so the encode /
/// decode round-trip is straightforward — the table stores
/// (section, position, entityType, entityId), and toResult reassembles
/// the parallel ID lists.
final homeIndexProvider = StreamProvider<HomeIndex>((ref) {
final db = ref.watch(appDbProvider);
final query = (db.select(db.cachedHomeIndex)
..orderBy([
(t) => drift.OrderingTerm.asc(t.section),
(t) => drift.OrderingTerm.asc(t.position),
]))
.watch();
return cacheFirst<CachedHomeIndexData, HomeIndex>(
driftStream: query,
fetchAndPopulate: () async {
final api = await ref.read(libraryApiProvider.future);
final fresh = await api.getHomeIndex();
// Full replace in a transaction so the watch() sees exactly one
// post-fetch emission with the merged state. Section ordering is
// re-asserted on read (orderBy above) so the write order doesn't
// matter — letting us batch flat instead of section-by-section.
await db.transaction(() async {
await db.delete(db.cachedHomeIndex).go();
await db.batch((b) {
void rows(String section, String entityType, List<String> ids) {
for (var i = 0; i < ids.length; i++) {
b.insert(
db.cachedHomeIndex,
CachedHomeIndexCompanion.insert(
section: section,
position: i,
entityType: entityType,
entityId: ids[i],
),
);
}
}
rows('recently_added_albums', 'album', fresh.recentlyAddedAlbums);
rows('rediscover_albums', 'album', fresh.rediscoverAlbums);
rows('rediscover_artists', 'artist', fresh.rediscoverArtists);
rows('most_played_tracks', 'track', fresh.mostPlayedTracks);
rows('last_played_artists', 'artist', fresh.lastPlayedArtists);
});
});
},
toResult: (rows) {
final recentlyAddedAlbums = <String>[];
final rediscoverAlbums = <String>[];
final rediscoverArtists = <String>[];
final mostPlayedTracks = <String>[];
final lastPlayedArtists = <String>[];
for (final r in rows) {
switch (r.section) {
case 'recently_added_albums':
recentlyAddedAlbums.add(r.entityId);
case 'rediscover_albums':
rediscoverAlbums.add(r.entityId);
case 'rediscover_artists':
rediscoverArtists.add(r.entityId);
case 'most_played_tracks':
mostPlayedTracks.add(r.entityId);
case 'last_played_artists':
lastPlayedArtists.add(r.entityId);
}
}
return HomeIndex(
recentlyAddedAlbums: recentlyAddedAlbums,
rediscoverAlbums: rediscoverAlbums,
rediscoverArtists: rediscoverArtists,
mostPlayedTracks: mostPlayedTracks,
lastPlayedArtists: lastPlayedArtists,
);
},
isOnline: () async => (await ref
.read(connectivityProvider.future)
.timeout(const Duration(seconds: 3), onTimeout: () => true)),
// SWR: cached layout shows instantly; fresh /api/home/index lands
// in the background and tiles re-resolve as the table mutates.
alwaysRefresh: true,
tag: 'homeIndex',
);
});
/// Encodes HomeData back to the wire-format JSON shape /api/home
/// emits, so HomeData.fromJson can round-trip through the drift cache.
String _encodeHomeData(HomeData h) => jsonEncode({
+41
View File
@@ -0,0 +1,41 @@
/// Mirrors internal/api/types.go HomeIndexPayload. Five flat slices of
/// entity ID strings — the per-item rendering variant of HomeData.
/// Section name implies entity type; no per-entry type tag is needed.
///
/// Slices are non-null after fromJson so callers can branch on `length`
/// instead of dealing with null sections.
class HomeIndex {
const HomeIndex({
required this.recentlyAddedAlbums,
required this.rediscoverAlbums,
required this.rediscoverArtists,
required this.mostPlayedTracks,
required this.lastPlayedArtists,
});
final List<String> recentlyAddedAlbums;
final List<String> rediscoverAlbums;
final List<String> rediscoverArtists;
final List<String> mostPlayedTracks;
final List<String> lastPlayedArtists;
static const empty = HomeIndex(
recentlyAddedAlbums: [],
rediscoverAlbums: [],
rediscoverArtists: [],
mostPlayedTracks: [],
lastPlayedArtists: [],
);
factory HomeIndex.fromJson(Map<String, dynamic> j) {
List<String> ids(String key) =>
((j[key] as List?) ?? const []).map((e) => e.toString()).toList();
return HomeIndex(
recentlyAddedAlbums: ids('recently_added_albums'),
rediscoverAlbums: ids('rediscover_albums'),
rediscoverArtists: ids('rediscover_artists'),
mostPlayedTracks: ids('most_played_tracks'),
lastPlayedArtists: ids('last_played_artists'),
);
}
}
@@ -5,28 +5,25 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:minstrel/api/endpoints/playlists.dart';
import 'package:minstrel/library/home_screen.dart';
import 'package:minstrel/library/library_providers.dart';
import 'package:minstrel/models/album.dart';
import 'package:minstrel/models/artist.dart';
import 'package:minstrel/models/home_data.dart';
import 'package:minstrel/models/home_index.dart';
import 'package:minstrel/models/playlist.dart';
import 'package:minstrel/models/system_playlists_status.dart';
import 'package:minstrel/playlists/playlists_provider.dart';
import 'package:minstrel/theme/theme_data.dart';
const _emptyHome = HomeData(
recentlyAddedAlbums: [],
rediscoverAlbums: [],
rediscoverArtists: [],
mostPlayedTracks: [],
lastPlayedArtists: [],
);
// All tests pump an empty HomeIndex unless they care about populated
// section IDs — per-tile hydration is intentionally not exercised here
// (that requires drift, and the @Tags(['drift']) tier covers it).
// What this suite verifies is the screen's section-level shape:
// placeholders / empty-state copy / section presence given the index.
const _emptyIndex = HomeIndex.empty;
void main() {
testWidgets('renders 4 placeholder cards in Playlists row when no system playlists exist',
(tester) async {
await tester.pumpWidget(ProviderScope(
overrides: [
homeProvider.overrideWith((ref) => Stream.value(_emptyHome)),
homeIndexProvider.overrideWith((ref) => Stream.value(_emptyIndex)),
playlistsListProvider('all').overrideWith(
(ref) => Stream.value(PlaylistsList.empty()),
),
@@ -37,16 +34,17 @@ void main() {
child: MaterialApp(theme: buildThemeData(), home: const HomeScreen()),
));
await tester.pumpAndSettle();
// For-You placeholder + 3 Songs-like placeholders.
// For-You placeholder + Discover placeholder + 3 Songs-like placeholders.
expect(find.text('For You'), findsOneWidget);
expect(find.text('Discover'), findsOneWidget);
expect(find.text('Songs like…'), findsNWidgets(3));
});
testWidgets('renders empty-state copy for each section when home payload is empty',
testWidgets('renders empty-state copy for each section when the index is empty',
(tester) async {
await tester.pumpWidget(ProviderScope(
overrides: [
homeProvider.overrideWith((ref) => Stream.value(_emptyHome)),
homeIndexProvider.overrideWith((ref) => Stream.value(_emptyIndex)),
playlistsListProvider('all').overrideWith(
(ref) => Stream.value(PlaylistsList.empty()),
),
@@ -87,7 +85,7 @@ void main() {
);
await tester.pumpWidget(ProviderScope(
overrides: [
homeProvider.overrideWith((ref) => Stream.value(_emptyHome)),
homeIndexProvider.overrideWith((ref) => Stream.value(_emptyIndex)),
playlistsListProvider('all').overrideWith(
(ref) => Stream.value(
const PlaylistsList(owned: [forYou], public: [])),
@@ -104,34 +102,8 @@ void main() {
expect(find.text('for you'), findsOneWidget);
});
testWidgets('renders home rollups when payload is non-empty', (tester) async {
const home = HomeData(
recentlyAddedAlbums: [
AlbumRef(id: 'a', title: 'Geogaddi', artistId: 'x', artistName: 'BoC'),
],
rediscoverAlbums: [],
rediscoverArtists: [
ArtistRef(id: 'r', name: 'Aphex Twin'),
],
mostPlayedTracks: [],
lastPlayedArtists: [],
);
await tester.pumpWidget(ProviderScope(
overrides: [
homeProvider.overrideWith((ref) => Stream.value(home)),
playlistsListProvider('all').overrideWith(
(ref) => Stream.value(PlaylistsList.empty()),
),
systemPlaylistsStatusProvider.overrideWith(
(ref) async => SystemPlaylistsStatus.empty(),
),
],
child: MaterialApp(theme: buildThemeData(), home: const HomeScreen()),
));
await tester.pumpAndSettle();
expect(find.text('Recently added'), findsOneWidget);
expect(find.text('Geogaddi'), findsOneWidget);
expect(find.text('Rediscover'), findsOneWidget);
expect(find.text('Aphex Twin'), findsOneWidget);
});
// Section-with-populated-IDs coverage lives in the drift-tagged
// integration suite (Slice F follow-up) because per-tile providers
// require a real (in-memory) drift DB. The flutter-ci runner skips
// drift tests pending libsqlite3.
}