What changed
What you'll notice
Loading now looks the same everywhere in the app, and the screen stops jumping around while it loads.
Before, almost every part of the app drew its own "loading" look. Some places pulsed, some sat as flat grey blocks that never moved, one showed a spinner, and several showed nothing at all until their data arrived. Worse, those placeholders rarely matched what replaced them, so the home feed shuffled and resettled as each section finished loading.
Now every loading state is the same shimmer, and the placeholder is the real layout — same cards, same heights — so when your data lands nothing moves. If you were mid-round when you last closed the app, the "Resume" banner is already there when you open it, instead of dropping in a moment later and pushing everything down.
Worth checking on device: cold-launch the dashboard on a slow connection. Every rail should shimmer in the same way, nothing should shift vertically as sections fill in, and pull-to-refresh should not grey out content that's already on screen.
What changed, technically
Adopts skeletonizer, which paints bones over the *real* widget tree rendered with placeholder data. The skeleton is then the real layout by construction, which removes the whole class of layout-shift bugs rather than patching each one.
Foundation
SkeletonSection<T>(lib/shared/widgets/skeletons/skeleton_section.dart) — the canonical pattern. One builder renders both states, so they cannot drift apart. Collapses with an animation when data resolves empty or errors, and owns its own bottom spacing so a hidden section leaves no dangling gap. Deliberately does *not* re-skeletonise a refresh that still holds a previous value.- One app-wide
SkeletonizerConfigDataonAppTheme.light.ignoreContainers: trueis load-bearing: rail cards are whiteContainers with a border, and without it Skeletonizer paints them solid grey, so the card chrome would materialise on load — the exact jump this removes. driverAwareThemeswaps in aSolidColorEffectunderkDriverMode.ShimmerEffectdrives its own repeating controller, which is the "frame scheduler never settles" problemrepeatUnlessDriverexists to solve, but that controller is internal to the package so the extension can't reach it.
Layout shifts fixed
TribeFeedSection's skeleton had no card chrome at all, while the loaded state is a white card with border and shadow.- Three
railHeightmismatches: connected-tribes 170 → 176, friendlies 176 → 160, andgames_around_me's 160/208/156/160, which also read its provider withref.readinside a layout getter while the children usedref.watch— so the height could be a frame behind the cards it was sizing. CourseInsightsSectionnested.whenover four providers withSizedBox.shrink()sub-rails, growing a section at a time. Now one skeleton for the whole card.- The fixed spacers between home sections were siblings, so they survived a hidden section and left dangling 16px gaps. Each section now owns its bottom gap.
Banner caching
The three conditional banners (active game, recent round, setup welcome) can't be skeletonised — most users have none of them, so a placeholder would flash for everyone. HomeBannerCacheNotifier reads shared_preferences synchronously in build(), so the last-known banner is on screen for the very first frame and nothing moves when the RPC agrees.
- Cached rounds carry a timestamp and expire after 8h — a round is a few hours of golf, and confidently offering "Resume" for one that ended yesterday is worse than a beat's delay.
- Only resolved values write to the cache, so a flaky network can't wipe a good banner.
- The cache stores only what each card draws (four strings / two fields / four booleans), and the cards were narrowed to those projections — which is what makes a cached banner render identically to a live one.
Coverage
All 12 dashboard home rails, the four dashboard tabs, the four tribe rails, member and ranking lists, the leaderboard boards, Fairdrop, the course picker, the contacts sheet and the conversations list.
On the tournament side that means every board: the six that share LeaderboardSkeleton (birdies, skins, battles and the live view's two), plus the individual and team boards on leaderboard_screen, which showed a mascot LoadingState and now skeletonise the real LeaderboardTable / TeamLeaderboardTable. LeaderboardSkeleton would have been the wrong tool there — it mirrors the bare zebra rows of the birdies/skins boards, while these are bordered cards with a header row. The unpaid-tournaments sheet and the battle player picker went the same way; in the picker the section label and search field stay live and usable above the skeleton rows, since neither needs data to work. The shared primitives in skeleton_loader.dart are rebuilt on skeletonizer, so their remaining callers inherit the same shimmer untouched; SkeletonPulse is renamed SkeletonGroup since it no longer owns an animation.
Also deletes four dashboard files nothing imports (hero_section, closing_soon_rail, recommended_courses_rail, performance_stats_section) — about 1,000 lines carrying four more skeletons.
Tests
Assert the guarantee directly rather than the markup: loading and loaded measure the same height, for SkeletonSection and for real rails. Plus banner-cache coverage — warm cache draws on frame one at an unchanged height, a stale cache is ignored, a finished round collapses away, and corrupt JSON is a cache miss rather than a launch crash.
leaderboard_table_skeleton_test measures the same table skeletonised and loaded. That isn't tautological: a text Bone is sized from the painted text, so a config change (ignoreContainers off, a different text bone radius) can resize rows — which is what it catches.
Known remainder
19 files still show a spinner where content will appear — mostly sheets and screens (tribe settings, schedule create, inter-tribe pairing, message thread, GPS panel). Each needs its own placeholder model and none is a list with an obvious shape, so they're better done alongside work on those screens than in a sweep. (An earlier commit message in this branch estimated "~12"; the count was 21 before the tournament sheets above, 19 now.)
LoadingState and in-button spinners are unchanged by design — the convention now recorded in CLAUDE.md is skeletonize content, spin for actions.