From a36c3b74e843d046ffc3def939d789f056c8bc24 Mon Sep 17 00:00:00 2001 From: klaus Date: Mon, 22 Jun 2026 01:44:29 +0900 Subject: [PATCH] =?UTF-8?q?feat(creator):=20=EC=BB=A4=EB=AE=A4=EB=8B=88?= =?UTF-8?q?=ED=8B=B0=20=ED=83=AD=20activity=20=EB=8F=99=EC=9E=91=EC=9D=84?= =?UTF-8?q?=20=EC=97=B0=EA=B2=B0=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 | 152 +++++++++++++++++- .../CreatorChannelCommunityFragment.kt | 9 +- .../CreatorChannelCommunityViewModel.kt | 6 + .../CreatorChannelActivitySourceTest.kt | 55 ++++++- 4 files changed, 211 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 ca377e60..a003af09 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 @@ -7,10 +7,10 @@ import android.content.Context import android.content.Intent import android.graphics.Color import android.view.Gravity -import android.view.animation.Interpolator import android.view.LayoutInflater import android.view.View import android.view.View.MeasureSpec +import android.view.animation.Interpolator import android.widget.LinearLayout import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AlertDialog @@ -24,11 +24,11 @@ import androidx.recyclerview.widget.RecyclerView import androidx.viewpager2.widget.ViewPager2 import com.google.android.material.tabs.TabLayoutMediator import com.google.gson.Gson +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers +import io.reactivex.rxjava3.schedulers.Schedulers import kr.co.vividnext.sodalive.R import kr.co.vividnext.sodalive.audio_content.detail.AudioContentDetailActivity import kr.co.vividnext.sodalive.audio_content.series.detail.SeriesDetailActivity -import kr.co.vividnext.sodalive.live.room.create.LiveRoomCreateActivity -import kr.co.vividnext.sodalive.explorer.profile.creator_community.write.CreatorCommunityWriteActivity import kr.co.vividnext.sodalive.audio_content.upload.AudioContentUploadActivity import kr.co.vividnext.sodalive.base.BaseActivity import kr.co.vividnext.sodalive.base.SodaDialog @@ -37,10 +37,16 @@ import kr.co.vividnext.sodalive.common.Constants import kr.co.vividnext.sodalive.common.LoadingDialog import kr.co.vividnext.sodalive.common.SharedPreferenceManager import kr.co.vividnext.sodalive.databinding.ActivityCreatorChannelBinding +import kr.co.vividnext.sodalive.explorer.profile.creator_community.CreatorCommunityRepository +import kr.co.vividnext.sodalive.explorer.profile.creator_community.all.CreatorCommunityPostMenuBottomSheetDialog +import kr.co.vividnext.sodalive.explorer.profile.creator_community.modify.CreatorCommunityModifyActivity +import kr.co.vividnext.sodalive.explorer.profile.creator_community.modify.ModifyCommunityPostRequest +import kr.co.vividnext.sodalive.explorer.profile.creator_community.write.CreatorCommunityWriteActivity 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.LiveViewModel +import kr.co.vividnext.sodalive.live.room.create.LiveRoomCreateActivity import kr.co.vividnext.sodalive.live.room.donation.LiveRoomDonationDialog import kr.co.vividnext.sodalive.mypage.MyPageViewModel import kr.co.vividnext.sodalive.mypage.auth.Auth @@ -50,11 +56,13 @@ import kr.co.vividnext.sodalive.report.ProfileReportDialog import kr.co.vividnext.sodalive.report.UserReportDialog import kr.co.vividnext.sodalive.settings.ContentSettingsActivity import kr.co.vividnext.sodalive.v2.common.CreatorActivityType +import kr.co.vividnext.sodalive.v2.creator.channel.audio.CreatorChannelAudioFragment +import kr.co.vividnext.sodalive.v2.creator.channel.community.CreatorChannelCommunityFragment +import kr.co.vividnext.sodalive.v2.creator.channel.community.model.CreatorChannelCommunityPostUiModel import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelAudioContentResponse import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelLiveResponse import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelScheduleResponse import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelSeriesResponse -import kr.co.vividnext.sodalive.v2.creator.channel.audio.CreatorChannelAudioFragment import kr.co.vividnext.sodalive.v2.creator.channel.live.CreatorChannelLiveFragment import kr.co.vividnext.sodalive.v2.creator.channel.model.CreatorChannelHeaderUiModel import kr.co.vividnext.sodalive.v2.creator.channel.model.CreatorChannelScrollState @@ -65,6 +73,8 @@ import kr.co.vividnext.sodalive.v2.main.MainV2Activity import kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomActivity import kr.co.vividnext.sodalive.splash.SplashActivity import kr.co.vividnext.sodalive.user.login.LoginActivity +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.RequestBody.Companion.toRequestBody import org.koin.android.ext.android.inject class CreatorChannelActivity : @@ -72,10 +82,12 @@ class CreatorChannelActivity : CreatorChannelHomeFragment.Host, CreatorChannelLiveFragment.Host, CreatorChannelAudioFragment.Host, - CreatorChannelSeriesFragment.Host { + CreatorChannelSeriesFragment.Host, + CreatorChannelCommunityFragment.Host { private val liveViewModel: LiveViewModel by inject() private val myPageViewModel: MyPageViewModel by inject() + private val creatorCommunityRepository: CreatorCommunityRepository by inject() private var creatorId: Long = 0L private var currentHeader: CreatorChannelHeaderUiModel? = null private var homeActionDelegate: CreatorChannelHomeFragment.HomeActionDelegate? = null @@ -103,6 +115,14 @@ class CreatorChannelActivity : ) { result -> if (result.resultCode == RESULT_OK) { homeActionDelegate?.refreshHome() + refreshCreatorChannelCommunity() + } + } + private val communityPostModifyLauncher = registerForActivityResult( + ActivityResultContracts.StartActivityForResult() + ) { result -> + if (result.resultCode == RESULT_OK) { + refreshCreatorChannelCommunity() } } private val liveRoomCreateLauncher = registerForActivityResult( @@ -407,6 +427,9 @@ class CreatorChannelActivity : CreatorChannelTab.Series.ordinal -> binding.viewPager.post { findSeriesFragment()?.onCreatorChannelSeriesTabSelected() } + CreatorChannelTab.Community.ordinal -> binding.viewPager.post { + findCommunityFragment()?.onCreatorChannelCommunityTabSelected() + } } } } @@ -430,6 +453,11 @@ class CreatorChannelActivity : findSeriesFragment()?.onCreatorChannelSeriesTabSelected() } } + if (binding.viewPager.currentItem == CreatorChannelTab.Community.ordinal) { + binding.viewPager.post { + findCommunityFragment()?.onCreatorChannelCommunityTabSelected() + } + } } override fun onCreatorChannelFollowProgressChanged(inProgress: Boolean) { @@ -495,6 +523,102 @@ class CreatorChannelActivity : postCheckCreatorChannelCurrentTabNeedsMore() } + override fun onCreatorChannelCommunityContentChanged() { + updateViewPagerHeight() + postCheckCreatorChannelCurrentTabNeedsMore() + } + + override fun onCreatorChannelCommunityOwnerMoreClicked(item: CreatorChannelCommunityPostUiModel) { + CreatorCommunityPostMenuBottomSheetDialog( + isFixed = item.isPinned, + isCreator = true, + onClickPin = { + updateCreatorChannelCommunityPostFixed(item) + }, + onClickModify = { + communityPostModifyLauncher.launch( + Intent(this, CreatorCommunityModifyActivity::class.java).apply { + putExtra(Constants.EXTRA_COMMUNITY_POST_ID, item.postId) + } + ) + }, + onClickDelete = { + showCreatorChannelCommunityDeleteDialog(item) + }, + onClickReport = {} + ).show(supportFragmentManager, CreatorCommunityPostMenuBottomSheetDialog::class.java.simpleName) + } + + private fun showCreatorChannelCommunityDeleteDialog(item: CreatorChannelCommunityPostUiModel) { + SodaDialog( + activity = this, + layoutInflater = layoutInflater, + title = getString(R.string.screen_creator_community_delete_title), + desc = getString(R.string.screen_creator_community_delete_desc), + confirmButtonTitle = getString(R.string.confirm_delete_title), + confirmButtonClick = { + deleteCreatorChannelCommunityPost(item) + }, + cancelButtonTitle = getString(R.string.cancel), + cancelButtonClick = {} + ).show(screenWidth) + } + + private fun updateCreatorChannelCommunityPostFixed(item: CreatorChannelCommunityPostUiModel) { + compositeDisposable.add( + creatorCommunityRepository.updateCommunityPostFixed( + postId = item.postId, + isFixed = !item.isPinned, + token = authToken() + ) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { response -> + if (response.success) { + refreshCreatorChannelCommunity() + } else { + response.message?.let(::showToast) + } + }, + { error -> error.message?.let(::showToast) } + ) + ) + } + + private fun deleteCreatorChannelCommunityPost(item: CreatorChannelCommunityPostUiModel) { + val request = ModifyCommunityPostRequest( + creatorCommunityId = item.postId, + isActive = false + ) + val requestJson = Gson().toJson(request) + compositeDisposable.add( + creatorCommunityRepository.modifyCommunityPost( + postImage = null, + request = requestJson.toRequestBody("text/plain".toMediaType()), + token = authToken() + ) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { response -> + if (response.success) { + refreshCreatorChannelCommunity() + } else { + response.message?.let(::showToast) + } + }, + { error -> error.message?.let(::showToast) } + ) + ) + } + + private fun authToken(): String = "Bearer ${SharedPreferenceManager.token}" + + private fun refreshCreatorChannelCommunity() { + findCommunityFragment()?.onCreatorChannelCommunityRefreshRequested() + } + private fun setupOwnerFabInsets() { binding.viewPager.updatePadding(bottom = OWNER_FAB_CONTENT_BOTTOM_PADDING_DP.dpToPx().toInt()) } @@ -517,10 +641,17 @@ class CreatorChannelActivity : iconResId = R.drawable.ic_new_upload_audio, textResId = R.string.creator_channel_audio_upload_button ) + CreatorChannelTab.Community -> bindOwnerCta( + iconResId = R.drawable.ic_new_upload_community_post, + textResId = R.string.creator_channel_owner_fab_community + ) else -> Unit } findLiveFragment()?.onCreatorChannelLiveOwnerCtaVisibilityChanged(ownerCtaTab == CreatorChannelTab.Live) findAudioFragment()?.onCreatorChannelAudioOwnerCtaVisibilityChanged(ownerCtaTab == CreatorChannelTab.Audio) + findCommunityFragment()?.onCreatorChannelCommunityOwnerCtaVisibilityChanged( + ownerCtaTab == CreatorChannelTab.Community + ) } private fun bindOwnerCta(iconResId: Int, textResId: Int) { @@ -533,6 +664,7 @@ class CreatorChannelActivity : return when (binding.viewPager.currentItem) { CreatorChannelTab.Live.ordinal -> CreatorChannelTab.Live CreatorChannelTab.Audio.ordinal -> CreatorChannelTab.Audio + CreatorChannelTab.Community.ordinal -> CreatorChannelTab.Community else -> null } } @@ -630,6 +762,7 @@ class CreatorChannelActivity : when (binding.viewPager.currentItem) { CreatorChannelTab.Live.ordinal -> onOwnerFabLiveClicked() CreatorChannelTab.Audio.ordinal -> onOwnerFabAudioClicked() + CreatorChannelTab.Community.ordinal -> onOwnerFabCommunityClicked() } } @@ -675,18 +808,25 @@ class CreatorChannelActivity : return supportFragmentManager.findFragmentByTag(fragmentTag) as? CreatorChannelSeriesFragment } + private fun findCommunityFragment(): CreatorChannelCommunityFragment? { + val fragmentTag = "f${CreatorChannelTab.Community.ordinal}" + return supportFragmentManager.findFragmentByTag(fragmentTag) as? CreatorChannelCommunityFragment + } + private fun notifyCurrentCreatorChannelTabScrolledToBottom() { when (binding.viewPager.currentItem) { CreatorChannelTab.Live.ordinal -> findLiveFragment()?.onCreatorChannelLiveScrolledToBottom() CreatorChannelTab.Audio.ordinal -> findAudioFragment()?.onCreatorChannelAudioScrolledToBottom() CreatorChannelTab.Series.ordinal -> findSeriesFragment()?.onCreatorChannelSeriesScrolledToBottom() + CreatorChannelTab.Community.ordinal -> findCommunityFragment()?.onCreatorChannelCommunityScrolledToBottom() } } private fun isCreatorChannelLoadMoreTab(position: Int): Boolean { return position == CreatorChannelTab.Live.ordinal || position == CreatorChannelTab.Audio.ordinal || - position == CreatorChannelTab.Series.ordinal + position == CreatorChannelTab.Series.ordinal || + position == CreatorChannelTab.Community.ordinal } private fun ensureLoginAndAdultAuth(isAdult: Boolean, onAuthed: () -> Unit) { diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/community/CreatorChannelCommunityFragment.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/community/CreatorChannelCommunityFragment.kt index a968215b..c12b833c 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/community/CreatorChannelCommunityFragment.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/community/CreatorChannelCommunityFragment.kt @@ -96,6 +96,10 @@ class CreatorChannelCommunityFragment : BaseFragment CreatorChannelAudioFragment.newInstance(creatorId)")) assertTrue(pagerAdapter.contains("CreatorChannelTab.Series -> CreatorChannelSeriesFragment.newInstance(creatorId)")) - assertFalse(source.contains("CreatorChannelTab.Community ->")) assertFalse(source.contains("CreatorChannelTab.FanTalk ->")) assertFalse(source.contains("CreatorChannelTab.Donation ->")) } @@ -440,6 +439,60 @@ class CreatorChannelActivitySourceTest { assertTrue(source.contains("putExtra(Constants.EXTRA_SERIES_ID, seriesId)")) } + @Test + fun `Community tab source는 Fragment Host pagination owner CTA를 Activity에 연결한다`() { + val source = projectFile( + "app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelActivity.kt" + ).readText() + val adapter = projectFile( + "app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelPagerAdapter.kt" + ).readText() + val fragment = projectFile( + "app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/community/CreatorChannelCommunityFragment.kt" + ).readText() + + assertTrue(adapter.contains("CreatorChannelCommunityFragment.newInstance(creatorId)")) + assertTrue(source.contains("CreatorChannelCommunityFragment.Host")) + assertTrue( + source.contains( + "import kr.co.vividnext.sodalive.v2.creator.channel.community.CreatorChannelCommunityFragment" + ) + ) + assertTrue(source.contains("private fun findCommunityFragment(): CreatorChannelCommunityFragment?")) + assertTrue(source.contains("findCommunityFragment()?.onCreatorChannelCommunityTabSelected()")) + assertTrue(source.contains("if (binding.viewPager.currentItem == CreatorChannelTab.Community.ordinal)")) + assertTrue(source.contains("findCommunityFragment()?.onCreatorChannelCommunityScrolledToBottom()")) + assertTrue(source.contains("position == CreatorChannelTab.Community.ordinal")) + assertTrue(source.contains("override fun onCreatorChannelCommunityContentChanged()")) + assertTrue(source.contains("findCommunityFragment()?.onCreatorChannelCommunityOwnerCtaVisibilityChanged(")) + assertTrue(source.contains("ownerCtaTab == CreatorChannelTab.Community")) + assertTrue(source.contains("CreatorChannelTab.Community.ordinal -> CreatorChannelTab.Community")) + assertTrue(source.contains("iconResId = R.drawable.ic_new_upload_community_post")) + assertTrue(source.contains("textResId = R.string.creator_channel_owner_fab_community")) + assertTrue(source.contains("CreatorChannelTab.Community.ordinal -> onOwnerFabCommunityClicked()")) + assertTrue(source.contains("private val communityPostModifyLauncher")) + assertTrue(source.contains("CreatorCommunityModifyActivity::class.java")) + assertTrue(source.contains("putExtra(Constants.EXTRA_COMMUNITY_POST_ID, item.postId)")) + assertTrue(source.contains("creatorCommunityRepository.updateCommunityPostFixed(")) + assertTrue(source.contains("isFixed = !item.isPinned")) + assertTrue(source.contains("creatorCommunityRepository.modifyCommunityPost(")) + assertTrue(source.contains("isActive = false")) + assertTrue(source.contains("findCommunityFragment()?.onCreatorChannelCommunityRefreshRequested()")) + assertFalse(source.contains("onCreatorChannelCommunityOwnerMoreClicked(postId: Long)")) + assertFalse(fragment.contains("onCreatorChannelCommunityOwnerMoreClicked(item.postId)")) + assertFalse(source.contains("onClickPin = {},")) + assertFalse(source.contains("onClickModify = {},")) + assertFalse(source.contains("onClickDelete = {},")) + assertTrue(fragment.contains("fun onCreatorChannelCommunityRefreshRequested()")) + assertTrue( + fragment.contains( + "CreatorCommunityMediaPlayerManager(requireContext()) { listAdapter.notifyDataSetChanged() }" + ) + ) + assertTrue(fragment.contains("mediaPlayerManager?.toggleContent(CreatorCommunityContentItem(item.postId, audioUrl))")) + assertTrue(fragment.contains("mediaPlayerManager?.stopContent()")) + } + @Test fun `section adapter source는 활동 지표를 행 단위 resource label로 표시한다`() { val adapter = projectFile(