feat(ui): m5 shard-website design pass #10

Merged
whitlocktech merged 1 commits from feat/m5-design-pass into main 2026-07-20 07:48:32 +00:00
20 changed files with 615 additions and 142 deletions
Showing only changes of commit 52e06da8fc - Show all commits

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

@@ -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()

View File

@@ -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 = {

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.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),
)

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

@@ -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),
)
}
}
}
}

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.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)

View File

@@ -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)
}
}

View File

@@ -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) {

View File

@@ -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(

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.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),
)
}

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.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

View File

@@ -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)

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
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,
)
}

View File

@@ -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,
),
)

Binary file not shown.

View File

@@ -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>

View File

@@ -143,6 +143,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>

View File

@@ -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>