feat(flutter): drift-first Library tabs + systemPlaylistsStatus + tab pre-warm

Four-part change to push more surfaces onto the drift cache and
eliminate cold-tab-visit latency on the Library screen.

* **Library Artists tab** — _libraryArtistsProvider migrates from
  REST-paginated AsyncNotifier with infinite-scroll loadMore to a
  drift-first StreamProvider over cached_artists ordered by
  sortName. Sync already populates the full set; cacheFirst's
  fetchAndPopulate covers the fresh-install + sync-not-yet-done
  cold case via /api/artists?limit=1000. SWR refresh on every
  visit. GridView.builder lazily realizes only visible cells so
  loading the full list up front is fine for typical libraries.
  loadMore + NotificationListener gone.

* **Library Albums tab** — same migration, drift-first over
  cached_albums joined with cached_artists for the artistName
  field.

* **systemPlaylistsStatusProvider** — new CachedSystemPlaylistsStatus
  single-row table (schema 6 → 7, JSON blob like CachedHomeSnapshot)
  for the home Playlists row's "building / pending / failed"
  placeholder logic. Drift-first means the row paints with the
  prior status instantly instead of flickering through
  SystemPlaylistsStatus.empty() while the REST call resolves.

* **Library screen tab pre-warm** — ref.listen on all 5 tab
  providers in _LibraryScreenState.build subscribes them upfront
  so swiping between tabs feels instant rather than each tab
  paying its own cold-cache cost on first visit. cacheFirst
  handles dedupe of concurrent fetchAndPopulate triggers.

Test mock updated for the StreamProvider type change on
systemPlaylistsStatusProvider.
This commit is contained in:
2026-05-14 10:14:46 -04:00
parent 507c532f6d
commit 2a18e91c39
4 changed files with 201 additions and 165 deletions
@@ -1,4 +1,5 @@
import 'dart:async';
import 'dart:convert';
import 'package:dio/dio.dart';
import 'package:drift/drift.dart' as drift;
@@ -362,8 +363,41 @@ class _TrackRefRow {
final int durationSec;
}
/// Drift-first per #357 pattern. Reads from cached_system_playlists_
/// status (single-row JSON blob populated by /api/me/system-playlists-
/// status). Home Playlists row reads this every render to choose
/// between real cards and "building / pending / failed" placeholders
/// — drift-first means the row paints with the prior status instantly
/// rather than flickering through SystemPlaylistsStatus.empty() while
/// the REST round-trip resolves. SWR refresh on every visit keeps it
/// current.
final systemPlaylistsStatusProvider =
FutureProvider<SystemPlaylistsStatus>((ref) async {
final dio = await ref.watch(dioProvider.future);
return MeApi(dio).systemPlaylistsStatus();
StreamProvider<SystemPlaylistsStatus>((ref) {
final db = ref.watch(appDbProvider);
return cacheFirst<CachedSystemPlaylistsStatusData, SystemPlaylistsStatus>(
driftStream: db.select(db.cachedSystemPlaylistsStatus).watch(),
fetchAndPopulate: () async {
final dio = await ref.read(dioProvider.future);
final fresh = await MeApi(dio).systemPlaylistsStatus();
await db.into(db.cachedSystemPlaylistsStatus).insertOnConflictUpdate(
CachedSystemPlaylistsStatusCompanion.insert(
json: jsonEncode({
'in_flight': fresh.inFlight,
'last_run_at': fresh.lastRunAt,
'last_error': fresh.lastError,
}),
updatedAt: drift.Value(DateTime.now()),
),
);
},
toResult: (rows) => rows.isEmpty
? SystemPlaylistsStatus.empty()
: SystemPlaylistsStatus.fromJson(
jsonDecode(rows.first.json) as Map<String, dynamic>),
isOnline: () async => (await ref
.read(connectivityProvider.future)
.timeout(const Duration(seconds: 3), onTimeout: () => true)),
alwaysRefresh: true,
tag: 'systemPlaylistsStatus',
);
});