Merge pull request 'feat(m4): player self-service & game data' (#9) from feat/m4-player-self-service into main
Reviewed-on: #9 Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
This commit is contained in:
55
app/src/main/java/com/runicgateway/app/data/api/MeApi.kt
Normal file
55
app/src/main/java/com/runicgateway/app/data/api/MeApi.kt
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.data.api
|
||||||
|
|
||||||
|
import com.runicgateway.app.data.api.dto.ChangePasswordRequest
|
||||||
|
import com.runicgateway.app.data.api.dto.ChangeUsernameRequest
|
||||||
|
import com.runicgateway.app.data.api.dto.LinkedIdentityDto
|
||||||
|
import com.runicgateway.app.data.api.dto.PlayerAccountDto
|
||||||
|
import com.runicgateway.app.data.api.dto.TotpCodeRequest
|
||||||
|
import com.runicgateway.app.data.api.dto.TotpSetupDto
|
||||||
|
import com.runicgateway.app.data.api.dto.TotpStateDto
|
||||||
|
import com.runicgateway.app.data.api.dto.UsernameResponse
|
||||||
|
import retrofit2.http.Body
|
||||||
|
import retrofit2.http.GET
|
||||||
|
import retrofit2.http.HTTP
|
||||||
|
import retrofit2.http.PATCH
|
||||||
|
import retrofit2.http.POST
|
||||||
|
import retrofit2.http.Path
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The role-agnostic self-service surface (PLAN.md §6.3, §6.4): account, credential
|
||||||
|
* changes, TOTP enrollment, and linked SSO identities under `/auth/me/account*`.
|
||||||
|
* The app calls these regardless of role and never touches `/admin`. Every call
|
||||||
|
* rides the main client, so [com.runicgateway.app.core.net.AuthInterceptor] attaches
|
||||||
|
* the bearer and [com.runicgateway.app.core.net.TokenAuthenticator] refreshes on 401.
|
||||||
|
*/
|
||||||
|
interface MeApi {
|
||||||
|
|
||||||
|
@GET("api/v1/auth/me/account")
|
||||||
|
suspend fun getAccount(): PlayerAccountDto
|
||||||
|
|
||||||
|
@PATCH("api/v1/auth/me/account/username")
|
||||||
|
suspend fun changeUsername(@Body body: ChangeUsernameRequest): UsernameResponse
|
||||||
|
|
||||||
|
@PATCH("api/v1/auth/me/account/password")
|
||||||
|
suspend fun changePassword(@Body body: ChangePasswordRequest): Unit
|
||||||
|
|
||||||
|
@POST("api/v1/auth/me/account/totp/setup")
|
||||||
|
suspend fun totpSetup(): TotpSetupDto
|
||||||
|
|
||||||
|
@POST("api/v1/auth/me/account/totp/enable")
|
||||||
|
suspend fun totpEnable(@Body body: TotpCodeRequest): TotpStateDto
|
||||||
|
|
||||||
|
@POST("api/v1/auth/me/account/totp/disable")
|
||||||
|
suspend fun totpDisable(@Body body: TotpCodeRequest): TotpStateDto
|
||||||
|
|
||||||
|
@GET("api/v1/auth/me/account/identities")
|
||||||
|
suspend fun identities(): List<LinkedIdentityDto>
|
||||||
|
|
||||||
|
// DELETE with no body — a plain @DELETE would suffice, but @HTTP keeps the
|
||||||
|
// path template explicit alongside the provider argument.
|
||||||
|
@HTTP(method = "DELETE", path = "api/v1/auth/me/account/identities/{provider}")
|
||||||
|
suspend fun unlinkIdentity(@Path("provider") provider: String): Unit
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.data.api
|
||||||
|
|
||||||
|
import com.runicgateway.app.data.api.dto.CharProfileDto
|
||||||
|
import com.runicgateway.app.data.api.dto.CreateGameAccountRequest
|
||||||
|
import com.runicgateway.app.data.api.dto.PlayerHouseDto
|
||||||
|
import com.runicgateway.app.data.api.dto.RosterDto
|
||||||
|
import com.runicgateway.app.data.api.dto.ShardLinkDto
|
||||||
|
import com.runicgateway.app.data.api.dto.ShardLinkRequest
|
||||||
|
import com.runicgateway.app.data.api.dto.ShardLinkResultDto
|
||||||
|
import com.runicgateway.app.data.api.dto.VendorSaleDto
|
||||||
|
import com.runicgateway.app.data.api.dto.VendorSnapshotDto
|
||||||
|
import retrofit2.http.Body
|
||||||
|
import retrofit2.http.GET
|
||||||
|
import retrofit2.http.POST
|
||||||
|
import retrofit2.http.Path
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A player's own game data + game-account linking (PLAN.md §6.3), over the
|
||||||
|
* bearer-gated `/player/shard/…` surface. Every read is ownership-checked
|
||||||
|
* server-side; a `503` means the shard/sidecar is down → the UI renders "offline,
|
||||||
|
* retry" (§7). All reads ride the main authed client (bearer + refresh-on-401).
|
||||||
|
*/
|
||||||
|
interface PlayerShardApi {
|
||||||
|
|
||||||
|
/** Confirm an in-game `[link` one-time code, tagging the game account to the user. */
|
||||||
|
@POST("api/v1/player/shard/link")
|
||||||
|
suspend fun link(@Body body: ShardLinkRequest): ShardLinkResultDto
|
||||||
|
|
||||||
|
/** Provision a game account (hybrid signup) and auto-link it to the caller. */
|
||||||
|
@POST("api/v1/player/shard/account")
|
||||||
|
suspend fun createAccount(@Body body: CreateGameAccountRequest): ShardLinkResultDto
|
||||||
|
|
||||||
|
/** The caller's linked game accounts. */
|
||||||
|
@GET("api/v1/player/shard/accounts")
|
||||||
|
suspend fun accounts(): List<ShardLinkDto>
|
||||||
|
|
||||||
|
/** Character roster for a linked account. */
|
||||||
|
@GET("api/v1/player/shard/roster/{account}")
|
||||||
|
suspend fun roster(@Path("account") account: String): RosterDto
|
||||||
|
|
||||||
|
/** A character sheet — only for a character on the caller's linked account. */
|
||||||
|
@GET("api/v1/player/shard/char/{serial}")
|
||||||
|
suspend fun char(@Path("serial") serial: String): CharProfileDto
|
||||||
|
|
||||||
|
/** Player vendors for a linked account. */
|
||||||
|
@GET("api/v1/player/shard/vendors/{account}")
|
||||||
|
suspend fun vendors(@Path("account") account: String): VendorSnapshotDto
|
||||||
|
|
||||||
|
/** Recent player-vendor sales across the caller's linked accounts. */
|
||||||
|
@GET("api/v1/player/shard/sales")
|
||||||
|
suspend fun sales(): List<VendorSaleDto>
|
||||||
|
|
||||||
|
/** The caller's own houses (home/decay status). */
|
||||||
|
@GET("api/v1/player/shard/houses")
|
||||||
|
suspend fun houses(): List<PlayerHouseDto>
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.data.api.dto
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The role-agnostic self-service ("me") wire shapes (PLAN.md §6.3, §6.4). Field
|
||||||
|
* names match the backend's `account.controller` handlers exactly, surfaced for
|
||||||
|
* the app under `/auth/me/account*`. Every DTO ignores unknown keys (NetworkModule's
|
||||||
|
* lenient Json), so additive backend fields are safe (recorded for M1).
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** `GET /auth/me/account` — the current account (any role). */
|
||||||
|
@Serializable
|
||||||
|
data class PlayerAccountDto(
|
||||||
|
val id: Long = 0,
|
||||||
|
val username: String = "",
|
||||||
|
val role: String = "",
|
||||||
|
val email: String? = null,
|
||||||
|
val status: String? = null,
|
||||||
|
val totp_enabled: Boolean = false,
|
||||||
|
/** False for an SSO-provisioned account that has not set a password yet. */
|
||||||
|
val has_password: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** `PATCH /auth/me/account/username` body. */
|
||||||
|
@Serializable
|
||||||
|
data class ChangeUsernameRequest(val username: String)
|
||||||
|
|
||||||
|
/** The `{ username }` returned by a successful username change. */
|
||||||
|
@Serializable
|
||||||
|
data class UsernameResponse(val username: String = "")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `PATCH /auth/me/account/password` body. [currentPassword] is omitted only for an
|
||||||
|
* SSO-provisioned account setting its initial password (has_password == false).
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class ChangePasswordRequest(
|
||||||
|
val newPassword: String,
|
||||||
|
val currentPassword: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Enrollment material from `POST /auth/me/account/totp/setup`. */
|
||||||
|
@Serializable
|
||||||
|
data class TotpSetupDto(
|
||||||
|
val otpauthUrl: String? = null,
|
||||||
|
/** QR code as a `data:image/png;base64,…` URL. */
|
||||||
|
val qr: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** `POST /auth/me/account/totp/enable|disable` body — a current authenticator code. */
|
||||||
|
@Serializable
|
||||||
|
data class TotpCodeRequest(val code: String)
|
||||||
|
|
||||||
|
/** Result of enabling/disabling 2FA. */
|
||||||
|
@Serializable
|
||||||
|
data class TotpStateDto(val totp_enabled: Boolean = false)
|
||||||
|
|
||||||
|
/** A linked external identity (`GET /auth/me/account/identities`). */
|
||||||
|
@Serializable
|
||||||
|
data class LinkedIdentityDto(
|
||||||
|
val provider: String = "",
|
||||||
|
val email: String? = null,
|
||||||
|
val linked_at: String? = null,
|
||||||
|
)
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.data.api.dto
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTOs for a player's OWN game data (PLAN.md §6.3), read over the bearer-gated
|
||||||
|
* `/player/shard/…` surface. The roster / char / vendor reads return the sidecar
|
||||||
|
* payload verbatim (a permissive object), so only the fields the app renders are
|
||||||
|
* modeled — unknown keys are ignored by the JSON parser (matching the website's
|
||||||
|
* `CharacterSheet.jsx` / `GameAccounts.jsx` and `docs/link/INTEGRATION.md` §5).
|
||||||
|
* Presentation is text-only for v1 (no item icons / paperdoll).
|
||||||
|
*
|
||||||
|
* In-game serials are hex strings (e.g. "0x24C"), unlike the numeric serials on
|
||||||
|
* the public boards — these are separate endpoints with separate shapes.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ── Game-account linking ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** `GET /player/shard/accounts` — a linked in-game account. */
|
||||||
|
@Serializable
|
||||||
|
data class ShardLinkDto(
|
||||||
|
val account: String = "",
|
||||||
|
val userId: Long? = null,
|
||||||
|
val charName: String? = null,
|
||||||
|
val linkedAt: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** `POST /player/shard/link` body — the one-time code shown by `[link` in game. */
|
||||||
|
@Serializable
|
||||||
|
data class ShardLinkRequest(val code: String)
|
||||||
|
|
||||||
|
/** `POST /player/shard/link` result — the confirmed link. */
|
||||||
|
@Serializable
|
||||||
|
data class ShardLinkResultDto(
|
||||||
|
val linked: Boolean = false,
|
||||||
|
val account: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** `POST /player/shard/account` (hybrid signup) body. */
|
||||||
|
@Serializable
|
||||||
|
data class CreateGameAccountRequest(
|
||||||
|
val account: String,
|
||||||
|
val password: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Character roster + sheet ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** `GET /player/shard/roster/:account` — the account's characters (incl. offline). */
|
||||||
|
@Serializable
|
||||||
|
data class RosterDto(
|
||||||
|
val acct: String? = null,
|
||||||
|
val chars: List<RosterCharDto> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** One character in a roster; the picker fetches the full sheet on demand. */
|
||||||
|
@Serializable
|
||||||
|
data class RosterCharDto(
|
||||||
|
val slot: Int? = null,
|
||||||
|
val serial: String = "",
|
||||||
|
val name: String? = null,
|
||||||
|
val body: Int? = null,
|
||||||
|
val online: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `GET /player/shard/char/:serial` — a character sheet. `guild` / `governorOf` are
|
||||||
|
* best-effort cross-links the backend decorates in (never fail the sheet).
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class CharProfileDto(
|
||||||
|
val serial: String? = null,
|
||||||
|
val name: String? = null,
|
||||||
|
val title: String? = null,
|
||||||
|
val online: Boolean = false,
|
||||||
|
val acct: String? = null,
|
||||||
|
val stats: CharStatsDto? = null,
|
||||||
|
val skills: List<SkillDto> = emptyList(),
|
||||||
|
val equipment: List<EquipmentDto> = emptyList(),
|
||||||
|
val titles: TitlesDto? = null,
|
||||||
|
val guild: GuildRefDto? = null,
|
||||||
|
val governorOf: List<String> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class CharStatsDto(
|
||||||
|
val str: Int? = null,
|
||||||
|
val dex: Int? = null,
|
||||||
|
val int: Int? = null,
|
||||||
|
val hits: Int? = null,
|
||||||
|
val hitsMax: Int? = null,
|
||||||
|
val mana: Int? = null,
|
||||||
|
val manaMax: Int? = null,
|
||||||
|
val stam: Int? = null,
|
||||||
|
val stamMax: Int? = null,
|
||||||
|
val resist: ResistDto? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ResistDto(
|
||||||
|
val phys: Int? = null,
|
||||||
|
val fire: Int? = null,
|
||||||
|
val cold: Int? = null,
|
||||||
|
val pois: Int? = null,
|
||||||
|
val energy: Int? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A skill line. `base` is the trained value, `value` includes item/temp bonuses,
|
||||||
|
* `cap` is the cap — do NOT assume base ≤ cap (GM chars exceed it). Doubles, as the
|
||||||
|
* shard reports tenths.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class SkillDto(
|
||||||
|
val n: String? = null,
|
||||||
|
val base: Double? = null,
|
||||||
|
val value: Double? = null,
|
||||||
|
val cap: Double? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An equipped item. Names are usually clilocs (numeric), not strings, and the app
|
||||||
|
* ships no cliloc table, so the text-only sheet renders layer + id + hue + mods.
|
||||||
|
* [mods] is a flattened map of non-zero AOS attributes (empty for plain items);
|
||||||
|
* kept as a raw object since values may be numbers or strings.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class EquipmentDto(
|
||||||
|
val serial: String? = null,
|
||||||
|
val layer: String? = null,
|
||||||
|
val itemId: Int? = null,
|
||||||
|
val hue: Int? = null,
|
||||||
|
val mods: JsonObject? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display titles (Protocol 2.0). `selected` is the index into `reward` currently
|
||||||
|
* shown (-1 if none); `reward` entries may be a cliloc number-as-string or a
|
||||||
|
* literal — numeric ones are skipped without a cliloc table (as the website does).
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class TitlesDto(
|
||||||
|
val selected: Int? = null,
|
||||||
|
val reward: List<String> = emptyList(),
|
||||||
|
val fameKarma: String? = null,
|
||||||
|
val skill: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** The guild a character leads (cross-linked from board data). */
|
||||||
|
@Serializable
|
||||||
|
data class GuildRefDto(
|
||||||
|
val name: String? = null,
|
||||||
|
val abbr: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Player vendors + sales ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** `GET /player/shard/vendors/:account` — every player vendor on the account. */
|
||||||
|
@Serializable
|
||||||
|
data class VendorSnapshotDto(
|
||||||
|
val acct: String? = null,
|
||||||
|
val vendors: List<VendorDto> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class VendorDto(
|
||||||
|
val serial: String? = null,
|
||||||
|
val shopName: String? = null,
|
||||||
|
val holdGold: Long? = null,
|
||||||
|
val ownerSerial: String? = null,
|
||||||
|
val map: String? = null,
|
||||||
|
val x: Int? = null,
|
||||||
|
val y: Int? = null,
|
||||||
|
val listings: List<VendorListingDto> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** A single vendor listing. Item names are clilocs (see [EquipmentDto]); text-only. */
|
||||||
|
@Serializable
|
||||||
|
data class VendorListingDto(
|
||||||
|
val serial: String? = null,
|
||||||
|
val itemId: Int? = null,
|
||||||
|
val amount: Int? = null,
|
||||||
|
val price: Long? = null,
|
||||||
|
val forSale: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** `GET /player/shard/sales` — a player-vendor sale, visible only to the owner. */
|
||||||
|
@Serializable
|
||||||
|
data class VendorSaleDto(
|
||||||
|
/** Sale time, epoch ms. */
|
||||||
|
val t: Long? = null,
|
||||||
|
val itemType: String? = null,
|
||||||
|
val amount: Int? = null,
|
||||||
|
val price: Long? = null,
|
||||||
|
val commission: Int? = null,
|
||||||
|
val ownerAcct: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Player houses ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `GET /player/shard/houses` — the caller's OWN houses, with full decay/IDOC
|
||||||
|
* detail (their own property). Serial is a hex string here.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class PlayerHouseDto(
|
||||||
|
val serial: String = "",
|
||||||
|
val stage: String? = null,
|
||||||
|
val map: String? = null,
|
||||||
|
val x: Int? = null,
|
||||||
|
val y: Int? = null,
|
||||||
|
val z: Int? = null,
|
||||||
|
val region: String? = null,
|
||||||
|
val name: String? = null,
|
||||||
|
val ownerSerial: String? = null,
|
||||||
|
val ownerAcct: String? = null,
|
||||||
|
val builtOn: String? = null,
|
||||||
|
val lastRefreshed: String? = null,
|
||||||
|
val isIdoc: Boolean = false,
|
||||||
|
val updatedAt: String? = null,
|
||||||
|
)
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
/*
|
||||||
|
* 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.MeApi
|
||||||
|
import com.runicgateway.app.data.api.dto.ChangePasswordRequest
|
||||||
|
import com.runicgateway.app.data.api.dto.ChangeUsernameRequest
|
||||||
|
import com.runicgateway.app.data.api.dto.LinkedIdentityDto
|
||||||
|
import com.runicgateway.app.data.api.dto.PlayerAccountDto
|
||||||
|
import com.runicgateway.app.data.api.dto.TotpCodeRequest
|
||||||
|
import com.runicgateway.app.data.api.dto.TotpSetupDto
|
||||||
|
import com.runicgateway.app.data.api.dto.TotpStateDto
|
||||||
|
import com.runicgateway.app.data.api.dto.UsernameResponse
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Self-service account management over the role-agnostic `/auth/me/account*`
|
||||||
|
* surface (PLAN.md §6.3, §6.4). Every call returns a typed [ApiResult] so the
|
||||||
|
* screens can map known statuses (409 taken, 400 wrong password / invalid code,
|
||||||
|
* 429 rate-limited) to friendly copy without a repository ever throwing (§7).
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class AccountRepository @Inject constructor(
|
||||||
|
private val api: MeApi,
|
||||||
|
) {
|
||||||
|
suspend fun getAccount(): ApiResult<PlayerAccountDto> = safeApiCall { api.getAccount() }
|
||||||
|
|
||||||
|
suspend fun changeUsername(username: String): ApiResult<UsernameResponse> =
|
||||||
|
safeApiCall { api.changeUsername(ChangeUsernameRequest(username)) }
|
||||||
|
|
||||||
|
/** [currentPassword] is null only for an SSO account setting its first password. */
|
||||||
|
suspend fun changePassword(newPassword: String, currentPassword: String?): ApiResult<Unit> =
|
||||||
|
safeApiCall { api.changePassword(ChangePasswordRequest(newPassword, currentPassword)) }
|
||||||
|
|
||||||
|
suspend fun totpSetup(): ApiResult<TotpSetupDto> = safeApiCall { api.totpSetup() }
|
||||||
|
|
||||||
|
suspend fun totpEnable(code: String): ApiResult<TotpStateDto> =
|
||||||
|
safeApiCall { api.totpEnable(TotpCodeRequest(code)) }
|
||||||
|
|
||||||
|
suspend fun totpDisable(code: String): ApiResult<TotpStateDto> =
|
||||||
|
safeApiCall { api.totpDisable(TotpCodeRequest(code)) }
|
||||||
|
|
||||||
|
suspend fun identities(): ApiResult<List<LinkedIdentityDto>> = safeApiCall { api.identities() }
|
||||||
|
|
||||||
|
suspend fun unlinkIdentity(provider: String): ApiResult<Unit> =
|
||||||
|
safeApiCall { api.unlinkIdentity(provider) }
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
/*
|
||||||
|
* 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.PlayerShardApi
|
||||||
|
import com.runicgateway.app.data.api.dto.CharProfileDto
|
||||||
|
import com.runicgateway.app.data.api.dto.CreateGameAccountRequest
|
||||||
|
import com.runicgateway.app.data.api.dto.PlayerHouseDto
|
||||||
|
import com.runicgateway.app.data.api.dto.RosterDto
|
||||||
|
import com.runicgateway.app.data.api.dto.ShardLinkDto
|
||||||
|
import com.runicgateway.app.data.api.dto.ShardLinkRequest
|
||||||
|
import com.runicgateway.app.data.api.dto.ShardLinkResultDto
|
||||||
|
import com.runicgateway.app.data.api.dto.VendorSaleDto
|
||||||
|
import com.runicgateway.app.data.api.dto.VendorSnapshotDto
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A player's own game data + game-account linking (PLAN.md §6.3), over the
|
||||||
|
* bearer-gated `/player/shard/…` surface. Every read returns a typed [ApiResult]
|
||||||
|
* so a down shard (`503`) renders as "offline, retry" and a not-linked account
|
||||||
|
* (`403`) is handled cleanly — the repository never throws for an expected
|
||||||
|
* failure (§7). Ownership is enforced server-side.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class PlayerShardRepository @Inject constructor(
|
||||||
|
private val api: PlayerShardApi,
|
||||||
|
) {
|
||||||
|
suspend fun link(code: String): ApiResult<ShardLinkResultDto> =
|
||||||
|
safeApiCall { api.link(ShardLinkRequest(code)) }
|
||||||
|
|
||||||
|
suspend fun createAccount(account: String, password: String): ApiResult<ShardLinkResultDto> =
|
||||||
|
safeApiCall { api.createAccount(CreateGameAccountRequest(account, password)) }
|
||||||
|
|
||||||
|
suspend fun accounts(): ApiResult<List<ShardLinkDto>> = safeApiCall { api.accounts() }
|
||||||
|
|
||||||
|
suspend fun roster(account: String): ApiResult<RosterDto> = safeApiCall { api.roster(account) }
|
||||||
|
|
||||||
|
suspend fun char(serial: String): ApiResult<CharProfileDto> = safeApiCall { api.char(serial) }
|
||||||
|
|
||||||
|
suspend fun vendors(account: String): ApiResult<VendorSnapshotDto> =
|
||||||
|
safeApiCall { api.vendors(account) }
|
||||||
|
|
||||||
|
suspend fun sales(): ApiResult<List<VendorSaleDto>> = safeApiCall { api.sales() }
|
||||||
|
|
||||||
|
suspend fun houses(): ApiResult<List<PlayerHouseDto>> = safeApiCall { api.houses() }
|
||||||
|
}
|
||||||
@@ -13,6 +13,8 @@ import com.runicgateway.app.core.net.TokenAuthenticator
|
|||||||
import com.runicgateway.app.core.net.UserAgentInterceptor
|
import com.runicgateway.app.core.net.UserAgentInterceptor
|
||||||
import com.runicgateway.app.data.api.AuthApi
|
import com.runicgateway.app.data.api.AuthApi
|
||||||
import com.runicgateway.app.data.api.AuthRefreshApi
|
import com.runicgateway.app.data.api.AuthRefreshApi
|
||||||
|
import com.runicgateway.app.data.api.MeApi
|
||||||
|
import com.runicgateway.app.data.api.PlayerShardApi
|
||||||
import com.runicgateway.app.data.api.PublicApi
|
import com.runicgateway.app.data.api.PublicApi
|
||||||
import dagger.Module
|
import dagger.Module
|
||||||
import dagger.Provides
|
import dagger.Provides
|
||||||
@@ -93,6 +95,17 @@ object NetworkModule {
|
|||||||
@Singleton
|
@Singleton
|
||||||
fun provideAuthApi(retrofit: Retrofit): AuthApi = retrofit.create(AuthApi::class.java)
|
fun provideAuthApi(retrofit: Retrofit): AuthApi = retrofit.create(AuthApi::class.java)
|
||||||
|
|
||||||
|
/** Role-agnostic self-service (§6.4) — bearer-authed on the main client. */
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideMeApi(retrofit: Retrofit): MeApi = retrofit.create(MeApi::class.java)
|
||||||
|
|
||||||
|
/** A player's own game data + linking (§6.3) — bearer-authed on the main client. */
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun providePlayerShardApi(retrofit: Retrofit): PlayerShardApi =
|
||||||
|
retrofit.create(PlayerShardApi::class.java)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Token refresh runs on its own **bare** client — UA + host retargeting only,
|
* Token refresh runs on its own **bare** client — UA + host retargeting only,
|
||||||
* no auth interceptor and no authenticator — so a refresh can never recurse
|
* no auth interceptor and no authenticator — so a refresh can never recurse
|
||||||
|
|||||||
@@ -54,6 +54,10 @@ import com.runicgateway.app.ui.navigation.visibleEntries
|
|||||||
import com.runicgateway.app.ui.news.NewsScreen
|
import com.runicgateway.app.ui.news.NewsScreen
|
||||||
import com.runicgateway.app.ui.news.PostScreen
|
import com.runicgateway.app.ui.news.PostScreen
|
||||||
import com.runicgateway.app.ui.page.PageScreen
|
import com.runicgateway.app.ui.page.PageScreen
|
||||||
|
import com.runicgateway.app.ui.player.CharacterSheetScreen
|
||||||
|
import com.runicgateway.app.ui.player.CharactersScreen
|
||||||
|
import com.runicgateway.app.ui.player.MyHousesScreen
|
||||||
|
import com.runicgateway.app.ui.player.VendorsScreen
|
||||||
import com.runicgateway.app.ui.session.SessionViewModel
|
import com.runicgateway.app.ui.session.SessionViewModel
|
||||||
import com.runicgateway.app.ui.shard.ChampsScreen
|
import com.runicgateway.app.ui.shard.ChampsScreen
|
||||||
import com.runicgateway.app.ui.shard.GovernorsScreen
|
import com.runicgateway.app.ui.shard.GovernorsScreen
|
||||||
@@ -68,6 +72,7 @@ import kotlinx.coroutines.launch
|
|||||||
/** Destinations that show the drawer (hamburger); others show a back arrow. */
|
/** Destinations that show the drawer (hamburger); others show a back arrow. */
|
||||||
private val TOP_LEVEL_ROUTES = setOf(
|
private val TOP_LEVEL_ROUTES = setOf(
|
||||||
Routes.HOME, Routes.NEWS, Routes.WIKI, Routes.SHARD, Routes.CONTACT, Routes.PAGE, Routes.ACCOUNT,
|
Routes.HOME, Routes.NEWS, Routes.WIKI, Routes.SHARD, Routes.CONTACT, Routes.PAGE, Routes.ACCOUNT,
|
||||||
|
Routes.PLAYER_CHARACTERS, Routes.PLAYER_VENDORS, Routes.PLAYER_HOUSES,
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -283,6 +288,45 @@ private fun RunicNavHost(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Player game data (§6.3) — reached from the player-only menu groups.
|
||||||
|
// The server enforces the player gate on every call; these screens simply
|
||||||
|
// render 401/403/503 as clean states (§7).
|
||||||
|
composable(Routes.PLAYER_CHARACTERS) {
|
||||||
|
PlayerGate(session, navController) {
|
||||||
|
CharactersScreen(onOpenChar = { serial -> navController.navigate(Routes.playerChar(serial)) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
composable(
|
||||||
|
route = Routes.PLAYER_CHAR,
|
||||||
|
arguments = listOf(navArgument(Routes.Args.SERIAL) { type = NavType.StringType }),
|
||||||
|
) {
|
||||||
|
CharacterSheetScreen()
|
||||||
|
}
|
||||||
|
composable(Routes.PLAYER_VENDORS) {
|
||||||
|
PlayerGate(session, navController) { VendorsScreen() }
|
||||||
|
}
|
||||||
|
composable(Routes.PLAYER_HOUSES) {
|
||||||
|
PlayerGate(session, navController) { MyHousesScreen() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A UX guard for the player-only groups: while signed in, render [content]; if the
|
||||||
|
* session drops (sign-out, or a server-side demotion caught on resume, §4.3), send
|
||||||
|
* the user home instead of leaving a stale player screen up. The backend remains
|
||||||
|
* the authority — this only mirrors the menu's visibility rule.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun PlayerGate(
|
||||||
|
session: Session,
|
||||||
|
navController: NavHostController,
|
||||||
|
content: @Composable () -> Unit,
|
||||||
|
) {
|
||||||
|
when (session) {
|
||||||
|
is Session.SignedIn -> content()
|
||||||
|
Session.SignedOut -> LaunchedEffect(Unit) { navController.navigateTopLevel(Routes.HOME) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,30 +3,59 @@
|
|||||||
*/
|
*/
|
||||||
package com.runicgateway.app.ui.auth
|
package com.runicgateway.app.ui.auth
|
||||||
|
|
||||||
|
import android.graphics.BitmapFactory
|
||||||
|
import android.util.Base64
|
||||||
|
import androidx.annotation.StringRes
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedButton
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.annotation.StringRes
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.ui.graphics.ImageBitmap
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import com.runicgateway.app.R
|
import com.runicgateway.app.R
|
||||||
import com.runicgateway.app.core.auth.Role
|
import com.runicgateway.app.core.auth.Role
|
||||||
|
import com.runicgateway.app.data.api.dto.LinkedIdentityDto
|
||||||
|
import com.runicgateway.app.data.api.dto.PlayerAccountDto
|
||||||
|
import com.runicgateway.app.ui.UiState
|
||||||
|
import com.runicgateway.app.ui.auth.AccountViewModel.Feedback
|
||||||
|
import com.runicgateway.app.ui.auth.AccountViewModel.Section
|
||||||
|
import com.runicgateway.app.ui.components.ErrorView
|
||||||
|
import com.runicgateway.app.ui.components.LoadingView
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The signed-in account surface (PLAN.md §5, "My Account"). For the M3 functional
|
* The signed-in account surface (PLAN.md §5, §6.3): identity + role, self-service
|
||||||
* pass it shows the identity + role and the sign-out controls; full self-service
|
* over `/auth/me/account*` (change username/password, TOTP, linked identities),
|
||||||
* (change username/password, TOTP, linked identities via the `/auth/me` surface)
|
* and the sign-out controls. Credential-provisioning flows (register / reset / SSO
|
||||||
* lands in M4 (§6.3).
|
* link) stay website hand-offs (§4.2) and are not rebuilt here.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun AccountScreen(
|
fun AccountScreen(
|
||||||
@@ -35,20 +64,43 @@ fun AccountScreen(
|
|||||||
onSignOut: () -> Unit,
|
onSignOut: () -> Unit,
|
||||||
onSignOutEverywhere: () -> Unit,
|
onSignOutEverywhere: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
viewModel: AccountViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(24.dp),
|
.verticalScroll(rememberScrollState())
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
.padding(16.dp),
|
||||||
verticalArrangement = Arrangement.Top,
|
|
||||||
) {
|
) {
|
||||||
Card(modifier = Modifier.fillMaxWidth()) {
|
IdentityCard(username = username, roleLabel = roleLabel)
|
||||||
|
|
||||||
|
when (val account = state.account) {
|
||||||
|
is UiState.Loading -> LoadingView(Modifier.padding(top = 32.dp))
|
||||||
|
is UiState.Error -> ErrorView(account.kind, onRetry = viewModel::load, modifier = Modifier.padding(top = 32.dp))
|
||||||
|
is UiState.Success -> AccountSections(account.data, state, viewModel)
|
||||||
|
}
|
||||||
|
|
||||||
|
HorizontalDivider(Modifier.padding(vertical = 20.dp))
|
||||||
|
|
||||||
|
OutlinedButton(onClick = onSignOut, modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Text(stringResource(R.string.account_sign_out))
|
||||||
|
}
|
||||||
|
TextButton(
|
||||||
|
onClick = onSignOutEverywhere,
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.account_sign_out_all))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun IdentityCard(username: String, roleLabel: String) {
|
||||||
|
Card(Modifier.fillMaxWidth()) {
|
||||||
Column(Modifier.padding(20.dp)) {
|
Column(Modifier.padding(20.dp)) {
|
||||||
Text(
|
Text(text = username, style = MaterialTheme.typography.titleLarge)
|
||||||
text = username,
|
|
||||||
style = MaterialTheme.typography.titleLarge,
|
|
||||||
)
|
|
||||||
Text(
|
Text(
|
||||||
text = roleLabel,
|
text = roleLabel,
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
@@ -57,25 +109,246 @@ fun AccountScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
OutlinedButton(
|
@Composable
|
||||||
onClick = onSignOut,
|
private fun AccountSections(
|
||||||
modifier = Modifier
|
account: PlayerAccountDto,
|
||||||
.fillMaxWidth()
|
state: AccountViewModel.State,
|
||||||
.padding(top = 24.dp),
|
viewModel: AccountViewModel,
|
||||||
) {
|
) {
|
||||||
Text(stringResource(R.string.account_sign_out))
|
UsernameSection(account, state, viewModel)
|
||||||
}
|
PasswordSection(account, state, viewModel)
|
||||||
|
TwoFactorSection(account, state, viewModel)
|
||||||
|
IdentitiesSection(state, viewModel)
|
||||||
|
}
|
||||||
|
|
||||||
TextButton(
|
@Composable
|
||||||
onClick = onSignOutEverywhere,
|
private fun SectionCard(@StringRes titleRes: Int, content: @Composable () -> Unit) {
|
||||||
modifier = Modifier
|
Card(Modifier.fillMaxWidth().padding(top = 12.dp)) {
|
||||||
.fillMaxWidth()
|
Column(Modifier.padding(16.dp)) {
|
||||||
.padding(top = 4.dp),
|
Text(stringResource(titleRes), style = MaterialTheme.typography.titleMedium)
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun UsernameSection(
|
||||||
|
account: PlayerAccountDto,
|
||||||
|
state: AccountViewModel.State,
|
||||||
|
viewModel: AccountViewModel,
|
||||||
|
) {
|
||||||
|
var username by rememberSaveable(account.username) { mutableStateOf(account.username) }
|
||||||
|
SectionCard(R.string.account_username_title) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = username,
|
||||||
|
onValueChange = { username = it },
|
||||||
|
singleLine = true,
|
||||||
|
enabled = !state.busy,
|
||||||
|
label = { Text(stringResource(R.string.login_username)) },
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(top = 12.dp),
|
||||||
|
)
|
||||||
|
Button(
|
||||||
|
onClick = { viewModel.changeUsername(username) },
|
||||||
|
enabled = !state.busy && username.trim() != account.username && username.trim().length >= 3,
|
||||||
|
modifier = Modifier.padding(top = 12.dp),
|
||||||
|
) { Text(stringResource(R.string.account_username_action)) }
|
||||||
|
SectionFeedback(state.feedback, Section.USERNAME)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PasswordSection(
|
||||||
|
account: PlayerAccountDto,
|
||||||
|
state: AccountViewModel.State,
|
||||||
|
viewModel: AccountViewModel,
|
||||||
|
) {
|
||||||
|
val hasPassword = account.has_password
|
||||||
|
var current by rememberSaveable { mutableStateOf("") }
|
||||||
|
var next by rememberSaveable { mutableStateOf("") }
|
||||||
|
SectionCard(if (hasPassword) R.string.account_password_title else R.string.account_password_set_title) {
|
||||||
|
if (!hasPassword) {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.account_password_set_hint),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(top = 8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (hasPassword) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = current,
|
||||||
|
onValueChange = { current = it },
|
||||||
|
singleLine = true,
|
||||||
|
enabled = !state.busy,
|
||||||
|
label = { Text(stringResource(R.string.account_password_current)) },
|
||||||
|
visualTransformation = PasswordVisualTransformation(),
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(top = 12.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
OutlinedTextField(
|
||||||
|
value = next,
|
||||||
|
onValueChange = { next = it },
|
||||||
|
singleLine = true,
|
||||||
|
enabled = !state.busy,
|
||||||
|
label = { Text(stringResource(R.string.account_password_new)) },
|
||||||
|
visualTransformation = PasswordVisualTransformation(),
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(top = 12.dp),
|
||||||
|
)
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
viewModel.changePassword(next, if (hasPassword) current else null)
|
||||||
|
current = ""
|
||||||
|
next = ""
|
||||||
|
},
|
||||||
|
enabled = !state.busy && next.length >= 8 && (!hasPassword || current.isNotBlank()),
|
||||||
|
modifier = Modifier.padding(top = 12.dp),
|
||||||
) {
|
) {
|
||||||
Text(stringResource(R.string.account_sign_out_all))
|
Text(stringResource(if (hasPassword) R.string.account_password_action else R.string.account_password_set_action))
|
||||||
|
}
|
||||||
|
SectionFeedback(state.feedback, Section.PASSWORD)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun TwoFactorSection(
|
||||||
|
account: PlayerAccountDto,
|
||||||
|
state: AccountViewModel.State,
|
||||||
|
viewModel: AccountViewModel,
|
||||||
|
) {
|
||||||
|
var code by rememberSaveable { mutableStateOf("") }
|
||||||
|
SectionCard(R.string.account_totp_title) {
|
||||||
|
Text(
|
||||||
|
stringResource(if (account.totp_enabled) R.string.account_totp_on else R.string.account_totp_off),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = if (account.totp_enabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(top = 8.dp),
|
||||||
|
)
|
||||||
|
|
||||||
|
when {
|
||||||
|
// Enabled → offer disable via a current code.
|
||||||
|
account.totp_enabled -> {
|
||||||
|
CodeField(code, { code = it }, !state.busy)
|
||||||
|
Button(
|
||||||
|
onClick = { viewModel.disableTotp(code); code = "" },
|
||||||
|
enabled = !state.busy && code.isNotBlank(),
|
||||||
|
modifier = Modifier.padding(top = 12.dp),
|
||||||
|
) { Text(stringResource(R.string.account_totp_disable)) }
|
||||||
|
}
|
||||||
|
// Mid-enrollment → show QR + confirm.
|
||||||
|
state.totpSetup != null -> {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.account_totp_scan),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(top = 12.dp),
|
||||||
|
)
|
||||||
|
rememberQrBitmap(state.totpSetup.qr)?.let { bmp ->
|
||||||
|
Image(
|
||||||
|
bitmap = bmp,
|
||||||
|
contentDescription = stringResource(R.string.account_totp_qr_desc),
|
||||||
|
modifier = Modifier.padding(top = 12.dp).size(180.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
CodeField(code, { code = it }, !state.busy)
|
||||||
|
Row(Modifier.padding(top = 12.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Button(
|
||||||
|
onClick = { viewModel.enableTotp(code); code = "" },
|
||||||
|
enabled = !state.busy && code.isNotBlank(),
|
||||||
|
) { Text(stringResource(R.string.account_totp_confirm)) }
|
||||||
|
OutlinedButton(onClick = { viewModel.cancelTotp(); code = "" }, enabled = !state.busy) {
|
||||||
|
Text(stringResource(R.string.account_totp_cancel))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
// Not enabled, not enrolling → start.
|
||||||
|
else -> {
|
||||||
|
Button(
|
||||||
|
onClick = viewModel::beginTotp,
|
||||||
|
enabled = !state.busy,
|
||||||
|
modifier = Modifier.padding(top = 12.dp),
|
||||||
|
) { Text(stringResource(R.string.account_totp_setup)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SectionFeedback(state.feedback, Section.TOTP)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun IdentitiesSection(state: AccountViewModel.State, viewModel: AccountViewModel) {
|
||||||
|
SectionCard(R.string.account_identities_title) {
|
||||||
|
if (state.identities.isEmpty()) {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.account_identities_empty),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(top = 8.dp),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
state.identities.forEach { identity -> IdentityRow(identity, state.busy, viewModel) }
|
||||||
|
}
|
||||||
|
SectionFeedback(state.feedback, Section.IDENTITY)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun IdentityRow(identity: LinkedIdentityDto, busy: Boolean, viewModel: AccountViewModel) {
|
||||||
|
Row(
|
||||||
|
Modifier.fillMaxWidth().padding(top = 12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Column(Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
identity.provider.replaceFirstChar { it.uppercase() },
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
)
|
||||||
|
identity.email?.let {
|
||||||
|
Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TextButton(onClick = { viewModel.unlinkIdentity(identity.provider) }, enabled = !busy) {
|
||||||
|
Text(stringResource(R.string.account_identity_unlink))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CodeField(code: String, onChange: (String) -> Unit, enabled: Boolean) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = code,
|
||||||
|
onValueChange = { onChange(it.filter(Char::isDigit).take(8)) },
|
||||||
|
singleLine = true,
|
||||||
|
enabled = enabled,
|
||||||
|
label = { Text(stringResource(R.string.login_totp_code)) },
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword),
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(top = 12.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SectionFeedback(feedback: Feedback?, section: Section) {
|
||||||
|
if (feedback == null || feedback.section != section) return
|
||||||
|
Text(
|
||||||
|
text = stringResource(feedback.messageRes),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = if (feedback.ok) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error,
|
||||||
|
modifier = Modifier.padding(top = 10.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Decode a `data:image/png;base64,…` URL (the TOTP QR) into an [ImageBitmap]. */
|
||||||
|
@Composable
|
||||||
|
private fun rememberQrBitmap(dataUrl: String?): ImageBitmap? = remember(dataUrl) {
|
||||||
|
if (dataUrl.isNullOrBlank()) return@remember null
|
||||||
|
val comma = dataUrl.indexOf(',')
|
||||||
|
if (comma < 0) return@remember null
|
||||||
|
runCatching {
|
||||||
|
val bytes = Base64.decode(dataUrl.substring(comma + 1), Base64.DEFAULT)
|
||||||
|
BitmapFactory.decodeByteArray(bytes, 0, bytes.size)?.asImageBitmap()
|
||||||
|
}.getOrNull()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Human label for a role (advisory display only — §4.3). */
|
/** Human label for a role (advisory display only — §4.3). */
|
||||||
|
|||||||
@@ -0,0 +1,185 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.ui.auth
|
||||||
|
|
||||||
|
import androidx.annotation.StringRes
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.runicgateway.app.R
|
||||||
|
import com.runicgateway.app.core.result.ApiResult
|
||||||
|
import com.runicgateway.app.data.api.dto.LinkedIdentityDto
|
||||||
|
import com.runicgateway.app.data.api.dto.PlayerAccountDto
|
||||||
|
import com.runicgateway.app.data.api.dto.TotpSetupDto
|
||||||
|
import com.runicgateway.app.data.repository.AccountRepository
|
||||||
|
import com.runicgateway.app.data.repository.AuthRepository
|
||||||
|
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.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drives the signed-in self-service surface (PLAN.md §6.3, §6.4) over
|
||||||
|
* `/auth/me/account*`: change username/password, enroll/disable TOTP, and manage
|
||||||
|
* linked SSO identities. Each mutation folds its [ApiResult] into a section-scoped
|
||||||
|
* [Feedback] so the screen shows friendly, localized copy inline (§7). A username
|
||||||
|
* change also re-validates the session so the shell reflects the new name at once.
|
||||||
|
*/
|
||||||
|
@HiltViewModel
|
||||||
|
class AccountViewModel @Inject constructor(
|
||||||
|
private val accountRepository: AccountRepository,
|
||||||
|
private val authRepository: AuthRepository,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
/** Which section an action's [Feedback] belongs to, so it renders in place. */
|
||||||
|
enum class Section { USERNAME, PASSWORD, TOTP, IDENTITY }
|
||||||
|
|
||||||
|
/** A one-shot result banner under a section. */
|
||||||
|
data class Feedback(val section: Section, val ok: Boolean, @param:StringRes val messageRes: Int)
|
||||||
|
|
||||||
|
data class State(
|
||||||
|
val account: UiState<PlayerAccountDto> = UiState.Loading,
|
||||||
|
val identities: List<LinkedIdentityDto> = emptyList(),
|
||||||
|
/** True while any mutation is in flight (disables that section's controls). */
|
||||||
|
val busy: Boolean = false,
|
||||||
|
/** The pending TOTP enrollment (QR shown) between setup and enable. */
|
||||||
|
val totpSetup: TotpSetupDto? = null,
|
||||||
|
val feedback: Feedback? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
private val _state = MutableStateFlow(State())
|
||||||
|
val state: StateFlow<State> = _state.asStateFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun load() {
|
||||||
|
_state.update { it.copy(account = UiState.Loading) }
|
||||||
|
viewModelScope.launch {
|
||||||
|
val account = accountRepository.getAccount()
|
||||||
|
_state.update { it.copy(account = account.toUiState()) }
|
||||||
|
// Identities are non-critical — an empty list on failure is fine.
|
||||||
|
when (val ids = accountRepository.identities()) {
|
||||||
|
is ApiResult.Ok -> _state.update { it.copy(identities = ids.data) }
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearFeedback() = _state.update { it.copy(feedback = null) }
|
||||||
|
|
||||||
|
// ── Username ─────────────────────────────────────────────────────────
|
||||||
|
fun changeUsername(username: String) {
|
||||||
|
if (_state.value.busy) return
|
||||||
|
_state.update { it.copy(busy = true, feedback = null) }
|
||||||
|
viewModelScope.launch {
|
||||||
|
when (val result = accountRepository.changeUsername(username.trim())) {
|
||||||
|
is ApiResult.Ok -> {
|
||||||
|
finish(Section.USERNAME, true, R.string.account_username_changed)
|
||||||
|
// Reflect the new name in the shell + refresh the loaded account.
|
||||||
|
authRepository.revalidate()
|
||||||
|
reloadAccount()
|
||||||
|
}
|
||||||
|
else -> finish(Section.USERNAME, false, usernameErrorRes(result))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Password ─────────────────────────────────────────────────────────
|
||||||
|
fun changePassword(newPassword: String, currentPassword: String?) {
|
||||||
|
if (_state.value.busy) return
|
||||||
|
_state.update { it.copy(busy = true, feedback = null) }
|
||||||
|
viewModelScope.launch {
|
||||||
|
when (accountRepository.changePassword(newPassword, currentPassword?.takeIf { it.isNotBlank() })) {
|
||||||
|
is ApiResult.Ok -> finish(Section.PASSWORD, true, R.string.account_password_changed)
|
||||||
|
is ApiResult.HttpError -> finish(Section.PASSWORD, false, R.string.account_password_error)
|
||||||
|
is ApiResult.NetworkError -> finish(Section.PASSWORD, false, R.string.error_network)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── TOTP ─────────────────────────────────────────────────────────────
|
||||||
|
fun beginTotp() {
|
||||||
|
if (_state.value.busy) return
|
||||||
|
_state.update { it.copy(busy = true, feedback = null) }
|
||||||
|
viewModelScope.launch {
|
||||||
|
when (val result = accountRepository.totpSetup()) {
|
||||||
|
is ApiResult.Ok -> _state.update { it.copy(busy = false, totpSetup = result.data) }
|
||||||
|
else -> finish(Section.TOTP, false, R.string.account_totp_setup_error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cancelTotp() = _state.update { it.copy(totpSetup = null, feedback = null) }
|
||||||
|
|
||||||
|
fun enableTotp(code: String) {
|
||||||
|
if (_state.value.busy) return
|
||||||
|
_state.update { it.copy(busy = true, feedback = null) }
|
||||||
|
viewModelScope.launch {
|
||||||
|
when (accountRepository.totpEnable(code.trim())) {
|
||||||
|
is ApiResult.Ok -> {
|
||||||
|
_state.update { it.copy(totpSetup = null) }
|
||||||
|
finish(Section.TOTP, true, R.string.account_totp_enabled)
|
||||||
|
reloadAccount()
|
||||||
|
}
|
||||||
|
is ApiResult.HttpError -> finish(Section.TOTP, false, R.string.account_totp_code_error)
|
||||||
|
is ApiResult.NetworkError -> finish(Section.TOTP, false, R.string.error_network)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun disableTotp(code: String) {
|
||||||
|
if (_state.value.busy) return
|
||||||
|
_state.update { it.copy(busy = true, feedback = null) }
|
||||||
|
viewModelScope.launch {
|
||||||
|
when (accountRepository.totpDisable(code.trim())) {
|
||||||
|
is ApiResult.Ok -> {
|
||||||
|
finish(Section.TOTP, true, R.string.account_totp_disabled)
|
||||||
|
reloadAccount()
|
||||||
|
}
|
||||||
|
is ApiResult.HttpError -> finish(Section.TOTP, false, R.string.account_totp_code_error)
|
||||||
|
is ApiResult.NetworkError -> finish(Section.TOTP, false, R.string.error_network)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Identities ───────────────────────────────────────────────────────
|
||||||
|
fun unlinkIdentity(provider: String) {
|
||||||
|
if (_state.value.busy) return
|
||||||
|
_state.update { it.copy(busy = true, feedback = null) }
|
||||||
|
viewModelScope.launch {
|
||||||
|
when (accountRepository.unlinkIdentity(provider)) {
|
||||||
|
is ApiResult.Ok -> {
|
||||||
|
finish(Section.IDENTITY, true, R.string.account_identity_unlinked)
|
||||||
|
when (val ids = accountRepository.identities()) {
|
||||||
|
is ApiResult.Ok -> _state.update { it.copy(identities = ids.data) }
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> finish(Section.IDENTITY, false, R.string.account_identity_error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun reloadAccount() {
|
||||||
|
when (val account = accountRepository.getAccount()) {
|
||||||
|
is ApiResult.Ok -> _state.update { it.copy(account = UiState.Success(account.data)) }
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun finish(section: Section, ok: Boolean, @StringRes messageRes: Int) =
|
||||||
|
_state.update { it.copy(busy = false, feedback = Feedback(section, ok, messageRes)) }
|
||||||
|
|
||||||
|
private fun usernameErrorRes(result: ApiResult<*>): Int = when {
|
||||||
|
result is ApiResult.HttpError && result.status == 409 -> R.string.account_username_taken
|
||||||
|
result is ApiResult.NetworkError -> R.string.error_network
|
||||||
|
else -> R.string.account_username_error
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,7 +21,7 @@ enum class MenuAccess {
|
|||||||
/** Visible to any signed-in account (§5, "My Account"). */
|
/** Visible to any signed-in account (§5, "My Account"). */
|
||||||
SIGNED_IN,
|
SIGNED_IN,
|
||||||
|
|
||||||
/** Visible only to a player (linked game data lands in M4, §6.3). */
|
/** Visible only to a player — the linked game-data groups (§6.3). */
|
||||||
PLAYER,
|
PLAYER,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,7 +33,8 @@ data class MenuEntry(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* The full menu, in display order. Public content first, then the signed-in
|
* The full menu, in display order. Public content first, then the signed-in
|
||||||
* surfaces. Player game-data groups (My Characters / Vendors / Houses) join in M4.
|
* surfaces, then the player-only game-data groups (revealed once the session's
|
||||||
|
* role is `player`, §6.3).
|
||||||
*/
|
*/
|
||||||
val APP_MENU: List<MenuEntry> = listOf(
|
val APP_MENU: List<MenuEntry> = listOf(
|
||||||
MenuEntry(Routes.HOME, R.string.menu_home),
|
MenuEntry(Routes.HOME, R.string.menu_home),
|
||||||
@@ -43,6 +44,9 @@ val APP_MENU: List<MenuEntry> = listOf(
|
|||||||
MenuEntry(Routes.page("about"), R.string.menu_about),
|
MenuEntry(Routes.page("about"), R.string.menu_about),
|
||||||
MenuEntry(Routes.CONTACT, R.string.menu_contact),
|
MenuEntry(Routes.CONTACT, R.string.menu_contact),
|
||||||
MenuEntry(Routes.ACCOUNT, R.string.menu_account, MenuAccess.SIGNED_IN),
|
MenuEntry(Routes.ACCOUNT, R.string.menu_account, 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),
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -27,6 +27,14 @@ object Routes {
|
|||||||
const val SHARD_GOVERNORS = "shard/governors"
|
const val SHARD_GOVERNORS = "shard/governors"
|
||||||
const val SHARD_HOUSES = "shard/houses"
|
const val SHARD_HOUSES = "shard/houses"
|
||||||
|
|
||||||
|
/** Player game-data groups (§6.3, player-only). Distinct from the public shard boards. */
|
||||||
|
const val PLAYER_CHARACTERS = "player/characters"
|
||||||
|
const val PLAYER_VENDORS = "player/vendors"
|
||||||
|
const val PLAYER_HOUSES = "player/houses"
|
||||||
|
|
||||||
|
/** A single character sheet by in-game (hex) serial. */
|
||||||
|
const val PLAYER_CHAR = "player/char/{serial}"
|
||||||
|
|
||||||
/** CMS page by slug (e.g. the conventional "about" page, mirrored from the site nav). */
|
/** CMS page by slug (e.g. the conventional "about" page, mirrored from the site nav). */
|
||||||
const val PAGE = "page/{slug}"
|
const val PAGE = "page/{slug}"
|
||||||
|
|
||||||
@@ -40,9 +48,13 @@ object Routes {
|
|||||||
const val SLUG = "slug"
|
const val SLUG = "slug"
|
||||||
const val CATEGORY = "category"
|
const val CATEGORY = "category"
|
||||||
const val ID_OR_SLUG = "idOrSlug"
|
const val ID_OR_SLUG = "idOrSlug"
|
||||||
|
const val SERIAL = "serial"
|
||||||
}
|
}
|
||||||
|
|
||||||
fun page(slug: String) = "page/$slug"
|
fun page(slug: String) = "page/$slug"
|
||||||
fun post(categoryUrlSlug: String, idOrSlug: String) = "news/$categoryUrlSlug/$idOrSlug"
|
fun post(categoryUrlSlug: String, idOrSlug: String) = "news/$categoryUrlSlug/$idOrSlug"
|
||||||
fun wikiPage(slug: String) = "wiki/$slug"
|
fun wikiPage(slug: String) = "wiki/$slug"
|
||||||
|
|
||||||
|
/** The character-sheet route for an in-game serial (e.g. "0x24C"). */
|
||||||
|
fun playerChar(serial: String) = "player/char/$serial"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,272 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.ui.player
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.FlowRow
|
||||||
|
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||||
|
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.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import com.runicgateway.app.R
|
||||||
|
import com.runicgateway.app.data.api.dto.CharProfileDto
|
||||||
|
import com.runicgateway.app.data.api.dto.CharStatsDto
|
||||||
|
import com.runicgateway.app.data.api.dto.EquipmentDto
|
||||||
|
import com.runicgateway.app.data.api.dto.ResistDto
|
||||||
|
import com.runicgateway.app.data.api.dto.SkillDto
|
||||||
|
import com.runicgateway.app.data.api.dto.TitlesDto
|
||||||
|
import com.runicgateway.app.ui.UiState
|
||||||
|
import com.runicgateway.app.ui.components.ErrorView
|
||||||
|
import com.runicgateway.app.ui.components.LoadingView
|
||||||
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A text-only character sheet (PLAN.md §6.3): identity + standing, attributes and
|
||||||
|
* vitals, resistances, skills, and equipment. No item icons / paperdoll art — a
|
||||||
|
* richer view is a future enhancement pending the platform art work.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun CharacterSheetScreen(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
viewModel: CharacterViewModel = 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 -> CharacterSheet(s.data, modifier)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CharacterSheet(char: CharProfileDto, modifier: Modifier = Modifier) {
|
||||||
|
Column(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
) {
|
||||||
|
IdentityBlock(char)
|
||||||
|
StandingChips(char)
|
||||||
|
char.stats?.let { AttributesBlock(it) }
|
||||||
|
char.stats?.resist?.let { ResistancesBlock(it) }
|
||||||
|
SkillsBlock(char.skills)
|
||||||
|
EquipmentBlock(char.equipment)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun IdentityBlock(char: CharProfileDto) {
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
char.name ?: stringResource(R.string.player_char_unknown),
|
||||||
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
|
)
|
||||||
|
char.title?.takeIf { it.isNotBlank() }?.let {
|
||||||
|
Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
Row(Modifier.padding(top = 4.dp)) {
|
||||||
|
Text(
|
||||||
|
stringResource(if (char.online) R.string.player_char_online else R.string.player_char_offline),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = if (char.online) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
char.serial?.let {
|
||||||
|
Text(
|
||||||
|
" · $it",
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
|
@Composable
|
||||||
|
private fun StandingChips(char: CharProfileDto) {
|
||||||
|
val chips = buildList {
|
||||||
|
char.governorOf.forEach { add(stringResource(R.string.player_char_governor, it)) }
|
||||||
|
char.guild?.let { g ->
|
||||||
|
val abbr = g.abbr?.let { " [$it]" } ?: ""
|
||||||
|
add(stringResource(R.string.player_char_guildmaster, "${g.name.orEmpty()}$abbr"))
|
||||||
|
}
|
||||||
|
addAll(displayTitles(char.titles))
|
||||||
|
}
|
||||||
|
if (chips.isEmpty()) return
|
||||||
|
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
chips.forEach { Chip(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Chip(text: String) {
|
||||||
|
Surface(
|
||||||
|
color = MaterialTheme.colorScheme.secondaryContainer,
|
||||||
|
shape = MaterialTheme.shapes.small,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text,
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||||
|
modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AttributesBlock(stats: CharStatsDto) {
|
||||||
|
SheetCard(R.string.player_char_attributes) {
|
||||||
|
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||||
|
Stat(stringResource(R.string.player_char_str), stats.str)
|
||||||
|
Stat(stringResource(R.string.player_char_dex), stats.dex)
|
||||||
|
Stat(stringResource(R.string.player_char_int), stats.int)
|
||||||
|
}
|
||||||
|
Column(Modifier.padding(top = 12.dp)) {
|
||||||
|
Vital(stringResource(R.string.player_char_hits), stats.hits, stats.hitsMax)
|
||||||
|
Vital(stringResource(R.string.player_char_mana), stats.mana, stats.manaMax)
|
||||||
|
Vital(stringResource(R.string.player_char_stam), stats.stam, stats.stamMax)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Stat(label: String, value: Int?) {
|
||||||
|
Column {
|
||||||
|
Text("${value ?: "—"}", style = MaterialTheme.typography.titleLarge)
|
||||||
|
Text(label, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Vital(label: String, cur: Int?, max: Int?) {
|
||||||
|
Row(Modifier.fillMaxWidth().padding(vertical = 2.dp), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||||
|
Text(label, style = MaterialTheme.typography.bodyMedium)
|
||||||
|
Text("${cur ?: "—"} / ${max ?: "—"}", style = MaterialTheme.typography.bodyMedium)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ResistancesBlock(resist: ResistDto) {
|
||||||
|
val rows = listOf(
|
||||||
|
stringResource(R.string.player_char_phys) to resist.phys,
|
||||||
|
stringResource(R.string.player_char_fire) to resist.fire,
|
||||||
|
stringResource(R.string.player_char_cold) to resist.cold,
|
||||||
|
stringResource(R.string.player_char_pois) to resist.pois,
|
||||||
|
stringResource(R.string.player_char_energy) to resist.energy,
|
||||||
|
)
|
||||||
|
if (rows.all { it.second == null }) return
|
||||||
|
SheetCard(R.string.player_char_resistances) {
|
||||||
|
Row(Modifier.fillMaxWidth().padding(top = 4.dp), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||||
|
rows.forEach { (label, value) ->
|
||||||
|
Column {
|
||||||
|
Text("${value ?: 0}", style = MaterialTheme.typography.titleMedium)
|
||||||
|
Text(label, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SkillsBlock(skills: List<SkillDto>) {
|
||||||
|
val shown = skills
|
||||||
|
.filter { (it.value ?: it.base ?: 0.0) > 0.0 }
|
||||||
|
.sortedByDescending { it.value ?: 0.0 }
|
||||||
|
if (shown.isEmpty()) return
|
||||||
|
SheetCard(R.string.player_char_skills) {
|
||||||
|
shown.forEach { skill ->
|
||||||
|
Row(Modifier.fillMaxWidth().padding(vertical = 3.dp), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||||
|
Text(skill.n ?: "—", style = MaterialTheme.typography.bodyMedium)
|
||||||
|
Text(formatSkill(skill.value ?: 0.0), style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
|
@Composable
|
||||||
|
private fun EquipmentBlock(equipment: List<EquipmentDto>) {
|
||||||
|
if (equipment.isEmpty()) return
|
||||||
|
SheetCard(R.string.player_char_equipment) {
|
||||||
|
equipment.forEach { item ->
|
||||||
|
Column(Modifier.fillMaxWidth().padding(vertical = 6.dp)) {
|
||||||
|
Text(
|
||||||
|
item.layer ?: stringResource(R.string.player_char_item),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
)
|
||||||
|
val meta = listOfNotNull(
|
||||||
|
item.itemId?.let { stringResource(R.string.player_char_item_id, it) },
|
||||||
|
item.hue?.takeIf { it != 0 }?.let { stringResource(R.string.player_char_item_hue, it) },
|
||||||
|
).joinToString(" · ")
|
||||||
|
if (meta.isNotBlank()) {
|
||||||
|
Text(meta, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
val mods = item.mods.orEmpty()
|
||||||
|
if (mods.isNotEmpty()) {
|
||||||
|
FlowRow(Modifier.padding(top = 4.dp), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
|
mods.forEach { (k, v) -> Chip("$k ${jsonText(v)}") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SheetCard(titleRes: Int, content: @Composable () -> Unit) {
|
||||||
|
Card(Modifier.fillMaxWidth()) {
|
||||||
|
Column(Modifier.padding(16.dp)) {
|
||||||
|
Text(stringResource(titleRes), style = MaterialTheme.typography.titleMedium)
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Pure helpers (unit-tested) ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Format a skill value: drop the ".0" on whole numbers, else one decimal. */
|
||||||
|
internal fun formatSkill(value: Double): String =
|
||||||
|
if (value % 1.0 == 0.0) value.toInt().toString() else "%.1f".format(value)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The human-readable title chips for a [TitlesDto] (parity with the website's
|
||||||
|
* `CharacterSheet.jsx#displayTitles`): fame/karma, skill title, and the selected
|
||||||
|
* reward title — but only if it is a literal string, not a bare cliloc number
|
||||||
|
* (the app ships no cliloc table). De-duplicated, blanks dropped.
|
||||||
|
*/
|
||||||
|
internal fun displayTitles(titles: TitlesDto?): List<String> {
|
||||||
|
if (titles == null) return emptyList()
|
||||||
|
val out = mutableListOf<String>()
|
||||||
|
titles.fameKarma?.let { out.add(it) }
|
||||||
|
titles.skill?.let { out.add(it) }
|
||||||
|
val reward = titles.reward
|
||||||
|
val sel = titles.selected ?: -1
|
||||||
|
val candidate = when {
|
||||||
|
sel in reward.indices -> reward[sel]
|
||||||
|
else -> reward.firstOrNull { it.isNotBlank() && !it.all(Char::isDigit) }
|
||||||
|
}
|
||||||
|
if (candidate != null && candidate.isNotBlank() && !candidate.all(Char::isDigit)) out.add(candidate)
|
||||||
|
return out.filter { it.isNotBlank() }.distinct()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun jsonText(element: kotlinx.serialization.json.JsonElement): String =
|
||||||
|
runCatching { element.jsonPrimitive.content }.getOrElse { element.toString() }
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.ui.player
|
||||||
|
|
||||||
|
import androidx.lifecycle.SavedStateHandle
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.runicgateway.app.data.api.dto.CharProfileDto
|
||||||
|
import com.runicgateway.app.data.repository.PlayerShardRepository
|
||||||
|
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
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single character sheet (PLAN.md §6.3), read from `/player/shard/char/:serial`
|
||||||
|
* for a character on one of the caller's linked accounts (ownership-checked
|
||||||
|
* server-side). A `503` renders as offline/retry, a `403` as not-found (§7).
|
||||||
|
* Text-only presentation for v1.
|
||||||
|
*/
|
||||||
|
@HiltViewModel
|
||||||
|
class CharacterViewModel @Inject constructor(
|
||||||
|
private val repository: PlayerShardRepository,
|
||||||
|
savedStateHandle: SavedStateHandle,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val serial: String = savedStateHandle.get<String>(Routes.Args.SERIAL).orEmpty()
|
||||||
|
|
||||||
|
private val _state = MutableStateFlow<UiState<CharProfileDto>>(UiState.Loading)
|
||||||
|
val state: StateFlow<UiState<CharProfileDto>> = _state.asStateFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun load() {
|
||||||
|
_state.value = UiState.Loading
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.value = repository.char(serial).toUiState()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.ui.player
|
||||||
|
|
||||||
|
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.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import com.runicgateway.app.R
|
||||||
|
import com.runicgateway.app.data.api.dto.RosterCharDto
|
||||||
|
import com.runicgateway.app.ui.ErrorKind
|
||||||
|
import com.runicgateway.app.ui.UiState
|
||||||
|
import com.runicgateway.app.ui.components.ErrorView
|
||||||
|
import com.runicgateway.app.ui.components.LoadingView
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The player's linked game accounts + character rosters (PLAN.md §6.3). Tapping a
|
||||||
|
* character opens its text-only sheet. Not-yet-linked players get the `[link` code
|
||||||
|
* prompt (and, when the shard offers it, a hybrid signup form).
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun CharactersScreen(
|
||||||
|
onOpenChar: (String) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
viewModel: CharactersViewModel = hiltViewModel(),
|
||||||
|
) {
|
||||||
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
when (val accounts = state.accounts) {
|
||||||
|
is UiState.Loading -> LoadingView(modifier)
|
||||||
|
is UiState.Error -> ErrorView(accounts.kind, onRetry = viewModel::load, modifier = modifier)
|
||||||
|
is UiState.Success -> Column(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
LinkCard(state, viewModel)
|
||||||
|
if (state.signupEnabled) CreateAccountCard(state, viewModel)
|
||||||
|
|
||||||
|
if (accounts.data.isEmpty()) {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.player_characters_empty),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
accounts.data.forEach { account ->
|
||||||
|
AccountRoster(
|
||||||
|
account = account,
|
||||||
|
roster = state.rosters[account] ?: UiState.Loading,
|
||||||
|
onRetry = { viewModel.loadRoster(account) },
|
||||||
|
onOpenChar = onOpenChar,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun LinkCard(state: CharactersViewModel.State, viewModel: CharactersViewModel) {
|
||||||
|
var code by rememberSaveable { mutableStateOf("") }
|
||||||
|
Card(Modifier.fillMaxWidth()) {
|
||||||
|
Column(Modifier.padding(16.dp)) {
|
||||||
|
Text(stringResource(R.string.player_link_title), style = MaterialTheme.typography.titleMedium)
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.player_link_hint),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(top = 6.dp),
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = code,
|
||||||
|
onValueChange = { code = it.uppercase() },
|
||||||
|
singleLine = true,
|
||||||
|
enabled = !state.busy,
|
||||||
|
label = { Text(stringResource(R.string.player_link_code)) },
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(top = 12.dp),
|
||||||
|
)
|
||||||
|
Button(
|
||||||
|
onClick = { viewModel.link(code); code = "" },
|
||||||
|
enabled = !state.busy && code.isNotBlank(),
|
||||||
|
modifier = Modifier.padding(top = 12.dp),
|
||||||
|
) { Text(stringResource(R.string.player_link_action)) }
|
||||||
|
FormFeedback(state.feedback)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CreateAccountCard(state: CharactersViewModel.State, viewModel: CharactersViewModel) {
|
||||||
|
var account by rememberSaveable { mutableStateOf("") }
|
||||||
|
var password by rememberSaveable { mutableStateOf("") }
|
||||||
|
Card(Modifier.fillMaxWidth()) {
|
||||||
|
Column(Modifier.padding(16.dp)) {
|
||||||
|
Text(stringResource(R.string.player_create_title), style = MaterialTheme.typography.titleMedium)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = account,
|
||||||
|
onValueChange = { account = it },
|
||||||
|
singleLine = true,
|
||||||
|
enabled = !state.busy,
|
||||||
|
label = { Text(stringResource(R.string.player_create_account)) },
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(top = 12.dp),
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = password,
|
||||||
|
onValueChange = { password = it },
|
||||||
|
singleLine = true,
|
||||||
|
enabled = !state.busy,
|
||||||
|
label = { Text(stringResource(R.string.player_create_password)) },
|
||||||
|
visualTransformation = PasswordVisualTransformation(),
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(top = 12.dp),
|
||||||
|
)
|
||||||
|
Button(
|
||||||
|
onClick = { viewModel.createAccount(account, password); password = "" },
|
||||||
|
enabled = !state.busy && account.isNotBlank() && password.length >= 8,
|
||||||
|
modifier = Modifier.padding(top = 12.dp),
|
||||||
|
) { Text(stringResource(R.string.player_create_action)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AccountRoster(
|
||||||
|
account: String,
|
||||||
|
roster: UiState<List<RosterCharDto>>,
|
||||||
|
onRetry: () -> Unit,
|
||||||
|
onOpenChar: (String) -> Unit,
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
account,
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.padding(vertical = 8.dp),
|
||||||
|
)
|
||||||
|
when (roster) {
|
||||||
|
is UiState.Loading -> Text(
|
||||||
|
stringResource(R.string.player_roster_loading),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
is UiState.Error -> RosterError(roster.kind, onRetry)
|
||||||
|
is UiState.Success -> {
|
||||||
|
if (roster.data.isEmpty()) {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.player_roster_empty),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
roster.data.forEach { CharRow(it, onOpenChar) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun RosterError(kind: ErrorKind, onRetry: () -> Unit) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(
|
||||||
|
stringResource(
|
||||||
|
if (kind == ErrorKind.SHARD_OFFLINE) R.string.error_shard_offline else R.string.error_server,
|
||||||
|
),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
TextButton(onClick = onRetry) { Text(stringResource(R.string.action_retry)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CharRow(char: RosterCharDto, onOpenChar: (String) -> Unit) {
|
||||||
|
Card(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 4.dp)
|
||||||
|
.clickable(enabled = char.serial.isNotBlank()) { onOpenChar(char.serial) },
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
Modifier.fillMaxWidth().padding(14.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Column(Modifier.weight(1f)) {
|
||||||
|
Text(char.name ?: stringResource(R.string.player_char_unknown), style = MaterialTheme.typography.bodyLarge)
|
||||||
|
Text(
|
||||||
|
stringResource(if (char.online) R.string.player_char_online else R.string.player_char_offline),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = if (char.online) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text("›", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun FormFeedback(feedback: CharactersViewModel.Feedback?) {
|
||||||
|
if (feedback == null) return
|
||||||
|
Text(
|
||||||
|
text = stringResource(feedback.messageRes),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = if (feedback.ok) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error,
|
||||||
|
modifier = Modifier.padding(top = 10.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.ui.player
|
||||||
|
|
||||||
|
import androidx.annotation.StringRes
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.runicgateway.app.R
|
||||||
|
import com.runicgateway.app.core.result.ApiResult
|
||||||
|
import com.runicgateway.app.core.result.map
|
||||||
|
import com.runicgateway.app.data.api.dto.RosterCharDto
|
||||||
|
import com.runicgateway.app.data.repository.PlayerShardRepository
|
||||||
|
import com.runicgateway.app.data.repository.SettingsRepository
|
||||||
|
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.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The player's linked game accounts + per-account character rosters (PLAN.md §6.3):
|
||||||
|
* link a game account with a `[link` one-time code (or, when the shard offers it,
|
||||||
|
* a hybrid signup), then browse characters and open a text-only sheet. A per-account
|
||||||
|
* roster carries its own load state so a down shard degrades that account to a
|
||||||
|
* retry without blocking the rest (§7).
|
||||||
|
*/
|
||||||
|
@HiltViewModel
|
||||||
|
class CharactersViewModel @Inject constructor(
|
||||||
|
private val repository: PlayerShardRepository,
|
||||||
|
private val settingsRepository: SettingsRepository,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
/** A one-shot banner for the link / create-account forms. */
|
||||||
|
data class Feedback(val ok: Boolean, @param:StringRes val messageRes: Int)
|
||||||
|
|
||||||
|
data class State(
|
||||||
|
val accounts: UiState<List<String>> = UiState.Loading,
|
||||||
|
val rosters: Map<String, UiState<List<RosterCharDto>>> = emptyMap(),
|
||||||
|
/** Whether the shard currently offers hybrid game-account signup. */
|
||||||
|
val signupEnabled: Boolean = false,
|
||||||
|
val busy: Boolean = false,
|
||||||
|
val feedback: Feedback? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
private val _state = MutableStateFlow(State())
|
||||||
|
val state: StateFlow<State> = _state.asStateFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun load() {
|
||||||
|
_state.update { it.copy(accounts = UiState.Loading, rosters = emptyMap()) }
|
||||||
|
viewModelScope.launch {
|
||||||
|
when (val result = repository.accounts()) {
|
||||||
|
is ApiResult.Ok -> {
|
||||||
|
val names = result.data.map { it.account }
|
||||||
|
_state.update { it.copy(accounts = UiState.Success(names)) }
|
||||||
|
names.forEach { loadRoster(it) }
|
||||||
|
}
|
||||||
|
else -> _state.update { it.copy(accounts = result.map { emptyList<String>() }.toUiState()) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Non-critical: whether hybrid signup is offered right now.
|
||||||
|
viewModelScope.launch {
|
||||||
|
when (val settings = settingsRepository.getSettings()) {
|
||||||
|
is ApiResult.Ok -> _state.update { it.copy(signupEnabled = settings.data.gameAccountSignup) }
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadRoster(account: String) {
|
||||||
|
_state.update { it.copy(rosters = it.rosters + (account to UiState.Loading)) }
|
||||||
|
viewModelScope.launch {
|
||||||
|
val roster = repository.roster(account).map { it.chars }.toUiState()
|
||||||
|
_state.update { it.copy(rosters = it.rosters + (account to roster)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearFeedback() = _state.update { it.copy(feedback = null) }
|
||||||
|
|
||||||
|
fun link(code: String) {
|
||||||
|
if (_state.value.busy || code.isBlank()) return
|
||||||
|
_state.update { it.copy(busy = true, feedback = null) }
|
||||||
|
viewModelScope.launch {
|
||||||
|
when (val result = repository.link(code.trim())) {
|
||||||
|
is ApiResult.Ok -> {
|
||||||
|
_state.update { it.copy(busy = false, feedback = Feedback(true, R.string.player_link_ok)) }
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
is ApiResult.HttpError -> _state.update {
|
||||||
|
it.copy(busy = false, feedback = Feedback(false, linkErrorRes(result.status)))
|
||||||
|
}
|
||||||
|
is ApiResult.NetworkError -> _state.update {
|
||||||
|
it.copy(busy = false, feedback = Feedback(false, R.string.error_network))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun createAccount(account: String, password: String) {
|
||||||
|
if (_state.value.busy || account.isBlank() || password.length < 8) return
|
||||||
|
_state.update { it.copy(busy = true, feedback = null) }
|
||||||
|
viewModelScope.launch {
|
||||||
|
when (val result = repository.createAccount(account.trim(), password)) {
|
||||||
|
is ApiResult.Ok -> {
|
||||||
|
_state.update { it.copy(busy = false, feedback = Feedback(true, R.string.player_create_ok)) }
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
is ApiResult.HttpError -> _state.update {
|
||||||
|
it.copy(busy = false, feedback = Feedback(false, createErrorRes(result.status)))
|
||||||
|
}
|
||||||
|
is ApiResult.NetworkError -> _state.update {
|
||||||
|
it.copy(busy = false, feedback = Feedback(false, R.string.error_network))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun linkErrorRes(status: Int): Int = when (status) {
|
||||||
|
400 -> R.string.player_link_bad_code
|
||||||
|
503 -> R.string.error_shard_offline
|
||||||
|
else -> R.string.player_link_error
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createErrorRes(status: Int): Int = when (status) {
|
||||||
|
409 -> R.string.player_create_taken
|
||||||
|
429 -> R.string.player_create_capped
|
||||||
|
403 -> R.string.player_create_unavailable
|
||||||
|
400 -> R.string.player_create_rejected
|
||||||
|
503 -> R.string.error_shard_offline
|
||||||
|
else -> R.string.player_create_error
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.ui.player
|
||||||
|
|
||||||
|
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.Card
|
||||||
|
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.res.stringResource
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
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.PlayerHouseDto
|
||||||
|
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
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The player's own houses with home/decay status (PLAN.md §6.3), text-only. An
|
||||||
|
* IDOC house is flagged prominently. Only the caller's own property is ever shown.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun MyHousesScreen(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
viewModel: MyHousesViewModel = 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 -> {
|
||||||
|
if (s.data.isEmpty()) {
|
||||||
|
EmptyView(stringResource(R.string.player_houses_empty), modifier)
|
||||||
|
} else {
|
||||||
|
LazyColumn(
|
||||||
|
modifier = modifier.fillMaxSize().padding(horizontal = 16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
contentPadding = androidx.compose.foundation.layout.PaddingValues(vertical = 16.dp),
|
||||||
|
) {
|
||||||
|
items(s.data, key = { it.serial }) { house -> HouseCard(house) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun HouseCard(house: PlayerHouseDto) {
|
||||||
|
Card(Modifier.fillMaxWidth()) {
|
||||||
|
Column(Modifier.padding(16.dp)) {
|
||||||
|
Row(Modifier.fillMaxWidth()) {
|
||||||
|
Text(
|
||||||
|
text = house.name?.takeIf { it.isNotBlank() }
|
||||||
|
?: house.region
|
||||||
|
?: stringResource(R.string.player_house_fallback),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
if (house.isIdoc) {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.houses_idoc_badge),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
house.stage?.takeIf { it.isNotBlank() }?.let {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.player_house_stage, it),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = if (house.isIdoc) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(top = 6.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val where = listOfNotNull(
|
||||||
|
house.region,
|
||||||
|
house.map,
|
||||||
|
house.x?.let { "(${house.x}, ${house.y})" },
|
||||||
|
).joinToString(" · ")
|
||||||
|
if (where.isNotBlank()) {
|
||||||
|
Text(
|
||||||
|
where,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(top = 4.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.ui.player
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.runicgateway.app.data.api.dto.PlayerHouseDto
|
||||||
|
import com.runicgateway.app.data.repository.PlayerShardRepository
|
||||||
|
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 player's OWN houses with decay/IDOC status (PLAN.md §6.3), read from
|
||||||
|
* `/player/shard/houses` — never anyone else's. A `503` renders as offline/retry (§7).
|
||||||
|
*/
|
||||||
|
@HiltViewModel
|
||||||
|
class MyHousesViewModel @Inject constructor(
|
||||||
|
private val repository: PlayerShardRepository,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val _state = MutableStateFlow<UiState<List<PlayerHouseDto>>>(UiState.Loading)
|
||||||
|
val state: StateFlow<UiState<List<PlayerHouseDto>>> = _state.asStateFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun load() {
|
||||||
|
_state.value = UiState.Loading
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.value = repository.houses().toUiState()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.ui.player
|
||||||
|
|
||||||
|
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.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
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.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import com.runicgateway.app.R
|
||||||
|
import com.runicgateway.app.data.api.dto.VendorDto
|
||||||
|
import com.runicgateway.app.data.api.dto.VendorListingDto
|
||||||
|
import com.runicgateway.app.data.api.dto.VendorSaleDto
|
||||||
|
import com.runicgateway.app.ui.ErrorKind
|
||||||
|
import com.runicgateway.app.ui.UiState
|
||||||
|
import com.runicgateway.app.ui.components.ErrorView
|
||||||
|
import com.runicgateway.app.ui.components.LoadingView
|
||||||
|
import java.text.DateFormat
|
||||||
|
import java.util.Date
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The player's own player-vendors + recent sales (PLAN.md §6.3), text-only. Vendor
|
||||||
|
* shops group under their game account; recent sales list across all linked
|
||||||
|
* accounts. A down shard degrades to a per-account retry (§7).
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun VendorsScreen(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
viewModel: VendorsViewModel = hiltViewModel(),
|
||||||
|
) {
|
||||||
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
when (val accounts = state.accounts) {
|
||||||
|
is UiState.Loading -> LoadingView(modifier)
|
||||||
|
is UiState.Error -> ErrorView(accounts.kind, onRetry = viewModel::load, modifier = modifier)
|
||||||
|
is UiState.Success -> Column(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
SalesCard(state.sales)
|
||||||
|
|
||||||
|
if (accounts.data.isEmpty()) {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.player_vendors_no_accounts),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
accounts.data.forEach { account ->
|
||||||
|
AccountVendors(
|
||||||
|
account = account,
|
||||||
|
vendors = state.vendors[account] ?: UiState.Loading,
|
||||||
|
onRetry = { viewModel.loadVendors(account) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SalesCard(sales: UiState<List<VendorSaleDto>>) {
|
||||||
|
Card(Modifier.fillMaxWidth()) {
|
||||||
|
Column(Modifier.padding(16.dp)) {
|
||||||
|
Text(stringResource(R.string.player_sales_title), style = MaterialTheme.typography.titleMedium)
|
||||||
|
when (sales) {
|
||||||
|
is UiState.Loading -> Text(
|
||||||
|
stringResource(R.string.player_roster_loading),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(top = 8.dp),
|
||||||
|
)
|
||||||
|
is UiState.Error -> Text(
|
||||||
|
stringResource(R.string.error_server),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(top = 8.dp),
|
||||||
|
)
|
||||||
|
is UiState.Success -> {
|
||||||
|
if (sales.data.isEmpty()) {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.player_sales_empty),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(top = 8.dp),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
sales.data.forEach { SaleRow(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SaleRow(sale: VendorSaleDto) {
|
||||||
|
Row(Modifier.fillMaxWidth().padding(top = 10.dp), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||||
|
Column(Modifier.padding(end = 12.dp)) {
|
||||||
|
Text(
|
||||||
|
sale.itemType ?: stringResource(R.string.player_char_item),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
)
|
||||||
|
sale.t?.let {
|
||||||
|
Text(
|
||||||
|
DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT).format(Date(it)),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.player_gold_amount, "%,d".format(sale.price ?: 0L)),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AccountVendors(
|
||||||
|
account: String,
|
||||||
|
vendors: UiState<List<VendorDto>>,
|
||||||
|
onRetry: () -> Unit,
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
account,
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.padding(vertical = 8.dp),
|
||||||
|
)
|
||||||
|
when (vendors) {
|
||||||
|
is UiState.Loading -> Text(
|
||||||
|
stringResource(R.string.player_roster_loading),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
is UiState.Error -> VendorError(vendors.kind, onRetry)
|
||||||
|
is UiState.Success -> {
|
||||||
|
if (vendors.data.isEmpty()) {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.player_vendors_empty),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
vendors.data.forEach { VendorCard(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun VendorError(kind: ErrorKind, onRetry: () -> Unit) {
|
||||||
|
Row {
|
||||||
|
Text(
|
||||||
|
stringResource(if (kind == ErrorKind.SHARD_OFFLINE) R.string.error_shard_offline else R.string.error_server),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
TextButton(onClick = onRetry) { Text(stringResource(R.string.action_retry)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun VendorCard(vendor: VendorDto) {
|
||||||
|
Card(Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
|
||||||
|
Column(Modifier.padding(16.dp)) {
|
||||||
|
Text(
|
||||||
|
vendor.shopName ?: stringResource(R.string.player_vendor_fallback),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
)
|
||||||
|
val meta = listOfNotNull(
|
||||||
|
vendor.holdGold?.let { stringResource(R.string.player_vendor_hold, "%,d".format(it)) },
|
||||||
|
vendor.map,
|
||||||
|
).joinToString(" · ")
|
||||||
|
if (meta.isNotBlank()) {
|
||||||
|
Text(meta, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(top = 2.dp))
|
||||||
|
}
|
||||||
|
if (vendor.listings.isNotEmpty()) {
|
||||||
|
HorizontalDivider(Modifier.padding(vertical = 8.dp))
|
||||||
|
vendor.listings.forEach { ListingRow(it) }
|
||||||
|
} else {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.player_vendor_no_listings),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(top = 8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ListingRow(listing: VendorListingDto) {
|
||||||
|
Row(Modifier.fillMaxWidth().padding(vertical = 3.dp), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.player_listing_item, listing.itemId ?: 0, listing.amount ?: 1),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.player_gold_amount, "%,d".format(listing.price ?: 0L)),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.ui.player
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.runicgateway.app.core.result.ApiResult
|
||||||
|
import com.runicgateway.app.core.result.map
|
||||||
|
import com.runicgateway.app.data.api.dto.VendorDto
|
||||||
|
import com.runicgateway.app.data.api.dto.VendorSaleDto
|
||||||
|
import com.runicgateway.app.data.repository.PlayerShardRepository
|
||||||
|
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.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The player's own player-vendors and recent vendor sales (PLAN.md §6.3): a
|
||||||
|
* per-account vendor snapshot (shops + listings) over `/player/shard/vendors/:account`
|
||||||
|
* plus the recent-sales log over `/player/shard/sales`. Each account's snapshot
|
||||||
|
* carries its own load state so a down shard degrades one account to a retry (§7).
|
||||||
|
* Text-only listings — item names are clilocs the app has no table for.
|
||||||
|
*/
|
||||||
|
@HiltViewModel
|
||||||
|
class VendorsViewModel @Inject constructor(
|
||||||
|
private val repository: PlayerShardRepository,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
data class State(
|
||||||
|
val accounts: UiState<List<String>> = UiState.Loading,
|
||||||
|
val vendors: Map<String, UiState<List<VendorDto>>> = emptyMap(),
|
||||||
|
val sales: UiState<List<VendorSaleDto>> = UiState.Loading,
|
||||||
|
)
|
||||||
|
|
||||||
|
private val _state = MutableStateFlow(State())
|
||||||
|
val state: StateFlow<State> = _state.asStateFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun load() {
|
||||||
|
_state.update { it.copy(accounts = UiState.Loading, vendors = emptyMap(), sales = UiState.Loading) }
|
||||||
|
viewModelScope.launch {
|
||||||
|
when (val result = repository.accounts()) {
|
||||||
|
is ApiResult.Ok -> {
|
||||||
|
val names = result.data.map { it.account }
|
||||||
|
_state.update { it.copy(accounts = UiState.Success(names)) }
|
||||||
|
names.forEach { loadVendors(it) }
|
||||||
|
}
|
||||||
|
else -> _state.update { it.copy(accounts = result.map { emptyList<String>() }.toUiState()) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.update { it.copy(sales = repository.sales().toUiState()) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadVendors(account: String) {
|
||||||
|
_state.update { it.copy(vendors = it.vendors + (account to UiState.Loading)) }
|
||||||
|
viewModelScope.launch {
|
||||||
|
val vendors = repository.vendors(account).map { it.vendors }.toUiState()
|
||||||
|
_state.update { it.copy(vendors = it.vendors + (account to vendors)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -42,6 +42,9 @@
|
|||||||
<string name="menu_about">About</string>
|
<string name="menu_about">About</string>
|
||||||
<string name="menu_contact">Contact</string>
|
<string name="menu_contact">Contact</string>
|
||||||
<string name="menu_account">My account</string>
|
<string name="menu_account">My account</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>
|
||||||
<string name="menu_sign_in">Sign in</string>
|
<string name="menu_sign_in">Sign in</string>
|
||||||
<string name="menu_sign_out">Sign out</string>
|
<string name="menu_sign_out">Sign out</string>
|
||||||
<string name="menu_change_server">Change server</string>
|
<string name="menu_change_server">Change server</string>
|
||||||
@@ -67,6 +70,112 @@
|
|||||||
<string name="account_title">My account</string>
|
<string name="account_title">My account</string>
|
||||||
<string name="account_sign_out">Sign out</string>
|
<string name="account_sign_out">Sign out</string>
|
||||||
<string name="account_sign_out_all">Sign out on all devices</string>
|
<string name="account_sign_out_all">Sign out on all devices</string>
|
||||||
|
|
||||||
|
<!-- Username -->
|
||||||
|
<string name="account_username_title">Username</string>
|
||||||
|
<string name="account_username_action">Change username</string>
|
||||||
|
<string name="account_username_changed">Username updated.</string>
|
||||||
|
<string name="account_username_taken">That username is already taken.</string>
|
||||||
|
<string name="account_username_error">Couldn\'t change your username.</string>
|
||||||
|
|
||||||
|
<!-- Password -->
|
||||||
|
<string name="account_password_title">Password</string>
|
||||||
|
<string name="account_password_set_title">Set a password</string>
|
||||||
|
<string name="account_password_set_hint">Your account was created through a linked provider and has no password yet. Set one to also sign in with a username and password.</string>
|
||||||
|
<string name="account_password_current">Current password</string>
|
||||||
|
<string name="account_password_new">New password</string>
|
||||||
|
<string name="account_password_action">Change password</string>
|
||||||
|
<string name="account_password_set_action">Set password</string>
|
||||||
|
<string name="account_password_changed">Password changed.</string>
|
||||||
|
<string name="account_password_error">Couldn\'t change your password. Check your current password and try again.</string>
|
||||||
|
|
||||||
|
<!-- Two-factor -->
|
||||||
|
<string name="account_totp_title">Two-factor authentication</string>
|
||||||
|
<string name="account_totp_on">Enabled</string>
|
||||||
|
<string name="account_totp_off">Not enabled</string>
|
||||||
|
<string name="account_totp_scan">Scan this code with your authenticator app, then enter the current code.</string>
|
||||||
|
<string name="account_totp_qr_desc">Two-factor QR code</string>
|
||||||
|
<string name="account_totp_setup">Set up two-factor</string>
|
||||||
|
<string name="account_totp_confirm">Confirm & enable</string>
|
||||||
|
<string name="account_totp_cancel">Cancel</string>
|
||||||
|
<string name="account_totp_disable">Disable two-factor</string>
|
||||||
|
<string name="account_totp_enabled">Two-factor is now enabled.</string>
|
||||||
|
<string name="account_totp_disabled">Two-factor has been disabled.</string>
|
||||||
|
<string name="account_totp_code_error">That code isn\'t valid. Try the current code.</string>
|
||||||
|
<string name="account_totp_setup_error">Couldn\'t start two-factor setup.</string>
|
||||||
|
|
||||||
|
<!-- Linked identities -->
|
||||||
|
<string name="account_identities_title">Linked accounts</string>
|
||||||
|
<string name="account_identities_empty">No linked sign-in providers.</string>
|
||||||
|
<string name="account_identity_unlink">Unlink</string>
|
||||||
|
<string name="account_identity_unlinked">Account unlinked.</string>
|
||||||
|
<string name="account_identity_error">Couldn\'t unlink that account.</string>
|
||||||
|
|
||||||
|
<!-- ── Player: game-account linking (§6.3) ─────────────────────────── -->
|
||||||
|
<string name="player_link_title">Link your game account</string>
|
||||||
|
<string name="player_link_hint">In game, type [link to get a one-time code, then enter it here to see your characters, vendors and houses.</string>
|
||||||
|
<string name="player_link_code">Link code</string>
|
||||||
|
<string name="player_link_action">Link account</string>
|
||||||
|
<string name="player_link_ok">Account linked.</string>
|
||||||
|
<string name="player_link_bad_code">That code is unknown or has expired. Run [link in game for a new one.</string>
|
||||||
|
<string name="player_link_error">Couldn\'t link that code. Please try again.</string>
|
||||||
|
|
||||||
|
<string name="player_create_title">Create a game account</string>
|
||||||
|
<string name="player_create_account">Account name</string>
|
||||||
|
<string name="player_create_password">Password</string>
|
||||||
|
<string name="player_create_action">Create account</string>
|
||||||
|
<string name="player_create_ok">Game account created and linked.</string>
|
||||||
|
<string name="player_create_taken">That account name is already taken.</string>
|
||||||
|
<string name="player_create_capped">The account limit for your network has been reached.</string>
|
||||||
|
<string name="player_create_unavailable">Game-account signup isn\'t available right now.</string>
|
||||||
|
<string name="player_create_rejected">That account name or password wasn\'t accepted.</string>
|
||||||
|
<string name="player_create_error">Couldn\'t create that account. Please try again.</string>
|
||||||
|
|
||||||
|
<!-- ── Player: characters (§6.3) ───────────────────────────────────── -->
|
||||||
|
<string name="player_characters_empty">Link a game account above to see your characters.</string>
|
||||||
|
<string name="player_roster_loading">Loading…</string>
|
||||||
|
<string name="player_roster_empty">No characters on this account.</string>
|
||||||
|
<string name="player_char_unknown">Unknown</string>
|
||||||
|
<string name="player_char_online">Online</string>
|
||||||
|
<string name="player_char_offline">Offline</string>
|
||||||
|
|
||||||
|
<!-- Character sheet -->
|
||||||
|
<string name="player_char_governor">Governor of %1$s</string>
|
||||||
|
<string name="player_char_guildmaster">Guildmaster, %1$s</string>
|
||||||
|
<string name="player_char_attributes">Attributes</string>
|
||||||
|
<string name="player_char_str">STR</string>
|
||||||
|
<string name="player_char_dex">DEX</string>
|
||||||
|
<string name="player_char_int">INT</string>
|
||||||
|
<string name="player_char_hits">Hits</string>
|
||||||
|
<string name="player_char_mana">Mana</string>
|
||||||
|
<string name="player_char_stam">Stamina</string>
|
||||||
|
<string name="player_char_resistances">Resistances</string>
|
||||||
|
<string name="player_char_phys">Physical</string>
|
||||||
|
<string name="player_char_fire">Fire</string>
|
||||||
|
<string name="player_char_cold">Cold</string>
|
||||||
|
<string name="player_char_pois">Poison</string>
|
||||||
|
<string name="player_char_energy">Energy</string>
|
||||||
|
<string name="player_char_skills">Skills</string>
|
||||||
|
<string name="player_char_equipment">Equipment</string>
|
||||||
|
<string name="player_char_item">Item</string>
|
||||||
|
<string name="player_char_item_id">id %1$d</string>
|
||||||
|
<string name="player_char_item_hue">hue %1$d</string>
|
||||||
|
|
||||||
|
<!-- ── Player: vendors & sales (§6.3) ──────────────────────────── -->
|
||||||
|
<string name="player_sales_title">Recent sales</string>
|
||||||
|
<string name="player_sales_empty">No recent sales.</string>
|
||||||
|
<string name="player_gold_amount">%1$s gp</string>
|
||||||
|
<string name="player_vendors_no_accounts">Link a game account to see your vendors.</string>
|
||||||
|
<string name="player_vendors_empty">No vendors on this account.</string>
|
||||||
|
<string name="player_vendor_fallback">Player vendor</string>
|
||||||
|
<string name="player_vendor_hold">Holding %1$s gp</string>
|
||||||
|
<string name="player_vendor_no_listings">No items listed.</string>
|
||||||
|
<string name="player_listing_item">Item %1$d ×%2$d</string>
|
||||||
|
|
||||||
|
<!-- ── Player: houses (§6.3) ───────────────────────────────────────── -->
|
||||||
|
<string name="player_houses_empty">You don\'t have any houses on your linked accounts.</string>
|
||||||
|
<string name="player_house_fallback">A house</string>
|
||||||
|
<string name="player_house_stage">Status: %1$s</string>
|
||||||
<string name="role_player">Player</string>
|
<string name="role_player">Player</string>
|
||||||
<string name="role_moderator">Moderator</string>
|
<string name="role_moderator">Moderator</string>
|
||||||
<string name="role_editor">Editor</string>
|
<string name="role_editor">Editor</string>
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.data.api.dto
|
||||||
|
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decoding tests for the self-service ("me") account DTOs (PLAN.md §6.3, §6.4).
|
||||||
|
* Shapes come from the website's `account.controller` handlers surfaced under
|
||||||
|
* `/auth/me/account*`; unknown keys are ignored (additive fields, §8).
|
||||||
|
*/
|
||||||
|
class AccountDtoTest {
|
||||||
|
|
||||||
|
private val json = Json {
|
||||||
|
ignoreUnknownKeys = true
|
||||||
|
explicitNulls = false
|
||||||
|
coerceInputValues = true
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun accountDecodesSecurityFlags() {
|
||||||
|
val dto = json.decodeFromString<PlayerAccountDto>(
|
||||||
|
"""{"id":42,"username":"newplayer","role":"player","email":"p@example.com",
|
||||||
|
"status":"active","totp_enabled":false,"has_password":true}""",
|
||||||
|
)
|
||||||
|
assertEquals(42L, dto.id)
|
||||||
|
assertEquals("newplayer", dto.username)
|
||||||
|
assertTrue(dto.has_password)
|
||||||
|
assertFalse(dto.totp_enabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun ssoAccountHasNoPassword() {
|
||||||
|
// An SSO-provisioned account omits email and reports has_password=false.
|
||||||
|
val dto = json.decodeFromString<PlayerAccountDto>(
|
||||||
|
"""{"id":7,"username":"ssouser","role":"player","email":null,
|
||||||
|
"status":"active","totp_enabled":true,"has_password":false}""",
|
||||||
|
)
|
||||||
|
assertFalse(dto.has_password)
|
||||||
|
assertTrue(dto.totp_enabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun totpSetupDecodesQr() {
|
||||||
|
val dto = json.decodeFromString<TotpSetupDto>(
|
||||||
|
"""{"otpauthUrl":"otpauth://totp/Runic:admin?secret=ABC","qr":"data:image/png;base64,iVBORw0KGgo="}""",
|
||||||
|
)
|
||||||
|
assertTrue(dto.qr!!.startsWith("data:image/png;base64,"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun totpStateDecodes() {
|
||||||
|
assertTrue(json.decodeFromString<TotpStateDto>("""{"totp_enabled":true}""").totp_enabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun linkedIdentityDecodes() {
|
||||||
|
val dto = json.decodeFromString<LinkedIdentityDto>(
|
||||||
|
"""{"provider":"discord","email":"u@example.com","linked_at":"2026-07-19T22:00:00Z"}""",
|
||||||
|
)
|
||||||
|
assertEquals("discord", dto.provider)
|
||||||
|
assertEquals("u@example.com", dto.email)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.data.api.dto
|
||||||
|
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNotNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decoding tests for a player's own game-data DTOs (PLAN.md §6.3). Shapes come from
|
||||||
|
* the website's `player/shard.controller.js` + `docs/link/INTEGRATION.md` §5. The
|
||||||
|
* roster / char / vendor reads are permissive objects, so the parser must ignore
|
||||||
|
* unknown keys (additive fields, §8) and tolerate hex-string serials.
|
||||||
|
*/
|
||||||
|
class PlayerShardDtoTest {
|
||||||
|
|
||||||
|
private val json = Json {
|
||||||
|
ignoreUnknownKeys = true
|
||||||
|
explicitNulls = false
|
||||||
|
coerceInputValues = true
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun rosterDecodesCharsIncludingOffline() {
|
||||||
|
val dto = json.decodeFromString<RosterDto>(
|
||||||
|
"""{"kind":"account.roster","acct":"whitlocktech",
|
||||||
|
"chars":[{"slot":0,"serial":"0x24C","name":"Darrow","body":400,"online":false}]}""",
|
||||||
|
)
|
||||||
|
assertEquals("whitlocktech", dto.acct)
|
||||||
|
assertEquals(1, dto.chars.size)
|
||||||
|
assertEquals("0x24C", dto.chars[0].serial)
|
||||||
|
assertEquals("Darrow", dto.chars[0].name)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun charProfileDecodesStatsSkillsEquipmentAndTitles() {
|
||||||
|
val dto = json.decodeFromString<CharProfileDto>(
|
||||||
|
"""{"kind":"char.profile","serial":"0x24C","name":"Darrow","online":true,"acct":"wt",
|
||||||
|
"stats":{"str":100,"dex":90,"int":75,"hits":100,"hitsMax":110,
|
||||||
|
"resist":{"phys":70,"fire":68,"cold":60,"pois":55,"energy":50}},
|
||||||
|
"skills":[{"n":"Swordsmanship","base":100.0,"value":120.0,"cap":120.0},
|
||||||
|
{"n":"Anatomy","base":0.0,"value":0.0,"cap":100.0}],
|
||||||
|
"equipment":[{"serial":"0x40","layer":"OneHanded","itemId":5040,"hue":1153,
|
||||||
|
"mods":{"DamageIncrease":50,"HitLightning":30}}],
|
||||||
|
"titles":{"selected":0,"reward":["Knight"],"fameKarma":"The Great"},
|
||||||
|
"guild":{"name":"Knights","abbr":"KNT"},"governorOf":["Britain"]}""",
|
||||||
|
)
|
||||||
|
assertEquals("Darrow", dto.name)
|
||||||
|
assertTrue(dto.online)
|
||||||
|
assertEquals(100, dto.stats?.str)
|
||||||
|
assertEquals(70, dto.stats?.resist?.phys)
|
||||||
|
assertEquals(2, dto.skills.size)
|
||||||
|
assertEquals(120.0, dto.skills[0].value!!, 0.0)
|
||||||
|
val mods = dto.equipment[0].mods
|
||||||
|
assertNotNull(mods)
|
||||||
|
assertEquals("50", mods!!["DamageIncrease"]!!.jsonPrimitive.content)
|
||||||
|
assertEquals("KNT", dto.guild?.abbr)
|
||||||
|
assertEquals(listOf("Britain"), dto.governorOf)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun vendorSnapshotDecodesListings() {
|
||||||
|
val dto = json.decodeFromString<VendorSnapshotDto>(
|
||||||
|
"""{"kind":"vendor.snapshot","acct":"seed_000",
|
||||||
|
"vendors":[{"serial":"0x2C0","shopName":"Seed Shop","holdGold":24186,
|
||||||
|
"ownerSerial":"0x1F5","map":"Felucca","x":1402,"y":1604,
|
||||||
|
"listings":[{"serial":"0x4001440F","itemId":3937,"amount":1,"price":69819,"forSale":true}]}]}""",
|
||||||
|
)
|
||||||
|
assertEquals(1, dto.vendors.size)
|
||||||
|
assertEquals(24186L, dto.vendors[0].holdGold)
|
||||||
|
assertEquals(69819L, dto.vendors[0].listings[0].price)
|
||||||
|
assertTrue(dto.vendors[0].listings[0].forSale)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun vendorSaleDecodes() {
|
||||||
|
val dto = json.decodeFromString<VendorSaleDto>(
|
||||||
|
"""{"t":1783720195626,"itemType":"Longsword","amount":1,"price":100,
|
||||||
|
"commission":5,"ownerAcct":"whitlocktech"}""",
|
||||||
|
)
|
||||||
|
assertEquals(1783720195626L, dto.t)
|
||||||
|
assertEquals("Longsword", dto.itemType)
|
||||||
|
assertEquals(100L, dto.price)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun playerHouseDecodesHexSerialAndIdoc() {
|
||||||
|
val dto = json.decodeFromString<PlayerHouseDto>(
|
||||||
|
"""{"serial":"0x4004705F","stage":"IDOC","map":"Trammel","x":1,"y":2,"z":3,
|
||||||
|
"region":"Britain","name":"An Unnamed House","isIdoc":true,
|
||||||
|
"ownerAcct":"wt","updatedAt":"2026-07-19T22:00:00Z"}""",
|
||||||
|
)
|
||||||
|
assertEquals("0x4004705F", dto.serial)
|
||||||
|
assertEquals("IDOC", dto.stage)
|
||||||
|
assertTrue(dto.isIdoc)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun linkResultDecodes() {
|
||||||
|
val dto = json.decodeFromString<ShardLinkResultDto>("""{"linked":true,"account":"whitlocktech"}""")
|
||||||
|
assertTrue(dto.linked)
|
||||||
|
assertEquals("whitlocktech", dto.account)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -40,7 +40,7 @@ class MenuAccessTest {
|
|||||||
@Test fun staffSeeAccountButNoPlayerOnlyGroups() {
|
@Test fun staffSeeAccountButNoPlayerOnlyGroups() {
|
||||||
val visible = routes(signedIn(Role.EDITOR))
|
val visible = routes(signedIn(Role.EDITOR))
|
||||||
assertTrue(visible.contains(Routes.ACCOUNT))
|
assertTrue(visible.contains(Routes.ACCOUNT))
|
||||||
// No PLAYER-access entry leaks to staff (none exist yet in M3; guard the rule).
|
// No PLAYER-access entry (the M4 game-data groups) leaks to staff.
|
||||||
val playerOnly = APP_MENU.filter { it.access == MenuAccess.PLAYER }.map { it.route }
|
val playerOnly = APP_MENU.filter { it.access == MenuAccess.PLAYER }.map { it.route }
|
||||||
assertTrue(playerOnly.none { visible.contains(it) })
|
assertTrue(playerOnly.none { visible.contains(it) })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.ui.player
|
||||||
|
|
||||||
|
import com.runicgateway.app.data.api.dto.TitlesDto
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unit tests for the character-sheet display helpers (PLAN.md §6.3), mirroring the
|
||||||
|
* website's `CharacterSheet.jsx#displayTitles`: fame/karma + skill + a *literal*
|
||||||
|
* selected reward title, dropping bare cliloc numbers the app can't resolve.
|
||||||
|
*/
|
||||||
|
class CharacterSheetHelpersTest {
|
||||||
|
|
||||||
|
@Test fun formatSkillDropsTrailingZero() {
|
||||||
|
assertEquals("100", formatSkill(100.0))
|
||||||
|
assertEquals("85.3", formatSkill(85.3))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun displayTitlesEmptyForNull() {
|
||||||
|
assertEquals(emptyList<String>(), displayTitles(null))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun displayTitlesUsesFameKarmaSkillAndSelectedLiteralReward() {
|
||||||
|
val titles = TitlesDto(
|
||||||
|
selected = 1,
|
||||||
|
reward = listOf("1049565", "Slayer of Dragons"),
|
||||||
|
fameKarma = "The Great",
|
||||||
|
skill = "Grandmaster Swordsman",
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
listOf("The Great", "Grandmaster Swordsman", "Slayer of Dragons"),
|
||||||
|
displayTitles(titles),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun displayTitlesSkipsNumericSelectedReward() {
|
||||||
|
// Selected points at a bare cliloc number → skipped (no cliloc table).
|
||||||
|
val titles = TitlesDto(selected = 0, reward = listOf("1049565"))
|
||||||
|
assertEquals(emptyList<String>(), displayTitles(titles))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun displayTitlesFallsBackToFirstLiteralWhenNoneSelected() {
|
||||||
|
val titles = TitlesDto(selected = -1, reward = listOf("1049565", "Champion"))
|
||||||
|
assertEquals(listOf("Champion"), displayTitles(titles))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun displayTitlesDeduplicates() {
|
||||||
|
val titles = TitlesDto(selected = 0, reward = listOf("The Great"), fameKarma = "The Great")
|
||||||
|
assertEquals(listOf("The Great"), displayTitles(titles))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user