From 395a6efb264cc1fc1bbff0072dc6f1302ce17332 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 13 May 2026 18:31:36 -0400 Subject: [PATCH] feat(flutter): drift-first History tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slice 4 of the smooth-loading pass. Adds CachedHistorySnapshot (schema 4) and rewires _historyProvider through cacheFirst, mirroring the homeProvider pattern: yield the last cached page immediately on subscribe so the tab paints from disk on cold open, then SWR-refresh in the background to surface fresh plays. Also enables basic offline scrollback — the most recent History page survives both app restart and connectivity loss. JSON blob storage (vs columnar) because the page is small, always read whole, and HistoryPage.fromJson already accepts the wire shape, so server-side field additions don't force a migration. History delta sync via library_changes is out of scope here; the next visit's SWR pull is the source of freshness for now. --- flutter_client/lib/cache/db.dart | 23 ++++++- .../lib/library/library_screen.dart | 64 ++++++++++++++++++- 2 files changed, 84 insertions(+), 3 deletions(-) diff --git a/flutter_client/lib/cache/db.dart b/flutter_client/lib/cache/db.dart index e2a4caf8..69ec7174 100644 --- a/flutter_client/lib/cache/db.dart +++ b/flutter_client/lib/cache/db.dart @@ -128,6 +128,20 @@ class CachedHomeSnapshot extends Table { Set get primaryKey => {id}; } +/// Single-row cache of the /api/me/history response. Mirrors the +/// CachedHomeSnapshot pattern: opening the History tab in the Library +/// screen yields the last-known page immediately while a fresh REST +/// pull lands underneath via SWR. Also makes basic offline scrollback +/// possible — the last fetched page survives both app restart and +/// loss of connectivity. Schema 4+. +class CachedHistorySnapshot 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: [ @@ -140,12 +154,13 @@ enum CacheSource { manual, autoLiked, autoPlaylist, autoPrefetch, incidental } AudioCacheIndex, SyncMetadata, CachedHomeSnapshot, + CachedHistorySnapshot, ]) class AppDb extends _$AppDb { AppDb([QueryExecutor? e]) : super(e ?? driftDatabase(name: 'minstrel_cache')); @override - int get schemaVersion => 3; + int get schemaVersion => 4; @override MigrationStrategy get migration => MigrationStrategy( @@ -167,6 +182,12 @@ class AppDb extends _$AppDb { // empty; the first /api/home fetch populates it. await m.createTable(cachedHomeSnapshot); } + if (from < 4) { + // Schema 4: cached_history_snapshot for drift-first + // History tab. Same pattern as cached_home_snapshot — + // empty on upgrade; first /api/me/history fetch populates. + await m.createTable(cachedHistorySnapshot); + } }, ); } diff --git a/flutter_client/lib/library/library_screen.dart b/flutter_client/lib/library/library_screen.dart index 75391edf..23b1c1ba 100644 --- a/flutter_client/lib/library/library_screen.dart +++ b/flutter_client/lib/library/library_screen.dart @@ -1,3 +1,5 @@ +import 'dart:convert'; + import 'package:drift/drift.dart' as drift; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -132,10 +134,68 @@ final _libraryAlbumsProvider = AsyncNotifierProvider< _LibraryAlbumsNotifier, wire.Paged>(_LibraryAlbumsNotifier.new); -final _historyProvider = FutureProvider((ref) async { - return (await ref.watch(_meApiProvider.future)).history(); +// Drift-first History tab. Mirrors homeProvider's pattern: store the +// last /api/me/history page as JSON in a single-row drift table, yield +// it immediately on subscribe (so the tab paints from disk on cold +// open), then SWR-refresh in the background. Also gives basic offline +// scrollback — the last fetched page survives connectivity loss. +// +// JSON blob (vs columnar) because the page is small, always read whole, +// and the HistoryPage.fromJson constructor already accepts the wire +// shape — no schema-evolution pain when server-side fields change. +final _historyProvider = StreamProvider((ref) { + final db = ref.watch(appDbProvider); + return cacheFirst( + driftStream: db.select(db.cachedHistorySnapshot).watch(), + fetchAndPopulate: () async { + final api = await ref.read(_meApiProvider.future); + final fresh = await api.history(); + await db.into(db.cachedHistorySnapshot).insertOnConflictUpdate( + CachedHistorySnapshotCompanion.insert( + json: _encodeHistoryPage(fresh), + updatedAt: drift.Value(DateTime.now()), + ), + ); + }, + toResult: (rows) => rows.isEmpty + ? const HistoryPage(events: [], hasMore: false) + : HistoryPage.fromJson( + jsonDecode(rows.first.json) as Map), + isOnline: () async => (await ref + .read(connectivityProvider.future) + .timeout(const Duration(seconds: 3), onTimeout: () => true)), + // SWR: yield cache instantly, then refresh in the background so the + // tab reflects the freshest plays. Matches homeProvider behavior. + alwaysRefresh: true, + tag: 'history', + ); }); +/// Encodes HistoryPage back to the wire-format JSON shape +/// /api/me/history emits, so HistoryPage.fromJson can round-trip +/// through the drift cache. +String _encodeHistoryPage(HistoryPage h) => jsonEncode({ + 'events': h.events + .map((e) => { + 'id': e.id, + 'played_at': e.playedAt, + 'track': { + 'id': e.track.id, + 'title': e.track.title, + 'album_id': e.track.albumId, + 'album_title': e.track.albumTitle, + 'artist_id': e.track.artistId, + 'artist_name': e.track.artistName, + 'track_number': e.track.trackNumber, + 'disc_number': e.track.discNumber, + 'duration_sec': e.track.durationSec, + 'stream_url': e.track.streamUrl, + }, + }) + .toList(), + 'has_more': h.hasMore, + }); + // Drift-first Liked tabs (#357 plan C). Read from cached_likes joined // against cached_tracks / cached_albums / cached_artists. SyncController // keeps both sides fresh; LikesController mutates cached_likes