From c6680d0bd245db14228b188faaef85e51e2dde2c Mon Sep 17 00:00:00 2001 From: klaus Date: Fri, 5 Jun 2026 16:01:37 +0900 Subject: [PATCH] =?UTF-8?q?feat(home):=20=EC=B6=94=EC=B2=9C=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=20observe=EB=A5=BC=20=EC=97=B0=EA=B2=B0=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sodalive/v2/main/HomeMainFragment.kt | 286 ++++++++---------- .../main/home/HomeMainFragmentLayoutTest.kt | 30 ++ 2 files changed, 163 insertions(+), 153 deletions(-) diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/main/HomeMainFragment.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/main/HomeMainFragment.kt index c60a94a1..43619a8f 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/v2/main/HomeMainFragment.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/main/HomeMainFragment.kt @@ -1,32 +1,35 @@ package kr.co.vividnext.sodalive.v2.main +import android.content.Intent import android.os.Bundle import android.view.View 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.base.BaseFragment +import kr.co.vividnext.sodalive.chat.character.detail.CharacterDetailActivity +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.FragmentV2MainHomeBinding import kr.co.vividnext.sodalive.databinding.ViewSectionTitleBinding +import kr.co.vividnext.sodalive.explorer.profile.UserProfileActivity +import kr.co.vividnext.sodalive.explorer.profile.creator_community.all.CreatorCommunityAllActivity +import kr.co.vividnext.sodalive.v2.main.home.HomeRecommendationViewModel import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationAiCharacterSection -import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationAiCharacterUiModel import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationBannerSection import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationBannerUiModel import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationCheerCreatorSection -import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationCreatorUiModel import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationFirstAudioContentSection import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationFirstAudioContentUiModel import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationGenreCreatorGroupUiModel import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationGenreCreatorSection import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationLiveSection import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationLiveUiModel -import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationPaidStatus import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationPopularCommunityPostSection -import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationPopularCommunityPostUiModel import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationRecentlyActiveCreatorSection -import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationRecentlyActiveCreatorUiModel import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationRecentDebutCreatorSection import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationUiState -import kr.co.vividnext.sodalive.v2.main.home.model.RecommendedActivityType import kr.co.vividnext.sodalive.v2.main.home.model.visibleHomeGenreCreatorGroups import kr.co.vividnext.sodalive.v2.main.home.ui.HomeAiCharacterAdapter import kr.co.vividnext.sodalive.v2.main.home.ui.HomeBannerBinder @@ -38,21 +41,28 @@ import kr.co.vividnext.sodalive.v2.main.home.ui.HomeLiveAdapter import kr.co.vividnext.sodalive.v2.main.home.ui.HomePopularCommunityAdapter import kr.co.vividnext.sodalive.v2.main.home.ui.HomeRecentActivityCreatorAdapter import kr.co.vividnext.sodalive.v2.main.home.ui.HomeRecentDebutCreatorAdapter -import kr.co.vividnext.sodalive.v2.widget.AudioContentTag -import kr.co.vividnext.sodalive.v2.widget.characterchatthumbnail.CharacterChatThumbnailItem import kr.co.vividnext.sodalive.v2.widget.feed.FeedItem +import org.koin.androidx.viewmodel.ext.android.viewModel class HomeMainFragment : BaseFragment( FragmentV2MainHomeBinding::inflate ) { + private val homeRecommendationViewModel: HomeRecommendationViewModel by viewModel() + private val loadingDialog: LoadingDialog by lazy { LoadingDialog(requireActivity(), layoutInflater) } private val liveAdapter = HomeLiveAdapter() - private val recentActivityCreatorAdapter = HomeRecentActivityCreatorAdapter() - private val recentDebutCreatorAdapter = HomeRecentDebutCreatorAdapter() - private val firstAudioAdapter = HomeFirstAudioAdapter() - private val aiCharacterAdapter = HomeAiCharacterAdapter() - private val genreCreatorAdapter = HomeGenreCreatorAdapter { creatorIds -> onGenreFollowAllClick(creatorIds) } - private val cheerCreatorAdapter = HomeCheerCreatorAdapter { creatorIds -> onCheerFollowAllClick(creatorIds) } - private val popularCommunityAdapter = HomePopularCommunityAdapter { } + private val recentActivityCreatorAdapter = HomeRecentActivityCreatorAdapter { openCreatorProfile(it.creatorId) } + private val recentDebutCreatorAdapter = HomeRecentDebutCreatorAdapter { openCreatorProfile(it.creatorId) } + private val firstAudioAdapter = HomeFirstAudioAdapter { openAudioContentDetail(it) } + private val aiCharacterAdapter = HomeAiCharacterAdapter { openCharacterDetail(it.characterId) } + private val genreCreatorAdapter = HomeGenreCreatorAdapter( + onFollowAllClick = { creatorIds -> onGenreFollowAllClick(creatorIds) }, + onCreatorClick = { creator -> openCreatorProfile(creator.creatorId) } + ) + private val cheerCreatorAdapter = HomeCheerCreatorAdapter( + onFollowAllClick = { creatorIds -> onCheerFollowAllClick(creatorIds) }, + onCreatorClick = { creator -> openCreatorProfile(creator.creatorId) } + ) + private val popularCommunityAdapter = HomePopularCommunityAdapter { openPopularCommunityPost(it) } private var bannerBinder: HomeBannerBinder? = null private var onGenreFollowAllClick: (List) -> Unit = {} private var onCheerFollowAllClick: (List) -> Unit = {} @@ -71,7 +81,8 @@ class HomeMainFragment : BaseFragment( setUpSectionTitles() setUpRecommendationAdapters() setUpBusinessInfo() - bindHomeRecommendationContent(phase6SampleContent()) + bindHomeRecommendationObservers() + homeRecommendationViewModel.loadRecommendations() } private fun setUpBusinessInfo() { @@ -94,7 +105,10 @@ class HomeMainFragment : BaseFragment( } private fun setUpRecommendationAdapters() { - bannerBinder = HomeBannerBinder(binding.rvHomeBanners) + bannerBinder = HomeBannerBinder(binding.rvHomeBanners).apply { + setOnBannerClick { onBannerClick(it) } + } + liveAdapter.setOnLiveClick { onLiveClick(it) } binding.rvHomeLives.adapter = liveAdapter binding.rvHomeRecentActivityCreators.adapter = recentActivityCreatorAdapter binding.rvHomeRecentDebutCreators.adapter = recentDebutCreatorAdapter @@ -112,6 +126,33 @@ class HomeMainFragment : BaseFragment( layoutManager = LinearLayoutManager(requireContext(), LinearLayoutManager.VERTICAL, false) adapter = popularCommunityAdapter } + onGenreFollowAllClick = { creatorIds -> + homeRecommendationViewModel.followCreators(genreSectionKey(creatorIds), creatorIds) + } + onCheerFollowAllClick = { creatorIds -> + homeRecommendationViewModel.followCreators(SECTION_KEY_CHEER_CREATORS, creatorIds) + } + } + + private fun bindHomeRecommendationObservers() { + homeRecommendationViewModel.recommendationStateLiveData.observe(viewLifecycleOwner) { state -> + when (state) { + is HomeRecommendationUiState.Content -> bindHomeRecommendationContent(state) + HomeRecommendationUiState.Empty, + is HomeRecommendationUiState.Error -> bindHomeRecommendationContent(emptyHomeRecommendationContent()) + HomeRecommendationUiState.Loading -> Unit + } + } + homeRecommendationViewModel.isLoading.observe(viewLifecycleOwner) { isLoading -> + if (isLoading) { + loadingDialog.show(screenWidth) + } else { + loadingDialog.dismiss() + } + } + homeRecommendationViewModel.toastLiveData.observe(viewLifecycleOwner) { toastMessage -> + toastMessage?.let(::showToast) + } } private fun bindHomeRecommendationContent(content: HomeRecommendationUiState.Content) { @@ -204,143 +245,82 @@ class HomeMainFragment : BaseFragment( ivSectionTitleChevron.visibility = if (showMore) View.VISIBLE else View.GONE } + private fun onLiveClick(item: HomeRecommendationLiveUiModel) = Unit + + private fun onBannerClick(item: HomeRecommendationBannerUiModel) = Unit + + private fun openCreatorProfile(creatorId: Long) { + startActivity( + Intent(requireContext(), UserProfileActivity::class.java).apply { + putExtra(Constants.EXTRA_USER_ID, creatorId) + } + ) + } + + private fun openAudioContentDetail(item: HomeRecommendationFirstAudioContentUiModel) { + startActivity( + Intent(requireContext(), AudioContentDetailActivity::class.java).apply { + putExtra(Constants.EXTRA_AUDIO_CONTENT_ID, item.contentId) + } + ) + } + + private fun openCharacterDetail(characterId: Long) { + startActivity( + Intent(requireContext(), CharacterDetailActivity::class.java).apply { + putExtra(CharacterDetailActivity.EXTRA_CHARACTER_ID, characterId) + } + ) + } + + private fun openPopularCommunityPost(item: FeedItem.Community) { + val creatorId = item.creatorId.toLongOrNull() ?: return + val postId = item.postId.toLongOrNull() ?: return + startActivity( + Intent(requireContext(), CreatorCommunityAllActivity::class.java).apply { + putExtra(Constants.EXTRA_COMMUNITY_CREATOR_ID, creatorId) + putExtra(Constants.EXTRA_COMMUNITY_POST_ID, postId) + putExtra(Constants.EXTRA_COMMUNITY_EXIST_ORDERED, item.existOrdered) + } + ) + } + + private fun showToast(toastMessage: ToastMessage) { + toastMessage.message?.let { message -> showToast(message) } + ?: toastMessage.resId?.let { resId -> showToast(getString(resId)) } + } + + private fun genreSectionKey(creatorIds: List): String { + val group = currentVisibleGenreGroups().firstOrNull { group -> + group.creators.map { it.creatorId } == creatorIds + } + return "$SECTION_KEY_GENRE_CREATORS:${group?.genre.orEmpty()}" + } + + private fun currentVisibleGenreGroups(): List { + val content = homeRecommendationViewModel.recommendationStateLiveData.value as? HomeRecommendationUiState.Content + ?: return emptyList() + return content.genreCreators.visibleHomeGenreCreatorGroups() + } + + private fun emptyHomeRecommendationContent(): HomeRecommendationUiState.Content { + return HomeRecommendationUiState.Content( + lives = HomeRecommendationLiveSection(emptyList()), + banners = HomeRecommendationBannerSection(emptyList()), + recentlyActiveCreators = HomeRecommendationRecentlyActiveCreatorSection(emptyList()), + recentDebutCreators = HomeRecommendationRecentDebutCreatorSection(emptyList()), + firstAudioContents = HomeRecommendationFirstAudioContentSection(emptyList()), + aiCharacters = HomeRecommendationAiCharacterSection(emptyList()), + genreCreators = HomeRecommendationGenreCreatorSection(emptyList()), + cheerCreators = HomeRecommendationCheerCreatorSection(emptyList()), + popularCommunityPosts = HomeRecommendationPopularCommunityPostSection(emptyList()) + ) + } + private fun List<*>.toSectionVisibility(): Int = if (isEmpty()) View.GONE else View.VISIBLE - private fun phase6SampleContent(): HomeRecommendationUiState.Content = HomeRecommendationUiState.Content( - lives = HomeRecommendationLiveSection( - listOf( - HomeRecommendationLiveUiModel(1L, 101L, null, "오늘 밤 라이브", "소다", "20:00"), - HomeRecommendationLiveUiModel(2L, 102L, null, "감성 낭독", "라임", "21:30"), - HomeRecommendationLiveUiModel(3L, 103L, null, "신작 토크", "하루", "22:00") - ) - ), - banners = HomeRecommendationBannerSection( - listOf( - HomeRecommendationBannerUiModel("sample-banner-1", null, null, null, null), - HomeRecommendationBannerUiModel("sample-banner-2", null, null, null, null) - ) - ), - recentlyActiveCreators = HomeRecommendationRecentlyActiveCreatorSection( - listOf( - HomeRecommendationRecentlyActiveCreatorUiModel( - 201L, - "베리", - null, - "LIVE_REPLAY도 라이브 라벨로 표시", - RecommendedActivityType.LiveReplay, - RecommendedActivityType.LiveReplay.labelResId, - "방금" - ), - HomeRecommendationRecentlyActiveCreatorUiModel(202L, "모카", null, "알 수 없는 활동", null, null, "오늘") - ) - ), - recentDebutCreators = HomeRecommendationRecentDebutCreatorSection(sampleCreators(301L)), - firstAudioContents = HomeRecommendationFirstAudioContentSection( - listOf( - HomeRecommendationFirstAudioContentUiModel( - contentId = 401L, - creatorId = 301L, - creatorNickname = "아린", - creatorProfileImage = null, - title = "처음 공개하는 오디오", - price = 0, - coverImage = null, - releaseDate = "2026.06.02", - tags = setOf(AudioContentTag.First, AudioContentTag.Point, AudioContentTag.Free) - ) - ) - ), - aiCharacters = HomeRecommendationAiCharacterSection( - listOf( - HomeRecommendationAiCharacterUiModel( - CharacterChatThumbnailItem( - characterId = 501L, - imageUrl = "", - characterName = "레아", - characterDescription = "밤마다 이야기를 들려주는 AI 캐릭터", - chatMessageCount = 14000L, - hasOriginal = true, - originalTitle = "달빛 아래" - ) - ) - ) - ), - genreCreators = HomeRecommendationGenreCreatorSection( - listOf( - HomeRecommendationGenreCreatorGroupUiModel("로맨스", sampleCreators(601L, count = 8)), - HomeRecommendationGenreCreatorGroupUiModel("판타지", sampleCreators(701L, count = 8)) - ) - ), - cheerCreators = HomeRecommendationCheerCreatorSection(sampleCreators(701L, count = 8)), - popularCommunityPosts = HomeRecommendationPopularCommunityPostSection(samplePopularCommunityPosts()) - ) - - private fun samplePopularCommunityPosts(): List { - return listOf( - samplePopularCommunityPost( - id = 801L, - creatorName = "소다", - bodyText = "무료 커뮤니티 게시글 샘플입니다. 이미지 영역과 본문, 댓글/좋아요 영역을 함께 확인합니다.", - imageUrl = "https://picsum.photos/seed/sodalive-community-free/692/472", - price = 0, - existOrdered = false, - paidStatus = HomeRecommendationPaidStatus.Free - ), - samplePopularCommunityPost( - id = 802L, - creatorName = "라임", - bodyText = "유료 미구매 커뮤니티 게시글 샘플입니다. 원본 이미지는 로드하지 않고 lock overlay와 가격만 표시합니다.", - imageUrl = "https://picsum.photos/seed/sodalive-community-locked/692/472", - price = 30, - existOrdered = false, - paidStatus = HomeRecommendationPaidStatus.Paid(30) - ), - samplePopularCommunityPost( - id = 803L, - creatorName = "하루", - bodyText = "구매 완료 커뮤니티 게시글 샘플입니다. 유료지만 overlay 없이 이미지를 표시합니다.", - imageUrl = "https://picsum.photos/seed/sodalive-community-purchased/692/472", - price = 50, - existOrdered = true, - paidStatus = HomeRecommendationPaidStatus.Purchased - ) - ) - } - - private fun samplePopularCommunityPost( - id: Long, - creatorName: String, - bodyText: String, - imageUrl: String, - price: Int, - existOrdered: Boolean, - paidStatus: HomeRecommendationPaidStatus - ): HomeRecommendationPopularCommunityPostUiModel { - return HomeRecommendationPopularCommunityPostUiModel( - item = FeedItem.Community( - feedId = "sample-community-$id", - creatorId = "sample-creator-$id", - creatorName = creatorName, - creatorImageUrl = "", - postId = id.toString(), - bodyText = bodyText, - keywordText = "", - createdAtText = "방금", - commentCount = 5, - likeCount = 12, - imageUrl = imageUrl, - audioUrl = "https://example.com/sample-community-$id.m4a", - price = price, - existOrdered = existOrdered, - showKeyword = false - ), - paidStatus = paidStatus - ) - } - - private fun sampleCreators(startId: Long, count: Int = 3): List { - return List(count) { index -> - HomeRecommendationCreatorUiModel(startId + index, "크리에이터${index + 1}", null) - } + private companion object { + const val SECTION_KEY_CHEER_CREATORS = "cheerCreators" + const val SECTION_KEY_GENRE_CREATORS = "genreCreators" } } diff --git a/app/src/test/java/kr/co/vividnext/sodalive/v2/main/home/HomeMainFragmentLayoutTest.kt b/app/src/test/java/kr/co/vividnext/sodalive/v2/main/home/HomeMainFragmentLayoutTest.kt index 3e20ad37..a62ac3c8 100644 --- a/app/src/test/java/kr/co/vividnext/sodalive/v2/main/home/HomeMainFragmentLayoutTest.kt +++ b/app/src/test/java/kr/co/vividnext/sodalive/v2/main/home/HomeMainFragmentLayoutTest.kt @@ -51,6 +51,7 @@ import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNotNull import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner @@ -927,6 +928,27 @@ class HomeMainFragmentLayoutTest { } } + @Test + fun `home main fragment phase9 replaces sample content with viewmodel state binding`() { + val source = homeMainFragmentSource() + + assertFalse(source.contains("phase6SampleContent")) + assertTrue(source.contains("HomeRecommendationViewModel")) + assertTrue(source.contains("recommendationStateLiveData.observe(viewLifecycleOwner)")) + assertTrue(source.contains("homeRecommendationViewModel.loadRecommendations()")) + } + + @Test + fun `home main fragment phase9 connects loading toast and follow all callbacks`() { + val source = homeMainFragmentSource() + + assertTrue(source.contains("isLoading.observe(viewLifecycleOwner)")) + assertTrue(source.contains("toastLiveData.observe(viewLifecycleOwner)")) + assertTrue(source.contains("showToast")) + assertTrue(source.contains("followCreators(SECTION_KEY_CHEER_CREATORS")) + assertTrue(source.contains("followCreators(genreSectionKey")) + } + private fun TextView.clickableSpanCount(): Int { val spanned = text as? Spanned ?: return 0 return spanned.getSpans(0, spanned.length, ClickableSpan::class.java).size @@ -937,6 +959,14 @@ class HomeMainFragmentLayoutTest { spanned.getSpans(0, spanned.length, ClickableSpan::class.java).last().onClick(this) } + private fun homeMainFragmentSource(): String { + val projectRoot = java.io.File("..").canonicalFile + return java.io.File( + projectRoot, + "app/src/main/java/kr/co/vividnext/sodalive/v2/main/HomeMainFragment.kt" + ).readText() + } + private fun inflateView(layoutResId: Int): View { val context = ApplicationProvider.getApplicationContext() return LayoutInflater.from(context).inflate(layoutResId, null, false)