Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion core/ui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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<Event>` / `SharedFlow<Event>` | UiState 의 nullable 필드 + `onXxxShown()` 콜백 |
| Repository 우회해 ViewModel 이 `DataSource` / cache 직접 주입 | Repository 만 진입점, cache 는 Repository 내부 책임 |
Expand Down
Original file line number Diff line number Diff line change
@@ -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 <T : Any> AfternoteRadioGroup(
options: List<T>,
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),
) { _, _ -> }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {},
) { _, _ -> }
}
}

Expand All @@ -25,6 +29,10 @@ internal fun customRadioButtonSelectedScreenshot() {
@Composable
internal fun customRadioButtonUnselectedScreenshot() {
AfternoteTheme {
CustomRadioButton(selected = false)
AfternoteRadioGroup(
options = listOf(Unit),
selectedValue = null,
onSelect = {},
) { _, _ -> }
}
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -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<String>()
composeRule.setContent {
var selectedValue by remember { mutableStateOf<String?>(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)
}
}
Loading