Compare commits
11 Commits
2e1bea4220
...
v0.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
| d0fb6bbdfc | |||
| 9268579c5f | |||
| 0c395b2527 | |||
| 2d84a930e5 | |||
| b6a0fa1f5d | |||
| bc09593550 | |||
| 9514172b71 | |||
| de79bf547c | |||
| 0df862a6af | |||
| e497e6c8a7 | |||
| 52e06da8fc |
194
.gitea/workflows/release.yml
Normal file
@@ -0,0 +1,194 @@
|
||||
# Automated release for the Runic Gateway Android app.
|
||||
#
|
||||
# Trigger: pushing a version tag `v*` (e.g. `v0.1.0`). Tag-driven on purpose — the
|
||||
# build never has to push to protected `main`; the tag *is* the release input.
|
||||
#
|
||||
# To cut a release:
|
||||
# git tag v0.1.0 && git push origin v0.1.0
|
||||
# (or create the tag from the Gitea UI). Re-build/re-release an existing tag via
|
||||
# the workflow_dispatch input below.
|
||||
#
|
||||
# versionName = the tag without its leading `v`; versionCode = major*10000 +
|
||||
# minor*100 + patch (deterministic + monotonic, PLAN.md §10). Both are injected
|
||||
# into app/build.gradle.kts for the build only — nothing is committed back to main.
|
||||
#
|
||||
# Prerequisites (Settings -> Actions -> Secrets on RunicGateway/Android-app):
|
||||
# REGISTRY_TOKEN — Gitea access token with `write:repository` (create the release)
|
||||
# ANDROID_KEYSTORE_BASE64 — base64 of the release .jks (single line)
|
||||
# ANDROID_KEYSTORE_PASSWORD — keystore password
|
||||
# ANDROID_KEY_ALIAS — key alias (e.g. runicgateway)
|
||||
# ANDROID_KEY_PASSWORD — key password (== store password for a PKCS12 keystore)
|
||||
#
|
||||
# Runner handling matches pr-checks.yml (self-hosted `ubuntu-latest`): the container
|
||||
# lacks git/curl/unzip and can't reach api.adoptium.net, so we apt-install the base
|
||||
# tools + JDK 17 (not actions/setup-java), install the exact SDK packages, and
|
||||
# `chmod +x ./gradlew` in-step (checkout drops the exec bit).
|
||||
|
||||
name: Release APK
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: 'Existing v* tag to (re)build and release'
|
||||
required: true
|
||||
|
||||
concurrency:
|
||||
group: release-apk-${{ github.event.inputs.tag || github.ref_name }}
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
GITEA_HOST: gitea.whitlocktech.com
|
||||
REPO: RunicGateway/Android-app
|
||||
GRADLE_MODULE: app
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
# Fail fast on a genuinely wedged run (e.g. a stalled SDK/network download on
|
||||
# the self-hosted runner) instead of hanging forever and — because concurrency
|
||||
# is `cancel-in-progress: false` — blocking every later release behind it.
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Install base tools + JDK 17
|
||||
run: |
|
||||
apt-get update
|
||||
apt-get install -y git curl unzip jq openjdk-17-jdk-headless
|
||||
echo "JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Check out the release tag (full history for the changelog)
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.inputs.tag || github.ref_name }}
|
||||
fetch-depth: 0
|
||||
|
||||
# ── Derive version + changelog straight from the tag ─────────────────
|
||||
- name: Plan the release (version + changelog from the tag)
|
||||
id: plan
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p dist
|
||||
git fetch --tags --force >/dev/null 2>&1 || true
|
||||
|
||||
TAG="${{ github.event.inputs.tag || github.ref_name }}"
|
||||
case "$TAG" in
|
||||
v[0-9]*) : ;;
|
||||
*) echo "::error::expected a v* version tag, got '$TAG'"; exit 1 ;;
|
||||
esac
|
||||
VERSION="${TAG#v}"
|
||||
|
||||
# versionCode: deterministic + monotonic from the semver (PLAN.md §10).
|
||||
IFS=. read -r MA MI PA <<< "$VERSION"
|
||||
: "${MA:=0}"; : "${MI:=0}"; : "${PA:=0}"
|
||||
VERSION_CODE=$(( MA*10000 + MI*100 + PA ))
|
||||
|
||||
# Changelog: conventional-commit subjects since the previous v* tag.
|
||||
PREV_TAG="$(git describe --tags --match 'v*' --abbrev=0 "${TAG}^" 2>/dev/null || true)"
|
||||
if [ -n "$PREV_TAG" ]; then RANGE="${PREV_TAG}..${TAG}"; else RANGE="${TAG}"; fi
|
||||
SUBJECTS="$(git log --no-merges --format='%s' $RANGE || true)"
|
||||
|
||||
{
|
||||
echo "## Runic Gateway Android ${TAG}"
|
||||
echo
|
||||
FEATS="$(echo "$SUBJECTS" | grep -E '^feat' || true)"
|
||||
FIXES="$(echo "$SUBJECTS" | grep -E '^(fix|perf)' || true)"
|
||||
[ -n "$FEATS" ] && { echo "### Features"; echo "$FEATS" | sed 's/^/- /'; echo; }
|
||||
[ -n "$FIXES" ] && { echo "### Fixes"; echo "$FIXES" | sed 's/^/- /'; echo; }
|
||||
echo "### All changes"
|
||||
if [ -n "$PREV_TAG" ]; then echo "Since ${PREV_TAG}:"; fi
|
||||
echo "$SUBJECTS" | sed 's/^/- /'
|
||||
echo
|
||||
echo "---"
|
||||
echo "Signed APK — sideload on Android 10+ (§10). The app self-configures its shard site on first run."
|
||||
} > dist/CHANGELOG.md
|
||||
|
||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "versionCode=${VERSION_CODE}" >> "$GITHUB_OUTPUT"
|
||||
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
|
||||
echo "==> tag=${TAG} version=${VERSION} code=${VERSION_CODE} prev_tag=${PREV_TAG:-<none>}"
|
||||
|
||||
# ── SDK + signing keystore ───────────────────────────────────────────
|
||||
- name: Set up Android SDK
|
||||
uses: android-actions/setup-android@v3
|
||||
with:
|
||||
# Only put cmdline-tools on PATH. The action's default package set drags in
|
||||
# the whole emulator + the legacy `tools` package (hundreds of MB, network-
|
||||
# bound on this runner) that a headless APK build never uses. The next step
|
||||
# installs exactly the packages we need.
|
||||
packages: ''
|
||||
|
||||
- name: Install Android SDK packages
|
||||
run: |
|
||||
set +o pipefail
|
||||
yes | sdkmanager "platform-tools" "platforms;android-35" "build-tools;35.0.0"
|
||||
|
||||
- name: Decode signing keystore
|
||||
env:
|
||||
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${ANDROID_KEYSTORE_BASE64:-}" ]; then
|
||||
echo "::error::ANDROID_KEYSTORE_BASE64 secret is not set — cannot build a signed release."
|
||||
exit 1
|
||||
fi
|
||||
printf '%s' "$ANDROID_KEYSTORE_BASE64" | base64 -d > "${RUNNER_TEMP}/release.jks"
|
||||
echo "ANDROID_KEYSTORE_FILE=${RUNNER_TEMP}/release.jks" >> "$GITHUB_ENV"
|
||||
|
||||
# ── Set the version, build the signed APK ────────────────────────────
|
||||
- name: Set the app version to match the tag
|
||||
run: |
|
||||
set -euo pipefail
|
||||
VERSION="${{ steps.plan.outputs.version }}"
|
||||
VERSION_CODE="${{ steps.plan.outputs.versionCode }}"
|
||||
# Replace only the version defaults (the `?: "x.y.z"` / `?: N` fallbacks).
|
||||
sed -i -E "s/(\?: )\"[0-9]+\.[0-9]+\.[0-9]+\"/\1\"${VERSION}\"/" "${GRADLE_MODULE}/build.gradle.kts"
|
||||
sed -i -E "s/(toIntOrNull\(\) \?: )[0-9]+/\1${VERSION_CODE}/" "${GRADLE_MODULE}/build.gradle.kts"
|
||||
grep -nE "versionCode = |versionName = " "${GRADLE_MODULE}/build.gradle.kts"
|
||||
|
||||
- name: Unit tests + signed release APK
|
||||
env:
|
||||
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
||||
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
|
||||
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
chmod +x ./gradlew
|
||||
./gradlew --no-daemon :${GRADLE_MODULE}:testDebugUnitTest :${GRADLE_MODULE}:assembleRelease
|
||||
|
||||
- name: Package APK + SHA256SUMS
|
||||
run: |
|
||||
set -euo pipefail
|
||||
SRC="${GRADLE_MODULE}/build/outputs/apk/release/app-release.apk"
|
||||
test -f "$SRC" || { echo "::error::release APK not found at $SRC"; exit 1; }
|
||||
cp "$SRC" "dist/runic-gateway-${{ steps.plan.outputs.version }}.apk"
|
||||
( cd dist && sha256sum "runic-gateway-${{ steps.plan.outputs.version }}.apk" > SHA256SUMS )
|
||||
ls -l dist && cat dist/SHA256SUMS
|
||||
|
||||
# ── Create the Gitea release + upload assets (no push to main) ───────
|
||||
- name: Create Gitea release and upload assets
|
||||
env:
|
||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG="${{ steps.plan.outputs.tag }}"
|
||||
API="https://${GITEA_HOST}/api/v1/repos/${REPO}"
|
||||
BODY="$(cat dist/CHANGELOG.md)"
|
||||
CI_TOKEN="$(printf '%s' "${REGISTRY_TOKEN}" | tr -d '\r\n')"
|
||||
|
||||
REL_ID="$(curl -sSf -X POST "${API}/releases" \
|
||||
-H "Authorization: token ${CI_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$(jq -n --arg tag "$TAG" --arg body "$BODY" \
|
||||
'{tag_name:$tag, name:$tag, body:$body, draft:false, prerelease:false}')" \
|
||||
| jq -r '.id')"
|
||||
echo "Created release ${TAG} (id=${REL_ID})"
|
||||
|
||||
for f in "runic-gateway-${{ steps.plan.outputs.version }}.apk" SHA256SUMS; do
|
||||
curl -sSf -X POST "${API}/releases/${REL_ID}/assets?name=${f}" \
|
||||
-H "Authorization: token ${CI_TOKEN}" \
|
||||
-F "attachment=@dist/${f}" >/dev/null
|
||||
echo " uploaded ${f}"
|
||||
done
|
||||
@@ -1,5 +1,8 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
import java.io.FileInputStream
|
||||
import java.util.Properties
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.kotlin.android)
|
||||
@@ -9,6 +12,25 @@ plugins {
|
||||
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 {
|
||||
namespace = "com.runicgateway.app"
|
||||
compileSdk = 35
|
||||
@@ -18,20 +40,46 @@ android {
|
||||
applicationId = "com.runicgateway.app"
|
||||
minSdk = 29
|
||||
targetSdk = 35
|
||||
versionCode = 1
|
||||
versionName = "0.1.0"
|
||||
// These committed defaults are the version source of truth (PLAN.md §10).
|
||||
// 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"
|
||||
}
|
||||
|
||||
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 {
|
||||
release {
|
||||
// Signing/minification are wired at M6 (release hardening). Debug is auto-signed.
|
||||
isMinifyEnabled = false
|
||||
// R8 full-mode minify + resource shrink (§7: no offline cache, so a lean
|
||||
// release APK). Keep rules live in proguard-rules.pro.
|
||||
isMinifyEnabled = true
|
||||
isShrinkResources = true
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
93
app/licenses/Cinzel-OFL.txt
Normal file
@@ -0,0 +1,93 @@
|
||||
Copyright 2020 The Cinzel Project Authors (https://github.com/NDISCOVER/Cinzel)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
62
app/proguard-rules.pro
vendored
@@ -1,3 +1,59 @@
|
||||
# Runic Gateway Android app — ProGuard/R8 rules.
|
||||
# Minification is disabled until M6 (release hardening); real keep rules for
|
||||
# kotlinx.serialization DTOs and Retrofit models are added there.
|
||||
# Runic Gateway Android app — ProGuard/R8 rules (release minify + resource shrink, M6).
|
||||
#
|
||||
# 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.**
|
||||
|
||||
|
||||
BIN
app/src/main/ic_launcher-playstore.png
Normal file
|
After Width: | Height: | Size: 160 KiB |
@@ -3,8 +3,10 @@
|
||||
*/
|
||||
package com.runicgateway.app
|
||||
|
||||
import android.graphics.Color
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.SystemBarStyle
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
@@ -35,7 +37,10 @@ import dagger.hilt.android.AndroidEntryPoint
|
||||
class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
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 {
|
||||
val appViewModel: AppViewModel = hiltViewModel()
|
||||
val state by appViewModel.state.collectAsStateWithLifecycle()
|
||||
|
||||
@@ -36,6 +36,13 @@ class ConnectionRepository @Inject constructor(
|
||||
|
||||
/** Reachable and 2xx, but not a Runic Gateway backend (wrong version identity). */
|
||||
data object NotRunicGateway : ProbeResult
|
||||
|
||||
/**
|
||||
* A Runic Gateway backend, but speaking an API version this app build does
|
||||
* not support (§3 version guard) — refuse rather than mis-render. [serverApi]
|
||||
* is what the site reported; [supportedApi] is what this app speaks.
|
||||
*/
|
||||
data class VersionMismatch(val serverApi: String, val supportedApi: String) : ProbeResult
|
||||
data class ServerError(val status: Int) : ProbeResult
|
||||
data class Unreachable(val cause: Throwable) : ProbeResult
|
||||
}
|
||||
@@ -68,14 +75,15 @@ class ConnectionRepository @Inject constructor(
|
||||
?: return ProbeResult.InvalidUrl(ServerUrl.Reason.MALFORMED)
|
||||
|
||||
return when (val result = safeApiCall { api.probeStatus(statusUrl) }) {
|
||||
is ApiResult.Ok -> {
|
||||
if (!result.data.version.service.equals(RUNIC_SERVICE_ID, ignoreCase = true)) {
|
||||
ProbeResult.NotRunicGateway
|
||||
} else {
|
||||
is ApiResult.Ok -> when (val verdict = evaluateVersion(result.data.version)) {
|
||||
is VersionVerdict.Ok -> {
|
||||
prefs.setBaseUrl(normalized.toString())
|
||||
baseUrlHolder.set(normalized)
|
||||
ProbeResult.Success(result.data)
|
||||
}
|
||||
is VersionVerdict.NotRunicGateway -> ProbeResult.NotRunicGateway
|
||||
is VersionVerdict.Mismatch ->
|
||||
ProbeResult.VersionMismatch(serverApi = verdict.serverApi, supportedApi = SUPPORTED_API)
|
||||
}
|
||||
is ApiResult.HttpError -> ProbeResult.ServerError(result.status)
|
||||
is ApiResult.NetworkError -> ProbeResult.Unreachable(result.cause)
|
||||
@@ -93,7 +101,38 @@ class ConnectionRepository @Inject constructor(
|
||||
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"
|
||||
|
||||
/** 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import androidx.compose.material3.NavigationDrawerItemDefaults
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.rememberDrawerState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
@@ -29,7 +30,9 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.LifecycleResumeEffect
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
@@ -111,11 +114,17 @@ fun RunicApp(
|
||||
drawerState = drawerState,
|
||||
gesturesEnabled = isTopLevel,
|
||||
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))
|
||||
Text(
|
||||
text = brand?.name?.takeIf { it.isNotBlank() } ?: stringResource(R.string.app_name),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.padding(horizontal = 24.dp, vertical = 12.dp),
|
||||
)
|
||||
HorizontalDivider()
|
||||
@@ -128,6 +137,7 @@ fun RunicApp(
|
||||
scope.launch { drawerState.close() }
|
||||
navController.navigateTopLevel(entry.route)
|
||||
},
|
||||
colors = drawerItemColors,
|
||||
modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding),
|
||||
)
|
||||
}
|
||||
@@ -152,6 +162,7 @@ fun RunicApp(
|
||||
navController.navigate(Routes.LOGIN)
|
||||
}
|
||||
},
|
||||
colors = drawerItemColors,
|
||||
modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding),
|
||||
)
|
||||
NavigationDrawerItem(
|
||||
@@ -161,6 +172,7 @@ fun RunicApp(
|
||||
scope.launch { drawerState.close() }
|
||||
onChangeServer()
|
||||
},
|
||||
colors = drawerItemColors,
|
||||
modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding),
|
||||
)
|
||||
}
|
||||
@@ -170,10 +182,19 @@ fun RunicApp(
|
||||
modifier = modifier,
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
titleContentColor = MaterialTheme.colorScheme.onSurface,
|
||||
navigationIconContentColor = MaterialTheme.colorScheme.onSurface,
|
||||
actionIconContentColor = MaterialTheme.colorScheme.onSurface,
|
||||
),
|
||||
title = {
|
||||
Text(
|
||||
brand?.name?.takeIf { it.isNotBlank() }
|
||||
?: stringResource(R.string.app_name),
|
||||
text = (brand?.name?.takeIf { it.isNotBlank() }
|
||||
?: stringResource(R.string.app_name)).uppercase(),
|
||||
style = MaterialTheme.typography.titleSmall.copy(letterSpacing = 1.2.sp),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
|
||||
@@ -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.components.ErrorView
|
||||
import com.runicgateway.app.ui.components.LoadingView
|
||||
import com.runicgateway.app.ui.components.PillTone
|
||||
import com.runicgateway.app.ui.components.StatusPill
|
||||
|
||||
/**
|
||||
* The signed-in account surface (PLAN.md §5, §6.3): identity + role, self-service
|
||||
@@ -101,11 +103,10 @@ private fun IdentityCard(username: String, roleLabel: String) {
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(20.dp)) {
|
||||
Text(text = username, style = MaterialTheme.typography.titleLarge)
|
||||
Text(
|
||||
StatusPill(
|
||||
text = roleLabel,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
tone = PillTone.Info,
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -221,10 +222,9 @@ private fun TwoFactorSection(
|
||||
) {
|
||||
var code by rememberSaveable { mutableStateOf("") }
|
||||
SectionCard(R.string.account_totp_title) {
|
||||
Text(
|
||||
stringResource(if (account.totp_enabled) R.string.account_totp_on else R.string.account_totp_off),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = if (account.totp_enabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
StatusPill(
|
||||
text = stringResource(if (account.totp_enabled) R.string.account_totp_on else R.string.account_totp_off),
|
||||
tone = if (account.totp_enabled) PillTone.Success else PillTone.Neutral,
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
)
|
||||
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -107,6 +107,7 @@ private fun errorMessage(error: ConnectError): String = when (error) {
|
||||
ConnectError.UnsupportedScheme -> stringResource(R.string.connect_error_scheme)
|
||||
ConnectError.Insecure -> stringResource(R.string.connect_error_insecure)
|
||||
ConnectError.NotRunicGateway -> stringResource(R.string.connect_error_not_runic)
|
||||
is ConnectError.VersionMismatch -> stringResource(R.string.connect_error_version, error.serverApi)
|
||||
ConnectError.Unreachable -> stringResource(R.string.connect_error_unreachable)
|
||||
is ConnectError.Server -> stringResource(R.string.connect_error_server, error.status)
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ class ConnectViewModel @Inject constructor(
|
||||
data object UnsupportedScheme : ConnectError
|
||||
data object Insecure : ConnectError
|
||||
data object NotRunicGateway : ConnectError
|
||||
data class VersionMismatch(val serverApi: String) : ConnectError
|
||||
data object Unreachable : ConnectError
|
||||
data class Server(val status: Int) : ConnectError
|
||||
}
|
||||
@@ -61,6 +62,7 @@ class ConnectViewModel @Inject constructor(
|
||||
}
|
||||
is ProbeResult.InvalidUrl -> fail(result.reason.toError())
|
||||
ProbeResult.NotRunicGateway -> fail(ConnectError.NotRunicGateway)
|
||||
is ProbeResult.VersionMismatch -> fail(ConnectError.VersionMismatch(result.serverApi))
|
||||
is ProbeResult.Unreachable -> fail(ConnectError.Unreachable)
|
||||
is ProbeResult.ServerError -> fail(ConnectError.Server(result.status))
|
||||
}
|
||||
|
||||
@@ -4,19 +4,19 @@
|
||||
package com.runicgateway.app.ui.home
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -27,7 +27,10 @@ import com.runicgateway.app.data.api.dto.BrandDto
|
||||
import com.runicgateway.app.data.api.dto.StatusDto
|
||||
import com.runicgateway.app.ui.UiState
|
||||
import com.runicgateway.app.ui.components.ErrorView
|
||||
import com.runicgateway.app.ui.components.FeatureCard
|
||||
import com.runicgateway.app.ui.components.LoadingView
|
||||
import com.runicgateway.app.ui.components.OnlineDot
|
||||
import com.runicgateway.app.ui.components.PillTone
|
||||
|
||||
/**
|
||||
* Home / status (PLAN.md §6.1): the shard's name + tagline from branding, and a
|
||||
@@ -83,40 +86,37 @@ private fun HomeContent(brand: BrandDto?, status: StatusDto, modifier: Modifier
|
||||
@Composable
|
||||
private fun StatusCard(status: StatusDto) {
|
||||
val online = !status.isMaintenance
|
||||
val containerColor =
|
||||
if (online) MaterialTheme.colorScheme.secondaryContainer
|
||||
else MaterialTheme.colorScheme.errorContainer
|
||||
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = containerColor),
|
||||
) {
|
||||
Column(Modifier.padding(16.dp)) {
|
||||
FeatureCard {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
OnlineDot(if (online) PillTone.Success else PillTone.Danger)
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Text(
|
||||
text = stringResource(
|
||||
if (online) R.string.home_status_live else R.string.home_status_maintenance,
|
||||
),
|
||||
style = MaterialTheme.typography.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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,6 +35,8 @@ import com.runicgateway.app.data.api.dto.TitlesDto
|
||||
import com.runicgateway.app.ui.UiState
|
||||
import com.runicgateway.app.ui.components.ErrorView
|
||||
import com.runicgateway.app.ui.components.LoadingView
|
||||
import com.runicgateway.app.ui.components.SectionLabel
|
||||
import com.runicgateway.app.ui.components.StatBar
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
|
||||
/**
|
||||
@@ -140,10 +142,13 @@ private fun AttributesBlock(stats: CharStatsDto) {
|
||||
Stat(stringResource(R.string.player_char_dex), stats.dex)
|
||||
Stat(stringResource(R.string.player_char_int), stats.int)
|
||||
}
|
||||
Column(Modifier.padding(top = 12.dp)) {
|
||||
Vital(stringResource(R.string.player_char_hits), stats.hits, stats.hitsMax)
|
||||
Vital(stringResource(R.string.player_char_mana), stats.mana, stats.manaMax)
|
||||
Vital(stringResource(R.string.player_char_stam), stats.stam, stats.stamMax)
|
||||
Column(Modifier.padding(top = 16.dp)) {
|
||||
SectionLabel(stringResource(R.string.player_char_vitals))
|
||||
Column(Modifier.padding(top = 8.dp)) {
|
||||
Vital(stringResource(R.string.player_char_hits), stats.hits, stats.hitsMax)
|
||||
Vital(stringResource(R.string.player_char_mana), stats.mana, stats.manaMax)
|
||||
Vital(stringResource(R.string.player_char_stam), stats.stam, stats.stamMax)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -158,9 +163,13 @@ private fun Stat(label: String, value: Int?) {
|
||||
|
||||
@Composable
|
||||
private fun Vital(label: String, cur: Int?, max: Int?) {
|
||||
Row(Modifier.fillMaxWidth().padding(vertical = 2.dp), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(label, style = MaterialTheme.typography.bodyMedium)
|
||||
Text("${cur ?: "—"} / ${max ?: "—"}", style = MaterialTheme.typography.bodyMedium)
|
||||
Column(Modifier.padding(vertical = 5.dp)) {
|
||||
Row(Modifier.fillMaxWidth().padding(bottom = 4.dp), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(label, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Text("${cur ?: "—"} / ${max ?: "—"}", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
val fraction = if (max != null && max > 0 && cur != null) cur.toFloat() / max else 0f
|
||||
StatBar(fraction)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -194,9 +203,13 @@ private fun SkillsBlock(skills: List<SkillDto>) {
|
||||
if (shown.isEmpty()) return
|
||||
SheetCard(R.string.player_char_skills) {
|
||||
shown.forEach { skill ->
|
||||
Row(Modifier.fillMaxWidth().padding(vertical = 3.dp), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(skill.n ?: "—", style = MaterialTheme.typography.bodyMedium)
|
||||
Text(formatSkill(skill.value ?: 0.0), style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium)
|
||||
val value = skill.value ?: 0.0
|
||||
Column(Modifier.padding(vertical = 5.dp)) {
|
||||
Row(Modifier.fillMaxWidth().padding(bottom = 4.dp), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(skill.n ?: "—", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Text(formatSkill(value), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, fontWeight = FontWeight.Medium)
|
||||
}
|
||||
StatBar((value / SKILL_CAP).toFloat())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -243,6 +256,9 @@ private fun SheetCard(titleRes: Int, content: @Composable () -> Unit) {
|
||||
|
||||
// ── Pure helpers (unit-tested) ───────────────────────────────────────────────
|
||||
|
||||
/** ServUO's default skill cap; used to scale the skill meter fill (0..1). */
|
||||
private const val SKILL_CAP = 120.0
|
||||
|
||||
/** Format a skill value: drop the ".0" on whole numbers, else one decimal. */
|
||||
internal fun formatSkill(value: Double): String =
|
||||
if (value % 1.0 == 0.0) value.toInt().toString() else "%.1f".format(value)
|
||||
|
||||
@@ -38,6 +38,8 @@ import com.runicgateway.app.ui.ErrorKind
|
||||
import com.runicgateway.app.ui.UiState
|
||||
import com.runicgateway.app.ui.components.ErrorView
|
||||
import com.runicgateway.app.ui.components.LoadingView
|
||||
import com.runicgateway.app.ui.components.PillTone
|
||||
import com.runicgateway.app.ui.components.StatusPill
|
||||
|
||||
/**
|
||||
* The player's linked game accounts + character rosters (PLAN.md §6.3). Tapping a
|
||||
@@ -212,14 +214,18 @@ private fun CharRow(char: RosterCharDto, onOpenChar: (String) -> Unit) {
|
||||
Modifier.fillMaxWidth().padding(14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(char.name ?: stringResource(R.string.player_char_unknown), style = MaterialTheme.typography.bodyLarge)
|
||||
Text(
|
||||
stringResource(if (char.online) R.string.player_char_online else R.string.player_char_offline),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = if (char.online) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
char.name ?: stringResource(R.string.player_char_unknown),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
StatusPill(
|
||||
text = stringResource(
|
||||
if (char.online) R.string.player_char_online else R.string.player_char_offline,
|
||||
),
|
||||
tone = if (char.online) PillTone.Success else PillTone.Neutral,
|
||||
modifier = Modifier.padding(end = 8.dp),
|
||||
)
|
||||
Text("›", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,8 @@ import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.runicgateway.app.R
|
||||
import com.runicgateway.app.data.api.dto.ChampDto
|
||||
import com.runicgateway.app.ui.components.PillTone
|
||||
import com.runicgateway.app.ui.components.StatusPill
|
||||
|
||||
/** The champion-spawn board (PLAN.md §6.2), live via SSE deltas. */
|
||||
@Composable
|
||||
@@ -53,11 +55,7 @@ private fun ChampCard(champ: ChampDto) {
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
champ.status?.let {
|
||||
Text(
|
||||
text = it.replaceFirstChar { c -> c.uppercase() },
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
StatusPill(text = it, tone = champStatusTone(it))
|
||||
}
|
||||
}
|
||||
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. */
|
||||
@Composable
|
||||
private fun champDetail(champ: ChampDto): String = when (champ.category) {
|
||||
|
||||
@@ -20,6 +20,8 @@ import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.runicgateway.app.R
|
||||
import com.runicgateway.app.data.api.dto.HouseDto
|
||||
import com.runicgateway.app.ui.components.PillTone
|
||||
import com.runicgateway.app.ui.components.StatusPill
|
||||
|
||||
/** The public "falling houses" (IDOC) board (PLAN.md §6.2), live via `house.decay`. */
|
||||
@Composable
|
||||
@@ -54,10 +56,9 @@ private fun HouseCard(house: HouseDto) {
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Text(
|
||||
StatusPill(
|
||||
text = stringResource(R.string.houses_idoc_badge),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
tone = PillTone.Danger,
|
||||
)
|
||||
}
|
||||
val where = listOfNotNull(
|
||||
|
||||
@@ -27,6 +27,8 @@ import com.runicgateway.app.ui.UiState
|
||||
import com.runicgateway.app.ui.components.EmptyView
|
||||
import com.runicgateway.app.ui.components.ErrorView
|
||||
import com.runicgateway.app.ui.components.LoadingView
|
||||
import com.runicgateway.app.ui.theme.ShardSuccess
|
||||
import com.runicgateway.app.ui.theme.ShardSuccessDot
|
||||
|
||||
/**
|
||||
* A small "Live / Offline" indicator for the shard screens (PLAN.md §6.2): a dot +
|
||||
@@ -34,22 +36,19 @@ import com.runicgateway.app.ui.components.LoadingView
|
||||
*/
|
||||
@Composable
|
||||
fun LiveChip(connected: Boolean, modifier: Modifier = Modifier) {
|
||||
val color = if (connected) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
}
|
||||
val dotColor = if (connected) ShardSuccessDot else MaterialTheme.colorScheme.onSurfaceVariant
|
||||
val textColor = if (connected) ShardSuccess else MaterialTheme.colorScheme.onSurfaceVariant
|
||||
Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) {
|
||||
androidx.compose.foundation.layout.Box(
|
||||
Modifier
|
||||
.size(8.dp)
|
||||
.clip(CircleShape)
|
||||
.background(color),
|
||||
.background(dotColor),
|
||||
)
|
||||
Text(
|
||||
text = stringResource(if (connected) R.string.shard_live else R.string.shard_offline),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = color,
|
||||
color = textColor,
|
||||
modifier = Modifier.padding(start = 6.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -30,7 +30,11 @@ import com.runicgateway.app.data.api.dto.OnlineStaffDto
|
||||
import com.runicgateway.app.data.api.dto.ShardStatusDto
|
||||
import com.runicgateway.app.ui.UiState
|
||||
import com.runicgateway.app.ui.components.ErrorView
|
||||
import com.runicgateway.app.ui.components.FeatureCard
|
||||
import com.runicgateway.app.ui.components.LoadingView
|
||||
import com.runicgateway.app.ui.components.PillTone
|
||||
import com.runicgateway.app.ui.components.SectionLabel
|
||||
import com.runicgateway.app.ui.components.StatusPill
|
||||
|
||||
/** Board destinations reachable from the hub. */
|
||||
enum class ShardBoard { CHAMPS, GUILDS, GOVERNORS, HOUSES }
|
||||
@@ -114,29 +118,42 @@ private fun HubContent(
|
||||
|
||||
@Composable
|
||||
private fun StatusCard(status: ShardStatusDto, presenceCount: Int?) {
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(16.dp)) {
|
||||
Text(
|
||||
text = stringResource(
|
||||
if (status.isOnline) R.string.shard_status_online else R.string.shard_status_offline,
|
||||
),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
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 ->
|
||||
FeatureCard {
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
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),
|
||||
text = stringResource(
|
||||
if (status.isOnline) R.string.shard_status_online else R.string.shard_status_offline,
|
||||
),
|
||||
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
|
||||
private fun SectionHeader(text: String) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
SectionLabel(text = text, modifier = Modifier.padding(top = 4.dp))
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -5,12 +5,44 @@ package com.runicgateway.app.ui.theme
|
||||
|
||||
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).
|
||||
val Purple80 = Color(0xFFD0BCFF)
|
||||
val PurpleGrey80 = Color(0xFFCCC2DC)
|
||||
val Pink80 = Color(0xFFEFB8C8)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Shard-website palette (M5 design pass, docs/android/PLAN.md §M5). Deep blue-black
|
||||
// surfaces, a slate-blue accent, parchment serif text, and a light CTA fill —
|
||||
// mirrored from the "Runic Gateway Screens" design. Dark-only by design.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
val Purple40 = Color(0xFF6650A4)
|
||||
val PurpleGrey40 = Color(0xFF625B71)
|
||||
val Pink40 = Color(0xFF7D5260)
|
||||
// Surfaces
|
||||
val ShardPage = Color(0xFF0B0F14) // app/page background
|
||||
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)
|
||||
|
||||
40
app/src/main/java/com/runicgateway/app/ui/theme/Font.kt
Normal 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
|
||||
@@ -3,54 +3,78 @@
|
||||
*/
|
||||
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.Shapes
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
private val DarkColorScheme = darkColorScheme(
|
||||
primary = Purple80,
|
||||
secondary = PurpleGrey80,
|
||||
tertiary = Pink80,
|
||||
/**
|
||||
* The shard-website color scheme (M5 design pass). The app is **dark-only** — the
|
||||
* design is a single deep blue-black theme, so there is no light variant and the
|
||||
* 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(
|
||||
primary = Purple40,
|
||||
secondary = PurpleGrey40,
|
||||
tertiary = Pink40,
|
||||
/** 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 for the functional pass. The color scheme is seeded from the
|
||||
* per-shard brand accent (PLAN.md §3: the app themes itself from the site's
|
||||
* branding) when one is available, so the app takes on each shard's color; it
|
||||
* falls back to a neutral placeholder scheme before connect or when a site
|
||||
* publishes no accent.
|
||||
*
|
||||
* 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.
|
||||
* App theme. The color scheme is the fixed shard-website dark palette; when a shard
|
||||
* publishes a brand accent (PLAN.md §3), it seeds the [MaterialTheme]'s primary and
|
||||
* secondary roles so buttons and highlights carry that shard's color while the rest
|
||||
* of the deep blue-black system stays intact. With no accent, the slate default is
|
||||
* used.
|
||||
*/
|
||||
@Composable
|
||||
fun RunicGatewayTheme(
|
||||
accent: Color? = null,
|
||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val base = if (darkTheme) DarkColorScheme else LightColorScheme
|
||||
val colorScheme = if (accent != null) {
|
||||
// Brand consistency across a shard wins over device dynamic color: seed
|
||||
// the primary role from the accent so buttons/highlights carry the brand.
|
||||
base.copy(primary = accent, secondary = accent)
|
||||
ShardColorScheme.copy(primary = accent, secondary = accent, tertiary = accent)
|
||||
} else {
|
||||
base
|
||||
ShardColorScheme
|
||||
}
|
||||
|
||||
MaterialTheme(
|
||||
colorScheme = colorScheme,
|
||||
typography = Typography,
|
||||
shapes = ShardShapes,
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,17 +5,77 @@ package com.runicgateway.app.ui.theme
|
||||
|
||||
import androidx.compose.material3.Typography
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
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(
|
||||
// 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(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 16.sp,
|
||||
lineHeight = 24.sp,
|
||||
letterSpacing = 0.5.sp,
|
||||
fontFamily = AppSerif, fontWeight = FontWeight.Normal,
|
||||
fontSize = 16.sp, lineHeight = 26.sp, letterSpacing = 0.15.sp,
|
||||
),
|
||||
bodyMedium = TextStyle(
|
||||
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,
|
||||
),
|
||||
)
|
||||
|
||||
19
app/src/main/res/drawable-anydpi/ic_stat_name.xml
Normal 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>
|
||||
BIN
app/src/main/res/drawable-hdpi/ic_stat_name.png
Normal file
|
After Width: | Height: | Size: 493 B |
BIN
app/src/main/res/drawable-mdpi/ic_stat_name.png
Normal file
|
After Width: | Height: | Size: 352 B |
BIN
app/src/main/res/drawable-xhdpi/ic_stat_name.png
Normal file
|
After Width: | Height: | Size: 647 B |
BIN
app/src/main/res/drawable-xxhdpi/ic_stat_name.png
Normal file
|
After Width: | Height: | Size: 975 B |
17
app/src/main/res/drawable/ic_launcher_background.xml
Normal 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>
|
||||
@@ -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>
|
||||
BIN
app/src/main/res/font/cinzel_variable.ttf
Normal file
@@ -1,7 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- 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">
|
||||
<background android:drawable="@color/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
||||
<background android:drawable="@drawable/ic_launcher_background" />
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- 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">
|
||||
<background android:drawable="@color/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
||||
<background android:drawable="@drawable/ic_launcher_background" />
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
|
||||
BIN
app/src/main/res/mipmap-hdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
app/src/main/res/mipmap-hdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 4.5 KiB |
BIN
app/src/main/res/mipmap-mdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp
Normal file
|
After Width: | Height: | Size: 9.1 KiB |
BIN
app/src/main/res/mipmap-mdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
app/src/main/res/mipmap-xhdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 6.5 KiB |
BIN
app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 7.3 KiB |
BIN
app/src/main/res/mipmap-xxhdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp
Normal file
|
After Width: | Height: | Size: 61 KiB |
BIN
app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp
Normal file
|
After Width: | Height: | Size: 106 KiB |
BIN
app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 26 KiB |
@@ -2,4 +2,7 @@
|
||||
<!-- SPDX-License-Identifier: GPL-3.0-or-later -->
|
||||
<resources>
|
||||
<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>
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
<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_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_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_guildmaster">Guildmaster, %1$s</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_dex">DEX</string>
|
||||
<string name="player_char_int">INT</string>
|
||||
|
||||
@@ -6,5 +6,11 @@
|
||||
background / status-bar behavior for launch.
|
||||
-->
|
||||
<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>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||