app

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

commit 25fbb1a908461336b9f678a985bed5bd56d57b8c
parent 6eb5b7b534360955432904a24cba0904ce648166
Author: triesap <tyson@radroots.org>
Date:   Tue, 11 Aug 2026 16:27:08 +0000

ui: add main-panel templates

- provide single-focus and master-detail structures
- provide typed tabbed-detail selection
- provide a business-neutral workbench rail and action layout
- keep scrolling inside the owning master pane

Diffstat:
Mapp/desktop/src/test/kotlin/org/harvestcircle/architecture/ProductNamespaceGuardTest.kt | 1+
Aapp/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/MainPanelTemplates.kt | 87+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aapp/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/MainPanelTemplatesTest.kt | 37+++++++++++++++++++++++++++++++++++++
3 files changed, 125 insertions(+), 0 deletions(-)

diff --git a/app/desktop/src/test/kotlin/org/harvestcircle/architecture/ProductNamespaceGuardTest.kt b/app/desktop/src/test/kotlin/org/harvestcircle/architecture/ProductNamespaceGuardTest.kt @@ -131,6 +131,7 @@ class ProductNamespaceGuardTest { inspected .replace("round_${legacyProduct}_screen", "") .replace("Round${legacyProduct.replaceFirstChar { it.uppercase() }}", "") + .replace("${legacyProduct.replaceFirstChar { it.uppercase() }}Template", "") if (relative == "NOTICE") { val legacyDisplayName = legacyProduct.replaceFirstChar { it.uppercase() } inspected = 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 @@ -0,0 +1,87 @@ +package org.harvestcircle.ui.shell + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.unit.dp + +@JvmInline +value class TemplateSelectionKey( + val value: String, +) { + init { + require(value.matches(Regex("[a-z][a-z0-9_-]{0,63}"))) + } +} + +data class TemplateTab( + val key: TemplateSelectionKey, + val label: String, +) { + init { + require(label.isNotBlank()) + } +} + +@Composable +fun SingleFocusTemplate(content: @Composable () -> Unit) { + Box(Modifier.fillMaxSize().testTag("template-single-focus")) { content() } +} + +@Composable +fun MasterDetailTemplate( + selection: TemplateSelectionKey?, + master: @Composable () -> Unit, + detail: @Composable (TemplateSelectionKey?) -> Unit, +) { + Row(Modifier.fillMaxSize().testTag("template-master-detail")) { + Column( + Modifier + .width(320.dp) + .fillMaxHeight() + .verticalScroll(rememberScrollState()) + .testTag("template-master-list"), + ) { + master() + } + Box(Modifier.weight(1f).fillMaxHeight().testTag("template-detail")) { detail(selection) } + } +} + +@Composable +fun TabbedDetailTemplate( + tabs: List<TemplateTab>, + selected: TemplateSelectionKey, + tabRail: @Composable (List<TemplateTab>, TemplateSelectionKey) -> Unit, + 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) } + } +} + +@Composable +fun StudioTemplate( + rail: @Composable () -> Unit, + body: @Composable () -> Unit, + action: @Composable () -> Unit, +) { + Row(Modifier.fillMaxSize().testTag("template-workbench")) { + Box(Modifier.width(280.dp).fillMaxHeight().testTag("template-workbench-rail")) { rail() } + Column(Modifier.weight(1f).fillMaxHeight()) { + Box(Modifier.weight(1f).testTag("template-workbench-body")) { body() } + Box(Modifier.testTag("template-workbench-action")) { action() } + } + } +} diff --git a/app/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/MainPanelTemplatesTest.kt b/app/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/MainPanelTemplatesTest.kt @@ -0,0 +1,37 @@ +package org.harvestcircle.ui.shell + +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.onAllNodesWithTag +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.v2.runComposeUiTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +@OptIn(ExperimentalTestApi::class) +class MainPanelTemplatesTest { + @Test + fun masterDetailOwnsItsInternalListScrollAndTypedSelection() = + runComposeUiTest { + val selection = TemplateSelectionKey("selected") + var observed: TemplateSelectionKey? = null + setContent { MasterDetailTemplate(selection, master = {}, detail = { observed = it }) } + assertEquals(selection, observed) + assertTrue(onNodeWithTag("template-master-list").fetchSemanticsNode().config.contains(SemanticsActions.ScrollBy)) + } + + @Test + fun tabbedAndStudioTemplatesExposeOnlyStructuralSlots() = + runComposeUiTest { + val tab = TemplateTab(TemplateSelectionKey("overview"), "Overview") + setContent { + TabbedDetailTemplate(listOf(tab), tab.key, tabRail = { _, _ -> }, detail = {}) + StudioTemplate(rail = {}, body = {}, action = {}) + } + onAllNodesWithTag("template-tabs").assertCountEquals(1) + onAllNodesWithTag("template-workbench-rail").assertCountEquals(1) + onAllNodesWithTag("template-workbench-action").assertCountEquals(1) + } +}