feat(home): 팔로잉 응답 UI 매핑을 추가한다
This commit is contained in:
@@ -0,0 +1,108 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.main.home.model
|
||||||
|
|
||||||
|
import androidx.annotation.StringRes
|
||||||
|
import kr.co.vividnext.sodalive.R
|
||||||
|
import kr.co.vividnext.sodalive.common.UtcRelativeTimeTextFormatter
|
||||||
|
import kr.co.vividnext.sodalive.v2.main.chat.model.toUiItems
|
||||||
|
import kr.co.vividnext.sodalive.v2.main.home.data.FollowingCreatorResponse
|
||||||
|
import kr.co.vividnext.sodalive.v2.main.home.data.FollowingLiveResponse
|
||||||
|
import kr.co.vividnext.sodalive.v2.main.home.data.FollowingNewsResponse
|
||||||
|
import kr.co.vividnext.sodalive.v2.main.home.data.FollowingNewsType
|
||||||
|
import kr.co.vividnext.sodalive.v2.main.home.data.FollowingScheduleResponse
|
||||||
|
import kr.co.vividnext.sodalive.v2.main.home.data.HomeFollowingTabResponse
|
||||||
|
|
||||||
|
fun HomeFollowingTabResponse.toUiState(
|
||||||
|
relativeTimeTextFormatter: UtcRelativeTimeTextFormatter
|
||||||
|
): HomeFollowingUiState {
|
||||||
|
if (isLoginRequired) return HomeFollowingUiState.LoginRequired
|
||||||
|
|
||||||
|
val content = toContent(relativeTimeTextFormatter)
|
||||||
|
return if (content.isEmpty) HomeFollowingUiState.Empty else content
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun HomeFollowingTabResponse.toContent(
|
||||||
|
relativeTimeTextFormatter: UtcRelativeTimeTextFormatter
|
||||||
|
) = HomeFollowingUiState.Content(
|
||||||
|
followingCreators = HomeFollowingCreatorSection(followingCreators.map { it.toUiItem() }),
|
||||||
|
onAirLives = HomeFollowingLiveSection(onAirLives.map { it.toUiItem() }),
|
||||||
|
recentChats = HomeFollowingChatSection(recentChats.toUiItems()),
|
||||||
|
monthlySchedules = HomeFollowingScheduleSection(monthlySchedules.map { it.toUiItem() }),
|
||||||
|
recentNews = HomeFollowingNewsSection(recentNews.mapNotNull { it.toUiItem(relativeTimeTextFormatter) })
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun FollowingCreatorResponse.toUiItem() = HomeFollowingCreatorUiItem(
|
||||||
|
creatorId = creatorId,
|
||||||
|
creatorNickname = creatorNickname,
|
||||||
|
creatorProfileImageUrl = creatorProfileImageUrl
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun FollowingLiveResponse.toUiItem() = HomeFollowingLiveUiItem(
|
||||||
|
liveId = liveId,
|
||||||
|
creatorProfileImageUrl = creatorProfileImageUrl,
|
||||||
|
creatorNickname = creatorNickname,
|
||||||
|
title = title,
|
||||||
|
startedAtUtc = startedAtUtc
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun FollowingScheduleResponse.toUiItem() = HomeFollowingScheduleUiItem(
|
||||||
|
scheduleId = scheduleId,
|
||||||
|
creatorId = creatorId,
|
||||||
|
creatorProfileImageUrl = creatorProfileImageUrl,
|
||||||
|
creatorNickname = creatorNickname,
|
||||||
|
title = title,
|
||||||
|
type = type,
|
||||||
|
typeLabelResId = type.labelResId,
|
||||||
|
targetId = targetId,
|
||||||
|
scheduledAtUtc = scheduledAtUtc,
|
||||||
|
isOnAir = isOnAir
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun FollowingNewsResponse.toUiItem(
|
||||||
|
relativeTimeTextFormatter: UtcRelativeTimeTextFormatter
|
||||||
|
): HomeFollowingNewsUiItem? {
|
||||||
|
val visibleFromText = relativeTimeTextFormatter.format(visibleFromAtUtc)
|
||||||
|
return when (type) {
|
||||||
|
FollowingNewsType.CREATOR_RANKING,
|
||||||
|
FollowingNewsType.CONTENT_RANKING -> rank?.let {
|
||||||
|
HomeFollowingNewsUiItem.Ranking(
|
||||||
|
newsId = newsId,
|
||||||
|
type = type,
|
||||||
|
creatorProfileImageUrl = creatorProfileImageUrl,
|
||||||
|
creatorNickname = creatorNickname,
|
||||||
|
title = title,
|
||||||
|
body = body,
|
||||||
|
thumbnailImageUrl = thumbnailImageUrl,
|
||||||
|
targetId = targetId,
|
||||||
|
occurredAtUtc = occurredAtUtc,
|
||||||
|
visibleFromAtUtc = visibleFromAtUtc,
|
||||||
|
visibleFromText = visibleFromText,
|
||||||
|
rank = it
|
||||||
|
)
|
||||||
|
}
|
||||||
|
FollowingNewsType.COMMUNITY_POST,
|
||||||
|
FollowingNewsType.AUDIO_CONTENT,
|
||||||
|
FollowingNewsType.PHOTO_CONTENT -> HomeFollowingNewsUiItem.Content(
|
||||||
|
newsId = newsId,
|
||||||
|
type = type,
|
||||||
|
creatorProfileImageUrl = creatorProfileImageUrl,
|
||||||
|
creatorNickname = creatorNickname,
|
||||||
|
title = title,
|
||||||
|
body = body,
|
||||||
|
thumbnailImageUrl = thumbnailImageUrl,
|
||||||
|
targetId = targetId,
|
||||||
|
occurredAtUtc = occurredAtUtc,
|
||||||
|
visibleFromAtUtc = visibleFromAtUtc,
|
||||||
|
visibleFromText = visibleFromText,
|
||||||
|
labelResId = type.toLabelResId()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@StringRes
|
||||||
|
private fun FollowingNewsType.toLabelResId(): Int = when (this) {
|
||||||
|
FollowingNewsType.PHOTO_CONTENT -> R.string.screen_home_following_photo_content
|
||||||
|
FollowingNewsType.AUDIO_CONTENT -> R.string.home_recommendation_activity_audio
|
||||||
|
FollowingNewsType.COMMUNITY_POST -> R.string.home_recommendation_activity_community
|
||||||
|
FollowingNewsType.CREATOR_RANKING,
|
||||||
|
FollowingNewsType.CONTENT_RANKING -> R.string.weekly_chart
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.main.home.model
|
||||||
|
|
||||||
|
import androidx.annotation.StringRes
|
||||||
|
import kr.co.vividnext.sodalive.v2.common.CreatorActivityType
|
||||||
|
import kr.co.vividnext.sodalive.v2.main.chat.model.ChatRoomListUiItem
|
||||||
|
import kr.co.vividnext.sodalive.v2.main.home.data.FollowingNewsType
|
||||||
|
|
||||||
|
data class HomeFollowingCreatorSection(
|
||||||
|
val items: List<HomeFollowingCreatorUiItem>
|
||||||
|
)
|
||||||
|
|
||||||
|
data class HomeFollowingLiveSection(
|
||||||
|
val items: List<HomeFollowingLiveUiItem>
|
||||||
|
)
|
||||||
|
|
||||||
|
data class HomeFollowingChatSection(
|
||||||
|
val items: List<ChatRoomListUiItem>
|
||||||
|
)
|
||||||
|
|
||||||
|
data class HomeFollowingScheduleSection(
|
||||||
|
val items: List<HomeFollowingScheduleUiItem>
|
||||||
|
)
|
||||||
|
|
||||||
|
data class HomeFollowingNewsSection(
|
||||||
|
val items: List<HomeFollowingNewsUiItem>
|
||||||
|
)
|
||||||
|
|
||||||
|
data class HomeFollowingCreatorUiItem(
|
||||||
|
val creatorId: Long,
|
||||||
|
val creatorNickname: String,
|
||||||
|
val creatorProfileImageUrl: String
|
||||||
|
)
|
||||||
|
|
||||||
|
data class HomeFollowingLiveUiItem(
|
||||||
|
val liveId: Long,
|
||||||
|
val creatorProfileImageUrl: String,
|
||||||
|
val creatorNickname: String,
|
||||||
|
val title: String,
|
||||||
|
val startedAtUtc: String
|
||||||
|
)
|
||||||
|
|
||||||
|
data class HomeFollowingScheduleUiItem(
|
||||||
|
val scheduleId: String,
|
||||||
|
val creatorId: Long,
|
||||||
|
val creatorProfileImageUrl: String,
|
||||||
|
val creatorNickname: String,
|
||||||
|
val title: String,
|
||||||
|
val type: CreatorActivityType,
|
||||||
|
@param:StringRes val typeLabelResId: Int,
|
||||||
|
val targetId: Long,
|
||||||
|
val scheduledAtUtc: String,
|
||||||
|
val isOnAir: Boolean
|
||||||
|
)
|
||||||
|
|
||||||
|
sealed interface HomeFollowingNewsUiItem {
|
||||||
|
val newsId: String
|
||||||
|
val type: FollowingNewsType
|
||||||
|
val creatorProfileImageUrl: String
|
||||||
|
val creatorNickname: String
|
||||||
|
val title: String
|
||||||
|
val body: String
|
||||||
|
val thumbnailImageUrl: String?
|
||||||
|
val targetId: Long
|
||||||
|
val occurredAtUtc: String
|
||||||
|
val visibleFromAtUtc: String
|
||||||
|
val visibleFromText: String
|
||||||
|
|
||||||
|
data class Ranking(
|
||||||
|
override val newsId: String,
|
||||||
|
override val type: FollowingNewsType,
|
||||||
|
override val creatorProfileImageUrl: String,
|
||||||
|
override val creatorNickname: String,
|
||||||
|
override val title: String,
|
||||||
|
override val body: String,
|
||||||
|
override val thumbnailImageUrl: String?,
|
||||||
|
override val targetId: Long,
|
||||||
|
override val occurredAtUtc: String,
|
||||||
|
override val visibleFromAtUtc: String,
|
||||||
|
override val visibleFromText: String,
|
||||||
|
val rank: Int
|
||||||
|
) : HomeFollowingNewsUiItem
|
||||||
|
|
||||||
|
data class Content(
|
||||||
|
override val newsId: String,
|
||||||
|
override val type: FollowingNewsType,
|
||||||
|
override val creatorProfileImageUrl: String,
|
||||||
|
override val creatorNickname: String,
|
||||||
|
override val title: String,
|
||||||
|
override val body: String,
|
||||||
|
override val thumbnailImageUrl: String?,
|
||||||
|
override val targetId: Long,
|
||||||
|
override val occurredAtUtc: String,
|
||||||
|
override val visibleFromAtUtc: String,
|
||||||
|
override val visibleFromText: String,
|
||||||
|
@param:StringRes val labelResId: Int
|
||||||
|
) : HomeFollowingNewsUiItem
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.main.home.model
|
||||||
|
|
||||||
|
sealed interface HomeFollowingUiState {
|
||||||
|
data object Loading : HomeFollowingUiState
|
||||||
|
|
||||||
|
data object LoginRequired : HomeFollowingUiState
|
||||||
|
|
||||||
|
data class Content(
|
||||||
|
val followingCreators: HomeFollowingCreatorSection,
|
||||||
|
val onAirLives: HomeFollowingLiveSection,
|
||||||
|
val recentChats: HomeFollowingChatSection,
|
||||||
|
val monthlySchedules: HomeFollowingScheduleSection,
|
||||||
|
val recentNews: HomeFollowingNewsSection
|
||||||
|
) : HomeFollowingUiState {
|
||||||
|
val isEmpty: Boolean
|
||||||
|
get() = followingCreators.items.isEmpty() &&
|
||||||
|
onAirLives.items.isEmpty() &&
|
||||||
|
recentChats.items.isEmpty() &&
|
||||||
|
monthlySchedules.items.isEmpty() &&
|
||||||
|
recentNews.items.isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
data object Empty : HomeFollowingUiState
|
||||||
|
|
||||||
|
data class Error(val message: String?) : HomeFollowingUiState
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.main.home
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.R
|
||||||
|
import kr.co.vividnext.sodalive.common.UtcRelativeTimeTextFormatter
|
||||||
|
import kr.co.vividnext.sodalive.v2.common.CreatorActivityType
|
||||||
|
import kr.co.vividnext.sodalive.v2.main.chat.data.ChatRoomListItemResponse
|
||||||
|
import kr.co.vividnext.sodalive.v2.main.home.data.FollowingCreatorResponse
|
||||||
|
import kr.co.vividnext.sodalive.v2.main.home.data.FollowingLiveResponse
|
||||||
|
import kr.co.vividnext.sodalive.v2.main.home.data.FollowingNewsResponse
|
||||||
|
import kr.co.vividnext.sodalive.v2.main.home.data.FollowingNewsType
|
||||||
|
import kr.co.vividnext.sodalive.v2.main.home.data.FollowingScheduleResponse
|
||||||
|
import kr.co.vividnext.sodalive.v2.main.home.data.HomeFollowingTabResponse
|
||||||
|
import kr.co.vividnext.sodalive.v2.main.home.model.HomeFollowingNewsUiItem
|
||||||
|
import kr.co.vividnext.sodalive.v2.main.home.model.HomeFollowingUiState
|
||||||
|
import kr.co.vividnext.sodalive.v2.main.home.model.toUiState
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class HomeFollowingMapperTest {
|
||||||
|
|
||||||
|
private val formatter = UtcRelativeTimeTextFormatter { "relative:$it" }
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `isLoginRequired true 응답은 LoginRequired로 매핑된다`() {
|
||||||
|
val state = response(isLoginRequired = true, followingCreators = listOf(creator())).toUiState(formatter)
|
||||||
|
|
||||||
|
assertTrue(state is HomeFollowingUiState.LoginRequired)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `로그인이 필요 없고 모든 섹션이 비면 Empty로 매핑된다`() {
|
||||||
|
val state = response().toUiState(formatter)
|
||||||
|
|
||||||
|
assertTrue(state is HomeFollowingUiState.Empty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `응답 섹션은 팔로잉 UI model로 매핑된다`() {
|
||||||
|
val state = response(
|
||||||
|
followingCreators = listOf(creator()),
|
||||||
|
onAirLives = listOf(live()),
|
||||||
|
recentChats = listOf(chat()),
|
||||||
|
monthlySchedules = listOf(schedule(scheduleId = "schedule-1")),
|
||||||
|
recentNews = listOf(news(newsId = "news-1", type = FollowingNewsType.PHOTO_CONTENT))
|
||||||
|
).toUiState(formatter) as HomeFollowingUiState.Content
|
||||||
|
|
||||||
|
assertEquals(1L, state.followingCreators.items.single().creatorId)
|
||||||
|
assertEquals(2L, state.onAirLives.items.single().liveId)
|
||||||
|
assertEquals(3L, state.recentChats.items.single().roomId)
|
||||||
|
assertEquals("schedule-1", state.monthlySchedules.items.single().scheduleId)
|
||||||
|
assertEquals("news-1", state.recentNews.items.single().newsId)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `recentChats는 toUiItem 결과가 null인 항목을 제외한다`() {
|
||||||
|
val state = response(
|
||||||
|
recentChats = listOf(chat(roomId = 1L, chatType = "UNKNOWN"), chat(roomId = 2L, chatType = "DM"))
|
||||||
|
).toUiState(formatter) as HomeFollowingUiState.Content
|
||||||
|
|
||||||
|
assertEquals(listOf(2L), state.recentChats.items.map { it.roomId })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `monthlySchedules는 서버 응답 순서를 유지한다`() {
|
||||||
|
val state = response(
|
||||||
|
monthlySchedules = listOf(schedule(scheduleId = "second"), schedule(scheduleId = "first"))
|
||||||
|
).toUiState(formatter) as HomeFollowingUiState.Content
|
||||||
|
|
||||||
|
assertEquals(listOf("second", "first"), state.monthlySchedules.items.map { it.scheduleId })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `PHOTO_CONTENT는 photo content label로 매핑된다`() {
|
||||||
|
val state = response(
|
||||||
|
recentNews = listOf(news(type = FollowingNewsType.PHOTO_CONTENT))
|
||||||
|
).toUiState(formatter) as HomeFollowingUiState.Content
|
||||||
|
val item = state.recentNews.items.single() as HomeFollowingNewsUiItem.Content
|
||||||
|
|
||||||
|
assertEquals(R.string.screen_home_following_photo_content, item.labelResId)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `ranking news의 rank null 항목은 제외된다`() {
|
||||||
|
val state = response(
|
||||||
|
recentNews = listOf(
|
||||||
|
news(newsId = "hidden", type = FollowingNewsType.CREATOR_RANKING, rank = null),
|
||||||
|
news(newsId = "shown", type = FollowingNewsType.CONTENT_RANKING, rank = 1)
|
||||||
|
)
|
||||||
|
).toUiState(formatter) as HomeFollowingUiState.Content
|
||||||
|
|
||||||
|
assertEquals(listOf("shown"), state.recentNews.items.map { it.newsId })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `news 상대 시간은 visibleFromAtUtc 값을 formatter에 전달한다`() {
|
||||||
|
val state = response(
|
||||||
|
recentNews = listOf(news(visibleFromAtUtc = "2026-06-25T00:00:00Z"))
|
||||||
|
).toUiState(formatter) as HomeFollowingUiState.Content
|
||||||
|
|
||||||
|
assertEquals("relative:2026-06-25T00:00:00Z", state.recentNews.items.single().visibleFromText)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun response(
|
||||||
|
isLoginRequired: Boolean = false,
|
||||||
|
followingCreators: List<FollowingCreatorResponse> = emptyList(),
|
||||||
|
onAirLives: List<FollowingLiveResponse> = emptyList(),
|
||||||
|
recentChats: List<ChatRoomListItemResponse> = emptyList(),
|
||||||
|
monthlySchedules: List<FollowingScheduleResponse> = emptyList(),
|
||||||
|
recentNews: List<FollowingNewsResponse> = emptyList()
|
||||||
|
) = HomeFollowingTabResponse(
|
||||||
|
isLoginRequired = isLoginRequired,
|
||||||
|
followingCreators = followingCreators,
|
||||||
|
onAirLives = onAirLives,
|
||||||
|
recentChats = recentChats,
|
||||||
|
monthlySchedules = monthlySchedules,
|
||||||
|
recentNews = recentNews
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun creator() = FollowingCreatorResponse(
|
||||||
|
creatorId = 1L,
|
||||||
|
creatorNickname = "creator",
|
||||||
|
creatorProfileImageUrl = "https://example.com/creator.png"
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun live() = FollowingLiveResponse(
|
||||||
|
liveId = 2L,
|
||||||
|
creatorProfileImageUrl = "https://example.com/live.png",
|
||||||
|
creatorNickname = "creator",
|
||||||
|
title = "live",
|
||||||
|
startedAtUtc = "2026-06-25T00:00:00Z"
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun chat(roomId: Long = 3L, chatType: String = "AI") = ChatRoomListItemResponse(
|
||||||
|
roomId = roomId,
|
||||||
|
chatType = chatType,
|
||||||
|
targetName = "target",
|
||||||
|
targetImageUrl = "https://example.com/chat.png",
|
||||||
|
lastMessage = "message",
|
||||||
|
lastMessageAt = "2026-06-25T00:00:00Z"
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun schedule(scheduleId: String) = FollowingScheduleResponse(
|
||||||
|
scheduleId = scheduleId,
|
||||||
|
creatorId = 4L,
|
||||||
|
creatorProfileImageUrl = "https://example.com/schedule.png",
|
||||||
|
creatorNickname = "creator",
|
||||||
|
title = "schedule",
|
||||||
|
type = CreatorActivityType.Live,
|
||||||
|
targetId = 5L,
|
||||||
|
scheduledAtUtc = "2026-06-25T00:00:00Z",
|
||||||
|
isOnAir = false
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun news(
|
||||||
|
newsId: String = "news",
|
||||||
|
type: FollowingNewsType = FollowingNewsType.AUDIO_CONTENT,
|
||||||
|
visibleFromAtUtc: String = "2026-06-25T01:00:00Z",
|
||||||
|
rank: Int? = null
|
||||||
|
) = FollowingNewsResponse(
|
||||||
|
newsId = newsId,
|
||||||
|
type = type,
|
||||||
|
creatorProfileImageUrl = "https://example.com/news.png",
|
||||||
|
creatorNickname = "creator",
|
||||||
|
title = "title",
|
||||||
|
body = "body",
|
||||||
|
thumbnailImageUrl = null,
|
||||||
|
targetId = 6L,
|
||||||
|
occurredAtUtc = "2026-06-25T00:00:00Z",
|
||||||
|
visibleFromAtUtc = visibleFromAtUtc,
|
||||||
|
rank = rank
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user