feat(creator): 채널 표시와 상대 시간을 정리한다

This commit is contained in:
2026-08-26 16:30:03 +09:00
parent 9939910c71
commit 1f88d721d7
48 changed files with 679 additions and 233 deletions
@@ -463,13 +463,13 @@ class AppDI(private val context: Context, isDebugMode: Boolean) {
viewModel { CreatorChannelLiveViewModel(get()) } viewModel { CreatorChannelLiveViewModel(get()) }
viewModel { CreatorChannelAudioViewModel(get()) } viewModel { CreatorChannelAudioViewModel(get()) }
viewModel { CreatorChannelSeriesViewModel(get()) } viewModel { CreatorChannelSeriesViewModel(get()) }
viewModel { CreatorChannelCommunityViewModel(get(), get()) } viewModel { CreatorChannelCommunityViewModel(get()) }
viewModel { CreatorChannelCommunityDetailViewModel(get(), get(), get()) } viewModel { CreatorChannelCommunityDetailViewModel(get(), get()) }
viewModel { CreatorChannelCommunityReplyViewModel(get(), get(), get()) } viewModel { CreatorChannelCommunityReplyViewModel(get(), get()) }
viewModel { CreatorChannelFanTalkViewModel(get(), get()) } viewModel { CreatorChannelFanTalkViewModel(get()) }
viewModel { CreatorChannelFanTalkDetailViewModel(get(), get()) } viewModel { CreatorChannelFanTalkDetailViewModel(get()) }
viewModel { CreatorChannelFanTalkWriteViewModel(get()) } viewModel { CreatorChannelFanTalkWriteViewModel(get()) }
viewModel { CreatorChannelDonationViewModel(get(), get()) } viewModel { CreatorChannelDonationViewModel(get()) }
viewModel { PushNotificationListViewModel(get()) } viewModel { PushNotificationListViewModel(get()) }
viewModel { CharacterTabViewModel(get()) } viewModel { CharacterTabViewModel(get()) }
viewModel { CharacterDetailViewModel(get()) } viewModel { CharacterDetailViewModel(get()) }
@@ -7,15 +7,13 @@ import io.reactivex.rxjava3.schedulers.Schedulers
import kr.co.vividnext.sodalive.base.BaseViewModel import kr.co.vividnext.sodalive.base.BaseViewModel
import kr.co.vividnext.sodalive.common.ApiResponse import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.common.SharedPreferenceManager import kr.co.vividnext.sodalive.common.SharedPreferenceManager
import kr.co.vividnext.sodalive.common.UtcRelativeTimeTextFormatter
import kr.co.vividnext.sodalive.v2.creator.channel.community.data.CreatorChannelCommunityTabResponse import kr.co.vividnext.sodalive.v2.creator.channel.community.data.CreatorChannelCommunityTabResponse
import kr.co.vividnext.sodalive.v2.creator.channel.community.model.CreatorChannelCommunityPostUiModel import kr.co.vividnext.sodalive.v2.creator.channel.community.model.CreatorChannelCommunityPostUiModel
import kr.co.vividnext.sodalive.v2.creator.channel.community.model.toCommunityPostUiModels import kr.co.vividnext.sodalive.v2.creator.channel.community.model.toCommunityPostUiModels
import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelRepository import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelRepository
class CreatorChannelCommunityViewModel( class CreatorChannelCommunityViewModel(
private val repository: CreatorChannelRepository, private val repository: CreatorChannelRepository
private val relativeTimeTextFormatter: UtcRelativeTimeTextFormatter
) : BaseViewModel() { ) : BaseViewModel() {
private val _communityStateLiveData = MutableLiveData<CreatorChannelCommunityUiState>() private val _communityStateLiveData = MutableLiveData<CreatorChannelCommunityUiState>()
@@ -158,7 +156,6 @@ class CreatorChannelCommunityViewModel(
private fun CreatorChannelCommunityTabResponse.toCommunityPostUiModels(): List<CreatorChannelCommunityPostUiModel> = private fun CreatorChannelCommunityTabResponse.toCommunityPostUiModels(): List<CreatorChannelCommunityPostUiModel> =
communityPosts.toCommunityPostUiModels( communityPosts.toCommunityPostUiModels(
relativeTimeTextFormatter = relativeTimeTextFormatter,
isOwner = isOwner, isOwner = isOwner,
currentUserId = SharedPreferenceManager.userId currentUserId = SharedPreferenceManager.userId
) )
@@ -24,6 +24,7 @@ import com.bumptech.glide.request.target.Target
import kr.co.vividnext.sodalive.R import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.base.BaseActivity import kr.co.vividnext.sodalive.base.BaseActivity
import kr.co.vividnext.sodalive.common.Constants import kr.co.vividnext.sodalive.common.Constants
import kr.co.vividnext.sodalive.common.formatUtcRelativeTimeText
import kr.co.vividnext.sodalive.databinding.ActivityCreatorChannelCommunityDetailBinding import kr.co.vividnext.sodalive.databinding.ActivityCreatorChannelCommunityDetailBinding
import kr.co.vividnext.sodalive.explorer.profile.creator_community.all.CreatorCommunityPostMenuBottomSheetDialog import kr.co.vividnext.sodalive.explorer.profile.creator_community.all.CreatorCommunityPostMenuBottomSheetDialog
import kr.co.vividnext.sodalive.explorer.profile.creator_community.all.CreatorCommunityReportDialog import kr.co.vividnext.sodalive.explorer.profile.creator_community.all.CreatorCommunityReportDialog
@@ -171,7 +172,8 @@ class CreatorChannelCommunityDetailActivity : BaseActivity<ActivityCreatorChanne
transformations(CircleCropTransformation()) transformations(CircleCropTransformation())
} }
tvCreatorChannelCommunityDetailNickname.text = post.creatorNickname tvCreatorChannelCommunityDetailNickname.text = post.creatorNickname
tvCreatorChannelCommunityDetailTime.text = post.createdAtText tvCreatorChannelCommunityDetailTime.text =
formatUtcRelativeTimeText(tvCreatorChannelCommunityDetailTime.context, post.createdAtUtc)
btnCreatorChannelCommunityDetailMore.isVisible = post.showMore btnCreatorChannelCommunityDetailMore.isVisible = post.showMore
tvCreatorChannelCommunityDetailBody.text = post.content tvCreatorChannelCommunityDetailBody.text = post.content
layoutCreatorChannelCommunityDetailReaction.isVisible = post.showReaction || content.isCommentAvailable layoutCreatorChannelCommunityDetailReaction.isVisible = post.showReaction || content.isCommentAvailable
@@ -11,7 +11,6 @@ import kr.co.vividnext.sodalive.base.BaseViewModel
import kr.co.vividnext.sodalive.common.ApiResponse import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.common.SharedPreferenceManager import kr.co.vividnext.sodalive.common.SharedPreferenceManager
import kr.co.vividnext.sodalive.common.ToastMessage import kr.co.vividnext.sodalive.common.ToastMessage
import kr.co.vividnext.sodalive.common.UtcRelativeTimeTextFormatter
import kr.co.vividnext.sodalive.explorer.profile.creator_community.CreatorCommunityRepository import kr.co.vividnext.sodalive.explorer.profile.creator_community.CreatorCommunityRepository
import kr.co.vividnext.sodalive.explorer.profile.creator_community.modify.ModifyCommunityPostRequest import kr.co.vividnext.sodalive.explorer.profile.creator_community.modify.ModifyCommunityPostRequest
import kr.co.vividnext.sodalive.v2.creator.channel.CreatorChannelEvent import kr.co.vividnext.sodalive.v2.creator.channel.CreatorChannelEvent
@@ -25,8 +24,7 @@ import okhttp3.RequestBody.Companion.toRequestBody
class CreatorChannelCommunityDetailViewModel( class CreatorChannelCommunityDetailViewModel(
private val repository: CreatorChannelRepository, private val repository: CreatorChannelRepository,
private val legacyRepository: CreatorCommunityRepository, private val legacyRepository: CreatorCommunityRepository
private val relativeTimeTextFormatter: UtcRelativeTimeTextFormatter
) : BaseViewModel() { ) : BaseViewModel() {
private val _detailStateLiveData = MutableLiveData<CreatorChannelCommunityDetailUiState>() private val _detailStateLiveData = MutableLiveData<CreatorChannelCommunityDetailUiState>()
@@ -536,7 +534,7 @@ class CreatorChannelCommunityDetailViewModel(
creatorId = creatorId, creatorId = creatorId,
creatorNickname = creatorNickname, creatorNickname = creatorNickname,
creatorProfileUrl = creatorProfileUrl, creatorProfileUrl = creatorProfileUrl,
createdAtText = relativeTimeTextFormatter.format(createdAtUtc), createdAtUtc = createdAtUtc,
content = content, content = content,
imageUrl = imageUrl.takeUnless { isLocked() }, imageUrl = imageUrl.takeUnless { isLocked() },
audioUrl = audioUrl.takeUnless { isLocked() }, audioUrl = audioUrl.takeUnless { isLocked() },
@@ -566,7 +564,6 @@ class CreatorChannelCommunityDetailViewModel(
memberNickname = writerNickname, memberNickname = writerNickname,
memberProfileUrl = writerProfileImageUrl, memberProfileUrl = writerProfileImageUrl,
createdAtUtc = createdAtUtc, createdAtUtc = createdAtUtc,
createdAtText = relativeTimeTextFormatter.format(createdAtUtc),
comment = content, comment = content,
likeCount = 0, likeCount = 0,
replyCount = if (latestReply != null) 1 else 0, replyCount = if (latestReply != null) 1 else 0,
@@ -586,7 +583,6 @@ class CreatorChannelCommunityDetailViewModel(
memberNickname = writerNickname, memberNickname = writerNickname,
memberProfileUrl = writerProfileImageUrl, memberProfileUrl = writerProfileImageUrl,
createdAtUtc = createdAtUtc, createdAtUtc = createdAtUtc,
createdAtText = relativeTimeTextFormatter.format(createdAtUtc),
comment = content, comment = content,
likeCount = 0, likeCount = 0,
isLiked = false, isLiked = false,
@@ -625,7 +621,7 @@ data class CreatorChannelCommunityPostDetailUiModel(
val creatorId: Long, val creatorId: Long,
val creatorNickname: String, val creatorNickname: String,
val creatorProfileUrl: String?, val creatorProfileUrl: String?,
val createdAtText: String, val createdAtUtc: String,
val content: String, val content: String,
val imageUrl: String?, val imageUrl: String?,
val audioUrl: String?, val audioUrl: String?,
@@ -648,7 +644,6 @@ data class CreatorChannelCommunityCommentUiModel(
val memberNickname: String, val memberNickname: String,
val memberProfileUrl: String?, val memberProfileUrl: String?,
val createdAtUtc: String, val createdAtUtc: String,
val createdAtText: String,
val comment: String, val comment: String,
val likeCount: Int, val likeCount: Int,
val replyCount: Int, val replyCount: Int,
@@ -665,7 +660,6 @@ data class CreatorChannelCommunityReplyUiModel(
val memberNickname: String, val memberNickname: String,
val memberProfileUrl: String?, val memberProfileUrl: String?,
val createdAtUtc: String, val createdAtUtc: String,
val createdAtText: String,
val comment: String, val comment: String,
val likeCount: Int, val likeCount: Int,
val isLiked: Boolean, val isLiked: Boolean,
@@ -12,6 +12,7 @@ import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import kr.co.vividnext.sodalive.R import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.base.BaseActivity import kr.co.vividnext.sodalive.base.BaseActivity
import kr.co.vividnext.sodalive.common.formatUtcRelativeTimeText
import kr.co.vividnext.sodalive.databinding.ActivityCreatorChannelCommunityReplyBinding import kr.co.vividnext.sodalive.databinding.ActivityCreatorChannelCommunityReplyBinding
import kr.co.vividnext.sodalive.v2.components.modal.V2ModalDialog import kr.co.vividnext.sodalive.v2.components.modal.V2ModalDialog
import kr.co.vividnext.sodalive.v2.creator.channel.community.detail.CreatorChannelCommunityCommentUiModel import kr.co.vividnext.sodalive.v2.creator.channel.community.detail.CreatorChannelCommunityCommentUiModel
@@ -110,6 +111,11 @@ class CreatorChannelCommunityReplyActivity : BaseActivity<ActivityCreatorChannel
onModifyClick = { comment -> viewModel.startParentCommentEdit(comment.comment) }, onModifyClick = { comment -> viewModel.startParentCommentEdit(comment.comment) },
onDeleteClick = { showDeleteParentCommentDialog() } onDeleteClick = { showDeleteParentCommentDialog() }
) )
layoutCreatorChannelCommunityReplyParent.tvCreatorChannelCommunityCommentTime.text =
formatUtcRelativeTimeText(
layoutCreatorChannelCommunityReplyParent.tvCreatorChannelCommunityCommentTime.context,
content.parentComment.createdAtUtc
)
layoutCreatorChannelCommunityReplyParent.layoutCreatorChannelCommunityCommentLatestReply.isVisible = false layoutCreatorChannelCommunityReplyParent.layoutCreatorChannelCommunityCommentLatestReply.isVisible = false
replyAdapter.submitItems(content.replies) replyAdapter.submitItems(content.replies)
if (etCreatorChannelCommunityReply.text.toString() != content.replyInput) { if (etCreatorChannelCommunityReply.text.toString() != content.replyInput) {
@@ -183,7 +189,6 @@ class CreatorChannelCommunityReplyActivity : BaseActivity<ActivityCreatorChannel
memberNickname = intent.getStringExtra(EXTRA_MEMBER_NICKNAME).orEmpty(), memberNickname = intent.getStringExtra(EXTRA_MEMBER_NICKNAME).orEmpty(),
memberProfileUrl = intent.getStringExtra(EXTRA_MEMBER_PROFILE_URL), memberProfileUrl = intent.getStringExtra(EXTRA_MEMBER_PROFILE_URL),
createdAtUtc = intent.getStringExtra(EXTRA_CREATED_AT_UTC).orEmpty(), createdAtUtc = intent.getStringExtra(EXTRA_CREATED_AT_UTC).orEmpty(),
createdAtText = intent.getStringExtra(EXTRA_CREATED_AT_TEXT).orEmpty(),
comment = intent.getStringExtra(EXTRA_COMMENT).orEmpty(), comment = intent.getStringExtra(EXTRA_COMMENT).orEmpty(),
likeCount = intent.getIntExtra(EXTRA_LIKE_COUNT, 0), likeCount = intent.getIntExtra(EXTRA_LIKE_COUNT, 0),
replyCount = intent.getIntExtra(EXTRA_REPLY_COUNT, 0), replyCount = intent.getIntExtra(EXTRA_REPLY_COUNT, 0),
@@ -201,7 +206,6 @@ class CreatorChannelCommunityReplyActivity : BaseActivity<ActivityCreatorChannel
private const val EXTRA_MEMBER_NICKNAME = "extra_member_nickname" private const val EXTRA_MEMBER_NICKNAME = "extra_member_nickname"
private const val EXTRA_MEMBER_PROFILE_URL = "extra_member_profile_url" private const val EXTRA_MEMBER_PROFILE_URL = "extra_member_profile_url"
private const val EXTRA_CREATED_AT_UTC = "extra_created_at_utc" private const val EXTRA_CREATED_AT_UTC = "extra_created_at_utc"
private const val EXTRA_CREATED_AT_TEXT = "extra_created_at_text"
private const val EXTRA_COMMENT = "extra_comment" private const val EXTRA_COMMENT = "extra_comment"
private const val EXTRA_LIKE_COUNT = "extra_like_count" private const val EXTRA_LIKE_COUNT = "extra_like_count"
private const val EXTRA_REPLY_COUNT = "extra_reply_count" private const val EXTRA_REPLY_COUNT = "extra_reply_count"
@@ -217,7 +221,6 @@ class CreatorChannelCommunityReplyActivity : BaseActivity<ActivityCreatorChannel
.putExtra(EXTRA_MEMBER_NICKNAME, parentComment.memberNickname) .putExtra(EXTRA_MEMBER_NICKNAME, parentComment.memberNickname)
.putExtra(EXTRA_MEMBER_PROFILE_URL, parentComment.memberProfileUrl) .putExtra(EXTRA_MEMBER_PROFILE_URL, parentComment.memberProfileUrl)
.putExtra(EXTRA_CREATED_AT_UTC, parentComment.createdAtUtc) .putExtra(EXTRA_CREATED_AT_UTC, parentComment.createdAtUtc)
.putExtra(EXTRA_CREATED_AT_TEXT, parentComment.createdAtText)
.putExtra(EXTRA_COMMENT, parentComment.comment) .putExtra(EXTRA_COMMENT, parentComment.comment)
.putExtra(EXTRA_LIKE_COUNT, parentComment.likeCount) .putExtra(EXTRA_LIKE_COUNT, parentComment.likeCount)
.putExtra(EXTRA_REPLY_COUNT, parentComment.replyCount) .putExtra(EXTRA_REPLY_COUNT, parentComment.replyCount)
@@ -10,7 +10,6 @@ import kr.co.vividnext.sodalive.base.BaseViewModel
import kr.co.vividnext.sodalive.common.ApiResponse import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.common.SharedPreferenceManager import kr.co.vividnext.sodalive.common.SharedPreferenceManager
import kr.co.vividnext.sodalive.common.ToastMessage import kr.co.vividnext.sodalive.common.ToastMessage
import kr.co.vividnext.sodalive.common.UtcRelativeTimeTextFormatter
import kr.co.vividnext.sodalive.explorer.profile.creator_community.CreatorCommunityRepository import kr.co.vividnext.sodalive.explorer.profile.creator_community.CreatorCommunityRepository
import kr.co.vividnext.sodalive.v2.creator.channel.CreatorChannelEvent import kr.co.vividnext.sodalive.v2.creator.channel.CreatorChannelEvent
import kr.co.vividnext.sodalive.v2.creator.channel.community.detail.CreatorChannelCommunityCommentUiModel import kr.co.vividnext.sodalive.v2.creator.channel.community.detail.CreatorChannelCommunityCommentUiModel
@@ -21,8 +20,7 @@ import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelRepository
class CreatorChannelCommunityReplyViewModel( class CreatorChannelCommunityReplyViewModel(
private val repository: CreatorChannelRepository, private val repository: CreatorChannelRepository,
private val legacyRepository: CreatorCommunityRepository, private val legacyRepository: CreatorCommunityRepository
private val relativeTimeTextFormatter: UtcRelativeTimeTextFormatter
) : BaseViewModel() { ) : BaseViewModel() {
private val _replyStateLiveData = MutableLiveData<CreatorChannelCommunityReplyUiState>() private val _replyStateLiveData = MutableLiveData<CreatorChannelCommunityReplyUiState>()
@@ -57,20 +55,17 @@ class CreatorChannelCommunityReplyViewModel(
private var isModifyingReply = false private var isModifyingReply = false
fun loadReplies(postId: Long, parentComment: CreatorChannelCommunityCommentUiModel) { fun loadReplies(postId: Long, parentComment: CreatorChannelCommunityCommentUiModel) {
val formattedParentComment = parentComment.copy(
createdAtText = relativeTimeTextFormatter.format(parentComment.createdAtUtc)
)
this.postId = postId this.postId = postId
this.parentComment = formattedParentComment this.parentComment = parentComment
replyRequestGeneration++ replyRequestGeneration++
isLoadingReplies = false isLoadingReplies = false
_replyStateLiveData.value = CreatorChannelCommunityReplyUiState.Content( _replyStateLiveData.value = CreatorChannelCommunityReplyUiState.Content(
parentComment = formattedParentComment, parentComment = parentComment,
replies = emptyList(), replies = emptyList(),
replyPage = FIRST_PAGE, replyPage = FIRST_PAGE,
hasNextReply = false hasNextReply = false
) )
if (formattedParentComment.commentId > 0) { if (parentComment.commentId > 0) {
loadReplyPage(page = FIRST_PAGE, append = false) loadReplyPage(page = FIRST_PAGE, append = false)
} }
} }
@@ -486,7 +481,6 @@ class CreatorChannelCommunityReplyViewModel(
memberNickname = writerNickname, memberNickname = writerNickname,
memberProfileUrl = writerProfileImageUrl, memberProfileUrl = writerProfileImageUrl,
createdAtUtc = createdAtUtc, createdAtUtc = createdAtUtc,
createdAtText = relativeTimeTextFormatter.format(createdAtUtc),
comment = content, comment = content,
likeCount = 0, likeCount = 0,
isLiked = false, isLiked = false,
@@ -7,6 +7,7 @@ import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import coil.transform.CircleCropTransformation import coil.transform.CircleCropTransformation
import kr.co.vividnext.sodalive.R import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.common.formatUtcRelativeTimeText
import kr.co.vividnext.sodalive.databinding.ItemCreatorChannelCommunityReplyBinding import kr.co.vividnext.sodalive.databinding.ItemCreatorChannelCommunityReplyBinding
import kr.co.vividnext.sodalive.extensions.loadUrl import kr.co.vividnext.sodalive.extensions.loadUrl
import kr.co.vividnext.sodalive.v2.creator.channel.community.detail.CreatorChannelCommunityReplyUiModel import kr.co.vividnext.sodalive.v2.creator.channel.community.detail.CreatorChannelCommunityReplyUiModel
@@ -51,7 +52,8 @@ class CreatorChannelCommunityReplyAdapter(
transformations(CircleCropTransformation()) transformations(CircleCropTransformation())
} }
tvCreatorChannelCommunityReplyItemNickname.text = item.memberNickname tvCreatorChannelCommunityReplyItemNickname.text = item.memberNickname
tvCreatorChannelCommunityReplyItemTime.text = item.createdAtText tvCreatorChannelCommunityReplyItemTime.text =
formatUtcRelativeTimeText(tvCreatorChannelCommunityReplyItemTime.context, item.createdAtUtc)
tvCreatorChannelCommunityReplyItemBody.text = item.comment tvCreatorChannelCommunityReplyItemBody.text = item.comment
ivCreatorChannelCommunityReplyItemMore.isVisible = item.canShowMore ivCreatorChannelCommunityReplyItemMore.isVisible = item.canShowMore
ivCreatorChannelCommunityReplyItemMore.setOnClickListener { anchor -> ivCreatorChannelCommunityReplyItemMore.setOnClickListener { anchor ->
@@ -7,6 +7,7 @@ import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import coil.transform.CircleCropTransformation import coil.transform.CircleCropTransformation
import kr.co.vividnext.sodalive.R import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.common.formatUtcRelativeTimeText
import kr.co.vividnext.sodalive.databinding.ItemCreatorChannelCommunityCommentBinding import kr.co.vividnext.sodalive.databinding.ItemCreatorChannelCommunityCommentBinding
import kr.co.vividnext.sodalive.extensions.loadUrl import kr.co.vividnext.sodalive.extensions.loadUrl
import kr.co.vividnext.sodalive.v2.creator.channel.community.detail.CreatorChannelCommunityCommentUiModel import kr.co.vividnext.sodalive.v2.creator.channel.community.detail.CreatorChannelCommunityCommentUiModel
@@ -70,7 +71,8 @@ class CreatorChannelCommunityCommentAdapter(
transformations(CircleCropTransformation()) transformations(CircleCropTransformation())
} }
tvCreatorChannelCommunityCommentNickname.text = item.memberNickname tvCreatorChannelCommunityCommentNickname.text = item.memberNickname
tvCreatorChannelCommunityCommentTime.text = item.createdAtText tvCreatorChannelCommunityCommentTime.text =
formatUtcRelativeTimeText(tvCreatorChannelCommunityCommentTime.context, item.createdAtUtc)
tvCreatorChannelCommunityCommentBody.text = item.comment tvCreatorChannelCommunityCommentBody.text = item.comment
ivCreatorChannelCommunityCommentMore.isVisible = item.canShowMore && onDeleteClick != null ivCreatorChannelCommunityCommentMore.isVisible = item.canShowMore && onDeleteClick != null
ivCreatorChannelCommunityCommentMore.setOnClickListener { anchor -> ivCreatorChannelCommunityCommentMore.setOnClickListener { anchor ->
@@ -1,20 +1,17 @@
package kr.co.vividnext.sodalive.v2.creator.channel.community.model 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 import kr.co.vividnext.sodalive.v2.creator.channel.community.data.CreatorChannelCommunityPostResponse
private const val GRID_PREVIEW_MAX_LENGTH = 24 private const val GRID_PREVIEW_MAX_LENGTH = 24
fun List<CreatorChannelCommunityPostResponse>.toCommunityPostUiModels( fun List<CreatorChannelCommunityPostResponse>.toCommunityPostUiModels(
relativeTimeTextFormatter: UtcRelativeTimeTextFormatter,
isOwner: Boolean, isOwner: Boolean,
currentUserId: Long currentUserId: Long
): List<CreatorChannelCommunityPostUiModel> = map { ): List<CreatorChannelCommunityPostUiModel> = map {
it.toCommunityPostUiModel(relativeTimeTextFormatter, isOwner, currentUserId) it.toCommunityPostUiModel(isOwner, currentUserId)
} }
private fun CreatorChannelCommunityPostResponse.toCommunityPostUiModel( private fun CreatorChannelCommunityPostResponse.toCommunityPostUiModel(
relativeTimeTextFormatter: UtcRelativeTimeTextFormatter,
isOwner: Boolean, isOwner: Boolean,
currentUserId: Long currentUserId: Long
): CreatorChannelCommunityPostUiModel { ): CreatorChannelCommunityPostUiModel {
@@ -26,7 +23,7 @@ private fun CreatorChannelCommunityPostResponse.toCommunityPostUiModel(
creatorId = creatorId, creatorId = creatorId,
creatorNickname = creatorNickname, creatorNickname = creatorNickname,
creatorProfileUrl = creatorProfileUrl, creatorProfileUrl = creatorProfileUrl,
createdAtText = relativeTimeTextFormatter.format(createdAtUtc), createdAtUtc = createdAtUtc,
content = content, content = content,
imageUrl = visibleImageUrl, imageUrl = visibleImageUrl,
price = price, price = price,
@@ -29,7 +29,7 @@ data class CreatorChannelCommunityPostUiModel(
val creatorId: Long, val creatorId: Long,
val creatorNickname: String, val creatorNickname: String,
val creatorProfileUrl: String, val creatorProfileUrl: String,
val createdAtText: String, val createdAtUtc: String,
val content: String, val content: String,
val imageUrl: String?, val imageUrl: String?,
val price: Int, val price: Int,
@@ -15,6 +15,7 @@ import com.bumptech.glide.load.engine.GlideException
import com.bumptech.glide.request.RequestListener import com.bumptech.glide.request.RequestListener
import com.bumptech.glide.request.target.Target import com.bumptech.glide.request.target.Target
import kr.co.vividnext.sodalive.R import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.common.formatUtcRelativeTimeText
import kr.co.vividnext.sodalive.databinding.ItemCreatorChannelCommunityListBinding import kr.co.vividnext.sodalive.databinding.ItemCreatorChannelCommunityListBinding
import kr.co.vividnext.sodalive.extensions.dpToPx import kr.co.vividnext.sodalive.extensions.dpToPx
import kr.co.vividnext.sodalive.extensions.loadUrl import kr.co.vividnext.sodalive.extensions.loadUrl
@@ -68,7 +69,8 @@ class CreatorChannelCommunityListAdapter(
transformations(CircleCropTransformation()) transformations(CircleCropTransformation())
} }
tvCreatorChannelCommunityListNickname.text = item.creatorNickname tvCreatorChannelCommunityListNickname.text = item.creatorNickname
tvCreatorChannelCommunityListTime.text = item.createdAtText tvCreatorChannelCommunityListTime.text =
formatUtcRelativeTimeText(tvCreatorChannelCommunityListTime.context, item.createdAtUtc)
layoutCreatorChannelCommunityListNotice.isVisible = item.showNotice layoutCreatorChannelCommunityListNotice.isVisible = item.showNotice
tvCreatorChannelCommunityListBody.text = item.content tvCreatorChannelCommunityListBody.text = item.content
layoutCreatorChannelCommunityListReaction.isVisible = item.showReaction || item.showComment layoutCreatorChannelCommunityListReaction.isVisible = item.showReaction || item.showComment
@@ -194,7 +194,7 @@ private data class CreatorChannelDonationItemLayoutKey(
val nickname: String, val nickname: String,
val can: Int, val can: Int,
val message: String, val message: String,
val createdAtText: String val createdAtUtc: String
) )
private fun CreatorChannelDonationUiState.Content.toContentLayoutKey(): CreatorChannelDonationContentLayoutKey { private fun CreatorChannelDonationUiState.Content.toContentLayoutKey(): CreatorChannelDonationContentLayoutKey {
@@ -206,7 +206,7 @@ private fun CreatorChannelDonationUiState.Content.toContentLayoutKey(): CreatorC
nickname = donation.nickname, nickname = donation.nickname,
can = donation.can, can = donation.can,
message = donation.message, message = donation.message,
createdAtText = donation.createdAtText createdAtUtc = donation.createdAtUtc
) )
} }
) )
@@ -7,8 +7,6 @@ import io.reactivex.rxjava3.schedulers.Schedulers
import kr.co.vividnext.sodalive.base.BaseViewModel import kr.co.vividnext.sodalive.base.BaseViewModel
import kr.co.vividnext.sodalive.common.ApiResponse import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.common.SharedPreferenceManager import kr.co.vividnext.sodalive.common.SharedPreferenceManager
import kr.co.vividnext.sodalive.common.SodaLiveApplicationHolder
import kr.co.vividnext.sodalive.common.UtcRelativeTimeTextFormatter
import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelRepository import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelRepository
import kr.co.vividnext.sodalive.v2.creator.channel.donation.data.CreatorChannelDonationTabResponse import kr.co.vividnext.sodalive.v2.creator.channel.donation.data.CreatorChannelDonationTabResponse
import kr.co.vividnext.sodalive.v2.creator.channel.donation.model.CreatorChannelDonationRankingUiModel import kr.co.vividnext.sodalive.v2.creator.channel.donation.model.CreatorChannelDonationRankingUiModel
@@ -17,15 +15,13 @@ import kr.co.vividnext.sodalive.v2.creator.channel.donation.model.toDonationRank
import kr.co.vividnext.sodalive.v2.creator.channel.donation.model.toDonationUiModels import kr.co.vividnext.sodalive.v2.creator.channel.donation.model.toDonationUiModels
class CreatorChannelDonationViewModel( class CreatorChannelDonationViewModel(
private val repository: CreatorChannelRepository, private val repository: CreatorChannelRepository
private val relativeTimeTextFormatter: UtcRelativeTimeTextFormatter
) : BaseViewModel() { ) : BaseViewModel() {
private val _donationStateLiveData = MutableLiveData<CreatorChannelDonationUiState>() private val _donationStateLiveData = MutableLiveData<CreatorChannelDonationUiState>()
val donationStateLiveData: LiveData<CreatorChannelDonationUiState> val donationStateLiveData: LiveData<CreatorChannelDonationUiState>
get() = _donationStateLiveData get() = _donationStateLiveData
private val context = SodaLiveApplicationHolder.get()
private var creatorId: Long = 0L private var creatorId: Long = 0L
private var isOwner: Boolean = false private var isOwner: Boolean = false
private var requestGeneration: Int = 0 private var requestGeneration: Int = 0
@@ -222,7 +218,7 @@ class CreatorChannelDonationViewModel(
rankings.toDonationRankingUiModels() rankings.toDonationRankingUiModels()
private fun CreatorChannelDonationTabResponse.toDonationUiModels(): List<CreatorChannelDonationUiModel> = private fun CreatorChannelDonationTabResponse.toDonationUiModels(): List<CreatorChannelDonationUiModel> =
donations.toDonationUiModels(context, relativeTimeTextFormatter) donations.toDonationUiModels()
private fun authToken(): String = "Bearer ${SharedPreferenceManager.token}" private fun authToken(): String = "Bearer ${SharedPreferenceManager.token}"
@@ -1,19 +1,15 @@
package kr.co.vividnext.sodalive.v2.creator.channel.donation.model package kr.co.vividnext.sodalive.v2.creator.channel.donation.model
import android.content.Context
import androidx.annotation.ColorRes import androidx.annotation.ColorRes
import kr.co.vividnext.sodalive.R import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.common.UtcRelativeTimeTextFormatter
import kr.co.vividnext.sodalive.v2.creator.channel.donation.data.CreatorChannelDonationResponse import kr.co.vividnext.sodalive.v2.creator.channel.donation.data.CreatorChannelDonationResponse
import kr.co.vividnext.sodalive.v2.creator.channel.donation.data.MemberDonationRankingResponse import kr.co.vividnext.sodalive.v2.creator.channel.donation.data.MemberDonationRankingResponse
fun List<MemberDonationRankingResponse>.toDonationRankingUiModels(): List<CreatorChannelDonationRankingUiModel> = fun List<MemberDonationRankingResponse>.toDonationRankingUiModels(): List<CreatorChannelDonationRankingUiModel> =
mapIndexed { index, response -> response.toDonationRankingUiModel(rank = index + 1) } mapIndexed { index, response -> response.toDonationRankingUiModel(rank = index + 1) }
fun List<CreatorChannelDonationResponse>.toDonationUiModels( fun List<CreatorChannelDonationResponse>.toDonationUiModels(): List<CreatorChannelDonationUiModel> =
context: Context, map { it.toDonationUiModel() }
relativeTimeTextFormatter: UtcRelativeTimeTextFormatter
): List<CreatorChannelDonationUiModel> = map { it.toDonationUiModel(context, relativeTimeTextFormatter) }
private fun MemberDonationRankingResponse.toDonationRankingUiModel(rank: Int) = CreatorChannelDonationRankingUiModel( private fun MemberDonationRankingResponse.toDonationRankingUiModel(rank: Int) = CreatorChannelDonationRankingUiModel(
rank = rank, rank = rank,
@@ -23,15 +19,12 @@ private fun MemberDonationRankingResponse.toDonationRankingUiModel(rank: Int) =
donationCan = donationCan donationCan = donationCan
) )
private fun CreatorChannelDonationResponse.toDonationUiModel( private fun CreatorChannelDonationResponse.toDonationUiModel() = CreatorChannelDonationUiModel(
context: Context,
relativeTimeTextFormatter: UtcRelativeTimeTextFormatter
) = CreatorChannelDonationUiModel(
nickname = nickname, nickname = nickname,
profileImageUrl = profileImageUrl, profileImageUrl = profileImageUrl,
can = can, can = can,
message = message, message = message,
createdAtText = createdAtUtc, createdAtUtc = createdAtUtc,
headerColorResId = calculateDonationHeaderColorRes(can) headerColorResId = calculateDonationHeaderColorRes(can)
) )
@@ -15,6 +15,6 @@ data class CreatorChannelDonationUiModel(
val profileImageUrl: String, val profileImageUrl: String,
val can: Int, val can: Int,
val message: String, val message: String,
val createdAtText: String, val createdAtUtc: String,
@param:ColorRes val headerColorResId: Int @param:ColorRes val headerColorResId: Int
) )
@@ -128,7 +128,7 @@ class CreatorChannelDonationAdapter(
headerColorResId = item.headerColorResId, headerColorResId = item.headerColorResId,
profileImageUrl = item.profileImageUrl, profileImageUrl = item.profileImageUrl,
nickname = item.nickname, nickname = item.nickname,
createdAtText = formatUtcRelativeTimeText(context, item.createdAtText), createdAtText = formatUtcRelativeTimeText(context, item.createdAtUtc),
can = item.can, can = item.can,
message = item.message.takeUnless { it.isBlank() } message = item.message.takeUnless { it.isBlank() }
?: context.getString(R.string.creator_channel_donation_fallback_message, item.can) ?: context.getString(R.string.creator_channel_donation_fallback_message, item.can)
@@ -9,15 +9,13 @@ import kr.co.vividnext.sodalive.common.SodaLiveApplicationHolder
import kr.co.vividnext.sodalive.base.BaseViewModel import kr.co.vividnext.sodalive.base.BaseViewModel
import kr.co.vividnext.sodalive.common.ApiResponse import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.common.SharedPreferenceManager import kr.co.vividnext.sodalive.common.SharedPreferenceManager
import kr.co.vividnext.sodalive.common.UtcRelativeTimeTextFormatter
import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelRepository import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelRepository
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.data.CreatorChannelFanTalkTabResponse import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.data.CreatorChannelFanTalkTabResponse
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.model.CreatorChannelFanTalkUiModel import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.model.CreatorChannelFanTalkUiModel
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.model.toFanTalkUiModels import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.model.toFanTalkUiModels
class CreatorChannelFanTalkViewModel( class CreatorChannelFanTalkViewModel(
private val repository: CreatorChannelRepository, private val repository: CreatorChannelRepository
private val relativeTimeTextFormatter: UtcRelativeTimeTextFormatter
) : BaseViewModel() { ) : BaseViewModel() {
private val _fanTalkStateLiveData = MutableLiveData<CreatorChannelFanTalkUiState>() private val _fanTalkStateLiveData = MutableLiveData<CreatorChannelFanTalkUiState>()
@@ -198,7 +196,6 @@ class CreatorChannelFanTalkViewModel(
private fun CreatorChannelFanTalkTabResponse.toFanTalkUiModels(): List<CreatorChannelFanTalkUiModel> = private fun CreatorChannelFanTalkTabResponse.toFanTalkUiModels(): List<CreatorChannelFanTalkUiModel> =
fanTalks.toFanTalkUiModels( fanTalks.toFanTalkUiModels(
relativeTimeTextFormatter = relativeTimeTextFormatter,
isOwner = isOwner, isOwner = isOwner,
currentUserId = SharedPreferenceManager.userId currentUserId = SharedPreferenceManager.userId
) )
@@ -19,6 +19,7 @@ import androidx.core.widget.doAfterTextChanged
import coil.transform.CircleCropTransformation import coil.transform.CircleCropTransformation
import kr.co.vividnext.sodalive.R import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.base.BaseActivity import kr.co.vividnext.sodalive.base.BaseActivity
import kr.co.vividnext.sodalive.common.formatUtcRelativeTimeText
import kr.co.vividnext.sodalive.databinding.ActivityCreatorChannelFantalkDetailBinding import kr.co.vividnext.sodalive.databinding.ActivityCreatorChannelFantalkDetailBinding
import kr.co.vividnext.sodalive.databinding.ViewCreatorChannelFantalkMorePopupBinding import kr.co.vividnext.sodalive.databinding.ViewCreatorChannelFantalkMorePopupBinding
import kr.co.vividnext.sodalive.extensions.loadUrl import kr.co.vividnext.sodalive.extensions.loadUrl
@@ -107,7 +108,8 @@ class CreatorChannelFanTalkDetailActivity : BaseActivity<ActivityCreatorChannelF
private fun bindParentFanTalk(item: CreatorChannelFanTalkUiModel) = with(binding) { private fun bindParentFanTalk(item: CreatorChannelFanTalkUiModel) = with(binding) {
ivCreatorChannelFantalkDetailProfile.loadProfile(item.writerProfileImageUrl) ivCreatorChannelFantalkDetailProfile.loadProfile(item.writerProfileImageUrl)
tvCreatorChannelFantalkDetailNickname.text = item.writerNickname tvCreatorChannelFantalkDetailNickname.text = item.writerNickname
tvCreatorChannelFantalkDetailTime.text = item.createdAtText tvCreatorChannelFantalkDetailTime.text =
formatUtcRelativeTimeText(tvCreatorChannelFantalkDetailTime.context, item.createdAtUtc)
tvCreatorChannelFantalkDetailContent.text = item.content tvCreatorChannelFantalkDetailContent.text = item.content
ivCreatorChannelFantalkDetailMore.isVisible = false ivCreatorChannelFantalkDetailMore.isVisible = false
ivCreatorChannelFantalkDetailMore.setOnClickListener(null) ivCreatorChannelFantalkDetailMore.setOnClickListener(null)
@@ -123,7 +125,8 @@ class CreatorChannelFanTalkDetailActivity : BaseActivity<ActivityCreatorChannelF
ivCreatorChannelFantalkDetailReplyProfile.loadProfile(reply.writerProfileImageUrl) ivCreatorChannelFantalkDetailReplyProfile.loadProfile(reply.writerProfileImageUrl)
tvCreatorChannelFantalkDetailReplyNickname.text = reply.writerNickname tvCreatorChannelFantalkDetailReplyNickname.text = reply.writerNickname
tvCreatorChannelFantalkDetailReplyTime.text = reply.createdAtText tvCreatorChannelFantalkDetailReplyTime.text =
formatUtcRelativeTimeText(tvCreatorChannelFantalkDetailReplyTime.context, reply.createdAtUtc)
tvCreatorChannelFantalkDetailReplyContent.text = reply.content tvCreatorChannelFantalkDetailReplyContent.text = reply.content
ivCreatorChannelFantalkDetailReplyMore.isVisible = reply.fanTalkId > 0L ivCreatorChannelFantalkDetailReplyMore.isVisible = reply.fanTalkId > 0L
ivCreatorChannelFantalkDetailReplyMore.setOnClickListener( ivCreatorChannelFantalkDetailReplyMore.setOnClickListener(
@@ -15,7 +15,7 @@ data class CreatorChannelFanTalkDetailPayload(
val writerNickname: String, val writerNickname: String,
val writerProfileImageUrl: String, val writerProfileImageUrl: String,
val content: String, val content: String,
val createdAtText: String, val createdAtUtc: String,
val reply: CreatorChannelFanTalkDetailReplyPayload?, val reply: CreatorChannelFanTalkDetailReplyPayload?,
val showEdit: Boolean, val showEdit: Boolean,
val showDelete: Boolean val showDelete: Boolean
@@ -26,7 +26,7 @@ data class CreatorChannelFanTalkDetailPayload(
writerNickname = writerNickname, writerNickname = writerNickname,
writerProfileImageUrl = writerProfileImageUrl, writerProfileImageUrl = writerProfileImageUrl,
content = content, content = content,
createdAtText = createdAtText, createdAtUtc = createdAtUtc,
reply = reply?.toUiModel(), reply = reply?.toUiModel(),
rightAction = CreatorChannelFanTalkRightAction.OwnerMore(showEdit = showEdit, showDelete = showDelete) rightAction = CreatorChannelFanTalkRightAction.OwnerMore(showEdit = showEdit, showDelete = showDelete)
) )
@@ -39,7 +39,7 @@ data class CreatorChannelFanTalkDetailReplyPayload(
val writerNickname: String, val writerNickname: String,
val writerProfileImageUrl: String, val writerProfileImageUrl: String,
val content: String, val content: String,
val createdAtText: String val createdAtUtc: String
) : Parcelable { ) : Parcelable {
fun toUiModel(): CreatorChannelFanTalkReplyUiModel = CreatorChannelFanTalkReplyUiModel( fun toUiModel(): CreatorChannelFanTalkReplyUiModel = CreatorChannelFanTalkReplyUiModel(
fanTalkId = fanTalkId, fanTalkId = fanTalkId,
@@ -47,7 +47,7 @@ data class CreatorChannelFanTalkDetailReplyPayload(
writerNickname = writerNickname, writerNickname = writerNickname,
writerProfileImageUrl = writerProfileImageUrl, writerProfileImageUrl = writerProfileImageUrl,
content = content, content = content,
createdAtText = createdAtText createdAtUtc = createdAtUtc
) )
} }
@@ -59,7 +59,7 @@ fun CreatorChannelFanTalkUiModel.toFanTalkDetailPayload(): CreatorChannelFanTalk
writerNickname = writerNickname, writerNickname = writerNickname,
writerProfileImageUrl = writerProfileImageUrl, writerProfileImageUrl = writerProfileImageUrl,
content = content, content = content,
createdAtText = createdAtText, createdAtUtc = createdAtUtc,
reply = reply?.toFanTalkDetailReplyPayload(), reply = reply?.toFanTalkDetailReplyPayload(),
showEdit = ownerMore?.showEdit == true, showEdit = ownerMore?.showEdit == true,
showDelete = ownerMore?.showDelete == true showDelete = ownerMore?.showDelete == true
@@ -73,7 +73,7 @@ fun CreatorChannelFanTalkReplyUiModel.toFanTalkDetailReplyPayload(): CreatorChan
writerNickname = writerNickname, writerNickname = writerNickname,
writerProfileImageUrl = writerProfileImageUrl, writerProfileImageUrl = writerProfileImageUrl,
content = content, content = content,
createdAtText = createdAtText createdAtUtc = createdAtUtc
) )
} }
@@ -11,15 +11,13 @@ import kr.co.vividnext.sodalive.base.BaseViewModel
import kr.co.vividnext.sodalive.common.ApiResponse import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.common.SharedPreferenceManager import kr.co.vividnext.sodalive.common.SharedPreferenceManager
import kr.co.vividnext.sodalive.common.ToastMessage import kr.co.vividnext.sodalive.common.ToastMessage
import kr.co.vividnext.sodalive.common.UtcRelativeTimeTextFormatter
import kr.co.vividnext.sodalive.v2.creator.channel.CreatorChannelEvent import kr.co.vividnext.sodalive.v2.creator.channel.CreatorChannelEvent
import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelRepository import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelRepository
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.data.CreatorChannelFanTalkReplyResponse import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.data.CreatorChannelFanTalkReplyResponse
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.model.toFanTalkReplyUiModel import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.model.toFanTalkReplyUiModel
class CreatorChannelFanTalkDetailViewModel( class CreatorChannelFanTalkDetailViewModel(
private val repository: CreatorChannelRepository, private val repository: CreatorChannelRepository
private val relativeTimeTextFormatter: UtcRelativeTimeTextFormatter
) : BaseViewModel() { ) : BaseViewModel() {
private val _detailStateLiveData = MutableLiveData<CreatorChannelFanTalkDetailUiState>() private val _detailStateLiveData = MutableLiveData<CreatorChannelFanTalkDetailUiState>()
@@ -191,7 +189,7 @@ class CreatorChannelFanTalkDetailViewModel(
val current = currentContent() ?: return val current = currentContent() ?: return
if (response.success) { if (response.success) {
_detailStateLiveData.value = current.copy( _detailStateLiveData.value = current.copy(
reply = response.data?.toFanTalkReplyUiModel(relativeTimeTextFormatter) ?: current.reply, reply = response.data?.toFanTalkReplyUiModel() ?: current.reply,
replyInput = "", replyInput = "",
isSubmitting = false, isSubmitting = false,
editingTarget = null editingTarget = null
@@ -1,19 +1,16 @@
package kr.co.vividnext.sodalive.v2.creator.channel.fantalk.model package kr.co.vividnext.sodalive.v2.creator.channel.fantalk.model
import kr.co.vividnext.sodalive.common.UtcRelativeTimeTextFormatter
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.data.CreatorChannelFanTalkReplyResponse import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.data.CreatorChannelFanTalkReplyResponse
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.data.CreatorChannelFanTalkResponse import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.data.CreatorChannelFanTalkResponse
fun List<CreatorChannelFanTalkResponse>.toFanTalkUiModels( fun List<CreatorChannelFanTalkResponse>.toFanTalkUiModels(
relativeTimeTextFormatter: UtcRelativeTimeTextFormatter,
isOwner: Boolean, isOwner: Boolean,
currentUserId: Long currentUserId: Long
): List<CreatorChannelFanTalkUiModel> = map { ): List<CreatorChannelFanTalkUiModel> = map {
it.toFanTalkUiModel(relativeTimeTextFormatter, isOwner, currentUserId) it.toFanTalkUiModel(isOwner, currentUserId)
} }
private fun CreatorChannelFanTalkResponse.toFanTalkUiModel( private fun CreatorChannelFanTalkResponse.toFanTalkUiModel(
relativeTimeTextFormatter: UtcRelativeTimeTextFormatter,
isOwner: Boolean, isOwner: Boolean,
currentUserId: Long currentUserId: Long
) = CreatorChannelFanTalkUiModel( ) = CreatorChannelFanTalkUiModel(
@@ -22,31 +19,27 @@ private fun CreatorChannelFanTalkResponse.toFanTalkUiModel(
writerNickname = writerNickname, writerNickname = writerNickname,
writerProfileImageUrl = writerProfileImageUrl, writerProfileImageUrl = writerProfileImageUrl,
content = content, content = content,
createdAtText = relativeTimeTextFormatter.format(createdAtUtc), createdAtUtc = createdAtUtc,
reply = creatorReplies.orEmpty().firstOrNull()?.toFanTalkReplyUiModel(relativeTimeTextFormatter), reply = creatorReplies.orEmpty().firstOrNull()?.toFanTalkReplyUiModel(),
rightAction = toRightAction(isOwner = isOwner, currentUserId = currentUserId) rightAction = toRightAction(isOwner = isOwner, currentUserId = currentUserId)
) )
fun CreatorChannelFanTalkReplyResponse.toFanTalkReplyUiModel( fun CreatorChannelFanTalkReplyResponse.toFanTalkReplyUiModel() = CreatorChannelFanTalkReplyUiModel(
relativeTimeTextFormatter: UtcRelativeTimeTextFormatter
) = CreatorChannelFanTalkReplyUiModel(
fanTalkId = fanTalkId, fanTalkId = fanTalkId,
writerId = writerId, writerId = writerId,
writerNickname = writerNickname, writerNickname = writerNickname,
writerProfileImageUrl = writerProfileImageUrl, writerProfileImageUrl = writerProfileImageUrl,
content = content, content = content,
createdAtText = relativeTimeTextFormatter.format(createdAtUtc) createdAtUtc = createdAtUtc
) )
fun CreatorChannelFanTalkResponse.toFanTalkReplyUiModel( fun CreatorChannelFanTalkResponse.toFanTalkReplyUiModel() = CreatorChannelFanTalkReplyUiModel(
relativeTimeTextFormatter: UtcRelativeTimeTextFormatter
) = CreatorChannelFanTalkReplyUiModel(
fanTalkId = fanTalkId, fanTalkId = fanTalkId,
writerId = writerId, writerId = writerId,
writerNickname = writerNickname, writerNickname = writerNickname,
writerProfileImageUrl = writerProfileImageUrl, writerProfileImageUrl = writerProfileImageUrl,
content = content, content = content,
createdAtText = relativeTimeTextFormatter.format(createdAtUtc) createdAtUtc = createdAtUtc
) )
private fun CreatorChannelFanTalkResponse.toRightAction( private fun CreatorChannelFanTalkResponse.toRightAction(
@@ -6,7 +6,7 @@ data class CreatorChannelFanTalkUiModel(
val writerNickname: String, val writerNickname: String,
val writerProfileImageUrl: String, val writerProfileImageUrl: String,
val content: String, val content: String,
val createdAtText: String, val createdAtUtc: String,
val reply: CreatorChannelFanTalkReplyUiModel?, val reply: CreatorChannelFanTalkReplyUiModel?,
val rightAction: CreatorChannelFanTalkRightAction val rightAction: CreatorChannelFanTalkRightAction
) )
@@ -17,7 +17,7 @@ data class CreatorChannelFanTalkReplyUiModel(
val writerNickname: String, val writerNickname: String,
val writerProfileImageUrl: String, val writerProfileImageUrl: String,
val content: String, val content: String,
val createdAtText: String val createdAtUtc: String
) )
sealed interface CreatorChannelFanTalkRightAction { sealed interface CreatorChannelFanTalkRightAction {
@@ -7,6 +7,7 @@ import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import coil.transform.CircleCropTransformation import coil.transform.CircleCropTransformation
import kr.co.vividnext.sodalive.R import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.common.formatUtcRelativeTimeText
import kr.co.vividnext.sodalive.databinding.ItemCreatorChannelFantalkBinding import kr.co.vividnext.sodalive.databinding.ItemCreatorChannelFantalkBinding
import kr.co.vividnext.sodalive.extensions.loadUrl import kr.co.vividnext.sodalive.extensions.loadUrl
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.model.CreatorChannelFanTalkReplyUiModel import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.model.CreatorChannelFanTalkReplyUiModel
@@ -51,7 +52,8 @@ class CreatorChannelFanTalkAdapter(
fun bind(item: CreatorChannelFanTalkUiModel) = with(binding) { fun bind(item: CreatorChannelFanTalkUiModel) = with(binding) {
ivCreatorChannelFantalkProfile.loadProfile(item.writerProfileImageUrl) ivCreatorChannelFantalkProfile.loadProfile(item.writerProfileImageUrl)
tvCreatorChannelFantalkNickname.text = item.writerNickname tvCreatorChannelFantalkNickname.text = item.writerNickname
tvCreatorChannelFantalkTime.text = item.createdAtText tvCreatorChannelFantalkTime.text =
formatUtcRelativeTimeText(tvCreatorChannelFantalkTime.context, item.createdAtUtc)
tvCreatorChannelFantalkContent.text = item.content tvCreatorChannelFantalkContent.text = item.content
root.setOnClickListener { onItemClick(item) } root.setOnClickListener { onItemClick(item) }
tvCreatorChannelFantalkContent.setOnClickListener { onItemClick(item) } tvCreatorChannelFantalkContent.setOnClickListener { onItemClick(item) }
@@ -63,6 +65,7 @@ class CreatorChannelFanTalkAdapter(
when (item.rightAction) { when (item.rightAction) {
CreatorChannelFanTalkRightAction.Report -> { CreatorChannelFanTalkRightAction.Report -> {
tvCreatorChannelFantalkReport.isVisible = true tvCreatorChannelFantalkReport.isVisible = true
tvCreatorChannelFantalkReport.text = root.context.getString(R.string.creator_channel_fantalk_report)
tvCreatorChannelFantalkReport.setOnClickListener { onReportClick(item) } tvCreatorChannelFantalkReport.setOnClickListener { onReportClick(item) }
ivCreatorChannelFantalkMore.isVisible = false ivCreatorChannelFantalkMore.isVisible = false
ivCreatorChannelFantalkMore.setOnClickListener(null) ivCreatorChannelFantalkMore.setOnClickListener(null)
@@ -84,7 +87,8 @@ class CreatorChannelFanTalkAdapter(
ivCreatorChannelFantalkReplyProfile.loadProfile(reply.writerProfileImageUrl) ivCreatorChannelFantalkReplyProfile.loadProfile(reply.writerProfileImageUrl)
tvCreatorChannelFantalkReplyNickname.text = reply.writerNickname tvCreatorChannelFantalkReplyNickname.text = reply.writerNickname
tvCreatorChannelFantalkReplyTime.text = reply.createdAtText tvCreatorChannelFantalkReplyTime.text =
formatUtcRelativeTimeText(tvCreatorChannelFantalkReplyTime.context, reply.createdAtUtc)
tvCreatorChannelFantalkReplyContent.text = reply.content tvCreatorChannelFantalkReplyContent.text = reply.content
} }
@@ -33,7 +33,7 @@ internal fun bindCreatorChannelDonationCard(
} }
nicknameView.text = nickname nicknameView.text = nickname
createdAtView.text = createdAtText createdAtView.text = createdAtText
canView.text = context.getString(R.string.creator_channel_donation_can_format, can.moneyFormat()) canView.text = can.moneyFormat()
messageView.text = message messageView.text = message
if (headerColorResId == R.color.red_400) { if (headerColorResId == R.color.red_400) {
nicknameView.setTextColor(context.getColor(R.color.white)) nicknameView.setTextColor(context.getColor(R.color.white))
@@ -77,7 +77,7 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginStart="2dp" android:layout_marginStart="2dp"
android:textColor="@color/white" android:textColor="@color/white"
tools:text="20" /> tools:text="20" />
</LinearLayout> </LinearLayout>
</FrameLayout> </FrameLayout>
-1
View File
@@ -411,7 +411,6 @@
<string name="creator_channel_home_donation_empty_title">Be the first to\nsupport this creator!</string> <string name="creator_channel_home_donation_empty_title">Be the first to\nsupport this creator!</string>
<string name="creator_channel_donation_empty_title">No support yet.\nBe the first to support this creator!</string> <string name="creator_channel_donation_empty_title">No support yet.\nBe the first to support this creator!</string>
<string name="creator_channel_donation_empty_owner_title">No donation history yet.</string> <string name="creator_channel_donation_empty_owner_title">No donation history yet.</string>
<string name="creator_channel_donation_can_format">%1$s cans</string>
<string name="creator_channel_donation_fallback_message">Supported with %1$d cans.</string> <string name="creator_channel_donation_fallback_message">Supported with %1$d cans.</string>
<string name="creator_channel_owner_fab_open">Create content</string> <string name="creator_channel_owner_fab_open">Create content</string>
<string name="creator_channel_owner_fab_community">Post community</string> <string name="creator_channel_owner_fab_community">Post community</string>
-1
View File
@@ -411,7 +411,6 @@
<string name="creator_channel_home_donation_empty_title">最初のサポートをしてみましょう!</string> <string name="creator_channel_home_donation_empty_title">最初のサポートをしてみましょう!</string>
<string name="creator_channel_donation_empty_title">まだサポートがありません。\n最初のサポートをしてみましょう!</string> <string name="creator_channel_donation_empty_title">まだサポートがありません。\n最初のサポートをしてみましょう!</string>
<string name="creator_channel_donation_empty_owner_title">応援履歴がありません</string> <string name="creator_channel_donation_empty_owner_title">応援履歴がありません</string>
<string name="creator_channel_donation_can_format">%1$sCAN</string>
<string name="creator_channel_donation_fallback_message">%1$dCANを応援しました。</string> <string name="creator_channel_donation_fallback_message">%1$dCANを応援しました。</string>
<string name="creator_channel_owner_fab_open">コンテンツ作成</string> <string name="creator_channel_owner_fab_open">コンテンツ作成</string>
<string name="creator_channel_owner_fab_community">コミュニティ投稿</string> <string name="creator_channel_owner_fab_community">コミュニティ投稿</string>
-1
View File
@@ -410,7 +410,6 @@
<string name="creator_channel_home_donation_empty_title">처음으로 크리에이터를\n후원해 보세요!</string> <string name="creator_channel_home_donation_empty_title">처음으로 크리에이터를\n후원해 보세요!</string>
<string name="creator_channel_donation_empty_title">아직 후원이 없습니다.\n처음으로 크리에이터를 후원해 보세요!</string> <string name="creator_channel_donation_empty_title">아직 후원이 없습니다.\n처음으로 크리에이터를 후원해 보세요!</string>
<string name="creator_channel_donation_empty_owner_title">후원 내역이 없습니다</string> <string name="creator_channel_donation_empty_owner_title">후원 내역이 없습니다</string>
<string name="creator_channel_donation_can_format">%1$s캔</string>
<string name="creator_channel_donation_fallback_message">%1$d캔을 후원하였습니다.</string> <string name="creator_channel_donation_fallback_message">%1$d캔을 후원하였습니다.</string>
<string name="creator_channel_owner_fab_open">콘텐츠 만들기</string> <string name="creator_channel_owner_fab_open">콘텐츠 만들기</string>
<string name="creator_channel_owner_fab_community">커뮤니티 글 올리기</string> <string name="creator_channel_owner_fab_community">커뮤니티 글 올리기</string>
@@ -13,7 +13,6 @@ import io.reactivex.rxjava3.plugins.RxJavaPlugins
import io.reactivex.rxjava3.schedulers.Schedulers import io.reactivex.rxjava3.schedulers.Schedulers
import kr.co.vividnext.sodalive.common.ApiResponse import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.common.SharedPreferenceManager import kr.co.vividnext.sodalive.common.SharedPreferenceManager
import kr.co.vividnext.sodalive.common.UtcRelativeTimeTextFormatter
import kr.co.vividnext.sodalive.audio_content.comment.ModifyCommentRequest import kr.co.vividnext.sodalive.audio_content.comment.ModifyCommentRequest
import kr.co.vividnext.sodalive.explorer.profile.creator_community.CreatorCommunityRepository import kr.co.vividnext.sodalive.explorer.profile.creator_community.CreatorCommunityRepository
import kr.co.vividnext.sodalive.v2.creator.channel.community.detail.CreatorChannelCommunityDetailUiState import kr.co.vividnext.sodalive.v2.creator.channel.community.detail.CreatorChannelCommunityDetailUiState
@@ -58,7 +57,7 @@ class CreatorChannelCommunityDetailViewModelTest {
SharedPreferenceManager.userId = 10L SharedPreferenceManager.userId = 10L
repository = org.mockito.kotlin.mock() repository = org.mockito.kotlin.mock()
legacyRepository = org.mockito.kotlin.mock() legacyRepository = org.mockito.kotlin.mock()
viewModel = CreatorChannelCommunityDetailViewModel(repository, legacyRepository, testFormatter) viewModel = CreatorChannelCommunityDetailViewModel(repository, legacyRepository)
} }
@After @After
@@ -84,6 +83,7 @@ class CreatorChannelCommunityDetailViewModelTest {
val state = viewModel.detailStateLiveData.requireValue() as CreatorChannelCommunityDetailUiState.Content val state = viewModel.detailStateLiveData.requireValue() as CreatorChannelCommunityDetailUiState.Content
assertEquals(POST_ID, state.post.postId) assertEquals(POST_ID, state.post.postId)
assertEquals("2026-07-08T00:00:00Z", state.post.createdAtUtc)
assertEquals("body", state.post.content) assertEquals("body", state.post.content)
assertEquals("image.png", state.post.imageUrl) assertEquals("image.png", state.post.imageUrl)
assertEquals("audio.mp3", state.post.audioUrl) assertEquals("audio.mp3", state.post.audioUrl)
@@ -729,6 +729,5 @@ class CreatorChannelCommunityDetailViewModelTest {
const val POST_ID = 200L const val POST_ID = 200L
const val COMMENT_ID = 11L const val COMMENT_ID = 11L
const val AUTH_TOKEN = "Bearer test-token" const val AUTH_TOKEN = "Bearer test-token"
val testFormatter = UtcRelativeTimeTextFormatter { "방금 전" }
} }
} }
@@ -1,10 +1,6 @@
package kr.co.vividnext.sodalive.v2.creator.channel.community 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.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.data.CreatorChannelCommunityPostResponse
import kr.co.vividnext.sodalive.v2.creator.channel.community.model.CreatorChannelCommunityImageMode 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.CreatorChannelCommunityViewMode
@@ -14,17 +10,9 @@ import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Test 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 { class CreatorChannelCommunityMapperTest {
private val context: Context = ApplicationProvider.getApplicationContext()
private val relativeTimeTextFormatter = AndroidUtcRelativeTimeTextFormatter(context)
@Test @Test
fun `보기 방식은 label과 icon resource를 가진다`() { fun `보기 방식은 label과 icon resource를 가진다`() {
assertEquals(R.string.creator_channel_community_view_mode_list, CreatorChannelCommunityViewMode.List.labelResId) assertEquals(R.string.creator_channel_community_view_mode_list, CreatorChannelCommunityViewMode.List.labelResId)
@@ -34,21 +22,22 @@ class CreatorChannelCommunityMapperTest {
} }
@Test @Test
fun `게시글 기본 필드와 상대 시간 notice 댓글 표시 상태를 매핑한다`() { fun `게시글 기본 필드와 raw UTC notice 댓글 표시 상태를 매핑한다`() {
val createdAtUtc = "2026-06-21T00:00:00Z"
val item = listOf( val item = listOf(
communityPost( communityPost(
creatorProfileUrl = "profile.png", creatorProfileUrl = "profile.png",
createdAtUtc = System.currentTimeMillis().toString(), createdAtUtc = createdAtUtc,
isPinned = true, isPinned = true,
isCommentAvailable = true isCommentAvailable = true
) )
).toCommunityPostUiModels(relativeTimeTextFormatter, isOwner = false, currentUserId = 99L).single() ).toCommunityPostUiModels(isOwner = false, currentUserId = 99L).single()
assertEquals(1L, item.postId) assertEquals(1L, item.postId)
assertEquals(10L, item.creatorId) assertEquals(10L, item.creatorId)
assertEquals("creator", item.creatorNickname) assertEquals("creator", item.creatorNickname)
assertEquals("profile.png", item.creatorProfileUrl) assertEquals("profile.png", item.creatorProfileUrl)
assertEquals(context.getString(R.string.character_comment_time_just_now), item.createdAtText) assertEquals(createdAtUtc, item.createdAtUtc)
assertEquals("hello community", item.content) assertEquals("hello community", item.content)
assertEquals(3, item.likeCount) assertEquals(3, item.likeCount)
assertEquals(4, item.commentCount) assertEquals(4, item.commentCount)
@@ -59,7 +48,7 @@ class CreatorChannelCommunityMapperTest {
@Test @Test
fun `댓글 불가 게시글은 댓글 표시 상태가 false다`() { fun `댓글 불가 게시글은 댓글 표시 상태가 false다`() {
val item = listOf(communityPost(isCommentAvailable = false)) val item = listOf(communityPost(isCommentAvailable = false))
.toCommunityPostUiModels(relativeTimeTextFormatter, isOwner = false, currentUserId = 99L) .toCommunityPostUiModels(isOwner = false, currentUserId = 99L)
.single() .single()
assertFalse(item.showComment) assertFalse(item.showComment)
@@ -69,7 +58,7 @@ class CreatorChannelCommunityMapperTest {
fun `유료 미구매 타인 게시글은 잠금 상태이고 이미지를 숨긴다`() { fun `유료 미구매 타인 게시글은 잠금 상태이고 이미지를 숨긴다`() {
val item = listOf( val item = listOf(
communityPost(price = 100, existOrdered = false, imageUrl = "image.png", audioUrl = "audio.mp3") communityPost(price = 100, existOrdered = false, imageUrl = "image.png", audioUrl = "audio.mp3")
).toCommunityPostUiModels(relativeTimeTextFormatter, isOwner = false, currentUserId = 99L).single() ).toCommunityPostUiModels(isOwner = false, currentUserId = 99L).single()
assertTrue(item.isLocked) assertTrue(item.isLocked)
assertNull(item.imageUrl) assertNull(item.imageUrl)
@@ -84,7 +73,7 @@ class CreatorChannelCommunityMapperTest {
existOrdered = false, existOrdered = false,
isCommentAvailable = true isCommentAvailable = true
) )
).toCommunityPostUiModels(relativeTimeTextFormatter, isOwner = false, currentUserId = 99L).single() ).toCommunityPostUiModels(isOwner = false, currentUserId = 99L).single()
assertTrue(item.isLocked) assertTrue(item.isLocked)
assertFalse(item.showReaction) assertFalse(item.showReaction)
@@ -94,10 +83,10 @@ class CreatorChannelCommunityMapperTest {
@Test @Test
fun `고정 게시글 여부는 UI 모델에 보존한다`() { fun `고정 게시글 여부는 UI 모델에 보존한다`() {
val pinnedItem = listOf(communityPost(isPinned = true)) val pinnedItem = listOf(communityPost(isPinned = true))
.toCommunityPostUiModels(relativeTimeTextFormatter, isOwner = true, currentUserId = 10L) .toCommunityPostUiModels(isOwner = true, currentUserId = 10L)
.single() .single()
val normalItem = listOf(communityPost(isPinned = false)) val normalItem = listOf(communityPost(isPinned = false))
.toCommunityPostUiModels(relativeTimeTextFormatter, isOwner = true, currentUserId = 10L) .toCommunityPostUiModels(isOwner = true, currentUserId = 10L)
.single() .single()
assertTrue(pinnedItem.isPinned) assertTrue(pinnedItem.isPinned)
@@ -109,16 +98,16 @@ class CreatorChannelCommunityMapperTest {
@Test @Test
fun `메뉴 권한은 작성자 무료 구매 여부에 따라 계산하고 고정 상태를 보존한다`() { fun `메뉴 권한은 작성자 무료 구매 여부에 따라 계산하고 고정 상태를 보존한다`() {
val authorPaidNotOrdered = listOf(communityPost(price = 100, existOrdered = false, isPinned = true)) val authorPaidNotOrdered = listOf(communityPost(price = 100, existOrdered = false, isPinned = true))
.toCommunityPostUiModels(relativeTimeTextFormatter, isOwner = false, currentUserId = 10L) .toCommunityPostUiModels(isOwner = false, currentUserId = 10L)
.single() .single()
val visitorFree = listOf(communityPost(price = 0, existOrdered = false)) val visitorFree = listOf(communityPost(price = 0, existOrdered = false))
.toCommunityPostUiModels(relativeTimeTextFormatter, isOwner = false, currentUserId = 99L) .toCommunityPostUiModels(isOwner = false, currentUserId = 99L)
.single() .single()
val visitorPaidOrdered = listOf(communityPost(price = 100, existOrdered = true)) val visitorPaidOrdered = listOf(communityPost(price = 100, existOrdered = true))
.toCommunityPostUiModels(relativeTimeTextFormatter, isOwner = false, currentUserId = 99L) .toCommunityPostUiModels(isOwner = false, currentUserId = 99L)
.single() .single()
val visitorPaidNotOrdered = listOf(communityPost(price = 100, existOrdered = false)) val visitorPaidNotOrdered = listOf(communityPost(price = 100, existOrdered = false))
.toCommunityPostUiModels(relativeTimeTextFormatter, isOwner = false, currentUserId = 99L) .toCommunityPostUiModels(isOwner = false, currentUserId = 99L)
.single() .single()
assertTrue(authorPaidNotOrdered.menuItem.isOwner) assertTrue(authorPaidNotOrdered.menuItem.isOwner)
@@ -134,16 +123,16 @@ class CreatorChannelCommunityMapperTest {
@Test @Test
fun `본인 채널에 본인이 쓴 유료 게시글에서만 top price를 표시한다`() { fun `본인 채널에 본인이 쓴 유료 게시글에서만 top price를 표시한다`() {
val ownerPaid = listOf(communityPost(price = 100, creatorId = 10L)) val ownerPaid = listOf(communityPost(price = 100, creatorId = 10L))
.toCommunityPostUiModels(relativeTimeTextFormatter, isOwner = true, currentUserId = 10L) .toCommunityPostUiModels(isOwner = true, currentUserId = 10L)
.single() .single()
val ownerFree = listOf(communityPost(price = 0, creatorId = 10L)) val ownerFree = listOf(communityPost(price = 0, creatorId = 10L))
.toCommunityPostUiModels(relativeTimeTextFormatter, isOwner = true, currentUserId = 10L) .toCommunityPostUiModels(isOwner = true, currentUserId = 10L)
.single() .single()
val otherCreator = listOf(communityPost(price = 100, creatorId = 11L)) val otherCreator = listOf(communityPost(price = 100, creatorId = 11L))
.toCommunityPostUiModels(relativeTimeTextFormatter, isOwner = true, currentUserId = 10L) .toCommunityPostUiModels(isOwner = true, currentUserId = 10L)
.single() .single()
val otherChannel = listOf(communityPost(price = 100, creatorId = 10L)) val otherChannel = listOf(communityPost(price = 100, creatorId = 10L))
.toCommunityPostUiModels(relativeTimeTextFormatter, isOwner = false, currentUserId = 10L) .toCommunityPostUiModels(isOwner = false, currentUserId = 10L)
.single() .single()
assertTrue(ownerPaid.showOwnerTopPrice) assertTrue(ownerPaid.showOwnerTopPrice)
@@ -155,7 +144,7 @@ class CreatorChannelCommunityMapperTest {
@Test @Test
fun `grid text-only preview는 줄바꿈을 공백으로 바꾸고 trim 후 24자까지만 사용한다`() { fun `grid text-only preview는 줄바꿈을 공백으로 바꾸고 trim 후 24자까지만 사용한다`() {
val item = listOf(communityPost(content = "\n123456789012345678901234567890\n")) val item = listOf(communityPost(content = "\n123456789012345678901234567890\n"))
.toCommunityPostUiModels(relativeTimeTextFormatter, isOwner = false, currentUserId = 99L) .toCommunityPostUiModels(isOwner = false, currentUserId = 99L)
.single() .single()
assertEquals("123456789012345678901234", item.gridPreviewText) assertEquals("123456789012345678901234", item.gridPreviewText)
@@ -12,7 +12,6 @@ import io.reactivex.rxjava3.plugins.RxJavaPlugins
import io.reactivex.rxjava3.schedulers.Schedulers import io.reactivex.rxjava3.schedulers.Schedulers
import io.reactivex.rxjava3.subjects.SingleSubject import io.reactivex.rxjava3.subjects.SingleSubject
import kr.co.vividnext.sodalive.common.ApiResponse import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.common.AndroidUtcRelativeTimeTextFormatter
import kr.co.vividnext.sodalive.common.SharedPreferenceManager import kr.co.vividnext.sodalive.common.SharedPreferenceManager
import kr.co.vividnext.sodalive.v2.creator.channel.community.data.CreatorChannelCommunityPostResponse import kr.co.vividnext.sodalive.v2.creator.channel.community.data.CreatorChannelCommunityPostResponse
import kr.co.vividnext.sodalive.v2.creator.channel.community.data.CreatorChannelCommunityTabResponse import kr.co.vividnext.sodalive.v2.creator.channel.community.data.CreatorChannelCommunityTabResponse
@@ -45,7 +44,7 @@ class CreatorChannelCommunityPaginationTest {
SharedPreferenceManager.init(context) SharedPreferenceManager.init(context)
SharedPreferenceManager.token = "test-token" SharedPreferenceManager.token = "test-token"
repository = org.mockito.kotlin.mock() repository = org.mockito.kotlin.mock()
viewModel = CreatorChannelCommunityViewModel(repository, AndroidUtcRelativeTimeTextFormatter(context)) viewModel = CreatorChannelCommunityViewModel(repository)
} }
@After @After
@@ -0,0 +1,179 @@
package kr.co.vividnext.sodalive.v2.creator.channel.community
import android.app.Application
import android.content.Context
import android.content.res.Configuration
import android.widget.FrameLayout
import android.widget.TextView
import androidx.test.core.app.ApplicationProvider
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.common.ImageLoaderProvider
import kr.co.vividnext.sodalive.common.formatUtcRelativeTimeText
import kr.co.vividnext.sodalive.v2.creator.channel.community.detail.CreatorChannelCommunityCommentUiModel
import kr.co.vividnext.sodalive.v2.creator.channel.community.detail.CreatorChannelCommunityReplyUiModel
import kr.co.vividnext.sodalive.v2.creator.channel.community.detail.reply.ui.CreatorChannelCommunityReplyAdapter
import kr.co.vividnext.sodalive.v2.creator.channel.community.detail.ui.CreatorChannelCommunityCommentAdapter
import kr.co.vividnext.sodalive.v2.creator.channel.community.model.CreatorChannelCommunityImageMode
import kr.co.vividnext.sodalive.v2.creator.channel.community.model.CreatorChannelCommunityMenuItem
import kr.co.vividnext.sodalive.v2.creator.channel.community.model.CreatorChannelCommunityPostUiModel
import kr.co.vividnext.sodalive.v2.creator.channel.community.ui.CreatorChannelCommunityListAdapter
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
import java.io.File
import java.util.Locale
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [28], application = Application::class)
class CreatorChannelCommunityRelativeTimeLocaleTest {
@Before
fun setUp() {
if (!ImageLoaderProvider.isInitialized) {
ImageLoaderProvider.init(RuntimeEnvironment.getApplication())
}
}
@Test
fun `영어와 일본어 목록 댓글 답글 bind는 raw UTC를 현재 View locale 상대 시간으로 표시한다`() {
listOf(Locale.ENGLISH, Locale.JAPANESE).forEach(::assertAdapterRelativeTime)
}
@Test
fun `영어와 일본어 상세와 답글 화면은 raw UTC를 현재 View locale 상대 시간으로 표시한다`() {
val detailSource = projectFile(
"app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/community/detail/" +
"CreatorChannelCommunityDetailActivity.kt"
).readText()
val replySource = projectFile(
"app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/community/detail/reply/" +
"CreatorChannelCommunityReplyActivity.kt"
).readText()
assertTrue(
Regex(
"tvCreatorChannelCommunityDetailTime\\.text\\s*=\\s*" +
"formatUtcRelativeTimeText\\(tvCreatorChannelCommunityDetailTime\\.context, post\\.createdAtUtc\\)"
).containsMatchIn(detailSource)
)
assertTrue(
Regex(
"layoutCreatorChannelCommunityReplyParent\\.tvCreatorChannelCommunityCommentTime\\.text\\s*=\\s*" +
"formatUtcRelativeTimeText\\(\\s*" +
"layoutCreatorChannelCommunityReplyParent\\.tvCreatorChannelCommunityCommentTime\\.context,\\s*" +
"content\\.parentComment\\.createdAtUtc\\s*\\)"
).containsMatchIn(replySource)
)
listOf(Locale.ENGLISH, Locale.JAPANESE).forEach { locale ->
val context = localizedContext(locale)
assertEquals(
context.getString(R.string.character_comment_time_just_now),
formatUtcRelativeTimeText(context, System.currentTimeMillis().toString())
)
}
}
private fun assertAdapterRelativeTime(locale: Locale) {
val context = localizedContext(locale)
val nowUtc = System.currentTimeMillis().toString()
val expected = context.getString(R.string.character_comment_time_just_now)
val listAdapter = CreatorChannelCommunityListAdapter()
val listHolder = listAdapter.onCreateViewHolder(FrameLayout(context), 0)
listAdapter.submitItems(listOf(post(nowUtc)))
listAdapter.onBindViewHolder(listHolder, 0)
assertEquals(
expected,
listHolder.itemView.findViewById<TextView>(R.id.tv_creator_channel_community_list_time).text.toString()
)
val commentAdapter = CreatorChannelCommunityCommentAdapter(onReplyClick = {})
val commentHolder = commentAdapter.onCreateViewHolder(FrameLayout(context), 0)
commentAdapter.submitItems(listOf(comment(nowUtc)))
commentAdapter.onBindViewHolder(commentHolder, 0)
assertEquals(
expected,
commentHolder.itemView.findViewById<TextView>(R.id.tv_creator_channel_community_comment_time).text.toString()
)
val replyAdapter = CreatorChannelCommunityReplyAdapter(onModifyClick = {}, onDeleteClick = {})
val replyHolder = replyAdapter.onCreateViewHolder(FrameLayout(context), 0)
replyAdapter.submitItems(listOf(reply(nowUtc)))
replyAdapter.onBindViewHolder(replyHolder, 0)
assertEquals(
expected,
replyHolder.itemView.findViewById<TextView>(R.id.tv_creator_channel_community_reply_item_time).text.toString()
)
}
private fun post(createdAtUtc: String) = CreatorChannelCommunityPostUiModel(
postId = 1L,
creatorId = 2L,
creatorNickname = "creator",
creatorProfileUrl = "",
createdAtUtc = createdAtUtc,
content = "post",
imageUrl = null,
price = 0,
existOrdered = true,
likeCount = 0,
commentCount = 0,
showReaction = true,
showComment = true,
showNotice = false,
isPinned = false,
isLocked = false,
showOwnerTopPrice = false,
gridPreviewText = "post",
imageMode = CreatorChannelCommunityImageMode.TextPreview,
menuItem = CreatorChannelCommunityMenuItem(1L, 2L, 3L, 0, true, false)
)
private fun comment(createdAtUtc: String) = CreatorChannelCommunityCommentUiModel(
commentId = 1L,
memberId = 2L,
memberNickname = "member",
memberProfileUrl = "",
createdAtUtc = createdAtUtc,
comment = "comment",
likeCount = 0,
replyCount = 0,
isLiked = false,
isMine = false,
isCreatorOwner = false,
latestReply = null
)
private fun reply(createdAtUtc: String) = CreatorChannelCommunityReplyUiModel(
replyId = 1L,
commentId = 2L,
memberId = 3L,
memberNickname = "member",
memberProfileUrl = "",
createdAtUtc = createdAtUtc,
comment = "reply",
likeCount = 0,
isLiked = false,
isMine = false,
isCreatorOwner = false
)
private fun localizedContext(locale: Locale): Context {
val context = ApplicationProvider.getApplicationContext<Context>()
val configuration = Configuration(context.resources.configuration)
configuration.setLocale(locale)
return context.createConfigurationContext(configuration)
}
private fun projectFile(path: String): File = File(projectRoot(), path)
private fun projectRoot(): File {
return generateSequence(File(System.getProperty("user.dir") ?: ".").absoluteFile) { it.parentFile }
.first { File(it, "settings.gradle").exists() }
}
}
@@ -14,7 +14,6 @@ import io.reactivex.rxjava3.subjects.PublishSubject
import kr.co.vividnext.sodalive.audio_content.comment.ModifyCommentRequest import kr.co.vividnext.sodalive.audio_content.comment.ModifyCommentRequest
import kr.co.vividnext.sodalive.common.ApiResponse import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.common.SharedPreferenceManager import kr.co.vividnext.sodalive.common.SharedPreferenceManager
import kr.co.vividnext.sodalive.common.UtcRelativeTimeTextFormatter
import kr.co.vividnext.sodalive.explorer.profile.creator_community.CreatorCommunityRepository import kr.co.vividnext.sodalive.explorer.profile.creator_community.CreatorCommunityRepository
import kr.co.vividnext.sodalive.v2.creator.channel.community.detail.CreatorChannelCommunityCommentUiModel import kr.co.vividnext.sodalive.v2.creator.channel.community.detail.CreatorChannelCommunityCommentUiModel
import kr.co.vividnext.sodalive.v2.creator.channel.community.detail.reply.CreatorChannelCommunityReplyUiState import kr.co.vividnext.sodalive.v2.creator.channel.community.detail.reply.CreatorChannelCommunityReplyUiState
@@ -46,7 +45,6 @@ class CreatorChannelCommunityReplyViewModelTest {
private lateinit var repository: CreatorChannelRepository private lateinit var repository: CreatorChannelRepository
private lateinit var legacyRepository: CreatorCommunityRepository private lateinit var legacyRepository: CreatorCommunityRepository
private lateinit var viewModel: CreatorChannelCommunityReplyViewModel private lateinit var viewModel: CreatorChannelCommunityReplyViewModel
private lateinit var formattedInputs: MutableList<String?>
@Before @Before
fun setUp() { fun setUp() {
@@ -57,15 +55,7 @@ class CreatorChannelCommunityReplyViewModelTest {
SharedPreferenceManager.userId = 10L SharedPreferenceManager.userId = 10L
repository = org.mockito.kotlin.mock() repository = org.mockito.kotlin.mock()
legacyRepository = org.mockito.kotlin.mock() legacyRepository = org.mockito.kotlin.mock()
formattedInputs = mutableListOf() viewModel = CreatorChannelCommunityReplyViewModel(repository, legacyRepository)
viewModel = CreatorChannelCommunityReplyViewModel(
repository,
legacyRepository,
UtcRelativeTimeTextFormatter { value ->
formattedInputs.add(value)
"relative:$value"
}
)
} }
@After @After
@@ -84,9 +74,9 @@ class CreatorChannelCommunityReplyViewModelTest {
val state = viewModel.replyStateLiveData.requireValue() as CreatorChannelCommunityReplyUiState.Content val state = viewModel.replyStateLiveData.requireValue() as CreatorChannelCommunityReplyUiState.Content
assertEquals(COMMENT_ID, state.parentComment.commentId) assertEquals(COMMENT_ID, state.parentComment.commentId)
assertEquals("부모 댓글", state.parentComment.comment) assertEquals("부모 댓글", state.parentComment.comment)
assertEquals("relative:2026-07-08T00:00:00Z", state.parentComment.createdAtText) assertEquals("2026-07-08T00:00:00Z", state.parentComment.createdAtUtc)
assertEquals(listOf(101L, 102L), state.replies.map { it.replyId }) assertEquals(listOf(101L, 102L), state.replies.map { it.replyId })
assertTrue(formattedInputs.contains("2026-07-08T00:00:00Z")) assertEquals(listOf("2026-07-08T00:00:00Z", "2026-07-08T00:00:00Z"), state.replies.map { it.createdAtUtc })
assertEquals(0, state.replyPage) assertEquals(0, state.replyPage)
assertTrue(state.hasNextReply) assertTrue(state.hasNextReply)
verify(repository).getCommunityCommentReplies( verify(repository).getCommunityCommentReplies(
@@ -499,7 +489,6 @@ class CreatorChannelCommunityReplyViewModelTest {
memberId = 10L, memberId = 10L,
memberNickname = "member", memberNickname = "member",
memberProfileUrl = "member.png", memberProfileUrl = "member.png",
createdAtText = "이미 포맷된 시간",
createdAtUtc = "2026-07-08T00:00:00Z", createdAtUtc = "2026-07-08T00:00:00Z",
comment = "부모 댓글", comment = "부모 댓글",
likeCount = 1, likeCount = 1,
@@ -11,7 +11,6 @@ import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.plugins.RxJavaPlugins import io.reactivex.rxjava3.plugins.RxJavaPlugins
import io.reactivex.rxjava3.schedulers.Schedulers import io.reactivex.rxjava3.schedulers.Schedulers
import kr.co.vividnext.sodalive.common.ApiResponse import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.common.AndroidUtcRelativeTimeTextFormatter
import kr.co.vividnext.sodalive.common.SharedPreferenceManager import kr.co.vividnext.sodalive.common.SharedPreferenceManager
import kr.co.vividnext.sodalive.v2.creator.channel.community.data.CreatorChannelCommunityPostResponse import kr.co.vividnext.sodalive.v2.creator.channel.community.data.CreatorChannelCommunityPostResponse
import kr.co.vividnext.sodalive.v2.creator.channel.community.data.CreatorChannelCommunityTabResponse import kr.co.vividnext.sodalive.v2.creator.channel.community.data.CreatorChannelCommunityTabResponse
@@ -45,7 +44,7 @@ class CreatorChannelCommunityViewModelTest {
SharedPreferenceManager.init(context) SharedPreferenceManager.init(context)
SharedPreferenceManager.token = "test-token" SharedPreferenceManager.token = "test-token"
repository = org.mockito.kotlin.mock() repository = org.mockito.kotlin.mock()
viewModel = CreatorChannelCommunityViewModel(repository, AndroidUtcRelativeTimeTextFormatter(context)) viewModel = CreatorChannelCommunityViewModel(repository)
} }
@After @After
@@ -66,6 +65,7 @@ class CreatorChannelCommunityViewModelTest {
assertEquals(0, state.page) assertEquals(0, state.page)
assertEquals(CreatorChannelCommunityViewModel.DEFAULT_PAGE_SIZE, state.size) assertEquals(CreatorChannelCommunityViewModel.DEFAULT_PAGE_SIZE, state.size)
assertEquals(listOf(1L), state.communityPosts.map { it.postId }) assertEquals(listOf(1L), state.communityPosts.map { it.postId })
assertEquals("2026-06-21T00:00:00Z", state.communityPosts.single().createdAtUtc)
verifyGetCommunity() verifyGetCommunity()
} }
@@ -94,7 +94,7 @@ class CreatorChannelDonationActionTest {
assertTrue(adapter.contains("if (!isOwner) View.OnClickListener { onEmptyDonationClick() } else null")) assertTrue(adapter.contains("if (!isOwner) View.OnClickListener { onEmptyDonationClick() } else null"))
assertTrue(adapter.contains("bindCreatorChannelDonationCard(")) assertTrue(adapter.contains("bindCreatorChannelDonationCard("))
assertTrue(adapter.contains("headerColorResId = item.headerColorResId")) assertTrue(adapter.contains("headerColorResId = item.headerColorResId"))
assertTrue(adapter.contains("createdAtText = formatUtcRelativeTimeText(context, item.createdAtText)")) assertTrue(adapter.contains("createdAtText = formatUtcRelativeTimeText(context, item.createdAtUtc)"))
assertTrue(adapter.contains("R.string.creator_channel_donation_fallback_message")) assertTrue(adapter.contains("R.string.creator_channel_donation_fallback_message"))
assertTrue(adapter.contains("GridLayoutManager(itemView.context, 4)")) assertTrue(adapter.contains("GridLayoutManager(itemView.context, 4)"))
assertTrue(rankingAdapter.contains("item.profileImageUrl")) assertTrue(rankingAdapter.contains("item.profileImageUrl"))
@@ -176,9 +176,9 @@ class CreatorChannelDonationFragmentLayoutTest {
} }
@Test @Test
fun `후원 can binder는 한국어 영어 일본어에서 1,000 단위 표시를 사용한다`() { fun `후원 can badge는 한국어 영어 일본어에서 단위 없이 포맷된 숫자만 표시한다`() {
listOf(Locale.KOREAN to "1,000캔", Locale.ENGLISH to "1,000 cans", Locale.JAPANESE to "1,000CAN") listOf(Locale.KOREAN, Locale.ENGLISH, Locale.JAPANESE)
.forEach { (locale, expected) -> .forEach { locale ->
val application = ApplicationProvider.getApplicationContext<Application>() val application = ApplicationProvider.getApplicationContext<Application>()
if (!ImageLoaderProvider.isInitialized) ImageLoaderProvider.init(application) if (!ImageLoaderProvider.isInitialized) ImageLoaderProvider.init(application)
val context = application.createConfigurationContext( val context = application.createConfigurationContext(
@@ -189,7 +189,7 @@ class CreatorChannelDonationFragmentLayoutTest {
View(context), ImageView(context), TextView(context), TextView(context), canView, View(context), ImageView(context), TextView(context), TextView(context), canView,
TextView(context), R.color.gray_200, "", "member", "time", 1000, "message" TextView(context), R.color.gray_200, "", "member", "time", 1000, "message"
) )
assertEquals(expected, canView.text.toString()) assertEquals("1,000", canView.text.toString())
} }
} }
@@ -212,7 +212,7 @@ class CreatorChannelDonationFragmentLayoutTest {
profileImageUrl = "", profileImageUrl = "",
can = 50, can = 50,
message = "", message = "",
createdAtText = twoMinutesAgoMillis.toString(), createdAtUtc = twoMinutesAgoMillis.toString(),
headerColorResId = R.color.gray_200 headerColorResId = R.color.gray_200
) )
) )
@@ -1,29 +1,15 @@
package kr.co.vividnext.sodalive.v2.creator.channel.donation package kr.co.vividnext.sodalive.v2.creator.channel.donation
import android.app.Application
import android.content.Context
import android.content.res.Configuration
import androidx.test.core.app.ApplicationProvider
import kr.co.vividnext.sodalive.R import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.common.AndroidUtcRelativeTimeTextFormatter
import kr.co.vividnext.sodalive.v2.creator.channel.donation.data.CreatorChannelDonationResponse import kr.co.vividnext.sodalive.v2.creator.channel.donation.data.CreatorChannelDonationResponse
import kr.co.vividnext.sodalive.v2.creator.channel.donation.data.MemberDonationRankingResponse import kr.co.vividnext.sodalive.v2.creator.channel.donation.data.MemberDonationRankingResponse
import kr.co.vividnext.sodalive.v2.creator.channel.donation.model.toDonationRankingUiModels import kr.co.vividnext.sodalive.v2.creator.channel.donation.model.toDonationRankingUiModels
import kr.co.vividnext.sodalive.v2.creator.channel.donation.model.toDonationUiModels import kr.co.vividnext.sodalive.v2.creator.channel.donation.model.toDonationUiModels
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import java.util.Locale
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [28], application = Application::class)
class CreatorChannelDonationMapperTest { class CreatorChannelDonationMapperTest {
private val context: Context = ApplicationProvider.getApplicationContext()
private val relativeTimeTextFormatter = AndroidUtcRelativeTimeTextFormatter(context)
@Test @Test
fun `ranking rank는 응답 순서 기준 1부터 시작하고 profileImage를 profileImageUrl로 매핑한다`() { fun `ranking rank는 응답 순서 기준 1부터 시작하고 profileImage를 profileImageUrl로 매핑한다`() {
val rankings = listOf( val rankings = listOf(
@@ -39,43 +25,38 @@ class CreatorChannelDonationMapperTest {
@Test @Test
fun `후원 mapper는 message 원문을 보존한다`() { fun `후원 mapper는 message 원문을 보존한다`() {
val item = listOf(donation(can = 50, message = "")) val item = listOf(donation(can = 50, message = ""))
.toDonationUiModels(context, relativeTimeTextFormatter) .toDonationUiModels()
.single() .single()
assertEquals("", item.message) assertEquals("", item.message)
} }
@Test @Test
fun `message는 한국어 영어 일본어 context와 관계없이 API 원문을 유지한다`() { fun `message는 API 원문을 유지한다`() {
listOf(Locale.KOREAN, Locale.ENGLISH, Locale.JAPANESE).forEach { locale -> val items = listOf(
val localizedContext = context.createConfigurationContext( donation(can = 50, message = ""),
Configuration(context.resources.configuration).apply { setLocale(locale) } donation(can = 50, message = "응원합니다")
) ).toDonationUiModels()
val items = listOf(
donation(can = 50, message = ""),
donation(can = 50, message = "응원합니다")
).toDonationUiModels(localizedContext) { "time" }
assertEquals("", items[0].message) assertEquals("", items[0].message)
assertEquals("응원합니다", items[1].message) assertEquals("응원합니다", items[1].message)
}
} }
@Test @Test
fun `후원 mapper는 createdAtUtc 원문을 보존하고 can 기준 header color를 계산한다`() { fun `후원 mapper는 createdAtUtc 원문을 보존하고 can 기준 header color를 계산한다`() {
val createdAtUtc = System.currentTimeMillis().toString() val createdAtUtc = System.currentTimeMillis().toString()
val item = listOf(donation(can = 501, createdAtUtc = createdAtUtc)) val item = listOf(donation(can = 501, createdAtUtc = createdAtUtc))
.toDonationUiModels(context, relativeTimeTextFormatter) .toDonationUiModels()
.single() .single()
assertEquals(createdAtUtc, item.createdAtText) assertEquals(createdAtUtc, item.createdAtUtc)
assertEquals(R.color.red_400, item.headerColorResId) assertEquals(R.color.red_400, item.headerColorResId)
} }
@Test @Test
fun `후원 header color는 501캔부터 red_400으로 매핑한다`() { fun `후원 header color는 501캔부터 red_400으로 매핑한다`() {
val items = listOf(donation(can = 500), donation(can = 501)) val items = listOf(donation(can = 500), donation(can = 501))
.toDonationUiModels(context, relativeTimeTextFormatter) .toDonationUiModels()
assertEquals(R.color.creator_channel_donation_cyan, items[0].headerColorResId) assertEquals(R.color.creator_channel_donation_cyan, items[0].headerColorResId)
assertEquals(R.color.red_400, items[1].headerColorResId) assertEquals(R.color.red_400, items[1].headerColorResId)
@@ -12,7 +12,6 @@ import io.reactivex.rxjava3.plugins.RxJavaPlugins
import io.reactivex.rxjava3.schedulers.Schedulers import io.reactivex.rxjava3.schedulers.Schedulers
import io.reactivex.rxjava3.subjects.SingleSubject import io.reactivex.rxjava3.subjects.SingleSubject
import kr.co.vividnext.sodalive.common.ApiResponse import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.common.AndroidUtcRelativeTimeTextFormatter
import kr.co.vividnext.sodalive.common.SharedPreferenceManager import kr.co.vividnext.sodalive.common.SharedPreferenceManager
import kr.co.vividnext.sodalive.common.SodaLiveApplicationHolder import kr.co.vividnext.sodalive.common.SodaLiveApplicationHolder
import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelRepository import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelRepository
@@ -48,7 +47,7 @@ class CreatorChannelDonationPaginationTest {
SodaLiveApplicationHolder.init(context as Application) SodaLiveApplicationHolder.init(context as Application)
SharedPreferenceManager.token = "test-token" SharedPreferenceManager.token = "test-token"
repository = org.mockito.kotlin.mock() repository = org.mockito.kotlin.mock()
viewModel = CreatorChannelDonationViewModel(repository, AndroidUtcRelativeTimeTextFormatter(context)) viewModel = CreatorChannelDonationViewModel(repository)
} }
@After @After
@@ -11,7 +11,6 @@ import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.plugins.RxJavaPlugins import io.reactivex.rxjava3.plugins.RxJavaPlugins
import io.reactivex.rxjava3.schedulers.Schedulers import io.reactivex.rxjava3.schedulers.Schedulers
import kr.co.vividnext.sodalive.common.ApiResponse import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.common.AndroidUtcRelativeTimeTextFormatter
import kr.co.vividnext.sodalive.common.SharedPreferenceManager import kr.co.vividnext.sodalive.common.SharedPreferenceManager
import kr.co.vividnext.sodalive.common.SodaLiveApplicationHolder import kr.co.vividnext.sodalive.common.SodaLiveApplicationHolder
import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelRepository import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelRepository
@@ -48,7 +47,7 @@ class CreatorChannelDonationViewModelTest {
SharedPreferenceManager.token = "test-token" SharedPreferenceManager.token = "test-token"
SharedPreferenceManager.can = 200 SharedPreferenceManager.can = 200
repository = org.mockito.kotlin.mock() repository = org.mockito.kotlin.mock()
viewModel = CreatorChannelDonationViewModel(repository, AndroidUtcRelativeTimeTextFormatter(context)) viewModel = CreatorChannelDonationViewModel(repository)
} }
@After @After
@@ -13,7 +13,6 @@ import io.reactivex.rxjava3.schedulers.Schedulers
import kr.co.vividnext.sodalive.R import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.common.SodaLiveApplicationHolder import kr.co.vividnext.sodalive.common.SodaLiveApplicationHolder
import kr.co.vividnext.sodalive.common.ApiResponse import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.common.AndroidUtcRelativeTimeTextFormatter
import kr.co.vividnext.sodalive.common.SharedPreferenceManager import kr.co.vividnext.sodalive.common.SharedPreferenceManager
import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelRepository import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelRepository
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.data.CreatorChannelFanTalkReplyResponse import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.data.CreatorChannelFanTalkReplyResponse
@@ -47,7 +46,7 @@ class CreatorChannelFanTalkActionTest {
SharedPreferenceManager.token = "test-token" SharedPreferenceManager.token = "test-token"
SharedPreferenceManager.userId = 10L SharedPreferenceManager.userId = 10L
repository = org.mockito.kotlin.mock() repository = org.mockito.kotlin.mock()
viewModel = CreatorChannelFanTalkViewModel(repository, AndroidUtcRelativeTimeTextFormatter(context)) viewModel = CreatorChannelFanTalkViewModel(repository)
} }
@After @After
@@ -0,0 +1,106 @@
package kr.co.vividnext.sodalive.v2.creator.channel.fantalk
import android.app.Application
import android.content.Context
import android.content.res.Configuration
import android.widget.FrameLayout
import android.widget.TextView
import androidx.test.core.app.ApplicationProvider
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.common.ImageLoaderProvider
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.model.CreatorChannelFanTalkReplyUiModel
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.model.CreatorChannelFanTalkRightAction
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.model.CreatorChannelFanTalkUiModel
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.ui.CreatorChannelFanTalkAdapter
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
import java.util.Locale
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [28], application = Application::class)
class CreatorChannelFanTalkAdapterLocaleTest {
@Before
fun setUp() {
if (!ImageLoaderProvider.isInitialized) {
ImageLoaderProvider.init(RuntimeEnvironment.getApplication())
}
}
@Test
fun `영어와 일본어 bind는 오염된 신고 문구를 현재 locale 문자열로 복원한다`() {
assertReportText(Locale.ENGLISH, "Report")
assertReportText(Locale.JAPANESE, "通報")
}
@Test
fun `영어와 일본어 목록 bind는 원글과 답글 raw UTC를 현재 View locale 상대 시간으로 표시한다`() {
assertRelativeTimeText(Locale.ENGLISH)
assertRelativeTimeText(Locale.JAPANESE)
}
private fun assertRelativeTimeText(locale: Locale) {
val context = localizedContext(locale)
val parent = FrameLayout(context)
val adapter = CreatorChannelFanTalkAdapter()
val holder = adapter.onCreateViewHolder(parent, 0)
val nowUtc = System.currentTimeMillis().toString()
adapter.submitItems(listOf(fanTalk(nowUtc)))
adapter.onBindViewHolder(holder, 0)
val expected = context.getString(R.string.character_comment_time_just_now)
assertEquals(
expected,
holder.itemView.findViewById<TextView>(R.id.tv_creator_channel_fantalk_time).text.toString()
)
assertEquals(
expected,
holder.itemView.findViewById<TextView>(R.id.tv_creator_channel_fantalk_reply_time).text.toString()
)
}
private fun assertReportText(locale: Locale, expected: String) {
val context = localizedContext(locale)
val parent = FrameLayout(context)
val adapter = CreatorChannelFanTalkAdapter()
val holder = adapter.onCreateViewHolder(parent, 0)
val report = holder.itemView.findViewById<TextView>(R.id.tv_creator_channel_fantalk_report)
report.text = "신고"
adapter.submitItems(listOf(fanTalk()))
adapter.onBindViewHolder(holder, 0)
assertEquals(expected, report.text.toString())
}
private fun localizedContext(locale: Locale): Context {
val context = ApplicationProvider.getApplicationContext<Context>()
val configuration = Configuration(context.resources.configuration)
configuration.setLocale(locale)
return context.createConfigurationContext(configuration)
}
private fun fanTalk(createdAtUtc: String = "2026-06-21T00:00:00Z") = CreatorChannelFanTalkUiModel(
fanTalkId = 1L,
writerId = 2L,
writerNickname = "writer",
writerProfileImageUrl = "",
content = "fan talk",
createdAtUtc = createdAtUtc,
reply = CreatorChannelFanTalkReplyUiModel(
fanTalkId = 3L,
writerId = 4L,
writerNickname = "creator",
writerProfileImageUrl = "",
content = "reply",
createdAtUtc = createdAtUtc
),
rightAction = CreatorChannelFanTalkRightAction.Report
)
}
@@ -1,10 +1,5 @@
package kr.co.vividnext.sodalive.v2.creator.channel.fantalk package kr.co.vividnext.sodalive.v2.creator.channel.fantalk
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.fantalk.data.CreatorChannelFanTalkReplyResponse import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.data.CreatorChannelFanTalkReplyResponse
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.data.CreatorChannelFanTalkResponse import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.data.CreatorChannelFanTalkResponse
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.model.CreatorChannelFanTalkRightAction import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.model.CreatorChannelFanTalkRightAction
@@ -13,35 +8,33 @@ import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Test 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 CreatorChannelFanTalkMapperTest { class CreatorChannelFanTalkMapperTest {
private val context: Context = ApplicationProvider.getApplicationContext()
private val relativeTimeTextFormatter = AndroidUtcRelativeTimeTextFormatter(context)
@Test @Test
fun `기본 필드와 상대 시간을 매핑한다`() { fun `기본 필드와 원글 답글 raw UTC를 변경 없이 매핑한다`() {
val item = listOf(fanTalk(createdAtUtc = System.currentTimeMillis().toString())) val item = listOf(
.toFanTalkUiModels(relativeTimeTextFormatter, isOwner = false, currentUserId = 99L) fanTalk(
createdAtUtc = "2026-06-21T00:00:00Z",
creatorReplies = listOf(reply(createdAtUtc = "2026-06-21T00:01:00Z"))
)
)
.toFanTalkUiModels(isOwner = false, currentUserId = 99L)
.single() .single()
assertEquals(1L, item.fanTalkId) assertEquals(1L, item.fanTalkId)
assertEquals(10L, item.writerId) assertEquals(10L, item.writerId)
assertEquals("writer", item.writerNickname) assertEquals("writer", item.writerNickname)
assertEquals("profile.png", item.writerProfileImageUrl) assertEquals("profile.png", item.writerProfileImageUrl)
assertEquals(context.getString(R.string.character_comment_time_just_now), item.createdAtText) assertEquals("2026-06-21T00:00:00Z", item.createdAtUtc)
assertEquals("2026-06-21T00:01:00Z", item.reply?.createdAtUtc)
assertEquals("hello fan talk", item.content) assertEquals("hello fan talk", item.content)
} }
@Test @Test
fun `creatorReplies가 비어 있으면 reply는 null이다`() { fun `creatorReplies가 비어 있으면 reply는 null이다`() {
val item = listOf(fanTalk(creatorReplies = emptyList())) val item = listOf(fanTalk(creatorReplies = emptyList()))
.toFanTalkUiModels(relativeTimeTextFormatter, isOwner = false, currentUserId = 99L) .toFanTalkUiModels(isOwner = false, currentUserId = 99L)
.single() .single()
assertNull(item.reply) assertNull(item.reply)
@@ -56,7 +49,7 @@ class CreatorChannelFanTalkMapperTest {
reply(fanTalkId = 3L, content = "second reply") reply(fanTalkId = 3L, content = "second reply")
) )
) )
).toFanTalkUiModels(relativeTimeTextFormatter, isOwner = false, currentUserId = 99L).single() ).toFanTalkUiModels(isOwner = false, currentUserId = 99L).single()
assertEquals(2L, item.reply?.fanTalkId) assertEquals(2L, item.reply?.fanTalkId)
assertEquals("first reply", item.reply?.content) assertEquals("first reply", item.reply?.content)
@@ -65,7 +58,7 @@ class CreatorChannelFanTalkMapperTest {
@Test @Test
fun `내가 쓴 글이면 수정과 삭제 owner more action이다`() { fun `내가 쓴 글이면 수정과 삭제 owner more action이다`() {
val item = listOf(fanTalk(writerId = 10L)) val item = listOf(fanTalk(writerId = 10L))
.toFanTalkUiModels(relativeTimeTextFormatter, isOwner = false, currentUserId = 10L) .toFanTalkUiModels(isOwner = false, currentUserId = 10L)
.single() .single()
val action = item.rightAction as CreatorChannelFanTalkRightAction.OwnerMore val action = item.rightAction as CreatorChannelFanTalkRightAction.OwnerMore
@@ -76,7 +69,7 @@ class CreatorChannelFanTalkMapperTest {
@Test @Test
fun `내 채널의 타인 글이면 삭제만 가능한 owner more action이다`() { fun `내 채널의 타인 글이면 삭제만 가능한 owner more action이다`() {
val item = listOf(fanTalk(writerId = 11L)) val item = listOf(fanTalk(writerId = 11L))
.toFanTalkUiModels(relativeTimeTextFormatter, isOwner = true, currentUserId = 10L) .toFanTalkUiModels(isOwner = true, currentUserId = 10L)
.single() .single()
val action = item.rightAction as CreatorChannelFanTalkRightAction.OwnerMore val action = item.rightAction as CreatorChannelFanTalkRightAction.OwnerMore
@@ -87,7 +80,7 @@ class CreatorChannelFanTalkMapperTest {
@Test @Test
fun `내가 쓴 글도 아니고 내 채널도 아니면 신고 action이다`() { fun `내가 쓴 글도 아니고 내 채널도 아니면 신고 action이다`() {
val item = listOf(fanTalk(writerId = 11L)) val item = listOf(fanTalk(writerId = 11L))
.toFanTalkUiModels(relativeTimeTextFormatter, isOwner = false, currentUserId = 10L) .toFanTalkUiModels(isOwner = false, currentUserId = 10L)
.single() .single()
assertTrue(item.rightAction is CreatorChannelFanTalkRightAction.Report) assertTrue(item.rightAction is CreatorChannelFanTalkRightAction.Report)
@@ -96,7 +89,7 @@ class CreatorChannelFanTalkMapperTest {
@Test @Test
fun `원글과 답글 content가 빈 문자열이어도 item을 유지한다`() { fun `원글과 답글 content가 빈 문자열이어도 item을 유지한다`() {
val item = listOf(fanTalk(content = "", creatorReplies = listOf(reply(content = "")))) val item = listOf(fanTalk(content = "", creatorReplies = listOf(reply(content = ""))))
.toFanTalkUiModels(relativeTimeTextFormatter, isOwner = false, currentUserId = 99L) .toFanTalkUiModels(isOwner = false, currentUserId = 99L)
.single() .single()
assertEquals("", item.content) assertEquals("", item.content)
@@ -123,13 +116,14 @@ class CreatorChannelFanTalkMapperTest {
private fun reply( private fun reply(
fanTalkId: Long = 2L, fanTalkId: Long = 2L,
writerId: Long = 20L, writerId: Long = 20L,
content: String = "reply" content: String = "reply",
createdAtUtc: String = "2026-06-21T00:00:00Z"
) = CreatorChannelFanTalkReplyResponse( ) = CreatorChannelFanTalkReplyResponse(
fanTalkId = fanTalkId, fanTalkId = fanTalkId,
writerId = writerId, writerId = writerId,
writerNickname = "creator", writerNickname = "creator",
writerProfileImageUrl = "creator.png", writerProfileImageUrl = "creator.png",
content = content, content = content,
createdAtUtc = "2026-06-21T00:00:00Z" createdAtUtc = createdAtUtc
) )
} }
@@ -12,7 +12,6 @@ import io.reactivex.rxjava3.plugins.RxJavaPlugins
import io.reactivex.rxjava3.schedulers.Schedulers import io.reactivex.rxjava3.schedulers.Schedulers
import io.reactivex.rxjava3.subjects.SingleSubject import io.reactivex.rxjava3.subjects.SingleSubject
import kr.co.vividnext.sodalive.common.ApiResponse import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.common.AndroidUtcRelativeTimeTextFormatter
import kr.co.vividnext.sodalive.common.SharedPreferenceManager import kr.co.vividnext.sodalive.common.SharedPreferenceManager
import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelRepository import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelRepository
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.data.CreatorChannelFanTalkResponse import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.data.CreatorChannelFanTalkResponse
@@ -46,7 +45,7 @@ class CreatorChannelFanTalkPaginationTest {
SharedPreferenceManager.token = "test-token" SharedPreferenceManager.token = "test-token"
SharedPreferenceManager.userId = 10L SharedPreferenceManager.userId = 10L
repository = org.mockito.kotlin.mock() repository = org.mockito.kotlin.mock()
viewModel = CreatorChannelFanTalkViewModel(repository, AndroidUtcRelativeTimeTextFormatter(context)) viewModel = CreatorChannelFanTalkViewModel(repository)
} }
@After @After
@@ -11,7 +11,6 @@ import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.plugins.RxJavaPlugins import io.reactivex.rxjava3.plugins.RxJavaPlugins
import io.reactivex.rxjava3.schedulers.Schedulers import io.reactivex.rxjava3.schedulers.Schedulers
import kr.co.vividnext.sodalive.common.ApiResponse import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.common.AndroidUtcRelativeTimeTextFormatter
import kr.co.vividnext.sodalive.common.SharedPreferenceManager import kr.co.vividnext.sodalive.common.SharedPreferenceManager
import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelRepository import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelRepository
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.data.CreatorChannelFanTalkResponse import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.data.CreatorChannelFanTalkResponse
@@ -45,7 +44,7 @@ class CreatorChannelFanTalkViewModelTest {
SharedPreferenceManager.token = "test-token" SharedPreferenceManager.token = "test-token"
SharedPreferenceManager.userId = 10L SharedPreferenceManager.userId = 10L
repository = org.mockito.kotlin.mock() repository = org.mockito.kotlin.mock()
viewModel = CreatorChannelFanTalkViewModel(repository, AndroidUtcRelativeTimeTextFormatter(context)) viewModel = CreatorChannelFanTalkViewModel(repository)
} }
@After @After
@@ -1,12 +1,23 @@
package kr.co.vividnext.sodalive.v2.creator.channel.fantalk.detail package kr.co.vividnext.sodalive.v2.creator.channel.fantalk.detail
import android.app.Application
import android.content.Context
import android.content.res.Configuration
import androidx.test.core.app.ApplicationProvider
import kr.co.vividnext.sodalive.R import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.common.formatUtcRelativeTimeText
import org.junit.Assert.assertFalse import org.junit.Assert.assertFalse
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import java.io.File import java.io.File
import java.util.Locale
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [28], application = Application::class)
class CreatorChannelFanTalkDetailUiContractTest { class CreatorChannelFanTalkDetailUiContractTest {
@Test @Test
@@ -50,6 +61,33 @@ class CreatorChannelFanTalkDetailUiContractTest {
assertTrue(source.contains("R.drawable.ic_new_arrow_up_gray")) assertTrue(source.contains("R.drawable.ic_new_arrow_up_gray"))
} }
@Test
fun `영어와 일본어 상세 bind는 원글과 답글 raw UTC를 현재 View locale 상대 시간으로 표시한다`() {
val source = projectFile(
"app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/detail/CreatorChannelFanTalkDetailActivity.kt"
).readText()
assertTrue(
Regex(
"tvCreatorChannelFantalkDetailTime\\.text\\s*=\\s*" +
"formatUtcRelativeTimeText\\(tvCreatorChannelFantalkDetailTime\\.context, item\\.createdAtUtc\\)"
).containsMatchIn(source)
)
assertTrue(
Regex(
"tvCreatorChannelFantalkDetailReplyTime\\.text\\s*=\\s*" +
"formatUtcRelativeTimeText\\(tvCreatorChannelFantalkDetailReplyTime\\.context, reply\\.createdAtUtc\\)"
).containsMatchIn(source)
)
listOf(Locale.ENGLISH, Locale.JAPANESE).forEach { locale ->
val context = localizedContext(locale)
assertEquals(
context.getString(R.string.character_comment_time_just_now),
formatUtcRelativeTimeText(context, System.currentTimeMillis().toString())
)
}
}
@Test @Test
fun `상세 layout은 커뮤니티 답글 구조와 FanTalk 답글 card 토큰을 사용한다`() { fun `상세 layout은 커뮤니티 답글 구조와 FanTalk 답글 card 토큰을 사용한다`() {
val layout = projectFile("app/src/main/res/layout/activity_creator_channel_fantalk_detail.xml").readText() val layout = projectFile("app/src/main/res/layout/activity_creator_channel_fantalk_detail.xml").readText()
@@ -113,7 +151,7 @@ class CreatorChannelFanTalkDetailUiContractTest {
"CreatorChannelFanTalkDetailViewModel" "CreatorChannelFanTalkDetailViewModel"
) )
) )
assertTrue(appDi.contains("viewModel { CreatorChannelFanTalkDetailViewModel(get(), get()) }")) assertTrue(appDi.contains("viewModel { CreatorChannelFanTalkDetailViewModel(get()) }"))
assertTrue(viewModel.contains("fun cancelReplyEdit()")) assertTrue(viewModel.contains("fun cancelReplyEdit()"))
assertTrue(viewModel.contains("fun consumeReplyChangedEvent()")) assertTrue(viewModel.contains("fun consumeReplyChangedEvent()"))
} }
@@ -153,6 +191,13 @@ class CreatorChannelFanTalkDetailUiContractTest {
private fun projectFile(path: String): File = File(projectRoot(), path) private fun projectFile(path: String): File = File(projectRoot(), path)
private fun localizedContext(locale: Locale): Context {
val context = ApplicationProvider.getApplicationContext<Context>()
val configuration = Configuration(context.resources.configuration)
configuration.setLocale(locale)
return context.createConfigurationContext(configuration)
}
private fun projectRoot(): File { private fun projectRoot(): File {
return generateSequence(File(System.getProperty("user.dir") ?: ".").absoluteFile) { it.parentFile } return generateSequence(File(System.getProperty("user.dir") ?: ".").absoluteFile) { it.parentFile }
.first { File(it, "settings.gradle").exists() } .first { File(it, "settings.gradle").exists() }
@@ -15,7 +15,6 @@ import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.common.SharedPreferenceManager import kr.co.vividnext.sodalive.common.SharedPreferenceManager
import kr.co.vividnext.sodalive.common.SodaLiveApplicationHolder import kr.co.vividnext.sodalive.common.SodaLiveApplicationHolder
import kr.co.vividnext.sodalive.common.ToastMessage import kr.co.vividnext.sodalive.common.ToastMessage
import kr.co.vividnext.sodalive.common.UtcRelativeTimeTextFormatter
import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelRepository import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelRepository
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.data.CreatorChannelFanTalkReplyResponse import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.data.CreatorChannelFanTalkReplyResponse
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.model.CreatorChannelFanTalkReplyUiModel import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.model.CreatorChannelFanTalkReplyUiModel
@@ -52,10 +51,7 @@ class CreatorChannelFanTalkDetailViewModelTest {
SharedPreferenceManager.init(context) SharedPreferenceManager.init(context)
SharedPreferenceManager.token = "test-token" SharedPreferenceManager.token = "test-token"
repository = org.mockito.kotlin.mock() repository = org.mockito.kotlin.mock()
viewModel = CreatorChannelFanTalkDetailViewModel( viewModel = CreatorChannelFanTalkDetailViewModel(repository)
repository,
UtcRelativeTimeTextFormatter { value -> "relative:$value" }
)
} }
@After @After
@@ -109,7 +105,7 @@ class CreatorChannelFanTalkDetailViewModelTest {
assertEquals("server-creator", state.reply?.writerNickname) assertEquals("server-creator", state.reply?.writerNickname)
assertEquals("server-creator.png", state.reply?.writerProfileImageUrl) assertEquals("server-creator.png", state.reply?.writerProfileImageUrl)
assertEquals("새 답글", state.reply?.content) assertEquals("새 답글", state.reply?.content)
assertEquals("relative:2026-07-09T01:02:03Z", state.reply?.createdAtText) assertEquals("2026-07-09T01:02:03Z", state.reply?.createdAtUtc)
assertEquals(null, viewModel.toastLiveData.requireValue()?.consume()) assertEquals(null, viewModel.toastLiveData.requireValue()?.consume())
assertTrue(viewModel.replyChangedEventLiveData.requireValue() == true) assertTrue(viewModel.replyChangedEventLiveData.requireValue() == true)
} }
@@ -292,7 +288,7 @@ class CreatorChannelFanTalkDetailViewModelTest {
writerNickname = "fan", writerNickname = "fan",
writerProfileImageUrl = "fan.png", writerProfileImageUrl = "fan.png",
content = "응원 원문", content = "응원 원문",
createdAtText = "방금 전", createdAtUtc = "2026-07-09T00:00:00Z",
reply = reply, reply = reply,
rightAction = CreatorChannelFanTalkRightAction.OwnerMore(showEdit = false, showDelete = true) rightAction = CreatorChannelFanTalkRightAction.OwnerMore(showEdit = false, showDelete = true)
) )
@@ -303,7 +299,7 @@ class CreatorChannelFanTalkDetailViewModelTest {
writerNickname = "creator", writerNickname = "creator",
writerProfileImageUrl = "creator.png", writerProfileImageUrl = "creator.png",
content = content, content = content,
createdAtText = "방금 전" createdAtUtc = "2026-07-09T00:01:00Z"
) )
private fun LiveData<CreatorChannelFanTalkDetailUiState>.requireContent(): CreatorChannelFanTalkDetailUiState.Content { private fun LiveData<CreatorChannelFanTalkDetailUiState>.requireContent(): CreatorChannelFanTalkDetailUiState.Content {
@@ -0,0 +1,97 @@
# Phase 1 크리에이터 채널 표시 계약 리뷰
## 1. 리뷰 정보
| 항목 | 내용 |
|---|---|
| 리뷰 대상 | Phase 1 / `P1-T1`~`P1-GATE` |
| 기준 commit 또는 working tree | 현재 working tree |
| 리뷰 일자 | 2026-08-26 |
| 리뷰어 | Sisyphus / Oracle |
| 기준 문서 | `prd.md`, `plan-task.md` |
| 리뷰 상태 | 판정 완료 |
## 2. 리뷰 목적과 범위
### 목적
- FanTalk report locale, empty count, Donation badge 숫자-only 계약이 PRD와 일치하는지 확인한다.
- 완료 체크박스와 실제 코드·test·검증 기록이 일치하는지 확인한다.
### 포함 범위
- 코드: FanTalk adapter, Donation card binder, Donation item layout, ko/en/ja strings
- 테스트: FanTalk locale/action/ViewModel, Donation layout/action/mapper/ViewModel
- 문서: PRD Phase 1 요구사항, `plan-task.md` Phase 1
- 수동 검증: 사용자 직접 확인한 FanTalk report 문구 변경
### 제외 범위
- 상대 시간 통합 Phase 2
- 계측 test, 기기·에뮬레이터 UI 조작, 스크린샷, 시각 QA
## 3. 판정 기준
| 심각도 | 기준 |
|---|---|
| Blocker | 완료 판정을 무효화하는 요구사항 위반 |
| High | 주요 기능 회귀 |
| Medium | 제한 조건의 표시 오류 |
| Low | 문서·유지보수성 문제 |
## 4. 검토한 근거
### 문서와 코드
- 요구사항: `FAN-001`, `FAN-002`, `DON-001`, `DON-002`, `DON-003`, `LOC-001`, `LOC-002`
- 계획: `P1-T1`, `P1-T2`, `P1-T3`, `P1-GATE`
- 코드: `CreatorChannelFanTalkAdapter.kt`, `CreatorChannelDonationCardBinder.kt`, `item_creator_channel_donation.xml`, `values*/strings.xml`
- 테스트: `CreatorChannelFanTalkAdapterLocaleTest`, `CreatorChannelFanTalkActionTest`, `CreatorChannelFanTalkViewModelTest`, `CreatorChannelDonationFragmentLayoutTest`, `CreatorChannelDonationActionTest`, `CreatorChannelDonationMapperTest`, `CreatorChannelDonationViewModelTest`
### 실행 환경
```text
OS: macOS / darwin
검증 방식: Gradle local unit test, resource merge, Kotlin compile, ktlint, git diff check
제외: androidTest, device/emulator, screenshot, visual QA
```
### 실행한 검증
| 명령 또는 검증 | 결과 | 핵심 증거 |
|---|---|---|
| `CreatorChannelFanTalkAdapterLocaleTest` RED | 성공 | `expected:<[Report]> but was:<[신고]>` |
| `CreatorChannelDonationFragmentLayoutTest` RED | 성공 | `expected:<1,000[]> but was:<1,000[캔]>` |
| FanTalk/Donation focused tests | 성공 | `BUILD SUCCESSFUL` |
| Phase 1 Gate 6개 명령 | 성공 | FanTalk.*, Donation.*, resource merge, compile, ktlint, diff check PASS |
| 사용자 수동 확인 | 성공 | 신고 버튼 문구 변경 확인 |
## 5. 발견 사항 요약
확정 발견 사항 없음.
## 6. 발견 사항 상세
확정 발견 사항 없음. FanTalk report는 현재 `root.context` locale로 재바인딩되고, Donation badge는 숫자만 표시하며 fallback message는 유지된다. Empty count 계약은 production 변경 없이 기존 ViewModel test와 Fragment source 대조로 확인됐다.
## 7. 확정 항목의 plan·goal 전환
전환 항목 없음.
## 8. 리뷰 종료 판정
| 판정 항목 | 결과 | 근거 |
|---|---|---|
| 리뷰 범위 전체 확인 | 충족 | Phase 1 code/test/docs 검토 |
| 후보 항목 판정 완료 | 충족 | 확정 발견 사항 없음 |
| 확정 항목 plan 반영 | 해당 없음 | 전환 항목 없음 |
| 보류 항목의 담당·재개 조건 기록 | 해당 없음 | 보류 항목 없음 |
| 검증 명령과 결과 기록 | 충족 | `plan-task.md` Task별 기록과 Verification Log |
**최종 결론:** 확정 발견 사항 없음
**남은 항목:** 없음
## 9. 수정 후 검증 기록
수정 항목 없음.
@@ -0,0 +1,103 @@
# Phase 2 상대 시간 통합 리뷰
## 1. 리뷰 정보
| 항목 | 내용 |
|---|---|
| 리뷰 대상 | Phase 2 / `P2-T1`~`P2-GATE` |
| 기준 commit 또는 working tree | 현재 working tree |
| 리뷰 일자 | 2026-08-26 |
| 리뷰어 | Sisyphus / Oracle |
| 기준 문서 | `prd.md`, `plan-task.md` |
| 리뷰 상태 | 판정 완료 |
## 2. 리뷰 목적과 범위
### 목적
- `v2` 크리에이터 채널 상대 시간이 raw UTC와 표시 View/Activity Context의 공통 formatter 경로로 통일됐는지 확인한다.
- 완료 체크박스와 실제 코드·test·검증 기록이 일치하는지 확인한다.
### 포함 범위
- 코드: FanTalk, Community, Donation, Home 표시부와 `AppDI.kt``v2` ViewModel 등록
- 테스트: FanTalk/Community/Donation mapper, ViewModel, adapter/activity locale, pagination/action 회귀, `RelativeTimeFormatterTest`
- 문서: PRD Phase 2 요구사항, `plan-task.md` Phase 2
- 수동 검증: 없음
### 제외 범위
- `RelativeTimeFormatter.kt` 구현 변경
- 레거시 화면 상대 시간 호출부
- 계측 test, 기기·에뮬레이터 UI 조작, 스크린샷, 시각 QA
## 3. 판정 기준
| 심각도 | 기준 |
|---|---|
| Blocker | 완료 판정을 무효화하는 요구사항 위반 |
| High | 주요 기능 회귀 또는 DI/navigation 오류 |
| Medium | 특정 화면의 locale 표시 오류 |
| Low | 문서·유지보수성 문제 |
## 4. 검토한 근거
### 문서와 코드
- 요구사항: `TIME-001`~`TIME-004`, `LOC-001`, `LOC-002`
- 계획: `P2-T1`, `P2-T2`, `P2-T3`, `P2-GATE`
- 코드: FanTalk/Community/Donation UI models, mappers, ViewModels, adapters, Activities, `AppDI.kt`, Home section adapter
- 테스트: FanTalk.*, Community.*, Donation.*, `CreatorChannelHomeMapperTest`, `RelativeTimeFormatterTest`
### 실행 환경
```text
OS: macOS / darwin
검증 방식: Gradle local unit test, resource merge, Kotlin compile, ktlint, git diff check, rg inventory
제외: androidTest, device/emulator, screenshot, visual QA
```
### 실행한 검증
| 명령 또는 검증 | 결과 | 핵심 증거 |
|---|---|---|
| FanTalk RED | 성공 | `createdAtUtc`/생성자 compile 실패 |
| Community RED | 성공 | `relativeTimeTextFormatter`, `createdAtUtc`, `createdAtText` compile 실패 |
| Donation RED | 성공 | old mapper 인자와 `createdAtUtc` compile 실패 |
| `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.common.RelativeTimeFormatterTest" --tests "kr.co.vividnext.sodalive.v2.creator.channel.*"` | 성공 | 최종 단독 재실행 `BUILD SUCCESSFUL` |
| `./gradlew :app:mergeDebugResources` | 성공 | `BUILD SUCCESSFUL` |
| `./gradlew :app:compileDebugKotlin` | 성공 | `BUILD SUCCESSFUL` |
| `./gradlew :app:ktlintCheck` | 성공 | line length 수정 후 `BUILD SUCCESSFUL` |
| `git diff --check` | 성공 | 출력 없음 |
| `rg -n "UtcRelativeTimeTextFormatter|relativeTimeTextFormatter\.format" app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel` | 성공 | 출력 없음 |
| `rg -n "formatUtcRelativeTimeText" app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel` | 성공 | FanTalk, Community, Donation, Home 표시부 포함 |
## 5. 발견 사항 요약
확정 발견 사항 없음.
## 6. 발견 사항 상세
확정 발견 사항 없음. FanTalk·Community·Donation presentation data는 raw UTC를 보존하고, 각 adapter/Activity가 현재 View Context로 `formatUtcRelativeTimeText()`를 호출한다. Home section의 기존 bind-time formatter 호출은 유지됐다. `RelativeTimeFormatter.kt`와 레거시 호출부는 수정하지 않았다.
## 7. 확정 항목의 plan·goal 전환
전환 항목 없음.
## 8. 리뷰 종료 판정
| 판정 항목 | 결과 | 근거 |
|---|---|---|
| 리뷰 범위 전체 확인 | 충족 | Phase 2 code/test/docs 검토 |
| 후보 항목 판정 완료 | 충족 | 확정 발견 사항 없음 |
| 확정 항목 plan 반영 | 해당 없음 | 전환 항목 없음 |
| 보류 항목의 담당·재개 조건 기록 | 해당 없음 | 보류 항목 없음 |
| 검증 명령과 결과 기록 | 충족 | `plan-task.md` Task별 기록과 Verification Log |
**최종 결론:** 확정 발견 사항 없음
**남은 항목:** 없음
## 9. 수정 후 검증 기록
수정 항목 없음.