mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-20 22:41:38 +02:00
feat(loading): align skeletons with collection and extension layouts
This commit is contained in:
1 parent
2111afa468
commit
23fe78f30d
8 files changed
+536
-319
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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]),
|
||||
],
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user