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:
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user