app

Local-first trade for farms and co-ops
git clone https://radroots.dev/git/app.git
Log | Files | Refs | README | LICENSE

commit 67ad9e5097ba8ac733c89b5fda18a6be70c26e50
parent 6a306a7b0c65a9eb5d76d0e1661e12f9a973468f
Author: triesap <tyson@radroots.org>
Date:   Tue, 11 Aug 2026 21:26:17 +0000

ui: apply the HarvestCircle theme

- Resolve System, Light, and Dark appearance through an injectable platform signal.
- Provide palette, typography, shape, motion, and text-scale composition locals.
- Apply themed backgrounds and surfaces across shell, dashboard, canvas, and overlays.
- Centralize Compose color conversion and reduced-motion availability.

Diffstat:
Mapp/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/CanvasScaffold.kt | 6+++++-
Mapp/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/DashboardScaffold.kt | 11+++++++++--
Mapp/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/FoundationOverlayHost.kt | 2++
Mapp/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/GlobalTopBar.kt | 16++--------------
Mapp/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/HarvestCircleShell.kt | 14+-------------
Aapp/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/HarvestCircleTheme.kt | 113+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mapp/shared/src/commonTest/kotlin/org/harvestcircle/design/HarvestCircleDesignTest.kt | 32++++++++++++++++++++++++++++++++
7 files changed, 164 insertions(+), 30 deletions(-)

diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/CanvasScaffold.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/CanvasScaffold.kt @@ -1,5 +1,6 @@ package org.harvestcircle.ui.shell +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -30,11 +31,13 @@ fun CanvasScaffold( val effectiveTextSize = textSize.takeUnless { it == TextSizePreference.Default } ?: LocalShellAppearance.current.textSize - Column(Modifier.fillMaxSize().testTag("canvas-scaffold")) { + val palette = LocalHarvestCirclePalette.current + Column(Modifier.fillMaxSize().background(palette.background.toComposeColor()).testTag("canvas-scaffold")) { Row( Modifier .fillMaxWidth() .height(64.dp) + .background(palette.surface.toComposeColor()) .semantics { contentDescription = "Canvas header" } .testTag("canvas-header"), verticalAlignment = Alignment.CenterVertically, @@ -62,6 +65,7 @@ fun CanvasScaffold( Modifier .fillMaxWidth() .height(72.dp) + .background(palette.surface.toComposeColor()) .semantics { contentDescription = "Canvas action bar" } .testTag("canvas-action-bar"), ) { diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/DashboardScaffold.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/DashboardScaffold.kt @@ -1,5 +1,6 @@ package org.harvestcircle.ui.shell +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -26,7 +27,8 @@ fun DashboardScaffold( inspector: @Composable () -> Unit = {}, ) { val placement = inspectorPlacement(windowWidthDp, inspectorVisible) - Column(Modifier.fillMaxSize().testTag("dashboard-scaffold")) { + val palette = LocalHarvestCirclePalette.current + Column(Modifier.fillMaxSize().background(palette.background.toComposeColor()).testTag("dashboard-scaffold")) { Region("dashboard-top-bar", "Global top bar", Modifier.fillMaxWidth().height(56.dp), topBar) Row(Modifier.fillMaxSize()) { Region("dashboard-sidebar", "Workspace sidebar", Modifier.width(232.dp).fillMaxHeight(), sidebar) @@ -56,7 +58,12 @@ private fun Region( Box( modifier = modifier - .semantics { contentDescription = label } + .background( + when (tag) { + "dashboard-main-body" -> LocalHarvestCirclePalette.current.background.toComposeColor() + else -> LocalHarvestCirclePalette.current.surface.toComposeColor() + }, + ).semantics { contentDescription = label } .testTag(tag), ) { content() diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/FoundationOverlayHost.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/FoundationOverlayHost.kt @@ -1,5 +1,6 @@ package org.harvestcircle.ui.shell +import androidx.compose.foundation.background import androidx.compose.foundation.focusGroup import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -33,6 +34,7 @@ fun FoundationOverlayHost( Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Column( Modifier + .background(LocalHarvestCirclePalette.current.surface.toComposeColor()) .focusGroup() .semantics { contentDescription = "Dialog" } .testTag("foundation-overlay"), diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/GlobalTopBar.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/GlobalTopBar.kt @@ -15,7 +15,6 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.focus.onFocusChanged -import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.testTag import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.contentDescription @@ -23,9 +22,7 @@ import androidx.compose.ui.semantics.disabled import androidx.compose.ui.semantics.role import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp -import org.harvestcircle.design.ColorToken import org.harvestcircle.design.HarvestCircleDesign -import org.harvestcircle.design.ThemePreference enum class SyncStatusLabel( val text: String, @@ -99,11 +96,7 @@ internal fun ShellAction( onClick: () -> Unit, ) { var focused by remember { mutableStateOf(false) } - val focusColor = - when (LocalShellAppearance.current.theme) { - ThemePreference.Dark -> HarvestCircleDesign.dark.focus - ThemePreference.System, ThemePreference.Light -> HarvestCircleDesign.light.focus - }.composeColor() + val focusColor = LocalHarvestCirclePalette.current.focus.toComposeColor() BasicText( text = label, modifier = @@ -112,7 +105,7 @@ internal fun ShellAction( .onFocusChanged { focused = it.isFocused } .border( width = HarvestCircleDesign.BORDER_DP.dp, - color = if (focused) focusColor else Color.Transparent, + color = if (focused) focusColor else androidx.compose.ui.graphics.Color.Transparent, ).padding(horizontal = HarvestCircleDesign.spacingDp[2].dp) .semantics { contentDescription = description @@ -122,8 +115,3 @@ internal fun ShellAction( .testTag(tag), ) } - -private fun ColorToken.composeColor(): Color { - val rgb = hex.removePrefix("#").toLong(16) - return Color(0xFF000000 or rgb) -} diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/HarvestCircleShell.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/HarvestCircleShell.kt @@ -5,12 +5,8 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.text.BasicText import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.remember import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.testTag -import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.dp import org.harvestcircle.application.FoundationOverlay import org.harvestcircle.application.HarvestCircleIntent @@ -35,15 +31,7 @@ fun HarvestCircleShell( platformActions: HarvestCirclePlatformActions, dispatch: (HarvestCircleShellIntent) -> Unit, ) { - val baseDensity = LocalDensity.current - val scaledDensity = - remember(baseDensity, state.appearance.textSize) { - Density(baseDensity.density, baseDensity.fontScale * state.appearance.textSize.scale) - } - CompositionLocalProvider( - LocalDensity provides scaledDensity, - LocalShellAppearance provides state.appearance, - ) { + HarvestCircleTheme(state.appearance) { ShellKeyboardHost(onShortcut = { dispatchShortcut(it, dispatch) }) { HarvestCircleShellContent(state, identityActions, platformActions, dispatch) } diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/HarvestCircleTheme.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/HarvestCircleTheme.kt @@ -0,0 +1,113 @@ +package org.harvestcircle.ui.shell + +import androidx.compose.foundation.background +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Density +import org.harvestcircle.design.AppearanceState +import org.harvestcircle.design.ColorToken +import org.harvestcircle.design.HarvestCircleDesign +import org.harvestcircle.design.HarvestCirclePalette +import org.harvestcircle.design.MotionPreference +import org.harvestcircle.design.ThemePreference +import org.harvestcircle.design.TypographyToken + +enum class ResolvedTheme { Light, Dark } + +data class HarvestCircleTypography( + val screenTitle: TypographyToken = HarvestCircleDesign.screenTitle, + val sectionTitle: TypographyToken = HarvestCircleDesign.sectionTitle, + val cardTitle: TypographyToken = HarvestCircleDesign.cardTitle, + val body: TypographyToken = HarvestCircleDesign.body, + val secondary: TypographyToken = HarvestCircleDesign.secondary, + val protocol: TypographyToken = HarvestCircleDesign.protocol, + val button: TypographyToken = HarvestCircleDesign.button, +) + +data class HarvestCircleShapes( + val smallRadiusDp: Int = HarvestCircleDesign.SMALL_RADIUS_DP, + val controlRadiusDp: Int = HarvestCircleDesign.CONTROL_RADIUS_DP, + val surfaceRadiusDp: Int = HarvestCircleDesign.SURFACE_RADIUS_DP, +) + +data class HarvestCircleMotion( + val nonessentialEnabled: Boolean, +) + +data class HarvestCircleThemeTokens( + val resolvedTheme: ResolvedTheme, + val palette: HarvestCirclePalette, + val typography: HarvestCircleTypography, + val shapes: HarvestCircleShapes, + val motion: HarvestCircleMotion, + val textScale: Float, +) + +val LocalHarvestCirclePalette = staticCompositionLocalOf { HarvestCircleDesign.light } +val LocalHarvestCircleTypography = staticCompositionLocalOf { HarvestCircleTypography() } +val LocalHarvestCircleShapes = staticCompositionLocalOf { HarvestCircleShapes() } +val LocalHarvestCircleMotion = staticCompositionLocalOf { HarvestCircleMotion(nonessentialEnabled = true) } + +fun resolveTheme( + preference: ThemePreference, + systemDark: Boolean, +): ResolvedTheme = + when (preference) { + ThemePreference.System -> if (systemDark) ResolvedTheme.Dark else ResolvedTheme.Light + ThemePreference.Light -> ResolvedTheme.Light + ThemePreference.Dark -> ResolvedTheme.Dark + } + +fun harvestCircleThemeTokens( + appearance: AppearanceState, + systemDark: Boolean, +): HarvestCircleThemeTokens { + val resolved = resolveTheme(appearance.theme, systemDark) + return HarvestCircleThemeTokens( + resolvedTheme = resolved, + palette = if (resolved == ResolvedTheme.Dark) HarvestCircleDesign.dark else HarvestCircleDesign.light, + typography = HarvestCircleTypography(), + shapes = HarvestCircleShapes(), + motion = HarvestCircleMotion(nonessentialEnabled = appearance.motion != MotionPreference.Reduced), + textScale = appearance.textSize.scale, + ) +} + +@Composable +fun HarvestCircleTheme( + appearance: AppearanceState, + systemDark: Boolean = isSystemInDarkTheme(), + content: @Composable () -> Unit, +) { + val tokens = harvestCircleThemeTokens(appearance, systemDark) + val baseDensity = LocalDensity.current + val scaledDensity = Density(baseDensity.density, baseDensity.fontScale * tokens.textScale) + CompositionLocalProvider( + LocalShellAppearance provides appearance, + LocalHarvestCirclePalette provides tokens.palette, + LocalHarvestCircleTypography provides tokens.typography, + LocalHarvestCircleShapes provides tokens.shapes, + LocalHarvestCircleMotion provides tokens.motion, + LocalDensity provides scaledDensity, + ) { + Box( + Modifier + .fillMaxSize() + .background(tokens.palette.background.toComposeColor()), + ) { + content() + } + } +} + +fun ColorToken.toComposeColor(): Color { + val rgb = hex.removePrefix("#").toLong(16) + return Color(0xFF000000 or rgb) +} diff --git a/app/shared/src/commonTest/kotlin/org/harvestcircle/design/HarvestCircleDesignTest.kt b/app/shared/src/commonTest/kotlin/org/harvestcircle/design/HarvestCircleDesignTest.kt @@ -1,5 +1,8 @@ package org.harvestcircle.design +import org.harvestcircle.ui.shell.ResolvedTheme +import org.harvestcircle.ui.shell.harvestCircleThemeTokens +import org.harvestcircle.ui.shell.resolveTheme import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFails @@ -38,4 +41,33 @@ class HarvestCircleDesignTest { assertEquals(1.3f, changed.textSize.scale) assertEquals(MotionPreference.Reduced, changed.motion) } + + @Test + fun themeResolutionFollowsExplicitAndInjectedSystemPreference() { + assertEquals(ResolvedTheme.Light, resolveTheme(ThemePreference.System, systemDark = false)) + assertEquals(ResolvedTheme.Dark, resolveTheme(ThemePreference.System, systemDark = true)) + assertEquals(ResolvedTheme.Light, resolveTheme(ThemePreference.Light, systemDark = true)) + assertEquals(ResolvedTheme.Dark, resolveTheme(ThemePreference.Dark, systemDark = false)) + + val light = harvestCircleThemeTokens(AppearanceState(theme = ThemePreference.Light), systemDark = true) + val dark = harvestCircleThemeTokens(AppearanceState(theme = ThemePreference.Dark), systemDark = false) + assertEquals(HarvestCircleDesign.light.background, light.palette.background) + assertEquals(HarvestCircleDesign.dark.background, dark.palette.background) + } + + @Test + fun themeCentralizesTextScaleShapesTypographyAndMotion() { + val tokens = + harvestCircleThemeTokens( + AppearanceState( + textSize = TextSizePreference.VeryLarge, + motion = MotionPreference.Reduced, + ), + systemDark = false, + ) + assertEquals(1.3f, tokens.textScale) + assertEquals(HarvestCircleDesign.screenTitle, tokens.typography.screenTitle) + assertEquals(HarvestCircleDesign.SURFACE_RADIUS_DP, tokens.shapes.surfaceRadiusDp) + assertEquals(false, tokens.motion.nonessentialEnabled) + } }