feat(flutter): cosmetic reveal animations (Slice F)

Final slice of the per-item rendering pass. Wraps every tile widget
in an AnimatedSwitcher between the skeleton placeholder and the real
card. 220ms cross-fade with easeOut: tiles "settle into place"
rather than hard-cutting from shimmer to content. Since each tile
fades independently as its data lands — and the HydrationQueue's
concurrency cap drains in a natural cascade — the overall feel is
the staged "page builds piece by piece" effect we wanted, with no
per-tile position math required.

Bumps CachedNetworkImage fadeInDuration from zero to 120ms (server_
image.dart, discover_screen.dart). Imperceptible on cache hits since
the image decodes synchronously; on cache misses the bytes fade in
smoothly instead of popping. Slice 1's "zero fade" call was right
about the 500ms default being a regression, but 120ms threads the
needle.

Playlist detail wraps its body in the same AnimatedSwitcher so the
cold-load skeleton page cross-fades into the real track list.

Tiles affected: home _AlbumTile / _ArtistTile / _TrackTile + liked
_LikedAlbumTile / _LikedArtistTile / _LikedTrackRow + playlist
_SkeletonBody / _Body. All keyed via ValueKey so AnimatedSwitcher
detects the transition.

End of the per-item pass. Net behavior: cold visits paint shaped
pages instantly with skeletons, content cascades in as hydration
lands; warm visits paint fully from drift in the first frame.
This commit is contained in:
2026-05-13 22:08:34 -04:00
parent 158a5d7506
commit 7367595e71
5 changed files with 115 additions and 58 deletions
@@ -67,16 +67,23 @@ class PlaylistDetailScreen extends ConsumerWidget {
overflow: TextOverflow.ellipsis,
),
),
body: detail.when(
// Loading from a seed: render the header immediately + the
// exact track-count of skeleton rows, instead of an opaque
// full-screen spinner. Cold-visit feel: shaped page with
// shimmering rows that swap in for real ones as the bulk
// detail fetch lands.
loading: () => _SkeletonBody(seed: seed),
error: (e, _) =>
Center(child: Text('$e', style: TextStyle(color: fs.error))),
data: (d) => _Body(detail: d),
// AnimatedSwitcher between the skeleton body and the real body
// smooths the cold-visit moment when bulk detail lands. 220ms
// matches the per-tile reveal feel used on home / liked tabs.
body: AnimatedSwitcher(
duration: const Duration(milliseconds: 220),
switchInCurve: Curves.easeOut,
child: detail.when(
loading: () => _SkeletonBody(
key: const ValueKey('skeleton'),
seed: seed,
),
error: (e, _) => Center(
key: const ValueKey('error'),
child: Text('$e', style: TextStyle(color: fs.error)),
),
data: (d) => _Body(key: const ValueKey('body'), detail: d),
),
),
);
}
@@ -88,7 +95,7 @@ class PlaylistDetailScreen extends ConsumerWidget {
/// (deep link straight to a playlist with no prior cache) the
/// skeleton renders a small default and grows when real data arrives.
class _SkeletonBody extends StatelessWidget {
const _SkeletonBody({this.seed});
const _SkeletonBody({super.key, this.seed});
final Playlist? seed;
static const _defaultSkeletonCount = 8;
@@ -163,7 +170,7 @@ class _PlaylistTrackSkeleton extends StatelessWidget {
}
class _Body extends ConsumerWidget {
const _Body({required this.detail});
const _Body({super.key, required this.detail});
final PlaylistDetail detail;
@override