diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/model/CreatorChannelHomeMappers.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/model/CreatorChannelHomeMappers.kt new file mode 100644 index 00000000..f6f4c6c0 --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/model/CreatorChannelHomeMappers.kt @@ -0,0 +1,49 @@ +package kr.co.vividnext.sodalive.v2.creator.channel.model + +import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelHomeResponse +import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelSnsResponse + +fun CreatorChannelHomeResponse.toUiContent(): CreatorChannelHomeUiState.Content { + val sections = buildList { + currentLive?.let { add(CreatorChannelHomeSection.CurrentLive(it)) } + latestAudioContent?.let { add(CreatorChannelHomeSection.LatestAudioContent(it)) } + channelDonations.takeIf { it.isNotEmpty() }?.let { add(CreatorChannelHomeSection.Donations(it)) } + notices.takeIf { it.isNotEmpty() }?.let { add(CreatorChannelHomeSection.Notices(it)) } + schedules.takeIf { it.isNotEmpty() }?.let { add(CreatorChannelHomeSection.Schedules(it)) } + audioContents.takeIf { it.isNotEmpty() }?.let { add(CreatorChannelHomeSection.AudioContents(it)) } + series.takeIf { it.isNotEmpty() }?.let { add(CreatorChannelHomeSection.Series(it)) } + communities.takeIf { it.isNotEmpty() }?.let { add(CreatorChannelHomeSection.Communities(it)) } + fanTalk.takeIf { it.totalCount > 0 || it.latestFanTalk != null }?.let { add(CreatorChannelHomeSection.FanTalk(it)) } + introduce.takeIf { it.isNotBlank() }?.let { add(CreatorChannelHomeSection.Introduce(it)) } + add(CreatorChannelHomeSection.Activity(activity)) + sns.toUiItems().takeIf { it.isNotEmpty() }?.let { add(CreatorChannelHomeSection.Sns(it)) } + } + + return CreatorChannelHomeUiState.Content( + header = CreatorChannelHeaderUiModel( + creatorId = creator.creatorId, + characterId = creator.characterId, + nickname = creator.nickname, + profileImageUrl = creator.profileImageUrl, + followerCount = creator.followerCount, + isFollow = creator.isFollow, + isNotify = creator.isNotify, + isAiChatAvailable = creator.isAiChatAvailable, + isDmAvailable = creator.isDmAvailable + ), + tabs = CreatorChannelTab.entries, + sections = sections + ) +} + +private fun CreatorChannelSnsResponse.toUiItems(): List = buildList { + instagramUrl.toSnsItem("Instagram")?.let(::add) + fancimmUrl.toSnsItem("Fancimm")?.let(::add) + xUrl.toSnsItem("X")?.let(::add) + youtubeUrl.toSnsItem("YouTube")?.let(::add) + kakaoOpenChatUrl.toSnsItem("Kakao Open Chat")?.let(::add) +} + +private fun String.toSnsItem(label: String): CreatorChannelSnsUiItem? = takeIf { it.isNotBlank() }?.let { + CreatorChannelSnsUiItem(label = label, url = it) +} diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/model/CreatorChannelHomeUiModels.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/model/CreatorChannelHomeUiModels.kt new file mode 100644 index 00000000..f1f7c3e0 --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/model/CreatorChannelHomeUiModels.kt @@ -0,0 +1,63 @@ +package kr.co.vividnext.sodalive.v2.creator.channel.model + +import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelActivityResponse +import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelAudioContentResponse +import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelCommunityPostResponse +import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelDonationResponse +import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelFanTalkSummaryResponse +import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelLiveResponse +import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelScheduleResponse +import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelSeriesResponse + +sealed interface CreatorChannelHomeUiState { + data object Loading : CreatorChannelHomeUiState + data object Empty : CreatorChannelHomeUiState + data class Error(val message: String?) : CreatorChannelHomeUiState + data class Content( + val header: CreatorChannelHeaderUiModel, + val tabs: List, + val sections: List + ) : CreatorChannelHomeUiState +} + +enum class CreatorChannelTab(val label: String) { + Home("홈"), + Live("라이브"), + Audio("오디오"), + Series("시리즈"), + Community("커뮤니티"), + FanTalk("팬Talk"), + Donation("후원") +} + +data class CreatorChannelHeaderUiModel( + val creatorId: Long, + val characterId: Long?, + val nickname: String, + val profileImageUrl: String, + val followerCount: Int, + val isFollow: Boolean, + val isNotify: Boolean, + val isAiChatAvailable: Boolean, + val isDmAvailable: Boolean +) + +sealed interface CreatorChannelHomeSection { + data class CurrentLive(val live: CreatorChannelLiveResponse) : CreatorChannelHomeSection + data class LatestAudioContent(val audioContent: CreatorChannelAudioContentResponse) : CreatorChannelHomeSection + data class Donations(val donations: List) : CreatorChannelHomeSection + data class Notices(val notices: List) : CreatorChannelHomeSection + data class Schedules(val schedules: List) : CreatorChannelHomeSection + data class AudioContents(val audioContents: List) : CreatorChannelHomeSection + data class Series(val series: List) : CreatorChannelHomeSection + data class Communities(val communities: List) : CreatorChannelHomeSection + data class FanTalk(val fanTalk: CreatorChannelFanTalkSummaryResponse) : CreatorChannelHomeSection + data class Introduce(val introduce: String) : CreatorChannelHomeSection + data class Activity(val activity: CreatorChannelActivityResponse) : CreatorChannelHomeSection + data class Sns(val items: List) : CreatorChannelHomeSection +} + +data class CreatorChannelSnsUiItem( + val label: String, + val url: String +) diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/model/CreatorChannelTitleBarState.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/model/CreatorChannelTitleBarState.kt new file mode 100644 index 00000000..e3072bf1 --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/model/CreatorChannelTitleBarState.kt @@ -0,0 +1,26 @@ +package kr.co.vividnext.sodalive.v2.creator.channel.model + +import androidx.annotation.DrawableRes +import kr.co.vividnext.sodalive.R + +data class CreatorChannelTitleBarState( + @DrawableRes val followIconResId: Int, + @DrawableRes val bellIconResId: Int?, + val isActionEnabled: Boolean +) { + companion object { + fun from( + isFollow: Boolean, + isNotify: Boolean, + isInProgress: Boolean + ): CreatorChannelTitleBarState = CreatorChannelTitleBarState( + followIconResId = if (isFollow) R.drawable.ic_new_following else R.drawable.ic_new_follow, + bellIconResId = when { + !isFollow -> null + isNotify -> R.drawable.ic_bar_bell_colored + else -> R.drawable.ic_bar_bell + }, + isActionEnabled = !isInProgress + ) + } +} diff --git a/app/src/test/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelHomeMapperTest.kt b/app/src/test/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelHomeMapperTest.kt new file mode 100644 index 00000000..db261e4b --- /dev/null +++ b/app/src/test/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelHomeMapperTest.kt @@ -0,0 +1,233 @@ +package kr.co.vividnext.sodalive.v2.creator.channel + +import kr.co.vividnext.sodalive.v2.common.CreatorActivityType +import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelActivityResponse +import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelAudioContentResponse +import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelCommunityPostResponse +import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelCreatorResponse +import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelDonationResponse +import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelFanTalkResponse +import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelFanTalkSummaryResponse +import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelHomeResponse +import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelLiveResponse +import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelScheduleResponse +import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelSeriesResponse +import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelSnsResponse +import kr.co.vividnext.sodalive.v2.creator.channel.model.CreatorChannelHomeSection +import kr.co.vividnext.sodalive.v2.creator.channel.model.CreatorChannelTab +import kr.co.vividnext.sodalive.v2.creator.channel.model.toUiContent +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class CreatorChannelHomeMapperTest { + + @Test + fun `크리에이터 정보와 탭 순서를 UI content에 그대로 매핑한다`() { + val content = response().toUiContent() + + assertEquals(100L, content.header.creatorId) + assertEquals(200L, content.header.characterId) + assertEquals("소다", content.header.nickname) + assertEquals("https://example.com/profile.png", content.header.profileImageUrl) + assertEquals(1234, content.header.followerCount) + assertTrue(content.header.isFollow) + assertFalse(content.header.isNotify) + assertTrue(content.header.isAiChatAvailable) + assertTrue(content.header.isDmAvailable) + assertEquals( + listOf("홈", "라이브", "오디오", "시리즈", "커뮤니티", "팬Talk", "후원"), + content.tabs.map(CreatorChannelTab::label) + ) + } + + @Test + fun `null 단건 콘텐츠와 빈 리스트와 blank SNS는 section을 생성하지 않는다`() { + val content = response( + currentLive = null, + latestAudioContent = null, + channelDonations = emptyList(), + notices = emptyList(), + schedules = emptyList(), + audioContents = emptyList(), + series = emptyList(), + communities = emptyList(), + fanTalk = CreatorChannelFanTalkSummaryResponse(totalCount = 0, latestFanTalk = null), + introduce = "", + sns = CreatorChannelSnsResponse( + instagramUrl = " ", + fancimmUrl = "", + xUrl = "", + youtubeUrl = "", + kakaoOpenChatUrl = "" + ) + ).toUiContent() + + assertFalse(content.sections.any { it is CreatorChannelHomeSection.CurrentLive }) + assertFalse(content.sections.any { it is CreatorChannelHomeSection.LatestAudioContent }) + assertFalse(content.sections.any { it is CreatorChannelHomeSection.Donations }) + assertFalse(content.sections.any { it is CreatorChannelHomeSection.Notices }) + assertFalse(content.sections.any { it is CreatorChannelHomeSection.Schedules }) + assertFalse(content.sections.any { it is CreatorChannelHomeSection.AudioContents }) + assertFalse(content.sections.any { it is CreatorChannelHomeSection.Series }) + assertFalse(content.sections.any { it is CreatorChannelHomeSection.Communities }) + assertFalse(content.sections.any { it is CreatorChannelHomeSection.FanTalk }) + assertFalse(content.sections.any { it is CreatorChannelHomeSection.Introduce }) + assertFalse(content.sections.any { it is CreatorChannelHomeSection.Sns }) + } + + @Test + fun `값이 있는 홈 필드는 section으로 생성하고 blank SNS URL만 제외한다`() { + val content = response().toUiContent() + + assertTrue(content.sections.any { it is CreatorChannelHomeSection.CurrentLive }) + assertTrue(content.sections.any { it is CreatorChannelHomeSection.LatestAudioContent }) + assertTrue(content.sections.any { it is CreatorChannelHomeSection.Donations }) + assertTrue(content.sections.any { it is CreatorChannelHomeSection.Notices }) + assertTrue(content.sections.any { it is CreatorChannelHomeSection.Schedules }) + assertTrue(content.sections.any { it is CreatorChannelHomeSection.AudioContents }) + assertTrue(content.sections.any { it is CreatorChannelHomeSection.Series }) + assertTrue(content.sections.any { it is CreatorChannelHomeSection.Communities }) + assertTrue(content.sections.any { it is CreatorChannelHomeSection.FanTalk }) + assertTrue(content.sections.any { it is CreatorChannelHomeSection.Introduce }) + val sns = content.sections.filterIsInstance().single() + + assertEquals(listOf("Instagram", "YouTube"), sns.items.map { it.label }) + assertEquals(listOf("https://instagram.example", "https://youtube.example"), sns.items.map { it.url }) + } + + private fun response( + currentLive: CreatorChannelLiveResponse? = live(), + latestAudioContent: CreatorChannelAudioContentResponse? = audioContent(10L), + channelDonations: List = listOf(donation()), + notices: List = listOf(post(20L)), + schedules: List = listOf(schedule()), + audioContents: List = listOf(audioContent(11L)), + series: List = listOf(series()), + communities: List = listOf(post(21L)), + fanTalk: CreatorChannelFanTalkSummaryResponse = fanTalk(), + introduce: String = "소개입니다", + activity: CreatorChannelActivityResponse = activity(), + sns: CreatorChannelSnsResponse = CreatorChannelSnsResponse( + instagramUrl = "https://instagram.example", + fancimmUrl = "", + xUrl = " ", + youtubeUrl = "https://youtube.example", + kakaoOpenChatUrl = "" + ) + ) = CreatorChannelHomeResponse( + creator = CreatorChannelCreatorResponse( + creatorId = 100L, + characterId = 200L, + nickname = "소다", + profileImageUrl = "https://example.com/profile.png", + followerCount = 1234, + isAiChatAvailable = true, + isDmAvailable = true, + isFollow = true, + isNotify = false + ), + currentLive = currentLive, + latestAudioContent = latestAudioContent, + channelDonations = channelDonations, + notices = notices, + schedules = schedules, + audioContents = audioContents, + series = series, + communities = communities, + fanTalk = fanTalk, + introduce = introduce, + activity = activity, + sns = sns + ) + + private fun live() = CreatorChannelLiveResponse( + liveId = 1L, + title = "라이브", + coverImageUrl = "https://example.com/live.png", + beginDateTimeUtc = "2026-06-11T12:00:00Z", + price = 10, + isAdult = false + ) + + private fun audioContent(id: Long) = CreatorChannelAudioContentResponse( + audioContentId = id, + title = "오디오 $id", + duration = "10:00", + imageUrl = "https://example.com/audio.png", + price = 10, + isPointAvailable = true, + isFirstContent = false, + seriesName = "시리즈", + isOriginalSeries = true + ) + + private fun donation() = CreatorChannelDonationResponse( + donationId = 1L, + memberId = 2L, + nickname = "후원자", + profileImageUrl = "https://example.com/member.png", + can = 100, + isSecret = false, + message = "응원합니다", + createdAtUtc = "2026-06-11T12:00:00Z" + ) + + private fun schedule() = CreatorChannelScheduleResponse( + scheduledAtUtc = "2026-06-12T12:00:00Z", + title = "일정", + type = CreatorActivityType.Live, + targetId = 1L + ) + + private fun series() = CreatorChannelSeriesResponse( + seriesId = 1L, + title = "시리즈", + coverImageUrl = "https://example.com/series.png", + publishedDaysOfWeek = "MON", + isComplete = false, + numberOfContent = 3, + isNew = true, + isPopular = false, + isOriginal = true + ) + + private fun post(id: Long) = CreatorChannelCommunityPostResponse( + postId = id, + creatorId = 100L, + creatorNickname = "소다", + creatorProfileUrl = "https://example.com/profile.png", + imageUrl = null, + audioUrl = null, + content = "게시글", + price = 0, + dateUtc = "2026-06-11T12:00:00Z", + existOrdered = false, + likeCount = 1, + commentCount = 2 + ) + + private fun fanTalk() = CreatorChannelFanTalkSummaryResponse( + totalCount = 1, + latestFanTalk = CreatorChannelFanTalkResponse( + fanTalkId = 1L, + memberId = 2L, + nickname = "팬", + profileImageUrl = "https://example.com/fan.png", + content = "팬톡", + languageCode = "ko", + createdAtUtc = "2026-06-11T12:00:00Z" + ) + ) + + private fun activity() = CreatorChannelActivityResponse( + debutDateUtc = "2026-01-01T00:00:00Z", + dDay = "D+1", + liveCount = 1, + liveDurationHours = 2, + liveContributorCount = 3, + audioContentCount = 4, + seriesCount = 5 + ) +} diff --git a/app/src/test/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelTitleBarStateTest.kt b/app/src/test/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelTitleBarStateTest.kt new file mode 100644 index 00000000..7cbed6d0 --- /dev/null +++ b/app/src/test/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelTitleBarStateTest.kt @@ -0,0 +1,46 @@ +package kr.co.vividnext.sodalive.v2.creator.channel + +import kr.co.vividnext.sodalive.R +import kr.co.vividnext.sodalive.v2.creator.channel.model.CreatorChannelTitleBarState +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class CreatorChannelTitleBarStateTest { + + @Test + fun `팔로우하지 않은 상태는 follow 아이콘만 표시한다`() { + val state = CreatorChannelTitleBarState.from(isFollow = false, isNotify = false, isInProgress = false) + + assertEquals(R.drawable.ic_new_follow, state.followIconResId) + assertNull(state.bellIconResId) + assertTrue(state.isActionEnabled) + } + + @Test + fun `팔로우와 알림이 모두 켜진 상태는 following과 colored bell 아이콘을 표시한다`() { + val state = CreatorChannelTitleBarState.from(isFollow = true, isNotify = true, isInProgress = false) + + assertEquals(R.drawable.ic_new_following, state.followIconResId) + assertEquals(R.drawable.ic_bar_bell_colored, state.bellIconResId) + assertTrue(state.isActionEnabled) + } + + @Test + fun `팔로우만 켜진 상태는 following과 기본 bell 아이콘을 표시한다`() { + val state = CreatorChannelTitleBarState.from(isFollow = true, isNotify = false, isInProgress = false) + + assertEquals(R.drawable.ic_new_following, state.followIconResId) + assertEquals(R.drawable.ic_bar_bell, state.bellIconResId) + assertTrue(state.isActionEnabled) + } + + @Test + fun `요청 진행 중에는 액션을 비활성화한다`() { + val state = CreatorChannelTitleBarState.from(isFollow = true, isNotify = true, isInProgress = true) + + assertFalse(state.isActionEnabled) + } +}