From 28433c10df671cc43ef11ebf3449959bba274435 Mon Sep 17 00:00:00 2001 From: klaus Date: Tue, 16 Jun 2026 19:22:28 +0900 Subject: [PATCH] =?UTF-8?q?feat(creator):=20=EC=B1=84=EB=84=90=20=ED=9B=84?= =?UTF-8?q?=EC=9B=90=20=EB=B2=84=ED=8A=BC=EC=9D=84=20=EC=97=B0=EA=B2=B0?= =?UTF-8?q?=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../creator/channel/CreatorChannelActivity.kt | 19 +++++ .../channel/CreatorChannelHomeFragment.kt | 16 +++- .../ui/CreatorChannelHomeSectionAdapter.kt | 40 +++++++-- .../CreatorChannelActivitySourceTest.kt | 85 ++++++++++++++++++- 4 files changed, 149 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelActivity.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelActivity.kt index b84ba2e5..eed3e5a5 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelActivity.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelActivity.kt @@ -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 diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelHomeFragment.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelHomeFragment.kt index b2d3f9a6..7342b9bb 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelHomeFragment.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelHomeFragment.kt @@ -18,7 +18,11 @@ class CreatorChannelHomeFragment : BaseFragment 0L) { @@ -97,6 +105,10 @@ class CreatorChannelHomeFragment : BaseFragment Unit = {}, private val onAudioContentClick: (CreatorChannelAudioContentResponse) -> Unit = {}, - private val onSeriesClick: (CreatorChannelSeriesResponse) -> Unit = {} + private val onSeriesClick: (CreatorChannelSeriesResponse) -> Unit = {}, + private val onDonationClick: () -> Unit = {} ) : RecyclerView.Adapter() { private var items: List = 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(R.id.tv_donation_nickname).text = donation.nickname - row.findViewById(R.id.tv_donation_created_at).text = donation.createdAtUtc + row.findViewById(R.id.tv_donation_created_at).text = + formatUtcRelativeTimeText(itemView.context, donation.createdAtUtc) row.findViewById(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(R.id.tv_notice_creator_name).text = notice.creatorNickname - row.findViewById(R.id.tv_notice_created_at).text = notice.dateUtc + row.findViewById(R.id.tv_notice_created_at).text = + formatUtcRelativeTimeText(itemView.context, notice.dateUtc) row.findViewById(R.id.tv_notice_content).text = notice.content val noticeThumbnail = row.findViewById(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, diff --git a/app/src/test/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelActivitySourceTest.kt b/app/src/test/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelActivitySourceTest.kt index 7fa028f2..cc0f187b 100644 --- a/app/src/test/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelActivitySourceTest.kt +++ b/app/src/test/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelActivitySourceTest.kt @@ -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(R.id.iv_notice_profile).loadUrl")) assertTrue(adapter.contains("row.findViewById(R.id.tv_notice_creator_name).text = notice.creatorNickname")) - assertTrue(adapter.contains("row.findViewById(R.id.tv_notice_created_at).text = notice.dateUtc")) + assertTrue(adapter.contains("row.findViewById(R.id.tv_notice_created_at).text =")) + assertTrue(adapter.contains("formatUtcRelativeTimeText(itemView.context, notice.dateUtc)")) assertTrue(adapter.contains("row.findViewById(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)"))