4 Commits

Author SHA1 Message Date
68da9da805 ci(release): auto-release engine (conventional commits) for the APK
Replace the tag-triggered release.yml with link/'s language-agnostic release
engine, adapted for Android. On every push to main it derives the next version
from conventional-commit subjects since the last v* tag (feat!/BREAKING -> major,
feat -> minor, fix|perf -> patch; nothing releasable -> no release), generates a
grouped changelog, bumps versionName in build.gradle.kts (versionCode derived
major*10000+minor*100+patch, monotonic), builds the SIGNED release APK, then
commits the bump [skip ci], tags vX.Y.Z, and creates the Gitea release with the
notes + APK + SHA256SUMS.

Uses REGISTRY_USER/REGISTRY_TOKEN (write:repository) to push the bump + create
the release, matching link/. main must allow that account to push (bump lands on
main; the [skip ci] + head-commit guard prevent a re-trigger loop). Signing
secrets (ANDROID_KEYSTORE_BASE64/_PASSWORD, ANDROID_KEY_ALIAS/_PASSWORD) unchanged.
Same self-hosted-runner handling as pr-checks.yml (apt JDK 17, sdkmanager, chmod).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-20 04:23:23 -05:00
0df862a6af feat(release): m6 release mechanics — signed APK, R8, version guard, icons
All checks were successful
PR Checks / android-build (pull_request) Successful in 9m52s
Release-hardening pass (PLAN.md §9 M6, §10, §12). No architecture, data-flow,
or endpoint changes; the app remains a pure API client.

App icons (default brand assets):
- New gateway-medallion launcher icon set (all densities, adaptive fg/bg, round,
  Play Store icon) + an RG notification icon staged for M7 push.
- Replace the Image Asset wizard's default green-grid adaptive background with the
  deep-indigo brand fill (@color/ic_launcher_background #1B1033); recomposite the
  legacy square/round webps and the 512 Play icon over indigo so the whole set is
  coherent (the green never shipped). Restore the SPDX headers the wizard stripped;
  drop the orphaned placeholder foreground vector. No <monochrome> layer — the
  full-colour medallion has no clean silhouette, so themed mode falls back to the
  standard icon rather than a tinted blob.

Version-mismatch guard (§3):
- The connect probe now refuses a Runic Gateway backend whose API version this
  build can't speak (e.g. a future v2) with a clear "app out of date" message,
  instead of mis-rendering; lenient on a blank api (older backend). Decision logic
  extracted to a pure ConnectionRepository.evaluateVersion() with unit tests.

Release build hardening (§7, §12):
- Enable R8 full-mode minify + resource shrink for release (~31 MB debug -> 4.2 MB
  signed release). ProGuard keep-rules for kotlinx.serialization serializers + our
  wire DTOs, Retrofit service interfaces, and a -dontwarn for Tink's compile-only
  Error Prone annotations (EncryptedSharedPreferences).
- Release signingConfig reads keystore material from a gitignored keystore.properties
  or env vars; absent -> unsigned (debug + PR gate unaffected). Keystore never in repo.
- versionName/versionCode overridable via -P so the release tag + CI run number
  drive them (§10).

CI:
- release.yml: on a `v*` tag, build a SIGNED release APK (keystore from a base64
  Gitea secret) and attach it + SHA256SUMS to a Gitea release; workflow_dispatch is
  a signing dry run. Mirrors pr-checks.yml's self-hosted-runner handling (apt JDK 17,
  explicit sdkmanager, in-step chmod +x gradlew).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-20 03:35:06 -05:00
e497e6c8a7 Merge pull request 'feat(ui): m5 shard-website design pass' (#10) from feat/m5-design-pass into main
Reviewed-on: #10
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-20 07:48:30 +00:00
52e06da8fc feat(ui): m5 shard-website design pass
All checks were successful
PR Checks / android-build (pull_request) Successful in 10m6s
Restyle every Android screen with the shard-website theme from the
"Runic Gateway Screens" design (docs/android/PLAN.md §M5): deep blue-black
surfaces, a slate-blue accent, parchment serif body copy, and an engraved
Cinzel serif display face. The app is now dark-only, matching the design.

Theme layer (propagates to all token-based screens):
- Color.kt: replace the placeholder purple palette with named shard tokens.
- Theme.kt: one dark color scheme mapped onto the palette + 8/12/16dp shapes;
  drop the light branch; keep optional per-shard brand-accent seeding.
- Type.kt: full type scale — Cinzel display/headline/title, serif body,
  letter-spaced sans labels/buttons.
- Font.kt + res/font/cinzel_variable.ttf (SIL OFL, app/licenses/Cinzel-OFL.txt):
  the Cinzel display family, pinned to 500/600/700 via FontVariation.

Shared components (ui/components/ThemeComponents.kt): StatusPill (semantic
tones), OnlineDot, SectionLabel, FeatureCard (gradient), StatBar — adopted
across Home, Account, Shard hub, Champs, Characters, Houses, and the
character sheet (vitals/skills meters).

Shell: dark top-bar + drawer styling; dark launch theme and light system-bar
icons so the first frame matches (no white flash).

Build (assembleDebug) and unit tests green.

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NgyHnrNa8WwG3doxvxjuCr
2026-07-20 01:51:51 -05:00
52 changed files with 1142 additions and 175 deletions

View File

@@ -0,0 +1,269 @@
# Automated build + release for the Runic Gateway Android app.
#
# Trigger: every push to `main` (i.e. every merged PR).
#
# Flow (two conceptual halves, kept separate on purpose) — mirrors link/'s engine:
#
# ┌── RELEASE ENGINE (language-agnostic) ─────────────────────────────┐
# │ reads: latest v* git tag + conventional-commit subjects │
# │ produces: next version, changelog, and (at the end) the release │
# └───────────────────────────────────────────────────────────────────┘
# ┌── ANDROID ADAPTER (the only Android-specific part) ───────────────┐
# │ consumes: the version │
# │ produces: the artifact (a signed release APK + SHA256SUMS) │
# └───────────────────────────────────────────────────────────────────┘
#
# Version bump (conventional commits since the last v* tag):
# feat!: / BREAKING CHANGE -> major feat: -> minor fix|perf: -> patch
# nothing releasable -> no release is cut
# (first ever run, no tag) -> releases the current build.gradle.kts version as-is
# versionName is the semver; versionCode is derived major*10000+minor*100+patch so
# it is deterministic + monotonic (PLAN.md §10). The bump is committed back to
# app/build.gradle.kts, then tagged.
#
# Prerequisites (Settings -> Actions -> Secrets on RunicGateway/Android-app):
# REGISTRY_USER — Gitea username the token below belongs to
# REGISTRY_TOKEN — Gitea access token with `write:repository` (push the bump
# commit + tag and create the release)
# ANDROID_KEYSTORE_BASE64 — base64 of the release .jks (single line)
# ANDROID_KEYSTORE_PASSWORD — keystore password
# ANDROID_KEY_ALIAS — key alias (e.g. runicgateway)
# ANDROID_KEY_PASSWORD — key password (== store password for a PKCS12 keystore)
# Also: `main` must accept a direct push from the REGISTRY_USER account (disable
# branch protection for it, or add it as an exception) — the bump commit lands on
# main. The bump commit carries `[skip ci]`, so it does not re-trigger this workflow.
#
# Runner handling matches pr-checks.yml (self-hosted `ubuntu-latest`): the container
# lacks git/curl/unzip and can't reach api.adoptium.net, so we apt-install the base
# tools + JDK 17 (not actions/setup-java), install the exact SDK packages, and
# `chmod +x ./gradlew` in-step (checkout drops the exec bit).
name: Release APK
on:
push:
branches: [main]
workflow_dispatch: {}
concurrency:
group: release-apk
cancel-in-progress: false
env:
GITEA_HOST: gitea.whitlocktech.com
REPO: RunicGateway/Android-app
GRADLE_MODULE: app
jobs:
release:
runs-on: ubuntu-latest
# Don't loop on our own bump commit (belt-and-suspenders with [skip ci]).
# Quoted because the expression contains a colon (`chore(release):`), which an
# unquoted YAML scalar would misparse as a mapping value.
if: "${{ !contains(github.event.head_commit.message, 'chore(release): bump version') }}"
steps:
- name: Install base tools + JDK 17
run: |
apt-get update
apt-get install -y git curl unzip jq openjdk-17-jdk-headless
echo "JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64" >> "$GITHUB_ENV"
- name: Check out full history (need tags + commit log for the bump)
uses: actions/checkout@v4
with:
fetch-depth: 0
# ── RELEASE ENGINE: decide the next version + changelog ──────────────
- name: Plan the release (version + changelog)
id: plan
run: |
set -euo pipefail
mkdir -p dist
git fetch --tags --force >/dev/null 2>&1 || true
# Current committed version (the `?: "x.y.z"` default in build.gradle.kts).
MANIFEST_VERSION="$(sed -nE 's/.*\?: "([0-9]+\.[0-9]+\.[0-9]+)".*/\1/p' "${GRADLE_MODULE}/build.gradle.kts" | head -1)"
LAST_TAG="$(git describe --tags --match 'v*' --abbrev=0 2>/dev/null || true)"
if [ -n "$LAST_TAG" ]; then RANGE="${LAST_TAG}..HEAD"; else RANGE="HEAD"; fi
SUBJECTS="$(git log --no-merges --format='%s' $RANGE || true)"
BODIES="$(git log --no-merges --format='%B' $RANGE || true)"
BUMP=none
if echo "$BODIES" | grep -qE 'BREAKING[ -]CHANGE' ; then BUMP=major; fi
if echo "$SUBJECTS" | grep -qE '^[a-z]+(\([^)]+\))?!:' ; then BUMP=major; fi
if [ "$BUMP" = none ] && echo "$SUBJECTS" | grep -qE '^feat(\([^)]+\))?:' ; then BUMP=minor; fi
if [ "$BUMP" = none ] && echo "$SUBJECTS" | grep -qE '^(fix|perf)(\([^)]+\))?:'; then BUMP=patch; fi
bump() { # <x.y.z> <major|minor|patch> -> bumped
IFS=. read -r MA MI PA <<< "$1"
case "$2" in
major) echo "$((MA+1)).0.0" ;;
minor) echo "${MA}.$((MI+1)).0" ;;
patch) echo "${MA}.${MI}.$((PA+1))" ;;
esac
}
RELEASE=true
if [ -z "$LAST_TAG" ]; then
VERSION="$MANIFEST_VERSION" # first release: ship what's committed
elif [ "$BUMP" = none ]; then
RELEASE=false # no feat/fix/breaking since last tag
VERSION="${LAST_TAG#v}"
else
VERSION="$(bump "${LAST_TAG#v}" "$BUMP")"
fi
if git rev-parse -q --verify "refs/tags/v${VERSION}" >/dev/null; then
echo "Tag v${VERSION} already exists — nothing to release."
RELEASE=false
fi
# Derive a deterministic, monotonic versionCode from the semver.
IFS=. read -r MA MI PA <<< "$VERSION"
VERSION_CODE=$(( MA*10000 + MI*100 + PA ))
{
echo "## Runic Gateway Android v${VERSION}"
echo
FEATS="$(echo "$SUBJECTS" | grep -E '^feat' || true)"
FIXES="$(echo "$SUBJECTS" | grep -E '^(fix|perf)' || true)"
[ -n "$FEATS" ] && { echo "### Features"; echo "$FEATS" | sed 's/^/- /'; echo; }
[ -n "$FIXES" ] && { echo "### Fixes"; echo "$FIXES" | sed 's/^/- /'; echo; }
echo "### All changes"
if [ -n "$LAST_TAG" ]; then echo "Since ${LAST_TAG}:"; fi
echo "$SUBJECTS" | sed 's/^/- /'
echo
echo "---"
echo "Signed APK — sideload on Android 10+ (§10). The app self-configures its shard site on first run."
} > dist/CHANGELOG.md
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "versionCode=${VERSION_CODE}" >> "$GITHUB_OUTPUT"
echo "tag=v${VERSION}" >> "$GITHUB_OUTPUT"
echo "release=${RELEASE}" >> "$GITHUB_OUTPUT"
echo "bump=${BUMP}" >> "$GITHUB_OUTPUT"
echo "==> release=${RELEASE} version=${VERSION} code=${VERSION_CODE} bump=${BUMP} last_tag=${LAST_TAG:-<none>}"
# ── ANDROID ADAPTER: SDK + signing keystore ──────────────────────────
- name: Set up Android SDK
if: ${{ steps.plan.outputs.release == 'true' }}
uses: android-actions/setup-android@v3
- name: Install Android SDK packages
if: ${{ steps.plan.outputs.release == 'true' }}
run: |
set +o pipefail
yes | sdkmanager "platform-tools" "platforms;android-35" "build-tools;35.0.0"
- name: Cache Gradle
if: ${{ steps.plan.outputs.release == 'true' }}
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 }}-
- name: Decode signing keystore
if: ${{ steps.plan.outputs.release == 'true' }}
env:
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
run: |
set -euo pipefail
if [ -z "${ANDROID_KEYSTORE_BASE64:-}" ]; then
echo "::error::ANDROID_KEYSTORE_BASE64 secret is not set — cannot build a signed release."
exit 1
fi
printf '%s' "$ANDROID_KEYSTORE_BASE64" | base64 -d > "${RUNNER_TEMP}/release.jks"
echo "ANDROID_KEYSTORE_FILE=${RUNNER_TEMP}/release.jks" >> "$GITHUB_ENV"
# ── ANDROID ADAPTER: set the version, gate, build the signed APK ─────
- name: Set the app version to match the release
if: ${{ steps.plan.outputs.release == 'true' }}
run: |
set -euo pipefail
VERSION="${{ steps.plan.outputs.version }}"
VERSION_CODE="${{ steps.plan.outputs.versionCode }}"
# Replace only the version defaults (the `?: "x.y.z"` / `?: N` fallbacks).
sed -i -E "s/(\?: )\"[0-9]+\.[0-9]+\.[0-9]+\"/\1\"${VERSION}\"/" "${GRADLE_MODULE}/build.gradle.kts"
sed -i -E "s/(toIntOrNull\(\) \?: )[0-9]+/\1${VERSION_CODE}/" "${GRADLE_MODULE}/build.gradle.kts"
grep -nE "versionCode = |versionName = " "${GRADLE_MODULE}/build.gradle.kts"
- name: Unit tests + signed release APK
if: ${{ steps.plan.outputs.release == 'true' }}
env:
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
run: |
set -euo pipefail
chmod +x ./gradlew
./gradlew --no-daemon :${GRADLE_MODULE}:testDebugUnitTest :${GRADLE_MODULE}:assembleRelease
- name: Package APK + SHA256SUMS
if: ${{ steps.plan.outputs.release == 'true' }}
run: |
set -euo pipefail
SRC="${GRADLE_MODULE}/build/outputs/apk/release/app-release.apk"
test -f "$SRC" || { echo "::error::release APK not found at $SRC"; exit 1; }
cp "$SRC" "dist/runic-gateway-${{ steps.plan.outputs.version }}.apk"
( cd dist && sha256sum "runic-gateway-${{ steps.plan.outputs.version }}.apk" > SHA256SUMS )
ls -l dist && cat dist/SHA256SUMS
# ── RELEASE ENGINE: commit the bump, tag, push ───────────────────────
- name: Commit version bump and push tag
if: ${{ steps.plan.outputs.release == 'true' }}
env:
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
set -euo pipefail
TAG="${{ steps.plan.outputs.tag }}"
# Secrets can arrive with a trailing newline; a stray CR/LF corrupts the
# remote URL / auth header. Strip line breaks before use.
CI_USER="$(printf '%s' "${REGISTRY_USER}" | tr -d '\r\n')"
CI_TOKEN="$(printf '%s' "${REGISTRY_TOKEN}" | tr -d '\r\n')"
git config user.name "android-app-ci"
git config user.email "ci@whitlocktech.com"
git remote set-url origin \
"https://${CI_USER}:${CI_TOKEN}@${GITEA_HOST}/${REPO}.git"
git add "${GRADLE_MODULE}/build.gradle.kts"
if ! git diff --cached --quiet; then
git commit -m "chore(release): bump version to ${TAG} [skip ci]"
git push origin "HEAD:main"
else
echo "Version unchanged (first release) — no bump commit needed."
fi
git tag "${TAG}"
git push origin "${TAG}"
# ── RELEASE ENGINE: create the Gitea release + upload assets ─────────
- name: Create Gitea release and upload assets
if: ${{ steps.plan.outputs.release == 'true' }}
env:
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
set -euo pipefail
TAG="${{ steps.plan.outputs.tag }}"
API="https://${GITEA_HOST}/api/v1/repos/${REPO}"
BODY="$(cat dist/CHANGELOG.md)"
CI_TOKEN="$(printf '%s' "${REGISTRY_TOKEN}" | tr -d '\r\n')"
REL_ID="$(curl -sSf -X POST "${API}/releases" \
-H "Authorization: token ${CI_TOKEN}" \
-H "Content-Type: application/json" \
-d "$(jq -n --arg tag "$TAG" --arg body "$BODY" \
'{tag_name:$tag, name:$tag, body:$body, draft:false, prerelease:false}')" \
| jq -r '.id')"
echo "Created release ${TAG} (id=${REL_ID})"
for f in "runic-gateway-${{ steps.plan.outputs.version }}.apk" SHA256SUMS; do
curl -sSf -X POST "${API}/releases/${REL_ID}/assets?name=${f}" \
-H "Authorization: token ${CI_TOKEN}" \
-F "attachment=@dist/${f}" >/dev/null
echo " uploaded ${f}"
done

View File

@@ -1,5 +1,8 @@
// SPDX-License-Identifier: GPL-3.0-or-later // SPDX-License-Identifier: GPL-3.0-or-later
import java.io.FileInputStream
import java.util.Properties
plugins { plugins {
alias(libs.plugins.android.application) alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.android)
@@ -9,6 +12,25 @@ plugins {
alias(libs.plugins.hilt) alias(libs.plugins.hilt)
} }
// Release signing material (PLAN.md §12) is never committed. It is read from, in
// order of precedence: a local gitignored `keystore.properties` at the repo root,
// then environment variables (how CI injects the decoded keystore + secrets). When
// none is present, the release build is simply left unsigned — `assembleDebug` and
// the PR gate are unaffected, so contributors without the keystore can still build.
val keystorePropsFile = rootProject.file("keystore.properties")
val keystoreProps = Properties().apply {
if (keystorePropsFile.exists()) FileInputStream(keystorePropsFile).use { load(it) }
}
fun signingValue(propKey: String, envKey: String): String? =
keystoreProps.getProperty(propKey) ?: System.getenv(envKey)
val ksStoreFilePath = signingValue("storeFile", "ANDROID_KEYSTORE_FILE")
val ksStorePassword = signingValue("storePassword", "ANDROID_KEYSTORE_PASSWORD")
val ksKeyAlias = signingValue("keyAlias", "ANDROID_KEY_ALIAS")
val ksKeyPassword = signingValue("keyPassword", "ANDROID_KEY_PASSWORD")
val hasReleaseSigning = ksStoreFilePath != null && ksStorePassword != null &&
ksKeyAlias != null && ksKeyPassword != null
android { android {
namespace = "com.runicgateway.app" namespace = "com.runicgateway.app"
compileSdk = 35 compileSdk = 35
@@ -18,20 +40,46 @@ android {
applicationId = "com.runicgateway.app" applicationId = "com.runicgateway.app"
minSdk = 29 minSdk = 29
targetSdk = 35 targetSdk = 35
versionCode = 1 // These committed defaults are the version source of truth (PLAN.md §10).
versionName = "0.1.0" // release.yml's conventional-commit engine bumps versionName here and commits
// it on release; versionCode is derived from it (major*10000+minor*100+patch)
// so it stays monotonic. Both remain overridable via -P for local/manual builds.
versionCode = (project.findProperty("versionCode") as String?)?.toIntOrNull() ?: 1
versionName = (project.findProperty("versionName") as String?)?.takeIf { it.isNotBlank() } ?: "0.1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }
signingConfigs {
if (hasReleaseSigning) {
create("release") {
storeFile = file(ksStoreFilePath!!)
storePassword = ksStorePassword
keyAlias = ksKeyAlias
keyPassword = ksKeyPassword
// Sign with both v1 (JAR) and v2 (APK) schemes for broad compatibility.
enableV1Signing = true
enableV2Signing = true
}
}
}
buildTypes { buildTypes {
release { release {
// Signing/minification are wired at M6 (release hardening). Debug is auto-signed. // R8 full-mode minify + resource shrink (§7: no offline cache, so a lean
isMinifyEnabled = false // release APK). Keep rules live in proguard-rules.pro.
isMinifyEnabled = true
isShrinkResources = true
proguardFiles( proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"), getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro", "proguard-rules.pro",
) )
// Signed only when the keystore material is present (local or CI); an
// unsigned APK is produced otherwise. The direct-APK release (§10) runs
// through release.yml, which supplies the keystore from a Gitea secret.
if (hasReleaseSigning) {
signingConfig = signingConfigs.getByName("release")
}
} }
} }

View File

@@ -0,0 +1,93 @@
Copyright 2020 The Cinzel Project Authors (https://github.com/NDISCOVER/Cinzel)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

View File

@@ -1,3 +1,59 @@
# Runic Gateway Android app — ProGuard/R8 rules. # Runic Gateway Android app — ProGuard/R8 rules (release minify + resource shrink, M6).
# Minification is disabled until M6 (release hardening); real keep rules for #
# kotlinx.serialization DTOs and Retrofit models are added there. # The dependency stack ships its own consumer rules that R8 applies automatically:
# Retrofit 2.11, OkHttp 4.12, kotlinx.serialization 1.7 (core), Hilt/Dagger, Coil 2.7.
# The rules below are defensive belt-and-suspenders for the areas full-mode R8 is
# most likely to over-strip in this app: the kotlinx.serialization generated
# serializers and our own @Serializable wire DTOs.
# ── kotlinx.serialization (canonical keep rules) ────────────────────────────
-keepattributes *Annotation*, InnerClasses
-dontnote kotlinx.serialization.**
# Keep the Companion of @Serializable classes so `.serializer()` resolves.
-if @kotlinx.serialization.Serializable class **
-keepclassmembers class <1> {
static <1>$Companion Companion;
}
-if @kotlinx.serialization.Serializable class ** {
static **$Companion Companion;
}
-keepclassmembers class <2>$Companion {
kotlinx.serialization.KSerializer serializer(...);
}
# Keep `INSTANCE.serializer()` of @Serializable objects.
-if @kotlinx.serialization.Serializable class ** {
public static ** INSTANCE;
}
-keepclassmembers class <1> {
public static <1> INSTANCE;
kotlinx.serialization.KSerializer serializer(...);
}
# Keep the synthesized $$serializer classes and their descriptor field.
-keepclassmembers class **$$serializer {
*** descriptor;
}
# ── Our wire DTOs ───────────────────────────────────────────────────────────
# All request/response models decoded by kotlinx.serialization. Keeping them
# (and their generated serializers) guarantees additive backend fields and
# @SerialName mappings survive minification. DTOs are small, so keeping them
# whole is cheap insurance against a full-mode strip.
-keep @kotlinx.serialization.Serializable class com.runicgateway.app.** { *; }
-keepclassmembers class com.runicgateway.app.data.api.dto.** { *; }
# ── Retrofit service interfaces ─────────────────────────────────────────────
# Retrofit reads method + parameter annotations reflectively; keep our API
# interfaces' generic signatures so return types (suspend .../Call<T>) resolve.
-keep,allowobfuscation interface com.runicgateway.app.data.api.*Api
-keepattributes Signature, Exceptions
# Kotlin metadata is needed for reflection over Kotlin types (serialization/Retrofit).
-keep class kotlin.Metadata { *; }
# ── Tink / EncryptedSharedPreferences (androidx.security-crypto) ─────────────
# Tink references Error Prone compile-only annotations that are absent at runtime;
# they are safe to ignore (they carry no runtime behaviour). Suppresses the R8
# "Missing class com.google.errorprone.annotations.*" errors.
-dontwarn com.google.errorprone.annotations.**

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

View File

@@ -3,8 +3,10 @@
*/ */
package com.runicgateway.app package com.runicgateway.app
import android.graphics.Color
import android.os.Bundle import android.os.Bundle
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.SystemBarStyle
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
@@ -35,7 +37,10 @@ import dagger.hilt.android.AndroidEntryPoint
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
enableEdgeToEdge() // Dark-only app (M5): force light system-bar icons over the transparent bars so
// they stay legible on the deep blue-black surfaces regardless of system theme.
val barStyle = SystemBarStyle.dark(Color.TRANSPARENT)
enableEdgeToEdge(statusBarStyle = barStyle, navigationBarStyle = barStyle)
setContent { setContent {
val appViewModel: AppViewModel = hiltViewModel() val appViewModel: AppViewModel = hiltViewModel()
val state by appViewModel.state.collectAsStateWithLifecycle() val state by appViewModel.state.collectAsStateWithLifecycle()

View File

@@ -36,6 +36,13 @@ class ConnectionRepository @Inject constructor(
/** Reachable and 2xx, but not a Runic Gateway backend (wrong version identity). */ /** Reachable and 2xx, but not a Runic Gateway backend (wrong version identity). */
data object NotRunicGateway : ProbeResult data object NotRunicGateway : ProbeResult
/**
* A Runic Gateway backend, but speaking an API version this app build does
* not support (§3 version guard) — refuse rather than mis-render. [serverApi]
* is what the site reported; [supportedApi] is what this app speaks.
*/
data class VersionMismatch(val serverApi: String, val supportedApi: String) : ProbeResult
data class ServerError(val status: Int) : ProbeResult data class ServerError(val status: Int) : ProbeResult
data class Unreachable(val cause: Throwable) : ProbeResult data class Unreachable(val cause: Throwable) : ProbeResult
} }
@@ -68,14 +75,15 @@ class ConnectionRepository @Inject constructor(
?: return ProbeResult.InvalidUrl(ServerUrl.Reason.MALFORMED) ?: return ProbeResult.InvalidUrl(ServerUrl.Reason.MALFORMED)
return when (val result = safeApiCall { api.probeStatus(statusUrl) }) { return when (val result = safeApiCall { api.probeStatus(statusUrl) }) {
is ApiResult.Ok -> { is ApiResult.Ok -> when (val verdict = evaluateVersion(result.data.version)) {
if (!result.data.version.service.equals(RUNIC_SERVICE_ID, ignoreCase = true)) { is VersionVerdict.Ok -> {
ProbeResult.NotRunicGateway
} else {
prefs.setBaseUrl(normalized.toString()) prefs.setBaseUrl(normalized.toString())
baseUrlHolder.set(normalized) baseUrlHolder.set(normalized)
ProbeResult.Success(result.data) ProbeResult.Success(result.data)
} }
is VersionVerdict.NotRunicGateway -> ProbeResult.NotRunicGateway
is VersionVerdict.Mismatch ->
ProbeResult.VersionMismatch(serverApi = verdict.serverApi, supportedApi = SUPPORTED_API)
} }
is ApiResult.HttpError -> ProbeResult.ServerError(result.status) is ApiResult.HttpError -> ProbeResult.ServerError(result.status)
is ApiResult.NetworkError -> ProbeResult.Unreachable(result.cause) is ApiResult.NetworkError -> ProbeResult.Unreachable(result.cause)
@@ -93,7 +101,38 @@ class ConnectionRepository @Inject constructor(
baseUrlHolder.set(null) baseUrlHolder.set(null)
} }
private companion object { /** The pure outcome of inspecting a probed site's [VersionDto] (§3 version guard). */
internal sealed interface VersionVerdict {
data object Ok : VersionVerdict
data object NotRunicGateway : VersionVerdict
data class Mismatch(val serverApi: String) : VersionVerdict
}
internal companion object {
const val RUNIC_SERVICE_ID = "runic-gateway" const val RUNIC_SERVICE_ID = "runic-gateway"
/** The backend API major version this app build speaks (matches `/public/version` `api`). */
const val SUPPORTED_API = "v1"
/**
* Decide whether a probed site is a Runic Gateway backend this app can talk
* to. Pure (no I/O) so it is unit-testable without a live site. Lenient on a
* blank `api` (an older backend that predates version surfacing); refuses only
* an API version we positively know we can't parse (e.g. a future `v2`).
*/
fun evaluateVersion(
version: com.runicgateway.app.data.api.dto.VersionDto,
supportedApi: String = SUPPORTED_API,
): VersionVerdict {
if (!version.service.trim().equals(RUNIC_SERVICE_ID, ignoreCase = true)) {
return VersionVerdict.NotRunicGateway
}
val serverApi = version.api.trim()
return when {
serverApi.isEmpty() -> VersionVerdict.Ok
serverApi.equals(supportedApi, ignoreCase = true) -> VersionVerdict.Ok
else -> VersionVerdict.Mismatch(serverApi)
}
}
} }
} }

View File

@@ -22,6 +22,7 @@ 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
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberDrawerState import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@@ -29,7 +30,9 @@ 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.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.LifecycleResumeEffect import androidx.lifecycle.compose.LifecycleResumeEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -111,11 +114,17 @@ fun RunicApp(
drawerState = drawerState, drawerState = drawerState,
gesturesEnabled = isTopLevel, gesturesEnabled = isTopLevel,
drawerContent = { drawerContent = {
ModalDrawerSheet { ModalDrawerSheet(drawerContainerColor = MaterialTheme.colorScheme.surfaceVariant) {
val drawerItemColors = NavigationDrawerItemDefaults.colors(
selectedContainerColor = MaterialTheme.colorScheme.secondaryContainer,
selectedTextColor = MaterialTheme.colorScheme.onSecondaryContainer,
unselectedTextColor = MaterialTheme.colorScheme.onSurface,
)
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(12.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,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(horizontal = 24.dp, vertical = 12.dp), modifier = Modifier.padding(horizontal = 24.dp, vertical = 12.dp),
) )
HorizontalDivider() HorizontalDivider()
@@ -128,6 +137,7 @@ fun RunicApp(
scope.launch { drawerState.close() } scope.launch { drawerState.close() }
navController.navigateTopLevel(entry.route) navController.navigateTopLevel(entry.route)
}, },
colors = drawerItemColors,
modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding), modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding),
) )
} }
@@ -152,6 +162,7 @@ fun RunicApp(
navController.navigate(Routes.LOGIN) navController.navigate(Routes.LOGIN)
} }
}, },
colors = drawerItemColors,
modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding), modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding),
) )
NavigationDrawerItem( NavigationDrawerItem(
@@ -161,6 +172,7 @@ fun RunicApp(
scope.launch { drawerState.close() } scope.launch { drawerState.close() }
onChangeServer() onChangeServer()
}, },
colors = drawerItemColors,
modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding), modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding),
) )
} }
@@ -170,10 +182,19 @@ fun RunicApp(
modifier = modifier, modifier = modifier,
topBar = { topBar = {
TopAppBar( TopAppBar(
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
titleContentColor = MaterialTheme.colorScheme.onSurface,
navigationIconContentColor = MaterialTheme.colorScheme.onSurface,
actionIconContentColor = MaterialTheme.colorScheme.onSurface,
),
title = { title = {
Text( Text(
brand?.name?.takeIf { it.isNotBlank() } text = (brand?.name?.takeIf { it.isNotBlank() }
?: stringResource(R.string.app_name), ?: stringResource(R.string.app_name)).uppercase(),
style = MaterialTheme.typography.titleSmall.copy(letterSpacing = 1.2.sp),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
) )
}, },
navigationIcon = { navigationIcon = {

View File

@@ -50,6 +50,8 @@ import com.runicgateway.app.ui.auth.AccountViewModel.Feedback
import com.runicgateway.app.ui.auth.AccountViewModel.Section 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.StatusPill
/** /**
* The signed-in account surface (PLAN.md §5, §6.3): identity + role, self-service * The signed-in account surface (PLAN.md §5, §6.3): identity + role, self-service
@@ -101,11 +103,10 @@ private fun IdentityCard(username: String, roleLabel: String) {
Card(Modifier.fillMaxWidth()) { Card(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)
Text( StatusPill(
text = roleLabel, text = roleLabel,
style = MaterialTheme.typography.bodyMedium, tone = PillTone.Info,
color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(top = 8.dp),
modifier = Modifier.padding(top = 4.dp),
) )
} }
} }
@@ -221,10 +222,9 @@ private fun TwoFactorSection(
) { ) {
var code by rememberSaveable { mutableStateOf("") } var code by rememberSaveable { mutableStateOf("") }
SectionCard(R.string.account_totp_title) { SectionCard(R.string.account_totp_title) {
Text( StatusPill(
stringResource(if (account.totp_enabled) R.string.account_totp_on else R.string.account_totp_off), text = stringResource(if (account.totp_enabled) R.string.account_totp_on else R.string.account_totp_off),
style = MaterialTheme.typography.bodyMedium, tone = if (account.totp_enabled) PillTone.Success else PillTone.Neutral,
color = if (account.totp_enabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 8.dp), modifier = Modifier.padding(top = 8.dp),
) )

View File

@@ -0,0 +1,148 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.runicgateway.app.ui.theme.ShardCardBottom
import com.runicgateway.app.ui.theme.ShardCardTop
import com.runicgateway.app.ui.theme.ShardDanger
import com.runicgateway.app.ui.theme.ShardDangerBg
import com.runicgateway.app.ui.theme.ShardElevated
import com.runicgateway.app.ui.theme.ShardFaint
import com.runicgateway.app.ui.theme.ShardOutline
import com.runicgateway.app.ui.theme.ShardPillBg
import com.runicgateway.app.ui.theme.ShardPillFg
import com.runicgateway.app.ui.theme.ShardSuccess
import com.runicgateway.app.ui.theme.ShardSuccessBg
import com.runicgateway.app.ui.theme.ShardSuccessDot
import com.runicgateway.app.ui.theme.ShardWarning
import com.runicgateway.app.ui.theme.ShardWarningBg
/**
* Shared visual building blocks for the M5 shard-website design pass
* (docs/android/PLAN.md §M5): the recurring pill, section-label, feature-card,
* and stat-bar motifs the mockup repeats across screens. Pure presentation —
* no state, no data dependencies — so any screen can adopt them.
*/
/** Semantic tone for a [StatusPill] / [OnlineDot]. */
enum class PillTone { Success, Warning, Danger, Neutral, Info }
private data class PillColors(val fg: Color, val bg: Color)
private fun toneColors(tone: PillTone): PillColors = when (tone) {
PillTone.Success -> PillColors(ShardSuccess, ShardSuccessBg)
PillTone.Warning -> PillColors(ShardWarning, ShardWarningBg)
PillTone.Danger -> PillColors(ShardDanger, ShardDangerBg)
PillTone.Neutral, PillTone.Info -> PillColors(ShardPillFg, ShardPillBg)
}
/**
* A small uppercase status chip — "Live", "Up", "Enabled", "IDOC", a role — with a
* rounded filled background tinted by [tone]. Mirrors the mockup's pill badges.
*/
@Composable
fun StatusPill(text: String, tone: PillTone, modifier: Modifier = Modifier) {
val c = toneColors(tone)
Surface(color = c.bg, shape = CircleShape, modifier = modifier) {
Text(
text = text.uppercase(),
style = MaterialTheme.typography.labelSmall,
color = c.fg,
modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp),
)
}
}
/** The small colored status dot (green when live), matched to a [PillTone]. */
@Composable
fun OnlineDot(tone: PillTone, modifier: Modifier = Modifier) {
val color = when (tone) {
PillTone.Success -> ShardSuccessDot
PillTone.Warning -> ShardWarning
PillTone.Danger -> ShardDanger
PillTone.Neutral, PillTone.Info -> ShardFaint
}
Box(modifier.size(8.dp).clip(CircleShape).background(color))
}
/**
* The muted, uppercase, letter-spaced section header the design uses above grouped
* content ("Attributes", "Vitals", "Password", "Linked accounts").
*/
@Composable
fun SectionLabel(text: String, modifier: Modifier = Modifier) {
Text(
text = text.uppercase(),
style = MaterialTheme.typography.labelSmall,
color = ShardFaint,
modifier = modifier,
)
}
/**
* The elevated "feature" card: a vertical blue gradient with a hairline outline and
* soft shadow, used for the home status card, the shard-online banner, and the
* vendor card. [content] is laid out in a padded [Column].
*/
@Composable
fun FeatureCard(
modifier: Modifier = Modifier,
contentPadding: Int = 18,
content: @Composable ColumnScope.() -> Unit,
) {
Box(
modifier = modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.background(Brush.verticalGradient(listOf(ShardCardTop, ShardCardBottom)))
.border(1.dp, ShardOutline, RoundedCornerShape(12.dp)),
) {
Column(Modifier.padding(contentPadding.dp), content = content)
}
}
/**
* A slim rounded meter (vitals / skills). [fraction] is clamped to 0..1; the fill is
* the slate accent over a bordered dark track.
*/
@Composable
fun StatBar(fraction: Float, modifier: Modifier = Modifier) {
val pct = fraction.coerceIn(0f, 1f)
Box(
modifier = modifier
.fillMaxWidth()
.height(6.dp)
.clip(RoundedCornerShape(3.dp))
.background(ShardElevated)
.border(1.dp, ShardOutline, RoundedCornerShape(3.dp)),
) {
Box(
Modifier
.fillMaxWidth(pct)
.height(6.dp)
.clip(RoundedCornerShape(3.dp))
.background(MaterialTheme.colorScheme.secondary),
)
}
}

View File

@@ -107,6 +107,7 @@ private fun errorMessage(error: ConnectError): String = when (error) {
ConnectError.UnsupportedScheme -> stringResource(R.string.connect_error_scheme) ConnectError.UnsupportedScheme -> stringResource(R.string.connect_error_scheme)
ConnectError.Insecure -> stringResource(R.string.connect_error_insecure) ConnectError.Insecure -> stringResource(R.string.connect_error_insecure)
ConnectError.NotRunicGateway -> stringResource(R.string.connect_error_not_runic) ConnectError.NotRunicGateway -> stringResource(R.string.connect_error_not_runic)
is ConnectError.VersionMismatch -> stringResource(R.string.connect_error_version, error.serverApi)
ConnectError.Unreachable -> stringResource(R.string.connect_error_unreachable) ConnectError.Unreachable -> stringResource(R.string.connect_error_unreachable)
is ConnectError.Server -> stringResource(R.string.connect_error_server, error.status) is ConnectError.Server -> stringResource(R.string.connect_error_server, error.status)
} }

View File

@@ -32,6 +32,7 @@ class ConnectViewModel @Inject constructor(
data object UnsupportedScheme : ConnectError data object UnsupportedScheme : ConnectError
data object Insecure : ConnectError data object Insecure : ConnectError
data object NotRunicGateway : ConnectError data object NotRunicGateway : ConnectError
data class VersionMismatch(val serverApi: String) : ConnectError
data object Unreachable : ConnectError data object Unreachable : ConnectError
data class Server(val status: Int) : ConnectError data class Server(val status: Int) : ConnectError
} }
@@ -61,6 +62,7 @@ class ConnectViewModel @Inject constructor(
} }
is ProbeResult.InvalidUrl -> fail(result.reason.toError()) is ProbeResult.InvalidUrl -> fail(result.reason.toError())
ProbeResult.NotRunicGateway -> fail(ConnectError.NotRunicGateway) ProbeResult.NotRunicGateway -> fail(ConnectError.NotRunicGateway)
is ProbeResult.VersionMismatch -> fail(ConnectError.VersionMismatch(result.serverApi))
is ProbeResult.Unreachable -> fail(ConnectError.Unreachable) is ProbeResult.Unreachable -> fail(ConnectError.Unreachable)
is ProbeResult.ServerError -> fail(ConnectError.Server(result.status)) is ProbeResult.ServerError -> fail(ConnectError.Server(result.status))
} }

View File

@@ -4,19 +4,19 @@
package com.runicgateway.app.ui.home package com.runicgateway.app.ui.home
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.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
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.CardDefaults
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
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -27,7 +27,10 @@ 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.ErrorView import com.runicgateway.app.ui.components.ErrorView
import com.runicgateway.app.ui.components.FeatureCard
import com.runicgateway.app.ui.components.LoadingView import com.runicgateway.app.ui.components.LoadingView
import com.runicgateway.app.ui.components.OnlineDot
import com.runicgateway.app.ui.components.PillTone
/** /**
* Home / status (PLAN.md §6.1): the shard's name + tagline from branding, and a * Home / status (PLAN.md §6.1): the shard's name + tagline from branding, and a
@@ -83,40 +86,37 @@ private fun HomeContent(brand: BrandDto?, status: StatusDto, modifier: Modifier
@Composable @Composable
private fun StatusCard(status: StatusDto) { private fun StatusCard(status: StatusDto) {
val online = !status.isMaintenance val online = !status.isMaintenance
val containerColor =
if (online) MaterialTheme.colorScheme.secondaryContainer
else MaterialTheme.colorScheme.errorContainer
Card( FeatureCard {
modifier = Modifier.fillMaxWidth(), Row(verticalAlignment = Alignment.CenterVertically) {
colors = CardDefaults.cardColors(containerColor = containerColor), OnlineDot(if (online) PillTone.Success else PillTone.Danger)
) { Spacer(Modifier.width(10.dp))
Column(Modifier.padding(16.dp)) {
Text( Text(
text = stringResource( text = stringResource(
if (online) R.string.home_status_live else R.string.home_status_maintenance, if (online) R.string.home_status_live else R.string.home_status_maintenance,
), ),
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
)
}
if (status.isMaintenance && status.statusMessage.isNotBlank()) {
Text(
text = status.statusMessage,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(top = 6.dp),
)
}
if (status.version.server.isNotBlank()) {
Text(
text = stringResource(
R.string.home_server_version,
status.version.server,
status.version.api,
),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 10.dp),
) )
if (status.isMaintenance && status.statusMessage.isNotBlank()) {
Text(
text = status.statusMessage,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(top = 6.dp),
)
}
if (status.version.server.isNotBlank()) {
Text(
text = stringResource(
R.string.home_server_version,
status.version.server,
status.version.api,
),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 10.dp),
)
}
} }
} }
} }

View File

@@ -35,6 +35,8 @@ import com.runicgateway.app.data.api.dto.TitlesDto
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.SectionLabel
import com.runicgateway.app.ui.components.StatBar
import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.jsonPrimitive
/** /**
@@ -140,10 +142,13 @@ private fun AttributesBlock(stats: CharStatsDto) {
Stat(stringResource(R.string.player_char_dex), stats.dex) Stat(stringResource(R.string.player_char_dex), stats.dex)
Stat(stringResource(R.string.player_char_int), stats.int) Stat(stringResource(R.string.player_char_int), stats.int)
} }
Column(Modifier.padding(top = 12.dp)) { Column(Modifier.padding(top = 16.dp)) {
Vital(stringResource(R.string.player_char_hits), stats.hits, stats.hitsMax) SectionLabel(stringResource(R.string.player_char_vitals))
Vital(stringResource(R.string.player_char_mana), stats.mana, stats.manaMax) Column(Modifier.padding(top = 8.dp)) {
Vital(stringResource(R.string.player_char_stam), stats.stam, stats.stamMax) Vital(stringResource(R.string.player_char_hits), stats.hits, stats.hitsMax)
Vital(stringResource(R.string.player_char_mana), stats.mana, stats.manaMax)
Vital(stringResource(R.string.player_char_stam), stats.stam, stats.stamMax)
}
} }
} }
} }
@@ -158,9 +163,13 @@ private fun Stat(label: String, value: Int?) {
@Composable @Composable
private fun Vital(label: String, cur: Int?, max: Int?) { private fun Vital(label: String, cur: Int?, max: Int?) {
Row(Modifier.fillMaxWidth().padding(vertical = 2.dp), horizontalArrangement = Arrangement.SpaceBetween) { Column(Modifier.padding(vertical = 5.dp)) {
Text(label, style = MaterialTheme.typography.bodyMedium) Row(Modifier.fillMaxWidth().padding(bottom = 4.dp), horizontalArrangement = Arrangement.SpaceBetween) {
Text("${cur ?: "—"} / ${max ?: "—"}", style = MaterialTheme.typography.bodyMedium) Text(label, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text("${cur ?: "—"} / ${max ?: "—"}", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
val fraction = if (max != null && max > 0 && cur != null) cur.toFloat() / max else 0f
StatBar(fraction)
} }
} }
@@ -194,9 +203,13 @@ private fun SkillsBlock(skills: List<SkillDto>) {
if (shown.isEmpty()) return if (shown.isEmpty()) return
SheetCard(R.string.player_char_skills) { SheetCard(R.string.player_char_skills) {
shown.forEach { skill -> shown.forEach { skill ->
Row(Modifier.fillMaxWidth().padding(vertical = 3.dp), horizontalArrangement = Arrangement.SpaceBetween) { val value = skill.value ?: 0.0
Text(skill.n ?: "—", style = MaterialTheme.typography.bodyMedium) Column(Modifier.padding(vertical = 5.dp)) {
Text(formatSkill(skill.value ?: 0.0), style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium) Row(Modifier.fillMaxWidth().padding(bottom = 4.dp), horizontalArrangement = Arrangement.SpaceBetween) {
Text(skill.n ?: "—", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(formatSkill(value), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, fontWeight = FontWeight.Medium)
}
StatBar((value / SKILL_CAP).toFloat())
} }
} }
} }
@@ -243,6 +256,9 @@ private fun SheetCard(titleRes: Int, content: @Composable () -> Unit) {
// ── Pure helpers (unit-tested) ─────────────────────────────────────────────── // ── Pure helpers (unit-tested) ───────────────────────────────────────────────
/** ServUO's default skill cap; used to scale the skill meter fill (0..1). */
private const val SKILL_CAP = 120.0
/** Format a skill value: drop the ".0" on whole numbers, else one decimal. */ /** Format a skill value: drop the ".0" on whole numbers, else one decimal. */
internal fun formatSkill(value: Double): String = internal fun formatSkill(value: Double): String =
if (value % 1.0 == 0.0) value.toInt().toString() else "%.1f".format(value) if (value % 1.0 == 0.0) value.toInt().toString() else "%.1f".format(value)

View File

@@ -38,6 +38,8 @@ 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.PillTone
import com.runicgateway.app.ui.components.StatusPill
/** /**
* The player's linked game accounts + character rosters (PLAN.md §6.3). Tapping a * The player's linked game accounts + character rosters (PLAN.md §6.3). Tapping a
@@ -212,14 +214,18 @@ private fun CharRow(char: RosterCharDto, onOpenChar: (String) -> Unit) {
Modifier.fillMaxWidth().padding(14.dp), Modifier.fillMaxWidth().padding(14.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Column(Modifier.weight(1f)) { Text(
Text(char.name ?: stringResource(R.string.player_char_unknown), style = MaterialTheme.typography.bodyLarge) char.name ?: stringResource(R.string.player_char_unknown),
Text( style = MaterialTheme.typography.bodyLarge,
stringResource(if (char.online) R.string.player_char_online else R.string.player_char_offline), modifier = Modifier.weight(1f),
style = MaterialTheme.typography.labelSmall, )
color = if (char.online) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, StatusPill(
) text = stringResource(
} if (char.online) R.string.player_char_online else R.string.player_char_offline,
),
tone = if (char.online) PillTone.Success else PillTone.Neutral,
modifier = Modifier.padding(end = 8.dp),
)
Text("›", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) Text("›", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
} }
} }

View File

@@ -20,6 +20,8 @@ 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.ChampDto import com.runicgateway.app.data.api.dto.ChampDto
import com.runicgateway.app.ui.components.PillTone
import com.runicgateway.app.ui.components.StatusPill
/** The champion-spawn board (PLAN.md §6.2), live via SSE deltas. */ /** The champion-spawn board (PLAN.md §6.2), live via SSE deltas. */
@Composable @Composable
@@ -53,11 +55,7 @@ private fun ChampCard(champ: ChampDto) {
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) )
champ.status?.let { champ.status?.let {
Text( StatusPill(text = it, tone = champStatusTone(it))
text = it.replaceFirstChar { c -> c.uppercase() },
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
)
} }
} }
val detail = champDetail(champ) val detail = champDetail(champ)
@@ -82,6 +80,13 @@ private fun ChampCard(champ: ChampDto) {
} }
} }
/** Map a champ status to a pill tone: live spawns read green, cooldowns amber. */
private fun champStatusTone(status: String): PillTone = when (status.lowercase()) {
"active", "up", "spawned" -> PillTone.Success
"cooldown", "restarting", "advancing" -> PillTone.Warning
else -> PillTone.Neutral
}
/** A short category-specific status line, mirroring the website's champ detail. */ /** A short category-specific status line, mirroring the website's champ detail. */
@Composable @Composable
private fun champDetail(champ: ChampDto): String = when (champ.category) { private fun champDetail(champ: ChampDto): String = when (champ.category) {

View File

@@ -20,6 +20,8 @@ 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.HouseDto import com.runicgateway.app.data.api.dto.HouseDto
import com.runicgateway.app.ui.components.PillTone
import com.runicgateway.app.ui.components.StatusPill
/** The public "falling houses" (IDOC) board (PLAN.md §6.2), live via `house.decay`. */ /** The public "falling houses" (IDOC) board (PLAN.md §6.2), live via `house.decay`. */
@Composable @Composable
@@ -54,10 +56,9 @@ private fun HouseCard(house: HouseDto) {
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) )
Text( StatusPill(
text = stringResource(R.string.houses_idoc_badge), text = stringResource(R.string.houses_idoc_badge),
style = MaterialTheme.typography.labelMedium, tone = PillTone.Danger,
color = MaterialTheme.colorScheme.error,
) )
} }
val where = listOfNotNull( val where = listOfNotNull(

View File

@@ -27,6 +27,8 @@ 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.theme.ShardSuccess
import com.runicgateway.app.ui.theme.ShardSuccessDot
/** /**
* A small "Live / Offline" indicator for the shard screens (PLAN.md §6.2): a dot + * A small "Live / Offline" indicator for the shard screens (PLAN.md §6.2): a dot +
@@ -34,22 +36,19 @@ import com.runicgateway.app.ui.components.LoadingView
*/ */
@Composable @Composable
fun LiveChip(connected: Boolean, modifier: Modifier = Modifier) { fun LiveChip(connected: Boolean, modifier: Modifier = Modifier) {
val color = if (connected) { val dotColor = if (connected) ShardSuccessDot else MaterialTheme.colorScheme.onSurfaceVariant
MaterialTheme.colorScheme.primary val textColor = if (connected) ShardSuccess else MaterialTheme.colorScheme.onSurfaceVariant
} else {
MaterialTheme.colorScheme.onSurfaceVariant
}
Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) { Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) {
androidx.compose.foundation.layout.Box( androidx.compose.foundation.layout.Box(
Modifier Modifier
.size(8.dp) .size(8.dp)
.clip(CircleShape) .clip(CircleShape)
.background(color), .background(dotColor),
) )
Text( Text(
text = stringResource(if (connected) R.string.shard_live else R.string.shard_offline), text = stringResource(if (connected) R.string.shard_live else R.string.shard_offline),
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelMedium,
color = color, color = textColor,
modifier = Modifier.padding(start = 6.dp), modifier = Modifier.padding(start = 6.dp),
) )
} }

View File

@@ -30,7 +30,11 @@ import com.runicgateway.app.data.api.dto.OnlineStaffDto
import com.runicgateway.app.data.api.dto.ShardStatusDto import com.runicgateway.app.data.api.dto.ShardStatusDto
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.FeatureCard
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.SectionLabel
import com.runicgateway.app.ui.components.StatusPill
/** Board destinations reachable from the hub. */ /** Board destinations reachable from the hub. */
enum class ShardBoard { CHAMPS, GUILDS, GOVERNORS, HOUSES } enum class ShardBoard { CHAMPS, GUILDS, GOVERNORS, HOUSES }
@@ -114,29 +118,42 @@ private fun HubContent(
@Composable @Composable
private fun StatusCard(status: ShardStatusDto, presenceCount: Int?) { private fun StatusCard(status: ShardStatusDto, presenceCount: Int?) {
Card(Modifier.fillMaxWidth()) { FeatureCard {
Column(Modifier.padding(16.dp)) { Row(
Text( Modifier.fillMaxWidth(),
text = stringResource( horizontalArrangement = Arrangement.SpaceBetween,
if (status.isOnline) R.string.shard_status_online else R.string.shard_status_offline, verticalAlignment = Alignment.CenterVertically,
), ) {
style = MaterialTheme.typography.titleMedium, Column(Modifier.weight(1f)) {
color = if (status.isOnline) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
)
val online = presenceCount ?: status.onlineCount
Text(
text = stringResource(R.string.shard_online_count, online),
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(top = 6.dp),
)
status.economy?.gold?.let { gold ->
Text( Text(
text = stringResource(R.string.shard_economy_gold, "%,.0f".format(gold)), text = stringResource(
style = MaterialTheme.typography.bodySmall, if (status.isOnline) R.string.shard_status_online else R.string.shard_status_offline,
color = MaterialTheme.colorScheme.onSurfaceVariant, ),
modifier = Modifier.padding(top = 2.dp), style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
) )
val online = presenceCount ?: status.onlineCount
Text(
text = stringResource(R.string.shard_online_count, online),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 6.dp),
)
status.economy?.gold?.let { gold ->
Text(
text = stringResource(R.string.shard_economy_gold, "%,.0f".format(gold)),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 2.dp),
)
}
} }
StatusPill(
text = stringResource(
if (status.isOnline) R.string.shard_live else R.string.shard_offline,
),
tone = if (status.isOnline) PillTone.Success else PillTone.Neutral,
)
} }
} }
} }
@@ -168,11 +185,7 @@ private fun BoardsCard(onOpenBoard: (ShardBoard) -> Unit) {
@Composable @Composable
private fun SectionHeader(text: String) { private fun SectionHeader(text: String) {
Text( SectionLabel(text = text, modifier = Modifier.padding(top = 4.dp))
text = text,
style = MaterialTheme.typography.titleSmall,
modifier = Modifier.padding(top = 4.dp),
)
} }
@Composable @Composable

View File

@@ -5,12 +5,44 @@ package com.runicgateway.app.ui.theme
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
// Placeholder palette for the M0 skeleton. The M5 design pass replaces this and // ─────────────────────────────────────────────────────────────────────────────
// derives Material 3 colors from each shard's per-install branding (PLAN.md §3, §5). // Shard-website palette (M5 design pass, docs/android/PLAN.md §M5). Deep blue-black
val Purple80 = Color(0xFFD0BCFF) // surfaces, a slate-blue accent, parchment serif text, and a light CTA fill —
val PurpleGrey80 = Color(0xFFCCC2DC) // mirrored from the "Runic Gateway Screens" design. Dark-only by design.
val Pink80 = Color(0xFFEFB8C8) // ─────────────────────────────────────────────────────────────────────────────
val Purple40 = Color(0xFF6650A4) // Surfaces
val PurpleGrey40 = Color(0xFF625B71) val ShardPage = Color(0xFF0B0F14) // app/page background
val Pink40 = Color(0xFF7D5260) val ShardSurface = Color(0xFF0E1318) // screen background
val ShardElevated = Color(0xFF11161D) // top bar, inputs, drawer, list tracks
val ShardCardTop = Color(0xFF192231) // feature-card gradient (top)
val ShardCardBottom = Color(0xFF141A21) // feature-card gradient (bottom)
// Lines
val ShardOutline = Color(0xFF2A3544) // borders / input outlines
val ShardDivider = Color(0xFF1D2733) // hairline row dividers
// Text
val ShardHeading = Color(0xFFEEF3F8) // brightest headings
val ShardHeadingDim = Color(0xFFE6EDF6) // heading on surface
val ShardBody = Color(0xFFC4CDD8) // body copy
val ShardMuted = Color(0xFFAEB8C4) // secondary text
val ShardFaint = Color(0xFF6F7D8E) // meta / faint labels
// Accent + CTA
val ShardAccent = Color(0xFF7F99BD) // links, secondary highlights
val ShardCta = Color(0xFFCDD9E8) // filled button surface
val ShardOnCta = Color(0xFF0B0F14) // text on the filled button
// Neutral / info pill
val ShardPillBg = Color(0xFF13243C)
val ShardPillFg = Color(0xFFCDD9E8)
// Semantic (status pills + dots)
val ShardSuccess = Color(0xFF7FD0A4)
val ShardSuccessBg = Color(0x295FB98A) // rgba(95,185,138,0.16)
val ShardSuccessDot = Color(0xFF5FB98A)
val ShardWarning = Color(0xFFE6C26A)
val ShardWarningBg = Color(0x2EE6C26A) // rgba(230,194,106,0.18)
val ShardDanger = Color(0xFFD98B84)
val ShardDangerBg = Color(0x29D98B84) // rgba(217,139,132,0.16)

View File

@@ -0,0 +1,40 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.theme
import androidx.compose.ui.text.ExperimentalTextApi
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontVariation
import androidx.compose.ui.text.font.FontWeight
import com.runicgateway.app.R
/**
* Type families for the M5 shard-website design pass (docs/android/PLAN.md §M5).
*
* - [Cinzel] — the engraved serif display face used for headings, screen titles,
* and the top-bar title. Shipped as a single weight-axis **variable** font
* (`res/font/cinzel_variable.ttf`, SIL OFL — see `app/licenses/Cinzel-OFL.txt`);
* the 500/600/700 instances the design uses are pinned via [FontVariation]
* (supported on API 26+, and our minSdk is 29).
* - [AppSerif] — the parchment body face. Android's platform serif is Noto Serif,
* 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).
*/
@OptIn(ExperimentalTextApi::class)
private fun cinzel(weight: FontWeight) =
Font(
R.font.cinzel_variable,
weight = weight,
variationSettings = FontVariation.Settings(FontVariation.weight(weight.weight)),
)
val Cinzel = FontFamily(
cinzel(FontWeight.Medium), // 500
cinzel(FontWeight.SemiBold), // 600
cinzel(FontWeight.Bold), // 700
)
val AppSerif = FontFamily.Serif
val AppSans = FontFamily.SansSerif

View File

@@ -3,54 +3,78 @@
*/ */
package com.runicgateway.app.ui.theme package com.runicgateway.app.ui.theme
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.shape.RoundedCornerShape
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.material3.lightColorScheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
private val DarkColorScheme = darkColorScheme( /**
primary = Purple80, * The shard-website color scheme (M5 design pass). The app is **dark-only** — the
secondary = PurpleGrey80, * design is a single deep blue-black theme, so there is no light variant and the
tertiary = Pink80, * system light/dark setting is intentionally ignored. Material roles are mapped
* onto the palette in [ui/theme/Color.kt] so the ~20 token-based screens take on
* the theme without per-screen color work.
*/
private val ShardColorScheme = darkColorScheme(
primary = ShardCta, // filled CTA buttons
onPrimary = ShardOnCta,
secondary = ShardAccent, // links / secondary highlights
onSecondary = ShardOnCta,
tertiary = ShardAccent,
onTertiary = ShardOnCta,
background = ShardPage,
onBackground = ShardBody,
surface = ShardSurface,
onSurface = ShardBody,
surfaceVariant = ShardElevated,
onSurfaceVariant = ShardMuted,
surfaceContainer = ShardElevated,
surfaceContainerHigh = ShardElevated,
surfaceContainerLow = ShardSurface,
outline = ShardOutline,
outlineVariant = ShardDivider,
secondaryContainer = ShardPillBg, // neutral chips / selected drawer item
onSecondaryContainer = ShardPillFg,
error = ShardDanger,
onError = ShardOnCta,
errorContainer = ShardDangerBg,
onErrorContainer = ShardDanger,
) )
private val LightColorScheme = lightColorScheme( /** 8dp inputs/chips, 12dp cards, 16dp large surfaces — matching the mockup radii. */
primary = Purple40, private val ShardShapes = Shapes(
secondary = PurpleGrey40, extraSmall = RoundedCornerShape(8.dp),
tertiary = Pink40, small = RoundedCornerShape(8.dp),
medium = RoundedCornerShape(12.dp),
large = RoundedCornerShape(16.dp),
extraLarge = RoundedCornerShape(24.dp),
) )
/** /**
* App theme for the functional pass. The color scheme is seeded from the * App theme. The color scheme is the fixed shard-website dark palette; when a shard
* per-shard brand accent (PLAN.md §3: the app themes itself from the site's * publishes a brand accent (PLAN.md §3), it seeds the [MaterialTheme]'s primary and
* branding) when one is available, so the app takes on each shard's color; it * secondary roles so buttons and highlights carry that shard's color while the rest
* falls back to a neutral placeholder scheme before connect or when a site * of the deep blue-black system stays intact. With no accent, the slate default is
* publishes no accent. * used.
*
* This is deliberately a minimal seeding — a single-color override on the
* default Material 3 schemes. The M5 design pass replaces it with a full,
* designed color system (§2.1, §5); nothing here is meant to be the final look.
*/ */
@Composable @Composable
fun RunicGatewayTheme( fun RunicGatewayTheme(
accent: Color? = null, accent: Color? = null,
darkTheme: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit, content: @Composable () -> Unit,
) { ) {
val base = if (darkTheme) DarkColorScheme else LightColorScheme
val colorScheme = if (accent != null) { val colorScheme = if (accent != null) {
// Brand consistency across a shard wins over device dynamic color: seed ShardColorScheme.copy(primary = accent, secondary = accent, tertiary = accent)
// the primary role from the accent so buttons/highlights carry the brand.
base.copy(primary = accent, secondary = accent)
} else { } else {
base ShardColorScheme
} }
MaterialTheme( MaterialTheme(
colorScheme = colorScheme, colorScheme = colorScheme,
typography = Typography, typography = Typography,
shapes = ShardShapes,
content = content, content = content,
) )
} }

View File

@@ -5,17 +5,77 @@ package com.runicgateway.app.ui.theme
import androidx.compose.material3.Typography import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
// Default Material 3 type scale for the skeleton; refined in the M5 design pass. /**
* The M5 type scale. Three families carry the design (see [ui/theme/Font.kt]):
* [Cinzel] for the engraved display/headline/title roles, [AppSerif] (Noto Serif)
* for parchment body copy, and [AppSans] for the letter-spaced label/meta/button
* roles. Sizes and tracking mirror the "Runic Gateway Screens" mockup.
*/
val Typography = Typography( val Typography = Typography(
// Display / headline / title — Cinzel engraved serif
displayLarge = TextStyle(
fontFamily = Cinzel, fontWeight = FontWeight.SemiBold,
fontSize = 40.sp, lineHeight = 46.sp, letterSpacing = 0.4.sp,
),
displayMedium = TextStyle(
fontFamily = Cinzel, fontWeight = FontWeight.SemiBold,
fontSize = 32.sp, lineHeight = 40.sp, letterSpacing = 0.3.sp,
),
displaySmall = TextStyle(
fontFamily = Cinzel, fontWeight = FontWeight.SemiBold,
fontSize = 28.sp, lineHeight = 36.sp, letterSpacing = 0.2.sp,
),
headlineLarge = TextStyle(
fontFamily = Cinzel, fontWeight = FontWeight.SemiBold,
fontSize = 26.sp, lineHeight = 34.sp, letterSpacing = 0.2.sp,
),
headlineMedium = TextStyle(
fontFamily = Cinzel, fontWeight = FontWeight.SemiBold,
fontSize = 24.sp, lineHeight = 32.sp, letterSpacing = 0.2.sp,
),
headlineSmall = TextStyle(
fontFamily = Cinzel, fontWeight = FontWeight.SemiBold,
fontSize = 22.sp, lineHeight = 28.sp, letterSpacing = 0.2.sp,
),
titleLarge = TextStyle(
fontFamily = Cinzel, fontWeight = FontWeight.SemiBold,
fontSize = 20.sp, lineHeight = 26.sp, letterSpacing = 0.2.sp,
),
titleMedium = TextStyle(
fontFamily = Cinzel, fontWeight = FontWeight.SemiBold,
fontSize = 17.sp, lineHeight = 24.sp, letterSpacing = 0.15.sp,
),
titleSmall = TextStyle(
fontFamily = Cinzel, fontWeight = FontWeight.SemiBold,
fontSize = 15.sp, lineHeight = 22.sp, letterSpacing = 0.1.sp,
),
// Body — parchment serif
bodyLarge = TextStyle( bodyLarge = TextStyle(
fontFamily = FontFamily.Default, fontFamily = AppSerif, fontWeight = FontWeight.Normal,
fontWeight = FontWeight.Normal, fontSize = 16.sp, lineHeight = 26.sp, letterSpacing = 0.15.sp,
fontSize = 16.sp, ),
lineHeight = 24.sp, bodyMedium = TextStyle(
letterSpacing = 0.5.sp, fontFamily = AppSerif, fontWeight = FontWeight.Normal,
fontSize = 15.sp, lineHeight = 24.sp, letterSpacing = 0.15.sp,
),
bodySmall = TextStyle(
fontFamily = AppSerif, fontWeight = FontWeight.Normal,
fontSize = 13.sp, lineHeight = 20.sp, letterSpacing = 0.2.sp,
),
// Labels / meta / buttons — sans, letter-spaced
labelLarge = TextStyle(
fontFamily = AppSans, fontWeight = FontWeight.Bold,
fontSize = 14.sp, lineHeight = 18.sp, letterSpacing = 0.45.sp,
),
labelMedium = TextStyle(
fontFamily = AppSans, fontWeight = FontWeight.Medium,
fontSize = 12.sp, lineHeight = 16.sp, letterSpacing = 0.4.sp,
),
labelSmall = TextStyle(
fontFamily = AppSans, fontWeight = FontWeight.Medium,
fontSize = 11.sp, lineHeight = 15.sp, letterSpacing = 0.5.sp,
), ),
) )

View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="172"
android:viewportHeight="218.95209"
android:tint="#FFFFFF">
<group android:scaleX="0.7227152"
android:scaleY="0.92"
android:translateX="23.846495"
android:translateY="8.758083">
<group android:translateY="154.51205">
<path android:pathData="M9.28125,-0L9.28125,-103.109375L42.265625,-103.109375Q53.140625,-103.109375,60.984375,-99.25Q68.828125,-95.40625,73.109375,-88.203125Q77.40625,-81,77.40625,-71.203125L77.40625,-70.0625Q77.40625,-60.1875,73.109375,-53.015625Q68.828125,-45.859375,60.9375,-41.96875Q53.0625,-38.09375,42.265625,-38.09375L20.296875,-38.09375L20.296875,-54.5L41.828125,-54.5Q49.46875,-54.5,53.75,-58.390625Q58.03125,-62.28125,58.03125,-69.125L58.03125,-70.984375Q58.03125,-77.828125,53.75,-81.71875Q49.46875,-85.609375,41.828125,-85.609375L28.4375,-85.609375L28.4375,-0L9.28125,-0ZM61.78125,-0L35.5625,-45.359375L56.65625,-45.359375L83.453125,-0L61.78125,-0Z"
android:fillColor="#000000"/>
<path android:pathData="M132.65625,2.375Q119.765625,2.375,110.40625,-3.921875Q101.046875,-10.21875,96.046875,-21.953125Q91.046875,-33.703125,91.046875,-49.96875L91.046875,-52.625Q91.046875,-69.046875,96.046875,-80.890625Q101.046875,-92.734375,110.296875,-99.109375Q119.546875,-105.484375,132.375,-105.484375Q145.25,-105.484375,153.96875,-99.109375Q162.6875,-92.734375,166.5625,-80.5L149.21875,-74.09375Q147.125,-81,143.04688,-84.234375Q138.98438,-87.484375,132.57812,-87.484375Q121.859375,-87.484375,116.125,-78.546875Q110.40625,-69.625,110.40625,-52.984375L110.40625,-49.609375Q110.40625,-32.96875,116.15625,-24.21875Q121.921875,-15.484375,132.79688,-15.484375Q141.57812,-15.484375,146.35938,-21.453125Q151.15625,-27.4375,151.15625,-38.453125L151.15625,-41.828125L129.84375,-41.828125L129.84375,-57.890625L169.09375,-57.890625L169.09375,-41.90625Q169.09375,-20.734375,159.57812,-9.171875Q150.07812,2.375,132.65625,2.375Z"
android:fillColor="#000000"/>
</group>
</group>
</vector>

Binary file not shown.

After

Width:  |  Height:  |  Size: 493 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 352 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 647 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 975 B

View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- SPDX-License-Identifier: GPL-3.0-or-later -->
<!--
Adaptive-icon background layer (M6): a solid deep-indigo fill behind the
transparent gateway-medallion foreground. Replaces the Image Asset wizard's
default green grid. Sourced from @color/ic_launcher_background so the launcher
background stays a single source of truth.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="@color/ic_launcher_background"
android:pathData="M0,0h108v108h-108z" />
</vector>

View File

@@ -1,15 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- SPDX-License-Identifier: GPL-3.0-or-later -->
<!-- Placeholder launcher glyph: a stylized gateway arch. Replaced in the M5 design pass. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#D0BCFF"
android:pathData="M38,74 L38,50 A16,16 0 0,1 70,50 L70,74 L62,74 L62,50 A8,8 0 0,0 46,50 L46,74 Z" />
<path
android:fillColor="#EFB8C8"
android:pathData="M52,34 L56,34 L56,40 L52,40 Z M50,40 L58,40 L58,44 L50,44 Z" />
</vector>

Binary file not shown.

View File

@@ -1,7 +1,13 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!-- SPDX-License-Identifier: GPL-3.0-or-later --> <!-- SPDX-License-Identifier: GPL-3.0-or-later -->
<!--
Adaptive launcher icon (API 26+, which is every target device at minSdk 29):
the gateway-medallion foreground (transparent PNG, all densities) over the
deep-indigo background. No <monochrome> layer: the medallion is a full-colour
mark that does not reduce to a clean single-tone silhouette, so themed-icon
mode falls back to this standard icon rather than a tinted blob.
-->
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> <adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background" /> <background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" /> <foreground android:drawable="@mipmap/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon> </adaptive-icon>

View File

@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!-- SPDX-License-Identifier: GPL-3.0-or-later --> <!-- SPDX-License-Identifier: GPL-3.0-or-later -->
<!-- Round adaptive launcher icon — same layers as ic_launcher.xml. -->
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> <adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background" /> <background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" /> <foreground android:drawable="@mipmap/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon> </adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

View File

@@ -2,4 +2,7 @@
<!-- SPDX-License-Identifier: GPL-3.0-or-later --> <!-- SPDX-License-Identifier: GPL-3.0-or-later -->
<resources> <resources>
<color name="ic_launcher_background">#1B1033</color> <color name="ic_launcher_background">#1B1033</color>
<!-- M5 design pass: the shard-website page color, used as the launch window
background so the first frame matches the dark Compose theme (no white flash). -->
<color name="launch_background">#0B0F14</color>
</resources> </resources>

View File

@@ -30,6 +30,7 @@
<string name="connect_error_scheme">Only http and https addresses are supported.</string> <string name="connect_error_scheme">Only http and https addresses are supported.</string>
<string name="connect_error_insecure">A secure https address is required.</string> <string name="connect_error_insecure">A secure https address is required.</string>
<string name="connect_error_not_runic">That site isn\'t a Runic Gateway shard.</string> <string name="connect_error_not_runic">That site isn\'t a Runic Gateway shard.</string>
<string name="connect_error_version">This app is out of date for that site (it speaks API %1$s). Update the app and try again.</string>
<string name="connect_error_unreachable">Couldn\'t reach that site. Check the address and your connection.</string> <string name="connect_error_unreachable">Couldn\'t reach that site. Check the address and your connection.</string>
<string name="connect_error_server">The site responded with an error (%1$d). Try again shortly.</string> <string name="connect_error_server">The site responded with an error (%1$d). Try again shortly.</string>
@@ -143,6 +144,7 @@
<string name="player_char_governor">Governor of %1$s</string> <string name="player_char_governor">Governor of %1$s</string>
<string name="player_char_guildmaster">Guildmaster, %1$s</string> <string name="player_char_guildmaster">Guildmaster, %1$s</string>
<string name="player_char_attributes">Attributes</string> <string name="player_char_attributes">Attributes</string>
<string name="player_char_vitals">Vitals</string>
<string name="player_char_str">STR</string> <string name="player_char_str">STR</string>
<string name="player_char_dex">DEX</string> <string name="player_char_dex">DEX</string>
<string name="player_char_int">INT</string> <string name="player_char_int">INT</string>

View File

@@ -6,5 +6,11 @@
background / status-bar behavior for launch. background / status-bar behavior for launch.
--> -->
<resources> <resources>
<style name="Theme.RunicGateway" parent="android:Theme.Material.Light.NoActionBar" /> <!-- Dark launch theme (the app is dark-only, M5). Sets the window background to the
page color so the pre-Compose frame matches; Compose owns the live UI theme. -->
<style name="Theme.RunicGateway" parent="android:Theme.Material.NoActionBar">
<item name="android:windowBackground">@color/launch_background</item>
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
</style>
</resources> </resources>

View File

@@ -0,0 +1,51 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.data.repository
import com.runicgateway.app.data.api.dto.VersionDto
import com.runicgateway.app.data.repository.ConnectionRepository.Companion.evaluateVersion
import com.runicgateway.app.data.repository.ConnectionRepository.VersionVerdict
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* The connect-probe version guard (§3): a Runic Gateway backend on the app's
* supported API is accepted; a wrong service identity or an unsupported API
* version is refused clearly rather than mis-rendered.
*/
class ConnectionVersionGuardTest {
@Test
fun `matching service and api is Ok`() {
val v = VersionDto(service = "runic-gateway", api = "v1", server = "1.2.3")
assertEquals(VersionVerdict.Ok, evaluateVersion(v))
}
@Test
fun `service id is case-insensitive and trimmed`() {
val v = VersionDto(service = " Runic-Gateway ", api = "V1", server = "x")
assertEquals(VersionVerdict.Ok, evaluateVersion(v))
}
@Test
fun `wrong service is NotRunicGateway`() {
val v = VersionDto(service = "some-other-app", api = "v1", server = "x")
assertEquals(VersionVerdict.NotRunicGateway, evaluateVersion(v))
}
@Test
fun `blank api is lenient (older backend) and accepted`() {
val v = VersionDto(service = "runic-gateway", api = "", server = "x")
assertEquals(VersionVerdict.Ok, evaluateVersion(v))
}
@Test
fun `future api version is a mismatch carrying the server value`() {
val v = VersionDto(service = "runic-gateway", api = "v2", server = "x")
val verdict = evaluateVersion(v)
assertTrue(verdict is VersionVerdict.Mismatch)
assertEquals("v2", (verdict as VersionVerdict.Mismatch).serverApi)
}
}