Files
Android-app/app/src/main/java/com/runicgateway/app/ui/Polling.kt
wtclaude a6b6c92c33 feat(rust): the Rust server list and one server's page (phase 5, Android leg A)
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
2026-09-16 22:16:21 -05:00

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)
}
}
}
}