fix(creator): 후원 item locale 표시를 적용한다
This commit is contained in:
+2
-2
@@ -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)
|
||||
)
|
||||
|
||||
|
||||
+6
-2
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
+2
-2
@@ -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()"))
|
||||
|
||||
+69
@@ -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<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
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
+24
-5
@@ -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 문구를 사용한다`() {
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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`을 확인했다.
|
||||
|
||||
@@ -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했다.
|
||||
|
||||
Reference in New Issue
Block a user