Compare commits
25 Commits
7c13476f29
...
v0.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
| d0fb6bbdfc | |||
| 9268579c5f | |||
| 0c395b2527 | |||
| 2d84a930e5 | |||
| b6a0fa1f5d | |||
| bc09593550 | |||
| 9514172b71 | |||
| de79bf547c | |||
| 0df862a6af | |||
| e497e6c8a7 | |||
| 52e06da8fc | |||
| 2e1bea4220 | |||
| d4f7fcb241 | |||
| ca704caaaf | |||
| 1c56eda64b | |||
| 199df6cd64 | |||
| c8f4e76370 | |||
| 81f10fbca4 | |||
| 01481ef2d5 | |||
| 9019ded556 | |||
| 91cd4585c0 | |||
| 9ea66c500e | |||
| 6f243b0af9 | |||
| e714980600 | |||
| 3647d25be2 |
@@ -8,10 +8,13 @@
|
|||||||
# * Enable Status Check
|
# * Enable Status Check
|
||||||
# * Status check patterns: PR Checks / *
|
# * Status check patterns: PR Checks / *
|
||||||
#
|
#
|
||||||
# Runner: the org's self-hosted `ubuntu-latest`, on a bare `ubuntu:latest`
|
# Runner: the org's self-hosted `ubuntu-latest`. The container lacks
|
||||||
# container that lacks git/curl/unzip (needed by checkout + sdkmanager) -- so the
|
# git/curl/unzip (needed by checkout + sdkmanager), so the first step installs
|
||||||
# first step installs them. (Faster later: switch to a prebuilt Android-SDK
|
# them. It also installs JDK 17 from the Ubuntu archive rather than using
|
||||||
# container image so nothing installs per-run.)
|
# actions/setup-java, because this runner can't resolve api.adoptium.net (that
|
||||||
|
# download fails with EAI_AGAIN) while the Ubuntu mirrors are reachable.
|
||||||
|
# (Faster later: switch to a prebuilt Android-SDK+JDK container image so nothing
|
||||||
|
# installs per-run.)
|
||||||
|
|
||||||
name: PR Checks
|
name: PR Checks
|
||||||
|
|
||||||
@@ -27,23 +30,29 @@ jobs:
|
|||||||
android-build:
|
android-build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
# Bare ubuntu:latest is missing the tools checkout + the SDK installer need.
|
# Install the tools checkout + the SDK installer need, plus JDK 17 (see the
|
||||||
- name: Install base tools
|
# header note on why we avoid actions/setup-java on this runner).
|
||||||
|
- name: Install base tools + JDK 17
|
||||||
run: |
|
run: |
|
||||||
apt-get update
|
apt-get update
|
||||||
apt-get install -y git curl unzip
|
apt-get install -y git curl unzip openjdk-17-jdk-headless
|
||||||
|
echo "JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Set up JDK 17
|
|
||||||
uses: actions/setup-java@v4
|
|
||||||
with:
|
|
||||||
distribution: temurin
|
|
||||||
java-version: "17"
|
|
||||||
|
|
||||||
- name: Set up Android SDK
|
- name: Set up Android SDK
|
||||||
uses: android-actions/setup-android@v3
|
uses: android-actions/setup-android@v3
|
||||||
|
|
||||||
|
# Install exactly what the build targets so it never depends on AGP's
|
||||||
|
# build-time auto-download. `yes |` accepts any license prompts; `set
|
||||||
|
# +o pipefail` so `yes` dying with SIGPIPE (exit 141) once sdkmanager
|
||||||
|
# closes the pipe doesn't fail the step -- sdkmanager's own exit, last in
|
||||||
|
# the pipeline, still gates success.
|
||||||
|
- name: Install Android SDK packages
|
||||||
|
run: |
|
||||||
|
set +o pipefail
|
||||||
|
yes | sdkmanager "platform-tools" "platforms;android-35" "build-tools;35.0.0"
|
||||||
|
|
||||||
- name: Cache Gradle
|
- name: Cache Gradle
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
@@ -54,5 +63,13 @@ jobs:
|
|||||||
restore-keys: |
|
restore-keys: |
|
||||||
gradle-${{ runner.os }}-
|
gradle-${{ runner.os }}-
|
||||||
|
|
||||||
|
# chmod defensively: this runner's checkout doesn't preserve the git
|
||||||
|
# executable bit, so `./gradlew` alone fails with "Permission denied".
|
||||||
|
# Debug-variant-only gate: unit tests, lint, and the debug APK. Scoping to
|
||||||
|
# the debug variant (vs. the aggregate `test`/`lint`) avoids compiling and
|
||||||
|
# linting the release variant in parallel, which halves peak memory on the
|
||||||
|
# runner and keeps lint's report phase from GC-thrashing (see gradle.properties).
|
||||||
- name: Lint, test, assemble debug
|
- name: Lint, test, assemble debug
|
||||||
run: ./gradlew --no-daemon lint test assembleDebug
|
run: |
|
||||||
|
chmod +x ./gradlew
|
||||||
|
./gradlew --no-daemon testDebugUnitTest lintDebug assembleDebug
|
||||||
|
|||||||
194
.gitea/workflows/release.yml
Normal file
194
.gitea/workflows/release.yml
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
# Automated release for the Runic Gateway Android app.
|
||||||
|
#
|
||||||
|
# Trigger: pushing a version tag `v*` (e.g. `v0.1.0`). Tag-driven on purpose — the
|
||||||
|
# build never has to push to protected `main`; the tag *is* the release input.
|
||||||
|
#
|
||||||
|
# To cut a release:
|
||||||
|
# git tag v0.1.0 && git push origin v0.1.0
|
||||||
|
# (or create the tag from the Gitea UI). Re-build/re-release an existing tag via
|
||||||
|
# the workflow_dispatch input below.
|
||||||
|
#
|
||||||
|
# versionName = the tag without its leading `v`; versionCode = major*10000 +
|
||||||
|
# minor*100 + patch (deterministic + monotonic, PLAN.md §10). Both are injected
|
||||||
|
# into app/build.gradle.kts for the build only — nothing is committed back to main.
|
||||||
|
#
|
||||||
|
# Prerequisites (Settings -> Actions -> Secrets on RunicGateway/Android-app):
|
||||||
|
# REGISTRY_TOKEN — Gitea access token with `write:repository` (create the release)
|
||||||
|
# ANDROID_KEYSTORE_BASE64 — base64 of the release .jks (single line)
|
||||||
|
# ANDROID_KEYSTORE_PASSWORD — keystore password
|
||||||
|
# ANDROID_KEY_ALIAS — key alias (e.g. runicgateway)
|
||||||
|
# ANDROID_KEY_PASSWORD — key password (== store password for a PKCS12 keystore)
|
||||||
|
#
|
||||||
|
# Runner handling matches pr-checks.yml (self-hosted `ubuntu-latest`): the container
|
||||||
|
# lacks git/curl/unzip and can't reach api.adoptium.net, so we apt-install the base
|
||||||
|
# tools + JDK 17 (not actions/setup-java), install the exact SDK packages, and
|
||||||
|
# `chmod +x ./gradlew` in-step (checkout drops the exec bit).
|
||||||
|
|
||||||
|
name: Release APK
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
tag:
|
||||||
|
description: 'Existing v* tag to (re)build and release'
|
||||||
|
required: true
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: release-apk-${{ github.event.inputs.tag || github.ref_name }}
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
env:
|
||||||
|
GITEA_HOST: gitea.whitlocktech.com
|
||||||
|
REPO: RunicGateway/Android-app
|
||||||
|
GRADLE_MODULE: app
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
release:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
# Fail fast on a genuinely wedged run (e.g. a stalled SDK/network download on
|
||||||
|
# the self-hosted runner) instead of hanging forever and — because concurrency
|
||||||
|
# is `cancel-in-progress: false` — blocking every later release behind it.
|
||||||
|
timeout-minutes: 30
|
||||||
|
steps:
|
||||||
|
- name: Install base tools + JDK 17
|
||||||
|
run: |
|
||||||
|
apt-get update
|
||||||
|
apt-get install -y git curl unzip jq openjdk-17-jdk-headless
|
||||||
|
echo "JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
|
- name: Check out the release tag (full history for the changelog)
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: ${{ github.event.inputs.tag || github.ref_name }}
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
# ── Derive version + changelog straight from the tag ─────────────────
|
||||||
|
- name: Plan the release (version + changelog from the tag)
|
||||||
|
id: plan
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
mkdir -p dist
|
||||||
|
git fetch --tags --force >/dev/null 2>&1 || true
|
||||||
|
|
||||||
|
TAG="${{ github.event.inputs.tag || github.ref_name }}"
|
||||||
|
case "$TAG" in
|
||||||
|
v[0-9]*) : ;;
|
||||||
|
*) echo "::error::expected a v* version tag, got '$TAG'"; exit 1 ;;
|
||||||
|
esac
|
||||||
|
VERSION="${TAG#v}"
|
||||||
|
|
||||||
|
# versionCode: deterministic + monotonic from the semver (PLAN.md §10).
|
||||||
|
IFS=. read -r MA MI PA <<< "$VERSION"
|
||||||
|
: "${MA:=0}"; : "${MI:=0}"; : "${PA:=0}"
|
||||||
|
VERSION_CODE=$(( MA*10000 + MI*100 + PA ))
|
||||||
|
|
||||||
|
# Changelog: conventional-commit subjects since the previous v* tag.
|
||||||
|
PREV_TAG="$(git describe --tags --match 'v*' --abbrev=0 "${TAG}^" 2>/dev/null || true)"
|
||||||
|
if [ -n "$PREV_TAG" ]; then RANGE="${PREV_TAG}..${TAG}"; else RANGE="${TAG}"; fi
|
||||||
|
SUBJECTS="$(git log --no-merges --format='%s' $RANGE || true)"
|
||||||
|
|
||||||
|
{
|
||||||
|
echo "## Runic Gateway Android ${TAG}"
|
||||||
|
echo
|
||||||
|
FEATS="$(echo "$SUBJECTS" | grep -E '^feat' || true)"
|
||||||
|
FIXES="$(echo "$SUBJECTS" | grep -E '^(fix|perf)' || true)"
|
||||||
|
[ -n "$FEATS" ] && { echo "### Features"; echo "$FEATS" | sed 's/^/- /'; echo; }
|
||||||
|
[ -n "$FIXES" ] && { echo "### Fixes"; echo "$FIXES" | sed 's/^/- /'; echo; }
|
||||||
|
echo "### All changes"
|
||||||
|
if [ -n "$PREV_TAG" ]; then echo "Since ${PREV_TAG}:"; fi
|
||||||
|
echo "$SUBJECTS" | sed 's/^/- /'
|
||||||
|
echo
|
||||||
|
echo "---"
|
||||||
|
echo "Signed APK — sideload on Android 10+ (§10). The app self-configures its shard site on first run."
|
||||||
|
} > dist/CHANGELOG.md
|
||||||
|
|
||||||
|
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "versionCode=${VERSION_CODE}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "==> tag=${TAG} version=${VERSION} code=${VERSION_CODE} prev_tag=${PREV_TAG:-<none>}"
|
||||||
|
|
||||||
|
# ── SDK + signing keystore ───────────────────────────────────────────
|
||||||
|
- name: Set up Android SDK
|
||||||
|
uses: android-actions/setup-android@v3
|
||||||
|
with:
|
||||||
|
# Only put cmdline-tools on PATH. The action's default package set drags in
|
||||||
|
# the whole emulator + the legacy `tools` package (hundreds of MB, network-
|
||||||
|
# bound on this runner) that a headless APK build never uses. The next step
|
||||||
|
# installs exactly the packages we need.
|
||||||
|
packages: ''
|
||||||
|
|
||||||
|
- name: Install Android SDK packages
|
||||||
|
run: |
|
||||||
|
set +o pipefail
|
||||||
|
yes | sdkmanager "platform-tools" "platforms;android-35" "build-tools;35.0.0"
|
||||||
|
|
||||||
|
- name: Decode signing keystore
|
||||||
|
env:
|
||||||
|
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
if [ -z "${ANDROID_KEYSTORE_BASE64:-}" ]; then
|
||||||
|
echo "::error::ANDROID_KEYSTORE_BASE64 secret is not set — cannot build a signed release."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
printf '%s' "$ANDROID_KEYSTORE_BASE64" | base64 -d > "${RUNNER_TEMP}/release.jks"
|
||||||
|
echo "ANDROID_KEYSTORE_FILE=${RUNNER_TEMP}/release.jks" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
|
# ── Set the version, build the signed APK ────────────────────────────
|
||||||
|
- name: Set the app version to match the tag
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
VERSION="${{ steps.plan.outputs.version }}"
|
||||||
|
VERSION_CODE="${{ steps.plan.outputs.versionCode }}"
|
||||||
|
# Replace only the version defaults (the `?: "x.y.z"` / `?: N` fallbacks).
|
||||||
|
sed -i -E "s/(\?: )\"[0-9]+\.[0-9]+\.[0-9]+\"/\1\"${VERSION}\"/" "${GRADLE_MODULE}/build.gradle.kts"
|
||||||
|
sed -i -E "s/(toIntOrNull\(\) \?: )[0-9]+/\1${VERSION_CODE}/" "${GRADLE_MODULE}/build.gradle.kts"
|
||||||
|
grep -nE "versionCode = |versionName = " "${GRADLE_MODULE}/build.gradle.kts"
|
||||||
|
|
||||||
|
- name: Unit tests + signed release APK
|
||||||
|
env:
|
||||||
|
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
||||||
|
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
|
||||||
|
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
chmod +x ./gradlew
|
||||||
|
./gradlew --no-daemon :${GRADLE_MODULE}:testDebugUnitTest :${GRADLE_MODULE}:assembleRelease
|
||||||
|
|
||||||
|
- name: Package APK + SHA256SUMS
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
SRC="${GRADLE_MODULE}/build/outputs/apk/release/app-release.apk"
|
||||||
|
test -f "$SRC" || { echo "::error::release APK not found at $SRC"; exit 1; }
|
||||||
|
cp "$SRC" "dist/runic-gateway-${{ steps.plan.outputs.version }}.apk"
|
||||||
|
( cd dist && sha256sum "runic-gateway-${{ steps.plan.outputs.version }}.apk" > SHA256SUMS )
|
||||||
|
ls -l dist && cat dist/SHA256SUMS
|
||||||
|
|
||||||
|
# ── Create the Gitea release + upload assets (no push to main) ───────
|
||||||
|
- name: Create Gitea release and upload assets
|
||||||
|
env:
|
||||||
|
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
TAG="${{ steps.plan.outputs.tag }}"
|
||||||
|
API="https://${GITEA_HOST}/api/v1/repos/${REPO}"
|
||||||
|
BODY="$(cat dist/CHANGELOG.md)"
|
||||||
|
CI_TOKEN="$(printf '%s' "${REGISTRY_TOKEN}" | tr -d '\r\n')"
|
||||||
|
|
||||||
|
REL_ID="$(curl -sSf -X POST "${API}/releases" \
|
||||||
|
-H "Authorization: token ${CI_TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "$(jq -n --arg tag "$TAG" --arg body "$BODY" \
|
||||||
|
'{tag_name:$tag, name:$tag, body:$body, draft:false, prerelease:false}')" \
|
||||||
|
| jq -r '.id')"
|
||||||
|
echo "Created release ${TAG} (id=${REL_ID})"
|
||||||
|
|
||||||
|
for f in "runic-gateway-${{ steps.plan.outputs.version }}.apk" SHA256SUMS; do
|
||||||
|
curl -sSf -X POST "${API}/releases/${REL_ID}/assets?name=${f}" \
|
||||||
|
-H "Authorization: token ${CI_TOKEN}" \
|
||||||
|
-F "attachment=@dist/${f}" >/dev/null
|
||||||
|
echo " uploaded ${f}"
|
||||||
|
done
|
||||||
13
README.md
13
README.md
@@ -50,7 +50,18 @@ any shard's website — there is no compiled-in API host.
|
|||||||
|
|
||||||
`.gitea/workflows/pr-checks.yml` gates PRs into `main` with `./gradlew lint test assembleDebug` on the
|
`.gitea/workflows/pr-checks.yml` gates PRs into `main` with `./gradlew lint test assembleDebug` on the
|
||||||
org's self-hosted runner (JDK 17 + Android SDK). Debug builds are auto-signed, so the gate needs no
|
org's self-hosted runner (JDK 17 + Android SDK). Debug builds are auto-signed, so the gate needs no
|
||||||
secrets. A signed **release** APK attached to a Gitea release comes at M6.
|
secrets. **This pipeline is verified green end-to-end on the runner** (M0). A signed **release** APK
|
||||||
|
attached to a Gitea release comes at M6.
|
||||||
|
|
||||||
|
The workflow carries a few runner-specific accommodations (each explained in comments in the file),
|
||||||
|
because this self-hosted runner differs from a stock GitHub runner:
|
||||||
|
|
||||||
|
- **JDK 17 is installed via `apt`** (not `actions/setup-java`) — the runner can't resolve
|
||||||
|
`api.adoptium.net`, while the Ubuntu mirrors are reachable.
|
||||||
|
- **SDK packages are installed explicitly** via `sdkmanager`, with `set +o pipefail` so `yes` dying of
|
||||||
|
`SIGPIPE` doesn't fail the step.
|
||||||
|
- **`gradlew` is `chmod +x`'d in the run step** — the runner's checkout does not preserve the git
|
||||||
|
executable bit, so `./gradlew` alone fails with "Permission denied".
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
import java.io.FileInputStream
|
||||||
|
import java.util.Properties
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.android.application)
|
alias(libs.plugins.android.application)
|
||||||
alias(libs.plugins.kotlin.android)
|
alias(libs.plugins.kotlin.android)
|
||||||
@@ -9,6 +12,25 @@ plugins {
|
|||||||
alias(libs.plugins.hilt)
|
alias(libs.plugins.hilt)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Release signing material (PLAN.md §12) is never committed. It is read from, in
|
||||||
|
// order of precedence: a local gitignored `keystore.properties` at the repo root,
|
||||||
|
// then environment variables (how CI injects the decoded keystore + secrets). When
|
||||||
|
// none is present, the release build is simply left unsigned — `assembleDebug` and
|
||||||
|
// the PR gate are unaffected, so contributors without the keystore can still build.
|
||||||
|
val keystorePropsFile = rootProject.file("keystore.properties")
|
||||||
|
val keystoreProps = Properties().apply {
|
||||||
|
if (keystorePropsFile.exists()) FileInputStream(keystorePropsFile).use { load(it) }
|
||||||
|
}
|
||||||
|
fun signingValue(propKey: String, envKey: String): String? =
|
||||||
|
keystoreProps.getProperty(propKey) ?: System.getenv(envKey)
|
||||||
|
|
||||||
|
val ksStoreFilePath = signingValue("storeFile", "ANDROID_KEYSTORE_FILE")
|
||||||
|
val ksStorePassword = signingValue("storePassword", "ANDROID_KEYSTORE_PASSWORD")
|
||||||
|
val ksKeyAlias = signingValue("keyAlias", "ANDROID_KEY_ALIAS")
|
||||||
|
val ksKeyPassword = signingValue("keyPassword", "ANDROID_KEY_PASSWORD")
|
||||||
|
val hasReleaseSigning = ksStoreFilePath != null && ksStorePassword != null &&
|
||||||
|
ksKeyAlias != null && ksKeyPassword != null
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "com.runicgateway.app"
|
namespace = "com.runicgateway.app"
|
||||||
compileSdk = 35
|
compileSdk = 35
|
||||||
@@ -18,20 +40,46 @@ android {
|
|||||||
applicationId = "com.runicgateway.app"
|
applicationId = "com.runicgateway.app"
|
||||||
minSdk = 29
|
minSdk = 29
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
versionCode = 1
|
// These committed defaults are the version source of truth (PLAN.md §10).
|
||||||
versionName = "0.1.0"
|
// release.yml's conventional-commit engine bumps versionName here and commits
|
||||||
|
// it on release; versionCode is derived from it (major*10000+minor*100+patch)
|
||||||
|
// so it stays monotonic. Both remain overridable via -P for local/manual builds.
|
||||||
|
versionCode = (project.findProperty("versionCode") as String?)?.toIntOrNull() ?: 1
|
||||||
|
versionName = (project.findProperty("versionName") as String?)?.takeIf { it.isNotBlank() } ?: "0.1.0"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
signingConfigs {
|
||||||
|
if (hasReleaseSigning) {
|
||||||
|
create("release") {
|
||||||
|
storeFile = file(ksStoreFilePath!!)
|
||||||
|
storePassword = ksStorePassword
|
||||||
|
keyAlias = ksKeyAlias
|
||||||
|
keyPassword = ksKeyPassword
|
||||||
|
// Sign with both v1 (JAR) and v2 (APK) schemes for broad compatibility.
|
||||||
|
enableV1Signing = true
|
||||||
|
enableV2Signing = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
release {
|
release {
|
||||||
// Signing/minification are wired at M6 (release hardening). Debug is auto-signed.
|
// R8 full-mode minify + resource shrink (§7: no offline cache, so a lean
|
||||||
isMinifyEnabled = false
|
// release APK). Keep rules live in proguard-rules.pro.
|
||||||
|
isMinifyEnabled = true
|
||||||
|
isShrinkResources = true
|
||||||
proguardFiles(
|
proguardFiles(
|
||||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||||
"proguard-rules.pro",
|
"proguard-rules.pro",
|
||||||
)
|
)
|
||||||
|
// Signed only when the keystore material is present (local or CI); an
|
||||||
|
// unsigned APK is produced otherwise. The direct-APK release (§10) runs
|
||||||
|
// through release.yml, which supplies the keystore from a Gitea secret.
|
||||||
|
if (hasReleaseSigning) {
|
||||||
|
signingConfig = signingConfigs.getByName("release")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,6 +118,7 @@ dependencies {
|
|||||||
implementation(libs.androidx.compose.ui.graphics)
|
implementation(libs.androidx.compose.ui.graphics)
|
||||||
implementation(libs.androidx.compose.ui.tooling.preview)
|
implementation(libs.androidx.compose.ui.tooling.preview)
|
||||||
implementation(libs.androidx.compose.material3)
|
implementation(libs.androidx.compose.material3)
|
||||||
|
implementation(libs.androidx.compose.material.icons.core)
|
||||||
implementation(libs.androidx.navigation.compose)
|
implementation(libs.androidx.navigation.compose)
|
||||||
debugImplementation(libs.androidx.compose.ui.tooling)
|
debugImplementation(libs.androidx.compose.ui.tooling)
|
||||||
debugImplementation(libs.androidx.compose.ui.test.manifest)
|
debugImplementation(libs.androidx.compose.ui.test.manifest)
|
||||||
@@ -92,6 +141,9 @@ dependencies {
|
|||||||
implementation(libs.androidx.datastore.preferences)
|
implementation(libs.androidx.datastore.preferences)
|
||||||
implementation(libs.androidx.security.crypto)
|
implementation(libs.androidx.security.crypto)
|
||||||
|
|
||||||
|
// Web hand-off (Chrome Custom Tabs) for register / invite / reset / SSO (§4.2)
|
||||||
|
implementation(libs.androidx.browser)
|
||||||
|
|
||||||
// Images
|
// Images
|
||||||
implementation(libs.coil.compose)
|
implementation(libs.coil.compose)
|
||||||
|
|
||||||
|
|||||||
93
app/licenses/Cinzel-OFL.txt
Normal file
93
app/licenses/Cinzel-OFL.txt
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
Copyright 2020 The Cinzel Project Authors (https://github.com/NDISCOVER/Cinzel)
|
||||||
|
|
||||||
|
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||||
|
This license is copied below, and is also available with a FAQ at:
|
||||||
|
http://scripts.sil.org/OFL
|
||||||
|
|
||||||
|
|
||||||
|
-----------------------------------------------------------
|
||||||
|
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||||
|
-----------------------------------------------------------
|
||||||
|
|
||||||
|
PREAMBLE
|
||||||
|
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||||
|
development of collaborative font projects, to support the font creation
|
||||||
|
efforts of academic and linguistic communities, and to provide a free and
|
||||||
|
open framework in which fonts may be shared and improved in partnership
|
||||||
|
with others.
|
||||||
|
|
||||||
|
The OFL allows the licensed fonts to be used, studied, modified and
|
||||||
|
redistributed freely as long as they are not sold by themselves. The
|
||||||
|
fonts, including any derivative works, can be bundled, embedded,
|
||||||
|
redistributed and/or sold with any software provided that any reserved
|
||||||
|
names are not used by derivative works. The fonts and derivatives,
|
||||||
|
however, cannot be released under any other type of license. The
|
||||||
|
requirement for fonts to remain under this license does not apply
|
||||||
|
to any document created using the fonts or their derivatives.
|
||||||
|
|
||||||
|
DEFINITIONS
|
||||||
|
"Font Software" refers to the set of files released by the Copyright
|
||||||
|
Holder(s) under this license and clearly marked as such. This may
|
||||||
|
include source files, build scripts and documentation.
|
||||||
|
|
||||||
|
"Reserved Font Name" refers to any names specified as such after the
|
||||||
|
copyright statement(s).
|
||||||
|
|
||||||
|
"Original Version" refers to the collection of Font Software components as
|
||||||
|
distributed by the Copyright Holder(s).
|
||||||
|
|
||||||
|
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||||
|
or substituting -- in part or in whole -- any of the components of the
|
||||||
|
Original Version, by changing formats or by porting the Font Software to a
|
||||||
|
new environment.
|
||||||
|
|
||||||
|
"Author" refers to any designer, engineer, programmer, technical
|
||||||
|
writer or other person who contributed to the Font Software.
|
||||||
|
|
||||||
|
PERMISSION & CONDITIONS
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||||
|
redistribute, and sell modified and unmodified copies of the Font
|
||||||
|
Software, subject to the following conditions:
|
||||||
|
|
||||||
|
1) Neither the Font Software nor any of its individual components,
|
||||||
|
in Original or Modified Versions, may be sold by itself.
|
||||||
|
|
||||||
|
2) Original or Modified Versions of the Font Software may be bundled,
|
||||||
|
redistributed and/or sold with any software, provided that each copy
|
||||||
|
contains the above copyright notice and this license. These can be
|
||||||
|
included either as stand-alone text files, human-readable headers or
|
||||||
|
in the appropriate machine-readable metadata fields within text or
|
||||||
|
binary files as long as those fields can be easily viewed by the user.
|
||||||
|
|
||||||
|
3) No Modified Version of the Font Software may use the Reserved Font
|
||||||
|
Name(s) unless explicit written permission is granted by the corresponding
|
||||||
|
Copyright Holder. This restriction only applies to the primary font name as
|
||||||
|
presented to the users.
|
||||||
|
|
||||||
|
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||||
|
Software shall not be used to promote, endorse or advertise any
|
||||||
|
Modified Version, except to acknowledge the contribution(s) of the
|
||||||
|
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||||
|
permission.
|
||||||
|
|
||||||
|
5) The Font Software, modified or unmodified, in part or in whole,
|
||||||
|
must be distributed entirely under this license, and must not be
|
||||||
|
distributed under any other license. The requirement for fonts to
|
||||||
|
remain under this license does not apply to any document created
|
||||||
|
using the Font Software.
|
||||||
|
|
||||||
|
TERMINATION
|
||||||
|
This license becomes null and void if any of the above conditions are
|
||||||
|
not met.
|
||||||
|
|
||||||
|
DISCLAIMER
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||||
|
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||||
|
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||||
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||||
|
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||||
62
app/proguard-rules.pro
vendored
62
app/proguard-rules.pro
vendored
@@ -1,3 +1,59 @@
|
|||||||
# Runic Gateway Android app — ProGuard/R8 rules.
|
# Runic Gateway Android app — ProGuard/R8 rules (release minify + resource shrink, M6).
|
||||||
# Minification is disabled until M6 (release hardening); real keep rules for
|
#
|
||||||
# kotlinx.serialization DTOs and Retrofit models are added there.
|
# The dependency stack ships its own consumer rules that R8 applies automatically:
|
||||||
|
# Retrofit 2.11, OkHttp 4.12, kotlinx.serialization 1.7 (core), Hilt/Dagger, Coil 2.7.
|
||||||
|
# The rules below are defensive belt-and-suspenders for the areas full-mode R8 is
|
||||||
|
# most likely to over-strip in this app: the kotlinx.serialization generated
|
||||||
|
# serializers and our own @Serializable wire DTOs.
|
||||||
|
|
||||||
|
# ── kotlinx.serialization (canonical keep rules) ────────────────────────────
|
||||||
|
-keepattributes *Annotation*, InnerClasses
|
||||||
|
-dontnote kotlinx.serialization.**
|
||||||
|
|
||||||
|
# Keep the Companion of @Serializable classes so `.serializer()` resolves.
|
||||||
|
-if @kotlinx.serialization.Serializable class **
|
||||||
|
-keepclassmembers class <1> {
|
||||||
|
static <1>$Companion Companion;
|
||||||
|
}
|
||||||
|
-if @kotlinx.serialization.Serializable class ** {
|
||||||
|
static **$Companion Companion;
|
||||||
|
}
|
||||||
|
-keepclassmembers class <2>$Companion {
|
||||||
|
kotlinx.serialization.KSerializer serializer(...);
|
||||||
|
}
|
||||||
|
# Keep `INSTANCE.serializer()` of @Serializable objects.
|
||||||
|
-if @kotlinx.serialization.Serializable class ** {
|
||||||
|
public static ** INSTANCE;
|
||||||
|
}
|
||||||
|
-keepclassmembers class <1> {
|
||||||
|
public static <1> INSTANCE;
|
||||||
|
kotlinx.serialization.KSerializer serializer(...);
|
||||||
|
}
|
||||||
|
# Keep the synthesized $$serializer classes and their descriptor field.
|
||||||
|
-keepclassmembers class **$$serializer {
|
||||||
|
*** descriptor;
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Our wire DTOs ───────────────────────────────────────────────────────────
|
||||||
|
# All request/response models decoded by kotlinx.serialization. Keeping them
|
||||||
|
# (and their generated serializers) guarantees additive backend fields and
|
||||||
|
# @SerialName mappings survive minification. DTOs are small, so keeping them
|
||||||
|
# whole is cheap insurance against a full-mode strip.
|
||||||
|
-keep @kotlinx.serialization.Serializable class com.runicgateway.app.** { *; }
|
||||||
|
-keepclassmembers class com.runicgateway.app.data.api.dto.** { *; }
|
||||||
|
|
||||||
|
# ── Retrofit service interfaces ─────────────────────────────────────────────
|
||||||
|
# Retrofit reads method + parameter annotations reflectively; keep our API
|
||||||
|
# interfaces' generic signatures so return types (suspend .../Call<T>) resolve.
|
||||||
|
-keep,allowobfuscation interface com.runicgateway.app.data.api.*Api
|
||||||
|
-keepattributes Signature, Exceptions
|
||||||
|
|
||||||
|
# Kotlin metadata is needed for reflection over Kotlin types (serialization/Retrofit).
|
||||||
|
-keep class kotlin.Metadata { *; }
|
||||||
|
|
||||||
|
# ── Tink / EncryptedSharedPreferences (androidx.security-crypto) ─────────────
|
||||||
|
# Tink references Error Prone compile-only annotations that are absent at runtime;
|
||||||
|
# they are safe to ignore (they carry no runtime behaviour). Suppresses the R8
|
||||||
|
# "Missing class com.google.errorprone.annotations.*" errors.
|
||||||
|
-dontwarn com.google.errorprone.annotations.**
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,6 @@
|
|||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:label="@string/app_name"
|
|
||||||
android:theme="@style/Theme.RunicGateway">
|
android:theme="@style/Theme.RunicGateway">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
|||||||
BIN
app/src/main/ic_launcher-playstore.png
Normal file
BIN
app/src/main/ic_launcher-playstore.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 160 KiB |
@@ -3,62 +3,66 @@
|
|||||||
*/
|
*/
|
||||||
package com.runicgateway.app
|
package com.runicgateway.app
|
||||||
|
|
||||||
|
import android.graphics.Color
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.SystemBarStyle
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import com.runicgateway.app.ui.AppViewModel
|
||||||
|
import com.runicgateway.app.ui.AppViewModel.AppState
|
||||||
|
import com.runicgateway.app.ui.LocalAssetResolver
|
||||||
|
import com.runicgateway.app.ui.RunicApp
|
||||||
|
import com.runicgateway.app.ui.components.LoadingView
|
||||||
|
import com.runicgateway.app.ui.connect.ConnectScreen
|
||||||
import com.runicgateway.app.ui.theme.RunicGatewayTheme
|
import com.runicgateway.app.ui.theme.RunicGatewayTheme
|
||||||
|
import com.runicgateway.app.ui.theme.parseBrandColor
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Single-activity host. Navigation-Compose and the first-run base-URL flow (§3)
|
* Single-activity host (PLAN.md §2). Gates on [AppViewModel]: the first-run
|
||||||
* land in M1; this M0 skeleton only proves the Compose + Hilt + theme wiring.
|
* connect screen until a shard site is configured (§3), then the main app.
|
||||||
|
* The Material theme is seeded from the per-shard brand accent, and asset-path
|
||||||
|
* resolution is provided to the whole tree.
|
||||||
*/
|
*/
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
enableEdgeToEdge()
|
// Dark-only app (M5): force light system-bar icons over the transparent bars so
|
||||||
|
// they stay legible on the deep blue-black surfaces regardless of system theme.
|
||||||
|
val barStyle = SystemBarStyle.dark(Color.TRANSPARENT)
|
||||||
|
enableEdgeToEdge(statusBarStyle = barStyle, navigationBarStyle = barStyle)
|
||||||
setContent {
|
setContent {
|
||||||
RunicGatewayTheme {
|
val appViewModel: AppViewModel = hiltViewModel()
|
||||||
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
|
val state by appViewModel.state.collectAsStateWithLifecycle()
|
||||||
Placeholder(modifier = Modifier.padding(innerPadding))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
val accent = (state as? AppState.Ready)?.brand?.let { parseBrandColor(it.accent) }
|
||||||
private fun Placeholder(modifier: Modifier = Modifier) {
|
|
||||||
Column(
|
RunicGatewayTheme(accent = accent) {
|
||||||
modifier = modifier.fillMaxSize(),
|
CompositionLocalProvider(LocalAssetResolver provides appViewModel::resolveAsset) {
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
Surface(
|
||||||
verticalArrangement = Arrangement.Center,
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
color = MaterialTheme.colorScheme.background,
|
||||||
) {
|
) {
|
||||||
Text(
|
when (val s = state) {
|
||||||
text = stringResource(id = R.string.app_scaffold_ready),
|
AppState.Loading -> LoadingView()
|
||||||
style = MaterialTheme.typography.titleLarge,
|
AppState.NeedsConnection ->
|
||||||
)
|
ConnectScreen(onConnected = appViewModel::onConnected)
|
||||||
|
is AppState.Ready ->
|
||||||
|
RunicApp(brand = s.brand, onChangeServer = appViewModel::changeServer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Preview(showBackground = true)
|
|
||||||
@Composable
|
|
||||||
private fun PlaceholderPreview() {
|
|
||||||
RunicGatewayTheme {
|
|
||||||
Placeholder()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
14
app/src/main/java/com/runicgateway/app/core/AppConfig.kt
Normal file
14
app/src/main/java/com/runicgateway/app/core/AppConfig.kt
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.core
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build-derived flags, injected rather than read from `BuildConfig` directly so
|
||||||
|
* the logic that consumes them (URL validation, etc.) stays plain and unit-testable.
|
||||||
|
*/
|
||||||
|
data class AppConfig(
|
||||||
|
/** Allow plain HTTP base URLs. Debug-only (local dev); release requires HTTPS (§3). */
|
||||||
|
val allowInsecureHttp: Boolean,
|
||||||
|
val versionName: String,
|
||||||
|
)
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.core.auth
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import androidx.security.crypto.EncryptedSharedPreferences
|
||||||
|
import androidx.security.crypto.MasterKey
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [TokenStore] backed by Jetpack Security's [EncryptedSharedPreferences]
|
||||||
|
* (Tink/AES-256-GCM), so the token pair is encrypted at rest (PLAN.md §2, §4.3).
|
||||||
|
* The base URL stays in plain DataStore ([com.runicgateway.app.core.prefs.ServerPreferences]);
|
||||||
|
* only tokens live here.
|
||||||
|
*
|
||||||
|
* The prefs handle is created lazily so a first-launch device (no session yet)
|
||||||
|
* pays the keystore cost only once a user actually signs in.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class EncryptedTokenStore @Inject constructor(
|
||||||
|
@param:ApplicationContext private val context: Context,
|
||||||
|
) : TokenStore {
|
||||||
|
|
||||||
|
private val prefs: SharedPreferences by lazy {
|
||||||
|
val masterKey = MasterKey.Builder(context)
|
||||||
|
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||||
|
.build()
|
||||||
|
EncryptedSharedPreferences.create(
|
||||||
|
context,
|
||||||
|
PREFS_NAME,
|
||||||
|
masterKey,
|
||||||
|
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||||
|
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun load(): StoredSession? {
|
||||||
|
val access = prefs.getString(KEY_ACCESS, null) ?: return null
|
||||||
|
val refresh = prefs.getString(KEY_REFRESH, null) ?: return null
|
||||||
|
val username = prefs.getString(KEY_USERNAME, null) ?: return null
|
||||||
|
val role = prefs.getString(KEY_ROLE, null) ?: return null
|
||||||
|
val id = prefs.getLong(KEY_USER_ID, -1L)
|
||||||
|
if (id < 0) return null
|
||||||
|
return StoredSession(access, refresh, id, username, role)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun save(session: StoredSession) {
|
||||||
|
prefs.edit()
|
||||||
|
.putString(KEY_ACCESS, session.accessToken)
|
||||||
|
.putString(KEY_REFRESH, session.refreshToken)
|
||||||
|
.putLong(KEY_USER_ID, session.userId)
|
||||||
|
.putString(KEY_USERNAME, session.username)
|
||||||
|
.putString(KEY_ROLE, session.role)
|
||||||
|
.apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun clear() {
|
||||||
|
prefs.edit().clear().apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val PREFS_NAME = "runic_session"
|
||||||
|
const val KEY_ACCESS = "access_token"
|
||||||
|
const val KEY_REFRESH = "refresh_token"
|
||||||
|
const val KEY_USER_ID = "user_id"
|
||||||
|
const val KEY_USERNAME = "username"
|
||||||
|
const val KEY_ROLE = "role"
|
||||||
|
}
|
||||||
|
}
|
||||||
60
app/src/main/java/com/runicgateway/app/core/auth/Session.kt
Normal file
60
app/src/main/java/com/runicgateway/app/core/auth/Session.kt
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.core.auth
|
||||||
|
|
||||||
|
import com.runicgateway.app.data.api.dto.SafeUserDto
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The signed-in identity the app carries (PLAN.md §4.3, §5). Role is *advisory*
|
||||||
|
* for menu rendering only — the backend re-checks every gated call, so the app
|
||||||
|
* treats a 403 as authoritative and never assumes access from this value.
|
||||||
|
*/
|
||||||
|
data class SessionUser(
|
||||||
|
val id: Long,
|
||||||
|
val username: String,
|
||||||
|
val role: Role,
|
||||||
|
) {
|
||||||
|
val isPlayer: Boolean get() = role == Role.PLAYER
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The account roles the backend issues. The three staff roles are gated by
|
||||||
|
* capability, not rank (PLAN.md §5); [UNKNOWN] absorbs any future role so an
|
||||||
|
* additive backend change never crashes the menu.
|
||||||
|
*/
|
||||||
|
enum class Role(val wire: String) {
|
||||||
|
PLAYER("player"),
|
||||||
|
MODERATOR("moderator"),
|
||||||
|
EDITOR("editor"),
|
||||||
|
ADMIN("admin"),
|
||||||
|
UNKNOWN("");
|
||||||
|
|
||||||
|
val isStaff: Boolean get() = this == MODERATOR || this == EDITOR || this == ADMIN
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun fromWire(value: String?): Role =
|
||||||
|
entries.firstOrNull { it.wire.equals(value, ignoreCase = true) } ?: UNKNOWN
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The two auth states the UI observes. */
|
||||||
|
sealed interface Session {
|
||||||
|
data object SignedOut : Session
|
||||||
|
data class SignedIn(val user: SessionUser) : Session
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun SafeUserDto.toSessionUser(): SessionUser =
|
||||||
|
SessionUser(id = id, username = username, role = Role.fromWire(role))
|
||||||
|
|
||||||
|
internal fun SafeUserDto.toStored(accessToken: String, refreshToken: String): StoredSession =
|
||||||
|
StoredSession(
|
||||||
|
accessToken = accessToken,
|
||||||
|
refreshToken = refreshToken,
|
||||||
|
userId = id,
|
||||||
|
username = username,
|
||||||
|
role = role,
|
||||||
|
)
|
||||||
|
|
||||||
|
internal fun StoredSession.toSessionUser(): SessionUser =
|
||||||
|
SessionUser(id = userId, username = username, role = Role.fromWire(role))
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.core.auth
|
||||||
|
|
||||||
|
import com.runicgateway.app.data.api.dto.SafeUserDto
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import java.util.concurrent.atomic.AtomicReference
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The single source of truth for the current session (PLAN.md §4.3). It holds the
|
||||||
|
* in-memory token pair the network layer reads on every call, mirrors the
|
||||||
|
* signed-in identity into an observable [state] the UI + menu react to, and keeps
|
||||||
|
* the encrypted [TokenStore] in sync.
|
||||||
|
*
|
||||||
|
* Threading: [state] and the token holders are read from the UI thread and
|
||||||
|
* written from both coroutines (login/logout) and the OkHttp
|
||||||
|
* [com.runicgateway.app.core.net.TokenAuthenticator] dispatcher thread (silent
|
||||||
|
* refresh), so tokens live in [AtomicReference]s and the mutators are
|
||||||
|
* `@Synchronized` to keep the token pair and [state] consistent with each other.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class SessionManager @Inject constructor(
|
||||||
|
private val store: TokenStore,
|
||||||
|
) {
|
||||||
|
private val accessRef = AtomicReference<String?>(null)
|
||||||
|
private val refreshRef = AtomicReference<String?>(null)
|
||||||
|
|
||||||
|
private val _state: MutableStateFlow<Session>
|
||||||
|
val state: StateFlow<Session>
|
||||||
|
|
||||||
|
init {
|
||||||
|
val restored = store.load()
|
||||||
|
if (restored != null) {
|
||||||
|
accessRef.set(restored.accessToken)
|
||||||
|
refreshRef.set(restored.refreshToken)
|
||||||
|
_state = MutableStateFlow(Session.SignedIn(restored.toSessionUser()))
|
||||||
|
} else {
|
||||||
|
_state = MutableStateFlow(Session.SignedOut)
|
||||||
|
}
|
||||||
|
state = _state.asStateFlow()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The bearer for the current request, or null when signed out. */
|
||||||
|
fun currentAccessToken(): String? = accessRef.get()
|
||||||
|
|
||||||
|
/** The refresh token the authenticator rotates, or null when signed out. */
|
||||||
|
fun currentRefreshToken(): String? = refreshRef.get()
|
||||||
|
|
||||||
|
val isSignedIn: Boolean get() = _state.value is Session.SignedIn
|
||||||
|
|
||||||
|
/** Establish a session from a successful login (§4.1). */
|
||||||
|
@Synchronized
|
||||||
|
fun onSignedIn(accessToken: String, refreshToken: String, user: SafeUserDto) {
|
||||||
|
accessRef.set(accessToken)
|
||||||
|
refreshRef.set(refreshToken)
|
||||||
|
store.save(user.toStored(accessToken, refreshToken))
|
||||||
|
_state.value = Session.SignedIn(user.toSessionUser())
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store a rotated token pair after a silent refresh (§4.3). Keeps the current
|
||||||
|
* user; if somehow signed out already, it is a no-op (the refresh raced a
|
||||||
|
* logout and must not resurrect the session).
|
||||||
|
*/
|
||||||
|
@Synchronized
|
||||||
|
fun onRefreshed(accessToken: String, refreshToken: String, user: SafeUserDto) {
|
||||||
|
if (_state.value !is Session.SignedIn) return
|
||||||
|
accessRef.set(accessToken)
|
||||||
|
refreshRef.set(refreshToken)
|
||||||
|
store.save(user.toStored(accessToken, refreshToken))
|
||||||
|
// Refresh may carry an updated role — reflect it so the menu stays honest.
|
||||||
|
_state.value = Session.SignedIn(user.toSessionUser())
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Refresh the cached identity from a `/auth/me` re-validation (§4.3). */
|
||||||
|
@Synchronized
|
||||||
|
fun onUserRefreshed(user: SafeUserDto) {
|
||||||
|
val current = _state.value
|
||||||
|
if (current !is Session.SignedIn) return
|
||||||
|
val access = accessRef.get() ?: return
|
||||||
|
val refresh = refreshRef.get() ?: return
|
||||||
|
store.save(user.toStored(access, refresh))
|
||||||
|
_state.value = Session.SignedIn(user.toSessionUser())
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tear the session down — user logout, dead refresh, or a server switch (§3). */
|
||||||
|
@Synchronized
|
||||||
|
fun onSignedOut() {
|
||||||
|
accessRef.set(null)
|
||||||
|
refreshRef.set(null)
|
||||||
|
store.clear()
|
||||||
|
_state.value = Session.SignedOut
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.core.auth
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The at-rest home for a signed-in session (PLAN.md §4.3): the access + refresh
|
||||||
|
* tokens plus the cached safe-user. Tokens are sensitive, so the production
|
||||||
|
* implementation stores them in EncryptedSharedPreferences — never plain
|
||||||
|
* DataStore or logs. Kept behind an interface so [SessionManager] is unit-testable
|
||||||
|
* against an in-memory fake.
|
||||||
|
*/
|
||||||
|
interface TokenStore {
|
||||||
|
/** The persisted session restored on launch, or null when signed out. */
|
||||||
|
fun load(): StoredSession?
|
||||||
|
|
||||||
|
/** Persist (overwrite) the current session atomically. */
|
||||||
|
fun save(session: StoredSession)
|
||||||
|
|
||||||
|
/** Wipe every stored token — sign-out and the Settings → Server hard reset (§3). */
|
||||||
|
fun clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A persisted session: the token pair and the non-sensitive user it belongs to. */
|
||||||
|
data class StoredSession(
|
||||||
|
val accessToken: String,
|
||||||
|
val refreshToken: String,
|
||||||
|
val userId: Long,
|
||||||
|
val username: String,
|
||||||
|
val role: String,
|
||||||
|
)
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.core.net
|
||||||
|
|
||||||
|
import com.runicgateway.app.core.auth.SessionManager
|
||||||
|
import okhttp3.Interceptor
|
||||||
|
import okhttp3.Response
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attaches the current bearer access token to outbound calls (PLAN.md §4.1).
|
||||||
|
* Public endpoints simply carry a token the backend ignores; the credential
|
||||||
|
* endpoints (login/refresh) tag themselves [Http.NO_SESSION_HEADER] and are left
|
||||||
|
* bare so a credential `401` is never mistaken for an expired session. A request
|
||||||
|
* that already set its own Authorization (the authenticator's retry) is untouched.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class AuthInterceptor @Inject constructor(
|
||||||
|
private val sessionManager: SessionManager,
|
||||||
|
) : Interceptor {
|
||||||
|
|
||||||
|
override fun intercept(chain: Interceptor.Chain): Response {
|
||||||
|
val request = chain.request()
|
||||||
|
if (request.header(Http.NO_SESSION_HEADER) != null) {
|
||||||
|
return chain.proceed(request)
|
||||||
|
}
|
||||||
|
if (request.header(Http.AUTHORIZATION) != null) {
|
||||||
|
return chain.proceed(request)
|
||||||
|
}
|
||||||
|
val token = sessionManager.currentAccessToken()
|
||||||
|
?: return chain.proceed(request)
|
||||||
|
val authed = request.newBuilder()
|
||||||
|
.header(Http.AUTHORIZATION, Http.bearer(token))
|
||||||
|
.build()
|
||||||
|
return chain.proceed(authed)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.core.net
|
||||||
|
|
||||||
|
import okhttp3.HttpUrl
|
||||||
|
import java.util.concurrent.atomic.AtomicReference
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Holds the currently-selected shard website base URL (PLAN.md §3). The API host
|
||||||
|
* is not compiled in: it is chosen on first run, may be changed later under
|
||||||
|
* Settings → Server, and every outbound API request is retargeted onto it by
|
||||||
|
* [HostSelectionInterceptor].
|
||||||
|
*
|
||||||
|
* Threading: the value is read on every network call and written from the
|
||||||
|
* connect/settings flows, so it lives in an [AtomicReference].
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class BaseUrlHolder @Inject constructor() {
|
||||||
|
private val ref = AtomicReference<HttpUrl?>(null)
|
||||||
|
|
||||||
|
/** The configured base, or null before first-run connect completes. */
|
||||||
|
val current: HttpUrl? get() = ref.get()
|
||||||
|
|
||||||
|
fun set(url: HttpUrl?) = ref.set(url)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/**
|
||||||
|
* Sentinel host used as Retrofit's compile-time `baseUrl`. Relative
|
||||||
|
* endpoint paths resolve against it; [HostSelectionInterceptor] rewrites
|
||||||
|
* exactly these requests onto [current]. Absolute-URL probe requests use
|
||||||
|
* a real host and are left untouched. `.invalid` is reserved (RFC 6761)
|
||||||
|
* so it can never accidentally resolve on a network.
|
||||||
|
*/
|
||||||
|
const val PLACEHOLDER_HOST = "runic-gateway.invalid"
|
||||||
|
const val PLACEHOLDER_BASE_URL = "https://$PLACEHOLDER_HOST/"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.core.net
|
||||||
|
|
||||||
|
import okhttp3.HttpUrl
|
||||||
|
import okhttp3.Interceptor
|
||||||
|
import okhttp3.Response
|
||||||
|
import java.io.IOException
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retargets each relative API request onto the runtime-selected base URL
|
||||||
|
* (PLAN.md §3). Retrofit is built with a sentinel base host
|
||||||
|
* ([BaseUrlHolder.PLACEHOLDER_HOST]); this interceptor swaps the scheme/host/port
|
||||||
|
* for the configured shard site and prefixes any base path the user included
|
||||||
|
* (`https://host/base`). Absolute-URL requests (the connect probe via `@Url`)
|
||||||
|
* carry a real host and pass through untouched.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class HostSelectionInterceptor @Inject constructor(
|
||||||
|
private val baseUrlHolder: BaseUrlHolder,
|
||||||
|
) : Interceptor {
|
||||||
|
|
||||||
|
override fun intercept(chain: Interceptor.Chain): Response {
|
||||||
|
val request = chain.request()
|
||||||
|
if (request.url.host != BaseUrlHolder.PLACEHOLDER_HOST) {
|
||||||
|
return chain.proceed(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
val base = baseUrlHolder.current
|
||||||
|
?: throw IOException("No shard website is configured yet.")
|
||||||
|
|
||||||
|
val rewritten = rewriteOntoBase(base, request.url)
|
||||||
|
return chain.proceed(request.newBuilder().url(rewritten).build())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the sentinel-hosted [requestUrl] against [base] the way a browser
|
||||||
|
* resolves a relative link. [base] is guaranteed to end in "/"
|
||||||
|
* (ServerUrl.normalize), so `https://host/base/` + `api/v1/x` yields
|
||||||
|
* `https://host/base/api/v1/x` — the base path prefix is preserved and no double
|
||||||
|
* slash appears. Extracted as a pure function for unit testing.
|
||||||
|
*/
|
||||||
|
fun rewriteOntoBase(base: HttpUrl, requestUrl: HttpUrl): HttpUrl {
|
||||||
|
val relative = buildString {
|
||||||
|
append(requestUrl.encodedPath.removePrefix("/"))
|
||||||
|
requestUrl.encodedQuery?.let { append('?').append(it) }
|
||||||
|
}
|
||||||
|
return base.resolve(relative) ?: base
|
||||||
|
}
|
||||||
19
app/src/main/java/com/runicgateway/app/core/net/Http.kt
Normal file
19
app/src/main/java/com/runicgateway/app/core/net/Http.kt
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.core.net
|
||||||
|
|
||||||
|
/** Shared HTTP constants for the auth layer (PLAN.md §4). */
|
||||||
|
object Http {
|
||||||
|
/**
|
||||||
|
* Marks the credential endpoints (login, refresh) that must run *without* a
|
||||||
|
* bearer and must never trigger the refresh-on-401 [TokenAuthenticator].
|
||||||
|
* [AuthInterceptor] sees it and skips attaching a token; the authenticator
|
||||||
|
* sees it on the failed request and declines to refresh. It is a harmless
|
||||||
|
* unknown header to the backend.
|
||||||
|
*/
|
||||||
|
const val NO_SESSION_HEADER = "X-Runic-No-Session"
|
||||||
|
const val AUTHORIZATION = "Authorization"
|
||||||
|
|
||||||
|
fun bearer(token: String): String = "Bearer $token"
|
||||||
|
}
|
||||||
78
app/src/main/java/com/runicgateway/app/core/net/ServerUrl.kt
Normal file
78
app/src/main/java/com/runicgateway/app/core/net/ServerUrl.kt
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.core.net
|
||||||
|
|
||||||
|
import okhttp3.HttpUrl
|
||||||
|
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses and normalizes the base URL a user types on the first-run "Connect to
|
||||||
|
* your shard's website" screen (PLAN.md §3). Pure, dependency-light logic so it
|
||||||
|
* is exercised directly in JVM unit tests.
|
||||||
|
*
|
||||||
|
* Rules:
|
||||||
|
* - accept `https://host[/base]`; a bare `host[/base]` gets an implicit scheme
|
||||||
|
* (`https` normally, so users needn't type it);
|
||||||
|
* - trim surrounding whitespace;
|
||||||
|
* - require HTTPS unless [allowInsecureHttp] (release builds forbid HTTP; debug
|
||||||
|
* allows it for local dev against `127.0.0.1:3000`);
|
||||||
|
* - drop any query/fragment and guarantee a trailing slash on the path so the
|
||||||
|
* stored value composes cleanly with relative endpoint paths.
|
||||||
|
*/
|
||||||
|
object ServerUrl {
|
||||||
|
|
||||||
|
sealed interface Result {
|
||||||
|
data class Valid(val url: HttpUrl) : Result
|
||||||
|
data class Invalid(val reason: Reason) : Result
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class Reason {
|
||||||
|
/** Empty or whitespace-only input. */
|
||||||
|
BLANK,
|
||||||
|
|
||||||
|
/** Not a parseable http(s) URL (bad host, illegal characters, …). */
|
||||||
|
MALFORMED,
|
||||||
|
|
||||||
|
/** A scheme other than http/https (e.g. ftp://, ws://). */
|
||||||
|
UNSUPPORTED_SCHEME,
|
||||||
|
|
||||||
|
/** Plain HTTP where the build requires HTTPS. */
|
||||||
|
INSECURE,
|
||||||
|
}
|
||||||
|
|
||||||
|
fun normalize(raw: String, allowInsecureHttp: Boolean): Result {
|
||||||
|
val trimmed = raw.trim()
|
||||||
|
if (trimmed.isEmpty()) return Result.Invalid(Reason.BLANK)
|
||||||
|
|
||||||
|
// Give a scheme-less entry an implicit, secure default so users can type
|
||||||
|
// just "shard.example.com". An explicit but unsupported scheme is rejected.
|
||||||
|
val hasScheme = SCHEME_RE.containsMatchIn(trimmed)
|
||||||
|
val candidate = if (hasScheme) trimmed else "https://$trimmed"
|
||||||
|
|
||||||
|
val lowerScheme = candidate.substringBefore("://", "").lowercase()
|
||||||
|
if (hasScheme && lowerScheme != "http" && lowerScheme != "https") {
|
||||||
|
return Result.Invalid(Reason.UNSUPPORTED_SCHEME)
|
||||||
|
}
|
||||||
|
|
||||||
|
val parsed = candidate.toHttpUrlOrNull() ?: return Result.Invalid(Reason.MALFORMED)
|
||||||
|
if (parsed.host.isBlank()) return Result.Invalid(Reason.MALFORMED)
|
||||||
|
|
||||||
|
if (parsed.scheme == "http" && !allowInsecureHttp) {
|
||||||
|
return Result.Invalid(Reason.INSECURE)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rebuild without query/fragment and force a trailing slash so
|
||||||
|
// HttpUrl.resolve()/addPathSegments compose predictably later.
|
||||||
|
val path = parsed.encodedPath.trimEnd('/')
|
||||||
|
val normalized = parsed.newBuilder()
|
||||||
|
.encodedPath(if (path.isEmpty()) "/" else "$path/")
|
||||||
|
.query(null)
|
||||||
|
.fragment(null)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
return Result.Valid(normalized)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val SCHEME_RE = Regex("^[a-zA-Z][a-zA-Z0-9+.-]*://")
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.core.net
|
||||||
|
|
||||||
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.channelFlow
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.JsonNull
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.jsonObject
|
||||||
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.Response
|
||||||
|
import okhttp3.sse.EventSource
|
||||||
|
import okhttp3.sse.EventSourceListener
|
||||||
|
import okhttp3.sse.EventSources
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Consumes the public live-event SSE stream (`GET /public/shard/stream`, safe kinds
|
||||||
|
* only) and re-emits each frame as a [ShardStreamEvent] (PLAN.md §6.2, §7).
|
||||||
|
*
|
||||||
|
* Unlike the browser's `EventSource`, OkHttp's does **not** auto-reconnect, so the
|
||||||
|
* reconnect/backoff loop lives here: on any disconnect the connection is torn down
|
||||||
|
* and re-opened after a growing delay (reset once a connection opens), and while no
|
||||||
|
* shard site is configured yet the flow simply idles. The stream is exposed as a
|
||||||
|
* cold [Flow]; a `viewModelScope` collect opens it and cancellation closes it, so a
|
||||||
|
* dropped feed degrades to "offline" rather than crashing.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class ShardStreamClient @Inject constructor(
|
||||||
|
baseClient: OkHttpClient,
|
||||||
|
private val baseUrlHolder: BaseUrlHolder,
|
||||||
|
private val json: Json,
|
||||||
|
) {
|
||||||
|
// SSE is a long-lived, mostly-idle connection (keepalive comments every ~25s),
|
||||||
|
// so the read timeout must be disabled or the idle stream would be killed.
|
||||||
|
private val sseClient: OkHttpClient = baseClient.newBuilder()
|
||||||
|
.readTimeout(0, TimeUnit.MILLISECONDS)
|
||||||
|
.retryOnConnectionFailure(true)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
private val factory = EventSources.createFactory(sseClient)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A cold flow of stream lifecycle + frame events, reconnecting with backoff
|
||||||
|
* until the collector cancels. [ShardStreamEvent.Open] / [ShardStreamEvent.Closed]
|
||||||
|
* drive a live/offline indicator; [ShardStreamEvent.Frame] carries a decoded
|
||||||
|
* `{ kind, … }` payload the boards merge in place.
|
||||||
|
*/
|
||||||
|
fun events(): Flow<ShardStreamEvent> = channelFlow {
|
||||||
|
var backoffMs = INITIAL_BACKOFF_MS
|
||||||
|
while (isActive) {
|
||||||
|
val url = baseUrlHolder.current?.resolve(STREAM_PATH)
|
||||||
|
if (url == null) {
|
||||||
|
// No shard site configured (or an unresolvable base) — idle, don't spin.
|
||||||
|
trySend(ShardStreamEvent.Closed)
|
||||||
|
delay(backoffMs)
|
||||||
|
backoffMs = grow(backoffMs)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url(url)
|
||||||
|
.header("Accept", "text/event-stream")
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val opened = AtomicBoolean(false)
|
||||||
|
val ended = CompletableDeferred<Unit>()
|
||||||
|
val listener = object : EventSourceListener() {
|
||||||
|
override fun onOpen(eventSource: EventSource, response: Response) {
|
||||||
|
opened.set(true)
|
||||||
|
trySend(ShardStreamEvent.Open)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onEvent(
|
||||||
|
eventSource: EventSource,
|
||||||
|
id: String?,
|
||||||
|
type: String?,
|
||||||
|
data: String,
|
||||||
|
) {
|
||||||
|
parseFrame(data)?.let { (kind, obj) ->
|
||||||
|
trySend(ShardStreamEvent.Frame(kind, obj))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onClosed(eventSource: EventSource) {
|
||||||
|
trySend(ShardStreamEvent.Closed)
|
||||||
|
ended.complete(Unit)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onFailure(
|
||||||
|
eventSource: EventSource,
|
||||||
|
t: Throwable?,
|
||||||
|
response: Response?,
|
||||||
|
) {
|
||||||
|
trySend(ShardStreamEvent.Closed)
|
||||||
|
ended.complete(Unit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val source = factory.newEventSource(request, listener)
|
||||||
|
try {
|
||||||
|
// Park until this connection ends; collector cancellation propagates
|
||||||
|
// out of await() and is handled by the finally + the while guard.
|
||||||
|
ended.await()
|
||||||
|
} finally {
|
||||||
|
source.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
// A connection that opened before dropping reconnects promptly; a run of
|
||||||
|
// failures that never opened backs off further to avoid hammering a down site.
|
||||||
|
backoffMs = if (opened.get()) INITIAL_BACKOFF_MS else grow(backoffMs)
|
||||||
|
delay(backoffMs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse an SSE `data:` line into `(kind, object)`, dropping keepalive comments
|
||||||
|
* and any frame without a string `kind`. Kept internal + pure for unit testing.
|
||||||
|
*/
|
||||||
|
internal fun parseFrame(data: String): Pair<String, JsonObject>? {
|
||||||
|
val trimmed = data.trim()
|
||||||
|
if (trimmed.isEmpty() || trimmed.startsWith(":")) return null
|
||||||
|
return try {
|
||||||
|
val obj = json.parseToJsonElement(trimmed).jsonObject
|
||||||
|
val kindEl = obj["kind"] ?: return null
|
||||||
|
if (kindEl is JsonNull) return null
|
||||||
|
val kind = kindEl.jsonPrimitive.content
|
||||||
|
if (kind.isEmpty()) null else kind to obj
|
||||||
|
} catch (_: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun grow(current: Long): Long = (current * 2).coerceAtMost(MAX_BACKOFF_MS)
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val STREAM_PATH = "api/v1/public/shard/stream"
|
||||||
|
const val INITIAL_BACKOFF_MS = 2_000L
|
||||||
|
const val MAX_BACKOFF_MS = 30_000L
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.core.net
|
||||||
|
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A lifecycle or data event from the public shard SSE stream (PLAN.md §6.2).
|
||||||
|
*
|
||||||
|
* - [Open] — a connection was established (drive the live indicator on).
|
||||||
|
* - [Closed] — the connection dropped or none is available (indicator off);
|
||||||
|
* [ShardStreamClient] will reconnect with backoff.
|
||||||
|
* - [Frame] — a live event: its `kind` plus the raw JSON object, which the
|
||||||
|
* boards decode into their DTO (`champ.update` → `ChampDto`, …).
|
||||||
|
*/
|
||||||
|
sealed interface ShardStreamEvent {
|
||||||
|
data object Open : ShardStreamEvent
|
||||||
|
data object Closed : ShardStreamEvent
|
||||||
|
data class Frame(val kind: String, val data: JsonObject) : ShardStreamEvent
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.core.net
|
||||||
|
|
||||||
|
import com.runicgateway.app.core.auth.SessionManager
|
||||||
|
import com.runicgateway.app.data.api.AuthRefreshApi
|
||||||
|
import com.runicgateway.app.data.api.dto.MobileRefreshRequest
|
||||||
|
import okhttp3.Authenticator
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.Response
|
||||||
|
import okhttp3.Route
|
||||||
|
import java.io.IOException
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transparently refreshes an expired access token on a bearer `401` and replays
|
||||||
|
* the request (PLAN.md §4.1, §4.3). Refresh tokens are single-use and rotated, so
|
||||||
|
* this is serialized behind a mutex: concurrent 401s trigger exactly one refresh
|
||||||
|
* and the losers reuse its result. A refresh that comes back `401` means the
|
||||||
|
* session is truly dead → sign out; a network error leaves the session intact so
|
||||||
|
* a later call can retry.
|
||||||
|
*
|
||||||
|
* The refresh call runs on [AuthRefreshApi] (its own bare client with no
|
||||||
|
* authenticator), so it can never recurse back into here.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class TokenAuthenticator @Inject constructor(
|
||||||
|
private val sessionManager: SessionManager,
|
||||||
|
private val refreshApi: AuthRefreshApi,
|
||||||
|
) : Authenticator {
|
||||||
|
|
||||||
|
private val lock = Any()
|
||||||
|
|
||||||
|
override fun authenticate(route: Route?, response: Response): Request? {
|
||||||
|
val failed = response.request
|
||||||
|
// Credential endpoints (login/refresh) must never be "refreshed".
|
||||||
|
if (failed.header(Http.NO_SESSION_HEADER) != null) return null
|
||||||
|
// Give up after a single refresh+replay to avoid an auth loop.
|
||||||
|
if (priorResponseCount(response) >= 2) return null
|
||||||
|
|
||||||
|
val attemptedAuth = failed.header(Http.AUTHORIZATION)
|
||||||
|
|
||||||
|
synchronized(lock) {
|
||||||
|
// Another thread may have already refreshed while we waited on the lock.
|
||||||
|
val current = sessionManager.currentAccessToken()
|
||||||
|
if (current != null && Http.bearer(current) != attemptedAuth) {
|
||||||
|
return failed.retryWith(current)
|
||||||
|
}
|
||||||
|
|
||||||
|
val refreshToken = sessionManager.currentRefreshToken()
|
||||||
|
?: return null // already signed out
|
||||||
|
|
||||||
|
val refreshed = try {
|
||||||
|
refreshApi.refresh(MobileRefreshRequest(refreshToken)).execute()
|
||||||
|
} catch (_: IOException) {
|
||||||
|
// Transient — surface the original 401 but keep the session.
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
val body = refreshed.body()
|
||||||
|
if (!refreshed.isSuccessful || body == null) {
|
||||||
|
// The refresh token is dead (401/expired/revoked) → session is over.
|
||||||
|
sessionManager.onSignedOut()
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionManager.onRefreshed(body.accessToken, body.refreshToken, body.user)
|
||||||
|
return failed.retryWith(body.accessToken)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Request.retryWith(accessToken: String): Request =
|
||||||
|
newBuilder().header(Http.AUTHORIZATION, Http.bearer(accessToken)).build()
|
||||||
|
|
||||||
|
private fun priorResponseCount(response: Response): Int {
|
||||||
|
var count = 1
|
||||||
|
var prior = response.priorResponse
|
||||||
|
while (prior != null) {
|
||||||
|
count++
|
||||||
|
prior = prior.priorResponse
|
||||||
|
}
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.core.net
|
||||||
|
|
||||||
|
import okhttp3.Interceptor
|
||||||
|
import okhttp3.Response
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets a stable, identifiable User-Agent on every request. The website mounts a
|
||||||
|
* bot/scanner guard ahead of routing (PLAN.md §8); a native client must present
|
||||||
|
* a sane UA so it is not caught by the scanner heuristics that reject blank or
|
||||||
|
* default agents. Constructed with the app's UA string in the network module.
|
||||||
|
*/
|
||||||
|
class UserAgentInterceptor(
|
||||||
|
private val userAgent: String,
|
||||||
|
) : Interceptor {
|
||||||
|
override fun intercept(chain: Interceptor.Chain): Response {
|
||||||
|
val request = chain.request().newBuilder()
|
||||||
|
.header("User-Agent", userAgent)
|
||||||
|
.build()
|
||||||
|
return chain.proceed(request)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.core.prefs
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.datastore.core.DataStore
|
||||||
|
import androidx.datastore.preferences.core.Preferences
|
||||||
|
import androidx.datastore.preferences.core.edit
|
||||||
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
|
import androidx.datastore.preferences.preferencesDataStore
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
private val Context.serverDataStore: DataStore<Preferences> by preferencesDataStore(name = "server")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persists the selected shard website base URL (PLAN.md §3). The base URL is
|
||||||
|
* non-sensitive, so it lives in plain DataStore; tokens (M3) will use
|
||||||
|
* EncryptedSharedPreferences instead, never this store.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class ServerPreferences @Inject constructor(
|
||||||
|
@param:dagger.hilt.android.qualifiers.ApplicationContext private val context: Context,
|
||||||
|
) {
|
||||||
|
private val store = context.serverDataStore
|
||||||
|
|
||||||
|
/** Emits the saved base URL, or null before first-run connect completes. */
|
||||||
|
val baseUrl: Flow<String?> = store.data.map { it[KEY_BASE_URL] }
|
||||||
|
|
||||||
|
suspend fun currentBaseUrl(): String? = baseUrl.first()
|
||||||
|
|
||||||
|
suspend fun setBaseUrl(url: String) {
|
||||||
|
store.edit { it[KEY_BASE_URL] = url }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clears the base URL — used by a Settings → Server switch (hard reset, §3). */
|
||||||
|
suspend fun clear() {
|
||||||
|
store.edit { it.remove(KEY_BASE_URL) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
val KEY_BASE_URL = stringPreferencesKey("base_url")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.core.result
|
||||||
|
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import retrofit2.HttpException
|
||||||
|
import java.io.IOException
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The single result type every repository call returns (PLAN.md §7). The UI
|
||||||
|
* degrades gracefully on a down backend or shard: a repository never throws for
|
||||||
|
* an expected failure, it returns a typed variant the screen can render.
|
||||||
|
*
|
||||||
|
* - [Ok] — a 2xx response with a decoded body.
|
||||||
|
* - [HttpError] — the server answered with a non-2xx status.
|
||||||
|
* - [NetworkError] — the request never got an answer (offline, DNS, TLS, timeout).
|
||||||
|
*/
|
||||||
|
sealed interface ApiResult<out T> {
|
||||||
|
data class Ok<T>(val data: T) : ApiResult<T>
|
||||||
|
data class HttpError(val status: Int, val message: String? = null) : ApiResult<Nothing>
|
||||||
|
data class NetworkError(val cause: Throwable) : ApiResult<Nothing>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True for the "shard/sidecar down" signal the player screens treat as offline (§6.3, §7). */
|
||||||
|
fun ApiResult<*>.isShardUnavailable(): Boolean =
|
||||||
|
this is ApiResult.HttpError && status == 503
|
||||||
|
|
||||||
|
/** Map an [ApiResult.Ok] body while preserving the failure variants unchanged. */
|
||||||
|
inline fun <T, R> ApiResult<T>.map(transform: (T) -> R): ApiResult<R> = when (this) {
|
||||||
|
is ApiResult.Ok -> ApiResult.Ok(transform(data))
|
||||||
|
is ApiResult.HttpError -> this
|
||||||
|
is ApiResult.NetworkError -> this
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run a suspending Retrofit call and normalize every outcome into an [ApiResult].
|
||||||
|
* Coroutine cancellation is rethrown so structured concurrency still works — it
|
||||||
|
* is control flow, not a network failure.
|
||||||
|
*/
|
||||||
|
suspend fun <T> safeApiCall(block: suspend () -> T): ApiResult<T> = try {
|
||||||
|
ApiResult.Ok(block())
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (e: HttpException) {
|
||||||
|
ApiResult.HttpError(e.code(), e.message())
|
||||||
|
} catch (e: IOException) {
|
||||||
|
ApiResult.NetworkError(e)
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.core.web
|
||||||
|
|
||||||
|
import android.content.ActivityNotFoundException
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.browser.customtabs.CustomTabsIntent
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens the website's own pages in a Chrome Custom Tab (PLAN.md §4.2):
|
||||||
|
* registration, invite acceptance, forgot/reset password, and SSO all stay
|
||||||
|
* website-handled, so the app hands off rather than rebuilding those flows. The
|
||||||
|
* user completes them in the browser and returns to sign in natively (§4.1).
|
||||||
|
*/
|
||||||
|
object WebHandoff {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Launch [url] in a Custom Tab. Returns false if no browser could handle it
|
||||||
|
* (extremely rare on Android) so the caller can surface a fallback.
|
||||||
|
*/
|
||||||
|
fun open(context: Context, url: String): Boolean = try {
|
||||||
|
CustomTabsIntent.Builder()
|
||||||
|
.setShowTitle(true)
|
||||||
|
.build()
|
||||||
|
.launchUrl(context, Uri.parse(url))
|
||||||
|
true
|
||||||
|
} catch (_: ActivityNotFoundException) {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.core.web
|
||||||
|
|
||||||
|
import com.runicgateway.app.core.net.BaseUrlHolder
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the website's front-end page paths against the configured base URL,
|
||||||
|
* for the Custom-Tab hand-offs (PLAN.md §4.2). These are the React SPA routes
|
||||||
|
* (mirrored from `website/client` `App.jsx`), not API endpoints. Null before a
|
||||||
|
* shard site is configured.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class WebsiteUrls @Inject constructor(
|
||||||
|
private val baseUrlHolder: BaseUrlHolder,
|
||||||
|
) {
|
||||||
|
private fun resolve(path: String): String? =
|
||||||
|
baseUrlHolder.current?.resolve(path)?.toString()
|
||||||
|
|
||||||
|
/** Create an account on the website. */
|
||||||
|
fun register(): String? = resolve(REGISTER)
|
||||||
|
|
||||||
|
/** Forgot / reset password (the flow built on the backend before app work, §8). */
|
||||||
|
fun forgotPassword(): String? = resolve(FORGOT)
|
||||||
|
|
||||||
|
/** The website login page — carries the SSO provider buttons (§4.2). */
|
||||||
|
fun login(): String? = resolve(LOGIN)
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val REGISTER = "account/register"
|
||||||
|
const val FORGOT = "account/forgot"
|
||||||
|
const val LOGIN = "account/login"
|
||||||
|
}
|
||||||
|
}
|
||||||
40
app/src/main/java/com/runicgateway/app/data/api/AuthApi.kt
Normal file
40
app/src/main/java/com/runicgateway/app/data/api/AuthApi.kt
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.data.api
|
||||||
|
|
||||||
|
import com.runicgateway.app.data.api.dto.MeResponse
|
||||||
|
import com.runicgateway.app.data.api.dto.MobileLoginRequest
|
||||||
|
import com.runicgateway.app.data.api.dto.MobileLogoutRequest
|
||||||
|
import com.runicgateway.app.data.api.dto.MobileTokenResponse
|
||||||
|
import retrofit2.Response
|
||||||
|
import retrofit2.http.Body
|
||||||
|
import retrofit2.http.GET
|
||||||
|
import retrofit2.http.Headers
|
||||||
|
import retrofit2.http.POST
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The native bearer-auth surface (PLAN.md §4.1). Login and logout run on the main
|
||||||
|
* OkHttp client; [com.runicgateway.app.core.net.AuthInterceptor] attaches the
|
||||||
|
* access token to logout + `/auth/me`, and [com.runicgateway.app.core.net.TokenAuthenticator]
|
||||||
|
* transparently refreshes on a `401`.
|
||||||
|
*
|
||||||
|
* Login is tagged [com.runicgateway.app.core.net.Http.NO_SESSION_HEADER] so it
|
||||||
|
* carries no bearer and a credential `401` (bad password / `totpRequired`) is not
|
||||||
|
* misread as an expired session. It returns a raw [Response] so the caller can
|
||||||
|
* inspect the status and parse the `{ totpRequired }` error body.
|
||||||
|
*/
|
||||||
|
interface AuthApi {
|
||||||
|
|
||||||
|
// Literal header value required by Retrofit @Headers; matches Http.NO_SESSION_HEADER.
|
||||||
|
@Headers("X-Runic-No-Session: 1")
|
||||||
|
@POST("api/v1/auth/mobile/login")
|
||||||
|
suspend fun login(@Body body: MobileLoginRequest): Response<MobileTokenResponse>
|
||||||
|
|
||||||
|
@POST("api/v1/auth/mobile/logout")
|
||||||
|
suspend fun logout(@Body body: MobileLogoutRequest): Response<Unit>
|
||||||
|
|
||||||
|
/** Current user — the app's authoritative role source, re-validated on resume (§4.3). */
|
||||||
|
@GET("api/v1/auth/me")
|
||||||
|
suspend fun me(): MeResponse
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.data.api
|
||||||
|
|
||||||
|
import com.runicgateway.app.data.api.dto.MobileRefreshRequest
|
||||||
|
import com.runicgateway.app.data.api.dto.MobileTokenResponse
|
||||||
|
import retrofit2.Call
|
||||||
|
import retrofit2.http.Body
|
||||||
|
import retrofit2.http.Headers
|
||||||
|
import retrofit2.http.POST
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The token-rotation endpoint, isolated onto its own **bare** OkHttp client
|
||||||
|
* (no auth interceptor, no authenticator) so refreshing can never recurse
|
||||||
|
* through the very [com.runicgateway.app.core.net.TokenAuthenticator] that calls
|
||||||
|
* it (PLAN.md §4.3). It is a blocking [Call] because the authenticator runs on an
|
||||||
|
* OkHttp dispatcher thread, outside any coroutine, and executes it synchronously.
|
||||||
|
*
|
||||||
|
* Tagged `NO_SESSION` so it carries no stale bearer.
|
||||||
|
*/
|
||||||
|
interface AuthRefreshApi {
|
||||||
|
|
||||||
|
@Headers("X-Runic-No-Session: 1")
|
||||||
|
@POST("api/v1/auth/mobile/refresh")
|
||||||
|
fun refresh(@Body body: MobileRefreshRequest): Call<MobileTokenResponse>
|
||||||
|
}
|
||||||
55
app/src/main/java/com/runicgateway/app/data/api/MeApi.kt
Normal file
55
app/src/main/java/com/runicgateway/app/data/api/MeApi.kt
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.data.api
|
||||||
|
|
||||||
|
import com.runicgateway.app.data.api.dto.ChangePasswordRequest
|
||||||
|
import com.runicgateway.app.data.api.dto.ChangeUsernameRequest
|
||||||
|
import com.runicgateway.app.data.api.dto.LinkedIdentityDto
|
||||||
|
import com.runicgateway.app.data.api.dto.PlayerAccountDto
|
||||||
|
import com.runicgateway.app.data.api.dto.TotpCodeRequest
|
||||||
|
import com.runicgateway.app.data.api.dto.TotpSetupDto
|
||||||
|
import com.runicgateway.app.data.api.dto.TotpStateDto
|
||||||
|
import com.runicgateway.app.data.api.dto.UsernameResponse
|
||||||
|
import retrofit2.http.Body
|
||||||
|
import retrofit2.http.GET
|
||||||
|
import retrofit2.http.HTTP
|
||||||
|
import retrofit2.http.PATCH
|
||||||
|
import retrofit2.http.POST
|
||||||
|
import retrofit2.http.Path
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The role-agnostic self-service surface (PLAN.md §6.3, §6.4): account, credential
|
||||||
|
* changes, TOTP enrollment, and linked SSO identities under `/auth/me/account*`.
|
||||||
|
* The app calls these regardless of role and never touches `/admin`. Every call
|
||||||
|
* rides the main client, so [com.runicgateway.app.core.net.AuthInterceptor] attaches
|
||||||
|
* the bearer and [com.runicgateway.app.core.net.TokenAuthenticator] refreshes on 401.
|
||||||
|
*/
|
||||||
|
interface MeApi {
|
||||||
|
|
||||||
|
@GET("api/v1/auth/me/account")
|
||||||
|
suspend fun getAccount(): PlayerAccountDto
|
||||||
|
|
||||||
|
@PATCH("api/v1/auth/me/account/username")
|
||||||
|
suspend fun changeUsername(@Body body: ChangeUsernameRequest): UsernameResponse
|
||||||
|
|
||||||
|
@PATCH("api/v1/auth/me/account/password")
|
||||||
|
suspend fun changePassword(@Body body: ChangePasswordRequest): Unit
|
||||||
|
|
||||||
|
@POST("api/v1/auth/me/account/totp/setup")
|
||||||
|
suspend fun totpSetup(): TotpSetupDto
|
||||||
|
|
||||||
|
@POST("api/v1/auth/me/account/totp/enable")
|
||||||
|
suspend fun totpEnable(@Body body: TotpCodeRequest): TotpStateDto
|
||||||
|
|
||||||
|
@POST("api/v1/auth/me/account/totp/disable")
|
||||||
|
suspend fun totpDisable(@Body body: TotpCodeRequest): TotpStateDto
|
||||||
|
|
||||||
|
@GET("api/v1/auth/me/account/identities")
|
||||||
|
suspend fun identities(): List<LinkedIdentityDto>
|
||||||
|
|
||||||
|
// DELETE with no body — a plain @DELETE would suffice, but @HTTP keeps the
|
||||||
|
// path template explicit alongside the provider argument.
|
||||||
|
@HTTP(method = "DELETE", path = "api/v1/auth/me/account/identities/{provider}")
|
||||||
|
suspend fun unlinkIdentity(@Path("provider") provider: String): Unit
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.data.api
|
||||||
|
|
||||||
|
import com.runicgateway.app.data.api.dto.CharProfileDto
|
||||||
|
import com.runicgateway.app.data.api.dto.CreateGameAccountRequest
|
||||||
|
import com.runicgateway.app.data.api.dto.PlayerHouseDto
|
||||||
|
import com.runicgateway.app.data.api.dto.RosterDto
|
||||||
|
import com.runicgateway.app.data.api.dto.ShardLinkDto
|
||||||
|
import com.runicgateway.app.data.api.dto.ShardLinkRequest
|
||||||
|
import com.runicgateway.app.data.api.dto.ShardLinkResultDto
|
||||||
|
import com.runicgateway.app.data.api.dto.VendorSaleDto
|
||||||
|
import com.runicgateway.app.data.api.dto.VendorSnapshotDto
|
||||||
|
import retrofit2.http.Body
|
||||||
|
import retrofit2.http.GET
|
||||||
|
import retrofit2.http.POST
|
||||||
|
import retrofit2.http.Path
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A player's own game data + game-account linking (PLAN.md §6.3), over the
|
||||||
|
* bearer-gated `/player/shard/…` surface. Every read is ownership-checked
|
||||||
|
* server-side; a `503` means the shard/sidecar is down → the UI renders "offline,
|
||||||
|
* retry" (§7). All reads ride the main authed client (bearer + refresh-on-401).
|
||||||
|
*/
|
||||||
|
interface PlayerShardApi {
|
||||||
|
|
||||||
|
/** Confirm an in-game `[link` one-time code, tagging the game account to the user. */
|
||||||
|
@POST("api/v1/player/shard/link")
|
||||||
|
suspend fun link(@Body body: ShardLinkRequest): ShardLinkResultDto
|
||||||
|
|
||||||
|
/** Provision a game account (hybrid signup) and auto-link it to the caller. */
|
||||||
|
@POST("api/v1/player/shard/account")
|
||||||
|
suspend fun createAccount(@Body body: CreateGameAccountRequest): ShardLinkResultDto
|
||||||
|
|
||||||
|
/** The caller's linked game accounts. */
|
||||||
|
@GET("api/v1/player/shard/accounts")
|
||||||
|
suspend fun accounts(): List<ShardLinkDto>
|
||||||
|
|
||||||
|
/** Character roster for a linked account. */
|
||||||
|
@GET("api/v1/player/shard/roster/{account}")
|
||||||
|
suspend fun roster(@Path("account") account: String): RosterDto
|
||||||
|
|
||||||
|
/** A character sheet — only for a character on the caller's linked account. */
|
||||||
|
@GET("api/v1/player/shard/char/{serial}")
|
||||||
|
suspend fun char(@Path("serial") serial: String): CharProfileDto
|
||||||
|
|
||||||
|
/** Player vendors for a linked account. */
|
||||||
|
@GET("api/v1/player/shard/vendors/{account}")
|
||||||
|
suspend fun vendors(@Path("account") account: String): VendorSnapshotDto
|
||||||
|
|
||||||
|
/** Recent player-vendor sales across the caller's linked accounts. */
|
||||||
|
@GET("api/v1/player/shard/sales")
|
||||||
|
suspend fun sales(): List<VendorSaleDto>
|
||||||
|
|
||||||
|
/** The caller's own houses (home/decay status). */
|
||||||
|
@GET("api/v1/player/shard/houses")
|
||||||
|
suspend fun houses(): List<PlayerHouseDto>
|
||||||
|
}
|
||||||
131
app/src/main/java/com/runicgateway/app/data/api/PublicApi.kt
Normal file
131
app/src/main/java/com/runicgateway/app/data/api/PublicApi.kt
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.data.api
|
||||||
|
|
||||||
|
import com.runicgateway.app.data.api.dto.ChampDto
|
||||||
|
import com.runicgateway.app.data.api.dto.ContactRequest
|
||||||
|
import com.runicgateway.app.data.api.dto.ContactResponse
|
||||||
|
import com.runicgateway.app.data.api.dto.EconomySampleDto
|
||||||
|
import com.runicgateway.app.data.api.dto.FeedEventDto
|
||||||
|
import com.runicgateway.app.data.api.dto.GovernorDto
|
||||||
|
import com.runicgateway.app.data.api.dto.GovernorTermDto
|
||||||
|
import com.runicgateway.app.data.api.dto.GuildDto
|
||||||
|
import com.runicgateway.app.data.api.dto.HouseDto
|
||||||
|
import com.runicgateway.app.data.api.dto.OnlineStaffDto
|
||||||
|
import com.runicgateway.app.data.api.dto.PageDto
|
||||||
|
import com.runicgateway.app.data.api.dto.PostDto
|
||||||
|
import com.runicgateway.app.data.api.dto.PresenceDto
|
||||||
|
import com.runicgateway.app.data.api.dto.SettingsDto
|
||||||
|
import com.runicgateway.app.data.api.dto.ShardStatusDto
|
||||||
|
import com.runicgateway.app.data.api.dto.StatusDto
|
||||||
|
import com.runicgateway.app.data.api.dto.WikiCategoryDto
|
||||||
|
import com.runicgateway.app.data.api.dto.WikiPageDto
|
||||||
|
import com.runicgateway.app.data.api.dto.WikiSummaryDto
|
||||||
|
import com.runicgateway.app.data.api.dto.WikiTagDto
|
||||||
|
import retrofit2.http.Body
|
||||||
|
import retrofit2.http.GET
|
||||||
|
import retrofit2.http.POST
|
||||||
|
import retrofit2.http.Path
|
||||||
|
import retrofit2.http.Query
|
||||||
|
import retrofit2.http.Url
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The public (unauthenticated) surface consumed in M1: site status/settings,
|
||||||
|
* news posts, CMS pages, wiki, and the contact form (PLAN.md §6.1). Paths are
|
||||||
|
* relative to the sentinel base host; [com.runicgateway.app.core.net.HostSelectionInterceptor]
|
||||||
|
* retargets them onto the configured shard site. The public shard widgets (§6.2)
|
||||||
|
* are added in M2; auth (§4) and player game data (§6.3) arrive in later milestones.
|
||||||
|
* The live SSE stream (`/public/shard/stream`) is not a Retrofit call — it is
|
||||||
|
* consumed via OkHttp in [com.runicgateway.app.core.net.ShardStreamClient].
|
||||||
|
*/
|
||||||
|
interface PublicApi {
|
||||||
|
|
||||||
|
// ── First-run probe (absolute URL; bypasses host rewriting) ──────────
|
||||||
|
/**
|
||||||
|
* Validates a candidate site during the first-run connect flow (§3). Takes a
|
||||||
|
* fully-qualified URL so the request carries a real host and is left
|
||||||
|
* untouched by the host interceptor — the probe targets the URL the user
|
||||||
|
* just typed, not the (not-yet-configured) base.
|
||||||
|
*/
|
||||||
|
@GET
|
||||||
|
suspend fun probeStatus(@Url absoluteStatusUrl: String): StatusDto
|
||||||
|
|
||||||
|
// ── Site status / settings ───────────────────────────────────────────
|
||||||
|
@GET("api/v1/public/status")
|
||||||
|
suspend fun getStatus(): StatusDto
|
||||||
|
|
||||||
|
@GET("api/v1/public/settings")
|
||||||
|
suspend fun getSettings(): SettingsDto
|
||||||
|
|
||||||
|
// ── News & content ───────────────────────────────────────────────────
|
||||||
|
@GET("api/v1/public/posts/{category}")
|
||||||
|
suspend fun getPosts(@Path("category") category: String): List<PostDto>
|
||||||
|
|
||||||
|
@GET("api/v1/public/posts/{category}/{idOrSlug}")
|
||||||
|
suspend fun getPost(
|
||||||
|
@Path("category") category: String,
|
||||||
|
@Path("idOrSlug") idOrSlug: String,
|
||||||
|
): PostDto
|
||||||
|
|
||||||
|
@GET("api/v1/public/pages/{slug}")
|
||||||
|
suspend fun getPage(@Path("slug") slug: String): PageDto
|
||||||
|
|
||||||
|
// ── Wiki ─────────────────────────────────────────────────────────────
|
||||||
|
@GET("api/v1/public/wiki")
|
||||||
|
suspend fun getWikiPages(
|
||||||
|
@Query("q") query: String? = null,
|
||||||
|
@Query("category") category: String? = null,
|
||||||
|
@Query("tag") tag: String? = null,
|
||||||
|
): List<WikiSummaryDto>
|
||||||
|
|
||||||
|
@GET("api/v1/public/wiki/categories")
|
||||||
|
suspend fun getWikiCategories(): List<WikiCategoryDto>
|
||||||
|
|
||||||
|
@GET("api/v1/public/wiki/tags")
|
||||||
|
suspend fun getWikiTags(): List<WikiTagDto>
|
||||||
|
|
||||||
|
@GET("api/v1/public/wiki/{slug}")
|
||||||
|
suspend fun getWikiPage(@Path("slug") slug: String): WikiPageDto
|
||||||
|
|
||||||
|
// ── Contact ──────────────────────────────────────────────────────────
|
||||||
|
@POST("api/v1/public/contact")
|
||||||
|
suspend fun postContact(@Body body: ContactRequest): ContactResponse
|
||||||
|
|
||||||
|
// ── Public shard widgets (§6.2) ──────────────────────────────────────
|
||||||
|
@GET("api/v1/public/shard/status")
|
||||||
|
suspend fun getShardStatus(): ShardStatusDto
|
||||||
|
|
||||||
|
@GET("api/v1/public/shard/feed")
|
||||||
|
suspend fun getShardFeed(
|
||||||
|
@Query("kind") kind: String? = null,
|
||||||
|
@Query("limit") limit: Int? = null,
|
||||||
|
): List<FeedEventDto>
|
||||||
|
|
||||||
|
@GET("api/v1/public/shard/economy")
|
||||||
|
suspend fun getShardEconomy(@Query("limit") limit: Int? = null): List<EconomySampleDto>
|
||||||
|
|
||||||
|
@GET("api/v1/public/shard/online")
|
||||||
|
suspend fun getShardOnline(): List<OnlineStaffDto>
|
||||||
|
|
||||||
|
@GET("api/v1/public/shard/presence")
|
||||||
|
suspend fun getShardPresence(): PresenceDto
|
||||||
|
|
||||||
|
@GET("api/v1/public/shard/champs")
|
||||||
|
suspend fun getShardChamps(): List<ChampDto>
|
||||||
|
|
||||||
|
@GET("api/v1/public/shard/guilds")
|
||||||
|
suspend fun getShardGuilds(): List<GuildDto>
|
||||||
|
|
||||||
|
@GET("api/v1/public/shard/governors")
|
||||||
|
suspend fun getShardGovernors(): List<GovernorDto>
|
||||||
|
|
||||||
|
@GET("api/v1/public/shard/governors/{city}/history")
|
||||||
|
suspend fun getShardGovernorHistory(
|
||||||
|
@Path("city") city: String,
|
||||||
|
@Query("limit") limit: Int? = null,
|
||||||
|
): List<GovernorTermDto>
|
||||||
|
|
||||||
|
@GET("api/v1/public/shard/houses")
|
||||||
|
suspend fun getShardHouses(): List<HouseDto>
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.data.api.dto
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The role-agnostic self-service ("me") wire shapes (PLAN.md §6.3, §6.4). Field
|
||||||
|
* names match the backend's `account.controller` handlers exactly, surfaced for
|
||||||
|
* the app under `/auth/me/account*`. Every DTO ignores unknown keys (NetworkModule's
|
||||||
|
* lenient Json), so additive backend fields are safe (recorded for M1).
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** `GET /auth/me/account` — the current account (any role). */
|
||||||
|
@Serializable
|
||||||
|
data class PlayerAccountDto(
|
||||||
|
val id: Long = 0,
|
||||||
|
val username: String = "",
|
||||||
|
val role: String = "",
|
||||||
|
val email: String? = null,
|
||||||
|
val status: String? = null,
|
||||||
|
val totp_enabled: Boolean = false,
|
||||||
|
/** False for an SSO-provisioned account that has not set a password yet. */
|
||||||
|
val has_password: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** `PATCH /auth/me/account/username` body. */
|
||||||
|
@Serializable
|
||||||
|
data class ChangeUsernameRequest(val username: String)
|
||||||
|
|
||||||
|
/** The `{ username }` returned by a successful username change. */
|
||||||
|
@Serializable
|
||||||
|
data class UsernameResponse(val username: String = "")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `PATCH /auth/me/account/password` body. [currentPassword] is omitted only for an
|
||||||
|
* SSO-provisioned account setting its initial password (has_password == false).
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class ChangePasswordRequest(
|
||||||
|
val newPassword: String,
|
||||||
|
val currentPassword: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Enrollment material from `POST /auth/me/account/totp/setup`. */
|
||||||
|
@Serializable
|
||||||
|
data class TotpSetupDto(
|
||||||
|
val otpauthUrl: String? = null,
|
||||||
|
/** QR code as a `data:image/png;base64,…` URL. */
|
||||||
|
val qr: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** `POST /auth/me/account/totp/enable|disable` body — a current authenticator code. */
|
||||||
|
@Serializable
|
||||||
|
data class TotpCodeRequest(val code: String)
|
||||||
|
|
||||||
|
/** Result of enabling/disabling 2FA. */
|
||||||
|
@Serializable
|
||||||
|
data class TotpStateDto(val totp_enabled: Boolean = false)
|
||||||
|
|
||||||
|
/** A linked external identity (`GET /auth/me/account/identities`). */
|
||||||
|
@Serializable
|
||||||
|
data class LinkedIdentityDto(
|
||||||
|
val provider: String = "",
|
||||||
|
val email: String? = null,
|
||||||
|
val linked_at: String? = null,
|
||||||
|
)
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.data.api.dto
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The mobile bearer-auth wire shapes (PLAN.md §4.1). Field names match the
|
||||||
|
* backend's `auth/mobile` controller and `/auth/me` exactly; every DTO ignores
|
||||||
|
* unknown keys (NetworkModule's lenient Json), so additive backend fields are
|
||||||
|
* safe (§8, recorded for M1).
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** `POST /auth/mobile/login` body. [code] is only sent on the 2FA retry. */
|
||||||
|
@Serializable
|
||||||
|
data class MobileLoginRequest(
|
||||||
|
val username: String,
|
||||||
|
val password: String,
|
||||||
|
val code: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** `POST /auth/mobile/refresh` body. */
|
||||||
|
@Serializable
|
||||||
|
data class MobileRefreshRequest(val refreshToken: String)
|
||||||
|
|
||||||
|
/** `POST /auth/mobile/logout` body — revoke this session or (with [all]) every session. */
|
||||||
|
@Serializable
|
||||||
|
data class MobileLogoutRequest(
|
||||||
|
val refreshToken: String? = null,
|
||||||
|
val all: Boolean? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Success payload from login and refresh: the token pair, the access lifetime
|
||||||
|
* (a zeit/ms duration string, e.g. "15m"), and the safe (secret-stripped) user.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class MobileTokenResponse(
|
||||||
|
val accessToken: String,
|
||||||
|
val refreshToken: String,
|
||||||
|
val expiresIn: String? = null,
|
||||||
|
val user: SafeUserDto,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** The minimal, non-sensitive user the app needs to render + gate the menu (§5). */
|
||||||
|
@Serializable
|
||||||
|
data class SafeUserDto(
|
||||||
|
val id: Long,
|
||||||
|
val username: String,
|
||||||
|
val role: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** `GET /auth/me` envelope — the role source, re-validated on resume (§4.3). */
|
||||||
|
@Serializable
|
||||||
|
data class MeResponse(val user: SafeUserDto)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `401 { totpRequired: true }` body the single-request 2FA flow returns when
|
||||||
|
* an account has TOTP on and no/invalid code accompanied the login (§4.1). Parsed
|
||||||
|
* from the error body since it is not a 2xx response.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class TotpRequiredError(
|
||||||
|
val totpRequired: Boolean = false,
|
||||||
|
val message: String? = null,
|
||||||
|
)
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.data.api.dto
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
/** `POST /public/contact` request body. */
|
||||||
|
@Serializable
|
||||||
|
data class ContactRequest(
|
||||||
|
val name: String,
|
||||||
|
val email: String,
|
||||||
|
val message: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `POST /public/contact` response (website `mailer.sendContactMessage`). Either
|
||||||
|
* `{ sent: true }`, or `{ sent: false, fallback: "mailto", email }` when the
|
||||||
|
* site has no mailer configured and the user should email directly instead.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class ContactResponse(
|
||||||
|
val sent: Boolean = false,
|
||||||
|
val fallback: String? = null,
|
||||||
|
val email: String? = null,
|
||||||
|
)
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.data.api.dto
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `GET /public/pages/:slug` — a block-based CMS page (website `pages.model.js`
|
||||||
|
* `serialize`). Everything block-specific lives inside `props`; the renderer
|
||||||
|
* dispatches on `type`. `props` is left as a raw JSON object so new block types
|
||||||
|
* or props never break decoding — the renderer reads the keys it knows and
|
||||||
|
* ignores the rest.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class PageDto(
|
||||||
|
val id: Long,
|
||||||
|
val slug: String = "",
|
||||||
|
val title: String = "",
|
||||||
|
val status: String = "",
|
||||||
|
val blocks: List<BlockDto> = emptyList(),
|
||||||
|
@SerialName("publishedAt") val publishedAt: String? = null,
|
||||||
|
@SerialName("updatedAt") val updatedAt: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One CMS block. Known types (website `src/blocks/types`): `heading`,
|
||||||
|
* `rich_text`, `image`, `quote`, `cta`, `divider`, `two_column`. Container
|
||||||
|
* blocks (`two_column`) hold sub-block arrays inside their props.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class BlockDto(
|
||||||
|
val type: String = "",
|
||||||
|
val props: JsonObject = JsonObject(emptyMap()),
|
||||||
|
val visible: Boolean = true,
|
||||||
|
)
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.data.api.dto
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTOs for a player's OWN game data (PLAN.md §6.3), read over the bearer-gated
|
||||||
|
* `/player/shard/…` surface. The roster / char / vendor reads return the sidecar
|
||||||
|
* payload verbatim (a permissive object), so only the fields the app renders are
|
||||||
|
* modeled — unknown keys are ignored by the JSON parser (matching the website's
|
||||||
|
* `CharacterSheet.jsx` / `GameAccounts.jsx` and `docs/link/INTEGRATION.md` §5).
|
||||||
|
* Presentation is text-only for v1 (no item icons / paperdoll).
|
||||||
|
*
|
||||||
|
* In-game serials are hex strings (e.g. "0x24C"), unlike the numeric serials on
|
||||||
|
* the public boards — these are separate endpoints with separate shapes.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ── Game-account linking ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** `GET /player/shard/accounts` — a linked in-game account. */
|
||||||
|
@Serializable
|
||||||
|
data class ShardLinkDto(
|
||||||
|
val account: String = "",
|
||||||
|
val userId: Long? = null,
|
||||||
|
val charName: String? = null,
|
||||||
|
val linkedAt: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** `POST /player/shard/link` body — the one-time code shown by `[link` in game. */
|
||||||
|
@Serializable
|
||||||
|
data class ShardLinkRequest(val code: String)
|
||||||
|
|
||||||
|
/** `POST /player/shard/link` result — the confirmed link. */
|
||||||
|
@Serializable
|
||||||
|
data class ShardLinkResultDto(
|
||||||
|
val linked: Boolean = false,
|
||||||
|
val account: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** `POST /player/shard/account` (hybrid signup) body. */
|
||||||
|
@Serializable
|
||||||
|
data class CreateGameAccountRequest(
|
||||||
|
val account: String,
|
||||||
|
val password: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Character roster + sheet ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** `GET /player/shard/roster/:account` — the account's characters (incl. offline). */
|
||||||
|
@Serializable
|
||||||
|
data class RosterDto(
|
||||||
|
val acct: String? = null,
|
||||||
|
val chars: List<RosterCharDto> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** One character in a roster; the picker fetches the full sheet on demand. */
|
||||||
|
@Serializable
|
||||||
|
data class RosterCharDto(
|
||||||
|
val slot: Int? = null,
|
||||||
|
val serial: String = "",
|
||||||
|
val name: String? = null,
|
||||||
|
val body: Int? = null,
|
||||||
|
val online: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `GET /player/shard/char/:serial` — a character sheet. `guild` / `governorOf` are
|
||||||
|
* best-effort cross-links the backend decorates in (never fail the sheet).
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class CharProfileDto(
|
||||||
|
val serial: String? = null,
|
||||||
|
val name: String? = null,
|
||||||
|
val title: String? = null,
|
||||||
|
val online: Boolean = false,
|
||||||
|
val acct: String? = null,
|
||||||
|
val stats: CharStatsDto? = null,
|
||||||
|
val skills: List<SkillDto> = emptyList(),
|
||||||
|
val equipment: List<EquipmentDto> = emptyList(),
|
||||||
|
val titles: TitlesDto? = null,
|
||||||
|
val guild: GuildRefDto? = null,
|
||||||
|
val governorOf: List<String> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class CharStatsDto(
|
||||||
|
val str: Int? = null,
|
||||||
|
val dex: Int? = null,
|
||||||
|
val int: Int? = null,
|
||||||
|
val hits: Int? = null,
|
||||||
|
val hitsMax: Int? = null,
|
||||||
|
val mana: Int? = null,
|
||||||
|
val manaMax: Int? = null,
|
||||||
|
val stam: Int? = null,
|
||||||
|
val stamMax: Int? = null,
|
||||||
|
val resist: ResistDto? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ResistDto(
|
||||||
|
val phys: Int? = null,
|
||||||
|
val fire: Int? = null,
|
||||||
|
val cold: Int? = null,
|
||||||
|
val pois: Int? = null,
|
||||||
|
val energy: Int? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A skill line. `base` is the trained value, `value` includes item/temp bonuses,
|
||||||
|
* `cap` is the cap — do NOT assume base ≤ cap (GM chars exceed it). Doubles, as the
|
||||||
|
* shard reports tenths.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class SkillDto(
|
||||||
|
val n: String? = null,
|
||||||
|
val base: Double? = null,
|
||||||
|
val value: Double? = null,
|
||||||
|
val cap: Double? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An equipped item. Names are usually clilocs (numeric), not strings, and the app
|
||||||
|
* ships no cliloc table, so the text-only sheet renders layer + id + hue + mods.
|
||||||
|
* [mods] is a flattened map of non-zero AOS attributes (empty for plain items);
|
||||||
|
* kept as a raw object since values may be numbers or strings.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class EquipmentDto(
|
||||||
|
val serial: String? = null,
|
||||||
|
val layer: String? = null,
|
||||||
|
val itemId: Int? = null,
|
||||||
|
val hue: Int? = null,
|
||||||
|
val mods: JsonObject? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display titles (Protocol 2.0). `selected` is the index into `reward` currently
|
||||||
|
* shown (-1 if none); `reward` entries may be a cliloc number-as-string or a
|
||||||
|
* literal — numeric ones are skipped without a cliloc table (as the website does).
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class TitlesDto(
|
||||||
|
val selected: Int? = null,
|
||||||
|
val reward: List<String> = emptyList(),
|
||||||
|
val fameKarma: String? = null,
|
||||||
|
val skill: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** The guild a character leads (cross-linked from board data). */
|
||||||
|
@Serializable
|
||||||
|
data class GuildRefDto(
|
||||||
|
val name: String? = null,
|
||||||
|
val abbr: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Player vendors + sales ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** `GET /player/shard/vendors/:account` — every player vendor on the account. */
|
||||||
|
@Serializable
|
||||||
|
data class VendorSnapshotDto(
|
||||||
|
val acct: String? = null,
|
||||||
|
val vendors: List<VendorDto> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class VendorDto(
|
||||||
|
val serial: String? = null,
|
||||||
|
val shopName: String? = null,
|
||||||
|
val holdGold: Long? = null,
|
||||||
|
val ownerSerial: String? = null,
|
||||||
|
val map: String? = null,
|
||||||
|
val x: Int? = null,
|
||||||
|
val y: Int? = null,
|
||||||
|
val listings: List<VendorListingDto> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** A single vendor listing. Item names are clilocs (see [EquipmentDto]); text-only. */
|
||||||
|
@Serializable
|
||||||
|
data class VendorListingDto(
|
||||||
|
val serial: String? = null,
|
||||||
|
val itemId: Int? = null,
|
||||||
|
val amount: Int? = null,
|
||||||
|
val price: Long? = null,
|
||||||
|
val forSale: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** `GET /player/shard/sales` — a player-vendor sale, visible only to the owner. */
|
||||||
|
@Serializable
|
||||||
|
data class VendorSaleDto(
|
||||||
|
/** Sale time, epoch ms. */
|
||||||
|
val t: Long? = null,
|
||||||
|
val itemType: String? = null,
|
||||||
|
val amount: Int? = null,
|
||||||
|
val price: Long? = null,
|
||||||
|
val commission: Int? = null,
|
||||||
|
val ownerAcct: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Player houses ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `GET /player/shard/houses` — the caller's OWN houses, with full decay/IDOC
|
||||||
|
* detail (their own property). Serial is a hex string here.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class PlayerHouseDto(
|
||||||
|
val serial: String = "",
|
||||||
|
val stage: String? = null,
|
||||||
|
val map: String? = null,
|
||||||
|
val x: Int? = null,
|
||||||
|
val y: Int? = null,
|
||||||
|
val z: Int? = null,
|
||||||
|
val region: String? = null,
|
||||||
|
val name: String? = null,
|
||||||
|
val ownerSerial: String? = null,
|
||||||
|
val ownerAcct: String? = null,
|
||||||
|
val builtOn: String? = null,
|
||||||
|
val lastRefreshed: String? = null,
|
||||||
|
val isIdoc: Boolean = false,
|
||||||
|
val updatedAt: String? = null,
|
||||||
|
)
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.data.api.dto
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `GET /public/posts/:category` (list) and `/public/posts/:category/:idOrSlug`
|
||||||
|
* (detail). One shape serves both; the list omits nothing the app renders. The
|
||||||
|
* `body` (HTML) is present on the detail response and rendered there.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class PostDto(
|
||||||
|
val id: Long,
|
||||||
|
/** Stored DB category (`news | five_on_friday | newsletter | screenshot`). */
|
||||||
|
val category: String = "",
|
||||||
|
val title: String = "",
|
||||||
|
val slug: String? = null,
|
||||||
|
val excerpt: String? = null,
|
||||||
|
val body: String? = null,
|
||||||
|
@SerialName("image_url") val imageUrl: String? = null,
|
||||||
|
@SerialName("published_at") val publishedAt: String? = null,
|
||||||
|
@SerialName("created_at") val createdAt: String? = null,
|
||||||
|
)
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.data.api.dto
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTOs for the public site/identity endpoints. Shapes mirror the backend
|
||||||
|
* responses (website `public.controller.js` + `settings.model.js`); unknown
|
||||||
|
* keys are ignored by the JSON parser so additive backend fields never break
|
||||||
|
* decoding (PLAN.md §8 "additive, v1").
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** `GET /public/version` and the `version` block embedded in `/public/status`. */
|
||||||
|
@Serializable
|
||||||
|
data class VersionDto(
|
||||||
|
val service: String = "",
|
||||||
|
val api: String = "",
|
||||||
|
val server: String = "",
|
||||||
|
)
|
||||||
|
|
||||||
|
/** `GET /public/status` — site mode + version for the first-run probe (§3). */
|
||||||
|
@Serializable
|
||||||
|
data class StatusDto(
|
||||||
|
val mode: String = "live",
|
||||||
|
@SerialName("status_message") val statusMessage: String = "",
|
||||||
|
val version: VersionDto = VersionDto(),
|
||||||
|
) {
|
||||||
|
val isMaintenance: Boolean get() = mode.equals("maintenance", ignoreCase = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Per-shard branding block the app themes itself from (§3, §6.1). */
|
||||||
|
@Serializable
|
||||||
|
data class BrandDto(
|
||||||
|
val name: String = "",
|
||||||
|
val shortName: String = "",
|
||||||
|
val tagline: String = "",
|
||||||
|
val description: String? = null,
|
||||||
|
val contactEmail: String = "",
|
||||||
|
val url: String = "",
|
||||||
|
/** Seed/accent color as a hex string, e.g. "#7f99bd". */
|
||||||
|
val accent: String = "",
|
||||||
|
/** Asset URL or site-relative path; empty = none. Resolve against the base URL. */
|
||||||
|
val logo: String = "",
|
||||||
|
val hero: String = "",
|
||||||
|
val favicon: String = "",
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Derived, public-safe registration availability flags. */
|
||||||
|
@Serializable
|
||||||
|
data class RegistrationFlagsDto(
|
||||||
|
val password: Boolean = false,
|
||||||
|
val sso: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `GET /public/settings` — whitelisted settings + branding. Only the keys the
|
||||||
|
* app consumes are modeled; other whitelisted keys are ignored.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class SettingsDto(
|
||||||
|
@SerialName("site_title") val siteTitle: String? = null,
|
||||||
|
@SerialName("status_message") val statusMessage: String? = null,
|
||||||
|
@SerialName("maintenance_message") val maintenanceMessage: String? = null,
|
||||||
|
val registration: RegistrationFlagsDto = RegistrationFlagsDto(),
|
||||||
|
val gameAccountSignup: Boolean = false,
|
||||||
|
val brand: BrandDto = BrandDto(),
|
||||||
|
)
|
||||||
169
app/src/main/java/com/runicgateway/app/data/api/dto/ShardDto.kt
Normal file
169
app/src/main/java/com/runicgateway/app/data/api/dto/ShardDto.kt
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
/*
|
||||||
|
* 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 the public shard widgets (PLAN.md §6.2). Shapes mirror the website's
|
||||||
|
* `public/shard.controller.js` responses and the live SSE frames emitted by
|
||||||
|
* `utils/shardBroadcast.js`. The champ/guild/governor board reads return the
|
||||||
|
* stored event payload verbatim (a permissive object), so only the fields the app
|
||||||
|
* renders are modeled; unknown keys are ignored by the JSON parser, and the live
|
||||||
|
* `*.update` frames on `/public/shard/stream` decode into these same DTOs.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** A game actor (player/leader/governor) as embedded in board payloads. */
|
||||||
|
@Serializable
|
||||||
|
data class ActorDto(
|
||||||
|
val serial: Long? = null,
|
||||||
|
val name: String? = null,
|
||||||
|
val acct: String? = null,
|
||||||
|
val webId: Long? = null,
|
||||||
|
) {
|
||||||
|
/** Best display label for this actor. */
|
||||||
|
val label: String get() = name ?: acct ?: "Someone"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A gold-supply sample (`economy.supply`), oldest → newest in the series. */
|
||||||
|
@Serializable
|
||||||
|
data class EconomySampleDto(
|
||||||
|
val accounts: Int? = null,
|
||||||
|
val gold: Double? = null,
|
||||||
|
val t: Long? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** `GET /public/shard/status` — connection state + online count + latest economy. */
|
||||||
|
@Serializable
|
||||||
|
data class ShardStatusDto(
|
||||||
|
val enabled: Boolean = false,
|
||||||
|
/** Sidecar link state: `connected` / `disconnected` / … */
|
||||||
|
val status: String? = null,
|
||||||
|
/** Whether the in-game plugin is currently connected to the sidecar. */
|
||||||
|
val pluginConnected: Boolean = false,
|
||||||
|
/** ISO timestamp of the last ingested event, or null. */
|
||||||
|
val lastEventAt: String? = null,
|
||||||
|
val onlineCount: Int = 0,
|
||||||
|
val economy: EconomySampleDto? = null,
|
||||||
|
) {
|
||||||
|
/** True when the shard is live (link enabled and the plugin is connected). */
|
||||||
|
val isOnline: Boolean get() = enabled && pluginConnected
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `GET /public/shard/feed` — a stored notable event. The domain fields live under
|
||||||
|
* [payload]; the live SSE frames carry those same fields at the top level (see
|
||||||
|
* `ShardEventText`).
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class FeedEventDto(
|
||||||
|
val id: Long = 0,
|
||||||
|
val kind: String = "",
|
||||||
|
val t: Long? = null,
|
||||||
|
val bootId: String? = null,
|
||||||
|
val payload: JsonObject? = null,
|
||||||
|
val createdAt: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `GET /public/shard/online` — a staff member currently in-world. Location is only
|
||||||
|
* present for privileged viewers server-side; anonymous/app callers see name+serial.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class OnlineStaffDto(
|
||||||
|
val serial: Long? = null,
|
||||||
|
val name: String? = null,
|
||||||
|
val map: String? = null,
|
||||||
|
val x: Int? = null,
|
||||||
|
val y: Int? = null,
|
||||||
|
val z: Int? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A house on the public IDOC board (`GET /public/shard/houses`) — location only.
|
||||||
|
* Owner/price/decay detail is staff-only and never reaches the app.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class HouseDto(
|
||||||
|
val serial: Long = 0,
|
||||||
|
val name: String? = null,
|
||||||
|
val region: String? = null,
|
||||||
|
val map: String? = null,
|
||||||
|
val x: Int? = null,
|
||||||
|
val y: Int? = null,
|
||||||
|
val z: Int? = null,
|
||||||
|
val isIdoc: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A champion-spawn board entry (`GET /public/shard/champs` + live `champ.update`).
|
||||||
|
* Three families share the board (`category`: champion / mini / sea); the
|
||||||
|
* category-specific fields are all nullable.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class ChampDto(
|
||||||
|
val serial: Long = 0,
|
||||||
|
val category: String? = null,
|
||||||
|
val type: String? = null,
|
||||||
|
val name: String? = null,
|
||||||
|
val status: String? = null,
|
||||||
|
val active: Boolean = false,
|
||||||
|
val map: String? = null,
|
||||||
|
val x: Int? = null,
|
||||||
|
val y: Int? = null,
|
||||||
|
val z: Int? = null,
|
||||||
|
val bossUp: Boolean = false,
|
||||||
|
val boss: String? = null,
|
||||||
|
val level: Int? = null,
|
||||||
|
val maxLevel: Int? = null,
|
||||||
|
val kills: Int? = null,
|
||||||
|
val maxKills: Int? = null,
|
||||||
|
val hits: Long? = null,
|
||||||
|
val hitsMax: Long? = null,
|
||||||
|
val restartAt: String? = null,
|
||||||
|
val t: Long? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** A guild board entry (`GET /public/shard/guilds` + live `guild.update`). */
|
||||||
|
@Serializable
|
||||||
|
data class GuildDto(
|
||||||
|
val id: Long = 0,
|
||||||
|
val name: String? = null,
|
||||||
|
val abbr: String? = null,
|
||||||
|
val members: Int? = null,
|
||||||
|
val online: Int? = null,
|
||||||
|
val alliance: String? = null,
|
||||||
|
val leader: ActorDto? = null,
|
||||||
|
val t: Long? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** A town-governor board entry (`GET /public/shard/governors` + live `city.update`). */
|
||||||
|
@Serializable
|
||||||
|
data class GovernorDto(
|
||||||
|
val city: String = "",
|
||||||
|
val governor: ActorDto? = null,
|
||||||
|
val governorElect: ActorDto? = null,
|
||||||
|
val electionPhase: String? = null,
|
||||||
|
val t: Long? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** One term in a city's governor ledger (`GET /public/shard/governors/:city/history`). */
|
||||||
|
@Serializable
|
||||||
|
data class GovernorTermDto(
|
||||||
|
val city: String? = null,
|
||||||
|
val governor: ActorDto? = null,
|
||||||
|
val startedAt: Long? = null,
|
||||||
|
val endedAt: Long? = null,
|
||||||
|
val votes: Int? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** `GET /public/shard/presence` — the online-population aggregate (live `presence.online`). */
|
||||||
|
@Serializable
|
||||||
|
data class PresenceDto(
|
||||||
|
val count: Int = 0,
|
||||||
|
val byFacet: Map<String, Int> = emptyMap(),
|
||||||
|
val byRegion: Map<String, Int> = emptyMap(),
|
||||||
|
val t: Long? = null,
|
||||||
|
)
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.data.api.dto
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wiki DTOs (website `wiki.model.js` / `wiki.db.js`). The list route returns
|
||||||
|
* lighter summary rows (no body); the detail route returns the full page plus
|
||||||
|
* its tags, backlinks, and unresolved ("red") link targets.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** `GET /public/wiki` — a summary row (body omitted). */
|
||||||
|
@Serializable
|
||||||
|
data class WikiSummaryDto(
|
||||||
|
val id: Long,
|
||||||
|
val slug: String = "",
|
||||||
|
val title: String = "",
|
||||||
|
val excerpt: String? = null,
|
||||||
|
@SerialName("category_slug") val categorySlug: String? = null,
|
||||||
|
@SerialName("category_title") val categoryTitle: String? = null,
|
||||||
|
@SerialName("updated_at") val updatedAt: String? = null,
|
||||||
|
@SerialName("published_at") val publishedAt: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** `GET /public/wiki/:slug` — full page + tags/backlinks. */
|
||||||
|
@Serializable
|
||||||
|
data class WikiPageDto(
|
||||||
|
val id: Long,
|
||||||
|
val slug: String = "",
|
||||||
|
val title: String = "",
|
||||||
|
val body: String? = null,
|
||||||
|
val excerpt: String? = null,
|
||||||
|
@SerialName("category_slug") val categorySlug: String? = null,
|
||||||
|
@SerialName("category_title") val categoryTitle: String? = null,
|
||||||
|
@SerialName("updated_at") val updatedAt: String? = null,
|
||||||
|
@SerialName("published_at") val publishedAt: String? = null,
|
||||||
|
val tags: List<WikiTagRefDto> = emptyList(),
|
||||||
|
val backlinks: List<WikiBacklinkDto> = emptyList(),
|
||||||
|
@SerialName("missing_links") val missingLinks: List<String> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** A tag as attached to a page (slug + label only). */
|
||||||
|
@Serializable
|
||||||
|
data class WikiTagRefDto(
|
||||||
|
val slug: String = "",
|
||||||
|
val label: String = "",
|
||||||
|
)
|
||||||
|
|
||||||
|
/** A page that links to the current page. */
|
||||||
|
@Serializable
|
||||||
|
data class WikiBacklinkDto(
|
||||||
|
val slug: String = "",
|
||||||
|
val title: String = "",
|
||||||
|
)
|
||||||
|
|
||||||
|
/** `GET /public/wiki/categories`. */
|
||||||
|
@Serializable
|
||||||
|
data class WikiCategoryDto(
|
||||||
|
val id: Long,
|
||||||
|
val slug: String = "",
|
||||||
|
val title: String = "",
|
||||||
|
val description: String? = null,
|
||||||
|
@SerialName("published_count") val publishedCount: Long = 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** `GET /public/wiki/tags`. */
|
||||||
|
@Serializable
|
||||||
|
data class WikiTagDto(
|
||||||
|
val id: Long,
|
||||||
|
val slug: String = "",
|
||||||
|
val label: String = "",
|
||||||
|
@SerialName("published_count") val publishedCount: Long = 0,
|
||||||
|
)
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.data.repository
|
||||||
|
|
||||||
|
import com.runicgateway.app.core.result.ApiResult
|
||||||
|
import com.runicgateway.app.core.result.safeApiCall
|
||||||
|
import com.runicgateway.app.data.api.MeApi
|
||||||
|
import com.runicgateway.app.data.api.dto.ChangePasswordRequest
|
||||||
|
import com.runicgateway.app.data.api.dto.ChangeUsernameRequest
|
||||||
|
import com.runicgateway.app.data.api.dto.LinkedIdentityDto
|
||||||
|
import com.runicgateway.app.data.api.dto.PlayerAccountDto
|
||||||
|
import com.runicgateway.app.data.api.dto.TotpCodeRequest
|
||||||
|
import com.runicgateway.app.data.api.dto.TotpSetupDto
|
||||||
|
import com.runicgateway.app.data.api.dto.TotpStateDto
|
||||||
|
import com.runicgateway.app.data.api.dto.UsernameResponse
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Self-service account management over the role-agnostic `/auth/me/account*`
|
||||||
|
* surface (PLAN.md §6.3, §6.4). Every call returns a typed [ApiResult] so the
|
||||||
|
* screens can map known statuses (409 taken, 400 wrong password / invalid code,
|
||||||
|
* 429 rate-limited) to friendly copy without a repository ever throwing (§7).
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class AccountRepository @Inject constructor(
|
||||||
|
private val api: MeApi,
|
||||||
|
) {
|
||||||
|
suspend fun getAccount(): ApiResult<PlayerAccountDto> = safeApiCall { api.getAccount() }
|
||||||
|
|
||||||
|
suspend fun changeUsername(username: String): ApiResult<UsernameResponse> =
|
||||||
|
safeApiCall { api.changeUsername(ChangeUsernameRequest(username)) }
|
||||||
|
|
||||||
|
/** [currentPassword] is null only for an SSO account setting its first password. */
|
||||||
|
suspend fun changePassword(newPassword: String, currentPassword: String?): ApiResult<Unit> =
|
||||||
|
safeApiCall { api.changePassword(ChangePasswordRequest(newPassword, currentPassword)) }
|
||||||
|
|
||||||
|
suspend fun totpSetup(): ApiResult<TotpSetupDto> = safeApiCall { api.totpSetup() }
|
||||||
|
|
||||||
|
suspend fun totpEnable(code: String): ApiResult<TotpStateDto> =
|
||||||
|
safeApiCall { api.totpEnable(TotpCodeRequest(code)) }
|
||||||
|
|
||||||
|
suspend fun totpDisable(code: String): ApiResult<TotpStateDto> =
|
||||||
|
safeApiCall { api.totpDisable(TotpCodeRequest(code)) }
|
||||||
|
|
||||||
|
suspend fun identities(): ApiResult<List<LinkedIdentityDto>> = safeApiCall { api.identities() }
|
||||||
|
|
||||||
|
suspend fun unlinkIdentity(provider: String): ApiResult<Unit> =
|
||||||
|
safeApiCall { api.unlinkIdentity(provider) }
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.data.repository
|
||||||
|
|
||||||
|
import com.runicgateway.app.core.auth.SessionManager
|
||||||
|
import com.runicgateway.app.data.api.AuthApi
|
||||||
|
import com.runicgateway.app.data.api.dto.MobileLoginRequest
|
||||||
|
import com.runicgateway.app.data.api.dto.MobileLogoutRequest
|
||||||
|
import com.runicgateway.app.data.api.dto.MobileTokenResponse
|
||||||
|
import com.runicgateway.app.data.api.dto.TotpRequiredError
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import retrofit2.Response
|
||||||
|
import java.io.IOException
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Native username/password (+TOTP) auth — the app's only native credential flow
|
||||||
|
* (PLAN.md §4.1). It drives the [SessionManager]: a successful login establishes
|
||||||
|
* the session; logout revokes it. Registration/invite/reset/SSO are website
|
||||||
|
* hand-offs (§4.2), not here.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class AuthRepository @Inject constructor(
|
||||||
|
private val authApi: AuthApi,
|
||||||
|
private val sessionManager: SessionManager,
|
||||||
|
private val json: Json,
|
||||||
|
) {
|
||||||
|
|
||||||
|
/** Outcome of a login attempt (§4.1). */
|
||||||
|
sealed interface LoginResult {
|
||||||
|
data object Success : LoginResult
|
||||||
|
|
||||||
|
/** The account has 2FA on — reveal the code field and resubmit with a code. */
|
||||||
|
data object TotpRequired : LoginResult
|
||||||
|
data object InvalidCredentials : LoginResult
|
||||||
|
|
||||||
|
/** Guarded by per-IP backoff → slowdown → hard cap; back off and retry. */
|
||||||
|
data object RateLimited : LoginResult
|
||||||
|
|
||||||
|
/** Any other server failure (5xx / unexpected). */
|
||||||
|
data object ServerError : LoginResult
|
||||||
|
|
||||||
|
/** No answer — offline, DNS, TLS, timeout. */
|
||||||
|
data object NetworkError : LoginResult
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun login(username: String, password: String, code: String? = null): LoginResult {
|
||||||
|
val response: Response<MobileTokenResponse> = try {
|
||||||
|
authApi.login(MobileLoginRequest(username = username, password = password, code = code))
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (_: IOException) {
|
||||||
|
return LoginResult.NetworkError
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.isSuccessful) {
|
||||||
|
val body = response.body() ?: return LoginResult.ServerError
|
||||||
|
sessionManager.onSignedIn(body.accessToken, body.refreshToken, body.user)
|
||||||
|
return LoginResult.Success
|
||||||
|
}
|
||||||
|
|
||||||
|
return when (response.code()) {
|
||||||
|
401 -> if (isTotpRequired(response)) LoginResult.TotpRequired else LoginResult.InvalidCredentials
|
||||||
|
429 -> LoginResult.RateLimited
|
||||||
|
else -> LoginResult.ServerError
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Revoke this session (or, with [allDevices], every session) and clear local
|
||||||
|
* tokens (§4.3). Best-effort: the local session is torn down even if the
|
||||||
|
* network call fails, so the user is always signed out locally.
|
||||||
|
*/
|
||||||
|
suspend fun logout(allDevices: Boolean = false) {
|
||||||
|
val refreshToken = sessionManager.currentRefreshToken()
|
||||||
|
try {
|
||||||
|
authApi.logout(MobileLogoutRequest(refreshToken = refreshToken, all = allDevices))
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (_: Exception) {
|
||||||
|
// Ignore — we still drop the local session below.
|
||||||
|
}
|
||||||
|
sessionManager.onSignedOut()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-validate the session against `GET /auth/me` on app resume (§4.3). A
|
||||||
|
* success refreshes the cached role (roles change server-side); a `401` that
|
||||||
|
* survives the silent refresh means the session is dead → sign out. Transient
|
||||||
|
* failures are ignored so a flaky network doesn't bounce the user.
|
||||||
|
*/
|
||||||
|
suspend fun revalidate() {
|
||||||
|
if (!sessionManager.isSignedIn) return
|
||||||
|
try {
|
||||||
|
val me = authApi.me()
|
||||||
|
sessionManager.onUserRefreshed(me.user)
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (e: retrofit2.HttpException) {
|
||||||
|
if (e.code() == 401) sessionManager.onSignedOut()
|
||||||
|
} catch (_: IOException) {
|
||||||
|
// Offline — keep the session; the next authed call will re-check.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isTotpRequired(response: Response<*>): Boolean = try {
|
||||||
|
val raw = response.errorBody()?.string()
|
||||||
|
!raw.isNullOrBlank() && json.decodeFromString<TotpRequiredError>(raw).totpRequired
|
||||||
|
} catch (_: Exception) {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.data.repository
|
||||||
|
|
||||||
|
import com.runicgateway.app.core.auth.SessionManager
|
||||||
|
import com.runicgateway.app.core.net.BaseUrlHolder
|
||||||
|
import com.runicgateway.app.core.net.ServerUrl
|
||||||
|
import com.runicgateway.app.core.prefs.ServerPreferences
|
||||||
|
import com.runicgateway.app.core.result.ApiResult
|
||||||
|
import com.runicgateway.app.core.result.safeApiCall
|
||||||
|
import com.runicgateway.app.data.api.PublicApi
|
||||||
|
import com.runicgateway.app.data.api.dto.StatusDto
|
||||||
|
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Owns the shard website base-URL lifecycle (PLAN.md §3): restoring a saved URL
|
||||||
|
* on launch, validating + persisting a candidate on the first-run connect
|
||||||
|
* screen, and the hard reset performed by a Settings → Server switch.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class ConnectionRepository @Inject constructor(
|
||||||
|
private val api: PublicApi,
|
||||||
|
private val prefs: ServerPreferences,
|
||||||
|
private val baseUrlHolder: BaseUrlHolder,
|
||||||
|
private val sessionManager: SessionManager,
|
||||||
|
private val config: com.runicgateway.app.core.AppConfig,
|
||||||
|
) {
|
||||||
|
|
||||||
|
/** Outcome of validating a candidate base URL against a live site. */
|
||||||
|
sealed interface ProbeResult {
|
||||||
|
data class Success(val status: StatusDto) : ProbeResult
|
||||||
|
data class InvalidUrl(val reason: ServerUrl.Reason) : ProbeResult
|
||||||
|
|
||||||
|
/** Reachable and 2xx, but not a Runic Gateway backend (wrong version identity). */
|
||||||
|
data object NotRunicGateway : ProbeResult
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A Runic Gateway backend, but speaking an API version this app build does
|
||||||
|
* not support (§3 version guard) — refuse rather than mis-render. [serverApi]
|
||||||
|
* is what the site reported; [supportedApi] is what this app speaks.
|
||||||
|
*/
|
||||||
|
data class VersionMismatch(val serverApi: String, val supportedApi: String) : ProbeResult
|
||||||
|
data class ServerError(val status: Int) : ProbeResult
|
||||||
|
data class Unreachable(val cause: Throwable) : ProbeResult
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True once a saved base URL has been loaded into the holder. */
|
||||||
|
val isConnected: Boolean get() = baseUrlHolder.current != null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restore any saved base URL into the holder on launch. Returns true if the
|
||||||
|
* app already has a configured shard site (skip the connect screen).
|
||||||
|
*/
|
||||||
|
suspend fun restore(): Boolean {
|
||||||
|
val saved = prefs.currentBaseUrl()?.toHttpUrlOrNull()
|
||||||
|
baseUrlHolder.set(saved)
|
||||||
|
return saved != null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate [rawUrl], and on success persist it and activate it for all
|
||||||
|
* subsequent API calls. Insecure HTTP is allowed only in debug builds
|
||||||
|
* (local dev against 127.0.0.1); release builds require HTTPS.
|
||||||
|
*/
|
||||||
|
suspend fun probeAndConnect(rawUrl: String): ProbeResult {
|
||||||
|
val normalized = when (val r = ServerUrl.normalize(rawUrl, allowInsecureHttp = config.allowInsecureHttp)) {
|
||||||
|
is ServerUrl.Result.Invalid -> return ProbeResult.InvalidUrl(r.reason)
|
||||||
|
is ServerUrl.Result.Valid -> r.url
|
||||||
|
}
|
||||||
|
|
||||||
|
val statusUrl = normalized.resolve("api/v1/public/status")?.toString()
|
||||||
|
?: return ProbeResult.InvalidUrl(ServerUrl.Reason.MALFORMED)
|
||||||
|
|
||||||
|
return when (val result = safeApiCall { api.probeStatus(statusUrl) }) {
|
||||||
|
is ApiResult.Ok -> when (val verdict = evaluateVersion(result.data.version)) {
|
||||||
|
is VersionVerdict.Ok -> {
|
||||||
|
prefs.setBaseUrl(normalized.toString())
|
||||||
|
baseUrlHolder.set(normalized)
|
||||||
|
ProbeResult.Success(result.data)
|
||||||
|
}
|
||||||
|
is VersionVerdict.NotRunicGateway -> ProbeResult.NotRunicGateway
|
||||||
|
is VersionVerdict.Mismatch ->
|
||||||
|
ProbeResult.VersionMismatch(serverApi = verdict.serverApi, supportedApi = SUPPORTED_API)
|
||||||
|
}
|
||||||
|
is ApiResult.HttpError -> ProbeResult.ServerError(result.status)
|
||||||
|
is ApiResult.NetworkError -> ProbeResult.Unreachable(result.cause)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hard reset for a Settings → Server switch (§3): sign out (clear stored
|
||||||
|
* tokens), clear the saved URL, and deactivate it — the app returns to a
|
||||||
|
* signed-out state against the new host.
|
||||||
|
*/
|
||||||
|
suspend fun disconnect() {
|
||||||
|
sessionManager.onSignedOut()
|
||||||
|
prefs.clear()
|
||||||
|
baseUrlHolder.set(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The pure outcome of inspecting a probed site's [VersionDto] (§3 version guard). */
|
||||||
|
internal sealed interface VersionVerdict {
|
||||||
|
data object Ok : VersionVerdict
|
||||||
|
data object NotRunicGateway : VersionVerdict
|
||||||
|
data class Mismatch(val serverApi: String) : VersionVerdict
|
||||||
|
}
|
||||||
|
|
||||||
|
internal companion object {
|
||||||
|
const val RUNIC_SERVICE_ID = "runic-gateway"
|
||||||
|
|
||||||
|
/** The backend API major version this app build speaks (matches `/public/version` `api`). */
|
||||||
|
const val SUPPORTED_API = "v1"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decide whether a probed site is a Runic Gateway backend this app can talk
|
||||||
|
* to. Pure (no I/O) so it is unit-testable without a live site. Lenient on a
|
||||||
|
* blank `api` (an older backend that predates version surfacing); refuses only
|
||||||
|
* an API version we positively know we can't parse (e.g. a future `v2`).
|
||||||
|
*/
|
||||||
|
fun evaluateVersion(
|
||||||
|
version: com.runicgateway.app.data.api.dto.VersionDto,
|
||||||
|
supportedApi: String = SUPPORTED_API,
|
||||||
|
): VersionVerdict {
|
||||||
|
if (!version.service.trim().equals(RUNIC_SERVICE_ID, ignoreCase = true)) {
|
||||||
|
return VersionVerdict.NotRunicGateway
|
||||||
|
}
|
||||||
|
val serverApi = version.api.trim()
|
||||||
|
return when {
|
||||||
|
serverApi.isEmpty() -> VersionVerdict.Ok
|
||||||
|
serverApi.equals(supportedApi, ignoreCase = true) -> VersionVerdict.Ok
|
||||||
|
else -> VersionVerdict.Mismatch(serverApi)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.data.repository
|
||||||
|
|
||||||
|
import com.runicgateway.app.core.result.ApiResult
|
||||||
|
import com.runicgateway.app.core.result.safeApiCall
|
||||||
|
import com.runicgateway.app.data.api.PublicApi
|
||||||
|
import com.runicgateway.app.data.api.dto.ContactRequest
|
||||||
|
import com.runicgateway.app.data.api.dto.ContactResponse
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contact form submission (PLAN.md §6.1). The endpoint is rate-limited; the
|
||||||
|
* caller handles `429` (too many) and `502` (mailer down) via [ApiResult.HttpError].
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class ContactRepository @Inject constructor(
|
||||||
|
private val api: PublicApi,
|
||||||
|
) {
|
||||||
|
suspend fun send(name: String, email: String, message: String): ApiResult<ContactResponse> =
|
||||||
|
safeApiCall { api.postContact(ContactRequest(name = name, email = email, message = message)) }
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.data.repository
|
||||||
|
|
||||||
|
import com.runicgateway.app.core.result.ApiResult
|
||||||
|
import com.runicgateway.app.core.result.safeApiCall
|
||||||
|
import com.runicgateway.app.data.api.PublicApi
|
||||||
|
import com.runicgateway.app.data.api.dto.PageDto
|
||||||
|
import com.runicgateway.app.data.api.dto.PostDto
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* News posts and CMS pages (PLAN.md §6.1). URL post categories map 1:1 to the
|
||||||
|
* backend's route segments (`news | five-on-friday | newsletter | screenshots`).
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class ContentRepository @Inject constructor(
|
||||||
|
private val api: PublicApi,
|
||||||
|
) {
|
||||||
|
/** Known URL categories, in display order. */
|
||||||
|
enum class PostCategory(val urlSlug: String) {
|
||||||
|
NEWS("news"),
|
||||||
|
FIVE_ON_FRIDAY("five-on-friday"),
|
||||||
|
NEWSLETTER("newsletter"),
|
||||||
|
SCREENSHOTS("screenshots"),
|
||||||
|
;
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun fromUrlSlug(slug: String?): PostCategory? = entries.firstOrNull { it.urlSlug == slug }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getPosts(category: PostCategory): ApiResult<List<PostDto>> =
|
||||||
|
safeApiCall { api.getPosts(category.urlSlug) }
|
||||||
|
|
||||||
|
suspend fun getPost(category: PostCategory, idOrSlug: String): ApiResult<PostDto> =
|
||||||
|
safeApiCall { api.getPost(category.urlSlug, idOrSlug) }
|
||||||
|
|
||||||
|
suspend fun getPage(slug: String): ApiResult<PageDto> =
|
||||||
|
safeApiCall { api.getPage(slug) }
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.data.repository
|
||||||
|
|
||||||
|
import com.runicgateway.app.core.result.ApiResult
|
||||||
|
import com.runicgateway.app.core.result.safeApiCall
|
||||||
|
import com.runicgateway.app.data.api.PlayerShardApi
|
||||||
|
import com.runicgateway.app.data.api.dto.CharProfileDto
|
||||||
|
import com.runicgateway.app.data.api.dto.CreateGameAccountRequest
|
||||||
|
import com.runicgateway.app.data.api.dto.PlayerHouseDto
|
||||||
|
import com.runicgateway.app.data.api.dto.RosterDto
|
||||||
|
import com.runicgateway.app.data.api.dto.ShardLinkDto
|
||||||
|
import com.runicgateway.app.data.api.dto.ShardLinkRequest
|
||||||
|
import com.runicgateway.app.data.api.dto.ShardLinkResultDto
|
||||||
|
import com.runicgateway.app.data.api.dto.VendorSaleDto
|
||||||
|
import com.runicgateway.app.data.api.dto.VendorSnapshotDto
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A player's own game data + game-account linking (PLAN.md §6.3), over the
|
||||||
|
* bearer-gated `/player/shard/…` surface. Every read returns a typed [ApiResult]
|
||||||
|
* so a down shard (`503`) renders as "offline, retry" and a not-linked account
|
||||||
|
* (`403`) is handled cleanly — the repository never throws for an expected
|
||||||
|
* failure (§7). Ownership is enforced server-side.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class PlayerShardRepository @Inject constructor(
|
||||||
|
private val api: PlayerShardApi,
|
||||||
|
) {
|
||||||
|
suspend fun link(code: String): ApiResult<ShardLinkResultDto> =
|
||||||
|
safeApiCall { api.link(ShardLinkRequest(code)) }
|
||||||
|
|
||||||
|
suspend fun createAccount(account: String, password: String): ApiResult<ShardLinkResultDto> =
|
||||||
|
safeApiCall { api.createAccount(CreateGameAccountRequest(account, password)) }
|
||||||
|
|
||||||
|
suspend fun accounts(): ApiResult<List<ShardLinkDto>> = safeApiCall { api.accounts() }
|
||||||
|
|
||||||
|
suspend fun roster(account: String): ApiResult<RosterDto> = safeApiCall { api.roster(account) }
|
||||||
|
|
||||||
|
suspend fun char(serial: String): ApiResult<CharProfileDto> = safeApiCall { api.char(serial) }
|
||||||
|
|
||||||
|
suspend fun vendors(account: String): ApiResult<VendorSnapshotDto> =
|
||||||
|
safeApiCall { api.vendors(account) }
|
||||||
|
|
||||||
|
suspend fun sales(): ApiResult<List<VendorSaleDto>> = safeApiCall { api.sales() }
|
||||||
|
|
||||||
|
suspend fun houses(): ApiResult<List<PlayerHouseDto>> = safeApiCall { api.houses() }
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.data.repository
|
||||||
|
|
||||||
|
import com.runicgateway.app.core.result.ApiResult
|
||||||
|
import com.runicgateway.app.core.result.safeApiCall
|
||||||
|
import com.runicgateway.app.data.api.PublicApi
|
||||||
|
import com.runicgateway.app.data.api.dto.SettingsDto
|
||||||
|
import com.runicgateway.app.data.api.dto.StatusDto
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/** Site status (mode/maintenance) and settings/branding (PLAN.md §6.1). */
|
||||||
|
@Singleton
|
||||||
|
class SettingsRepository @Inject constructor(
|
||||||
|
private val api: PublicApi,
|
||||||
|
) {
|
||||||
|
suspend fun getStatus(): ApiResult<StatusDto> = safeApiCall { api.getStatus() }
|
||||||
|
|
||||||
|
suspend fun getSettings(): ApiResult<SettingsDto> = safeApiCall { api.getSettings() }
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.data.repository
|
||||||
|
|
||||||
|
import com.runicgateway.app.core.net.ShardStreamClient
|
||||||
|
import com.runicgateway.app.core.net.ShardStreamEvent
|
||||||
|
import com.runicgateway.app.core.result.ApiResult
|
||||||
|
import com.runicgateway.app.core.result.safeApiCall
|
||||||
|
import com.runicgateway.app.data.api.PublicApi
|
||||||
|
import com.runicgateway.app.data.api.dto.ChampDto
|
||||||
|
import com.runicgateway.app.data.api.dto.EconomySampleDto
|
||||||
|
import com.runicgateway.app.data.api.dto.FeedEventDto
|
||||||
|
import com.runicgateway.app.data.api.dto.GovernorDto
|
||||||
|
import com.runicgateway.app.data.api.dto.GovernorTermDto
|
||||||
|
import com.runicgateway.app.data.api.dto.GuildDto
|
||||||
|
import com.runicgateway.app.data.api.dto.HouseDto
|
||||||
|
import com.runicgateway.app.data.api.dto.OnlineStaffDto
|
||||||
|
import com.runicgateway.app.data.api.dto.PresenceDto
|
||||||
|
import com.runicgateway.app.data.api.dto.ShardStatusDto
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.serialization.KSerializer
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The public shard widgets (PLAN.md §6.2): point-in-time board snapshots over
|
||||||
|
* the `/public/shard/…` GETs plus the live SSE stream. Every read returns a typed
|
||||||
|
* [ApiResult] so a down shard renders as offline (§7); [liveEvents] is the shared
|
||||||
|
* SSE feed the boards merge in place. Live `*.update` frames decode into the same
|
||||||
|
* DTOs as the snapshot reads via the `*Frame` decoders.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class ShardRepository @Inject constructor(
|
||||||
|
private val api: PublicApi,
|
||||||
|
private val stream: ShardStreamClient,
|
||||||
|
private val json: Json,
|
||||||
|
) {
|
||||||
|
// ── Snapshots ────────────────────────────────────────────────────────
|
||||||
|
suspend fun status(): ApiResult<ShardStatusDto> = safeApiCall { api.getShardStatus() }
|
||||||
|
|
||||||
|
suspend fun feed(limit: Int = 40): ApiResult<List<FeedEventDto>> =
|
||||||
|
safeApiCall { api.getShardFeed(limit = limit) }
|
||||||
|
|
||||||
|
suspend fun economy(limit: Int = 100): ApiResult<List<EconomySampleDto>> =
|
||||||
|
safeApiCall { api.getShardEconomy(limit) }
|
||||||
|
|
||||||
|
suspend fun online(): ApiResult<List<OnlineStaffDto>> = safeApiCall { api.getShardOnline() }
|
||||||
|
|
||||||
|
suspend fun presence(): ApiResult<PresenceDto> = safeApiCall { api.getShardPresence() }
|
||||||
|
|
||||||
|
suspend fun champs(): ApiResult<List<ChampDto>> = safeApiCall { api.getShardChamps() }
|
||||||
|
|
||||||
|
suspend fun guilds(): ApiResult<List<GuildDto>> = safeApiCall { api.getShardGuilds() }
|
||||||
|
|
||||||
|
suspend fun governors(): ApiResult<List<GovernorDto>> = safeApiCall { api.getShardGovernors() }
|
||||||
|
|
||||||
|
suspend fun governorHistory(city: String, limit: Int = 25): ApiResult<List<GovernorTermDto>> =
|
||||||
|
safeApiCall { api.getShardGovernorHistory(city, limit) }
|
||||||
|
|
||||||
|
suspend fun houses(): ApiResult<List<HouseDto>> = safeApiCall { api.getShardHouses() }
|
||||||
|
|
||||||
|
// ── Live stream ──────────────────────────────────────────────────────
|
||||||
|
/** The shared public SSE feed (safe kinds only), reconnecting with backoff (§7). */
|
||||||
|
fun liveEvents(): Flow<ShardStreamEvent> = stream.events()
|
||||||
|
|
||||||
|
// Decode a live `*.update` frame into the board DTO it mirrors; null on shape
|
||||||
|
// mismatch so a malformed frame is skipped rather than crashing the board.
|
||||||
|
fun champFrame(obj: JsonObject): ChampDto? = decode(obj, ChampDto.serializer())
|
||||||
|
fun guildFrame(obj: JsonObject): GuildDto? = decode(obj, GuildDto.serializer())
|
||||||
|
fun governorFrame(obj: JsonObject): GovernorDto? = decode(obj, GovernorDto.serializer())
|
||||||
|
fun presenceFrame(obj: JsonObject): PresenceDto? = decode(obj, PresenceDto.serializer())
|
||||||
|
|
||||||
|
private fun <T> decode(obj: JsonObject, serializer: KSerializer<T>): T? = try {
|
||||||
|
json.decodeFromJsonElement(serializer, obj)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.data.repository
|
||||||
|
|
||||||
|
import com.runicgateway.app.core.result.ApiResult
|
||||||
|
import com.runicgateway.app.core.result.safeApiCall
|
||||||
|
import com.runicgateway.app.data.api.PublicApi
|
||||||
|
import com.runicgateway.app.data.api.dto.WikiCategoryDto
|
||||||
|
import com.runicgateway.app.data.api.dto.WikiPageDto
|
||||||
|
import com.runicgateway.app.data.api.dto.WikiSummaryDto
|
||||||
|
import com.runicgateway.app.data.api.dto.WikiTagDto
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/** Wiki listing/filtering, categories, tags, and detail (PLAN.md §6.1). */
|
||||||
|
@Singleton
|
||||||
|
class WikiRepository @Inject constructor(
|
||||||
|
private val api: PublicApi,
|
||||||
|
) {
|
||||||
|
/** Full-text [query] takes precedence over [category]/[tag] on the backend. */
|
||||||
|
suspend fun getPages(
|
||||||
|
query: String? = null,
|
||||||
|
category: String? = null,
|
||||||
|
tag: String? = null,
|
||||||
|
): ApiResult<List<WikiSummaryDto>> =
|
||||||
|
safeApiCall { api.getWikiPages(query?.takeIf { it.isNotBlank() }, category, tag) }
|
||||||
|
|
||||||
|
suspend fun getCategories(): ApiResult<List<WikiCategoryDto>> =
|
||||||
|
safeApiCall { api.getWikiCategories() }
|
||||||
|
|
||||||
|
suspend fun getTags(): ApiResult<List<WikiTagDto>> =
|
||||||
|
safeApiCall { api.getWikiTags() }
|
||||||
|
|
||||||
|
suspend fun getPage(slug: String): ApiResult<WikiPageDto> =
|
||||||
|
safeApiCall { api.getWikiPage(slug) }
|
||||||
|
}
|
||||||
24
app/src/main/java/com/runicgateway/app/di/AppModule.kt
Normal file
24
app/src/main/java/com/runicgateway/app/di/AppModule.kt
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.di
|
||||||
|
|
||||||
|
import com.runicgateway.app.BuildConfig
|
||||||
|
import com.runicgateway.app.core.AppConfig
|
||||||
|
import dagger.Module
|
||||||
|
import dagger.Provides
|
||||||
|
import dagger.hilt.InstallIn
|
||||||
|
import dagger.hilt.components.SingletonComponent
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Module
|
||||||
|
@InstallIn(SingletonComponent::class)
|
||||||
|
object AppModule {
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideAppConfig(): AppConfig = AppConfig(
|
||||||
|
allowInsecureHttp = BuildConfig.DEBUG,
|
||||||
|
versionName = BuildConfig.VERSION_NAME,
|
||||||
|
)
|
||||||
|
}
|
||||||
133
app/src/main/java/com/runicgateway/app/di/NetworkModule.kt
Normal file
133
app/src/main/java/com/runicgateway/app/di/NetworkModule.kt
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.di
|
||||||
|
|
||||||
|
import android.os.Build
|
||||||
|
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
|
||||||
|
import com.runicgateway.app.BuildConfig
|
||||||
|
import com.runicgateway.app.core.net.AuthInterceptor
|
||||||
|
import com.runicgateway.app.core.net.BaseUrlHolder
|
||||||
|
import com.runicgateway.app.core.net.HostSelectionInterceptor
|
||||||
|
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
|
||||||
|
import dagger.hilt.InstallIn
|
||||||
|
import dagger.hilt.components.SingletonComponent
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.logging.HttpLoggingInterceptor
|
||||||
|
import retrofit2.Retrofit
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Module
|
||||||
|
@InstallIn(SingletonComponent::class)
|
||||||
|
object NetworkModule {
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideJson(): Json = Json {
|
||||||
|
// Additive backend fields (§8) must never break decoding; be lenient.
|
||||||
|
ignoreUnknownKeys = true
|
||||||
|
explicitNulls = false
|
||||||
|
coerceInputValues = true
|
||||||
|
}
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideUserAgentInterceptor(): UserAgentInterceptor {
|
||||||
|
// Identifiable UA so the site's scanner guard (§8) doesn't reject the app.
|
||||||
|
val ua = "RunicGatewayApp/${BuildConfig.VERSION_NAME} (Android ${Build.VERSION.RELEASE})"
|
||||||
|
return UserAgentInterceptor(ua)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The main client every API and the SSE stream ride on. Order: identify (UA),
|
||||||
|
* retarget onto the configured shard host, then attach the bearer; the
|
||||||
|
* [TokenAuthenticator] handles silent refresh-on-401 (§4.1). Logging sits last
|
||||||
|
* so it observes the final, authed request.
|
||||||
|
*/
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideOkHttpClient(
|
||||||
|
hostSelectionInterceptor: HostSelectionInterceptor,
|
||||||
|
userAgentInterceptor: UserAgentInterceptor,
|
||||||
|
authInterceptor: AuthInterceptor,
|
||||||
|
tokenAuthenticator: TokenAuthenticator,
|
||||||
|
): OkHttpClient {
|
||||||
|
val builder = OkHttpClient.Builder()
|
||||||
|
.addInterceptor(userAgentInterceptor)
|
||||||
|
.addInterceptor(hostSelectionInterceptor)
|
||||||
|
.addInterceptor(authInterceptor)
|
||||||
|
.authenticator(tokenAuthenticator)
|
||||||
|
|
||||||
|
if (BuildConfig.DEBUG) {
|
||||||
|
builder.addInterceptor(
|
||||||
|
HttpLoggingInterceptor().apply { level = HttpLoggingInterceptor.Level.BASIC },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return builder.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideRetrofit(client: OkHttpClient, json: Json): Retrofit =
|
||||||
|
Retrofit.Builder()
|
||||||
|
// Compile-time sentinel; every relative call is rewritten onto the
|
||||||
|
// runtime-selected shard site by HostSelectionInterceptor (§3).
|
||||||
|
.baseUrl(BaseUrlHolder.PLACEHOLDER_BASE_URL)
|
||||||
|
.client(client)
|
||||||
|
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
|
||||||
|
.build()
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun providePublicApi(retrofit: Retrofit): PublicApi = retrofit.create(PublicApi::class.java)
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@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
|
||||||
|
* back through [TokenAuthenticator] (§4.3). This throwaway client/Retrofit is
|
||||||
|
* not exposed as a bean, so there is no ambiguous [OkHttpClient] binding.
|
||||||
|
*/
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideAuthRefreshApi(
|
||||||
|
hostSelectionInterceptor: HostSelectionInterceptor,
|
||||||
|
userAgentInterceptor: UserAgentInterceptor,
|
||||||
|
json: Json,
|
||||||
|
): AuthRefreshApi {
|
||||||
|
val bareClient = OkHttpClient.Builder()
|
||||||
|
.addInterceptor(userAgentInterceptor)
|
||||||
|
.addInterceptor(hostSelectionInterceptor)
|
||||||
|
.build()
|
||||||
|
val retrofit = Retrofit.Builder()
|
||||||
|
.baseUrl(BaseUrlHolder.PLACEHOLDER_BASE_URL)
|
||||||
|
.client(bareClient)
|
||||||
|
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
|
||||||
|
.build()
|
||||||
|
return retrofit.create(AuthRefreshApi::class.java)
|
||||||
|
}
|
||||||
|
}
|
||||||
22
app/src/main/java/com/runicgateway/app/di/StorageModule.kt
Normal file
22
app/src/main/java/com/runicgateway/app/di/StorageModule.kt
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.di
|
||||||
|
|
||||||
|
import com.runicgateway.app.core.auth.EncryptedTokenStore
|
||||||
|
import com.runicgateway.app.core.auth.TokenStore
|
||||||
|
import dagger.Binds
|
||||||
|
import dagger.Module
|
||||||
|
import dagger.hilt.InstallIn
|
||||||
|
import dagger.hilt.components.SingletonComponent
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/** Binds the at-rest token store to its EncryptedSharedPreferences impl (§4.3). */
|
||||||
|
@Module
|
||||||
|
@InstallIn(SingletonComponent::class)
|
||||||
|
abstract class StorageModule {
|
||||||
|
|
||||||
|
@Binds
|
||||||
|
@Singleton
|
||||||
|
abstract fun bindTokenStore(impl: EncryptedTokenStore): TokenStore
|
||||||
|
}
|
||||||
83
app/src/main/java/com/runicgateway/app/ui/AppViewModel.kt
Normal file
83
app/src/main/java/com/runicgateway/app/ui/AppViewModel.kt
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.ui
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.runicgateway.app.core.net.BaseUrlHolder
|
||||||
|
import com.runicgateway.app.core.result.ApiResult
|
||||||
|
import com.runicgateway.app.data.api.dto.BrandDto
|
||||||
|
import com.runicgateway.app.data.repository.ConnectionRepository
|
||||||
|
import com.runicgateway.app.data.repository.SettingsRepository
|
||||||
|
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
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Top-level app gate (PLAN.md §3): decides whether the first-run connect screen
|
||||||
|
* or the main UI shows, and holds the per-shard branding the theme is seeded
|
||||||
|
* from. Activity-scoped so the whole app observes one state.
|
||||||
|
*/
|
||||||
|
@HiltViewModel
|
||||||
|
class AppViewModel @Inject constructor(
|
||||||
|
private val connectionRepository: ConnectionRepository,
|
||||||
|
private val settingsRepository: SettingsRepository,
|
||||||
|
private val baseUrlHolder: BaseUrlHolder,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
sealed interface AppState {
|
||||||
|
/** Restoring the saved base URL / loading branding. */
|
||||||
|
data object Loading : AppState
|
||||||
|
|
||||||
|
/** No shard site configured yet — show the connect screen. */
|
||||||
|
data object NeedsConnection : AppState
|
||||||
|
|
||||||
|
/** A site is configured; [brand] is null if branding couldn't be loaded (still usable). */
|
||||||
|
data class Ready(val brand: BrandDto?) : AppState
|
||||||
|
}
|
||||||
|
|
||||||
|
private val _state = MutableStateFlow<AppState>(AppState.Loading)
|
||||||
|
val state: StateFlow<AppState> = _state.asStateFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.value = if (connectionRepository.restore()) {
|
||||||
|
AppState.Ready(loadBrand())
|
||||||
|
} else {
|
||||||
|
AppState.NeedsConnection
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Called by the connect screen once a site has been validated + saved. */
|
||||||
|
fun onConnected() {
|
||||||
|
viewModelScope.launch { _state.value = AppState.Ready(loadBrand()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Settings → Server switch: hard reset back to the connect screen (§3). */
|
||||||
|
fun changeServer() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
connectionRepository.disconnect()
|
||||||
|
_state.value = AppState.NeedsConnection
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun loadBrand(): BrandDto? =
|
||||||
|
(settingsRepository.getSettings() as? ApiResult.Ok)?.data?.brand
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a possibly site-relative asset path (branding logos, post images)
|
||||||
|
* to an absolute URL against the configured base (§8: asset fields may be
|
||||||
|
* site-relative). Absolute URLs pass through unchanged; null/blank stays null.
|
||||||
|
*/
|
||||||
|
fun resolveAsset(path: String?): String? {
|
||||||
|
if (path.isNullOrBlank()) return null
|
||||||
|
val base = baseUrlHolder.current ?: return path
|
||||||
|
return base.resolve(path)?.toString() ?: path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.ui
|
||||||
|
|
||||||
|
import androidx.compose.runtime.staticCompositionLocalOf
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves possibly site-relative asset paths (post images, branding) to
|
||||||
|
* absolute URLs against the configured base URL. Provided at the app root from
|
||||||
|
* [AppViewModel.resolveAsset] so any screen or CMS block can turn a stored path
|
||||||
|
* into a loadable URL without reaching into the network layer.
|
||||||
|
*/
|
||||||
|
val LocalAssetResolver = staticCompositionLocalOf<(String?) -> String?> {
|
||||||
|
// Default: identity. Overridden at the app root; this fallback keeps previews
|
||||||
|
// and tests from crashing if the provider is missing.
|
||||||
|
{ it }
|
||||||
|
}
|
||||||
361
app/src/main/java/com/runicgateway/app/ui/RunicApp.kt
Normal file
361
app/src/main/java/com/runicgateway/app/ui/RunicApp.kt
Normal file
@@ -0,0 +1,361 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.filled.Menu
|
||||||
|
import androidx.compose.material3.DrawerValue
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.ModalDrawerSheet
|
||||||
|
import androidx.compose.material3.ModalNavigationDrawer
|
||||||
|
import androidx.compose.material3.NavigationDrawerItem
|
||||||
|
import androidx.compose.material3.NavigationDrawerItemDefaults
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
|
import androidx.compose.material3.rememberDrawerState
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
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.compose.ui.unit.sp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.compose.LifecycleResumeEffect
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import androidx.navigation.NavHostController
|
||||||
|
import androidx.navigation.NavType
|
||||||
|
import androidx.navigation.compose.NavHost
|
||||||
|
import androidx.navigation.compose.composable
|
||||||
|
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||||
|
import androidx.navigation.compose.rememberNavController
|
||||||
|
import androidx.navigation.navArgument
|
||||||
|
import com.runicgateway.app.R
|
||||||
|
import com.runicgateway.app.core.auth.Session
|
||||||
|
import com.runicgateway.app.data.api.dto.BrandDto
|
||||||
|
import com.runicgateway.app.ui.auth.AccountScreen
|
||||||
|
import com.runicgateway.app.ui.auth.LoginScreen
|
||||||
|
import com.runicgateway.app.ui.auth.roleLabelRes
|
||||||
|
import com.runicgateway.app.ui.contact.ContactScreen
|
||||||
|
import com.runicgateway.app.ui.home.HomeScreen
|
||||||
|
import com.runicgateway.app.ui.navigation.APP_MENU
|
||||||
|
import com.runicgateway.app.ui.navigation.Routes
|
||||||
|
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
|
||||||
|
import com.runicgateway.app.ui.shard.GuildsScreen
|
||||||
|
import com.runicgateway.app.ui.shard.HousesScreen
|
||||||
|
import com.runicgateway.app.ui.shard.ShardBoard
|
||||||
|
import com.runicgateway.app.ui.shard.ShardScreen
|
||||||
|
import com.runicgateway.app.ui.wiki.WikiPageScreen
|
||||||
|
import com.runicgateway.app.ui.wiki.WikiScreen
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The main app shell once a shard site is configured (PLAN.md §5): one shared,
|
||||||
|
* declarative, access-level navigation drawer whose entries are filtered by the
|
||||||
|
* current session, plus the Sign in / Sign out toggle and the Settings → Server
|
||||||
|
* switch. The signed-in role is re-validated against the backend on every resume
|
||||||
|
* (§4.3), so a server-side demotion drops menu access promptly.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun RunicApp(
|
||||||
|
brand: BrandDto?,
|
||||||
|
onChangeServer: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
sessionViewModel: SessionViewModel = hiltViewModel(),
|
||||||
|
) {
|
||||||
|
val navController = rememberNavController()
|
||||||
|
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
val session by sessionViewModel.session.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
// Re-validate the cached role each time the app returns to the foreground (§4.3).
|
||||||
|
LifecycleResumeEffect(Unit) {
|
||||||
|
sessionViewModel.revalidate()
|
||||||
|
onPauseOrDispose { }
|
||||||
|
}
|
||||||
|
|
||||||
|
val backStackEntry by navController.currentBackStackEntryAsState()
|
||||||
|
val currentRoute = backStackEntry?.destination?.route
|
||||||
|
val isTopLevel = currentRoute in TOP_LEVEL_ROUTES
|
||||||
|
val entries = visibleEntries(APP_MENU, session)
|
||||||
|
|
||||||
|
ModalNavigationDrawer(
|
||||||
|
drawerState = drawerState,
|
||||||
|
gesturesEnabled = isTopLevel,
|
||||||
|
drawerContent = {
|
||||||
|
ModalDrawerSheet(drawerContainerColor = MaterialTheme.colorScheme.surfaceVariant) {
|
||||||
|
val drawerItemColors = NavigationDrawerItemDefaults.colors(
|
||||||
|
selectedContainerColor = MaterialTheme.colorScheme.secondaryContainer,
|
||||||
|
selectedTextColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||||
|
unselectedTextColor = MaterialTheme.colorScheme.onSurface,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
Text(
|
||||||
|
text = brand?.name?.takeIf { it.isNotBlank() } ?: stringResource(R.string.app_name),
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
modifier = Modifier.padding(horizontal = 24.dp, vertical = 12.dp),
|
||||||
|
)
|
||||||
|
HorizontalDivider()
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
entries.forEach { entry ->
|
||||||
|
NavigationDrawerItem(
|
||||||
|
label = { Text(stringResource(entry.labelRes)) },
|
||||||
|
selected = currentRoute == entry.route,
|
||||||
|
onClick = {
|
||||||
|
scope.launch { drawerState.close() }
|
||||||
|
navController.navigateTopLevel(entry.route)
|
||||||
|
},
|
||||||
|
colors = drawerItemColors,
|
||||||
|
modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
HorizontalDivider(Modifier.padding(vertical = 8.dp))
|
||||||
|
|
||||||
|
// Sign in / Sign out toggles on the session (§5).
|
||||||
|
val signInLabel = if (session is Session.SignedIn) {
|
||||||
|
R.string.menu_sign_out
|
||||||
|
} else {
|
||||||
|
R.string.menu_sign_in
|
||||||
|
}
|
||||||
|
NavigationDrawerItem(
|
||||||
|
label = { Text(stringResource(signInLabel)) },
|
||||||
|
selected = false,
|
||||||
|
onClick = {
|
||||||
|
scope.launch { drawerState.close() }
|
||||||
|
if (session is Session.SignedIn) {
|
||||||
|
sessionViewModel.signOut()
|
||||||
|
navController.navigateTopLevel(Routes.HOME)
|
||||||
|
} else {
|
||||||
|
navController.navigate(Routes.LOGIN)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors = drawerItemColors,
|
||||||
|
modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding),
|
||||||
|
)
|
||||||
|
NavigationDrawerItem(
|
||||||
|
label = { Text(stringResource(R.string.menu_change_server)) },
|
||||||
|
selected = false,
|
||||||
|
onClick = {
|
||||||
|
scope.launch { drawerState.close() }
|
||||||
|
onChangeServer()
|
||||||
|
},
|
||||||
|
colors = drawerItemColors,
|
||||||
|
modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Scaffold(
|
||||||
|
modifier = modifier,
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
|
titleContentColor = MaterialTheme.colorScheme.onSurface,
|
||||||
|
navigationIconContentColor = MaterialTheme.colorScheme.onSurface,
|
||||||
|
actionIconContentColor = MaterialTheme.colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
title = {
|
||||||
|
Text(
|
||||||
|
text = (brand?.name?.takeIf { it.isNotBlank() }
|
||||||
|
?: stringResource(R.string.app_name)).uppercase(),
|
||||||
|
style = MaterialTheme.typography.titleSmall.copy(letterSpacing = 1.2.sp),
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
navigationIcon = {
|
||||||
|
if (isTopLevel) {
|
||||||
|
IconButton(onClick = { scope.launch { drawerState.open() } }) {
|
||||||
|
Icon(Icons.Filled.Menu, stringResource(R.string.nav_open_menu))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
IconButton(onClick = { navController.popBackStack() }) {
|
||||||
|
Icon(
|
||||||
|
Icons.AutoMirrored.Filled.ArrowBack,
|
||||||
|
stringResource(R.string.action_back),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) { innerPadding ->
|
||||||
|
RunicNavHost(
|
||||||
|
navController = navController,
|
||||||
|
brand = brand,
|
||||||
|
session = session,
|
||||||
|
onSignOut = { sessionViewModel.signOut() },
|
||||||
|
onSignOutEverywhere = { sessionViewModel.signOut(allDevices = true) },
|
||||||
|
modifier = Modifier.padding(innerPadding),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun RunicNavHost(
|
||||||
|
navController: NavHostController,
|
||||||
|
brand: BrandDto?,
|
||||||
|
session: Session,
|
||||||
|
onSignOut: () -> Unit,
|
||||||
|
onSignOutEverywhere: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
NavHost(
|
||||||
|
navController = navController,
|
||||||
|
startDestination = Routes.HOME,
|
||||||
|
modifier = modifier,
|
||||||
|
) {
|
||||||
|
composable(Routes.HOME) {
|
||||||
|
HomeScreen(brand = brand)
|
||||||
|
}
|
||||||
|
composable(Routes.NEWS) {
|
||||||
|
NewsScreen(onOpenPost = { category, idOrSlug ->
|
||||||
|
navController.navigate(Routes.post(category, idOrSlug))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
composable(
|
||||||
|
route = Routes.POST,
|
||||||
|
arguments = listOf(
|
||||||
|
navArgument(Routes.Args.CATEGORY) { type = NavType.StringType },
|
||||||
|
navArgument(Routes.Args.ID_OR_SLUG) { type = NavType.StringType },
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
PostScreen()
|
||||||
|
}
|
||||||
|
composable(Routes.SHARD) {
|
||||||
|
ShardScreen(onOpenBoard = { board ->
|
||||||
|
navController.navigate(
|
||||||
|
when (board) {
|
||||||
|
ShardBoard.CHAMPS -> Routes.SHARD_CHAMPS
|
||||||
|
ShardBoard.GUILDS -> Routes.SHARD_GUILDS
|
||||||
|
ShardBoard.GOVERNORS -> Routes.SHARD_GOVERNORS
|
||||||
|
ShardBoard.HOUSES -> Routes.SHARD_HOUSES
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
composable(Routes.SHARD_CHAMPS) { ChampsScreen() }
|
||||||
|
composable(Routes.SHARD_GUILDS) { GuildsScreen() }
|
||||||
|
composable(Routes.SHARD_GOVERNORS) { GovernorsScreen() }
|
||||||
|
composable(Routes.SHARD_HOUSES) { HousesScreen() }
|
||||||
|
composable(Routes.WIKI) {
|
||||||
|
WikiScreen(onOpenPage = { slug -> navController.navigate(Routes.wikiPage(slug)) })
|
||||||
|
}
|
||||||
|
composable(
|
||||||
|
route = Routes.WIKI_PAGE,
|
||||||
|
arguments = listOf(navArgument(Routes.Args.SLUG) { type = NavType.StringType }),
|
||||||
|
) {
|
||||||
|
WikiPageScreen(onOpenPage = { slug -> navController.navigate(Routes.wikiPage(slug)) })
|
||||||
|
}
|
||||||
|
composable(
|
||||||
|
route = Routes.PAGE,
|
||||||
|
arguments = listOf(navArgument(Routes.Args.SLUG) { type = NavType.StringType }),
|
||||||
|
) {
|
||||||
|
PageScreen()
|
||||||
|
}
|
||||||
|
composable(Routes.CONTACT) {
|
||||||
|
ContactScreen()
|
||||||
|
}
|
||||||
|
composable(Routes.LOGIN) {
|
||||||
|
LoginScreen(onSignedIn = { navController.popBackStack() })
|
||||||
|
}
|
||||||
|
composable(Routes.ACCOUNT) {
|
||||||
|
// Only meaningful while signed in; a sign-out (here or from the drawer)
|
||||||
|
// sends the user home rather than leaving a stale identity on screen.
|
||||||
|
when (val s = session) {
|
||||||
|
is Session.SignedIn -> AccountScreen(
|
||||||
|
username = s.user.username,
|
||||||
|
roleLabel = stringResource(roleLabelRes(s.user.role)),
|
||||||
|
onSignOut = onSignOut,
|
||||||
|
onSignOutEverywhere = onSignOutEverywhere,
|
||||||
|
)
|
||||||
|
Session.SignedOut -> LaunchedEffect(Unit) {
|
||||||
|
navController.navigateTopLevel(Routes.HOME)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Navigate to a top-level menu destination: single instance, reset to it. */
|
||||||
|
private fun NavHostController.navigateTopLevel(route: String) {
|
||||||
|
navigate(route) {
|
||||||
|
popUpTo(Routes.HOME) { saveState = true }
|
||||||
|
launchSingleTop = true
|
||||||
|
restoreState = true
|
||||||
|
}
|
||||||
|
}
|
||||||
54
app/src/main/java/com/runicgateway/app/ui/UiState.kt
Normal file
54
app/src/main/java/com/runicgateway/app/ui/UiState.kt
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.ui
|
||||||
|
|
||||||
|
import com.runicgateway.app.core.result.ApiResult
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The load state a screen renders. View models expose a `StateFlow<UiState<T>>`;
|
||||||
|
* the Compose layer renders loading / content / error+retry from it (PLAN.md §7:
|
||||||
|
* clean loading/error/retry states, no crash on a down backend).
|
||||||
|
*/
|
||||||
|
sealed interface UiState<out T> {
|
||||||
|
data object Loading : UiState<Nothing>
|
||||||
|
data class Success<T>(val data: T) : UiState<T>
|
||||||
|
data class Error(val kind: ErrorKind, val httpStatus: Int? = null) : UiState<Nothing>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Coarse failure buckets the UI turns into a friendly message + retry. Kept
|
||||||
|
* transport-agnostic so a screen picks the right copy without inspecting raw
|
||||||
|
* exceptions or status codes.
|
||||||
|
*/
|
||||||
|
enum class ErrorKind {
|
||||||
|
/** No answer from the backend — offline, DNS, TLS, timeout. */
|
||||||
|
NETWORK,
|
||||||
|
|
||||||
|
/** Requested item doesn't exist (404). */
|
||||||
|
NOT_FOUND,
|
||||||
|
|
||||||
|
/** Too many requests (429) — back off and retry shortly. */
|
||||||
|
RATE_LIMITED,
|
||||||
|
|
||||||
|
/** Shard/sidecar down (503) — shard reads only; render as offline (§6.3). */
|
||||||
|
SHARD_OFFLINE,
|
||||||
|
|
||||||
|
/** Any other non-2xx server response. */
|
||||||
|
SERVER,
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fold an [ApiResult] into a [UiState], mapping known statuses to [ErrorKind]s. */
|
||||||
|
fun <T> ApiResult<T>.toUiState(): UiState<T> = when (this) {
|
||||||
|
is ApiResult.Ok -> UiState.Success(data)
|
||||||
|
is ApiResult.NetworkError -> UiState.Error(ErrorKind.NETWORK)
|
||||||
|
is ApiResult.HttpError -> UiState.Error(
|
||||||
|
kind = when (status) {
|
||||||
|
404 -> ErrorKind.NOT_FOUND
|
||||||
|
429 -> ErrorKind.RATE_LIMITED
|
||||||
|
503 -> ErrorKind.SHARD_OFFLINE
|
||||||
|
else -> ErrorKind.SERVER
|
||||||
|
},
|
||||||
|
httpStatus = status,
|
||||||
|
)
|
||||||
|
}
|
||||||
362
app/src/main/java/com/runicgateway/app/ui/auth/AccountScreen.kt
Normal file
362
app/src/main/java/com/runicgateway/app/ui/auth/AccountScreen.kt
Normal file
@@ -0,0 +1,362 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
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.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
|
||||||
|
import com.runicgateway.app.ui.components.PillTone
|
||||||
|
import com.runicgateway.app.ui.components.StatusPill
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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(
|
||||||
|
username: String,
|
||||||
|
roleLabel: String,
|
||||||
|
onSignOut: () -> Unit,
|
||||||
|
onSignOutEverywhere: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
viewModel: AccountViewModel = hiltViewModel(),
|
||||||
|
) {
|
||||||
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(16.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)
|
||||||
|
}
|
||||||
|
|
||||||
|
HorizontalDivider(Modifier.padding(vertical = 20.dp))
|
||||||
|
|
||||||
|
OutlinedButton(onClick = onSignOut, modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Text(stringResource(R.string.account_sign_out))
|
||||||
|
}
|
||||||
|
TextButton(
|
||||||
|
onClick = onSignOutEverywhere,
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.account_sign_out_all))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun IdentityCard(username: String, roleLabel: String) {
|
||||||
|
Card(Modifier.fillMaxWidth()) {
|
||||||
|
Column(Modifier.padding(20.dp)) {
|
||||||
|
Text(text = username, style = MaterialTheme.typography.titleLarge)
|
||||||
|
StatusPill(
|
||||||
|
text = roleLabel,
|
||||||
|
tone = PillTone.Info,
|
||||||
|
modifier = Modifier.padding(top = 8.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) {
|
||||||
|
StatusPill(
|
||||||
|
text = stringResource(if (account.totp_enabled) R.string.account_totp_on else R.string.account_totp_off),
|
||||||
|
tone = if (account.totp_enabled) PillTone.Success else PillTone.Neutral,
|
||||||
|
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) {
|
||||||
|
Role.PLAYER -> R.string.role_player
|
||||||
|
Role.MODERATOR -> R.string.role_moderator
|
||||||
|
Role.EDITOR -> R.string.role_editor
|
||||||
|
Role.ADMIN -> R.string.role_admin
|
||||||
|
Role.UNKNOWN -> R.string.role_unknown
|
||||||
|
}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.ui.auth
|
||||||
|
|
||||||
|
import androidx.annotation.StringRes
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.runicgateway.app.R
|
||||||
|
import com.runicgateway.app.core.result.ApiResult
|
||||||
|
import com.runicgateway.app.data.api.dto.LinkedIdentityDto
|
||||||
|
import com.runicgateway.app.data.api.dto.PlayerAccountDto
|
||||||
|
import com.runicgateway.app.data.api.dto.TotpSetupDto
|
||||||
|
import com.runicgateway.app.data.repository.AccountRepository
|
||||||
|
import com.runicgateway.app.data.repository.AuthRepository
|
||||||
|
import com.runicgateway.app.ui.UiState
|
||||||
|
import com.runicgateway.app.ui.toUiState
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drives the signed-in self-service surface (PLAN.md §6.3, §6.4) over
|
||||||
|
* `/auth/me/account*`: change username/password, enroll/disable TOTP, and manage
|
||||||
|
* linked SSO identities. Each mutation folds its [ApiResult] into a section-scoped
|
||||||
|
* [Feedback] so the screen shows friendly, localized copy inline (§7). A username
|
||||||
|
* change also re-validates the session so the shell reflects the new name at once.
|
||||||
|
*/
|
||||||
|
@HiltViewModel
|
||||||
|
class AccountViewModel @Inject constructor(
|
||||||
|
private val accountRepository: AccountRepository,
|
||||||
|
private val authRepository: AuthRepository,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
/** Which section an action's [Feedback] belongs to, so it renders in place. */
|
||||||
|
enum class Section { USERNAME, PASSWORD, TOTP, IDENTITY }
|
||||||
|
|
||||||
|
/** A one-shot result banner under a section. */
|
||||||
|
data class Feedback(val section: Section, val ok: Boolean, @param:StringRes val messageRes: Int)
|
||||||
|
|
||||||
|
data class State(
|
||||||
|
val account: UiState<PlayerAccountDto> = UiState.Loading,
|
||||||
|
val identities: List<LinkedIdentityDto> = emptyList(),
|
||||||
|
/** True while any mutation is in flight (disables that section's controls). */
|
||||||
|
val busy: Boolean = false,
|
||||||
|
/** The pending TOTP enrollment (QR shown) between setup and enable. */
|
||||||
|
val totpSetup: TotpSetupDto? = null,
|
||||||
|
val feedback: Feedback? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
private val _state = MutableStateFlow(State())
|
||||||
|
val state: StateFlow<State> = _state.asStateFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun load() {
|
||||||
|
_state.update { it.copy(account = UiState.Loading) }
|
||||||
|
viewModelScope.launch {
|
||||||
|
val account = accountRepository.getAccount()
|
||||||
|
_state.update { it.copy(account = account.toUiState()) }
|
||||||
|
// Identities are non-critical — an empty list on failure is fine.
|
||||||
|
when (val ids = accountRepository.identities()) {
|
||||||
|
is ApiResult.Ok -> _state.update { it.copy(identities = ids.data) }
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearFeedback() = _state.update { it.copy(feedback = null) }
|
||||||
|
|
||||||
|
// ── Username ─────────────────────────────────────────────────────────
|
||||||
|
fun changeUsername(username: String) {
|
||||||
|
if (_state.value.busy) return
|
||||||
|
_state.update { it.copy(busy = true, feedback = null) }
|
||||||
|
viewModelScope.launch {
|
||||||
|
when (val result = accountRepository.changeUsername(username.trim())) {
|
||||||
|
is ApiResult.Ok -> {
|
||||||
|
finish(Section.USERNAME, true, R.string.account_username_changed)
|
||||||
|
// Reflect the new name in the shell + refresh the loaded account.
|
||||||
|
authRepository.revalidate()
|
||||||
|
reloadAccount()
|
||||||
|
}
|
||||||
|
else -> finish(Section.USERNAME, false, usernameErrorRes(result))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Password ─────────────────────────────────────────────────────────
|
||||||
|
fun changePassword(newPassword: String, currentPassword: String?) {
|
||||||
|
if (_state.value.busy) return
|
||||||
|
_state.update { it.copy(busy = true, feedback = null) }
|
||||||
|
viewModelScope.launch {
|
||||||
|
when (accountRepository.changePassword(newPassword, currentPassword?.takeIf { it.isNotBlank() })) {
|
||||||
|
is ApiResult.Ok -> finish(Section.PASSWORD, true, R.string.account_password_changed)
|
||||||
|
is ApiResult.HttpError -> finish(Section.PASSWORD, false, R.string.account_password_error)
|
||||||
|
is ApiResult.NetworkError -> finish(Section.PASSWORD, false, R.string.error_network)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── TOTP ─────────────────────────────────────────────────────────────
|
||||||
|
fun beginTotp() {
|
||||||
|
if (_state.value.busy) return
|
||||||
|
_state.update { it.copy(busy = true, feedback = null) }
|
||||||
|
viewModelScope.launch {
|
||||||
|
when (val result = accountRepository.totpSetup()) {
|
||||||
|
is ApiResult.Ok -> _state.update { it.copy(busy = false, totpSetup = result.data) }
|
||||||
|
else -> finish(Section.TOTP, false, R.string.account_totp_setup_error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cancelTotp() = _state.update { it.copy(totpSetup = null, feedback = null) }
|
||||||
|
|
||||||
|
fun enableTotp(code: String) {
|
||||||
|
if (_state.value.busy) return
|
||||||
|
_state.update { it.copy(busy = true, feedback = null) }
|
||||||
|
viewModelScope.launch {
|
||||||
|
when (accountRepository.totpEnable(code.trim())) {
|
||||||
|
is ApiResult.Ok -> {
|
||||||
|
_state.update { it.copy(totpSetup = null) }
|
||||||
|
finish(Section.TOTP, true, R.string.account_totp_enabled)
|
||||||
|
reloadAccount()
|
||||||
|
}
|
||||||
|
is ApiResult.HttpError -> finish(Section.TOTP, false, R.string.account_totp_code_error)
|
||||||
|
is ApiResult.NetworkError -> finish(Section.TOTP, false, R.string.error_network)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun disableTotp(code: String) {
|
||||||
|
if (_state.value.busy) return
|
||||||
|
_state.update { it.copy(busy = true, feedback = null) }
|
||||||
|
viewModelScope.launch {
|
||||||
|
when (accountRepository.totpDisable(code.trim())) {
|
||||||
|
is ApiResult.Ok -> {
|
||||||
|
finish(Section.TOTP, true, R.string.account_totp_disabled)
|
||||||
|
reloadAccount()
|
||||||
|
}
|
||||||
|
is ApiResult.HttpError -> finish(Section.TOTP, false, R.string.account_totp_code_error)
|
||||||
|
is ApiResult.NetworkError -> finish(Section.TOTP, false, R.string.error_network)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Identities ───────────────────────────────────────────────────────
|
||||||
|
fun unlinkIdentity(provider: String) {
|
||||||
|
if (_state.value.busy) return
|
||||||
|
_state.update { it.copy(busy = true, feedback = null) }
|
||||||
|
viewModelScope.launch {
|
||||||
|
when (accountRepository.unlinkIdentity(provider)) {
|
||||||
|
is ApiResult.Ok -> {
|
||||||
|
finish(Section.IDENTITY, true, R.string.account_identity_unlinked)
|
||||||
|
when (val ids = accountRepository.identities()) {
|
||||||
|
is ApiResult.Ok -> _state.update { it.copy(identities = ids.data) }
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> finish(Section.IDENTITY, false, R.string.account_identity_error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun reloadAccount() {
|
||||||
|
when (val account = accountRepository.getAccount()) {
|
||||||
|
is ApiResult.Ok -> _state.update { it.copy(account = UiState.Success(account.data)) }
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun finish(section: Section, ok: Boolean, @StringRes messageRes: Int) =
|
||||||
|
_state.update { it.copy(busy = false, feedback = Feedback(section, ok, messageRes)) }
|
||||||
|
|
||||||
|
private fun usernameErrorRes(result: ApiResult<*>): Int = when {
|
||||||
|
result is ApiResult.HttpError && result.status == 409 -> R.string.account_username_taken
|
||||||
|
result is ApiResult.NetworkError -> R.string.error_network
|
||||||
|
else -> R.string.account_username_error
|
||||||
|
}
|
||||||
|
}
|
||||||
184
app/src/main/java/com/runicgateway/app/ui/auth/LoginScreen.kt
Normal file
184
app/src/main/java/com/runicgateway/app/ui/auth/LoginScreen.kt
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.ui.auth
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
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.text.KeyboardActions
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
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.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import com.runicgateway.app.R
|
||||||
|
import com.runicgateway.app.core.web.WebHandoff
|
||||||
|
import com.runicgateway.app.ui.auth.LoginViewModel.LoginError
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Native username/password (+TOTP) login (PLAN.md §4.1) — the app's only native
|
||||||
|
* credential screen. Registration, forgot-password, and SSO are website hand-offs
|
||||||
|
* opened in a Custom Tab (§4.2); the user completes them in the browser and
|
||||||
|
* returns here to sign in.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun LoginScreen(
|
||||||
|
onSignedIn: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
viewModel: LoginViewModel = hiltViewModel(),
|
||||||
|
) {
|
||||||
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
LaunchedEffect(state.signedIn) {
|
||||||
|
if (state.signedIn) onSignedIn()
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(24.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.login_title),
|
||||||
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.login_subtitle),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
modifier = Modifier.padding(top = 8.dp, bottom = 24.dp),
|
||||||
|
)
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = state.username,
|
||||||
|
onValueChange = viewModel::onUsernameChange,
|
||||||
|
singleLine = true,
|
||||||
|
enabled = !state.submitting,
|
||||||
|
label = { Text(stringResource(R.string.login_username)) },
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
keyboardType = KeyboardType.Text,
|
||||||
|
imeAction = ImeAction.Next,
|
||||||
|
),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = state.password,
|
||||||
|
onValueChange = viewModel::onPasswordChange,
|
||||||
|
singleLine = true,
|
||||||
|
enabled = !state.submitting,
|
||||||
|
label = { Text(stringResource(R.string.login_password)) },
|
||||||
|
visualTransformation = PasswordVisualTransformation(),
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
keyboardType = KeyboardType.Password,
|
||||||
|
imeAction = if (state.totpRequired) ImeAction.Next else ImeAction.Go,
|
||||||
|
),
|
||||||
|
keyboardActions = KeyboardActions(onGo = { viewModel.submit() }),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(top = 12.dp),
|
||||||
|
)
|
||||||
|
|
||||||
|
if (state.totpRequired) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = state.code,
|
||||||
|
onValueChange = viewModel::onCodeChange,
|
||||||
|
singleLine = true,
|
||||||
|
enabled = !state.submitting,
|
||||||
|
label = { Text(stringResource(R.string.login_totp_code)) },
|
||||||
|
supportingText = { Text(stringResource(R.string.login_totp_hint)) },
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
keyboardType = KeyboardType.NumberPassword,
|
||||||
|
imeAction = ImeAction.Go,
|
||||||
|
),
|
||||||
|
keyboardActions = KeyboardActions(onGo = { viewModel.submit() }),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(top = 12.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
state.error?.let { err ->
|
||||||
|
Text(
|
||||||
|
text = stringResource(loginErrorRes(err)),
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(top = 12.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = viewModel::submit,
|
||||||
|
enabled = !state.submitting,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(top = 20.dp),
|
||||||
|
) {
|
||||||
|
if (state.submitting) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
strokeWidth = 2.dp,
|
||||||
|
modifier = Modifier.size(20.dp),
|
||||||
|
color = MaterialTheme.colorScheme.onPrimary,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Text(stringResource(R.string.login_button))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Website hand-offs (§4.2): open the site's own pages in a Custom Tab ──
|
||||||
|
viewModel.registerUrl?.let { url ->
|
||||||
|
TextButton(
|
||||||
|
onClick = { WebHandoff.open(context, url) },
|
||||||
|
modifier = Modifier.padding(top = 16.dp),
|
||||||
|
) { Text(stringResource(R.string.login_register)) }
|
||||||
|
}
|
||||||
|
viewModel.forgotPasswordUrl?.let { url ->
|
||||||
|
TextButton(onClick = { WebHandoff.open(context, url) }) {
|
||||||
|
Text(stringResource(R.string.login_forgot))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
viewModel.ssoLoginUrl?.let { url ->
|
||||||
|
TextButton(onClick = { WebHandoff.open(context, url) }) {
|
||||||
|
Text(stringResource(R.string.login_sso))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loginErrorRes(error: LoginError): Int = when (error) {
|
||||||
|
LoginError.INVALID_CREDENTIALS -> R.string.login_error_credentials
|
||||||
|
LoginError.BAD_CODE -> R.string.login_error_code
|
||||||
|
LoginError.RATE_LIMITED -> R.string.login_error_rate_limited
|
||||||
|
LoginError.SERVER -> R.string.login_error_server
|
||||||
|
LoginError.NETWORK -> R.string.login_error_network
|
||||||
|
}
|
||||||
101
app/src/main/java/com/runicgateway/app/ui/auth/LoginViewModel.kt
Normal file
101
app/src/main/java/com/runicgateway/app/ui/auth/LoginViewModel.kt
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.ui.auth
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.runicgateway.app.core.web.WebsiteUrls
|
||||||
|
import com.runicgateway.app.data.repository.AuthRepository
|
||||||
|
import com.runicgateway.app.data.repository.AuthRepository.LoginResult
|
||||||
|
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 native login screen (PLAN.md §4.1): username/password, single-request
|
||||||
|
* TOTP (the code field is revealed once the backend answers `totpRequired`), and
|
||||||
|
* friendly 429 backoff handling. Registration / forgot-password / SSO are website
|
||||||
|
* hand-offs whose URLs it exposes (§4.2).
|
||||||
|
*/
|
||||||
|
@HiltViewModel
|
||||||
|
class LoginViewModel @Inject constructor(
|
||||||
|
private val authRepository: AuthRepository,
|
||||||
|
private val websiteUrls: WebsiteUrls,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
/** The transient error surfaced under the form after a failed attempt. */
|
||||||
|
enum class LoginError { INVALID_CREDENTIALS, BAD_CODE, RATE_LIMITED, SERVER, NETWORK }
|
||||||
|
|
||||||
|
data class UiState(
|
||||||
|
val username: String = "",
|
||||||
|
val password: String = "",
|
||||||
|
val code: String = "",
|
||||||
|
/** True once the account is known to have 2FA on — reveal the code field. */
|
||||||
|
val totpRequired: Boolean = false,
|
||||||
|
val submitting: Boolean = false,
|
||||||
|
val error: LoginError? = null,
|
||||||
|
val signedIn: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
private val _state = MutableStateFlow(UiState())
|
||||||
|
val state: StateFlow<UiState> = _state.asStateFlow()
|
||||||
|
|
||||||
|
fun onUsernameChange(value: String) = _state.update { it.copy(username = value, error = null) }
|
||||||
|
fun onPasswordChange(value: String) = _state.update { it.copy(password = value, error = null) }
|
||||||
|
fun onCodeChange(value: String) =
|
||||||
|
_state.update { it.copy(code = value.filter(Char::isDigit).take(8), error = null) }
|
||||||
|
|
||||||
|
val registerUrl: String? get() = websiteUrls.register()
|
||||||
|
val forgotPasswordUrl: String? get() = websiteUrls.forgotPassword()
|
||||||
|
val ssoLoginUrl: String? get() = websiteUrls.login()
|
||||||
|
|
||||||
|
fun submit() {
|
||||||
|
val s = _state.value
|
||||||
|
if (s.submitting) return
|
||||||
|
if (s.username.isBlank() || s.password.isBlank()) {
|
||||||
|
_state.update { it.copy(error = LoginError.INVALID_CREDENTIALS) }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// If 2FA is being requested, a code must accompany the resubmit.
|
||||||
|
if (s.totpRequired && s.code.isBlank()) {
|
||||||
|
_state.update { it.copy(error = LoginError.BAD_CODE) }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_state.update { it.copy(submitting = true, error = null) }
|
||||||
|
viewModelScope.launch {
|
||||||
|
val code = s.code.trim().takeIf { it.isNotBlank() }
|
||||||
|
when (authRepository.login(s.username.trim(), s.password, code)) {
|
||||||
|
LoginResult.Success ->
|
||||||
|
_state.update { it.copy(submitting = false, signedIn = true) }
|
||||||
|
|
||||||
|
LoginResult.TotpRequired ->
|
||||||
|
// Reveal the code field; a wrong code re-lands here as BAD_CODE.
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
submitting = false,
|
||||||
|
totpRequired = true,
|
||||||
|
error = if (it.code.isNotBlank()) LoginError.BAD_CODE else null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
LoginResult.InvalidCredentials ->
|
||||||
|
_state.update { it.copy(submitting = false, error = LoginError.INVALID_CREDENTIALS) }
|
||||||
|
|
||||||
|
LoginResult.RateLimited ->
|
||||||
|
_state.update { it.copy(submitting = false, error = LoginError.RATE_LIMITED) }
|
||||||
|
|
||||||
|
LoginResult.ServerError ->
|
||||||
|
_state.update { it.copy(submitting = false, error = LoginError.SERVER) }
|
||||||
|
|
||||||
|
LoginResult.NetworkError ->
|
||||||
|
_state.update { it.copy(submitting = false, error = LoginError.NETWORK) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.ui.components
|
||||||
|
|
||||||
|
import android.text.method.LinkMovementMethod
|
||||||
|
import android.widget.TextView
|
||||||
|
import androidx.compose.material3.LocalContentColor
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.toArgb
|
||||||
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
|
import androidx.core.text.HtmlCompat
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders server-authored HTML (post/wiki bodies, `rich_text` blocks) via a
|
||||||
|
* native [TextView] + [HtmlCompat]. Compose has no HTML renderer; for the
|
||||||
|
* functional pass this is the pragmatic path and handles links, lists, and
|
||||||
|
* basic markup. The body is already sanitized server-side (website `cleanBody`).
|
||||||
|
* The M5 design pass can revisit richer/native rendering.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun HtmlText(html: String, modifier: Modifier = Modifier) {
|
||||||
|
val textColor = LocalContentColor.current.toArgb()
|
||||||
|
val linkColor = MaterialTheme.colorScheme.primary.toArgb()
|
||||||
|
AndroidView(
|
||||||
|
modifier = modifier,
|
||||||
|
factory = { context ->
|
||||||
|
TextView(context).apply {
|
||||||
|
movementMethod = LinkMovementMethod.getInstance()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
update = { view ->
|
||||||
|
view.setTextColor(textColor)
|
||||||
|
view.setLinkTextColor(linkColor)
|
||||||
|
view.text = HtmlCompat.fromHtml(html, HtmlCompat.FROM_HTML_MODE_COMPACT)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.ui.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.runicgateway.app.R
|
||||||
|
import com.runicgateway.app.ui.ErrorKind
|
||||||
|
|
||||||
|
/** Centered progress indicator for a whole-screen loading state (§7). */
|
||||||
|
@Composable
|
||||||
|
fun LoadingView(modifier: Modifier = Modifier) {
|
||||||
|
Column(
|
||||||
|
modifier = modifier.fillMaxSize().padding(24.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whole-screen error state with a friendly, kind-specific message and a Retry
|
||||||
|
* button (§7). Copy is resolved from string resources so it stays localizable.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun ErrorView(
|
||||||
|
kind: ErrorKind,
|
||||||
|
onRetry: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = modifier.fillMaxSize().padding(24.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(errorMessageRes(kind)),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
Button(
|
||||||
|
onClick = onRetry,
|
||||||
|
modifier = Modifier.padding(top = 16.dp).width(160.dp),
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.action_retry))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Centered informational message for an empty list (§7). */
|
||||||
|
@Composable
|
||||||
|
fun EmptyView(message: String, modifier: Modifier = Modifier) {
|
||||||
|
Column(
|
||||||
|
modifier = modifier.fillMaxSize().padding(24.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = message,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun errorMessageRes(kind: ErrorKind): Int = when (kind) {
|
||||||
|
ErrorKind.NETWORK -> R.string.error_network
|
||||||
|
ErrorKind.NOT_FOUND -> R.string.error_not_found
|
||||||
|
ErrorKind.RATE_LIMITED -> R.string.error_rate_limited
|
||||||
|
ErrorKind.SHARD_OFFLINE -> R.string.error_shard_offline
|
||||||
|
ErrorKind.SERVER -> R.string.error_server
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.ui.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.ColumnScope
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.runicgateway.app.ui.theme.ShardCardBottom
|
||||||
|
import com.runicgateway.app.ui.theme.ShardCardTop
|
||||||
|
import com.runicgateway.app.ui.theme.ShardDanger
|
||||||
|
import com.runicgateway.app.ui.theme.ShardDangerBg
|
||||||
|
import com.runicgateway.app.ui.theme.ShardElevated
|
||||||
|
import com.runicgateway.app.ui.theme.ShardFaint
|
||||||
|
import com.runicgateway.app.ui.theme.ShardOutline
|
||||||
|
import com.runicgateway.app.ui.theme.ShardPillBg
|
||||||
|
import com.runicgateway.app.ui.theme.ShardPillFg
|
||||||
|
import com.runicgateway.app.ui.theme.ShardSuccess
|
||||||
|
import com.runicgateway.app.ui.theme.ShardSuccessBg
|
||||||
|
import com.runicgateway.app.ui.theme.ShardSuccessDot
|
||||||
|
import com.runicgateway.app.ui.theme.ShardWarning
|
||||||
|
import com.runicgateway.app.ui.theme.ShardWarningBg
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared visual building blocks for the M5 shard-website design pass
|
||||||
|
* (docs/android/PLAN.md §M5): the recurring pill, section-label, feature-card,
|
||||||
|
* and stat-bar motifs the mockup repeats across screens. Pure presentation —
|
||||||
|
* no state, no data dependencies — so any screen can adopt them.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Semantic tone for a [StatusPill] / [OnlineDot]. */
|
||||||
|
enum class PillTone { Success, Warning, Danger, Neutral, Info }
|
||||||
|
|
||||||
|
private data class PillColors(val fg: Color, val bg: Color)
|
||||||
|
|
||||||
|
private fun toneColors(tone: PillTone): PillColors = when (tone) {
|
||||||
|
PillTone.Success -> PillColors(ShardSuccess, ShardSuccessBg)
|
||||||
|
PillTone.Warning -> PillColors(ShardWarning, ShardWarningBg)
|
||||||
|
PillTone.Danger -> PillColors(ShardDanger, ShardDangerBg)
|
||||||
|
PillTone.Neutral, PillTone.Info -> PillColors(ShardPillFg, ShardPillBg)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A small uppercase status chip — "Live", "Up", "Enabled", "IDOC", a role — with a
|
||||||
|
* rounded filled background tinted by [tone]. Mirrors the mockup's pill badges.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun StatusPill(text: String, tone: PillTone, modifier: Modifier = Modifier) {
|
||||||
|
val c = toneColors(tone)
|
||||||
|
Surface(color = c.bg, shape = CircleShape, modifier = modifier) {
|
||||||
|
Text(
|
||||||
|
text = text.uppercase(),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = c.fg,
|
||||||
|
modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The small colored status dot (green when live), matched to a [PillTone]. */
|
||||||
|
@Composable
|
||||||
|
fun OnlineDot(tone: PillTone, modifier: Modifier = Modifier) {
|
||||||
|
val color = when (tone) {
|
||||||
|
PillTone.Success -> ShardSuccessDot
|
||||||
|
PillTone.Warning -> ShardWarning
|
||||||
|
PillTone.Danger -> ShardDanger
|
||||||
|
PillTone.Neutral, PillTone.Info -> ShardFaint
|
||||||
|
}
|
||||||
|
Box(modifier.size(8.dp).clip(CircleShape).background(color))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The muted, uppercase, letter-spaced section header the design uses above grouped
|
||||||
|
* content ("Attributes", "Vitals", "Password", "Linked accounts").
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun SectionLabel(text: String, modifier: Modifier = Modifier) {
|
||||||
|
Text(
|
||||||
|
text = text.uppercase(),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = ShardFaint,
|
||||||
|
modifier = modifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The elevated "feature" card: a vertical blue gradient with a hairline outline and
|
||||||
|
* soft shadow, used for the home status card, the shard-online banner, and the
|
||||||
|
* vendor card. [content] is laid out in a padded [Column].
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun FeatureCard(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
contentPadding: Int = 18,
|
||||||
|
content: @Composable ColumnScope.() -> Unit,
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clip(RoundedCornerShape(12.dp))
|
||||||
|
.background(Brush.verticalGradient(listOf(ShardCardTop, ShardCardBottom)))
|
||||||
|
.border(1.dp, ShardOutline, RoundedCornerShape(12.dp)),
|
||||||
|
) {
|
||||||
|
Column(Modifier.padding(contentPadding.dp), content = content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A slim rounded meter (vitals / skills). [fraction] is clamped to 0..1; the fill is
|
||||||
|
* the slate accent over a bordered dark track.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun StatBar(fraction: Float, modifier: Modifier = Modifier) {
|
||||||
|
val pct = fraction.coerceIn(0f, 1f)
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(6.dp)
|
||||||
|
.clip(RoundedCornerShape(3.dp))
|
||||||
|
.background(ShardElevated)
|
||||||
|
.border(1.dp, ShardOutline, RoundedCornerShape(3.dp)),
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth(pct)
|
||||||
|
.height(6.dp)
|
||||||
|
.clip(RoundedCornerShape(3.dp))
|
||||||
|
.background(MaterialTheme.colorScheme.secondary),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.ui.connect
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
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.text.KeyboardActions
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import com.runicgateway.app.R
|
||||||
|
import com.runicgateway.app.ui.connect.ConnectViewModel.ConnectError
|
||||||
|
|
||||||
|
/**
|
||||||
|
* First-run "Connect to your shard's website" screen (PLAN.md §3). Nothing else
|
||||||
|
* in the app runs until a valid Runic Gateway site is entered and validated.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun ConnectScreen(
|
||||||
|
onConnected: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
viewModel: ConnectViewModel = hiltViewModel(),
|
||||||
|
) {
|
||||||
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(24.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.connect_title),
|
||||||
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.connect_subtitle),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
modifier = Modifier.padding(top = 8.dp, bottom = 24.dp),
|
||||||
|
)
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = state.input,
|
||||||
|
onValueChange = viewModel::onInputChange,
|
||||||
|
singleLine = true,
|
||||||
|
enabled = !state.submitting,
|
||||||
|
isError = state.error != null,
|
||||||
|
label = { Text(stringResource(R.string.connect_url_label)) },
|
||||||
|
placeholder = { Text(stringResource(R.string.connect_url_hint)) },
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
keyboardType = KeyboardType.Uri,
|
||||||
|
imeAction = ImeAction.Go,
|
||||||
|
),
|
||||||
|
keyboardActions = KeyboardActions(onGo = { viewModel.connect(onConnected) }),
|
||||||
|
supportingText = state.error?.let { err ->
|
||||||
|
{ Text(errorMessage(err), color = MaterialTheme.colorScheme.error) }
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = { viewModel.connect(onConnected) },
|
||||||
|
enabled = !state.submitting,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(top = 20.dp),
|
||||||
|
) {
|
||||||
|
if (state.submitting) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
strokeWidth = 2.dp,
|
||||||
|
modifier = Modifier.size(20.dp),
|
||||||
|
color = MaterialTheme.colorScheme.onPrimary,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Text(stringResource(R.string.connect_button))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun errorMessage(error: ConnectError): String = when (error) {
|
||||||
|
ConnectError.Blank -> stringResource(R.string.connect_error_blank)
|
||||||
|
ConnectError.Malformed -> stringResource(R.string.connect_error_malformed)
|
||||||
|
ConnectError.UnsupportedScheme -> stringResource(R.string.connect_error_scheme)
|
||||||
|
ConnectError.Insecure -> stringResource(R.string.connect_error_insecure)
|
||||||
|
ConnectError.NotRunicGateway -> stringResource(R.string.connect_error_not_runic)
|
||||||
|
is ConnectError.VersionMismatch -> stringResource(R.string.connect_error_version, error.serverApi)
|
||||||
|
ConnectError.Unreachable -> stringResource(R.string.connect_error_unreachable)
|
||||||
|
is ConnectError.Server -> stringResource(R.string.connect_error_server, error.status)
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.ui.connect
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.runicgateway.app.core.net.ServerUrl
|
||||||
|
import com.runicgateway.app.data.repository.ConnectionRepository
|
||||||
|
import com.runicgateway.app.data.repository.ConnectionRepository.ProbeResult
|
||||||
|
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 first-run connect screen (PLAN.md §3): validates + probes the
|
||||||
|
* entered base URL, and on success activates it for the whole app. The error is
|
||||||
|
* exposed as a resource-free [ConnectError] the screen maps to localized copy.
|
||||||
|
*/
|
||||||
|
@HiltViewModel
|
||||||
|
class ConnectViewModel @Inject constructor(
|
||||||
|
private val connectionRepository: ConnectionRepository,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
sealed interface ConnectError {
|
||||||
|
data object Blank : ConnectError
|
||||||
|
data object Malformed : ConnectError
|
||||||
|
data object UnsupportedScheme : ConnectError
|
||||||
|
data object Insecure : ConnectError
|
||||||
|
data object NotRunicGateway : ConnectError
|
||||||
|
data class VersionMismatch(val serverApi: String) : ConnectError
|
||||||
|
data object Unreachable : ConnectError
|
||||||
|
data class Server(val status: Int) : ConnectError
|
||||||
|
}
|
||||||
|
|
||||||
|
data class UiState(
|
||||||
|
val input: String = "",
|
||||||
|
val submitting: Boolean = false,
|
||||||
|
val error: ConnectError? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
private val _state = MutableStateFlow(UiState())
|
||||||
|
val state: StateFlow<UiState> = _state.asStateFlow()
|
||||||
|
|
||||||
|
fun onInputChange(value: String) {
|
||||||
|
_state.update { it.copy(input = value, error = null) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun connect(onSuccess: () -> Unit) {
|
||||||
|
if (_state.value.submitting) return
|
||||||
|
_state.update { it.copy(submitting = true, error = null) }
|
||||||
|
viewModelScope.launch {
|
||||||
|
val result = connectionRepository.probeAndConnect(_state.value.input)
|
||||||
|
when (result) {
|
||||||
|
is ProbeResult.Success -> {
|
||||||
|
_state.update { it.copy(submitting = false) }
|
||||||
|
onSuccess()
|
||||||
|
}
|
||||||
|
is ProbeResult.InvalidUrl -> fail(result.reason.toError())
|
||||||
|
ProbeResult.NotRunicGateway -> fail(ConnectError.NotRunicGateway)
|
||||||
|
is ProbeResult.VersionMismatch -> fail(ConnectError.VersionMismatch(result.serverApi))
|
||||||
|
is ProbeResult.Unreachable -> fail(ConnectError.Unreachable)
|
||||||
|
is ProbeResult.ServerError -> fail(ConnectError.Server(result.status))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun fail(error: ConnectError) {
|
||||||
|
_state.update { it.copy(submitting = false, error = error) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ServerUrl.Reason.toError(): ConnectError = when (this) {
|
||||||
|
ServerUrl.Reason.BLANK -> ConnectError.Blank
|
||||||
|
ServerUrl.Reason.MALFORMED -> ConnectError.Malformed
|
||||||
|
ServerUrl.Reason.UNSUPPORTED_SCHEME -> ConnectError.UnsupportedScheme
|
||||||
|
ServerUrl.Reason.INSECURE -> ConnectError.Insecure
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.ui.contact
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
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.input.KeyboardType
|
||||||
|
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.ui.ErrorKind
|
||||||
|
import com.runicgateway.app.ui.contact.ContactViewModel.Result
|
||||||
|
|
||||||
|
/** Contact form (PLAN.md §6.1). Handles validation, send, mailto-fallback, errors. */
|
||||||
|
@Composable
|
||||||
|
fun ContactScreen(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
viewModel: ContactViewModel = hiltViewModel(),
|
||||||
|
) {
|
||||||
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(20.dp),
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.contact_title), style = MaterialTheme.typography.headlineSmall)
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = state.name,
|
||||||
|
onValueChange = viewModel::onNameChange,
|
||||||
|
singleLine = true,
|
||||||
|
enabled = !state.submitting,
|
||||||
|
label = { Text(stringResource(R.string.contact_name)) },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
OutlinedTextField(
|
||||||
|
value = state.email,
|
||||||
|
onValueChange = viewModel::onEmailChange,
|
||||||
|
singleLine = true,
|
||||||
|
enabled = !state.submitting,
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
|
||||||
|
label = { Text(stringResource(R.string.contact_email)) },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
OutlinedTextField(
|
||||||
|
value = state.message,
|
||||||
|
onValueChange = viewModel::onMessageChange,
|
||||||
|
enabled = !state.submitting,
|
||||||
|
minLines = 4,
|
||||||
|
label = { Text(stringResource(R.string.contact_message)) },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
|
||||||
|
state.result?.let { ResultMessage(it) }
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = viewModel::send,
|
||||||
|
enabled = !state.submitting,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(top = 20.dp),
|
||||||
|
) {
|
||||||
|
if (state.submitting) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
strokeWidth = 2.dp,
|
||||||
|
modifier = Modifier.size(20.dp),
|
||||||
|
color = MaterialTheme.colorScheme.onPrimary,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Text(stringResource(R.string.contact_send))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ResultMessage(result: Result) {
|
||||||
|
val (text, isError) = when (result) {
|
||||||
|
Result.Sent -> stringResource(R.string.contact_sent) to false
|
||||||
|
is Result.Fallback -> stringResource(R.string.contact_fallback, result.email ?: "") to false
|
||||||
|
Result.ValidationError -> stringResource(R.string.contact_validation) to true
|
||||||
|
is Result.Failed -> errorText(result.kind) to true
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = if (isError) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.padding(top = 12.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun errorText(kind: ErrorKind): String = when (kind) {
|
||||||
|
ErrorKind.NETWORK -> stringResource(R.string.error_network)
|
||||||
|
ErrorKind.RATE_LIMITED -> stringResource(R.string.error_rate_limited)
|
||||||
|
else -> stringResource(R.string.contact_error)
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.ui.contact
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.runicgateway.app.core.result.ApiResult
|
||||||
|
import com.runicgateway.app.data.repository.ContactRepository
|
||||||
|
import com.runicgateway.app.ui.ErrorKind
|
||||||
|
import com.runicgateway.app.ui.toUiState
|
||||||
|
import com.runicgateway.app.ui.UiState as LoadUiState
|
||||||
|
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
|
||||||
|
|
||||||
|
/** Contact form submission with client-side validation (PLAN.md §6.1). */
|
||||||
|
@HiltViewModel
|
||||||
|
class ContactViewModel @Inject constructor(
|
||||||
|
private val contactRepository: ContactRepository,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
sealed interface Result {
|
||||||
|
data object Sent : Result
|
||||||
|
|
||||||
|
/** Site has no mailer; email directly at [email] instead. */
|
||||||
|
data class Fallback(val email: String?) : Result
|
||||||
|
data object ValidationError : Result
|
||||||
|
data class Failed(val kind: ErrorKind) : Result
|
||||||
|
}
|
||||||
|
|
||||||
|
data class UiState(
|
||||||
|
val name: String = "",
|
||||||
|
val email: String = "",
|
||||||
|
val message: String = "",
|
||||||
|
val submitting: Boolean = false,
|
||||||
|
val result: Result? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
private val _state = MutableStateFlow(UiState())
|
||||||
|
val state: StateFlow<UiState> = _state.asStateFlow()
|
||||||
|
|
||||||
|
fun onNameChange(value: String) = _state.update { it.copy(name = value, result = null) }
|
||||||
|
fun onEmailChange(value: String) = _state.update { it.copy(email = value, result = null) }
|
||||||
|
fun onMessageChange(value: String) = _state.update { it.copy(message = value, result = null) }
|
||||||
|
|
||||||
|
fun send() {
|
||||||
|
val s = _state.value
|
||||||
|
if (s.submitting) return
|
||||||
|
if (s.name.isBlank() || s.email.isBlank() || s.message.isBlank()) {
|
||||||
|
_state.update { it.copy(result = Result.ValidationError) }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_state.update { it.copy(submitting = true, result = null) }
|
||||||
|
viewModelScope.launch {
|
||||||
|
val result = when (val r = contactRepository.send(s.name.trim(), s.email.trim(), s.message.trim())) {
|
||||||
|
is ApiResult.Ok ->
|
||||||
|
if (r.data.sent) Result.Sent else Result.Fallback(r.data.email)
|
||||||
|
else -> {
|
||||||
|
val kind = (r.toUiState() as? LoadUiState.Error)?.kind ?: ErrorKind.SERVER
|
||||||
|
Result.Failed(kind)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_state.update {
|
||||||
|
// Clear the fields on a successful send; keep them on failure so the
|
||||||
|
// user can retry without retyping.
|
||||||
|
if (result is Result.Sent) {
|
||||||
|
UiState(result = result)
|
||||||
|
} else {
|
||||||
|
it.copy(submitting = false, result = result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
122
app/src/main/java/com/runicgateway/app/ui/home/HomeScreen.kt
Normal file
122
app/src/main/java/com/runicgateway/app/ui/home/HomeScreen.kt
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.ui.home
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import com.runicgateway.app.R
|
||||||
|
import com.runicgateway.app.data.api.dto.BrandDto
|
||||||
|
import com.runicgateway.app.data.api.dto.StatusDto
|
||||||
|
import com.runicgateway.app.ui.UiState
|
||||||
|
import com.runicgateway.app.ui.components.ErrorView
|
||||||
|
import com.runicgateway.app.ui.components.FeatureCard
|
||||||
|
import com.runicgateway.app.ui.components.LoadingView
|
||||||
|
import com.runicgateway.app.ui.components.OnlineDot
|
||||||
|
import com.runicgateway.app.ui.components.PillTone
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Home / status (PLAN.md §6.1): the shard's name + tagline from branding, and a
|
||||||
|
* live site-status card (online vs maintenance) with the backend version.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun HomeScreen(
|
||||||
|
brand: BrandDto?,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
viewModel: HomeViewModel = 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 -> HomeContent(brand, s.data, modifier)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun HomeContent(brand: BrandDto?, status: StatusDto, modifier: Modifier = Modifier) {
|
||||||
|
Column(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(20.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = brand?.name?.takeIf { it.isNotBlank() } ?: stringResource(R.string.app_name),
|
||||||
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
|
)
|
||||||
|
brand?.tagline?.takeIf { it.isNotBlank() }?.let { tagline ->
|
||||||
|
Text(
|
||||||
|
text = tagline,
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(top = 4.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(20.dp))
|
||||||
|
|
||||||
|
StatusCard(status)
|
||||||
|
|
||||||
|
brand?.description?.takeIf { it.isNotBlank() }?.let { desc ->
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
Text(text = desc, style = MaterialTheme.typography.bodyMedium)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun StatusCard(status: StatusDto) {
|
||||||
|
val online = !status.isMaintenance
|
||||||
|
|
||||||
|
FeatureCard {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
OnlineDot(if (online) PillTone.Success else PillTone.Danger)
|
||||||
|
Spacer(Modifier.width(10.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(
|
||||||
|
if (online) R.string.home_status_live else R.string.home_status_maintenance,
|
||||||
|
),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (status.isMaintenance && status.statusMessage.isNotBlank()) {
|
||||||
|
Text(
|
||||||
|
text = status.statusMessage,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
modifier = Modifier.padding(top = 6.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (status.version.server.isNotBlank()) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(
|
||||||
|
R.string.home_server_version,
|
||||||
|
status.version.server,
|
||||||
|
status.version.api,
|
||||||
|
),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(top = 10.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.ui.home
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.runicgateway.app.data.api.dto.StatusDto
|
||||||
|
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.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/** Loads site status (mode + version) for the Home screen (PLAN.md §6.1). */
|
||||||
|
@HiltViewModel
|
||||||
|
class HomeViewModel @Inject constructor(
|
||||||
|
private val settingsRepository: SettingsRepository,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val _state = MutableStateFlow<UiState<StatusDto>>(UiState.Loading)
|
||||||
|
val state: StateFlow<UiState<StatusDto>> = _state.asStateFlow()
|
||||||
|
|
||||||
|
init { load() }
|
||||||
|
|
||||||
|
fun load() {
|
||||||
|
_state.value = UiState.Loading
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.value = settingsRepository.getStatus().toUiState()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
63
app/src/main/java/com/runicgateway/app/ui/navigation/Menu.kt
Normal file
63
app/src/main/java/com/runicgateway/app/ui/navigation/Menu.kt
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.ui.navigation
|
||||||
|
|
||||||
|
import androidx.annotation.StringRes
|
||||||
|
import com.runicgateway.app.R
|
||||||
|
import com.runicgateway.app.core.auth.Session
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One shared, declarative, access-level navigation definition (PLAN.md §5): a
|
||||||
|
* single list where each entry declares the minimum access it needs, filtered by
|
||||||
|
* the current session — not a pile of `if role ==` checks. The server stays the
|
||||||
|
* source of truth; a hidden item is a UX convenience and every gated call still
|
||||||
|
* enforces on the backend.
|
||||||
|
*/
|
||||||
|
enum class MenuAccess {
|
||||||
|
/** Visible to everyone, signed in or not. */
|
||||||
|
PUBLIC,
|
||||||
|
|
||||||
|
/** Visible to any signed-in account (§5, "My Account"). */
|
||||||
|
SIGNED_IN,
|
||||||
|
|
||||||
|
/** Visible only to a player — the linked game-data groups (§6.3). */
|
||||||
|
PLAYER,
|
||||||
|
}
|
||||||
|
|
||||||
|
data class MenuEntry(
|
||||||
|
val route: String,
|
||||||
|
@param:StringRes val labelRes: Int,
|
||||||
|
val access: MenuAccess = MenuAccess.PUBLIC,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The full menu, in display order. Public content first, then the signed-in
|
||||||
|
* surfaces, then the player-only game-data groups (revealed once the session's
|
||||||
|
* role is `player`, §6.3).
|
||||||
|
*/
|
||||||
|
val APP_MENU: List<MenuEntry> = listOf(
|
||||||
|
MenuEntry(Routes.HOME, R.string.menu_home),
|
||||||
|
MenuEntry(Routes.NEWS, R.string.menu_news),
|
||||||
|
MenuEntry(Routes.WIKI, R.string.menu_wiki),
|
||||||
|
MenuEntry(Routes.SHARD, R.string.menu_shard),
|
||||||
|
MenuEntry(Routes.page("about"), R.string.menu_about),
|
||||||
|
MenuEntry(Routes.CONTACT, R.string.menu_contact),
|
||||||
|
MenuEntry(Routes.ACCOUNT, R.string.menu_account, MenuAccess.SIGNED_IN),
|
||||||
|
MenuEntry(Routes.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),
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The entries the given [session] may see. Pure + side-effect-free so the access
|
||||||
|
* gating is unit-tested without Compose.
|
||||||
|
*/
|
||||||
|
fun visibleEntries(entries: List<MenuEntry>, session: Session): List<MenuEntry> =
|
||||||
|
entries.filter { entry ->
|
||||||
|
when (entry.access) {
|
||||||
|
MenuAccess.PUBLIC -> true
|
||||||
|
MenuAccess.SIGNED_IN -> session is Session.SignedIn
|
||||||
|
MenuAccess.PLAYER -> session is Session.SignedIn && session.user.isPlayer
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.ui.navigation
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigation destinations for the M1 public surface (PLAN.md §5). Routes are
|
||||||
|
* plain strings for Navigation-Compose; argument-bearing routes expose a
|
||||||
|
* `build(...)` helper so call sites never hand-format paths.
|
||||||
|
*/
|
||||||
|
object Routes {
|
||||||
|
const val HOME = "home"
|
||||||
|
const val NEWS = "news"
|
||||||
|
const val WIKI = "wiki"
|
||||||
|
const val CONTACT = "contact"
|
||||||
|
|
||||||
|
/** Native login (§4.1) and the signed-in account surface (§5). */
|
||||||
|
const val LOGIN = "login"
|
||||||
|
const val ACCOUNT = "account"
|
||||||
|
|
||||||
|
/** Public shard hub (§6.2). */
|
||||||
|
const val SHARD = "shard"
|
||||||
|
|
||||||
|
/** Shard boards, reachable from the hub. */
|
||||||
|
const val SHARD_CHAMPS = "shard/champs"
|
||||||
|
const val SHARD_GUILDS = "shard/guilds"
|
||||||
|
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}"
|
||||||
|
|
||||||
|
/** News post detail by category + id-or-slug. */
|
||||||
|
const val POST = "news/{category}/{idOrSlug}"
|
||||||
|
|
||||||
|
/** Wiki page detail by slug. */
|
||||||
|
const val WIKI_PAGE = "wiki/{slug}"
|
||||||
|
|
||||||
|
object Args {
|
||||||
|
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"
|
||||||
|
}
|
||||||
117
app/src/main/java/com/runicgateway/app/ui/news/NewsScreen.kt
Normal file
117
app/src/main/java/com/runicgateway/app/ui/news/NewsScreen.kt
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.ui.news
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
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.ScrollableTabRow
|
||||||
|
import androidx.compose.material3.Tab
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.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.PostDto
|
||||||
|
import com.runicgateway.app.data.repository.ContentRepository.PostCategory
|
||||||
|
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
|
||||||
|
|
||||||
|
/** News hub with category tabs and a post list (PLAN.md §6.1). */
|
||||||
|
@Composable
|
||||||
|
fun NewsScreen(
|
||||||
|
onOpenPost: (categoryUrlSlug: String, idOrSlug: String) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
viewModel: NewsViewModel = hiltViewModel(),
|
||||||
|
) {
|
||||||
|
val category by viewModel.category.collectAsStateWithLifecycle()
|
||||||
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
Column(modifier = modifier.fillMaxSize()) {
|
||||||
|
ScrollableTabRow(selectedTabIndex = PostCategory.entries.indexOf(category)) {
|
||||||
|
PostCategory.entries.forEach { cat ->
|
||||||
|
Tab(
|
||||||
|
selected = cat == category,
|
||||||
|
onClick = { viewModel.selectCategory(cat) },
|
||||||
|
text = { Text(stringResource(categoryLabel(cat))) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
when (val s = state) {
|
||||||
|
is UiState.Loading -> LoadingView()
|
||||||
|
is UiState.Error -> ErrorView(s.kind, onRetry = viewModel::load)
|
||||||
|
is UiState.Success -> {
|
||||||
|
if (s.data.isEmpty()) {
|
||||||
|
EmptyView(stringResource(R.string.news_empty))
|
||||||
|
} else {
|
||||||
|
PostList(s.data, category.urlSlug, onOpenPost)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PostList(
|
||||||
|
posts: List<PostDto>,
|
||||||
|
categoryUrlSlug: String,
|
||||||
|
onOpenPost: (String, String) -> Unit,
|
||||||
|
) {
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp),
|
||||||
|
) {
|
||||||
|
items(posts, key = { it.id }) { post ->
|
||||||
|
PostRow(post) { onOpenPost(categoryUrlSlug, post.slug ?: post.id.toString()) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PostRow(post: PostDto, onClick: () -> Unit) {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 6.dp)
|
||||||
|
.clickable(onClick = onClick),
|
||||||
|
) {
|
||||||
|
Column(Modifier.padding(16.dp)) {
|
||||||
|
Text(post.title, style = MaterialTheme.typography.titleMedium)
|
||||||
|
post.publishedAt?.let { date ->
|
||||||
|
Text(
|
||||||
|
text = date,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(top = 2.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
post.excerpt?.takeIf { it.isNotBlank() }?.let { excerpt ->
|
||||||
|
Text(
|
||||||
|
text = excerpt,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
modifier = Modifier.padding(top = 8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun categoryLabel(category: PostCategory): Int = when (category) {
|
||||||
|
PostCategory.NEWS -> R.string.news_cat_news
|
||||||
|
PostCategory.FIVE_ON_FRIDAY -> R.string.news_cat_five_on_friday
|
||||||
|
PostCategory.NEWSLETTER -> R.string.news_cat_newsletter
|
||||||
|
PostCategory.SCREENSHOTS -> R.string.news_cat_screenshots
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.ui.news
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.runicgateway.app.data.api.dto.PostDto
|
||||||
|
import com.runicgateway.app.data.repository.ContentRepository
|
||||||
|
import com.runicgateway.app.data.repository.ContentRepository.PostCategory
|
||||||
|
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
|
||||||
|
|
||||||
|
/** News hub: a category selector over the four post feeds (PLAN.md §6.1). */
|
||||||
|
@HiltViewModel
|
||||||
|
class NewsViewModel @Inject constructor(
|
||||||
|
private val contentRepository: ContentRepository,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val _category = MutableStateFlow(PostCategory.NEWS)
|
||||||
|
val category: StateFlow<PostCategory> = _category.asStateFlow()
|
||||||
|
|
||||||
|
private val _state = MutableStateFlow<UiState<List<PostDto>>>(UiState.Loading)
|
||||||
|
val state: StateFlow<UiState<List<PostDto>>> = _state.asStateFlow()
|
||||||
|
|
||||||
|
init { load() }
|
||||||
|
|
||||||
|
fun selectCategory(category: PostCategory) {
|
||||||
|
if (category == _category.value) return
|
||||||
|
_category.value = category
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun load() {
|
||||||
|
_state.value = UiState.Loading
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.value = contentRepository.getPosts(_category.value).toUiState()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
80
app/src/main/java/com/runicgateway/app/ui/news/PostScreen.kt
Normal file
80
app/src/main/java/com/runicgateway/app/ui/news/PostScreen.kt
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.ui.news
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
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.layout.ContentScale
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import coil.compose.AsyncImage
|
||||||
|
import com.runicgateway.app.data.api.dto.PostDto
|
||||||
|
import com.runicgateway.app.ui.LocalAssetResolver
|
||||||
|
import com.runicgateway.app.ui.UiState
|
||||||
|
import com.runicgateway.app.ui.components.ErrorView
|
||||||
|
import com.runicgateway.app.ui.components.HtmlText
|
||||||
|
import com.runicgateway.app.ui.components.LoadingView
|
||||||
|
|
||||||
|
/** Post detail: title, date, hero image, and HTML body (PLAN.md §6.1). */
|
||||||
|
@Composable
|
||||||
|
fun PostScreen(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
viewModel: PostViewModel = 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 -> PostContent(s.data, modifier)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PostContent(post: PostDto, modifier: Modifier = Modifier) {
|
||||||
|
val resolveAsset = LocalAssetResolver.current
|
||||||
|
Column(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(20.dp),
|
||||||
|
) {
|
||||||
|
Text(post.title, style = MaterialTheme.typography.headlineSmall)
|
||||||
|
post.publishedAt?.let { date ->
|
||||||
|
Text(
|
||||||
|
text = date,
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(top = 4.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
resolveAsset(post.imageUrl)?.let { url ->
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
AsyncImage(
|
||||||
|
model = url,
|
||||||
|
contentDescription = null,
|
||||||
|
contentScale = ContentScale.FillWidth,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
post.body?.takeIf { it.isNotBlank() }?.let { body ->
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
HtmlText(body, modifier = Modifier.fillMaxWidth())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.ui.news
|
||||||
|
|
||||||
|
import androidx.lifecycle.SavedStateHandle
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.runicgateway.app.data.api.dto.PostDto
|
||||||
|
import com.runicgateway.app.data.repository.ContentRepository
|
||||||
|
import com.runicgateway.app.data.repository.ContentRepository.PostCategory
|
||||||
|
import com.runicgateway.app.ui.ErrorKind
|
||||||
|
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
|
||||||
|
|
||||||
|
/** Loads a single post for the detail screen (PLAN.md §6.1). */
|
||||||
|
@HiltViewModel
|
||||||
|
class PostViewModel @Inject constructor(
|
||||||
|
private val contentRepository: ContentRepository,
|
||||||
|
savedStateHandle: SavedStateHandle,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val categorySlug: String? = savedStateHandle[Routes.Args.CATEGORY]
|
||||||
|
private val idOrSlug: String = savedStateHandle[Routes.Args.ID_OR_SLUG] ?: ""
|
||||||
|
|
||||||
|
private val _state = MutableStateFlow<UiState<PostDto>>(UiState.Loading)
|
||||||
|
val state: StateFlow<UiState<PostDto>> = _state.asStateFlow()
|
||||||
|
|
||||||
|
init { load() }
|
||||||
|
|
||||||
|
fun load() {
|
||||||
|
val category = PostCategory.fromUrlSlug(categorySlug)
|
||||||
|
if (category == null) {
|
||||||
|
_state.value = UiState.Error(ErrorKind.NOT_FOUND)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_state.value = UiState.Loading
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.value = contentRepository.getPost(category, idOrSlug).toUiState()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
152
app/src/main/java/com/runicgateway/app/ui/page/BlockRenderer.kt
Normal file
152
app/src/main/java/com/runicgateway/app/ui/page/BlockRenderer.kt
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.ui.page
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.platform.LocalUriHandler
|
||||||
|
import androidx.compose.ui.text.font.FontStyle
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import coil.compose.AsyncImage
|
||||||
|
import com.runicgateway.app.data.api.dto.BlockDto
|
||||||
|
import com.runicgateway.app.ui.LocalAssetResolver
|
||||||
|
import com.runicgateway.app.ui.components.HtmlText
|
||||||
|
import kotlinx.serialization.builtins.ListSerializer
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.JsonArray
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders the block-based CMS page body (PLAN.md §6.1). Dispatches on the block
|
||||||
|
* `type` from the server registry (`heading | rich_text | image | quote | cta |
|
||||||
|
* divider | two_column`); unknown or invisible blocks are skipped so a new
|
||||||
|
* server-side block type never breaks an older app.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun BlockList(blocks: List<BlockDto>, modifier: Modifier = Modifier) {
|
||||||
|
Column(modifier = modifier) {
|
||||||
|
blocks.filter { it.visible }.forEach { block ->
|
||||||
|
BlockView(block)
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun BlockView(block: BlockDto) {
|
||||||
|
val props = block.props
|
||||||
|
when (block.type) {
|
||||||
|
"heading" -> {
|
||||||
|
val text = props.str("text").orEmpty()
|
||||||
|
val level = props.int("level") ?: 2
|
||||||
|
Text(text = text, style = headingStyle(level))
|
||||||
|
}
|
||||||
|
|
||||||
|
"rich_text" -> {
|
||||||
|
props.str("html")?.takeIf { it.isNotBlank() }?.let {
|
||||||
|
HtmlText(it, modifier = Modifier.fillMaxWidth())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
"image" -> {
|
||||||
|
val resolveAsset = LocalAssetResolver.current
|
||||||
|
resolveAsset(props.str("src"))?.let { url ->
|
||||||
|
AsyncImage(
|
||||||
|
model = url,
|
||||||
|
contentDescription = props.str("alt"),
|
||||||
|
contentScale = ContentScale.FillWidth,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
props.str("caption")?.takeIf { it.isNotBlank() }?.let { caption ->
|
||||||
|
Text(
|
||||||
|
text = caption,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(top = 4.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
"quote" -> {
|
||||||
|
Column(Modifier.padding(start = 12.dp)) {
|
||||||
|
Text(
|
||||||
|
text = props.str("text").orEmpty(),
|
||||||
|
style = MaterialTheme.typography.titleMedium.copy(fontStyle = FontStyle.Italic),
|
||||||
|
)
|
||||||
|
props.str("attribution")?.takeIf { it.isNotBlank() }?.let { attribution ->
|
||||||
|
Text(
|
||||||
|
text = "— $attribution",
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(top = 4.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
"cta" -> {
|
||||||
|
val resolveAsset = LocalAssetResolver.current
|
||||||
|
val uriHandler = LocalUriHandler.current
|
||||||
|
val label = props.str("text").orEmpty()
|
||||||
|
val url = resolveAsset(props.str("url"))
|
||||||
|
val onClick: () -> Unit = { url?.let { runCatching { uriHandler.openUri(it) } } }
|
||||||
|
if (props.str("style") == "secondary") {
|
||||||
|
OutlinedButton(onClick = onClick) { Text(label) }
|
||||||
|
} else {
|
||||||
|
Button(onClick = onClick) { Text(label) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
"divider" -> HorizontalDivider()
|
||||||
|
|
||||||
|
"two_column" -> {
|
||||||
|
// Stack the two columns vertically on a phone (§2.1 functional pass).
|
||||||
|
BlockList(props.blocks("left"))
|
||||||
|
BlockList(props.blocks("right"))
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> Unit // unknown block type — skip rather than crash
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun headingStyle(level: Int) = when (level) {
|
||||||
|
1 -> MaterialTheme.typography.headlineMedium
|
||||||
|
2 -> MaterialTheme.typography.headlineSmall
|
||||||
|
3 -> MaterialTheme.typography.titleLarge
|
||||||
|
else -> MaterialTheme.typography.titleMedium
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Prop readers (tolerant of missing/typed values) ──────────────────────
|
||||||
|
private val blockJson = Json {
|
||||||
|
ignoreUnknownKeys = true
|
||||||
|
explicitNulls = false
|
||||||
|
coerceInputValues = true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun JsonObject.str(key: String): String? =
|
||||||
|
(this[key] as? JsonPrimitive)?.takeIf { it.isString }?.content
|
||||||
|
|
||||||
|
private fun JsonObject.int(key: String): Int? =
|
||||||
|
(this[key] as? JsonPrimitive)?.content?.toIntOrNull()
|
||||||
|
|
||||||
|
private fun JsonObject.blocks(key: String): List<BlockDto> {
|
||||||
|
val array = this[key] as? JsonArray ?: return emptyList()
|
||||||
|
return runCatching {
|
||||||
|
blockJson.decodeFromJsonElement(ListSerializer(BlockDto.serializer()), array)
|
||||||
|
}.getOrDefault(emptyList())
|
||||||
|
}
|
||||||
54
app/src/main/java/com/runicgateway/app/ui/page/PageScreen.kt
Normal file
54
app/src/main/java/com/runicgateway/app/ui/page/PageScreen.kt
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.ui.page
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
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.unit.dp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import com.runicgateway.app.data.api.dto.PageDto
|
||||||
|
import com.runicgateway.app.ui.UiState
|
||||||
|
import com.runicgateway.app.ui.components.ErrorView
|
||||||
|
import com.runicgateway.app.ui.components.LoadingView
|
||||||
|
|
||||||
|
/** CMS page detail: title + rendered blocks (PLAN.md §6.1). */
|
||||||
|
@Composable
|
||||||
|
fun PageScreen(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
viewModel: PageViewModel = 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 -> PageContent(s.data, modifier)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PageContent(page: PageDto, modifier: Modifier = Modifier) {
|
||||||
|
Column(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(20.dp),
|
||||||
|
) {
|
||||||
|
Text(page.title, style = MaterialTheme.typography.headlineSmall)
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
BlockList(page.blocks, modifier = Modifier.fillMaxWidth())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.ui.page
|
||||||
|
|
||||||
|
import androidx.lifecycle.SavedStateHandle
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.runicgateway.app.data.api.dto.PageDto
|
||||||
|
import com.runicgateway.app.data.repository.ContentRepository
|
||||||
|
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
|
||||||
|
|
||||||
|
/** Loads a block-based CMS page by slug (PLAN.md §6.1). */
|
||||||
|
@HiltViewModel
|
||||||
|
class PageViewModel @Inject constructor(
|
||||||
|
private val contentRepository: ContentRepository,
|
||||||
|
savedStateHandle: SavedStateHandle,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val slug: String = savedStateHandle[Routes.Args.SLUG] ?: ""
|
||||||
|
|
||||||
|
private val _state = MutableStateFlow<UiState<PageDto>>(UiState.Loading)
|
||||||
|
val state: StateFlow<UiState<PageDto>> = _state.asStateFlow()
|
||||||
|
|
||||||
|
init { load() }
|
||||||
|
|
||||||
|
fun load() {
|
||||||
|
_state.value = UiState.Loading
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.value = contentRepository.getPage(slug).toUiState()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,288 @@
|
|||||||
|
/*
|
||||||
|
* 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 com.runicgateway.app.ui.components.SectionLabel
|
||||||
|
import com.runicgateway.app.ui.components.StatBar
|
||||||
|
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 = 16.dp)) {
|
||||||
|
SectionLabel(stringResource(R.string.player_char_vitals))
|
||||||
|
Column(Modifier.padding(top = 8.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?) {
|
||||||
|
Column(Modifier.padding(vertical = 5.dp)) {
|
||||||
|
Row(Modifier.fillMaxWidth().padding(bottom = 4.dp), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||||
|
Text(label, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
Text("${cur ?: "—"} / ${max ?: "—"}", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
val fraction = if (max != null && max > 0 && cur != null) cur.toFloat() / max else 0f
|
||||||
|
StatBar(fraction)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ResistancesBlock(resist: ResistDto) {
|
||||||
|
val rows = listOf(
|
||||||
|
stringResource(R.string.player_char_phys) to resist.phys,
|
||||||
|
stringResource(R.string.player_char_fire) to resist.fire,
|
||||||
|
stringResource(R.string.player_char_cold) to resist.cold,
|
||||||
|
stringResource(R.string.player_char_pois) to resist.pois,
|
||||||
|
stringResource(R.string.player_char_energy) to resist.energy,
|
||||||
|
)
|
||||||
|
if (rows.all { it.second == null }) return
|
||||||
|
SheetCard(R.string.player_char_resistances) {
|
||||||
|
Row(Modifier.fillMaxWidth().padding(top = 4.dp), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||||
|
rows.forEach { (label, value) ->
|
||||||
|
Column {
|
||||||
|
Text("${value ?: 0}", style = MaterialTheme.typography.titleMedium)
|
||||||
|
Text(label, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SkillsBlock(skills: List<SkillDto>) {
|
||||||
|
val shown = skills
|
||||||
|
.filter { (it.value ?: it.base ?: 0.0) > 0.0 }
|
||||||
|
.sortedByDescending { it.value ?: 0.0 }
|
||||||
|
if (shown.isEmpty()) return
|
||||||
|
SheetCard(R.string.player_char_skills) {
|
||||||
|
shown.forEach { skill ->
|
||||||
|
val value = skill.value ?: 0.0
|
||||||
|
Column(Modifier.padding(vertical = 5.dp)) {
|
||||||
|
Row(Modifier.fillMaxWidth().padding(bottom = 4.dp), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||||
|
Text(skill.n ?: "—", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
Text(formatSkill(value), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, fontWeight = FontWeight.Medium)
|
||||||
|
}
|
||||||
|
StatBar((value / SKILL_CAP).toFloat())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
|
@Composable
|
||||||
|
private fun EquipmentBlock(equipment: List<EquipmentDto>) {
|
||||||
|
if (equipment.isEmpty()) return
|
||||||
|
SheetCard(R.string.player_char_equipment) {
|
||||||
|
equipment.forEach { item ->
|
||||||
|
Column(Modifier.fillMaxWidth().padding(vertical = 6.dp)) {
|
||||||
|
Text(
|
||||||
|
item.layer ?: stringResource(R.string.player_char_item),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
)
|
||||||
|
val meta = listOfNotNull(
|
||||||
|
item.itemId?.let { stringResource(R.string.player_char_item_id, it) },
|
||||||
|
item.hue?.takeIf { it != 0 }?.let { stringResource(R.string.player_char_item_hue, it) },
|
||||||
|
).joinToString(" · ")
|
||||||
|
if (meta.isNotBlank()) {
|
||||||
|
Text(meta, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
val mods = item.mods.orEmpty()
|
||||||
|
if (mods.isNotEmpty()) {
|
||||||
|
FlowRow(Modifier.padding(top = 4.dp), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
|
mods.forEach { (k, v) -> Chip("$k ${jsonText(v)}") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SheetCard(titleRes: Int, content: @Composable () -> Unit) {
|
||||||
|
Card(Modifier.fillMaxWidth()) {
|
||||||
|
Column(Modifier.padding(16.dp)) {
|
||||||
|
Text(stringResource(titleRes), style = MaterialTheme.typography.titleMedium)
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Pure helpers (unit-tested) ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** ServUO's default skill cap; used to scale the skill meter fill (0..1). */
|
||||||
|
private const val SKILL_CAP = 120.0
|
||||||
|
|
||||||
|
/** Format a skill value: drop the ".0" on whole numbers, else one decimal. */
|
||||||
|
internal fun formatSkill(value: Double): String =
|
||||||
|
if (value % 1.0 == 0.0) value.toInt().toString() else "%.1f".format(value)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The human-readable title chips for a [TitlesDto] (parity with the website's
|
||||||
|
* `CharacterSheet.jsx#displayTitles`): fame/karma, skill title, and the selected
|
||||||
|
* reward title — but only if it is a literal string, not a bare cliloc number
|
||||||
|
* (the app ships no cliloc table). De-duplicated, blanks dropped.
|
||||||
|
*/
|
||||||
|
internal fun displayTitles(titles: TitlesDto?): List<String> {
|
||||||
|
if (titles == null) return emptyList()
|
||||||
|
val out = mutableListOf<String>()
|
||||||
|
titles.fameKarma?.let { out.add(it) }
|
||||||
|
titles.skill?.let { out.add(it) }
|
||||||
|
val reward = titles.reward
|
||||||
|
val sel = titles.selected ?: -1
|
||||||
|
val candidate = when {
|
||||||
|
sel in reward.indices -> reward[sel]
|
||||||
|
else -> reward.firstOrNull { it.isNotBlank() && !it.all(Char::isDigit) }
|
||||||
|
}
|
||||||
|
if (candidate != null && candidate.isNotBlank() && !candidate.all(Char::isDigit)) out.add(candidate)
|
||||||
|
return out.filter { it.isNotBlank() }.distinct()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun jsonText(element: kotlinx.serialization.json.JsonElement): String =
|
||||||
|
runCatching { element.jsonPrimitive.content }.getOrElse { element.toString() }
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.ui.player
|
||||||
|
|
||||||
|
import androidx.lifecycle.SavedStateHandle
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.runicgateway.app.data.api.dto.CharProfileDto
|
||||||
|
import com.runicgateway.app.data.repository.PlayerShardRepository
|
||||||
|
import com.runicgateway.app.ui.UiState
|
||||||
|
import com.runicgateway.app.ui.navigation.Routes
|
||||||
|
import com.runicgateway.app.ui.toUiState
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single character sheet (PLAN.md §6.3), read from `/player/shard/char/:serial`
|
||||||
|
* for a character on one of the caller's linked accounts (ownership-checked
|
||||||
|
* server-side). A `503` renders as offline/retry, a `403` as not-found (§7).
|
||||||
|
* Text-only presentation for v1.
|
||||||
|
*/
|
||||||
|
@HiltViewModel
|
||||||
|
class CharacterViewModel @Inject constructor(
|
||||||
|
private val repository: PlayerShardRepository,
|
||||||
|
savedStateHandle: SavedStateHandle,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val serial: String = savedStateHandle.get<String>(Routes.Args.SERIAL).orEmpty()
|
||||||
|
|
||||||
|
private val _state = MutableStateFlow<UiState<CharProfileDto>>(UiState.Loading)
|
||||||
|
val state: StateFlow<UiState<CharProfileDto>> = _state.asStateFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun load() {
|
||||||
|
_state.value = UiState.Loading
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.value = repository.char(serial).toUiState()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
/*
|
||||||
|
* 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
|
||||||
|
import com.runicgateway.app.ui.components.PillTone
|
||||||
|
import com.runicgateway.app.ui.components.StatusPill
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The player's linked game accounts + character rosters (PLAN.md §6.3). Tapping a
|
||||||
|
* character opens its text-only sheet. Not-yet-linked players get the `[link` code
|
||||||
|
* prompt (and, when the shard offers it, a hybrid signup form).
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun CharactersScreen(
|
||||||
|
onOpenChar: (String) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
viewModel: CharactersViewModel = hiltViewModel(),
|
||||||
|
) {
|
||||||
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
when (val accounts = state.accounts) {
|
||||||
|
is UiState.Loading -> LoadingView(modifier)
|
||||||
|
is UiState.Error -> ErrorView(accounts.kind, onRetry = viewModel::load, modifier = modifier)
|
||||||
|
is UiState.Success -> Column(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
LinkCard(state, viewModel)
|
||||||
|
if (state.signupEnabled) CreateAccountCard(state, viewModel)
|
||||||
|
|
||||||
|
if (accounts.data.isEmpty()) {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.player_characters_empty),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
accounts.data.forEach { account ->
|
||||||
|
AccountRoster(
|
||||||
|
account = account,
|
||||||
|
roster = state.rosters[account] ?: UiState.Loading,
|
||||||
|
onRetry = { viewModel.loadRoster(account) },
|
||||||
|
onOpenChar = onOpenChar,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun LinkCard(state: CharactersViewModel.State, viewModel: CharactersViewModel) {
|
||||||
|
var code by rememberSaveable { mutableStateOf("") }
|
||||||
|
Card(Modifier.fillMaxWidth()) {
|
||||||
|
Column(Modifier.padding(16.dp)) {
|
||||||
|
Text(stringResource(R.string.player_link_title), style = MaterialTheme.typography.titleMedium)
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.player_link_hint),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(top = 6.dp),
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = code,
|
||||||
|
onValueChange = { code = it.uppercase() },
|
||||||
|
singleLine = true,
|
||||||
|
enabled = !state.busy,
|
||||||
|
label = { Text(stringResource(R.string.player_link_code)) },
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(top = 12.dp),
|
||||||
|
)
|
||||||
|
Button(
|
||||||
|
onClick = { viewModel.link(code); code = "" },
|
||||||
|
enabled = !state.busy && code.isNotBlank(),
|
||||||
|
modifier = Modifier.padding(top = 12.dp),
|
||||||
|
) { Text(stringResource(R.string.player_link_action)) }
|
||||||
|
FormFeedback(state.feedback)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CreateAccountCard(state: CharactersViewModel.State, viewModel: CharactersViewModel) {
|
||||||
|
var account by rememberSaveable { mutableStateOf("") }
|
||||||
|
var password by rememberSaveable { mutableStateOf("") }
|
||||||
|
Card(Modifier.fillMaxWidth()) {
|
||||||
|
Column(Modifier.padding(16.dp)) {
|
||||||
|
Text(stringResource(R.string.player_create_title), style = MaterialTheme.typography.titleMedium)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = account,
|
||||||
|
onValueChange = { account = it },
|
||||||
|
singleLine = true,
|
||||||
|
enabled = !state.busy,
|
||||||
|
label = { Text(stringResource(R.string.player_create_account)) },
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(top = 12.dp),
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = password,
|
||||||
|
onValueChange = { password = it },
|
||||||
|
singleLine = true,
|
||||||
|
enabled = !state.busy,
|
||||||
|
label = { Text(stringResource(R.string.player_create_password)) },
|
||||||
|
visualTransformation = PasswordVisualTransformation(),
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(top = 12.dp),
|
||||||
|
)
|
||||||
|
Button(
|
||||||
|
onClick = { viewModel.createAccount(account, password); password = "" },
|
||||||
|
enabled = !state.busy && account.isNotBlank() && password.length >= 8,
|
||||||
|
modifier = Modifier.padding(top = 12.dp),
|
||||||
|
) { Text(stringResource(R.string.player_create_action)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AccountRoster(
|
||||||
|
account: String,
|
||||||
|
roster: UiState<List<RosterCharDto>>,
|
||||||
|
onRetry: () -> Unit,
|
||||||
|
onOpenChar: (String) -> Unit,
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
account,
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.padding(vertical = 8.dp),
|
||||||
|
)
|
||||||
|
when (roster) {
|
||||||
|
is UiState.Loading -> Text(
|
||||||
|
stringResource(R.string.player_roster_loading),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
is UiState.Error -> RosterError(roster.kind, onRetry)
|
||||||
|
is UiState.Success -> {
|
||||||
|
if (roster.data.isEmpty()) {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.player_roster_empty),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
roster.data.forEach { CharRow(it, onOpenChar) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun RosterError(kind: ErrorKind, onRetry: () -> Unit) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(
|
||||||
|
stringResource(
|
||||||
|
if (kind == ErrorKind.SHARD_OFFLINE) R.string.error_shard_offline else R.string.error_server,
|
||||||
|
),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
TextButton(onClick = onRetry) { Text(stringResource(R.string.action_retry)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CharRow(char: RosterCharDto, onOpenChar: (String) -> Unit) {
|
||||||
|
Card(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 4.dp)
|
||||||
|
.clickable(enabled = char.serial.isNotBlank()) { onOpenChar(char.serial) },
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
Modifier.fillMaxWidth().padding(14.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
char.name ?: stringResource(R.string.player_char_unknown),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
StatusPill(
|
||||||
|
text = stringResource(
|
||||||
|
if (char.online) R.string.player_char_online else R.string.player_char_offline,
|
||||||
|
),
|
||||||
|
tone = if (char.online) PillTone.Success else PillTone.Neutral,
|
||||||
|
modifier = Modifier.padding(end = 8.dp),
|
||||||
|
)
|
||||||
|
Text("›", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun FormFeedback(feedback: CharactersViewModel.Feedback?) {
|
||||||
|
if (feedback == null) return
|
||||||
|
Text(
|
||||||
|
text = stringResource(feedback.messageRes),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = if (feedback.ok) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error,
|
||||||
|
modifier = Modifier.padding(top = 10.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.ui.player
|
||||||
|
|
||||||
|
import androidx.annotation.StringRes
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.runicgateway.app.R
|
||||||
|
import com.runicgateway.app.core.result.ApiResult
|
||||||
|
import com.runicgateway.app.core.result.map
|
||||||
|
import com.runicgateway.app.data.api.dto.RosterCharDto
|
||||||
|
import com.runicgateway.app.data.repository.PlayerShardRepository
|
||||||
|
import com.runicgateway.app.data.repository.SettingsRepository
|
||||||
|
import com.runicgateway.app.ui.UiState
|
||||||
|
import com.runicgateway.app.ui.toUiState
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The player's linked game accounts + per-account character rosters (PLAN.md §6.3):
|
||||||
|
* link a game account with a `[link` one-time code (or, when the shard offers it,
|
||||||
|
* a hybrid signup), then browse characters and open a text-only sheet. A per-account
|
||||||
|
* roster carries its own load state so a down shard degrades that account to a
|
||||||
|
* retry without blocking the rest (§7).
|
||||||
|
*/
|
||||||
|
@HiltViewModel
|
||||||
|
class CharactersViewModel @Inject constructor(
|
||||||
|
private val repository: PlayerShardRepository,
|
||||||
|
private val settingsRepository: SettingsRepository,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
/** A one-shot banner for the link / create-account forms. */
|
||||||
|
data class Feedback(val ok: Boolean, @param:StringRes val messageRes: Int)
|
||||||
|
|
||||||
|
data class State(
|
||||||
|
val accounts: UiState<List<String>> = UiState.Loading,
|
||||||
|
val rosters: Map<String, UiState<List<RosterCharDto>>> = emptyMap(),
|
||||||
|
/** Whether the shard currently offers hybrid game-account signup. */
|
||||||
|
val signupEnabled: Boolean = false,
|
||||||
|
val busy: Boolean = false,
|
||||||
|
val feedback: Feedback? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
private val _state = MutableStateFlow(State())
|
||||||
|
val state: StateFlow<State> = _state.asStateFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun load() {
|
||||||
|
_state.update { it.copy(accounts = UiState.Loading, rosters = emptyMap()) }
|
||||||
|
viewModelScope.launch {
|
||||||
|
when (val result = repository.accounts()) {
|
||||||
|
is ApiResult.Ok -> {
|
||||||
|
val names = result.data.map { it.account }
|
||||||
|
_state.update { it.copy(accounts = UiState.Success(names)) }
|
||||||
|
names.forEach { loadRoster(it) }
|
||||||
|
}
|
||||||
|
else -> _state.update { it.copy(accounts = result.map { emptyList<String>() }.toUiState()) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Non-critical: whether hybrid signup is offered right now.
|
||||||
|
viewModelScope.launch {
|
||||||
|
when (val settings = settingsRepository.getSettings()) {
|
||||||
|
is ApiResult.Ok -> _state.update { it.copy(signupEnabled = settings.data.gameAccountSignup) }
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadRoster(account: String) {
|
||||||
|
_state.update { it.copy(rosters = it.rosters + (account to UiState.Loading)) }
|
||||||
|
viewModelScope.launch {
|
||||||
|
val roster = repository.roster(account).map { it.chars }.toUiState()
|
||||||
|
_state.update { it.copy(rosters = it.rosters + (account to roster)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearFeedback() = _state.update { it.copy(feedback = null) }
|
||||||
|
|
||||||
|
fun link(code: String) {
|
||||||
|
if (_state.value.busy || code.isBlank()) return
|
||||||
|
_state.update { it.copy(busy = true, feedback = null) }
|
||||||
|
viewModelScope.launch {
|
||||||
|
when (val result = repository.link(code.trim())) {
|
||||||
|
is ApiResult.Ok -> {
|
||||||
|
_state.update { it.copy(busy = false, feedback = Feedback(true, R.string.player_link_ok)) }
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
is ApiResult.HttpError -> _state.update {
|
||||||
|
it.copy(busy = false, feedback = Feedback(false, linkErrorRes(result.status)))
|
||||||
|
}
|
||||||
|
is ApiResult.NetworkError -> _state.update {
|
||||||
|
it.copy(busy = false, feedback = Feedback(false, R.string.error_network))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun createAccount(account: String, password: String) {
|
||||||
|
if (_state.value.busy || account.isBlank() || password.length < 8) return
|
||||||
|
_state.update { it.copy(busy = true, feedback = null) }
|
||||||
|
viewModelScope.launch {
|
||||||
|
when (val result = repository.createAccount(account.trim(), password)) {
|
||||||
|
is ApiResult.Ok -> {
|
||||||
|
_state.update { it.copy(busy = false, feedback = Feedback(true, R.string.player_create_ok)) }
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
is ApiResult.HttpError -> _state.update {
|
||||||
|
it.copy(busy = false, feedback = Feedback(false, createErrorRes(result.status)))
|
||||||
|
}
|
||||||
|
is ApiResult.NetworkError -> _state.update {
|
||||||
|
it.copy(busy = false, feedback = Feedback(false, R.string.error_network))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun linkErrorRes(status: Int): Int = when (status) {
|
||||||
|
400 -> R.string.player_link_bad_code
|
||||||
|
503 -> R.string.error_shard_offline
|
||||||
|
else -> R.string.player_link_error
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createErrorRes(status: Int): Int = when (status) {
|
||||||
|
409 -> R.string.player_create_taken
|
||||||
|
429 -> R.string.player_create_capped
|
||||||
|
403 -> R.string.player_create_unavailable
|
||||||
|
400 -> R.string.player_create_rejected
|
||||||
|
503 -> R.string.error_shard_offline
|
||||||
|
else -> R.string.player_create_error
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.ui.player
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import com.runicgateway.app.R
|
||||||
|
import com.runicgateway.app.data.api.dto.PlayerHouseDto
|
||||||
|
import com.runicgateway.app.ui.UiState
|
||||||
|
import com.runicgateway.app.ui.components.EmptyView
|
||||||
|
import com.runicgateway.app.ui.components.ErrorView
|
||||||
|
import com.runicgateway.app.ui.components.LoadingView
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The player's own houses with home/decay status (PLAN.md §6.3), text-only. An
|
||||||
|
* IDOC house is flagged prominently. Only the caller's own property is ever shown.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun MyHousesScreen(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
viewModel: MyHousesViewModel = hiltViewModel(),
|
||||||
|
) {
|
||||||
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
|
when (val s = state) {
|
||||||
|
is UiState.Loading -> LoadingView(modifier)
|
||||||
|
is UiState.Error -> ErrorView(s.kind, onRetry = viewModel::load, modifier = modifier)
|
||||||
|
is UiState.Success -> {
|
||||||
|
if (s.data.isEmpty()) {
|
||||||
|
EmptyView(stringResource(R.string.player_houses_empty), modifier)
|
||||||
|
} else {
|
||||||
|
LazyColumn(
|
||||||
|
modifier = modifier.fillMaxSize().padding(horizontal = 16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
contentPadding = androidx.compose.foundation.layout.PaddingValues(vertical = 16.dp),
|
||||||
|
) {
|
||||||
|
items(s.data, key = { it.serial }) { house -> HouseCard(house) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun HouseCard(house: PlayerHouseDto) {
|
||||||
|
Card(Modifier.fillMaxWidth()) {
|
||||||
|
Column(Modifier.padding(16.dp)) {
|
||||||
|
Row(Modifier.fillMaxWidth()) {
|
||||||
|
Text(
|
||||||
|
text = house.name?.takeIf { it.isNotBlank() }
|
||||||
|
?: house.region
|
||||||
|
?: stringResource(R.string.player_house_fallback),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
if (house.isIdoc) {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.houses_idoc_badge),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
house.stage?.takeIf { it.isNotBlank() }?.let {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.player_house_stage, it),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = if (house.isIdoc) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(top = 6.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val where = listOfNotNull(
|
||||||
|
house.region,
|
||||||
|
house.map,
|
||||||
|
house.x?.let { "(${house.x}, ${house.y})" },
|
||||||
|
).joinToString(" · ")
|
||||||
|
if (where.isNotBlank()) {
|
||||||
|
Text(
|
||||||
|
where,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(top = 4.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.ui.player
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.runicgateway.app.data.api.dto.PlayerHouseDto
|
||||||
|
import com.runicgateway.app.data.repository.PlayerShardRepository
|
||||||
|
import com.runicgateway.app.ui.UiState
|
||||||
|
import com.runicgateway.app.ui.toUiState
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The player's OWN houses with decay/IDOC status (PLAN.md §6.3), read from
|
||||||
|
* `/player/shard/houses` — never anyone else's. A `503` renders as offline/retry (§7).
|
||||||
|
*/
|
||||||
|
@HiltViewModel
|
||||||
|
class MyHousesViewModel @Inject constructor(
|
||||||
|
private val repository: PlayerShardRepository,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val _state = MutableStateFlow<UiState<List<PlayerHouseDto>>>(UiState.Loading)
|
||||||
|
val state: StateFlow<UiState<List<PlayerHouseDto>>> = _state.asStateFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun load() {
|
||||||
|
_state.value = UiState.Loading
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.value = repository.houses().toUiState()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.ui.player
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import com.runicgateway.app.R
|
||||||
|
import com.runicgateway.app.data.api.dto.VendorDto
|
||||||
|
import com.runicgateway.app.data.api.dto.VendorListingDto
|
||||||
|
import com.runicgateway.app.data.api.dto.VendorSaleDto
|
||||||
|
import com.runicgateway.app.ui.ErrorKind
|
||||||
|
import com.runicgateway.app.ui.UiState
|
||||||
|
import com.runicgateway.app.ui.components.ErrorView
|
||||||
|
import com.runicgateway.app.ui.components.LoadingView
|
||||||
|
import java.text.DateFormat
|
||||||
|
import java.util.Date
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The player's own player-vendors + recent sales (PLAN.md §6.3), text-only. Vendor
|
||||||
|
* shops group under their game account; recent sales list across all linked
|
||||||
|
* accounts. A down shard degrades to a per-account retry (§7).
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun VendorsScreen(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
viewModel: VendorsViewModel = hiltViewModel(),
|
||||||
|
) {
|
||||||
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
when (val accounts = state.accounts) {
|
||||||
|
is UiState.Loading -> LoadingView(modifier)
|
||||||
|
is UiState.Error -> ErrorView(accounts.kind, onRetry = viewModel::load, modifier = modifier)
|
||||||
|
is UiState.Success -> Column(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
SalesCard(state.sales)
|
||||||
|
|
||||||
|
if (accounts.data.isEmpty()) {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.player_vendors_no_accounts),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
accounts.data.forEach { account ->
|
||||||
|
AccountVendors(
|
||||||
|
account = account,
|
||||||
|
vendors = state.vendors[account] ?: UiState.Loading,
|
||||||
|
onRetry = { viewModel.loadVendors(account) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SalesCard(sales: UiState<List<VendorSaleDto>>) {
|
||||||
|
Card(Modifier.fillMaxWidth()) {
|
||||||
|
Column(Modifier.padding(16.dp)) {
|
||||||
|
Text(stringResource(R.string.player_sales_title), style = MaterialTheme.typography.titleMedium)
|
||||||
|
when (sales) {
|
||||||
|
is UiState.Loading -> Text(
|
||||||
|
stringResource(R.string.player_roster_loading),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(top = 8.dp),
|
||||||
|
)
|
||||||
|
is UiState.Error -> Text(
|
||||||
|
stringResource(R.string.error_server),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(top = 8.dp),
|
||||||
|
)
|
||||||
|
is UiState.Success -> {
|
||||||
|
if (sales.data.isEmpty()) {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.player_sales_empty),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(top = 8.dp),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
sales.data.forEach { SaleRow(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SaleRow(sale: VendorSaleDto) {
|
||||||
|
Row(Modifier.fillMaxWidth().padding(top = 10.dp), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||||
|
Column(Modifier.padding(end = 12.dp)) {
|
||||||
|
Text(
|
||||||
|
sale.itemType ?: stringResource(R.string.player_char_item),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
)
|
||||||
|
sale.t?.let {
|
||||||
|
Text(
|
||||||
|
DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT).format(Date(it)),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.player_gold_amount, "%,d".format(sale.price ?: 0L)),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AccountVendors(
|
||||||
|
account: String,
|
||||||
|
vendors: UiState<List<VendorDto>>,
|
||||||
|
onRetry: () -> Unit,
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
account,
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.padding(vertical = 8.dp),
|
||||||
|
)
|
||||||
|
when (vendors) {
|
||||||
|
is UiState.Loading -> Text(
|
||||||
|
stringResource(R.string.player_roster_loading),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
is UiState.Error -> VendorError(vendors.kind, onRetry)
|
||||||
|
is UiState.Success -> {
|
||||||
|
if (vendors.data.isEmpty()) {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.player_vendors_empty),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
vendors.data.forEach { VendorCard(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun VendorError(kind: ErrorKind, onRetry: () -> Unit) {
|
||||||
|
Row {
|
||||||
|
Text(
|
||||||
|
stringResource(if (kind == ErrorKind.SHARD_OFFLINE) R.string.error_shard_offline else R.string.error_server),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
TextButton(onClick = onRetry) { Text(stringResource(R.string.action_retry)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun VendorCard(vendor: VendorDto) {
|
||||||
|
Card(Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
|
||||||
|
Column(Modifier.padding(16.dp)) {
|
||||||
|
Text(
|
||||||
|
vendor.shopName ?: stringResource(R.string.player_vendor_fallback),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
)
|
||||||
|
val meta = listOfNotNull(
|
||||||
|
vendor.holdGold?.let { stringResource(R.string.player_vendor_hold, "%,d".format(it)) },
|
||||||
|
vendor.map,
|
||||||
|
).joinToString(" · ")
|
||||||
|
if (meta.isNotBlank()) {
|
||||||
|
Text(meta, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(top = 2.dp))
|
||||||
|
}
|
||||||
|
if (vendor.listings.isNotEmpty()) {
|
||||||
|
HorizontalDivider(Modifier.padding(vertical = 8.dp))
|
||||||
|
vendor.listings.forEach { ListingRow(it) }
|
||||||
|
} else {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.player_vendor_no_listings),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(top = 8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ListingRow(listing: VendorListingDto) {
|
||||||
|
Row(Modifier.fillMaxWidth().padding(vertical = 3.dp), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.player_listing_item, listing.itemId ?: 0, listing.amount ?: 1),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.player_gold_amount, "%,d".format(listing.price ?: 0L)),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.ui.player
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.runicgateway.app.core.result.ApiResult
|
||||||
|
import com.runicgateway.app.core.result.map
|
||||||
|
import com.runicgateway.app.data.api.dto.VendorDto
|
||||||
|
import com.runicgateway.app.data.api.dto.VendorSaleDto
|
||||||
|
import com.runicgateway.app.data.repository.PlayerShardRepository
|
||||||
|
import com.runicgateway.app.ui.UiState
|
||||||
|
import com.runicgateway.app.ui.toUiState
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The player's own player-vendors and recent vendor sales (PLAN.md §6.3): a
|
||||||
|
* per-account vendor snapshot (shops + listings) over `/player/shard/vendors/:account`
|
||||||
|
* plus the recent-sales log over `/player/shard/sales`. Each account's snapshot
|
||||||
|
* carries its own load state so a down shard degrades one account to a retry (§7).
|
||||||
|
* Text-only listings — item names are clilocs the app has no table for.
|
||||||
|
*/
|
||||||
|
@HiltViewModel
|
||||||
|
class VendorsViewModel @Inject constructor(
|
||||||
|
private val repository: PlayerShardRepository,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
data class State(
|
||||||
|
val accounts: UiState<List<String>> = UiState.Loading,
|
||||||
|
val vendors: Map<String, UiState<List<VendorDto>>> = emptyMap(),
|
||||||
|
val sales: UiState<List<VendorSaleDto>> = UiState.Loading,
|
||||||
|
)
|
||||||
|
|
||||||
|
private val _state = MutableStateFlow(State())
|
||||||
|
val state: StateFlow<State> = _state.asStateFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun load() {
|
||||||
|
_state.update { it.copy(accounts = UiState.Loading, vendors = emptyMap(), sales = UiState.Loading) }
|
||||||
|
viewModelScope.launch {
|
||||||
|
when (val result = repository.accounts()) {
|
||||||
|
is ApiResult.Ok -> {
|
||||||
|
val names = result.data.map { it.account }
|
||||||
|
_state.update { it.copy(accounts = UiState.Success(names)) }
|
||||||
|
names.forEach { loadVendors(it) }
|
||||||
|
}
|
||||||
|
else -> _state.update { it.copy(accounts = result.map { emptyList<String>() }.toUiState()) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.update { it.copy(sales = repository.sales().toUiState()) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadVendors(account: String) {
|
||||||
|
_state.update { it.copy(vendors = it.vendors + (account to UiState.Loading)) }
|
||||||
|
viewModelScope.launch {
|
||||||
|
val vendors = repository.vendors(account).map { it.vendors }.toUiState()
|
||||||
|
_state.update { it.copy(vendors = it.vendors + (account to vendors)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.ui.session
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.runicgateway.app.core.auth.Session
|
||||||
|
import com.runicgateway.app.core.auth.SessionManager
|
||||||
|
import com.runicgateway.app.data.repository.AuthRepository
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Activity-scoped view of the current session for the app shell (PLAN.md §4.3,
|
||||||
|
* §5): the shared menu observes [session] to reveal signed-in groups + the
|
||||||
|
* sign-in/out toggle, and the shell drives resume re-validation and sign-out.
|
||||||
|
* Login itself lives in [com.runicgateway.app.ui.auth.LoginViewModel].
|
||||||
|
*/
|
||||||
|
@HiltViewModel
|
||||||
|
class SessionViewModel @Inject constructor(
|
||||||
|
sessionManager: SessionManager,
|
||||||
|
private val authRepository: AuthRepository,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
val session: StateFlow<Session> = sessionManager.state
|
||||||
|
|
||||||
|
/** Re-validate the cached role against the backend on app resume. */
|
||||||
|
fun revalidate() {
|
||||||
|
viewModelScope.launch { authRepository.revalidate() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sign out of this session, or every session with [allDevices]. */
|
||||||
|
fun signOut(allDevices: Boolean = false) {
|
||||||
|
viewModelScope.launch { authRepository.logout(allDevices) }
|
||||||
|
}
|
||||||
|
}
|
||||||
108
app/src/main/java/com/runicgateway/app/ui/shard/ChampsScreen.kt
Normal file
108
app/src/main/java/com/runicgateway/app/ui/shard/ChampsScreen.kt
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.ui.shard
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
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.ChampDto
|
||||||
|
import com.runicgateway.app.ui.components.PillTone
|
||||||
|
import com.runicgateway.app.ui.components.StatusPill
|
||||||
|
|
||||||
|
/** The champion-spawn board (PLAN.md §6.2), live via SSE deltas. */
|
||||||
|
@Composable
|
||||||
|
fun ChampsScreen(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
viewModel: ChampsViewModel = hiltViewModel(),
|
||||||
|
) {
|
||||||
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
|
val connected by viewModel.connected.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
LiveBoardScreen(
|
||||||
|
emptyMessage = stringResource(R.string.champs_empty),
|
||||||
|
state = state,
|
||||||
|
connected = connected,
|
||||||
|
onRetry = viewModel::load,
|
||||||
|
key = { it.serial },
|
||||||
|
modifier = modifier,
|
||||||
|
) { champ -> ChampCard(champ) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ChampCard(champ: ChampDto) {
|
||||||
|
Card(Modifier.fillMaxWidth()) {
|
||||||
|
Column(Modifier.padding(16.dp)) {
|
||||||
|
Row(Modifier.fillMaxWidth()) {
|
||||||
|
Text(
|
||||||
|
text = champ.name ?: champ.type ?: stringResource(R.string.champs_fallback_name),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
champ.status?.let {
|
||||||
|
StatusPill(text = it, tone = champStatusTone(it))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val detail = champDetail(champ)
|
||||||
|
if (detail.isNotBlank()) {
|
||||||
|
Text(
|
||||||
|
text = detail,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(top = 6.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val where = listOfNotNull(champ.map, champ.x?.let { "(${champ.x}, ${champ.y})" }).joinToString(" ")
|
||||||
|
if (where.isNotBlank()) {
|
||||||
|
Text(
|
||||||
|
text = where,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(top = 4.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Map a champ status to a pill tone: live spawns read green, cooldowns amber. */
|
||||||
|
private fun champStatusTone(status: String): PillTone = when (status.lowercase()) {
|
||||||
|
"active", "up", "spawned" -> PillTone.Success
|
||||||
|
"cooldown", "restarting", "advancing" -> PillTone.Warning
|
||||||
|
else -> PillTone.Neutral
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A short category-specific status line, mirroring the website's champ detail. */
|
||||||
|
@Composable
|
||||||
|
private fun champDetail(champ: ChampDto): String = when (champ.category) {
|
||||||
|
"sea" -> if (champ.hitsMax != null) {
|
||||||
|
"%,d / %,d hp".format(champ.hits ?: 0, champ.hitsMax)
|
||||||
|
} else {
|
||||||
|
champ.boss ?: champ.type ?: ""
|
||||||
|
}
|
||||||
|
"mini" -> stringResource(R.string.champ_level, champ.level ?: 0)
|
||||||
|
else -> when (champ.status) {
|
||||||
|
"active" -> if (champ.maxKills != null) {
|
||||||
|
"%,d / %,d kills".format(champ.kills ?: 0, champ.maxKills)
|
||||||
|
} else {
|
||||||
|
stringResource(R.string.champ_level, champ.level ?: 0)
|
||||||
|
}
|
||||||
|
"cooldown" -> stringResource(R.string.champ_cooldown)
|
||||||
|
else -> ""
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.ui.shard
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.runicgateway.app.core.net.ShardStreamEvent
|
||||||
|
import com.runicgateway.app.core.result.ApiResult
|
||||||
|
import com.runicgateway.app.data.api.dto.ChampDto
|
||||||
|
import com.runicgateway.app.data.repository.ShardRepository
|
||||||
|
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 champion-spawn board (PLAN.md §6.2): loaded once from `/public/shard/champs`,
|
||||||
|
* then kept live by merging `champ.update` / `champ.remove` frames in place. Rows
|
||||||
|
* are ordered by category then name for a stable display.
|
||||||
|
*/
|
||||||
|
@HiltViewModel
|
||||||
|
class ChampsViewModel @Inject constructor(
|
||||||
|
private val repository: ShardRepository,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val board = LiveBoard<ChampDto> { it.serial.toString() }
|
||||||
|
|
||||||
|
private val _state = MutableStateFlow<UiState<List<ChampDto>>>(UiState.Loading)
|
||||||
|
val state: StateFlow<UiState<List<ChampDto>>> = _state.asStateFlow()
|
||||||
|
|
||||||
|
private val _connected = MutableStateFlow(false)
|
||||||
|
val connected: StateFlow<Boolean> = _connected.asStateFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
load()
|
||||||
|
collectLive()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun load() {
|
||||||
|
_state.value = UiState.Loading
|
||||||
|
viewModelScope.launch {
|
||||||
|
when (val result = repository.champs()) {
|
||||||
|
is ApiResult.Ok -> {
|
||||||
|
board.seed(result.data)
|
||||||
|
publish()
|
||||||
|
}
|
||||||
|
else -> _state.value = result.toUiState()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun collectLive() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
repository.liveEvents().collect { event ->
|
||||||
|
when (event) {
|
||||||
|
is ShardStreamEvent.Open -> _connected.value = true
|
||||||
|
is ShardStreamEvent.Closed -> _connected.value = false
|
||||||
|
is ShardStreamEvent.Frame -> applyFrame(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applyFrame(frame: ShardStreamEvent.Frame) {
|
||||||
|
when (frame.kind) {
|
||||||
|
"champ.update" -> repository.champFrame(frame.data)?.let { board.upsert(it) }
|
||||||
|
"champ.remove" -> FrameFields.longField(frame.data, "serial")?.let { board.remove(it.toString()) }
|
||||||
|
else -> return
|
||||||
|
}
|
||||||
|
// Only republish when the board actually changed (Success state only).
|
||||||
|
if (_state.value is UiState.Success) publish()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun publish() {
|
||||||
|
val rows = board.values().sortedWith(
|
||||||
|
compareBy({ it.category ?: "" }, { it.name ?: it.type ?: "" }),
|
||||||
|
)
|
||||||
|
_state.value = UiState.Success(rows)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.ui.shard
|
||||||
|
|
||||||
|
import kotlinx.serialization.json.JsonNull
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal typed reads from a raw SSE frame [JsonObject] for the fields a board's
|
||||||
|
* `*.remove` / `*.decay` delta needs when the whole frame doesn't warrant decoding
|
||||||
|
* into a DTO (e.g. `champ.remove` carries only a `serial`). Returns null on a
|
||||||
|
* missing or ill-typed field so a malformed frame is skipped, not fatal.
|
||||||
|
*/
|
||||||
|
object FrameFields {
|
||||||
|
fun longField(obj: JsonObject, key: String): Long? {
|
||||||
|
val el = obj[key]
|
||||||
|
if (el !is JsonPrimitive || el is JsonNull) return null
|
||||||
|
return el.content.toLongOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stringField(obj: JsonObject, key: String): String? {
|
||||||
|
val el = obj[key]
|
||||||
|
if (el !is JsonPrimitive || el is JsonNull) return null
|
||||||
|
return el.content
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.ui.shard
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
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.GovernorDto
|
||||||
|
import com.runicgateway.app.data.api.dto.GovernorTermDto
|
||||||
|
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 town-governor board (PLAN.md §6.2), live via `city.update`, with per-city history. */
|
||||||
|
@Composable
|
||||||
|
fun GovernorsScreen(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
viewModel: GovernorsViewModel = hiltViewModel(),
|
||||||
|
) {
|
||||||
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
|
val connected by viewModel.connected.collectAsStateWithLifecycle()
|
||||||
|
val history by viewModel.history.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
Column(modifier = modifier.fillMaxSize()) {
|
||||||
|
Row(
|
||||||
|
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp),
|
||||||
|
horizontalArrangement = Arrangement.End,
|
||||||
|
) {
|
||||||
|
LiveChip(connected)
|
||||||
|
}
|
||||||
|
when (val s = state) {
|
||||||
|
is UiState.Loading -> LoadingView()
|
||||||
|
is UiState.Error -> ErrorView(s.kind, onRetry = viewModel::load)
|
||||||
|
is UiState.Success -> {
|
||||||
|
if (s.data.isEmpty()) {
|
||||||
|
EmptyView(stringResource(R.string.governors_empty))
|
||||||
|
} else {
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
contentPadding = androidx.compose.foundation.layout.PaddingValues(bottom = 16.dp),
|
||||||
|
) {
|
||||||
|
items(s.data, key = { it.city }) { city ->
|
||||||
|
CityCard(
|
||||||
|
city = city,
|
||||||
|
terms = history[city.city],
|
||||||
|
onExpand = { viewModel.loadHistory(city.city) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CityCard(
|
||||||
|
city: GovernorDto,
|
||||||
|
terms: List<GovernorTermDto>?,
|
||||||
|
onExpand: () -> Unit,
|
||||||
|
) {
|
||||||
|
var expanded by remember { mutableStateOf(false) }
|
||||||
|
Card(Modifier.fillMaxWidth()) {
|
||||||
|
Column {
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable {
|
||||||
|
expanded = !expanded
|
||||||
|
if (expanded) onExpand()
|
||||||
|
}
|
||||||
|
.padding(16.dp),
|
||||||
|
) {
|
||||||
|
Text(city.city, style = MaterialTheme.typography.titleMedium)
|
||||||
|
Text(
|
||||||
|
text = city.governor?.label?.let { stringResource(R.string.governor_current, it) }
|
||||||
|
?: stringResource(R.string.governor_none),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(top = 4.dp),
|
||||||
|
)
|
||||||
|
city.electionPhase?.takeIf { it.isNotBlank() && !it.equals("none", ignoreCase = true) }?.let {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.governor_election, it),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.padding(top = 2.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (expanded) {
|
||||||
|
HorizontalDivider()
|
||||||
|
Column(Modifier.padding(16.dp)) {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.governor_history),
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
)
|
||||||
|
when {
|
||||||
|
terms == null -> Text(
|
||||||
|
stringResource(R.string.governor_history_loading),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(top = 6.dp),
|
||||||
|
)
|
||||||
|
terms.isEmpty() -> Text(
|
||||||
|
stringResource(R.string.governor_history_empty),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(top = 6.dp),
|
||||||
|
)
|
||||||
|
else -> terms.forEach { term ->
|
||||||
|
Text(
|
||||||
|
text = term.governor?.label ?: stringResource(R.string.governor_none),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
modifier = Modifier.padding(top = 6.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.ui.shard
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.runicgateway.app.core.net.ShardStreamEvent
|
||||||
|
import com.runicgateway.app.core.result.ApiResult
|
||||||
|
import com.runicgateway.app.data.api.dto.GovernorDto
|
||||||
|
import com.runicgateway.app.data.api.dto.GovernorTermDto
|
||||||
|
import com.runicgateway.app.data.repository.ShardRepository
|
||||||
|
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 town-governor board (PLAN.md §6.2): loaded from `/public/shard/governors`,
|
||||||
|
* kept live by `city.update` frames (keyed by city — cities are fixed, so there is
|
||||||
|
* no remove). Empty on shards without City Loyalty. A city's term ledger is fetched
|
||||||
|
* on demand via [loadHistory] for the look-back panel.
|
||||||
|
*/
|
||||||
|
@HiltViewModel
|
||||||
|
class GovernorsViewModel @Inject constructor(
|
||||||
|
private val repository: ShardRepository,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val board = LiveBoard<GovernorDto> { it.city }
|
||||||
|
|
||||||
|
private val _state = MutableStateFlow<UiState<List<GovernorDto>>>(UiState.Loading)
|
||||||
|
val state: StateFlow<UiState<List<GovernorDto>>> = _state.asStateFlow()
|
||||||
|
|
||||||
|
private val _connected = MutableStateFlow(false)
|
||||||
|
val connected: StateFlow<Boolean> = _connected.asStateFlow()
|
||||||
|
|
||||||
|
/** Per-city expanded term history, loaded lazily; absent = not yet requested. */
|
||||||
|
private val _history = MutableStateFlow<Map<String, List<GovernorTermDto>>>(emptyMap())
|
||||||
|
val history: StateFlow<Map<String, List<GovernorTermDto>>> = _history.asStateFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
load()
|
||||||
|
collectLive()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun load() {
|
||||||
|
_state.value = UiState.Loading
|
||||||
|
viewModelScope.launch {
|
||||||
|
when (val result = repository.governors()) {
|
||||||
|
is ApiResult.Ok -> {
|
||||||
|
board.seed(result.data)
|
||||||
|
publish()
|
||||||
|
}
|
||||||
|
else -> _state.value = result.toUiState()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fetch (once) the term ledger for [city] for its expandable history panel. */
|
||||||
|
fun loadHistory(city: String) {
|
||||||
|
if (_history.value.containsKey(city)) return
|
||||||
|
viewModelScope.launch {
|
||||||
|
val terms = (repository.governorHistory(city) as? ApiResult.Ok)?.data ?: emptyList()
|
||||||
|
_history.value = _history.value + (city to terms)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun collectLive() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
repository.liveEvents().collect { event ->
|
||||||
|
when (event) {
|
||||||
|
is ShardStreamEvent.Open -> _connected.value = true
|
||||||
|
is ShardStreamEvent.Closed -> _connected.value = false
|
||||||
|
is ShardStreamEvent.Frame -> applyFrame(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applyFrame(frame: ShardStreamEvent.Frame) {
|
||||||
|
if (frame.kind != "city.update") return
|
||||||
|
val gov = repository.governorFrame(frame.data) ?: return
|
||||||
|
if (gov.city.isBlank()) return
|
||||||
|
board.upsert(gov)
|
||||||
|
if (_state.value is UiState.Success) publish()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun publish() {
|
||||||
|
val rows = board.values().sortedBy { it.city.lowercase() }
|
||||||
|
_state.value = UiState.Success(rows)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.ui.shard
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
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.GuildDto
|
||||||
|
|
||||||
|
/** The guild board (PLAN.md §6.2), live via SSE deltas. */
|
||||||
|
@Composable
|
||||||
|
fun GuildsScreen(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
viewModel: GuildsViewModel = hiltViewModel(),
|
||||||
|
) {
|
||||||
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
|
val connected by viewModel.connected.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
LiveBoardScreen(
|
||||||
|
emptyMessage = stringResource(R.string.guilds_empty),
|
||||||
|
state = state,
|
||||||
|
connected = connected,
|
||||||
|
onRetry = viewModel::load,
|
||||||
|
key = { it.id },
|
||||||
|
modifier = modifier,
|
||||||
|
) { guild -> GuildCard(guild) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun GuildCard(guild: GuildDto) {
|
||||||
|
Card(Modifier.fillMaxWidth()) {
|
||||||
|
Column(Modifier.padding(16.dp)) {
|
||||||
|
Row(Modifier.fillMaxWidth()) {
|
||||||
|
Text(
|
||||||
|
text = buildString {
|
||||||
|
guild.abbr?.takeIf { it.isNotBlank() }?.let { append("[").append(it).append("] ") }
|
||||||
|
append(guild.name ?: stringResource(R.string.guilds_fallback_name))
|
||||||
|
},
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
guild.members?.let {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.guilds_members, it),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
guild.leader?.let { leader ->
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.guilds_leader, leader.label),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(top = 6.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
guild.alliance?.takeIf { it.isNotBlank() }?.let { alliance ->
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.guilds_alliance, alliance),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(top = 2.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.ui.shard
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.runicgateway.app.core.net.ShardStreamEvent
|
||||||
|
import com.runicgateway.app.core.result.ApiResult
|
||||||
|
import com.runicgateway.app.data.api.dto.GuildDto
|
||||||
|
import com.runicgateway.app.data.repository.ShardRepository
|
||||||
|
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 guild board (PLAN.md §6.2): loaded from `/public/shard/guilds`, kept live by
|
||||||
|
* `guild.update` / `guild.remove` frames. `guild.join` is a membership tick that
|
||||||
|
* carries no board snapshot, so it is ignored here (the roster count refreshes on
|
||||||
|
* the next `guild.update`). Rows are ordered by name.
|
||||||
|
*/
|
||||||
|
@HiltViewModel
|
||||||
|
class GuildsViewModel @Inject constructor(
|
||||||
|
private val repository: ShardRepository,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val board = LiveBoard<GuildDto> { it.id.toString() }
|
||||||
|
|
||||||
|
private val _state = MutableStateFlow<UiState<List<GuildDto>>>(UiState.Loading)
|
||||||
|
val state: StateFlow<UiState<List<GuildDto>>> = _state.asStateFlow()
|
||||||
|
|
||||||
|
private val _connected = MutableStateFlow(false)
|
||||||
|
val connected: StateFlow<Boolean> = _connected.asStateFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
load()
|
||||||
|
collectLive()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun load() {
|
||||||
|
_state.value = UiState.Loading
|
||||||
|
viewModelScope.launch {
|
||||||
|
when (val result = repository.guilds()) {
|
||||||
|
is ApiResult.Ok -> {
|
||||||
|
board.seed(result.data)
|
||||||
|
publish()
|
||||||
|
}
|
||||||
|
else -> _state.value = result.toUiState()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun collectLive() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
repository.liveEvents().collect { event ->
|
||||||
|
when (event) {
|
||||||
|
is ShardStreamEvent.Open -> _connected.value = true
|
||||||
|
is ShardStreamEvent.Closed -> _connected.value = false
|
||||||
|
is ShardStreamEvent.Frame -> applyFrame(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applyFrame(frame: ShardStreamEvent.Frame) {
|
||||||
|
when (frame.kind) {
|
||||||
|
"guild.update" -> repository.guildFrame(frame.data)?.let { board.upsert(it) }
|
||||||
|
"guild.remove" -> FrameFields.longField(frame.data, "id")?.let { board.remove(it.toString()) }
|
||||||
|
else -> return
|
||||||
|
}
|
||||||
|
if (_state.value is UiState.Success) publish()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun publish() {
|
||||||
|
val rows = board.values().sortedBy { it.name?.lowercase() ?: "" }
|
||||||
|
_state.value = UiState.Success(rows)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.ui.shard
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
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.HouseDto
|
||||||
|
import com.runicgateway.app.ui.components.PillTone
|
||||||
|
import com.runicgateway.app.ui.components.StatusPill
|
||||||
|
|
||||||
|
/** The public "falling houses" (IDOC) board (PLAN.md §6.2), live via `house.decay`. */
|
||||||
|
@Composable
|
||||||
|
fun HousesScreen(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
viewModel: HousesViewModel = hiltViewModel(),
|
||||||
|
) {
|
||||||
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
|
val connected by viewModel.connected.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
LiveBoardScreen(
|
||||||
|
emptyMessage = stringResource(R.string.houses_empty),
|
||||||
|
state = state,
|
||||||
|
connected = connected,
|
||||||
|
onRetry = viewModel::load,
|
||||||
|
key = { it.serial },
|
||||||
|
modifier = modifier,
|
||||||
|
) { house -> HouseCard(house) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun HouseCard(house: HouseDto) {
|
||||||
|
Card(Modifier.fillMaxWidth()) {
|
||||||
|
Column(Modifier.padding(16.dp)) {
|
||||||
|
Row(Modifier.fillMaxWidth()) {
|
||||||
|
Text(
|
||||||
|
text = house.name?.takeIf { it.isNotBlank() }
|
||||||
|
?: house.region
|
||||||
|
?: stringResource(R.string.houses_fallback_name),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
StatusPill(
|
||||||
|
text = stringResource(R.string.houses_idoc_badge),
|
||||||
|
tone = PillTone.Danger,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val where = listOfNotNull(
|
||||||
|
house.region,
|
||||||
|
house.map,
|
||||||
|
house.x?.let { "(${house.x}, ${house.y})" },
|
||||||
|
).joinToString(" · ")
|
||||||
|
if (where.isNotBlank()) {
|
||||||
|
Text(
|
||||||
|
text = where,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(top = 6.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.ui.shard
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.runicgateway.app.core.net.ShardStreamEvent
|
||||||
|
import com.runicgateway.app.core.result.ApiResult
|
||||||
|
import com.runicgateway.app.data.api.dto.HouseDto
|
||||||
|
import com.runicgateway.app.data.repository.ShardRepository
|
||||||
|
import com.runicgateway.app.ui.UiState
|
||||||
|
import com.runicgateway.app.ui.toUiState
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The public houses board (PLAN.md §6.2): the "where are the falling houses" view —
|
||||||
|
* loaded from `/public/shard/houses` (IDOC only, location only) and kept live by
|
||||||
|
* `house.decay` frames. A house entering IDOC adds/updates its row; any other decay
|
||||||
|
* transition (refreshed, collapsed) drops it. Rows are ordered by region.
|
||||||
|
*/
|
||||||
|
@HiltViewModel
|
||||||
|
class HousesViewModel @Inject constructor(
|
||||||
|
private val repository: ShardRepository,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val board = LiveBoard<HouseDto> { it.serial.toString() }
|
||||||
|
|
||||||
|
private val _state = MutableStateFlow<UiState<List<HouseDto>>>(UiState.Loading)
|
||||||
|
val state: StateFlow<UiState<List<HouseDto>>> = _state.asStateFlow()
|
||||||
|
|
||||||
|
private val _connected = MutableStateFlow(false)
|
||||||
|
val connected: StateFlow<Boolean> = _connected.asStateFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
load()
|
||||||
|
collectLive()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun load() {
|
||||||
|
_state.value = UiState.Loading
|
||||||
|
viewModelScope.launch {
|
||||||
|
when (val result = repository.houses()) {
|
||||||
|
is ApiResult.Ok -> {
|
||||||
|
board.seed(result.data)
|
||||||
|
publish()
|
||||||
|
}
|
||||||
|
else -> _state.value = result.toUiState()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun collectLive() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
repository.liveEvents().collect { event ->
|
||||||
|
when (event) {
|
||||||
|
is ShardStreamEvent.Open -> _connected.value = true
|
||||||
|
is ShardStreamEvent.Closed -> _connected.value = false
|
||||||
|
is ShardStreamEvent.Frame -> applyFrame(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applyFrame(frame: ShardStreamEvent.Frame) {
|
||||||
|
if (frame.kind != "house.decay") return
|
||||||
|
val serial = FrameFields.longField(frame.data, "serial") ?: return
|
||||||
|
// `to` is the new decay stage; only IDOC belongs on the public board.
|
||||||
|
val stage = FrameFields.stringField(frame.data, "to")
|
||||||
|
?: FrameFields.stringField(frame.data, "stage")
|
||||||
|
if (stage?.equals("IDOC", ignoreCase = true) == true) {
|
||||||
|
board.upsert(
|
||||||
|
HouseDto(
|
||||||
|
serial = serial,
|
||||||
|
name = FrameFields.stringField(frame.data, "name"),
|
||||||
|
region = FrameFields.stringField(frame.data, "region"),
|
||||||
|
map = FrameFields.stringField(frame.data, "map"),
|
||||||
|
x = FrameFields.longField(frame.data, "x")?.toInt(),
|
||||||
|
y = FrameFields.longField(frame.data, "y")?.toInt(),
|
||||||
|
z = FrameFields.longField(frame.data, "z")?.toInt(),
|
||||||
|
isIdoc = true,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
board.remove(serial.toString())
|
||||||
|
}
|
||||||
|
if (_state.value is UiState.Success) publish()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun publish() {
|
||||||
|
val rows = board.values().sortedBy { it.region?.lowercase() ?: "" }
|
||||||
|
_state.value = UiState.Success(rows)
|
||||||
|
}
|
||||||
|
}
|
||||||
33
app/src/main/java/com/runicgateway/app/ui/shard/LiveBoard.kt
Normal file
33
app/src/main/java/com/runicgateway/app/ui/shard/LiveBoard.kt
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.ui.shard
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A live board keyed by a stable id: seeded from a snapshot read, then kept current
|
||||||
|
* by SSE `*.update` / `*.remove` deltas (PLAN.md §6.2). Insertion order is
|
||||||
|
* preserved (a [LinkedHashMap]) so re-seeding is deterministic; callers sort for
|
||||||
|
* display. Not thread-safe — mutate it only from the owning ViewModel coroutine.
|
||||||
|
*/
|
||||||
|
class LiveBoard<T>(private val idOf: (T) -> String) {
|
||||||
|
private val items = LinkedHashMap<String, T>()
|
||||||
|
|
||||||
|
/** Replace the whole board with a fresh snapshot. */
|
||||||
|
fun seed(snapshot: List<T>) {
|
||||||
|
items.clear()
|
||||||
|
for (item in snapshot) items[idOf(item)] = item
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Insert or update one entry (a `*.update` frame). */
|
||||||
|
fun upsert(item: T) {
|
||||||
|
items[idOf(item)] = item
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drop one entry by id (a `*.remove` frame). No-op if absent. */
|
||||||
|
fun remove(id: String) {
|
||||||
|
items.remove(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The current board contents, in insertion order. */
|
||||||
|
fun values(): List<T> = items.values.toList()
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.ui.shard
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
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.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.runicgateway.app.R
|
||||||
|
import com.runicgateway.app.ui.UiState
|
||||||
|
import com.runicgateway.app.ui.components.EmptyView
|
||||||
|
import com.runicgateway.app.ui.components.ErrorView
|
||||||
|
import com.runicgateway.app.ui.components.LoadingView
|
||||||
|
import com.runicgateway.app.ui.theme.ShardSuccess
|
||||||
|
import com.runicgateway.app.ui.theme.ShardSuccessDot
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A small "Live / Offline" indicator for the shard screens (PLAN.md §6.2): a dot +
|
||||||
|
* label reflecting the SSE connection state. Green when connected, muted otherwise.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun LiveChip(connected: Boolean, modifier: Modifier = Modifier) {
|
||||||
|
val dotColor = if (connected) ShardSuccessDot else MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
val textColor = if (connected) ShardSuccess else MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
androidx.compose.foundation.layout.Box(
|
||||||
|
Modifier
|
||||||
|
.size(8.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(dotColor),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = stringResource(if (connected) R.string.shard_live else R.string.shard_offline),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = textColor,
|
||||||
|
modifier = Modifier.padding(start = 6.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The shared scaffold for a live shard board (champs/guilds/houses): a [LiveChip]
|
||||||
|
* header over the board list, folding [state] into loading/error/empty/content
|
||||||
|
* (§7). Each caller supplies the per-row composable. Governors use their own layout
|
||||||
|
* (expandable history), so they don't route through this.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun <T> LiveBoardScreen(
|
||||||
|
emptyMessage: String,
|
||||||
|
state: UiState<List<T>>,
|
||||||
|
connected: Boolean,
|
||||||
|
onRetry: () -> Unit,
|
||||||
|
key: (T) -> Any,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
row: @Composable (T) -> Unit,
|
||||||
|
) {
|
||||||
|
Column(modifier = modifier.fillMaxSize()) {
|
||||||
|
Row(
|
||||||
|
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp),
|
||||||
|
horizontalArrangement = Arrangement.End,
|
||||||
|
) {
|
||||||
|
LiveChip(connected)
|
||||||
|
}
|
||||||
|
when (state) {
|
||||||
|
is UiState.Loading -> LoadingView()
|
||||||
|
is UiState.Error -> ErrorView(state.kind, onRetry = onRetry)
|
||||||
|
is UiState.Success -> {
|
||||||
|
if (state.data.isEmpty()) {
|
||||||
|
EmptyView(emptyMessage)
|
||||||
|
} else {
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
contentPadding = androidx.compose.foundation.layout.PaddingValues(bottom = 16.dp),
|
||||||
|
) {
|
||||||
|
items(state.data, key = key) { item -> row(item) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.ui.shard
|
||||||
|
|
||||||
|
import kotlinx.serialization.json.JsonElement
|
||||||
|
import kotlinx.serialization.json.JsonNull
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
import kotlinx.serialization.json.jsonObject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One-line human descriptions of shard events for the live activity feed
|
||||||
|
* (PLAN.md §6.2). Mirrors the website's `lib/shardEvents.js` `describe()` for the
|
||||||
|
* public-safe kinds, so the app reads the same as the site. Accepts the event
|
||||||
|
* fields as a [JsonObject] — the live SSE frame carries them at the top level;
|
||||||
|
* a stored feed row carries them under `payload` (unwrap before calling).
|
||||||
|
*/
|
||||||
|
object ShardEventText {
|
||||||
|
|
||||||
|
fun describe(kind: String, fields: JsonObject): String = when (kind) {
|
||||||
|
"player.death" ->
|
||||||
|
"${nameOf(fields["who"])} was slain" + fields["killer"].let { if (isPresent(it)) " by ${nameOf(it)}" else "" }
|
||||||
|
"player.murdered" ->
|
||||||
|
"${nameOf(fields["victim"])} was murdered" + fields["murderer"].let { if (isPresent(it)) " by ${nameOf(it)}" else "" }
|
||||||
|
"mob.killed" -> "${nameOf(fields["killer"])} killed ${nameOf(fields["killed"])}"
|
||||||
|
"skill.gain" -> "${nameOf(fields["who"])} gained ${str(fields["skill"])}".trim()
|
||||||
|
"fame.change" -> "${nameOf(fields["who"])}’s fame changed to ${num(fields["new"])}"
|
||||||
|
"karma.change" -> "${nameOf(fields["who"])}’s karma changed to ${num(fields["new"])}"
|
||||||
|
"quest.complete" -> "${nameOf(fields["who"])} completed “${str(fields["quest"])}”"
|
||||||
|
"house.decay" -> {
|
||||||
|
val name = str(fields["name"]).ifBlank { "A house" }
|
||||||
|
val stage = str(fields["to"]).ifBlank { str(fields["stage"]) }
|
||||||
|
val region = str(fields["region"])
|
||||||
|
"$name is now $stage" + if (region.isNotBlank()) " — $region" else ""
|
||||||
|
}
|
||||||
|
"mob.login" -> "${nameOf(fields["who"])} entered the world"
|
||||||
|
"mob.logout" -> "${nameOf(fields["who"])} left the world"
|
||||||
|
"economy.supply" -> "Gold supply: ${num(fields["gold"])} across ${num(fields["accounts"])} accounts"
|
||||||
|
"server.hello" -> "Shard online — ${num(fields["accounts"])} accounts, ${num(fields["mobiles"])} mobiles"
|
||||||
|
"server.shutdown" -> "Shard shut down"
|
||||||
|
"server.crashed" -> "Shard crashed" + str(fields["error"]).let { if (it.isNotBlank()) ": $it" else "" }
|
||||||
|
"champ.update" -> {
|
||||||
|
val where = str(fields["name"]).ifBlank { str(fields["type"]).ifBlank { "A champion spawn" } }
|
||||||
|
when {
|
||||||
|
str(fields["status"]) == "active" && bool(fields["bossUp"]) ->
|
||||||
|
"$where: boss is up" + str(fields["boss"]).let { if (it.isNotBlank()) " ($it)" else "" }
|
||||||
|
str(fields["status"]) == "active" -> "$where is active"
|
||||||
|
str(fields["status"]) == "cooldown" -> "$where is on cooldown"
|
||||||
|
else -> "$where is ${str(fields["status"]).ifBlank { "idle" }}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"champ.remove" -> "A champion spawn ended"
|
||||||
|
"guild.update" -> "${str(fields["name"]).ifBlank { "A guild" }} updated"
|
||||||
|
"guild.remove" -> "A guild disbanded"
|
||||||
|
"guild.join" -> "${nameOf(fields["who"])} joined ${str(fields["guild"]).ifBlank { "a guild" }}".trim()
|
||||||
|
"city.update" -> {
|
||||||
|
val gov = fields["governor"]
|
||||||
|
if (isPresent(gov)) "${str(fields["city"])} is governed by ${nameOf(gov)}"
|
||||||
|
else "${str(fields["city"])} has no governor"
|
||||||
|
}
|
||||||
|
"presence.online" -> "${num(fields["count"])} players online"
|
||||||
|
"region.enter" -> "${nameOf(fields["who"])} entered ${str(fields["region"]).ifBlank { "a region" }}".trim()
|
||||||
|
else -> kind
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── field helpers ─────────────────────────────────────────────────────
|
||||||
|
private fun isPresent(el: JsonElement?): Boolean = el != null && el !is JsonNull
|
||||||
|
|
||||||
|
/** Name of an actor that may be a bare string or a `{ name, acct }` object. */
|
||||||
|
private fun nameOf(el: JsonElement?): String {
|
||||||
|
if (!isPresent(el)) return "Someone"
|
||||||
|
if (el is JsonPrimitive) return el.content.ifBlank { "Someone" }
|
||||||
|
val obj = runCatching { el!!.jsonObject }.getOrNull() ?: return "Someone"
|
||||||
|
return str(obj["name"]).ifBlank { str(obj["acct"]).ifBlank { "Someone" } }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun str(el: JsonElement?): String =
|
||||||
|
if (el is JsonPrimitive && el !is JsonNull) el.content else ""
|
||||||
|
|
||||||
|
private fun bool(el: JsonElement?): Boolean =
|
||||||
|
el is JsonPrimitive && el.content.equals("true", ignoreCase = true)
|
||||||
|
|
||||||
|
/** Format a numeric field with thousands grouping; falls back to its raw text. */
|
||||||
|
private fun num(el: JsonElement?): String {
|
||||||
|
val raw = str(el)
|
||||||
|
val d = raw.toDoubleOrNull() ?: return raw
|
||||||
|
return if (d == d.toLong().toDouble()) "%,d".format(d.toLong()) else "%,.0f".format(d)
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user