The app's half of module-rust's read path — the leg R10 says trails the website surface it consumes by one phase, so it is built against routes that exist. Two screens, mirroring what phase 4 shipped: `/rust` is the server list (D12), and one server is a single screen with four tabs (D13) rather than four destinations. Both render entirely from the website's own tables, so the phase criterion — a fleet that is entirely off still shows its maps, seeds, wipe dates, killfeeds, leaderboards and last known presence — holds here for the same reason it holds on the web. What is new to the app rather than copied: - **A poll that is not a load.** `PollWhileResumed` + `refreshInto` (D17): a refresh is invisible when it succeeds and KEEPS the rows when it fails. The app had one shape for a read — blank, ask, replace — which is right for opening a screen and would clear the killfeed three times a minute here. Gated on RESUMED, so a backgrounded app makes no requests at all and returning to it refreshes at once. - **A second game module in the drawer.** `Capability.RUST`, gating one row. It deliberately does not gate on `servers`/`killfeed`/`leaderboard`/`presence`/ `wipes`: those name surfaces, core flattens every module's capabilities into one list, and another module declaring `servers` would reveal these screens on a site with no Rust. Module-Rust#5 adds the identity string. - **`/rust` in NavPaths**, so an admin's nav override or an added link opens natively instead of handing off to a browser (D19). - **A live player count on the drawer row** (D19) — the phone's answer to D15's footer slot, in the same badge slot the inbox count uses, with the same screen-reader treatment. Zero renders nothing; a failed read keeps the last number; it never polls. Two things carried across from the website's own page walk rather than rediscovered: "last reported" reads `lastSeenAt` and never `updatedAt` (a failed poll moves the second), and a feed row from another calendar day carries its date, or a row from a past wipe reads as this afternoon. The four navigation tests that moved did so because APP_MENU gained a row and the website's nav number line gained an index; each now says which. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
111 lines
4.9 KiB
Kotlin
111 lines
4.9 KiB
Kotlin
/*
|
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
|
*/
|
|
package com.runicgateway.app.ui
|
|
|
|
import androidx.compose.runtime.Composable
|
|
import androidx.compose.runtime.LaunchedEffect
|
|
import androidx.compose.runtime.getValue
|
|
import androidx.compose.runtime.rememberUpdatedState
|
|
import androidx.lifecycle.Lifecycle
|
|
import androidx.lifecycle.compose.LocalLifecycleOwner
|
|
import androidx.lifecycle.repeatOnLifecycle
|
|
import com.runicgateway.app.core.result.ApiResult
|
|
import kotlinx.coroutines.delay
|
|
|
|
/**
|
|
* Repeated reads of a surface that changes while somebody is looking at it
|
|
* (`docs/modules/rust/PLAN.md` D14, and D17 for this leg).
|
|
*
|
|
* ## Why a refresh is not a load
|
|
*
|
|
* The app has had exactly one shape for a read until now: set [UiState.Loading],
|
|
* ask, replace. That is right for opening a screen and wrong for a poll — a
|
|
* twenty-second refresh built on it would clear the killfeed, put a spinner where
|
|
* it was and re-fill it, three times a minute, for ever. The website hit the same
|
|
* wall one tier along: core's `useAsync` blanks its data on every dependency
|
|
* change, so `module-rust` bundles its own `usePolled`. This is that hook's other
|
|
* half.
|
|
*
|
|
* The rule both ends keep: **a refresh is invisible when it succeeds, and keeps
|
|
* the rows when it fails.** A site whose whole premise is "it renders while the
|
|
* game is off" must not blank itself the first time a request does.
|
|
*/
|
|
|
|
/** How often a live surface re-reads itself while somebody is looking at it (D17). */
|
|
const val POLL_INTERVAL_MS = 20_000L
|
|
|
|
/**
|
|
* What a poll produced: the state to render, and whether the last attempt failed.
|
|
*
|
|
* Two fields rather than a wider [UiState] because they are two facts and a
|
|
* screen renders them in different places — the rows in the list, the failure as
|
|
* a quiet line above it. Collapsing them would force the choice this exists to
|
|
* avoid: show the error and lose the rows, or keep the rows and say nothing.
|
|
*/
|
|
data class Polled<out T>(
|
|
val state: UiState<T> = UiState.Loading,
|
|
/** True when the most recent refresh failed **and there were rows to keep**. */
|
|
val refreshFailed: Boolean = false,
|
|
)
|
|
|
|
/**
|
|
* Fold a refresh into what is already on screen.
|
|
*
|
|
* Three cases, and the middle one is the whole point:
|
|
*
|
|
* - **It answered.** The new data replaces the old and any previous failure
|
|
* clears. This is the ordinary path and it is silent.
|
|
* - **It failed, and there are rows.** The rows stay exactly as they are and the
|
|
* failure is reported beside them. Nothing is blanked and nothing is retried
|
|
* on the reader's behalf — the next tick is twenty seconds away.
|
|
* - **It failed, and there is nothing yet.** There is nothing to protect, so it
|
|
* becomes an ordinary error with a retry — which is what the first load
|
|
* failing means.
|
|
*
|
|
* Pure, and takes the current state rather than reading one, so the rule is
|
|
* tested without a dispatcher, a view model or Compose.
|
|
*/
|
|
fun <T> refreshInto(current: UiState<T>, result: ApiResult<T>): Polled<T> = when {
|
|
result is ApiResult.Ok -> Polled(UiState.Success(result.data), refreshFailed = false)
|
|
current is UiState.Success -> Polled(current, refreshFailed = true)
|
|
else -> Polled(result.toUiState(), refreshFailed = false)
|
|
}
|
|
|
|
/**
|
|
* Run [block] now and every [intervalMs] for as long as this screen is resumed.
|
|
*
|
|
* `repeatOnLifecycle` is what makes this the phone's version of D14's Page
|
|
* Visibility gate, and it gets three behaviours from one line:
|
|
*
|
|
* - **Nothing runs while the app is away.** The coroutine is cancelled at
|
|
* `onPause`, so a backgrounded app makes no requests at all — not a slower
|
|
* poll, none.
|
|
* - **Coming back refreshes immediately.** The block is restarted from the top
|
|
* at `onResume`, which calls [block] before the first [delay] — so the first
|
|
* thing a returning reader sees is current, not up to twenty seconds old.
|
|
* - **A dialog or the recents switcher pauses it**, because that is what RESUMED
|
|
* means. The alternative, STARTED, keeps polling behind a partially
|
|
* obscured screen, which is precisely the reader who is not reading.
|
|
*
|
|
* **Keyed on the lifecycle owner alone, and [block] is held through
|
|
* `rememberUpdatedState`.** Keying on the block would restart the loop on every
|
|
* recomposition, because a lambda is a new object each time; capturing it without
|
|
* `rememberUpdatedState` would freeze the *first* one, so a tab change or a
|
|
* newly chosen wipe would keep refreshing the question the reader has stopped
|
|
* asking. The loop is stable and what it calls is current.
|
|
*/
|
|
@Composable
|
|
fun PollWhileResumed(intervalMs: Long = POLL_INTERVAL_MS, block: suspend () -> Unit) {
|
|
val lifecycleOwner = LocalLifecycleOwner.current
|
|
val current by rememberUpdatedState(block)
|
|
LaunchedEffect(lifecycleOwner) {
|
|
lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) {
|
|
while (true) {
|
|
current()
|
|
delay(intervalMs)
|
|
}
|
|
}
|
|
}
|
|
}
|