app

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

commit ddad30f5016260f3c8d6b4966f2c0e22a6abb78f
parent 6892dc9344c517285897f1db619ffe1cd286a041
Author: triesap <tyson@radroots.org>
Date:   Tue, 11 Aug 2026 21:45:19 +0000

ui: make the shell responsive

- derive inspector placement reactively from live Compose constraints
- keep Network and Settings content reachable in typed bounded panes
- anchor Settings in the sidebar bottom region beneath flexible space
- verify threshold recomposition, internal scrolling, and fixed shell chrome

Diffstat:
Mapp/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/DashboardScaffold.kt | 37++++++++++++++++++++-----------------
Mapp/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/FoundationNetworkScreen.kt | 1+
Mapp/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/FoundationSettingsScreen.kt | 1+
Mapp/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/HarvestCircleShell.kt | 1-
Mapp/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/MainPanelTemplates.kt | 15++++++++++++++-
Mapp/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/WorkspaceSidebar.kt | 6+++++-
Mapp/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/DashboardScaffoldTest.kt | 19++++++++++++++++++-
Mapp/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/FoundationNetworkScreenTest.kt | 1+
Mapp/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/FoundationSettingsScreenTest.kt | 1+
Mapp/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/WorkspaceSidebarTest.kt | 5+++++
10 files changed, 66 insertions(+), 21 deletions(-)

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 @@ -2,6 +2,7 @@ package org.harvestcircle.ui.shell import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxHeight @@ -15,10 +16,10 @@ import androidx.compose.ui.platform.testTag import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp +import kotlin.math.roundToInt @Composable fun DashboardScaffold( - windowWidthDp: Int, inspectorVisible: Boolean, topBar: @Composable () -> Unit, sidebar: @Composable () -> Unit, @@ -26,24 +27,26 @@ fun DashboardScaffold( mainBody: @Composable () -> Unit, inspector: @Composable () -> Unit = {}, ) { - val placement = inspectorPlacement(windowWidthDp, inspectorVisible) - 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) - Column(Modifier.weight(1f).fillMaxHeight()) { - Region("dashboard-main-header", "Main panel header", Modifier.fillMaxWidth().height(56.dp), mainHeader) - Region("dashboard-main-body", "Main panel body", Modifier.fillMaxSize(), mainBody) - } - if (placement == InspectorPlacement.Beside) { - Region("dashboard-inspector-beside", "Inspector", Modifier.width(400.dp).fillMaxHeight(), inspector) + BoxWithConstraints(Modifier.fillMaxSize().testTag("dashboard-scaffold")) { + val placement = inspectorPlacement(maxWidth.value.roundToInt(), inspectorVisible) + val palette = LocalHarvestCirclePalette.current + Column(Modifier.fillMaxSize().background(palette.background.toComposeColor())) { + 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) + Column(Modifier.weight(1f).fillMaxHeight()) { + Region("dashboard-main-header", "Main panel header", Modifier.fillMaxWidth().height(56.dp), mainHeader) + Region("dashboard-main-body", "Main panel body", Modifier.fillMaxSize(), mainBody) + } + if (placement == InspectorPlacement.Beside) { + Region("dashboard-inspector-beside", "Inspector", Modifier.width(400.dp).fillMaxHeight(), inspector) + } } } - } - if (placement == InspectorPlacement.Overlay) { - Box(Modifier.fillMaxSize(), contentAlignment = androidx.compose.ui.Alignment.CenterEnd) { - Region("dashboard-inspector-overlay", "Inspector", Modifier.width(360.dp).fillMaxHeight(), inspector) + if (placement == InspectorPlacement.Overlay) { + Box(Modifier.fillMaxSize(), contentAlignment = androidx.compose.ui.Alignment.CenterEnd) { + Region("dashboard-inspector-overlay", "Inspector", Modifier.width(360.dp).fillMaxHeight(), inspector) + } } } } diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/FoundationNetworkScreen.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/FoundationNetworkScreen.kt @@ -103,6 +103,7 @@ fun FoundationNetworkScreen( } } }, + detailPane = DetailPaneKind.Network, detail = { selection -> NetworkDetail(selection, model, refreshProfile, signOut) }, ) } 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 @@ -61,6 +61,7 @@ fun FoundationSettingsScreen( } } }, + detailPane = DetailPaneKind.Settings, detail = { when (section) { SettingsSection.Appearance -> AppearanceSettings(appearance, actions) 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 @@ -145,7 +145,6 @@ private fun DashboardRoot( val route = root.navigation.current val status = deriveShellStatus(state) DashboardScaffold( - windowWidthDp = ShellDimensions.PREFERRED_WINDOW_WIDTH_DP, inspectorVisible = false, topBar = { GlobalTopBar( diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/MainPanelTemplates.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/MainPanelTemplates.kt @@ -31,6 +31,8 @@ data class TemplateTab( } } +enum class DetailPaneKind { Network, Settings } + @Composable fun SingleFocusTemplate(content: @Composable () -> Unit) { Box(Modifier.fillMaxSize().testTag("template-single-focus")) { content() } @@ -61,13 +63,24 @@ fun TabbedDetailTemplate( tabs: List<TemplateTab>, selected: TemplateSelectionKey, tabRail: @Composable (List<TemplateTab>, TemplateSelectionKey) -> Unit, + detailPane: DetailPaneKind? = null, detail: @Composable (TemplateSelectionKey) -> Unit, ) { require(tabs.map(TemplateTab::key).distinct().size == tabs.size) require(tabs.any { it.key == selected }) Column(Modifier.fillMaxSize().testTag("template-tabbed-detail")) { Box(Modifier.testTag("template-tabs")) { tabRail(tabs, selected) } - Box(Modifier.weight(1f).testTag("template-tab-detail")) { detail(selected) } + val detailModifier = + Modifier + .weight(1f) + .then( + if (detailPane != null) { + Modifier.verticalScroll(rememberScrollState()).testTag("bounded-detail-${detailPane.name.lowercase()}") + } else { + Modifier + }, + ).testTag("template-tab-detail") + Box(detailModifier) { detail(selected) } } } diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/WorkspaceSidebar.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/WorkspaceSidebar.kt @@ -1,6 +1,8 @@ package org.harvestcircle.ui.shell import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier @@ -16,7 +18,7 @@ fun WorkspaceSidebar( selected: ScreenKey, onScreen: (ScreenKey) -> Unit, ) { - Column(Modifier.testTag("workspace-sidebar")) { + Column(Modifier.fillMaxHeight().testTag("workspace-sidebar")) { ShellText("Workspace", Modifier.testTag("workspace-label"), ShellTextRole.SectionTitle) ShellText("Personal", Modifier.testTag("workspace-personal"), ShellTextRole.Secondary) ShellDivider() @@ -24,6 +26,8 @@ fun WorkspaceSidebar( SidebarItem(item, selected == item.screenKey, onScreen) } DisabledWorkspaceAction() + Spacer(Modifier.weight(1f)) + ShellDivider() SidebarItem(shellSettingsItem, selected == shellSettingsItem.screenKey, onScreen) } } diff --git a/app/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/DashboardScaffoldTest.kt b/app/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/DashboardScaffoldTest.kt @@ -2,9 +2,14 @@ package org.harvestcircle.ui.shell import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.requiredSize +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertCountEquals import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.onAllNodesWithTag import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.v2.runComposeUiTest import androidx.compose.ui.unit.dp @@ -30,13 +35,25 @@ class DashboardScaffoldTest { onNodeWithTag("dashboard-inspector-overlay").assertIsDisplayed() onNodeWithTag("dashboard-main-body").assertIsDisplayed() } + + @Test + fun liveConstraintChangesMoveTheInspectorWithoutRecreatingTheShell() = + runComposeUiTest { + var width by mutableStateOf(1280) + setContent { dashboard(width) } + onAllNodesWithTag("dashboard-inspector-beside").assertCountEquals(1) + + width = 1100 + waitForIdle() + onAllNodesWithTag("dashboard-inspector-beside").assertCountEquals(0) + onAllNodesWithTag("dashboard-inspector-overlay").assertCountEquals(1) + } } @androidx.compose.runtime.Composable private fun dashboard(width: Int) { Box(Modifier.requiredSize(width.dp, 720.dp)) { DashboardScaffold( - windowWidthDp = width, inspectorVisible = true, topBar = {}, sidebar = {}, diff --git a/app/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/FoundationNetworkScreenTest.kt b/app/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/FoundationNetworkScreenTest.kt @@ -17,6 +17,7 @@ class FoundationNetworkScreenTest { fun overviewIsTruthfulAndClaimsNoManagedService() = runComposeUiTest { setContent { FoundationNetworkScreen(model()) } + onNodeWithTag("bounded-detail-network").assertExists() onNodeWithText("Signer").assertExists() onNodeWithText("Local identity active").assertExists() onNodeWithText("No managed HarvestCircle service is configured.").assertExists() 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 @@ -37,6 +37,7 @@ class FoundationSettingsScreenTest { ) } + onNodeWithTag("bounded-detail-settings").assertExists() onNodeWithTag("theme-dark").performClick() onNodeWithTag("text-size-verylarge").performClick() onNodeWithTag("motion-reduced").performClick() diff --git a/app/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/WorkspaceSidebarTest.kt b/app/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/WorkspaceSidebarTest.kt @@ -10,6 +10,7 @@ import androidx.compose.ui.test.v2.runComposeUiTest import org.harvestcircle.product.ScreenKey import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertTrue @OptIn(ExperimentalTestApi::class) class WorkspaceSidebarTest { @@ -25,6 +26,10 @@ class WorkspaceSidebarTest { onNodeWithText("Explore").performClick() onNodeWithText("Network").performClick() onNodeWithText("Settings").performClick() + assertTrue( + onNodeWithTag("sidebar-Settings").fetchSemanticsNode().boundsInRoot.top > + onNodeWithTag("sidebar-add-farm").fetchSemanticsNode().boundsInRoot.bottom, + ) assertEquals(listOf(ScreenKey.Network, ScreenKey.Settings), selected) } }