From f9501c156a806ac157f32fd58b1cb9b1e3347841 Mon Sep 17 00:00:00 2001 From: klaus Date: Sun, 21 Jun 2026 22:32:23 +0900 Subject: [PATCH] =?UTF-8?q?feat(creator):=20=EC=BB=A4=EB=AE=A4=EB=8B=88?= =?UTF-8?q?=ED=8B=B0=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20UI=20=EB=AA=A8?= =?UTF-8?q?=EB=8D=B8=EC=9D=84=20=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 --- .../sodalive/common/RelativeTimeFormatter.kt | 11 ++ .../CreatorChannelCommunityViewMode.kt | 4 + .../model/CreatorChannelCommunityMappers.kt | 56 ++++++ .../model/CreatorChannelCommunityUiModels.kt | 48 +++++ .../CreatorChannelCommunityMapperTest.kt | 166 ++++++++++++++++++ 5 files changed, 285 insertions(+) create mode 100644 app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/community/CreatorChannelCommunityViewMode.kt create mode 100644 app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/community/model/CreatorChannelCommunityMappers.kt create mode 100644 app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/community/model/CreatorChannelCommunityUiModels.kt create mode 100644 app/src/test/java/kr/co/vividnext/sodalive/v2/creator/channel/community/CreatorChannelCommunityMapperTest.kt diff --git a/app/src/main/java/kr/co/vividnext/sodalive/common/RelativeTimeFormatter.kt b/app/src/main/java/kr/co/vividnext/sodalive/common/RelativeTimeFormatter.kt index fcb88a93..dd407939 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/common/RelativeTimeFormatter.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/common/RelativeTimeFormatter.kt @@ -64,6 +64,17 @@ fun formatUtcRelativeTimeText(context: Context, utcText: String?): String { return context.getString(R.string.character_comment_time_years, years) } +fun interface UtcRelativeTimeTextFormatter { + fun format(utcText: String?): String +} + +class AndroidUtcRelativeTimeTextFormatter(context: Context) : UtcRelativeTimeTextFormatter { + + private val applicationContext = context.applicationContext + + override fun format(utcText: String?): String = formatUtcRelativeTimeText(applicationContext, utcText) +} + private fun parseServerUtcToMillis(utcText: String?): Long? { if (utcText.isNullOrBlank()) return null diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/community/CreatorChannelCommunityViewMode.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/community/CreatorChannelCommunityViewMode.kt new file mode 100644 index 00000000..017aba0a --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/community/CreatorChannelCommunityViewMode.kt @@ -0,0 +1,4 @@ +package kr.co.vividnext.sodalive.v2.creator.channel.community + +typealias CreatorChannelCommunityViewMode = + kr.co.vividnext.sodalive.v2.creator.channel.community.model.CreatorChannelCommunityViewMode diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/community/model/CreatorChannelCommunityMappers.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/community/model/CreatorChannelCommunityMappers.kt new file mode 100644 index 00000000..2761ce05 --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/community/model/CreatorChannelCommunityMappers.kt @@ -0,0 +1,56 @@ +package kr.co.vividnext.sodalive.v2.creator.channel.community.model + +import kr.co.vividnext.sodalive.common.UtcRelativeTimeTextFormatter +import kr.co.vividnext.sodalive.v2.creator.channel.community.data.CreatorChannelCommunityPostResponse + +private const val GRID_PREVIEW_MAX_LENGTH = 24 + +fun List.toCommunityPostUiModels( + relativeTimeTextFormatter: UtcRelativeTimeTextFormatter, + isOwner: Boolean, + currentUserId: Long +): List = map { + it.toCommunityPostUiModel(relativeTimeTextFormatter, isOwner, currentUserId) +} + +private fun CreatorChannelCommunityPostResponse.toCommunityPostUiModel( + relativeTimeTextFormatter: UtcRelativeTimeTextFormatter, + isOwner: Boolean, + currentUserId: Long +): CreatorChannelCommunityPostUiModel { + val isLocked = price > 0 && !existOrdered && !isOwner + val showOwnerActions = isOwner && creatorId == currentUserId + val showPlayButton = !isLocked && !audioUrl.isNullOrBlank() && !imageUrl.isNullOrBlank() + return CreatorChannelCommunityPostUiModel( + postId = postId, + creatorId = creatorId, + creatorNickname = creatorNickname, + creatorProfileUrl = creatorProfileUrl, + createdAtText = relativeTimeTextFormatter.format(createdAtUtc), + content = content, + imageUrl = imageUrl, + audioUrl = audioUrl, + price = price, + existOrdered = existOrdered, + likeCount = likeCount, + commentCount = commentCount, + showComment = isCommentAvailable, + showNotice = isPinned, + isLocked = isLocked, + showOwnerMore = showOwnerActions, + showOwnerTopPrice = showOwnerActions && price > 0, + showPlayButton = showPlayButton, + gridPreviewText = content.toGridPreviewText(), + imageMode = toImageMode(isLocked) + ) +} + +private fun CreatorChannelCommunityPostResponse.toImageMode(isLocked: Boolean): CreatorChannelCommunityImageMode = when { + isLocked -> CreatorChannelCommunityImageMode.LockedGray + imageUrl.isNullOrBlank() -> CreatorChannelCommunityImageMode.TextPreview + else -> CreatorChannelCommunityImageMode.Image +} + +private fun String.toGridPreviewText(): String = replace("\n", " ") + .trim() + .take(GRID_PREVIEW_MAX_LENGTH) diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/community/model/CreatorChannelCommunityUiModels.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/community/model/CreatorChannelCommunityUiModels.kt new file mode 100644 index 00000000..709dc188 --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/community/model/CreatorChannelCommunityUiModels.kt @@ -0,0 +1,48 @@ +package kr.co.vividnext.sodalive.v2.creator.channel.community.model + +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import kr.co.vividnext.sodalive.R + +enum class CreatorChannelCommunityViewMode( + @StringRes val labelResId: Int, + @DrawableRes val iconResId: Int +) { + List( + labelResId = R.string.creator_channel_community_view_mode_list, + iconResId = R.drawable.ic_new_list + ), + Grid( + labelResId = R.string.creator_channel_community_view_mode_grid, + iconResId = R.drawable.ic_new_grid + ) +} + +enum class CreatorChannelCommunityImageMode { + Image, + TextPreview, + LockedGray +} + +data class CreatorChannelCommunityPostUiModel( + val postId: Long, + val creatorId: Long, + val creatorNickname: String, + val creatorProfileUrl: String, + val createdAtText: String, + val content: String, + val imageUrl: String?, + val audioUrl: String?, + val price: Int, + val existOrdered: Boolean, + val likeCount: Int, + val commentCount: Int, + val showComment: Boolean, + val showNotice: Boolean, + val isLocked: Boolean, + val showOwnerMore: Boolean, + val showOwnerTopPrice: Boolean, + val showPlayButton: Boolean, + val gridPreviewText: String, + val imageMode: CreatorChannelCommunityImageMode +) diff --git a/app/src/test/java/kr/co/vividnext/sodalive/v2/creator/channel/community/CreatorChannelCommunityMapperTest.kt b/app/src/test/java/kr/co/vividnext/sodalive/v2/creator/channel/community/CreatorChannelCommunityMapperTest.kt new file mode 100644 index 00000000..e40beaac --- /dev/null +++ b/app/src/test/java/kr/co/vividnext/sodalive/v2/creator/channel/community/CreatorChannelCommunityMapperTest.kt @@ -0,0 +1,166 @@ +package kr.co.vividnext.sodalive.v2.creator.channel.community + +import android.app.Application +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import kr.co.vividnext.sodalive.R +import kr.co.vividnext.sodalive.common.AndroidUtcRelativeTimeTextFormatter +import kr.co.vividnext.sodalive.v2.creator.channel.community.data.CreatorChannelCommunityPostResponse +import kr.co.vividnext.sodalive.v2.creator.channel.community.model.CreatorChannelCommunityImageMode +import kr.co.vividnext.sodalive.v2.creator.channel.community.model.CreatorChannelCommunityViewMode +import kr.co.vividnext.sodalive.v2.creator.channel.community.model.toCommunityPostUiModels +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], application = Application::class) +class CreatorChannelCommunityMapperTest { + + private val context: Context = ApplicationProvider.getApplicationContext() + private val relativeTimeTextFormatter = AndroidUtcRelativeTimeTextFormatter(context) + + @Test + fun `보기 방식은 label과 icon resource를 가진다`() { + assertEquals(R.string.creator_channel_community_view_mode_list, CreatorChannelCommunityViewMode.List.labelResId) + assertEquals(R.drawable.ic_new_list, CreatorChannelCommunityViewMode.List.iconResId) + assertEquals(R.string.creator_channel_community_view_mode_grid, CreatorChannelCommunityViewMode.Grid.labelResId) + assertEquals(R.drawable.ic_new_grid, CreatorChannelCommunityViewMode.Grid.iconResId) + } + + @Test + fun `게시글 기본 필드와 상대 시간 notice 댓글 표시 상태를 매핑한다`() { + val item = listOf( + communityPost( + creatorProfileUrl = "profile.png", + createdAtUtc = System.currentTimeMillis().toString(), + isPinned = true, + isCommentAvailable = true + ) + ).toCommunityPostUiModels(relativeTimeTextFormatter, isOwner = false, currentUserId = 99L).single() + + assertEquals(1L, item.postId) + assertEquals(10L, item.creatorId) + assertEquals("creator", item.creatorNickname) + assertEquals("profile.png", item.creatorProfileUrl) + assertEquals(context.getString(R.string.character_comment_time_just_now), item.createdAtText) + assertEquals("hello community", item.content) + assertEquals(3, item.likeCount) + assertEquals(4, item.commentCount) + assertTrue(item.showNotice) + assertTrue(item.showComment) + } + + @Test + fun `댓글 불가 게시글은 댓글 표시 상태가 false다`() { + val item = listOf(communityPost(isCommentAvailable = false)) + .toCommunityPostUiModels(relativeTimeTextFormatter, isOwner = false, currentUserId = 99L) + .single() + + assertFalse(item.showComment) + } + + @Test + fun `유료 미구매 타인 게시글은 잠금 상태이고 play button을 숨긴다`() { + val item = listOf( + communityPost(price = 100, existOrdered = false, imageUrl = "image.png", audioUrl = "audio.mp3") + ).toCommunityPostUiModels(relativeTimeTextFormatter, isOwner = false, currentUserId = 99L).single() + + assertTrue(item.isLocked) + assertEquals(CreatorChannelCommunityImageMode.LockedGray, item.imageMode) + assertFalse(item.showPlayButton) + } + + @Test + fun `본인 또는 구매한 사용자는 이미지와 오디오가 있으면 play button을 표시한다`() { + val ownerItem = listOf( + communityPost(price = 100, existOrdered = false, imageUrl = "image.png", audioUrl = "audio.mp3") + ).toCommunityPostUiModels(relativeTimeTextFormatter, isOwner = true, currentUserId = 10L).single() + val orderedItem = listOf( + communityPost(price = 100, existOrdered = true, imageUrl = "image.png", audioUrl = "audio.mp3") + ).toCommunityPostUiModels(relativeTimeTextFormatter, isOwner = false, currentUserId = 99L).single() + + assertFalse(ownerItem.isLocked) + assertTrue(ownerItem.showPlayButton) + assertFalse(orderedItem.isLocked) + assertTrue(orderedItem.showPlayButton) + } + + @Test + fun `오디오 또는 이미지가 없으면 play button을 숨긴다`() { + val items = listOf( + communityPost(postId = 1L, imageUrl = null, audioUrl = "audio.mp3"), + communityPost(postId = 2L, imageUrl = "image.png", audioUrl = null), + communityPost(postId = 3L, imageUrl = "image.png", audioUrl = " ") + ).toCommunityPostUiModels(relativeTimeTextFormatter, isOwner = false, currentUserId = 99L) + + assertEquals(listOf(false, false, false), items.map { it.showPlayButton }) + } + + @Test + fun `본인 채널에 본인이 쓴 게시글에서만 owner more와 유료 top price를 표시한다`() { + val ownerPaid = listOf(communityPost(price = 100, creatorId = 10L)) + .toCommunityPostUiModels(relativeTimeTextFormatter, isOwner = true, currentUserId = 10L) + .single() + val ownerFree = listOf(communityPost(price = 0, creatorId = 10L)) + .toCommunityPostUiModels(relativeTimeTextFormatter, isOwner = true, currentUserId = 10L) + .single() + val otherCreator = listOf(communityPost(price = 100, creatorId = 11L)) + .toCommunityPostUiModels(relativeTimeTextFormatter, isOwner = true, currentUserId = 10L) + .single() + val otherChannel = listOf(communityPost(price = 100, creatorId = 10L)) + .toCommunityPostUiModels(relativeTimeTextFormatter, isOwner = false, currentUserId = 10L) + .single() + + assertTrue(ownerPaid.showOwnerMore) + assertTrue(ownerPaid.showOwnerTopPrice) + assertTrue(ownerFree.showOwnerMore) + assertFalse(ownerFree.showOwnerTopPrice) + assertFalse(otherCreator.showOwnerMore) + assertFalse(otherCreator.showOwnerTopPrice) + assertFalse(otherChannel.showOwnerMore) + assertFalse(otherChannel.showOwnerTopPrice) + } + + @Test + fun `grid text-only preview는 줄바꿈을 공백으로 바꾸고 trim 후 24자까지만 사용한다`() { + val item = listOf(communityPost(content = "\n123456789012345678901234567890\n")) + .toCommunityPostUiModels(relativeTimeTextFormatter, isOwner = false, currentUserId = 99L) + .single() + + assertEquals("123456789012345678901234", item.gridPreviewText) + } + + private fun communityPost( + postId: Long = 1L, + creatorId: Long = 10L, + creatorProfileUrl: String = "profile.png", + createdAtUtc: String = "2026-06-21T00:00:00Z", + content: String = "hello community", + imageUrl: String? = null, + audioUrl: String? = null, + price: Int = 0, + existOrdered: Boolean = true, + isCommentAvailable: Boolean = true, + isPinned: Boolean = false + ) = CreatorChannelCommunityPostResponse( + postId = postId, + creatorId = creatorId, + creatorNickname = "creator", + creatorProfileUrl = creatorProfileUrl, + createdAtUtc = createdAtUtc, + content = content, + imageUrl = imageUrl, + audioUrl = audioUrl, + price = price, + existOrdered = existOrdered, + isCommentAvailable = isCommentAvailable, + likeCount = 3, + commentCount = 4, + isPinned = isPinned + ) +}