app

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

commit cec59ee494dabaa51e489d213bc30fd3a1790e4b
parent 1c39f050c41281351ae66ebf1c610af9efc1d836
Author: triesap <tyson@radroots.org>
Date:   Thu, 13 Aug 2026 17:30:38 +0000

ui: restore the approved Studio shell chrome

- compose the global bar with owned Studio-derived tabs
- rebuild navigation and the pinned session footer anatomy
- add owned route icons and responsive collapse behavior
- preserve disabled product availability and focus restoration

Diffstat:
Aapp/design_system/src/commonMain/composeResources/drawable/harvestcircle_activity.xml | 6++++++
Aapp/design_system/src/commonMain/composeResources/drawable/harvestcircle_network.xml | 6++++++
Aapp/design_system/src/commonMain/composeResources/drawable/harvestcircle_settings.xml | 6++++++
Aapp/design_system/src/commonMain/composeResources/drawable/harvestcircle_today.xml | 6++++++
Mapp/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/icon/HarvestCircleIcons.kt | 16++++++++++++++++
Mapp/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/DashboardScaffold.kt | 20+++++++++++++-------
Mapp/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/GlobalTopBar.kt | 141+++++++++++++++++++++++++++++++++++++++++++++----------------------------------
Mapp/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/HarvestCircleShell.kt | 23+++++++++++++++++++++--
Mapp/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/WorkspaceSidebar.kt | 203+++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------------
Mapp/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/DashboardScaffoldTest.kt | 4++--
Mapp/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/WorkspaceSidebarTest.kt | 9++++-----
11 files changed, 298 insertions(+), 142 deletions(-)

diff --git a/app/design_system/src/commonMain/composeResources/drawable/harvestcircle_activity.xml b/app/design_system/src/commonMain/composeResources/drawable/harvestcircle_activity.xml @@ -0,0 +1,6 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" android:height="24dp" + android:viewportWidth="24" android:viewportHeight="24"> + <path android:fillColor="#FF000000" + android:pathData="M4,18h3v-5H4zM10.5,18h3V6h-3zM17,18h3V9h-3z"/> +</vector> diff --git a/app/design_system/src/commonMain/composeResources/drawable/harvestcircle_network.xml b/app/design_system/src/commonMain/composeResources/drawable/harvestcircle_network.xml @@ -0,0 +1,6 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" android:height="24dp" + android:viewportWidth="24" android:viewportHeight="24"> + <path android:fillColor="#FF000000" + android:pathData="M8.1,13.9a4,4 0,0 1,0 -5.7l2.1,-2.1a4,4 0,0 1,5.7 5.7l-1.1,1.1l-1.4,-1.4l1.1,-1.1a2,2 0,1 0,-2.9 -2.9L9.5,9.6a2,2 0,0 0,0 2.9zM15.9,10.1a4,4 0,0 1,0 5.7l-2.1,2.1a4,4 0,0 1,-5.7 -5.7l1.1,-1.1l1.4,1.4l-1.1,1.1a2,2 0,1 0,2.9 2.9l2.1,-2.1a2,2 0,0 0,0 -2.9z"/> +</vector> diff --git a/app/design_system/src/commonMain/composeResources/drawable/harvestcircle_settings.xml b/app/design_system/src/commonMain/composeResources/drawable/harvestcircle_settings.xml @@ -0,0 +1,6 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" android:height="24dp" + android:viewportWidth="24" android:viewportHeight="24"> + <path android:fillColor="#FF000000" + android:pathData="M19.4,13a7.7,7.7 0,0 0,0 -2l2,-1.5l-2,-3.4l-2.4,1a7.2,7.2 0,0 0,-1.7 -1L13,3.5H9L8.7,6a7.2,7.2 0,0 0,-1.7 1l-2.4,-1l-2,3.4l2,1.5a7.7,7.7 0,0 0,0 2l-2,1.5l2,3.4l2.4,-1a7.2,7.2 0,0 0,1.7 1L9,20.5h4l0.3,-2.5a7.2,7.2 0,0 0,1.7 -1l2.4,1l2,-3.4zM12,10a2,2 0,1 0,0 4a2,2 0,0 0,0 -4z"/> +</vector> diff --git a/app/design_system/src/commonMain/composeResources/drawable/harvestcircle_today.xml b/app/design_system/src/commonMain/composeResources/drawable/harvestcircle_today.xml @@ -0,0 +1,6 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" android:height="24dp" + android:viewportWidth="24" android:viewportHeight="24"> + <path android:fillColor="#FF000000" + android:pathData="M3,3h8v8H3zM13,3h8v5h-8zM13,10h8v11h-8zM3,13h8v8H3z"/> +</vector> diff --git a/app/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/icon/HarvestCircleIcons.kt b/app/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/icon/HarvestCircleIcons.kt @@ -1,6 +1,7 @@ package org.harvestcircle.designsystem.icon import org.harvestcircle.designsystem.generated.resources.Res +import org.harvestcircle.designsystem.generated.resources.harvestcircle_activity import org.harvestcircle.designsystem.generated.resources.harvestcircle_add import org.harvestcircle.designsystem.generated.resources.harvestcircle_check import org.harvestcircle.designsystem.generated.resources.harvestcircle_chevron_down @@ -9,12 +10,27 @@ import org.harvestcircle.designsystem.generated.resources.harvestcircle_chevron_ import org.harvestcircle.designsystem.generated.resources.harvestcircle_close import org.harvestcircle.designsystem.generated.resources.harvestcircle_delete import org.harvestcircle.designsystem.generated.resources.harvestcircle_info +import org.harvestcircle.designsystem.generated.resources.harvestcircle_network import org.harvestcircle.designsystem.generated.resources.harvestcircle_search +import org.harvestcircle.designsystem.generated.resources.harvestcircle_settings +import org.harvestcircle.designsystem.generated.resources.harvestcircle_today import org.harvestcircle.designsystem.generated.resources.harvestcircle_warning import org.jetbrains.compose.resources.DrawableResource /** Stable names for icons owned by the HarvestCircle design system. */ public object HarvestCircleIcons { + public val Today: DrawableResource + get() = Res.drawable.harvestcircle_today + + public val Network: DrawableResource + get() = Res.drawable.harvestcircle_network + + public val Activity: DrawableResource + get() = Res.drawable.harvestcircle_activity + + public val Settings: DrawableResource + get() = Res.drawable.harvestcircle_settings + public val Add: DrawableResource get() = Res.drawable.harvestcircle_add 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 @@ -13,6 +13,7 @@ import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp import org.harvestcircle.designsystem.layout.HarvestCircleAppFrame +import org.harvestcircle.designsystem.layout.HarvestCircleFrameGeometry import org.harvestcircle.designsystem.layout.HarvestCirclePaneSlot import org.harvestcircle.designsystem.layout.HarvestCirclePaneWidth import org.harvestcircle.designsystem.primitive.HarvestCircleSurface @@ -22,8 +23,9 @@ import kotlin.math.roundToInt @Composable fun DashboardScaffold( inspectorVisible: Boolean, - topBar: @Composable () -> Unit, - sidebar: @Composable () -> Unit, + sidebarCollapsed: Boolean = false, + topBar: @Composable (HarvestCircleFrameGeometry) -> Unit, + sidebar: @Composable (HarvestCircleFrameGeometry) -> Unit, mainHeader: @Composable () -> Unit, mainBody: @Composable () -> Unit, inspector: @Composable () -> Unit = {}, @@ -44,12 +46,16 @@ fun DashboardScaffold( } HarvestCircleAppFrame( - sidebarCollapsed = false, - sidebar = { - DashboardRegion("dashboard-sidebar", "Workspace sidebar", Modifier.fillMaxSize(), sidebar) + sidebarCollapsed = sidebarCollapsed, + sidebar = { geometry -> + DashboardRegion("dashboard-sidebar", "Workspace sidebar", Modifier.fillMaxSize()) { + sidebar(geometry) + } }, - topBar = { - DashboardRegion("dashboard-top-bar", "Global top bar", Modifier.fillMaxSize(), topBar) + topBar = { geometry -> + DashboardRegion("dashboard-top-bar", "Global top bar", Modifier.fillMaxSize()) { + topBar(geometry) + } }, mainHeader = { DashboardRegion("dashboard-main-header", "Main panel header", Modifier.fillMaxSize(), mainHeader) 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 @@ -1,31 +1,37 @@ package org.harvestcircle.ui.shell +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag -import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.semantics import org.harvestcircle.application.ShellFocusTarget import org.harvestcircle.application.SignerStatusLabel import org.harvestcircle.application.SyncStatusLabel -import org.harvestcircle.designsystem.component.HarvestCircleButtonVariant import org.harvestcircle.designsystem.component.HarvestCircleControlSize -import org.harvestcircle.designsystem.component.HarvestCircleTextRole -import org.harvestcircle.designsystem.component.action.HarvestCircleButton -import org.harvestcircle.designsystem.component.action.HarvestCircleIconButton import org.harvestcircle.designsystem.component.menu.HarvestCircleMenuOption import org.harvestcircle.designsystem.component.menu.HarvestCirclePopupButton import org.harvestcircle.designsystem.icon.HarvestCircleIcons -import org.harvestcircle.designsystem.layout.HarvestCircleToolbar -import org.harvestcircle.designsystem.primitive.HarvestCircleText +import org.harvestcircle.designsystem.primitive.HarvestCircleIcon +import org.harvestcircle.designsystem.shell.HarvestCircleShellButton +import org.harvestcircle.designsystem.shell.HarvestCircleShellIconButton +import org.harvestcircle.designsystem.shell.HarvestCircleShellMetrics +import org.harvestcircle.designsystem.shell.HarvestCircleShellPalette +import org.harvestcircle.designsystem.shell.HarvestCircleShellTab +import org.harvestcircle.product.ScreenKey data class GlobalTopBarModel( val canGoBack: Boolean, val canGoForward: Boolean, val syncStatus: SyncStatusLabel, val signerStatus: SignerStatusLabel, + val selectedScreen: ScreenKey = ScreenKey.PersonalToday, ) enum class ApplicationMenuAction( @@ -42,6 +48,10 @@ sealed interface GlobalTopBarIntent { data object Forward : GlobalTopBarIntent + data object OpenToday : GlobalTopBarIntent + + data object OpenNetwork : GlobalTopBarIntent + data object OpenNostrReference : GlobalTopBarIntent data object ShowSyncStatus : GlobalTopBarIntent @@ -57,50 +67,83 @@ sealed interface GlobalTopBarIntent { fun GlobalTopBar( model: GlobalTopBarModel, onIntent: (GlobalTopBarIntent) -> Unit, + compact: Boolean = false, ) { - HarvestCircleToolbar(Modifier.fillMaxSize().testTag("global-top-bar")) { - HarvestCircleIconButton( + val colors = HarvestCircleShellPalette + Row( + Modifier + .fillMaxWidth() + .fillMaxHeight() + .background(colors.applicationFrame) + .padding(horizontal = HarvestCircleShellMetrics.topBarHorizontalInset) + .testTag("global-top-bar"), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(HarvestCircleShellMetrics.topBarControlGap), + ) { + HarvestCircleShellTab( + "Today", + model.selectedScreen == ScreenKey.PersonalToday, + { onIntent(GlobalTopBarIntent.OpenToday) }, + icon = HarvestCircleIcons.Today, + ) + HarvestCircleShellTab( + "Network", + model.selectedScreen == ScreenKey.Network, + { onIntent(GlobalTopBarIntent.OpenNetwork) }, + icon = HarvestCircleIcons.Network, + ) + HarvestCircleShellIconButton( onClick = { onIntent(GlobalTopBarIntent.Back) }, icon = HarvestCircleIcons.ChevronLeft, label = "Go back", modifier = Modifier.testTag("top-bar-back"), - size = HarvestCircleControlSize.Small, enabled = model.canGoBack, ) - HarvestCircleIconButton( + HarvestCircleShellIconButton( onClick = { onIntent(GlobalTopBarIntent.Forward) }, icon = HarvestCircleIcons.ChevronRight, label = "Go forward", modifier = Modifier.testTag("top-bar-forward"), - size = HarvestCircleControlSize.Small, enabled = model.canGoForward, ) - HarvestCircleButton( - onClick = { onIntent(GlobalTopBarIntent.OpenNostrReference) }, - modifier = - Modifier - .shellFocusTarget(ShellFocusTarget.TopBarReference) - .semantics { contentDescription = "Open a Nostr reference" } - .testTag("top-bar-open-reference"), - variant = HarvestCircleButtonVariant.Ghost, - size = HarvestCircleControlSize.Small, - leadingIcon = { - org.harvestcircle.designsystem.primitive.HarvestCircleIcon( - resource = HarvestCircleIcons.Search, - contentDescription = null, - ) - }, - ) { - HarvestCircleText("Open a Nostr reference", role = HarvestCircleTextRole.Label) - } - Spacer(Modifier.weight(1f)) - StatusAction(model.syncStatus.text, "Sync status", "top-bar-sync") { - onIntent(GlobalTopBarIntent.ShowSyncStatus) - } - StatusAction(model.signerStatus.text, "Signer status", "top-bar-signer") { - onIntent(GlobalTopBarIntent.ShowSignerStatus) + if (compact) { + HarvestCircleShellIconButton( + onClick = { onIntent(GlobalTopBarIntent.OpenNostrReference) }, + icon = HarvestCircleIcons.Search, + label = "Open a Nostr reference", + modifier = + Modifier + .shellFocusTarget(ShellFocusTarget.TopBarReference) + .testTag("top-bar-open-reference"), + ) + } else { + HarvestCircleShellButton( + text = "Open a Nostr reference", + onClick = { onIntent(GlobalTopBarIntent.OpenNostrReference) }, + modifier = + Modifier + .shellFocusTarget(ShellFocusTarget.TopBarReference) + .testTag("top-bar-open-reference"), + leadingContent = { + HarvestCircleIcon( + HarvestCircleIcons.Search, + null, + tint = colors.contentSecondary, + ) + }, + ) } + HarvestCircleShellButton( + model.syncStatus.text, + { onIntent(GlobalTopBarIntent.ShowSyncStatus) }, + Modifier.shellFocusTarget(ShellFocusTarget.TopBarSync).testTag("top-bar-sync"), + ) + HarvestCircleShellButton( + model.signerStatus.text, + { onIntent(GlobalTopBarIntent.ShowSignerStatus) }, + Modifier.shellFocusTarget(ShellFocusTarget.TopBarSigner).testTag("top-bar-signer"), + ) HarvestCirclePopupButton( selectedValue = ApplicationMenuAction.Settings, options = ApplicationMenuAction.entries.map { HarvestCircleMenuOption(it, it.label) }, @@ -112,25 +155,3 @@ fun GlobalTopBar( ) } } - -@Composable -private fun StatusAction( - label: String, - description: String, - tag: String, - onClick: () -> Unit, -) { - HarvestCircleButton( - onClick = onClick, - modifier = - Modifier - .shellFocusTarget( - if (tag == "top-bar-sync") ShellFocusTarget.TopBarSync else ShellFocusTarget.TopBarSigner, - ).semantics { contentDescription = description } - .testTag(tag), - variant = HarvestCircleButtonVariant.Ghost, - size = HarvestCircleControlSize.Small, - ) { - HarvestCircleText(label, role = HarvestCircleTextRole.Label) - } -} 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 @@ -8,7 +8,11 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag import androidx.compose.ui.semantics.clearAndSetSemantics @@ -25,6 +29,7 @@ import org.harvestcircle.application.deriveShellStatus import org.harvestcircle.designsystem.component.HarvestCircleContentTone import org.harvestcircle.designsystem.component.HarvestCircleTextRole import org.harvestcircle.designsystem.component.action.HarvestCircleLabeledButton +import org.harvestcircle.designsystem.layout.HarvestCircleFrameLayoutClass import org.harvestcircle.designsystem.primitive.HarvestCircleText import org.harvestcircle.identities.ui.HarvestCirclePlatformActions import org.harvestcircle.identities.ui.HarvestCircleUiActions @@ -191,9 +196,11 @@ private fun DashboardRoot( ) { val route = root.navigation.current val status = deriveShellStatus(state) + var sidebarCollapsed by rememberSaveable { mutableStateOf(false) } DashboardScaffold( inspectorVisible = false, - topBar = { + sidebarCollapsed = sidebarCollapsed, + topBar = { geometry -> GlobalTopBar( model = GlobalTopBarModel( @@ -201,11 +208,21 @@ private fun DashboardRoot( canGoForward = root.navigation.forwardStack.isNotEmpty(), syncStatus = status.sync, signerStatus = status.signer, + selectedScreen = route.screenKey, ), onIntent = { intent -> dispatchTopBar(intent, platformActions, dispatch) }, + compact = geometry.layoutClass != HarvestCircleFrameLayoutClass.Expanded, + ) + }, + sidebar = { geometry -> + WorkspaceSidebar( + selected = route.screenKey, + onScreen = { dispatch(HarvestCircleShellIntent.Navigate(it)) }, + compact = geometry.sidebarWidth == HarvestCircleDesignTokens.shell.frame.collapsedSidebarWidth, + onToggleCollapsed = { sidebarCollapsed = !sidebarCollapsed }, + sessionLabel = todayContext(state), ) }, - sidebar = { WorkspaceSidebar(route.screenKey) { dispatch(HarvestCircleShellIntent.Navigate(it)) } }, mainHeader = { MainPanelHeader(MainPanelHeaderModel(title = route.title())) }, mainBody = { RouteFocusTarget( @@ -294,6 +311,8 @@ private fun dispatchTopBar( when (intent) { GlobalTopBarIntent.Back -> dispatch(HarvestCircleShellIntent.Navigation(NavigationIntent.Back)) GlobalTopBarIntent.Forward -> dispatch(HarvestCircleShellIntent.Navigation(NavigationIntent.Forward)) + GlobalTopBarIntent.OpenToday -> dispatch(HarvestCircleShellIntent.Navigate(ScreenKey.PersonalToday)) + GlobalTopBarIntent.OpenNetwork -> dispatch(HarvestCircleShellIntent.Navigate(ScreenKey.Network)) GlobalTopBarIntent.OpenNostrReference -> dispatch(HarvestCircleShellIntent.Overlay(OverlayIntent.OpenReference(ShellFocusTarget.TopBarReference))) GlobalTopBarIntent.ShowSyncStatus -> 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,45 +1,144 @@ 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 import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier 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 org.harvestcircle.application.ShellNavigationItem import org.harvestcircle.application.addFarmWorkspaceAction import org.harvestcircle.application.shellNavigationItems import org.harvestcircle.application.shellSettingsItem -import org.harvestcircle.designsystem.component.HarvestCircleContentTone -import org.harvestcircle.designsystem.component.HarvestCircleTextRole -import org.harvestcircle.designsystem.component.navigation.HarvestCircleNavigationItem -import org.harvestcircle.designsystem.component.utility.HarvestCircleHorizontalDivider -import org.harvestcircle.designsystem.layout.HarvestCircleSidebar -import org.harvestcircle.designsystem.layout.HarvestCircleSidebarSectionHeader -import org.harvestcircle.designsystem.primitive.HarvestCircleText -import org.harvestcircle.designsystem.theme.HarvestCircleTheme +import org.harvestcircle.designsystem.icon.HarvestCircleIcons +import org.harvestcircle.designsystem.primitive.HarvestCircleIcon +import org.harvestcircle.designsystem.shell.HarvestCircleShellButton +import org.harvestcircle.designsystem.shell.HarvestCircleShellIconButton +import org.harvestcircle.designsystem.shell.HarvestCircleShellMetrics +import org.harvestcircle.designsystem.shell.HarvestCircleShellNavigationItem +import org.harvestcircle.designsystem.shell.HarvestCircleShellPalette +import org.harvestcircle.designsystem.shell.HarvestCircleShellText +import org.harvestcircle.designsystem.shell.HarvestCircleShellTextRole import org.harvestcircle.product.ScreenKey +import org.jetbrains.compose.resources.DrawableResource @Composable fun WorkspaceSidebar( selected: ScreenKey, onScreen: (ScreenKey) -> Unit, + compact: Boolean = false, + onToggleCollapsed: () -> Unit = {}, + sessionLabel: String = "Read-only", ) { - HarvestCircleSidebar(Modifier.fillMaxHeight().testTag("workspace-sidebar")) { - HarvestCircleSidebarSectionHeader("Navigation", Modifier.testTag("workspace-label")) - shellNavigationItems.forEach { item -> - SidebarItem(item, selected == item.screenKey, onScreen) + val colors = HarvestCircleShellPalette + Column( + Modifier + .fillMaxHeight() + .fillMaxWidth() + .background(colors.sidebar) + .testTag("workspace-sidebar"), + ) { + Row( + Modifier + .fillMaxWidth() + .height(HarvestCircleShellMetrics.sidebarHeaderHeight) + .padding(start = if (compact) HarvestCircleShellMetrics.sidebarHorizontalInset else 74.dp, end = 10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (!compact) { + HarvestCircleShellText("HarvestCircle", Modifier.weight(1f), HarvestCircleShellTextRole.PaneTitle) + } else { + Spacer(Modifier.weight(1f)) + } + HarvestCircleShellIconButton( + onClick = onToggleCollapsed, + icon = HarvestCircleIcons.ChevronLeft, + label = if (compact) "Expand sidebar" else "Collapse sidebar", + controlSize = HarvestCircleShellMetrics.sidebarHeaderIconTarget, + iconSize = HarvestCircleShellMetrics.sidebarHeaderIconSize, + ) + } + + Box(Modifier.fillMaxWidth().padding(horizontal = HarvestCircleShellMetrics.sidebarHorizontalInset)) { + HarvestCircleShellButton( + text = if (compact) "+" else addFarmWorkspaceAction.label, + onClick = {}, + modifier = + Modifier + .fillMaxWidth() + .testTag("sidebar-add-farm") + .semantics { + contentDescription = + "${addFarmWorkspaceAction.label}. ${addFarmWorkspaceAction.unavailableExplanation}" + }, + enabled = false, + ) + } + Spacer(Modifier.height(HarvestCircleShellMetrics.sidebarQuickActionBottomGap)) + + Column( + Modifier.weight(1f).fillMaxWidth().padding(horizontal = HarvestCircleShellMetrics.sidebarHorizontalInset), + ) { + if (!compact) SidebarSection("Personal") + shellNavigationItems.forEach { item -> + SidebarItem(item, selected == item.screenKey, compact, onScreen) + Spacer(Modifier.height(HarvestCircleShellMetrics.sidebarNavigationGap)) + } + Spacer(Modifier.weight(1f)) + SidebarItem(shellSettingsItem, selected == shellSettingsItem.screenKey, compact, onScreen) + Spacer(Modifier.height(8.dp)) } - HarvestCircleSidebarSectionHeader("Workspaces") - DisabledWorkspaceAction() - Spacer(Modifier.weight(1f)) - HarvestCircleHorizontalDivider() - SidebarItem(shellSettingsItem, selected == shellSettingsItem.screenKey, onScreen) + Box(Modifier.fillMaxWidth().height(HarvestCircleShellMetrics.structuralDividerWidth).background(colors.divider)) + Box(Modifier.fillMaxWidth().padding(HarvestCircleShellMetrics.sidebarFooterOuterInset)) { + Row( + Modifier + .fillMaxWidth() + .height(HarvestCircleShellMetrics.sidebarFooterHeight) + .background( + colors.raised, + androidx.compose.foundation.shape + .RoundedCornerShape(HarvestCircleShellMetrics.controlRadius), + ).padding(horizontal = 10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + HarvestCircleIcon( + HarvestCircleIcons.Network, + null, + Modifier.size(HarvestCircleShellMetrics.sidebarFooterIconSize), + tint = colors.contentMuted, + ) + if (!compact) { + Spacer(Modifier.width(8.dp)) + HarvestCircleShellText(sessionLabel, Modifier.weight(1f), HarvestCircleShellTextRole.Label, maxLines = 1) + } + } + } + } +} + +@Composable +private fun SidebarSection(label: String) { + Row( + Modifier + .fillMaxWidth() + .height(HarvestCircleShellMetrics.sidebarSectionHeaderHeight) + .padding(horizontal = HarvestCircleShellMetrics.sidebarNavigationHorizontalPadding), + verticalAlignment = Alignment.CenterVertically, + ) { + HarvestCircleShellText(label, role = HarvestCircleShellTextRole.Body, color = HarvestCircleShellPalette.contentSecondary) } } @@ -47,57 +146,29 @@ fun WorkspaceSidebar( private fun SidebarItem( item: ShellNavigationItem, selected: Boolean, + compact: Boolean, onScreen: (ScreenKey) -> Unit, ) { - val description = - if (item.enabled) { - item.label - } else { - "${item.label}. ${item.unavailableExplanation}" - } - Column { - HarvestCircleNavigationItem( - selected = selected, - onClick = { if (item.enabled && !selected) onScreen(item.screenKey) }, - label = item.label, - modifier = - Modifier - .fillMaxWidth() - .semantics { contentDescription = description } - .testTag("sidebar-${item.screenKey.name}"), - enabled = item.enabled, - ) - if (!item.enabled) { - HarvestCircleText( - text = requireNotNull(item.unavailableExplanation), - modifier = Modifier.padding(horizontal = HarvestCircleTheme.foundation.spacing.md), - role = HarvestCircleTextRole.LabelSmall, - tone = HarvestCircleContentTone.Muted, - ) - } - } + HarvestCircleShellNavigationItem( + label = item.label, + icon = item.screenKey.icon(), + selected = selected, + onClick = { if (item.enabled && !selected) onScreen(item.screenKey) }, + modifier = + Modifier + .semantics { + contentDescription = + if (item.enabled) item.label else "${item.label}. ${item.unavailableExplanation}" + }.testTag("sidebar-${item.screenKey.name}"), + enabled = item.enabled, + compact = compact, + ) } -@Composable -private fun DisabledWorkspaceAction() { - val action = addFarmWorkspaceAction - Column { - HarvestCircleNavigationItem( - selected = false, - onClick = {}, - label = action.label, - modifier = - Modifier - .fillMaxWidth() - .semantics { contentDescription = "${action.label}. ${action.unavailableExplanation}" } - .testTag("sidebar-add-farm"), - enabled = false, - ) - HarvestCircleText( - text = requireNotNull(action.unavailableExplanation), - modifier = Modifier.padding(horizontal = HarvestCircleTheme.foundation.spacing.md), - role = HarvestCircleTextRole.LabelSmall, - tone = HarvestCircleContentTone.Muted, - ) +private fun ScreenKey.icon(): DrawableResource = + when (this) { + ScreenKey.PersonalToday -> HarvestCircleIcons.Today + ScreenKey.Network -> HarvestCircleIcons.Network + ScreenKey.Settings -> HarvestCircleIcons.Settings + else -> HarvestCircleIcons.Activity } -} 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 @@ -55,8 +55,8 @@ private fun dashboard(width: Int) { Box(Modifier.requiredSize(width.dp, 720.dp)) { DashboardScaffold( inspectorVisible = true, - topBar = {}, - sidebar = {}, + topBar = { _ -> }, + sidebar = { _ -> }, mainHeader = {}, mainBody = {}, inspector = {}, 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 @@ -3,7 +3,6 @@ package org.harvestcircle.ui.shell import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.assertIsEnabled import androidx.compose.ui.test.assertIsNotEnabled -import androidx.compose.ui.test.assertIsNotSelected import androidx.compose.ui.test.assertIsSelected import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithTag @@ -23,13 +22,13 @@ class WorkspaceSidebarTest { val selected = mutableListOf<ScreenKey>() setHarvestCircleContent { WorkspaceSidebar(ScreenKey.PersonalToday, selected::add) } onNodeWithTag("sidebar-PersonalToday").assertIsSelected().assertIsEnabled().performClick() - onNodeWithText("Explore").assertIsNotEnabled().assertIsNotSelected() - onNodeWithText("Activity").assertIsNotEnabled().assertIsNotSelected() - onNodeWithText("Add a farm workspace").assertIsNotEnabled() + onNodeWithTag("sidebar-Explore").assertIsNotEnabled() + onNodeWithTag("sidebar-Activity").assertIsNotEnabled() + onNodeWithTag("sidebar-add-farm").assertIsNotEnabled() onNodeWithContentDescription("Explore. Not available in this build.").assertExists() onNodeWithContentDescription("Activity. Not available in this build.").assertExists() onNodeWithContentDescription("Add a farm workspace. Not available in this build.").assertExists() - onNodeWithText("Explore").performClick() + onNodeWithTag("sidebar-Explore").performClick() onNodeWithText("Network").performClick() onNodeWithText("Settings").performClick() assertTrue(