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
@@ -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.
}