From 5ca5da45ba0c579549e80408b5a871698ffeee26 Mon Sep 17 00:00:00 2001 From: klaus Date: Mon, 22 Jun 2026 21:24:43 +0900 Subject: [PATCH] =?UTF-8?q?feat(creator):=20=ED=9B=84=EC=9B=90=20=ED=83=AD?= =?UTF-8?q?=20=ED=99=94=EB=A9=B4=EC=9D=84=20=EA=B5=AC=ED=98=84=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../CreatorChannelDonationFragment.kt | 207 ++++++++++++++++++ .../CreatorChannelDonationActionTest.kt | 88 ++++++++ ...reatorChannelDonationFragmentLayoutTest.kt | 127 +++++++++++ 3 files changed, 422 insertions(+) create mode 100644 app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/donation/CreatorChannelDonationFragment.kt create mode 100644 app/src/test/java/kr/co/vividnext/sodalive/v2/creator/channel/donation/CreatorChannelDonationActionTest.kt create mode 100644 app/src/test/java/kr/co/vividnext/sodalive/v2/creator/channel/donation/CreatorChannelDonationFragmentLayoutTest.kt diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/donation/CreatorChannelDonationFragment.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/donation/CreatorChannelDonationFragment.kt new file mode 100644 index 00000000..380c719a --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/donation/CreatorChannelDonationFragment.kt @@ -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::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, + val donationItems: List +) + +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 + ) + } + ) +} 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 new file mode 100644 index 00000000..b538f5b6 --- /dev/null +++ b/app/src/test/java/kr/co/vividnext/sodalive/v2/creator/channel/donation/CreatorChannelDonationActionTest.kt @@ -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")) + 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") + } +} 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 new file mode 100644 index 00000000..82fe0ec4 --- /dev/null +++ b/app/src/test/java/kr/co/vividnext/sodalive/v2/creator/channel/donation/CreatorChannelDonationFragmentLayoutTest.kt @@ -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(R.id.layout_creator_channel_donation_count_bar)) + val donationList = requireNotNull(root.findViewById(R.id.rv_creator_channel_donation)) + val emptyContainer = requireNotNull(root.findViewById(R.id.layout_creator_channel_donation_empty)) + val emptyMessage = requireNotNull(root.findViewById(R.id.tv_creator_channel_donation_empty_message)) + val errorMessage = requireNotNull(root.findViewById(R.id.tv_creator_channel_donation_error_message)) + val retryButton = requireNotNull(root.findViewById(R.id.btn_creator_channel_donation_retry)) + val donationButton = requireNotNull(root.findViewById(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(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(R.id.tv_creator_channel_donation_total_label)) + assertNotNull(countBar.findViewById(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(R.id.tv_creator_channel_donation_ranking_title)) + assertNotNull(item.findViewById(R.id.rv_creator_channel_donation_ranking_members)) + assertNotNull(item.findViewById(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(R.id.iv_creator_channel_donation_ranking_profile)) + assertNotNull(item.findViewById(R.id.tv_creator_channel_donation_ranking_rank)) + assertNotNull(item.findViewById(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(R.id.layout_creator_channel_donation_header)) + assertNotNull(item.findViewById(R.id.iv_creator_channel_donation_profile)) + assertNotNull(item.findViewById(R.id.tv_creator_channel_donation_nickname)) + assertNotNull(item.findViewById(R.id.tv_creator_channel_donation_created_at)) + assertNotNull(item.findViewById(R.id.layout_creator_channel_donation_can_badge)) + assertNotNull(item.findViewById(R.id.tv_creator_channel_donation_can)) + assertNotNull(item.findViewById(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() + 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") + } +}