diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/detail/MemorialDetailScreen.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/detail/MemorialDetailScreen.kt index c45242f1ed..676af5dd48 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/detail/MemorialDetailScreen.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/detail/MemorialDetailScreen.kt @@ -43,7 +43,6 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage -import com.afternote.core.ui.ProfileImage import com.afternote.core.ui.modifierextention.FadingEdgeDirection import com.afternote.core.ui.modifierextention.horizontalFadingEdge import com.afternote.core.ui.popup.AfternoteActionMenu @@ -165,7 +164,12 @@ private fun MemorialDetailScrollContent( .padding(horizontal = 20.dp), ) { Spacer(modifier = Modifier.height(24.dp)) - TitleSection(categoryLabel = categoryLabel, userName = userName) + HeaderSection( + categoryLabel = categoryLabel, + userName = userName, + finalWriteDate = content.finalWriteDate, + profileImageUri = content.profileImageUri, + ) Spacer(modifier = Modifier.height(24.dp)) CardSection(content = content, onVideoClick = onVideoClick) Spacer(modifier = Modifier.height(24.dp)) @@ -174,6 +178,47 @@ private fun MemorialDetailScrollContent( } } +/** + * 상세 헤더 블록 — 정본 시안 + * [node 4327:72819](https://www.figma.com/design/UP9ZR186jHvRBicjA2SOea/?node-id=4327-72819) 기준 (#463). + * + * 시안은 350×94 한 행이다. 왼쪽 열에 타이틀과 「최종 작성일」이 4dp 간격으로 쌓이고, 오른쪽에 폭 148 + * 고정의 겹친 프로필 원 스택([MemorialProfileStack])이 붙는다. 종전 코드가 쓰던 «회색 카드 안에 + * 중앙 정렬된 134dp 프로필» 컨테이너는 시안에 존재하지 않아 카드째 걷어냈다. + * + * 「최종 작성일」은 값이 없을 때 줄을 그리지 않는다 — 접두어만 남은 「최종 작성일 」 렌더를 막는다. + * 바로 아래 [TitleSection] 의 이름 누락 처리와 같은 취지다. + */ +@Composable +private fun HeaderSection( + categoryLabel: String, + userName: String, + finalWriteDate: String, + profileImageUri: String?, +) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.Top, + ) { + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + TitleSection(categoryLabel = categoryLabel, userName = userName) + if (finalWriteDate.isNotBlank()) { + Text( + text = stringResource(R.string.afternote_last_written_date, finalWriteDate), + style = + AfternoteDesign.typography.footnoteCaption.copy( + color = AfternoteDesign.colors.gray6, + ), + ) + } + } + MemorialProfileStack(profileImageUri = profileImageUri) + } +} + @Composable private fun TitleSection( categoryLabel: String, @@ -205,10 +250,6 @@ private fun CardSection( onVideoClick: (String) -> Unit, ) { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - PhotoCard( - finalWriteDate = content.finalWriteDate, - profileImageUri = content.profileImageUri, - ) ReceiversCard(receivers = content.afternoteEditReceivers) PlaylistCard( albumCovers = content.albumCovers, @@ -244,35 +285,6 @@ private fun SharingNotice() { } } -@Composable -private fun PhotoCard( - finalWriteDate: String, - profileImageUri: String?, -) { - InfoCard( - modifier = Modifier.fillMaxWidth(), - content = { - Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(8.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Text( - text = stringResource(R.string.afternote_last_written_date, finalWriteDate), - modifier = Modifier.fillMaxWidth(), - style = - AfternoteDesign.typography.footnoteCaption.copy( - color = AfternoteDesign.colors.gray6, - ), - ) - ProfileImage( - displayImageUri = profileImageUri, - ) - } - }, - ) -} - /** * 장례식에 남길 영상 카드 — 정본 시안 * [node 4327:72859](https://www.figma.com/design/UP9ZR186jHvRBicjA2SOea/?node-id=4327-72859) 기준. diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/detail/MemorialProfileStack.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/detail/MemorialProfileStack.kt new file mode 100644 index 0000000000..3c4f5fbc29 --- /dev/null +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/detail/MemorialProfileStack.kt @@ -0,0 +1,112 @@ +package com.afternote.feature.afternote.presentation.detail + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.afternote.core.ui.ProfileImage +import com.afternote.core.ui.theme.AfternoteDesign +import com.afternote.feature.afternote.presentation.R + +/** 맨 앞 원 — 실제 추억 사진이 붙는 유일한 원. */ +private val FrontCircleSize: Dp = 96.dp + +/** 뒤에 겹치는 장식 원 둘. */ +private val EchoCircleSize: Dp = 80.dp + +/** 컨테이너 폭. 시안 실측 148 = 뒤 원 왼쪽 68 + 지름 80. */ +private val StackWidth: Dp = 148.dp + +private val MiddleCircleOffset: Dp = 43.dp + +private val BackCircleOffset: Dp = 68.dp + +/** + * 추억 노트 상세 헤더의 겹친 프로필 원 스택 — 정본 시안 + * [node 4327:72823](https://www.figma.com/design/UP9ZR186jHvRBicjA2SOea/?node-id=4327-72823) 기준 (#463). + * + * 실측(컨테이너 148×94, 좌표는 컨테이너 기준): + * + * | 원 | 지름 | left | top | 색 | 글리프 | + * |---|---|---|---|---|---| + * | 앞 | 96 | 0 | 3 | `#616161` (= `gray7`) | 흰 사람 | + * | 가운데 | 80 | 43 | 11 | `#BDBDBD` (= `gray4`) | 흰 사람 | + * | 뒤 | 80 | 68 | 11 | `#E0E0E0` (= `gray3`) | 없음 | + * + * 세 원의 중심 y 가 51.5 로 같아 **수직 중앙 정렬**이고, 원 사이 흰 테두리 링은 **없다** — 시안 렌더를 + * 픽셀로 훑어 확인했다(중심 행에서 `#616161` → `#BDBDBD` → `#E0E0E0` 가 경계 픽셀 없이 바로 바뀐다). + * 앞 원 오른쪽으로 드러나는 폭은 가운데 27, 뒤 25 다. + * + * 뒤 두 원은 **장식**이다. 서버 계약 + * [com.afternote.feature.afternote.domain.model.author.playlist.MemorialMedia] 의 사진은 `photoUrl` + * 한 장뿐이라 실데이터가 붙는 원은 맨 앞 하나이고, 나머지 둘은 시안이 그린 깊이 표현이다. 장식이므로 + * 접근성 시맨틱을 주지 않는다 — 스택 전체의 의미는 앞 원([ProfileImage])이 읽어 준다. + * + * 색은 디자인 토큰으로 그린다. 앞 원만 core:ui 의 고정 자산(`core_ui_ic_profile_placeholder`, `#616161` + * 구움)이라 다크 테마에서 토큰과 함께 뒤집히지 않는데, 그 자산은 core:ui 소관이라 이 PR 에서 손대지 + * 않는다. + */ +@Composable +internal fun MemorialProfileStack( + modifier: Modifier = Modifier, + profileImageUri: String? = null, +) { + Box(modifier = modifier.size(width = StackWidth, height = FrontCircleSize)) { + // 뒤 → 앞 순서로 그린다. 시안 z 순서는 왼쪽(앞) → 오른쪽(뒤)이다. + EchoCircle( + color = AfternoteDesign.colors.gray3, + modifier = + Modifier + .align(Alignment.CenterStart) + .offset(x = BackCircleOffset), + ) + EchoCircle( + color = AfternoteDesign.colors.gray4, + showGlyph = true, + modifier = + Modifier + .align(Alignment.CenterStart) + .offset(x = MiddleCircleOffset), + ) + ProfileImage( + modifier = Modifier.align(Alignment.CenterStart), + displayImageUri = profileImageUri, + size = FrontCircleSize, + ) + } +} + +@Composable +private fun EchoCircle( + color: Color, + modifier: Modifier = Modifier, + showGlyph: Boolean = false, +) { + Box( + modifier = + modifier + .size(EchoCircleSize) + .clip(CircleShape) + .background(color), + ) { + if (showGlyph) { + Image( + painter = painterResource(R.drawable.afternote_img_memorial_profile_glyph), + // 장식 원이라 읽어 줄 내용이 없다. + contentDescription = null, + modifier = Modifier.fillMaxSize(), + ) + } + } +} diff --git a/feature/afternote/presentation/src/main/res/drawable/afternote_img_memorial_profile_glyph.xml b/feature/afternote/presentation/src/main/res/drawable/afternote_img_memorial_profile_glyph.xml new file mode 100644 index 0000000000..6bd259292d --- /dev/null +++ b/feature/afternote/presentation/src/main/res/drawable/afternote_img_memorial_profile_glyph.xml @@ -0,0 +1,25 @@ + + + + + diff --git a/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/detail/MemorialDetailScreenScreenshotTestKt/memorialDetailScreenScreenshot_748aa731_0.png b/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/detail/MemorialDetailScreenScreenshotTestKt/memorialDetailScreenScreenshot_748aa731_0.png index b0551e44c1..e84703c5f8 100644 Binary files a/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/detail/MemorialDetailScreenScreenshotTestKt/memorialDetailScreenScreenshot_748aa731_0.png and b/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/detail/MemorialDetailScreenScreenshotTestKt/memorialDetailScreenScreenshot_748aa731_0.png differ diff --git a/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/detail/MemorialDetailScreenScreenshotTestKt/memorialDetailScreenWithVideoScreenshot_748aa731_0.png b/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/detail/MemorialDetailScreenScreenshotTestKt/memorialDetailScreenWithVideoScreenshot_748aa731_0.png index 5ff9e23172..be9a6f6ea0 100644 Binary files a/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/detail/MemorialDetailScreenScreenshotTestKt/memorialDetailScreenWithVideoScreenshot_748aa731_0.png and b/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/detail/MemorialDetailScreenScreenshotTestKt/memorialDetailScreenWithVideoScreenshot_748aa731_0.png differ diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/detail/MemorialDetailHeaderTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/detail/MemorialDetailHeaderTest.kt new file mode 100644 index 0000000000..d4495f8da7 --- /dev/null +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/detail/MemorialDetailHeaderTest.kt @@ -0,0 +1,109 @@ +package com.afternote.feature.afternote.presentation.detail + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.getUnclippedBoundsInRoot +import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule +import androidx.compose.ui.test.onAllNodesWithContentDescription +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.unit.dp +import com.afternote.core.ui.theme.AfternoteTheme +import com.afternote.feature.afternote.presentation.R +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import com.afternote.core.ui.R as CoreUiR + +/** + * 추억 노트 상세 헤더 블록을 정본 시안 + * [node 4327:72819](https://www.figma.com/design/UP9ZR186jHvRBicjA2SOea/?node-id=4327-72819) 에 고정한다 (#463 5번). + * + * 종전 코드는 타이틀 아래에 «회색 카드 + 중앙 정렬 프로필» 을 따로 쌓았는데, 그 카드는 시안에 없다. + * 시안은 타이틀 오른쪽에 겹친 원 스택을 붙이고 「최종 작성일」을 타이틀 바로 아래로 올린다. + * 되돌아가면 프로필이 다시 타이틀 «아래» 로 내려가므로, 여기서 «같은 행» 을 잠근다. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35], qualifiers = "w480dp-h2000dp") +class MemorialDetailHeaderTest { + @get:Rule + val composeRule = createAndroidComposeRule() + + @Test + fun `프로필 원 스택을 타이틀 오른쪽 같은 행에 놓는다`() { + setContent(finalWriteDate = "2025.11.26") + + val title = composeRule.onNodeWithText(TITLE).getUnclippedBoundsInRoot() + val profile = composeRule.onNodeWithContentDescription(profileDescription()).getUnclippedBoundsInRoot() + + assertTrue( + "프로필은 타이틀 오른쪽에 있어야 한다 (타이틀 right ${title.right}, 프로필 left ${profile.left})", + profile.left >= title.right, + ) + assertTrue( + "프로필은 타이틀과 같은 행이어야 한다 (타이틀 ${title.top}~${title.bottom}, 프로필 ${profile.top}~${profile.bottom})", + profile.top < title.bottom, + ) + } + + @Test + fun `최종 작성일을 타이틀 바로 아래 헤더 안에 그린다`() { + setContent(finalWriteDate = "2025.11.26") + + val title = composeRule.onNodeWithText(TITLE).getUnclippedBoundsInRoot() + val writtenDate = composeRule.activity.getString(R.string.afternote_last_written_date, "2025.11.26") + val date = composeRule.onNodeWithText(writtenDate).getUnclippedBoundsInRoot() + + assertTrue( + "최종 작성일은 타이틀과 같은 열에서 시작해야 한다 — 카드 안으로 다시 들어가면 왼쪽이 어긋난다 " + + "(타이틀 left ${title.left}, 날짜 left ${date.left})", + date.left == title.left, + ) + // 시안 실측 간격 4. 종전 카드 배치에서는 24(Spacer) + 16(카드 패딩) 이 끼어 있었다. + assertEquals(4.dp, date.top - title.bottom) + } + + @Test + fun `최종 작성일 값이 없으면 그 줄을 그리지 않는다`() { + setContent(finalWriteDate = "") + + val prefixOnly = composeRule.activity.getString(R.string.afternote_last_written_date, "") + + composeRule.onNodeWithText(prefixOnly).assertDoesNotExist() + composeRule.onNodeWithText(TITLE).assertExists() + } + + @Test + fun `겹친 장식 원은 접근성 트리에 노출하지 않는다`() { + setContent(finalWriteDate = "2025.11.26") + + // 시안의 원은 셋이지만 실데이터가 붙는 것은 맨 앞 하나뿐이다. 뒤 둘은 장식이라 + // contentDescription 이 없어야 한다 — 있으면 스크린리더가 "프로필 이미지" 를 세 번 읽는다. + composeRule.onAllNodesWithContentDescription(profileDescription()).assertCountEquals(1) + } + + private fun setContent(finalWriteDate: String) { + composeRule.setContent { + AfternoteTheme { + MemorialDetailScreen( + onBackClick = {}, + onEditClick = {}, + onDeleteConfirm = {}, + onVideoClick = {}, + content = MemorialDetailContent(finalWriteDate = finalWriteDate), + userName = "서영", + ) + } + } + } + + private fun profileDescription(): String = composeRule.activity.getString(CoreUiR.string.core_ui_content_description_profile_image) + + private companion object { + const val TITLE = "추억 노트에 대한\n서영님의 기록" + } +}