app

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

commit 901f1c9c738eeb27c0f254f37ca1313b64c3c0e7
parent 9fd327ff403d1ffbc686dcbc070079b027a3a507
Author: triesap <tyson@radroots.org>
Date:   Thu, 13 Aug 2026 17:40:14 +0000

ui: compose product panes with Studio anatomy

- center Today in the approved illustrated empty-state layout
- move Network and Settings tabs into the local pane header
- render live details through owned pages and raised panels
- preserve typed actions disabled availability and bounded scrolling

Diffstat:
Mapp/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/shell/HarvestCircleShellContent.kt | 2++
Mapp/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/FoundationNetworkScreen.kt | 152++++++++++++++++++++++++++++++++++++++++++++-----------------------------------
Mapp/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/FoundationSettingsScreen.kt | 190+++++++++++++++++++++++++++++++++++++++----------------------------------------
Mapp/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/FoundationTodayScreen.kt | 68+++++++++++++++++++++++++++-----------------------------------------
Mapp/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/HarvestCircleShell.kt | 46+++++++++++++++++++++++++++++++++++++++++++++-
Mapp/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/MainPanelHeader.kt | 84++++++++++++++++++++++++++++++++++++++++++-------------------------------------
Mapp/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/MainPanelTemplates.kt | 16+++-------------
7 files changed, 301 insertions(+), 257 deletions(-)

diff --git a/app/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/shell/HarvestCircleShellContent.kt b/app/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/shell/HarvestCircleShellContent.kt @@ -78,6 +78,7 @@ public fun HarvestCircleShellEmptyState( body: List<String>, modifier: Modifier = Modifier, context: String? = null, + contextModifier: Modifier = Modifier, showIllustration: Boolean = true, actions: @Composable RowScope.() -> Unit = {}, ) { @@ -101,6 +102,7 @@ public fun HarvestCircleShellEmptyState( context?.let { HarvestCircleShellText( it, + modifier = contextModifier, role = HarvestCircleShellTextRole.Small, color = HarvestCircleShellPalette.contentSecondary, maxLines = 1, 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 @@ -1,7 +1,11 @@ package org.harvestcircle.ui.shell import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -9,20 +13,18 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag +import androidx.compose.ui.unit.dp import org.harvestcircle.application.ApplicationLifecycle import org.harvestcircle.application.HarvestCircleShellState import org.harvestcircle.application.RelayConnectionState import org.harvestcircle.application.RelayDestination import org.harvestcircle.application.SignerAvailability -import org.harvestcircle.designsystem.component.HarvestCircleContentTone -import org.harvestcircle.designsystem.component.HarvestCircleTextRole -import org.harvestcircle.designsystem.component.action.HarvestCircleLabeledButton -import org.harvestcircle.designsystem.component.container.HarvestCircleCard -import org.harvestcircle.designsystem.component.feedback.HarvestCircleBadge -import org.harvestcircle.designsystem.component.navigation.HarvestCircleTab -import org.harvestcircle.designsystem.component.navigation.HarvestCircleTabRow -import org.harvestcircle.designsystem.primitive.HarvestCircleText -import org.harvestcircle.designsystem.theme.HarvestCircleTheme +import org.harvestcircle.designsystem.shell.HarvestCircleShellButton +import org.harvestcircle.designsystem.shell.HarvestCircleShellPage +import org.harvestcircle.designsystem.shell.HarvestCircleShellPanel +import org.harvestcircle.designsystem.shell.HarvestCircleShellTab +import org.harvestcircle.designsystem.shell.HarvestCircleShellText +import org.harvestcircle.designsystem.shell.HarvestCircleShellTextRole enum class NetworkIdentityState { ReadOnly, Active, CredentialUnavailable, Available, SignedOut } @@ -30,6 +32,16 @@ enum class RelayObservationState { NotYetObserved, Available, Degraded, Unavaila enum class RelayCapability { Configured, NotConfigured } +enum class NetworkSection( + val key: String, + val label: String, +) { + Overview("overview", "Overview"), + Identity("identity", "Identity"), + PublicRelays("public_relays", "Public relays"), + Runtime("runtime", "Runtime"), +} + data class NetworkRelayModel( val url: String, val destination: RelayDestination, @@ -86,48 +98,51 @@ fun FoundationNetworkScreen( model: FoundationNetworkModel, refreshProfile: () -> Unit = {}, signOut: () -> Unit = {}, + section: NetworkSection = NetworkSection.Overview, + onSectionSelected: (NetworkSection) -> Unit = {}, + showSectionTabs: Boolean = true, ) { - val tabs = - listOf( - TemplateTab(TemplateSelectionKey("overview"), "Overview"), - TemplateTab(TemplateSelectionKey("identity"), "Identity"), - TemplateTab(TemplateSelectionKey("public_relays"), "Public relays"), - TemplateTab(TemplateSelectionKey("runtime"), "Runtime"), - ) - var selected by remember { mutableStateOf(tabs.first().key) } - TabbedDetailTemplate( - tabs = tabs, - selected = selected, - tabRail = { available, current -> - HarvestCircleTabRow { - available.forEach { tab -> - HarvestCircleTab( - label = tab.label, - selected = tab.key == current, - onClick = { if (tab.key != current) selected = tab.key }, - modifier = Modifier.testTag("network-tab-${tab.key.value}"), + var localSection by remember { mutableStateOf(section) } + val selected = if (showSectionTabs) localSection else section + HarvestCircleShellPage(Modifier.testTag("template-tabbed-detail")) { + if (showSectionTabs) { + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + NetworkSection.entries.forEach { candidate -> + HarvestCircleShellTab( + label = candidate.label, + selected = candidate == selected, + onClick = { + if (candidate != selected) { + localSection = candidate + onSectionSelected(candidate) + } + }, + modifier = Modifier.testTag("network-tab-${candidate.key}"), ) } } - }, - detailPane = DetailPaneKind.Network, - detail = { selection -> NetworkDetail(selection, model, refreshProfile, signOut) }, - ) + } + Box( + Modifier + .weight(1f) + .verticalScroll(rememberScrollState()) + .testTag("bounded-detail-network"), + ) { + NetworkDetail(selected, model, refreshProfile, signOut) + } + } } @Composable private fun NetworkDetail( - selection: TemplateSelectionKey, + selection: NetworkSection, model: FoundationNetworkModel, refreshProfile: () -> Unit, signOut: () -> Unit, ) { - Column( - Modifier.testTag("network-${selection.value}"), - verticalArrangement = Arrangement.spacedBy(HarvestCircleTheme.shell.layout.contentGap), - ) { - when (selection.value) { - "overview" -> { + Column(Modifier.testTag("network-${selection.key}"), verticalArrangement = Arrangement.spacedBy(16.dp)) { + when (selection) { + NetworkSection.Overview -> { Fact("Signer", model.identityState.label()) Fact( "Configured read endpoints", @@ -138,43 +153,46 @@ private fun NetworkDetail( model.relays.count { it.writeCapability == RelayCapability.Configured }.toString(), ) Fact("Local runtime", model.runtimeState.label()) - HarvestCircleText("No managed HarvestCircle service is configured.") + HarvestCircleShellText("No managed HarvestCircle service is configured.") } - "identity" -> { - HarvestCircleBadge(model.identityState.label(), Modifier.testTag("network-identity-state")) + NetworkSection.Identity -> { + HarvestCircleShellText( + model.identityState.label(), + Modifier.testTag("network-identity-state"), + HarvestCircleShellTextRole.Small, + ) model.identityLabel?.let { - HarvestCircleText(it, Modifier.testTag("network-identity-label"), HarvestCircleTextRole.SubsectionTitle) + HarvestCircleShellText(it, Modifier.testTag("network-identity-label"), HarvestCircleShellTextRole.SectionTitle) } - model.profileLabel?.let { HarvestCircleText("Display name: $it", Modifier.testTag("network-profile-label")) } + model.profileLabel?.let { HarvestCircleShellText("Display name: $it", Modifier.testTag("network-profile-label")) } if (model.identityState == NetworkIdentityState.Active) { - HarvestCircleLabeledButton( - "Refresh profile", - "Refresh active profile", - refreshProfile, - Modifier.testTag("refresh-profile"), - ) - HarvestCircleLabeledButton("Sign out", "Sign out", signOut, Modifier.testTag("sign-out")) + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + HarvestCircleShellButton("Refresh profile", refreshProfile, Modifier.testTag("refresh-profile"), primary = true) + HarvestCircleShellButton("Sign out", signOut, Modifier.testTag("sign-out")) + } } } - "public_relays" -> { - HarvestCircleBadge(model.relayState.label(), Modifier.testTag("network-relay-state")) + NetworkSection.PublicRelays -> { + HarvestCircleShellText( + model.relayState.label(), + Modifier.testTag("network-relay-state"), + HarvestCircleShellTextRole.Small, + ) if (model.relays.isEmpty()) { - HarvestCircleText("No public relay endpoints are configured.", Modifier.testTag("network-relays-empty")) + HarvestCircleShellText("No public relay endpoints are configured.", Modifier.testTag("network-relays-empty")) } model.relays.forEach { relay -> - HarvestCircleCard(Modifier.testTag("network-relay:${relay.url}")) { - Column { - HarvestCircleText(relay.url, role = HarvestCircleTextRole.Code) - HarvestCircleText(relay.destination.label()) - HarvestCircleText(relay.readCapability.label("Read")) - HarvestCircleText(relay.writeCapability.label("Write")) - } + HarvestCircleShellPanel(Modifier.testTag("network-relay:${relay.url}")) { + HarvestCircleShellText(relay.url, role = HarvestCircleShellTextRole.Code) + HarvestCircleShellText(relay.destination.label()) + HarvestCircleShellText(relay.readCapability.label("Read")) + HarvestCircleShellText(relay.writeCapability.label("Write")) } } } - "runtime" -> { + NetworkSection.Runtime -> { Fact("Local runtime", model.runtimeState.label()) - model.runtimeProblem?.let { HarvestCircleText(it, Modifier.testTag("network-runtime-problem")) } + model.runtimeProblem?.let { HarvestCircleShellText(it, Modifier.testTag("network-runtime-problem")) } } } } @@ -186,12 +204,12 @@ private fun Fact( value: String, ) { Column { - HarvestCircleText( + HarvestCircleShellText( label, - role = HarvestCircleTextRole.BodySmall, - tone = HarvestCircleContentTone.Secondary, + role = HarvestCircleShellTextRole.Small, + color = org.harvestcircle.designsystem.shell.HarvestCircleShellPalette.contentSecondary, ) - HarvestCircleText(value, Modifier.testTag("network-fact-${label.lowercase().replace(' ', '-') }")) + HarvestCircleShellText(value, Modifier.testTag("network-fact-${label.lowercase().replace(' ', '-')}")) } } 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 @@ -1,24 +1,28 @@ package org.harvestcircle.ui.shell import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +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 import org.harvestcircle.appearance.AppearanceState import org.harvestcircle.appearance.MotionPreference import org.harvestcircle.appearance.TextSizePreference import org.harvestcircle.appearance.ThemePreference import org.harvestcircle.application.BuildDirtyState import org.harvestcircle.application.BuildInfo -import org.harvestcircle.designsystem.component.HarvestCircleContentTone -import org.harvestcircle.designsystem.component.HarvestCircleTextRole -import org.harvestcircle.designsystem.component.action.HarvestCircleLabeledButton -import org.harvestcircle.designsystem.component.navigation.HarvestCircleTab -import org.harvestcircle.designsystem.component.navigation.HarvestCircleTabRow -import org.harvestcircle.designsystem.primitive.HarvestCircleText -import org.harvestcircle.designsystem.theme.HarvestCircleTheme +import org.harvestcircle.designsystem.shell.HarvestCircleShellButton +import org.harvestcircle.designsystem.shell.HarvestCircleShellPage +import org.harvestcircle.designsystem.shell.HarvestCircleShellPalette +import org.harvestcircle.designsystem.shell.HarvestCircleShellPanel +import org.harvestcircle.designsystem.shell.HarvestCircleShellTab +import org.harvestcircle.designsystem.shell.HarvestCircleShellText +import org.harvestcircle.designsystem.shell.HarvestCircleShellTextRole import org.harvestcircle.identities.ui.HarvestCirclePlatformActions import org.harvestcircle.navigation.SettingsSection @@ -41,42 +45,33 @@ fun FoundationSettingsScreen( buildInfo: BuildInfo, actions: FoundationSettingsActions, platformActions: HarvestCirclePlatformActions, + showSectionTabs: Boolean = true, ) { - 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 -> - HarvestCircleTabRow { - available.forEach { tab -> - HarvestCircleTab( - label = tab.label, - selected = tab.key == current, - onClick = { - if (tab.key != current) { - actions.selectSection( - if (tab.key.value == "appearance") SettingsSection.Appearance else SettingsSection.Project, - ) - } - }, - modifier = Modifier.testTag("settings-${tab.key.value}"), + HarvestCircleShellPage(Modifier.testTag("template-tabbed-detail")) { + if (showSectionTabs) { + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + SettingsSection.entries.forEach { candidate -> + HarvestCircleShellTab( + label = candidate.label(), + selected = candidate == section, + onClick = { if (candidate != section) actions.selectSection(candidate) }, + modifier = Modifier.testTag("settings-${candidate.name.lowercase()}"), ) } } - }, - detailPane = DetailPaneKind.Settings, - detail = { + } + Box( + Modifier + .weight(1f) + .verticalScroll(rememberScrollState()) + .testTag("bounded-detail-settings"), + ) { when (section) { SettingsSection.Appearance -> AppearanceSettings(appearance, actions) SettingsSection.Project -> ProjectSettings(buildInfo, platformActions) } - }, - ) + } + } } @Composable @@ -84,31 +79,42 @@ private fun AppearanceSettings( appearance: AppearanceState, actions: FoundationSettingsActions, ) { - Column( - Modifier.testTag("settings-appearance-panel"), - verticalArrangement = Arrangement.spacedBy(HarvestCircleTheme.shell.layout.sectionGap), - ) { - HarvestCircleText("Theme", role = HarvestCircleTextRole.SectionTitle) - OptionRow( - listOf(ThemePreference.System, ThemePreference.Light, ThemePreference.Dark), - appearance.theme, - "theme", - actions.setTheme, - ) - HarvestCircleText("Text size", role = HarvestCircleTextRole.SectionTitle) - OptionRow( - listOf(TextSizePreference.Default, TextSizePreference.Large, TextSizePreference.VeryLarge), - appearance.textSize, - "text-size", - actions.setTextSize, - ) - HarvestCircleText("Motion", role = HarvestCircleTextRole.SectionTitle) - OptionRow( - listOf(MotionPreference.Standard, MotionPreference.Reduced), - appearance.motion, - "motion", - actions.setMotion, - ) + Column(Modifier.testTag("settings-appearance-panel"), verticalArrangement = Arrangement.spacedBy(16.dp)) { + SettingsChoicePanel("Theme") { + OptionRow( + listOf(ThemePreference.System, ThemePreference.Light, ThemePreference.Dark), + appearance.theme, + "theme", + actions.setTheme, + ) + } + SettingsChoicePanel("Text size") { + OptionRow( + listOf(TextSizePreference.Default, TextSizePreference.Large, TextSizePreference.VeryLarge), + appearance.textSize, + "text-size", + actions.setTextSize, + ) + } + SettingsChoicePanel("Motion") { + OptionRow( + listOf(MotionPreference.Standard, MotionPreference.Reduced), + appearance.motion, + "motion", + actions.setMotion, + ) + } + } +} + +@Composable +private fun SettingsChoicePanel( + title: String, + content: @Composable () -> Unit, +) { + HarvestCircleShellPanel { + HarvestCircleShellText(title, role = HarvestCircleShellTextRole.SectionTitle) + content() } } @@ -119,11 +125,10 @@ private fun <T : Enum<T>> OptionRow( tagPrefix: String, select: (T) -> Unit, ) { - HarvestCircleTabRow { + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { values.forEach { value -> - val label = value.label() - HarvestCircleTab( - label = label, + HarvestCircleShellTab( + label = value.label(), selected = value == selected, onClick = { if (value != selected) select(value) }, modifier = Modifier.testTag("$tagPrefix-${value.name.lowercase()}"), @@ -137,38 +142,29 @@ private fun ProjectSettings( build: BuildInfo, platformActions: HarvestCirclePlatformActions, ) { - Column( - Modifier.testTag("settings-project-panel"), - verticalArrangement = Arrangement.spacedBy(HarvestCircleTheme.shell.layout.inlineGap), - ) { - 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(HarvestCircleTheme.shell.layout.inlineGap)) { - HarvestCircleLabeledButton( + Column(Modifier.testTag("settings-project-panel"), verticalArrangement = Arrangement.spacedBy(16.dp)) { + HarvestCircleShellPanel { + 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(12.dp)) { + HarvestCircleShellButton( "Source", - "Open HarvestCircle source", platformActions.openSource, Modifier.testTag("project-open-source"), + primary = true, ) - HarvestCircleLabeledButton( - label = "Licence", - accessibilityLabel = "Open HarvestCircle licence", - modifier = Modifier.testTag("project-open-licence"), - onClick = platformActions.openLicence, - ) + HarvestCircleShellButton("Licence", platformActions.openLicence, Modifier.testTag("project-open-licence")) } } } @@ -179,15 +175,17 @@ private fun ProjectFact( value: String, ) { Column { - HarvestCircleText( + HarvestCircleShellText( label, - role = HarvestCircleTextRole.BodySmall, - tone = HarvestCircleContentTone.Secondary, + role = HarvestCircleShellTextRole.Small, + color = HarvestCircleShellPalette.contentSecondary, ) - HarvestCircleText(value, Modifier.testTag("project-${label.lowercase().replace(' ', '-')}")) + HarvestCircleShellText(value, Modifier.testTag("project-${label.lowercase().replace(' ', '-')}")) } } +private fun SettingsSection.label(): String = name.lowercase().replaceFirstChar(Char::uppercaseChar) + private fun Enum<*>.label(): String = when (this) { TextSizePreference.VeryLarge -> "Very large" diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/FoundationTodayScreen.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/FoundationTodayScreen.kt @@ -1,18 +1,11 @@ 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.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag import org.harvestcircle.application.ShellFocusTarget -import org.harvestcircle.designsystem.component.HarvestCircleContentTone -import org.harvestcircle.designsystem.component.HarvestCircleTextRole -import org.harvestcircle.designsystem.component.action.HarvestCircleLabeledButton -import org.harvestcircle.designsystem.component.feedback.HarvestCircleBadge -import org.harvestcircle.designsystem.primitive.HarvestCircleText -import org.harvestcircle.designsystem.theme.HarvestCircleTheme +import org.harvestcircle.designsystem.shell.HarvestCircleShellButton +import org.harvestcircle.designsystem.shell.HarvestCircleShellEmptyState data class FoundationTodayModel( val context: String, @@ -23,38 +16,31 @@ fun FoundationTodayScreen( model: FoundationTodayModel, openNostrReference: () -> Unit, ) { - SingleFocusTemplate { - Column( - Modifier.testTag("foundation-today"), - verticalArrangement = Arrangement.spacedBy(HarvestCircleTheme.shell.layout.contentGap), - ) { - HarvestCircleBadge(model.context, Modifier.testTag("today-context")) - HarvestCircleText("No active commitments", role = HarvestCircleTextRole.SectionTitle) - HarvestCircleText("Explore nearby buying circles or open a shared Nostr reference.") - Row(horizontalArrangement = Arrangement.spacedBy(HarvestCircleTheme.shell.layout.inlineGap)) { - HarvestCircleLabeledButton( - label = "Explore circles", - accessibilityLabel = "Explore circles", - modifier = Modifier.testTag("today-explore-circles"), - enabled = false, - onClick = {}, - ) - HarvestCircleLabeledButton( - label = "Open a Nostr reference", - accessibilityLabel = "Open a Nostr reference", - modifier = - Modifier - .shellFocusTarget(ShellFocusTarget.TodayReference) - .testTag("today-open-reference"), - onClick = openNostrReference, - ) - } - HarvestCircleText( + HarvestCircleShellEmptyState( + title = "No active commitments", + body = + listOf( + "Explore nearby buying circles or open a shared Nostr reference.", "Not available in this build.", - Modifier.testTag("today-deferred-helper"), - HarvestCircleTextRole.BodySmall, - HarvestCircleContentTone.Secondary, - ) - } + ), + modifier = Modifier.testTag("foundation-today"), + context = model.context, + contextModifier = Modifier.testTag("today-context"), + ) { + HarvestCircleShellButton( + text = "Explore circles", + onClick = {}, + modifier = Modifier.testTag("today-explore-circles"), + enabled = false, + ) + HarvestCircleShellButton( + text = "Open a Nostr reference", + onClick = openNostrReference, + modifier = + Modifier + .shellFocusTarget(ShellFocusTarget.TodayReference) + .testTag("today-open-reference"), + primary = true, + ) } } 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 @@ -197,6 +197,7 @@ private fun DashboardRoot( val route = root.navigation.current val status = deriveShellStatus(state) var sidebarCollapsed by rememberSaveable { mutableStateOf(false) } + var networkSection by remember { mutableStateOf(NetworkSection.Overview) } DashboardScaffold( inspectorVisible = false, sidebarCollapsed = sidebarCollapsed, @@ -223,7 +224,46 @@ private fun DashboardRoot( sessionLabel = todayContext(state), ) }, - mainHeader = { MainPanelHeader(MainPanelHeaderModel(title = route.title())) }, + mainHeader = { + when (route) { + AppRoute.Network -> + MainPanelHeader( + MainPanelHeaderModel( + title = route.title(), + tabs = NetworkSection.entries.map { TemplateTab(TemplateSelectionKey(it.key), it.label) }, + selectedTab = TemplateSelectionKey(networkSection.key), + ), + onTabSelected = { selected -> + networkSection = NetworkSection.entries.single { it.key == selected.value } + }, + ) + AppRoute.Settings -> + MainPanelHeader( + MainPanelHeaderModel( + title = route.title(), + tabs = + SettingsSection.entries.map { + TemplateTab(TemplateSelectionKey(it.name.lowercase()), it.name) + }, + selectedTab = + TemplateSelectionKey( + root.navigation.settings.section.name + .lowercase(), + ), + ), + onTabSelected = { selected -> + dispatch( + HarvestCircleShellIntent.Navigation( + NavigationIntent.SelectSettingsSection( + SettingsSection.entries.single { it.name.equals(selected.value, ignoreCase = true) }, + ), + ), + ) + }, + ) + else -> MainPanelHeader(MainPanelHeaderModel(title = route.title())) + } + }, mainBody = { RouteFocusTarget( route.toString(), @@ -246,6 +286,9 @@ private fun DashboardRoot( foundationNetworkModel(state), refreshProfile = identityActions.refreshActiveProfile, signOut = identityActions.signOut, + section = networkSection, + onSectionSelected = { networkSection = it }, + showSectionTabs = false, ) AppRoute.Settings -> FoundationSettingsScreen( @@ -266,6 +309,7 @@ private fun DashboardRoot( setMotion = { dispatch(HarvestCircleShellIntent.SetMotion(it)) }, ), platformActions = platformActions, + showSectionTabs = false, ) else -> HarvestCircleText(route.title(), Modifier.testTag("foundation-route-body")) } diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/MainPanelHeader.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/MainPanelHeader.kt @@ -1,8 +1,9 @@ package org.harvestcircle.ui.shell +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable @@ -11,13 +12,11 @@ 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.designsystem.component.HarvestCircleContentTone -import org.harvestcircle.designsystem.component.HarvestCircleTextRole -import org.harvestcircle.designsystem.component.feedback.HarvestCircleBadge -import org.harvestcircle.designsystem.component.navigation.HarvestCircleTab -import org.harvestcircle.designsystem.component.navigation.HarvestCircleTabRow -import org.harvestcircle.designsystem.primitive.HarvestCircleText -import org.harvestcircle.designsystem.theme.HarvestCircleTheme +import org.harvestcircle.designsystem.shell.HarvestCircleShellMetrics +import org.harvestcircle.designsystem.shell.HarvestCircleShellPalette +import org.harvestcircle.designsystem.shell.HarvestCircleShellTab +import org.harvestcircle.designsystem.shell.HarvestCircleShellText +import org.harvestcircle.designsystem.shell.HarvestCircleShellTextRole data class MainPanelHeaderModel( val title: String, @@ -41,48 +40,55 @@ fun MainPanelHeader( secondaryAction: @Composable () -> Unit = {}, primaryAction: @Composable () -> Unit = {}, ) { + val colors = HarvestCircleShellPalette Row( modifier = Modifier .fillMaxSize() - .padding(horizontal = HarvestCircleTheme.shell.layout.paneInset) + .background(colors.pane) + .padding(horizontal = HarvestCircleShellMetrics.localHeaderHorizontalInset) .testTag("main-panel-header"), - horizontalArrangement = Arrangement.spacedBy(HarvestCircleTheme.foundation.spacing.lg), + horizontalArrangement = Arrangement.spacedBy(HarvestCircleShellMetrics.localHeaderGap), verticalAlignment = Alignment.CenterVertically, ) { - Column(Modifier.weight(1f)) { - HarvestCircleText( - text = model.title, + HarvestCircleShellText( + text = model.title, + modifier = + Modifier + .semantics { contentDescription = "Screen title: ${model.title}" } + .testTag("main-title"), + role = HarvestCircleShellTextRole.PaneTitle, + maxLines = 1, + ) + if (model.breadcrumb.isNotEmpty()) { + HarvestCircleShellText( + text = model.breadcrumb.joinToString(" / "), + modifier = Modifier.testTag("main-breadcrumb"), + role = HarvestCircleShellTextRole.Small, + color = colors.contentSecondary, + maxLines = 1, + ) + } + model.tabs.forEach { tab -> + HarvestCircleShellTab( + label = tab.label, + selected = tab.key == model.selectedTab, + onClick = { if (tab.key != model.selectedTab) onTabSelected(tab.key) }, modifier = Modifier - .semantics { contentDescription = "Screen title: ${model.title}" } - .testTag("main-title"), - role = HarvestCircleTextRole.PageTitle, + .semantics { contentDescription = "Show ${tab.label}" } + .testTag("main-tab-${tab.key.value}"), ) - if (model.breadcrumb.isNotEmpty()) { - HarvestCircleText( - text = model.breadcrumb.joinToString(" / "), - modifier = Modifier.testTag("main-breadcrumb"), - role = HarvestCircleTextRole.LabelSmall, - tone = HarvestCircleContentTone.Secondary, - ) - } } - model.localStatus?.let { HarvestCircleBadge(it, Modifier.testTag("main-local-status")) } - if (model.tabs.isNotEmpty()) { - HarvestCircleTabRow { - model.tabs.forEach { tab -> - HarvestCircleTab( - selected = tab.key == model.selectedTab, - onClick = { if (tab.key != model.selectedTab) onTabSelected(tab.key) }, - label = tab.label, - modifier = - Modifier - .semantics { contentDescription = "Show ${tab.label}" } - .testTag("main-tab-${tab.key.value}"), - ) - } - } + Spacer(Modifier.weight(1f)) + model.localStatus?.let { + HarvestCircleShellText( + it, + Modifier.testTag("main-local-status"), + HarvestCircleShellTextRole.Small, + colors.contentSecondary, + maxLines = 1, + ) } Row(Modifier.testTag("main-secondary-action")) { secondaryAction() } Row(Modifier.testTag("main-primary-action")) { primaryAction() } 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 @@ -1,7 +1,6 @@ package org.harvestcircle.ui.shell import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState @@ -9,8 +8,7 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag -import org.harvestcircle.designsystem.layout.HarvestCirclePane -import org.harvestcircle.designsystem.primitive.HarvestCircleSurfaceRole +import org.harvestcircle.designsystem.shell.HarvestCircleShellPage import org.harvestcircle.designsystem.theme.HarvestCircleTheme @JvmInline @@ -35,10 +33,7 @@ enum class DetailPaneKind { Network, Settings } @Composable fun SingleFocusTemplate(content: @Composable () -> Unit) { - HarvestCirclePane( - modifier = Modifier.fillMaxSize().testTag("template-single-focus"), - role = HarvestCircleSurfaceRole.Canvas, - ) { + Box(Modifier.fillMaxSize().testTag("template-single-focus")) { content() } } @@ -53,12 +48,7 @@ fun TabbedDetailTemplate( ) { require(tabs.map(TemplateTab::key).distinct().size == tabs.size) require(tabs.any { it.key == selected }) - Column( - Modifier - .fillMaxSize() - .padding(HarvestCircleTheme.shell.layout.paneInset) - .testTag("template-tabbed-detail"), - ) { + HarvestCircleShellPage(Modifier.testTag("template-tabbed-detail")) { Box( Modifier .padding(bottom = HarvestCircleTheme.shell.layout.contentGap)