commit d1965a01ce8d055e636879127c472860da47961c parent 67ad9e5097ba8ac733c89b5fda18a6be70c26e50 Author: triesap <tyson@radroots.org> Date: Tue, 11 Aug 2026 21:33:34 +0000 ui: add typed shell controls - centralize themed text, fields, surfaces, cards, badges, tabs, and actions - expose hover, press, focus, selection, and disabled visual states - migrate active shell and bootstrap controls away from raw Compose primitives - cover interaction priority, semantics, labels, placeholders, and minimum targets Diffstat:
13 files changed, 474 insertions(+), 170 deletions(-)
diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/BootstrapIdentityEntry.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/BootstrapIdentityEntry.kt @@ -3,8 +3,6 @@ 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.foundation.text.BasicTextField import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember @@ -34,12 +32,13 @@ fun BootstrapIdentityEntry( textSize = TextSizePreference.Default, navigation = { ShellAction("Back", "Back", "identity-entry-back", onClick = onBack) }, header = { - BasicText( + ShellText( if (step == BootstrapStep.CreateIdentity) { "Create a local Nostr identity" } else { "Import an existing identity" }, + textRole = ShellTextRole.ScreenTitle, ) }, body = { @@ -69,9 +68,9 @@ fun BootstrapIdentityEntry( @Composable private fun CreateIdentityBody(model: HarvestCircleUiModel) { Column(Modifier.testTag("create-identity-entry"), verticalArrangement = Arrangement.spacedBy(16.dp)) { - BasicText("HarvestCircle will generate a new Nostr identity.") - BasicText("Save the recovery key before the identity is stored in the operating-system keyring.") - model.problem?.let { BasicText(it, Modifier.testTag("identity-entry-problem")) } + ShellText("HarvestCircle will generate a new Nostr identity.") + ShellText("Save the recovery key before the identity is stored in the operating-system keyring.") + model.problem?.let { ShellText(it, Modifier.testTag("identity-entry-problem")) } } } @@ -82,10 +81,11 @@ private fun ImportIdentityBody( ) { val requester = remember { FocusRequester() } Column(Modifier.testTag("import-identity-entry"), verticalArrangement = Arrangement.spacedBy(16.dp)) { - BasicText("Nostr secret key") - BasicTextField( + ShellTextField( value = model.importDraft, onValueChange = actions.editImportDraft, + label = "Nostr secret key", + placeholder = "nsec1…", modifier = Modifier .focusRequester(requester) @@ -95,10 +95,9 @@ private fun ImportIdentityBody( }.testTag("import-nsec-input"), visualTransformation = PasswordVisualTransformation(), ) - BasicText("nsec1…") - BasicText("The secret is sent directly to the local native runtime and is not retained in the interface.") - model.importGuidance?.let { BasicText(it, Modifier.testTag("identity-entry-guidance")) } - model.problem?.let { BasicText(it, Modifier.testTag("identity-entry-problem")) } + ShellText("The secret is sent directly to the local native runtime and is not retained in the interface.") + model.importGuidance?.let { ShellText(it, Modifier.testTag("identity-entry-guidance")) } + model.problem?.let { ShellText(it, Modifier.testTag("identity-entry-problem")) } } LaunchedEffect(Unit) { requester.requestFocus() } } diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/BootstrapRecoveryAndChooser.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/BootstrapRecoveryAndChooser.kt @@ -6,7 +6,6 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.text.BasicText import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag @@ -28,13 +27,13 @@ fun GeneratedRecoveryCanvas( val backup = model.generatedKeyBackup ?: return CanvasScaffold( textSize = TextSizePreference.Default, - header = { BasicText("Save your recovery key") }, + header = { ShellText("Save your recovery key", textRole = ShellTextRole.ScreenTitle) }, body = { Column(Modifier.testTag("generated-key-backup"), verticalArrangement = Arrangement.spacedBy(16.dp)) { - BasicText("This key is shown once.") - BasicText("Store it somewhere private before continuing.") - BasicText("Recovery key") - BasicText(backup.nsec, Modifier.testTag("generated-nsec")) + ShellText("This key is shown once.") + ShellText("Store it somewhere private before continuing.") + ShellText("Recovery key", textRole = ShellTextRole.CardTitle) + ShellText(backup.nsec, Modifier.testTag("generated-nsec"), ShellTextRole.Protocol) } }, actionBar = { @@ -61,7 +60,7 @@ fun IdentityChooserCanvas( ) { CanvasScaffold( textSize = TextSizePreference.Default, - header = { BasicText("Choose a Nostr identity") }, + header = { ShellText("Choose a Nostr identity", textRole = ShellTextRole.ScreenTitle) }, body = { LazyColumn(Modifier.fillMaxWidth().testTag("saved-identity-list")) { items(model.identities, key = IdentityUiModel::publicKeyHex) { identity -> @@ -96,16 +95,16 @@ private fun IdentityRow( .testTag("identity-row:${identity.publicKeyHex}"), verticalArrangement = Arrangement.spacedBy(8.dp), ) { - BasicText(identity.label) - BasicText(identity.shortNpub) - BasicText( + ShellText(identity.label, textRole = ShellTextRole.CardTitle) + ShellText(identity.shortNpub, textRole = ShellTextRole.Protocol) + ShellText( if (identity.signerAvailability == org.harvestcircle.application.SignerAvailability.Available) { "Local credential available" } else { "Local credential unavailable" }, ) - if (identity.selected) BasicText("Selected") + if (identity.selected) ShellBadge("Selected") ShellAction( label = if (identity.selected) "Selected identity" else "Select identity", description = "Select ${identity.label}", @@ -131,12 +130,12 @@ private fun IdentityRow( actions.requestIdentityRemoval(identity.publicKeyHex) } if (model.pendingRemovalPublicKeyHex == identity.publicKeyHex) { - BasicText("Remove this saved identity?") + ShellText("Remove this saved identity?", textRole = ShellTextRole.CardTitle) model.removalImpact?.takeIf { it.deletesLocalCredential }?.let { - BasicText("Its local credential will be deleted from the operating-system keyring.") + ShellText("Its local credential will be deleted from the operating-system keyring.") } model.removalImpact?.takeIf { it.signsOut }?.let { - BasicText("The active session will be signed out before removal.") + ShellText("The active session will be signed out before removal.") } ShellAction("Keep identity", "Keep identity", "remove-cancel") { actions.cancelIdentityRemoval() } ShellAction( 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 @@ -3,7 +3,6 @@ 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.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -131,34 +130,36 @@ private fun NetworkDetail( model.relays.count { it.writeCapability == RelayCapability.Configured }.toString(), ) Fact("Local runtime", model.runtimeState.label()) - BasicText("No managed HarvestCircle service is configured.") + ShellText("No managed HarvestCircle service is configured.") } "identity" -> { - BasicText(model.identityState.label(), Modifier.testTag("network-identity-state")) - model.identityLabel?.let { BasicText(it, Modifier.testTag("network-identity-label")) } - model.profileLabel?.let { BasicText("Display name: $it", Modifier.testTag("network-profile-label")) } + ShellBadge(model.identityState.label(), Modifier.testTag("network-identity-state")) + model.identityLabel?.let { ShellText(it, Modifier.testTag("network-identity-label"), ShellTextRole.CardTitle) } + model.profileLabel?.let { ShellText("Display name: $it", Modifier.testTag("network-profile-label")) } if (model.identityState == NetworkIdentityState.Active) { ShellAction("Refresh profile", "Refresh active profile", "refresh-profile", onClick = refreshProfile) ShellAction("Sign out", "Sign out", "sign-out", onClick = signOut) } } "public_relays" -> { - BasicText(model.relayState.label(), Modifier.testTag("network-relay-state")) + ShellBadge(model.relayState.label(), Modifier.testTag("network-relay-state")) if (model.relays.isEmpty()) { - BasicText("No public relay endpoints are configured.", Modifier.testTag("network-relays-empty")) + ShellText("No public relay endpoints are configured.", Modifier.testTag("network-relays-empty")) } model.relays.forEach { relay -> - Column(Modifier.testTag("network-relay:${relay.url}")) { - BasicText(relay.url) - BasicText(relay.destination.label()) - BasicText(relay.readCapability.label("Read")) - BasicText(relay.writeCapability.label("Write")) + ShellCard(Modifier.testTag("network-relay:${relay.url}")) { + Column { + ShellText(relay.url, textRole = ShellTextRole.Protocol) + ShellText(relay.destination.label()) + ShellText(relay.readCapability.label("Read")) + ShellText(relay.writeCapability.label("Write")) + } } } } "runtime" -> { Fact("Local runtime", model.runtimeState.label()) - model.runtimeProblem?.let { BasicText(it, Modifier.testTag("network-runtime-problem")) } + model.runtimeProblem?.let { ShellText(it, Modifier.testTag("network-runtime-problem")) } } } } @@ -170,8 +171,8 @@ private fun Fact( value: String, ) { Column { - BasicText(label) - BasicText(value, Modifier.testTag("network-fact-${label.lowercase().replace(' ', '-') }")) + ShellText(label, textRole = ShellTextRole.Secondary) + ShellText(value, Modifier.testTag("network-fact-${label.lowercase().replace(' ', '-') }")) } } diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/FoundationOverlayHost.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/FoundationOverlayHost.kt @@ -5,8 +5,6 @@ import androidx.compose.foundation.focusGroup import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.text.BasicText -import androidx.compose.foundation.text.BasicTextField import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -25,7 +23,7 @@ fun FoundationOverlayHost( onIntent: (OverlayIntent) -> Unit, ) { state.banner?.let { banner -> - BasicText( + ShellBadge( banner.message, Modifier.semantics { contentDescription = "Status: ${banner.message}" }.testTag("global-status-banner"), ) @@ -55,8 +53,8 @@ private fun ConfirmOverlay( overlay: FoundationOverlay.ConfirmAction, onIntent: (OverlayIntent) -> Unit, ) { - BasicText(overlay.title) - BasicText(overlay.explanation) + ShellText(overlay.title, textRole = ShellTextRole.SectionTitle) + ShellText(overlay.explanation) ShellAction(overlay.actionLabel, overlay.actionLabel, "overlay-confirm") { onIntent(OverlayIntent.Confirm) } ShellAction("Cancel", "Cancel", "overlay-cancel") { onIntent(OverlayIntent.Close) } } @@ -67,8 +65,8 @@ private fun StatusOverlay( status: String, onIntent: (OverlayIntent) -> Unit, ) { - BasicText(title) - BasicText(status, Modifier.testTag("overlay-status")) + ShellText(title, textRole = ShellTextRole.SectionTitle) + ShellText(status, Modifier.testTag("overlay-status")) ShellAction("Close", "Close", "overlay-close") { onIntent(OverlayIntent.Close) } } @@ -78,14 +76,16 @@ private fun ReferenceOverlay( onIntent: (OverlayIntent) -> Unit, ) { val requester = androidx.compose.runtime.remember { FocusRequester() } - BasicText("Open a Nostr reference") - BasicTextField( + ShellText("Open a Nostr reference", textRole = ShellTextRole.SectionTitle) + ShellTextField( value = overlay.input, onValueChange = { onIntent(OverlayIntent.EditReference(it)) }, + label = "Nostr reference", + placeholder = "npub1…, note1…, or nevent1…", modifier = Modifier.focusRequester(requester).testTag("nostr-reference-input"), ) androidx.compose.runtime.LaunchedEffect(Unit) { requester.requestFocus() } - overlay.result?.let { BasicText(it.message, Modifier.testTag("nostr-reference-result")) } + overlay.result?.let { ShellText(it.message, Modifier.testTag("nostr-reference-result")) } ShellAction("Open reference", "Open Nostr reference", "nostr-reference-submit") { onIntent(OverlayIntent.SubmitReference) } 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 @@ -3,7 +3,6 @@ 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 @@ -80,21 +79,21 @@ private fun AppearanceSettings( Modifier.testTag("settings-appearance-panel"), verticalArrangement = Arrangement.spacedBy(16.dp), ) { - BasicText("Theme") + ShellText("Theme", textRole = ShellTextRole.SectionTitle) OptionRow( listOf(ThemePreference.System, ThemePreference.Light, ThemePreference.Dark), appearance.theme, "theme", actions.setTheme, ) - BasicText("Text size") + ShellText("Text size", textRole = ShellTextRole.SectionTitle) OptionRow( listOf(TextSizePreference.Default, TextSizePreference.Large, TextSizePreference.VeryLarge), appearance.textSize, "text-size", actions.setTextSize, ) - BasicText("Motion") + ShellText("Motion", textRole = ShellTextRole.SectionTitle) OptionRow( listOf(MotionPreference.Standard, MotionPreference.Reduced), appearance.motion, @@ -166,8 +165,8 @@ private fun ProjectFact( value: String, ) { Column { - BasicText(label) - BasicText(value, Modifier.testTag("project-${label.lowercase().replace(' ', '-')}")) + ShellText(label, textRole = ShellTextRole.Secondary) + ShellText(value, Modifier.testTag("project-${label.lowercase().replace(' ', '-')}")) } } 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 @@ -3,7 +3,6 @@ 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 @@ -23,9 +22,9 @@ fun FoundationTodayScreen( Modifier.testTag("foundation-today"), verticalArrangement = Arrangement.spacedBy(16.dp), ) { - BasicText(model.context, Modifier.testTag("today-context")) - BasicText("No active commitments") - BasicText("Explore nearby buying circles or open a shared Nostr reference.") + ShellBadge(model.context, Modifier.testTag("today-context")) + ShellText("No active commitments", textRole = ShellTextRole.SectionTitle) + ShellText("Explore nearby buying circles or open a shared Nostr reference.") Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { ShellAction( "Explore circles", @@ -41,7 +40,7 @@ fun FoundationTodayScreen( onClick = openNostrReference, ) } - BasicText("Not available in this build.", Modifier.testTag("today-deferred-helper")) + ShellText("Not available in this build.", Modifier.testTag("today-deferred-helper"), ShellTextRole.Secondary) } } } 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,28 +1,12 @@ package org.harvestcircle.ui.shell -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.text.BasicText import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.platform.testTag -import androidx.compose.ui.semantics.Role -import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.disabled -import androidx.compose.ui.semantics.role -import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp -import org.harvestcircle.design.HarvestCircleDesign enum class SyncStatusLabel( val text: String, @@ -95,23 +79,5 @@ internal fun ShellAction( enabled: Boolean = true, onClick: () -> Unit, ) { - var focused by remember { mutableStateOf(false) } - val focusColor = LocalHarvestCirclePalette.current.focus.toComposeColor() - BasicText( - text = label, - modifier = - Modifier - .heightIn(min = HarvestCircleDesign.MINIMUM_TARGET_DP.dp) - .onFocusChanged { focused = it.isFocused } - .border( - width = HarvestCircleDesign.BORDER_DP.dp, - color = if (focused) focusColor else androidx.compose.ui.graphics.Color.Transparent, - ).padding(horizontal = HarvestCircleDesign.spacingDp[2].dp) - .semantics { - contentDescription = description - role = Role.Button - if (!enabled) disabled() - }.then(if (enabled) Modifier.clickable(role = Role.Button, onClick = onClick) else Modifier) - .testTag(tag), - ) + ShellButton(label, description, onClick, Modifier.testTag(tag), enabled) } 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 @@ -3,7 +3,6 @@ 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 @@ -87,22 +86,22 @@ private fun HarvestCircleShellContent( private fun BootstrapWelcome(dispatch: (HarvestCircleShellIntent) -> Unit) { CanvasScaffold( textSize = org.harvestcircle.design.TextSizePreference.Default, - header = { BasicText("HarvestCircle") }, + header = { ShellText("HarvestCircle", textRole = ShellTextRole.ScreenTitle) }, body = { Column( Modifier.testTag("bootstrap-welcome"), verticalArrangement = Arrangement.spacedBy(16.dp), ) { - BasicText("Coordinate local food with clear, signed terms.") - BasicText("HarvestCircle helps farms and nearby buyers form one shared order.") - BasicText("You do not need a HarvestCircle account.") - BasicText("A farm opens a round") - BasicText("The farm publishes the available boxes, pickup terms, and price levels.") - BasicText("Buyers make private commitments") - BasicText("Each buyer signs a maximum amount without publishing their identity.") - BasicText("The authority clears the round") - BasicText("The selected authority applies the farm’s signed terms and issues allocations.") - BasicText("Open source · Nostr-based · No managed service required") + ShellText("Coordinate local food with clear, signed terms.", textRole = ShellTextRole.SectionTitle) + ShellText("HarvestCircle helps farms and nearby buyers form one shared order.") + ShellText("You do not need a HarvestCircle account.") + ShellText("A farm opens a round", textRole = ShellTextRole.CardTitle) + ShellText("The farm publishes the available boxes, pickup terms, and price levels.") + ShellText("Buyers make private commitments", textRole = ShellTextRole.CardTitle) + ShellText("Each buyer signs a maximum amount without publishing their identity.") + ShellText("The authority clears the round", textRole = ShellTextRole.CardTitle) + ShellText("The selected authority applies the farm’s signed terms and issues allocations.") + ShellText("Open source · Nostr-based · No managed service required", textRole = ShellTextRole.Secondary) } }, actionBar = { @@ -197,7 +196,7 @@ private fun DashboardRoot( ), platformActions = platformActions, ) - else -> BasicText(route.title(), Modifier.testTag("foundation-route-body")) + else -> ShellText(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,18 +1,13 @@ package org.harvestcircle.ui.shell -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize -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.semantics.Role import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.role -import androidx.compose.ui.semantics.selected import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp @@ -43,21 +38,23 @@ fun MainPanelHeader( horizontalArrangement = Arrangement.spacedBy(16.dp), ) { Column(Modifier.weight(1f)) { - BasicText(model.title, Modifier.semantics { contentDescription = "Screen title: ${model.title}" }.testTag("main-title")) + ShellText( + model.title, + Modifier.semantics { contentDescription = "Screen title: ${model.title}" }.testTag("main-title"), + ShellTextRole.ScreenTitle, + ) if (model.breadcrumb.isNotEmpty()) { - BasicText(model.breadcrumb.joinToString(" / "), Modifier.testTag("main-breadcrumb")) + ShellText(model.breadcrumb.joinToString(" / "), Modifier.testTag("main-breadcrumb"), ShellTextRole.Secondary) } - model.localStatus?.let { BasicText(it, Modifier.testTag("main-local-status")) } + model.localStatus?.let { ShellBadge(it, Modifier.testTag("main-local-status")) } } model.tabs.forEach { tab -> - BasicText( - tab.label, - Modifier - .semantics { - role = Role.Tab - selected = tab.key == model.selectedTab - }.clickable(role = Role.Tab) { onTabSelected(tab.key) } - .testTag("main-tab-${tab.key.value}"), + ShellTab( + label = tab.label, + description = "Show ${tab.label}", + selected = tab.key == model.selectedTab, + onClick = { onTabSelected(tab.key) }, + modifier = Modifier.testTag("main-tab-${tab.key.value}"), ) } Row(Modifier.testTag("main-secondary-action")) { secondaryAction() } diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/ShellControls.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/ShellControls.kt @@ -0,0 +1,315 @@ +package org.harvestcircle.ui.shell + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.focusable +import androidx.compose.foundation.hoverable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsHoveredAsState +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.sizeIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicText +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.disabled +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.selected +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.harvestcircle.design.FontWeightToken +import org.harvestcircle.design.HarvestCircleDesign +import org.harvestcircle.design.TypographyToken + +enum class ShellTextRole { ScreenTitle, SectionTitle, CardTitle, Body, Secondary, Protocol, Button } + +enum class ShellButtonKind { Primary, Secondary, Quiet, Destructive } + +enum class ShellControlVisualState { Normal, Hovered, Pressed, Focused, Selected, Disabled } + +fun shellControlVisualState( + enabled: Boolean, + selected: Boolean, + focused: Boolean, + pressed: Boolean, + hovered: Boolean, +): ShellControlVisualState = + when { + !enabled -> ShellControlVisualState.Disabled + focused -> ShellControlVisualState.Focused + pressed -> ShellControlVisualState.Pressed + selected -> ShellControlVisualState.Selected + hovered -> ShellControlVisualState.Hovered + else -> ShellControlVisualState.Normal + } + +@Composable +fun ShellSurface( + modifier: Modifier = Modifier, + secondary: Boolean = false, + content: @Composable () -> Unit, +) { + val palette = LocalHarvestCirclePalette.current + Box( + modifier.background( + if (secondary) palette.surfaceSecondary.toComposeColor() else palette.surface.toComposeColor(), + RoundedCornerShape(LocalHarvestCircleShapes.current.surfaceRadiusDp.dp), + ), + ) { + content() + } +} + +@Composable +fun ShellText( + text: String, + modifier: Modifier = Modifier, + textRole: ShellTextRole = ShellTextRole.Body, + color: Color? = null, +) { + val typography = LocalHarvestCircleTypography.current + val token = + when (textRole) { + ShellTextRole.ScreenTitle -> typography.screenTitle + ShellTextRole.SectionTitle -> typography.sectionTitle + ShellTextRole.CardTitle -> typography.cardTitle + ShellTextRole.Body -> typography.body + ShellTextRole.Secondary -> typography.secondary + ShellTextRole.Protocol -> typography.protocol + ShellTextRole.Button -> typography.button + } + val palette = LocalHarvestCirclePalette.current + BasicText( + text, + modifier, + style = + token.toTextStyle( + color + ?: if (textRole == + ShellTextRole.Secondary + ) { + palette.textSecondary.toComposeColor() + } else { + palette.textPrimary.toComposeColor() + }, + ), + ) +} + +@Composable +fun ShellButton( + label: String, + description: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + selected: Boolean = false, + kind: ShellButtonKind = ShellButtonKind.Secondary, +) { + val interactionSource = remember { MutableInteractionSource() } + val hovered by interactionSource.collectIsHoveredAsState() + val pressed by interactionSource.collectIsPressedAsState() + var focused by remember { mutableStateOf(false) } + val state = shellControlVisualState(enabled, selected, focused, pressed, hovered) + val palette = LocalHarvestCirclePalette.current + val background = + when (state) { + ShellControlVisualState.Disabled -> palette.surfaceSecondary.toComposeColor() + ShellControlVisualState.Hovered -> palette.primaryHover.toComposeColor() + ShellControlVisualState.Pressed -> palette.primary.toComposeColor() + ShellControlVisualState.Focused -> palette.surface.toComposeColor() + ShellControlVisualState.Selected -> palette.primary.toComposeColor() + ShellControlVisualState.Normal -> + when (kind) { + ShellButtonKind.Primary -> palette.primary.toComposeColor() + ShellButtonKind.Destructive -> palette.critical.toComposeColor() + ShellButtonKind.Secondary -> palette.surfaceSecondary.toComposeColor() + ShellButtonKind.Quiet -> Color.Transparent + } + } + val foreground = + if (state == ShellControlVisualState.Disabled) { + palette.textSecondary.toComposeColor() + } else if (state in setOf(ShellControlVisualState.Hovered, ShellControlVisualState.Pressed, ShellControlVisualState.Selected) || + kind == ShellButtonKind.Primary || + kind == ShellButtonKind.Destructive + ) { + palette.surface.toComposeColor() + } else { + palette.textPrimary.toComposeColor() + } + Box( + modifier + .heightIn(min = HarvestCircleDesign.MINIMUM_TARGET_DP.dp) + .background(background, RoundedCornerShape(LocalHarvestCircleShapes.current.controlRadiusDp.dp)) + .border( + HarvestCircleDesign.BORDER_DP.dp, + if (focused) palette.focus.toComposeColor() else palette.border.toComposeColor(), + RoundedCornerShape(LocalHarvestCircleShapes.current.controlRadiusDp.dp), + ).onFocusChanged { focused = it.isFocused } + .hoverable(interactionSource, enabled) + .clickable(interactionSource, indication = null, enabled = enabled, role = Role.Button, onClick = onClick) + .focusable(enabled, interactionSource) + .semantics { + contentDescription = description + role = Role.Button + this.selected = selected + if (!enabled) disabled() + }.padding(horizontal = HarvestCircleDesign.spacingDp[3].dp, vertical = HarvestCircleDesign.spacingDp[2].dp), + ) { + ShellText(label, textRole = ShellTextRole.Button, color = foreground) + } +} + +@Composable +fun ShellIconButton( + glyph: String, + description: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, +) { + ShellButton( + glyph, + description, + onClick, + modifier.sizeIn(minWidth = HarvestCircleDesign.MINIMUM_TARGET_DP.dp), + enabled, + kind = ShellButtonKind.Quiet, + ) +} + +@Composable +fun ShellTab( + label: String, + description: String, + selected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, +) { + ShellButton(label, description, onClick, modifier, enabled, selected, ShellButtonKind.Quiet) +} + +@Composable +fun ShellTextField( + value: String, + onValueChange: (String) -> Unit, + label: String, + placeholder: String, + modifier: Modifier = Modifier, + enabled: Boolean = true, + visualTransformation: VisualTransformation = VisualTransformation.None, +) { + var focused by remember { mutableStateOf(false) } + val palette = LocalHarvestCirclePalette.current + Column { + ShellText(label, textRole = ShellTextRole.Secondary) + BasicTextField( + value = value, + onValueChange = onValueChange, + enabled = enabled, + visualTransformation = visualTransformation, + textStyle = LocalHarvestCircleTypography.current.body.toTextStyle(palette.textPrimary.toComposeColor()), + modifier = + modifier + .fillMaxWidth() + .heightIn(min = HarvestCircleDesign.PRIMARY_CONTROL_DP.dp) + .background(palette.surface.toComposeColor(), RoundedCornerShape(LocalHarvestCircleShapes.current.controlRadiusDp.dp)) + .border( + HarvestCircleDesign.BORDER_DP.dp, + if (focused) palette.focus.toComposeColor() else palette.border.toComposeColor(), + RoundedCornerShape(LocalHarvestCircleShapes.current.controlRadiusDp.dp), + ).onFocusChanged { focused = it.isFocused } + .semantics { + contentDescription = label + if (!enabled) disabled() + }.padding(PaddingValues(HarvestCircleDesign.spacingDp[3].dp)), + decorationBox = { innerTextField -> + Box { + if (value.isEmpty()) ShellText(placeholder, textRole = ShellTextRole.Secondary) + innerTextField() + } + }, + ) + } +} + +@Composable +fun ShellBadge( + label: String, + modifier: Modifier = Modifier, +) { + val palette = LocalHarvestCirclePalette.current + Box( + modifier + .semantics(mergeDescendants = true) {} + .background(palette.surfaceSecondary.toComposeColor(), RoundedCornerShape(LocalHarvestCircleShapes.current.smallRadiusDp.dp)) + .border( + HarvestCircleDesign.BORDER_DP.dp, + palette.border.toComposeColor(), + RoundedCornerShape(LocalHarvestCircleShapes.current.smallRadiusDp.dp), + ).padding(horizontal = HarvestCircleDesign.spacingDp[2].dp, vertical = HarvestCircleDesign.spacingDp[1].dp), + ) { + ShellText(label, textRole = ShellTextRole.Secondary) + } +} + +@Composable +fun ShellCard( + modifier: Modifier = Modifier, + content: @Composable () -> Unit, +) { + val palette = LocalHarvestCirclePalette.current + Box( + modifier + .background(palette.surface.toComposeColor(), RoundedCornerShape(LocalHarvestCircleShapes.current.surfaceRadiusDp.dp)) + .border( + HarvestCircleDesign.BORDER_DP.dp, + palette.border.toComposeColor(), + RoundedCornerShape(LocalHarvestCircleShapes.current.surfaceRadiusDp.dp), + ).padding(HarvestCircleDesign.spacingDp[4].dp), + ) { + content() + } +} + +@Composable +fun ShellDivider(modifier: Modifier = Modifier) { + Box( + modifier + .fillMaxWidth() + .heightIn( + min = HarvestCircleDesign.BORDER_DP.dp, + ).background(LocalHarvestCirclePalette.current.border.toComposeColor()), + ) +} + +private fun TypographyToken.toTextStyle(color: Color): TextStyle = + TextStyle( + color = color, + fontSize = sizeSp.sp, + fontWeight = if (weight == FontWeightToken.Semibold) FontWeight.SemiBold else FontWeight.Normal, + fontFamily = if (monospace) FontFamily.Monospace else FontFamily.Default, + ) 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,21 +1,10 @@ package org.harvestcircle.ui.shell -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.padding -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.semantics.Role -import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.disabled -import androidx.compose.ui.semantics.role -import androidx.compose.ui.semantics.selected -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 @@ -28,8 +17,9 @@ fun WorkspaceSidebar( onScreen: (ScreenKey) -> Unit, ) { Column(Modifier.testTag("workspace-sidebar")) { - BasicText("Workspace", Modifier.testTag("workspace-label")) - BasicText("Personal", Modifier.testTag("workspace-personal")) + ShellText("Workspace", Modifier.testTag("workspace-label"), ShellTextRole.SectionTitle) + ShellText("Personal", Modifier.testTag("workspace-personal"), ShellTextRole.Secondary) + ShellDivider() shellNavigationItems.forEach { item -> SidebarItem(item, selected == item.screenKey, onScreen) } @@ -50,42 +40,25 @@ private fun SidebarItem( } else { "${item.label}. ${item.unavailableExplanation}" } - BasicText( - text = item.label, - modifier = - Modifier - .fillMaxWidth() - .heightIn(min = 44.dp) - .semantics { - contentDescription = description - role = Role.Tab - this.selected = selected - if (!item.enabled) disabled() - }.then( - if (item.enabled) { - Modifier.clickable(role = Role.Tab) { onScreen(item.screenKey) } - } else { - Modifier - }, - ).padding(horizontal = 16.dp, vertical = 12.dp) - .testTag("sidebar-${item.screenKey.name}"), + ShellTab( + label = item.label, + description = description, + selected = selected, + onClick = { onScreen(item.screenKey) }, + modifier = Modifier.fillMaxWidth().testTag("sidebar-${item.screenKey.name}"), + enabled = item.enabled, ) } @Composable private fun DisabledWorkspaceAction() { val action = addFarmWorkspaceAction - BasicText( - text = action.label, - modifier = - Modifier - .fillMaxWidth() - .heightIn(min = 44.dp) - .semantics { - contentDescription = "${action.label}. ${action.unavailableExplanation}" - role = Role.Button - disabled() - }.padding(horizontal = 16.dp, vertical = 12.dp) - .testTag("sidebar-add-farm"), + ShellButton( + label = action.label, + description = "${action.label}. ${action.unavailableExplanation}", + onClick = {}, + modifier = Modifier.fillMaxWidth().testTag("sidebar-add-farm"), + enabled = false, + kind = ShellButtonKind.Quiet, ) } diff --git a/app/shared/src/commonTest/kotlin/org/harvestcircle/ui/shell/ShellControlsTest.kt b/app/shared/src/commonTest/kotlin/org/harvestcircle/ui/shell/ShellControlsTest.kt @@ -0,0 +1,16 @@ +package org.harvestcircle.ui.shell + +import kotlin.test.Test +import kotlin.test.assertEquals + +class ShellControlsTest { + @Test + fun visualStateUsesAccessibilityAndInteractionPriority() { + assertEquals(ShellControlVisualState.Disabled, shellControlVisualState(false, true, true, true, true)) + assertEquals(ShellControlVisualState.Focused, shellControlVisualState(true, true, true, true, true)) + assertEquals(ShellControlVisualState.Pressed, shellControlVisualState(true, true, false, true, true)) + assertEquals(ShellControlVisualState.Selected, shellControlVisualState(true, true, false, false, true)) + assertEquals(ShellControlVisualState.Hovered, shellControlVisualState(true, false, false, false, true)) + assertEquals(ShellControlVisualState.Normal, shellControlVisualState(true, false, false, false, false)) + } +} diff --git a/app/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/ShellControlsUiTest.kt b/app/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/ShellControlsUiTest.kt @@ -0,0 +1,41 @@ +package org.harvestcircle.ui.shell + +import androidx.compose.foundation.layout.Column +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertHeightIsAtLeast +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.assertIsSelected +import androidx.compose.ui.test.assertTextContains +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.v2.runComposeUiTest +import androidx.compose.ui.unit.dp +import kotlin.test.Test + +@OptIn(ExperimentalTestApi::class) +class ShellControlsUiTest { + @Test + fun controlsExposeTargetsSelectionDisabledStateAndFieldCopy() = + runComposeUiTest { + setContent { + Column { + ShellTab( + "Today", + "Show Today", + selected = true, + onClick = {}, + modifier = Modifier.testTag("control-tab"), + ) + ShellButton("Unavailable", "Unavailable action", {}, Modifier.testTag("control-disabled"), enabled = false) + ShellTextField("", {}, "Nostr reference", "npub1…", Modifier.testTag("control-field")) + ShellIconButton("?", "Help", {}, Modifier.testTag("control-icon")) + } + } + + onNodeWithTag("control-tab").assertIsSelected().assertHeightIsAtLeast(44.dp) + onNodeWithTag("control-disabled").assertIsNotEnabled() + onNodeWithTag("control-field").assertTextContains("npub1…") + onNodeWithTag("control-icon").assertHeightIsAtLeast(44.dp) + } +}