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
+87 -13
View File
@@ -12,7 +12,6 @@ import '../models/track.dart';
import '../playlists/playlists_provider.dart';
import '../playlists/widgets/playlist_card.dart';
import '../playlists/widgets/playlist_placeholder_card.dart';
import '../shared/delayed_loading.dart';
import '../shared/widgets/connection_error_banner.dart';
import '../shared/widgets/main_app_bar_actions.dart';
import '../theme/theme_extension.dart';
@@ -50,12 +49,7 @@ class HomeScreen extends ConsumerWidget {
}
return Center(child: Text('$e', style: TextStyle(color: fs.error)));
},
loading: () => const DelayedLoading(
isLoading: true,
whenReady: SizedBox.shrink(),
whileDelayed:
Center(child: CircularProgressIndicator()),
),
loading: () => _HomeSkeleton(fs: fs),
data: (h) => RefreshIndicator(
onRefresh: () async => ref.refresh(homeProvider.future),
child: ListView(
@@ -190,7 +184,7 @@ class _RecentlyAddedSection extends StatelessWidget {
.map((a) => AlbumCard(
album: a,
onTap: () =>
_push(context, '/albums/${a.id}'),
context.push('/albums/${a.id}', extra: a),
))
.toList(),
),
@@ -220,7 +214,7 @@ class _RediscoverSection extends StatelessWidget {
.map((a) => AlbumCard(
album: a,
onTap: () =>
_push(context, '/albums/${a.id}'),
context.push('/albums/${a.id}', extra: a),
))
.toList(),
),
@@ -232,7 +226,7 @@ class _RediscoverSection extends StatelessWidget {
.map((ar) => ArtistCard(
artist: ar,
onTap: () =>
_push(context, '/artists/${ar.id}'),
context.push('/artists/${ar.id}', extra: ar),
))
.toList(),
),
@@ -291,7 +285,8 @@ class _LastPlayedSection extends StatelessWidget {
children: artists
.map((ar) => ArtistCard(
artist: ar,
onTap: () => _push(context, '/artists/${ar.id}'),
onTap: () =>
context.push('/artists/${ar.id}', extra: ar),
))
.toList(),
);
@@ -324,8 +319,87 @@ class _EmptySection extends StatelessWidget {
}
}
void _push(BuildContext context, String path) {
context.push(path);
/// Cold-start skeleton. Renders the same shape as the real home (a few
/// section titles + card-sized placeholders) so the page feels alive
/// while /api/home is in flight, instead of a 30-second blank spinner.
/// Each section drops in independently as data arrives via the
/// individual providers.
class _HomeSkeleton extends StatelessWidget {
const _HomeSkeleton({required this.fs});
final FabledSwordTheme fs;
@override
Widget build(BuildContext context) {
return ListView(
physics: const ClampingScrollPhysics(),
children: [
_SkeletonSection(fs: fs, title: 'Playlists', cardWidth: 176),
_SkeletonSection(fs: fs, title: 'Recently added', cardWidth: 140),
_SkeletonSection(fs: fs, title: 'Rediscover', cardWidth: 140),
_SkeletonSection(fs: fs, title: 'Most played', cardWidth: 140),
_SkeletonSection(fs: fs, title: 'Last played', cardWidth: 140),
const SizedBox(height: 140),
],
);
}
}
class _SkeletonSection extends StatelessWidget {
const _SkeletonSection({
required this.fs,
required this.title,
required this.cardWidth,
});
final FabledSwordTheme fs;
final String title;
final double cardWidth;
@override
Widget build(BuildContext context) {
final coverSize = cardWidth - 16;
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: Text(
title,
style: TextStyle(
fontFamily: fs.display.fontFamily,
fontSize: 18,
color: fs.parchment,
),
),
),
SizedBox(
height: coverSize + 40,
child: ListView.builder(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 16),
physics: const NeverScrollableScrollPhysics(),
itemCount: 6,
itemBuilder: (_, __) => Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: coverSize,
height: coverSize,
decoration: BoxDecoration(
color: fs.slate,
borderRadius: BorderRadius.circular(6),
),
),
const SizedBox(height: 8),
Container(width: coverSize * 0.7, height: 12, color: fs.slate),
const SizedBox(height: 4),
Container(width: coverSize * 0.4, height: 10, color: fs.iron),
],
),
),
),
),
]);
}
}
List<List<T>> _chunk<T>(List<T> items, int size) {