app

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

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:
Aapp/desktop/src/main/kotlin/org/harvestcircle/application/DesktopProjectLinks.kt | 21+++++++++++++++++++++
Mapp/desktop/src/main/kotlin/org/harvestcircle/application/HarvestCircleApplication.kt | 7++++++-
Aapp/desktop/src/test/kotlin/org/harvestcircle/application/DesktopProjectLinksTest.kt | 13+++++++++++++
Mapp/shared/src/commonMain/kotlin/org/harvestcircle/identities/ui/HarvestCircleScreen.kt | 2++
Aapp/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/FoundationSettingsScreen.kt | 180+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mapp/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/HarvestCircleShell.kt | 23++++++++++++++++++++++-
Aapp/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/FoundationSettingsScreenTest.kt | 80+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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) + } +}