diff --git a/core/ui/src/main/kotlin/com/afternote/core/ui/badge/RecipientDesignationBadge.kt b/core/ui/src/main/kotlin/com/afternote/core/ui/badge/RecipientDesignationBadge.kt index 7af50377ba..9d6524e203 100644 --- a/core/ui/src/main/kotlin/com/afternote/core/ui/badge/RecipientDesignationBadge.kt +++ b/core/ui/src/main/kotlin/com/afternote/core/ui/badge/RecipientDesignationBadge.kt @@ -1,11 +1,20 @@ package com.afternote.core.ui.badge +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp import com.afternote.core.ui.R import com.afternote.core.ui.button.CheckboxState +import com.afternote.core.ui.modifierextention.shimmerLoadingPlaceholder import com.afternote.core.ui.theme.AfternoteDesign import com.afternote.core.ui.theme.AfternoteTheme @@ -18,6 +27,15 @@ import com.afternote.core.ui.theme.AfternoteTheme * - [Incomplete]: 미완료. [Incomplete.onClick] 이 null 이 아니면 탭 가능(홈), null 이면 표시만(애프터노트 상세). */ sealed interface RecipientDesignationBadgeState { + /** + * 아직 조회되지 않았다 — 지정 여부를 **결과로 확정하지 않는다** (#698). + * + * 미결정을 `Incomplete` 로 그리면 이미 지정한 사용자도 진입할 때마다 «미완료» 배지를 + * 보고, 그 배지가 탭 가능한 CTA 라 하지 않아도 될 행동을 유도한다. 같은 화면의 오늘의 + * 질문·카테고리 카운트는 이미 미결정을 스켈레톤으로 표현하고 있어 표현도 어긋났다. + */ + data object Unknown : RecipientDesignationBadgeState + data object Completed : RecipientDesignationBadgeState data class Incomplete( @@ -34,6 +52,25 @@ fun RecipientDesignationBadge( modifier: Modifier = Modifier, ) { when (state) { + RecipientDesignationBadgeState.Unknown -> { + // **콘텐츠를 그리지 않는다.** 칩에 shimmer 를 걸면 칩 내부의 불투명 배경이 + // shimmer 를 덮어 정지된 회색 칩이 되고, 라벨은 그 위에 그대로 그려진다 — + // 「조회 전인데 확정된 무언가로 읽힘」이 문구만 바뀐 채 남는다. + // + // 같은 화면의 오늘의 질문·카테고리 카운트와 같은 형태로 자리만 잡고, 읽을 + // 것은 semantics 로 준다. + val unknownDescription = stringResource(R.string.core_ui_recipient_designation_unknown) + Box( + modifier = + modifier + .width(UNKNOWN_BADGE_WIDTH) + .height(UNKNOWN_BADGE_HEIGHT) + .clip(RoundedCornerShape(20.dp)) + .shimmerLoadingPlaceholder() + .semantics { contentDescription = unknownDescription }, + ) + } + RecipientDesignationBadgeState.Completed -> { CircularCheckboxOutlineChip( label = stringResource(R.string.core_ui_recipient_designated), @@ -77,3 +114,7 @@ private fun RecipientDesignationBadgeIncompletePreview() { ) } } + +/** 확정 상태 칩과 같은 자리를 차지하도록 맞춘 크기 (`CircularCheckboxOutlineChip` 실측). */ +private val UNKNOWN_BADGE_WIDTH = 132.dp +private val UNKNOWN_BADGE_HEIGHT = 36.dp diff --git a/core/ui/src/main/res/values/strings.xml b/core/ui/src/main/res/values/strings.xml index 660a0ae08b..e30bac9ebe 100644 --- a/core/ui/src/main/res/values/strings.xml +++ b/core/ui/src/main/res/values/strings.xml @@ -17,6 +17,11 @@ 수신인 지정 완료 수신인 지정 미완료 + + 수신인 지정 여부 확인 중 네트워크 연결 오류 인터넷 연결을 확인한 후 다시 시도해 주세요. diff --git a/feature/home/presentation/build.gradle.kts b/feature/home/presentation/build.gradle.kts index a2d9dd51fa..276890c769 100644 --- a/feature/home/presentation/build.gradle.kts +++ b/feature/home/presentation/build.gradle.kts @@ -5,6 +5,8 @@ plugins { } android { + testOptions.unitTests.isIncludeAndroidResources = true + namespace = "com.afternote.feature.home.presentation" experimentalProperties["android.experimental.enableScreenshotTest"] = true } @@ -22,6 +24,11 @@ dependencies { // HomeTabViewModel 경합 테스트 — 가상 시간으로 viewModelScope 요청 순서를 제어한다. testImplementation(libs.coroutines.test) + // 로딩 중 배지가 «미완료» 로 확정되지 않는지는 실제로 그려 봐야 확인된다 (#698). + testImplementation(libs.robolectric) + testImplementation(libs.androidx.compose.ui.test.junit4) + debugImplementation(libs.androidx.compose.ui.test.manifest) + screenshotTestImplementation(libs.screenshot.validation.api) screenshotTestImplementation(libs.androidx.compose.ui.tooling) } diff --git a/feature/home/presentation/src/main/kotlin/com/afternote/feature/home/presentation/HomeTabScreen.kt b/feature/home/presentation/src/main/kotlin/com/afternote/feature/home/presentation/HomeTabScreen.kt index e1b10b2cde..63e8d05af6 100644 --- a/feature/home/presentation/src/main/kotlin/com/afternote/feature/home/presentation/HomeTabScreen.kt +++ b/feature/home/presentation/src/main/kotlin/com/afternote/feature/home/presentation/HomeTabScreen.kt @@ -146,7 +146,8 @@ fun HomeTabScreen( is HomeTabUiState.Loading -> { HomeTabScrollContent( userName = uiState.cachedUserName ?: "\u2026", - isRecipientDesignated = false, + // 조회 전이다 — 지정 여부를 결과로 확정하지 않는다 (#698). + recipientBadgeState = RecipientDesignationBadgeState.Unknown, categoryCounts = MindRecordCategory.entries.associateWith { 0 }, categoryCountsLoading = true, todayDateText = todayDateText, @@ -159,7 +160,12 @@ fun HomeTabScreen( is HomeTabUiState.Success -> { HomeTabScrollContent( userName = uiState.userName, - isRecipientDesignated = uiState.isRecipientDesignated, + recipientBadgeState = + if (uiState.isRecipientDesignated) { + RecipientDesignationBadgeState.Completed + } else { + RecipientDesignationBadgeState.Incomplete(onClick = actions::onRecipientChipClick) + }, categoryCounts = uiState.categoryCounts, categoryCountsLoading = false, todayDateText = todayDateText, @@ -202,7 +208,13 @@ fun HomeTabScreen( @Composable private fun HomeTabScrollContent( userName: String, - isRecipientDesignated: Boolean, + /** + * 수신인 지정 배지 상태. + * + * `Boolean?` 로 좁혔다 다시 펼치지 않는다 — 「null = 미결정」이 주석으로만 유지되는 + * 약속이 되고, 「널+폴백 대신 값으로 명시」(#934) 와도 어긋난다 (#698 리뷰). + */ + recipientBadgeState: RecipientDesignationBadgeState, categoryCounts: Map, categoryCountsLoading: Boolean, todayDateText: String, @@ -230,16 +242,7 @@ private fun HomeTabScrollContent( ) Spacer(modifier = Modifier.height(8.dp)) - RecipientDesignationBadge( - state = - if (isRecipientDesignated) { - RecipientDesignationBadgeState.Completed - } else { - RecipientDesignationBadgeState.Incomplete( - onClick = actions::onRecipientChipClick, - ) - }, - ) + RecipientDesignationBadge(state = recipientBadgeState) Spacer(Modifier.height(32.dp)) } diff --git a/feature/home/presentation/src/screenshotTestDebug/reference/com/afternote/feature/home/presentation/HomeTabScreenScreenshotTestKt/homeTabScreenLoadingScreenshot_748aa731_0.png b/feature/home/presentation/src/screenshotTestDebug/reference/com/afternote/feature/home/presentation/HomeTabScreenScreenshotTestKt/homeTabScreenLoadingScreenshot_748aa731_0.png index 7eb27d781c..11a017deea 100644 Binary files a/feature/home/presentation/src/screenshotTestDebug/reference/com/afternote/feature/home/presentation/HomeTabScreenScreenshotTestKt/homeTabScreenLoadingScreenshot_748aa731_0.png and b/feature/home/presentation/src/screenshotTestDebug/reference/com/afternote/feature/home/presentation/HomeTabScreenScreenshotTestKt/homeTabScreenLoadingScreenshot_748aa731_0.png differ diff --git a/feature/home/presentation/src/test/kotlin/com/afternote/feature/home/presentation/RecipientBadgeLoadingTest.kt b/feature/home/presentation/src/test/kotlin/com/afternote/feature/home/presentation/RecipientBadgeLoadingTest.kt new file mode 100644 index 0000000000..8dd197e34c --- /dev/null +++ b/feature/home/presentation/src/test/kotlin/com/afternote/feature/home/presentation/RecipientBadgeLoadingTest.kt @@ -0,0 +1,84 @@ +package com.afternote.feature.home.presentation + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.assertHasNoClickAction +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import com.afternote.core.ui.badge.RecipientDesignationBadge +import com.afternote.core.ui.badge.RecipientDesignationBadgeState +import com.afternote.core.ui.theme.AfternoteTheme +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * 조회 전 수신인 지정 배지 (#698). + * + * 로딩 분기가 `isRecipientDesignated = false` 를 넘겨, **아직 조회되지 않은 상태를 + * «미완료» 라는 결과로 단정**했다. 이미 지정한 사용자도 진입할 때마다 그 배지를 보고, + * 배지가 탭 가능한 CTA 라 하지 않아도 될 행동을 유도했다. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class RecipientBadgeLoadingTest { + @get:Rule + val composeRule = createAndroidComposeRule() + + /** + * 미결정 배지는 shimmer 가 무한 애니메이션이라 Compose 가 영원히 idle 이 되지 않는다. + * 시계를 수동으로 돌려 한 프레임만 그린 뒤 읽는다. + */ + private fun setUnknownBadge() { + composeRule.mainClock.autoAdvance = false + composeRule.setContent { + AfternoteTheme { RecipientDesignationBadge(state = RecipientDesignationBadgeState.Unknown) } + } + composeRule.mainClock.advanceTimeByFrame() + } + + @Test + fun `조회 전에는 미완료로 확정하지 않는다`() { + setUnknownBadge() + + composeRule.onNodeWithText("수신인 지정 미완료").assertDoesNotExist() + composeRule.onNodeWithText("수신인 지정 완료").assertDoesNotExist() + } + + @Test + fun `조회 전 배지는 눌리지 않고 문구도 그리지 않는다`() { + // 미결정 상태에서 CTA 를 노출하면 하지 않아도 될 행동을 유도한다. 문구 자체도 + // 그리지 않는다 — 시안에 없는 안내가 홈 첫 화면에 뜨면 그것대로 확정된 무언가로 + // 읽힌다. 읽을 것은 접근성으로만 준다. + setUnknownBadge() + + composeRule.onNodeWithText("수신인 지정 여부 확인 중").assertDoesNotExist() + composeRule + .onNodeWithContentDescription("수신인 지정 여부 확인 중") + .assertHasNoClickAction() + } + + @Test + fun `홈 로딩 분기가 미완료 배지를 그리지 않는다`() { + // 컴포넌트만 고쳐도 화면이 여전히 false 를 넘기면 증상이 그대로 남는다 — 배선까지 본다. + composeRule.mainClock.autoAdvance = false + composeRule.setContent { + AfternoteTheme { HomeTabScreen(uiState = HomeTabUiState.Loading()) } + } + composeRule.mainClock.advanceTimeByFrame() + + composeRule.onNodeWithText("수신인 지정 미완료").assertDoesNotExist() + } + + @Test + fun `조회가 끝나면 결과를 그대로 보여준다`() { + composeRule.setContent { + AfternoteTheme { RecipientDesignationBadge(state = RecipientDesignationBadgeState.Completed) } + } + + composeRule.onNodeWithText("수신인 지정 완료").assertIsDisplayed() + } +}