From 51b50eed752c8aed6d4b5a99ae0d69d6a9399c1e Mon Sep 17 00:00:00 2001 From: klaus Date: Tue, 23 Jun 2026 11:33:35 +0900 Subject: [PATCH] =?UTF-8?q?feat(home):=20AI=20=EC=BA=90=EB=A6=AD=ED=84=B0?= =?UTF-8?q?=20=ED=81=AC=EB=A6=AC=EC=97=90=EC=9D=B4=ED=84=B0=20=EC=9D=B4?= =?UTF-8?q?=EB=8F=99=EC=9D=84=20=EC=97=B0=EA=B2=B0=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sodalive/v2/main/home/HomeMainFragment.kt | 19 ++++--- .../home/data/HomeRecommendationModels.kt | 1 + .../home/model/HomeRecommendationMappers.kt | 1 + .../home/model/HomeRecommendationUiModels.kt | 15 ++++++ .../v2/main/home/ui/HomeAiCharacterAdapter.kt | 15 +++--- .../main/home/HomeMainFragmentLayoutTest.kt | 51 +++++++++++++++++++ 6 files changed, 84 insertions(+), 18 deletions(-) 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 53db559f..36f44873 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 @@ -7,7 +7,6 @@ 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 @@ -18,6 +17,7 @@ import kr.co.vividnext.sodalive.explorer.profile.creator_community.all.CreatorCo import kr.co.vividnext.sodalive.v2.creator.channel.CreatorChannelActivity 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 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 @@ -35,6 +35,8 @@ import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationRecentlyAct import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationUiState import kr.co.vividnext.sodalive.v2.main.home.model.toHomeRecommendationBannerIntent import kr.co.vividnext.sodalive.v2.main.home.model.toHomeRecommendationBannerRoute +import kr.co.vividnext.sodalive.v2.main.home.model.toHomeRecommendationAiCharacterIntent +import kr.co.vividnext.sodalive.v2.main.home.model.toHomeRecommendationAiCharacterRoute import kr.co.vividnext.sodalive.v2.main.home.model.toHomeRecommendationRecentlyActiveCreatorIntent import kr.co.vividnext.sodalive.v2.main.home.model.toHomeRecommendationRecentlyActiveCreatorRoute import kr.co.vividnext.sodalive.v2.main.home.model.visibleHomeGenreCreatorGroups @@ -63,7 +65,7 @@ class HomeMainFragment : BaseFragment( private val recentActivityCreatorAdapter = HomeRecentActivityCreatorAdapter { onRecentActivityClick(it) } private val recentDebutCreatorAdapter = HomeRecentDebutCreatorAdapter { openCreatorProfile(it.creatorId) } private val firstAudioAdapter = HomeFirstAudioAdapter { openAudioContentDetail(it) } - private val aiCharacterAdapter = HomeAiCharacterAdapter { openCharacterDetail(it.characterId) } + private val aiCharacterAdapter = HomeAiCharacterAdapter { onAiCharacterClick(it) } private val genreCreatorAdapter = HomeGenreCreatorAdapter( onFollowAllClick = { creatorIds -> onGenreFollowAllClick(creatorIds) }, onCreatorClick = { creator -> openCreatorProfile(creator.creatorId) } @@ -339,6 +341,11 @@ class HomeMainFragment : BaseFragment( startActivity(route.toHomeRecommendationRecentlyActiveCreatorIntent(requireContext())) } + private fun onAiCharacterClick(item: HomeRecommendationAiCharacterUiModel) { + val route = item.toHomeRecommendationAiCharacterRoute() ?: return + startActivity(route.toHomeRecommendationAiCharacterIntent(requireContext())) + } + private fun openCreatorRankingProfile(item: CreatorRankingItem) { if (item.creatorId <= 0L) return openCreatorProfile(item.creatorId) @@ -358,14 +365,6 @@ class HomeMainFragment : BaseFragment( ) } - 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 diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/data/HomeRecommendationModels.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/data/HomeRecommendationModels.kt index a6c95bc2..92cd05e9 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/data/HomeRecommendationModels.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/data/HomeRecommendationModels.kt @@ -64,6 +64,7 @@ data class HomeFirstAudioContentItem( @Keep data class HomeAiCharacterItem( @SerializedName("characterId") val characterId: Long, + @SerializedName("creatorId") val creatorId: Long, @SerializedName("name") val name: String, @SerializedName("description") val description: String, @SerializedName("profileImage") val profileImage: String?, diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/model/HomeRecommendationMappers.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/model/HomeRecommendationMappers.kt index f5038551..07651f87 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/model/HomeRecommendationMappers.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/model/HomeRecommendationMappers.kt @@ -79,6 +79,7 @@ fun HomeFirstAudioContentItem.toUiModel(): HomeRecommendationFirstAudioContentUi ) fun HomeAiCharacterItem.toUiModel(): HomeRecommendationAiCharacterUiModel = HomeRecommendationAiCharacterUiModel( + creatorId = creatorId, item = CharacterChatThumbnailItem( characterId = characterId, imageUrl = profileImage.orEmpty(), diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/model/HomeRecommendationUiModels.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/model/HomeRecommendationUiModels.kt index ae29fbcc..9e86b23e 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/model/HomeRecommendationUiModels.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/model/HomeRecommendationUiModels.kt @@ -191,9 +191,24 @@ data class HomeRecommendationFirstAudioContentUiModel( ) data class HomeRecommendationAiCharacterUiModel( + val creatorId: Long, val item: CharacterChatThumbnailItem ) +sealed interface HomeRecommendationAiCharacterRoute { + data class Creator(val creatorId: Long) : HomeRecommendationAiCharacterRoute +} + +fun HomeRecommendationAiCharacterUiModel.toHomeRecommendationAiCharacterRoute(): HomeRecommendationAiCharacterRoute? { + return creatorId.takeIf { it > 0L }?.let(HomeRecommendationAiCharacterRoute::Creator) +} + +fun HomeRecommendationAiCharacterRoute.toHomeRecommendationAiCharacterIntent(context: Context): Intent { + return when (this) { + is HomeRecommendationAiCharacterRoute.Creator -> CreatorChannelActivity.newIntent(context, creatorId) + } +} + data class HomeRecommendationGenreCreatorGroupUiModel( val genre: String, val creators: List, diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/ui/HomeAiCharacterAdapter.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/ui/HomeAiCharacterAdapter.kt index 105aeacc..0cb52764 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/ui/HomeAiCharacterAdapter.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/ui/HomeAiCharacterAdapter.kt @@ -6,11 +6,10 @@ import androidx.recyclerview.widget.RecyclerView import kr.co.vividnext.sodalive.R import kr.co.vividnext.sodalive.extensions.loadUrl import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationAiCharacterUiModel -import kr.co.vividnext.sodalive.v2.widget.characterchatthumbnail.CharacterChatThumbnailItem import kr.co.vividnext.sodalive.v2.widget.characterchatthumbnail.CharacterChatThumbnailView class HomeAiCharacterAdapter( - private val onClickItem: (CharacterChatThumbnailItem) -> Unit = {} + private val onClickItem: (HomeRecommendationAiCharacterUiModel) -> Unit = {} ) : RecyclerView.Adapter() { private var items: List = emptyList() @@ -31,19 +30,19 @@ class HomeAiCharacterAdapter( } override fun onBindViewHolder(holder: CharacterViewHolder, position: Int) { - holder.bind(items[position].item) + holder.bind(items[position]) } override fun getItemCount(): Int = items.size class CharacterViewHolder( private val view: CharacterChatThumbnailView, - private val onClickItem: (CharacterChatThumbnailItem) -> Unit + private val onClickItem: (HomeRecommendationAiCharacterUiModel) -> Unit ) : RecyclerView.ViewHolder(view) { - fun bind(item: CharacterChatThumbnailItem) { - view.bind(item) - view.imageView().loadUrl(item.imageUrl) - view.setOnCharacterClick(onClickItem) + fun bind(item: HomeRecommendationAiCharacterUiModel) { + view.bind(item.item) + view.imageView().loadUrl(item.item.imageUrl) + view.setOnCharacterClick { onClickItem(item) } } } } 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 453287bc..b153aa80 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 @@ -32,6 +32,7 @@ import kr.co.vividnext.sodalive.settings.event.EventDetailActivity import kr.co.vividnext.sodalive.settings.event.EventItem import kr.co.vividnext.sodalive.v2.creator.channel.CreatorChannelActivity import kr.co.vividnext.sodalive.v2.main.home.data.HomeActiveCreatorItem +import kr.co.vividnext.sodalive.v2.main.home.data.HomeAiCharacterItem import kr.co.vividnext.sodalive.v2.main.home.data.HomeBannerItem import kr.co.vividnext.sodalive.v2.main.home.data.HomeCreatorItem import kr.co.vividnext.sodalive.v2.main.home.data.HomeFirstAudioContentItem @@ -40,6 +41,7 @@ import kr.co.vividnext.sodalive.v2.main.home.data.HomeLiveItem import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationBannerSection import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationBannerRoute import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationBannerUiModel +import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationAiCharacterRoute import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationFirstAudioContentUiModel import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationCreatorUiModel import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationGenreCreatorGroupUiModel @@ -53,6 +55,8 @@ import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationRecentlyAct import kr.co.vividnext.sodalive.v2.common.CreatorActivityType import kr.co.vividnext.sodalive.v2.main.home.model.toHomeRecommendationBannerIntent import kr.co.vividnext.sodalive.v2.main.home.model.toHomeRecommendationBannerRoute +import kr.co.vividnext.sodalive.v2.main.home.model.toHomeRecommendationAiCharacterIntent +import kr.co.vividnext.sodalive.v2.main.home.model.toHomeRecommendationAiCharacterRoute import kr.co.vividnext.sodalive.v2.main.home.model.toHomeRecommendationRecentlyActiveCreatorIntent import kr.co.vividnext.sodalive.v2.main.home.model.toHomeRecommendationRecentlyActiveCreatorRoute import kr.co.vividnext.sodalive.v2.main.home.model.visibleHomePopularCommunityPosts @@ -1087,6 +1091,41 @@ class HomeMainFragmentLayoutTest { assertEquals(13L, communityIntent.getLongExtra(Constants.EXTRA_COMMUNITY_CREATOR_ID, 0L)) } + @Test + fun `home ai character mapper preserves creator id for routing and character id for display`() { + val item = HomeAiCharacterItem( + characterId = 11L, + creatorId = 22L, + name = "캐릭터", + description = "설명", + profileImage = "https://example.com/character.png", + totalChatCount = 33L, + originalWorkTitle = "원작" + ).toUiModel() + + assertEquals(22L, item.creatorId) + assertEquals(11L, item.item.characterId) + } + + @Test + fun `home ai character route uses creator id and ignores invalid ids`() { + assertEquals( + HomeRecommendationAiCharacterRoute.Creator(22L), + aiCharacter(creatorId = 22L).toHomeRecommendationAiCharacterRoute() + ) + assertEquals(null, aiCharacter(creatorId = 0L).toHomeRecommendationAiCharacterRoute()) + assertEquals(null, aiCharacter(creatorId = -1L).toHomeRecommendationAiCharacterRoute()) + } + + @Test + fun `home ai character route creates creator channel intent`() { + val context = ApplicationProvider.getApplicationContext() + val intent = HomeRecommendationAiCharacterRoute.Creator(22L).toHomeRecommendationAiCharacterIntent(context) + + assertEquals(CreatorChannelActivity::class.java.name, intent.component?.className) + assertEquals(22L, intent.getLongExtra(CreatorChannelActivity.EXTRA_CREATOR_ID, 0L)) + } + @Test fun `home popular community adapter applies blur when locked paid post image is loaded`() { val source = projectFile( @@ -1517,6 +1556,18 @@ class HomeMainFragmentLayoutTest { ) } + private fun aiCharacter(creatorId: Long): kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationAiCharacterUiModel { + return HomeAiCharacterItem( + characterId = 11L, + creatorId = creatorId, + name = "캐릭터", + description = "설명", + profileImage = null, + totalChatCount = 33L, + originalWorkTitle = null + ).toUiModel() + } + private fun popularCommunityData( audioUrl: String?, createdAt: String = "2분 전"