Compare commits
41 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c55ee7f47e | |||
| 6cbfdb1e65 | |||
| b84a973559 | |||
| c14342aa51 | |||
| aeda919376 | |||
| 15a4d44c3f | |||
| fbe8b0bab6 | |||
| 94a5c26d6c | |||
| b95fc45548 | |||
| 3edd45d5f4 | |||
| 0051e97bc7 | |||
| a19fdd3582 | |||
| 7acbe54f46 | |||
| c7c49a9d6b | |||
| 1530c83fbc | |||
| c65913c62a | |||
| 17e9451494 | |||
| b0117acac1 | |||
| 5eaf5d22c6 | |||
| 12b2172731 | |||
| 4f85021be2 | |||
| 06b6b015c2 | |||
| aacef35def | |||
| 833e51de69 | |||
| 4fe7a7e2a3 | |||
| b10dd444b3 | |||
| f3da6ea618 | |||
| ae170670d9 | |||
| 7fc497a1a4 | |||
| 4e3bb914ff | |||
| efe14d3828 | |||
| 43215b49a0 | |||
| a6446b04d8 | |||
| 9c52a3dafa | |||
| f0a3b6c03e | |||
| 3aeb295342 | |||
| 03d4ef6fad | |||
| a1fa4901ef | |||
| befbc01670 | |||
| 1a14d47d5c | |||
| d6d966882b |
3
.gitattributes
vendored
3
.gitattributes
vendored
@@ -17,3 +17,6 @@ gradlew text eol=lf
|
|||||||
*.png binary
|
*.png binary
|
||||||
*.webp binary
|
*.webp binary
|
||||||
*.ico 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
|
||||||
|
|||||||
54
.gitea/scripts/gen_tree.py
Normal file
54
.gitea/scripts/gen_tree.py
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
#!/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()
|
||||||
@@ -18,11 +18,12 @@
|
|||||||
# SonarQube Quality Gate, so a failing gate does not fail this job — check the
|
# SonarQube Quality Gate, so a failing gate does not fail this job — check the
|
||||||
# dashboard when you want to.
|
# dashboard when you want to.
|
||||||
#
|
#
|
||||||
# Scope: this analyses the Kotlin source directly (the Sonar scanner reads
|
# Scope: the Sonar scanner reads sonar-project.properties and analyses the Kotlin
|
||||||
# sonar-project.properties). It does NOT run a Gradle build, so no Android SDK /
|
# source directly. Before the scan we run the JVM unit tests + JaCoCo so SonarQube
|
||||||
# JDK install is needed — the Kotlin analyzer is source-based. See the "Optional
|
# receives real coverage (sonar.coverage.jacoco.xmlReportPaths) — otherwise it
|
||||||
# enrichment" note in sonar-project.properties for wiring in Android Lint /
|
# reports 0% and the coverage gate fails despite the test suite existing. That
|
||||||
# coverage reports later.
|
# 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.
|
||||||
|
|
||||||
name: SonarQube
|
name: SonarQube
|
||||||
|
|
||||||
@@ -40,6 +41,16 @@ jobs:
|
|||||||
analysis:
|
analysis:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
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)
|
- name: Check out (full history for accurate new-code + blame)
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
@@ -47,6 +58,31 @@ jobs:
|
|||||||
# compute "new code". A shallow clone degrades both.
|
# compute "new code". A shallow clone degrades both.
|
||||||
fetch-depth: 0
|
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
|
- name: Run SonarQube scan
|
||||||
uses: sonarsource/sonarqube-scan-action@v4
|
uses: sonarsource/sonarqube-scan-action@v4
|
||||||
env:
|
env:
|
||||||
|
|||||||
111
.gitea/workflows/sync-project-tree.yml
Normal file
111
.gitea/workflows/sync-project-tree.yml
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
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
|
||||||
@@ -10,6 +10,11 @@ plugins {
|
|||||||
alias(libs.plugins.kotlin.serialization)
|
alias(libs.plugins.kotlin.serialization)
|
||||||
alias(libs.plugins.ksp)
|
alias(libs.plugins.ksp)
|
||||||
alias(libs.plugins.hilt)
|
alias(libs.plugins.hilt)
|
||||||
|
jacoco
|
||||||
|
}
|
||||||
|
|
||||||
|
jacoco {
|
||||||
|
toolVersion = "0.8.12"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Release signing material (PLAN.md §12) is never committed. It is read from, in
|
// Release signing material (PLAN.md §12) is never committed. It is read from, in
|
||||||
@@ -78,6 +83,11 @@ android {
|
|||||||
}
|
}
|
||||||
|
|
||||||
buildTypes {
|
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 {
|
release {
|
||||||
// R8 full-mode minify + resource shrink (§7: no offline cache, so a lean
|
// R8 full-mode minify + resource shrink (§7: no offline cache, so a lean
|
||||||
// release APK). Keep rules live in proguard-rules.pro.
|
// release APK). Keep rules live in proguard-rules.pro.
|
||||||
@@ -170,3 +180,35 @@ dependencies {
|
|||||||
androidTestImplementation(platform(libs.androidx.compose.bom))
|
androidTestImplementation(platform(libs.androidx.compose.bom))
|
||||||
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
|
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")
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
93
app/licenses/EBGaramond-OFL.txt
Normal file
93
app/licenses/EBGaramond-OFL.txt
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
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.
|
||||||
93
app/licenses/IMFellEnglish-OFL.txt
Normal file
93
app/licenses/IMFellEnglish-OFL.txt
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
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.
|
||||||
93
app/licenses/Inter-OFL.txt
Normal file
93
app/licenses/Inter-OFL.txt
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
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.
|
||||||
93
app/licenses/Merriweather-OFL.txt
Normal file
93
app/licenses/Merriweather-OFL.txt
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
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.
|
||||||
93
app/licenses/PlayfairDisplay-OFL.txt
Normal file
93
app/licenses/PlayfairDisplay-OFL.txt
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
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.
|
||||||
93
app/licenses/SourceSans3-OFL.txt
Normal file
93
app/licenses/SourceSans3-OFL.txt
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
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.
|
||||||
93
app/licenses/WorkSans-OFL.txt
Normal file
93
app/licenses/WorkSans-OFL.txt
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
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.
|
||||||
@@ -23,6 +23,7 @@ import androidx.compose.ui.Modifier
|
|||||||
import com.runicgateway.app.core.auth.sso.SsoAuthManager
|
import com.runicgateway.app.core.auth.sso.SsoAuthManager
|
||||||
import com.runicgateway.app.core.push.PushNotifier
|
import com.runicgateway.app.core.push.PushNotifier
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.compose.LifecycleResumeEffect
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import com.runicgateway.app.ui.AppViewModel
|
import com.runicgateway.app.ui.AppViewModel
|
||||||
import com.runicgateway.app.ui.AppViewModel.AppState
|
import com.runicgateway.app.ui.AppViewModel.AppState
|
||||||
@@ -30,8 +31,8 @@ import com.runicgateway.app.ui.LocalAssetResolver
|
|||||||
import com.runicgateway.app.ui.RunicApp
|
import com.runicgateway.app.ui.RunicApp
|
||||||
import com.runicgateway.app.ui.components.LoadingView
|
import com.runicgateway.app.ui.components.LoadingView
|
||||||
import com.runicgateway.app.ui.connect.ConnectScreen
|
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.RunicGatewayTheme
|
||||||
import com.runicgateway.app.ui.theme.parseBrandColor
|
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
@@ -39,8 +40,8 @@ import javax.inject.Inject
|
|||||||
/**
|
/**
|
||||||
* Single-activity host (PLAN.md §2). Gates on [AppViewModel]: the first-run
|
* 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.
|
* connect screen until a shard site is configured (§3), then the main app.
|
||||||
* The Material theme is seeded from the per-shard brand accent, and asset-path
|
* The Material theme is resolved from the shard's published appearance (M12),
|
||||||
* resolution is provided to the whole tree.
|
* and asset-path resolution is provided to the whole tree.
|
||||||
*/
|
*/
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
@@ -69,9 +70,21 @@ class MainActivity : ComponentActivity() {
|
|||||||
val appViewModel: AppViewModel = hiltViewModel()
|
val appViewModel: AppViewModel = hiltViewModel()
|
||||||
val state by appViewModel.state.collectAsStateWithLifecycle()
|
val state by appViewModel.state.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
val accent = (state as? AppState.Ready)?.brand?.let { parseBrandColor(it.accent) }
|
// 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
|
||||||
|
|
||||||
RunicGatewayTheme(accent = 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) {
|
||||||
CompositionLocalProvider(LocalAssetResolver provides appViewModel::resolveAsset) {
|
CompositionLocalProvider(LocalAssetResolver provides appViewModel::resolveAsset) {
|
||||||
Surface(
|
Surface(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
@@ -83,7 +96,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
ConnectScreen(onConnected = appViewModel::onConnected)
|
ConnectScreen(onConnected = appViewModel::onConnected)
|
||||||
is AppState.Ready ->
|
is AppState.Ready ->
|
||||||
RunicApp(
|
RunicApp(
|
||||||
brand = s.brand,
|
appearance = s.appearance,
|
||||||
onChangeServer = appViewModel::changeServer,
|
onChangeServer = appViewModel::changeServer,
|
||||||
deepLinkStream = pendingStream,
|
deepLinkStream = pendingStream,
|
||||||
onDeepLinkConsumed = { pendingStream = null },
|
onDeepLinkConsumed = { pendingStream = null },
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
/*
|
||||||
|
* 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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
/*
|
||||||
|
* 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
/*
|
||||||
|
* 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()
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ package com.runicgateway.app.core.auth.sso
|
|||||||
|
|
||||||
import com.runicgateway.app.BuildConfig
|
import com.runicgateway.app.BuildConfig
|
||||||
import com.runicgateway.app.core.auth.SessionManager
|
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.BaseUrlHolder
|
||||||
import com.runicgateway.app.data.api.SsoApi
|
import com.runicgateway.app.data.api.SsoApi
|
||||||
import com.runicgateway.app.data.api.dto.MobileSsoExchangeRequest
|
import com.runicgateway.app.data.api.dto.MobileSsoExchangeRequest
|
||||||
@@ -49,6 +50,7 @@ class SsoAuthManager @Inject constructor(
|
|||||||
private val sessionManager: SessionManager,
|
private val sessionManager: SessionManager,
|
||||||
private val baseUrlHolder: BaseUrlHolder,
|
private val baseUrlHolder: BaseUrlHolder,
|
||||||
private val pendingStore: PendingSsoStore,
|
private val pendingStore: PendingSsoStore,
|
||||||
|
private val trustTokenStore: TrustTokenStore,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
/** Why an SSO attempt ended, for a friendly inline message on the login screen. */
|
/** Why an SSO attempt ended, for a friendly inline message on the login screen. */
|
||||||
@@ -193,6 +195,14 @@ class SsoAuthManager @Inject constructor(
|
|||||||
_outcome.value = Outcome.Failed(Failure.SERVER)
|
_outcome.value = Outcome.Failed(Failure.SERVER)
|
||||||
return
|
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)
|
sessionManager.onSignedIn(body.accessToken, body.refreshToken, body.user)
|
||||||
_outcome.value = Outcome.Success
|
_outcome.value = Outcome.Success
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
/*
|
||||||
|
* 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>
|
||||||
|
}
|
||||||
@@ -40,7 +40,7 @@ class ShardStreamClient @Inject constructor(
|
|||||||
baseClient: OkHttpClient,
|
baseClient: OkHttpClient,
|
||||||
private val baseUrlHolder: BaseUrlHolder,
|
private val baseUrlHolder: BaseUrlHolder,
|
||||||
private val json: Json,
|
private val json: Json,
|
||||||
) {
|
) : ShardStream {
|
||||||
// SSE is a long-lived, mostly-idle connection (keepalive comments every ~25s),
|
// 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.
|
// so the read timeout must be disabled or the idle stream would be killed.
|
||||||
private val sseClient: OkHttpClient = baseClient.newBuilder()
|
private val sseClient: OkHttpClient = baseClient.newBuilder()
|
||||||
@@ -56,7 +56,7 @@ class ShardStreamClient @Inject constructor(
|
|||||||
* drive a live/offline indicator; [ShardStreamEvent.Frame] carries a decoded
|
* drive a live/offline indicator; [ShardStreamEvent.Frame] carries a decoded
|
||||||
* `{ kind, … }` payload the boards merge in place.
|
* `{ kind, … }` payload the boards merge in place.
|
||||||
*/
|
*/
|
||||||
fun events(): Flow<ShardStreamEvent> = channelFlow {
|
override fun events(): Flow<ShardStreamEvent> = channelFlow {
|
||||||
var backoffMs = INITIAL_BACKOFF_MS
|
var backoffMs = INITIAL_BACKOFF_MS
|
||||||
while (isActive) {
|
while (isActive) {
|
||||||
val url = baseUrlHolder.current?.resolve(STREAM_PATH)
|
val url = baseUrlHolder.current?.resolve(STREAM_PATH)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
package com.runicgateway.app.core.result
|
package com.runicgateway.app.core.result
|
||||||
|
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.serialization.SerializationException
|
||||||
import retrofit2.HttpException
|
import retrofit2.HttpException
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
|
|
||||||
@@ -37,6 +38,16 @@ 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].
|
* Run a suspending Retrofit call and normalize every outcome into an [ApiResult].
|
||||||
* Coroutine cancellation is rethrown so structured concurrency still works — it
|
* Coroutine cancellation is rethrown so structured concurrency still works — it
|
||||||
* is control flow, not a network failure.
|
* 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 {
|
suspend fun <T> safeApiCall(block: suspend () -> T): ApiResult<T> = try {
|
||||||
ApiResult.Ok(block())
|
ApiResult.Ok(block())
|
||||||
@@ -46,4 +57,9 @@ suspend fun <T> safeApiCall(block: suspend () -> T): ApiResult<T> = try {
|
|||||||
ApiResult.HttpError(e.code(), e.message())
|
ApiResult.HttpError(e.code(), e.message())
|
||||||
} catch (e: IOException) {
|
} catch (e: IOException) {
|
||||||
ApiResult.NetworkError(e)
|
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
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import com.runicgateway.app.data.api.dto.MobileTokenResponse
|
|||||||
import retrofit2.Response
|
import retrofit2.Response
|
||||||
import retrofit2.http.Body
|
import retrofit2.http.Body
|
||||||
import retrofit2.http.GET
|
import retrofit2.http.GET
|
||||||
|
import retrofit2.http.Header
|
||||||
import retrofit2.http.Headers
|
import retrofit2.http.Headers
|
||||||
import retrofit2.http.POST
|
import retrofit2.http.POST
|
||||||
|
|
||||||
@@ -27,9 +28,15 @@ import retrofit2.http.POST
|
|||||||
interface AuthApi {
|
interface AuthApi {
|
||||||
|
|
||||||
// Literal header value required by Retrofit @Headers; matches Http.NO_SESSION_HEADER.
|
// 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")
|
@Headers("X-Runic-No-Session: 1")
|
||||||
@POST("api/v1/auth/mobile/login")
|
@POST("api/v1/auth/mobile/login")
|
||||||
suspend fun login(@Body body: MobileLoginRequest): Response<MobileTokenResponse>
|
suspend fun login(
|
||||||
|
@Body body: MobileLoginRequest,
|
||||||
|
@Header("X-Trust-Token") trustToken: String? = null,
|
||||||
|
): Response<MobileTokenResponse>
|
||||||
|
|
||||||
@POST("api/v1/auth/mobile/logout")
|
@POST("api/v1/auth/mobile/logout")
|
||||||
suspend fun logout(@Body body: MobileLogoutRequest): Response<Unit>
|
suspend fun logout(@Body body: MobileLogoutRequest): Response<Unit>
|
||||||
|
|||||||
@@ -7,11 +7,21 @@ import com.runicgateway.app.data.api.dto.ChangePasswordRequest
|
|||||||
import com.runicgateway.app.data.api.dto.ChangeUsernameRequest
|
import com.runicgateway.app.data.api.dto.ChangeUsernameRequest
|
||||||
import com.runicgateway.app.data.api.dto.LinkedIdentityDto
|
import com.runicgateway.app.data.api.dto.LinkedIdentityDto
|
||||||
import com.runicgateway.app.data.api.dto.PlayerAccountDto
|
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.TotpCodeRequest
|
||||||
import com.runicgateway.app.data.api.dto.TotpSetupDto
|
import com.runicgateway.app.data.api.dto.TotpSetupDto
|
||||||
import com.runicgateway.app.data.api.dto.TotpStateDto
|
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 com.runicgateway.app.data.api.dto.UsernameResponse
|
||||||
|
import retrofit2.Response
|
||||||
import retrofit2.http.Body
|
import retrofit2.http.Body
|
||||||
|
import retrofit2.http.DELETE
|
||||||
import retrofit2.http.GET
|
import retrofit2.http.GET
|
||||||
import retrofit2.http.HTTP
|
import retrofit2.http.HTTP
|
||||||
import retrofit2.http.PATCH
|
import retrofit2.http.PATCH
|
||||||
@@ -52,4 +62,28 @@ interface MeApi {
|
|||||||
// path template explicit alongside the provider argument.
|
// path template explicit alongside the provider argument.
|
||||||
@HTTP(method = "DELETE", path = "api/v1/auth/me/account/identities/{provider}")
|
@HTTP(method = "DELETE", path = "api/v1/auth/me/account/identities/{provider}")
|
||||||
suspend fun unlinkIdentity(@Path("provider") provider: String): Unit
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,9 @@
|
|||||||
*/
|
*/
|
||||||
package com.runicgateway.app.data.api
|
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.ChampDto
|
||||||
import com.runicgateway.app.data.api.dto.ContactRequest
|
import com.runicgateway.app.data.api.dto.ContactRequest
|
||||||
import com.runicgateway.app.data.api.dto.ContactResponse
|
import com.runicgateway.app.data.api.dto.ContactResponse
|
||||||
@@ -12,11 +15,17 @@ import com.runicgateway.app.data.api.dto.GovernorDto
|
|||||||
import com.runicgateway.app.data.api.dto.GovernorTermDto
|
import com.runicgateway.app.data.api.dto.GovernorTermDto
|
||||||
import com.runicgateway.app.data.api.dto.GuildDto
|
import com.runicgateway.app.data.api.dto.GuildDto
|
||||||
import com.runicgateway.app.data.api.dto.HouseDto
|
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.OnlineStaffDto
|
||||||
import com.runicgateway.app.data.api.dto.PageDto
|
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.PostDto
|
||||||
import com.runicgateway.app.data.api.dto.PresenceDto
|
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.SettingsDto
|
||||||
|
import com.runicgateway.app.data.api.dto.ShardFeaturesDto
|
||||||
import com.runicgateway.app.data.api.dto.ShardStatusDto
|
import com.runicgateway.app.data.api.dto.ShardStatusDto
|
||||||
import com.runicgateway.app.data.api.dto.StatusDto
|
import com.runicgateway.app.data.api.dto.StatusDto
|
||||||
import com.runicgateway.app.data.api.dto.WikiCategoryDto
|
import com.runicgateway.app.data.api.dto.WikiCategoryDto
|
||||||
@@ -93,6 +102,14 @@ interface PublicApi {
|
|||||||
suspend fun postContact(@Body body: ContactRequest): ContactResponse
|
suspend fun postContact(@Body body: ContactRequest): ContactResponse
|
||||||
|
|
||||||
// ── Public shard widgets (§6.2) ──────────────────────────────────────
|
// ── 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")
|
@GET("api/v1/public/shard/status")
|
||||||
suspend fun getShardStatus(): ShardStatusDto
|
suspend fun getShardStatus(): ShardStatusDto
|
||||||
|
|
||||||
@@ -128,4 +145,65 @@ interface PublicApi {
|
|||||||
|
|
||||||
@GET("api/v1/public/shard/houses")
|
@GET("api/v1/public/shard/houses")
|
||||||
suspend fun getShardHouses(): List<HouseDto>
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,9 +55,78 @@ data class TotpSetupDto(
|
|||||||
@Serializable
|
@Serializable
|
||||||
data class TotpCodeRequest(val code: String)
|
data class TotpCodeRequest(val code: String)
|
||||||
|
|
||||||
/** Result of enabling/disabling 2FA. */
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
@Serializable
|
@Serializable
|
||||||
data class TotpStateDto(val totp_enabled: Boolean = false)
|
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())
|
||||||
|
|
||||||
/** A linked external identity (`GET /auth/me/account/identities`). */
|
/** A linked external identity (`GET /auth/me/account/identities`). */
|
||||||
@Serializable
|
@Serializable
|
||||||
|
|||||||
@@ -12,12 +12,23 @@ import kotlinx.serialization.Serializable
|
|||||||
* safe (§8, recorded for M1).
|
* safe (§8, recorded for M1).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** `POST /auth/mobile/login` body. [code] is only sent on the 2FA retry. */
|
/**
|
||||||
|
* `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).
|
||||||
|
*/
|
||||||
@Serializable
|
@Serializable
|
||||||
data class MobileLoginRequest(
|
data class MobileLoginRequest(
|
||||||
val username: String,
|
val username: String,
|
||||||
val password: String,
|
val password: String,
|
||||||
val code: String? = null,
|
val code: String? = null,
|
||||||
|
val recoveryCode: String? = null,
|
||||||
|
val trustDevice: Boolean? = null,
|
||||||
|
val device_name: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
/** `POST /auth/mobile/refresh` body. */
|
/** `POST /auth/mobile/refresh` body. */
|
||||||
@@ -34,6 +45,11 @@ data class MobileLogoutRequest(
|
|||||||
/**
|
/**
|
||||||
* Success payload from login and refresh: the token pair, the access lifetime
|
* 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.
|
* (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
|
@Serializable
|
||||||
data class MobileTokenResponse(
|
data class MobileTokenResponse(
|
||||||
@@ -41,6 +57,9 @@ data class MobileTokenResponse(
|
|||||||
val refreshToken: String,
|
val refreshToken: String,
|
||||||
val expiresIn: String? = null,
|
val expiresIn: String? = null,
|
||||||
val user: SafeUserDto,
|
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). */
|
/** The minimal, non-sensitive user the app needs to render + gate the menu (§5). */
|
||||||
|
|||||||
@@ -60,8 +60,15 @@ data class NotificationStreamsDto(
|
|||||||
* `GET/PUT /auth/me/notifications/subscriptions` — the user's opted-in stream ids.
|
* `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
|
* PUT replaces the full set; unknown ids are dropped server-side and the stored set
|
||||||
* echoed back.
|
* 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
|
@Serializable
|
||||||
data class NotificationSubscriptionsDto(
|
data class NotificationSubscriptionsDto(
|
||||||
val streams: List<String> = emptyList(),
|
val streams: List<String>,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ import kotlinx.serialization.json.JsonObject
|
|||||||
* `CharacterSheet.jsx` / `GameAccounts.jsx` and `docs/link/INTEGRATION.md` §5).
|
* `CharacterSheet.jsx` / `GameAccounts.jsx` and `docs/link/INTEGRATION.md` §5).
|
||||||
* Presentation is text-only for v1 (no item icons / paperdoll).
|
* Presentation is text-only for v1 (no item icons / paperdoll).
|
||||||
*
|
*
|
||||||
* In-game serials are hex strings (e.g. "0x24C"), unlike the numeric serials on
|
* In-game serials are hex strings (e.g. "0x24C"), the same opaque-key form used on
|
||||||
* the public boards — these are separate endpoints with separate shapes.
|
* the public boards (`ShardDto.ActorDto`/`ChampDto`/`HouseDto`) — never numbers.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// ── Game-account linking ─────────────────────────────────────────────────────
|
// ── Game-account linking ─────────────────────────────────────────────────────
|
||||||
@@ -83,8 +83,47 @@ data class CharProfileDto(
|
|||||||
val titles: TitlesDto? = null,
|
val titles: TitlesDto? = null,
|
||||||
val guild: GuildRefDto? = null,
|
val guild: GuildRefDto? = null,
|
||||||
val governorOf: List<String> = emptyList(),
|
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
|
@Serializable
|
||||||
data class CharStatsDto(
|
data class CharStatsDto(
|
||||||
val str: Int? = null,
|
val str: Int? = null,
|
||||||
@@ -134,17 +173,45 @@ data class EquipmentDto(
|
|||||||
val itemId: Int? = null,
|
val itemId: Int? = null,
|
||||||
val hue: Int? = null,
|
val hue: Int? = null,
|
||||||
val mods: JsonObject? = 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
|
* 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
|
* shown (-1 if none); [reward] entries may be a cliloc number-as-string or a literal.
|
||||||
* literal — numeric ones are skipped without a cliloc table (as the website does).
|
*
|
||||||
|
* [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.
|
||||||
*/
|
*/
|
||||||
@Serializable
|
@Serializable
|
||||||
data class TitlesDto(
|
data class TitlesDto(
|
||||||
val selected: Int? = null,
|
val selected: Int? = null,
|
||||||
val reward: List<String> = emptyList(),
|
val reward: List<String> = emptyList(),
|
||||||
|
val rewardResolved: List<String?> = emptyList(),
|
||||||
val fameKarma: String? = null,
|
val fameKarma: String? = null,
|
||||||
val skill: String? = null,
|
val skill: String? = null,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ package com.runicgateway.app.data.api.dto
|
|||||||
|
|
||||||
import kotlinx.serialization.SerialName
|
import kotlinx.serialization.SerialName
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.json.JsonElement
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DTOs for the public site/identity endpoints. Shapes mirror the backend
|
* DTOs for the public site/identity endpoints. Shapes mirror the backend
|
||||||
@@ -80,4 +81,28 @@ data class SettingsDto(
|
|||||||
val brand: BrandDto = BrandDto(),
|
val brand: BrandDto = BrandDto(),
|
||||||
/** Push relay config (M7); default (null ntfyUrl) on a backend that predates it. */
|
/** Push relay config (M7); default (null ntfyUrl) on a backend that predates it. */
|
||||||
val push: PushConfigDto = PushConfigDto(),
|
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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,367 @@
|
|||||||
|
/*
|
||||||
|
* 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(),
|
||||||
|
)
|
||||||
@@ -15,13 +15,43 @@ import kotlinx.serialization.json.JsonObject
|
|||||||
* `*.update` frames on `/public/shard/stream` decode into these same DTOs.
|
* `*.update` frames on `/public/shard/stream` decode into these same DTOs.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** A game actor (player/leader/governor) as embedded in board payloads. */
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
@Serializable
|
@Serializable
|
||||||
data class ActorDto(
|
data class ActorDto(
|
||||||
val serial: Long? = null,
|
val serial: String? = null,
|
||||||
val name: String? = null,
|
val name: String? = null,
|
||||||
val acct: String? = null,
|
val acct: String? = null,
|
||||||
val webId: Long? = null,
|
val webId: String? = null,
|
||||||
) {
|
) {
|
||||||
/** Best display label for this actor. */
|
/** Best display label for this actor. */
|
||||||
val label: String get() = name ?: acct ?: "Someone"
|
val label: String get() = name ?: acct ?: "Someone"
|
||||||
@@ -73,7 +103,7 @@ data class FeedEventDto(
|
|||||||
*/
|
*/
|
||||||
@Serializable
|
@Serializable
|
||||||
data class OnlineStaffDto(
|
data class OnlineStaffDto(
|
||||||
val serial: Long? = null,
|
val serial: String? = null,
|
||||||
val name: String? = null,
|
val name: String? = null,
|
||||||
val map: String? = null,
|
val map: String? = null,
|
||||||
val x: Int? = null,
|
val x: Int? = null,
|
||||||
@@ -87,7 +117,7 @@ data class OnlineStaffDto(
|
|||||||
*/
|
*/
|
||||||
@Serializable
|
@Serializable
|
||||||
data class HouseDto(
|
data class HouseDto(
|
||||||
val serial: Long = 0,
|
val serial: String = "",
|
||||||
val name: String? = null,
|
val name: String? = null,
|
||||||
val region: String? = null,
|
val region: String? = null,
|
||||||
val map: String? = null,
|
val map: String? = null,
|
||||||
@@ -104,7 +134,7 @@ data class HouseDto(
|
|||||||
*/
|
*/
|
||||||
@Serializable
|
@Serializable
|
||||||
data class ChampDto(
|
data class ChampDto(
|
||||||
val serial: Long = 0,
|
val serial: String = "",
|
||||||
val category: String? = null,
|
val category: String? = null,
|
||||||
val type: String? = null,
|
val type: String? = null,
|
||||||
val name: String? = null,
|
val name: String? = null,
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
/*
|
||||||
|
* 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
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
/*
|
||||||
|
* 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,10 +10,19 @@ import com.runicgateway.app.data.api.dto.ChangePasswordRequest
|
|||||||
import com.runicgateway.app.data.api.dto.ChangeUsernameRequest
|
import com.runicgateway.app.data.api.dto.ChangeUsernameRequest
|
||||||
import com.runicgateway.app.data.api.dto.LinkedIdentityDto
|
import com.runicgateway.app.data.api.dto.LinkedIdentityDto
|
||||||
import com.runicgateway.app.data.api.dto.PlayerAccountDto
|
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.TotpCodeRequest
|
||||||
import com.runicgateway.app.data.api.dto.TotpSetupDto
|
import com.runicgateway.app.data.api.dto.TotpSetupDto
|
||||||
import com.runicgateway.app.data.api.dto.TotpStateDto
|
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 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.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
@@ -26,6 +35,7 @@ import javax.inject.Singleton
|
|||||||
@Singleton
|
@Singleton
|
||||||
class AccountRepository @Inject constructor(
|
class AccountRepository @Inject constructor(
|
||||||
private val api: MeApi,
|
private val api: MeApi,
|
||||||
|
private val json: Json,
|
||||||
) {
|
) {
|
||||||
suspend fun getAccount(): ApiResult<PlayerAccountDto> = safeApiCall { api.getAccount() }
|
suspend fun getAccount(): ApiResult<PlayerAccountDto> = safeApiCall { api.getAccount() }
|
||||||
|
|
||||||
@@ -48,4 +58,63 @@ class AccountRepository @Inject constructor(
|
|||||||
|
|
||||||
suspend fun unlinkIdentity(provider: String): ApiResult<Unit> =
|
suspend fun unlinkIdentity(provider: String): ApiResult<Unit> =
|
||||||
safeApiCall { api.unlinkIdentity(provider) }
|
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)) }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,9 @@
|
|||||||
*/
|
*/
|
||||||
package com.runicgateway.app.data.repository
|
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.SessionManager
|
||||||
|
import com.runicgateway.app.core.auth.TrustTokenStore
|
||||||
import com.runicgateway.app.core.push.PushManager
|
import com.runicgateway.app.core.push.PushManager
|
||||||
import com.runicgateway.app.data.api.AuthApi
|
import com.runicgateway.app.data.api.AuthApi
|
||||||
import com.runicgateway.app.data.api.SsoApi
|
import com.runicgateway.app.data.api.SsoApi
|
||||||
@@ -12,6 +14,7 @@ import com.runicgateway.app.data.api.dto.MobileLogoutRequest
|
|||||||
import com.runicgateway.app.data.api.dto.MobileTokenResponse
|
import com.runicgateway.app.data.api.dto.MobileTokenResponse
|
||||||
import com.runicgateway.app.data.api.dto.SsoProviderDto
|
import com.runicgateway.app.data.api.dto.SsoProviderDto
|
||||||
import com.runicgateway.app.data.api.dto.TotpRequiredError
|
import com.runicgateway.app.data.api.dto.TotpRequiredError
|
||||||
|
import com.runicgateway.app.data.api.dto.TrustedDeviceDto
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
@@ -32,6 +35,8 @@ class AuthRepository @Inject constructor(
|
|||||||
private val ssoApi: SsoApi,
|
private val ssoApi: SsoApi,
|
||||||
private val sessionManager: SessionManager,
|
private val sessionManager: SessionManager,
|
||||||
private val pushManager: PushManager,
|
private val pushManager: PushManager,
|
||||||
|
private val trustTokenStore: TrustTokenStore,
|
||||||
|
private val deviceNameProvider: DeviceNameProvider,
|
||||||
private val json: Json,
|
private val json: Json,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
@@ -73,7 +78,15 @@ class AuthRepository @Inject constructor(
|
|||||||
|
|
||||||
/** Outcome of a login attempt (§4.1). */
|
/** Outcome of a login attempt (§4.1). */
|
||||||
sealed interface LoginResult {
|
sealed interface LoginResult {
|
||||||
data object Success : 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
|
||||||
|
|
||||||
/** The account has 2FA on — reveal the code field and resubmit with a code. */
|
/** The account has 2FA on — reveal the code field and resubmit with a code. */
|
||||||
data object TotpRequired : LoginResult
|
data object TotpRequired : LoginResult
|
||||||
@@ -89,9 +102,32 @@ class AuthRepository @Inject constructor(
|
|||||||
data object NetworkError : LoginResult
|
data object NetworkError : LoginResult
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun login(username: String, password: String, code: String? = null): 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)
|
||||||
val response: Response<MobileTokenResponse> = try {
|
val response: Response<MobileTokenResponse> = try {
|
||||||
authApi.login(MobileLoginRequest(username = username, password = password, code = code))
|
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,
|
||||||
|
)
|
||||||
} catch (e: CancellationException) {
|
} catch (e: CancellationException) {
|
||||||
throw e
|
throw e
|
||||||
} catch (_: IOException) {
|
} catch (_: IOException) {
|
||||||
@@ -100,8 +136,14 @@ class AuthRepository @Inject constructor(
|
|||||||
|
|
||||||
if (response.isSuccessful) {
|
if (response.isSuccessful) {
|
||||||
val body = response.body() ?: return LoginResult.ServerError
|
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)
|
sessionManager.onSignedIn(body.accessToken, body.refreshToken, body.user)
|
||||||
return LoginResult.Success
|
return LoginResult.Success(
|
||||||
|
trustLimitReached = body.trustLimitReached,
|
||||||
|
devices = body.devices,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return when (response.code()) {
|
return when (response.code()) {
|
||||||
@@ -111,6 +153,20 @@ 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
|
* 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
|
* tokens (§4.3). Best-effort: the local session is torn down even if the
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
package com.runicgateway.app.data.repository
|
package com.runicgateway.app.data.repository
|
||||||
|
|
||||||
import com.runicgateway.app.core.auth.SessionManager
|
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.BaseUrlHolder
|
||||||
import com.runicgateway.app.core.net.ServerUrl
|
import com.runicgateway.app.core.net.ServerUrl
|
||||||
import com.runicgateway.app.core.prefs.ServerPreferences
|
import com.runicgateway.app.core.prefs.ServerPreferences
|
||||||
@@ -26,6 +27,8 @@ class ConnectionRepository @Inject constructor(
|
|||||||
private val prefs: ServerPreferences,
|
private val prefs: ServerPreferences,
|
||||||
private val baseUrlHolder: BaseUrlHolder,
|
private val baseUrlHolder: BaseUrlHolder,
|
||||||
private val sessionManager: SessionManager,
|
private val sessionManager: SessionManager,
|
||||||
|
private val trustTokenStore: TrustTokenStore,
|
||||||
|
private val shardFeaturesRepository: ShardFeaturesRepository,
|
||||||
private val pushManager: com.runicgateway.app.core.push.PushManager,
|
private val pushManager: com.runicgateway.app.core.push.PushManager,
|
||||||
private val config: com.runicgateway.app.core.AppConfig,
|
private val config: com.runicgateway.app.core.AppConfig,
|
||||||
) {
|
) {
|
||||||
@@ -106,6 +109,13 @@ class ConnectionRepository @Inject constructor(
|
|||||||
}
|
}
|
||||||
pushManager.setNtfyUrl(null)
|
pushManager.setNtfyUrl(null)
|
||||||
sessionManager.onSignedOut()
|
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()
|
prefs.clear()
|
||||||
baseUrlHolder.set(null)
|
baseUrlHolder.set(null)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
/*
|
||||||
|
* 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"
|
||||||
|
}
|
||||||
@@ -3,11 +3,13 @@
|
|||||||
*/
|
*/
|
||||||
package com.runicgateway.app.data.repository
|
package com.runicgateway.app.data.repository
|
||||||
|
|
||||||
import com.runicgateway.app.core.net.ShardStreamClient
|
import com.runicgateway.app.core.net.ShardStream
|
||||||
import com.runicgateway.app.core.net.ShardStreamEvent
|
import com.runicgateway.app.core.net.ShardStreamEvent
|
||||||
import com.runicgateway.app.core.result.ApiResult
|
import com.runicgateway.app.core.result.ApiResult
|
||||||
import com.runicgateway.app.core.result.safeApiCall
|
import com.runicgateway.app.core.result.safeApiCall
|
||||||
import com.runicgateway.app.data.api.PublicApi
|
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.ChampDto
|
||||||
import com.runicgateway.app.data.api.dto.EconomySampleDto
|
import com.runicgateway.app.data.api.dto.EconomySampleDto
|
||||||
import com.runicgateway.app.data.api.dto.FeedEventDto
|
import com.runicgateway.app.data.api.dto.FeedEventDto
|
||||||
@@ -15,8 +17,13 @@ import com.runicgateway.app.data.api.dto.GovernorDto
|
|||||||
import com.runicgateway.app.data.api.dto.GovernorTermDto
|
import com.runicgateway.app.data.api.dto.GovernorTermDto
|
||||||
import com.runicgateway.app.data.api.dto.GuildDto
|
import com.runicgateway.app.data.api.dto.GuildDto
|
||||||
import com.runicgateway.app.data.api.dto.HouseDto
|
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.OnlineStaffDto
|
||||||
|
import com.runicgateway.app.data.api.dto.PointsBoardDto
|
||||||
import com.runicgateway.app.data.api.dto.PresenceDto
|
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 com.runicgateway.app.data.api.dto.ShardStatusDto
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.serialization.KSerializer
|
import kotlinx.serialization.KSerializer
|
||||||
@@ -35,7 +42,7 @@ import javax.inject.Singleton
|
|||||||
@Singleton
|
@Singleton
|
||||||
class ShardRepository @Inject constructor(
|
class ShardRepository @Inject constructor(
|
||||||
private val api: PublicApi,
|
private val api: PublicApi,
|
||||||
private val stream: ShardStreamClient,
|
private val stream: ShardStream,
|
||||||
private val json: Json,
|
private val json: Json,
|
||||||
) {
|
) {
|
||||||
// ── Snapshots ────────────────────────────────────────────────────────
|
// ── Snapshots ────────────────────────────────────────────────────────
|
||||||
@@ -62,6 +69,59 @@ class ShardRepository @Inject constructor(
|
|||||||
|
|
||||||
suspend fun houses(): ApiResult<List<HouseDto>> = safeApiCall { api.getShardHouses() }
|
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 ──────────────────────────────────────────────────────
|
// ── Live stream ──────────────────────────────────────────────────────
|
||||||
/** The shared public SSE feed (safe kinds only), reconnecting with backoff (§7). */
|
/** The shared public SSE feed (safe kinds only), reconnecting with backoff (§7). */
|
||||||
fun liveEvents(): Flow<ShardStreamEvent> = stream.events()
|
fun liveEvents(): Flow<ShardStreamEvent> = stream.events()
|
||||||
@@ -73,9 +133,27 @@ class ShardRepository @Inject constructor(
|
|||||||
fun governorFrame(obj: JsonObject): GovernorDto? = decode(obj, GovernorDto.serializer())
|
fun governorFrame(obj: JsonObject): GovernorDto? = decode(obj, GovernorDto.serializer())
|
||||||
fun presenceFrame(obj: JsonObject): PresenceDto? = decode(obj, PresenceDto.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 {
|
private fun <T> decode(obj: JsonObject, serializer: KSerializer<T>): T? = try {
|
||||||
json.decodeFromJsonElement(serializer, obj)
|
json.decodeFromJsonElement(serializer, obj)
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
null
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import com.runicgateway.app.BuildConfig
|
|||||||
import com.runicgateway.app.core.net.AuthInterceptor
|
import com.runicgateway.app.core.net.AuthInterceptor
|
||||||
import com.runicgateway.app.core.net.BaseUrlHolder
|
import com.runicgateway.app.core.net.BaseUrlHolder
|
||||||
import com.runicgateway.app.core.net.HostSelectionInterceptor
|
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.TokenAuthenticator
|
||||||
import com.runicgateway.app.core.net.UserAgentInterceptor
|
import com.runicgateway.app.core.net.UserAgentInterceptor
|
||||||
import com.runicgateway.app.data.api.AuthApi
|
import com.runicgateway.app.data.api.AuthApi
|
||||||
@@ -94,6 +96,12 @@ object NetworkModule {
|
|||||||
@Singleton
|
@Singleton
|
||||||
fun providePublicApi(retrofit: Retrofit): PublicApi = retrofit.create(PublicApi::class.java)
|
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
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
fun provideAuthApi(retrofit: Retrofit): AuthApi = retrofit.create(AuthApi::class.java)
|
fun provideAuthApi(retrofit: Retrofit): AuthApi = retrofit.create(AuthApi::class.java)
|
||||||
|
|||||||
@@ -3,8 +3,12 @@
|
|||||||
*/
|
*/
|
||||||
package com.runicgateway.app.di
|
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.EncryptedTokenStore
|
||||||
|
import com.runicgateway.app.core.auth.EncryptedTrustTokenStore
|
||||||
import com.runicgateway.app.core.auth.TokenStore
|
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.EncryptedPendingSsoStore
|
||||||
import com.runicgateway.app.core.auth.sso.PendingSsoStore
|
import com.runicgateway.app.core.auth.sso.PendingSsoStore
|
||||||
import dagger.Binds
|
import dagger.Binds
|
||||||
@@ -25,4 +29,13 @@ abstract class StorageModule {
|
|||||||
@Binds
|
@Binds
|
||||||
@Singleton
|
@Singleton
|
||||||
abstract fun bindPendingSsoStore(impl: EncryptedPendingSsoStore): PendingSsoStore
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import androidx.lifecycle.viewModelScope
|
|||||||
import com.runicgateway.app.core.net.BaseUrlHolder
|
import com.runicgateway.app.core.net.BaseUrlHolder
|
||||||
import com.runicgateway.app.core.push.PushManager
|
import com.runicgateway.app.core.push.PushManager
|
||||||
import com.runicgateway.app.core.result.ApiResult
|
import com.runicgateway.app.core.result.ApiResult
|
||||||
import com.runicgateway.app.data.api.dto.BrandDto
|
import com.runicgateway.app.data.appearance.SiteAppearance
|
||||||
import com.runicgateway.app.data.repository.ConnectionRepository
|
import com.runicgateway.app.data.repository.ConnectionRepository
|
||||||
import com.runicgateway.app.data.repository.SettingsRepository
|
import com.runicgateway.app.data.repository.SettingsRepository
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
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
|
* Top-level app gate (PLAN.md §3): decides whether the first-run connect screen
|
||||||
* or the main UI shows, and holds the per-shard branding the theme is seeded
|
* or the main UI shows, and holds the per-shard [SiteAppearance] the theme and
|
||||||
* from. Activity-scoped so the whole app observes one state.
|
* the drawer are built from. Activity-scoped so the whole app observes one state.
|
||||||
*/
|
*/
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class AppViewModel @Inject constructor(
|
class AppViewModel @Inject constructor(
|
||||||
@@ -38,8 +38,11 @@ class AppViewModel @Inject constructor(
|
|||||||
/** No shard site configured yet — show the connect screen. */
|
/** No shard site configured yet — show the connect screen. */
|
||||||
data object NeedsConnection : AppState
|
data object NeedsConnection : AppState
|
||||||
|
|
||||||
/** A site is configured; [brand] is null if branding couldn't be loaded (still usable). */
|
/**
|
||||||
data class Ready(val brand: BrandDto?) : AppState
|
* 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
|
||||||
}
|
}
|
||||||
|
|
||||||
private val _state = MutableStateFlow<AppState>(AppState.Loading)
|
private val _state = MutableStateFlow<AppState>(AppState.Loading)
|
||||||
@@ -48,7 +51,7 @@ class AppViewModel @Inject constructor(
|
|||||||
init {
|
init {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_state.value = if (connectionRepository.restore()) {
|
_state.value = if (connectionRepository.restore()) {
|
||||||
AppState.Ready(loadBrand())
|
AppState.Ready(loadAppearance())
|
||||||
} else {
|
} else {
|
||||||
AppState.NeedsConnection
|
AppState.NeedsConnection
|
||||||
}
|
}
|
||||||
@@ -57,7 +60,30 @@ class AppViewModel @Inject constructor(
|
|||||||
|
|
||||||
/** Called by the connect screen once a site has been validated + saved. */
|
/** Called by the connect screen once a site has been validated + saved. */
|
||||||
fun onConnected() {
|
fun onConnected() {
|
||||||
viewModelScope.launch { _state.value = AppState.Ready(loadBrand()) }
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Settings → Server switch: hard reset back to the connect screen (§3). */
|
/** Settings → Server switch: hard reset back to the connect screen (§3). */
|
||||||
@@ -69,14 +95,14 @@ class AppViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load public settings for branding and feed the shard's push relay URL into the
|
* Load public settings for the appearance and feed the shard's push relay URL into
|
||||||
* [PushManager] (§11) — its arrival is what lets push re-register after a restart
|
* 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).
|
* or sign-in. Returns [SiteAppearance.NONE] if settings couldn't be loaded.
|
||||||
*/
|
*/
|
||||||
private suspend fun loadBrand(): BrandDto? {
|
private suspend fun loadAppearance(): SiteAppearance {
|
||||||
val settings = (settingsRepository.getSettings() as? ApiResult.Ok)?.data
|
val settings = (settingsRepository.getSettings() as? ApiResult.Ok)?.data
|
||||||
pushManager.setNtfyUrl(settings?.push?.ntfyUrl)
|
pushManager.setNtfyUrl(settings?.push?.ntfyUrl)
|
||||||
return settings?.brand
|
return SiteAppearance.from(settings)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -7,10 +7,12 @@ import androidx.compose.foundation.layout.Column
|
|||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
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.material.icons.filled.Menu
|
||||||
import androidx.compose.material3.DrawerValue
|
import androidx.compose.material3.DrawerValue
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
@@ -21,6 +23,7 @@ import androidx.compose.material3.MaterialTheme
|
|||||||
import androidx.compose.material3.ModalDrawerSheet
|
import androidx.compose.material3.ModalDrawerSheet
|
||||||
import androidx.compose.material3.ModalNavigationDrawer
|
import androidx.compose.material3.ModalNavigationDrawer
|
||||||
import androidx.compose.material3.NavigationDrawerItem
|
import androidx.compose.material3.NavigationDrawerItem
|
||||||
|
import androidx.compose.material3.NavigationDrawerItemColors
|
||||||
import androidx.compose.material3.NavigationDrawerItemDefaults
|
import androidx.compose.material3.NavigationDrawerItemDefaults
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
@@ -32,6 +35,7 @@ import androidx.compose.runtime.LaunchedEffect
|
|||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
@@ -48,15 +52,23 @@ import androidx.navigation.compose.rememberNavController
|
|||||||
import androidx.navigation.navArgument
|
import androidx.navigation.navArgument
|
||||||
import com.runicgateway.app.R
|
import com.runicgateway.app.R
|
||||||
import com.runicgateway.app.core.auth.Session
|
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.api.dto.BrandDto
|
||||||
|
import com.runicgateway.app.data.appearance.SiteAppearance
|
||||||
import com.runicgateway.app.ui.auth.AccountScreen
|
import com.runicgateway.app.ui.auth.AccountScreen
|
||||||
import com.runicgateway.app.ui.auth.LoginScreen
|
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.auth.roleLabelRes
|
||||||
|
import com.runicgateway.app.ui.components.BrandLogo
|
||||||
import com.runicgateway.app.ui.contact.ContactScreen
|
import com.runicgateway.app.ui.contact.ContactScreen
|
||||||
import com.runicgateway.app.ui.home.HomeScreen
|
import com.runicgateway.app.ui.home.HomeScreen
|
||||||
import com.runicgateway.app.ui.navigation.APP_MENU
|
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.Routes
|
||||||
import com.runicgateway.app.ui.navigation.visibleEntries
|
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.news.NewsScreen
|
import com.runicgateway.app.ui.news.NewsScreen
|
||||||
import com.runicgateway.app.ui.news.PostScreen
|
import com.runicgateway.app.ui.news.PostScreen
|
||||||
import com.runicgateway.app.ui.admin.AdminContentScreen
|
import com.runicgateway.app.ui.admin.AdminContentScreen
|
||||||
@@ -70,12 +82,19 @@ import com.runicgateway.app.ui.player.CharactersScreen
|
|||||||
import com.runicgateway.app.ui.player.MyHousesScreen
|
import com.runicgateway.app.ui.player.MyHousesScreen
|
||||||
import com.runicgateway.app.ui.player.VendorsScreen
|
import com.runicgateway.app.ui.player.VendorsScreen
|
||||||
import com.runicgateway.app.ui.session.SessionViewModel
|
import com.runicgateway.app.ui.session.SessionViewModel
|
||||||
|
import com.runicgateway.app.ui.shard.AtlasCreatureScreen
|
||||||
|
import com.runicgateway.app.ui.shard.AtlasScreen
|
||||||
import com.runicgateway.app.ui.shard.ChampsScreen
|
import com.runicgateway.app.ui.shard.ChampsScreen
|
||||||
import com.runicgateway.app.ui.shard.GovernorsScreen
|
import com.runicgateway.app.ui.shard.GovernorsScreen
|
||||||
import com.runicgateway.app.ui.shard.GuildsScreen
|
import com.runicgateway.app.ui.shard.GuildsScreen
|
||||||
import com.runicgateway.app.ui.shard.HousesScreen
|
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.ShardBoard
|
||||||
import com.runicgateway.app.ui.shard.ShardScreen
|
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.WikiPageScreen
|
||||||
import com.runicgateway.app.ui.wiki.WikiScreen
|
import com.runicgateway.app.ui.wiki.WikiScreen
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -83,6 +102,9 @@ import kotlinx.coroutines.launch
|
|||||||
/** Destinations that show the drawer (hamburger); others show a back arrow. */
|
/** Destinations that show the drawer (hamburger); others show a back arrow. */
|
||||||
private val TOP_LEVEL_ROUTES = setOf(
|
private val TOP_LEVEL_ROUTES = setOf(
|
||||||
Routes.HOME, Routes.NEWS, Routes.WIKI, Routes.SHARD, Routes.CONTACT, Routes.PAGE, Routes.ACCOUNT,
|
Routes.HOME, Routes.NEWS, Routes.WIKI, Routes.SHARD, Routes.CONTACT, Routes.PAGE, Routes.ACCOUNT,
|
||||||
|
// 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.NOTIFICATIONS,
|
||||||
Routes.PLAYER_CHARACTERS, Routes.PLAYER_VENDORS, Routes.PLAYER_HOUSES,
|
Routes.PLAYER_CHARACTERS, Routes.PLAYER_VENDORS, Routes.PLAYER_HOUSES,
|
||||||
Routes.ADMIN_DASHBOARD, Routes.ADMIN_CONTENT, Routes.ADMIN_MODERATION, Routes.ADMIN_SUPPORT,
|
Routes.ADMIN_DASHBOARD, Routes.ADMIN_CONTENT, Routes.ADMIN_MODERATION, Routes.ADMIN_SUPPORT,
|
||||||
@@ -98,18 +120,21 @@ private val TOP_LEVEL_ROUTES = setOf(
|
|||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun RunicApp(
|
fun RunicApp(
|
||||||
brand: BrandDto?,
|
appearance: SiteAppearance,
|
||||||
onChangeServer: () -> Unit,
|
onChangeServer: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
deepLinkStream: String? = null,
|
deepLinkStream: String? = null,
|
||||||
onDeepLinkConsumed: () -> Unit = {},
|
onDeepLinkConsumed: () -> Unit = {},
|
||||||
sessionViewModel: SessionViewModel = hiltViewModel(),
|
sessionViewModel: SessionViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
|
val brand = appearance.brand
|
||||||
val navController = rememberNavController()
|
val navController = rememberNavController()
|
||||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
val session by sessionViewModel.session.collectAsStateWithLifecycle()
|
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).
|
// Re-validate the cached role each time the app returns to the foreground (§4.3).
|
||||||
LifecycleResumeEffect(Unit) {
|
LifecycleResumeEffect(Unit) {
|
||||||
@@ -128,9 +153,37 @@ fun RunicApp(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val backStackEntry by navController.currentBackStackEntryAsState()
|
val backStackEntry by navController.currentBackStackEntryAsState()
|
||||||
val currentRoute = backStackEntry?.destination?.route
|
// 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 isTopLevel = currentRoute in TOP_LEVEL_ROUTES
|
val isTopLevel = currentRoute in TOP_LEVEL_ROUTES
|
||||||
val entries = visibleEntries(APP_MENU, session)
|
// 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
ModalNavigationDrawer(
|
ModalNavigationDrawer(
|
||||||
drawerState = drawerState,
|
drawerState = drawerState,
|
||||||
@@ -148,6 +201,14 @@ fun RunicApp(
|
|||||||
// unreachable. See RunicGateway M10.
|
// unreachable. See RunicGateway M10.
|
||||||
Column(Modifier.verticalScroll(rememberScrollState())) {
|
Column(Modifier.verticalScroll(rememberScrollState())) {
|
||||||
Spacer(Modifier.height(12.dp))
|
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(
|
||||||
text = brand?.name?.takeIf { it.isNotBlank() } ?: stringResource(R.string.app_name),
|
text = brand?.name?.takeIf { it.isNotBlank() } ?: stringResource(R.string.app_name),
|
||||||
style = MaterialTheme.typography.titleLarge,
|
style = MaterialTheme.typography.titleLarge,
|
||||||
@@ -156,17 +217,30 @@ fun RunicApp(
|
|||||||
)
|
)
|
||||||
HorizontalDivider()
|
HorizontalDivider()
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
entries.forEach { entry ->
|
nav.forEach { node ->
|
||||||
NavigationDrawerItem(
|
if (node is NavNode.Section) {
|
||||||
label = { Text(stringResource(entry.labelRes)) },
|
// A group the admin created: its label as a header, its rows
|
||||||
selected = currentRoute == entry.route,
|
// beneath it. Always open — a drawer is already a vertical
|
||||||
onClick = {
|
// list, so the website's dropdown does not translate (§6.3).
|
||||||
scope.launch { drawerState.close() }
|
Text(
|
||||||
navController.navigateTopLevel(entry.route)
|
text = node.label,
|
||||||
},
|
style = MaterialTheme.typography.labelLarge,
|
||||||
colors = drawerItemColors,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding),
|
modifier = Modifier.padding(
|
||||||
|
start = 28.dp,
|
||||||
|
end = 28.dp,
|
||||||
|
top = 12.dp,
|
||||||
|
bottom = 4.dp,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
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))
|
HorizontalDivider(Modifier.padding(vertical = 8.dp))
|
||||||
@@ -190,6 +264,7 @@ fun RunicApp(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
colors = drawerItemColors,
|
colors = drawerItemColors,
|
||||||
|
shape = LocalShardStructure.current.pill,
|
||||||
modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding),
|
modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding),
|
||||||
)
|
)
|
||||||
NavigationDrawerItem(
|
NavigationDrawerItem(
|
||||||
@@ -200,6 +275,7 @@ fun RunicApp(
|
|||||||
onChangeServer()
|
onChangeServer()
|
||||||
},
|
},
|
||||||
colors = drawerItemColors,
|
colors = drawerItemColors,
|
||||||
|
shape = LocalShardStructure.current.pill,
|
||||||
modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding),
|
modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -217,13 +293,24 @@ fun RunicApp(
|
|||||||
actionIconContentColor = MaterialTheme.colorScheme.onSurface,
|
actionIconContentColor = MaterialTheme.colorScheme.onSurface,
|
||||||
),
|
),
|
||||||
title = {
|
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(
|
||||||
text = (brand?.name?.takeIf { it.isNotBlank() }
|
text = name.uppercase(),
|
||||||
?: stringResource(R.string.app_name)).uppercase(),
|
style = MaterialTheme.typography.titleSmall
|
||||||
style = MaterialTheme.typography.titleSmall.copy(letterSpacing = 1.2.sp),
|
.copy(letterSpacing = 1.2.sp),
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
)
|
)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
if (isTopLevel) {
|
if (isTopLevel) {
|
||||||
@@ -254,6 +341,62 @@ 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
|
@Composable
|
||||||
private fun RunicNavHost(
|
private fun RunicNavHost(
|
||||||
navController: NavHostController,
|
navController: NavHostController,
|
||||||
@@ -271,7 +414,19 @@ private fun RunicNavHost(
|
|||||||
composable(Routes.HOME) {
|
composable(Routes.HOME) {
|
||||||
HomeScreen(brand = brand)
|
HomeScreen(brand = brand)
|
||||||
}
|
}
|
||||||
composable(Routes.NEWS) {
|
// 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
|
||||||
|
},
|
||||||
|
),
|
||||||
|
) {
|
||||||
NewsScreen(onOpenPost = { category, idOrSlug ->
|
NewsScreen(onOpenPost = { category, idOrSlug ->
|
||||||
navController.navigate(Routes.post(category, idOrSlug))
|
navController.navigate(Routes.post(category, idOrSlug))
|
||||||
})
|
})
|
||||||
@@ -301,6 +456,30 @@ private fun RunicNavHost(
|
|||||||
composable(Routes.SHARD_GUILDS) { GuildsScreen() }
|
composable(Routes.SHARD_GUILDS) { GuildsScreen() }
|
||||||
composable(Routes.SHARD_GOVERNORS) { GovernorsScreen() }
|
composable(Routes.SHARD_GOVERNORS) { GovernorsScreen() }
|
||||||
composable(Routes.SHARD_HOUSES) { HousesScreen() }
|
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) {
|
composable(Routes.WIKI) {
|
||||||
WikiScreen(onOpenPage = { slug -> navController.navigate(Routes.wikiPage(slug)) })
|
WikiScreen(onOpenPage = { slug -> navController.navigate(Routes.wikiPage(slug)) })
|
||||||
}
|
}
|
||||||
@@ -339,12 +518,27 @@ private fun RunicNavHost(
|
|||||||
roleLabel = stringResource(roleLabelRes(s.user.role)),
|
roleLabel = stringResource(roleLabelRes(s.user.role)),
|
||||||
onSignOut = onSignOut,
|
onSignOut = onSignOut,
|
||||||
onSignOutEverywhere = onSignOutEverywhere,
|
onSignOutEverywhere = onSignOutEverywhere,
|
||||||
|
onOpenTrustedDevices = { navController.navigate(Routes.ACCOUNT_TRUSTED_DEVICES) },
|
||||||
|
onOpenRecoveryCodes = { navController.navigate(Routes.ACCOUNT_RECOVERY_CODES) },
|
||||||
)
|
)
|
||||||
Session.SignedOut -> LaunchedEffect(Unit) {
|
Session.SignedOut -> LaunchedEffect(Unit) {
|
||||||
navController.navigateTopLevel(Routes.HOME)
|
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) {
|
composable(Routes.NOTIFICATIONS) {
|
||||||
// Signed-in only; a sign-out (or demotion) sends the user home rather than
|
// 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).
|
// leaving stale settings up. The backend gates every call regardless (§5).
|
||||||
|
|||||||
@@ -34,6 +34,13 @@ enum class ErrorKind {
|
|||||||
/** Shard/sidecar down (503) — shard reads only; render as offline (§6.3). */
|
/** Shard/sidecar down (503) — shard reads only; render as offline (§6.3). */
|
||||||
SHARD_OFFLINE,
|
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. */
|
/** Any other non-2xx server response. */
|
||||||
SERVER,
|
SERVER,
|
||||||
}
|
}
|
||||||
@@ -52,3 +59,27 @@ fun <T> ApiResult<T>.toUiState(): UiState<T> = when (this) {
|
|||||||
httpStatus = status,
|
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()
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import androidx.compose.foundation.layout.width
|
|||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.Card
|
|
||||||
import androidx.compose.material3.FilterChip
|
import androidx.compose.material3.FilterChip
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
@@ -30,7 +29,6 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
@@ -47,6 +45,7 @@ import com.runicgateway.app.ui.UiState
|
|||||||
import com.runicgateway.app.ui.components.ErrorView
|
import com.runicgateway.app.ui.components.ErrorView
|
||||||
import com.runicgateway.app.ui.components.LoadingView
|
import com.runicgateway.app.ui.components.LoadingView
|
||||||
import com.runicgateway.app.ui.components.PillTone
|
import com.runicgateway.app.ui.components.PillTone
|
||||||
|
import com.runicgateway.app.ui.components.ShardCard
|
||||||
import com.runicgateway.app.ui.components.StatusPill
|
import com.runicgateway.app.ui.components.StatusPill
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -140,7 +139,7 @@ private fun PostsTab(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
items(state.data, key = { it.id }) { post ->
|
items(state.data, key = { it.id }) { post ->
|
||||||
Card(Modifier.fillMaxWidth().padding(vertical = 6.dp)) {
|
ShardCard(Modifier.fillMaxWidth().padding(vertical = 6.dp)) {
|
||||||
Column(Modifier.padding(12.dp)) {
|
Column(Modifier.padding(12.dp)) {
|
||||||
Text(post.title, style = MaterialTheme.typography.bodyLarge)
|
Text(post.title, style = MaterialTheme.typography.bodyLarge)
|
||||||
Spacer(Modifier.height(4.dp))
|
Spacer(Modifier.height(4.dp))
|
||||||
@@ -191,7 +190,7 @@ private fun WikiTab(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
items(state.data, key = { it.id }) { cat ->
|
items(state.data, key = { it.id }) { cat ->
|
||||||
Card(Modifier.fillMaxWidth().padding(vertical = 6.dp)) {
|
ShardCard(Modifier.fillMaxWidth().padding(vertical = 6.dp)) {
|
||||||
Column(Modifier.padding(12.dp)) {
|
Column(Modifier.padding(12.dp)) {
|
||||||
Text(cat.title, style = MaterialTheme.typography.bodyLarge)
|
Text(cat.title, style = MaterialTheme.typography.bodyLarge)
|
||||||
Text(
|
Text(
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import androidx.compose.foundation.layout.padding
|
|||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.Card
|
|
||||||
import androidx.compose.material3.Checkbox
|
import androidx.compose.material3.Checkbox
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
@@ -38,6 +37,7 @@ import com.runicgateway.app.ui.UiState
|
|||||||
import com.runicgateway.app.ui.components.EmptyView
|
import com.runicgateway.app.ui.components.EmptyView
|
||||||
import com.runicgateway.app.ui.components.ErrorView
|
import com.runicgateway.app.ui.components.ErrorView
|
||||||
import com.runicgateway.app.ui.components.LoadingView
|
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,
|
* The support (help-page) queue (PLAN.md §1, M10): open tickets with reply/close,
|
||||||
@@ -84,7 +84,6 @@ fun AdminSupportScreen(
|
|||||||
|
|
||||||
replyTo?.let { page ->
|
replyTo?.let { page ->
|
||||||
RespondDialog(
|
RespondDialog(
|
||||||
page = page,
|
|
||||||
onDismiss = { replyTo = null },
|
onDismiss = { replyTo = null },
|
||||||
onSend = { message, close ->
|
onSend = { message, close ->
|
||||||
viewModel.respond(page.pageId, message, close)
|
viewModel.respond(page.pageId, message, close)
|
||||||
@@ -101,7 +100,7 @@ private fun SupportPageCard(
|
|||||||
onReply: () -> Unit,
|
onReply: () -> Unit,
|
||||||
onClose: () -> Unit,
|
onClose: () -> Unit,
|
||||||
) {
|
) {
|
||||||
Card(Modifier.fillMaxWidth().padding(vertical = 6.dp)) {
|
ShardCard(Modifier.fillMaxWidth().padding(vertical = 6.dp)) {
|
||||||
Column(Modifier.padding(12.dp)) {
|
Column(Modifier.padding(12.dp)) {
|
||||||
val who = page.sender?.name ?: page.sender?.account ?: page.pageId
|
val who = page.sender?.name ?: page.sender?.account ?: page.pageId
|
||||||
Text(
|
Text(
|
||||||
@@ -122,7 +121,6 @@ private fun SupportPageCard(
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun RespondDialog(
|
private fun RespondDialog(
|
||||||
page: SupportPageDto,
|
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
onSend: (message: String, close: Boolean) -> Unit,
|
onSend: (message: String, close: Boolean) -> Unit,
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ import androidx.compose.foundation.layout.size
|
|||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.Card
|
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedButton
|
import androidx.compose.material3.OutlinedButton
|
||||||
@@ -51,6 +50,7 @@ import com.runicgateway.app.ui.auth.AccountViewModel.Section
|
|||||||
import com.runicgateway.app.ui.components.ErrorView
|
import com.runicgateway.app.ui.components.ErrorView
|
||||||
import com.runicgateway.app.ui.components.LoadingView
|
import com.runicgateway.app.ui.components.LoadingView
|
||||||
import com.runicgateway.app.ui.components.PillTone
|
import com.runicgateway.app.ui.components.PillTone
|
||||||
|
import com.runicgateway.app.ui.components.ShardCard
|
||||||
import com.runicgateway.app.ui.components.StatusPill
|
import com.runicgateway.app.ui.components.StatusPill
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -65,6 +65,8 @@ fun AccountScreen(
|
|||||||
roleLabel: String,
|
roleLabel: String,
|
||||||
onSignOut: () -> Unit,
|
onSignOut: () -> Unit,
|
||||||
onSignOutEverywhere: () -> Unit,
|
onSignOutEverywhere: () -> Unit,
|
||||||
|
onOpenTrustedDevices: () -> Unit,
|
||||||
|
onOpenRecoveryCodes: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
viewModel: AccountViewModel = hiltViewModel(),
|
viewModel: AccountViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
@@ -78,10 +80,15 @@ fun AccountScreen(
|
|||||||
) {
|
) {
|
||||||
IdentityCard(username = username, roleLabel = roleLabel)
|
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) {
|
when (val account = state.account) {
|
||||||
is UiState.Loading -> LoadingView(Modifier.padding(top = 32.dp))
|
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.Error -> ErrorView(account.kind, onRetry = viewModel::load, modifier = Modifier.padding(top = 32.dp))
|
||||||
is UiState.Success -> AccountSections(account.data, state, viewModel)
|
is UiState.Success -> AccountSections(account.data, state, viewModel, onOpenTrustedDevices, onOpenRecoveryCodes)
|
||||||
}
|
}
|
||||||
|
|
||||||
HorizontalDivider(Modifier.padding(vertical = 20.dp))
|
HorizontalDivider(Modifier.padding(vertical = 20.dp))
|
||||||
@@ -100,7 +107,7 @@ fun AccountScreen(
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun IdentityCard(username: String, roleLabel: String) {
|
private fun IdentityCard(username: String, roleLabel: String) {
|
||||||
Card(Modifier.fillMaxWidth()) {
|
ShardCard(Modifier.fillMaxWidth()) {
|
||||||
Column(Modifier.padding(20.dp)) {
|
Column(Modifier.padding(20.dp)) {
|
||||||
Text(text = username, style = MaterialTheme.typography.titleLarge)
|
Text(text = username, style = MaterialTheme.typography.titleLarge)
|
||||||
StatusPill(
|
StatusPill(
|
||||||
@@ -117,16 +124,37 @@ private fun AccountSections(
|
|||||||
account: PlayerAccountDto,
|
account: PlayerAccountDto,
|
||||||
state: AccountViewModel.State,
|
state: AccountViewModel.State,
|
||||||
viewModel: AccountViewModel,
|
viewModel: AccountViewModel,
|
||||||
|
onOpenTrustedDevices: () -> Unit,
|
||||||
|
onOpenRecoveryCodes: () -> Unit,
|
||||||
) {
|
) {
|
||||||
UsernameSection(account, state, viewModel)
|
UsernameSection(account, state, viewModel)
|
||||||
PasswordSection(account, state, viewModel)
|
PasswordSection(account, state, viewModel)
|
||||||
TwoFactorSection(account, state, viewModel)
|
TwoFactorSection(account, state, viewModel)
|
||||||
|
SecuritySection(onOpenTrustedDevices, onOpenRecoveryCodes)
|
||||||
IdentitiesSection(state, viewModel)
|
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
|
@Composable
|
||||||
private fun SectionCard(@StringRes titleRes: Int, content: @Composable () -> Unit) {
|
private fun SectionCard(@StringRes titleRes: Int, content: @Composable () -> Unit) {
|
||||||
Card(Modifier.fillMaxWidth().padding(top = 12.dp)) {
|
ShardCard(Modifier.fillMaxWidth().padding(top = 12.dp)) {
|
||||||
Column(Modifier.padding(16.dp)) {
|
Column(Modifier.padding(16.dp)) {
|
||||||
Text(stringResource(titleRes), style = MaterialTheme.typography.titleMedium)
|
Text(stringResource(titleRes), style = MaterialTheme.typography.titleMedium)
|
||||||
content()
|
content()
|
||||||
|
|||||||
@@ -49,6 +49,8 @@ class AccountViewModel @Inject constructor(
|
|||||||
val busy: Boolean = false,
|
val busy: Boolean = false,
|
||||||
/** The pending TOTP enrollment (QR shown) between setup and enable. */
|
/** The pending TOTP enrollment (QR shown) between setup and enable. */
|
||||||
val totpSetup: TotpSetupDto? = null,
|
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,
|
val feedback: Feedback? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -122,9 +124,12 @@ class AccountViewModel @Inject constructor(
|
|||||||
if (_state.value.busy) return
|
if (_state.value.busy) return
|
||||||
_state.update { it.copy(busy = true, feedback = null) }
|
_state.update { it.copy(busy = true, feedback = null) }
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
when (accountRepository.totpEnable(code.trim())) {
|
when (val result = accountRepository.totpEnable(code.trim())) {
|
||||||
is ApiResult.Ok -> {
|
is ApiResult.Ok -> {
|
||||||
_state.update { it.copy(totpSetup = null) }
|
// 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))
|
||||||
|
}
|
||||||
finish(Section.TOTP, true, R.string.account_totp_enabled)
|
finish(Section.TOTP, true, R.string.account_totp_enabled)
|
||||||
reloadAccount()
|
reloadAccount()
|
||||||
}
|
}
|
||||||
@@ -134,6 +139,9 @@ 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) {
|
fun disableTotp(code: String) {
|
||||||
if (_state.value.busy) return
|
if (_state.value.busy) return
|
||||||
_state.update { it.copy(busy = true, feedback = null) }
|
_state.update { it.copy(busy = true, feedback = null) }
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ package com.runicgateway.app.ui.auth
|
|||||||
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
@@ -16,6 +17,7 @@ import androidx.compose.foundation.text.KeyboardActions
|
|||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.Checkbox
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
@@ -123,6 +125,24 @@ fun LoginScreen(
|
|||||||
)
|
)
|
||||||
|
|
||||||
if (state.totpRequired) {
|
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(
|
OutlinedTextField(
|
||||||
value = state.code,
|
value = state.code,
|
||||||
onValueChange = viewModel::onCodeChange,
|
onValueChange = viewModel::onCodeChange,
|
||||||
@@ -141,6 +161,39 @@ 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 ->
|
state.error?.let { err ->
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(loginErrorRes(err)),
|
text = stringResource(loginErrorRes(err)),
|
||||||
|
|||||||
@@ -39,8 +39,14 @@ class LoginViewModel @Inject constructor(
|
|||||||
val username: String = "",
|
val username: String = "",
|
||||||
val password: String = "",
|
val password: String = "",
|
||||||
val code: 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. */
|
/** True once the account is known to have 2FA on — reveal the code field. */
|
||||||
val totpRequired: Boolean = false,
|
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 submitting: Boolean = false,
|
||||||
val error: LoginError? = null,
|
val error: LoginError? = null,
|
||||||
val signedIn: Boolean = false,
|
val signedIn: Boolean = false,
|
||||||
@@ -83,6 +89,16 @@ class LoginViewModel @Inject constructor(
|
|||||||
fun onCodeChange(value: String) =
|
fun onCodeChange(value: String) =
|
||||||
_state.update { it.copy(code = value.filter(Char::isDigit).take(8), error = null) }
|
_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 registerUrl: String? get() = websiteUrls.register()
|
||||||
val forgotPasswordUrl: String? get() = websiteUrls.forgotPassword()
|
val forgotPasswordUrl: String? get() = websiteUrls.forgotPassword()
|
||||||
|
|
||||||
@@ -137,30 +153,55 @@ class LoginViewModel @Inject constructor(
|
|||||||
fun submit() {
|
fun submit() {
|
||||||
val s = _state.value
|
val s = _state.value
|
||||||
if (s.submitting) return
|
if (s.submitting) return
|
||||||
if (s.username.isBlank() || s.password.isBlank()) {
|
val validationError = validateForSubmit(s)
|
||||||
_state.update { it.copy(error = LoginError.INVALID_CREDENTIALS) }
|
if (validationError != null) {
|
||||||
return
|
_state.update { it.copy(error = validationError) }
|
||||||
}
|
|
||||||
// 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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
_state.update { it.copy(submitting = true, error = null) }
|
_state.update { it.copy(submitting = true, error = null) }
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val code = s.code.trim().takeIf { it.isNotBlank() }
|
// Only one second factor is sent; the recovery toggle picks which.
|
||||||
when (authRepository.login(s.username.trim(), s.password, code)) {
|
val code = s.code.trim().takeIf { it.isNotBlank() && !s.useRecoveryCode }
|
||||||
LoginResult.Success ->
|
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.
|
||||||
_state.update { it.copy(submitting = false, signedIn = true) }
|
_state.update { it.copy(submitting = false, signedIn = true) }
|
||||||
|
|
||||||
LoginResult.TotpRequired ->
|
LoginResult.TotpRequired ->
|
||||||
// Reveal the code field; a wrong code re-lands here as BAD_CODE.
|
// Reveal the 2FA fields; a wrong code/recovery code re-lands here as BAD_CODE.
|
||||||
_state.update {
|
_state.update {
|
||||||
|
val hadFactor = if (it.useRecoveryCode) it.recoveryCode.isNotBlank() else it.code.isNotBlank()
|
||||||
it.copy(
|
it.copy(
|
||||||
submitting = false,
|
submitting = false,
|
||||||
totpRequired = true,
|
totpRequired = true,
|
||||||
error = if (it.code.isNotBlank()) LoginError.BAD_CODE else null,
|
error = if (hadFactor) LoginError.BAD_CODE else null,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,6 +217,4 @@ class LoginViewModel @Inject constructor(
|
|||||||
LoginResult.NetworkError ->
|
LoginResult.NetworkError ->
|
||||||
_state.update { it.copy(submitting = false, error = LoginError.NETWORK) }
|
_state.update { it.copy(submitting = false, error = LoginError.NETWORK) }
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
/*
|
||||||
|
* 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)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
/*
|
||||||
|
* 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
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
/*
|
||||||
|
* 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
/*
|
||||||
|
* 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)) }
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
/*
|
||||||
|
* 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() }
|
||||||
@@ -36,6 +36,10 @@ fun LoadingView(modifier: Modifier = Modifier) {
|
|||||||
/**
|
/**
|
||||||
* Whole-screen error state with a friendly, kind-specific message and a Retry
|
* 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.
|
* 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
|
@Composable
|
||||||
fun ErrorView(
|
fun ErrorView(
|
||||||
@@ -53,6 +57,7 @@ fun ErrorView(
|
|||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
)
|
)
|
||||||
|
if (isRetryable(kind)) {
|
||||||
Button(
|
Button(
|
||||||
onClick = onRetry,
|
onClick = onRetry,
|
||||||
modifier = Modifier.padding(top = 16.dp).width(160.dp),
|
modifier = Modifier.padding(top = 16.dp).width(160.dp),
|
||||||
@@ -60,8 +65,12 @@ fun ErrorView(
|
|||||||
Text(stringResource(R.string.action_retry))
|
Text(stringResource(R.string.action_retry))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 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). */
|
/** Centered informational message for an empty list (§7). */
|
||||||
@Composable
|
@Composable
|
||||||
fun EmptyView(message: String, modifier: Modifier = Modifier) {
|
fun EmptyView(message: String, modifier: Modifier = Modifier) {
|
||||||
@@ -83,5 +92,6 @@ private fun errorMessageRes(kind: ErrorKind): Int = when (kind) {
|
|||||||
ErrorKind.NOT_FOUND -> R.string.error_not_found
|
ErrorKind.NOT_FOUND -> R.string.error_not_found
|
||||||
ErrorKind.RATE_LIMITED -> R.string.error_rate_limited
|
ErrorKind.RATE_LIMITED -> R.string.error_rate_limited
|
||||||
ErrorKind.SHARD_OFFLINE -> R.string.error_shard_offline
|
ErrorKind.SHARD_OFFLINE -> R.string.error_shard_offline
|
||||||
|
ErrorKind.FEATURE_UNAVAILABLE -> R.string.error_feature_unavailable
|
||||||
ErrorKind.SERVER -> R.string.error_server
|
ErrorKind.SERVER -> R.string.error_server
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,24 +14,22 @@ import androidx.compose.foundation.layout.padding
|
|||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
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.MaterialTheme
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.draw.shadow
|
||||||
import androidx.compose.ui.graphics.Brush
|
import androidx.compose.ui.graphics.Brush
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.runicgateway.app.ui.theme.ShardCardBottom
|
import com.runicgateway.app.ui.theme.LocalShardPalette
|
||||||
import com.runicgateway.app.ui.theme.ShardCardTop
|
import com.runicgateway.app.ui.theme.LocalShardStructure
|
||||||
import com.runicgateway.app.ui.theme.ShardDanger
|
import com.runicgateway.app.ui.theme.ShardDanger
|
||||||
import com.runicgateway.app.ui.theme.ShardDangerBg
|
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.ShardSuccess
|
||||||
import com.runicgateway.app.ui.theme.ShardSuccessBg
|
import com.runicgateway.app.ui.theme.ShardSuccessBg
|
||||||
import com.runicgateway.app.ui.theme.ShardSuccessDot
|
import com.runicgateway.app.ui.theme.ShardSuccessDot
|
||||||
@@ -43,6 +41,14 @@ import com.runicgateway.app.ui.theme.ShardWarningBg
|
|||||||
* (docs/android/PLAN.md §M5): the recurring pill, section-label, feature-card,
|
* (docs/android/PLAN.md §M5): the recurring pill, section-label, feature-card,
|
||||||
* and stat-bar motifs the mockup repeats across screens. Pure presentation —
|
* and stat-bar motifs the mockup repeats across screens. Pure presentation —
|
||||||
* no state, no data dependencies — so any screen can adopt them.
|
* 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]. */
|
/** Semantic tone for a [StatusPill] / [OnlineDot]. */
|
||||||
@@ -50,21 +56,26 @@ enum class PillTone { Success, Warning, Danger, Neutral, Info }
|
|||||||
|
|
||||||
private data class PillColors(val fg: Color, val bg: Color)
|
private data class PillColors(val fg: Color, val bg: Color)
|
||||||
|
|
||||||
|
@Composable
|
||||||
private fun toneColors(tone: PillTone): PillColors = when (tone) {
|
private fun toneColors(tone: PillTone): PillColors = when (tone) {
|
||||||
PillTone.Success -> PillColors(ShardSuccess, ShardSuccessBg)
|
PillTone.Success -> PillColors(ShardSuccess, ShardSuccessBg)
|
||||||
PillTone.Warning -> PillColors(ShardWarning, ShardWarningBg)
|
PillTone.Warning -> PillColors(ShardWarning, ShardWarningBg)
|
||||||
PillTone.Danger -> PillColors(ShardDanger, ShardDangerBg)
|
PillTone.Danger -> PillColors(ShardDanger, ShardDangerBg)
|
||||||
PillTone.Neutral, PillTone.Info -> PillColors(ShardPillFg, ShardPillBg)
|
PillTone.Neutral, PillTone.Info ->
|
||||||
|
LocalShardPalette.current.let { PillColors(it.pillFg, it.pillBg) }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A small uppercase status chip — "Live", "Up", "Enabled", "IDOC", a role — with a
|
* 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.
|
* 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
|
@Composable
|
||||||
fun StatusPill(text: String, tone: PillTone, modifier: Modifier = Modifier) {
|
fun StatusPill(text: String, tone: PillTone, modifier: Modifier = Modifier) {
|
||||||
val c = toneColors(tone)
|
val c = toneColors(tone)
|
||||||
Surface(color = c.bg, shape = CircleShape, modifier = modifier) {
|
Surface(color = c.bg, shape = LocalShardStructure.current.pill, modifier = modifier) {
|
||||||
Text(
|
Text(
|
||||||
text = text.uppercase(),
|
text = text.uppercase(),
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
@@ -81,7 +92,7 @@ fun OnlineDot(tone: PillTone, modifier: Modifier = Modifier) {
|
|||||||
PillTone.Success -> ShardSuccessDot
|
PillTone.Success -> ShardSuccessDot
|
||||||
PillTone.Warning -> ShardWarning
|
PillTone.Warning -> ShardWarning
|
||||||
PillTone.Danger -> ShardDanger
|
PillTone.Danger -> ShardDanger
|
||||||
PillTone.Neutral, PillTone.Info -> ShardFaint
|
PillTone.Neutral, PillTone.Info -> LocalShardPalette.current.faint
|
||||||
}
|
}
|
||||||
Box(modifier.size(8.dp).clip(CircleShape).background(color))
|
Box(modifier.size(8.dp).clip(CircleShape).background(color))
|
||||||
}
|
}
|
||||||
@@ -95,7 +106,7 @@ fun SectionLabel(text: String, modifier: Modifier = Modifier) {
|
|||||||
Text(
|
Text(
|
||||||
text = text.uppercase(),
|
text = text.uppercase(),
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
color = ShardFaint,
|
color = LocalShardPalette.current.faint,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -104,6 +115,11 @@ fun SectionLabel(text: String, modifier: Modifier = Modifier) {
|
|||||||
* The elevated "feature" card: a vertical blue gradient with a hairline outline and
|
* 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
|
* soft shadow, used for the home status card, the shard-online banner, and the
|
||||||
* vendor card. [content] is laid out in a padded [Column].
|
* 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
|
@Composable
|
||||||
fun FeatureCard(
|
fun FeatureCard(
|
||||||
@@ -111,17 +127,40 @@ fun FeatureCard(
|
|||||||
contentPadding: Int = 18,
|
contentPadding: Int = 18,
|
||||||
content: @Composable ColumnScope.() -> Unit,
|
content: @Composable ColumnScope.() -> Unit,
|
||||||
) {
|
) {
|
||||||
|
val palette = LocalShardPalette.current
|
||||||
|
val shape = MaterialTheme.shapes.medium
|
||||||
Box(
|
Box(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.clip(RoundedCornerShape(12.dp))
|
.shadow(LocalShardStructure.current.cardElevation, shape)
|
||||||
.background(Brush.verticalGradient(listOf(ShardCardTop, ShardCardBottom)))
|
.clip(shape)
|
||||||
.border(1.dp, ShardOutline, RoundedCornerShape(12.dp)),
|
.background(Brush.verticalGradient(listOf(palette.cardTop, palette.cardBottom)))
|
||||||
|
.border(1.dp, palette.outline, shape),
|
||||||
) {
|
) {
|
||||||
Column(Modifier.padding(contentPadding.dp), content = content)
|
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
|
* A slim rounded meter (vitals / skills). [fraction] is clamped to 0..1; the fill is
|
||||||
* the slate accent over a bordered dark track.
|
* the slate accent over a bordered dark track.
|
||||||
@@ -129,13 +168,14 @@ fun FeatureCard(
|
|||||||
@Composable
|
@Composable
|
||||||
fun StatBar(fraction: Float, modifier: Modifier = Modifier) {
|
fun StatBar(fraction: Float, modifier: Modifier = Modifier) {
|
||||||
val pct = fraction.coerceIn(0f, 1f)
|
val pct = fraction.coerceIn(0f, 1f)
|
||||||
|
val palette = LocalShardPalette.current
|
||||||
Box(
|
Box(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(6.dp)
|
.height(6.dp)
|
||||||
.clip(RoundedCornerShape(3.dp))
|
.clip(RoundedCornerShape(3.dp))
|
||||||
.background(ShardElevated)
|
.background(palette.elevated)
|
||||||
.border(1.dp, ShardOutline, RoundedCornerShape(3.dp)),
|
.border(1.dp, palette.outline, RoundedCornerShape(3.dp)),
|
||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
Modifier
|
Modifier
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import com.runicgateway.app.R
|
|||||||
import com.runicgateway.app.data.api.dto.BrandDto
|
import com.runicgateway.app.data.api.dto.BrandDto
|
||||||
import com.runicgateway.app.data.api.dto.StatusDto
|
import com.runicgateway.app.data.api.dto.StatusDto
|
||||||
import com.runicgateway.app.ui.UiState
|
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.ErrorView
|
||||||
import com.runicgateway.app.ui.components.FeatureCard
|
import com.runicgateway.app.ui.components.FeatureCard
|
||||||
import com.runicgateway.app.ui.components.LoadingView
|
import com.runicgateway.app.ui.components.LoadingView
|
||||||
@@ -59,6 +60,12 @@ private fun HomeContent(brand: BrandDto?, status: StatusDto, modifier: Modifier
|
|||||||
.verticalScroll(rememberScrollState())
|
.verticalScroll(rememberScrollState())
|
||||||
.padding(20.dp),
|
.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(
|
||||||
text = brand?.name?.takeIf { it.isNotBlank() } ?: stringResource(R.string.app_name),
|
text = brand?.name?.takeIf { it.isNotBlank() } ?: stringResource(R.string.app_name),
|
||||||
style = MaterialTheme.typography.headlineMedium,
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ package com.runicgateway.app.ui.navigation
|
|||||||
import androidx.annotation.StringRes
|
import androidx.annotation.StringRes
|
||||||
import com.runicgateway.app.R
|
import com.runicgateway.app.R
|
||||||
import com.runicgateway.app.core.auth.Session
|
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
|
* One shared, declarative, access-level navigation definition (PLAN.md §5): a
|
||||||
@@ -21,7 +24,12 @@ enum class MenuAccess {
|
|||||||
/** Visible to any signed-in account (§5, "My Account"). */
|
/** Visible to any signed-in account (§5, "My Account"). */
|
||||||
SIGNED_IN,
|
SIGNED_IN,
|
||||||
|
|
||||||
/** Visible only to a player — the linked game-data groups (§6.3). */
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
PLAYER,
|
PLAYER,
|
||||||
|
|
||||||
/** Visible to any staff role (admin/editor/moderator) — the M10 staff surface (§1). */
|
/** Visible to any staff role (admin/editor/moderator) — the M10 staff surface (§1). */
|
||||||
@@ -35,6 +43,24 @@ data class MenuEntry(
|
|||||||
val route: String,
|
val route: String,
|
||||||
@param:StringRes val labelRes: Int,
|
@param:StringRes val labelRes: Int,
|
||||||
val access: MenuAccess = MenuAccess.PUBLIC,
|
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,
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -46,7 +72,13 @@ val APP_MENU: List<MenuEntry> = listOf(
|
|||||||
MenuEntry(Routes.HOME, R.string.menu_home),
|
MenuEntry(Routes.HOME, R.string.menu_home),
|
||||||
MenuEntry(Routes.NEWS, R.string.menu_news),
|
MenuEntry(Routes.NEWS, R.string.menu_news),
|
||||||
MenuEntry(Routes.WIKI, R.string.menu_wiki),
|
MenuEntry(Routes.WIKI, R.string.menu_wiki),
|
||||||
MenuEntry(Routes.SHARD, R.string.menu_shard),
|
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.page("about"), R.string.menu_about),
|
MenuEntry(Routes.page("about"), R.string.menu_about),
|
||||||
MenuEntry(Routes.CONTACT, R.string.menu_contact),
|
MenuEntry(Routes.CONTACT, R.string.menu_contact),
|
||||||
MenuEntry(Routes.ACCOUNT, R.string.menu_account, MenuAccess.SIGNED_IN),
|
MenuEntry(Routes.ACCOUNT, R.string.menu_account, MenuAccess.SIGNED_IN),
|
||||||
@@ -62,16 +94,40 @@ val APP_MENU: List<MenuEntry> = listOf(
|
|||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The entries the given [session] may see. Pure + side-effect-free so the access
|
* The entries the given [session] may see, given the shard [features] it may reach.
|
||||||
* gating is unit-tested without Compose.
|
* 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.
|
||||||
*/
|
*/
|
||||||
fun visibleEntries(entries: List<MenuEntry>, session: Session): List<MenuEntry> =
|
fun visibleEntries(
|
||||||
entries.filter { entry ->
|
entries: List<MenuEntry>,
|
||||||
when (entry.access) {
|
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) {
|
||||||
MenuAccess.PUBLIC -> true
|
MenuAccess.PUBLIC -> true
|
||||||
MenuAccess.SIGNED_IN -> session is Session.SignedIn
|
MenuAccess.SIGNED_IN -> session is Session.SignedIn
|
||||||
MenuAccess.PLAYER -> session is Session.SignedIn && session.user.isPlayer
|
MenuAccess.PLAYER -> session is Session.SignedIn && (session.user.isPlayer || session.user.isStaff)
|
||||||
MenuAccess.STAFF -> session is Session.SignedIn && session.user.isStaff
|
MenuAccess.STAFF -> session is Session.SignedIn && session.user.isStaff
|
||||||
MenuAccess.MODERATOR -> session is Session.SignedIn && session.user.isModerator
|
MenuAccess.MODERATOR -> session is Session.SignedIn && session.user.isModerator
|
||||||
}
|
}
|
||||||
}
|
return allowedByRole && (entry.feature == null || canSee(features, entry.feature))
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,171 @@
|
|||||||
|
/*
|
||||||
|
* 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
220
app/src/main/java/com/runicgateway/app/ui/navigation/NavPaths.kt
Normal file
220
app/src/main/java/com/runicgateway/app/ui/navigation/NavPaths.kt
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
/*
|
||||||
|
* 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
|
||||||
|
}
|
||||||
|
}
|
||||||
239
app/src/main/java/com/runicgateway/app/ui/navigation/NavTree.kt
Normal file
239
app/src/main/java/com/runicgateway/app/ui/navigation/NavTree.kt
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
/*
|
||||||
|
* 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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,8 @@
|
|||||||
*/
|
*/
|
||||||
package com.runicgateway.app.ui.navigation
|
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
|
* Navigation destinations for the M1 public surface (PLAN.md §5). Routes are
|
||||||
* plain strings for Navigation-Compose; argument-bearing routes expose a
|
* plain strings for Navigation-Compose; argument-bearing routes expose a
|
||||||
@@ -14,10 +16,27 @@ object Routes {
|
|||||||
const val WIKI = "wiki"
|
const val WIKI = "wiki"
|
||||||
const val CONTACT = "contact"
|
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). */
|
/** Native login (§4.1) and the signed-in account surface (§5). */
|
||||||
const val LOGIN = "login"
|
const val LOGIN = "login"
|
||||||
const val ACCOUNT = "account"
|
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). */
|
/** Opt-in push notification settings (§11, signed-in). */
|
||||||
const val NOTIFICATIONS = "notifications"
|
const val NOTIFICATIONS = "notifications"
|
||||||
|
|
||||||
@@ -30,6 +49,18 @@ object Routes {
|
|||||||
const val SHARD_GOVERNORS = "shard/governors"
|
const val SHARD_GOVERNORS = "shard/governors"
|
||||||
const val SHARD_HOUSES = "shard/houses"
|
const val SHARD_HOUSES = "shard/houses"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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. */
|
/** Player game-data groups (§6.3, player-only). Distinct from the public shard boards. */
|
||||||
const val PLAYER_CHARACTERS = "player/characters"
|
const val PLAYER_CHARACTERS = "player/characters"
|
||||||
const val PLAYER_VENDORS = "player/vendors"
|
const val PLAYER_VENDORS = "player/vendors"
|
||||||
@@ -63,11 +94,24 @@ object Routes {
|
|||||||
|
|
||||||
fun page(slug: String) = "page/$slug"
|
fun page(slug: String) = "page/$slug"
|
||||||
fun post(categoryUrlSlug: String, idOrSlug: String) = "news/$categoryUrlSlug/$idOrSlug"
|
fun post(categoryUrlSlug: String, idOrSlug: String) = "news/$categoryUrlSlug/$idOrSlug"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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"
|
fun wikiPage(slug: String) = "wiki/$slug"
|
||||||
|
|
||||||
/** The character-sheet route for an in-game serial (e.g. "0x24C"). */
|
/** The character-sheet route for an in-game serial (e.g. "0x24C"). */
|
||||||
fun playerChar(serial: String) = "player/char/$serial"
|
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
|
* 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;
|
* Part 2 work item 7). Maps a stream id to the screen that shows its content;
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
|||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.material3.Card
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.ScrollableTabRow
|
import androidx.compose.material3.ScrollableTabRow
|
||||||
import androidx.compose.material3.Tab
|
import androidx.compose.material3.Tab
|
||||||
@@ -29,6 +28,7 @@ import com.runicgateway.app.ui.UiState
|
|||||||
import com.runicgateway.app.ui.components.EmptyView
|
import com.runicgateway.app.ui.components.EmptyView
|
||||||
import com.runicgateway.app.ui.components.ErrorView
|
import com.runicgateway.app.ui.components.ErrorView
|
||||||
import com.runicgateway.app.ui.components.LoadingView
|
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). */
|
/** News hub with category tabs and a post list (PLAN.md §6.1). */
|
||||||
@Composable
|
@Composable
|
||||||
@@ -82,7 +82,7 @@ private fun PostList(
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun PostRow(post: PostDto, onClick: () -> Unit) {
|
private fun PostRow(post: PostDto, onClick: () -> Unit) {
|
||||||
Card(
|
ShardCard(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(vertical = 6.dp)
|
.padding(vertical = 6.dp)
|
||||||
|
|||||||
@@ -3,12 +3,14 @@
|
|||||||
*/
|
*/
|
||||||
package com.runicgateway.app.ui.news
|
package com.runicgateway.app.ui.news
|
||||||
|
|
||||||
|
import androidx.lifecycle.SavedStateHandle
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.runicgateway.app.data.api.dto.PostDto
|
import com.runicgateway.app.data.api.dto.PostDto
|
||||||
import com.runicgateway.app.data.repository.ContentRepository
|
import com.runicgateway.app.data.repository.ContentRepository
|
||||||
import com.runicgateway.app.data.repository.ContentRepository.PostCategory
|
import com.runicgateway.app.data.repository.ContentRepository.PostCategory
|
||||||
import com.runicgateway.app.ui.UiState
|
import com.runicgateway.app.ui.UiState
|
||||||
|
import com.runicgateway.app.ui.navigation.Routes
|
||||||
import com.runicgateway.app.ui.toUiState
|
import com.runicgateway.app.ui.toUiState
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
@@ -21,9 +23,15 @@ import javax.inject.Inject
|
|||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class NewsViewModel @Inject constructor(
|
class NewsViewModel @Inject constructor(
|
||||||
private val contentRepository: ContentRepository,
|
private val contentRepository: ContentRepository,
|
||||||
|
savedStateHandle: SavedStateHandle,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val _category = MutableStateFlow(PostCategory.NEWS)
|
// 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,
|
||||||
|
)
|
||||||
val category: StateFlow<PostCategory> = _category.asStateFlow()
|
val category: StateFlow<PostCategory> = _category.asStateFlow()
|
||||||
|
|
||||||
private val _state = MutableStateFlow<UiState<List<PostDto>>>(UiState.Loading)
|
private val _state = MutableStateFlow<UiState<List<PostDto>>>(UiState.Loading)
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
|||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material3.Card
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
@@ -26,6 +25,7 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import com.runicgateway.app.R
|
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.CharProfileDto
|
||||||
import com.runicgateway.app.data.api.dto.CharStatsDto
|
import com.runicgateway.app.data.api.dto.CharStatsDto
|
||||||
import com.runicgateway.app.data.api.dto.EquipmentDto
|
import com.runicgateway.app.data.api.dto.EquipmentDto
|
||||||
@@ -36,6 +36,7 @@ import com.runicgateway.app.ui.UiState
|
|||||||
import com.runicgateway.app.ui.components.ErrorView
|
import com.runicgateway.app.ui.components.ErrorView
|
||||||
import com.runicgateway.app.ui.components.LoadingView
|
import com.runicgateway.app.ui.components.LoadingView
|
||||||
import com.runicgateway.app.ui.components.SectionLabel
|
import com.runicgateway.app.ui.components.SectionLabel
|
||||||
|
import com.runicgateway.app.ui.components.ShardCard
|
||||||
import com.runicgateway.app.ui.components.StatBar
|
import com.runicgateway.app.ui.components.StatBar
|
||||||
import kotlinx.serialization.json.jsonPrimitive
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
|
|
||||||
@@ -71,6 +72,7 @@ private fun CharacterSheet(char: CharProfileDto, modifier: Modifier = Modifier)
|
|||||||
char.stats?.let { AttributesBlock(it) }
|
char.stats?.let { AttributesBlock(it) }
|
||||||
char.stats?.resist?.let { ResistancesBlock(it) }
|
char.stats?.resist?.let { ResistancesBlock(it) }
|
||||||
SkillsBlock(char.skills)
|
SkillsBlock(char.skills)
|
||||||
|
PointsBlock(displayPoints(char))
|
||||||
EquipmentBlock(char.equipment)
|
EquipmentBlock(char.equipment)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -215,6 +217,47 @@ 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)
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun EquipmentBlock(equipment: List<EquipmentDto>) {
|
private fun EquipmentBlock(equipment: List<EquipmentDto>) {
|
||||||
@@ -223,7 +266,7 @@ private fun EquipmentBlock(equipment: List<EquipmentDto>) {
|
|||||||
equipment.forEach { item ->
|
equipment.forEach { item ->
|
||||||
Column(Modifier.fillMaxWidth().padding(vertical = 6.dp)) {
|
Column(Modifier.fillMaxWidth().padding(vertical = 6.dp)) {
|
||||||
Text(
|
Text(
|
||||||
item.layer ?: stringResource(R.string.player_char_item),
|
item.label ?: stringResource(R.string.player_char_item),
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
)
|
)
|
||||||
val meta = listOfNotNull(
|
val meta = listOfNotNull(
|
||||||
@@ -246,7 +289,7 @@ private fun EquipmentBlock(equipment: List<EquipmentDto>) {
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun SheetCard(titleRes: Int, content: @Composable () -> Unit) {
|
private fun SheetCard(titleRes: Int, content: @Composable () -> Unit) {
|
||||||
Card(Modifier.fillMaxWidth()) {
|
ShardCard(Modifier.fillMaxWidth()) {
|
||||||
Column(Modifier.padding(16.dp)) {
|
Column(Modifier.padding(16.dp)) {
|
||||||
Text(stringResource(titleRes), style = MaterialTheme.typography.titleMedium)
|
Text(stringResource(titleRes), style = MaterialTheme.typography.titleMedium)
|
||||||
content()
|
content()
|
||||||
@@ -266,23 +309,58 @@ internal fun formatSkill(value: Double): String =
|
|||||||
/**
|
/**
|
||||||
* The human-readable title chips for a [TitlesDto] (parity with the website's
|
* The human-readable title chips for a [TitlesDto] (parity with the website's
|
||||||
* `CharacterSheet.jsx#displayTitles`): fame/karma, skill title, and the selected
|
* `CharacterSheet.jsx#displayTitles`): fame/karma, skill title, and the selected
|
||||||
* reward title — but only if it is a literal string, not a bare cliloc number
|
* reward title.
|
||||||
* (the app ships no cliloc table). De-duplicated, blanks dropped.
|
*
|
||||||
|
* 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.
|
||||||
*/
|
*/
|
||||||
internal fun displayTitles(titles: TitlesDto?): List<String> {
|
internal fun displayTitles(titles: TitlesDto?): List<String> {
|
||||||
if (titles == null) return emptyList()
|
if (titles == null) return emptyList()
|
||||||
val out = mutableListOf<String>()
|
val out = mutableListOf<String>()
|
||||||
titles.fameKarma?.let { out.add(it) }
|
titles.fameKarma?.let { out.add(it) }
|
||||||
titles.skill?.let { out.add(it) }
|
titles.skill?.let { out.add(it) }
|
||||||
val reward = titles.reward
|
val reward = titles.reward.mapIndexed { i, raw ->
|
||||||
val sel = titles.selected ?: -1
|
titles.rewardResolved.getOrNull(i)
|
||||||
val candidate = when {
|
?: raw.takeUnless { it.isBlank() || it.all(Char::isDigit) }
|
||||||
sel in reward.indices -> reward[sel]
|
|
||||||
else -> reward.firstOrNull { it.isNotBlank() && !it.all(Char::isDigit) }
|
|
||||||
}
|
}
|
||||||
if (candidate != null && candidate.isNotBlank() && !candidate.all(Char::isDigit)) out.add(candidate)
|
val candidate = reward.getOrNull(titles.selected ?: -1) ?: reward.firstNotNullOfOrNull { it }
|
||||||
|
if (!candidate.isNullOrBlank()) out.add(candidate)
|
||||||
return out.filter { it.isNotBlank() }.distinct()
|
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 =
|
private fun jsonText(element: kotlinx.serialization.json.JsonElement): String =
|
||||||
runCatching { element.jsonPrimitive.content }.getOrElse { element.toString() }
|
runCatching { element.jsonPrimitive.content }.getOrElse { element.toString() }
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import androidx.compose.foundation.layout.padding
|
|||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.Card
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
@@ -39,6 +38,7 @@ import com.runicgateway.app.ui.UiState
|
|||||||
import com.runicgateway.app.ui.components.ErrorView
|
import com.runicgateway.app.ui.components.ErrorView
|
||||||
import com.runicgateway.app.ui.components.LoadingView
|
import com.runicgateway.app.ui.components.LoadingView
|
||||||
import com.runicgateway.app.ui.components.PillTone
|
import com.runicgateway.app.ui.components.PillTone
|
||||||
|
import com.runicgateway.app.ui.components.ShardCard
|
||||||
import com.runicgateway.app.ui.components.StatusPill
|
import com.runicgateway.app.ui.components.StatusPill
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -90,7 +90,7 @@ fun CharactersScreen(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun LinkCard(state: CharactersViewModel.State, viewModel: CharactersViewModel) {
|
private fun LinkCard(state: CharactersViewModel.State, viewModel: CharactersViewModel) {
|
||||||
var code by rememberSaveable { mutableStateOf("") }
|
var code by rememberSaveable { mutableStateOf("") }
|
||||||
Card(Modifier.fillMaxWidth()) {
|
ShardCard(Modifier.fillMaxWidth()) {
|
||||||
Column(Modifier.padding(16.dp)) {
|
Column(Modifier.padding(16.dp)) {
|
||||||
Text(stringResource(R.string.player_link_title), style = MaterialTheme.typography.titleMedium)
|
Text(stringResource(R.string.player_link_title), style = MaterialTheme.typography.titleMedium)
|
||||||
Text(
|
Text(
|
||||||
@@ -121,7 +121,7 @@ private fun LinkCard(state: CharactersViewModel.State, viewModel: CharactersView
|
|||||||
private fun CreateAccountCard(state: CharactersViewModel.State, viewModel: CharactersViewModel) {
|
private fun CreateAccountCard(state: CharactersViewModel.State, viewModel: CharactersViewModel) {
|
||||||
var account by rememberSaveable { mutableStateOf("") }
|
var account by rememberSaveable { mutableStateOf("") }
|
||||||
var password by rememberSaveable { mutableStateOf("") }
|
var password by rememberSaveable { mutableStateOf("") }
|
||||||
Card(Modifier.fillMaxWidth()) {
|
ShardCard(Modifier.fillMaxWidth()) {
|
||||||
Column(Modifier.padding(16.dp)) {
|
Column(Modifier.padding(16.dp)) {
|
||||||
Text(stringResource(R.string.player_create_title), style = MaterialTheme.typography.titleMedium)
|
Text(stringResource(R.string.player_create_title), style = MaterialTheme.typography.titleMedium)
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
@@ -204,7 +204,7 @@ private fun RosterError(kind: ErrorKind, onRetry: () -> Unit) {
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun CharRow(char: RosterCharDto, onOpenChar: (String) -> Unit) {
|
private fun CharRow(char: RosterCharDto, onOpenChar: (String) -> Unit) {
|
||||||
Card(
|
ShardCard(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(vertical = 4.dp)
|
.padding(vertical = 4.dp)
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
|||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.material3.Card
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -28,6 +27,7 @@ import com.runicgateway.app.ui.UiState
|
|||||||
import com.runicgateway.app.ui.components.EmptyView
|
import com.runicgateway.app.ui.components.EmptyView
|
||||||
import com.runicgateway.app.ui.components.ErrorView
|
import com.runicgateway.app.ui.components.ErrorView
|
||||||
import com.runicgateway.app.ui.components.LoadingView
|
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
|
* The player's own houses with home/decay status (PLAN.md §6.3), text-only. An
|
||||||
@@ -60,7 +60,7 @@ fun MyHousesScreen(
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun HouseCard(house: PlayerHouseDto) {
|
private fun HouseCard(house: PlayerHouseDto) {
|
||||||
Card(Modifier.fillMaxWidth()) {
|
ShardCard(Modifier.fillMaxWidth()) {
|
||||||
Column(Modifier.padding(16.dp)) {
|
Column(Modifier.padding(16.dp)) {
|
||||||
Row(Modifier.fillMaxWidth()) {
|
Row(Modifier.fillMaxWidth()) {
|
||||||
Text(
|
Text(
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
|||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material3.Card
|
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
@@ -31,6 +30,7 @@ import com.runicgateway.app.ui.ErrorKind
|
|||||||
import com.runicgateway.app.ui.UiState
|
import com.runicgateway.app.ui.UiState
|
||||||
import com.runicgateway.app.ui.components.ErrorView
|
import com.runicgateway.app.ui.components.ErrorView
|
||||||
import com.runicgateway.app.ui.components.LoadingView
|
import com.runicgateway.app.ui.components.LoadingView
|
||||||
|
import com.runicgateway.app.ui.components.ShardCard
|
||||||
import java.text.DateFormat
|
import java.text.DateFormat
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
|
|
||||||
@@ -79,7 +79,7 @@ fun VendorsScreen(
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun SalesCard(sales: UiState<List<VendorSaleDto>>) {
|
private fun SalesCard(sales: UiState<List<VendorSaleDto>>) {
|
||||||
Card(Modifier.fillMaxWidth()) {
|
ShardCard(Modifier.fillMaxWidth()) {
|
||||||
Column(Modifier.padding(16.dp)) {
|
Column(Modifier.padding(16.dp)) {
|
||||||
Text(stringResource(R.string.player_sales_title), style = MaterialTheme.typography.titleMedium)
|
Text(stringResource(R.string.player_sales_title), style = MaterialTheme.typography.titleMedium)
|
||||||
when (sales) {
|
when (sales) {
|
||||||
@@ -185,7 +185,7 @@ private fun VendorError(kind: ErrorKind, onRetry: () -> Unit) {
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun VendorCard(vendor: VendorDto) {
|
private fun VendorCard(vendor: VendorDto) {
|
||||||
Card(Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
|
ShardCard(Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
|
||||||
Column(Modifier.padding(16.dp)) {
|
Column(Modifier.padding(16.dp)) {
|
||||||
Text(
|
Text(
|
||||||
vendor.shopName ?: stringResource(R.string.player_vendor_fallback),
|
vendor.shopName ?: stringResource(R.string.player_vendor_fallback),
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import androidx.lifecycle.viewModelScope
|
|||||||
import com.runicgateway.app.core.auth.Session
|
import com.runicgateway.app.core.auth.Session
|
||||||
import com.runicgateway.app.core.auth.SessionManager
|
import com.runicgateway.app.core.auth.SessionManager
|
||||||
import com.runicgateway.app.data.repository.AuthRepository
|
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 dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -23,10 +25,28 @@ import javax.inject.Inject
|
|||||||
class SessionViewModel @Inject constructor(
|
class SessionViewModel @Inject constructor(
|
||||||
sessionManager: SessionManager,
|
sessionManager: SessionManager,
|
||||||
private val authRepository: AuthRepository,
|
private val authRepository: AuthRepository,
|
||||||
|
shardFeaturesRepository: ShardFeaturesRepository,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
val session: StateFlow<Session> = sessionManager.state
|
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. */
|
/** Re-validate the cached role against the backend on app resume. */
|
||||||
fun revalidate() {
|
fun revalidate() {
|
||||||
viewModelScope.launch { authRepository.revalidate() }
|
viewModelScope.launch { authRepository.revalidate() }
|
||||||
|
|||||||
301
app/src/main/java/com/runicgateway/app/ui/shard/AtlasScreen.kt
Normal file
301
app/src/main/java/com/runicgateway/app/ui/shard/AtlasScreen.kt
Normal file
@@ -0,0 +1,301 @@
|
|||||||
|
/*
|
||||||
|
* 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 }
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
/*
|
||||||
|
* 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"
|
||||||
|
}
|
||||||
@@ -7,7 +7,6 @@ import androidx.compose.foundation.layout.Column
|
|||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.material3.Card
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -21,6 +20,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|||||||
import com.runicgateway.app.R
|
import com.runicgateway.app.R
|
||||||
import com.runicgateway.app.data.api.dto.ChampDto
|
import com.runicgateway.app.data.api.dto.ChampDto
|
||||||
import com.runicgateway.app.ui.components.PillTone
|
import com.runicgateway.app.ui.components.PillTone
|
||||||
|
import com.runicgateway.app.ui.components.ShardCard
|
||||||
import com.runicgateway.app.ui.components.StatusPill
|
import com.runicgateway.app.ui.components.StatusPill
|
||||||
|
|
||||||
/** The champion-spawn board (PLAN.md §6.2), live via SSE deltas. */
|
/** The champion-spawn board (PLAN.md §6.2), live via SSE deltas. */
|
||||||
@@ -44,7 +44,7 @@ fun ChampsScreen(
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ChampCard(champ: ChampDto) {
|
private fun ChampCard(champ: ChampDto) {
|
||||||
Card(Modifier.fillMaxWidth()) {
|
ShardCard(Modifier.fillMaxWidth()) {
|
||||||
Column(Modifier.padding(16.dp)) {
|
Column(Modifier.padding(16.dp)) {
|
||||||
Row(Modifier.fillMaxWidth()) {
|
Row(Modifier.fillMaxWidth()) {
|
||||||
Text(
|
Text(
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import com.runicgateway.app.core.result.ApiResult
|
|||||||
import com.runicgateway.app.data.api.dto.ChampDto
|
import com.runicgateway.app.data.api.dto.ChampDto
|
||||||
import com.runicgateway.app.data.repository.ShardRepository
|
import com.runicgateway.app.data.repository.ShardRepository
|
||||||
import com.runicgateway.app.ui.UiState
|
import com.runicgateway.app.ui.UiState
|
||||||
import com.runicgateway.app.ui.toUiState
|
import com.runicgateway.app.ui.toShardUiState
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
@@ -49,7 +49,7 @@ class ChampsViewModel @Inject constructor(
|
|||||||
board.seed(result.data)
|
board.seed(result.data)
|
||||||
publish()
|
publish()
|
||||||
}
|
}
|
||||||
else -> _state.value = result.toUiState()
|
else -> _state.value = result.toShardUiState()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -69,7 +69,9 @@ class ChampsViewModel @Inject constructor(
|
|||||||
private fun applyFrame(frame: ShardStreamEvent.Frame) {
|
private fun applyFrame(frame: ShardStreamEvent.Frame) {
|
||||||
when (frame.kind) {
|
when (frame.kind) {
|
||||||
"champ.update" -> repository.champFrame(frame.data)?.let { board.upsert(it) }
|
"champ.update" -> repository.champFrame(frame.data)?.let { board.upsert(it) }
|
||||||
"champ.remove" -> FrameFields.longField(frame.data, "serial")?.let { board.remove(it.toString()) }
|
// 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) }
|
||||||
else -> return
|
else -> return
|
||||||
}
|
}
|
||||||
// Only republish when the board actually changed (Success state only).
|
// Only republish when the board actually changed (Success state only).
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
|||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.material3.Card
|
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
@@ -33,6 +32,7 @@ import com.runicgateway.app.ui.UiState
|
|||||||
import com.runicgateway.app.ui.components.EmptyView
|
import com.runicgateway.app.ui.components.EmptyView
|
||||||
import com.runicgateway.app.ui.components.ErrorView
|
import com.runicgateway.app.ui.components.ErrorView
|
||||||
import com.runicgateway.app.ui.components.LoadingView
|
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. */
|
/** The town-governor board (PLAN.md §6.2), live via `city.update`, with per-city history. */
|
||||||
@Composable
|
@Composable
|
||||||
@@ -84,7 +84,7 @@ private fun CityCard(
|
|||||||
onExpand: () -> Unit,
|
onExpand: () -> Unit,
|
||||||
) {
|
) {
|
||||||
var expanded by remember { mutableStateOf(false) }
|
var expanded by remember { mutableStateOf(false) }
|
||||||
Card(Modifier.fillMaxWidth()) {
|
ShardCard(Modifier.fillMaxWidth()) {
|
||||||
Column {
|
Column {
|
||||||
Column(
|
Column(
|
||||||
Modifier
|
Modifier
|
||||||
|
|||||||
@@ -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.api.dto.GovernorTermDto
|
||||||
import com.runicgateway.app.data.repository.ShardRepository
|
import com.runicgateway.app.data.repository.ShardRepository
|
||||||
import com.runicgateway.app.ui.UiState
|
import com.runicgateway.app.ui.UiState
|
||||||
import com.runicgateway.app.ui.toUiState
|
import com.runicgateway.app.ui.toShardUiState
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
@@ -55,7 +55,7 @@ class GovernorsViewModel @Inject constructor(
|
|||||||
board.seed(result.data)
|
board.seed(result.data)
|
||||||
publish()
|
publish()
|
||||||
}
|
}
|
||||||
else -> _state.value = result.toUiState()
|
else -> _state.value = result.toShardUiState()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import androidx.compose.foundation.layout.Column
|
|||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.material3.Card
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -20,6 +19,7 @@ import androidx.hilt.navigation.compose.hiltViewModel
|
|||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import com.runicgateway.app.R
|
import com.runicgateway.app.R
|
||||||
import com.runicgateway.app.data.api.dto.GuildDto
|
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. */
|
/** The guild board (PLAN.md §6.2), live via SSE deltas. */
|
||||||
@Composable
|
@Composable
|
||||||
@@ -42,7 +42,7 @@ fun GuildsScreen(
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun GuildCard(guild: GuildDto) {
|
private fun GuildCard(guild: GuildDto) {
|
||||||
Card(Modifier.fillMaxWidth()) {
|
ShardCard(Modifier.fillMaxWidth()) {
|
||||||
Column(Modifier.padding(16.dp)) {
|
Column(Modifier.padding(16.dp)) {
|
||||||
Row(Modifier.fillMaxWidth()) {
|
Row(Modifier.fillMaxWidth()) {
|
||||||
Text(
|
Text(
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import com.runicgateway.app.core.result.ApiResult
|
|||||||
import com.runicgateway.app.data.api.dto.GuildDto
|
import com.runicgateway.app.data.api.dto.GuildDto
|
||||||
import com.runicgateway.app.data.repository.ShardRepository
|
import com.runicgateway.app.data.repository.ShardRepository
|
||||||
import com.runicgateway.app.ui.UiState
|
import com.runicgateway.app.ui.UiState
|
||||||
import com.runicgateway.app.ui.toUiState
|
import com.runicgateway.app.ui.toShardUiState
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
@@ -50,7 +50,7 @@ class GuildsViewModel @Inject constructor(
|
|||||||
board.seed(result.data)
|
board.seed(result.data)
|
||||||
publish()
|
publish()
|
||||||
}
|
}
|
||||||
else -> _state.value = result.toUiState()
|
else -> _state.value = result.toShardUiState()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import androidx.compose.foundation.layout.Column
|
|||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.material3.Card
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -21,6 +20,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|||||||
import com.runicgateway.app.R
|
import com.runicgateway.app.R
|
||||||
import com.runicgateway.app.data.api.dto.HouseDto
|
import com.runicgateway.app.data.api.dto.HouseDto
|
||||||
import com.runicgateway.app.ui.components.PillTone
|
import com.runicgateway.app.ui.components.PillTone
|
||||||
|
import com.runicgateway.app.ui.components.ShardCard
|
||||||
import com.runicgateway.app.ui.components.StatusPill
|
import com.runicgateway.app.ui.components.StatusPill
|
||||||
|
|
||||||
/** The public "falling houses" (IDOC) board (PLAN.md §6.2), live via `house.decay`. */
|
/** The public "falling houses" (IDOC) board (PLAN.md §6.2), live via `house.decay`. */
|
||||||
@@ -44,7 +44,7 @@ fun HousesScreen(
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun HouseCard(house: HouseDto) {
|
private fun HouseCard(house: HouseDto) {
|
||||||
Card(Modifier.fillMaxWidth()) {
|
ShardCard(Modifier.fillMaxWidth()) {
|
||||||
Column(Modifier.padding(16.dp)) {
|
Column(Modifier.padding(16.dp)) {
|
||||||
Row(Modifier.fillMaxWidth()) {
|
Row(Modifier.fillMaxWidth()) {
|
||||||
Text(
|
Text(
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import com.runicgateway.app.core.result.ApiResult
|
|||||||
import com.runicgateway.app.data.api.dto.HouseDto
|
import com.runicgateway.app.data.api.dto.HouseDto
|
||||||
import com.runicgateway.app.data.repository.ShardRepository
|
import com.runicgateway.app.data.repository.ShardRepository
|
||||||
import com.runicgateway.app.ui.UiState
|
import com.runicgateway.app.ui.UiState
|
||||||
import com.runicgateway.app.ui.toUiState
|
import com.runicgateway.app.ui.toShardUiState
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
@@ -50,7 +50,7 @@ class HousesViewModel @Inject constructor(
|
|||||||
board.seed(result.data)
|
board.seed(result.data)
|
||||||
publish()
|
publish()
|
||||||
}
|
}
|
||||||
else -> _state.value = result.toUiState()
|
else -> _state.value = result.toShardUiState()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -69,7 +69,9 @@ class HousesViewModel @Inject constructor(
|
|||||||
|
|
||||||
private fun applyFrame(frame: ShardStreamEvent.Frame) {
|
private fun applyFrame(frame: ShardStreamEvent.Frame) {
|
||||||
if (frame.kind != "house.decay") return
|
if (frame.kind != "house.decay") return
|
||||||
val serial = FrameFields.longField(frame.data, "serial") ?: 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
|
||||||
// `to` is the new decay stage; only IDOC belongs on the public board.
|
// `to` is the new decay stage; only IDOC belongs on the public board.
|
||||||
val stage = FrameFields.stringField(frame.data, "to")
|
val stage = FrameFields.stringField(frame.data, "to")
|
||||||
?: FrameFields.stringField(frame.data, "stage")
|
?: FrameFields.stringField(frame.data, "stage")
|
||||||
|
|||||||
@@ -0,0 +1,169 @@
|
|||||||
|
/*
|
||||||
|
* 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)
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
/*
|
||||||
|
* 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() },
|
||||||
|
)
|
||||||
260
app/src/main/java/com/runicgateway/app/ui/shard/MarketScreen.kt
Normal file
260
app/src/main/java/com/runicgateway/app/ui/shard/MarketScreen.kt
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
/*
|
||||||
|
* 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
/*
|
||||||
|
* 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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
207
app/src/main/java/com/runicgateway/app/ui/shard/RulesScreen.kt
Normal file
207
app/src/main/java/com/runicgateway/app/ui/shard/RulesScreen.kt
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
/*
|
||||||
|
* 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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
/*
|
||||||
|
* 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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,7 +12,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
|||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.material3.Card
|
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
@@ -32,12 +31,34 @@ import com.runicgateway.app.ui.UiState
|
|||||||
import com.runicgateway.app.ui.components.ErrorView
|
import com.runicgateway.app.ui.components.ErrorView
|
||||||
import com.runicgateway.app.ui.components.FeatureCard
|
import com.runicgateway.app.ui.components.FeatureCard
|
||||||
import com.runicgateway.app.ui.components.LoadingView
|
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.PillTone
|
||||||
import com.runicgateway.app.ui.components.SectionLabel
|
import com.runicgateway.app.ui.components.SectionLabel
|
||||||
|
import com.runicgateway.app.ui.components.ShardCard
|
||||||
import com.runicgateway.app.ui.components.StatusPill
|
import com.runicgateway.app.ui.components.StatusPill
|
||||||
|
|
||||||
/** Board destinations reachable from the hub. */
|
/**
|
||||||
enum class ShardBoard { CHAMPS, GUILDS, GOVERNORS, HOUSES }
|
* 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) }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The Shard hub (PLAN.md §6.2): live connection status, online count + latest
|
* The Shard hub (PLAN.md §6.2): live connection status, online count + latest
|
||||||
@@ -54,6 +75,7 @@ fun ShardScreen(
|
|||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
val feed by viewModel.feed.collectAsStateWithLifecycle()
|
val feed by viewModel.feed.collectAsStateWithLifecycle()
|
||||||
val connected by viewModel.connected.collectAsStateWithLifecycle()
|
val connected by viewModel.connected.collectAsStateWithLifecycle()
|
||||||
|
val features by viewModel.shardFeatures.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
when (val s = state) {
|
when (val s = state) {
|
||||||
is UiState.Loading -> LoadingView(modifier)
|
is UiState.Loading -> LoadingView(modifier)
|
||||||
@@ -62,6 +84,7 @@ fun ShardScreen(
|
|||||||
hub = s.data,
|
hub = s.data,
|
||||||
feed = feed,
|
feed = feed,
|
||||||
connected = connected,
|
connected = connected,
|
||||||
|
features = features,
|
||||||
onOpenBoard = onOpenBoard,
|
onOpenBoard = onOpenBoard,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
)
|
)
|
||||||
@@ -73,6 +96,7 @@ private fun HubContent(
|
|||||||
hub: ShardHub,
|
hub: ShardHub,
|
||||||
feed: List<FeedLine>,
|
feed: List<FeedLine>,
|
||||||
connected: Boolean,
|
connected: Boolean,
|
||||||
|
features: ShardFeatures?,
|
||||||
onOpenBoard: (ShardBoard) -> Unit,
|
onOpenBoard: (ShardBoard) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
@@ -82,7 +106,7 @@ private fun HubContent(
|
|||||||
contentPadding = androidx.compose.foundation.layout.PaddingValues(vertical = 16.dp),
|
contentPadding = androidx.compose.foundation.layout.PaddingValues(vertical = 16.dp),
|
||||||
) {
|
) {
|
||||||
item { StatusCard(hub.status, hub.presence?.count) }
|
item { StatusCard(hub.status, hub.presence?.count) }
|
||||||
item { BoardsCard(onOpenBoard) }
|
item { BoardsCard(features, onOpenBoard) }
|
||||||
|
|
||||||
if (hub.online.isNotEmpty()) {
|
if (hub.online.isNotEmpty()) {
|
||||||
item { SectionHeader(stringResource(R.string.shard_section_staff)) }
|
item { SectionHeader(stringResource(R.string.shard_section_staff)) }
|
||||||
@@ -159,14 +183,17 @@ private fun StatusCard(status: ShardStatusDto, presenceCount: Int?) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun BoardsCard(onOpenBoard: (ShardBoard) -> Unit) {
|
private fun BoardsCard(features: ShardFeatures?, onOpenBoard: (ShardBoard) -> Unit) {
|
||||||
val boards = listOf(
|
val boards = visibleBoards(features).map { board ->
|
||||||
ShardBoard.CHAMPS to R.string.shard_nav_champs,
|
board to when (board) {
|
||||||
ShardBoard.GUILDS to R.string.shard_nav_guilds,
|
ShardBoard.CHAMPS -> R.string.shard_nav_champs
|
||||||
ShardBoard.GOVERNORS to R.string.shard_nav_governors,
|
ShardBoard.GUILDS -> R.string.shard_nav_guilds
|
||||||
ShardBoard.HOUSES to R.string.shard_nav_houses,
|
ShardBoard.GOVERNORS -> R.string.shard_nav_governors
|
||||||
)
|
ShardBoard.HOUSES -> R.string.shard_nav_houses
|
||||||
Card(Modifier.fillMaxWidth()) {
|
}
|
||||||
|
}
|
||||||
|
if (boards.isEmpty()) return
|
||||||
|
ShardCard(Modifier.fillMaxWidth()) {
|
||||||
Column {
|
Column {
|
||||||
boards.forEachIndexed { index, (board, labelRes) ->
|
boards.forEachIndexed { index, (board, labelRes) ->
|
||||||
Text(
|
Text(
|
||||||
|
|||||||
@@ -10,9 +10,11 @@ import com.runicgateway.app.core.result.ApiResult
|
|||||||
import com.runicgateway.app.data.api.dto.OnlineStaffDto
|
import com.runicgateway.app.data.api.dto.OnlineStaffDto
|
||||||
import com.runicgateway.app.data.api.dto.PresenceDto
|
import com.runicgateway.app.data.api.dto.PresenceDto
|
||||||
import com.runicgateway.app.data.api.dto.ShardStatusDto
|
import com.runicgateway.app.data.api.dto.ShardStatusDto
|
||||||
|
import com.runicgateway.app.data.repository.ShardFeatures
|
||||||
|
import com.runicgateway.app.data.repository.ShardFeaturesRepository
|
||||||
import com.runicgateway.app.data.repository.ShardRepository
|
import com.runicgateway.app.data.repository.ShardRepository
|
||||||
import com.runicgateway.app.ui.UiState
|
import com.runicgateway.app.ui.UiState
|
||||||
import com.runicgateway.app.ui.toUiState
|
import com.runicgateway.app.ui.toShardUiState
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
@@ -39,11 +41,18 @@ data class ShardHub(
|
|||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class ShardViewModel @Inject constructor(
|
class ShardViewModel @Inject constructor(
|
||||||
private val repository: ShardRepository,
|
private val repository: ShardRepository,
|
||||||
|
shardFeaturesRepository: ShardFeaturesRepository,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val _state = MutableStateFlow<UiState<ShardHub>>(UiState.Loading)
|
private val _state = MutableStateFlow<UiState<ShardHub>>(UiState.Loading)
|
||||||
val state: StateFlow<UiState<ShardHub>> = _state.asStateFlow()
|
val state: StateFlow<UiState<ShardHub>> = _state.asStateFlow()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which boards to offer (M11). Read-only here — the app shell refreshes this on
|
||||||
|
* every session change, and the hub only filters its tiles with it.
|
||||||
|
*/
|
||||||
|
val shardFeatures: StateFlow<ShardFeatures?> = shardFeaturesRepository.features
|
||||||
|
|
||||||
private val _feed = MutableStateFlow<List<FeedLine>>(emptyList())
|
private val _feed = MutableStateFlow<List<FeedLine>>(emptyList())
|
||||||
val feed: StateFlow<List<FeedLine>> = _feed.asStateFlow()
|
val feed: StateFlow<List<FeedLine>> = _feed.asStateFlow()
|
||||||
|
|
||||||
@@ -69,8 +78,8 @@ class ShardViewModel @Inject constructor(
|
|||||||
seedFeed()
|
seedFeed()
|
||||||
}
|
}
|
||||||
// Both error variants are ApiResult<Nothing>, so their UiState is Nothing-typed.
|
// Both error variants are ApiResult<Nothing>, so their UiState is Nothing-typed.
|
||||||
is ApiResult.HttpError -> _state.value = status.toUiState()
|
is ApiResult.HttpError -> _state.value = status.toShardUiState()
|
||||||
is ApiResult.NetworkError -> _state.value = status.toUiState()
|
is ApiResult.NetworkError -> _state.value = status.toShardUiState()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,11 @@ import androidx.compose.ui.graphics.Color
|
|||||||
* without the leading `#`) into a Compose [Color]. Returns null for anything
|
* without the leading `#`) into a Compose [Color]. Returns null for anything
|
||||||
* unparseable so the theme falls back to its default scheme (PLAN.md §3, §5).
|
* unparseable so the theme falls back to its default scheme (PLAN.md §3, §5).
|
||||||
* Pure logic — covered by JVM unit tests.
|
* Pure logic — covered by JVM unit tests.
|
||||||
|
*
|
||||||
|
* Also the parser for every color token in the shard's resolved theme map
|
||||||
|
* ([ShardPalette.resolve], M12): the server validates those as `#RGB` or
|
||||||
|
* `#RRGGBB` on write, and a null here is what makes a token that slipped
|
||||||
|
* through anyway cost only itself.
|
||||||
*/
|
*/
|
||||||
fun parseBrandColor(hex: String?): Color? {
|
fun parseBrandColor(hex: String?): Color? {
|
||||||
if (hex.isNullOrBlank()) return null
|
if (hex.isNullOrBlank()) return null
|
||||||
|
|||||||
@@ -6,35 +6,117 @@ package com.runicgateway.app.ui.theme
|
|||||||
import androidx.compose.ui.text.ExperimentalTextApi
|
import androidx.compose.ui.text.ExperimentalTextApi
|
||||||
import androidx.compose.ui.text.font.Font
|
import androidx.compose.ui.text.font.Font
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.font.FontStyle
|
||||||
import androidx.compose.ui.text.font.FontVariation
|
import androidx.compose.ui.text.font.FontVariation
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import com.runicgateway.app.R
|
import com.runicgateway.app.R
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Type families for the M5 shard-website design pass (docs/android/PLAN.md §M5).
|
* Every type family the app can draw with — the eight the admin's Appearance page
|
||||||
|
* can select between (THEMING_AND_NAV.md §5.3) plus the two system stacks its
|
||||||
|
* "shipped default" options resolve to.
|
||||||
*
|
*
|
||||||
* - [Cinzel] — the engraved serif display face used for headings, screen titles,
|
* All eight webfonts are **bundled**, not downloadable: the Play Store font
|
||||||
* and the top-bar title. Shipped as a single weight-axis **variable** font
|
* provider is the only downloadable-font source Compose ships with, so a
|
||||||
* (`res/font/cinzel_variable.ttf`, SIL OFL — see `app/licenses/Cinzel-OFL.txt`);
|
* de-Googled device would silently fall back and every text style would gain an
|
||||||
* the 500/600/700 instances the design uses are pinned via [FontVariation]
|
* async loading state. The binaries are taken verbatim from `google/fonts`, which
|
||||||
* (supported on API 26+, and our minSdk is 29).
|
* is how [Cinzel] arrived in M5; each carries its SIL OFL licence under
|
||||||
* - [AppSerif] — the parchment body face. Android's platform serif is Noto Serif,
|
* `app/licenses/`, **never** under `res/font/` (aapt rejects a `.txt` there).
|
||||||
* which reads as the design's Georgia body copy without bundling another binary.
|
*
|
||||||
* - [AppSans] — the label/meta/button face (the design's "Helvetica Neue" runs).
|
* A family reaches a text style through [ShardTypeface], which is what maps a
|
||||||
|
* shard's `--display` / `--serif` / `--sans` stacks onto these. The three
|
||||||
|
* declared *shipped* roles are [Cinzel] for the engraved display/headline/title
|
||||||
|
* block, [AppSerif] for parchment body copy, and [AppSans] for the letter-spaced
|
||||||
|
* label/meta/button block.
|
||||||
|
*
|
||||||
|
* ## Weights
|
||||||
|
*
|
||||||
|
* The type scale asks for four: 400 (body), 500 and 700 (labels), 600 (display).
|
||||||
|
* Every family here must supply all four, because a family is **not** confined to
|
||||||
|
* the role its dropdown lives in — the `modern` preset puts Work Sans in the
|
||||||
|
* display slot and the `fantasy` preset puts EB Garamond in the sans slot, both
|
||||||
|
* bypassing the server's per-role option list (see [ShardTypeface]). The variable
|
||||||
|
* families pin the four instances through [FontVariation] (API 26+; minSdk is 29).
|
||||||
|
*
|
||||||
|
* Two families are exceptions, both upstream facts rather than choices:
|
||||||
|
* - **[IMFellEnglish] has a single weight.** Its one 400 face answers all four
|
||||||
|
* requests and Android synthesises the bold. The website's dropdown labels it
|
||||||
|
* "(no bold weight)" for the same reason.
|
||||||
|
* - **[Cinzel] is left at the 500/600/700 it shipped with in M5.** It is the only
|
||||||
|
* family the server offers in the display role alone, so nothing can ask it for
|
||||||
|
* 400; adding an instance would have edited M5's type for no reachable case.
|
||||||
|
*
|
||||||
|
* ## Italics
|
||||||
|
*
|
||||||
|
* Four families carry a true italic — the same four `client/index.html` requests
|
||||||
|
* one for. The rest are upright-only and Compose skews them, which is what the
|
||||||
|
* app already did for every family before this milestone and what the website
|
||||||
|
* does for its own upright-only faces. The app draws italic in two places.
|
||||||
*/
|
*/
|
||||||
@OptIn(ExperimentalTextApi::class)
|
@OptIn(ExperimentalTextApi::class)
|
||||||
private fun cinzel(weight: FontWeight) =
|
private fun variable(resId: Int, weight: FontWeight, style: FontStyle = FontStyle.Normal) =
|
||||||
Font(
|
Font(
|
||||||
R.font.cinzel_variable,
|
resId,
|
||||||
weight = weight,
|
weight = weight,
|
||||||
|
style = style,
|
||||||
variationSettings = FontVariation.Settings(FontVariation.weight(weight.weight)),
|
variationSettings = FontVariation.Settings(FontVariation.weight(weight.weight)),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// The four weights the type scale asks for, in the order Compose prefers to match.
|
||||||
|
private val ScaleWeights = listOf(
|
||||||
|
FontWeight.Normal, // 400 — body
|
||||||
|
FontWeight.Medium, // 500 — labelMedium / labelSmall
|
||||||
|
FontWeight.SemiBold, // 600 — display / headline / title
|
||||||
|
FontWeight.Bold, // 700 — labelLarge
|
||||||
|
)
|
||||||
|
|
||||||
|
/** A variable family pinned at the four scale weights, upright only. */
|
||||||
|
private fun variableFamily(resId: Int) =
|
||||||
|
FontFamily(ScaleWeights.map { variable(resId, it) })
|
||||||
|
|
||||||
|
/** A variable family pinned at the four scale weights, upright and italic. */
|
||||||
|
private fun variableFamily(uprightResId: Int, italicResId: Int) =
|
||||||
|
FontFamily(
|
||||||
|
ScaleWeights.map { variable(uprightResId, it) } +
|
||||||
|
ScaleWeights.map { variable(italicResId, it, FontStyle.Italic) },
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun cinzel(weight: FontWeight) = variable(R.font.cinzel_variable, weight)
|
||||||
|
|
||||||
val Cinzel = FontFamily(
|
val Cinzel = FontFamily(
|
||||||
cinzel(FontWeight.Medium), // 500
|
cinzel(FontWeight.Medium), // 500
|
||||||
cinzel(FontWeight.SemiBold), // 600
|
cinzel(FontWeight.SemiBold), // 600
|
||||||
cinzel(FontWeight.Bold), // 700
|
cinzel(FontWeight.Bold), // 700
|
||||||
)
|
)
|
||||||
|
|
||||||
|
val EBGaramond = variableFamily(R.font.eb_garamond_variable, R.font.eb_garamond_italic)
|
||||||
|
val Merriweather = variableFamily(R.font.merriweather_variable, R.font.merriweather_italic)
|
||||||
|
val PlayfairDisplay =
|
||||||
|
variableFamily(R.font.playfair_display_variable, R.font.playfair_display_italic)
|
||||||
|
val Inter = variableFamily(R.font.inter_variable)
|
||||||
|
val WorkSans = variableFamily(R.font.work_sans_variable)
|
||||||
|
val SourceSans3 = variableFamily(R.font.source_sans_3_variable)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* IM Fell English, whose upstream release is a single 400 face per style — there
|
||||||
|
* is no weight axis and no bold cut to pin. Declared once per style so a request
|
||||||
|
* at 500/600/700 lands on it rather than falling out of the family.
|
||||||
|
*/
|
||||||
|
val IMFellEnglish = FontFamily(
|
||||||
|
Font(R.font.im_fell_english_regular, weight = FontWeight.Normal),
|
||||||
|
Font(R.font.im_fell_english_italic, weight = FontWeight.Normal, style = FontStyle.Italic),
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The platform serif (Noto Serif), which is what the website's
|
||||||
|
* `Georgia, "Times New Roman", serif` stack resolves to on Android — and the
|
||||||
|
* app's shipped body face since M5.
|
||||||
|
*/
|
||||||
val AppSerif = FontFamily.Serif
|
val AppSerif = FontFamily.Serif
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The platform sans (Roboto), which the website's
|
||||||
|
* `"Helvetica Neue", Arial, sans-serif` stack resolves to on Android — and the
|
||||||
|
* app's shipped label face since M5.
|
||||||
|
*/
|
||||||
val AppSans = FontFamily.SansSerif
|
val AppSans = FontFamily.SansSerif
|
||||||
|
|||||||
126
app/src/main/java/com/runicgateway/app/ui/theme/ShardPalette.kt
Normal file
126
app/src/main/java/com/runicgateway/app/ui/theme/ShardPalette.kt
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.ui.theme
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Immutable
|
||||||
|
import androidx.compose.runtime.staticCompositionLocalOf
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The shard's resolved color palette — the fifteen themable tokens of
|
||||||
|
* `GET /public/settings`' `theme` map, parsed into Compose colors
|
||||||
|
* (THEMING_AND_NAV.md §5.1).
|
||||||
|
*
|
||||||
|
* **The default value of every field is the shipped constant from
|
||||||
|
* [ui/theme/Color.kt], and that is not an approximation.** The app's M5 palette
|
||||||
|
* *is* the website's `runic-gateway` preset, value for value, because both were
|
||||||
|
* drawn from the same `theme.css`. So [Shipped] renders exactly as the app did
|
||||||
|
* before this milestone, and an instance with no `theme_visual` row resolves
|
||||||
|
* back to it token by token (§2, AC-1).
|
||||||
|
*
|
||||||
|
* The palette has two consumers and one resolution: ten of the fifteen tokens
|
||||||
|
* have a Material role and are fed into the [androidx.compose.material3.ColorScheme]
|
||||||
|
* by [shardColorScheme]; the other five have none, and reach the screens that
|
||||||
|
* need them through [LocalShardPalette].
|
||||||
|
*/
|
||||||
|
@Immutable
|
||||||
|
data class ShardPalette(
|
||||||
|
/** `--bg-deep` — the page behind everything. */
|
||||||
|
val page: Color = ShardPage,
|
||||||
|
/** `--bg` — the screen background. */
|
||||||
|
val surface: Color = ShardSurface,
|
||||||
|
/** `--panel-flat` — top bar, inputs, drawer, list tracks. */
|
||||||
|
val elevated: Color = ShardElevated,
|
||||||
|
/** `--panel-a` — feature-card gradient, top. No Material role. */
|
||||||
|
val cardTop: Color = ShardCardTop,
|
||||||
|
/** `--panel-b` — feature-card gradient, bottom. No Material role. */
|
||||||
|
val cardBottom: Color = ShardCardBottom,
|
||||||
|
/** `--line` — borders and input outlines. */
|
||||||
|
val outline: Color = ShardOutline,
|
||||||
|
/** `--line-soft` — hairline row dividers. */
|
||||||
|
val divider: Color = ShardDivider,
|
||||||
|
/** `--ink` — the brightest headings. No Material role. */
|
||||||
|
val heading: Color = ShardHeading,
|
||||||
|
/** `--head` — heading on a surface. No Material role. */
|
||||||
|
val headingDim: Color = ShardHeadingDim,
|
||||||
|
/** `--text` — body copy. */
|
||||||
|
val body: Color = ShardBody,
|
||||||
|
/** `--muted` — secondary text. */
|
||||||
|
val muted: Color = ShardMuted,
|
||||||
|
/** `--dim` — meta and faint labels. No Material role. */
|
||||||
|
val faint: Color = ShardFaint,
|
||||||
|
/** `--accent` — links and secondary highlights. */
|
||||||
|
val accent: Color = ShardAccent,
|
||||||
|
/** `--accent-bright` — the filled CTA surface. */
|
||||||
|
val cta: Color = ShardCta,
|
||||||
|
/** `--blue` — the neutral/info pill background. */
|
||||||
|
val pillBg: Color = ShardPillBg,
|
||||||
|
) {
|
||||||
|
/**
|
||||||
|
* Text drawn on the [cta] fill. **Derived, never themed** — it tracks
|
||||||
|
* `--bg-deep`, exactly as the server refuses to freeze `--panel-grad` as a
|
||||||
|
* literal (§5.1). A value expressed in terms of another token must follow
|
||||||
|
* it, or a future light preset inherits a dark one and looks broken.
|
||||||
|
*/
|
||||||
|
val onCta: Color get() = page
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The neutral/info pill's foreground. Also derived: `ShardPillFg` and
|
||||||
|
* `ShardCta` are the same `--accent-bright` value, so the pill's text
|
||||||
|
* follows the CTA fill rather than being a sixteenth token the contract
|
||||||
|
* does not have.
|
||||||
|
*/
|
||||||
|
val pillFg: Color get() = cta
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/** The shipped app: the M5 palette, i.e. the `runic-gateway` preset. */
|
||||||
|
val Shipped = ShardPalette()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a `theme` token map into a palette, **field by field** (§2).
|
||||||
|
* A token that is missing, blank or unparseable falls back to its
|
||||||
|
* shipped value on its own; a bad `--accent` must never discard a good
|
||||||
|
* `--bg` beside it (AC-2).
|
||||||
|
*
|
||||||
|
* [brandAccent] is the pre-feature branding path and must keep working:
|
||||||
|
* an instance with a `BRAND_ACCENT_COLOR` but no `theme_visual` row
|
||||||
|
* still tints its links and highlights. It seeds `--accent` only — the
|
||||||
|
* server resolves `brand.accent` as `theme['--accent'] || env`, so the
|
||||||
|
* token always wins where both exist.
|
||||||
|
*/
|
||||||
|
fun resolve(theme: Map<String, String>, brandAccent: Color? = null): ShardPalette {
|
||||||
|
if (theme.isEmpty() && brandAccent == null) return Shipped
|
||||||
|
fun token(name: String, shipped: Color): Color =
|
||||||
|
parseBrandColor(theme[name]) ?: shipped
|
||||||
|
return ShardPalette(
|
||||||
|
page = token("--bg-deep", ShardPage),
|
||||||
|
surface = token("--bg", ShardSurface),
|
||||||
|
elevated = token("--panel-flat", ShardElevated),
|
||||||
|
cardTop = token("--panel-a", ShardCardTop),
|
||||||
|
cardBottom = token("--panel-b", ShardCardBottom),
|
||||||
|
outline = token("--line", ShardOutline),
|
||||||
|
divider = token("--line-soft", ShardDivider),
|
||||||
|
heading = token("--ink", ShardHeading),
|
||||||
|
headingDim = token("--head", ShardHeadingDim),
|
||||||
|
body = token("--text", ShardBody),
|
||||||
|
muted = token("--muted", ShardMuted),
|
||||||
|
faint = token("--dim", ShardFaint),
|
||||||
|
accent = token("--accent", brandAccent ?: ShardAccent),
|
||||||
|
cta = token("--accent-bright", ShardCta),
|
||||||
|
pillBg = token("--blue", ShardPillBg),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The live palette, for the five tokens with no Material role and for the
|
||||||
|
* components that draw the card gradient. Everything that *can* go through
|
||||||
|
* `MaterialTheme.colorScheme` still should — this is the escape hatch, not the
|
||||||
|
* front door.
|
||||||
|
*
|
||||||
|
* Defaulted to [ShardPalette.Shipped] so previews and any composable outside
|
||||||
|
* [RunicGatewayTheme] still draw the shipped palette rather than crashing.
|
||||||
|
*/
|
||||||
|
val LocalShardPalette = staticCompositionLocalOf { ShardPalette.Shipped }
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.ui.theme
|
||||||
|
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.Shapes
|
||||||
|
import androidx.compose.runtime.Immutable
|
||||||
|
import androidx.compose.runtime.staticCompositionLocalOf
|
||||||
|
import androidx.compose.ui.graphics.Shape
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import kotlin.math.abs
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The shard's resolved corner radii and card depth — the `structure` half of the
|
||||||
|
* admin's Appearance page (THEMING_AND_NAV.md §5.2, §5.4), the counterpart to
|
||||||
|
* [ShardPalette].
|
||||||
|
*
|
||||||
|
* **Radii are applied as a ratio, never as a literal.** The app's [Shapes] came
|
||||||
|
* from the M5 mockup and the website's from `theme.css`; the two scales genuinely
|
||||||
|
* differ (`--radius-card` 10px against `medium` 12dp). Copying the web value in
|
||||||
|
* would restyle an untouched app the day this milestone shipped, so each field is
|
||||||
|
* scaled by `resolved ÷ runic-gateway baseline` instead. A shard on the shipped
|
||||||
|
* theme, or one that explicitly picks `runic-gateway`, gives ratio 1.0 on every
|
||||||
|
* field and is a provable no-op (§2, AC-1).
|
||||||
|
*
|
||||||
|
* Card depth is the one thing here that is **not** a no-op — see [ShippedCardElevation].
|
||||||
|
*/
|
||||||
|
@Immutable
|
||||||
|
data class ShardStructure(
|
||||||
|
/** The Material shape scale, ratio-scaled off the app's own shipped dp values. */
|
||||||
|
val shapes: Shapes = ShippedShapes,
|
||||||
|
/**
|
||||||
|
* `--radius-pill`. Not part of [shapes]: the app draws its chips with
|
||||||
|
* [CircleShape], which is a percentage and so has no dp for a ratio to scale.
|
||||||
|
* Resolved as a literal instead — the only rule available — see [pillShape].
|
||||||
|
*/
|
||||||
|
val pill: Shape = CircleShape,
|
||||||
|
/** `--shadow-card`, mapped onto Material elevation (§5.4). */
|
||||||
|
val cardElevation: Dp = ShippedCardElevation,
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
/** The shipped app: the M5 shape scale and the `runic-gateway` card depth. */
|
||||||
|
val Shipped = ShardStructure()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a `theme` token map into a structure, **field by field** (§2):
|
||||||
|
* a `--radius-panel` the server never validated must not cost the
|
||||||
|
* `--radius-card` beside it, exactly as in [ShardPalette.resolve].
|
||||||
|
*/
|
||||||
|
fun resolve(theme: Map<String, String>): ShardStructure {
|
||||||
|
if (theme.isEmpty()) return Shipped
|
||||||
|
val input = ratio(theme["--radius-input"], BaseInputPx)
|
||||||
|
val card = ratio(theme["--radius-card"], BaseCardPx)
|
||||||
|
val panel = ratio(theme["--radius-panel"], BasePanelPx)
|
||||||
|
return ShardStructure(
|
||||||
|
shapes = Shapes(
|
||||||
|
extraSmall = corner(ShippedExtraSmallDp, input),
|
||||||
|
small = corner(ShippedSmallDp, input),
|
||||||
|
medium = corner(ShippedMediumDp, card),
|
||||||
|
// extraLarge has no web counterpart and follows the panel
|
||||||
|
// ratio, since it is the panel family.
|
||||||
|
large = corner(ShippedLargeDp, panel),
|
||||||
|
extraLarge = corner(ShippedExtraLargeDp, panel),
|
||||||
|
),
|
||||||
|
pill = pillShape(theme["--radius-pill"]),
|
||||||
|
cardElevation = elevation(theme["--shadow-card"]),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The live structure, for the two things Material's theme cannot carry: the pill
|
||||||
|
* shape, and a card elevation ([androidx.compose.material3.Card] takes its
|
||||||
|
* elevation as a default argument, not from a composition local). The shape
|
||||||
|
* scale itself reaches screens through `MaterialTheme.shapes` and needs nothing
|
||||||
|
* here.
|
||||||
|
*/
|
||||||
|
val LocalShardStructure = staticCompositionLocalOf { ShardStructure.Shipped }
|
||||||
|
|
||||||
|
// ── the shipped scale ──────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// The app's own dp values, which the ratios scale. Kept here rather than in
|
||||||
|
// Theme.kt so the resolution and the thing it resolves back to sit together.
|
||||||
|
|
||||||
|
private const val ShippedExtraSmallDp = 8
|
||||||
|
private const val ShippedSmallDp = 8
|
||||||
|
private const val ShippedMediumDp = 12
|
||||||
|
private const val ShippedLargeDp = 16
|
||||||
|
private const val ShippedExtraLargeDp = 24
|
||||||
|
|
||||||
|
/** 8dp inputs/chips, 12dp cards, 16dp large surfaces — matching the mockup radii. */
|
||||||
|
internal val ShippedShapes = Shapes(
|
||||||
|
extraSmall = RoundedCornerShape(ShippedExtraSmallDp.dp),
|
||||||
|
small = RoundedCornerShape(ShippedSmallDp.dp),
|
||||||
|
medium = RoundedCornerShape(ShippedMediumDp.dp),
|
||||||
|
large = RoundedCornerShape(ShippedLargeDp.dp),
|
||||||
|
extraLarge = RoundedCornerShape(ShippedExtraLargeDp.dp),
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The depth an unthemed instance draws its cards at.
|
||||||
|
*
|
||||||
|
* **This is the one field of this milestone that is deliberately not a no-op.**
|
||||||
|
* The app has been flat since M5 — Material's filled `Card` is `Level0` and
|
||||||
|
* `FeatureCard` never had the shadow its own docs claimed — while the
|
||||||
|
* `runic-gateway` preset's `--shadow-card` is the "Default" option. §5.4 is
|
||||||
|
* applied as written rather than rebased on the app's flat baseline, so every
|
||||||
|
* card gains this depth and the admin's four-step control reads the same on the
|
||||||
|
* phone as on the web. Approved by the org lead as an amendment to §2.
|
||||||
|
*/
|
||||||
|
private val ShippedCardElevation = 4.dp
|
||||||
|
|
||||||
|
// ── the runic-gateway baselines ───────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// The preset the app's own scale corresponds to (server/src/config/themePresets.js).
|
||||||
|
// A resolved value is meaningful only against these: the ratio, not the number,
|
||||||
|
// is what crosses from the web scale to the app's.
|
||||||
|
|
||||||
|
private const val BaseInputPx = 8f
|
||||||
|
private const val BaseCardPx = 10f
|
||||||
|
private const val BasePanelPx = 12f
|
||||||
|
private const val BasePillPx = 999f
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Below half the pill baseline the chip stops reading as a pill and becomes a
|
||||||
|
* rounded rectangle, so an admin who squares the site off squares off the app's
|
||||||
|
* chips too. Fantasy's 4px and Modern's 8px both land here; `runic-gateway`'s
|
||||||
|
* 999px does not.
|
||||||
|
*/
|
||||||
|
private const val PillCircleFloorPx = BasePillPx / 2f
|
||||||
|
|
||||||
|
// A radius as the server writes it: an integer count of px, 0..999, always with
|
||||||
|
// the unit (`isRadius` in utils/themeResolve.js). Anything else is not a value
|
||||||
|
// this app can scale, and falls back to the shipped dp on its own.
|
||||||
|
private val RadiusPx = Regex("""^\s*(\d{1,3})px\s*$""")
|
||||||
|
|
||||||
|
// The blur of a CSS box-shadow: `0 14px 34px rgba(...)`. The x offset carries no
|
||||||
|
// unit, so the blur is the second px length.
|
||||||
|
private val ShadowLengthPx = Regex("""(\d+(?:\.\d+)?)px""")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `--shadow-card` mapped to elevation, by **nearest blur** rather than by exact
|
||||||
|
* string. §5.4 specified a string match against the server's `SHADOW_OPTIONS`,
|
||||||
|
* but the Fantasy preset publishes `0 16px 38px rgba(0, 0, 0, 0.45)` — a value
|
||||||
|
* `SHADOW_OPTIONS` does not contain, because a preset's own tokens never pass
|
||||||
|
* through that dropdown. An exact match would have missed the one preset whose
|
||||||
|
* point is a heavier shadow. Matching the blur puts any future preset on the
|
||||||
|
* nearest step instead of silently on the default.
|
||||||
|
*/
|
||||||
|
private val ShadowSteps = listOf(20f to 2.dp, 34f to 4.dp, 44f to 8.dp)
|
||||||
|
|
||||||
|
private fun parseRadiusPx(raw: String?): Float? =
|
||||||
|
raw?.let { RadiusPx.find(it) }?.groupValues?.get(1)?.toFloatOrNull()
|
||||||
|
|
||||||
|
private fun ratio(raw: String?, baselinePx: Float): Float =
|
||||||
|
parseRadiusPx(raw)?.let { it / baselinePx } ?: 1f
|
||||||
|
|
||||||
|
/** Scale one shipped dp by its ratio, rounded to whole dp and clamped at 0. */
|
||||||
|
private fun corner(shippedDp: Int, ratio: Float) =
|
||||||
|
RoundedCornerShape((shippedDp * ratio).roundToInt().coerceAtLeast(0).dp)
|
||||||
|
|
||||||
|
private fun pillShape(raw: String?): Shape {
|
||||||
|
val px = parseRadiusPx(raw) ?: return CircleShape
|
||||||
|
return if (px >= PillCircleFloorPx) CircleShape else RoundedCornerShape(px.roundToInt().dp)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun elevation(raw: String?): Dp {
|
||||||
|
val value = raw?.trim() ?: return ShippedCardElevation
|
||||||
|
if (value.equals("none", ignoreCase = true)) return 0.dp
|
||||||
|
val blur = ShadowLengthPx.findAll(value).drop(1).firstOrNull()
|
||||||
|
?.groupValues?.get(1)?.toFloatOrNull()
|
||||||
|
?: return ShippedCardElevation
|
||||||
|
return ShadowSteps.minByOrNull { abs(it.first - blur) }?.second ?: ShippedCardElevation
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.ui.theme
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Immutable
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The shard's resolved type families — the `fonts` third of the admin's
|
||||||
|
* Appearance page (THEMING_AND_NAV.md §5.3), alongside [ShardPalette] and
|
||||||
|
* [ShardStructure].
|
||||||
|
*
|
||||||
|
* The three roles map onto the M5 type scale's three groups verbatim: `--display`
|
||||||
|
* carries the Cinzel display/headline/title block, `--serif` the body block, and
|
||||||
|
* `--sans` the label/meta/button block. Sizes, weights and tracking do not move —
|
||||||
|
* only the family, which is why [shardTypography] is a one-field substitution and
|
||||||
|
* an unthemed shard is a provable no-op (§2, AC-1).
|
||||||
|
*
|
||||||
|
* Resolution is pure, so the acceptance tests need no Compose rule.
|
||||||
|
*/
|
||||||
|
@Immutable
|
||||||
|
data class ShardTypeface(
|
||||||
|
/** `--display`. */
|
||||||
|
val display: FontFamily = Cinzel,
|
||||||
|
/** `--serif`. */
|
||||||
|
val serif: FontFamily = AppSerif,
|
||||||
|
/** `--sans`. */
|
||||||
|
val sans: FontFamily = AppSans,
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
/** The shipped app: the three M5 families. */
|
||||||
|
val Shipped = ShardTypeface()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a `theme` token map into three families, **field by field** (§2):
|
||||||
|
* an unreadable `--sans` must not cost the `--serif` beside it, exactly as
|
||||||
|
* in [ShardPalette.resolve] and [ShardStructure.resolve].
|
||||||
|
*/
|
||||||
|
fun resolve(theme: Map<String, String>): ShardTypeface {
|
||||||
|
if (theme.isEmpty()) return Shipped
|
||||||
|
return ShardTypeface(
|
||||||
|
display = family(theme["--display"]) ?: Shipped.display,
|
||||||
|
serif = family(theme["--serif"]) ?: Shipped.serif,
|
||||||
|
sans = family(theme["--sans"]) ?: Shipped.sans,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every family name the server can publish, keyed by the lowercased first family
|
||||||
|
* of the stack.
|
||||||
|
*
|
||||||
|
* **This map is deliberately global rather than per-role**, and that is not a
|
||||||
|
* simplification. The server validates an *admin-entered* font against
|
||||||
|
* `FONT_OPTIONS[role]`, but a preset's tokens are copied verbatim by
|
||||||
|
* `resolveThemeTokens` and never pass through that list — `modern` publishes
|
||||||
|
* `--display: 'Work Sans', Arial, sans-serif`, which the display dropdown does
|
||||||
|
* not offer, and `fantasy` publishes `--sans: 'EB Garamond', Georgia, serif`,
|
||||||
|
* which the sans dropdown does not either. A per-role lookup would have missed
|
||||||
|
* the display face of one preset and the label face of the other. It is the same
|
||||||
|
* trap phase 2 hit with `--shadow-card`, in a different token group.
|
||||||
|
*
|
||||||
|
* The two system entries are the "shipped default" options: neither pulls in a
|
||||||
|
* webfont on the web, and on Android both resolve to the platform family the app
|
||||||
|
* has drawn with since M5.
|
||||||
|
*/
|
||||||
|
private val FamiliesByFirstName: Map<String, FontFamily> = mapOf(
|
||||||
|
"cinzel" to Cinzel,
|
||||||
|
"eb garamond" to EBGaramond,
|
||||||
|
"merriweather" to Merriweather,
|
||||||
|
"playfair display" to PlayfairDisplay,
|
||||||
|
"im fell english" to IMFellEnglish,
|
||||||
|
"inter" to Inter,
|
||||||
|
"work sans" to WorkSans,
|
||||||
|
"source sans 3" to SourceSans3,
|
||||||
|
"georgia" to AppSerif,
|
||||||
|
"helvetica neue" to AppSans,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a CSS font stack to a family by **its first name**, which is how the
|
||||||
|
* value is constructed server-side and the only part of it that carries the
|
||||||
|
* admin's choice — everything after the first comma is the web's fallback chain,
|
||||||
|
* which Android has no use for.
|
||||||
|
*
|
||||||
|
* Returns `null` for a stack this app cannot draw, so the caller falls back to
|
||||||
|
* the role's shipped family rather than to some other role's.
|
||||||
|
*/
|
||||||
|
private fun family(stack: String?): FontFamily? {
|
||||||
|
val first = stack?.substringBefore(',')?.trim()?.trim('\'', '"')?.trim()
|
||||||
|
if (first.isNullOrEmpty()) return null
|
||||||
|
return FamiliesByFirstName[first.lowercase()]
|
||||||
|
}
|
||||||
@@ -3,78 +3,100 @@
|
|||||||
*/
|
*/
|
||||||
package com.runicgateway.app.ui.theme
|
package com.runicgateway.app.ui.theme
|
||||||
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.material3.ColorScheme
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Shapes
|
|
||||||
import androidx.compose.material3.darkColorScheme
|
import androidx.compose.material3.darkColorScheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.runtime.remember
|
||||||
|
import com.runicgateway.app.data.appearance.SiteAppearance
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The shard-website color scheme (M5 design pass). The app is **dark-only** — the
|
* Maps a resolved [ShardPalette] onto the Material roles (THEMING_AND_NAV.md
|
||||||
* design is a single deep blue-black theme, so there is no light variant and the
|
* §5.1). The app is **dark-only** — the design is a single deep blue-black
|
||||||
* system light/dark setting is intentionally ignored. Material roles are mapped
|
* theme, so there is no light variant and the system light/dark setting is
|
||||||
* onto the palette in [ui/theme/Color.kt] so the ~20 token-based screens take on
|
* intentionally ignored; every v1 preset on the website is dark too.
|
||||||
* the theme without per-screen color work.
|
*
|
||||||
|
* Ten of the palette's fifteen tokens land here, which is why the ~20
|
||||||
|
* token-based screens take on a shard's theme with no per-screen color work.
|
||||||
|
* Pure, so the no-op proof (AC-1) can assert on it directly.
|
||||||
*/
|
*/
|
||||||
private val ShardColorScheme = darkColorScheme(
|
internal fun shardColorScheme(palette: ShardPalette): ColorScheme = darkColorScheme(
|
||||||
primary = ShardCta, // filled CTA buttons
|
primary = palette.cta, // filled CTA buttons
|
||||||
onPrimary = ShardOnCta,
|
onPrimary = palette.onCta,
|
||||||
secondary = ShardAccent, // links / secondary highlights
|
secondary = palette.accent, // links / secondary highlights
|
||||||
onSecondary = ShardOnCta,
|
onSecondary = palette.onCta,
|
||||||
tertiary = ShardAccent,
|
tertiary = palette.accent,
|
||||||
onTertiary = ShardOnCta,
|
onTertiary = palette.onCta,
|
||||||
background = ShardPage,
|
background = palette.page,
|
||||||
onBackground = ShardBody,
|
onBackground = palette.body,
|
||||||
surface = ShardSurface,
|
surface = palette.surface,
|
||||||
onSurface = ShardBody,
|
onSurface = palette.body,
|
||||||
surfaceVariant = ShardElevated,
|
surfaceVariant = palette.elevated,
|
||||||
onSurfaceVariant = ShardMuted,
|
onSurfaceVariant = palette.muted,
|
||||||
surfaceContainer = ShardElevated,
|
surfaceContainer = palette.elevated,
|
||||||
surfaceContainerHigh = ShardElevated,
|
surfaceContainerHigh = palette.elevated,
|
||||||
surfaceContainerLow = ShardSurface,
|
// Material's filled Card takes its container from surfaceContainerHighest —
|
||||||
outline = ShardOutline,
|
// FilledCardTokens.ContainerColor, checked in the 1.3.0 artifact's bytecode.
|
||||||
outlineVariant = ShardDivider,
|
// Leaving it unmapped is what made every ShardCard draw in darkColorScheme()'s
|
||||||
secondaryContainer = ShardPillBg, // neutral chips / selected drawer item
|
// default grey instead of --panel-flat, on themed AND untouched instances alike
|
||||||
onSecondaryContainer = ShardPillFg,
|
// (found on device in phase 8's AC-5 walk; see "Phase 8 as landed").
|
||||||
|
surfaceContainerHighest = palette.elevated,
|
||||||
|
surfaceContainerLow = palette.surface,
|
||||||
|
surfaceContainerLowest = palette.surface,
|
||||||
|
outline = palette.outline,
|
||||||
|
outlineVariant = palette.divider,
|
||||||
|
secondaryContainer = palette.pillBg, // neutral chips / selected drawer item
|
||||||
|
onSecondaryContainer = palette.pillFg,
|
||||||
|
// Semantic, never themed — mirrors the server's FIXED_TOKENS (§4).
|
||||||
error = ShardDanger,
|
error = ShardDanger,
|
||||||
onError = ShardOnCta,
|
onError = palette.onCta,
|
||||||
errorContainer = ShardDangerBg,
|
errorContainer = ShardDangerBg,
|
||||||
onErrorContainer = ShardDanger,
|
onErrorContainer = ShardDanger,
|
||||||
)
|
)
|
||||||
|
|
||||||
/** 8dp inputs/chips, 12dp cards, 16dp large surfaces — matching the mockup radii. */
|
|
||||||
private val ShardShapes = Shapes(
|
|
||||||
extraSmall = RoundedCornerShape(8.dp),
|
|
||||||
small = RoundedCornerShape(8.dp),
|
|
||||||
medium = RoundedCornerShape(12.dp),
|
|
||||||
large = RoundedCornerShape(16.dp),
|
|
||||||
extraLarge = RoundedCornerShape(24.dp),
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* App theme. The color scheme is the fixed shard-website dark palette; when a shard
|
* App theme, themed by the shard (M12). [appearance] carries the resolved token
|
||||||
* publishes a brand accent (PLAN.md §3), it seeds the [MaterialTheme]'s primary and
|
* map the admin's Appearance page publishes; it is applied field by field over
|
||||||
* secondary roles so buttons and highlights carry that shard's color while the rest
|
* the shipped palette and shape scale, so [SiteAppearance.NONE] — no settings
|
||||||
* of the deep blue-black system stays intact. With no accent, the slate default is
|
* rows, a backend that predates the feature, or a settings call that failed —
|
||||||
* used.
|
* renders as the app did before this milestone (§2), the one exception being the
|
||||||
|
* card depth [ShardStructure] documents.
|
||||||
|
*
|
||||||
|
* The palette reaches screens two ways: through [MaterialTheme]'s color scheme
|
||||||
|
* for the ten tokens with a Material role, and through [LocalShardPalette] for
|
||||||
|
* the five without one. The radii split the same way — [MaterialTheme]'s shape
|
||||||
|
* scale for everything Material draws, [LocalShardStructure] for the pill and
|
||||||
|
* the card depth, which it cannot carry. The type families need no split and so
|
||||||
|
* no composition local: every text style in the app comes from
|
||||||
|
* [MaterialTheme.typography], and the two that override anything override the
|
||||||
|
* style rather than the family.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun RunicGatewayTheme(
|
fun RunicGatewayTheme(
|
||||||
accent: Color? = null,
|
appearance: SiteAppearance = SiteAppearance.NONE,
|
||||||
content: @Composable () -> Unit,
|
content: @Composable () -> Unit,
|
||||||
) {
|
) {
|
||||||
val colorScheme = if (accent != null) {
|
val palette = remember(appearance) {
|
||||||
ShardColorScheme.copy(primary = accent, secondary = accent, tertiary = accent)
|
ShardPalette.resolve(
|
||||||
} else {
|
theme = appearance.theme,
|
||||||
ShardColorScheme
|
brandAccent = parseBrandColor(appearance.brand?.accent),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
val colorScheme = remember(palette) { shardColorScheme(palette) }
|
||||||
|
val structure = remember(appearance) { ShardStructure.resolve(appearance.theme) }
|
||||||
|
val typeface = remember(appearance) { ShardTypeface.resolve(appearance.theme) }
|
||||||
|
val typography = remember(typeface) { shardTypography(typeface) }
|
||||||
|
|
||||||
|
CompositionLocalProvider(
|
||||||
|
LocalShardPalette provides palette,
|
||||||
|
LocalShardStructure provides structure,
|
||||||
|
) {
|
||||||
MaterialTheme(
|
MaterialTheme(
|
||||||
colorScheme = colorScheme,
|
colorScheme = colorScheme,
|
||||||
typography = Typography,
|
typography = typography,
|
||||||
shapes = ShardShapes,
|
shapes = structure.shapes,
|
||||||
content = content,
|
content = content,
|
||||||
)
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,73 +9,82 @@ import androidx.compose.ui.text.font.FontWeight
|
|||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The M5 type scale. Three families carry the design (see [ui/theme/Font.kt]):
|
* The M5 type scale, drawn in whichever three families the shard resolved to
|
||||||
* [Cinzel] for the engraved display/headline/title roles, [AppSerif] (Noto Serif)
|
* (THEMING_AND_NAV.md §5.3).
|
||||||
* for parchment body copy, and [AppSans] for the letter-spaced label/meta/button
|
*
|
||||||
* roles. Sizes and tracking mirror the "Runic Gateway Screens" mockup.
|
* Only the family moves. Every size, weight, line height and tracking below is
|
||||||
|
* the M5 value from the "Runic Gateway Screens" mockup, so `ShardTypeface.Shipped`
|
||||||
|
* — an unthemed instance, a backend that predates the feature, a settings call
|
||||||
|
* that failed — reproduces the pre-M12 scale exactly. [androidx.compose.material3.Typography]
|
||||||
|
* implements `equals`, so AC-1 asserts that in one comparison.
|
||||||
|
*
|
||||||
|
* The three groups map onto the three roles verbatim: [ShardTypeface.display]
|
||||||
|
* carries the engraved display/headline/title block, [ShardTypeface.serif] the
|
||||||
|
* parchment body copy, and [ShardTypeface.sans] the letter-spaced
|
||||||
|
* label/meta/button roles.
|
||||||
*/
|
*/
|
||||||
val Typography = Typography(
|
internal fun shardTypography(faces: ShardTypeface) = Typography(
|
||||||
// Display / headline / title — Cinzel engraved serif
|
// Display / headline / title — the engraved serif role
|
||||||
displayLarge = TextStyle(
|
displayLarge = TextStyle(
|
||||||
fontFamily = Cinzel, fontWeight = FontWeight.SemiBold,
|
fontFamily = faces.display, fontWeight = FontWeight.SemiBold,
|
||||||
fontSize = 40.sp, lineHeight = 46.sp, letterSpacing = 0.4.sp,
|
fontSize = 40.sp, lineHeight = 46.sp, letterSpacing = 0.4.sp,
|
||||||
),
|
),
|
||||||
displayMedium = TextStyle(
|
displayMedium = TextStyle(
|
||||||
fontFamily = Cinzel, fontWeight = FontWeight.SemiBold,
|
fontFamily = faces.display, fontWeight = FontWeight.SemiBold,
|
||||||
fontSize = 32.sp, lineHeight = 40.sp, letterSpacing = 0.3.sp,
|
fontSize = 32.sp, lineHeight = 40.sp, letterSpacing = 0.3.sp,
|
||||||
),
|
),
|
||||||
displaySmall = TextStyle(
|
displaySmall = TextStyle(
|
||||||
fontFamily = Cinzel, fontWeight = FontWeight.SemiBold,
|
fontFamily = faces.display, fontWeight = FontWeight.SemiBold,
|
||||||
fontSize = 28.sp, lineHeight = 36.sp, letterSpacing = 0.2.sp,
|
fontSize = 28.sp, lineHeight = 36.sp, letterSpacing = 0.2.sp,
|
||||||
),
|
),
|
||||||
headlineLarge = TextStyle(
|
headlineLarge = TextStyle(
|
||||||
fontFamily = Cinzel, fontWeight = FontWeight.SemiBold,
|
fontFamily = faces.display, fontWeight = FontWeight.SemiBold,
|
||||||
fontSize = 26.sp, lineHeight = 34.sp, letterSpacing = 0.2.sp,
|
fontSize = 26.sp, lineHeight = 34.sp, letterSpacing = 0.2.sp,
|
||||||
),
|
),
|
||||||
headlineMedium = TextStyle(
|
headlineMedium = TextStyle(
|
||||||
fontFamily = Cinzel, fontWeight = FontWeight.SemiBold,
|
fontFamily = faces.display, fontWeight = FontWeight.SemiBold,
|
||||||
fontSize = 24.sp, lineHeight = 32.sp, letterSpacing = 0.2.sp,
|
fontSize = 24.sp, lineHeight = 32.sp, letterSpacing = 0.2.sp,
|
||||||
),
|
),
|
||||||
headlineSmall = TextStyle(
|
headlineSmall = TextStyle(
|
||||||
fontFamily = Cinzel, fontWeight = FontWeight.SemiBold,
|
fontFamily = faces.display, fontWeight = FontWeight.SemiBold,
|
||||||
fontSize = 22.sp, lineHeight = 28.sp, letterSpacing = 0.2.sp,
|
fontSize = 22.sp, lineHeight = 28.sp, letterSpacing = 0.2.sp,
|
||||||
),
|
),
|
||||||
titleLarge = TextStyle(
|
titleLarge = TextStyle(
|
||||||
fontFamily = Cinzel, fontWeight = FontWeight.SemiBold,
|
fontFamily = faces.display, fontWeight = FontWeight.SemiBold,
|
||||||
fontSize = 20.sp, lineHeight = 26.sp, letterSpacing = 0.2.sp,
|
fontSize = 20.sp, lineHeight = 26.sp, letterSpacing = 0.2.sp,
|
||||||
),
|
),
|
||||||
titleMedium = TextStyle(
|
titleMedium = TextStyle(
|
||||||
fontFamily = Cinzel, fontWeight = FontWeight.SemiBold,
|
fontFamily = faces.display, fontWeight = FontWeight.SemiBold,
|
||||||
fontSize = 17.sp, lineHeight = 24.sp, letterSpacing = 0.15.sp,
|
fontSize = 17.sp, lineHeight = 24.sp, letterSpacing = 0.15.sp,
|
||||||
),
|
),
|
||||||
titleSmall = TextStyle(
|
titleSmall = TextStyle(
|
||||||
fontFamily = Cinzel, fontWeight = FontWeight.SemiBold,
|
fontFamily = faces.display, fontWeight = FontWeight.SemiBold,
|
||||||
fontSize = 15.sp, lineHeight = 22.sp, letterSpacing = 0.1.sp,
|
fontSize = 15.sp, lineHeight = 22.sp, letterSpacing = 0.1.sp,
|
||||||
),
|
),
|
||||||
// Body — parchment serif
|
// Body — the parchment serif role
|
||||||
bodyLarge = TextStyle(
|
bodyLarge = TextStyle(
|
||||||
fontFamily = AppSerif, fontWeight = FontWeight.Normal,
|
fontFamily = faces.serif, fontWeight = FontWeight.Normal,
|
||||||
fontSize = 16.sp, lineHeight = 26.sp, letterSpacing = 0.15.sp,
|
fontSize = 16.sp, lineHeight = 26.sp, letterSpacing = 0.15.sp,
|
||||||
),
|
),
|
||||||
bodyMedium = TextStyle(
|
bodyMedium = TextStyle(
|
||||||
fontFamily = AppSerif, fontWeight = FontWeight.Normal,
|
fontFamily = faces.serif, fontWeight = FontWeight.Normal,
|
||||||
fontSize = 15.sp, lineHeight = 24.sp, letterSpacing = 0.15.sp,
|
fontSize = 15.sp, lineHeight = 24.sp, letterSpacing = 0.15.sp,
|
||||||
),
|
),
|
||||||
bodySmall = TextStyle(
|
bodySmall = TextStyle(
|
||||||
fontFamily = AppSerif, fontWeight = FontWeight.Normal,
|
fontFamily = faces.serif, fontWeight = FontWeight.Normal,
|
||||||
fontSize = 13.sp, lineHeight = 20.sp, letterSpacing = 0.2.sp,
|
fontSize = 13.sp, lineHeight = 20.sp, letterSpacing = 0.2.sp,
|
||||||
),
|
),
|
||||||
// Labels / meta / buttons — sans, letter-spaced
|
// Labels / meta / buttons — the sans role, letter-spaced
|
||||||
labelLarge = TextStyle(
|
labelLarge = TextStyle(
|
||||||
fontFamily = AppSans, fontWeight = FontWeight.Bold,
|
fontFamily = faces.sans, fontWeight = FontWeight.Bold,
|
||||||
fontSize = 14.sp, lineHeight = 18.sp, letterSpacing = 0.45.sp,
|
fontSize = 14.sp, lineHeight = 18.sp, letterSpacing = 0.45.sp,
|
||||||
),
|
),
|
||||||
labelMedium = TextStyle(
|
labelMedium = TextStyle(
|
||||||
fontFamily = AppSans, fontWeight = FontWeight.Medium,
|
fontFamily = faces.sans, fontWeight = FontWeight.Medium,
|
||||||
fontSize = 12.sp, lineHeight = 16.sp, letterSpacing = 0.4.sp,
|
fontSize = 12.sp, lineHeight = 16.sp, letterSpacing = 0.4.sp,
|
||||||
),
|
),
|
||||||
labelSmall = TextStyle(
|
labelSmall = TextStyle(
|
||||||
fontFamily = AppSans, fontWeight = FontWeight.Medium,
|
fontFamily = faces.sans, fontWeight = FontWeight.Medium,
|
||||||
fontSize = 11.sp, lineHeight = 15.sp, letterSpacing = 0.5.sp,
|
fontSize = 11.sp, lineHeight = 15.sp, letterSpacing = 0.5.sp,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import androidx.compose.foundation.lazy.LazyColumn
|
|||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.text.KeyboardActions
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.material3.Card
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
@@ -30,6 +29,7 @@ import com.runicgateway.app.ui.UiState
|
|||||||
import com.runicgateway.app.ui.components.EmptyView
|
import com.runicgateway.app.ui.components.EmptyView
|
||||||
import com.runicgateway.app.ui.components.ErrorView
|
import com.runicgateway.app.ui.components.ErrorView
|
||||||
import com.runicgateway.app.ui.components.LoadingView
|
import com.runicgateway.app.ui.components.LoadingView
|
||||||
|
import com.runicgateway.app.ui.components.ShardCard
|
||||||
|
|
||||||
/** Wiki index: search field + page list (PLAN.md §6.1). */
|
/** Wiki index: search field + page list (PLAN.md §6.1). */
|
||||||
@Composable
|
@Composable
|
||||||
@@ -72,7 +72,7 @@ fun WikiScreen(
|
|||||||
private fun WikiList(pages: List<WikiSummaryDto>, onOpenPage: (String) -> Unit) {
|
private fun WikiList(pages: List<WikiSummaryDto>, onOpenPage: (String) -> Unit) {
|
||||||
LazyColumn(modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp)) {
|
LazyColumn(modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp)) {
|
||||||
items(pages, key = { it.id }) { page ->
|
items(pages, key = { it.id }) { page ->
|
||||||
Card(
|
ShardCard(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(vertical = 6.dp)
|
.padding(vertical = 6.dp)
|
||||||
|
|||||||
BIN
app/src/main/res/font/eb_garamond_italic.ttf
Normal file
BIN
app/src/main/res/font/eb_garamond_italic.ttf
Normal file
Binary file not shown.
BIN
app/src/main/res/font/eb_garamond_variable.ttf
Normal file
BIN
app/src/main/res/font/eb_garamond_variable.ttf
Normal file
Binary file not shown.
BIN
app/src/main/res/font/im_fell_english_italic.ttf
Normal file
BIN
app/src/main/res/font/im_fell_english_italic.ttf
Normal file
Binary file not shown.
BIN
app/src/main/res/font/im_fell_english_regular.ttf
Normal file
BIN
app/src/main/res/font/im_fell_english_regular.ttf
Normal file
Binary file not shown.
BIN
app/src/main/res/font/inter_variable.ttf
Normal file
BIN
app/src/main/res/font/inter_variable.ttf
Normal file
Binary file not shown.
BIN
app/src/main/res/font/merriweather_italic.ttf
Normal file
BIN
app/src/main/res/font/merriweather_italic.ttf
Normal file
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user