diff --git a/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/detail/ReceivedAfternoteDetailSuccessMapper.kt b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/detail/ReceivedAfternoteDetailSuccessMapper.kt index 2bc133f9e1..3038d2398b 100644 --- a/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/detail/ReceivedAfternoteDetailSuccessMapper.kt +++ b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/detail/ReceivedAfternoteDetailSuccessMapper.kt @@ -3,6 +3,7 @@ package com.afternote.feature.receiver.presentation.detail import com.afternote.feature.afternote.domain.AfternoteType import com.afternote.feature.afternote.presentation.shared.model.AlbumCover import com.afternote.feature.afternote.presentation.shared.model.toMessageBlockUiModels +import com.afternote.feature.receiver.domain.model.ReceivedAccountCredentials import com.afternote.feature.receiver.domain.model.ReceivedAfternoteDetail internal fun ReceivedAfternoteDetail.toReceivedDetailContentUiModel(): ReceivedDetailContentUiModel = @@ -32,13 +33,29 @@ internal fun ReceivedAfternoteDetail.toReceivedDetailContentUiModel(): ReceivedD private fun ReceivedAfternoteDetail.toReceivedSocialNetworkDetailContent(): ReceivedSocialNetworkDetailContent = ReceivedSocialNetworkDetailContent( serviceName = title, - accountId = credentials?.id.orEmpty(), - password = credentials?.password.orEmpty(), + credentials = credentials.toUiModelOrNull(), processingMethods = processingMethods, messageBlocks = leaveMessageBlocks.toMessageBlockUiModels(), finalWriteDate = createdAt.orEmpty(), ) +/** + * 계정 자격증명을 표시 모델로 옮긴다. + * + * 공백뿐인 값은 미제공과 구분할 수 없으므로 `null` 로 접고, 그러고도 남는 값이 없으면 모델 자체를 + * 만들지 않는다. 예전처럼 `orEmpty()` 로 뭉개면 화면이 빈 비밀번호에 마스킹을 그려, 수신자가 + * 가려진 값이 있다고 오인한 채 "표시" 를 눌러도 아무것도 얻지 못했다 (#619). + */ +private fun ReceivedAccountCredentials?.toUiModelOrNull(): ReceivedAccountCredentialsUiModel? { + val accountId = this?.id?.takeIf(String::isNotBlank) + val password = this?.password?.takeIf(String::isNotBlank) + return if (accountId == null && password == null) { + null + } else { + ReceivedAccountCredentialsUiModel(accountId = accountId, password = password) + } +} + private fun ReceivedAfternoteDetail.toReceivedGalleryDetailContent(): ReceivedGalleryDetailContent = ReceivedGalleryDetailContent( serviceName = title, diff --git a/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/detail/ReceivedDetailContentUiModel.kt b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/detail/ReceivedDetailContentUiModel.kt index f70f72fcda..f4be1f8a4c 100644 --- a/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/detail/ReceivedDetailContentUiModel.kt +++ b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/detail/ReceivedDetailContentUiModel.kt @@ -34,13 +34,28 @@ sealed interface ReceivedDetailContentUiModel { @Immutable data class ReceivedSocialNetworkDetailContent( val serviceName: String = "", - val accountId: String = "", - val password: String = "", + val credentials: ReceivedAccountCredentialsUiModel? = null, val processingMethods: List = emptyList(), val messageBlocks: List = emptyList(), val finalWriteDate: String = "", ) +/** + * 발신자가 남긴 계정 자격증명 표시 모델. + * + * **부재를 빈 문자열로 표현하지 않는다.** 서버는 수신자 상세에 `credentials` 를 아예 내려주지 + * 않고(#619), 내려주더라도 발신자가 한쪽만 적었을 수 있다. 값이 없는 자리를 `""` 로 채우면 + * 화면이 "가려진 값이 있다" 는 마스킹을 그리게 되므로, 없는 값은 `null` 로 남긴다. + * + * 양쪽 모두 없으면 이 모델을 만들지 않고 [ReceivedSocialNetworkDetailContent.credentials] 를 + * `null` 로 둔다 — 이 타입이 존재하면 최소 한쪽에는 값이 있다는 뜻이다. + */ +@Immutable +data class ReceivedAccountCredentialsUiModel( + val accountId: String?, + val password: String?, +) + @Immutable data class ReceivedGalleryDetailContent( val serviceName: String = "", diff --git a/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/detail/SocialNetworkReceivedDetailScreen.kt b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/detail/SocialNetworkReceivedDetailScreen.kt index 030363bdca..ad39760068 100644 --- a/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/detail/SocialNetworkReceivedDetailScreen.kt +++ b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/detail/SocialNetworkReceivedDetailScreen.kt @@ -35,6 +35,7 @@ import com.afternote.feature.afternote.presentation.shared.detail.MessageSection import com.afternote.feature.afternote.presentation.shared.detail.ProcessingMethodsSection import com.afternote.feature.afternote.presentation.shared.model.AfternoteServiceDisplay import com.afternote.feature.afternote.presentation.shared.model.MessageBlockUiModel +import com.afternote.feature.receiver.presentation.R as ReceiverR /** * 수신 소셜 네트워크 상세 (Stateless). @@ -92,10 +93,7 @@ private fun SocialNetworkReceivedDetailScrollContent( Spacer(modifier = Modifier.height(31.dp)) Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { - ReceivedAccountSection( - accountId = content.accountId, - password = content.password, - ) + ReceivedAccountSection(credentials = content.credentials) ProcessingMethodsSection(methods = content.processingMethods) MessageSection(blocks = content.messageBlocks) } @@ -107,64 +105,100 @@ private fun SocialNetworkReceivedDetailScrollContent( * * 발신자 화면의 동명 private composable과 시각적으로 동일하나, 향후 노출 정책 변경 가능성에 대비해 * receiver 화면에 별도 정의한다. + * + * [credentials] 가 `null` 이면 — 서버가 수신자에게 자격증명을 내려주지 않았거나 발신자가 적지 + * 않은 경우 — 아이디·비밀번호 행 대신 부재 문구를 그린다. 빈 값에 마스킹을 씌우면 수신자는 + * 가려진 값이 있다고 믿고 "표시" 를 누르지만 아무것도 얻지 못한다 (#619). */ @Composable private fun ReceivedAccountSection( - accountId: String, - password: String, + credentials: ReceivedAccountCredentialsUiModel?, modifier: Modifier = Modifier, ) { - var passwordVisible by remember { mutableStateOf(false) } - DetailSection( iconResId = com.afternote.core.ui.R.drawable.core_ui_user, label = stringResource(R.string.feature_afternote_detail_section_account), modifier = modifier, ) { + if (credentials == null) { + Text( + text = stringResource(ReceiverR.string.receiver_detail_account_absent), + style = AfternoteDesign.typography.bodySmallR, + color = AfternoteDesign.colors.gray6, + ) + return@DetailSection + } + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { DetailInfoRow( iconResId = com.afternote.core.ui.R.drawable.core_ui_user, label = stringResource(R.string.feature_afternote_detail_label_id), - value = accountId, + value = credentials.accountId ?: stringResource(ReceiverR.string.receiver_detail_account_value_absent), ) HorizontalDivider( color = AfternoteDesign.colors.gray2, thickness = 1.dp, ) - DetailInfoRow( - iconResId = R.drawable.feature_afternote_ic_lock, - label = stringResource(R.string.feature_afternote_detail_label_password), - value = - if (passwordVisible) { - password - } else { - stringResource(R.string.feature_afternote_detail_password_mask) - }, - trailingContent = { - val toggleLabel = - if (passwordVisible) { - stringResource(R.string.feature_afternote_detail_password_hide) - } else { - stringResource(R.string.feature_afternote_detail_password_show) - } - Text( - text = toggleLabel, - style = AfternoteDesign.typography.captionLargeR, - color = AfternoteDesign.colors.b1, - modifier = - Modifier.clickable( - role = Role.Button, - onClickLabel = toggleLabel, - ) { - passwordVisible = !passwordVisible - }, - ) - }, - ) + ReceivedPasswordRow(password = credentials.password) } } } +/** + * 비밀번호 행. 값이 있을 때만 마스킹과 표시 토글을 붙인다 — 없는 값을 가리는 시늉을 하지 않는다. + */ +@Composable +private fun ReceivedPasswordRow( + password: String?, + modifier: Modifier = Modifier, +) { + val passwordLabel = stringResource(R.string.feature_afternote_detail_label_password) + + if (password == null) { + DetailInfoRow( + iconResId = R.drawable.feature_afternote_ic_lock, + label = passwordLabel, + value = stringResource(ReceiverR.string.receiver_detail_account_value_absent), + modifier = modifier, + ) + return + } + + var passwordVisible by remember { mutableStateOf(false) } + + DetailInfoRow( + iconResId = R.drawable.feature_afternote_ic_lock, + label = passwordLabel, + value = + if (passwordVisible) { + password + } else { + stringResource(R.string.feature_afternote_detail_password_mask) + }, + modifier = modifier, + trailingContent = { + val toggleLabel = + if (passwordVisible) { + stringResource(R.string.feature_afternote_detail_password_hide) + } else { + stringResource(R.string.feature_afternote_detail_password_show) + } + Text( + text = toggleLabel, + style = AfternoteDesign.typography.captionLargeR, + color = AfternoteDesign.colors.b1, + modifier = + Modifier.clickable( + role = Role.Button, + onClickLabel = toggleLabel, + ) { + passwordVisible = !passwordVisible + }, + ) + }, + ) +} + @Preview(showBackground = true) @Composable private fun SocialNetworkReceivedDetailScreenPreview() { @@ -174,8 +208,11 @@ private fun SocialNetworkReceivedDetailScreenPreview() { content = ReceivedSocialNetworkDetailContent( serviceName = "인스타그램", - accountId = "qwerty123", - password = "qwerty123!", + credentials = + ReceivedAccountCredentialsUiModel( + accountId = "qwerty123", + password = "qwerty123!", + ), processingMethods = listOf("게시물 내리기", "추모 게시물 올리기", "추모 계정으로 전환하기"), messageBlocks = listOf( diff --git a/feature/receiver/presentation/src/main/res/values/strings.xml b/feature/receiver/presentation/src/main/res/values/strings.xml index e1abec4eda..926c59fa9e 100644 --- a/feature/receiver/presentation/src/main/res/values/strings.xml +++ b/feature/receiver/presentation/src/main/res/values/strings.xml @@ -35,4 +35,12 @@ --> 고인이 사후 정리하고자 하는 데이터입니다. 총 %1$d곡 + + + 고인이 계정 정보를 남기지 않았습니다. + 남기지 않음 diff --git a/feature/receiver/presentation/src/screenshotTest/kotlin/com/afternote/feature/receiver/presentation/detail/SocialNetworkReceivedDetailScreenScreenshotTest.kt b/feature/receiver/presentation/src/screenshotTest/kotlin/com/afternote/feature/receiver/presentation/detail/SocialNetworkReceivedDetailScreenScreenshotTest.kt index 2a86aae0e2..a38f226e4d 100644 --- a/feature/receiver/presentation/src/screenshotTest/kotlin/com/afternote/feature/receiver/presentation/detail/SocialNetworkReceivedDetailScreenScreenshotTest.kt +++ b/feature/receiver/presentation/src/screenshotTest/kotlin/com/afternote/feature/receiver/presentation/detail/SocialNetworkReceivedDetailScreenScreenshotTest.kt @@ -5,11 +5,66 @@ import androidx.compose.ui.tooling.preview.Preview import com.afternote.core.ui.theme.AfternoteTheme import com.android.tools.screenshot.PreviewTest +/** + * 자격증명 부재 — 서버가 수신자에게 `credentials` 를 내려주지 않는 실제 상태다. + * + * 예전에는 이 baseline 이 빈 아이디와 `••••••••` 마스킹을 찍었다. 가려진 값이 있다는 표시였지만 + * "표시" 를 눌러도 빈 값뿐이었다 (#619). + * + * 세 baseline 은 **같은 애프터노트의 자격증명 상태만 다른 판**이다 — 서비스명을 함께 채우는 이유는 + * 제목이 빈 화면이 실재하지 않기 때문이다. 제목은 에디터 검증(`TITLE_REQUIRED`)·서버 + * `@NotBlank`·DB `nullable = false` 로 3중으로 강제된다. + */ @PreviewTest @Preview(showBackground = true) @Composable internal fun socialNetworkReceivedDetailScreenScreenshot() { AfternoteTheme { - SocialNetworkReceivedDetailScreen(onBackClick = {}) + SocialNetworkReceivedDetailScreen( + onBackClick = {}, + content = ReceivedSocialNetworkDetailContent(serviceName = "인스타그램"), + ) + } +} + +/** 자격증명이 온전히 전달된 상태 — 마스킹과 표시 토글이 붙는 유일한 경우다. */ +@PreviewTest +@Preview(showBackground = true) +@Composable +internal fun socialNetworkReceivedDetailScreenWithCredentialsScreenshot() { + AfternoteTheme { + SocialNetworkReceivedDetailScreen( + onBackClick = {}, + content = + ReceivedSocialNetworkDetailContent( + serviceName = "인스타그램", + credentials = + ReceivedAccountCredentialsUiModel( + accountId = "qwerty123", + password = "qwerty123!", + ), + ), + ) + } +} + +/** 발신자가 아이디만 남긴 상태 — 비밀번호 자리에 마스킹 대신 부재를 명시한다. */ +@PreviewTest +@Preview(showBackground = true) +@Composable +internal fun socialNetworkReceivedDetailScreenWithIdOnlyScreenshot() { + AfternoteTheme { + SocialNetworkReceivedDetailScreen( + onBackClick = {}, + content = + ReceivedSocialNetworkDetailContent( + serviceName = "인스타그램", + credentials = + ReceivedAccountCredentialsUiModel( + accountId = "qwerty123", + password = null, + ), + ), + ) } } diff --git a/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/detail/SocialNetworkReceivedDetailScreenScreenshotTestKt/socialNetworkReceivedDetailScreenScreenshot_748aa731_0.png b/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/detail/SocialNetworkReceivedDetailScreenScreenshotTestKt/socialNetworkReceivedDetailScreenScreenshot_748aa731_0.png index 3f664811a6..43ae02e4b1 100644 Binary files a/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/detail/SocialNetworkReceivedDetailScreenScreenshotTestKt/socialNetworkReceivedDetailScreenScreenshot_748aa731_0.png and b/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/detail/SocialNetworkReceivedDetailScreenScreenshotTestKt/socialNetworkReceivedDetailScreenScreenshot_748aa731_0.png differ diff --git a/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/detail/SocialNetworkReceivedDetailScreenScreenshotTestKt/socialNetworkReceivedDetailScreenWithCredentialsScreenshot_748aa731_0.png b/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/detail/SocialNetworkReceivedDetailScreenScreenshotTestKt/socialNetworkReceivedDetailScreenWithCredentialsScreenshot_748aa731_0.png new file mode 100644 index 0000000000..15dd6b1823 Binary files /dev/null and b/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/detail/SocialNetworkReceivedDetailScreenScreenshotTestKt/socialNetworkReceivedDetailScreenWithCredentialsScreenshot_748aa731_0.png differ diff --git a/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/detail/SocialNetworkReceivedDetailScreenScreenshotTestKt/socialNetworkReceivedDetailScreenWithIdOnlyScreenshot_748aa731_0.png b/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/detail/SocialNetworkReceivedDetailScreenScreenshotTestKt/socialNetworkReceivedDetailScreenWithIdOnlyScreenshot_748aa731_0.png new file mode 100644 index 0000000000..4ff25de761 Binary files /dev/null and b/feature/receiver/presentation/src/screenshotTestDebug/reference/com/afternote/feature/receiver/presentation/detail/SocialNetworkReceivedDetailScreenScreenshotTestKt/socialNetworkReceivedDetailScreenWithIdOnlyScreenshot_748aa731_0.png differ diff --git a/feature/receiver/presentation/src/test/kotlin/com/afternote/feature/receiver/presentation/detail/ReceivedSocialNetworkCredentialsMappingTest.kt b/feature/receiver/presentation/src/test/kotlin/com/afternote/feature/receiver/presentation/detail/ReceivedSocialNetworkCredentialsMappingTest.kt new file mode 100644 index 0000000000..2d5465f24b --- /dev/null +++ b/feature/receiver/presentation/src/test/kotlin/com/afternote/feature/receiver/presentation/detail/ReceivedSocialNetworkCredentialsMappingTest.kt @@ -0,0 +1,70 @@ +package com.afternote.feature.receiver.presentation.detail + +import com.afternote.feature.afternote.domain.AfternoteType +import com.afternote.feature.receiver.domain.model.ReceivedAccountCredentials +import com.afternote.feature.receiver.domain.model.ReceivedAfternoteDetail +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +/** + * SOCIAL 수신 상세가 계정 자격증명의 **부재를 부재로** 옮긴다는 계약 (이슈 #619). + * + * 서버(`GET receiver-auth/after-notes/{id}`)는 수신자에게 `credentials` 를 내려주지 않는다. + * 그런데도 매퍼가 `orEmpty()` 로 빈 문자열을 채우던 탓에 화면이 빈 비밀번호에 마스킹을 그렸고, + * 수신자는 "표시" 를 눌러 빈 값을 확인하고서야 아무것도 전달되지 않았음을 알았다. + */ +class ReceivedSocialNetworkCredentialsMappingTest { + private fun detail(credentials: ReceivedAccountCredentials?) = + ReceivedAfternoteDetail( + title = "인스타그램", + type = AfternoteType.SOCIAL_NETWORK, + credentials = credentials, + ) + + private fun credentialsOf(detail: ReceivedAfternoteDetail): ReceivedAccountCredentialsUiModel? { + val uiModel = detail.toReceivedDetailContentUiModel() + + return (uiModel as ReceivedDetailContentUiModel.SocialNetwork).content.credentials + } + + @Test + fun `서버가 credentials 를 안 주면 표시 모델도 없다`() { + assertNull(credentialsOf(detail(credentials = null))) + } + + @Test + fun `아이디와 비밀번호가 모두 비면 표시 모델을 만들지 않는다`() { + val bothMissing = ReceivedAccountCredentials(id = null, password = null) + + assertNull(credentialsOf(detail(bothMissing))) + } + + @Test + fun `공백뿐인 값은 미제공과 같게 다룬다`() { + val blank = ReceivedAccountCredentials(id = "", password = " ") + + assertNull(credentialsOf(detail(blank))) + } + + @Test + fun `한쪽만 남겼으면 그쪽만 값으로 싣는다`() { + val idOnly = credentialsOf(detail(ReceivedAccountCredentials(id = "qwerty123", password = null))) + + assertEquals("qwerty123", idOnly?.accountId) + assertNull("비밀번호를 남기지 않았는데 값이 생겼다", idOnly?.password) + + val passwordOnly = credentialsOf(detail(ReceivedAccountCredentials(id = " ", password = "qwerty123!"))) + + assertNull("아이디가 공백뿐인데 값으로 실렸다", passwordOnly?.accountId) + assertEquals("qwerty123!", passwordOnly?.password) + } + + @Test + fun `둘 다 남겼으면 그대로 싣는다`() { + val both = credentialsOf(detail(ReceivedAccountCredentials(id = "qwerty123", password = "qwerty123!"))) + + assertEquals("qwerty123", both?.accountId) + assertEquals("qwerty123!", both?.password) + } +}