feat(home): 추천 상태 observe를 연결한다

This commit is contained in:
2026-06-05 16:01:37 +09:00
parent 6679808a18
commit c6680d0bd2
2 changed files with 163 additions and 153 deletions

View File

@@ -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>(
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<Long>) -> Unit = {}
private var onCheerFollowAllClick: (List<Long>) -> Unit = {}
@@ -71,7 +81,8 @@ class HomeMainFragment : BaseFragment<FragmentV2MainHomeBinding>(
setUpSectionTitles()
setUpRecommendationAdapters()
setUpBusinessInfo()
bindHomeRecommendationContent(phase6SampleContent())
bindHomeRecommendationObservers()
homeRecommendationViewModel.loadRecommendations()
}
private fun setUpBusinessInfo() {
@@ -94,7 +105,10 @@ class HomeMainFragment : BaseFragment<FragmentV2MainHomeBinding>(
}
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<FragmentV2MainHomeBinding>(
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<FragmentV2MainHomeBinding>(
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<Long>): String {
val group = currentVisibleGenreGroups().firstOrNull { group ->
group.creators.map { it.creatorId } == creatorIds
}
return "$SECTION_KEY_GENRE_CREATORS:${group?.genre.orEmpty()}"
}
private fun currentVisibleGenreGroups(): List<HomeRecommendationGenreCreatorGroupUiModel> {
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<HomeRecommendationPopularCommunityPostUiModel> {
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<HomeRecommendationCreatorUiModel> {
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"
}
}

View File

@@ -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<Context>()
return LayoutInflater.from(context).inflate(layoutResId, null, false)