feat(flutter): SWR everywhere + nav hydration + cold-start home skeleton

Three changes addressing the cold-start spinner + stale-on-revisit pain.

A. SWR on remaining cacheFirst providers
- artistProvider, artistAlbumsProvider, artistTracksProvider all gain
  alwaysRefresh: true. Cache hit still renders instantly; one
  background refresh per subscription keeps the row from going stale
  forever.
- albumProvider (inline async*) and playlistDetailProvider (inline
  async*) now keep a `revalidated` flag and kick a one-shot
  background fetch on the first complete cache hit. Same effect as
  cacheFirst's alwaysRefresh, just inline.
- Three providers gained the connectivity timeout that
  album/artist/playlist already had.

B. Navigation hydration
- AlbumDetailScreen accepts `AlbumRef? seed`; ArtistDetailScreen
  accepts `ArtistRef? seed`. When the live provider is still loading,
  the seed populates cover/title/artist immediately so the page
  isn't blank.
- Routing wires `extra: AlbumRef|ArtistRef` from go_router into
  the seed parameter.
- Call sites updated: home (Recently added, Rediscover albums +
  artists, Last played), artist detail album grid. Where a ref isn't
  available (track actions sheet), the screen falls back to the
  spinner — no regression.

C. Cold-start home skeleton
- Replace the full-screen CircularProgressIndicator on /home with a
  layout-preserving skeleton: 5 section titles + 6 grey card-shaped
  placeholders per row. The page feels populated immediately;
  sections fill in independently as data arrives via the per-section
  providers.
- Drops the unused DelayedLoading import.

Net effect: re-visits to detail screens render instantly (cache hit
+ silent refresh); first visit from a tile shows the seed header
immediately while tracks load; cold-start home shows a layout
skeleton instead of a 30s blank spinner.
This commit is contained in:
2026-05-11 12:12:44 -04:00
parent f4d07ef9a1
commit 11c40c6aca
6 changed files with 269 additions and 48 deletions
@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:dio/dio.dart';
import 'package:drift/drift.dart' as drift;
import 'package:flutter/foundation.dart' show debugPrint;
@@ -64,6 +66,9 @@ final artistProvider =
isOnline: () async => (await ref
.read(connectivityProvider.future)
.timeout(const Duration(seconds: 3), onTimeout: () => true)),
// SWR: yield cache instantly on hit, refresh in the background so
// the row catches up to server state without making the user wait.
alwaysRefresh: true,
tag: 'artist($id)',
);
});
@@ -95,6 +100,7 @@ final artistAlbumsProvider =
isOnline: () async => (await ref
.read(connectivityProvider.future)
.timeout(const Duration(seconds: 3), onTimeout: () => true)),
alwaysRefresh: true,
tag: 'artistAlbums($artistId)',
);
});
@@ -128,8 +134,11 @@ final artistTracksProvider =
albumTitle: album?.title ?? '',
);
}).toList(),
isOnline: () async =>
(await ref.read(connectivityProvider.future)),
isOnline: () async => (await ref
.read(connectivityProvider.future)
.timeout(const Duration(seconds: 3), onTimeout: () => true)),
alwaysRefresh: true,
tag: 'artistTracks($artistId)',
);
});
@@ -160,6 +169,10 @@ final albumProvider = StreamProvider.family<
// Once-per-subscription guard so we don't re-fetch in a loop if the
// server genuinely returns zero tracks (or if the fetch fails).
var fetchAttempted = false;
// SWR revalidation guard — fire one background refresh per
// subscription on the first complete cache hit, so the displayed
// data catches up to server state without making the user wait.
var revalidated = false;
Future<bool> isOnline() async {
try {
@@ -290,6 +303,16 @@ final albumProvider = StreamProvider.family<
);
}).toList();
// SWR: first complete cache hit triggers one background refresh
// so we don't show stale data indefinitely. Drift watch re-emits
// on success and the next iteration yields the fresh data.
if (!revalidated && trackRows.isNotEmpty) {
revalidated = true;
if (await isOnline()) {
unawaited(fetchAndPopulate());
}
}
yield (album: album, tracks: tracks);
}
});