1 Commits

Author SHA1 Message Date
f6deeb9624 ci(sonarqube): add non-blocking SonarQube analysis on push to main
All checks were successful
PR Checks / android-build (pull_request) Successful in 10m48s
Mirrors the website repo's setup: a source-based scan of app/src/main
(Kotlin) that reports to the self-hosted SonarQube server after merge,
never gating PRs. Project key runic-gateway-android-app.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-20 23:13:39 -05:00
174 changed files with 382 additions and 13742 deletions

3
.gitattributes vendored
View File

@@ -17,6 +17,3 @@ gradlew text eol=lf
*.png binary
*.webp binary
*.ico binary
# Bundled type families (res/font). `text=auto` already detects these as binary,
# but a font is too easy to corrupt silently to leave to a heuristic.
*.ttf binary

View File

@@ -1,54 +0,0 @@
#!/usr/bin/env python3
"""Render an ASCII tree of tracked files, read from stdin (one path per line).
Used by the `sync-project-tree` workflow to regenerate this repo's PROJECT_TREE.md
snapshot in the RunicGateway/docs repo. Feed it `git ls-files`:
git ls-files | python3 .gitea/scripts/gen_tree.py <root-label>
Deterministic ordering: directories before files, each group sorted
case-insensitively with the raw name as a tiebreak. Output uses the classic
`tree(1)` box-drawing style so the result is stable across runs and platforms.
"""
import sys
def build(paths):
root = {}
for p in paths:
p = p.strip().replace("\\", "/")
if not p:
continue
node = root
for part in p.split("/"):
node = node.setdefault(part, {})
return root
def render(node, prefix, lines):
entries = list(node.items())
# directories (non-empty children dict) before files, then case-insensitive name
entries.sort(key=lambda kv: (0 if kv[1] else 1, kv[0].lower(), kv[0]))
for i, (name, child) in enumerate(entries):
last = i == len(entries) - 1
branch = "└── " if last else "├── "
suffix = "/" if child else ""
lines.append(f"{prefix}{branch}{name}{suffix}")
if child:
render(child, prefix + (" " if last else ""), lines)
def main():
try:
sys.stdout.reconfigure(encoding="utf-8", newline="\n")
except AttributeError:
pass
root_label = sys.argv[1] if len(sys.argv) > 1 else "."
tree = build(sys.stdin.read().splitlines())
lines = [f"{root_label}/"]
render(tree, "", lines)
sys.stdout.write("\n".join(lines) + "\n")
if __name__ == "__main__":
main()

View File

@@ -8,7 +8,7 @@
# Prerequisites (one-time, in the Gitea UI — Repo → Settings → Actions):
# • Secret SONAR_TOKEN — a SonarQube "Analysis" token generated at
# My Account → Security in SonarQube for the
# Runic-Gateway-Android-app project (or a global one).
# runic-gateway-android-app project (or a global one).
# • Variable SONAR_HOST_URL — the SonarQube base URL on your LAN, e.g.
# http://192.168.0.56:9000
# (kept as a variable, not committed, so the internal address stays out of git.)
@@ -18,12 +18,11 @@
# SonarQube Quality Gate, so a failing gate does not fail this job — check the
# dashboard when you want to.
#
# Scope: the Sonar scanner reads sonar-project.properties and analyses the Kotlin
# source directly. Before the scan we run the JVM unit tests + JaCoCo so SonarQube
# receives real coverage (sonar.coverage.jacoco.xmlReportPaths) — otherwise it
# reports 0% and the coverage gate fails despite the test suite existing. That
# Gradle step needs JDK 17 + the Android SDK (same toolchain as pr-checks.yml);
# the runner container is bare, so base tools are apt-installed first.
# Scope: this analyses the Kotlin source directly (the Sonar scanner reads
# sonar-project.properties). It does NOT run a Gradle build, so no Android SDK /
# JDK install is needed — the Kotlin analyzer is source-based. See the "Optional
# enrichment" note in sonar-project.properties for wiring in Android Lint /
# coverage reports later.
name: SonarQube
@@ -41,16 +40,6 @@ jobs:
analysis:
runs-on: ubuntu-latest
steps:
# The bare runner container lacks git/curl/unzip (checkout + sdkmanager need
# them) and we install JDK 17 from the Ubuntu archive rather than
# actions/setup-java (this runner can't reach api.adoptium.net). Mirrors
# pr-checks.yml — see its header note.
- name: Install base tools + JDK 17
run: |
apt-get update
apt-get install -y git curl unzip openjdk-17-jdk-headless
echo "JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64" >> "$GITHUB_ENV"
- name: Check out (full history for accurate new-code + blame)
uses: actions/checkout@v4
with:
@@ -58,31 +47,6 @@ jobs:
# compute "new code". A shallow clone degrades both.
fetch-depth: 0
- name: Set up Android SDK
uses: android-actions/setup-android@v3
- name: Install Android SDK packages
run: |
set +o pipefail
yes | sdkmanager "platform-tools" "platforms;android-35" "build-tools;35.0.0"
- name: Cache Gradle
uses: actions/cache@v4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle.kts', 'gradle/libs.versions.toml', 'gradle/wrapper/gradle-wrapper.properties') }}
restore-keys: |
gradle-${{ runner.os }}-
# Produce the JaCoCo XML the scan reports as coverage. Scoped to the debug
# variant (matches enableUnitTestCoverage) to keep peak memory down.
- name: Unit tests + JaCoCo coverage
run: |
chmod +x ./gradlew
./gradlew --no-daemon testDebugUnitTest jacocoTestReport
- name: Run SonarQube scan
uses: sonarsource/sonarqube-scan-action@v4
env:

View File

@@ -1,111 +0,0 @@
name: sync-project-tree
# Keeps this repo's file-layout snapshot (docs/android/PROJECT_TREE.md in the
# RunicGateway/docs repo) current. On every push to `main` it regenerates the
# tree from tracked files and, if it changed, opens (or force-updates) a pull
# request against the docs repo. It never writes to the docs repo's `main`
# directly. Auth reuses the same REGISTRY_USER / REGISTRY_TOKEN secrets the
# other workflows use (the token needs repo read/write on RunicGateway/docs).
on:
push:
branches: [main]
workflow_dispatch: {}
concurrency:
group: sync-project-tree
cancel-in-progress: true
env:
GITEA_HOST: gitea.whitlocktech.com
DOCS_REPO: RunicGateway/docs
SELF_REPO: RunicGateway/Android-app
DOCS_PATH: android/PROJECT_TREE.md
TREE_TITLE: Android App
ROOT_LABEL: android-app
PR_BRANCH: chore/sync-android-tree
jobs:
sync:
runs-on: ubuntu-latest
steps:
- name: Check out this repo
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Ensure python3 is available
run: |
set -euo pipefail
command -v python3 >/dev/null 2>&1 || { sudo apt-get update -qq && sudo apt-get install -y -qq python3; }
- name: Render PROJECT_TREE.md from tracked files
run: |
set -euo pipefail
mkdir -p _sync
{
printf '# %s — Project Tree\n\n' "${TREE_TITLE}"
printf '> **Auto-generated.** This file is maintained by the `sync-project-tree` CI workflow in\n'
printf '> the [`%s`](https://%s/%s) repository, which\n' "${SELF_REPO}" "${GITEA_HOST}" "${SELF_REPO}"
printf '> opens a pull request here whenever the tracked file layout on `main` changes. Do not edit\n'
printf '> by hand — changes will be overwritten by the next sync.\n\n'
printf 'A snapshot of the tracked files in the repository (build output, dependencies, and other\n'
printf 'git-ignored paths are excluded).\n\n'
printf '```text\n'
git ls-files | python3 .gitea/scripts/gen_tree.py "${ROOT_LABEL}"
printf '```\n'
} > _sync/PROJECT_TREE.md
echo "----- generated ${DOCS_PATH} -----"
cat _sync/PROJECT_TREE.md
- name: Open or update the docs PR if the tree changed
env:
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
set -euo pipefail
# Secrets can carry a trailing CR/LF depending on how they were pasted;
# strip line breaks before they land in a URL or Authorization header.
CI_USER="$(printf '%s' "${REGISTRY_USER}" | tr -d '\r\n')"
CI_TOKEN="$(printf '%s' "${REGISTRY_TOKEN}" | tr -d '\r\n')"
API="https://${GITEA_HOST}/api/v1/repos/${DOCS_REPO}"
REMOTE="https://${CI_USER}:${CI_TOKEN}@${GITEA_HOST}/${DOCS_REPO}.git"
git clone --depth 1 "${REMOTE}" docs_repo
cd docs_repo
git config user.name "runic-docs-bot"
git config user.email "ci@whitlocktech.com"
mkdir -p "$(dirname "${DOCS_PATH}")"
cp ../_sync/PROJECT_TREE.md "${DOCS_PATH}"
git add "${DOCS_PATH}"
if git diff --cached --quiet; then
echo "PROJECT_TREE.md already up to date — nothing to sync."
exit 0
fi
SHORT_SHA="$(echo "${GITHUB_SHA:-local}" | cut -c1-7)"
git checkout -B "${PR_BRANCH}"
git commit -m "docs(tree): sync ${DOCS_PATH} from ${SELF_REPO}@${SHORT_SHA} [skip ci]"
git push --force "${REMOTE}" "HEAD:${PR_BRANCH}"
# Open a PR only if one isn't already open for this branch (a force-push
# to an existing open PR's head updates it in place).
OPEN="$(curl -sSf -H "Authorization: token ${CI_TOKEN}" \
"${API}/pulls?state=open&limit=50" \
| jq --arg b "${PR_BRANCH}" '[.[] | select(.head.ref == $b)] | length')"
if [ "${OPEN}" = "0" ]; then
curl -sSf -X POST "${API}/pulls" \
-H "Authorization: token ${CI_TOKEN}" \
-H "Content-Type: application/json" \
-d "$(jq -n \
--arg head "${PR_BRANCH}" \
--arg base "main" \
--arg title "docs(tree): sync ${DOCS_PATH}" \
--arg body "Automated project-tree sync from [\`${SELF_REPO}\`](https://${GITEA_HOST}/${SELF_REPO}), regenerated from tracked files on \`main\`. Merge once the layout looks right; the workflow will keep this branch current until then." \
'{head: $head, base: $base, title: $title, body: $body}')" \
>/dev/null
echo "Opened a new docs PR for ${PR_BRANCH}."
else
echo "Existing open docs PR for ${PR_BRANCH} was updated via force-push."
fi

View File

@@ -10,11 +10,6 @@ plugins {
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.ksp)
alias(libs.plugins.hilt)
jacoco
}
jacoco {
toolVersion = "0.8.12"
}
// Release signing material (PLAN.md §12) is never committed. It is read from, in
@@ -83,11 +78,6 @@ android {
}
buildTypes {
debug {
// Produce a JaCoCo .exec from JVM unit tests so SonarQube receives real
// coverage (§12.1). Debug-only: the scan analyses the debug variant.
enableUnitTestCoverage = true
}
release {
// R8 full-mode minify + resource shrink (§7: no offline cache, so a lean
// release APK). Keep rules live in proguard-rules.pro.
@@ -180,35 +170,3 @@ dependencies {
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
}
// JaCoCo XML coverage from the JVM unit tests, consumed by SonarQube (§12.1). Generated,
// DI (Hilt), and Compose-scaffold classes are excluded so they don't dilute the number;
// pure-@Composable UI is excluded on the Sonar side (sonar.coverage.exclusions) because
// JVM unit tests can't execute composable bodies without Robolectric.
tasks.register<JacocoReport>("jacocoTestReport") {
dependsOn("testDebugUnitTest")
group = "verification"
description = "Generates JaCoCo XML/HTML coverage for the debug unit tests."
reports {
xml.required.set(true)
html.required.set(true)
}
val coverageExcludes = listOf(
"**/R.class", "**/R$*.class", "**/BuildConfig.*", "**/Manifest*.*",
"**/*_Hilt*.*", "**/Hilt_*.*", "**/*_Factory*.*", "**/*_MembersInjector*.*",
"**/*_Impl*.*", "**/di/**", "**/*Module.*", "**/*Module$*.*",
"**/*ComposableSingletons*.*", "**/ComposableSingletons$*.*",
)
val buildDirFile = layout.buildDirectory.get().asFile
classDirectories.setFrom(
fileTree("$buildDirFile/tmp/kotlin-classes/debug") { exclude(coverageExcludes) },
)
sourceDirectories.setFrom(files("src/main/java", "src/main/kotlin"))
executionData.setFrom(
fileTree(buildDirFile) {
include("outputs/unit_test_code_coverage/debugUnitTest/testDebugUnitTest.exec")
},
)
}

View File

@@ -1,93 +0,0 @@
Copyright 2017 The EB Garamond Project Authors (https://github.com/octaviopardo/EBGaramond12)
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:
https://openfontlicense.org
-----------------------------------------------------------
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.

View File

@@ -1,93 +0,0 @@
Copyright (c) 2010, Igino Marini (mail@iginomarini.com)
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.

View File

@@ -1,93 +0,0 @@
Copyright 2020 The Inter Project Authors (https://github.com/rsms/inter)
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:
https://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.

View File

@@ -1,93 +0,0 @@
Copyright 2020 The Merriweather Project Authors (https://github.com/EbenSorkin/Merriweather4) with Reserved Font Name "Merriweather".
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:
https://openfontlicense.org
-----------------------------------------------------------
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.

View File

@@ -1,93 +0,0 @@
Copyright 2017 The Playfair Display Project Authors (https://github.com/clauseggers/Playfair-Display), with Reserved Font Name "Playfair Display"
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.

View File

@@ -1,93 +0,0 @@
Copyright 2010-2020 Adobe (http://www.adobe.com/), with Reserved Font Name 'Source'. All Rights Reserved. Source is a trademark of Adobe in the United States and/or other countries.
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.

View File

@@ -1,93 +0,0 @@
Copyright 2019 The Work Sans Project Authors (https://github.com/weiweihuanghuang/Work-Sans)
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.

View File

@@ -1,20 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- SPDX-License-Identifier: GPL-3.0-or-later -->
<!--
Debug-only override of the main network_security_config.xml. Keeps the secure
base posture (no cleartext) but re-permits cleartext to loopback so debug builds
can reach a local website backend at http://127.0.0.1:3000 / http://localhost:3000
(ServerUrl allows plain HTTP only when allowInsecureHttp = BuildConfig.DEBUG).
Because the platform default already blocks cleartext at targetSdk 28+, this
domain-config is what actually makes the debug local-dev path work at runtime.
This file is compiled only into debug builds; release builds use the main
source set's config and permit no cleartext at all.
-->
<network-security-config>
<base-config cleartextTrafficPermitted="false" />
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="false">127.0.0.1</domain>
<domain includeSubdomains="false">localhost</domain>
</domain-config>
</network-security-config>

View File

@@ -20,7 +20,6 @@
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:networkSecurityConfig="@xml/network_security_config"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.RunicGateway">

View File

@@ -23,7 +23,6 @@ import androidx.compose.ui.Modifier
import com.runicgateway.app.core.auth.sso.SsoAuthManager
import com.runicgateway.app.core.push.PushNotifier
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.LifecycleResumeEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.runicgateway.app.ui.AppViewModel
import com.runicgateway.app.ui.AppViewModel.AppState
@@ -31,8 +30,8 @@ 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.data.appearance.SiteAppearance
import com.runicgateway.app.ui.theme.RunicGatewayTheme
import com.runicgateway.app.ui.theme.parseBrandColor
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch
import javax.inject.Inject
@@ -40,8 +39,8 @@ import javax.inject.Inject
/**
* Single-activity host (PLAN.md §2). Gates on [AppViewModel]: the first-run
* connect screen until a shard site is configured (§3), then the main app.
* The Material theme is resolved from the shard's published appearance (M12),
* and asset-path resolution is provided to the whole tree.
* The Material theme is seeded from the per-shard brand accent, and asset-path
* resolution is provided to the whole tree.
*/
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
@@ -70,21 +69,9 @@ class MainActivity : ComponentActivity() {
val appViewModel: AppViewModel = hiltViewModel()
val state by appViewModel.state.collectAsStateWithLifecycle()
// The whole theme, not just the accent (THEMING_AND_NAV.md §5.1): the
// resolved token map is applied field by field over the shipped palette,
// so NONE — before the site is connected, or when settings can't be
// read — is the app exactly as it shipped.
val appearance = (state as? AppState.Ready)?.appearance ?: SiteAppearance.NONE
val accent = (state as? AppState.Ready)?.brand?.let { parseBrandColor(it.accent) }
// The admin's theme and nav can change while the app is backgrounded
// (THEMING_AND_NAV.md §5.5). Re-read them on resume, beside the session
// re-validation RunicApp already does. Best-effort and silent.
LifecycleResumeEffect(Unit) {
appViewModel.refreshAppearance()
onPauseOrDispose { }
}
RunicGatewayTheme(appearance = appearance) {
RunicGatewayTheme(accent = accent) {
CompositionLocalProvider(LocalAssetResolver provides appViewModel::resolveAsset) {
Surface(
modifier = Modifier.fillMaxSize(),
@@ -96,7 +83,7 @@ class MainActivity : ComponentActivity() {
ConnectScreen(onConnected = appViewModel::onConnected)
is AppState.Ready ->
RunicApp(
appearance = s.appearance,
brand = s.brand,
onChangeServer = appViewModel::changeServer,
deepLinkStream = pendingStream,
onDeepLinkConsumed = { pendingStream = null },

View File

@@ -1,34 +0,0 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.core.auth
import android.os.Build
import javax.inject.Inject
import javax.inject.Singleton
/**
* Supplies a friendly label for this device, sent as `device_name` at login so a
* trusted-device / active-session row is recognizable in the account lists
* (TRUSTED_DEVICES_MFA.md). Behind an interface so the auth repository stays free of
* `android.os.Build` and unit-testable on the JVM.
*/
fun interface DeviceNameProvider {
/** A human label like "Google Pixel 8", or null if nothing meaningful is available. */
fun deviceName(): String?
}
/** Production impl: manufacturer + model from [Build] (e.g. "Samsung SM-S918B"). */
@Singleton
class BuildDeviceNameProvider @Inject constructor() : DeviceNameProvider {
override fun deviceName(): String? {
val manufacturer = Build.MANUFACTURER?.trim().orEmpty()
val model = Build.MODEL?.trim().orEmpty()
val label = when {
model.isEmpty() -> manufacturer
manufacturer.isEmpty() || model.startsWith(manufacturer, ignoreCase = true) -> model
else -> "$manufacturer $model"
}.replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() }
return label.take(100).ifBlank { null }
}
}

View File

@@ -1,65 +0,0 @@
/*
* 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
/**
* [TrustTokenStore] backed by its **own** EncryptedSharedPreferences file
* (Tink/AES-256-GCM), distinct from the session store so it is never wiped by
* [SessionManager.onSignedOut] — the trust token must outlive a logout to do its
* job (TRUSTED_DEVICES_MFA.md). The token is stored alongside the username it was
* minted for so [tokenFor] only returns it for a matching login.
*
* The prefs handle is lazy so a device that never trusts pays the keystore cost
* only if a token is actually stored or read.
*/
@Singleton
class EncryptedTrustTokenStore @Inject constructor(
@param:ApplicationContext private val context: Context,
) : TrustTokenStore {
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 tokenFor(username: String): String? {
val token = prefs.getString(KEY_TOKEN, null) ?: return null
val owner = prefs.getString(KEY_USERNAME, null) ?: return null
// Case-insensitive: usernames are matched case-insensitively server-side.
return if (owner.equals(username, ignoreCase = true)) token else null
}
override fun save(username: String, token: String) {
prefs.edit()
.putString(KEY_TOKEN, token)
.putString(KEY_USERNAME, username)
.apply()
}
override fun clear() {
prefs.edit().clear().apply()
}
private companion object {
const val PREFS_NAME = "runic_trust"
const val KEY_TOKEN = "trust_token"
const val KEY_USERNAME = "trust_username"
}
}

View File

@@ -16,15 +16,6 @@ data class SessionUser(
val role: Role,
) {
val isPlayer: Boolean get() = role == Role.PLAYER
/** Any staff role (moderator/editor/admin) — the staff-operations surface (§1, M10). */
val isStaff: Boolean get() = role.isStaff
/** Admin or moderator — moderation actions + the support queue (`modAccess`). */
val isModerator: Boolean get() = role == Role.ADMIN || role == Role.MODERATOR
/** Admin only — site-mode and other `adminOnly` controls. */
val isAdmin: Boolean get() = role == Role.ADMIN
}
/**

View File

@@ -1,31 +0,0 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.core.auth
/**
* At-rest home for the opaque trusted-device token (TRUSTED_DEVICES_MFA.md). It is
* the native analogue of the web `rg_trust` cookie: a device that holds a valid
* token skips the TOTP step on its next login (never the password).
*
* Deliberately **separate** from [TokenStore] and untouched by session teardown —
* the token must **survive logout and a dead-refresh sign-out**, because it is only
* ever consulted at a *fresh* login (exactly the moment after the session is gone).
* Clearing it there would make the feature a no-op. It is scoped to the username it
* was minted for so it is never replayed for a different account on a shared device,
* and is cleared only by an explicit untrust, a Settings → Server switch, or a
* server-side revocation (password change/reset, TOTP disable) that renders it dead.
*
* Tokens are sensitive, so the production impl uses EncryptedSharedPreferences —
* never plain prefs or logs. Kept behind an interface for an in-memory test fake.
*/
interface TrustTokenStore {
/** The stored trust token for [username], or null if this device isn't trusted for them. */
fun tokenFor(username: String): String?
/** Persist [token] as the trust token for [username] (overwrites any prior one). */
fun save(username: String, token: String)
/** Drop the trust token — untrust-all and the Settings → Server hard reset. */
fun clear()
}

View File

@@ -1,61 +0,0 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.core.auth.sso
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
/**
* [PendingSsoStore] backed by Jetpack Security's [EncryptedSharedPreferences]
* (Tink/AES-256-GCM), so the PKCE verifier is encrypted at rest for the brief
* window a flow is in progress. Separate prefs file from the session token store —
* this holds only the transient SSO handshake, cleared as soon as the callback is
* consumed. Lazy, so a device that never signs in via SSO pays no keystore cost.
*/
@Singleton
class EncryptedPendingSsoStore @Inject constructor(
@param:ApplicationContext private val context: Context,
) : PendingSsoStore {
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 save(state: String, verifier: String) {
prefs.edit()
.putString(KEY_STATE, state)
.putString(KEY_VERIFIER, verifier)
.apply()
}
override fun load(): PendingSso? {
val state = prefs.getString(KEY_STATE, null) ?: return null
val verifier = prefs.getString(KEY_VERIFIER, null) ?: return null
return PendingSso(state = state, verifier = verifier)
}
override fun clear() {
prefs.edit().clear().apply()
}
private companion object {
const val PREFS_NAME = "runic_sso_pending"
const val KEY_STATE = "state"
const val KEY_VERIFIER = "verifier"
}
}

View File

@@ -1,24 +0,0 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.core.auth.sso
/**
* Persists the in-flight SSO `{state, verifier}` (PKCE Layer B + CSRF state) across
* the Custom-Tab round trip so the exchange survives process death — a low-memory
* device can evict the app while the Custom Tab is foreground, and the callback then
* returns to a fresh process (PLAN.md §4.2). Kept behind an interface so
* [SsoAuthManager] stays framework-free and unit-tests on the JVM with a fake.
*
* Exactly one flow is pending at a time; [save] overwrites any prior. The verifier
* is a bearer-equivalent secret for the one-time code, so the production impl
* ([EncryptedPendingSsoStore]) encrypts it at rest, mirroring the token store.
*/
interface PendingSsoStore {
fun save(state: String, verifier: String)
fun load(): PendingSso?
fun clear()
}
/** The stashed CSRF state + PKCE verifier for the current SSO attempt. */
data class PendingSso(val state: String, val verifier: String)

View File

@@ -5,7 +5,6 @@ package com.runicgateway.app.core.auth.sso
import com.runicgateway.app.BuildConfig
import com.runicgateway.app.core.auth.SessionManager
import com.runicgateway.app.core.auth.TrustTokenStore
import com.runicgateway.app.core.net.BaseUrlHolder
import com.runicgateway.app.data.api.SsoApi
import com.runicgateway.app.data.api.dto.MobileSsoExchangeRequest
@@ -14,6 +13,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import java.io.IOException
import java.util.concurrent.atomic.AtomicReference
import javax.inject.Inject
import javax.inject.Singleton
@@ -34,23 +34,20 @@ import javax.inject.Singleton
* parses the callback `Uri` (the Android edge) and hands the raw params here,
* so this class stays free of framework types and unit-tests on the JVM.
*
* The pending `{state, verifier}` is persisted via [PendingSsoStore] (encrypted at
* rest), so the exchange survives the process being evicted while the Custom Tab is
* foreground — the callback can land in a fresh process and still complete. It is
* cleared the moment [complete] consumes it, so a lost/duplicate callback still
* **fails closed** as [Failure.STATE_MISMATCH] rather than double-exchanging.
* The pending `{state, verifier}` lives only in memory: if the process is killed
* while the Custom Tab is foreground it is lost and the exchange **fails closed**
* (the user simply retries) — never a security downgrade.
*
* Threading: [buildStartUrl] runs on the UI thread; [complete] runs on the
* activity's coroutine scope after a deep link. [outcome] is a [StateFlow], so a
* ViewModel/activity recreation while the Custom Tab is open cannot drop a result.
* activity's coroutine scope after a deep link. The pending holder is an
* [AtomicReference] and [outcome] a [StateFlow], so a ViewModel/activity recreation
* while the Custom Tab is open cannot drop a result.
*/
@Singleton
class SsoAuthManager @Inject constructor(
private val ssoApi: SsoApi,
private val sessionManager: SessionManager,
private val baseUrlHolder: BaseUrlHolder,
private val pendingStore: PendingSsoStore,
private val trustTokenStore: TrustTokenStore,
) {
/** Why an SSO attempt ended, for a friendly inline message on the login screen. */
@@ -78,6 +75,10 @@ class SsoAuthManager @Inject constructor(
data class Failed(val reason: Failure) : Outcome
}
private data class Pending(val state: String, val verifier: String)
private val pending = AtomicReference<Pending?>(null)
/**
* The host this build baked an App Link intent-filter for (`BuildConfig.APP_LINK_HOST`,
* empty on the generic multi-tenant build — see docs/android/APP_LINKS.md).
@@ -96,16 +97,16 @@ class SsoAuthManager @Inject constructor(
/**
* Build the `/auth/mobile/sso/start` URL for [providerId] and stash the pending
* PKCE verifier + CSRF state (persisted so it survives process death). Returns
* null when no shard site is configured yet. Also resets [outcome] to
* [Outcome.Idle] so a stale prior result can't fire against the new attempt.
* PKCE verifier + CSRF state. Returns null when no shard site is configured yet
* (the caller then keeps the website hand-off fallback). Also resets [outcome]
* to [Outcome.Idle] so a stale prior result can't fire against the new attempt.
*/
fun buildStartUrl(providerId: String): String? {
val base = baseUrlHolder.current ?: return null
val verifier = Pkce.newVerifier()
val challenge = Pkce.challengeOf(verifier)
val state = Pkce.newState()
pendingStore.save(state = state, verifier = verifier)
pending.set(Pending(state = state, verifier = verifier))
_outcome.value = Outcome.Idle
return base.newBuilder()
.addPathSegments("api/v1/auth/mobile/sso/start")
@@ -157,8 +158,7 @@ class SsoAuthManager @Inject constructor(
* single-uses the code).
*/
suspend fun complete(state: String?, code: String?, error: String?) {
val stashed = pendingStore.load()
pendingStore.clear()
val stashed = pending.getAndSet(null)
// CSRF: the callback must echo the exact state we generated at /start.
if (stashed == null || state.isNullOrEmpty() || state != stashed.state) {
@@ -195,14 +195,6 @@ class SsoAuthManager @Inject constructor(
_outcome.value = Outcome.Failed(Failure.SERVER)
return
}
// The user ticked "trust this device" on the TOTP form inside the Custom
// Tab. That tab's cookie already covers future SSO sign-ins; persisting
// the token the exchange handed back is what lets a native PASSWORD login
// on this device skip the code too (TRUSTED_DEVICES_MFA.md). Scoped to the
// username exactly like the password path, so it is never replayed for a
// different account on a shared device. Saved BEFORE onSignedIn so a
// process death mid-callback can't lose it.
body.trustToken?.let { trustTokenStore.save(body.user.username, it) }
sessionManager.onSignedIn(body.accessToken, body.refreshToken, body.user)
_outcome.value = Outcome.Success
return

View File

@@ -1,16 +0,0 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.core.net
import kotlinx.coroutines.flow.Flow
/**
* The live shard SSE feed as a cold flow of lifecycle + frame events (PLAN.md §6.2,
* §7). Extracted as an interface so consumers (e.g. [com.runicgateway.app.data.repository.ShardRepository])
* depend on the capability, not the OkHttp-backed [ShardStreamClient] — the boards
* can then be unit-tested against a fake stream instead of a real network connection.
*/
interface ShardStream {
fun events(): Flow<ShardStreamEvent>
}

View File

@@ -40,7 +40,7 @@ class ShardStreamClient @Inject constructor(
baseClient: OkHttpClient,
private val baseUrlHolder: BaseUrlHolder,
private val json: Json,
) : ShardStream {
) {
// 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()
@@ -56,7 +56,7 @@ class ShardStreamClient @Inject constructor(
* drive a live/offline indicator; [ShardStreamEvent.Frame] carries a decoded
* `{ kind, … }` payload the boards merge in place.
*/
override fun events(): Flow<ShardStreamEvent> = channelFlow {
fun events(): Flow<ShardStreamEvent> = channelFlow {
var backoffMs = INITIAL_BACKOFF_MS
while (isActive) {
val url = baseUrlHolder.current?.resolve(STREAM_PATH)

View File

@@ -4,7 +4,6 @@
package com.runicgateway.app.core.result
import kotlinx.coroutines.CancellationException
import kotlinx.serialization.SerializationException
import retrofit2.HttpException
import java.io.IOException
@@ -38,16 +37,6 @@ inline fun <T, R> ApiResult<T>.map(transform: (T) -> R): ApiResult<R> = when (th
* 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.
*
* A body the app can't decode (a field whose type/shape doesn't match its DTO, e.g.
* a live-shaped `guild.update` snapshot carrying an unexpected value) throws a
* [SerializationException] out of the Retrofit converter. That is a broken contract
* with the backend, not a bug to crash on: the request completed but the response is
* unusable — an invalid upstream response — so it is surfaced as a server-side error
* (`502` → [ErrorKind.SERVER]) the screen renders as "something went wrong, retry",
* exactly the graceful-degradation the layer promises (never throw for an expected
* failure). Without this catch the exception escapes the collecting coroutine and
* takes down the whole app.
*/
suspend fun <T> safeApiCall(block: suspend () -> T): ApiResult<T> = try {
ApiResult.Ok(block())
@@ -57,9 +46,4 @@ suspend fun <T> safeApiCall(block: suspend () -> T): ApiResult<T> = try {
ApiResult.HttpError(e.code(), e.message())
} catch (e: IOException) {
ApiResult.NetworkError(e)
} catch (e: SerializationException) {
ApiResult.HttpError(MALFORMED_RESPONSE_STATUS, e.message)
}
/** Synthetic status for a 2xx body the app couldn't decode — an invalid upstream response. */
private const val MALFORMED_RESPONSE_STATUS = 502

View File

@@ -26,8 +26,12 @@ class WebsiteUrls @Inject constructor(
/** 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"
}
}

View File

@@ -1,97 +0,0 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.data.api
import com.runicgateway.app.data.api.dto.AdminDashboardDto
import com.runicgateway.app.data.api.dto.AdminPostDto
import com.runicgateway.app.data.api.dto.BanRequest
import com.runicgateway.app.data.api.dto.BroadcastRequest
import com.runicgateway.app.data.api.dto.KickRequest
import com.runicgateway.app.data.api.dto.PageRespondRequest
import com.runicgateway.app.data.api.dto.PostCreateRequest
import com.runicgateway.app.data.api.dto.PublishRequest
import com.runicgateway.app.data.api.dto.SiteModeRequest
import com.runicgateway.app.data.api.dto.SiteModeStateDto
import com.runicgateway.app.data.api.dto.SupportPageDto
import com.runicgateway.app.data.api.dto.UnbanRequest
import com.runicgateway.app.data.api.dto.AdminWikiCategoryDto
import com.runicgateway.app.data.api.dto.WikiCategoryRequest
import com.runicgateway.app.data.api.dto.AdminWikiTagDto
import retrofit2.Response
import retrofit2.http.Body
import retrofit2.http.DELETE
import retrofit2.http.GET
import retrofit2.http.PATCH
import retrofit2.http.PUT
import retrofit2.http.POST
import retrofit2.http.Path
/**
* The M10 staff-operations surface over `/api/v1/admin/…` (PLAN.md §1, §6.4). On
* the authed client — every call carries the bearer, and the backend re-checks the
* caller's role on every request (`staffOnly` / `modAccess` / `adminOnly`), so a
* demoted user is refused server-side even if a stale menu still showed the entry.
*
* Grows one group at a time (dashboard first); moderation, support, and content
* endpoints are added with their screens.
*/
interface AdminApi {
/** `GET /admin/dashboard` — summary counts + site mode (any staff role). */
@GET("api/v1/admin/dashboard")
suspend fun dashboard(): AdminDashboardDto
/** `PUT /admin/site-mode` — switch live/maintenance (admin only; 403 otherwise). */
@PUT("api/v1/admin/site-mode")
suspend fun setSiteMode(@Body body: SiteModeRequest): SiteModeStateDto
// ── Content: news posts (any staff role) ──────────────────────────────
@GET("api/v1/admin/posts")
suspend fun posts(): List<AdminPostDto>
@POST("api/v1/admin/posts")
suspend fun createPost(@Body body: PostCreateRequest): AdminPostDto
@PATCH("api/v1/admin/posts/{id}/publish")
suspend fun publishPost(@Path("id") id: Long, @Body body: PublishRequest): AdminPostDto
@DELETE("api/v1/admin/posts/{id}")
suspend fun deletePost(@Path("id") id: Long): Response<Unit>
// ── Content: wiki taxonomy (any staff role) ───────────────────────────
@GET("api/v1/admin/wiki/categories")
suspend fun wikiCategories(): List<AdminWikiCategoryDto>
@POST("api/v1/admin/wiki/categories")
suspend fun createWikiCategory(@Body body: WikiCategoryRequest): AdminWikiCategoryDto
@DELETE("api/v1/admin/wiki/categories/{id}")
suspend fun deleteWikiCategory(@Path("id") id: Long): Response<Unit>
@GET("api/v1/admin/wiki/tags")
suspend fun wikiTags(): List<AdminWikiTagDto>
// ── Moderation: shard write plane (admin/moderator) ───────────────────
@POST("api/v1/admin/shard/kick")
suspend fun kick(@Body body: KickRequest): Response<Unit>
@POST("api/v1/admin/shard/ban")
suspend fun ban(@Body body: BanRequest): Response<Unit>
@POST("api/v1/admin/shard/unban")
suspend fun unban(@Body body: UnbanRequest): Response<Unit>
@POST("api/v1/admin/shard/broadcast")
suspend fun broadcast(@Body body: BroadcastRequest): Response<Unit>
// ── Support queue: help pages (admin/moderator) ───────────────────────
@GET("api/v1/admin/shard/pages")
suspend fun supportPages(): List<SupportPageDto>
@POST("api/v1/admin/shard/pages/{id}/respond")
suspend fun respondPage(@Path("id") id: String, @Body body: PageRespondRequest): Response<Unit>
@POST("api/v1/admin/shard/pages/{id}/close")
suspend fun closePage(@Path("id") id: String): Response<Unit>
}

View File

@@ -10,7 +10,6 @@ import com.runicgateway.app.data.api.dto.MobileTokenResponse
import retrofit2.Response
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.Header
import retrofit2.http.Headers
import retrofit2.http.POST
@@ -28,15 +27,9 @@ import retrofit2.http.POST
interface AuthApi {
// Literal header value required by Retrofit @Headers; matches Http.NO_SESSION_HEADER.
// [trustToken] rides the `X-Trust-Token` header (TRUSTED_DEVICES_MFA.md): a valid
// token bound to this user lets the server skip the TOTP step. Retrofit omits the
// header entirely when it is null, so an untrusted device sends nothing.
@Headers("X-Runic-No-Session: 1")
@POST("api/v1/auth/mobile/login")
suspend fun login(
@Body body: MobileLoginRequest,
@Header("X-Trust-Token") trustToken: String? = null,
): Response<MobileTokenResponse>
suspend fun login(@Body body: MobileLoginRequest): Response<MobileTokenResponse>
@POST("api/v1/auth/mobile/logout")
suspend fun logout(@Body body: MobileLogoutRequest): Response<Unit>

View File

@@ -7,21 +7,11 @@ 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.RecoveryCodesDto
import com.runicgateway.app.data.api.dto.RecoveryGenerateRequest
import com.runicgateway.app.data.api.dto.RecoveryStatusDto
import com.runicgateway.app.data.api.dto.RevokedCountDto
import com.runicgateway.app.data.api.dto.RevokedFlagDto
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.TrustDeviceRequest
import com.runicgateway.app.data.api.dto.TrustDeviceResultDto
import com.runicgateway.app.data.api.dto.TrustedDeviceDto
import com.runicgateway.app.data.api.dto.UsernameResponse
import retrofit2.Response
import retrofit2.http.Body
import retrofit2.http.DELETE
import retrofit2.http.GET
import retrofit2.http.HTTP
import retrofit2.http.PATCH
@@ -62,28 +52,4 @@ interface MeApi {
// 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
// ── Trusted devices (TRUSTED_DEVICES_MFA.md) — devices allowed to skip TOTP ──
@GET("api/v1/auth/me/trusted-devices")
suspend fun trustedDevices(): List<TrustedDeviceDto>
// Raw [Response] so the caller can read the `409 { error, devices }` cap body,
// which a thrown HttpException would discard.
@POST("api/v1/auth/me/trusted-devices")
suspend fun trustThisDevice(@Body body: TrustDeviceRequest): Response<TrustDeviceResultDto>
@DELETE("api/v1/auth/me/trusted-devices/{id}")
suspend fun revokeTrustedDevice(@Path("id") id: Long): RevokedFlagDto
@DELETE("api/v1/auth/me/trusted-devices")
suspend fun revokeAllTrustedDevices(): RevokedCountDto
// ── Recovery (backup) codes ──────────────────────────────────────────────
@GET("api/v1/auth/me/account/recovery-codes/status")
suspend fun recoveryCodesStatus(): RecoveryStatusDto
@POST("api/v1/auth/me/account/recovery-codes/generate")
suspend fun generateRecoveryCodes(@Body body: RecoveryGenerateRequest): RecoveryCodesDto
}

View File

@@ -3,9 +3,6 @@
*/
package com.runicgateway.app.data.api
import com.runicgateway.app.data.api.dto.AtlasCreatureDto
import com.runicgateway.app.data.api.dto.AtlasCreaturePageDto
import com.runicgateway.app.data.api.dto.AtlasMetaDto
import com.runicgateway.app.data.api.dto.ChampDto
import com.runicgateway.app.data.api.dto.ContactRequest
import com.runicgateway.app.data.api.dto.ContactResponse
@@ -15,17 +12,11 @@ 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.MarketMetaDto
import com.runicgateway.app.data.api.dto.MarketPageDto
import com.runicgateway.app.data.api.dto.MarketVendorDto
import com.runicgateway.app.data.api.dto.OnlineStaffDto
import com.runicgateway.app.data.api.dto.PageDto
import com.runicgateway.app.data.api.dto.PointsBoardDto
import com.runicgateway.app.data.api.dto.PostDto
import com.runicgateway.app.data.api.dto.PresenceDto
import com.runicgateway.app.data.api.dto.RulesetDto
import com.runicgateway.app.data.api.dto.SettingsDto
import com.runicgateway.app.data.api.dto.ShardFeaturesDto
import com.runicgateway.app.data.api.dto.ShardStatusDto
import com.runicgateway.app.data.api.dto.StatusDto
import com.runicgateway.app.data.api.dto.WikiCategoryDto
@@ -102,14 +93,6 @@ interface PublicApi {
suspend fun postContact(@Body body: ContactRequest): ContactResponse
// ── Public shard widgets (§6.2) ──────────────────────────────────────
/**
* Which shard features this caller may reach, so the menu hides entries instead
* of rendering links that 404/403 (§5, M11). Answered per-viewer: an anonymous
* call and a signed-in one can differ.
*/
@GET("api/v1/public/shard/features")
suspend fun getShardFeatures(): ShardFeaturesDto
@GET("api/v1/public/shard/status")
suspend fun getShardStatus(): ShardStatusDto
@@ -145,65 +128,4 @@ interface PublicApi {
@GET("api/v1/public/shard/houses")
suspend fun getShardHouses(): List<HouseDto>
// ── Protocol 3.0 shard content (§9 M11) ──────────────────────────────
//
// Each of these sits behind the website's `requireFeature` gate: a 404 means the
// shard doesn't publish it and a 403 means this viewer is below its audience rung,
// which `toShardUiState()` folds into one "not available here" state.
/** The shard's configured ruleset. A `null` body means "not published yet". */
@GET("api/v1/public/shard/ruleset")
suspend fun getShardRuleset(): RulesetDto?
/** Every points/loyalty leaderboard the shard publishes. */
@GET("api/v1/public/shard/points")
suspend fun getShardPoints(): List<PointsBoardDto>
@GET("api/v1/public/shard/points/{system}")
suspend fun getShardPointsBoard(@Path("system") system: String): PointsBoardDto
/**
* Search the player-vendor index. **Rate-limited** — the first genuinely expensive
* public endpoint on the site, so handle `429` (`ErrorKind.RATE_LIMITED`).
*/
@GET("api/v1/public/shard/market")
suspend fun getShardMarket(
@Query("q") query: String? = null,
@Query("minPrice") minPrice: Long? = null,
@Query("maxPrice") maxPrice: Long? = null,
@Query("map") map: String? = null,
@Query("region") region: String? = null,
@Query("sort") sort: String? = null,
@Query("limit") limit: Int? = null,
@Query("offset") offset: Int? = null,
): MarketPageDto
/** Index size, staleness, and which facets/regions actually hold vendors. */
@GET("api/v1/public/shard/market/meta")
suspend fun getShardMarketMeta(): MarketMetaDto
@GET("api/v1/public/shard/market/vendors/{serial}")
suspend fun getShardMarketVendor(
@Path("serial") serial: String,
@Query("limit") limit: Int? = null,
@Query("offset") offset: Int? = null,
): MarketVendorDto
// The atlas lives under /public/atlas, NOT /public/shard: it is static shard
// content parsed from the server's data files, so it stays readable while the
// shard is down — but it IS site-mode gated, unlike the shard routes.
@GET("api/v1/public/atlas/creatures")
suspend fun getAtlasCreatures(
@Query("q") query: String? = null,
@Query("facet") facet: String? = null,
@Query("limit") limit: Int? = null,
@Query("offset") offset: Int? = null,
): AtlasCreaturePageDto
@GET("api/v1/public/atlas/creatures/{slug}")
suspend fun getAtlasCreature(@Path("slug") slug: String): AtlasCreatureDto
@GET("api/v1/public/atlas/meta")
suspend fun getAtlasMeta(): AtlasMetaDto
}

View File

@@ -55,78 +55,9 @@ data class TotpSetupDto(
@Serializable
data class TotpCodeRequest(val code: String)
/**
* Result of enabling/disabling 2FA. Enabling also returns the freshly generated
* single-use [recoveryCodes] **once** (null on disable and for older backends) — the
* app shows them for the user to save and never persists them.
*/
/** Result of enabling/disabling 2FA. */
@Serializable
data class TotpStateDto(
val totp_enabled: Boolean = false,
val recoveryCodes: List<String>? = null,
)
// ── Trusted devices & recovery codes (TRUSTED_DEVICES_MFA.md) ───────────────
/**
* An active trusted device (`GET /auth/me/trusted-devices`): a browser/app allowed
* to skip the TOTP step at login. Never carries the token. Timestamps are ISO-8601
* strings shown as-is (advisory display).
*/
@Serializable
data class TrustedDeviceDto(
val id: Long = 0,
val platform: String? = null,
val deviceName: String? = null,
val userAgent: String? = null,
val createdAt: String? = null,
val lastUsedAt: String? = null,
val expiresAt: String? = null,
)
/** `POST /auth/me/trusted-devices` body — an optional friendly label. */
@Serializable
data class TrustDeviceRequest(val deviceName: String? = null)
/**
* `POST /auth/me/trusted-devices` success (native): the opaque [trustToken] to store
* and replay via `X-Trust-Token`. Web receives the token as a cookie and no body token.
*/
@Serializable
data class TrustDeviceResultDto(
val trusted: Boolean = false,
val trustToken: String? = null,
)
/**
* `409 { error: "trusted_device_limit", devices }` from a trust attempt at the cap —
* the app lists [devices] and asks the user to revoke one, then retry.
*/
@Serializable
data class TrustedDeviceLimitDto(
val error: String? = null,
val devices: List<TrustedDeviceDto> = emptyList(),
)
/** `DELETE /auth/me/trusted-devices/:id` — idempotent single-revoke result. */
@Serializable
data class RevokedFlagDto(val revoked: Boolean = false)
/** `DELETE /auth/me/trusted-devices` — count of devices untrusted ("untrust all"). */
@Serializable
data class RevokedCountDto(val revoked: Int = 0)
/** `GET /auth/me/account/recovery-codes/status` — remaining unused count only. */
@Serializable
data class RecoveryStatusDto(val remaining: Int = 0)
/** `POST /auth/me/account/recovery-codes/generate` body — password step-up. */
@Serializable
data class RecoveryGenerateRequest(val currentPassword: String? = null)
/** A fresh single-use recovery-code batch, returned **once** (generate + totp enable). */
@Serializable
data class RecoveryCodesDto(val recoveryCodes: List<String> = emptyList())
data class TotpStateDto(val totp_enabled: Boolean = false)
/** A linked external identity (`GET /auth/me/account/identities`). */
@Serializable

View File

@@ -1,179 +0,0 @@
/*
* 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.JsonElement
/**
* Wire shapes for the M10 staff-operations surface over `/api/v1/admin/…` (PLAN.md
* §1, §6.4). These are consumed only by the staff screens (dashboard, moderation,
* support, content); every DTO ignores unknown keys (NetworkModule's lenient Json)
* so additive backend fields stay safe. Nothing here is auto-provisioned or secret.
*/
/** `GET /admin/dashboard` — the staff landing summary. */
@Serializable
data class AdminDashboardDto(
@SerialName("site_mode") val siteMode: String = "live",
@SerialName("last_change") val lastChange: SiteModeChangeDto = SiteModeChangeDto(),
val counts: AdminCountsDto = AdminCountsDto(),
@SerialName("recent_activity") val recentActivity: List<AdminActivityDto> = emptyList(),
)
@Serializable
data class SiteModeChangeDto(
val at: String? = null,
val by: String? = null,
)
@Serializable
data class AdminCountsDto(
/** Post counts keyed by DB category (`news`, `five_on_friday`, …). */
val posts: Map<String, Int> = emptyMap(),
val users: Int = 0,
)
/** One row of the recent admin-activity log. `detail` is provider-shaped JSON. */
@Serializable
data class AdminActivityDto(
val id: Long = 0,
val username: String? = null,
val action: String = "",
val detail: JsonElement? = null,
@SerialName("created_at") val createdAt: String? = null,
)
/** `PUT /admin/site-mode` request + response. */
@Serializable
data class SiteModeRequest(val mode: String)
@Serializable
data class SiteModeStateDto(
@SerialName("site_mode") val siteMode: String = "live",
@SerialName("changed_at") val changedAt: String? = null,
@SerialName("changed_by") val changedBy: String? = null,
)
// ── Content: news posts ───────────────────────────────────────────────────
/**
* A post row from `GET /admin/posts` (all posts, incl. unpublished — unlike the
* public feed). `published` is a 0/1 flag (MariaDB tinyint), exposed as [isPublished].
*/
@Serializable
data class AdminPostDto(
val id: Long,
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,
val published: Int = 0,
@SerialName("published_at") val publishedAt: String? = null,
@SerialName("created_at") val createdAt: String? = null,
) {
val isPublished: Boolean get() = published != 0
}
/** `POST/PUT /admin/posts` body. `category` is a URL category the backend maps
* (news | five-on-friday | newsletter | screenshots). */
@Serializable
data class PostCreateRequest(
val category: String,
val title: String,
val excerpt: String? = null,
val body: String? = null,
@SerialName("image_url") val imageUrl: String? = null,
val published: Boolean = false,
)
/** `PATCH /admin/posts/:id/publish` body. */
@Serializable
data class PublishRequest(val published: Boolean)
// ── Content: wiki taxonomy ────────────────────────────────────────────────
/** A wiki category from `GET /admin/wiki/categories` (with page counts). */
@Serializable
data class AdminWikiCategoryDto(
val id: Long,
val slug: String = "",
val title: String = "",
val description: String? = null,
@SerialName("sort_order") val sortOrder: Int? = null,
@SerialName("page_count") val pageCount: Int? = null,
@SerialName("published_count") val publishedCount: Int? = null,
)
/** `POST /admin/wiki/categories` body. */
@Serializable
data class WikiCategoryRequest(
val slug: String,
val title: String,
val description: String? = null,
@SerialName("sort_order") val sortOrder: Int? = null,
)
/** A wiki tag from `GET /admin/wiki/tags` (tags derive from pages; read-only here). */
@Serializable
data class AdminWikiTagDto(
val id: Long,
val slug: String = "",
val label: String = "",
@SerialName("published_count") val publishedCount: Int? = null,
)
// ── Moderation (admin/moderator; shard write plane) ───────────────────────
/** `POST /admin/shard/kick` — at least one of account/serial. */
@Serializable
data class KickRequest(val account: String? = null, val serial: String? = null)
/** `POST /admin/shard/ban` — account/serial + optional duration (0/absent = indefinite). */
@Serializable
data class BanRequest(
val account: String? = null,
val serial: String? = null,
@SerialName("durationSec") val durationSec: Long? = null,
val reason: String? = null,
)
/** `POST /admin/shard/unban`. */
@Serializable
data class UnbanRequest(val account: String)
/** `POST /admin/shard/broadcast` — a system message to everyone online. */
@Serializable
data class BroadcastRequest(val text: String, val hue: Int? = null)
// ── Support queue (admin/moderator; help pages) ───────────────────────────
/**
* One open help page from `GET /admin/shard/pages` (INTEGRATION.md §4). `pageId`
* is the sender's in-game serial (the `:id` for respond/close). Permissive — the
* shard-state fields beyond these (coords, timing) are ignored.
*/
@Serializable
data class SupportPageDto(
@SerialName("pageId") val pageId: String = "",
val type: String? = null,
val message: String? = null,
val handled: Boolean? = null,
val handler: String? = null,
val sender: SupportActorDto? = null,
)
/** The page's sender (actor object); [account] present when the character is linked. */
@Serializable
data class SupportActorDto(
val name: String? = null,
val account: String? = null,
)
/** `POST /admin/shard/pages/:id/respond` — reply, optionally closing the page. */
@Serializable
data class PageRespondRequest(val message: String, val close: Boolean = false)

View File

@@ -12,23 +12,12 @@ import kotlinx.serialization.Serializable
* safe (§8, recorded for M1).
*/
/**
* `POST /auth/mobile/login` body (trusted-devices contract, TRUSTED_DEVICES_MFA.md).
* [code] is only sent on the 2FA retry; [recoveryCode] is its single-use fallback
* (sent instead of [code]). [trustDevice] asks the server to remember this device so
* future logins skip the second factor — on success the response carries a
* [MobileTokenResponse.trustToken] the app stores and replays via `X-Trust-Token`.
* [device_name] labels the resulting trusted-device / session row (snake_case to
* match the backend field exactly).
*/
/** `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,
val recoveryCode: String? = null,
val trustDevice: Boolean? = null,
val device_name: String? = null,
)
/** `POST /auth/mobile/refresh` body. */
@@ -45,11 +34,6 @@ data class MobileLogoutRequest(
/**
* 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.
*
* Login additionally carries the trusted-device outcome when `trustDevice` was set:
* [trustToken] is the opaque token to persist + replay (present only when the trust
* was accepted), or [trustLimitReached] + [devices] when the per-user cap blocked it
* (the login itself still succeeded). Refresh never sets these.
*/
@Serializable
data class MobileTokenResponse(
@@ -57,9 +41,6 @@ data class MobileTokenResponse(
val refreshToken: String,
val expiresIn: String? = null,
val user: SafeUserDto,
val trustToken: String? = null,
val trustLimitReached: Boolean = false,
val devices: List<TrustedDeviceDto> = emptyList(),
)
/** The minimal, non-sensitive user the app needs to render + gate the menu (§5). */

View File

@@ -60,15 +60,8 @@ data class NotificationStreamsDto(
* `GET/PUT /auth/me/notifications/subscriptions` — the user's opted-in stream ids.
* PUT replaces the full set; unknown ids are dropped server-side and the stored set
* echoed back.
*
* [streams] intentionally has NO default: this DTO doubles as the PUT body, and the
* backend validator requires the `streams` field (`body('streams').isArray()`).
* kotlinx omits a property equal to its default (encodeDefaults=false), so a default
* of `emptyList()` would drop the field when the user clears their LAST subscription,
* sending `{}` → 400 "Validation failed" (the "can't turn off the last one" bug). With
* no default the empty list always serializes as `{"streams":[]}`. Do not re-add a default.
*/
@Serializable
data class NotificationSubscriptionsDto(
val streams: List<String>,
val streams: List<String> = emptyList(),
)

View File

@@ -14,8 +14,8 @@ import kotlinx.serialization.json.JsonObject
* `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"), the same opaque-key form used on
* the public boards (`ShardDto.ActorDto`/`ChampDto`/`HouseDto`) — never numbers.
* 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 ─────────────────────────────────────────────────────
@@ -83,47 +83,8 @@ data class CharProfileDto(
val titles: TitlesDto? = null,
val guild: GuildRefDto? = null,
val governorOf: List<String> = emptyList(),
/**
* Loyalty / points standings (Protocol 3.0 §7.3). Empty for a character that has
* earned nothing anywhere — the shard omits systems the character has no entry in
* — and empty on a shard whose plugin predates 3.0.
*
* Served **ungated**: a character's own standings are self-service data on
* `/player/shard/char/:serial` and do not depend on the public `leaderboards`
* feature being visible. Don't re-gate them app-side.
*/
val points: List<CharPointsDto> = emptyList(),
)
/**
* One point system a character holds a score in (Protocol 3.0 §7.3).
*
* Three shapes here are counter-intuitive, and all three are what a REAL shard sends
* (`docs/link/v3.md` §7.5 — a fake shard emits whatever the spec says it should):
*
* - **[maxPoints] `0` means UNCAPPED, and is the common case**, not an edge case.
* ServUO's idiom for an uncapped system is `double.MaxValue`, which the plugin
* normalises to `0` because the C# cast is unchecked and yielded `long.MinValue`.
* Nothing may divide by it, and a full-width progress bar for an uncapped score
* would imply a completion that doesn't exist.
* - **[nameString] is usually `null`.** Most systems name themselves with a cliloc
* rather than a literal, so humanising [system] (`QueensLoyalty` → "Queens
* Loyalty") is the PRIMARY display path, not a defensive fallback.
* - **[rank] is absent unless the shard runs `Bridge.cfg PointsProfileRank=true`.**
* Absent and "unranked" are different answers, so it renders only when sent.
*/
@Serializable
data class CharPointsDto(
val system: String? = null,
val nameString: String? = null,
val points: Long? = null,
val maxPoints: Long? = null,
val rank: Int? = null,
) {
/** The cap, or null when the system is uncapped (see [maxPoints]). */
val cap: Long? get() = maxPoints?.takeIf { it > 0 }
}
@Serializable
data class CharStatsDto(
val str: Int? = null,
@@ -173,45 +134,17 @@ data class EquipmentDto(
val itemId: Int? = null,
val hue: Int? = null,
val mods: JsonObject? = null,
/**
* A player-given name — set for the minority of items someone has renamed, null
* for almost everything else. The shard sends the plain `Item.Name` field; it
* never builds a display name (that call is a packet builder, not a field read).
*/
val name: String? = null,
/**
* The item's type name, resolved from its cliloc id **by the website** against
* its own table (`docs/website/CLILOCS.md`). Null on a shard that has no cliloc
* table configured, which is fully supported — the sheet then falls back to the
* layer, exactly as it did before the table existed.
*/
val clilocName: String? = null,
) {
/**
* What to call this item.
*
* A player-given [name] outranks the resolved type name — "Bob's lucky axe" must
* not be relabelled "hatchet" — and the server applies the same precedence, so
* this only re-states it for an item that arrived with both.
*/
val label: String? get() = name ?: clilocName ?: layer
}
)
/**
* 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.
*
* [rewardResolved] is the website's **parallel array** with the numeric entries turned
* into words against its cliloc table — same length and order as [reward], with a null
* where an id resolved to nothing. It is absent entirely when no entry was numeric or
* the shard has no cliloc table, so read it positionally and tolerate it being short.
* See `displayTitles` in the character sheet.
* 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 rewardResolved: List<String?> = emptyList(),
val fameKarma: String? = null,
val skill: String? = null,
)

View File

@@ -5,7 +5,6 @@ package com.runicgateway.app.data.api.dto
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement
/**
* DTOs for the public site/identity endpoints. Shapes mirror the backend
@@ -81,28 +80,4 @@ data class SettingsDto(
val brand: BrandDto = BrandDto(),
/** Push relay config (M7); default (null ntfyUrl) on a backend that predates it. */
val push: PushConfigDto = PushConfigDto(),
/**
* The admin's **resolved** theme tokens — the CSS custom properties the site
* paints, already layered `:root ← preset ← custom` by the server
* (THEMING_AND_NAV.md §3). Absent when no `theme_visual` row exists, which
* means "the shipped defaults" and is the untouched-instance path.
*
* Held as a raw [JsonElement] rather than a `Map<String, String>` on
* purpose: a single unexpected value must not fail the decode of the whole
* settings payload and take `brand` and `push` down with it. It is coerced
* field-by-field by `SiteAppearance.from`.
*
* The raw `theme_visual` / `brand_assets` rows ride along in this same
* response and are deliberately **not** modeled — they are inputs, and
* re-deriving a palette from them would be a second `resolveThemeTokens` in
* Kotlin, guaranteed to drift (§3).
*/
val theme: JsonElement? = null,
/**
* The public nav overrides, as the raw JSON **string** stored in
* `settings.value` (TEXT) — so it is parsed a second time, exactly as the web
* client's `parseJsonSetting` does. Absent when the admin never edited the
* nav.
*/
@SerialName("nav_public") val navPublic: String? = null,
)

View File

@@ -1,367 +0,0 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.data.api.dto
import kotlinx.serialization.Serializable
/**
* DTOs for the four shard-content surfaces Protocol 3.0 added (PLAN.md §9 M11):
* the ruleset, the points leaderboards, the player-vendor marketplace, and the spawn
* atlas. Shapes mirror the website's `public/shard.controller.js` + `public/atlas.
* controller.js` responses; see `docs/link/v3.md` §5§8.
*
* Every field is nullable-with-a-default, which is load-bearing rather than merely
* defensive here: an admin can gate individual fields away per audience rung
* (`ownerName`, `location`, a board's `name`), so a response legitimately arrives
* with them missing and must still decode.
*/
// ── Ruleset (§5) ────────────────────────────────────────────────────────────
/**
* `GET /public/shard/ruleset` — what this shard's world is configured to do.
*
* Every block is optional and omitted when its system is off, so a null block means
* "not applicable here", not "unknown". A `null` BODY (rather than an empty object)
* means the shard has never published a ruleset — distinct from the feature being
* switched off, which is a 404.
*/
@Serializable
data class RulesetDto(
val shard: String? = null,
val expansion: String? = null,
/**
* The public connect address, published only when the operator set one. It is
* also the ruleset's one admin-configurable field, so it can be present for a
* signed-in viewer and absent for an anonymous one.
*/
val connect: String? = null,
/** A flat bag of on/off flags — `cityLoyalty`, `vvv`, `siege`, `chat`, … */
val systems: Map<String, Boolean> = emptyMap(),
val caps: RulesetCapsDto? = null,
val accounts: RulesetAccountsDto? = null,
val housing: RulesetHousingDto? = null,
val vetRewards: RulesetVetRewardsDto? = null,
val vendors: RulesetVendorsDto? = null,
val vvv: RulesetVvvDto? = null,
val store: RulesetStoreDto? = null,
val schedule: RulesetScheduleDto? = null,
val updatedAt: String? = null,
)
/**
* Skill and stat caps.
*
* **[skill] and [totalSkill] are in TENTHS** — 1000 is 100.0 — the way ServUO stores
* them, and the raw number is actively misleading rather than merely unhelpful (a
* "1000 skill cap" reads as a shard with ten times the usual limit). Use [skillCap]
* and [totalSkillCap]. The stat caps below them are plain values.
*/
@Serializable
data class RulesetCapsDto(
val skill: Int? = null,
val totalSkill: Int? = null,
val stat: Int? = null,
val str: Int? = null,
val dex: Int? = null,
val int: Int? = null,
val strMax: Int? = null,
val dexMax: Int? = null,
val intMax: Int? = null,
) {
val skillCap: Double? get() = skill?.let { it / 10.0 }
val totalSkillCap: Double? get() = totalSkill?.let { it / 10.0 }
}
@Serializable
data class RulesetAccountsDto(
val perIp: Int? = null,
val charSlots: Int? = null,
val autoCreate: Boolean? = null,
)
@Serializable
data class RulesetHousingDto(val accountHouseLimit: Int? = null)
@Serializable
data class RulesetVetRewardsDto(
val enabled: Boolean? = null,
val rewardIntervalDays: Int? = null,
)
@Serializable
data class RulesetVendorsDto(
val restockDelayMinutes: Int? = null,
val maxSell: Int? = null,
val economyStockAmount: Int? = null,
)
@Serializable
data class RulesetVvvDto(
val enabled: Boolean? = null,
val startSilver: Int? = null,
val enhancedRules: Boolean? = null,
)
@Serializable
data class RulesetStoreDto(
val enabled: Boolean? = null,
val currencyName: String? = null,
)
@Serializable
data class RulesetScheduleDto(
val autoSaveFrequencyMinutes: Int? = null,
val autoRestartEnabled: Boolean? = null,
val autoRestartHour: Int? = null,
val autoRestartMinute: Int? = null,
)
// ── Leaderboards (§7) ───────────────────────────────────────────────────────
/**
* One point system's board (`GET /public/shard/points`, `/points/:system`).
*
* [maxPoints] `0` means **uncapped** and is the common case, and [nameString] is
* usually null because most systems name themselves with a cliloc — the same two
* traps as [CharPointsDto], documented in full there.
*
* [players] counts players actually *holding* points, not the entry count: ten of the
* shard's systems auto-add a zero-point row for every character ever created, so the
* raw count would report the whole census as one system's participants.
*/
@Serializable
data class PointsBoardDto(
val system: String? = null,
val nameString: String? = null,
val nameNumber: Int? = null,
val maxPoints: Long? = null,
val players: Int? = null,
val showOnGump: Boolean = true,
val top: List<PointsEntryDto> = emptyList(),
val t: Long? = null,
val updatedAt: String? = null,
) {
/** The cap, or null when the system is uncapped. */
val cap: Long? get() = maxPoints?.takeIf { it > 0 }
}
/**
* A ranked character on a board. [name] is admin-configurable (the `leaderboards`
* feature's one field rule), so a shard can publish standings without naming who
* holds them — a rank with no name is a valid row, not a broken one.
*/
@Serializable
data class PointsEntryDto(
val rank: Int? = null,
val serial: String? = null,
val name: String? = null,
val points: Long? = null,
)
// ── Marketplace (§8) ────────────────────────────────────────────────────────
/**
* Where a shop stands. **Nested, not flattened**, on the wire and in the read model
* alike, so that ONE admin rule hides the facet, the coordinates, the region and the
* house together — five flat keys would be five rules that drift apart (`v3.md` §8.8).
* A null location means an admin gated it away; render that as an answer, not a blank.
*/
@Serializable
data class MarketLocationDto(
val map: String? = null,
val x: Int? = null,
val y: Int? = null,
val z: Int? = null,
val region: String? = null,
val house: String? = null,
)
/** The shop a listing belongs to, as embedded in a search result. */
@Serializable
data class MarketVendorRefDto(
val serial: String? = null,
val shopName: String? = null,
val ownerName: String? = null,
val location: MarketLocationDto? = null,
)
/**
* One item for sale. [displayName] is resolved server-side against the site's cliloc
* table, preferring a player-set [name]; a shard with no cliloc table configured sends
* neither and the item renders by id.
*
* [child] marks an item priced by an enclosing container rather than itself, exactly
* as the in-game Vendor Search reports it.
*/
@Serializable
data class MarketListingDto(
val serial: String? = null,
val itemId: Int? = null,
val hue: Int? = null,
val amount: Int? = null,
val price: Long? = null,
val name: String? = null,
val cliloc: Int? = null,
val displayName: String? = null,
val child: Boolean = false,
val vendor: MarketVendorRefDto? = null,
) {
/** What to call this item; null when the shard publishes no name for it. */
val label: String? get() = name ?: displayName
}
/**
* A page of search results (`GET /public/shard/market`).
*
* Returns **listings, not vendors**: "who sells a vanquishing kryss and for how much"
* is the question, and a vendor-shaped result would make every caller flatten the
* shops back out.
*
* [staleAt] is the oldest vendor timestamp in the index and **must be surfaced**. The
* shard sweeps vendors round-robin, so a listing can legitimately be a full cycle old;
* a page implying live prices sends someone to an item that sold twenty minutes ago.
*/
@Serializable
data class MarketPageDto(
val listings: List<MarketListingDto> = emptyList(),
val total: Int = 0,
val limit: Int? = null,
val offset: Int? = null,
val vendors: Int? = null,
val staleAt: String? = null,
)
/**
* One shop and its stock (`GET /public/shard/market/vendors/:serial`).
*
* [truncated] means the shard publishes only the first `MarketMaxListings` of a larger
* inventory — [count] is what is published, [total] what the shop holds. Saying so is
* the point of this screen: a search result list cannot express it.
*/
@Serializable
data class MarketVendorDto(
val serial: String? = null,
val shopName: String? = null,
val ownerSerial: String? = null,
val ownerName: String? = null,
val location: MarketLocationDto? = null,
val count: Int? = null,
val total: Int? = null,
val truncated: Boolean = false,
val updatedAt: String? = null,
val items: List<MarketListingDto> = emptyList(),
)
/** Index size, staleness and the filter options that actually hold vendors. */
@Serializable
data class MarketMetaDto(
val vendors: Int = 0,
val items: Int = 0,
val staleAt: String? = null,
val freshAt: String? = null,
val maps: List<String> = emptyList(),
val regions: List<String> = emptyList(),
)
// ── Spawn atlas (§6) ────────────────────────────────────────────────────────
/**
* A creature in the bestiary. Served from `/public/atlas`, **not** `/public/shard`:
* the atlas is static shard *content* parsed from the server's own data files, not
* live shard *state*, so it does not go offline with the sidecar — but unlike the
* shard routes it IS site-mode gated, like posts and the wiki.
*
* [points] is a **count** of spawners; [spawners] is the list, and only the
* single-creature route sends it. The two names are one letter apart in meaning and
* were deliberately separated (`v3.md` §6.3) — do not reuse one for the other.
*/
@Serializable
data class AtlasCreatureDto(
val slug: String? = null,
val name: String? = null,
/** How many can be alive at once, summed across every spawner. */
val total: Int? = null,
/** How many spawners mention this creature. */
val points: Int? = null,
/** Spawner count per facet. */
val facets: Map<String, Int> = emptyMap(),
/**
* Where it appears, aggregated per named place — the detail route only, and the
* answer the whole screen exists to give. **Objects, not strings:** the server
* sends `{facet, label, spawners, maxAlive}`, and typing this `List<String>`
* made the detail route fail to decode entirely.
*/
val places: List<AtlasPlaceDto> = emptyList(),
/**
* Operator-supplied sprite file name under `/uploads/atlas/`, or null — which is
* the normal state, since no artwork ships. Neither client renders it yet; the
* field is carried so a decode never depends on that staying true.
*/
val art: String? = null,
val spawners: List<AtlasSpawnerDto> = emptyList(),
val spawnersTruncated: Boolean = false,
/** Creatures sharing its spawners — the detail route only. */
val alsoHere: List<AtlasCreatureDto> = emptyList(),
)
/**
* One named place a creature spawns in, already aggregated across its spawners.
*
* [label] is the server's point-in-rect resolution of raw coordinates ("Shrines",
* "Isamu-Jima", "Yew"), falling back to the nearest landmark and finally
* "Wilderness" — turning a list of coordinates into an answer.
*/
@Serializable
data class AtlasPlaceDto(
val facet: String? = null,
val label: String? = null,
/** Spawners in this place. */
val spawners: Int? = null,
/** How many can be alive at once here, summed across those spawners. */
val maxAlive: Int? = null,
)
/**
* One spawn point.
*
* **[minDelay] / [maxDelay] are SECONDS**, normalised by the server's parser.
* XmlSpawner writes them in minutes *except* when a delay doesn't divide into whole
* minutes, flagging that per record — so the raw file has `5` meaning five minutes on
* one spawner and five seconds on the next, both plausible. The API and this client
* carry seconds throughout.
*/
@Serializable
data class AtlasSpawnerDto(
val id: Long? = null,
val facet: String? = null,
val name: String? = null,
val x: Int? = null,
val y: Int? = null,
val maxCount: Int? = null,
val minDelay: Int? = null,
val maxDelay: Int? = null,
val region: String? = null,
val landmark: String? = null,
/** The server's own "Despise, Felucca" style placement label. */
val label: String? = null,
)
/** A page of creature search results (`GET /public/atlas/creatures`). */
@Serializable
data class AtlasCreaturePageDto(
val creatures: List<AtlasCreatureDto> = emptyList(),
val total: Int = 0,
val limit: Int? = null,
val offset: Int? = null,
)
/** When the atlas was last derived from the shard's data files, and what it holds. */
@Serializable
data class AtlasMetaDto(
val importedAt: String? = null,
val generatedAt: String? = null,
val counts: Map<String, Int> = emptyMap(),
val facets: List<String> = emptyList(),
)

View File

@@ -15,43 +15,13 @@ import kotlinx.serialization.json.JsonObject
* `*.update` frames on `/public/shard/stream` decode into these same DTOs.
*/
/**
* Which shard surfaces this caller may reach (`GET /public/shard/features`), plus
* the audience rung they resolved to.
*
* Every shard-derived feature is admin-configurable — it can be switched off or
* raised to a higher rung — so the menu cannot be a static list (PLAN.md §5, M11).
* [level] is the SERVER's answer on the `anonymous → logged_in → player → staff →
* admin` ladder and is authoritative: don't re-derive a rung from the session role,
* since `player` means *a linked game account* and staff always satisfy it.
*
* The response reports only what the caller can see, so the list itself never
* discloses a feature they're gated out of.
*/
@Serializable
data class ShardFeaturesDto(
val level: String? = null,
val features: List<String> = emptyList(),
)
/**
* A game actor (player/leader/governor) as embedded in board payloads. Per the wire
* spec (`docs/link/INTEGRATION.md` §1), in-game [serial]s are opaque hex-string keys
* (e.g. `"0x1A2B"`), never numbers.
*
* [acct] and [webId] are **locked to the admin rung** by the visibility framework
* (`docs/link/v3.md` §3.4 rule 1) — a game account name and a linked site-user id are
* not in-game-visible the way a character name is, so they are stripped from every
* response below `admin` and no admin setting can loosen that. The fields stay
* declared because an admin session does receive them; nothing below one should
* expect a value.
*/
/** A game actor (player/leader/governor) as embedded in board payloads. */
@Serializable
data class ActorDto(
val serial: String? = null,
val serial: Long? = null,
val name: String? = null,
val acct: String? = null,
val webId: String? = null,
val webId: Long? = null,
) {
/** Best display label for this actor. */
val label: String get() = name ?: acct ?: "Someone"
@@ -103,7 +73,7 @@ data class FeedEventDto(
*/
@Serializable
data class OnlineStaffDto(
val serial: String? = null,
val serial: Long? = null,
val name: String? = null,
val map: String? = null,
val x: Int? = null,
@@ -117,7 +87,7 @@ data class OnlineStaffDto(
*/
@Serializable
data class HouseDto(
val serial: String = "",
val serial: Long = 0,
val name: String? = null,
val region: String? = null,
val map: String? = null,
@@ -134,7 +104,7 @@ data class HouseDto(
*/
@Serializable
data class ChampDto(
val serial: String = "",
val serial: Long = 0,
val category: String? = null,
val type: String? = null,
val name: String? = null,

View File

@@ -1,37 +0,0 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.data.appearance
import kotlinx.serialization.SerializationException
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
/**
* Parse a JSON-valued settings row, client side — the second stage of decoding
* `nav_public` (THEMING_AND_NAV.md §3).
*
* The Kotlin counterpart to the web client's `lib/settingsJson.js`, and
* deliberately the same three lines of judgement: `settings.value` is TEXT, so
* the row arrives as a **string inside** the already-decoded settings object,
* and a malformed or wrong-shaped one must read as **absent** — the surface
* falls back to the coded default — never as an error and never as a
* half-applied object.
*/
private val settingsJson = Json { ignoreUnknownKeys = true }
/**
* @param raw the raw stored value, as it arrived in the settings payload
* @return the parsed object, or null when absent/malformed
*/
fun parseJsonSetting(raw: String?): JsonObject? {
if (raw.isNullOrEmpty()) return null
val parsed = try {
settingsJson.parseToJsonElement(raw)
} catch (_: SerializationException) {
return null
}
// Only plain objects. A stored `null`, `4`, `"x"` or array is as unusable to
// every consumer of these keys as a syntax error is.
return parsed as? JsonObject
}

View File

@@ -1,73 +0,0 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.data.appearance
import com.runicgateway.app.data.api.dto.BrandDto
import com.runicgateway.app.data.api.dto.SettingsDto
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
/**
* Everything the app renders itself with that the shard's admin controls
* (THEMING_AND_NAV.md, M12): the brand block, the resolved theme tokens, and the
* public navigation overrides. One value, held once in [com.runicgateway.app.ui.AppViewModel],
* so the theme and the drawer can never disagree about which shard they are showing.
*
* **[NONE] is the shipped app.** An instance with no settings rows, a backend
* that predates the feature, and a settings call that failed outright are all the
* same state here, and all three must render exactly as the app did before this
* milestone existed (§2). That is why nothing on this class is nullable except
* [brand], which was already nullable and whose absence already meant "use the
* bundled strings".
*/
data class SiteAppearance(
/** The per-shard branding block; null when settings couldn't be loaded. */
val brand: BrandDto? = null,
/**
* The resolved CSS custom properties, keyed by token (`"--accent"` → `"#7f99bd"`).
* Empty means "the shipped defaults" — the server never emits an empty map,
* but absent and empty are the same thing to the app and it must not depend
* on that.
*/
val theme: Map<String, String> = emptyMap(),
/**
* The parsed `nav_public` row, or null when the admin never edited the nav.
* Kept as the raw object here; reading `items` / `sections` / `links` out of
* it is the job of the phases that render them.
*/
val navPublic: JsonObject? = null,
) {
companion object {
/** The shipped app: no brand, no overrides. Also what a failed load means. */
val NONE = SiteAppearance()
/**
* Build the appearance from a `GET /public/settings` body. Forgiving
* field by field (§2): a bad `--accent` must not discard a good `--bg`
* beside it, and a malformed `nav_public` must not cost the theme.
*/
fun from(settings: SettingsDto?): SiteAppearance {
if (settings == null) return NONE
return SiteAppearance(
brand = settings.brand,
theme = themeTokens(settings.theme as? JsonObject),
navPublic = parseJsonSetting(settings.navPublic),
)
}
// Every themable token is a string server-side (validated on write, and
// resolveThemeTokens only ever copies a validated value). Anything else
// is dropped rather than coerced, so an unexpected value costs exactly
// its own token and the rest of the palette still applies.
private fun themeTokens(raw: JsonObject?): Map<String, String> {
if (raw.isNullOrEmpty()) return emptyMap()
return buildMap {
for ((token, value) in raw) {
val text = (value as? JsonPrimitive)?.takeIf { it.isString }?.content
if (!text.isNullOrBlank()) put(token, text)
}
}
}
}
}

View File

@@ -10,19 +10,10 @@ 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.RecoveryCodesDto
import com.runicgateway.app.data.api.dto.RecoveryGenerateRequest
import com.runicgateway.app.data.api.dto.RecoveryStatusDto
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.TrustDeviceRequest
import com.runicgateway.app.data.api.dto.TrustedDeviceDto
import com.runicgateway.app.data.api.dto.TrustedDeviceLimitDto
import com.runicgateway.app.data.api.dto.UsernameResponse
import kotlinx.coroutines.CancellationException
import kotlinx.serialization.json.Json
import java.io.IOException
import javax.inject.Inject
import javax.inject.Singleton
@@ -35,7 +26,6 @@ import javax.inject.Singleton
@Singleton
class AccountRepository @Inject constructor(
private val api: MeApi,
private val json: Json,
) {
suspend fun getAccount(): ApiResult<PlayerAccountDto> = safeApiCall { api.getAccount() }
@@ -58,63 +48,4 @@ class AccountRepository @Inject constructor(
suspend fun unlinkIdentity(provider: String): ApiResult<Unit> =
safeApiCall { api.unlinkIdentity(provider) }
// ── Trusted devices (TRUSTED_DEVICES_MFA.md) ───────────────────────────
suspend fun trustedDevices(): ApiResult<List<TrustedDeviceDto>> =
safeApiCall { api.trustedDevices() }
/** The distinct outcomes of trusting the current device — the cap is a first-class case. */
sealed interface TrustOutcome {
/** Trusted; [trustToken] is the opaque token to persist (native). */
data class Trusted(val trustToken: String?) : TrustOutcome
/** At the per-user cap — [devices] must be pruned before retrying. */
data class LimitReached(val devices: List<TrustedDeviceDto>) : TrustOutcome
data object NetworkError : TrustOutcome
data object ServerError : TrustOutcome
}
/**
* Trust the current device. Reads the raw response so the `409 { error, devices }`
* cap body survives (a thrown [retrofit2.HttpException] would discard it).
*/
suspend fun trustThisDevice(deviceName: String? = null): TrustOutcome {
val response = try {
api.trustThisDevice(TrustDeviceRequest(deviceName))
} catch (e: CancellationException) {
throw e
} catch (_: IOException) {
return TrustOutcome.NetworkError
} catch (_: Exception) {
return TrustOutcome.ServerError
}
if (response.isSuccessful) {
return TrustOutcome.Trusted(response.body()?.trustToken)
}
if (response.code() == 409) {
val devices = runCatching {
val raw = response.errorBody()?.string()
if (raw.isNullOrBlank()) emptyList()
else json.decodeFromString<TrustedDeviceLimitDto>(raw).devices
}.getOrDefault(emptyList())
return TrustOutcome.LimitReached(devices)
}
return TrustOutcome.ServerError
}
suspend fun revokeTrustedDevice(id: Long): ApiResult<Boolean> =
safeApiCall { api.revokeTrustedDevice(id).revoked }
suspend fun revokeAllTrustedDevices(): ApiResult<Int> =
safeApiCall { api.revokeAllTrustedDevices().revoked }
// ── Recovery (backup) codes ────────────────────────────────────────────
suspend fun recoveryCodesStatus(): ApiResult<RecoveryStatusDto> =
safeApiCall { api.recoveryCodesStatus() }
/** Regenerate the single-use codes (password step-up). Returned once — never stored. */
suspend fun generateRecoveryCodes(currentPassword: String?): ApiResult<RecoveryCodesDto> =
safeApiCall { api.generateRecoveryCodes(RecoveryGenerateRequest(currentPassword)) }
}

View File

@@ -1,94 +0,0 @@
/*
* 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.AdminApi
import com.runicgateway.app.data.api.dto.AdminDashboardDto
import com.runicgateway.app.data.api.dto.AdminPostDto
import com.runicgateway.app.data.api.dto.BanRequest
import com.runicgateway.app.data.api.dto.BroadcastRequest
import com.runicgateway.app.data.api.dto.KickRequest
import com.runicgateway.app.data.api.dto.PageRespondRequest
import com.runicgateway.app.data.api.dto.PostCreateRequest
import com.runicgateway.app.data.api.dto.PublishRequest
import com.runicgateway.app.data.api.dto.SiteModeRequest
import com.runicgateway.app.data.api.dto.SiteModeStateDto
import com.runicgateway.app.data.api.dto.SupportPageDto
import com.runicgateway.app.data.api.dto.UnbanRequest
import com.runicgateway.app.data.api.dto.AdminWikiCategoryDto
import com.runicgateway.app.data.api.dto.WikiCategoryRequest
import com.runicgateway.app.data.api.dto.AdminWikiTagDto
import retrofit2.HttpException
import retrofit2.Response
import javax.inject.Inject
import javax.inject.Singleton
/**
* The M10 staff-operations data source over `/api/v1/admin/…` (PLAN.md §1, §6.4).
* Every call returns a typed [ApiResult] so a screen renders a clean error/retry
* rather than crashing — a `403` (role lost since the menu rendered) and a `503`
* (shard/sidecar offline for the shard-write actions) are both expected outcomes
* the UI handles, never thrown. Role is authoritative on the server.
*/
@Singleton
class AdminRepository @Inject constructor(
private val api: AdminApi,
) {
suspend fun dashboard(): ApiResult<AdminDashboardDto> = safeApiCall { api.dashboard() }
suspend fun setSiteMode(mode: String): ApiResult<SiteModeStateDto> =
safeApiCall { api.setSiteMode(SiteModeRequest(mode)) }
// ── Content: news posts ───────────────────────────────────────────────
suspend fun posts(): ApiResult<List<AdminPostDto>> = safeApiCall { api.posts() }
suspend fun createPost(body: PostCreateRequest): ApiResult<AdminPostDto> =
safeApiCall { api.createPost(body) }
suspend fun setPostPublished(id: Long, published: Boolean): ApiResult<AdminPostDto> =
safeApiCall { api.publishPost(id, PublishRequest(published)) }
suspend fun deletePost(id: Long): ApiResult<Unit> = safeApiCall { api.deletePost(id).requireOk() }
// ── Content: wiki taxonomy ────────────────────────────────────────────
suspend fun wikiCategories(): ApiResult<List<AdminWikiCategoryDto>> = safeApiCall { api.wikiCategories() }
suspend fun createWikiCategory(body: WikiCategoryRequest): ApiResult<AdminWikiCategoryDto> =
safeApiCall { api.createWikiCategory(body) }
suspend fun deleteWikiCategory(id: Long): ApiResult<Unit> =
safeApiCall { api.deleteWikiCategory(id).requireOk() }
suspend fun wikiTags(): ApiResult<List<AdminWikiTagDto>> = safeApiCall { api.wikiTags() }
// ── Moderation: shard write plane ─────────────────────────────────────
suspend fun kick(account: String?, serial: String?): ApiResult<Unit> =
safeApiCall { api.kick(KickRequest(account, serial)).requireOk() }
suspend fun ban(account: String?, serial: String?, durationSec: Long?, reason: String?): ApiResult<Unit> =
safeApiCall { api.ban(BanRequest(account, serial, durationSec, reason)).requireOk() }
suspend fun unban(account: String): ApiResult<Unit> =
safeApiCall { api.unban(UnbanRequest(account)).requireOk() }
suspend fun broadcast(text: String, hue: Int?): ApiResult<Unit> =
safeApiCall { api.broadcast(BroadcastRequest(text, hue)).requireOk() }
// ── Support queue: help pages ─────────────────────────────────────────
suspend fun supportPages(): ApiResult<List<SupportPageDto>> = safeApiCall { api.supportPages() }
suspend fun respondPage(id: String, message: String, close: Boolean): ApiResult<Unit> =
safeApiCall { api.respondPage(id, PageRespondRequest(message, close)).requireOk() }
suspend fun closePage(id: String): ApiResult<Unit> =
safeApiCall { api.closePage(id).requireOk() }
/** Turn a bodyless [Response] into a thrown [HttpException] on a non-2xx, so
* [safeApiCall] can fold it into an [ApiResult.HttpError] like every other call. */
private fun Response<Unit>.requireOk() {
if (!isSuccessful) throw HttpException(this)
}
}

View File

@@ -3,9 +3,7 @@
*/
package com.runicgateway.app.data.repository
import com.runicgateway.app.core.auth.DeviceNameProvider
import com.runicgateway.app.core.auth.SessionManager
import com.runicgateway.app.core.auth.TrustTokenStore
import com.runicgateway.app.core.push.PushManager
import com.runicgateway.app.data.api.AuthApi
import com.runicgateway.app.data.api.SsoApi
@@ -14,9 +12,7 @@ import com.runicgateway.app.data.api.dto.MobileLogoutRequest
import com.runicgateway.app.data.api.dto.MobileTokenResponse
import com.runicgateway.app.data.api.dto.SsoProviderDto
import com.runicgateway.app.data.api.dto.TotpRequiredError
import com.runicgateway.app.data.api.dto.TrustedDeviceDto
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.delay
import kotlinx.serialization.json.Json
import retrofit2.Response
import java.io.IOException
@@ -35,58 +31,25 @@ class AuthRepository @Inject constructor(
private val ssoApi: SsoApi,
private val sessionManager: SessionManager,
private val pushManager: PushManager,
private val trustTokenStore: TrustTokenStore,
private val deviceNameProvider: DeviceNameProvider,
private val json: Json,
) {
/** The three outcomes of SSO provider discovery, so the login screen can tell a
* shard that offers no SSO ([None]) apart from a discovery that failed
* ([Unavailable], offer a retry) — the old "empty on any failure" conflation hid
* a broken call behind a dead website hand-off (§4.2). */
sealed interface SsoDiscovery {
/** At least one enabled provider — render a native button per entry. */
data class Available(val providers: List<SsoProviderDto>) : SsoDiscovery
/** Discovery succeeded but the shard has no SSO providers configured. */
data object None : SsoDiscovery
/** The discovery call failed (offline / server error) — surface a retry. */
data object Unavailable : SsoDiscovery
}
/**
* Discover the shard's enabled SSO providers for the native login buttons (§4.2).
* Public discovery, never secrets. Retries once before reporting [Unavailable],
* so a single transient blip doesn't strand the user.
* The shard's enabled SSO providers for the native login buttons (§4.2). Public
* discovery, never secrets. Returns an empty list on any failure — the login
* screen then keeps the website hand-off fallback rather than showing nothing.
*/
suspend fun ssoProviders(): SsoDiscovery {
var lastFailed = false
repeat(2) { attempt ->
try {
val providers = ssoApi.providers()
return if (providers.isEmpty()) SsoDiscovery.None else SsoDiscovery.Available(providers)
suspend fun ssoProviders(): List<SsoProviderDto> = try {
ssoApi.providers()
} catch (e: CancellationException) {
throw e
} catch (_: Exception) {
lastFailed = true
if (attempt == 0) delay(DISCOVERY_RETRY_DELAY_MS)
}
}
return if (lastFailed) SsoDiscovery.Unavailable else SsoDiscovery.None
emptyList()
}
/** Outcome of a login attempt (§4.1). */
sealed interface LoginResult {
/**
* Signed in. [trustLimitReached] is true when "trust this device" was asked
* for but the per-user cap blocked it (the login still succeeded, but no trust
* token was issued); [devices] then lists the trusted devices to manage.
*/
data class Success(
val trustLimitReached: Boolean = false,
val devices: List<TrustedDeviceDto> = emptyList(),
) : LoginResult
data object Success : LoginResult
/** The account has 2FA on — reveal the code field and resubmit with a code. */
data object TotpRequired : LoginResult
@@ -102,32 +65,9 @@ class AuthRepository @Inject constructor(
data object NetworkError : LoginResult
}
/**
* Native login (TRUSTED_DEVICES_MFA.md). A stored trust token bound to [username]
* rides the `X-Trust-Token` header so a trusted device skips the TOTP step. A
* second factor is either a [code] (TOTP) or a single-use [recoveryCode]. With
* [trustDevice], the server may return a fresh trust token to persist for next time.
*/
suspend fun login(
username: String,
password: String,
code: String? = null,
recoveryCode: String? = null,
trustDevice: Boolean = false,
): LoginResult {
val storedTrustToken = trustTokenStore.tokenFor(username)
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,
recoveryCode = recoveryCode,
trustDevice = trustDevice.takeIf { it },
device_name = if (trustDevice) deviceNameProvider.deviceName() else null,
),
trustToken = storedTrustToken,
)
authApi.login(MobileLoginRequest(username = username, password = password, code = code))
} catch (e: CancellationException) {
throw e
} catch (_: IOException) {
@@ -136,14 +76,8 @@ class AuthRepository @Inject constructor(
if (response.isSuccessful) {
val body = response.body() ?: return LoginResult.ServerError
// Persist a freshly minted trust token (scoped to this account) so the next
// login skips the second factor — it deliberately outlives logout.
body.trustToken?.let { trustTokenStore.save(username, it) }
sessionManager.onSignedIn(body.accessToken, body.refreshToken, body.user)
return LoginResult.Success(
trustLimitReached = body.trustLimitReached,
devices = body.devices,
)
return LoginResult.Success
}
return when (response.code()) {
@@ -153,20 +87,6 @@ class AuthRepository @Inject constructor(
}
}
/**
* Persist a trust token minted by the self-service "trust this device" action
* (Account → Trusted Devices), scoped to [username] exactly like the login path.
*/
fun saveTrustToken(username: String, token: String) = trustTokenStore.save(username, token)
/**
* Drop the locally stored trust token so this device stops skipping the TOTP step
* (used after "untrust all" and on a Settings → Server switch). Server-side
* revocation makes any surviving token inert anyway — the next login just prompts
* for the code — so this is a client-side cleanliness step, never load-bearing.
*/
fun clearTrustToken() = trustTokenStore.clear()
/**
* 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
@@ -220,8 +140,4 @@ class AuthRepository @Inject constructor(
} catch (_: Exception) {
false
}
private companion object {
const val DISCOVERY_RETRY_DELAY_MS = 400L
}
}

View File

@@ -4,7 +4,6 @@
package com.runicgateway.app.data.repository
import com.runicgateway.app.core.auth.SessionManager
import com.runicgateway.app.core.auth.TrustTokenStore
import com.runicgateway.app.core.net.BaseUrlHolder
import com.runicgateway.app.core.net.ServerUrl
import com.runicgateway.app.core.prefs.ServerPreferences
@@ -27,8 +26,6 @@ class ConnectionRepository @Inject constructor(
private val prefs: ServerPreferences,
private val baseUrlHolder: BaseUrlHolder,
private val sessionManager: SessionManager,
private val trustTokenStore: TrustTokenStore,
private val shardFeaturesRepository: ShardFeaturesRepository,
private val pushManager: com.runicgateway.app.core.push.PushManager,
private val config: com.runicgateway.app.core.AppConfig,
) {
@@ -109,13 +106,6 @@ class ConnectionRepository @Inject constructor(
}
pushManager.setNtfyUrl(null)
sessionManager.onSignedOut()
// The trust token is bound to the old host — drop it so we don't replay it
// against a different shard (it survives a plain logout, but not a host switch).
trustTokenStore.clear()
// Shard visibility is the OLD host's answer. Sign-out alone would not clear it:
// a switch between two signed-out hosts changes no session, so nothing else
// invalidates the cache and the new shard would inherit the old one's menu.
shardFeaturesRepository.invalidate()
prefs.clear()
baseUrlHolder.set(null)
}

View File

@@ -1,111 +0,0 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.data.repository
import com.runicgateway.app.core.result.ApiResult
import com.runicgateway.app.core.result.safeApiCall
import com.runicgateway.app.data.api.PublicApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import javax.inject.Inject
import javax.inject.Singleton
/**
* Which shard surfaces the current viewer may reach, from
* `GET /public/shard/features` (PLAN.md §5, §9 M11).
*
* Every shard-derived feature is admin-configurable — it can be switched off, or its
* audience raised above the caller's rung — so shard navigation can no longer be a
* static list gated on the session role alone. [level] is the server's own answer on
* the `anonymous → logged_in → player → staff → admin` ladder; the app does not
* re-derive it.
*
* **This is presentation only.** The gate is server-side: a disabled feature `404`s
* and an out-of-rung one `403`s whether or not the entry was rendered. That is why an
* unknown answer deliberately **fails open** — see [ShardFeatures] and [canSee].
*/
@Singleton
class ShardFeaturesRepository @Inject constructor(
private val api: PublicApi,
) {
private val _features = MutableStateFlow<ShardFeatures?>(null)
/** The current answer, or `null` while it is unknown (in flight, or the lookup failed). */
val features: StateFlow<ShardFeatures?> = _features.asStateFlow()
// Serializes concurrent refreshes: the shell refreshes on every session change,
// and two overlapping loads would race to publish.
private val mutex = Mutex()
/**
* Re-resolve the visible set. Called on every session change (sign-in, sign-out,
* a role revalidation that actually changed the user), because the answer is
* per-viewer.
*
* A failed lookup clears the cache rather than keeping a stale one: falling back
* to "show everything" is the safe direction here, since the server still gates
* every call.
*/
suspend fun refresh() = mutex.withLock {
_features.value = when (val result = safeApiCall { api.getShardFeatures() }) {
is ApiResult.Ok -> ShardFeatures(
level = result.data.level,
visible = result.data.features.toSet(),
)
// Includes the 404 an older, pre-Protocol-3.0 website returns for this
// route — that site has no visibility framework, so "unknown" is exactly
// the right answer and the menu behaves as it did before M11.
else -> null
}
}
/**
* Drop the cached answer. Called on a Settings → Server switch: the features
* belong to the host that reported them, and a switch between two signed-out
* hosts changes no session, so nothing else would invalidate them.
*/
fun invalidate() {
_features.value = null
}
}
/**
* The resolved visibility answer for one viewer: the rung the server placed them on
* and the shard features they may reach.
*/
data class ShardFeatures(
val level: String?,
val visible: Set<String>,
)
/**
* True when [feature] may be shown — **or when the answer isn't known yet**.
*
* The fail-open default is deliberate and matches the web client (`lib/useShardFeatures.js`):
* the server gates every call regardless, so the cost of guessing wrong is a link that
* briefly `403`s, while the cost of guessing the other way is a navigation drawer that
* flickers its entries in on every cold start.
*/
fun canSee(features: ShardFeatures?, feature: String): Boolean =
features == null || feature in features.visible
/** Feature names as the website's `shardVisibility.js` `FEATURES` map spells them. */
object ShardFeature {
const val STATUS = "status"
const val ACTIVITY = "activity"
const val CHAMPS = "champs"
const val GUILDS = "guilds"
const val GOVERNORS = "governors"
const val HOUSES = "houses"
const val PRESENCE = "presence"
// Added by Protocol 3.0.
const val RULESET = "ruleset"
const val ATLAS = "atlas"
const val LEADERBOARDS = "leaderboards"
const val MARKET = "market"
}

View File

@@ -3,13 +3,11 @@
*/
package com.runicgateway.app.data.repository
import com.runicgateway.app.core.net.ShardStream
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.AtlasCreatureDto
import com.runicgateway.app.data.api.dto.AtlasCreaturePageDto
import com.runicgateway.app.data.api.dto.ChampDto
import com.runicgateway.app.data.api.dto.EconomySampleDto
import com.runicgateway.app.data.api.dto.FeedEventDto
@@ -17,13 +15,8 @@ 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.MarketMetaDto
import com.runicgateway.app.data.api.dto.MarketPageDto
import com.runicgateway.app.data.api.dto.MarketVendorDto
import com.runicgateway.app.data.api.dto.OnlineStaffDto
import com.runicgateway.app.data.api.dto.PointsBoardDto
import com.runicgateway.app.data.api.dto.PresenceDto
import com.runicgateway.app.data.api.dto.RulesetDto
import com.runicgateway.app.data.api.dto.ShardStatusDto
import kotlinx.coroutines.flow.Flow
import kotlinx.serialization.KSerializer
@@ -42,7 +35,7 @@ import javax.inject.Singleton
@Singleton
class ShardRepository @Inject constructor(
private val api: PublicApi,
private val stream: ShardStream,
private val stream: ShardStreamClient,
private val json: Json,
) {
// ── Snapshots ────────────────────────────────────────────────────────
@@ -69,59 +62,6 @@ class ShardRepository @Inject constructor(
suspend fun houses(): ApiResult<List<HouseDto>> = safeApiCall { api.getShardHouses() }
// ── Protocol 3.0 shard content (§9 M11) ──────────────────────────────
//
// All four sit behind `requireFeature`, so a 404/403 here is "this shard doesn't
// publish it" rather than a fault — see `toShardUiState()`.
/** The shard ruleset, or `Ok(null)` when the shard has never published one. */
suspend fun ruleset(): ApiResult<RulesetDto?> = safeApiCall { api.getShardRuleset() }
suspend fun pointsBoards(): ApiResult<List<PointsBoardDto>> = safeApiCall { api.getShardPoints() }
suspend fun pointsBoard(system: String): ApiResult<PointsBoardDto> =
safeApiCall { api.getShardPointsBoard(system) }
suspend fun market(
query: String? = null,
map: String? = null,
region: String? = null,
sort: String = SORT_PRICE_ASC,
limit: Int = MARKET_PAGE,
offset: Int = 0,
): ApiResult<MarketPageDto> = safeApiCall {
api.getShardMarket(
query = query?.takeIf { it.isNotBlank() },
map = map?.takeIf { it.isNotBlank() },
region = region?.takeIf { it.isNotBlank() },
sort = sort,
limit = limit,
offset = offset,
)
}
suspend fun marketMeta(): ApiResult<MarketMetaDto> = safeApiCall { api.getShardMarketMeta() }
suspend fun marketVendor(serial: String): ApiResult<MarketVendorDto> =
safeApiCall { api.getShardMarketVendor(serial) }
suspend fun atlasCreatures(
query: String? = null,
facet: String? = null,
limit: Int = ATLAS_PAGE,
offset: Int = 0,
): ApiResult<AtlasCreaturePageDto> = safeApiCall {
api.getAtlasCreatures(
query = query?.takeIf { it.isNotBlank() },
facet = facet?.takeIf { it.isNotBlank() },
limit = limit,
offset = offset,
)
}
suspend fun atlasCreature(slug: String): ApiResult<AtlasCreatureDto> =
safeApiCall { api.getAtlasCreature(slug) }
// ── Live stream ──────────────────────────────────────────────────────
/** The shared public SSE feed (safe kinds only), reconnecting with backoff (§7). */
fun liveEvents(): Flow<ShardStreamEvent> = stream.events()
@@ -133,27 +73,9 @@ class ShardRepository @Inject constructor(
fun governorFrame(obj: JsonObject): GovernorDto? = decode(obj, GovernorDto.serializer())
fun presenceFrame(obj: JsonObject): PresenceDto? = decode(obj, PresenceDto.serializer())
// Protocol 3.0 frames. `world.ruleset` and `points.board` ride the public stream by
// default; `vendor.listing` does NOT — the market feature ships with its SSE fan-out
// disabled (a live firehose of vendor inventories would be the site's biggest
// bandwidth consumer), so the market screen is a plain paginated read and must never
// wait on a frame.
fun rulesetFrame(obj: JsonObject): RulesetDto? = decode(obj, RulesetDto.serializer())
fun pointsBoardFrame(obj: JsonObject): PointsBoardDto? = decode(obj, PointsBoardDto.serializer())
private fun <T> decode(obj: JsonObject, serializer: KSerializer<T>): T? = try {
json.decodeFromJsonElement(serializer, obj)
} catch (_: Exception) {
null
}
companion object {
const val SORT_PRICE_ASC = "price_asc"
const val SORT_PRICE_DESC = "price_desc"
const val SORT_RECENT = "recent"
/** The server caps `limit` at 100; stay well under it on a phone. */
const val MARKET_PAGE = 50
const val ATLAS_PAGE = 50
}
}

View File

@@ -9,14 +9,11 @@ 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.ShardStream
import com.runicgateway.app.core.net.ShardStreamClient
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.AdminApi
import com.runicgateway.app.data.api.NotificationsApi
import com.runicgateway.app.data.api.PlayerShardApi
import com.runicgateway.app.data.api.PublicApi
@@ -96,12 +93,6 @@ object NetworkModule {
@Singleton
fun providePublicApi(retrofit: Retrofit): PublicApi = retrofit.create(PublicApi::class.java)
/** Expose the live SSE feed as the [ShardStream] capability so repositories depend
* on the interface (unit-testable against a fake), not the OkHttp-backed client. */
@Provides
@Singleton
fun provideShardStream(client: ShardStreamClient): ShardStream = client
@Provides
@Singleton
fun provideAuthApi(retrofit: Retrofit): AuthApi = retrofit.create(AuthApi::class.java)
@@ -128,11 +119,6 @@ object NetworkModule {
fun provideNotificationsApi(retrofit: Retrofit): NotificationsApi =
retrofit.create(NotificationsApi::class.java)
/** Staff operations (§1, §6.4, M10) — bearer-authed; the server re-checks role every call. */
@Provides
@Singleton
fun provideAdminApi(retrofit: Retrofit): AdminApi = retrofit.create(AdminApi::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

View File

@@ -3,21 +3,15 @@
*/
package com.runicgateway.app.di
import com.runicgateway.app.core.auth.BuildDeviceNameProvider
import com.runicgateway.app.core.auth.DeviceNameProvider
import com.runicgateway.app.core.auth.EncryptedTokenStore
import com.runicgateway.app.core.auth.EncryptedTrustTokenStore
import com.runicgateway.app.core.auth.TokenStore
import com.runicgateway.app.core.auth.TrustTokenStore
import com.runicgateway.app.core.auth.sso.EncryptedPendingSsoStore
import com.runicgateway.app.core.auth.sso.PendingSsoStore
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
/** Binds the at-rest stores to their EncryptedSharedPreferences impls (§4.3). */
/** Binds the at-rest token store to its EncryptedSharedPreferences impl (§4.3). */
@Module
@InstallIn(SingletonComponent::class)
abstract class StorageModule {
@@ -25,17 +19,4 @@ abstract class StorageModule {
@Binds
@Singleton
abstract fun bindTokenStore(impl: EncryptedTokenStore): TokenStore
@Binds
@Singleton
abstract fun bindPendingSsoStore(impl: EncryptedPendingSsoStore): PendingSsoStore
/** The trusted-device token store — its own encrypted file, outlives session teardown. */
@Binds
@Singleton
abstract fun bindTrustTokenStore(impl: EncryptedTrustTokenStore): TrustTokenStore
@Binds
@Singleton
abstract fun bindDeviceNameProvider(impl: BuildDeviceNameProvider): DeviceNameProvider
}

View File

@@ -8,7 +8,7 @@ import androidx.lifecycle.viewModelScope
import com.runicgateway.app.core.net.BaseUrlHolder
import com.runicgateway.app.core.push.PushManager
import com.runicgateway.app.core.result.ApiResult
import com.runicgateway.app.data.appearance.SiteAppearance
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
@@ -20,8 +20,8 @@ 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 [SiteAppearance] the theme and
* the drawer are built from. Activity-scoped so the whole app observes one state.
* 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(
@@ -38,11 +38,8 @@ class AppViewModel @Inject constructor(
/** No shard site configured yet — show the connect screen. */
data object NeedsConnection : AppState
/**
* A site is configured. [appearance] is [SiteAppearance.NONE] when settings
* couldn't be loaded — the shipped app, still fully usable (§2).
*/
data class Ready(val appearance: SiteAppearance) : 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)
@@ -51,7 +48,7 @@ class AppViewModel @Inject constructor(
init {
viewModelScope.launch {
_state.value = if (connectionRepository.restore()) {
AppState.Ready(loadAppearance())
AppState.Ready(loadBrand())
} else {
AppState.NeedsConnection
}
@@ -60,30 +57,7 @@ class AppViewModel @Inject constructor(
/** Called by the connect screen once a site has been validated + saved. */
fun onConnected() {
viewModelScope.launch { _state.value = AppState.Ready(loadAppearance()) }
}
/**
* Re-read the appearance while the app is already running — on resume, beside
* the session's own re-validation (§5.5). An admin who re-skins the site from
* a laptop and picks the phone up should see it.
*
* Best-effort, and silent either way: a failed refresh **keeps the last good
* appearance** rather than dropping back to the shipped one, so a moment of
* no connectivity does not repaint a themed shard. There is no loading state
* and no error surface. Ignored unless a site is configured.
*/
fun refreshAppearance() {
if (_state.value !is AppState.Ready) return
viewModelScope.launch {
val settings = (settingsRepository.getSettings() as? ApiResult.Ok)?.data ?: return@launch
pushManager.setNtfyUrl(settings.push.ntfyUrl)
// changeServer() may have raced us back to the connect screen while the
// call was in flight; don't resurrect Ready on top of it.
if (_state.value is AppState.Ready) {
_state.value = AppState.Ready(SiteAppearance.from(settings))
}
}
viewModelScope.launch { _state.value = AppState.Ready(loadBrand()) }
}
/** Settings → Server switch: hard reset back to the connect screen (§3). */
@@ -95,14 +69,14 @@ class AppViewModel @Inject constructor(
}
/**
* Load public settings for the appearance and feed the shard's push relay URL into
* the [PushManager] (§11) — its arrival is what lets push re-register after a restart
* or sign-in. Returns [SiteAppearance.NONE] if settings couldn't be loaded.
* Load public settings for branding and feed the shard's push relay URL into the
* [PushManager] (§11) — its arrival is what lets push re-register after a restart
* or sign-in. Returns the brand block (null if settings couldn't be loaded).
*/
private suspend fun loadAppearance(): SiteAppearance {
private suspend fun loadBrand(): BrandDto? {
val settings = (settingsRepository.getSettings() as? ApiResult.Ok)?.data
pushManager.setNtfyUrl(settings?.push?.ntfyUrl)
return SiteAppearance.from(settings)
return settings?.brand
}
/**

View File

@@ -3,16 +3,11 @@
*/
package com.runicgateway.app.ui
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
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.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.ExitToApp
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material3.DrawerValue
import androidx.compose.material3.ExperimentalMaterial3Api
@@ -23,7 +18,6 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalDrawerSheet
import androidx.compose.material3.ModalNavigationDrawer
import androidx.compose.material3.NavigationDrawerItem
import androidx.compose.material3.NavigationDrawerItemColors
import androidx.compose.material3.NavigationDrawerItemDefaults
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
@@ -35,7 +29,6 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@@ -52,29 +45,17 @@ 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.core.web.WebHandoff
import com.runicgateway.app.data.api.dto.BrandDto
import com.runicgateway.app.data.appearance.SiteAppearance
import com.runicgateway.app.ui.auth.AccountScreen
import com.runicgateway.app.ui.auth.LoginScreen
import com.runicgateway.app.ui.auth.RecoveryCodesScreen
import com.runicgateway.app.ui.auth.TrustedDevicesScreen
import com.runicgateway.app.ui.auth.roleLabelRes
import com.runicgateway.app.ui.components.BrandLogo
import com.runicgateway.app.ui.contact.ContactScreen
import com.runicgateway.app.ui.home.HomeScreen
import com.runicgateway.app.ui.navigation.APP_MENU
import com.runicgateway.app.ui.navigation.NavNode
import com.runicgateway.app.ui.navigation.Routes
import com.runicgateway.app.ui.navigation.buildNavTree
import com.runicgateway.app.ui.navigation.isEntryVisible
import com.runicgateway.app.ui.navigation.pruneNav
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.admin.AdminContentScreen
import com.runicgateway.app.ui.admin.AdminDashboardScreen
import com.runicgateway.app.ui.admin.AdminModerationScreen
import com.runicgateway.app.ui.admin.AdminSupportScreen
import com.runicgateway.app.ui.notifications.NotificationsScreen
import com.runicgateway.app.ui.page.PageScreen
import com.runicgateway.app.ui.player.CharacterSheetScreen
@@ -82,19 +63,12 @@ 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.AtlasCreatureScreen
import com.runicgateway.app.ui.shard.AtlasScreen
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.LeaderboardsScreen
import com.runicgateway.app.ui.shard.MarketScreen
import com.runicgateway.app.ui.shard.MarketVendorScreen
import com.runicgateway.app.ui.shard.RulesScreen
import com.runicgateway.app.ui.shard.ShardBoard
import com.runicgateway.app.ui.shard.ShardScreen
import com.runicgateway.app.ui.theme.LocalShardStructure
import com.runicgateway.app.ui.wiki.WikiPageScreen
import com.runicgateway.app.ui.wiki.WikiScreen
import kotlinx.coroutines.launch
@@ -102,12 +76,8 @@ 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,
// Protocol 3.0 content screens are drawer destinations, so the drawer gesture works
// on them too (M11).
Routes.SHARD_RULES, Routes.SHARD_LEADERBOARDS, Routes.SHARD_MARKET, Routes.ATLAS,
Routes.NOTIFICATIONS,
Routes.PLAYER_CHARACTERS, Routes.PLAYER_VENDORS, Routes.PLAYER_HOUSES,
Routes.ADMIN_DASHBOARD, Routes.ADMIN_CONTENT, Routes.ADMIN_MODERATION, Routes.ADMIN_SUPPORT,
)
/**
@@ -120,21 +90,18 @@ private val TOP_LEVEL_ROUTES = setOf(
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RunicApp(
appearance: SiteAppearance,
brand: BrandDto?,
onChangeServer: () -> Unit,
modifier: Modifier = Modifier,
deepLinkStream: String? = null,
onDeepLinkConsumed: () -> Unit = {},
sessionViewModel: SessionViewModel = hiltViewModel(),
) {
val brand = appearance.brand
val navController = rememberNavController()
val drawerState = rememberDrawerState(DrawerValue.Closed)
val scope = rememberCoroutineScope()
val session by sessionViewModel.session.collectAsStateWithLifecycle()
// What this shard publishes, independently of who the caller is (§5, M11).
val shardFeatures by sessionViewModel.shardFeatures.collectAsStateWithLifecycle()
// Re-validate the cached role each time the app returns to the foreground (§4.3).
LifecycleResumeEffect(Unit) {
@@ -153,37 +120,9 @@ fun RunicApp(
}
val backStackEntry by navController.currentBackStackEntryAsState()
// A destination's route is its NavHost *pattern*, so News reports
// "news?category={category}" (§6.2). Compare on the part before the query.
val currentRoute = backStackEntry?.destination?.route?.substringBefore('?')
val currentRoute = backStackEntry?.destination?.route
val isTopLevel = currentRoute in TOP_LEVEL_ROUTES
// The admin's nav overrides, then the gates — never the other way round. An
// override is presentation only: it may relabel, reorder, group and hide, so
// `pruneNav` still decides what this caller may see and remains the boundary
// (§6.1, AC-3). With no stored row the merge returns APP_MENU itself.
val nav = pruneNav(buildNavTree(APP_MENU, appearance.navPublic)) {
isEntryVisible(it, session, shardFeatures)
}
val context = LocalContext.current
// An added link's path is site-relative; a hand-off needs it absolute against
// the configured base URL, which is exactly what the asset resolver does (§6.3).
val resolveUrl = LocalAssetResolver.current
val openNode: (NavNode) -> Unit = { node ->
scope.launch { drawerState.close() }
when (node) {
is NavNode.Item -> navController.navigateTopLevel(node.entry.route)
// A link the app resolved opens like any other drawer row, detail screen
// or not: one rule, and back-press lands on Home as it does from every
// row. One it could not resolve goes to the browser, absolute against
// the site's base URL (§6.3).
is NavNode.Link -> node.route
?.let { navController.navigateTopLevel(it) }
?: resolveUrl(node.path)?.let { WebHandoff.open(context, it) }
// Section headers aren't clickable — the group is always open (§6.3).
is NavNode.Section -> Unit
}
}
val entries = visibleEntries(APP_MENU, session)
ModalNavigationDrawer(
drawerState = drawerState,
@@ -195,20 +134,7 @@ fun RunicApp(
selectedTextColor = MaterialTheme.colorScheme.onSecondaryContainer,
unselectedTextColor = MaterialTheme.colorScheme.onSurface,
)
// Scroll the drawer: a signed-in session adds Account, Notifications, and
// the player groups, and the full list overflows a phone's drawer height —
// without this the lower entries (Notifications included) are clipped and
// unreachable. See RunicGateway M10.
Column(Modifier.verticalScroll(rememberScrollState())) {
Spacer(Modifier.height(12.dp))
// The instance's logo above its name (§5.6). Decorative — the name
// is the very next line — and absent on an instance that uploaded
// none, in which case the header is exactly what it was before M12.
BrandLogo(
logo = brand?.logo,
height = 32.dp,
modifier = Modifier.padding(start = 24.dp, end = 24.dp, bottom = 4.dp),
)
Text(
text = brand?.name?.takeIf { it.isNotBlank() } ?: stringResource(R.string.app_name),
style = MaterialTheme.typography.titleLarge,
@@ -217,30 +143,17 @@ fun RunicApp(
)
HorizontalDivider()
Spacer(Modifier.height(8.dp))
nav.forEach { node ->
if (node is NavNode.Section) {
// A group the admin created: its label as a header, its rows
// beneath it. Always open — a drawer is already a vertical
// list, so the website's dropdown does not translate (§6.3).
Text(
text = node.label,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(
start = 28.dp,
end = 28.dp,
top = 12.dp,
bottom = 4.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),
)
node.items.forEach { child ->
NavRow(child, currentRoute, drawerItemColors, indented = true) {
openNode(child)
}
}
} else {
NavRow(node, currentRoute, drawerItemColors) { openNode(node) }
}
}
HorizontalDivider(Modifier.padding(vertical = 8.dp))
@@ -264,7 +177,6 @@ fun RunicApp(
}
},
colors = drawerItemColors,
shape = LocalShardStructure.current.pill,
modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding),
)
NavigationDrawerItem(
@@ -275,11 +187,9 @@ fun RunicApp(
onChangeServer()
},
colors = drawerItemColors,
shape = LocalShardStructure.current.pill,
modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding),
)
}
}
},
) {
Scaffold(
@@ -293,24 +203,13 @@ fun RunicApp(
actionIconContentColor = MaterialTheme.colorScheme.onSurface,
),
title = {
val name = brand?.name?.takeIf { it.isNotBlank() }
?: stringResource(R.string.app_name)
// The logo stands in for the title here, so unlike the drawer's
// it is named for a screen reader — and it falls back to the
// text when the instance has no logo or the load fails (§5.6).
BrandLogo(
logo = brand?.logo,
height = 24.dp,
contentDescription = name,
) {
Text(
text = name.uppercase(),
style = MaterialTheme.typography.titleSmall
.copy(letterSpacing = 1.2.sp),
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) {
@@ -341,62 +240,6 @@ fun RunicApp(
}
}
/**
* One drawer row: a coded entry, or an admin's added link (§6.3).
*
* A link that the app can open natively is deliberately indistinguishable from a
* coded row — that is the point of resolving it. One that hands off to the browser
* carries a trailing icon, so leaving the app is never a surprise.
*/
@Composable
private fun NavRow(
node: NavNode,
currentRoute: String?,
colors: NavigationDrawerItemColors,
indented: Boolean = false,
onClick: () -> Unit,
) {
val route = when (node) {
is NavNode.Item -> node.entry.route
is NavNode.Link -> node.route
is NavNode.Section -> null
}
val label = when (node) {
// An admin's label wins over the bundled one, and is the same string in
// every locale — see MenuEntry.label.
is NavNode.Item -> node.entry.label ?: stringResource(node.entry.labelRes)
is NavNode.Link -> node.label
is NavNode.Section -> return
}
val handsOff = node is NavNode.Link && node.route == null
NavigationDrawerItem(
label = { Text(label) },
selected = route != null && currentRoute == route.substringBefore('?'),
onClick = onClick,
badge = if (!handsOff) {
null
} else {
{
Icon(
Icons.AutoMirrored.Filled.ExitToApp,
contentDescription = stringResource(R.string.nav_opens_in_browser),
modifier = Modifier.size(18.dp),
)
}
},
colors = colors,
// Like Card's elevation, NavigationDrawerItem takes its shape as a default
// argument (CircleShape) rather than from the theme, so --radius-pill has to
// be handed to it at every call site or the selected row stays fully round
// while every other radius follows the shard (phase 8's AC-5 walk).
shape = LocalShardStructure.current.pill,
modifier = Modifier
.padding(NavigationDrawerItemDefaults.ItemPadding)
.padding(start = if (indented) 16.dp else 0.dp),
)
}
@Composable
private fun RunicNavHost(
navController: NavHostController,
@@ -414,19 +257,7 @@ private fun RunicNavHost(
composable(Routes.HOME) {
HomeScreen(brand = brand)
}
// The category is optional: navigating to plain Routes.NEWS matches this
// pattern with no argument and opens the default tab, which is every route
// into the screen except an admin's nav override or added link (§6.2).
composable(
route = Routes.NEWS_ROUTE,
arguments = listOf(
navArgument(Routes.Args.CATEGORY) {
type = NavType.StringType
nullable = true
defaultValue = null
},
),
) {
composable(Routes.NEWS) {
NewsScreen(onOpenPost = { category, idOrSlug ->
navController.navigate(Routes.post(category, idOrSlug))
})
@@ -456,30 +287,6 @@ private fun RunicNavHost(
composable(Routes.SHARD_GUILDS) { GuildsScreen() }
composable(Routes.SHARD_GOVERNORS) { GovernorsScreen() }
composable(Routes.SHARD_HOUSES) { HousesScreen() }
// Protocol 3.0 shard content (M11). Each screen self-reports "not published
// here" from its own 404/403, so a deep link to a gated feature still lands on
// an honest answer even though the menu hides the entry.
composable(Routes.SHARD_RULES) { RulesScreen() }
composable(Routes.SHARD_LEADERBOARDS) { LeaderboardsScreen(brand = brand) }
composable(Routes.SHARD_MARKET) {
MarketScreen(onOpenVendor = { serial -> navController.navigate(Routes.marketVendor(serial)) })
}
composable(
route = Routes.SHARD_MARKET_VENDOR,
arguments = listOf(navArgument(Routes.Args.SERIAL) { type = NavType.StringType }),
) { entry ->
MarketVendorScreen(serial = entry.arguments?.getString(Routes.Args.SERIAL).orEmpty())
}
composable(Routes.ATLAS) {
AtlasScreen(onOpenCreature = { slug -> navController.navigate(Routes.atlasCreature(slug)) })
}
composable(
route = Routes.ATLAS_CREATURE,
arguments = listOf(navArgument(Routes.Args.SLUG) { type = NavType.StringType }),
) { entry ->
AtlasCreatureScreen(slug = entry.arguments?.getString(Routes.Args.SLUG).orEmpty())
}
composable(Routes.WIKI) {
WikiScreen(onOpenPage = { slug -> navController.navigate(Routes.wikiPage(slug)) })
}
@@ -499,16 +306,8 @@ private fun RunicNavHost(
ContactScreen()
}
composable(Routes.LOGIN) {
// Leave the login screen as soon as the session is established — whether by
// password or the SSO bridge. Keying off the shared session (not just the
// login VM's local flag) makes this robust to the deep-link/recomposition
// timing of the Custom-Tab return, which the LoginScreen callback alone can miss.
if (session is Session.SignedIn) {
LaunchedEffect(Unit) { navController.popBackStack(Routes.LOGIN, inclusive = true) }
} else {
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.
@@ -518,27 +317,12 @@ private fun RunicNavHost(
roleLabel = stringResource(roleLabelRes(s.user.role)),
onSignOut = onSignOut,
onSignOutEverywhere = onSignOutEverywhere,
onOpenTrustedDevices = { navController.navigate(Routes.ACCOUNT_TRUSTED_DEVICES) },
onOpenRecoveryCodes = { navController.navigate(Routes.ACCOUNT_RECOVERY_CODES) },
)
Session.SignedOut -> LaunchedEffect(Unit) {
navController.navigateTopLevel(Routes.HOME)
}
}
}
composable(Routes.ACCOUNT_TRUSTED_DEVICES) {
// Signed-in only; a drop (sign-out/demotion) sends the user home (§4.3).
when (session) {
is Session.SignedIn -> TrustedDevicesScreen()
Session.SignedOut -> LaunchedEffect(Unit) { navController.navigateTopLevel(Routes.HOME) }
}
}
composable(Routes.ACCOUNT_RECOVERY_CODES) {
when (session) {
is Session.SignedIn -> RecoveryCodesScreen()
Session.SignedOut -> LaunchedEffect(Unit) { navController.navigateTopLevel(Routes.HOME) }
}
}
composable(Routes.NOTIFICATIONS) {
// Signed-in only; a sign-out (or demotion) sends the user home rather than
// leaving stale settings up. The backend gates every call regardless (§5).
@@ -568,24 +352,6 @@ private fun RunicNavHost(
composable(Routes.PLAYER_HOUSES) {
PlayerGate(session, navController) { MyHousesScreen() }
}
// ── Staff operations (§1, §6.4, M10) — reached from the staff menu section.
// The backend re-checks role on every /admin/… call; these gates only mirror
// the menu's visibility so a signed-out/demoted user isn't left on a stale screen.
composable(Routes.ADMIN_DASHBOARD) {
StaffGate(session, navController) {
AdminDashboardScreen(isAdmin = (session as? Session.SignedIn)?.user?.isAdmin == true)
}
}
composable(Routes.ADMIN_CONTENT) {
StaffGate(session, navController) { AdminContentScreen() }
}
composable(Routes.ADMIN_MODERATION) {
StaffGate(session, navController, require = { it.isModerator }) { AdminModerationScreen() }
}
composable(Routes.ADMIN_SUPPORT) {
StaffGate(session, navController, require = { it.isModerator }) { AdminSupportScreen() }
}
}
}
@@ -607,23 +373,6 @@ private fun PlayerGate(
}
}
/**
* The staff-operations analogue of [PlayerGate] (§1, M10): render [content] only for
* a signed-in staff account; a signed-out/demoted session (caught on resume, §4.3) is
* sent home rather than left on a stale admin screen. The backend is the authority —
* every `/admin/…` call re-checks role — so this only mirrors the menu's visibility.
*/
@Composable
private fun StaffGate(
session: Session,
navController: NavHostController,
require: (com.runicgateway.app.core.auth.SessionUser) -> Boolean = { it.isStaff },
content: @Composable () -> Unit,
) {
val ok = (session as? Session.SignedIn)?.user?.let(require) == true
if (ok) content() else 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) {

View File

@@ -34,13 +34,6 @@ enum class ErrorKind {
/** Shard/sidecar down (503) — shard reads only; render as offline (§6.3). */
SHARD_OFFLINE,
/**
* This shard doesn't publish the surface, or doesn't publish it to this viewer
* (M11). Distinct from [NOT_FOUND] and [SHARD_OFFLINE]: the site is up, the shard
* may well be up, and retrying changes nothing — an admin decides this.
*/
FEATURE_UNAVAILABLE,
/** Any other non-2xx server response. */
SERVER,
}
@@ -59,27 +52,3 @@ fun <T> ApiResult<T>.toUiState(): UiState<T> = when (this) {
httpStatus = status,
)
}
/**
* [toUiState] for a **shard-derived** read, where `404` carries a second meaning.
*
* The website's `requireFeature` gate answers `404` when a feature is switched off —
* deliberately, so the response doesn't disclose that the surface exists — and `403`
* when it's on but the caller is below its audience rung (`docs/link/v3.md` §3.6).
* On these routes a `404` therefore almost never means "no such thing"; it means this
* shard doesn't publish it. Rendering "couldn't be found" with a retry button would
* invite the user to retry something an admin controls.
*
* Kept as a separate mapper rather than folded into [toUiState] because both statuses
* mean something else off the shard surface: `404` is a genuinely missing item (a
* deleted post, an unknown wiki slug) and `403` is an ownership or role refusal on a
* player or admin route, which is not an admin's visibility setting.
*/
fun <T> ApiResult<T>.toShardUiState(): UiState<T> = when (this) {
is ApiResult.HttpError -> if (status == 403 || status == 404) {
UiState.Error(ErrorKind.FEATURE_UNAVAILABLE, httpStatus = status)
} else {
toUiState()
}
else -> toUiState()
}

View File

@@ -1,291 +0,0 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.admin
import androidx.compose.foundation.layout.Arrangement
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.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.FilterChip
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Switch
import androidx.compose.material3.Tab
import androidx.compose.material3.TabRow
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
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.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.AdminPostDto
import com.runicgateway.app.data.api.dto.AdminWikiCategoryDto
import com.runicgateway.app.data.api.dto.AdminWikiTagDto
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.ShardCard
import com.runicgateway.app.ui.components.StatusPill
/**
* The staff content screen (PLAN.md §1, M10): news posts and wiki taxonomy, in two
* tabs. Create/publish/delete over the existing `/admin/posts` + `/admin/wiki/…`
* routes; the CMS block/hero editor stays out of scope. Any staff role; the server
* re-checks on every call.
*/
@Composable
fun AdminContentScreen(
modifier: Modifier = Modifier,
viewModel: AdminContentViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
var tab by rememberSaveable { mutableIntStateOf(0) }
var showNewPost by rememberSaveable { mutableStateOf(false) }
var showNewCategory by rememberSaveable { mutableStateOf(false) }
Column(modifier.fillMaxSize()) {
TabRow(selectedTabIndex = tab) {
Tab(selected = tab == 0, onClick = { tab = 0 }, text = { Text(stringResource(R.string.admin_content_tab_posts)) })
Tab(selected = tab == 1, onClick = { tab = 1 }, text = { Text(stringResource(R.string.admin_content_tab_wiki)) })
}
state.feedback?.let {
Text(
text = stringResource(it.messageRes),
style = MaterialTheme.typography.bodySmall,
color = if (it.ok) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.error,
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 6.dp),
)
}
when (tab) {
0 -> PostsTab(
state = state.posts,
busy = state.busy,
onNew = { showNewPost = true },
onToggle = viewModel::togglePublish,
onDelete = viewModel::deletePost,
onRetry = viewModel::loadPosts,
)
else -> WikiTab(
state = state.categories,
tags = state.tags,
busy = state.busy,
onNew = { showNewCategory = true },
onDelete = viewModel::deleteCategory,
onRetry = viewModel::loadWiki,
)
}
}
if (showNewPost) {
NewPostDialog(
categories = viewModel.postCategories,
onDismiss = { showNewPost = false },
onCreate = { cat, title, excerpt, body, published ->
viewModel.createPost(cat, title, excerpt, body, published)
showNewPost = false
},
)
}
if (showNewCategory) {
NewCategoryDialog(
onDismiss = { showNewCategory = false },
onCreate = { slug, title, desc, sort ->
viewModel.createCategory(slug, title, desc, sort)
showNewCategory = false
},
)
}
}
@Composable
private fun PostsTab(
state: UiState<List<AdminPostDto>>,
busy: Boolean,
onNew: () -> Unit,
onToggle: (AdminPostDto) -> Unit,
onDelete: (Long) -> Unit,
onRetry: () -> Unit,
) {
when (state) {
is UiState.Loading -> LoadingView()
is UiState.Error -> ErrorView(state.kind, onRetry = onRetry)
is UiState.Success -> LazyColumn(Modifier.fillMaxSize().padding(16.dp)) {
item {
OutlinedButton(onClick = onNew, enabled = !busy, modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp)) {
Text(stringResource(R.string.admin_content_new_post))
}
}
items(state.data, key = { it.id }) { post ->
ShardCard(Modifier.fillMaxWidth().padding(vertical = 6.dp)) {
Column(Modifier.padding(12.dp)) {
Text(post.title, style = MaterialTheme.typography.bodyLarge)
Spacer(Modifier.height(4.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
StatusPill(
text = if (post.isPublished) stringResource(R.string.admin_content_published)
else stringResource(R.string.admin_content_draft),
tone = if (post.isPublished) PillTone.Success else PillTone.Neutral,
)
Spacer(Modifier.width(8.dp))
Text(post.category, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
Row(Modifier.fillMaxWidth().padding(top = 8.dp), horizontalArrangement = Arrangement.End) {
TextButton(onClick = { onToggle(post) }, enabled = !busy) {
Text(
stringResource(
if (post.isPublished) R.string.admin_content_unpublish else R.string.admin_content_publish,
),
)
}
TextButton(onClick = { onDelete(post.id) }, enabled = !busy) {
Text(stringResource(R.string.admin_content_delete), color = MaterialTheme.colorScheme.error)
}
}
}
}
}
}
}
}
@Composable
private fun WikiTab(
state: UiState<List<AdminWikiCategoryDto>>,
tags: List<AdminWikiTagDto>,
busy: Boolean,
onNew: () -> Unit,
onDelete: (Long) -> Unit,
onRetry: () -> Unit,
) {
when (state) {
is UiState.Loading -> LoadingView()
is UiState.Error -> ErrorView(state.kind, onRetry = onRetry)
is UiState.Success -> LazyColumn(Modifier.fillMaxSize().padding(16.dp)) {
item {
OutlinedButton(onClick = onNew, enabled = !busy, modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp)) {
Text(stringResource(R.string.admin_content_new_category))
}
}
items(state.data, key = { it.id }) { cat ->
ShardCard(Modifier.fillMaxWidth().padding(vertical = 6.dp)) {
Column(Modifier.padding(12.dp)) {
Text(cat.title, style = MaterialTheme.typography.bodyLarge)
Text(
text = stringResource(R.string.admin_content_cat_meta, cat.slug, cat.pageCount ?: 0),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Row(Modifier.fillMaxWidth().padding(top = 8.dp), horizontalArrangement = Arrangement.End) {
TextButton(onClick = { onDelete(cat.id) }, enabled = !busy) {
Text(stringResource(R.string.admin_content_delete), color = MaterialTheme.colorScheme.error)
}
}
}
}
}
if (tags.isNotEmpty()) {
item {
HorizontalDivider(Modifier.padding(vertical = 12.dp))
Text(
stringResource(R.string.admin_content_tags, tags.joinToString(", ") { it.label }),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}
@Composable
private fun NewPostDialog(
categories: List<String>,
onDismiss: () -> Unit,
onCreate: (category: String, title: String, excerpt: String, body: String, published: Boolean) -> Unit,
) {
var category by rememberSaveable { mutableStateOf(categories.first()) }
var title by rememberSaveable { mutableStateOf("") }
var excerpt by rememberSaveable { mutableStateOf("") }
var body by rememberSaveable { mutableStateOf("") }
var published by rememberSaveable { mutableStateOf(false) }
AlertDialog(
onDismissRequest = onDismiss,
confirmButton = {
TextButton(onClick = { onCreate(category, title, excerpt, body, published) }) {
Text(stringResource(R.string.admin_content_create))
}
},
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) } },
title = { Text(stringResource(R.string.admin_content_new_post)) },
text = {
Column {
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
categories.forEach { c ->
FilterChip(selected = category == c, onClick = { category = c }, label = { Text(c) })
}
}
OutlinedTextField(value = title, onValueChange = { title = it }, singleLine = true, label = { Text(stringResource(R.string.admin_content_field_title)) }, modifier = Modifier.fillMaxWidth().padding(top = 8.dp))
OutlinedTextField(value = excerpt, onValueChange = { excerpt = it }, label = { Text(stringResource(R.string.admin_content_field_excerpt)) }, modifier = Modifier.fillMaxWidth().padding(top = 8.dp))
OutlinedTextField(value = body, onValueChange = { body = it }, label = { Text(stringResource(R.string.admin_content_field_body)) }, modifier = Modifier.fillMaxWidth().padding(top = 8.dp))
Row(Modifier.fillMaxWidth().padding(top = 8.dp), verticalAlignment = Alignment.CenterVertically) {
Text(stringResource(R.string.admin_content_publish_now), modifier = Modifier.weight(1f))
Switch(checked = published, onCheckedChange = { published = it })
}
}
},
)
}
@Composable
private fun NewCategoryDialog(
onDismiss: () -> Unit,
onCreate: (slug: String, title: String, description: String, sortOrder: Int?) -> Unit,
) {
var slug by rememberSaveable { mutableStateOf("") }
var title by rememberSaveable { mutableStateOf("") }
var description by rememberSaveable { mutableStateOf("") }
var sort by rememberSaveable { mutableStateOf("") }
AlertDialog(
onDismissRequest = onDismiss,
confirmButton = {
TextButton(onClick = { onCreate(slug, title, description, sort.toIntOrNull()) }) {
Text(stringResource(R.string.admin_content_create))
}
},
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) } },
title = { Text(stringResource(R.string.admin_content_new_category)) },
text = {
Column {
OutlinedTextField(value = slug, onValueChange = { slug = it }, singleLine = true, label = { Text(stringResource(R.string.admin_content_field_slug)) }, modifier = Modifier.fillMaxWidth())
OutlinedTextField(value = title, onValueChange = { title = it }, singleLine = true, label = { Text(stringResource(R.string.admin_content_field_title)) }, modifier = Modifier.fillMaxWidth().padding(top = 8.dp))
OutlinedTextField(value = description, onValueChange = { description = it }, label = { Text(stringResource(R.string.admin_content_field_description)) }, modifier = Modifier.fillMaxWidth().padding(top = 8.dp))
OutlinedTextField(value = sort, onValueChange = { sort = it.filter(Char::isDigit) }, singleLine = true, label = { Text(stringResource(R.string.admin_content_field_sort)) }, modifier = Modifier.fillMaxWidth().padding(top = 8.dp))
}
},
)
}

View File

@@ -1,140 +0,0 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.admin
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.AdminPostDto
import com.runicgateway.app.data.api.dto.PostCreateRequest
import com.runicgateway.app.data.api.dto.AdminWikiCategoryDto
import com.runicgateway.app.data.api.dto.WikiCategoryRequest
import com.runicgateway.app.data.api.dto.AdminWikiTagDto
import com.runicgateway.app.data.repository.AdminRepository
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 staff content screen (PLAN.md §1, M10): news posts (list, create,
* publish/unpublish, delete) and wiki taxonomy (list categories/tags, create/delete
* category). Any staff role reaches these (`staffOnly`); the full CMS block/hero
* editor stays out of scope. Reads go through the typed [AdminRepository] (§7).
*/
@HiltViewModel
class AdminContentViewModel @Inject constructor(
private val admin: AdminRepository,
) : ViewModel() {
/** The valid URL categories the backend maps (posts.model CATEGORY_MAP keys). */
val postCategories = listOf("news", "five-on-friday", "newsletter", "screenshots")
data class Feedback(val ok: Boolean, @param:StringRes val messageRes: Int)
data class State(
val posts: UiState<List<AdminPostDto>> = UiState.Loading,
val categories: UiState<List<AdminWikiCategoryDto>> = UiState.Loading,
val tags: List<AdminWikiTagDto> = emptyList(),
val busy: Boolean = false,
val feedback: Feedback? = null,
)
private val _state = MutableStateFlow(State())
val state: StateFlow<State> = _state.asStateFlow()
init {
loadPosts()
loadWiki()
}
fun clearFeedback() = _state.update { it.copy(feedback = null) }
fun loadPosts() {
_state.update { it.copy(posts = UiState.Loading) }
viewModelScope.launch { _state.update { it.copy(posts = admin.posts().toUiState()) } }
}
fun loadWiki() {
_state.update { it.copy(categories = UiState.Loading) }
viewModelScope.launch {
_state.update { it.copy(categories = admin.wikiCategories().toUiState()) }
when (val tags = admin.wikiTags()) {
is ApiResult.Ok -> _state.update { it.copy(tags = tags.data) }
else -> Unit // tags are secondary; leave the last list on a failure
}
}
}
fun togglePublish(post: AdminPostDto) = mutate(onSuccess = ::loadPosts) {
admin.setPostPublished(post.id, !post.isPublished).asFeedback(R.string.admin_content_post_updated)
}
fun deletePost(id: Long) = mutate(onSuccess = ::loadPosts) {
admin.deletePost(id).asFeedback(R.string.admin_content_post_deleted)
}
fun createPost(category: String, title: String, excerpt: String, body: String, published: Boolean) {
if (title.isBlank()) {
_state.update { it.copy(feedback = Feedback(false, R.string.admin_content_title_required)) }
return
}
mutate(onSuccess = ::loadPosts) {
admin.createPost(
PostCreateRequest(
category = category,
title = title.trim(),
excerpt = excerpt.ifBlank { null },
body = body.ifBlank { null },
published = published,
),
).asFeedback(R.string.admin_content_post_created)
}
}
fun createCategory(slug: String, title: String, description: String, sortOrder: Int?) {
if (slug.isBlank() || title.isBlank()) {
_state.update { it.copy(feedback = Feedback(false, R.string.admin_content_cat_fields_required)) }
return
}
mutate(onSuccess = ::loadWiki) {
admin.createWikiCategory(
WikiCategoryRequest(slug.trim(), title.trim(), description.ifBlank { null }, sortOrder),
).asFeedback(R.string.admin_content_cat_created)
}
}
fun deleteCategory(id: Long) = mutate(onSuccess = ::loadWiki) {
admin.deleteWikiCategory(id).asFeedback(R.string.admin_content_cat_deleted)
}
// ── Shared mutation plumbing ──────────────────────────────────────────
/** Run a write: set busy + clear feedback, then on completion set the feedback
* banner and, only if it succeeded, run [onSuccess] (a targeted reload). */
private fun mutate(onSuccess: () -> Unit = {}, block: suspend () -> Feedback) {
if (_state.value.busy) return
_state.update { it.copy(busy = true, feedback = null) }
viewModelScope.launch {
val feedback = block()
if (feedback.ok) onSuccess()
_state.update { it.copy(busy = false, feedback = feedback) }
}
}
/** Map an [ApiResult] to a [Feedback], with role/permission-aware failure copy. */
private fun ApiResult<*>.asFeedback(@StringRes okRes: Int): Feedback = when (this) {
is ApiResult.Ok -> Feedback(true, okRes)
is ApiResult.HttpError ->
Feedback(false, if (status == 403) R.string.admin_forbidden else R.string.admin_action_failed)
is ApiResult.NetworkError -> Feedback(false, R.string.error_network)
}
}

View File

@@ -1,169 +0,0 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.admin
import androidx.compose.foundation.layout.Arrangement
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.fillMaxWidth
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.Button
import androidx.compose.material3.CircularProgressIndicator
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.AdminDashboardDto
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.SectionLabel
import com.runicgateway.app.ui.components.StatusPill
/**
* The staff dashboard (PLAN.md §1, M10): site mode + a site-mode toggle (admins
* only), summary counts, and recent admin activity. Read-only for moderators/editors;
* only [isAdmin] callers see the maintenance switch, and the server enforces it too.
*/
@Composable
fun AdminDashboardScreen(
isAdmin: Boolean,
modifier: Modifier = Modifier,
viewModel: AdminDashboardViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
when (val ds = state.dashboard) {
is UiState.Loading -> LoadingView(modifier)
is UiState.Error -> ErrorView(ds.kind, onRetry = viewModel::load, modifier = modifier)
is UiState.Success -> DashboardContent(
data = ds.data,
isAdmin = isAdmin,
switching = state.switching,
feedbackRes = state.feedback?.messageRes,
onSetMode = viewModel::setSiteMode,
modifier = modifier,
)
}
}
@Composable
private fun DashboardContent(
data: AdminDashboardDto,
isAdmin: Boolean,
switching: Boolean,
feedbackRes: Int?,
onSetMode: (String) -> Unit,
modifier: Modifier = Modifier,
) {
val live = data.siteMode.equals("live", ignoreCase = true)
Column(
modifier = modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(20.dp),
) {
// ── Site status ──────────────────────────────────────────────
SectionLabel(stringResource(R.string.admin_dashboard_site))
Spacer(Modifier.height(8.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
StatusPill(
text = if (live) stringResource(R.string.admin_site_live) else stringResource(R.string.admin_site_maintenance),
tone = if (live) PillTone.Success else PillTone.Warning,
)
data.lastChange.by?.takeIf { it.isNotBlank() }?.let { by ->
Spacer(Modifier.width(12.dp))
Text(
text = stringResource(R.string.admin_site_changed_by, by),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
if (isAdmin) {
Spacer(Modifier.height(12.dp))
Button(
onClick = { onSetMode(if (live) "maintenance" else "live") },
enabled = !switching,
modifier = Modifier.fillMaxWidth(),
) {
if (switching) {
CircularProgressIndicator(strokeWidth = 2.dp, modifier = Modifier.height(20.dp))
} else {
Text(
stringResource(
if (live) R.string.admin_site_switch_maintenance else R.string.admin_site_switch_live,
),
)
}
}
}
feedbackRes?.let {
Spacer(Modifier.height(8.dp))
Text(
text = stringResource(it),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
// ── Counts ───────────────────────────────────────────────────
Spacer(Modifier.height(24.dp))
SectionLabel(stringResource(R.string.admin_dashboard_counts))
Spacer(Modifier.height(8.dp))
StatRow(stringResource(R.string.admin_count_users), data.counts.users.toString())
val totalPosts = data.counts.posts.values.sum()
StatRow(stringResource(R.string.admin_count_posts), totalPosts.toString())
data.counts.posts.forEach { (category, count) ->
StatRow("· $category", count.toString())
}
// ── Recent activity ──────────────────────────────────────────
if (data.recentActivity.isNotEmpty()) {
Spacer(Modifier.height(24.dp))
SectionLabel(stringResource(R.string.admin_dashboard_recent_activity))
Spacer(Modifier.height(8.dp))
data.recentActivity.forEach { row ->
Column(Modifier.fillMaxWidth().padding(vertical = 6.dp)) {
Text(row.action, style = MaterialTheme.typography.bodyMedium)
val meta = listOfNotNull(row.username, row.createdAt).joinToString(" · ")
if (meta.isNotBlank()) {
Text(
text = meta,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}
}
@Composable
private fun StatRow(label: String, value: String) {
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(label, style = MaterialTheme.typography.bodyMedium)
Text(value, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}

View File

@@ -1,91 +0,0 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.admin
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.AdminDashboardDto
import com.runicgateway.app.data.repository.AdminRepository
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 staff dashboard (PLAN.md §1, M10): summary counts + the site-mode
* toggle. The mode switch is admin-only server-side (`adminOnly`); the screen only
* offers it to admins, but a `403` is still handled cleanly if a moderator reaches
* it. Everything is read through the typed [AdminRepository] (§7).
*/
@HiltViewModel
class AdminDashboardViewModel @Inject constructor(
private val admin: AdminRepository,
) : ViewModel() {
data class Feedback(val ok: Boolean, @param:StringRes val messageRes: Int)
data class State(
val dashboard: UiState<AdminDashboardDto> = UiState.Loading,
/** True while a site-mode switch is in flight (disables the control). */
val switching: 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(dashboard = UiState.Loading) }
viewModelScope.launch {
_state.update { it.copy(dashboard = admin.dashboard().toUiState()) }
}
}
fun clearFeedback() = _state.update { it.copy(feedback = null) }
/** Switch the site between "live" and "maintenance" (admin only). */
fun setSiteMode(mode: String) {
if (_state.value.switching) return
_state.update { it.copy(switching = true, feedback = null) }
viewModelScope.launch {
when (val result = admin.setSiteMode(mode)) {
is ApiResult.Ok -> {
// Reflect the new mode locally, then refresh the full summary.
val current = _state.value.dashboard
if (current is UiState.Success) {
_state.update {
it.copy(dashboard = UiState.Success(current.data.copy(siteMode = result.data.siteMode)))
}
}
_state.update { it.copy(switching = false, feedback = Feedback(true, R.string.admin_site_mode_updated)) }
load()
}
is ApiResult.HttpError ->
_state.update {
it.copy(
switching = false,
feedback = Feedback(
false,
if (result.status == 403) R.string.admin_forbidden else R.string.admin_action_failed,
),
)
}
is ApiResult.NetworkError ->
_state.update { it.copy(switching = false, feedback = Feedback(false, R.string.error_network)) }
}
}
}
}

View File

@@ -1,92 +0,0 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.admin
import androidx.compose.foundation.layout.Arrangement
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.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.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
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.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.ui.components.SectionLabel
/**
* The moderation screen (PLAN.md §1, M10): kick / ban / unban an account and
* broadcast, over `/admin/shard/…` (admin/moderator). A live sidecar is required;
* offline, actions return a clean "shard offline" message. Fields are entered here;
* the [AdminModerationViewModel] performs the guarded action.
*/
@Composable
fun AdminModerationScreen(
modifier: Modifier = Modifier,
viewModel: AdminModerationViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
var account by rememberSaveable { mutableStateOf("") }
var serial by rememberSaveable { mutableStateOf("") }
var reason by rememberSaveable { mutableStateOf("") }
var duration by rememberSaveable { mutableStateOf("") }
var broadcast by rememberSaveable { mutableStateOf("") }
val busy = state.busy
Column(
modifier = modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(20.dp),
) {
state.feedback?.let {
Text(
text = stringResource(it.messageRes),
style = MaterialTheme.typography.bodySmall,
color = if (it.ok) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.error,
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
)
}
// ── Account actions ──────────────────────────────────────────────
SectionLabel(stringResource(R.string.admin_mod_account_action))
OutlinedTextField(value = account, onValueChange = { account = it }, singleLine = true, label = { Text(stringResource(R.string.admin_mod_account)) }, modifier = Modifier.fillMaxWidth().padding(top = 8.dp))
OutlinedTextField(value = serial, onValueChange = { serial = it }, singleLine = true, label = { Text(stringResource(R.string.admin_mod_serial)) }, modifier = Modifier.fillMaxWidth().padding(top = 8.dp))
OutlinedTextField(value = reason, onValueChange = { reason = it }, label = { Text(stringResource(R.string.admin_mod_reason)) }, modifier = Modifier.fillMaxWidth().padding(top = 8.dp))
OutlinedTextField(value = duration, onValueChange = { duration = it.filter(Char::isDigit) }, singleLine = true, label = { Text(stringResource(R.string.admin_mod_duration)) }, modifier = Modifier.fillMaxWidth().padding(top = 8.dp))
Row(Modifier.fillMaxWidth().padding(top = 12.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedButton(onClick = { viewModel.kick(account, serial) }, enabled = !busy, modifier = Modifier.weight(1f)) {
Text(stringResource(R.string.admin_mod_kick))
}
Button(onClick = { viewModel.ban(account, serial, duration.toLongOrNull(), reason) }, enabled = !busy, modifier = Modifier.weight(1f)) {
Text(stringResource(R.string.admin_mod_ban))
}
OutlinedButton(onClick = { viewModel.unban(account) }, enabled = !busy, modifier = Modifier.weight(1f)) {
Text(stringResource(R.string.admin_mod_unban))
}
}
// ── Broadcast ────────────────────────────────────────────────────
Spacer(Modifier.height(24.dp))
SectionLabel(stringResource(R.string.admin_mod_broadcast_section))
OutlinedTextField(value = broadcast, onValueChange = { broadcast = it }, label = { Text(stringResource(R.string.admin_mod_broadcast_text)) }, modifier = Modifier.fillMaxWidth().padding(top = 8.dp))
Button(onClick = { viewModel.broadcast(broadcast, null) }, enabled = !busy, modifier = Modifier.fillMaxWidth().padding(top = 12.dp)) {
Text(stringResource(R.string.admin_mod_broadcast))
}
}
}

View File

@@ -1,89 +0,0 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.admin
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.repository.AdminRepository
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 moderation actions (PLAN.md §1, M10): kick / ban / unban an account
* and broadcast a system message, over the shard write plane (`/admin/shard/…`,
* admin/moderator). These need a live sidecar — when the shard is offline the call
* fails and the screen shows a clean error, never a crash (§7). The form fields live
* in the screen; this VM owns only the busy + feedback state and the actions.
*/
@HiltViewModel
class AdminModerationViewModel @Inject constructor(
private val admin: AdminRepository,
) : ViewModel() {
data class Feedback(val ok: Boolean, @param:StringRes val messageRes: Int)
data class State(val busy: Boolean = false, val feedback: Feedback? = null)
private val _state = MutableStateFlow(State())
val state: StateFlow<State> = _state.asStateFlow()
fun clearFeedback() = _state.update { it.copy(feedback = null) }
fun kick(account: String, serial: String) {
if (account.isBlank() && serial.isBlank()) return badTarget()
run(R.string.admin_mod_kicked) { admin.kick(account.ifBlank { null }, serial.ifBlank { null }) }
}
fun ban(account: String, serial: String, durationSec: Long?, reason: String) {
if (account.isBlank() && serial.isBlank()) return badTarget()
run(R.string.admin_mod_banned) {
admin.ban(account.ifBlank { null }, serial.ifBlank { null }, durationSec, reason.ifBlank { null })
}
}
fun unban(account: String) {
if (account.isBlank()) return badTarget()
run(R.string.admin_mod_unbanned) { admin.unban(account.trim()) }
}
fun broadcast(text: String, hue: Int?) {
if (text.isBlank()) {
_state.update { it.copy(feedback = Feedback(false, R.string.admin_mod_text_required)) }
return
}
run(R.string.admin_mod_broadcasted) { admin.broadcast(text.trim(), hue) }
}
private fun badTarget() {
_state.update { it.copy(feedback = Feedback(false, R.string.admin_mod_target_required)) }
}
private fun run(@StringRes okRes: Int, block: suspend () -> ApiResult<Unit>) {
if (_state.value.busy) return
_state.update { it.copy(busy = true, feedback = null) }
viewModelScope.launch {
val feedback = when (val r = block()) {
is ApiResult.Ok -> Feedback(true, okRes)
is ApiResult.HttpError -> Feedback(
false,
when (r.status) {
403 -> R.string.admin_forbidden
503 -> R.string.admin_mod_shard_offline
else -> R.string.admin_action_failed
},
)
is ApiResult.NetworkError -> Feedback(false, R.string.error_network)
}
_state.update { it.copy(busy = false, feedback = feedback) }
}
}
}

View File

@@ -1,145 +0,0 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.admin
import androidx.compose.foundation.layout.Arrangement
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.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Checkbox
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.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.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.SupportPageDto
import com.runicgateway.app.ui.UiState
import com.runicgateway.app.ui.components.EmptyView
import com.runicgateway.app.ui.components.ErrorView
import com.runicgateway.app.ui.components.LoadingView
import com.runicgateway.app.ui.components.ShardCard
/**
* The support (help-page) queue (PLAN.md §1, M10): open tickets with reply/close,
* over `/admin/shard/pages…` (admin/moderator). Empty when there are no open pages
* (or the shard is offline); every read/write degrades cleanly (§7).
*/
@Composable
fun AdminSupportScreen(
modifier: Modifier = Modifier,
viewModel: AdminSupportViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
var replyTo by remember { mutableStateOf<SupportPageDto?>(null) }
Column(modifier.fillMaxSize()) {
state.feedback?.let {
Text(
text = stringResource(it.messageRes),
style = MaterialTheme.typography.bodySmall,
color = if (it.ok) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.error,
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 6.dp),
)
}
when (val s = state.pages) {
is UiState.Loading -> LoadingView()
is UiState.Error -> ErrorView(s.kind, onRetry = viewModel::load)
is UiState.Success ->
if (s.data.isEmpty()) {
EmptyView(stringResource(R.string.admin_support_empty))
} else {
LazyColumn(Modifier.fillMaxSize().padding(16.dp)) {
items(s.data, key = { it.pageId }) { page ->
SupportPageCard(
page = page,
busy = state.busy,
onReply = { replyTo = page },
onClose = { viewModel.close(page.pageId) },
)
}
}
}
}
}
replyTo?.let { page ->
RespondDialog(
onDismiss = { replyTo = null },
onSend = { message, close ->
viewModel.respond(page.pageId, message, close)
replyTo = null
},
)
}
}
@Composable
private fun SupportPageCard(
page: SupportPageDto,
busy: Boolean,
onReply: () -> Unit,
onClose: () -> Unit,
) {
ShardCard(Modifier.fillMaxWidth().padding(vertical = 6.dp)) {
Column(Modifier.padding(12.dp)) {
val who = page.sender?.name ?: page.sender?.account ?: page.pageId
Text(
text = listOfNotNull(page.type, who).joinToString(" · "),
style = MaterialTheme.typography.bodyLarge,
)
page.message?.takeIf { it.isNotBlank() }?.let {
Spacer(Modifier.height(4.dp))
Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
Row(Modifier.fillMaxWidth().padding(top = 8.dp), horizontalArrangement = Arrangement.End) {
TextButton(onClick = onReply, enabled = !busy) { Text(stringResource(R.string.admin_support_reply)) }
TextButton(onClick = onClose, enabled = !busy) { Text(stringResource(R.string.admin_support_close)) }
}
}
}
}
@Composable
private fun RespondDialog(
onDismiss: () -> Unit,
onSend: (message: String, close: Boolean) -> Unit,
) {
var message by rememberSaveable { mutableStateOf("") }
var alsoClose by rememberSaveable { mutableStateOf(true) }
AlertDialog(
onDismissRequest = onDismiss,
confirmButton = { TextButton(onClick = { onSend(message, alsoClose) }) { Text(stringResource(R.string.admin_support_send)) } },
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) } },
title = { Text(stringResource(R.string.admin_support_reply)) },
text = {
Column {
OutlinedTextField(value = message, onValueChange = { message = it }, label = { Text(stringResource(R.string.admin_support_message)) }, modifier = Modifier.fillMaxWidth())
Row(Modifier.fillMaxWidth().padding(top = 8.dp), verticalAlignment = Alignment.CenterVertically) {
Checkbox(checked = alsoClose, onCheckedChange = { alsoClose = it })
Text(stringResource(R.string.admin_support_close_after))
}
}
},
)
}

View File

@@ -1,87 +0,0 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.admin
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.SupportPageDto
import com.runicgateway.app.data.repository.AdminRepository
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 support (help-page) queue (PLAN.md §1, M10): list open pages, reply
* (optionally closing), and close, over `/admin/shard/pages…` (admin/moderator).
* The list is served from shard state — empty when no tickets (or the shard is
* offline); writes need a live sidecar and fail cleanly otherwise (§7).
*/
@HiltViewModel
class AdminSupportViewModel @Inject constructor(
private val admin: AdminRepository,
) : ViewModel() {
data class Feedback(val ok: Boolean, @param:StringRes val messageRes: Int)
data class State(
val pages: UiState<List<SupportPageDto>> = UiState.Loading,
val busy: Boolean = false,
val feedback: Feedback? = null,
)
private val _state = MutableStateFlow(State())
val state: StateFlow<State> = _state.asStateFlow()
init {
load()
}
fun clearFeedback() = _state.update { it.copy(feedback = null) }
fun load() {
_state.update { it.copy(pages = UiState.Loading) }
viewModelScope.launch { _state.update { it.copy(pages = admin.supportPages().toUiState()) } }
}
fun respond(id: String, message: String, close: Boolean) {
if (message.isBlank()) {
_state.update { it.copy(feedback = Feedback(false, R.string.admin_support_message_required)) }
return
}
mutate(R.string.admin_support_responded) { admin.respondPage(id, message.trim(), close) }
}
fun close(id: String) = mutate(R.string.admin_support_closed) { admin.closePage(id) }
private fun mutate(@StringRes okRes: Int, block: suspend () -> ApiResult<Unit>) {
if (_state.value.busy) return
_state.update { it.copy(busy = true, feedback = null) }
viewModelScope.launch {
val feedback = when (val r = block()) {
is ApiResult.Ok -> Feedback(true, okRes)
is ApiResult.HttpError -> Feedback(
false,
when (r.status) {
403 -> R.string.admin_forbidden
404 -> R.string.admin_support_unknown_page
503 -> R.string.admin_mod_shard_offline
else -> R.string.admin_action_failed
},
)
is ApiResult.NetworkError -> Feedback(false, R.string.error_network)
}
if (feedback.ok) load()
_state.update { it.copy(busy = false, feedback = feedback) }
}
}
}

View File

@@ -17,6 +17,7 @@ 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
@@ -50,7 +51,6 @@ 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.ShardCard
import com.runicgateway.app.ui.components.StatusPill
/**
@@ -65,8 +65,6 @@ fun AccountScreen(
roleLabel: String,
onSignOut: () -> Unit,
onSignOutEverywhere: () -> Unit,
onOpenTrustedDevices: () -> Unit,
onOpenRecoveryCodes: () -> Unit,
modifier: Modifier = Modifier,
viewModel: AccountViewModel = hiltViewModel(),
) {
@@ -80,15 +78,10 @@ fun AccountScreen(
) {
IdentityCard(username = username, roleLabel = roleLabel)
// One-time recovery codes surfaced right after enabling 2FA — save them now.
state.recoveryCodesOnce?.let { codes ->
RecoveryCodesShowOnceCard(codes, onDismiss = viewModel::dismissRecoveryCodes)
}
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, onOpenTrustedDevices, onOpenRecoveryCodes)
is UiState.Success -> AccountSections(account.data, state, viewModel)
}
HorizontalDivider(Modifier.padding(vertical = 20.dp))
@@ -107,7 +100,7 @@ fun AccountScreen(
@Composable
private fun IdentityCard(username: String, roleLabel: String) {
ShardCard(Modifier.fillMaxWidth()) {
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(20.dp)) {
Text(text = username, style = MaterialTheme.typography.titleLarge)
StatusPill(
@@ -124,37 +117,16 @@ private fun AccountSections(
account: PlayerAccountDto,
state: AccountViewModel.State,
viewModel: AccountViewModel,
onOpenTrustedDevices: () -> Unit,
onOpenRecoveryCodes: () -> Unit,
) {
UsernameSection(account, state, viewModel)
PasswordSection(account, state, viewModel)
TwoFactorSection(account, state, viewModel)
SecuritySection(onOpenTrustedDevices, onOpenRecoveryCodes)
IdentitiesSection(state, viewModel)
}
/**
* Links to the dedicated trusted-device and recovery-code screens
* (TRUSTED_DEVICES_MFA.md). Kept simple — the management UX lives on those screens.
*/
@Composable
private fun SecuritySection(onOpenTrustedDevices: () -> Unit, onOpenRecoveryCodes: () -> Unit) {
SectionCard(R.string.account_security_title) {
OutlinedButton(
onClick = onOpenTrustedDevices,
modifier = Modifier.fillMaxWidth().padding(top = 12.dp),
) { Text(stringResource(R.string.account_security_trusted_devices)) }
OutlinedButton(
onClick = onOpenRecoveryCodes,
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
) { Text(stringResource(R.string.account_security_recovery_codes)) }
}
}
@Composable
private fun SectionCard(@StringRes titleRes: Int, content: @Composable () -> Unit) {
ShardCard(Modifier.fillMaxWidth().padding(top = 12.dp)) {
Card(Modifier.fillMaxWidth().padding(top = 12.dp)) {
Column(Modifier.padding(16.dp)) {
Text(stringResource(titleRes), style = MaterialTheme.typography.titleMedium)
content()

View File

@@ -49,8 +49,6 @@ class AccountViewModel @Inject constructor(
val busy: Boolean = false,
/** The pending TOTP enrollment (QR shown) between setup and enable. */
val totpSetup: TotpSetupDto? = null,
/** The single-use recovery codes returned once when 2FA was just enabled. */
val recoveryCodesOnce: List<String>? = null,
val feedback: Feedback? = null,
)
@@ -124,12 +122,9 @@ class AccountViewModel @Inject constructor(
if (_state.value.busy) return
_state.update { it.copy(busy = true, feedback = null) }
viewModelScope.launch {
when (val result = accountRepository.totpEnable(code.trim())) {
when (accountRepository.totpEnable(code.trim())) {
is ApiResult.Ok -> {
// 2FA enable returns the fresh recovery-code batch once — surface it.
_state.update {
it.copy(totpSetup = null, recoveryCodesOnce = result.data.recoveryCodes?.takeIf(List<String>::isNotEmpty))
}
_state.update { it.copy(totpSetup = null) }
finish(Section.TOTP, true, R.string.account_totp_enabled)
reloadAccount()
}
@@ -139,9 +134,6 @@ class AccountViewModel @Inject constructor(
}
}
/** Dismiss the one-time recovery-code batch shown after enabling 2FA. */
fun dismissRecoveryCodes() = _state.update { it.copy(recoveryCodesOnce = null) }
fun disableTotp(code: String) {
if (_state.value.busy) return
_state.update { it.copy(busy = true, feedback = null) }

View File

@@ -5,11 +5,8 @@ package com.runicgateway.app.ui.auth
import androidx.compose.foundation.layout.Arrangement
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.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
@@ -17,22 +14,15 @@ 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.Checkbox
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
@@ -125,24 +115,6 @@ fun LoginScreen(
)
if (state.totpRequired) {
if (state.useRecoveryCode) {
OutlinedTextField(
value = state.recoveryCode,
onValueChange = viewModel::onRecoveryCodeChange,
singleLine = true,
enabled = !state.submitting,
label = { Text(stringResource(R.string.login_recovery_code)) },
supportingText = { Text(stringResource(R.string.login_recovery_hint)) },
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Go,
),
keyboardActions = KeyboardActions(onGo = { viewModel.submit() }),
modifier = Modifier
.fillMaxWidth()
.padding(top = 12.dp),
)
} else {
OutlinedTextField(
value = state.code,
onValueChange = viewModel::onCodeChange,
@@ -161,39 +133,6 @@ fun LoginScreen(
)
}
// Toggle between authenticator code and a single-use recovery code.
TextButton(
onClick = { viewModel.onUseRecoveryCodeChange(!state.useRecoveryCode) },
enabled = !state.submitting,
modifier = Modifier.align(Alignment.Start),
) {
Text(
stringResource(
if (state.useRecoveryCode) R.string.login_use_totp_instead
else R.string.login_use_recovery_instead,
),
)
}
// "Trust this device" → skip the 2FA step on future logins here.
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(top = 4.dp),
) {
Checkbox(
checked = state.trustDevice,
onCheckedChange = viewModel::onTrustDeviceChange,
enabled = !state.submitting,
)
Text(
text = stringResource(R.string.login_trust_device),
style = MaterialTheme.typography.bodyMedium,
)
}
}
state.error?.let { err ->
Text(
text = stringResource(loginErrorRes(err)),
@@ -224,46 +163,27 @@ fun LoginScreen(
}
}
// ── Native SSO (§4.2, M9): a single "Sign in with SSO" button that opens the
// Custom-Tab bridge. With one provider it launches straight through; with
// several it presents a native picker (below). No website-login fallback —
// that page can't deep-link the session back; a failed discovery offers a retry.
var showSsoPicker by remember { mutableStateOf(false) }
when {
state.ssoProviders.isNotEmpty() -> {
// ── Native SSO (§4.2, M9): a button per enabled provider that opens the
// Custom-Tab bridge and returns the user signed in. Falls back to the
// website login hand-off when the shard exposes no providers.
if (state.ssoProviders.isNotEmpty()) {
state.ssoProviders.forEach { provider ->
OutlinedButton(
onClick = {
val providers = state.ssoProviders
if (providers.size == 1) viewModel.onSsoProviderClick(providers.first())
else showSsoPicker = true
},
onClick = { viewModel.onSsoProviderClick(provider) },
enabled = !state.submitting,
modifier = Modifier
.fillMaxWidth()
.padding(top = 12.dp),
) {
Text(stringResource(R.string.login_sso_button))
Text(stringResource(R.string.login_sso_provider, provider.name))
}
}
state.ssoDiscovering -> {
Text(
text = stringResource(R.string.login_sso_loading),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 12.dp),
)
}
state.ssoUnavailable -> {
TextButton(
onClick = { viewModel.discoverSsoProviders() },
modifier = Modifier.padding(top = 4.dp),
) {
Text(stringResource(R.string.login_sso_retry))
} else {
viewModel.ssoLoginUrl?.let { url ->
TextButton(onClick = { WebHandoff.open(context, url) }) {
Text(stringResource(R.string.login_sso))
}
}
// else: discovery succeeded with no providers — this shard offers no SSO.
}
// ── Website hand-offs (§4.2): open the site's own pages in a Custom Tab ──
@@ -278,53 +198,6 @@ fun LoginScreen(
Text(stringResource(R.string.login_forgot))
}
}
if (showSsoPicker) {
SsoProviderPicker(
providers = state.ssoProviders,
onDismiss = { showSsoPicker = false },
onPick = { provider ->
showSsoPicker = false
viewModel.onSsoProviderClick(provider)
},
)
}
}
}
/**
* The native provider picker (§4.2): a bottom sheet listing the shard's enabled SSO
* providers so a single "Sign in with SSO" button can serve several IdPs without a
* website chooser page. Each row opens the Custom-Tab bridge for that provider.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun SsoProviderPicker(
providers: List<com.runicgateway.app.data.api.dto.SsoProviderDto>,
onDismiss: () -> Unit,
onPick: (com.runicgateway.app.data.api.dto.SsoProviderDto) -> Unit,
) {
ModalBottomSheet(onDismissRequest = onDismiss, sheetState = rememberModalBottomSheetState()) {
Text(
text = stringResource(R.string.login_sso_pick_title),
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp),
)
providers.forEach { provider ->
TextButton(
onClick = { onPick(provider) },
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 2.dp),
) {
Text(
text = stringResource(R.string.login_sso_provider, provider.name),
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Start,
)
}
}
Spacer(Modifier.height(24.dp)) // clears the gesture inset at the sheet's bottom
}
}

View File

@@ -10,7 +10,6 @@ import com.runicgateway.app.core.web.WebsiteUrls
import com.runicgateway.app.data.api.dto.SsoProviderDto
import com.runicgateway.app.data.repository.AuthRepository
import com.runicgateway.app.data.repository.AuthRepository.LoginResult
import com.runicgateway.app.data.repository.AuthRepository.SsoDiscovery
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -39,23 +38,13 @@ class LoginViewModel @Inject constructor(
val username: String = "",
val password: String = "",
val code: String = "",
/** A single-use recovery code, entered instead of [code] when [useRecoveryCode]. */
val recoveryCode: String = "",
/** True once the account is known to have 2FA on — reveal the code field. */
val totpRequired: Boolean = false,
/** "Enter a recovery code instead" — swap the TOTP field for the recovery field. */
val useRecoveryCode: Boolean = false,
/** "Trust this device" — skip the 2FA step on future logins (TRUSTED_DEVICES_MFA.md). */
val trustDevice: Boolean = false,
val submitting: Boolean = false,
val error: LoginError? = null,
val signedIn: Boolean = false,
/** The shard's enabled SSO providers (§4.2); empty until discovery resolves. */
/** The shard's enabled SSO providers (§4.2); empty → website hand-off fallback. */
val ssoProviders: List<SsoProviderDto> = emptyList(),
/** True while discovery is in flight — the screen shows a spinner, not an empty gap. */
val ssoDiscovering: Boolean = true,
/** True when discovery failed (offline/server) — offer a retry rather than a dead end. */
val ssoUnavailable: Boolean = false,
/** A `/auth/mobile/sso/start` URL the screen should open in a Custom Tab, once. */
val ssoLaunchUrl: String? = null,
)
@@ -64,7 +53,11 @@ class LoginViewModel @Inject constructor(
val state: StateFlow<UiState> = _state.asStateFlow()
init {
discoverSsoProviders()
// Discover the native SSO providers to render buttons for (§4.2).
viewModelScope.launch {
val providers = authRepository.ssoProviders()
if (providers.isNotEmpty()) _state.update { it.copy(ssoProviders = providers) }
}
// Consume the SSO bridge outcome: a returned callback completes here even if
// this ViewModel was recreated while the Custom Tab was foreground (§4.2).
viewModelScope.launch {
@@ -89,43 +82,11 @@ class LoginViewModel @Inject constructor(
fun onCodeChange(value: String) =
_state.update { it.copy(code = value.filter(Char::isDigit).take(8), error = null) }
/** Recovery codes are alphanumeric; keep it permissive, just trim length + noise. */
fun onRecoveryCodeChange(value: String) =
_state.update { it.copy(recoveryCode = value.filterNot(Char::isWhitespace).take(32), error = null) }
fun onTrustDeviceChange(value: Boolean) = _state.update { it.copy(trustDevice = value) }
/** Toggle between the TOTP field and the recovery-code field on the 2FA step. */
fun onUseRecoveryCodeChange(value: Boolean) =
_state.update { it.copy(useRecoveryCode = value, error = null) }
val registerUrl: String? get() = websiteUrls.register()
val forgotPasswordUrl: String? get() = websiteUrls.forgotPassword()
/**
* Discover the shard's native SSO providers (§4.2). A failure surfaces a retry
* affordance instead of the old dead website-login hand-off, which was never
* mobile-formatted and could not deep-link the session back.
*/
fun discoverSsoProviders() {
_state.update { it.copy(ssoDiscovering = true, ssoUnavailable = false) }
viewModelScope.launch {
when (val result = authRepository.ssoProviders()) {
is SsoDiscovery.Available ->
_state.update {
it.copy(ssoProviders = result.providers, ssoDiscovering = false, ssoUnavailable = false)
}
SsoDiscovery.None ->
_state.update {
it.copy(ssoProviders = emptyList(), ssoDiscovering = false, ssoUnavailable = false)
}
SsoDiscovery.Unavailable ->
_state.update {
it.copy(ssoProviders = emptyList(), ssoDiscovering = false, ssoUnavailable = true)
}
}
}
}
/** Website login hand-off — the fallback when native SSO discovery is empty (§4.2). */
val ssoLoginUrl: String? get() = websiteUrls.login()
/**
* Begin a native SSO flow for [provider]: mint PKCE + state and surface the
@@ -153,55 +114,30 @@ class LoginViewModel @Inject constructor(
fun submit() {
val s = _state.value
if (s.submitting) return
val validationError = validateForSubmit(s)
if (validationError != null) {
_state.update { it.copy(error = validationError) }
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 {
// Only one second factor is sent; the recovery toggle picks which.
val code = s.code.trim().takeIf { it.isNotBlank() && !s.useRecoveryCode }
val recoveryCode = s.recoveryCode.trim().takeIf { it.isNotBlank() && s.useRecoveryCode }
val result = authRepository.login(
username = s.username.trim(),
password = s.password,
code = code,
recoveryCode = recoveryCode,
trustDevice = s.trustDevice,
)
applyLoginResult(result)
}
}
/** Pre-flight form checks for [submit]; returns the error to surface, or null if ready to send. */
private fun validateForSubmit(s: UiState): LoginError? {
if (s.username.isBlank() || s.password.isBlank()) return LoginError.INVALID_CREDENTIALS
// If 2FA is being requested, the chosen second factor must accompany the resubmit.
if (s.totpRequired) {
val factor = if (s.useRecoveryCode) s.recoveryCode else s.code
if (factor.isBlank()) return LoginError.BAD_CODE
}
return null
}
/** Folds a [LoginResult] back into the UI state (clears [UiState.submitting] on every path). */
private fun applyLoginResult(result: LoginResult) = when (result) {
is LoginResult.Success ->
// The trusted-device cap (result.trustLimitReached) is an edge case:
// login succeeded but the device wasn't remembered. It's surfaced +
// managed on the Trusted Devices screen rather than blocking sign-in.
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 2FA fields; a wrong code/recovery code re-lands here as BAD_CODE.
// Reveal the code field; a wrong code re-lands here as BAD_CODE.
_state.update {
val hadFactor = if (it.useRecoveryCode) it.recoveryCode.isNotBlank() else it.code.isNotBlank()
it.copy(
submitting = false,
totpRequired = true,
error = if (hadFactor) LoginError.BAD_CODE else null,
error = if (it.code.isNotBlank()) LoginError.BAD_CODE else null,
)
}
@@ -218,3 +154,5 @@ class LoginViewModel @Inject constructor(
_state.update { it.copy(submitting = false, error = LoginError.NETWORK) }
}
}
}
}

View File

@@ -1,153 +0,0 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.auth
import android.content.Intent
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.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
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.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
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.UiState
import com.runicgateway.app.ui.components.ShardCard
/**
* Account → Recovery Codes (TRUSTED_DEVICES_MFA.md): shows the remaining count and a
* password-stepped regenerate that reveals a fresh single-use batch **once**. The
* codes are shown only in memory — copy or share them before leaving; they are never
* stored on the device.
*/
@Composable
fun RecoveryCodesScreen(
modifier: Modifier = Modifier,
viewModel: RecoveryCodesViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
var currentPassword by rememberSaveable { mutableStateOf("") }
Column(
modifier = modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(16.dp),
) {
Text(stringResource(R.string.recovery_codes_title), style = MaterialTheme.typography.titleLarge)
Text(
stringResource(R.string.recovery_codes_subtitle),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp),
)
val remainingText = when (val r = state.remaining) {
is UiState.Success -> stringResource(R.string.recovery_codes_remaining, r.data)
is UiState.Error -> stringResource(R.string.recovery_codes_remaining_unknown)
UiState.Loading -> stringResource(R.string.recovery_codes_remaining_loading)
}
Text(remainingText, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.padding(top = 16.dp))
state.freshCodes?.let { codes ->
RecoveryCodesShowOnceCard(codes, onDismiss = { viewModel.dismissFreshCodes(); currentPassword = "" })
}
OutlinedTextField(
value = currentPassword,
onValueChange = { currentPassword = it },
singleLine = true,
enabled = !state.busy,
label = { Text(stringResource(R.string.account_password_current)) },
supportingText = { Text(stringResource(R.string.recovery_codes_password_hint)) },
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
modifier = Modifier.fillMaxWidth().padding(top = 20.dp),
)
state.error?.let { err ->
Text(
text = stringResource(err),
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(top = 12.dp),
)
}
Button(
onClick = { viewModel.regenerate(currentPassword) },
enabled = !state.busy,
modifier = Modifier.fillMaxWidth().padding(top = 16.dp),
) { Text(stringResource(R.string.recovery_codes_regenerate)) }
}
}
/**
* A show-once display of a freshly generated recovery-code batch, with copy/share and
* a dismiss. Shared by this screen and the "2FA just enabled" surface on AccountScreen.
*/
@Composable
fun RecoveryCodesShowOnceCard(codes: List<String>, onDismiss: () -> Unit) {
val context = LocalContext.current
val clipboard = LocalClipboardManager.current
val joined = remember(codes) { codes.joinToString("\n") }
ShardCard(Modifier.fillMaxWidth().padding(top = 16.dp)) {
Column(Modifier.padding(16.dp)) {
Text(stringResource(R.string.recovery_codes_new_title), style = MaterialTheme.typography.titleMedium)
Text(
stringResource(R.string.recovery_codes_new_hint),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp),
)
codes.forEach { code ->
Text(
code,
style = MaterialTheme.typography.bodyLarge.copy(fontFamily = FontFamily.Monospace),
modifier = Modifier.padding(top = 8.dp),
)
}
Row(Modifier.fillMaxWidth().padding(top = 16.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedButton(
onClick = { clipboard.setText(AnnotatedString(joined)) },
) { Text(stringResource(R.string.recovery_codes_copy)) }
OutlinedButton(
onClick = {
val send = Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(Intent.EXTRA_TEXT, joined)
}
context.startActivity(Intent.createChooser(send, null))
},
) { Text(stringResource(R.string.recovery_codes_share)) }
Button(onClick = onDismiss) { Text(stringResource(R.string.recovery_codes_done)) }
}
}
}
}

View File

@@ -1,84 +0,0 @@
/*
* 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.repository.AccountRepository
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 Account → Recovery Codes (TRUSTED_DEVICES_MFA.md): the remaining-count
* status and a password-stepped regenerate that surfaces a fresh single-use batch
* **once** (never persisted). The freshly generated codes live only in memory until
* the user leaves the screen or dismisses them.
*/
@HiltViewModel
class RecoveryCodesViewModel @Inject constructor(
private val accountRepository: AccountRepository,
) : ViewModel() {
data class State(
/** Remaining unused codes (the status endpoint). */
val remaining: UiState<Int> = UiState.Loading,
/** A just-generated batch to show once, or null. Cleared on dismiss/leave. */
val freshCodes: List<String>? = null,
val busy: Boolean = false,
@param:StringRes val error: Int? = null,
)
private val _state = MutableStateFlow(State())
val state: StateFlow<State> = _state.asStateFlow()
init {
load()
}
fun load() {
_state.update { it.copy(remaining = UiState.Loading) }
viewModelScope.launch {
_state.update { it.copy(remaining = accountRepository.recoveryCodesStatus().toUiState().map { s -> s.remaining }) }
}
}
/** Regenerate the codes; [currentPassword] is required for accounts that have one. */
fun regenerate(currentPassword: String?) {
if (_state.value.busy) return
_state.update { it.copy(busy = true, error = null, freshCodes = null) }
viewModelScope.launch {
when (val result = accountRepository.generateRecoveryCodes(currentPassword?.takeIf { it.isNotBlank() })) {
is ApiResult.Ok -> {
_state.update { it.copy(busy = false, freshCodes = result.data.recoveryCodes) }
// Refresh the remaining count to reflect the new batch.
_state.update { it.copy(remaining = accountRepository.recoveryCodesStatus().toUiState().map { s -> s.remaining }) }
}
is ApiResult.HttpError ->
_state.update { it.copy(busy = false, error = R.string.recovery_codes_error) }
is ApiResult.NetworkError ->
_state.update { it.copy(busy = false, error = R.string.error_network) }
}
}
}
/** Drop the shown-once batch from memory (user saved them / navigated away). */
fun dismissFreshCodes() = _state.update { it.copy(freshCodes = null) }
}
/** Map an [UiState] success value (local helper mirroring ApiResult.map). */
private inline fun <T, R> UiState<T>.map(transform: (T) -> R): UiState<R> = when (this) {
is UiState.Success -> UiState.Success(transform(data))
is UiState.Loading -> UiState.Loading
is UiState.Error -> this
}

View File

@@ -1,136 +0,0 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.auth
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.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.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.TrustedDeviceDto
import com.runicgateway.app.ui.UiState
import com.runicgateway.app.ui.components.ErrorView
import com.runicgateway.app.ui.components.LoadingView
import com.runicgateway.app.ui.components.ShardCard
/**
* Account → Trusted Devices (TRUSTED_DEVICES_MFA.md): the devices allowed to skip
* the TOTP step at login. Trust the current device, revoke one, or untrust all. The
* server re-checks ownership on every call; this screen just renders the outcomes.
*/
@Composable
fun TrustedDevicesScreen(
modifier: Modifier = Modifier,
viewModel: TrustedDevicesViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
Column(
modifier = modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(16.dp),
) {
Text(
stringResource(R.string.trusted_devices_title),
style = MaterialTheme.typography.titleLarge,
)
Text(
stringResource(R.string.trusted_devices_subtitle),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp),
)
state.feedback?.let { fb ->
Text(
text = stringResource(fb.messageRes),
color = if (fb.ok) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(top = 12.dp),
)
}
when (val devices = state.devices) {
is UiState.Loading -> LoadingView(Modifier.padding(top = 32.dp))
is UiState.Error -> ErrorView(devices.kind, onRetry = viewModel::load, modifier = Modifier.padding(top = 32.dp))
is UiState.Success -> {
if (devices.data.isEmpty()) {
Text(
stringResource(R.string.trusted_devices_empty),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 24.dp),
)
} else {
devices.data.forEach { device ->
TrustedDeviceRow(device, state.busy, onRevoke = { viewModel.revoke(device.id) })
}
}
HorizontalDivider(Modifier.padding(vertical = 20.dp))
Button(
onClick = viewModel::trustThisDevice,
enabled = !state.busy,
modifier = Modifier.fillMaxWidth(),
) { Text(stringResource(R.string.trusted_devices_trust_this)) }
if (devices.data.isNotEmpty()) {
OutlinedButton(
onClick = viewModel::revokeAll,
enabled = !state.busy,
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
) { Text(stringResource(R.string.trusted_devices_untrust_all)) }
}
}
}
}
}
@Composable
private fun TrustedDeviceRow(device: TrustedDeviceDto, busy: Boolean, onRevoke: () -> Unit) {
ShardCard(Modifier.fillMaxWidth().padding(top = 12.dp)) {
Row(
Modifier.fillMaxWidth().padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(Modifier.weight(1f)) {
Text(
text = device.deviceName?.takeIf { it.isNotBlank() }
?: device.platform?.replaceFirstChar { it.uppercase() }
?: stringResource(R.string.trusted_devices_unknown),
style = MaterialTheme.typography.bodyLarge,
)
device.lastUsedAt?.let {
Text(
stringResource(R.string.trusted_devices_last_used, it),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
TextButton(onClick = onRevoke, enabled = !busy) {
Text(stringResource(R.string.trusted_devices_revoke))
}
}
}
}

View File

@@ -1,125 +0,0 @@
/*
* 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.auth.DeviceNameProvider
import com.runicgateway.app.core.result.ApiResult
import com.runicgateway.app.data.api.dto.TrustedDeviceDto
import com.runicgateway.app.data.repository.AccountRepository
import com.runicgateway.app.data.repository.AccountRepository.TrustOutcome
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 Trusted Devices screen (TRUSTED_DEVICES_MFA.md): list the devices
* allowed to skip the TOTP step, trust the current one (persisting the returned
* token via [AuthRepository]), revoke one, and untrust all. The trust action folds
* the `409` cap into a first-class [Feedback] telling the user to revoke one first.
*/
@HiltViewModel
class TrustedDevicesViewModel @Inject constructor(
private val accountRepository: AccountRepository,
private val authRepository: AuthRepository,
private val sessionManager: com.runicgateway.app.core.auth.SessionManager,
private val deviceNameProvider: DeviceNameProvider,
) : ViewModel() {
/** A one-shot result banner shown above the list. */
data class Feedback(val ok: Boolean, @param:StringRes val messageRes: Int)
data class State(
val devices: UiState<List<TrustedDeviceDto>> = UiState.Loading,
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(devices = UiState.Loading) }
viewModelScope.launch {
_state.update { it.copy(devices = accountRepository.trustedDevices().toUiState()) }
}
}
/** Trust the current device; persist the returned token so future logins skip 2FA. */
fun trustThisDevice() {
if (_state.value.busy) return
_state.update { it.copy(busy = true, feedback = null) }
viewModelScope.launch {
when (val outcome = accountRepository.trustThisDevice(deviceNameProvider.deviceName())) {
is TrustOutcome.Trusted -> {
// Bind the fresh token to the signed-in username (mirrors the login path).
val username = sessionManager.state.value.let {
(it as? com.runicgateway.app.core.auth.Session.SignedIn)?.user?.username
}
if (outcome.trustToken != null && username != null) {
authRepository.saveTrustToken(username, outcome.trustToken)
}
finish(true, R.string.trusted_devices_trusted)
reload()
}
is TrustOutcome.LimitReached -> finish(false, R.string.trusted_devices_limit)
TrustOutcome.NetworkError -> finish(false, R.string.error_network)
TrustOutcome.ServerError -> finish(false, R.string.trusted_devices_error)
}
}
}
fun revoke(id: Long) {
if (_state.value.busy) return
_state.update { it.copy(busy = true, feedback = null) }
viewModelScope.launch {
when (accountRepository.revokeTrustedDevice(id)) {
is ApiResult.Ok -> {
finish(true, R.string.trusted_devices_revoked)
reload()
}
else -> finish(false, R.string.trusted_devices_error)
}
}
}
fun revokeAll() {
if (_state.value.busy) return
_state.update { it.copy(busy = true, feedback = null) }
viewModelScope.launch {
when (accountRepository.revokeAllTrustedDevices()) {
is ApiResult.Ok -> {
// Every device is untrusted now, including this one — drop the local token.
authRepository.clearTrustToken()
finish(true, R.string.trusted_devices_revoked_all)
reload()
}
else -> finish(false, R.string.trusted_devices_error)
}
}
}
fun clearFeedback() = _state.update { it.copy(feedback = null) }
private suspend fun reload() {
_state.update { it.copy(devices = accountRepository.trustedDevices().toUiState()) }
}
private fun finish(ok: Boolean, @StringRes messageRes: Int) =
_state.update { it.copy(busy = false, feedback = Feedback(ok, messageRes)) }
}

View File

@@ -1,157 +0,0 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.components
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.widthIn
import androidx.compose.material3.MaterialTheme
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.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import com.runicgateway.app.ui.LocalAssetResolver
/**
* The two brand assets an instance can upload — the logo and the hero
* (THEMING_AND_NAV.md §5.6, M12 phase 4). Both have ridden in `BrandDto` since
* M1 and neither has ever been drawn; the app has always spelled the instance
* out in text wherever the website shows a mark.
*
* **The rule that governs this whole file: an empty slot renders nothing.** Not
* a placeholder, not a reserved gap, not the app's own emblem — an instance
* that has uploaded no logo must lay out exactly as it did before this phase
* existed, which is §2 applied to assets. The website's `BrandLogo.jsx` opens
* with the same `if (!brand.logo) return null`.
*
* **A failed load is an empty slot.** No broken-image icon and no retry: an
* asset that 404s, or that can't be reached because the shard is down, must
* degrade to the same layout as an instance that never uploaded one. That is
* why nothing here reserves its space up front — every size modifier hangs off
* the image itself, so when the image isn't composed neither is its padding.
* A caller that wants space *below* a hero passes it as `Modifier.padding`
* rather than a sibling `Spacer`, and gets both cases right for free.
*/
/**
* Widest a logo may draw, as a multiple of its height. Mirrors the website's
* `maxWidth: height * 6` — an operator who uploads a long wordmark gets it
* scaled down rather than pushing the drawer header or the top bar's title out
* of shape.
*/
private const val LOGO_MAX_ASPECT = 6f
/** The Home hero's band height (§5.6, phase 4). See [BrandHero] for why it's fixed. */
private val HERO_HEIGHT = 180.dp
/**
* The instance's uploaded logo at [height], or [fallback] when there is none.
*
* [fallback] defaults to drawing nothing, which is what the drawer header wants:
* the instance name sits directly below it, so an instance with no logo simply
* has the name where it has always been. The top bar passes the name itself,
* because there the logo *replaces* the title — leaving that blank on a failed
* load would strand the app in an unnamed shell until the next resume refresh,
* and "a failed load is an empty slot" means the slot falls back to whatever
* empty would have shown, which for the top bar is the text.
*
* There is deliberately no fallback while the load is still in flight. Drawing
* the text first would flash text → logo on every navigation for the sake of
* one frame, since Coil serves the second and later reads from its memory cache.
*
* Pass [contentDescription] only where the logo stands alone. Beside or above
* the name in text it is decorative, and describing it would have a screen
* reader say the instance's name twice — the same call the website's `alt=''`
* makes.
*/
@Composable
fun BrandLogo(
logo: String?,
height: Dp,
modifier: Modifier = Modifier,
contentDescription: String? = null,
fallback: @Composable () -> Unit = {},
) {
val url = brandAssetUrl(logo, LocalAssetResolver.current)
// Keyed on the url so a refreshed appearance that swaps the logo (§5.5) gets
// a fresh attempt rather than inheriting the old one's failure.
var failed by remember(url) { mutableStateOf(false) }
if (url == null || failed) {
fallback()
return
}
AsyncImage(
model = url,
contentDescription = contentDescription,
contentScale = ContentScale.Fit,
onError = { failed = true },
modifier = modifier
.height(height)
.widthIn(max = height * LOGO_MAX_ASPECT),
)
}
/**
* The instance's hero image as a full-width band above Home's title block, or
* nothing when there is none.
*
* **Fixed height and cropped**, rather than the intrinsic aspect ratio the app's
* other images (`PostScreen`, `BlockRenderer`) draw at. The website's hero is a
* CSS background driven by `hero_layout`, which the app does not port, so the
* app needs its own rule — and the website's *default* hero is a square emblem,
* so an uploaded square is a case to expect rather than an edge one. At the
* intrinsic aspect that square would be a ~360dp block that pushes the status
* card off the first screenful; cropped to a band, a wide banner and a square
* both give the same frame above the title.
*
* Clipped to `shapes.medium`, so the hero follows the shard's `--radius-card`
* like every other surface the admin can round off (§5.2).
*
* Decorative: Home spells the instance's name and tagline out in text directly
* below, so the hero carries no content description.
*/
@Composable
fun BrandHero(hero: String?, modifier: Modifier = Modifier) {
val url = brandAssetUrl(hero, LocalAssetResolver.current)
var failed by remember(url) { mutableStateOf(false) }
if (url == null || failed) return
AsyncImage(
model = url,
contentDescription = null,
contentScale = ContentScale.Crop,
onError = { failed = true },
modifier = modifier
.fillMaxWidth()
.height(HERO_HEIGHT)
.clip(MaterialTheme.shapes.medium),
)
}
/**
* Resolve a brand asset slot to a loadable URL, or null when the slot is empty.
*
* The blank check has to happen on **both** sides of [resolve]: `BrandDto`
* defaults every asset field to `""` rather than null (the server publishes the
* empty string for "not set"), and a resolver given a path it cannot make
* absolute may hand one straight back. Null out of here is the signal for "draw
* nothing", so a blank slipping through would put a zero-size image request in
* the layout instead of no image at all.
*
* Pulled out of the composables purely so it can be tested: the app has no
* Robolectric, so a composable body cannot run in a JVM unit test, but this rule
* is the whole of §5.6's "renders nothing when unset" and it is worth pinning.
*/
internal fun brandAssetUrl(path: String?, resolve: (String?) -> String?): String? =
path?.takeIf { it.isNotBlank() }
?.let(resolve)
?.takeIf { it.isNotBlank() }

View File

@@ -36,10 +36,6 @@ fun LoadingView(modifier: Modifier = Modifier) {
/**
* 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.
*
* [ErrorKind.FEATURE_UNAVAILABLE] renders **without** the button: an admin decides
* whether the shard publishes that surface, so retrying cannot change the answer and
* offering it would read as a transient failure the user could wait out (M11).
*/
@Composable
fun ErrorView(
@@ -57,7 +53,6 @@ fun ErrorView(
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center,
)
if (isRetryable(kind)) {
Button(
onClick = onRetry,
modifier = Modifier.padding(top = 16.dp).width(160.dp),
@@ -66,10 +61,6 @@ fun ErrorView(
}
}
}
}
/** Whether retrying this failure could plausibly succeed. Pure, so it is unit-tested. */
fun isRetryable(kind: ErrorKind): Boolean = kind != ErrorKind.FEATURE_UNAVAILABLE
/** Centered informational message for an empty list (§7). */
@Composable
@@ -92,6 +83,5 @@ private fun errorMessageRes(kind: ErrorKind): Int = when (kind) {
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.FEATURE_UNAVAILABLE -> R.string.error_feature_unavailable
ErrorKind.SERVER -> R.string.error_server
}

View File

@@ -14,22 +14,24 @@ 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.Card
import androidx.compose.material3.CardDefaults
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.draw.shadow
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.runicgateway.app.ui.theme.LocalShardPalette
import com.runicgateway.app.ui.theme.LocalShardStructure
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
@@ -41,14 +43,6 @@ import com.runicgateway.app.ui.theme.ShardWarningBg
* (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.
*
* This is the app's **only** file that reaches past `MaterialTheme` for a
* themable value, so it is the one place M12 had to migrate: the surface, line
* and accent tokens come from [LocalShardPalette] and the pill shape and card
* depth from [LocalShardStructure], both following the shard's theme
* (THEMING_AND_NAV.md §5.1, §5.2, §5.4). The success/warning/danger constants
* stay imported directly — those are semantic and never themed, mirroring the
* server's `FIXED_TOKENS`.
*/
/** Semantic tone for a [StatusPill] / [OnlineDot]. */
@@ -56,26 +50,21 @@ enum class PillTone { Success, Warning, Danger, Neutral, Info }
private data class PillColors(val fg: Color, val bg: Color)
@Composable
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 ->
LocalShardPalette.current.let { PillColors(it.pillFg, it.pillBg) }
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.
*
* The one place `--radius-pill` lands: the app's other two [CircleShape] uses are
* 8dp status dots, and a dot stays a dot however square the shard makes its site.
*/
@Composable
fun StatusPill(text: String, tone: PillTone, modifier: Modifier = Modifier) {
val c = toneColors(tone)
Surface(color = c.bg, shape = LocalShardStructure.current.pill, modifier = modifier) {
Surface(color = c.bg, shape = CircleShape, modifier = modifier) {
Text(
text = text.uppercase(),
style = MaterialTheme.typography.labelSmall,
@@ -92,7 +81,7 @@ fun OnlineDot(tone: PillTone, modifier: Modifier = Modifier) {
PillTone.Success -> ShardSuccessDot
PillTone.Warning -> ShardWarning
PillTone.Danger -> ShardDanger
PillTone.Neutral, PillTone.Info -> LocalShardPalette.current.faint
PillTone.Neutral, PillTone.Info -> ShardFaint
}
Box(modifier.size(8.dp).clip(CircleShape).background(color))
}
@@ -106,7 +95,7 @@ fun SectionLabel(text: String, modifier: Modifier = Modifier) {
Text(
text = text.uppercase(),
style = MaterialTheme.typography.labelSmall,
color = LocalShardPalette.current.faint,
color = ShardFaint,
modifier = modifier,
)
}
@@ -115,11 +104,6 @@ fun SectionLabel(text: String, modifier: 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].
*
* The radius is `MaterialTheme.shapes.medium` rather than the literal 12dp it was
* built with — the same value, now following `--radius-card`'s ratio (§5.2). The
* shadow this doc always claimed is finally drawn, at the depth `--shadow-card`
* resolves to (§5.4).
*/
@Composable
fun FeatureCard(
@@ -127,40 +111,17 @@ fun FeatureCard(
contentPadding: Int = 18,
content: @Composable ColumnScope.() -> Unit,
) {
val palette = LocalShardPalette.current
val shape = MaterialTheme.shapes.medium
Box(
modifier = modifier
.fillMaxWidth()
.shadow(LocalShardStructure.current.cardElevation, shape)
.clip(shape)
.background(Brush.verticalGradient(listOf(palette.cardTop, palette.cardBottom)))
.border(1.dp, palette.outline, shape),
.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 Material [Card] at the shard's resolved depth — the app's standard card, and
* the reason every screen's `Card(` became a `ShardCard(`.
*
* `Card` takes its elevation as a **default argument**, not from the theme, so
* unlike the color scheme and the shape scale there is no way to make
* `--shadow-card` reach ~24 call sites without a wrapper. Passing
* [CardDefaults.cardElevation] at each site instead would have put the same line
* in eighteen files and let one drift. A `Card(` outside this file is therefore a
* card the shard cannot theme, which makes the invariant greppable.
*/
@Composable
fun ShardCard(modifier: Modifier = Modifier, content: @Composable ColumnScope.() -> Unit) {
Card(
modifier = modifier,
elevation = CardDefaults.cardElevation(defaultElevation = LocalShardStructure.current.cardElevation),
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.
@@ -168,14 +129,13 @@ fun ShardCard(modifier: Modifier = Modifier, content: @Composable ColumnScope.()
@Composable
fun StatBar(fraction: Float, modifier: Modifier = Modifier) {
val pct = fraction.coerceIn(0f, 1f)
val palette = LocalShardPalette.current
Box(
modifier = modifier
.fillMaxWidth()
.height(6.dp)
.clip(RoundedCornerShape(3.dp))
.background(palette.elevated)
.border(1.dp, palette.outline, RoundedCornerShape(3.dp)),
.background(ShardElevated)
.border(1.dp, ShardOutline, RoundedCornerShape(3.dp)),
) {
Box(
Modifier

View File

@@ -26,7 +26,6 @@ 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.BrandHero
import com.runicgateway.app.ui.components.ErrorView
import com.runicgateway.app.ui.components.FeatureCard
import com.runicgateway.app.ui.components.LoadingView
@@ -60,12 +59,6 @@ private fun HomeContent(brand: BrandDto?, status: StatusDto, modifier: Modifier
.verticalScroll(rememberScrollState())
.padding(20.dp),
) {
// The instance's hero above the title block (§5.6) — Home is the one screen
// with a hero-shaped space. Its bottom gap rides on the image's own modifier
// rather than a Spacer, so an instance with no hero (or one whose hero fails
// to load) opens on the title exactly where it has always been.
BrandHero(hero = brand?.hero, modifier = Modifier.padding(bottom = 16.dp))
Text(
text = brand?.name?.takeIf { it.isNotBlank() } ?: stringResource(R.string.app_name),
style = MaterialTheme.typography.headlineMedium,

View File

@@ -6,9 +6,6 @@ package com.runicgateway.app.ui.navigation
import androidx.annotation.StringRes
import com.runicgateway.app.R
import com.runicgateway.app.core.auth.Session
import com.runicgateway.app.data.repository.ShardFeature
import com.runicgateway.app.data.repository.ShardFeatures
import com.runicgateway.app.data.repository.canSee
/**
* One shared, declarative, access-level navigation definition (PLAN.md §5): a
@@ -24,43 +21,14 @@ enum class MenuAccess {
/** Visible to any signed-in account (§5, "My Account"). */
SIGNED_IN,
/**
* The linked game-data groups (§6.3). Visible to any player **or** staff:
* staff are a superset of players (all player abilities plus their staff
* tools), and the backend's player self-service surface is role-agnostic, so
* a signed-in admin/editor/moderator sees + uses their own characters too.
*/
/** Visible only to a player — the linked game-data groups (§6.3). */
PLAYER,
/** Visible to any staff role (admin/editor/moderator) — the M10 staff surface (§1). */
STAFF,
/** Visible to admin/moderator — moderation actions + the support queue (§1, M10). */
MODERATOR,
}
data class MenuEntry(
val route: String,
@param:StringRes val labelRes: Int,
val access: MenuAccess = MenuAccess.PUBLIC,
/**
* For a shard-derived surface, the visibility feature it belongs to (M11).
*
* Session role is not the only gate on these: an admin can switch a feature off
* or raise its audience above the caller's rung, so the entry is filtered by
* `GET /public/shard/features` as well as by [access]. `null` means the entry
* isn't shard-derived and only [access] applies.
*/
val feature: String? = null,
/**
* An admin's own label for this row, from the shard's `nav_public` override
* (THEMING_AND_NAV.md §6). Null — always, as coded — means [labelRes] stands.
*
* A label set this way is **not localized**: it is one string for every locale,
* which is what an admin typing a label means, and it matches the website. It
* only ever arrives via [applyNavOverrides]; nothing in [APP_MENU] sets it.
*/
val label: String? = null,
)
/**
@@ -72,13 +40,7 @@ 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, feature = ShardFeature.STATUS),
// Protocol 3.0 shard content (M11). Each hides when the shard doesn't publish it,
// which for a brand-new install is every one of them until the plugin has swept.
MenuEntry(Routes.SHARD_RULES, R.string.menu_rules, feature = ShardFeature.RULESET),
MenuEntry(Routes.ATLAS, R.string.menu_atlas, feature = ShardFeature.ATLAS),
MenuEntry(Routes.SHARD_LEADERBOARDS, R.string.menu_leaderboards, feature = ShardFeature.LEADERBOARDS),
MenuEntry(Routes.SHARD_MARKET, R.string.menu_market, feature = ShardFeature.MARKET),
MenuEntry(Routes.SHARD, 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),
@@ -86,48 +48,17 @@ val APP_MENU: List<MenuEntry> = listOf(
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),
// Staff operations (§1, M10) — revealed for staff roles; the backend re-checks every call.
MenuEntry(Routes.ADMIN_DASHBOARD, R.string.menu_admin_dashboard, MenuAccess.STAFF),
MenuEntry(Routes.ADMIN_CONTENT, R.string.menu_admin_content, MenuAccess.STAFF),
MenuEntry(Routes.ADMIN_MODERATION, R.string.menu_admin_moderation, MenuAccess.MODERATOR),
MenuEntry(Routes.ADMIN_SUPPORT, R.string.menu_admin_support, MenuAccess.MODERATOR),
)
/**
* The entries the given [session] may see, given the shard [features] it may reach.
* Pure + side-effect-free so the gating is unit-tested without Compose.
*
* Two independent filters, and both must pass:
*
* - [MenuEntry.access] against the session — who the caller is.
* - [MenuEntry.feature] against the shard's live visibility config — what this shard
* publishes at all (M11). `null` [features] means the answer isn't known yet and
* every shard entry shows; see [canSee] for why that direction is deliberate.
* 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,
features: ShardFeatures? = null,
): List<MenuEntry> = entries.filter { isEntryVisible(it, session, features) }
/**
* [visibleEntries] for a single entry — the same two filters, and the same
* boundary. Split out because the drawer is a tree once an admin groups rows into
* sections (§6.3): [pruneNav] applies this predicate inside a section as well, and
* both callers must ask exactly one question or a sectioned row could be gated by
* a rule its top-level twin is not.
*/
fun isEntryVisible(
entry: MenuEntry,
session: Session,
features: ShardFeatures? = null,
): Boolean {
val allowedByRole = when (entry.access) {
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 || session.user.isStaff)
MenuAccess.STAFF -> session is Session.SignedIn && session.user.isStaff
MenuAccess.MODERATOR -> session is Session.SignedIn && session.user.isModerator
MenuAccess.PLAYER -> session is Session.SignedIn && session.user.isPlayer
}
return allowedByRole && (entry.feature == null || canSee(features, entry.feature))
}

View File

@@ -1,171 +0,0 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.navigation
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.booleanOrNull
import kotlinx.serialization.json.doubleOrNull
/**
* Apply the admin's stored public-nav overrides to the app's coded menu
* (THEMING_AND_NAV.md §6). The Kotlin counterpart of the website's
* `client/src/lib/navOverrides.js`, narrowed to what a drawer can express.
*
* **This is presentation, never authorization.** An override carries `label`,
* `order` and `hidden` and nothing else: it cannot introduce a route, cannot
* touch [MenuEntry.access] or [MenuEntry.feature], and cannot un-hide anything —
* `hidden: false` is simply the absence of hiding. [visibleEntries] therefore runs
* **after** this merge, unchanged, and remains the actual boundary (§6.1, AC-3).
*
* Fail-safe throughout, matching the web: anything unrecognized — an unknown path,
* a non-string label, a path the app doesn't surface in its menu — is ignored
* rather than rejected, so a stale or hand-edited settings row degrades to the
* coded menu instead of rendering a broken drawer.
*/
/** A usable override for one menu row. Absent fields mean "as coded". */
internal data class NavOverride(
val label: String? = null,
val order: Double? = null,
val hidden: Boolean = false,
/**
* The id of the section this row was dropped into, or null for a top-level
* row. Read here but honored only by the tree build (`NavTree.kt`) — the flat
* [applyNavOverrides] has nowhere to put it. Not validated against the stored
* sections here; that is the tree's job, since only it knows them.
*/
val section: String? = null,
) {
/**
* Nothing a **flat** list can express. [section] is deliberately not part of
* this: to [applyNavOverrides] a section-only override says nothing, so an
* instance that only ever grouped rows still gets its coded list back by
* identity. The tree build adds its own check.
*/
val isEmpty: Boolean get() = label == null && order == null && !hidden
}
/**
* The `items` map out of a stored `nav_public` value.
*
* Two shapes exist, because website phase 10 added sections and links without
* migrating what phases 6-8 had already stored: `{items, sections, links}` and a
* bare map of path → override. A bare map is unambiguous — every key is a path,
* so a key can never be the string `items`.
*
* `sections` and `links` come out of the same wrapper, and only ever out of the
* wrapped shape — see [sectionsOf] and [linksOf].
*/
internal fun itemsOf(navPublic: JsonObject?): Map<String, JsonObject> {
if (navPublic == null) return emptyMap()
val items = wrapperOf(navPublic)?.get("items") as? JsonObject ?: navPublic
return items.entries
.mapNotNull { (key, value) -> (value as? JsonObject)?.let { key to it } }
.toMap()
}
/**
* The stored value as the wrapped `{items, sections, links}` shape, or null when
* it is the bare items map phases 6-8 wrote. The discriminator is the web's: an
* `items` **object**, which a bare map can never carry because every key in one is
* a path.
*/
private fun wrapperOf(navPublic: JsonObject?): JsonObject? =
navPublic?.takeIf { it["items"] is JsonObject }
internal fun sectionsOf(navPublic: JsonObject?): List<JsonObject> = jsonObjectsAt(navPublic,"sections")
internal fun linksOf(navPublic: JsonObject?): List<JsonObject> = jsonObjectsAt(navPublic,"links")
private fun jsonObjectsAt(navPublic: JsonObject?, key: String): List<JsonObject> =
(wrapperOf(navPublic)?.get(key) as? JsonArray)
?.mapNotNull { it as? JsonObject }
.orEmpty()
// Field by field, like every other read in M12: a bad `label` must not discard a
// good `order` beside it.
//
// `group` is ignored — it names a section of the *admin sidebar*, a nav the app
// never renders, and a value it cannot honor is better dropped than half-applied.
internal fun cleanOverride(raw: JsonObject): NavOverride {
val label = (raw["label"] as? JsonPrimitive)
?.takeIf { it.isString }
?.content
?.trim()
?.takeIf { it.isNotEmpty() }
val order = (raw["order"] as? JsonPrimitive)
?.takeIf { !it.isString }
?.doubleOrNull
?.takeIf { it.isFinite() }
val hidden = (raw["hidden"] as? JsonPrimitive)
?.takeIf { !it.isString }
?.booleanOrNull == true
val section = (raw["section"] as? JsonPrimitive)
?.takeIf { it.isString }
?.content
?.takeIf { it.isNotEmpty() }
return NavOverride(label = label, order = order, hidden = hidden, section = section)
}
/**
* [base] with the admin's overrides applied: rows relabeled, reordered and
* dropped as the stored row asks.
*
* @param base the coded menu — the only source of `route`, `access` and `feature`
* @param navPublic the parsed `nav_public` row, or null when the admin never
* edited the nav. Null, malformed, and "nothing usable in it" all return [base]
* itself, which is what makes an untouched instance's drawer provably today's
* (§2, AC-1).
*/
fun applyNavOverrides(base: List<MenuEntry>, navPublic: JsonObject?): List<MenuEntry> {
val items = itemsOf(navPublic)
if (items.isEmpty()) return base
val coded = base.map { it.route }.toSet()
// Keyed by app route, and only for a route the coded menu actually declares.
// This is where an override for a path the app doesn't surface in its drawer —
// a news category tab, a Shard hub board — is dropped (§6.2). The web does the
// same with an unknown `to`.
val overrides = buildMap {
for ((path, raw) in items) {
val route = appRouteForWebPath(path) ?: continue
if (route !in coded) continue
val override = cleanOverride(raw)
if (!override.isEmpty) put(route, override)
}
}
if (overrides.isEmpty()) return base
// Rows the website's nav knows about are the ones an override can move; the
// app's own surfaces (Contact, Account, the player groups, the staff rows)
// have no counterpart to be reordered against and keep their coded order,
// appended after the public block — which is exactly where they sit today, so
// this partition is the current layout rather than a new one (§6.2).
val (mapped, appOnly) = base.partition { it.route in WEB_ROUTE_ORDER }
val sorted = mapped
// An untouched row's sort key is its index in the WEBSITE's nav, not the
// app's: a stored `order` is a position in that list, so both keys have to
// sit on one number line to be comparable at all.
//
// Two tie-breaks, the web's: an explicit order beats a coincidental index
// (the admin said "first", so first), and two explicit orders keep code
// order, because the sort is stable.
.sortedWith(
compareBy<MenuEntry> { entry ->
overrides[entry.route]?.order ?: WEB_ROUTE_ORDER.getValue(entry.route).toDouble()
}.thenByDescending { overrides[it.route]?.order != null },
)
return (sorted + appOnly).mapNotNull { entry ->
val override = overrides[entry.route] ?: return@mapNotNull entry
when {
override.hidden -> null
override.label != null -> entry.copy(label = override.label)
else -> entry
}
}
}

View File

@@ -1,220 +0,0 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.navigation
import com.runicgateway.app.data.repository.ContentRepository.PostCategory
/**
* The website path → app route table (THEMING_AND_NAV.md §6.2).
*
* The public nav an admin edits is keyed by **website** paths, so honoring it in
* the app needs a translation. This is the one new piece of cross-repo coupling
* the milestone introduces, which is why it lives in a single file with the
* website's own array quoted right beside it — the coupling is visible and
* reviewable in one place rather than spread across the drawer's call sites.
*
* Verbatim from `website/client/src/components/SiteHeader.jsx`, which is the
* exported owner of the list (`export const NAV`, and Admin → Navigation edits
* exactly it):
*
* ```js
* export const NAV = [
* { label: 'Home', to: '/', end: true },
* { label: 'News', to: '/site/news' },
* { label: 'Screenshots', to: '/site/screenshots' },
* { label: 'Five on Friday', to: '/site/five-on-friday' },
* { label: 'Newsletter', to: '/site/newsletter' },
* { label: 'Wiki', to: '/wiki' },
* { label: 'Shard', to: '/site/shard', feature: 'status' },
* { label: 'Champions', to: '/site/champs', feature: 'champs' },
* { label: 'Guilds', to: '/site/guilds', feature: 'guilds' },
* { label: 'Governors', to: '/site/governors', feature: 'governors' },
* { label: 'Houses', to: '/site/houses', feature: 'houses' },
* { label: 'Rules', to: '/site/rules', feature: 'ruleset' },
* { label: 'Atlas', to: '/site/atlas', feature: 'atlas' },
* { label: 'Leaderboards', to: '/site/leaderboards', feature: 'leaderboards' },
* { label: 'Market', to: '/site/market', feature: 'market' },
* { label: 'About', to: '/site/about' },
* ]
* ```
*
* The `feature` values are **not** mirrored here on purpose. [APP_MENU] is the
* app's own source of truth for gating, and a second copy of a security-relevant
* value that drifts silently is worth more than it costs. This table carries the
* mapping and nothing else.
*
* Not every row maps to something the app shows in its drawer, and that is the
* design rather than an omission — see [WEB_PATH_TO_ROUTE].
*/
/** One row of the website's public nav: its path, and the app route it opens. */
data class WebNavPath(val path: String, val route: String)
/**
* The website's public nav in **its** order, mapped to app routes.
*
* The order is load-bearing, not decorative: a stored `order` is an index into
* *this* list (the admin's editor writes the position a row holds on the web), so
* a row the admin never moved has to take its key from the same number line or
* explicit and implicit keys would be incomparable. See `NavOverrides.kt`.
*/
val WEBSITE_PUBLIC_NAV: List<WebNavPath> = listOf(
WebNavPath("/", Routes.HOME),
WebNavPath("/site/news", Routes.NEWS),
// The app's News screen carries all four categories as tabs, so these three
// have a route but no drawer row of their own — see the note below.
WebNavPath("/site/screenshots", Routes.news(PostCategory.SCREENSHOTS)),
WebNavPath("/site/five-on-friday", Routes.news(PostCategory.FIVE_ON_FRIDAY)),
WebNavPath("/site/newsletter", Routes.news(PostCategory.NEWSLETTER)),
WebNavPath("/wiki", Routes.WIKI),
WebNavPath("/site/shard", Routes.SHARD),
// Behind the Shard hub in the app, deliberately — no drawer row either.
WebNavPath("/site/champs", Routes.SHARD_CHAMPS),
WebNavPath("/site/guilds", Routes.SHARD_GUILDS),
WebNavPath("/site/governors", Routes.SHARD_GOVERNORS),
WebNavPath("/site/houses", Routes.SHARD_HOUSES),
WebNavPath("/site/rules", Routes.SHARD_RULES),
WebNavPath("/site/atlas", Routes.ATLAS),
WebNavPath("/site/leaderboards", Routes.SHARD_LEADERBOARDS),
WebNavPath("/site/market", Routes.SHARD_MARKET),
WebNavPath("/site/about", Routes.page("about")),
)
/**
* The same table as a lookup.
*
* **A mapped route is not the same thing as a drawer row.** Seven of these paths
* resolve to a screen the app reaches some other way: the three news categories
* are tabs on one News screen, and champs / guilds / governors / houses sit behind
* the Shard hub because that is the better shape on a phone. An override for one
* of them is **ignored** — §6.1's rule is that a nav override may never introduce
* navigation, and the hub is a design decision, not an accident to correct. The
* merge enforces that by intersecting with [APP_MENU]; nothing here needs to know
* which rows those are.
*
* The mapping still exists for all sixteen because phase 6's added links resolve
* an admin-authored path against the same table, and *there* a category tab or a
* hub board is a perfectly good destination — the admin asked for it by path.
*/
val WEB_PATH_TO_ROUTE: Map<String, String> =
WEBSITE_PUBLIC_NAV.associate { it.path to it.route }
/**
* Each app route's index in the website's own nav order — the sort key a row the
* admin never moved takes, so it lands on the same number line as a stored
* `order`. All sixteen routes are distinct, so this loses nothing.
*/
internal val WEB_ROUTE_ORDER: Map<String, Int> =
WEBSITE_PUBLIC_NAV.withIndex().associate { (index, row) -> row.route to index }
/**
* The app route a website nav path opens, or null when the app has no screen for
* it. A trailing slash is tolerated (`/wiki/` is `/wiki`) since a hand-edited
* settings row may carry one; the root path is left alone.
*/
fun appRouteForWebPath(path: String?): String? = WEB_PATH_TO_ROUTE[normalizeWebPath(path)]
/** `/wiki/` → `/wiki`, blank → null, and `/` left alone. */
private fun normalizeWebPath(path: String?): String? {
val trimmed = path?.trim().orEmpty()
if (trimmed.isEmpty()) return null
val normalized = if (trimmed.length > 1) trimmed.trimEnd('/') else trimmed
return normalized.ifEmpty { "/" }
}
/**
* The website's top-level paths that are **not** CMS pages.
*
* The site serves its CMS pages from a top-level `/<slug>` (React Router ranks its
* static routes above that dynamic one), which is what lets [resolveWebPath]'s
* last rule open an admin-authored page natively. These are the segments that rule
* must not swallow: the SPA's own sections, and the two server mounts. A link to
* one of them hands off to the browser, which is where they actually live.
*/
private val RESERVED_TOP_LEVEL = setOf(
"admin", "account", "player", "site", "wiki", "invite", "preview", "api", "uploads",
)
/**
* The app route an **arbitrary** website path opens, or null when the app has no
* screen for it and the link must hand off to a Custom Tab (§6.3).
*
* [appRouteForWebPath] answers for the sixteen paths the *nav* is built from; this
* answers for a path an admin typed into an added link, which may name any page on
* the site. It is the app's read of the site's own route table, and like the table
* above it is cross-repo coupling kept in one file — quoted here for the same
* reason, from `website/client/src/App.jsx`:
*
* ```jsx
* <Route path="/" element={<Portal />} />
* <Route path="/site/news" element={<News />} />
* <Route path="/site/screenshots" element={<Screenshots />} />
* <Route path="/site/five-on-friday" element={<FiveOnFriday />} />
* <Route path="/site/newsletter" element={<Newsletter />} />
* <Route path="/site/newsletter/:id" element={<NewsletterIssue />} />
* <Route path="/site/about" element={<About />} />
* <Route path="/site/status" element={<Status />} />
* <Route path="/site/shard" element={<Shard />} />
* <Route path="/site/shard/activity" element={<ShardActivity />} />
* ... /site/champs, /guilds, /governors, /houses, /rules, /leaderboards, /market
* <Route path="/site/atlas" element={<Atlas />} />
* <Route path="/site/atlas/:slug" element={<AtlasCreature />} />
* <Route path="/site/market/vendors/:serial" element={<MarketVendor />} />
* <Route path="/wiki" element={<Wiki />} />
* <Route path="/wiki/:slug" element={<WikiArticle />} />
* // CMS pages: top-level /:slug, matched only after the named routes above
* <Route path="/:slug" element={<CmsPage />} />
* ```
*
* Note what is *not* in it: no `/site/news/<id>` (a news item renders on its
* category page; the newsletter's is the site's one post-detail route), no
* `/page/<slug>`, and no `/contact` — the app's contact form is app-only (§6.2).
*
* ```
* / → HOME
* /site/news → NEWS
* /site/{screenshots,five-on-friday,newsletter}
* → NEWS, that category's tab
* /site/newsletter/<id> → POST (the site's one post-detail route)
* /wiki → WIKI
* /wiki/<slug> → WIKI_PAGE
* /site/<shard surface> → the mapped shard route (§6.2)
* /site/atlas/<slug> → ATLAS_CREATURE
* /site/market/vendors/<serial> → SHARD_MARKET_VENDOR
* /site/about → PAGE("about")
* /<slug> → PAGE(slug), unless <slug> is reserved
* anything else → null, i.e. the Custom Tab
* ```
*
* **A path carrying a query or a fragment hands off**, whatever its route part
* says. No app route takes either, so a native match would quietly drop what the
* admin wrote; the browser honors it exactly.
*
* Resolving a path is not the same as being allowed to see the screen behind it.
* A link to `/site/market` on a shard that does not publish the market lands on
* the Market screen's honest "not published here" state, which is what typing the
* URL on the web does too (§6.3).
*/
fun resolveWebPath(path: String?): String? {
val normalized = normalizeWebPath(path) ?: return null
if (normalized.any { it == '?' || it == '#' }) return null
WEB_PATH_TO_ROUTE[normalized]?.let { return it }
if (!normalized.startsWith("/")) return null
// Blank segments ("/site//news") mean a malformed path, not a slug.
val segments = normalized.removePrefix("/").split('/')
if (segments.any { it.isBlank() }) return null
return when {
segments.size == 1 -> segments[0].takeIf { it !in RESERVED_TOP_LEVEL }?.let(Routes::page)
segments[0] == "wiki" && segments.size == 2 -> Routes.wikiPage(segments[1])
segments[0] != "site" -> null
segments.size == 3 && segments[1] == "newsletter" ->
Routes.post(PostCategory.NEWSLETTER.urlSlug, segments[2])
segments.size == 3 && segments[1] == "atlas" -> Routes.atlasCreature(segments[2])
segments.size == 4 && segments[1] == "market" && segments[2] == "vendors" ->
Routes.marketVendor(segments[3])
else -> null
}
}

View File

@@ -1,239 +0,0 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.navigation
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.doubleOrNull
/**
* The drawer as a one-level tree: the coded menu, plus the **sections** an admin
* grouped rows into and the **links** they added of their own (THEMING_AND_NAV.md
* §6.3). The Kotlin counterpart of the website's `buildPublicNav` + `pruneNav`.
*
* The public nav is the one nav an admin can restructure rather than only reorder,
* and §6.1's invariant survives that structurally rather than by vigilance: a
* coded row is still keyed by a website path the app's own table declares, so an
* override still cannot invent a destination or touch a gate, while everything
* that *can* name an arbitrary path lives in [NavNode.Link], where the path rule
* is applied and the result is resolved through [resolveWebPath].
*
* An added link carries no gate and needs none — the screen behind it enforces its
* own access, so a link to somewhere this caller cannot reach lands on that
* screen's own honest state, exactly as typing the URL on the web does.
*/
sealed interface NavNode {
/** A coded [MenuEntry], relabeled/reordered by the merge but never re-gated. */
data class Item(val entry: MenuEntry) : NavNode
/**
* An admin-authored link to a page on this site.
*
* @param path the stored website path, already validated — this is what a
* Custom Tab opens, resolved against the site's base URL
* @param route the app route [path] maps to, or null when the app has no
* screen for it and the link must hand off (§6.3)
*/
data class Link(
val id: String,
val label: String,
val path: String,
val route: String?,
) : NavNode
/**
* A drawer group: its [label] as a header, its [items] beneath it.
*
* The website renders these as click-to-open dropdowns; a drawer is already a
* vertical list, so the app renders the group open (§6.3). Never empty — see
* [pruneNav].
*/
data class Section(
val id: String,
val label: String,
val items: List<NavNode>,
) : NavNode
}
/** A usable `sections` entry. */
private data class SectionSpec(val id: String, val label: String, val order: Double?)
/** A usable `links` entry, with its section already checked against the stored ones. */
private data class LinkSpec(
val id: String,
val label: String,
val to: String,
val order: Double?,
val section: String?,
)
/** One node waiting to be placed: its sort key, and whether that key was stored. */
private data class Placed(val node: NavNode, val section: String?, val key: Double, val explicit: Boolean)
/**
* Characters that must never appear in a stored link path. The same rule the
* website applies on read: a value that would leave the origin, or carry markup
* into a link, is dropped rather than rendered.
*/
private val FORBIDDEN_IN_PATH = Regex("""[\s<>"'\\]""")
// Forgiving, like every other read in M12: an entry that is not usable is dropped
// and its neighbours kept. A repeated id is dropped too — the first wins, since
// the id is what a link's identity in the drawer is.
private fun readSections(raw: List<JsonObject>): List<SectionSpec> {
val seen = mutableSetOf<String>()
return raw.mapNotNull { section ->
val id = section.stringOrNull("id") ?: return@mapNotNull null
val label = section.stringOrNull("label")?.trim()?.takeIf { it.isNotEmpty() } ?: return@mapNotNull null
if (!seen.add(id)) return@mapNotNull null
SectionSpec(id = id, label = label, order = section.orderOrNull())
}
}
private fun readLinks(raw: List<JsonObject>, knownSections: Set<String>): List<LinkSpec> {
val seen = mutableSetOf<String>()
return raw.mapNotNull { link ->
val id = link.stringOrNull("id") ?: return@mapNotNull null
val label = link.stringOrNull("label")?.trim()?.takeIf { it.isNotEmpty() } ?: return@mapNotNull null
val to = link.stringOrNull("to") ?: return@mapNotNull null
if (!to.startsWith("/") || to.startsWith("//") || FORBIDDEN_IN_PATH.containsMatchIn(to)) {
return@mapNotNull null
}
if (!seen.add(id)) return@mapNotNull null
LinkSpec(
id = id,
label = label,
to = to,
order = link.orderOrNull(),
// A link naming a section that does not exist is a top-level link, not
// a dropped one: the admin's destination is still good.
section = link.stringOrNull("section")?.takeIf { it in knownSections },
)
}
}
private fun JsonObject.stringOrNull(key: String): String? =
(this[key] as? JsonPrimitive)?.takeIf { it.isString }?.content
private fun JsonObject.orderOrNull(): Double? =
(this["order"] as? JsonPrimitive)?.takeIf { !it.isString }?.doubleOrNull?.takeIf { it.isFinite() }
// Two tie-breaks, the web's and phase 5's: an explicit order beats a coincidental
// index (the admin said "first", so first), and two explicit orders keep
// declaration order, because the sort is stable.
private fun List<Placed>.place(): List<NavNode> =
sortedWith(compareBy<Placed> { it.key }.thenByDescending { it.explicit }).map { it.node }
/**
* The coded menu with the admin's `nav_public` applied in full: relabeled,
* reordered and hidden as phase 5 already did, plus grouped into sections and
* joined by added links.
*
* With no sections and no links this **is** phase 5 — [applyNavOverrides] answers,
* so an untouched instance still gets [APP_MENU] back by identity and AC-1's proof
* is unchanged (§2). The tree build only runs when the admin actually created
* structure.
*
* @param base the coded menu — the only source of `route`, `access` and `feature`
* @param navPublic the parsed `nav_public` row, or null when the admin never
* edited the nav
*/
fun buildNavTree(base: List<MenuEntry>, navPublic: JsonObject?): List<NavNode> {
val sections = readSections(sectionsOf(navPublic))
val links = readLinks(linksOf(navPublic), sections.map { it.id }.toSet())
if (sections.isEmpty() && links.isEmpty()) {
return applyNavOverrides(base, navPublic).map { NavNode.Item(it) }
}
val knownSections = sections.map { it.id }.toSet()
val coded = base.map { it.route }.toSet()
// Keyed by app route, and only for a route the coded menu declares — the same
// narrowing as the flat merge, so an override for a path the app maps but does
// not surface (a news category tab, a Shard hub board) is dropped here too.
val overrides = buildMap {
for ((path, raw) in itemsOf(navPublic)) {
val route = appRouteForWebPath(path) ?: continue
if (route !in coded) continue
val override = cleanOverride(raw)
// A section the stored value never declares is no section at all.
val section = override.section?.takeIf { it in knownSections }
if (!override.isEmpty || section != null) put(route, override.copy(section = section))
}
}
// The app's own surfaces (Contact, Account, the player groups, the staff rows)
// have no website counterpart to be reordered against or grouped under, so they
// keep their coded order after the public block — where they already sit (§6.2).
val (mapped, appOnly) = base.partition { it.route in WEB_ROUTE_ORDER }
val placed = mutableListOf<Placed>()
for (entry in mapped) {
val override = overrides[entry.route]
if (override?.hidden == true) continue
placed += Placed(
node = NavNode.Item(override?.label?.let { entry.copy(label = it) } ?: entry),
section = override?.section,
// An untouched row's key is its index in the WEBSITE's nav, so stored
// and implicit keys sit on one number line (phase 5).
key = override?.order ?: WEB_ROUTE_ORDER.getValue(entry.route).toDouble(),
explicit = override?.order != null,
)
}
// An admin-created entity with no stored order appends after the coded rows, in
// creation order, rather than jumping to the front on a 0 default.
var next = WEBSITE_PUBLIC_NAV.size
for (section in sections) {
placed += Placed(
node = NavNode.Section(section.id, section.label, emptyList()),
section = null,
key = section.order ?: (next++).toDouble(),
explicit = section.order != null,
)
}
for (link in links) {
placed += Placed(
node = NavNode.Link(link.id, link.label, link.to, resolveWebPath(link.to)),
section = link.section,
key = link.order ?: (next++).toDouble(),
explicit = link.order != null,
)
}
val top = placed.filter { it.node is NavNode.Section || it.section == null }.place()
return top.map { node ->
if (node !is NavNode.Section) {
node
} else {
node.copy(items = placed.filter { it.section == node.id }.place())
}
} + appOnly.map { NavNode.Item(it) }
}
/**
* The tree with this caller's gates applied — and a section they empty dropped.
*
* This is the boundary, and it runs **after** [buildNavTree], never before: an
* override is presentation, so a row it relabels, moves or marks `hidden: false`
* is still shown only if [isVisible] says so (§6.1, AC-3).
*
* The empty-section case is the one with real correctness risk and the reason the
* rule is ported rather than left to the drawer: a group whose every member is
* withheld by the caller's role or by the shard's visibility config must not draw
* as a header with nothing under it.
*
* Links are not gated — see [NavNode].
*
* @param isVisible the caller's own predicate, applied to coded items only, so
* this file stays ignorant of sessions and shard features
*/
fun pruneNav(tree: List<NavNode>, isVisible: (MenuEntry) -> Boolean): List<NavNode> {
fun keep(node: NavNode) = node !is NavNode.Item || isVisible(node.entry)
return tree.mapNotNull { node ->
when (node) {
is NavNode.Section -> node.copy(items = node.items.filter(::keep)).takeIf { it.items.isNotEmpty() }
else -> node.takeIf { keep(it) }
}
}
}

View File

@@ -3,8 +3,6 @@
*/
package com.runicgateway.app.ui.navigation
import com.runicgateway.app.data.repository.ContentRepository
/**
* Navigation destinations for the M1 public surface (PLAN.md §5). Routes are
* plain strings for Navigation-Compose; argument-bearing routes expose a
@@ -16,27 +14,10 @@ object Routes {
const val WIKI = "wiki"
const val CONTACT = "contact"
/**
* The News hub's NavHost pattern: [NEWS] plus an optional category, so a link
* to one of the website's three category pages can land on the matching tab
* (THEMING_AND_NAV.md §6.2). Navigating to plain [NEWS] matches this pattern
* with no argument and opens the default tab, so every existing call site —
* the drawer, [forStream] — is unaffected.
*
* Declared beside [NEWS] rather than replacing it because the two are used for
* different things: this is what `composable()` and `destination.route` speak,
* [NEWS] is what callers navigate to.
*/
const val NEWS_ROUTE = "news?category={category}"
/** Native login (§4.1) and the signed-in account surface (§5). */
const val LOGIN = "login"
const val ACCOUNT = "account"
/** MFA management, reached from Account (TRUSTED_DEVICES_MFA.md). Signed-in only. */
const val ACCOUNT_TRUSTED_DEVICES = "account/trusted-devices"
const val ACCOUNT_RECOVERY_CODES = "account/recovery-codes"
/** Opt-in push notification settings (§11, signed-in). */
const val NOTIFICATIONS = "notifications"
@@ -49,18 +30,6 @@ object Routes {
const val SHARD_GOVERNORS = "shard/governors"
const val SHARD_HOUSES = "shard/houses"
/**
* Protocol 3.0 shard content (M11), each gated by its own visibility feature. The
* atlas is not under `shard/` on the wire (`/public/atlas`) because it is static
* content rather than live state, but it is a peer of these in the app's nav.
*/
const val SHARD_RULES = "shard/rules"
const val SHARD_LEADERBOARDS = "shard/leaderboards"
const val SHARD_MARKET = "shard/market"
const val SHARD_MARKET_VENDOR = "shard/market/{serial}"
const val ATLAS = "atlas"
const val ATLAS_CREATURE = "atlas/{slug}"
/** 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"
@@ -69,13 +38,6 @@ object Routes {
/** A single character sheet by in-game (hex) serial. */
const val PLAYER_CHAR = "player/char/{serial}"
/** Staff operations (§1, §6.4, M10). Gated to staff roles by the menu access level;
* the backend re-checks role on every `/admin/…` call. */
const val ADMIN_DASHBOARD = "admin/dashboard"
const val ADMIN_MODERATION = "admin/moderation"
const val ADMIN_SUPPORT = "admin/support"
const val ADMIN_CONTENT = "admin/content"
/** CMS page by slug (e.g. the conventional "about" page, mirrored from the site nav). */
const val PAGE = "page/{slug}"
@@ -94,24 +56,11 @@ object Routes {
fun page(slug: String) = "page/$slug"
fun post(categoryUrlSlug: String, idOrSlug: String) = "news/$categoryUrlSlug/$idOrSlug"
/**
* The News hub with [category] preselected. Takes the enum rather than a slug
* so an unmapped category cannot reach the NavHost — the screen's tabs are the
* enum's entries, and a slug it doesn't know would select nothing.
*/
fun news(category: ContentRepository.PostCategory) = "news?category=${category.urlSlug}"
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"
/** One player vendor's shop, by in-game (hex) serial. */
fun marketVendor(serial: String) = "shard/market/$serial"
/** One creature's atlas page, by slug. */
fun atlasCreature(slug: String) = "atlas/$slug"
/**
* The in-app destination a tapped push notification deep-links to (§11, M7
* Part 2 work item 7). Maps a stream id to the screen that shows its content;

View File

@@ -10,6 +10,7 @@ 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
@@ -28,7 +29,6 @@ import com.runicgateway.app.ui.UiState
import com.runicgateway.app.ui.components.EmptyView
import com.runicgateway.app.ui.components.ErrorView
import com.runicgateway.app.ui.components.LoadingView
import com.runicgateway.app.ui.components.ShardCard
/** News hub with category tabs and a post list (PLAN.md §6.1). */
@Composable
@@ -82,7 +82,7 @@ private fun PostList(
@Composable
private fun PostRow(post: PostDto, onClick: () -> Unit) {
ShardCard(
Card(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 6.dp)

View File

@@ -3,14 +3,12 @@
*/
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.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
@@ -23,15 +21,9 @@ import javax.inject.Inject
@HiltViewModel
class NewsViewModel @Inject constructor(
private val contentRepository: ContentRepository,
savedStateHandle: SavedStateHandle,
) : ViewModel() {
// Which tab to open on. Absent — every route into this screen except an
// admin's nav override or added link (THEMING_AND_NAV.md §6.2) — is the
// default feed, and so is a slug the app doesn't know.
private val _category = MutableStateFlow(
PostCategory.fromUrlSlug(savedStateHandle[Routes.Args.CATEGORY]) ?: PostCategory.NEWS,
)
private val _category = MutableStateFlow(PostCategory.NEWS)
val category: StateFlow<PostCategory> = _category.asStateFlow()
private val _state = MutableStateFlow<UiState<List<PostDto>>>(UiState.Loading)

View File

@@ -13,6 +13,7 @@ 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
@@ -25,7 +26,6 @@ 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.CharPointsDto
import com.runicgateway.app.data.api.dto.CharProfileDto
import com.runicgateway.app.data.api.dto.CharStatsDto
import com.runicgateway.app.data.api.dto.EquipmentDto
@@ -36,7 +36,6 @@ 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.ShardCard
import com.runicgateway.app.ui.components.StatBar
import kotlinx.serialization.json.jsonPrimitive
@@ -72,7 +71,6 @@ private fun CharacterSheet(char: CharProfileDto, modifier: Modifier = Modifier)
char.stats?.let { AttributesBlock(it) }
char.stats?.resist?.let { ResistancesBlock(it) }
SkillsBlock(char.skills)
PointsBlock(displayPoints(char))
EquipmentBlock(char.equipment)
}
}
@@ -217,47 +215,6 @@ private fun SkillsBlock(skills: List<SkillDto>) {
}
}
/**
* Loyalty & points standings (Protocol 3.0 §7.3). Renders nothing at all for a
* character that has earned nothing anywhere, which is a normal state.
*
* Only a system with a real cap gets a meter: an uncapped score
* ([CharPointsDto.maxPoints] `0`, the common case on a real shard) has nothing to be
* a fraction of, and a full-width bar would imply a completion that doesn't exist.
*/
@Composable
private fun PointsBlock(points: List<CharPointsDto>) {
if (points.isEmpty()) return
SheetCard(R.string.player_char_points) {
points.forEach { entry ->
val cap = entry.cap
val score = entry.points ?: 0L
Column(Modifier.padding(vertical = 5.dp)) {
Row(
Modifier.fillMaxWidth().padding(bottom = 4.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
// `rank` is absent unless the shard opts in; absent and
// "unranked" are different, so the suffix only appears when sent.
entry.rank?.let { stringResource(R.string.player_char_points_ranked, pointsLabel(entry), it) }
?: pointsLabel(entry),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
cap?.let { stringResource(R.string.player_char_points_of, score, it) } ?: score.toString(),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontWeight = FontWeight.Medium,
)
}
if (cap != null) StatBar((score.toDouble() / cap).coerceIn(0.0, 1.0).toFloat())
}
}
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun EquipmentBlock(equipment: List<EquipmentDto>) {
@@ -266,7 +223,7 @@ private fun EquipmentBlock(equipment: List<EquipmentDto>) {
equipment.forEach { item ->
Column(Modifier.fillMaxWidth().padding(vertical = 6.dp)) {
Text(
item.label ?: stringResource(R.string.player_char_item),
item.layer ?: stringResource(R.string.player_char_item),
style = MaterialTheme.typography.bodyLarge,
)
val meta = listOfNotNull(
@@ -289,7 +246,7 @@ private fun EquipmentBlock(equipment: List<EquipmentDto>) {
@Composable
private fun SheetCard(titleRes: Int, content: @Composable () -> Unit) {
ShardCard(Modifier.fillMaxWidth()) {
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp)) {
Text(stringResource(titleRes), style = MaterialTheme.typography.titleMedium)
content()
@@ -309,58 +266,23 @@ internal fun formatSkill(value: Double): String =
/**
* 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.
*
* Reward entries arrive as either a literal or a cliloc number in string form. The
* server now resolves the numeric ones into `rewardResolved`, a **parallel** array
* (see `docs/website/CLILOCS.md`), so the mapping below is index-preserving: an entry
* that didn't resolve becomes null and is skipped, but must not shift the `selected`
* index onto its neighbour. A number with no resolution is still skipped rather than
* rendered as a raw id, which is also the whole behavior on a shard that configures
* no cliloc table.
*
* Falling back to the first title that resolved (rather than showing nothing) matters
* when the *selected* one is the unresolved entry. De-duplicated, blanks dropped.
* 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.mapIndexed { i, raw ->
titles.rewardResolved.getOrNull(i)
?: raw.takeUnless { it.isBlank() || it.all(Char::isDigit) }
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) }
}
val candidate = reward.getOrNull(titles.selected ?: -1) ?: reward.firstNotNullOfOrNull { it }
if (!candidate.isNullOrBlank()) out.add(candidate)
if (candidate != null && candidate.isNotBlank() && !candidate.all(Char::isDigit)) out.add(candidate)
return out.filter { it.isNotBlank() }.distinct()
}
/**
* A point system's display name: the shard's own [CharPointsDto.nameString] when it
* has one, else the humanised `PointsType` key.
*
* The fallback is the PRIMARY path, not a defensive nicety — most systems name
* themselves with a cliloc, so `nameString` comes back null for four of five boards
* on a real shard (`docs/link/v3.md` §7.5). Parity with the website's
* `humanisePoints`.
*/
internal fun pointsLabel(entry: CharPointsDto): String {
entry.nameString?.takeIf { it.isNotBlank() }?.let { return it }
val key = entry.system.orEmpty()
return key
.replace(Regex("([a-z0-9])([A-Z])"), "$1 $2")
.replaceFirstChar { it.uppercaseChar() }
}
/**
* The points block, best standing first, dropping systems the character has no score
* in. Guarded for an older shard plugin that sends no `points` block at all.
*/
internal fun displayPoints(char: CharProfileDto): List<CharPointsDto> =
char.points
.filter { (it.points ?: 0L) > 0L }
.sortedByDescending { it.points ?: 0L }
private fun jsonText(element: kotlinx.serialization.json.JsonElement): String =
runCatching { element.jsonPrimitive.content }.getOrElse { element.toString() }

View File

@@ -13,6 +13,7 @@ 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
@@ -38,7 +39,6 @@ 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.ShardCard
import com.runicgateway.app.ui.components.StatusPill
/**
@@ -90,7 +90,7 @@ fun CharactersScreen(
@Composable
private fun LinkCard(state: CharactersViewModel.State, viewModel: CharactersViewModel) {
var code by rememberSaveable { mutableStateOf("") }
ShardCard(Modifier.fillMaxWidth()) {
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp)) {
Text(stringResource(R.string.player_link_title), style = MaterialTheme.typography.titleMedium)
Text(
@@ -121,7 +121,7 @@ private fun LinkCard(state: CharactersViewModel.State, viewModel: CharactersView
private fun CreateAccountCard(state: CharactersViewModel.State, viewModel: CharactersViewModel) {
var account by rememberSaveable { mutableStateOf("") }
var password by rememberSaveable { mutableStateOf("") }
ShardCard(Modifier.fillMaxWidth()) {
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp)) {
Text(stringResource(R.string.player_create_title), style = MaterialTheme.typography.titleMedium)
OutlinedTextField(
@@ -204,7 +204,7 @@ private fun RosterError(kind: ErrorKind, onRetry: () -> Unit) {
@Composable
private fun CharRow(char: RosterCharDto, onOpenChar: (String) -> Unit) {
ShardCard(
Card(
Modifier
.fillMaxWidth()
.padding(vertical = 4.dp)

View File

@@ -11,6 +11,7 @@ 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
@@ -27,7 +28,6 @@ import com.runicgateway.app.ui.UiState
import com.runicgateway.app.ui.components.EmptyView
import com.runicgateway.app.ui.components.ErrorView
import com.runicgateway.app.ui.components.LoadingView
import com.runicgateway.app.ui.components.ShardCard
/**
* The player's own houses with home/decay status (PLAN.md §6.3), text-only. An
@@ -60,7 +60,7 @@ fun MyHousesScreen(
@Composable
private fun HouseCard(house: PlayerHouseDto) {
ShardCard(Modifier.fillMaxWidth()) {
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp)) {
Row(Modifier.fillMaxWidth()) {
Text(

View File

@@ -11,6 +11,7 @@ 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
@@ -30,7 +31,6 @@ 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.ShardCard
import java.text.DateFormat
import java.util.Date
@@ -79,7 +79,7 @@ fun VendorsScreen(
@Composable
private fun SalesCard(sales: UiState<List<VendorSaleDto>>) {
ShardCard(Modifier.fillMaxWidth()) {
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp)) {
Text(stringResource(R.string.player_sales_title), style = MaterialTheme.typography.titleMedium)
when (sales) {
@@ -185,7 +185,7 @@ private fun VendorError(kind: ErrorKind, onRetry: () -> Unit) {
@Composable
private fun VendorCard(vendor: VendorDto) {
ShardCard(Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
Card(Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
Column(Modifier.padding(16.dp)) {
Text(
vendor.shopName ?: stringResource(R.string.player_vendor_fallback),

View File

@@ -8,8 +8,6 @@ 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 com.runicgateway.app.data.repository.ShardFeatures
import com.runicgateway.app.data.repository.ShardFeaturesRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
@@ -25,28 +23,10 @@ import javax.inject.Inject
class SessionViewModel @Inject constructor(
sessionManager: SessionManager,
private val authRepository: AuthRepository,
shardFeaturesRepository: ShardFeaturesRepository,
) : ViewModel() {
val session: StateFlow<Session> = sessionManager.state
/**
* Which shard features this viewer may reach (M11). Held here beside [session]
* because it answers the same question for the same consumer: what the shared
* menu reveals. Role and feature config are independent gates — see
* [com.runicgateway.app.ui.navigation.visibleEntries].
*/
val shardFeatures: StateFlow<ShardFeatures?> = shardFeaturesRepository.features
init {
// The answer is per-viewer, so it is re-resolved on every session change.
// A StateFlow conflates equal values, so a resume revalidation that returns
// the same user does not refetch — only a real sign-in/out/role change does.
viewModelScope.launch {
session.collect { shardFeaturesRepository.refresh() }
}
}
/** Re-validate the cached role against the backend on app resume. */
fun revalidate() {
viewModelScope.launch { authRepository.revalidate() }

View File

@@ -1,301 +0,0 @@
/*
* 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.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
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.AtlasCreatureDto
import com.runicgateway.app.data.api.dto.AtlasPlaceDto
import com.runicgateway.app.data.api.dto.AtlasSpawnerDto
import com.runicgateway.app.ui.UiState
import com.runicgateway.app.ui.components.EmptyView
import com.runicgateway.app.ui.components.ErrorView
import com.runicgateway.app.ui.components.LoadingView
import com.runicgateway.app.ui.components.PillTone
import com.runicgateway.app.ui.components.SectionLabel
import com.runicgateway.app.ui.components.ShardCard
import com.runicgateway.app.ui.components.StatusPill
/**
* The spawn atlas / bestiary (PLAN.md §9 M11): "where do I find X".
*
* The whole point of the feature is the placement transform the server does — a spawn
* at 5411,1234 becomes *"Despise, Felucca"* — so a row leads with where a creature is
* found, not with coordinates.
*/
@Composable
fun AtlasScreen(
onOpenCreature: (String) -> Unit,
modifier: Modifier = Modifier,
viewModel: AtlasViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
val query by viewModel.query.collectAsStateWithLifecycle()
Column(modifier.fillMaxSize()) {
OutlinedTextField(
value = query,
onValueChange = viewModel::onQueryChange,
label = { Text(stringResource(R.string.atlas_search_label)) },
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
keyboardActions = KeyboardActions(onSearch = { viewModel.search() }),
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
)
when (val s = state) {
is UiState.Loading -> LoadingView()
is UiState.Error -> ErrorView(s.kind, onRetry = viewModel::load)
is UiState.Success -> {
if (s.data.creatures.isEmpty()) {
EmptyView(stringResource(R.string.atlas_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.creatures, key = { it.slug.orEmpty() }) { creature ->
CreatureCard(creature, onOpenCreature)
}
}
}
}
}
}
}
@Composable
private fun CreatureCard(creature: AtlasCreatureDto, onOpenCreature: (String) -> Unit) {
val slug = creature.slug
ShardCard(
Modifier
.fillMaxWidth()
.then(if (slug != null) Modifier.clickable { onOpenCreature(slug) } else Modifier),
) {
Column(Modifier.padding(16.dp)) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(
text = creature.name ?: slug.orEmpty(),
style = MaterialTheme.typography.titleSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
// `points` is a COUNT of spawners on this route; `spawners` is the list,
// and only the detail route sends it.
creature.points?.let {
Text(
text = pluralStringResource(R.plurals.atlas_spawner_count, it, it),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
facetSummary(creature)?.let {
Text(
text = it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
/** One creature: every spawner, where it stands, and what shares its spawns. */
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun AtlasCreatureScreen(
slug: String,
modifier: Modifier = Modifier,
viewModel: AtlasCreatureViewModel = hiltViewModel(),
) {
LaunchedEffect(slug) { viewModel.load(slug) }
val state by viewModel.state.collectAsStateWithLifecycle()
when (val s = state) {
is UiState.Loading -> LoadingView(modifier)
is UiState.Error -> ErrorView(s.kind, onRetry = viewModel::retry, modifier = modifier)
is UiState.Success -> {
val creature = s.data
LazyColumn(
modifier = modifier.fillMaxSize().padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
contentPadding = androidx.compose.foundation.layout.PaddingValues(vertical = 16.dp),
) {
item {
Column {
Text(
creature.name ?: creature.slug.orEmpty(),
style = MaterialTheme.typography.titleLarge,
)
creature.total?.let {
Text(
stringResource(R.string.atlas_total_alive, it),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (creature.facets.isNotEmpty()) {
FlowRow(
Modifier.padding(top = 8.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
creature.facets.entries.sortedBy { it.key }.forEach { (facet, count) ->
StatusPill(
text = stringResource(R.string.atlas_facet_count, facet, count),
tone = PillTone.Neutral,
)
}
}
}
}
}
// The aggregate comes first: "where is it" is the question, and the
// individual coordinates below are the follow-up. Same ordering as web.
if (creature.places.isNotEmpty()) {
item { SectionLabel(stringResource(R.string.atlas_section_places)) }
items(
creature.places,
key = { "${it.facet.orEmpty()}:${it.label.orEmpty()}" },
) { place ->
PlaceRow(place)
}
}
if (creature.spawners.isNotEmpty()) {
item { SectionLabel(stringResource(R.string.atlas_section_spawners)) }
items(creature.spawners, key = { it.id ?: it.hashCode().toLong() }) { spawner ->
SpawnerRow(spawner)
}
if (creature.spawnersTruncated) {
item {
Text(
stringResource(R.string.atlas_spawners_truncated),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
if (creature.alsoHere.isNotEmpty()) {
item { SectionLabel(stringResource(R.string.atlas_section_also_here)) }
item {
Text(
creature.alsoHere.mapNotNull { it.name ?: it.slug }.joinToString(", "),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}
}
@Composable
private fun PlaceRow(place: AtlasPlaceDto) {
Column(Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
Text(
text = placeLabel(place),
style = MaterialTheme.typography.bodyMedium,
)
val meta = listOfNotNull(
place.facet,
place.spawners?.let { pluralStringResource(R.plurals.atlas_spawner_count, it, it) },
place.maxAlive?.let { stringResource(R.string.atlas_place_max_alive, it) },
).joinToString(" · ")
if (meta.isNotBlank()) {
Text(meta, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
@Composable
private fun SpawnerRow(spawner: AtlasSpawnerDto) {
Column(Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
Text(
text = spawnerPlace(spawner),
style = MaterialTheme.typography.bodyMedium,
)
val meta = listOfNotNull(
spawner.maxCount?.let { stringResource(R.string.atlas_max_count, it) },
// Seconds, normalised server-side — the raw XmlSpawner values are minutes
// OR seconds per record.
formatRespawn(spawner.minDelay, spawner.maxDelay)
?.let { stringResource(R.string.atlas_respawn, it) },
).joinToString(" · ")
if (meta.isNotBlank()) {
Text(meta, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
// ── Pure helpers (unit-tested) ───────────────────────────────────────────────
/**
* Where a spawner stands, preferring the server's own placement label — the
* point-in-rect transform is what turns a coordinate into "Despise, Felucca" and is
* the reason this feature exists. Falls back through region, landmark, and finally the
* raw coordinates, which is honest rather than useless for the ~17% of spawns that
* resolve to no named place.
*/
internal fun spawnerPlace(spawner: AtlasSpawnerDto): String {
spawner.label?.takeIf { it.isNotBlank() }?.let { return it }
val place = spawner.region ?: spawner.landmark
val facet = spawner.facet
return when {
place != null && facet != null -> "$place, $facet"
place != null -> place
spawner.x != null && spawner.y != null ->
listOfNotNull(facet, "${spawner.x}, ${spawner.y}").joinToString(" ")
else -> facet.orEmpty()
}
}
/**
* The name of an aggregated place. [AtlasPlaceDto.label] is already the server's
* resolved answer and falls back to "Wilderness" there, so the only case left here is
* a place that carried no label at all — then the facet is better than nothing.
*/
internal fun placeLabel(place: AtlasPlaceDto): String =
place.label?.takeIf { it.isNotBlank() } ?: place.facet.orEmpty()
/**
* A creature's facets as one line, most spawners first — "where is it *mostly*" is the
* question a search result answers.
*/
internal fun facetSummary(creature: AtlasCreatureDto): String? {
if (creature.facets.isEmpty()) return null
return creature.facets.entries
.sortedByDescending { it.value }
.joinToString(", ") { it.key }
}

View File

@@ -1,125 +0,0 @@
/*
* 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.result.ApiResult
import com.runicgateway.app.data.api.dto.AtlasCreatureDto
import com.runicgateway.app.data.api.dto.AtlasCreaturePageDto
import com.runicgateway.app.data.repository.ShardRepository
import com.runicgateway.app.ui.UiState
import com.runicgateway.app.ui.toShardUiState
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 spawn atlas / bestiary (PLAN.md §9 M11, `docs/link/v3.md` §6): where each
* creature spawns, derived server-side from the shard's own data files.
*
* Static shard **content**, not live state — it does not go offline with the sidecar,
* and it lives under `/public/atlas`, not `/public/shard`. Unlike the shard routes it
* IS site-mode gated, so a site in maintenance withholds it independently.
*/
@HiltViewModel
class AtlasViewModel @Inject constructor(
private val repository: ShardRepository,
) : ViewModel() {
private val _state = MutableStateFlow<UiState<AtlasCreaturePageDto>>(UiState.Loading)
val state: StateFlow<UiState<AtlasCreaturePageDto>> = _state.asStateFlow()
private val _query = MutableStateFlow("")
val query: StateFlow<String> = _query.asStateFlow()
private val _facet = MutableStateFlow<String?>(null)
val facet: StateFlow<String?> = _facet.asStateFlow()
/**
* The facets this shard actually has. Discovered from the atlas itself — a shard
* may add, replace or rename facets when its maps change, so nothing here may name
* one (`v3.md` §6.1 R2).
*/
private val _facets = MutableStateFlow<List<String>>(emptyList())
val facets: StateFlow<List<String>> = _facets.asStateFlow()
init {
load()
}
fun onQueryChange(value: String) {
_query.value = value
}
fun onFacetChange(value: String?) {
if (value == _facet.value) return
_facet.value = value
search()
}
fun search() = load()
fun load() {
_state.value = UiState.Loading
viewModelScope.launch {
val page = repository.atlasCreatures(query = _query.value, facet = _facet.value)
if (page is ApiResult.Ok && _facets.value.isEmpty()) {
// Only the first successful page needs to establish the filter options;
// a filtered page would otherwise narrow them to its own results.
_facets.value = page.data.creatures
.flatMap { it.facets.keys }
.distinct()
.sorted()
}
_state.value = page.toShardUiState()
}
}
}
/** One creature's detail page: every spawner, and what else shares them. */
@HiltViewModel
class AtlasCreatureViewModel @Inject constructor(
private val repository: ShardRepository,
) : ViewModel() {
private val _state = MutableStateFlow<UiState<AtlasCreatureDto>>(UiState.Loading)
val state: StateFlow<UiState<AtlasCreatureDto>> = _state.asStateFlow()
private var slug: String? = null
fun load(slug: String) {
this.slug = slug
_state.value = UiState.Loading
viewModelScope.launch {
_state.value = repository.atlasCreature(slug).toShardUiState()
}
}
fun retry() {
slug?.let { load(it) }
}
}
/**
* A respawn delay as text. **The API carries SECONDS** — XmlSpawner stores minutes
* except when a delay doesn't divide into whole minutes, and the server's parser
* normalises the two spellings so a `5` is never ambiguous here (`v3.md` §6.3).
*
* Pure, so the unit conversion is unit-tested rather than eyeballed on a page.
*/
internal fun formatRespawn(minSeconds: Int?, maxSeconds: Int?): String? {
val lo = minSeconds ?: maxSeconds ?: return null
val hi = maxSeconds ?: minSeconds ?: return null
return if (lo == hi) humaniseSeconds(lo) else "${humaniseSeconds(lo)}${humaniseSeconds(hi)}"
}
private fun humaniseSeconds(seconds: Int): String = when {
seconds < 60 -> "${seconds}s"
seconds % 60 == 0 -> "${seconds / 60}m"
else -> "${seconds / 60}m ${seconds % 60}s"
}

View File

@@ -7,6 +7,7 @@ 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
@@ -20,7 +21,6 @@ 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.ShardCard
import com.runicgateway.app.ui.components.StatusPill
/** The champion-spawn board (PLAN.md §6.2), live via SSE deltas. */
@@ -44,7 +44,7 @@ fun ChampsScreen(
@Composable
private fun ChampCard(champ: ChampDto) {
ShardCard(Modifier.fillMaxWidth()) {
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp)) {
Row(Modifier.fillMaxWidth()) {
Text(

View File

@@ -10,7 +10,7 @@ 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.toShardUiState
import com.runicgateway.app.ui.toUiState
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -49,7 +49,7 @@ class ChampsViewModel @Inject constructor(
board.seed(result.data)
publish()
}
else -> _state.value = result.toShardUiState()
else -> _state.value = result.toUiState()
}
}
}
@@ -69,9 +69,7 @@ class ChampsViewModel @Inject constructor(
private fun applyFrame(frame: ShardStreamEvent.Frame) {
when (frame.kind) {
"champ.update" -> repository.champFrame(frame.data)?.let { board.upsert(it) }
// Serial is an opaque hex-string key ("0x…"), not a number — read as a
// string (reading it as a Long silently dropped every champ.remove).
"champ.remove" -> FrameFields.stringField(frame.data, "serial")?.let { board.remove(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).

View File

@@ -12,6 +12,7 @@ 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
@@ -32,7 +33,6 @@ import com.runicgateway.app.ui.UiState
import com.runicgateway.app.ui.components.EmptyView
import com.runicgateway.app.ui.components.ErrorView
import com.runicgateway.app.ui.components.LoadingView
import com.runicgateway.app.ui.components.ShardCard
/** The town-governor board (PLAN.md §6.2), live via `city.update`, with per-city history. */
@Composable
@@ -84,7 +84,7 @@ private fun CityCard(
onExpand: () -> Unit,
) {
var expanded by remember { mutableStateOf(false) }
ShardCard(Modifier.fillMaxWidth()) {
Card(Modifier.fillMaxWidth()) {
Column {
Column(
Modifier

View File

@@ -11,7 +11,7 @@ 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.toShardUiState
import com.runicgateway.app.ui.toUiState
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -55,7 +55,7 @@ class GovernorsViewModel @Inject constructor(
board.seed(result.data)
publish()
}
else -> _state.value = result.toShardUiState()
else -> _state.value = result.toUiState()
}
}
}

View File

@@ -7,6 +7,7 @@ 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
@@ -19,7 +20,6 @@ import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.runicgateway.app.R
import com.runicgateway.app.data.api.dto.GuildDto
import com.runicgateway.app.ui.components.ShardCard
/** The guild board (PLAN.md §6.2), live via SSE deltas. */
@Composable
@@ -42,7 +42,7 @@ fun GuildsScreen(
@Composable
private fun GuildCard(guild: GuildDto) {
ShardCard(Modifier.fillMaxWidth()) {
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp)) {
Row(Modifier.fillMaxWidth()) {
Text(

View File

@@ -10,7 +10,7 @@ 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.toShardUiState
import com.runicgateway.app.ui.toUiState
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -50,7 +50,7 @@ class GuildsViewModel @Inject constructor(
board.seed(result.data)
publish()
}
else -> _state.value = result.toShardUiState()
else -> _state.value = result.toUiState()
}
}
}

View File

@@ -7,6 +7,7 @@ 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
@@ -20,7 +21,6 @@ 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.ShardCard
import com.runicgateway.app.ui.components.StatusPill
/** The public "falling houses" (IDOC) board (PLAN.md §6.2), live via `house.decay`. */
@@ -44,7 +44,7 @@ fun HousesScreen(
@Composable
private fun HouseCard(house: HouseDto) {
ShardCard(Modifier.fillMaxWidth()) {
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp)) {
Row(Modifier.fillMaxWidth()) {
Text(

View File

@@ -10,7 +10,7 @@ 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.toShardUiState
import com.runicgateway.app.ui.toUiState
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -50,7 +50,7 @@ class HousesViewModel @Inject constructor(
board.seed(result.data)
publish()
}
else -> _state.value = result.toShardUiState()
else -> _state.value = result.toUiState()
}
}
}
@@ -69,9 +69,7 @@ class HousesViewModel @Inject constructor(
private fun applyFrame(frame: ShardStreamEvent.Frame) {
if (frame.kind != "house.decay") return
// Serials are opaque hex-string keys ("0x…"), not numbers — read as a string
// (reading it as a Long silently dropped every live IDOC update).
val serial = FrameFields.stringField(frame.data, "serial") ?: 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")

View File

@@ -1,169 +0,0 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.shard
import androidx.compose.foundation.layout.Arrangement
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.HorizontalDivider
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.font.FontWeight
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.BrandDto
import com.runicgateway.app.data.api.dto.PointsBoardDto
import com.runicgateway.app.data.api.dto.PointsEntryDto
import com.runicgateway.app.ui.components.SectionLabel
import com.runicgateway.app.ui.components.ShardCard
/**
* The points/loyalty leaderboards (PLAN.md §9 M11), one card per system, live via
* `points.board` frames.
*/
@Composable
fun LeaderboardsScreen(
brand: BrandDto? = null,
modifier: Modifier = Modifier,
viewModel: LeaderboardsViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
val connected by viewModel.connected.collectAsStateWithLifecycle()
LiveBoardScreen(
emptyMessage = stringResource(R.string.leaderboards_empty),
state = state,
connected = connected,
onRetry = viewModel::load,
key = { it.system.orEmpty() },
modifier = modifier,
) { board -> BoardCard(board, placeholderName(brand)) }
}
@Composable
private fun BoardCard(board: PointsBoardDto, placeholderName: String) {
ShardCard(Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(2.dp)) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(
text = boardLabel(board),
style = MaterialTheme.typography.titleMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
board.players?.let {
Text(
text = stringResource(R.string.leaderboards_players, it),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
// A cap is worth stating only when there is one; most systems on a real
// shard are uncapped (maxPoints 0), and "/ 0" would be nonsense.
board.cap?.let {
SectionLabel(stringResource(R.string.leaderboards_cap, it))
}
if (board.top.isEmpty()) {
// A board nobody has scored on still gets a row, so the page reads as a
// set of standings waiting to be filled rather than a stack of blanks.
// It is deliberately NOT shaped like an entry — no rank, no score, the
// instance's own name — because a placeholder that looked like a real
// standing would be a fabricated one. The first real entry replaces it.
HorizontalDivider(Modifier.padding(vertical = 8.dp))
Row(
Modifier.fillMaxWidth().padding(vertical = 3.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = placeholderName,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
Text(
text = stringResource(R.string.leaderboards_no_score),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Text(
stringResource(R.string.leaderboards_board_empty),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 6.dp),
)
} else {
HorizontalDivider(Modifier.padding(vertical = 8.dp))
board.top.forEach { entry -> EntryRow(entry) }
}
}
}
}
@Composable
private fun EntryRow(entry: PointsEntryDto) {
Row(
Modifier.fillMaxWidth().padding(vertical = 3.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = stringResource(
R.string.leaderboards_rank_name,
entry.rank ?: 0,
// The character name is admin-configurable — a shard can publish
// standings without naming who holds them, so a nameless rank is a
// valid row rather than a broken one.
entry.name ?: stringResource(R.string.leaderboards_hidden_name),
),
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
Text(
text = (entry.points ?: 0L).toString(),
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
/**
* A board's display name: the shard's own literal when it has one, else the humanised
* `PointsType` key. The fallback is the PRIMARY path — four of five boards on a real
* shard name themselves with a cliloc and send `nameString: null`.
*/
internal fun boardLabel(board: PointsBoardDto): String {
board.nameString?.takeIf { it.isNotBlank() }?.let { return it }
return board.system.orEmpty()
.replace(Regex("([a-z0-9])([A-Z])"), "$1 $2")
.replaceFirstChar { it.uppercaseChar() }
}
/**
* The name to stand in for an empty board: this instance's, falling back to the app
* name — the same resolution the app bar uses, so a shard that publishes no branding
* still reads as *something* rather than as a blank row.
*
* Pure and separate so the fallback order is testable; [BrandDto.name] can be present
* but blank, which is a shard that set the key and left it empty.
*/
@Composable
internal fun placeholderName(brand: BrandDto?): String =
brand?.name?.takeIf { it.isNotBlank() } ?: stringResource(R.string.app_name)

View File

@@ -1,101 +0,0 @@
/*
* 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.PointsBoardDto
import com.runicgateway.app.data.repository.ShardRepository
import com.runicgateway.app.ui.UiState
import com.runicgateway.app.ui.toShardUiState
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 points/loyalty leaderboards (PLAN.md §9 M11, `docs/link/v3.md` §7): one board
* per point currency the shard publishes, each with its top ranks.
*
* Served from the website's own store, so the page renders while the shard is down —
* which matters more here than for live state: these are standings accumulated over
* months, and blanking them during a restart would look like data loss.
*
* Kept live by `points.board` frames, one per system, merged in place by [LiveBoard].
*/
@HiltViewModel
class LeaderboardsViewModel @Inject constructor(
private val repository: ShardRepository,
) : ViewModel() {
private val board = LiveBoard<PointsBoardDto> { it.system.orEmpty() }
private val _state = MutableStateFlow<UiState<List<PointsBoardDto>>>(UiState.Loading)
val state: StateFlow<UiState<List<PointsBoardDto>>> = _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.pointsBoards()) {
is ApiResult.Ok -> {
board.seed(result.data)
publish()
}
else -> _state.value = result.toShardUiState()
}
}
}
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) {
// There is deliberately no `points.remove` on the wire: the system set is fixed
// for a given shard build, the same argument `city.update` makes.
if (frame.kind != "points.board") return
repository.pointsBoardFrame(frame.data)?.let { board.upsert(it) }
if (_state.value is UiState.Success) publish()
}
private fun publish() {
_state.value = UiState.Success(orderBoards(board.values()))
}
}
/**
* Board display order: most-contested first, then by name, so the boards people
* actually compete on lead. Pure, so the ordering is unit-tested.
*
* Boards the shard flags as not player-facing (`showOnGump = false`) are dropped —
* that is the shard's own "is this for players?" signal and the plugin already filters
* on it, so this only guards a shard configured to publish extras.
*/
internal fun orderBoards(boards: Collection<PointsBoardDto>): List<PointsBoardDto> =
boards
.filter { it.showOnGump }
.sortedWith(
compareByDescending<PointsBoardDto> { it.players ?: 0 }
.thenBy { (it.nameString ?: it.system).orEmpty().lowercase() },
)

View File

@@ -1,260 +0,0 @@
/*
* 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.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
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.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
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.MarketListingDto
import com.runicgateway.app.data.api.dto.MarketLocationDto
import com.runicgateway.app.data.api.dto.MarketVendorDto
import com.runicgateway.app.ui.UiState
import com.runicgateway.app.ui.components.EmptyView
import com.runicgateway.app.ui.components.ErrorView
import com.runicgateway.app.ui.components.LoadingView
import com.runicgateway.app.ui.components.SectionLabel
import com.runicgateway.app.ui.components.ShardCard
/**
* The shard-wide marketplace (PLAN.md §9 M11): search every player vendor's stock.
*
* The staleness line under the search box is required, not decoration — see
* [MarketViewModel]. Results are listings, so a row names both the item and the shop
* that sells it, and tapping it opens that shop.
*/
@Composable
fun MarketScreen(
onOpenVendor: (String) -> Unit,
modifier: Modifier = Modifier,
viewModel: MarketViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
val meta by viewModel.meta.collectAsStateWithLifecycle()
val query by viewModel.query.collectAsStateWithLifecycle()
Column(modifier.fillMaxSize()) {
OutlinedTextField(
value = query,
onValueChange = viewModel::onQueryChange,
label = { Text(stringResource(R.string.market_search_label)) },
singleLine = true,
// Searched on submit rather than per keystroke: this is the site's first
// rate-limited public endpoint.
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
keyboardActions = KeyboardActions(onSearch = { viewModel.search() }),
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
)
meta?.staleAt?.let {
SectionLabel(
text = stringResource(R.string.market_staleness),
modifier = Modifier.padding(horizontal = 16.dp),
)
}
when (val s = state) {
is UiState.Loading -> LoadingView()
is UiState.Error -> ErrorView(s.kind, onRetry = viewModel::load)
is UiState.Success -> {
if (s.data.listings.isEmpty()) {
EmptyView(stringResource(R.string.market_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.listings, key = { it.serial ?: it.hashCode().toString() }) { listing ->
ListingCard(listing, onOpenVendor)
}
}
}
}
}
}
}
@Composable
private fun ListingCard(listing: MarketListingDto, onOpenVendor: (String) -> Unit) {
val vendorSerial = listing.vendor?.serial
ShardCard(
Modifier
.fillMaxWidth()
.then(if (vendorSerial != null) Modifier.clickable { onOpenVendor(vendorSerial) } else Modifier),
) {
Column(Modifier.padding(16.dp)) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(
text = listingTitle(listing)
?: stringResource(R.string.market_unnamed_item, listing.itemId ?: 0),
style = MaterialTheme.typography.titleSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
Text(
text = stringResource(R.string.market_price, listing.price ?: 0L),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Medium,
)
}
val shop = listing.vendor?.shopName ?: listing.vendor?.ownerName
if (shop != null) {
Text(
text = shop,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
locationLine(listing.vendor?.location)?.let {
Text(
text = it,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
/**
* One shop and its stock. The only surface that can answer the two questions a result
* list can't: how much of a truncated shop is published, and where a shop is when the
* shard doesn't say.
*/
@Composable
fun MarketVendorScreen(
serial: String,
modifier: Modifier = Modifier,
viewModel: MarketVendorViewModel = hiltViewModel(),
) {
androidx.compose.runtime.LaunchedEffect(serial) { viewModel.load(serial) }
val state by viewModel.state.collectAsStateWithLifecycle()
when (val s = state) {
is UiState.Loading -> LoadingView(modifier)
is UiState.Error -> ErrorView(s.kind, onRetry = viewModel::retry, modifier = modifier)
is UiState.Success -> VendorContent(s.data, modifier)
}
}
@Composable
private fun VendorContent(vendor: MarketVendorDto, modifier: Modifier = Modifier) {
LazyColumn(
modifier = modifier.fillMaxSize().padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
contentPadding = androidx.compose.foundation.layout.PaddingValues(vertical = 16.dp),
) {
item {
Column {
Text(
vendor.shopName ?: stringResource(R.string.market_unnamed_shop),
style = MaterialTheme.typography.titleLarge,
)
vendor.ownerName?.let {
Text(
stringResource(R.string.market_owner, it),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Text(
// A gated location is a real answer, not a blank: the shard has
// this shop, it just doesn't publish where it stands.
text = locationLine(vendor.location) ?: stringResource(R.string.market_location_hidden),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
if (vendor.truncated) {
Text(
text = stringResource(
R.string.market_truncated,
vendor.count ?: vendor.items.size,
vendor.total ?: 0,
),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 6.dp),
)
}
}
}
if (vendor.items.isEmpty()) {
item { Text(stringResource(R.string.market_shop_empty), style = MaterialTheme.typography.bodyMedium) }
} else {
items(vendor.items, key = { it.serial ?: it.hashCode().toString() }) { item ->
Row(Modifier.fillMaxWidth().padding(vertical = 4.dp), horizontalArrangement = Arrangement.SpaceBetween) {
Text(
text = listingTitle(item) ?: stringResource(R.string.market_unnamed_item, item.itemId ?: 0),
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
Text(
text = stringResource(R.string.market_price, item.price ?: 0L),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}
// ── Pure helpers (unit-tested) ───────────────────────────────────────────────
/**
* What to call a listing: a player-set name, else the server-resolved cliloc name,
* else null so the caller can fall back to the item id. A shard with no cliloc table
* configured legitimately publishes neither.
*
* A stack shows its count, since "12 × ingot" and "ingot" at the same price are very
* different offers.
*/
internal fun listingTitle(listing: MarketListingDto): String? {
val base = listing.label ?: return null
val amount = listing.amount ?: 1
return if (amount > 1) "$amount × $base" else base
}
/**
* A shop's whereabouts as one line, or null when the shard publishes no location —
* which happens both because an admin gated the field and because the nesting means
* the WHOLE block goes at once, never a half-populated one.
*/
internal fun locationLine(location: MarketLocationDto?): String? {
if (location == null) return null
val place = location.house ?: location.region
val facet = location.map
return when {
place != null && facet != null -> "$place, $facet"
place != null -> place
facet != null -> facet
else -> null
}
}

View File

@@ -1,118 +0,0 @@
/*
* 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.result.ApiResult
import com.runicgateway.app.data.api.dto.MarketMetaDto
import com.runicgateway.app.data.api.dto.MarketPageDto
import com.runicgateway.app.data.api.dto.MarketVendorDto
import com.runicgateway.app.data.repository.ShardRepository
import com.runicgateway.app.ui.UiState
import com.runicgateway.app.ui.toShardUiState
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 shard-wide player-vendor marketplace (PLAN.md §9 M11, `docs/link/v3.md` §8):
* search every shop's stock at once.
*
* **Not live, on purpose.** The `market` feature ships with its SSE fan-out disabled —
* a firehose of full vendor inventories would be the site's biggest bandwidth consumer
* and no screen needs it live — so this is a plain paginated read. It is also the
* first genuinely **rate-limited** public endpoint, which is why the query is applied
* on submit rather than on every keystroke.
*
* The staleness stamp from [meta] is not decoration: the shard sweeps vendors
* round-robin, so a listing can legitimately be a full cycle behind, and a screen that
* implied live prices would send someone to an item that sold twenty minutes ago.
*/
@HiltViewModel
class MarketViewModel @Inject constructor(
private val repository: ShardRepository,
) : ViewModel() {
private val _state = MutableStateFlow<UiState<MarketPageDto>>(UiState.Loading)
val state: StateFlow<UiState<MarketPageDto>> = _state.asStateFlow()
private val _meta = MutableStateFlow<MarketMetaDto?>(null)
val meta: StateFlow<MarketMetaDto?> = _meta.asStateFlow()
private val _query = MutableStateFlow("")
val query: StateFlow<String> = _query.asStateFlow()
private val _sort = MutableStateFlow(ShardRepository.SORT_PRICE_ASC)
val sort: StateFlow<String> = _sort.asStateFlow()
init {
load()
}
fun onQueryChange(value: String) {
// Bounded to what the server accepts, so an over-long query is trimmed here
// rather than bounced as a 400.
_query.value = value.take(MAX_QUERY)
}
fun onSortChange(value: String) {
if (value == _sort.value) return
_sort.value = value
search()
}
/** Run the current query. Called on submit, not per keystroke — this endpoint is rate-limited. */
fun search() = load()
fun load() {
_state.value = UiState.Loading
viewModelScope.launch {
// Meta is secondary: the staleness banner and filter options are worth
// having, but a failure there must not blank the results.
_meta.value = (repository.marketMeta() as? ApiResult.Ok)?.data
_state.value = repository.market(
query = _query.value,
sort = _sort.value,
).toShardUiState()
}
}
companion object {
/** The server rejects a longer `q`. */
const val MAX_QUERY = 60
}
}
/**
* One shop and its stock. The only surface that can render the two states a result
* list cannot: a [MarketVendorDto.truncated] shop, and a location an admin has gated
* away — which is a real answer ("the shard doesn't publish where this is") rather
* than an empty coordinate.
*/
@HiltViewModel
class MarketVendorViewModel @Inject constructor(
private val repository: ShardRepository,
) : ViewModel() {
private val _state = MutableStateFlow<UiState<MarketVendorDto>>(UiState.Loading)
val state: StateFlow<UiState<MarketVendorDto>> = _state.asStateFlow()
private var serial: String? = null
fun load(serial: String) {
this.serial = serial
_state.value = UiState.Loading
viewModelScope.launch {
_state.value = repository.marketVendor(serial).toShardUiState()
}
}
fun retry() {
serial?.let { load(it) }
}
}

View File

@@ -1,207 +0,0 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.shard
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
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.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.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.RulesetCapsDto
import com.runicgateway.app.data.api.dto.RulesetDto
import com.runicgateway.app.ui.UiState
import com.runicgateway.app.ui.components.EmptyView
import com.runicgateway.app.ui.components.ErrorView
import com.runicgateway.app.ui.components.LoadingView
import com.runicgateway.app.ui.components.PillTone
import com.runicgateway.app.ui.components.ShardCard
import com.runicgateway.app.ui.components.StatusPill
/**
* The shard ruleset (PLAN.md §9 M11): what this world is configured to do.
*
* Every block renders only when the shard published it — an omitted block means the
* system is off, not that the value is unknown, so an empty section would assert
* something false.
*/
@Composable
fun RulesScreen(
modifier: Modifier = Modifier,
viewModel: RulesViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
when (val s = state) {
is UiState.Loading -> LoadingView(modifier)
is UiState.Error -> ErrorView(s.kind, onRetry = viewModel::load, modifier = modifier)
is UiState.Success -> {
val ruleset = s.data
// A null body is a successful read of a shard that has never published its
// ruleset — distinct from the feature being switched off, which is an error
// state above.
if (ruleset == null) {
EmptyView(stringResource(R.string.rules_unpublished), modifier)
} else {
RulesetContent(ruleset, modifier)
}
}
}
}
@Composable
private fun RulesetContent(ruleset: RulesetDto, modifier: Modifier = Modifier) {
LazyColumn(
modifier = modifier.fillMaxSize().padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
contentPadding = androidx.compose.foundation.layout.PaddingValues(vertical = 16.dp),
) {
item {
RuleCard(stringResource(R.string.rules_section_shard)) {
RuleRow(stringResource(R.string.rules_name), ruleset.shard)
RuleRow(stringResource(R.string.rules_expansion), ruleset.expansion)
// `connect` is the ruleset's one admin-configurable field: an operator
// who published an address may still want it behind a login, so its
// absence here is a setting, not a missing value.
RuleRow(stringResource(R.string.rules_connect), ruleset.connect)
}
}
if (ruleset.systems.isNotEmpty()) {
item { SystemsCard(ruleset.systems) }
}
ruleset.caps?.let { caps -> item { CapsCard(caps) } }
item {
val accounts = ruleset.accounts
val housing = ruleset.housing
if (accounts != null || housing != null) {
RuleCard(stringResource(R.string.rules_section_accounts)) {
RuleRow(stringResource(R.string.rules_char_slots), accounts?.charSlots?.toString())
RuleRow(stringResource(R.string.rules_per_ip), accounts?.perIp?.toString())
RuleRow(
stringResource(R.string.rules_house_limit),
housing?.accountHouseLimit?.toString(),
)
}
}
}
ruleset.vendors?.let { vendors ->
item {
RuleCard(stringResource(R.string.rules_section_vendors)) {
RuleRow(
stringResource(R.string.rules_restock_delay),
vendors.restockDelayMinutes?.let { stringResource(R.string.rules_minutes, it) },
)
RuleRow(stringResource(R.string.rules_max_sell), vendors.maxSell?.toString())
}
}
}
ruleset.schedule?.let { schedule ->
item {
RuleCard(stringResource(R.string.rules_section_schedule)) {
RuleRow(
stringResource(R.string.rules_autosave),
schedule.autoSaveFrequencyMinutes?.let { stringResource(R.string.rules_minutes, it) },
)
RuleRow(
stringResource(R.string.rules_autorestart),
formatRestart(
schedule.autoRestartEnabled,
schedule.autoRestartHour,
schedule.autoRestartMinute,
),
)
}
}
}
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun SystemsCard(systems: Map<String, Boolean>) {
RuleCard(stringResource(R.string.rules_section_systems)) {
FlowRow(
Modifier.padding(top = 6.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
// Sorted so the list is stable across reloads; the wire order is a config
// read order and carries no meaning.
systems.entries.sortedBy { it.key }.forEach { (key, on) ->
StatusPill(
text = humaniseSystem(key),
tone = if (on) PillTone.Success else PillTone.Neutral,
)
}
}
}
}
@Composable
private fun CapsCard(caps: RulesetCapsDto) {
RuleCard(stringResource(R.string.rules_section_caps)) {
// Skill caps arrive in TENTHS (1000 = 100.0). Showing the raw number would read
// as a shard with ten times the usual limit.
RuleRow(stringResource(R.string.rules_skill_cap), caps.skillCap?.let { formatSkillCap(it) })
RuleRow(stringResource(R.string.rules_total_skill_cap), caps.totalSkillCap?.let { formatSkillCap(it) })
RuleRow(stringResource(R.string.rules_stat_cap), caps.stat?.toString())
RuleRow(stringResource(R.string.rules_str_cap), caps.str?.toString())
RuleRow(stringResource(R.string.rules_dex_cap), caps.dex?.toString())
RuleRow(stringResource(R.string.rules_int_cap), caps.int?.toString())
}
}
@Composable
private fun RuleCard(title: String, content: @Composable () -> Unit) {
ShardCard(Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp)) {
Text(title, style = MaterialTheme.typography.titleMedium)
content()
}
}
}
/** One label/value line. Renders nothing when the shard published no value. */
@Composable
private fun RuleRow(label: String, value: String?) {
if (value.isNullOrBlank()) return
Row(
Modifier.fillMaxWidth().padding(top = 6.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(label, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(value, style = MaterialTheme.typography.bodyMedium)
}
}
// ── Pure helpers (unit-tested) ───────────────────────────────────────────────
/** `cityLoyalty` → "City loyalty". The systems block is a flat bag of config keys. */
internal fun humaniseSystem(key: String): String = key
.replace(Regex("([a-z0-9])([A-Z])"), "$1 $2")
.replaceFirstChar { it.uppercaseChar() }
/** A skill cap already converted out of tenths: drop the ".0" on whole values. */
internal fun formatSkillCap(value: Double): String =
if (value % 1.0 == 0.0) value.toInt().toString() else "%.1f".format(value)
/** The auto-restart schedule, or null when the shard doesn't run one. */
internal fun formatRestart(enabled: Boolean?, hour: Int?, minute: Int?): String? {
if (enabled != true) return null
if (hour == null) return null
return "%02d:%02d".format(hour, minute ?: 0)
}

View File

@@ -1,63 +0,0 @@
/*
* 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.data.api.dto.RulesetDto
import com.runicgateway.app.data.repository.ShardRepository
import com.runicgateway.app.ui.UiState
import com.runicgateway.app.ui.toShardUiState
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 shard ruleset (PLAN.md §9 M11, `docs/link/v3.md` §5): what this world is
* configured to do — systems on/off, caps, account and housing limits, the champion
* and Felucca tables, the save/restart schedule.
*
* Two states the screen must tell apart, which is why the success type is nullable:
* a `null` body means the shard has **never published** a ruleset (the plugin is old,
* or `RulesetEnabled=false`), while the feature being switched off is a 404 folded
* into `ErrorKind.FEATURE_UNAVAILABLE`.
*
* Kept live by the `world.ruleset` frame, which the shard re-emits on every sidecar
* reconnect — so a shard that restarts with edited config updates the open screen.
*/
@HiltViewModel
class RulesViewModel @Inject constructor(
private val repository: ShardRepository,
) : ViewModel() {
private val _state = MutableStateFlow<UiState<RulesetDto?>>(UiState.Loading)
val state: StateFlow<UiState<RulesetDto?>> = _state.asStateFlow()
init {
load()
collectLive()
}
fun load() {
_state.value = UiState.Loading
viewModelScope.launch {
_state.value = repository.ruleset().toShardUiState()
}
}
private fun collectLive() {
viewModelScope.launch {
repository.liveEvents().collect { event ->
if (event !is ShardStreamEvent.Frame || event.kind != "world.ruleset") return@collect
// The frame IS the whole ruleset — replace rather than merge. A frame the
// app can't decode is skipped, leaving the loaded copy in place.
repository.rulesetFrame(event.data)?.let { _state.value = UiState.Success(it) }
}
}
}
}

View File

@@ -12,6 +12,7 @@ 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
@@ -31,34 +32,12 @@ 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.data.repository.ShardFeature
import com.runicgateway.app.data.repository.ShardFeatures
import com.runicgateway.app.data.repository.canSee
import com.runicgateway.app.ui.components.PillTone
import com.runicgateway.app.ui.components.SectionLabel
import com.runicgateway.app.ui.components.ShardCard
import com.runicgateway.app.ui.components.StatusPill
/**
* Board destinations reachable from the hub, each tagged with the visibility feature
* that governs it (M11). An admin can switch any of these off or raise its audience,
* so the hub's board list is filtered the same way the drawer is — a tile whose
* feature the caller can't see would only lead to a `404`/`403`.
*/
enum class ShardBoard(val feature: String) {
CHAMPS(ShardFeature.CHAMPS),
GUILDS(ShardFeature.GUILDS),
GOVERNORS(ShardFeature.GOVERNORS),
HOUSES(ShardFeature.HOUSES),
}
/**
* The boards this viewer may reach. Pure + side-effect-free so the gating is
* unit-tested without Compose, exactly like `visibleEntries` for the drawer. An
* unknown answer shows every board — the server gates regardless (see [canSee]).
*/
fun visibleBoards(features: ShardFeatures?): List<ShardBoard> =
ShardBoard.entries.filter { canSee(features, it.feature) }
/** Board destinations reachable from the hub. */
enum class ShardBoard { CHAMPS, GUILDS, GOVERNORS, HOUSES }
/**
* The Shard hub (PLAN.md §6.2): live connection status, online count + latest
@@ -75,7 +54,6 @@ fun ShardScreen(
val state by viewModel.state.collectAsStateWithLifecycle()
val feed by viewModel.feed.collectAsStateWithLifecycle()
val connected by viewModel.connected.collectAsStateWithLifecycle()
val features by viewModel.shardFeatures.collectAsStateWithLifecycle()
when (val s = state) {
is UiState.Loading -> LoadingView(modifier)
@@ -84,7 +62,6 @@ fun ShardScreen(
hub = s.data,
feed = feed,
connected = connected,
features = features,
onOpenBoard = onOpenBoard,
modifier = modifier,
)
@@ -96,7 +73,6 @@ private fun HubContent(
hub: ShardHub,
feed: List<FeedLine>,
connected: Boolean,
features: ShardFeatures?,
onOpenBoard: (ShardBoard) -> Unit,
modifier: Modifier = Modifier,
) {
@@ -106,7 +82,7 @@ private fun HubContent(
contentPadding = androidx.compose.foundation.layout.PaddingValues(vertical = 16.dp),
) {
item { StatusCard(hub.status, hub.presence?.count) }
item { BoardsCard(features, onOpenBoard) }
item { BoardsCard(onOpenBoard) }
if (hub.online.isNotEmpty()) {
item { SectionHeader(stringResource(R.string.shard_section_staff)) }
@@ -183,17 +159,14 @@ private fun StatusCard(status: ShardStatusDto, presenceCount: Int?) {
}
@Composable
private fun BoardsCard(features: ShardFeatures?, onOpenBoard: (ShardBoard) -> Unit) {
val boards = visibleBoards(features).map { board ->
board to when (board) {
ShardBoard.CHAMPS -> R.string.shard_nav_champs
ShardBoard.GUILDS -> R.string.shard_nav_guilds
ShardBoard.GOVERNORS -> R.string.shard_nav_governors
ShardBoard.HOUSES -> R.string.shard_nav_houses
}
}
if (boards.isEmpty()) return
ShardCard(Modifier.fillMaxWidth()) {
private fun BoardsCard(onOpenBoard: (ShardBoard) -> Unit) {
val boards = listOf(
ShardBoard.CHAMPS to R.string.shard_nav_champs,
ShardBoard.GUILDS to R.string.shard_nav_guilds,
ShardBoard.GOVERNORS to R.string.shard_nav_governors,
ShardBoard.HOUSES to R.string.shard_nav_houses,
)
Card(Modifier.fillMaxWidth()) {
Column {
boards.forEachIndexed { index, (board, labelRes) ->
Text(

Some files were not shown because too many files have changed in this diff Show More