feat(loading): align skeletons with collection and extension layouts

This commit is contained in:
zarzet committed 2026-10-15 19:16:47 +07:00
1 parent 2111afa468
commit 23fe78f30d
8 files changed
+536 -319

No files matched your search

+3 -7
View File
@@ -467,13 +467,9 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen>
),
),
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(
@@ -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<T extends StatefulWidget> on State<T> {
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);
+1
View File
@@ -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';
+42
View File
@@ -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),
+2 -8
View File
@@ -394,14 +394,8 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen>
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) {
+96 -6
View File
@@ -209,12 +209,7 @@ class _RepoTabState extends ConsumerState<RepoTab> {
),
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;
+294 -294
View File
@@ -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<T> slidePageRoute<T>({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<ShimmerLoading> createState() => _ShimmerLoadingState();
@@ -147,28 +152,19 @@ class _ShimmerLoadingState extends State<ShimmerLoading>
@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<ShimmerLoading>
);
},
// 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]),
],
],
),
+89
View File
@@ -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<void> 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());
});
}
}
}