feat(creator): 커뮤니티 게시글 UI 모델을 추가한다
This commit is contained in:
@@ -64,6 +64,17 @@ fun formatUtcRelativeTimeText(context: Context, utcText: String?): String {
|
|||||||
return context.getString(R.string.character_comment_time_years, years)
|
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? {
|
private fun parseServerUtcToMillis(utcText: String?): Long? {
|
||||||
if (utcText.isNullOrBlank()) return null
|
if (utcText.isNullOrBlank()) return null
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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<CreatorChannelCommunityPostResponse>.toCommunityPostUiModels(
|
||||||
|
relativeTimeTextFormatter: UtcRelativeTimeTextFormatter,
|
||||||
|
isOwner: Boolean,
|
||||||
|
currentUserId: Long
|
||||||
|
): List<CreatorChannelCommunityPostUiModel> = 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)
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user