feat(events): the app's events screens, and the module rows that were never gated (Phase 14b)
All checks were successful
PR Checks / android-build (pull_request) Successful in 12m7s

Events Phase 14b, the app half — recorded as M13 in docs/android/PLAN.md.

Four screens on the four routes Phase 14a shipped: the public calendar, an event
page carrying `?run=`, an arc, and participation history. One drawer row for the
history, at SIGNED_IN rather than PLAYER: the route is `requireAuth` alone and
self-scoped, and the website needed two mounts for it only because `RequirePlayer`
guards `/account` there.

The prerequisite fix is the larger half. The app read `/public/modules` nowhere
and mapped every `/public/shard/features` failure to "unknown", which `canSee`
treats as visible — so on a site with no `uo` module every shard row rendered and
every one of them 404'd. Absence of an answer is not an answer of absence: a
successful module list that omits `shard` hides the rows, a failed read keeps the
last answer the host gave, and a host that has never answered leaves the gate
open. Capability and feature compose as two gates and answer different questions:
whether the module is installed (per host) and whether this shard publishes the
surface to this viewer (per viewer).

Also corrects the website path → route table, wrong since the module-system
cutover on 2026-08-12: core's NAV is eight rows, not sixteen, and the nine shard
rows moved to `/uo/*`. A nav override on any shard row was ignored, an added link
to one handed off to a browser, and the sort-key line was wrong. Two existing
tests had been passing vacuously since that day.

An inbox link to an event now opens the app rather than a Custom Tab, through
`resolveWebPath` rather than a second mechanism — so its "a query hands off" rule
gains exactly one exception, `run` on an event page.

The emulator walk found three defects that 563 green tests did not:

- the three player game-data rows read `/player/shard/*` and were not gated, so
  they rendered and 404'd; the test meant to catch that asked whether every row
  *with a feature* declared the capability, and those three have none. It now
  asks by route.
- `score` is DECIMAL(18,4) and was declared an integer, so one `318.5` made
  kotlinx refuse the entire body and a 200 rendered as a server error — latent on
  the public results table for every visitor.
- a drawer route's view model outlives a sign-out, so signing in as a second
  account showed it the first account's participation history with no request
  made at all.

570 tests, 0 failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
This commit is contained in:
2026-09-08 13:08:43 -05:00
parent 80441c3367
commit e10e1f1617
38 changed files with 3346 additions and 142 deletions

View File

@@ -0,0 +1,41 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.core.time
import java.time.Instant
import java.time.LocalDateTime
import java.time.ZoneId
/**
* Parse a timestamp off the wire, in either shape the backend sends.
*
* **Which one arrives is not the app's to decide.** Express serializes a `Date`
* to ISO-8601 with a `Z`, but these values start life as MariaDB `DATETIME`
* columns, and one read back as a string reaches the wire as
* `2026-08-31 07:13:50` with no zone at all. A zoneless stamp is read as **UTC**,
* because that is what the server stores — reading it as local time would
* silently shift every timestamp by the device's offset, which is a bug that
* looks right on the machine it was written on.
*
* Anything unparseable answers null, and every caller is expected to render
* *something* without it: a notification with an odd date is still worth reading,
* and an event with one is still worth listing.
*
* Lives here rather than beside either caller because the trap is the wire's, not
* one screen's — the inbox found it (ENGAGEMENT.md phase 8) and the event screens
* inherit it (EVENTS.md §I).
*/
fun parseWireInstant(raw: String?): Instant? {
val text = raw?.trim().orEmpty()
if (text.isEmpty()) return null
return try {
Instant.parse(text)
} catch (_: Exception) {
try {
LocalDateTime.parse(text.replace(' ', 'T')).atZone(ZoneId.of("UTC")).toInstant()
} catch (_: Exception) {
null
}
}
}

View File

@@ -0,0 +1,84 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.data.api
import com.runicgateway.app.data.api.dto.EventCalendarDto
import com.runicgateway.app.data.api.dto.EventHistoryDto
import com.runicgateway.app.data.api.dto.EventSeriesResponse
import com.runicgateway.app.data.api.dto.PublicEventResponse
import retrofit2.http.GET
import retrofit2.http.Path
import retrofit2.http.Query
/**
* The event surface (PLAN.md §9 M13, `docs/website/EVENTS.md` § API surface).
*
* **These are CORE routes, not a module's**, which is why they live here rather
* than beside the shard reads in [PublicApi]: they exist on a backend
* with no game module installed at all, and they are gated by core's own `events`
* capability rather than by a module's. Nothing here is under `/shard`.
*
* The three public reads and the one player read share an interface for the same
* reason the website mounts them in one feature: the history row's whole purpose
* is to link back to the public page. The player call carries a bearer through
* [com.runicgateway.app.core.net.AuthInterceptor] like every other authenticated
* call; there is one Retrofit.
*/
interface EventsApi {
/**
* The public calendar. Defaults to now through 31 days out when neither end
* is named; the window may span at most 92 days and the server 400s past it.
*
* Rehearsals and unlisted events are absent — that filtering is in SQL, not
* in the answer, so there is nothing here to re-check.
*/
@GET("api/v1/public/events")
suspend fun getCalendar(
@Query("from") from: String? = null,
@Query("to") to: String? = null,
@Query("seriesId") seriesId: Long? = null,
): EventCalendarDto
/**
* One event.
*
* **[run] selects which occurrence the results table is about**, and is what
* an announcement's link carries: the page lives at the definition's slug, so
* a weekly event has one address that survives a retitle, while every
* `event.` trigger is about one occurrence. A run belonging to some other
* event is ignored rather than refused, so a stale link in a months-old mail
* still opens the page it was about.
*
* A draft, an archived definition and an unlisted one all answer 404,
* indistinguishable from a slug that never existed.
*/
@GET("api/v1/public/events/{slug}")
suspend fun getEvent(
@Path("slug") slug: String,
@Query("run") run: String? = null,
): PublicEventResponse
/**
* One arc. A series with no listed events answers 404 rather than an empty
* page — an arc is a label on its definitions, so a page for an empty one
* would publish that an operator has named something they have not announced.
*/
@GET("api/v1/public/events/series/{slug}")
suspend fun getSeries(@Path("slug") slug: String): EventSeriesResponse
/**
* The caller's own participation history. Self-scoped on the session's user
* id server-side; there is deliberately no id parameter here, because there
* is none on the route.
*
* [before] is a participation row id, not an offset — the list gains rows at
* the top as the reader attends things.
*/
@GET("api/v1/player/events/history")
suspend fun getHistory(
@Query("limit") limit: Int? = null,
@Query("before") before: Long? = null,
): EventHistoryDto
}

View File

@@ -18,6 +18,7 @@ import com.runicgateway.app.data.api.dto.HouseDto
import com.runicgateway.app.data.api.dto.MarketMetaDto
import com.runicgateway.app.data.api.dto.MarketPageDto
import com.runicgateway.app.data.api.dto.MarketVendorDto
import com.runicgateway.app.data.api.dto.ModulesDto
import com.runicgateway.app.data.api.dto.OnlineStaffDto
import com.runicgateway.app.data.api.dto.PageDto
import com.runicgateway.app.data.api.dto.PointsBoardDto
@@ -67,6 +68,18 @@ interface PublicApi {
@GET("api/v1/public/settings")
suspend fun getSettings(): SettingsDto
/**
* Which modules this backend is serving, and the capabilities each declares
* (§5, M13). Read together with the `version` block's own `capabilities` —
* core's list and a module's are separate lists on purpose.
*
* This is what lets the app tell a module that is **not installed** from a
* lookup that failed: `/public/shard/features` 404s in both cases, and only
* this call distinguishes them.
*/
@GET("api/v1/public/modules")
suspend fun getModules(): ModulesDto
// ── News & content ───────────────────────────────────────────────────
@GET("api/v1/public/posts/{category}")
suspend fun getPosts(@Path("category") category: String): List<PostDto>

View File

@@ -0,0 +1,212 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.data.api.dto
import kotlinx.serialization.Serializable
/**
* Wire shapes for the public event surface (`docs/website/EVENTS.md` §I, events
* Phase 14a; the app's half is M13). Field names match
* `server/src/model/events/eventPublic.model.js` exactly.
*
* **That model is a PROJECTION, and these DTOs must not out-grow it.** Nothing on
* the server side is spread into a public entry — a field reaches one because a
* line put it there — so three things are absent from every shape below and each
* absence is a decision core made: the **spec** (phases, steps, actions and their
* params are the operator's plan for changing a live world; a visitor gets the
* phase LABEL while a run is live and nothing else), **health, cleanup, claims
* and errors** (facts about the deployment's plumbing, not about the event), and
* **`member_key`** (module-opaque, so core cannot say what publishing one would
* disclose). Adding a field here that the server does not send would decode to a
* default and render as a fact.
*
* Every DTO ignores unknown keys (NetworkModule's lenient Json), so an additive
* backend field is safe.
*/
/**
* One calendar entry. [kind] is `run` or `projected` and the two are drawn
* differently on purpose.
*
* A **run** is a materialised occurrence: a row exists, it can be cancelled, and
* what it says is committed to. A **projected** entry is arithmetic past the
* materialisation horizon — a forecast with nothing behind it — so the screen
* labels it rather than drawing it as a booking. [adjusted] and [shiftMinutes]
* only ever arrive on a projection, and say a DST shift moved it.
*
* [scheduledFor] is a UTC instant and [timezone] is the EVENT's own zone, never
* the reader's. See [com.runicgateway.app.ui.events.eventTime].
*/
@Serializable
data class EventCalendarEntryDto(
val kind: String = "run",
val title: String = "",
val slug: String = "",
val seriesName: String? = null,
val seriesSlug: String? = null,
val scheduledFor: String = "",
val timezone: String? = null,
val status: String = "scheduled",
val live: Boolean = false,
val adjusted: Boolean = false,
val shiftMinutes: Int = 0,
) {
/** True for a forecast the server has committed nothing to. */
val isProjected: Boolean get() = kind == "projected"
}
/** `GET /public/events` — the calendar for a window, ascending by instant. */
@Serializable
data class EventCalendarDto(
val entries: List<EventCalendarEntryDto> = emptyList(),
/** True when the server capped the answer; the screen says so rather than lying by omission. */
val truncated: Boolean = false,
)
/**
* One occurrence on an event's page.
*
* [phase] is the label of the phase a live run is in, resolved from the version
* that run PINNED — so an edit since does not relabel a run in flight. It is null
* on anything that is not live, which is why the screen only ever shows it there.
*/
@Serializable
data class EventOccurrenceDto(
val runId: Long = 0,
val scheduledFor: String = "",
val timezone: String? = null,
val startedAt: String? = null,
val endedAt: String? = null,
val status: String = "scheduled",
val live: Boolean = false,
val scope: String? = null,
val phase: String? = null,
val resultsPublishedAt: String? = null,
)
/**
* One row of a published results table.
*
* [name] is whatever the module put in its participation `meta`, and there is
* genuinely nothing else to render when it is absent: core has no name for a
* character and the member key is not published, so the screen says "Unnamed"
* rather than inventing one.
*
* **[score] is fractional, and it has to be.** `event_run_participants.score` is
* `DECIMAL(18,4)`, and a module scoring by distance, time or a weighted tally
* writes a fraction — the live walk found `318.5` in the first row it read.
* Declaring it `Long` does not merely round: kotlinx REFUSES the body, the whole
* response fails to decode, and the screen reports a server error for a `200`.
* See [com.runicgateway.app.ui.events.scoreText] for how it is rendered.
*/
@Serializable
data class EventParticipantDto(
val name: String? = null,
val score: Double = 0.0,
val rank: Int? = null,
)
/** The results table for ONE occurrence, present only once it has been published. */
@Serializable
data class EventResultsDto(
val runId: Long = 0,
val scheduledFor: String = "",
val publishedAt: String? = null,
val participants: List<EventParticipantDto> = emptyList(),
)
/** The arc an event belongs to, as its own page names it. */
@Serializable
data class EventSeriesRefDto(
val name: String = "",
val slug: String = "",
)
/** `GET /public/events/:slug` — the event. */
@Serializable
data class PublicEventDto(
val title: String = "",
val slug: String = "",
val summary: String? = null,
/** Sanitized HTML, written the way a wiki page and a forum post are. */
val body: String? = null,
val imageUrl: String? = null,
val timezone: String? = null,
val series: EventSeriesRefDto? = null,
val live: Boolean = false,
val current: EventOccurrenceDto? = null,
/**
* The next occurrence — **narrower than the first of [upcoming]**, and the
* server decides which. A cancelled occurrence still appears under what is
* coming, because "next Friday is off" is what somebody checking a calendar
* came to find out; it is not what "next" means.
*/
val next: EventOccurrenceDto? = null,
val upcoming: List<EventOccurrenceDto> = emptyList(),
val past: List<EventOccurrenceDto> = emptyList(),
val results: EventResultsDto? = null,
)
/** The envelope `GET /public/events/:slug` answers with. */
@Serializable
data class PublicEventResponse(val event: PublicEventDto = PublicEventDto())
/** One event as an arc lists it — the editor's order, so no dates. */
@Serializable
data class EventSeriesEntryDto(
val title: String = "",
val slug: String = "",
val summary: String? = null,
val imageUrl: String? = null,
)
/** `GET /public/events/series/:slug` — one arc and the listed events in it. */
@Serializable
data class EventSeriesDto(
val name: String = "",
val slug: String = "",
val description: String? = null,
val events: List<EventSeriesEntryDto> = emptyList(),
)
/** The envelope `GET /public/events/series/:slug` answers with. */
@Serializable
data class EventSeriesResponse(val series: EventSeriesDto = EventSeriesDto())
/**
* One row of the caller's own participation history.
*
* [rank] is null until `core.results.publish` ran for that occurrence, and that
* is a real state rather than an error — the screen says "not published" rather
* than rendering a dash that reads as a bug.
*
* [id] is the participation row's own id and is what the keyset page walks back
* on: the list gains a row every time the reader attends something, so an offset
* would skip and repeat.
*/
@Serializable
data class EventHistoryEntryDto(
val id: Long = 0,
val runId: Long = 0,
val title: String = "",
val slug: String = "",
val seriesName: String? = null,
val seriesSlug: String? = null,
val scheduledFor: String = "",
val startedAt: String? = null,
val endedAt: String? = null,
val timezone: String? = null,
val status: String = "scheduled",
val joinedAt: String? = null,
// Fractional, for the reason [EventParticipantDto.score] gives.
val score: Double = 0.0,
val rank: Int? = null,
val resultsPublishedAt: String? = null,
)
/** `GET /player/events/history` — self-scoped, one page. */
@Serializable
data class EventHistoryDto(
val entries: List<EventHistoryEntryDto> = emptyList(),
)

View File

@@ -20,6 +20,47 @@ data class VersionDto(
val service: String = "",
val api: String = "",
val server: String = "",
/**
* What CORE serves beyond the baseline every backend has (events Phase 14a;
* `MODULE_API.md` §2.9). Opaque strings, the same word a module uses on
* `GET /public/modules` so a client feature-detects one way, and a **separate
* list** because core is not a module.
*
* **The value is in what is absent**, which is why the default is empty
* rather than something meaningful: a backend released before a capability
* existed omits the key entirely, and that is how the app tells an older site
* from one that simply has nothing to show. An unknown string is absent, and
* no route may be inferred from one.
*/
val capabilities: List<String> = emptyList(),
)
/**
* One installed, **started** module on `GET /public/modules`.
*
* A module that is disabled or failed to load is absent rather than listed with a
* state — its routes and its nav are absent too, so a client renders a site
* without that capability rather than one advertising a capability that 503s.
*/
@Serializable
data class InstalledModuleDto(
val id: String = "",
val name: String = "",
val version: String = "",
val capabilities: List<String> = emptyList(),
)
/**
* `GET /public/modules` — what this backend is serving beyond core.
*
* Database-free and never gated by site mode, so the app can feature-detect
* during maintenance. A **500** is the one answer that is not an answer: core
* refuses to return `[]` for a list read before its loader ran, because a caller
* cannot tell an empty list from a mis-ordered boot.
*/
@Serializable
data class ModulesDto(
val modules: List<InstalledModuleDto> = emptyList(),
)
/** `GET /public/status` — site mode + version for the first-run probe (§3). */

View File

@@ -29,6 +29,7 @@ class ConnectionRepository @Inject constructor(
private val sessionManager: SessionManager,
private val trustTokenStore: TrustTokenStore,
private val shardFeaturesRepository: ShardFeaturesRepository,
private val siteCapabilitiesRepository: SiteCapabilitiesRepository,
private val pushManager: com.runicgateway.app.core.push.PushManager,
private val config: com.runicgateway.app.core.AppConfig,
) {
@@ -116,6 +117,10 @@ class ConnectionRepository @Inject constructor(
// a switch between two signed-out hosts changes no session, so nothing else
// invalidates the cache and the new shard would inherit the old one's menu.
shardFeaturesRepository.invalidate()
// Same argument, one layer up: what the OLD host served says nothing about
// the new one, and a stale "this backend has no game module" would hide the
// new host's shard rows until its first successful read.
siteCapabilitiesRepository.invalidate()
prefs.clear()
baseUrlHolder.set(null)
}

View File

@@ -0,0 +1,61 @@
/*
* 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.EventsApi
import com.runicgateway.app.data.api.dto.EventCalendarDto
import com.runicgateway.app.data.api.dto.EventHistoryEntryDto
import com.runicgateway.app.data.api.dto.EventSeriesDto
import com.runicgateway.app.data.api.dto.PublicEventDto
import javax.inject.Inject
import javax.inject.Singleton
/**
* The event calendar, event pages, arcs and the caller's own participation
* history (PLAN.md §6.1, §9 M13).
*
* The two single-object reads unwrap their envelope here rather than in a view
* model, so a screen never holds a `…Response` whose only job was to carry one
* field. The calendar and the history keep theirs: `truncated` is a fact about
* the answer that the screen renders, and the history's page is a list the pager
* appends to.
*/
@Singleton
class EventsRepository @Inject constructor(
private val api: EventsApi,
) {
/** The public calendar. Both ends optional; the server's default window is 31 days. */
suspend fun calendar(
from: String? = null,
to: String? = null,
seriesId: Long? = null,
): ApiResult<EventCalendarDto> = safeApiCall { api.getCalendar(from, to, seriesId) }
/**
* One event, optionally about one occurrence.
*
* [run] is passed through untouched — including a run that belongs to some
* other event, which the server ignores rather than refusing. Filtering it
* here would turn a stale link into a dead end instead of a page about the
* thing the link was about.
*/
suspend fun event(slug: String, run: String? = null): ApiResult<PublicEventDto> =
safeApiCall { api.getEvent(slug, run?.takeIf { it.isNotBlank() }) }.map { it.event }
/** One arc. A series with nothing listed in it answers 404, not an empty page. */
suspend fun series(slug: String): ApiResult<EventSeriesDto> =
safeApiCall { api.getSeries(slug) }.map { it.series }
/**
* One page of the caller's own participation history, newest first.
*
* [before] is the id of the last row already shown — a keyset page, not an
* offset, because the list gains rows at the top as the reader attends things.
*/
suspend fun history(limit: Int, before: Long? = null): ApiResult<List<EventHistoryEntryDto>> =
safeApiCall { api.getHistory(limit, before) }.map { it.entries }
}

View File

@@ -0,0 +1,164 @@
/*
* 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.safeApiCall
import com.runicgateway.app.data.api.PublicApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import javax.inject.Inject
import javax.inject.Singleton
/**
* What this BACKEND serves — core's own capabilities and every started module's
* (PLAN.md §5, §9 M13; `docs/website/MODULE_API.md` §2.9).
*
* ## Why this exists at all, and why it is not [ShardFeaturesRepository]
*
* The two answer different questions and neither can answer the other's:
*
* - **Capability — is this module installed at all?** Per HOST. It changes when
* an operator installs or removes a module, so it is resolved beside the
* appearance and invalidated on a server switch.
* - **Feature — does this shard publish this surface to this viewer?** Per
* VIEWER. It changes on sign-in, which is why it is resolved on every session
* change.
*
* Without the first, the app cannot tell a module that is **not installed** from
* a lookup that failed: `GET /public/shard/features` 404s in both cases, and
* [ShardFeaturesRepository] maps every failure to "unknown", which [canSee]
* treats as visible. On a site running a different game that renders every shard
* row in the drawer and every one of them 404s when tapped.
*
* ## Absence of an answer is not an answer of absence
*
* The distinction this class exists to make, and the reason [SiteCapabilities]
* carries no "unknown" member of its own — the *absence of the whole value* is
* the unknown state:
*
* - a **successful** read that does not name a capability is an answer, and
* [canUse] hides what needs it;
* - a **failed** read keeps the last answer this host gave, because a moment
* with no connectivity is not an uninstall;
* - a host that has **never** answered leaves the value null, and [canUse]
* passes — the drawer renders as it did before this existed rather than
* flickering its rows in on every cold start.
*
* The last one is deliberately the same fail-open direction [canSee] takes, for
* the same reason: the server gates every call regardless, so the cost of
* guessing wrong is a link that briefly 404s.
*/
@Singleton
class SiteCapabilitiesRepository @Inject constructor(
private val api: PublicApi,
) {
private val _capabilities = MutableStateFlow<SiteCapabilities?>(null)
/** The current answer, or `null` while this host has never given one. */
val capabilities: StateFlow<SiteCapabilities?> = _capabilities.asStateFlow()
// Serializes concurrent refreshes: the shell refreshes on resume and the
// connect flow refreshes on first load, and two overlapping reads would race
// to publish.
private val mutex = Mutex()
/**
* Re-resolve what this backend serves.
*
* **Two calls, and one failing is not the same as both failing.** Core's list
* and a module's are separate lists (§2.9), so they are merged from separate
* reads and each is kept only if it answered. A backend released before
* events omits `capabilities` from its `version` block entirely, which is an
* answer — the empty list — and not a failure.
*/
suspend fun refresh() = mutex.withLock {
val status = safeApiCall { api.getStatus() }
val modules = safeApiCall { api.getModules() }
// Neither call answered: keep whatever this host said last, which for a
// host that has never answered is still null.
if (status !is ApiResult.Ok && modules !is ApiResult.Ok) return@withLock
val previous = _capabilities.value
val core = (status as? ApiResult.Ok)?.data?.version?.capabilities?.toSet()
?: previous?.core
?: emptySet()
val installed = (modules as? ApiResult.Ok)?.data?.modules
?.flatMap { it.capabilities }
?.toSet()
?: previous?.modules
?: emptySet()
_capabilities.value = SiteCapabilities(core = core, modules = installed)
}
/**
* Drop the answer. Called on a Settings → Server switch: capabilities belong
* to the host that reported them, and the new host must not inherit them —
* a switch between two signed-out hosts changes no session, so nothing else
* would invalidate this.
*/
fun invalidate() {
_capabilities.value = null
}
}
/**
* What one backend serves, as two lists rather than one.
*
* They are kept apart because core is not a module: merging them would leave the
* app unable to tell *"this backend has events"* from *"a module called core
* happens to be installed"*, which is exactly the distinction
* `GET /public/modules` exists to make. [canUse] looks in both, because a menu
* entry does not care which half serves it — but the halves stay separable, so a
* future caller that does care still can.
*/
data class SiteCapabilities(
/** Core's own, from the `version` block. Empty on a backend that predates them. */
val core: Set<String>,
/** Every started module's, flattened. Two modules may declare the same string. */
val modules: Set<String>,
) {
/** True when either half names [capability]. */
operator fun contains(capability: String): Boolean =
capability in core || capability in modules
}
/**
* True when [capability] may be relied on — **or when this host has not answered
* yet**.
*
* The null case is the fail-open one and it is not the same as the empty one: a
* [SiteCapabilities] that names nothing is a backend that told us it serves
* nothing extra, and that hides. See the class doc above.
*
* `null` [capability] means the caller declared none, which always passes.
*/
fun canUse(capabilities: SiteCapabilities?, capability: String?): Boolean =
capability == null || capabilities == null || capability in capabilities
/**
* The capability strings the app gates on.
*
* **Deliberately few.** `module-uo` declares eight, and gating each shard row on
* its own would be a second, worse copy of what the per-viewer feature flags
* already decide — and one that drifts, because a capability is opaque to core
* and nothing checks the two agree. One string answers the only question a
* capability can: is the module there.
*/
object Capability {
/**
* A game module serving a live shard. Declared by `module-uo`; a different
* game's module that serves the same surfaces would declare it too, which is
* the point of an opaque string.
*/
const val SHARD = "shard"
/** Core's event system (events Phase 14a). Never a module's. */
const val EVENTS = "events"
}

View File

@@ -15,6 +15,7 @@ import com.runicgateway.app.core.net.TokenAuthenticator
import com.runicgateway.app.core.net.UserAgentInterceptor
import com.runicgateway.app.data.api.AuthApi
import com.runicgateway.app.data.api.AuthRefreshApi
import com.runicgateway.app.data.api.EventsApi
import com.runicgateway.app.data.api.MeApi
import com.runicgateway.app.data.api.AdminApi
import com.runicgateway.app.data.api.NotificationsApi
@@ -122,6 +123,15 @@ object NetworkModule {
fun providePlayerShardApi(retrofit: Retrofit): PlayerShardApi =
retrofit.create(PlayerShardApi::class.java)
/**
* The event surface (§9 M13). Three public reads and one bearer-authed player
* read on one interface — they are all CORE routes, so none of them is a
* module path and none is under `/shard`.
*/
@Provides
@Singleton
fun provideEventsApi(retrofit: Retrofit): EventsApi = retrofit.create(EventsApi::class.java)
/** Opt-in push devices + subscriptions (§11, M7) — bearer-authed on the main client. */
@Provides
@Singleton

View File

@@ -11,6 +11,7 @@ import com.runicgateway.app.core.result.ApiResult
import com.runicgateway.app.data.appearance.SiteAppearance
import com.runicgateway.app.data.repository.ConnectionRepository
import com.runicgateway.app.data.repository.SettingsRepository
import com.runicgateway.app.data.repository.SiteCapabilitiesRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -29,6 +30,7 @@ class AppViewModel @Inject constructor(
private val settingsRepository: SettingsRepository,
private val baseUrlHolder: BaseUrlHolder,
private val pushManager: PushManager,
private val siteCapabilitiesRepository: SiteCapabilitiesRepository,
) : ViewModel() {
sealed interface AppState {
@@ -76,6 +78,12 @@ class AppViewModel @Inject constructor(
fun refreshAppearance() {
if (_state.value !is AppState.Ready) return
viewModelScope.launch {
// What the backend SERVES is a per-host fact and refreshes on the same
// clock as the appearance: an operator who installs a module while the
// app is backgrounded should see its rows on the next resume. Done
// before the early return below, because a failed settings read is no
// reason to skip it — they are separate calls to separate routes.
siteCapabilitiesRepository.refresh()
val settings = (settingsRepository.getSettings() as? ApiResult.Ok)?.data ?: return@launch
pushManager.setNtfyUrl(settings.push.ntfyUrl)
// changeServer() may have raced us back to the connect screen while the
@@ -100,6 +108,7 @@ class AppViewModel @Inject constructor(
* or sign-in. Returns [SiteAppearance.NONE] if settings couldn't be loaded.
*/
private suspend fun loadAppearance(): SiteAppearance {
siteCapabilitiesRepository.refresh()
val settings = (settingsRepository.getSettings() as? ApiResult.Ok)?.data
pushManager.setNtfyUrl(settings?.push?.ntfyUrl)
return SiteAppearance.from(settings)

View File

@@ -64,6 +64,10 @@ import com.runicgateway.app.ui.auth.TrustedDevicesScreen
import com.runicgateway.app.ui.auth.roleLabelRes
import com.runicgateway.app.ui.components.BrandLogo
import com.runicgateway.app.ui.contact.ContactScreen
import com.runicgateway.app.ui.events.EventScreen
import com.runicgateway.app.ui.events.EventSeriesScreen
import com.runicgateway.app.ui.events.EventsScreen
import com.runicgateway.app.ui.events.MyEventsScreen
import com.runicgateway.app.ui.home.HomeScreen
import com.runicgateway.app.ui.navigation.APP_MENU
import com.runicgateway.app.ui.navigation.NavNode
@@ -110,6 +114,10 @@ private val TOP_LEVEL_ROUTES = setOf(
// on them too (M11).
Routes.SHARD_RULES, Routes.SHARD_LEADERBOARDS, Routes.SHARD_MARKET, Routes.ATLAS,
Routes.NOTIFICATIONS,
// Events (M13): the calendar and the history are drawer rows, so the drawer
// 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,
Routes.PLAYER_CHARACTERS, Routes.PLAYER_VENDORS, Routes.PLAYER_HOUSES,
Routes.ADMIN_DASHBOARD, Routes.ADMIN_CONTENT, Routes.ADMIN_MODERATION, Routes.ADMIN_SUPPORT,
)
@@ -141,6 +149,9 @@ fun RunicApp(
val session by sessionViewModel.session.collectAsStateWithLifecycle()
// What this shard publishes, independently of who the caller is (§5, M11).
val shardFeatures by sessionViewModel.shardFeatures.collectAsStateWithLifecycle()
// What this BACKEND serves at all, independently of both (§5, M13). A different
// question from the line above and gated separately — see `isEntryVisible`.
val capabilities by sessionViewModel.capabilities.collectAsStateWithLifecycle()
// Re-validate the cached role each time the app returns to the foreground (§4.3),
// and re-read the unread count with it: a tickle that arrived while the app was
@@ -178,7 +189,7 @@ fun RunicApp(
// `pruneNav` still decides what this caller may see and remains the boundary
// (§6.1, AC-3). With no stored row the merge returns APP_MENU itself.
val nav = pruneNav(buildNavTree(APP_MENU, appearance.navPublic)) {
isEntryVisible(it, session, shardFeatures)
isEntryVisible(it, session, shardFeatures, capabilities)
}
val context = LocalContext.current
@@ -478,6 +489,50 @@ private fun RunicNavHost(
) {
PostScreen()
}
// Events (M13). CORE's routes, so these screens are reachable on a backend
// with no game module at all — which is why they sit above the shard block
// rather than inside it.
composable(Routes.EVENTS) {
EventsScreen(onOpenEvent = { slug -> navController.navigate(Routes.event(slug)) })
}
// The app's one route with a query argument. `run` is optional and nullable:
// navigating to Routes.event(slug) with no run matches this pattern with no
// argument, which is every route in except an announcement's link.
composable(
route = Routes.EVENT_ROUTE,
arguments = listOf(
navArgument(Routes.Args.SLUG) { type = NavType.StringType },
navArgument(Routes.Args.RUN) {
type = NavType.StringType
nullable = true
defaultValue = null
},
),
) {
EventScreen(
onOpenSeries = { slug -> navController.navigate(Routes.eventSeries(slug)) },
onOpenRun = { slug, runId ->
navController.navigate(Routes.event(slug, runId.toString()))
},
)
}
composable(
route = Routes.EVENT_SERIES,
arguments = listOf(navArgument(Routes.Args.SLUG) { type = NavType.StringType }),
) {
EventSeriesScreen(onOpenEvent = { slug -> navController.navigate(Routes.event(slug)) })
}
composable(Routes.MY_EVENTS) {
// Signed out, this route is not in the drawer — but a saved back-stack
// entry can still be restored onto it, so the shell says where to go
// rather than letting the screen ask the server and render a 401.
when (session) {
is Session.SignedIn -> MyEventsScreen(onOpenRun = { slug, runId ->
navController.navigate(Routes.event(slug, runId.toString()))
})
Session.SignedOut -> LaunchedEffect(Unit) { navController.navigateTopLevel(Routes.HOME) }
}
}
composable(Routes.SHARD) {
ShardScreen(onOpenBoard = { board ->
navController.navigate(
@@ -586,6 +641,10 @@ private fun RunicNavHost(
when (session) {
is Session.SignedIn -> InboxScreen(
onOpenSettings = { navController.navigate(Routes.NOTIFICATIONS_SETTINGS) },
// A notification whose link the app can render opens in the app.
// `navigate`, not `navigateTopLevel`: the inbox is where the
// reader came from and back should return there.
onOpenRoute = { route -> navController.navigate(route) },
)
Session.SignedOut -> LaunchedEffect(Unit) { navController.navigateTopLevel(Routes.HOME) }
}

View File

@@ -0,0 +1,310 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.events
import androidx.compose.foundation.clickable
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.layout.width
import androidx.compose.foundation.lazy.LazyColumn
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.text.style.TextAlign
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.EventOccurrenceDto
import com.runicgateway.app.data.api.dto.EventParticipantDto
import com.runicgateway.app.data.api.dto.PublicEventDto
import com.runicgateway.app.ui.UiState
import com.runicgateway.app.ui.components.ErrorView
import com.runicgateway.app.ui.components.HtmlText
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
/**
* One event's public page (EVENTS.md § API surface, M13).
*
* The storyline, its arc, what is live, what is next, what happened recently, and
* a results table once an occurrence has published one.
*
* **The plan behind the event is never shown**, because the server never sends
* it: a live run carries the LABEL of the phase it is in — resolved from the
* version that run pinned, so an edit since does not relabel it — and nothing
* else. Phases, steps and actions are the operator's.
*/
@Composable
fun EventScreen(
onOpenSeries: (String) -> Unit,
onOpenRun: (String, Long) -> Unit,
modifier: Modifier = Modifier,
viewModel: EventViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
// Error before content. Phase 13 found the inverse of this one tier along: a
// `if (loading || !form)` spinner above the error branch left a failed load
// spinning for ever with nothing on screen naming the problem.
when (val s = state) {
is UiState.Error -> ErrorView(s.kind, onRetry = viewModel::load, modifier = modifier)
is UiState.Loading -> LoadingView(modifier)
is UiState.Success -> EventBody(s.data, onOpenSeries, onOpenRun, modifier)
}
}
@Composable
private fun EventBody(
event: PublicEventDto,
onOpenSeries: (String) -> Unit,
onOpenRun: (String, Long) -> Unit,
modifier: Modifier = Modifier,
) {
LazyColumn(
modifier = modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
item(key = "head") {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(
text = event.title,
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.onSurface,
)
event.summary?.takeIf { it.isNotBlank() }?.let {
Text(
text = it,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
event.series?.let { series ->
Text(
text = stringResource(R.string.events_part_of, series.name),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.clickable { onOpenSeries(series.slug) },
)
}
}
}
// The one fact a visitor came for, above the storyline rather than below
// it: whether it is happening now, and if not, when it next is.
item(key = "headline") { Headline(event) }
event.body?.takeIf { it.isNotBlank() }?.let { body ->
item(key = "body") {
ShardCard(Modifier.fillMaxWidth()) {
// Sanitized on write, the treatment a wiki page and a forum
// post already get.
HtmlText(body, Modifier.padding(16.dp))
}
}
}
event.results?.let { results ->
item(key = "results-head") {
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(
text = stringResource(R.string.events_results),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
)
Text(
text = eventDateTime(results.scheduledFor, event.timezone),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
if (results.participants.isEmpty()) {
item(key = "results-empty") {
Text(
text = stringResource(R.string.events_results_nobody),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
} else {
items(results.participants.size, key = { "p$it" }) { index ->
ParticipantRow(results.participants[index])
}
}
}
occurrenceSection(
key = "upcoming",
titleRes = R.string.events_coming_up,
list = event.upcoming,
timezone = event.timezone,
slug = event.slug,
onOpenRun = onOpenRun,
linkResults = false,
)
occurrenceSection(
key = "past",
titleRes = R.string.events_previously,
list = event.past,
timezone = event.timezone,
slug = event.slug,
onOpenRun = onOpenRun,
linkResults = true,
)
if (event.current == null && event.next == null && event.past.isEmpty()) {
item(key = "unscheduled") {
Text(
text = stringResource(R.string.events_never_scheduled),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
@Composable
private fun Headline(event: PublicEventDto) {
ShardCard(Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
val current = event.current
when {
event.live && current != null -> {
StatusPill(
text = stringResource(R.string.events_status_live),
tone = PillTone.Success,
)
Text(
// The phase LABEL, and only while it is live.
text = current.phase?.takeIf { it.isNotBlank() }
?: stringResource(R.string.events_under_way),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
)
}
event.next != null -> {
Text(
text = stringResource(R.string.events_next),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = eventDateTime(event.next.scheduledFor, event.next.timezone ?: event.timezone),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
)
}
else -> Text(
text = stringResource(R.string.events_nothing_scheduled),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
/**
* A titled list of occurrences, or nothing at all when there are none.
*
* `linkResults` is what separates the two calls: only a PAST occurrence that
* actually published results gets its own tap target, because on any other one
* `?run=` would change nothing a reader could see.
*/
private fun androidx.compose.foundation.lazy.LazyListScope.occurrenceSection(
key: String,
titleRes: Int,
list: List<EventOccurrenceDto>,
timezone: String?,
slug: String,
onOpenRun: (String, Long) -> Unit,
linkResults: Boolean,
) {
if (list.isEmpty()) return
item(key = "$key-title") {
Text(
text = stringResource(titleRes),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
)
}
items(list.size, key = { "$key-${list[it].runId}" }) { index ->
val occurrence = list[index]
val tappable = linkResults && occurrence.resultsPublishedAt != null
ShardCard(
modifier = Modifier
.fillMaxWidth()
.then(
if (tappable) Modifier.clickable { onOpenRun(slug, occurrence.runId) }
else Modifier,
),
) {
Row(
Modifier.fillMaxWidth().padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = eventDateTime(occurrence.scheduledFor, occurrence.timezone ?: timezone),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.weight(1f),
)
Text(
text = stringResource(
statusWordRes(occurrence.status, occurrence.scheduledFor),
),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
@Composable
private fun ParticipantRow(participant: EventParticipantDto) {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = participant.rank?.toString() ?: "—",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.End,
modifier = Modifier.width(32.dp),
)
Text(
// A module supplies a display name in its participation meta or it does
// not; the member key is never published, so there is genuinely nothing
// else to render.
text = participant.name?.takeIf { it.isNotBlank() }
?: stringResource(R.string.events_participant_unnamed),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.weight(1f),
)
Text(
text = scoreText(participant.score),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
)
}
}

View File

@@ -0,0 +1,113 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.events
import androidx.compose.foundation.clickable
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.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.runicgateway.app.ui.UiState
import com.runicgateway.app.ui.components.ErrorView
import com.runicgateway.app.ui.components.LoadingView
import com.runicgateway.app.ui.components.ShardCard
/**
* One arc (EVENTS.md §I, M13).
*
* **The arc is the thing the tooling this replaces could not express at all.** A
* calendar plugin has no series field, so "Royal Spy Mission → Risky Partner →
* Message From the Void" existed only in a GM's head and in whatever the forum
* post said. This screen is that continuity, in the order an editor arranged it —
* which is why the events are numbered rather than dated: an arc has an order, and
* its parts may be months apart or run out of sequence.
*/
@Composable
fun EventSeriesScreen(
onOpenEvent: (String) -> Unit,
modifier: Modifier = Modifier,
viewModel: EventSeriesViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
// Error first, then loading — the order Phase 13 had to fix one tier along.
when (val s = state) {
is UiState.Error -> ErrorView(s.kind, onRetry = viewModel::load, modifier = modifier)
is UiState.Loading -> LoadingView(modifier)
is UiState.Success -> {
val series = s.data
LazyColumn(
modifier = modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
item(key = "head") {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(
text = series.name,
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.onSurface,
)
series.description?.takeIf { it.isNotBlank() }?.let {
Text(
text = it,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
items(series.events.size, key = { series.events[it].slug }) { index ->
val entry = series.events[index]
ShardCard(
modifier = Modifier
.fillMaxWidth()
.clickable { onOpenEvent(entry.slug) },
) {
Row(
Modifier.fillMaxWidth().padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(14.dp),
) {
Text(
text = (index + 1).toString(),
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.primary,
textAlign = TextAlign.End,
modifier = Modifier.width(32.dp),
)
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(
text = entry.title,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
)
entry.summary?.takeIf { it.isNotBlank() }?.let {
Text(
text = it,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}
}
}
}
}

View File

@@ -0,0 +1,50 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.events
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.runicgateway.app.data.api.dto.EventSeriesDto
import com.runicgateway.app.data.repository.EventsRepository
import com.runicgateway.app.ui.UiState
import com.runicgateway.app.ui.navigation.Routes
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.launch
import javax.inject.Inject
/**
* One arc (PLAN.md §9 M13).
*
* A series with nothing listed in it answers 404 rather than an empty page, so
* there is no "empty arc" state to render: the error branch is the whole of it,
* and that is the server's decision rather than this screen's — an empty page
* would publish that an operator has named something they have not announced.
*/
@HiltViewModel
class EventSeriesViewModel @Inject constructor(
private val repository: EventsRepository,
savedStateHandle: SavedStateHandle,
) : ViewModel() {
private val slug: String = savedStateHandle.get<String>(Routes.Args.SLUG).orEmpty()
private val _state = MutableStateFlow<UiState<EventSeriesDto>>(UiState.Loading)
val state: StateFlow<UiState<EventSeriesDto>> = _state.asStateFlow()
init {
load()
}
fun load() {
_state.value = UiState.Loading
viewModelScope.launch {
_state.value = repository.series(slug).toUiState()
}
}
}

View File

@@ -0,0 +1,166 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.events
import androidx.annotation.StringRes
import com.runicgateway.app.R
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
/**
* Rendering an event's instant and its status word (EVENTS.md §I).
*
* Everything here is pure and takes its clock, zone and locale as parameters, so
* the rules below are unit-tested off-device rather than eyeballed on one.
*
* ## The split, which is the one thing about event times that is easy to get wrong
*
* The server returns UTC instants and never guesses the reader's zone. The client
* places them, and it places the two halves differently:
*
* - the **day** an entry is filed under is the READER's own — "what is on this
* month" is a question about the month the person holding the phone is living
* in;
* - the **time** beside it is always the EVENT's zone, carried on the entry —
* because every listing this feature replaces is written in the shard's local
* zone, and "8pm" means the shard's evening to everyone reading it.
*
* Rendering the time in the reader's zone instead is defensible and wrong here: a
* player in Berlin told an American shard's event is at 02:00 has been told
* something true and useless, and told it in a way that makes the shard's own
* announcement look like a mistake.
*/
/**
* A participation score, as a reader should see it.
*
* Scores are `DECIMAL(18,4)` on the wire because a module may score by distance,
* time or a weighted tally — but most score by counting, and rendering a plain
* tally of kills as `12.0` reads as a rounding artefact. So a whole number prints
* whole and a fraction keeps its digits, with trailing zeros trimmed: `1420`,
* `318.5`, `0.25`.
*/
fun scoreText(score: Double, locale: Locale = Locale.getDefault()): String {
if (!score.isFinite()) return "0"
if (score == Math.floor(score) && Math.abs(score) < 1e15) {
return String.format(locale, "%d", score.toLong())
}
return String.format(locale, "%.4f", score).trimEnd('0').trimEnd('.', ',')
}
/** The event's own wall clock, with the zone named so it misreads as nothing. */
fun eventTime(
instant: String?,
timezone: String?,
locale: Locale = Locale.getDefault(),
): String {
val at = parseWireInstant(instant) ?: return ""
val zone = eventZone(timezone)
val time = DateTimeFormatter.ofPattern("HH:mm", locale).withZone(zone).format(at)
return "$time ${shortZone(timezone)}"
}
/**
* The event's own day and time together, for a screen showing one occurrence.
*
* Localized rather than patterned, because a full date's field order is the
* locale's business; only the zone stays the event's.
*/
fun eventDateTime(
instant: String?,
timezone: String?,
locale: Locale = Locale.getDefault(),
): String {
val at = parseWireInstant(instant) ?: return ""
val zone = eventZone(timezone)
val text = DateTimeFormatter
.ofLocalizedDateTime(FormatStyle.MEDIUM, FormatStyle.SHORT)
.withLocale(locale)
.withZone(zone)
.format(at)
return "$text ${shortZone(timezone)}"
}
/** The reader's own day, for the heading an entry is filed under. */
fun readerDayLabel(
instant: String?,
zone: ZoneId = ZoneId.systemDefault(),
locale: Locale = Locale.getDefault(),
): String {
val at = parseWireInstant(instant) ?: return ""
return DateTimeFormatter
.ofLocalizedDate(FormatStyle.FULL)
.withLocale(locale)
.withZone(zone)
.format(at)
}
/**
* The zone as a reader recognises it: `America/New_York` → `New York`.
*
* Not the abbreviation (`EDT`), which is unstable across the year and unknown to
* most readers of a shard in another country.
*/
fun shortZone(timezone: String?): String {
if (timezone.isNullOrBlank()) return "UTC"
return timezone.substringAfterLast('/').replace('_', ' ')
}
/**
* The event's zone, or UTC when its column holds something `java.time` will not
* read.
*
* A typo in a definition's timezone must still render: UTC off the instant is the
* honest answer when the zone cannot be honoured, and it is what the web client
* falls back to for the same reason.
*/
private fun eventZone(timezone: String?): ZoneId = try {
if (timezone.isNullOrBlank()) ZoneId.of("UTC") else ZoneId.of(timezone)
} catch (_: Exception) {
ZoneId.of("UTC")
}
/**
* The word beside an occurrence, for the four statuses the server publishes.
*
* **`cancelled` needs the instant, and that is the whole reason this takes one.**
* The server publishes `failed` and `missed` as `cancelled` too — to a visitor the
* three are one event, and the difference between them is about the deployment —
* but the three do not share one English sentence. *Did not happen* is right for a
* past occurrence and a plain falsehood for a future one, and a run four days out
* that an operator has called off is exactly the common case: this is the defect
* Phase 14a's own calendar shipped and the live walk caught, which is why it is
* restated here rather than ported.
*
* So **the tense follows the clock, not the status**. A future call-off reads
* *Cancelled*; a past one reads *Did not happen*, which is also the honest word
* for the failed and missed runs folded in with it.
*
* An unrecognised status reads *Scheduled*, mirroring the server's own fallback:
* `publicStatus()` folds anything it does not know to `scheduled`, so a word the
* app has never seen is a contract break rather than a state, and rendering a raw
* enum at a reader is not an improvement on it.
*/
@StringRes
fun statusWordRes(status: String?, scheduledFor: String?, now: Instant = Instant.now()): Int =
when (status) {
"live" -> R.string.events_status_live
"completed" -> R.string.events_status_completed
"cancelled" -> {
val at = parseWireInstant(scheduledFor)
// An unreadable instant is treated as past, which is the safer of the
// two: "did not happen" about something unplaceable in time is vague,
// while "cancelled" about a past run implies it is still coming.
if (at != null && at.isAfter(now)) {
R.string.events_status_cancelled
} else {
R.string.events_status_did_not_happen
}
}
else -> R.string.events_status_scheduled
}

View File

@@ -0,0 +1,59 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.events
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.runicgateway.app.data.api.dto.PublicEventDto
import com.runicgateway.app.data.repository.EventsRepository
import com.runicgateway.app.ui.UiState
import com.runicgateway.app.ui.navigation.Routes
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.launch
import javax.inject.Inject
/**
* One event's page (PLAN.md §9 M13, EVENTS.md § API surface).
*
* **`run` is read from the route and passed through untouched**, because that is
* what an announcement's link carries. The page lives at the definition's slug —
* one stable address, so a link posted in Discord survives a retitle — and the
* occurrence has to be in the query or a mail about last Friday's invasion would
* open next Friday's.
*
* A run that belongs to some other event is **not** filtered here. The server
* ignores it and answers with this event anyway, which turns a stale link in a
* months-old mail into the page it was about rather than a dead end; second-
* guessing that would undo it.
*/
@HiltViewModel
class EventViewModel @Inject constructor(
private val repository: EventsRepository,
savedStateHandle: SavedStateHandle,
) : ViewModel() {
private val slug: String = savedStateHandle.get<String>(Routes.Args.SLUG).orEmpty()
/** Null unless the route carried one; never an empty string forwarded to the server. */
private val run: String? = savedStateHandle.get<String>(Routes.Args.RUN)?.takeIf { it.isNotBlank() }
private val _state = MutableStateFlow<UiState<PublicEventDto>>(UiState.Loading)
val state: StateFlow<UiState<PublicEventDto>> = _state.asStateFlow()
init {
load()
}
fun load() {
_state.value = UiState.Loading
viewModelScope.launch {
_state.value = repository.event(slug, run).toUiState()
}
}
}

View File

@@ -0,0 +1,191 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.events
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.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.text.font.FontStyle
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.EventCalendarEntryDto
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
/**
* The public event calendar (EVENTS.md §I, M13).
*
* **A list, not a month grid**, which is the same call the web client makes and
* for the same reason: an operator's question is "what does this month look
* like" — coverage, clashes, the gap on the third weekend — and a grid answers
* it. A visitor's question is "what is on, and when is the next one", which a
* chronological list answers in one glance and a grid answers by making them
* count squares. On a phone the grid is not even a close second.
*
* **A projection is drawn differently from a run**, one tier along from the
* operator's own reason for the distinction: past the materialisation horizon
* there is no row, nothing is committed to, and nothing can be cancelled. Drawing
* a forecast identically to a booking would be the screen promising something the
* server has not.
*/
@Composable
fun EventsScreen(
onOpenEvent: (String) -> Unit,
modifier: Modifier = Modifier,
viewModel: EventsViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
when (val s = state) {
is UiState.Loading -> LoadingView(modifier)
is UiState.Error -> ErrorView(s.kind, onRetry = viewModel::load, modifier = modifier)
is UiState.Success -> {
val entries = s.data.entries
if (entries.isEmpty()) {
EmptyView(stringResource(R.string.events_empty), modifier)
} else {
Calendar(entries, s.data.truncated, onOpenEvent, modifier)
}
}
}
}
/**
* Group by the READER's day, preserving the server's order rather than re-sorting.
*
* Internal + pure so the grouping — and the fact that it never reorders — is
* unit-tested without Compose.
*/
internal fun groupByReaderDay(entries: List<EventCalendarEntryDto>): List<CalendarDayGroup> {
val days = mutableListOf<CalendarDayGroup>()
for (entry in entries) {
val label = readerDayLabel(entry.scheduledFor)
val last = days.lastOrNull()
if (last != null && last.label == label) {
last.entries.add(entry)
} else {
days.add(CalendarDayGroup(label, mutableListOf(entry)))
}
}
return days
}
/** A mutable builder shape for [groupByReaderDay]; the screen only reads it. */
internal data class CalendarDayGroup(
val label: String,
val entries: MutableList<EventCalendarEntryDto>,
)
@Composable
private fun Calendar(
entries: List<EventCalendarEntryDto>,
truncated: Boolean,
onOpenEvent: (String) -> Unit,
modifier: Modifier = Modifier,
) {
val days = groupByReaderDay(entries)
LazyColumn(
modifier = modifier.fillMaxSize(),
contentPadding = androidx.compose.foundation.layout.PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
days.forEach { day ->
item(key = "day-${day.label}") {
Text(
text = day.label,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
items(
items = day.entries,
key = { "${it.slug}-${it.scheduledFor}-${it.kind}" },
) { entry ->
EntryCard(entry, onOpenEvent)
}
}
if (truncated) {
item(key = "truncated") {
Text(
text = stringResource(R.string.events_truncated),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
@Composable
private fun EntryCard(entry: EventCalendarEntryDto, onOpenEvent: (String) -> Unit) {
ShardCard(
modifier = Modifier
.fillMaxWidth()
// A projection has a page too — the definition's — so it opens like any
// other entry. What it does not have is an occurrence to link to.
.clickable { onOpenEvent(entry.slug) },
) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = entry.title,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.weight(1f),
)
StatusPill(
text = stringResource(statusWordRes(entry.status, entry.scheduledFor)),
tone = if (entry.live) PillTone.Success else PillTone.Neutral,
)
}
Text(
text = eventTime(entry.scheduledFor, entry.timezone),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
entry.seriesName?.takeIf { it.isNotBlank() }?.let { series ->
Text(
text = series,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (entry.isProjected) {
// Said in words rather than drawn as a dashed border, because a
// phone reader skimming a list will not decode a border and the
// distinction is worth more than the pixel it would cost.
Text(
text = stringResource(R.string.events_projected),
style = MaterialTheme.typography.bodySmall,
fontStyle = FontStyle.Italic,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}

View File

@@ -0,0 +1,51 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.events
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.runicgateway.app.data.api.dto.EventCalendarDto
import com.runicgateway.app.data.repository.EventsRepository
import com.runicgateway.app.ui.UiState
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.launch
import javax.inject.Inject
/**
* The public event calendar (PLAN.md §9 M13, EVENTS.md §I).
*
* **No window is asked for**, and that is the whole of this view model's design.
* The server's default is now through 31 days out, so a client that computed a
* window before it could ask anything would make every deep link carry two ISO
* instants and would have to agree with the server about what "now" is. The
* window bound and the entry cap are the server's defence on the one surface with
* no login in front of it; there is nothing for the app to add.
*
* `toUiState`, not `toShardUiState`: these are CORE routes. A 404 here means the
* backend has no events at all, not that an admin switched a shard surface off,
* and offering "not published here" for it would name the wrong cause.
*/
@HiltViewModel
class EventsViewModel @Inject constructor(
private val repository: EventsRepository,
) : ViewModel() {
private val _state = MutableStateFlow<UiState<EventCalendarDto>>(UiState.Loading)
val state: StateFlow<UiState<EventCalendarDto>> = _state.asStateFlow()
init {
load()
}
fun load() {
_state.value = UiState.Loading
viewModelScope.launch {
_state.value = repository.calendar().toUiState()
}
}
}

View File

@@ -0,0 +1,142 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.events
import androidx.compose.foundation.clickable
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.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
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.style.TextAlign
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.EventHistoryEntryDto
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.ShardCard
/**
* This account's event participation (EVENTS.md §J, M13).
*
* **The screen's one real design decision is what an unranked row says.** A run
* whose participants were collected but whose results have not been published has
* a score and no rank, and that is a real state rather than an error — it is the
* same state the admin run console has shown since events Phase 10. Rendering a
* dash with nothing explaining it would read as a bug; the row says the results
* are not published, which is a fact about the event rather than about the reader.
*
* Reached by **one drawer row for every signed-in account**, players and staff
* alike. The website mounts this twice only because its `RequirePlayer` guard sits
* over `/account` and the route behind it is role-agnostic; the app has no such
* wall, so it needs no second mount.
*/
@Composable
fun MyEventsScreen(
onOpenRun: (String, Long) -> Unit,
modifier: Modifier = Modifier,
viewModel: MyEventsViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
when (val items = state.items) {
is UiState.Loading -> LoadingView(modifier)
is UiState.Error -> ErrorView(items.kind, onRetry = viewModel::load, modifier = modifier)
is UiState.Success -> if (items.data.isEmpty()) {
EmptyView(stringResource(R.string.events_history_empty), modifier)
} else {
androidx.compose.foundation.lazy.LazyColumn(
modifier = modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
items(items.data.size, key = { items.data[it].id }) { index ->
HistoryRow(items.data[index], onOpenRun)
}
if (state.hasMore) {
item(key = "more") {
TextButton(
onClick = viewModel::loadMore,
enabled = !state.loadingMore,
modifier = Modifier.fillMaxWidth(),
) {
Text(
stringResource(
if (state.loadingMore) R.string.events_loading
else R.string.events_show_more,
),
)
}
}
}
}
}
}
}
@Composable
private fun HistoryRow(entry: EventHistoryEntryDto, onOpenRun: (String, Long) -> Unit) {
ShardCard(
modifier = Modifier
.fillMaxWidth()
// Straight to the occurrence the reader took part in, not to whatever
// is next: `?run=` is what makes the event page answer about this one.
.clickable { onOpenRun(entry.slug, entry.runId) },
) {
Row(
Modifier.fillMaxWidth().padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(
text = entry.title,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
)
Text(
text = eventDateTime(entry.scheduledFor, entry.timezone),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
entry.seriesName?.takeIf { it.isNotBlank() }?.let {
Text(
text = it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Column(horizontalAlignment = Alignment.End) {
Text(
text = entry.rank
?.let { stringResource(R.string.events_rank, it) }
?: stringResource(R.string.events_results_unpublished),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.End,
)
Text(
text = stringResource(R.string.events_score, scoreText(entry.score)),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}

View File

@@ -0,0 +1,117 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.events
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.runicgateway.app.core.auth.Session
import com.runicgateway.app.core.auth.SessionManager
import com.runicgateway.app.core.result.ApiResult
import com.runicgateway.app.data.api.dto.EventHistoryEntryDto
import com.runicgateway.app.data.repository.EventsRepository
import com.runicgateway.app.ui.UiState
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.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
* This account's event participation (PLAN.md §9 M13, EVENTS.md §J).
*
* **Self-scoped by the session and nothing else.** There is no id parameter on
* the route and deliberately none here: one account never reads another's, and
* there is no argument that could later grow into one.
*
* **Keyset-paged on the participation row's own id, never an offset** — the list
* gains a row every time the reader attends something, so an offset page would
* skip and repeat rows around the seam.
*
* ## Why this watches the session, when no other screen here does
*
* **A drawer route's view model outlives a sign-out.** `navigateTopLevel` saves
* and restores back-stack state, so the `NavBackStackEntry` for this route keeps
* its `ViewModelStore` across a sign-out and a sign-in as somebody else — and a
* view model that loads only in `init` never runs again. The live walk found the
* consequence: signing out of an admin account and back in as a player showed the
* PLAYER the admin's participation history, with no request made at all.
*
* The public event screens have the same lifetime and do not care, because a
* calendar is the same for everybody. This one is per-account, so the account is
* what it keys on: the flow emits the current session immediately, which is also
* the first load, and re-emits only when the signed-in id actually changes — a
* resume revalidation returning the same user does not refetch.
*/
@HiltViewModel
class MyEventsViewModel @Inject constructor(
private val repository: EventsRepository,
sessionManager: SessionManager,
) : ViewModel() {
data class State(
val items: UiState<List<EventHistoryEntryDto>> = UiState.Loading,
val hasMore: Boolean = false,
val loadingMore: Boolean = false,
)
private val _state = MutableStateFlow(State())
val state: StateFlow<State> = _state.asStateFlow()
init {
viewModelScope.launch {
sessionManager.state
.map { (it as? Session.SignedIn)?.user?.id }
.distinctUntilChanged()
.collect { userId ->
// Signed out: drop the rows rather than leave the last
// account's on screen behind a shell that is about to
// navigate away.
if (userId == null) _state.value = State(items = UiState.Success(emptyList()))
else load()
}
}
}
fun load() {
_state.value = State()
viewModelScope.launch {
val result = repository.history(PAGE)
_state.value = State(
items = result.toUiState(),
// A full page means there is probably another; a short one is the
// end. One request rather than a count the server does not send.
hasMore = (result as? ApiResult.Ok)?.data?.size == PAGE,
)
}
}
fun loadMore() {
val current = _state.value
val shown = (current.items as? UiState.Success)?.data ?: return
val last = shown.lastOrNull() ?: return
if (current.loadingMore || !current.hasMore) return
_state.value = current.copy(loadingMore = true)
viewModelScope.launch {
when (val result = repository.history(PAGE, before = last.id)) {
is ApiResult.Ok -> _state.value = State(
items = UiState.Success(shown + result.data),
hasMore = result.data.size == PAGE,
)
// A failed NEXT page keeps the pages already read rather than
// replacing a screenful of history with an error: the reader can
// still see what loaded, and tapping again retries.
else -> _state.value = current.copy(loadingMore = false)
}
}
}
private companion object {
const val PAGE = 25
}
}

View File

@@ -6,9 +6,12 @@ package com.runicgateway.app.ui.navigation
import androidx.annotation.StringRes
import com.runicgateway.app.R
import com.runicgateway.app.core.auth.Session
import com.runicgateway.app.data.repository.Capability
import com.runicgateway.app.data.repository.ShardFeature
import com.runicgateway.app.data.repository.ShardFeatures
import com.runicgateway.app.data.repository.SiteCapabilities
import com.runicgateway.app.data.repository.canSee
import com.runicgateway.app.data.repository.canUse
/**
* One shared, declarative, access-level navigation definition (PLAN.md §5): a
@@ -52,6 +55,20 @@ data class MenuEntry(
* isn't shard-derived and only [access] applies.
*/
val feature: String? = null,
/**
* The backend capability this row needs, or null when it needs none (M13).
*
* **A different question from [feature], which is why it is a second field
* and not a wider one.** This asks whether the code behind the row is
* *installed at all* — a per-HOST fact, from `GET /public/modules` and core's
* own list — while [feature] asks whether this shard publishes that surface
* to *this viewer*, which is per-viewer and admin-configurable. A site with no
* game module has no `shard` capability and no shard rows, whoever is looking;
* a site with one may still hide its market from anonymous visitors.
*
* The two also fail differently, and [canUse] is where that lives.
*/
val capability: String? = null,
/**
* An admin's own label for this row, from the shard's `nav_public` override
* (THEMING_AND_NAV.md §6). Null — always, as coded — means [labelRes] stands.
@@ -71,21 +88,84 @@ data class MenuEntry(
val APP_MENU: List<MenuEntry> = listOf(
MenuEntry(Routes.HOME, R.string.menu_home),
MenuEntry(Routes.NEWS, R.string.menu_news),
// Events are CORE's, so this row is gated on core's own capability rather than
// a module's: a site with no game module still has a calendar. Placed here to
// match the website's own nav, where Events is the row after News.
MenuEntry(Routes.EVENTS, R.string.menu_events, capability = Capability.EVENTS),
MenuEntry(Routes.WIKI, R.string.menu_wiki),
MenuEntry(Routes.SHARD, R.string.menu_shard, feature = ShardFeature.STATUS),
// The shard group. Every row needs the game module INSTALLED (one capability,
// because that is the only question a capability can answer) and its own
// feature published to this viewer (M11) — both, independently.
MenuEntry(
Routes.SHARD,
R.string.menu_shard,
feature = ShardFeature.STATUS,
capability = Capability.SHARD,
),
// Protocol 3.0 shard content (M11). Each hides when the shard doesn't publish it,
// which for a brand-new install is every one of them until the plugin has swept.
MenuEntry(Routes.SHARD_RULES, R.string.menu_rules, feature = ShardFeature.RULESET),
MenuEntry(Routes.ATLAS, R.string.menu_atlas, feature = ShardFeature.ATLAS),
MenuEntry(Routes.SHARD_LEADERBOARDS, R.string.menu_leaderboards, feature = ShardFeature.LEADERBOARDS),
MenuEntry(Routes.SHARD_MARKET, R.string.menu_market, feature = ShardFeature.MARKET),
MenuEntry(
Routes.SHARD_RULES,
R.string.menu_rules,
feature = ShardFeature.RULESET,
capability = Capability.SHARD,
),
MenuEntry(
Routes.ATLAS,
R.string.menu_atlas,
feature = ShardFeature.ATLAS,
capability = Capability.SHARD,
),
MenuEntry(
Routes.SHARD_LEADERBOARDS,
R.string.menu_leaderboards,
feature = ShardFeature.LEADERBOARDS,
capability = Capability.SHARD,
),
MenuEntry(
Routes.SHARD_MARKET,
R.string.menu_market,
feature = ShardFeature.MARKET,
capability = Capability.SHARD,
),
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),
MenuEntry(Routes.NOTIFICATIONS, R.string.menu_notifications, MenuAccess.SIGNED_IN),
MenuEntry(Routes.PLAYER_CHARACTERS, R.string.menu_my_characters, MenuAccess.PLAYER),
MenuEntry(Routes.PLAYER_VENDORS, R.string.menu_my_vendors, MenuAccess.PLAYER),
MenuEntry(Routes.PLAYER_HOUSES, R.string.menu_my_houses, MenuAccess.PLAYER),
// Participation history: SIGNED_IN, not PLAYER. The route is `requireAuth`
// alone and self-scoped on the caller's own id, and the website needed two
// mounts for it only because `RequirePlayer` guards `/account` there. Staff
// attend events too, and event history is not game-linked data.
MenuEntry(
Routes.MY_EVENTS,
R.string.menu_my_events,
MenuAccess.SIGNED_IN,
capability = Capability.EVENTS,
),
// These three read `/player/shard/*`, which is the SAME module's player mount —
// so they need the capability for the same reason the public rows do. They
// carry no `feature`, because the visibility framework covers the public
// surfaces and these are self-service, gated by role and ownership instead.
// That asymmetry is exactly why the live walk found them and the suite did
// not: "a shard row" had been defined as "a row with a feature".
MenuEntry(
Routes.PLAYER_CHARACTERS,
R.string.menu_my_characters,
MenuAccess.PLAYER,
capability = Capability.SHARD,
),
MenuEntry(
Routes.PLAYER_VENDORS,
R.string.menu_my_vendors,
MenuAccess.PLAYER,
capability = Capability.SHARD,
),
MenuEntry(
Routes.PLAYER_HOUSES,
R.string.menu_my_houses,
MenuAccess.PLAYER,
capability = Capability.SHARD,
),
// Staff operations (§1, M10) — revealed for staff roles; the backend re-checks every call.
MenuEntry(Routes.ADMIN_DASHBOARD, R.string.menu_admin_dashboard, MenuAccess.STAFF),
MenuEntry(Routes.ADMIN_CONTENT, R.string.menu_admin_content, MenuAccess.STAFF),
@@ -94,24 +174,33 @@ val APP_MENU: List<MenuEntry> = listOf(
)
/**
* The entries the given [session] may see, given the shard [features] it may reach.
* Pure + side-effect-free so the gating is unit-tested without Compose.
* The entries the given [session] may see, on a backend with these [capabilities]
* and this shard's [features]. Pure + side-effect-free so the gating is unit-tested
* without Compose.
*
* Two independent filters, and both must pass:
* Three independent filters, and all three must pass:
*
* - [MenuEntry.access] against the session — who the caller is.
* - [MenuEntry.capability] against what this backend serves — whether the code
* behind the row is installed at all (M13). Per host.
* - [MenuEntry.feature] against the shard's live visibility config — what this shard
* publishes at all (M11). `null` [features] means the answer isn't known yet and
* every shard entry shows; see [canSee] for why that direction is deliberate.
* publishes to this viewer (M11). Per viewer.
*
* **The last two both fail open on an unknown answer, but "unknown" means
* different things to them.** A `null` [features] is unknown; so is a `null`
* [capabilities] — but a *non-null* [capabilities] that does not name the string
* is an ANSWER, and it hides. Without that, a site with no game module renders
* five shard rows that each 404. See [canUse].
*/
fun visibleEntries(
entries: List<MenuEntry>,
session: Session,
features: ShardFeatures? = null,
): List<MenuEntry> = entries.filter { isEntryVisible(it, session, features) }
capabilities: SiteCapabilities? = null,
): List<MenuEntry> = entries.filter { isEntryVisible(it, session, features, capabilities) }
/**
* [visibleEntries] for a single entry — the same two filters, and the same
* [visibleEntries] for a single entry — the same three filters, and the same
* boundary. Split out because the drawer is a tree once an admin groups rows into
* sections (§6.3): [pruneNav] applies this predicate inside a section as well, and
* both callers must ask exactly one question or a sectioned row could be gated by
@@ -121,6 +210,7 @@ fun isEntryVisible(
entry: MenuEntry,
session: Session,
features: ShardFeatures? = null,
capabilities: SiteCapabilities? = null,
): Boolean {
val allowedByRole = when (entry.access) {
MenuAccess.PUBLIC -> true
@@ -129,5 +219,7 @@ fun isEntryVisible(
MenuAccess.STAFF -> session is Session.SignedIn && session.user.isStaff
MenuAccess.MODERATOR -> session is Session.SignedIn && session.user.isModerator
}
return allowedByRole && (entry.feature == null || canSee(features, entry.feature))
return allowedByRole &&
canUse(capabilities, entry.capability) &&
(entry.feature == null || canSee(features, entry.feature))
}

View File

@@ -11,34 +11,68 @@ import com.runicgateway.app.data.repository.ContentRepository.PostCategory
* The public nav an admin edits is keyed by **website** paths, so honoring it in
* the app needs a translation. This is the one new piece of cross-repo coupling
* the milestone introduces, which is why it lives in a single file with the
* website's own array quoted right beside it — the coupling is visible and
* website's own arrays quoted right beside it — the coupling is visible and
* reviewable in one place rather than spread across the drawer's call sites.
*
* ## The nav is TWO arrays now, and that is what M13 had to correct
*
* This file was written when the website's public nav was one sixteen-row array.
* Since the module-system cutover on 2026-08-12 it is **core's eight rows plus
* every installed module's**, interleaved at render time by `withModuleNav`, and
* a module's pages are mounted by core at `/<module id>/<path>` — so the nine
* shard rows moved from `/site/champs` to `/uo/champs` and this table stopped
* resolving any of them. Three things followed, all of them true of the shipped
* app until M13: a nav override on a shard row was ignored, an added link to a
* shard page handed off to a browser instead of opening natively, and the sort
* key line below was a sixteen-row line against a nav numbered differently.
*
* **The nine `/uo/` paths are hardcoded, and they are ONE module's.** The alternative
* — reading the installed module's id from `GET /public/modules` and building
* `/<id>/shard` — is forbidden by `MODULE_API.md` §2.9 (*"a client must not infer
* a route from a capability"*) and would hardcode the same path shape less
* visibly. A site running a different game module matches none of these nine, its
* links hand off to a Custom Tab, and that is the correct answer rather than a
* gap: core cannot tell the app what another module calls its pages.
*
* Verbatim from `website/client/src/components/SiteHeader.jsx`, which is the
* exported owner of the list (`export const NAV`, and Admin → Navigation edits
* exported owner of core's list (`export const NAV`, and Admin → Navigation edits
* exactly it):
*
* ```js
* export const NAV = [
* { label: 'Home', to: '/', end: true },
* { label: 'News', to: '/site/news' },
* { label: 'Events', to: '/site/events' },
* { label: 'Screenshots', to: '/site/screenshots' },
* { label: 'Five on Friday', to: '/site/five-on-friday' },
* { label: 'Newsletter', to: '/site/newsletter' },
* { label: 'Wiki', to: '/wiki' },
* { label: 'Shard', to: '/site/shard', feature: 'status' },
* { label: 'Champions', to: '/site/champs', feature: 'champs' },
* { label: 'Guilds', to: '/site/guilds', feature: 'guilds' },
* { label: 'Governors', to: '/site/governors', feature: 'governors' },
* { label: 'Houses', to: '/site/houses', feature: 'houses' },
* { label: 'Rules', to: '/site/rules', feature: 'ruleset' },
* { label: 'Atlas', to: '/site/atlas', feature: 'atlas' },
* { label: 'Leaderboards', to: '/site/leaderboards', feature: 'leaderboards' },
* { label: 'Market', to: '/site/market', feature: 'market' },
* { label: 'About', to: '/site/about' },
* ]
* ```
*
* and from `module-uo/client/src/entry.jsx`, which registers the rest:
*
* ```jsx
* registry.registerNav(ID, {
* area: 'public',
* items: [
* { label: 'Shard', to: '/uo/shard', feature: 'status' },
* { label: 'Champions', to: '/uo/champs', feature: 'champs' },
* { label: 'Guilds', to: '/uo/guilds', feature: 'guilds' },
* { label: 'Governors', to: '/uo/governors', feature: 'governors' },
* { label: 'Houses', to: '/uo/houses', feature: 'houses' },
* { label: 'Rules', to: '/uo/rules', feature: 'ruleset' },
* { label: 'Atlas', to: '/uo/atlas', feature: 'atlas' },
* { label: 'Leaderboards', to: '/uo/leaderboards', feature: 'leaderboards' },
* { label: 'Market', to: '/uo/market', feature: 'market' },
* ],
* })
* ```
*
* 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.
*
* The `feature` values are **not** mirrored here on purpose. [APP_MENU] is the
* app's own source of truth for gating, and a second copy of a security-relevant
* value that drifts silently is worth more than it costs. This table carries the
@@ -58,27 +92,33 @@ data class WebNavPath(val path: String, val route: String)
* *this* list (the admin's editor writes the position a row holds on the web), so
* a row the admin never moved has to take its key from the same number line or
* explicit and implicit keys would be incomparable. See `NavOverrides.kt`.
*
* Core's eight first, then the module's nine, because that is what `withModuleNav`
* renders and therefore what the admin's editor numbered.
*/
val WEBSITE_PUBLIC_NAV: List<WebNavPath> = listOf(
WebNavPath("/", Routes.HOME),
WebNavPath("/site/news", Routes.NEWS),
WebNavPath("/site/events", Routes.EVENTS),
// The app's News screen carries all four categories as tabs, so these three
// have a route but no drawer row of their own — see the note below.
WebNavPath("/site/screenshots", Routes.news(PostCategory.SCREENSHOTS)),
WebNavPath("/site/five-on-friday", Routes.news(PostCategory.FIVE_ON_FRIDAY)),
WebNavPath("/site/newsletter", Routes.news(PostCategory.NEWSLETTER)),
WebNavPath("/wiki", Routes.WIKI),
WebNavPath("/site/shard", Routes.SHARD),
// Behind the Shard hub in the app, deliberately — no drawer row either.
WebNavPath("/site/champs", Routes.SHARD_CHAMPS),
WebNavPath("/site/guilds", Routes.SHARD_GUILDS),
WebNavPath("/site/governors", Routes.SHARD_GOVERNORS),
WebNavPath("/site/houses", Routes.SHARD_HOUSES),
WebNavPath("/site/rules", Routes.SHARD_RULES),
WebNavPath("/site/atlas", Routes.ATLAS),
WebNavPath("/site/leaderboards", Routes.SHARD_LEADERBOARDS),
WebNavPath("/site/market", Routes.SHARD_MARKET),
WebNavPath("/site/about", Routes.page("about")),
// module-uo's rows. Mounted by core at `/<module id>/<path>`, which is why
// every one of these is `/uo/` and not `/site/`.
WebNavPath("/uo/shard", Routes.SHARD),
// Behind the Shard hub in the app, deliberately — no drawer row either.
WebNavPath("/uo/champs", Routes.SHARD_CHAMPS),
WebNavPath("/uo/guilds", Routes.SHARD_GUILDS),
WebNavPath("/uo/governors", Routes.SHARD_GOVERNORS),
WebNavPath("/uo/houses", Routes.SHARD_HOUSES),
WebNavPath("/uo/rules", Routes.SHARD_RULES),
WebNavPath("/uo/atlas", Routes.ATLAS),
WebNavPath("/uo/leaderboards", Routes.SHARD_LEADERBOARDS),
WebNavPath("/uo/market", Routes.SHARD_MARKET),
)
/**
@@ -134,6 +174,11 @@ private fun normalizeWebPath(path: String?): String? {
*/
private val RESERVED_TOP_LEVEL = setOf(
"admin", "account", "player", "site", "wiki", "invite", "preview", "api", "uploads",
// An installed module's pages are mounted at `/<id>/…` and are not CMS pages.
// 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",
)
/**
@@ -153,16 +198,17 @@ private val RESERVED_TOP_LEVEL = setOf(
* <Route path="/site/five-on-friday" element={<FiveOnFriday />} />
* <Route path="/site/newsletter" element={<Newsletter />} />
* <Route path="/site/newsletter/:id" element={<NewsletterIssue />} />
* <Route path="/site/events" element={<Events />} />
* <Route path="/site/events/series/:slug" element={<EventSeries />} />
* <Route path="/site/events/:slug" element={<EventPage />} />
* <Route path="/site/about" element={<About />} />
* <Route path="/site/status" element={<Status />} />
* <Route path="/site/shard" element={<Shard />} />
* <Route path="/site/shard/activity" element={<ShardActivity />} />
* ... /site/champs, /guilds, /governors, /houses, /rules, /leaderboards, /market
* <Route path="/site/atlas" element={<Atlas />} />
* <Route path="/site/atlas/:slug" element={<AtlasCreature />} />
* <Route path="/site/market/vendors/:serial" element={<MarketVendor />} />
* <Route path="/wiki" element={<Wiki />} />
* <Route path="/wiki/:slug" element={<WikiArticle />} />
* // Installed modules' pages, mounted at `/<module id>/<path>`:
* // /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
* // CMS pages: top-level /:slug, matched only after the named routes above
* <Route path="/:slug" element={<CmsPage />} />
* ```
@@ -177,19 +223,31 @@ private val RESERVED_TOP_LEVEL = setOf(
* /site/{screenshots,five-on-friday,newsletter}
* → NEWS, that category's tab
* /site/newsletter/<id> → POST (the site's one post-detail route)
* /site/events → EVENTS
* /site/events/series/<slug> → EVENT_SERIES
* /site/events/<slug>[?run=<id>] → EVENT (the one route that takes a query)
* /wiki → WIKI
* /wiki/<slug> → WIKI_PAGE
* /site/<shard surface> → the mapped shard route (§6.2)
* /site/atlas/<slug> → ATLAS_CREATURE
* /site/market/vendors/<serial> → SHARD_MARKET_VENDOR
* /uo/<shard surface> → the mapped shard route (§6.2)
* /uo/atlas/<slug> → ATLAS_CREATURE
* /uo/market/vendors/<serial> → SHARD_MARKET_VENDOR
* /site/about → PAGE("about")
* /<slug> → PAGE(slug), unless <slug> is reserved
* anything else → null, i.e. the Custom Tab
* ```
*
* **A path carrying a query or a fragment hands off**, whatever its route part
* says. No app route takes either, so a native match would quietly drop what the
* admin wrote; the browser honors it exactly.
* **A path carrying a query or a fragment hands off — with exactly one
* exception.** The rule exists because no app route took either, so a native
* match would quietly drop what the admin wrote while the browser honors it. The
* event page (M13) is the first route that takes a query, and it takes one key:
* `run`, which is what every `event.` announcement's `eventUrl` carries. So a
* `?run=` on an event path resolves natively and **anything else in a query
* string, any second parameter, and any fragment still hand off** — the carve-out
* is one key on one path, not a general "parse the query".
*
* That narrowness is the point: an admin who writes `/site/events/x?utm=mail` gets
* the browser, which honors `utm`, rather than an app screen that silently ignored
* it.
*
* Resolving a path is not the same as being allowed to see the screen behind it.
* A link to `/site/market` on a shard that does not publish the market lands on
@@ -197,24 +255,70 @@ private val RESERVED_TOP_LEVEL = setOf(
* URL on the web does too (§6.3).
*/
fun resolveWebPath(path: String?): String? {
val normalized = normalizeWebPath(path) ?: return null
if (normalized.any { it == '?' || it == '#' }) return null
WEB_PATH_TO_ROUTE[normalized]?.let { return it }
val raw = path?.trim().orEmpty()
// A fragment is never honored natively: no app route has one to put it in.
if (raw.isEmpty() || '#' in raw) return null
val queryAt = raw.indexOf('?')
val query = if (queryAt >= 0) raw.substring(queryAt + 1) else ""
val normalized = normalizeWebPath(if (queryAt >= 0) raw.substring(0, queryAt) else raw)
?: return null
if (query.isEmpty()) WEB_PATH_TO_ROUTE[normalized]?.let { return it }
if (!normalized.startsWith("/")) return null
// Blank segments ("/site//news") mean a malformed path, not a slug.
val segments = normalized.removePrefix("/").split('/')
if (segments.any { it.isBlank() }) return null
// The one path that may carry a query, and the one key it may carry. Checked
// before the general "a query hands off" rule below, and nowhere else.
if (segments.size == 3 && segments[0] == "site" && segments[1] == "events" &&
segments[2] != "series"
) {
// No query is the ordinary case — a link to the event rather than to one
// of its occurrences. A query is honored only when it is exactly the run.
if (query.isEmpty()) return Routes.event(segments[2])
val run = runParam(query) ?: return null
return Routes.event(segments[2], run)
}
if (query.isNotEmpty()) return null
return when {
segments.size == 1 -> segments[0].takeIf { it !in RESERVED_TOP_LEVEL }?.let(Routes::page)
segments[0] == "wiki" && segments.size == 2 -> Routes.wikiPage(segments[1])
segments[0] != "site" -> null
segments.size == 3 && segments[1] == "newsletter" ->
segments[0] == "site" && segments.size == 3 && segments[1] == "newsletter" ->
Routes.post(PostCategory.NEWSLETTER.urlSlug, segments[2])
segments.size == 3 && segments[1] == "atlas" -> Routes.atlasCreature(segments[2])
segments.size == 4 && segments[1] == "market" && segments[2] == "vendors" ->
Routes.marketVendor(segments[3])
segments[0] == "site" && segments.size == 4 && segments[1] == "events" &&
segments[2] == "series" -> Routes.eventSeries(segments[3])
segments[0] == MODULE_UO && segments.size == 3 && segments[1] == "atlas" ->
Routes.atlasCreature(segments[2])
segments[0] == MODULE_UO && segments.size == 4 && segments[1] == "market" &&
segments[2] == "vendors" -> Routes.marketVendor(segments[3])
else -> null
}
}
/**
* The `run` value of a query that consists of **exactly** `run=<something>`, or
* null for every other query — including one that merely contains a `run` among
* others.
*
* Deliberately not a query parser. A second parameter means the writer meant
* something the app cannot honor, and the honest answer to that is the browser.
* An empty value (`?run=`) is null too: it would reach the screen as a blank
* string and be forwarded to the server as one.
*/
private fun runParam(query: String): String? {
val value = query.removePrefix("run=")
if (value.length == query.length || value.isEmpty()) return null
return value.takeIf { '&' !in it && '=' !in it }
}
/**
* The module id whose public pages this table maps.
*
* Named once rather than spelled into four branches, so what is coupled to one
* module is countable. It is a literal on purpose — see the file header.
*/
private const val MODULE_UO = "uo"

View File

@@ -47,6 +47,29 @@ object Routes {
const val NOTIFICATIONS = "notifications"
const val NOTIFICATIONS_SETTINGS = "notifications/settings"
/**
* Events (§9 M13) — CORE's, not a module's: these screens exist on a backend
* with no game module at all, which is why they are not under `shard/`.
*
* **[EVENT_ROUTE] is the app's first route that takes a query**, and it takes
* exactly one: `run`, naming which occurrence a results table is about. The
* page lives at the definition's slug so a weekly event has one address that
* survives a retitle, and the occurrence has to live somewhere else. See
* [resolveWebPath], whose "a query hands off" rule this is the one exception
* to.
*
* **[MY_EVENTS] is `account/events` and not `events/mine`**, which is not
* cosmetic: `events/mine` and `events/{slug}` are both two segments, and a
* static-versus-argument race between two NavHost patterns is exactly the bug
* events Phase 13 shipped one tier along, where a static `events/new` outranked
* `events/:id` in React Router and made creating an event impossible for seven
* phases. Under `account/` there is no dynamic sibling and no race to lose.
*/
const val EVENTS = "events"
const val EVENT_ROUTE = "events/{slug}?run={run}"
const val EVENT_SERIES = "events/series/{slug}"
const val MY_EVENTS = "account/events"
/** Public shard hub (§6.2). */
const val SHARD = "shard"
@@ -97,6 +120,7 @@ object Routes {
const val CATEGORY = "category"
const val ID_OR_SLUG = "idOrSlug"
const val SERIAL = "serial"
const val RUN = "run"
}
fun page(slug: String) = "page/$slug"
@@ -119,6 +143,21 @@ object Routes {
/** One creature's atlas page, by slug. */
fun atlasCreature(slug: String) = "atlas/$slug"
/**
* One event's page, optionally about one occurrence.
*
* [runId] is what an announcement's link carries, and it is dropped when
* absent rather than sent as an empty argument — `events/x?run=` would reach
* the screen as a blank string and be forwarded to the server as one.
*/
fun event(slug: String, runId: String? = null): String {
val base = "events/$slug"
return if (runId.isNullOrBlank()) base else "$base?run=$runId"
}
/** One arc, by slug. */
fun eventSeries(slug: String) = "events/series/$slug"
/**
* The in-app destination a tapped push notification deep-links to (§11, M7
* Part 2 work item 7). Maps a stream id to the screen that shows its content;
@@ -135,9 +174,19 @@ object Routes {
com.runicgateway.app.core.push.PushStreams.VENDOR_SALE -> PLAYER_VENDORS
com.runicgateway.app.core.push.PushStreams.HOUSE_IDOC -> PLAYER_HOUSES
com.runicgateway.app.core.push.PushStreams.ACCOUNT_LOGIN -> ACCOUNT
else -> HOME
// An engagement rule's tickle carries the TRIGGER id as its stream
// (ENGAGEMENT.md §7.2's one namespace), and `event.run.started` is the only
// event trigger that is also a push stream. The calendar is the honest
// destination when there is no inbox row to send it to — the tickle names
// no occurrence, so there is no page to open. A row, when there is one,
// wins via [forTickle] and carries the link that does.
else -> if (streamId.startsWith(EVENT_STREAM_PREFIX)) EVENTS else HOME
}
/** What every core `event.` trigger id begins with (EVENTS.md §J). */
private const val EVENT_STREAM_PREFIX = "event."
/**
* Where a tapped tickle lands, given both halves of `{ stream, ref }`.
*

View File

@@ -3,8 +3,7 @@
*/
package com.runicgateway.app.ui.notifications
import java.time.Instant
import java.time.LocalDateTime
import com.runicgateway.app.core.time.parseWireInstant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
@@ -14,11 +13,8 @@ import java.time.format.FormatStyle
* locale (ENGAGEMENT.md phase 8). Pure, so it is unit-testable off-device.
*
* **Two shapes have to be accepted, and which one arrives is not the app's to
* decide.** Express serializes a `Date` to ISO-8601 with a `Z`, but the value
* starts life as a MariaDB `DATETIME`, and a column read back as a string reaches
* the wire as `2026-08-31 07:13:50` with no zone at all. A zoneless stamp is read
* as UTC — that is what the server stores — rather than as local time, which
* would silently shift every timestamp by the device's offset.
* decide** — see [parseWireInstant], which owns that trap for every screen that
* reads a timestamp, this one and the event screens (M13).
*
* Anything unparseable returns null and the row simply shows no stamp: a
* notification with an odd date is still worth reading.
@@ -29,25 +25,10 @@ fun inboxTimestamp(
formatter: DateTimeFormatter =
DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM, FormatStyle.SHORT),
): String? {
val instant = parseInstant(raw) ?: return null
val instant = parseWireInstant(raw) ?: return null
return try {
formatter.withZone(zone).format(instant)
} catch (_: Exception) {
null
}
}
private fun parseInstant(raw: String): Instant? {
val text = raw.trim()
if (text.isEmpty()) return null
return try {
Instant.parse(text)
} catch (_: Exception) {
try {
// No zone on the wire → UTC, because that is what the server stored.
LocalDateTime.parse(text.replace(' ', 'T')).atZone(ZoneId.of("UTC")).toInstant()
} catch (_: Exception) {
null
}
}
}

View File

@@ -63,6 +63,7 @@ import com.runicgateway.app.ui.components.ShardCard
@Composable
fun InboxScreen(
onOpenSettings: () -> Unit,
onOpenRoute: (String) -> Unit,
modifier: Modifier = Modifier,
viewModel: InboxViewModel = hiltViewModel(),
) {
@@ -119,10 +120,18 @@ fun InboxScreen(
onOpen = { item ->
viewModel.markRead(item.id)
// Most items have no url at all — an inbox row is complete on
// its own — and the ones that do carry a SITE-RELATIVE path,
// so the view model resolves it against the configured shard
// before anything is opened.
viewModel.linkFor(item)?.let { WebHandoff.open(context, it) }
// its own — and the ones that do carry a SITE-RELATIVE path.
//
// A path the app has a screen for opens natively (M13): an
// event announcement's link is the case that made this worth
// doing. Everything else resolves against the configured
// shard and goes to the browser, exactly as before.
val route = viewModel.routeFor(item)
if (route != null) {
onOpenRoute(route)
} else {
viewModel.linkFor(item)?.let { WebHandoff.open(context, it) }
}
},
)
}

View File

@@ -14,6 +14,7 @@ import com.runicgateway.app.core.result.map
import com.runicgateway.app.data.api.dto.NotificationItemDto
import com.runicgateway.app.data.repository.NotificationsRepository
import com.runicgateway.app.ui.UiState
import com.runicgateway.app.ui.navigation.resolveWebPath
import com.runicgateway.app.ui.toUiState
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
@@ -220,6 +221,38 @@ class InboxViewModel @Inject constructor(
return baseUrlHolder.current?.resolve(raw)?.toString()
}
/**
* The app route this item opens natively, or null when it has none and
* [linkFor] should hand it to a browser (M13).
*
* **Why this exists at all:** events Phase 14a gave the six public `event.`
* triggers an `eventUrl` of the form `/site/events/<slug>?run=<id>`, so an
* inbox row about an event now has a native destination — and opening a
* Custom Tab onto a page the app itself renders is a worse answer than it was
* when there was no such page.
*
* **It reuses `resolveWebPath` rather than adding a second link-routing
* mechanism.** That function is already the app's read of the site's own route
* table, it already answers null for everything it does not recognise, and
* every path it does not recognise still hands off exactly as before. Adding a
* parser here would put the decision in two places.
*
* The item's url is site-relative by contract, but an absolute one on this
* host is accepted too: the shape is the server's to change, and a link that
* opened the browser only because it arrived fully qualified would be a
* puzzle. An absolute url on ANOTHER host is not ours to route — the app has
* no screen for somebody else's site — so it falls through to the browser.
*/
fun routeFor(item: NotificationItemDto): String? {
val raw = item.url?.trim().orEmpty()
if (raw.isEmpty()) return null
val base = baseUrlHolder.current ?: return null
val resolved = base.resolve(raw) ?: return null
if (resolved.host != base.host) return null
val query = resolved.query
return resolveWebPath(resolved.encodedPath + if (query.isNullOrEmpty()) "" else "?$query")
}
/**
* Keep the snapshot in step with a local read.
*

View File

@@ -10,6 +10,8 @@ import com.runicgateway.app.core.auth.SessionManager
import com.runicgateway.app.data.repository.AuthRepository
import com.runicgateway.app.data.repository.ShardFeatures
import com.runicgateway.app.data.repository.ShardFeaturesRepository
import com.runicgateway.app.data.repository.SiteCapabilities
import com.runicgateway.app.data.repository.SiteCapabilitiesRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
@@ -26,6 +28,7 @@ class SessionViewModel @Inject constructor(
sessionManager: SessionManager,
private val authRepository: AuthRepository,
shardFeaturesRepository: ShardFeaturesRepository,
siteCapabilitiesRepository: SiteCapabilitiesRepository,
) : ViewModel() {
val session: StateFlow<Session> = sessionManager.state
@@ -38,6 +41,19 @@ class SessionViewModel @Inject constructor(
*/
val shardFeatures: StateFlow<ShardFeatures?> = shardFeaturesRepository.features
/**
* What this BACKEND serves — core's capabilities and every installed module's
* (M13). Exposed here for the reason [shardFeatures] is: the shared menu is
* the consumer, and a row is filtered by both.
*
* **Read-only here, and deliberately not refreshed here.** This answer is per
* HOST, not per viewer: signing in does not install a module. It is resolved
* beside the appearance in [com.runicgateway.app.ui.AppViewModel], which is
* what owns the host's lifecycle — first load, resume, and the Settings →
* Server switch that invalidates it.
*/
val capabilities: StateFlow<SiteCapabilities?> = siteCapabilitiesRepository.capabilities
init {
// The answer is per-viewer, so it is re-resolved on every session change.
// A StateFlow conflates equal values, so a resume revalidation that returns

View File

@@ -41,6 +41,7 @@
<string name="nav_opens_in_browser">Opens in your browser</string>
<string name="menu_home">Home</string>
<string name="menu_news">News</string>
<string name="menu_events">Events</string>
<string name="menu_wiki">Wiki</string>
<string name="menu_shard">Shard</string>
<string name="menu_rules">Rules</string>
@@ -50,6 +51,7 @@
<string name="menu_about">About</string>
<string name="menu_contact">Contact</string>
<string name="menu_account">My account</string>
<string name="menu_my_events">My events</string>
<string name="menu_my_characters">My characters</string>
<string name="menu_my_vendors">My vendors</string>
<string name="menu_my_houses">My houses</string>
@@ -525,4 +527,43 @@
<string name="push_stream_house_idoc">Your house entered IDOC</string>
<string name="push_stream_account_login">Login to your account</string>
<string name="push_stream_generic">New notification</string>
<!-- ── Events (§9 M13, EVENTS.md §I) ─────────────────────────── -->
<!--
The four status words. `cancelled` has TWO, chosen by the clock rather than
the status: "did not happen" is right for a past occurrence and false for a
future one, and a run four days out that an operator called off is the common
case. See EventTimes.statusWordRes.
-->
<string name="events_status_live">Happening now</string>
<string name="events_status_scheduled">Scheduled</string>
<string name="events_status_completed">Finished</string>
<string name="events_status_cancelled">Cancelled</string>
<string name="events_status_did_not_happen">Did not happen</string>
<string name="events_empty">Nothing on the calendar just yet — check back soon.</string>
<!-- A forecast past the materialisation horizon: nothing is committed to it. -->
<string name="events_projected">Expected — not yet confirmed</string>
<string name="events_truncated">Showing the first part of a busy calendar.</string>
<string name="events_part_of">Part of %1$s</string>
<string name="events_next">Next</string>
<string name="events_under_way">Under way</string>
<string name="events_nothing_scheduled">Nothing scheduled at the moment.</string>
<string name="events_never_scheduled">This event has not been scheduled yet.</string>
<string name="events_coming_up">Coming up</string>
<string name="events_previously">Previously</string>
<string name="events_results">Results</string>
<string name="events_results_nobody">Results were published with nobody recorded.</string>
<!-- A module puts a display name in its participation meta or it does not; the
member key is never published, so there is nothing else to render. -->
<string name="events_participant_unnamed">Unnamed</string>
<string name="events_history_empty">You have not taken part in an event yet.</string>
<string name="events_rank">Rank %1$d</string>
<!-- Not a dash: an unranked row is a real state, not a missing value. -->
<string name="events_results_unpublished">Results not published</string>
<string name="events_score">Score %1$s</string>
<string name="events_show_more">Show more</string>
<string name="events_loading">Loading…</string>
</resources>