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서영님의 기록"
+ }
+}