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:
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../api/endpoints/likes.dart';
|
||||
import '../models/album.dart';
|
||||
import '../cache/audio_cache_manager.dart';
|
||||
import '../cache/db.dart';
|
||||
import '../likes/like_button.dart';
|
||||
@@ -12,18 +13,78 @@ import 'library_providers.dart';
|
||||
import 'widgets/track_row.dart';
|
||||
|
||||
class AlbumDetailScreen extends ConsumerWidget {
|
||||
const AlbumDetailScreen({required this.id, super.key});
|
||||
const AlbumDetailScreen({required this.id, this.seed, super.key});
|
||||
final String id;
|
||||
|
||||
/// Optional album reference passed by the caller (typically the
|
||||
/// tile they tapped) so the header can render immediately while
|
||||
/// the full provider loads tracks. Hydration only — provider data
|
||||
/// still wins once it arrives.
|
||||
final AlbumRef? seed;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
final live = ref.watch(albumProvider(id));
|
||||
|
||||
// Pick the best header info available: live data > seed > nothing.
|
||||
final headerTitle = live.value?.album.title.isNotEmpty == true
|
||||
? live.value!.album.title
|
||||
: seed?.title ?? '';
|
||||
final headerArtist = live.value?.album.artistName.isNotEmpty == true
|
||||
? live.value!.album.artistName
|
||||
: seed?.artistName ?? '';
|
||||
|
||||
Widget header() => Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: SizedBox(
|
||||
width: 96,
|
||||
height: 96,
|
||||
child: ServerImage(
|
||||
url: '/api/albums/$id/cover',
|
||||
fit: BoxFit.cover,
|
||||
fallback: Container(color: fs.slate),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
headerTitle,
|
||||
style: TextStyle(
|
||||
color: fs.parchment,
|
||||
fontFamily: fs.display.fontFamily,
|
||||
fontSize: 22,
|
||||
),
|
||||
),
|
||||
if (headerArtist.isNotEmpty)
|
||||
Text(headerArtist, style: TextStyle(color: fs.ash)),
|
||||
],
|
||||
),
|
||||
),
|
||||
]),
|
||||
);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(),
|
||||
backgroundColor: fs.obsidian,
|
||||
body: ref.watch(albumProvider(id)).when(
|
||||
body: live.when(
|
||||
error: (e, _) => Center(child: Text('$e', style: TextStyle(color: fs.error))),
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
loading: () => seed != null
|
||||
? ListView(children: [
|
||||
header(),
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 24),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
])
|
||||
: const Center(child: CircularProgressIndicator()),
|
||||
data: (r) => ListView(children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
|
||||
@@ -12,22 +12,49 @@ import 'library_providers.dart';
|
||||
import 'widgets/album_card.dart';
|
||||
|
||||
class ArtistDetailScreen extends ConsumerWidget {
|
||||
const ArtistDetailScreen({required this.id, super.key});
|
||||
const ArtistDetailScreen({required this.id, this.seed, super.key});
|
||||
final String id;
|
||||
|
||||
/// Optional artist reference from the caller. Lets the screen render
|
||||
/// the name + avatar immediately while albums load.
|
||||
final ArtistRef? seed;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
final artist = ref.watch(artistProvider(id));
|
||||
final albums = ref.watch(artistAlbumsProvider(id));
|
||||
|
||||
// Resolve which artist info to render in the header. Live wins
|
||||
// when present and populated; seed fills the gap during the
|
||||
// first frame after navigation.
|
||||
final liveArtist = artist.value;
|
||||
final hasLiveName = liveArtist != null && liveArtist.name.isNotEmpty;
|
||||
final effective = hasLiveName ? liveArtist : (seed ?? liveArtist);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(),
|
||||
backgroundColor: fs.obsidian,
|
||||
body: artist.when(
|
||||
error: (e, _) => Center(child: Text('$e', style: TextStyle(color: fs.error))),
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
data: (a) => ListView(children: [
|
||||
// While loading: render header from seed if available so the
|
||||
// page isn't blank.
|
||||
loading: () => seed == null
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _artistBody(context, ref, seed!, albums, fs),
|
||||
data: (_) => _artistBody(context, ref, effective ?? seed!, albums, fs),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _artistBody(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
ArtistRef a,
|
||||
AsyncValue<List<AlbumRef>> albums,
|
||||
FabledSwordTheme fs,
|
||||
) =>
|
||||
ListView(children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(children: [
|
||||
@@ -111,16 +138,14 @@ class ArtistDetailScreen extends ConsumerWidget {
|
||||
width: cellW,
|
||||
titleMaxLines: 2,
|
||||
showArtist: false,
|
||||
onTap: () => context.push('/albums/${album.id}'),
|
||||
onTap: () =>
|
||||
context.push('/albums/${album.id}', extra: album),
|
||||
);
|
||||
},
|
||||
);
|
||||
}),
|
||||
),
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
]);
|
||||
}
|
||||
|
||||
/// Renders the artist's avatar. Server-emitted coverUrl wins when
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:drift/drift.dart' as drift;
|
||||
import 'package:flutter/foundation.dart' show debugPrint;
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
@@ -123,29 +125,45 @@ final playlistDetailProvider =
|
||||
tracks: const [],
|
||||
);
|
||||
|
||||
Future<bool> isOnline() async {
|
||||
try {
|
||||
return await ref
|
||||
.read(connectivityProvider.future)
|
||||
.timeout(const Duration(seconds: 3), onTimeout: () => true);
|
||||
} catch (_) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> fetchAndPopulate() async {
|
||||
final api = await ref.read(playlistsApiProvider.future);
|
||||
final fresh = await api.get(id);
|
||||
await db.batch((b) {
|
||||
b.insert(db.cachedPlaylists, fresh.playlist.toDrift(),
|
||||
mode: drift.InsertMode.insertOrReplace);
|
||||
for (var i = 0; i < fresh.tracks.length; i++) {
|
||||
final t = fresh.tracks[i];
|
||||
if (t.trackId == null) continue;
|
||||
b.insert(
|
||||
db.cachedPlaylistTracks,
|
||||
CachedPlaylistTracksCompanion.insert(
|
||||
playlistId: id,
|
||||
trackId: t.trackId!,
|
||||
position: drift.Value(i),
|
||||
),
|
||||
mode: drift.InsertMode.insertOrReplace,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
var revalidated = false;
|
||||
|
||||
await for (final playlistRows in playlistQuery.watch()) {
|
||||
if (playlistRows.isEmpty) {
|
||||
if (await ref.read(connectivityProvider.future)) {
|
||||
if (await isOnline()) {
|
||||
try {
|
||||
final api = await ref.read(playlistsApiProvider.future);
|
||||
final fresh = await api.get(id);
|
||||
await db.batch((b) {
|
||||
b.insert(db.cachedPlaylists, fresh.playlist.toDrift(),
|
||||
mode: drift.InsertMode.insertOrReplace);
|
||||
for (var i = 0; i < fresh.tracks.length; i++) {
|
||||
final t = fresh.tracks[i];
|
||||
if (t.trackId == null) continue;
|
||||
b.insert(
|
||||
db.cachedPlaylistTracks,
|
||||
CachedPlaylistTracksCompanion.insert(
|
||||
playlistId: id,
|
||||
trackId: t.trackId!,
|
||||
position: drift.Value(i),
|
||||
),
|
||||
mode: drift.InsertMode.insertOrReplace,
|
||||
);
|
||||
}
|
||||
});
|
||||
await fetchAndPopulate();
|
||||
// watch() re-emits next iteration with populated row.
|
||||
} catch (_) {
|
||||
yield emptyDetail();
|
||||
@@ -177,6 +195,16 @@ final playlistDetailProvider =
|
||||
}).toList();
|
||||
|
||||
yield PlaylistDetail(playlist: playlist, tracks: tracks);
|
||||
|
||||
// SWR: first complete cache hit kicks one background refresh so
|
||||
// the playlist catches up to server state (new tracks, renames,
|
||||
// etc.) without making the user wait.
|
||||
if (!revalidated) {
|
||||
revalidated = true;
|
||||
if (await isOnline()) {
|
||||
unawaited(fetchAndPopulate().catchError((_) {}));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -7,6 +7,8 @@ import '../auth/login_screen.dart';
|
||||
import '../auth/server_url_screen.dart';
|
||||
import '../library/album_detail_screen.dart';
|
||||
import '../library/artist_detail_screen.dart';
|
||||
import '../models/album.dart';
|
||||
import '../models/artist.dart';
|
||||
import '../discover/discover_screen.dart';
|
||||
import '../library/home_screen.dart';
|
||||
import '../library/library_screen.dart';
|
||||
@@ -89,11 +91,19 @@ GoRouter buildRouter(Ref ref) {
|
||||
GoRoute(path: '/home', builder: (_, __) => const HomeScreen()),
|
||||
GoRoute(
|
||||
path: '/artists/:id',
|
||||
builder: (_, s) => ArtistDetailScreen(id: s.pathParameters['id']!),
|
||||
// `extra` carries an optional ArtistRef so the detail
|
||||
// header renders immediately while tracks/albums load.
|
||||
builder: (_, s) => ArtistDetailScreen(
|
||||
id: s.pathParameters['id']!,
|
||||
seed: s.extra is ArtistRef ? s.extra as ArtistRef : null,
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/albums/:id',
|
||||
builder: (_, s) => AlbumDetailScreen(id: s.pathParameters['id']!),
|
||||
builder: (_, s) => AlbumDetailScreen(
|
||||
id: s.pathParameters['id']!,
|
||||
seed: s.extra is AlbumRef ? s.extra as AlbumRef : null,
|
||||
),
|
||||
),
|
||||
GoRoute(path: '/queue', builder: (_, __) => const QueueScreen()),
|
||||
GoRoute(path: '/search', builder: (_, __) => const SearchScreen()),
|
||||
|
||||
Reference in New Issue
Block a user