From ff3462eec4ec2c5d47d4d83037ab5251f177a373 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Fri, 28 Aug 2026 19:12:01 +0900 Subject: [PATCH 1/4] =?UTF-8?q?refactor(core-ui):=20=EB=9D=BC=EB=94=94?= =?UTF-8?q?=EC=98=A4=20=EB=8B=A8=EC=9D=BC=20=EC=84=A0=ED=83=9D=20=EA=B7=B8?= =?UTF-8?q?=EB=A3=B9=20API=20=EC=8A=B9=EA=B2=A9=20(#649)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- core/ui/README.md | 3 +- .../core/ui/button/AfternoteRadioGroup.kt | 162 ++++++++++++++++++ .../core/ui/button/CustomRadioButton.kt | 139 --------------- .../ui/CustomRadioButtonScreenshotTest.kt | 18 +- .../core/ui/button/AfternoteRadioGroupTest.kt | 104 +++++++++++ .../shared/SelectableRadioCard.kt | 122 ------------- feature/setting/presentation/build.gradle.kts | 7 + .../presentation/component/RadioGroup.kt | 27 +-- .../presentation/component/RadioGroupCard.kt | 97 ++++------- .../presentation/component/RadioGroupTest.kt | 80 +++++++++ 10 files changed, 420 insertions(+), 339 deletions(-) create mode 100644 core/ui/src/main/kotlin/com/afternote/core/ui/button/AfternoteRadioGroup.kt delete mode 100644 core/ui/src/main/kotlin/com/afternote/core/ui/button/CustomRadioButton.kt create mode 100644 core/ui/src/test/kotlin/com/afternote/core/ui/button/AfternoteRadioGroupTest.kt delete mode 100644 feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/shared/SelectableRadioCard.kt create mode 100644 feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/component/RadioGroupTest.kt 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/main/kotlin/com/afternote/core/ui/button/CustomRadioButton.kt b/core/ui/src/main/kotlin/com/afternote/core/ui/button/CustomRadioButton.kt deleted file mode 100644 index 7fa9c3fdf5..0000000000 --- a/core/ui/src/main/kotlin/com/afternote/core/ui/button/CustomRadioButton.kt +++ /dev/null @@ -1,139 +0,0 @@ -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.spacedBy -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.selection.selectable -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.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 - -/** - * 커스텀 라디오 버튼 컴포넌트 - * - * 체크 표시(인디케이터)와 윤곽선 간 간격이 전체 크기의 12분의 1이 되도록 자동 계산됩니다. - * [onClick]이 있으면 바깥에 [Modifier.minimumInteractiveComponentSize]와 - * [Modifier.selectable] + [Role.RadioButton]을 두어 최소 48×48dp 터치 영역과 리플을 쓰고, - * 안쪽은 [buttonSize]만큼만 그립니다. 부모가 선택을 처리할 때는 [onClick]을 null로 두면 24dp만 차지합니다. - * - * @param selected 선택 여부 - * @param onClick 클릭 이벤트 (null이면 비인터랙티브·부모에서 처리) - * @param buttonSize 전체 버튼 크기 (기본값: 24.dp) - * @param selectedColor 선택된 색상 (기본값: AfternoteDesign.colors.gray9) - * @param unselectedColor 선택 안 된 색상 (기본값: AfternoteDesign.colors.gray4) - */ -@Composable -fun CustomRadioButton( - selected: Boolean, - modifier: Modifier = Modifier, - onClick: (() -> Unit)? = null, - 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 = "CustomRadioButtonBorderColor", - ) - - val indicatorSize by animateDpAsState( - targetValue = if (selected) maxIndicatorSize else 0.dp, - animationSpec = tween(durationMillis = 150), - label = "CustomRadioButtonIndicatorSize", - ) - - val interactionSource = remember { MutableInteractionSource() } - - Box( - modifier = - modifier.then( - if (onClick != null) { - Modifier - .minimumInteractiveComponentSize() - .selectable( - selected = selected, - onClick = onClick, - role = Role.RadioButton, - interactionSource = interactionSource, - indication = ripple(), - ) - } else { - 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 CustomRadioButtonPreview() { - AfternoteTheme { - Row( - modifier = Modifier.padding(16.dp), - horizontalArrangement = spacedBy(16.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - CustomRadioButton( - selected = true, - onClick = {}, - ) - CustomRadioButton( - selected = false, - onClick = {}, - ) - } - } -} 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/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) + } +} diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/shared/SelectableRadioCard.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/shared/SelectableRadioCard.kt deleted file mode 100644 index 4f44850389..0000000000 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/shared/SelectableRadioCard.kt +++ /dev/null @@ -1,122 +0,0 @@ -package com.afternote.feature.afternote.presentation.shared - -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.selection.selectable -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.material3.ripple -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.semantics.Role -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import com.afternote.core.ui.button.CustomRadioButton -import com.afternote.core.ui.theme.AfternoteDesign -import com.afternote.core.ui.theme.AfternoteTheme - -/** - * 선택 가능한 라디오 버튼 카드 컴포넌트 - * - * @param title 카드 제목 - * @param description 카드 설명. null이거나 공백이면 표시하지 않습니다. - * @param selected 선택 여부 - * @param onClick 클릭 이벤트 - * @param modifier Modifier - */ -@Composable -fun SelectableRadioCard( - title: String, - selected: Boolean, - onClick: () -> Unit, - modifier: Modifier = Modifier, - description: String? = null, -) { - Surface( - modifier = - modifier.selectable( - selected = selected, - onClick = onClick, - role = Role.RadioButton, - interactionSource = remember { MutableInteractionSource() }, - indication = ripple(), - ), - shape = RoundedCornerShape(6.dp), - border = - BorderStroke( - 1.dp, - if (selected) { - AfternoteDesign.colors.gray8 - } else { - AfternoteDesign.colors.gray2 - }, - ), - color = AfternoteDesign.colors.white, - ) { - Row( - modifier = - Modifier - .fillMaxWidth() - .padding(16.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(16.dp), - ) { - // 내부 라디오 버튼은 클릭 이벤트 null 처리하여 부모 Surface 클릭과 충돌 방지 - CustomRadioButton( - selected = selected, - onClick = null, - buttonSize = 24.dp, - selectedColor = AfternoteDesign.colors.gray9, - unselectedColor = AfternoteDesign.colors.gray4, - ) - Column( - verticalArrangement = Arrangement.spacedBy(5.dp), - ) { - Text( - text = title, - style = - if (selected) { - AfternoteDesign.typography.primaryButton.copy( - color = AfternoteDesign.colors.gray9, - ) - } else { - AfternoteDesign.typography.bodyBase.copy( - color = AfternoteDesign.colors.gray8, - ) - }, - ) - if (!description.isNullOrBlank()) { - Text( - text = description, - style = - AfternoteDesign.typography.bodySmallR.copy( - color = AfternoteDesign.colors.gray6, - ), - ) - } - } - } - } -} - -@Preview(showBackground = true) -@Composable -private fun SelectableRadioCardPreview() { - AfternoteTheme { - // 선택됨 - 제목 + 설명 - SelectableRadioCard( - title = "제목 텍스트", - description = "설명 텍스트", - selected = true, - onClick = {}, - ) - } -} diff --git a/feature/setting/presentation/build.gradle.kts b/feature/setting/presentation/build.gradle.kts index d1cab149f9..bdc56040f6 100644 --- a/feature/setting/presentation/build.gradle.kts +++ b/feature/setting/presentation/build.gradle.kts @@ -14,6 +14,8 @@ android { buildConfig = true } + testOptions.unitTests.isIncludeAndroidResources = true + defaultConfig { buildConfigField("String", "GOOGLE_WEB_CLIENT_ID", "\"$googleWebClientId\"") } @@ -38,4 +40,9 @@ dependencies { testImplementation(libs.coroutines.test) testImplementation(testFixtures(projects.core.domain)) + testImplementation(platform(libs.androidx.compose.bom)) + testImplementation(libs.junit) + testImplementation(libs.robolectric) + testImplementation(libs.androidx.compose.ui.test.junit4) + debugImplementation(libs.androidx.compose.ui.test.manifest) } diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/component/RadioGroup.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/component/RadioGroup.kt index 0c9ef796c3..5cc449b6fc 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/component/RadioGroup.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/component/RadioGroup.kt @@ -1,10 +1,10 @@ package com.afternote.feature.setting.presentation.component -import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy -import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import com.afternote.core.ui.button.AfternoteRadioGroup @Composable fun RadioGroup( @@ -13,16 +13,19 @@ fun RadioGroup( onSelectIndex: (Int) -> Unit, modifier: Modifier = Modifier, ) { - Column( + val indexedItems = items.withIndex().toList() + + AfternoteRadioGroup( + options = indexedItems, + selectedValue = indexedItems.getOrNull(selectedIndex), + onSelect = { onSelectIndex(it.index) }, modifier = modifier, - verticalArrangement = spacedBy(12.dp), - ) { - items.forEachIndexed { index, item -> - RadioGroupCard( - item = item, - selected = index == selectedIndex, - onClick = { onSelectIndex(index) }, - ) - } + itemContentPadding = PaddingValues(16.dp), + itemDecoration = { _, selected -> radioGroupCardDecoration(selected) }, + ) { indexedItem, selected -> + RadioGroupCardContent( + item = indexedItem.value, + selected = selected, + ) } } diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/component/RadioGroupCard.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/component/RadioGroupCard.kt index 6400865cf9..cb11e0a161 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/component/RadioGroupCard.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/component/RadioGroupCard.kt @@ -3,85 +3,62 @@ package com.afternote.feature.setting.presentation.component import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.tween import androidx.compose.foundation.border -import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.selection.selectable import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Text -import androidx.compose.material3.ripple import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -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.semantics.Role import androidx.compose.ui.unit.dp -import com.afternote.core.ui.button.CustomRadioButton import com.afternote.core.ui.theme.AfternoteDesign @Composable -fun RadioGroupCard( - item: RadioGroupItem, - selected: Boolean, - onClick: () -> Unit, - modifier: Modifier = Modifier, -) { +internal fun radioGroupCardDecoration(selected: Boolean): Modifier { val borderColor by animateColorAsState( targetValue = if (selected) AfternoteDesign.colors.gray9 else AfternoteDesign.colors.gray3, animationSpec = tween(durationMillis = 150), label = "RadioGroupCardBorderColor", ) - Row( - modifier = - modifier - .fillMaxWidth() - .clip(RoundedCornerShape(6.dp)) - .border( - width = 1.dp, - color = borderColor, - shape = RoundedCornerShape(6.dp), - ).selectable( - selected = selected, - onClick = onClick, - role = Role.RadioButton, - indication = ripple(), - interactionSource = remember { MutableInteractionSource() }, - ).padding(16.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - CustomRadioButton( - selected = selected, - // onClick null → Row에서 처리 + return Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(6.dp)) + .border( + width = 1.dp, + color = borderColor, + shape = RoundedCornerShape(6.dp), + ) +} + +@Composable +internal fun RowScope.RadioGroupCardContent( + item: RadioGroupItem, + selected: Boolean, +) { + Column(verticalArrangement = spacedBy(4.dp)) { + Text( + text = item.title, + color = + if (selected) { + AfternoteDesign.colors.gray9 + } else { + AfternoteDesign.colors.gray8 + }, + style = + if (selected) { + AfternoteDesign.typography.primaryButton + } else { + AfternoteDesign.typography.bodyBase + }, + ) + Text( + text = item.description, + color = AfternoteDesign.colors.gray6, + style = AfternoteDesign.typography.bodySmallR, ) - Spacer(modifier = Modifier.padding(start = 16.dp)) - Column(verticalArrangement = spacedBy(4.dp)) { - Text( - text = item.title, - color = - if (selected) { - AfternoteDesign.colors.gray9 - } else { - AfternoteDesign.colors.gray8 - }, - style = - if (selected) { - AfternoteDesign.typography.primaryButton - } else { - AfternoteDesign.typography.bodyBase - }, - ) - Text( - text = item.description, - color = AfternoteDesign.colors.gray6, - style = AfternoteDesign.typography.bodySmallR, - ) - } } } diff --git a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/component/RadioGroupTest.kt b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/component/RadioGroupTest.kt new file mode 100644 index 0000000000..cfffd032cf --- /dev/null +++ b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/component/RadioGroupTest.kt @@ -0,0 +1,80 @@ +package com.afternote.feature.setting.presentation.component + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +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 RadioGroupTest { + @get:Rule + val composeRule = createComposeRule() + + @Test + fun `기존 index 계약으로 한 항목만 선택하고 선택 index를 전달한다`() { + val selectedIndices = mutableListOf() + composeRule.setContent { + var selectedIndex by remember { mutableIntStateOf(0) } + + AfternoteTheme { + RadioGroup( + items = ITEMS, + selectedIndex = selectedIndex, + onSelectIndex = { + selectedIndices += it + selectedIndex = it + }, + ) + } + } + + composeRule.onAllNodes(radioRoleMatcher).apply { + assertCountEquals(2) + get(0).assertIsSelected() + get(1).assertIsNotSelected() + get(1).performClick() + get(0).assertIsNotSelected() + get(1).assertIsSelected() + } + composeRule.runOnIdle { + assertEquals(listOf(1), selectedIndices) + } + + composeRule.onAllNodes(radioRoleMatcher, useUnmergedTree = true).apply { + assertCountEquals(2) + repeat(2) { index -> + val bounds = get(index).getUnclippedBoundsInRoot() + assertTrue(bounds.right - bounds.left >= 48.dp) + assertTrue(bounds.bottom - bounds.top >= 48.dp) + } + } + } + + private companion object { + val ITEMS = + listOf( + RadioGroupItem(title = "첫 번째", description = "첫 번째 설명"), + RadioGroupItem(title = "두 번째", description = "두 번째 설명"), + ) + val radioRoleMatcher = SemanticsMatcher.expectValue(SemanticsProperties.Role, Role.RadioButton) + } +} From 640a3c4d95ee3a524f9855b95b1b2d9f6e943978 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 28 Aug 2026 10:39:23 +0000 Subject: [PATCH 2/4] test: update screenshot baselines in CI --- ...dioButtonSelectedScreenshot_748aa731_0.png | Bin 2100 -> 2497 bytes ...oButtonUnselectedScreenshot_748aa731_0.png | Bin 1296 -> 1649 bytes 2 files changed, 0 insertions(+), 0 deletions(-) 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 bbeea81cedfb6c0d641da911f9d04122c856c474..8d047f16d9a50ff4a7893ba53d5c0e695ffe4de6 100644 GIT binary patch literal 2497 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 literal 2100 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 literal 1296 zcmV+r1@HQaP)j{000EnNklY@4ffld*2tX4w55dUc-iGI(UD7k3~#QPR0VO zLVeol*M+D}{P*{Fbb5LkjgOC~MAQN8(mwszzN=D;`1AAgXk=t0HDDMU8;c+H!^6Y4 z-1YT!^!WG~y}iA~b@}B#>D*soGsDE&95Y5ler~07ZySuwooibDs|Mm5iIUw#B@4GEu2eeE3wok^vcx?G1lotQ; z@{)SQmzI`#zRN|SAFPA!pYbp*Tecge#PiPTPMM8+ee|lrD`%}q*}0&w`1SSmL`Pd& zTd6XCK`-O#Djw4^Gc%Lu;N;{aRqii2Iyy>>Z+3RJkV)(+er;{dH#$5#96xThOcTIV z4h;?YvGPRNGM0|wkB^UiV>FCCwq=^c!^1=30(5&qbHy`}=|_nxYs)l|%gf8exI0bA znc}gjx&a&wTc*jdK+@wEZ_<(}URGSNva*uOZv)HA%U%O|Y(AvKvz(Ml%ccJ~PiOv>hE@ zLoaEuc9o;$pT^NazR@ zx*e07B(QN{D};m&e4yLi-{1QL;^MYKNN978Zl~jNOnkONNZ{d#R&=|)y}iWMuoXgr zD3oYrneJ9sSA7Bt0b3y?w6lZP=k&6#UmJo=K?iAAuDij(L7!j?hlprpiR*OWzh7HT zO@WoFXhpZv;hx{xa{4QXK8sd#JA8Rb$O`p@1QBS_y1Tm@Ic@m$S~o;=qzZ@5>8sJU z5OrB}NE_WwzX#ZSTOlO${Dy9)0~uKn&XCZNX>`Zyd2Q}Ob#e82UZPd8cwT39$ zLqJ3Q+A0{Cl5G)lq|Jv37 z2jkY0c-FC+lz7lvJ9Be$sr)v;xJ3hcY(8X)*LEM+>ak^-47<#F{CV5$nc~5o#J+2~mTqrCa?CECW0Dtuw;K1mkG|=rGx0DhOdW9sg+5cN%Z~DBHc+Gi#40=8ezu)=SAT}8jW`W`)r2Npv(7FicT?u>j_C!dOD zeOD)+oOb)Jj(#Kz)hj)V7q|!vUWbe7gmw{K509$upW0000 Date: Fri, 28 Aug 2026 19:39:59 +0900 Subject: [PATCH 3/4] chore(ci): verify screenshot baselines (#649) From 04ee21829ade42bdb404a8cc40f7d9c727ba144d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Sat, 29 Aug 2026 04:06:11 +0900 Subject: [PATCH 4/4] =?UTF-8?q?refactor(core-ui):=20=EC=86=8C=EB=B9=84?= =?UTF-8?q?=EC=B2=98=20=EC=9D=B4=EA=B4=80=EC=9D=84=20=EB=B6=84=EB=A6=AC?= =?UTF-8?q?=ED=95=98=EA=B3=A0=20CustomRadioButton=20=EC=9D=84=20=EC=A1=B4?= =?UTF-8?q?=EC=B9=98=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 남의 담당 모듈 0줄 원칙에 따라 setting 이관과 afternote SelectableRadioCard 삭제를 이 PR 에서 제거한다. 이관 재료는 refactor/649-setting-radio-migration 브랜치와 #649 코멘트로 인계한다. --- .../core/ui/button/CustomRadioButton.kt | 139 ++++++++++++++++++ .../shared/SelectableRadioCard.kt | 122 +++++++++++++++ feature/setting/presentation/build.gradle.kts | 3 - .../presentation/component/RadioGroup.kt | 27 ++-- .../presentation/component/RadioGroupCard.kt | 97 +++++++----- .../presentation/component/RadioGroupTest.kt | 80 ---------- 6 files changed, 333 insertions(+), 135 deletions(-) create mode 100644 core/ui/src/main/kotlin/com/afternote/core/ui/button/CustomRadioButton.kt create mode 100644 feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/shared/SelectableRadioCard.kt delete mode 100644 feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/component/RadioGroupTest.kt diff --git a/core/ui/src/main/kotlin/com/afternote/core/ui/button/CustomRadioButton.kt b/core/ui/src/main/kotlin/com/afternote/core/ui/button/CustomRadioButton.kt new file mode 100644 index 0000000000..7fa9c3fdf5 --- /dev/null +++ b/core/ui/src/main/kotlin/com/afternote/core/ui/button/CustomRadioButton.kt @@ -0,0 +1,139 @@ +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.spacedBy +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.selection.selectable +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.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 + +/** + * 커스텀 라디오 버튼 컴포넌트 + * + * 체크 표시(인디케이터)와 윤곽선 간 간격이 전체 크기의 12분의 1이 되도록 자동 계산됩니다. + * [onClick]이 있으면 바깥에 [Modifier.minimumInteractiveComponentSize]와 + * [Modifier.selectable] + [Role.RadioButton]을 두어 최소 48×48dp 터치 영역과 리플을 쓰고, + * 안쪽은 [buttonSize]만큼만 그립니다. 부모가 선택을 처리할 때는 [onClick]을 null로 두면 24dp만 차지합니다. + * + * @param selected 선택 여부 + * @param onClick 클릭 이벤트 (null이면 비인터랙티브·부모에서 처리) + * @param buttonSize 전체 버튼 크기 (기본값: 24.dp) + * @param selectedColor 선택된 색상 (기본값: AfternoteDesign.colors.gray9) + * @param unselectedColor 선택 안 된 색상 (기본값: AfternoteDesign.colors.gray4) + */ +@Composable +fun CustomRadioButton( + selected: Boolean, + modifier: Modifier = Modifier, + onClick: (() -> Unit)? = null, + 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 = "CustomRadioButtonBorderColor", + ) + + val indicatorSize by animateDpAsState( + targetValue = if (selected) maxIndicatorSize else 0.dp, + animationSpec = tween(durationMillis = 150), + label = "CustomRadioButtonIndicatorSize", + ) + + val interactionSource = remember { MutableInteractionSource() } + + Box( + modifier = + modifier.then( + if (onClick != null) { + Modifier + .minimumInteractiveComponentSize() + .selectable( + selected = selected, + onClick = onClick, + role = Role.RadioButton, + interactionSource = interactionSource, + indication = ripple(), + ) + } else { + 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 CustomRadioButtonPreview() { + AfternoteTheme { + Row( + modifier = Modifier.padding(16.dp), + horizontalArrangement = spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + CustomRadioButton( + selected = true, + onClick = {}, + ) + CustomRadioButton( + selected = false, + onClick = {}, + ) + } + } +} diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/shared/SelectableRadioCard.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/shared/SelectableRadioCard.kt new file mode 100644 index 0000000000..4f44850389 --- /dev/null +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/shared/SelectableRadioCard.kt @@ -0,0 +1,122 @@ +package com.afternote.feature.afternote.presentation.shared + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.ripple +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.afternote.core.ui.button.CustomRadioButton +import com.afternote.core.ui.theme.AfternoteDesign +import com.afternote.core.ui.theme.AfternoteTheme + +/** + * 선택 가능한 라디오 버튼 카드 컴포넌트 + * + * @param title 카드 제목 + * @param description 카드 설명. null이거나 공백이면 표시하지 않습니다. + * @param selected 선택 여부 + * @param onClick 클릭 이벤트 + * @param modifier Modifier + */ +@Composable +fun SelectableRadioCard( + title: String, + selected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, + description: String? = null, +) { + Surface( + modifier = + modifier.selectable( + selected = selected, + onClick = onClick, + role = Role.RadioButton, + interactionSource = remember { MutableInteractionSource() }, + indication = ripple(), + ), + shape = RoundedCornerShape(6.dp), + border = + BorderStroke( + 1.dp, + if (selected) { + AfternoteDesign.colors.gray8 + } else { + AfternoteDesign.colors.gray2 + }, + ), + color = AfternoteDesign.colors.white, + ) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + // 내부 라디오 버튼은 클릭 이벤트 null 처리하여 부모 Surface 클릭과 충돌 방지 + CustomRadioButton( + selected = selected, + onClick = null, + buttonSize = 24.dp, + selectedColor = AfternoteDesign.colors.gray9, + unselectedColor = AfternoteDesign.colors.gray4, + ) + Column( + verticalArrangement = Arrangement.spacedBy(5.dp), + ) { + Text( + text = title, + style = + if (selected) { + AfternoteDesign.typography.primaryButton.copy( + color = AfternoteDesign.colors.gray9, + ) + } else { + AfternoteDesign.typography.bodyBase.copy( + color = AfternoteDesign.colors.gray8, + ) + }, + ) + if (!description.isNullOrBlank()) { + Text( + text = description, + style = + AfternoteDesign.typography.bodySmallR.copy( + color = AfternoteDesign.colors.gray6, + ), + ) + } + } + } + } +} + +@Preview(showBackground = true) +@Composable +private fun SelectableRadioCardPreview() { + AfternoteTheme { + // 선택됨 - 제목 + 설명 + SelectableRadioCard( + title = "제목 텍스트", + description = "설명 텍스트", + selected = true, + onClick = {}, + ) + } +} diff --git a/feature/setting/presentation/build.gradle.kts b/feature/setting/presentation/build.gradle.kts index 89491391c6..b0e7153824 100644 --- a/feature/setting/presentation/build.gradle.kts +++ b/feature/setting/presentation/build.gradle.kts @@ -17,8 +17,6 @@ android { buildConfig = true } - testOptions.unitTests.isIncludeAndroidResources = true - defaultConfig { buildConfigField("String", "GOOGLE_WEB_CLIENT_ID", "\"$googleWebClientId\"") } @@ -45,7 +43,6 @@ dependencies { testImplementation(testFixtures(projects.core.domain)) testImplementation(projects.core.network) testImplementation(platform(libs.androidx.compose.bom)) - testImplementation(libs.junit) testImplementation(libs.androidx.compose.ui.test.junit4) testImplementation(libs.androidx.test.core.ktx) testImplementation(libs.robolectric) diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/component/RadioGroup.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/component/RadioGroup.kt index 5cc449b6fc..0c9ef796c3 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/component/RadioGroup.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/component/RadioGroup.kt @@ -1,10 +1,10 @@ package com.afternote.feature.setting.presentation.component -import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy +import androidx.compose.foundation.layout.Column import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import com.afternote.core.ui.button.AfternoteRadioGroup @Composable fun RadioGroup( @@ -13,19 +13,16 @@ fun RadioGroup( onSelectIndex: (Int) -> Unit, modifier: Modifier = Modifier, ) { - val indexedItems = items.withIndex().toList() - - AfternoteRadioGroup( - options = indexedItems, - selectedValue = indexedItems.getOrNull(selectedIndex), - onSelect = { onSelectIndex(it.index) }, + Column( modifier = modifier, - itemContentPadding = PaddingValues(16.dp), - itemDecoration = { _, selected -> radioGroupCardDecoration(selected) }, - ) { indexedItem, selected -> - RadioGroupCardContent( - item = indexedItem.value, - selected = selected, - ) + verticalArrangement = spacedBy(12.dp), + ) { + items.forEachIndexed { index, item -> + RadioGroupCard( + item = item, + selected = index == selectedIndex, + onClick = { onSelectIndex(index) }, + ) + } } } diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/component/RadioGroupCard.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/component/RadioGroupCard.kt index cb11e0a161..6400865cf9 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/component/RadioGroupCard.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/component/RadioGroupCard.kt @@ -3,62 +3,85 @@ package com.afternote.feature.setting.presentation.component import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.tween import androidx.compose.foundation.border +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.selection.selectable import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Text +import androidx.compose.material3.ripple import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +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.semantics.Role import androidx.compose.ui.unit.dp +import com.afternote.core.ui.button.CustomRadioButton import com.afternote.core.ui.theme.AfternoteDesign @Composable -internal fun radioGroupCardDecoration(selected: Boolean): Modifier { +fun RadioGroupCard( + item: RadioGroupItem, + selected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { val borderColor by animateColorAsState( targetValue = if (selected) AfternoteDesign.colors.gray9 else AfternoteDesign.colors.gray3, animationSpec = tween(durationMillis = 150), label = "RadioGroupCardBorderColor", ) - return Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(6.dp)) - .border( - width = 1.dp, - color = borderColor, - shape = RoundedCornerShape(6.dp), - ) -} - -@Composable -internal fun RowScope.RadioGroupCardContent( - item: RadioGroupItem, - selected: Boolean, -) { - Column(verticalArrangement = spacedBy(4.dp)) { - Text( - text = item.title, - color = - if (selected) { - AfternoteDesign.colors.gray9 - } else { - AfternoteDesign.colors.gray8 - }, - style = - if (selected) { - AfternoteDesign.typography.primaryButton - } else { - AfternoteDesign.typography.bodyBase - }, - ) - Text( - text = item.description, - color = AfternoteDesign.colors.gray6, - style = AfternoteDesign.typography.bodySmallR, + Row( + modifier = + modifier + .fillMaxWidth() + .clip(RoundedCornerShape(6.dp)) + .border( + width = 1.dp, + color = borderColor, + shape = RoundedCornerShape(6.dp), + ).selectable( + selected = selected, + onClick = onClick, + role = Role.RadioButton, + indication = ripple(), + interactionSource = remember { MutableInteractionSource() }, + ).padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + CustomRadioButton( + selected = selected, + // onClick null → Row에서 처리 ) + Spacer(modifier = Modifier.padding(start = 16.dp)) + Column(verticalArrangement = spacedBy(4.dp)) { + Text( + text = item.title, + color = + if (selected) { + AfternoteDesign.colors.gray9 + } else { + AfternoteDesign.colors.gray8 + }, + style = + if (selected) { + AfternoteDesign.typography.primaryButton + } else { + AfternoteDesign.typography.bodyBase + }, + ) + Text( + text = item.description, + color = AfternoteDesign.colors.gray6, + style = AfternoteDesign.typography.bodySmallR, + ) + } } } diff --git a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/component/RadioGroupTest.kt b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/component/RadioGroupTest.kt deleted file mode 100644 index cfffd032cf..0000000000 --- a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/component/RadioGroupTest.kt +++ /dev/null @@ -1,80 +0,0 @@ -package com.afternote.feature.setting.presentation.component - -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf -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 RadioGroupTest { - @get:Rule - val composeRule = createComposeRule() - - @Test - fun `기존 index 계약으로 한 항목만 선택하고 선택 index를 전달한다`() { - val selectedIndices = mutableListOf() - composeRule.setContent { - var selectedIndex by remember { mutableIntStateOf(0) } - - AfternoteTheme { - RadioGroup( - items = ITEMS, - selectedIndex = selectedIndex, - onSelectIndex = { - selectedIndices += it - selectedIndex = it - }, - ) - } - } - - composeRule.onAllNodes(radioRoleMatcher).apply { - assertCountEquals(2) - get(0).assertIsSelected() - get(1).assertIsNotSelected() - get(1).performClick() - get(0).assertIsNotSelected() - get(1).assertIsSelected() - } - composeRule.runOnIdle { - assertEquals(listOf(1), selectedIndices) - } - - composeRule.onAllNodes(radioRoleMatcher, useUnmergedTree = true).apply { - assertCountEquals(2) - repeat(2) { index -> - val bounds = get(index).getUnclippedBoundsInRoot() - assertTrue(bounds.right - bounds.left >= 48.dp) - assertTrue(bounds.bottom - bounds.top >= 48.dp) - } - } - } - - private companion object { - val ITEMS = - listOf( - RadioGroupItem(title = "첫 번째", description = "첫 번째 설명"), - RadioGroupItem(title = "두 번째", description = "두 번째 설명"), - ) - val radioRoleMatcher = SemanticsMatcher.expectValue(SemanticsProperties.Role, Role.RadioButton) - } -}