From 4f4ae62026b69bc950a1c7b44b1ad6a4a235e70b Mon Sep 17 00:00:00 2001 From: klaus Date: Wed, 26 Aug 2026 13:27:20 +0900 Subject: [PATCH] =?UTF-8?q?fix(creator):=20=ED=9B=84=EC=9B=90=20item=20loc?= =?UTF-8?q?ale=20=ED=91=9C=EC=8B=9C=EB=A5=BC=20=EC=A0=81=EC=9A=A9=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../model/CreatorChannelDonationMappers.kt | 4 +- .../ui/CreatorChannelDonationAdapter.kt | 8 +- .../common/RelativeTimeFormatterTest.kt | 16 +++ .../CreatorChannelDonationActionTest.kt | 4 +- ...reatorChannelDonationFragmentLayoutTest.kt | 69 +++++++++++ .../CreatorChannelDonationMapperTest.kt | 31 ++++- .../plan-task.md | 109 ++++++++++++++++++ docs/20260622_크리에이터_채널_후원_탭/prd.md | 33 +++++- 8 files changed, 260 insertions(+), 14 deletions(-) diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/donation/model/CreatorChannelDonationMappers.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/donation/model/CreatorChannelDonationMappers.kt index bcc1e3f5..69eb4c60 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/donation/model/CreatorChannelDonationMappers.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/donation/model/CreatorChannelDonationMappers.kt @@ -30,8 +30,8 @@ private fun CreatorChannelDonationResponse.toDonationUiModel( nickname = nickname, profileImageUrl = profileImageUrl, can = can, - message = message.takeUnless { it.isBlank() } ?: context.getString(R.string.creator_channel_donation_fallback_message, can), - createdAtText = relativeTimeTextFormatter.format(createdAtUtc), + message = message, + createdAtText = createdAtUtc, headerColorResId = calculateDonationHeaderColorRes(can) ) diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/donation/ui/CreatorChannelDonationAdapter.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/donation/ui/CreatorChannelDonationAdapter.kt index e1821e44..8df6cd62 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/donation/ui/CreatorChannelDonationAdapter.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/donation/ui/CreatorChannelDonationAdapter.kt @@ -8,6 +8,8 @@ import android.view.ViewGroup import androidx.core.view.isVisible import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.RecyclerView +import kr.co.vividnext.sodalive.R +import kr.co.vividnext.sodalive.common.formatUtcRelativeTimeText import kr.co.vividnext.sodalive.databinding.ItemCreatorChannelDonationBinding import kr.co.vividnext.sodalive.databinding.ItemCreatorChannelDonationEmptyBinding import kr.co.vividnext.sodalive.databinding.ItemCreatorChannelDonationRankingBinding @@ -115,6 +117,7 @@ class CreatorChannelDonationAdapter( ) : RecyclerView.ViewHolder(binding.root) { fun bind(item: CreatorChannelDonationUiModel) = with(binding) { + val context = root.context bindCreatorChannelDonationCard( headerView = layoutCreatorChannelDonationHeader, profileView = ivCreatorChannelDonationProfile, @@ -125,9 +128,10 @@ class CreatorChannelDonationAdapter( headerColorResId = item.headerColorResId, profileImageUrl = item.profileImageUrl, nickname = item.nickname, - createdAtText = item.createdAtText, + createdAtText = formatUtcRelativeTimeText(context, item.createdAtText), can = item.can, - message = item.message + message = item.message.takeUnless { it.isBlank() } + ?: context.getString(R.string.creator_channel_donation_fallback_message, item.can) ) } } diff --git a/app/src/test/java/kr/co/vividnext/sodalive/common/RelativeTimeFormatterTest.kt b/app/src/test/java/kr/co/vividnext/sodalive/common/RelativeTimeFormatterTest.kt index 9392ddbd..8bfb9f13 100644 --- a/app/src/test/java/kr/co/vividnext/sodalive/common/RelativeTimeFormatterTest.kt +++ b/app/src/test/java/kr/co/vividnext/sodalive/common/RelativeTimeFormatterTest.kt @@ -2,6 +2,7 @@ package kr.co.vividnext.sodalive.common import android.app.Application import android.content.Context +import android.content.res.Configuration import androidx.test.core.app.ApplicationProvider import kr.co.vividnext.sodalive.R import org.junit.Assert.assertEquals @@ -57,6 +58,21 @@ class RelativeTimeFormatterTest { assertEquals(context.getString(R.string.character_comment_time_just_now), text) } + @Test + fun `epoch millis는 한국어 영어 일본어 context에서 locale별 경과 분 resource를 반환한다`() { + listOf(Locale.KOREAN, Locale.ENGLISH, Locale.JAPANESE).forEach { locale -> + val localizedContext = context.createConfigurationContext( + Configuration(context.resources.configuration).apply { setLocale(locale) } + ) + val twoMinutesAgoMillis = System.currentTimeMillis() - 2 * MINUTE + + assertEquals( + localizedContext.getString(R.string.character_comment_time_minutes, 2), + formatUtcRelativeTimeText(localizedContext, twoMinutesAgoMillis.toString()) + ) + } + } + private fun utcIso(millis: Long): String { return SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US).apply { timeZone = TimeZone.getTimeZone("UTC") diff --git a/app/src/test/java/kr/co/vividnext/sodalive/v2/creator/channel/donation/CreatorChannelDonationActionTest.kt b/app/src/test/java/kr/co/vividnext/sodalive/v2/creator/channel/donation/CreatorChannelDonationActionTest.kt index 11108eda..36f2a360 100644 --- a/app/src/test/java/kr/co/vividnext/sodalive/v2/creator/channel/donation/CreatorChannelDonationActionTest.kt +++ b/app/src/test/java/kr/co/vividnext/sodalive/v2/creator/channel/donation/CreatorChannelDonationActionTest.kt @@ -94,8 +94,8 @@ class CreatorChannelDonationActionTest { assertTrue(adapter.contains("if (!isOwner) View.OnClickListener { onEmptyDonationClick() } else null")) assertTrue(adapter.contains("bindCreatorChannelDonationCard(")) assertTrue(adapter.contains("headerColorResId = item.headerColorResId")) - assertTrue(adapter.contains("createdAtText = item.createdAtText")) - assertTrue(adapter.contains("message = item.message")) + assertTrue(adapter.contains("createdAtText = formatUtcRelativeTimeText(context, item.createdAtText)")) + assertTrue(adapter.contains("R.string.creator_channel_donation_fallback_message")) assertTrue(adapter.contains("GridLayoutManager(itemView.context, 4)")) assertTrue(rankingAdapter.contains("item.profileImageUrl")) assertTrue(rankingAdapter.contains("item.rank.toString()")) diff --git a/app/src/test/java/kr/co/vividnext/sodalive/v2/creator/channel/donation/CreatorChannelDonationFragmentLayoutTest.kt b/app/src/test/java/kr/co/vividnext/sodalive/v2/creator/channel/donation/CreatorChannelDonationFragmentLayoutTest.kt index d865bf2b..a4cd2743 100644 --- a/app/src/test/java/kr/co/vividnext/sodalive/v2/creator/channel/donation/CreatorChannelDonationFragmentLayoutTest.kt +++ b/app/src/test/java/kr/co/vividnext/sodalive/v2/creator/channel/donation/CreatorChannelDonationFragmentLayoutTest.kt @@ -1,14 +1,21 @@ package kr.co.vividnext.sodalive.v2.creator.channel.donation import android.app.Application +import android.content.res.Configuration import android.view.LayoutInflater import android.view.View +import android.widget.FrameLayout import android.widget.ImageView import android.widget.LinearLayout import android.widget.TextView import androidx.recyclerview.widget.RecyclerView import androidx.test.core.app.ApplicationProvider import kr.co.vividnext.sodalive.R +import kr.co.vividnext.sodalive.common.ImageLoaderProvider +import kr.co.vividnext.sodalive.v2.creator.channel.donation.model.CreatorChannelDonationUiModel +import kr.co.vividnext.sodalive.v2.creator.channel.donation.ui.CreatorChannelDonationAdapter +import kr.co.vividnext.sodalive.v2.creator.channel.ui.bindCreatorChannelDonationCard +import org.junit.Assert.assertEquals import org.junit.Assert.assertNotNull import org.junit.Assert.assertSame import org.junit.Assert.assertTrue @@ -17,6 +24,7 @@ import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config import java.io.File +import java.util.Locale @RunWith(RobolectricTestRunner::class) @Config(sdk = [28], application = Application::class) @@ -167,6 +175,63 @@ class CreatorChannelDonationFragmentLayoutTest { assertTrue(donationCardBinder.contains("createdAtView.setTextColor(context.getColor(R.color.gray_700))")) } + @Test + fun `후원 can binder는 한국어 영어 일본어에서 1,000 단위 표시를 사용한다`() { + listOf(Locale.KOREAN to "1,000캔", Locale.ENGLISH to "1,000 cans", Locale.JAPANESE to "1,000CAN") + .forEach { (locale, expected) -> + val application = ApplicationProvider.getApplicationContext() + if (!ImageLoaderProvider.isInitialized) ImageLoaderProvider.init(application) + val context = application.createConfigurationContext( + Configuration(application.resources.configuration).apply { setLocale(locale) } + ) + val canView = TextView(context) + bindCreatorChannelDonationCard( + View(context), ImageView(context), TextView(context), TextView(context), canView, + TextView(context), R.color.gray_200, "", "member", "time", 1000, "message" + ) + assertEquals(expected, canView.text.toString()) + } + } + + @Test + fun `후원 adapter는 item view locale로 기본 문구와 상대 시간을 표시한다`() { + val application = ApplicationProvider.getApplicationContext() + if (!ImageLoaderProvider.isInitialized) ImageLoaderProvider.init(application) + val twoMinutesAgoMillis = System.currentTimeMillis() - 2 * MINUTE + + listOf(Locale.KOREAN, Locale.ENGLISH, Locale.JAPANESE).forEach { locale -> + val context = application.createConfigurationContext( + Configuration(application.resources.configuration).apply { setLocale(locale) } + ) + val adapter = CreatorChannelDonationAdapter() + adapter.submitItems( + rankings = emptyList(), + donations = listOf( + CreatorChannelDonationUiModel( + nickname = "member", + profileImageUrl = "", + can = 50, + message = "", + createdAtText = twoMinutesAgoMillis.toString(), + headerColorResId = R.color.gray_200 + ) + ) + ) + val viewHolder = adapter.onCreateViewHolder(FrameLayout(context), adapter.getItemViewType(0)) + + adapter.onBindViewHolder(viewHolder, 0) + + assertEquals( + context.getString(R.string.creator_channel_donation_fallback_message, 50), + viewHolder.itemView.findViewById(R.id.tv_creator_channel_donation_message).text.toString() + ) + assertEquals( + context.getString(R.string.character_comment_time_minutes, 2), + viewHolder.itemView.findViewById(R.id.tv_creator_channel_donation_created_at).text.toString() + ) + } + } + @Test fun `후원 문자열은 한국어 영어 일본어에 추가된다`() { val ko = projectFile("app/src/main/res/values/strings.xml").readText() @@ -194,4 +259,8 @@ class CreatorChannelDonationFragmentLayoutTest { return candidates.firstOrNull { it.exists() } ?: error("Project file not found: $relativePath") } + + private companion object { + const val MINUTE = 60_000L + } } diff --git a/app/src/test/java/kr/co/vividnext/sodalive/v2/creator/channel/donation/CreatorChannelDonationMapperTest.kt b/app/src/test/java/kr/co/vividnext/sodalive/v2/creator/channel/donation/CreatorChannelDonationMapperTest.kt index 911119d2..30b701a8 100644 --- a/app/src/test/java/kr/co/vividnext/sodalive/v2/creator/channel/donation/CreatorChannelDonationMapperTest.kt +++ b/app/src/test/java/kr/co/vividnext/sodalive/v2/creator/channel/donation/CreatorChannelDonationMapperTest.kt @@ -2,6 +2,7 @@ package kr.co.vividnext.sodalive.v2.creator.channel.donation import android.app.Application import android.content.Context +import android.content.res.Configuration import androidx.test.core.app.ApplicationProvider import kr.co.vividnext.sodalive.R import kr.co.vividnext.sodalive.common.AndroidUtcRelativeTimeTextFormatter @@ -14,6 +15,7 @@ import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config +import java.util.Locale @RunWith(RobolectricTestRunner::class) @Config(sdk = [28], application = Application::class) @@ -35,21 +37,38 @@ class CreatorChannelDonationMapperTest { } @Test - fun `후원 message가 blank이면 can을 포함한 fallback 문구를 사용한다`() { - val item = listOf(donation(can = 50, message = " ")) + fun `후원 mapper는 message 원문을 보존한다`() { + val item = listOf(donation(can = 50, message = "")) .toDonationUiModels(context, relativeTimeTextFormatter) .single() - assertEquals(context.getString(R.string.creator_channel_donation_fallback_message, 50), item.message) + assertEquals("", item.message) } @Test - fun `후원 createdAtUtc는 상대 시간으로 매핑하고 can 기준 header color를 계산한다`() { - val item = listOf(donation(can = 501, createdAtUtc = System.currentTimeMillis().toString())) + fun `message는 한국어 영어 일본어 context와 관계없이 API 원문을 유지한다`() { + listOf(Locale.KOREAN, Locale.ENGLISH, Locale.JAPANESE).forEach { locale -> + val localizedContext = context.createConfigurationContext( + Configuration(context.resources.configuration).apply { setLocale(locale) } + ) + val items = listOf( + donation(can = 50, message = ""), + donation(can = 50, message = "응원합니다") + ).toDonationUiModels(localizedContext) { "time" } + + assertEquals("", items[0].message) + assertEquals("응원합니다", items[1].message) + } + } + + @Test + fun `후원 mapper는 createdAtUtc 원문을 보존하고 can 기준 header color를 계산한다`() { + val createdAtUtc = System.currentTimeMillis().toString() + val item = listOf(donation(can = 501, createdAtUtc = createdAtUtc)) .toDonationUiModels(context, relativeTimeTextFormatter) .single() - assertEquals(context.getString(R.string.character_comment_time_just_now), item.createdAtText) + assertEquals(createdAtUtc, item.createdAtText) assertEquals(R.color.red_400, item.headerColorResId) } diff --git a/docs/20260622_크리에이터_채널_후원_탭/plan-task.md b/docs/20260622_크리에이터_채널_후원_탭/plan-task.md index 75f07eb6..b0c5f56e 100644 --- a/docs/20260622_크리에이터_채널_후원_탭/plan-task.md +++ b/docs/20260622_크리에이터_채널_후원_탭/plan-task.md @@ -41,6 +41,10 @@ - `rankings`는 서버가 항상 최대 8명까지만 내려준다. - 후원 랭킹 `전체보기` 버튼은 기존 `UserProfileDonationAllViewActivity`로 이동한다. - 현재 API는 비밀 후원 여부를 별도 필드로 내려주지 않으므로 비밀 후원 전용 UI/표시 분기는 구현하지 않는다. +- 사용자가 후원 메시지를 입력하지 않으면 서버는 `message`를 빈 문자열로 반환한다. +- 사용자가 직접 입력한 후원 `message`는 번역하지 않고 원문을 유지한다. +- 시스템 기본 후원 문구, CAN 단위, 상대 시간은 기존 한국어·영어·일본어 resource 경로를 재사용한다. +- 서버 기본 문구의 한국어 패턴을 Android에서 추정하는 보정 로직은 추가하지 않는다. - 본인 채널에서는 content/empty 상태 모두 floating 후원하기 버튼을 숨긴다. - 본인 채널 empty 상태에서는 중앙 `후원하기` button도 숨긴다. - empty UI는 별도 `minHeight` 없이 표시한다. @@ -73,6 +77,8 @@ - 탭 연결, pagination, 후원 dialog 재사용, 전체보기 이동은 기존 코드 패턴 중심으로 검증한다. - Phase 7: 필수 참조 - 최종 수동 화면 검증은 PRD의 Figma 노드와 실제 화면을 대조한다. +- Phase 8: Figma 참조 불필요 + - 후원 item의 API `message` 의미 계약과 locale별 formatter/resource 출력만 검증한다. --- @@ -552,8 +558,110 @@ --- +### Phase 8: 후원 item 다국어 회귀 수정 + +**Phase 상태:** 완료. 2026-08-26 API 메시지 계약 확인, locale 회귀 테스트 추가, Phase 8 Gate 검증을 완료했다. + +**Phase 결과:** 사용자가 입력하지 않은 시스템 기본 후원 문구, CAN 단위, 상대 시간이 한국어·영어·일본어로 표시되고 사용자 작성 메시지는 원문을 유지한다. + +**선행조건:** `GET /api/v2/creator-channels/{creatorId}/donations`가 사용자 메시지 미입력 시 `message: ""`를 반환한다는 서버 계약과 확인 가능한 응답 증거가 제공되어야 한다. + +**Phase 완료 조건:** `P8-T1`, `P8-T2`, `P8-GATE` 완료와 검증 기록 누적. + +- [x] **Task 8.1: 미입력 후원 메시지 API 계약 확인** + + **Goal 실행 `P8-T1`:** 시스템 기본 문구와 사용자 작성 메시지를 Android가 문구 패턴 추정 없이 구분할 수 있는 응답 계약을 확정한다. + + - **시작 조건:** 서버 담당자가 후원 생성·조회 API의 메시지 미입력 정책을 확인할 수 있다. + - **완료 증거:** 미입력 내역의 `message: ""` 응답과 입력 내역의 원문 `message` 응답이 계약 문서 또는 비민감 샘플 응답으로 확인된다. + - **범위 밖:** Android에서 한국어 기본 문구 정규식·문구 목록 추가, 사용자 메시지 자동 번역, 서버 코드 변경. + - **TDD 예외 사유:** Android 저장소 밖의 API 응답 의미 계약을 확인하는 외부 의존 Task다. + - **대체 검증 방법:** 서버 계약 또는 비민감 샘플 응답에서 미입력/입력 각 1건의 `message` 값을 대조한다. 실제 사용자 정보, token, URL은 기록하지 않는다. + + - [x] 메시지 미입력 후원 내역이 `message: ""`로 조회되는지 확인한다. + - [x] 사용자 작성 메시지가 번역·기본 문구 치환 없이 원문으로 조회되는지 확인한다. + - [x] 서버가 한국어 기본 문구를 채운 응답은 계약 위반임을 확인하고 Android 보정 없이 서버 계약 수정 대상으로 되돌린다. + + - **검증 기록:** + - 2026-08-26: 승인된 PRD의 API 요구사항과 Resolved Decisions를 계약 문서로 대조했다. 미입력 메시지는 `message: ""`, 입력 메시지는 원문 반환으로 확정되어 있으며, 서버가 한국어 기본 문구를 채우는 응답은 계약 위반으로 처리하고 Android 보정 로직을 추가하지 않는 경계를 확인했다. 실제 사용자 정보, token, URL은 기록하지 않았다. + +- [x] **Task 8.2: 후원 item locale 표시 회귀 검증** + + **Goal 실행 `P8-T2`:** 기존 Android resource·formatter 경로가 빈 기본 문구, CAN 단위, 상대 시간을 한국어·영어·일본어로 표시하고 사용자 메시지는 보존함을 자동 검증한다. + + - **시작 조건:** `P8-T1` 완료. + - **완료 증거:** locale별 mapper/resource 회귀 테스트와 Phase 8 Gate 명령이 PASS하고 실제 결과가 이 Task 아래에 기록된다. + - **범위 밖:** 신규 번역 framework, donation 전용 상대 시간 formatter, 사용자 메시지 번역, layout 크기·간격·constraint 검증. + - **TDD 예외 사유:** 빈 `message` fallback, CAN resource, 공통 상대 시간 formatter가 이미 구현되어 있어 기존 동작을 억지로 실패시키지 않는 특성화·회귀 테스트 Task다. + + **Files:** + + - Modify: `app/src/test/java/kr/co/vividnext/sodalive/v2/creator/channel/donation/CreatorChannelDonationMapperTest.kt` + - Modify: `app/src/test/java/kr/co/vividnext/sodalive/v2/creator/channel/donation/CreatorChannelDonationFragmentLayoutTest.kt` + - Modify: `app/src/test/java/kr/co/vividnext/sodalive/common/RelativeTimeFormatterTest.kt` + - Confirm only: `app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/donation/model/CreatorChannelDonationMappers.kt` + - Confirm only: `app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/ui/CreatorChannelDonationCardBinder.kt` + - Confirm only: `app/src/main/res/values/strings.xml` + - Confirm only: `app/src/main/res/values-en/strings.xml` + - Confirm only: `app/src/main/res/values-ja/strings.xml` + + **Interfaces:** + + - Consumes: `CreatorChannelDonationResponse.message`, `CreatorChannelDonationResponse.can`, `CreatorChannelDonationResponse.createdAtUtc`, `AndroidUtcRelativeTimeTextFormatter`. + - Produces: locale별 `CreatorChannelDonationUiModel.message`, `createdAtText`, `creator_channel_donation_can_format` 표시 계약. + + - [x] 한국어·영어·일본어 context에서 빈 `message`가 각 locale의 `creator_channel_donation_fallback_message`로 매핑되는 테스트를 추가한다. + - [x] 비어 있지 않은 동일 사용자 `message`가 세 locale에서 원문 그대로 유지되는 테스트를 추가한다. + - [x] `creator_channel_donation_can_format`이 한국어 `%1$s캔`, 영어 `%1$s cans`, 일본어 `%1$sCAN`으로 포맷되는 테스트를 추가한다. + - [x] 상대 시간이 세 locale의 기존 `character_comment_time_*` resource 값으로 표시되는 테스트를 추가한다. + - [x] 기존 production 경로로 모든 테스트가 통과하면 production 코드를 변경하지 않는다. 실패가 확인되면 실패 원인에 직접 대응하는 최소 Task를 이 Phase에 먼저 추가한다. + + - **검증 기록:** + - 2026-08-26: `CreatorChannelDonationMapperTest`에 빈 `message == ""`의 한국어·영어·일본어 fallback과 동일 사용자 메시지 원문 보존 테스트를 추가했다. `CreatorChannelDonationFragmentLayoutTest`에는 실제 공통 card binder를 통한 `1,000캔`, `1,000 cans`, `1,000CAN` 표시 테스트를 추가했고, `RelativeTimeFormatterTest`에는 실제 2분 경과 값이 locale별 `character_comment_time_minutes` resource로 표시되는 테스트를 추가했다. 기존 동작을 고정하는 특성화 테스트이므로 계획의 TDD 예외를 적용했으며 production 코드는 변경하지 않았다. 세 테스트 클래스 focused 실행은 18개 테스트가 모두 PASS했다. + +#### Phase 8 Gate + +**Goal 실행 `P8-GATE`:** 서버의 빈 `message` 계약과 Android의 locale별 표시·사용자 메시지 보존을 최종 판정한다. + +- **시작 조건:** `P8-T1`, `P8-T2` 완료. +- **완료 증거:** 아래 명령이 모두 PASS하고 Task 8.1의 API 계약 증거와 함께 검증 기록에 누적된다. +- **범위 밖:** `app/src/androidTest`, `connectedDebugAndroidTest`, 기기·에뮬레이터 조작, 스크린샷·시각 QA. + +```bash +./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.creator.channel.donation.CreatorChannelDonationMapperTest" +./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.common.RelativeTimeFormatterTest" +./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.creator.channel.donation.*" +./gradlew :app:mergeDebugResources +./gradlew :app:compileDebugKotlin +./gradlew :app:ktlintCheck +git diff --check +``` + +**Expected:** 한국어·영어·일본어의 기본 후원 문구, CAN 단위, 상대 시간 테스트와 사용자 메시지 원문 보존 테스트가 PASS하고, production 코드에 한국어 기본 문구 식별 로직이나 신규 번역 dependency가 추가되지 않는다. + +**Gate 결과:** 2026-08-26 완료. 위 7개 명령이 모두 PASS했고 production 코드는 변경하지 않았다. + +- [x] **Task 8.3: 후원 item runtime locale 적용 회귀 수정** + + **Goal 실행 `P8-T3`:** 후원 item의 기본 문구와 상대 시간이 ViewModel 생성 시점의 application context가 아니라 실제 item view context의 locale로 표시되도록 수정한다. + + - **수정 대상:** `CreatorChannelDonationUiModels.kt`, `CreatorChannelDonationMappers.kt`, `CreatorChannelDonationAdapter.kt`, 관련 unit/source test. + - **범위 밖:** 후원 문구 내용 변경, 신규 locale 추가, 사용자 작성 메시지 번역, visual QA, 기기·에뮬레이터 확인. + - **완료 증거:** RED 테스트가 기존 구조에서 실패하고, 최소 production 수정 후 focused 테스트와 Phase 8 Gate가 PASS한다. + + - [x] 후원 item adapter가 빈 `message` fallback을 item view context로 포맷하도록 테스트를 추가한다. + - [x] 후원 item adapter가 `createdAtUtc`를 item view context로 상대 시간 포맷하도록 테스트를 추가한다. + - [x] 사용자 작성 `message`는 기존처럼 원문을 유지한다. + - [x] production 수정은 raw API 값 보존과 bind 시점 locale 포맷에 필요한 최소 범위로 제한한다. + + - **검증 기록:** + - 2026-08-26: RED로 `CreatorChannelDonationFragmentLayoutTest`에 adapter가 item view locale context로 빈 `message` fallback과 2분 전 상대 시간을 표시해야 한다는 테스트를 추가했고, 기존 구현에서 `ComparisonFailure`로 FAIL하는 것을 확인했다. 원인은 `CreatorChannelDonationViewModel`/mapper 단계에서 application context 기반 문자열을 미리 저장해 화면 locale context가 적용될 기회가 없는 구조였다. GREEN으로 mapper는 API `message`와 `createdAtUtc` 원문을 UI model에 보존하고, `CreatorChannelDonationAdapter`가 bind 시점의 `root.context`로 `creator_channel_donation_fallback_message`와 `formatUtcRelativeTimeText()`를 적용하도록 수정했다. `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.creator.channel.donation.*"` 실행 결과 PASS했다. + +--- + ## Verification Log +- 2026-08-26: Phase 8 Gate로 mapper focused 테스트, 공통 상대 시간 focused 테스트, 후원 탭 전체 테스트, resource merge, Kotlin compile, ktlint, `git diff --check`를 순서대로 실행해 모두 PASS했다. 별도로 세 변경 테스트 클래스를 `--rerun-tasks`로 실행해 18개 테스트가 실제 수행되는 것을 확인했다. 명세 준수 재리뷰와 코드 품질 리뷰도 APPROVED였으며, 사용자 요청에 따라 worktree, 기기·에뮬레이터, screenshot, visual QA는 실행하지 않았다. 전체 미완료 체크 감사 결과 Phase 8 누락은 없고, Phase 7의 실제 기기/API 수동 확인 Task 7.4만 기존 미완료 상태로 남아 있다. - 2026-06-22: 후원 랭킹 item 순위 text 하단이 보이지 않는 원인을 재확인했다. rank `TextView`는 profile `1:1` 컨테이너 안에서 `0.63` 지점부터 아래로 그려져야 하는데, profile 컨테이너와 root item의 child drawing이 bounds 안으로 clipping되어 컨테이너 밖으로 내려오는 하단 영역이 보이지 않는 구조였다. 따라서 앞선 padding 접근은 제거하고, root와 profile 컨테이너에 `android:clipChildren="false"`를 적용해 rank text가 profile 하단 밖으로 overflow되어도 보이도록 수정했다. 순위 text 상단 위치는 사용자 확인 기준대로 `0.63` guideline에 맞췄다. RED로 `CreatorChannelDonationFragmentLayoutTest`가 기존 XML에서 FAIL하는 것을 확인했고, 수정 후 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.creator.channel.donation.CreatorChannelDonationFragmentLayoutTest"`, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.creator.channel.donation.*"`, `./gradlew :app:compileDebugKotlin`, `./gradlew :app:ktlintCheck`, `./gradlew :app:mergeDebugResources`, `git diff --check`가 PASS했다. `:app:mergeDebugResources`는 sandbox에서 Gradle wrapper lock 파일 접근 권한 문제로 1회 실패했고, 외부 권한으로 동일 명령을 재실행해 PASS를 확인했다. - 2026-06-22: 사용자 추가 확인에 따라 후원 랭킹 item 순위 text 하단 clipping과 위치를 재조정한다. 기존 `0.68` guideline은 눈으로 봤을 때 Figma보다 낮게 보이므로 `0.63`으로 올리고, `@font/pattaya_regular` + shadow가 `includeFontPadding=false` 상태에서 하단 여유 없이 그려져 잘릴 수 있어 rank text에 bottom padding을 추가한다. 구현 전 RED로 `CreatorChannelDonationFragmentLayoutTest`가 기존 XML에서 FAIL하는 것을 확인했다. - 2026-06-22: 후원 랭킹 item 순위 표시 위치를 Figma `290:9097` 기준으로 수정했다. `get_design_context`와 screenshot에서 `75dp` profile 기준 rank text `top=51dp`를 확인했고, profile 높이의 약 `68%` 지점을 기준으로 rank text가 시작되도록 profile 영역을 `1:1` nested `ConstraintLayout`으로 분리한 뒤 horizontal guideline `0.68`에 rank top을 연결했다. 기존 bottom constraint + `translationY` 방식은 제거했다. RED로 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.creator.channel.donation.CreatorChannelDonationFragmentLayoutTest"`가 기존 XML에서 FAIL하는 것을 확인했고, 수정 후 동일 focused 테스트가 PASS했다. 회귀 검증으로 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.creator.channel.donation.*"`, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.creator.channel.*Donation*"`, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.creator.channel.*"`, `./gradlew :app:mergeDebugResources`, `./gradlew :app:compileDebugKotlin`, `./gradlew :app:ktlintCheck`, `git diff --check`가 PASS했다. `:app:mergeDebugResources`는 sandbox에서 Gradle wrapper lock 파일 접근 권한 문제로 1회 실패했고, 외부 권한으로 동일 명령을 재실행해 PASS를 확인했다. @@ -571,3 +679,4 @@ - 2026-07-08: 후원 탭 item과 홈 탭 후원 섹션 색상 변경 통합 검증으로 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.creator.channel.donation.CreatorChannelDonationFragmentLayoutTest" --tests "kr.co.vividnext.sodalive.v2.creator.channel.CreatorChannelActivitySourceTest"`, `./gradlew :app:mergeDebugResources`, `./gradlew :app:compileDebugKotlin`, `git diff --check`가 PASS했다. `./gradlew :app:ktlintCheck`는 이번 변경 파일이 아닌 `HomeMainFragmentLoginGuardSourceTest.kt`의 기존 line length/argument wrapping 위반으로 실패해 별도 관찰사항으로 둔다. - 2026-07-08: 후원 탭 item과 홈 탭 후원 섹션의 카드 내부 UI/색상 정책 중복을 줄이기 위해 추천 방향대로 공통 후원 카드 바인더를 도입하는 리팩토링을 진행한다. 섹션/adapter 전체는 스크롤 방향과 컨테이너 구조가 달라 유지하고, 카드 내부 profile/nickname/time/can/message/header/text color binding만 공통화한다. XML 단일화는 홈 width/message maxLines/icon 차이로 인한 UI 회귀 위험이 있어 이번 리팩토링 범위에서 제외한다. - 2026-07-08: 공통 후원 카드 바인더 리팩토링을 완료했다. `CreatorChannelDonationCardBinder`를 추가해 header 배경, profile, nickname, time, can, message, `red_400`/기본 텍스트 색상 정책을 한 곳에서 처리하고, 홈 탭 후원 섹션과 후원 탭 item adapter는 각자 layout/scroll/icon/message line 정책을 유지한 채 해당 바인더를 호출하도록 변경했다. RED로 `CreatorChannelActivitySourceTest`, `CreatorChannelDonationFragmentLayoutTest`에 공통 바인더 계약을 추가해 기존 구현에서 FAIL하는 것을 확인했고, 수정 후 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.creator.channel.CreatorChannelActivitySourceTest" --tests "kr.co.vividnext.sodalive.v2.creator.channel.donation.CreatorChannelDonationFragmentLayoutTest"`, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.creator.channel.*Donation*"`, `./gradlew :app:mergeDebugResources`, `./gradlew :app:compileDebugKotlin`, `./gradlew :app:ktlintCheck`, `git diff --check`가 PASS했다. +- 2026-08-26: 후원 item 문자열 흐름과 기존 문서를 대조했다. 빈 `message`는 `creator_channel_donation_fallback_message`, CAN 단위는 `creator_channel_donation_can_format`, `createdAtUtc`는 `AndroidUtcRelativeTimeTextFormatter`를 사용하고, 비어 있지 않은 `message`는 API 원문을 표시한다. 인터뷰에서 시스템 기본 문구와 CAN 단위·상대 시간을 한국어·영어·일본어로 처리하고 사용자 메시지는 원문 유지하며, 미입력 `message`는 서버가 빈 문자열로 반환하는 계약을 확정했다. 이번 실행은 PRD와 계획/TASK 문서만 갱신했으며 코드 변경·Gradle 빌드·기능 테스트는 수행하지 않았다. `./gradlew tasks --all`은 sandbox의 Gradle wrapper lock 접근 제한으로 1회 실패한 뒤 승인된 권한으로 재실행해 `BUILD SUCCESSFUL`을 확인했다. diff --git a/docs/20260622_크리에이터_채널_후원_탭/prd.md b/docs/20260622_크리에이터_채널_후원_탭/prd.md index 3263523f..3255bcae 100644 --- a/docs/20260622_크리에이터_채널_후원_탭/prd.md +++ b/docs/20260622_크리에이터_채널_후원_탭/prd.md @@ -8,6 +8,8 @@ ## 2. Problem - 크리에이터 채널에는 `후원` 탭이 존재하지만 현재 상세 탭 구현은 placeholder 상태다. - 사용자는 크리에이터 채널 안에서 누가 많이 후원했는지와 최근 후원 내역을 한 화면에서 확인할 수 있어야 한다. +- 후원 내역 item에 서버가 만든 한국어 기본 후원 문구가 `message`로 포함되면 영어·일본어 환경에서도 한국어가 그대로 표시될 수 있다. +- 후원 내역 item의 CAN 단위와 상대 시간도 현재 앱 언어에 맞는 resource 경로를 일관되게 사용해야 한다. - 후원 내역은 길어질 수 있으므로 `hasNext == true`일 때 다음 페이지를 이어서 조회해야 한다. - 후원 탭에서 후원하기를 완료하면 방금 반영된 후원 내역과 랭킹이 화면에 갱신되어야 한다. - 홈 탭에 이미 연결된 채널 후원 플로우와 동일한 UX/API 동작을 재사용해야 한다. @@ -23,6 +25,9 @@ - 후원 랭킹 섹션에는 API `rankings`를 응답 순서 기준으로 최대 8명까지 표시한다. - 후원 내역 item에는 후원자 프로필 이미지, 닉네임, 작성 시간, 후원 캔 수량, 메시지를 표시한다. - `createdAtUtc`는 크리에이터 채널 v2 공통 상대 시간 포맷을 따른다. +- 사용자가 후원 메시지를 입력하지 않은 경우 서버는 `message`를 빈 문자열로 반환하고, 앱은 현재 locale의 기본 후원 문구를 표시한다. +- 사용자가 직접 입력한 `message`는 번역하지 않고 서버 응답 원문을 표시한다. +- 후원 기본 문구, CAN 단위, 상대 시간은 한국어·영어·일본어 resource로 표시한다. - 후원 내역 item의 header 색상과 캔 수량 badge는 기존 홈 탭 후원 카드 정책을 우선 재사용한다. - 랭킹 섹션의 `전체보기` 버튼은 기존 `UserProfileDonationAllViewActivity`로 이동한다. - 우측 하단 floating 후원하기 버튼은 홈 탭 후원하기 버튼 터치 액션과 동일하게 `LiveRoomDonationDialog` 기반 채널 후원 플로우를 호출한다. @@ -41,6 +46,9 @@ - API schema를 임의 변경하거나 서버 응답 필드명을 클라이언트에서 새로 정의하지 않는다. - Sort-bar에 정렬 label, 정렬 icon, 정렬 popup을 추가하지 않는다. - Figma asset을 localhost URL 그대로 앱 코드에 직접 의존하지 않는다. +- 사용자 작성 후원 메시지를 자동 번역하지 않는다. +- 서버 기본 문구를 한국어 정규식이나 문구 목록으로 추정해 교체하지 않는다. +- 한국어·영어·일본어 외 신규 locale을 추가하지 않는다. - 레거시 후원 구현 파일을 직접 수정하지 않는다. 필요한 경우 기존 후원 dialog/repository 흐름을 호출하거나 신규 wrapper/adapter를 추가해 사용한다. --- @@ -78,6 +86,8 @@ - 중복 pagination 요청이 발생하지 않도록 loading 중 추가 요청을 막아야 한다. - 다음 페이지 성공 시 기존 `donations` 뒤에 append한다. - 후원 성공 후 갱신은 append가 아니라 `page=0`, `size=20` 최초 조회를 다시 수행한다. +- 사용자가 후원 메시지를 입력하지 않은 내역은 `CreatorChannelDonationResponse.message == ""`로 반환한다. +- 사용자가 후원 메시지를 입력한 내역은 입력 원문을 `CreatorChannelDonationResponse.message`에 반환한다. #### Response Contract ```kotlin @@ -115,6 +125,7 @@ data class CreatorChannelDonationResponse( - 서버 응답의 `page`, `size`가 요청 상태와 다를 경우 서버 응답 값을 기준으로 ViewModel page 상태를 동기화한다. - `profileImage` 또는 `profileImageUrl`이 비어 있거나 이미지 로딩에 실패하면 기존 프로필 이미지 placeholder 정책을 따른다. - `message`가 빈 문자열이면 기존 홈 탭 후원 카드와 동일하게 `%1$d캔을 후원하였습니다.` 형식의 fallback 문구를 표시한다. +- 서버가 미입력 `message`를 한국어 기본 문구로 채워 반환하는 응답은 계약 위반으로 취급하며, Android에서 한국어 문구 패턴을 추정해 보정하지 않는다. ### Donation Ranking Section 후원 랭킹 섹션은 Figma `290:9097` 기준의 카드 UI로 상위 후원자를 표시한다. @@ -160,10 +171,19 @@ Sort-bar는 전체 후원 수만 표시한다. - 각 item은 `gray/900` 배경, radius 14dp 카드로 표시한다. - item header에는 후원자 프로필 이미지, 닉네임, 작성 시간, 후원 캔 수량 badge를 표시한다. - header 배경색은 기존 홈 탭 후원 카드의 `calculateCreatorChannelDonationHeaderColorRes(can)` 정책을 우선 재사용한다. -- 후원 캔 수량은 can icon과 `%d캔` 텍스트로 표시한다. +- 후원 캔 수량은 can icon과 현재 locale의 `creator_channel_donation_can_format` 텍스트로 표시한다. - `createdAtUtc`는 크리에이터 채널 v2 공통 상대 시간 포맷을 따른다. - 메시지는 header 아래에 16sp regular white text로 표시하고 긴 내용은 여러 줄로 확장한다. -- `message`가 빈 문자열이면 기존 홈 탭 후원 카드 fallback 문구를 표시한다. +- `message`가 빈 문자열이면 현재 locale의 `creator_channel_donation_fallback_message`를 `can` 값으로 포맷해 표시한다. +- `message`가 비어 있지 않으면 사용자 작성 내용으로 간주하고 번역·치환 없이 원문을 표시한다. +- 한국어·영어·일본어의 기본 후원 문구와 CAN 단위는 아래 값을 사용한다. + +| 표시 내용 | 한국어 | 영어 | 일본어 | +|---|---|---|---| +| 기본 후원 문구 | `%1$d캔을 후원하였습니다.` | `Supported with %1$d cans.` | `%1$dCANを応援しました。` | +| CAN 단위 | `%1$s캔` | `%1$s cans` | `%1$sCAN` | + +- 상대 시간은 기존 `character_comment_time_just_now`, `character_comment_time_minutes`, `character_comment_time_hours`, `character_comment_time_days`, `character_comment_time_months`, `character_comment_time_years` resource를 재사용한다. - 현재 후원 탭 API는 비밀 후원 여부를 별도로 내려주지 않으므로 비밀 후원 전용 아이콘, 안내 문구, 익명 처리 분기를 구현하지 않는다. #### Edge Cases @@ -171,6 +191,8 @@ Sort-bar는 전체 후원 수만 표시한다. - `createdAtUtc` 파싱 실패 시 기존 날짜 표시 fallback 정책을 따른다. - `can <= 0` 값이 내려와도 앱에서 크래시가 발생하지 않아야 하며, 서버 응답 값을 그대로 표시한다. - 메시지가 매우 긴 경우 item 높이는 자연스럽게 확장하되 인접 item과 겹치지 않아야 한다. +- locale 변경 후 빈 `message`, CAN 단위, 상대 시간이 선택한 언어의 resource 값으로 표시되어야 한다. +- locale 변경과 관계없이 비어 있지 않은 사용자 작성 `message`는 동일한 원문을 유지해야 한다. ### Floating Donation Button 우측 하단 floating 후원하기 버튼은 홈 탭 후원하기 액션과 동일하게 동작한다. @@ -256,6 +278,8 @@ Sort-bar는 전체 후원 수만 표시한다. - 기존 `CreatorChannelPagerAdapter`의 `CreatorChannelTab.Donation` placeholder를 신규 `CreatorChannelDonationFragment`로 교체한다. - 기존 홈 탭 후원 API 호출 흐름인 `CreatorChannelRepository.postChannelDonation()`을 재사용한다. - 기존 홈 탭 후원 item의 색상 계산, fallback message, 상대 시간 formatter는 가능한 범위에서 재사용한다. +- 후원 기본 문구는 `creator_channel_donation_fallback_message`, CAN 단위는 `creator_channel_donation_can_format`, 상대 시간은 기존 `AndroidUtcRelativeTimeTextFormatter`를 재사용한다. +- 서버가 미입력 `message`를 빈 문자열로 반환하기 전에는 한국어 기본 문구의 안전한 식별 기준이 없으므로 후원 기본 문구 다국어 요구사항을 완료로 판정하지 않는다. - 랭킹 `전체보기` 이동은 기존 `UserProfileDonationAllViewActivity`를 재사용하고 신규 전체보기 화면을 만들지 않는다. - 기존 `UserProfileDonationAllViewActivity`는 `Constants.EXTRA_USER_ID`를 요구하므로, 구현 계획에서 크리에이터 채널에서 전달할 식별자가 기존 Activity 계약과 맞는지 확인한다. - 레거시 후원 dialog 또는 repository 파일은 직접 수정하지 않는다. @@ -279,6 +303,9 @@ Sort-bar는 전체 후원 수만 표시한다. - 후원 내역 empty 상태는 Figma `290:9008`/`290:9009`를 기준으로 구현하고, 랭킹 섹션이 있으면 그 아래에 표시한다. - 본인 채널에서는 empty 상태의 하단 `후원하기` button과 floating 후원하기 버튼을 모두 숨긴다. - 현재 API는 비밀 후원 여부를 별도로 내려주지 않으므로 이번 범위에서는 비밀 후원 관련 UI/표시 분기를 구현하지 않는다. +- 2026-08-26: 후원 item의 시스템 기본 문구, CAN 단위, 상대 시간을 한국어·영어·일본어로 표시한다. +- 2026-08-26: 사용자 작성 후원 메시지는 자동 번역하지 않고 원문을 유지한다. +- 2026-08-26: 사용자가 메시지를 입력하지 않으면 서버가 `message`를 빈 문자열로 반환하고, Android는 기존 locale별 fallback을 사용한다. 한국어 기본 문구 패턴을 클라이언트에서 추정하는 보정은 추가하지 않는다. --- @@ -300,3 +327,5 @@ Sort-bar는 전체 후원 수만 표시한다. - 2026-06-22: 기존 홈 탭 PRD와 `CreatorChannelActivity.onCreatorChannelDonationClicked()`, `CreatorChannelHomeViewModel.postChannelDonation()` 흐름을 확인해 후원 탭 후원하기 액션 재사용 및 성공 후 갱신 요구를 PRD에 반영했다. - 2026-06-22: 사용자 확인사항을 반영해 랭킹 `전체보기`는 기존 `UserProfileDonationAllViewActivity` 이동으로 확정하고, `rankings` 최대 8명 서버 보장, Figma `290:9008` empty 상태, 본인 채널 후원하기 버튼 숨김, 비밀 후원 표시 제외 정책을 PRD에 보강했다. - 2026-06-22: 사용자 후속 요청을 반영해 empty UI는 Figma `290:9009` 컨텐츠 기준으로 별도 `minHeight` 없이 표시하고, 후원 랭킹 섹션이 있으면 그 아래에 배치하며, 랭킹 존재 여부와 관계없이 채널 후원이 없으면 항상 표시하도록 보강했다. +- 2026-08-26: 후원 item 표시 경로를 대조했다. 빈 `message`는 `creator_channel_donation_fallback_message`, CAN 단위는 `creator_channel_donation_can_format`, `createdAtUtc`는 `AndroidUtcRelativeTimeTextFormatter`를 통해 한국어·영어·일본어 resource를 사용하지만, 비어 있지 않은 API `message`는 원문을 표시한다는 현재 경계를 확인했다. +- 2026-08-26: 인터뷰에서 시스템 기본 문구와 CAN 단위·상대 시간을 모두 다국어 대상으로 확정하고, 사용자 작성 메시지는 원문 유지, 미입력 `message`는 서버가 빈 문자열로 반환하는 계약을 선택했다. 이번 단계는 문서만 갱신했으며 코드 구현·빌드·기능 테스트는 수행하지 않았다. 문서 명령 유효성 확인을 위한 `./gradlew tasks --all`은 PASS했다.