commit 859dd17ed338c318aa0442e7759454741d034bbd
parent 2f7d2f98e4bd2899ed3c6e47b9281e3d4d4464e9
Author: triesap <tyson@radroots.org>
Date: Tue, 11 Aug 2026 17:01:38 +0000
ui: implement foundation settings
- Add session-local appearance controls for theme, text size, and motion.
- Render actual build, FFI, storage, and source provenance with safe unknowns.
- Open only fixed source and licence coordinates through typed desktop actions.
- Verify settings, link mapping, formatting, Rust, and desktop test suites.
Diffstat:
7 files changed, 324 insertions(+), 2 deletions(-)
diff --git a/app/desktop/src/main/kotlin/org/harvestcircle/application/DesktopProjectLinks.kt b/app/desktop/src/main/kotlin/org/harvestcircle/application/DesktopProjectLinks.kt
@@ -0,0 +1,21 @@
+package org.harvestcircle.application
+
+import org.harvestcircle.ui.shell.HarvestCircleProjectLinks
+import java.awt.Desktop
+import java.net.URI
+
+internal enum class ProjectLink { Source, Licence }
+
+internal fun projectLinkUri(link: ProjectLink): URI =
+ URI.create(
+ when (link) {
+ ProjectLink.Source -> HarvestCircleProjectLinks.SOURCE
+ ProjectLink.Licence -> HarvestCircleProjectLinks.LICENCE
+ },
+ )
+
+internal fun openProjectLink(link: ProjectLink) {
+ if (Desktop.isDesktopSupported() && Desktop.getDesktop().isSupported(Desktop.Action.BROWSE)) {
+ Desktop.getDesktop().browse(projectLinkUri(link))
+ }
+}
diff --git a/app/desktop/src/main/kotlin/org/harvestcircle/application/HarvestCircleApplication.kt b/app/desktop/src/main/kotlin/org/harvestcircle/application/HarvestCircleApplication.kt
@@ -126,7 +126,12 @@ internal fun HarvestCircleApplicationWithDependencies(
HarvestCircleShell(
state = shellState,
identityActions = identityActions,
- platformActions = HarvestCirclePlatformActions(copySecret = clipboard::copy),
+ platformActions =
+ HarvestCirclePlatformActions(
+ copySecret = clipboard::copy,
+ openSource = { openProjectLink(ProjectLink.Source) },
+ openLicence = { openProjectLink(ProjectLink.Licence) },
+ ),
dispatch = shellPresenter::dispatch,
)
}
diff --git a/app/desktop/src/test/kotlin/org/harvestcircle/application/DesktopProjectLinksTest.kt b/app/desktop/src/test/kotlin/org/harvestcircle/application/DesktopProjectLinksTest.kt
@@ -0,0 +1,13 @@
+package org.harvestcircle.application
+
+import org.harvestcircle.ui.shell.HarvestCircleProjectLinks
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+class DesktopProjectLinksTest {
+ @Test
+ fun projectLinksAreFixedAndCannotAcceptArbitraryUris() {
+ assertEquals(HarvestCircleProjectLinks.SOURCE, projectLinkUri(ProjectLink.Source).toString())
+ assertEquals(HarvestCircleProjectLinks.LICENCE, projectLinkUri(ProjectLink.Licence).toString())
+ }
+}
diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/identities/ui/HarvestCircleScreen.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/identities/ui/HarvestCircleScreen.kt
@@ -61,6 +61,8 @@ data class HarvestCircleUiActions(
data class HarvestCirclePlatformActions(
val copySecret: (String) -> Unit = {},
+ val openSource: () -> Unit = {},
+ val openLicence: () -> Unit = {},
)
@Composable
diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/FoundationSettingsScreen.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/FoundationSettingsScreen.kt
@@ -0,0 +1,180 @@
+package org.harvestcircle.ui.shell
+
+import androidx.compose.foundation.layout.Arrangement
+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.ui.Modifier
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.unit.dp
+import org.harvestcircle.application.BuildDirtyState
+import org.harvestcircle.application.BuildInfo
+import org.harvestcircle.design.AppearanceState
+import org.harvestcircle.design.MotionPreference
+import org.harvestcircle.design.TextSizePreference
+import org.harvestcircle.design.ThemePreference
+import org.harvestcircle.identities.ui.HarvestCirclePlatformActions
+import org.harvestcircle.navigation.SettingsSection
+
+object HarvestCircleProjectLinks {
+ const val SOURCE = "https://github.com/radrootslabs/studio_app"
+ const val LICENCE = "https://github.com/radrootslabs/studio_app/blob/dev/LICENSE"
+}
+
+data class FoundationSettingsActions(
+ val selectSection: (SettingsSection) -> Unit,
+ val setTheme: (ThemePreference) -> Unit,
+ val setTextSize: (TextSizePreference) -> Unit,
+ val setMotion: (MotionPreference) -> Unit,
+)
+
+@Composable
+fun FoundationSettingsScreen(
+ section: SettingsSection,
+ appearance: AppearanceState,
+ buildInfo: BuildInfo,
+ actions: FoundationSettingsActions,
+ platformActions: HarvestCirclePlatformActions,
+) {
+ val tabs =
+ listOf(
+ TemplateTab(TemplateSelectionKey("appearance"), "Appearance"),
+ TemplateTab(TemplateSelectionKey("project"), "Project"),
+ )
+ val selected = if (section == SettingsSection.Appearance) tabs[0].key else tabs[1].key
+ TabbedDetailTemplate(
+ tabs = tabs,
+ selected = selected,
+ tabRail = { available, current ->
+ Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
+ available.forEach { tab ->
+ ShellAction(
+ tab.label,
+ "Show ${tab.label} settings",
+ "settings-${tab.key.value}",
+ enabled = tab.key != current,
+ ) {
+ actions.selectSection(
+ if (tab.key.value == "appearance") SettingsSection.Appearance else SettingsSection.Project,
+ )
+ }
+ }
+ }
+ },
+ detail = {
+ when (section) {
+ SettingsSection.Appearance -> AppearanceSettings(appearance, actions)
+ SettingsSection.Project -> ProjectSettings(buildInfo, platformActions)
+ }
+ },
+ )
+}
+
+@Composable
+private fun AppearanceSettings(
+ appearance: AppearanceState,
+ actions: FoundationSettingsActions,
+) {
+ Column(
+ Modifier.testTag("settings-appearance-panel"),
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ ) {
+ BasicText("Theme")
+ OptionRow(
+ listOf(ThemePreference.System, ThemePreference.Light, ThemePreference.Dark),
+ appearance.theme,
+ "theme",
+ actions.setTheme,
+ )
+ BasicText("Text size")
+ OptionRow(
+ listOf(TextSizePreference.Default, TextSizePreference.Large, TextSizePreference.VeryLarge),
+ appearance.textSize,
+ "text-size",
+ actions.setTextSize,
+ )
+ BasicText("Motion")
+ OptionRow(
+ listOf(MotionPreference.Standard, MotionPreference.Reduced),
+ appearance.motion,
+ "motion",
+ actions.setMotion,
+ )
+ }
+}
+
+@Composable
+private fun <T : Enum<T>> OptionRow(
+ values: List<T>,
+ selected: T,
+ tagPrefix: String,
+ select: (T) -> Unit,
+) {
+ Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
+ values.forEach { value ->
+ val label = value.label()
+ ShellAction(
+ label,
+ "Select $label",
+ "$tagPrefix-${value.name.lowercase()}",
+ enabled = value != selected,
+ ) { select(value) }
+ }
+ }
+}
+
+@Composable
+private fun ProjectSettings(
+ build: BuildInfo,
+ platformActions: HarvestCirclePlatformActions,
+) {
+ Column(
+ Modifier.testTag("settings-project-panel"),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ ProjectFact("HarvestCircle version", build.productVersion)
+ ProjectFact("Source commit", build.sourceCommit)
+ ProjectFact("Source state", build.sourceDirty.label())
+ ProjectFact("Radroots revision", build.radrootsRevision)
+ ProjectFact("Kotlin", build.kotlinToolchain)
+ ProjectFact("Compose Multiplatform", build.composeMultiplatformVersion)
+ ProjectFact("Rust", build.rustToolchain)
+ ProjectFact("Java", build.javaToolchain)
+ ProjectFact("FFI contract", "${build.ffiContractId} ${build.ffiContractMajor}.${build.ffiContractMinor}")
+ ProjectFact("FFI hash", build.ffiContractHash)
+ ProjectFact(
+ "Storage schema",
+ "${build.minimumStorageSchemaVersion}..${build.currentStorageSchemaVersion}",
+ )
+ ProjectFact("Licence", "GPL-3.0-only")
+ Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
+ ShellAction("Source", "Open HarvestCircle source", "project-open-source", onClick = platformActions.openSource)
+ ShellAction(
+ "Licence",
+ "Open HarvestCircle licence",
+ "project-open-licence",
+ onClick = platformActions.openLicence,
+ )
+ }
+ }
+}
+
+@Composable
+private fun ProjectFact(
+ label: String,
+ value: String,
+) {
+ Column {
+ BasicText(label)
+ BasicText(value, Modifier.testTag("project-${label.lowercase().replace(' ', '-')}"))
+ }
+}
+
+private fun Enum<*>.label(): String =
+ when (this) {
+ TextSizePreference.VeryLarge -> "Very large"
+ else -> name.lowercase().replaceFirstChar(Char::uppercaseChar)
+ }
+
+private fun BuildDirtyState.label(): String = name.lowercase().replaceFirstChar(Char::uppercaseChar)
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
@@ -64,7 +64,7 @@ fun HarvestCircleShell(
onReadOnly = { dispatch(HarvestCircleShellIntent.EnterReadOnly) },
)
}
- is ShellRoot.Dashboard -> DashboardRoot(state, root, dispatch)
+ is ShellRoot.Dashboard -> DashboardRoot(state, root, platformActions, dispatch)
}
FoundationOverlayHost(state.overlays) { dispatch(HarvestCircleShellIntent.Overlay(it)) }
}
@@ -121,6 +121,7 @@ private fun BootstrapWelcome(dispatch: (HarvestCircleShellIntent) -> Unit) {
private fun DashboardRoot(
state: HarvestCircleShellState,
root: ShellRoot.Dashboard,
+ platformActions: HarvestCirclePlatformActions,
dispatch: (HarvestCircleShellIntent) -> Unit,
) {
val route = root.navigation.current
@@ -162,6 +163,26 @@ private fun DashboardRoot(
},
)
AppRoute.Network -> FoundationNetworkScreen(foundationNetworkModel(state))
+ is AppRoute.Settings ->
+ FoundationSettingsScreen(
+ section = route.section,
+ appearance = state.appearance,
+ buildInfo = state.buildInfo,
+ actions =
+ FoundationSettingsActions(
+ selectSection = {
+ dispatch(
+ HarvestCircleShellIntent.Navigation(
+ NavigationIntent.Navigate(AppRoute.Settings(it)),
+ ),
+ )
+ },
+ setTheme = { dispatch(HarvestCircleShellIntent.SetTheme(it)) },
+ setTextSize = { dispatch(HarvestCircleShellIntent.SetTextSize(it)) },
+ setMotion = { dispatch(HarvestCircleShellIntent.SetMotion(it)) },
+ ),
+ platformActions = platformActions,
+ )
else -> BasicText(route.title(), Modifier.testTag("foundation-route-body"))
}
},
diff --git a/app/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/FoundationSettingsScreenTest.kt b/app/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/FoundationSettingsScreenTest.kt
@@ -0,0 +1,80 @@
+package org.harvestcircle.ui.shell
+
+import androidx.compose.ui.test.ExperimentalTestApi
+import androidx.compose.ui.test.assertCountEquals
+import androidx.compose.ui.test.assertTextEquals
+import androidx.compose.ui.test.onAllNodesWithText
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.onNodeWithText
+import androidx.compose.ui.test.performClick
+import androidx.compose.ui.test.v2.runComposeUiTest
+import org.harvestcircle.application.BuildInfo
+import org.harvestcircle.design.AppearanceState
+import org.harvestcircle.design.MotionPreference
+import org.harvestcircle.design.TextSizePreference
+import org.harvestcircle.design.ThemePreference
+import org.harvestcircle.identities.ui.HarvestCirclePlatformActions
+import org.harvestcircle.navigation.SettingsSection
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+@OptIn(ExperimentalTestApi::class)
+class FoundationSettingsScreenTest {
+ @Test
+ fun appearanceDispatchesSessionLocalTypedChoices() =
+ runComposeUiTest {
+ val selectedSections = mutableListOf<SettingsSection>()
+ val themes = mutableListOf<ThemePreference>()
+ val textSizes = mutableListOf<TextSizePreference>()
+ val motions = mutableListOf<MotionPreference>()
+ setContent {
+ FoundationSettingsScreen(
+ SettingsSection.Appearance,
+ AppearanceState(),
+ BuildInfo.unknown(),
+ FoundationSettingsActions(selectedSections::add, themes::add, textSizes::add, motions::add),
+ HarvestCirclePlatformActions(),
+ )
+ }
+
+ onNodeWithTag("theme-dark").performClick()
+ onNodeWithTag("text-size-verylarge").performClick()
+ onNodeWithTag("motion-reduced").performClick()
+ onNodeWithTag("settings-project").performClick()
+ assertEquals(listOf(ThemePreference.Dark), themes)
+ assertEquals(listOf(TextSizePreference.VeryLarge), textSizes)
+ assertEquals(listOf(MotionPreference.Reduced), motions)
+ assertEquals(listOf(SettingsSection.Project), selectedSections)
+ }
+
+ @Test
+ fun projectShowsActualUnknownProvenanceAndFixedTypedActions() =
+ runComposeUiTest {
+ var source = 0
+ var licence = 0
+ setContent {
+ FoundationSettingsScreen(
+ SettingsSection.Project,
+ AppearanceState(),
+ BuildInfo.unknown(),
+ FoundationSettingsActions({}, {}, {}, {}),
+ HarvestCirclePlatformActions(
+ openSource = { source += 1 },
+ openLicence = { licence += 1 },
+ ),
+ )
+ }
+
+ onNodeWithTag("project-source-commit").assertTextEquals("unknown")
+ onNodeWithTag("project-source-state").assertTextEquals("Unknown")
+ onNodeWithTag("project-ffi-contract").assertTextEquals("unknown 0.0")
+ onNodeWithTag("project-storage-schema").assertTextEquals("0..0")
+ onNodeWithText("GPL-3.0-only").assertExists()
+ onNodeWithTag("project-open-source").performClick()
+ onNodeWithTag("project-open-licence").performClick()
+ assertEquals(1, source)
+ assertEquals(1, licence)
+ onAllNodesWithText("Locality").assertCountEquals(0)
+ onAllNodesWithText("Delete data").assertCountEquals(0)
+ }
+}