feat(creator): 채널 후원 버튼을 연결한다

This commit is contained in:
2026-06-16 19:22:28 +09:00
parent de351d700c
commit 28433c10df
4 changed files with 149 additions and 11 deletions

View File

@@ -3,6 +3,7 @@ package kr.co.vividnext.sodalive.v2.creator.channel
import android.content.Context
import android.content.Intent
import android.graphics.Color
import android.view.LayoutInflater
import android.view.View
import android.view.View.MeasureSpec
import android.widget.LinearLayout
@@ -26,6 +27,7 @@ import kr.co.vividnext.sodalive.extensions.dpToPx
import kr.co.vividnext.sodalive.extensions.loadUrl
import kr.co.vividnext.sodalive.extensions.moneyFormat
import kr.co.vividnext.sodalive.live.room.detail.LiveRoomDetailFragment
import kr.co.vividnext.sodalive.live.room.donation.LiveRoomDonationDialog
import kr.co.vividnext.sodalive.report.ProfileReportDialog
import kr.co.vividnext.sodalive.report.UserReportDialog
import kr.co.vividnext.sodalive.v2.common.CreatorActivityType
@@ -302,6 +304,23 @@ class CreatorChannelActivity :
updateViewPagerHeight()
}
override fun onCreatorChannelDonationClicked() {
val header = currentHeader ?: return
if (header.isOwner) return
val dialog = LiveRoomDonationDialog(
this,
LayoutInflater.from(this),
isLiveDonation = true,
messageMaxLength = 100,
secretToggleLabelResId = R.string.screen_user_profile_channel_donation_secret,
applySecretMissionMessageHint = false
) { can, message, isSecret ->
homeActionDelegate?.postChannelDonation(can = can, isSecret = isSecret, message = message)
}
dialog.show(screenWidth - 26.7f.dpToPx().toInt())
}
private fun updateViewPagerHeight() {
binding.viewPager.post {
val recyclerView = binding.viewPager.getChildAt(0) as? RecyclerView ?: return@post

View File

@@ -18,7 +18,11 @@ class CreatorChannelHomeFragment : BaseFragment<FragmentCreatorChannelHomeBindin
) {
private val viewModel: CreatorChannelHomeViewModel by viewModel()
private val sectionAdapter = CreatorChannelHomeSectionAdapter(::onScheduleClicked, ::onAudioContentClicked)
private val sectionAdapter = CreatorChannelHomeSectionAdapter(
onScheduleClick = ::onScheduleClicked,
onAudioContentClick = ::onAudioContentClicked,
onDonationClick = ::onDonationClicked
)
private val creatorId: Long by lazy { arguments?.getLong(ARG_CREATOR_ID) ?: 0L }
private val host: Host
get() = requireActivity() as Host
@@ -49,6 +53,10 @@ class CreatorChannelHomeFragment : BaseFragment<FragmentCreatorChannelHomeBindin
override fun reportProfile() {
viewModel.reportProfile()
}
override fun postChannelDonation(can: Int, isSecret: Boolean, message: String) {
viewModel.postChannelDonation(can = can, isSecret = isSecret, message = message)
}
}
)
if (creatorId > 0L) {
@@ -97,6 +105,10 @@ class CreatorChannelHomeFragment : BaseFragment<FragmentCreatorChannelHomeBindin
host.onCreatorChannelAudioContentClicked(audioContent)
}
private fun onDonationClicked() {
host.onCreatorChannelDonationClicked()
}
interface Host {
fun onCreatorChannelHeaderChanged(header: CreatorChannelHeaderUiModel)
fun onCreatorChannelFollowProgressChanged(inProgress: Boolean)
@@ -105,6 +117,7 @@ class CreatorChannelHomeFragment : BaseFragment<FragmentCreatorChannelHomeBindin
fun onCreatorChannelAudioContentClicked(audioContent: CreatorChannelAudioContentResponse)
fun onCreatorChannelHomeActionDelegateReady(delegate: HomeActionDelegate?)
fun onCreatorChannelHomeContentChanged()
fun onCreatorChannelDonationClicked()
}
interface HomeActionDelegate {
@@ -113,6 +126,7 @@ class CreatorChannelHomeFragment : BaseFragment<FragmentCreatorChannelHomeBindin
fun blockUser()
fun reportUser(reason: String)
fun reportProfile()
fun postChannelDonation(can: Int, isSecret: Boolean, message: String)
}
companion object {

View File

@@ -17,6 +17,7 @@ import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView
import kr.co.vividnext.sodalive.R
import coil.transform.CircleCropTransformation
import kr.co.vividnext.sodalive.common.formatUtcRelativeTimeText
import kr.co.vividnext.sodalive.common.image.BlurTransformation
import kr.co.vividnext.sodalive.extensions.loadUrl
import kr.co.vividnext.sodalive.extensions.moneyFormat
@@ -36,7 +37,8 @@ import kotlin.math.roundToInt
class CreatorChannelHomeSectionAdapter(
private val onScheduleClick: (CreatorChannelScheduleResponse) -> Unit = {},
private val onAudioContentClick: (CreatorChannelAudioContentResponse) -> Unit = {},
private val onSeriesClick: (CreatorChannelSeriesResponse) -> Unit = {}
private val onSeriesClick: (CreatorChannelSeriesResponse) -> Unit = {},
private val onDonationClick: () -> Unit = {}
) : RecyclerView.Adapter<CreatorChannelHomeSectionAdapter.SectionViewHolder>() {
private var items: List<CreatorChannelHomeSection> = emptyList()
@@ -50,7 +52,7 @@ class CreatorChannelHomeSectionAdapter(
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SectionViewHolder {
val view = LayoutInflater.from(parent.context).inflate(viewType, parent, false)
return SectionViewHolder(view, onScheduleClick, onAudioContentClick, onSeriesClick)
return SectionViewHolder(view, onScheduleClick, onAudioContentClick, onSeriesClick, onDonationClick)
}
override fun onBindViewHolder(holder: SectionViewHolder, position: Int) {
@@ -63,7 +65,8 @@ class CreatorChannelHomeSectionAdapter(
view: View,
private val onScheduleClick: (CreatorChannelScheduleResponse) -> Unit,
private val onAudioContentClick: (CreatorChannelAudioContentResponse) -> Unit,
private val onSeriesClick: (CreatorChannelSeriesResponse) -> Unit
private val onSeriesClick: (CreatorChannelSeriesResponse) -> Unit,
private val onDonationClick: () -> Unit
) : RecyclerView.ViewHolder(view) {
private val title: TextView? = view.findViewById(R.id.tv_section_title)
private val currentLiveTitle: TextView? = view.findViewById(R.id.tv_current_live_title)
@@ -75,7 +78,12 @@ class CreatorChannelHomeSectionAdapter(
private val latestAudioPointTag: ImageView? = view.findViewById(R.id.iv_latest_audio_point_tag)
private val latestAudioTitle: TextView? = view.findViewById(R.id.tv_latest_audio_title)
private val latestAudioDuration: TextView? = view.findViewById(R.id.tv_latest_audio_duration)
private val donationItemsScrollView: View? = view.findViewById(R.id.hsv_donation_items)
private val donationItems: LinearLayout? = view.findViewById(R.id.ll_donation_items)
private val donationEmpty: View? = view.findViewById(R.id.layout_donation_empty)
private val donationEmptyButton: View? = view.findViewById(R.id.layout_donation_empty_button)
private val donationButton: View? = view.findViewById(R.id.layout_donation_button)
private val donationEmptyTitle: TextView? = view.findViewById(R.id.tv_donation_empty_title)
private val noticeItems: LinearLayout? = view.findViewById(R.id.ll_notice_items)
private val scheduleTimeline: LinearLayout? = view.findViewById(R.id.ll_schedule_timeline)
private val scheduleItems: LinearLayout? = view.findViewById(R.id.ll_schedule_items)
@@ -107,6 +115,7 @@ class CreatorChannelHomeSectionAdapter(
}
fun bind(item: CreatorChannelHomeSection) {
itemView.setOnClickListener(null)
title?.setText(item.titleResId)
donationItems?.removeAllViews()
noticeItems?.removeAllViews()
@@ -144,9 +153,26 @@ class CreatorChannelHomeSectionAdapter(
latestAudioDuration?.text = item.audioContent.duration.orEmpty()
latestAudioPointTag?.isVisible = item.audioContent.isPointAvailable
latestAudioThumbnail?.loadUrl(item.audioContent.imageUrl)
itemView.setOnClickListener { onAudioContentClick(item.audioContent) }
}
private fun bindDonations(item: CreatorChannelHomeSection.Donations) {
donationItems?.removeAllViews()
donationItemsScrollView?.isVisible = item.donations.isNotEmpty()
donationEmpty?.isVisible = item.donations.isEmpty()
val isDonationButtonVisible = item.donations.isNotEmpty() && !item.isOwner
val isDonationEmptyButtonVisible = !item.isOwner
donationButton?.isVisible = isDonationButtonVisible
donationEmptyButton?.isVisible = isDonationEmptyButtonVisible
donationEmptyTitle?.setText(
if (item.isOwner) {
R.string.creator_channel_donation_empty_owner_title
} else {
R.string.creator_channel_donation_empty_title
}
)
donationButton?.setOnClickListener(if (isDonationButtonVisible) View.OnClickListener { onDonationClick() } else null)
donationEmptyButton?.setOnClickListener(if (isDonationEmptyButtonVisible) View.OnClickListener { onDonationClick() } else null)
val visibleDonations = item.donations.take(MAX_DONATION_ITEM_COUNT)
visibleDonations.forEachIndexed { index, donation ->
val row = LayoutInflater.from(itemView.context).inflate(
@@ -167,7 +193,8 @@ class CreatorChannelHomeSectionAdapter(
transformations(CircleCropTransformation())
}
row.findViewById<TextView>(R.id.tv_donation_nickname).text = donation.nickname
row.findViewById<TextView>(R.id.tv_donation_created_at).text = donation.createdAtUtc
row.findViewById<TextView>(R.id.tv_donation_created_at).text =
formatUtcRelativeTimeText(itemView.context, donation.createdAtUtc)
row.findViewById<TextView>(R.id.tv_donation_can).text = itemView.context.getString(
R.string.creator_channel_donation_can_format,
donation.can.moneyFormat()
@@ -198,7 +225,8 @@ class CreatorChannelHomeSectionAdapter(
transformations(CircleCropTransformation())
}
row.findViewById<TextView>(R.id.tv_notice_creator_name).text = notice.creatorNickname
row.findViewById<TextView>(R.id.tv_notice_created_at).text = notice.dateUtc
row.findViewById<TextView>(R.id.tv_notice_created_at).text =
formatUtcRelativeTimeText(itemView.context, notice.dateUtc)
row.findViewById<TextView>(R.id.tv_notice_content).text = notice.content
val noticeThumbnail = row.findViewById<ImageView>(R.id.iv_notice_thumbnail)
noticeThumbnail.isVisible = !notice.imageUrl.isNullOrBlank()
@@ -383,7 +411,7 @@ class CreatorChannelHomeSectionAdapter(
postId = postId.toString(),
bodyText = content,
keywordText = "",
createdAtText = dateUtc,
createdAtText = formatUtcRelativeTimeText(itemView.context, dateUtc),
commentCount = commentCount,
likeCount = likeCount,
imageUrl = imageUrl,

View File

@@ -207,7 +207,7 @@ class CreatorChannelActivitySourceTest {
assertTrue(source.contains("arguments = Bundle().apply"))
assertTrue(source.contains("putLong(ARG_CREATOR_ID, creatorId)"))
assertTrue(source.contains("private val viewModel: CreatorChannelHomeViewModel by viewModel()"))
assertTrue(source.contains("CreatorChannelHomeSectionAdapter(::onScheduleClicked, ::onAudioContentClicked)"))
assertTrue(source.contains("onDonationClick = ::onDonationClicked"))
assertTrue(source.contains("binding.rvHomeSections.layoutManager = LinearLayoutManager(requireContext())"))
assertTrue(source.contains("binding.rvHomeSections.adapter = sectionAdapter"))
assertTrue(source.contains("viewModel.homeStateLiveData.observe(viewLifecycleOwner)"))
@@ -610,6 +610,8 @@ class CreatorChannelActivitySourceTest {
assertTrue(adapter.contains("latestAudioTitle?.text = item.audioContent.title"))
assertTrue(adapter.contains("latestAudioThumbnail?.loadUrl"))
assertTrue(adapter.contains("latestAudioPointTag?.isVisible = item.audioContent.isPointAvailable"))
assertTrue(adapter.contains("itemView.setOnClickListener(null)"))
assertTrue(adapter.contains("itemView.setOnClickListener { onAudioContentClick(item.audioContent) }"))
assertFalse(adapter.contains("private fun addAudioCard"))
assertFalse(adapter.contains("addAudioCard(item.audioContent)"))
}
@@ -625,9 +627,15 @@ class CreatorChannelActivitySourceTest {
assertTrue(donationLayout.contains("@layout/view_section_title"))
assertTrue(donationLayout.contains("@+id/hsv_donation_items"))
assertTrue(donationLayout.contains("@+id/ll_donation_items"))
assertTrue(donationLayout.contains("@+id/layout_donation_empty"))
assertTrue(donationLayout.contains("@+id/tv_donation_empty_title"))
assertTrue(donationLayout.contains("@string/creator_channel_donation_empty_title"))
assertTrue(donationLayout.contains("android:layout_width=\"match_parent\""))
assertTrue(donationLayout.contains("android:layout_height=\"196dp\""))
assertTrue(donationLayout.contains("@+id/layout_donation_button"))
assertTrue(donationLayout.contains("@drawable/ic_new_donation"))
assertFalse(donationLayout.contains("@+id/tv_donation_button"))
assertFalse(donationLayout.contains("android:layout_width=\"374dp\""))
assertFalse(donationLayout.contains("@+id/ll_section_items"))
val donationCardView = projectFile(
"app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/ui/CreatorChannelDonationCardView.kt"
@@ -644,16 +652,63 @@ class CreatorChannelActivitySourceTest {
assertTrue(donationRowLayout.contains("@drawable/ic_can"))
assertFalse(donationRowLayout.contains("android:layout_width=\"374dp\""))
assertTrue(adapter.contains("private val donationItems: LinearLayout?"))
assertTrue(adapter.contains("private val donationItemsScrollView: View?"))
assertTrue(adapter.contains("private val donationEmpty: View?"))
assertTrue(adapter.contains("donationItemsScrollView?.isVisible = item.donations.isNotEmpty()"))
assertTrue(adapter.contains("donationEmpty?.isVisible = item.donations.isEmpty()"))
assertTrue(adapter.contains("private fun bindDonations(item: CreatorChannelHomeSection.Donations) {\n donationItems?.removeAllViews()"))
assertTrue(adapter.contains("R.layout.item_creator_channel_home_donation_row"))
assertTrue(adapter.contains("donationItems?.addView(row)"))
assertTrue(adapter.contains("val visibleDonations = item.donations.take(MAX_DONATION_ITEM_COUNT)"))
assertTrue(adapter.contains("calculateCreatorChannelDonationCardWidthDp"))
assertTrue(adapter.contains("calculateCreatorChannelDonationHeaderColorRes(donation.can)"))
assertTrue(adapter.contains("formatUtcRelativeTimeText(itemView.context, donation.createdAtUtc)"))
assertFalse(adapter.contains("tv_donation_created_at).text = donation.createdAtUtc"))
assertTrue(adapter.contains("R.string.creator_channel_donation_fallback_message"))
assertFalse(adapter.contains("private fun addDonationCard"))
assertFalse(adapter.contains("addDonationCard("))
}
@Test
fun `후원 버튼은 본인 채널에서 숨기고 타인 채널에서 다이얼로그를 연다`() {
val adapter = projectFile(
"app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/ui/CreatorChannelHomeSectionAdapter.kt"
).readText()
val fragment = projectFile(
"app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelHomeFragment.kt"
).readText()
val activity = projectFile(
"app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelActivity.kt"
).readText()
assertTrue(adapter.contains("private val donationEmptyButton: View?"))
assertTrue(adapter.contains("private val onDonationClick: () -> Unit"))
assertTrue(adapter.contains("val isDonationButtonVisible = item.donations.isNotEmpty() && !item.isOwner"))
assertTrue(adapter.contains("val isDonationEmptyButtonVisible = !item.isOwner"))
assertTrue(adapter.contains("donationButton?.isVisible = isDonationButtonVisible"))
assertTrue(adapter.contains("donationEmptyButton?.isVisible = isDonationEmptyButtonVisible"))
assertTrue(
adapter.contains(
"donationButton?.setOnClickListener(if (isDonationButtonVisible) View.OnClickListener { onDonationClick() } else null)"
)
)
assertTrue(
adapter.contains(
"donationEmptyButton?.setOnClickListener(if (isDonationEmptyButtonVisible) View.OnClickListener { onDonationClick() } else null)"
)
)
assertTrue(fragment.contains("override fun postChannelDonation(can: Int, isSecret: Boolean, message: String)"))
assertTrue(fragment.contains("viewModel.postChannelDonation(can = can, isSecret = isSecret, message = message)"))
assertTrue(fragment.contains("host.onCreatorChannelDonationClicked()"))
assertTrue(activity.contains("LiveRoomDonationDialog"))
assertTrue(activity.contains("isLiveDonation = true"))
assertTrue(activity.contains("messageMaxLength = 100"))
assertTrue(activity.contains("secretToggleLabelResId = R.string.screen_user_profile_channel_donation_secret"))
assertTrue(activity.contains("applySecretMissionMessageHint = false"))
assertTrue(activity.contains("homeActionDelegate?.postChannelDonation(can = can, isSecret = isSecret, message = message)"))
assertTrue(activity.contains("dialog.show(screenWidth - 26.7f.dpToPx().toInt())"))
}
@Test
fun `후원 can 수량은 요청된 배경색 resource 경계로 매핑한다`() {
assertEquals(R.color.gray_200, calculateCreatorChannelDonationHeaderColorRes(1))
@@ -684,6 +739,17 @@ class CreatorChannelActivitySourceTest {
assertTrue(ja.contains("creator_channel_donation_fallback_message"))
}
@Test
fun `후원 empty 안내 문구는 다국어 string resource로 제공한다`() {
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()
assertTrue(ko.contains("name=\"creator_channel_donation_empty_title\">처음으로 크리에이터를\\n후원해 보세요!"))
assertTrue(en.contains("creator_channel_donation_empty_title"))
assertTrue(ja.contains("creator_channel_donation_empty_title"))
}
@Test
fun `공지 섹션은 최대 3개 Figma feed card를 가로 row로 렌더링한다`() {
val adapter = projectFile(
@@ -727,7 +793,8 @@ class CreatorChannelActivitySourceTest {
assertTrue(adapter.contains("noticeItems?.addView(row)"))
assertTrue(adapter.contains("row.findViewById<ImageView>(R.id.iv_notice_profile).loadUrl"))
assertTrue(adapter.contains("row.findViewById<TextView>(R.id.tv_notice_creator_name).text = notice.creatorNickname"))
assertTrue(adapter.contains("row.findViewById<TextView>(R.id.tv_notice_created_at).text = notice.dateUtc"))
assertTrue(adapter.contains("row.findViewById<TextView>(R.id.tv_notice_created_at).text ="))
assertTrue(adapter.contains("formatUtcRelativeTimeText(itemView.context, notice.dateUtc)"))
assertTrue(adapter.contains("row.findViewById<TextView>(R.id.tv_notice_content).text = notice.content"))
assertTrue(adapter.contains("noticeThumbnail.isVisible = !notice.imageUrl.isNullOrBlank()"))
assertTrue(adapter.contains("marginEnd = if (index == visibleNotices.lastIndex) 0 else 4.dp()"))
@@ -759,6 +826,16 @@ class CreatorChannelActivitySourceTest {
assertFalse(adapter.contains("schedule.type.code"))
}
@Test
fun `커뮤니티 날짜는 dateUtc 원문 대신 상대 시간 formatter를 사용한다`() {
val adapter = projectFile(
"app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/ui/CreatorChannelHomeSectionAdapter.kt"
).readText()
assertTrue(adapter.contains("formatUtcRelativeTimeText(itemView.context, dateUtc)"))
assertFalse(adapter.contains("createdAtText = dateUtc,"))
}
@Test
fun `일정 섹션은 Figma 전용 layout과 row bind로 최대 3개를 렌더링한다`() {
val adapter = projectFile(
@@ -828,7 +905,7 @@ class CreatorChannelActivitySourceTest {
"app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelHomeFragment.kt"
).readText()
assertTrue(fragment.contains("CreatorChannelHomeSectionAdapter(::onScheduleClicked"))
assertTrue(fragment.contains("onScheduleClick = ::onScheduleClicked"))
assertTrue(fragment.contains("private fun onScheduleClicked(schedule: CreatorChannelScheduleResponse)"))
assertTrue(fragment.contains("host.onCreatorChannelScheduleClicked(schedule)"))
assertTrue(source.contains("private fun onScheduleClicked(schedule: CreatorChannelScheduleResponse)"))
@@ -907,7 +984,7 @@ class CreatorChannelActivitySourceTest {
"app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelHomeFragment.kt"
).readText()
assertTrue(fragment.contains("CreatorChannelHomeSectionAdapter(::onScheduleClicked, ::onAudioContentClicked)"))
assertTrue(fragment.contains("onAudioContentClick = ::onAudioContentClicked"))
assertTrue(fragment.contains("private fun onAudioContentClicked(audioContent: CreatorChannelAudioContentResponse)"))
assertTrue(fragment.contains("host.onCreatorChannelAudioContentClicked(audioContent)"))
assertTrue(source.contains("private fun onAudioContentClicked(audioContent: CreatorChannelAudioContentResponse)"))