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