From 23fe78f30d021cee0b5fd6d266021dff78bff3bd Mon Sep 17 00:00:00 2001 From: zarzet <42882290+zarzet@users.noreply.github.com> Date: Thu, 15 Oct 2026 19:16:47 +0700 Subject: [PATCH] feat(loading): align skeletons with collection and extension layouts --- lib/screens/album_screen.dart | 10 +- .../collapsing_header_scroll_mixin.dart | 13 +- lib/screens/home_tab.dart | 1 + lib/screens/home_tab_widgets.dart | 42 ++ lib/screens/playlist_screen.dart | 10 +- lib/screens/repo_tab.dart | 102 ++- lib/widgets/animation_utils.dart | 588 +++++++++--------- test/loading_layout_regression_test.dart | 89 +++ 8 files changed, 536 insertions(+), 319 deletions(-) create mode 100644 test/loading_layout_regression_test.dart diff --git a/lib/screens/album_screen.dart b/lib/screens/album_screen.dart index f84ad180..ca4afefb 100644 --- a/lib/screens/album_screen.dart +++ b/lib/screens/album_screen.dart @@ -467,13 +467,9 @@ class _AlbumScreenState extends ConsumerState ), ), if (_isLoading) - SliverToBoxAdapter( - child: Padding( - padding: context.isMornye - ? EdgeInsets.zero - : const EdgeInsets.all(16), - child: const AlbumTrackListSkeleton(itemCount: 10), - ), + // The skeleton rows carry the list's own inset. + const SliverToBoxAdapter( + child: AlbumTrackListSkeleton(itemCount: 10), ), if (_error != null) SliverToBoxAdapter( diff --git a/lib/screens/collapsing_header_scroll_mixin.dart b/lib/screens/collapsing_header_scroll_mixin.dart index 841be65e..d33b06b1 100644 --- a/lib/screens/collapsing_header_scroll_mixin.dart +++ b/lib/screens/collapsing_header_scroll_mixin.dart @@ -1,5 +1,12 @@ import 'package:flutter/material.dart'; +/// Expanded height of the album-style collapsing header, shared with the +/// loading screens that show the same header before the tracks arrive. +double collectionHeaderExpandedHeight(BuildContext context) { + final mediaSize = MediaQuery.sizeOf(context); + return (mediaSize.height * 0.6).clamp(400.0, 580.0); +} + /// Shared collapsing-header scroll behavior for album-style screens: owns the /// [ScrollController] and flips [showTitleInAppBar] once the header has /// scrolled out of view. @@ -20,10 +27,8 @@ mixin CollapsingHeaderScrollMixin on State { super.dispose(); } - double calculateExpandedHeight(BuildContext context) { - final mediaSize = MediaQuery.sizeOf(context); - return (mediaSize.height * 0.6).clamp(400.0, 580.0); - } + double calculateExpandedHeight(BuildContext context) => + collectionHeaderExpandedHeight(context); void _onHeaderScroll() { final expandedHeight = calculateExpandedHeight(context); diff --git a/lib/screens/home_tab.dart b/lib/screens/home_tab.dart index 51f4beac..97a2328f 100644 --- a/lib/screens/home_tab.dart +++ b/lib/screens/home_tab.dart @@ -44,6 +44,7 @@ import 'package:spotiflac_android/widgets/explore_featured_section.dart'; 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/collapsing_header_scroll_mixin.dart'; import 'package:spotiflac_android/screens/artist_screen.dart'; import 'package:spotiflac_android/screens/home_search_logic.dart'; import 'package:spotiflac_android/services/csv_import_service.dart'; diff --git a/lib/screens/home_tab_widgets.dart b/lib/screens/home_tab_widgets.dart index a2df18c4..f6b95354 100644 --- a/lib/screens/home_tab_widgets.dart +++ b/lib/screens/home_tab_widgets.dart @@ -714,6 +714,48 @@ class _LoadingOrErrorScaffold extends StatelessWidget { ), ); } + if (!context.isMornye && collectionHeader) { + // The real header with the title and cover already known; only the + // rows that wait for the tracks are skeletons. + final cover = coverUrl; + final hasCover = cover != null && cover.isNotEmpty; + final colors = Theme.of(context).colorScheme; + return Scaffold( + body: CustomScrollView( + physics: const NeverScrollableScrollPhysics(), + slivers: [ + AlbumDetailHeader( + title: title, + expandedHeight: collectionHeaderExpandedHeight(context), + showTitleInAppBar: false, + paletteSource: hasCover ? cover : null, + blurAndScrimBackground: hasCover, + background: hasCover + ? CachedCoverImage(imageUrl: cover, fit: BoxFit.cover) + : ColoredBox(color: colors.surfaceContainerHighest), + coverBuilder: (context, size) => hasCover + ? CachedCoverImage( + imageUrl: cover, + width: size, + height: size, + fit: BoxFit.cover, + ) + : ShimmerLoading( + child: SkeletonBox( + width: size, + height: size, + borderRadius: 0, + ), + ), + subtitle: CollectionHeaderSkeletonRows.subtitle(), + meta: CollectionHeaderSkeletonRows.meta(), + actions: CollectionHeaderSkeletonRows.actions(context), + ), + SliverToBoxAdapter(child: loadingBody), + ], + ), + ); + } return Scaffold( appBar: AppBar( title: Text(title), diff --git a/lib/screens/playlist_screen.dart b/lib/screens/playlist_screen.dart index 905082c9..729aee40 100644 --- a/lib/screens/playlist_screen.dart +++ b/lib/screens/playlist_screen.dart @@ -394,14 +394,8 @@ class _PlaylistScreenState extends ConsumerState Widget _buildTrackList(BuildContext context, ColorScheme colorScheme) { if (_isLoading) { - return SliverToBoxAdapter( - child: Padding( - padding: context.isMornye - ? EdgeInsets.zero - : const EdgeInsets.all(16), - child: const TrackListSkeleton(itemCount: 8), - ), - ); + // The skeleton rows carry the list's own inset. + return const SliverToBoxAdapter(child: TrackListSkeleton(itemCount: 8)); } if (_error != null) { diff --git a/lib/screens/repo_tab.dart b/lib/screens/repo_tab.dart index c57d0a5c..f4e07889 100644 --- a/lib/screens/repo_tab.dart +++ b/lib/screens/repo_tab.dart @@ -209,12 +209,7 @@ class _RepoTabState extends ConsumerState { ), if (isLoading && extensions.isEmpty) - SliverToBoxAdapter( - child: Padding( - padding: EdgeInsets.fromLTRB(edge, 16, edge, 16), - child: const TrackListSkeleton(itemCount: 6), - ), - ) + SliverToBoxAdapter(child: _RepoListSkeleton(edge: edge)) else if (error != null && extensions.isEmpty) SliverFillRemaining(child: _buildErrorState(error, colorScheme)) else if (filteredExtensions.isEmpty) @@ -731,6 +726,101 @@ class _CategoryChip extends ConsumerWidget { } } +/// The store list while the registry loads: real extension rows inside the +/// same grouped surface, with only their contents shimmering. +class _RepoListSkeleton extends StatelessWidget { + const _RepoListSkeleton({required this.edge}); + + /// Horizontal edge of the centered column, as the loaded list uses. + final double edge; + + static const _count = 6; + + @override + Widget build(BuildContext context) { + final tokens = context.tokens; + // Placeholders keep their contrast on the Material group surface. + final surface = context.isMornye ? null : settingsGroupColor(context); + final rows = [ + for (var index = 0; index < _count; index++) + ExtensionRow( + showDivider: index < _count - 1, + avatar: ShimmerLoading( + surface: surface, + child: const SkeletonBox( + width: ExtensionAvatar.size, + height: ExtensionAvatar.size, + borderRadius: 12, + ), + ), + title: ShimmerLoading( + surface: surface, + child: Row( + children: [ + Expanded( + child: Align( + alignment: AlignmentDirectional.centerStart, + child: SkeletonBox( + width: 110 + (index % 3) * 30, + height: 16, + borderRadius: 4, + ), + ), + ), + SizedBox(width: tokens.gapSm), + const SkeletonBox(width: 44, height: 18, borderRadius: 6), + ], + ), + ), + subtitle: ShimmerLoading( + surface: surface, + child: const Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox(height: 4), + SkeletonBox( + width: double.infinity, + height: 12, + borderRadius: 4, + ), + SizedBox(height: 6), + FractionallySizedBox( + widthFactor: 0.6, + child: SkeletonBox( + width: double.infinity, + height: 12, + borderRadius: 4, + ), + ), + ], + ), + ), + trailing: Padding( + padding: EdgeInsets.only(left: tokens.gapMd), + child: ShimmerLoading( + surface: surface, + child: SkeletonBox( + width: 108, + height: tokens.minTouchTarget, + borderRadius: tokens.minTouchTarget / 2, + ), + ), + ), + ), + ]; + if (context.isMornye) { + return ExtensionRepoCard( + margin: EdgeInsets.fromLTRB(edge, 12, edge, 4), + child: Column(mainAxisSize: MainAxisSize.min, children: rows), + ); + } + return Padding( + padding: const EdgeInsets.only(top: 8), + child: SettingsGroup(connected: true, children: rows), + ); + } +} + class _ExtensionItem extends StatelessWidget { final RepoExtension extension; final bool showDivider; diff --git a/lib/widgets/animation_utils.dart b/lib/widgets/animation_utils.dart index f30d6183..c26769fb 100644 --- a/lib/widgets/animation_utils.dart +++ b/lib/widgets/animation_utils.dart @@ -3,6 +3,7 @@ import 'package:spotiflac_android/theme/app_motion.dart'; import 'package:spotiflac_android/theme/app_tokens.dart'; import 'package:spotiflac_android/theme/material_expressive.dart'; import 'package:spotiflac_android/theme/mornye_theme.dart'; +import 'package:spotiflac_android/utils/adaptive_layout.dart'; /// Wraps a child in a staggered fade-in + slide-up animation. /// @@ -109,7 +110,11 @@ Route slidePageRoute({required Widget page}) { class ShimmerLoading extends StatefulWidget { final Widget child; - const ShimmerLoading({super.key, required this.child}); + /// The surface the placeholders sit on; defaults to the page surface. Pass + /// a card or group color so the placeholders keep their contrast on it. + final Color? surface; + + const ShimmerLoading({super.key, required this.child, this.surface}); @override State createState() => _ShimmerLoadingState(); @@ -147,28 +152,19 @@ class _ShimmerLoadingState extends State @override Widget build(BuildContext context) { - if (MediaQuery.disableAnimationsOf(context)) return widget.child; - final colorScheme = Theme.of(context).colorScheme; + final content = widget.surface == null + ? widget.child + : _SkeletonSurface(color: widget.surface!, child: widget.child); + if (MediaQuery.disableAnimationsOf(context)) return content; + final surface = widget.surface ?? Theme.of(context).colorScheme.surface; final isDark = Theme.of(context).brightness == Brightness.dark; final baseColor = isDark - ? Color.alphaBlend( - Colors.white.withValues(alpha: 0.08), - colorScheme.surface, - ) - : Color.alphaBlend( - Colors.black.withValues(alpha: 0.10), - colorScheme.surface, - ); + ? Color.alphaBlend(Colors.white.withValues(alpha: 0.08), surface) + : Color.alphaBlend(Colors.black.withValues(alpha: 0.10), surface); final highlightColor = isDark - ? Color.alphaBlend( - Colors.white.withValues(alpha: 0.14), - colorScheme.surface, - ) - : Color.alphaBlend( - Colors.black.withValues(alpha: 0.01), - colorScheme.surface, - ); + ? Color.alphaBlend(Colors.white.withValues(alpha: 0.14), surface) + : Color.alphaBlend(Colors.black.withValues(alpha: 0.01), surface); return AnimatedBuilder( animation: _controller, @@ -192,11 +188,26 @@ class _ShimmerLoadingState extends State ); }, // The mask moves every frame; the skeleton beneath it does not. - child: RepaintBoundary(child: widget.child), + child: RepaintBoundary(child: content), ); } } +/// The surface a [ShimmerLoading] declared, so still placeholders (reduced +/// motion) keep their contrast on a card or group as well. +class _SkeletonSurface extends InheritedWidget { + const _SkeletonSurface({required this.color, required super.child}); + + final Color color; + + static Color? maybeOf(BuildContext context) => + context.dependOnInheritedWidgetOfExactType<_SkeletonSurface>()?.color; + + @override + bool updateShouldNotify(_SkeletonSurface oldWidget) => + color != oldWidget.color; +} + /// A skeleton placeholder box used inside [ShimmerLoading]. class SkeletonBox extends StatelessWidget { final double width; @@ -212,17 +223,13 @@ class SkeletonBox extends StatelessWidget { @override Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; + final surface = + _SkeletonSurface.maybeOf(context) ?? + Theme.of(context).colorScheme.surface; final isDark = Theme.of(context).brightness == Brightness.dark; final color = isDark - ? Color.alphaBlend( - Colors.white.withValues(alpha: 0.08), - colorScheme.surface, - ) - : Color.alphaBlend( - Colors.black.withValues(alpha: 0.06), - colorScheme.surface, - ); + ? Color.alphaBlend(Colors.white.withValues(alpha: 0.08), surface) + : Color.alphaBlend(Colors.black.withValues(alpha: 0.06), surface); return Container( width: width, height: height, @@ -237,6 +244,8 @@ class SkeletonBox extends StatelessWidget { /// Track list skeleton – mimics a list of track items while loading. class TrackListSkeleton extends StatelessWidget { final int itemCount; + + /// Mornye only; Material loading screens show the real header. final bool showCoverHeader; const TrackListSkeleton({ @@ -270,53 +279,92 @@ class TrackListSkeleton extends StatelessWidget { physics: const NeverScrollableScrollPhysics(), child: Column( children: [ - if (showCoverHeader) const _CollectionHeaderSkeleton(), - ...List.generate(itemCount, (index) { - return Padding( - padding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 8, + for (var index = 0; index < itemCount; index++) + _MaterialTrackSkeleton( + index: index, + leading: const SkeletonBox( + width: 48, + height: 48, + borderRadius: 8, ), - child: Row( + ), + ], + ), + ), + ); + } +} + +/// One Material track row as [TrackListTile] lays it out: a flat card with +/// the shared list inset, leading art or number, two text lines and the +/// preview and options buttons. +class _MaterialTrackSkeleton extends StatelessWidget { + const _MaterialTrackSkeleton({required this.index, required this.leading}); + + final int index; + final Widget leading; + + @override + Widget build(BuildContext context) { + final tokens = context.tokens; + // Flat card margin plus its content padding, centered on wide screens. + final inset = wideListInset(context); + return Padding( + padding: EdgeInsets.fromLTRB( + tokens.gapSm + tokens.gapMd + inset, + 2 + tokens.gapMd, + tokens.gapSm + 6 + inset, + 2 + tokens.gapMd, + ), + child: Row( + children: [ + leading, + SizedBox(width: tokens.gapMd), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + FractionallySizedBox( + alignment: AlignmentDirectional.centerStart, + widthFactor: 0.45 + (index % 3) * 0.1, + child: const SkeletonBox( + width: double.infinity, + height: 16, + borderRadius: 4, + ), + ), + const SizedBox(height: 8), + Row( children: [ - const SkeletonBox(width: 48, height: 48, borderRadius: 8), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SkeletonBox( - width: 150 + (index % 3) * 30, - height: 15, - borderRadius: 4, - ), - const SizedBox(height: 8), - Row( - children: [ - SkeletonBox( - width: 90 + (index % 2) * 20, - height: 12, - borderRadius: 4, - ), - const SizedBox(width: 8), - const SkeletonBox( - width: 38, - height: 12, - borderRadius: 6, - ), - ], - ), - ], + Flexible( + child: SkeletonBox( + width: 80 + (index % 2) * 24, + height: 13, + borderRadius: 4, ), ), const SizedBox(width: 8), - const SkeletonBox(width: 24, height: 24, borderRadius: 12), + const SkeletonBox(width: 38, height: 13, borderRadius: 6), ], ), - ); - }), - ], - ), + ], + ), + ), + SizedBox(width: tokens.gapSm), + // Preview and options icon buttons: 24dp glyphs in 48dp targets. + const SizedBox( + width: 48, + child: Center( + child: SkeletonBox(width: 24, height: 24, borderRadius: 12), + ), + ), + const SizedBox( + width: 48, + child: Center( + child: SkeletonBox(width: 6, height: 20, borderRadius: 3), + ), + ), + ], ), ); } @@ -326,6 +374,8 @@ class TrackListSkeleton extends StatelessWidget { /// (track number + title + artist + trailing icon, no cover art thumbnail). class AlbumTrackListSkeleton extends StatelessWidget { final int itemCount; + + /// Mornye only; Material loading screens show the real header. final bool showCoverHeader; const AlbumTrackListSkeleton({ @@ -358,60 +408,16 @@ class AlbumTrackListSkeleton extends StatelessWidget { physics: const NeverScrollableScrollPhysics(), child: Column( children: [ - if (showCoverHeader) const _CollectionHeaderSkeleton(), - ...List.generate(itemCount, (index) { - return Padding( - padding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 10, + for (var index = 0; index < itemCount; index++) + _MaterialTrackSkeleton( + index: index, + leading: const SizedBox( + width: 32, + child: Center( + child: SkeletonBox(width: 16, height: 14, borderRadius: 4), + ), ), - child: Row( - children: [ - SizedBox( - width: 32, - child: Center( - child: SkeletonBox( - width: 16, - height: 14, - borderRadius: 4, - ), - ), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SkeletonBox( - width: 130 + (index % 4) * 35, - height: 15, - borderRadius: 4, - ), - const SizedBox(height: 8), - Row( - children: [ - SkeletonBox( - width: 70 + (index % 3) * 20, - height: 12, - borderRadius: 4, - ), - const SizedBox(width: 8), - const SkeletonBox( - width: 38, - height: 12, - borderRadius: 6, - ), - ], - ), - ], - ), - ), - const SizedBox(width: 8), - const SkeletonBox(width: 24, height: 24, borderRadius: 12), - ], - ), - ); - }), + ), ], ), ), @@ -419,8 +425,9 @@ class AlbumTrackListSkeleton extends StatelessWidget { } } -/// Mirrors each theme's collection header, including Mornye's full-width -/// square artwork and compact primary action between two circular controls. +/// Mirrors Mornye's collection header: full-width square artwork and a +/// compact primary action between two circular controls. Material loading +/// screens show the real [AlbumDetailHeader] instead. class _CollectionHeaderSkeleton extends StatelessWidget { const _CollectionHeaderSkeleton({this.showSubtitle = true}); @@ -428,131 +435,128 @@ class _CollectionHeaderSkeleton extends StatelessWidget { @override Widget build(BuildContext context) { - if (context.isMornye) { - return LayoutBuilder( - builder: (context, constraints) { - final coverSize = constraints.maxWidth.clamp(0.0, 440.0); - return Stack( - children: [ - Positioned( - top: 0, - left: 0, - right: 0, - child: Center( - child: ShaderMask( - blendMode: BlendMode.dstIn, - shaderCallback: (bounds) => const LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - stops: [0, 0.48, 1], - colors: [Colors.white, Colors.white, Colors.transparent], - ).createShader(bounds), - child: SkeletonBox( - width: coverSize, - height: coverSize, - borderRadius: 0, - ), + return LayoutBuilder( + builder: (context, constraints) { + final coverSize = constraints.maxWidth.clamp(0.0, 440.0); + return Stack( + children: [ + Positioned( + top: 0, + left: 0, + right: 0, + child: Center( + child: ShaderMask( + blendMode: BlendMode.dstIn, + shaderCallback: (bounds) => const LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + stops: [0, 0.48, 1], + colors: [Colors.white, Colors.white, Colors.transparent], + ).createShader(bounds), + child: SkeletonBox( + width: coverSize, + height: coverSize, + borderRadius: 0, ), ), ), - Padding( - padding: EdgeInsets.fromLTRB( - 20, - (coverSize - 20).clamp(0.0, double.infinity), - 20, - 28, - ), - child: Column( - children: [ + ), + Padding( + padding: EdgeInsets.fromLTRB( + 20, + (coverSize - 20).clamp(0.0, double.infinity), + 20, + 28, + ), + child: Column( + children: [ + const FractionallySizedBox( + widthFactor: 0.75, + child: SkeletonBox( + width: double.infinity, + height: 28, + borderRadius: 6, + ), + ), + if (showSubtitle) ...[ + const SizedBox(height: 5), const FractionallySizedBox( - widthFactor: 0.75, + widthFactor: 0.5, child: SkeletonBox( width: double.infinity, - height: 28, - borderRadius: 6, - ), - ), - if (showSubtitle) ...[ - const SizedBox(height: 5), - const FractionallySizedBox( - widthFactor: 0.5, - child: SkeletonBox( - width: double.infinity, - height: 24, - borderRadius: 5, - ), - ), - ], - const SizedBox(height: 5), - const SkeletonBox(width: 140, height: 16, borderRadius: 4), - const SizedBox(height: 16), - ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 460), - child: const Row( - mainAxisAlignment: MainAxisAlignment.center, - spacing: 16, - children: [ - SkeletonBox(width: 44, height: 44, borderRadius: 22), - Flexible( - child: SkeletonBox( - width: 172, - height: 50, - borderRadius: 25, - ), - ), - SkeletonBox(width: 44, height: 44, borderRadius: 22), - ], + height: 24, + borderRadius: 5, ), ), ], - ), + const SizedBox(height: 5), + const SkeletonBox(width: 140, height: 16, borderRadius: 4), + const SizedBox(height: 16), + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 460), + child: const Row( + mainAxisAlignment: MainAxisAlignment.center, + spacing: 16, + children: [ + SkeletonBox(width: 44, height: 44, borderRadius: 22), + Flexible( + child: SkeletonBox( + width: 172, + height: 50, + borderRadius: 25, + ), + ), + SkeletonBox(width: 44, height: 44, borderRadius: 22), + ], + ), + ), + ], ), - ], - ); - }, - ); - } - final screenWidth = MediaQuery.sizeOf(context).width; - final coverSize = (screenWidth * 0.5).clamp(150.0, 210.0).toDouble(); + ), + ], + ); + }, + ); + } +} - return Padding( - padding: const EdgeInsets.fromLTRB(24, 24, 24, 24), - child: Column( +/// Placeholders for the Material collection header's unloaded rows: artist, +/// meta and the action group (two circular buttons around the wide primary +/// action), sized like the real [AlbumDetailHeader] content. +class CollectionHeaderSkeletonRows { + const CollectionHeaderSkeletonRows._(); + + static Widget subtitle() => const ShimmerLoading( + child: SkeletonBox(width: 140, height: 18, borderRadius: 4), + ); + + static Widget meta() => const ShimmerLoading( + child: Row( + mainAxisSize: MainAxisSize.min, + spacing: 10, + children: [ + SkeletonBox(width: 40, height: 14, borderRadius: 6), + SkeletonBox(width: 76, height: 14, borderRadius: 6), + SkeletonBox(width: 84, height: 14, borderRadius: 6), + ], + ), + ); + + static Widget actions(BuildContext context) { + final target = context.tokens.minTouchTarget; + return ShimmerLoading( + child: Row( + spacing: 12, children: [ - SkeletonBox(width: coverSize, height: coverSize, borderRadius: 16), - const SizedBox(height: 20), - SkeletonBox(width: screenWidth * 0.6, height: 22, borderRadius: 6), - const SizedBox(height: 10), - SkeletonBox(width: screenWidth * 0.35, height: 15, borderRadius: 4), - const SizedBox(height: 14), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: const [ - SkeletonBox(width: 44, height: 14, borderRadius: 6), - SizedBox(width: 10), - SkeletonBox(width: 70, height: 14, borderRadius: 6), - SizedBox(width: 10), - SkeletonBox(width: 60, height: 14, borderRadius: 6), - ], + SkeletonBox(width: target, height: target, borderRadius: target / 2), + Expanded( + child: SkeletonBox( + width: double.infinity, + height: target, + borderRadius: target / 2, + ), ), - const SizedBox(height: 24), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const SkeletonBox(width: 48, height: 48, borderRadius: 24), - const SizedBox(width: 16), - Flexible( - child: SkeletonBox( - width: screenWidth * 0.45, - height: 48, - borderRadius: 24, - ), - ), - const SizedBox(width: 16), - const SkeletonBox(width: 48, height: 48, borderRadius: 24), - ], - ), - const SizedBox(height: 16), + SkeletonBox(width: target, height: target, borderRadius: target / 2), ], ), ); @@ -898,52 +902,35 @@ class _MornyeTrackSkeleton extends StatelessWidget { } } -/// Home search skeleton – mimics filter chips + sectioned results -/// (Artists section with rounded card items, Albums section, etc.) +/// Home search skeleton – mimics the sectioned results (an Artists section of +/// round avatars, then an Albums section of covers). The real filter chips +/// stay visible above it, so it draws none of its own. class HomeSearchSkeleton extends StatelessWidget { const HomeSearchSkeleton({super.key}); @override Widget build(BuildContext context) { - return ShimmerLoading( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - physics: const NeverScrollableScrollPhysics(), - child: Row( - children: [ - SkeletonBox(width: 48, height: 32, borderRadius: 16), - const SizedBox(width: 8), - SkeletonBox(width: 64, height: 32, borderRadius: 16), - const SizedBox(width: 8), - SkeletonBox(width: 72, height: 32, borderRadius: 16), - const SizedBox(width: 8), - SkeletonBox(width: 60, height: 32, borderRadius: 16), - const SizedBox(width: 8), - SkeletonBox(width: 70, height: 32, borderRadius: 16), - ], - ), - ), - ), - const SizedBox(height: 8), - _sectionSkeleton(context, 70, 2), - const SizedBox(height: 16), - _sectionSkeleton(context, 65, 4), - ], - ), + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _sectionSkeleton(context, 70, 2, round: true), + const SizedBox(height: 8), + _sectionSkeleton(context, 65, 4, round: false), + ], ); } + /// One section: its header and a grouped list centered like the results. + /// Only the placeholders shimmer; the group keeps the results' surface. static Widget _sectionSkeleton( BuildContext context, double headerWidth, - int itemCount, - ) { + int itemCount, { + required bool round, + }) { final mornye = context.isMornye; + final colorScheme = Theme.of(context).colorScheme; + final inset = wideListInset(context); final rows = List.generate(itemCount, (index) { return Padding( padding: EdgeInsets.symmetric( @@ -952,11 +939,7 @@ class HomeSearchSkeleton extends StatelessWidget { ), child: Row( children: [ - SkeletonBox( - width: mornye ? 56 : 48, - height: mornye ? 56 : 48, - borderRadius: 24, - ), + SkeletonBox(width: 56, height: 56, borderRadius: round ? 28 : 8), const SizedBox(width: 12), Expanded( child: Column( @@ -977,39 +960,56 @@ class HomeSearchSkeleton extends StatelessWidget { ), ), if (!mornye) - const SkeletonBox(width: 20, height: 20, borderRadius: 10), + const SkeletonBox(width: 10, height: 16, borderRadius: 4), ], ), ); }); - final decoration = BoxDecoration( - color: Theme.of( - context, - ).colorScheme.surfaceContainerHighest.withValues(alpha: 0.3), - borderRadius: BorderRadius.circular(mornye ? 24 : 20), - ); + // Material uses the result groups' own section tone; Mornye's glass + // results keep a faint neutral wash. + final groupColor = mornye + ? colorScheme.surfaceContainerHighest.withValues(alpha: 0.3) + : Theme.of(context).brightness == Brightness.dark + ? Color.alphaBlend( + Colors.white.withValues(alpha: 0.08), + colorScheme.surface, + ) + : colorScheme.surfaceContainerHighest; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: Row( - children: [ - SkeletonBox(width: headerWidth, height: 18, borderRadius: 4), - const Spacer(), - const SkeletonBox(width: 50, height: 16, borderRadius: 4), - ], + padding: + (mornye + ? const EdgeInsets.fromLTRB(20, 16, 12, 10) + : const EdgeInsets.fromLTRB(16, 8, 8, 8)) + + EdgeInsets.symmetric(horizontal: inset), + child: ShimmerLoading( + child: Row( + children: [ + SkeletonBox(width: headerWidth, height: 16, borderRadius: 4), + const Spacer(), + const SkeletonBox(width: 50, height: 14, borderRadius: 4), + ], + ), ), ), Container( - margin: const EdgeInsets.symmetric(horizontal: 16), - decoration: decoration, + margin: EdgeInsets.symmetric(horizontal: 16 + inset), + decoration: BoxDecoration( + color: groupColor, + borderRadius: BorderRadius.circular(mornye ? 24 : 20), + ), child: Column( children: [ for (var index = 0; index < rows.length; index++) ...[ - if (mornye && index > 0) - const Divider(height: 1, indent: 80, endIndent: 12), - rows[index], + if (index > 0) + Divider( + height: 1, + indent: mornye ? 80 : 88, + endIndent: mornye ? 12 : 16, + ), + ShimmerLoading(surface: groupColor, child: rows[index]), ], ], ), diff --git a/test/loading_layout_regression_test.dart b/test/loading_layout_regression_test.dart new file mode 100644 index 00000000..9fb563ab --- /dev/null +++ b/test/loading_layout_regression_test.dart @@ -0,0 +1,89 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:spotiflac_android/l10n/l10n.dart'; +import 'package:spotiflac_android/providers/repo_provider.dart'; +import 'package:spotiflac_android/screens/repo_tab.dart'; +import 'package:spotiflac_android/theme/app_theme.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; +import 'package:spotiflac_android/widgets/animation_utils.dart'; +import 'package:spotiflac_android/widgets/extension_row.dart'; + +class _LoadingRepo extends RepoNotifier { + @override + RepoState build() => const RepoState( + isLoading: true, + isInitialized: true, + registryUrl: 'https://example.invalid/registry.json', + ); + + @override + Future initialize(String cacheDir) async {} +} + +void main() { + for (final width in [320.0, 1024.0]) { + for (final brightness in Brightness.values) { + testWidgets('Material loading rows fit at $width in $brightness', ( + tester, + ) async { + tester.view.physicalSize = Size(width, 1200); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + for (final skeleton in const [ + TrackListSkeleton(itemCount: 3), + AlbumTrackListSkeleton(itemCount: 3), + HomeSearchSkeleton(), + ]) { + await tester.pumpWidget( + MaterialApp( + theme: brightness == Brightness.light + ? AppTheme.light() + : AppTheme.dark(), + home: Scaffold(body: skeleton), + ), + ); + await tester.pump(const Duration(milliseconds: 100)); + expect(tester.takeException(), isNull); + } + await tester.pumpWidget(const SizedBox.shrink()); + }); + } + + for (final mornye in [false, true]) { + testWidgets('store skeleton fits at $width with Mornye=$mornye', ( + tester, + ) async { + tester.view.physicalSize = Size(width, 1200); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + const paths = MethodChannel('plugins.flutter.io/path_provider'); + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + messenger.setMockMethodCallHandler( + paths, + (_) async => '/tmp/spotiflac-loading-test', + ); + addTearDown(() => messenger.setMockMethodCallHandler(paths, null)); + await tester.pumpWidget( + ProviderScope( + overrides: [repoProvider.overrideWith(_LoadingRepo.new)], + child: MaterialApp( + theme: mornye + ? MornyeTheme.build(Brightness.dark) + : AppTheme.light(), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: const RepoTab(), + ), + ), + ); + await tester.pump(const Duration(milliseconds: 100)); + expect(find.byType(ExtensionRow), findsWidgets); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox.shrink()); + }); + } + } +}