diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/shared/detail/MemorialVideoThumbnail.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/shared/detail/MemorialVideoThumbnail.kt index 46a908e4ca..66a6ba8fc3 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/shared/detail/MemorialVideoThumbnail.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/shared/detail/MemorialVideoThumbnail.kt @@ -3,9 +3,9 @@ package com.afternote.feature.afternote.presentation.shared.detail import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape @@ -27,6 +27,34 @@ import coil3.compose.AsyncImage import com.afternote.core.ui.theme.AfternoteDesign import com.afternote.feature.afternote.presentation.R +/** + * 추모 영상 상자의 시안 종횡비 350:183 (≈ 1.91:1) — 높이 기준의 정본이다 (#1780). + * + * **고정 높이 183dp 를 쓰지 않는 이유.** 시안이 담고 있는 정보는 「높이 183」이 아니라 「폭 350 에 + * 대한 183」이라는 **관계**다. 이 상자는 폭이 유연해서(`fillMaxWidth`) 실제 렌더 폭은 화면 폭 − 72 + * (화면 여백 40 + [InfoCard] 패딩 32)이고, 390dp 기기에서 318dp 라 이미 350 이 아니다. 폭이 350 이 + * 아닌 순간 절대값 183 은 근거를 잃고 종횡비만 흔들린다 — 320dp 기기에서 1.36:1 로, 시안 대비 −29%. + * + * 내용물이 [ContentScale.Crop] 이라 이 흔들림은 상자 모양에서 끝나지 않는다. 폭이 좁아질수록 가로가 + * 더 잘려 나가 **기기마다 다른 그림**이 보인다. 이미지 컨테이너에서는 상자 높이가 일정한 것보다 + * 어느 기기에서든 같은 화면이 보이는 쪽이 중요하다. 폭이 350 이면 두 방식 모두 183 으로 수렴하므로 + * 시안 재현도 잃지 않는다 — 갈리는 건 폭이 350 이 아닐 때 무엇을 지키느냐뿐이다. + * + * **#1735(PR #1610)가 고정 183 을 택하며 든 근거 하나는 성립하지 않는다.** 「비율로 바꿔도 카드 + * 패딩 32 때문에 시안이 재현되지 않는다」는 근거인데, 패딩 32 는 고정 높이 쪽에서도 똑같이 빠지므로 + * 두 방식을 가르지 못한다. 게다가 폭이 유연한 컴포넌트에서 「시안 재현」은 절대 픽셀이 아니라 비율 + * 보존을 뜻한다 — 패딩이 폭을 줄이는 만큼 어긋나는 쪽은 오히려 고정 높이다. + * + * **넓은 화면(600dp+) 상한은 두지 않는다.** 앱은 `screenOrientation="portrait"` 고정이고, 폴더블 + * 펼침·태블릿에서 폭이 벌어질 때 벌어지는 건 이 상자만이 아니라 화면 여백·카드·본문 전체다. 이 + * 상자에만 상한을 걸면 시안에 없는 숫자를 하나 더 만들면서 주변과 어긋난다. 상한이 필요해지는 + * 시점은 화면 전체의 최대 폭을 정할 때다. + */ +private const val MEMORIAL_VIDEO_ASPECT_RATIO = 350f / 183f + +/** 추모 영상 상자의 모서리 반경 — 시안 [node 4327:72864](https://www.figma.com/design/UP9ZR186jHvRBicjA2SOea/?node-id=4327-72864) 실측 6dp. */ +private val MemorialVideoCornerRadius = 6.dp + /** * 추모 영상 썸네일 — 썸네일 이미지 + 어두운 그라디언트 + 중앙 재생 버튼 + 좌하단 오버레이 라벨. * @@ -36,7 +64,7 @@ import com.afternote.feature.afternote.presentation.R * * 치수·색은 정본 시안 「NEW 추억 노트」 섹션의 애프터노트 상세페이지 * [node 4327:72864](https://www.figma.com/design/UP9ZR186jHvRBicjA2SOea/?node-id=4327-72864) 실측이다: - * - 카드 350×183, corner radius 6 + * - 카드 350×183, corner radius 6 — 높이는 고정 183dp 가 아니라 [MEMORIAL_VIDEO_ASPECT_RATIO] 로 둔다 (#1780) * - 이미지 fill 위 세로 그라디언트 `#000` 40% → `#000` 50% * (Figma `gradientTransform [[0,1,0],[-0.5,0,0.75]]` → 위→아래 방향) * - 중앙 재생 버튼 48dp 원, 배경 `#000` 60%(= `iconBk` 토큰), 흰 재생 삼각형 20dp @@ -55,8 +83,8 @@ fun MemorialVideoThumbnail( modifier = modifier .fillMaxWidth() - .height(183.dp) - .clip(RoundedCornerShape(6.dp)), + .aspectRatio(MEMORIAL_VIDEO_ASPECT_RATIO) + .clip(RoundedCornerShape(MemorialVideoCornerRadius)), ) { if (!thumbnailUrl.isNullOrBlank()) { AsyncImage( 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 0c3dc1faaf..68640c19ac 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/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/receiver/detail/MemorialReceivedDetailScreenScreenshotTestKt/memorialReceivedDetailScreenWithVideoScreenshot_748aa731_0.png b/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/receiver/detail/MemorialReceivedDetailScreenScreenshotTestKt/memorialReceivedDetailScreenWithVideoScreenshot_748aa731_0.png index b0ae4531f8..2ada176a12 100644 Binary files a/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/receiver/detail/MemorialReceivedDetailScreenScreenshotTestKt/memorialReceivedDetailScreenWithVideoScreenshot_748aa731_0.png and b/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/receiver/detail/MemorialReceivedDetailScreenScreenshotTestKt/memorialReceivedDetailScreenWithVideoScreenshot_748aa731_0.png differ diff --git a/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/shared/detail/MemorialVideoThumbnailScreenshotTestKt/memorialVideoThumbnailDarkScreenshot_76fd4e5f_0.png b/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/shared/detail/MemorialVideoThumbnailScreenshotTestKt/memorialVideoThumbnailDarkScreenshot_76fd4e5f_0.png index 71228400fe..b355422ca7 100644 Binary files a/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/shared/detail/MemorialVideoThumbnailScreenshotTestKt/memorialVideoThumbnailDarkScreenshot_76fd4e5f_0.png and b/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/shared/detail/MemorialVideoThumbnailScreenshotTestKt/memorialVideoThumbnailDarkScreenshot_76fd4e5f_0.png differ diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/shared/detail/MemorialVideoThumbnailAspectRatioTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/shared/detail/MemorialVideoThumbnailAspectRatioTest.kt new file mode 100644 index 0000000000..c1b0d53f70 --- /dev/null +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/shared/detail/MemorialVideoThumbnailAspectRatioTest.kt @@ -0,0 +1,76 @@ +package com.afternote.feature.afternote.presentation.shared.detail + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.width +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.getUnclippedBoundsInRoot +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.unit.dp +import com.afternote.core.ui.theme.AfternoteTheme +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * [MemorialVideoThumbnail] 이 폭에 관계없이 시안 종횡비를 지키는지 고정한다 (#1780). + * + * 종전 코드는 `fillMaxWidth().height(183.dp)` 라 폭만 유연했고, 화면 폭에 따라 종횡비가 1.36:1까지 + * 흔들렸다. 내용물이 `ContentScale.Crop` 이라 모양이 흔들리면 잘려 나가는 그림도 기기마다 달라진다. + * + * 실기기 폭(화면 폭 − 72)이 350dp 를 넘지 않으므로 좁은 폭 쪽이 회귀의 실제 무대다. 고정 높이로 + * 되돌아가면 두 번째 테스트가 깨진다 — 240dp 폭에서 높이가 183dp 로 남기 때문이다. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35], qualifiers = "w480dp-h2000dp") +class MemorialVideoThumbnailAspectRatioTest { + @get:Rule + val composeRule = createComposeRule() + + @Test + fun `폭이 시안과 같은 350dp 면 시안 높이 183dp 로 수렴한다`() { + val (width, height) = boundsAtWidth(350) + + assertEquals(350f, width, WIDTH_TOLERANCE_DP) + assertEquals(183f, height, HEIGHT_TOLERANCE_DP) + } + + @Test + fun `폭이 좁아져도 시안 종횡비를 유지한다`() { + val (width, height) = boundsAtWidth(240) + + assertEquals(240f, width, WIDTH_TOLERANCE_DP) + // 프로덕션 상수를 참조하지 않고 시안 수치를 다시 적는다 — 상수가 잘못 바뀌어도 이 단언은 깨진다. + assertEquals(350f / 183f, width / height, RATIO_TOLERANCE) + } + + /** 폭 [widthDp] 인 부모 안에 썸네일을 그리고 실제 레이아웃 크기를 dp 로 돌려준다. */ + private fun boundsAtWidth(widthDp: Int): Pair { + composeRule.setContent { + AfternoteTheme { + Box(modifier = Modifier.width(widthDp.dp)) { + MemorialVideoThumbnail( + thumbnailUrl = null, + modifier = Modifier.testTag(THUMBNAIL_TAG), + ) + } + } + } + + val bounds = composeRule.onNodeWithTag(THUMBNAIL_TAG, useUnmergedTree = true).getUnclippedBoundsInRoot() + return (bounds.right - bounds.left).value to (bounds.bottom - bounds.top).value + } + + private companion object { + const val THUMBNAIL_TAG = "memorialVideoThumbnailUnderTest" + + /** 레이아웃은 픽셀 정수로 반올림되므로 dp 단언에는 1px 미만의 여유가 필요하다. */ + const val WIDTH_TOLERANCE_DP = 0.5f + const val HEIGHT_TOLERANCE_DP = 0.5f + const val RATIO_TOLERANCE = 0.01f + } +}