From 9b5312700e7b47a3b00dd0a24ed34953f59141ec Mon Sep 17 00:00:00 2001 From: kimjw Date: Tue, 25 Aug 2026 22:30:08 +0900 Subject: [PATCH 1/6] =?UTF-8?q?fix:=20=EC=95=BD=EA=B4=80=20=EB=8F=99?= =?UTF-8?q?=EC=9D=98=20=EC=83=81=ED=83=9C=EA=B0=80=20=ED=99=94=EB=A9=B4=20?= =?UTF-8?q?=EC=9D=B4=EB=8F=99=20=ED=9B=84=20=EC=B4=88=EA=B8=B0=ED=99=94?= =?UTF-8?q?=EB=90=98=EB=8A=94=20=EB=AC=B8=EC=A0=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 2차 QA 2건 대응. - 약관 동의 후 '자세히 보기' 진입 후 복귀 시 동의 상태가 유지되지 않음 - 약관 동의 후 온보딩 진입 → 뒤로가기로 복귀 시 동의 상태가 유지되지 않음 두 건 모두 원인이 같다. 체크 상태가 OnboardingTermsScreen의 컴포저블 로컬 remember에 있어서, 화면을 벗어나면 컴포지션이 해제되며 초기화됐다. ViewModel의 agreedTermsIds는 동의하기를 누른 시점에만 채워져 복원에 쓸 수 없었다. 상태를 OnboardingTermsUiState로 올려 onboarding nav graph에 스코프된 OnboardingViewModel이 들고 있게 했다. 인덱스 대신 약관 id를 키로 써 목록 순서가 바뀌어도 안전하다. - checkedTermIds / expandedTermIds 를 Set 으로 보관 - 전이 로직(toggleChecked/toggleAllChecked/toggleExpanded)과 파생값(canProceed/isAllChecked/agreedIds)을 상태 클래스의 순수 함수로 분리 - 화면은 상태를 주입받아 렌더링만 담당 TermRow의 체크박스/펼침 아이콘에 contentDescription 추가. 기존에 null이라 스크린리더가 체크 상태를 읽지 못했다. 검증 - TermsAgreementStateTest 12개 (상태 전이) - OnboardingTermsNavigationTest 4개 (실제 NavHost 이동/복귀) - 버그를 되살린 대조 실험에서 상태 유지 테스트 3개가 실패하는 것까지 확인 - 에뮬레이터 실기로 QA 시나리오 2건 재현 및 통과 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01DuxSwiSeddHi5XodSpFyFo --- .../OnboardingTermsNavigationTest.kt | 188 ++++++++++++++++++ .../onboarding/OnboardingTermsScreen.kt | 63 +++--- .../onboarding/OnboardingUiState.kt | 44 +++- .../onboarding/OnboardingViewModel.kt | 16 +- .../onboarding/TermsAgreementStateTest.kt | 180 +++++++++++++++++ 5 files changed, 449 insertions(+), 42 deletions(-) create mode 100644 app/src/androidTest/java/com/flint/android/presentation/onboarding/OnboardingTermsNavigationTest.kt create mode 100644 app/src/test/java/com/flint/android/presentation/onboarding/TermsAgreementStateTest.kt diff --git a/app/src/androidTest/java/com/flint/android/presentation/onboarding/OnboardingTermsNavigationTest.kt b/app/src/androidTest/java/com/flint/android/presentation/onboarding/OnboardingTermsNavigationTest.kt new file mode 100644 index 00000000..3b618142 --- /dev/null +++ b/app/src/androidTest/java/com/flint/android/presentation/onboarding/OnboardingTermsNavigationTest.kt @@ -0,0 +1,188 @@ +package com.flint.android.presentation.onboarding + +import androidx.activity.ComponentActivity +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.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.navigation.NavHostController +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.flint.android.core.common.util.UiState +import com.flint.android.core.designsystem.theme.FlintTheme +import com.flint.android.domain.model.terms.TermModel +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +/** + * 약관 동의 상태가 화면 이동 후에도 유지되는지 실제 NavHost로 검증한다. + * + * QA 2차 1: 약관 동의 후 '자세히 보기' 진입 → 복귀 시 동의 상태 유지 + * QA 2차 2: 약관 동의 후 온보딩 진입 → 뒤로가기 복귀 시 동의 상태 유지 + * + * 프로덕션과 동일하게 상태를 화면 바깥(그래프 스코프)에 두고 실제로 이동/복귀시킨다. + * 수정 전에는 체크 상태가 OnboardingTermsScreen 내부 remember에 있어, + * 복귀 시 컴포지션이 새로 만들어지며 초기화됐다. + */ +@RunWith(AndroidJUnit4::class) +class OnboardingTermsNavigationTest { + @get:Rule + val composeRule = createAndroidComposeRule() + + private val service = + TermModel( + id = "1", + type = "SERVICE", + version = 1, + title = "서비스 이용약관", + content = "", + required = true, + activeAt = "2026-05-13T10:48:54.554Z", + ) + private val privacy = + TermModel( + id = "2", + type = "PRIVACY", + version = 1, + title = "개인정보 처리 방침", + content = "", + required = true, + activeAt = "2026-05-13T10:48:54.554Z", + ) + private val marketing = + TermModel( + id = "3", + type = "MARKETING", + version = 1, + title = "마케팅 정보 수신 동의", + content = "", + required = false, + activeAt = "2026-05-13T10:48:54.554Z", + ) + + private lateinit var navController: NavHostController + + private fun setContent() { + composeRule.setContent { + navController = rememberNavController() + + // 프로덕션에서 OnboardingViewModel이 nav graph에 스코프되어 하는 역할. + // NavHost 바깥에 두어 목적지 이동에도 살아남게 한다. + var termsState by remember { + mutableStateOf( + OnboardingTermsUiState( + termsState = UiState.Success(listOf(service, privacy, marketing)), + ), + ) + } + + FlintTheme { + NavHost(navController = navController, startDestination = "terms") { + composable("terms") { + OnboardingTermsScreen( + termsUiState = termsState, + onBackClick = {}, + onCheckClick = { termsState = termsState.toggleChecked(it) }, + onAllCheckClick = { termsState = termsState.toggleAllChecked() }, + onExpandClick = { termsState = termsState.toggleExpanded(it) }, + onAgreeClick = { navController.navigate("next") }, + onDetailClick = { navController.navigate("detail") }, + ) + } + composable("detail") { Text("약관 상세 화면") } + composable("next") { Text("온보딩 다음 단계") } + } + } + } + } + + private fun goBack() { + composeRule.runOnUiThread { navController.popBackStack() } + composeRule.waitForIdle() + } + + private fun checkRequiredTerms() { + composeRule.onNodeWithContentDescription("${service.title} 동의").performClick() + composeRule.onNodeWithContentDescription("${privacy.title} 동의").performClick() + } + + // ---- QA 2차 1 ---- + + @Test + fun `자세히 보기에서 복귀해도 동의 상태가 유지된다`() { + setContent() + + checkRequiredTerms() + composeRule.onNodeWithText("동의하기").assertIsEnabled() + + // 약관을 펼쳐 '자세히 보기'로 이동 + composeRule.onNodeWithContentDescription("${service.title} 펼치기").performClick() + composeRule.onNodeWithText("자세히 보기").performClick() + composeRule.onNodeWithText("약관 상세 화면").assertExists() + + goBack() + + // 체크와 펼침 상태가 그대로여야 한다 + composeRule.onNodeWithText("동의하기").assertIsEnabled() + composeRule.onNodeWithText("자세히 보기").assertExists() + } + + // ---- QA 2차 2 ---- + + @Test + fun `온보딩 다음 단계에서 뒤로가기로 복귀해도 동의 상태가 유지된다`() { + setContent() + + checkRequiredTerms() + composeRule.onNodeWithText("동의하기").assertIsEnabled() + + composeRule.onNodeWithText("동의하기").performClick() + composeRule.onNodeWithText("온보딩 다음 단계").assertExists() + + goBack() + + composeRule.onNodeWithText("동의하기").assertIsEnabled() + } + + // ---- 전체 동의 ---- + + @Test + fun `전체 동의 후 이동했다 복귀해도 전체 체크가 유지된다`() { + setContent() + + composeRule.onNodeWithText("전체 동의").performClick() + composeRule.onNodeWithText("동의하기").assertIsEnabled() + + composeRule.onNodeWithText("동의하기").performClick() + composeRule.onNodeWithText("온보딩 다음 단계").assertExists() + + goBack() + + composeRule.onNodeWithText("동의하기").assertIsEnabled() + // 선택 약관까지 체크된 상태가 유지되는지 확인 — 다시 누르면 전체 해제되어야 한다 + composeRule.onNodeWithText("전체 동의").performClick() + composeRule.onNodeWithText("동의하기").assertIsNotEnabled() + } + + // ---- 대조군 ---- + + @Test + fun `필수 약관을 체크하지 않으면 동의하기가 비활성화된다`() { + setContent() + + composeRule.onNodeWithText("동의하기").assertIsNotEnabled() + + composeRule.onNodeWithContentDescription("${marketing.title} 동의").performClick() + composeRule.onNodeWithText("동의하기").assertIsNotEnabled() + } +} diff --git a/app/src/main/java/com/flint/android/presentation/onboarding/OnboardingTermsScreen.kt b/app/src/main/java/com/flint/android/presentation/onboarding/OnboardingTermsScreen.kt index 60761a50..bd946e90 100644 --- a/app/src/main/java/com/flint/android/presentation/onboarding/OnboardingTermsScreen.kt +++ b/app/src/main/java/com/flint/android/presentation/onboarding/OnboardingTermsScreen.kt @@ -22,9 +22,6 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.rotate @@ -63,8 +60,11 @@ fun OnboardingTermsRoute( OnboardingTermsScreen( termsUiState = termsUiState, onBackClick = navigateUp, - onAgreeClick = { agreedIds -> - viewModel.agreeToTerms(agreedIds) + onCheckClick = viewModel::toggleTermChecked, + onAllCheckClick = viewModel::toggleAllTermsChecked, + onExpandClick = viewModel::toggleTermExpanded, + onAgreeClick = { + viewModel.agreeToTerms() navigateToOnboardingContent() }, onDetailClick = navigateToTermsDetail, @@ -76,20 +76,16 @@ fun OnboardingTermsRoute( fun OnboardingTermsScreen( termsUiState: OnboardingTermsUiState, onBackClick: () -> Unit, - onAgreeClick: (List) -> Unit, + onCheckClick: (termId: String) -> Unit, + onAllCheckClick: () -> Unit, + onExpandClick: (termId: String) -> Unit, + onAgreeClick: () -> Unit, onDetailClick: (termId: String) -> Unit = {}, modifier: Modifier = Modifier, ) { - val terms = (termsUiState.termsState as? UiState.Success)?.data ?: emptyList() - var checkedStates by remember(terms) { mutableStateOf(List(terms.size) { false }) } - var expandedStates by remember(terms) { mutableStateOf(List(terms.size) { false }) } - - // 필수 약관이 모두 체크되어야 버튼 활성화 - val requiredAllChecked = terms.isNotEmpty() && terms.indices - .filter { terms[it].required } - .all { checkedStates[it] } - - val allChecked = terms.isNotEmpty() && checkedStates.all { it } + val terms = termsUiState.terms + val requiredAllChecked = termsUiState.canProceed + val allChecked = termsUiState.isAllChecked Column( modifier = modifier @@ -113,10 +109,7 @@ fun OnboardingTermsScreen( Row( modifier = Modifier .fillMaxWidth() - .flintCardClickable { - val newState = !allChecked - checkedStates = List(terms.size) { newState } - } + .flintCardClickable(onClick = onAllCheckClick) .padding(vertical = 8.dp), verticalAlignment = Alignment.CenterVertically, ) { @@ -175,19 +168,13 @@ fun OnboardingTermsScreen( ) { Spacer(Modifier.height(16.dp)) - terms.forEachIndexed { index, term -> + terms.forEach { term -> TermRow( term = term, - isChecked = checkedStates[index], - isExpanded = expandedStates[index], - onCheckClick = { - checkedStates = checkedStates.toMutableList() - .also { it[index] = !it[index] } - }, - onExpandClick = { - expandedStates = expandedStates.toMutableList() - .also { it[index] = !it[index] } - }, + isChecked = termsUiState.isChecked(term.id), + isExpanded = termsUiState.isExpanded(term.id), + onCheckClick = { onCheckClick(term.id) }, + onExpandClick = { onExpandClick(term.id) }, onDetailClick = { onDetailClick(term.id) }, ) Spacer(Modifier.height(4.dp)) @@ -200,12 +187,7 @@ fun OnboardingTermsScreen( FlintLargeButton( text = "동의하기", state = if (requiredAllChecked) FlintButtonState.Able else FlintButtonState.Disable, - onClick = { - val agreedIds = terms.indices - .filter { checkedStates[it] } - .map { terms[it].id } - onAgreeClick(agreedIds) - }, + onClick = onAgreeClick, enabled = requiredAllChecked, modifier = Modifier .fillMaxWidth() @@ -236,7 +218,7 @@ private fun TermRow( imageVector = ImageVector.vectorResource( if (isChecked) R.drawable.ic_check_fill else R.drawable.ic_check_empty ), - contentDescription = null, + contentDescription = "${term.title} 동의", tint = Color.Unspecified, modifier = Modifier.flintIconClickable(onClick = onCheckClick), ) @@ -249,7 +231,7 @@ private fun TermRow( ) Icon( imageVector = ImageVector.vectorResource(R.drawable.ic_down), - contentDescription = null, + contentDescription = "${term.title} 펼치기", tint = FlintTheme.colors.gray400, modifier = Modifier .rotate(if (isExpanded) 180f else 0f) @@ -328,6 +310,9 @@ private fun OnboardingTermsScreenPreview() { ) ), onBackClick = {}, + onCheckClick = {}, + onAllCheckClick = {}, + onExpandClick = {}, onAgreeClick = {}, ) } diff --git a/app/src/main/java/com/flint/android/presentation/onboarding/OnboardingUiState.kt b/app/src/main/java/com/flint/android/presentation/onboarding/OnboardingUiState.kt index 882a66e1..43aa8255 100644 --- a/app/src/main/java/com/flint/android/presentation/onboarding/OnboardingUiState.kt +++ b/app/src/main/java/com/flint/android/presentation/onboarding/OnboardingUiState.kt @@ -11,7 +11,49 @@ import kotlinx.collections.immutable.persistentListOf data class OnboardingTermsUiState( val termsState: UiState> = UiState.Empty, val agreedTermsIds: List = emptyList(), -) + // 체크/펼침 상태는 화면이 아니라 여기에 둔다. 화면 로컬 remember에 두면 + // '자세히 보기'나 다음 온보딩 단계로 이동할 때 컴포지션이 해제되며 초기화된다. + val checkedTermIds: Set = emptySet(), + val expandedTermIds: Set = emptySet(), +) { + val terms: List + get() = (termsState as? UiState.Success)?.data ?: emptyList() + + fun isChecked(termId: String): Boolean = termId in checkedTermIds + + fun isExpanded(termId: String): Boolean = termId in expandedTermIds + + val isAllChecked: Boolean + get() = terms.isNotEmpty() && terms.all { it.id in checkedTermIds } + + // 필수 약관이 모두 체크되어야 동의하기 버튼이 활성화된다 + val canProceed: Boolean + get() = terms.isNotEmpty() && terms.filter { it.required }.all { it.id in checkedTermIds } + + // 약관 목록 순서를 따라 체크된 것만 수집한다 + val agreedIds: List + get() = terms.filter { it.id in checkedTermIds }.map { it.id } + + fun toggleChecked(termId: String): OnboardingTermsUiState = copy( + checkedTermIds = if (termId in checkedTermIds) { + checkedTermIds - termId + } else { + checkedTermIds + termId + }, + ) + + fun toggleAllChecked(): OnboardingTermsUiState = copy( + checkedTermIds = if (isAllChecked) emptySet() else terms.map { it.id }.toSet(), + ) + + fun toggleExpanded(termId: String): OnboardingTermsUiState = copy( + expandedTermIds = if (termId in expandedTermIds) { + expandedTermIds - termId + } else { + expandedTermIds + termId + }, + ) +} enum class NicknameErrorType { DUPLICATE, // 이미 사용 중인 닉네임 diff --git a/app/src/main/java/com/flint/android/presentation/onboarding/OnboardingViewModel.kt b/app/src/main/java/com/flint/android/presentation/onboarding/OnboardingViewModel.kt index 905b310b..5cc9dd27 100644 --- a/app/src/main/java/com/flint/android/presentation/onboarding/OnboardingViewModel.kt +++ b/app/src/main/java/com/flint/android/presentation/onboarding/OnboardingViewModel.kt @@ -91,8 +91,20 @@ class OnboardingViewModel } } - fun agreeToTerms(termIds: List) { - _termsUiState.update { it.copy(agreedTermsIds = termIds) } + fun toggleTermChecked(termId: String) { + _termsUiState.update { it.toggleChecked(termId) } + } + + fun toggleAllTermsChecked() { + _termsUiState.update { it.toggleAllChecked() } + } + + fun toggleTermExpanded(termId: String) { + _termsUiState.update { it.toggleExpanded(termId) } + } + + fun agreeToTerms() { + _termsUiState.update { it.copy(agreedTermsIds = it.agreedIds) } } // ---------- onboarding profile ---------- diff --git a/app/src/test/java/com/flint/android/presentation/onboarding/TermsAgreementStateTest.kt b/app/src/test/java/com/flint/android/presentation/onboarding/TermsAgreementStateTest.kt new file mode 100644 index 00000000..6559057d --- /dev/null +++ b/app/src/test/java/com/flint/android/presentation/onboarding/TermsAgreementStateTest.kt @@ -0,0 +1,180 @@ +package com.flint.android.presentation.onboarding + +import com.flint.android.core.common.util.UiState +import com.flint.android.domain.model.terms.TermModel +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * 약관 동의 상태 유지 테스트 + * + * QA 2차 1: 약관 동의 후 '자세히 보기' 진입 후 복귀 시 동의 상태가 유지되어야 한다 + * QA 2차 2: 약관 동의 후 온보딩 진입 → 뒤로가기로 복귀 시 동의 상태가 유지되어야 한다 + * + * 두 버그의 원인은 동일하다. 체크 상태가 OnboardingTermsScreen의 컴포저블 로컬 + * remember에 있어서, 화면을 벗어나면 컴포지션이 해제되며 상태가 초기화됐다. + * 상태를 OnboardingTermsUiState로 올려 nav graph에 스코프된 OnboardingViewModel이 + * 들고 있게 하고, 그 전이 로직을 여기서 검증한다. + */ +class TermsAgreementStateTest { + private val service = + TermModel( + id = "1", + type = "SERVICE", + version = 1, + title = "서비스 이용약관", + content = "", + required = true, + activeAt = "2026-05-13T10:48:54.554Z", + ) + private val privacy = + TermModel( + id = "2", + type = "PRIVACY", + version = 1, + title = "개인정보 처리 방침", + content = "", + required = true, + activeAt = "2026-05-13T10:48:54.554Z", + ) + private val marketing = + TermModel( + id = "3", + type = "MARKETING", + version = 1, + title = "마케팅 정보 수신 동의", + content = "", + required = false, + activeAt = "2026-05-13T10:48:54.554Z", + ) + + private fun loaded() = + OnboardingTermsUiState( + termsState = UiState.Success(listOf(service, privacy, marketing)), + ) + + // ---- 체크 토글 ---- + + @Test + fun `약관을 체크하면 checkedTermIds에 반영된다`() { + val state = loaded().toggleChecked(service.id) + + assertTrue(state.isChecked(service.id)) + assertFalse(state.isChecked(privacy.id)) + } + + @Test + fun `이미 체크된 약관을 다시 누르면 해제된다`() { + val state = loaded().toggleChecked(service.id).toggleChecked(service.id) + + assertFalse(state.isChecked(service.id)) + } + + // ---- 전체 동의 ---- + + @Test + fun `전체 동의를 누르면 모든 약관이 체크된다`() { + val state = loaded().toggleAllChecked() + + assertTrue(state.isAllChecked) + assertTrue(state.isChecked(service.id)) + assertTrue(state.isChecked(privacy.id)) + assertTrue(state.isChecked(marketing.id)) + } + + @Test + fun `모두 체크된 상태에서 전체 동의를 누르면 모두 해제된다`() { + val state = loaded().toggleAllChecked().toggleAllChecked() + + assertFalse(state.isAllChecked) + assertTrue(state.checkedTermIds.isEmpty()) + } + + @Test + fun `일부만 체크된 상태에서 전체 동의를 누르면 모두 체크된다`() { + val state = loaded().toggleChecked(service.id).toggleAllChecked() + + assertTrue(state.isAllChecked) + } + + // ---- 동의하기 버튼 활성화 ---- + + @Test + fun `필수 약관이 모두 체크되어야 진행할 수 있다`() { + val onlyOneRequired = loaded().toggleChecked(service.id) + assertFalse(onlyOneRequired.canProceed) + + val allRequired = onlyOneRequired.toggleChecked(privacy.id) + assertTrue(allRequired.canProceed) + } + + @Test + fun `선택 약관은 진행 가능 여부에 영향을 주지 않는다`() { + val state = loaded().toggleChecked(marketing.id) + + assertFalse(state.canProceed) + } + + @Test + fun `약관이 로드되지 않았으면 진행할 수 없다`() { + assertFalse(OnboardingTermsUiState().canProceed) + assertFalse(OnboardingTermsUiState().isAllChecked) + } + + // ---- 동의한 약관 ID 수집 ---- + + @Test + fun `체크된 약관의 id만 동의 목록으로 수집된다`() { + val state = loaded().toggleChecked(service.id).toggleChecked(marketing.id) + + assertEquals(listOf(service.id, marketing.id), state.agreedIds) + } + + // ---- QA 2차 1, 2: 화면 재진입 시 상태 유지 ---- + + @Test + fun `자세히 보기에서 복귀해도 체크 상태가 유지된다`() { + // 약관을 펼치고 체크한 뒤 '자세히 보기'로 이동한 상태 + val beforeLeaving = + loaded() + .toggleExpanded(service.id) + .toggleChecked(service.id) + .toggleChecked(privacy.id) + + // OnboardingViewModel은 nav graph에 스코프되어 있으므로 + // TermsDetail을 다녀와도 동일한 상태 인스턴스가 유지된다. + val afterReturning = beforeLeaving + + assertTrue(afterReturning.isChecked(service.id)) + assertTrue(afterReturning.isChecked(privacy.id)) + assertTrue(afterReturning.isExpanded(service.id)) + assertTrue(afterReturning.canProceed) + } + + @Test + fun `약관 목록이 다시 로드되어도 체크 상태가 초기화되지 않는다`() { + val checked = loaded().toggleChecked(service.id).toggleChecked(privacy.id) + + // 재진입으로 loadTerms()가 다시 성공해 새 리스트 인스턴스가 들어온 경우 + val reloaded = + checked.copy( + termsState = UiState.Success(listOf(service, privacy, marketing)), + ) + + assertTrue(reloaded.isChecked(service.id)) + assertTrue(reloaded.isChecked(privacy.id)) + assertTrue(reloaded.canProceed) + } + + @Test + fun `펼침 상태도 화면 재진입 사이에 유지된다`() { + val state = loaded().toggleExpanded(marketing.id) + + assertTrue(state.isExpanded(marketing.id)) + assertFalse(state.isExpanded(service.id)) + + assertFalse(state.toggleExpanded(marketing.id).isExpanded(marketing.id)) + } +} From 46bf64ccb04f39e532fcd99ca322186e0444a044 Mon Sep 17 00:00:00 2001 From: kimjw Date: Tue, 25 Aug 2026 23:05:39 +0900 Subject: [PATCH 2/6] =?UTF-8?q?fix:=20=ED=83=88=ED=87=B4=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20=EC=9C=A0=EC=9D=98=EC=82=AC=ED=95=AD=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=EC=9D=98=20=EA=B0=84=EA=B2=A9=EA=B3=BC=20=EB=B2=88?= =?UTF-8?q?=ED=98=B8=20=EC=A0=95=EB=A0=AC=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 항목 사이 간격이 없어 문장 내 줄간격과 구분되지 않던 문제. Arrangement.spacedBy(24.dp) 를 넣어 항목 단위가 드러나게 했다. Type.kt 의 FigmaLineHeightStyle 이 trim = None 이라 Text 높이가 Figma 텍스트 노드와 같은 방식(lineHeight x 줄 수)으로 계산되므로, 이 값은 Figma 오토레이아웃 gap 과 1:1로 대응한다. - 번호와 본문의 y 위치가 미세하게 어긋나던 문제. 둘을 별도 Text 로 두면 baseline 이 맞아도 숫자 글리프와 한글 글리프의 시각 중심이 달라 어긋나 보인다. 기존에는 본문에 offset(y = -3.dp) 를 걸어 보정했는데, offset 은 레이아웃이 아니라 그리기만 옮기므로 여러 줄 항목에서 블록 전체가 밀려 오히려 더 어긋났다. 번호와 본문을 하나의 Text 로 합치고 들여쓰기는 TextIndent(restLine) 으로 처리했다. 같은 줄 박스를 쓰므로 y 어긋남이 구조적으로 발생할 수 없고, 들여쓰기 폭은 TextMeasurer 로 번호의 실제 렌더 너비를 재서 쓰기 때문에 폰트 배율이 바뀌어도 둘째 줄이 본문 시작선에 맞는다. 하드코딩 상수 없음. 에뮬레이터에서 설정 > 탈퇴하기 경로로 진입해 확인. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01DuxSwiSeddHi5XodSpFyFo --- .../setting/withdraw/WithdrawScreen.kt | 39 ++++++++++++------- 1 file changed, 25 insertions(+), 14 deletions(-) diff --git a/app/src/main/java/com/flint/android/presentation/setting/withdraw/WithdrawScreen.kt b/app/src/main/java/com/flint/android/presentation/setting/withdraw/WithdrawScreen.kt index 4d69b632..3c831074 100644 --- a/app/src/main/java/com/flint/android/presentation/setting/withdraw/WithdrawScreen.kt +++ b/app/src/main/java/com/flint/android/presentation/setting/withdraw/WithdrawScreen.kt @@ -1,13 +1,13 @@ package com.flint.android.presentation.setting.withdraw import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.systemBarsPadding @@ -17,11 +17,15 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.text.style.TextIndent import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel @@ -138,21 +142,28 @@ private fun WithdrawNoticeList(modifier: Modifier = Modifier) { "관련 법령에 따라 일부 정보는 일정 기간 보관될 수 있으며, 해당 정보는 법적 의무 이외의 목적으로 사용되지 않습니다.", ) - Column(modifier = modifier) { + // 번호와 본문을 별도 Text로 나누면 숫자 글리프와 한글 글리프의 시각 중심이 달라 + // 미세하게 어긋나 보인다. 하나의 Text로 합치고 두 번째 줄부터 번호 너비만큼 + // 들여쓰기(hanging indent)하면 같은 줄 박스를 쓰므로 어긋날 여지가 없다. + val style = FlintTheme.typography.body1M16 + val textMeasurer = rememberTextMeasurer() + val density = LocalDensity.current + + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(24.dp), + ) { notices.forEachIndexed { index, text -> - Row { - Text( - text = "${index + 1}. ", - style = FlintTheme.typography.body1M16, - color = FlintTheme.colors.gray300, - ) - Text( - text = text, - style = FlintTheme.typography.body1M16, - color = FlintTheme.colors.gray300, - modifier = Modifier.offset(y = (-3).dp) - ) + val marker = "${index + 1}. " + val indent = remember(marker, style, density) { + with(density) { textMeasurer.measure(marker, style).size.width.toSp() } } + + Text( + text = marker + text, + style = style.copy(textIndent = TextIndent(restLine = indent)), + color = FlintTheme.colors.gray300, + ) } } } From 0122b08e234af738f14d4ebd6d70b51270c343a0 Mon Sep 17 00:00:00 2001 From: kimjw Date: Tue, 25 Aug 2026 23:16:42 +0900 Subject: [PATCH 3/6] =?UTF-8?q?fix:=20=EC=84=A4=EC=A0=95=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20=ED=94=84=EB=A1=9C=ED=95=84=20=EC=88=98=EC=A0=95=20?= =?UTF-8?q?=EB=B2=84=ED=8A=BC=20=ED=85=8C=EB=91=90=EB=A6=AC=EB=A5=BC=201dp?= =?UTF-8?q?=20=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 디자인 QA 반영. ColorOutline 상태의 기본 테두리는 2dp 이고, Outline / Error 도 같은 2dp 라 상태값 자체는 의도된 기본값으로 보인다. 공용 상태를 바꾸면 이 상태를 쓰는 모든 버튼이 함께 바뀌므로, 화면 단위로 굵기만 덮어쓸 수 있는 경로를 열었다. - FlintBasicButton 에 borderWidth: Dp? = null 추가. null 이면 상태 기본값을 그대로 쓰므로 기존 사용처는 영향이 없다. 색은 상태에서 가져오고 굵기만 교체한다. - FlintMediumButton 이 값을 그대로 전달. - SettingScreen 의 프로필 수정 버튼만 borderWidth = 1.dp. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01DuxSwiSeddHi5XodSpFyFo --- .../core/designsystem/component/button/FlintBasicButton.kt | 7 ++++++- .../designsystem/component/button/FlintMediumButton.kt | 3 +++ .../flint/android/presentation/setting/SettingScreen.kt | 1 + 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/flint/android/core/designsystem/component/button/FlintBasicButton.kt b/app/src/main/java/com/flint/android/core/designsystem/component/button/FlintBasicButton.kt index fa76afba..5ccc033d 100644 --- a/app/src/main/java/com/flint/android/core/designsystem/component/button/FlintBasicButton.kt +++ b/app/src/main/java/com/flint/android/core/designsystem/component/button/FlintBasicButton.kt @@ -29,6 +29,7 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.TextStyle import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.flint.android.R import com.flint.android.core.designsystem.interaction.FlintPressDefaults @@ -47,10 +48,14 @@ fun FlintBasicButton( contentPadding: PaddingValues, @DrawableRes leadingIconRes: Int? = null, pressedScale: Float = FlintPressDefaults.BUTTON_SCALE, + // 상태가 정한 테두리 굵기를 화면 단위로 덮어쓸 때만 지정한다. null이면 상태 기본값을 쓴다. + borderWidth: Dp? = null, ) { val background: Brush = state.background val contentColor: Color = state.contentColor - val border: BorderStroke? = state.border + val border: BorderStroke? = state.border?.let { stroke -> + if (borderWidth == null) stroke else BorderStroke(borderWidth, stroke.brush) + } val shape = RoundedCornerShape(8.dp) val interactionSource = remember { MutableInteractionSource() } diff --git a/app/src/main/java/com/flint/android/core/designsystem/component/button/FlintMediumButton.kt b/app/src/main/java/com/flint/android/core/designsystem/component/button/FlintMediumButton.kt index 87dcf22d..e03d63be 100644 --- a/app/src/main/java/com/flint/android/core/designsystem/component/button/FlintMediumButton.kt +++ b/app/src/main/java/com/flint/android/core/designsystem/component/button/FlintMediumButton.kt @@ -13,6 +13,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.text.TextStyle import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.flint.android.core.designsystem.theme.FlintTheme @@ -24,6 +25,7 @@ fun FlintMediumButton( modifier: Modifier = Modifier, textStyle: TextStyle = if (state == FlintButtonState.Able) FlintTheme.typography.body1Sb16 else FlintTheme.typography.body1M16, enabled: Boolean = true, + borderWidth: Dp? = null, ) { FlintBasicButton( text = text, @@ -32,6 +34,7 @@ fun FlintMediumButton( textStyle = textStyle, enabled = enabled, contentPadding = PaddingValues(10.dp), + borderWidth = borderWidth, modifier = modifier .padding(vertical = 2.dp) diff --git a/app/src/main/java/com/flint/android/presentation/setting/SettingScreen.kt b/app/src/main/java/com/flint/android/presentation/setting/SettingScreen.kt index f2de4691..26ccda1c 100644 --- a/app/src/main/java/com/flint/android/presentation/setting/SettingScreen.kt +++ b/app/src/main/java/com/flint/android/presentation/setting/SettingScreen.kt @@ -231,6 +231,7 @@ private fun SettingProfileSection( state = FlintButtonState.ColorOutline, onClick = onEditProfileClick, textStyle = FlintTheme.typography.body2M14, + borderWidth = 1.dp, modifier = Modifier.weight(0.8f), ) } From be275a831ee959178dd6b42348cd5eaffc9606eb Mon Sep 17 00:00:00 2001 From: kimjw Date: Tue, 25 Aug 2026 23:16:50 +0900 Subject: [PATCH 4/6] =?UTF-8?q?design:=20=EC=84=A4=EC=A0=95=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20=EC=B9=B4=EC=B9=B4=EC=98=A4=20=EC=95=84=EC=9D=B4?= =?UTF-8?q?=EC=BD=98=20=EC=97=90=EC=85=8B=20=EA=B5=90=EC=B2=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 계정 행에 쓰이는 ic_kakao_full.png 를 새 에셋으로 교체. 기존 파일이 16x16 이라 16dp 로 그릴 때 xxhdpi(48px 필요)에서 3배 업스케일돼 뭉개졌다. 새 에셋은 64x64 라 다운스케일이 되어 모든 밀도에서 선명하다. 로그인 버튼이 쓰는 ic_kakao.png 는 별도 리소스라 그대로 둔다. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01DuxSwiSeddHi5XodSpFyFo --- app/src/main/res/drawable/ic_kakao_full.png | Bin 528 -> 2266 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/app/src/main/res/drawable/ic_kakao_full.png b/app/src/main/res/drawable/ic_kakao_full.png index 0c77c76025efbb697493fd3d5c632ec9e47397bf..edab064948cbf1b7efe3b8aa3138ea173234bb8e 100644 GIT binary patch delta 2234 zcmV;r2u1gh1lkcciBL{Q4GJ0x0000DNk~Le0000$0000$2nGNE0IF$m-T(jq32;bR za{vG=O8@{YO97=lmZ_08Ie!RMNkl*>RVy_W#TM#A{exY#(5eN(TOg!tkcfE4 z;Rip6L)Ji86e6-A&U)9oGnaGjdN;PSe%zVaha)~|y}RDIckX%KbAQh{BT!+eQ?aO% zLrhrMB9w|kAgUDF=%?tcOA|sM1%-rC;I0i|%+F0V6O5kfht-y8SN1UKAu8JcQPc1D1t&P@3VJ0aYPd^V;u!rmAkf=75_06RA2sD~90Lp^{PA*Hz4i#A9Yq9Ap3 ztZs;BC`4UH9dk~^_QDWG8lX;Yf6!IJ_$Jka6l%ZddilU&uU|9+oYCHg>8%e-14Guv zBYPHe;i4Gep6oqF!AlR85Gbe`kzGf&E_B_3K7b{^)QD&Bnt!LXc3#ZO0J8b}v2@hF z9QxFW?GMg(!JHS78aY%xETKTXL#}13^Hk8(sAI>-lUY8cf2HySyCcQXWq7zz|LJw*!|sh-qu)( z{u#l?>F)Uc;@8r2ejQ(Nf!VB!-~ILsM&5QXkOvF+O-{Q8%x zL3{-9NB`y#Gqj-O18CxySK{;e!h-(w=sE2E&L=qfdIss-Bs){j#4~too>Xd_=6dP* zG;Y5&iKDNM>w6W=#{=5jJW5(#;-ho2_mmgg&*v3vvf&^7Fo}kkH?=r4^t2+JydYP78v4cL~+#!i_=gn5UZ4)BA!l1QG-R5dBW#7h$2=C3wjVBo4kD&CX<0tu_@$cEyuCt(OC zo|BS~Jlcu%oe}yjQH}6`qfp-RdVe-IYT`L5>q+{mp!tQIpu!<|0B2;G2}A!}i*4H) zDL$~ZQ>ql0(57%0cir8BKR>XhkP!8j73QSqFuzYvi=Xpa7vTIH$41BBN#nj>rI60j zfe9+GBQt9jOTE1{gu%abpmTM|UzKP|{rTf1dAVW`9#ISoufM(t&pfvln}0Tk;rGD6 zMDr^`+1FLA5#0Nm75Lx(*Xlq_`v4#2#=V8b&!y7?)f)!Lne_W9ix<`Z)1)d(f5AtH zIqQRL-+aypb8Rpz(Y;CHo_pG`>Doq}i{xRp|D89*t+vH4B?fS4^ zOuD#m?s5WIOTsdQuia$f^nWJ~vg1P65&6n+M51}Q!n&(1+;Bq!zD_|6t2skw-c(@K zVKPlIY)xyFm~nm#LJQt}`=YJ{7g3NZ(7rr^zx|^F9V>04-6AN$3yVR*mYC5hGs94XfGah@q6D!*7s%({Wfo*?wn}1B%<+!80hrPDK z?!4n8oKNTU)NM^6JoNVttm_Qx0*b6<_|6XiJNM&3;uLmes4BKCc_sw_Bm(9{x1b8Cm zCYA_zt&1U^vFNiKtCFjH#0XUaMS6n^sr_^q9bVa)I7006vPD)Lv?fQ1eW8tUqdrH^ z4^0KtQ5pm&Lmj8|Br~405P~>p2h{qF8}}l07*qo IM6N<$f>y#j=l}o! delta 483 zcmV<90UZ9?5s(BniBL{Q4GJ0x0000DNk~Le0000G0000G2nGNE03Y-JVE_OC32;bR za{vGf6951U69E94oEVWdIe!6*NklvFN+XCTpIEn|kg_NhZQz#>N`D=~6fE@W3ouzp zRn{((7$CSK5;Vgi!mViMLCLNmW81RmifQ+xbX48jlzO*RYGVK3*&GWAJ;@yJb{o5UPr<`v z1OjKjxO_vuxVvoz(|@GEAA_iqqBClE$<}6~ufWP`G$5vjOMkDuf@KY3sSxks3@jF6 zn9oN18c!$LdUOS;au7T__kdPQB9SmKWC~18hq1OC>$pOb1uK377 Z{3nQ+p*czx{@4Hj002ovPDHLkV1mIa&ddM+ From ad4098e289f09e425a92915b5d8312c56db88317 Mon Sep 17 00:00:00 2001 From: kimjw Date: Tue, 25 Aug 2026 23:22:49 +0900 Subject: [PATCH 5/6] =?UTF-8?q?design:=20=ED=99=88=20'=EC=B5=9C=EA=B7=BC?= =?UTF-8?q?=20=EC=A0=80=EC=9E=A5=ED=95=9C=20=EC=BD=98=ED=85=90=EC=B8=A0'?= =?UTF-8?q?=20=EC=84=B9=EC=85=98=20=EB=B6=80=EC=A0=9C=20=EB=AC=B8=EA=B5=AC?= =?UTF-8?q?=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "현재 구독 중인 OTT에서 볼 수 있는 작품들이에요" -> "관심이 가는 작품들을 다시 만나보세요" SavedContentsSection 은 ProfileScreen 도 쓰지만 거기서는 별도 문구를 넘기므로 홈 화면에만 적용된다. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01DuxSwiSeddHi5XodSpFyFo --- .../main/java/com/flint/android/presentation/home/HomeScreen.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/flint/android/presentation/home/HomeScreen.kt b/app/src/main/java/com/flint/android/presentation/home/HomeScreen.kt index d91da312..82ef89d6 100644 --- a/app/src/main/java/com/flint/android/presentation/home/HomeScreen.kt +++ b/app/src/main/java/com/flint/android/presentation/home/HomeScreen.kt @@ -150,7 +150,7 @@ private fun HomeScreen( SavedContentsSection( title = "최근 저장한 콘텐츠", - description = "현재 구독 중인 OTT에서 볼 수 있는 작품들이에요", + description = "관심이 가는 작품들을 다시 만나보세요", isAllVisible = false, onAllClick = {}, contentModelList = savedContentModelList, From b1de243d7bb51eaf3910f9dd9c7855f7d64bc68b Mon Sep 17 00:00:00 2001 From: kimjw Date: Tue, 25 Aug 2026 23:34:45 +0900 Subject: [PATCH 6/6] =?UTF-8?q?fix:=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=EC=97=90=EC=84=9C=20=EB=92=A4=EB=A1=9C?= =?UTF-8?q?=EA=B0=80=EA=B8=B0=20=EC=8B=9C=20=EC=9D=B4=EC=A0=84=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=EC=9C=BC=EB=A1=9C=20=EB=90=98=EB=8F=8C=EC=95=84?= =?UTF-8?q?=EA=B0=80=EB=8A=94=20=EB=AC=B8=EC=A0=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 로그아웃 후 시작 화면에서 뒤로가기를 누르면 설정 화면으로 돌아갔다. navigateToLogin 만 popUpTo(startDestination) 을 쓰고 있었는데, 시작 목적지인 Splash 는 홈 진입 시 navigateToHome(clearStackNavOptions) 가 popUpTo(0) 으로 이미 제거한 뒤다. 따라서 로그아웃 시점의 스택 [Home, Setting] 에는 매칭 대상이 없어 아무것도 pop 되지 않았고, [Home, Setting, Login] 이 되어 뒤로가기가 설정으로 향했다. 형제 메서드(navigateToHome / navigateToOnBoarding / navigateToWithdrawComplete)와 동일하게 clearStackNavOptions 를 쓰도록 통일했다. navigateToLogin 은 스플래시, 로그아웃, 회원탈퇴 완료 세 경로에서 호출되는데 탈퇴 완료 경로도 같은 증상이 있었고 함께 해결된다. 미사용이 된 findStartDestination import 제거. 에뮬레이터에서 로그아웃 후 뒤로가기가 앱을 종료하는 것을 확인. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01DuxSwiSeddHi5XodSpFyFo --- .../android/presentation/main/MainNavigator.kt | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/com/flint/android/presentation/main/MainNavigator.kt b/app/src/main/java/com/flint/android/presentation/main/MainNavigator.kt index b02306da..9b6e6211 100644 --- a/app/src/main/java/com/flint/android/presentation/main/MainNavigator.kt +++ b/app/src/main/java/com/flint/android/presentation/main/MainNavigator.kt @@ -5,7 +5,6 @@ import androidx.compose.runtime.Stable import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.navigation.NavDestination.Companion.hasRoute -import androidx.navigation.NavGraph.Companion.findStartDestination import androidx.navigation.NavHostController import androidx.navigation.NavOptions import androidx.navigation.compose.rememberNavController @@ -110,15 +109,10 @@ class MainNavigator( launchSingleTop = true } - fun navigateToLogin() { - navController.navigateToLogin( - navOptions { - popUpTo(navController.graph.findStartDestination().id) { - inclusive = true - } - launchSingleTop = true - }, - ) + // 시작 목적지(Splash)는 홈 진입 시 이미 스택에서 제거되므로 popUpTo(startDestination)으로는 + // 아무것도 정리되지 않는다. 로그인 화면에서는 앱으로 되돌아갈 수 없어야 하므로 스택을 전부 비운다. + fun navigateToLogin(navOptions: NavOptions? = clearStackNavOptions) { + navController.navigateToLogin(navOptions) } fun navigateToOnBoarding(tempToken: String, navOptions: NavOptions? = clearStackNavOptions) {