feat(creator): 커뮤니티 구매와 메뉴 동작을 정리한다

This commit is contained in:
2026-08-11 00:10:46 +09:00
parent 02cd583f56
commit 8b8b384b29
31 changed files with 1055 additions and 410 deletions

View File

@@ -6,12 +6,4 @@ sealed interface CommunityChange {
data object Created : CommunityChange
data object Updated : CommunityChange
data class Deleted(
val postId: Long
) : CommunityChange
data class PinChanged(
val postId: Long
) : CommunityChange
}

View File

@@ -33,6 +33,7 @@ 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.all.CreatorCommunityReportDialog
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
@@ -60,8 +61,9 @@ import kr.co.vividnext.sodalive.v2.components.modal.V2ModalDialog
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.community.model.CreatorChannelCommunityMenuItem
import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelAudioContentResponse
import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelRepository
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
@@ -96,6 +98,7 @@ class CreatorChannelActivity :
private val liveViewModel: LiveViewModel by inject()
private val creatorCommunityRepository: CreatorCommunityRepository by inject()
private val creatorChannelRepository: CreatorChannelRepository by inject()
private var creatorId: Long = 0L
private var currentHeader: CreatorChannelHeaderUiModel? = null
private var homeActionDelegate: CreatorChannelHomeFragment.HomeActionDelegate? = null
@@ -107,6 +110,7 @@ class CreatorChannelActivity :
private var isOwnerFabAnimating: Boolean = false
private var isDonationFloatingButtonVisible: Boolean = false
private var isFanTalkFixedPlusVisible: Boolean = false
private var isCommunityPostMutating: Boolean = false
private lateinit var loadingDialog: LoadingDialog
private val baseTitleBarHeight: Int by lazy { 60.dpToPx().toInt() }
private val liveActionCoordinator: LiveActionCoordinator by lazy {
@@ -126,13 +130,6 @@ class CreatorChannelActivity :
resolveCommunityActivityResult(CommunityActivityResultSource.Write, result.resultCode)
)
}
private val communityPostModifyLauncher = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { result ->
handleCommunityChange(
resolveCommunityActivityResult(CommunityActivityResultSource.Modify, result.resultCode)
)
}
private val communityDetailLauncher = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { result ->
@@ -140,6 +137,13 @@ class CreatorChannelActivity :
resolveCommunityActivityResult(CommunityActivityResultSource.Detail, result.resultCode)
)
}
private val communityModifyLauncher = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { result ->
handleCommunityChange(
resolveCommunityActivityResult(CommunityActivityResultSource.Modify, result.resultCode)
)
}
private val fanTalkWriteLauncher = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { result ->
@@ -627,6 +631,142 @@ class CreatorChannelActivity :
handleCommunityAction(CommunityActionCommand.PostDetail(postId), communityDetailLauncher::launch)
}
override fun onCreatorChannelCommunityMoreClicked(item: CreatorChannelCommunityMenuItem) {
if (!item.showMore) return
CreatorCommunityPostMenuBottomSheetDialog(
isFixed = item.isPinned,
isCreator = item.isOwner,
onClickPin = { updateCreatorChannelCommunityPostFixed(item) },
onClickModify = {
if (item.isOwner) {
communityModifyLauncher.launch(
Intent(this, CreatorCommunityModifyActivity::class.java).apply {
putExtra(Constants.EXTRA_COMMUNITY_POST_ID, item.postId)
}
)
}
},
onClickDelete = { showDeleteCreatorChannelCommunityPostDialog(item) },
onClickReport = {
CreatorCommunityReportDialog(
this,
layoutInflater
) { reason -> reportCreatorChannelCommunityPost(item, reason) }.show(screenWidth)
}
).show(supportFragmentManager, CreatorCommunityPostMenuBottomSheetDialog::class.java.simpleName)
}
private fun updateCreatorChannelCommunityPostFixed(item: CreatorChannelCommunityMenuItem) {
if (!item.isOwner || isCommunityPostMutating) return
isCommunityPostMutating = true
compositeDisposable.add(
creatorCommunityRepository.updateCommunityPostFixed(item.postId, !item.isPinned, authToken())
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{ response ->
isCommunityPostMutating = false
if (response.success) {
handleCommunityChange(CommunityChange.Updated)
} else {
showCommunityMutationFailureToast(response.message)
}
},
{
isCommunityPostMutating = false
showCommunityMutationFailureToast()
}
)
)
}
private fun showDeleteCreatorChannelCommunityPostDialog(item: CreatorChannelCommunityMenuItem) {
if (!item.isOwner) return
V2ModalDialog(
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)
).show(screenWidth)
}
private fun deleteCreatorChannelCommunityPost(item: CreatorChannelCommunityMenuItem) {
if (!item.isOwner || isCommunityPostMutating) return
val request = Gson().toJson(
ModifyCommunityPostRequest(
creatorCommunityId = item.postId,
isActive = false
)
).toRequestBody("text/plain".toMediaType())
isCommunityPostMutating = true
compositeDisposable.add(
creatorCommunityRepository.modifyCommunityPost(postImage = null, request = request, token = authToken())
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{ response ->
isCommunityPostMutating = false
if (response.success) {
handleCommunityChange(CommunityChange.Updated)
} else {
showCommunityMutationFailureToast(response.message)
}
},
{
isCommunityPostMutating = false
showCommunityMutationFailureToast()
}
)
)
}
private fun reportCreatorChannelCommunityPost(
item: CreatorChannelCommunityMenuItem,
reason: String
) {
if (item.isOwner || !item.showMore || reason.isBlank() || isCommunityPostMutating) return
isCommunityPostMutating = true
compositeDisposable.add(
creatorChannelRepository.reportCommunityPost(item.postId, reason, authToken())
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{ response ->
isCommunityPostMutating = false
if (response.success) {
showToast(
if (response.message.isNullOrBlank()) {
getString(R.string.character_comment_report_submitted)
} else {
response.message
}
)
} else {
showCommunityMutationFailureToast(response.message)
}
},
{
isCommunityPostMutating = false
showCommunityMutationFailureToast()
}
)
)
}
private fun showCommunityMutationFailureToast(message: String? = null) {
showToast(message.takeUnless { it.isNullOrBlank() } ?: getString(R.string.common_error_unknown))
}
private fun authToken(): String = "Bearer ${SharedPreferenceManager.token}"
override fun onCreatorChannelFanTalkContentChanged() {
updateViewPagerHeight {
postCheckCreatorChannelCurrentTabNeedsMore()
@@ -697,93 +837,6 @@ class CreatorChannelActivity :
).show(screenWidth)
}
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) {
V2ModalDialog(
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) {
handleCommunityChange(CommunityChange.PinChanged(item.postId))
} 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) {
handleCommunityChange(CommunityChange.Deleted(item.postId))
} else {
response.message?.let(::showToast)
}
},
{ error -> error.message?.let(::showToast) }
)
)
}
private fun authToken(): String = "Bearer ${SharedPreferenceManager.token}"
private fun refreshCreatorChannelCommunity() {
findCommunityFragment()?.onCreatorChannelCommunityRefreshRequested()
}
@@ -795,9 +848,10 @@ class CreatorChannelActivity :
homeActionDelegate?.refreshHome()
refreshCreatorChannelCommunity()
}
CommunityChange.Updated,
is CommunityChange.Deleted,
is CommunityChange.PinChanged -> refreshCreatorChannelCommunity()
CommunityChange.Updated -> {
homeActionDelegate?.refreshHome()
refreshCreatorChannelCommunity()
}
}
}

View File

@@ -10,6 +10,7 @@ import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelAudioConte
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.community.model.CreatorChannelCommunityMenuItem
import kr.co.vividnext.sodalive.v2.creator.channel.model.CreatorChannelHeaderUiModel
import kr.co.vividnext.sodalive.v2.creator.channel.model.CreatorChannelHomeUiState
import kr.co.vividnext.sodalive.v2.creator.channel.model.CreatorChannelTab
@@ -28,7 +29,8 @@ class CreatorChannelHomeFragment : BaseFragment<FragmentCreatorChannelHomeBindin
onSeriesClick = ::onSeriesClicked,
onDonationClick = ::onDonationClicked,
onSectionChevronClick = ::onSectionChevronClicked,
onCommunityClick = ::onCommunityClicked
onCommunityClick = ::onCommunityClicked,
onCommunityMoreClick = ::onCommunityMoreClicked
)
private val creatorId: Long by lazy { arguments?.getLong(ARG_CREATOR_ID) ?: 0L }
private val host: Host
@@ -134,6 +136,10 @@ class CreatorChannelHomeFragment : BaseFragment<FragmentCreatorChannelHomeBindin
host.onCreatorChannelCommunityPostClicked(postId)
}
private fun onCommunityMoreClicked(item: CreatorChannelCommunityMenuItem) {
host.onCreatorChannelCommunityMoreClicked(item)
}
private fun onCurrentLiveClicked(live: CreatorChannelLiveResponse) {
host.onCreatorChannelCurrentLiveClicked(live)
}
@@ -150,6 +156,7 @@ class CreatorChannelHomeFragment : BaseFragment<FragmentCreatorChannelHomeBindin
fun onCreatorChannelDonationClicked()
fun onCreatorChannelHomeTabRequested(tab: CreatorChannelTab)
fun onCreatorChannelCommunityPostClicked(postId: Long)
fun onCreatorChannelCommunityMoreClicked(item: CreatorChannelCommunityMenuItem)
fun onCreatorChannelCurrentLiveClicked(live: CreatorChannelLiveResponse)
}

View File

@@ -11,11 +11,9 @@ import androidx.recyclerview.widget.LinearLayoutManager
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.base.BaseFragment
import kr.co.vividnext.sodalive.databinding.FragmentCreatorChannelCommunityBinding
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.dpToPx
import kr.co.vividnext.sodalive.extensions.moneyFormat
import kr.co.vividnext.sodalive.v2.creator.channel.community.model.CreatorChannelCommunityPostUiModel
import kr.co.vividnext.sodalive.v2.creator.channel.community.model.CreatorChannelCommunityMenuItem
import kr.co.vividnext.sodalive.v2.creator.channel.community.model.CreatorChannelCommunityViewMode
import kr.co.vividnext.sodalive.v2.creator.channel.community.ui.CreatorChannelCommunityGridAdapter
import kr.co.vividnext.sodalive.v2.creator.channel.community.ui.calculateCreatorChannelCommunityGridItemSize
@@ -29,14 +27,11 @@ class CreatorChannelCommunityFragment : BaseFragment<FragmentCreatorChannelCommu
private val viewModel: CreatorChannelCommunityViewModel by viewModel()
private val listAdapter = CreatorChannelCommunityListAdapter(
onPostClick = { item -> host.onCreatorChannelCommunityPostClicked(item.postId) },
onPlayClick = { item -> toggleCommunityAudio(item) },
onOwnerMoreClick = { item -> host.onCreatorChannelCommunityOwnerMoreClicked(item) },
isPlayingContent = { postId -> mediaPlayerManager?.isPlayingContent(postId) == true }
onMoreClick = { item -> host.onCreatorChannelCommunityMoreClicked(item) }
)
private val gridAdapter = CreatorChannelCommunityGridAdapter(
onPostClick = { item -> host.onCreatorChannelCommunityPostClicked(item.postId) }
)
private var mediaPlayerManager: CreatorCommunityMediaPlayerManager? = null
private var currentContentState: CreatorChannelCommunityUiState.Content? = null
private var lastContentLayoutKey: CreatorChannelCommunityContentLayoutKey? = null
private val creatorId: Long by lazy { arguments?.getLong(ARG_CREATOR_ID) ?: 0L }
@@ -45,7 +40,6 @@ class CreatorChannelCommunityFragment : BaseFragment<FragmentCreatorChannelCommu
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
mediaPlayerManager = CreatorCommunityMediaPlayerManager(requireContext()) { listAdapter.notifyDataSetChanged() }
bindLoading()
setupCommunityList()
setupClickListeners()
@@ -53,19 +47,12 @@ class CreatorChannelCommunityFragment : BaseFragment<FragmentCreatorChannelCommu
}
override fun onDestroyView() {
mediaPlayerManager?.stopContent()
mediaPlayerManager = null
currentContentState = null
lastContentLayoutKey = null
binding.rvCreatorChannelCommunity.adapter = null
super.onDestroyView()
}
override fun onPause() {
mediaPlayerManager?.pauseContent()
super.onPause()
}
private fun setupCommunityList() = with(binding.rvCreatorChannelCommunity) {
layoutManager = LinearLayoutManager(requireContext())
adapter = listAdapter
@@ -220,16 +207,11 @@ class CreatorChannelCommunityFragment : BaseFragment<FragmentCreatorChannelCommu
layoutCreatorChannelCommunityEmpty.updatePadding(bottom = bottomPadding)
}
private fun toggleCommunityAudio(item: CreatorChannelCommunityPostUiModel) {
val audioUrl = item.audioUrl ?: return
mediaPlayerManager?.toggleContent(CreatorCommunityContentItem(item.postId, audioUrl))
}
interface Host {
fun isCreatorChannelOwner(): Boolean
fun onCreatorChannelCommunityContentChanged()
fun onCreatorChannelCommunityPostClicked(postId: Long)
fun onCreatorChannelCommunityOwnerMoreClicked(item: CreatorChannelCommunityPostUiModel)
fun onCreatorChannelCommunityMoreClicked(item: CreatorChannelCommunityMenuItem)
}
companion object {

View File

@@ -23,10 +23,14 @@ import com.bumptech.glide.request.RequestListener
import com.bumptech.glide.request.target.Target
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.base.BaseActivity
import kr.co.vividnext.sodalive.common.Constants
import kr.co.vividnext.sodalive.databinding.ActivityCreatorChannelCommunityDetailBinding
import kr.co.vividnext.sodalive.explorer.profile.creator_community.all.CreatorCommunityPostMenuBottomSheetDialog
import kr.co.vividnext.sodalive.explorer.profile.creator_community.all.CreatorCommunityReportDialog
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.explorer.profile.creator_community.modify.CreatorCommunityModifyActivity
import kr.co.vividnext.sodalive.extensions.loadUrl
import kr.co.vividnext.sodalive.extensions.moneyFormat
import kr.co.vividnext.sodalive.v2.components.modal.V2ModalDialog
@@ -40,6 +44,12 @@ class CreatorChannelCommunityDetailActivity : BaseActivity<ActivityCreatorChanne
) {
private val viewModel: CreatorChannelCommunityDetailViewModel by viewModel()
private val modifyLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode == Activity.RESULT_OK && postId > 0L) {
setResult(Activity.RESULT_OK)
viewModel.loadDetail(postId)
}
}
private val replyLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode == Activity.RESULT_OK && postId > 0L) {
setResult(Activity.RESULT_OK)
@@ -65,6 +75,11 @@ class CreatorChannelCommunityDetailActivity : BaseActivity<ActivityCreatorChanne
}
binding.btnCreatorChannelCommunityDetailBack.setOnClickListener { finish() }
binding.btnCreatorChannelCommunityDetailMore.setOnClickListener {
val content = viewModel.detailStateLiveData.value as? CreatorChannelCommunityDetailUiState.Content
?: return@setOnClickListener
showPostMenu(content.post)
}
binding.layoutCreatorChannelCommunityDetailImageContainer.clipToOutline = true
binding.layoutCreatorChannelCommunityDetailImageContainer.outlineProvider = roundedOutlineProvider()
binding.rvCreatorChannelCommunityDetailComments.layoutManager = LinearLayoutManager(this)
@@ -103,6 +118,13 @@ class CreatorChannelCommunityDetailActivity : BaseActivity<ActivityCreatorChanne
viewModel.consumePostChangedEvent()
}
}
viewModel.postDeletedEventLiveData.observe(this) { deleted ->
if (deleted == true) {
setResult(Activity.RESULT_OK)
viewModel.consumePostDeletedEvent()
finish()
}
}
viewModel.toastLiveData.observe(this) { event ->
event.consume()?.let { toast ->
val message = toast.message ?: toast.resId?.let(::getString)
@@ -150,6 +172,7 @@ class CreatorChannelCommunityDetailActivity : BaseActivity<ActivityCreatorChanne
}
tvCreatorChannelCommunityDetailNickname.text = post.creatorNickname
tvCreatorChannelCommunityDetailTime.text = post.createdAtText
btnCreatorChannelCommunityDetailMore.isVisible = post.showMore
tvCreatorChannelCommunityDetailBody.text = post.content
layoutCreatorChannelCommunityDetailReaction.isVisible = post.showReaction || content.isCommentAvailable
tvCreatorChannelCommunityDetailLikeCount.text = post.likeCount.moneyFormat()
@@ -163,9 +186,6 @@ class CreatorChannelCommunityDetailActivity : BaseActivity<ActivityCreatorChanne
ivCreatorChannelCommunityDetailHeart.setImageResource(
if (post.isLiked) R.drawable.ic_feed_community_heart_fill else R.drawable.ic_feed_community_heart
)
layoutCreatorChannelCommunityDetailPrice.isVisible = post.isLocked
tvCreatorChannelCommunityDetailPrice.text = post.price.moneyFormat()
tvCreatorChannelCommunityDetailPurchased.isVisible = post.existOrdered && post.price > 0
layoutCreatorChannelCommunityDetailImageContainer.isVisible = post.imageUrl != null || post.showPaywall
layoutCreatorChannelCommunityDetailImageContainer.layoutParams =
layoutCreatorChannelCommunityDetailImageContainer.layoutParams.apply {
@@ -296,6 +316,37 @@ class CreatorChannelCommunityDetailActivity : BaseActivity<ActivityCreatorChanne
).show(screenWidth)
}
private fun showPostMenu(post: CreatorChannelCommunityPostDetailUiModel) {
CreatorCommunityPostMenuBottomSheetDialog(
isFixed = post.isPinned,
isCreator = post.isOwner,
onClickPin = viewModel::updateCommunityPostFixed,
onClickModify = {
modifyLauncher.launch(
Intent(this, CreatorCommunityModifyActivity::class.java).apply {
putExtra(Constants.EXTRA_COMMUNITY_POST_ID, post.postId)
}
)
},
onClickDelete = ::showDeletePostDialog,
onClickReport = {
CreatorCommunityReportDialog(this, layoutInflater, viewModel::reportCommunityPost).show(screenWidth)
}
).show(supportFragmentManager, CreatorCommunityPostMenuBottomSheetDialog::class.java.simpleName)
}
private fun showDeletePostDialog() {
V2ModalDialog(
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 = viewModel::deleteCommunityPost,
cancelButtonTitle = getString(R.string.cancel)
).show(screenWidth)
}
private fun showDeleteCommentDialog(commentId: Long) {
V2ModalDialog(
activity = this,

View File

@@ -2,6 +2,7 @@ package kr.co.vividnext.sodalive.v2.creator.channel.community.detail
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import com.google.gson.Gson
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.schedulers.Schedulers
import kr.co.vividnext.sodalive.R
@@ -12,12 +13,15 @@ import kr.co.vividnext.sodalive.common.SharedPreferenceManager
import kr.co.vividnext.sodalive.common.ToastMessage
import kr.co.vividnext.sodalive.common.UtcRelativeTimeTextFormatter
import kr.co.vividnext.sodalive.explorer.profile.creator_community.CreatorCommunityRepository
import kr.co.vividnext.sodalive.explorer.profile.creator_community.modify.ModifyCommunityPostRequest
import kr.co.vividnext.sodalive.v2.creator.channel.CreatorChannelEvent
import kr.co.vividnext.sodalive.v2.creator.channel.community.detail.data.CreatorChannelCommunityCommentResponse
import kr.co.vividnext.sodalive.v2.creator.channel.community.detail.data.CreatorChannelCommunityCommentsResponse
import kr.co.vividnext.sodalive.v2.creator.channel.community.detail.data.CreatorChannelCommunityPostDetailResponse
import kr.co.vividnext.sodalive.v2.creator.channel.community.detail.data.CreatorChannelCommunityReplyResponse
import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelRepository
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.RequestBody.Companion.toRequestBody
class CreatorChannelCommunityDetailViewModel(
private val repository: CreatorChannelRepository,
@@ -37,6 +41,10 @@ class CreatorChannelCommunityDetailViewModel(
val postChangedEventLiveData: LiveData<Boolean>
get() = _postChangedEventLiveData
private val _postDeletedEventLiveData = MutableLiveData<Boolean>()
val postDeletedEventLiveData: LiveData<Boolean>
get() = _postDeletedEventLiveData
private val _toastLiveData = MutableLiveData<CreatorChannelEvent<ToastMessage>>()
val toastLiveData: LiveData<CreatorChannelEvent<ToastMessage>>
get() = _toastLiveData
@@ -49,6 +57,7 @@ class CreatorChannelCommunityDetailViewModel(
private var isSendingComment = false
private var isModifyingComment = false
private var isPurchasingPost = false
private var isMutatingPost = false
fun loadDetail(postId: Long) {
if (postId <= 0) return
@@ -181,6 +190,10 @@ class CreatorChannelCommunityDetailViewModel(
_postChangedEventLiveData.value = false
}
fun consumePostDeletedEvent() {
_postDeletedEventLiveData.value = false
}
fun startCommentEdit(commentId: Long, comment: String) {
val content = _detailStateLiveData.value as? CreatorChannelCommunityDetailUiState.Content ?: return
if (commentId <= 0) return
@@ -245,6 +258,97 @@ class CreatorChannelCommunityDetailViewModel(
)
}
fun updateCommunityPostFixed() {
val content = _detailStateLiveData.value as? CreatorChannelCommunityDetailUiState.Content ?: return
if (!content.post.isOwner || isMutatingPost) return
isMutatingPost = true
compositeDisposable.add(
legacyRepository.updateCommunityPostFixed(postId, !content.post.isPinned, authToken())
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{ response ->
isMutatingPost = false
if (response.success) {
_postChangedEventLiveData.value = true
loadDetail(postId)
} else {
showMutationFailureToast(response.message)
}
},
{
isMutatingPost = false
showMutationFailureToast()
}
)
)
}
fun deleteCommunityPost() {
val content = _detailStateLiveData.value as? CreatorChannelCommunityDetailUiState.Content ?: return
if (!content.post.isOwner || isMutatingPost) return
val request = Gson().toJson(
ModifyCommunityPostRequest(
creatorCommunityId = postId,
isActive = false
)
).toRequestBody("text/plain".toMediaType())
isMutatingPost = true
compositeDisposable.add(
legacyRepository.modifyCommunityPost(postImage = null, request = request, token = authToken())
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{ response ->
isMutatingPost = false
if (response.success) {
_postDeletedEventLiveData.value = true
} else {
showMutationFailureToast(response.message)
}
},
{
isMutatingPost = false
showMutationFailureToast()
}
)
)
}
fun reportCommunityPost(reason: String) {
val content = _detailStateLiveData.value as? CreatorChannelCommunityDetailUiState.Content ?: return
if (reason.isBlank() || content.post.isOwner || !content.post.showMore || isMutatingPost) return
isMutatingPost = true
compositeDisposable.add(
repository.reportCommunityPost(postId, reason, authToken())
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{ response ->
isMutatingPost = false
if (response.success) {
_toastLiveData.value = CreatorChannelEvent(
if (response.message.isNullOrBlank()) {
ToastMessage(resId = R.string.character_comment_report_submitted)
} else {
ToastMessage(message = response.message)
}
)
} else {
showMutationFailureToast(response.message)
}
},
{
isMutatingPost = false
showMutationFailureToast()
}
)
)
}
private fun submitCommentEdit(
content: CreatorChannelCommunityDetailUiState.Content,
commentId: Long,
@@ -425,7 +529,9 @@ class CreatorChannelCommunityDetailViewModel(
)
}
private fun CreatorChannelCommunityPostDetailResponse.toUiModel() = CreatorChannelCommunityPostDetailUiModel(
private fun CreatorChannelCommunityPostDetailResponse.toUiModel(): CreatorChannelCommunityPostDetailUiModel {
val isOwner = creatorId == SharedPreferenceManager.userId
return CreatorChannelCommunityPostDetailUiModel(
postId = postId,
creatorId = creatorId,
creatorNickname = creatorNickname,
@@ -440,10 +546,13 @@ class CreatorChannelCommunityDetailViewModel(
commentCount = commentCount,
isLiked = isLiked,
isPinned = isPinned,
isOwner = isOwner,
showMore = isOwner || price == 0 || existOrdered,
isLocked = isLocked(),
showPaywall = isLocked(),
showReaction = !isLocked()
)
}
private fun CreatorChannelCommunityPostDetailResponse.isLocked(): Boolean {
return price > 0 && !existOrdered && creatorId != SharedPreferenceManager.userId
@@ -526,6 +635,8 @@ data class CreatorChannelCommunityPostDetailUiModel(
val commentCount: Int,
val isLiked: Boolean,
val isPinned: Boolean,
val isOwner: Boolean,
val showMore: Boolean,
val isLocked: Boolean,
val showPaywall: Boolean,
val showReaction: Boolean

View File

@@ -21,7 +21,6 @@ private fun CreatorChannelCommunityPostResponse.toCommunityPostUiModel(
val isLocked = price > 0 && !existOrdered && !isOwner
val showOwnerActions = isOwner && creatorId == currentUserId
val visibleImageUrl = imageUrl.takeUnless { isLocked }
val showPlayButton = !isLocked && !audioUrl.isNullOrBlank() && !visibleImageUrl.isNullOrBlank()
return CreatorChannelCommunityPostUiModel(
postId = postId,
creatorId = creatorId,
@@ -30,7 +29,6 @@ private fun CreatorChannelCommunityPostResponse.toCommunityPostUiModel(
createdAtText = relativeTimeTextFormatter.format(createdAtUtc),
content = content,
imageUrl = visibleImageUrl,
audioUrl = audioUrl,
price = price,
existOrdered = existOrdered,
likeCount = likeCount,
@@ -40,11 +38,17 @@ private fun CreatorChannelCommunityPostResponse.toCommunityPostUiModel(
showNotice = isPinned,
isPinned = isPinned,
isLocked = isLocked,
showOwnerMore = showOwnerActions,
showOwnerTopPrice = showOwnerActions && price > 0,
showPlayButton = showPlayButton,
gridPreviewText = content.toGridPreviewText(),
imageMode = toImageMode(isLocked, visibleImageUrl)
imageMode = toImageMode(isLocked, visibleImageUrl),
menuItem = CreatorChannelCommunityMenuItem(
postId = postId,
creatorId = creatorId,
currentUserId = currentUserId,
price = price,
existOrdered = existOrdered,
isPinned = isPinned
)
)
}

View File

@@ -0,0 +1,16 @@
package kr.co.vividnext.sodalive.v2.creator.channel.community.model
data class CreatorChannelCommunityMenuItem(
val postId: Long,
val creatorId: Long,
val currentUserId: Long,
val price: Int,
val existOrdered: Boolean,
val isPinned: Boolean
) {
val isOwner: Boolean
get() = creatorId == currentUserId
val showMore: Boolean
get() = isOwner || price == 0 || existOrdered
}

View File

@@ -32,7 +32,6 @@ data class CreatorChannelCommunityPostUiModel(
val createdAtText: String,
val content: String,
val imageUrl: String?,
val audioUrl: String?,
val price: Int,
val existOrdered: Boolean,
val likeCount: Int,
@@ -42,9 +41,8 @@ data class CreatorChannelCommunityPostUiModel(
val showNotice: Boolean,
val isPinned: Boolean,
val isLocked: Boolean,
val showOwnerMore: Boolean,
val showOwnerTopPrice: Boolean,
val showPlayButton: Boolean,
val gridPreviewText: String,
val imageMode: CreatorChannelCommunityImageMode
val imageMode: CreatorChannelCommunityImageMode,
val menuItem: CreatorChannelCommunityMenuItem
)

View File

@@ -19,14 +19,13 @@ import kr.co.vividnext.sodalive.databinding.ItemCreatorChannelCommunityListBindi
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.v2.creator.channel.community.model.CreatorChannelCommunityMenuItem
import kr.co.vividnext.sodalive.v2.creator.channel.community.model.CreatorChannelCommunityPostUiModel
import kr.co.vividnext.sodalive.v2.widget.feed.calculateFeedCommunityImageHeight
class CreatorChannelCommunityListAdapter(
private val onPostClick: (CreatorChannelCommunityPostUiModel) -> Unit = {},
private val onPlayClick: (CreatorChannelCommunityPostUiModel) -> Unit = {},
private val onOwnerMoreClick: (CreatorChannelCommunityPostUiModel) -> Unit = {},
private val isPlayingContent: (Long) -> Boolean = { false }
private val onMoreClick: (CreatorChannelCommunityMenuItem) -> Unit = {}
) : RecyclerView.Adapter<CreatorChannelCommunityListAdapter.ViewHolder>() {
private var items: List<CreatorChannelCommunityPostUiModel> = emptyList()
@@ -40,9 +39,7 @@ class CreatorChannelCommunityListAdapter(
return ViewHolder(
ItemCreatorChannelCommunityListBinding.inflate(LayoutInflater.from(parent.context), parent, false),
onPostClick,
onPlayClick,
onOwnerMoreClick,
isPlayingContent
onMoreClick
)
}
@@ -55,9 +52,7 @@ class CreatorChannelCommunityListAdapter(
class ViewHolder(
private val binding: ItemCreatorChannelCommunityListBinding,
private val onPostClick: (CreatorChannelCommunityPostUiModel) -> Unit,
private val onPlayClick: (CreatorChannelCommunityPostUiModel) -> Unit,
private val onOwnerMoreClick: (CreatorChannelCommunityPostUiModel) -> Unit,
private val isPlayingContent: (Long) -> Boolean
private val onMoreClick: (CreatorChannelCommunityMenuItem) -> Unit
) : RecyclerView.ViewHolder(binding.root) {
init {
@@ -86,7 +81,7 @@ class CreatorChannelCommunityListAdapter(
val visibleImageUrl = item.imageUrl.takeUnless { item.isLocked }
layoutCreatorChannelCommunityListImageContainer.isVisible =
visibleImageUrl != null || item.isLocked || item.showPlayButton
visibleImageUrl != null || item.isLocked
ivCreatorChannelCommunityListImage.isVisible = visibleImageUrl != null
resetCommunityImageHeight()
if (visibleImageUrl != null) {
@@ -119,19 +114,14 @@ class CreatorChannelCommunityListAdapter(
}
layoutCreatorChannelCommunityListLockedOverlay.isVisible = item.isLocked
ivCreatorChannelCommunityListLock.isVisible = item.isLocked
tvCreatorChannelCommunityListLockedPrice.isVisible = item.isLocked
layoutCreatorChannelCommunityListLockedPrice.isVisible = item.isLocked
tvCreatorChannelCommunityListLockedPrice.text = item.price.moneyFormat()
ivCreatorChannelCommunityListPlay.isVisible = item.showPlayButton
ivCreatorChannelCommunityListPlay.setImageResource(
if (isPlayingContent(item.postId)) R.drawable.ic_player_pause else R.drawable.ic_new_player_play
)
ivCreatorChannelCommunityListPlay.setOnClickListener { onPlayClick(item) }
layoutCreatorChannelCommunityListTopActions.isVisible = item.showOwnerMore || item.showOwnerTopPrice
layoutCreatorChannelCommunityListTopActions.isVisible = item.showOwnerTopPrice || item.menuItem.showMore
layoutCreatorChannelCommunityListTopPrice.isVisible = item.showOwnerTopPrice
tvCreatorChannelCommunityListTopPrice.text = item.price.moneyFormat()
ivCreatorChannelCommunityListOwnerMore.isVisible = item.showOwnerMore
ivCreatorChannelCommunityListOwnerMore.setOnClickListener { onOwnerMoreClick(item) }
btnCreatorChannelCommunityListMore.isVisible = item.menuItem.showMore
btnCreatorChannelCommunityListMore.setOnClickListener { onMoreClick(item.menuItem) }
}
private fun resetCommunityImageHeight() {

View File

@@ -103,7 +103,9 @@ data class CreatorChannelCommunityPostResponse(
@SerializedName("dateUtc") val dateUtc: String,
@SerializedName("existOrdered") val existOrdered: Boolean,
@SerializedName("likeCount") val likeCount: Int,
@SerializedName("commentCount") val commentCount: Int
@SerializedName("commentCount") val commentCount: Int,
@SerializedName("isPinned") val isPinned: Boolean,
@SerializedName("isCommentAvailable") val isCommentAvailable: Boolean
)
@Keep

View File

@@ -185,6 +185,11 @@ class CreatorChannelRepository(
token = token
)
fun reportCommunityPost(postId: Long, reason: String, token: String) = reportRepository.report(
request = ReportRequest(ReportType.COMMUNITY_POST, reason, communityPostId = postId),
token = token
)
fun deleteFanTalk(fanTalkId: Long, token: String) = explorerRepository.modifyCheers(
request = PutModifyCheersRequest(cheersId = fanTalkId, isActive = false),
token = token

View File

@@ -18,7 +18,8 @@ fun CreatorChannelHomeResponse.toUiContent(currentMemberId: Long): CreatorChanne
?.let { add(CreatorChannelHomeSection.Schedules(it)) }
audioContents.takeIf { it.isNotEmpty() }?.let { add(CreatorChannelHomeSection.AudioContents(it)) }
series.takeIf { it.isNotEmpty() }?.let { add(CreatorChannelHomeSection.Series(it)) }
communities.takeIf { it.isNotEmpty() }?.let { add(CreatorChannelHomeSection.Communities(it)) }
communities.takeIf { it.isNotEmpty() }
?.let { add(CreatorChannelHomeSection.Communities(it, currentMemberId)) }
add(CreatorChannelHomeSection.FanTalk(fanTalk))
introduce.takeIf { it.isNotBlank() }?.let { add(CreatorChannelHomeSection.Introduce(it)) }
add(CreatorChannelHomeSection.Activity(activity))

View File

@@ -57,7 +57,10 @@ sealed interface CreatorChannelHomeSection {
data class Schedules(val schedules: List<CreatorChannelScheduleResponse>) : CreatorChannelHomeSection
data class AudioContents(val audioContents: List<CreatorChannelAudioContentResponse>) : CreatorChannelHomeSection
data class Series(val series: List<CreatorChannelSeriesResponse>) : CreatorChannelHomeSection
data class Communities(val communities: List<CreatorChannelCommunityPostResponse>) : CreatorChannelHomeSection
data class Communities(
val communities: List<CreatorChannelCommunityPostResponse>,
val currentMemberId: Long
) : CreatorChannelHomeSection
data class FanTalk(val fanTalk: CreatorChannelFanTalkSummaryResponse) : CreatorChannelHomeSection
data class Introduce(val introduce: String) : CreatorChannelHomeSection
data class Activity(val activity: CreatorChannelActivityResponse) : CreatorChannelHomeSection

View File

@@ -25,6 +25,7 @@ import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelCommunityP
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.community.model.CreatorChannelCommunityMenuItem
import kr.co.vividnext.sodalive.v2.creator.channel.model.CreatorChannelHomeSection
import kr.co.vividnext.sodalive.v2.creator.channel.model.CreatorChannelTab
import kr.co.vividnext.sodalive.v2.widget.feed.FeedCommunityView
@@ -43,7 +44,8 @@ class CreatorChannelHomeSectionAdapter(
private val onSeriesClick: (CreatorChannelSeriesResponse) -> Unit = {},
private val onDonationClick: () -> Unit = {},
private val onSectionChevronClick: (CreatorChannelTab) -> Unit = {},
private val onCommunityClick: (Long) -> Unit = {}
private val onCommunityClick: (Long) -> Unit = {},
private val onCommunityMoreClick: (CreatorChannelCommunityMenuItem) -> Unit = {}
) : RecyclerView.Adapter<CreatorChannelHomeSectionAdapter.SectionViewHolder>() {
private var items: List<CreatorChannelHomeSection> = emptyList()
@@ -65,7 +67,8 @@ class CreatorChannelHomeSectionAdapter(
onSeriesClick,
onDonationClick,
onSectionChevronClick,
onCommunityClick
onCommunityClick,
onCommunityMoreClick
)
}
@@ -83,7 +86,8 @@ class CreatorChannelHomeSectionAdapter(
private val onSeriesClick: (CreatorChannelSeriesResponse) -> Unit,
private val onDonationClick: () -> Unit,
private val onSectionChevronClick: (CreatorChannelTab) -> Unit,
private val onCommunityClick: (Long) -> Unit
private val onCommunityClick: (Long) -> Unit,
private val onCommunityMoreClick: (CreatorChannelCommunityMenuItem) -> Unit
) : RecyclerView.ViewHolder(view) {
private val title: TextView? = view.findViewById(R.id.tv_section_title)
private val sectionTitleChevron: ImageView? = view.findViewById(R.id.iv_section_title_chevron)
@@ -366,6 +370,14 @@ class CreatorChannelHomeSectionAdapter(
communityMoreButton?.setOnClickListener { onSectionChevronClick(CreatorChannelTab.Community) }
val visibleCommunities = item.communities.take(MAX_COMMUNITY_ITEM_COUNT)
visibleCommunities.forEachIndexed { index, community ->
val menuItem = CreatorChannelCommunityMenuItem(
postId = community.postId,
creatorId = community.creatorId,
currentUserId = item.currentMemberId,
price = community.price,
existOrdered = community.existOrdered,
isPinned = community.isPinned
)
val communityWidthDp = calculateCreatorChannelCommunityCardWidthDp(
itemView.resources.configuration.screenWidthDp
)
@@ -381,7 +393,8 @@ class CreatorChannelHomeSectionAdapter(
)
)
setHideEmptyTextRows(true)
bind(community.toFeedCommunityItem())
bind(community.toFeedCommunityItem(showMore = menuItem.showMore))
setOnMoreClick { onCommunityMoreClick(menuItem) }
}
bindCommunityImages(row, community)
row.layoutParams = LinearLayout.LayoutParams(
@@ -443,7 +456,9 @@ class CreatorChannelHomeSectionAdapter(
}
}
private fun CreatorChannelCommunityPostResponse.toFeedCommunityItem(): FeedItem.Community = FeedItem.Community(
private fun CreatorChannelCommunityPostResponse.toFeedCommunityItem(
showMore: Boolean
): FeedItem.Community = FeedItem.Community(
feedId = postId.toString(),
creatorId = creatorId.toString(),
creatorName = creatorNickname,
@@ -458,7 +473,11 @@ class CreatorChannelHomeSectionAdapter(
audioUrl = audioUrl,
price = price,
existOrdered = existOrdered,
showKeyword = false
isPinned = isPinned,
isCommentAvailable = isCommentAvailable,
hideReactionWhenLocked = true,
showKeyword = false,
showMore = showMore
)
private fun bindIntroduce(item: CreatorChannelHomeSection.Introduce) {

View File

@@ -20,6 +20,8 @@ class FeedCommunityView @JvmOverloads constructor(
) : LinearLayout(context, attrs, defStyleAttr) {
private var profileImage: ImageView? = null
private var noticeRow: View? = null
private var moreButton: View? = null
private var creatorText: TextView? = null
private var createdAtText: TextView? = null
private var bodyText: TextView? = null
@@ -27,15 +29,21 @@ class FeedCommunityView @JvmOverloads constructor(
private var communityImage: ImageView? = null
private var paidOverlay: View? = null
private var priceText: TextView? = null
private var reactionRow: View? = null
private var commentIcon: ImageView? = null
private var commentCountText: TextView? = null
private var likeIcon: ImageView? = null
private var likeCountText: TextView? = null
private var currentItem: FeedItem.Community? = null
private var clickListener: ((FeedItem) -> Unit)? = null
private var moreClickListener: ((FeedItem.Community) -> Unit)? = null
private var hideEmptyTextRows: Boolean = false
override fun onFinishInflate() {
super.onFinishInflate()
noticeRow = findViewById(R.id.ll_feed_community_notice)
profileImage = findViewById(R.id.iv_feed_community_profile)
moreButton = findViewById(R.id.btn_feed_community_more)
creatorText = findViewById(R.id.tv_feed_community_creator)
createdAtText = findViewById(R.id.tv_feed_community_created_at)
bodyText = findViewById(R.id.tv_feed_community_body)
@@ -43,7 +51,10 @@ class FeedCommunityView @JvmOverloads constructor(
communityImage = findViewById(R.id.iv_feed_community_image)
paidOverlay = findViewById(R.id.ll_feed_community_paid_overlay)
priceText = findViewById(R.id.tv_feed_community_price)
reactionRow = findViewById(R.id.ll_feed_community_reaction)
commentIcon = findViewById(R.id.iv_feed_community_comment)
commentCountText = findViewById(R.id.tv_feed_community_comment_count)
likeIcon = findViewById(R.id.iv_feed_community_like)
likeCountText = findViewById(R.id.tv_feed_community_like_count)
clipToOutline = true
outlineProvider = roundedOutlineProvider(CARD_RADIUS_DP)
@@ -59,7 +70,8 @@ class FeedCommunityView @JvmOverloads constructor(
requireNotNull(createdAtText).text = item.createdAtText
requireNotNull(bodyText).text = item.bodyText
requireNotNull(bodyText).visibility = visibilityForText(item.bodyText)
val isLocked = item.price > 0 && !item.existOrdered
requireNotNull(noticeRow).isVisible = item.isPinned
val isLocked = item.isLocked
val hasImage = !item.imageUrl.isNullOrBlank()
requireNotNull(communityImageContainer).isVisible = hasImage || isLocked
resetCommunityImageHeight()
@@ -69,9 +81,15 @@ class FeedCommunityView @JvmOverloads constructor(
}
requireNotNull(paidOverlay).isVisible = isLocked
requireNotNull(priceText).text = item.price.toString()
requireNotNull(reactionRow).isVisible = item.showReaction
requireNotNull(commentIcon).isVisible = item.showComment
requireNotNull(commentCountText).text = item.commentCount.toString()
requireNotNull(commentCountText).isVisible = item.showComment
requireNotNull(likeIcon).isVisible = item.showReaction
requireNotNull(likeCountText).text = item.likeCount.toString()
requireNotNull(likeCountText).isVisible = item.showReaction
applyClickState(item)
applyMoreClickState(item)
}
fun profileImageView(): ImageView = requireNotNull(profileImage)
@@ -116,6 +134,11 @@ class FeedCommunityView @JvmOverloads constructor(
currentItem?.let(::applyClickState)
}
fun setOnMoreClick(listener: ((FeedItem.Community) -> Unit)?) {
moreClickListener = listener
currentItem?.let(::applyMoreClickState)
}
fun setHideEmptyTextRows(hide: Boolean) {
hideEmptyTextRows = hide
currentItem?.let(::bind)
@@ -130,6 +153,14 @@ class FeedCommunityView @JvmOverloads constructor(
isClickable = listener != null
}
private fun applyMoreClickState(item: FeedItem.Community) {
val listener = moreClickListener
requireNotNull(moreButton).isVisible = item.showMore
requireNotNull(moreButton).setOnClickListener(
if (listener == null) null else View.OnClickListener { listener(item) }
)
}
private fun updateRootWidth(width: Int) {
val currentLayoutParams = layoutParams
layoutParams = if (currentLayoutParams == null) {

View File

@@ -47,6 +47,19 @@ sealed class FeedItem(open val feedId: String, val variant: FeedVariant) {
val audioUrl: String? = null,
val price: Int = 0,
val existOrdered: Boolean = false,
val showKeyword: Boolean = true
) : FeedItem(feedId, FeedVariant.Community)
val isPinned: Boolean = false,
val isCommentAvailable: Boolean = true,
val hideReactionWhenLocked: Boolean = false,
val showKeyword: Boolean = true,
val showMore: Boolean = false
) : FeedItem(feedId, FeedVariant.Community) {
val isLocked: Boolean
get() = price > 0 && !existOrdered
val showReaction: Boolean
get() = !hideReactionWhenLocked || !isLocked
val showComment: Boolean
get() = showReaction && isCommentAvailable
}
}

View File

@@ -73,7 +73,7 @@
android:includeFontPadding="false"
android:maxLines="1"
android:textColor="@color/white"
app:layout_constraintEnd_toStartOf="@id/layout_creator_channel_community_detail_price"
app:layout_constraintEnd_toStartOf="@id/btn_creator_channel_community_detail_more"
app:layout_constraintStart_toEndOf="@id/iv_creator_channel_community_detail_profile"
app:layout_constraintTop_toTopOf="@id/iv_creator_channel_community_detail_profile"
tools:text="크리에이터" />
@@ -91,36 +91,17 @@
app:layout_constraintTop_toBottomOf="@id/tv_creator_channel_community_detail_nickname"
tools:text="2분 전" />
<LinearLayout
android:id="@+id/layout_creator_channel_community_detail_price"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@drawable/bg_creator_channel_community_price"
android:gravity="center"
android:orientation="horizontal"
android:paddingHorizontal="@dimen/spacing_4"
android:paddingVertical="2dp"
<ImageButton
android:id="@+id/btn_creator_channel_community_detail_more"
android:layout_width="42dp"
android:layout_height="42dp"
android:background="@android:color/transparent"
android:contentDescription="@string/read_more"
android:src="@drawable/ic_seemore_vertical"
android:visibility="gone"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="@id/iv_creator_channel_community_detail_profile"
tools:visibility="visible">
<ImageView
android:layout_width="18dp"
android:layout_height="18dp"
android:contentDescription="@null"
android:src="@drawable/ic_bar_cash" />
<TextView
android:id="@+id/tv_creator_channel_community_detail_price"
style="@style/Typography.Caption3"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="2dp"
android:includeFontPadding="false"
android:textColor="@color/black"
tools:text="300" />
</LinearLayout>
tools:visibility="visible" />
</androidx.constraintlayout.widget.ConstraintLayout>
<TextView
@@ -222,22 +203,6 @@
tools:text="Audio"
tools:visibility="visible" />
<TextView
android:id="@+id/tv_creator_channel_community_detail_purchased"
style="@style/Typography.Caption2"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="end"
android:layout_marginTop="@dimen/spacing_8"
android:background="@drawable/bg_creator_channel_community_price"
android:includeFontPadding="false"
android:paddingHorizontal="@dimen/spacing_8"
android:paddingVertical="@dimen/spacing_4"
android:text="@string/creator_channel_community_purchased_badge"
android:textColor="@color/black"
android:visibility="gone"
tools:visibility="visible" />
<LinearLayout
android:id="@+id/layout_creator_channel_community_detail_reaction"
android:layout_width="match_parent"

View File

@@ -122,13 +122,13 @@
tools:text="300" />
</LinearLayout>
<ImageView
android:id="@+id/iv_creator_channel_community_list_owner_more"
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_marginStart="@dimen/spacing_4"
android:contentDescription="@null"
android:src="@drawable/ic_new_more"
<ImageButton
android:id="@+id/btn_creator_channel_community_list_more"
android:layout_width="42dp"
android:layout_height="42dp"
android:background="@android:color/transparent"
android:contentDescription="@string/read_more"
android:src="@drawable/ic_seemore_vertical"
android:visibility="gone"
tools:visibility="visible" />
</LinearLayout>
@@ -187,32 +187,34 @@
android:visibility="gone"
tools:visibility="visible" />
<TextView
android:id="@+id/tv_creator_channel_community_list_locked_price"
style="@style/Typography.Body3"
<LinearLayout
android:id="@+id/layout_creator_channel_community_list_locked_price"
android:layout_width="70dp"
android:layout_height="36dp"
android:layout_marginTop="@dimen/spacing_4"
android:background="@drawable/bg_creator_channel_community_price"
android:drawableStart="@drawable/ic_bar_cash"
android:drawablePadding="@dimen/spacing_6"
android:gravity="center"
android:includeFontPadding="false"
android:textColor="@color/black"
android:orientation="horizontal"
android:visibility="gone"
tools:text="30"
tools:visibility="visible" />
</LinearLayout>
tools:visibility="visible">
<ImageView
android:id="@+id/iv_creator_channel_community_list_play"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_gravity="center"
android:layout_width="18dp"
android:layout_height="18dp"
android:contentDescription="@null"
android:src="@drawable/ic_new_player_play"
android:visibility="gone"
tools:visibility="visible" />
android:src="@drawable/ic_bar_cash" />
<TextView
android:id="@+id/tv_creator_channel_community_list_locked_price"
style="@style/Typography.Body3"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/spacing_6"
android:includeFontPadding="false"
android:textColor="@color/black"
tools:text="30" />
</LinearLayout>
</LinearLayout>
</FrameLayout>
<LinearLayout

View File

@@ -8,7 +8,33 @@
android:padding="@dimen/spacing_14">
<LinearLayout
android:id="@+id/ll_feed_community_notice"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="@dimen/spacing_14"
android:gravity="center_vertical"
android:orientation="horizontal"
android:visibility="gone"
tools:visibility="visible">
<ImageView
android:layout_width="18dp"
android:layout_height="18dp"
android:contentDescription="@null"
android:src="@drawable/ic_pin" />
<TextView
style="@style/Typography.Body5"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="2dp"
android:includeFontPadding="false"
android:text="@string/creator_channel_community_notice"
android:textColor="@color/green_400" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="42dp"
android:gravity="center_vertical"
android:orientation="horizontal">
@@ -22,9 +48,10 @@
tools:src="@drawable/ic_launcher_background" />
<LinearLayout
android:layout_width="wrap_content"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/spacing_8"
android:layout_weight="1"
android:orientation="vertical">
<TextView
@@ -50,6 +77,16 @@
android:textColor="@color/gray_500"
tools:text="2분 전" />
</LinearLayout>
<ImageButton
android:id="@+id/btn_feed_community_more"
android:layout_width="42dp"
android:layout_height="42dp"
android:background="@android:color/transparent"
android:contentDescription="@string/read_more"
android:src="@drawable/ic_seemore_vertical"
android:visibility="gone"
tools:visibility="visible" />
</LinearLayout>
<TextView
@@ -125,6 +162,7 @@
</FrameLayout>
<LinearLayout
android:id="@+id/ll_feed_community_reaction"
android:layout_width="wrap_content"
android:layout_height="24dp"
android:layout_marginTop="@dimen/spacing_16"
@@ -132,6 +170,7 @@
android:orientation="horizontal">
<ImageView
android:id="@+id/iv_feed_community_comment"
android:layout_width="18dp"
android:layout_height="18dp"
android:contentDescription="@null"
@@ -148,6 +187,7 @@
tools:text="5" />
<ImageView
android:id="@+id/iv_feed_community_like"
android:layout_width="18dp"
android:layout_height="18dp"
android:layout_marginStart="15dp"

View File

@@ -44,13 +44,4 @@ class CommunityChangeTest {
assertEquals(List(CommunityActivityResultSource.entries.size) { CommunityChange.Ignored }, changes)
}
@Test
fun `삭제와 고정 변경은 post ID를 가진 명시적 변경으로 표현한다`() {
val deleted = CommunityChange.Deleted(postId = 31L)
val pinChanged = CommunityChange.PinChanged(postId = 32L)
assertEquals(31L, deleted.postId)
assertEquals(32L, pinChanged.postId)
}
}

View File

@@ -119,6 +119,164 @@ class CreatorChannelActivitySourceTest {
assertTrue(source.contains("private fun handleCommunityChange(change: CommunityChange)"))
}
@Test
fun `홈과 커뮤니티 list 더보기는 동일 menu item을 Activity Host까지 전달한다`() {
val adapter = projectFile(
"app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/ui/CreatorChannelHomeSectionAdapter.kt"
).readText()
val homeFragment = projectFile(
"app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelHomeFragment.kt"
).readText()
val communityFragment = projectFile(
"app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/community/CreatorChannelCommunityFragment.kt"
).readText()
val activity = projectFile(
"app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelActivity.kt"
).readText()
assertTrue(
adapter.contains("private val onCommunityMoreClick: (CreatorChannelCommunityMenuItem) -> Unit = {}")
)
assertTrue(adapter.contains("currentUserId = item.currentMemberId"))
assertTrue(adapter.contains("showMore = menuItem.showMore"))
assertTrue(adapter.contains("setOnMoreClick { onCommunityMoreClick(menuItem) }"))
assertTrue(adapter.contains("row.setOnClickListener { onCommunityClick(community.postId) }"))
assertTrue(homeFragment.contains("onCommunityMoreClick = ::onCommunityMoreClicked"))
assertTrue(homeFragment.contains("host.onCreatorChannelCommunityMoreClicked(item)"))
assertTrue(
homeFragment.contains("fun onCreatorChannelCommunityMoreClicked(item: CreatorChannelCommunityMenuItem)")
)
assertTrue(communityFragment.contains("host.onCreatorChannelCommunityMoreClicked(item)"))
assertTrue(
communityFragment.contains("fun onCreatorChannelCommunityMoreClicked(item: CreatorChannelCommunityMenuItem)")
)
assertTrue(
activity.contains(
"override fun onCreatorChannelCommunityMoreClicked(item: CreatorChannelCommunityMenuItem)"
)
)
}
@Test
fun `커뮤니티 더보기는 하나의 Activity callback에서 권한별 기존 메뉴와 수정 결과를 연결한다`() {
val source = projectFile(
"app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelActivity.kt"
).readText()
val handlerSource = sourceSection(
source = source,
startMarker = "override fun onCreatorChannelCommunityMoreClicked(item: CreatorChannelCommunityMenuItem)",
endMarker = "override fun onCreatorChannelFanTalkContentChanged"
)
assertEquals(
1,
Regex(
Regex.escape(
"override fun onCreatorChannelCommunityMoreClicked(item: CreatorChannelCommunityMenuItem)"
)
)
.findAll(source)
.count()
)
assertTrue(handlerSource.contains("if (!item.showMore) return"))
assertTrue(handlerSource.contains("CreatorCommunityPostMenuBottomSheetDialog("))
assertTrue(handlerSource.contains("isFixed = item.isPinned"))
assertTrue(handlerSource.contains("isCreator = item.isOwner"))
assertTrue(handlerSource.contains("onClickPin = { updateCreatorChannelCommunityPostFixed(item) }"))
assertTrue(handlerSource.contains("onClickDelete = { showDeleteCreatorChannelCommunityPostDialog(item) }"))
assertTrue(handlerSource.contains("CreatorCommunityReportDialog("))
assertTrue(handlerSource.contains("if (item.isOwner)"))
assertFalse(handlerSource.contains("isCreatorChannelOwner()"))
assertTrue(source.contains("private val communityModifyLauncher = registerForActivityResult"))
assertTrue(source.contains("CommunityActivityResultSource.Modify"))
assertTrue(source.contains("CreatorCommunityModifyActivity::class.java"))
assertTrue(source.contains("putExtra(Constants.EXTRA_COMMUNITY_POST_ID, item.postId)"))
}
@Test
fun `커뮤니티 고정과 삭제 mutation은 역할과 중복을 재확인하고 성공만 목록을 갱신한다`() {
val source = projectFile(
"app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelActivity.kt"
).readText()
val pinSource = sourceSection(
source = source,
startMarker = "private fun updateCreatorChannelCommunityPostFixed(item: CreatorChannelCommunityMenuItem)",
endMarker = "private fun showDeleteCreatorChannelCommunityPostDialog"
)
val deleteSource = sourceSection(
source = source,
startMarker = "private fun deleteCreatorChannelCommunityPost(item: CreatorChannelCommunityMenuItem)",
endMarker = "private fun reportCreatorChannelCommunityPost"
)
val deleteDialogSource = sourceSection(
source = source,
startMarker = "private fun showDeleteCreatorChannelCommunityPostDialog",
endMarker = "private fun deleteCreatorChannelCommunityPost"
)
listOf(pinSource, deleteSource).forEach { mutationSource ->
assertTrue(
mutationSource.contains(
"if (!item.isOwner || isCommunityPostMutating) return"
)
)
assertFalse(mutationSource.contains("isCreatorChannelOwner()"))
assertTrue(mutationSource.contains("isCommunityPostMutating = true"))
assertEquals(2, "isCommunityPostMutating = false".toRegex().findAll(mutationSource).count())
assertTrue(mutationSource.contains("if (response.success)"))
assertTrue(mutationSource.contains("handleCommunityChange(CommunityChange.Updated)"))
assertTrue(mutationSource.contains("showCommunityMutationFailureToast"))
}
assertTrue(
pinSource.contains(
"creatorCommunityRepository.updateCommunityPostFixed(item.postId, !item.isPinned, authToken())"
)
)
assertTrue(deleteDialogSource.contains("if (!item.isOwner) return"))
assertFalse(deleteDialogSource.contains("isCreatorChannelOwner()"))
assertTrue(deleteSource.contains("ModifyCommunityPostRequest("))
assertTrue(deleteSource.contains("creatorCommunityId = item.postId"))
assertTrue(deleteSource.contains("isActive = false"))
assertTrue(deleteSource.contains("Gson().toJson"))
assertTrue(deleteSource.contains("toRequestBody(\"text/plain\".toMediaType())"))
assertTrue(
deleteSource.contains(
"creatorCommunityRepository.modifyCommunityPost(postImage = null, request = request, token = authToken())"
)
)
}
@Test
fun `커뮤니티 신고 mutation은 방문자 역할과 중복을 재확인하고 성공 토스트만 표시한다`() {
val source = projectFile(
"app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelActivity.kt"
).readText()
val reportSource = sourceSection(
source = source,
startMarker = "private fun reportCreatorChannelCommunityPost(",
endMarker = "private fun showCommunityMutationFailureToast"
)
assertTrue(
reportSource.contains(
"if (item.isOwner || !item.showMore || reason.isBlank() || " +
"isCommunityPostMutating) return"
)
)
assertFalse(reportSource.contains("isCreatorChannelOwner()"))
assertTrue(reportSource.contains("isCommunityPostMutating = true"))
assertEquals(2, "isCommunityPostMutating = false".toRegex().findAll(reportSource).count())
assertTrue(
reportSource.contains(
"creatorChannelRepository.reportCommunityPost(item.postId, reason, authToken())"
)
)
assertTrue(reportSource.contains("response.message.isNullOrBlank()"))
assertTrue(reportSource.contains("R.string.character_comment_report_submitted"))
assertTrue(reportSource.contains("showCommunityMutationFailureToast"))
assertFalse(reportSource.contains("handleCommunityChange("))
}
@Test
fun `follow notify source는 Phase 11 직접 팔로우 알림 액션을 연결한다`() {
val source = projectFile(
@@ -653,80 +811,12 @@ class CreatorChannelActivitySourceTest {
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"))
assertFalse(source.contains("communityPostModifyLauncher"))
assertFalse(source.contains("onCreatorChannelCommunityOwnerMoreClicked"))
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 `커뮤니티 고정 성공은 실제 post id로 단일 변경 handler를 호출한다`() {
val source = projectFile(
"app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelActivity.kt"
).readText()
val fixedSource = sourceSection(
source = source,
startMarker = "private fun updateCreatorChannelCommunityPostFixed",
endMarker = "private fun deleteCreatorChannelCommunityPost"
)
val successMarker = "if (response.success)"
val beforeSuccessSource = fixedSource.substring(0, fixedSource.indexOf(successMarker))
val successSource = fixedSource.substring(
fixedSource.indexOf(successMarker),
fixedSource.indexOf("} else", fixedSource.indexOf(successMarker))
)
assertTrue(fixedSource.contains("postId = item.postId"))
assertTrue(fixedSource.contains("isFixed = !item.isPinned"))
assertTrue(successSource.contains("handleCommunityChange(CommunityChange.PinChanged(item.postId))"))
assertFalse(beforeSuccessSource.contains("CommunityChange.PinChanged"))
assertEquals(1, fixedSource.split("CommunityChange.PinChanged").size - 1)
assertFalse(fixedSource.contains("CommunityChange.PinChanged(creatorId)"))
assertFalse(fixedSource.contains("refreshCreatorChannelCommunity()"))
}
@Test
fun `커뮤니티 삭제 성공은 실제 post id로 단일 변경 handler를 호출한다`() {
val source = projectFile(
"app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelActivity.kt"
).readText()
val deleteSource = sourceSection(
source = source,
startMarker = "private fun deleteCreatorChannelCommunityPost",
endMarker = "private fun authToken()"
)
val successMarker = "if (response.success)"
val beforeSuccessSource = deleteSource.substring(0, deleteSource.indexOf(successMarker))
val successSource = deleteSource.substring(
deleteSource.indexOf(successMarker),
deleteSource.indexOf("} else", deleteSource.indexOf(successMarker))
)
assertTrue(deleteSource.contains("creatorCommunityId = item.postId"))
assertTrue(deleteSource.contains("isActive = false"))
assertTrue(successSource.contains("handleCommunityChange(CommunityChange.Deleted(item.postId))"))
assertFalse(beforeSuccessSource.contains("CommunityChange.Deleted"))
assertEquals(1, deleteSource.split("CommunityChange.Deleted").size - 1)
assertFalse(deleteSource.contains("CommunityChange.Deleted(creatorId)"))
assertFalse(deleteSource.contains("refreshCreatorChannelCommunity()"))
assertFalse(fragment.contains("CreatorCommunityMediaPlayerManager"))
assertFalse(fragment.contains("toggleCommunityAudio"))
}
@Test
@@ -743,18 +833,14 @@ class CreatorChannelActivitySourceTest {
handlerSource.indexOf("CommunityChange.Created"),
handlerSource.indexOf("CommunityChange.Updated")
)
val mutationSource = handlerSource.substring(handlerSource.indexOf("CommunityChange.Updated"))
val updatedSource = handlerSource.substring(handlerSource.indexOf("CommunityChange.Updated"))
assertTrue(createdSource.contains("homeActionDelegate?.refreshHome()"))
assertTrue(createdSource.contains("refreshCreatorChannelCommunity()"))
assertTrue(
mutationSource.contains(
"CommunityChange.Updated,\n" +
" is CommunityChange.Deleted,\n" +
" is CommunityChange.PinChanged -> refreshCreatorChannelCommunity()"
)
)
assertFalse(mutationSource.contains("homeActionDelegate?.refreshHome()"))
assertTrue(updatedSource.contains("homeActionDelegate?.refreshHome()"))
assertTrue(updatedSource.contains("refreshCreatorChannelCommunity()"))
assertFalse(updatedSource.contains("CommunityChange.Deleted"))
assertFalse(updatedSource.contains("CommunityChange.PinChanged"))
}
@Test
@@ -2003,7 +2089,7 @@ class CreatorChannelActivitySourceTest {
assertTrue(adapter.contains("val communityWidthDp = calculateCreatorChannelCommunityCardWidthDp("))
assertTrue(adapter.contains("rootWidthDp = communityWidthDp"))
assertTrue(adapter.contains("setHideEmptyTextRows(true)"))
assertTrue(adapter.contains("bind(community.toFeedCommunityItem())"))
assertTrue(adapter.contains("bind(community.toFeedCommunityItem(showMore = menuItem.showMore))"))
assertTrue(adapter.contains("bindCommunityImages(row, community)"))
assertTrue(adapter.contains("BlurTransformation(itemView.context, 25f, 2.5f)"))
assertTrue(adapter.contains("row.layoutParams = LinearLayout.LayoutParams("))

View File

@@ -61,6 +61,16 @@ class CreatorChannelHomeMapperTest {
assertFalse(visitorContent.sections.filterIsInstance<CreatorChannelHomeSection.Donations>().single().isOwner)
}
@Test
fun `현재 회원 id를 커뮤니티 section에 보존한다`() {
val communities = response().toUiContent(currentMemberId = 77L)
.sections
.filterIsInstance<CreatorChannelHomeSection.Communities>()
.single()
assertEquals(77L, communities.currentMemberId)
}
@Test
fun `null 단건 콘텐츠와 빈 리스트와 blank SNS는 후원 empty와 팬Talk empty section을 생성한다`() {
val content = response(
@@ -267,7 +277,9 @@ class CreatorChannelHomeMapperTest {
dateUtc = "2026-06-11T12:00:00Z",
existOrdered = false,
likeCount = 1,
commentCount = 2
commentCount = 2,
isPinned = false,
isCommentAvailable = true
)
private fun fanTalk() = CreatorChannelFanTalkSummaryResponse(

View File

@@ -4,7 +4,9 @@ import com.google.gson.Gson
import kr.co.vividnext.sodalive.v2.common.CreatorActivityType
import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelHomeResponse
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class CreatorChannelHomeModelsTest {
@@ -36,6 +38,15 @@ class CreatorChannelHomeModelsTest {
assertEquals("https://x.example", response.sns.xUrl)
}
@Test
fun `home response preserves community pinned and comment availability`() {
val response = gson.fromJson(homeJson(scheduleType = "AUDIO"), CreatorChannelHomeResponse::class.java)
val community = response.communities.single()
assertTrue(community.isPinned)
assertFalse(community.isCommentAvailable)
}
private fun homeJson(scheduleType: String): String = """
{
"creator": {
@@ -63,7 +74,24 @@ class CreatorChannelHomeModelsTest {
],
"audioContents": [],
"series": [],
"communities": [],
"communities": [
{
"postId": 21,
"creatorId": 1,
"creatorNickname": "creator",
"creatorProfileUrl": "https://example.com/profile.png",
"imageUrl": null,
"audioUrl": null,
"content": "community",
"price": 0,
"dateUtc": "2026-06-12T00:00:00Z",
"existOrdered": false,
"likeCount": 6,
"commentCount": 5,
"isPinned": true,
"isCommentAvailable": false
}
],
"fanTalk": {
"totalCount": 0,
"latestFanTalk": null

View File

@@ -11,6 +11,7 @@ import androidx.recyclerview.widget.RecyclerView
import androidx.test.core.app.ApplicationProvider
import kr.co.vividnext.sodalive.R
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Test
@@ -29,6 +30,7 @@ class CreatorChannelCommunityDetailReplyUiContractTest {
val reply = inflateView(R.layout.activity_creator_channel_community_reply)
assertNotNull(detail.findViewById<ImageButton>(R.id.btn_creator_channel_community_detail_back))
assertNotNull(detail.findViewById<ImageButton>(R.id.btn_creator_channel_community_detail_more))
assertNotNull(detail.findViewById<TextView>(R.id.tv_creator_channel_community_detail_comment_header))
assertNotNull(detail.findViewById<RecyclerView>(R.id.rv_creator_channel_community_detail_comments))
assertNotNull(detail.findViewById<View>(R.id.layout_creator_channel_community_detail_paywall))
@@ -39,6 +41,9 @@ class CreatorChannelCommunityDetailReplyUiContractTest {
assertNotNull(detail.findViewById<ImageButton>(R.id.btn_creator_channel_community_detail_send))
assertNotNull(detail.findViewById<ImageButton>(R.id.btn_creator_channel_community_detail_edit_cancel))
assertRemovedId("tv_creator_channel_community_detail_title")
assertRemovedId("layout_creator_channel_community_detail_price")
assertRemovedId("tv_creator_channel_community_detail_price")
assertRemovedId("tv_creator_channel_community_detail_purchased")
assertRemovedId("tv_creator_channel_community_detail_comment_unavailable")
assertNotNull(reply.findViewById<ImageButton>(R.id.btn_creator_channel_community_reply_back))
@@ -156,6 +161,23 @@ class CreatorChannelCommunityDetailReplyUiContractTest {
assertTrue(detailActivity.contains("btn_audio_content_play"))
}
@Test
fun `상세 게시글 더보기는 기존 관리 신고 UI와 수정 launcher를 재사용한다`() {
val detailActivity = projectFile(
"app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/community/detail/" +
"CreatorChannelCommunityDetailActivity.kt"
).readText()
assertTrue(detailActivity.contains("CreatorCommunityPostMenuBottomSheetDialog"))
assertTrue(detailActivity.contains("CreatorCommunityReportDialog"))
assertTrue(detailActivity.contains("CreatorCommunityModifyActivity"))
assertTrue(detailActivity.contains("modifyLauncher"))
assertTrue(detailActivity.contains("Constants.EXTRA_COMMUNITY_POST_ID"))
assertTrue(detailActivity.contains("btnCreatorChannelCommunityDetailMore"))
assertFalse(detailActivity.contains("layoutCreatorChannelCommunityDetailPrice"))
assertFalse(detailActivity.contains("tvCreatorChannelCommunityDetailPurchased"))
}
@Test
fun `댓글 답글 Adapter는 PopupMenu 대신 regular font popup을 사용한다`() {
val commentAdapter = projectFile(

View File

@@ -31,6 +31,8 @@ import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.kotlin.any
import org.mockito.kotlin.argThat
import org.mockito.kotlin.eq
import org.mockito.kotlin.isNull
import org.mockito.kotlin.never
import org.mockito.kotlin.times
import org.mockito.kotlin.verify
@@ -469,6 +471,99 @@ class CreatorChannelCommunityDetailViewModelTest {
verify(repository, times(2)).getCommunityPostDetail(POST_ID, AUTH_TOKEN)
}
@Test
fun `게시글 더보기는 작성자 또는 무료 또는 구매 완료일 때만 표시한다`() {
SharedPreferenceManager.userId = 10L
val cases = listOf(
detailResponse(isCommentAvailable = false, creatorId = 10L, price = 300, existOrdered = false),
detailResponse(isCommentAvailable = false, creatorId = 999L, price = 0, existOrdered = false),
detailResponse(isCommentAvailable = false, creatorId = 999L, price = 300, existOrdered = true),
detailResponse(isCommentAvailable = false, creatorId = 999L, price = 300, existOrdered = false)
)
val results = cases.map { response ->
stubDetail(response)
viewModel.loadDetail(POST_ID)
(viewModel.detailStateLiveData.requireValue() as CreatorChannelCommunityDetailUiState.Content).post
}
assertTrue(results[0].isOwner)
assertTrue(results[0].showMore)
assertFalse(results[1].isOwner)
assertTrue(results[1].showMore)
assertTrue(results[2].showMore)
assertFalse(results[3].showMore)
}
@Test
fun `게시글 고정 성공은 반전 값을 요청하고 상세 재조회와 변경 이벤트를 노출한다`() {
whenever(repository.getCommunityPostDetail(POST_ID, AUTH_TOKEN)).thenReturn(
Single.just(ApiResponse(true, detailResponse(isCommentAvailable = false, creatorId = 10L, isPinned = false), null)),
Single.just(ApiResponse(true, detailResponse(isCommentAvailable = false, creatorId = 10L, isPinned = true), null))
)
whenever(legacyRepository.updateCommunityPostFixed(POST_ID, true, AUTH_TOKEN))
.thenReturn(Single.just(ApiResponse(true, Any(), null)))
viewModel.loadDetail(POST_ID)
viewModel.updateCommunityPostFixed()
val state = viewModel.detailStateLiveData.requireValue() as CreatorChannelCommunityDetailUiState.Content
assertTrue(state.post.isPinned)
assertTrue(viewModel.postChangedEventLiveData.requireValue() == true)
verify(legacyRepository).updateCommunityPostFixed(POST_ID, true, AUTH_TOKEN)
verify(repository, times(2)).getCommunityPostDetail(POST_ID, AUTH_TOKEN)
}
@Test
fun `게시글 삭제 성공은 삭제 이벤트를 노출한다`() {
stubDetail(detailResponse(isCommentAvailable = false, creatorId = 10L))
whenever(legacyRepository.modifyCommunityPost(isNull(), any(), eq(AUTH_TOKEN)))
.thenReturn(Single.just(ApiResponse(true, Any(), null)))
viewModel.loadDetail(POST_ID)
viewModel.deleteCommunityPost()
assertTrue(viewModel.postDeletedEventLiveData.requireValue() == true)
verify(legacyRepository).modifyCommunityPost(
isNull(),
argThat { requestBody ->
okio.Buffer().also(requestBody::writeTo).readUtf8().contains("\"isActive\":false")
},
eq(AUTH_TOKEN)
)
}
@Test
fun `게시글 신고 성공은 repository 호출과 toast만 노출한다`() {
stubDetail(detailResponse(isCommentAvailable = false, creatorId = 999L))
whenever(repository.reportCommunityPost(POST_ID, "스팸", AUTH_TOKEN))
.thenReturn(Single.just(ApiResponse(true, Any(), "접수됨")))
viewModel.loadDetail(POST_ID)
viewModel.reportCommunityPost("스팸")
verify(repository).reportCommunityPost(POST_ID, "스팸", AUTH_TOKEN)
assertEquals("접수됨", viewModel.toastLiveData.requireValue()?.consume()?.message)
assertFalse(viewModel.postChangedEventLiveData.requireValue() == true)
}
@Test
fun `게시글 mutation 진행 중에는 중복 요청을 막고 실패 시 성공 이벤트를 노출하지 않는다`() {
stubDetail(detailResponse(isCommentAvailable = false, creatorId = 10L))
val subject = PublishSubject.create<ApiResponse<Any>>()
whenever(legacyRepository.updateCommunityPostFixed(POST_ID, true, AUTH_TOKEN))
.thenReturn(subject.firstOrError())
viewModel.loadDetail(POST_ID)
viewModel.updateCommunityPostFixed()
viewModel.updateCommunityPostFixed()
subject.onNext(ApiResponse(false, null, "고정 실패"))
verify(legacyRepository, times(1)).updateCommunityPostFixed(POST_ID, true, AUTH_TOKEN)
assertEquals("고정 실패", viewModel.toastLiveData.requireValue()?.consume()?.message)
assertFalse(viewModel.postChangedEventLiveData.requireValue() == true)
}
@Test
fun `댓글 UI 모델은 답글 화면 재포맷을 위해 원본 createdAtUtc를 보존한다`() {
stubDetail(detailResponse(isCommentAvailable = true, commentIds = listOf(11L)))
@@ -571,7 +666,8 @@ class CreatorChannelCommunityDetailViewModelTest {
existOrdered: Boolean = true,
creatorId: Long = 100L,
commentIds: List<Long> = emptyList(),
commentsHasNext: Boolean = false
commentsHasNext: Boolean = false,
isPinned: Boolean = false
) = CreatorChannelCommunityPostDetailResponse(
postId = POST_ID,
creatorId = creatorId,
@@ -587,7 +683,7 @@ class CreatorChannelCommunityDetailViewModelTest {
likeCount = likeCount,
commentCount = 2,
isLiked = isLiked,
isPinned = false,
isPinned = isPinned,
comments = commentsResponse(ids = commentIds, hasNext = commentsHasNext)
)

View File

@@ -11,6 +11,7 @@ import androidx.test.core.app.ApplicationProvider
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.v2.creator.channel.community.ui.calculateCreatorChannelCommunityGridItemSize
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertSame
import org.junit.Assert.assertTrue
@@ -62,7 +63,7 @@ class CreatorChannelCommunityFragmentLayoutTest {
}
@Test
fun `커뮤니티 list item layout은 프로필 본문 이미지 잠금 재생 반응 owner 영역을 제공한다`() {
fun `커뮤니티 list item layout은 프로필 본문 이미지 잠금 반응과 작성자 금액을 제공한다`() {
val item = inflateView(R.layout.item_creator_channel_community_list)
val itemLayout = projectFile("app/src/main/res/layout/item_creator_channel_community_list.xml").readText()
val imageContainer = requireNotNull(item.findViewById<View>(R.id.layout_creator_channel_community_list_image_container))
@@ -77,24 +78,29 @@ class CreatorChannelCommunityFragmentLayoutTest {
assertNotNull(item.findViewById<ImageView>(R.id.iv_creator_channel_community_list_image))
assertNotNull(item.findViewById<View>(R.id.layout_creator_channel_community_list_locked_overlay))
assertNotNull(item.findViewById<TextView>(R.id.tv_creator_channel_community_list_locked_price))
assertNotNull(item.findViewById<ImageView>(R.id.iv_creator_channel_community_list_play))
assertNotNull(item.findViewById<TextView>(R.id.tv_creator_channel_community_list_comment_count))
assertNotNull(item.findViewById<TextView>(R.id.tv_creator_channel_community_list_like_count))
assertNotNull(item.findViewById<View>(R.id.layout_creator_channel_community_list_top_actions))
assertNotNull(item.findViewById<View>(R.id.layout_creator_channel_community_list_top_price))
assertNotNull(item.findViewById<ImageView>(R.id.iv_creator_channel_community_list_owner_more))
assertNotNull(item.findViewById<TextView>(R.id.tv_creator_channel_community_list_top_price))
val moreButton = requireNotNull(item.findViewById<View>(R.id.btn_creator_channel_community_list_more))
assertSame(imageContainer, (lockIcon.parent as View).parent)
assertSame(imageContainer, (lockedPrice.parent as View).parent)
assertSame(imageContainer, ((lockedPrice.parent as View).parent as View).parent)
assertSame(
item.findViewById<View>(R.id.layout_creator_channel_community_list_top_actions),
item.findViewById<View>(R.id.layout_creator_channel_community_list_top_price).parent
)
assertSame(
item.findViewById<View>(R.id.layout_creator_channel_community_list_top_actions),
moreButton.parent
)
assertTrue(itemLayout.contains("android:id=\"@+id/layout_creator_channel_community_list_locked_overlay\""))
assertTrue(itemLayout.contains("android:id=\"@+id/tv_creator_channel_community_list_locked_price\""))
assertTrue(itemLayout.contains("android:background=\"@drawable/bg_creator_channel_community_price\""))
assertTrue(itemLayout.contains("android:drawableStart=\"@drawable/ic_bar_cash\""))
assertTrue(itemLayout.contains("android:id=\"@+id/iv_creator_channel_community_list_play\""))
assertFalse(itemLayout.contains("android:drawableStart=\"@drawable/ic_bar_cash\""))
assertTrue(itemLayout.contains("android:src=\"@drawable/ic_bar_cash\""))
assertFalse(itemLayout.contains("iv_creator_channel_community_list_play"))
assertFalse(itemLayout.contains("iv_creator_channel_community_list_owner_more"))
}
@Test
@@ -138,7 +144,7 @@ class CreatorChannelCommunityFragmentLayoutTest {
}
@Test
fun `커뮤니티 fragment source는 pagination view mode owner padding stopContent 계약을 사용한다`() {
fun `커뮤니티 fragment source는 pagination view mode owner padding과 상세 이동 계약을 사용한다`() {
val fragment = projectFile(
"app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/community/CreatorChannelCommunityFragment.kt"
).readText()
@@ -161,8 +167,10 @@ class CreatorChannelCommunityFragmentLayoutTest {
assertTrue(fragment.contains("calculateCreatorChannelCommunityGridItemSize("))
assertTrue(fragment.contains("viewModel.consumePaginationErrorMessage()"))
assertTrue(fragment.contains("applyOwnerCtaPadding"))
assertTrue(fragment.contains("pauseContent"))
assertTrue(fragment.contains("stopContent"))
assertFalse(fragment.contains("CreatorCommunityMediaPlayerManager"))
assertFalse(fragment.contains("toggleCommunityAudio"))
assertFalse(fragment.contains("onPlayClick"))
assertFalse(fragment.contains("onCreatorChannelCommunityOwnerMoreClicked"))
}
@Test
@@ -181,19 +189,25 @@ class CreatorChannelCommunityFragmentLayoutTest {
assertTrue(listAdapter.contains("showComment") && listAdapter.contains("isVisible = item.showComment"))
assertTrue(listAdapter.contains("isLocked"))
assertTrue(gridAdapter.contains("isLocked"))
assertTrue(listAdapter.contains("showPlayButton"))
assertTrue(listAdapter.contains("showOwnerMore"))
assertTrue(listAdapter.contains("isPlayingContent"))
assertTrue(listAdapter.contains("R.drawable.ic_player_pause"))
assertTrue(listAdapter.contains("R.drawable.ic_new_player_play"))
assertTrue(listAdapter.contains("private val onPostClick: (CreatorChannelCommunityPostUiModel) -> Unit = {}"))
assertTrue(listAdapter.contains("private val onMoreClick: (CreatorChannelCommunityMenuItem) -> Unit = {}"))
assertTrue(listAdapter.contains("root.setOnClickListener { onPostClick(item) }"))
assertTrue(listAdapter.contains("ivCreatorChannelCommunityListPlay.setOnClickListener { onPlayClick(item) }"))
assertTrue(listAdapter.contains("onOwnerMoreClick(item)"))
assertTrue(listAdapter.contains("ivCreatorChannelCommunityListOwnerMore.setOnClickListener { onOwnerMoreClick(item) }"))
assertTrue(
listAdapter.contains(
"btnCreatorChannelCommunityListMore.setOnClickListener { onMoreClick(item.menuItem) }"
)
)
assertTrue(listAdapter.contains("item.showOwnerTopPrice || item.menuItem.showMore"))
assertFalse(listAdapter.contains("onPlayClick"))
assertFalse(listAdapter.contains("onOwnerMoreClick"))
assertFalse(listAdapter.contains("isPlayingContent"))
assertFalse(listAdapter.contains("showPlayButton"))
assertFalse(listAdapter.contains("showOwnerMore"))
assertTrue(gridAdapter.contains("private val onPostClick: (CreatorChannelCommunityPostUiModel) -> Unit = {}"))
assertTrue(gridAdapter.contains("root.setOnClickListener { onPostClick(item) }"))
assertTrue(listAdapter.contains("tvCreatorChannelCommunityListLockedPrice.isVisible = item.isLocked"))
assertFalse(gridAdapter.contains("onMoreClick"))
assertFalse(gridAdapter.contains("CreatorChannelCommunityMenuItem"))
assertTrue(listAdapter.contains("layoutCreatorChannelCommunityListLockedPrice.isVisible = item.isLocked"))
assertTrue(listAdapter.contains("layoutCreatorChannelCommunityListTopPrice.isVisible = item.showOwnerTopPrice"))
assertTrue(!listAdapter.contains("item.isLocked || item.showOwnerTopPrice"))
assertTrue(gridAdapter.contains(".asBitmap()"))
@@ -211,8 +225,17 @@ class CreatorChannelCommunityFragmentLayoutTest {
).readText()
assertTrue(fragment.contains("onPostClick = { item -> host.onCreatorChannelCommunityPostClicked(item.postId) }"))
assertTrue(fragment.contains("onMoreClick = { item -> host.onCreatorChannelCommunityMoreClicked(item) }"))
assertTrue(fragment.contains("private val gridAdapter = CreatorChannelCommunityGridAdapter("))
assertTrue(fragment.contains("fun onCreatorChannelCommunityPostClicked(postId: Long)"))
assertTrue(fragment.contains("fun onCreatorChannelCommunityMoreClicked(item: CreatorChannelCommunityMenuItem)"))
assertFalse(fragment.contains("onPlayClick"))
assertFalse(fragment.contains("onCreatorChannelCommunityOwnerMoreClicked"))
assertFalse(
fragment.substringAfter("private val gridAdapter = CreatorChannelCommunityGridAdapter(")
.substringBefore(")\n private var currentContentState")
.contains("onMoreClick")
)
}
@Test

View File

@@ -66,7 +66,7 @@ class CreatorChannelCommunityMapperTest {
}
@Test
fun `유료 미구매 타인 게시글은 잠금 상태이고 play button을 숨긴다`() {
fun `유료 미구매 타인 게시글은 잠금 상태이고 이미지를 숨긴다`() {
val item = listOf(
communityPost(price = 100, existOrdered = false, imageUrl = "image.png", audioUrl = "audio.mp3")
).toCommunityPostUiModels(relativeTimeTextFormatter, isOwner = false, currentUserId = 99L).single()
@@ -74,7 +74,6 @@ class CreatorChannelCommunityMapperTest {
assertTrue(item.isLocked)
assertNull(item.imageUrl)
assertEquals(CreatorChannelCommunityImageMode.LockedGray, item.imageMode)
assertFalse(item.showPlayButton)
}
@Test
@@ -93,7 +92,7 @@ class CreatorChannelCommunityMapperTest {
}
@Test
fun `고정 게시글 여부는 owner more 정책에서 사용할 수 있도록 UI 모델에 보존한다`() {
fun `고정 게시글 여부는 UI 모델에 보존한다`() {
val pinnedItem = listOf(communityPost(isPinned = true))
.toCommunityPostUiModels(relativeTimeTextFormatter, isOwner = true, currentUserId = 10L)
.single()
@@ -108,33 +107,32 @@ class CreatorChannelCommunityMapperTest {
}
@Test
fun `본인 또는 구매한 사용자는 이미지와 오디오가 있으면 play button을 표시한다`() {
val ownerItem = listOf(
communityPost(price = 100, existOrdered = false, imageUrl = "image.png", audioUrl = "audio.mp3")
).toCommunityPostUiModels(relativeTimeTextFormatter, isOwner = true, currentUserId = 10L).single()
val orderedItem = listOf(
communityPost(price = 100, existOrdered = true, imageUrl = "image.png", audioUrl = "audio.mp3")
).toCommunityPostUiModels(relativeTimeTextFormatter, isOwner = false, currentUserId = 99L).single()
fun `메뉴 권한은 작성자 무료 구매 여부에 따라 계산하고 고정 상태를 보존한다`() {
val authorPaidNotOrdered = listOf(communityPost(price = 100, existOrdered = false, isPinned = true))
.toCommunityPostUiModels(relativeTimeTextFormatter, isOwner = false, currentUserId = 10L)
.single()
val visitorFree = listOf(communityPost(price = 0, existOrdered = false))
.toCommunityPostUiModels(relativeTimeTextFormatter, isOwner = false, currentUserId = 99L)
.single()
val visitorPaidOrdered = listOf(communityPost(price = 100, existOrdered = true))
.toCommunityPostUiModels(relativeTimeTextFormatter, isOwner = false, currentUserId = 99L)
.single()
val visitorPaidNotOrdered = listOf(communityPost(price = 100, existOrdered = false))
.toCommunityPostUiModels(relativeTimeTextFormatter, isOwner = false, currentUserId = 99L)
.single()
assertFalse(ownerItem.isLocked)
assertTrue(ownerItem.showPlayButton)
assertFalse(orderedItem.isLocked)
assertTrue(orderedItem.showPlayButton)
assertTrue(authorPaidNotOrdered.menuItem.isOwner)
assertTrue(authorPaidNotOrdered.menuItem.showMore)
assertTrue(authorPaidNotOrdered.menuItem.isPinned)
assertTrue(authorPaidNotOrdered.isPinned)
assertTrue(visitorFree.menuItem.showMore)
assertTrue(visitorPaidOrdered.menuItem.showMore)
assertFalse(visitorPaidNotOrdered.menuItem.isOwner)
assertFalse(visitorPaidNotOrdered.menuItem.showMore)
}
@Test
fun `오디오 또는 이미지가 없으면 play button을 숨긴다`() {
val items = listOf(
communityPost(postId = 1L, imageUrl = null, audioUrl = "audio.mp3"),
communityPost(postId = 2L, imageUrl = "image.png", audioUrl = null),
communityPost(postId = 3L, imageUrl = "image.png", audioUrl = " ")
).toCommunityPostUiModels(relativeTimeTextFormatter, isOwner = false, currentUserId = 99L)
assertEquals(listOf(false, false, false), items.map { it.showPlayButton })
}
@Test
fun `본인 채널에 본인이 쓴 게시글에서만 owner more와 유료 top price를 표시한다`() {
fun `본인 채널에 본인이 쓴 유료 게시글에서만 top price를 표시한다`() {
val ownerPaid = listOf(communityPost(price = 100, creatorId = 10L))
.toCommunityPostUiModels(relativeTimeTextFormatter, isOwner = true, currentUserId = 10L)
.single()
@@ -148,13 +146,9 @@ class CreatorChannelCommunityMapperTest {
.toCommunityPostUiModels(relativeTimeTextFormatter, isOwner = false, currentUserId = 10L)
.single()
assertTrue(ownerPaid.showOwnerMore)
assertTrue(ownerPaid.showOwnerTopPrice)
assertTrue(ownerFree.showOwnerMore)
assertFalse(ownerFree.showOwnerTopPrice)
assertFalse(otherCreator.showOwnerMore)
assertFalse(otherCreator.showOwnerTopPrice)
assertFalse(otherChannel.showOwnerMore)
assertFalse(otherChannel.showOwnerTopPrice)
}

View File

@@ -3,7 +3,6 @@ package kr.co.vividnext.sodalive.v2.main.home
import android.app.Application
import android.content.Context
import android.content.res.Configuration
import android.graphics.drawable.ColorDrawable
import android.text.Spanned
import android.text.TextUtils
import android.text.style.ClickableSpan
@@ -659,6 +658,19 @@ class HomeMainFragmentLayoutTest {
assertEquals("https://example.com/audio.m4a", item.toUiModel().item.audioUrl)
}
@Test
fun `popular community mapper preserves locked item reactions`() {
val item = popularCommunityData(
audioUrl = null,
price = 100,
existOrdered = false
).toUiModel().item
assertTrue(item.isLocked)
assertTrue(item.showReaction)
assertTrue(item.showComment)
}
@Test
fun `home relative time formatter converts active creator utc timestamp to relative time text`() {
val context = ApplicationProvider.getApplicationContext<Context>()
@@ -1509,7 +1521,9 @@ class HomeMainFragmentLayoutTest {
private fun popularCommunityData(
audioUrl: String?,
createdAt: String = "2분 전"
createdAt: String = "2분 전",
price: Int = 0,
existOrdered: Boolean = false
): HomePopularCommunityPostItem {
return HomePopularCommunityPostItem(
postId = 1L,
@@ -1519,11 +1533,11 @@ class HomeMainFragmentLayoutTest {
imageUrl = null,
audioUrl = audioUrl,
content = "본문",
price = 0,
price = price,
createdAt = createdAt,
likeCount = 6L,
commentCount = 5L,
existOrdered = false
existOrdered = existOrdered
)
}

View File

@@ -1,6 +1,8 @@
package kr.co.vividnext.sodalive.v2.widget.feed
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import kr.co.vividnext.sodalive.R
import org.junit.Test
@@ -102,4 +104,71 @@ class FeedItemTest {
assertEquals(5, item.commentCount)
assertEquals(6, item.likeCount)
}
@Test
fun `locked paid community item hides reactions and comments`() {
val item = communityItem(
price = 100,
existOrdered = false,
hideReactionWhenLocked = true
)
assertTrue(item.isLocked)
assertFalse(item.showReaction)
assertFalse(item.showComment)
}
@Test
fun `locked paid community item preserves reactions by default`() {
val item = communityItem(price = 100, existOrdered = false)
assertTrue(item.isLocked)
assertTrue(item.showReaction)
assertTrue(item.showComment)
}
@Test
fun `free community item with comments disabled keeps likes only`() {
val item = communityItem(price = 0, isCommentAvailable = false)
assertFalse(item.isLocked)
assertTrue(item.showReaction)
assertFalse(item.showComment)
}
@Test
fun `community item preserves pinned state`() {
val item = communityItem(isPinned = true)
assertTrue(item.isPinned)
}
@Test
fun `community item hides more action by default`() {
assertFalse(communityItem().showMore)
}
private fun communityItem(
price: Int = 0,
existOrdered: Boolean = false,
isPinned: Boolean = false,
isCommentAvailable: Boolean = true,
hideReactionWhenLocked: Boolean = false
) = FeedItem.Community(
feedId = "feed-community-policy",
creatorId = "creator-1",
creatorName = "크리에이터 이름",
creatorImageUrl = "https://example.com/profile.png",
postId = "post-policy",
bodyText = "커뮤니티 본문",
keywordText = "",
createdAtText = "2분 전",
commentCount = 5,
likeCount = 6,
price = price,
existOrdered = existOrdered,
isPinned = isPinned,
isCommentAvailable = isCommentAvailable,
hideReactionWhenLocked = hideReactionWhenLocked
)
}

View File

@@ -63,6 +63,28 @@ class FeedViewTest {
assertEquals(View.GONE, view.findViewById<TextView>(R.id.tv_feed_community_body).visibility)
}
@Test
fun `community more click은 현재 bind된 item만 전달한다`() {
val view = inflateView<FeedCommunityView>(R.layout.view_feed_community)
val first = sampleCommunityItem(bodyText = "첫 번째", keywordText = "", showMore = true)
val second = sampleCommunityItem(bodyText = "두 번째", keywordText = "", showMore = true)
var clickedItem: FeedItem.Community? = null
var rootClickedItem: FeedItem? = null
view.setOnMoreClick { clickedItem = it }
view.setOnFeedClick { rootClickedItem = it }
view.bind(first)
view.bind(second)
view.findViewById<View>(R.id.btn_feed_community_more).performClick()
assertSame(second, clickedItem)
assertEquals(null, rootClickedItem)
view.performClick()
assertSame(second, rootClickedItem)
}
@Test
fun `community layout uses runtime image height instead of fixed crop height`() {
val view = inflateView<FeedCommunityView>(R.layout.view_feed_community)
@@ -201,7 +223,8 @@ class FeedViewTest {
audioUrl: String? = null,
price: Int = 0,
existOrdered: Boolean = false,
showKeyword: Boolean = true
showKeyword: Boolean = true,
showMore: Boolean = false
) = FeedItem.Community(
feedId = "feed-community-1",
creatorId = "creator-1",
@@ -217,7 +240,8 @@ class FeedViewTest {
audioUrl = audioUrl,
price = price,
existOrdered = existOrdered,
showKeyword = showKeyword
showKeyword = showKeyword,
showMore = showMore
)
private fun Int.dpToPx(): Int {