feat(creator): 채널 홈 UI 상태를 추가한다

This commit is contained in:
2026-06-13 17:19:58 +09:00
parent 0ae6596816
commit a355838039
5 changed files with 417 additions and 0 deletions

View File

@@ -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<CreatorChannelSnsUiItem> = 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)
}

View File

@@ -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<CreatorChannelTab>,
val sections: List<CreatorChannelHomeSection>
) : 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<CreatorChannelDonationResponse>) : CreatorChannelHomeSection
data class Notices(val notices: List<CreatorChannelCommunityPostResponse>) : CreatorChannelHomeSection
data class Schedules(val schedules: List<CreatorChannelScheduleResponse>) : CreatorChannelHomeSection
data class AudioContents(val audioContents: List<CreatorChannelAudioContentResponse>) : CreatorChannelHomeSection
data class Series(val series: List<CreatorChannelSeriesResponse>) : CreatorChannelHomeSection
data class Communities(val communities: List<CreatorChannelCommunityPostResponse>) : 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<CreatorChannelSnsUiItem>) : CreatorChannelHomeSection
}
data class CreatorChannelSnsUiItem(
val label: String,
val url: String
)

View File

@@ -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
)
}
}

View File

@@ -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<CreatorChannelHomeSection.Sns>().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<CreatorChannelDonationResponse> = listOf(donation()),
notices: List<CreatorChannelCommunityPostResponse> = listOf(post(20L)),
schedules: List<CreatorChannelScheduleResponse> = listOf(schedule()),
audioContents: List<CreatorChannelAudioContentResponse> = listOf(audioContent(11L)),
series: List<CreatorChannelSeriesResponse> = listOf(series()),
communities: List<CreatorChannelCommunityPostResponse> = 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
)
}

View File

@@ -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)
}
}