feat(creator): 오디오 탭 콘텐츠 UI를 연결한다

This commit is contained in:
2026-06-19 21:04:01 +09:00
parent 5b89d6c6d7
commit bcbc48540e
2 changed files with 119 additions and 4 deletions

View File

@@ -5,14 +5,19 @@ import android.text.SpannableString
import android.text.Spanned import android.text.Spanned
import android.text.style.ForegroundColorSpan import android.text.style.ForegroundColorSpan
import android.view.View import android.view.View
import android.widget.Toast
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.core.view.updatePadding
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import kr.co.vividnext.sodalive.R import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.base.BaseFragment import kr.co.vividnext.sodalive.base.BaseFragment
import kr.co.vividnext.sodalive.databinding.FragmentCreatorChannelAudioBinding import kr.co.vividnext.sodalive.databinding.FragmentCreatorChannelAudioBinding
import kr.co.vividnext.sodalive.extensions.dpToPx
import kr.co.vividnext.sodalive.extensions.moneyFormat import kr.co.vividnext.sodalive.extensions.moneyFormat
import kr.co.vividnext.sodalive.v2.creator.channel.audio.model.CreatorChannelAudioRateUiModel import kr.co.vividnext.sodalive.v2.creator.channel.audio.model.CreatorChannelAudioRateUiModel
import kr.co.vividnext.sodalive.v2.creator.channel.ui.CreatorChannelAudioContentAdapter
import kr.co.vividnext.sodalive.v2.creator.channel.ui.CreatorChannelSortPopup
import kr.co.vividnext.sodalive.v2.creator.channel.model.toLabelResId import kr.co.vividnext.sodalive.v2.creator.channel.model.toLabelResId
import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.androidx.viewmodel.ext.android.viewModel
@@ -21,7 +26,16 @@ class CreatorChannelAudioFragment : BaseFragment<FragmentCreatorChannelAudioBind
) { ) {
private val viewModel: CreatorChannelAudioViewModel by viewModel() private val viewModel: CreatorChannelAudioViewModel by viewModel()
private val audioContentAdapter = CreatorChannelAudioContentAdapter { item ->
host.onCreatorChannelAudioContentClicked(item.audioContentId)
}
private var sortPopup: CreatorChannelSortPopup? = null
private var currentContentState: CreatorChannelAudioUiState.Content? = null
private var lastContentLayoutKey: CreatorChannelAudioContentLayoutKey? = null
private var emptyMinHeight: Int = 0
private val creatorId: Long by lazy { arguments?.getLong(ARG_CREATOR_ID) ?: 0L } private val creatorId: Long by lazy { arguments?.getLong(ARG_CREATOR_ID) ?: 0L }
private val host: Host
get() = requireActivity() as Host
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
@@ -29,22 +43,32 @@ class CreatorChannelAudioFragment : BaseFragment<FragmentCreatorChannelAudioBind
setupAudioList() setupAudioList()
setupClickListeners() setupClickListeners()
observeViewModel() observeViewModel()
if (creatorId > 0L) {
viewModel.loadAudio(creatorId, isOwner = false)
}
} }
override fun onDestroyView() { override fun onDestroyView() {
sortPopup?.dismiss()
sortPopup = null
currentContentState = null
lastContentLayoutKey = null
binding.rvCreatorChannelAudioContents.adapter = null binding.rvCreatorChannelAudioContents.adapter = null
super.onDestroyView() super.onDestroyView()
} }
private fun setupAudioList() = with(binding.rvCreatorChannelAudioContents) { private fun setupAudioList() = with(binding.rvCreatorChannelAudioContents) {
layoutManager = LinearLayoutManager(requireContext()) layoutManager = LinearLayoutManager(requireContext())
adapter = audioContentAdapter
} }
private fun setupClickListeners() = with(binding) { private fun setupClickListeners() = with(binding) {
ivCreatorChannelAudioSort.setImageResource(R.drawable.ic_new_sort) ivCreatorChannelAudioSort.setImageResource(R.drawable.ic_new_sort)
layoutCreatorChannelAudioSortButton.setOnClickListener {
currentContentState?.let { state -> showSortPopup(state) }
}
viewCreatorChannelAudioThemeTabs.root.setOnTabSelectedListener { index ->
currentContentState?.themes?.getOrNull(index)?.let { theme ->
viewModel.changeTheme(theme.themeId)
}
}
btnCreatorChannelAudioRetry.setOnClickListener { btnCreatorChannelAudioRetry.setOnClickListener {
viewModel.retryAudio() viewModel.retryAudio()
} }
@@ -61,7 +85,38 @@ class CreatorChannelAudioFragment : BaseFragment<FragmentCreatorChannelAudioBind
} }
} }
fun onCreatorChannelAudioTabSelected() {
if (creatorId > 0L) {
viewModel.loadAudio(creatorId, isOwner = host.isCreatorChannelOwner())
}
}
fun onCreatorChannelAudioScrolledToBottom() {
viewModel.loadMore()
}
fun onCreatorChannelAudioViewportHeightChanged(minHeight: Int) {
emptyMinHeight = minHeight.coerceAtLeast(0)
applyEmptyMinHeight()
}
fun onCreatorChannelAudioOwnerCtaVisibilityChanged(isVisible: Boolean) = with(binding) {
val bottomPadding = if (isVisible) {
OWNER_CTA_LIST_BOTTOM_PADDING_DP.dpToPx().toInt()
} else {
DEFAULT_LIST_BOTTOM_PADDING_DP.dpToPx().toInt()
}
rvCreatorChannelAudioContents.updatePadding(bottom = bottomPadding)
layoutCreatorChannelAudioEmpty.updatePadding(bottom = bottomPadding)
}
private fun applyEmptyMinHeight() = with(binding) {
layoutCreatorChannelAudioEmpty.minimumHeight = emptyMinHeight
}
private fun bindLoading() = with(binding) { private fun bindLoading() = with(binding) {
currentContentState = null
lastContentLayoutKey = null
viewCreatorChannelAudioThemeTabs.root.isVisible = false viewCreatorChannelAudioThemeTabs.root.isVisible = false
layoutCreatorChannelAudioSortBar.isVisible = false layoutCreatorChannelAudioSortBar.isVisible = false
layoutCreatorChannelAudioRateCard.isVisible = false layoutCreatorChannelAudioRateCard.isVisible = false
@@ -72,16 +127,22 @@ class CreatorChannelAudioFragment : BaseFragment<FragmentCreatorChannelAudioBind
} }
private fun bindEmpty() = with(binding) { private fun bindEmpty() = with(binding) {
currentContentState = null
lastContentLayoutKey = null
viewCreatorChannelAudioThemeTabs.root.isVisible = false viewCreatorChannelAudioThemeTabs.root.isVisible = false
layoutCreatorChannelAudioSortBar.isVisible = false layoutCreatorChannelAudioSortBar.isVisible = false
layoutCreatorChannelAudioRateCard.isVisible = false layoutCreatorChannelAudioRateCard.isVisible = false
rvCreatorChannelAudioContents.isVisible = false rvCreatorChannelAudioContents.isVisible = false
layoutCreatorChannelAudioEmpty.isVisible = true layoutCreatorChannelAudioEmpty.isVisible = true
applyEmptyMinHeight()
tvCreatorChannelAudioErrorMessage.isVisible = false tvCreatorChannelAudioErrorMessage.isVisible = false
btnCreatorChannelAudioRetry.isVisible = false btnCreatorChannelAudioRetry.isVisible = false
host.onCreatorChannelAudioContentChanged()
} }
private fun bindError(state: CreatorChannelAudioUiState.Error) = with(binding) { private fun bindError(state: CreatorChannelAudioUiState.Error) = with(binding) {
currentContentState = null
lastContentLayoutKey = null
viewCreatorChannelAudioThemeTabs.root.isVisible = false viewCreatorChannelAudioThemeTabs.root.isVisible = false
layoutCreatorChannelAudioSortBar.isVisible = false layoutCreatorChannelAudioSortBar.isVisible = false
layoutCreatorChannelAudioRateCard.isVisible = false layoutCreatorChannelAudioRateCard.isVisible = false
@@ -90,9 +151,11 @@ class CreatorChannelAudioFragment : BaseFragment<FragmentCreatorChannelAudioBind
tvCreatorChannelAudioErrorMessage.isVisible = true tvCreatorChannelAudioErrorMessage.isVisible = true
tvCreatorChannelAudioErrorMessage.text = state.message ?: getString(R.string.creator_channel_audio_error_message) tvCreatorChannelAudioErrorMessage.text = state.message ?: getString(R.string.creator_channel_audio_error_message)
btnCreatorChannelAudioRetry.isVisible = true btnCreatorChannelAudioRetry.isVisible = true
host.onCreatorChannelAudioContentChanged()
} }
private fun bindContent(state: CreatorChannelAudioUiState.Content) = with(binding) { private fun bindContent(state: CreatorChannelAudioUiState.Content) = with(binding) {
currentContentState = state
viewCreatorChannelAudioThemeTabs.root.isVisible = true viewCreatorChannelAudioThemeTabs.root.isVisible = true
viewCreatorChannelAudioThemeTabs.root.setMenus( viewCreatorChannelAudioThemeTabs.root.setMenus(
menus = state.themes.map { it.title }, menus = state.themes.map { it.title },
@@ -103,9 +166,32 @@ class CreatorChannelAudioFragment : BaseFragment<FragmentCreatorChannelAudioBind
tvCreatorChannelAudioSortLabel.setText(state.selectedSort.toLabelResId()) tvCreatorChannelAudioSortLabel.setText(state.selectedSort.toLabelResId())
bindRate(state.rate) bindRate(state.rate)
rvCreatorChannelAudioContents.isVisible = true rvCreatorChannelAudioContents.isVisible = true
audioContentAdapter.submitItems(state.audioContents)
layoutCreatorChannelAudioEmpty.isVisible = false layoutCreatorChannelAudioEmpty.isVisible = false
tvCreatorChannelAudioErrorMessage.isVisible = false tvCreatorChannelAudioErrorMessage.isVisible = false
btnCreatorChannelAudioRetry.isVisible = false btnCreatorChannelAudioRetry.isVisible = false
notifyContentChangedIfLayoutChanged(state)
state.paginationErrorMessage?.let {
Toast.makeText(requireContext(), it, Toast.LENGTH_SHORT).show()
viewModel.consumePaginationErrorMessage()
}
}
private fun notifyContentChangedIfLayoutChanged(state: CreatorChannelAudioUiState.Content) {
val contentLayoutKey = state.toContentLayoutKey()
if (contentLayoutKey == lastContentLayoutKey) return
lastContentLayoutKey = contentLayoutKey
host.onCreatorChannelAudioContentChanged()
}
private fun showSortPopup(state: CreatorChannelAudioUiState.Content) {
sortPopup?.dismiss()
sortPopup = CreatorChannelSortPopup(
anchor = binding.layoutCreatorChannelAudioSortButton,
selectedSort = state.selectedSort,
onSortSelected = { sort -> viewModel.changeSort(sort) }
).also { it.show() }
} }
private fun bindRate(rate: CreatorChannelAudioRateUiModel?) = with(binding) { private fun bindRate(rate: CreatorChannelAudioRateUiModel?) = with(binding) {
@@ -142,8 +228,16 @@ class CreatorChannelAudioFragment : BaseFragment<FragmentCreatorChannelAudioBind
return spannable return spannable
} }
interface Host {
fun isCreatorChannelOwner(): Boolean
fun onCreatorChannelAudioContentClicked(audioContentId: Long)
fun onCreatorChannelAudioContentChanged()
}
companion object { companion object {
private const val ARG_CREATOR_ID: String = "arg_creator_id" private const val ARG_CREATOR_ID: String = "arg_creator_id"
private const val DEFAULT_LIST_BOTTOM_PADDING_DP = 32
private const val OWNER_CTA_LIST_BOTTOM_PADDING_DP = 132
fun newInstance(creatorId: Long): CreatorChannelAudioFragment { fun newInstance(creatorId: Long): CreatorChannelAudioFragment {
return CreatorChannelAudioFragment().apply { return CreatorChannelAudioFragment().apply {
@@ -152,3 +246,17 @@ class CreatorChannelAudioFragment : BaseFragment<FragmentCreatorChannelAudioBind
} }
} }
} }
private data class CreatorChannelAudioContentLayoutKey(
val audioContentCount: Int,
val selectedThemeId: Long?,
val audioContentIds: List<Long>
)
private fun CreatorChannelAudioUiState.Content.toContentLayoutKey(): CreatorChannelAudioContentLayoutKey {
return CreatorChannelAudioContentLayoutKey(
audioContentCount = audioContentCount,
selectedThemeId = selectedThemeId,
audioContentIds = audioContents.map { it.audioContentId }
)
}

View File

@@ -84,7 +84,7 @@ class CreatorChannelAudioFragmentLayoutTest {
val item = inflateView(R.layout.item_creator_channel_audio_content) val item = inflateView(R.layout.item_creator_channel_audio_content)
val itemLayout = projectFile("app/src/main/res/layout/item_creator_channel_audio_content.xml").readText() val itemLayout = projectFile("app/src/main/res/layout/item_creator_channel_audio_content.xml").readText()
val adapter = projectFile( val adapter = projectFile(
"app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/live/ui/CreatorChannelLiveReplayAdapter.kt" "app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/ui/CreatorChannelAudioContentAdapter.kt"
).readText() ).readText()
val thumbnail = requireNotNull(item.findViewById<View>(R.id.layout_creator_channel_audio_content_thumbnail)) val thumbnail = requireNotNull(item.findViewById<View>(R.id.layout_creator_channel_audio_content_thumbnail))
@@ -120,7 +120,14 @@ class CreatorChannelAudioFragmentLayoutTest {
assertTrue(fragment.contains("private val viewModel: CreatorChannelAudioViewModel by viewModel()")) assertTrue(fragment.contains("private val viewModel: CreatorChannelAudioViewModel by viewModel()"))
assertTrue(fragment.contains("fun newInstance(creatorId: Long): CreatorChannelAudioFragment")) assertTrue(fragment.contains("fun newInstance(creatorId: Long): CreatorChannelAudioFragment"))
assertTrue(fragment.contains("arguments = Bundle().apply { putLong(ARG_CREATOR_ID, creatorId) }")) assertTrue(fragment.contains("arguments = Bundle().apply { putLong(ARG_CREATOR_ID, creatorId) }"))
assertTrue(fragment.contains("fun onCreatorChannelAudioTabSelected()"))
assertTrue(fragment.contains("viewModel.loadAudio(creatorId, isOwner = host.isCreatorChannelOwner())"))
assertTrue(fragment.contains("fun onCreatorChannelAudioViewportHeightChanged(minHeight: Int)"))
assertTrue(fragment.contains("layoutCreatorChannelAudioEmpty.minimumHeight = emptyMinHeight"))
assertTrue(fragment.contains("notifyContentChangedIfLayoutChanged(state)"))
assertTrue(fragment.contains("if (contentLayoutKey == lastContentLayoutKey) return"))
assertTrue(fragment.contains("viewModel.audioStateLiveData.observe(viewLifecycleOwner)")) assertTrue(fragment.contains("viewModel.audioStateLiveData.observe(viewLifecycleOwner)"))
assertTrue(!fragment.contains("observeViewModel()\n if (creatorId > 0L)"))
assertTrue(fragment.contains("CreatorChannelAudioUiState.Loading -> bindLoading()")) assertTrue(fragment.contains("CreatorChannelAudioUiState.Loading -> bindLoading()"))
assertTrue(fragment.contains("CreatorChannelAudioUiState.Empty -> bindEmpty()")) assertTrue(fragment.contains("CreatorChannelAudioUiState.Empty -> bindEmpty()"))
assertTrue(fragment.contains("is CreatorChannelAudioUiState.Error -> bindError(state)")) assertTrue(fragment.contains("is CreatorChannelAudioUiState.Error -> bindError(state)"))