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
This commit is contained in:
94
app/src/main/java/com/runicgateway/app/data/api/RustApi.kt
Normal file
94
app/src/main/java/com/runicgateway/app/data/api/RustApi.kt
Normal file
@@ -0,0 +1,94 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.data.api
|
||||
|
||||
import com.runicgateway.app.data.api.dto.RustEventListDto
|
||||
import com.runicgateway.app.data.api.dto.RustLeaderboardDto
|
||||
import com.runicgateway.app.data.api.dto.RustOnlineDto
|
||||
import com.runicgateway.app.data.api.dto.RustServerListDto
|
||||
import com.runicgateway.app.data.api.dto.RustServerResponse
|
||||
import com.runicgateway.app.data.api.dto.RustWipeListDto
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.Path
|
||||
import retrofit2.http.Query
|
||||
|
||||
/**
|
||||
* `module-rust`'s public read path (PLAN.md §9 M14; `docs/modules/rust/PLAN.md`
|
||||
* §17).
|
||||
*
|
||||
* **These paths are hardcoded, and that is the contract rather than a shortcut.**
|
||||
* `MODULE_API.md` §2.9 forbids a client inferring a route from a capability, so
|
||||
* the app cannot build `/<module id>/servers` from what `GET /public/modules`
|
||||
* reports. A capability answers one question — *is the module there* — and these
|
||||
* five addresses are knowledge the app has because someone read the module's
|
||||
* router, exactly as the nine `/uo/` paths in [NavPaths] are.
|
||||
*
|
||||
* Its own interface, not a section of [PublicApi], for the reason [EventsApi] is
|
||||
* its own: these exist only where the Rust module is installed, and a backend
|
||||
* running a different game answers none of them.
|
||||
*/
|
||||
interface RustApi {
|
||||
|
||||
/**
|
||||
* Every Rust server this site follows.
|
||||
*
|
||||
* Answers from the module's own tables and never from a live call to a game
|
||||
* host, so it succeeds while every server in the fleet is off — a server
|
||||
* nobody can reach comes back `online: false, stale: true` with everything it
|
||||
* last said still attached. There is no failure case here for the game being
|
||||
* down, only for the website being down.
|
||||
*/
|
||||
@GET("api/v1/public/rust/servers")
|
||||
suspend fun getServers(): RustServerListDto
|
||||
|
||||
/**
|
||||
* One server, or a **404**.
|
||||
*
|
||||
* The only route under `/servers/{id}` that can say a server is not there:
|
||||
* the four below answer an empty list for an id nobody configured, because an
|
||||
* unknown server genuinely has no events and nobody online. A server an
|
||||
* operator **disabled** answers the same 404 — switching one off is not
|
||||
* switching it into a refusal.
|
||||
*/
|
||||
@GET("api/v1/public/rust/servers/{id}")
|
||||
suspend fun getServer(@Path("id") id: String): RustServerResponse
|
||||
|
||||
/**
|
||||
* The feed, newest first.
|
||||
*
|
||||
* [kind] is comma-separated and [wipe] a wipe id; both are optional, and an
|
||||
* **absent one must be absent rather than empty** — `?wipe=` asks for a wipe
|
||||
* whose id is the empty string and answers nothing, with no error to notice.
|
||||
* Retrofit drops a null `@Query` entirely, which is why these are nullable
|
||||
* and never defaulted to `""`.
|
||||
*
|
||||
* The server serves a default-deny allowlist: moderation events, login
|
||||
* attempts and anything carrying an IP address are stored and never returned
|
||||
* here, whatever is asked for.
|
||||
*/
|
||||
@GET("api/v1/public/rust/servers/{id}/events")
|
||||
suspend fun getEvents(
|
||||
@Path("id") id: String,
|
||||
@Query("kind") kind: String? = null,
|
||||
@Query("wipe") wipe: String? = null,
|
||||
@Query("limit") limit: Int? = null,
|
||||
): RustEventListDto
|
||||
|
||||
/** Per wipe when [wipe] is given, all-time otherwise — the same rows summed. */
|
||||
@GET("api/v1/public/rust/servers/{id}/leaderboard")
|
||||
suspend fun getLeaderboard(
|
||||
@Path("id") id: String,
|
||||
@Query("wipe") wipe: String? = null,
|
||||
@Query("sort") sort: String? = null,
|
||||
@Query("limit") limit: Int? = null,
|
||||
): RustLeaderboardDto
|
||||
|
||||
/** Every wipe this server has had, newest first. */
|
||||
@GET("api/v1/public/rust/servers/{id}/wipes")
|
||||
suspend fun getWipes(@Path("id") id: String): RustWipeListDto
|
||||
|
||||
/** The presence board, which an unreachable server does not clear. */
|
||||
@GET("api/v1/public/rust/servers/{id}/online")
|
||||
suspend fun getOnline(@Path("id") id: String): RustOnlineDto
|
||||
}
|
||||
196
app/src/main/java/com/runicgateway/app/data/api/dto/RustDto.kt
Normal file
196
app/src/main/java/com/runicgateway/app/data/api/dto/RustDto.kt
Normal file
@@ -0,0 +1,196 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.data.api.dto
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
|
||||
/**
|
||||
* DTOs for `module-rust`'s public read path (`docs/modules/rust/PLAN.md` §17,
|
||||
* §18; M14).
|
||||
*
|
||||
* **Every one of these renders while the game is off**, which is the module's own
|
||||
* promise and therefore this leg's: the website never calls a game server from a
|
||||
* page, it answers from its own tables, and a server nobody can reach answers
|
||||
* `online: false` with everything it last said still attached. Nothing here has
|
||||
* an "unavailable" shape, because there is no such answer on this wire.
|
||||
*/
|
||||
|
||||
/** `GET /public/rust/servers` — every server this site follows. */
|
||||
@Serializable
|
||||
data class RustServerListDto(
|
||||
val servers: List<RustServerDto> = emptyList(),
|
||||
)
|
||||
|
||||
/** `GET /public/rust/servers/{id}` — one of them, or a 404. */
|
||||
@Serializable
|
||||
data class RustServerResponse(
|
||||
val server: RustServerDto = RustServerDto(),
|
||||
)
|
||||
|
||||
/**
|
||||
* One Rust server and what it last reported.
|
||||
*
|
||||
* **[online] and [stale] are not the same fact and the screen needs both.**
|
||||
* `online` is what the last frame said; `stale` is whether anything has arrived
|
||||
* recently enough to believe it. The server computes `online` as *"the row says
|
||||
* up AND the row is fresh"*, so a stale row can never claim a server is up — but
|
||||
* `stale` still has to come through, because a fresh row saying "down" and a row
|
||||
* nobody has written in an hour are different things to say to a reader.
|
||||
*
|
||||
* **[lastSeenAt] is what a page means by "last reported", and [updatedAt] is
|
||||
* not.** The module shipped a defect on exactly this in phase 3 and fixed it in
|
||||
* phase 4: `updatedAt` moves on every poll including a FAILED one, so reading it
|
||||
* as "last reported" made an offline server claim it had just checked in, every
|
||||
* thirty seconds, for as long as it stayed down. Only a frame moves
|
||||
* `lastSeenAt`. The app must not repeat the mistake one tier along.
|
||||
*/
|
||||
@Serializable
|
||||
data class RustServerDto(
|
||||
val id: String = "",
|
||||
val name: String = "",
|
||||
val online: Boolean = false,
|
||||
val players: Int = 0,
|
||||
val maxPlayers: Int = 0,
|
||||
val hostname: String? = null,
|
||||
val level: String? = null,
|
||||
val worldSize: Int? = null,
|
||||
val seed: Long? = null,
|
||||
/** The CURRENT wipe, from the state row rather than the newest ingested wipe. */
|
||||
val wipeId: String? = null,
|
||||
val wipedAt: String? = null,
|
||||
/** When a frame last arrived. What "last reported" means. */
|
||||
val lastSeenAt: String? = null,
|
||||
/** When this module last wrote the row — a failed poll moves it too. */
|
||||
val updatedAt: String? = null,
|
||||
val stale: Boolean = false,
|
||||
)
|
||||
|
||||
/** `GET /public/rust/servers/{id}/events` — the killfeed and everything else public. */
|
||||
@Serializable
|
||||
data class RustEventListDto(
|
||||
val events: List<RustEventDto> = emptyList(),
|
||||
)
|
||||
|
||||
/**
|
||||
* One stored frame.
|
||||
*
|
||||
* **[frame] is deliberately untyped.** The module stores the whole frame the
|
||||
* bridge plugin emitted and indexes only the columns it serves, so the fields
|
||||
* differ per [kind] and a later protocol adds more. A sealed hierarchy here would
|
||||
* have to be extended in this repo before a server running a newer plugin could
|
||||
* say anything new, and the module's own rule is the opposite: an unknown kind
|
||||
* renders as itself rather than being dropped. [RustFeed] is the one place that
|
||||
* knows the field names.
|
||||
*
|
||||
* [t] is epoch milliseconds — the stamp the plugin put on the frame, not a
|
||||
* database column, so it is a number here and an ISO string everywhere else on
|
||||
* this wire.
|
||||
*/
|
||||
@Serializable
|
||||
data class RustEventDto(
|
||||
val id: Long = 0,
|
||||
val kind: String = "",
|
||||
val t: Long = 0,
|
||||
val wipeId: String? = null,
|
||||
val steamId: String? = null,
|
||||
val frame: JsonObject = JsonObject(emptyMap()),
|
||||
) {
|
||||
/**
|
||||
* One frame field as text, or null.
|
||||
*
|
||||
* **A JSON `null` answers null, not the four letters.** The plugin writes
|
||||
* explicit nulls — `reason` on a clean disconnect, `weapon` on a fall — and a
|
||||
* primitive's `content` is the string `"null"` for every one of them, which
|
||||
* would put the word into a killfeed line. An empty string answers null too:
|
||||
* the callers here all mean "is there something to show".
|
||||
*/
|
||||
fun str(key: String): String? = primitive(key)?.content?.takeIf { it.isNotEmpty() }
|
||||
|
||||
/** One frame field as a number, or null when it is absent, null or not one. */
|
||||
fun num(key: String): Double? = primitive(key)?.content?.toDoubleOrNull()
|
||||
|
||||
/** One frame field as a flag. Absent, null and anything non-boolean are all false. */
|
||||
fun flag(key: String): Boolean = primitive(key)?.content == "true"
|
||||
|
||||
/** The raw element, for a caller that wants to decide for itself. */
|
||||
fun raw(key: String): JsonElement? = frame[key]
|
||||
|
||||
private fun primitive(key: String): JsonPrimitive? =
|
||||
(frame[key] as? JsonPrimitive)?.takeIf { it !is JsonNull }
|
||||
}
|
||||
|
||||
/** `GET /public/rust/servers/{id}/leaderboard` — per wipe, or all-time. */
|
||||
@Serializable
|
||||
data class RustLeaderboardDto(
|
||||
val leaderboard: List<RustLeaderboardRowDto> = emptyList(),
|
||||
)
|
||||
|
||||
/**
|
||||
* One player's standing.
|
||||
*
|
||||
* All-time is these same per-wipe rows summed rather than a second set of
|
||||
* counters, so the two can never disagree — which is why a player who appears
|
||||
* only in an older wipe **drops out** of the current one rather than reading
|
||||
* zero. The screen must not fill that gap in with zeroes.
|
||||
*/
|
||||
@Serializable
|
||||
data class RustLeaderboardRowDto(
|
||||
val steamId: String = "",
|
||||
val name: String? = null,
|
||||
val kills: Int = 0,
|
||||
val deaths: Int = 0,
|
||||
val npcKills: Int = 0,
|
||||
val structures: Int = 0,
|
||||
val playtimeSec: Long = 0,
|
||||
val lastSeen: String? = null,
|
||||
)
|
||||
|
||||
/** `GET /public/rust/servers/{id}/wipes` — every wipe this server has had, newest first. */
|
||||
@Serializable
|
||||
data class RustWipeListDto(
|
||||
val wipes: List<RustWipeDto> = emptyList(),
|
||||
)
|
||||
|
||||
/**
|
||||
* One wipe.
|
||||
*
|
||||
* [wipeId] is derived by the bridge plugin from the save's creation time and
|
||||
* stamped on every frame, so it is the same id the feed and the leaderboard are
|
||||
* filtered by — which is what makes the per-wipe view navigable at all.
|
||||
*/
|
||||
@Serializable
|
||||
data class RustWipeDto(
|
||||
val wipeId: String = "",
|
||||
val saveCreatedAt: String? = null,
|
||||
val firstSeen: String? = null,
|
||||
val lastSeen: String? = null,
|
||||
)
|
||||
|
||||
/** `GET /public/rust/servers/{id}/online` — who is on right now. */
|
||||
@Serializable
|
||||
data class RustOnlineDto(
|
||||
val players: List<RustPresenceDto> = emptyList(),
|
||||
)
|
||||
|
||||
/**
|
||||
* One row of the presence board.
|
||||
*
|
||||
* Read from the board the bridge re-sends on every connect and every minute,
|
||||
* rather than counted from connect and disconnect events — so it is right even
|
||||
* after the website has missed one. **An unreachable server does not clear it**,
|
||||
* deliberately: these rows are still the best answer anybody has. Presented bare
|
||||
* they read as *who is on right now*, which is the one thing an offline server
|
||||
* cannot be saying, so the screen has to say which it is.
|
||||
*/
|
||||
@Serializable
|
||||
data class RustPresenceDto(
|
||||
val steamId: String = "",
|
||||
val name: String? = null,
|
||||
val sleeping: Boolean = false,
|
||||
val connectedAt: String? = null,
|
||||
)
|
||||
@@ -0,0 +1,84 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.data.repository
|
||||
|
||||
import com.runicgateway.app.core.result.ApiResult
|
||||
import com.runicgateway.app.core.result.map
|
||||
import com.runicgateway.app.core.result.safeApiCall
|
||||
import com.runicgateway.app.data.api.RustApi
|
||||
import com.runicgateway.app.data.api.dto.RustEventDto
|
||||
import com.runicgateway.app.data.api.dto.RustLeaderboardRowDto
|
||||
import com.runicgateway.app.data.api.dto.RustPresenceDto
|
||||
import com.runicgateway.app.data.api.dto.RustServerDto
|
||||
import com.runicgateway.app.data.api.dto.RustWipeDto
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* The Rust module's public read path (PLAN.md §9 M14).
|
||||
*
|
||||
* Every read unwraps its envelope here rather than in a view model, so no screen
|
||||
* holds a `…Dto` whose only job was to carry one list. Nothing is cached and
|
||||
* nothing is merged: the module's tables are already the cache — the site's whole
|
||||
* premise is that it answers from what a server last said rather than from the
|
||||
* server — so a second copy in the app would only add a way for the two to
|
||||
* disagree.
|
||||
*/
|
||||
@Singleton
|
||||
class RustRepository @Inject constructor(
|
||||
private val api: RustApi,
|
||||
) {
|
||||
/** Every server this site follows, with what each last reported. */
|
||||
suspend fun servers(): ApiResult<List<RustServerDto>> =
|
||||
safeApiCall { api.getServers() }.map { it.servers }
|
||||
|
||||
/** One server. A 404 here means no such server, or one an operator disabled. */
|
||||
suspend fun server(id: String): ApiResult<RustServerDto> =
|
||||
safeApiCall { api.getServer(id) }.map { it.server }
|
||||
|
||||
/**
|
||||
* The feed.
|
||||
*
|
||||
* [kinds] is joined here rather than by a caller, so the query string this
|
||||
* app sends exists in one place — and an **empty** list is sent as no `kind`
|
||||
* parameter at all, which asks for the whole allowlist. Sending `kind=` would
|
||||
* ask for a kind named the empty string.
|
||||
*/
|
||||
suspend fun events(
|
||||
id: String,
|
||||
kinds: List<String> = emptyList(),
|
||||
wipe: String? = null,
|
||||
limit: Int? = null,
|
||||
): ApiResult<List<RustEventDto>> = safeApiCall {
|
||||
api.getEvents(
|
||||
id = id,
|
||||
kind = kinds.takeIf { it.isNotEmpty() }?.joinToString(","),
|
||||
wipe = wipe?.takeIf { it.isNotBlank() },
|
||||
limit = limit,
|
||||
)
|
||||
}.map { it.events }
|
||||
|
||||
/** The leaderboard: per wipe when [wipe] is given, all-time otherwise. */
|
||||
suspend fun leaderboard(
|
||||
id: String,
|
||||
wipe: String? = null,
|
||||
sort: String? = null,
|
||||
limit: Int? = null,
|
||||
): ApiResult<List<RustLeaderboardRowDto>> = safeApiCall {
|
||||
api.getLeaderboard(
|
||||
id = id,
|
||||
wipe = wipe?.takeIf { it.isNotBlank() },
|
||||
sort = sort?.takeIf { it.isNotBlank() },
|
||||
limit = limit,
|
||||
)
|
||||
}.map { it.leaderboard }
|
||||
|
||||
/** Every wipe this server has had, newest first. */
|
||||
suspend fun wipes(id: String): ApiResult<List<RustWipeDto>> =
|
||||
safeApiCall { api.getWipes(id) }.map { it.wipes }
|
||||
|
||||
/** The presence board. Rows survive an unreachable server, by design. */
|
||||
suspend fun online(id: String): ApiResult<List<RustPresenceDto>> =
|
||||
safeApiCall { api.getOnline(id) }.map { it.players }
|
||||
}
|
||||
@@ -159,6 +159,24 @@ object Capability {
|
||||
*/
|
||||
const val SHARD = "shard"
|
||||
|
||||
/**
|
||||
* The Rust module (`docs/modules/rust/PLAN.md` D16, phase 5).
|
||||
*
|
||||
* A second game module, and therefore a second string rather than a second
|
||||
* meaning for [SHARD]: a Rust site is a **fleet of servers** with a list
|
||||
* above them, where a shard is one place — the surfaces are not the same
|
||||
* shape and a client cannot render one as the other.
|
||||
*
|
||||
* `module-rust` also declares `servers`, `killfeed`, `leaderboard`,
|
||||
* `presence` and `wipes`, and this gates on none of them. Every one of those
|
||||
* names a SURFACE, and core flattens all modules' capabilities into one list
|
||||
* — so `servers` is a word another module could declare tomorrow, which would
|
||||
* silently reveal these rows on a site that does not run Rust. `rust` is the
|
||||
* string only that module can mean, which is the same job [SHARD] does for
|
||||
* `module-uo`.
|
||||
*/
|
||||
const val RUST = "rust"
|
||||
|
||||
/** Core's event system (events Phase 14a). Never a module's. */
|
||||
const val EVENTS = "events"
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import com.runicgateway.app.data.api.AdminApi
|
||||
import com.runicgateway.app.data.api.NotificationsApi
|
||||
import com.runicgateway.app.data.api.PlayerShardApi
|
||||
import com.runicgateway.app.data.api.PublicApi
|
||||
import com.runicgateway.app.data.api.RustApi
|
||||
import com.runicgateway.app.data.api.SsoApi
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
@@ -132,6 +133,19 @@ object NetworkModule {
|
||||
@Singleton
|
||||
fun provideEventsApi(retrofit: Retrofit): EventsApi = retrofit.create(EventsApi::class.java)
|
||||
|
||||
/**
|
||||
* `module-rust`'s public read path (§9 M14).
|
||||
*
|
||||
* A MODULE's routes, unlike [provideEventsApi] beside it — they exist only on
|
||||
* a backend where an operator installed the Rust module, and the drawer rows
|
||||
* that lead to them are gated on its `rust` capability. Provided
|
||||
* unconditionally all the same: a Retrofit interface costs nothing until
|
||||
* something calls it, and there is nowhere at injection time to ask.
|
||||
*/
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideRustApi(retrofit: Retrofit): RustApi = retrofit.create(RustApi::class.java)
|
||||
|
||||
/** Opt-in push devices + subscriptions (§11, M7) — bearer-authed on the main client. */
|
||||
@Provides
|
||||
@Singleton
|
||||
|
||||
110
app/src/main/java/com/runicgateway/app/ui/Polling.kt
Normal file
110
app/src/main/java/com/runicgateway/app/ui/Polling.kt
Normal file
@@ -0,0 +1,110 @@
|
||||
/*
|
||||
* 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -57,6 +57,7 @@ import com.runicgateway.app.core.auth.Session
|
||||
import com.runicgateway.app.core.web.WebHandoff
|
||||
import com.runicgateway.app.data.api.dto.BrandDto
|
||||
import com.runicgateway.app.data.appearance.SiteAppearance
|
||||
import com.runicgateway.app.data.repository.Capability
|
||||
import com.runicgateway.app.ui.auth.AccountScreen
|
||||
import com.runicgateway.app.ui.auth.LoginScreen
|
||||
import com.runicgateway.app.ui.auth.RecoveryCodesScreen
|
||||
@@ -101,6 +102,9 @@ import com.runicgateway.app.ui.shard.MarketScreen
|
||||
import com.runicgateway.app.ui.shard.MarketVendorScreen
|
||||
import com.runicgateway.app.ui.shard.RulesScreen
|
||||
import com.runicgateway.app.ui.shard.ShardBoard
|
||||
import com.runicgateway.app.ui.rust.RustBadgeViewModel
|
||||
import com.runicgateway.app.ui.rust.RustServerScreen
|
||||
import com.runicgateway.app.ui.rust.RustServersScreen
|
||||
import com.runicgateway.app.ui.shard.ShardScreen
|
||||
import com.runicgateway.app.ui.theme.LocalShardStructure
|
||||
import com.runicgateway.app.ui.wiki.WikiPageScreen
|
||||
@@ -118,6 +122,9 @@ private val TOP_LEVEL_ROUTES = setOf(
|
||||
// gesture works on them. The event page and an arc are detail screens and are
|
||||
// deliberately absent — a back gesture there means "back", not "open the menu".
|
||||
Routes.EVENTS, Routes.MY_EVENTS,
|
||||
// The Rust server list is a drawer row (M14); one server's page is a detail
|
||||
// screen and is deliberately absent — a back gesture there means "back".
|
||||
Routes.RUST,
|
||||
Routes.PLAYER_CHARACTERS, Routes.PLAYER_VENDORS, Routes.PLAYER_HOUSES,
|
||||
Routes.ADMIN_DASHBOARD, Routes.ADMIN_CONTENT, Routes.ADMIN_MODERATION, Routes.ADMIN_SUPPORT,
|
||||
)
|
||||
@@ -140,6 +147,7 @@ fun RunicApp(
|
||||
onDeepLinkConsumed: () -> Unit = {},
|
||||
sessionViewModel: SessionViewModel = hiltViewModel(),
|
||||
inboxBadgeViewModel: InboxBadgeViewModel = hiltViewModel(),
|
||||
rustBadgeViewModel: RustBadgeViewModel = hiltViewModel(),
|
||||
) {
|
||||
val brand = appearance.brand
|
||||
val navController = rememberNavController()
|
||||
@@ -163,6 +171,17 @@ fun RunicApp(
|
||||
}
|
||||
|
||||
val unread by inboxBadgeViewModel.unread.collectAsStateWithLifecycle()
|
||||
// How many people are on the Rust fleet, for the drawer row's badge — the
|
||||
// phone's answer to D15's footer count (M14). Refreshed with the capability
|
||||
// answer rather than on a timer: a badge is a glance, not a feed, and this is
|
||||
// the only place that knows whether the module is installed at all. A host
|
||||
// that has not answered yet asks nothing, so a cold start makes no request
|
||||
// until it knows there is something to ask about.
|
||||
val rustOnline by rustBadgeViewModel.online.collectAsStateWithLifecycle()
|
||||
LaunchedEffect(capabilities) {
|
||||
val caps = capabilities
|
||||
if (caps != null) rustBadgeViewModel.refresh(Capability.RUST in caps)
|
||||
}
|
||||
// The badge follows the session, so signing out clears it rather than leaving
|
||||
// the previous account's count on the drawer.
|
||||
LaunchedEffect(session) { inboxBadgeViewModel.refresh() }
|
||||
@@ -267,10 +286,17 @@ fun RunicApp(
|
||||
colors = drawerItemColors,
|
||||
indented = true,
|
||||
unread = unread,
|
||||
rustOnline = rustOnline,
|
||||
) { openNode(child) }
|
||||
}
|
||||
} else {
|
||||
NavRow(node, currentRoute, drawerItemColors, unread = unread) { openNode(node) }
|
||||
NavRow(
|
||||
node = node,
|
||||
currentRoute = currentRoute,
|
||||
colors = drawerItemColors,
|
||||
unread = unread,
|
||||
rustOnline = rustOnline,
|
||||
) { openNode(node) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -386,6 +412,7 @@ private fun NavRow(
|
||||
colors: NavigationDrawerItemColors,
|
||||
indented: Boolean = false,
|
||||
unread: Int = 0,
|
||||
rustOnline: Int = 0,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val route = when (node) {
|
||||
@@ -405,6 +432,15 @@ private fun NavRow(
|
||||
// admin's own nav override pointing at it, since the badge belongs to the
|
||||
// destination, not to the bundled entry.
|
||||
val showsUnread = !handsOff && unread > 0 && route == Routes.NOTIFICATIONS
|
||||
// The live player count rides on whichever row leads to the Rust list, for the
|
||||
// same reason the unread count rides on whichever leads to the inbox — the
|
||||
// number belongs to the destination, not to the bundled entry, so an admin's
|
||||
// own nav override pointing there carries it too.
|
||||
//
|
||||
// **Zero renders nothing**, rather than a `0`: an empty fleet is not a
|
||||
// notification, and a badge that read `0` on a site whose servers are simply
|
||||
// quiet would be worse than no badge at all.
|
||||
val showsRustOnline = !handsOff && rustOnline > 0 && route == Routes.RUST
|
||||
|
||||
NavigationDrawerItem(
|
||||
label = { Text(label) },
|
||||
@@ -432,6 +468,19 @@ private fun NavRow(
|
||||
)
|
||||
}
|
||||
}
|
||||
showsRustOnline -> {
|
||||
{
|
||||
// Named for a screen reader: "42" beside "Rust servers" reads
|
||||
// as a count to a sighted user and as a bare number to
|
||||
// everyone else.
|
||||
val spoken = stringResource(R.string.rust_online_badge, rustOnline)
|
||||
Text(
|
||||
text = rustOnline.toString(),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
modifier = Modifier.semantics { contentDescription = spoken },
|
||||
)
|
||||
}
|
||||
}
|
||||
else -> null
|
||||
},
|
||||
colors = colors,
|
||||
@@ -573,6 +622,17 @@ private fun RunicNavHost(
|
||||
) { entry ->
|
||||
AtlasCreatureScreen(slug = entry.arguments?.getString(Routes.Args.SLUG).orEmpty())
|
||||
}
|
||||
// The Rust module's two screens (M14). Not under `shard/`: a different game,
|
||||
// a different shape — a fleet with a list above it rather than one place.
|
||||
composable(Routes.RUST) {
|
||||
RustServersScreen(onOpenServer = { id -> navController.navigate(Routes.rustServer(id)) })
|
||||
}
|
||||
composable(
|
||||
route = Routes.RUST_SERVER,
|
||||
arguments = listOf(navArgument(Routes.Args.SERVER_ID) { type = NavType.StringType }),
|
||||
) {
|
||||
RustServerScreen(onBack = { navController.navigateTopLevel(Routes.RUST) })
|
||||
}
|
||||
composable(Routes.WIKI) {
|
||||
WikiScreen(onOpenPage = { slug -> navController.navigate(Routes.wikiPage(slug)) })
|
||||
}
|
||||
|
||||
@@ -128,6 +128,17 @@ val APP_MENU: List<MenuEntry> = listOf(
|
||||
feature = ShardFeature.MARKET,
|
||||
capability = Capability.SHARD,
|
||||
),
|
||||
// The Rust module (M14). ONE row, because the module's whole public surface is
|
||||
// one list and one page beneath it — `/rust` IS the server list, not a hub
|
||||
// above one.
|
||||
//
|
||||
// **No `feature`, and that is not an omission.** The visibility framework is
|
||||
// `module-uo`'s own (`shardVisibility`, six files under `module-uo/server/`
|
||||
// and none under core's), and §2.7 forbids a module importing another's — so
|
||||
// `module-rust` has no per-viewer visibility layer yet. Its phase 14 builds
|
||||
// one; until then these routes are public to everyone the site is public to,
|
||||
// and a `feature` here would be gating on a flag nothing publishes.
|
||||
MenuEntry(Routes.RUST, R.string.menu_rust, capability = Capability.RUST),
|
||||
MenuEntry(Routes.page("about"), R.string.menu_about),
|
||||
MenuEntry(Routes.CONTACT, R.string.menu_contact),
|
||||
MenuEntry(Routes.ACCOUNT, R.string.menu_account, MenuAccess.SIGNED_IN),
|
||||
|
||||
@@ -70,6 +70,15 @@ import com.runicgateway.app.data.repository.ContentRepository.PostCategory
|
||||
* })
|
||||
* ```
|
||||
*
|
||||
* and from `module-rust/client/src/entry.jsx`, which registers one (M14):
|
||||
*
|
||||
* ```jsx
|
||||
* registry.registerNav(ID, {
|
||||
* area: 'public',
|
||||
* items: [{ label: 'Servers', to: '/rust' }],
|
||||
* })
|
||||
* ```
|
||||
*
|
||||
* None of those nine declares an `order`, so `mergeFlat` appends them after core's
|
||||
* rows in registration order — which is the order they are listed in below.
|
||||
*
|
||||
@@ -119,6 +128,16 @@ val WEBSITE_PUBLIC_NAV: List<WebNavPath> = listOf(
|
||||
WebNavPath("/uo/atlas", Routes.ATLAS),
|
||||
WebNavPath("/uo/leaderboards", Routes.SHARD_LEADERBOARDS),
|
||||
WebNavPath("/uo/market", Routes.SHARD_MARKET),
|
||||
// module-rust's one row (M14). It registers `{ label: 'Servers', to: '/rust' }`
|
||||
// and nothing else — `/rust` IS the server list, because core strips the
|
||||
// trailing separator from a module route registered with `path: ''`.
|
||||
//
|
||||
// **Both modules can be installed on one backend**, and then the nav is core's
|
||||
// eight plus ten. This table is a superset by design: a path here for a module
|
||||
// an operator has NOT installed never appears in that backend's nav and so is
|
||||
// never looked up, while a path missing from it makes a link that exists hand
|
||||
// off to a browser.
|
||||
WebNavPath("/rust", Routes.RUST),
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -178,7 +197,12 @@ private val RESERVED_TOP_LEVEL = setOf(
|
||||
// Only ids the app knows about need listing: an unknown module's `/<id>` would
|
||||
// resolve to a CMS page that 404s, which is the same answer the browser gives
|
||||
// it, and core cannot enumerate them for us here anyway.
|
||||
"uo",
|
||||
//
|
||||
// `rust` is here for the opposite reason to the rest: `/rust` DOES resolve, to
|
||||
// the server list, and it does so through the nav table above — this set only
|
||||
// stops the CMS-page fallback claiming it. Without the entry a site with the
|
||||
// module absent would open a page-not-found screen instead of the browser.
|
||||
"uo", "rust",
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -209,6 +233,7 @@ private val RESERVED_TOP_LEVEL = setOf(
|
||||
* // /uo/shard, /uo/shard/activity, /uo/champs, /uo/guilds, /uo/guilds/:id,
|
||||
* // /uo/governors, /uo/houses, /uo/rules, /uo/leaderboards, /uo/market,
|
||||
* // /uo/market/vendors/:serial, /uo/atlas, /uo/atlas/:slug
|
||||
* // /rust, /rust/servers/:id
|
||||
* // CMS pages: top-level /:slug, matched only after the named routes above
|
||||
* <Route path="/:slug" element={<CmsPage />} />
|
||||
* ```
|
||||
@@ -231,6 +256,8 @@ private val RESERVED_TOP_LEVEL = setOf(
|
||||
* /uo/<shard surface> → the mapped shard route (§6.2)
|
||||
* /uo/atlas/<slug> → ATLAS_CREATURE
|
||||
* /uo/market/vendors/<serial> → SHARD_MARKET_VENDOR
|
||||
* /rust → RUST (module-rust's server list)
|
||||
* /rust/servers/<id> → RUST_SERVER
|
||||
* /site/about → PAGE("about")
|
||||
* /<slug> → PAGE(slug), unless <slug> is reserved
|
||||
* anything else → null, i.e. the Custom Tab
|
||||
@@ -295,6 +322,10 @@ fun resolveWebPath(path: String?): String? {
|
||||
Routes.atlasCreature(segments[2])
|
||||
segments[0] == MODULE_UO && segments.size == 4 && segments[1] == "market" &&
|
||||
segments[2] == "vendors" -> Routes.marketVendor(segments[3])
|
||||
// module-rust's one page below the list. `/rust` itself is already
|
||||
// answered by the nav table above, before this fallback is reached.
|
||||
segments[0] == MODULE_RUST && segments.size == 3 && segments[1] == "servers" ->
|
||||
Routes.rustServer(segments[2])
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
@@ -322,3 +353,6 @@ private fun runParam(query: String): String? {
|
||||
* module is countable. It is a literal on purpose — see the file header.
|
||||
*/
|
||||
private const val MODULE_UO = "uo"
|
||||
|
||||
/** The second game module's id (M14). A literal for the same reason. */
|
||||
private const val MODULE_RUST = "rust"
|
||||
|
||||
@@ -70,6 +70,22 @@ object Routes {
|
||||
const val EVENT_SERIES = "events/series/{slug}"
|
||||
const val MY_EVENTS = "account/events"
|
||||
|
||||
/**
|
||||
* The Rust module's surface (§9 M14, `docs/modules/rust/PLAN.md` D12, D13).
|
||||
*
|
||||
* **[RUST] is the server list, not a hub above one.** The module registers its
|
||||
* pages with `path: ''` and core strips the trailing separator, so `/rust` on
|
||||
* the website *is* the list — there is no landing page between the drawer row
|
||||
* and the servers, and adding one here would invent a screen the website does
|
||||
* not have.
|
||||
*
|
||||
* **A different game, so a different route tree.** These are deliberately not
|
||||
* folded into [SHARD]: one shard is a place, and a Rust site is a fleet. The
|
||||
* two can be installed on the same backend, and then both trees exist at once.
|
||||
*/
|
||||
const val RUST = "rust"
|
||||
const val RUST_SERVER = "rust/servers/{serverId}"
|
||||
|
||||
/** Public shard hub (§6.2). */
|
||||
const val SHARD = "shard"
|
||||
|
||||
@@ -121,6 +137,7 @@ object Routes {
|
||||
const val ID_OR_SLUG = "idOrSlug"
|
||||
const val SERIAL = "serial"
|
||||
const val RUN = "run"
|
||||
const val SERVER_ID = "serverId"
|
||||
}
|
||||
|
||||
fun page(slug: String) = "page/$slug"
|
||||
@@ -140,6 +157,42 @@ object Routes {
|
||||
/** One player vendor's shop, by in-game (hex) serial. */
|
||||
fun marketVendor(serial: String) = "shard/market/$serial"
|
||||
|
||||
/**
|
||||
* One Rust server's page.
|
||||
*
|
||||
* The id is a slug an operator chose, so it is encoded: nothing stops one
|
||||
* carrying a character a path would otherwise eat, and a server nobody can
|
||||
* open is a worse failure than a name nobody can read.
|
||||
*
|
||||
* **Encoded here rather than with `android.net.Uri`**, which is a stub in a
|
||||
* JVM unit test and throws "not mocked" — this object is pure and every test
|
||||
* that builds a route would have to become an instrumented one to keep it
|
||||
* that way.
|
||||
*/
|
||||
fun rustServer(id: String) = "rust/servers/${encodePathSegment(id)}"
|
||||
|
||||
/**
|
||||
* Percent-encode one path segment, allowing only the unreserved set.
|
||||
*
|
||||
* Deliberately stricter than it needs to be: encoding a character that did
|
||||
* not need it still round-trips, where missing one that did produces a route
|
||||
* NavHost matches differently from the one that was built. UTF-8 first, so a
|
||||
* non-ASCII name is encoded per byte rather than per character.
|
||||
*/
|
||||
private fun encodePathSegment(value: String): String = buildString {
|
||||
for (byte in value.toByteArray(Charsets.UTF_8)) {
|
||||
val code = byte.toInt() and 0xFF
|
||||
val char = code.toChar()
|
||||
if (code < 128 && (char.isLetterOrDigit() || char in UNRESERVED)) {
|
||||
append(char)
|
||||
} else {
|
||||
append('%').append(code.toString(16).uppercase().padStart(2, '0'))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val UNRESERVED = "-._~"
|
||||
|
||||
/** One creature's atlas page, by slug. */
|
||||
fun atlasCreature(slug: String) = "atlas/$slug"
|
||||
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.rust
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.runicgateway.app.core.result.ApiResult
|
||||
import com.runicgateway.app.data.repository.RustRepository
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* The live player count on the drawer's Rust row — the phone's answer to D15
|
||||
* (`docs/modules/rust/PLAN.md` §17.4).
|
||||
*
|
||||
* ## Why the drawer and not a footer
|
||||
*
|
||||
* D15 put "2 servers · 42 online" in core's `site.footer.status` slot, which
|
||||
* exists because every page of the website renders the same footer. The app has
|
||||
* no footer and no slot; what it has is a drawer row per surface and, already, a
|
||||
* precedent for a number beside one — the inbox's unread badge, in the same
|
||||
* `NavigationDrawerItem` badge slot, with the same screen-reader treatment. So
|
||||
* the count rides there.
|
||||
*
|
||||
* **The number is players, not servers.** A badge is one integer, and of the two
|
||||
* halves of D15's line the live one is how many people are on: a server count
|
||||
* changes when an operator edits configuration, which is not news, and is visible
|
||||
* on the page the row opens anyway.
|
||||
*
|
||||
* ## What keeps it honest
|
||||
*
|
||||
* The website's version renders nothing until it has an answer, nothing at all if
|
||||
* the request fails, and never polls — because one request per page view is a
|
||||
* cost and a timer in a footer on every page is a different kind of thing. All
|
||||
* three rules hold here:
|
||||
*
|
||||
* - **Zero renders nothing.** No badge, rather than a `0` — an empty server is
|
||||
* not a notification.
|
||||
* - **A failure leaves the last count** rather than dropping to zero. A moment
|
||||
* with no connectivity is not everybody logging off.
|
||||
* - **It refreshes on resume, with the unread badge**, and never on a timer. The
|
||||
* count is a glance, not a feed.
|
||||
*
|
||||
* It is asked for **only when the module is installed** — the caller gates on the
|
||||
* `rust` capability — so a site running a different game makes no request at all.
|
||||
*/
|
||||
@HiltViewModel
|
||||
class RustBadgeViewModel @Inject constructor(
|
||||
private val repository: RustRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _online = MutableStateFlow(0)
|
||||
|
||||
/** How many people are on across every server, or 0 when there is nothing to say. */
|
||||
val online: StateFlow<Int> = _online.asStateFlow()
|
||||
|
||||
/**
|
||||
* Ask, if the Rust module is there.
|
||||
*
|
||||
* [installed] is passed in rather than read here so this holds no opinion
|
||||
* about capabilities: the drawer already knows, and a view model that
|
||||
* re-derived it would be a second copy of a rule that lives in one place.
|
||||
* Absent — the host has not answered yet — makes no request and keeps
|
||||
* whatever is showing.
|
||||
*/
|
||||
fun refresh(installed: Boolean) {
|
||||
if (!installed) {
|
||||
_online.value = 0
|
||||
return
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
when (val result = repository.servers()) {
|
||||
// A server that is stale or unreachable already answers `online:
|
||||
// false` with `players: 0`, so summing the whole list needs no
|
||||
// second staleness rule here.
|
||||
is ApiResult.Ok -> _online.value = result.data.sumOf { it.players }
|
||||
// Keep the last number. See the class doc.
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
199
app/src/main/java/com/runicgateway/app/ui/rust/RustFeed.kt
Normal file
199
app/src/main/java/com/runicgateway/app/ui/rust/RustFeed.kt
Normal file
@@ -0,0 +1,199 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.rust
|
||||
|
||||
import com.runicgateway.app.data.api.dto.RustEventDto
|
||||
|
||||
/**
|
||||
* One stored frame as one line of a feed — the Kotlin half of `module-rust`'s
|
||||
* `client/src/lib/feed.js` (PLAN.md §9 M14).
|
||||
*
|
||||
* `GET /public/rust/servers/{id}/events` answers rows shaped
|
||||
* `{ id, kind, t, wipeId, steamId, frame }`, where `frame` is the whole frame the
|
||||
* bridge plugin emitted. Everything a killfeed line needs is in there, under the
|
||||
* names the plugin wrote, and **this file is the one place in the app that knows
|
||||
* them**.
|
||||
*
|
||||
* ## It returns parts, not a sentence
|
||||
*
|
||||
* A row wants the names emphasised and the detail muted, and a function returning
|
||||
* `"Alice killed Bob"` would force the screen to re-parse its own output to style
|
||||
* it. Parts also make this testable without Compose, which is the only way this
|
||||
* leg has real coverage of what a feed row says.
|
||||
*
|
||||
* ## The rule for an unknown kind
|
||||
*
|
||||
* **It renders as itself.** A later protocol adds kinds and an operator's module
|
||||
* may be older than their game host, so a feed that dropped what it did not
|
||||
* recognise would be a screen quietly saying less than the truth. The server's
|
||||
* allowlist has already decided the row may be seen; what is left here is
|
||||
* presentation, and the honest presentation of a kind we have no words for is its
|
||||
* own name.
|
||||
*/
|
||||
|
||||
/** The row's category, for the small colour a screen gives it — never for meaning. */
|
||||
enum class FeedTone { KILL, DEATH, JOIN, LEAVE, CHAT, SERVER, OTHER }
|
||||
|
||||
/**
|
||||
* One row, ready to render.
|
||||
*
|
||||
* [actor] and [subject] are names and are emphasised; [verb] and [detail] are
|
||||
* prose. Any of them may be null or empty.
|
||||
*
|
||||
* [join] is what goes between the actor and the verb, and it exists for exactly
|
||||
* one case: chat. "Brannock see you in september" is not a sentence anybody
|
||||
* writes, and putting the colon in the message would put presentation inside text
|
||||
* a player typed.
|
||||
*/
|
||||
data class FeedLine(
|
||||
val tone: FeedTone,
|
||||
val actor: String? = null,
|
||||
val join: String = " ",
|
||||
val verb: String = "",
|
||||
val subject: String? = null,
|
||||
val detail: String = "",
|
||||
)
|
||||
|
||||
/** One filter the feed offers, and the kinds it asks the API for. */
|
||||
data class FeedFilter(val id: String, val label: String, val kinds: List<String>)
|
||||
|
||||
/**
|
||||
* Kinds this feed asks for.
|
||||
*
|
||||
* `player.tally` is public and deliberately **not** here: it is an aggregate the
|
||||
* plugin flushes every sixty seconds per active player, so a feed including it
|
||||
* would be mostly wood counts. It is the leaderboard's input, and the leaderboard
|
||||
* is where it shows up.
|
||||
*/
|
||||
val FEED_KINDS: List<String> = listOf(
|
||||
"player.death",
|
||||
"player.connected",
|
||||
"player.disconnected",
|
||||
"player.respawned",
|
||||
"player.chat",
|
||||
"server.wipe",
|
||||
"server.initialized",
|
||||
"server.shutdown",
|
||||
)
|
||||
|
||||
/** The filters the feed offers. The first is the default and asks for everything. */
|
||||
val FEED_FILTERS: List<FeedFilter> = listOf(
|
||||
FeedFilter("all", "Everything", FEED_KINDS),
|
||||
FeedFilter("kills", "Kills", listOf("player.death")),
|
||||
FeedFilter("chat", "Chat", listOf("player.chat")),
|
||||
FeedFilter(
|
||||
"sessions",
|
||||
"Comings and goings",
|
||||
listOf("player.connected", "player.disconnected", "player.respawned"),
|
||||
),
|
||||
FeedFilter("server", "Server", listOf("server.wipe", "server.initialized", "server.shutdown")),
|
||||
)
|
||||
|
||||
/** The kinds a filter id asks for; an id nobody offers falls back to everything. */
|
||||
fun kindsFor(filterId: String): List<String> =
|
||||
(FEED_FILTERS.firstOrNull { it.id == filterId } ?: FEED_FILTERS.first()).kinds
|
||||
|
||||
/** One row as the parts a screen renders. */
|
||||
fun describe(row: RustEventDto): FeedLine {
|
||||
val name = row.str("name")
|
||||
|
||||
return when (row.kind) {
|
||||
"player.death" -> death(row, name)
|
||||
|
||||
"player.connected" ->
|
||||
FeedLine(FeedTone.JOIN, actor = name, verb = "connected")
|
||||
|
||||
"player.disconnected" -> FeedLine(
|
||||
tone = FeedTone.LEAVE,
|
||||
actor = name,
|
||||
verb = "disconnected",
|
||||
// Two optional halves, and the session is the interesting one. The
|
||||
// plugin OMITS `sessionSec` for a player who was already on when it
|
||||
// loaded, so an absent value means "unknown" and never zero — which is
|
||||
// why this reads the parsed number rather than trusting a default.
|
||||
detail = listOfNotNull(
|
||||
row.str("reason"),
|
||||
row.num("sessionSec")?.takeIf { it > 0 }?.let { "after ${playtime(it.toLong())}" },
|
||||
).joinToString(" · "),
|
||||
)
|
||||
|
||||
"player.respawned" ->
|
||||
FeedLine(FeedTone.JOIN, actor = name, verb = "respawned")
|
||||
|
||||
"player.chat" -> FeedLine(
|
||||
tone = FeedTone.CHAT,
|
||||
actor = name,
|
||||
join = ": ",
|
||||
// The message is the row, so it goes in `verb` where a screen renders
|
||||
// it unemphasised — and it is the one field on this wire whose bytes a
|
||||
// player chooses. Compose renders it as text and never as markup;
|
||||
// nothing here may ever stop doing that.
|
||||
verb = row.str("message").orEmpty(),
|
||||
detail = row.str("channel")?.takeIf { it != "Global" }.orEmpty(),
|
||||
)
|
||||
|
||||
"server.wipe" -> FeedLine(
|
||||
tone = FeedTone.SERVER,
|
||||
verb = "The map was wiped",
|
||||
detail = row.str("wipeId")?.let { "new wipe $it" }.orEmpty(),
|
||||
)
|
||||
|
||||
"server.initialized" -> FeedLine(FeedTone.SERVER, verb = "The server came up")
|
||||
|
||||
"server.shutdown" -> FeedLine(FeedTone.SERVER, verb = "The server went down")
|
||||
|
||||
else -> FeedLine(
|
||||
tone = FeedTone.OTHER,
|
||||
actor = name,
|
||||
verb = row.kind.takeIf { it.isNotBlank() } ?: "unknown",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A death, which is four different sentences.
|
||||
*
|
||||
* The plugin distinguishes `player`, `self`, `npc` and `environment` precisely so
|
||||
* a reader does not have to guess from an absent field, and collapsing any two of
|
||||
* them loses something. A killfeed reporting a fall as a kill by nobody is the
|
||||
* failure this avoids.
|
||||
*/
|
||||
private fun death(row: RustEventDto, name: String?): FeedLine {
|
||||
val where = listOfNotNull(
|
||||
row.str("weapon")?.let { "with ${prefabName(it)}" },
|
||||
row.num("distance")?.let { "${Math.round(it)}m" },
|
||||
row.str("grid"),
|
||||
if (row.flag("sleeping")) "while sleeping" else null,
|
||||
).joinToString(" · ")
|
||||
|
||||
return when (row.str("attackerType")) {
|
||||
"player" -> FeedLine(
|
||||
tone = FeedTone.KILL,
|
||||
actor = row.str("attackerName"),
|
||||
verb = "killed",
|
||||
subject = name,
|
||||
detail = where,
|
||||
)
|
||||
|
||||
"self" -> FeedLine(
|
||||
tone = FeedTone.DEATH,
|
||||
actor = name,
|
||||
verb = "died by their own hand",
|
||||
detail = where,
|
||||
)
|
||||
|
||||
"npc" -> FeedLine(
|
||||
tone = FeedTone.DEATH,
|
||||
actor = prefabName(row.str("attackerName")).ifBlank { "Something" },
|
||||
verb = "killed",
|
||||
subject = name,
|
||||
detail = where,
|
||||
)
|
||||
|
||||
// `environment` and anything else: falling, drowning, the world. The
|
||||
// plugin legitimately has no attacker on this path, so an ABSENT type is
|
||||
// this case rather than a missing field to complain about.
|
||||
else -> FeedLine(tone = FeedTone.DEATH, actor = name, verb = "died", detail = where)
|
||||
}
|
||||
}
|
||||
154
app/src/main/java/com/runicgateway/app/ui/rust/RustFormat.kt
Normal file
154
app/src/main/java/com/runicgateway/app/ui/rust/RustFormat.kt
Normal file
@@ -0,0 +1,154 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.rust
|
||||
|
||||
import com.runicgateway.app.core.time.parseWireInstant
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.format.FormatStyle
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Formatting for the Rust screens — pure, no Compose, no Android (PLAN.md §9
|
||||
* M14).
|
||||
*
|
||||
* The Kotlin half of `module-rust`'s `client/src/lib/format.js`, and it is a
|
||||
* deliberate second implementation rather than something shared: the two clients
|
||||
* have different formatting libraries under them (`Intl` there, `java.time`
|
||||
* here), and the thing worth keeping identical is the **rules**, not the code.
|
||||
* Those rules are restated here beside each function so a reader can check them
|
||||
* against the website without opening it.
|
||||
*
|
||||
* Everything takes `now` as a parameter, so a boundary is testable rather than a
|
||||
* property of the machine the test runs on.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The stamp on a feed row.
|
||||
*
|
||||
* **Today's rows get a time; everything older gets a date as well.** The feed can
|
||||
* be filtered to a past wipe, and a row from six weeks ago rendered as `14:03`
|
||||
* reads as this afternoon — which is exactly what the website's own page walk
|
||||
* found, three events from August all apparently a few minutes old. The boundary
|
||||
* is the **calendar day**, not a duration, because that is what a reader means by
|
||||
* "what time was that".
|
||||
*/
|
||||
fun feedClock(
|
||||
value: String?,
|
||||
epochMillis: Long? = null,
|
||||
now: Instant = Instant.now(),
|
||||
zone: ZoneId = ZoneId.systemDefault(),
|
||||
locale: Locale = Locale.getDefault(),
|
||||
): String {
|
||||
val at = epochMillis?.takeIf { it > 0 }?.let(Instant::ofEpochMilli) ?: parseWireInstant(value) ?: return ""
|
||||
|
||||
val time = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT)
|
||||
.withLocale(locale)
|
||||
.format(at.atZone(zone))
|
||||
|
||||
val sameDay = at.atZone(zone).toLocalDate() == now.atZone(zone).toLocalDate()
|
||||
if (sameDay) return time
|
||||
|
||||
val date = DateTimeFormatter.ofPattern("d MMM", locale).format(at.atZone(zone))
|
||||
return "$date $time"
|
||||
}
|
||||
|
||||
/** A date, for a wipe: the thing people actually compare wipes by. */
|
||||
fun wipeDay(
|
||||
value: String?,
|
||||
zone: ZoneId = ZoneId.systemDefault(),
|
||||
locale: Locale = Locale.getDefault(),
|
||||
): String? {
|
||||
val at = parseWireInstant(value) ?: return null
|
||||
return DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM)
|
||||
.withLocale(locale)
|
||||
.format(at.atZone(zone))
|
||||
}
|
||||
|
||||
/**
|
||||
* "3 minutes ago", for a "last reported" line.
|
||||
*
|
||||
* Returns null rather than a word for an absent stamp, so the caller decides what
|
||||
* "never" looks like in its own layout — on this surface a server that has never
|
||||
* reported is a real and ordinary state, not a missing value to apologise for.
|
||||
*/
|
||||
fun rustAgo(value: String?, now: Instant = Instant.now()): String? {
|
||||
val at = parseWireInstant(value) ?: return null
|
||||
val seconds = java.time.Duration.between(at, now).seconds
|
||||
|
||||
// Under a minute in either direction, say the thing rather than "in 0 seconds".
|
||||
if (kotlin.math.abs(seconds) < 45) return "just now"
|
||||
|
||||
val future = seconds < 0
|
||||
val magnitude = kotlin.math.abs(seconds)
|
||||
val (unit, size) = AGO_UNITS.first { magnitude >= it.second }
|
||||
val amount = Math.round(magnitude.toDouble() / size)
|
||||
val plural = if (amount == 1L) unit else "${unit}s"
|
||||
|
||||
return if (future) "in $amount $plural" else "$amount $plural ago"
|
||||
}
|
||||
|
||||
private val AGO_UNITS = listOf(
|
||||
"year" to 31_536_000L,
|
||||
"month" to 2_592_000L,
|
||||
"week" to 604_800L,
|
||||
"day" to 86_400L,
|
||||
"hour" to 3_600L,
|
||||
"minute" to 60L,
|
||||
"second" to 1L,
|
||||
)
|
||||
|
||||
/**
|
||||
* A session or a playtime, as `4h 12m`.
|
||||
*
|
||||
* Seconds are dropped above a minute and kept below it: a two-hour session
|
||||
* reported to the second is noise, and a forty-second one reported as "0m" is
|
||||
* wrong.
|
||||
*/
|
||||
fun playtime(seconds: Long?): String {
|
||||
val total = seconds ?: return "—"
|
||||
if (total <= 0) return "—"
|
||||
if (total < 60) return "${total}s"
|
||||
|
||||
val hours = total / 3600
|
||||
val minutes = Math.round((total % 3600) / 60.0)
|
||||
|
||||
return when {
|
||||
hours == 0L -> "${minutes}m"
|
||||
minutes == 0L -> "${hours}h"
|
||||
else -> "${hours}h ${minutes}m"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A prefab short name as something readable — `patrolhelicopter` stays itself,
|
||||
* `rifle.ak` becomes `rifle ak`.
|
||||
*
|
||||
* Deliberately a light touch rather than a lookup table: a table mapping every
|
||||
* Rust prefab to a pretty name is a second copy of the game's item list that goes
|
||||
* stale every wipe, and the short name is what a Rust player reads on their own
|
||||
* server console anyway.
|
||||
*/
|
||||
fun prefabName(name: String?): String {
|
||||
if (name.isNullOrBlank()) return ""
|
||||
return name.replace(Regex("[_.]+"), " ").trim()
|
||||
}
|
||||
|
||||
/** A steam id, shortened for a table cell, without pretending it is a name. */
|
||||
fun shortSteamId(steamId: String?): String {
|
||||
val id = steamId.orEmpty()
|
||||
return if (id.length > 10) "…${id.takeLast(6)}" else id
|
||||
}
|
||||
|
||||
/**
|
||||
* What to call a player who has no name yet.
|
||||
*
|
||||
* The presence board and the leaderboard both carry a nullable `name`: the plugin
|
||||
* knows a steam id before it knows anything else. Showing a shortened id is
|
||||
* honest — it is not a name and does not look like one — where "Unknown" would
|
||||
* lose the only identifier there is.
|
||||
*/
|
||||
fun playerLabel(name: String?, steamId: String?): String =
|
||||
name?.takeIf { it.isNotBlank() } ?: shortSteamId(steamId)
|
||||
@@ -0,0 +1,535 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.rust
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ScrollableTabRow
|
||||
import androidx.compose.material3.Tab
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.runicgateway.app.R
|
||||
import com.runicgateway.app.data.api.dto.RustEventDto
|
||||
import com.runicgateway.app.data.api.dto.RustLeaderboardRowDto
|
||||
import com.runicgateway.app.data.api.dto.RustPresenceDto
|
||||
import com.runicgateway.app.data.api.dto.RustServerDto
|
||||
import com.runicgateway.app.data.api.dto.RustWipeDto
|
||||
import com.runicgateway.app.ui.ErrorKind
|
||||
import com.runicgateway.app.ui.PollWhileResumed
|
||||
import com.runicgateway.app.ui.Polled
|
||||
import com.runicgateway.app.ui.UiState
|
||||
import com.runicgateway.app.ui.components.EmptyView
|
||||
import com.runicgateway.app.ui.components.ErrorView
|
||||
import com.runicgateway.app.ui.components.LoadingView
|
||||
import com.runicgateway.app.ui.components.PillTone
|
||||
import com.runicgateway.app.ui.components.SectionLabel
|
||||
import com.runicgateway.app.ui.components.ShardCard
|
||||
import com.runicgateway.app.ui.components.StatusPill
|
||||
|
||||
/**
|
||||
* One Rust server: the feed, the leaderboard, who is on, and the wipes (D13).
|
||||
*
|
||||
* **One screen with tabs, not four destinations** — the same call the website
|
||||
* makes, and more obviously right on a phone: the four panels are four questions
|
||||
* about one thing, and a reader moving between them is not navigating.
|
||||
*
|
||||
* The phase criterion lives here. With the server unreachable this still renders
|
||||
* its map, size, seed, wipe date, killfeed, leaderboards, last known presence
|
||||
* board and wipe history, because every one of those is read from the website's
|
||||
* own tables rather than from the game.
|
||||
*/
|
||||
@Composable
|
||||
fun RustServerScreen(
|
||||
onBack: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: RustServerViewModel = hiltViewModel(),
|
||||
) {
|
||||
val ui by viewModel.state.collectAsStateWithLifecycle()
|
||||
|
||||
PollWhileResumed { viewModel.refresh() }
|
||||
|
||||
when (val s = ui.server.state) {
|
||||
is UiState.Loading -> LoadingView(modifier)
|
||||
|
||||
// **A mistyped address is not a fault and must not be dressed as one.**
|
||||
// The website's first version put its generic error panel under this
|
||||
// heading, so an unknown id read "No such server / Something went wrong"
|
||||
// and sent a reader looking for an outage. A 404 is its own answer; the
|
||||
// error panel is kept for a request that failed for a reason nobody can
|
||||
// see. A server an operator disabled answers the same 404 — switching one
|
||||
// off is not switching it into a refusal.
|
||||
is UiState.Error -> if (s.kind == ErrorKind.NOT_FOUND) {
|
||||
MissingServer(onBack, modifier)
|
||||
} else {
|
||||
ErrorView(s.kind, onRetry = viewModel::load, modifier = modifier)
|
||||
}
|
||||
|
||||
is UiState.Success -> ServerDetail(s.data, ui, viewModel, modifier)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MissingServer(onBack: () -> Unit, modifier: Modifier = Modifier) {
|
||||
Column(
|
||||
modifier = modifier.fillMaxSize().padding(24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(stringResource(R.string.rust_no_such_server), style = MaterialTheme.typography.titleLarge)
|
||||
Text(
|
||||
text = stringResource(R.string.rust_no_such_server_detail),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.rust_back_to_servers),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.clickable(onClick = onBack).padding(top = 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ServerDetail(
|
||||
server: RustServerDto,
|
||||
ui: RustServerUi,
|
||||
viewModel: RustServerViewModel,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(modifier.fillMaxSize()) {
|
||||
ServerHeader(server, ui.selectedWipe, viewModel::selectWipe, ui.wipes)
|
||||
|
||||
val tabs = RustTab.entries
|
||||
ScrollableTabRow(selectedTabIndex = tabs.indexOf(ui.tab), edgePadding = 16.dp) {
|
||||
tabs.forEach { tab ->
|
||||
Tab(
|
||||
selected = tab == ui.tab,
|
||||
onClick = { viewModel.selectTab(tab) },
|
||||
text = { Text(stringResource(tabLabel(tab))) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
when (ui.tab) {
|
||||
RustTab.FEED -> FeedPanel(ui.feed, ui.filterId, viewModel::selectFilter, viewModel::retryFeed)
|
||||
RustTab.LEADERBOARD -> LeaderboardPanel(
|
||||
ui.leaderboard,
|
||||
ui.sort,
|
||||
viewModel::selectSort,
|
||||
viewModel::retryLeaderboard,
|
||||
)
|
||||
RustTab.ONLINE -> OnlinePanel(ui.online, server.online, viewModel::retryOnline)
|
||||
RustTab.WIPES -> WipesPanel(
|
||||
ui.wipes,
|
||||
server.wipeId,
|
||||
ui.selectedWipe,
|
||||
viewModel::openWipe,
|
||||
viewModel::retryWipes,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun tabLabel(tab: RustTab): Int = when (tab) {
|
||||
RustTab.FEED -> R.string.rust_tab_feed
|
||||
RustTab.LEADERBOARD -> R.string.rust_tab_leaderboard
|
||||
RustTab.ONLINE -> R.string.rust_tab_online
|
||||
RustTab.WIPES -> R.string.rust_tab_wipes
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ServerHeader(
|
||||
server: RustServerDto,
|
||||
selectedWipe: String?,
|
||||
onSelectWipe: (String?) -> Unit,
|
||||
wipes: UiState<List<RustWipeDto>>,
|
||||
) {
|
||||
Column(Modifier.padding(horizontal = 16.dp, vertical = 12.dp)) {
|
||||
Text(server.name.ifBlank { server.id }, style = MaterialTheme.typography.headlineSmall)
|
||||
|
||||
describeWorld(server)?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (server.online) {
|
||||
StatusPill(
|
||||
text = stringResource(R.string.rust_online_count, server.players, server.maxPlayers),
|
||||
tone = PillTone.Success,
|
||||
)
|
||||
} else {
|
||||
StatusPill(text = stringResource(R.string.rust_offline), tone = PillTone.Neutral)
|
||||
}
|
||||
Text(
|
||||
text = lastReported(server),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
// The wipe picker sits above the tabs because it filters two of them. It
|
||||
// is absent until the wipe list has loaded — offering a filter with one
|
||||
// option would look like a server that has only ever had one wipe.
|
||||
val available = (wipes as? UiState.Success)?.data.orEmpty()
|
||||
if (available.isNotEmpty()) {
|
||||
WipeFilter(available, server.wipeId, selectedWipe, onSelectWipe)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WipeFilter(
|
||||
wipes: List<RustWipeDto>,
|
||||
currentWipeId: String?,
|
||||
selected: String?,
|
||||
onSelect: (String?) -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(top = 10.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
// **Null is all time, and it is the default.** It is a real choice rather
|
||||
// than an absent filter: all-time is the per-wipe rows summed, which is
|
||||
// the answer to "who plays here", where a wipe is the answer to "who is
|
||||
// winning now".
|
||||
FilterChip(
|
||||
selected = selected == null,
|
||||
onClick = { onSelect(null) },
|
||||
label = { Text(stringResource(R.string.rust_all_time)) },
|
||||
)
|
||||
wipes.forEach { wipe ->
|
||||
val label = wipeDay(wipe.saveCreatedAt ?: wipe.firstSeen) ?: wipe.wipeId
|
||||
FilterChip(
|
||||
selected = selected == wipe.wipeId,
|
||||
onClick = { onSelect(wipe.wipeId) },
|
||||
label = {
|
||||
Text(
|
||||
if (wipe.wipeId == currentWipeId) {
|
||||
stringResource(R.string.rust_wipe_current, label)
|
||||
} else {
|
||||
label
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Feed ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@Composable
|
||||
private fun FeedPanel(
|
||||
feed: Polled<List<RustEventDto>>,
|
||||
filterId: String,
|
||||
onFilter: (String) -> Unit,
|
||||
onRetry: () -> Unit,
|
||||
) {
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState())
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
FEED_FILTERS.forEach { filter ->
|
||||
FilterChip(
|
||||
selected = filter.id == filterId,
|
||||
onClick = { onFilter(filter.id) },
|
||||
label = { Text(filter.label) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
when (val s = feed.state) {
|
||||
is UiState.Loading -> LoadingView()
|
||||
is UiState.Error -> ErrorView(s.kind, onRetry = onRetry)
|
||||
is UiState.Success -> if (s.data.isEmpty()) {
|
||||
EmptyView(stringResource(R.string.rust_feed_empty))
|
||||
} else {
|
||||
LazyColumn(
|
||||
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
if (feed.refreshFailed) {
|
||||
item { RefreshFailedLine() }
|
||||
}
|
||||
items(s.data, key = { it.id }) { FeedRow(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FeedRow(row: RustEventDto) {
|
||||
val line = describe(row)
|
||||
|
||||
Row(verticalAlignment = Alignment.Top) {
|
||||
// The stamp carries a date for anything not from today — a row from six
|
||||
// weeks ago rendered as a bare time reads as this afternoon, which is
|
||||
// exactly what happens the moment the feed is filtered to a past wipe.
|
||||
Text(
|
||||
text = feedClock(value = null, epochMillis = row.t),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(end = 10.dp, top = 2.dp),
|
||||
)
|
||||
Column {
|
||||
Row {
|
||||
line.actor?.let {
|
||||
Text(it, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold)
|
||||
Text(line.join, style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
// The chat message lands here, and it is the one field on this
|
||||
// wire whose bytes a player chooses. Compose renders it as text
|
||||
// and never as markup; nothing here may ever stop doing that.
|
||||
Text(line.verb, style = MaterialTheme.typography.bodyMedium)
|
||||
line.subject?.let {
|
||||
Text(" ", style = MaterialTheme.typography.bodyMedium)
|
||||
Text(it, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
}
|
||||
if (line.detail.isNotBlank()) {
|
||||
Text(
|
||||
text = line.detail,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Leaderboard ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The columns, and which of them the API can sort by.
|
||||
*
|
||||
* `structures` has no sort on the wire and therefore no tap here — a header that
|
||||
* sorts by something other than what it says is worse than one that does not
|
||||
* sort.
|
||||
*/
|
||||
private data class RustColumn(val labelRes: Int, val sort: String?, val value: (RustLeaderboardRowDto) -> String)
|
||||
|
||||
private val RUST_COLUMNS = listOf(
|
||||
RustColumn(R.string.rust_col_kills, RustSort.KILLS) { it.kills.toString() },
|
||||
RustColumn(R.string.rust_col_deaths, RustSort.DEATHS) { it.deaths.toString() },
|
||||
RustColumn(R.string.rust_col_npc_kills, RustSort.NPC_KILLS) { it.npcKills.toString() },
|
||||
RustColumn(R.string.rust_col_structures, null) { it.structures.toString() },
|
||||
RustColumn(R.string.rust_col_played, RustSort.PLAYTIME) { playtime(it.playtimeSec) },
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun LeaderboardPanel(
|
||||
state: UiState<List<RustLeaderboardRowDto>>,
|
||||
sort: String,
|
||||
onSort: (String) -> Unit,
|
||||
onRetry: () -> Unit,
|
||||
) {
|
||||
when (state) {
|
||||
is UiState.Loading -> LoadingView()
|
||||
is UiState.Error -> ErrorView(state.kind, onRetry = onRetry)
|
||||
is UiState.Success -> if (state.data.isEmpty()) {
|
||||
// **Empty is a real answer here and is not "no data".** All-time is
|
||||
// the per-wipe rows summed, so a player who appears only in an older
|
||||
// wipe drops out of the current one rather than reading zero — an
|
||||
// empty board for a wipe means nobody scored on that map.
|
||||
EmptyView(stringResource(R.string.rust_leaderboard_empty))
|
||||
} else {
|
||||
LazyColumn(
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
item {
|
||||
Row(Modifier.fillMaxWidth()) {
|
||||
SectionLabel(stringResource(R.string.rust_col_player), Modifier.weight(1f))
|
||||
RUST_COLUMNS.forEach { column ->
|
||||
SectionLabel(
|
||||
text = stringResource(column.labelRes),
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.then(
|
||||
if (column.sort != null) {
|
||||
Modifier.clickable { onSort(column.sort) }
|
||||
} else {
|
||||
Modifier
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
items(state.data, key = { it.steamId }) { row ->
|
||||
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = playerLabel(row.name, row.steamId),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
RUST_COLUMNS.forEach { column ->
|
||||
Text(
|
||||
text = column.value(row),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = if (column.sort == sort) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Online ────────────────────────────────────────────────────────────────
|
||||
|
||||
@Composable
|
||||
private fun OnlinePanel(
|
||||
online: Polled<List<RustPresenceDto>>,
|
||||
serverOnline: Boolean,
|
||||
onRetry: () -> Unit,
|
||||
) {
|
||||
when (val s = online.state) {
|
||||
is UiState.Loading -> LoadingView()
|
||||
is UiState.Error -> ErrorView(s.kind, onRetry = onRetry)
|
||||
is UiState.Success -> if (s.data.isEmpty()) {
|
||||
EmptyView(
|
||||
stringResource(
|
||||
if (serverOnline) R.string.rust_nobody_on else R.string.rust_presence_offline,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
LazyColumn(
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
// **The board is the last one that ARRIVED, and an unreachable
|
||||
// server does not clear it** — deliberately, because these rows
|
||||
// are still the best answer anybody has. Presented bare they read
|
||||
// as "these people are on right now", which is the one thing an
|
||||
// offline server cannot be saying. So the panel says which it is.
|
||||
item {
|
||||
Text(
|
||||
text = stringResource(
|
||||
if (serverOnline) R.string.rust_presence_live else R.string.rust_presence_last_known,
|
||||
),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
if (online.refreshFailed) {
|
||||
item { RefreshFailedLine() }
|
||||
}
|
||||
items(s.data, key = { it.steamId }) { player ->
|
||||
ShardCard(Modifier.fillMaxWidth()) {
|
||||
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = playerLabel(player.name, player.steamId),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
if (player.sleeping) {
|
||||
StatusPill(
|
||||
text = stringResource(R.string.rust_sleeping),
|
||||
tone = PillTone.Neutral,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Wipes ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@Composable
|
||||
private fun WipesPanel(
|
||||
state: UiState<List<RustWipeDto>>,
|
||||
currentWipeId: String?,
|
||||
selected: String?,
|
||||
onOpenWipe: (String) -> Unit,
|
||||
onRetry: () -> Unit,
|
||||
) {
|
||||
when (state) {
|
||||
is UiState.Loading -> LoadingView()
|
||||
is UiState.Error -> ErrorView(state.kind, onRetry = onRetry)
|
||||
is UiState.Success -> if (state.data.isEmpty()) {
|
||||
EmptyView(stringResource(R.string.rust_wipes_empty))
|
||||
} else {
|
||||
LazyColumn(
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
items(state.data, key = { it.wipeId }) { wipe ->
|
||||
ShardCard(
|
||||
Modifier.fillMaxWidth().clickable { onOpenWipe(wipe.wipeId) },
|
||||
) {
|
||||
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = wipeDay(wipe.saveCreatedAt ?: wipe.firstSeen) ?: wipe.wipeId,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
if (wipe.wipeId == currentWipeId) {
|
||||
StatusPill(
|
||||
text = stringResource(R.string.rust_wipe_this_one),
|
||||
tone = PillTone.Success,
|
||||
)
|
||||
} else if (wipe.wipeId == selected) {
|
||||
StatusPill(
|
||||
text = stringResource(R.string.rust_wipe_selected),
|
||||
tone = PillTone.Info,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RefreshFailedLine() {
|
||||
Text(
|
||||
text = stringResource(R.string.rust_refresh_failed),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.rust
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.runicgateway.app.data.api.dto.RustEventDto
|
||||
import com.runicgateway.app.data.api.dto.RustLeaderboardRowDto
|
||||
import com.runicgateway.app.data.api.dto.RustPresenceDto
|
||||
import com.runicgateway.app.data.api.dto.RustServerDto
|
||||
import com.runicgateway.app.data.api.dto.RustWipeDto
|
||||
import com.runicgateway.app.data.repository.RustRepository
|
||||
import com.runicgateway.app.ui.Polled
|
||||
import com.runicgateway.app.ui.UiState
|
||||
import com.runicgateway.app.ui.navigation.Routes
|
||||
import com.runicgateway.app.ui.refreshInto
|
||||
import com.runicgateway.app.ui.toUiState
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
/** The four sections of a server's page (D13). */
|
||||
enum class RustTab { FEED, LEADERBOARD, ONLINE, WIPES }
|
||||
|
||||
/** What a leaderboard column sorts by — the API's own vocabulary, not the app's. */
|
||||
object RustSort {
|
||||
const val KILLS = "kills"
|
||||
const val DEATHS = "deaths"
|
||||
const val NPC_KILLS = "npcKills"
|
||||
const val PLAYTIME = "playtime"
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything one server's page is showing.
|
||||
*
|
||||
* One state object rather than eight flows: every panel on the page is about the
|
||||
* same server and the same selected wipe, and a screen that collected them
|
||||
* separately could render a leaderboard for one wipe beside a feed for another
|
||||
* for a frame.
|
||||
*/
|
||||
data class RustServerUi(
|
||||
val serverId: String = "",
|
||||
val server: Polled<RustServerDto> = Polled(),
|
||||
val tab: RustTab = RustTab.FEED,
|
||||
val filterId: String = "all",
|
||||
val sort: String = RustSort.KILLS,
|
||||
/** The wipe every panel is filtered to. **Null is all time**, not "unknown". */
|
||||
val selectedWipe: String? = null,
|
||||
val feed: Polled<List<RustEventDto>> = Polled(),
|
||||
val online: Polled<List<RustPresenceDto>> = Polled(),
|
||||
val leaderboard: UiState<List<RustLeaderboardRowDto>> = UiState.Loading,
|
||||
val wipes: UiState<List<RustWipeDto>> = UiState.Loading,
|
||||
)
|
||||
|
||||
/**
|
||||
* One Rust server (PLAN.md §9 M14; `docs/modules/rust/PLAN.md` D13, D14).
|
||||
*
|
||||
* ## What polls and what does not
|
||||
*
|
||||
* D14, and it is a statement about the questions rather than about cost: the
|
||||
* **feed**, **who is on** and the **server's own line** change while somebody is
|
||||
* looking at the page, and the **leaderboard** and the **wipe list** do not in
|
||||
* any way a reader would want to watch. A leaderboard that re-sorted itself under
|
||||
* a finger every twenty seconds would be worse than a stale one.
|
||||
*
|
||||
* Only the **visible** live panel is polled. The website can afford to mount the
|
||||
* one tab it is showing; here the tabs are one screen, so the refresh asks what
|
||||
* the reader is actually looking at.
|
||||
*
|
||||
* ## Changing the question versus asking it again
|
||||
*
|
||||
* A poll is the same question asked again, so it keeps what is on screen
|
||||
* ([refreshInto]). Changing the filter, the sort or the wipe is a **different
|
||||
* question**, so the panel blanks and loads — what is there is an answer to
|
||||
* something the reader has stopped asking, and leaving it up while the new one
|
||||
* arrives would show a killfeed for last wipe under a heading naming this one.
|
||||
*/
|
||||
@HiltViewModel
|
||||
class RustServerViewModel @Inject constructor(
|
||||
private val repository: RustRepository,
|
||||
savedStateHandle: SavedStateHandle,
|
||||
) : ViewModel() {
|
||||
|
||||
private val serverId: String = savedStateHandle[Routes.Args.SERVER_ID] ?: ""
|
||||
|
||||
private val _state = MutableStateFlow(RustServerUi(serverId = serverId))
|
||||
val state: StateFlow<RustServerUi> = _state.asStateFlow()
|
||||
|
||||
init {
|
||||
load()
|
||||
}
|
||||
|
||||
/** A first load or a retry of the whole page. */
|
||||
fun load() {
|
||||
_state.update { it.copy(server = Polled(UiState.Loading), feed = Polled(UiState.Loading)) }
|
||||
viewModelScope.launch {
|
||||
askServer()
|
||||
askFeed()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The poll tick.
|
||||
*
|
||||
* The server line always, and then whichever live panel is on screen. A tab
|
||||
* showing the leaderboard or the wipes does no extra work — the reader is
|
||||
* looking at something that does not move.
|
||||
*/
|
||||
fun refresh() {
|
||||
viewModelScope.launch {
|
||||
askServer()
|
||||
when (_state.value.tab) {
|
||||
RustTab.FEED -> askFeed()
|
||||
RustTab.ONLINE -> askOnline()
|
||||
RustTab.LEADERBOARD, RustTab.WIPES -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a tab, loading its panel the first time it is opened.
|
||||
*
|
||||
* The two that do not poll are loaded exactly once per question: re-asking on
|
||||
* every tab switch would put a spinner over a leaderboard the reader has
|
||||
* already read, for an answer that cannot have changed while they were three
|
||||
* taps away.
|
||||
*/
|
||||
fun selectTab(tab: RustTab) {
|
||||
val already = _state.value
|
||||
_state.update { it.copy(tab = tab) }
|
||||
|
||||
viewModelScope.launch {
|
||||
when (tab) {
|
||||
RustTab.FEED -> if (already.feed.state !is UiState.Success) askFeed()
|
||||
RustTab.ONLINE -> if (already.online.state !is UiState.Success) askOnline()
|
||||
RustTab.LEADERBOARD -> if (already.leaderboard !is UiState.Success) askLeaderboard()
|
||||
RustTab.WIPES -> if (already.wipes !is UiState.Success) askWipes()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A different question for the feed: blank it and ask. */
|
||||
fun selectFilter(filterId: String) {
|
||||
if (filterId == _state.value.filterId) return
|
||||
_state.update { it.copy(filterId = filterId, feed = Polled(UiState.Loading)) }
|
||||
viewModelScope.launch { askFeed() }
|
||||
}
|
||||
|
||||
/** A different question for the leaderboard: blank it and ask. */
|
||||
fun selectSort(sort: String) {
|
||||
if (sort == _state.value.sort) return
|
||||
_state.update { it.copy(sort = sort, leaderboard = UiState.Loading) }
|
||||
viewModelScope.launch { askLeaderboard() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick a wipe, or all time with null.
|
||||
*
|
||||
* It is the one selection that changes **two** panels, so both are blanked —
|
||||
* and only the loaded ones are re-asked, so choosing a wipe from the Wipes tab
|
||||
* does not fetch a leaderboard nobody has opened.
|
||||
*/
|
||||
fun selectWipe(wipeId: String?) {
|
||||
if (wipeId == _state.value.selectedWipe) return
|
||||
|
||||
val hadLeaderboard = _state.value.leaderboard is UiState.Success
|
||||
_state.update {
|
||||
it.copy(
|
||||
selectedWipe = wipeId,
|
||||
feed = Polled(UiState.Loading),
|
||||
leaderboard = if (hadLeaderboard) UiState.Loading else it.leaderboard,
|
||||
)
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
askFeed()
|
||||
if (hadLeaderboard) askLeaderboard()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick a wipe from the Wipes tab, which is a navigation as much as a filter.
|
||||
*
|
||||
* The question it asks is "what happened during that map", and the answer is
|
||||
* the feed — so it lands there rather than leaving the reader on a list of
|
||||
* dates with nothing visibly changed.
|
||||
*/
|
||||
fun openWipe(wipeId: String) {
|
||||
selectWipe(wipeId)
|
||||
selectTab(RustTab.FEED)
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry one panel after its own load failed.
|
||||
*
|
||||
* Four entry points rather than one, because a failed leaderboard is not a
|
||||
* reason to re-read the feed the reader can already see — and [load] is the
|
||||
* whole page, which is right for a failed *server* read and heavy-handed for
|
||||
* anything else.
|
||||
*/
|
||||
fun retryFeed() {
|
||||
_state.update { it.copy(feed = Polled(UiState.Loading)) }
|
||||
viewModelScope.launch { askFeed() }
|
||||
}
|
||||
|
||||
fun retryLeaderboard() {
|
||||
_state.update { it.copy(leaderboard = UiState.Loading) }
|
||||
viewModelScope.launch { askLeaderboard() }
|
||||
}
|
||||
|
||||
fun retryOnline() {
|
||||
_state.update { it.copy(online = Polled(UiState.Loading)) }
|
||||
viewModelScope.launch { askOnline() }
|
||||
}
|
||||
|
||||
fun retryWipes() {
|
||||
_state.update { it.copy(wipes = UiState.Loading) }
|
||||
viewModelScope.launch { askWipes() }
|
||||
}
|
||||
|
||||
private suspend fun askServer() {
|
||||
val result = repository.server(serverId)
|
||||
_state.update { it.copy(server = refreshInto(it.server.state, result)) }
|
||||
}
|
||||
|
||||
private suspend fun askFeed() {
|
||||
val current = _state.value
|
||||
val result = repository.events(
|
||||
id = serverId,
|
||||
kinds = kindsFor(current.filterId),
|
||||
wipe = current.selectedWipe,
|
||||
limit = FEED_LIMIT,
|
||||
)
|
||||
_state.update { it.copy(feed = refreshInto(it.feed.state, result)) }
|
||||
}
|
||||
|
||||
private suspend fun askOnline() {
|
||||
val result = repository.online(serverId)
|
||||
_state.update { it.copy(online = refreshInto(it.online.state, result)) }
|
||||
}
|
||||
|
||||
private suspend fun askLeaderboard() {
|
||||
val current = _state.value
|
||||
val result = repository.leaderboard(
|
||||
id = serverId,
|
||||
wipe = current.selectedWipe,
|
||||
sort = current.sort,
|
||||
limit = LEADERBOARD_LIMIT,
|
||||
)
|
||||
_state.update { it.copy(leaderboard = result.toUiState()) }
|
||||
}
|
||||
|
||||
private suspend fun askWipes() {
|
||||
_state.update { it.copy(wipes = repository.wipes(serverId).toUiState()) }
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/** Matches the website's feed page size; the server caps at 200 regardless. */
|
||||
const val FEED_LIMIT = 100
|
||||
const val LEADERBOARD_LIMIT = 50
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.rust
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.runicgateway.app.R
|
||||
import com.runicgateway.app.data.api.dto.RustServerDto
|
||||
import com.runicgateway.app.ui.PollWhileResumed
|
||||
import com.runicgateway.app.ui.UiState
|
||||
import com.runicgateway.app.ui.components.EmptyView
|
||||
import com.runicgateway.app.ui.components.ErrorView
|
||||
import com.runicgateway.app.ui.components.LoadingView
|
||||
import com.runicgateway.app.ui.components.PillTone
|
||||
import com.runicgateway.app.ui.components.ShardCard
|
||||
import com.runicgateway.app.ui.components.StatusPill
|
||||
|
||||
/**
|
||||
* Every Rust server this site follows — the module's landing page (D8, D12).
|
||||
*
|
||||
* **The phase criterion is this screen with every server off.** Nothing here is a
|
||||
* live call to a game host: the website answers from its own tables, so a fleet
|
||||
* that has been down for a week renders a week of last-known state rather than an
|
||||
* error. The one thing that can fail is the website itself.
|
||||
*/
|
||||
@Composable
|
||||
fun RustServersScreen(
|
||||
onOpenServer: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: RustServersViewModel = hiltViewModel(),
|
||||
) {
|
||||
val polled by viewModel.state.collectAsStateWithLifecycle()
|
||||
|
||||
PollWhileResumed { viewModel.refresh() }
|
||||
|
||||
when (val s = polled.state) {
|
||||
is UiState.Loading -> LoadingView(modifier)
|
||||
is UiState.Error -> ErrorView(s.kind, onRetry = viewModel::load, modifier = modifier)
|
||||
is UiState.Success -> {
|
||||
if (s.data.isEmpty()) {
|
||||
EmptyView(stringResource(R.string.rust_servers_empty), modifier)
|
||||
} else {
|
||||
ServerList(s.data, polled.refreshFailed, onOpenServer, modifier)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ServerList(
|
||||
servers: List<RustServerDto>,
|
||||
refreshFailed: Boolean,
|
||||
onOpenServer: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
// A failed refresh says so and changes nothing else. The rows below it are
|
||||
// the last good answer and stay exactly as they were — blanking them is
|
||||
// the one thing a site whose premise is "it renders while the game is off"
|
||||
// must not do when a request fails.
|
||||
if (refreshFailed) {
|
||||
item {
|
||||
Text(
|
||||
text = stringResource(R.string.rust_refresh_failed),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
items(servers, key = { it.id }) { server ->
|
||||
ServerRow(server) { onOpenServer(server.id) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ServerRow(server: RustServerDto, onOpen: () -> Unit) {
|
||||
ShardCard(modifier = Modifier.fillMaxWidth().clickable(onClick = onOpen)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = server.name.ifBlank { server.id },
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
// `online` already has staleness folded into it server-side — a row
|
||||
// nobody has written recently cannot claim a server is up — so this
|
||||
// renders the field rather than second-guessing it.
|
||||
if (server.online) {
|
||||
StatusPill(
|
||||
text = stringResource(
|
||||
R.string.rust_online_count,
|
||||
server.players,
|
||||
server.maxPlayers,
|
||||
),
|
||||
tone = PillTone.Success,
|
||||
)
|
||||
} else {
|
||||
StatusPill(text = stringResource(R.string.rust_offline), tone = PillTone.Neutral)
|
||||
}
|
||||
}
|
||||
|
||||
val world = describeWorld(server)
|
||||
if (world != null) {
|
||||
Text(
|
||||
text = world,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = lastReported(server),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 2.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The world line — the things a Rust player asks first.
|
||||
*
|
||||
* Null when the server has never described itself, so the caller leaves the line
|
||||
* out rather than printing an empty one. A server configured this morning that
|
||||
* has not connected yet is in exactly that state, and it is not an error.
|
||||
*/
|
||||
@Composable
|
||||
internal fun describeWorld(server: RustServerDto): String? {
|
||||
val parts = listOfNotNull(
|
||||
server.level,
|
||||
server.worldSize?.let { stringResource(R.string.rust_world_size, it) },
|
||||
server.seed?.let { stringResource(R.string.rust_world_seed, it) },
|
||||
wipeDay(server.wipedAt)?.let { stringResource(R.string.rust_wiped_on, it) },
|
||||
)
|
||||
return parts.takeIf { it.isNotEmpty() }?.joinToString(" · ")
|
||||
}
|
||||
|
||||
/**
|
||||
* "last reported 3 minutes ago".
|
||||
*
|
||||
* **Reads `lastSeenAt` and never `updatedAt`.** The module shipped that exact
|
||||
* confusion and fixed it in phase 4: `updatedAt` moves on every poll including a
|
||||
* failed one, so an offline server claimed it had just checked in, every thirty
|
||||
* seconds, for as long as it stayed down. Only a frame moves `lastSeenAt`.
|
||||
*/
|
||||
@Composable
|
||||
internal fun lastReported(server: RustServerDto): String {
|
||||
val ago = rustAgo(server.lastSeenAt)
|
||||
?: return stringResource(R.string.rust_never_reported)
|
||||
|
||||
return if (server.stale) {
|
||||
stringResource(R.string.rust_last_reported_stale, ago)
|
||||
} else {
|
||||
stringResource(R.string.rust_last_reported, ago)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.rust
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.runicgateway.app.data.api.dto.RustServerDto
|
||||
import com.runicgateway.app.data.repository.RustRepository
|
||||
import com.runicgateway.app.ui.Polled
|
||||
import com.runicgateway.app.ui.UiState
|
||||
import com.runicgateway.app.ui.refreshInto
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* The Rust server list — the module's landing page, one tier along (PLAN.md §9
|
||||
* M14; `docs/modules/rust/PLAN.md` D12).
|
||||
*
|
||||
* **`toUiState`, not `toShardUiState`.** These routes carry no `requireFeature`
|
||||
* gate, so a `404` here is a genuinely missing thing and never an admin's
|
||||
* visibility switch. Offering "this shard doesn't publish it" for one would name
|
||||
* a cause that does not exist on this surface.
|
||||
*
|
||||
* [refresh] is what the screen's poll calls and [load] is what a retry calls, and
|
||||
* the difference is the whole of [refreshInto]: a refresh keeps the rows when it
|
||||
* fails, a load is allowed to blank them because there is nothing on screen to
|
||||
* protect.
|
||||
*/
|
||||
@HiltViewModel
|
||||
class RustServersViewModel @Inject constructor(
|
||||
private val repository: RustRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _state = MutableStateFlow(Polled<List<RustServerDto>>())
|
||||
val state: StateFlow<Polled<List<RustServerDto>>> = _state.asStateFlow()
|
||||
|
||||
/** A first load or a retry: show the spinner, then replace whatever comes back. */
|
||||
fun load() {
|
||||
_state.value = Polled(UiState.Loading)
|
||||
viewModelScope.launch { ask() }
|
||||
}
|
||||
|
||||
/** A poll: silent on success, and it keeps the rows on failure. */
|
||||
fun refresh() {
|
||||
viewModelScope.launch { ask() }
|
||||
}
|
||||
|
||||
private suspend fun ask() {
|
||||
_state.value = refreshInto(_state.value.state, repository.servers())
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,7 @@
|
||||
<string name="menu_events">Events</string>
|
||||
<string name="menu_wiki">Wiki</string>
|
||||
<string name="menu_shard">Shard</string>
|
||||
<string name="menu_rust">Rust servers</string>
|
||||
<string name="menu_rules">Rules</string>
|
||||
<string name="menu_atlas">Atlas</string>
|
||||
<string name="menu_leaderboards">Leaderboards</string>
|
||||
@@ -566,4 +567,42 @@
|
||||
<string name="events_score">Score %1$s</string>
|
||||
<string name="events_show_more">Show more</string>
|
||||
<string name="events_loading">Loading…</string>
|
||||
|
||||
<!-- Rust module (M14, docs/modules/rust/PLAN.md §18) -->
|
||||
<string name="rust_servers_empty">No Rust servers are configured on this site yet.</string>
|
||||
<string name="rust_offline">Offline</string>
|
||||
<string name="rust_online_count">%1$d / %2$d online</string>
|
||||
<string name="rust_never_reported">has never reported</string>
|
||||
<string name="rust_last_reported">last reported %1$s</string>
|
||||
<string name="rust_last_reported_stale">last reported %1$s — out of date, so it is shown as offline</string>
|
||||
<string name="rust_world_size">size %1$d</string>
|
||||
<string name="rust_world_seed">seed %1$d</string>
|
||||
<string name="rust_wiped_on">wiped %1$s</string>
|
||||
<string name="rust_refresh_failed">Could not refresh just now. This is the last thing the site heard.</string>
|
||||
<string name="rust_no_such_server">No such server</string>
|
||||
<string name="rust_no_such_server_detail">This address does not name a server this site follows.</string>
|
||||
<string name="rust_back_to_servers">Back to the server list</string>
|
||||
<string name="rust_tab_feed">Feed</string>
|
||||
<string name="rust_tab_leaderboard">Leaderboard</string>
|
||||
<string name="rust_tab_online">Online</string>
|
||||
<string name="rust_tab_wipes">Wipes</string>
|
||||
<string name="rust_all_time">All time</string>
|
||||
<string name="rust_wipe_current">%1$s (this wipe)</string>
|
||||
<string name="rust_wipe_this_one">Current</string>
|
||||
<string name="rust_wipe_selected">Showing</string>
|
||||
<string name="rust_feed_empty">Nothing has happened on this server yet — or not during the wipe you are looking at.</string>
|
||||
<string name="rust_leaderboard_empty">Nobody has scored here yet.</string>
|
||||
<string name="rust_wipes_empty">This server has not reported a wipe yet.</string>
|
||||
<string name="rust_nobody_on">The server is up and the island is empty. Somebody has to be first.</string>
|
||||
<string name="rust_presence_offline">Presence is the one thing on this page that cannot be answered from the record — it is who is connected now, and nothing is.</string>
|
||||
<string name="rust_presence_live">On the server right now.</string>
|
||||
<string name="rust_presence_last_known">The last board this server sent. It is offline, so this is who was on then — not who is on now.</string>
|
||||
<string name="rust_sleeping">Sleeping</string>
|
||||
<string name="rust_col_player">Player</string>
|
||||
<string name="rust_col_kills">Kills</string>
|
||||
<string name="rust_col_deaths">Deaths</string>
|
||||
<string name="rust_col_npc_kills">NPC</string>
|
||||
<string name="rust_col_structures">Built</string>
|
||||
<string name="rust_col_played">Played</string>
|
||||
<string name="rust_online_badge">%1$d players online</string>
|
||||
</resources>
|
||||
|
||||
Reference in New Issue
Block a user