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
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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(
Expand All @@ -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),
Expand Down Expand Up @@ -77,3 +114,7 @@ private fun RecipientDesignationBadgeIncompletePreview() {
)
}
}

/** 확정 상태 칩과 같은 자리를 차지하도록 맞춘 크기 (`CircularCheckboxOutlineChip` 실측). */
private val UNKNOWN_BADGE_WIDTH = 132.dp

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] 확정 상태 칩은 고정 크기가 아니라 콘텐츠 wrap + padding(horizontal = 15.dp, vertical = 9.dp) 입니다(CircularCheckboxOutlineChip.kt:55). 그래서 이 132×36 은 «지금 이 폰트 크기에서 잰 한 시점의 값» 입니다.

두 가지가 걸립니다.

  1. 완료(수신인 지정 완료)와 미완료(수신인 지정 미완료 + 후행 화살표)의 폭이 서로 다른데, 자리표시자는 하나의 고정값이라 어느 쪽으로 전환돼도 폭이 한 번 튑니다.
  2. 폰트 스케일을 키우면 칩만 커지고 자리표시자는 그대로입니다. 접근성 설정에서 글꼴을 키운 사용자에게는 자리표시자가 실제 칩보다 눈에 띄게 작아집니다.

지금 값이 틀렸다는 건 아니고, 칩 라벨이 바뀌거나 타이포가 조정되면 조용히 어긋나는 종류라 «실측값 상수» 로 굳히기보다 칩과 같은 계산을 타게 하는 편이 안전합니다. 예를 들어 확정 상태 칩을 alpha = 0f 로 그려 자리만 차지하게 하면 padding·타이포·폰트 스케일이 자동으로 따라옵니다.

후속으로 보셔도 됩니다 — 승인 자체를 막을 사안은 아닙니다.

private val UNKNOWN_BADGE_HEIGHT = 36.dp
5 changes: 5 additions & 0 deletions core/ui/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@

<string name="core_ui_recipient_designated">수신인 지정 완료</string>
<string name="core_ui_recipient_not_designated">수신인 지정 미완료</string>
<!--
조회 전 자리 표시. **화면에는 그리지 않고** TalkBack 에만 읽힌다(semantics) —
확정 문구("완료"·"미완료")를 쓰면 안 된다 (#698).
-->
<string name="core_ui_recipient_designation_unknown">수신인 지정 여부 확인 중</string>

<string name="core_ui_network_error_title">네트워크 연결 오류</string>
<string name="core_ui_network_error_description">인터넷 연결을 확인한 후 다시 시도해 주세요.</string>
Expand Down
7 changes: 7 additions & 0 deletions feature/home/presentation/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ plugins {
}

android {
testOptions.unitTests.isIncludeAndroidResources = true

namespace = "com.afternote.feature.home.presentation"
experimentalProperties["android.experimental.enableScreenshotTest"] = true
}
Expand All @@ -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)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -202,7 +208,13 @@ fun HomeTabScreen(
@Composable
private fun HomeTabScrollContent(
userName: String,
isRecipientDesignated: Boolean,
/**
* 수신인 지정 배지 상태.
*
* `Boolean?` 로 좁혔다 다시 펼치지 않는다 — 「null = 미결정」이 주석으로만 유지되는
* 약속이 되고, 「널+폴백 대신 값으로 명시」(#934) 와도 어긋난다 (#698 리뷰).
*/
recipientBadgeState: RecipientDesignationBadgeState,
categoryCounts: Map<MindRecordCategory, Int>,
categoryCountsLoading: Boolean,
todayDateText: String,
Expand Down Expand Up @@ -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))
}
Expand Down
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,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<ComponentActivity>()

/**
* 미결정 배지는 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()
}
}
Loading