From 93b173cff8d483979332588c7703543e2507ec29 Mon Sep 17 00:00:00 2001 From: zarzet Date: Mon, 13 Jul 2026 21:23:34 +0700 Subject: [PATCH] feat(ui): real headers on extension loading screens so hero covers fly on open Opening an extension album/artist showed a bare skeleton, so the cover had no Hero destination and only flew on the way back. Loading states now render the actual header (title + Hero cover in its final slot, skeleton placeholders for the remaining rows) above the shimmering list. SkeletonCrossfade mutes the outgoing child's heroes to avoid duplicate tags if the user pops mid-fade. --- lib/screens/home_tab.dart | 3 + lib/screens/home_tab_widgets.dart | 223 ++++++++++++++++++++++++++++-- lib/widgets/animation_utils.dart | 16 +++ 3 files changed, 233 insertions(+), 9 deletions(-) diff --git a/lib/screens/home_tab.dart b/lib/screens/home_tab.dart index 18ef0fb8..acf97e04 100644 --- a/lib/screens/home_tab.dart +++ b/lib/screens/home_tab.dart @@ -17,6 +17,9 @@ import 'package:spotiflac_android/providers/local_library_provider.dart'; import 'package:spotiflac_android/screens/track_metadata_screen.dart'; import 'package:spotiflac_android/screens/album_screen.dart'; import 'package:spotiflac_android/screens/artist_screen.dart'; +import 'package:spotiflac_android/screens/collapsing_header_scroll_mixin.dart'; +import 'package:spotiflac_android/utils/cover_art_utils.dart'; +import 'package:spotiflac_android/widgets/album_detail_header.dart'; import 'package:spotiflac_android/services/csv_import_service.dart'; import 'package:spotiflac_android/services/downloaded_embedded_cover_resolver.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; diff --git a/lib/screens/home_tab_widgets.dart b/lib/screens/home_tab_widgets.dart index 8da8d0f1..bc5b0c31 100644 --- a/lib/screens/home_tab_widgets.dart +++ b/lib/screens/home_tab_widgets.dart @@ -919,6 +919,194 @@ class _LoadingOrErrorScaffold extends StatelessWidget { } } +/// Loading state for [ExtensionAlbumScreen]: the real collection header with +/// the Hero cover already in its final slot (so the flight from the tapped +/// list item lands correctly) above a shimmering track list. +class _AlbumLoadingScaffold extends StatefulWidget { + final String title; + final String? coverUrl; + final Object? heroTag; + + const _AlbumLoadingScaffold({ + required this.title, + required this.coverUrl, + required this.heroTag, + }); + + @override + State<_AlbumLoadingScaffold> createState() => _AlbumLoadingScaffoldState(); +} + +class _AlbumLoadingScaffoldState extends State<_AlbumLoadingScaffold> + with CollapsingHeaderScrollMixin<_AlbumLoadingScaffold> { + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final url = widget.coverUrl; + Widget cover() => url != null && url.isNotEmpty + ? CachedCoverImage( + imageUrl: highResCoverUrl(url) ?? url, + fit: BoxFit.cover, + ) + : Container( + color: colorScheme.surfaceContainerHighest, + child: Icon( + Icons.album, + size: 48, + color: colorScheme.onSurfaceVariant, + ), + ); + + return Scaffold( + body: CustomScrollView( + controller: scrollController, + slivers: [ + AlbumDetailHeader( + title: widget.title, + expandedHeight: calculateExpandedHeight(context), + showTitleInAppBar: showTitleInAppBar, + heroTag: widget.heroTag, + background: cover(), + coverBuilder: (context, coverSize) => cover(), + // Placeholder rows sized like the loaded header's subtitle, meta, + // and action rows so the cover barely shifts when content lands. + subtitle: const ShimmerLoading( + child: SkeletonBox(width: 120, height: 16, borderRadius: 4), + ), + meta: const ShimmerLoading( + child: SkeletonBox(width: 150, height: 14, borderRadius: 6), + ), + actions: const ShimmerLoading( + child: SkeletonBox(width: 220, height: 48, borderRadius: 24), + ), + ), + const SliverToBoxAdapter(child: AlbumTrackListSkeleton(itemCount: 8)), + ], + ), + ); + } +} + +/// Loading state for [ExtensionArtistScreen]: the real full-bleed header with +/// the Hero cover and artist name above the discography skeleton. +class _ArtistLoadingScaffold extends StatelessWidget { + final String artistName; + final String? coverUrl; + final Object? heroTag; + + const _ArtistLoadingScaffold({ + required this.artistName, + required this.coverUrl, + required this.heroTag, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final isDark = Theme.of(context).brightness == Brightness.dark; + final url = coverUrl; + final hasImage = + url != null && + url.isNotEmpty && + Uri.tryParse(url)?.hasAuthority == true; + + Widget image = hasImage + ? CachedCoverImage( + imageUrl: url, + fit: BoxFit.cover, + alignment: Alignment.topCenter, + memCacheWidth: 800, + ) + : Container( + color: colorScheme.surfaceContainerHighest, + child: Icon( + Icons.person, + size: 80, + color: colorScheme.onSurfaceVariant, + ), + ); + if (heroTag != null) { + image = Hero(tag: heroTag!, child: image); + } + + return Scaffold( + body: CustomScrollView( + slivers: [ + SliverAppBar( + expandedHeight: 420, + pinned: true, + backgroundColor: colorScheme.surface, + surfaceTintColor: Colors.transparent, + flexibleSpace: FlexibleSpaceBar( + collapseMode: CollapseMode.none, + background: Stack( + fit: StackFit.expand, + children: [ + image, + Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.transparent, + Colors.black.withValues(alpha: 0.3), + Colors.black.withValues(alpha: 0.7), + isDark + ? colorScheme.surface + : Colors.black.withValues(alpha: 0.85), + ], + stops: const [0.0, 0.5, 0.75, 1.0], + ), + ), + ), + Positioned( + left: 16, + right: 16, + bottom: 16, + child: Text( + artistName, + style: Theme.of(context).textTheme.headlineLarge + ?.copyWith( + fontWeight: FontWeight.bold, + color: Colors.white, + shadows: [ + Shadow( + offset: const Offset(0, 1), + blurRadius: 4, + color: Colors.black.withValues(alpha: 0.5), + ), + ], + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + leading: IconButton( + tooltip: MaterialLocalizations.of(context).backButtonTooltip, + icon: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.4), + shape: BoxShape.circle, + ), + child: const Icon(Icons.arrow_back, color: Colors.white), + ), + onPressed: () => Navigator.pop(context), + ), + ), + const SliverToBoxAdapter( + child: ArtistScreenSkeleton(showCoverHeader: false), + ), + ], + ), + ); + } +} + class ExtensionAlbumScreen extends ConsumerStatefulWidget { final String extensionId; final String albumId; @@ -1077,21 +1265,28 @@ class _ExtensionAlbumScreenState extends ConsumerState { @override Widget build(BuildContext context) { - if (_isLoading || _error != null) { + if (_error != null) { return SkeletonCrossfade( child: _LoadingOrErrorScaffold( title: widget.albumName, - isLoading: _isLoading, + isLoading: false, error: _error, - loadingBody: const AlbumTrackListSkeleton( - itemCount: 10, - showCoverHeader: true, - ), + loadingBody: const SizedBox.shrink(), onRetry: _fetchTracks, ), ); } + if (_isLoading) { + return SkeletonCrossfade( + child: _AlbumLoadingScaffold( + title: widget.albumName, + coverUrl: widget.coverUrl, + heroTag: widget.heroTag, + ), + ); + } + return SkeletonCrossfade( child: AlbumScreen( albumId: widget.albumId, @@ -1383,18 +1578,28 @@ class _ExtensionArtistScreenState extends ConsumerState { @override Widget build(BuildContext context) { - if (_isLoading || _error != null) { + if (_error != null) { return SkeletonCrossfade( child: _LoadingOrErrorScaffold( title: widget.artistName, - isLoading: _isLoading, + isLoading: false, error: _error, - loadingBody: const ArtistScreenSkeleton(), + loadingBody: const SizedBox.shrink(), onRetry: _fetchArtist, ), ); } + if (_isLoading) { + return SkeletonCrossfade( + child: _ArtistLoadingScaffold( + artistName: widget.artistName, + coverUrl: widget.coverUrl, + heroTag: widget.heroTag, + ), + ); + } + return SkeletonCrossfade( child: ArtistScreen( artistId: widget.artistId, diff --git a/lib/widgets/animation_utils.dart b/lib/widgets/animation_utils.dart index 50bb1aea..d3218bbe 100644 --- a/lib/widgets/animation_utils.dart +++ b/lib/widgets/animation_utils.dart @@ -635,6 +635,22 @@ class SkeletonCrossfade extends StatelessWidget { Widget build(BuildContext context) { return AnimatedSwitcher( duration: const Duration(milliseconds: 250), + // Loading and loaded screens may carry the same Hero tag (both render + // the header cover); mute the outgoing child's heroes so a pop during + // the fade doesn't find duplicate tags. + transitionBuilder: (child, animation) => FadeTransition( + opacity: animation, + child: AnimatedBuilder( + animation: animation, + child: child, + builder: (context, child) => HeroMode( + enabled: + animation.status == AnimationStatus.forward || + animation.status == AnimationStatus.completed, + child: child!, + ), + ), + ), child: child, ); }