feat(flutter): per-item rendering infrastructure (Slice A)
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.
This commit is contained in:
Vendored
+30
-1
@@ -142,6 +142,27 @@ class CachedHistorySnapshot extends Table {
|
|||||||
Set<Column> get primaryKey => {id};
|
Set<Column> get primaryKey => {id};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Section→position→entity-id index for the home screen, populated
|
||||||
|
/// from the per-item discovery endpoint `/api/home/index`. Each row
|
||||||
|
/// pins one tile slot to an entity; the actual entity data lives in
|
||||||
|
/// cached_albums / cached_artists / cached_tracks. The home screen
|
||||||
|
/// reads this table to know the layout, then hydrates each tile
|
||||||
|
/// against the entity tables (per-item rendering). Schema 6+.
|
||||||
|
class CachedHomeIndex extends Table {
|
||||||
|
/// One of: 'recently_added_albums', 'rediscover_albums',
|
||||||
|
/// 'rediscover_artists', 'most_played_tracks', 'last_played_artists'.
|
||||||
|
/// Mirrors the keys /api/home and /api/home/index emit.
|
||||||
|
TextColumn get section => text()();
|
||||||
|
IntColumn get position => integer()();
|
||||||
|
/// One of: 'album', 'artist', 'track'. Used to dispatch hydration
|
||||||
|
/// to the right per-entity endpoint.
|
||||||
|
TextColumn get entityType => text()();
|
||||||
|
TextColumn get entityId => text()();
|
||||||
|
DateTimeColumn get fetchedAt => dateTime().withDefault(currentDateAndTime)();
|
||||||
|
@override
|
||||||
|
Set<Column> get primaryKey => {section, position};
|
||||||
|
}
|
||||||
|
|
||||||
/// Caller-scoped quarantine ("hidden") rows. Mirrors the wire shape of
|
/// Caller-scoped quarantine ("hidden") rows. Mirrors the wire shape of
|
||||||
/// /api/quarantine/mine — fully denormalized (track + album + artist
|
/// /api/quarantine/mine — fully denormalized (track + album + artist
|
||||||
/// fields inline) because the Hidden tab renders straight from this row
|
/// fields inline) because the Hidden tab renders straight from this row
|
||||||
@@ -179,12 +200,13 @@ enum CacheSource { manual, autoLiked, autoPlaylist, autoPrefetch, incidental }
|
|||||||
CachedHomeSnapshot,
|
CachedHomeSnapshot,
|
||||||
CachedHistorySnapshot,
|
CachedHistorySnapshot,
|
||||||
CachedQuarantineMine,
|
CachedQuarantineMine,
|
||||||
|
CachedHomeIndex,
|
||||||
])
|
])
|
||||||
class AppDb extends _$AppDb {
|
class AppDb extends _$AppDb {
|
||||||
AppDb([QueryExecutor? e]) : super(e ?? driftDatabase(name: 'minstrel_cache'));
|
AppDb([QueryExecutor? e]) : super(e ?? driftDatabase(name: 'minstrel_cache'));
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get schemaVersion => 5;
|
int get schemaVersion => 6;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
MigrationStrategy get migration => MigrationStrategy(
|
MigrationStrategy get migration => MigrationStrategy(
|
||||||
@@ -218,6 +240,13 @@ class AppDb extends _$AppDb {
|
|||||||
// populates. Optimistic flag/unflag also writes here.
|
// populates. Optimistic flag/unflag also writes here.
|
||||||
await m.createTable(cachedQuarantineMine);
|
await m.createTable(cachedQuarantineMine);
|
||||||
}
|
}
|
||||||
|
if (from < 6) {
|
||||||
|
// Schema 6: cached_home_index for per-item rendering.
|
||||||
|
// Empty on upgrade; first /api/home/index fetch populates.
|
||||||
|
// cached_home_snapshot stays in place for now — older
|
||||||
|
// builds still read from it as a fallback path.
|
||||||
|
await m.createTable(cachedHomeIndex);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+137
@@ -0,0 +1,137 @@
|
|||||||
|
// Per-item hydration coordinator for the home/playlist/liked surfaces.
|
||||||
|
//
|
||||||
|
// Tile widgets are reactive to their per-entity drift row. On first
|
||||||
|
// build, if drift has no row for the requested id, the tile asks this
|
||||||
|
// queue to hydrate it. The queue dispatches one /api/<type>/:id
|
||||||
|
// request, writes the result to drift, and the drift watch() on the
|
||||||
|
// tile re-emits with the populated row.
|
||||||
|
//
|
||||||
|
// Concurrency cap: HydrationQueue limits the number of in-flight
|
||||||
|
// requests so a 50-tile home screen doesn't fire 50 parallel /api/
|
||||||
|
// calls and stall the auth-token-bound dio pool. In-flight dedup
|
||||||
|
// (keyed by `<type>:<id>`) ensures a single hydration when multiple
|
||||||
|
// tiles share an entity (rare on home, common when several screens
|
||||||
|
// reference the same album).
|
||||||
|
//
|
||||||
|
// Failures are swallowed — a single failed hydration leaves the tile
|
||||||
|
// in skeleton state. A future retry-on-visit pass can layer on top
|
||||||
|
// without changing the queue's contract.
|
||||||
|
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:collection';
|
||||||
|
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../api/endpoints/library.dart';
|
||||||
|
import '../cache/adapters.dart';
|
||||||
|
import '../cache/audio_cache_manager.dart' show appDbProvider;
|
||||||
|
import '../cache/db.dart';
|
||||||
|
import '../library/library_providers.dart' show libraryApiProvider;
|
||||||
|
|
||||||
|
/// Bounded-concurrency request queue for per-entity hydration. One
|
||||||
|
/// instance per Riverpod scope (provider singleton below).
|
||||||
|
class HydrationQueue {
|
||||||
|
HydrationQueue(this._ref);
|
||||||
|
final Ref _ref;
|
||||||
|
|
||||||
|
/// Concurrent slot count. 4 keeps the dio token pool from saturating
|
||||||
|
/// while still pulling tiles down faster than a serial loop. Bump
|
||||||
|
/// if profiling shows the queue idle while the network is healthy.
|
||||||
|
static const _maxConcurrent = 4;
|
||||||
|
|
||||||
|
final Queue<_HydrationRequest> _pending = Queue();
|
||||||
|
final Set<String> _inFlightKeys = {};
|
||||||
|
int _inFlight = 0;
|
||||||
|
|
||||||
|
/// Request hydration for (entityType, entityId). Idempotent — second
|
||||||
|
/// call for the same key while the first is queued or in flight is
|
||||||
|
/// dropped, so multiple tile rebuilds during a fast scroll don't
|
||||||
|
/// inflate the queue.
|
||||||
|
void enqueue({required String entityType, required String entityId}) {
|
||||||
|
if (entityId.isEmpty) return;
|
||||||
|
final key = '$entityType:$entityId';
|
||||||
|
if (_inFlightKeys.contains(key)) return;
|
||||||
|
if (_pending.any((r) => r.key == key)) return;
|
||||||
|
_pending.add(_HydrationRequest(
|
||||||
|
entityType: entityType, entityId: entityId, key: key));
|
||||||
|
_pump();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _pump() {
|
||||||
|
while (_inFlight < _maxConcurrent && _pending.isNotEmpty) {
|
||||||
|
final req = _pending.removeFirst();
|
||||||
|
_inFlightKeys.add(req.key);
|
||||||
|
_inFlight++;
|
||||||
|
// Unawaited on purpose — _pump returns immediately so further
|
||||||
|
// enqueues can fill remaining slots while this one runs.
|
||||||
|
unawaited(_execute(req).whenComplete(() {
|
||||||
|
_inFlight--;
|
||||||
|
_inFlightKeys.remove(req.key);
|
||||||
|
_pump();
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _execute(_HydrationRequest req) async {
|
||||||
|
try {
|
||||||
|
switch (req.entityType) {
|
||||||
|
case 'album':
|
||||||
|
await _hydrateAlbum(req.entityId);
|
||||||
|
case 'artist':
|
||||||
|
await _hydrateArtist(req.entityId);
|
||||||
|
case 'track':
|
||||||
|
// TODO(per-item slice B): GET /api/tracks/:id doesn't exist
|
||||||
|
// yet; tracks remain bulk-loaded until that endpoint lands.
|
||||||
|
// Tile providers for tracks read drift but never enqueue.
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// Swallow — tile stays in skeleton. A retry-on-visit pass can
|
||||||
|
// layer on top later without changing this contract.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _hydrateAlbum(String id) async {
|
||||||
|
final api = await _ref.read(libraryApiProvider.future);
|
||||||
|
final result = await api.getAlbum(id);
|
||||||
|
final db = _ref.read(appDbProvider);
|
||||||
|
// Album + tracks come bundled in the same response; persist both
|
||||||
|
// so opening the album detail later is also a drift hit.
|
||||||
|
await db.transaction(() async {
|
||||||
|
await db
|
||||||
|
.into(db.cachedAlbums)
|
||||||
|
.insertOnConflictUpdate(result.album.toDrift());
|
||||||
|
if (result.tracks.isNotEmpty) {
|
||||||
|
await db.batch((b) {
|
||||||
|
b.insertAllOnConflictUpdate(
|
||||||
|
db.cachedTracks, result.tracks.map((t) => t.toDrift()).toList());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _hydrateArtist(String id) async {
|
||||||
|
final api = await _ref.read(libraryApiProvider.future);
|
||||||
|
final fresh = await api.getArtist(id);
|
||||||
|
final db = _ref.read(appDbProvider);
|
||||||
|
await db.into(db.cachedArtists).insertOnConflictUpdate(fresh.toDrift());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _HydrationRequest {
|
||||||
|
_HydrationRequest({
|
||||||
|
required this.entityType,
|
||||||
|
required this.entityId,
|
||||||
|
required this.key,
|
||||||
|
});
|
||||||
|
final String entityType;
|
||||||
|
final String entityId;
|
||||||
|
final String key;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Singleton queue scoped to the Riverpod container. Kept as a plain
|
||||||
|
/// Provider (not StateProvider) since the queue's internal state is
|
||||||
|
/// pump-driven, not reactively observed.
|
||||||
|
final hydrationQueueProvider = Provider<HydrationQueue>((ref) {
|
||||||
|
return HydrationQueue(ref);
|
||||||
|
});
|
||||||
+124
@@ -0,0 +1,124 @@
|
|||||||
|
// Per-entity tile providers for the per-item rendering architecture
|
||||||
|
// (see docs/superpowers/specs/2026-05-13-per-item-rendering-design.md).
|
||||||
|
//
|
||||||
|
// Each provider is a StreamProvider.family<EntityRef?, String> keyed
|
||||||
|
// by entity id. The stream:
|
||||||
|
// 1. Watches the entity's drift row
|
||||||
|
// 2. Yields the populated row on every emission
|
||||||
|
// 3. Yields null while the row is absent (UI shows skeleton)
|
||||||
|
// 4. On the first missing-row emission, enqueues a hydration via
|
||||||
|
// HydrationQueue so the row eventually lands
|
||||||
|
//
|
||||||
|
// The tile widget reacts to AsyncValue<EntityRef?>:
|
||||||
|
// - loading or data:null → skeleton
|
||||||
|
// - data:non-null → real card
|
||||||
|
// - error → error placeholder
|
||||||
|
//
|
||||||
|
// Subscriptions are per-tile. A 50-tile home screen creates 50 drift
|
||||||
|
// subscriptions; drift handles this fine in practice but worth
|
||||||
|
// measuring if a future surface scales to hundreds.
|
||||||
|
|
||||||
|
import 'package:drift/drift.dart' show leftOuterJoin;
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../cache/adapters.dart';
|
||||||
|
import '../cache/audio_cache_manager.dart' show appDbProvider;
|
||||||
|
import '../cache/db.dart';
|
||||||
|
import '../models/album.dart';
|
||||||
|
import '../models/artist.dart';
|
||||||
|
import '../models/track.dart';
|
||||||
|
import 'hydration_queue.dart';
|
||||||
|
|
||||||
|
/// Watches the cached_albums row for [id]. Triggers background
|
||||||
|
/// hydration on miss; yields the row once it lands.
|
||||||
|
final albumTileProvider =
|
||||||
|
StreamProvider.family<AlbumRef?, String>((ref, id) async* {
|
||||||
|
if (id.isEmpty) {
|
||||||
|
yield null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final db = ref.watch(appDbProvider);
|
||||||
|
final query = (db.select(db.cachedAlbums)..where((t) => t.id.equals(id)))
|
||||||
|
.join([
|
||||||
|
leftOuterJoin(db.cachedArtists,
|
||||||
|
db.cachedArtists.id.equalsExp(db.cachedAlbums.artistId)),
|
||||||
|
]);
|
||||||
|
var enqueued = false;
|
||||||
|
await for (final rows in query.watch()) {
|
||||||
|
if (rows.isEmpty) {
|
||||||
|
if (!enqueued) {
|
||||||
|
enqueued = true;
|
||||||
|
ref
|
||||||
|
.read(hydrationQueueProvider)
|
||||||
|
.enqueue(entityType: 'album', entityId: id);
|
||||||
|
}
|
||||||
|
yield null;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
final r = rows.first;
|
||||||
|
final album = r.readTable(db.cachedAlbums);
|
||||||
|
final artist = r.readTableOrNull(db.cachedArtists);
|
||||||
|
yield album.toRef(artistName: artist?.name ?? '');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Watches the cached_artists row for [id]. Triggers background
|
||||||
|
/// hydration on miss; yields the row once it lands.
|
||||||
|
final artistTileProvider =
|
||||||
|
StreamProvider.family<ArtistRef?, String>((ref, id) async* {
|
||||||
|
if (id.isEmpty) {
|
||||||
|
yield null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final db = ref.watch(appDbProvider);
|
||||||
|
final query = db.select(db.cachedArtists)..where((t) => t.id.equals(id));
|
||||||
|
var enqueued = false;
|
||||||
|
await for (final rows in query.watch()) {
|
||||||
|
if (rows.isEmpty) {
|
||||||
|
if (!enqueued) {
|
||||||
|
enqueued = true;
|
||||||
|
ref
|
||||||
|
.read(hydrationQueueProvider)
|
||||||
|
.enqueue(entityType: 'artist', entityId: id);
|
||||||
|
}
|
||||||
|
yield null;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
yield rows.first.toRef();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Watches the cached_tracks row for [id]. No hydration today — see
|
||||||
|
/// hydration_queue.dart's `case 'track':` note. The tile renders a
|
||||||
|
/// skeleton while drift is empty; once sync populates the row (or a
|
||||||
|
/// future GET /api/tracks/:id endpoint enables real hydration), the
|
||||||
|
/// stream emits the populated TrackRef.
|
||||||
|
final trackTileProvider =
|
||||||
|
StreamProvider.family<TrackRef?, String>((ref, id) async* {
|
||||||
|
if (id.isEmpty) {
|
||||||
|
yield null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final db = ref.watch(appDbProvider);
|
||||||
|
final query = (db.select(db.cachedTracks)..where((t) => t.id.equals(id)))
|
||||||
|
.join([
|
||||||
|
leftOuterJoin(db.cachedArtists,
|
||||||
|
db.cachedArtists.id.equalsExp(db.cachedTracks.artistId)),
|
||||||
|
leftOuterJoin(db.cachedAlbums,
|
||||||
|
db.cachedAlbums.id.equalsExp(db.cachedTracks.albumId)),
|
||||||
|
]);
|
||||||
|
await for (final rows in query.watch()) {
|
||||||
|
if (rows.isEmpty) {
|
||||||
|
yield null;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
final r = rows.first;
|
||||||
|
final t = r.readTable(db.cachedTracks);
|
||||||
|
final a = r.readTableOrNull(db.cachedArtists);
|
||||||
|
final al = r.readTableOrNull(db.cachedAlbums);
|
||||||
|
yield t.toRef(
|
||||||
|
artistName: a?.name ?? '',
|
||||||
|
albumTitle: al?.title ?? '',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
// 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),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user