Merge pull request 'feat(theme): scale the shard's radii and card depth onto the app's scale (M12 phase 2)' (#35) from feat/m12-phase-2-structure into edge
Reviewed-on: #35
This commit is contained in:
@@ -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,16 +14,20 @@ 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.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.ShardSuccess
|
||||
@@ -38,10 +42,11 @@ import com.runicgateway.app.ui.theme.ShardWarningBg
|
||||
* 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.colorScheme`
|
||||
* for a themable color, so it is the one place M12 had to migrate: the surface,
|
||||
* line and accent tokens now come from [LocalShardPalette] and follow the
|
||||
* shard's theme (THEMING_AND_NAV.md §5.1). The success/warning/danger constants
|
||||
* 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`.
|
||||
*/
|
||||
@@ -63,11 +68,14 @@ private fun toneColors(tone: PillTone): PillColors = when (tone) {
|
||||
/**
|
||||
* 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,
|
||||
@@ -107,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(
|
||||
@@ -115,17 +128,39 @@ fun FeatureCard(
|
||||
content: @Composable ColumnScope.() -> Unit,
|
||||
) {
|
||||
val palette = LocalShardPalette.current
|
||||
val shape = MaterialTheme.shapes.medium
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.shadow(LocalShardStructure.current.cardElevation, shape)
|
||||
.clip(shape)
|
||||
.background(Brush.verticalGradient(listOf(palette.cardTop, palette.cardBottom)))
|
||||
.border(1.dp, palette.outline, RoundedCornerShape(12.dp)),
|
||||
.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.
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,15 +3,12 @@
|
||||
*/
|
||||
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.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.runicgateway.app.data.appearance.SiteAppearance
|
||||
|
||||
/**
|
||||
@@ -51,25 +48,19 @@ internal fun shardColorScheme(palette: ShardPalette): ColorScheme = darkColorSch
|
||||
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, 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, so [SiteAppearance.NONE] — no settings rows, a backend
|
||||
* that predates the feature, or a settings call that failed — renders exactly
|
||||
* as the app did before this milestone (§2).
|
||||
* 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 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(
|
||||
@@ -83,12 +74,16 @@ fun RunicGatewayTheme(
|
||||
)
|
||||
}
|
||||
val colorScheme = remember(palette) { shardColorScheme(palette) }
|
||||
val structure = remember(appearance) { ShardStructure.resolve(appearance.theme) }
|
||||
|
||||
CompositionLocalProvider(LocalShardPalette provides palette) {
|
||||
CompositionLocalProvider(
|
||||
LocalShardPalette provides palette,
|
||||
LocalShardStructure provides structure,
|
||||
) {
|
||||
MaterialTheme(
|
||||
colorScheme = colorScheme,
|
||||
typography = Typography,
|
||||
shapes = ShardShapes,
|
||||
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)
|
||||
|
||||
@@ -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