diff --git a/core/ui/README.md b/core/ui/README.md index 7a584915a3..308a2f7b56 100644 --- a/core/ui/README.md +++ b/core/ui/README.md @@ -40,7 +40,7 @@ Column( - **Card / Section**: [`AfternoteOutlinedCard`](src/main/kotlin/com/afternote/core/ui/AfternoteOutlinedCard.kt) / [`AfternoteSectionHeader`](src/main/kotlin/com/afternote/core/ui/AfternoteSectionHeader.kt) - **Profile**: [`ProfileImage`](src/main/kotlin/com/afternote/core/ui/ProfileImage.kt) / [`ProfileImagePicker`](src/main/kotlin/com/afternote/core/ui/ProfileImage.kt) - **Calendar**: [`BottomSheetCalendar`](src/main/kotlin/com/afternote/core/ui/calendar/BottomSheetCalendar.kt) -- **Checkbox / Radio**: [`AfternoteCircularCheckbox`](src/main/kotlin/com/afternote/core/ui/button/AfternoteCircularCheckbox.kt) / [`CustomRadioButton`](src/main/kotlin/com/afternote/core/ui/button/CustomRadioButton.kt) +- **Checkbox / Radio**: [`AfternoteCircularCheckbox`](src/main/kotlin/com/afternote/core/ui/button/AfternoteCircularCheckbox.kt) / [`AfternoteRadioGroup`](src/main/kotlin/com/afternote/core/ui/button/AfternoteRadioGroup.kt) (`selectedValue: T?` + `onSelect(T)` 단일 선택 계약) - **FAB**: [`AfternoteFloatingActionButton`](src/main/kotlin/com/afternote/core/ui/button/FAB/AfternoteFloatingActionButton.kt) / [`PenFloatingActionButton`](src/main/kotlin/com/afternote/core/ui/button/FAB/PenFloatingActionButton.kt) - **Icon**: [`CloseIcon`](src/main/kotlin/com/afternote/core/ui/icon/CloseIcon.kt) / [`RightArrowIcon`](src/main/kotlin/com/afternote/core/ui/icon/RightArrowIcon.kt) - **Badge**: [`RecipientDesignationBadge`](src/main/kotlin/com/afternote/core/ui/badge/RecipientDesignationBadge.kt) / [`CircularCheckboxOutlineChip`](src/main/kotlin/com/afternote/core/ui/badge/CircularCheckboxOutlineChip.kt) @@ -87,6 +87,7 @@ core/ui/ |---|---| | `Material3 Card` 단순 컨테이너 | `Column + clip/background/padding` (위 참조) | | `Color(0xFF...)` 하드코드 | `AfternoteDesign.colors.*` 토큰 | +| 라디오 인디케이터를 단품으로 조합해 선택 상태 직접 관리 | `AfternoteRadioGroup(selectedValue, onSelect)`로 단일 선택 구조화 | | ViewModel 에 `Context` 주입 (string resource 용) | UiState 에 sealed error / `@StringRes Int?` 노출 → UI 가 `stringResource` 변환 | | ViewModel 에 `Channel` / `SharedFlow` | UiState 의 nullable 필드 + `onXxxShown()` 콜백 | | Repository 우회해 ViewModel 이 `DataSource` / cache 직접 주입 | Repository 만 진입점, cache 는 Repository 내부 책임 | diff --git a/core/ui/src/main/kotlin/com/afternote/core/ui/button/AfternoteRadioGroup.kt b/core/ui/src/main/kotlin/com/afternote/core/ui/button/AfternoteRadioGroup.kt new file mode 100644 index 0000000000..607848bec4 --- /dev/null +++ b/core/ui/src/main/kotlin/com/afternote/core/ui/button/AfternoteRadioGroup.kt @@ -0,0 +1,162 @@ +package com.afternote.core.ui.button + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.minimumInteractiveComponentSize +import androidx.compose.material3.ripple +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.remember +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.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.afternote.core.ui.theme.AfternoteDesign +import com.afternote.core.ui.theme.AfternoteTheme + +/** + * 하나의 [selectedValue]만 받는 공용 라디오 그룹입니다. + * + * 그룹에는 [Modifier.selectableGroup]을, 각 옵션 행에는 하나의 [Role.RadioButton] semantics를 + * 부여합니다. 원형 인디케이터는 입력·선택 semantics가 없는 내부 시각 요소이므로 클릭 영역과 + * 선택 상태가 중복 노출되지 않습니다. 같은 값이 둘 이상 있으면 한 값으로 여러 행이 선택될 수 + * 있으므로 중복 [options]는 허용하지 않습니다. + * + * [itemDecoration]은 행의 레이아웃·시각 장식만 추가하는 용도입니다. 선택 입력은 그룹이 소유하도록 + * clickable/selectable semantics를 추가하지 마세요. + */ +@Composable +fun AfternoteRadioGroup( + options: List, + selectedValue: T?, + onSelect: (T) -> Unit, + modifier: Modifier = Modifier, + verticalArrangement: Arrangement.Vertical = Arrangement.spacedBy(12.dp), + horizontalArrangement: Arrangement.Horizontal = Arrangement.spacedBy(16.dp), + itemContentPadding: PaddingValues = PaddingValues(0.dp), + itemDecoration: @Composable (T, Boolean) -> Modifier = { _, _ -> Modifier }, + itemContent: @Composable RowScope.(T, Boolean) -> Unit, +) { + require(options.distinct().size == options.size) { + "AfternoteRadioGroup options must be unique." + } + + Column( + modifier = modifier.selectableGroup(), + verticalArrangement = verticalArrangement, + ) { + options.forEach { option -> + key(option) { + val selected = option == selectedValue + val interactionSource = remember { MutableInteractionSource() } + + Row( + modifier = + itemDecoration(option, selected) + .selectable( + selected = selected, + onClick = { onSelect(option) }, + role = Role.RadioButton, + interactionSource = interactionSource, + indication = ripple(), + ).minimumInteractiveComponentSize() + .padding(itemContentPadding), + horizontalArrangement = horizontalArrangement, + verticalAlignment = Alignment.CenterVertically, + ) { + RadioIndicator(selected = selected) + itemContent(option, selected) + } + } + } + } +} + +/** 입력 semantics 없이 선택 상태만 그리는 그룹 내부 인디케이터. */ +@Composable +private fun RadioIndicator( + selected: Boolean, + modifier: Modifier = Modifier, + buttonSize: Dp = 24.dp, + selectedColor: Color = AfternoteDesign.colors.gray9, + unselectedColor: Color = AfternoteDesign.colors.gray4, +) { + val borderWidth = 1.dp + val spacing = buttonSize / 12f + val maxIndicatorSize = buttonSize - (borderWidth * 2) - (spacing * 2) + + val targetBorderColor = if (selected) selectedColor else unselectedColor + val animatedBorderColor by animateColorAsState( + targetValue = targetBorderColor, + animationSpec = tween(durationMillis = 150), + label = "RadioIndicatorBorderColor", + ) + val indicatorSize by animateDpAsState( + targetValue = if (selected) maxIndicatorSize else 0.dp, + animationSpec = tween(durationMillis = 150), + label = "RadioIndicatorSize", + ) + + Box( + modifier = modifier, + contentAlignment = Alignment.Center, + ) { + Box( + modifier = + Modifier + .size(buttonSize) + .clip(CircleShape) + .border( + width = borderWidth, + color = animatedBorderColor, + shape = CircleShape, + ), + contentAlignment = Alignment.Center, + ) { + if (indicatorSize > 0.dp) { + Box( + modifier = + Modifier + .size(indicatorSize) + .background( + color = animatedBorderColor, + shape = CircleShape, + ), + ) + } + } + } +} + +@Preview(showBackground = true) +@Composable +private fun AfternoteRadioGroupPreview() { + AfternoteTheme { + AfternoteRadioGroup( + options = listOf("첫 번째", "두 번째"), + selectedValue = "첫 번째", + onSelect = {}, + modifier = Modifier.padding(16.dp), + ) { _, _ -> } + } +} diff --git a/core/ui/src/screenshotTest/kotlin/com/afternote/core/ui/CustomRadioButtonScreenshotTest.kt b/core/ui/src/screenshotTest/kotlin/com/afternote/core/ui/CustomRadioButtonScreenshotTest.kt index 8da7001311..36e47d0006 100644 --- a/core/ui/src/screenshotTest/kotlin/com/afternote/core/ui/CustomRadioButtonScreenshotTest.kt +++ b/core/ui/src/screenshotTest/kotlin/com/afternote/core/ui/CustomRadioButtonScreenshotTest.kt @@ -2,21 +2,25 @@ package com.afternote.core.ui import androidx.compose.runtime.Composable import androidx.compose.ui.tooling.preview.Preview -import com.afternote.core.ui.button.CustomRadioButton +import com.afternote.core.ui.button.AfternoteRadioGroup import com.afternote.core.ui.theme.AfternoteTheme import com.android.tools.screenshot.PreviewTest /** - * [CustomRadioButton] 의 선택/비선택 두 케이스 baseline. + * [AfternoteRadioGroup]의 선택/비선택 두 케이스 baseline. * - * 의도된 시각 변경 시 `./gradlew :core:ui:updateScreenshotTest` 로 baseline 갱신. + * baseline은 GitHub Actions의 screenshot-baseline workflow에서 갱신합니다. */ @PreviewTest @Preview(showBackground = true) @Composable internal fun customRadioButtonSelectedScreenshot() { AfternoteTheme { - CustomRadioButton(selected = true) + AfternoteRadioGroup( + options = listOf(Unit), + selectedValue = Unit, + onSelect = {}, + ) { _, _ -> } } } @@ -25,6 +29,10 @@ internal fun customRadioButtonSelectedScreenshot() { @Composable internal fun customRadioButtonUnselectedScreenshot() { AfternoteTheme { - CustomRadioButton(selected = false) + AfternoteRadioGroup( + options = listOf(Unit), + selectedValue = null, + onSelect = {}, + ) { _, _ -> } } } diff --git a/core/ui/src/screenshotTestDebug/reference/com/afternote/core/ui/CustomRadioButtonScreenshotTestKt/customRadioButtonSelectedScreenshot_748aa731_0.png b/core/ui/src/screenshotTestDebug/reference/com/afternote/core/ui/CustomRadioButtonScreenshotTestKt/customRadioButtonSelectedScreenshot_748aa731_0.png index bbeea81ced..8d047f16d9 100644 Binary files a/core/ui/src/screenshotTestDebug/reference/com/afternote/core/ui/CustomRadioButtonScreenshotTestKt/customRadioButtonSelectedScreenshot_748aa731_0.png and b/core/ui/src/screenshotTestDebug/reference/com/afternote/core/ui/CustomRadioButtonScreenshotTestKt/customRadioButtonSelectedScreenshot_748aa731_0.png differ diff --git a/core/ui/src/screenshotTestDebug/reference/com/afternote/core/ui/CustomRadioButtonScreenshotTestKt/customRadioButtonUnselectedScreenshot_748aa731_0.png b/core/ui/src/screenshotTestDebug/reference/com/afternote/core/ui/CustomRadioButtonScreenshotTestKt/customRadioButtonUnselectedScreenshot_748aa731_0.png index 56e9de9a1c..f7e3aae7fd 100644 Binary files a/core/ui/src/screenshotTestDebug/reference/com/afternote/core/ui/CustomRadioButtonScreenshotTestKt/customRadioButtonUnselectedScreenshot_748aa731_0.png and b/core/ui/src/screenshotTestDebug/reference/com/afternote/core/ui/CustomRadioButtonScreenshotTestKt/customRadioButtonUnselectedScreenshot_748aa731_0.png differ diff --git a/core/ui/src/test/kotlin/com/afternote/core/ui/button/AfternoteRadioGroupTest.kt b/core/ui/src/test/kotlin/com/afternote/core/ui/button/AfternoteRadioGroupTest.kt new file mode 100644 index 0000000000..d212cc72ba --- /dev/null +++ b/core/ui/src/test/kotlin/com/afternote/core/ui/button/AfternoteRadioGroupTest.kt @@ -0,0 +1,104 @@ +package com.afternote.core.ui.button + +import androidx.compose.material3.Text +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.assertIsNotSelected +import androidx.compose.ui.test.assertIsSelected +import androidx.compose.ui.test.getUnclippedBoundsInRoot +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.performClick +import androidx.compose.ui.unit.dp +import com.afternote.core.ui.theme.AfternoteTheme +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class AfternoteRadioGroupTest { + @get:Rule + val composeRule = createComposeRule() + + @Test + fun `nullable 미선택에서 누른 값 하나만 선택하고 정확한 값을 전달한다`() { + val selectedValues = mutableListOf() + composeRule.setContent { + var selectedValue by remember { mutableStateOf(null) } + + AfternoteTheme { + AfternoteRadioGroup( + options = OPTIONS, + selectedValue = selectedValue, + onSelect = { + selectedValues += it + selectedValue = it + }, + ) { option, _ -> + Text(text = option) + } + } + } + + composeRule.onAllNodes(radioRoleMatcher).apply { + assertCountEquals(2) + get(0).assertIsNotSelected() + get(1).assertIsNotSelected() + get(1).performClick() + get(0).assertIsNotSelected() + get(1).assertIsSelected() + get(0).performClick() + get(0).assertIsSelected() + get(1).assertIsNotSelected() + } + + composeRule.runOnIdle { + assertEquals(listOf("두 번째", "첫 번째"), selectedValues) + } + } + + @Test + fun `옵션마다 하나의 라디오 semantics와 48dp 터치 영역만 노출한다`() { + composeRule.setContent { + AfternoteTheme { + AfternoteRadioGroup( + options = OPTIONS, + selectedValue = OPTIONS.first(), + onSelect = {}, + ) { option, _ -> + Text(text = option) + } + } + } + + composeRule.onAllNodes(radioRoleMatcher).assertCountEquals(2) + composeRule.onAllNodes(radioRoleMatcher, useUnmergedTree = true).apply { + assertCountEquals(2) + get(0).assertIsSelected() + get(1).assertIsNotSelected() + repeat(2) { index -> + val bounds = get(index).getUnclippedBoundsInRoot() + assertTrue(bounds.right - bounds.left >= 48.dp) + assertTrue(bounds.bottom - bounds.top >= 48.dp) + } + } + composeRule.onAllNodes(selectedMatcher).assertCountEquals(1) + composeRule.onAllNodes(selectedMatcher, useUnmergedTree = true).assertCountEquals(1) + } + + private companion object { + val OPTIONS = listOf("첫 번째", "두 번째") + val radioRoleMatcher = SemanticsMatcher.expectValue(SemanticsProperties.Role, Role.RadioButton) + val selectedMatcher = SemanticsMatcher.expectValue(SemanticsProperties.Selected, true) + } +}