From 502bbd4f35e8d1c793e8cadbfbfe625abb612668 Mon Sep 17 00:00:00 2001 From: klaus Date: Thu, 25 Jun 2026 22:22:22 +0900 Subject: [PATCH] =?UTF-8?q?feat(home):=20=ED=8C=94=EB=A1=9C=EC=9E=89=20?= =?UTF-8?q?=EC=9D=91=EB=8B=B5=20UI=20=EB=A7=A4=ED=95=91=EC=9D=84=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/home/model/HomeFollowingMappers.kt | 108 +++++++++++ .../main/home/model/HomeFollowingUiModels.kt | 97 ++++++++++ .../main/home/model/HomeFollowingUiState.kt | 26 +++ .../v2/main/home/HomeFollowingMapperTest.kt | 173 ++++++++++++++++++ 4 files changed, 404 insertions(+) create mode 100644 app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/model/HomeFollowingMappers.kt create mode 100644 app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/model/HomeFollowingUiModels.kt create mode 100644 app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/model/HomeFollowingUiState.kt create mode 100644 app/src/test/java/kr/co/vividnext/sodalive/v2/main/home/HomeFollowingMapperTest.kt diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/model/HomeFollowingMappers.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/model/HomeFollowingMappers.kt new file mode 100644 index 00000000..05f2dc18 --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/model/HomeFollowingMappers.kt @@ -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 +} diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/model/HomeFollowingUiModels.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/model/HomeFollowingUiModels.kt new file mode 100644 index 00000000..8170b420 --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/model/HomeFollowingUiModels.kt @@ -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 +) + +data class HomeFollowingLiveSection( + val items: List +) + +data class HomeFollowingChatSection( + val items: List +) + +data class HomeFollowingScheduleSection( + val items: List +) + +data class HomeFollowingNewsSection( + val items: List +) + +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 +} diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/model/HomeFollowingUiState.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/model/HomeFollowingUiState.kt new file mode 100644 index 00000000..33c7e943 --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/model/HomeFollowingUiState.kt @@ -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 +} diff --git a/app/src/test/java/kr/co/vividnext/sodalive/v2/main/home/HomeFollowingMapperTest.kt b/app/src/test/java/kr/co/vividnext/sodalive/v2/main/home/HomeFollowingMapperTest.kt new file mode 100644 index 00000000..4f748079 --- /dev/null +++ b/app/src/test/java/kr/co/vividnext/sodalive/v2/main/home/HomeFollowingMapperTest.kt @@ -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 = emptyList(), + onAirLives: List = emptyList(), + recentChats: List = emptyList(), + monthlySchedules: List = emptyList(), + recentNews: List = 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 + ) +}