feat(content): 전체 탭 화면 연결을 추가한다

This commit is contained in:
2026-06-25 11:54:43 +09:00
parent 4df724ee56
commit f4a32086b0
2 changed files with 396 additions and 2 deletions

View File

@@ -2,9 +2,15 @@ package kr.co.vividnext.sodalive.v2.main.content
import android.content.Intent
import android.os.Bundle
import android.view.Gravity
import android.view.View
import android.view.ViewGroup
import android.widget.LinearLayout
import android.widget.TextView
import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
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
@@ -14,7 +20,13 @@ 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.extensions.moneyFormat
import kr.co.vividnext.sodalive.home.SeriesPublishedDaysOfWeek
import kr.co.vividnext.sodalive.v2.common.data.ContentSort
import kr.co.vividnext.sodalive.v2.creator.channel.model.toLabelResId
import kr.co.vividnext.sodalive.v2.creator.channel.ui.CreatorChannelSortPopup
import kr.co.vividnext.sodalive.v2.main.content.data.AudioRankingType
import kr.co.vividnext.sodalive.v2.main.content.data.MainContentAllType
import kr.co.vividnext.sodalive.v2.main.content.model.AudioRankingsUiState
import kr.co.vividnext.sodalive.v2.main.content.model.AudioRecommendationsUiState
import kr.co.vividnext.sodalive.v2.main.content.model.ContentAudioCardSection
@@ -25,9 +37,17 @@ import kr.co.vividnext.sodalive.v2.main.content.model.ContentCommentedAudioSecti
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.MainContentAllTabUiState
import kr.co.vividnext.sodalive.v2.main.content.model.contentAllDayOfWeekOptions
import kr.co.vividnext.sodalive.v2.main.content.model.toContentAllDayLabelResId
import kr.co.vividnext.sodalive.v2.main.content.model.toContentAllTypeLabelResId
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.model.usesDayOfWeekQuery
import kr.co.vividnext.sodalive.v2.main.content.model.usesSeriesItems
import kr.co.vividnext.sodalive.v2.main.content.ui.CONTENT_RECOMMENDED_GRID_SPAN_COUNT
import kr.co.vividnext.sodalive.v2.main.content.ui.ContentAllAudioCardAdapter
import kr.co.vividnext.sodalive.v2.main.content.ui.ContentAllSeriesCardAdapter
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
@@ -45,7 +65,15 @@ class ContentMainFragment : BaseFragment<FragmentV2MainContentBinding>(
) {
private val contentMainViewModel: ContentMainViewModel by viewModel()
private val contentRankingViewModel: ContentRankingViewModel by viewModel()
private val contentAllTabViewModel: ContentAllTabViewModel by viewModel()
private val loadingDialog: LoadingDialog by lazy { LoadingDialog(requireActivity(), layoutInflater) }
private val contentAllTypes = listOf(
MainContentAllType.AUDIO,
MainContentAllType.SERIES,
MainContentAllType.ORIGINAL,
MainContentAllType.FREE,
MainContentAllType.POINT
)
private val originalSeriesAdapter = ContentOriginalSeriesAdapter { openSeriesDetail(it) }
private val latestAudioAdapter = ContentAudioCardAdapter(AudioContentCardSize.Medium) { openAudioContentDetail(it) }
private val newAndHotAdapter = ContentNewAndHotAdapter { openAudioContentDetail(it) }
@@ -54,15 +82,27 @@ class ContentMainFragment : BaseFragment<FragmentV2MainContentBinding>(
private val commentedAudioAdapter = ContentCommentedAudioAdapter { openAudioContentDetail(it) }
private val recommendedAudioAdapter = ContentAudioCardAdapter(AudioContentCardSize.Large) { openAudioContentDetail(it) }
private val contentRankingAdapter = ContentRankingAdapter { openRankingAudioContentDetail(it) }
private val contentAllAudioCardAdapter = ContentAllAudioCardAdapter { audioContentId ->
openAudioContentDetail(audioContentId)
}
private val contentAllSeriesCardAdapter = ContentAllSeriesCardAdapter { seriesId -> openSeriesDetail(seriesId) }
private var bannerBinder: ContentBannerBinder? = null
private var sortPopup: CreatorChannelSortPopup? = null
private var isRecommendationLoading = false
private var isRankingLoading = false
private var isAllTabLoading = false
private var hasSelectedRankingTab = false
private var hasSelectedAllTab = false
private var currentContentTab = CONTENT_TAB_RECOMMENDATION
private var currentAllTabState: MainContentAllTabUiState? = null
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
setUpTextTabs()
setUpRankingTypeTabs()
setUpAllTypeTabs()
setUpAllDayFilter()
setUpAllSortButton()
showContentTab(CONTENT_TAB_RECOMMENDATION)
setUpSectionTitles()
setUpAdapters()
@@ -70,6 +110,14 @@ class ContentMainFragment : BaseFragment<FragmentV2MainContentBinding>(
contentMainViewModel.loadRecommendations()
}
override fun onDestroyView() {
sortPopup?.dismiss()
sortPopup = null
bannerBinder = null
binding.rvContentAllItems.adapter = null
super.onDestroyView()
}
private fun setUpTextTabs() {
binding.textTabBarContent.root.setMenus(
listOf(
@@ -94,11 +142,56 @@ class ContentMainFragment : BaseFragment<FragmentV2MainContentBinding>(
}
}
private fun setUpAllTypeTabs() {
binding.viewContentAllTypeTabs.root.setMenus(
contentAllTypes.map { type -> getString(type.toContentAllTypeLabelResId()) },
selectedIndex = MainContentAllType.AUDIO.ordinal
)
binding.viewContentAllTypeTabs.root.setOnTabSelectedListener { index ->
contentAllTabViewModel.changeType(contentAllTypes[index])
}
}
private fun setUpAllDayFilter() {
binding.layoutContentAllDayFilter.removeAllViews()
contentAllDayOfWeekOptions.forEach { dayOfWeek ->
binding.layoutContentAllDayFilter.addView(createAllDayFilterView(dayOfWeek))
}
}
private fun createAllDayFilterView(dayOfWeek: SeriesPublishedDaysOfWeek): TextView {
return TextView(requireContext()).apply {
tag = dayOfWeek
gravity = Gravity.CENTER
setText(dayOfWeek.toContentAllDayLabelResId())
setTextAppearance(R.style.Typography_Body5)
setPadding(
resources.getDimensionPixelSize(R.dimen.spacing_8),
0,
resources.getDimensionPixelSize(R.dimen.spacing_8),
0
)
layoutParams = LinearLayout.LayoutParams(
ViewGroup.LayoutParams.WRAP_CONTENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
setOnClickListener { contentAllTabViewModel.changeDayOfWeek(dayOfWeek) }
}
}
private fun setUpAllSortButton() {
binding.layoutContentAllSortButton.setOnClickListener {
val state = currentAllTabState ?: return@setOnClickListener
showAllSortPopup(state.selectedSort)
}
}
private fun showContentTab(index: Int) {
currentContentTab = index
when (index) {
CONTENT_TAB_RECOMMENDATION -> showRecommendationContent()
CONTENT_TAB_RANKING -> showRankingContent()
CONTENT_TAB_ALL -> hideContentSurfaces()
CONTENT_TAB_ALL -> showAllContent()
}
}
@@ -106,22 +199,37 @@ class ContentMainFragment : BaseFragment<FragmentV2MainContentBinding>(
binding.nsvContentRecommendationContent.visibility = View.VISIBLE
binding.viewContentRankingTypeTabs.root.visibility = View.GONE
binding.rvContentRankings.visibility = View.GONE
binding.layoutContentAllSurface.visibility = View.GONE
}
private fun showRankingContent() {
binding.nsvContentRecommendationContent.visibility = View.GONE
binding.viewContentRankingTypeTabs.root.visibility = View.VISIBLE
binding.rvContentRankings.visibility = View.VISIBLE
binding.layoutContentAllSurface.visibility = View.GONE
if (!hasSelectedRankingTab) {
hasSelectedRankingTab = true
contentRankingViewModel.loadRankings(AudioRankingType.WEEKLY_POPULAR)
}
}
private fun showAllContent() {
binding.nsvContentRecommendationContent.visibility = View.GONE
binding.viewContentRankingTypeTabs.root.visibility = View.GONE
binding.rvContentRankings.visibility = View.GONE
binding.layoutContentAllSurface.visibility = View.VISIBLE
currentAllTabState?.let(::renderAllTabState)
if (currentAllTabState == null && !hasSelectedAllTab) {
hasSelectedAllTab = true
contentAllTabViewModel.loadInitial()
}
}
private fun hideContentSurfaces() {
binding.nsvContentRecommendationContent.visibility = View.GONE
binding.viewContentRankingTypeTabs.root.visibility = View.GONE
binding.rvContentRankings.visibility = View.GONE
binding.layoutContentAllSurface.visibility = View.GONE
}
private fun setUpAdapters() {
@@ -167,6 +275,24 @@ class ContentMainFragment : BaseFragment<FragmentV2MainContentBinding>(
layoutManager = ContentRankingAdapter.createGridLayoutManager(requireContext())
adapter = contentRankingAdapter
}
val contentAllGridLayoutManager = GridLayoutManager(requireContext(), CONTENT_ALL_GRID_SPAN_COUNT)
binding.rvContentAllItems.apply {
layoutManager = contentAllGridLayoutManager
adapter = contentAllAudioCardAdapter
addContentGridItemSpacing(CONTENT_ALL_GRID_SPAN_COUNT)
addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
super.onScrolled(recyclerView, dx, dy)
if (dy <= 0) return
val itemCount = recyclerView.adapter?.itemCount ?: return
val lastVisiblePosition = contentAllGridLayoutManager.findLastVisibleItemPosition()
if (lastVisiblePosition >= itemCount - CONTENT_ALL_GRID_SPAN_COUNT) {
contentAllTabViewModel.loadMore()
}
}
})
}
}
private fun bindObservers() {
@@ -202,6 +328,116 @@ class ContentMainFragment : BaseFragment<FragmentV2MainContentBinding>(
contentRankingViewModel.toastLiveData.observe(viewLifecycleOwner) { toastMessage ->
toastMessage?.let(::showToast)
}
contentAllTabViewModel.allTabStateLiveData.observe(viewLifecycleOwner) { state ->
currentAllTabState = state
renderAllTabState(state)
}
contentAllTabViewModel.isLoading.observe(viewLifecycleOwner) { isLoading ->
isAllTabLoading = isLoading
updateLoadingDialog()
}
contentAllTabViewModel.toastLiveData.observe(viewLifecycleOwner) { toastMessage ->
toastMessage?.let(::showToast)
}
}
private fun renderAllTabState(state: MainContentAllTabUiState) {
if (currentContentTab == CONTENT_TAB_ALL) {
when (state) {
is MainContentAllTabUiState.Content -> bindAllTabContent(state)
is MainContentAllTabUiState.Empty -> bindAllTabEmpty(state)
is MainContentAllTabUiState.Error -> bindAllTabError(state)
is MainContentAllTabUiState.Loading -> bindAllTabLoading(state)
}
}
}
private fun bindAllTabContent(state: MainContentAllTabUiState.Content) {
bindAllTabControls(state)
binding.layoutContentAllSurface.visibility = View.VISIBLE
hideAllTabEmptyError()
if (state.selectedType.usesSeriesItems()) {
binding.rvContentAllItems.adapter = contentAllSeriesCardAdapter
contentAllAudioCardAdapter.submitItems(emptyList())
contentAllSeriesCardAdapter.submitItems(state.seriesItems)
} else {
binding.rvContentAllItems.adapter = contentAllAudioCardAdapter
contentAllSeriesCardAdapter.submitItems(emptyList())
contentAllAudioCardAdapter.submitItems(state.audioItems)
}
state.paginationErrorMessage?.let { message ->
showToast(message)
contentAllTabViewModel.consumePaginationErrorMessage()
}
}
private fun bindAllTabEmpty(state: MainContentAllTabUiState.Empty) {
bindAllTabControls(state)
binding.layoutContentAllSurface.visibility = View.VISIBLE
binding.layoutContentAllEmptyError.visibility = View.VISIBLE
binding.tvContentAllEmptyError.setText(R.string.screen_content_all_empty)
clearAllTabItems()
}
private fun bindAllTabError(state: MainContentAllTabUiState.Error) {
bindAllTabControls(state)
binding.layoutContentAllSurface.visibility = View.VISIBLE
binding.layoutContentAllEmptyError.visibility = View.VISIBLE
binding.tvContentAllEmptyError.text = state.message ?: getString(R.string.common_error_unknown)
clearAllTabItems()
}
private fun bindAllTabLoading(state: MainContentAllTabUiState.Loading) {
bindAllTabControls(state)
binding.layoutContentAllSurface.visibility = View.VISIBLE
hideAllTabEmptyError()
clearAllTabItems()
}
private fun bindAllTabControls(state: MainContentAllTabUiState) {
binding.tvContentAllTotalCount.text = state.totalCount.moneyFormat()
binding.tvContentAllSortLabel.setText(state.selectedSort.toLabelResId())
binding.viewContentAllTypeTabs.root.setMenus(
contentAllTypes.map { type -> getString(type.toContentAllTypeLabelResId()) },
selectedIndex = contentAllTypes.indexOf(state.selectedType).coerceAtLeast(0)
)
binding.layoutContentAllDayFilter.visibility = if (state.selectedType.usesDayOfWeekQuery()) View.VISIBLE else View.GONE
bindAllDayFilterSelection(state.selectedDayOfWeek)
}
private fun bindAllDayFilterSelection(selectedDayOfWeek: SeriesPublishedDaysOfWeek?) {
for (index in 0 until binding.layoutContentAllDayFilter.childCount) {
val child = binding.layoutContentAllDayFilter.getChildAt(index) as? TextView ?: continue
val isSelected = child.tag == selectedDayOfWeek
child.isSelected = isSelected
child.setBackgroundResource(if (isSelected) R.drawable.bg_content_all_day_selected else 0)
child.setTextColor(
ContextCompat.getColor(requireContext(), if (isSelected) R.color.black else R.color.white)
)
}
}
private fun clearAllTabItems() {
binding.rvContentAllItems.adapter = null
contentAllAudioCardAdapter.submitItems(emptyList())
contentAllSeriesCardAdapter.submitItems(emptyList())
}
private fun hideAllTabEmptyError() {
binding.layoutContentAllEmptyError.visibility = View.GONE
}
private fun showAllSortPopup(selectedSort: ContentSort) {
sortPopup?.dismiss()
sortPopup = CreatorChannelSortPopup(
anchor = binding.layoutContentAllSortButton,
selectedSort = selectedSort,
onSortSelected = { sort ->
if (sort in ContentSort.entries) {
contentAllTabViewModel.changeSort(sort)
}
}
).also { it.show() }
}
private fun bindContent(content: AudioRecommendationsUiState.Content) {
@@ -299,6 +535,11 @@ class ContentMainFragment : BaseFragment<FragmentV2MainContentBinding>(
private fun openSeriesDetail(item: ContentOriginalSeriesUiModel) {
val seriesId = item.seriesId.takeIf { it > 0L } ?: return
openSeriesDetail(seriesId)
}
private fun openSeriesDetail(seriesId: Long) {
if (seriesId <= 0L) return
startActivity(
Intent(requireContext(), SeriesDetailActivity::class.java).apply {
putExtra(Constants.EXTRA_SERIES_ID, seriesId)
@@ -312,7 +553,7 @@ class ContentMainFragment : BaseFragment<FragmentV2MainContentBinding>(
}
private fun updateLoadingDialog() {
if (isRecommendationLoading || isRankingLoading) {
if (isRecommendationLoading || isRankingLoading || isAllTabLoading) {
loadingDialog.show(screenWidth)
} else {
loadingDialog.dismiss()
@@ -347,5 +588,6 @@ class ContentMainFragment : BaseFragment<FragmentV2MainContentBinding>(
private const val CONTENT_TAB_RECOMMENDATION = 0
private const val CONTENT_TAB_RANKING = 1
private const val CONTENT_TAB_ALL = 2
private const val CONTENT_ALL_GRID_SPAN_COUNT = 3
}
}