Skip to content

Fix cache-miss spinner and animate screen state changes - #28

Open
solcott wants to merge 3 commits into
mainfrom
cache-miss-spinner
Open

Fix cache-miss spinner and animate screen state changes#28
solcott wants to merge 3 commits into
mainfrom
cache-miss-spinner

Conversation

@solcott

@solcott solcott commented Sep 13, 2026

Copy link
Copy Markdown
Owner

Summary

Three commits:

  • 33d6e70: show a spinner, not an empty list, while a never-fetched key loads. Room returns [] for a key it has never fetched, and Store emits that before the fetch starts. Every refreshing repository stream now calls asOutcomes(fetching = refresh) { … }, which holds back that first empty read until the fetch answers. As a backstop, foldToState checks hasAnswer instead of hasLoaded.
  • 48d68d5: shared ContentState fold and animated states. RecipeDetailsState/RecipeDetailsEvent now use the same Loading / Error / Success shape as the other screens, and every screen animates between states.
  • 5d428ff: fixes for three bugs in 48d68d5.
    • The single-item foldToState passed hasAnswer a non-null check where it expects an "is empty" one. A cached recipe dropped to a full-screen spinner as soon as a refresh started. Any recipe that loaded as null (for example an unknown id from a deep link) hit checkNotNull and crashed the presenter.
    • The favorite button on the details screen did nothing, because its remembered click handler captured the recipe from the first composition, which is always null.
    • AnimatedContent(state) treated every new Success as new content, so a favorite toggle or a finished refresh reset grid and scroll positions to the top. The new AnimatedStateContent keys on the state's class: a change of type animates, and a new state of the same type updates in place.
    • Also: RefreshingContent lays the refresh bar over the content instead of pushing it down, LoadingDisplay sits next to ErrorDisplay, and the three tab screens lose their duplicated wrappers.

Testing

  • New test: RecipeDetailsPresenterTest has four cases. The three that cover the bugs fail against 48d68d5; the retry case, which was never broken, passes. All domain tests pass now.
  • Checks: ktfmtFormat sortDependencies and detektAll are clean. :domain:build :ui:build compiles for all six targets locally, including iOS, which CI skips on Linux.
  • Desktop app: on a recipe's details page I scrolled down and toggled favorite. Positions and node ids stayed the same, meaning the screen updated in place. Favorites updated on both the add and the remove.

Dependency

This needs the dataresult snapshot from solcott/kmp-dataresult#5, which merged on 2026-09-12. CI pulls it from GitHub Packages, so the build only resolves once that repo's snapshot has been published.

🤖 Generated with Claude Code

solcott and others added 3 commits September 12, 2026 16:35
A Room-backed Store source of truth returns [] for a key it has never
fetched, and Store emits that before the fetch starts. asOutcomes()
passed it on as data and foldToState counted it as loaded, so a first
visit rendered "No recipes found" under a refresh bar until the
network answered -- and kept rendering it, instead of the error and
Retry, if the network failed. Categories, Areas and Ingredients share
foldToState and had the same problem.

Every refreshing repository stream now calls
asOutcomes(fetching = refresh) { ... } inside its flatMapLatest, so
the adapter can hold that first empty read back until the fetch
answers. getFavoritesAsFlow is left alone: its request never
refreshes, and there is no fetcher to wait on.

foldToState checks hasAnswer instead of hasLoaded, as a backstop: an
empty cached value while its request is in flight or has failed is
not an answer. Idle always is, so a legitimately empty tab still
settles rather than spinning.

Side effect: AreaRepository.countryFor now waits for the areas fetch
on a fresh install instead of reading null from the empty cache.

Needs the kmp-dataresult snapshot from solcott/kmp-dataresult#5. CI
resolves it from GitHub Packages, so this stays red until that PR
merges and publish-snapshot runs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* **Domain**:
    * Added a `foldToState` extension on `ContentState` to standardize mapping the loaded/loading/error matrix into three explicit UI states (Loading, Error, Success).
    * Refactored `RecipeDetailsState` and `RecipeDetailsEvent` into sealed interfaces matching the other screens, replacing the boolean `loading` and `error` flags.
* **UI**:
    * Wrapped the top-level state evaluation blocks in all five screens (`AreasScreen`, `CategoriesScreen`, `IngredientsScreen`, `RecipeDetailsScreen`, and `RecipesScreen`) with `AnimatedContent` to crossfade between loading, error, and success states.
    * Added a `LinearProgressIndicator` to `RecipeDetailsScreen` and `RecipesScreen`, wrapped in `AnimatedVisibility`, to display background refreshes (`isRefreshing`) without dropping the currently displayed data.
    * Centered `ErrorDisplay` contents vertically and horizontally.

Signed-off-by: Scott Olcott <scottolcott@gmail.com>
48d68d5 moved RecipeDetailsPresenter onto foldToState and animated
every screen's state changes, and shipped three bugs with it.

The single-item foldToState handed hasAnswer a non-null test where it
takes an isEmpty one. A cached recipe therefore counted as a cache
miss the moment a refresh started and dropped to a full-screen
spinner, and any settled null -- an id the API does not know, reached
by a deep link -- hit checkNotNull and threw in the presenter. A
non-null item is always an answer, so the fold now reads the item
directly: an item is content (refreshing while a request is in
flight), none while loading is Loading, none once settled is Error.

The details success sink was remembered along with the recipe it
closed over, which on the first composition is always null, so the
favorite button did nothing. It now reads the recipe through
rememberUpdatedState, and retryTrigger is retained like every other
presenter's.

AnimatedContent(state) keyed each state on itself, so every new
Success -- a favorite toggled, a refresh finishing -- animated into a
freshly composed subtree and reset grid and scroll positions to the
top. AnimatedStateContent keys on the state's class instead: a change
of type animates, a same-type update recomposes in place.

Also: RefreshingContent lays the refresh bar over the content instead
of pushing it down, LoadingDisplay sits beside ErrorDisplay, the three
tab screens drop their duplicated wrappers, and each screen applies
its modifier at the root. RecipeDetailsPresenterTest covers the three
bugs; three of its four cases fail against the previous code.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Scott Olcott <scottolcott@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant