diff --git a/app/src/main/java/com/runicgateway/app/data/api/MeApi.kt b/app/src/main/java/com/runicgateway/app/data/api/MeApi.kt new file mode 100644 index 0000000..73c907d --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/data/api/MeApi.kt @@ -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 + + // 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 +} diff --git a/app/src/main/java/com/runicgateway/app/data/api/PlayerShardApi.kt b/app/src/main/java/com/runicgateway/app/data/api/PlayerShardApi.kt new file mode 100644 index 0000000..400434d --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/data/api/PlayerShardApi.kt @@ -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 + + /** 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 + + /** The caller's own houses (home/decay status). */ + @GET("api/v1/player/shard/houses") + suspend fun houses(): List +} diff --git a/app/src/main/java/com/runicgateway/app/data/api/dto/AccountDto.kt b/app/src/main/java/com/runicgateway/app/data/api/dto/AccountDto.kt new file mode 100644 index 0000000..094525d --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/data/api/dto/AccountDto.kt @@ -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, +) diff --git a/app/src/main/java/com/runicgateway/app/data/api/dto/PlayerShardDto.kt b/app/src/main/java/com/runicgateway/app/data/api/dto/PlayerShardDto.kt new file mode 100644 index 0000000..16d6910 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/data/api/dto/PlayerShardDto.kt @@ -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 = 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 = emptyList(), + val equipment: List = emptyList(), + val titles: TitlesDto? = null, + val guild: GuildRefDto? = null, + val governorOf: List = 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 = 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 = 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 = 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, +) diff --git a/app/src/main/java/com/runicgateway/app/data/repository/AccountRepository.kt b/app/src/main/java/com/runicgateway/app/data/repository/AccountRepository.kt new file mode 100644 index 0000000..fc4f9fc --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/data/repository/AccountRepository.kt @@ -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 = safeApiCall { api.getAccount() } + + suspend fun changeUsername(username: String): ApiResult = + 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 = + safeApiCall { api.changePassword(ChangePasswordRequest(newPassword, currentPassword)) } + + suspend fun totpSetup(): ApiResult = safeApiCall { api.totpSetup() } + + suspend fun totpEnable(code: String): ApiResult = + safeApiCall { api.totpEnable(TotpCodeRequest(code)) } + + suspend fun totpDisable(code: String): ApiResult = + safeApiCall { api.totpDisable(TotpCodeRequest(code)) } + + suspend fun identities(): ApiResult> = safeApiCall { api.identities() } + + suspend fun unlinkIdentity(provider: String): ApiResult = + safeApiCall { api.unlinkIdentity(provider) } +} diff --git a/app/src/main/java/com/runicgateway/app/data/repository/PlayerShardRepository.kt b/app/src/main/java/com/runicgateway/app/data/repository/PlayerShardRepository.kt new file mode 100644 index 0000000..e4bc547 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/data/repository/PlayerShardRepository.kt @@ -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 = + safeApiCall { api.link(ShardLinkRequest(code)) } + + suspend fun createAccount(account: String, password: String): ApiResult = + safeApiCall { api.createAccount(CreateGameAccountRequest(account, password)) } + + suspend fun accounts(): ApiResult> = safeApiCall { api.accounts() } + + suspend fun roster(account: String): ApiResult = safeApiCall { api.roster(account) } + + suspend fun char(serial: String): ApiResult = safeApiCall { api.char(serial) } + + suspend fun vendors(account: String): ApiResult = + safeApiCall { api.vendors(account) } + + suspend fun sales(): ApiResult> = safeApiCall { api.sales() } + + suspend fun houses(): ApiResult> = safeApiCall { api.houses() } +} diff --git a/app/src/main/java/com/runicgateway/app/di/NetworkModule.kt b/app/src/main/java/com/runicgateway/app/di/NetworkModule.kt index a8e70ef..e76a735 100644 --- a/app/src/main/java/com/runicgateway/app/di/NetworkModule.kt +++ b/app/src/main/java/com/runicgateway/app/di/NetworkModule.kt @@ -13,6 +13,8 @@ import com.runicgateway.app.core.net.TokenAuthenticator import com.runicgateway.app.core.net.UserAgentInterceptor import com.runicgateway.app.data.api.AuthApi import com.runicgateway.app.data.api.AuthRefreshApi +import com.runicgateway.app.data.api.MeApi +import com.runicgateway.app.data.api.PlayerShardApi import com.runicgateway.app.data.api.PublicApi import dagger.Module import dagger.Provides @@ -93,6 +95,17 @@ object NetworkModule { @Singleton 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, * no auth interceptor and no authenticator — so a refresh can never recurse diff --git a/app/src/main/java/com/runicgateway/app/ui/RunicApp.kt b/app/src/main/java/com/runicgateway/app/ui/RunicApp.kt index 9d99f39..be27d97 100644 --- a/app/src/main/java/com/runicgateway/app/ui/RunicApp.kt +++ b/app/src/main/java/com/runicgateway/app/ui/RunicApp.kt @@ -54,6 +54,10 @@ import com.runicgateway.app.ui.navigation.visibleEntries import com.runicgateway.app.ui.news.NewsScreen import com.runicgateway.app.ui.news.PostScreen 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.shard.ChampsScreen 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. */ private val TOP_LEVEL_ROUTES = setOf( 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) } } } diff --git a/app/src/main/java/com/runicgateway/app/ui/auth/AccountScreen.kt b/app/src/main/java/com/runicgateway/app/ui/auth/AccountScreen.kt index b15c496..ca5f859 100644 --- a/app/src/main/java/com/runicgateway/app/ui/auth/AccountScreen.kt +++ b/app/src/main/java/com/runicgateway/app/ui/auth/AccountScreen.kt @@ -3,30 +3,59 @@ */ 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.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.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.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton +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.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment 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.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation 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.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 - * pass it shows the identity + role and the sign-out controls; full self-service - * (change username/password, TOTP, linked identities via the `/auth/me` surface) - * lands in M4 (§6.3). + * The signed-in account surface (PLAN.md §5, §6.3): identity + role, self-service + * over `/auth/me/account*` (change username/password, TOTP, linked identities), + * and the sign-out controls. Credential-provisioning flows (register / reset / SSO + * link) stay website hand-offs (§4.2) and are not rebuilt here. */ @Composable fun AccountScreen( @@ -35,49 +64,293 @@ fun AccountScreen( onSignOut: () -> Unit, onSignOutEverywhere: () -> Unit, modifier: Modifier = Modifier, + viewModel: AccountViewModel = hiltViewModel(), ) { + val state by viewModel.state.collectAsStateWithLifecycle() + Column( modifier = modifier .fillMaxSize() - .padding(24.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Top, + .verticalScroll(rememberScrollState()) + .padding(16.dp), ) { - Card(modifier = Modifier.fillMaxWidth()) { - Column(Modifier.padding(20.dp)) { - Text( - text = username, - style = MaterialTheme.typography.titleLarge, - ) - Text( - text = roleLabel, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(top = 4.dp), - ) - } + 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) } - OutlinedButton( - onClick = onSignOut, - modifier = Modifier - .fillMaxWidth() - .padding(top = 24.dp), - ) { + 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), + 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)) { + Text(text = username, style = MaterialTheme.typography.titleLarge) + Text( + text = roleLabel, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 4.dp), + ) + } + } +} + +@Composable +private fun AccountSections( + account: PlayerAccountDto, + state: AccountViewModel.State, + viewModel: AccountViewModel, +) { + UsernameSection(account, state, viewModel) + PasswordSection(account, state, viewModel) + TwoFactorSection(account, state, viewModel) + IdentitiesSection(state, viewModel) +} + +@Composable +private fun SectionCard(@StringRes titleRes: Int, content: @Composable () -> Unit) { + Card(Modifier.fillMaxWidth().padding(top = 12.dp)) { + Column(Modifier.padding(16.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(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). */ @StringRes fun roleLabelRes(role: Role): Int = when (role) { diff --git a/app/src/main/java/com/runicgateway/app/ui/auth/AccountViewModel.kt b/app/src/main/java/com/runicgateway/app/ui/auth/AccountViewModel.kt new file mode 100644 index 0000000..3efa8db --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/auth/AccountViewModel.kt @@ -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 = UiState.Loading, + val identities: List = 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.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 + } +} diff --git a/app/src/main/java/com/runicgateway/app/ui/navigation/Menu.kt b/app/src/main/java/com/runicgateway/app/ui/navigation/Menu.kt index cec691a..0f97553 100644 --- a/app/src/main/java/com/runicgateway/app/ui/navigation/Menu.kt +++ b/app/src/main/java/com/runicgateway/app/ui/navigation/Menu.kt @@ -21,7 +21,7 @@ enum class MenuAccess { /** Visible to any signed-in account (§5, "My Account"). */ 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, } @@ -33,7 +33,8 @@ data class MenuEntry( /** * 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 = listOf( MenuEntry(Routes.HOME, R.string.menu_home), @@ -43,6 +44,9 @@ val APP_MENU: List = listOf( MenuEntry(Routes.page("about"), R.string.menu_about), MenuEntry(Routes.CONTACT, R.string.menu_contact), MenuEntry(Routes.ACCOUNT, R.string.menu_account, MenuAccess.SIGNED_IN), + MenuEntry(Routes.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), ) /** diff --git a/app/src/main/java/com/runicgateway/app/ui/navigation/Routes.kt b/app/src/main/java/com/runicgateway/app/ui/navigation/Routes.kt index de3eb2b..d3e39db 100644 --- a/app/src/main/java/com/runicgateway/app/ui/navigation/Routes.kt +++ b/app/src/main/java/com/runicgateway/app/ui/navigation/Routes.kt @@ -27,6 +27,14 @@ object Routes { const val SHARD_GOVERNORS = "shard/governors" 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). */ const val PAGE = "page/{slug}" @@ -40,9 +48,13 @@ object Routes { const val SLUG = "slug" const val CATEGORY = "category" const val ID_OR_SLUG = "idOrSlug" + const val SERIAL = "serial" } fun page(slug: String) = "page/$slug" fun post(categoryUrlSlug: String, idOrSlug: String) = "news/$categoryUrlSlug/$idOrSlug" 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" } diff --git a/app/src/main/java/com/runicgateway/app/ui/player/CharacterSheetScreen.kt b/app/src/main/java/com/runicgateway/app/ui/player/CharacterSheetScreen.kt new file mode 100644 index 0000000..35fa13d --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/player/CharacterSheetScreen.kt @@ -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) { + 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) { + 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 { + if (titles == null) return emptyList() + val out = mutableListOf() + 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() } diff --git a/app/src/main/java/com/runicgateway/app/ui/player/CharacterViewModel.kt b/app/src/main/java/com/runicgateway/app/ui/player/CharacterViewModel.kt new file mode 100644 index 0000000..59eec3d --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/player/CharacterViewModel.kt @@ -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(Routes.Args.SERIAL).orEmpty() + + private val _state = MutableStateFlow>(UiState.Loading) + val state: StateFlow> = _state.asStateFlow() + + init { + load() + } + + fun load() { + _state.value = UiState.Loading + viewModelScope.launch { + _state.value = repository.char(serial).toUiState() + } + } +} diff --git a/app/src/main/java/com/runicgateway/app/ui/player/CharactersScreen.kt b/app/src/main/java/com/runicgateway/app/ui/player/CharactersScreen.kt new file mode 100644 index 0000000..875dd9f --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/player/CharactersScreen.kt @@ -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>, + 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), + ) +} diff --git a/app/src/main/java/com/runicgateway/app/ui/player/CharactersViewModel.kt b/app/src/main/java/com/runicgateway/app/ui/player/CharactersViewModel.kt new file mode 100644 index 0000000..04f7089 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/player/CharactersViewModel.kt @@ -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> = UiState.Loading, + val rosters: Map>> = 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.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() }.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 + } +} diff --git a/app/src/main/java/com/runicgateway/app/ui/player/MyHousesScreen.kt b/app/src/main/java/com/runicgateway/app/ui/player/MyHousesScreen.kt new file mode 100644 index 0000000..89d0dc1 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/player/MyHousesScreen.kt @@ -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), + ) + } + } + } +} diff --git a/app/src/main/java/com/runicgateway/app/ui/player/MyHousesViewModel.kt b/app/src/main/java/com/runicgateway/app/ui/player/MyHousesViewModel.kt new file mode 100644 index 0000000..a2234e1 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/player/MyHousesViewModel.kt @@ -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.Loading) + val state: StateFlow>> = _state.asStateFlow() + + init { + load() + } + + fun load() { + _state.value = UiState.Loading + viewModelScope.launch { + _state.value = repository.houses().toUiState() + } + } +} diff --git a/app/src/main/java/com/runicgateway/app/ui/player/VendorsScreen.kt b/app/src/main/java/com/runicgateway/app/ui/player/VendorsScreen.kt new file mode 100644 index 0000000..c06c2a9 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/player/VendorsScreen.kt @@ -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>) { + 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>, + 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, + ) + } +} diff --git a/app/src/main/java/com/runicgateway/app/ui/player/VendorsViewModel.kt b/app/src/main/java/com/runicgateway/app/ui/player/VendorsViewModel.kt new file mode 100644 index 0000000..c598381 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/player/VendorsViewModel.kt @@ -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> = UiState.Loading, + val vendors: Map>> = emptyMap(), + val sales: UiState> = UiState.Loading, + ) + + private val _state = MutableStateFlow(State()) + val state: StateFlow = _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() }.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)) } + } + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f53d9d8..61d5834 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -42,6 +42,9 @@ About Contact My account + My characters + My vendors + My houses Sign in Sign out Change server @@ -67,6 +70,112 @@ My account Sign out Sign out on all devices + + + Username + Change username + Username updated. + That username is already taken. + Couldn\'t change your username. + + + Password + Set a password + Your account was created through a linked provider and has no password yet. Set one to also sign in with a username and password. + Current password + New password + Change password + Set password + Password changed. + Couldn\'t change your password. Check your current password and try again. + + + Two-factor authentication + Enabled + Not enabled + Scan this code with your authenticator app, then enter the current code. + Two-factor QR code + Set up two-factor + Confirm & enable + Cancel + Disable two-factor + Two-factor is now enabled. + Two-factor has been disabled. + That code isn\'t valid. Try the current code. + Couldn\'t start two-factor setup. + + + Linked accounts + No linked sign-in providers. + Unlink + Account unlinked. + Couldn\'t unlink that account. + + + Link your game account + In game, type [link to get a one-time code, then enter it here to see your characters, vendors and houses. + Link code + Link account + Account linked. + That code is unknown or has expired. Run [link in game for a new one. + Couldn\'t link that code. Please try again. + + Create a game account + Account name + Password + Create account + Game account created and linked. + That account name is already taken. + The account limit for your network has been reached. + Game-account signup isn\'t available right now. + That account name or password wasn\'t accepted. + Couldn\'t create that account. Please try again. + + + Link a game account above to see your characters. + Loading… + No characters on this account. + Unknown + Online + Offline + + + Governor of %1$s + Guildmaster, %1$s + Attributes + STR + DEX + INT + Hits + Mana + Stamina + Resistances + Physical + Fire + Cold + Poison + Energy + Skills + Equipment + Item + id %1$d + hue %1$d + + + Recent sales + No recent sales. + %1$s gp + Link a game account to see your vendors. + No vendors on this account. + Player vendor + Holding %1$s gp + No items listed. + Item %1$d ×%2$d + + + You don\'t have any houses on your linked accounts. + A house + Status: %1$s Player Moderator Editor diff --git a/app/src/test/java/com/runicgateway/app/data/api/dto/AccountDtoTest.kt b/app/src/test/java/com/runicgateway/app/data/api/dto/AccountDtoTest.kt new file mode 100644 index 0000000..ccbadf9 --- /dev/null +++ b/app/src/test/java/com/runicgateway/app/data/api/dto/AccountDtoTest.kt @@ -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( + """{"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( + """{"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( + """{"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("""{"totp_enabled":true}""").totp_enabled) + } + + @Test fun linkedIdentityDecodes() { + val dto = json.decodeFromString( + """{"provider":"discord","email":"u@example.com","linked_at":"2026-07-19T22:00:00Z"}""", + ) + assertEquals("discord", dto.provider) + assertEquals("u@example.com", dto.email) + } +} diff --git a/app/src/test/java/com/runicgateway/app/data/api/dto/PlayerShardDtoTest.kt b/app/src/test/java/com/runicgateway/app/data/api/dto/PlayerShardDtoTest.kt new file mode 100644 index 0000000..d92b832 --- /dev/null +++ b/app/src/test/java/com/runicgateway/app/data/api/dto/PlayerShardDtoTest.kt @@ -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( + """{"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( + """{"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( + """{"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( + """{"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( + """{"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("""{"linked":true,"account":"whitlocktech"}""") + assertTrue(dto.linked) + assertEquals("whitlocktech", dto.account) + } +} diff --git a/app/src/test/java/com/runicgateway/app/ui/navigation/MenuAccessTest.kt b/app/src/test/java/com/runicgateway/app/ui/navigation/MenuAccessTest.kt index f7f08ba..61ab317 100644 --- a/app/src/test/java/com/runicgateway/app/ui/navigation/MenuAccessTest.kt +++ b/app/src/test/java/com/runicgateway/app/ui/navigation/MenuAccessTest.kt @@ -40,7 +40,7 @@ class MenuAccessTest { @Test fun staffSeeAccountButNoPlayerOnlyGroups() { val visible = routes(signedIn(Role.EDITOR)) 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 } assertTrue(playerOnly.none { visible.contains(it) }) } diff --git a/app/src/test/java/com/runicgateway/app/ui/player/CharacterSheetHelpersTest.kt b/app/src/test/java/com/runicgateway/app/ui/player/CharacterSheetHelpersTest.kt new file mode 100644 index 0000000..ade21e5 --- /dev/null +++ b/app/src/test/java/com/runicgateway/app/ui/player/CharacterSheetHelpersTest.kt @@ -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(), 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(), 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)) + } +}