app

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

commit 824dfdc2cbc8bf281d7413f70598c08ab6bf6bbd
parent 25a69d06371dc855e297c9f57d34756730109ba2
Author: triesap <tyson@radroots.org>
Date:   Thu, 13 Aug 2026 15:32:10 +0000

design: port HarvestCircle choice components

- own popup menus navigation items and tab controls
- port checkbox radio and switch selection behavior
- preserve disabled semantics and independent hover state
- require visible keyboard focus across every public choice control

Diffstat:
Aapp/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/component/menu/HarvestCirclePopupButton.kt | 317+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aapp/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/component/navigation/HarvestCircleNavigationItem.kt | 122+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aapp/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/component/navigation/HarvestCircleTabs.kt | 136+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aapp/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/component/selection/HarvestCircleSelectionControls.kt | 433+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
4 files changed, 1008 insertions(+), 0 deletions(-)

diff --git a/app/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/component/menu/HarvestCirclePopupButton.kt b/app/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/component/menu/HarvestCirclePopupButton.kt @@ -0,0 +1,317 @@ +package org.harvestcircle.designsystem.component.menu + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.focusable +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.layout.Spacer +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntRect +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Popup +import androidx.compose.ui.window.PopupPositionProvider +import androidx.compose.ui.window.PopupProperties +import org.harvestcircle.designsystem.component.HarvestCircleButtonVariant +import org.harvestcircle.designsystem.component.HarvestCircleContentTone +import org.harvestcircle.designsystem.component.HarvestCircleControlSize +import org.harvestcircle.designsystem.component.HarvestCircleFocusRing +import org.harvestcircle.designsystem.component.HarvestCircleIconSize +import org.harvestcircle.designsystem.component.HarvestCircleTextRole +import org.harvestcircle.designsystem.component.action.HarvestCircleButton +import org.harvestcircle.designsystem.icon.HarvestCircleIcons +import org.harvestcircle.designsystem.internal.interaction.harvestCircleHoverable +import org.harvestcircle.designsystem.internal.interaction.harvestCircleInteractions +import org.harvestcircle.designsystem.internal.interaction.rememberHarvestCircleInteractionSources +import org.harvestcircle.designsystem.primitive.HarvestCircleIcon +import org.harvestcircle.designsystem.primitive.HarvestCircleSurface +import org.harvestcircle.designsystem.primitive.HarvestCircleSurfaceRole +import org.harvestcircle.designsystem.primitive.HarvestCircleText +import org.harvestcircle.designsystem.primitive.ProvideHarvestCircleContentColor +import org.harvestcircle.designsystem.theme.HarvestCircleInputMode +import org.harvestcircle.designsystem.theme.HarvestCircleTheme + +/** One value in a canonical macOS popup button menu. */ +public data class HarvestCircleMenuOption<T>( + public val value: T, + public val label: String, + public val enabled: Boolean = true, +) + +private fun <T> nextEnabledIndex( + options: List<HarvestCircleMenuOption<T>>, + current: Int, + direction: Int, +): Int { + if (options.isEmpty()) return -1 + var candidate = current + repeat(options.size) { + candidate = (candidate + direction + options.size).mod(options.size) + if (options[candidate].enabled) return candidate + } + return current +} + +/** Canonical macOS popup button: a compact bezeled control opening a keyboard-operable menu. */ +@Composable +public fun <T> HarvestCirclePopupButton( + selectedValue: T, + options: List<HarvestCircleMenuOption<T>>, + onValueChange: (T) -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + size: HarvestCircleControlSize = HarvestCircleControlSize.Medium, + focusRing: HarvestCircleFocusRing = HarvestCircleFocusRing.WhenFocused, +) { + require(options.isNotEmpty()) { "HarvestCirclePopupButton requires at least one option" } + + var expanded by remember { mutableStateOf(false) } + var anchorWidthPx by remember { mutableIntStateOf(0) } + var activeIndex by remember(options, selectedValue) { + mutableIntStateOf(options.indexOfFirst { it.value == selectedValue }.coerceAtLeast(0)) + } + val selected = options.firstOrNull { it.value == selectedValue } ?: options.first() + val menuItemMinimumHeight = + if (HarvestCircleTheme.component.inputMode == HarvestCircleInputMode.Touch) { + HarvestCircleTheme.shell.dimensions.minimumInteractive + } else { + HarvestCircleTheme.shell.dimensions.menuItemHeight + } + val density = LocalDensity.current + val focusManager = LocalFocusManager.current + val focusRequester = remember { FocusRequester() } + val gapPx = with(density) { 4.dp.roundToPx() } + val anchorWidth = with(density) { anchorWidthPx.toDp() } + val positionProvider = + remember(gapPx) { + object : PopupPositionProvider { + override fun calculatePosition( + anchorBounds: IntRect, + windowSize: IntSize, + layoutDirection: LayoutDirection, + popupContentSize: IntSize, + ): IntOffset { + val preferredX = + if (layoutDirection == LayoutDirection.Ltr) { + anchorBounds.left + } else { + anchorBounds.right - popupContentSize.width + } + val x = + preferredX.coerceIn( + minimumValue = 0, + maximumValue = (windowSize.width - popupContentSize.width).coerceAtLeast(0), + ) + val below = anchorBounds.bottom + gapPx + val y = + if (below + popupContentSize.height <= windowSize.height) { + below + } else { + (anchorBounds.top - popupContentSize.height - gapPx).coerceAtLeast(0) + } + return IntOffset(x, y) + } + } + } + + Box( + modifier = + modifier.onGloballyPositioned { + anchorWidthPx = it.size.width + }, + ) { + HarvestCircleButton( + onClick = { expanded = !expanded }, + variant = HarvestCircleButtonVariant.Secondary, + size = size, + enabled = enabled, + focusRing = focusRing, + ) { + HarvestCircleText( + text = selected.label, + role = HarvestCircleTextRole.Label, + tone = HarvestCircleContentTone.Inherit, + maxLines = 1, + ) + Spacer(Modifier.width(HarvestCircleTheme.foundation.spacing.md)) + HarvestCircleIcon( + resource = HarvestCircleIcons.ChevronDown, + contentDescription = null, + size = HarvestCircleIconSize.Small, + tone = HarvestCircleContentTone.Inherit, + ) + } + + if (expanded) { + Popup( + popupPositionProvider = positionProvider, + onDismissRequest = { + expanded = false + focusManager.clearFocus() + }, + properties = PopupProperties(focusable = true), + ) { + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } + + HarvestCircleSurface( + modifier = + Modifier + .widthIn( + min = anchorWidth.coerceAtLeast(160.dp), + max = 360.dp, + ).focusRequester(focusRequester) + .focusable() + .onPreviewKeyEvent { event -> + if (event.type != KeyEventType.KeyDown) { + return@onPreviewKeyEvent false + } + when (event.key) { + Key.DirectionDown -> { + activeIndex = nextEnabledIndex(options, activeIndex, 1) + true + } + + Key.DirectionUp -> { + activeIndex = nextEnabledIndex(options, activeIndex, -1) + true + } + + Key.Enter, + Key.Spacebar, + -> { + options.getOrNull(activeIndex)?.takeIf { it.enabled }?.let { + onValueChange(it.value) + expanded = false + } + true + } + + Key.MoveHome -> { + activeIndex = options.indexOfFirst { it.enabled }.coerceAtLeast(0) + true + } + + Key.MoveEnd -> { + activeIndex = options.indexOfLast { it.enabled }.coerceAtLeast(0) + true + } + + Key.Escape -> { + expanded = false + true + } + + else -> false + } + }, + role = HarvestCircleSurfaceRole.Overlay, + shape = HarvestCircleTheme.foundation.shapes.control, + border = BorderStroke(1.dp, HarvestCircleTheme.foundation.colors.border.default), + shadowElevation = HarvestCircleTheme.component.elevations.overlay, + ) { + Column(modifier = Modifier.padding(HarvestCircleTheme.foundation.spacing.xs)) { + options.forEachIndexed { index, option -> + val sources = rememberHarvestCircleInteractionSources(option.value) + val interactions = sources.harvestCircleInteractions(option.enabled) + LaunchedEffect(interactions.hovered) { + if (interactions.hovered && option.enabled) { + activeIndex = index + } + } + val active = index == activeIndex + val selectedOption = option.value == selectedValue + val container = + when { + active && option.enabled -> HarvestCircleTheme.foundation.colors.action.primary.rest + else -> HarvestCircleTheme.foundation.colors.surface.overlay + } + val contentColor = + when { + !option.enabled -> HarvestCircleTheme.foundation.colors.content.disabled + active -> HarvestCircleTheme.foundation.colors.content.inverse + else -> HarvestCircleTheme.foundation.colors.content.primary + } + + ProvideHarvestCircleContentColor(contentColor) { + Row( + modifier = + Modifier + .fillMaxWidth() + .defaultMinSize(minHeight = menuItemMinimumHeight) + .clip(HarvestCircleTheme.foundation.shapes.control) + .background(container) + .harvestCircleHoverable(sources = sources, enabled = option.enabled) + .clickable( + interactionSource = sources.activationSource, + indication = null, + enabled = option.enabled, + role = Role.Button, + onClick = { + onValueChange(option.value) + expanded = false + }, + ).padding(horizontal = HarvestCircleTheme.foundation.spacing.md), + horizontalArrangement = Arrangement.spacedBy(HarvestCircleTheme.foundation.spacing.sm), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier.width(HarvestCircleTheme.shell.dimensions.iconSmall), + contentAlignment = Alignment.Center, + ) { + if (selectedOption) { + HarvestCircleIcon( + resource = HarvestCircleIcons.Check, + contentDescription = null, + size = HarvestCircleIconSize.Small, + tone = HarvestCircleContentTone.Inherit, + ) + } + } + HarvestCircleText( + text = option.label, + role = HarvestCircleTextRole.Body, + tone = HarvestCircleContentTone.Inherit, + maxLines = 1, + ) + } + } + } + } + } + } + } + } +} diff --git a/app/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/component/navigation/HarvestCircleNavigationItem.kt b/app/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/component/navigation/HarvestCircleNavigationItem.kt @@ -0,0 +1,122 @@ +package org.harvestcircle.designsystem.component.navigation + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.selection.selectable +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.semantics.Role +import org.harvestcircle.designsystem.component.HarvestCircleContentTone +import org.harvestcircle.designsystem.component.HarvestCircleFocusRing +import org.harvestcircle.designsystem.component.HarvestCircleIconSize +import org.harvestcircle.designsystem.component.HarvestCircleTextRole +import org.harvestcircle.designsystem.internal.chrome.HarvestCircleMacFocusFrame +import org.harvestcircle.designsystem.internal.interaction.harvestCircleHoverable +import org.harvestcircle.designsystem.internal.interaction.harvestCircleInteractions +import org.harvestcircle.designsystem.internal.interaction.rememberHarvestCircleInteractionSources +import org.harvestcircle.designsystem.primitive.HarvestCircleIcon +import org.harvestcircle.designsystem.primitive.HarvestCircleText +import org.harvestcircle.designsystem.primitive.ProvideHarvestCircleContentColor +import org.harvestcircle.designsystem.theme.HarvestCircleTheme +import org.jetbrains.compose.resources.DrawableResource + +/** + * Inset macOS sidebar row. + * + * [emphasized] represents an active key window. Set it to false for an inactive-window selection. + * The optional focus ring is disabled by default. + */ +@Composable +public fun HarvestCircleNavigationItem( + selected: Boolean, + onClick: () -> Unit, + label: String, + modifier: Modifier = Modifier, + icon: DrawableResource? = null, + enabled: Boolean = true, + trailingContent: (@Composable RowScope.() -> Unit)? = null, + emphasized: Boolean = true, + focusRing: HarvestCircleFocusRing = HarvestCircleFocusRing.WhenFocused, +) { + val sources = rememberHarvestCircleInteractionSources() + val interactions = sources.harvestCircleInteractions(enabled) + val container = + when { + !enabled -> Color.Transparent + selected && emphasized -> HarvestCircleTheme.foundation.colors.action.primary.rest + selected -> HarvestCircleTheme.foundation.colors.surface.selected + interactions.pressed -> HarvestCircleTheme.foundation.colors.action.ghost.pressed + interactions.hovered -> HarvestCircleTheme.foundation.colors.action.ghost.hover + else -> Color.Transparent + } + val contentColor = + when { + !enabled -> HarvestCircleTheme.foundation.colors.content.disabled + selected && emphasized -> HarvestCircleTheme.foundation.colors.content.inverse + else -> HarvestCircleTheme.foundation.colors.content.primary + } + + Box( + modifier = + modifier + .defaultMinSize(minHeight = HarvestCircleTheme.shell.dimensions.minimumInteractive) + .harvestCircleHoverable(sources = sources, enabled = enabled) + .selectable( + selected = selected, + interactionSource = sources.activationSource, + indication = null, + enabled = enabled, + role = Role.Tab, + onClick = onClick, + ), + contentAlignment = Alignment.Center, + ) { + HarvestCircleMacFocusFrame( + focused = interactions.focused, + focusRing = focusRing, + shape = HarvestCircleTheme.foundation.shapes.control, + ringShape = HarvestCircleTheme.foundation.shapes.controlFocusRing, + ) { + ProvideHarvestCircleContentColor(contentColor) { + Row( + modifier = + Modifier + .height(HarvestCircleTheme.shell.dimensions.rowHeight) + .clip(HarvestCircleTheme.foundation.shapes.control) + .background(container) + .padding(horizontal = HarvestCircleTheme.foundation.spacing.md), + horizontalArrangement = Arrangement.spacedBy(HarvestCircleTheme.foundation.spacing.md), + verticalAlignment = Alignment.CenterVertically, + ) { + if (icon != null) { + HarvestCircleIcon( + resource = icon, + contentDescription = null, + size = HarvestCircleIconSize.Medium, + tone = HarvestCircleContentTone.Inherit, + ) + } + + HarvestCircleText( + text = label, + modifier = Modifier.weight(1f), + role = HarvestCircleTextRole.Label, + tone = HarvestCircleContentTone.Inherit, + maxLines = 1, + ) + + trailingContent?.invoke(this) + } + } + } + } +} diff --git a/app/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/component/navigation/HarvestCircleTabs.kt b/app/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/component/navigation/HarvestCircleTabs.kt @@ -0,0 +1,136 @@ +package org.harvestcircle.designsystem.component.navigation + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.selection.selectable +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.unit.dp +import org.harvestcircle.designsystem.component.HarvestCircleContentTone +import org.harvestcircle.designsystem.component.HarvestCircleFocusRing +import org.harvestcircle.designsystem.component.HarvestCircleTextRole +import org.harvestcircle.designsystem.internal.chrome.HarvestCircleMacBezel +import org.harvestcircle.designsystem.internal.chrome.HarvestCircleMacFocusFrame +import org.harvestcircle.designsystem.internal.chrome.harvestCircleMacControlBrush +import org.harvestcircle.designsystem.internal.interaction.harvestCircleHoverable +import org.harvestcircle.designsystem.internal.interaction.harvestCircleInteractions +import org.harvestcircle.designsystem.internal.interaction.rememberHarvestCircleInteractionSources +import org.harvestcircle.designsystem.primitive.HarvestCircleText +import org.harvestcircle.designsystem.primitive.ProvideHarvestCircleContentColor +import org.harvestcircle.designsystem.theme.HarvestCircleTheme + +/** AppKit-style segmented control container. */ +@Composable +public fun HarvestCircleTabRow( + modifier: Modifier = Modifier, + content: @Composable RowScope.() -> Unit, +) { + Row( + modifier = + modifier + .clip(HarvestCircleTheme.foundation.shapes.control) + .background(HarvestCircleTheme.foundation.colors.surface.sunken) + .border( + width = 1.dp, + color = HarvestCircleTheme.foundation.colors.border.default, + shape = HarvestCircleTheme.foundation.shapes.control, + ).padding(HarvestCircleTheme.foundation.spacing.xxs), + verticalAlignment = Alignment.CenterVertically, + content = content, + ) +} + +/** One selectable segment inside [HarvestCircleTabRow]. */ +@Composable +public fun RowScope.HarvestCircleTab( + selected: Boolean, + onClick: () -> Unit, + label: String, + modifier: Modifier = Modifier, + enabled: Boolean = true, + focusRing: HarvestCircleFocusRing = HarvestCircleFocusRing.WhenFocused, +) { + val sources = rememberHarvestCircleInteractionSources() + val interactions = sources.harvestCircleInteractions(enabled) + val container = + when { + !enabled -> Color.Transparent + selected -> HarvestCircleTheme.foundation.colors.surface.raised + interactions.pressed -> HarvestCircleTheme.foundation.colors.action.ghost.pressed + interactions.hovered -> HarvestCircleTheme.foundation.colors.action.ghost.hover + else -> Color.Transparent + } + val border = + if (selected) { + HarvestCircleTheme.foundation.colors.border.subtle + } else { + Color.Transparent + } + + Box( + modifier = + modifier + .defaultMinSize(minHeight = HarvestCircleTheme.shell.dimensions.minimumInteractive) + .harvestCircleHoverable(sources = sources, enabled = enabled) + .selectable( + selected = selected, + interactionSource = sources.activationSource, + indication = null, + enabled = enabled, + role = Role.Tab, + onClick = onClick, + ), + contentAlignment = Alignment.Center, + ) { + HarvestCircleMacFocusFrame( + focused = interactions.focused, + focusRing = focusRing, + shape = HarvestCircleTheme.foundation.shapes.control, + ringShape = HarvestCircleTheme.foundation.shapes.controlFocusRing, + ) { + HarvestCircleMacBezel( + modifier = Modifier.height(HarvestCircleTheme.shell.dimensions.controlMedium), + brush = harvestCircleMacControlBrush(container), + border = BorderStroke(1.dp, border), + shape = HarvestCircleTheme.foundation.shapes.control, + shadowElevation = if (selected) HarvestCircleTheme.component.elevations.raised else 0.dp, + ) { + ProvideHarvestCircleContentColor( + if (enabled) { + HarvestCircleTheme.foundation.colors.content.primary + } else { + HarvestCircleTheme.foundation.colors.content.disabled + }, + ) { + HarvestCircleText( + text = label, + modifier = + Modifier.padding( + horizontal = HarvestCircleTheme.foundation.spacing.lg, + vertical = HarvestCircleTheme.foundation.spacing.xs, + ), + role = HarvestCircleTextRole.Label, + tone = + if (enabled) { + HarvestCircleContentTone.Inherit + } else { + HarvestCircleContentTone.Disabled + }, + maxLines = 1, + ) + } + } + } + } +} diff --git a/app/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/component/selection/HarvestCircleSelectionControls.kt b/app/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/component/selection/HarvestCircleSelectionControls.kt @@ -0,0 +1,433 @@ +package org.harvestcircle.designsystem.component.selection + +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.snap +import androidx.compose.animation.core.tween +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.toggleable +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.lerp +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import org.harvestcircle.designsystem.component.HarvestCircleContentTone +import org.harvestcircle.designsystem.component.HarvestCircleFocusRing +import org.harvestcircle.designsystem.component.HarvestCircleIconSize +import org.harvestcircle.designsystem.component.HarvestCircleTextRole +import org.harvestcircle.designsystem.icon.HarvestCircleIcons +import org.harvestcircle.designsystem.internal.chrome.HarvestCircleMacFocusFrame +import org.harvestcircle.designsystem.internal.chrome.harvestCircleMacControlBrush +import org.harvestcircle.designsystem.internal.interaction.harvestCircleHoverable +import org.harvestcircle.designsystem.internal.interaction.harvestCircleInteractions +import org.harvestcircle.designsystem.internal.interaction.rememberHarvestCircleInteractionSources +import org.harvestcircle.designsystem.primitive.HarvestCircleIcon +import org.harvestcircle.designsystem.primitive.HarvestCircleText +import org.harvestcircle.designsystem.primitive.ProvideHarvestCircleContentColor +import org.harvestcircle.designsystem.theme.HarvestCircleTheme + +private val CheckboxShape = RoundedCornerShape(3.dp) +private val CheckboxFocusRingShape = RoundedCornerShape(7.dp) + +@Composable +private fun HarvestCircleCheckboxVisual( + checked: Boolean, + enabled: Boolean, + hovered: Boolean, + focused: Boolean, + focusRing: HarvestCircleFocusRing, +) { + val colors = HarvestCircleTheme.foundation.colors + val container = + when { + !enabled && checked -> colors.action.primary.disabled + !enabled -> colors.surface.sunken + checked -> colors.action.primary.rest + hovered -> colors.action.ghost.hover + else -> colors.surface.raised + } + val border = + when { + !enabled -> colors.border.subtle + checked -> lerp(colors.action.primary.rest, Color.Black, 0.16f) + hovered -> colors.border.strong + else -> colors.border.default + } + + HarvestCircleMacFocusFrame( + focused = focused, + focusRing = focusRing, + shape = CheckboxShape, + ringShape = CheckboxFocusRingShape, + ) { + Box( + modifier = + Modifier + .size(HarvestCircleTheme.shell.dimensions.selectionControl) + .clip(CheckboxShape) + .background( + brush = harvestCircleMacControlBrush(container, emphasized = checked), + shape = CheckboxShape, + ).border(BorderStroke(1.dp, border), CheckboxShape), + contentAlignment = Alignment.Center, + ) { + if (checked) { + ProvideHarvestCircleContentColor( + if (enabled) colors.action.primary.content else colors.content.disabled, + ) { + HarvestCircleIcon( + resource = HarvestCircleIcons.Check, + contentDescription = null, + size = HarvestCircleIconSize.Small, + tone = HarvestCircleContentTone.Inherit, + ) + } + } + } + } +} + +/** Canonical AppKit checkbox with an optional merged, clickable label. */ +@Composable +public fun HarvestCircleCheckbox( + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + label: String? = null, + focusRing: HarvestCircleFocusRing = HarvestCircleFocusRing.WhenFocused, +) { + val sources = rememberHarvestCircleInteractionSources() + val interactions = sources.harvestCircleInteractions(enabled) + val toggleModifier = + modifier + .defaultMinSize( + minWidth = HarvestCircleTheme.shell.dimensions.minimumInteractive, + minHeight = HarvestCircleTheme.shell.dimensions.minimumInteractive, + ).harvestCircleHoverable(sources = sources, enabled = enabled) + .toggleable( + value = checked, + interactionSource = sources.activationSource, + indication = null, + enabled = enabled, + role = Role.Checkbox, + onValueChange = onCheckedChange, + ) + + if (label == null) { + Box( + modifier = toggleModifier, + contentAlignment = Alignment.Center, + ) { + HarvestCircleCheckboxVisual( + checked = checked, + enabled = enabled, + hovered = interactions.hovered, + focused = interactions.focused, + focusRing = focusRing, + ) + } + } else { + Row( + modifier = toggleModifier, + horizontalArrangement = Arrangement.spacedBy(HarvestCircleTheme.foundation.spacing.sm), + verticalAlignment = Alignment.CenterVertically, + ) { + HarvestCircleCheckboxVisual( + checked = checked, + enabled = enabled, + hovered = interactions.hovered, + focused = interactions.focused, + focusRing = focusRing, + ) + HarvestCircleText( + text = label, + role = HarvestCircleTextRole.Body, + tone = if (enabled) HarvestCircleContentTone.Primary else HarvestCircleContentTone.Disabled, + ) + } + } +} + +@Composable +private fun HarvestCircleRadioVisual( + selected: Boolean, + enabled: Boolean, + hovered: Boolean, + focused: Boolean, + focusRing: HarvestCircleFocusRing, +) { + val colors = HarvestCircleTheme.foundation.colors + val container = + when { + !enabled && selected -> colors.action.primary.disabled + !enabled -> colors.surface.sunken + selected -> colors.action.primary.rest + hovered -> colors.action.ghost.hover + else -> colors.surface.raised + } + val border = + when { + !enabled -> colors.border.subtle + selected -> lerp(colors.action.primary.rest, Color.Black, 0.16f) + hovered -> colors.border.strong + else -> colors.border.default + } + + HarvestCircleMacFocusFrame( + focused = focused, + focusRing = focusRing, + shape = CircleShape, + ringShape = CircleShape, + ) { + Box( + modifier = + Modifier + .size(HarvestCircleTheme.shell.dimensions.selectionControl) + .clip(CircleShape) + .background( + brush = harvestCircleMacControlBrush(container, emphasized = selected), + shape = CircleShape, + ).border(BorderStroke(1.dp, border), CircleShape), + contentAlignment = Alignment.Center, + ) { + if (selected) { + Box( + modifier = + Modifier + .size(HarvestCircleTheme.shell.dimensions.selectionControl * 0.38f) + .clip(CircleShape) + .background( + if (enabled) { + colors.action.primary.content + } else { + colors.content.disabled + }, + ), + ) + } + } + } +} + +/** Canonical AppKit radio button with an optional merged, clickable label. */ +@Composable +public fun HarvestCircleRadioButton( + selected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + label: String? = null, + focusRing: HarvestCircleFocusRing = HarvestCircleFocusRing.WhenFocused, +) { + val sources = rememberHarvestCircleInteractionSources() + val interactions = sources.harvestCircleInteractions(enabled) + val selectableModifier = + modifier + .defaultMinSize( + minWidth = HarvestCircleTheme.shell.dimensions.minimumInteractive, + minHeight = HarvestCircleTheme.shell.dimensions.minimumInteractive, + ).harvestCircleHoverable(sources = sources, enabled = enabled) + .selectable( + selected = selected, + interactionSource = sources.activationSource, + indication = null, + enabled = enabled, + role = Role.RadioButton, + onClick = onClick, + ) + + if (label == null) { + Box( + modifier = selectableModifier, + contentAlignment = Alignment.Center, + ) { + HarvestCircleRadioVisual( + selected = selected, + enabled = enabled, + hovered = interactions.hovered, + focused = interactions.focused, + focusRing = focusRing, + ) + } + } else { + Row( + modifier = selectableModifier, + horizontalArrangement = Arrangement.spacedBy(HarvestCircleTheme.foundation.spacing.sm), + verticalAlignment = Alignment.CenterVertically, + ) { + HarvestCircleRadioVisual( + selected = selected, + enabled = enabled, + hovered = interactions.hovered, + focused = interactions.focused, + focusRing = focusRing, + ) + HarvestCircleText( + text = label, + role = HarvestCircleTextRole.Body, + tone = if (enabled) HarvestCircleContentTone.Primary else HarvestCircleContentTone.Disabled, + ) + } + } +} + +@Composable +private fun HarvestCircleSwitchVisual( + checked: Boolean, + enabled: Boolean, + hovered: Boolean, + focused: Boolean, + focusRing: HarvestCircleFocusRing, +) { + val colors = HarvestCircleTheme.foundation.colors + val width = HarvestCircleTheme.shell.dimensions.switchWidth + val height = HarvestCircleTheme.shell.dimensions.switchHeight + val knob = height - 4.dp + val travel = width - knob - 4.dp + val direction = LocalLayoutDirection.current + val targetOffset = + when { + checked && direction == LayoutDirection.Ltr -> travel + !checked && direction == LayoutDirection.Rtl -> travel + else -> 0.dp + } + val duration = HarvestCircleTheme.component.motion.standardMillis + val knobOffset = + animateDpAsState( + targetValue = targetOffset, + animationSpec = if (duration == 0) snap() else tween(durationMillis = duration), + label = "HarvestCircleSwitchKnob", + ).value + val track = + when { + !enabled && checked -> colors.action.primary.disabled + !enabled -> colors.surface.sunken + checked -> colors.action.primary.rest + hovered -> colors.border.default + else -> colors.surface.sunken + } + val border = + when { + !enabled -> colors.border.subtle + checked -> lerp(colors.action.primary.rest, Color.Black, 0.16f) + hovered -> colors.border.strong + else -> colors.border.default + } + + HarvestCircleMacFocusFrame( + focused = focused, + focusRing = focusRing, + shape = CircleShape, + ringShape = CircleShape, + ) { + Box( + modifier = + Modifier + .size(width = width, height = height) + .clip(CircleShape) + .background(track) + .border(BorderStroke(1.dp, border), CircleShape), + contentAlignment = Alignment.CenterStart, + ) { + Box( + modifier = + Modifier + .offset(x = 2.dp + knobOffset) + .size(knob) + .shadow(1.dp, CircleShape, clip = false) + .clip(CircleShape) + .background( + if (enabled) { + colors.surface.raised + } else { + colors.content.disabled + }, + ).border( + BorderStroke(1.dp, colors.border.subtle), + CircleShape, + ), + ) + } + } +} + +/** Canonical AppKit switch with an optional merged, clickable label. */ +@Composable +public fun HarvestCircleSwitch( + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + label: String? = null, + focusRing: HarvestCircleFocusRing = HarvestCircleFocusRing.WhenFocused, +) { + val sources = rememberHarvestCircleInteractionSources() + val interactions = sources.harvestCircleInteractions(enabled) + val toggleModifier = + modifier + .defaultMinSize( + minWidth = HarvestCircleTheme.shell.dimensions.minimumInteractive, + minHeight = HarvestCircleTheme.shell.dimensions.minimumInteractive, + ).harvestCircleHoverable(sources = sources, enabled = enabled) + .toggleable( + value = checked, + interactionSource = sources.activationSource, + indication = null, + enabled = enabled, + role = Role.Switch, + onValueChange = onCheckedChange, + ) + + if (label == null) { + Box( + modifier = toggleModifier, + contentAlignment = Alignment.Center, + ) { + HarvestCircleSwitchVisual( + checked = checked, + enabled = enabled, + hovered = interactions.hovered, + focused = interactions.focused, + focusRing = focusRing, + ) + } + } else { + Row( + modifier = toggleModifier.padding(vertical = HarvestCircleTheme.foundation.spacing.xs), + horizontalArrangement = Arrangement.spacedBy(HarvestCircleTheme.foundation.spacing.md), + verticalAlignment = Alignment.CenterVertically, + ) { + HarvestCircleText( + text = label, + modifier = Modifier.weight(1f), + role = HarvestCircleTextRole.Body, + tone = if (enabled) HarvestCircleContentTone.Primary else HarvestCircleContentTone.Disabled, + ) + HarvestCircleSwitchVisual( + checked = checked, + enabled = enabled, + hovered = interactions.hovered, + focused = interactions.focused, + focusRing = focusRing, + ) + } + } +}