// Skeleton placeholder widgets for the per-item rendering architecture // (see docs/superpowers/specs/2026-05-13-per-item-rendering-design.md). // // Each skeleton matches the exact dimensions of its corresponding real // card so the layout doesn't shift when content lands. A subtle // shimmer sweep makes the page feel alive while individual tiles // hydrate against drift / REST in the background. // // Self-contained shimmer (no `shimmer` package dep) so we can keep // pubspec lean and tune the sweep colors against FabledSword tokens. import 'package:flutter/material.dart'; import '../../theme/theme_extension.dart'; /// Period of one full shimmer sweep across the skeleton. const Duration _shimmerPeriod = Duration(milliseconds: 1200); /// Wraps a child with a slow-moving highlight band on top of the /// skeleton's base color. Cheap — uses a single AnimationController /// per surface and a LinearGradient shader. class _Shimmer extends StatefulWidget { const _Shimmer({required this.child}); final Widget child; @override State<_Shimmer> createState() => _ShimmerState(); } class _ShimmerState extends State<_Shimmer> with SingleTickerProviderStateMixin { late final AnimationController _ctrl = AnimationController( vsync: this, duration: _shimmerPeriod, )..repeat(); @override void dispose() { _ctrl.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final fs = Theme.of(context).extension()!; return AnimatedBuilder( animation: _ctrl, builder: (context, child) { return ShaderMask( blendMode: BlendMode.srcATop, shaderCallback: (rect) { // Sweep starts off-screen left, ends off-screen right. final dx = (_ctrl.value * 2 - 1) * rect.width; return LinearGradient( begin: Alignment.centerLeft, end: Alignment.centerRight, colors: [ fs.slate, fs.iron, fs.slate, ], stops: const [0.0, 0.5, 1.0], transform: _SlideGradient(dx), ).createShader(rect); }, child: child, ); }, child: widget.child, ); } } /// Helper for translating a gradient horizontally inside its rect. class _SlideGradient extends GradientTransform { const _SlideGradient(this.dx); final double dx; @override Matrix4 transform(Rect bounds, {TextDirection? textDirection}) { return Matrix4.translationValues(dx, 0, 0); } } /// Skeleton matched to AlbumCard's 140px outer width / 124px cover / /// title + artist text rows. Used in horizontal carousels where the /// real card lives. class SkeletonAlbumTile extends StatelessWidget { const SkeletonAlbumTile({super.key, this.width = 140}); final double width; @override Widget build(BuildContext context) { final fs = Theme.of(context).extension()!; final coverSize = width - 16; return _Shimmer( child: SizedBox( width: width, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 8), child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ ClipRRect( borderRadius: BorderRadius.circular(6), child: Container( width: coverSize, height: coverSize, color: fs.slate, ), ), const SizedBox(height: 8), Container(width: coverSize * 0.8, height: 14, color: fs.slate), const SizedBox(height: 4), Container(width: coverSize * 0.6, height: 12, color: fs.slate), ], ), ), ), ); } } /// Skeleton matched to ArtistCard's 140px / 124px round-thumb shape. class SkeletonArtistTile extends StatelessWidget { const SkeletonArtistTile({super.key, this.width = 140}); final double width; @override Widget build(BuildContext context) { final fs = Theme.of(context).extension()!; final coverSize = width - 16; return _Shimmer( child: SizedBox( width: width, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 8), child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Container( width: coverSize, height: coverSize, decoration: BoxDecoration( color: fs.slate, shape: BoxShape.circle, ), ), const SizedBox(height: 8), Container(width: coverSize * 0.7, height: 14, color: fs.slate), ], ), ), ), ); } } /// Skeleton matched to TrackRow's vertical-list layout. 56dp cover + /// title + artist line, similar to the real row. class SkeletonTrackRow extends StatelessWidget { const SkeletonTrackRow({super.key}); @override Widget build(BuildContext context) { final fs = Theme.of(context).extension()!; return _Shimmer( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), child: Row( children: [ Container( width: 56, height: 56, decoration: BoxDecoration( color: fs.slate, borderRadius: BorderRadius.circular(6), ), ), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Container(width: 180, height: 14, color: fs.slate), const SizedBox(height: 6), Container(width: 120, height: 12, color: fs.slate), ], ), ), ], ), ), ); } }