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()
+ }
+}