fix(creator): 후원 item locale 표시를 적용한다

This commit is contained in:
2026-08-26 13:27:20 +09:00
parent 3560d7bf89
commit 4f4ae62026
8 changed files with 260 additions and 14 deletions
@@ -30,8 +30,8 @@ private fun CreatorChannelDonationResponse.toDonationUiModel(
nickname = nickname, nickname = nickname,
profileImageUrl = profileImageUrl, profileImageUrl = profileImageUrl,
can = can, can = can,
message = message.takeUnless { it.isBlank() } ?: context.getString(R.string.creator_channel_donation_fallback_message, can), message = message,
createdAtText = relativeTimeTextFormatter.format(createdAtUtc), createdAtText = createdAtUtc,
headerColorResId = calculateDonationHeaderColorRes(can) headerColorResId = calculateDonationHeaderColorRes(can)
) )
@@ -8,6 +8,8 @@ import android.view.ViewGroup
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView 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.ItemCreatorChannelDonationBinding
import kr.co.vividnext.sodalive.databinding.ItemCreatorChannelDonationEmptyBinding import kr.co.vividnext.sodalive.databinding.ItemCreatorChannelDonationEmptyBinding
import kr.co.vividnext.sodalive.databinding.ItemCreatorChannelDonationRankingBinding import kr.co.vividnext.sodalive.databinding.ItemCreatorChannelDonationRankingBinding
@@ -115,6 +117,7 @@ class CreatorChannelDonationAdapter(
) : RecyclerView.ViewHolder(binding.root) { ) : RecyclerView.ViewHolder(binding.root) {
fun bind(item: CreatorChannelDonationUiModel) = with(binding) { fun bind(item: CreatorChannelDonationUiModel) = with(binding) {
val context = root.context
bindCreatorChannelDonationCard( bindCreatorChannelDonationCard(
headerView = layoutCreatorChannelDonationHeader, headerView = layoutCreatorChannelDonationHeader,
profileView = ivCreatorChannelDonationProfile, profileView = ivCreatorChannelDonationProfile,
@@ -125,9 +128,10 @@ class CreatorChannelDonationAdapter(
headerColorResId = item.headerColorResId, headerColorResId = item.headerColorResId,
profileImageUrl = item.profileImageUrl, profileImageUrl = item.profileImageUrl,
nickname = item.nickname, nickname = item.nickname,
createdAtText = item.createdAtText, createdAtText = formatUtcRelativeTimeText(context, item.createdAtText),
can = item.can, can = item.can,
message = item.message message = item.message.takeUnless { it.isBlank() }
?: context.getString(R.string.creator_channel_donation_fallback_message, item.can)
) )
} }
} }
@@ -2,6 +2,7 @@ package kr.co.vividnext.sodalive.common
import android.app.Application import android.app.Application
import android.content.Context import android.content.Context
import android.content.res.Configuration
import androidx.test.core.app.ApplicationProvider import androidx.test.core.app.ApplicationProvider
import kr.co.vividnext.sodalive.R import kr.co.vividnext.sodalive.R
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
@@ -57,6 +58,21 @@ class RelativeTimeFormatterTest {
assertEquals(context.getString(R.string.character_comment_time_just_now), text) 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 { private fun utcIso(millis: Long): String {
return SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US).apply { return SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US).apply {
timeZone = TimeZone.getTimeZone("UTC") timeZone = TimeZone.getTimeZone("UTC")
@@ -94,8 +94,8 @@ class CreatorChannelDonationActionTest {
assertTrue(adapter.contains("if (!isOwner) View.OnClickListener { onEmptyDonationClick() } else null")) assertTrue(adapter.contains("if (!isOwner) View.OnClickListener { onEmptyDonationClick() } else null"))
assertTrue(adapter.contains("bindCreatorChannelDonationCard(")) assertTrue(adapter.contains("bindCreatorChannelDonationCard("))
assertTrue(adapter.contains("headerColorResId = item.headerColorResId")) assertTrue(adapter.contains("headerColorResId = item.headerColorResId"))
assertTrue(adapter.contains("createdAtText = item.createdAtText")) assertTrue(adapter.contains("createdAtText = formatUtcRelativeTimeText(context, item.createdAtText)"))
assertTrue(adapter.contains("message = item.message")) assertTrue(adapter.contains("R.string.creator_channel_donation_fallback_message"))
assertTrue(adapter.contains("GridLayoutManager(itemView.context, 4)")) assertTrue(adapter.contains("GridLayoutManager(itemView.context, 4)"))
assertTrue(rankingAdapter.contains("item.profileImageUrl")) assertTrue(rankingAdapter.contains("item.profileImageUrl"))
assertTrue(rankingAdapter.contains("item.rank.toString()")) assertTrue(rankingAdapter.contains("item.rank.toString()"))
@@ -1,14 +1,21 @@
package kr.co.vividnext.sodalive.v2.creator.channel.donation package kr.co.vividnext.sodalive.v2.creator.channel.donation
import android.app.Application import android.app.Application
import android.content.res.Configuration
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.widget.FrameLayout
import android.widget.ImageView import android.widget.ImageView
import android.widget.LinearLayout import android.widget.LinearLayout
import android.widget.TextView import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import androidx.test.core.app.ApplicationProvider import androidx.test.core.app.ApplicationProvider
import kr.co.vividnext.sodalive.R 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.assertNotNull
import org.junit.Assert.assertSame import org.junit.Assert.assertSame
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
@@ -17,6 +24,7 @@ import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config import org.robolectric.annotation.Config
import java.io.File import java.io.File
import java.util.Locale
@RunWith(RobolectricTestRunner::class) @RunWith(RobolectricTestRunner::class)
@Config(sdk = [28], application = Application::class) @Config(sdk = [28], application = Application::class)
@@ -167,6 +175,63 @@ class CreatorChannelDonationFragmentLayoutTest {
assertTrue(donationCardBinder.contains("createdAtView.setTextColor(context.getColor(R.color.gray_700))")) 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<Application>()
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<Application>()
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<TextView>(R.id.tv_creator_channel_donation_message).text.toString()
)
assertEquals(
context.getString(R.string.character_comment_time_minutes, 2),
viewHolder.itemView.findViewById<TextView>(R.id.tv_creator_channel_donation_created_at).text.toString()
)
}
}
@Test @Test
fun `후원 문자열은 한국어 영어 일본어에 추가된다`() { fun `후원 문자열은 한국어 영어 일본어에 추가된다`() {
val ko = projectFile("app/src/main/res/values/strings.xml").readText() val ko = projectFile("app/src/main/res/values/strings.xml").readText()
@@ -194,4 +259,8 @@ class CreatorChannelDonationFragmentLayoutTest {
return candidates.firstOrNull { it.exists() } return candidates.firstOrNull { it.exists() }
?: error("Project file not found: $relativePath") ?: error("Project file not found: $relativePath")
} }
private companion object {
const val MINUTE = 60_000L
}
} }
@@ -2,6 +2,7 @@ package kr.co.vividnext.sodalive.v2.creator.channel.donation
import android.app.Application import android.app.Application
import android.content.Context import android.content.Context
import android.content.res.Configuration
import androidx.test.core.app.ApplicationProvider import androidx.test.core.app.ApplicationProvider
import kr.co.vividnext.sodalive.R import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.common.AndroidUtcRelativeTimeTextFormatter import kr.co.vividnext.sodalive.common.AndroidUtcRelativeTimeTextFormatter
@@ -14,6 +15,7 @@ import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config import org.robolectric.annotation.Config
import java.util.Locale
@RunWith(RobolectricTestRunner::class) @RunWith(RobolectricTestRunner::class)
@Config(sdk = [28], application = Application::class) @Config(sdk = [28], application = Application::class)
@@ -35,21 +37,38 @@ class CreatorChannelDonationMapperTest {
} }
@Test @Test
fun `후원 message가 blank이면 can을 포함한 fallback 문구를 사용한다`() { fun `후원 mapper는 message 원문을 보존한다`() {
val item = listOf(donation(can = 50, message = " ")) val item = listOf(donation(can = 50, message = ""))
.toDonationUiModels(context, relativeTimeTextFormatter) .toDonationUiModels(context, relativeTimeTextFormatter)
.single() .single()
assertEquals(context.getString(R.string.creator_channel_donation_fallback_message, 50), item.message) assertEquals("", item.message)
} }
@Test @Test
fun `후원 createdAtUtc는 상대 시간으로 매핑하고 can 기준 header color를 계산한다`() { fun `message는 한국어 영어 일본어 context와 관계없이 API 원문을 유지한다`() {
val item = listOf(donation(can = 501, createdAtUtc = System.currentTimeMillis().toString())) 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) .toDonationUiModels(context, relativeTimeTextFormatter)
.single() .single()
assertEquals(context.getString(R.string.character_comment_time_just_now), item.createdAtText) assertEquals(createdAtUtc, item.createdAtText)
assertEquals(R.color.red_400, item.headerColorResId) assertEquals(R.color.red_400, item.headerColorResId)
} }
@@ -41,6 +41,10 @@
- `rankings`는 서버가 항상 최대 8명까지만 내려준다. - `rankings`는 서버가 항상 최대 8명까지만 내려준다.
- 후원 랭킹 `전체보기` 버튼은 기존 `UserProfileDonationAllViewActivity`로 이동한다. - 후원 랭킹 `전체보기` 버튼은 기존 `UserProfileDonationAllViewActivity`로 이동한다.
- 현재 API는 비밀 후원 여부를 별도 필드로 내려주지 않으므로 비밀 후원 전용 UI/표시 분기는 구현하지 않는다. - 현재 API는 비밀 후원 여부를 별도 필드로 내려주지 않으므로 비밀 후원 전용 UI/표시 분기는 구현하지 않는다.
- 사용자가 후원 메시지를 입력하지 않으면 서버는 `message`를 빈 문자열로 반환한다.
- 사용자가 직접 입력한 후원 `message`는 번역하지 않고 원문을 유지한다.
- 시스템 기본 후원 문구, CAN 단위, 상대 시간은 기존 한국어·영어·일본어 resource 경로를 재사용한다.
- 서버 기본 문구의 한국어 패턴을 Android에서 추정하는 보정 로직은 추가하지 않는다.
- 본인 채널에서는 content/empty 상태 모두 floating 후원하기 버튼을 숨긴다. - 본인 채널에서는 content/empty 상태 모두 floating 후원하기 버튼을 숨긴다.
- 본인 채널 empty 상태에서는 중앙 `후원하기` button도 숨긴다. - 본인 채널 empty 상태에서는 중앙 `후원하기` button도 숨긴다.
- empty UI는 별도 `minHeight` 없이 표시한다. - empty UI는 별도 `minHeight` 없이 표시한다.
@@ -73,6 +77,8 @@
- 탭 연결, pagination, 후원 dialog 재사용, 전체보기 이동은 기존 코드 패턴 중심으로 검증한다. - 탭 연결, pagination, 후원 dialog 재사용, 전체보기 이동은 기존 코드 패턴 중심으로 검증한다.
- Phase 7: 필수 참조 - Phase 7: 필수 참조
- 최종 수동 화면 검증은 PRD의 Figma 노드와 실제 화면을 대조한다. - 최종 수동 화면 검증은 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 ## 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 하단이 보이지 않는 원인을 재확인했다. 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 순위 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를 확인했다. - 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과 홈 탭 후원 섹션 색상 변경 통합 검증으로 `./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: 후원 탭 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-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`을 확인했다.
@@ -8,6 +8,8 @@
## 2. Problem ## 2. Problem
- 크리에이터 채널에는 `후원` 탭이 존재하지만 현재 상세 탭 구현은 placeholder 상태다. - 크리에이터 채널에는 `후원` 탭이 존재하지만 현재 상세 탭 구현은 placeholder 상태다.
- 사용자는 크리에이터 채널 안에서 누가 많이 후원했는지와 최근 후원 내역을 한 화면에서 확인할 수 있어야 한다. - 사용자는 크리에이터 채널 안에서 누가 많이 후원했는지와 최근 후원 내역을 한 화면에서 확인할 수 있어야 한다.
- 후원 내역 item에 서버가 만든 한국어 기본 후원 문구가 `message`로 포함되면 영어·일본어 환경에서도 한국어가 그대로 표시될 수 있다.
- 후원 내역 item의 CAN 단위와 상대 시간도 현재 앱 언어에 맞는 resource 경로를 일관되게 사용해야 한다.
- 후원 내역은 길어질 수 있으므로 `hasNext == true`일 때 다음 페이지를 이어서 조회해야 한다. - 후원 내역은 길어질 수 있으므로 `hasNext == true`일 때 다음 페이지를 이어서 조회해야 한다.
- 후원 탭에서 후원하기를 완료하면 방금 반영된 후원 내역과 랭킹이 화면에 갱신되어야 한다. - 후원 탭에서 후원하기를 완료하면 방금 반영된 후원 내역과 랭킹이 화면에 갱신되어야 한다.
- 홈 탭에 이미 연결된 채널 후원 플로우와 동일한 UX/API 동작을 재사용해야 한다. - 홈 탭에 이미 연결된 채널 후원 플로우와 동일한 UX/API 동작을 재사용해야 한다.
@@ -23,6 +25,9 @@
- 후원 랭킹 섹션에는 API `rankings`를 응답 순서 기준으로 최대 8명까지 표시한다. - 후원 랭킹 섹션에는 API `rankings`를 응답 순서 기준으로 최대 8명까지 표시한다.
- 후원 내역 item에는 후원자 프로필 이미지, 닉네임, 작성 시간, 후원 캔 수량, 메시지를 표시한다. - 후원 내역 item에는 후원자 프로필 이미지, 닉네임, 작성 시간, 후원 캔 수량, 메시지를 표시한다.
- `createdAtUtc`는 크리에이터 채널 v2 공통 상대 시간 포맷을 따른다. - `createdAtUtc`는 크리에이터 채널 v2 공통 상대 시간 포맷을 따른다.
- 사용자가 후원 메시지를 입력하지 않은 경우 서버는 `message`를 빈 문자열로 반환하고, 앱은 현재 locale의 기본 후원 문구를 표시한다.
- 사용자가 직접 입력한 `message`는 번역하지 않고 서버 응답 원문을 표시한다.
- 후원 기본 문구, CAN 단위, 상대 시간은 한국어·영어·일본어 resource로 표시한다.
- 후원 내역 item의 header 색상과 캔 수량 badge는 기존 홈 탭 후원 카드 정책을 우선 재사용한다. - 후원 내역 item의 header 색상과 캔 수량 badge는 기존 홈 탭 후원 카드 정책을 우선 재사용한다.
- 랭킹 섹션의 `전체보기` 버튼은 기존 `UserProfileDonationAllViewActivity`로 이동한다. - 랭킹 섹션의 `전체보기` 버튼은 기존 `UserProfileDonationAllViewActivity`로 이동한다.
- 우측 하단 floating 후원하기 버튼은 홈 탭 후원하기 버튼 터치 액션과 동일하게 `LiveRoomDonationDialog` 기반 채널 후원 플로우를 호출한다. - 우측 하단 floating 후원하기 버튼은 홈 탭 후원하기 버튼 터치 액션과 동일하게 `LiveRoomDonationDialog` 기반 채널 후원 플로우를 호출한다.
@@ -41,6 +46,9 @@
- API schema를 임의 변경하거나 서버 응답 필드명을 클라이언트에서 새로 정의하지 않는다. - API schema를 임의 변경하거나 서버 응답 필드명을 클라이언트에서 새로 정의하지 않는다.
- Sort-bar에 정렬 label, 정렬 icon, 정렬 popup을 추가하지 않는다. - Sort-bar에 정렬 label, 정렬 icon, 정렬 popup을 추가하지 않는다.
- Figma asset을 localhost URL 그대로 앱 코드에 직접 의존하지 않는다. - Figma asset을 localhost URL 그대로 앱 코드에 직접 의존하지 않는다.
- 사용자 작성 후원 메시지를 자동 번역하지 않는다.
- 서버 기본 문구를 한국어 정규식이나 문구 목록으로 추정해 교체하지 않는다.
- 한국어·영어·일본어 외 신규 locale을 추가하지 않는다.
- 레거시 후원 구현 파일을 직접 수정하지 않는다. 필요한 경우 기존 후원 dialog/repository 흐름을 호출하거나 신규 wrapper/adapter를 추가해 사용한다. - 레거시 후원 구현 파일을 직접 수정하지 않는다. 필요한 경우 기존 후원 dialog/repository 흐름을 호출하거나 신규 wrapper/adapter를 추가해 사용한다.
--- ---
@@ -78,6 +86,8 @@
- 중복 pagination 요청이 발생하지 않도록 loading 중 추가 요청을 막아야 한다. - 중복 pagination 요청이 발생하지 않도록 loading 중 추가 요청을 막아야 한다.
- 다음 페이지 성공 시 기존 `donations` 뒤에 append한다. - 다음 페이지 성공 시 기존 `donations` 뒤에 append한다.
- 후원 성공 후 갱신은 append가 아니라 `page=0`, `size=20` 최초 조회를 다시 수행한다. - 후원 성공 후 갱신은 append가 아니라 `page=0`, `size=20` 최초 조회를 다시 수행한다.
- 사용자가 후원 메시지를 입력하지 않은 내역은 `CreatorChannelDonationResponse.message == ""`로 반환한다.
- 사용자가 후원 메시지를 입력한 내역은 입력 원문을 `CreatorChannelDonationResponse.message`에 반환한다.
#### Response Contract #### Response Contract
```kotlin ```kotlin
@@ -115,6 +125,7 @@ data class CreatorChannelDonationResponse(
- 서버 응답의 `page`, `size`가 요청 상태와 다를 경우 서버 응답 값을 기준으로 ViewModel page 상태를 동기화한다. - 서버 응답의 `page`, `size`가 요청 상태와 다를 경우 서버 응답 값을 기준으로 ViewModel page 상태를 동기화한다.
- `profileImage` 또는 `profileImageUrl`이 비어 있거나 이미지 로딩에 실패하면 기존 프로필 이미지 placeholder 정책을 따른다. - `profileImage` 또는 `profileImageUrl`이 비어 있거나 이미지 로딩에 실패하면 기존 프로필 이미지 placeholder 정책을 따른다.
- `message`가 빈 문자열이면 기존 홈 탭 후원 카드와 동일하게 `%1$d캔을 후원하였습니다.` 형식의 fallback 문구를 표시한다. - `message`가 빈 문자열이면 기존 홈 탭 후원 카드와 동일하게 `%1$d캔을 후원하였습니다.` 형식의 fallback 문구를 표시한다.
- 서버가 미입력 `message`를 한국어 기본 문구로 채워 반환하는 응답은 계약 위반으로 취급하며, Android에서 한국어 문구 패턴을 추정해 보정하지 않는다.
### Donation Ranking Section ### Donation Ranking Section
후원 랭킹 섹션은 Figma `290:9097` 기준의 카드 UI로 상위 후원자를 표시한다. 후원 랭킹 섹션은 Figma `290:9097` 기준의 카드 UI로 상위 후원자를 표시한다.
@@ -160,10 +171,19 @@ Sort-bar는 전체 후원 수만 표시한다.
- 각 item은 `gray/900` 배경, radius 14dp 카드로 표시한다. - 각 item은 `gray/900` 배경, radius 14dp 카드로 표시한다.
- item header에는 후원자 프로필 이미지, 닉네임, 작성 시간, 후원 캔 수량 badge를 표시한다. - item header에는 후원자 프로필 이미지, 닉네임, 작성 시간, 후원 캔 수량 badge를 표시한다.
- header 배경색은 기존 홈 탭 후원 카드의 `calculateCreatorChannelDonationHeaderColorRes(can)` 정책을 우선 재사용한다. - header 배경색은 기존 홈 탭 후원 카드의 `calculateCreatorChannelDonationHeaderColorRes(can)` 정책을 우선 재사용한다.
- 후원 캔 수량은 can icon과 `%d캔` 텍스트로 표시한다. - 후원 캔 수량은 can icon과 현재 locale의 `creator_channel_donation_can_format` 텍스트로 표시한다.
- `createdAtUtc`는 크리에이터 채널 v2 공통 상대 시간 포맷을 따른다. - `createdAtUtc`는 크리에이터 채널 v2 공통 상대 시간 포맷을 따른다.
- 메시지는 header 아래에 16sp regular white text로 표시하고 긴 내용은 여러 줄로 확장한다. - 메시지는 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는 비밀 후원 여부를 별도로 내려주지 않으므로 비밀 후원 전용 아이콘, 안내 문구, 익명 처리 분기를 구현하지 않는다. - 현재 후원 탭 API는 비밀 후원 여부를 별도로 내려주지 않으므로 비밀 후원 전용 아이콘, 안내 문구, 익명 처리 분기를 구현하지 않는다.
#### Edge Cases #### Edge Cases
@@ -171,6 +191,8 @@ Sort-bar는 전체 후원 수만 표시한다.
- `createdAtUtc` 파싱 실패 시 기존 날짜 표시 fallback 정책을 따른다. - `createdAtUtc` 파싱 실패 시 기존 날짜 표시 fallback 정책을 따른다.
- `can <= 0` 값이 내려와도 앱에서 크래시가 발생하지 않아야 하며, 서버 응답 값을 그대로 표시한다. - `can <= 0` 값이 내려와도 앱에서 크래시가 발생하지 않아야 하며, 서버 응답 값을 그대로 표시한다.
- 메시지가 매우 긴 경우 item 높이는 자연스럽게 확장하되 인접 item과 겹치지 않아야 한다. - 메시지가 매우 긴 경우 item 높이는 자연스럽게 확장하되 인접 item과 겹치지 않아야 한다.
- locale 변경 후 빈 `message`, CAN 단위, 상대 시간이 선택한 언어의 resource 값으로 표시되어야 한다.
- locale 변경과 관계없이 비어 있지 않은 사용자 작성 `message`는 동일한 원문을 유지해야 한다.
### Floating Donation Button ### Floating Donation Button
우측 하단 floating 후원하기 버튼은 홈 탭 후원하기 액션과 동일하게 동작한다. 우측 하단 floating 후원하기 버튼은 홈 탭 후원하기 액션과 동일하게 동작한다.
@@ -256,6 +278,8 @@ Sort-bar는 전체 후원 수만 표시한다.
- 기존 `CreatorChannelPagerAdapter``CreatorChannelTab.Donation` placeholder를 신규 `CreatorChannelDonationFragment`로 교체한다. - 기존 `CreatorChannelPagerAdapter``CreatorChannelTab.Donation` placeholder를 신규 `CreatorChannelDonationFragment`로 교체한다.
- 기존 홈 탭 후원 API 호출 흐름인 `CreatorChannelRepository.postChannelDonation()`을 재사용한다. - 기존 홈 탭 후원 API 호출 흐름인 `CreatorChannelRepository.postChannelDonation()`을 재사용한다.
- 기존 홈 탭 후원 item의 색상 계산, fallback message, 상대 시간 formatter는 가능한 범위에서 재사용한다. - 기존 홈 탭 후원 item의 색상 계산, fallback message, 상대 시간 formatter는 가능한 범위에서 재사용한다.
- 후원 기본 문구는 `creator_channel_donation_fallback_message`, CAN 단위는 `creator_channel_donation_can_format`, 상대 시간은 기존 `AndroidUtcRelativeTimeTextFormatter`를 재사용한다.
- 서버가 미입력 `message`를 빈 문자열로 반환하기 전에는 한국어 기본 문구의 안전한 식별 기준이 없으므로 후원 기본 문구 다국어 요구사항을 완료로 판정하지 않는다.
- 랭킹 `전체보기` 이동은 기존 `UserProfileDonationAllViewActivity`를 재사용하고 신규 전체보기 화면을 만들지 않는다. - 랭킹 `전체보기` 이동은 기존 `UserProfileDonationAllViewActivity`를 재사용하고 신규 전체보기 화면을 만들지 않는다.
- 기존 `UserProfileDonationAllViewActivity``Constants.EXTRA_USER_ID`를 요구하므로, 구현 계획에서 크리에이터 채널에서 전달할 식별자가 기존 Activity 계약과 맞는지 확인한다. - 기존 `UserProfileDonationAllViewActivity``Constants.EXTRA_USER_ID`를 요구하므로, 구현 계획에서 크리에이터 채널에서 전달할 식별자가 기존 Activity 계약과 맞는지 확인한다.
- 레거시 후원 dialog 또는 repository 파일은 직접 수정하지 않는다. - 레거시 후원 dialog 또는 repository 파일은 직접 수정하지 않는다.
@@ -279,6 +303,9 @@ Sort-bar는 전체 후원 수만 표시한다.
- 후원 내역 empty 상태는 Figma `290:9008`/`290:9009`를 기준으로 구현하고, 랭킹 섹션이 있으면 그 아래에 표시한다. - 후원 내역 empty 상태는 Figma `290:9008`/`290:9009`를 기준으로 구현하고, 랭킹 섹션이 있으면 그 아래에 표시한다.
- 본인 채널에서는 empty 상태의 하단 `후원하기` button과 floating 후원하기 버튼을 모두 숨긴다. - 본인 채널에서는 empty 상태의 하단 `후원하기` button과 floating 후원하기 버튼을 모두 숨긴다.
- 현재 API는 비밀 후원 여부를 별도로 내려주지 않으므로 이번 범위에서는 비밀 후원 관련 UI/표시 분기를 구현하지 않는다. - 현재 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: 기존 홈 탭 PRD와 `CreatorChannelActivity.onCreatorChannelDonationClicked()`, `CreatorChannelHomeViewModel.postChannelDonation()` 흐름을 확인해 후원 탭 후원하기 액션 재사용 및 성공 후 갱신 요구를 PRD에 반영했다.
- 2026-06-22: 사용자 확인사항을 반영해 랭킹 `전체보기`는 기존 `UserProfileDonationAllViewActivity` 이동으로 확정하고, `rankings` 최대 8명 서버 보장, Figma `290:9008` empty 상태, 본인 채널 후원하기 버튼 숨김, 비밀 후원 표시 제외 정책을 PRD에 보강했다. - 2026-06-22: 사용자 확인사항을 반영해 랭킹 `전체보기`는 기존 `UserProfileDonationAllViewActivity` 이동으로 확정하고, `rankings` 최대 8명 서버 보장, Figma `290:9008` empty 상태, 본인 채널 후원하기 버튼 숨김, 비밀 후원 표시 제외 정책을 PRD에 보강했다.
- 2026-06-22: 사용자 후속 요청을 반영해 empty UI는 Figma `290:9009` 컨텐츠 기준으로 별도 `minHeight` 없이 표시하고, 후원 랭킹 섹션이 있으면 그 아래에 배치하며, 랭킹 존재 여부와 관계없이 채널 후원이 없으면 항상 표시하도록 보강했다. - 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했다.