feat(content): 랭킹 탭 화면 연결을 추가한다

This commit is contained in:
2026-06-24 14:45:45 +09:00
parent cf89052806
commit 2818f8d4a4
2 changed files with 142 additions and 9 deletions

View File

@@ -14,6 +14,8 @@ import kr.co.vividnext.sodalive.common.LoadingDialog
import kr.co.vividnext.sodalive.common.ToastMessage import kr.co.vividnext.sodalive.common.ToastMessage
import kr.co.vividnext.sodalive.databinding.FragmentV2MainContentBinding import kr.co.vividnext.sodalive.databinding.FragmentV2MainContentBinding
import kr.co.vividnext.sodalive.databinding.ViewSectionTitleBinding import kr.co.vividnext.sodalive.databinding.ViewSectionTitleBinding
import kr.co.vividnext.sodalive.v2.main.content.data.AudioRankingType
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.AudioRecommendationsUiState
import kr.co.vividnext.sodalive.v2.main.content.model.ContentAudioCardSection 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.ContentAudioCardUiModel
@@ -34,12 +36,15 @@ 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.addContentGridItemSpacing
import kr.co.vividnext.sodalive.v2.main.content.ui.addContentHorizontalItemSpacing import kr.co.vividnext.sodalive.v2.main.content.ui.addContentHorizontalItemSpacing
import kr.co.vividnext.sodalive.v2.widget.AudioContentCardSize import kr.co.vividnext.sodalive.v2.widget.AudioContentCardSize
import kr.co.vividnext.sodalive.v2.widget.contentranking.ContentRankingAdapter
import kr.co.vividnext.sodalive.v2.widget.contentranking.ContentRankingItem
import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.androidx.viewmodel.ext.android.viewModel
class ContentMainFragment : BaseFragment<FragmentV2MainContentBinding>( class ContentMainFragment : BaseFragment<FragmentV2MainContentBinding>(
FragmentV2MainContentBinding::inflate FragmentV2MainContentBinding::inflate
) { ) {
private val contentMainViewModel: ContentMainViewModel by viewModel() private val contentMainViewModel: ContentMainViewModel by viewModel()
private val contentRankingViewModel: ContentRankingViewModel by viewModel()
private val loadingDialog: LoadingDialog by lazy { LoadingDialog(requireActivity(), layoutInflater) } private val loadingDialog: LoadingDialog by lazy { LoadingDialog(requireActivity(), layoutInflater) }
private val originalSeriesAdapter = ContentOriginalSeriesAdapter { openSeriesDetail(it) } private val originalSeriesAdapter = ContentOriginalSeriesAdapter { openSeriesDetail(it) }
private val latestAudioAdapter = ContentAudioCardAdapter(AudioContentCardSize.Medium) { openAudioContentDetail(it) } private val latestAudioAdapter = ContentAudioCardAdapter(AudioContentCardSize.Medium) { openAudioContentDetail(it) }
@@ -48,20 +53,77 @@ class ContentMainFragment : BaseFragment<FragmentV2MainContentBinding>(
private val pointAudioAdapter = ContentAudioCardAdapter(AudioContentCardSize.Medium) { openAudioContentDetail(it) } private val pointAudioAdapter = ContentAudioCardAdapter(AudioContentCardSize.Medium) { openAudioContentDetail(it) }
private val commentedAudioAdapter = ContentCommentedAudioAdapter { openAudioContentDetail(it) } private val commentedAudioAdapter = ContentCommentedAudioAdapter { openAudioContentDetail(it) }
private val recommendedAudioAdapter = ContentAudioCardAdapter(AudioContentCardSize.Large) { openAudioContentDetail(it) } private val recommendedAudioAdapter = ContentAudioCardAdapter(AudioContentCardSize.Large) { openAudioContentDetail(it) }
private val contentRankingAdapter = ContentRankingAdapter { openRankingAudioContentDetail(it) }
private var bannerBinder: ContentBannerBinder? = null private var bannerBinder: ContentBannerBinder? = null
private var isRecommendationLoading = false
private var isRankingLoading = false
private var hasSelectedRankingTab = false
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
binding.textTabBarContent.root.setMenus( setUpTextTabs()
listOf(getString(R.string.screen_content_tab_recommendation)), setUpRankingTypeTabs()
selectedIndex = 0 showContentTab(CONTENT_TAB_RECOMMENDATION)
)
setUpSectionTitles() setUpSectionTitles()
setUpAdapters() setUpAdapters()
bindObservers() bindObservers()
contentMainViewModel.loadRecommendations() contentMainViewModel.loadRecommendations()
} }
private fun setUpTextTabs() {
binding.textTabBarContent.root.setMenus(
listOf(
getString(R.string.screen_content_tab_recommendation),
getString(R.string.screen_content_tab_ranking),
getString(R.string.screen_content_tab_all)
),
selectedIndex = 0
)
binding.textTabBarContent.root.setOnTabSelectedListener { index ->
showContentTab(index)
}
}
private fun setUpRankingTypeTabs() {
binding.viewContentRankingTypeTabs.root.setMenus(
AudioRankingType.entries.map { type -> getString(type.labelResId()) },
selectedIndex = AudioRankingType.WEEKLY_POPULAR.ordinal
)
binding.viewContentRankingTypeTabs.root.setOnTabSelectedListener { index ->
contentRankingViewModel.loadRankings(AudioRankingType.entries[index])
}
}
private fun showContentTab(index: Int) {
when (index) {
CONTENT_TAB_RECOMMENDATION -> showRecommendationContent()
CONTENT_TAB_RANKING -> showRankingContent()
CONTENT_TAB_ALL -> hideContentSurfaces()
}
}
private fun showRecommendationContent() {
binding.nsvContentRecommendationContent.visibility = View.VISIBLE
binding.viewContentRankingTypeTabs.root.visibility = View.GONE
binding.rvContentRankings.visibility = View.GONE
}
private fun showRankingContent() {
binding.nsvContentRecommendationContent.visibility = View.GONE
binding.viewContentRankingTypeTabs.root.visibility = View.VISIBLE
binding.rvContentRankings.visibility = View.VISIBLE
if (!hasSelectedRankingTab) {
hasSelectedRankingTab = true
contentRankingViewModel.loadRankings(AudioRankingType.WEEKLY_POPULAR)
}
}
private fun hideContentSurfaces() {
binding.nsvContentRecommendationContent.visibility = View.GONE
binding.viewContentRankingTypeTabs.root.visibility = View.GONE
binding.rvContentRankings.visibility = View.GONE
}
private fun setUpAdapters() { private fun setUpAdapters() {
bannerBinder = ContentBannerBinder(binding.rvContentBanners).apply { bannerBinder = ContentBannerBinder(binding.rvContentBanners).apply {
setOnBannerClick { onBannerClick(it) } setOnBannerClick { onBannerClick(it) }
@@ -101,6 +163,10 @@ class ContentMainFragment : BaseFragment<FragmentV2MainContentBinding>(
adapter = recommendedAudioAdapter adapter = recommendedAudioAdapter
addContentGridItemSpacing() addContentGridItemSpacing()
} }
binding.rvContentRankings.apply {
layoutManager = ContentRankingAdapter.createGridLayoutManager(requireContext())
adapter = contentRankingAdapter
}
} }
private fun bindObservers() { private fun bindObservers() {
@@ -114,15 +180,28 @@ class ContentMainFragment : BaseFragment<FragmentV2MainContentBinding>(
} }
} }
contentMainViewModel.isLoading.observe(viewLifecycleOwner) { isLoading -> contentMainViewModel.isLoading.observe(viewLifecycleOwner) { isLoading ->
if (isLoading) { isRecommendationLoading = isLoading
loadingDialog.show(screenWidth) updateLoadingDialog()
} else {
loadingDialog.dismiss()
}
} }
contentMainViewModel.toastLiveData.observe(viewLifecycleOwner) { toastMessage -> contentMainViewModel.toastLiveData.observe(viewLifecycleOwner) { toastMessage ->
toastMessage?.let(::showToast) toastMessage?.let(::showToast)
} }
contentRankingViewModel.rankingStateLiveData.observe(viewLifecycleOwner) { state ->
when (state) {
is AudioRankingsUiState.Content -> contentRankingAdapter.submitItems(state.items)
is AudioRankingsUiState.Empty,
is AudioRankingsUiState.Error -> contentRankingAdapter.submitItems(emptyList())
AudioRankingsUiState.Loading -> Unit
}
}
contentRankingViewModel.isLoading.observe(viewLifecycleOwner) { isLoading ->
isRankingLoading = isLoading
updateLoadingDialog()
}
contentRankingViewModel.toastLiveData.observe(viewLifecycleOwner) { toastMessage ->
toastMessage?.let(::showToast)
}
} }
private fun bindContent(content: AudioRecommendationsUiState.Content) { private fun bindContent(content: AudioRecommendationsUiState.Content) {
@@ -213,6 +292,11 @@ class ContentMainFragment : BaseFragment<FragmentV2MainContentBinding>(
) )
} }
private fun openRankingAudioContentDetail(item: ContentRankingItem) {
val audioContentId = item.contentId.toLongOrNull() ?: return
openAudioContentDetail(audioContentId)
}
private fun openSeriesDetail(item: ContentOriginalSeriesUiModel) { private fun openSeriesDetail(item: ContentOriginalSeriesUiModel) {
val seriesId = item.seriesId.takeIf { it > 0L } ?: return val seriesId = item.seriesId.takeIf { it > 0L } ?: return
startActivity( startActivity(
@@ -227,6 +311,14 @@ class ContentMainFragment : BaseFragment<FragmentV2MainContentBinding>(
?: toastMessage.resId?.let { resId -> showToast(getString(resId)) } ?: toastMessage.resId?.let { resId -> showToast(getString(resId)) }
} }
private fun updateLoadingDialog() {
if (isRecommendationLoading || isRankingLoading) {
loadingDialog.show(screenWidth)
} else {
loadingDialog.dismiss()
}
}
private fun emptyContent(): AudioRecommendationsUiState.Content { private fun emptyContent(): AudioRecommendationsUiState.Content {
return AudioRecommendationsUiState.Content( return AudioRecommendationsUiState.Content(
banners = ContentBannerSection(emptyList()), banners = ContentBannerSection(emptyList()),
@@ -241,4 +333,19 @@ class ContentMainFragment : BaseFragment<FragmentV2MainContentBinding>(
} }
private fun List<*>.toSectionVisibility(): Int = if (isEmpty()) View.GONE else View.VISIBLE private fun List<*>.toSectionVisibility(): Int = if (isEmpty()) View.GONE else View.VISIBLE
private fun AudioRankingType.labelResId(): Int = when (this) {
AudioRankingType.WEEKLY_POPULAR -> R.string.screen_content_ranking_type_weekly_popular
AudioRankingType.RISING -> R.string.screen_content_ranking_type_rising
AudioRankingType.REVENUE -> R.string.screen_content_ranking_type_revenue
AudioRankingType.SALES_COUNT -> R.string.screen_content_ranking_type_sales_count
AudioRankingType.COMMENT_COUNT -> R.string.screen_content_ranking_type_comment_count
AudioRankingType.LIKE_COUNT -> R.string.screen_content_ranking_type_like_count
}
companion object {
private const val CONTENT_TAB_RECOMMENDATION = 0
private const val CONTENT_TAB_RANKING = 1
private const val CONTENT_TAB_ALL = 2
}
} }

View File

@@ -33,18 +33,44 @@ class ContentMainFragmentSourceTest {
).readText() ).readText()
assertTrue(source.contains("private val contentMainViewModel: ContentMainViewModel by viewModel()")) assertTrue(source.contains("private val contentMainViewModel: ContentMainViewModel by viewModel()"))
assertTrue(source.contains("private val contentRankingViewModel: ContentRankingViewModel by viewModel()"))
assertTrue(source.contains("ContentBannerBinder(binding.rvContentBanners)")) assertTrue(source.contains("ContentBannerBinder(binding.rvContentBanners)"))
assertTrue(source.contains("ContentOriginalSeriesAdapter")) assertTrue(source.contains("ContentOriginalSeriesAdapter"))
assertTrue(source.contains("ContentAudioCardAdapter")) assertTrue(source.contains("ContentAudioCardAdapter"))
assertTrue(source.contains("ContentNewAndHotAdapter")) assertTrue(source.contains("ContentNewAndHotAdapter"))
assertTrue(source.contains("ContentCommentedAudioAdapter")) assertTrue(source.contains("ContentCommentedAudioAdapter"))
assertTrue(source.contains("ContentRankingAdapter"))
assertTrue(source.contains("ContentRankingAdapter.createGridLayoutManager(requireContext())"))
assertTrue(source.contains("recommendationsStateLiveData.observe(viewLifecycleOwner)")) assertTrue(source.contains("recommendationsStateLiveData.observe(viewLifecycleOwner)"))
assertTrue(source.contains("rankingStateLiveData.observe(viewLifecycleOwner)"))
assertTrue(source.contains("contentMainViewModel.loadRecommendations()")) assertTrue(source.contains("contentMainViewModel.loadRecommendations()"))
assertTrue(source.contains("contentRankingViewModel.loadRankings(AudioRankingType.WEEKLY_POPULAR)"))
assertTrue(source.contains("LoadingDialog(requireActivity(), layoutInflater)")) assertTrue(source.contains("LoadingDialog(requireActivity(), layoutInflater)"))
assertTrue(source.contains("loadingDialog.show(screenWidth)")) assertTrue(source.contains("loadingDialog.show(screenWidth)"))
assertTrue(source.contains("toastMessage?.let(::showToast)")) assertTrue(source.contains("toastMessage?.let(::showToast)"))
} }
@Test
fun `content 랭킹 layout과 tab source는 Phase 5 요구를 포함한다`() {
val source = projectFile(
"app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/ContentMainFragment.kt"
).readText()
val fragmentLayout = projectFile("app/src/main/res/layout/fragment_v2_main_content.xml").readText()
assertTrue(fragmentLayout.contains("@+id/view_content_ranking_type_tabs"))
assertTrue(fragmentLayout.contains("layout=\"@layout/view_capsule_tab_bar\""))
assertTrue(fragmentLayout.contains("@+id/rv_content_rankings"))
assertTrue(source.contains("R.string.screen_content_tab_recommendation"))
assertTrue(source.contains("R.string.screen_content_tab_ranking"))
assertTrue(source.contains("R.string.screen_content_tab_all"))
assertTrue(source.contains("binding.textTabBarContent.root.setOnTabSelectedListener"))
assertTrue(source.contains("binding.viewContentRankingTypeTabs.root.setMenus"))
assertTrue(source.contains("binding.viewContentRankingTypeTabs.root.setOnTabSelectedListener"))
assertTrue(source.contains("AudioRankingType.entries[index]"))
assertTrue(source.contains("screen_content_ranking_type_weekly_popular"))
assertTrue(source.contains("screen_content_ranking_type_like_count"))
}
@Test @Test
fun `content 추천 source는 오디오와 시리즈 routing extra를 사용한다`() { fun `content 추천 source는 오디오와 시리즈 routing extra를 사용한다`() {
val source = projectFile( val source = projectFile(