64db364834
Plumbing for the per-item rendering pass — no UI changes yet, just the layers the home/playlist/liked migrations will sit on. * CachedHomeIndex drift table (schema 5→6) — section/position → entity-id rows, populated by the upcoming /api/home/index endpoint. * HydrationQueue (concurrency=4, in-flight dedup) — bounded request pump that takes (entityType, entityId) and persists the result to the right cached_<entity> table. Albums + artists wired today; tracks deferred until /api/tracks/:id exists. * Per-entity tile providers (albumTileProvider, artistTileProvider, trackTileProvider as StreamProvider.family) — watch drift, enqueue hydration on miss, yield AsyncValue<EntityRef?>. * Skeleton widgets (album/artist/track) matched to the real card dimensions with a 1.2s shimmer sweep using FabledSword tokens. No shimmer-package dep — single AnimationController per surface. See docs/superpowers/specs/2026-05-13-per-item-rendering-design.md for the full architecture rationale.
200 lines
6.1 KiB
Dart
200 lines
6.1 KiB
Dart
// 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<FabledSwordTheme>()!;
|
|
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<FabledSwordTheme>()!;
|
|
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<FabledSwordTheme>()!;
|
|
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<FabledSwordTheme>()!;
|
|
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),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|