diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/HomeMainFragment.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/HomeMainFragment.kt index 36f44873..1b2e3ac2 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/HomeMainFragment.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/HomeMainFragment.kt @@ -14,7 +14,20 @@ import kr.co.vividnext.sodalive.common.formatUtcRelativeTimeText import kr.co.vividnext.sodalive.databinding.FragmentV2MainHomeBinding import kr.co.vividnext.sodalive.databinding.ViewSectionTitleBinding import kr.co.vividnext.sodalive.explorer.profile.creator_community.all.CreatorCommunityAllActivity +import kr.co.vividnext.sodalive.chat.talk.room.ChatRoomActivity import kr.co.vividnext.sodalive.v2.creator.channel.CreatorChannelActivity +import kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomActivity +import kr.co.vividnext.sodalive.v2.main.chat.model.ChatRoomListUiItem +import kr.co.vividnext.sodalive.v2.main.chat.model.ChatRoomType +import kr.co.vividnext.sodalive.v2.main.home.model.HomeFollowingChatSection +import kr.co.vividnext.sodalive.v2.main.home.model.HomeFollowingCreatorSection +import kr.co.vividnext.sodalive.v2.main.home.model.HomeFollowingLiveSection +import kr.co.vividnext.sodalive.v2.main.home.model.HomeFollowingLiveUiItem +import kr.co.vividnext.sodalive.v2.main.home.model.HomeFollowingNewsSection +import kr.co.vividnext.sodalive.v2.main.home.model.HomeFollowingNewsUiItem +import kr.co.vividnext.sodalive.v2.main.home.model.HomeFollowingScheduleSection +import kr.co.vividnext.sodalive.v2.main.home.model.HomeFollowingScheduleUiItem +import kr.co.vividnext.sodalive.v2.main.home.model.HomeFollowingUiState import kr.co.vividnext.sodalive.v2.main.home.model.HomeCreatorRankingUiState import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationAiCharacterSection import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationAiCharacterUiModel @@ -45,6 +58,11 @@ import kr.co.vividnext.sodalive.v2.main.home.ui.HomeBannerBinder import kr.co.vividnext.sodalive.v2.main.home.ui.HomeBusinessInfoBinder import kr.co.vividnext.sodalive.v2.main.home.ui.HomeCheerCreatorAdapter import kr.co.vividnext.sodalive.v2.main.home.ui.HomeFirstAudioAdapter +import kr.co.vividnext.sodalive.v2.main.home.ui.HomeFollowingChatAdapter +import kr.co.vividnext.sodalive.v2.main.home.ui.HomeFollowingCreatorAdapter +import kr.co.vividnext.sodalive.v2.main.home.ui.HomeFollowingLiveAdapter +import kr.co.vividnext.sodalive.v2.main.home.ui.HomeFollowingNewsAdapter +import kr.co.vividnext.sodalive.v2.main.home.ui.HomeFollowingScheduleAdapter import kr.co.vividnext.sodalive.v2.main.home.ui.HomeGenreCreatorAdapter import kr.co.vividnext.sodalive.v2.main.home.ui.HomeLiveAdapter import kr.co.vividnext.sodalive.v2.main.home.ui.HomePopularCommunityAdapter @@ -60,6 +78,7 @@ class HomeMainFragment : BaseFragment( ) { private val homeRecommendationViewModel: HomeRecommendationViewModel by viewModel() private val homeCreatorRankingViewModel: HomeCreatorRankingViewModel by viewModel() + private val homeFollowingViewModel: HomeFollowingViewModel by viewModel() private val loadingDialog: LoadingDialog by lazy { LoadingDialog(requireActivity(), layoutInflater) } private val liveAdapter = HomeLiveAdapter() private val recentActivityCreatorAdapter = HomeRecentActivityCreatorAdapter { onRecentActivityClick(it) } @@ -76,10 +95,16 @@ class HomeMainFragment : BaseFragment( ) private val popularCommunityAdapter = HomePopularCommunityAdapter { openPopularCommunityPost(it) } private val creatorRankingAdapter = CreatorRankingAdapter { openCreatorRankingProfile(it) } + private val followingCreatorAdapter = HomeFollowingCreatorAdapter { openCreatorProfile(it.creatorId) } + private val followingLiveAdapter = HomeFollowingLiveAdapter { onFollowingLiveClick(it) } + private val followingChatAdapter = HomeFollowingChatAdapter { openFollowingChat(it) } + private val followingScheduleAdapter = HomeFollowingScheduleAdapter { onFollowingScheduleClick(it) } + private val followingNewsAdapter = HomeFollowingNewsAdapter { onFollowingNewsClick(it) } private var bannerBinder: HomeBannerBinder? = null private var onGenreFollowAllClick: (List) -> Unit = {} private var onCheerFollowAllClick: (List) -> Unit = {} private var hasLoadedCreatorRankings = false + private var hasLoadedFollowing = false override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) @@ -97,9 +122,11 @@ class HomeMainFragment : BaseFragment( setUpSectionTitles() setUpRecommendationAdapters() setUpCreatorRankingAdapter() + setUpFollowingAdapters() setUpBusinessInfo() bindHomeRecommendationObservers() bindHomeCreatorRankingObservers() + bindHomeFollowingObservers() homeRecommendationViewModel.loadRecommendations() } @@ -159,6 +186,29 @@ class HomeMainFragment : BaseFragment( } } + private fun setUpFollowingAdapters() { + binding.rvHomeFollowingCreators.apply { + layoutManager = LinearLayoutManager(requireContext(), LinearLayoutManager.HORIZONTAL, false) + adapter = followingCreatorAdapter + } + binding.rvHomeFollowingOnAirLives.apply { + layoutManager = LinearLayoutManager(requireContext(), LinearLayoutManager.HORIZONTAL, false) + adapter = followingLiveAdapter + } + binding.rvHomeFollowingRecentChats.apply { + layoutManager = LinearLayoutManager(requireContext(), LinearLayoutManager.VERTICAL, false) + adapter = followingChatAdapter + } + binding.rvHomeFollowingMonthlySchedules.apply { + layoutManager = LinearLayoutManager(requireContext(), LinearLayoutManager.VERTICAL, false) + adapter = followingScheduleAdapter + } + binding.rvHomeFollowingRecentNews.apply { + layoutManager = LinearLayoutManager(requireContext(), LinearLayoutManager.VERTICAL, false) + adapter = followingNewsAdapter + } + } + private fun bindHomeCreatorRankingObservers() { homeCreatorRankingViewModel.rankingStateLiveData.observe(viewLifecycleOwner) { state -> when (state) { @@ -186,18 +236,51 @@ class HomeMainFragment : BaseFragment( HOME_TAB_RECOMMENDATION -> { binding.nsvHomeRecommendationContent.visibility = View.VISIBLE binding.rvHomeCreatorRankings.visibility = View.GONE + binding.nsvHomeFollowingContent.visibility = View.GONE } HOME_TAB_RANKING -> { binding.nsvHomeRecommendationContent.visibility = View.GONE binding.rvHomeCreatorRankings.visibility = View.VISIBLE + binding.nsvHomeFollowingContent.visibility = View.GONE if (!hasLoadedCreatorRankings) { hasLoadedCreatorRankings = true homeCreatorRankingViewModel.loadCreatorRankings() } } - HOME_TAB_FOLLOWING -> Unit + HOME_TAB_FOLLOWING -> { + binding.nsvHomeFollowingContent.visibility = View.VISIBLE + binding.nsvHomeRecommendationContent.visibility = View.GONE + binding.rvHomeCreatorRankings.visibility = View.GONE + if (!hasLoadedFollowing) { + hasLoadedFollowing = true + homeFollowingViewModel.loadFollowing() + } + } + } + } + + private fun bindHomeFollowingObservers() { + homeFollowingViewModel.followingStateLiveData.observe(viewLifecycleOwner) { state -> + when (state) { + is HomeFollowingUiState.Content -> bindHomeFollowingContent(state) + HomeFollowingUiState.LoginRequired, + HomeFollowingUiState.Empty, + is HomeFollowingUiState.Error -> bindHomeFollowingEmpty() + + HomeFollowingUiState.Loading -> Unit + } + } + homeFollowingViewModel.isLoading.observe(viewLifecycleOwner) { isLoading -> + if (isLoading) { + loadingDialog.show(screenWidth) + } else { + loadingDialog.dismiss() + } + } + homeFollowingViewModel.toastLiveData.observe(viewLifecycleOwner) { toastMessage -> + toastMessage?.let(::showToast) } } @@ -235,6 +318,52 @@ class HomeMainFragment : BaseFragment( bindPopularCommunitySection(content.popularCommunityPosts) } + private fun bindHomeFollowingContent(content: HomeFollowingUiState.Content) { + bindFollowingCreatorSection(content.followingCreators) + bindFollowingLiveSection(content.onAirLives) + bindFollowingChatSection(content.recentChats) + bindFollowingScheduleSection(content.monthlySchedules) + bindFollowingNewsSection(content.recentNews) + } + + private fun bindHomeFollowingEmpty() { + binding.llHomeFollowingCreatorsSection.visibility = View.GONE + binding.llHomeFollowingOnAirSection.visibility = View.GONE + binding.llHomeFollowingRecentChatsSection.visibility = View.GONE + binding.llHomeFollowingMonthlySchedulesSection.visibility = View.GONE + binding.llHomeFollowingRecentNewsSection.visibility = View.GONE + followingCreatorAdapter.submitItems(emptyList()) + followingLiveAdapter.submitItems(emptyList()) + followingChatAdapter.submitItems(emptyList()) + followingScheduleAdapter.submitItems(emptyList()) + followingNewsAdapter.submitItems(emptyList()) + } + + private fun bindFollowingCreatorSection(section: HomeFollowingCreatorSection) { + binding.llHomeFollowingCreatorsSection.visibility = section.items.toSectionVisibility() + followingCreatorAdapter.submitItems(section.items) + } + + private fun bindFollowingLiveSection(section: HomeFollowingLiveSection) { + binding.llHomeFollowingOnAirSection.visibility = section.items.toSectionVisibility() + followingLiveAdapter.submitItems(section.items) + } + + private fun bindFollowingChatSection(section: HomeFollowingChatSection) { + binding.llHomeFollowingRecentChatsSection.visibility = section.items.toSectionVisibility() + followingChatAdapter.submitItems(section.items) + } + + private fun bindFollowingScheduleSection(section: HomeFollowingScheduleSection) { + binding.llHomeFollowingMonthlySchedulesSection.visibility = section.items.toSectionVisibility() + followingScheduleAdapter.submitItems(section.items) + } + + private fun bindFollowingNewsSection(section: HomeFollowingNewsSection) { + binding.llHomeFollowingRecentNewsSection.visibility = section.items.toSectionVisibility() + followingNewsAdapter.submitItems(section.items) + } + private fun bindLiveSection(section: HomeRecommendationLiveSection) { binding.llHomeLiveSection.visibility = section.items.toSectionVisibility() liveAdapter.submitItems(section.items) @@ -319,6 +448,41 @@ class HomeMainFragment : BaseFragment( binding.viewHomePopularCommunityTitle.setTitle( R.string.home_recommendation_section_popular_community_posts ) + binding.viewHomeFollowingCreatorsTitle.setTitle( + R.string.screen_home_following_creators_title, + showMore = true + ) + binding.viewHomeFollowingOnAirTitle.setTitle( + R.string.screen_home_following_on_air, + showMore = true + ) + binding.viewHomeFollowingRecentChatsTitle.setTitle( + R.string.screen_home_following_recent_chats_title, + showMore = true + ) + binding.viewHomeFollowingMonthlySchedulesTitle.setTitle( + R.string.screen_home_following_monthly_schedules_title, + showMore = true + ) + binding.viewHomeFollowingRecentNewsTitle.setTitle( + R.string.screen_home_following_recent_news_title, + showMore = true + ) + binding.viewHomeFollowingCreatorsTitle.ivSectionTitleChevron.setOnClickListener { + onFollowingSectionMoreClick(HomeFollowingSection.CREATORS) + } + binding.viewHomeFollowingOnAirTitle.ivSectionTitleChevron.setOnClickListener { + onFollowingSectionMoreClick(HomeFollowingSection.ON_AIR) + } + binding.viewHomeFollowingRecentChatsTitle.ivSectionTitleChevron.setOnClickListener { + onFollowingSectionMoreClick(HomeFollowingSection.RECENT_CHATS) + } + binding.viewHomeFollowingMonthlySchedulesTitle.ivSectionTitleChevron.setOnClickListener { + onFollowingSectionMoreClick(HomeFollowingSection.MONTHLY_SCHEDULES) + } + binding.viewHomeFollowingRecentNewsTitle.ivSectionTitleChevron.setOnClickListener { + onFollowingSectionMoreClick(HomeFollowingSection.RECENT_NEWS) + } } private fun ViewSectionTitleBinding.setTitle( @@ -331,6 +495,21 @@ class HomeMainFragment : BaseFragment( private fun onLiveClick(item: HomeRecommendationLiveUiModel) = Unit + private fun onFollowingSectionMoreClick(section: HomeFollowingSection) = Unit + + private fun onFollowingLiveClick(item: HomeFollowingLiveUiItem) = Unit + + private fun onFollowingScheduleClick(item: HomeFollowingScheduleUiItem) = Unit + + private fun onFollowingNewsClick(item: HomeFollowingNewsUiItem) = Unit + + private fun openFollowingChat(item: ChatRoomListUiItem) { + when (item.chatType) { + ChatRoomType.AI -> startActivity(ChatRoomActivity.newIntent(requireContext(), item.roomId)) + ChatRoomType.DM -> startActivity(DmChatRoomActivity.newIntentByRoomId(requireContext(), item.roomId)) + } + } + private fun onBannerClick(item: HomeRecommendationBannerUiModel) { val route = item.toHomeRecommendationBannerRoute() ?: return startActivity(route.toHomeRecommendationBannerIntent(requireContext())) @@ -419,3 +598,11 @@ class HomeMainFragment : BaseFragment( const val SECTION_KEY_GENRE_CREATORS = "genreCreators" } } + +enum class HomeFollowingSection { + CREATORS, + ON_AIR, + RECENT_CHATS, + MONTHLY_SCHEDULES, + RECENT_NEWS +} diff --git a/app/src/main/res/layout/fragment_v2_main_home.xml b/app/src/main/res/layout/fragment_v2_main_home.xml index 8acfaae4..071111f5 100644 --- a/app/src/main/res/layout/fragment_v2_main_home.xml +++ b/app/src/main/res/layout/fragment_v2_main_home.xml @@ -239,6 +239,141 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ") + .substringBefore("}\n }") + + assertTrue(branch.contains("binding.nsvHomeFollowingContent.visibility = View.VISIBLE")) + assertTrue(branch.contains("binding.nsvHomeRecommendationContent.visibility = View.GONE")) + assertTrue(branch.contains("binding.rvHomeCreatorRankings.visibility = View.GONE")) + } + + @Test + fun `following tab loads following content only once`() { + val source = homeMainFragmentSource() + + assertTrue(source.contains("private var hasLoadedFollowing = false")) + assertTrue(source.contains("if (!hasLoadedFollowing)")) + assertTrue(source.contains("hasLoadedFollowing = true")) + assertTrue(source.contains("homeFollowingViewModel.loadFollowing()")) + } + + @Test + fun `following section chevrons call callback without starting activity`() { + val source = homeMainFragmentSource() + val callback = source.substringAfter("private fun onFollowingSectionMoreClick") + .substringBefore("\n private fun") + + assertTrue(source.contains("onFollowingSectionMoreClick(")) + assertTrue(source.contains("binding.viewHomeFollowingCreatorsTitle")) + assertTrue(source.contains("binding.viewHomeFollowingOnAirTitle")) + assertTrue(source.contains("binding.viewHomeFollowingRecentChatsTitle")) + assertTrue(source.contains("binding.viewHomeFollowingMonthlySchedulesTitle")) + assertTrue(source.contains("binding.viewHomeFollowingRecentNewsTitle")) + assertTrue(source.contains("setOnClickListener")) + assertTrue(source.contains("onFollowingSectionMoreClick(HomeFollowingSection.")) + assertFalse(callback.contains("startActivity")) + } + + @Test + fun `following state binding clears or binds each following section`() { + val source = homeMainFragmentSource() + + assertTrue(source.contains("followingStateLiveData.observe(viewLifecycleOwner)")) + assertTrue(source.contains("is HomeFollowingUiState.Content -> bindHomeFollowingContent(state)")) + assertTrue(source.contains("HomeFollowingUiState.LoginRequired,")) + assertTrue(source.contains("HomeFollowingUiState.Empty,")) + assertTrue(source.contains("is HomeFollowingUiState.Error -> bindHomeFollowingEmpty()")) + assertTrue(source.contains("followingCreatorAdapter.submitItems(emptyList())")) + assertTrue(source.contains("followingLiveAdapter.submitItems(emptyList())")) + assertTrue(source.contains("followingChatAdapter.submitItems(emptyList())")) + assertTrue(source.contains("followingScheduleAdapter.submitItems(emptyList())")) + assertTrue(source.contains("followingNewsAdapter.submitItems(emptyList())")) + assertTrue(source.contains("bindFollowingCreatorSection(content.followingCreators)")) + assertTrue(source.contains("bindFollowingLiveSection(content.onAirLives)")) + assertTrue(source.contains("bindFollowingChatSection(content.recentChats)")) + assertTrue(source.contains("bindFollowingScheduleSection(content.monthlySchedules)")) + assertTrue(source.contains("bindFollowingNewsSection(content.recentNews)")) + } + + @Test + fun `following adapters bind figma required item fields`() { + val liveAdapter = projectFile( + "app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/ui/HomeFollowingLiveAdapter.kt" + ).readText() + val liveLayout = projectFile("app/src/main/res/layout/item_home_following_live.xml").readText() + val scheduleAdapter = projectFile( + "app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/ui/HomeFollowingScheduleAdapter.kt" + ).readText() + val scheduleLayout = projectFile("app/src/main/res/layout/item_home_following_schedule.xml").readText() + val newsAdapter = projectFile( + "app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/ui/HomeFollowingNewsAdapter.kt" + ).readText() + val newsLayout = projectFile("app/src/main/res/layout/item_home_following_news_content.xml").readText() + + assertTrue(liveLayout.contains("@+id/tv_home_following_live_started_at")) + assertTrue(liveAdapter.contains("startedAtText.text")) + assertTrue(liveAdapter.contains("item.startedAtUtc")) + + assertTrue(scheduleLayout.contains("@+id/iv_home_following_schedule_creator_profile")) + assertTrue(scheduleLayout.contains("@+id/tv_home_following_schedule_type")) + assertTrue(scheduleAdapter.contains("profileImage.loadHomeCreatorProfileImage(item.creatorProfileImageUrl)")) + assertTrue(scheduleAdapter.contains("typeText.setText(item.typeLabelResId)")) + assertTrue(scheduleAdapter.contains("item.isOnAir")) + assertTrue(scheduleAdapter.contains("R.string.screen_home_following_on_air")) + + assertTrue(newsLayout.contains("@+id/tv_home_following_news_label")) + assertTrue(newsLayout.contains("@+id/tv_home_following_news_title")) + assertTrue(newsAdapter.contains("labelText.setText(content.labelResId)")) + assertTrue(newsAdapter.contains("titleText.text = content.title")) + assertTrue(newsAdapter.contains("createdAtText.text = content.visibleFromText")) + } + + private fun homeMainFragmentSource(): String { + return projectFile("app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/HomeMainFragment.kt").readText() + } + + private fun homeMainLayoutSource(): String { + return projectFile("app/src/main/res/layout/fragment_v2_main_home.xml").readText() + } + + private fun projectFile(relativePath: String): File { + val candidates = listOf(File(relativePath), File("../$relativePath")) + return candidates.first { it.exists() }.canonicalFile + } +}