mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-29 23:38:50 +02:00
Opening an extension album/artist showed a bare skeleton, so the cover had no Hero destination and only flew on the way back. Loading states now render the actual header (title + Hero cover in its final slot, skeleton placeholders for the remaining rows) above the shimmering list. SkeletonCrossfade mutes the outgoing child's heroes to avoid duplicate tags if the user pops mid-fade.
839 lines
26 KiB
Dart
839 lines
26 KiB
Dart
import 'package:flutter/material.dart';
|
||
|
||
/// Wraps a child in a staggered fade-in + slide-up animation.
|
||
///
|
||
/// [index] controls the stagger delay (each item delayed by [staggerDelay]).
|
||
/// Set [animate] to false to skip the animation (e.g. when scrolling back).
|
||
class StaggeredListItem extends StatelessWidget {
|
||
static const int _defaultMaxAnimatedItems = 10;
|
||
|
||
final int index;
|
||
final Widget child;
|
||
final Duration duration;
|
||
final Duration staggerDelay;
|
||
final bool animate;
|
||
final int maxAnimatedItems;
|
||
|
||
const StaggeredListItem({
|
||
super.key,
|
||
required this.index,
|
||
required this.child,
|
||
this.duration = const Duration(milliseconds: 250),
|
||
this.staggerDelay = const Duration(milliseconds: 40),
|
||
this.animate = true,
|
||
this.maxAnimatedItems = _defaultMaxAnimatedItems,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
if (!animate || index >= maxAnimatedItems) return child;
|
||
final cappedIndex = index.clamp(0, maxAnimatedItems - 1);
|
||
final delay = staggerDelay * cappedIndex;
|
||
final totalDuration = duration + delay;
|
||
|
||
return TweenAnimationBuilder<double>(
|
||
key: ValueKey('stagger_$index'),
|
||
tween: Tween(begin: 0.0, end: 1.0),
|
||
duration: totalDuration,
|
||
curve: Curves.easeOutCubic,
|
||
builder: (context, value, child) {
|
||
final delayFraction = totalDuration.inMilliseconds > 0
|
||
? delay.inMilliseconds / totalDuration.inMilliseconds
|
||
: 0.0;
|
||
final progress = value <= delayFraction
|
||
? 0.0
|
||
: ((value - delayFraction) / (1.0 - delayFraction)).clamp(0.0, 1.0);
|
||
return Opacity(
|
||
opacity: progress,
|
||
child: Transform.translate(
|
||
offset: Offset(0, 12 * (1 - progress)),
|
||
child: child,
|
||
),
|
||
);
|
||
},
|
||
child: child,
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Creates a platform-aware material route.
|
||
///
|
||
/// This intentionally defers route transitions to Flutter's material route and
|
||
/// theme so Android predictive back and platform-default animations remain
|
||
/// intact.
|
||
Route<T> slidePageRoute<T>({required Widget page}) {
|
||
return MaterialPageRoute<T>(builder: (context) => page);
|
||
}
|
||
|
||
/// A shimmer effect widget that can wrap skeleton placeholders.
|
||
class ShimmerLoading extends StatefulWidget {
|
||
final Widget child;
|
||
|
||
const ShimmerLoading({super.key, required this.child});
|
||
|
||
@override
|
||
State<ShimmerLoading> createState() => _ShimmerLoadingState();
|
||
}
|
||
|
||
class _ShimmerLoadingState extends State<ShimmerLoading>
|
||
with SingleTickerProviderStateMixin {
|
||
late AnimationController _controller;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_controller = AnimationController(
|
||
vsync: this,
|
||
duration: const Duration(milliseconds: 1200),
|
||
)..repeat();
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_controller.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final colorScheme = Theme.of(context).colorScheme;
|
||
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,
|
||
);
|
||
final highlightColor = isDark
|
||
? Color.alphaBlend(
|
||
Colors.white.withValues(alpha: 0.14),
|
||
colorScheme.surface,
|
||
)
|
||
: Color.alphaBlend(
|
||
Colors.black.withValues(alpha: 0.01),
|
||
colorScheme.surface,
|
||
);
|
||
|
||
return AnimatedBuilder(
|
||
animation: _controller,
|
||
builder: (context, child) {
|
||
return ShaderMask(
|
||
shaderCallback: (bounds) {
|
||
return LinearGradient(
|
||
begin: Alignment.centerLeft,
|
||
end: Alignment.centerRight,
|
||
colors: [baseColor, highlightColor, baseColor],
|
||
stops: [
|
||
(_controller.value - 0.3).clamp(0.0, 1.0),
|
||
_controller.value,
|
||
(_controller.value + 0.3).clamp(0.0, 1.0),
|
||
],
|
||
tileMode: TileMode.clamp,
|
||
).createShader(bounds);
|
||
},
|
||
blendMode: BlendMode.srcATop,
|
||
child: child,
|
||
);
|
||
},
|
||
child: widget.child,
|
||
);
|
||
}
|
||
}
|
||
|
||
/// A skeleton placeholder box used inside [ShimmerLoading].
|
||
class SkeletonBox extends StatelessWidget {
|
||
final double width;
|
||
final double height;
|
||
final double borderRadius;
|
||
|
||
const SkeletonBox({
|
||
super.key,
|
||
required this.width,
|
||
required this.height,
|
||
this.borderRadius = 8,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final colorScheme = Theme.of(context).colorScheme;
|
||
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,
|
||
);
|
||
return Container(
|
||
width: width,
|
||
height: height,
|
||
decoration: BoxDecoration(
|
||
color: color,
|
||
borderRadius: BorderRadius.circular(borderRadius),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Track list skeleton – mimics a list of track items while loading.
|
||
class TrackListSkeleton extends StatelessWidget {
|
||
final int itemCount;
|
||
final bool showCoverHeader;
|
||
|
||
const TrackListSkeleton({
|
||
super.key,
|
||
this.itemCount = 8,
|
||
this.showCoverHeader = false,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return ShimmerLoading(
|
||
child: SingleChildScrollView(
|
||
physics: const NeverScrollableScrollPhysics(),
|
||
child: Column(
|
||
children: [
|
||
if (showCoverHeader) const _CollectionHeaderSkeleton(),
|
||
...List.generate(itemCount, (index) {
|
||
return Padding(
|
||
padding: const EdgeInsets.symmetric(
|
||
horizontal: 16,
|
||
vertical: 8,
|
||
),
|
||
child: 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,
|
||
),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
),
|
||
const SizedBox(width: 8),
|
||
const SkeletonBox(width: 24, height: 24, borderRadius: 12),
|
||
],
|
||
),
|
||
);
|
||
}),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Album track list skeleton – mimics the album screen track list layout
|
||
/// (track number + title + artist + trailing icon, no cover art thumbnail).
|
||
class AlbumTrackListSkeleton extends StatelessWidget {
|
||
final int itemCount;
|
||
final bool showCoverHeader;
|
||
|
||
const AlbumTrackListSkeleton({
|
||
super.key,
|
||
this.itemCount = 10,
|
||
this.showCoverHeader = false,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return ShimmerLoading(
|
||
child: SingleChildScrollView(
|
||
physics: const NeverScrollableScrollPhysics(),
|
||
child: Column(
|
||
children: [
|
||
if (showCoverHeader) const _CollectionHeaderSkeleton(),
|
||
...List.generate(itemCount, (index) {
|
||
return Padding(
|
||
padding: const EdgeInsets.symmetric(
|
||
horizontal: 16,
|
||
vertical: 10,
|
||
),
|
||
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),
|
||
],
|
||
),
|
||
);
|
||
}),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Header skeleton matching the redesigned album/playlist header: a blurred
|
||
/// backdrop block with a centered square cover, title/subtitle bars, a meta
|
||
/// line (year + quality badges) and the action button row.
|
||
class _CollectionHeaderSkeleton extends StatelessWidget {
|
||
const _CollectionHeaderSkeleton();
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
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(
|
||
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),
|
||
],
|
||
),
|
||
const SizedBox(height: 24),
|
||
Row(
|
||
mainAxisAlignment: MainAxisAlignment.center,
|
||
children: [
|
||
const SkeletonBox(width: 48, height: 48, borderRadius: 24),
|
||
const SizedBox(width: 16),
|
||
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),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Artist screen skeleton shown below the SliverAppBar header while the
|
||
/// discography loads: optional cover placeholder, "Popular" section, and the
|
||
/// horizontal album sections.
|
||
class ArtistScreenSkeleton extends StatelessWidget {
|
||
final int popularCount;
|
||
final int albumCount;
|
||
final bool showCoverHeader;
|
||
final bool showPopularSection;
|
||
|
||
const ArtistScreenSkeleton({
|
||
super.key,
|
||
this.popularCount = 5,
|
||
this.albumCount = 5,
|
||
this.showCoverHeader = true,
|
||
this.showPopularSection = true,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final screenWidth = MediaQuery.sizeOf(context).width;
|
||
return ShimmerLoading(
|
||
child: SingleChildScrollView(
|
||
physics: const NeverScrollableScrollPhysics(),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
if (showCoverHeader)
|
||
SkeletonBox(
|
||
width: screenWidth,
|
||
height: screenWidth * 0.75,
|
||
borderRadius: 0,
|
||
),
|
||
if (showPopularSection) ...[
|
||
const Padding(
|
||
padding: EdgeInsets.fromLTRB(16, 24, 16, 12),
|
||
child: SkeletonBox(width: 110, height: 22, borderRadius: 4),
|
||
),
|
||
...List.generate(popularCount, (index) {
|
||
return Padding(
|
||
padding: const EdgeInsets.symmetric(
|
||
horizontal: 16,
|
||
vertical: 8,
|
||
),
|
||
child: Row(
|
||
children: [
|
||
const SizedBox(
|
||
width: 24,
|
||
child: Center(
|
||
child: SkeletonBox(
|
||
width: 12,
|
||
height: 14,
|
||
borderRadius: 4,
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(width: 12),
|
||
const SkeletonBox(width: 48, height: 48, borderRadius: 4),
|
||
const SizedBox(width: 12),
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
SkeletonBox(
|
||
width: 120 + (index % 4) * 30,
|
||
height: 14,
|
||
borderRadius: 4,
|
||
),
|
||
const SizedBox(height: 8),
|
||
|
||
const SkeletonBox(
|
||
width: 64,
|
||
height: 14,
|
||
borderRadius: 4,
|
||
),
|
||
],
|
||
),
|
||
),
|
||
const SizedBox(width: 8),
|
||
const SkeletonBox(width: 18, height: 18, borderRadius: 4),
|
||
],
|
||
),
|
||
);
|
||
}),
|
||
const SizedBox(height: 16),
|
||
],
|
||
const Padding(
|
||
padding: EdgeInsets.fromLTRB(16, 8, 16, 12),
|
||
child: SkeletonBox(width: 120, height: 22, borderRadius: 4),
|
||
),
|
||
SizedBox(
|
||
height: 190,
|
||
child: ListView.builder(
|
||
scrollDirection: Axis.horizontal,
|
||
physics: const NeverScrollableScrollPhysics(),
|
||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||
itemCount: albumCount,
|
||
itemBuilder: (context, index) {
|
||
return Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 6),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
const SkeletonBox(width: 140, height: 140),
|
||
const SizedBox(height: 8),
|
||
SkeletonBox(
|
||
width: 80 + (index % 3) * 20,
|
||
height: 12,
|
||
borderRadius: 4,
|
||
),
|
||
const SizedBox(height: 4),
|
||
SkeletonBox(
|
||
width: 50 + (index % 2) * 15,
|
||
height: 10,
|
||
borderRadius: 4,
|
||
),
|
||
],
|
||
),
|
||
);
|
||
},
|
||
),
|
||
),
|
||
const SizedBox(height: 24),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Home search skeleton – mimics filter chips + sectioned results
|
||
/// (Artists section with rounded card items, Albums section, etc.)
|
||
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),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
static Widget _sectionSkeleton(
|
||
BuildContext context,
|
||
double headerWidth,
|
||
int itemCount,
|
||
) {
|
||
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),
|
||
],
|
||
),
|
||
),
|
||
Container(
|
||
margin: const EdgeInsets.symmetric(horizontal: 16),
|
||
decoration: BoxDecoration(
|
||
color: Theme.of(
|
||
context,
|
||
).colorScheme.surfaceContainerHighest.withValues(alpha: 0.3),
|
||
borderRadius: BorderRadius.circular(20),
|
||
),
|
||
child: Column(
|
||
children: List.generate(itemCount, (index) {
|
||
return Padding(
|
||
padding: const EdgeInsets.symmetric(
|
||
horizontal: 16,
|
||
vertical: 10,
|
||
),
|
||
child: Row(
|
||
children: [
|
||
const SkeletonBox(width: 48, height: 48, borderRadius: 24),
|
||
const SizedBox(width: 12),
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
SkeletonBox(
|
||
width: 100 + (index % 3) * 40,
|
||
height: 14,
|
||
borderRadius: 4,
|
||
),
|
||
const SizedBox(height: 6),
|
||
SkeletonBox(
|
||
width: 60 + (index % 2) * 25,
|
||
height: 12,
|
||
borderRadius: 4,
|
||
),
|
||
],
|
||
),
|
||
),
|
||
const SkeletonBox(width: 20, height: 20, borderRadius: 10),
|
||
],
|
||
),
|
||
);
|
||
}),
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Crossfades when the child's runtime type changes — used to swap a loading
|
||
/// skeleton for the loaded screen without a single-frame hard cut.
|
||
class SkeletonCrossfade extends StatelessWidget {
|
||
final Widget child;
|
||
|
||
const SkeletonCrossfade({super.key, required this.child});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return AnimatedSwitcher(
|
||
duration: const Duration(milliseconds: 250),
|
||
// Loading and loaded screens may carry the same Hero tag (both render
|
||
// the header cover); mute the outgoing child's heroes so a pop during
|
||
// the fade doesn't find duplicate tags.
|
||
transitionBuilder: (child, animation) => FadeTransition(
|
||
opacity: animation,
|
||
child: AnimatedBuilder(
|
||
animation: animation,
|
||
child: child,
|
||
builder: (context, child) => HeroMode(
|
||
enabled:
|
||
animation.status == AnimationStatus.forward ||
|
||
animation.status == AnimationStatus.completed,
|
||
child: child!,
|
||
),
|
||
),
|
||
),
|
||
child: child,
|
||
);
|
||
}
|
||
}
|
||
|
||
/// An animated selection indicator that scales in/out and crossfades the
|
||
/// checked/unchecked state.
|
||
class AnimatedSelectionCheckbox extends StatelessWidget {
|
||
final bool visible;
|
||
final bool selected;
|
||
final ColorScheme colorScheme;
|
||
final double size;
|
||
|
||
/// Background color when not selected. Defaults to `Colors.transparent`.
|
||
final Color? unselectedColor;
|
||
|
||
const AnimatedSelectionCheckbox({
|
||
super.key,
|
||
required this.visible,
|
||
required this.selected,
|
||
required this.colorScheme,
|
||
this.size = 20,
|
||
this.unselectedColor,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return AnimatedScale(
|
||
scale: visible ? 1.0 : 0.0,
|
||
duration: const Duration(milliseconds: 200),
|
||
curve: Curves.easeOutBack,
|
||
child: AnimatedContainer(
|
||
duration: const Duration(milliseconds: 200),
|
||
width: size,
|
||
height: size,
|
||
decoration: BoxDecoration(
|
||
color: selected
|
||
? colorScheme.primary
|
||
: unselectedColor ?? Colors.transparent,
|
||
shape: BoxShape.circle,
|
||
border: Border.all(
|
||
color: selected ? colorScheme.primary : colorScheme.outline,
|
||
width: 2,
|
||
),
|
||
),
|
||
child: AnimatedSwitcher(
|
||
duration: const Duration(milliseconds: 150),
|
||
child: selected
|
||
? Icon(
|
||
Icons.check,
|
||
key: const ValueKey('checked'),
|
||
size: size - 6,
|
||
color: colorScheme.onPrimary,
|
||
)
|
||
: SizedBox(
|
||
key: const ValueKey('unchecked'),
|
||
width: size - 6,
|
||
height: size - 6,
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// A widget that briefly flashes a success color behind its child and shows
|
||
/// an animated checkmark when [showSuccess] transitions to true.
|
||
class DownloadSuccessOverlay extends StatefulWidget {
|
||
final bool showSuccess;
|
||
final Widget child;
|
||
|
||
const DownloadSuccessOverlay({
|
||
super.key,
|
||
required this.showSuccess,
|
||
required this.child,
|
||
});
|
||
|
||
@override
|
||
State<DownloadSuccessOverlay> createState() => _DownloadSuccessOverlayState();
|
||
}
|
||
|
||
class _DownloadSuccessOverlayState extends State<DownloadSuccessOverlay>
|
||
with SingleTickerProviderStateMixin {
|
||
late AnimationController _controller;
|
||
late Animation<double> _flashAnimation;
|
||
late bool _wasSuccess;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_wasSuccess = widget.showSuccess;
|
||
_controller = AnimationController(
|
||
vsync: this,
|
||
duration: const Duration(milliseconds: 600),
|
||
);
|
||
_flashAnimation = TweenSequence<double>([
|
||
TweenSequenceItem(tween: Tween(begin: 0.0, end: 0.15), weight: 30),
|
||
TweenSequenceItem(tween: Tween(begin: 0.15, end: 0.0), weight: 70),
|
||
]).animate(_controller);
|
||
}
|
||
|
||
@override
|
||
void didUpdateWidget(DownloadSuccessOverlay oldWidget) {
|
||
super.didUpdateWidget(oldWidget);
|
||
if (widget.showSuccess && !_wasSuccess) {
|
||
_controller.forward(from: 0);
|
||
}
|
||
_wasSuccess = widget.showSuccess;
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_controller.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return AnimatedBuilder(
|
||
animation: _controller,
|
||
builder: (context, child) {
|
||
return Container(
|
||
decoration: BoxDecoration(
|
||
color: Colors.green.withValues(alpha: _flashAnimation.value),
|
||
borderRadius: BorderRadius.circular(12),
|
||
),
|
||
child: child,
|
||
);
|
||
},
|
||
child: widget.child,
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Wraps a [Badge] child and plays a brief scale-bump whenever [count] changes.
|
||
class AnimatedBadge extends StatefulWidget {
|
||
final int count;
|
||
final Widget child;
|
||
|
||
const AnimatedBadge({super.key, required this.count, required this.child});
|
||
|
||
@override
|
||
State<AnimatedBadge> createState() => _AnimatedBadgeState();
|
||
}
|
||
|
||
class _AnimatedBadgeState extends State<AnimatedBadge>
|
||
with SingleTickerProviderStateMixin {
|
||
late AnimationController _controller;
|
||
late Animation<double> _scaleAnimation;
|
||
int _previousCount = 0;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_previousCount = widget.count;
|
||
_controller = AnimationController(
|
||
vsync: this,
|
||
duration: const Duration(milliseconds: 300),
|
||
);
|
||
_scaleAnimation = TweenSequence<double>([
|
||
TweenSequenceItem(tween: Tween(begin: 1.0, end: 1.3), weight: 40),
|
||
TweenSequenceItem(tween: Tween(begin: 1.3, end: 1.0), weight: 60),
|
||
]).animate(_controller);
|
||
}
|
||
|
||
@override
|
||
void didUpdateWidget(AnimatedBadge oldWidget) {
|
||
super.didUpdateWidget(oldWidget);
|
||
if (widget.count != _previousCount && widget.count > _previousCount) {
|
||
_controller.forward(from: 0);
|
||
}
|
||
_previousCount = widget.count;
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_controller.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return ScaleTransition(scale: _scaleAnimation, child: widget.child);
|
||
}
|
||
}
|