feat(content): 추천 API 상태를 화면에 바인딩한다

This commit is contained in:
2026-06-23 17:18:39 +09:00
parent 1a45f42f9e
commit e4d650c3e7
3 changed files with 439 additions and 20 deletions

View File

@@ -1,14 +1,55 @@
package kr.co.vividnext.sodalive.v2.main.content
import android.content.Intent
import android.os.Bundle
import android.view.View
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.audio_content.detail.AudioContentDetailActivity
import kr.co.vividnext.sodalive.audio_content.series.detail.SeriesDetailActivity
import kr.co.vividnext.sodalive.base.BaseFragment
import kr.co.vividnext.sodalive.common.Constants
import kr.co.vividnext.sodalive.common.LoadingDialog
import kr.co.vividnext.sodalive.common.ToastMessage
import kr.co.vividnext.sodalive.databinding.FragmentV2MainContentBinding
import kr.co.vividnext.sodalive.databinding.ViewSectionTitleBinding
import kr.co.vividnext.sodalive.v2.main.content.model.AudioRecommendationsUiState
import kr.co.vividnext.sodalive.v2.main.content.model.ContentAudioCardSection
import kr.co.vividnext.sodalive.v2.main.content.model.ContentAudioCardUiModel
import kr.co.vividnext.sodalive.v2.main.content.model.ContentBannerSection
import kr.co.vividnext.sodalive.v2.main.content.model.ContentBannerUiModel
import kr.co.vividnext.sodalive.v2.main.content.model.ContentCommentedAudioSection
import kr.co.vividnext.sodalive.v2.main.content.model.ContentCommentedAudioUiModel
import kr.co.vividnext.sodalive.v2.main.content.model.ContentOriginalSeriesSection
import kr.co.vividnext.sodalive.v2.main.content.model.ContentOriginalSeriesUiModel
import kr.co.vividnext.sodalive.v2.main.content.model.toContentBannerIntent
import kr.co.vividnext.sodalive.v2.main.content.model.toContentBannerRoute
import kr.co.vividnext.sodalive.v2.main.content.ui.CONTENT_RECOMMENDED_GRID_SPAN_COUNT
import kr.co.vividnext.sodalive.v2.main.content.ui.ContentAudioCardAdapter
import kr.co.vividnext.sodalive.v2.main.content.ui.ContentBannerBinder
import kr.co.vividnext.sodalive.v2.main.content.ui.ContentCommentedAudioAdapter
import kr.co.vividnext.sodalive.v2.main.content.ui.ContentNewAndHotAdapter
import kr.co.vividnext.sodalive.v2.main.content.ui.ContentOriginalSeriesAdapter
import kr.co.vividnext.sodalive.v2.main.content.ui.addContentGridItemSpacing
import kr.co.vividnext.sodalive.v2.main.content.ui.addContentHorizontalItemSpacing
import kr.co.vividnext.sodalive.v2.widget.AudioContentCardSize
import org.koin.androidx.viewmodel.ext.android.viewModel
class ContentMainFragment : BaseFragment<FragmentV2MainContentBinding>(
FragmentV2MainContentBinding::inflate
) {
private val contentMainViewModel: ContentMainViewModel by viewModel()
private val loadingDialog: LoadingDialog by lazy { LoadingDialog(requireActivity(), layoutInflater) }
private val originalSeriesAdapter = ContentOriginalSeriesAdapter { openSeriesDetail(it) }
private val latestAudioAdapter = ContentAudioCardAdapter(AudioContentCardSize.Medium) { openAudioContentDetail(it) }
private val newAndHotAdapter = ContentNewAndHotAdapter { openAudioContentDetail(it) }
private val freeAudioAdapter = ContentAudioCardAdapter(AudioContentCardSize.Medium) { openAudioContentDetail(it) }
private val pointAudioAdapter = ContentAudioCardAdapter(AudioContentCardSize.Medium) { openAudioContentDetail(it) }
private val commentedAudioAdapter = ContentCommentedAudioAdapter { openAudioContentDetail(it) }
private val recommendedAudioAdapter = ContentAudioCardAdapter(AudioContentCardSize.Large) { openAudioContentDetail(it) }
private var bannerBinder: ContentBannerBinder? = null
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.textTabBarContent.root.setMenus(
@@ -16,29 +57,188 @@ class ContentMainFragment : BaseFragment<FragmentV2MainContentBinding>(
selectedIndex = 0
)
setUpSectionTitles()
setUpAdapters()
bindObservers()
contentMainViewModel.loadRecommendations()
}
private fun setUpAdapters() {
bannerBinder = ContentBannerBinder(binding.rvContentBanners).apply {
setOnBannerClick { onBannerClick(it) }
}
binding.rvContentOriginalSeries.apply {
layoutManager = LinearLayoutManager(requireContext(), LinearLayoutManager.HORIZONTAL, false)
adapter = originalSeriesAdapter
addContentHorizontalItemSpacing()
}
binding.rvContentLatestAudios.apply {
layoutManager = LinearLayoutManager(requireContext(), LinearLayoutManager.HORIZONTAL, false)
adapter = latestAudioAdapter
addContentHorizontalItemSpacing()
}
binding.rvContentNewAndHotAudios.apply {
layoutManager = LinearLayoutManager(requireContext(), LinearLayoutManager.HORIZONTAL, false)
adapter = newAndHotAdapter
addContentHorizontalItemSpacing()
}
binding.rvContentFreeAudios.apply {
layoutManager = LinearLayoutManager(requireContext(), LinearLayoutManager.HORIZONTAL, false)
adapter = freeAudioAdapter
addContentHorizontalItemSpacing()
}
binding.rvContentPointAudios.apply {
layoutManager = LinearLayoutManager(requireContext(), LinearLayoutManager.HORIZONTAL, false)
adapter = pointAudioAdapter
addContentHorizontalItemSpacing()
}
binding.rvContentMostCommentedAudios.apply {
layoutManager = LinearLayoutManager(requireContext(), LinearLayoutManager.HORIZONTAL, false)
adapter = commentedAudioAdapter
addContentHorizontalItemSpacing()
}
binding.rvContentRecommendedAudios.apply {
layoutManager = GridLayoutManager(requireContext(), CONTENT_RECOMMENDED_GRID_SPAN_COUNT)
adapter = recommendedAudioAdapter
addContentGridItemSpacing()
}
}
private fun bindObservers() {
contentMainViewModel.recommendationsStateLiveData.observe(viewLifecycleOwner) { state ->
when (state) {
is AudioRecommendationsUiState.Content -> bindContent(state)
AudioRecommendationsUiState.Empty,
is AudioRecommendationsUiState.Error -> bindContent(emptyContent())
AudioRecommendationsUiState.Loading -> Unit
}
}
contentMainViewModel.isLoading.observe(viewLifecycleOwner) { isLoading ->
if (isLoading) {
loadingDialog.show(screenWidth)
} else {
loadingDialog.dismiss()
}
}
contentMainViewModel.toastLiveData.observe(viewLifecycleOwner) { toastMessage ->
toastMessage?.let(::showToast)
}
}
private fun bindContent(content: AudioRecommendationsUiState.Content) {
bindBannerSection(content.banners)
bindOriginalSeriesSection(content.originalSeries)
bindLatestAudioSection(content.latestAudios)
bindNewAndHotSection(content.newAndHotAudios)
bindFreeAudioSection(content.freeAudios)
bindPointAudioSection(content.pointAudios)
bindCommentedAudioSection(content.mostCommentedAudios)
bindRecommendedAudioSection(content.recommendedAudios)
}
private fun bindBannerSection(section: ContentBannerSection) {
binding.llContentBannerSection.visibility = section.items.toSectionVisibility()
bannerBinder?.bind(section)
}
private fun bindOriginalSeriesSection(section: ContentOriginalSeriesSection) {
binding.llContentOriginalSeriesSection.visibility = section.items.toSectionVisibility()
originalSeriesAdapter.submitItems(section.items)
}
private fun bindLatestAudioSection(section: ContentAudioCardSection) {
binding.llContentLatestAudioSection.visibility = section.items.toSectionVisibility()
latestAudioAdapter.submitItems(section.items)
}
private fun bindNewAndHotSection(section: ContentAudioCardSection) {
binding.llContentNewAndHotSection.visibility = section.items.toSectionVisibility()
newAndHotAdapter.submitItems(section.items)
}
private fun bindFreeAudioSection(section: ContentAudioCardSection) {
binding.llContentFreeAudioSection.visibility = section.items.toSectionVisibility()
freeAudioAdapter.submitItems(section.items)
}
private fun bindPointAudioSection(section: ContentAudioCardSection) {
binding.llContentPointAudioSection.visibility = section.items.toSectionVisibility()
pointAudioAdapter.submitItems(section.items)
}
private fun bindCommentedAudioSection(section: ContentCommentedAudioSection) {
binding.llContentMostCommentedAudioSection.visibility = section.items.toSectionVisibility()
commentedAudioAdapter.submitItems(section.items)
}
private fun bindRecommendedAudioSection(section: ContentAudioCardSection) {
binding.llContentRecommendedAudioSection.visibility = section.items.toSectionVisibility()
recommendedAudioAdapter.submitItems(section.items)
}
private fun setUpSectionTitles() {
binding.viewContentOriginalSeriesTitle.tvSectionTitle.setText(
R.string.content_recommendation_section_original_series
)
binding.viewContentLatestAudioTitle.tvSectionTitle.setText(
R.string.content_recommendation_section_latest_audio
)
binding.viewContentNewAndHotTitle.tvSectionTitle.setText(
R.string.content_recommendation_section_new_and_hot
)
binding.viewContentFreeAudioTitle.tvSectionTitle.setText(
R.string.content_recommendation_section_free_audio
)
binding.viewContentPointAudioTitle.tvSectionTitle.setText(
R.string.content_recommendation_section_point_audio
)
binding.viewContentMostCommentedAudioTitle.tvSectionTitle.setText(
R.string.content_recommendation_section_most_commented_audio
)
binding.viewContentRecommendedAudioTitle.tvSectionTitle.setText(
R.string.content_recommendation_section_recommended_audio
binding.viewContentOriginalSeriesTitle.setTitle(R.string.content_recommendation_section_original_series)
binding.viewContentLatestAudioTitle.setTitle(R.string.content_recommendation_section_latest_audio)
binding.viewContentNewAndHotTitle.setTitle(R.string.content_recommendation_section_new_and_hot)
binding.viewContentFreeAudioTitle.setTitle(R.string.content_recommendation_section_free_audio)
binding.viewContentPointAudioTitle.setTitle(R.string.content_recommendation_section_point_audio)
binding.viewContentMostCommentedAudioTitle.setTitle(R.string.content_recommendation_section_most_commented_audio)
binding.viewContentRecommendedAudioTitle.setTitle(R.string.content_recommendation_section_recommended_audio)
}
private fun ViewSectionTitleBinding.setTitle(titleResId: Int) {
tvSectionTitle.setText(titleResId)
ivSectionTitleChevron.visibility = View.GONE
}
private fun onBannerClick(item: ContentBannerUiModel) {
val route = item.toContentBannerRoute() ?: return
startActivity(route.toContentBannerIntent(requireContext()))
}
private fun openAudioContentDetail(item: ContentAudioCardUiModel) {
openAudioContentDetail(item.audioContentId)
}
private fun openAudioContentDetail(item: ContentCommentedAudioUiModel) {
openAudioContentDetail(item.audioContentId)
}
private fun openAudioContentDetail(audioContentId: Long) {
if (audioContentId <= 0L) return
startActivity(
Intent(requireContext(), AudioContentDetailActivity::class.java).apply {
putExtra(Constants.EXTRA_AUDIO_CONTENT_ID, audioContentId)
}
)
}
private fun openSeriesDetail(item: ContentOriginalSeriesUiModel) {
val seriesId = item.seriesId.takeIf { it > 0L } ?: return
startActivity(
Intent(requireContext(), SeriesDetailActivity::class.java).apply {
putExtra(Constants.EXTRA_SERIES_ID, seriesId)
}
)
}
private fun showToast(toastMessage: ToastMessage) {
toastMessage.message?.let { message -> showToast(message) }
?: toastMessage.resId?.let { resId -> showToast(getString(resId)) }
}
private fun emptyContent(): AudioRecommendationsUiState.Content {
return AudioRecommendationsUiState.Content(
banners = ContentBannerSection(emptyList()),
originalSeries = ContentOriginalSeriesSection(emptyList()),
latestAudios = ContentAudioCardSection(emptyList()),
newAndHotAudios = ContentAudioCardSection(emptyList()),
freeAudios = ContentAudioCardSection(emptyList()),
pointAudios = ContentAudioCardSection(emptyList()),
mostCommentedAudios = ContentCommentedAudioSection(emptyList()),
recommendedAudios = ContentAudioCardSection(emptyList())
)
}
private fun List<*>.toSectionVisibility(): Int = if (isEmpty()) View.GONE else View.VISIBLE
}

View File

@@ -0,0 +1,41 @@
package kr.co.vividnext.sodalive.v2.main.content.ui
import android.graphics.Rect
import android.view.View
import androidx.recyclerview.widget.RecyclerView
import kr.co.vividnext.sodalive.extensions.dpToPx
fun RecyclerView.addContentHorizontalItemSpacing() {
if (itemDecorationCount == 0) addItemDecoration(ContentHorizontalItemDecoration())
}
fun RecyclerView.addContentGridItemSpacing() {
if (itemDecorationCount == 0) addItemDecoration(ContentGridItemDecoration())
}
private class ContentHorizontalItemDecoration : RecyclerView.ItemDecoration() {
override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) {
val position = parent.getChildAdapterPosition(view)
if (position != RecyclerView.NO_POSITION && position < (parent.adapter?.itemCount ?: 0) - 1) {
outRect.right = HORIZONTAL_ITEM_GAP_DP.dpToPx().toInt()
}
}
}
private class ContentGridItemDecoration : RecyclerView.ItemDecoration() {
override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) {
val position = parent.getChildAdapterPosition(view)
if (position == RecyclerView.NO_POSITION) return
val isLeftColumn = position % GRID_SPAN_COUNT == 0
val halfGap = GRID_ITEM_GAP_DP.dpToPx().toInt() / 2
outRect.left = if (isLeftColumn) 0 else halfGap
outRect.right = if (isLeftColumn) halfGap else 0
if (position >= GRID_SPAN_COUNT) outRect.top = GRID_ITEM_VERTICAL_GAP_DP.dpToPx().toInt()
}
}
private const val HORIZONTAL_ITEM_GAP_DP = 8
private const val GRID_ITEM_GAP_DP = 8
private const val GRID_ITEM_VERTICAL_GAP_DP = 28
const val CONTENT_RECOMMENDED_GRID_SPAN_COUNT = 2
private const val GRID_SPAN_COUNT = CONTENT_RECOMMENDED_GRID_SPAN_COUNT