Compare commits
6 Commits
v0.4.0
...
7acbe54f46
| Author | SHA1 | Date | |
|---|---|---|---|
| 7acbe54f46 | |||
| c7c49a9d6b | |||
| 1530c83fbc | |||
| c65913c62a | |||
| 17e9451494 | |||
| b0117acac1 |
@@ -23,6 +23,7 @@ import androidx.compose.ui.Modifier
|
||||
import com.runicgateway.app.core.auth.sso.SsoAuthManager
|
||||
import com.runicgateway.app.core.push.PushNotifier
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.LifecycleResumeEffect
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.runicgateway.app.ui.AppViewModel
|
||||
import com.runicgateway.app.ui.AppViewModel.AppState
|
||||
@@ -30,8 +31,8 @@ import com.runicgateway.app.ui.LocalAssetResolver
|
||||
import com.runicgateway.app.ui.RunicApp
|
||||
import com.runicgateway.app.ui.components.LoadingView
|
||||
import com.runicgateway.app.ui.connect.ConnectScreen
|
||||
import com.runicgateway.app.data.appearance.SiteAppearance
|
||||
import com.runicgateway.app.ui.theme.RunicGatewayTheme
|
||||
import com.runicgateway.app.ui.theme.parseBrandColor
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
@@ -39,8 +40,8 @@ import javax.inject.Inject
|
||||
/**
|
||||
* Single-activity host (PLAN.md §2). Gates on [AppViewModel]: the first-run
|
||||
* connect screen until a shard site is configured (§3), then the main app.
|
||||
* The Material theme is seeded from the per-shard brand accent, and asset-path
|
||||
* resolution is provided to the whole tree.
|
||||
* The Material theme is resolved from the shard's published appearance (M12),
|
||||
* and asset-path resolution is provided to the whole tree.
|
||||
*/
|
||||
@AndroidEntryPoint
|
||||
class MainActivity : ComponentActivity() {
|
||||
@@ -69,9 +70,21 @@ class MainActivity : ComponentActivity() {
|
||||
val appViewModel: AppViewModel = hiltViewModel()
|
||||
val state by appViewModel.state.collectAsStateWithLifecycle()
|
||||
|
||||
val accent = (state as? AppState.Ready)?.brand?.let { parseBrandColor(it.accent) }
|
||||
// The whole theme, not just the accent (THEMING_AND_NAV.md §5.1): the
|
||||
// resolved token map is applied field by field over the shipped palette,
|
||||
// so NONE — before the site is connected, or when settings can't be
|
||||
// read — is the app exactly as it shipped.
|
||||
val appearance = (state as? AppState.Ready)?.appearance ?: SiteAppearance.NONE
|
||||
|
||||
RunicGatewayTheme(accent = accent) {
|
||||
// The admin's theme and nav can change while the app is backgrounded
|
||||
// (THEMING_AND_NAV.md §5.5). Re-read them on resume, beside the session
|
||||
// re-validation RunicApp already does. Best-effort and silent.
|
||||
LifecycleResumeEffect(Unit) {
|
||||
appViewModel.refreshAppearance()
|
||||
onPauseOrDispose { }
|
||||
}
|
||||
|
||||
RunicGatewayTheme(appearance = appearance) {
|
||||
CompositionLocalProvider(LocalAssetResolver provides appViewModel::resolveAsset) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
@@ -83,7 +96,7 @@ class MainActivity : ComponentActivity() {
|
||||
ConnectScreen(onConnected = appViewModel::onConnected)
|
||||
is AppState.Ready ->
|
||||
RunicApp(
|
||||
brand = s.brand,
|
||||
brand = s.appearance.brand,
|
||||
onChangeServer = appViewModel::changeServer,
|
||||
deepLinkStream = pendingStream,
|
||||
onDeepLinkConsumed = { pendingStream = null },
|
||||
|
||||
@@ -5,6 +5,7 @@ package com.runicgateway.app.data.api.dto
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
|
||||
/**
|
||||
* DTOs for the public site/identity endpoints. Shapes mirror the backend
|
||||
@@ -80,4 +81,28 @@ data class SettingsDto(
|
||||
val brand: BrandDto = BrandDto(),
|
||||
/** Push relay config (M7); default (null ntfyUrl) on a backend that predates it. */
|
||||
val push: PushConfigDto = PushConfigDto(),
|
||||
/**
|
||||
* The admin's **resolved** theme tokens — the CSS custom properties the site
|
||||
* paints, already layered `:root ← preset ← custom` by the server
|
||||
* (THEMING_AND_NAV.md §3). Absent when no `theme_visual` row exists, which
|
||||
* means "the shipped defaults" and is the untouched-instance path.
|
||||
*
|
||||
* Held as a raw [JsonElement] rather than a `Map<String, String>` on
|
||||
* purpose: a single unexpected value must not fail the decode of the whole
|
||||
* settings payload and take `brand` and `push` down with it. It is coerced
|
||||
* field-by-field by `SiteAppearance.from`.
|
||||
*
|
||||
* The raw `theme_visual` / `brand_assets` rows ride along in this same
|
||||
* response and are deliberately **not** modeled — they are inputs, and
|
||||
* re-deriving a palette from them would be a second `resolveThemeTokens` in
|
||||
* Kotlin, guaranteed to drift (§3).
|
||||
*/
|
||||
val theme: JsonElement? = null,
|
||||
/**
|
||||
* The public nav overrides, as the raw JSON **string** stored in
|
||||
* `settings.value` (TEXT) — so it is parsed a second time, exactly as the web
|
||||
* client's `parseJsonSetting` does. Absent when the admin never edited the
|
||||
* nav.
|
||||
*/
|
||||
@SerialName("nav_public") val navPublic: String? = null,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.data.appearance
|
||||
|
||||
import kotlinx.serialization.SerializationException
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
|
||||
/**
|
||||
* Parse a JSON-valued settings row, client side — the second stage of decoding
|
||||
* `nav_public` (THEMING_AND_NAV.md §3).
|
||||
*
|
||||
* The Kotlin counterpart to the web client's `lib/settingsJson.js`, and
|
||||
* deliberately the same three lines of judgement: `settings.value` is TEXT, so
|
||||
* the row arrives as a **string inside** the already-decoded settings object,
|
||||
* and a malformed or wrong-shaped one must read as **absent** — the surface
|
||||
* falls back to the coded default — never as an error and never as a
|
||||
* half-applied object.
|
||||
*/
|
||||
private val settingsJson = Json { ignoreUnknownKeys = true }
|
||||
|
||||
/**
|
||||
* @param raw the raw stored value, as it arrived in the settings payload
|
||||
* @return the parsed object, or null when absent/malformed
|
||||
*/
|
||||
fun parseJsonSetting(raw: String?): JsonObject? {
|
||||
if (raw.isNullOrEmpty()) return null
|
||||
val parsed = try {
|
||||
settingsJson.parseToJsonElement(raw)
|
||||
} catch (_: SerializationException) {
|
||||
return null
|
||||
}
|
||||
// Only plain objects. A stored `null`, `4`, `"x"` or array is as unusable to
|
||||
// every consumer of these keys as a syntax error is.
|
||||
return parsed as? JsonObject
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.data.appearance
|
||||
|
||||
import com.runicgateway.app.data.api.dto.BrandDto
|
||||
import com.runicgateway.app.data.api.dto.SettingsDto
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
|
||||
/**
|
||||
* Everything the app renders itself with that the shard's admin controls
|
||||
* (THEMING_AND_NAV.md, M12): the brand block, the resolved theme tokens, and the
|
||||
* public navigation overrides. One value, held once in [com.runicgateway.app.ui.AppViewModel],
|
||||
* so the theme and the drawer can never disagree about which shard they are showing.
|
||||
*
|
||||
* **[NONE] is the shipped app.** An instance with no settings rows, a backend
|
||||
* that predates the feature, and a settings call that failed outright are all the
|
||||
* same state here, and all three must render exactly as the app did before this
|
||||
* milestone existed (§2). That is why nothing on this class is nullable except
|
||||
* [brand], which was already nullable and whose absence already meant "use the
|
||||
* bundled strings".
|
||||
*/
|
||||
data class SiteAppearance(
|
||||
/** The per-shard branding block; null when settings couldn't be loaded. */
|
||||
val brand: BrandDto? = null,
|
||||
/**
|
||||
* The resolved CSS custom properties, keyed by token (`"--accent"` → `"#7f99bd"`).
|
||||
* Empty means "the shipped defaults" — the server never emits an empty map,
|
||||
* but absent and empty are the same thing to the app and it must not depend
|
||||
* on that.
|
||||
*/
|
||||
val theme: Map<String, String> = emptyMap(),
|
||||
/**
|
||||
* The parsed `nav_public` row, or null when the admin never edited the nav.
|
||||
* Kept as the raw object here; reading `items` / `sections` / `links` out of
|
||||
* it is the job of the phases that render them.
|
||||
*/
|
||||
val navPublic: JsonObject? = null,
|
||||
) {
|
||||
companion object {
|
||||
/** The shipped app: no brand, no overrides. Also what a failed load means. */
|
||||
val NONE = SiteAppearance()
|
||||
|
||||
/**
|
||||
* Build the appearance from a `GET /public/settings` body. Forgiving
|
||||
* field by field (§2): a bad `--accent` must not discard a good `--bg`
|
||||
* beside it, and a malformed `nav_public` must not cost the theme.
|
||||
*/
|
||||
fun from(settings: SettingsDto?): SiteAppearance {
|
||||
if (settings == null) return NONE
|
||||
return SiteAppearance(
|
||||
brand = settings.brand,
|
||||
theme = themeTokens(settings.theme as? JsonObject),
|
||||
navPublic = parseJsonSetting(settings.navPublic),
|
||||
)
|
||||
}
|
||||
|
||||
// Every themable token is a string server-side (validated on write, and
|
||||
// resolveThemeTokens only ever copies a validated value). Anything else
|
||||
// is dropped rather than coerced, so an unexpected value costs exactly
|
||||
// its own token and the rest of the palette still applies.
|
||||
private fun themeTokens(raw: JsonObject?): Map<String, String> {
|
||||
if (raw.isNullOrEmpty()) return emptyMap()
|
||||
return buildMap {
|
||||
for ((token, value) in raw) {
|
||||
val text = (value as? JsonPrimitive)?.takeIf { it.isString }?.content
|
||||
if (!text.isNullOrBlank()) put(token, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import androidx.lifecycle.viewModelScope
|
||||
import com.runicgateway.app.core.net.BaseUrlHolder
|
||||
import com.runicgateway.app.core.push.PushManager
|
||||
import com.runicgateway.app.core.result.ApiResult
|
||||
import com.runicgateway.app.data.api.dto.BrandDto
|
||||
import com.runicgateway.app.data.appearance.SiteAppearance
|
||||
import com.runicgateway.app.data.repository.ConnectionRepository
|
||||
import com.runicgateway.app.data.repository.SettingsRepository
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
@@ -20,8 +20,8 @@ import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Top-level app gate (PLAN.md §3): decides whether the first-run connect screen
|
||||
* or the main UI shows, and holds the per-shard branding the theme is seeded
|
||||
* from. Activity-scoped so the whole app observes one state.
|
||||
* or the main UI shows, and holds the per-shard [SiteAppearance] the theme and
|
||||
* the drawer are built from. Activity-scoped so the whole app observes one state.
|
||||
*/
|
||||
@HiltViewModel
|
||||
class AppViewModel @Inject constructor(
|
||||
@@ -38,8 +38,11 @@ class AppViewModel @Inject constructor(
|
||||
/** No shard site configured yet — show the connect screen. */
|
||||
data object NeedsConnection : AppState
|
||||
|
||||
/** A site is configured; [brand] is null if branding couldn't be loaded (still usable). */
|
||||
data class Ready(val brand: BrandDto?) : AppState
|
||||
/**
|
||||
* A site is configured. [appearance] is [SiteAppearance.NONE] when settings
|
||||
* couldn't be loaded — the shipped app, still fully usable (§2).
|
||||
*/
|
||||
data class Ready(val appearance: SiteAppearance) : AppState
|
||||
}
|
||||
|
||||
private val _state = MutableStateFlow<AppState>(AppState.Loading)
|
||||
@@ -48,7 +51,7 @@ class AppViewModel @Inject constructor(
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
_state.value = if (connectionRepository.restore()) {
|
||||
AppState.Ready(loadBrand())
|
||||
AppState.Ready(loadAppearance())
|
||||
} else {
|
||||
AppState.NeedsConnection
|
||||
}
|
||||
@@ -57,7 +60,30 @@ class AppViewModel @Inject constructor(
|
||||
|
||||
/** Called by the connect screen once a site has been validated + saved. */
|
||||
fun onConnected() {
|
||||
viewModelScope.launch { _state.value = AppState.Ready(loadBrand()) }
|
||||
viewModelScope.launch { _state.value = AppState.Ready(loadAppearance()) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-read the appearance while the app is already running — on resume, beside
|
||||
* the session's own re-validation (§5.5). An admin who re-skins the site from
|
||||
* a laptop and picks the phone up should see it.
|
||||
*
|
||||
* Best-effort, and silent either way: a failed refresh **keeps the last good
|
||||
* appearance** rather than dropping back to the shipped one, so a moment of
|
||||
* no connectivity does not repaint a themed shard. There is no loading state
|
||||
* and no error surface. Ignored unless a site is configured.
|
||||
*/
|
||||
fun refreshAppearance() {
|
||||
if (_state.value !is AppState.Ready) return
|
||||
viewModelScope.launch {
|
||||
val settings = (settingsRepository.getSettings() as? ApiResult.Ok)?.data ?: return@launch
|
||||
pushManager.setNtfyUrl(settings.push.ntfyUrl)
|
||||
// changeServer() may have raced us back to the connect screen while the
|
||||
// call was in flight; don't resurrect Ready on top of it.
|
||||
if (_state.value is AppState.Ready) {
|
||||
_state.value = AppState.Ready(SiteAppearance.from(settings))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Settings → Server switch: hard reset back to the connect screen (§3). */
|
||||
@@ -69,14 +95,14 @@ class AppViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
/**
|
||||
* Load public settings for branding and feed the shard's push relay URL into the
|
||||
* [PushManager] (§11) — its arrival is what lets push re-register after a restart
|
||||
* or sign-in. Returns the brand block (null if settings couldn't be loaded).
|
||||
* Load public settings for the appearance and feed the shard's push relay URL into
|
||||
* the [PushManager] (§11) — its arrival is what lets push re-register after a restart
|
||||
* or sign-in. Returns [SiteAppearance.NONE] if settings couldn't be loaded.
|
||||
*/
|
||||
private suspend fun loadBrand(): BrandDto? {
|
||||
private suspend fun loadAppearance(): SiteAppearance {
|
||||
val settings = (settingsRepository.getSettings() as? ApiResult.Ok)?.data
|
||||
pushManager.setNtfyUrl(settings?.push?.ntfyUrl)
|
||||
return settings?.brand
|
||||
return SiteAppearance.from(settings)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -15,7 +15,6 @@ import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -46,6 +45,7 @@ import com.runicgateway.app.ui.UiState
|
||||
import com.runicgateway.app.ui.components.ErrorView
|
||||
import com.runicgateway.app.ui.components.LoadingView
|
||||
import com.runicgateway.app.ui.components.PillTone
|
||||
import com.runicgateway.app.ui.components.ShardCard
|
||||
import com.runicgateway.app.ui.components.StatusPill
|
||||
|
||||
/**
|
||||
@@ -139,7 +139,7 @@ private fun PostsTab(
|
||||
}
|
||||
}
|
||||
items(state.data, key = { it.id }) { post ->
|
||||
Card(Modifier.fillMaxWidth().padding(vertical = 6.dp)) {
|
||||
ShardCard(Modifier.fillMaxWidth().padding(vertical = 6.dp)) {
|
||||
Column(Modifier.padding(12.dp)) {
|
||||
Text(post.title, style = MaterialTheme.typography.bodyLarge)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
@@ -190,7 +190,7 @@ private fun WikiTab(
|
||||
}
|
||||
}
|
||||
items(state.data, key = { it.id }) { cat ->
|
||||
Card(Modifier.fillMaxWidth().padding(vertical = 6.dp)) {
|
||||
ShardCard(Modifier.fillMaxWidth().padding(vertical = 6.dp)) {
|
||||
Column(Modifier.padding(12.dp)) {
|
||||
Text(cat.title, style = MaterialTheme.typography.bodyLarge)
|
||||
Text(
|
||||
|
||||
@@ -14,7 +14,6 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
@@ -38,6 +37,7 @@ import com.runicgateway.app.ui.UiState
|
||||
import com.runicgateway.app.ui.components.EmptyView
|
||||
import com.runicgateway.app.ui.components.ErrorView
|
||||
import com.runicgateway.app.ui.components.LoadingView
|
||||
import com.runicgateway.app.ui.components.ShardCard
|
||||
|
||||
/**
|
||||
* The support (help-page) queue (PLAN.md §1, M10): open tickets with reply/close,
|
||||
@@ -100,7 +100,7 @@ private fun SupportPageCard(
|
||||
onReply: () -> Unit,
|
||||
onClose: () -> Unit,
|
||||
) {
|
||||
Card(Modifier.fillMaxWidth().padding(vertical = 6.dp)) {
|
||||
ShardCard(Modifier.fillMaxWidth().padding(vertical = 6.dp)) {
|
||||
Column(Modifier.padding(12.dp)) {
|
||||
val who = page.sender?.name ?: page.sender?.account ?: page.pageId
|
||||
Text(
|
||||
|
||||
@@ -17,7 +17,6 @@ import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
@@ -51,6 +50,7 @@ import com.runicgateway.app.ui.auth.AccountViewModel.Section
|
||||
import com.runicgateway.app.ui.components.ErrorView
|
||||
import com.runicgateway.app.ui.components.LoadingView
|
||||
import com.runicgateway.app.ui.components.PillTone
|
||||
import com.runicgateway.app.ui.components.ShardCard
|
||||
import com.runicgateway.app.ui.components.StatusPill
|
||||
|
||||
/**
|
||||
@@ -107,7 +107,7 @@ fun AccountScreen(
|
||||
|
||||
@Composable
|
||||
private fun IdentityCard(username: String, roleLabel: String) {
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
ShardCard(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(20.dp)) {
|
||||
Text(text = username, style = MaterialTheme.typography.titleLarge)
|
||||
StatusPill(
|
||||
@@ -154,7 +154,7 @@ private fun SecuritySection(onOpenTrustedDevices: () -> Unit, onOpenRecoveryCode
|
||||
|
||||
@Composable
|
||||
private fun SectionCard(@StringRes titleRes: Int, content: @Composable () -> Unit) {
|
||||
Card(Modifier.fillMaxWidth().padding(top = 12.dp)) {
|
||||
ShardCard(Modifier.fillMaxWidth().padding(top = 12.dp)) {
|
||||
Column(Modifier.padding(16.dp)) {
|
||||
Text(stringResource(titleRes), style = MaterialTheme.typography.titleMedium)
|
||||
content()
|
||||
|
||||
@@ -14,7 +14,6 @@ import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
@@ -38,6 +37,7 @@ import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.runicgateway.app.R
|
||||
import com.runicgateway.app.ui.UiState
|
||||
import com.runicgateway.app.ui.components.ShardCard
|
||||
|
||||
/**
|
||||
* Account → Recovery Codes (TRUSTED_DEVICES_MFA.md): shows the remaining count and a
|
||||
@@ -117,7 +117,7 @@ fun RecoveryCodesShowOnceCard(codes: List<String>, onDismiss: () -> Unit) {
|
||||
val clipboard = LocalClipboardManager.current
|
||||
val joined = remember(codes) { codes.joinToString("\n") }
|
||||
|
||||
Card(Modifier.fillMaxWidth().padding(top = 16.dp)) {
|
||||
ShardCard(Modifier.fillMaxWidth().padding(top = 16.dp)) {
|
||||
Column(Modifier.padding(16.dp)) {
|
||||
Text(stringResource(R.string.recovery_codes_new_title), style = MaterialTheme.typography.titleMedium)
|
||||
Text(
|
||||
|
||||
@@ -11,7 +11,6 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
@@ -30,6 +29,7 @@ import com.runicgateway.app.data.api.dto.TrustedDeviceDto
|
||||
import com.runicgateway.app.ui.UiState
|
||||
import com.runicgateway.app.ui.components.ErrorView
|
||||
import com.runicgateway.app.ui.components.LoadingView
|
||||
import com.runicgateway.app.ui.components.ShardCard
|
||||
|
||||
/**
|
||||
* Account → Trusted Devices (TRUSTED_DEVICES_MFA.md): the devices allowed to skip
|
||||
@@ -108,7 +108,7 @@ fun TrustedDevicesScreen(
|
||||
|
||||
@Composable
|
||||
private fun TrustedDeviceRow(device: TrustedDeviceDto, busy: Boolean, onRevoke: () -> Unit) {
|
||||
Card(Modifier.fillMaxWidth().padding(top = 12.dp)) {
|
||||
ShardCard(Modifier.fillMaxWidth().padding(top = 12.dp)) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
|
||||
@@ -14,24 +14,22 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.runicgateway.app.ui.theme.ShardCardBottom
|
||||
import com.runicgateway.app.ui.theme.ShardCardTop
|
||||
import com.runicgateway.app.ui.theme.LocalShardPalette
|
||||
import com.runicgateway.app.ui.theme.LocalShardStructure
|
||||
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
|
||||
@@ -43,6 +41,14 @@ import com.runicgateway.app.ui.theme.ShardWarningBg
|
||||
* (docs/android/PLAN.md §M5): the recurring pill, section-label, feature-card,
|
||||
* and stat-bar motifs the mockup repeats across screens. Pure presentation —
|
||||
* no state, no data dependencies — so any screen can adopt them.
|
||||
*
|
||||
* This is the app's **only** file that reaches past `MaterialTheme` for a
|
||||
* themable value, so it is the one place M12 had to migrate: the surface, line
|
||||
* and accent tokens come from [LocalShardPalette] and the pill shape and card
|
||||
* depth from [LocalShardStructure], both following the shard's theme
|
||||
* (THEMING_AND_NAV.md §5.1, §5.2, §5.4). The success/warning/danger constants
|
||||
* stay imported directly — those are semantic and never themed, mirroring the
|
||||
* server's `FIXED_TOKENS`.
|
||||
*/
|
||||
|
||||
/** Semantic tone for a [StatusPill] / [OnlineDot]. */
|
||||
@@ -50,21 +56,26 @@ enum class PillTone { Success, Warning, Danger, Neutral, Info }
|
||||
|
||||
private data class PillColors(val fg: Color, val bg: Color)
|
||||
|
||||
@Composable
|
||||
private fun toneColors(tone: PillTone): PillColors = when (tone) {
|
||||
PillTone.Success -> PillColors(ShardSuccess, ShardSuccessBg)
|
||||
PillTone.Warning -> PillColors(ShardWarning, ShardWarningBg)
|
||||
PillTone.Danger -> PillColors(ShardDanger, ShardDangerBg)
|
||||
PillTone.Neutral, PillTone.Info -> PillColors(ShardPillFg, ShardPillBg)
|
||||
PillTone.Neutral, PillTone.Info ->
|
||||
LocalShardPalette.current.let { PillColors(it.pillFg, it.pillBg) }
|
||||
}
|
||||
|
||||
/**
|
||||
* A small uppercase status chip — "Live", "Up", "Enabled", "IDOC", a role — with a
|
||||
* rounded filled background tinted by [tone]. Mirrors the mockup's pill badges.
|
||||
*
|
||||
* The one place `--radius-pill` lands: the app's other two [CircleShape] uses are
|
||||
* 8dp status dots, and a dot stays a dot however square the shard makes its site.
|
||||
*/
|
||||
@Composable
|
||||
fun StatusPill(text: String, tone: PillTone, modifier: Modifier = Modifier) {
|
||||
val c = toneColors(tone)
|
||||
Surface(color = c.bg, shape = CircleShape, modifier = modifier) {
|
||||
Surface(color = c.bg, shape = LocalShardStructure.current.pill, modifier = modifier) {
|
||||
Text(
|
||||
text = text.uppercase(),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
@@ -81,7 +92,7 @@ fun OnlineDot(tone: PillTone, modifier: Modifier = Modifier) {
|
||||
PillTone.Success -> ShardSuccessDot
|
||||
PillTone.Warning -> ShardWarning
|
||||
PillTone.Danger -> ShardDanger
|
||||
PillTone.Neutral, PillTone.Info -> ShardFaint
|
||||
PillTone.Neutral, PillTone.Info -> LocalShardPalette.current.faint
|
||||
}
|
||||
Box(modifier.size(8.dp).clip(CircleShape).background(color))
|
||||
}
|
||||
@@ -95,7 +106,7 @@ fun SectionLabel(text: String, modifier: Modifier = Modifier) {
|
||||
Text(
|
||||
text = text.uppercase(),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = ShardFaint,
|
||||
color = LocalShardPalette.current.faint,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
@@ -104,6 +115,11 @@ fun SectionLabel(text: String, modifier: Modifier = Modifier) {
|
||||
* The elevated "feature" card: a vertical blue gradient with a hairline outline and
|
||||
* soft shadow, used for the home status card, the shard-online banner, and the
|
||||
* vendor card. [content] is laid out in a padded [Column].
|
||||
*
|
||||
* The radius is `MaterialTheme.shapes.medium` rather than the literal 12dp it was
|
||||
* built with — the same value, now following `--radius-card`'s ratio (§5.2). The
|
||||
* shadow this doc always claimed is finally drawn, at the depth `--shadow-card`
|
||||
* resolves to (§5.4).
|
||||
*/
|
||||
@Composable
|
||||
fun FeatureCard(
|
||||
@@ -111,17 +127,40 @@ fun FeatureCard(
|
||||
contentPadding: Int = 18,
|
||||
content: @Composable ColumnScope.() -> Unit,
|
||||
) {
|
||||
val palette = LocalShardPalette.current
|
||||
val shape = MaterialTheme.shapes.medium
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(Brush.verticalGradient(listOf(ShardCardTop, ShardCardBottom)))
|
||||
.border(1.dp, ShardOutline, RoundedCornerShape(12.dp)),
|
||||
.shadow(LocalShardStructure.current.cardElevation, shape)
|
||||
.clip(shape)
|
||||
.background(Brush.verticalGradient(listOf(palette.cardTop, palette.cardBottom)))
|
||||
.border(1.dp, palette.outline, shape),
|
||||
) {
|
||||
Column(Modifier.padding(contentPadding.dp), content = content)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A Material [Card] at the shard's resolved depth — the app's standard card, and
|
||||
* the reason every screen's `Card(` became a `ShardCard(`.
|
||||
*
|
||||
* `Card` takes its elevation as a **default argument**, not from the theme, so
|
||||
* unlike the color scheme and the shape scale there is no way to make
|
||||
* `--shadow-card` reach ~24 call sites without a wrapper. Passing
|
||||
* [CardDefaults.cardElevation] at each site instead would have put the same line
|
||||
* in eighteen files and let one drift. A `Card(` outside this file is therefore a
|
||||
* card the shard cannot theme, which makes the invariant greppable.
|
||||
*/
|
||||
@Composable
|
||||
fun ShardCard(modifier: Modifier = Modifier, content: @Composable ColumnScope.() -> Unit) {
|
||||
Card(
|
||||
modifier = modifier,
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = LocalShardStructure.current.cardElevation),
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A slim rounded meter (vitals / skills). [fraction] is clamped to 0..1; the fill is
|
||||
* the slate accent over a bordered dark track.
|
||||
@@ -129,13 +168,14 @@ fun FeatureCard(
|
||||
@Composable
|
||||
fun StatBar(fraction: Float, modifier: Modifier = Modifier) {
|
||||
val pct = fraction.coerceIn(0f, 1f)
|
||||
val palette = LocalShardPalette.current
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.height(6.dp)
|
||||
.clip(RoundedCornerShape(3.dp))
|
||||
.background(ShardElevated)
|
||||
.border(1.dp, ShardOutline, RoundedCornerShape(3.dp)),
|
||||
.background(palette.elevated)
|
||||
.border(1.dp, palette.outline, RoundedCornerShape(3.dp)),
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
|
||||
@@ -10,7 +10,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ScrollableTabRow
|
||||
import androidx.compose.material3.Tab
|
||||
@@ -29,6 +28,7 @@ import com.runicgateway.app.ui.UiState
|
||||
import com.runicgateway.app.ui.components.EmptyView
|
||||
import com.runicgateway.app.ui.components.ErrorView
|
||||
import com.runicgateway.app.ui.components.LoadingView
|
||||
import com.runicgateway.app.ui.components.ShardCard
|
||||
|
||||
/** News hub with category tabs and a post list (PLAN.md §6.1). */
|
||||
@Composable
|
||||
@@ -82,7 +82,7 @@ private fun PostList(
|
||||
|
||||
@Composable
|
||||
private fun PostRow(post: PostDto, onClick: () -> Unit) {
|
||||
Card(
|
||||
ShardCard(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 6.dp)
|
||||
|
||||
@@ -13,7 +13,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
@@ -37,6 +36,7 @@ import com.runicgateway.app.ui.UiState
|
||||
import com.runicgateway.app.ui.components.ErrorView
|
||||
import com.runicgateway.app.ui.components.LoadingView
|
||||
import com.runicgateway.app.ui.components.SectionLabel
|
||||
import com.runicgateway.app.ui.components.ShardCard
|
||||
import com.runicgateway.app.ui.components.StatBar
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
|
||||
@@ -289,7 +289,7 @@ private fun EquipmentBlock(equipment: List<EquipmentDto>) {
|
||||
|
||||
@Composable
|
||||
private fun SheetCard(titleRes: Int, content: @Composable () -> Unit) {
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
ShardCard(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(16.dp)) {
|
||||
Text(stringResource(titleRes), style = MaterialTheme.typography.titleMedium)
|
||||
content()
|
||||
|
||||
@@ -13,7 +13,6 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
@@ -39,6 +38,7 @@ import com.runicgateway.app.ui.UiState
|
||||
import com.runicgateway.app.ui.components.ErrorView
|
||||
import com.runicgateway.app.ui.components.LoadingView
|
||||
import com.runicgateway.app.ui.components.PillTone
|
||||
import com.runicgateway.app.ui.components.ShardCard
|
||||
import com.runicgateway.app.ui.components.StatusPill
|
||||
|
||||
/**
|
||||
@@ -90,7 +90,7 @@ fun CharactersScreen(
|
||||
@Composable
|
||||
private fun LinkCard(state: CharactersViewModel.State, viewModel: CharactersViewModel) {
|
||||
var code by rememberSaveable { mutableStateOf("") }
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
ShardCard(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(16.dp)) {
|
||||
Text(stringResource(R.string.player_link_title), style = MaterialTheme.typography.titleMedium)
|
||||
Text(
|
||||
@@ -121,7 +121,7 @@ private fun LinkCard(state: CharactersViewModel.State, viewModel: CharactersView
|
||||
private fun CreateAccountCard(state: CharactersViewModel.State, viewModel: CharactersViewModel) {
|
||||
var account by rememberSaveable { mutableStateOf("") }
|
||||
var password by rememberSaveable { mutableStateOf("") }
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
ShardCard(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(16.dp)) {
|
||||
Text(stringResource(R.string.player_create_title), style = MaterialTheme.typography.titleMedium)
|
||||
OutlinedTextField(
|
||||
@@ -204,7 +204,7 @@ private fun RosterError(kind: ErrorKind, onRetry: () -> Unit) {
|
||||
|
||||
@Composable
|
||||
private fun CharRow(char: RosterCharDto, onOpenChar: (String) -> Unit) {
|
||||
Card(
|
||||
ShardCard(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp)
|
||||
|
||||
@@ -11,7 +11,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -28,6 +27,7 @@ import com.runicgateway.app.ui.UiState
|
||||
import com.runicgateway.app.ui.components.EmptyView
|
||||
import com.runicgateway.app.ui.components.ErrorView
|
||||
import com.runicgateway.app.ui.components.LoadingView
|
||||
import com.runicgateway.app.ui.components.ShardCard
|
||||
|
||||
/**
|
||||
* The player's own houses with home/decay status (PLAN.md §6.3), text-only. An
|
||||
@@ -60,7 +60,7 @@ fun MyHousesScreen(
|
||||
|
||||
@Composable
|
||||
private fun HouseCard(house: PlayerHouseDto) {
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
ShardCard(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(16.dp)) {
|
||||
Row(Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
|
||||
@@ -11,7 +11,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
@@ -31,6 +30,7 @@ import com.runicgateway.app.ui.ErrorKind
|
||||
import com.runicgateway.app.ui.UiState
|
||||
import com.runicgateway.app.ui.components.ErrorView
|
||||
import com.runicgateway.app.ui.components.LoadingView
|
||||
import com.runicgateway.app.ui.components.ShardCard
|
||||
import java.text.DateFormat
|
||||
import java.util.Date
|
||||
|
||||
@@ -79,7 +79,7 @@ fun VendorsScreen(
|
||||
|
||||
@Composable
|
||||
private fun SalesCard(sales: UiState<List<VendorSaleDto>>) {
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
ShardCard(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(16.dp)) {
|
||||
Text(stringResource(R.string.player_sales_title), style = MaterialTheme.typography.titleMedium)
|
||||
when (sales) {
|
||||
@@ -185,7 +185,7 @@ private fun VendorError(kind: ErrorKind, onRetry: () -> Unit) {
|
||||
|
||||
@Composable
|
||||
private fun VendorCard(vendor: VendorDto) {
|
||||
Card(Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
|
||||
ShardCard(Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
|
||||
Column(Modifier.padding(16.dp)) {
|
||||
Text(
|
||||
vendor.shopName ?: stringResource(R.string.player_vendor_fallback),
|
||||
|
||||
@@ -16,7 +16,6 @@ import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
@@ -41,6 +40,7 @@ import com.runicgateway.app.ui.components.ErrorView
|
||||
import com.runicgateway.app.ui.components.LoadingView
|
||||
import com.runicgateway.app.ui.components.PillTone
|
||||
import com.runicgateway.app.ui.components.SectionLabel
|
||||
import com.runicgateway.app.ui.components.ShardCard
|
||||
import com.runicgateway.app.ui.components.StatusPill
|
||||
|
||||
/**
|
||||
@@ -95,7 +95,7 @@ fun AtlasScreen(
|
||||
@Composable
|
||||
private fun CreatureCard(creature: AtlasCreatureDto, onOpenCreature: (String) -> Unit) {
|
||||
val slug = creature.slug
|
||||
Card(
|
||||
ShardCard(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.then(if (slug != null) Modifier.clickable { onOpenCreature(slug) } else Modifier),
|
||||
|
||||
@@ -7,7 +7,6 @@ import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -21,6 +20,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.runicgateway.app.R
|
||||
import com.runicgateway.app.data.api.dto.ChampDto
|
||||
import com.runicgateway.app.ui.components.PillTone
|
||||
import com.runicgateway.app.ui.components.ShardCard
|
||||
import com.runicgateway.app.ui.components.StatusPill
|
||||
|
||||
/** The champion-spawn board (PLAN.md §6.2), live via SSE deltas. */
|
||||
@@ -44,7 +44,7 @@ fun ChampsScreen(
|
||||
|
||||
@Composable
|
||||
private fun ChampCard(champ: ChampDto) {
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
ShardCard(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(16.dp)) {
|
||||
Row(Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
|
||||
@@ -12,7 +12,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
@@ -33,6 +32,7 @@ import com.runicgateway.app.ui.UiState
|
||||
import com.runicgateway.app.ui.components.EmptyView
|
||||
import com.runicgateway.app.ui.components.ErrorView
|
||||
import com.runicgateway.app.ui.components.LoadingView
|
||||
import com.runicgateway.app.ui.components.ShardCard
|
||||
|
||||
/** The town-governor board (PLAN.md §6.2), live via `city.update`, with per-city history. */
|
||||
@Composable
|
||||
@@ -84,7 +84,7 @@ private fun CityCard(
|
||||
onExpand: () -> Unit,
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
ShardCard(Modifier.fillMaxWidth()) {
|
||||
Column {
|
||||
Column(
|
||||
Modifier
|
||||
|
||||
@@ -7,7 +7,6 @@ import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -20,6 +19,7 @@ import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.runicgateway.app.R
|
||||
import com.runicgateway.app.data.api.dto.GuildDto
|
||||
import com.runicgateway.app.ui.components.ShardCard
|
||||
|
||||
/** The guild board (PLAN.md §6.2), live via SSE deltas. */
|
||||
@Composable
|
||||
@@ -42,7 +42,7 @@ fun GuildsScreen(
|
||||
|
||||
@Composable
|
||||
private fun GuildCard(guild: GuildDto) {
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
ShardCard(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(16.dp)) {
|
||||
Row(Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
|
||||
@@ -7,7 +7,6 @@ import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -21,6 +20,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.runicgateway.app.R
|
||||
import com.runicgateway.app.data.api.dto.HouseDto
|
||||
import com.runicgateway.app.ui.components.PillTone
|
||||
import com.runicgateway.app.ui.components.ShardCard
|
||||
import com.runicgateway.app.ui.components.StatusPill
|
||||
|
||||
/** The public "falling houses" (IDOC) board (PLAN.md §6.2), live via `house.decay`. */
|
||||
@@ -44,7 +44,7 @@ fun HousesScreen(
|
||||
|
||||
@Composable
|
||||
private fun HouseCard(house: HouseDto) {
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
ShardCard(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(16.dp)) {
|
||||
Row(Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
|
||||
@@ -8,7 +8,6 @@ import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
@@ -26,6 +25,7 @@ import com.runicgateway.app.data.api.dto.BrandDto
|
||||
import com.runicgateway.app.data.api.dto.PointsBoardDto
|
||||
import com.runicgateway.app.data.api.dto.PointsEntryDto
|
||||
import com.runicgateway.app.ui.components.SectionLabel
|
||||
import com.runicgateway.app.ui.components.ShardCard
|
||||
|
||||
/**
|
||||
* The points/loyalty leaderboards (PLAN.md §9 M11), one card per system, live via
|
||||
@@ -52,7 +52,7 @@ fun LeaderboardsScreen(
|
||||
|
||||
@Composable
|
||||
private fun BoardCard(board: PointsBoardDto, placeholderName: String) {
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
ShardCard(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(
|
||||
|
||||
@@ -14,7 +14,6 @@ import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
@@ -37,6 +36,7 @@ import com.runicgateway.app.ui.components.EmptyView
|
||||
import com.runicgateway.app.ui.components.ErrorView
|
||||
import com.runicgateway.app.ui.components.LoadingView
|
||||
import com.runicgateway.app.ui.components.SectionLabel
|
||||
import com.runicgateway.app.ui.components.ShardCard
|
||||
|
||||
/**
|
||||
* The shard-wide marketplace (PLAN.md §9 M11): search every player vendor's stock.
|
||||
@@ -99,7 +99,7 @@ fun MarketScreen(
|
||||
@Composable
|
||||
private fun ListingCard(listing: MarketListingDto, onOpenVendor: (String) -> Unit) {
|
||||
val vendorSerial = listing.vendor?.serial
|
||||
Card(
|
||||
ShardCard(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.then(if (vendorSerial != null) Modifier.clickable { onOpenVendor(vendorSerial) } else Modifier),
|
||||
|
||||
@@ -12,7 +12,6 @@ import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -30,6 +29,7 @@ import com.runicgateway.app.ui.components.EmptyView
|
||||
import com.runicgateway.app.ui.components.ErrorView
|
||||
import com.runicgateway.app.ui.components.LoadingView
|
||||
import com.runicgateway.app.ui.components.PillTone
|
||||
import com.runicgateway.app.ui.components.ShardCard
|
||||
import com.runicgateway.app.ui.components.StatusPill
|
||||
|
||||
/**
|
||||
@@ -167,7 +167,7 @@ private fun CapsCard(caps: RulesetCapsDto) {
|
||||
|
||||
@Composable
|
||||
private fun RuleCard(title: String, content: @Composable () -> Unit) {
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
ShardCard(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(16.dp)) {
|
||||
Text(title, style = MaterialTheme.typography.titleMedium)
|
||||
content()
|
||||
|
||||
@@ -12,7 +12,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
@@ -37,6 +36,7 @@ import com.runicgateway.app.data.repository.ShardFeatures
|
||||
import com.runicgateway.app.data.repository.canSee
|
||||
import com.runicgateway.app.ui.components.PillTone
|
||||
import com.runicgateway.app.ui.components.SectionLabel
|
||||
import com.runicgateway.app.ui.components.ShardCard
|
||||
import com.runicgateway.app.ui.components.StatusPill
|
||||
|
||||
/**
|
||||
@@ -193,7 +193,7 @@ private fun BoardsCard(features: ShardFeatures?, onOpenBoard: (ShardBoard) -> Un
|
||||
}
|
||||
}
|
||||
if (boards.isEmpty()) return
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
ShardCard(Modifier.fillMaxWidth()) {
|
||||
Column {
|
||||
boards.forEachIndexed { index, (board, labelRes) ->
|
||||
Text(
|
||||
|
||||
@@ -10,6 +10,11 @@ import androidx.compose.ui.graphics.Color
|
||||
* without the leading `#`) into a Compose [Color]. Returns null for anything
|
||||
* unparseable so the theme falls back to its default scheme (PLAN.md §3, §5).
|
||||
* Pure logic — covered by JVM unit tests.
|
||||
*
|
||||
* Also the parser for every color token in the shard's resolved theme map
|
||||
* ([ShardPalette.resolve], M12): the server validates those as `#RGB` or
|
||||
* `#RRGGBB` on write, and a null here is what makes a token that slipped
|
||||
* through anyway cost only itself.
|
||||
*/
|
||||
fun parseBrandColor(hex: String?): Color? {
|
||||
if (hex.isNullOrBlank()) return null
|
||||
|
||||
126
app/src/main/java/com/runicgateway/app/ui/theme/ShardPalette.kt
Normal file
126
app/src/main/java/com/runicgateway/app/ui/theme/ShardPalette.kt
Normal file
@@ -0,0 +1,126 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.theme
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
/**
|
||||
* The shard's resolved color palette — the fifteen themable tokens of
|
||||
* `GET /public/settings`' `theme` map, parsed into Compose colors
|
||||
* (THEMING_AND_NAV.md §5.1).
|
||||
*
|
||||
* **The default value of every field is the shipped constant from
|
||||
* [ui/theme/Color.kt], and that is not an approximation.** The app's M5 palette
|
||||
* *is* the website's `runic-gateway` preset, value for value, because both were
|
||||
* drawn from the same `theme.css`. So [Shipped] renders exactly as the app did
|
||||
* before this milestone, and an instance with no `theme_visual` row resolves
|
||||
* back to it token by token (§2, AC-1).
|
||||
*
|
||||
* The palette has two consumers and one resolution: ten of the fifteen tokens
|
||||
* have a Material role and are fed into the [androidx.compose.material3.ColorScheme]
|
||||
* by [shardColorScheme]; the other five have none, and reach the screens that
|
||||
* need them through [LocalShardPalette].
|
||||
*/
|
||||
@Immutable
|
||||
data class ShardPalette(
|
||||
/** `--bg-deep` — the page behind everything. */
|
||||
val page: Color = ShardPage,
|
||||
/** `--bg` — the screen background. */
|
||||
val surface: Color = ShardSurface,
|
||||
/** `--panel-flat` — top bar, inputs, drawer, list tracks. */
|
||||
val elevated: Color = ShardElevated,
|
||||
/** `--panel-a` — feature-card gradient, top. No Material role. */
|
||||
val cardTop: Color = ShardCardTop,
|
||||
/** `--panel-b` — feature-card gradient, bottom. No Material role. */
|
||||
val cardBottom: Color = ShardCardBottom,
|
||||
/** `--line` — borders and input outlines. */
|
||||
val outline: Color = ShardOutline,
|
||||
/** `--line-soft` — hairline row dividers. */
|
||||
val divider: Color = ShardDivider,
|
||||
/** `--ink` — the brightest headings. No Material role. */
|
||||
val heading: Color = ShardHeading,
|
||||
/** `--head` — heading on a surface. No Material role. */
|
||||
val headingDim: Color = ShardHeadingDim,
|
||||
/** `--text` — body copy. */
|
||||
val body: Color = ShardBody,
|
||||
/** `--muted` — secondary text. */
|
||||
val muted: Color = ShardMuted,
|
||||
/** `--dim` — meta and faint labels. No Material role. */
|
||||
val faint: Color = ShardFaint,
|
||||
/** `--accent` — links and secondary highlights. */
|
||||
val accent: Color = ShardAccent,
|
||||
/** `--accent-bright` — the filled CTA surface. */
|
||||
val cta: Color = ShardCta,
|
||||
/** `--blue` — the neutral/info pill background. */
|
||||
val pillBg: Color = ShardPillBg,
|
||||
) {
|
||||
/**
|
||||
* Text drawn on the [cta] fill. **Derived, never themed** — it tracks
|
||||
* `--bg-deep`, exactly as the server refuses to freeze `--panel-grad` as a
|
||||
* literal (§5.1). A value expressed in terms of another token must follow
|
||||
* it, or a future light preset inherits a dark one and looks broken.
|
||||
*/
|
||||
val onCta: Color get() = page
|
||||
|
||||
/**
|
||||
* The neutral/info pill's foreground. Also derived: `ShardPillFg` and
|
||||
* `ShardCta` are the same `--accent-bright` value, so the pill's text
|
||||
* follows the CTA fill rather than being a sixteenth token the contract
|
||||
* does not have.
|
||||
*/
|
||||
val pillFg: Color get() = cta
|
||||
|
||||
companion object {
|
||||
/** The shipped app: the M5 palette, i.e. the `runic-gateway` preset. */
|
||||
val Shipped = ShardPalette()
|
||||
|
||||
/**
|
||||
* Resolve a `theme` token map into a palette, **field by field** (§2).
|
||||
* A token that is missing, blank or unparseable falls back to its
|
||||
* shipped value on its own; a bad `--accent` must never discard a good
|
||||
* `--bg` beside it (AC-2).
|
||||
*
|
||||
* [brandAccent] is the pre-feature branding path and must keep working:
|
||||
* an instance with a `BRAND_ACCENT_COLOR` but no `theme_visual` row
|
||||
* still tints its links and highlights. It seeds `--accent` only — the
|
||||
* server resolves `brand.accent` as `theme['--accent'] || env`, so the
|
||||
* token always wins where both exist.
|
||||
*/
|
||||
fun resolve(theme: Map<String, String>, brandAccent: Color? = null): ShardPalette {
|
||||
if (theme.isEmpty() && brandAccent == null) return Shipped
|
||||
fun token(name: String, shipped: Color): Color =
|
||||
parseBrandColor(theme[name]) ?: shipped
|
||||
return ShardPalette(
|
||||
page = token("--bg-deep", ShardPage),
|
||||
surface = token("--bg", ShardSurface),
|
||||
elevated = token("--panel-flat", ShardElevated),
|
||||
cardTop = token("--panel-a", ShardCardTop),
|
||||
cardBottom = token("--panel-b", ShardCardBottom),
|
||||
outline = token("--line", ShardOutline),
|
||||
divider = token("--line-soft", ShardDivider),
|
||||
heading = token("--ink", ShardHeading),
|
||||
headingDim = token("--head", ShardHeadingDim),
|
||||
body = token("--text", ShardBody),
|
||||
muted = token("--muted", ShardMuted),
|
||||
faint = token("--dim", ShardFaint),
|
||||
accent = token("--accent", brandAccent ?: ShardAccent),
|
||||
cta = token("--accent-bright", ShardCta),
|
||||
pillBg = token("--blue", ShardPillBg),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The live palette, for the five tokens with no Material role and for the
|
||||
* components that draw the card gradient. Everything that *can* go through
|
||||
* `MaterialTheme.colorScheme` still should — this is the escape hatch, not the
|
||||
* front door.
|
||||
*
|
||||
* Defaulted to [ShardPalette.Shipped] so previews and any composable outside
|
||||
* [RunicGatewayTheme] still draw the shipped palette rather than crashing.
|
||||
*/
|
||||
val LocalShardPalette = staticCompositionLocalOf { ShardPalette.Shipped }
|
||||
@@ -0,0 +1,179 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.theme
|
||||
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Shapes
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* The shard's resolved corner radii and card depth — the `structure` half of the
|
||||
* admin's Appearance page (THEMING_AND_NAV.md §5.2, §5.4), the counterpart to
|
||||
* [ShardPalette].
|
||||
*
|
||||
* **Radii are applied as a ratio, never as a literal.** The app's [Shapes] came
|
||||
* from the M5 mockup and the website's from `theme.css`; the two scales genuinely
|
||||
* differ (`--radius-card` 10px against `medium` 12dp). Copying the web value in
|
||||
* would restyle an untouched app the day this milestone shipped, so each field is
|
||||
* scaled by `resolved ÷ runic-gateway baseline` instead. A shard on the shipped
|
||||
* theme, or one that explicitly picks `runic-gateway`, gives ratio 1.0 on every
|
||||
* field and is a provable no-op (§2, AC-1).
|
||||
*
|
||||
* Card depth is the one thing here that is **not** a no-op — see [ShippedCardElevation].
|
||||
*/
|
||||
@Immutable
|
||||
data class ShardStructure(
|
||||
/** The Material shape scale, ratio-scaled off the app's own shipped dp values. */
|
||||
val shapes: Shapes = ShippedShapes,
|
||||
/**
|
||||
* `--radius-pill`. Not part of [shapes]: the app draws its chips with
|
||||
* [CircleShape], which is a percentage and so has no dp for a ratio to scale.
|
||||
* Resolved as a literal instead — the only rule available — see [pillShape].
|
||||
*/
|
||||
val pill: Shape = CircleShape,
|
||||
/** `--shadow-card`, mapped onto Material elevation (§5.4). */
|
||||
val cardElevation: Dp = ShippedCardElevation,
|
||||
) {
|
||||
companion object {
|
||||
/** The shipped app: the M5 shape scale and the `runic-gateway` card depth. */
|
||||
val Shipped = ShardStructure()
|
||||
|
||||
/**
|
||||
* Resolve a `theme` token map into a structure, **field by field** (§2):
|
||||
* a `--radius-panel` the server never validated must not cost the
|
||||
* `--radius-card` beside it, exactly as in [ShardPalette.resolve].
|
||||
*/
|
||||
fun resolve(theme: Map<String, String>): ShardStructure {
|
||||
if (theme.isEmpty()) return Shipped
|
||||
val input = ratio(theme["--radius-input"], BaseInputPx)
|
||||
val card = ratio(theme["--radius-card"], BaseCardPx)
|
||||
val panel = ratio(theme["--radius-panel"], BasePanelPx)
|
||||
return ShardStructure(
|
||||
shapes = Shapes(
|
||||
extraSmall = corner(ShippedExtraSmallDp, input),
|
||||
small = corner(ShippedSmallDp, input),
|
||||
medium = corner(ShippedMediumDp, card),
|
||||
// extraLarge has no web counterpart and follows the panel
|
||||
// ratio, since it is the panel family.
|
||||
large = corner(ShippedLargeDp, panel),
|
||||
extraLarge = corner(ShippedExtraLargeDp, panel),
|
||||
),
|
||||
pill = pillShape(theme["--radius-pill"]),
|
||||
cardElevation = elevation(theme["--shadow-card"]),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The live structure, for the two things Material's theme cannot carry: the pill
|
||||
* shape, and a card elevation ([androidx.compose.material3.Card] takes its
|
||||
* elevation as a default argument, not from a composition local). The shape
|
||||
* scale itself reaches screens through `MaterialTheme.shapes` and needs nothing
|
||||
* here.
|
||||
*/
|
||||
val LocalShardStructure = staticCompositionLocalOf { ShardStructure.Shipped }
|
||||
|
||||
// ── the shipped scale ──────────────────────────────────────────────────────
|
||||
//
|
||||
// The app's own dp values, which the ratios scale. Kept here rather than in
|
||||
// Theme.kt so the resolution and the thing it resolves back to sit together.
|
||||
|
||||
private const val ShippedExtraSmallDp = 8
|
||||
private const val ShippedSmallDp = 8
|
||||
private const val ShippedMediumDp = 12
|
||||
private const val ShippedLargeDp = 16
|
||||
private const val ShippedExtraLargeDp = 24
|
||||
|
||||
/** 8dp inputs/chips, 12dp cards, 16dp large surfaces — matching the mockup radii. */
|
||||
internal val ShippedShapes = Shapes(
|
||||
extraSmall = RoundedCornerShape(ShippedExtraSmallDp.dp),
|
||||
small = RoundedCornerShape(ShippedSmallDp.dp),
|
||||
medium = RoundedCornerShape(ShippedMediumDp.dp),
|
||||
large = RoundedCornerShape(ShippedLargeDp.dp),
|
||||
extraLarge = RoundedCornerShape(ShippedExtraLargeDp.dp),
|
||||
)
|
||||
|
||||
/**
|
||||
* The depth an unthemed instance draws its cards at.
|
||||
*
|
||||
* **This is the one field of this milestone that is deliberately not a no-op.**
|
||||
* The app has been flat since M5 — Material's filled `Card` is `Level0` and
|
||||
* `FeatureCard` never had the shadow its own docs claimed — while the
|
||||
* `runic-gateway` preset's `--shadow-card` is the "Default" option. §5.4 is
|
||||
* applied as written rather than rebased on the app's flat baseline, so every
|
||||
* card gains this depth and the admin's four-step control reads the same on the
|
||||
* phone as on the web. Approved by the org lead as an amendment to §2.
|
||||
*/
|
||||
private val ShippedCardElevation = 4.dp
|
||||
|
||||
// ── the runic-gateway baselines ───────────────────────────────────────────
|
||||
//
|
||||
// The preset the app's own scale corresponds to (server/src/config/themePresets.js).
|
||||
// A resolved value is meaningful only against these: the ratio, not the number,
|
||||
// is what crosses from the web scale to the app's.
|
||||
|
||||
private const val BaseInputPx = 8f
|
||||
private const val BaseCardPx = 10f
|
||||
private const val BasePanelPx = 12f
|
||||
private const val BasePillPx = 999f
|
||||
|
||||
/**
|
||||
* Below half the pill baseline the chip stops reading as a pill and becomes a
|
||||
* rounded rectangle, so an admin who squares the site off squares off the app's
|
||||
* chips too. Fantasy's 4px and Modern's 8px both land here; `runic-gateway`'s
|
||||
* 999px does not.
|
||||
*/
|
||||
private const val PillCircleFloorPx = BasePillPx / 2f
|
||||
|
||||
// A radius as the server writes it: an integer count of px, 0..999, always with
|
||||
// the unit (`isRadius` in utils/themeResolve.js). Anything else is not a value
|
||||
// this app can scale, and falls back to the shipped dp on its own.
|
||||
private val RadiusPx = Regex("""^\s*(\d{1,3})px\s*$""")
|
||||
|
||||
// The blur of a CSS box-shadow: `0 14px 34px rgba(...)`. The x offset carries no
|
||||
// unit, so the blur is the second px length.
|
||||
private val ShadowLengthPx = Regex("""(\d+(?:\.\d+)?)px""")
|
||||
|
||||
/**
|
||||
* `--shadow-card` mapped to elevation, by **nearest blur** rather than by exact
|
||||
* string. §5.4 specified a string match against the server's `SHADOW_OPTIONS`,
|
||||
* but the Fantasy preset publishes `0 16px 38px rgba(0, 0, 0, 0.45)` — a value
|
||||
* `SHADOW_OPTIONS` does not contain, because a preset's own tokens never pass
|
||||
* through that dropdown. An exact match would have missed the one preset whose
|
||||
* point is a heavier shadow. Matching the blur puts any future preset on the
|
||||
* nearest step instead of silently on the default.
|
||||
*/
|
||||
private val ShadowSteps = listOf(20f to 2.dp, 34f to 4.dp, 44f to 8.dp)
|
||||
|
||||
private fun parseRadiusPx(raw: String?): Float? =
|
||||
raw?.let { RadiusPx.find(it) }?.groupValues?.get(1)?.toFloatOrNull()
|
||||
|
||||
private fun ratio(raw: String?, baselinePx: Float): Float =
|
||||
parseRadiusPx(raw)?.let { it / baselinePx } ?: 1f
|
||||
|
||||
/** Scale one shipped dp by its ratio, rounded to whole dp and clamped at 0. */
|
||||
private fun corner(shippedDp: Int, ratio: Float) =
|
||||
RoundedCornerShape((shippedDp * ratio).roundToInt().coerceAtLeast(0).dp)
|
||||
|
||||
private fun pillShape(raw: String?): Shape {
|
||||
val px = parseRadiusPx(raw) ?: return CircleShape
|
||||
return if (px >= PillCircleFloorPx) CircleShape else RoundedCornerShape(px.roundToInt().dp)
|
||||
}
|
||||
|
||||
private fun elevation(raw: String?): Dp {
|
||||
val value = raw?.trim() ?: return ShippedCardElevation
|
||||
if (value.equals("none", ignoreCase = true)) return 0.dp
|
||||
val blur = ShadowLengthPx.findAll(value).drop(1).firstOrNull()
|
||||
?.groupValues?.get(1)?.toFloatOrNull()
|
||||
?: return ShippedCardElevation
|
||||
return ShadowSteps.minByOrNull { abs(it.first - blur) }?.second ?: ShippedCardElevation
|
||||
}
|
||||
@@ -3,78 +3,88 @@
|
||||
*/
|
||||
package com.runicgateway.app.ui.theme
|
||||
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.ColorScheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Shapes
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.remember
|
||||
import com.runicgateway.app.data.appearance.SiteAppearance
|
||||
|
||||
/**
|
||||
* The shard-website color scheme (M5 design pass). The app is **dark-only** — the
|
||||
* 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.
|
||||
* Maps a resolved [ShardPalette] onto the Material roles (THEMING_AND_NAV.md
|
||||
* §5.1). 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; every v1 preset on the website is dark too.
|
||||
*
|
||||
* Ten of the palette's fifteen tokens land here, which is why the ~20
|
||||
* token-based screens take on a shard's theme with no per-screen color work.
|
||||
* Pure, so the no-op proof (AC-1) can assert on it directly.
|
||||
*/
|
||||
private val ShardColorScheme = darkColorScheme(
|
||||
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,
|
||||
internal fun shardColorScheme(palette: ShardPalette): ColorScheme = darkColorScheme(
|
||||
primary = palette.cta, // filled CTA buttons
|
||||
onPrimary = palette.onCta,
|
||||
secondary = palette.accent, // links / secondary highlights
|
||||
onSecondary = palette.onCta,
|
||||
tertiary = palette.accent,
|
||||
onTertiary = palette.onCta,
|
||||
background = palette.page,
|
||||
onBackground = palette.body,
|
||||
surface = palette.surface,
|
||||
onSurface = palette.body,
|
||||
surfaceVariant = palette.elevated,
|
||||
onSurfaceVariant = palette.muted,
|
||||
surfaceContainer = palette.elevated,
|
||||
surfaceContainerHigh = palette.elevated,
|
||||
surfaceContainerLow = palette.surface,
|
||||
outline = palette.outline,
|
||||
outlineVariant = palette.divider,
|
||||
secondaryContainer = palette.pillBg, // neutral chips / selected drawer item
|
||||
onSecondaryContainer = palette.pillFg,
|
||||
// Semantic, never themed — mirrors the server's FIXED_TOKENS (§4).
|
||||
error = ShardDanger,
|
||||
onError = ShardOnCta,
|
||||
onError = palette.onCta,
|
||||
errorContainer = ShardDangerBg,
|
||||
onErrorContainer = ShardDanger,
|
||||
)
|
||||
|
||||
/** 8dp inputs/chips, 12dp cards, 16dp large surfaces — matching the mockup radii. */
|
||||
private val ShardShapes = Shapes(
|
||||
extraSmall = RoundedCornerShape(8.dp),
|
||||
small = RoundedCornerShape(8.dp),
|
||||
medium = RoundedCornerShape(12.dp),
|
||||
large = RoundedCornerShape(16.dp),
|
||||
extraLarge = RoundedCornerShape(24.dp),
|
||||
)
|
||||
|
||||
/**
|
||||
* App theme. The color scheme is the fixed shard-website dark palette; when a shard
|
||||
* 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.
|
||||
* App theme, themed by the shard (M12). [appearance] carries the resolved token
|
||||
* map the admin's Appearance page publishes; it is applied field by field over
|
||||
* the shipped palette and shape scale, so [SiteAppearance.NONE] — no settings
|
||||
* rows, a backend that predates the feature, or a settings call that failed —
|
||||
* renders as the app did before this milestone (§2), the one exception being the
|
||||
* card depth [ShardStructure] documents.
|
||||
*
|
||||
* The palette reaches screens two ways: through [MaterialTheme]'s color scheme
|
||||
* for the ten tokens with a Material role, and through [LocalShardPalette] for
|
||||
* the five without one. The radii split the same way — [MaterialTheme]'s shape
|
||||
* scale for everything Material draws, [LocalShardStructure] for the pill and
|
||||
* the card depth, which it cannot carry.
|
||||
*/
|
||||
@Composable
|
||||
fun RunicGatewayTheme(
|
||||
accent: Color? = null,
|
||||
appearance: SiteAppearance = SiteAppearance.NONE,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val colorScheme = if (accent != null) {
|
||||
ShardColorScheme.copy(primary = accent, secondary = accent, tertiary = accent)
|
||||
} else {
|
||||
ShardColorScheme
|
||||
val palette = remember(appearance) {
|
||||
ShardPalette.resolve(
|
||||
theme = appearance.theme,
|
||||
brandAccent = parseBrandColor(appearance.brand?.accent),
|
||||
)
|
||||
}
|
||||
val colorScheme = remember(palette) { shardColorScheme(palette) }
|
||||
val structure = remember(appearance) { ShardStructure.resolve(appearance.theme) }
|
||||
|
||||
MaterialTheme(
|
||||
colorScheme = colorScheme,
|
||||
typography = Typography,
|
||||
shapes = ShardShapes,
|
||||
content = content,
|
||||
)
|
||||
CompositionLocalProvider(
|
||||
LocalShardPalette provides palette,
|
||||
LocalShardStructure provides structure,
|
||||
) {
|
||||
MaterialTheme(
|
||||
colorScheme = colorScheme,
|
||||
typography = Typography,
|
||||
shapes = structure.shapes,
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
@@ -30,6 +29,7 @@ import com.runicgateway.app.ui.UiState
|
||||
import com.runicgateway.app.ui.components.EmptyView
|
||||
import com.runicgateway.app.ui.components.ErrorView
|
||||
import com.runicgateway.app.ui.components.LoadingView
|
||||
import com.runicgateway.app.ui.components.ShardCard
|
||||
|
||||
/** Wiki index: search field + page list (PLAN.md §6.1). */
|
||||
@Composable
|
||||
@@ -72,7 +72,7 @@ fun WikiScreen(
|
||||
private fun WikiList(pages: List<WikiSummaryDto>, onOpenPage: (String) -> Unit) {
|
||||
LazyColumn(modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp)) {
|
||||
items(pages, key = { it.id }) { page ->
|
||||
Card(
|
||||
ShardCard(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 6.dp)
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
package com.runicgateway.app.data.api.dto
|
||||
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
@@ -69,5 +71,32 @@ class PublicDtoTest {
|
||||
assertFalse(dto.registration.password)
|
||||
assertEquals("", dto.brand.name)
|
||||
assertEquals(null, dto.push.ntfyUrl)
|
||||
// …and one that predates admin theming: both M12 fields are simply absent
|
||||
// (THEMING_AND_NAV.md §2 — absence means the shipped defaults).
|
||||
assertEquals(null, dto.theme)
|
||||
assertEquals(null, dto.navPublic)
|
||||
}
|
||||
|
||||
@Test fun settingsDecodesTheThemeAndNavRows() {
|
||||
val dto = json.decodeFromString<SettingsDto>(
|
||||
"""{
|
||||
"theme":{"--accent":"#c8a45c","--radius-card":"3px"},
|
||||
"nav_public":"{\"/wiki\":{\"label\":\"Codex\"}}",
|
||||
"theme_visual":"{\"preset\":\"fantasy\"}"
|
||||
}""",
|
||||
)
|
||||
assertEquals("#c8a45c", (dto.theme as JsonObject)["--accent"]?.jsonPrimitive?.content)
|
||||
// nav_public stays a raw string here: settings.value is TEXT, so it is
|
||||
// parsed a second time by SiteAppearance.
|
||||
assertEquals("""{"/wiki":{"label":"Codex"}}""", dto.navPublic)
|
||||
}
|
||||
|
||||
@Test fun anUnexpectedThemeKindStillDecodesTheRest() {
|
||||
// `theme` is a raw JsonElement precisely so a value we did not expect
|
||||
// cannot fail the decode and take brand/push with it.
|
||||
val dto = json.decodeFromString<SettingsDto>(
|
||||
"""{"theme":"nonsense","brand":{"name":"UOMysticmoon"}}""",
|
||||
)
|
||||
assertEquals("UOMysticmoon", dto.brand.name)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.data.appearance
|
||||
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* The second-stage parse of a JSON-valued settings row (THEMING_AND_NAV.md §3).
|
||||
* The rule under test is the one the web client's `parseJsonSetting` states:
|
||||
* anything that is not a plain object reads as **absent**, never as an error.
|
||||
*/
|
||||
class SettingsJsonTest {
|
||||
|
||||
@Test fun parsesAPlainObject() {
|
||||
val parsed = parseJsonSetting("""{"/wiki":{"label":"Codex","order":0}}""")
|
||||
assertEquals(1, parsed!!.size)
|
||||
assertEquals(setOf("/wiki"), parsed.keys)
|
||||
}
|
||||
|
||||
@Test fun parsesTheWrappedPublicShape() {
|
||||
val parsed = parseJsonSetting(
|
||||
"""{"items":{"/":{"hidden":true}},"sections":[{"id":"s1","label":"Play"}],"links":[]}""",
|
||||
)
|
||||
assertEquals(setOf("items", "sections", "links"), parsed!!.keys)
|
||||
}
|
||||
|
||||
@Test fun absentValuesReadAsNull() {
|
||||
assertNull(parseJsonSetting(null))
|
||||
assertNull(parseJsonSetting(""))
|
||||
}
|
||||
|
||||
@Test fun malformedJsonReadsAsNull() {
|
||||
assertNull(parseJsonSetting("{"))
|
||||
assertNull(parseJsonSetting("""{"a":}"""))
|
||||
assertNull(parseJsonSetting("not json at all"))
|
||||
}
|
||||
|
||||
@Test fun nonObjectJsonReadsAsNull() {
|
||||
// A stored `null`, number, string or array is as unusable to every
|
||||
// consumer of these keys as a syntax error is.
|
||||
assertNull(parseJsonSetting("null"))
|
||||
assertNull(parseJsonSetting("4"))
|
||||
assertNull(parseJsonSetting("\"x\""))
|
||||
assertNull(parseJsonSetting("[]"))
|
||||
}
|
||||
|
||||
@Test fun unusualKeysAndValuesSurviveVerbatim() {
|
||||
// The parse stage validates the *kind*, not the shape — a nonsense entry
|
||||
// is dropped later, by the phase that reads it.
|
||||
val parsed = parseJsonSetting("""{"/site/news":{"order":"first"},"nonsense":7}""")
|
||||
assertEquals(JsonPrimitive(7), parsed!!["nonsense"])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.data.appearance
|
||||
|
||||
import com.runicgateway.app.data.api.dto.SettingsDto
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertSame
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* [SiteAppearance.from] — the coercion between the settings payload and what the
|
||||
* theme and the drawer read (THEMING_AND_NAV.md §2, §3).
|
||||
*
|
||||
* The claims that matter here are the two the milestone rests on: an untouched
|
||||
* instance resolves to *nothing* (so the shipped app renders), and a bad token
|
||||
* costs exactly its own token.
|
||||
*/
|
||||
class SiteAppearanceTest {
|
||||
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
explicitNulls = false
|
||||
coerceInputValues = true
|
||||
}
|
||||
|
||||
private fun appearanceOf(body: String) =
|
||||
SiteAppearance.from(json.decodeFromString<SettingsDto>(body))
|
||||
|
||||
@Test fun untouchedInstanceResolvesToNoOverrides() {
|
||||
// No theme_visual row, no nav_public row: the shipped app, exactly (§2).
|
||||
val appearance = appearanceOf("""{"brand":{"name":"UOMysticmoon"}}""")
|
||||
assertTrue(appearance.theme.isEmpty())
|
||||
assertNull(appearance.navPublic)
|
||||
assertEquals("UOMysticmoon", appearance.brand?.name)
|
||||
}
|
||||
|
||||
@Test fun failedSettingsCallIsTheSameAsNoOverrides() {
|
||||
assertSame(SiteAppearance.NONE, SiteAppearance.from(null))
|
||||
assertNull(SiteAppearance.NONE.brand)
|
||||
assertTrue(SiteAppearance.NONE.theme.isEmpty())
|
||||
assertNull(SiteAppearance.NONE.navPublic)
|
||||
}
|
||||
|
||||
@Test fun resolvedThemeTokensAreReadAsAMap() {
|
||||
val appearance = appearanceOf(
|
||||
"""{"theme":{"--accent":"#c8a45c","--bg":"#1a1410","--radius-card":"10px",
|
||||
"--shadow-card":"none","--sans":"Inter, sans-serif"}}""",
|
||||
)
|
||||
assertEquals("#c8a45c", appearance.theme["--accent"])
|
||||
assertEquals("#1a1410", appearance.theme["--bg"])
|
||||
assertEquals("10px", appearance.theme["--radius-card"])
|
||||
assertEquals("none", appearance.theme["--shadow-card"])
|
||||
assertEquals("Inter, sans-serif", appearance.theme["--sans"])
|
||||
}
|
||||
|
||||
@Test fun anEmptyThemeMapIsTheSameAsAbsent() {
|
||||
// The server returns null rather than {} — the app must not depend on that.
|
||||
assertTrue(appearanceOf("""{"theme":{}}""").theme.isEmpty())
|
||||
}
|
||||
|
||||
@Test fun aBadTokenCostsOnlyItself() {
|
||||
// AC-2 in miniature at the decode boundary: a non-string or blank value is
|
||||
// dropped field-by-field, and its neighbours still apply.
|
||||
val appearance = appearanceOf(
|
||||
"""{"theme":{"--accent":"#c8a45c","--bg":7,"--line":null,"--ink":" "}}""",
|
||||
)
|
||||
assertEquals(mapOf("--accent" to "#c8a45c"), appearance.theme)
|
||||
}
|
||||
|
||||
@Test fun aThemeOfTheWrongKindDoesNotCostTheBrand() {
|
||||
// The whole reason `theme` is modeled as a raw JsonElement: one unexpected
|
||||
// value must not fail the decode and take brand and push down with it.
|
||||
val appearance = appearanceOf(
|
||||
"""{"theme":"not an object","brand":{"name":"UOMysticmoon","accent":"#7f99bd"},
|
||||
"push":{"ntfyUrl":"https://ntfy.example.com"}}""",
|
||||
)
|
||||
assertTrue(appearance.theme.isEmpty())
|
||||
assertEquals("#7f99bd", appearance.brand?.accent)
|
||||
}
|
||||
|
||||
@Test fun navPublicIsParsedASecondTime() {
|
||||
// It arrives as a JSON string inside a JSON object, because settings.value
|
||||
// is TEXT.
|
||||
val appearance = appearanceOf("""{"nav_public":"{\"/wiki\":{\"label\":\"Codex\"}}"}""")
|
||||
assertEquals(setOf("/wiki"), appearance.navPublic?.keys)
|
||||
}
|
||||
|
||||
@Test fun aMalformedNavPublicDoesNotCostTheTheme() {
|
||||
val appearance = appearanceOf("""{"nav_public":"{oops","theme":{"--accent":"#c8a45c"}}""")
|
||||
assertNull(appearance.navPublic)
|
||||
assertEquals("#c8a45c", appearance.theme["--accent"])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.theme
|
||||
|
||||
import androidx.compose.material3.ColorScheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* **AC-1, the no-op proof** (THEMING_AND_NAV.md §7). An instance with no
|
||||
* `theme_visual` row must resolve to a color scheme equal to the one the app
|
||||
* shipped before M12 — not "close enough", identical, because the app's M5
|
||||
* palette *is* the website's `runic-gateway` preset.
|
||||
*
|
||||
* Two things this test has to work around:
|
||||
*
|
||||
* - **`ColorScheme` does not implement `equals`** (checked against material3
|
||||
* 1.3.0), so "the full scheme, not a spot check" is a field-by-field compare.
|
||||
* It is done by reflection over every color-valued getter rather than by a
|
||||
* hand-written list, so a role that is added to Material — or one the mapping
|
||||
* forgets — cannot silently escape the assertion.
|
||||
* - The expected value is a **verbatim copy of the pre-M12 `ShardColorScheme`**,
|
||||
* kept here rather than referenced, so the proof is against what the app used
|
||||
* to do and not against whatever [shardColorScheme] does today.
|
||||
*/
|
||||
class ShardColorSchemeTest {
|
||||
|
||||
/** The scheme exactly as `ui/theme/Theme.kt` declared it before M12. */
|
||||
private val preM12Scheme = darkColorScheme(
|
||||
primary = ShardCta,
|
||||
onPrimary = ShardOnCta,
|
||||
secondary = ShardAccent,
|
||||
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,
|
||||
onSecondaryContainer = ShardPillFg,
|
||||
error = ShardDanger,
|
||||
onError = ShardOnCta,
|
||||
errorContainer = ShardDangerBg,
|
||||
onErrorContainer = ShardDanger,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `the shipped palette reproduces the pre-M12 color scheme exactly`() {
|
||||
assertEquals(roles(preM12Scheme), roles(shardColorScheme(ShardPalette.Shipped)))
|
||||
}
|
||||
|
||||
/** The same claim from the other end: an absent theme map is the shipped app. */
|
||||
@Test
|
||||
fun `an absent theme map reproduces the pre-M12 color scheme`() {
|
||||
val resolved = shardColorScheme(ShardPalette.resolve(emptyMap()))
|
||||
assertEquals(roles(preM12Scheme), roles(resolved))
|
||||
}
|
||||
|
||||
/** Sanity: the comparison is capable of failing, and covers the whole scheme. */
|
||||
@Test
|
||||
fun `the role comparison sees every color role`() {
|
||||
val roles = roles(preM12Scheme)
|
||||
// material3 1.3.0 declares 36 color roles; fewer than that means the
|
||||
// reflection has stopped seeing them and the comparison above went hollow.
|
||||
assertTrue("expected the full Material role set, got ${roles.keys}", roles.size >= 36)
|
||||
assertEquals(ShardCta, roles["primary"])
|
||||
assertNotEquals(
|
||||
roles,
|
||||
roles(shardColorScheme(ShardPalette.Shipped.copy(cta = Color(0xFFC9973F)))),
|
||||
)
|
||||
}
|
||||
|
||||
/** A themed shard moves the roles its tokens own, and only those. */
|
||||
@Test
|
||||
fun `a theme token reaches its Material role`() {
|
||||
val themed = shardColorScheme(
|
||||
ShardPalette.resolve(mapOf("--accent" to "#c9973f", "--bg-deep" to "#120c07")),
|
||||
)
|
||||
val roles = roles(themed)
|
||||
assertEquals(Color(0xFFC9973F), roles["secondary"])
|
||||
assertEquals(Color(0xFFC9973F), roles["tertiary"])
|
||||
assertEquals(Color(0xFF120C07), roles["background"])
|
||||
assertEquals(Color(0xFF120C07), roles["onPrimary"]) // derived: onCta tracks --bg-deep
|
||||
assertEquals(ShardCta, roles["primary"]) // untouched by these two tokens
|
||||
}
|
||||
|
||||
/**
|
||||
* Every color role of a scheme, by name. `Color` is a value class, so the
|
||||
* roles are the `long`-returning getters and their names carry Kotlin's
|
||||
* mangling suffix (`getPrimary-0d7_KjU`), which is stripped here.
|
||||
*/
|
||||
private fun roles(scheme: ColorScheme): Map<String, Color> =
|
||||
ColorScheme::class.java.declaredMethods
|
||||
.filter {
|
||||
it.name.startsWith("get") &&
|
||||
it.returnType == java.lang.Long.TYPE &&
|
||||
it.parameterCount == 0
|
||||
}
|
||||
.associate { method ->
|
||||
val name = method.name.removePrefix("get").substringBefore('-')
|
||||
.replaceFirstChar { it.lowercase() }
|
||||
// The getter hands back Color's packed ULong bits, not an ARGB int.
|
||||
name to Color((method.invoke(scheme) as Long).toULong())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.theme
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertSame
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* The token → palette resolution (THEMING_AND_NAV.md §5.1) and its
|
||||
* forgiving-on-read rule (§2, AC-2).
|
||||
*/
|
||||
class ShardPaletteTest {
|
||||
|
||||
/** AC-1, the palette half: no theme row resolves to the shipped M5 palette. */
|
||||
@Test
|
||||
fun `no tokens resolve to the shipped palette`() {
|
||||
assertSame(ShardPalette.Shipped, ShardPalette.resolve(emptyMap()))
|
||||
}
|
||||
|
||||
/** Every one of the fifteen has a home, and lands in the right one. */
|
||||
@Test
|
||||
fun `all fifteen tokens map to their palette field`() {
|
||||
val p = ShardPalette.resolve(
|
||||
mapOf(
|
||||
"--bg" to "#010101",
|
||||
"--bg-deep" to "#020202",
|
||||
"--panel-a" to "#030303",
|
||||
"--panel-b" to "#040404",
|
||||
"--panel-flat" to "#050505",
|
||||
"--line" to "#060606",
|
||||
"--line-soft" to "#070707",
|
||||
"--accent" to "#080808",
|
||||
"--accent-bright" to "#090909",
|
||||
"--ink" to "#0a0a0a",
|
||||
"--head" to "#0b0b0b",
|
||||
"--text" to "#0c0c0c",
|
||||
"--muted" to "#0d0d0d",
|
||||
"--dim" to "#0e0e0e",
|
||||
"--blue" to "#0f0f0f",
|
||||
),
|
||||
)
|
||||
assertEquals(Color(0xFF010101), p.surface)
|
||||
assertEquals(Color(0xFF020202), p.page)
|
||||
assertEquals(Color(0xFF030303), p.cardTop)
|
||||
assertEquals(Color(0xFF040404), p.cardBottom)
|
||||
assertEquals(Color(0xFF050505), p.elevated)
|
||||
assertEquals(Color(0xFF060606), p.outline)
|
||||
assertEquals(Color(0xFF070707), p.divider)
|
||||
assertEquals(Color(0xFF080808), p.accent)
|
||||
assertEquals(Color(0xFF090909), p.cta)
|
||||
assertEquals(Color(0xFF0A0A0A), p.heading)
|
||||
assertEquals(Color(0xFF0B0B0B), p.headingDim)
|
||||
assertEquals(Color(0xFF0C0C0C), p.body)
|
||||
assertEquals(Color(0xFF0D0D0D), p.muted)
|
||||
assertEquals(Color(0xFF0E0E0E), p.faint)
|
||||
assertEquals(Color(0xFF0F0F0F), p.pillBg)
|
||||
}
|
||||
|
||||
/**
|
||||
* AC-2. One valid token applies; four malformed ones each fall back on their
|
||||
* own, and none of them costs the good one beside it.
|
||||
*/
|
||||
@Test
|
||||
fun `a malformed token costs only itself`() {
|
||||
val p = ShardPalette.resolve(
|
||||
mapOf(
|
||||
"--accent" to "#c9973f", // the one valid token
|
||||
"--bg" to "not a color",
|
||||
"--ink" to "",
|
||||
"--line" to "#12",
|
||||
"--text" to "rgb(1, 2, 3)",
|
||||
),
|
||||
)
|
||||
assertEquals(Color(0xFFC9973F), p.accent)
|
||||
assertEquals(ShardPalette.Shipped.surface, p.surface)
|
||||
assertEquals(ShardPalette.Shipped.heading, p.heading)
|
||||
assertEquals(ShardPalette.Shipped.outline, p.outline)
|
||||
assertEquals(ShardPalette.Shipped.body, p.body)
|
||||
}
|
||||
|
||||
/** A token the app does not know is not an error — it is simply not read. */
|
||||
@Test
|
||||
fun `unknown tokens are ignored`() {
|
||||
val p = ShardPalette.resolve(mapOf("--mode-live" to "#ff0000", "--radius-card" to "2px"))
|
||||
assertEquals(ShardPalette.Shipped, p)
|
||||
}
|
||||
|
||||
/**
|
||||
* The pre-feature branding path: a `BRAND_ACCENT_COLOR` with no theme row
|
||||
* still tints links and highlights, and nothing else moves.
|
||||
*/
|
||||
@Test
|
||||
fun `brand accent seeds only the accent token`() {
|
||||
val p = ShardPalette.resolve(emptyMap(), brandAccent = Color(0xFFC9973F))
|
||||
assertEquals(Color(0xFFC9973F), p.accent)
|
||||
assertEquals(ShardPalette.Shipped.copy(accent = Color(0xFFC9973F)), p)
|
||||
}
|
||||
|
||||
/**
|
||||
* The server resolves `brand.accent` as `theme['--accent'] || env`, so the two
|
||||
* can only disagree if a client is holding a stale brand — the token wins.
|
||||
*/
|
||||
@Test
|
||||
fun `the accent token beats the brand accent`() {
|
||||
val p = ShardPalette.resolve(mapOf("--accent" to "#4f8ef7"), brandAccent = Color(0xFFC9973F))
|
||||
assertEquals(Color(0xFF4F8EF7), p.accent)
|
||||
}
|
||||
|
||||
/** An unparseable brand accent is the same as none. */
|
||||
@Test
|
||||
fun `a malformed brand accent falls back to the shipped accent`() {
|
||||
assertEquals(ShardPalette.Shipped, ShardPalette.resolve(emptyMap(), parseBrandColor("nope")))
|
||||
}
|
||||
|
||||
/**
|
||||
* The derived pair (§5.1): expressed in terms of another token, so they must
|
||||
* follow it rather than being frozen at the shipped literal.
|
||||
*/
|
||||
@Test
|
||||
fun `derived colors track the tokens they are expressed in`() {
|
||||
val p = ShardPalette.resolve(mapOf("--bg-deep" to "#120c07", "--accent-bright" to "#e8c374"))
|
||||
assertEquals(Color(0xFF120C07), p.onCta)
|
||||
assertEquals(Color(0xFFE8C374), p.pillFg)
|
||||
}
|
||||
|
||||
/** And on the shipped palette they are exactly today's two constants. */
|
||||
@Test
|
||||
fun `derived colors are the shipped constants by default`() {
|
||||
assertEquals(ShardOnCta, ShardPalette.Shipped.onCta)
|
||||
assertEquals(ShardPillFg, ShardPalette.Shipped.pillFg)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.theme
|
||||
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Shapes
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Assert.assertSame
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* [ShardStructure.resolve] — the radius ratio (§5.2) and the shadow map (§5.4).
|
||||
*
|
||||
* The no-op proof is easier here than it was for the palette: material3's
|
||||
* [Shapes] *does* implement `equals` (unlike `ColorScheme`), so a whole shape
|
||||
* scale can be compared in one assertion. As in [ShardColorSchemeTest] the
|
||||
* expected value is a **verbatim copy of the pre-M12 scale**, kept here rather
|
||||
* than referenced, so the proof is against what the app used to draw and not
|
||||
* against whatever [ShippedShapes] says today.
|
||||
*/
|
||||
class ShardStructureTest {
|
||||
|
||||
/** The scale exactly as `ui/theme/Theme.kt` declared it before M12. */
|
||||
private val preM12Shapes = Shapes(
|
||||
extraSmall = RoundedCornerShape(8.dp),
|
||||
small = RoundedCornerShape(8.dp),
|
||||
medium = RoundedCornerShape(12.dp),
|
||||
large = RoundedCornerShape(16.dp),
|
||||
extraLarge = RoundedCornerShape(24.dp),
|
||||
)
|
||||
|
||||
/** The `runic-gateway` preset's structure tokens, as the server publishes them. */
|
||||
private val runicGateway = mapOf(
|
||||
"--radius-pill" to "999px",
|
||||
"--radius-panel" to "12px",
|
||||
"--radius-card" to "10px",
|
||||
"--radius-input" to "8px",
|
||||
"--shadow-card" to "0 14px 34px rgba(0, 0, 0, 0.3)",
|
||||
)
|
||||
|
||||
private val fantasy = mapOf(
|
||||
"--radius-pill" to "4px",
|
||||
"--radius-panel" to "3px",
|
||||
"--radius-card" to "2px",
|
||||
"--radius-input" to "2px",
|
||||
"--shadow-card" to "0 16px 38px rgba(0, 0, 0, 0.45)",
|
||||
)
|
||||
|
||||
private val modern = mapOf(
|
||||
"--radius-pill" to "8px",
|
||||
"--radius-panel" to "8px",
|
||||
"--radius-card" to "6px",
|
||||
"--radius-input" to "6px",
|
||||
"--shadow-card" to "0 8px 20px rgba(0, 0, 0, 0.25)",
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `the shipped scale is the pre-M12 scale`() {
|
||||
assertEquals(preM12Shapes, ShardStructure.Shipped.shapes)
|
||||
assertSame(CircleShape, ShardStructure.Shipped.pill)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no theme resolves to the shipped structure`() {
|
||||
assertEquals(ShardStructure.Shipped, ShardStructure.resolve(emptyMap()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a theme with no structure tokens resolves to the shipped structure`() {
|
||||
// A shard that themed its colors only still draws the app's own radii:
|
||||
// every ratio is 1.0 because every token is absent.
|
||||
val colorsOnly = mapOf("--accent" to "#7f99bd", "--bg" to "#0b1220")
|
||||
assertEquals(ShardStructure.Shipped, ShardStructure.resolve(colorsOnly))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the runic-gateway preset is a no-op`() {
|
||||
// AC-1 for the structure half: an admin who explicitly picks the preset
|
||||
// the app was drawn from gets ratio 1.0 on all four fields.
|
||||
assertEquals(ShardStructure.Shipped, ShardStructure.resolve(runicGateway))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fantasy scales the app's own dp, it does not adopt the web's`() {
|
||||
val s = ShardStructure.resolve(fantasy)
|
||||
// 2/8 -> 8dp becomes 2dp; the web's own value is also 2px, coincidentally.
|
||||
assertEquals(RoundedCornerShape(2.dp), s.shapes.extraSmall)
|
||||
assertEquals(RoundedCornerShape(2.dp), s.shapes.small)
|
||||
// 2/10 -> 12dp * 0.2 = 2.4, rounded.
|
||||
assertEquals(RoundedCornerShape(2.dp), s.shapes.medium)
|
||||
// 3/12 -> 16dp * 0.25. The web value is 3px; the app's is 4dp, which is
|
||||
// the whole point of the ratio.
|
||||
assertEquals(RoundedCornerShape(4.dp), s.shapes.large)
|
||||
// extraLarge has no web counterpart and follows the panel ratio.
|
||||
assertEquals(RoundedCornerShape(6.dp), s.shapes.extraLarge)
|
||||
assertEquals(RoundedCornerShape(4.dp), s.pill)
|
||||
// 38px blur is nearer Default's 34 than Deep's 44.
|
||||
assertEquals(4.dp, s.cardElevation)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `modern scales the app's own dp`() {
|
||||
val s = ShardStructure.resolve(modern)
|
||||
assertEquals(RoundedCornerShape(6.dp), s.shapes.extraSmall)
|
||||
assertEquals(RoundedCornerShape(7.dp), s.shapes.medium)
|
||||
assertEquals(RoundedCornerShape(11.dp), s.shapes.large)
|
||||
assertEquals(RoundedCornerShape(16.dp), s.shapes.extraLarge)
|
||||
assertEquals(RoundedCornerShape(8.dp), s.pill)
|
||||
assertEquals(2.dp, s.cardElevation)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a bad radius costs only its own field`() {
|
||||
val s = ShardStructure.resolve(
|
||||
mapOf(
|
||||
"--radius-input" to "8", // no unit; the server never writes this
|
||||
"--radius-card" to "huge",
|
||||
"--radius-panel" to "3px", // good, and must still apply
|
||||
"--radius-pill" to "",
|
||||
),
|
||||
)
|
||||
assertEquals(preM12Shapes.extraSmall, s.shapes.extraSmall)
|
||||
assertEquals(preM12Shapes.medium, s.shapes.medium)
|
||||
assertEquals(RoundedCornerShape(4.dp), s.shapes.large)
|
||||
assertSame(CircleShape, s.pill)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a zero radius squares the corner off rather than clamping to the shipped value`() {
|
||||
val s = ShardStructure.resolve(mapOf("--radius-card" to "0px", "--radius-input" to "0px"))
|
||||
assertEquals(RoundedCornerShape(0.dp), s.shapes.medium)
|
||||
assertEquals(RoundedCornerShape(0.dp), s.shapes.extraSmall)
|
||||
assertNotEquals(preM12Shapes, s.shapes)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the pill keeps its circle until the site is squared off`() {
|
||||
assertSame(CircleShape, ShardStructure.resolve(mapOf("--radius-pill" to "999px")).pill)
|
||||
assertSame(CircleShape, ShardStructure.resolve(mapOf("--radius-pill" to "500px")).pill)
|
||||
assertEquals(RoundedCornerShape(499.dp), ShardStructure.resolve(mapOf("--radius-pill" to "499px")).pill)
|
||||
assertEquals(RoundedCornerShape(0.dp), ShardStructure.resolve(mapOf("--radius-pill" to "0px")).pill)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `every shadow option lands on its step`() {
|
||||
fun elevation(shadow: String) = ShardStructure.resolve(mapOf("--shadow-card" to shadow)).cardElevation
|
||||
assertEquals(0.dp, elevation("none"))
|
||||
assertEquals(2.dp, elevation("0 8px 20px rgba(0, 0, 0, 0.25)"))
|
||||
assertEquals(4.dp, elevation("0 14px 34px rgba(0, 0, 0, 0.3)"))
|
||||
assertEquals(8.dp, elevation("0 18px 44px rgba(0, 0, 0, 0.45)"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a shadow the options do not contain lands on the nearest step`() {
|
||||
// The reason the match is on blur and not on the exact string: the
|
||||
// Fantasy preset's own --shadow-card is not one of SHADOW_OPTIONS'
|
||||
// four values, because a preset's tokens never pass through that
|
||||
// dropdown. An exact match would have dropped it on the floor.
|
||||
fun elevation(shadow: String) = ShardStructure.resolve(mapOf("--shadow-card" to shadow)).cardElevation
|
||||
assertEquals(4.dp, elevation("0 16px 38px rgba(0, 0, 0, 0.45)"))
|
||||
assertEquals(8.dp, elevation("0 20px 60px rgba(0, 0, 0, 0.5)"))
|
||||
assertEquals(2.dp, elevation("0 2px 4px rgba(0, 0, 0, 0.2)"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unreadable shadow keeps the shipped depth`() {
|
||||
fun elevation(shadow: String) = ShardStructure.resolve(mapOf("--shadow-card" to shadow)).cardElevation
|
||||
assertEquals(ShardStructure.Shipped.cardElevation, elevation("inset 0 0 nonsense"))
|
||||
assertEquals(ShardStructure.Shipped.cardElevation, elevation(""))
|
||||
// One length is an offset, not a blur — an incomplete value is not a
|
||||
// reason to flatten every card on the shard.
|
||||
assertEquals(ShardStructure.Shipped.cardElevation, elevation("0 14px"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the shipped depth is the runic-gateway default, not flat`() {
|
||||
// The one deliberate departure from §2: the app has been flat since M5
|
||||
// (material3's filled Card is Level0 and FeatureCard drew no shadow),
|
||||
// while the preset the app was drawn from selects the "Default" shadow.
|
||||
// §5.4 is applied as written, so an untouched instance gains this depth.
|
||||
assertEquals(4.dp, ShardStructure.Shipped.cardElevation)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user