fix(content): 상세 오디오와 구매 흐름을 보정한다

This commit is contained in:
2026-07-12 08:32:48 +09:00
parent 53cc96b84d
commit 39959c3f47
5 changed files with 139 additions and 2 deletions

View File

@@ -16,6 +16,7 @@ import com.bumptech.glide.Glide
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.base.BaseActivity
import kr.co.vividnext.sodalive.databinding.ActivityCreatorChannelCommunityDetailBinding
import kr.co.vividnext.sodalive.explorer.profile.creator_community.all.PurchaseCommunityPostDialog
import kr.co.vividnext.sodalive.explorer.profile.creator_community.all.player.CreatorCommunityContentItem
import kr.co.vividnext.sodalive.explorer.profile.creator_community.all.player.CreatorCommunityMediaPlayerManager
import kr.co.vividnext.sodalive.extensions.loadUrl
@@ -46,7 +47,7 @@ class CreatorChannelCommunityDetailActivity : BaseActivity<ActivityCreatorChanne
private var renderedEditingCommentId: Long? = null
override fun setupView() {
mediaPlayerManager = CreatorCommunityMediaPlayerManager(this) { }
mediaPlayerManager = CreatorCommunityMediaPlayerManager(this) { renderCurrentAudioButton() }
postId = intent.getLongExtra(EXTRA_POST_ID, 0L)
if (postId <= 0L) {
Toast.makeText(this, R.string.creator_channel_community_invalid_post, Toast.LENGTH_SHORT).show()
@@ -154,6 +155,12 @@ class CreatorChannelCommunityDetailActivity : BaseActivity<ActivityCreatorChanne
layoutCreatorChannelCommunityDetailImageContainer.isVisible = post.imageUrl != null || post.showPaywall
layoutCreatorChannelCommunityDetailPaywall.isVisible = post.showPaywall
tvCreatorChannelCommunityDetailPaywallPrice.text = post.price.moneyFormat()
layoutCreatorChannelCommunityDetailPaywall.setOnClickListener {
showPurchaseDialog(post)
}
tvCreatorChannelCommunityDetailPaywallPrice.setOnClickListener {
showPurchaseDialog(post)
}
if (post.imageUrl != null) {
Glide.with(ivCreatorChannelCommunityDetailImage)
.load(post.imageUrl)
@@ -162,11 +169,14 @@ class CreatorChannelCommunityDetailActivity : BaseActivity<ActivityCreatorChanne
} else {
Glide.with(ivCreatorChannelCommunityDetailImage).clear(ivCreatorChannelCommunityDetailImage)
}
tvCreatorChannelCommunityDetailAudio.isVisible = post.audioUrl != null
ivCreatorChannelCommunityDetailPlayOrPause.isVisible = post.audioUrl != null && post.imageUrl != null
ivCreatorChannelCommunityDetailPlayOrPause.setOnClickListener { toggleAudio(post) }
tvCreatorChannelCommunityDetailAudio.isVisible = post.audioUrl != null && post.imageUrl == null
tvCreatorChannelCommunityDetailAudio.text = getString(R.string.creator_channel_tab_audio)
tvCreatorChannelCommunityDetailAudio.setOnClickListener {
toggleAudio(post)
}
renderAudioButton(post)
rvCreatorChannelCommunityDetailComments.isVisible = content.isCommentAvailable
tvCreatorChannelCommunityDetailCommentHeader.isVisible = content.isCommentAvailable
viewCreatorChannelCommunityDetailCommentDivider.isVisible = content.isCommentAvailable
@@ -201,6 +211,32 @@ class CreatorChannelCommunityDetailActivity : BaseActivity<ActivityCreatorChanne
private fun toggleAudio(post: CreatorChannelCommunityPostDetailUiModel) {
val audioUrl = post.audioUrl ?: return
mediaPlayerManager.toggleContent(CreatorCommunityContentItem(post.postId, audioUrl))
renderAudioButton(post)
}
private fun renderCurrentAudioButton() {
val content = viewModel.detailStateLiveData.value as? CreatorChannelCommunityDetailUiState.Content ?: return
renderAudioButton(content.post)
}
private fun renderAudioButton(post: CreatorChannelCommunityPostDetailUiModel) {
binding.ivCreatorChannelCommunityDetailPlayOrPause.setImageResource(
if (mediaPlayerManager.isPlayingContent(post.postId)) {
R.drawable.btn_audio_content_pause
} else {
R.drawable.btn_audio_content_play
}
)
}
private fun showPurchaseDialog(post: CreatorChannelCommunityPostDetailUiModel) {
if (!post.isLocked) return
PurchaseCommunityPostDialog(
activity = this,
layoutInflater = layoutInflater,
can = post.price,
confirmButtonClick = { viewModel.purchaseCommunityPost() }
).show(screenWidth)
}
private fun showDeleteCommentDialog(commentId: Long) {

View File

@@ -48,6 +48,7 @@ class CreatorChannelCommunityDetailViewModel(
private var isTogglingLike = false
private var isSendingComment = false
private var isModifyingComment = false
private var isPurchasingPost = false
fun loadDetail(postId: Long) {
if (postId <= 0) return
@@ -217,6 +218,33 @@ class CreatorChannelCommunityDetailViewModel(
modifyComment(ModifyCommentRequest(commentId = commentId, isActive = false))
}
fun purchaseCommunityPost() {
val content = _detailStateLiveData.value as? CreatorChannelCommunityDetailUiState.Content ?: return
if (!content.post.isLocked || isPurchasingPost) return
isPurchasingPost = true
compositeDisposable.add(
legacyRepository.purchaseCommunityPost(postId, authToken())
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{ response ->
isPurchasingPost = false
if (response.success) {
_postChangedEventLiveData.value = true
loadDetail(postId)
} else {
showMutationFailureToast(response.message)
}
},
{
isPurchasingPost = false
showMutationFailureToast()
}
)
)
}
private fun submitCommentEdit(
content: CreatorChannelCommunityDetailUiState.Content,
commentId: Long,

View File

@@ -149,6 +149,16 @@
android:contentDescription="@string/a11y_feed_content_image"
android:scaleType="centerCrop" />
<ImageView
android:id="@+id/iv_creator_channel_community_detail_play_or_pause"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:contentDescription="@null"
android:src="@drawable/btn_audio_content_play"
android:visibility="gone"
tools:visibility="visible" />
<LinearLayout
android:id="@+id/layout_creator_channel_community_detail_paywall"
android:layout_width="match_parent"

View File

@@ -33,6 +33,7 @@ class CreatorChannelCommunityDetailReplyUiContractTest {
assertNotNull(detail.findViewById<RecyclerView>(R.id.rv_creator_channel_community_detail_comments))
assertNotNull(detail.findViewById<View>(R.id.layout_creator_channel_community_detail_paywall))
assertNotNull(detail.findViewById<TextView>(R.id.tv_creator_channel_community_detail_paywall_price))
assertNotNull(detail.findViewById<ImageView>(R.id.iv_creator_channel_community_detail_play_or_pause))
assertNotNull(detail.findViewById<View>(R.id.layout_creator_channel_community_detail_input_bar))
assertNotNull(detail.findViewById<EditText>(R.id.et_creator_channel_community_detail_comment))
assertNotNull(detail.findViewById<ImageButton>(R.id.btn_creator_channel_community_detail_send))
@@ -138,6 +139,23 @@ class CreatorChannelCommunityDetailReplyUiContractTest {
assertTrue(!replyActivity.contains("AlertDialog"))
}
@Test
fun `상세 Activity는 레거시 구매 다이얼로그와 이미지 중앙 오디오 버튼을 사용한다`() {
val detailLayout = projectFile("app/src/main/res/layout/activity_creator_channel_community_detail.xml").readText()
val detailActivity = projectFile(
"app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/community/detail/" +
"CreatorChannelCommunityDetailActivity.kt"
).readText()
assertTrue(detailLayout.contains("@+id/iv_creator_channel_community_detail_play_or_pause"))
assertTrue(detailLayout.contains("android:layout_gravity=\"center\""))
assertTrue(detailLayout.contains("@drawable/btn_audio_content_play"))
assertTrue(detailActivity.contains("PurchaseCommunityPostDialog"))
assertTrue(detailActivity.contains("viewModel.purchaseCommunityPost()"))
assertTrue(detailActivity.contains("btn_audio_content_pause"))
assertTrue(detailActivity.contains("btn_audio_content_play"))
}
@Test
fun `댓글 답글 Adapter는 PopupMenu 대신 regular font popup을 사용한다`() {
val commentAdapter = projectFile(

View File

@@ -394,6 +394,51 @@ class CreatorChannelCommunityDetailViewModelTest {
assertFalse(state.post.showPaywall)
}
@Test
fun `게시물 구매 성공은 상세를 다시 조회해 잠금 상태를 해제한다`() {
whenever(repository.getCommunityPostDetail(POST_ID, AUTH_TOKEN))
.thenReturn(
Single.just(
ApiResponse(
true,
detailResponse(
isCommentAvailable = false,
price = 300,
existOrdered = false,
creatorId = 999L
),
null
)
),
Single.just(
ApiResponse(
true,
detailResponse(
isCommentAvailable = false,
price = 300,
existOrdered = true,
creatorId = 999L
),
null
)
)
)
whenever(legacyRepository.purchaseCommunityPost(POST_ID, AUTH_TOKEN))
.thenReturn(Single.just(ApiResponse(true, org.mockito.kotlin.mock(), null)))
viewModel.loadDetail(POST_ID)
viewModel.purchaseCommunityPost()
val state = viewModel.detailStateLiveData.requireValue() as CreatorChannelCommunityDetailUiState.Content
assertFalse(state.post.isLocked)
assertEquals("image.png", state.post.imageUrl)
assertEquals("audio.mp3", state.post.audioUrl)
assertTrue(state.post.existOrdered)
assertTrue(viewModel.postChangedEventLiveData.requireValue() == true)
verify(legacyRepository).purchaseCommunityPost(POST_ID, AUTH_TOKEN)
verify(repository, times(2)).getCommunityPostDetail(POST_ID, AUTH_TOKEN)
}
@Test
fun `댓글 UI 모델은 답글 화면 재포맷을 위해 원본 createdAtUtc를 보존한다`() {
stubDetail(detailResponse(isCommentAvailable = true, commentIds = listOf(11L)))