feat(creator): 후원 탭 화면을 구현한다

This commit is contained in:
2026-06-22 21:24:43 +09:00
parent 77d889d9ab
commit 5ca5da45ba
3 changed files with 422 additions and 0 deletions

View File

@@ -0,0 +1,207 @@
package kr.co.vividnext.sodalive.v2.creator.channel.donation
import android.os.Bundle
import android.view.View
import android.widget.Toast
import androidx.core.view.isVisible
import androidx.recyclerview.widget.LinearLayoutManager
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.base.BaseFragment
import kr.co.vividnext.sodalive.databinding.FragmentCreatorChannelDonationBinding
import kr.co.vividnext.sodalive.extensions.moneyFormat
import kr.co.vividnext.sodalive.v2.creator.channel.donation.ui.CreatorChannelDonationAdapter
import org.koin.androidx.viewmodel.ext.android.viewModel
class CreatorChannelDonationFragment : BaseFragment<FragmentCreatorChannelDonationBinding>(
FragmentCreatorChannelDonationBinding::inflate
) {
private val viewModel: CreatorChannelDonationViewModel by viewModel()
private val donationAdapter = CreatorChannelDonationAdapter(
onRankingAllClick = { host.onCreatorChannelDonationRankingAllClicked() }
)
private var lastContentLayoutKey: CreatorChannelDonationContentLayoutKey? = null
private val creatorId: Long by lazy { arguments?.getLong(ARG_CREATOR_ID) ?: 0L }
private val host: Host
get() = requireActivity() as Host
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
bindLoading()
setupDonationList()
setupClickListeners()
observeViewModel()
}
override fun onDestroyView() {
lastContentLayoutKey = null
binding.rvCreatorChannelDonation.adapter = null
super.onDestroyView()
}
fun onCreatorChannelDonationTabSelected() {
if (creatorId > 0L) {
viewModel.loadDonations(creatorId, isOwner = host.isCreatorChannelOwner())
}
}
fun onCreatorChannelDonationScrolledToBottom() {
viewModel.loadMore()
}
fun onCreatorChannelDonationRefreshRequested() {
viewModel.refreshDonations()
}
fun onCreatorChannelDonationViewportHeightChanged(minHeight: Int) {
binding.root.minimumHeight = minHeight
}
private fun setupDonationList() = with(binding.rvCreatorChannelDonation) {
layoutManager = LinearLayoutManager(requireContext())
adapter = donationAdapter
}
private fun setupClickListeners() = with(binding) {
btnCreatorChannelDonationRetry.setOnClickListener {
viewModel.retryDonations()
}
btnCreatorChannelDonationWrite.setOnClickListener {
host.onCreatorChannelDonationRequested { can, isSecret, message ->
viewModel.postChannelDonation(can, isSecret, message)
}
}
}
private fun observeViewModel() {
viewModel.donationStateLiveData.observe(viewLifecycleOwner) { state ->
when (state) {
CreatorChannelDonationUiState.Loading -> bindLoading()
is CreatorChannelDonationUiState.Empty -> bindEmpty(state)
is CreatorChannelDonationUiState.Error -> bindError(state)
is CreatorChannelDonationUiState.Content -> bindContent(state)
}
handleDonationSuccessEvent()
}
}
private fun bindLoading() = with(binding) {
lastContentLayoutKey = null
layoutCreatorChannelDonationCountBar.isVisible = false
rvCreatorChannelDonation.isVisible = false
layoutCreatorChannelDonationEmpty.isVisible = false
tvCreatorChannelDonationErrorMessage.isVisible = false
btnCreatorChannelDonationRetry.isVisible = false
btnCreatorChannelDonationWrite.isVisible = false
}
private fun bindEmpty(state: CreatorChannelDonationUiState.Empty) = with(binding) {
lastContentLayoutKey = null
layoutCreatorChannelDonationCountBar.isVisible = false
rvCreatorChannelDonation.isVisible = false
layoutCreatorChannelDonationEmpty.isVisible = true
tvCreatorChannelDonationEmptyMessage.setText(
if (state.isOwner) {
R.string.creator_channel_donation_empty_owner_title
} else {
R.string.creator_channel_donation_empty_title
}
)
tvCreatorChannelDonationErrorMessage.isVisible = false
btnCreatorChannelDonationRetry.isVisible = false
btnCreatorChannelDonationWrite.isVisible = false
host.onCreatorChannelDonationContentChanged()
}
private fun bindError(state: CreatorChannelDonationUiState.Error) = with(binding) {
lastContentLayoutKey = null
layoutCreatorChannelDonationCountBar.isVisible = false
rvCreatorChannelDonation.isVisible = false
layoutCreatorChannelDonationEmpty.isVisible = false
tvCreatorChannelDonationErrorMessage.isVisible = true
tvCreatorChannelDonationErrorMessage.text = state.message ?: getString(R.string.creator_channel_donation_error_message)
btnCreatorChannelDonationRetry.isVisible = true
btnCreatorChannelDonationWrite.isVisible = false
host.onCreatorChannelDonationContentChanged()
}
private fun bindContent(state: CreatorChannelDonationUiState.Content) = with(binding) {
layoutCreatorChannelDonationCountBar.isVisible = true
tvCreatorChannelDonationTotalCount.text = state.donationCount.moneyFormat()
rvCreatorChannelDonation.isVisible = true
donationAdapter.submitItems(state.rankings, state.donations)
layoutCreatorChannelDonationEmpty.isVisible = false
tvCreatorChannelDonationErrorMessage.isVisible = false
btnCreatorChannelDonationRetry.isVisible = false
btnCreatorChannelDonationWrite.isVisible = !state.isOwner
notifyContentChangedIfLayoutChanged(state)
state.paginationErrorMessage?.let {
Toast.makeText(requireContext(), it, Toast.LENGTH_SHORT).show()
viewModel.consumePaginationErrorMessage()
}
state.actionToastMessage?.let {
Toast.makeText(requireContext(), it, Toast.LENGTH_SHORT).show()
viewModel.consumeActionToastMessage()
}
}
private fun handleDonationSuccessEvent() {
if (viewModel.consumeDonationSuccessEvent()) {
host.onCreatorChannelDonationCompleted()
}
}
private fun notifyContentChangedIfLayoutChanged(state: CreatorChannelDonationUiState.Content) {
val contentLayoutKey = state.toContentLayoutKey()
if (contentLayoutKey == lastContentLayoutKey) return
lastContentLayoutKey = contentLayoutKey
host.onCreatorChannelDonationContentChanged()
}
interface Host {
fun isCreatorChannelOwner(): Boolean
fun onCreatorChannelDonationContentChanged()
fun onCreatorChannelDonationRequested(onSubmit: (can: Int, isSecret: Boolean, message: String) -> Unit)
fun onCreatorChannelDonationRankingAllClicked()
fun onCreatorChannelDonationCompleted()
}
companion object {
private const val ARG_CREATOR_ID: String = "arg_creator_id"
fun newInstance(creatorId: Long): CreatorChannelDonationFragment {
return CreatorChannelDonationFragment().apply {
arguments = Bundle().apply { putLong(ARG_CREATOR_ID, creatorId) }
}
}
}
}
private data class CreatorChannelDonationContentLayoutKey(
val donationCount: Int,
val rankingUserIds: List<Long>,
val donationItems: List<CreatorChannelDonationItemLayoutKey>
)
private data class CreatorChannelDonationItemLayoutKey(
val nickname: String,
val can: Int,
val message: String,
val createdAtText: String
)
private fun CreatorChannelDonationUiState.Content.toContentLayoutKey(): CreatorChannelDonationContentLayoutKey {
return CreatorChannelDonationContentLayoutKey(
donationCount = donationCount,
rankingUserIds = rankings.map { it.userId },
donationItems = donations.map { donation ->
CreatorChannelDonationItemLayoutKey(
nickname = donation.nickname,
can = donation.can,
message = donation.message,
createdAtText = donation.createdAtText
)
}
)
}

View File

@@ -0,0 +1,88 @@
package kr.co.vividnext.sodalive.v2.creator.channel.donation
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import java.io.File
class CreatorChannelDonationActionTest {
@Test
fun `후원 fragment source는 Host와 탭 load pagination refresh viewport 계약을 제공한다`() {
val fragment = projectFile(
"app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/donation/CreatorChannelDonationFragment.kt"
).readText()
assertTrue(fragment.contains("BaseFragment<FragmentCreatorChannelDonationBinding>"))
assertTrue(fragment.contains("private val viewModel: CreatorChannelDonationViewModel by viewModel()"))
assertTrue(fragment.contains("interface Host"))
assertTrue(fragment.contains("fun isCreatorChannelOwner(): Boolean"))
assertTrue(fragment.contains("fun onCreatorChannelDonationContentChanged()"))
assertTrue(
fragment.contains(
"fun onCreatorChannelDonationRequested(onSubmit: (can: Int, isSecret: Boolean, message: String) -> Unit)"
)
)
assertTrue(fragment.contains("fun onCreatorChannelDonationRankingAllClicked()"))
assertTrue(fragment.contains("fun onCreatorChannelDonationCompleted()"))
assertTrue(fragment.contains("fun newInstance(creatorId: Long): CreatorChannelDonationFragment"))
assertTrue(fragment.contains("viewModel.loadDonations(creatorId, isOwner = host.isCreatorChannelOwner())"))
assertTrue(fragment.contains("viewModel.loadMore()"))
assertTrue(fragment.contains("viewModel.refreshDonations()"))
assertTrue(fragment.contains("binding.root.minimumHeight = minHeight"))
}
@Test
fun `후원 fragment source는 owner일 때 floating button을 숨기고 후원 요청을 ViewModel에 전달한다`() {
val fragment = projectFile(
"app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/donation/CreatorChannelDonationFragment.kt"
).readText()
assertTrue(fragment.contains("btnCreatorChannelDonationWrite.isVisible = !state.isOwner"))
assertTrue(fragment.contains("host.onCreatorChannelDonationRequested { can, isSecret, message ->"))
assertTrue(fragment.contains("viewModel.postChannelDonation(can, isSecret, message)"))
assertTrue(fragment.contains("viewModel.consumeDonationSuccessEvent()"))
assertTrue(fragment.contains("host.onCreatorChannelDonationCompleted()"))
}
@Test
fun `후원 fragment source는 성공 이벤트를 content 상태와 독립적으로 전달한다`() {
val fragment = projectFile(
"app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/donation/CreatorChannelDonationFragment.kt"
).readText()
val observeViewModel = fragment.substringAfter("private fun observeViewModel()")
.substringBefore("private fun bindLoading()")
val bindContent = fragment.substringAfter("private fun bindContent")
.substringBefore("private fun handleDonationSuccessEvent")
assertTrue(observeViewModel.contains("handleDonationSuccessEvent()"))
assertFalse(bindContent.contains("consumeDonationSuccessEvent()"))
}
@Test
fun `후원 adapter source는 ranking 전체보기 callback과 donation binding을 제공한다`() {
val adapter = projectFile(
"app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/donation/ui/CreatorChannelDonationAdapter.kt"
).readText()
val rankingAdapter = projectFile(
"app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/donation/ui/CreatorChannelDonationRankingAdapter.kt"
).readText()
assertTrue(adapter.contains("ItemCreatorChannelDonationBinding"))
assertTrue(adapter.contains("ItemCreatorChannelDonationRankingBinding"))
assertTrue(adapter.contains("submitItems"))
assertTrue(adapter.contains("onRankingAllClick"))
assertTrue(adapter.contains("btnCreatorChannelDonationRankingAll.setOnClickListener"))
assertTrue(adapter.contains("setBackgroundColor(root.context.getColor(item.headerColorResId))"))
assertTrue(adapter.contains("R.string.creator_channel_donation_can_format"))
assertTrue(adapter.contains("GridLayoutManager(itemView.context, 4)"))
assertTrue(rankingAdapter.contains("item.profileImageUrl"))
assertTrue(rankingAdapter.contains("item.rank.toString()"))
}
private fun projectFile(relativePath: String): File {
val candidates = listOf(File(relativePath), File("../$relativePath"))
return candidates.firstOrNull { it.exists() }
?: error("Project file not found: $relativePath")
}
}

View File

@@ -0,0 +1,127 @@
package kr.co.vividnext.sodalive.v2.creator.channel.donation
import android.app.Application
import android.view.LayoutInflater
import android.view.View
import android.widget.ImageView
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView
import androidx.test.core.app.ApplicationProvider
import kr.co.vividnext.sodalive.R
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertSame
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import java.io.File
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [28], application = Application::class)
class CreatorChannelDonationFragmentLayoutTest {
@Test
fun `후원 fragment layout은 list empty error retry floating button을 제공한다`() {
val root = inflateView(R.layout.fragment_creator_channel_donation)
val layout = projectFile("app/src/main/res/layout/fragment_creator_channel_donation.xml").readText()
val countBar = requireNotNull(root.findViewById<View>(R.id.layout_creator_channel_donation_count_bar))
val donationList = requireNotNull(root.findViewById<RecyclerView>(R.id.rv_creator_channel_donation))
val emptyContainer = requireNotNull(root.findViewById<View>(R.id.layout_creator_channel_donation_empty))
val emptyMessage = requireNotNull(root.findViewById<TextView>(R.id.tv_creator_channel_donation_empty_message))
val errorMessage = requireNotNull(root.findViewById<TextView>(R.id.tv_creator_channel_donation_error_message))
val retryButton = requireNotNull(root.findViewById<TextView>(R.id.btn_creator_channel_donation_retry))
val donationButton = requireNotNull(root.findViewById<ImageView>(R.id.btn_creator_channel_donation_write))
assertSame(root, countBar.parent)
assertSame(root, donationList.parent)
assertSame(root, emptyContainer.parent)
assertSame(emptyContainer, emptyMessage.parent)
assertSame(root, errorMessage.parent)
assertSame(root, retryButton.parent)
assertSame(root, donationButton.parent)
assertTrue(layout.contains("android:background=\"@color/black\""))
assertTrue(layout.contains("tools:listitem=\"@layout/item_creator_channel_donation\""))
assertTrue(!layout.contains("creator_channel_donation_sort"))
}
@Test
fun `후원 count bar는 전체 label과 count만 제공하고 sort UI를 만들지 않는다`() {
val root = inflateView(R.layout.fragment_creator_channel_donation)
val countBar = requireNotNull(root.findViewById<View>(R.id.layout_creator_channel_donation_count_bar))
val layout = projectFile("app/src/main/res/layout/fragment_creator_channel_donation.xml").readText()
assertNotNull(countBar.findViewById<TextView>(R.id.tv_creator_channel_donation_total_label))
assertNotNull(countBar.findViewById<TextView>(R.id.tv_creator_channel_donation_total_count))
assertTrue(layout.contains("android:layout_height=\"52dp\""))
assertTrue(!layout.contains("popup"))
assertTrue(!layout.contains("sort"))
}
@Test
fun `후원 ranking item layout은 타이틀 grid 전체보기 button을 제공한다`() {
val item = inflateView(R.layout.item_creator_channel_donation_ranking)
val layout = projectFile("app/src/main/res/layout/item_creator_channel_donation_ranking.xml").readText()
assertNotNull(item.findViewById<TextView>(R.id.tv_creator_channel_donation_ranking_title))
assertNotNull(item.findViewById<RecyclerView>(R.id.rv_creator_channel_donation_ranking_members))
assertNotNull(item.findViewById<TextView>(R.id.btn_creator_channel_donation_ranking_all))
assertTrue(layout.contains("android:text=\"@string/creator_channel_donation_ranking_title\""))
assertTrue(layout.contains("android:text=\"@string/creator_channel_donation_ranking_all\""))
assertTrue(layout.contains("android:layout_marginHorizontal=\"@dimen/spacing_14\""))
assertTrue(layout.contains("android:background=\"@drawable/bg_creator_channel_donation_card\""))
}
@Test
fun `후원 ranking member item layout은 프로필 닉네임 rank overlay를 제공한다`() {
val item = inflateView(R.layout.item_creator_channel_donation_ranking_member)
assertNotNull(item.findViewById<ImageView>(R.id.iv_creator_channel_donation_ranking_profile))
assertNotNull(item.findViewById<TextView>(R.id.tv_creator_channel_donation_ranking_rank))
assertNotNull(item.findViewById<TextView>(R.id.tv_creator_channel_donation_ranking_nickname))
}
@Test
fun `후원 item layout은 profile nickname createdAt can badge message를 제공한다`() {
val item = inflateView(R.layout.item_creator_channel_donation)
val layout = projectFile("app/src/main/res/layout/item_creator_channel_donation.xml").readText()
assertNotNull(item.findViewById<View>(R.id.layout_creator_channel_donation_header))
assertNotNull(item.findViewById<ImageView>(R.id.iv_creator_channel_donation_profile))
assertNotNull(item.findViewById<TextView>(R.id.tv_creator_channel_donation_nickname))
assertNotNull(item.findViewById<TextView>(R.id.tv_creator_channel_donation_created_at))
assertNotNull(item.findViewById<View>(R.id.layout_creator_channel_donation_can_badge))
assertNotNull(item.findViewById<TextView>(R.id.tv_creator_channel_donation_can))
assertNotNull(item.findViewById<TextView>(R.id.tv_creator_channel_donation_message))
assertTrue(layout.contains("android:src=\"@drawable/ic_bar_cash\""))
assertTrue(layout.contains("android:background=\"@drawable/bg_creator_channel_donation_card\""))
}
@Test
fun `후원 문자열은 한국어 영어 일본어에 추가된다`() {
val ko = projectFile("app/src/main/res/values/strings.xml").readText()
val en = projectFile("app/src/main/res/values-en/strings.xml").readText()
val ja = projectFile("app/src/main/res/values-ja/strings.xml").readText()
listOf(ko, en, ja).forEach { strings ->
assertTrue(strings.contains("name=\"creator_channel_donation_all_label\""))
assertTrue(strings.contains("name=\"creator_channel_donation_ranking_title\""))
assertTrue(strings.contains("name=\"creator_channel_donation_ranking_all\""))
assertTrue(strings.contains("name=\"creator_channel_donation_action\""))
assertTrue(strings.contains("name=\"creator_channel_donation_error_message\""))
assertTrue(strings.contains("name=\"creator_channel_donation_retry\""))
}
}
private fun inflateView(layoutResId: Int): View {
val context = ApplicationProvider.getApplicationContext<Application>()
return LayoutInflater.from(context).inflate(layoutResId, null, false)
}
private fun projectFile(relativePath: String): File {
val candidates = listOf(File(relativePath), File("../$relativePath"))
return candidates.firstOrNull { it.exists() }
?: error("Project file not found: $relativePath")
}
}