From 22bd06a578d51296eb50ee40ea0fd56300fcd027 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 13 May 2026 15:40:43 -0400 Subject: [PATCH] feat(flutter): drift-first homeProvider (#357 deferred follow-up) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Home screen is the first surface on app open; without a local cache the cold-start blocks on /api/home, which dominates felt latency on slow or remote connections. This commit caches the last successful HomeData as a single-row JSON blob in drift, so subsequent app opens yield content immediately and revalidate in the background. Schema: - New CachedHomeSnapshot table (single row: id=1, json TEXT, updated_at). schemaVersion bumped 2 → 3 with a forward migration that calls m.createTable(cachedHomeSnapshot). Codegen regenerated via build_runner; the *.g.dart files are gitignored and rebuilt by the CI Codegen step. Provider rewrite: - homeProvider: FutureProvider → StreamProvider using the existing cacheFirst pattern (alwaysRefresh: true for SWR). On cold cache the first /api/home fetch populates the row. On warm cache the cached HomeData is yielded immediately and a background REST fetch overwrites the row, which drift's watch() picks up. - Encoder helpers (_albumToJson / _artistToJson / _trackToJson) so HomeData survives the JSON round-trip into and out of drift. Field names match the server's /api/home wire shape exactly so HomeData.fromJson handles both fresh server responses and cached drift rows. Callers untouched: home_screen.dart's ref.watch + ref.refresh + metadata_prefetcher's ref.listen all keep working with the StreamProvider shape (AsyncValue stays the surface type). Test fix: 4 homeProvider.overrideWith sites in home_screen_test.dart switched from `(ref) async => _emptyHome` (FutureProvider form) to `(ref) => Stream.value(_emptyHome)` (StreamProvider form). For #357. Completes the user-visible deferred follow-up. Remaining deferred items: library_changes server-side retention. Co-Authored-By: Claude Opus 4.7 (1M context) --- flutter_client/lib/cache/db.dart | 22 ++++- .../lib/library/library_providers.dart | 95 ++++++++++++++++++- .../test/library/home_screen_test.dart | 8 +- 3 files changed, 118 insertions(+), 7 deletions(-) diff --git a/flutter_client/lib/cache/db.dart b/flutter_client/lib/cache/db.dart index 15df5372..e2a4caf8 100644 --- a/flutter_client/lib/cache/db.dart +++ b/flutter_client/lib/cache/db.dart @@ -115,6 +115,19 @@ class SyncMetadata extends Table { Set get primaryKey => {id}; } +/// Single-row cache of the /api/home response (#357 follow-up). The +/// home screen is the first thing the user sees on app open; storing +/// the last successful HomeData as JSON lets us yield it immediately +/// before the REST round-trip resolves, eliminating the cold-start +/// blank on slow / remote connections. Schema 3+. +class CachedHomeSnapshot extends Table { + IntColumn get id => integer().withDefault(const Constant(1))(); + TextColumn get json => text()(); + DateTimeColumn get updatedAt => dateTime().withDefault(currentDateAndTime)(); + @override + Set get primaryKey => {id}; +} + enum CacheSource { manual, autoLiked, autoPlaylist, autoPrefetch, incidental } @DriftDatabase(tables: [ @@ -126,12 +139,13 @@ enum CacheSource { manual, autoLiked, autoPlaylist, autoPrefetch, incidental } CachedPlaylistTracks, AudioCacheIndex, SyncMetadata, + CachedHomeSnapshot, ]) class AppDb extends _$AppDb { AppDb([QueryExecutor? e]) : super(e ?? driftDatabase(name: 'minstrel_cache')); @override - int get schemaVersion => 2; + int get schemaVersion => 3; @override MigrationStrategy get migration => MigrationStrategy( @@ -147,6 +161,12 @@ class AppDb extends _$AppDb { // pre-existing rows until they happen to change server-side. await customStatement('UPDATE sync_metadata SET cursor = 0'); } + if (from < 3) { + // Schema 3: cached_home_snapshot for #357 drift-first + // home page. No existing rows to migrate — table starts + // empty; the first /api/home fetch populates it. + await m.createTable(cachedHomeSnapshot); + } }, ); } diff --git a/flutter_client/lib/library/library_providers.dart b/flutter_client/lib/library/library_providers.dart index 9482c05c..57f0df66 100644 --- a/flutter_client/lib/library/library_providers.dart +++ b/flutter_client/lib/library/library_providers.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:convert'; import 'package:dio/dio.dart'; import 'package:drift/drift.dart' as drift; @@ -42,10 +43,100 @@ final libraryApiProvider = FutureProvider((ref) async { return LibraryApi(await ref.watch(dioProvider.future)); }); -final homeProvider = FutureProvider((ref) async { - return (await ref.watch(libraryApiProvider.future)).getHome(); +/// Drift-first home data (#357 follow-up). The home screen is the first +/// view the user sees on app open; without a local cache the cold-start +/// blocks on the /api/home round-trip, which is the dominant felt- +/// latency on slow / remote connections. +/// +/// Cache layout: a single row in `cached_home_snapshot` storing the +/// last successful HomeData as JSON. On stream subscription: +/// +/// - If a row exists, yield it immediately (parsed from JSON) and +/// kick off a background revalidation against /api/home (SWR). +/// The drift watch() re-emits when the new row is written. +/// - If no row exists yet (cold cache), fetch /api/home synchronously, +/// upsert into drift, and emit. Subsequent emissions come from the +/// drift watch as background revalidations land. +/// +/// The HomeData JSON encodes back to the same wire shape /api/home +/// emits, so the existing HomeData.fromJson handles both sources. +final homeProvider = StreamProvider((ref) { + final db = ref.watch(appDbProvider); + return cacheFirst( + driftStream: db.select(db.cachedHomeSnapshot).watch(), + fetchAndPopulate: () async { + final api = await ref.read(libraryApiProvider.future); + final fresh = await api.getHome(); + await db.into(db.cachedHomeSnapshot).insertOnConflictUpdate( + CachedHomeSnapshotCompanion.insert( + json: _encodeHomeData(fresh), + updatedAt: drift.Value(DateTime.now()), + ), + ); + }, + toResult: (rows) => rows.isEmpty + ? const HomeData( + recentlyAddedAlbums: [], + rediscoverAlbums: [], + rediscoverArtists: [], + mostPlayedTracks: [], + lastPlayedArtists: [], + ) + : HomeData.fromJson(jsonDecode(rows.first.json) as Map), + isOnline: () async => (await ref + .read(connectivityProvider.future) + .timeout(const Duration(seconds: 3), onTimeout: () => true)), + // SWR: when cached snapshot exists, still hit /api/home in the + // background so the user sees the freshest curation. The cache + // mostly serves the first frame, not the canonical state. + alwaysRefresh: true, + tag: 'home', + ); }); +/// 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({ + 'recently_added_albums': h.recentlyAddedAlbums.map(_albumToJson).toList(), + 'rediscover_albums': h.rediscoverAlbums.map(_albumToJson).toList(), + 'rediscover_artists': h.rediscoverArtists.map(_artistToJson).toList(), + 'most_played_tracks': h.mostPlayedTracks.map(_trackToJson).toList(), + 'last_played_artists': h.lastPlayedArtists.map(_artistToJson).toList(), + }); + +Map _albumToJson(AlbumRef a) => { + 'id': a.id, + 'title': a.title, + 'sort_title': a.sortTitle, + 'artist_id': a.artistId, + 'artist_name': a.artistName, + 'year': a.year, + 'track_count': a.trackCount, + 'duration_sec': a.durationSec, + 'cover_url': a.coverUrl, + }; + +Map _artistToJson(ArtistRef a) => { + 'id': a.id, + 'name': a.name, + 'sort_name': a.sortName, + 'album_count': a.albumCount, + 'cover_url': a.coverUrl, + }; + +Map _trackToJson(TrackRef t) => { + 'id': t.id, + 'title': t.title, + 'album_id': t.albumId, + 'album_title': t.albumTitle, + 'artist_id': t.artistId, + 'artist_name': t.artistName, + 'track_number': t.trackNumber, + 'disc_number': t.discNumber, + 'duration_sec': t.durationSec, + 'stream_url': t.streamUrl, + }; + /// Drift-first per #357 plan C. Watches cached_artists for the row; /// when empty + online, fetches via REST + populates drift, which /// re-emits via watch(). diff --git a/flutter_client/test/library/home_screen_test.dart b/flutter_client/test/library/home_screen_test.dart index 3d6cee03..8a36116a 100644 --- a/flutter_client/test/library/home_screen_test.dart +++ b/flutter_client/test/library/home_screen_test.dart @@ -26,7 +26,7 @@ void main() { (tester) async { await tester.pumpWidget(ProviderScope( overrides: [ - homeProvider.overrideWith((ref) async => _emptyHome), + homeProvider.overrideWith((ref) => Stream.value(_emptyHome)), playlistsListProvider('all').overrideWith( (ref) => Stream.value(PlaylistsList.empty()), ), @@ -46,7 +46,7 @@ void main() { (tester) async { await tester.pumpWidget(ProviderScope( overrides: [ - homeProvider.overrideWith((ref) async => _emptyHome), + homeProvider.overrideWith((ref) => Stream.value(_emptyHome)), playlistsListProvider('all').overrideWith( (ref) => Stream.value(PlaylistsList.empty()), ), @@ -87,7 +87,7 @@ void main() { ); await tester.pumpWidget(ProviderScope( overrides: [ - homeProvider.overrideWith((ref) async => _emptyHome), + homeProvider.overrideWith((ref) => Stream.value(_emptyHome)), playlistsListProvider('all').overrideWith( (ref) => Stream.value( const PlaylistsList(owned: [forYou], public: [])), @@ -118,7 +118,7 @@ void main() { ); await tester.pumpWidget(ProviderScope( overrides: [ - homeProvider.overrideWith((ref) async => home), + homeProvider.overrideWith((ref) => Stream.value(home)), playlistsListProvider('all').overrideWith( (ref) => Stream.value(PlaylistsList.empty()), ),