Loading, error and refresh states
How a screen says “still loading”, “it broke”, and “let me try again”.
Before this existed, 76 files dropped a raw CircularProgressIndicator inline, WorkoutState
used isError/errorMessage while FinanceState used error: String?, most ViewModels stored
e.message and threw away the typed failure the API layer had already produced, and no screen
had a consistent full-screen error with a retry. Desktop and web had no way to refresh at all.
Everything below is enforced by scripts/check_loading_states.sh (wired into
verifyAndroidQuality as checkLoadingStates), whose baseline is now empty.
The state model
One sealed type, in ui/state/UiState.kt:
sealed interface UiState<out T> {
data object Loading : UiState<Nothing>
data class Success<T>(
val data: T,
val isRefreshing: Boolean = false,
val refreshError: RemoteFailure? = null,
) : UiState<T>
data class Error(val failure: RemoteFailure) : UiState<Nothing>
}The invariant that makes everything else mechanical:
Errormeans the screen is empty. Anything with data on it isSuccess.
Success carries refreshError precisely so a failed refresh keeps the stale data on screen
instead of blanking it. A three-case type cannot express that, and blanking a workout mid-session
because a background poll failed is not acceptable.
| Situation | State |
|---|---|
| First load, nothing yet | Loading |
| First load failed, no data | Error(failure) |
| Data present | Success(data) |
| Refresh in flight | Success(data, isRefreshing = true) |
| Refresh failed, data preserved | Success(data, refreshError = failure) |
UiState<T> wraps one independently-loadable slice, never a whole screen. A screen with N
independent loads holds N UiState fields; transient UI (search text, selected tab, dialog
visibility) sits beside them as plain fields. FinanceState has six slices, so a failing Budgets
tab leaves the Accounts tab beside it untouched. WorkoutState has one, plus a separate
actionError for failures that are actions rather than loads.
Helpers so ViewModels never re-derive the transitions: reduce(outcome), toRefreshing(),
settled(), dataOrNull(), isRefreshing().
Which primitive, when
This is the whole taxonomy. The guard bans CircularProgressIndicator in screens because the
right answer is almost never “a spinner”.
| The situation | Use | Why |
|---|---|---|
| First load, nothing on screen | OterSkeletonList / OterSkeletonCard / OterSkeletonText | Reserves the shape the content will occupy, so nothing jumps when it arrives |
| Refresh over existing content | OterRefreshBox (gesture) + OterRefreshButton (desktop/web) | The content stays; the indicator says work is happening |
| In-place action of unknown duration — a submitting button, a row awaiting a mutation | OterInlineSpinner | There is no content shape to stand in for, so a skeleton would be a lie |
| Determinate progress | OterProgressRing / OterProgressBar | It’s not a load at all |
| Load failed, nothing to show | ApiErrorState(failure, onRetry) | Full-screen, localized, with an honest retry |
| Load failed, stale content still valid | OterBanner over the content | Never blank what the user was reading |
| Nothing to show, but nothing failed | OterEmptyState | Predates this work; unchanged |
RestTimerBar is the case worth remembering: it was
CircularProgressIndicator(progress = progress, …) — the workout rest countdown, not a load.
A blanket find-and-replace would have turned a progress ring into a spinner. Read the call site.
Skeletons are shaped, and they can be per-slice
A skeleton exists to reserve the geometry the real content will take, so nothing jumps when it
lands. OterSkeletonList is the right default for a list; it is the wrong answer for a page with
its own layout. DashboardScreen has a private DashboardSkeleton that mirrors its hero, KPI strip
and bento grid — column count included, so the grid doesn’t reflow on arrival.
If a screen’s slices load independently, shimmer them independently. The dashboard fetches nine
widgets in nine requests; its isLoading is any { it is Loading }, so one slow widget used to
hold every other card behind a full-screen skeleton. It now also exposes
loadingWidgets: StateFlow<Set<String>>, and each card renders either itself or an
OterSkeletonCard in its own grid cell. A slow Finance widget shimmers alone while Tasks, Habits,
Nutrition, Workout and Journal are already usable.
Which failure gets which surface
Encoded once in api/ApiErrorCodes.kt as isRetryable() / isSessionExpired() /
isConnectivity(). The state case already tells you the surface; the code refines whether a retry
is honest.
ApiErrorCodes | First load (UiState.Error) | Refresh failure (Success.refreshError) | Retry? |
|---|---|---|---|
CLIENT_NETWORK, CLIENT_TIMEOUT | Full-screen ApiErrorState | Global snackbar only — no banner | Yes |
SERVER_ERROR, INTERNAL_ERROR, UNKNOWN, CLIENT_ERROR_UNREADABLE | Full-screen ApiErrorState | Inline OterBanner | Yes |
NOT_FOUND | Full-screen, not-found copy | n/a | No |
FORBIDDEN | Full-screen | Banner | No |
UNAUTHORIZED | Renders nothing | Nothing | No |
BAD_REQUEST, INVALID_* | Full-screen | Banner | No |
Two rules that are easy to get wrong:
A 401 renders nothing. ApiErrorState returns early. The token refresh in HttpClientConfig
and SessionExpiredNotifier own that path, and a user-driven retry would race the in-flight
refresh.
Connectivity failures never show a banner over stale content. The global network snackbar
already reported them; a banner would be the same news twice. ApiErrorState does show
full-screen for a first-load connectivity failure, and claims it via ScreenErrorOwnership so
the snackbar stands down. The snackbar waits SCREEN_CLAIM_WINDOW_MS (350ms) before firing,
because the API funnel reports a failure the instant the request fails — before the ViewModel
writes its state and a frame before the screen composes.
Action errors are not load errors. A set that wouldn’t save, a track that wouldn’t delete: those
go to actionError and a snackbar. They must never blank the screen. Workout had ~16 of these
conflated with load failures before this work.
Refresh: gesture on touch, button on desktop
One hoisted state object feeds two sibling affordances. The screen owns both, so nothing needs a
CompositionLocal:
val refresh = rememberOterRefreshState(
isRefreshing = state.isLoading && state.items.isNotEmpty(),
onRefresh = viewModel::refresh,
)
OterPageScaffold(title = "…", toolbar = { OterRefreshButton(refresh) }) {
OterRefreshBox(refresh) { /* the list */ }
}OterRefreshBox uses Material3 PullToRefreshBox on touch and is a transparent passthrough
elsewhere. OterRefreshButton renders nothing on touch, so a screen can place it
unconditionally.
The branch is prefersPullToRefresh() — not isDesktop(), whose wasm actual is false, which
would wrongly enable the gesture in a browser:
| Target | prefersPullToRefresh() | Affordance |
|---|---|---|
| Android, iOS | true | pull gesture |
| Desktop (JVM), wasm | false | toolbar button |
isRefreshing must be wired, or the gesture looks broken
isRefreshing = state.isLoading && state.items.isNotEmpty()A load with nothing on screen is a first load (skeleton). A load with items already visible is a
refresh (indicator). Tasks shipped for months with this hardcoded to false: the gesture fired,
the data reloaded, and PullToRefreshBox retracted the indicator the instant the finger lifted,
so it read as “nothing happened”.
Refresh coverage, and why
Refresh is not a blanket feature. It belongs on a screen that lists server data the user might expect to be stale. It does not belong on a form, a dialog, or a screen whose data was handed to it by its parent.
Has it (10)
TasksScreen · WorkScreen · HabitsScreen · BooksScreen · MealDetailScreen ·
NutritionScreen (three tabs, each with its own retry) · StudyScreen (three tabs) ·
DashboardScreen · WorkoutScreen (per view) · FinanceScreen (per tab)
DashboardScreen is the reference for a screen with no page toolbar: the refresh button is a
TopEnd-aligned overlay inside the OterRefreshBox, which costs no layout on touch because
OterRefreshButton renders nothing there.
WorkoutScreen and FinanceScreen are the reference for a screen with several views or tabs: the
refresh action reloads only the visible one (onDaySelected / onLoadWeeklyPlan /
onGetAllExercises; getBudgets / getAccounts / …), and the toolbar button reflects that
view’s own slice, so it spins only while that view is reloading.
settled()is not optional.updateContent {}and its equivalents preserveisRefreshingon purpose — they exist for enrichments that land during a refresh. When a success path is the end of a refresh, it has to settle explicitly.loadWeeklyPlandidn’t, and the Week tab’s indicator spun forever once a pull could start one. Pinned byWorkoutStateTest.
Deliberately excluded
Study’s History tab. HistoryTab owns its own filtered load — date range, topic, mode — and
that filter state lives inside the composable. A blind reload would silently drop the user’s
filters. Fixing it means hoisting the filter state, not adding a wrapper.
Forms, dialogs and wizards — RecipeEditScreen, StudySessionFormScreen, NewEdit*,
TransactionImportScreen, ReceiptScanScreen, the Work*Dialog family. There is nothing to
refresh; the user is editing.
Auth — LoginScreen, SignUpScreen, AuthScreen, ForgotEmailScreen,
ForgotNewPasswordScreen. No remote list. These use OterInlineSpinner in their submit buttons.
Local-only state — placeholders, ComponentGalleryScreen.
Not wired yet, and why not
These load server data and plausibly should have refresh. They don’t, because a refresh needs a reload callback plumbed from the ViewModel to the screen, and each is per-screen work rather than a wrapper. Listed so nobody has to rediscover them:
Lists — NotificationsScreen · CalendarScreen · StatisticsScreen ·
ProjectsScreen · JournalScreen · WorkTicketsScreen · WorkClientsScreen ·
WorkStoreScreen · WorkStatsScreen · WorkoutHistoryScreen (desktop) ·
DesktopBooksScreen · DesktopProjectsScreen · DesktopTagsScreen · TagsScreen ·
TimersScreen
Detail screens that load their own data — TaskDetailScreen, HabitDetailScreen,
ProjectDetailScreen, WorkTicketDetailScreen, SearchRecordDetailScreens. Each does a
LaunchedEffect(id) { … } of its own, so refresh is meaningful here, exactly as it is on
MealDetailScreen (which does have it). They are only unwired, not excluded.
Of these, the Work family (WorkTicketsScreen, WorkClientsScreen, WorkStoreScreen,
WorkStatsScreen) are the cheapest: each already has a state.isLoading and a load callback, and
they share one shape.
Unverified on a device.
prefersPullToRefresh()isfalseon the JVM, so no JVM test harness can reachPullToRefreshBoxthroughOterRefreshBox— the mechanism was verified by drivingPullToRefreshBoxdirectly with a realswipeDown. The seven wired screens have not been pulled on Android or iOS hardware.
Typed failures, and the two places they’re still lossy
ApiOutcome / RemoteFailure / HttpFailureMapper already produce typed error codes. Two
boundaries still discard them:
Services that throw. About half the services predate ApiOutcome and throw
(TaskService → TaskServiceException). apiOutcomeOf { … } maps them via
HttpFailureMapper.fromThrowable, which walks the cause chain and reads a real Ktor
ResponseException’s status when there is one. Accurate — but the fix belongs upstream: those
services should return ApiOutcome via safeApiCall.
Six Finance source ViewModels store error: String?. FinanceState is an aggregate
assembled from them, so financeSlice(data, isLoading, error) has to synthesise a RemoteFailure
with an UNKNOWN code, keeping the original text in debugMessage. Users get correct generic copy
and a working retry; the specific server code doesn’t survive. When those ViewModels adopt
UiState, financeSlice collapses into a plain forward.
Both are documented at the call sites. Neither is a reason to reach for a raw spinner.
The guard
scripts/check_loading_states.sh — report-only by default, --strict in CI.
Scope: shared/…/ui/screens/**, composeApp/…/desktopMain/…/ui/screens/**, and every
*_presentation source tree. ui/components/primitives/** is exempt: it is the kit, and it’s
where the spinner legitimately lives.
Its baseline (config/loading-states-guard-baseline.txt) was seeded with the 39 screens that
predated the kit and is now empty. A new entry is a regression to fix, not something to record.
The legacy ui/components/Loading.kt and ui/components/Error.kt were deleted once it emptied.
Error.kt never had a single caller.
See also
- tokens.md — the
Oter.*token reconciliation - ../architecture/design-system-conventions.md — consumption rules
ui/screens/ComponentGalleryScreen.kt— every primitive rendered in both themes