Compare commits
50 Commits
79a6ecdc0b
...
2d8f8cab81
| Author | SHA1 | Date | |
|---|---|---|---|
| 2d8f8cab81 | |||
| f799449d54 | |||
| 9da6c7381d | |||
| ced226c3ef | |||
| f69a9a6ee0 | |||
| 94d2773d1e | |||
| bc5fd46671 | |||
| a00745dd38 | |||
| 01908c84d3 | |||
| 39959c3f47 | |||
| 53cc96b84d | |||
| 1aeb336898 | |||
| 1715c591a5 | |||
| eb41506bf4 | |||
| 9fdcd49c98 | |||
| 8538f014aa | |||
| 848cd03068 | |||
| 4debf4654d | |||
| 6ed2be4173 | |||
| 557de11bb3 | |||
| 97d917e35d | |||
| 9b2666e671 | |||
| c883556dbf | |||
| 5bcd0f64d2 | |||
| d53167c48e | |||
| c2b212c1ce | |||
| d1510b8ba3 | |||
| 273feef017 | |||
| 11a42bf8a1 | |||
| d436efdc66 | |||
| 30f7bd7556 | |||
| 62007862a5 | |||
| 2786e45c75 | |||
| 46c3a02655 | |||
| 5ca07ef049 | |||
| c715c890b5 | |||
| 9925a954d1 | |||
| 0c111aaf47 | |||
| 2a2315ecbc | |||
| 17f77351ad | |||
| abd084cff8 | |||
| e77d0a1d5b | |||
| 991d70f22b | |||
| f477b563a2 | |||
| 63a7c28e96 | |||
| 2b2cb66dfc | |||
| bf5cc1d7bb | |||
| bbf94b8a0d | |||
| 951d7cf561 | |||
| 40337c9590 |
@@ -2,6 +2,7 @@ package kr.co.vividnext.sodalive.di
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import com.google.gson.GsonBuilder
|
import com.google.gson.GsonBuilder
|
||||||
|
import com.orhanobut.logger.Logger
|
||||||
import kr.co.vividnext.sodalive.BuildConfig
|
import kr.co.vividnext.sodalive.BuildConfig
|
||||||
import kr.co.vividnext.sodalive.agora.v2v.V2vApi
|
import kr.co.vividnext.sodalive.agora.v2v.V2vApi
|
||||||
import kr.co.vividnext.sodalive.agora.v2v.V2vRepository
|
import kr.co.vividnext.sodalive.agora.v2v.V2vRepository
|
||||||
@@ -349,7 +350,14 @@ class AppDI(private val context: Context, isDebugMode: Boolean) {
|
|||||||
single { ApiBuilder().build(get(), CharacterCommentApi::class.java) }
|
single { ApiBuilder().build(get(), CharacterCommentApi::class.java) }
|
||||||
single { ApiBuilder().build(get(), OriginalWorkApi::class.java) }
|
single { ApiBuilder().build(get(), OriginalWorkApi::class.java) }
|
||||||
single { ApiBuilder().build(get<Retrofit>(named("agoraRetrofit")), V2vApi::class.java) }
|
single { ApiBuilder().build(get<Retrofit>(named("agoraRetrofit")), V2vApi::class.java) }
|
||||||
single { DmChatSocketClient(okHttpClient = get(), gson = get(), baseUrl = baseUrl) }
|
single {
|
||||||
|
DmChatSocketClient(
|
||||||
|
okHttpClient = get(),
|
||||||
|
gson = get(),
|
||||||
|
baseUrl = baseUrl,
|
||||||
|
rawJsonLogger = { rawJson -> Logger.d("DM WebSocket raw JSON: $rawJson") }
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private val viewModelModule = module {
|
private val viewModelModule = module {
|
||||||
|
|||||||
@@ -2,13 +2,11 @@ package kr.co.vividnext.sodalive.splash
|
|||||||
|
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.net.Uri
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.os.Handler
|
import android.os.Handler
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
import android.view.View
|
|
||||||
import androidx.annotation.OptIn
|
import androidx.annotation.OptIn
|
||||||
import androidx.constraintlayout.widget.ConstraintLayout
|
import androidx.core.net.toUri
|
||||||
import androidx.media3.common.util.UnstableApi
|
import androidx.media3.common.util.UnstableApi
|
||||||
import com.google.firebase.ktx.Firebase
|
import com.google.firebase.ktx.Firebase
|
||||||
import com.google.firebase.remoteconfig.ktx.get
|
import com.google.firebase.remoteconfig.ktx.get
|
||||||
@@ -31,40 +29,10 @@ class SplashActivity : BaseActivity<ActivitySplashBinding>(ActivitySplashBinding
|
|||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
setLayoutParams(binding.llBottom, start = 0, end = 0, top = 0, bottom = 68)
|
|
||||||
|
|
||||||
setupRemoteConfig()
|
setupRemoteConfig()
|
||||||
fetchAndroidLatestVersion()
|
fetchAndroidLatestVersion()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun setLayoutParams(
|
|
||||||
view: View,
|
|
||||||
start: Int = 0,
|
|
||||||
end: Int = 0,
|
|
||||||
top: Int = 0,
|
|
||||||
bottom: Int = 0
|
|
||||||
) {
|
|
||||||
val lp = view.layoutParams as ConstraintLayout.LayoutParams
|
|
||||||
|
|
||||||
if (top > 0) {
|
|
||||||
lp.topMargin = screenHeight * top / 2337
|
|
||||||
}
|
|
||||||
|
|
||||||
if (bottom > 0) {
|
|
||||||
lp.bottomMargin = screenHeight * bottom / 2337
|
|
||||||
}
|
|
||||||
|
|
||||||
if (start > 0) {
|
|
||||||
lp.leftMargin = screenWidth * start / 1080
|
|
||||||
}
|
|
||||||
|
|
||||||
if (end > 0) {
|
|
||||||
lp.rightMargin = screenWidth * end / 1080
|
|
||||||
}
|
|
||||||
|
|
||||||
view.layoutParams = lp
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun setupRemoteConfig() {
|
private fun setupRemoteConfig() {
|
||||||
val configSettings = remoteConfigSettings {
|
val configSettings = remoteConfigSettings {
|
||||||
minimumFetchIntervalInSeconds = 60
|
minimumFetchIntervalInSeconds = 60
|
||||||
@@ -145,7 +113,7 @@ class SplashActivity : BaseActivity<ActivitySplashBinding>(ActivitySplashBinding
|
|||||||
startActivity(
|
startActivity(
|
||||||
Intent(
|
Intent(
|
||||||
Intent.ACTION_VIEW,
|
Intent.ACTION_VIEW,
|
||||||
Uri.parse("market://details?id=${BuildConfig.APPLICATION_ID}")
|
"market://details?id=${BuildConfig.APPLICATION_ID}".toUri()
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
finish()
|
finish()
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import com.bumptech.glide.Glide
|
|||||||
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.databinding.ActivityCreatorChannelCommunityDetailBinding
|
import kr.co.vividnext.sodalive.databinding.ActivityCreatorChannelCommunityDetailBinding
|
||||||
|
import kr.co.vividnext.sodalive.explorer.profile.creator_community.all.PurchaseCommunityPostDialog
|
||||||
import kr.co.vividnext.sodalive.explorer.profile.creator_community.all.player.CreatorCommunityContentItem
|
import kr.co.vividnext.sodalive.explorer.profile.creator_community.all.player.CreatorCommunityContentItem
|
||||||
import kr.co.vividnext.sodalive.explorer.profile.creator_community.all.player.CreatorCommunityMediaPlayerManager
|
import kr.co.vividnext.sodalive.explorer.profile.creator_community.all.player.CreatorCommunityMediaPlayerManager
|
||||||
import kr.co.vividnext.sodalive.extensions.loadUrl
|
import kr.co.vividnext.sodalive.extensions.loadUrl
|
||||||
@@ -46,7 +47,7 @@ class CreatorChannelCommunityDetailActivity : BaseActivity<ActivityCreatorChanne
|
|||||||
private var renderedEditingCommentId: Long? = null
|
private var renderedEditingCommentId: Long? = null
|
||||||
|
|
||||||
override fun setupView() {
|
override fun setupView() {
|
||||||
mediaPlayerManager = CreatorCommunityMediaPlayerManager(this) { }
|
mediaPlayerManager = CreatorCommunityMediaPlayerManager(this) { renderCurrentAudioButton() }
|
||||||
postId = intent.getLongExtra(EXTRA_POST_ID, 0L)
|
postId = intent.getLongExtra(EXTRA_POST_ID, 0L)
|
||||||
if (postId <= 0L) {
|
if (postId <= 0L) {
|
||||||
Toast.makeText(this, R.string.creator_channel_community_invalid_post, Toast.LENGTH_SHORT).show()
|
Toast.makeText(this, R.string.creator_channel_community_invalid_post, Toast.LENGTH_SHORT).show()
|
||||||
@@ -139,12 +140,15 @@ class CreatorChannelCommunityDetailActivity : BaseActivity<ActivityCreatorChanne
|
|||||||
tvCreatorChannelCommunityDetailNickname.text = post.creatorNickname
|
tvCreatorChannelCommunityDetailNickname.text = post.creatorNickname
|
||||||
tvCreatorChannelCommunityDetailTime.text = post.createdAtText
|
tvCreatorChannelCommunityDetailTime.text = post.createdAtText
|
||||||
tvCreatorChannelCommunityDetailBody.text = post.content
|
tvCreatorChannelCommunityDetailBody.text = post.content
|
||||||
|
layoutCreatorChannelCommunityDetailReaction.isVisible = post.showReaction || content.isCommentAvailable
|
||||||
tvCreatorChannelCommunityDetailLikeCount.text = post.likeCount.moneyFormat()
|
tvCreatorChannelCommunityDetailLikeCount.text = post.likeCount.moneyFormat()
|
||||||
tvCreatorChannelCommunityDetailCommentCount.text = post.commentCount.moneyFormat()
|
tvCreatorChannelCommunityDetailCommentCount.text = post.commentCount.moneyFormat()
|
||||||
tvCreatorChannelCommunityDetailCommentHeader.text = getString(
|
tvCreatorChannelCommunityDetailCommentHeader.text = getString(
|
||||||
R.string.creator_channel_community_comment_header,
|
R.string.creator_channel_community_comment_header,
|
||||||
post.commentCount.moneyFormat()
|
post.commentCount.moneyFormat()
|
||||||
)
|
)
|
||||||
|
ivCreatorChannelCommunityDetailHeart.isVisible = post.showReaction
|
||||||
|
tvCreatorChannelCommunityDetailLikeCount.isVisible = post.showReaction
|
||||||
ivCreatorChannelCommunityDetailHeart.setImageResource(
|
ivCreatorChannelCommunityDetailHeart.setImageResource(
|
||||||
if (post.isLiked) R.drawable.ic_feed_community_heart_fill else R.drawable.ic_feed_community_heart
|
if (post.isLiked) R.drawable.ic_feed_community_heart_fill else R.drawable.ic_feed_community_heart
|
||||||
)
|
)
|
||||||
@@ -154,6 +158,9 @@ class CreatorChannelCommunityDetailActivity : BaseActivity<ActivityCreatorChanne
|
|||||||
layoutCreatorChannelCommunityDetailImageContainer.isVisible = post.imageUrl != null || post.showPaywall
|
layoutCreatorChannelCommunityDetailImageContainer.isVisible = post.imageUrl != null || post.showPaywall
|
||||||
layoutCreatorChannelCommunityDetailPaywall.isVisible = post.showPaywall
|
layoutCreatorChannelCommunityDetailPaywall.isVisible = post.showPaywall
|
||||||
tvCreatorChannelCommunityDetailPaywallPrice.text = post.price.moneyFormat()
|
tvCreatorChannelCommunityDetailPaywallPrice.text = post.price.moneyFormat()
|
||||||
|
layoutCreatorChannelCommunityDetailPaywall.setOnClickListener {
|
||||||
|
showPurchaseDialog(post)
|
||||||
|
}
|
||||||
if (post.imageUrl != null) {
|
if (post.imageUrl != null) {
|
||||||
Glide.with(ivCreatorChannelCommunityDetailImage)
|
Glide.with(ivCreatorChannelCommunityDetailImage)
|
||||||
.load(post.imageUrl)
|
.load(post.imageUrl)
|
||||||
@@ -162,13 +169,17 @@ class CreatorChannelCommunityDetailActivity : BaseActivity<ActivityCreatorChanne
|
|||||||
} else {
|
} else {
|
||||||
Glide.with(ivCreatorChannelCommunityDetailImage).clear(ivCreatorChannelCommunityDetailImage)
|
Glide.with(ivCreatorChannelCommunityDetailImage).clear(ivCreatorChannelCommunityDetailImage)
|
||||||
}
|
}
|
||||||
tvCreatorChannelCommunityDetailAudio.isVisible = post.audioUrl != null
|
ivCreatorChannelCommunityDetailPlayOrPause.isVisible = post.audioUrl != null && post.imageUrl != null
|
||||||
|
ivCreatorChannelCommunityDetailPlayOrPause.setOnClickListener { toggleAudio(post) }
|
||||||
|
tvCreatorChannelCommunityDetailAudio.isVisible = post.audioUrl != null && post.imageUrl == null
|
||||||
tvCreatorChannelCommunityDetailAudio.text = getString(R.string.creator_channel_tab_audio)
|
tvCreatorChannelCommunityDetailAudio.text = getString(R.string.creator_channel_tab_audio)
|
||||||
tvCreatorChannelCommunityDetailAudio.setOnClickListener {
|
tvCreatorChannelCommunityDetailAudio.setOnClickListener {
|
||||||
toggleAudio(post)
|
toggleAudio(post)
|
||||||
}
|
}
|
||||||
|
renderAudioButton(post)
|
||||||
rvCreatorChannelCommunityDetailComments.isVisible = content.isCommentAvailable
|
rvCreatorChannelCommunityDetailComments.isVisible = content.isCommentAvailable
|
||||||
tvCreatorChannelCommunityDetailCommentHeader.isVisible = content.isCommentAvailable
|
tvCreatorChannelCommunityDetailCommentHeader.isVisible = content.isCommentAvailable
|
||||||
|
viewCreatorChannelCommunityDetailCommentDivider.isVisible = content.isCommentAvailable
|
||||||
layoutCreatorChannelCommunityDetailInputBar.isVisible = content.isCommentInputVisible
|
layoutCreatorChannelCommunityDetailInputBar.isVisible = content.isCommentInputVisible
|
||||||
ivCreatorChannelCommunityDetailComment.isVisible = content.isCommentAvailable
|
ivCreatorChannelCommunityDetailComment.isVisible = content.isCommentAvailable
|
||||||
tvCreatorChannelCommunityDetailCommentCount.isVisible = content.isCommentAvailable
|
tvCreatorChannelCommunityDetailCommentCount.isVisible = content.isCommentAvailable
|
||||||
@@ -200,6 +211,32 @@ class CreatorChannelCommunityDetailActivity : BaseActivity<ActivityCreatorChanne
|
|||||||
private fun toggleAudio(post: CreatorChannelCommunityPostDetailUiModel) {
|
private fun toggleAudio(post: CreatorChannelCommunityPostDetailUiModel) {
|
||||||
val audioUrl = post.audioUrl ?: return
|
val audioUrl = post.audioUrl ?: return
|
||||||
mediaPlayerManager.toggleContent(CreatorCommunityContentItem(post.postId, audioUrl))
|
mediaPlayerManager.toggleContent(CreatorCommunityContentItem(post.postId, audioUrl))
|
||||||
|
renderAudioButton(post)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun renderCurrentAudioButton() {
|
||||||
|
val content = viewModel.detailStateLiveData.value as? CreatorChannelCommunityDetailUiState.Content ?: return
|
||||||
|
renderAudioButton(content.post)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun renderAudioButton(post: CreatorChannelCommunityPostDetailUiModel) {
|
||||||
|
binding.ivCreatorChannelCommunityDetailPlayOrPause.setImageResource(
|
||||||
|
if (mediaPlayerManager.isPlayingContent(post.postId)) {
|
||||||
|
R.drawable.btn_audio_content_pause
|
||||||
|
} else {
|
||||||
|
R.drawable.btn_audio_content_play
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showPurchaseDialog(post: CreatorChannelCommunityPostDetailUiModel) {
|
||||||
|
if (!post.isLocked) return
|
||||||
|
PurchaseCommunityPostDialog(
|
||||||
|
activity = this,
|
||||||
|
layoutInflater = layoutInflater,
|
||||||
|
can = post.price,
|
||||||
|
confirmButtonClick = { viewModel.purchaseCommunityPost() }
|
||||||
|
).show(screenWidth)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun showDeleteCommentDialog(commentId: Long) {
|
private fun showDeleteCommentDialog(commentId: Long) {
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ class CreatorChannelCommunityDetailViewModel(
|
|||||||
private var isTogglingLike = false
|
private var isTogglingLike = false
|
||||||
private var isSendingComment = false
|
private var isSendingComment = false
|
||||||
private var isModifyingComment = false
|
private var isModifyingComment = false
|
||||||
|
private var isPurchasingPost = false
|
||||||
|
|
||||||
fun loadDetail(postId: Long) {
|
fun loadDetail(postId: Long) {
|
||||||
if (postId <= 0) return
|
if (postId <= 0) return
|
||||||
@@ -217,6 +218,33 @@ class CreatorChannelCommunityDetailViewModel(
|
|||||||
modifyComment(ModifyCommentRequest(commentId = commentId, isActive = false))
|
modifyComment(ModifyCommentRequest(commentId = commentId, isActive = false))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun purchaseCommunityPost() {
|
||||||
|
val content = _detailStateLiveData.value as? CreatorChannelCommunityDetailUiState.Content ?: return
|
||||||
|
if (!content.post.isLocked || isPurchasingPost) return
|
||||||
|
|
||||||
|
isPurchasingPost = true
|
||||||
|
compositeDisposable.add(
|
||||||
|
legacyRepository.purchaseCommunityPost(postId, authToken())
|
||||||
|
.subscribeOn(Schedulers.io())
|
||||||
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
|
.subscribe(
|
||||||
|
{ response ->
|
||||||
|
isPurchasingPost = false
|
||||||
|
if (response.success) {
|
||||||
|
_postChangedEventLiveData.value = true
|
||||||
|
loadDetail(postId)
|
||||||
|
} else {
|
||||||
|
showMutationFailureToast(response.message)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
isPurchasingPost = false
|
||||||
|
showMutationFailureToast()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private fun submitCommentEdit(
|
private fun submitCommentEdit(
|
||||||
content: CreatorChannelCommunityDetailUiState.Content,
|
content: CreatorChannelCommunityDetailUiState.Content,
|
||||||
commentId: Long,
|
commentId: Long,
|
||||||
@@ -276,18 +304,20 @@ class CreatorChannelCommunityDetailViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val isCreatorOwner = data.creatorId == SharedPreferenceManager.userId
|
val isCreatorOwner = data.creatorId == SharedPreferenceManager.userId
|
||||||
val comments = if (data.isCommentAvailable) {
|
val post = data.toUiModel()
|
||||||
|
val isCommentVisible = data.isCommentAvailable && !post.isLocked
|
||||||
|
val comments = if (isCommentVisible) {
|
||||||
data.comments.comments.map { it.toUiModel(isCreatorOwner) }
|
data.comments.comments.map { it.toUiModel(isCreatorOwner) }
|
||||||
} else {
|
} else {
|
||||||
emptyList()
|
emptyList()
|
||||||
}
|
}
|
||||||
_detailStateLiveData.value = CreatorChannelCommunityDetailUiState.Content(
|
_detailStateLiveData.value = CreatorChannelCommunityDetailUiState.Content(
|
||||||
post = data.toUiModel(),
|
post = post,
|
||||||
isCommentAvailable = data.isCommentAvailable,
|
isCommentAvailable = isCommentVisible,
|
||||||
isCommentInputVisible = data.isCommentAvailable,
|
isCommentInputVisible = isCommentVisible,
|
||||||
comments = comments,
|
comments = comments,
|
||||||
commentPage = data.comments.page,
|
commentPage = data.comments.page,
|
||||||
hasNextComment = data.isCommentAvailable && data.comments.hasNext
|
hasNextComment = isCommentVisible && data.comments.hasNext
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -411,7 +441,8 @@ class CreatorChannelCommunityDetailViewModel(
|
|||||||
isLiked = isLiked,
|
isLiked = isLiked,
|
||||||
isPinned = isPinned,
|
isPinned = isPinned,
|
||||||
isLocked = isLocked(),
|
isLocked = isLocked(),
|
||||||
showPaywall = isLocked()
|
showPaywall = isLocked(),
|
||||||
|
showReaction = !isLocked()
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun CreatorChannelCommunityPostDetailResponse.isLocked(): Boolean {
|
private fun CreatorChannelCommunityPostDetailResponse.isLocked(): Boolean {
|
||||||
@@ -496,7 +527,8 @@ data class CreatorChannelCommunityPostDetailUiModel(
|
|||||||
val isLiked: Boolean,
|
val isLiked: Boolean,
|
||||||
val isPinned: Boolean,
|
val isPinned: Boolean,
|
||||||
val isLocked: Boolean,
|
val isLocked: Boolean,
|
||||||
val showPaywall: Boolean
|
val showPaywall: Boolean,
|
||||||
|
val showReaction: Boolean
|
||||||
)
|
)
|
||||||
|
|
||||||
data class CreatorChannelCommunityCommentUiModel(
|
data class CreatorChannelCommunityCommentUiModel(
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ class CreatorChannelCommunityCommentAdapter(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
|
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
|
||||||
holder.bind(items[position])
|
holder.bind(items[position], isLastItem = position == items.lastIndex)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getItemCount(): Int = items.size
|
override fun getItemCount(): Int = items.size
|
||||||
@@ -46,8 +46,9 @@ class CreatorChannelCommunityCommentAdapter(
|
|||||||
private val onDeleteClick: (CreatorChannelCommunityCommentUiModel) -> Unit
|
private val onDeleteClick: (CreatorChannelCommunityCommentUiModel) -> Unit
|
||||||
) : RecyclerView.ViewHolder(binding.root) {
|
) : RecyclerView.ViewHolder(binding.root) {
|
||||||
|
|
||||||
fun bind(item: CreatorChannelCommunityCommentUiModel) {
|
fun bind(item: CreatorChannelCommunityCommentUiModel, isLastItem: Boolean) {
|
||||||
bindComment(binding, item, onReplyClick)
|
bindComment(binding, item, onReplyClick)
|
||||||
|
binding.viewCreatorChannelCommunityCommentDivider.isVisible = !isLastItem
|
||||||
binding.ivCreatorChannelCommunityCommentMore.isVisible = item.canShowMore
|
binding.ivCreatorChannelCommunityCommentMore.isVisible = item.canShowMore
|
||||||
binding.ivCreatorChannelCommunityCommentMore.setOnClickListener { anchor ->
|
binding.ivCreatorChannelCommunityCommentMore.setOnClickListener { anchor ->
|
||||||
showOptionMenu(anchor, item, onModifyClick, onDeleteClick)
|
showOptionMenu(anchor, item, onModifyClick, onDeleteClick)
|
||||||
|
|||||||
@@ -35,7 +35,8 @@ private fun CreatorChannelCommunityPostResponse.toCommunityPostUiModel(
|
|||||||
existOrdered = existOrdered,
|
existOrdered = existOrdered,
|
||||||
likeCount = likeCount,
|
likeCount = likeCount,
|
||||||
commentCount = commentCount,
|
commentCount = commentCount,
|
||||||
showComment = isCommentAvailable,
|
showReaction = !isLocked,
|
||||||
|
showComment = isCommentAvailable && !isLocked,
|
||||||
showNotice = isPinned,
|
showNotice = isPinned,
|
||||||
isPinned = isPinned,
|
isPinned = isPinned,
|
||||||
isLocked = isLocked,
|
isLocked = isLocked,
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ data class CreatorChannelCommunityPostUiModel(
|
|||||||
val existOrdered: Boolean,
|
val existOrdered: Boolean,
|
||||||
val likeCount: Int,
|
val likeCount: Int,
|
||||||
val commentCount: Int,
|
val commentCount: Int,
|
||||||
|
val showReaction: Boolean,
|
||||||
val showComment: Boolean,
|
val showComment: Boolean,
|
||||||
val showNotice: Boolean,
|
val showNotice: Boolean,
|
||||||
val isPinned: Boolean,
|
val isPinned: Boolean,
|
||||||
|
|||||||
@@ -76,8 +76,6 @@ class CreatorChannelCommunityGridAdapter(
|
|||||||
!item.isLocked && item.imageMode != CreatorChannelCommunityImageMode.Image
|
!item.isLocked && item.imageMode != CreatorChannelCommunityImageMode.Image
|
||||||
tvCreatorChannelCommunityGridTextPreview.text = item.gridPreviewText
|
tvCreatorChannelCommunityGridTextPreview.text = item.gridPreviewText
|
||||||
layoutCreatorChannelCommunityGridLockedOverlay.isVisible = item.isLocked
|
layoutCreatorChannelCommunityGridLockedOverlay.isVisible = item.isLocked
|
||||||
ivCreatorChannelCommunityGridLock.isVisible = item.isLocked
|
|
||||||
tvCreatorChannelCommunityGridLockPrice.isVisible = item.isLocked
|
|
||||||
tvCreatorChannelCommunityGridLockPrice.text = item.price.moneyFormat()
|
tvCreatorChannelCommunityGridLockPrice.text = item.price.moneyFormat()
|
||||||
ivCreatorChannelCommunityGridNotice.isVisible = item.showNotice
|
ivCreatorChannelCommunityGridNotice.isVisible = item.showNotice
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,10 +65,13 @@ class CreatorChannelCommunityListAdapter(
|
|||||||
tvCreatorChannelCommunityListTime.text = item.createdAtText
|
tvCreatorChannelCommunityListTime.text = item.createdAtText
|
||||||
layoutCreatorChannelCommunityListNotice.isVisible = item.showNotice
|
layoutCreatorChannelCommunityListNotice.isVisible = item.showNotice
|
||||||
tvCreatorChannelCommunityListBody.text = item.content
|
tvCreatorChannelCommunityListBody.text = item.content
|
||||||
|
layoutCreatorChannelCommunityListReaction.isVisible = item.showReaction || item.showComment
|
||||||
tvCreatorChannelCommunityListCommentCount.text = item.commentCount.moneyFormat()
|
tvCreatorChannelCommunityListCommentCount.text = item.commentCount.moneyFormat()
|
||||||
tvCreatorChannelCommunityListCommentCount.isVisible = item.showComment
|
tvCreatorChannelCommunityListCommentCount.isVisible = item.showComment
|
||||||
ivCreatorChannelCommunityListComment.isVisible = item.showComment
|
ivCreatorChannelCommunityListComment.isVisible = item.showComment
|
||||||
tvCreatorChannelCommunityListLikeCount.text = item.likeCount.moneyFormat()
|
tvCreatorChannelCommunityListLikeCount.text = item.likeCount.moneyFormat()
|
||||||
|
tvCreatorChannelCommunityListLikeCount.isVisible = item.showReaction
|
||||||
|
ivCreatorChannelCommunityListLike.isVisible = item.showReaction
|
||||||
|
|
||||||
val visibleImageUrl = item.imageUrl.takeUnless { item.isLocked }
|
val visibleImageUrl = item.imageUrl.takeUnless { item.isLocked }
|
||||||
layoutCreatorChannelCommunityListImageContainer.isVisible =
|
layoutCreatorChannelCommunityListImageContainer.isVisible =
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import kr.co.vividnext.sodalive.v2.creator.channel.model.toSortOptionUiModel
|
|||||||
class CreatorChannelSortPopup(
|
class CreatorChannelSortPopup(
|
||||||
private val anchor: View,
|
private val anchor: View,
|
||||||
private val selectedSort: ContentSort,
|
private val selectedSort: ContentSort,
|
||||||
|
private val sortOptions: List<ContentSort> = ContentSort.entries,
|
||||||
private val onSortSelected: (ContentSort) -> Unit
|
private val onSortSelected: (ContentSort) -> Unit
|
||||||
) {
|
) {
|
||||||
|
|
||||||
@@ -61,7 +62,7 @@ class CreatorChannelSortPopup(
|
|||||||
val sample = view.findViewById<TextView>(R.id.tv_creator_channel_sort_option_sample)
|
val sample = view.findViewById<TextView>(R.id.tv_creator_channel_sort_option_sample)
|
||||||
container.removeView(sample)
|
container.removeView(sample)
|
||||||
|
|
||||||
ContentSort.entries.map { it.toSortOptionUiModel(selectedSort) }.forEach { option ->
|
sortOptions.map { it.toSortOptionUiModel(selectedSort) }.forEach { option ->
|
||||||
val row = TextView(anchor.context).apply {
|
val row = TextView(anchor.context).apply {
|
||||||
layoutParams = LinearLayout.LayoutParams(
|
layoutParams = LinearLayout.LayoutParams(
|
||||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import android.view.View
|
|||||||
import android.widget.ImageView
|
import android.widget.ImageView
|
||||||
import android.widget.LinearLayout
|
import android.widget.LinearLayout
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
|
import androidx.core.view.isVisible
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager
|
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
|
||||||
@@ -21,6 +22,7 @@ import kr.co.vividnext.sodalive.v2.main.chat.model.ChatRoomFilter
|
|||||||
import kr.co.vividnext.sodalive.v2.main.chat.model.ChatRoomListUiItem
|
import kr.co.vividnext.sodalive.v2.main.chat.model.ChatRoomListUiItem
|
||||||
import kr.co.vividnext.sodalive.v2.main.chat.model.ChatRoomListUiState
|
import kr.co.vividnext.sodalive.v2.main.chat.model.ChatRoomListUiState
|
||||||
import kr.co.vividnext.sodalive.v2.main.chat.model.ChatRoomType
|
import kr.co.vividnext.sodalive.v2.main.chat.model.ChatRoomType
|
||||||
|
import kr.co.vividnext.sodalive.v2.main.chat.model.emptyMessageResId
|
||||||
import kr.co.vividnext.sodalive.v2.main.chat.ui.ChatRoomListAdapter
|
import kr.co.vividnext.sodalive.v2.main.chat.ui.ChatRoomListAdapter
|
||||||
import org.koin.androidx.viewmodel.ext.android.viewModel
|
import org.koin.androidx.viewmodel.ext.android.viewModel
|
||||||
|
|
||||||
@@ -44,22 +46,25 @@ class ChatMainFragment : BaseFragment<FragmentV2MainChatBinding>(
|
|||||||
private val viewModel: ChatMainViewModel by viewModel()
|
private val viewModel: ChatMainViewModel by viewModel()
|
||||||
private val chatRoomListAdapter = ChatRoomListAdapter { onChatRoomClick(it) }
|
private val chatRoomListAdapter = ChatRoomListAdapter { onChatRoomClick(it) }
|
||||||
private lateinit var loadingDialog: LoadingDialog
|
private lateinit var loadingDialog: LoadingDialog
|
||||||
|
private var selectedFilter: ChatRoomFilter = ChatRoomFilter.ALL
|
||||||
|
|
||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
super.onViewCreated(view, savedInstanceState)
|
super.onViewCreated(view, savedInstanceState)
|
||||||
loadingDialog = LoadingDialog(requireActivity(), layoutInflater)
|
loadingDialog = LoadingDialog(requireActivity(), layoutInflater)
|
||||||
|
|
||||||
setupTitleBar()
|
setupTitleBar()
|
||||||
val initialFilter = getInitialFilter()
|
val initialFilter = getInitialFilter() ?: ChatRoomFilter.ALL
|
||||||
setupFilterTabs(initialFilter ?: ChatRoomFilter.ALL)
|
selectedFilter = initialFilter
|
||||||
|
setupFilterTabs(initialFilter)
|
||||||
setupChatRooms()
|
setupChatRooms()
|
||||||
bindViewModel()
|
bindViewModel()
|
||||||
|
|
||||||
binding.btnChatFloating.setOnClickListener { }
|
binding.btnChatFloating.setOnClickListener { }
|
||||||
viewModel.loadFirstPage(initialFilter ?: ChatRoomFilter.ALL)
|
viewModel.loadFirstPage(initialFilter)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun selectFilter(filter: ChatRoomFilter) {
|
fun selectFilter(filter: ChatRoomFilter) {
|
||||||
|
selectedFilter = filter
|
||||||
selectFilterTab(filter)
|
selectFilterTab(filter)
|
||||||
viewModel.selectFilter(filter)
|
viewModel.selectFilter(filter)
|
||||||
}
|
}
|
||||||
@@ -102,7 +107,9 @@ class ChatMainFragment : BaseFragment<FragmentV2MainChatBinding>(
|
|||||||
selectedIndex = initialFilter.tabIndex
|
selectedIndex = initialFilter.tabIndex
|
||||||
)
|
)
|
||||||
binding.viewChatFilterTabs.root.setOnTabSelectedListener { index ->
|
binding.viewChatFilterTabs.root.setOnTabSelectedListener { index ->
|
||||||
viewModel.selectFilter(ChatRoomFilter.fromTabIndex(index))
|
val filter = ChatRoomFilter.fromTabIndex(index)
|
||||||
|
selectedFilter = filter
|
||||||
|
viewModel.selectFilter(filter)
|
||||||
binding.rvChatRooms.scrollToPosition(0)
|
binding.rvChatRooms.scrollToPosition(0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -130,14 +137,14 @@ class ChatMainFragment : BaseFragment<FragmentV2MainChatBinding>(
|
|||||||
viewModel.chatRoomStateLiveData.observe(viewLifecycleOwner) { state ->
|
viewModel.chatRoomStateLiveData.observe(viewLifecycleOwner) { state ->
|
||||||
when (state) {
|
when (state) {
|
||||||
is ChatRoomListUiState.Content -> {
|
is ChatRoomListUiState.Content -> {
|
||||||
chatRoomListAdapter.submitItems(state.items)
|
bindChatRooms(state.items, showEmpty = false)
|
||||||
if (!state.isAppending) {
|
if (!state.isAppending) {
|
||||||
binding.rvChatRooms.scrollToPosition(0)
|
binding.rvChatRooms.scrollToPosition(0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ChatRoomListUiState.Empty -> chatRoomListAdapter.submitItems(emptyList())
|
ChatRoomListUiState.Empty -> bindChatRooms(emptyList(), showEmpty = true)
|
||||||
is ChatRoomListUiState.Error -> chatRoomListAdapter.submitItems(emptyList())
|
is ChatRoomListUiState.Error -> bindChatRooms(emptyList(), showEmpty = false)
|
||||||
ChatRoomListUiState.Loading -> Unit
|
ChatRoomListUiState.Loading -> binding.tvChatEmptyMessage.isVisible = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
viewModel.isLoading.observe(viewLifecycleOwner) {
|
viewModel.isLoading.observe(viewLifecycleOwner) {
|
||||||
@@ -155,6 +162,15 @@ class ChatMainFragment : BaseFragment<FragmentV2MainChatBinding>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun bindChatRooms(items: List<ChatRoomListUiItem>, showEmpty: Boolean) {
|
||||||
|
chatRoomListAdapter.submitItems(items)
|
||||||
|
binding.rvChatRooms.isVisible = !showEmpty
|
||||||
|
binding.tvChatEmptyMessage.isVisible = showEmpty
|
||||||
|
if (showEmpty) {
|
||||||
|
binding.tvChatEmptyMessage.setText(selectedFilter.emptyMessageResId())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun onChatRoomClick(item: ChatRoomListUiItem) {
|
private fun onChatRoomClick(item: ChatRoomListUiItem) {
|
||||||
when (item.chatType) {
|
when (item.chatType) {
|
||||||
ChatRoomType.AI -> startActivity(ChatRoomActivity.newIntent(requireContext(), item.roomId))
|
ChatRoomType.AI -> startActivity(ChatRoomActivity.newIntent(requireContext(), item.roomId))
|
||||||
|
|||||||
@@ -4,15 +4,15 @@ import android.content.Context
|
|||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.text.Editable
|
import android.text.Editable
|
||||||
import android.text.TextWatcher
|
import android.text.TextWatcher
|
||||||
import android.view.View
|
|
||||||
import android.view.inputmethod.EditorInfo
|
import android.view.inputmethod.EditorInfo
|
||||||
import android.view.inputmethod.InputMethodManager
|
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import androidx.recyclerview.widget.SimpleItemAnimator
|
import androidx.recyclerview.widget.SimpleItemAnimator
|
||||||
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.LoadingDialog
|
||||||
|
import kr.co.vividnext.sodalive.common.SharedPreferenceManager
|
||||||
import kr.co.vividnext.sodalive.databinding.ActivityDmChatRoomBinding
|
import kr.co.vividnext.sodalive.databinding.ActivityDmChatRoomBinding
|
||||||
import kr.co.vividnext.sodalive.extensions.loadUrl
|
import kr.co.vividnext.sodalive.extensions.loadUrl
|
||||||
import kr.co.vividnext.sodalive.v2.main.chat.dm.model.DmChatRoomUiState
|
import kr.co.vividnext.sodalive.v2.main.chat.dm.model.DmChatRoomUiState
|
||||||
@@ -25,6 +25,7 @@ class DmChatRoomActivity : BaseActivity<ActivityDmChatRoomBinding>(
|
|||||||
|
|
||||||
private val viewModel: DmChatRoomViewModel by viewModel()
|
private val viewModel: DmChatRoomViewModel by viewModel()
|
||||||
private val messageAdapter = DmChatMessageAdapter { localId -> viewModel.retry(localId) }
|
private val messageAdapter = DmChatMessageAdapter { localId -> viewModel.retry(localId) }
|
||||||
|
private val loadingDialog: LoadingDialog by lazy { LoadingDialog(this, layoutInflater) }
|
||||||
private lateinit var layoutManager: LinearLayoutManager
|
private lateinit var layoutManager: LinearLayoutManager
|
||||||
private var roomId: Long = 0L
|
private var roomId: Long = 0L
|
||||||
private var creatorId: Long = 0L
|
private var creatorId: Long = 0L
|
||||||
@@ -119,6 +120,9 @@ class DmChatRoomActivity : BaseActivity<ActivityDmChatRoomBinding>(
|
|||||||
viewModel.roomOpenedEventLiveData.observe(this) {
|
viewModel.roomOpenedEventLiveData.observe(this) {
|
||||||
if (it.consume() == true) connectRealtimeIfStarted()
|
if (it.consume() == true) connectRealtimeIfStarted()
|
||||||
}
|
}
|
||||||
|
viewModel.isRealtimeConnectingLiveData.observe(this) { isConnecting ->
|
||||||
|
updateRealtimeLoadingDialog(isConnecting)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun bindContent(state: DmChatRoomUiState.Content) {
|
private fun bindContent(state: DmChatRoomUiState.Content) {
|
||||||
@@ -129,7 +133,7 @@ class DmChatRoomActivity : BaseActivity<ActivityDmChatRoomBinding>(
|
|||||||
error(R.drawable.ic_placeholder_profile)
|
error(R.drawable.ic_placeholder_profile)
|
||||||
transformations(CircleCropTransformation())
|
transformations(CircleCropTransformation())
|
||||||
}
|
}
|
||||||
binding.ivBackgroundProfile.loadUrl(state.opponentProfileImageUrl) {
|
binding.ivBackgroundProfile.loadUrl(SharedPreferenceManager.profileImage) {
|
||||||
placeholder(R.drawable.ic_placeholder_profile)
|
placeholder(R.drawable.ic_placeholder_profile)
|
||||||
error(R.drawable.ic_placeholder_profile)
|
error(R.drawable.ic_placeholder_profile)
|
||||||
}
|
}
|
||||||
@@ -143,10 +147,10 @@ class DmChatRoomActivity : BaseActivity<ActivityDmChatRoomBinding>(
|
|||||||
val text = binding.etMessage.text?.toString().orEmpty()
|
val text = binding.etMessage.text?.toString().orEmpty()
|
||||||
if (text.isBlank()) return
|
if (text.isBlank()) return
|
||||||
|
|
||||||
viewModel.sendText(text)
|
if (viewModel.sendText(text)) {
|
||||||
binding.etMessage.setText("")
|
binding.etMessage.setText("")
|
||||||
setSendButtonEnabled(false)
|
setSendButtonEnabled(false)
|
||||||
hideKeyboard(binding.etMessage)
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun setSendButtonEnabled(enabled: Boolean) {
|
private fun setSendButtonEnabled(enabled: Boolean) {
|
||||||
@@ -182,10 +186,12 @@ class DmChatRoomActivity : BaseActivity<ActivityDmChatRoomBinding>(
|
|||||||
viewModel.connectRealtime()
|
viewModel.connectRealtime()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun hideKeyboard(view: View) {
|
private fun updateRealtimeLoadingDialog(isConnecting: Boolean) {
|
||||||
val imm = getSystemService(INPUT_METHOD_SERVICE) as? InputMethodManager
|
if (isConnecting) {
|
||||||
imm?.hideSoftInputFromWindow(view.windowToken, 0)
|
loadingDialog.show(screenWidth)
|
||||||
view.clearFocus()
|
} else {
|
||||||
|
loadingDialog.dismiss()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
|||||||
import io.reactivex.rxjava3.core.Scheduler
|
import io.reactivex.rxjava3.core.Scheduler
|
||||||
import io.reactivex.rxjava3.disposables.Disposable
|
import io.reactivex.rxjava3.disposables.Disposable
|
||||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||||
|
import kr.co.vividnext.sodalive.R
|
||||||
|
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
|
||||||
@@ -26,6 +28,7 @@ import kr.co.vividnext.sodalive.v2.main.chat.dm.model.mergeByMessageId
|
|||||||
import kr.co.vividnext.sodalive.v2.main.chat.dm.model.sortByCreatedAtAndMessageId
|
import kr.co.vividnext.sodalive.v2.main.chat.dm.model.sortByCreatedAtAndMessageId
|
||||||
import kr.co.vividnext.sodalive.v2.main.chat.dm.model.toUiItem
|
import kr.co.vividnext.sodalive.v2.main.chat.dm.model.toUiItem
|
||||||
import kr.co.vividnext.sodalive.v2.main.chat.dm.model.toUiItems
|
import kr.co.vividnext.sodalive.v2.main.chat.dm.model.toUiItems
|
||||||
|
import java.util.UUID
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
class DmChatRoomViewModel(
|
class DmChatRoomViewModel(
|
||||||
@@ -43,6 +46,7 @@ class DmChatRoomViewModel(
|
|||||||
private var isLoadingOlder: Boolean = false
|
private var isLoadingOlder: Boolean = false
|
||||||
private var isRealtimeJoining: Boolean = false
|
private var isRealtimeJoining: Boolean = false
|
||||||
private var isRealtimeConnected: Boolean = false
|
private var isRealtimeConnected: Boolean = false
|
||||||
|
private var joinErrorRetryCount: Int = 0
|
||||||
private var shouldReconnectRealtime: Boolean = false
|
private var shouldReconnectRealtime: Boolean = false
|
||||||
private var currentAuthToken: String = ""
|
private var currentAuthToken: String = ""
|
||||||
private var currentRealtimeToken: String = ""
|
private var currentRealtimeToken: String = ""
|
||||||
@@ -51,7 +55,6 @@ class DmChatRoomViewModel(
|
|||||||
private var heartbeatPingDisposable: Disposable? = null
|
private var heartbeatPingDisposable: Disposable? = null
|
||||||
private var heartbeatTimeoutDisposable: Disposable? = null
|
private var heartbeatTimeoutDisposable: Disposable? = null
|
||||||
private var localMessageSequence: Long = 0L
|
private var localMessageSequence: Long = 0L
|
||||||
private var requestSequence: Long = 0L
|
|
||||||
private val pendingRequestLocalIds = mutableMapOf<String, String>()
|
private val pendingRequestLocalIds = mutableMapOf<String, String>()
|
||||||
private val recentFailedRequestLocalIds = mutableMapOf<String, String>()
|
private val recentFailedRequestLocalIds = mutableMapOf<String, String>()
|
||||||
private val pendingTimeoutDisposables = mutableMapOf<String, Disposable>()
|
private val pendingTimeoutDisposables = mutableMapOf<String, Disposable>()
|
||||||
@@ -73,6 +76,10 @@ class DmChatRoomViewModel(
|
|||||||
val roomOpenedEventLiveData: LiveData<DmChatEvent<Boolean>>
|
val roomOpenedEventLiveData: LiveData<DmChatEvent<Boolean>>
|
||||||
get() = _roomOpenedEventLiveData
|
get() = _roomOpenedEventLiveData
|
||||||
|
|
||||||
|
private val _isRealtimeConnectingLiveData = MutableLiveData(false)
|
||||||
|
val isRealtimeConnectingLiveData: LiveData<Boolean>
|
||||||
|
get() = _isRealtimeConnectingLiveData
|
||||||
|
|
||||||
fun enter(roomId: Long, creatorId: Long) {
|
fun enter(roomId: Long, creatorId: Long) {
|
||||||
when {
|
when {
|
||||||
roomId > 0L -> openRoom(roomId)
|
roomId > 0L -> openRoom(roomId)
|
||||||
@@ -108,9 +115,9 @@ class DmChatRoomViewModel(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun sendText(text: String) {
|
fun sendText(text: String): Boolean {
|
||||||
val trimmed = text.trim()
|
val trimmed = text.trim()
|
||||||
if (trimmed.isBlank() || currentRoomId <= 0L) return
|
if (trimmed.isBlank() || currentRoomId <= 0L || !isRealtimeConnected) return false
|
||||||
|
|
||||||
val localId = nextLocalId()
|
val localId = nextLocalId()
|
||||||
val requestId = nextRequestId()
|
val requestId = nextRequestId()
|
||||||
@@ -131,13 +138,14 @@ class DmChatRoomViewModel(
|
|||||||
emitContent()
|
emitContent()
|
||||||
|
|
||||||
sendLocalMessage(requestId = requestId, text = trimmed)
|
sendLocalMessage(requestId = requestId, text = trimmed)
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
fun retry(localId: String) {
|
fun retry(localId: String) {
|
||||||
val failedItem = currentMessages.firstOrNull {
|
val failedItem = currentMessages.firstOrNull {
|
||||||
it.localId == localId && it.status == DmChatMessageStatus.FAILED
|
it.localId == localId && it.status == DmChatMessageStatus.FAILED
|
||||||
} ?: return
|
} ?: return
|
||||||
if (currentRoomId <= 0L) return
|
if (currentRoomId <= 0L || !isRealtimeConnected) return
|
||||||
val requestId = nextRequestId()
|
val requestId = nextRequestId()
|
||||||
|
|
||||||
removeRecentFailedRequests(localId)
|
removeRecentFailedRequests(localId)
|
||||||
@@ -207,6 +215,7 @@ class DmChatRoomViewModel(
|
|||||||
currentRealtimeRoomId = roomId
|
currentRealtimeRoomId = roomId
|
||||||
isRealtimeJoining = true
|
isRealtimeJoining = true
|
||||||
isRealtimeConnected = false
|
isRealtimeConnected = false
|
||||||
|
setRealtimeConnecting(true)
|
||||||
shouldReconnectRealtime = true
|
shouldReconnectRealtime = true
|
||||||
reconnectDisposable?.dispose()
|
reconnectDisposable?.dispose()
|
||||||
reconnectDisposable = null
|
reconnectDisposable = null
|
||||||
@@ -219,15 +228,12 @@ class DmChatRoomViewModel(
|
|||||||
|
|
||||||
override fun onFailure(throwable: Throwable) {
|
override fun onFailure(throwable: Throwable) {
|
||||||
scheduleRealtimeCallback {
|
scheduleRealtimeCallback {
|
||||||
isRealtimeJoining = false
|
handleRealtimeDisconnect(throwable)
|
||||||
isRealtimeConnected = false
|
|
||||||
throwable.message?.let { Logger.e(it) }
|
|
||||||
scheduleRealtimeReconnect()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
repository.sendJoinRoom(roomId)
|
repository.sendJoinRoom(roomId = roomId, requestId = nextRequestId())
|
||||||
}
|
}
|
||||||
|
|
||||||
fun leaveRealtime() {
|
fun leaveRealtime() {
|
||||||
@@ -237,15 +243,20 @@ class DmChatRoomViewModel(
|
|||||||
(isRealtimeJoining || isRealtimeConnected || currentRealtimeToken.isNotEmpty())
|
(isRealtimeJoining || isRealtimeConnected || currentRealtimeToken.isNotEmpty())
|
||||||
if (!hasActiveSocket) return
|
if (!hasActiveSocket) return
|
||||||
|
|
||||||
|
val shouldSendLeave = isRealtimeConnected
|
||||||
shouldReconnectRealtime = false
|
shouldReconnectRealtime = false
|
||||||
stopHeartbeat()
|
stopHeartbeat()
|
||||||
currentRealtimeToken = ""
|
currentRealtimeToken = ""
|
||||||
currentRealtimeRoomId = 0L
|
currentRealtimeRoomId = 0L
|
||||||
isRealtimeJoining = false
|
isRealtimeJoining = false
|
||||||
isRealtimeConnected = false
|
isRealtimeConnected = false
|
||||||
|
joinErrorRetryCount = 0
|
||||||
|
setRealtimeConnecting(false)
|
||||||
reconnectDisposable?.dispose()
|
reconnectDisposable?.dispose()
|
||||||
reconnectDisposable = null
|
reconnectDisposable = null
|
||||||
repository.sendLeaveRoom(roomId)
|
if (shouldSendLeave) {
|
||||||
|
repository.sendLeaveRoom(roomId = roomId, requestId = nextRequestId())
|
||||||
|
}
|
||||||
repository.closeSocket()
|
repository.closeSocket()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -282,6 +293,8 @@ class DmChatRoomViewModel(
|
|||||||
currentRealtimeRoomId = 0L
|
currentRealtimeRoomId = 0L
|
||||||
isRealtimeJoining = false
|
isRealtimeJoining = false
|
||||||
isRealtimeConnected = false
|
isRealtimeConnected = false
|
||||||
|
joinErrorRetryCount = 0
|
||||||
|
setRealtimeConnecting(false)
|
||||||
repository.closeSocket()
|
repository.closeSocket()
|
||||||
super.onCleared()
|
super.onCleared()
|
||||||
}
|
}
|
||||||
@@ -344,6 +357,7 @@ class DmChatRoomViewModel(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
joinErrorRetryCount = 0
|
||||||
currentRoomId = data.roomId
|
currentRoomId = data.roomId
|
||||||
opponentNickname = data.opponentNickname
|
opponentNickname = data.opponentNickname
|
||||||
opponentProfileImageUrl = data.opponentProfileImageUrl
|
opponentProfileImageUrl = data.opponentProfileImageUrl
|
||||||
@@ -375,19 +389,72 @@ class DmChatRoomViewModel(
|
|||||||
|
|
||||||
private fun handleSocketEvent(event: DmChatSocketEvent, token: String) {
|
private fun handleSocketEvent(event: DmChatSocketEvent, token: String) {
|
||||||
when (event) {
|
when (event) {
|
||||||
DmChatSocketEvent.Joined -> {
|
is DmChatSocketEvent.Joined -> {
|
||||||
|
if (!isCurrentRoomEvent(event.roomId)) return
|
||||||
|
joinErrorRetryCount = 0
|
||||||
isRealtimeJoining = false
|
isRealtimeJoining = false
|
||||||
isRealtimeConnected = true
|
isRealtimeConnected = true
|
||||||
|
setRealtimeConnecting(false)
|
||||||
startHeartbeat()
|
startHeartbeat()
|
||||||
syncLatestMessagesAfterReconnect(token = token)
|
syncLatestMessagesAfterReconnect(token = token)
|
||||||
}
|
}
|
||||||
is DmChatSocketEvent.Message -> handleRealtimeMessage(event.requestId, event.message)
|
is DmChatSocketEvent.Message -> {
|
||||||
is DmChatSocketEvent.SendAck -> handleSendAck(event.requestId, event.message)
|
if (!isRealtimeConnected || !isStrictCurrentRoomEvent(event.roomId)) return
|
||||||
is DmChatSocketEvent.Error -> event.requestId?.let { markPendingMessageFailed(it) }
|
handleRealtimeMessage(event.requestId, event.message)
|
||||||
DmChatSocketEvent.Pong -> clearHeartbeatTimeout()
|
}
|
||||||
|
is DmChatSocketEvent.SendAck -> {
|
||||||
|
if (!isRealtimeConnected || !isStrictCurrentRoomEvent(event.roomId)) return
|
||||||
|
handleSendAck(event.requestId, event.message)
|
||||||
|
}
|
||||||
|
is DmChatSocketEvent.Error -> {
|
||||||
|
if (isRealtimeJoining && !isRealtimeConnected) {
|
||||||
|
handleJoinErrorBeforeJoined()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!isRealtimeConnected || !isCurrentRoomEvent(event.roomId)) return
|
||||||
|
event.requestId?.let { markPendingMessageFailed(it) }
|
||||||
|
}
|
||||||
|
is DmChatSocketEvent.Pong -> {
|
||||||
|
if (!isRealtimeConnected || !isCurrentRoomEvent(event.roomId)) return
|
||||||
|
clearHeartbeatTimeout()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun isCurrentRoomEvent(roomId: Long?): Boolean = roomId == null || roomId == currentRoomId
|
||||||
|
|
||||||
|
private fun isStrictCurrentRoomEvent(roomId: Long?): Boolean = roomId == currentRoomId
|
||||||
|
|
||||||
|
private fun handleJoinErrorBeforeJoined() {
|
||||||
|
isRealtimeJoining = false
|
||||||
|
isRealtimeConnected = false
|
||||||
|
stopHeartbeat()
|
||||||
|
repository.closeSocket()
|
||||||
|
if (joinErrorRetryCount < MAX_JOIN_ERROR_RETRY_COUNT) {
|
||||||
|
joinErrorRetryCount += 1
|
||||||
|
setRealtimeConnecting(true)
|
||||||
|
scheduleRealtimeReconnect()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
failRealtimeRoomEntry()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun failRealtimeRoomEntry() {
|
||||||
|
shouldReconnectRealtime = false
|
||||||
|
joinErrorRetryCount = 0
|
||||||
|
reconnectDisposable?.dispose()
|
||||||
|
reconnectDisposable = null
|
||||||
|
currentRealtimeToken = ""
|
||||||
|
currentRealtimeRoomId = 0L
|
||||||
|
isRealtimeJoining = false
|
||||||
|
isRealtimeConnected = false
|
||||||
|
setRealtimeConnecting(false)
|
||||||
|
repository.closeSocket()
|
||||||
|
showError(SodaLiveApplicationHolder.get().getString(R.string.screen_dm_chat_room_enter_failed))
|
||||||
|
_finishEventLiveData.value = true
|
||||||
|
}
|
||||||
|
|
||||||
private fun startHeartbeat() {
|
private fun startHeartbeat() {
|
||||||
stopHeartbeat()
|
stopHeartbeat()
|
||||||
heartbeatPingDisposable = reconnectScheduler.schedulePeriodicallyDirect(
|
heartbeatPingDisposable = reconnectScheduler.schedulePeriodicallyDirect(
|
||||||
@@ -399,7 +466,11 @@ class DmChatRoomViewModel(
|
|||||||
connectRealtime(token = latestToken)
|
connectRealtime(token = latestToken)
|
||||||
return@scheduleRealtimeCallback
|
return@scheduleRealtimeCallback
|
||||||
}
|
}
|
||||||
if (repository.sendPing()) scheduleHeartbeatTimeout()
|
if (repository.sendPing(roomId = currentRoomId, requestId = nextRequestId())) {
|
||||||
|
scheduleHeartbeatTimeout()
|
||||||
|
} else {
|
||||||
|
handleRealtimeDisconnect(IllegalStateException("WebSocket PING send failed"))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
HEARTBEAT_INTERVAL_MILLIS,
|
HEARTBEAT_INTERVAL_MILLIS,
|
||||||
@@ -416,6 +487,7 @@ class DmChatRoomViewModel(
|
|||||||
if (!isRealtimeConnected || !shouldReconnectRealtime) return@scheduleRealtimeCallback
|
if (!isRealtimeConnected || !shouldReconnectRealtime) return@scheduleRealtimeCallback
|
||||||
isRealtimeJoining = false
|
isRealtimeJoining = false
|
||||||
isRealtimeConnected = false
|
isRealtimeConnected = false
|
||||||
|
setRealtimeConnecting(false)
|
||||||
stopHeartbeat()
|
stopHeartbeat()
|
||||||
repository.closeSocket()
|
repository.closeSocket()
|
||||||
scheduleRealtimeReconnect()
|
scheduleRealtimeReconnect()
|
||||||
@@ -426,6 +498,16 @@ class DmChatRoomViewModel(
|
|||||||
).also { compositeDisposable.add(it) }
|
).also { compositeDisposable.add(it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun handleRealtimeDisconnect(throwable: Throwable) {
|
||||||
|
isRealtimeJoining = false
|
||||||
|
isRealtimeConnected = false
|
||||||
|
setRealtimeConnecting(false)
|
||||||
|
stopHeartbeat()
|
||||||
|
repository.closeSocket()
|
||||||
|
throwable.message?.let { Logger.e(it) }
|
||||||
|
scheduleRealtimeReconnect()
|
||||||
|
}
|
||||||
|
|
||||||
private fun stopHeartbeat() {
|
private fun stopHeartbeat() {
|
||||||
heartbeatPingDisposable?.dispose()
|
heartbeatPingDisposable?.dispose()
|
||||||
heartbeatPingDisposable = null
|
heartbeatPingDisposable = null
|
||||||
@@ -437,6 +519,11 @@ class DmChatRoomViewModel(
|
|||||||
heartbeatTimeoutDisposable = null
|
heartbeatTimeoutDisposable = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun setRealtimeConnecting(isConnecting: Boolean) {
|
||||||
|
if (_isRealtimeConnectingLiveData.value == isConnecting) return
|
||||||
|
_isRealtimeConnectingLiveData.value = isConnecting
|
||||||
|
}
|
||||||
|
|
||||||
private fun handleSendAck(requestId: String, message: DmChatMessageResponse) {
|
private fun handleSendAck(requestId: String, message: DmChatMessageResponse) {
|
||||||
val localId = pendingRequestLocalIds.remove(requestId)
|
val localId = pendingRequestLocalIds.remove(requestId)
|
||||||
?: recentFailedRequestLocalIds.remove(requestId)
|
?: recentFailedRequestLocalIds.remove(requestId)
|
||||||
@@ -522,10 +609,7 @@ class DmChatRoomViewModel(
|
|||||||
return "local-$localMessageSequence"
|
return "local-$localMessageSequence"
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun nextRequestId(): String {
|
private fun nextRequestId(): String = UUID.randomUUID().toString()
|
||||||
requestSequence += 1L
|
|
||||||
return "request-$requestSequence"
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun authToken(): String {
|
private fun authToken(): String {
|
||||||
val token = tokenProvider()
|
val token = tokenProvider()
|
||||||
@@ -540,9 +624,10 @@ class DmChatRoomViewModel(
|
|||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
const val RECONNECT_DELAY_MILLIS = 3_000L
|
const val RECONNECT_DELAY_MILLIS = 3_000L
|
||||||
const val SEND_ACK_TIMEOUT_MILLIS = 10_000L
|
const val SEND_ACK_TIMEOUT_MILLIS = 15_000L
|
||||||
const val HEARTBEAT_INTERVAL_MILLIS = 30_000L
|
const val HEARTBEAT_INTERVAL_MILLIS = 30_000L
|
||||||
const val HEARTBEAT_TIMEOUT_MILLIS = 10_000L
|
const val HEARTBEAT_TIMEOUT_MILLIS = 10_000L
|
||||||
|
const val MAX_JOIN_ERROR_RETRY_COUNT = 3
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,10 +2,14 @@ package kr.co.vividnext.sodalive.v2.main.chat.dm.data
|
|||||||
|
|
||||||
import io.reactivex.rxjava3.core.Single
|
import io.reactivex.rxjava3.core.Single
|
||||||
import kr.co.vividnext.sodalive.common.ApiResponse
|
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||||
|
import okhttp3.MultipartBody
|
||||||
|
import okhttp3.RequestBody
|
||||||
import retrofit2.http.Body
|
import retrofit2.http.Body
|
||||||
import retrofit2.http.GET
|
import retrofit2.http.GET
|
||||||
|
import retrofit2.http.Multipart
|
||||||
import retrofit2.http.Header
|
import retrofit2.http.Header
|
||||||
import retrofit2.http.POST
|
import retrofit2.http.POST
|
||||||
|
import retrofit2.http.Part
|
||||||
import retrofit2.http.Path
|
import retrofit2.http.Path
|
||||||
import retrofit2.http.Query
|
import retrofit2.http.Query
|
||||||
|
|
||||||
@@ -30,4 +34,13 @@ interface DmChatApi {
|
|||||||
@Query("cursor") cursor: Long?,
|
@Query("cursor") cursor: Long?,
|
||||||
@Query("limit") limit: Int = 20
|
@Query("limit") limit: Int = 20
|
||||||
): Single<ApiResponse<DmChatMessagesPageResponse>>
|
): Single<ApiResponse<DmChatMessagesPageResponse>>
|
||||||
|
|
||||||
|
@Multipart
|
||||||
|
@POST("/api/v2/user-creator-chat/rooms/{roomId}/messages/voice")
|
||||||
|
fun sendDmVoiceMessage(
|
||||||
|
@Header("Authorization") authHeader: String,
|
||||||
|
@Path("roomId") roomId: Long,
|
||||||
|
@Part voiceMessageFile: MultipartBody.Part,
|
||||||
|
@Part("request") request: RequestBody
|
||||||
|
): Single<ApiResponse<SendDmVoiceMessageResponse>>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,168 +0,0 @@
|
|||||||
package kr.co.vividnext.sodalive.v2.main.chat.dm.data
|
|
||||||
|
|
||||||
import com.google.gson.Gson
|
|
||||||
import com.google.gson.JsonSyntaxException
|
|
||||||
import okhttp3.Call
|
|
||||||
import okhttp3.Callback
|
|
||||||
import okhttp3.OkHttpClient
|
|
||||||
import okhttp3.Request
|
|
||||||
import okhttp3.Response
|
|
||||||
import java.io.IOException
|
|
||||||
import java.util.concurrent.TimeUnit
|
|
||||||
|
|
||||||
class DmChatEventParser(private val gson: Gson) {
|
|
||||||
sealed class Event {
|
|
||||||
data object Connected : Event()
|
|
||||||
data class Message(val message: DmChatMessageResponse) : Event()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun parse(frame: String): Event? {
|
|
||||||
val lines = frame.lineSequence().filter { it.isNotBlank() }
|
|
||||||
var eventName: String? = null
|
|
||||||
val dataLines = mutableListOf<String>()
|
|
||||||
|
|
||||||
lines.forEach { line ->
|
|
||||||
when {
|
|
||||||
line.startsWith("event:") -> eventName = line.substringAfter(':').trim()
|
|
||||||
line.startsWith("data:") -> dataLines += line.substringAfter(':').removeSingleLeadingSpace()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return when (eventName) {
|
|
||||||
EVENT_CONNECTED -> Event.Connected
|
|
||||||
EVENT_MESSAGE -> parseMessage(dataLines.joinToString(separator = "\n"))
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun parseMessage(data: String): Event.Message? = try {
|
|
||||||
Event.Message(gson.fromJson(data, DmChatMessageResponse::class.java))
|
|
||||||
} catch (e: JsonSyntaxException) {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun String.removeSingleLeadingSpace(): String =
|
|
||||||
if (startsWith(' ')) drop(1) else this
|
|
||||||
|
|
||||||
private companion object {
|
|
||||||
const val EVENT_CONNECTED = "connected"
|
|
||||||
const val EVENT_MESSAGE = "message"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DmChatRealtimeClient {
|
|
||||||
fun connect(
|
|
||||||
token: String,
|
|
||||||
roomId: Long,
|
|
||||||
listener: DmChatEventClient.Listener
|
|
||||||
)
|
|
||||||
|
|
||||||
fun cancel()
|
|
||||||
}
|
|
||||||
|
|
||||||
class DmChatEventClient(
|
|
||||||
okHttpClient: OkHttpClient,
|
|
||||||
gson: Gson,
|
|
||||||
private val baseUrl: String
|
|
||||||
) : DmChatRealtimeClient {
|
|
||||||
interface Listener {
|
|
||||||
fun onConnected()
|
|
||||||
fun onMessage(message: DmChatMessageResponse)
|
|
||||||
fun onFailure(throwable: Throwable)
|
|
||||||
}
|
|
||||||
|
|
||||||
private val okHttpClient = okHttpClient.newBuilder()
|
|
||||||
.readTimeout(0, TimeUnit.MILLISECONDS)
|
|
||||||
.build()
|
|
||||||
private val parser = DmChatEventParser(gson)
|
|
||||||
private var call: Call? = null
|
|
||||||
|
|
||||||
@Volatile
|
|
||||||
private var listener: Listener? = null
|
|
||||||
|
|
||||||
@Synchronized
|
|
||||||
override fun connect(
|
|
||||||
token: String,
|
|
||||||
roomId: Long,
|
|
||||||
listener: Listener
|
|
||||||
) {
|
|
||||||
cancel()
|
|
||||||
this.listener = listener
|
|
||||||
|
|
||||||
val request = Request.Builder()
|
|
||||||
.url(eventsUrl(roomId))
|
|
||||||
.header(HEADER_AUTHORIZATION, bearer(token))
|
|
||||||
.build()
|
|
||||||
|
|
||||||
call = okHttpClient.newCall(request).also { newCall ->
|
|
||||||
newCall.enqueue(object : Callback {
|
|
||||||
override fun onFailure(call: Call, e: IOException) {
|
|
||||||
if (!call.isCanceled()) listener.onFailure(e)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onResponse(call: Call, response: Response) {
|
|
||||||
response.use { usedResponse ->
|
|
||||||
if (!usedResponse.isSuccessful) {
|
|
||||||
if (!call.isCanceled()) listener.onFailure(IOException("Unexpected code ${usedResponse.code}"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
val body = usedResponse.body ?: return
|
|
||||||
try {
|
|
||||||
readFrames(call, body.charStream().buffered())
|
|
||||||
} catch (e: IOException) {
|
|
||||||
if (!call.isCanceled()) listener.onFailure(e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Synchronized
|
|
||||||
override fun cancel() {
|
|
||||||
call?.cancel()
|
|
||||||
call = null
|
|
||||||
listener = null
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun readFrames(call: Call, reader: java.io.BufferedReader) {
|
|
||||||
reader.use { bufferedReader ->
|
|
||||||
val frame = StringBuilder()
|
|
||||||
while (!call.isCanceled()) {
|
|
||||||
val line = bufferedReader.readLine()
|
|
||||||
if (line == null) {
|
|
||||||
if (frame.isNotEmpty()) dispatch(frame.toString())
|
|
||||||
notifyStreamClosed(call)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (line.isBlank()) {
|
|
||||||
dispatch(frame.toString())
|
|
||||||
frame.clear()
|
|
||||||
} else {
|
|
||||||
frame.append(line).append('\n')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun notifyStreamClosed(call: Call) {
|
|
||||||
if (!call.isCanceled()) listener?.onFailure(IOException("SSE stream closed"))
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun dispatch(frame: String) {
|
|
||||||
when (val event = parser.parse(frame)) {
|
|
||||||
DmChatEventParser.Event.Connected -> listener?.onConnected()
|
|
||||||
is DmChatEventParser.Event.Message -> listener?.onMessage(event.message)
|
|
||||||
null -> Unit
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun eventsUrl(roomId: Long): String =
|
|
||||||
"${baseUrl.trimEnd('/')}/api/v2/user-creator-chat/rooms/$roomId/events"
|
|
||||||
|
|
||||||
private fun bearer(token: String) = "Bearer $token"
|
|
||||||
|
|
||||||
private companion object {
|
|
||||||
const val HEADER_AUTHORIZATION = "Authorization"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -42,3 +42,10 @@ data class DmChatMessageResponse(
|
|||||||
@SerializedName("senderNickname") val senderNickname: String,
|
@SerializedName("senderNickname") val senderNickname: String,
|
||||||
@SerializedName("senderProfileImageUrl") val senderProfileImageUrl: String
|
@SerializedName("senderProfileImageUrl") val senderProfileImageUrl: String
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@Keep
|
||||||
|
data class SendDmVoiceMessageResponse(
|
||||||
|
@SerializedName("message") val message: DmChatMessageResponse,
|
||||||
|
@SerializedName("deliveredRealtime") val deliveredRealtime: Boolean,
|
||||||
|
@SerializedName("pushSent") val pushSent: Boolean
|
||||||
|
)
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ package kr.co.vividnext.sodalive.v2.main.chat.dm.data
|
|||||||
|
|
||||||
import io.reactivex.rxjava3.core.Single
|
import io.reactivex.rxjava3.core.Single
|
||||||
import kr.co.vividnext.sodalive.common.ApiResponse
|
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||||
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
|
import okhttp3.MultipartBody
|
||||||
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
|
|
||||||
class DmChatRepository(
|
class DmChatRepository(
|
||||||
private val api: DmChatApi,
|
private val api: DmChatApi,
|
||||||
@@ -37,6 +40,17 @@ class DmChatRepository(
|
|||||||
limit = limit
|
limit = limit
|
||||||
)
|
)
|
||||||
|
|
||||||
|
fun sendVoiceMessage(
|
||||||
|
token: String,
|
||||||
|
roomId: Long,
|
||||||
|
voiceMessageFile: MultipartBody.Part
|
||||||
|
): Single<ApiResponse<SendDmVoiceMessageResponse>> = api.sendDmVoiceMessage(
|
||||||
|
authHeader = bearer(token),
|
||||||
|
roomId = roomId,
|
||||||
|
voiceMessageFile = voiceMessageFile,
|
||||||
|
request = VOICE_REQUEST_JSON.toRequestBody(JSON_MEDIA_TYPE.toMediaType())
|
||||||
|
)
|
||||||
|
|
||||||
fun connectSocket(
|
fun connectSocket(
|
||||||
token: String,
|
token: String,
|
||||||
listener: DmChatSocketClient.Listener
|
listener: DmChatSocketClient.Listener
|
||||||
@@ -44,9 +58,21 @@ class DmChatRepository(
|
|||||||
socketClient?.connect(token = token, listener = listener)
|
socketClient?.connect(token = token, listener = listener)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun sendJoinRoom(roomId: Long): Boolean = socketClient?.sendJoinRoom(roomId = roomId) ?: false
|
fun sendJoinRoom(
|
||||||
|
roomId: Long,
|
||||||
|
requestId: String
|
||||||
|
): Boolean = socketClient?.sendJoinRoom(
|
||||||
|
roomId = roomId,
|
||||||
|
requestId = requestId
|
||||||
|
) ?: false
|
||||||
|
|
||||||
fun sendLeaveRoom(roomId: Long): Boolean = socketClient?.sendLeaveRoom(roomId = roomId) ?: false
|
fun sendLeaveRoom(
|
||||||
|
roomId: Long,
|
||||||
|
requestId: String
|
||||||
|
): Boolean = socketClient?.sendLeaveRoom(
|
||||||
|
roomId = roomId,
|
||||||
|
requestId = requestId
|
||||||
|
) ?: false
|
||||||
|
|
||||||
fun sendSocketText(
|
fun sendSocketText(
|
||||||
roomId: Long,
|
roomId: Long,
|
||||||
@@ -58,7 +84,13 @@ class DmChatRepository(
|
|||||||
textMessage = textMessage
|
textMessage = textMessage
|
||||||
) ?: false
|
) ?: false
|
||||||
|
|
||||||
fun sendPing(): Boolean = socketClient?.sendPing() ?: false
|
fun sendPing(
|
||||||
|
roomId: Long,
|
||||||
|
requestId: String
|
||||||
|
): Boolean = socketClient?.sendPing(
|
||||||
|
roomId = roomId,
|
||||||
|
requestId = requestId
|
||||||
|
) ?: false
|
||||||
|
|
||||||
fun closeSocket() {
|
fun closeSocket() {
|
||||||
socketClient?.close()
|
socketClient?.close()
|
||||||
@@ -68,5 +100,7 @@ class DmChatRepository(
|
|||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
const val DEFAULT_LIMIT = 20
|
const val DEFAULT_LIMIT = 20
|
||||||
|
const val VOICE_REQUEST_JSON = "{\"recipientId\":null}"
|
||||||
|
const val JSON_MEDIA_TYPE = "application/json"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,22 @@
|
|||||||
package kr.co.vividnext.sodalive.v2.main.chat.dm.data
|
package kr.co.vividnext.sodalive.v2.main.chat.dm.data
|
||||||
|
|
||||||
|
import androidx.annotation.Keep
|
||||||
import com.google.gson.Gson
|
import com.google.gson.Gson
|
||||||
import com.google.gson.JsonObject
|
import com.google.gson.JsonObject
|
||||||
|
import com.google.gson.annotations.SerializedName
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import okhttp3.Response
|
import okhttp3.Response
|
||||||
import okhttp3.WebSocket
|
import okhttp3.WebSocket
|
||||||
import okhttp3.WebSocketListener
|
import okhttp3.WebSocketListener
|
||||||
|
import java.io.IOException
|
||||||
|
|
||||||
class DmChatSocketClient(
|
class DmChatSocketClient(
|
||||||
private val okHttpClient: OkHttpClient,
|
private val okHttpClient: OkHttpClient,
|
||||||
private val gson: Gson,
|
private val gson: Gson,
|
||||||
private val baseUrl: String,
|
private val baseUrl: String,
|
||||||
private val webSocketFactory: (Request, WebSocketListener) -> WebSocket = okHttpClient::newWebSocket
|
private val webSocketFactory: (Request, WebSocketListener) -> WebSocket = okHttpClient::newWebSocket,
|
||||||
|
private val rawJsonLogger: (String) -> Unit = {}
|
||||||
) {
|
) {
|
||||||
interface Listener {
|
interface Listener {
|
||||||
fun onEvent(event: DmChatSocketEvent)
|
fun onEvent(event: DmChatSocketEvent)
|
||||||
@@ -40,8 +44,11 @@ class DmChatSocketClient(
|
|||||||
.header(HEADER_AUTHORIZATION, bearer(token))
|
.header(HEADER_AUTHORIZATION, bearer(token))
|
||||||
.build()
|
.build()
|
||||||
val socketListener = object : WebSocketListener() {
|
val socketListener = object : WebSocketListener() {
|
||||||
|
private var closeNotified: Boolean = false
|
||||||
|
|
||||||
override fun onMessage(webSocket: WebSocket, text: String) {
|
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||||
if (webSocket != activeSocket) return
|
if (webSocket != activeSocket) return
|
||||||
|
rawJsonLogger(text)
|
||||||
parser.parse(text)?.let { event -> this@DmChatSocketClient.listener?.onEvent(event) }
|
parser.parse(text)?.let { event -> this@DmChatSocketClient.listener?.onEvent(event) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,19 +56,39 @@ class DmChatSocketClient(
|
|||||||
if (webSocket != activeSocket) return
|
if (webSocket != activeSocket) return
|
||||||
this@DmChatSocketClient.listener?.onFailure(t)
|
this@DmChatSocketClient.listener?.onFailure(t)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
|
||||||
|
notifySocketClosed(webSocket, code, reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
|
||||||
|
notifySocketClosed(webSocket, code, reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun notifySocketClosed(webSocket: WebSocket, code: Int, reason: String) {
|
||||||
|
if (webSocket != activeSocket || closeNotified) return
|
||||||
|
closeNotified = true
|
||||||
|
this@DmChatSocketClient.listener?.onFailure(
|
||||||
|
IOException("WebSocket closed: code=$code reason=$reason")
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
webSocket = webSocketFactory(request, socketListener).also { activeSocket = it }
|
webSocket = webSocketFactory(request, socketListener).also { activeSocket = it }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun sendJoinRoom(roomId: Long): Boolean = send(
|
fun sendJoinRoom(roomId: Long, requestId: String): Boolean = send(
|
||||||
type = DmChatSocketClientType.JOIN_ROOM,
|
type = DmChatSocketClientType.JOIN_ROOM,
|
||||||
payload = DmChatSocketRoomPayload(roomId = roomId)
|
requestId = requestId,
|
||||||
|
roomId = roomId,
|
||||||
|
payload = JsonObject()
|
||||||
)
|
)
|
||||||
|
|
||||||
fun sendLeaveRoom(roomId: Long): Boolean = send(
|
fun sendLeaveRoom(roomId: Long, requestId: String): Boolean = send(
|
||||||
type = DmChatSocketClientType.LEAVE_ROOM,
|
type = DmChatSocketClientType.LEAVE_ROOM,
|
||||||
payload = DmChatSocketRoomPayload(roomId = roomId)
|
requestId = requestId,
|
||||||
|
roomId = roomId,
|
||||||
|
payload = JsonObject()
|
||||||
)
|
)
|
||||||
|
|
||||||
fun sendText(
|
fun sendText(
|
||||||
@@ -70,15 +97,15 @@ class DmChatSocketClient(
|
|||||||
textMessage: String
|
textMessage: String
|
||||||
): Boolean = send(
|
): Boolean = send(
|
||||||
type = DmChatSocketClientType.SEND_TEXT,
|
type = DmChatSocketClientType.SEND_TEXT,
|
||||||
payload = DmChatSocketSendTextPayload(
|
requestId = requestId,
|
||||||
roomId = roomId,
|
roomId = roomId,
|
||||||
requestId = requestId,
|
payload = DmChatSocketSendTextPayload(textMessage = textMessage)
|
||||||
textMessage = textMessage
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
fun sendPing(): Boolean = send(
|
fun sendPing(roomId: Long, requestId: String): Boolean = send(
|
||||||
type = DmChatSocketClientType.PING,
|
type = DmChatSocketClientType.PING,
|
||||||
|
requestId = requestId,
|
||||||
|
roomId = roomId,
|
||||||
payload = JsonObject()
|
payload = JsonObject()
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -93,10 +120,21 @@ class DmChatSocketClient(
|
|||||||
|
|
||||||
private fun send(
|
private fun send(
|
||||||
type: DmChatSocketClientType,
|
type: DmChatSocketClientType,
|
||||||
|
requestId: String,
|
||||||
|
roomId: Long,
|
||||||
payload: Any
|
payload: Any
|
||||||
): Boolean {
|
): Boolean {
|
||||||
val socket = webSocket ?: return false
|
val socket = webSocket ?: return false
|
||||||
return socket.send(gson.toJson(DmChatSocketOutboundEnvelope(type = type.value, payload = payload)))
|
val rawJson = gson.toJson(
|
||||||
|
DmChatSocketOutboundEnvelope(
|
||||||
|
type = type.value,
|
||||||
|
requestId = requestId,
|
||||||
|
roomId = roomId,
|
||||||
|
payload = payload
|
||||||
|
)
|
||||||
|
)
|
||||||
|
rawJsonLogger(rawJson)
|
||||||
|
return socket.send(rawJson)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun socketUrl(): String = baseUrl
|
private fun socketUrl(): String = baseUrl
|
||||||
@@ -106,9 +144,12 @@ class DmChatSocketClient(
|
|||||||
|
|
||||||
private fun bearer(token: String) = "Bearer $token"
|
private fun bearer(token: String) = "Bearer $token"
|
||||||
|
|
||||||
|
@Keep
|
||||||
private data class DmChatSocketOutboundEnvelope(
|
private data class DmChatSocketOutboundEnvelope(
|
||||||
val type: String,
|
@SerializedName("type") val type: String,
|
||||||
val payload: Any
|
@SerializedName("requestId") val requestId: String,
|
||||||
|
@SerializedName("roomId") val roomId: Long,
|
||||||
|
@SerializedName("payload") val payload: Any
|
||||||
)
|
)
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
|
|||||||
@@ -9,38 +9,19 @@ import com.google.gson.annotations.SerializedName
|
|||||||
@Keep
|
@Keep
|
||||||
data class DmChatSocketEnvelope(
|
data class DmChatSocketEnvelope(
|
||||||
@SerializedName("type") val type: String,
|
@SerializedName("type") val type: String,
|
||||||
|
@SerializedName("requestId") val requestId: String?,
|
||||||
|
@SerializedName("roomId") val roomId: Long?,
|
||||||
@SerializedName("payload") val payload: JsonObject?
|
@SerializedName("payload") val payload: JsonObject?
|
||||||
)
|
)
|
||||||
|
|
||||||
@Keep
|
|
||||||
data class DmChatSocketRoomPayload(
|
|
||||||
@SerializedName("roomId") val roomId: Long
|
|
||||||
)
|
|
||||||
|
|
||||||
@Keep
|
@Keep
|
||||||
data class DmChatSocketSendTextPayload(
|
data class DmChatSocketSendTextPayload(
|
||||||
@SerializedName("roomId") val roomId: Long,
|
|
||||||
@SerializedName("requestId") val requestId: String,
|
|
||||||
@SerializedName("textMessage") val textMessage: String
|
@SerializedName("textMessage") val textMessage: String
|
||||||
)
|
)
|
||||||
|
|
||||||
@Keep
|
|
||||||
data class DmChatSocketMessagePayload(
|
|
||||||
@SerializedName("requestId") val requestId: String?,
|
|
||||||
@SerializedName("message") val message: DmChatMessageResponse
|
|
||||||
)
|
|
||||||
|
|
||||||
@Keep
|
|
||||||
data class DmChatSocketSendAckPayload(
|
|
||||||
@SerializedName("requestId") val requestId: String,
|
|
||||||
@SerializedName("message") val message: DmChatMessageResponse
|
|
||||||
)
|
|
||||||
|
|
||||||
@Keep
|
@Keep
|
||||||
data class DmChatSocketErrorPayload(
|
data class DmChatSocketErrorPayload(
|
||||||
@SerializedName("requestId") val requestId: String?,
|
@SerializedName("messageKey") val messageKey: String?
|
||||||
@SerializedName("code") val code: String?,
|
|
||||||
@SerializedName("message") val message: String?
|
|
||||||
)
|
)
|
||||||
|
|
||||||
enum class DmChatSocketClientType(val value: String) {
|
enum class DmChatSocketClientType(val value: String) {
|
||||||
@@ -51,32 +32,46 @@ enum class DmChatSocketClientType(val value: String) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
sealed class DmChatSocketEvent {
|
sealed class DmChatSocketEvent {
|
||||||
data object Joined : DmChatSocketEvent()
|
data class Joined(
|
||||||
|
val requestId: String?,
|
||||||
|
val roomId: Long?
|
||||||
|
) : DmChatSocketEvent()
|
||||||
data class Message(
|
data class Message(
|
||||||
val requestId: String?,
|
val requestId: String?,
|
||||||
|
val roomId: Long?,
|
||||||
val message: DmChatMessageResponse
|
val message: DmChatMessageResponse
|
||||||
) : DmChatSocketEvent()
|
) : DmChatSocketEvent()
|
||||||
data class SendAck(
|
data class SendAck(
|
||||||
val requestId: String,
|
val requestId: String,
|
||||||
|
val roomId: Long?,
|
||||||
val message: DmChatMessageResponse
|
val message: DmChatMessageResponse
|
||||||
) : DmChatSocketEvent()
|
) : DmChatSocketEvent()
|
||||||
data class Error(
|
data class Error(
|
||||||
val requestId: String?,
|
val requestId: String?,
|
||||||
val code: String?,
|
val roomId: Long?,
|
||||||
val message: String?
|
val messageKey: String?
|
||||||
|
) : DmChatSocketEvent()
|
||||||
|
data class Pong(
|
||||||
|
val requestId: String?,
|
||||||
|
val roomId: Long?
|
||||||
) : DmChatSocketEvent()
|
) : DmChatSocketEvent()
|
||||||
data object Pong : DmChatSocketEvent()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class DmChatSocketParser(private val gson: Gson) {
|
class DmChatSocketParser(private val gson: Gson) {
|
||||||
fun parse(text: String): DmChatSocketEvent? = try {
|
fun parse(text: String): DmChatSocketEvent? = try {
|
||||||
val envelope = gson.fromJson(text, DmChatSocketEnvelope::class.java)
|
val envelope = gson.fromJson(text, DmChatSocketEnvelope::class.java)
|
||||||
when (envelope.type) {
|
when (envelope.type) {
|
||||||
TYPE_JOINED -> DmChatSocketEvent.Joined
|
TYPE_JOINED -> DmChatSocketEvent.Joined(
|
||||||
TYPE_MESSAGE -> parseMessage(envelope.payload)
|
requestId = envelope.requestId,
|
||||||
TYPE_SEND_ACK -> parseSendAck(envelope.payload)
|
roomId = envelope.roomId
|
||||||
TYPE_ERROR -> parseError(envelope.payload)
|
)
|
||||||
TYPE_PONG -> DmChatSocketEvent.Pong
|
TYPE_MESSAGE -> parseMessage(envelope)
|
||||||
|
TYPE_SEND_ACK -> parseSendAck(envelope)
|
||||||
|
TYPE_ERROR -> parseError(envelope)
|
||||||
|
TYPE_PONG -> DmChatSocketEvent.Pong(
|
||||||
|
requestId = envelope.requestId,
|
||||||
|
roomId = envelope.roomId
|
||||||
|
)
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
} catch (e: JsonSyntaxException) {
|
} catch (e: JsonSyntaxException) {
|
||||||
@@ -87,28 +82,31 @@ class DmChatSocketParser(private val gson: Gson) {
|
|||||||
null
|
null
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun parseMessage(payload: JsonObject?): DmChatSocketEvent.Message? {
|
private fun parseMessage(envelope: DmChatSocketEnvelope): DmChatSocketEvent.Message? {
|
||||||
val messagePayload = gson.fromJson(payload, DmChatSocketMessagePayload::class.java) ?: return null
|
val message = gson.fromJson(envelope.payload, DmChatMessageResponse::class.java) ?: return null
|
||||||
return DmChatSocketEvent.Message(
|
return DmChatSocketEvent.Message(
|
||||||
requestId = messagePayload.requestId,
|
requestId = envelope.requestId,
|
||||||
message = messagePayload.message
|
roomId = envelope.roomId,
|
||||||
|
message = message
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun parseSendAck(payload: JsonObject?): DmChatSocketEvent.SendAck? {
|
private fun parseSendAck(envelope: DmChatSocketEnvelope): DmChatSocketEvent.SendAck? {
|
||||||
val ackPayload = gson.fromJson(payload, DmChatSocketSendAckPayload::class.java) ?: return null
|
val requestId = envelope.requestId ?: return null
|
||||||
|
val message = gson.fromJson(envelope.payload, DmChatMessageResponse::class.java) ?: return null
|
||||||
return DmChatSocketEvent.SendAck(
|
return DmChatSocketEvent.SendAck(
|
||||||
requestId = ackPayload.requestId,
|
requestId = requestId,
|
||||||
message = ackPayload.message
|
roomId = envelope.roomId,
|
||||||
|
message = message
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun parseError(payload: JsonObject?): DmChatSocketEvent.Error? {
|
private fun parseError(envelope: DmChatSocketEnvelope): DmChatSocketEvent.Error? {
|
||||||
val errorPayload = gson.fromJson(payload, DmChatSocketErrorPayload::class.java) ?: return null
|
val errorPayload = gson.fromJson(envelope.payload, DmChatSocketErrorPayload::class.java) ?: return null
|
||||||
return DmChatSocketEvent.Error(
|
return DmChatSocketEvent.Error(
|
||||||
requestId = errorPayload.requestId,
|
requestId = envelope.requestId,
|
||||||
code = errorPayload.code,
|
roomId = envelope.roomId,
|
||||||
message = errorPayload.message
|
messageKey = errorPayload.messageKey
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import androidx.recyclerview.widget.DiffUtil
|
|||||||
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.chat.talk.room.formatMessageTime
|
||||||
import kr.co.vividnext.sodalive.extensions.loadUrl
|
import kr.co.vividnext.sodalive.extensions.loadUrl
|
||||||
import kr.co.vividnext.sodalive.v2.main.chat.dm.model.DmChatMessageStatus
|
import kr.co.vividnext.sodalive.v2.main.chat.dm.model.DmChatMessageStatus
|
||||||
import kr.co.vividnext.sodalive.v2.main.chat.dm.model.DmChatMessageUiItem
|
import kr.co.vividnext.sodalive.v2.main.chat.dm.model.DmChatMessageUiItem
|
||||||
@@ -84,11 +85,13 @@ class DmChatMessageAdapter(
|
|||||||
) : RecyclerView.ViewHolder(itemView) {
|
) : RecyclerView.ViewHolder(itemView) {
|
||||||
private val tvMessage = itemView.findViewById<TextView>(R.id.tv_message)
|
private val tvMessage = itemView.findViewById<TextView>(R.id.tv_message)
|
||||||
private val tvStatus = itemView.findViewById<TextView>(R.id.tv_status)
|
private val tvStatus = itemView.findViewById<TextView>(R.id.tv_status)
|
||||||
|
private val tvTime = itemView.findViewById<TextView>(R.id.tv_time)
|
||||||
private val ivRetry = itemView.findViewById<ImageView>(R.id.iv_retry)
|
private val ivRetry = itemView.findViewById<ImageView>(R.id.iv_retry)
|
||||||
private val messageContainer = itemView.findViewById<View>(R.id.message_container)
|
private val messageContainer = itemView.findViewById<View>(R.id.message_container)
|
||||||
|
|
||||||
fun bind(item: DmChatMessageUiItem) {
|
fun bind(item: DmChatMessageUiItem) {
|
||||||
tvMessage.text = item.textMessage
|
tvMessage.text = item.textMessage
|
||||||
|
tvTime.text = formatMessageTime(item.createdAt)
|
||||||
tvMessage.maxWidth = (itemView.resources.displayMetrics.widthPixels * MESSAGE_MAX_WIDTH_RATIO).toInt()
|
tvMessage.maxWidth = (itemView.resources.displayMetrics.widthPixels * MESSAGE_MAX_WIDTH_RATIO).toInt()
|
||||||
|
|
||||||
val statusText = when (item.status) {
|
val statusText = when (item.status) {
|
||||||
@@ -119,10 +122,12 @@ class DmChatMessageAdapter(
|
|||||||
private val ivProfile = itemView.findViewById<ImageView>(R.id.iv_profile)
|
private val ivProfile = itemView.findViewById<ImageView>(R.id.iv_profile)
|
||||||
private val tvName = itemView.findViewById<TextView>(R.id.tv_name)
|
private val tvName = itemView.findViewById<TextView>(R.id.tv_name)
|
||||||
private val tvMessage = itemView.findViewById<TextView>(R.id.tv_message)
|
private val tvMessage = itemView.findViewById<TextView>(R.id.tv_message)
|
||||||
|
private val tvTime = itemView.findViewById<TextView>(R.id.tv_time)
|
||||||
|
|
||||||
fun bind(item: DmChatMessageUiItem) {
|
fun bind(item: DmChatMessageUiItem) {
|
||||||
tvName.text = item.senderNickname
|
tvName.text = item.senderNickname
|
||||||
tvMessage.text = item.textMessage
|
tvMessage.text = item.textMessage
|
||||||
|
tvTime.text = formatMessageTime(item.createdAt)
|
||||||
tvMessage.maxWidth = (itemView.resources.displayMetrics.widthPixels * MESSAGE_MAX_WIDTH_RATIO).toInt()
|
tvMessage.maxWidth = (itemView.resources.displayMetrics.widthPixels * MESSAGE_MAX_WIDTH_RATIO).toInt()
|
||||||
ivProfile.loadUrl(item.senderProfileImageUrl) {
|
ivProfile.loadUrl(item.senderProfileImageUrl) {
|
||||||
placeholder(R.drawable.ic_placeholder_profile)
|
placeholder(R.drawable.ic_placeholder_profile)
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
package kr.co.vividnext.sodalive.v2.main.chat.model
|
package kr.co.vividnext.sodalive.v2.main.chat.model
|
||||||
|
|
||||||
|
import androidx.annotation.StringRes
|
||||||
|
import kr.co.vividnext.sodalive.R
|
||||||
|
|
||||||
enum class ChatRoomFilter(val apiValue: String, val tabIndex: Int) {
|
enum class ChatRoomFilter(val apiValue: String, val tabIndex: Int) {
|
||||||
ALL("ALL", 0),
|
ALL("ALL", 0),
|
||||||
AI("AI", 1),
|
AI("AI", 1),
|
||||||
@@ -13,3 +16,10 @@ enum class ChatRoomFilter(val apiValue: String, val tabIndex: Int) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@StringRes
|
||||||
|
fun ChatRoomFilter.emptyMessageResId(): Int = when (this) {
|
||||||
|
ChatRoomFilter.ALL -> R.string.screen_chat_empty_all
|
||||||
|
ChatRoomFilter.AI -> R.string.screen_chat_empty_ai
|
||||||
|
ChatRoomFilter.DM -> R.string.screen_chat_empty_dm
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import kr.co.vividnext.sodalive.v2.main.content.data.MainContentAllTabResponse
|
|||||||
import kr.co.vividnext.sodalive.v2.main.content.data.MainContentAllType
|
import kr.co.vividnext.sodalive.v2.main.content.data.MainContentAllType
|
||||||
import kr.co.vividnext.sodalive.v2.main.content.model.MainContentAllTabUiState
|
import kr.co.vividnext.sodalive.v2.main.content.model.MainContentAllTabUiState
|
||||||
import kr.co.vividnext.sodalive.v2.main.content.model.currentDeviceDayOfWeek
|
import kr.co.vividnext.sodalive.v2.main.content.model.currentDeviceDayOfWeek
|
||||||
|
import kr.co.vividnext.sodalive.v2.main.content.model.normalizeContentAllSort
|
||||||
import kr.co.vividnext.sodalive.v2.main.content.model.toContent
|
import kr.co.vividnext.sodalive.v2.main.content.model.toContent
|
||||||
import kr.co.vividnext.sodalive.v2.main.content.model.toUiModel
|
import kr.co.vividnext.sodalive.v2.main.content.model.toUiModel
|
||||||
import kr.co.vividnext.sodalive.v2.main.content.model.usesDayOfWeekQuery
|
import kr.co.vividnext.sodalive.v2.main.content.model.usesDayOfWeekQuery
|
||||||
@@ -54,20 +55,21 @@ class ContentAllTabViewModel(
|
|||||||
|
|
||||||
fun changeType(type: MainContentAllType) {
|
fun changeType(type: MainContentAllType) {
|
||||||
selectedType = type
|
selectedType = type
|
||||||
|
selectedSort = type.normalizeContentAllSort(selectedSort)
|
||||||
selectedDayOfWeek = selectedDayOfWeekFor(type)
|
selectedDayOfWeek = selectedDayOfWeekFor(type)
|
||||||
loadFirstPage(type, selectedSort, selectedDayOfWeek)
|
loadFirstPage(type, selectedSort, selectedDayOfWeek)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun changeSort(sort: ContentSort) {
|
fun changeSort(sort: ContentSort) {
|
||||||
selectedSort = sort
|
selectedSort = selectedType.normalizeContentAllSort(sort)
|
||||||
loadFirstPage(selectedType, sort, selectedDayOfWeekFor(selectedType))
|
loadFirstPage(selectedType, selectedSort, selectedDayOfWeekFor(selectedType))
|
||||||
}
|
}
|
||||||
|
|
||||||
fun selectTypeAndSort(type: MainContentAllType, sort: ContentSort) {
|
fun selectTypeAndSort(type: MainContentAllType, sort: ContentSort) {
|
||||||
selectedType = type
|
selectedType = type
|
||||||
selectedSort = sort
|
selectedSort = type.normalizeContentAllSort(sort)
|
||||||
selectedDayOfWeek = selectedDayOfWeekFor(type)
|
selectedDayOfWeek = selectedDayOfWeekFor(type)
|
||||||
loadFirstPage(type, sort, selectedDayOfWeek)
|
loadFirstPage(type, selectedSort, selectedDayOfWeek)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun changeDayOfWeek(dayOfWeek: SeriesPublishedDaysOfWeek) {
|
fun changeDayOfWeek(dayOfWeek: SeriesPublishedDaysOfWeek) {
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ import kr.co.vividnext.sodalive.common.LoadingDialog
|
|||||||
import kr.co.vividnext.sodalive.common.ToastMessage
|
import kr.co.vividnext.sodalive.common.ToastMessage
|
||||||
import kr.co.vividnext.sodalive.databinding.FragmentV2MainContentBinding
|
import kr.co.vividnext.sodalive.databinding.FragmentV2MainContentBinding
|
||||||
import kr.co.vividnext.sodalive.databinding.ViewSectionTitleBinding
|
import kr.co.vividnext.sodalive.databinding.ViewSectionTitleBinding
|
||||||
import kr.co.vividnext.sodalive.extensions.moneyFormat
|
|
||||||
import kr.co.vividnext.sodalive.home.SeriesPublishedDaysOfWeek
|
import kr.co.vividnext.sodalive.home.SeriesPublishedDaysOfWeek
|
||||||
import kr.co.vividnext.sodalive.mypage.can.charge.CanChargeActivity
|
import kr.co.vividnext.sodalive.mypage.can.charge.CanChargeActivity
|
||||||
import kr.co.vividnext.sodalive.search.SearchActivity
|
import kr.co.vividnext.sodalive.search.SearchActivity
|
||||||
@@ -44,6 +43,7 @@ import kr.co.vividnext.sodalive.v2.main.content.model.ContentCommentedAudioUiMod
|
|||||||
import kr.co.vividnext.sodalive.v2.main.content.model.ContentOriginalSeriesSection
|
import kr.co.vividnext.sodalive.v2.main.content.model.ContentOriginalSeriesSection
|
||||||
import kr.co.vividnext.sodalive.v2.main.content.model.ContentOriginalSeriesUiModel
|
import kr.co.vividnext.sodalive.v2.main.content.model.ContentOriginalSeriesUiModel
|
||||||
import kr.co.vividnext.sodalive.v2.main.content.model.MainContentAllTabUiState
|
import kr.co.vividnext.sodalive.v2.main.content.model.MainContentAllTabUiState
|
||||||
|
import kr.co.vividnext.sodalive.v2.main.content.model.contentAllSortOptions
|
||||||
import kr.co.vividnext.sodalive.v2.main.content.model.contentAllDayOfWeekOptions
|
import kr.co.vividnext.sodalive.v2.main.content.model.contentAllDayOfWeekOptions
|
||||||
import kr.co.vividnext.sodalive.v2.main.content.model.toContentAllDayLabelResId
|
import kr.co.vividnext.sodalive.v2.main.content.model.toContentAllDayLabelResId
|
||||||
import kr.co.vividnext.sodalive.v2.main.content.model.toContentAllTypeLabelResId
|
import kr.co.vividnext.sodalive.v2.main.content.model.toContentAllTypeLabelResId
|
||||||
@@ -232,7 +232,7 @@ class ContentMainFragment : BaseFragment<FragmentV2MainContentBinding>(
|
|||||||
private fun setUpAllSortButton() {
|
private fun setUpAllSortButton() {
|
||||||
binding.layoutContentAllSortButton.setOnClickListener {
|
binding.layoutContentAllSortButton.setOnClickListener {
|
||||||
val state = currentAllTabState ?: return@setOnClickListener
|
val state = currentAllTabState ?: return@setOnClickListener
|
||||||
showAllSortPopup(state.selectedSort)
|
showAllSortPopup(state)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -411,11 +411,11 @@ class ContentMainFragment : BaseFragment<FragmentV2MainContentBinding>(
|
|||||||
hideAllTabEmptyError()
|
hideAllTabEmptyError()
|
||||||
updateAllTabGridItemWidth()
|
updateAllTabGridItemWidth()
|
||||||
if (state.selectedType.usesSeriesItems()) {
|
if (state.selectedType.usesSeriesItems()) {
|
||||||
binding.rvContentAllItems.adapter = contentAllSeriesCardAdapter
|
setAllTabAdapterIfNeeded(contentAllSeriesCardAdapter)
|
||||||
contentAllAudioCardAdapter.submitItems(emptyList())
|
contentAllAudioCardAdapter.submitItems(emptyList())
|
||||||
contentAllSeriesCardAdapter.submitItems(state.seriesItems)
|
contentAllSeriesCardAdapter.submitItems(state.seriesItems)
|
||||||
} else {
|
} else {
|
||||||
binding.rvContentAllItems.adapter = contentAllAudioCardAdapter
|
setAllTabAdapterIfNeeded(contentAllAudioCardAdapter)
|
||||||
contentAllSeriesCardAdapter.submitItems(emptyList())
|
contentAllSeriesCardAdapter.submitItems(emptyList())
|
||||||
contentAllAudioCardAdapter.submitItems(state.audioItems)
|
contentAllAudioCardAdapter.submitItems(state.audioItems)
|
||||||
}
|
}
|
||||||
@@ -439,6 +439,11 @@ class ContentMainFragment : BaseFragment<FragmentV2MainContentBinding>(
|
|||||||
contentAllSeriesCardAdapter.setGridItemWidthPx(widthPx)
|
contentAllSeriesCardAdapter.setGridItemWidthPx(widthPx)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun setAllTabAdapterIfNeeded(adapter: RecyclerView.Adapter<*>) {
|
||||||
|
if (binding.rvContentAllItems.adapter == adapter) return
|
||||||
|
binding.rvContentAllItems.adapter = adapter
|
||||||
|
}
|
||||||
|
|
||||||
private fun bindAllTabEmpty(state: MainContentAllTabUiState.Empty) {
|
private fun bindAllTabEmpty(state: MainContentAllTabUiState.Empty) {
|
||||||
bindAllTabControls(state)
|
bindAllTabControls(state)
|
||||||
binding.layoutContentAllSurface.visibility = View.VISIBLE
|
binding.layoutContentAllSurface.visibility = View.VISIBLE
|
||||||
@@ -463,7 +468,6 @@ class ContentMainFragment : BaseFragment<FragmentV2MainContentBinding>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun bindAllTabControls(state: MainContentAllTabUiState) {
|
private fun bindAllTabControls(state: MainContentAllTabUiState) {
|
||||||
binding.tvContentAllTotalCount.text = state.totalCount.moneyFormat()
|
|
||||||
binding.tvContentAllSortLabel.setText(state.selectedSort.toLabelResId())
|
binding.tvContentAllSortLabel.setText(state.selectedSort.toLabelResId())
|
||||||
binding.viewContentAllTypeTabs.root.setMenus(
|
binding.viewContentAllTypeTabs.root.setMenus(
|
||||||
contentAllTypes.map { type -> getString(type.toContentAllTypeLabelResId()) },
|
contentAllTypes.map { type -> getString(type.toContentAllTypeLabelResId()) },
|
||||||
@@ -486,7 +490,6 @@ class ContentMainFragment : BaseFragment<FragmentV2MainContentBinding>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun clearAllTabItems() {
|
private fun clearAllTabItems() {
|
||||||
binding.rvContentAllItems.adapter = null
|
|
||||||
contentAllAudioCardAdapter.submitItems(emptyList())
|
contentAllAudioCardAdapter.submitItems(emptyList())
|
||||||
contentAllSeriesCardAdapter.submitItems(emptyList())
|
contentAllSeriesCardAdapter.submitItems(emptyList())
|
||||||
}
|
}
|
||||||
@@ -495,13 +498,15 @@ class ContentMainFragment : BaseFragment<FragmentV2MainContentBinding>(
|
|||||||
binding.layoutContentAllEmptyError.visibility = View.GONE
|
binding.layoutContentAllEmptyError.visibility = View.GONE
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun showAllSortPopup(selectedSort: ContentSort) {
|
private fun showAllSortPopup(state: MainContentAllTabUiState) {
|
||||||
|
val sortOptions = state.selectedType.contentAllSortOptions()
|
||||||
sortPopup?.dismiss()
|
sortPopup?.dismiss()
|
||||||
sortPopup = CreatorChannelSortPopup(
|
sortPopup = CreatorChannelSortPopup(
|
||||||
anchor = binding.layoutContentAllSortButton,
|
anchor = binding.layoutContentAllSortButton,
|
||||||
selectedSort = selectedSort,
|
selectedSort = state.selectedSort,
|
||||||
|
sortOptions = sortOptions,
|
||||||
onSortSelected = { sort ->
|
onSortSelected = { sort ->
|
||||||
if (sort in ContentSort.entries) {
|
if (sort in sortOptions) {
|
||||||
contentAllTabViewModel.changeSort(sort)
|
contentAllTabViewModel.changeSort(sort)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package kr.co.vividnext.sodalive.v2.main.content.model
|
|||||||
|
|
||||||
import androidx.annotation.StringRes
|
import androidx.annotation.StringRes
|
||||||
import kr.co.vividnext.sodalive.R
|
import kr.co.vividnext.sodalive.R
|
||||||
|
import kr.co.vividnext.sodalive.v2.common.data.ContentSort
|
||||||
import kr.co.vividnext.sodalive.v2.main.content.data.MainContentAllType
|
import kr.co.vividnext.sodalive.v2.main.content.data.MainContentAllType
|
||||||
import kr.co.vividnext.sodalive.v2.widget.AudioContentTag
|
import kr.co.vividnext.sodalive.v2.widget.AudioContentTag
|
||||||
|
|
||||||
@@ -47,3 +48,24 @@ fun MainContentAllType.usesSeriesItems(): Boolean = when (this) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun MainContentAllType.usesDayOfWeekQuery(): Boolean = this == MainContentAllType.SERIES
|
fun MainContentAllType.usesDayOfWeekQuery(): Boolean = this == MainContentAllType.SERIES
|
||||||
|
|
||||||
|
fun MainContentAllType.contentAllSortOptions(): List<ContentSort> = when (this) {
|
||||||
|
MainContentAllType.FREE -> listOf(
|
||||||
|
ContentSort.LATEST,
|
||||||
|
ContentSort.POPULAR
|
||||||
|
)
|
||||||
|
|
||||||
|
MainContentAllType.AUDIO,
|
||||||
|
MainContentAllType.SERIES,
|
||||||
|
MainContentAllType.ORIGINAL,
|
||||||
|
MainContentAllType.POINT -> listOf(
|
||||||
|
ContentSort.LATEST,
|
||||||
|
ContentSort.POPULAR,
|
||||||
|
ContentSort.PRICE_HIGH,
|
||||||
|
ContentSort.PRICE_LOW
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun MainContentAllType.normalizeContentAllSort(sort: ContentSort): ContentSort {
|
||||||
|
return if (sort in contentAllSortOptions()) sort else ContentSort.LATEST
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package kr.co.vividnext.sodalive.v2.main.content.ui
|
|||||||
|
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
|
import androidx.recyclerview.widget.DiffUtil
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import kr.co.vividnext.sodalive.databinding.ItemContentAudioCardBinding
|
import kr.co.vividnext.sodalive.databinding.ItemContentAudioCardBinding
|
||||||
import kr.co.vividnext.sodalive.extensions.loadUrl
|
import kr.co.vividnext.sodalive.extensions.loadUrl
|
||||||
@@ -17,12 +18,13 @@ class ContentAllAudioCardAdapter(
|
|||||||
fun setGridItemWidthPx(widthPx: Int) {
|
fun setGridItemWidthPx(widthPx: Int) {
|
||||||
if (widthPx <= 0 || gridItemWidthPx == widthPx) return
|
if (widthPx <= 0 || gridItemWidthPx == widthPx) return
|
||||||
gridItemWidthPx = widthPx
|
gridItemWidthPx = widthPx
|
||||||
notifyDataSetChanged()
|
if (items.isNotEmpty()) notifyItemRangeChanged(0, items.size)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun submitItems(items: List<MainContentAllAudioUiModel>) {
|
fun submitItems(newItems: List<MainContentAllAudioUiModel>) {
|
||||||
this.items = items
|
val diffResult = DiffUtil.calculateDiff(ContentAllAudioDiffCallback(items, newItems))
|
||||||
notifyDataSetChanged()
|
items = newItems
|
||||||
|
diffResult.dispatchUpdatesTo(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
|
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
|
||||||
@@ -41,6 +43,20 @@ class ContentAllAudioCardAdapter(
|
|||||||
|
|
||||||
override fun getItemCount(): Int = items.size
|
override fun getItemCount(): Int = items.size
|
||||||
|
|
||||||
|
private class ContentAllAudioDiffCallback(
|
||||||
|
private val oldList: List<MainContentAllAudioUiModel>,
|
||||||
|
private val newList: List<MainContentAllAudioUiModel>
|
||||||
|
) : DiffUtil.Callback() {
|
||||||
|
override fun getOldListSize(): Int = oldList.size
|
||||||
|
override fun getNewListSize(): Int = newList.size
|
||||||
|
|
||||||
|
override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean =
|
||||||
|
oldList[oldItemPosition].audioContentId == newList[newItemPosition].audioContentId
|
||||||
|
|
||||||
|
override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean =
|
||||||
|
oldList[oldItemPosition] == newList[newItemPosition]
|
||||||
|
}
|
||||||
|
|
||||||
class ViewHolder(
|
class ViewHolder(
|
||||||
private val binding: ItemContentAudioCardBinding,
|
private val binding: ItemContentAudioCardBinding,
|
||||||
private val onAudioClick: (Long) -> Unit
|
private val onAudioClick: (Long) -> Unit
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package kr.co.vividnext.sodalive.v2.main.content.ui
|
|||||||
|
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
|
import androidx.recyclerview.widget.DiffUtil
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import kr.co.vividnext.sodalive.databinding.ItemContentAllSeriesCardBinding
|
import kr.co.vividnext.sodalive.databinding.ItemContentAllSeriesCardBinding
|
||||||
import kr.co.vividnext.sodalive.extensions.loadUrl
|
import kr.co.vividnext.sodalive.extensions.loadUrl
|
||||||
@@ -17,12 +18,13 @@ class ContentAllSeriesCardAdapter(
|
|||||||
fun setGridItemWidthPx(widthPx: Int) {
|
fun setGridItemWidthPx(widthPx: Int) {
|
||||||
if (widthPx <= 0 || gridItemWidthPx == widthPx) return
|
if (widthPx <= 0 || gridItemWidthPx == widthPx) return
|
||||||
gridItemWidthPx = widthPx
|
gridItemWidthPx = widthPx
|
||||||
notifyDataSetChanged()
|
if (items.isNotEmpty()) notifyItemRangeChanged(0, items.size)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun submitItems(items: List<MainContentAllSeriesUiModel>) {
|
fun submitItems(newItems: List<MainContentAllSeriesUiModel>) {
|
||||||
this.items = items
|
val diffResult = DiffUtil.calculateDiff(ContentAllSeriesDiffCallback(items, newItems))
|
||||||
notifyDataSetChanged()
|
items = newItems
|
||||||
|
diffResult.dispatchUpdatesTo(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
|
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
|
||||||
@@ -41,6 +43,20 @@ class ContentAllSeriesCardAdapter(
|
|||||||
|
|
||||||
override fun getItemCount(): Int = items.size
|
override fun getItemCount(): Int = items.size
|
||||||
|
|
||||||
|
private class ContentAllSeriesDiffCallback(
|
||||||
|
private val oldList: List<MainContentAllSeriesUiModel>,
|
||||||
|
private val newList: List<MainContentAllSeriesUiModel>
|
||||||
|
) : DiffUtil.Callback() {
|
||||||
|
override fun getOldListSize(): Int = oldList.size
|
||||||
|
override fun getNewListSize(): Int = newList.size
|
||||||
|
|
||||||
|
override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean =
|
||||||
|
oldList[oldItemPosition].seriesId == newList[newItemPosition].seriesId
|
||||||
|
|
||||||
|
override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean =
|
||||||
|
oldList[oldItemPosition] == newList[newItemPosition]
|
||||||
|
}
|
||||||
|
|
||||||
class ViewHolder(
|
class ViewHolder(
|
||||||
private val binding: ItemContentAllSeriesCardBinding,
|
private val binding: ItemContentAllSeriesCardBinding,
|
||||||
private val onSeriesClick: (Long) -> Unit
|
private val onSeriesClick: (Long) -> Unit
|
||||||
|
|||||||
@@ -6,14 +6,12 @@ import android.view.View
|
|||||||
import androidx.recyclerview.widget.LinearLayoutManager
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
import kr.co.vividnext.sodalive.R
|
import kr.co.vividnext.sodalive.R
|
||||||
import kr.co.vividnext.sodalive.base.BaseFragment
|
import kr.co.vividnext.sodalive.base.BaseFragment
|
||||||
import kr.co.vividnext.sodalive.common.Constants
|
|
||||||
import kr.co.vividnext.sodalive.common.LoadingDialog
|
import kr.co.vividnext.sodalive.common.LoadingDialog
|
||||||
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.formatUtcRelativeTimeText
|
import kr.co.vividnext.sodalive.common.formatUtcRelativeTimeText
|
||||||
import kr.co.vividnext.sodalive.databinding.FragmentV2MainHomeBinding
|
import kr.co.vividnext.sodalive.databinding.FragmentV2MainHomeBinding
|
||||||
import kr.co.vividnext.sodalive.databinding.ViewSectionTitleBinding
|
import kr.co.vividnext.sodalive.databinding.ViewSectionTitleBinding
|
||||||
import kr.co.vividnext.sodalive.explorer.profile.creator_community.all.CreatorCommunityAllActivity
|
|
||||||
import kr.co.vividnext.sodalive.following.FollowingCreatorActivity
|
import kr.co.vividnext.sodalive.following.FollowingCreatorActivity
|
||||||
import kr.co.vividnext.sodalive.chat.talk.room.ChatRoomActivity
|
import kr.co.vividnext.sodalive.chat.talk.room.ChatRoomActivity
|
||||||
import kr.co.vividnext.sodalive.home.pushnotification.PushNotificationListActivity
|
import kr.co.vividnext.sodalive.home.pushnotification.PushNotificationListActivity
|
||||||
@@ -22,6 +20,7 @@ import kr.co.vividnext.sodalive.mypage.can.charge.CanChargeActivity
|
|||||||
import kr.co.vividnext.sodalive.search.SearchActivity
|
import kr.co.vividnext.sodalive.search.SearchActivity
|
||||||
import kr.co.vividnext.sodalive.v2.creator.channel.CreatorChannelActivity
|
import kr.co.vividnext.sodalive.v2.creator.channel.CreatorChannelActivity
|
||||||
import kr.co.vividnext.sodalive.v2.creator.channel.CreatorChannelLiveCoordinator
|
import kr.co.vividnext.sodalive.v2.creator.channel.CreatorChannelLiveCoordinator
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.community.detail.CreatorChannelCommunityDetailActivity
|
||||||
import kr.co.vividnext.sodalive.v2.live.onair.HomeOnAirLiveActivity
|
import kr.co.vividnext.sodalive.v2.live.onair.HomeOnAirLiveActivity
|
||||||
import kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomActivity
|
import kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomActivity
|
||||||
import kr.co.vividnext.sodalive.v2.main.chat.model.ChatRoomListUiItem
|
import kr.co.vividnext.sodalive.v2.main.chat.model.ChatRoomListUiItem
|
||||||
@@ -42,7 +41,6 @@ import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationAiCharacter
|
|||||||
import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationBannerSection
|
import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationBannerSection
|
||||||
import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationBannerUiModel
|
import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationBannerUiModel
|
||||||
import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationCheerCreatorSection
|
import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationCheerCreatorSection
|
||||||
import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationGenreCreatorGroupUiModel
|
|
||||||
import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationGenreCreatorSection
|
import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationGenreCreatorSection
|
||||||
import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationLiveSection
|
import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationLiveSection
|
||||||
import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationLiveUiModel
|
import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationLiveUiModel
|
||||||
@@ -58,7 +56,6 @@ import kr.co.vividnext.sodalive.v2.main.home.model.toHomeRecommendationAiCharact
|
|||||||
import kr.co.vividnext.sodalive.v2.main.home.model.toHomeRecommendationAiCharacterRoute
|
import kr.co.vividnext.sodalive.v2.main.home.model.toHomeRecommendationAiCharacterRoute
|
||||||
import kr.co.vividnext.sodalive.v2.main.home.model.toHomeRecommendationRecentlyActiveCreatorIntent
|
import kr.co.vividnext.sodalive.v2.main.home.model.toHomeRecommendationRecentlyActiveCreatorIntent
|
||||||
import kr.co.vividnext.sodalive.v2.main.home.model.toHomeRecommendationRecentlyActiveCreatorRoute
|
import kr.co.vividnext.sodalive.v2.main.home.model.toHomeRecommendationRecentlyActiveCreatorRoute
|
||||||
import kr.co.vividnext.sodalive.v2.main.home.model.visibleHomeGenreCreatorGroups
|
|
||||||
import kr.co.vividnext.sodalive.v2.main.home.ui.HomeAiCharacterAdapter
|
import kr.co.vividnext.sodalive.v2.main.home.ui.HomeAiCharacterAdapter
|
||||||
import kr.co.vividnext.sodalive.v2.main.home.ui.HomeBannerBinder
|
import kr.co.vividnext.sodalive.v2.main.home.ui.HomeBannerBinder
|
||||||
import kr.co.vividnext.sodalive.v2.main.home.ui.HomeBusinessInfoBinder
|
import kr.co.vividnext.sodalive.v2.main.home.ui.HomeBusinessInfoBinder
|
||||||
@@ -101,10 +98,7 @@ class HomeMainFragment : BaseFragment<FragmentV2MainHomeBinding>(
|
|||||||
private val recentActivityCreatorAdapter = HomeRecentActivityCreatorAdapter { onRecentActivityClick(it) }
|
private val recentActivityCreatorAdapter = HomeRecentActivityCreatorAdapter { onRecentActivityClick(it) }
|
||||||
private val recentDebutCreatorAdapter = HomeRecentDebutCreatorAdapter { openCreatorProfile(it.creatorId) }
|
private val recentDebutCreatorAdapter = HomeRecentDebutCreatorAdapter { openCreatorProfile(it.creatorId) }
|
||||||
private val aiCharacterAdapter = HomeAiCharacterAdapter { onAiCharacterClick(it) }
|
private val aiCharacterAdapter = HomeAiCharacterAdapter { onAiCharacterClick(it) }
|
||||||
private val genreCreatorAdapter = HomeGenreCreatorAdapter(
|
private val genreCreatorAdapter = HomeGenreCreatorAdapter(onFollowAllClick = {})
|
||||||
onFollowAllClick = { creatorIds -> onGenreFollowAllClick(creatorIds) },
|
|
||||||
onCreatorClick = { creator -> openCreatorProfile(creator.creatorId) }
|
|
||||||
)
|
|
||||||
private val cheerCreatorAdapter = HomeCheerCreatorAdapter(
|
private val cheerCreatorAdapter = HomeCheerCreatorAdapter(
|
||||||
onFollowAllClick = { creatorIds -> onCheerFollowAllClick(creatorIds) },
|
onFollowAllClick = { creatorIds -> onCheerFollowAllClick(creatorIds) },
|
||||||
onCreatorClick = { creator -> openCreatorProfile(creator.creatorId) }
|
onCreatorClick = { creator -> openCreatorProfile(creator.creatorId) }
|
||||||
@@ -120,7 +114,6 @@ class HomeMainFragment : BaseFragment<FragmentV2MainHomeBinding>(
|
|||||||
private val followingScheduleAdapter = HomeFollowingScheduleAdapter { onFollowingScheduleClick(it) }
|
private val followingScheduleAdapter = HomeFollowingScheduleAdapter { onFollowingScheduleClick(it) }
|
||||||
private val followingNewsAdapter = HomeFollowingNewsAdapter { onFollowingNewsClick(it) }
|
private val followingNewsAdapter = HomeFollowingNewsAdapter { onFollowingNewsClick(it) }
|
||||||
private var bannerBinder: HomeBannerBinder? = null
|
private var bannerBinder: HomeBannerBinder? = null
|
||||||
private var onGenreFollowAllClick: (List<Long>) -> Unit = {}
|
|
||||||
private var onCheerFollowAllClick: (List<Long>) -> Unit = {}
|
private var onCheerFollowAllClick: (List<Long>) -> Unit = {}
|
||||||
private var hasLoadedCreatorRankings = false
|
private var hasLoadedCreatorRankings = false
|
||||||
private var hasLoadedFollowing = false
|
private var hasLoadedFollowing = false
|
||||||
@@ -219,9 +212,6 @@ class HomeMainFragment : BaseFragment<FragmentV2MainHomeBinding>(
|
|||||||
layoutManager = LinearLayoutManager(requireContext(), LinearLayoutManager.VERTICAL, false)
|
layoutManager = LinearLayoutManager(requireContext(), LinearLayoutManager.VERTICAL, false)
|
||||||
adapter = popularCommunityAdapter
|
adapter = popularCommunityAdapter
|
||||||
}
|
}
|
||||||
onGenreFollowAllClick = { creatorIds ->
|
|
||||||
homeRecommendationViewModel.followCreators(genreSectionKey(creatorIds), creatorIds)
|
|
||||||
}
|
|
||||||
onCheerFollowAllClick = { creatorIds ->
|
onCheerFollowAllClick = { creatorIds ->
|
||||||
homeRecommendationViewModel.followCreators(SECTION_KEY_CHEER_CREATORS, creatorIds)
|
homeRecommendationViewModel.followCreators(SECTION_KEY_CHEER_CREATORS, creatorIds)
|
||||||
}
|
}
|
||||||
@@ -459,9 +449,8 @@ class HomeMainFragment : BaseFragment<FragmentV2MainHomeBinding>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun bindGenreCreatorSection(section: HomeRecommendationGenreCreatorSection) {
|
private fun bindGenreCreatorSection(section: HomeRecommendationGenreCreatorSection) {
|
||||||
val visibleGroups = section.visibleHomeGenreCreatorGroups()
|
binding.llHomeGenreCreatorSection.visibility = View.GONE
|
||||||
binding.llHomeGenreCreatorSection.visibility = visibleGroups.toSectionVisibility()
|
genreCreatorAdapter.submitGroups(emptyList())
|
||||||
genreCreatorAdapter.submitGroups(visibleGroups)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun bindCheerCreatorSection(section: HomeRecommendationCheerCreatorSection) {
|
private fun bindCheerCreatorSection(section: HomeRecommendationCheerCreatorSection) {
|
||||||
@@ -495,12 +484,10 @@ class HomeMainFragment : BaseFragment<FragmentV2MainHomeBinding>(
|
|||||||
R.string.home_recommendation_section_recently_active_creators
|
R.string.home_recommendation_section_recently_active_creators
|
||||||
)
|
)
|
||||||
binding.viewHomeRecentDebutTitle.setTitle(
|
binding.viewHomeRecentDebutTitle.setTitle(
|
||||||
R.string.home_recommendation_section_recent_debut_creators,
|
R.string.home_recommendation_section_recent_debut_creators
|
||||||
showMore = true
|
|
||||||
)
|
)
|
||||||
binding.viewHomeAiCharacterTitle.setTitle(
|
binding.viewHomeAiCharacterTitle.setTitle(
|
||||||
R.string.home_recommendation_section_ai_characters,
|
R.string.home_recommendation_section_ai_characters
|
||||||
showMore = true
|
|
||||||
)
|
)
|
||||||
binding.viewHomeCheerCreatorTitle.setTitle(
|
binding.viewHomeCheerCreatorTitle.setTitle(
|
||||||
R.string.home_recommendation_section_cheer_creators
|
R.string.home_recommendation_section_cheer_creators
|
||||||
@@ -565,7 +552,14 @@ class HomeMainFragment : BaseFragment<FragmentV2MainHomeBinding>(
|
|||||||
|
|
||||||
private fun onFollowingScheduleClick(item: HomeFollowingScheduleUiItem) = Unit
|
private fun onFollowingScheduleClick(item: HomeFollowingScheduleUiItem) = Unit
|
||||||
|
|
||||||
private fun onFollowingNewsClick(item: HomeFollowingNewsUiItem) = Unit
|
private fun onFollowingNewsClick(item: HomeFollowingNewsUiItem) {
|
||||||
|
val community = item as? HomeFollowingNewsUiItem.Community ?: return
|
||||||
|
val postId = community.postId
|
||||||
|
if (postId <= 0L) return
|
||||||
|
ensureMainV2NavigationAllowed {
|
||||||
|
startActivity(CreatorChannelCommunityDetailActivity.newIntent(requireContext(), postId))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun openFollowingCreatorAll() {
|
private fun openFollowingCreatorAll() {
|
||||||
ensureMainV2NavigationAllowed {
|
ensureMainV2NavigationAllowed {
|
||||||
@@ -617,16 +611,9 @@ class HomeMainFragment : BaseFragment<FragmentV2MainHomeBinding>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun openPopularCommunityPost(item: FeedItem.Community) {
|
private fun openPopularCommunityPost(item: FeedItem.Community) {
|
||||||
val creatorId = item.creatorId.toLongOrNull() ?: return
|
|
||||||
val postId = item.postId.toLongOrNull() ?: return
|
val postId = item.postId.toLongOrNull() ?: return
|
||||||
ensureMainV2NavigationAllowed {
|
ensureMainV2NavigationAllowed {
|
||||||
startActivity(
|
startActivity(CreatorChannelCommunityDetailActivity.newIntent(requireContext(), postId))
|
||||||
Intent(requireContext(), CreatorCommunityAllActivity::class.java).apply {
|
|
||||||
putExtra(Constants.EXTRA_COMMUNITY_CREATOR_ID, creatorId)
|
|
||||||
putExtra(Constants.EXTRA_COMMUNITY_POST_ID, postId)
|
|
||||||
putExtra(Constants.EXTRA_COMMUNITY_EXIST_ORDERED, item.existOrdered)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -635,19 +622,6 @@ class HomeMainFragment : BaseFragment<FragmentV2MainHomeBinding>(
|
|||||||
?: toastMessage.resId?.let { resId -> showToast(getString(resId)) }
|
?: toastMessage.resId?.let { resId -> showToast(getString(resId)) }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun genreSectionKey(creatorIds: List<Long>): String {
|
|
||||||
val group = currentVisibleGenreGroups().firstOrNull { group ->
|
|
||||||
group.creators.map { it.creatorId } == creatorIds
|
|
||||||
}
|
|
||||||
return "$SECTION_KEY_GENRE_CREATORS:${group?.genre.orEmpty()}"
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun currentVisibleGenreGroups(): List<HomeRecommendationGenreCreatorGroupUiModel> {
|
|
||||||
val content = homeRecommendationViewModel.recommendationStateLiveData.value as? HomeRecommendationUiState.Content
|
|
||||||
?: return emptyList()
|
|
||||||
return content.genreCreators.visibleHomeGenreCreatorGroups()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun emptyHomeRecommendationContent(): HomeRecommendationUiState.Content {
|
private fun emptyHomeRecommendationContent(): HomeRecommendationUiState.Content {
|
||||||
return HomeRecommendationUiState.Content(
|
return HomeRecommendationUiState.Content(
|
||||||
lives = HomeRecommendationLiveSection(emptyList()),
|
lives = HomeRecommendationLiveSection(emptyList()),
|
||||||
@@ -668,7 +642,6 @@ class HomeMainFragment : BaseFragment<FragmentV2MainHomeBinding>(
|
|||||||
const val HOME_TAB_RANKING = 1
|
const val HOME_TAB_RANKING = 1
|
||||||
const val HOME_TAB_FOLLOWING = 2
|
const val HOME_TAB_FOLLOWING = 2
|
||||||
const val SECTION_KEY_CHEER_CREATORS = "cheerCreators"
|
const val SECTION_KEY_CHEER_CREATORS = "cheerCreators"
|
||||||
const val SECTION_KEY_GENRE_CREATORS = "genreCreators"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ 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.v2.main.home.data.FollowRecommendedCreatorsRequest
|
import kr.co.vividnext.sodalive.v2.main.home.data.FollowRecommendedCreatorsRequest
|
||||||
import kr.co.vividnext.sodalive.v2.main.home.data.HomeRecommendationRepository
|
import kr.co.vividnext.sodalive.v2.main.home.data.HomeRecommendationRepository
|
||||||
import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationGenreCreatorSection
|
|
||||||
import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationUiState
|
import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationUiState
|
||||||
import kr.co.vividnext.sodalive.v2.main.home.model.toContent
|
import kr.co.vividnext.sodalive.v2.main.home.model.toContent
|
||||||
|
|
||||||
@@ -94,22 +93,10 @@ class HomeRecommendationViewModel(
|
|||||||
|
|
||||||
private fun updateFollowCompleted(sectionKey: String) {
|
private fun updateFollowCompleted(sectionKey: String) {
|
||||||
val content = _recommendationStateLiveData.value as? HomeRecommendationUiState.Content ?: return
|
val content = _recommendationStateLiveData.value as? HomeRecommendationUiState.Content ?: return
|
||||||
_recommendationStateLiveData.value = when (sectionKey) {
|
if (sectionKey != SECTION_KEY_CHEER_CREATORS) return
|
||||||
SECTION_KEY_CHEER_CREATORS -> content.copy(
|
_recommendationStateLiveData.value = content.copy(
|
||||||
cheerCreators = content.cheerCreators.copy(isFollowCompleted = true)
|
cheerCreators = content.cheerCreators.copy(isFollowCompleted = true)
|
||||||
)
|
)
|
||||||
else -> content.copy(
|
|
||||||
genreCreators = HomeRecommendationGenreCreatorSection(
|
|
||||||
groups = content.genreCreators.groups.map { group ->
|
|
||||||
if (group.genre == sectionKey || sectionKey == "$SECTION_KEY_GENRE_CREATORS:${group.genre}") {
|
|
||||||
group.copy(isFollowCompleted = true)
|
|
||||||
} else {
|
|
||||||
group
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun showUnknownError(message: String?) {
|
private fun showUnknownError(message: String?) {
|
||||||
@@ -121,6 +108,5 @@ class HomeRecommendationViewModel(
|
|||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
const val SECTION_KEY_CHEER_CREATORS = "cheerCreators"
|
const val SECTION_KEY_CHEER_CREATORS = "cheerCreators"
|
||||||
const val SECTION_KEY_GENRE_CREATORS = "genreCreators"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,11 +7,11 @@ import kr.co.vividnext.sodalive.BuildConfig
|
|||||||
import kr.co.vividnext.sodalive.audio_content.detail.AudioContentDetailActivity
|
import kr.co.vividnext.sodalive.audio_content.detail.AudioContentDetailActivity
|
||||||
import kr.co.vividnext.sodalive.audio_content.series.detail.SeriesDetailActivity
|
import kr.co.vividnext.sodalive.audio_content.series.detail.SeriesDetailActivity
|
||||||
import kr.co.vividnext.sodalive.common.Constants
|
import kr.co.vividnext.sodalive.common.Constants
|
||||||
import kr.co.vividnext.sodalive.explorer.profile.creator_community.all.CreatorCommunityAllActivity
|
|
||||||
import kr.co.vividnext.sodalive.settings.event.EventDetailActivity
|
import kr.co.vividnext.sodalive.settings.event.EventDetailActivity
|
||||||
import kr.co.vividnext.sodalive.settings.event.EventItem
|
import kr.co.vividnext.sodalive.settings.event.EventItem
|
||||||
import kr.co.vividnext.sodalive.v2.common.CreatorActivityType
|
import kr.co.vividnext.sodalive.v2.common.CreatorActivityType
|
||||||
import kr.co.vividnext.sodalive.v2.creator.channel.CreatorChannelActivity
|
import kr.co.vividnext.sodalive.v2.creator.channel.CreatorChannelActivity
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.community.detail.CreatorChannelCommunityDetailActivity
|
||||||
import kr.co.vividnext.sodalive.v2.widget.characterchatthumbnail.CharacterChatThumbnailItem
|
import kr.co.vividnext.sodalive.v2.widget.characterchatthumbnail.CharacterChatThumbnailItem
|
||||||
import kr.co.vividnext.sodalive.v2.widget.feed.FeedItem
|
import kr.co.vividnext.sodalive.v2.widget.feed.FeedItem
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
@@ -136,7 +136,7 @@ data class HomeRecommendationRecentlyActiveCreatorUiModel(
|
|||||||
sealed interface HomeRecommendationRecentlyActiveCreatorRoute {
|
sealed interface HomeRecommendationRecentlyActiveCreatorRoute {
|
||||||
data class AudioContent(val contentId: Long) : HomeRecommendationRecentlyActiveCreatorRoute
|
data class AudioContent(val contentId: Long) : HomeRecommendationRecentlyActiveCreatorRoute
|
||||||
|
|
||||||
data class Community(val creatorId: Long) : HomeRecommendationRecentlyActiveCreatorRoute
|
data class Community(val postId: Long) : HomeRecommendationRecentlyActiveCreatorRoute
|
||||||
}
|
}
|
||||||
|
|
||||||
fun HomeRecommendationRecentlyActiveCreatorUiModel.toHomeRecommendationRecentlyActiveCreatorRoute():
|
fun HomeRecommendationRecentlyActiveCreatorUiModel.toHomeRecommendationRecentlyActiveCreatorRoute():
|
||||||
@@ -160,11 +160,8 @@ fun HomeRecommendationRecentlyActiveCreatorRoute.toHomeRecommendationRecentlyAct
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
is HomeRecommendationRecentlyActiveCreatorRoute.Community -> {
|
is HomeRecommendationRecentlyActiveCreatorRoute.Community ->
|
||||||
Intent(context, CreatorCommunityAllActivity::class.java).apply {
|
CreatorChannelCommunityDetailActivity.newIntent(context, postId)
|
||||||
putExtra(Constants.EXTRA_COMMUNITY_CREATOR_ID, creatorId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ sealed interface HomeRecommendationUiState {
|
|||||||
recentlyActiveCreators.items,
|
recentlyActiveCreators.items,
|
||||||
recentDebutCreators.items,
|
recentDebutCreators.items,
|
||||||
aiCharacters.items,
|
aiCharacters.items,
|
||||||
genreCreators.groups,
|
|
||||||
cheerCreators.items,
|
cheerCreators.items,
|
||||||
popularCommunityPosts.items
|
popularCommunityPosts.items
|
||||||
).all { it.isEmpty() }
|
).all { it.isEmpty() }
|
||||||
|
|||||||
@@ -21,9 +21,14 @@ class HomeFollowingLiveAdapter(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): LiveViewHolder {
|
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): LiveViewHolder {
|
||||||
val view = LayoutInflater.from(parent.context).inflate(R.layout.item_home_following_live, parent, false)
|
return LiveViewHolder(
|
||||||
view.layoutParams = recyclerItemLayoutParams(parent)
|
LayoutInflater.from(parent.context).inflate(
|
||||||
return LiveViewHolder(view, onClickItem)
|
R.layout.item_home_following_live,
|
||||||
|
parent,
|
||||||
|
false
|
||||||
|
),
|
||||||
|
onClickItem
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onBindViewHolder(holder: LiveViewHolder, position: Int) {
|
override fun onBindViewHolder(holder: LiveViewHolder, position: Int) {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import android.graphics.RenderEffect
|
|||||||
import android.graphics.Shader
|
import android.graphics.Shader
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.util.AttributeSet
|
import android.util.AttributeSet
|
||||||
|
import android.util.TypedValue
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.view.ViewOutlineProvider
|
import android.view.ViewOutlineProvider
|
||||||
@@ -13,6 +14,7 @@ import android.widget.FrameLayout
|
|||||||
import android.widget.ImageView
|
import android.widget.ImageView
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
import kr.co.vividnext.sodalive.R
|
import kr.co.vividnext.sodalive.R
|
||||||
|
import kr.co.vividnext.sodalive.v2.widget.ranking.RankingResponsiveScale
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
open class ContentRankingGridCardView @JvmOverloads constructor(
|
open class ContentRankingGridCardView @JvmOverloads constructor(
|
||||||
@@ -29,6 +31,7 @@ open class ContentRankingGridCardView @JvmOverloads constructor(
|
|||||||
private var titleText: TextView? = null
|
private var titleText: TextView? = null
|
||||||
private var creatorText: TextView? = null
|
private var creatorText: TextView? = null
|
||||||
private var currentItem: ContentRankingItem? = null
|
private var currentItem: ContentRankingItem? = null
|
||||||
|
private var currentTextStyle: ContentRankingTextStyle? = null
|
||||||
private var clickListener: ((ContentRankingItem) -> Unit)? = null
|
private var clickListener: ((ContentRankingItem) -> Unit)? = null
|
||||||
|
|
||||||
override fun onFinishInflate() {
|
override fun onFinishInflate() {
|
||||||
@@ -48,11 +51,13 @@ open class ContentRankingGridCardView @JvmOverloads constructor(
|
|||||||
|
|
||||||
fun bind(item: ContentRankingItem) {
|
fun bind(item: ContentRankingItem) {
|
||||||
currentItem = item
|
currentItem = item
|
||||||
|
val textStyle = currentTextStyle ?: defaultTextStyle()
|
||||||
|
applyTextStyle(textStyle)
|
||||||
requireNotNull(rankText).apply {
|
requireNotNull(rankText).apply {
|
||||||
text = item.rank.toString()
|
text = item.rank.toString()
|
||||||
applyContentRankingRankGradient()
|
applyContentRankingRankGradient()
|
||||||
}
|
}
|
||||||
bindDelta(item)
|
bindDelta(item, textStyle)
|
||||||
requireNotNull(titleText).apply {
|
requireNotNull(titleText).apply {
|
||||||
text = item.displayContentName(context.getString(R.string.content_ranking_inaccessible_info))
|
text = item.displayContentName(context.getString(R.string.content_ranking_inaccessible_info))
|
||||||
visibility = if (text.isNullOrBlank()) View.INVISIBLE else View.VISIBLE
|
visibility = if (text.isNullOrBlank()) View.INVISIBLE else View.VISIBLE
|
||||||
@@ -79,34 +84,58 @@ open class ContentRankingGridCardView @JvmOverloads constructor(
|
|||||||
currentItem?.let(::applyAccessState)
|
currentItem?.let(::applyAccessState)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun bindDelta(item: ContentRankingItem) {
|
private fun bindDelta(
|
||||||
|
item: ContentRankingItem,
|
||||||
|
textStyle: ContentRankingTextStyle
|
||||||
|
) {
|
||||||
requireNotNull(deltaGroup).visibility = if (item.showRankChange) View.VISIBLE else View.GONE
|
requireNotNull(deltaGroup).visibility = if (item.showRankChange) View.VISIBLE else View.GONE
|
||||||
if (!item.showRankChange) return
|
if (!item.showRankChange) return
|
||||||
|
|
||||||
val presentation = ContentRankingDeltaPresentation.from(item.rankChangeType, item.rankChangeAmount)
|
val presentation = ContentRankingDeltaPresentation.from(item.rankChangeType, item.rankChangeAmount)
|
||||||
applyDeltaContainer(presentation)
|
val scale = RankingResponsiveScale.fromResources(resources)
|
||||||
|
applyDeltaContainer(presentation, scale)
|
||||||
requireNotNull(deltaIcon).apply {
|
requireNotNull(deltaIcon).apply {
|
||||||
setImageResource(presentation.iconRes)
|
setImageResource(presentation.iconRes)
|
||||||
layoutParams = (layoutParams as ViewGroup.MarginLayoutParams).apply {
|
layoutParams = (layoutParams as ViewGroup.MarginLayoutParams).apply {
|
||||||
width = presentation.iconWidthDp.dpToPx()
|
width = presentation.iconWidthDp.dpToPx(scale)
|
||||||
height = presentation.iconHeightDp.dpToPx()
|
height = presentation.iconHeightDp.dpToPx(scale)
|
||||||
marginStart = presentation.iconMarginStartDp.dpToPx()
|
marginStart = presentation.iconMarginStartDp.dpToPx(scale)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
requireNotNull(deltaAmountText).apply {
|
requireNotNull(deltaAmountText).apply {
|
||||||
text = presentation.amountText
|
text = presentation.amountText
|
||||||
|
setTextSize(TypedValue.COMPLEX_UNIT_SP, textStyle.deltaTextSizeSp)
|
||||||
visibility = if (presentation.showAmount) View.VISIBLE else View.GONE
|
visibility = if (presentation.showAmount) View.VISIBLE else View.GONE
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun applyDeltaContainer(presentation: ContentRankingDeltaPresentation) {
|
private fun applyDeltaContainer(
|
||||||
|
presentation: ContentRankingDeltaPresentation,
|
||||||
|
scale: Float
|
||||||
|
) {
|
||||||
requireNotNull(deltaGroup).apply {
|
requireNotNull(deltaGroup).apply {
|
||||||
setBackgroundResource(if (presentation.showPillBackground) R.drawable.bg_creator_ranking_delta else 0)
|
setBackgroundResource(if (presentation.showPillBackground) R.drawable.bg_creator_ranking_delta else 0)
|
||||||
val horizontalPadding = if (presentation.showPillBackground) 4.dpToPx() else 0
|
val horizontalPadding = if (presentation.showPillBackground) 4.dpToPx(scale) else 0
|
||||||
setPadding(horizontalPadding, paddingTop, horizontalPadding, paddingBottom)
|
setPadding(horizontalPadding, paddingTop, horizontalPadding, paddingBottom)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun applyTextStyle(style: ContentRankingTextStyle) {
|
||||||
|
requireNotNull(rankText).setTextSize(TypedValue.COMPLEX_UNIT_SP, style.rankTextSizeSp)
|
||||||
|
requireNotNull(titleText).setTextSize(TypedValue.COMPLEX_UNIT_SP, style.titleTextSizeSp)
|
||||||
|
requireNotNull(creatorText).setTextSize(TypedValue.COMPLEX_UNIT_SP, style.creatorTextSizeSp)
|
||||||
|
requireNotNull(deltaAmountText).setTextSize(TypedValue.COMPLEX_UNIT_SP, style.deltaTextSizeSp)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun defaultTextStyle(): ContentRankingTextStyle {
|
||||||
|
val scale = RankingResponsiveScale.fromResources(resources)
|
||||||
|
return if (this is ContentRankingSmallGridCardView) {
|
||||||
|
ContentRankingTextStyle.smallGrid(scale)
|
||||||
|
} else {
|
||||||
|
ContentRankingTextStyle.mediumGrid(scale)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun applyAccessState(item: ContentRankingItem) {
|
private fun applyAccessState(item: ContentRankingItem) {
|
||||||
applyBlur(item.isInaccessible)
|
applyBlur(item.isInaccessible)
|
||||||
isClickable = item.isTouchable && clickListener != null
|
isClickable = item.isTouchable && clickListener != null
|
||||||
@@ -120,26 +149,32 @@ open class ContentRankingGridCardView @JvmOverloads constructor(
|
|||||||
|
|
||||||
private fun positionMedium(size: ContentRankingCardSize) {
|
private fun positionMedium(size: ContentRankingCardSize) {
|
||||||
val scale = size.widthPx / 185f
|
val scale = size.widthPx / 185f
|
||||||
|
val textStyle = ContentRankingTextStyle.mediumGrid(RankingResponsiveScale.fromResources(resources))
|
||||||
|
currentTextStyle = textStyle
|
||||||
requireNotNull(rankText).apply {
|
requireNotNull(rankText).apply {
|
||||||
textSize = 54f
|
setTextSize(TypedValue.COMPLEX_UNIT_SP, textStyle.rankTextSizeSp)
|
||||||
layoutParams = LayoutParams((55 * scale).roundToInt(), (75 * scale).roundToInt())
|
layoutParams = LayoutParams((55 * scale).roundToInt(), (75 * scale).roundToInt())
|
||||||
setPadding(0, 0, 0, (6 * scale).roundToInt())
|
setPadding(0, 0, 0, (6 * scale).roundToInt())
|
||||||
}
|
}
|
||||||
requireNotNull(titleText).textSize = 22f
|
requireNotNull(titleText).setTextSize(TypedValue.COMPLEX_UNIT_SP, textStyle.titleTextSizeSp)
|
||||||
|
requireNotNull(creatorText).setTextSize(TypedValue.COMPLEX_UNIT_SP, textStyle.creatorTextSizeSp)
|
||||||
placeDelta(left = 10, top = 70, scale = scale)
|
placeDelta(left = 10, top = 70, scale = scale)
|
||||||
placeLabel(width = 165, top = 136, scale = scale, size = size)
|
placeLabel(width = 165, top = 136, scale = scale, size = size)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun positionSmall(size: ContentRankingCardSize) {
|
private fun positionSmall(size: ContentRankingCardSize) {
|
||||||
val scale = size.widthPx / 122f
|
val scale = size.widthPx / 122f
|
||||||
|
val textStyle = ContentRankingTextStyle.smallGrid(RankingResponsiveScale.fromResources(resources))
|
||||||
|
currentTextStyle = textStyle
|
||||||
requireNotNull(rankText).apply {
|
requireNotNull(rankText).apply {
|
||||||
textSize = 40f
|
setTextSize(TypedValue.COMPLEX_UNIT_SP, textStyle.rankTextSizeSp)
|
||||||
layoutParams = LayoutParams((42 * scale).roundToInt(), (56 * scale).roundToInt()).apply {
|
layoutParams = LayoutParams((42 * scale).roundToInt(), (56 * scale).roundToInt()).apply {
|
||||||
leftMargin = (8 * scale).roundToInt()
|
leftMargin = (8 * scale).roundToInt()
|
||||||
}
|
}
|
||||||
setPadding(0, 0, 0, (5 * scale).roundToInt())
|
setPadding(0, 0, 0, (5 * scale).roundToInt())
|
||||||
}
|
}
|
||||||
requireNotNull(titleText).textSize = 14f
|
requireNotNull(titleText).setTextSize(TypedValue.COMPLEX_UNIT_SP, textStyle.titleTextSizeSp)
|
||||||
|
requireNotNull(creatorText).setTextSize(TypedValue.COMPLEX_UNIT_SP, textStyle.creatorTextSizeSp)
|
||||||
placeDelta(left = 10, top = 49, scale = scale)
|
placeDelta(left = 10, top = 49, scale = scale)
|
||||||
placeLabel(width = 102, top = 81, scale = scale, size = size)
|
placeLabel(width = 102, top = 81, scale = scale, size = size)
|
||||||
}
|
}
|
||||||
@@ -178,7 +213,7 @@ open class ContentRankingGridCardView @JvmOverloads constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun Int.dpToPx(): Int = (this * resources.displayMetrics.density).roundToInt()
|
private fun Int.dpToPx(scale: Float = 1f): Int = (this * scale * resources.displayMetrics.density).roundToInt()
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
const val SMALL_THRESHOLD_PX = 140
|
const val SMALL_THRESHOLD_PX = 140
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import android.graphics.RenderEffect
|
|||||||
import android.graphics.Shader
|
import android.graphics.Shader
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.util.AttributeSet
|
import android.util.AttributeSet
|
||||||
|
import android.util.TypedValue
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.view.ViewOutlineProvider
|
import android.view.ViewOutlineProvider
|
||||||
@@ -13,6 +14,7 @@ import android.widget.FrameLayout
|
|||||||
import android.widget.ImageView
|
import android.widget.ImageView
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
import kr.co.vividnext.sodalive.R
|
import kr.co.vividnext.sodalive.R
|
||||||
|
import kr.co.vividnext.sodalive.v2.widget.ranking.RankingResponsiveScale
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
class ContentRankingHorizontalCardView @JvmOverloads constructor(
|
class ContentRankingHorizontalCardView @JvmOverloads constructor(
|
||||||
@@ -48,11 +50,13 @@ class ContentRankingHorizontalCardView @JvmOverloads constructor(
|
|||||||
|
|
||||||
fun bind(item: ContentRankingItem) {
|
fun bind(item: ContentRankingItem) {
|
||||||
currentItem = item
|
currentItem = item
|
||||||
|
val textStyle = ContentRankingTextStyle.horizontal(RankingResponsiveScale.fromResources(resources))
|
||||||
|
applyTextStyle(textStyle)
|
||||||
requireNotNull(rankText).apply {
|
requireNotNull(rankText).apply {
|
||||||
text = item.rank.toString()
|
text = item.rank.toString()
|
||||||
applyContentRankingRankGradient()
|
applyContentRankingRankGradient()
|
||||||
}
|
}
|
||||||
bindDelta(item)
|
bindDelta(item, textStyle)
|
||||||
val inaccessibleMessage = context.getString(R.string.content_ranking_inaccessible_info)
|
val inaccessibleMessage = context.getString(R.string.content_ranking_inaccessible_info)
|
||||||
requireNotNull(titleText).text = item.displayContentName(inaccessibleMessage)
|
requireNotNull(titleText).text = item.displayContentName(inaccessibleMessage)
|
||||||
requireNotNull(creatorText).text = item.displayCreatorName()
|
requireNotNull(creatorText).text = item.displayCreatorName()
|
||||||
@@ -69,6 +73,7 @@ class ContentRankingHorizontalCardView @JvmOverloads constructor(
|
|||||||
height = size.heightPx
|
height = size.heightPx
|
||||||
}
|
}
|
||||||
positionViews(size)
|
positionViews(size)
|
||||||
|
applyTextStyle(ContentRankingTextStyle.horizontal(RankingResponsiveScale.fromResources(resources)))
|
||||||
}
|
}
|
||||||
|
|
||||||
fun imageView(): ImageView = requireNotNull(image)
|
fun imageView(): ImageView = requireNotNull(image)
|
||||||
@@ -78,35 +83,51 @@ class ContentRankingHorizontalCardView @JvmOverloads constructor(
|
|||||||
currentItem?.let(::applyAccessState)
|
currentItem?.let(::applyAccessState)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun bindDelta(item: ContentRankingItem) {
|
private fun bindDelta(
|
||||||
|
item: ContentRankingItem,
|
||||||
|
textStyle: ContentRankingTextStyle
|
||||||
|
) {
|
||||||
val deltaGroup = findViewById<View>(R.id.ll_content_ranking_delta)
|
val deltaGroup = findViewById<View>(R.id.ll_content_ranking_delta)
|
||||||
deltaGroup.visibility = if (item.showRankChange) View.VISIBLE else View.GONE
|
deltaGroup.visibility = if (item.showRankChange) View.VISIBLE else View.GONE
|
||||||
if (!item.showRankChange) return
|
if (!item.showRankChange) return
|
||||||
|
|
||||||
val presentation = ContentRankingDeltaPresentation.from(item.rankChangeType, item.rankChangeAmount)
|
val presentation = ContentRankingDeltaPresentation.from(item.rankChangeType, item.rankChangeAmount)
|
||||||
applyDeltaContainer(presentation)
|
val scale = RankingResponsiveScale.fromResources(resources)
|
||||||
|
applyDeltaContainer(presentation, scale)
|
||||||
requireNotNull(deltaIcon).apply {
|
requireNotNull(deltaIcon).apply {
|
||||||
setImageResource(presentation.iconRes)
|
setImageResource(presentation.iconRes)
|
||||||
layoutParams = (layoutParams as ViewGroup.MarginLayoutParams).apply {
|
layoutParams = (layoutParams as ViewGroup.MarginLayoutParams).apply {
|
||||||
width = presentation.iconWidthDp.dpToPx()
|
width = presentation.iconWidthDp.dpToPx(scale)
|
||||||
height = presentation.iconHeightDp.dpToPx()
|
height = presentation.iconHeightDp.dpToPx(scale)
|
||||||
marginStart = presentation.iconMarginStartDp.dpToPx()
|
marginStart = presentation.iconMarginStartDp.dpToPx(scale)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
requireNotNull(deltaAmountText).apply {
|
requireNotNull(deltaAmountText).apply {
|
||||||
text = presentation.amountText
|
text = presentation.amountText
|
||||||
|
setTextSize(TypedValue.COMPLEX_UNIT_SP, textStyle.deltaTextSizeSp)
|
||||||
visibility = if (presentation.showAmount) View.VISIBLE else View.GONE
|
visibility = if (presentation.showAmount) View.VISIBLE else View.GONE
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun applyDeltaContainer(presentation: ContentRankingDeltaPresentation) {
|
private fun applyDeltaContainer(
|
||||||
|
presentation: ContentRankingDeltaPresentation,
|
||||||
|
scale: Float
|
||||||
|
) {
|
||||||
findViewById<View>(R.id.ll_content_ranking_delta).apply {
|
findViewById<View>(R.id.ll_content_ranking_delta).apply {
|
||||||
setBackgroundResource(if (presentation.showPillBackground) R.drawable.bg_creator_ranking_delta else 0)
|
setBackgroundResource(if (presentation.showPillBackground) R.drawable.bg_creator_ranking_delta else 0)
|
||||||
val horizontalPadding = if (presentation.showPillBackground) 4.dpToPx() else 0
|
val horizontalPadding = if (presentation.showPillBackground) 4.dpToPx(scale) else 0
|
||||||
setPadding(horizontalPadding, paddingTop, horizontalPadding, paddingBottom)
|
setPadding(horizontalPadding, paddingTop, horizontalPadding, paddingBottom)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun applyTextStyle(style: ContentRankingTextStyle) {
|
||||||
|
requireNotNull(rankText).setTextSize(TypedValue.COMPLEX_UNIT_SP, style.rankTextSizeSp)
|
||||||
|
requireNotNull(titleText).setTextSize(TypedValue.COMPLEX_UNIT_SP, style.titleTextSizeSp)
|
||||||
|
requireNotNull(creatorText).setTextSize(TypedValue.COMPLEX_UNIT_SP, style.creatorTextSizeSp)
|
||||||
|
requireNotNull(inaccessibleText).setTextSize(TypedValue.COMPLEX_UNIT_SP, style.titleTextSizeSp)
|
||||||
|
requireNotNull(deltaAmountText).setTextSize(TypedValue.COMPLEX_UNIT_SP, style.deltaTextSizeSp)
|
||||||
|
}
|
||||||
|
|
||||||
private fun applyAccessState(item: ContentRankingItem) {
|
private fun applyAccessState(item: ContentRankingItem) {
|
||||||
applyBlur(item.isInaccessible)
|
applyBlur(item.isInaccessible)
|
||||||
isClickable = item.isTouchable && clickListener != null
|
isClickable = item.isTouchable && clickListener != null
|
||||||
@@ -157,5 +178,5 @@ class ContentRankingHorizontalCardView @JvmOverloads constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun Int.dpToPx(): Int = (this * resources.displayMetrics.density).roundToInt()
|
private fun Int.dpToPx(scale: Float = 1f): Int = (this * scale * resources.displayMetrics.density).roundToInt()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import android.graphics.RenderEffect
|
|||||||
import android.graphics.Shader
|
import android.graphics.Shader
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.util.AttributeSet
|
import android.util.AttributeSet
|
||||||
|
import android.util.TypedValue
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.view.ViewOutlineProvider
|
import android.view.ViewOutlineProvider
|
||||||
@@ -13,6 +14,7 @@ import android.widget.FrameLayout
|
|||||||
import android.widget.ImageView
|
import android.widget.ImageView
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
import kr.co.vividnext.sodalive.R
|
import kr.co.vividnext.sodalive.R
|
||||||
|
import kr.co.vividnext.sodalive.v2.widget.ranking.RankingResponsiveScale
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
class ContentRankingLargeCardView @JvmOverloads constructor(
|
class ContentRankingLargeCardView @JvmOverloads constructor(
|
||||||
@@ -50,11 +52,13 @@ class ContentRankingLargeCardView @JvmOverloads constructor(
|
|||||||
|
|
||||||
fun bind(item: ContentRankingItem) {
|
fun bind(item: ContentRankingItem) {
|
||||||
currentItem = item
|
currentItem = item
|
||||||
|
val textStyle = ContentRankingTextStyle.large(RankingResponsiveScale.fromResources(resources))
|
||||||
|
applyTextStyle(textStyle)
|
||||||
requireNotNull(rankText).apply {
|
requireNotNull(rankText).apply {
|
||||||
text = item.rank.toString()
|
text = item.rank.toString()
|
||||||
applyContentRankingRankGradient()
|
applyContentRankingRankGradient()
|
||||||
}
|
}
|
||||||
bindDelta(item)
|
bindDelta(item, textStyle)
|
||||||
requireNotNull(titleText).apply {
|
requireNotNull(titleText).apply {
|
||||||
text = item.displayContentName(context.getString(R.string.content_ranking_inaccessible_info))
|
text = item.displayContentName(context.getString(R.string.content_ranking_inaccessible_info))
|
||||||
visibility = if (text.isNullOrBlank()) View.INVISIBLE else View.VISIBLE
|
visibility = if (text.isNullOrBlank()) View.INVISIBLE else View.VISIBLE
|
||||||
@@ -72,6 +76,7 @@ class ContentRankingLargeCardView @JvmOverloads constructor(
|
|||||||
height = size.heightPx
|
height = size.heightPx
|
||||||
}
|
}
|
||||||
positionViews(size)
|
positionViews(size)
|
||||||
|
applyTextStyle(ContentRankingTextStyle.large(RankingResponsiveScale.fromResources(resources)))
|
||||||
}
|
}
|
||||||
|
|
||||||
fun imageView(): ImageView = contentImageView()
|
fun imageView(): ImageView = contentImageView()
|
||||||
@@ -83,34 +88,49 @@ class ContentRankingLargeCardView @JvmOverloads constructor(
|
|||||||
currentItem?.let(::applyAccessState)
|
currentItem?.let(::applyAccessState)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun bindDelta(item: ContentRankingItem) {
|
private fun bindDelta(
|
||||||
|
item: ContentRankingItem,
|
||||||
|
textStyle: ContentRankingTextStyle
|
||||||
|
) {
|
||||||
requireNotNull(deltaGroup).visibility = if (item.showRankChange) View.VISIBLE else View.GONE
|
requireNotNull(deltaGroup).visibility = if (item.showRankChange) View.VISIBLE else View.GONE
|
||||||
if (!item.showRankChange) return
|
if (!item.showRankChange) return
|
||||||
|
|
||||||
val presentation = ContentRankingDeltaPresentation.from(item.rankChangeType, item.rankChangeAmount)
|
val presentation = ContentRankingDeltaPresentation.from(item.rankChangeType, item.rankChangeAmount)
|
||||||
applyDeltaContainer(presentation)
|
val scale = RankingResponsiveScale.fromResources(resources)
|
||||||
|
applyDeltaContainer(presentation, scale)
|
||||||
requireNotNull(deltaIcon).apply {
|
requireNotNull(deltaIcon).apply {
|
||||||
setImageResource(presentation.iconRes)
|
setImageResource(presentation.iconRes)
|
||||||
layoutParams = (layoutParams as ViewGroup.MarginLayoutParams).apply {
|
layoutParams = (layoutParams as ViewGroup.MarginLayoutParams).apply {
|
||||||
width = presentation.iconWidthDp.dpToPx()
|
width = presentation.iconWidthDp.dpToPx(scale)
|
||||||
height = presentation.iconHeightDp.dpToPx()
|
height = presentation.iconHeightDp.dpToPx(scale)
|
||||||
marginStart = presentation.iconMarginStartDp.dpToPx()
|
marginStart = presentation.iconMarginStartDp.dpToPx(scale)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
requireNotNull(deltaAmountText).apply {
|
requireNotNull(deltaAmountText).apply {
|
||||||
text = presentation.amountText
|
text = presentation.amountText
|
||||||
|
setTextSize(TypedValue.COMPLEX_UNIT_SP, textStyle.deltaTextSizeSp)
|
||||||
visibility = if (presentation.showAmount) View.VISIBLE else View.GONE
|
visibility = if (presentation.showAmount) View.VISIBLE else View.GONE
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun applyDeltaContainer(presentation: ContentRankingDeltaPresentation) {
|
private fun applyDeltaContainer(
|
||||||
|
presentation: ContentRankingDeltaPresentation,
|
||||||
|
scale: Float
|
||||||
|
) {
|
||||||
requireNotNull(deltaGroup).apply {
|
requireNotNull(deltaGroup).apply {
|
||||||
setBackgroundResource(if (presentation.showPillBackground) R.drawable.bg_creator_ranking_delta else 0)
|
setBackgroundResource(if (presentation.showPillBackground) R.drawable.bg_creator_ranking_delta else 0)
|
||||||
val horizontalPadding = if (presentation.showPillBackground) 4.dpToPx() else 0
|
val horizontalPadding = if (presentation.showPillBackground) 4.dpToPx(scale) else 0
|
||||||
setPadding(horizontalPadding, paddingTop, horizontalPadding, paddingBottom)
|
setPadding(horizontalPadding, paddingTop, horizontalPadding, paddingBottom)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun applyTextStyle(style: ContentRankingTextStyle) {
|
||||||
|
requireNotNull(rankText).setTextSize(TypedValue.COMPLEX_UNIT_SP, style.rankTextSizeSp)
|
||||||
|
requireNotNull(titleText).setTextSize(TypedValue.COMPLEX_UNIT_SP, style.titleTextSizeSp)
|
||||||
|
requireNotNull(creatorText).setTextSize(TypedValue.COMPLEX_UNIT_SP, style.creatorTextSizeSp)
|
||||||
|
requireNotNull(deltaAmountText).setTextSize(TypedValue.COMPLEX_UNIT_SP, style.deltaTextSizeSp)
|
||||||
|
}
|
||||||
|
|
||||||
private fun applyAccessState(item: ContentRankingItem) {
|
private fun applyAccessState(item: ContentRankingItem) {
|
||||||
applyBlur(item.isInaccessible)
|
applyBlur(item.isInaccessible)
|
||||||
isClickable = item.isTouchable && clickListener != null
|
isClickable = item.isTouchable && clickListener != null
|
||||||
@@ -159,7 +179,7 @@ class ContentRankingLargeCardView @JvmOverloads constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun Int.dpToPx(): Int = (this * resources.displayMetrics.density).roundToInt()
|
private fun Int.dpToPx(scale: Float = 1f): Int = (this * scale * resources.displayMetrics.density).roundToInt()
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
const val FIGMA_WIDTH = 374
|
const val FIGMA_WIDTH = 374
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.widget.contentranking
|
||||||
|
|
||||||
|
data class ContentRankingTextStyle(
|
||||||
|
val rankTextSizeSp: Float,
|
||||||
|
val titleTextSizeSp: Float,
|
||||||
|
val creatorTextSizeSp: Float,
|
||||||
|
val deltaTextSizeSp: Float
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
fun large(scale: Float): ContentRankingTextStyle = baseline(96f, 22f, 12f, scale)
|
||||||
|
|
||||||
|
fun mediumGrid(scale: Float): ContentRankingTextStyle = baseline(54f, 22f, 12f, scale)
|
||||||
|
|
||||||
|
fun smallGrid(scale: Float): ContentRankingTextStyle = baseline(36f, 14f, 12f, scale)
|
||||||
|
|
||||||
|
fun horizontal(scale: Float): ContentRankingTextStyle = baseline(40f, 18f, 14f, scale)
|
||||||
|
|
||||||
|
private fun baseline(
|
||||||
|
rankTextSizeSp: Float,
|
||||||
|
titleTextSizeSp: Float,
|
||||||
|
creatorTextSizeSp: Float,
|
||||||
|
scale: Float
|
||||||
|
) = ContentRankingTextStyle(
|
||||||
|
rankTextSizeSp = rankTextSizeSp * scale,
|
||||||
|
titleTextSizeSp = titleTextSizeSp * scale,
|
||||||
|
creatorTextSizeSp = creatorTextSizeSp * scale,
|
||||||
|
deltaTextSizeSp = 16f * scale
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ import android.widget.ImageView
|
|||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
import androidx.constraintlayout.widget.ConstraintLayout
|
import androidx.constraintlayout.widget.ConstraintLayout
|
||||||
import kr.co.vividnext.sodalive.R
|
import kr.co.vividnext.sodalive.R
|
||||||
|
import kr.co.vividnext.sodalive.v2.widget.ranking.RankingResponsiveScale
|
||||||
import kotlin.math.max
|
import kotlin.math.max
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
@@ -61,16 +62,17 @@ class CreatorRankingLowerRowView @JvmOverloads constructor(
|
|||||||
|
|
||||||
fun bind(item: CreatorRankingItem) {
|
fun bind(item: CreatorRankingItem) {
|
||||||
currentItem = item
|
currentItem = item
|
||||||
val style = CreatorRankingTextStyle.fromRank(item.rank)
|
val scale = RankingResponsiveScale.fromResources(resources)
|
||||||
|
val style = CreatorRankingTextStyle.fromRank(item.rank).scaledBy(scale)
|
||||||
requireNotNull(rankText).apply {
|
requireNotNull(rankText).apply {
|
||||||
text = item.rank.toString()
|
text = item.rank.toString()
|
||||||
setTextSize(TypedValue.COMPLEX_UNIT_SP, style.rankTextSizeSp.toFloat())
|
setTextSize(TypedValue.COMPLEX_UNIT_SP, style.rankTextSizeSp)
|
||||||
applyCreatorRankingRankGradient()
|
applyCreatorRankingRankGradient()
|
||||||
}
|
}
|
||||||
bindDelta(item, style)
|
bindDelta(item, style)
|
||||||
requireNotNull(nameText).apply {
|
requireNotNull(nameText).apply {
|
||||||
text = item.displayName(context.getString(R.string.creator_ranking_inaccessible_info))
|
text = item.displayName(context.getString(R.string.creator_ranking_inaccessible_info))
|
||||||
setTextSize(TypedValue.COMPLEX_UNIT_SP, style.nameTextSizeSp.toFloat())
|
setTextSize(TypedValue.COMPLEX_UNIT_SP, style.nameTextSizeSp)
|
||||||
}
|
}
|
||||||
applyAccessState(item)
|
applyAccessState(item)
|
||||||
}
|
}
|
||||||
@@ -84,32 +86,36 @@ class CreatorRankingLowerRowView @JvmOverloads constructor(
|
|||||||
|
|
||||||
private fun bindDelta(
|
private fun bindDelta(
|
||||||
item: CreatorRankingItem,
|
item: CreatorRankingItem,
|
||||||
style: CreatorRankingTextStyle
|
style: CreatorRankingScaledTextStyle
|
||||||
) {
|
) {
|
||||||
requireNotNull(deltaGroup).visibility = if (item.showRankChange) VISIBLE else GONE
|
requireNotNull(deltaGroup).visibility = if (item.showRankChange) VISIBLE else GONE
|
||||||
if (!item.showRankChange) return
|
if (!item.showRankChange) return
|
||||||
|
|
||||||
val presentation = CreatorRankingDeltaPresentation.from(item.rankChangeType, item.rankChangeAmount)
|
val presentation = CreatorRankingDeltaPresentation.from(item.rankChangeType, item.rankChangeAmount)
|
||||||
applyDeltaContainer(presentation)
|
val scale = RankingResponsiveScale.fromResources(resources)
|
||||||
|
applyDeltaContainer(presentation, scale)
|
||||||
requireNotNull(deltaIcon).apply {
|
requireNotNull(deltaIcon).apply {
|
||||||
setImageResource(presentation.iconRes)
|
setImageResource(presentation.iconRes)
|
||||||
layoutParams = (layoutParams as ViewGroup.MarginLayoutParams).apply {
|
layoutParams = (layoutParams as ViewGroup.MarginLayoutParams).apply {
|
||||||
width = presentation.iconWidthDp.dpToPx()
|
width = presentation.iconWidthDp.dpToPx(scale)
|
||||||
height = presentation.iconHeightDp.dpToPx()
|
height = presentation.iconHeightDp.dpToPx(scale)
|
||||||
marginStart = presentation.iconMarginStartDp.dpToPx()
|
marginStart = presentation.iconMarginStartDp.dpToPx(scale)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
requireNotNull(deltaAmountText).apply {
|
requireNotNull(deltaAmountText).apply {
|
||||||
text = presentation.amountText.orEmpty()
|
text = presentation.amountText.orEmpty()
|
||||||
setTextSize(TypedValue.COMPLEX_UNIT_SP, style.deltaTextSizeSp.toFloat())
|
setTextSize(TypedValue.COMPLEX_UNIT_SP, style.deltaTextSizeSp)
|
||||||
visibility = if (presentation.showAmount) VISIBLE else GONE
|
visibility = if (presentation.showAmount) VISIBLE else GONE
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun applyDeltaContainer(presentation: CreatorRankingDeltaPresentation) {
|
private fun applyDeltaContainer(
|
||||||
|
presentation: CreatorRankingDeltaPresentation,
|
||||||
|
scale: Float
|
||||||
|
) {
|
||||||
requireNotNull(deltaGroup).apply {
|
requireNotNull(deltaGroup).apply {
|
||||||
setBackgroundResource(if (presentation.showPillBackground) R.drawable.bg_creator_ranking_delta else 0)
|
setBackgroundResource(if (presentation.showPillBackground) R.drawable.bg_creator_ranking_delta else 0)
|
||||||
val horizontalPadding = if (presentation.showPillBackground) 4.dpToPx() else 0
|
val horizontalPadding = if (presentation.showPillBackground) 4.dpToPx(scale) else 0
|
||||||
setPadding(horizontalPadding, paddingTop, horizontalPadding, paddingBottom)
|
setPadding(horizontalPadding, paddingTop, horizontalPadding, paddingBottom)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -134,7 +140,7 @@ class CreatorRankingLowerRowView @JvmOverloads constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun Int.dpToPx(): Int = (this * resources.displayMetrics.density).roundToInt()
|
private fun Int.dpToPx(scale: Float = 1f): Int = (this * scale * resources.displayMetrics.density).roundToInt()
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
const val FIGMA_HEIGHT_RATIO_NUMERATOR = 100
|
const val FIGMA_HEIGHT_RATIO_NUMERATOR = 100
|
||||||
|
|||||||
@@ -5,6 +5,12 @@ data class CreatorRankingTextStyle(
|
|||||||
val nameTextSizeSp: Int,
|
val nameTextSizeSp: Int,
|
||||||
val deltaTextSizeSp: Int
|
val deltaTextSizeSp: Int
|
||||||
) {
|
) {
|
||||||
|
fun scaledBy(scale: Float): CreatorRankingScaledTextStyle = CreatorRankingScaledTextStyle(
|
||||||
|
rankTextSizeSp = rankTextSizeSp * scale,
|
||||||
|
nameTextSizeSp = nameTextSizeSp * scale,
|
||||||
|
deltaTextSizeSp = deltaTextSizeSp * scale
|
||||||
|
)
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
fun fromPlacement(placement: CreatorRankingPlacement): CreatorRankingTextStyle {
|
fun fromPlacement(placement: CreatorRankingPlacement): CreatorRankingTextStyle {
|
||||||
return when (placement.viewType) {
|
return when (placement.viewType) {
|
||||||
@@ -25,3 +31,9 @@ data class CreatorRankingTextStyle(
|
|||||||
fun fromRank(rank: Int): CreatorRankingTextStyle = fromPlacement(CreatorRankingPlacement.fromRank(rank))
|
fun fromRank(rank: Int): CreatorRankingTextStyle = fromPlacement(CreatorRankingPlacement.fromRank(rank))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
data class CreatorRankingScaledTextStyle(
|
||||||
|
val rankTextSizeSp: Float,
|
||||||
|
val nameTextSizeSp: Float,
|
||||||
|
val deltaTextSizeSp: Float
|
||||||
|
)
|
||||||
|
|||||||
@@ -8,12 +8,12 @@ import android.os.Build
|
|||||||
import android.util.AttributeSet
|
import android.util.AttributeSet
|
||||||
import android.util.TypedValue
|
import android.util.TypedValue
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
|
||||||
import android.view.ViewOutlineProvider
|
import android.view.ViewOutlineProvider
|
||||||
import android.widget.ImageView
|
import android.widget.ImageView
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
import androidx.constraintlayout.widget.ConstraintLayout
|
import androidx.constraintlayout.widget.ConstraintLayout
|
||||||
import kr.co.vividnext.sodalive.R
|
import kr.co.vividnext.sodalive.R
|
||||||
|
import kr.co.vividnext.sodalive.v2.widget.ranking.RankingResponsiveScale
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
class CreatorRankingTopCardView @JvmOverloads constructor(
|
class CreatorRankingTopCardView @JvmOverloads constructor(
|
||||||
@@ -57,17 +57,18 @@ class CreatorRankingTopCardView @JvmOverloads constructor(
|
|||||||
|
|
||||||
fun bind(item: CreatorRankingItem) {
|
fun bind(item: CreatorRankingItem) {
|
||||||
currentItem = item
|
currentItem = item
|
||||||
val style = CreatorRankingTextStyle.fromRank(item.rank)
|
val scale = RankingResponsiveScale.fromResources(resources)
|
||||||
|
val style = CreatorRankingTextStyle.fromRank(item.rank).scaledBy(scale)
|
||||||
applyMargins(CreatorRankingTopCardMargin.fromRank(item.rank))
|
applyMargins(CreatorRankingTopCardMargin.fromRank(item.rank))
|
||||||
requireNotNull(rankText).apply {
|
requireNotNull(rankText).apply {
|
||||||
text = item.rank.toString()
|
text = item.rank.toString()
|
||||||
setTextSize(TypedValue.COMPLEX_UNIT_SP, style.rankTextSizeSp.toFloat())
|
setTextSize(TypedValue.COMPLEX_UNIT_SP, style.rankTextSizeSp)
|
||||||
applyCreatorRankingRankGradient()
|
applyCreatorRankingRankGradient()
|
||||||
}
|
}
|
||||||
bindDelta(item, style)
|
bindDelta(item, style)
|
||||||
requireNotNull(nameText).apply {
|
requireNotNull(nameText).apply {
|
||||||
text = item.displayName(context.getString(R.string.creator_ranking_inaccessible_info))
|
text = item.displayName(context.getString(R.string.creator_ranking_inaccessible_info))
|
||||||
setTextSize(TypedValue.COMPLEX_UNIT_SP, style.nameTextSizeSp.toFloat())
|
setTextSize(TypedValue.COMPLEX_UNIT_SP, style.nameTextSizeSp)
|
||||||
visibility = if (text.isNullOrBlank()) INVISIBLE else VISIBLE
|
visibility = if (text.isNullOrBlank()) INVISIBLE else VISIBLE
|
||||||
}
|
}
|
||||||
dimGradient?.visibility = VISIBLE
|
dimGradient?.visibility = VISIBLE
|
||||||
@@ -96,32 +97,36 @@ class CreatorRankingTopCardView @JvmOverloads constructor(
|
|||||||
|
|
||||||
private fun bindDelta(
|
private fun bindDelta(
|
||||||
item: CreatorRankingItem,
|
item: CreatorRankingItem,
|
||||||
style: CreatorRankingTextStyle
|
style: CreatorRankingScaledTextStyle
|
||||||
) {
|
) {
|
||||||
requireNotNull(deltaGroup).visibility = if (item.showRankChange) VISIBLE else GONE
|
requireNotNull(deltaGroup).visibility = if (item.showRankChange) VISIBLE else GONE
|
||||||
if (!item.showRankChange) return
|
if (!item.showRankChange) return
|
||||||
|
|
||||||
val presentation = CreatorRankingDeltaPresentation.from(item.rankChangeType, item.rankChangeAmount)
|
val presentation = CreatorRankingDeltaPresentation.from(item.rankChangeType, item.rankChangeAmount)
|
||||||
applyDeltaContainer(presentation)
|
val scale = RankingResponsiveScale.fromResources(resources)
|
||||||
|
applyDeltaContainer(presentation, scale)
|
||||||
requireNotNull(deltaIcon).apply {
|
requireNotNull(deltaIcon).apply {
|
||||||
setImageResource(presentation.iconRes)
|
setImageResource(presentation.iconRes)
|
||||||
layoutParams = (layoutParams as MarginLayoutParams).apply {
|
layoutParams = (layoutParams as MarginLayoutParams).apply {
|
||||||
width = presentation.iconWidthDp.dpToPx()
|
width = presentation.iconWidthDp.dpToPx(scale)
|
||||||
height = presentation.iconHeightDp.dpToPx()
|
height = presentation.iconHeightDp.dpToPx(scale)
|
||||||
marginStart = presentation.iconMarginStartDp.dpToPx()
|
marginStart = presentation.iconMarginStartDp.dpToPx(scale)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
requireNotNull(deltaAmountText).apply {
|
requireNotNull(deltaAmountText).apply {
|
||||||
text = presentation.amountText.orEmpty()
|
text = presentation.amountText.orEmpty()
|
||||||
setTextSize(TypedValue.COMPLEX_UNIT_SP, style.deltaTextSizeSp.toFloat())
|
setTextSize(TypedValue.COMPLEX_UNIT_SP, style.deltaTextSizeSp)
|
||||||
visibility = if (presentation.showAmount) VISIBLE else GONE
|
visibility = if (presentation.showAmount) VISIBLE else GONE
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun applyDeltaContainer(presentation: CreatorRankingDeltaPresentation) {
|
private fun applyDeltaContainer(
|
||||||
|
presentation: CreatorRankingDeltaPresentation,
|
||||||
|
scale: Float
|
||||||
|
) {
|
||||||
requireNotNull(deltaGroup).apply {
|
requireNotNull(deltaGroup).apply {
|
||||||
setBackgroundResource(if (presentation.showPillBackground) R.drawable.bg_creator_ranking_delta else 0)
|
setBackgroundResource(if (presentation.showPillBackground) R.drawable.bg_creator_ranking_delta else 0)
|
||||||
val horizontalPadding = if (presentation.showPillBackground) 4.dpToPx() else 0
|
val horizontalPadding = if (presentation.showPillBackground) 4.dpToPx(scale) else 0
|
||||||
setPadding(horizontalPadding, paddingTop, horizontalPadding, paddingBottom)
|
setPadding(horizontalPadding, paddingTop, horizontalPadding, paddingBottom)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -146,5 +151,5 @@ class CreatorRankingTopCardView @JvmOverloads constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun Int.dpToPx(): Int = (this * resources.displayMetrics.density).roundToInt()
|
private fun Int.dpToPx(scale: Float = 1f): Int = (this * scale * resources.displayMetrics.density).roundToInt()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.widget.ranking
|
||||||
|
|
||||||
|
import android.content.res.Resources
|
||||||
|
|
||||||
|
object RankingResponsiveScale {
|
||||||
|
private const val BASE_WIDTH_DP = 402
|
||||||
|
|
||||||
|
fun fromResources(resources: Resources): Float = fromScreenWidthDp(resources.configuration.screenWidthDp)
|
||||||
|
|
||||||
|
fun fromScreenWidthDp(screenWidthDp: Int): Float {
|
||||||
|
return when {
|
||||||
|
screenWidthDp <= 0 -> 1f
|
||||||
|
screenWidthDp >= BASE_WIDTH_DP -> 1f
|
||||||
|
else -> screenWidthDp / BASE_WIDTH_DP.toFloat()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
app/src/main/res/drawable-mdpi/ic_feed_community_heart_fill.png
Normal file
BIN
app/src/main/res/drawable-mdpi/ic_feed_community_heart_fill.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 401 B |
Binary file not shown.
|
Before Width: | Height: | Size: 4.1 MiB |
@@ -1,10 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="18dp"
|
|
||||||
android:height="18dp"
|
|
||||||
android:viewportWidth="18"
|
|
||||||
android:viewportHeight="18">
|
|
||||||
<path
|
|
||||||
android:fillColor="@color/soda_400"
|
|
||||||
android:pathData="M9,15.8L7.9,14.8C4,11.3 1.5,9 1.5,6.2C1.5,3.9 3.3,2.2 5.5,2.2C6.8,2.2 8,2.8 9,3.8C10,2.8 11.2,2.2 12.5,2.2C14.7,2.2 16.5,3.9 16.5,6.2C16.5,9 14,11.3 10.1,14.8L9,15.8Z" />
|
|
||||||
</vector>
|
|
||||||
@@ -46,7 +46,6 @@
|
|||||||
android:id="@+id/layout_creator_channel_community_detail_post"
|
android:id="@+id/layout_creator_channel_community_detail_post"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:background="@drawable/bg_feed_card"
|
|
||||||
android:orientation="vertical"
|
android:orientation="vertical"
|
||||||
android:padding="@dimen/spacing_14">
|
android:padding="@dimen/spacing_14">
|
||||||
|
|
||||||
@@ -150,6 +149,16 @@
|
|||||||
android:contentDescription="@string/a11y_feed_content_image"
|
android:contentDescription="@string/a11y_feed_content_image"
|
||||||
android:scaleType="centerCrop" />
|
android:scaleType="centerCrop" />
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/iv_creator_channel_community_detail_play_or_pause"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_gravity="center"
|
||||||
|
android:contentDescription="@null"
|
||||||
|
android:src="@drawable/btn_audio_content_play"
|
||||||
|
android:visibility="gone"
|
||||||
|
tools:visibility="visible" />
|
||||||
|
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:id="@+id/layout_creator_channel_community_detail_paywall"
|
android:id="@+id/layout_creator_channel_community_detail_paywall"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
@@ -166,19 +175,33 @@
|
|||||||
android:contentDescription="@null"
|
android:contentDescription="@null"
|
||||||
android:src="@drawable/ic_new_community_lock" />
|
android:src="@drawable/ic_new_community_lock" />
|
||||||
|
|
||||||
<TextView
|
<LinearLayout
|
||||||
android:id="@+id/tv_creator_channel_community_detail_paywall_price"
|
android:layout_width="wrap_content"
|
||||||
style="@style/Typography.Body3"
|
android:layout_height="wrap_content"
|
||||||
android:layout_width="70dp"
|
|
||||||
android:layout_height="36dp"
|
|
||||||
android:layout_marginTop="@dimen/spacing_4"
|
android:layout_marginTop="@dimen/spacing_4"
|
||||||
android:background="@drawable/bg_creator_channel_community_price"
|
android:background="@drawable/bg_creator_channel_community_price"
|
||||||
android:drawableStart="@drawable/ic_bar_cash"
|
|
||||||
android:drawablePadding="@dimen/spacing_6"
|
|
||||||
android:gravity="center"
|
android:gravity="center"
|
||||||
android:includeFontPadding="false"
|
android:orientation="horizontal"
|
||||||
android:textColor="@color/black"
|
android:paddingHorizontal="12dp"
|
||||||
tools:text="30" />
|
android:paddingVertical="6dp">
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:contentDescription="@null"
|
||||||
|
android:src="@drawable/ic_bar_cash" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tv_creator_channel_community_detail_paywall_price"
|
||||||
|
style="@style/Typography.Body3"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="@dimen/spacing_6"
|
||||||
|
android:gravity="center"
|
||||||
|
android:includeFontPadding="false"
|
||||||
|
android:textColor="@color/black"
|
||||||
|
tools:text="30" />
|
||||||
|
</LinearLayout>
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
</FrameLayout>
|
</FrameLayout>
|
||||||
|
|
||||||
@@ -215,6 +238,7 @@
|
|||||||
tools:visibility="visible" />
|
tools:visibility="visible" />
|
||||||
|
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
|
android:id="@+id/layout_creator_channel_community_detail_reaction"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="24dp"
|
android:layout_height="24dp"
|
||||||
android:layout_marginTop="@dimen/spacing_16"
|
android:layout_marginTop="@dimen/spacing_16"
|
||||||
@@ -233,6 +257,7 @@
|
|||||||
style="@style/Typography.Body3"
|
style="@style/Typography.Body3"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginEnd="15dp"
|
||||||
android:layout_marginStart="@dimen/spacing_4"
|
android:layout_marginStart="@dimen/spacing_4"
|
||||||
android:includeFontPadding="false"
|
android:includeFontPadding="false"
|
||||||
android:textColor="@color/gray_400"
|
android:textColor="@color/gray_400"
|
||||||
@@ -242,7 +267,6 @@
|
|||||||
android:id="@+id/iv_creator_channel_community_detail_heart"
|
android:id="@+id/iv_creator_channel_community_detail_heart"
|
||||||
android:layout_width="18dp"
|
android:layout_width="18dp"
|
||||||
android:layout_height="18dp"
|
android:layout_height="18dp"
|
||||||
android:layout_marginStart="15dp"
|
|
||||||
android:contentDescription="@null"
|
android:contentDescription="@null"
|
||||||
android:src="@drawable/ic_feed_community_heart" />
|
android:src="@drawable/ic_feed_community_heart" />
|
||||||
|
|
||||||
@@ -259,6 +283,14 @@
|
|||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
|
<View
|
||||||
|
android:id="@+id/view_creator_channel_community_detail_comment_divider"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="1dp"
|
||||||
|
android:background="@color/gray_800"
|
||||||
|
android:visibility="gone"
|
||||||
|
tools:visibility="visible" />
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/tv_creator_channel_community_detail_comment_header"
|
android:id="@+id/tv_creator_channel_community_detail_comment_header"
|
||||||
style="@style/Typography.Body3"
|
style="@style/Typography.Body3"
|
||||||
|
|||||||
@@ -1,23 +1,11 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
xmlns:tools="http://schemas.android.com/tools"
|
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent">
|
android:layout_height="match_parent"
|
||||||
|
android:background="@color/black">
|
||||||
<ImageView
|
|
||||||
android:layout_width="0dp"
|
|
||||||
android:layout_height="0dp"
|
|
||||||
android:contentDescription="@null"
|
|
||||||
android:scaleType="centerCrop"
|
|
||||||
android:src="@drawable/splash_bg"
|
|
||||||
app:layout_constraintBottom_toBottomOf="parent"
|
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
|
||||||
app:layout_constraintTop_toTopOf="parent" />
|
|
||||||
|
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:id="@+id/ll_bottom"
|
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:gravity="center"
|
android:gravity="center"
|
||||||
@@ -25,32 +13,19 @@
|
|||||||
app:layout_constraintBottom_toBottomOf="parent"
|
app:layout_constraintBottom_toBottomOf="parent"
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
tools:ignore="UseCompoundDrawables">
|
app:layout_constraintTop_toTopOf="parent">
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
|
style="@style/Typography.Heading3"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:fontFamily="@font/bold"
|
|
||||||
android:text="@string/screen_splash_tagline"
|
android:text="@string/screen_splash_tagline"
|
||||||
android:textColor="@color/white"
|
android:textColor="@color/white" />
|
||||||
android:textSize="23sp" />
|
|
||||||
|
|
||||||
<ImageView
|
<ImageView
|
||||||
android:id="@+id/iv_text_logo"
|
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginTop="10dp"
|
|
||||||
android:contentDescription="@null"
|
|
||||||
android:src="@drawable/splash_text_logo" />
|
android:src="@drawable/splash_text_logo" />
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/tv_company_name"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_marginTop="52dp"
|
|
||||||
android:fontFamily="@font/bold"
|
|
||||||
android:text="@string/screen_splash_brand_name"
|
|
||||||
android:textColor="@color/white"
|
|
||||||
android:textSize="21sp" />
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||||
|
|||||||
@@ -37,6 +37,20 @@
|
|||||||
app:layout_constraintTop_toBottomOf="@id/view_chat_filter_tabs"
|
app:layout_constraintTop_toBottomOf="@id/view_chat_filter_tabs"
|
||||||
tools:listitem="@layout/item_v2_chat_room" />
|
tools:listitem="@layout/item_v2_chat_room" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tv_chat_empty_message"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:gravity="center"
|
||||||
|
android:textColor="@color/color_777777"
|
||||||
|
android:textSize="14sp"
|
||||||
|
android:visibility="gone"
|
||||||
|
app:layout_constraintBottom_toBottomOf="parent"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toBottomOf="@id/view_chat_filter_tabs"
|
||||||
|
tools:text="아직 나눈 대화가 없어요.\n크리에이터와 이야기를 시작해 보세요." />
|
||||||
|
|
||||||
<ImageView
|
<ImageView
|
||||||
android:id="@+id/btn_chat_floating"
|
android:id="@+id/btn_chat_floating"
|
||||||
android:layout_width="@dimen/spacing_48"
|
android:layout_width="@dimen/spacing_48"
|
||||||
|
|||||||
@@ -69,24 +69,10 @@
|
|||||||
app:layout_constraintStart_toStartOf="parent"
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
app:layout_constraintTop_toBottomOf="@id/layout_content_all_day_filter">
|
app:layout_constraintTop_toBottomOf="@id/layout_content_all_day_filter">
|
||||||
|
|
||||||
<TextView
|
<Space
|
||||||
android:id="@+id/tv_content_all_total_label"
|
|
||||||
style="@style/Typography.Body2"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:includeFontPadding="false"
|
|
||||||
android:text="@string/screen_content_tab_all"
|
|
||||||
android:textColor="@color/white" />
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/tv_content_all_total_count"
|
|
||||||
style="@style/Typography.Body2"
|
|
||||||
android:layout_width="0dp"
|
android:layout_width="0dp"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginStart="@dimen/spacing_4"
|
android:layout_weight="1" />
|
||||||
android:layout_weight="1"
|
|
||||||
android:includeFontPadding="false"
|
|
||||||
android:textColor="@color/gray_500" />
|
|
||||||
|
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:id="@+id/layout_content_all_sort_button"
|
android:id="@+id/layout_content_all_sort_button"
|
||||||
|
|||||||
@@ -141,7 +141,8 @@
|
|||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginTop="@dimen/spacing_48"
|
android:layout_marginTop="@dimen/spacing_48"
|
||||||
android:orientation="vertical">
|
android:orientation="vertical"
|
||||||
|
android:visibility="gone">
|
||||||
|
|
||||||
<kr.co.vividnext.sodalive.v2.main.home.ui.HomePagerSnapRecyclerView
|
<kr.co.vividnext.sodalive.v2.main.home.ui.HomePagerSnapRecyclerView
|
||||||
android:id="@+id/rv_home_genre_creators"
|
android:id="@+id/rv_home_genre_creators"
|
||||||
|
|||||||
@@ -103,6 +103,7 @@
|
|||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
<View
|
<View
|
||||||
|
android:id="@+id/view_creator_channel_community_comment_divider"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="1dp"
|
android:layout_height="1dp"
|
||||||
android:layout_marginTop="@dimen/spacing_14"
|
android:layout_marginTop="@dimen/spacing_14"
|
||||||
|
|||||||
@@ -41,49 +41,54 @@
|
|||||||
tools:text="텍스트만 있는 게시물"
|
tools:text="텍스트만 있는 게시물"
|
||||||
tools:visibility="visible" />
|
tools:visibility="visible" />
|
||||||
|
|
||||||
<View
|
<LinearLayout
|
||||||
android:id="@+id/layout_creator_channel_community_grid_locked_overlay"
|
android:id="@+id/layout_creator_channel_community_grid_locked_overlay"
|
||||||
android:layout_width="0dp"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="0dp"
|
android:layout_height="match_parent"
|
||||||
android:background="@color/color_26909090"
|
android:background="@color/color_26909090"
|
||||||
android:visibility="gone"
|
|
||||||
app:layout_constraintBottom_toBottomOf="parent"
|
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
|
||||||
app:layout_constraintTop_toTopOf="parent"
|
|
||||||
tools:visibility="visible" />
|
|
||||||
|
|
||||||
<ImageView
|
|
||||||
android:id="@+id/iv_creator_channel_community_grid_lock"
|
|
||||||
android:layout_width="24dp"
|
|
||||||
android:layout_height="24dp"
|
|
||||||
android:contentDescription="@null"
|
|
||||||
android:src="@drawable/ic_new_community_lock"
|
|
||||||
android:visibility="gone"
|
|
||||||
app:layout_constraintBottom_toBottomOf="parent"
|
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
|
||||||
app:layout_constraintTop_toTopOf="parent"
|
|
||||||
tools:visibility="visible" />
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/tv_creator_channel_community_grid_lock_price"
|
|
||||||
style="@style/Typography.Body3"
|
|
||||||
android:layout_width="70dp"
|
|
||||||
android:layout_height="36dp"
|
|
||||||
android:layout_marginTop="@dimen/spacing_4"
|
|
||||||
android:background="@drawable/bg_creator_channel_community_price"
|
|
||||||
android:drawableStart="@drawable/ic_bar_cash"
|
|
||||||
android:drawablePadding="@dimen/spacing_6"
|
|
||||||
android:gravity="center"
|
android:gravity="center"
|
||||||
android:includeFontPadding="false"
|
android:orientation="vertical"
|
||||||
android:textColor="@color/black"
|
|
||||||
android:visibility="gone"
|
android:visibility="gone"
|
||||||
|
app:layout_constraintBottom_toBottomOf="parent"
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
app:layout_constraintTop_toBottomOf="@id/iv_creator_channel_community_grid_lock"
|
app:layout_constraintTop_toTopOf="parent"
|
||||||
tools:text="30"
|
tools:visibility="visible">
|
||||||
tools:visibility="visible" />
|
|
||||||
|
<ImageView
|
||||||
|
android:layout_width="24dp"
|
||||||
|
android:layout_height="24dp"
|
||||||
|
android:contentDescription="@null"
|
||||||
|
android:src="@drawable/ic_new_community_lock" />
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="@dimen/spacing_4"
|
||||||
|
android:background="@drawable/bg_creator_channel_community_price"
|
||||||
|
android:gravity="center"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:paddingHorizontal="12dp"
|
||||||
|
android:paddingVertical="6dp">
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:contentDescription="@null"
|
||||||
|
android:src="@drawable/ic_bar_cash" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tv_creator_channel_community_grid_lock_price"
|
||||||
|
style="@style/Typography.Body3"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="@dimen/spacing_6"
|
||||||
|
android:gravity="center"
|
||||||
|
android:includeFontPadding="false"
|
||||||
|
android:textColor="@color/black"
|
||||||
|
tools:text="30" />
|
||||||
|
</LinearLayout>
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
<ImageView
|
<ImageView
|
||||||
android:id="@+id/iv_creator_channel_community_grid_notice"
|
android:id="@+id/iv_creator_channel_community_grid_notice"
|
||||||
|
|||||||
@@ -216,6 +216,7 @@
|
|||||||
</FrameLayout>
|
</FrameLayout>
|
||||||
|
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
|
android:id="@+id/layout_creator_channel_community_list_reaction"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="24dp"
|
android:layout_height="24dp"
|
||||||
android:layout_marginTop="@dimen/spacing_16"
|
android:layout_marginTop="@dimen/spacing_16"
|
||||||
@@ -235,14 +236,15 @@
|
|||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginStart="@dimen/spacing_4"
|
android:layout_marginStart="@dimen/spacing_4"
|
||||||
|
android:layout_marginEnd="15dp"
|
||||||
android:includeFontPadding="false"
|
android:includeFontPadding="false"
|
||||||
android:textColor="@color/gray_400"
|
android:textColor="@color/gray_400"
|
||||||
tools:text="5" />
|
tools:text="5" />
|
||||||
|
|
||||||
<ImageView
|
<ImageView
|
||||||
|
android:id="@+id/iv_creator_channel_community_list_like"
|
||||||
android:layout_width="18dp"
|
android:layout_width="18dp"
|
||||||
android:layout_height="18dp"
|
android:layout_height="18dp"
|
||||||
android:layout_marginStart="15dp"
|
|
||||||
android:contentDescription="@null"
|
android:contentDescription="@null"
|
||||||
android:src="@drawable/ic_feed_community_heart" />
|
android:src="@drawable/ic_feed_community_heart" />
|
||||||
|
|
||||||
|
|||||||
@@ -18,9 +18,25 @@
|
|||||||
android:includeFontPadding="false"
|
android:includeFontPadding="false"
|
||||||
android:textColor="@color/color_777777"
|
android:textColor="@color/color_777777"
|
||||||
android:textSize="12sp"
|
android:textSize="12sp"
|
||||||
|
app:layout_constraintBottom_toTopOf="@id/tv_time"
|
||||||
|
app:layout_constraintEnd_toEndOf="@id/tv_time"
|
||||||
|
tools:text="전송 중" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tv_time"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginEnd="4dp"
|
||||||
|
android:fontFamily="@font/regular"
|
||||||
|
android:includeFontPadding="false"
|
||||||
|
android:textColor="@color/gray_500"
|
||||||
|
android:textSize="12sp"
|
||||||
app:layout_constraintBottom_toBottomOf="@id/message_container"
|
app:layout_constraintBottom_toBottomOf="@id/message_container"
|
||||||
app:layout_constraintEnd_toStartOf="@id/message_container"
|
app:layout_constraintEnd_toStartOf="@id/message_container"
|
||||||
tools:text="전송 중" />
|
app:layout_constraintHorizontal_bias="1"
|
||||||
|
app:layout_constraintHorizontal_chainStyle="packed"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
tools:text="오후 11:03" />
|
||||||
|
|
||||||
<androidx.constraintlayout.widget.ConstraintLayout
|
<androidx.constraintlayout.widget.ConstraintLayout
|
||||||
android:id="@+id/message_container"
|
android:id="@+id/message_container"
|
||||||
@@ -31,7 +47,7 @@
|
|||||||
android:paddingVertical="10dp"
|
android:paddingVertical="10dp"
|
||||||
app:layout_constrainedWidth="true"
|
app:layout_constrainedWidth="true"
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
app:layout_constraintStart_toEndOf="@id/tv_status"
|
app:layout_constraintStart_toEndOf="@id/tv_time"
|
||||||
app:layout_constraintTop_toTopOf="parent">
|
app:layout_constraintTop_toTopOf="parent">
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
|
|||||||
@@ -73,4 +73,17 @@
|
|||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tv_time"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="4dp"
|
||||||
|
android:fontFamily="@font/regular"
|
||||||
|
android:includeFontPadding="false"
|
||||||
|
android:textColor="@color/gray_500"
|
||||||
|
android:textSize="12sp"
|
||||||
|
app:layout_constraintBottom_toBottomOf="@id/message_group"
|
||||||
|
app:layout_constraintStart_toEndOf="@id/message_group"
|
||||||
|
tools:text="오후 11:03" />
|
||||||
|
|
||||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
android:id="@+id/tv_creator_ranking_rank"
|
android:id="@+id/tv_creator_ranking_rank"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:fontFamily="@font/phosphate_solid"
|
android:fontFamily="@font/pattaya_regular"
|
||||||
android:gravity="center"
|
android:gravity="center"
|
||||||
android:shadowColor="#7A000000"
|
android:shadowColor="#7A000000"
|
||||||
android:shadowRadius="4"
|
android:shadowRadius="4"
|
||||||
|
|||||||
@@ -41,7 +41,7 @@
|
|||||||
android:id="@+id/tv_creator_ranking_rank"
|
android:id="@+id/tv_creator_ranking_rank"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:fontFamily="@font/phosphate_solid"
|
android:fontFamily="@font/pattaya_regular"
|
||||||
android:gravity="center"
|
android:gravity="center"
|
||||||
android:padding="0dp"
|
android:padding="0dp"
|
||||||
android:shadowColor="#7A000000"
|
android:shadowColor="#7A000000"
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
<string name="a11y_feed_profile_image">Creator profile image</string>
|
<string name="a11y_feed_profile_image">Creator profile image</string>
|
||||||
|
|
||||||
<!-- Splash / Main common -->
|
<!-- Splash / Main common -->
|
||||||
<string name="screen_splash_tagline">A new world through voice</string>
|
<string name="screen_splash_tagline">Meet Your Favorites</string>
|
||||||
<string name="screen_splash_brand_name">SodaLive</string>
|
<string name="screen_splash_brand_name">SodaLive</string>
|
||||||
<string name="screen_splash_maintenance_title">Notice</string>
|
<string name="screen_splash_maintenance_title">Notice</string>
|
||||||
<string name="screen_splash_maintenance_desc">Service Under Maintenance</string>
|
<string name="screen_splash_maintenance_desc">Service Under Maintenance</string>
|
||||||
@@ -158,6 +158,10 @@
|
|||||||
<string name="screen_chat_filter_ai">AI Chat</string>
|
<string name="screen_chat_filter_ai">AI Chat</string>
|
||||||
<string name="screen_chat_filter_dm">DM</string>
|
<string name="screen_chat_filter_dm">DM</string>
|
||||||
<string name="screen_chat_floating_button">New chat</string>
|
<string name="screen_chat_floating_button">New chat</string>
|
||||||
|
<string name="screen_chat_empty_all">No conversations yet.\nStart a conversation with a creator.</string>
|
||||||
|
<string name="screen_chat_empty_ai">No conversations yet.\nStart chatting with an AI creator.</string>
|
||||||
|
<string name="screen_chat_empty_dm">No conversations yet.\nSend a message to your favorite creator.</string>
|
||||||
|
<string name="screen_dm_chat_room_enter_failed">Could not enter the chat room.</string>
|
||||||
<string name="live_now">On Air</string>
|
<string name="live_now">On Air</string>
|
||||||
<string name="screen_live_now_all_title">View all</string>
|
<string name="screen_live_now_all_title">View all</string>
|
||||||
<string name="screen_live_now_all_empty_message">There are currently no live broadcasts available, or access may be restricted due to age limits.\nVerify your identity or follow the channel to get notified when a live broadcast starts.</string>
|
<string name="screen_live_now_all_empty_message">There are currently no live broadcasts available, or access may be restricted due to age limits.\nVerify your identity or follow the channel to get notified when a live broadcast starts.</string>
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
<string name="a11y_feed_profile_image">クリエイターのプロフィール画像</string>
|
<string name="a11y_feed_profile_image">クリエイターのプロフィール画像</string>
|
||||||
|
|
||||||
<!-- Splash / Main common -->
|
<!-- Splash / Main common -->
|
||||||
<string name="screen_splash_tagline">声でつながる新しいワールド</string>
|
<string name="screen_splash_tagline">推しが待つ場所</string>
|
||||||
<string name="screen_splash_brand_name">ソダライブ</string>
|
<string name="screen_splash_brand_name">ソダライブ</string>
|
||||||
<string name="screen_splash_maintenance_title">お知らせ</string>
|
<string name="screen_splash_maintenance_title">お知らせ</string>
|
||||||
<string name="screen_splash_maintenance_desc">ただいまメンテナンス中です。</string>
|
<string name="screen_splash_maintenance_desc">ただいまメンテナンス中です。</string>
|
||||||
@@ -158,6 +158,10 @@
|
|||||||
<string name="screen_chat_filter_ai">AIチャット</string>
|
<string name="screen_chat_filter_ai">AIチャット</string>
|
||||||
<string name="screen_chat_filter_dm">DM</string>
|
<string name="screen_chat_filter_dm">DM</string>
|
||||||
<string name="screen_chat_floating_button">新しいチャット</string>
|
<string name="screen_chat_floating_button">新しいチャット</string>
|
||||||
|
<string name="screen_chat_empty_all">まだ会話がありません。\nクリエイターと話してみましょう。</string>
|
||||||
|
<string name="screen_chat_empty_ai">まだ会話がありません。\nAIクリエイターと話してみましょう。</string>
|
||||||
|
<string name="screen_chat_empty_dm">まだ会話がありません。\n好きなクリエイターにメッセージを送ってみましょう。</string>
|
||||||
|
<string name="screen_dm_chat_room_enter_failed">チャットルームに接続できませんでした</string>
|
||||||
<string name="live_now">ただいま配信中</string>
|
<string name="live_now">ただいま配信中</string>
|
||||||
<string name="screen_live_now_all_title">配信中のライブをすべて見る</string>
|
<string name="screen_live_now_all_title">配信中のライブをすべて見る</string>
|
||||||
<string name="screen_live_now_all_empty_message">参加可能なライブがないか、年齢制限で入室できません。\n本人確認を行うか、チャンネルをフォローして\n配信通知を受け取ってみましょう。</string>
|
<string name="screen_live_now_all_empty_message">参加可能なライブがないか、年齢制限で入室できません。\n本人確認を行うか、チャンネルをフォローして\n配信通知を受け取ってみましょう。</string>
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
<string name="a11y_feed_profile_image">크리에이터 프로필 이미지</string>
|
<string name="a11y_feed_profile_image">크리에이터 프로필 이미지</string>
|
||||||
|
|
||||||
<!-- Splash / Main common -->
|
<!-- Splash / Main common -->
|
||||||
<string name="screen_splash_tagline">목소리로 만나는 새로운 세계</string>
|
<string name="screen_splash_tagline">당신의 최애가 기다리는 곳</string>
|
||||||
<string name="screen_splash_brand_name">소다라이브</string>
|
<string name="screen_splash_brand_name">소다라이브</string>
|
||||||
<string name="screen_splash_maintenance_title">안내</string>
|
<string name="screen_splash_maintenance_title">안내</string>
|
||||||
<string name="screen_splash_maintenance_desc">서비스 점검중입니다.</string>
|
<string name="screen_splash_maintenance_desc">서비스 점검중입니다.</string>
|
||||||
@@ -157,6 +157,10 @@
|
|||||||
<string name="screen_chat_filter_ai">AI 채팅</string>
|
<string name="screen_chat_filter_ai">AI 채팅</string>
|
||||||
<string name="screen_chat_filter_dm">DM</string>
|
<string name="screen_chat_filter_dm">DM</string>
|
||||||
<string name="screen_chat_floating_button">새 대화</string>
|
<string name="screen_chat_floating_button">새 대화</string>
|
||||||
|
<string name="screen_chat_empty_all">아직 나눈 대화가 없어요.\n크리에이터와 이야기를 시작해 보세요.</string>
|
||||||
|
<string name="screen_chat_empty_ai">아직 나눈 대화가 없어요.\nAI 크리에이터와 대화를 시작해 보세요.</string>
|
||||||
|
<string name="screen_chat_empty_dm">아직 나눈 대화가 없어요.\n좋아하는 크리에이터에게 메세지를 보내보세요.</string>
|
||||||
|
<string name="screen_dm_chat_room_enter_failed">대화방에 접속하지 못했습니다</string>
|
||||||
<string name="live_now">지금 라이브 중</string>
|
<string name="live_now">지금 라이브 중</string>
|
||||||
<string name="screen_live_now_all_title">지금 라이브 중 전체보기</string>
|
<string name="screen_live_now_all_title">지금 라이브 중 전체보기</string>
|
||||||
<string name="screen_live_now_all_empty_message">현재 참여 가능한 라이브 방송이 없거나\n연령제한으로 입장이 불가능합니다.\n본인인증을 해보거나 채널을 팔로잉하고\n라이브 방송 알림을 받아보세요.</string>
|
<string name="screen_live_now_all_empty_message">현재 참여 가능한 라이브 방송이 없거나\n연령제한으로 입장이 불가능합니다.\n본인인증을 해보거나 채널을 팔로잉하고\n라이브 방송 알림을 받아보세요.</string>
|
||||||
@@ -295,7 +299,7 @@
|
|||||||
<string name="content_recommendation_section_new_and_hot">New&Hot</string>
|
<string name="content_recommendation_section_new_and_hot">New&Hot</string>
|
||||||
<string name="content_recommendation_section_free_audio">무료 오디오</string>
|
<string name="content_recommendation_section_free_audio">무료 오디오</string>
|
||||||
<string name="content_recommendation_section_point_audio">포인트 오디오</string>
|
<string name="content_recommendation_section_point_audio">포인트 오디오</string>
|
||||||
<string name="content_recommendation_section_most_commented_audio">최근 댓글이 많은 오디오</string>
|
<string name="content_recommendation_section_most_commented_audio">크리에이터의 댓글 많은 오디오</string>
|
||||||
<string name="content_recommendation_section_recommended_audio">추천 오디오</string>
|
<string name="content_recommendation_section_recommended_audio">추천 오디오</string>
|
||||||
<string name="home_recommendation_section_live">라이브</string>
|
<string name="home_recommendation_section_live">라이브</string>
|
||||||
<string name="home_recommendation_section_recently_active_creators">방금 활동한 크리에이터</string>
|
<string name="home_recommendation_section_recently_active_creators">방금 활동한 크리에이터</string>
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ class CreatorChannelCommunityDetailReplyUiContractTest {
|
|||||||
assertNotNull(detail.findViewById<RecyclerView>(R.id.rv_creator_channel_community_detail_comments))
|
assertNotNull(detail.findViewById<RecyclerView>(R.id.rv_creator_channel_community_detail_comments))
|
||||||
assertNotNull(detail.findViewById<View>(R.id.layout_creator_channel_community_detail_paywall))
|
assertNotNull(detail.findViewById<View>(R.id.layout_creator_channel_community_detail_paywall))
|
||||||
assertNotNull(detail.findViewById<TextView>(R.id.tv_creator_channel_community_detail_paywall_price))
|
assertNotNull(detail.findViewById<TextView>(R.id.tv_creator_channel_community_detail_paywall_price))
|
||||||
|
assertNotNull(detail.findViewById<ImageView>(R.id.iv_creator_channel_community_detail_play_or_pause))
|
||||||
assertNotNull(detail.findViewById<View>(R.id.layout_creator_channel_community_detail_input_bar))
|
assertNotNull(detail.findViewById<View>(R.id.layout_creator_channel_community_detail_input_bar))
|
||||||
assertNotNull(detail.findViewById<EditText>(R.id.et_creator_channel_community_detail_comment))
|
assertNotNull(detail.findViewById<EditText>(R.id.et_creator_channel_community_detail_comment))
|
||||||
assertNotNull(detail.findViewById<ImageButton>(R.id.btn_creator_channel_community_detail_send))
|
assertNotNull(detail.findViewById<ImageButton>(R.id.btn_creator_channel_community_detail_send))
|
||||||
@@ -138,6 +139,23 @@ class CreatorChannelCommunityDetailReplyUiContractTest {
|
|||||||
assertTrue(!replyActivity.contains("AlertDialog"))
|
assertTrue(!replyActivity.contains("AlertDialog"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `상세 Activity는 레거시 구매 다이얼로그와 이미지 중앙 오디오 버튼을 사용한다`() {
|
||||||
|
val detailLayout = projectFile("app/src/main/res/layout/activity_creator_channel_community_detail.xml").readText()
|
||||||
|
val detailActivity = projectFile(
|
||||||
|
"app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/community/detail/" +
|
||||||
|
"CreatorChannelCommunityDetailActivity.kt"
|
||||||
|
).readText()
|
||||||
|
|
||||||
|
assertTrue(detailLayout.contains("@+id/iv_creator_channel_community_detail_play_or_pause"))
|
||||||
|
assertTrue(detailLayout.contains("android:layout_gravity=\"center\""))
|
||||||
|
assertTrue(detailLayout.contains("@drawable/btn_audio_content_play"))
|
||||||
|
assertTrue(detailActivity.contains("PurchaseCommunityPostDialog"))
|
||||||
|
assertTrue(detailActivity.contains("viewModel.purchaseCommunityPost()"))
|
||||||
|
assertTrue(detailActivity.contains("btn_audio_content_pause"))
|
||||||
|
assertTrue(detailActivity.contains("btn_audio_content_play"))
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `댓글 답글 Adapter는 PopupMenu 대신 regular font popup을 사용한다`() {
|
fun `댓글 답글 Adapter는 PopupMenu 대신 regular font popup을 사용한다`() {
|
||||||
val commentAdapter = projectFile(
|
val commentAdapter = projectFile(
|
||||||
|
|||||||
@@ -373,6 +373,36 @@ class CreatorChannelCommunityDetailViewModelTest {
|
|||||||
assertTrue(state.post.showPaywall)
|
assertTrue(state.post.showPaywall)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `유료 미구매 게시물은 작성자가 아니면 reaction 현황과 댓글을 숨긴다`() {
|
||||||
|
SharedPreferenceManager.userId = 10L
|
||||||
|
stubDetail(
|
||||||
|
detailResponse(
|
||||||
|
isCommentAvailable = true,
|
||||||
|
isLiked = true,
|
||||||
|
likeCount = 3,
|
||||||
|
price = 300,
|
||||||
|
existOrdered = false,
|
||||||
|
creatorId = 999L,
|
||||||
|
commentIds = listOf(11L),
|
||||||
|
commentsHasNext = true
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
viewModel.loadDetail(POST_ID)
|
||||||
|
viewModel.updateCommentInput("hello")
|
||||||
|
viewModel.submitComment()
|
||||||
|
|
||||||
|
val state = viewModel.detailStateLiveData.requireValue() as CreatorChannelCommunityDetailUiState.Content
|
||||||
|
assertTrue(state.post.isLocked)
|
||||||
|
assertFalse(state.post.showReaction)
|
||||||
|
assertFalse(state.isCommentAvailable)
|
||||||
|
assertFalse(state.isCommentInputVisible)
|
||||||
|
assertTrue(state.comments.isEmpty())
|
||||||
|
assertFalse(state.hasNextComment)
|
||||||
|
verify(legacyRepository, never()).registerComment(any(), any(), any(), any(), any())
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `채널 작성자는 유료 미구매 게시물도 이미지와 오디오 URL을 볼 수 있다`() {
|
fun `채널 작성자는 유료 미구매 게시물도 이미지와 오디오 URL을 볼 수 있다`() {
|
||||||
SharedPreferenceManager.userId = 999L
|
SharedPreferenceManager.userId = 999L
|
||||||
@@ -394,6 +424,51 @@ class CreatorChannelCommunityDetailViewModelTest {
|
|||||||
assertFalse(state.post.showPaywall)
|
assertFalse(state.post.showPaywall)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `게시물 구매 성공은 상세를 다시 조회해 잠금 상태를 해제한다`() {
|
||||||
|
whenever(repository.getCommunityPostDetail(POST_ID, AUTH_TOKEN))
|
||||||
|
.thenReturn(
|
||||||
|
Single.just(
|
||||||
|
ApiResponse(
|
||||||
|
true,
|
||||||
|
detailResponse(
|
||||||
|
isCommentAvailable = false,
|
||||||
|
price = 300,
|
||||||
|
existOrdered = false,
|
||||||
|
creatorId = 999L
|
||||||
|
),
|
||||||
|
null
|
||||||
|
)
|
||||||
|
),
|
||||||
|
Single.just(
|
||||||
|
ApiResponse(
|
||||||
|
true,
|
||||||
|
detailResponse(
|
||||||
|
isCommentAvailable = false,
|
||||||
|
price = 300,
|
||||||
|
existOrdered = true,
|
||||||
|
creatorId = 999L
|
||||||
|
),
|
||||||
|
null
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
whenever(legacyRepository.purchaseCommunityPost(POST_ID, AUTH_TOKEN))
|
||||||
|
.thenReturn(Single.just(ApiResponse(true, org.mockito.kotlin.mock(), null)))
|
||||||
|
viewModel.loadDetail(POST_ID)
|
||||||
|
|
||||||
|
viewModel.purchaseCommunityPost()
|
||||||
|
|
||||||
|
val state = viewModel.detailStateLiveData.requireValue() as CreatorChannelCommunityDetailUiState.Content
|
||||||
|
assertFalse(state.post.isLocked)
|
||||||
|
assertEquals("image.png", state.post.imageUrl)
|
||||||
|
assertEquals("audio.mp3", state.post.audioUrl)
|
||||||
|
assertTrue(state.post.existOrdered)
|
||||||
|
assertTrue(viewModel.postChangedEventLiveData.requireValue() == true)
|
||||||
|
verify(legacyRepository).purchaseCommunityPost(POST_ID, AUTH_TOKEN)
|
||||||
|
verify(repository, times(2)).getCommunityPostDetail(POST_ID, AUTH_TOKEN)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `댓글 UI 모델은 답글 화면 재포맷을 위해 원본 createdAtUtc를 보존한다`() {
|
fun `댓글 UI 모델은 답글 화면 재포맷을 위해 원본 createdAtUtc를 보존한다`() {
|
||||||
stubDetail(detailResponse(isCommentAvailable = true, commentIds = listOf(11L)))
|
stubDetail(detailResponse(isCommentAvailable = true, commentIds = listOf(11L)))
|
||||||
|
|||||||
@@ -107,6 +107,10 @@ class CreatorChannelCommunityFragmentLayoutTest {
|
|||||||
assertNotNull(item.findViewById<TextView>(R.id.tv_creator_channel_community_grid_lock_price))
|
assertNotNull(item.findViewById<TextView>(R.id.tv_creator_channel_community_grid_lock_price))
|
||||||
assertNotNull(item.findViewById<ImageView>(R.id.iv_creator_channel_community_grid_notice))
|
assertNotNull(item.findViewById<ImageView>(R.id.iv_creator_channel_community_grid_notice))
|
||||||
assertTrue(itemLayout.contains("android:id=\"@+id/layout_creator_channel_community_grid_root\""))
|
assertTrue(itemLayout.contains("android:id=\"@+id/layout_creator_channel_community_grid_root\""))
|
||||||
|
assertTrue(itemLayout.contains("android:id=\"@+id/layout_creator_channel_community_grid_locked_overlay\""))
|
||||||
|
assertTrue(itemLayout.contains("android:layout_width=\"match_parent\""))
|
||||||
|
assertTrue(itemLayout.contains("android:layout_height=\"match_parent\""))
|
||||||
|
assertTrue(itemLayout.contains("android:gravity=\"center\""))
|
||||||
assertTrue(itemLayout.contains("android:layout_width=\"wrap_content\""))
|
assertTrue(itemLayout.contains("android:layout_width=\"wrap_content\""))
|
||||||
assertTrue(itemLayout.contains("android:layout_height=\"wrap_content\""))
|
assertTrue(itemLayout.contains("android:layout_height=\"wrap_content\""))
|
||||||
assertTrue(!itemLayout.contains("android:layout_margin=\"@dimen/spacing_4\""))
|
assertTrue(!itemLayout.contains("android:layout_margin=\"@dimen/spacing_4\""))
|
||||||
|
|||||||
@@ -77,6 +77,21 @@ class CreatorChannelCommunityMapperTest {
|
|||||||
assertFalse(item.showPlayButton)
|
assertFalse(item.showPlayButton)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `유료 미구매 타인 게시글은 reaction 현황과 댓글을 숨긴다`() {
|
||||||
|
val item = listOf(
|
||||||
|
communityPost(
|
||||||
|
price = 100,
|
||||||
|
existOrdered = false,
|
||||||
|
isCommentAvailable = true
|
||||||
|
)
|
||||||
|
).toCommunityPostUiModels(relativeTimeTextFormatter, isOwner = false, currentUserId = 99L).single()
|
||||||
|
|
||||||
|
assertTrue(item.isLocked)
|
||||||
|
assertFalse(item.showReaction)
|
||||||
|
assertFalse(item.showComment)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `고정 게시글 여부는 owner more 정책에서 사용할 수 있도록 UI 모델에 보존한다`() {
|
fun `고정 게시글 여부는 owner more 정책에서 사용할 수 있도록 UI 모델에 보존한다`() {
|
||||||
val pinnedItem = listOf(communityPost(isPinned = true))
|
val pinnedItem = listOf(communityPost(isPinned = true))
|
||||||
|
|||||||
@@ -52,12 +52,15 @@ class ChatMainFragmentLayoutTest {
|
|||||||
val tabBar = requireNotNull(root.findViewById<CapsuleTabBarView>(R.id.view_chat_filter_tabs))
|
val tabBar = requireNotNull(root.findViewById<CapsuleTabBarView>(R.id.view_chat_filter_tabs))
|
||||||
val recyclerView = requireNotNull(root.findViewById<RecyclerView>(R.id.rv_chat_rooms))
|
val recyclerView = requireNotNull(root.findViewById<RecyclerView>(R.id.rv_chat_rooms))
|
||||||
val floatingButton = requireNotNull(root.findViewById<ImageView>(R.id.btn_chat_floating))
|
val floatingButton = requireNotNull(root.findViewById<ImageView>(R.id.btn_chat_floating))
|
||||||
|
val emptyMessage = requireNotNull(root.findViewById<TextView>(R.id.tv_chat_empty_message))
|
||||||
|
|
||||||
assertEquals(Color.BLACK, (root.background as ColorDrawable).color)
|
assertEquals(Color.BLACK, (root.background as ColorDrawable).color)
|
||||||
assertSame(root, titleBar.parent)
|
assertSame(root, titleBar.parent)
|
||||||
assertSame(root, tabBar.parent)
|
assertSame(root, tabBar.parent)
|
||||||
assertSame(root, recyclerView.parent)
|
assertSame(root, recyclerView.parent)
|
||||||
assertSame(root, floatingButton.parent)
|
assertSame(root, floatingButton.parent)
|
||||||
|
assertSame(root, emptyMessage.parent)
|
||||||
|
assertEquals(View.GONE, emptyMessage.visibility)
|
||||||
assertFalse(root.containsClassName("com.google.android.material.bottomnavigation.BottomNavigationView"))
|
assertFalse(root.containsClassName("com.google.android.material.bottomnavigation.BottomNavigationView"))
|
||||||
assertFalse(root.containsViewIdContaining("unread"))
|
assertFalse(root.containsViewIdContaining("unread"))
|
||||||
}
|
}
|
||||||
@@ -69,8 +72,10 @@ class ChatMainFragmentLayoutTest {
|
|||||||
val tabBar = requireNotNull(root.findViewById<CapsuleTabBarView>(R.id.view_chat_filter_tabs))
|
val tabBar = requireNotNull(root.findViewById<CapsuleTabBarView>(R.id.view_chat_filter_tabs))
|
||||||
val recyclerView = requireNotNull(root.findViewById<RecyclerView>(R.id.rv_chat_rooms))
|
val recyclerView = requireNotNull(root.findViewById<RecyclerView>(R.id.rv_chat_rooms))
|
||||||
val floatingButton = requireNotNull(root.findViewById<ImageView>(R.id.btn_chat_floating))
|
val floatingButton = requireNotNull(root.findViewById<ImageView>(R.id.btn_chat_floating))
|
||||||
|
val emptyMessage = requireNotNull(root.findViewById<TextView>(R.id.tv_chat_empty_message))
|
||||||
val tabParams = tabBar.layoutParams as ConstraintLayout.LayoutParams
|
val tabParams = tabBar.layoutParams as ConstraintLayout.LayoutParams
|
||||||
val listParams = recyclerView.layoutParams as ConstraintLayout.LayoutParams
|
val listParams = recyclerView.layoutParams as ConstraintLayout.LayoutParams
|
||||||
|
val emptyParams = emptyMessage.layoutParams as ConstraintLayout.LayoutParams
|
||||||
val buttonParams = floatingButton.layoutParams as ConstraintLayout.LayoutParams
|
val buttonParams = floatingButton.layoutParams as ConstraintLayout.LayoutParams
|
||||||
|
|
||||||
assertEquals(60.dpToPx(), titleBar.layoutParams.height)
|
assertEquals(60.dpToPx(), titleBar.layoutParams.height)
|
||||||
@@ -78,6 +83,8 @@ class ChatMainFragmentLayoutTest {
|
|||||||
assertEquals(R.id.view_chat_title_bar, tabParams.topToBottom)
|
assertEquals(R.id.view_chat_title_bar, tabParams.topToBottom)
|
||||||
assertEquals(R.id.view_chat_filter_tabs, listParams.topToBottom)
|
assertEquals(R.id.view_chat_filter_tabs, listParams.topToBottom)
|
||||||
assertEquals(ConstraintLayout.LayoutParams.PARENT_ID, listParams.bottomToBottom)
|
assertEquals(ConstraintLayout.LayoutParams.PARENT_ID, listParams.bottomToBottom)
|
||||||
|
assertEquals(R.id.view_chat_filter_tabs, emptyParams.topToBottom)
|
||||||
|
assertEquals(ConstraintLayout.LayoutParams.PARENT_ID, emptyParams.bottomToBottom)
|
||||||
assertEquals(false, recyclerView.clipToPadding)
|
assertEquals(false, recyclerView.clipToPadding)
|
||||||
assertTrue(recyclerView.paddingBottom >= 28.dpToPx())
|
assertTrue(recyclerView.paddingBottom >= 28.dpToPx())
|
||||||
assertEquals(ConstraintLayout.LayoutParams.PARENT_ID, buttonParams.endToEnd)
|
assertEquals(ConstraintLayout.LayoutParams.PARENT_ID, buttonParams.endToEnd)
|
||||||
@@ -101,7 +108,8 @@ class ChatMainFragmentLayoutTest {
|
|||||||
assertTrue(source.contains("R.string.screen_chat_filter_all"))
|
assertTrue(source.contains("R.string.screen_chat_filter_all"))
|
||||||
assertTrue(source.contains("R.string.screen_chat_filter_ai"))
|
assertTrue(source.contains("R.string.screen_chat_filter_ai"))
|
||||||
assertTrue(source.contains("R.string.screen_chat_filter_dm"))
|
assertTrue(source.contains("R.string.screen_chat_filter_dm"))
|
||||||
assertTrue(source.contains("viewModel.loadFirstPage(initialFilter ?: ChatRoomFilter.ALL)"))
|
assertTrue(source.contains("val initialFilter = getInitialFilter() ?: ChatRoomFilter.ALL"))
|
||||||
|
assertTrue(source.contains("viewModel.loadFirstPage(initialFilter)"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -145,6 +153,23 @@ class ChatMainFragmentLayoutTest {
|
|||||||
assertTrue(source.contains("!state.isAppending"))
|
assertTrue(source.contains("!state.isAppending"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `채팅 fragment source는 탭별 empty state 문구를 표시한다`() {
|
||||||
|
val source = chatMainFragmentSource()
|
||||||
|
val filter = projectFile(
|
||||||
|
"app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/model/ChatRoomFilter.kt"
|
||||||
|
).readText()
|
||||||
|
|
||||||
|
assertTrue(source.contains("private var selectedFilter: ChatRoomFilter = ChatRoomFilter.ALL"))
|
||||||
|
assertTrue(source.contains("binding.tvChatEmptyMessage.setText(selectedFilter.emptyMessageResId())"))
|
||||||
|
assertTrue(source.contains("ChatRoomListUiState.Empty -> bindChatRooms(emptyList(), showEmpty = true)"))
|
||||||
|
assertTrue(source.contains("is ChatRoomListUiState.Error -> bindChatRooms(emptyList(), showEmpty = false)"))
|
||||||
|
assertTrue(source.contains("binding.rvChatRooms.isVisible = !showEmpty"))
|
||||||
|
assertTrue(filter.contains("ChatRoomFilter.ALL -> R.string.screen_chat_empty_all"))
|
||||||
|
assertTrue(filter.contains("ChatRoomFilter.AI -> R.string.screen_chat_empty_ai"))
|
||||||
|
assertTrue(filter.contains("ChatRoomFilter.DM -> R.string.screen_chat_empty_dm"))
|
||||||
|
}
|
||||||
|
|
||||||
private fun inflateView(layoutResId: Int): View {
|
private fun inflateView(layoutResId: Int): View {
|
||||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||||
return LayoutInflater.from(context).inflate(layoutResId, null, false)
|
return LayoutInflater.from(context).inflate(layoutResId, null, false)
|
||||||
|
|||||||
@@ -1,174 +0,0 @@
|
|||||||
package kr.co.vividnext.sodalive.v2.main.chat.dm
|
|
||||||
|
|
||||||
import com.google.gson.Gson
|
|
||||||
import kr.co.vividnext.sodalive.v2.main.chat.dm.data.DmChatEventClient
|
|
||||||
import kr.co.vividnext.sodalive.v2.main.chat.dm.data.DmChatMessageResponse
|
|
||||||
import okhttp3.MediaType.Companion.toMediaType
|
|
||||||
import okhttp3.OkHttpClient
|
|
||||||
import okhttp3.Protocol
|
|
||||||
import okhttp3.Response
|
|
||||||
import okhttp3.ResponseBody.Companion.toResponseBody
|
|
||||||
import org.junit.Assert.assertEquals
|
|
||||||
import org.junit.Assert.assertNotNull
|
|
||||||
import org.junit.Assert.assertTrue
|
|
||||||
import org.junit.Test
|
|
||||||
import java.util.concurrent.CountDownLatch
|
|
||||||
import java.util.concurrent.TimeUnit
|
|
||||||
|
|
||||||
class DmChatEventClientTest {
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `비정상 HTTP 응답은 failure callback으로 전달된다`() {
|
|
||||||
val failureLatch = CountDownLatch(1)
|
|
||||||
var failure: Throwable? = null
|
|
||||||
val client = clientWithResponse(
|
|
||||||
code = 500,
|
|
||||||
body = "server error"
|
|
||||||
)
|
|
||||||
|
|
||||||
client.connect(
|
|
||||||
token = "test-token",
|
|
||||||
roomId = 10L,
|
|
||||||
listener = object : TestListener() {
|
|
||||||
override fun onFailure(throwable: Throwable) {
|
|
||||||
failure = throwable
|
|
||||||
failureLatch.countDown()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
failureLatch.await(2, TimeUnit.SECONDS)
|
|
||||||
assertNotNull(failure)
|
|
||||||
assertEquals("Unexpected code 500", failure?.message)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `trailing blank line 없이 종료된 마지막 frame도 message callback으로 전달된다`() {
|
|
||||||
val messageLatch = CountDownLatch(1)
|
|
||||||
var receivedMessage: DmChatMessageResponse? = null
|
|
||||||
val client = clientWithResponse(
|
|
||||||
code = 200,
|
|
||||||
body = "event: message\ndata: ${messageJson()}"
|
|
||||||
)
|
|
||||||
|
|
||||||
client.connect(
|
|
||||||
token = "test-token",
|
|
||||||
roomId = 10L,
|
|
||||||
listener = object : TestListener() {
|
|
||||||
override fun onMessage(message: DmChatMessageResponse) {
|
|
||||||
receivedMessage = message
|
|
||||||
messageLatch.countDown()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
messageLatch.await(2, TimeUnit.SECONDS)
|
|
||||||
assertEquals(10L, receivedMessage?.messageId)
|
|
||||||
assertEquals("안녕하세요", receivedMessage?.textMessage)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `취소되지 않은 SSE stream이 EOF로 종료되면 failure callback으로 전달된다`() {
|
|
||||||
val failureLatch = CountDownLatch(1)
|
|
||||||
var failure: Throwable? = null
|
|
||||||
val client = clientWithResponse(
|
|
||||||
code = 200,
|
|
||||||
body = "event: connected\n\n"
|
|
||||||
)
|
|
||||||
|
|
||||||
client.connect(
|
|
||||||
token = "test-token",
|
|
||||||
roomId = 10L,
|
|
||||||
listener = object : TestListener() {
|
|
||||||
override fun onFailure(throwable: Throwable) {
|
|
||||||
failure = throwable
|
|
||||||
failureLatch.countDown()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
failureLatch.await(2, TimeUnit.SECONDS)
|
|
||||||
assertNotNull(failure)
|
|
||||||
assertEquals("SSE stream closed", failure?.message)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `SSE 전용 client는 공유 client의 read timeout을 제거한다`() {
|
|
||||||
val requestLatch = CountDownLatch(1)
|
|
||||||
var requestReadTimeoutMillis: Int? = null
|
|
||||||
val baseClient = OkHttpClient.Builder()
|
|
||||||
.readTimeout(60, TimeUnit.SECONDS)
|
|
||||||
.addInterceptor { chain ->
|
|
||||||
requestReadTimeoutMillis = chain.readTimeoutMillis()
|
|
||||||
requestLatch.countDown()
|
|
||||||
Response.Builder()
|
|
||||||
.request(chain.request())
|
|
||||||
.protocol(Protocol.HTTP_1_1)
|
|
||||||
.code(200)
|
|
||||||
.message("test")
|
|
||||||
.body("event: connected\n\n".toResponseBody("text/event-stream".toMediaType()))
|
|
||||||
.build()
|
|
||||||
}
|
|
||||||
.build()
|
|
||||||
|
|
||||||
val client = DmChatEventClient(
|
|
||||||
okHttpClient = baseClient,
|
|
||||||
gson = Gson(),
|
|
||||||
baseUrl = "https://example.com"
|
|
||||||
)
|
|
||||||
|
|
||||||
client.connect(
|
|
||||||
token = "test-token",
|
|
||||||
roomId = 10L,
|
|
||||||
listener = TestListener()
|
|
||||||
)
|
|
||||||
|
|
||||||
assertTrue(requestLatch.await(2, TimeUnit.SECONDS))
|
|
||||||
|
|
||||||
assertEquals(60_000, baseClient.readTimeoutMillis)
|
|
||||||
assertEquals(0, requestReadTimeoutMillis)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun clientWithResponse(
|
|
||||||
code: Int,
|
|
||||||
body: String
|
|
||||||
): DmChatEventClient {
|
|
||||||
val okHttpClient = OkHttpClient.Builder()
|
|
||||||
.addInterceptor { chain ->
|
|
||||||
Response.Builder()
|
|
||||||
.request(chain.request())
|
|
||||||
.protocol(Protocol.HTTP_1_1)
|
|
||||||
.code(code)
|
|
||||||
.message("test")
|
|
||||||
.body(body.toResponseBody("text/event-stream".toMediaType()))
|
|
||||||
.build()
|
|
||||||
}
|
|
||||||
.build()
|
|
||||||
return DmChatEventClient(
|
|
||||||
okHttpClient = okHttpClient,
|
|
||||||
gson = Gson(),
|
|
||||||
baseUrl = "https://example.com"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private open class TestListener : DmChatEventClient.Listener {
|
|
||||||
override fun onConnected() = Unit
|
|
||||||
override fun onMessage(message: DmChatMessageResponse) = Unit
|
|
||||||
override fun onFailure(throwable: Throwable) = Unit
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun messageJson(): String =
|
|
||||||
"""
|
|
||||||
{
|
|
||||||
"messageId": 10,
|
|
||||||
"messageType": "TEXT",
|
|
||||||
"mine": false,
|
|
||||||
"createdAt": 1000,
|
|
||||||
"textMessage": "안녕하세요",
|
|
||||||
"voiceMessageUrl": null,
|
|
||||||
"senderId": 20,
|
|
||||||
"senderNickname": "크리에이터",
|
|
||||||
"senderProfileImageUrl": "https://example.com/profile.png"
|
|
||||||
}
|
|
||||||
""".trimIndent().replace("\n", "")
|
|
||||||
}
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
package kr.co.vividnext.sodalive.v2.main.chat.dm
|
|
||||||
|
|
||||||
import com.google.gson.Gson
|
|
||||||
import kr.co.vividnext.sodalive.v2.main.chat.dm.data.DmChatEventParser
|
|
||||||
import kr.co.vividnext.sodalive.v2.main.chat.dm.data.DmChatMessageResponse
|
|
||||||
import org.junit.Assert.assertEquals
|
|
||||||
import org.junit.Assert.assertNull
|
|
||||||
import org.junit.Test
|
|
||||||
|
|
||||||
class DmChatEventParserTest {
|
|
||||||
|
|
||||||
private val parser = DmChatEventParser(Gson())
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `connected 이벤트는 연결 이벤트로 파싱된다`() {
|
|
||||||
val event = parser.parse("event: connected\ndata: {}\n\n")
|
|
||||||
|
|
||||||
assertEquals(DmChatEventParser.Event.Connected, event)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `message 이벤트는 DM 메시지로 파싱된다`() {
|
|
||||||
val event = parser.parse("event: message\ndata: ${messageJson()}\n\n")
|
|
||||||
|
|
||||||
val message = requireMessage(event)
|
|
||||||
assertEquals(10L, message.messageId)
|
|
||||||
assertEquals("안녕하세요", message.textMessage)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `여러 줄 data는 하나의 message payload로 합쳐 파싱된다`() {
|
|
||||||
val json = messageJson(textMessage = "첫줄\\n둘째줄")
|
|
||||||
val splitIndex = json.indexOf("\\\\n") + 2
|
|
||||||
val event = parser.parse(
|
|
||||||
"event: message\n" +
|
|
||||||
"data: ${json.substring(0, splitIndex)}\n" +
|
|
||||||
"data: ${json.substring(splitIndex)}\n\n"
|
|
||||||
)
|
|
||||||
|
|
||||||
val message = requireMessage(event)
|
|
||||||
assertEquals("첫줄\n둘째줄", message.textMessage)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `data field는 콜론 뒤 공백 하나만 제거한다`() {
|
|
||||||
val json = messageJson(textMessage = " 앞 공백 유지")
|
|
||||||
val event = parser.parse("event: message\ndata: $json\n\n")
|
|
||||||
|
|
||||||
val message = requireMessage(event)
|
|
||||||
assertEquals(" 앞 공백 유지", message.textMessage)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `잘못된 JSON message 이벤트는 null로 무시된다`() {
|
|
||||||
val event = parser.parse("event: message\ndata: {not-json}\n\n")
|
|
||||||
|
|
||||||
assertNull(event)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun requireMessage(event: DmChatEventParser.Event?): DmChatMessageResponse {
|
|
||||||
val messageEvent = event as? DmChatEventParser.Event.Message
|
|
||||||
requireNotNull(messageEvent)
|
|
||||||
return messageEvent.message
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun messageJson(
|
|
||||||
messageId: Long = 10L,
|
|
||||||
textMessage: String = "안녕하세요"
|
|
||||||
): String =
|
|
||||||
"""
|
|
||||||
{
|
|
||||||
"messageId": $messageId,
|
|
||||||
"messageType": "TEXT",
|
|
||||||
"mine": false,
|
|
||||||
"createdAt": 1000,
|
|
||||||
"textMessage": "$textMessage",
|
|
||||||
"voiceMessageUrl": null,
|
|
||||||
"senderId": 20,
|
|
||||||
"senderNickname": "크리에이터",
|
|
||||||
"senderProfileImageUrl": "https://example.com/profile.png"
|
|
||||||
}
|
|
||||||
""".trimIndent().replace("\n", "")
|
|
||||||
}
|
|
||||||
@@ -14,7 +14,8 @@ class DmChatRemovedEndpointSourceTest {
|
|||||||
"app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomViewModel.kt",
|
"app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomViewModel.kt",
|
||||||
"app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/data/DmChatApi.kt",
|
"app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/data/DmChatApi.kt",
|
||||||
"app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/data/DmChatRepository.kt",
|
"app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/data/DmChatRepository.kt",
|
||||||
"app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/data/DmChatSocketClient.kt"
|
"app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/data/DmChatSocketClient.kt",
|
||||||
|
"app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/data/DmChatSocketModels.kt"
|
||||||
)
|
)
|
||||||
|
|
||||||
activeSources.forEach { path ->
|
activeSources.forEach { path ->
|
||||||
@@ -24,6 +25,9 @@ class DmChatRemovedEndpointSourceTest {
|
|||||||
assertFalse("$path must not use removed text REST endpoint", source.contains("messages/text"))
|
assertFalse("$path must not use removed text REST endpoint", source.contains("messages/text"))
|
||||||
assertFalse("$path must not use SSE accept header", source.contains("text/event-stream"))
|
assertFalse("$path must not use SSE accept header", source.contains("text/event-stream"))
|
||||||
assertFalse("$path must not use EventSource", source.contains("EventSource"))
|
assertFalse("$path must not use EventSource", source.contains("EventSource"))
|
||||||
|
assertFalse("$path must not use STOMP CONNECT frame", source.contains("CONNECT\n"))
|
||||||
|
assertFalse("$path must not use STOMP SUBSCRIBE frame", source.contains("SUBSCRIBE"))
|
||||||
|
assertFalse("$path must not use SockJS", source.contains("SockJS"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,7 +41,7 @@ class DmChatRemovedEndpointSourceTest {
|
|||||||
).readText()
|
).readText()
|
||||||
|
|
||||||
assertTrue(viewModel.contains("repository.sendSocketText("))
|
assertTrue(viewModel.contains("repository.sendSocketText("))
|
||||||
assertTrue(viewModel.contains("repository.sendLeaveRoom(roomId)"))
|
assertTrue(viewModel.contains("repository.sendLeaveRoom(roomId = roomId"))
|
||||||
assertTrue(viewModel.contains("repository.closeSocket()"))
|
assertTrue(viewModel.contains("repository.closeSocket()"))
|
||||||
assertTrue(repository.contains("fun sendSocketText"))
|
assertTrue(repository.contains("fun sendSocketText"))
|
||||||
assertTrue(repository.contains("fun sendLeaveRoom"))
|
assertTrue(repository.contains("fun sendLeaveRoom"))
|
||||||
@@ -45,7 +49,7 @@ class DmChatRemovedEndpointSourceTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `음성 메시지는 전송 API 추가 없이 DTO 필드 보존 범위로 유지한다`() {
|
fun `음성 메시지는 multipart API 계약만 보존하고 UI에는 연결하지 않는다`() {
|
||||||
val api = projectFile(
|
val api = projectFile(
|
||||||
"app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/data/DmChatApi.kt"
|
"app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/data/DmChatApi.kt"
|
||||||
).readText()
|
).readText()
|
||||||
@@ -57,9 +61,26 @@ class DmChatRemovedEndpointSourceTest {
|
|||||||
).readText()
|
).readText()
|
||||||
|
|
||||||
assertTrue(models.contains("@SerializedName(\"voiceMessageUrl\") val voiceMessageUrl: String?"))
|
assertTrue(models.contains("@SerializedName(\"voiceMessageUrl\") val voiceMessageUrl: String?"))
|
||||||
assertFalse(api.contains("messages/voice"))
|
assertTrue(api.contains("messages/voice"))
|
||||||
assertFalse(api.contains("Multipart"))
|
assertTrue(api.contains("Multipart"))
|
||||||
assertFalse(repository.contains("sendDmVoiceMessage"))
|
assertTrue(api.contains("voiceMessageFile"))
|
||||||
|
assertTrue(repository.contains("fun sendVoiceMessage"))
|
||||||
|
val activity = projectFile(
|
||||||
|
"app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomActivity.kt"
|
||||||
|
).readText()
|
||||||
|
assertFalse(activity.contains("sendVoiceMessage"))
|
||||||
|
assertFalse(activity.contains("voiceMessageFile"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `legacy realtime DmChatEventClient production 파일은 제거되어 있다`() {
|
||||||
|
assertFalse(
|
||||||
|
projectFileExists("app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/data/DmChatEventClient.kt")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun projectFileExists(relativePath: String): Boolean {
|
||||||
|
return listOf(File(relativePath), File("../$relativePath")).any { it.exists() }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun projectFile(relativePath: String): File {
|
private fun projectFile(relativePath: String): File {
|
||||||
|
|||||||
@@ -9,8 +9,13 @@ import kr.co.vividnext.sodalive.v2.main.chat.dm.data.DmChatMessagesPageResponse
|
|||||||
import kr.co.vividnext.sodalive.v2.main.chat.dm.data.DmChatRepository
|
import kr.co.vividnext.sodalive.v2.main.chat.dm.data.DmChatRepository
|
||||||
import kr.co.vividnext.sodalive.v2.main.chat.dm.data.DmChatRoomOpenResponse
|
import kr.co.vividnext.sodalive.v2.main.chat.dm.data.DmChatRoomOpenResponse
|
||||||
import kr.co.vividnext.sodalive.v2.main.chat.dm.data.DmChatSocketClient
|
import kr.co.vividnext.sodalive.v2.main.chat.dm.data.DmChatSocketClient
|
||||||
|
import kr.co.vividnext.sodalive.v2.main.chat.dm.data.SendDmVoiceMessageResponse
|
||||||
import kr.co.vividnext.sodalive.v2.main.chat.dm.data.DmChatSocketEvent
|
import kr.co.vividnext.sodalive.v2.main.chat.dm.data.DmChatSocketEvent
|
||||||
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
|
import okhttp3.MultipartBody
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.RequestBody
|
||||||
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import okhttp3.WebSocket
|
import okhttp3.WebSocket
|
||||||
import okhttp3.WebSocketListener
|
import okhttp3.WebSocketListener
|
||||||
@@ -71,14 +76,30 @@ class DmChatRepositoryTest {
|
|||||||
fun `socket send 메서드는 socket client envelope 전송에 위임한다`() {
|
fun `socket send 메서드는 socket client envelope 전송에 위임한다`() {
|
||||||
repository.connectSocket(token = "test-token", listener = TestListener())
|
repository.connectSocket(token = "test-token", listener = TestListener())
|
||||||
|
|
||||||
assertTrue(repository.sendJoinRoom(roomId = 12L))
|
assertTrue(repository.sendJoinRoom(roomId = 12L, requestId = "join-request"))
|
||||||
assertTrue(repository.sendLeaveRoom(roomId = 12L))
|
assertTrue(repository.sendLeaveRoom(roomId = 12L, requestId = "leave-request"))
|
||||||
assertTrue(repository.sendSocketText(roomId = 12L, requestId = "request-1", textMessage = "안녕"))
|
assertTrue(repository.sendSocketText(roomId = 12L, requestId = "request-1", textMessage = "안녕"))
|
||||||
assertTrue(repository.sendPing())
|
assertTrue(repository.sendPing(roomId = 12L, requestId = "ping-request"))
|
||||||
|
|
||||||
assertEquals(listOf("JOIN_ROOM", "LEAVE_ROOM", "SEND_TEXT", "PING"), socketFactory.webSocket.sentTypes())
|
assertEquals(listOf("JOIN_ROOM", "LEAVE_ROOM", "SEND_TEXT", "PING"), socketFactory.webSocket.sentTypes())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `sendVoiceMessage는 voice multipart 계약을 API에 위임한다`() {
|
||||||
|
val voicePart = MultipartBody.Part.createFormData(
|
||||||
|
"voiceMessageFile",
|
||||||
|
"voice.m4a",
|
||||||
|
"voice".toRequestBody("audio/mp4".toMediaType())
|
||||||
|
)
|
||||||
|
|
||||||
|
repository.sendVoiceMessage(token = "test-token", roomId = 12L, voiceMessageFile = voicePart).blockingGet()
|
||||||
|
|
||||||
|
assertEquals("Bearer test-token", api.lastAuthHeader)
|
||||||
|
assertEquals(12L, api.lastRoomId)
|
||||||
|
assertEquals(voicePart, api.lastVoiceMessageFile)
|
||||||
|
assertEquals("{\"recipientId\":null}", api.lastVoiceRequestBody?.bodyString())
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `closeSocket은 socket close에 위임하고 미연결 전송은 false를 반환한다`() {
|
fun `closeSocket은 socket close에 위임하고 미연결 전송은 false를 반환한다`() {
|
||||||
repository.connectSocket(token = "test-token", listener = TestListener())
|
repository.connectSocket(token = "test-token", listener = TestListener())
|
||||||
@@ -86,7 +107,7 @@ class DmChatRepositoryTest {
|
|||||||
repository.closeSocket()
|
repository.closeSocket()
|
||||||
|
|
||||||
assertEquals(1, socketFactory.webSocket.closeCount)
|
assertEquals(1, socketFactory.webSocket.closeCount)
|
||||||
assertFalse(repository.sendPing())
|
assertFalse(repository.sendPing(roomId = 12L, requestId = "ping-request"))
|
||||||
}
|
}
|
||||||
|
|
||||||
private class FakeDmChatApi : DmChatApi {
|
private class FakeDmChatApi : DmChatApi {
|
||||||
@@ -95,6 +116,8 @@ class DmChatRepositoryTest {
|
|||||||
var lastRoomId: Long? = null
|
var lastRoomId: Long? = null
|
||||||
var lastCursor: Long? = null
|
var lastCursor: Long? = null
|
||||||
var lastLimit: Int? = null
|
var lastLimit: Int? = null
|
||||||
|
var lastVoiceMessageFile: MultipartBody.Part? = null
|
||||||
|
var lastVoiceRequestBody: RequestBody? = null
|
||||||
|
|
||||||
override fun createDmChatRoom(
|
override fun createDmChatRoom(
|
||||||
authHeader: String,
|
authHeader: String,
|
||||||
@@ -149,6 +172,44 @@ class DmChatRepositoryTest {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun sendDmVoiceMessage(
|
||||||
|
authHeader: String,
|
||||||
|
roomId: Long,
|
||||||
|
voiceMessageFile: MultipartBody.Part,
|
||||||
|
request: RequestBody
|
||||||
|
): Single<ApiResponse<SendDmVoiceMessageResponse>> {
|
||||||
|
lastAuthHeader = authHeader
|
||||||
|
lastRoomId = roomId
|
||||||
|
lastVoiceMessageFile = voiceMessageFile
|
||||||
|
lastVoiceRequestBody = request
|
||||||
|
return Single.just(
|
||||||
|
ApiResponse(
|
||||||
|
success = true,
|
||||||
|
data = SendDmVoiceMessageResponse(
|
||||||
|
message = kr.co.vividnext.sodalive.v2.main.chat.dm.data.DmChatMessageResponse(
|
||||||
|
messageId = 10L,
|
||||||
|
messageType = "VOICE",
|
||||||
|
mine = true,
|
||||||
|
createdAt = 1000L,
|
||||||
|
textMessage = null,
|
||||||
|
voiceMessageUrl = "https://example.com/voice.m4a",
|
||||||
|
senderId = 1L,
|
||||||
|
senderNickname = "나",
|
||||||
|
senderProfileImageUrl = "https://example.com/me.png"
|
||||||
|
),
|
||||||
|
deliveredRealtime = true,
|
||||||
|
pushSent = false
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun RequestBody.bodyString(): String {
|
||||||
|
val buffer = okio.Buffer()
|
||||||
|
writeTo(buffer)
|
||||||
|
return buffer.readUtf8()
|
||||||
}
|
}
|
||||||
|
|
||||||
private open class TestListener : DmChatSocketClient.Listener {
|
private open class TestListener : DmChatSocketClient.Listener {
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ class DmChatRoomActivitySourceTest {
|
|||||||
).readText()
|
).readText()
|
||||||
|
|
||||||
assertTrue(source.contains("BaseActivity<ActivityDmChatRoomBinding>"))
|
assertTrue(source.contains("BaseActivity<ActivityDmChatRoomBinding>"))
|
||||||
|
assertTrue(source.contains("import kr.co.vividnext.sodalive.common.LoadingDialog"))
|
||||||
|
assertTrue(source.contains("private val loadingDialog: LoadingDialog by lazy"))
|
||||||
assertTrue(source.contains("const val EXTRA_ROOM_ID"))
|
assertTrue(source.contains("const val EXTRA_ROOM_ID"))
|
||||||
assertTrue(source.contains("const val EXTRA_CREATOR_ID"))
|
assertTrue(source.contains("const val EXTRA_CREATOR_ID"))
|
||||||
assertTrue(source.contains("fun newIntentByRoomId"))
|
assertTrue(source.contains("fun newIntentByRoomId"))
|
||||||
@@ -26,6 +28,11 @@ class DmChatRoomActivitySourceTest {
|
|||||||
assertTrue(source.contains("EditorInfo.IME_ACTION_SEND"))
|
assertTrue(source.contains("EditorInfo.IME_ACTION_SEND"))
|
||||||
assertTrue(source.contains("binding.ivSend.setOnClickListener"))
|
assertTrue(source.contains("binding.ivSend.setOnClickListener"))
|
||||||
assertTrue(source.contains("viewModel.sendText"))
|
assertTrue(source.contains("viewModel.sendText"))
|
||||||
|
assertTrue(source.contains("if (viewModel.sendText(text))"))
|
||||||
|
assertFalse(source.contains("viewModel.sendText(text)\n binding.etMessage.setText(\"\")"))
|
||||||
|
assertFalse(source.contains("hideKeyboard"))
|
||||||
|
assertFalse(source.contains("hideSoftInputFromWindow"))
|
||||||
|
assertFalse(source.contains("view.clearFocus()"))
|
||||||
assertTrue(source.contains("findFirstVisibleItemPosition()"))
|
assertTrue(source.contains("findFirstVisibleItemPosition()"))
|
||||||
assertTrue(source.contains("viewModel.loadOlderMessages()"))
|
assertTrue(source.contains("viewModel.loadOlderMessages()"))
|
||||||
assertTrue(source.contains("prependedMessageCountLiveData.observe(this)"))
|
assertTrue(source.contains("prependedMessageCountLiveData.observe(this)"))
|
||||||
@@ -35,6 +42,10 @@ class DmChatRoomActivitySourceTest {
|
|||||||
assertTrue(source.contains("viewModel.connectRealtime()"))
|
assertTrue(source.contains("viewModel.connectRealtime()"))
|
||||||
assertTrue(source.contains("roomOpenedEventLiveData.observe(this)"))
|
assertTrue(source.contains("roomOpenedEventLiveData.observe(this)"))
|
||||||
assertTrue(source.contains("if (it.consume() == true) connectRealtimeIfStarted()"))
|
assertTrue(source.contains("if (it.consume() == true) connectRealtimeIfStarted()"))
|
||||||
|
assertTrue(source.contains("isRealtimeConnectingLiveData.observe(this)"))
|
||||||
|
assertTrue(source.contains("updateRealtimeLoadingDialog(isConnecting)"))
|
||||||
|
assertTrue(source.contains("loadingDialog.show(screenWidth)"))
|
||||||
|
assertTrue(source.contains("loadingDialog.dismiss()"))
|
||||||
assertTrue(source.contains("connectRealtimeIfStarted()"))
|
assertTrue(source.contains("connectRealtimeIfStarted()"))
|
||||||
assertTrue(source.contains("viewModel.leaveRealtime()"))
|
assertTrue(source.contains("viewModel.leaveRealtime()"))
|
||||||
assertFalse(source.contains("if (isStarted) viewModel.connectRealtime()"))
|
assertFalse(source.contains("if (isStarted) viewModel.connectRealtime()"))
|
||||||
@@ -53,6 +64,80 @@ class DmChatRoomActivitySourceTest {
|
|||||||
assertTrue(source.contains("putExtra(EXTRA_CREATOR_ID, creatorId)"))
|
assertTrue(source.contains("putExtra(EXTRA_CREATOR_ID, creatorId)"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `DM 메시지 UI는 좌우 정렬 기존 버블 텍스트 전송만 유지한다`() {
|
||||||
|
val myMessage = projectFile(
|
||||||
|
"app/src/main/res/layout/item_dm_chat_my_message.xml"
|
||||||
|
).readText()
|
||||||
|
val opponentMessage = projectFile(
|
||||||
|
"app/src/main/res/layout/item_dm_chat_opponent_message.xml"
|
||||||
|
).readText()
|
||||||
|
val roomLayout = projectFile(
|
||||||
|
"app/src/main/res/layout/activity_dm_chat_room.xml"
|
||||||
|
).readText()
|
||||||
|
val activity = projectFile(
|
||||||
|
"app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomActivity.kt"
|
||||||
|
).readText()
|
||||||
|
val adapter = projectFile(
|
||||||
|
"app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/ui/DmChatMessageAdapter.kt"
|
||||||
|
).readText()
|
||||||
|
|
||||||
|
assertTrue(myMessage.contains("android:background=\"@drawable/bg_chat_user_message\""))
|
||||||
|
assertTrue(myMessage.contains("android:id=\"@+id/tv_time\""))
|
||||||
|
assertTrue(myMessage.contains("app:layout_constraintEnd_toStartOf=\"@id/message_container\""))
|
||||||
|
assertTrue(myMessage.contains("app:layout_constraintEnd_toEndOf=\"parent\""))
|
||||||
|
assertTrue(myMessage.contains("app:layout_constraintStart_toStartOf=\"parent\""))
|
||||||
|
assertTrue(myMessage.contains("app:layout_constraintStart_toEndOf=\"@id/tv_time\""))
|
||||||
|
assertTrue(myMessage.contains("app:layout_constraintHorizontal_bias=\"1\""))
|
||||||
|
assertTrue(myMessage.contains("app:layout_constraintHorizontal_chainStyle=\"packed\""))
|
||||||
|
assertTrue(myMessage.contains("app:layout_constrainedWidth=\"true\""))
|
||||||
|
assertTrue(opponentMessage.contains("android:background=\"@drawable/bg_chat_ai_message\""))
|
||||||
|
assertTrue(opponentMessage.contains("android:id=\"@+id/tv_time\""))
|
||||||
|
assertTrue(opponentMessage.contains("app:layout_constraintStart_toStartOf=\"parent\""))
|
||||||
|
assertTrue(opponentMessage.contains("app:layout_constraintStart_toEndOf=\"@id/iv_profile\""))
|
||||||
|
assertTrue(opponentMessage.contains("app:layout_constraintStart_toEndOf=\"@id/message_group\""))
|
||||||
|
assertTrue(adapter.contains("import kr.co.vividnext.sodalive.chat.talk.room.formatMessageTime"))
|
||||||
|
assertTrue(adapter.contains("tvTime.text = formatMessageTime(item.createdAt)"))
|
||||||
|
assertTrue(adapter.contains("MESSAGE_MAX_WIDTH_RATIO = 0.68f"))
|
||||||
|
|
||||||
|
assertTrue(roomLayout.contains("@+id/et_message"))
|
||||||
|
assertTrue(roomLayout.contains("@+id/iv_send"))
|
||||||
|
assertFalse(roomLayout.contains("voice"))
|
||||||
|
assertFalse(activity.contains("sendVoiceMessage"))
|
||||||
|
assertFalse(activity.contains("record"))
|
||||||
|
assertFalse(activity.contains("playVoice"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `AppDI는 DM WebSocket raw JSON logger를 production 생성 경로에 주입한다`() {
|
||||||
|
val appDi = projectFile(
|
||||||
|
"app/src/main/java/kr/co/vividnext/sodalive/di/AppDI.kt"
|
||||||
|
).readText()
|
||||||
|
|
||||||
|
assertTrue(appDi.contains("import com.orhanobut.logger.Logger"))
|
||||||
|
assertTrue(appDi.contains("rawJsonLogger = { rawJson ->"))
|
||||||
|
assertTrue(appDi.contains("Logger.d(\"DM WebSocket raw JSON: ${'$'}rawJson\")"))
|
||||||
|
assertFalse(appDi.contains("DmChatSocketClient(okHttpClient = get(), gson = get(), baseUrl = baseUrl)"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `DM 채팅방 Activity 프로필 배경은 내 프로필 이미지를 사용하고 헤더는 상대 프로필을 유지한다`() {
|
||||||
|
val source = projectFile(
|
||||||
|
"app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomActivity.kt"
|
||||||
|
).readText()
|
||||||
|
|
||||||
|
val foregroundProfileBinding = source.substringAfter("binding.ivProfile.loadUrl(")
|
||||||
|
.substringBefore("binding.ivBackgroundProfile.loadUrl(")
|
||||||
|
val backgroundProfileBinding = source.substringAfter("binding.ivBackgroundProfile.loadUrl(")
|
||||||
|
.substringBefore("messageAdapter.submitItems")
|
||||||
|
|
||||||
|
assertTrue(source.contains("import kr.co.vividnext.sodalive.common.SharedPreferenceManager"))
|
||||||
|
assertTrue(foregroundProfileBinding.contains("state.opponentProfileImageUrl"))
|
||||||
|
assertTrue(foregroundProfileBinding.contains("transformations(CircleCropTransformation())"))
|
||||||
|
assertTrue(backgroundProfileBinding.contains("SharedPreferenceManager.profileImage"))
|
||||||
|
assertFalse(backgroundProfileBinding.contains("state.opponentProfileImageUrl"))
|
||||||
|
}
|
||||||
|
|
||||||
private fun projectFile(relativePath: String): File {
|
private fun projectFile(relativePath: String): File {
|
||||||
val candidates = listOf(File(relativePath), File("../$relativePath"))
|
val candidates = listOf(File(relativePath), File("../$relativePath"))
|
||||||
return candidates.firstOrNull { it.exists() }
|
return candidates.firstOrNull { it.exists() }
|
||||||
|
|||||||
@@ -14,8 +14,10 @@ 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 io.reactivex.rxjava3.schedulers.TestScheduler
|
import io.reactivex.rxjava3.schedulers.TestScheduler
|
||||||
|
import kr.co.vividnext.sodalive.R
|
||||||
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.v2.main.chat.dm.data.CreateDmChatRoomRequest
|
import kr.co.vividnext.sodalive.v2.main.chat.dm.data.CreateDmChatRoomRequest
|
||||||
import kr.co.vividnext.sodalive.v2.main.chat.dm.data.CreateDmChatRoomResponse
|
import kr.co.vividnext.sodalive.v2.main.chat.dm.data.CreateDmChatRoomResponse
|
||||||
import kr.co.vividnext.sodalive.v2.main.chat.dm.data.DmChatApi
|
import kr.co.vividnext.sodalive.v2.main.chat.dm.data.DmChatApi
|
||||||
@@ -24,9 +26,12 @@ import kr.co.vividnext.sodalive.v2.main.chat.dm.data.DmChatMessagesPageResponse
|
|||||||
import kr.co.vividnext.sodalive.v2.main.chat.dm.data.DmChatRepository
|
import kr.co.vividnext.sodalive.v2.main.chat.dm.data.DmChatRepository
|
||||||
import kr.co.vividnext.sodalive.v2.main.chat.dm.data.DmChatRoomOpenResponse
|
import kr.co.vividnext.sodalive.v2.main.chat.dm.data.DmChatRoomOpenResponse
|
||||||
import kr.co.vividnext.sodalive.v2.main.chat.dm.data.DmChatSocketClient
|
import kr.co.vividnext.sodalive.v2.main.chat.dm.data.DmChatSocketClient
|
||||||
|
import kr.co.vividnext.sodalive.v2.main.chat.dm.data.SendDmVoiceMessageResponse
|
||||||
import kr.co.vividnext.sodalive.v2.main.chat.dm.model.DmChatMessageStatus
|
import kr.co.vividnext.sodalive.v2.main.chat.dm.model.DmChatMessageStatus
|
||||||
import kr.co.vividnext.sodalive.v2.main.chat.dm.model.DmChatRoomUiState
|
import kr.co.vividnext.sodalive.v2.main.chat.dm.model.DmChatRoomUiState
|
||||||
|
import okhttp3.MultipartBody
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.RequestBody
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import okhttp3.WebSocket
|
import okhttp3.WebSocket
|
||||||
import okhttp3.WebSocketListener
|
import okhttp3.WebSocketListener
|
||||||
@@ -62,6 +67,7 @@ class DmChatRoomViewModelTest {
|
|||||||
setImmediateRxSchedulers()
|
setImmediateRxSchedulers()
|
||||||
SharedPreferenceManager.resetForTest()
|
SharedPreferenceManager.resetForTest()
|
||||||
SharedPreferenceManager.init(context)
|
SharedPreferenceManager.init(context)
|
||||||
|
SodaLiveApplicationHolder.init(context as Application)
|
||||||
SharedPreferenceManager.token = "test-token"
|
SharedPreferenceManager.token = "test-token"
|
||||||
token = "test-token"
|
token = "test-token"
|
||||||
api = FakeDmChatApi()
|
api = FakeDmChatApi()
|
||||||
@@ -159,25 +165,147 @@ class DmChatRoomViewModelTest {
|
|||||||
api.enqueueOpenSuccess(openResponse(roomId = 10L))
|
api.enqueueOpenSuccess(openResponse(roomId = 10L))
|
||||||
viewModel.enter(roomId = 10L, creatorId = 0L)
|
viewModel.enter(roomId = 10L, creatorId = 0L)
|
||||||
|
|
||||||
viewModel.sendText(" ")
|
val accepted = viewModel.sendText(" ")
|
||||||
|
|
||||||
|
assertEquals(false, accepted)
|
||||||
assertTrue(socketFactory.webSocket.sentTexts.isEmpty())
|
assertTrue(socketFactory.webSocket.sentTexts.isEmpty())
|
||||||
val state = viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content
|
val state = viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content
|
||||||
assertTrue(state.messages.isEmpty())
|
assertTrue(state.messages.isEmpty())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `JOINED 전에는 텍스트를 전송하지 않는다`() {
|
||||||
|
api.enqueueOpenSuccess(openResponse(roomId = 10L))
|
||||||
|
viewModel.enter(roomId = 10L, creatorId = 0L)
|
||||||
|
viewModel.connectRealtime()
|
||||||
|
|
||||||
|
val accepted = viewModel.sendText("안녕")
|
||||||
|
|
||||||
|
assertEquals(false, accepted)
|
||||||
|
assertEquals(listOf("JOIN_ROOM"), socketFactory.webSocket.sentTexts.map { it.type() })
|
||||||
|
val state = viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content
|
||||||
|
assertTrue(state.messages.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `텍스트 전송 requestId는 UUID 형식이다`() {
|
||||||
|
api.enqueueOpenSuccess(openResponse(roomId = 10L))
|
||||||
|
viewModel.enter(roomId = 10L, creatorId = 0L)
|
||||||
|
viewModel.connectRealtime()
|
||||||
|
socketFactory.emitJoined()
|
||||||
|
|
||||||
|
val accepted = viewModel.sendText("안녕")
|
||||||
|
|
||||||
|
assertEquals(true, accepted)
|
||||||
|
val requestId = (viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content)
|
||||||
|
.messages.single().requestId!!
|
||||||
|
assertTrue(requestId.matches(Regex("[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `pending timeout은 15초 기준으로 실패 처리한다`() {
|
||||||
|
api.enqueueOpenSuccess(openResponse(roomId = 10L))
|
||||||
|
viewModel.enter(roomId = 10L, creatorId = 0L)
|
||||||
|
viewModel.connectRealtime()
|
||||||
|
socketFactory.emitJoined()
|
||||||
|
viewModel.sendText("timeout")
|
||||||
|
|
||||||
|
reconnectScheduler.advanceTimeBy(10L, TimeUnit.SECONDS)
|
||||||
|
shadowOf(Looper.getMainLooper()).idle()
|
||||||
|
var state = viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content
|
||||||
|
assertEquals(DmChatMessageStatus.SENDING, state.messages.single().status)
|
||||||
|
|
||||||
|
reconnectScheduler.advanceTimeBy(5L, TimeUnit.SECONDS)
|
||||||
|
shadowOf(Looper.getMainLooper()).idle()
|
||||||
|
state = viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content
|
||||||
|
assertEquals(DmChatMessageStatus.FAILED, state.messages.single().status)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `현재 roomId와 다른 socket MESSAGE와 SEND_ACK는 무시한다`() {
|
||||||
|
api.enqueueOpenSuccess(openResponse(roomId = 10L))
|
||||||
|
viewModel.enter(roomId = 10L, creatorId = 0L)
|
||||||
|
viewModel.connectRealtime()
|
||||||
|
socketFactory.emitJoined()
|
||||||
|
viewModel.sendText("안녕")
|
||||||
|
val requestId = (viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content)
|
||||||
|
.messages.single().requestId!!
|
||||||
|
|
||||||
|
socketFactory.emitMessage(roomId = 11L, message = message(messageId = 80L, textMessage = "다른 방"))
|
||||||
|
socketFactory.emitAck(
|
||||||
|
roomId = 11L,
|
||||||
|
requestId = requestId,
|
||||||
|
message = message(messageId = 81L, mine = true, textMessage = "다른 ACK")
|
||||||
|
)
|
||||||
|
|
||||||
|
val state = viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content
|
||||||
|
assertEquals(listOf(null), state.messages.map { it.messageId })
|
||||||
|
assertEquals(listOf("안녕"), state.messages.map { it.textMessage })
|
||||||
|
assertEquals(listOf(DmChatMessageStatus.SENDING), state.messages.map { it.status })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `roomId가 null인 socket MESSAGE와 SEND_ACK는 무시한다`() {
|
||||||
|
api.enqueueOpenSuccess(openResponse(roomId = 10L))
|
||||||
|
viewModel.enter(roomId = 10L, creatorId = 0L)
|
||||||
|
viewModel.connectRealtime()
|
||||||
|
socketFactory.emitJoined()
|
||||||
|
viewModel.sendText("안녕")
|
||||||
|
val requestId = (viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content)
|
||||||
|
.messages.single().requestId!!
|
||||||
|
|
||||||
|
socketFactory.emitMessage(roomId = null, message = message(messageId = 80L, textMessage = "malformed"))
|
||||||
|
socketFactory.emitAck(
|
||||||
|
roomId = null,
|
||||||
|
requestId = requestId,
|
||||||
|
message = message(messageId = 81L, mine = true, textMessage = "malformed ACK")
|
||||||
|
)
|
||||||
|
|
||||||
|
val state = viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content
|
||||||
|
assertEquals(listOf(null), state.messages.map { it.messageId })
|
||||||
|
assertEquals(listOf(DmChatMessageStatus.SENDING), state.messages.map { it.status })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `JOINED 전 ERROR는 세 번 재시도 후 로딩 해제 오류 표시 종료를 수행한다`() {
|
||||||
|
api.enqueueOpenSuccess(openResponse(roomId = 10L))
|
||||||
|
viewModel.enter(roomId = 10L, creatorId = 0L)
|
||||||
|
viewModel.connectRealtime()
|
||||||
|
|
||||||
|
repeat(3) { retryIndex ->
|
||||||
|
socketFactory.emitJoinError(roomId = 0L)
|
||||||
|
assertEquals(true, viewModel.isRealtimeConnectingLiveData.requireValue())
|
||||||
|
reconnectScheduler.advanceTimeBy(3L, TimeUnit.SECONDS)
|
||||||
|
shadowOf(Looper.getMainLooper()).idle()
|
||||||
|
assertEquals(retryIndex + 2, socketFactory.connectCalls.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
socketFactory.emitJoinError(roomId = 0L)
|
||||||
|
|
||||||
|
assertEquals(false, viewModel.isRealtimeConnectingLiveData.requireValue())
|
||||||
|
assertEquals(true, viewModel.finishEventLiveData.requireValue())
|
||||||
|
val state = viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Error
|
||||||
|
assertEquals(context.getString(R.string.screen_dm_chat_room_enter_failed), state.message)
|
||||||
|
val connectCountAfterFailure = socketFactory.connectCalls.size
|
||||||
|
reconnectScheduler.advanceTimeBy(3L, TimeUnit.SECONDS)
|
||||||
|
shadowOf(Looper.getMainLooper()).idle()
|
||||||
|
assertEquals(connectCountAfterFailure, socketFactory.connectCalls.size)
|
||||||
|
assertTrue(socketFactory.closeCount >= 4)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `전송 직후 pending을 추가하고 성공 시 서버 메시지로 교체한다`() {
|
fun `전송 직후 pending을 추가하고 성공 시 서버 메시지로 교체한다`() {
|
||||||
api.enqueueOpenSuccess(openResponse(roomId = 10L))
|
api.enqueueOpenSuccess(openResponse(roomId = 10L))
|
||||||
viewModel.enter(roomId = 10L, creatorId = 0L)
|
viewModel.enter(roomId = 10L, creatorId = 0L)
|
||||||
viewModel.connectRealtime()
|
viewModel.connectRealtime()
|
||||||
|
socketFactory.emitJoined()
|
||||||
|
|
||||||
viewModel.sendText(" 안녕 ")
|
viewModel.sendText(" 안녕 ")
|
||||||
val sendingState = viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content
|
val sendingState = viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content
|
||||||
val requestId = sendingState.messages.single().requestId!!
|
val requestId = sendingState.messages.single().requestId!!
|
||||||
|
|
||||||
assertEquals("SEND_TEXT", socketFactory.webSocket.sentJsonAt(1).get("type").asString)
|
assertEquals("SEND_TEXT", socketFactory.webSocket.sentJsonAt(1).get("type").asString)
|
||||||
assertEquals(requestId, socketFactory.webSocket.sentJsonAt(1).getAsJsonObject("payload").get("requestId").asString)
|
assertEquals(requestId, socketFactory.webSocket.sentJsonAt(1).get("requestId").asString)
|
||||||
assertEquals(DmChatMessageStatus.SENDING, sendingState.messages.single().status)
|
assertEquals(DmChatMessageStatus.SENDING, sendingState.messages.single().status)
|
||||||
assertEquals("안녕", sendingState.messages.single().textMessage)
|
assertEquals("안녕", sendingState.messages.single().textMessage)
|
||||||
|
|
||||||
@@ -192,6 +320,7 @@ class DmChatRoomViewModelTest {
|
|||||||
api.enqueueOpenSuccess(openResponse(roomId = 10L))
|
api.enqueueOpenSuccess(openResponse(roomId = 10L))
|
||||||
viewModel.enter(roomId = 10L, creatorId = 0L)
|
viewModel.enter(roomId = 10L, creatorId = 0L)
|
||||||
viewModel.connectRealtime()
|
viewModel.connectRealtime()
|
||||||
|
socketFactory.emitJoined()
|
||||||
|
|
||||||
viewModel.sendText("안녕")
|
viewModel.sendText("안녕")
|
||||||
viewModel.sendText("안녕")
|
viewModel.sendText("안녕")
|
||||||
@@ -206,6 +335,7 @@ class DmChatRoomViewModelTest {
|
|||||||
api.enqueueOpenSuccess(openResponse(roomId = 10L))
|
api.enqueueOpenSuccess(openResponse(roomId = 10L))
|
||||||
viewModel.enter(roomId = 10L, creatorId = 0L)
|
viewModel.enter(roomId = 10L, creatorId = 0L)
|
||||||
viewModel.connectRealtime()
|
viewModel.connectRealtime()
|
||||||
|
socketFactory.emitJoined()
|
||||||
|
|
||||||
viewModel.sendText("첫번째")
|
viewModel.sendText("첫번째")
|
||||||
viewModel.sendText("두번째")
|
viewModel.sendText("두번째")
|
||||||
@@ -217,8 +347,8 @@ class DmChatRoomViewModelTest {
|
|||||||
assertNotEquals(requestIds[0], requestIds[1])
|
assertNotEquals(requestIds[0], requestIds[1])
|
||||||
assertEquals("SEND_TEXT", socketFactory.webSocket.sentJsonAt(1).get("type").asString)
|
assertEquals("SEND_TEXT", socketFactory.webSocket.sentJsonAt(1).get("type").asString)
|
||||||
assertEquals("SEND_TEXT", socketFactory.webSocket.sentJsonAt(2).get("type").asString)
|
assertEquals("SEND_TEXT", socketFactory.webSocket.sentJsonAt(2).get("type").asString)
|
||||||
assertEquals(requestIds[0], socketFactory.webSocket.sentJsonAt(1).getAsJsonObject("payload").get("requestId").asString)
|
assertEquals(requestIds[0], socketFactory.webSocket.sentJsonAt(1).get("requestId").asString)
|
||||||
assertEquals(requestIds[1], socketFactory.webSocket.sentJsonAt(2).getAsJsonObject("payload").get("requestId").asString)
|
assertEquals(requestIds[1], socketFactory.webSocket.sentJsonAt(2).get("requestId").asString)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -226,6 +356,7 @@ class DmChatRoomViewModelTest {
|
|||||||
api.enqueueOpenSuccess(openResponse(roomId = 10L))
|
api.enqueueOpenSuccess(openResponse(roomId = 10L))
|
||||||
viewModel.enter(roomId = 10L, creatorId = 0L)
|
viewModel.enter(roomId = 10L, creatorId = 0L)
|
||||||
viewModel.connectRealtime()
|
viewModel.connectRealtime()
|
||||||
|
socketFactory.emitJoined()
|
||||||
viewModel.sendText("첫번째")
|
viewModel.sendText("첫번째")
|
||||||
viewModel.sendText("두번째")
|
viewModel.sendText("두번째")
|
||||||
val pendingState = viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content
|
val pendingState = viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content
|
||||||
@@ -244,6 +375,7 @@ class DmChatRoomViewModelTest {
|
|||||||
api.enqueueOpenSuccess(openResponse(roomId = 10L))
|
api.enqueueOpenSuccess(openResponse(roomId = 10L))
|
||||||
viewModel.enter(roomId = 10L, creatorId = 0L)
|
viewModel.enter(roomId = 10L, creatorId = 0L)
|
||||||
viewModel.connectRealtime()
|
viewModel.connectRealtime()
|
||||||
|
socketFactory.emitJoined()
|
||||||
viewModel.sendText("첫번째")
|
viewModel.sendText("첫번째")
|
||||||
viewModel.sendText("두번째")
|
viewModel.sendText("두번째")
|
||||||
val pendingState = viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content
|
val pendingState = viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content
|
||||||
@@ -260,13 +392,14 @@ class DmChatRoomViewModelTest {
|
|||||||
api.enqueueOpenSuccess(openResponse(roomId = 10L))
|
api.enqueueOpenSuccess(openResponse(roomId = 10L))
|
||||||
viewModel.enter(roomId = 10L, creatorId = 0L)
|
viewModel.enter(roomId = 10L, creatorId = 0L)
|
||||||
viewModel.connectRealtime()
|
viewModel.connectRealtime()
|
||||||
|
socketFactory.emitJoined()
|
||||||
viewModel.sendText("첫번째")
|
viewModel.sendText("첫번째")
|
||||||
viewModel.sendText("두번째")
|
viewModel.sendText("두번째")
|
||||||
val pendingState = viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content
|
val pendingState = viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content
|
||||||
val secondRequestId = pendingState.messages[1].requestId!!
|
val secondRequestId = pendingState.messages[1].requestId!!
|
||||||
|
|
||||||
socketFactory.emitAck(secondRequestId, message(messageId = 31L, mine = true, textMessage = "두번째"))
|
socketFactory.emitAck(secondRequestId, message(messageId = 31L, mine = true, textMessage = "두번째"))
|
||||||
reconnectScheduler.advanceTimeBy(10L, TimeUnit.SECONDS)
|
reconnectScheduler.advanceTimeBy(15L, TimeUnit.SECONDS)
|
||||||
shadowOf(Looper.getMainLooper()).idle()
|
shadowOf(Looper.getMainLooper()).idle()
|
||||||
|
|
||||||
val state = viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content
|
val state = viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content
|
||||||
@@ -279,6 +412,7 @@ class DmChatRoomViewModelTest {
|
|||||||
api.enqueueOpenSuccess(openResponse(roomId = 10L))
|
api.enqueueOpenSuccess(openResponse(roomId = 10L))
|
||||||
viewModel.enter(roomId = 10L, creatorId = 0L)
|
viewModel.enter(roomId = 10L, creatorId = 0L)
|
||||||
viewModel.connectRealtime()
|
viewModel.connectRealtime()
|
||||||
|
socketFactory.emitJoined()
|
||||||
viewModel.sendText("안녕")
|
viewModel.sendText("안녕")
|
||||||
val requestId = (viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content)
|
val requestId = (viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content)
|
||||||
.messages.single().requestId!!
|
.messages.single().requestId!!
|
||||||
@@ -296,6 +430,7 @@ class DmChatRoomViewModelTest {
|
|||||||
api.enqueueOpenSuccess(openResponse(roomId = 10L))
|
api.enqueueOpenSuccess(openResponse(roomId = 10L))
|
||||||
viewModel.enter(roomId = 10L, creatorId = 0L)
|
viewModel.enter(roomId = 10L, creatorId = 0L)
|
||||||
viewModel.connectRealtime()
|
viewModel.connectRealtime()
|
||||||
|
socketFactory.emitJoined()
|
||||||
viewModel.sendText("재시도")
|
viewModel.sendText("재시도")
|
||||||
val failedRequestId = (viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content)
|
val failedRequestId = (viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content)
|
||||||
.messages.single().requestId!!
|
.messages.single().requestId!!
|
||||||
@@ -311,7 +446,7 @@ class DmChatRoomViewModelTest {
|
|||||||
assertEquals(DmChatMessageStatus.SENDING, retryItem.status)
|
assertEquals(DmChatMessageStatus.SENDING, retryItem.status)
|
||||||
assertEquals(
|
assertEquals(
|
||||||
retryItem.requestId,
|
retryItem.requestId,
|
||||||
socketFactory.webSocket.sentJsonAt(2).getAsJsonObject("payload").get("requestId").asString
|
socketFactory.webSocket.sentJsonAt(2).get("requestId").asString
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -320,6 +455,7 @@ class DmChatRoomViewModelTest {
|
|||||||
api.enqueueOpenSuccess(openResponse(roomId = 10L))
|
api.enqueueOpenSuccess(openResponse(roomId = 10L))
|
||||||
viewModel.enter(roomId = 10L, creatorId = 0L)
|
viewModel.enter(roomId = 10L, creatorId = 0L)
|
||||||
viewModel.connectRealtime()
|
viewModel.connectRealtime()
|
||||||
|
socketFactory.emitJoined()
|
||||||
socketFactory.webSocket.sendResult = false
|
socketFactory.webSocket.sendResult = false
|
||||||
|
|
||||||
viewModel.sendText("안녕")
|
viewModel.sendText("안녕")
|
||||||
@@ -343,6 +479,7 @@ class DmChatRoomViewModelTest {
|
|||||||
api.enqueueOpenSuccess(openResponse(roomId = 10L))
|
api.enqueueOpenSuccess(openResponse(roomId = 10L))
|
||||||
viewModel.enter(roomId = 10L, creatorId = 0L)
|
viewModel.enter(roomId = 10L, creatorId = 0L)
|
||||||
viewModel.connectRealtime()
|
viewModel.connectRealtime()
|
||||||
|
socketFactory.emitJoined()
|
||||||
socketFactory.webSocket.sendResult = false
|
socketFactory.webSocket.sendResult = false
|
||||||
viewModel.sendText("안녕")
|
viewModel.sendText("안녕")
|
||||||
val failedState = viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content
|
val failedState = viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content
|
||||||
@@ -378,6 +515,7 @@ class DmChatRoomViewModelTest {
|
|||||||
api.enqueueOpenSuccess(openResponse(roomId = 10L))
|
api.enqueueOpenSuccess(openResponse(roomId = 10L))
|
||||||
viewModel.enter(roomId = 10L, creatorId = 0L)
|
viewModel.enter(roomId = 10L, creatorId = 0L)
|
||||||
viewModel.connectRealtime()
|
viewModel.connectRealtime()
|
||||||
|
socketFactory.emitJoined()
|
||||||
|
|
||||||
viewModel.sendText("안녕")
|
viewModel.sendText("안녕")
|
||||||
val requestId = (viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content)
|
val requestId = (viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content)
|
||||||
@@ -395,6 +533,7 @@ class DmChatRoomViewModelTest {
|
|||||||
api.enqueueOpenSuccess(openResponse(roomId = 10L))
|
api.enqueueOpenSuccess(openResponse(roomId = 10L))
|
||||||
viewModel.enter(roomId = 10L, creatorId = 0L)
|
viewModel.enter(roomId = 10L, creatorId = 0L)
|
||||||
viewModel.connectRealtime()
|
viewModel.connectRealtime()
|
||||||
|
socketFactory.emitJoined()
|
||||||
viewModel.sendText("선도착")
|
viewModel.sendText("선도착")
|
||||||
val pendingItem = (viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content)
|
val pendingItem = (viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content)
|
||||||
.messages.single()
|
.messages.single()
|
||||||
@@ -414,6 +553,7 @@ class DmChatRoomViewModelTest {
|
|||||||
api.enqueueOpenSuccess(openResponse(roomId = 10L))
|
api.enqueueOpenSuccess(openResponse(roomId = 10L))
|
||||||
viewModel.enter(roomId = 10L, creatorId = 0L)
|
viewModel.enter(roomId = 10L, creatorId = 0L)
|
||||||
viewModel.connectRealtime()
|
viewModel.connectRealtime()
|
||||||
|
socketFactory.emitJoined()
|
||||||
viewModel.sendText("중복 방지")
|
viewModel.sendText("중복 방지")
|
||||||
val requestId = (viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content)
|
val requestId = (viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content)
|
||||||
.messages.single().requestId!!
|
.messages.single().requestId!!
|
||||||
@@ -431,12 +571,13 @@ class DmChatRoomViewModelTest {
|
|||||||
api.enqueueOpenSuccess(openResponse(roomId = 10L))
|
api.enqueueOpenSuccess(openResponse(roomId = 10L))
|
||||||
viewModel.enter(roomId = 10L, creatorId = 0L)
|
viewModel.enter(roomId = 10L, creatorId = 0L)
|
||||||
viewModel.connectRealtime()
|
viewModel.connectRealtime()
|
||||||
|
socketFactory.emitJoined()
|
||||||
viewModel.sendText("늦은 ACK")
|
viewModel.sendText("늦은 ACK")
|
||||||
val pendingItem = (viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content)
|
val pendingItem = (viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content)
|
||||||
.messages.single()
|
.messages.single()
|
||||||
val requestId = pendingItem.requestId!!
|
val requestId = pendingItem.requestId!!
|
||||||
|
|
||||||
reconnectScheduler.advanceTimeBy(10L, TimeUnit.SECONDS)
|
reconnectScheduler.advanceTimeBy(15L, TimeUnit.SECONDS)
|
||||||
shadowOf(Looper.getMainLooper()).idle()
|
shadowOf(Looper.getMainLooper()).idle()
|
||||||
socketFactory.emitAck(requestId, message(messageId = 62L, mine = true, textMessage = "늦은 ACK"))
|
socketFactory.emitAck(requestId, message(messageId = 62L, mine = true, textMessage = "늦은 ACK"))
|
||||||
|
|
||||||
@@ -451,12 +592,13 @@ class DmChatRoomViewModelTest {
|
|||||||
api.enqueueOpenSuccess(openResponse(roomId = 10L))
|
api.enqueueOpenSuccess(openResponse(roomId = 10L))
|
||||||
viewModel.enter(roomId = 10L, creatorId = 0L)
|
viewModel.enter(roomId = 10L, creatorId = 0L)
|
||||||
viewModel.connectRealtime()
|
viewModel.connectRealtime()
|
||||||
|
socketFactory.emitJoined()
|
||||||
viewModel.sendText("재시도 ACK")
|
viewModel.sendText("재시도 ACK")
|
||||||
val firstState = viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content
|
val firstState = viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content
|
||||||
val failedItem = firstState.messages.single()
|
val failedItem = firstState.messages.single()
|
||||||
val firstRequestId = failedItem.requestId!!
|
val firstRequestId = failedItem.requestId!!
|
||||||
|
|
||||||
reconnectScheduler.advanceTimeBy(10L, TimeUnit.SECONDS)
|
reconnectScheduler.advanceTimeBy(15L, TimeUnit.SECONDS)
|
||||||
shadowOf(Looper.getMainLooper()).idle()
|
shadowOf(Looper.getMainLooper()).idle()
|
||||||
viewModel.retry(failedItem.localId!!)
|
viewModel.retry(failedItem.localId!!)
|
||||||
val retryRequestId = (viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content)
|
val retryRequestId = (viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content)
|
||||||
@@ -535,6 +677,36 @@ class DmChatRoomViewModelTest {
|
|||||||
assertEquals(listOf(1L, 2L), state.messages.map { it.messageId })
|
assertEquals(listOf(1L, 2L), state.messages.map { it.messageId })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `realtime 연결 중 상태는 JOINED 수신 전까지만 true이다`() {
|
||||||
|
api.enqueueOpenSuccess(openResponse(roomId = 10L))
|
||||||
|
viewModel.enter(roomId = 10L, creatorId = 0L)
|
||||||
|
|
||||||
|
viewModel.connectRealtime()
|
||||||
|
|
||||||
|
assertEquals(true, viewModel.isRealtimeConnectingLiveData.requireValue())
|
||||||
|
|
||||||
|
socketFactory.emitJoined()
|
||||||
|
|
||||||
|
assertEquals(false, viewModel.isRealtimeConnectingLiveData.requireValue())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `realtime 연결 실패와 leave는 연결 중 상태를 해제한다`() {
|
||||||
|
api.enqueueOpenSuccess(openResponse(roomId = 10L))
|
||||||
|
viewModel.enter(roomId = 10L, creatorId = 0L)
|
||||||
|
|
||||||
|
viewModel.connectRealtime()
|
||||||
|
socketFactory.emitFailure(IllegalStateException("network"))
|
||||||
|
|
||||||
|
assertEquals(false, viewModel.isRealtimeConnectingLiveData.requireValue())
|
||||||
|
|
||||||
|
viewModel.connectRealtime()
|
||||||
|
viewModel.leaveRealtime()
|
||||||
|
|
||||||
|
assertEquals(false, viewModel.isRealtimeConnectingLiveData.requireValue())
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `realtime 연결 중 중복 connect 요청은 무시한다`() {
|
fun `realtime 연결 중 중복 connect 요청은 무시한다`() {
|
||||||
api.enqueueOpenSuccess(openResponse(roomId = 10L))
|
api.enqueueOpenSuccess(openResponse(roomId = 10L))
|
||||||
@@ -587,6 +759,7 @@ class DmChatRoomViewModelTest {
|
|||||||
viewModel.enter(roomId = 10L, creatorId = 0L)
|
viewModel.enter(roomId = 10L, creatorId = 0L)
|
||||||
|
|
||||||
viewModel.connectRealtime()
|
viewModel.connectRealtime()
|
||||||
|
socketFactory.emitJoined()
|
||||||
socketFactory.emitMessage(message(messageId = 3L, textMessage = "실시간"))
|
socketFactory.emitMessage(message(messageId = 3L, textMessage = "실시간"))
|
||||||
|
|
||||||
val state = viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content
|
val state = viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content
|
||||||
@@ -599,6 +772,7 @@ class DmChatRoomViewModelTest {
|
|||||||
api.enqueueOpenSuccess(openResponse(roomId = 10L))
|
api.enqueueOpenSuccess(openResponse(roomId = 10L))
|
||||||
viewModel.enter(roomId = 10L, creatorId = 0L)
|
viewModel.enter(roomId = 10L, creatorId = 0L)
|
||||||
viewModel.connectRealtime()
|
viewModel.connectRealtime()
|
||||||
|
socketFactory.emitJoined()
|
||||||
val beforeCallbackSize = viewModel.compositeDisposable.size()
|
val beforeCallbackSize = viewModel.compositeDisposable.size()
|
||||||
|
|
||||||
socketFactory.emitMessage(message(messageId = 3L, textMessage = "실시간1"))
|
socketFactory.emitMessage(message(messageId = 3L, textMessage = "실시간1"))
|
||||||
@@ -725,6 +899,7 @@ class DmChatRoomViewModelTest {
|
|||||||
api.enqueueOpenSuccess(openResponse(roomId = 10L))
|
api.enqueueOpenSuccess(openResponse(roomId = 10L))
|
||||||
viewModel.enter(roomId = 10L, creatorId = 0L)
|
viewModel.enter(roomId = 10L, creatorId = 0L)
|
||||||
viewModel.connectRealtime()
|
viewModel.connectRealtime()
|
||||||
|
socketFactory.emitJoined()
|
||||||
|
|
||||||
viewModel.leaveRealtime()
|
viewModel.leaveRealtime()
|
||||||
viewModel.leaveRealtime()
|
viewModel.leaveRealtime()
|
||||||
@@ -773,6 +948,48 @@ class DmChatRoomViewModelTest {
|
|||||||
assertEquals("JOIN_ROOM", socketFactory.webSocket.sentTexts.lastJson().get("type").asString)
|
assertEquals("JOIN_ROOM", socketFactory.webSocket.sentTexts.lastJson().get("type").asString)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `socket close callback은 foreground 상태에서 reconnect를 예약한다`() {
|
||||||
|
api.enqueueOpenSuccess(openResponse(roomId = 10L))
|
||||||
|
api.enqueueMessagesSuccess(messagesPage(messages = emptyList()))
|
||||||
|
viewModel.enter(roomId = 10L, creatorId = 0L)
|
||||||
|
viewModel.connectRealtime()
|
||||||
|
socketFactory.emitJoined()
|
||||||
|
|
||||||
|
socketFactory.emitClosed()
|
||||||
|
reconnectScheduler.advanceTimeBy(2999L, TimeUnit.MILLISECONDS)
|
||||||
|
|
||||||
|
assertEquals(false, viewModel.isRealtimeConnectedForTest())
|
||||||
|
assertEquals(1, socketFactory.closeCount)
|
||||||
|
assertEquals(1, socketFactory.connectCalls.size)
|
||||||
|
|
||||||
|
reconnectScheduler.advanceTimeBy(1L, TimeUnit.MILLISECONDS)
|
||||||
|
|
||||||
|
assertEquals(2, socketFactory.connectCalls.size)
|
||||||
|
assertEquals("JOIN_ROOM", socketFactory.webSocket.sentTexts.lastJson().get("type").asString)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `heartbeat PING 전송 실패는 timeout 대기 없이 socket close 후 reconnect를 예약한다`() {
|
||||||
|
api.enqueueOpenSuccess(openResponse(roomId = 10L))
|
||||||
|
api.enqueueMessagesSuccess(messagesPage(messages = emptyList()))
|
||||||
|
viewModel.enter(roomId = 10L, creatorId = 0L)
|
||||||
|
viewModel.connectRealtime()
|
||||||
|
socketFactory.emitJoined()
|
||||||
|
socketFactory.webSocket.sendResult = false
|
||||||
|
|
||||||
|
reconnectScheduler.advanceTimeBy(30L, TimeUnit.SECONDS)
|
||||||
|
reconnectScheduler.advanceTimeBy(2999L, TimeUnit.MILLISECONDS)
|
||||||
|
|
||||||
|
assertEquals(false, viewModel.isRealtimeConnectedForTest())
|
||||||
|
assertEquals(1, socketFactory.closeCount)
|
||||||
|
assertEquals(1, socketFactory.connectCalls.size)
|
||||||
|
|
||||||
|
reconnectScheduler.advanceTimeBy(1L, TimeUnit.MILLISECONDS)
|
||||||
|
|
||||||
|
assertEquals(2, socketFactory.connectCalls.size)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `leave는 heartbeat timeout과 reconnect 예약을 취소한다`() {
|
fun `leave는 heartbeat timeout과 reconnect 예약을 취소한다`() {
|
||||||
api.enqueueOpenSuccess(openResponse(roomId = 10L))
|
api.enqueueOpenSuccess(openResponse(roomId = 10L))
|
||||||
@@ -1031,8 +1248,47 @@ class FakeDmChatApi : DmChatApi {
|
|||||||
limit: Int
|
limit: Int
|
||||||
): Single<ApiResponse<DmChatMessagesPageResponse>> {
|
): Single<ApiResponse<DmChatMessagesPageResponse>> {
|
||||||
messagesCalls.add(MessagesCall(authHeader, roomId, cursor, limit))
|
messagesCalls.add(MessagesCall(authHeader, roomId, cursor, limit))
|
||||||
return messagesResponses.removeFirst()
|
return if (messagesResponses.isEmpty()) {
|
||||||
|
Single.just(
|
||||||
|
ApiResponse(
|
||||||
|
success = true,
|
||||||
|
data = DmChatMessagesPageResponse(
|
||||||
|
messages = emptyList(),
|
||||||
|
hasMore = false,
|
||||||
|
nextCursor = null
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
messagesResponses.removeFirst()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun sendDmVoiceMessage(
|
||||||
|
authHeader: String,
|
||||||
|
roomId: Long,
|
||||||
|
voiceMessageFile: MultipartBody.Part,
|
||||||
|
request: RequestBody
|
||||||
|
): Single<ApiResponse<SendDmVoiceMessageResponse>> = Single.just(
|
||||||
|
ApiResponse(
|
||||||
|
success = true,
|
||||||
|
data = SendDmVoiceMessageResponse(
|
||||||
|
message = DmChatMessageResponse(
|
||||||
|
messageId = 1L,
|
||||||
|
messageType = "VOICE",
|
||||||
|
mine = true,
|
||||||
|
createdAt = 1000L,
|
||||||
|
textMessage = null,
|
||||||
|
voiceMessageUrl = "https://example.com/voice.m4a",
|
||||||
|
senderId = 1L,
|
||||||
|
senderNickname = "나",
|
||||||
|
senderProfileImageUrl = "https://example.com/me.png"
|
||||||
|
),
|
||||||
|
deliveredRealtime = true,
|
||||||
|
pushSent = false
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
class FakeWebSocketFactory {
|
class FakeWebSocketFactory {
|
||||||
@@ -1050,44 +1306,83 @@ class FakeWebSocketFactory {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun emitJoined() {
|
fun emitJoined() {
|
||||||
webSocketListener?.onMessage(webSocket, "{\"type\":\"JOINED\",\"payload\":{}}")
|
webSocketListener?.onMessage(
|
||||||
|
webSocket,
|
||||||
|
"{\"type\":\"JOINED\",\"requestId\":\"join-request\",\"roomId\":10,\"payload\":{}}"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun emitMessage(message: DmChatMessageResponse) {
|
fun emitMessage(message: DmChatMessageResponse) {
|
||||||
val json = Gson().toJson(message)
|
val json = Gson().toJson(message)
|
||||||
webSocketListener?.onMessage(webSocket, "{\"type\":\"MESSAGE\",\"payload\":{\"message\":$json}}")
|
webSocketListener?.onMessage(webSocket, "{\"type\":\"MESSAGE\",\"requestId\":null,\"roomId\":10,\"payload\":$json}")
|
||||||
}
|
}
|
||||||
|
|
||||||
fun emitMessage(requestId: String, message: DmChatMessageResponse) {
|
fun emitMessage(requestId: String, message: DmChatMessageResponse) {
|
||||||
|
emitMessage(roomId = 10L, requestId = requestId, message = message)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun emitMessage(roomId: Long?, message: DmChatMessageResponse) {
|
||||||
val json = Gson().toJson(message)
|
val json = Gson().toJson(message)
|
||||||
webSocketListener?.onMessage(
|
webSocketListener?.onMessage(
|
||||||
webSocket,
|
webSocket,
|
||||||
"{\"type\":\"MESSAGE\",\"payload\":{\"requestId\":\"$requestId\",\"message\":$json}}"
|
"{\"type\":\"MESSAGE\",\"requestId\":null,\"roomId\":$roomId,\"payload\":$json}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun emitMessage(roomId: Long, requestId: String, message: DmChatMessageResponse) {
|
||||||
|
val json = Gson().toJson(message)
|
||||||
|
webSocketListener?.onMessage(
|
||||||
|
webSocket,
|
||||||
|
"{\"type\":\"MESSAGE\",\"requestId\":\"$requestId\",\"roomId\":$roomId,\"payload\":$json}"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun emitAck(requestId: String, message: DmChatMessageResponse) {
|
fun emitAck(requestId: String, message: DmChatMessageResponse) {
|
||||||
|
emitAck(roomId = 10L, requestId = requestId, message = message)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun emitAck(roomId: Long?, requestId: String, message: DmChatMessageResponse) {
|
||||||
val json = Gson().toJson(message)
|
val json = Gson().toJson(message)
|
||||||
webSocketListener?.onMessage(
|
webSocketListener?.onMessage(
|
||||||
webSocket,
|
webSocket,
|
||||||
"{\"type\":\"SEND_ACK\",\"payload\":{\"requestId\":\"$requestId\",\"message\":$json}}"
|
"{\"type\":\"SEND_ACK\",\"requestId\":\"$requestId\",\"roomId\":$roomId,\"payload\":$json}"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun emitError(requestId: String) {
|
fun emitError(requestId: String) {
|
||||||
|
val json = """
|
||||||
|
{"type":"ERROR","requestId":"$requestId","roomId":10,"payload":{"messageKey":"chat.message.send_failed"}}
|
||||||
|
""".trimIndent()
|
||||||
webSocketListener?.onMessage(
|
webSocketListener?.onMessage(
|
||||||
webSocket,
|
webSocket,
|
||||||
"{\"type\":\"ERROR\",\"payload\":{\"requestId\":\"$requestId\",\"code\":\"SEND_FAILED\",\"message\":\"failed\"}}"
|
json
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun emitJoinError(roomId: Long?) {
|
||||||
|
val json = """
|
||||||
|
{"type":"ERROR","requestId":"join-request","roomId":$roomId,"payload":{"messageKey":"chat.room.invalid_access"}}
|
||||||
|
""".trimIndent()
|
||||||
|
webSocketListener?.onMessage(
|
||||||
|
webSocket,
|
||||||
|
json
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun emitPong() {
|
fun emitPong() {
|
||||||
webSocketListener?.onMessage(webSocket, "{\"type\":\"PONG\",\"payload\":{}}")
|
webSocketListener?.onMessage(
|
||||||
|
webSocket,
|
||||||
|
"{\"type\":\"PONG\",\"requestId\":\"ping-request\",\"roomId\":10,\"payload\":{}}"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun emitFailure(throwable: Throwable) {
|
fun emitFailure(throwable: Throwable) {
|
||||||
webSocketListener?.onFailure(webSocket, throwable, null)
|
webSocketListener?.onFailure(webSocket, throwable, null)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun emitClosed() {
|
||||||
|
webSocketListener?.onClosed(webSocket, 1001, "going away")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class FakeWebSocket : WebSocket {
|
class FakeWebSocket : WebSocket {
|
||||||
|
|||||||
@@ -44,31 +44,35 @@ class DmChatSocketClientTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `sendJoinRoom은 JOIN_ROOM envelope를 전송한다`() {
|
fun `sendJoinRoom은 root requestId와 roomId를 포함한 JOIN_ROOM envelope를 전송한다`() {
|
||||||
val factory = FakeWebSocketFactory()
|
val factory = FakeWebSocketFactory()
|
||||||
val client = connectedClient(factory)
|
val client = connectedClient(factory)
|
||||||
|
|
||||||
assertTrue(client.sendJoinRoom(roomId = 10L))
|
assertTrue(client.sendJoinRoom(roomId = 10L, requestId = "join-request"))
|
||||||
|
|
||||||
val json = factory.webSocket.singleSentJson()
|
val json = factory.webSocket.singleSentJson()
|
||||||
assertEquals("JOIN_ROOM", json.type())
|
assertEquals("JOIN_ROOM", json.type())
|
||||||
assertEquals(10L, json.payload().get("roomId").asLong)
|
assertEquals("join-request", json.requestId())
|
||||||
|
assertEquals(10L, json.roomId())
|
||||||
|
assertEquals(0, json.payload().size())
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `sendLeaveRoom은 LEAVE_ROOM envelope를 전송한다`() {
|
fun `sendLeaveRoom은 root requestId와 roomId를 포함한 LEAVE_ROOM envelope를 전송한다`() {
|
||||||
val factory = FakeWebSocketFactory()
|
val factory = FakeWebSocketFactory()
|
||||||
val client = connectedClient(factory)
|
val client = connectedClient(factory)
|
||||||
|
|
||||||
assertTrue(client.sendLeaveRoom(roomId = 10L))
|
assertTrue(client.sendLeaveRoom(roomId = 10L, requestId = "leave-request"))
|
||||||
|
|
||||||
val json = factory.webSocket.singleSentJson()
|
val json = factory.webSocket.singleSentJson()
|
||||||
assertEquals("LEAVE_ROOM", json.type())
|
assertEquals("LEAVE_ROOM", json.type())
|
||||||
assertEquals(10L, json.payload().get("roomId").asLong)
|
assertEquals("leave-request", json.requestId())
|
||||||
|
assertEquals(10L, json.roomId())
|
||||||
|
assertEquals(0, json.payload().size())
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `sendText는 SEND_TEXT envelope를 전송한다`() {
|
fun `sendText는 root requestId roomId와 textMessage payload만 전송한다`() {
|
||||||
val factory = FakeWebSocketFactory()
|
val factory = FakeWebSocketFactory()
|
||||||
val client = connectedClient(factory)
|
val client = connectedClient(factory)
|
||||||
|
|
||||||
@@ -76,25 +80,28 @@ class DmChatSocketClientTest {
|
|||||||
|
|
||||||
val json = factory.webSocket.singleSentJson()
|
val json = factory.webSocket.singleSentJson()
|
||||||
assertEquals("SEND_TEXT", json.type())
|
assertEquals("SEND_TEXT", json.type())
|
||||||
assertEquals(10L, json.payload().get("roomId").asLong)
|
assertEquals("request-1", json.requestId())
|
||||||
assertEquals("request-1", json.payload().get("requestId").asString)
|
assertEquals(10L, json.roomId())
|
||||||
|
assertEquals(setOf("textMessage"), json.payload().keySet())
|
||||||
assertEquals("안녕하세요", json.payload().get("textMessage").asString)
|
assertEquals("안녕하세요", json.payload().get("textMessage").asString)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `sendPing은 PING envelope를 전송한다`() {
|
fun `sendPing은 root requestId와 roomId를 포함한 PING envelope를 전송한다`() {
|
||||||
val factory = FakeWebSocketFactory()
|
val factory = FakeWebSocketFactory()
|
||||||
val client = connectedClient(factory)
|
val client = connectedClient(factory)
|
||||||
|
|
||||||
assertTrue(client.sendPing())
|
assertTrue(client.sendPing(roomId = 10L, requestId = "ping-request"))
|
||||||
|
|
||||||
val json = factory.webSocket.singleSentJson()
|
val json = factory.webSocket.singleSentJson()
|
||||||
assertEquals("PING", json.type())
|
assertEquals("PING", json.type())
|
||||||
|
assertEquals("ping-request", json.requestId())
|
||||||
|
assertEquals(10L, json.roomId())
|
||||||
assertEquals(0, json.payload().size())
|
assertEquals(0, json.payload().size())
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `onMessage는 parser event를 listener로 전달한다`() {
|
fun `onMessage는 direct payload parser event를 listener로 전달한다`() {
|
||||||
val factory = FakeWebSocketFactory()
|
val factory = FakeWebSocketFactory()
|
||||||
var receivedEvent: DmChatSocketEvent? = null
|
var receivedEvent: DmChatSocketEvent? = null
|
||||||
val client = client(factory = factory)
|
val client = client(factory = factory)
|
||||||
@@ -111,6 +118,7 @@ class DmChatSocketClientTest {
|
|||||||
|
|
||||||
val messageEvent = receivedEvent as? DmChatSocketEvent.Message
|
val messageEvent = receivedEvent as? DmChatSocketEvent.Message
|
||||||
requireNotNull(messageEvent)
|
requireNotNull(messageEvent)
|
||||||
|
assertEquals(10L, messageEvent.roomId)
|
||||||
assertEquals(10L, messageEvent.message.messageId)
|
assertEquals(10L, messageEvent.message.messageId)
|
||||||
assertEquals("안녕하세요", messageEvent.message.textMessage)
|
assertEquals("안녕하세요", messageEvent.message.textMessage)
|
||||||
}
|
}
|
||||||
@@ -134,6 +142,8 @@ class DmChatSocketClientTest {
|
|||||||
"""
|
"""
|
||||||
{
|
{
|
||||||
"type": "UNKNOWN",
|
"type": "UNKNOWN",
|
||||||
|
"requestId": "request-1",
|
||||||
|
"roomId": 10,
|
||||||
"payload": {}
|
"payload": {}
|
||||||
}
|
}
|
||||||
""".trimIndent()
|
""".trimIndent()
|
||||||
@@ -143,6 +153,23 @@ class DmChatSocketClientTest {
|
|||||||
assertEquals(0, eventCount)
|
assertEquals(0, eventCount)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `send와 receive raw JSON을 token 없이 로그로 남긴다`() {
|
||||||
|
val factory = FakeWebSocketFactory()
|
||||||
|
val logs = mutableListOf<String>()
|
||||||
|
val client = client(factory = factory, logger = logs::add)
|
||||||
|
client.connect(token = "secret-token", listener = TestListener())
|
||||||
|
|
||||||
|
client.sendText(roomId = 10L, requestId = "request-1", textMessage = "안녕하세요")
|
||||||
|
factory.listener?.onMessage(factory.webSocket, messageEnvelope())
|
||||||
|
|
||||||
|
assertTrue(logs.any { it.contains("SEND_TEXT") && it.contains("request-1") })
|
||||||
|
assertTrue(logs.any { it.contains("MESSAGE") && it.contains("안녕하세요") })
|
||||||
|
assertTrue(logs.none { it.contains("secret-token") })
|
||||||
|
assertTrue(logs.none { it.contains("Bearer") })
|
||||||
|
assertTrue(logs.none { it.contains("Authorization") })
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `onFailure는 현재 listener로 전달된다`() {
|
fun `onFailure는 현재 listener로 전달된다`() {
|
||||||
val factory = FakeWebSocketFactory()
|
val factory = FakeWebSocketFactory()
|
||||||
@@ -162,6 +189,27 @@ class DmChatSocketClientTest {
|
|||||||
assertEquals("socket failed", failure?.message)
|
assertEquals("socket failed", failure?.message)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `onClosing과 onClosed는 현재 listener로 close failure를 한 번만 전달한다`() {
|
||||||
|
val factory = FakeWebSocketFactory()
|
||||||
|
val failures = mutableListOf<Throwable>()
|
||||||
|
val client = client(factory = factory)
|
||||||
|
client.connect(
|
||||||
|
token = "test-token",
|
||||||
|
listener = object : TestListener() {
|
||||||
|
override fun onFailure(throwable: Throwable) {
|
||||||
|
failures += throwable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
factory.listener?.onClosing(factory.webSocket, 1001, "going away")
|
||||||
|
factory.listener?.onClosed(factory.webSocket, 1001, "going away")
|
||||||
|
|
||||||
|
assertEquals(1, failures.size)
|
||||||
|
assertEquals("WebSocket closed: code=1001 reason=going away", failures.single().message)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `close는 socket을 정상 종료하고 listener를 해제한다`() {
|
fun `close는 socket을 정상 종료하고 listener를 해제한다`() {
|
||||||
val factory = FakeWebSocketFactory()
|
val factory = FakeWebSocketFactory()
|
||||||
@@ -185,7 +233,28 @@ class DmChatSocketClientTest {
|
|||||||
assertEquals(1000, factory.webSocket.closeCode)
|
assertEquals(1000, factory.webSocket.closeCode)
|
||||||
assertNull(factory.webSocket.closeReason)
|
assertNull(factory.webSocket.closeReason)
|
||||||
assertEquals(0, eventCount)
|
assertEquals(0, eventCount)
|
||||||
assertFalse(client.sendPing())
|
assertFalse(client.sendPing(roomId = 10L, requestId = "ping-request"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `client close 이후 onClosed는 해제된 listener로 전달하지 않는다`() {
|
||||||
|
val factory = FakeWebSocketFactory()
|
||||||
|
var failureCount = 0
|
||||||
|
val client = client(factory = factory)
|
||||||
|
client.connect(
|
||||||
|
token = "test-token",
|
||||||
|
listener = object : TestListener() {
|
||||||
|
override fun onFailure(throwable: Throwable) {
|
||||||
|
failureCount += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
val oldListener = factory.listener
|
||||||
|
|
||||||
|
client.close()
|
||||||
|
oldListener?.onClosed(factory.webSocket, 1000, "normal")
|
||||||
|
|
||||||
|
assertEquals(0, failureCount)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun connectedClient(factory: FakeWebSocketFactory): DmChatSocketClient =
|
private fun connectedClient(factory: FakeWebSocketFactory): DmChatSocketClient =
|
||||||
@@ -193,12 +262,14 @@ class DmChatSocketClientTest {
|
|||||||
|
|
||||||
private fun client(
|
private fun client(
|
||||||
factory: FakeWebSocketFactory,
|
factory: FakeWebSocketFactory,
|
||||||
baseUrl: String = "https://api.example.com"
|
baseUrl: String = "https://api.example.com",
|
||||||
|
logger: (String) -> Unit = {}
|
||||||
): DmChatSocketClient = DmChatSocketClient(
|
): DmChatSocketClient = DmChatSocketClient(
|
||||||
okHttpClient = OkHttpClient(),
|
okHttpClient = OkHttpClient(),
|
||||||
gson = gson,
|
gson = gson,
|
||||||
baseUrl = baseUrl,
|
baseUrl = baseUrl,
|
||||||
webSocketFactory = factory::newWebSocket
|
webSocketFactory = factory::newWebSocket,
|
||||||
|
rawJsonLogger = logger
|
||||||
)
|
)
|
||||||
|
|
||||||
private open class TestListener : DmChatSocketClient.Listener {
|
private open class TestListener : DmChatSocketClient.Listener {
|
||||||
@@ -249,13 +320,19 @@ class DmChatSocketClientTest {
|
|||||||
|
|
||||||
private fun JsonObject.type(): String = get("type").asString
|
private fun JsonObject.type(): String = get("type").asString
|
||||||
|
|
||||||
|
private fun JsonObject.requestId(): String = get("requestId").asString
|
||||||
|
|
||||||
|
private fun JsonObject.roomId(): Long = get("roomId").asLong
|
||||||
|
|
||||||
private fun JsonObject.payload(): JsonObject = getAsJsonObject("payload")
|
private fun JsonObject.payload(): JsonObject = getAsJsonObject("payload")
|
||||||
|
|
||||||
private fun messageEnvelope(): String =
|
private fun messageEnvelope(): String =
|
||||||
"""
|
"""
|
||||||
{
|
{
|
||||||
"type": "MESSAGE",
|
"type": "MESSAGE",
|
||||||
"payload": { "message": ${messageJson()} }
|
"requestId": null,
|
||||||
|
"roomId": 10,
|
||||||
|
"payload": ${messageJson()}
|
||||||
}
|
}
|
||||||
""".trimIndent()
|
""".trimIndent()
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package kr.co.vividnext.sodalive.v2.main.chat.dm
|
package kr.co.vividnext.sodalive.v2.main.chat.dm
|
||||||
|
|
||||||
import com.google.gson.Gson
|
import com.google.gson.Gson
|
||||||
import kr.co.vividnext.sodalive.v2.main.chat.dm.data.DmChatMessageResponse
|
|
||||||
import kr.co.vividnext.sodalive.v2.main.chat.dm.data.DmChatSocketEvent
|
import kr.co.vividnext.sodalive.v2.main.chat.dm.data.DmChatSocketEvent
|
||||||
import kr.co.vividnext.sodalive.v2.main.chat.dm.data.DmChatSocketParser
|
import kr.co.vividnext.sodalive.v2.main.chat.dm.data.DmChatSocketParser
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
@@ -13,65 +12,54 @@ class DmChatSocketParserTest {
|
|||||||
private val parser = DmChatSocketParser(Gson())
|
private val parser = DmChatSocketParser(Gson())
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `JOINED type은 joined event로 파싱된다`() {
|
fun `JOINED type은 root requestId와 roomId를 보존한다`() {
|
||||||
val event = parser.parse(
|
val event = parser.parse(
|
||||||
"""
|
"""
|
||||||
{
|
{
|
||||||
"type": "JOINED",
|
"type": "JOINED",
|
||||||
"payload": { "roomId": 10 }
|
"requestId": "join-request",
|
||||||
|
"roomId": 10,
|
||||||
|
"payload": {}
|
||||||
}
|
}
|
||||||
""".trimIndent()
|
""".trimIndent()
|
||||||
)
|
)
|
||||||
|
|
||||||
assertEquals(DmChatSocketEvent.Joined, event)
|
val joined = event as? DmChatSocketEvent.Joined
|
||||||
|
requireNotNull(joined)
|
||||||
|
assertEquals("join-request", joined.requestId)
|
||||||
|
assertEquals(10L, joined.roomId)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `MESSAGE type은 DM 메시지 payload로 파싱된다`() {
|
fun `MESSAGE type은 direct MessageItem payload와 root roomId를 파싱한다`() {
|
||||||
val event = parser.parse(
|
val event = parser.parse(
|
||||||
"""
|
"""
|
||||||
{
|
{
|
||||||
"type": "MESSAGE",
|
"type": "MESSAGE",
|
||||||
"payload": { "message": ${messageJson()} }
|
"requestId": null,
|
||||||
}
|
"roomId": 10,
|
||||||
""".trimIndent()
|
"payload": ${messageJson()}
|
||||||
)
|
|
||||||
|
|
||||||
val message = requireMessage(event)
|
|
||||||
assertEquals(10L, message.messageId)
|
|
||||||
assertEquals("안녕하세요", message.textMessage)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `MESSAGE type은 nullable requestId를 보존한다`() {
|
|
||||||
val event = parser.parse(
|
|
||||||
"""
|
|
||||||
{
|
|
||||||
"type": "MESSAGE",
|
|
||||||
"payload": {
|
|
||||||
"requestId": "request-1",
|
|
||||||
"message": ${messageJson(messageId = 12L, textMessage = "선도착")}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
""".trimIndent()
|
""".trimIndent()
|
||||||
)
|
)
|
||||||
|
|
||||||
val messageEvent = event as? DmChatSocketEvent.Message
|
val messageEvent = event as? DmChatSocketEvent.Message
|
||||||
requireNotNull(messageEvent)
|
requireNotNull(messageEvent)
|
||||||
assertEquals("request-1", messageEvent.requestId)
|
assertNull(messageEvent.requestId)
|
||||||
assertEquals(12L, messageEvent.message.messageId)
|
assertEquals(10L, messageEvent.roomId)
|
||||||
|
assertEquals(10L, messageEvent.message.messageId)
|
||||||
|
assertEquals("안녕하세요", messageEvent.message.textMessage)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `SEND_ACK type은 requestId와 서버 확정 메시지로 파싱된다`() {
|
fun `SEND_ACK type은 root requestId와 direct MessageItem payload를 파싱한다`() {
|
||||||
val event = parser.parse(
|
val event = parser.parse(
|
||||||
"""
|
"""
|
||||||
{
|
{
|
||||||
"type": "SEND_ACK",
|
"type": "SEND_ACK",
|
||||||
"payload": {
|
"requestId": "request-1",
|
||||||
"requestId": "request-1",
|
"roomId": 10,
|
||||||
"message": ${messageJson(messageId = 11L, textMessage = "확정")}
|
"payload": ${messageJson(messageId = 11L, textMessage = "확정")}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
""".trimIndent()
|
""".trimIndent()
|
||||||
)
|
)
|
||||||
@@ -79,43 +67,20 @@ class DmChatSocketParserTest {
|
|||||||
val ack = event as? DmChatSocketEvent.SendAck
|
val ack = event as? DmChatSocketEvent.SendAck
|
||||||
requireNotNull(ack)
|
requireNotNull(ack)
|
||||||
assertEquals("request-1", ack.requestId)
|
assertEquals("request-1", ack.requestId)
|
||||||
|
assertEquals(10L, ack.roomId)
|
||||||
assertEquals(11L, ack.message.messageId)
|
assertEquals(11L, ack.message.messageId)
|
||||||
assertEquals("확정", ack.message.textMessage)
|
assertEquals("확정", ack.message.textMessage)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `ERROR type은 nullable requestId와 code message를 보존한다`() {
|
fun `ERROR type은 payload messageKey와 root requestId roomId를 보존한다`() {
|
||||||
val event = parser.parse(
|
val event = parser.parse(
|
||||||
"""
|
"""
|
||||||
{
|
{
|
||||||
"type": "ERROR",
|
"type": "ERROR",
|
||||||
"payload": {
|
"requestId": "request-1",
|
||||||
"requestId": null,
|
"roomId": 10,
|
||||||
"code": "INVALID_MESSAGE",
|
"payload": { "messageKey": "chat.message.send_failed" }
|
||||||
"message": "메시지를 전송할 수 없습니다"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
""".trimIndent()
|
|
||||||
)
|
|
||||||
|
|
||||||
val error = event as? DmChatSocketEvent.Error
|
|
||||||
requireNotNull(error)
|
|
||||||
assertNull(error.requestId)
|
|
||||||
assertEquals("INVALID_MESSAGE", error.code)
|
|
||||||
assertEquals("메시지를 전송할 수 없습니다", error.message)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `ERROR type은 nullable code와 message를 보존한다`() {
|
|
||||||
val event = parser.parse(
|
|
||||||
"""
|
|
||||||
{
|
|
||||||
"type": "ERROR",
|
|
||||||
"payload": {
|
|
||||||
"requestId": "request-1",
|
|
||||||
"code": null,
|
|
||||||
"message": null
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
""".trimIndent()
|
""".trimIndent()
|
||||||
)
|
)
|
||||||
@@ -123,22 +88,27 @@ class DmChatSocketParserTest {
|
|||||||
val error = event as? DmChatSocketEvent.Error
|
val error = event as? DmChatSocketEvent.Error
|
||||||
requireNotNull(error)
|
requireNotNull(error)
|
||||||
assertEquals("request-1", error.requestId)
|
assertEquals("request-1", error.requestId)
|
||||||
assertNull(error.code)
|
assertEquals(10L, error.roomId)
|
||||||
assertNull(error.message)
|
assertEquals("chat.message.send_failed", error.messageKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `PONG type은 pong event로 파싱된다`() {
|
fun `PONG type은 root requestId와 roomId를 보존한다`() {
|
||||||
val event = parser.parse(
|
val event = parser.parse(
|
||||||
"""
|
"""
|
||||||
{
|
{
|
||||||
"type": "PONG",
|
"type": "PONG",
|
||||||
|
"requestId": "ping-request",
|
||||||
|
"roomId": 10,
|
||||||
"payload": {}
|
"payload": {}
|
||||||
}
|
}
|
||||||
""".trimIndent()
|
""".trimIndent()
|
||||||
)
|
)
|
||||||
|
|
||||||
assertEquals(DmChatSocketEvent.Pong, event)
|
val pong = event as? DmChatSocketEvent.Pong
|
||||||
|
requireNotNull(pong)
|
||||||
|
assertEquals("ping-request", pong.requestId)
|
||||||
|
assertEquals(10L, pong.roomId)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -147,6 +117,8 @@ class DmChatSocketParserTest {
|
|||||||
"""
|
"""
|
||||||
{
|
{
|
||||||
"type": "UNKNOWN",
|
"type": "UNKNOWN",
|
||||||
|
"requestId": "request-1",
|
||||||
|
"roomId": 10,
|
||||||
"payload": {}
|
"payload": {}
|
||||||
}
|
}
|
||||||
""".trimIndent()
|
""".trimIndent()
|
||||||
@@ -162,12 +134,6 @@ class DmChatSocketParserTest {
|
|||||||
assertNull(event)
|
assertNull(event)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun requireMessage(event: DmChatSocketEvent?): DmChatMessageResponse {
|
|
||||||
val messageEvent = event as? DmChatSocketEvent.Message
|
|
||||||
requireNotNull(messageEvent)
|
|
||||||
return messageEvent.message
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun messageJson(
|
private fun messageJson(
|
||||||
messageId: Long = 10L,
|
messageId: Long = 10L,
|
||||||
textMessage: String = "안녕하세요"
|
textMessage: String = "안녕하세요"
|
||||||
|
|||||||
@@ -260,6 +260,37 @@ class ContentAllTabViewModelTest {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `외부 FREE 선택 요청의 가격 정렬은 LATEST로 보정한다`() {
|
||||||
|
stubGetContents(
|
||||||
|
type = MainContentAllType.FREE,
|
||||||
|
sort = ContentSort.LATEST,
|
||||||
|
response = Single.just(
|
||||||
|
ApiResponse(
|
||||||
|
true,
|
||||||
|
response(
|
||||||
|
type = MainContentAllType.FREE,
|
||||||
|
sort = ContentSort.LATEST,
|
||||||
|
audios = listOf(audio(32L))
|
||||||
|
),
|
||||||
|
null
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
viewModel.selectTypeAndSort(MainContentAllType.FREE, ContentSort.PRICE_LOW)
|
||||||
|
|
||||||
|
val state = viewModel.allTabStateLiveData.requireValue() as MainContentAllTabUiState.Content
|
||||||
|
assertEquals(MainContentAllType.FREE, state.selectedType)
|
||||||
|
assertEquals(ContentSort.LATEST, state.selectedSort)
|
||||||
|
verifyGetContents(
|
||||||
|
type = MainContentAllType.FREE,
|
||||||
|
sort = ContentSort.LATEST,
|
||||||
|
page = 0,
|
||||||
|
dayOfWeek = null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `외부 선택 요청은 ORIGINAL LATEST 첫 페이지를 dayOfWeek 없이 요청한다`() {
|
fun `외부 선택 요청은 ORIGINAL LATEST 첫 페이지를 dayOfWeek 없이 요청한다`() {
|
||||||
stubGetContents(
|
stubGetContents(
|
||||||
@@ -291,6 +322,80 @@ class ContentAllTabViewModelTest {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `무료 타입 변경은 이전 가격 정렬을 LATEST로 보정한다`() {
|
||||||
|
stubGetContents(
|
||||||
|
sort = ContentSort.PRICE_HIGH,
|
||||||
|
response = Single.just(
|
||||||
|
ApiResponse(
|
||||||
|
true,
|
||||||
|
response(
|
||||||
|
sort = ContentSort.PRICE_HIGH,
|
||||||
|
audios = listOf(audio(33L))
|
||||||
|
),
|
||||||
|
null
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
stubGetContents(
|
||||||
|
type = MainContentAllType.FREE,
|
||||||
|
sort = ContentSort.LATEST,
|
||||||
|
response = Single.just(
|
||||||
|
ApiResponse(
|
||||||
|
true,
|
||||||
|
response(
|
||||||
|
type = MainContentAllType.FREE,
|
||||||
|
sort = ContentSort.LATEST,
|
||||||
|
audios = listOf(audio(34L))
|
||||||
|
),
|
||||||
|
null
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
viewModel.changeSort(ContentSort.PRICE_HIGH)
|
||||||
|
viewModel.changeType(MainContentAllType.FREE)
|
||||||
|
|
||||||
|
val state = viewModel.allTabStateLiveData.requireValue() as MainContentAllTabUiState.Content
|
||||||
|
assertEquals(MainContentAllType.FREE, state.selectedType)
|
||||||
|
assertEquals(ContentSort.LATEST, state.selectedSort)
|
||||||
|
verifyGetContents(
|
||||||
|
type = MainContentAllType.FREE,
|
||||||
|
sort = ContentSort.LATEST,
|
||||||
|
page = 0,
|
||||||
|
dayOfWeek = null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `소장순 정렬 변경은 LATEST로 보정한다`() {
|
||||||
|
stubGetContents(
|
||||||
|
sort = ContentSort.LATEST,
|
||||||
|
response = Single.just(
|
||||||
|
ApiResponse(
|
||||||
|
true,
|
||||||
|
response(
|
||||||
|
sort = ContentSort.LATEST,
|
||||||
|
audios = listOf(audio(35L))
|
||||||
|
),
|
||||||
|
null
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
viewModel.changeSort(ContentSort.OWNED)
|
||||||
|
|
||||||
|
val state = viewModel.allTabStateLiveData.requireValue() as MainContentAllTabUiState.Content
|
||||||
|
assertEquals(MainContentAllType.AUDIO, state.selectedType)
|
||||||
|
assertEquals(ContentSort.LATEST, state.selectedSort)
|
||||||
|
verifyGetContents(
|
||||||
|
type = MainContentAllType.AUDIO,
|
||||||
|
sort = ContentSort.LATEST,
|
||||||
|
page = 0,
|
||||||
|
dayOfWeek = null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `SERIES에서 요일 변경은 변경 요일과 page 0으로 요청한다`() {
|
fun `SERIES에서 요일 변경은 변경 요일과 page 0으로 요청한다`() {
|
||||||
stubGetContents(response = Single.just(ApiResponse(true, response(), null)))
|
stubGetContents(response = Single.just(ApiResponse(true, response(), null)))
|
||||||
|
|||||||
@@ -97,12 +97,11 @@ class ContentMainFragmentSourceTest {
|
|||||||
assertTrue(selectedDayBackground.contains("@dimen/radius_8"))
|
assertTrue(selectedDayBackground.contains("@dimen/radius_8"))
|
||||||
assertTrue(fragmentLayout.contains("@+id/layout_content_all_sort_bar"))
|
assertTrue(fragmentLayout.contains("@+id/layout_content_all_sort_bar"))
|
||||||
assertTrue(fragmentLayout.contains("android:layout_height=\"52dp\""))
|
assertTrue(fragmentLayout.contains("android:layout_height=\"52dp\""))
|
||||||
assertTrue(fragmentLayout.contains("@+id/tv_content_all_total_label"))
|
assertFalse(fragmentLayout.contains("@+id/tv_content_all_total_label"))
|
||||||
assertTrue(fragmentLayout.contains("android:text=\"@string/screen_content_tab_all\""))
|
assertFalse(fragmentLayout.contains("android:text=\"@string/screen_content_tab_all\""))
|
||||||
assertTrue(fragmentLayout.contains("android:textColor=\"@color/white\""))
|
assertFalse(fragmentLayout.contains("@+id/tv_content_all_total_count"))
|
||||||
assertTrue(fragmentLayout.contains("@+id/tv_content_all_total_count"))
|
assertTrue(fragmentLayout.contains("<Space"))
|
||||||
assertTrue(fragmentLayout.contains("style=\"@style/Typography.Body2\""))
|
assertTrue(fragmentLayout.contains("android:layout_weight=\"1\""))
|
||||||
assertTrue(fragmentLayout.contains("android:textColor=\"@color/gray_500\""))
|
|
||||||
assertTrue(fragmentLayout.contains("@+id/layout_content_all_sort_button"))
|
assertTrue(fragmentLayout.contains("@+id/layout_content_all_sort_button"))
|
||||||
assertTrue(fragmentLayout.contains("android:layout_height=\"match_parent\""))
|
assertTrue(fragmentLayout.contains("android:layout_height=\"match_parent\""))
|
||||||
assertTrue(fragmentLayout.contains("@+id/tv_content_all_sort_label"))
|
assertTrue(fragmentLayout.contains("@+id/tv_content_all_sort_label"))
|
||||||
@@ -131,7 +130,8 @@ class ContentMainFragmentSourceTest {
|
|||||||
assertFalse(source.contains("screen_content_all_type_all"))
|
assertFalse(source.contains("screen_content_all_type_all"))
|
||||||
assertFalse(source.contains("screen_content_all_type_serialized"))
|
assertFalse(source.contains("screen_content_all_type_serialized"))
|
||||||
assertTrue(source.contains("CreatorChannelSortPopup"))
|
assertTrue(source.contains("CreatorChannelSortPopup"))
|
||||||
assertTrue(source.contains("ContentSort.entries"))
|
assertTrue(source.contains("state.selectedType.contentAllSortOptions()"))
|
||||||
|
assertTrue(source.contains("sortOptions = sortOptions"))
|
||||||
assertTrue(source.contains("toLabelResId()"))
|
assertTrue(source.contains("toLabelResId()"))
|
||||||
assertTrue(source.contains("contentAllDayOfWeekOptions"))
|
assertTrue(source.contains("contentAllDayOfWeekOptions"))
|
||||||
assertTrue(source.contains("toContentAllDayLabelResId()"))
|
assertTrue(source.contains("toContentAllDayLabelResId()"))
|
||||||
@@ -221,7 +221,7 @@ class ContentMainFragmentSourceTest {
|
|||||||
assertSourceContains(source, "binding.viewContentAllTypeTabs.root.setMenus")
|
assertSourceContains(source, "binding.viewContentAllTypeTabs.root.setMenus")
|
||||||
assertSourceContains(source, "binding.layoutContentAllDayFilter.visibility")
|
assertSourceContains(source, "binding.layoutContentAllDayFilter.visibility")
|
||||||
assertSourceContains(source, "binding.tvContentAllSortLabel.setText")
|
assertSourceContains(source, "binding.tvContentAllSortLabel.setText")
|
||||||
assertSourceContains(source, "binding.tvContentAllTotalCount.text")
|
assertFalse(source.contains("binding.tvContentAllTotalCount.text"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -572,6 +572,59 @@ class ContentMainFragmentSourceTest {
|
|||||||
assertSourceContains(seriesCard, "172f / 122f")
|
assertSourceContains(seriesCard, "172f / 122f")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `content 전체 탭 pagination 렌더링은 RecyclerView adapter를 보존한다`() {
|
||||||
|
val source = projectFile(
|
||||||
|
"app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/ContentMainFragment.kt"
|
||||||
|
).readText()
|
||||||
|
val bindContentSource = source.substringFrom("private fun bindAllTabContent")
|
||||||
|
val clearItemsSource = source.substringFrom("private fun clearAllTabItems")
|
||||||
|
val destroyViewSource = source.substringFrom("override fun onDestroyView")
|
||||||
|
|
||||||
|
assertSourceContains(source, "private fun setAllTabAdapterIfNeeded(adapter: RecyclerView.Adapter<*>)")
|
||||||
|
assertSourceContains(source, "if (binding.rvContentAllItems.adapter == adapter) return")
|
||||||
|
assertSourceContains(bindContentSource, "setAllTabAdapterIfNeeded(contentAllSeriesCardAdapter)")
|
||||||
|
assertSourceContains(bindContentSource, "setAllTabAdapterIfNeeded(contentAllAudioCardAdapter)")
|
||||||
|
assertFalse(
|
||||||
|
"페이지 추가 상태 재렌더링은 같은 adapter를 직접 재대입하면 스크롤 위치가 흔들릴 수 있다.",
|
||||||
|
bindContentSource.contains("binding.rvContentAllItems.adapter = contentAll")
|
||||||
|
)
|
||||||
|
assertFalse(
|
||||||
|
"Loading/Empty/Error 렌더링의 item clear는 adapter를 분리하지 않아야 한다.",
|
||||||
|
clearItemsSource.contains("binding.rvContentAllItems.adapter = null")
|
||||||
|
)
|
||||||
|
assertSourceContains(destroyViewSource, "binding.rvContentAllItems.adapter = null")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `content 전체 탭 adapter는 DiffUtil로 변경분만 반영한다`() {
|
||||||
|
val audioAdapter = projectFile(
|
||||||
|
"app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/ui/ContentAllAudioCardAdapter.kt"
|
||||||
|
).readText()
|
||||||
|
val seriesAdapter = projectFile(
|
||||||
|
"app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/ui/ContentAllSeriesCardAdapter.kt"
|
||||||
|
).readText()
|
||||||
|
val audioSubmitSource = audioAdapter.substringFrom("fun submitItems")
|
||||||
|
val seriesSubmitSource = seriesAdapter.substringFrom("fun submitItems")
|
||||||
|
|
||||||
|
assertSourceContains(audioAdapter, "import androidx.recyclerview.widget.DiffUtil")
|
||||||
|
assertSourceContains(seriesAdapter, "import androidx.recyclerview.widget.DiffUtil")
|
||||||
|
assertSourceContains(audioSubmitSource, "DiffUtil.calculateDiff")
|
||||||
|
assertSourceContains(seriesSubmitSource, "DiffUtil.calculateDiff")
|
||||||
|
assertSourceContains(audioSubmitSource, "diffResult.dispatchUpdatesTo(this)")
|
||||||
|
assertSourceContains(seriesSubmitSource, "diffResult.dispatchUpdatesTo(this)")
|
||||||
|
assertSourceContains(audioAdapter, "oldList[oldItemPosition].audioContentId ==")
|
||||||
|
assertSourceContains(seriesAdapter, "oldList[oldItemPosition].seriesId ==")
|
||||||
|
assertSourceContains(audioAdapter, "oldList[oldItemPosition] == newList[newItemPosition]")
|
||||||
|
assertSourceContains(seriesAdapter, "oldList[oldItemPosition] == newList[newItemPosition]")
|
||||||
|
assertFalse(audioSubmitSource.contains("notifyDataSetChanged()"))
|
||||||
|
assertFalse(seriesSubmitSource.contains("notifyDataSetChanged()"))
|
||||||
|
assertFalse(audioAdapter.contains("notifyDataSetChanged()"))
|
||||||
|
assertFalse(seriesAdapter.contains("notifyDataSetChanged()"))
|
||||||
|
assertSourceContains(audioAdapter, "notifyItemRangeChanged(0, items.size)")
|
||||||
|
assertSourceContains(seriesAdapter, "notifyItemRangeChanged(0, items.size)")
|
||||||
|
}
|
||||||
|
|
||||||
private fun banner(
|
private fun banner(
|
||||||
eventItem: EventItem? = null,
|
eventItem: EventItem? = null,
|
||||||
creatorId: Long? = null,
|
creatorId: Long? = null,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import kr.co.vividnext.sodalive.v2.main.content.data.MainContentAllTabResponse
|
|||||||
import kr.co.vividnext.sodalive.v2.main.content.data.MainContentAllType
|
import kr.co.vividnext.sodalive.v2.main.content.data.MainContentAllType
|
||||||
import kr.co.vividnext.sodalive.v2.main.content.data.MainContentAudioResponse
|
import kr.co.vividnext.sodalive.v2.main.content.data.MainContentAudioResponse
|
||||||
import kr.co.vividnext.sodalive.v2.main.content.data.MainContentSeriesResponse
|
import kr.co.vividnext.sodalive.v2.main.content.data.MainContentSeriesResponse
|
||||||
|
import kr.co.vividnext.sodalive.v2.main.content.model.contentAllSortOptions
|
||||||
import kr.co.vividnext.sodalive.v2.main.content.model.toContent
|
import kr.co.vividnext.sodalive.v2.main.content.model.toContent
|
||||||
import kr.co.vividnext.sodalive.v2.main.content.model.usesDayOfWeekQuery
|
import kr.co.vividnext.sodalive.v2.main.content.model.usesDayOfWeekQuery
|
||||||
import kr.co.vividnext.sodalive.v2.main.content.model.usesSeriesItems
|
import kr.co.vividnext.sodalive.v2.main.content.model.usesSeriesItems
|
||||||
@@ -92,6 +93,21 @@ class MainContentAllTabMapperTest {
|
|||||||
assertTrue(content.hasNext)
|
assertTrue(content.hasNext)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `전체 탭 정렬 옵션은 소장순을 제외한다`() {
|
||||||
|
MainContentAllType.entries.forEach { type ->
|
||||||
|
assertFalse(ContentSort.OWNED in type.contentAllSortOptions())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `무료 타입 정렬 옵션은 가격순을 제외한다`() {
|
||||||
|
assertEquals(
|
||||||
|
listOf(ContentSort.LATEST, ContentSort.POPULAR),
|
||||||
|
MainContentAllType.FREE.contentAllSortOptions()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private fun response(
|
private fun response(
|
||||||
type: MainContentAllType = MainContentAllType.AUDIO,
|
type: MainContentAllType = MainContentAllType.AUDIO,
|
||||||
totalCount: Int = 1,
|
totalCount: Int = 1,
|
||||||
|
|||||||
@@ -27,10 +27,10 @@ import kr.co.vividnext.sodalive.audio_content.detail.AudioContentDetailActivity
|
|||||||
import kr.co.vividnext.sodalive.audio_content.series.detail.SeriesDetailActivity
|
import kr.co.vividnext.sodalive.audio_content.series.detail.SeriesDetailActivity
|
||||||
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.common.formatUtcRelativeTimeText
|
||||||
import kr.co.vividnext.sodalive.explorer.profile.creator_community.all.CreatorCommunityAllActivity
|
|
||||||
import kr.co.vividnext.sodalive.settings.event.EventDetailActivity
|
import kr.co.vividnext.sodalive.settings.event.EventDetailActivity
|
||||||
import kr.co.vividnext.sodalive.settings.event.EventItem
|
import kr.co.vividnext.sodalive.settings.event.EventItem
|
||||||
import kr.co.vividnext.sodalive.v2.creator.channel.CreatorChannelActivity
|
import kr.co.vividnext.sodalive.v2.creator.channel.CreatorChannelActivity
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.community.detail.CreatorChannelCommunityDetailActivity
|
||||||
import kr.co.vividnext.sodalive.v2.main.home.data.HomeActiveCreatorItem
|
import kr.co.vividnext.sodalive.v2.main.home.data.HomeActiveCreatorItem
|
||||||
import kr.co.vividnext.sodalive.v2.main.home.data.HomeAiCharacterItem
|
import kr.co.vividnext.sodalive.v2.main.home.data.HomeAiCharacterItem
|
||||||
import kr.co.vividnext.sodalive.v2.main.home.data.HomeBannerItem
|
import kr.co.vividnext.sodalive.v2.main.home.data.HomeBannerItem
|
||||||
@@ -40,6 +40,12 @@ import kr.co.vividnext.sodalive.v2.main.home.data.HomeLiveItem
|
|||||||
import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationBannerSection
|
import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationBannerSection
|
||||||
import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationBannerRoute
|
import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationBannerRoute
|
||||||
import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationBannerUiModel
|
import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationBannerUiModel
|
||||||
|
import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationUiState
|
||||||
|
import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationRecentlyActiveCreatorSection
|
||||||
|
import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationRecentDebutCreatorSection
|
||||||
|
import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationLiveSection
|
||||||
|
import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationCheerCreatorSection
|
||||||
|
import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationAiCharacterSection
|
||||||
import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationAiCharacterRoute
|
import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationAiCharacterRoute
|
||||||
import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationCreatorUiModel
|
import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationCreatorUiModel
|
||||||
import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationGenreCreatorGroupUiModel
|
import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationGenreCreatorGroupUiModel
|
||||||
@@ -119,7 +125,6 @@ class HomeMainFragmentLayoutTest {
|
|||||||
R.id.ll_home_recent_activity_section,
|
R.id.ll_home_recent_activity_section,
|
||||||
R.id.ll_home_recent_debut_section,
|
R.id.ll_home_recent_debut_section,
|
||||||
R.id.ll_home_ai_character_section,
|
R.id.ll_home_ai_character_section,
|
||||||
R.id.ll_home_genre_creator_section,
|
|
||||||
R.id.ll_home_cheer_creator_section,
|
R.id.ll_home_cheer_creator_section,
|
||||||
R.id.ll_home_popular_community_section
|
R.id.ll_home_popular_community_section
|
||||||
)
|
)
|
||||||
@@ -128,7 +133,6 @@ class HomeMainFragmentLayoutTest {
|
|||||||
R.id.rv_home_recent_activity_creators,
|
R.id.rv_home_recent_activity_creators,
|
||||||
R.id.rv_home_recent_debut_creators,
|
R.id.rv_home_recent_debut_creators,
|
||||||
R.id.rv_home_ai_characters,
|
R.id.rv_home_ai_characters,
|
||||||
R.id.rv_home_genre_creators,
|
|
||||||
R.id.rv_home_cheer_creators
|
R.id.rv_home_cheer_creators
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -144,6 +148,37 @@ class HomeMainFragmentLayoutTest {
|
|||||||
assertNotNull(root.findViewById<View>(R.id.ll_home_business_info))
|
assertNotNull(root.findViewById<View>(R.id.ll_home_business_info))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `home recommendation does not display genre creator section`() {
|
||||||
|
val root = inflateView(R.layout.fragment_v2_main_home)
|
||||||
|
val layoutSource = homeMainLayoutSource()
|
||||||
|
val source = homeMainFragmentSource()
|
||||||
|
val genreSection = root.findViewById<LinearLayout>(R.id.ll_home_genre_creator_section)
|
||||||
|
|
||||||
|
assertEquals(View.GONE, genreSection.visibility)
|
||||||
|
assertTrue(layoutSource.contains("android:id=\"@+id/ll_home_genre_creator_section\""))
|
||||||
|
assertTrue(layoutSource.contains("android:visibility=\"gone\""))
|
||||||
|
assertTrue(source.contains("private fun bindGenreCreatorSection(section: HomeRecommendationGenreCreatorSection)"))
|
||||||
|
assertTrue(source.contains("binding.llHomeGenreCreatorSection.visibility = View.GONE"))
|
||||||
|
assertTrue(source.contains("genreCreatorAdapter.submitGroups(emptyList())"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `home recommendation empty state ignores hidden genre creators`() {
|
||||||
|
val content = HomeRecommendationUiState.Content(
|
||||||
|
lives = HomeRecommendationLiveSection(emptyList()),
|
||||||
|
banners = HomeRecommendationBannerSection(emptyList()),
|
||||||
|
recentlyActiveCreators = HomeRecommendationRecentlyActiveCreatorSection(emptyList()),
|
||||||
|
recentDebutCreators = HomeRecommendationRecentDebutCreatorSection(emptyList()),
|
||||||
|
aiCharacters = HomeRecommendationAiCharacterSection(emptyList()),
|
||||||
|
genreCreators = HomeRecommendationGenreCreatorSection(listOf(genreGroup("genre", genreCreators(1L)))),
|
||||||
|
cheerCreators = HomeRecommendationCheerCreatorSection(emptyList()),
|
||||||
|
popularCommunityPosts = HomeRecommendationPopularCommunityPostSection(emptyList())
|
||||||
|
)
|
||||||
|
|
||||||
|
assertTrue(content.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `home phase6 item layouts expose required binding views`() {
|
fun `home phase6 item layouts expose required binding views`() {
|
||||||
val recentActivity = inflateView(R.layout.item_home_recent_activity_creator)
|
val recentActivity = inflateView(R.layout.item_home_recent_activity_creator)
|
||||||
@@ -437,17 +472,6 @@ class HomeMainFragmentLayoutTest {
|
|||||||
assertEquals(14f, nickname.textSize / nickname.resources.displayMetrics.scaledDensity)
|
assertEquals(14f, nickname.textSize / nickname.resources.displayMetrics.scaledDensity)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `home genre creator section uses pager snapping list`() {
|
|
||||||
val root = inflateView(R.layout.fragment_v2_main_home)
|
|
||||||
val genreList = root.findViewById<RecyclerView>(R.id.rv_home_genre_creators)
|
|
||||||
|
|
||||||
assertEquals(ViewGroup.LayoutParams.WRAP_CONTENT, genreList.layoutParams.height)
|
|
||||||
assertEquals(14.dpToPx(), genreList.paddingStart)
|
|
||||||
assertEquals(14.dpToPx(), genreList.paddingEnd)
|
|
||||||
assertNotNull(genreList.onFlingListener)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `genre creator adapter renders max five non empty genre groups`() {
|
fun `genre creator adapter renders max five non empty genre groups`() {
|
||||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||||
@@ -474,22 +498,6 @@ class HomeMainFragmentLayoutTest {
|
|||||||
assertEquals(8, viewHolder.itemView.findViewById<GridLayout>(R.id.gl_home_genre_creator_profiles).childCount)
|
assertEquals(8, viewHolder.itemView.findViewById<GridLayout>(R.id.gl_home_genre_creator_profiles).childCount)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `genre creator adapter follow all uses ids from its page group`() {
|
|
||||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
|
||||||
val parent = RecyclerView(context)
|
|
||||||
parent.layoutManager = LinearLayoutManager(context, RecyclerView.HORIZONTAL, false)
|
|
||||||
val clickedCreatorIds = mutableListOf<Long>()
|
|
||||||
val adapter = HomeGenreCreatorAdapter(onFollowAllClick = { clickedCreatorIds.addAll(it) })
|
|
||||||
adapter.submitGroups(listOf(genreGroup("genre", genreCreators(10L))))
|
|
||||||
val viewHolder = adapter.onCreateViewHolder(parent, 0)
|
|
||||||
|
|
||||||
adapter.onBindViewHolder(viewHolder, 0)
|
|
||||||
viewHolder.itemView.findViewById<View>(R.id.view_home_genre_group_follow_all).performClick()
|
|
||||||
|
|
||||||
assertEquals((10L..17L).toList(), clickedCreatorIds)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `cheer creator group item matches figma card structure`() {
|
fun `cheer creator group item matches figma card structure`() {
|
||||||
val group = inflateViewWithParent(R.layout.item_home_cheer_creator_group)
|
val group = inflateViewWithParent(R.layout.item_home_cheer_creator_group)
|
||||||
@@ -948,8 +956,8 @@ class HomeMainFragmentLayoutTest {
|
|||||||
|
|
||||||
assertEquals(AudioContentDetailActivity::class.java.name, audioIntent.component?.className)
|
assertEquals(AudioContentDetailActivity::class.java.name, audioIntent.component?.className)
|
||||||
assertEquals(11L, audioIntent.getLongExtra(Constants.EXTRA_AUDIO_CONTENT_ID, 0L))
|
assertEquals(11L, audioIntent.getLongExtra(Constants.EXTRA_AUDIO_CONTENT_ID, 0L))
|
||||||
assertEquals(CreatorCommunityAllActivity::class.java.name, communityIntent.component?.className)
|
assertEquals(CreatorChannelCommunityDetailActivity::class.java.name, communityIntent.component?.className)
|
||||||
assertEquals(13L, communityIntent.getLongExtra(Constants.EXTRA_COMMUNITY_CREATOR_ID, 0L))
|
assertEquals(13L, communityIntent.getLongExtra("extra_post_id", 0L))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -1257,7 +1265,7 @@ class HomeMainFragmentLayoutTest {
|
|||||||
assertTrue(source.contains("toastLiveData.observe(viewLifecycleOwner)"))
|
assertTrue(source.contains("toastLiveData.observe(viewLifecycleOwner)"))
|
||||||
assertTrue(source.contains("showToast"))
|
assertTrue(source.contains("showToast"))
|
||||||
assertTrue(source.contains("followCreators(SECTION_KEY_CHEER_CREATORS"))
|
assertTrue(source.contains("followCreators(SECTION_KEY_CHEER_CREATORS"))
|
||||||
assertTrue(source.contains("followCreators(genreSectionKey"))
|
assertFalse(source.contains("followCreators(genreSectionKey"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -35,11 +35,48 @@ class HomeMainFragmentLoginGuardSourceTest {
|
|||||||
assertBeforeGuard(source, "val route = item.toHomeRecommendationBannerRoute() ?: return")
|
assertBeforeGuard(source, "val route = item.toHomeRecommendationBannerRoute() ?: return")
|
||||||
assertBeforeGuard(source, "val route = item.toHomeRecommendationRecentlyActiveCreatorRoute() ?: return")
|
assertBeforeGuard(source, "val route = item.toHomeRecommendationRecentlyActiveCreatorRoute() ?: return")
|
||||||
assertBeforeGuard(source, "val route = item.toHomeRecommendationAiCharacterRoute() ?: return")
|
assertBeforeGuard(source, "val route = item.toHomeRecommendationAiCharacterRoute() ?: return")
|
||||||
assertBeforeGuard(source, "val creatorId = item.creatorId.toLongOrNull() ?: return")
|
|
||||||
assertBeforeGuard(source, "val postId = item.postId.toLongOrNull() ?: return")
|
assertBeforeGuard(source, "val postId = item.postId.toLongOrNull() ?: return")
|
||||||
assertFalse(source.contains("requiresAdultContentAccess = true"))
|
assertFalse(source.contains("requiresAdultContentAccess = true"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `HomeMainFragment 인기 커뮤니티는 커뮤니티 상세로 이동한다`() {
|
||||||
|
val source = projectFile(
|
||||||
|
"app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/HomeMainFragment.kt"
|
||||||
|
).readText()
|
||||||
|
|
||||||
|
val clickSource = source.substringFrom("private fun openPopularCommunityPost(item: FeedItem.Community)")
|
||||||
|
assertTrue(clickSource.contains("CreatorChannelCommunityDetailActivity.newIntent(requireContext(), postId)"))
|
||||||
|
assertFalse(clickSource.contains("CreatorCommunityAllActivity"))
|
||||||
|
assertFalse(clickSource.contains("EXTRA_COMMUNITY_CREATOR_ID"))
|
||||||
|
assertFalse(clickSource.contains("EXTRA_COMMUNITY_POST_ID"))
|
||||||
|
assertFalse(clickSource.contains("EXTRA_COMMUNITY_EXIST_ORDERED"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `HomeMainFragment 팔로잉 최근 소식 COMMUNITY_POST는 커뮤니티 상세로 이동한다`() {
|
||||||
|
val source = projectFile(
|
||||||
|
"app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/HomeMainFragment.kt"
|
||||||
|
).readText()
|
||||||
|
|
||||||
|
val clickSource = source.substringFrom("private fun onFollowingNewsClick(item: HomeFollowingNewsUiItem)")
|
||||||
|
assertTrue(clickSource.contains("val community = item as? HomeFollowingNewsUiItem.Community ?: return"))
|
||||||
|
assertTrue(clickSource.contains("val postId = community.postId"))
|
||||||
|
assertBefore(
|
||||||
|
clickSource,
|
||||||
|
"val community = item as? HomeFollowingNewsUiItem.Community ?: return",
|
||||||
|
"ensureMainV2NavigationAllowed"
|
||||||
|
)
|
||||||
|
assertBefore(
|
||||||
|
clickSource,
|
||||||
|
"if (postId <= 0L) return",
|
||||||
|
"ensureMainV2NavigationAllowed"
|
||||||
|
)
|
||||||
|
assertTrue(clickSource.contains("CreatorChannelCommunityDetailActivity.newIntent(requireContext(), postId)"))
|
||||||
|
assertFalse(clickSource.contains("CreatorCommunityAllActivity"))
|
||||||
|
assertFalse(clickSource.contains("EXTRA_COMMUNITY_POST_ID"))
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `HomeMainFragment 팔로잉 탭 선택은 로그인 가드 통과 후 탭을 전환한다`() {
|
fun `HomeMainFragment 팔로잉 탭 선택은 로그인 가드 통과 후 탭을 전환한다`() {
|
||||||
val source = projectFile(
|
val source = projectFile(
|
||||||
@@ -86,9 +123,21 @@ class HomeMainFragmentLoginGuardSourceTest {
|
|||||||
val clickSource = source.substringFrom("private fun onLiveClick(item: HomeRecommendationLiveUiModel)")
|
val clickSource = source.substringFrom("private fun onLiveClick(item: HomeRecommendationLiveUiModel)")
|
||||||
assertTrue(clickSource.contains("ensureMainV2NavigationAllowed"))
|
assertTrue(clickSource.contains("ensureMainV2NavigationAllowed"))
|
||||||
assertTrue(clickSource.contains("liveViewModel.getRoomDetail(item.roomId) { roomDetail ->"))
|
assertTrue(clickSource.contains("liveViewModel.getRoomDetail(item.roomId) { roomDetail ->"))
|
||||||
assertBefore(clickSource, "if (!isAdded || view == null) return@getRoomDetail", "requiresAdultContentAccess = roomDetail.isAdult")
|
assertBefore(
|
||||||
assertBefore(clickSource, "liveViewModel.getRoomDetail(item.roomId) { roomDetail ->", "requiresAdultContentAccess = roomDetail.isAdult")
|
clickSource,
|
||||||
assertBefore(clickSource, "requiresAdultContentAccess = roomDetail.isAdult", "liveCoordinator.enterLiveRoom(item.roomId, roomDetail)")
|
"if (!isAdded || view == null) return@getRoomDetail",
|
||||||
|
"requiresAdultContentAccess = roomDetail.isAdult"
|
||||||
|
)
|
||||||
|
assertBefore(
|
||||||
|
clickSource,
|
||||||
|
"liveViewModel.getRoomDetail(item.roomId) { roomDetail ->",
|
||||||
|
"requiresAdultContentAccess = roomDetail.isAdult"
|
||||||
|
)
|
||||||
|
assertBefore(
|
||||||
|
clickSource,
|
||||||
|
"requiresAdultContentAccess = roomDetail.isAdult",
|
||||||
|
"liveCoordinator.enterLiveRoom(item.roomId, roomDetail)"
|
||||||
|
)
|
||||||
assertFalse(clickSource.contains("= Unit"))
|
assertFalse(clickSource.contains("= Unit"))
|
||||||
|
|
||||||
assertTrue(source.contains("import kr.co.vividnext.sodalive.v2.creator.channel.CreatorChannelLiveCoordinator"))
|
assertTrue(source.contains("import kr.co.vividnext.sodalive.v2.creator.channel.CreatorChannelLiveCoordinator"))
|
||||||
@@ -107,14 +156,34 @@ class HomeMainFragmentLoginGuardSourceTest {
|
|||||||
assertTrue(source.contains("private var isLiveEntryLoading = false"))
|
assertTrue(source.contains("private var isLiveEntryLoading = false"))
|
||||||
|
|
||||||
val updateSource = source.substringFrom("private fun updateLoadingDialog()")
|
val updateSource = source.substringFrom("private fun updateLoadingDialog()")
|
||||||
assertTrue(updateSource.contains("isRecommendationLoading || isCreatorRankingLoading || isFollowingLoading || isLiveEntryLoading"))
|
val expectedLoadingExpression = "isRecommendationLoading || " +
|
||||||
|
"isCreatorRankingLoading || " +
|
||||||
|
"isFollowingLoading || " +
|
||||||
|
"isLiveEntryLoading"
|
||||||
|
assertTrue(updateSource.contains(expectedLoadingExpression))
|
||||||
assertTrue(updateSource.contains("loadingDialog.show(screenWidth)"))
|
assertTrue(updateSource.contains("loadingDialog.show(screenWidth)"))
|
||||||
assertTrue(updateSource.contains("loadingDialog.dismiss()"))
|
assertTrue(updateSource.contains("loadingDialog.dismiss()"))
|
||||||
|
|
||||||
assertLoadingObserverUsesUpdate(source, "homeRecommendationViewModel.isLoading.observe(viewLifecycleOwner)", "isRecommendationLoading = isLoading")
|
assertLoadingObserverUsesUpdate(
|
||||||
assertLoadingObserverUsesUpdate(source, "homeCreatorRankingViewModel.isLoading.observe(viewLifecycleOwner)", "isCreatorRankingLoading = isLoading")
|
source,
|
||||||
assertLoadingObserverUsesUpdate(source, "homeFollowingViewModel.isLoading.observe(viewLifecycleOwner)", "isFollowingLoading = isLoading")
|
"homeRecommendationViewModel.isLoading.observe(viewLifecycleOwner)",
|
||||||
assertLoadingObserverUsesUpdate(source, "liveViewModel.isLoading.observe(viewLifecycleOwner)", "isLiveEntryLoading = isLoading")
|
"isRecommendationLoading = isLoading"
|
||||||
|
)
|
||||||
|
assertLoadingObserverUsesUpdate(
|
||||||
|
source,
|
||||||
|
"homeCreatorRankingViewModel.isLoading.observe(viewLifecycleOwner)",
|
||||||
|
"isCreatorRankingLoading = isLoading"
|
||||||
|
)
|
||||||
|
assertLoadingObserverUsesUpdate(
|
||||||
|
source,
|
||||||
|
"homeFollowingViewModel.isLoading.observe(viewLifecycleOwner)",
|
||||||
|
"isFollowingLoading = isLoading"
|
||||||
|
)
|
||||||
|
assertLoadingObserverUsesUpdate(
|
||||||
|
source,
|
||||||
|
"liveViewModel.isLoading.observe(viewLifecycleOwner)",
|
||||||
|
"isLiveEntryLoading = isLoading"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun assertGuardedStartActivity(source: String, functionSignature: String) {
|
private fun assertGuardedStartActivity(source: String, functionSignature: String) {
|
||||||
|
|||||||
@@ -28,6 +28,33 @@ class HomeMainFragmentSourceTest {
|
|||||||
assertFalse(source.contains("ContentOverviewType.FIRST_AUDIO_CONTENT"))
|
assertFalse(source.contains("ContentOverviewType.FIRST_AUDIO_CONTENT"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `홈 추천 최근 데뷔와 AI 캐릭터 섹션 타이틀은 chevron을 숨긴다`() {
|
||||||
|
val source = projectFile(
|
||||||
|
"app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/HomeMainFragment.kt"
|
||||||
|
).readText()
|
||||||
|
val setUpSectionTitles = source.substringFrom("private fun setUpSectionTitles()")
|
||||||
|
|
||||||
|
assertTrue(setUpSectionTitles.contains("binding.viewHomeRecentDebutTitle.setTitle"))
|
||||||
|
assertTrue(setUpSectionTitles.contains("binding.viewHomeAiCharacterTitle.setTitle"))
|
||||||
|
assertFalse(
|
||||||
|
setUpSectionTitles.contains(
|
||||||
|
"""binding.viewHomeRecentDebutTitle.setTitle(
|
||||||
|
R.string.home_recommendation_section_recent_debut_creators,
|
||||||
|
showMore = true
|
||||||
|
)"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assertFalse(
|
||||||
|
setUpSectionTitles.contains(
|
||||||
|
"""binding.viewHomeAiCharacterTitle.setTitle(
|
||||||
|
R.string.home_recommendation_section_ai_characters,
|
||||||
|
showMore = true
|
||||||
|
)"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private fun String.substringFrom(marker: String): String {
|
private fun String.substringFrom(marker: String): String {
|
||||||
val startIndex = indexOf(marker)
|
val startIndex = indexOf(marker)
|
||||||
assertTrue("Missing function: $marker", startIndex >= 0)
|
assertTrue("Missing function: $marker", startIndex >= 0)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package kr.co.vividnext.sodalive.v2.widget.contentranking
|
|||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.content.res.Configuration
|
||||||
import android.view.Gravity
|
import android.view.Gravity
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
@@ -144,6 +145,83 @@ class ContentRankingCardViewTest {
|
|||||||
assertEquals(View.VISIBLE, view.findViewById<View>(R.id.iv_content_ranking_background).visibility)
|
assertEquals(View.VISIBLE, view.findViewById<View>(R.id.iv_content_ranking_background).visibility)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `large card는 402dp 미만에서 실제 text size를 축소한다`() {
|
||||||
|
val view = inflateView<ContentRankingLargeCardView>(
|
||||||
|
layoutResId = R.layout.view_content_ranking_large_card,
|
||||||
|
screenWidthDp = 360
|
||||||
|
)
|
||||||
|
|
||||||
|
view.bind(sampleItem(rank = 1))
|
||||||
|
|
||||||
|
assertTextSizeSp(view.findViewById(R.id.tv_content_ranking_rank), 96f * 360f / 402f)
|
||||||
|
assertTextSizeSp(view.findViewById(R.id.tv_content_ranking_title), 22f * 360f / 402f)
|
||||||
|
assertTextSizeSp(view.findViewById(R.id.tv_content_ranking_creator), 12f * 360f / 402f)
|
||||||
|
assertTextSizeSp(view.findViewById(R.id.tv_content_ranking_delta_amount), 16f * 360f / 402f)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `medium grid card는 402dp 미만에서 실제 text size를 축소한다`() {
|
||||||
|
val view = inflateView<ContentRankingMediumGridCardView>(
|
||||||
|
layoutResId = R.layout.view_content_ranking_medium_grid_card,
|
||||||
|
screenWidthDp = 360
|
||||||
|
)
|
||||||
|
|
||||||
|
view.setCardSize(ContentRankingCardSize(widthPx = 185, heightPx = 185))
|
||||||
|
view.bind(sampleItem(rank = 2))
|
||||||
|
|
||||||
|
assertTextSizeSp(view.findViewById(R.id.tv_content_ranking_rank), 54f * 360f / 402f)
|
||||||
|
assertTextSizeSp(view.findViewById(R.id.tv_content_ranking_title), 22f * 360f / 402f)
|
||||||
|
assertTextSizeSp(view.findViewById(R.id.tv_content_ranking_creator), 12f * 360f / 402f)
|
||||||
|
assertTextSizeSp(view.findViewById(R.id.tv_content_ranking_delta_amount), 16f * 360f / 402f)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `small grid card는 402dp 미만에서 조건부 baseline text size를 축소한다`() {
|
||||||
|
val view = inflateView<ContentRankingSmallGridCardView>(
|
||||||
|
layoutResId = R.layout.view_content_ranking_small_grid_card,
|
||||||
|
screenWidthDp = 360
|
||||||
|
)
|
||||||
|
|
||||||
|
view.setCardSize(ContentRankingCardSize(widthPx = 122, heightPx = 122))
|
||||||
|
view.bind(sampleItem(rank = 8))
|
||||||
|
|
||||||
|
assertTextSizeSp(view.findViewById(R.id.tv_content_ranking_rank), 36f * 360f / 402f)
|
||||||
|
assertTextSizeSp(view.findViewById(R.id.tv_content_ranking_title), 14f * 360f / 402f)
|
||||||
|
assertTextSizeSp(view.findViewById(R.id.tv_content_ranking_creator), 12f * 360f / 402f)
|
||||||
|
assertTextSizeSp(view.findViewById(R.id.tv_content_ranking_delta_amount), 16f * 360f / 402f)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `small grid card는 card size 적용 전 bind에서도 402dp 미만 text size를 축소한다`() {
|
||||||
|
val view = inflateView<ContentRankingSmallGridCardView>(
|
||||||
|
layoutResId = R.layout.view_content_ranking_small_grid_card,
|
||||||
|
screenWidthDp = 360
|
||||||
|
)
|
||||||
|
|
||||||
|
view.bind(sampleItem(rank = 8))
|
||||||
|
|
||||||
|
assertTextSizeSp(view.findViewById(R.id.tv_content_ranking_rank), 36f * 360f / 402f)
|
||||||
|
assertTextSizeSp(view.findViewById(R.id.tv_content_ranking_title), 14f * 360f / 402f)
|
||||||
|
assertTextSizeSp(view.findViewById(R.id.tv_content_ranking_creator), 12f * 360f / 402f)
|
||||||
|
assertTextSizeSp(view.findViewById(R.id.tv_content_ranking_delta_amount), 16f * 360f / 402f)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `horizontal card는 402dp 미만에서 실제 text size를 축소한다`() {
|
||||||
|
val view = inflateView<ContentRankingHorizontalCardView>(
|
||||||
|
layoutResId = R.layout.view_content_ranking_horizontal_card,
|
||||||
|
screenWidthDp = 360
|
||||||
|
)
|
||||||
|
|
||||||
|
view.bind(sampleItem(rank = 11))
|
||||||
|
|
||||||
|
assertTextSizeSp(view.findViewById(R.id.tv_content_ranking_rank), 40f * 360f / 402f)
|
||||||
|
assertTextSizeSp(view.findViewById(R.id.tv_content_ranking_title), 18f * 360f / 402f)
|
||||||
|
assertTextSizeSp(view.findViewById(R.id.tv_content_ranking_creator), 14f * 360f / 402f)
|
||||||
|
assertTextSizeSp(view.findViewById(R.id.tv_content_ranking_delta_amount), 16f * 360f / 402f)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `large view holder는 배경 blur와 전경 non blur coverImage를 모두 로드한다`() {
|
fun `large view holder는 배경 blur와 전경 non blur coverImage를 모두 로드한다`() {
|
||||||
val source = projectFile(
|
val source = projectFile(
|
||||||
@@ -161,11 +239,28 @@ class ContentRankingCardViewTest {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private inline fun <reified T : View> inflateView(layoutResId: Int): T {
|
private inline fun <reified T : View> inflateView(
|
||||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
layoutResId: Int,
|
||||||
|
screenWidthDp: Int? = null
|
||||||
|
): T {
|
||||||
|
val baseContext = ApplicationProvider.getApplicationContext<Context>()
|
||||||
|
val context = if (screenWidthDp == null) {
|
||||||
|
baseContext
|
||||||
|
} else {
|
||||||
|
baseContext.createConfigurationContext(
|
||||||
|
Configuration(baseContext.resources.configuration).apply { this.screenWidthDp = screenWidthDp }
|
||||||
|
)
|
||||||
|
}
|
||||||
return LayoutInflater.from(context).inflate(layoutResId, null, false) as T
|
return LayoutInflater.from(context).inflate(layoutResId, null, false) as T
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun assertTextSizeSp(
|
||||||
|
view: TextView,
|
||||||
|
expectedSp: Float
|
||||||
|
) {
|
||||||
|
assertEquals(expectedSp, view.textSize / view.resources.displayMetrics.scaledDensity, 0.0001f)
|
||||||
|
}
|
||||||
|
|
||||||
private fun assertRankTextBox(
|
private fun assertRankTextBox(
|
||||||
view: TextView,
|
view: TextView,
|
||||||
expectedWidth: Int,
|
expectedWidth: Int,
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.widget.contentranking
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class ContentRankingResponsiveSizeTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `402dp 이상 scale에서는 large baseline text size를 유지한다`() {
|
||||||
|
val size = ContentRankingTextStyle.large(scale = 1f)
|
||||||
|
|
||||||
|
assertEquals(96f, size.rankTextSizeSp, 0.0001f)
|
||||||
|
assertEquals(22f, size.titleTextSizeSp, 0.0001f)
|
||||||
|
assertEquals(12f, size.creatorTextSizeSp, 0.0001f)
|
||||||
|
assertEquals(16f, size.deltaTextSizeSp, 0.0001f)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `402dp 이상 scale에서는 medium grid baseline text size를 유지한다`() {
|
||||||
|
val size = ContentRankingTextStyle.mediumGrid(scale = 1f)
|
||||||
|
|
||||||
|
assertEquals(54f, size.rankTextSizeSp, 0.0001f)
|
||||||
|
assertEquals(22f, size.titleTextSizeSp, 0.0001f)
|
||||||
|
assertEquals(12f, size.creatorTextSizeSp, 0.0001f)
|
||||||
|
assertEquals(16f, size.deltaTextSizeSp, 0.0001f)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `402dp 이상 scale에서는 small grid baseline text size를 유지한다`() {
|
||||||
|
val size = ContentRankingTextStyle.smallGrid(scale = 1f)
|
||||||
|
|
||||||
|
assertEquals(36f, size.rankTextSizeSp, 0.0001f)
|
||||||
|
assertEquals(14f, size.titleTextSizeSp, 0.0001f)
|
||||||
|
assertEquals(12f, size.creatorTextSizeSp, 0.0001f)
|
||||||
|
assertEquals(16f, size.deltaTextSizeSp, 0.0001f)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `402dp 이상 scale에서는 horizontal baseline text size를 유지한다`() {
|
||||||
|
val size = ContentRankingTextStyle.horizontal(scale = 1f)
|
||||||
|
|
||||||
|
assertEquals(40f, size.rankTextSizeSp, 0.0001f)
|
||||||
|
assertEquals(18f, size.titleTextSizeSp, 0.0001f)
|
||||||
|
assertEquals(14f, size.creatorTextSizeSp, 0.0001f)
|
||||||
|
assertEquals(16f, size.deltaTextSizeSp, 0.0001f)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `402dp 미만 scale에서는 baseline text size에 scale을 곱한다`() {
|
||||||
|
val scale = 360f / 402f
|
||||||
|
val large = ContentRankingTextStyle.large(scale)
|
||||||
|
val small = ContentRankingTextStyle.smallGrid(scale)
|
||||||
|
|
||||||
|
assertEquals(96f * scale, large.rankTextSizeSp, 0.0001f)
|
||||||
|
assertEquals(22f * scale, large.titleTextSizeSp, 0.0001f)
|
||||||
|
assertEquals(12f * scale, large.creatorTextSizeSp, 0.0001f)
|
||||||
|
assertEquals(36f * scale, small.rankTextSizeSp, 0.0001f)
|
||||||
|
assertEquals(14f * scale, small.titleTextSizeSp, 0.0001f)
|
||||||
|
assertEquals(12f * scale, small.creatorTextSizeSp, 0.0001f)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -46,4 +46,23 @@ class CreatorRankingTextStyleTest {
|
|||||||
assertEquals(16, style.deltaTextSizeSp)
|
assertEquals(16, style.deltaTextSizeSp)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `402dp 이상 scale에서는 기존 baseline text size를 유지한다`() {
|
||||||
|
val style = CreatorRankingTextStyle.fromRank(1).scaledBy(1f)
|
||||||
|
|
||||||
|
assertEquals(96f, style.rankTextSizeSp, 0.0001f)
|
||||||
|
assertEquals(32f, style.nameTextSizeSp, 0.0001f)
|
||||||
|
assertEquals(16f, style.deltaTextSizeSp, 0.0001f)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `402dp 미만 scale에서는 baseline text size에 scale을 곱한다`() {
|
||||||
|
val scale = 360f / 402f
|
||||||
|
val style = CreatorRankingTextStyle.fromRank(11).scaledBy(scale)
|
||||||
|
|
||||||
|
assertEquals(40f * scale, style.rankTextSizeSp, 0.0001f)
|
||||||
|
assertEquals(18f * scale, style.nameTextSizeSp, 0.0001f)
|
||||||
|
assertEquals(16f * scale, style.deltaTextSizeSp, 0.0001f)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.widget.ranking
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class RankingResponsiveScaleTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `402dp 이상이면 scale 1을 반환한다`() {
|
||||||
|
assertEquals(1f, RankingResponsiveScale.fromScreenWidthDp(402), 0.0001f)
|
||||||
|
assertEquals(1f, RankingResponsiveScale.fromScreenWidthDp(430), 0.0001f)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `402dp 미만이면 device width와 기준 폭 비율을 반환한다`() {
|
||||||
|
assertEquals(360f / 402f, RankingResponsiveScale.fromScreenWidthDp(360), 0.0001f)
|
||||||
|
assertEquals(401f / 402f, RankingResponsiveScale.fromScreenWidthDp(401), 0.0001f)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `screen width를 확인할 수 없으면 scale 1을 반환한다`() {
|
||||||
|
assertEquals(1f, RankingResponsiveScale.fromScreenWidthDp(0), 0.0001f)
|
||||||
|
assertEquals(1f, RankingResponsiveScale.fromScreenWidthDp(-1), 0.0001f)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -215,16 +215,17 @@
|
|||||||
- 검증: 샘플 audio/AI character 데이터로 실제 기기에서 카드 배치와 이미지/텍스트 영역이 보인다. 오디오 태그 조합이 PRD 조건과 일치.
|
- 검증: 샘플 audio/AI character 데이터로 실제 기기에서 카드 배치와 이미지/텍스트 영역이 보인다. 오디오 태그 조합이 PRD 조건과 일치.
|
||||||
|
|
||||||
- [x] **Task 6.4: 장르별/응원 크리에이터와 모두 팔로우 버튼 구현**
|
- [x] **Task 6.4: 장르별/응원 크리에이터와 모두 팔로우 버튼 구현**
|
||||||
|
- 현재 정책: 2026-07-12 Phase 14 이후 `genreCreators` UI와 모두 팔로우 버튼은 추천 탭에 표시하지 않으며, 아래 장르 관련 구현 내용은 과거 이력으로만 유지한다.
|
||||||
- 생성 후보: `app/src/main/res/layout/item_home_creator_profile.xml`
|
- 생성 후보: `app/src/main/res/layout/item_home_creator_profile.xml`
|
||||||
- 생성 후보: `app/src/main/res/layout/view_home_follow_all_button.xml`
|
- 생성 후보: `app/src/main/res/layout/view_home_follow_all_button.xml`
|
||||||
- 생성 후보: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/ui/HomeGenreCreatorAdapter.kt`
|
- 생성 후보: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/ui/HomeGenreCreatorAdapter.kt`
|
||||||
- 생성 후보: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/ui/HomeCheerCreatorAdapter.kt`
|
- 생성 후보: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/ui/HomeCheerCreatorAdapter.kt`
|
||||||
- 구현 내용:
|
- 구현 내용:
|
||||||
- `cheerCreators.creatorId` 목록으로 모두 팔로우 API 호출
|
- `cheerCreators.creatorId` 목록으로 모두 팔로우 API 호출
|
||||||
- `genreCreators`의 해당 그룹 `creators.creatorId` 목록으로 모두 팔로우 API 호출
|
- 현재 미사용: `genreCreators`는 추천 탭 UI에 표시하지 않으므로 해당 그룹 `creators.creatorId` 목록으로 모두 팔로우 API를 호출하지 않는다.
|
||||||
- success 후 `모두 팔로우 완료`, `ic_new_following`, click disabled
|
- success 후 `모두 팔로우 완료`, `ic_new_following`, click disabled
|
||||||
- 실패 시 toast/error 표시
|
- 실패 시 toast/error 표시
|
||||||
- 검증: 샘플 genre/cheer creator 데이터로 실제 기기에서 profile grid와 모두 팔로우 버튼 배치가 보인다. 빈 creator list에서는 API 호출하지 않고 버튼 상태 유지.
|
- 검증: 현재 정책에서는 `genreCreators` UI 미표시를 검증하고, `cheerCreators` profile grid와 모두 팔로우 버튼 배치는 기존 정책대로 유지한다. 빈 creator list에서는 API 호출하지 않고 버튼 상태 유지.
|
||||||
|
|
||||||
- [x] **Task 6.5: 방금 활동한 크리에이터 Figma 정합 수정**
|
- [x] **Task 6.5: 방금 활동한 크리에이터 Figma 정합 수정**
|
||||||
- 기준: Figma `24:5529`
|
- 기준: Figma `24:5529`
|
||||||
@@ -325,6 +326,7 @@
|
|||||||
- 검증: `HomeMainFragmentLayoutTest`에 모두 팔로우 기본/완료 상태와 완료 상태 no-op 회귀 테스트를 추가한다.
|
- 검증: `HomeMainFragmentLayoutTest`에 모두 팔로우 기본/완료 상태와 완료 상태 no-op 회귀 테스트를 추가한다.
|
||||||
|
|
||||||
- [x] **Task 6.12: 장르의 크리에이터 Figma 정합 추가 수정**
|
- [x] **Task 6.12: 장르의 크리에이터 Figma 정합 추가 수정**
|
||||||
|
- 현재 정책: 2026-07-12 Phase 14 이후 `genreCreators` 그룹 page/snapping UI는 추천 탭에 표시하지 않으며, 아래 표시 요구는 과거 구현 이력으로만 유지한다.
|
||||||
- 기준: Figma `24:5611`
|
- 기준: Figma `24:5611`
|
||||||
- 수정: `app/src/main/res/layout/fragment_v2_main_home.xml`
|
- 수정: `app/src/main/res/layout/fragment_v2_main_home.xml`
|
||||||
- 수정: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/HomeMainFragment.kt`
|
- 수정: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/HomeMainFragment.kt`
|
||||||
@@ -333,12 +335,12 @@
|
|||||||
- 생성 후보: `app/src/main/res/layout/item_home_genre_creator_profile.xml`
|
- 생성 후보: `app/src/main/res/layout/item_home_genre_creator_profile.xml`
|
||||||
- 생성 후보: `app/src/main/res/drawable/bg_home_genre_creator_group.xml`
|
- 생성 후보: `app/src/main/res/drawable/bg_home_genre_creator_group.xml`
|
||||||
- 구현 내용:
|
- 구현 내용:
|
||||||
- `genreCreators: List<HomeGenreCreatorGroupItem>`를 그룹 단위 page로 표시한다.
|
- 현재 미사용: `genreCreators: List<HomeGenreCreatorGroupItem>`를 그룹 단위 page로 표시하지 않는다.
|
||||||
- 각 장르 page는 `374dp` 폭, `14dp` padding/radius/gap, dark-blue gradient 배경을 적용한다.
|
- 현재 미사용: 장르 page 치수/배경 요구는 추천 탭 UI에 적용하지 않는다.
|
||||||
- 각 page 내부에 장르 제목, 8명 creator profile grid, 모두 팔로우 버튼을 포함한다.
|
- 현재 미사용: 장르 제목, creator profile grid, 모두 팔로우 버튼을 포함한 page를 표시하지 않는다.
|
||||||
- 전체 장르 page 목록은 free scroll이 아니라 `PagerSnapHelper` 기반 banner snapping으로 이동한다.
|
- 현재 미사용: 장르 page 목록 snapping을 제공하지 않는다.
|
||||||
- 빈 creator group은 숨기고 최대 5개 장르 group만 표시한다.
|
- 현재 미사용: 장르 group 표시/최대 개수 정책은 추천 탭 UI에 적용하지 않는다.
|
||||||
- 검증: `HomeMainFragmentLayoutTest`에 genre page 치수/gradient/grid/snap/helper/filtering 회귀 테스트를 추가한다.
|
- 검증: 현재 정책에서는 `HomeMainFragmentLayoutTest`에서 `genreCreators` 섹션 기본 숨김, 바인딩 시 `GONE`, hidden section 제외 empty 판정을 검증한다.
|
||||||
|
|
||||||
- [x] **Task 6.13: 최근 응원이 많은 크리에이터 Figma 정합 추가 수정**
|
- [x] **Task 6.13: 최근 응원이 많은 크리에이터 Figma 정합 추가 수정**
|
||||||
- 기준: Figma `24:5636`
|
- 기준: Figma `24:5636`
|
||||||
@@ -675,6 +677,7 @@
|
|||||||
- 2026-06-02: `AudioContentCardView`와 `item_home_first_audio_content.xml`의 badge별 제어를 위해 `Original`/`First`/`Point`/`Free` badge에 안정적인 id를 추가했다. `AudioContentCardView`의 runtime 생성 badge에도 동일 id를 부여하고, `First`와 `Free` badge 높이를 `24dp`로 유지하도록 맞췄다. `HomeMainFragmentLayoutTest`에서 badge id/높이 계약을 검증하도록 보강했으며 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.home.HomeMainFragmentLayoutTest"`, `./gradlew :app:mergeDebugResources`, `./gradlew :app:compileDebugKotlin`, `./gradlew :app:ktlintCheck`, `./gradlew :app:testDebugUnitTest`를 실행해 모두 BUILD SUCCESSFUL을 확인했다.
|
- 2026-06-02: `AudioContentCardView`와 `item_home_first_audio_content.xml`의 badge별 제어를 위해 `Original`/`First`/`Point`/`Free` badge에 안정적인 id를 추가했다. `AudioContentCardView`의 runtime 생성 badge에도 동일 id를 부여하고, `First`와 `Free` badge 높이를 `24dp`로 유지하도록 맞췄다. `HomeMainFragmentLayoutTest`에서 badge id/높이 계약을 검증하도록 보강했으며 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.home.HomeMainFragmentLayoutTest"`, `./gradlew :app:mergeDebugResources`, `./gradlew :app:compileDebugKotlin`, `./gradlew :app:ktlintCheck`, `./gradlew :app:testDebugUnitTest`를 실행해 모두 BUILD SUCCESSFUL을 확인했다.
|
||||||
- 2026-06-02: Figma `24:5551` 기준 `크리에이터와 이야기를 나눠요!` 섹션을 확인해 기존 목록 padding `20dp`, adapter item gap `12dp`, generic `RecyclerView.LayoutParams(WRAP_CONTENT, WRAP_CONTENT)` 적용이 `view_character_chat_thumbnail.xml`의 root `185dp x wrap_content` 계약을 보존하지 못함을 확인했다. `HomeMainFragmentLayoutTest`에 character thumbnail root 치수, AI 캐릭터 목록 spacing, adapter item dimension 회귀 테스트를 먼저 추가했고, 수정 전 targeted test에서 새 테스트 2건 실패로 RED를 확인했다.
|
- 2026-06-02: Figma `24:5551` 기준 `크리에이터와 이야기를 나눠요!` 섹션을 확인해 기존 목록 padding `20dp`, adapter item gap `12dp`, generic `RecyclerView.LayoutParams(WRAP_CONTENT, WRAP_CONTENT)` 적용이 `view_character_chat_thumbnail.xml`의 root `185dp x wrap_content` 계약을 보존하지 못함을 확인했다. `HomeMainFragmentLayoutTest`에 character thumbnail root 치수, AI 캐릭터 목록 spacing, adapter item dimension 회귀 테스트를 먼저 추가했고, 수정 전 targeted test에서 새 테스트 2건 실패로 RED를 확인했다.
|
||||||
- 2026-06-02: `rv_home_ai_characters` padding을 `14dp`로 맞추고, `HomeAiCharacterAdapter`에서 inflate된 `view_character_chat_thumbnail.xml` root layout params를 유지한 채 `marginEnd`만 `4dp`로 적용했다. 수정 후 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.home.HomeMainFragmentLayoutTest"`, `./gradlew :app:mergeDebugResources`, `./gradlew :app:compileDebugKotlin`, `./gradlew :app:ktlintCheck`를 실행했고 모두 BUILD SUCCESSFUL을 확인했다. `lsp_diagnostics`는 Kotlin/XML LSP 미구성으로 실행하지 못해 Gradle compile/test/ktlint로 보완했다.
|
- 2026-06-02: `rv_home_ai_characters` padding을 `14dp`로 맞추고, `HomeAiCharacterAdapter`에서 inflate된 `view_character_chat_thumbnail.xml` root layout params를 유지한 채 `marginEnd`만 `4dp`로 적용했다. 수정 후 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.home.HomeMainFragmentLayoutTest"`, `./gradlew :app:mergeDebugResources`, `./gradlew :app:compileDebugKotlin`, `./gradlew :app:ktlintCheck`를 실행했고 모두 BUILD SUCCESSFUL을 확인했다. `lsp_diagnostics`는 Kotlin/XML LSP 미구성으로 실행하지 못해 Gradle compile/test/ktlint로 보완했다.
|
||||||
|
- 2026-07-12 현재 정책: 아래 2026-06-04 장르 크리에이터 UI/모두 팔로우/grid/snapping 관련 검증 기록은 과거 구현 이력으로만 유지한다. Phase 14 이후 `genreCreators` 섹션은 추천 탭에 표시하지 않고, follow-all API 호출과 완료 상태 갱신도 사용하지 않는다.
|
||||||
- 2026-06-04: Figma `24:5611`, 완료 버튼 `24:9092` 기준 장르 크리에이터 모두 팔로우 버튼을 확인해 기본 상태는 `ic_new_follow`, 투명 배경과 흰색 30% stroke, 흰색 텍스트이고 완료 상태는 `ic_new_following`, 흰색 배경, 검은색 텍스트여야 함을 확인했다. `HomeMainFragmentLayoutTest`에 모두 팔로우 기본/완료 상태와 완료 상태 no-op 회귀 테스트를 먼저 추가했고, 구현 전 `bg_home_follow_all_button` 리소스 미존재로 RED 컴파일 실패를 확인했다.
|
- 2026-06-04: Figma `24:5611`, 완료 버튼 `24:9092` 기준 장르 크리에이터 모두 팔로우 버튼을 확인해 기본 상태는 `ic_new_follow`, 투명 배경과 흰색 30% stroke, 흰색 텍스트이고 완료 상태는 `ic_new_following`, 흰색 배경, 검은색 텍스트여야 함을 확인했다. `HomeMainFragmentLayoutTest`에 모두 팔로우 기본/완료 상태와 완료 상태 no-op 회귀 테스트를 먼저 추가했고, 구현 전 `bg_home_follow_all_button` 리소스 미존재로 RED 컴파일 실패를 확인했다.
|
||||||
- 2026-06-04: `bg_home_follow_all_button.xml`을 추가하고 `view_home_follow_all_button.xml`의 기본 상태를 Figma 활성 상태로 변경했다. `HomeFollowAllButtonBinder`는 완료 상태에서 `bg_round_corner_999_white`, 검은 텍스트, `ic_new_following`, `모두 팔로우 완료`를 적용하고 터치 시 callback을 호출하지 않도록 수정했다. 이후 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.home.HomeMainFragmentLayoutTest"`, `./gradlew :app:mergeDebugResources`, `./gradlew :app:compileDebugKotlin`, `./gradlew :app:ktlintCheck`, `./gradlew :app:testDebugUnitTest`를 실행했고 모두 BUILD SUCCESSFUL을 확인했다. `./gradlew :app:installDebug`는 연결된 기기가 없어 `No connected devices!`로 실패해 실기기 설치 확인은 수행하지 못했다.
|
- 2026-06-04: `bg_home_follow_all_button.xml`을 추가하고 `view_home_follow_all_button.xml`의 기본 상태를 Figma 활성 상태로 변경했다. `HomeFollowAllButtonBinder`는 완료 상태에서 `bg_round_corner_999_white`, 검은 텍스트, `ic_new_following`, `모두 팔로우 완료`를 적용하고 터치 시 callback을 호출하지 않도록 수정했다. 이후 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.home.HomeMainFragmentLayoutTest"`, `./gradlew :app:mergeDebugResources`, `./gradlew :app:compileDebugKotlin`, `./gradlew :app:ktlintCheck`, `./gradlew :app:testDebugUnitTest`를 실행했고 모두 BUILD SUCCESSFUL을 확인했다. `./gradlew :app:installDebug`는 연결된 기기가 없어 `No connected devices!`로 실패해 실기기 설치 확인은 수행하지 못했다.
|
||||||
- 2026-06-04: 사용자 추가 요청에 따라 Figma `24:5611` 기준 `장르의 크리에이터` 섹션을 그룹 단위 snapping card 구조로 수정했다. 구현 전 `HomeMainFragmentLayoutTest`에 genre group card/profile/snapping/filtering/follow callback 회귀 테스트를 추가했고, 기존 구현에서 `item_home_genre_creator_group`, `bg_home_genre_creator_group`, `submitGroups` 등 새 계약 미존재로 RED 컴파일 실패를 확인했다. 이후 `HomeGenreCreatorAdapter`를 group page adapter로 변경하고, `HomePagerSnapRecyclerView`, `item_home_genre_creator_group.xml`, `item_home_genre_creator_profile.xml`, `bg_home_genre_creator_group.xml`을 추가해 각 page에 장르 제목, 8명 grid, 모두 팔로우 버튼, dark-blue gradient, `PagerSnapHelper` snapping을 적용했다. targeted 테스트 최초 재실행에서는 null image가 Coil `ImageLoaderProvider`를 요구해 실패했고, nullable image는 drawable을 명시적으로 비우도록 수정한 뒤 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.home.HomeMainFragmentLayoutTest"`가 BUILD SUCCESSFUL임을 확인했다. 추가 검증으로 `./gradlew :app:mergeDebugResources`, `./gradlew :app:compileDebugKotlin`, `./gradlew :app:ktlintCheck`, `./gradlew :app:testDebugUnitTest`를 실행했고 모두 BUILD SUCCESSFUL을 확인했다. ktlint 최초 실행은 테스트 파일 긴 줄 2곳으로 실패했고 줄바꿈 수정 후 재실행으로 성공했다. `lsp_diagnostics`는 Kotlin/XML LSP 미구성으로 실행하지 못해 Gradle compile/test/ktlint로 보완했다. 실기기 수동 확인은 연결 기기가 없어 수행하지 못했다.
|
- 2026-06-04: 사용자 추가 요청에 따라 Figma `24:5611` 기준 `장르의 크리에이터` 섹션을 그룹 단위 snapping card 구조로 수정했다. 구현 전 `HomeMainFragmentLayoutTest`에 genre group card/profile/snapping/filtering/follow callback 회귀 테스트를 추가했고, 기존 구현에서 `item_home_genre_creator_group`, `bg_home_genre_creator_group`, `submitGroups` 등 새 계약 미존재로 RED 컴파일 실패를 확인했다. 이후 `HomeGenreCreatorAdapter`를 group page adapter로 변경하고, `HomePagerSnapRecyclerView`, `item_home_genre_creator_group.xml`, `item_home_genre_creator_profile.xml`, `bg_home_genre_creator_group.xml`을 추가해 각 page에 장르 제목, 8명 grid, 모두 팔로우 버튼, dark-blue gradient, `PagerSnapHelper` snapping을 적용했다. targeted 테스트 최초 재실행에서는 null image가 Coil `ImageLoaderProvider`를 요구해 실패했고, nullable image는 drawable을 명시적으로 비우도록 수정한 뒤 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.home.HomeMainFragmentLayoutTest"`가 BUILD SUCCESSFUL임을 확인했다. 추가 검증으로 `./gradlew :app:mergeDebugResources`, `./gradlew :app:compileDebugKotlin`, `./gradlew :app:ktlintCheck`, `./gradlew :app:testDebugUnitTest`를 실행했고 모두 BUILD SUCCESSFUL을 확인했다. ktlint 최초 실행은 테스트 파일 긴 줄 2곳으로 실패했고 줄바꿈 수정 후 재실행으로 성공했다. `lsp_diagnostics`는 Kotlin/XML LSP 미구성으로 실행하지 못해 Gradle compile/test/ktlint로 보완했다. 실기기 수동 확인은 연결 기기가 없어 수행하지 못했다.
|
||||||
@@ -734,5 +737,65 @@
|
|||||||
- 수정: `app/src/main/res/values/strings.xml`, `values-en/strings.xml`, `values-ja/strings.xml`
|
- 수정: `app/src/main/res/values/strings.xml`, `values-en/strings.xml`, `values-ja/strings.xml`
|
||||||
- 검증 기록: 2026-06-29 `ContentOverviewType.FIRST_AUDIO_CONTENT`와 `home_recommendation_section_first_audio_contents` production 참조를 제거하고, `NEW_AND_HOT_AUDIO` 경로는 유지했다.
|
- 검증 기록: 2026-06-29 `ContentOverviewType.FIRST_AUDIO_CONTENT`와 `home_recommendation_section_first_audio_contents` production 참조를 제거하고, `NEW_AND_HOT_AUDIO` 경로는 유지했다.
|
||||||
|
|
||||||
|
### Phase 13: 커뮤니티 상세 라우팅 변경
|
||||||
|
|
||||||
|
- [x] **Task 13.1: 방금 활동한 크리에이터 COMMUNITY 라우팅 변경**
|
||||||
|
- 수정: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/model/HomeRecommendationUiModels.kt`
|
||||||
|
- 수정: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/home/HomeMainFragmentLayoutTest.kt`
|
||||||
|
- 구현 내용:
|
||||||
|
- `CreatorActivityType.Community` route의 `targetId`를 커뮤니티 게시글 `postId`로 해석한다.
|
||||||
|
- `CreatorActivityType.Community` route intent는 `CreatorChannelCommunityDetailActivity.newIntent(context, postId)`로 생성한다.
|
||||||
|
- `targetId`가 없거나 0 이하이면 기존처럼 이동하지 않는다.
|
||||||
|
- 검증:
|
||||||
|
- `HomeMainFragmentLayoutTest`에서 COMMUNITY route intent component와 `extra_post_id` 전달을 확인한다.
|
||||||
|
- 검증 기록:
|
||||||
|
- 2026-07-10: `HomeMainFragmentLayoutTest`의 최근 활동 COMMUNITY intent 기대값을 `CreatorChannelCommunityDetailActivity`와 `extra_post_id`로 먼저 변경해 기존 `CreatorCommunityAllActivity` 구현에서 RED 실패를 확인했다. 이후 `HomeRecommendationRecentlyActiveCreatorRoute.Community`의 `targetId`를 `postId`로 해석하고 `CreatorChannelCommunityDetailActivity.newIntent(context, postId)`를 생성하도록 수정했다.
|
||||||
|
|
||||||
|
- [x] **Task 13.2: 인기 커뮤니티 item 라우팅 변경**
|
||||||
|
- 수정: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/HomeMainFragment.kt`
|
||||||
|
- 수정: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/home/HomeMainFragmentLoginGuardSourceTest.kt`
|
||||||
|
- 구현 내용:
|
||||||
|
- 인기 커뮤니티 item 클릭 시 `FeedItem.Community.postId`를 사용해 `CreatorChannelCommunityDetailActivity`로 이동한다.
|
||||||
|
- 기존 `CreatorCommunityAllActivity`와 `EXTRA_COMMUNITY_CREATOR_ID`, `EXTRA_COMMUNITY_POST_ID`, `EXTRA_COMMUNITY_EXIST_ORDERED` 전달을 제거한다.
|
||||||
|
- `postId`가 없거나 숫자로 변환되지 않으면 기존처럼 이동하지 않는다.
|
||||||
|
- 검증:
|
||||||
|
- `HomeMainFragmentLoginGuardSourceTest`에서 invalid `postId` return이 로그인 가드보다 먼저 유지되는지 확인한다.
|
||||||
|
- `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.home.HomeMainFragmentLayoutTest.home recent activity route creates expected intents" --tests "kr.co.vividnext.sodalive.v2.main.home.HomeMainFragmentLoginGuardSourceTest"`를 실행한다.
|
||||||
|
- 검증 기록:
|
||||||
|
- 2026-07-10: `HomeMainFragmentLoginGuardSourceTest`에 인기 커뮤니티가 `CreatorChannelCommunityDetailActivity.newIntent(requireContext(), postId)`를 사용하고 기존 `CreatorCommunityAllActivity`/커뮤니티 목록 extra를 쓰지 않는 source 검증을 추가했다. 기존 구현에서 RED 실패를 확인한 뒤 `HomeMainFragment.openPopularCommunityPost()`가 `postId`만 검증하고 상세 Activity를 열도록 수정했다.
|
||||||
|
- 2026-07-10: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.home.HomeMainFragmentLayoutTest.home recent activity route creates expected intents" --tests "kr.co.vividnext.sodalive.v2.main.home.HomeMainFragmentLoginGuardSourceTest"` 실행 결과 BUILD SUCCESSFUL을 확인했다.
|
||||||
|
|
||||||
|
### Phase 14: `genreCreators` UI 미표시
|
||||||
|
|
||||||
|
- [x] **Task 14.1: 추천 탭 장르별 크리에이터 섹션 숨김**
|
||||||
|
- 수정: `app/src/main/res/layout/fragment_v2_main_home.xml`
|
||||||
|
- 수정: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/HomeMainFragment.kt`
|
||||||
|
- 수정: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/home/HomeMainFragmentLayoutTest.kt`
|
||||||
|
- 구현 내용:
|
||||||
|
- `ll_home_genre_creator_section`의 기본 visibility를 `gone`으로 둔다.
|
||||||
|
- `bindGenreCreatorSection()`은 API 응답 데이터와 무관하게 섹션을 `GONE`으로 유지하고 adapter에는 빈 그룹을 제출한다.
|
||||||
|
- `genreCreators` DTO/API/UI model/mapper 계약은 유지하고, `cheerCreators` 섹션은 변경하지 않는다.
|
||||||
|
- 검증 기록:
|
||||||
|
- 2026-07-12: `HomeMainFragmentLayoutTest`에 `home recommendation does not display genre creator section`을 먼저 추가해 기존 구현에서 `HomeMainFragmentLayoutTest.kt:152` assertion 실패로 RED를 확인했다. 이후 XML 기본 숨김과 Fragment 바인딩의 `View.GONE`/`emptyList()` 제출을 적용했고 동일 테스트가 BUILD SUCCESSFUL로 GREEN 전환됨을 확인했다.
|
||||||
|
|
||||||
|
### Phase 15: 추천 섹션 타이틀 chevron 숨김
|
||||||
|
|
||||||
|
- [x] **Task 15.1: 최근 데뷔와 AI 캐릭터 섹션 chevron 숨김**
|
||||||
|
- 수정: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/HomeMainFragment.kt`
|
||||||
|
- 수정: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/home/HomeMainFragmentSourceTest.kt`
|
||||||
|
- 구현 내용:
|
||||||
|
- `최근 데뷔한 크리에이터` 섹션 타이틀은 `view_section_title` chevron을 표시하지 않는다.
|
||||||
|
- `크리에이터와 이야기를 나눠요!` 섹션 타이틀은 `view_section_title` chevron을 표시하지 않는다.
|
||||||
|
- 섹션 목록 표시와 item 터치 동작, 다른 섹션의 chevron 정책은 변경하지 않는다.
|
||||||
|
- 검증:
|
||||||
|
- `HomeMainFragmentSourceTest`에서 두 섹션 title 설정이 `showMore = true`를 사용하지 않는지 확인한다.
|
||||||
|
- `HomeMainFragmentLayoutTest` 전체로 홈 추천 인접 layout 계약을 확인한다.
|
||||||
|
- 검증 기록:
|
||||||
|
- 2026-07-12: `HomeMainFragmentSourceTest`에 `홈 추천 최근 데뷔와 AI 캐릭터 섹션 타이틀은 chevron을 숨긴다`를 먼저 추가해 기존 구현에서 `HomeMainFragmentSourceTest.kt:40` assertion 실패로 RED를 확인했다. 이후 `HomeMainFragment.setUpSectionTitles()`에서 `viewHomeRecentDebutTitle`, `viewHomeAiCharacterTitle`의 `showMore = true`를 제거해 기본 `false` 정책을 적용했다.
|
||||||
|
|
||||||
## Verification Log
|
## Verification Log
|
||||||
|
- 2026-07-12: Phase 15 추천 섹션 타이틀 chevron 숨김 변경 검증으로 `HomeMainFragmentSourceTest`에 `홈 추천 최근 데뷔와 AI 캐릭터 섹션 타이틀은 chevron을 숨긴다`를 추가했고, 구현 전 `HomeMainFragmentSourceTest.kt:40` assertion 실패로 RED를 확인했다. `HomeMainFragment.setUpSectionTitles()`에서 `viewHomeRecentDebutTitle`, `viewHomeAiCharacterTitle`의 `showMore = true`를 제거한 뒤 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.home.HomeMainFragmentSourceTest" --tests "kr.co.vividnext.sodalive.v2.main.home.HomeMainFragmentLayoutTest" --tests "kr.co.vividnext.sodalive.v2.main.home.HomeFollowingFragmentSourceTest"`, `./gradlew :app:mergeDebugResources :app:compileDebugKotlin :app:ktlintCheck`, `git diff --check`가 통과했다. `ktlintCheck`의 `.editorconfig disabled_rules` deprecation warning은 기존 설정 경고다. `./gradlew :app:testDebugUnitTest` 전체 실행은 `CreatorChannelActivitySourceTest > 팬Talk 섹션은 Figma ListComment layout과 bind로 렌더링한다`, `CreatorChannelHomeViewModelTest > 채널 후원 성공은 기존 후원 API를 호출하고 홈을 다시 로드한다` 실패 후 타임아웃됐으며, 실패 영역은 이번 변경 파일과 무관한 `v2.creator.channel` 범위다. `adb devices`는 처음 1대를 표시했으나 `:app:installDebug` 시점에는 `No connected devices!`로 실패했고 이후 `adb devices`가 빈 목록을 반환해 실기기 수동 확인은 수행하지 못했다.
|
||||||
|
- 2026-07-12: `genreCreators` UI 미표시 변경 검증으로 targeted RED/GREEN 테스트를 수행했다. 리뷰에서 PRD 앞부분의 장르별 크리에이터 표시/모두 팔로우 문구와 `HomeRecommendationUiState.Content.isEmpty`의 `genreCreators.groups` 포함이 차단 이슈로 확인되어, PRD 문구를 미표시 정책으로 정리하고 `isEmpty`는 실제 표시 섹션만 기준으로 판단하도록 수정했다. `home recommendation empty state ignores hidden genre creators` 테스트는 수정 전 `HomeMainFragmentLayoutTest.kt:180` assertion 실패로 RED를 확인했고, 수정 후 GREEN 전환을 확인했다. 추가 재검토에서 plan-task 과거 Task 6.4/6.12와 코드/테스트에 남은 `genreCreators` follow-all 및 pager UI 활성 계약이 차단 이슈로 확인되어, 과거 Task 문구는 현재 미사용 정책으로 정리하고 `HomeMainFragment`의 genre follow-all 연결, `HomeRecommendationViewModel`의 genre follow 완료 상태 갱신, 활성 UI/follow-all 테스트 계약을 제거했다. 이후 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.home.HomeMainFragmentLayoutTest" :app:mergeDebugResources :app:compileDebugKotlin :app:ktlintCheck`를 실행해 BUILD SUCCESSFUL을 확인했다. `ktlintCheck` 최초 재실행은 테스트 파일 불필요한 빈 줄로 실패했고 해당 줄만 제거한 뒤 재실행해 성공했다. `git diff --check` 출력 없음도 확인했다. `ktlintCheck`에서는 기존 `.editorconfig disabled_rules` deprecation warning만 출력됐고 lint 실패는 없었다. `adb devices` 결과 연결된 기기/에뮬레이터가 없어 실기기 수동 확인은 수행하지 못했다.
|
||||||
|
- 2026-07-10: Phase 13 코드 리뷰 및 검증을 재수행했다. 리뷰 범위는 최근 활동 COMMUNITY route가 `targetId`를 `postId`로 해석해 `CreatorChannelCommunityDetailActivity.newIntent(context, postId)`를 생성하는지, 인기 커뮤니티 item이 `FeedItem.Community.postId` 기반으로 상세 Activity를 여는지, 기존 `CreatorCommunityAllActivity`/커뮤니티 목록 extra 제거 여부, 로그인 가드 전 invalid return 유지 여부다. 코드 리뷰 결과 blocking finding은 없었다. 검증으로 Phase 13 targeted test, `HomeMainFragmentLayoutTest` 전체, `./gradlew :app:compileDebugKotlin`, `./gradlew :app:ktlintCheck`, `git diff --cached --check`가 모두 통과했고, `rg`로 v2 home production 코드에 기존 커뮤니티 목록 라우팅 참조가 남지 않았음을 확인했다.
|
||||||
|
- 2026-07-10: 커뮤니티 상세 라우팅 변경 회귀 검증으로 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.home.HomeMainFragmentLayoutTest"`, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.home.HomeMainFragmentLoginGuardSourceTest"`, `./gradlew :app:compileDebugKotlin`, `./gradlew :app:ktlintCheck`, `git diff --check`를 실행했고 모두 통과했다. `ktlintCheck` 최초 실행은 이번에 수정한 source test 파일의 기존 긴 assertion 줄 때문에 실패했으며, 동작 변경 없이 줄바꿈과 긴 문자열 분리만 적용한 뒤 재실행해 BUILD SUCCESSFUL을 확인했다. `rg`로 v2 home production 코드에 `CreatorCommunityAllActivity`와 기존 커뮤니티 전체목록 extra 참조가 남지 않았고, 해당 문자열은 금지 조건 source test에만 남아 있음을 확인했다.
|
||||||
- 2026-06-29: `처음부터 함께 성장!` 제거 회귀 검증으로 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.home.*" --tests "kr.co.vividnext.sodalive.v2.main.content.overview.*" :app:mergeDebugResources :app:compileDebugKotlin :app:ktlintCheck`를 실행했고 BUILD SUCCESSFUL을 확인했다. `git diff --check` 출력 없음도 확인했다. Android 기기/에뮬레이터 접근이 없어 실제 화면 조작은 수행하지 못했으며, `rg`로 `app/src/main`의 first audio/FIRST_AUDIO_CONTENT production 참조 제거를 확인했다. 리뷰 게이트에서 차단 이슈 없이 승인받았다.
|
- 2026-06-29: `처음부터 함께 성장!` 제거 회귀 검증으로 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.home.*" --tests "kr.co.vividnext.sodalive.v2.main.content.overview.*" :app:mergeDebugResources :app:compileDebugKotlin :app:ktlintCheck`를 실행했고 BUILD SUCCESSFUL을 확인했다. `git diff --check` 출력 없음도 확인했다. Android 기기/에뮬레이터 접근이 없어 실제 화면 조작은 수행하지 못했으며, `rg`로 `app/src/main`의 first audio/FIRST_AUDIO_CONTENT production 참조 제거를 확인했다. 리뷰 게이트에서 차단 이슈 없이 승인받았다.
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ Figma `home_001` 화면(`24:5514`)을 기준으로 메인 홈 추천 영역을
|
|||||||
---
|
---
|
||||||
|
|
||||||
## 2. Problem
|
## 2. Problem
|
||||||
- 메인 홈 추천 화면은 라이브, 배너, 최근 활동, 신규 데뷔, 첫 오디오 콘텐츠, AI 캐릭터, 장르별 크리에이터, 응원 크리에이터, 인기 커뮤니티 등 여러 데이터 섹션을 한 화면에 표시해야 한다.
|
- 메인 홈 추천 화면은 라이브, 배너, 최근 활동, 신규 데뷔, 첫 오디오 콘텐츠, AI 캐릭터, 응원 크리에이터, 인기 커뮤니티 등 여러 데이터 섹션을 한 화면에 표시해야 한다. `genreCreators` 응답 계약은 유지하지만 장르별 크리에이터 UI는 추천 탭에 표시하지 않는다.
|
||||||
- Figma에는 이미 `live`, `banner`, `section-title`, `contents`, `chat-thumbnail`, `profile`, `feed` 등 재사용 가능한 형태가 다수 포함되어 있으므로, 기존에 생성된 widget을 최대한 활용해야 한다.
|
- Figma에는 이미 `live`, `banner`, `section-title`, `contents`, `chat-thumbnail`, `profile`, `feed` 등 재사용 가능한 형태가 다수 포함되어 있으므로, 기존에 생성된 widget을 최대한 활용해야 한다.
|
||||||
- `HomeActiveCreatorItem.activityType`은 백엔드 enum code 그대로 표시하면 사용자에게 부자연스러우므로 `LIVE`, `LIVE_REPLAY`, `AUDIO`, `COMMUNITY`를 다국어 처리 가능한 표시 문자열로 변환해야 한다.
|
- `HomeActiveCreatorItem.activityType`은 백엔드 enum code 그대로 표시하면 사용자에게 부자연스러우므로 `LIVE`, `LIVE_REPLAY`, `AUDIO`, `COMMUNITY`를 다국어 처리 가능한 표시 문자열로 변환해야 한다.
|
||||||
- 사업자 정보 섹션은 별도 외부 라이브러리 없이 최대 3줄 말줄임과 더보기/접기 토글을 제공해야 한다.
|
- 사업자 정보 섹션은 별도 외부 라이브러리 없이 최대 3줄 말줄임과 더보기/접기 토글을 제공해야 한다.
|
||||||
@@ -172,7 +172,7 @@ Figma 최하단 사업자 정보 섹션(`218:2058`)은 외부 라이브러리
|
|||||||
- 세로 스크롤 시 title-bar와 `TextTabBarView`는 화면에 유지하고, `TextTabBarView` 아래 추천 content 영역만 스크롤되도록 구성한다.
|
- 세로 스크롤 시 title-bar와 `TextTabBarView`는 화면에 유지하고, `TextTabBarView` 아래 추천 content 영역만 스크롤되도록 구성한다.
|
||||||
|
|
||||||
### 모두 팔로우 하기
|
### 모두 팔로우 하기
|
||||||
`최근 응원이 많은 크리에이터`, `장르별 크리에이터` 섹션에는 모두 팔로우 버튼을 제공한다.
|
`최근 응원이 많은 크리에이터` 섹션에는 모두 팔로우 버튼을 제공한다. `장르별 크리에이터`는 추천 탭 UI에 표시하지 않으므로 모두 팔로우 버튼도 제공하지 않는다.
|
||||||
|
|
||||||
#### API Requirements
|
#### API Requirements
|
||||||
- API는 `POST /api/v2/home/recommendations/creators/follow`를 사용한다.
|
- API는 `POST /api/v2/home/recommendations/creators/follow`를 사용한다.
|
||||||
@@ -189,9 +189,9 @@ data class FollowRecommendedCreatorsRequest(
|
|||||||
```
|
```
|
||||||
|
|
||||||
#### Request Requirements
|
#### Request Requirements
|
||||||
- `creatorIds`에는 사용자가 터치한 `모두 팔로우 하기` 버튼이 속한 섹션의 크리에이터 id 목록을 전달한다.
|
- `creatorIds`에는 사용자가 터치한 `모두 팔로우 하기` 버튼이 속한 표시 섹션의 크리에이터 id 목록을 전달한다.
|
||||||
- `최근 응원이 많은 크리에이터` 섹션에서는 `cheerCreators.creatorId` 목록을 전달한다.
|
- `최근 응원이 많은 크리에이터` 섹션에서는 `cheerCreators.creatorId` 목록을 전달한다.
|
||||||
- `장르별 크리에이터` 섹션에서는 해당 장르 그룹의 `creators.creatorId` 목록을 전달한다.
|
- `장르별 크리에이터` 섹션은 추천 탭 UI에 표시하지 않으므로 해당 장르 그룹의 `creators.creatorId` 목록을 전달하지 않는다.
|
||||||
- `creatorIds`가 빈 리스트인 경우 API를 호출하지 않고 버튼 상태를 변경하지 않는다.
|
- `creatorIds`가 빈 리스트인 경우 API를 호출하지 않고 버튼 상태를 변경하지 않는다.
|
||||||
|
|
||||||
#### Button Requirements
|
#### Button Requirements
|
||||||
@@ -262,9 +262,7 @@ HomeRecommendation 화면
|
|||||||
│ ├─ 재사용: view_section_title
|
│ ├─ 재사용: view_section_title
|
||||||
│ └─ 재사용: v2.widget.characterchatthumbnail.CharacterChatThumbnailView
|
│ └─ 재사용: v2.widget.characterchatthumbnail.CharacterChatThumbnailView
|
||||||
├─ 장르별 크리에이터: genreCreators
|
├─ 장르별 크리에이터: genreCreators
|
||||||
│ ├─ Figma instance: section-title, profile, button-capsule
|
│ └─ 미표시: 응답/매핑 계약은 유지하되 추천 탭 UI에는 표시하지 않음
|
||||||
│ ├─ 재사용: view_section_title
|
|
||||||
│ └─ 신규: 장르 그룹 카드, 2행 profile grid, 모두 팔로우 버튼
|
|
||||||
├─ 추천 필모그래피
|
├─ 추천 필모그래피
|
||||||
│ └─ 제외: 만들지 않음
|
│ └─ 제외: 만들지 않음
|
||||||
├─ 최근 응원이 많은 크리에이터: cheerCreators
|
├─ 최근 응원이 많은 크리에이터: cheerCreators
|
||||||
@@ -288,7 +286,7 @@ HomeRecommendation 화면
|
|||||||
| `recentlyActiveCreators` | `24:5529` | 방금 활동한 크리에이터 | `view_section_title` | `list-act` 신규 필요 |
|
| `recentlyActiveCreators` | `24:5529` | 방금 활동한 크리에이터 | `view_section_title` | `list-act` 신규 필요 |
|
||||||
| `recentDebutCreators` | `24:5534` | 최근 데뷔한 크리에이터 | `view_section_title` | 신규 카드 필요 |
|
| `recentDebutCreators` | `24:5534` | 최근 데뷔한 크리에이터 | `view_section_title` | 신규 카드 필요 |
|
||||||
| `aiCharacters` | `24:5551` | AI 캐릭터 | `view_section_title`, `CharacterChatThumbnailView` | DTO mapping 필요 |
|
| `aiCharacters` | `24:5551` | AI 캐릭터 | `view_section_title`, `CharacterChatThumbnailView` | DTO mapping 필요 |
|
||||||
| `genreCreators` | `24:5611`, `24:5636` 후보 | 장르별 크리에이터 | `view_section_title` | 그룹 카드, 모두 팔로우 버튼 신규 필요 |
|
| `genreCreators` | `24:5611`, `24:5636` 후보 | 미표시 | 없음 | API/DTO/UI model 계약만 유지 |
|
||||||
| `cheerCreators` | `24:5636` 후보 | 최근 응원이 많은 크리에이터 | `view_section_title` | profile grid, 모두 팔로우 버튼 신규 필요 |
|
| `cheerCreators` | `24:5636` 후보 | 최근 응원이 많은 크리에이터 | `view_section_title` | profile grid, 모두 팔로우 버튼 신규 필요 |
|
||||||
| `popularCommunityPosts` | `24:5645`, `309:19774`, `309:19775` | 인기 커뮤니티 | `view_section_title`, `FeedCommunityView` | 이미지/유료 상태 UI 수정 필요 |
|
| `popularCommunityPosts` | `24:5645`, `309:19774`, `309:19775` | 인기 커뮤니티 | `view_section_title`, `FeedCommunityView` | 이미지/유료 상태 UI 수정 필요 |
|
||||||
| 사업자 정보 | `218:2058` | 사업자 정보 | 없음 | 접기/더보기 신규 필요 |
|
| 사업자 정보 | `218:2058` | 사업자 정보 | 없음 | 접기/더보기 신규 필요 |
|
||||||
@@ -323,7 +321,7 @@ HomeRecommendation 화면
|
|||||||
- 상단 라이브 목록, 배너, 추천 카드들은 어두운 홈 배경 위에서 기존 v2 widget 색상/typography/radius를 유지한다.
|
- 상단 라이브 목록, 배너, 추천 카드들은 어두운 홈 배경 위에서 기존 v2 widget 색상/typography/radius를 유지한다.
|
||||||
- 각 섹션 제목은 기존 `view_section_title`을 사용한다.
|
- 각 섹션 제목은 기존 `view_section_title`을 사용한다.
|
||||||
- `최근 데뷔한 크리에이터` item은 기존 `205dp x 259dp` 비율을 유지해 `185dp x 234dp`로 축소하고, 이미지뿐 아니라 dim gradient와 닉네임을 포함한 item root 전체가 `14dp` rounded corner로 잘려야 한다.
|
- `최근 데뷔한 크리에이터` item은 기존 `205dp x 259dp` 비율을 유지해 `185dp x 234dp`로 축소하고, 이미지뿐 아니라 dim gradient와 닉네임을 포함한 item root 전체가 `14dp` rounded corner로 잘려야 한다.
|
||||||
- `최근 응원이 많은 크리에이터`, `장르별 크리에이터`의 모두 팔로우 버튼은 success 전/후 상태가 명확히 구분되어야 한다.
|
- `최근 응원이 많은 크리에이터`의 모두 팔로우 버튼은 success 전/후 상태가 명확히 구분되어야 한다. `장르별 크리에이터`는 추천 탭 UI에 표시하지 않으므로 버튼도 노출하지 않는다.
|
||||||
- 인기 커뮤니티는 keyword 없이 본문, 선택적 이미지, 유료 잠금 상태, reaction row를 보여야 한다.
|
- 인기 커뮤니티는 keyword 없이 본문, 선택적 이미지, 유료 잠금 상태, reaction row를 보여야 한다.
|
||||||
- 유료 미구매 커뮤니티 이미지는 내용을 바로 읽을 수 없도록 blur/lock overlay가 적용되어야 한다.
|
- 유료 미구매 커뮤니티 이미지는 내용을 바로 읽을 수 없도록 blur/lock overlay가 적용되어야 한다.
|
||||||
- 유료 구매 완료 또는 무료 커뮤니티 이미지는 overlay 없이 표시하고, `구매완료` 태그는 표시하지 않는다.
|
- 유료 구매 완료 또는 무료 커뮤니티 이미지는 overlay 없이 표시하고, `구매완료` 태그는 표시하지 않는다.
|
||||||
@@ -361,7 +359,7 @@ HomeRecommendation 화면
|
|||||||
- `AudioContentCardView`에서 `ic_content_tag_point`, `무료`, `FIRST` 태그가 조건에 맞게 표시되고, `ic_content_tag_original`은 표시되지 않는다.
|
- `AudioContentCardView`에서 `ic_content_tag_point`, `무료`, `FIRST` 태그가 조건에 맞게 표시되고, `ic_content_tag_original`은 표시되지 않는다.
|
||||||
- `HomeAiCharacterItem.creatorId`가 UI model까지 보존되고, AI 캐릭터 item 터치 시 `CreatorChannelActivity`에 `creatorId`가 전달된다.
|
- `HomeAiCharacterItem.creatorId`가 UI model까지 보존되고, AI 캐릭터 item 터치 시 `CreatorChannelActivity`에 `creatorId`가 전달된다.
|
||||||
- 제외 대상인 `추천 필모그래피`, `또 다른 모습` 섹션이 화면에 생성되지 않는다.
|
- 제외 대상인 `추천 필모그래피`, `또 다른 모습` 섹션이 화면에 생성되지 않는다.
|
||||||
- 모두 팔로우 API success 후 버튼이 `모두 팔로우 완료`와 `ic_new_following` 상태로 변경된다.
|
- 최근 응원이 많은 크리에이터 모두 팔로우 API success 후 버튼이 `모두 팔로우 완료`와 `ic_new_following` 상태로 변경된다.
|
||||||
- 사업자 정보는 기본 3줄 말줄임, 더보기 후 전체 표시, 접기 후 3줄 복귀가 동작한다.
|
- 사업자 정보는 기본 3줄 말줄임, 더보기 후 전체 표시, 접기 후 3줄 복귀가 동작한다.
|
||||||
- 기존 v2 widget 재사용 범위가 구현 diff에서 확인된다.
|
- 기존 v2 widget 재사용 범위가 구현 diff에서 확인된다.
|
||||||
- 관련 unit test, resource merge, `./gradlew :app:testDebugUnitTest` 또는 구현 범위에 맞는 단일 테스트가 성공한다.
|
- 관련 unit test, resource merge, `./gradlew :app:testDebugUnitTest` 또는 구현 범위에 맞는 단일 테스트가 성공한다.
|
||||||
@@ -372,13 +370,14 @@ HomeRecommendation 화면
|
|||||||
- 홈 추천 API endpoint는 `/api/v2/home/recommendations`로 확정됐다. HTTP method는 `GET`으로 가정하되, 구현 전 백엔드 계약 또는 기존 `HomeApi` 패턴에서 최종 확인한다.
|
- 홈 추천 API endpoint는 `/api/v2/home/recommendations`로 확정됐다. HTTP method는 `GET`으로 가정하되, 구현 전 백엔드 계약 또는 기존 `HomeApi` 패턴에서 최종 확인한다.
|
||||||
- 모두 팔로우 API는 `POST /api/v2/home/recommendations/creators/follow`, request body는 `FollowRecommendedCreatorsRequest(creatorIds: List<Long>)`로 확정됐다.
|
- 모두 팔로우 API는 `POST /api/v2/home/recommendations/creators/follow`, request body는 `FollowRecommendedCreatorsRequest(creatorIds: List<Long>)`로 확정됐다.
|
||||||
- 인증 token 처리 방식은 아직 제공되지 않았다. 구현 전 기존 `HomeApi` 확장 지점과 공통 인증 interceptor 사용 여부를 확인해야 한다.
|
- 인증 token 처리 방식은 아직 제공되지 않았다. 구현 전 기존 `HomeApi` 확장 지점과 공통 인증 interceptor 사용 여부를 확인해야 한다.
|
||||||
- `genreCreators`와 `cheerCreators`의 profile grid는 기존 profile widget이 저장소에 있는지 추가 확인 후 재사용/신규를 결정한다.
|
- `cheerCreators`의 profile grid는 기존 profile widget이 저장소에 있는지 추가 확인 후 재사용/신규를 결정한다. `genreCreators`는 응답/매핑 계약만 유지하고 추천 탭 UI에는 표시하지 않는다.
|
||||||
- 배너 이동 정책은 `type` 없이 `eventItem`, `creatorId`, `seriesId`, `link` 값 기준으로 처리하는 것으로 확정됐다. `link`가 내부 딥링크이면 기존 딥링크 실행 정책을 사용한다.
|
- 배너 이동 정책은 `type` 없이 `eventItem`, `creatorId`, `seriesId`, `link` 값 기준으로 처리하는 것으로 확정됐다. `link`가 내부 딥링크이면 기존 딥링크 실행 정책을 사용한다.
|
||||||
- `activityAt`, `releaseDate`, `createdAt`, `beginDateTime`의 표시 포맷은 기존 시간 formatter 재사용 여부를 구현 계획에서 확인한다.
|
- `activityAt`, `releaseDate`, `createdAt`, `beginDateTime`의 표시 포맷은 기존 시간 formatter 재사용 여부를 구현 계획에서 확인한다.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 12. 검증 기록
|
## 12. 검증 기록
|
||||||
|
- 2026-07-10: 사용자 요청에 따라 `방금 활동한 크리에이터` 섹션의 `activityType == COMMUNITY` 클릭과 `인기 커뮤니티` 섹션 item 클릭을 커뮤니티 전체 목록이 아니라 `CreatorChannelCommunityDetailActivity`로 이동하도록 요구사항과 구현을 반영했다. 최근 활동 COMMUNITY의 `targetId`는 커뮤니티 게시글 `postId`로 해석하고, 인기 커뮤니티는 `HomePopularCommunityPostItem.postId`를 상세 화면 식별 값으로 사용한다.
|
||||||
- 2026-06-29: 사용자 추가 요청에 따라 `최근 데뷔한 크리에이터` item을 기존 `205dp x 259dp` 비율로 축소한 `185dp x 234dp`로 표시하고, 이미지 단독이 아니라 item root 전체에 `14dp` rounded clipping을 적용해야 한다는 요구사항을 PRD에 반영했다. Figma `24:5534`, `24:5537` design context와 screenshot을 확인했다.
|
- 2026-06-29: 사용자 추가 요청에 따라 `최근 데뷔한 크리에이터` item을 기존 `205dp x 259dp` 비율로 축소한 `185dp x 234dp`로 표시하고, 이미지 단독이 아니라 item root 전체에 `14dp` rounded clipping을 적용해야 한다는 요구사항을 PRD에 반영했다. Figma `24:5534`, `24:5537` design context와 screenshot을 확인했다.
|
||||||
- 2026-06-23: 기존 `CreatorChannelFanTalkFragmentLayoutTest.kt:78` ktlint 줄바꿈 위반을 동작 변경 없이 수정한 뒤 `./gradlew :app:testDebugUnitTest`, `./gradlew :app:compileDebugKotlin`, `./gradlew :app:mergeDebugResources`, `./gradlew :app:ktlintCheck`가 모두 BUILD SUCCESSFUL임을 재확인했다. `adb devices` 결과 연결된 Android 기기가 없어 실제 기기 수동 확인은 수행하지 못했고, 해당 수동 확인은 환경 차단으로 계획 문서에 남겼다.
|
- 2026-06-23: 기존 `CreatorChannelFanTalkFragmentLayoutTest.kt:78` ktlint 줄바꿈 위반을 동작 변경 없이 수정한 뒤 `./gradlew :app:testDebugUnitTest`, `./gradlew :app:compileDebugKotlin`, `./gradlew :app:mergeDebugResources`, `./gradlew :app:ktlintCheck`가 모두 BUILD SUCCESSFUL임을 재확인했다. `adb devices` 결과 연결된 Android 기기가 없어 실제 기기 수동 확인은 수행하지 못했고, 해당 수동 확인은 환경 차단으로 계획 문서에 남겼다.
|
||||||
- 2026-06-23: Phase 12 검증으로 `./gradlew :app:testDebugUnitTest`, `./gradlew :app:compileDebugKotlin`, `./gradlew :app:mergeDebugResources`는 BUILD SUCCESSFUL을 확인했다. `./gradlew :app:ktlintCheck`는 이번 변경 범위 밖의 `CreatorChannelFanTalkFragmentLayoutTest.kt:78` 기존 줄 길이/인자 줄바꿈 위반으로 실패했으며, 해당 파일은 이번 diff에 포함되지 않음을 확인했다.
|
- 2026-06-23: Phase 12 검증으로 `./gradlew :app:testDebugUnitTest`, `./gradlew :app:compileDebugKotlin`, `./gradlew :app:mergeDebugResources`는 BUILD SUCCESSFUL을 확인했다. `./gradlew :app:ktlintCheck`는 이번 변경 범위 밖의 `CreatorChannelFanTalkFragmentLayoutTest.kt:78` 기존 줄 길이/인자 줄바꿈 위반으로 실패했으며, 해당 파일은 이번 diff에 포함되지 않음을 확인했다.
|
||||||
@@ -415,4 +414,20 @@ HomeRecommendation 화면
|
|||||||
- 홈 추천 탭에서 `처음부터 함께 성장!` 섹션을 제거한다.
|
- 홈 추천 탭에서 `처음부터 함께 성장!` 섹션을 제거한다.
|
||||||
- `GET /api/v2/home/recommendations` 응답 계약에서 `firstAudioContents`와 `HomeFirstAudioContentItem`을 제거한다.
|
- `GET /api/v2/home/recommendations` 응답 계약에서 `firstAudioContents`와 `HomeFirstAudioContentItem`을 제거한다.
|
||||||
- UI model, mapper, Fragment binding, adapter, item layout, section title string도 함께 제거한다.
|
- UI model, mapper, Fragment binding, adapter, item layout, section title string도 함께 제거한다.
|
||||||
- 다른 섹션(`recentDebutCreators`, `aiCharacters`, `genreCreators`, `cheerCreators`, `popularCommunityPosts`)의 노출/매핑 계약은 유지한다.
|
- 다른 섹션(`recentDebutCreators`, `aiCharacters`, `cheerCreators`, `popularCommunityPosts`)의 노출/매핑 계약은 유지한다. `genreCreators`는 응답/매핑 계약은 유지하되 추천 탭 UI에는 표시하지 않는다.
|
||||||
|
|
||||||
|
### 2026-07-10 변경: 커뮤니티 상세 라우팅
|
||||||
|
- `방금 활동한 크리에이터` 섹션에서 `activityType == COMMUNITY`인 item을 터치하면 커뮤니티 전체 목록이 아니라 `CreatorChannelCommunityDetailActivity`로 이동한다.
|
||||||
|
- `activityType == COMMUNITY`인 `HomeActiveCreatorItem.targetId`는 커뮤니티 게시글 `postId`로 해석해 상세 화면에 전달한다.
|
||||||
|
- `인기 커뮤니티` 섹션 item을 터치하면 커뮤니티 전체 목록이 아니라 `CreatorChannelCommunityDetailActivity`로 이동한다.
|
||||||
|
- `인기 커뮤니티` 섹션은 기존 `HomePopularCommunityPostItem.postId`를 상세 화면 식별 값으로 사용한다.
|
||||||
|
|
||||||
|
### 2026-07-12 변경: `genreCreators` UI 미표시
|
||||||
|
- 홈 추천 API 응답의 `genreCreators` 필드와 DTO/UI model/mapper 계약은 유지한다.
|
||||||
|
- 메인 홈 추천 탭에서는 장르(카테고리)별 크리에이터 섹션과 해당 모두 팔로우 버튼을 표시하지 않는다.
|
||||||
|
- `cheerCreators`를 포함한 다른 추천 섹션 노출 정책은 변경하지 않는다.
|
||||||
|
|
||||||
|
### 2026-07-12 변경: 추천 섹션 타이틀 chevron 숨김
|
||||||
|
- `최근 데뷔한 크리에이터` 섹션 타이틀 우측 chevron을 표시하지 않는다.
|
||||||
|
- `크리에이터와 이야기를 나눠요!` 섹션 타이틀 우측 chevron을 표시하지 않는다.
|
||||||
|
- 섹션 목록 표시, item 터치 동작, 다른 섹션의 chevron 정책은 변경하지 않는다.
|
||||||
|
|||||||
@@ -485,6 +485,26 @@
|
|||||||
- 실패가 있었다면 실패 원인과 후속 조치
|
- 실패가 있었다면 실패 원인과 후속 조치
|
||||||
- 검증: 기존 Verification Log를 삭제하거나 덮어쓰지 않고 하단에 새 항목으로 누적한다.
|
- 검증: 기존 Verification Log를 삭제하거나 덮어쓰지 않고 하단에 새 항목으로 누적한다.
|
||||||
|
|
||||||
|
### Phase 11: 채팅 탭 empty state 문구 표시
|
||||||
|
- [x] **Task 11.1: 탭별 empty state 문구 요구사항 반영**
|
||||||
|
- Files:
|
||||||
|
- Modify: `docs/20260609_채팅_탭_페이지/prd.md`
|
||||||
|
- Modify: `app/src/main/res/layout/fragment_v2_main_chat.xml`
|
||||||
|
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/ChatMainFragment.kt`
|
||||||
|
- Modify: `app/src/main/res/values/strings.xml`
|
||||||
|
- Modify: `app/src/main/res/values-en/strings.xml`
|
||||||
|
- Modify: `app/src/main/res/values-ja/strings.xml`
|
||||||
|
- Modify: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/ChatMainFragmentLayoutTest.kt`
|
||||||
|
- 작업:
|
||||||
|
- `ChatRoomListUiState.Empty` 수신 시 검은 빈 화면 대신 현재 선택된 탭에 맞는 empty 문구를 표시한다.
|
||||||
|
- Content/Loading/Error 상태에서는 empty 문구를 숨기고 기존 목록/오류 처리 정책을 유지한다.
|
||||||
|
- 검증:
|
||||||
|
- Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.ChatMainFragmentLayoutTest" --max-workers=1`
|
||||||
|
- Expected: empty placeholder layout과 탭별 문구 source 계약 검증이 PASS.
|
||||||
|
- 검증 기록:
|
||||||
|
- 2026-07-11: `ChatMainFragmentLayoutTest`에 empty placeholder layout과 탭별 문구 source 계약 검증을 추가하고, `ChatMainFragment`에서 `ChatRoomListUiState.Empty` 수신 시 현재 filter별 empty 문구를 표시하도록 보정했다. 회귀 검증으로 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.ChatMainFragmentLayoutTest" --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomViewModelTest" --max-workers=1`, `./gradlew :app:compileDebugKotlin --max-workers=1`, `./gradlew :app:ktlintCheck --max-workers=1`, `git diff --check` PASS를 확인했다. `ktlintCheck`에서는 기존 `.editorconfig`의 `disabled_rules` deprecation warning이 출력됐지만 실패는 없었다.
|
||||||
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 구현 제외 체크리스트
|
## 구현 제외 체크리스트
|
||||||
@@ -536,5 +556,6 @@
|
|||||||
- 2026-06-10: 사용자 피드백에 따라 통합 검증 Phase가 마지막에 오도록 Plan-Task 순서를 정리했다. 기존 Phase 9(탭 전환/첫 페이지 로딩 시 스크롤 최상단 이동 처리)를 Phase 8로, 기존 Phase 10(채팅 타이틀바 우측 액션 메뉴 이미지 간격 조정)을 Phase 9로 한 단계씩 당기고, 기존 Phase 8(통합 검증과 문서 기록)을 마지막 Phase 10으로 이동했다. 문서 순서 정정만 수행했으며 빌드/테스트는 실행하지 않았다.
|
- 2026-06-10: 사용자 피드백에 따라 통합 검증 Phase가 마지막에 오도록 Plan-Task 순서를 정리했다. 기존 Phase 9(탭 전환/첫 페이지 로딩 시 스크롤 최상단 이동 처리)를 Phase 8로, 기존 Phase 10(채팅 타이틀바 우측 액션 메뉴 이미지 간격 조정)을 Phase 9로 한 단계씩 당기고, 기존 Phase 8(통합 검증과 문서 기록)을 마지막 Phase 10으로 이동했다. 문서 순서 정정만 수행했으며 빌드/테스트는 실행하지 않았다.
|
||||||
- 2026-06-10: Phase 10 통합 검증을 완료했다. `./gradlew :app:mergeDebugResources`는 `BUILD SUCCESSFUL in 1s`, `./gradlew :app:compileDebugKotlin`은 `BUILD SUCCESSFUL in 1s`, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.*"`는 `BUILD SUCCESSFUL in 9s`, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.*CapsuleTab*"`는 `BUILD SUCCESSFUL in 5s`, `./gradlew :app:ktlintCheck`는 `BUILD SUCCESSFUL in 3s`로 통과했다. ktlint의 `.editorconfig disabled_rules` deprecation warning과 Gradle deprecation warning은 기존 경고로 남아 있다.
|
- 2026-06-10: Phase 10 통합 검증을 완료했다. `./gradlew :app:mergeDebugResources`는 `BUILD SUCCESSFUL in 1s`, `./gradlew :app:compileDebugKotlin`은 `BUILD SUCCESSFUL in 1s`, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.*"`는 `BUILD SUCCESSFUL in 9s`, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.*CapsuleTab*"`는 `BUILD SUCCESSFUL in 5s`, `./gradlew :app:ktlintCheck`는 `BUILD SUCCESSFUL in 3s`로 통과했다. ktlint의 `.editorconfig disabled_rules` deprecation warning과 Gradle deprecation warning은 기존 경고로 남아 있다.
|
||||||
- 2026-06-10: 구현 제외 체크리스트를 확인했다. `ChatMainFragment.kt`는 `chatType=AI`가 아닌 item 클릭 시 즉시 return하고 floating button은 빈 listener만 유지한다. `fragment_v2_main_chat.xml`에는 `BottomNavigationView`가 없고, `item_v2_chat_room.xml`에는 unread dot view/binding이 없다. `rg`와 직접 파일 확인으로 WebSocket/SSE, pull-to-refresh, skeleton/shimmer 추가가 없고 `ChatRoomModels.kt`의 API schema 필드명도 `roomId`, `chatType`, `targetName`, `targetImageUrl`, `lastMessage`, `lastMessageAt`, `rooms`, `hasMore`, `nextCursor` 계약을 유지함을 확인했다.
|
- 2026-06-10: 구현 제외 체크리스트를 확인했다. `ChatMainFragment.kt`는 `chatType=AI`가 아닌 item 클릭 시 즉시 return하고 floating button은 빈 listener만 유지한다. `fragment_v2_main_chat.xml`에는 `BottomNavigationView`가 없고, `item_v2_chat_room.xml`에는 unread dot view/binding이 없다. `rg`와 직접 파일 확인으로 WebSocket/SSE, pull-to-refresh, skeleton/shimmer 추가가 없고 `ChatRoomModels.kt`의 API schema 필드명도 `roomId`, `chatType`, `targetName`, `targetImageUrl`, `lastMessage`, `lastMessageAt`, `rooms`, `hasMore`, `nextCursor` 계약을 유지함을 확인했다.
|
||||||
|
- 2026-07-11: Phase 11 empty state 보정 완료. 전체/AI 채팅/DM 탭별 empty 문구를 `values`, `values-en`, `values-ja`에 추가하고 `fragment_v2_main_chat.xml`의 empty `TextView`, `ChatRoomFilter.emptyMessageResId()`, `ChatMainFragment.bindChatRooms(items, showEmpty)`로 빈 목록 표시를 연결했다. `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.ChatMainFragmentLayoutTest" --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomViewModelTest" --max-workers=1`, `./gradlew :app:compileDebugKotlin --max-workers=1`, `./gradlew :app:ktlintCheck --max-workers=1`, `git diff --check` PASS를 확인했다. `ktlintCheck`에서는 기존 `.editorconfig`의 `disabled_rules` deprecation warning이 출력됐지만 실패는 없었다.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -80,7 +80,7 @@
|
|||||||
#### Edge Cases
|
#### Edge Cases
|
||||||
- API 요청 `filter`는 `ALL`, `AI`, `DM` 중 하나만 사용한다.
|
- API 요청 `filter`는 `ALL`, `AI`, `DM` 중 하나만 사용한다.
|
||||||
- `chatType`은 `AI`, `DM` 문자열만 사용한다.
|
- `chatType`은 `AI`, `DM` 문자열만 사용한다.
|
||||||
- 탭 전환 후 표시할 item이 없으면 목록을 비운다. 별도 empty placeholder는 추가하지 않는다.
|
- 탭 전환 후 표시할 item이 없으면 탭별 empty placeholder를 표시한다. 전체 탭은 `아직 나눈 대화가 없어요.\n크리에이터와 이야기를 시작해 보세요.`, AI 채팅 탭은 `아직 나눈 대화가 없어요.\nAI 크리에이터와 대화를 시작해 보세요.`, DM 탭은 `아직 나눈 대화가 없어요.\n좋아하는 크리에이터에게 메세지를 보내보세요.`를 표시한다.
|
||||||
- 탭 전환 시점 또는 첫 번째 페이지 로드 시(isAppending = false인 상태) 목록의 스크롤을 최상단으로 이동시킨다.
|
- 탭 전환 시점 또는 첫 번째 페이지 로드 시(isAppending = false인 상태) 목록의 스크롤을 최상단으로 이동시킨다.
|
||||||
|
|
||||||
### Chat Room List
|
### Chat Room List
|
||||||
|
|||||||
@@ -11,8 +11,10 @@
|
|||||||
- `roomId <= 0 && creatorId > 0`: `CreateOrGetRoom` 호출 후 반환된 `roomId`로 `OpenRoom`을 호출한다.
|
- `roomId <= 0 && creatorId > 0`: `CreateOrGetRoom` 호출 후 반환된 `roomId`로 `OpenRoom`을 호출한다.
|
||||||
- 둘 다 유효하지 않으면 Activity를 종료한다.
|
- 둘 다 유효하지 않으면 Activity를 종료한다.
|
||||||
- REST API는 기존 v2 채팅 탭과 동일하게 Retrofit + RxJava3 + `ApiResponse<T>` 패턴을 사용한다.
|
- REST API는 기존 v2 채팅 탭과 동일하게 Retrofit + RxJava3 + `ApiResponse<T>` 패턴을 사용한다.
|
||||||
- Phase 1~8은 기존 SSE 기반 구현 이력으로 보존한다. WebSocket 전환은 Phase 9부터 기존 SSE 구현을 교체하는 후속 범위로 진행한다.
|
- Phase 1~13은 과거 구현/검증 이력으로만 남긴다. 최신 raw WebSocket 계약 보정의 active 작업 범위는 Phase 14부터 진행한다.
|
||||||
- WebSocket은 `OkHttpClient.newWebSocket()` 기반 전용 `DmChatSocketClient`를 추가하고, 기존 `DmChatEventClient`/SSE parser는 WebSocket 전환 완료 후 사용하지 않는다.
|
- 과거 이력에 남은 SSE/EventSource 내용은 이번 구현에서 유지하거나 재사용하지 않는다.
|
||||||
|
- WebSocket은 `OkHttpClient.newWebSocket()` 기반 전용 `DmChatSocketClient`를 사용한다.
|
||||||
|
- 기존 `DmChatEventClient`와 제거된 `/events` 계열 구현은 active main source에서 삭제한다.
|
||||||
- WebSocket 연결/해제는 Activity foreground 범위와 로그아웃 흐름에서 처리한다.
|
- WebSocket 연결/해제는 Activity foreground 범위와 로그아웃 흐름에서 처리한다.
|
||||||
- `onStart`: `OpenRoom` 완료 후 연결 가능 상태면 WebSocket 연결을 시작하고 `JOIN_ROOM`을 보낸다.
|
- `onStart`: `OpenRoom` 완료 후 연결 가능 상태면 WebSocket 연결을 시작하고 `JOIN_ROOM`을 보낸다.
|
||||||
- `JOINED`: 실시간 수신 가능 상태로 판단한다.
|
- `JOINED`: 실시간 수신 가능 상태로 판단한다.
|
||||||
@@ -22,7 +24,9 @@
|
|||||||
- 네트워크 오류로 WebSocket이 실패하면 화면이 foreground에 있고 채팅방이 활성 상태인 경우에만 재연결을 시도한다.
|
- 네트워크 오류로 WebSocket이 실패하면 화면이 foreground에 있고 채팅방이 활성 상태인 경우에만 재연결을 시도한다.
|
||||||
- 재연결 성공 후 `JOIN_ROOM`을 다시 보내고 `GetMessages`로 누락 가능 메시지를 보정한다.
|
- 재연결 성공 후 `JOIN_ROOM`을 다시 보내고 `GetMessages`로 누락 가능 메시지를 보정한다.
|
||||||
- 화면 이탈 또는 background 전환 시 예약된 재연결 시도는 취소한다.
|
- 화면 이탈 또는 background 전환 시 예약된 재연결 시도는 취소한다.
|
||||||
- `VOICE` 메시지는 DTO에 보존하되 이번 UI 목록에는 표시하지 않는다.
|
- `VOICE` 메시지는 DTO/API 계약에 보존하되 이번 UI 목록에는 표시하지 않고, 화면에서는 텍스트 메시지만 보낼 수 있게 한다.
|
||||||
|
- 상대방 메시지는 왼쪽 정렬, 내가 보낸 메시지는 오른쪽 정렬로 고정한다.
|
||||||
|
- 채팅 버블은 현재 구현된 DM 채팅 버블을 그대로 사용한다.
|
||||||
- 전송은 낙관적 UI를 적용한다.
|
- 전송은 낙관적 UI를 적용한다.
|
||||||
- 전송 직후 local pending 메시지를 추가한다.
|
- 전송 직후 local pending 메시지를 추가한다.
|
||||||
- WebSocket `SEND_ACK` 수신 시 `requestId`로 pending 메시지를 찾아 서버 `messageId`, `createdAt`, 프로필 정보로 확정한다.
|
- WebSocket `SEND_ACK` 수신 시 `requestId`로 pending 메시지를 찾아 서버 `messageId`, `createdAt`, 프로필 정보로 확정한다.
|
||||||
@@ -32,6 +36,18 @@
|
|||||||
- 같은 UI item 재시도는 새 `requestId`를 발급하되 기존 local item을 유지한다.
|
- 같은 UI item 재시도는 새 `requestId`를 발급하되 기존 local item을 유지한다.
|
||||||
- Phase 3 ViewModel의 pagination/reconnect 동기화 실패는 화면 종료나 Error 화면 전환 없이 기존 메시지 상태를 유지하고 내부 loading 상태만 복구한다.
|
- Phase 3 ViewModel의 pagination/reconnect 동기화 실패는 화면 종료나 Error 화면 전환 없이 기존 메시지 상태를 유지하고 내부 loading 상태만 복구한다.
|
||||||
- 사용자 노출 toast/retry UI는 Phase 5 Activity 연결 시 필요하면 별도 처리한다.
|
- 사용자 노출 toast/retry UI는 Phase 5 Activity 연결 시 필요하면 별도 처리한다.
|
||||||
|
- 2026-07-10 최신 서버 계약은 Phase 9~13에서 구현한 WebSocket envelope 일부를 대체한다.
|
||||||
|
- 기존 완료 이력과 검증 기록은 보존한다.
|
||||||
|
- 최신 계약 보정은 Phase 14에서 미완료 작업으로 진행한다.
|
||||||
|
- client send JSON은 root `type`, `requestId`, `roomId`, `payload`를 사용한다.
|
||||||
|
- `JOIN_ROOM`, `SEND_TEXT`, `PING`, `LEAVE_ROOM`은 모두 UUID `requestId`와 실제 `roomId > 0`을 root에 포함한다.
|
||||||
|
- `SEND_TEXT` payload에는 `textMessage`만 둔다.
|
||||||
|
- server `SEND_ACK`/`MESSAGE` payload는 `{message: ...}` wrapper 없는 `MessageItem`이다.
|
||||||
|
- `ERROR`는 root `code`/`message`가 아니라 `payload.messageKey`를 파싱한다.
|
||||||
|
- `JOINED` 이후에만 `SEND_TEXT`/`PING`/`LEAVE_ROOM`을 허용한다.
|
||||||
|
- `PING`은 `JOINED` 상태에서 30초 주기로 보낸다.
|
||||||
|
- `SEND_ACK` timeout 기준은 15초로 보정한다.
|
||||||
|
- 음성 메시지는 구현하더라도 UI에 연결하지 않는다.
|
||||||
|
|
||||||
## 3. 파일 구조
|
## 3. 파일 구조
|
||||||
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomActivity.kt`
|
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomActivity.kt`
|
||||||
@@ -44,12 +60,18 @@
|
|||||||
- REST DTO와 서버 메시지 DTO를 정의한다.
|
- REST DTO와 서버 메시지 DTO를 정의한다.
|
||||||
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/data/DmChatRepository.kt`
|
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/data/DmChatRepository.kt`
|
||||||
- REST API 호출 래핑, token 전달, WebSocket 클라이언트 위임을 담당한다.
|
- REST API 호출 래핑, token 전달, WebSocket 클라이언트 위임을 담당한다.
|
||||||
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/data/DmChatEventClient.kt`
|
- Delete: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/data/DmChatEventClient.kt`
|
||||||
- 기존 SSE 연결 구현이다. Phase 9 이후 WebSocket 전환 완료 시 신규 경로에서 사용하지 않으며 제거 또는 미사용 상태로 둔다.
|
- 제거된 `/events` 기반 legacy realtime 구현은 active main source에서 제거한다.
|
||||||
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/data/DmChatSocketClient.kt`
|
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/data/DmChatSocketClient.kt`
|
||||||
- OkHttp WebSocket 연결, handshake header, `JOIN_ROOM`/`LEAVE_ROOM`/`SEND_TEXT`/`PING` 송신, 수신 callback, close 처리를 담당한다.
|
- OkHttp WebSocket 연결, handshake header, `JOIN_ROOM`/`LEAVE_ROOM`/`SEND_TEXT`/`PING` 송신, 수신 callback, close 처리를 담당한다.
|
||||||
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/data/DmChatSocketModels.kt`
|
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/data/DmChatSocketModels.kt`
|
||||||
- WebSocket envelope, payload, `requestId` 기반 `SEND_ACK`, `ERROR`, `MESSAGE`, `PONG` 모델을 정의한다.
|
- WebSocket root envelope, payload, `requestId` 기반 `SEND_ACK`, `ERROR`, `MESSAGE`, `PONG` 모델을 정의한다.
|
||||||
|
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/data/DmChatSocketModels.kt`
|
||||||
|
- Phase 14에서 최신 raw WebSocket 계약에 맞게 root `requestId`/`roomId`, direct `MessageItem` payload, `payload.messageKey`를 보정한다.
|
||||||
|
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/data/DmChatSocketClient.kt`
|
||||||
|
- Phase 14에서 client send raw JSON, UUID `requestId`, root `roomId`, raw JSON logging, `onOpen`/`JOINED` 상태 분리를 보정한다.
|
||||||
|
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/data/DmChatApi.kt`
|
||||||
|
- Phase 14에서 음성 메시지 multipart endpoint가 필요한 경우 `voiceMessageFile` file part와 `request` string part를 추가한다.
|
||||||
- Create: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatSocketClientTest.kt`
|
- Create: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatSocketClientTest.kt`
|
||||||
- WebSocket handshake header, endpoint, send/receive envelope, close 동작을 검증한다.
|
- WebSocket handshake header, endpoint, send/receive envelope, close 동작을 검증한다.
|
||||||
- Create: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatSocketParserTest.kt`
|
- Create: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatSocketParserTest.kt`
|
||||||
@@ -90,12 +112,22 @@
|
|||||||
- `creatorId` 기반 진입은 `CreateOrGetRoom` 성공 후 반환된 `roomId`로 `OpenRoom`을 호출한다.
|
- `creatorId` 기반 진입은 `CreateOrGetRoom` 성공 후 반환된 `roomId`로 `OpenRoom`을 호출한다.
|
||||||
- OpenRoom 메시지는 오래된 순서에서 최신 순서로 표시된다.
|
- OpenRoom 메시지는 오래된 순서에서 최신 순서로 표시된다.
|
||||||
- 메시지 병합은 `messageId` 기준으로 중복을 제거한다.
|
- 메시지 병합은 `messageId` 기준으로 중복을 제거한다.
|
||||||
|
- 상대방 메시지는 왼쪽 정렬, 내가 보낸 메시지는 오른쪽 정렬로 표시한다.
|
||||||
|
- 채팅 버블은 현재 구현된 DM 채팅 버블을 그대로 사용한다.
|
||||||
- 상단 스크롤 시 `hasMore=true`, `nextCursor != null`, `isLoading=false` 조건에서만 과거 메시지를 조회한다.
|
- 상단 스크롤 시 `hasMore=true`, `nextCursor != null`, `isLoading=false` 조건에서만 과거 메시지를 조회한다.
|
||||||
- 텍스트 전송은 blank 입력을 무시하고, WebSocket `SEND_TEXT`와 `requestId` pending 매칭을 사용한다.
|
- 텍스트 전송은 blank 입력을 무시하고, WebSocket `SEND_TEXT`와 `requestId` pending 매칭을 사용한다.
|
||||||
|
- DM 채팅방 입력 UI는 텍스트 전송만 제공하고 음성 녹음/전송/재생 컨트롤을 노출하지 않는다.
|
||||||
|
- WebSocket client send raw JSON은 최신 계약의 root `type`/`requestId`/`roomId`/`payload` 구조와 일치한다.
|
||||||
|
- WebSocket server receive parser는 `SEND_ACK`/`MESSAGE`의 direct `MessageItem` payload와 `ERROR payload.messageKey`를 처리한다.
|
||||||
|
- `roomId`가 0/null이면 `JOIN_ROOM`을 보내지 않는다.
|
||||||
|
- `JOINED` 전에는 `SEND_TEXT`, `PING`, `LEAVE_ROOM`을 보내지 않는다.
|
||||||
|
- `PING`은 30초 주기로 UUID `requestId`와 실제 `roomId`를 포함해 보낸다.
|
||||||
|
- `SEND_ACK` timeout은 15초 기준으로 실패 처리한다.
|
||||||
|
- WebSocket send/receive raw JSON은 토큰 없이 로그로 남긴다.
|
||||||
- 전송 실패 메시지는 `ERROR` 또는 timeout 기준으로 실패 상태와 재시도 버튼을 표시하고, 재시도 성공 시 정상 메시지로 교체된다.
|
- 전송 실패 메시지는 `ERROR` 또는 timeout 기준으로 실패 상태와 재시도 버튼을 표시하고, 재시도 성공 시 정상 메시지로 교체된다.
|
||||||
- 화면 stop/destroy/background/logout 흐름에서 `LEAVE_ROOM` 전송과 socket close가 화면 종료를 막지 않는다.
|
- 화면 stop/destroy/background/logout 흐름에서 `LEAVE_ROOM` 전송과 socket close가 화면 종료를 막지 않는다.
|
||||||
- 제거된 `GET /api/v2/user-creator-chat/rooms/{roomId}/events`, `POST /api/v2/user-creator-chat/rooms/{roomId}/events/disconnect`, 텍스트 전송용 `POST /api/v2/user-creator-chat/rooms/{roomId}/messages/text`는 신규 텍스트 실시간 송수신 경로에서 호출하지 않는다.
|
- 제거된 `GET /api/v2/user-creator-chat/rooms/{roomId}/events`, `POST /api/v2/user-creator-chat/rooms/{roomId}/events/disconnect`, 텍스트 전송용 `POST /api/v2/user-creator-chat/rooms/{roomId}/messages/text`는 신규 텍스트 실시간 송수신 경로에서 호출하지 않는다.
|
||||||
- 음성 메시지는 기존 multipart REST API 경로를 유지한다.
|
- 음성 메시지 multipart REST 계약은 보존하되 이번 화면 UI에는 연결하지 않는다.
|
||||||
- DM 화면에는 `character_type_badge`, `ll_can_badge`, `iv_more`, `notice_container`가 없다.
|
- DM 화면에는 `character_type_badge`, `ll_can_badge`, `iv_more`, `notice_container`가 없다.
|
||||||
- `ChatRoomActivity` 기존 동작은 변경하지 않는다.
|
- `ChatRoomActivity` 기존 동작은 변경하지 않는다.
|
||||||
|
|
||||||
@@ -921,6 +953,303 @@
|
|||||||
- 2026-06-19: `DmChatRoomViewModelTest`, `DmChatSocketClientTest`, `DmChatRemovedEndpointSourceTest`, `SodaFirebaseMessagingServiceSourceTest`, `DeepLinkActivitySourceTest`, `MainV2ActivitySourceTest`로 WebSocket join/send/ack/error/timeout/retry/reconnect/heartbeat, 제거 endpoint 미사용, USER_CREATOR push/deep link 라우팅의 자동 검증 PASS를 확인했다. 단, `adb devices` 결과 연결된 Android 기기가 없어 실제 앱 화면과 실제 서버/WebSocket을 통한 수동 확인은 수행하지 못했으므로 Task 13.4는 미완료로 유지한다.
|
- 2026-06-19: `DmChatRoomViewModelTest`, `DmChatSocketClientTest`, `DmChatRemovedEndpointSourceTest`, `SodaFirebaseMessagingServiceSourceTest`, `DeepLinkActivitySourceTest`, `MainV2ActivitySourceTest`로 WebSocket join/send/ack/error/timeout/retry/reconnect/heartbeat, 제거 endpoint 미사용, USER_CREATOR push/deep link 라우팅의 자동 검증 PASS를 확인했다. 단, `adb devices` 결과 연결된 Android 기기가 없어 실제 앱 화면과 실제 서버/WebSocket을 통한 수동 확인은 수행하지 못했으므로 Task 13.4는 미완료로 유지한다.
|
||||||
- 2026-06-19: Phase 13 코드 리뷰로 `DmChatRoomActivity`, `DmChatRoomViewModel`, `DmChatSocketClient`, `DmChatSocketModels`, `DmChatRepository`, `DmChatApi`의 WebSocket 연결/해제, `JOINED` 기준 연결 확정, `SEND_TEXT`/`SEND_ACK` pending 처리, timeout/retry/reconnect/heartbeat, 제거 REST endpoint 미사용 경로를 재검토했고 blocking issue는 발견하지 못했다. 재검증으로 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.*" --max-workers=1`, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.fcm.*" --tests "kr.co.vividnext.sodalive.main.*" --tests "kr.co.vividnext.sodalive.v2.main.*" --max-workers=1`, `./gradlew :app:compileDebugKotlin --max-workers=1`, `./gradlew :app:ktlintCheck --max-workers=1`, `git diff --check` PASS를 확인했다. active DM main 코드의 제거 endpoint 문자열 검색 결과는 없음이며, DM test 경로까지 확장하면 삭제 검증 테스트와 과거 SSE 이력 테스트 fixture만 매칭된다. 최초 Gradle 실행은 `~/.gradle` wrapper lock 파일 접근 제한으로 실패해 승인된 Gradle 실행으로 재검증했고, `adb devices`는 승인된 실행에서도 연결된 기기가 없어 실제 앱/서버 WebSocket 수동 확인은 미완료로 유지한다.
|
- 2026-06-19: Phase 13 코드 리뷰로 `DmChatRoomActivity`, `DmChatRoomViewModel`, `DmChatSocketClient`, `DmChatSocketModels`, `DmChatRepository`, `DmChatApi`의 WebSocket 연결/해제, `JOINED` 기준 연결 확정, `SEND_TEXT`/`SEND_ACK` pending 처리, timeout/retry/reconnect/heartbeat, 제거 REST endpoint 미사용 경로를 재검토했고 blocking issue는 발견하지 못했다. 재검증으로 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.*" --max-workers=1`, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.fcm.*" --tests "kr.co.vividnext.sodalive.main.*" --tests "kr.co.vividnext.sodalive.v2.main.*" --max-workers=1`, `./gradlew :app:compileDebugKotlin --max-workers=1`, `./gradlew :app:ktlintCheck --max-workers=1`, `git diff --check` PASS를 확인했다. active DM main 코드의 제거 endpoint 문자열 검색 결과는 없음이며, DM test 경로까지 확장하면 삭제 검증 테스트와 과거 SSE 이력 테스트 fixture만 매칭된다. 최초 Gradle 실행은 `~/.gradle` wrapper lock 파일 접근 제한으로 실패해 승인된 Gradle 실행으로 재검증했고, `adb devices`는 승인된 실행에서도 연결된 기기가 없어 실제 앱/서버 WebSocket 수동 확인은 미완료로 유지한다.
|
||||||
|
|
||||||
|
### Phase 14: 최신 raw WebSocket 계약 보정
|
||||||
|
|
||||||
|
> 2026-07-10 추가 계약 변경: 서버는 STOMP/SockJS가 아니라 raw WebSocket만 사용하며, WebSocket JSON은 root `type`, `requestId`, `roomId`, `payload` 구조를 따른다. Phase 9~13의 완료 이력은 보존하되, 실제 구현 전 이 Phase의 테스트를 RED로 먼저 고정한 뒤 최소 수정한다.
|
||||||
|
|
||||||
|
- [x] **Task 14.1: 현재 구현과 최신 계약 차이 문서화**
|
||||||
|
- Files:
|
||||||
|
- Check: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/data/DmChatSocketModels.kt`
|
||||||
|
- Check: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/data/DmChatSocketClient.kt`
|
||||||
|
- Check: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/data/DmChatRepository.kt`
|
||||||
|
- Check: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomViewModel.kt`
|
||||||
|
- Modify: `docs/20260610_DM_채팅화면/prd.md`
|
||||||
|
- Modify: `docs/20260610_DM_채팅화면/plan-task.md`
|
||||||
|
- 확인 결과:
|
||||||
|
- `DmChatSocketClient`의 outbound envelope는 root `requestId`/`roomId`가 없고, `JOIN_ROOM`/`LEAVE_ROOM`은 payload에 `roomId`를 넣는다.
|
||||||
|
- `SEND_TEXT`는 payload에 `roomId`/`requestId`/`textMessage`를 함께 넣지만 최신 계약은 root `requestId`/`roomId`, payload `{textMessage}`다.
|
||||||
|
- `PING`은 empty payload만 보내며 root `requestId`/`roomId`가 없다.
|
||||||
|
- `DmChatSocketParser`는 `MESSAGE`/`SEND_ACK` payload를 `{requestId, message}` wrapper로 기대하지만 최신 계약은 root `requestId`, payload `MessageItem`이다.
|
||||||
|
- `ERROR` parser는 `code`/`message`를 보존하지만 최신 계약은 `payload.messageKey`를 읽어야 한다.
|
||||||
|
- `SEND_ACK_TIMEOUT_MILLIS`는 10초이며 최신 예시는 15초다.
|
||||||
|
- `nextRequestId()`는 `request-1` 형식이고 최신 계약은 UUID다.
|
||||||
|
- `AppDI`/Repository 경로는 `DmChatSocketClient`를 사용하지만, legacy realtime `DmChatEventClient.kt`와 `/api/v2/user-creator-chat/rooms/{roomId}/events` URL 정의가 main source에 남아 있다.
|
||||||
|
- REST create/open/messages와 제거된 `/events/disconnect`, `/messages/text` 미사용 상태는 최신 계약과 큰 충돌이 없다.
|
||||||
|
- 검증 기록:
|
||||||
|
- 2026-07-10: 위 파일을 확인해 최신 계약 차이를 문서화했다. 이번 단계는 문서 보완만 수행했으며 Android 구현, 빌드, 테스트는 실행하지 않았다.
|
||||||
|
|
||||||
|
- [x] **Task 14.2: WebSocket raw envelope 모델/파서 RED 테스트 추가**
|
||||||
|
- Files:
|
||||||
|
- Modify: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatSocketParserTest.kt`
|
||||||
|
- Modify: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatSocketClientTest.kt`
|
||||||
|
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/data/DmChatSocketModels.kt`
|
||||||
|
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/data/DmChatSocketClient.kt`
|
||||||
|
- 작업:
|
||||||
|
- client send JSON이 아래 형태와 정확히 일치하는지 테스트한다.
|
||||||
|
- `{"type":"JOIN_ROOM","requestId":"uuid","roomId":10,"payload":{}}`
|
||||||
|
- `{"type":"SEND_TEXT","requestId":"uuid","roomId":10,"payload":{"textMessage":"hello"}}`
|
||||||
|
- `{"type":"PING","requestId":"uuid","roomId":10,"payload":{}}`
|
||||||
|
- `{"type":"LEAVE_ROOM","requestId":"uuid","roomId":10,"payload":{}}`
|
||||||
|
- server receive JSON이 아래 형태로 파싱되는지 테스트한다.
|
||||||
|
- `JOINED`: root `requestId`, root `roomId`, empty payload
|
||||||
|
- `SEND_ACK`: root `requestId`, root `roomId`, payload `MessageItem`
|
||||||
|
- `MESSAGE`: root `requestId=null`, root `roomId`, payload `MessageItem`
|
||||||
|
- `PONG`: root `requestId`, root `roomId`, empty payload
|
||||||
|
- `ERROR`: root `roomId`, payload `messageKey`
|
||||||
|
- `{message: ...}` wrapper 전제와 payload 내부 `requestId`/`roomId` 전제를 제거한다.
|
||||||
|
- 검증:
|
||||||
|
- Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatSocketParserTest" --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatSocketClientTest" --max-workers=1`
|
||||||
|
- Expected: 구현 전에는 기존 envelope 차이로 RED, 구현 후 최신 raw JSON 계약 PASS.
|
||||||
|
- 검증 기록:
|
||||||
|
- 2026-07-10: `DmChatSocketParserTest`와 `DmChatSocketClientTest`를 최신 raw envelope 기준으로 갱신하고, `DmChatSocketModels.kt`/`DmChatSocketClient.kt`를 root `type`/`requestId`/`roomId`/`payload` 구조로 보정했다. `SEND_ACK`/`MESSAGE`는 direct `MessageItem` payload로 파싱하고 `ERROR`는 `payload.messageKey`를 읽도록 변경했다. `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatSocketParserTest" --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatSocketClientTest" --max-workers=1` PASS를 확인했다.
|
||||||
|
|
||||||
|
- [x] **Task 14.3: ViewModel WebSocket 상태와 전송 gate 보정**
|
||||||
|
- Files:
|
||||||
|
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomViewModel.kt`
|
||||||
|
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/data/DmChatRepository.kt`
|
||||||
|
- Modify: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomViewModelTest.kt`
|
||||||
|
- 작업:
|
||||||
|
- 상태를 `DISCONNECTED`, `CONNECTING`, `SOCKET_OPEN`, `JOINING`, `JOINED` 또는 동등하게 테스트 가능한 구조로 분리한다.
|
||||||
|
- WebSocket `onOpen`은 `SOCKET_OPEN`으로만 보고 UI 실시간 연결 상태로 확정하지 않는다.
|
||||||
|
- `OpenRoom` 성공 전 또는 `roomId <= 0`에서는 `JOIN_ROOM`을 보내지 않는다.
|
||||||
|
- `JOINED` 이후에만 `SEND_TEXT`, `PING`, `LEAVE_ROOM`을 허용한다.
|
||||||
|
- `requestId`는 UUID로 생성한다.
|
||||||
|
- `SEND_ACK` timeout은 15초로 보정한다.
|
||||||
|
- `ERROR payload.messageKey`가 pending `requestId`와 연결 가능한 경우 해당 pending 메시지를 실패 처리하고, 세션 오류면 화면 오류/재연결 정책으로 넘긴다.
|
||||||
|
- `MESSAGE`/`SEND_ACK` root `roomId`가 현재 채팅방과 다르면 무시한다.
|
||||||
|
- 검증:
|
||||||
|
- Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomViewModelTest" --max-workers=1`
|
||||||
|
- Expected: 0 room join 금지, `JOINED` 전 전송 금지, UUID requestId, 15초 timeout, root room filtering, `payload.messageKey` error 처리 테스트가 PASS.
|
||||||
|
- 검증 기록:
|
||||||
|
- 2026-07-10: `DmChatRoomViewModel.kt`와 `DmChatRepository.kt`를 보정해 `JOINED` 이후에만 `SEND_TEXT`/`PING`/`LEAVE_ROOM`을 허용하고, `requestId`를 UUID로 생성하며, `SEND_ACK` timeout을 15초로 변경했다. 다른 `roomId`의 `MESSAGE`/`SEND_ACK` 무시와 `payload.messageKey` 오류 처리를 테스트로 고정했다. `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomViewModelTest" --max-workers=1` PASS를 확인했다.
|
||||||
|
|
||||||
|
- [x] **Task 14.4: WebSocket raw JSON logging 추가**
|
||||||
|
- Files:
|
||||||
|
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/di/AppDI.kt`
|
||||||
|
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/data/DmChatSocketClient.kt`
|
||||||
|
- Modify: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatSocketClientTest.kt`
|
||||||
|
- Modify: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomActivitySourceTest.kt`
|
||||||
|
- 작업:
|
||||||
|
- WebSocket send 직전 raw JSON을 로그로 남긴다.
|
||||||
|
- `onMessage` 수신 raw JSON을 parser 적용 전에 로그로 남긴다.
|
||||||
|
- Authorization token과 bearer header는 로그에 남기지 않는다.
|
||||||
|
- logging은 테스트 가능한 logger seam 또는 최소 source test로 검증한다.
|
||||||
|
- 검증:
|
||||||
|
- Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatSocketClientTest" --max-workers=1`
|
||||||
|
- Expected: send/receive raw JSON logging 경로가 존재하고 token 값은 로그에 포함되지 않는다.
|
||||||
|
- 검증 기록:
|
||||||
|
- 2026-07-10: `DmChatSocketClient`에 테스트 가능한 `rawJsonLogger` seam을 추가해 send 직전 JSON과 `onMessage` 수신 raw JSON을 기록하도록 했다. handshake token/header 문자열은 raw JSON logger에 전달하지 않도록 테스트로 확인했다. `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatSocketClientTest" --max-workers=1` PASS를 확인했다.
|
||||||
|
- 2026-07-10: review-work에서 production `AppDI` 생성 경로가 `rawJsonLogger` 기본 no-op을 사용해 실제 앱 raw JSON logging 요구가 미충족이라는 blocker를 발견했다. `AppDI.kt`에서 `DmChatSocketClient` 생성 시 `Logger.d("DM WebSocket raw JSON: $rawJson")` logger를 명시 주입하고, `DmChatRoomActivitySourceTest`에 기본 no-op 생성 회귀 방지 검증을 추가했다. Authorization/Bearer header 문자열은 logger 주입 문자열에 포함하지 않는다. `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomActivitySourceTest" --max-workers=1` PASS를 확인했다.
|
||||||
|
|
||||||
|
- [x] **Task 14.5: 음성 메시지 multipart REST 계약 보정**
|
||||||
|
- Files:
|
||||||
|
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/data/DmChatApi.kt`
|
||||||
|
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/data/DmChatModels.kt`
|
||||||
|
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/data/DmChatRepository.kt`
|
||||||
|
- Modify: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRepositoryTest.kt`
|
||||||
|
- 작업:
|
||||||
|
- `POST /api/v2/user-creator-chat/rooms/{roomId}/messages/voice` multipart endpoint를 추가한다.
|
||||||
|
- file part 이름은 `voiceMessageFile`로 둔다.
|
||||||
|
- string part 이름은 `request`로 두고 값은 JSON 문자열 `{"recipientId": null}` 형식을 사용한다.
|
||||||
|
- response data 모델은 `message: MessageItem`, `deliveredRealtime: Boolean`, `pushSent: Boolean`을 보존한다.
|
||||||
|
- API/Repository 계약까지만 추가하고 화면 UI에는 연결하지 않는다.
|
||||||
|
- DM 채팅방 입력 영역에는 텍스트 입력/전송만 남긴다.
|
||||||
|
- 검증:
|
||||||
|
- Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRepositoryTest" --max-workers=1`
|
||||||
|
- Expected: multipart part 이름, `request` JSON 문자열, response DTO 필드 보존 테스트가 PASS하고, 음성 UI 연결은 없다.
|
||||||
|
- 검증 기록:
|
||||||
|
- 2026-07-10: `DmChatApi.kt`, `DmChatModels.kt`, `DmChatRepository.kt`에 `POST /api/v2/user-creator-chat/rooms/{roomId}/messages/voice` multipart 계약을 추가했다. file part는 `voiceMessageFile`, string part는 `request` JSON으로 전송하고, 응답 DTO는 `message`/`deliveredRealtime`/`pushSent`를 보존한다. UI에는 연결하지 않았다. `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRepositoryTest" --max-workers=1` PASS를 확인했다.
|
||||||
|
|
||||||
|
- [x] **Task 14.6: 제거 endpoint와 STOMP/SockJS 회귀 방지 갱신**
|
||||||
|
- Files:
|
||||||
|
- Delete: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/data/DmChatEventClient.kt`
|
||||||
|
- Modify: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRemovedEndpointSourceTest.kt`
|
||||||
|
- 작업:
|
||||||
|
- legacy realtime `DmChatEventClient.kt`는 active main source에서 제거한다.
|
||||||
|
- legacy realtime parser/client 이력 테스트를 유지해야 하면 production source가 아닌 test fixture로 옮긴다.
|
||||||
|
- active DM main 코드에 아래 문자열이 남지 않는지 source test를 갱신한다.
|
||||||
|
- `/api/v2/user-creator-chat/rooms/{roomId}/events`
|
||||||
|
- `/api/v2/user-creator-chat/rooms/{roomId}/events/disconnect`
|
||||||
|
- `/api/v2/user-creator-chat/rooms/{roomId}/messages/text`
|
||||||
|
- `CONNECT\n`, `SUBSCRIBE`, STOMP `SEND`, `SockJS`
|
||||||
|
- 테스트 fixture나 문서 이력에 있는 문자열은 active source 검사 범위에서 제외한다.
|
||||||
|
- 검증:
|
||||||
|
- Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRemovedEndpointSourceTest" --max-workers=1`
|
||||||
|
- Expected: 제거 endpoint와 STOMP/SockJS frame 문자열이 active DM main 코드에 없음.
|
||||||
|
- 검증 기록:
|
||||||
|
- 2026-07-10: active main source의 legacy realtime `DmChatEventClient.kt`를 제거하고 관련 legacy test를 삭제했다. `DmChatRemovedEndpointSourceTest`를 갱신해 active DM main 코드에 `/events`, `/events/disconnect`, `/messages/text`, STOMP/SockJS frame 문자열이 남지 않음을 확인했다. `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRemovedEndpointSourceTest" --max-workers=1` PASS를 확인했다.
|
||||||
|
|
||||||
|
- [x] **Task 14.7: 채팅 메시지 UI 정렬과 텍스트 전송 UI 보정**
|
||||||
|
- Files:
|
||||||
|
- Modify: `app/src/main/res/layout/item_dm_chat_my_message.xml`
|
||||||
|
- Modify: `app/src/main/res/layout/item_dm_chat_opponent_message.xml`
|
||||||
|
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/ui/DmChatMessageAdapter.kt`
|
||||||
|
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomActivity.kt`
|
||||||
|
- Modify: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomActivitySourceTest.kt`
|
||||||
|
- 작업:
|
||||||
|
- 상대방 메시지는 왼쪽 정렬로 고정한다.
|
||||||
|
- 내가 보낸 메시지는 오른쪽 정렬로 고정한다.
|
||||||
|
- 현재 구현된 DM 채팅 버블 drawable/style을 유지하고 신규 버블 디자인을 만들지 않는다.
|
||||||
|
- DM 채팅방 입력 UI는 텍스트 메시지 전송만 제공한다.
|
||||||
|
- 음성 녹음, 음성 전송, 음성 재생 관련 view/callback이 DM 채팅방 UI에 노출되지 않도록 확인한다.
|
||||||
|
- 긴 텍스트는 현재 버블 폭 정책 안에서 줄바꿈되고, 말풍선이 화면 밖으로 밀리지 않도록 확인한다.
|
||||||
|
- 검증:
|
||||||
|
- Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomActivitySourceTest" --max-workers=1`
|
||||||
|
- Expected: 상대방 왼쪽/내 메시지 오른쪽 정렬, 기존 버블 사용, 텍스트 전송 UI만 노출, 음성 UI 미노출 source/layout 검증이 PASS.
|
||||||
|
- 검증 기록:
|
||||||
|
- 2026-07-10: 기존 DM 채팅 버블 drawable/style은 유지하고, source/layout 검증으로 상대방 메시지 왼쪽 정렬, 내 메시지 오른쪽 정렬, 텍스트 전송 UI만 노출, 음성 UI 미노출을 확인했다. `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomActivitySourceTest" --max-workers=1` PASS를 확인했다.
|
||||||
|
|
||||||
|
- [ ] **Task 14.8: 최신 계약 통합 검증과 실제 서버 수동 확인**
|
||||||
|
- Files:
|
||||||
|
- Check: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm`
|
||||||
|
- Check: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm`
|
||||||
|
- Run:
|
||||||
|
- `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.*" --max-workers=1`
|
||||||
|
- `./gradlew :app:compileDebugKotlin --max-workers=1`
|
||||||
|
- `./gradlew :app:ktlintCheck --max-workers=1`
|
||||||
|
- `git diff --check`
|
||||||
|
- 수동 확인:
|
||||||
|
- 실제 서버에서 WebSocket handshake `101` 후 response body unreadable 표시가 정상으로 간주되는지 확인한다.
|
||||||
|
- `JOIN_ROOM` raw JSON의 root `roomId`가 실제 room id이고 0이 아님을 확인한다.
|
||||||
|
- 서버가 `JOINED`, `SEND_ACK`, `MESSAGE`, `PONG`, `ERROR payload.messageKey`를 최신 계약대로 내려주는지 log로 확인한다.
|
||||||
|
- `SEND_TEXT` 전송 시 REST `/messages/text`가 호출되지 않음을 proxy/log로 확인한다.
|
||||||
|
- 화면에서 텍스트 메시지만 전송할 수 있고 음성 UI가 노출되지 않는지 확인한다.
|
||||||
|
- 상대방 메시지는 왼쪽, 내 메시지는 오른쪽에 현재 구현된 버블로 표시되는지 확인한다.
|
||||||
|
- `voiceon://chat/{roomId}` 또는 `voiceon-test://chat/{roomId}` push/deep link가 DM 채팅방으로 진입하고 일반 진입과 동일하게 `OpenRoom` 후 `JOIN_ROOM`을 수행하는지 확인한다.
|
||||||
|
- Expected:
|
||||||
|
- 자동 검증 PASS.
|
||||||
|
- 실제 서버에서 raw WebSocket 송수신 계약이 일치한다.
|
||||||
|
- 검증 기록:
|
||||||
|
- 2026-07-10: 자동 검증으로 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.*" --max-workers=1`, `./gradlew :app:compileDebugKotlin --max-workers=1`, `./gradlew :app:ktlintCheck --max-workers=1`, `git diff --check` PASS를 확인했다. `ktlintCheck`에서는 기존 `.editorconfig`의 `disabled_rules` deprecation warning만 출력됐다. 실제 서버/WebSocket 및 앱 화면 수동 확인은 연결된 기기와 서버 세션 증거가 없어 미완료로 유지한다.
|
||||||
|
- 2026-07-10: review-work blocker 수정 후 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomActivitySourceTest" --max-workers=1`, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.*" --max-workers=1`, `./gradlew :app:compileDebugKotlin --max-workers=1`, `./gradlew :app:ktlintCheck --max-workers=1`, `git diff --check` PASS를 재확인했다. 실제 서버/WebSocket 및 앱 화면 수동 확인은 여전히 환경 증거가 없어 미완료로 유지한다.
|
||||||
|
|
||||||
|
- [x] **Task 14.9: 전송 후 키보드 유지와 내 메시지 오른쪽 정렬 보정**
|
||||||
|
- Files:
|
||||||
|
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomActivity.kt`
|
||||||
|
- Modify: `app/src/main/res/layout/item_dm_chat_my_message.xml`
|
||||||
|
- Modify: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomActivitySourceTest.kt`
|
||||||
|
- 작업:
|
||||||
|
- 텍스트 메시지 전송 후 `EditText` focus와 soft keyboard를 유지한다.
|
||||||
|
- 내 메시지 `message_container`와 상태 텍스트 수평 체인을 오른쪽 packed 정렬로 고정한다.
|
||||||
|
- 검증:
|
||||||
|
- Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomActivitySourceTest" --max-workers=1`
|
||||||
|
- Expected: 전송 후 keyboard hide 호출이 없고, 내 메시지 bubble chain이 오른쪽으로 packed 정렬되는 source/layout 검증이 PASS.
|
||||||
|
|
||||||
|
- [x] **Task 14.10: WebSocket 연결 중 LoadingDialog 표시**
|
||||||
|
- Files:
|
||||||
|
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomViewModel.kt`
|
||||||
|
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomActivity.kt`
|
||||||
|
- Modify: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomViewModelTest.kt`
|
||||||
|
- Modify: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomActivitySourceTest.kt`
|
||||||
|
- 작업:
|
||||||
|
- WebSocket `connectRealtime()` 시작 후 `JOINED` 수신 전까지 연결 중 상태를 노출한다.
|
||||||
|
- DM 채팅방 Activity는 연결 중 상태가 true이면 `LoadingDialog`를 표시하고, `JOINED`/failure/leave 시 dismiss한다.
|
||||||
|
- 검증:
|
||||||
|
- Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomViewModelTest" --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomActivitySourceTest" --max-workers=1`
|
||||||
|
- Expected: 느린 WebSocket 연결 중 LoadingDialog 표시 경로와 상태 해제 회귀 테스트가 PASS.
|
||||||
|
|
||||||
|
- [x] **Task 14.11: 프로필 배경에 내 프로필 이미지 표시**
|
||||||
|
- Files:
|
||||||
|
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomActivity.kt`
|
||||||
|
- Modify: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomActivitySourceTest.kt`
|
||||||
|
- 작업:
|
||||||
|
- DM 채팅방 header foreground profile은 기존처럼 상대방 프로필 이미지를 유지한다.
|
||||||
|
- DM 채팅방 background profile은 현재 요구사항에 맞춰 내 프로필 이미지(`SharedPreferenceManager.profileImage`)를 표시한다.
|
||||||
|
- 검증:
|
||||||
|
- Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomActivitySourceTest" --max-workers=1`
|
||||||
|
- Expected: foreground profile은 상대방 프로필을 유지하고 background profile은 내 프로필 이미지를 사용하는 source 검증이 PASS.
|
||||||
|
|
||||||
|
### Phase 15: 채팅 empty state와 WebSocket ERROR UX 보정
|
||||||
|
|
||||||
|
- [x] **Task 15.1: 채팅 탭 empty state 문구 표시**
|
||||||
|
- Files:
|
||||||
|
- Modify: `docs/20260609_채팅_탭_페이지/prd.md`
|
||||||
|
- Modify: `docs/20260609_채팅_탭_페이지/plan-task.md`
|
||||||
|
- Modify: `app/src/main/res/layout/fragment_v2_main_chat.xml`
|
||||||
|
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/ChatMainFragment.kt`
|
||||||
|
- Modify: `app/src/main/res/values/strings.xml`
|
||||||
|
- Modify: `app/src/main/res/values-en/strings.xml`
|
||||||
|
- Modify: `app/src/main/res/values-ja/strings.xml`
|
||||||
|
- Modify: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/ChatMainFragmentLayoutTest.kt`
|
||||||
|
- 작업:
|
||||||
|
- 전체/AI 채팅/DM 탭의 빈 목록 응답에서 지정된 empty state 문구를 표시한다.
|
||||||
|
- Content/Loading/Error 상태에서는 empty state 문구를 숨긴다.
|
||||||
|
- 검증:
|
||||||
|
- Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.ChatMainFragmentLayoutTest" --max-workers=1`
|
||||||
|
- Expected: 탭별 empty state 문구와 visibility source/layout 검증이 PASS.
|
||||||
|
|
||||||
|
- [x] **Task 15.2: JOINED 전 WebSocket ERROR 재시도와 null roomId 필터링**
|
||||||
|
- Files:
|
||||||
|
- Modify: `docs/20260610_DM_채팅화면/prd.md`
|
||||||
|
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomViewModel.kt`
|
||||||
|
- Modify: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomViewModelTest.kt`
|
||||||
|
- 작업:
|
||||||
|
- `JOINED` 전 `ERROR` 수신 시 최대 3번 재시도한다.
|
||||||
|
- 재시도 후에도 실패하면 LoadingDialog 상태를 해제하고 `대화방에 접속하지 못했습니다` 오류를 표시한 뒤 socket을 닫고 화면을 종료한다.
|
||||||
|
- root `roomId`가 null인 `MESSAGE`/`SEND_ACK`는 malformed 이벤트로 보고 무시한다.
|
||||||
|
- 검증:
|
||||||
|
- Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomViewModelTest" --max-workers=1`
|
||||||
|
- Expected: pre-JOIN ERROR retry/terminal failure와 null roomId filtering 테스트가 PASS.
|
||||||
|
- 검증 기록:
|
||||||
|
- 2026-07-11: `JOINED` 전 `ERROR` 수신 시 최대 3회 재시도 후 `screen_dm_chat_room_enter_failed` 오류 상태, LoadingDialog 해제 상태, socket close, 화면 종료 이벤트를 발행하도록 보정했다. 또한 root `roomId == null`인 `MESSAGE`/`SEND_ACK`는 현재 방 이벤트로 허용하지 않도록 strict filtering으로 변경했다. 회귀 검증으로 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.ChatMainFragmentLayoutTest" --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomViewModelTest" --max-workers=1`, `./gradlew :app:compileDebugKotlin --max-workers=1`, `./gradlew :app:ktlintCheck --max-workers=1`, `git diff --check` PASS를 확인했다. `ktlintCheck`에서는 기존 `.editorconfig`의 `disabled_rules` deprecation warning이 출력됐지만 실패는 없었다.
|
||||||
|
|
||||||
|
### Phase 16: DM 메시지 시간 표시 후속 보정
|
||||||
|
|
||||||
|
- [x] **Task 16.1: DM 메시지 item createdAt 시간 표시**
|
||||||
|
- Files:
|
||||||
|
- Modify: `docs/20260610_DM_채팅화면/prd.md`
|
||||||
|
- Modify: `docs/20260610_DM_채팅화면/plan-task.md`
|
||||||
|
- Modify: `app/src/main/res/layout/item_dm_chat_my_message.xml`
|
||||||
|
- Modify: `app/src/main/res/layout/item_dm_chat_opponent_message.xml`
|
||||||
|
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/ui/DmChatMessageAdapter.kt`
|
||||||
|
- Modify: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomActivitySourceTest.kt`
|
||||||
|
- 작업:
|
||||||
|
- 내 메시지와 상대 메시지 item layout에 `tv_time` TextView를 추가한다.
|
||||||
|
- 기존 `bg_chat_user_message`, `bg_chat_ai_message` 말풍선과 내 메시지 오른쪽/상대 메시지 왼쪽 정렬을 유지한다.
|
||||||
|
- `DmChatMessageAdapter`에서 `DmChatMessageUiItem.createdAt`을 기존 `kr.co.vividnext.sodalive.chat.talk.room.formatMessageTime`으로 변환해 `tv_time`에 바인딩한다.
|
||||||
|
- source/layout 테스트에 `tv_time` 존재와 formatter import/call 검증을 추가한다.
|
||||||
|
- 검증:
|
||||||
|
- Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomActivitySourceTest" --max-workers=1`
|
||||||
|
- Run: `./gradlew :app:compileDebugKotlin --max-workers=1`
|
||||||
|
- Run: `./gradlew :app:ktlintCheck --max-workers=1`
|
||||||
|
- Run: `git diff --check`
|
||||||
|
- Expected: DM 메시지 source/layout 검증, Kotlin compile, ktlint, diff whitespace 검증이 PASS.
|
||||||
|
- 검증 기록:
|
||||||
|
- 2026-07-11: `item_dm_chat_my_message.xml`과 `item_dm_chat_opponent_message.xml`에 `tv_time`을 추가하고, `DmChatMessageAdapter`에서 `formatMessageTime(item.createdAt)`을 내 메시지/상대 메시지 모두에 바인딩했다. source/layout 회귀 테스트에 시간 TextView와 formatter import/call 검증을 추가했다. 검증으로 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomActivitySourceTest" --max-workers=1`, `./gradlew :app:compileDebugKotlin --max-workers=1`, `./gradlew :app:ktlintCheck --max-workers=1`, `git diff --check` PASS를 확인했다. `ktlintCheck`에서는 기존 `.editorconfig`의 `disabled_rules` deprecation warning만 출력됐고 실패는 없었다.
|
||||||
|
- 2026-07-11: 시간 표시 데이터는 서버 DTO `DmChatMessageResponse.createdAt`과 UI 모델 `DmChatMessageUiItem.createdAt`에 이미 존재하고 매퍼에서 보존됨을 확인했다. UI는 해당 값을 기존 `formatMessageTime(createdAt)`으로 변환해 `tv_time`에 표시하므로, 별도 서버 필드 추가 없이 메시지 송수신 시간이 표시된다.
|
||||||
|
|
||||||
|
### Phase 17: WebSocket close 재연결과 전송 수락 여부 보정
|
||||||
|
|
||||||
|
- [x] **Task 17.1: socket close와 heartbeat PING 실패를 연결 끊김으로 처리**
|
||||||
|
- Files:
|
||||||
|
- Modify: `docs/20260610_DM_채팅화면/prd.md`
|
||||||
|
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/data/DmChatSocketClient.kt`
|
||||||
|
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomViewModel.kt`
|
||||||
|
- Modify: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatSocketClientTest.kt`
|
||||||
|
- Modify: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomViewModelTest.kt`
|
||||||
|
- 작업:
|
||||||
|
- WebSocket `onClosing`/`onClosed`를 현재 listener의 연결 끊김 신호로 한 번만 전달한다.
|
||||||
|
- ViewModel은 socket failure/close callback을 동일한 disconnect 처리로 모아 heartbeat 정리, socket close, foreground 조건 재연결 예약을 수행한다.
|
||||||
|
- heartbeat `PING` 전송이 false를 반환하면 `PONG` timeout을 기다리지 않고 연결 끊김으로 처리한다.
|
||||||
|
- 검증:
|
||||||
|
- Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatSocketClientTest" --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomViewModelTest" --max-workers=1`
|
||||||
|
- Expected: socket close callback, heartbeat PING send false 재연결 테스트가 PASS.
|
||||||
|
|
||||||
|
- [x] **Task 17.2: 전송 미수락 시 입력값 보존**
|
||||||
|
- Files:
|
||||||
|
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomViewModel.kt`
|
||||||
|
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomActivity.kt`
|
||||||
|
- Modify: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomViewModelTest.kt`
|
||||||
|
- Modify: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomActivitySourceTest.kt`
|
||||||
|
- 작업:
|
||||||
|
- `sendText(text)`는 blank, invalid room, `JOINED` 전/재연결 중이면 `false`를 반환한다.
|
||||||
|
- 메시지가 pending/failed local item으로 ViewModel 상태에 수락된 경우에만 `true`를 반환한다.
|
||||||
|
- Activity는 `sendText(text) == true`일 때만 입력창을 비우고 전송 버튼을 비활성화한다.
|
||||||
|
- 검증:
|
||||||
|
- Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomViewModelTest" --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomActivitySourceTest" --max-workers=1`
|
||||||
|
- Expected: JOINED 전 전송 false와 Activity conditional clear source 검증이 PASS.
|
||||||
|
|
||||||
|
|
||||||
## 5. 검증 기록
|
## 5. 검증 기록
|
||||||
- 2026-06-10: `docs/20260610_DM_채팅화면/prd.md`를 확인해 DM 채팅방 진입, UI 제거 대상, REST API, SSE 이벤트, pagination, 전송 실패/재시도, lifecycle disconnect 요구사항을 계획에 반영했다.
|
- 2026-06-10: `docs/20260610_DM_채팅화면/prd.md`를 확인해 DM 채팅방 진입, UI 제거 대상, REST API, SSE 이벤트, pagination, 전송 실패/재시도, lifecycle disconnect 요구사항을 계획에 반영했다.
|
||||||
- 2026-06-10: `docs/agent-guides/work-plan-docs.md`, `docs/agent-guides/build-test-style.md`, `docs/agent-guides/code-style.md`를 확인해 신규 계획 문서 위치, phase/task 체크박스 형식, 테스트 명령 작성 방식을 확인했다.
|
- 2026-06-10: `docs/agent-guides/work-plan-docs.md`, `docs/agent-guides/build-test-style.md`, `docs/agent-guides/code-style.md`를 확인해 신규 계획 문서 위치, phase/task 체크박스 형식, 테스트 명령 작성 방식을 확인했다.
|
||||||
@@ -973,3 +1302,13 @@
|
|||||||
- 2026-06-18: 현재 코드 기준으로 `DmChatApi`의 `/messages/text`, `/events/disconnect`, `DmChatEventClient`, `DmChatRoomViewModel.sendText()` REST 전송, `disconnectRealtime()` SSE 해제 경로가 남아 있음을 확인했고, 이를 Phase 9~13에서 제거/교체할 대상으로 문서화했다. 이번 단계는 계획 문서 수정만 수행했으며 Android 구현, 빌드, 테스트는 실행하지 않았다.
|
- 2026-06-18: 현재 코드 기준으로 `DmChatApi`의 `/messages/text`, `/events/disconnect`, `DmChatEventClient`, `DmChatRoomViewModel.sendText()` REST 전송, `disconnectRealtime()` SSE 해제 경로가 남아 있음을 확인했고, 이를 Phase 9~13에서 제거/교체할 대상으로 문서화했다. 이번 단계는 계획 문서 수정만 수행했으며 Android 구현, 빌드, 테스트는 실행하지 않았다.
|
||||||
- 2026-06-18: Phase 10의 Task 10.1~10.3 코드 리뷰 및 검증을 수행했다. `DmChatRoomViewModel`의 `JOINED` 기준 연결 확정, WebSocket `MESSAGE` 병합, `requestId` 단위 pending/SEND_ACK/ERROR/timeout/retry 처리를 확인했고 blocking issue는 발견하지 못했다. `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomViewModelTest" --max-workers=1`, `./gradlew :app:compileDebugKotlin --max-workers=1`, `./gradlew :app:ktlintCheck --max-workers=1`, `git diff --check` PASS를 확인했다. `ktlintCheck`와 Gradle 실행에서는 기존 Gradle deprecation warning만 출력됐고 실패는 없었다. Task 10.4의 MESSAGE/SEND_ACK race와 Task 10.5 회귀 테스트 정리는 후속 미완료 범위로 유지한다.
|
- 2026-06-18: Phase 10의 Task 10.1~10.3 코드 리뷰 및 검증을 수행했다. `DmChatRoomViewModel`의 `JOINED` 기준 연결 확정, WebSocket `MESSAGE` 병합, `requestId` 단위 pending/SEND_ACK/ERROR/timeout/retry 처리를 확인했고 blocking issue는 발견하지 못했다. `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomViewModelTest" --max-workers=1`, `./gradlew :app:compileDebugKotlin --max-workers=1`, `./gradlew :app:ktlintCheck --max-workers=1`, `git diff --check` PASS를 확인했다. `ktlintCheck`와 Gradle 실행에서는 기존 Gradle deprecation warning만 출력됐고 실패는 없었다. Task 10.4의 MESSAGE/SEND_ACK race와 Task 10.5 회귀 테스트 정리는 후속 미완료 범위로 유지한다.
|
||||||
- 2026-06-19: 사용자 제공 최신 FCM payload 계약을 반영해 파일 구조와 Phase 13 수동 확인 항목의 푸시 진입 기준을 `deep_link=${URISCHEME}://chat/{roomId}`로 갱신했다. Phase 12에는 기존 `chat_type`/`room_id` 기준 완료 이력을 보존한다는 주석을 추가하고, 새 계약 반영 범위를 미완료 `Task 12.5`로 추가했다. 이번 단계는 계획 문서 수정만 수행했으며 Android 구현, 빌드, 테스트는 실행하지 않았다.
|
- 2026-06-19: 사용자 제공 최신 FCM payload 계약을 반영해 파일 구조와 Phase 13 수동 확인 항목의 푸시 진입 기준을 `deep_link=${URISCHEME}://chat/{roomId}`로 갱신했다. Phase 12에는 기존 `chat_type`/`room_id` 기준 완료 이력을 보존한다는 주석을 추가하고, 새 계약 반영 범위를 미완료 `Task 12.5`로 추가했다. 이번 단계는 계획 문서 수정만 수행했으며 Android 구현, 빌드, 테스트는 실행하지 않았다.
|
||||||
|
- 2026-07-10: 사용자 제공 최신 raw WebSocket 계약을 기준으로 `DmChatSocketModels.kt`, `DmChatSocketClient.kt`, `DmChatRepository.kt`, `DmChatRoomViewModel.kt`, `DmChatApi.kt`, `DmChatModels.kt`, `DmChatEventClient.kt`, `AppDI.kt`를 확인했다. 현재 구현은 REST create/open/messages와 제거된 `/events/disconnect`, `/messages/text` 미사용은 대체로 맞지만, 기존 SSE `DmChatEventClient.kt`와 `/events` URL 정의가 main source에 남아 있고 WebSocket root envelope와 server payload 파싱이 최신 계약과 다르다. 이에 따라 Phase 14에 envelope/parser/client/ViewModel/logging/voice multipart/SSE 제거/회귀 검증 작업을 추가했다. 이번 단계는 문서 보완만 수행했으며 Android 구현, 빌드, 테스트는 실행하지 않았다.
|
||||||
|
- 2026-07-10: 사용자 추가 요청을 반영해 Phase 14 active 범위를 raw WebSocket 보정, legacy realtime 제거, 텍스트 전송 UI, 상대방 왼쪽/내 메시지 오른쪽 정렬, 기존 DM 채팅 버블 유지로 재정리했다. 음성 메시지는 DTO/API 계약만 보존하고 이번 UI에는 연결하지 않도록 Task 14.5, 14.7, 14.8에 반영했다. 이번 단계는 문서 보완만 수행했으며 Android 구현, 빌드, 테스트는 실행하지 않았다.
|
||||||
|
- 2026-07-10: Phase 14 구현으로 WebSocket client/server envelope를 root `type`/`requestId`/`roomId`/`payload` 계약에 맞췄고, `SEND_ACK`/`MESSAGE` direct payload, `ERROR payload.messageKey`, `JOINED` 이후 전송 gate, UUID requestId, 15초 SEND_ACK timeout, raw JSON logging, voice multipart API/DTO, legacy realtime production source 제거, DM 채팅 UI 회귀 검증을 반영했다. 자동 검증으로 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.*" --max-workers=1`, `./gradlew :app:compileDebugKotlin --max-workers=1`, `./gradlew :app:ktlintCheck --max-workers=1`, `git diff --check` PASS를 확인했다. 실제 서버/WebSocket 및 앱 화면 수동 확인은 연결된 기기와 서버 세션 증거가 없어 Task 14.8 미완료로 유지한다.
|
||||||
|
- 2026-07-10: review-work에서 `DmChatSocketClient`의 `rawJsonLogger` seam은 있으나 `AppDI` production 생성 경로가 no-op 기본값을 사용한다는 blocker를 발견했다. `AppDI.kt`에 `Logger.d("DM WebSocket raw JSON: $rawJson")` 주입을 추가하고 `DmChatRoomActivitySourceTest`에 회귀 검증을 추가했다. 재검증으로 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomActivitySourceTest" --max-workers=1`, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.*" --max-workers=1`, `./gradlew :app:compileDebugKotlin --max-workers=1`, `./gradlew :app:ktlintCheck --max-workers=1`, `git diff --check` PASS를 확인했다.
|
||||||
|
- 2026-07-10: Task 14.10으로 WebSocket `connectRealtime()` 시작 후 `JOINED` 전까지 `LoadingDialog`를 표시하고, `JOINED`/failure/leave/onCleared 시 dismiss되도록 `DmChatRoomViewModel`과 `DmChatRoomActivity`를 보강했다. 회귀 검증으로 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomViewModelTest" --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomActivitySourceTest" --max-workers=1`, `./gradlew :app:compileDebugKotlin --max-workers=1`, `./gradlew :app:ktlintCheck --max-workers=1`, `git diff --check` PASS를 확인했다. `ktlintCheck`에서는 기존 `.editorconfig`의 `disabled_rules` deprecation warning이 출력됐지만 실패는 없었다.
|
||||||
|
- 2026-07-11: DM 채팅방 header foreground profile은 `state.opponentProfileImageUrl`과 `CircleCropTransformation()`을 유지하고, background profile은 `SharedPreferenceManager.profileImage`를 사용하도록 `DmChatRoomActivity`를 보정했다. `DmChatRoomActivitySourceTest`에 background binding 블록이 `SharedPreferenceManager.profileImage`를 포함하고 `state.opponentProfileImageUrl`를 포함하지 않는 회귀 검증을 추가했다. `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomActivitySourceTest" --max-workers=1`, `./gradlew :app:compileDebugKotlin --max-workers=1`, `./gradlew :app:ktlintCheck --max-workers=1`, `git diff --check` PASS를 확인했다. `ktlintCheck`에서는 기존 `.editorconfig`의 `disabled_rules` deprecation warning이 출력됐지만 실패는 없었다.
|
||||||
|
- 2026-07-11: Phase 15 보정 완료. 채팅 탭 empty state 문구 표시, `JOINED` 전 WebSocket `ERROR` 3회 재시도 후 실패 UX, null root `roomId` `MESSAGE`/`SEND_ACK` 무시를 구현하고 source/unit 테스트로 고정했다. `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.ChatMainFragmentLayoutTest" --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomViewModelTest" --max-workers=1`, `./gradlew :app:compileDebugKotlin --max-workers=1`, `./gradlew :app:ktlintCheck --max-workers=1`, `git diff --check` PASS를 확인했다. `ktlintCheck`에서는 기존 `.editorconfig`의 `disabled_rules` deprecation warning이 출력됐지만 실패는 없었다. 실제 서버/WebSocket 및 앱 화면 수동 확인은 연결된 기기와 서버 세션 증거가 없어 수행하지 못했다.
|
||||||
|
- 2026-07-11: Phase 16 작업 전 `prd.md`, `plan-task.md`, DM 메시지 item layout, `DmChatMessageAdapter.kt`, `DmChatRoomActivitySourceTest.kt`, 기존 AI 채팅 메시지 layout/adapter의 시간 표시 패턴을 확인했다. 기존 AI 채팅은 `tv_time`과 `formatMessageTime(createdAt)`을 사용하고 있어 DM에도 같은 formatter를 사용하도록 계획했다.
|
||||||
|
- 2026-07-11: Phase 16 완료. PRD에 DM 메시지 item createdAt 시간 표시 요구사항과 성공 기준을 추가했고, 계획 문서에 Phase 16/Task 16.1을 완료 상태로 누적했다. DM 내 메시지/상대 메시지 layout에는 `tv_time`을 추가하되 기존 말풍선 drawable과 좌우 정렬을 유지했다. `DmChatMessageAdapter`는 기존 `formatMessageTime(item.createdAt)`로 시간을 바인딩한다. 검증으로 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomActivitySourceTest" --max-workers=1`, `./gradlew :app:compileDebugKotlin --max-workers=1`, `./gradlew :app:ktlintCheck --max-workers=1`, `git diff --check` PASS를 확인했다. 실제 기기/에뮬레이터 화면 수동 확인은 현재 실행 가능한 디바이스 세션 증거가 없어 source/layout/compile 검증으로 대체했다.
|
||||||
|
- 2026-07-11: Phase 17 리뷰 보정으로 WebSocket close frame/closing 상태를 current listener의 disconnect signal로 전달하고, ViewModel에서 socket failure/close 및 heartbeat `PING` 전송 실패를 동일한 reconnect 경로로 처리하도록 보강했다. 또한 `sendText(text)`가 수락 여부를 `Boolean`으로 반환하고, Activity는 수락된 경우에만 입력창을 비우도록 수정했다. 회귀 검증으로 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatSocketClientTest" --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomViewModelTest" --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomActivitySourceTest" --max-workers=1`, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.*" --max-workers=1`, `./gradlew :app:compileDebugKotlin --max-workers=1`, `./gradlew :app:ktlintCheck --max-workers=1`, `git diff --check` PASS를 확인했다. `ktlintCheck`에서는 기존 `.editorconfig`의 `disabled_rules` deprecation warning만 출력됐고 실패는 없었다.
|
||||||
|
|||||||
@@ -9,11 +9,13 @@
|
|||||||
- 기존 `ChatRoomActivity`는 AI 캐릭터 채팅방 기준 화면으로, 캐릭터 타입 배지, CAN 배지, 더보기, 안내 메시지, 쿼터/유료 메시지 흐름이 포함되어 있다.
|
- 기존 `ChatRoomActivity`는 AI 캐릭터 채팅방 기준 화면으로, 캐릭터 타입 배지, CAN 배지, 더보기, 안내 메시지, 쿼터/유료 메시지 흐름이 포함되어 있다.
|
||||||
- 채팅 탭의 DM item 클릭 시 이동할 DM 상세 화면이 아직 별도 범위로 구현되어 있지 않다.
|
- 채팅 탭의 DM item 클릭 시 이동할 DM 상세 화면이 아직 별도 범위로 구현되어 있지 않다.
|
||||||
- DM 채팅은 AI 채팅과 다르게 크리에이터와 사용자 간 메시지 송수신, WebSocket 실시간 연결/해제, 커서 기반 과거 메시지 조회가 핵심이다.
|
- DM 채팅은 AI 채팅과 다르게 크리에이터와 사용자 간 메시지 송수신, WebSocket 실시간 연결/해제, 커서 기반 과거 메시지 조회가 핵심이다.
|
||||||
- 기존 개발 중 테스트 앱은 채팅방 화면 진입 시 `GET /api/v2/user-creator-chat/rooms/{roomId}/events` SSE 연결을 사용했고, 화면 이탈/백그라운드/로그아웃 시 `POST /api/v2/user-creator-chat/rooms/{roomId}/events/disconnect`를 호출했다.
|
- 이전 실시간 구현 흔적과 제거된 `/events` 계열 endpoint 정의가 남아 있으면 서버 계약과 혼동되고, raw WebSocket 동작 검증도 어려워진다.
|
||||||
- 서버 배포와 같은 릴리스 범위에서 위 SSE endpoint와 disconnect endpoint가 제거되므로, 네이티브 앱은 같은 생명주기 위치를 WebSocket `JOIN_ROOM`/`LEAVE_ROOM`/close 흐름으로 전환해야 한다.
|
|
||||||
- REST pagination과 WebSocket 실시간 수신 결과가 겹칠 수 있으므로 메시지 병합/중복 제거 기준이 필요하다.
|
- REST pagination과 WebSocket 실시간 수신 결과가 겹칠 수 있으므로 메시지 병합/중복 제거 기준이 필요하다.
|
||||||
- 텍스트 메시지 전송 성공 판단이 REST 응답의 `deliveredRealtime`/`pushSent`가 아니라 WebSocket `SEND_ACK`/`ERROR`/timeout 기준으로 바뀌므로 pending 메시지 매칭 기준이 필요하다.
|
- 텍스트 메시지 전송 성공 판단이 REST 응답의 `deliveredRealtime`/`pushSent`가 아니라 WebSocket `SEND_ACK`/`ERROR`/timeout 기준으로 바뀌므로 pending 메시지 매칭 기준이 필요하다.
|
||||||
- 크리에이터 채널에서 `DM 보내기`를 눌러 `creatorId` 기반으로 `DmChatRoomActivity`에 진입하면 `DmChatRoomViewModel.emitContent()`가 background thread에서 `MutableLiveData.setValue()`를 호출해 앱이 crash 된다.
|
- 크리에이터 채널에서 `DM 보내기`를 눌러 `creatorId` 기반으로 `DmChatRoomActivity`에 진입하면 `DmChatRoomViewModel.emitContent()`가 background thread에서 `MutableLiveData.setValue()`를 호출해 앱이 crash 된다.
|
||||||
|
- 2026-07-10 확인 결과, 현재 WebSocket 문서/구현 일부는 최신 서버 계약과 다르게 `roomId`/`requestId`를 root가 아닌 payload 안에 넣거나, 서버 payload를 `{message: ...}` wrapper로 파싱하는 전제에 기대고 있다. 최신 계약은 raw WebSocket JSON의 root에 `type`, `requestId`, `roomId`, `payload`를 둔다.
|
||||||
|
- WebSocket 전환 후 `AppDI`/Repository 경로는 `DmChatSocketClient`를 사용하지만, 기존 realtime 구현 파일 `DmChatEventClient.kt`와 `/api/v2/user-creator-chat/rooms/{roomId}/events` URL 정의가 main source에 남아 있다. 실제 호출 여부와 별개로 최신 계약 보정 시 제거해야 한다.
|
||||||
|
- 현재 채팅 UI는 말풍선 정렬과 전송 UI 범위가 불명확하다. 상대방 메시지는 왼쪽, 내가 보낸 메시지는 오른쪽으로 고정하고, 현재 구현된 채팅 버블을 그대로 사용해야 한다.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -25,19 +27,25 @@
|
|||||||
- 텍스트 메시지 전송 후 서버 응답 메시지를 화면에 반영한다.
|
- 텍스트 메시지 전송 후 서버 응답 메시지를 화면에 반영한다.
|
||||||
- 채팅방 화면 진입/이탈, 앱 foreground/background 전환, 로그아웃에 따른 WebSocket 연결/해제 정책을 정의한다.
|
- 채팅방 화면 진입/이탈, 앱 foreground/background 전환, 로그아웃에 따른 WebSocket 연결/해제 정책을 정의한다.
|
||||||
- WebSocket 메시지 타입, `JOINED` 기준 연결 확인, `MESSAGE` 수신, `SEND_TEXT`/`SEND_ACK` pending 확정, `PING`/`PONG` heartbeat, 재연결과 최신 메시지 동기화 기준을 정의한다.
|
- WebSocket 메시지 타입, `JOINED` 기준 연결 확인, `MESSAGE` 수신, `SEND_TEXT`/`SEND_ACK` pending 확정, `PING`/`PONG` heartbeat, 재연결과 최신 메시지 동기화 기준을 정의한다.
|
||||||
- 제거되는 SSE endpoint와 `events/disconnect` endpoint를 더 이상 호출하지 않도록 migration 범위를 명확히 한다.
|
- raw WebSocket envelope를 최신 서버 계약 그대로 고정한다. STOMP/SockJS frame은 보내지 않고, 모든 client send JSON은 `type`, `requestId`, `roomId`, `payload` root field를 사용한다.
|
||||||
|
- 제거된 `/events`, `/events/disconnect`, `/messages/text` endpoint와 관련 구현을 active DM 채팅 경로에서 제거한다.
|
||||||
|
- DM 채팅 화면에서는 텍스트 메시지만 전송할 수 있도록 UI 범위를 고정한다.
|
||||||
|
- 메시지 UI는 현재 구현된 채팅 버블을 유지하고, 상대방 메시지는 왼쪽 정렬, 내 메시지는 오른쪽 정렬로 고정한다.
|
||||||
- 크리에이터 채널 `DM 보내기` 진입에서 방 생성/열기 완료 후 모든 `LiveData` 상태 갱신이 main thread에서 수행되어 background thread `setValue()` 예외가 발생하지 않도록 한다.
|
- 크리에이터 채널 `DM 보내기` 진입에서 방 생성/열기 완료 후 모든 `LiveData` 상태 갱신이 main thread에서 수행되어 background thread `setValue()` 예외가 발생하지 않도록 한다.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. Non-Goals
|
## 4. Non-Goals
|
||||||
- AI 캐릭터 채팅방의 쿼터 구매, 광고 보상, 유료 메시지 구매, 채팅 리셋 기능은 DM 채팅화면에 포함하지 않는다.
|
- AI 캐릭터 채팅방의 쿼터 구매, 광고 보상, 유료 메시지 구매, 채팅 리셋 기능은 DM 채팅화면에 포함하지 않는다.
|
||||||
|
- STOMP/SockJS `CONNECT`, `SUBSCRIBE`, `SEND` frame 전송은 포함하지 않는다.
|
||||||
|
- SSE/EventSource 기반 실시간 연결은 구현하거나 유지하지 않는다.
|
||||||
- `character_type_badge`, `ll_can_badge`, `iv_more`, `notice_container`는 DM 화면에 표시하지 않는다.
|
- `character_type_badge`, `ll_can_badge`, `iv_more`, `notice_container`는 DM 화면에 표시하지 않는다.
|
||||||
- 음성 메시지는 기존 multipart REST API를 유지한다. 단, 음성 메시지 전송/재생 UI 변경은 이번 WebSocket 텍스트 전송 전환 범위에 포함하지 않는다.
|
- 음성 메시지 API/DTO는 구현하더라도 이번 화면 UI에서는 사용하지 않는다.
|
||||||
|
- 음성 메시지 녹음, 전송 버튼, 재생 UI는 이번 범위에 포함하지 않는다.
|
||||||
- 메시지 삭제, 신고, 차단, 알림 설정, 읽음 처리, unread count 실시간 갱신은 이번 범위에 포함하지 않는다.
|
- 메시지 삭제, 신고, 차단, 알림 설정, 읽음 처리, unread count 실시간 갱신은 이번 범위에 포함하지 않는다.
|
||||||
- 백엔드 API 스키마와 필드명을 Android에서 임의 변경하지 않는다. Kotlin class 이름만 프로젝트 가이드에 맞게 조정한다.
|
- 백엔드 API 스키마와 필드명을 Android에서 임의 변경하지 않는다. Kotlin class 이름만 프로젝트 가이드에 맞게 조정한다.
|
||||||
- 기존 AI `ChatRoomActivity` 동작을 변경하거나 리팩터링하지 않는다.
|
- 기존 AI `ChatRoomActivity` 동작을 변경하거나 리팩터링하지 않는다.
|
||||||
- 음성 메시지 UI가 아직 정해지지 않았으므로 `messageType=VOICE` 메시지는 DTO에는 보존하되 이번 구현 대상 UI에서 제외한다.
|
- `messageType=VOICE` 메시지는 DTO에는 보존하되 이번 구현 대상 UI에서 제외한다.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -104,6 +112,13 @@ data class CreateUserCreatorChatRoomResponse(
|
|||||||
- `rv_messages`의 top constraint는 `notice_container`가 아닌 `header_container` 하단을 기준으로 조정한다.
|
- `rv_messages`의 top constraint는 `notice_container`가 아닌 `header_container` 하단을 기준으로 조정한다.
|
||||||
- 입력 영역은 기존 `et_message`, `iv_send`와 유사하게 텍스트 입력 및 전송 버튼을 제공한다.
|
- 입력 영역은 기존 `et_message`, `iv_send`와 유사하게 텍스트 입력 및 전송 버튼을 제공한다.
|
||||||
- 전송 버튼은 입력값이 blank이면 비활성화하고, 입력값이 있으면 활성화한다.
|
- 전송 버튼은 입력값이 blank이면 비활성화하고, 입력값이 있으면 활성화한다.
|
||||||
|
- DM 채팅방 입력 UI는 텍스트 전송만 제공한다.
|
||||||
|
- 음성 녹음, 음성 전송, 음성 재생 컨트롤은 이번 화면에 노출하지 않는다.
|
||||||
|
- 상대방 메시지는 왼쪽 정렬로 표시한다.
|
||||||
|
- 내가 보낸 메시지는 오른쪽 정렬로 표시한다.
|
||||||
|
- 채팅 버블 drawable/style은 현재 구현된 DM 채팅 버블을 그대로 사용하고, 신규 버블 디자인을 만들지 않는다.
|
||||||
|
- DM 메시지 item에는 `DmChatMessageUiItem.createdAt`을 기존 메시지 시간 포맷터(`formatMessageTime`)로 변환한 시간을 표시한다.
|
||||||
|
- 채팅 탭 목록의 empty state 문구는 탭별 정책을 따른다. 전체 탭은 `아직 나눈 대화가 없어요.\n크리에이터와 이야기를 시작해 보세요.`, AI 채팅 탭은 `아직 나눈 대화가 없어요.\nAI 크리에이터와 대화를 시작해 보세요.`, DM 탭은 `아직 나눈 대화가 없어요.\n좋아하는 크리에이터에게 메세지를 보내보세요.`를 표시한다.
|
||||||
|
|
||||||
#### Edge Cases
|
#### Edge Cases
|
||||||
- 상대 프로필 이미지 URL이 비어 있거나 로딩 실패하면 기존 placeholder 정책을 따른다.
|
- 상대 프로필 이미지 URL이 비어 있거나 로딩 실패하면 기존 placeholder 정책을 따른다.
|
||||||
@@ -119,7 +134,8 @@ data class CreateUserCreatorChatRoomResponse(
|
|||||||
- 응답의 `messages`를 오래된 메시지부터 최신 메시지 순으로 정렬해 표시한다.
|
- 응답의 `messages`를 오래된 메시지부터 최신 메시지 순으로 정렬해 표시한다.
|
||||||
- `hasMore`, `nextCursor`를 저장해 과거 메시지 조회 상태로 사용한다.
|
- `hasMore`, `nextCursor`를 저장해 과거 메시지 조회 상태로 사용한다.
|
||||||
- 응답의 `opponentNickname`, `opponentProfileImageUrl`을 header 상대 정보로 사용한다.
|
- 응답의 `opponentNickname`, `opponentProfileImageUrl`을 header 상대 정보로 사용한다.
|
||||||
- `mine=true`인 메시지는 사용자 발신 말풍선, `mine=false`인 메시지는 상대 발신 말풍선으로 표시한다.
|
- `mine=true`인 메시지는 오른쪽 정렬 사용자 발신 말풍선으로 표시한다.
|
||||||
|
- `mine=false`인 메시지는 왼쪽 정렬 상대방 발신 말풍선으로 표시한다.
|
||||||
- `senderNickname`, `senderProfileImageUrl`은 상대 메시지 item 표시와 메시지별 발신자 정보가 필요한 경우에 활용한다.
|
- `senderNickname`, `senderProfileImageUrl`은 상대 메시지 item 표시와 메시지별 발신자 정보가 필요한 경우에 활용한다.
|
||||||
|
|
||||||
#### API Contract
|
#### API Contract
|
||||||
@@ -158,32 +174,59 @@ data class UserCreatorChatRoomOpenResponse(
|
|||||||
채팅방이 열려 있는 동안 WebSocket으로 방 참여 상태를 만들고, `JOINED` 수신을 실시간 수신 가능 기준으로 삼는다.
|
채팅방이 열려 있는 동안 WebSocket으로 방 참여 상태를 만들고, `JOINED` 수신을 실시간 수신 가능 기준으로 삼는다.
|
||||||
|
|
||||||
#### Requirements
|
#### Requirements
|
||||||
- 클라이언트는 채팅방 화면 진입 시 기존 `GET /api/v2/user-creator-chat/rooms/{roomId}/events` SSE 연결을 열지 않는다.
|
- 클라이언트는 채팅방 화면 진입 시 제거된 `GET /api/v2/user-creator-chat/rooms/{roomId}/events` endpoint를 열지 않는다.
|
||||||
- 화면 진입 시 기존 `GET /api/v2/user-creator-chat/rooms/{roomId}/open`으로 초기 메시지와 상대방 정보를 먼저 조회한다.
|
- 화면 진입 시 기존 `GET /api/v2/user-creator-chat/rooms/{roomId}/open`으로 초기 메시지와 상대방 정보를 먼저 조회한다.
|
||||||
- `OpenRoom` 성공 후 WebSocket `/ws/v2/user-creator-chat`에 연결한다.
|
- `OpenRoom` 성공 후 WebSocket `/ws/v2/user-creator-chat`에 연결한다.
|
||||||
|
- WebSocket URL은 기존 REST base URL의 host를 사용해 `wss://{host}/ws/v2/user-creator-chat`로 만든다. local/dev base URL이 `http://`이면 `ws://`, `https://`이면 `wss://`로 변환한다.
|
||||||
- WebSocket handshake에는 기존 API와 동일한 access token을 `Authorization: Bearer <accessToken>` 헤더로 전달한다.
|
- WebSocket handshake에는 기존 API와 동일한 access token을 `Authorization: Bearer <accessToken>` 헤더로 전달한다.
|
||||||
- WebSocket 연결 직후 현재 `roomId` 기준 `JOIN_ROOM` 메시지를 전송한다.
|
- WebSocket handshake 성공 `code=101` 응답의 body unreadable 표시는 정상으로 본다.
|
||||||
|
- WebSocket 연결 직후라도 `roomId`가 0/null이면 어떤 메시지도 보내지 않는다. `OpenRoom` 성공 후 실제 `roomId`가 준비된 뒤에만 현재 `roomId` 기준 `JOIN_ROOM` 메시지를 전송한다.
|
||||||
- `JOINED`를 수신하면 해당 채팅방이 실시간 수신 상태라고 판단한다.
|
- `JOINED`를 수신하면 해당 채팅방이 실시간 수신 상태라고 판단한다.
|
||||||
- 기존 SSE `connected` 이벤트 기반 연결 확인 로직은 WebSocket `JOINED` 수신 기준으로 변경한다.
|
- UI의 "실시간 연결됨" 상태는 WebSocket `onOpen`이 아니라 `JOINED` 수신 이후에만 표시한다.
|
||||||
|
- 이전 realtime `connected` 이벤트 기반 연결 확인 로직은 WebSocket `JOINED` 수신 기준으로 변경한다.
|
||||||
- access token refresh가 발생하면 기존 WebSocket을 close하고 새 token의 `Authorization` 헤더로 다시 연결한 뒤 `JOIN_ROOM`을 다시 보낸다.
|
- access token refresh가 발생하면 기존 WebSocket을 close하고 새 token의 `Authorization` 헤더로 다시 연결한 뒤 `JOIN_ROOM`을 다시 보낸다.
|
||||||
|
- WebSocket send 전 모든 raw JSON을 로그로 남기고, `onMessage`로 받은 raw JSON도 로그로 남긴다. 단, Authorization token은 로그에 남기지 않는다.
|
||||||
|
|
||||||
#### WebSocket Message Types
|
#### WebSocket Message Types
|
||||||
- Client to Server: `JOIN_ROOM`, `LEAVE_ROOM`, `SEND_TEXT`, `PING`
|
- Client to Server: `JOIN_ROOM`, `LEAVE_ROOM`, `SEND_TEXT`, `PING`
|
||||||
- Server to Client: `JOINED`, `MESSAGE`, `SEND_ACK`, `ERROR`, `PONG`
|
- Server to Client: `JOINED`, `MESSAGE`, `SEND_ACK`, `ERROR`, `PONG`
|
||||||
- 메시지 envelope의 정확한 JSON 필드명은 서버 계약을 따른다. Android 구현은 type과 payload를 분리해 파싱할 수 있어야 한다.
|
- 모든 WebSocket 메시지는 raw JSON object이며 root field는 `type`, `requestId`, `roomId`, `payload`다.
|
||||||
|
- Client request의 `requestId`는 UUID 문자열을 사용한다.
|
||||||
|
- `JOIN_ROOM`, `SEND_TEXT`, `PING`, `LEAVE_ROOM`은 `roomId > 0`일 때만 보낸다.
|
||||||
|
- `SEND_TEXT`, `PING`, `LEAVE_ROOM`은 `JOINED` 상태 이후에만 허용한다. `JOINED` 전 화면 이탈은 `LEAVE_ROOM` 없이 normal close만 수행한다.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"type":"JOIN_ROOM","requestId":"uuid","roomId":10,"payload":{}}
|
||||||
|
{"type":"SEND_TEXT","requestId":"uuid","roomId":10,"payload":{"textMessage":"hello"}}
|
||||||
|
{"type":"LEAVE_ROOM","requestId":"uuid","roomId":10,"payload":{}}
|
||||||
|
{"type":"PING","requestId":"uuid","roomId":10,"payload":{}}
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"type":"JOINED","requestId":"uuid","roomId":10,"payload":{}}
|
||||||
|
{"type":"SEND_ACK","requestId":"send uuid","roomId":10,"payload":{ /* MessageItem */ }}
|
||||||
|
{"type":"MESSAGE","requestId":null,"roomId":10,"payload":{ /* MessageItem */ }}
|
||||||
|
{"type":"PONG","requestId":"ping uuid","roomId":10,"payload":{}}
|
||||||
|
{"type":"ERROR","requestId":null,"roomId":0,"payload":{"messageKey":"chat.room.invalid_access"}}
|
||||||
|
```
|
||||||
|
- `ERROR`는 root `code`/`message`가 아니라 `payload.messageKey`를 읽는다.
|
||||||
|
|
||||||
#### Edge Cases
|
#### Edge Cases
|
||||||
- `OpenRoom` 실패 시 WebSocket을 연결하지 않는다.
|
- `OpenRoom` 실패 시 WebSocket을 연결하지 않는다.
|
||||||
- 이미 같은 `roomId`로 연결 중이면 중복 WebSocket 연결이나 중복 `JOIN_ROOM` 전송을 만들지 않는다.
|
- 이미 같은 `roomId`로 연결 중이면 중복 WebSocket 연결이나 중복 `JOIN_ROOM` 전송을 만들지 않는다.
|
||||||
- `JOINED` 수신 전에는 실시간 수신 상태로 표시하지 않는다.
|
- `JOINED` 수신 전에는 실시간 수신 상태로 표시하지 않는다.
|
||||||
- WebSocket 연결 실패 또는 `JOIN_ROOM` 실패 시 앱이 crash 되지 않아야 하며, 현재 채팅방 화면에 남아 있는 동안에만 재연결 정책을 적용한다.
|
- WebSocket 연결 실패 시 앱이 crash 되지 않아야 하며, 현재 채팅방 화면에 남아 있는 동안에만 재연결 정책을 적용한다.
|
||||||
|
- `JOINED` 수신 전 서버가 `ERROR`를 내려주면 `JOIN_ROOM` 실패로 보고 최대 3번 재시도한다. 3번 재시도 후에도 `ERROR` 상태이면 LoadingDialog를 숨기고 `대화방에 접속하지 못했습니다` 토스트를 표시한 뒤 socket을 닫고 이전 페이지로 나간다.
|
||||||
|
- 서버가 WebSocket close frame을 보내거나 socket이 closing/closed 상태로 전환되면 연결 끊김으로 보고, 현재 화면이 foreground인 경우 재연결을 예약한다.
|
||||||
|
- heartbeat `PING` 전송 자체가 실패하면 `PONG` timeout을 기다리지 않고 연결 끊김으로 처리해 socket을 닫고 재연결을 예약한다.
|
||||||
|
|
||||||
### WebSocket Message Receive
|
### WebSocket Message Receive
|
||||||
서버가 WebSocket으로 전달하는 `MESSAGE` 이벤트를 현재 채팅방 메시지 목록에 반영한다.
|
서버가 WebSocket으로 전달하는 `MESSAGE` 이벤트를 현재 채팅방 메시지 목록에 반영한다.
|
||||||
|
|
||||||
#### Requirements
|
#### Requirements
|
||||||
- 기존 SSE `message` 이벤트 수신 로직은 WebSocket `MESSAGE` 수신 로직으로 변경한다.
|
- 이전 realtime `message` 이벤트 수신 로직은 WebSocket `MESSAGE` 수신 로직으로 변경한다.
|
||||||
- `MESSAGE` payload는 `UserCreatorChatMessageItemDto`와 동일한 메시지 모델로 변환한다.
|
- `MESSAGE` root `roomId`가 현재 채팅방과 같을 때만 반영한다.
|
||||||
|
- `MESSAGE` payload는 wrapper 없는 `MessageItem`이며 `UserCreatorChatMessageItemDto`와 동일한 메시지 모델로 변환한다.
|
||||||
- 상대방 메시지는 `MESSAGE` 이벤트 수신 시 현재 채팅방 메시지 목록에 append한다.
|
- 상대방 메시지는 `MESSAGE` 이벤트 수신 시 현재 채팅방 메시지 목록에 append한다.
|
||||||
- 수신 메시지는 기존 목록에 중복 추가하지 않는다. 중복 판단 기준은 `messageId`다.
|
- 수신 메시지는 기존 목록에 중복 추가하지 않는다. 중복 판단 기준은 `messageId`다.
|
||||||
- `messageType=TEXT`인 메시지는 텍스트 말풍선으로 표시한다.
|
- `messageType=TEXT`인 메시지는 텍스트 말풍선으로 표시한다.
|
||||||
@@ -191,7 +234,9 @@ data class UserCreatorChatRoomOpenResponse(
|
|||||||
|
|
||||||
#### Edge Cases
|
#### Edge Cases
|
||||||
- 현재 열려 있는 `roomId`와 다른 방의 메시지가 들어오면 현재 목록에 append하지 않는다.
|
- 현재 열려 있는 `roomId`와 다른 방의 메시지가 들어오면 현재 목록에 append하지 않는다.
|
||||||
- pending 텍스트 메시지와 동일한 서버 메시지가 `SEND_ACK`보다 먼저 `MESSAGE`로 도착할 수 있으므로 `requestId` 또는 `messageId` 기준 병합 정책을 구현 계획에서 명확히 한다.
|
- `MESSAGE`와 `SEND_ACK`의 root `roomId`가 null이면 malformed 이벤트로 보고 현재 방 목록이나 pending 메시지에 반영하지 않는다.
|
||||||
|
- `SEND_ACK` payload도 wrapper 없는 `MessageItem`이며 root `requestId`로 pending 메시지를 확정한다.
|
||||||
|
- pending 텍스트 메시지와 동일한 서버 메시지가 `SEND_ACK`보다 먼저 `MESSAGE`로 도착할 수 있으므로 root `requestId` 또는 `messageId` 기준 병합 정책을 구현 계획에서 명확히 한다.
|
||||||
- 잘못된 JSON이나 알 수 없는 type은 앱 crash 없이 무시하거나 오류 상태로 기록한다.
|
- 잘못된 JSON이나 알 수 없는 type은 앱 crash 없이 무시하거나 오류 상태로 기록한다.
|
||||||
|
|
||||||
### Load Older Messages
|
### Load Older Messages
|
||||||
@@ -226,26 +271,28 @@ data class UserCreatorChatMessagesPageResponse(
|
|||||||
#### Requirements
|
#### Requirements
|
||||||
- 전송 버튼 또는 IME send 액션 시 trim된 입력값이 blank이면 전송하지 않는다.
|
- 전송 버튼 또는 IME send 액션 시 trim된 입력값이 blank이면 전송하지 않는다.
|
||||||
- 텍스트 메시지는 기존 `POST /api/v2/user-creator-chat/rooms/{roomId}/messages/text` 대신 WebSocket `SEND_TEXT`를 사용한다.
|
- 텍스트 메시지는 기존 `POST /api/v2/user-creator-chat/rooms/{roomId}/messages/text` 대신 WebSocket `SEND_TEXT`를 사용한다.
|
||||||
- 전송 UI는 클라이언트에서 `requestId`를 생성해 pending 메시지와 서버 `SEND_ACK`를 매칭한다.
|
- 전송 UI는 클라이언트에서 UUID `requestId`를 생성해 pending 메시지와 서버 `SEND_ACK` root `requestId`를 매칭한다.
|
||||||
- `SEND_TEXT` payload에는 현재 `roomId`, `requestId`, trim된 `textMessage`를 포함한다.
|
- `SEND_TEXT` root에는 `type`, `requestId`, `roomId`를 포함하고, payload에는 trim된 `textMessage`만 포함한다.
|
||||||
- 전송 시 낙관적 UI를 적용해 사용자 메시지를 즉시 목록에 추가하고 전송 중 상태로 표시한다.
|
- 전송 시 낙관적 UI를 적용해 사용자 메시지를 즉시 목록에 추가하고 전송 중 상태로 표시한다.
|
||||||
- `SEND_ACK`를 수신하면 pending 메시지를 서버가 내려준 `messageId`, `createdAt`, 프로필 정보 기준으로 확정한다.
|
- `SEND_ACK`를 수신하면 pending 메시지를 서버가 내려준 `messageId`, `createdAt`, 프로필 정보 기준으로 확정한다.
|
||||||
- `ERROR` 또는 timeout을 수신하면 해당 pending 메시지를 실패 상태로 전환하고 재시도 버튼을 표시한다.
|
- `ERROR` 또는 15초 `SEND_ACK` timeout을 수신하면 해당 pending 메시지를 실패 상태로 전환하고 재시도 버튼을 표시한다.
|
||||||
- 재시도 버튼을 누르면 새 `requestId`로 같은 텍스트 메시지를 다시 `SEND_TEXT` 전송하고, 성공 시 실패 상태를 정상 메시지 상태로 갱신한다.
|
- 재시도 버튼을 누르면 새 `requestId`로 같은 텍스트 메시지를 다시 `SEND_TEXT` 전송하고, 성공 시 실패 상태를 정상 메시지 상태로 갱신한다.
|
||||||
- 클라이언트는 기존 `POST /api/v2/user-creator-chat/rooms/{roomId}/messages/text` 응답의 `deliveredRealtime`/`pushSent`를 텍스트 전송 UI 판단에 사용하지 않는다.
|
- 클라이언트는 기존 `POST /api/v2/user-creator-chat/rooms/{roomId}/messages/text` 응답의 `deliveredRealtime`/`pushSent`를 텍스트 전송 UI 판단에 사용하지 않는다.
|
||||||
- 사용자가 과거 메시지를 보고 있는 상태에서 메시지를 전송하면 최신 메시지 위치로 이동하거나, 최신 페이지 동기화 후 전송 메시지를 반영하는 방식 중 하나를 구현 계획에서 확정한다.
|
- 사용자가 과거 메시지를 보고 있는 상태에서 메시지를 전송하면 최신 메시지 위치로 이동하거나, 최신 페이지 동기화 후 전송 메시지를 반영하는 방식 중 하나를 구현 계획에서 확정한다.
|
||||||
|
|
||||||
#### Edge Cases
|
#### Edge Cases
|
||||||
- `SEND_ACK`가 timeout 이후 도착하면 현재 pending 상태와 중복 여부를 확인해 이미 실패 처리된 메시지를 정상 메시지로 복구할지 구현 계획에서 확정한다.
|
- `SEND_ACK`가 timeout 이후 도착하면 현재 pending 상태와 중복 여부를 확인해 이미 실패 처리된 메시지를 정상 메시지로 복구할지 구현 계획에서 확정한다.
|
||||||
- WebSocket이 연결되지 않았거나 `JOINED` 전이면 텍스트 전송을 막거나 실패 상태로 전환한다.
|
- WebSocket이 연결되지 않았거나 `JOINED` 전이면 텍스트 전송을 수락하지 않고 입력창의 기존 텍스트를 지우지 않는다.
|
||||||
- 같은 `requestId`에 대한 `SEND_ACK`가 중복 수신되면 첫 번째 확정 결과만 반영한다.
|
- 같은 `requestId`에 대한 `SEND_ACK`가 중복 수신되면 첫 번째 확정 결과만 반영한다.
|
||||||
|
|
||||||
### Voice Message Send
|
### Voice Message Contract
|
||||||
음성 메시지는 WebSocket 전환 범위에서 제외하고 기존 multipart REST API를 유지한다.
|
음성 메시지는 DTO/API 계약만 보존하고 이번 채팅 UI에서는 사용하지 않는다.
|
||||||
|
|
||||||
#### Requirements
|
#### Requirements
|
||||||
- 음성 메시지 전송은 기존 `POST /api/v2/user-creator-chat/rooms/{roomId}/messages/voice` multipart 호출을 유지한다.
|
- 음성 메시지 전송 API가 필요한 경우 `POST /api/v2/user-creator-chat/rooms/{roomId}/messages/voice` multipart 호출을 구현할 수 있다.
|
||||||
- 음성 전송 후 상대방 실시간 수신 여부와 push 발송 여부는 서버 정책에 따른다.
|
- multipart field는 파일 part `voiceMessageFile`, string part `request`를 사용한다. `request` 값은 JSON 문자열 `{"recipientId": null}` 형식이다.
|
||||||
|
- 응답 `data.message`는 `MessageItem`, `data.deliveredRealtime`은 실시간 전달 여부, `data.pushSent`는 push 발송 여부로 처리한다.
|
||||||
|
- 이번 화면에는 음성 녹음/전송/재생 UI를 연결하지 않는다.
|
||||||
- 이번 WebSocket 전환 범위에서 음성 메시지를 `SEND_TEXT`와 같은 방식으로 변경하지 않는다.
|
- 이번 WebSocket 전환 범위에서 음성 메시지를 `SEND_TEXT`와 같은 방식으로 변경하지 않는다.
|
||||||
|
|
||||||
#### API Contract
|
#### API Contract
|
||||||
@@ -253,14 +300,17 @@ data class UserCreatorChatMessagesPageResponse(
|
|||||||
- Method: `POST`
|
- Method: `POST`
|
||||||
- Path: `/api/v2/user-creator-chat/rooms/{roomId}/messages/voice`
|
- Path: `/api/v2/user-creator-chat/rooms/{roomId}/messages/voice`
|
||||||
- Request: `multipart/form-data`
|
- Request: `multipart/form-data`
|
||||||
|
- `voiceMessageFile`: file part
|
||||||
|
- `request`: string part, JSON 문자열 `{"recipientId": null}`
|
||||||
|
|
||||||
### Leave Room And Close
|
### Leave Room And Close
|
||||||
채팅방 화면 종료 또는 앱 background 전환, 로그아웃 시 WebSocket 방 참여를 해제하고 socket을 닫는다.
|
채팅방 화면 종료 또는 앱 background 전환, 로그아웃 시 WebSocket 방 참여를 해제하고 socket을 닫는다.
|
||||||
|
|
||||||
#### Requirements
|
#### Requirements
|
||||||
- 기존 `POST /api/v2/user-creator-chat/rooms/{roomId}/events/disconnect` 호출 위치는 WebSocket `LEAVE_ROOM` 전송 후 socket close 처리로 대체한다.
|
- 제거된 `POST /api/v2/user-creator-chat/rooms/{roomId}/events/disconnect` 호출 위치는 WebSocket `LEAVE_ROOM` 전송 후 socket close 처리로 대체한다.
|
||||||
- 채팅방 화면 이탈, 앱 백그라운드 진입, 로그아웃 시 `LEAVE_ROOM`을 보낸 뒤 WebSocket을 close한다.
|
- 채팅방 화면 이탈, 앱 백그라운드 진입, 로그아웃 시 `JOINED` 상태이면 `LEAVE_ROOM`을 보낸 뒤 WebSocket을 close한다.
|
||||||
- 클라이언트는 제거된 `events/disconnect` endpoint를 더 이상 호출하지 않는다.
|
- `JOINED` 전 또는 `roomId <= 0` 상태에서는 `LEAVE_ROOM`을 보내지 않고 normal close만 수행한다.
|
||||||
|
- 클라이언트는 제거된 `events/disconnect` endpoint를 호출하지 않는다.
|
||||||
- 이미 leave/close 처리 중이면 중복 `LEAVE_ROOM` 전송과 중복 close를 만들지 않는다.
|
- 이미 leave/close 처리 중이면 중복 `LEAVE_ROOM` 전송과 중복 close를 만들지 않는다.
|
||||||
- close 처리는 UI thread를 블로킹하지 않아야 한다.
|
- close 처리는 UI thread를 블로킹하지 않아야 한다.
|
||||||
|
|
||||||
@@ -277,8 +327,8 @@ WebSocket 연결 상태를 유지하고, 네트워크 오류로 끊긴 경우
|
|||||||
- `PONG` timeout 또는 네트워크 오류로 WebSocket이 끊기면 현재 채팅방 화면에 남아 있는 동안에만 재연결한다.
|
- `PONG` timeout 또는 네트워크 오류로 WebSocket이 끊기면 현재 채팅방 화면에 남아 있는 동안에만 재연결한다.
|
||||||
- 재연결 성공 후 `JOIN_ROOM`을 다시 보낸다.
|
- 재연결 성공 후 `JOIN_ROOM`을 다시 보낸다.
|
||||||
- 재연결 후 필요하면 REST `GET /api/v2/user-creator-chat/rooms/{roomId}/messages` API로 누락 메시지를 동기화한다.
|
- 재연결 후 필요하면 REST `GET /api/v2/user-creator-chat/rooms/{roomId}/messages` API로 누락 메시지를 동기화한다.
|
||||||
- 기존 SSE retry/backoff 코드는 WebSocket 재연결 정책으로 대체하고, 채팅방 화면 밖에서는 재연결하지 않는다.
|
- 이전 realtime retry/backoff 코드는 WebSocket 재연결 정책으로 대체하고, 채팅방 화면 밖에서는 재연결하지 않는다.
|
||||||
- 재연결 backoff 간격, 최대 재시도 횟수, heartbeat 주기는 구현 계획에서 서버 권장값 또는 앱 공통 네트워크 정책에 맞춰 확정한다.
|
- heartbeat `PING` 주기는 30초로 둔다. `PONG` timeout, 재연결 backoff 간격, 최대 재시도 횟수는 구현 계획에서 서버 권장값 또는 앱 공통 네트워크 정책에 맞춰 확정한다.
|
||||||
|
|
||||||
#### Edge Cases
|
#### Edge Cases
|
||||||
- 사용자가 화면을 벗어난 뒤 도착한 reconnect timer는 실행하지 않는다.
|
- 사용자가 화면을 벗어난 뒤 도착한 reconnect timer는 실행하지 않는다.
|
||||||
@@ -295,14 +345,14 @@ WebSocket 연결 상태를 유지하고, 네트워크 오류로 끊긴 경우
|
|||||||
- 푸시로 채팅방에 진입한 뒤에도 일반 진입과 동일하게 `OpenRoom` 호출 후 WebSocket 연결과 `JOIN_ROOM` 전송을 수행한다.
|
- 푸시로 채팅방에 진입한 뒤에도 일반 진입과 동일하게 `OpenRoom` 호출 후 WebSocket 연결과 `JOIN_ROOM` 전송을 수행한다.
|
||||||
- `deep_link`가 없거나 `/chat/{roomId}`에서 유효한 `roomId`를 파싱할 수 없으면 DM 채팅방을 열지 않고 기존 푸시 오류 처리 정책을 따른다.
|
- `deep_link`가 없거나 `/chat/{roomId}`에서 유효한 `roomId`를 파싱할 수 없으면 DM 채팅방을 열지 않고 기존 푸시 오류 처리 정책을 따른다.
|
||||||
|
|
||||||
### Removed SSE Endpoints
|
### Removed Realtime Endpoints
|
||||||
SSE 제거에 따라 네이티브 앱에서 더 이상 호출하면 안 되는 endpoint를 명시한다.
|
제거된 realtime endpoint와 관련 구현은 네이티브 앱의 active DM 채팅 경로에서 사용하지 않는다.
|
||||||
|
|
||||||
#### Requirements
|
#### Requirements
|
||||||
- SSE client 또는 `EventSource` wrapper를 제거한다.
|
- `DmChatEventClient.kt`를 active main source에서 제거한다.
|
||||||
- `GET /api/v2/user-creator-chat/rooms/{roomId}/events` 호출, `Accept: text/event-stream`, SSE event parser, SSE reconnect/retry timer를 삭제한다.
|
- `GET /api/v2/user-creator-chat/rooms/{roomId}/events` 호출, `Accept: text/event-stream`, 이전 realtime event parser, 이전 reconnect/retry timer를 삭제한다.
|
||||||
- `POST /api/v2/user-creator-chat/rooms/{roomId}/events/disconnect` 호출을 삭제한다.
|
- `POST /api/v2/user-creator-chat/rooms/{roomId}/events/disconnect` 호출을 삭제한다.
|
||||||
- 기존 `connected`/`message` SSE event 이름에 의존하는 로직을 WebSocket `JOINED`/`MESSAGE` 기준으로 대체한다.
|
- 이전 `connected`/`message` event 이름에 의존하는 로직을 WebSocket `JOINED`/`MESSAGE` 기준으로 대체한다.
|
||||||
|
|
||||||
### Message DTO
|
### Message DTO
|
||||||
서버 메시지 DTO는 텍스트/음성 필드를 모두 보존하되, 이번 WebSocket 전환 구현 대상은 텍스트 메시지 실시간 송수신 변경으로 한정한다.
|
서버 메시지 DTO는 텍스트/음성 필드를 모두 보존하되, 이번 WebSocket 전환 구현 대상은 텍스트 메시지 실시간 송수신 변경으로 한정한다.
|
||||||
@@ -328,7 +378,7 @@ data class UserCreatorChatMessageItemDto(
|
|||||||
- `messageType`은 서버 문자열을 그대로 보존한다.
|
- `messageType`은 서버 문자열을 그대로 보존한다.
|
||||||
- 가능한 `messageType` 값은 `TEXT`, `VOICE`다.
|
- 가능한 `messageType` 값은 `TEXT`, `VOICE`다.
|
||||||
- `TEXT`는 텍스트 메시지이며 `textMessage`를 사용해 말풍선 UI로 표시한다.
|
- `TEXT`는 텍스트 메시지이며 `textMessage`를 사용해 말풍선 UI로 표시한다.
|
||||||
- `VOICE`는 음성 메시지이며 `voiceMessageUrl`을 보존한다. 음성 메시지 UI가 아직 정해지지 않았으므로 이번 구현에서는 별도 재생/전송 UI를 만들지 않는다.
|
- `VOICE`는 음성 메시지이며 `voiceMessageUrl`을 보존한다. 이번 구현에서는 별도 재생/전송 UI를 만들지 않는다.
|
||||||
- `createdAt`은 epoch millis로 보고 기존 시간 표시 유틸 또는 신규 formatter에서 변환한다.
|
- `createdAt`은 epoch millis로 보고 기존 시간 표시 유틸 또는 신규 formatter에서 변환한다.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -337,7 +387,9 @@ data class UserCreatorChatMessageItemDto(
|
|||||||
- 기존 AI 채팅방과 동일한 채팅 화면 감각을 유지하되 DM에서 불필요한 캐릭터/쿼터 요소는 제거한다.
|
- 기존 AI 채팅방과 동일한 채팅 화면 감각을 유지하되 DM에서 불필요한 캐릭터/쿼터 요소는 제거한다.
|
||||||
- header는 뒤로가기, 상대 프로필, 상대 닉네임만으로 간결하게 구성한다.
|
- header는 뒤로가기, 상대 프로필, 상대 닉네임만으로 간결하게 구성한다.
|
||||||
- 메시지 영역은 header 바로 아래부터 시작한다.
|
- 메시지 영역은 header 바로 아래부터 시작한다.
|
||||||
- 내 메시지와 상대 메시지의 시각적 구분은 기존 `ChatMessageAdapter`/item 스타일을 우선 재사용한다.
|
- 상대방 메시지는 왼쪽 정렬, 내가 보낸 메시지는 오른쪽 정렬로 표시한다.
|
||||||
|
- 채팅 버블은 현재 구현된 DM 채팅 버블 drawable/style을 그대로 사용한다.
|
||||||
|
- 내 메시지와 상대 메시지의 시각적 구분은 기존 DM 채팅 item 스타일을 우선 재사용한다.
|
||||||
- 긴 텍스트 메시지는 기존 채팅 말풍선의 줄바꿈/최대 폭 정책을 따른다.
|
- 긴 텍스트 메시지는 기존 채팅 말풍선의 줄바꿈/최대 폭 정책을 따른다.
|
||||||
- 키보드가 올라와도 입력 영역과 최신 메시지가 가려지지 않아야 한다.
|
- 키보드가 올라와도 입력 영역과 최신 메시지가 가려지지 않아야 한다.
|
||||||
|
|
||||||
@@ -349,6 +401,8 @@ data class UserCreatorChatMessageItemDto(
|
|||||||
- DM 화면은 신규 Activity로 생성한다.
|
- DM 화면은 신규 Activity로 생성한다.
|
||||||
- 기존 `ChatRoomActivity`는 직접 변형하지 않는다. 필요한 경우 메시지 item/adapter/model 등 공용화 가능한 최소 컴포넌트만 구현 계획에서 검토한다.
|
- 기존 `ChatRoomActivity`는 직접 변형하지 않는다. 필요한 경우 메시지 item/adapter/model 등 공용화 가능한 최소 컴포넌트만 구현 계획에서 검토한다.
|
||||||
- REST API 응답은 기존 패턴처럼 `Single<ApiResponse<...>>`를 우선 사용한다. WebSocket은 `/ws/v2/user-creator-chat` endpoint에 `Authorization: Bearer <accessToken>` handshake header를 전달한다.
|
- REST API 응답은 기존 패턴처럼 `Single<ApiResponse<...>>`를 우선 사용한다. WebSocket은 `/ws/v2/user-creator-chat` endpoint에 `Authorization: Bearer <accessToken>` handshake header를 전달한다.
|
||||||
|
- WebSocket 상태는 최소 `DISCONNECTED`, `CONNECTING`, `SOCKET_OPEN`, `JOINING`, `JOINED`로 구분한다. `onOpen`은 `SOCKET_OPEN`일 뿐 실시간 연결 완료가 아니다.
|
||||||
|
- WebSocket envelope parser는 root `requestId`, root `roomId`, root `payload`를 읽고, 서버 payload에 `{message: ...}` wrapper가 있다고 가정하지 않는다.
|
||||||
- token 전달은 기존 v2 API 패턴과 `SharedPreferenceManager.token` 사용 방식을 따른다. REST와 WebSocket handshake의 `Authorization` 헤더 문자열은 단일 bearer helper로 생성해 오입력을 방지한다.
|
- token 전달은 기존 v2 API 패턴과 `SharedPreferenceManager.token` 사용 방식을 따른다. REST와 WebSocket handshake의 `Authorization` 헤더 문자열은 단일 bearer helper로 생성해 오입력을 방지한다.
|
||||||
- 앱 foreground/background 감지는 Activity lifecycle과 앱 전체 `ProcessLifecycleOwner` 중 어떤 기준을 사용할지 구현 계획에서 확정한다.
|
- 앱 foreground/background 감지는 Activity lifecycle과 앱 전체 `ProcessLifecycleOwner` 중 어떤 기준을 사용할지 구현 계획에서 확정한다.
|
||||||
- 구현 전 `docs/20260610_DM_채팅화면/plan-task.md`를 WebSocket 전환 범위에 맞게 갱신하고, 그 문서에 따라 최소 구현한다.
|
- 구현 전 `docs/20260610_DM_채팅화면/plan-task.md`를 WebSocket 전환 범위에 맞게 갱신하고, 그 문서에 따라 최소 구현한다.
|
||||||
@@ -364,13 +418,13 @@ data class UserCreatorChatMessageItemDto(
|
|||||||
| 방 생성 또는 조회 | `POST` | `/api/v2/user-creator-chat/rooms/create` | `CreateUserCreatorChatRoomRequest` | `CreateUserCreatorChatRoomResponse` |
|
| 방 생성 또는 조회 | `POST` | `/api/v2/user-creator-chat/rooms/create` | `CreateUserCreatorChatRoomRequest` | `CreateUserCreatorChatRoomResponse` |
|
||||||
| 생성된 채팅방 열기 | `GET` | `/api/v2/user-creator-chat/rooms/{roomId}/open?limit=20` | 없음 | `UserCreatorChatRoomOpenResponse` |
|
| 생성된 채팅방 열기 | `GET` | `/api/v2/user-creator-chat/rooms/{roomId}/open?limit=20` | 없음 | `UserCreatorChatRoomOpenResponse` |
|
||||||
| WebSocket 연결 | WebSocket | `/ws/v2/user-creator-chat` | Handshake header: `Authorization: Bearer <accessToken>` | WebSocket session |
|
| WebSocket 연결 | WebSocket | `/ws/v2/user-creator-chat` | Handshake header: `Authorization: Bearer <accessToken>` | WebSocket session |
|
||||||
| 방 참여 | WebSocket send | `/ws/v2/user-creator-chat` | `JOIN_ROOM` | `JOINED` |
|
| 방 참여 | WebSocket send | `/ws/v2/user-creator-chat` | root `JOIN_ROOM`, `requestId`, `roomId`, empty `payload` | `JOINED` |
|
||||||
| 실시간 메시지 수신 | WebSocket receive | `/ws/v2/user-creator-chat` | 없음 | `MESSAGE` |
|
| 실시간 메시지 수신 | WebSocket receive | `/ws/v2/user-creator-chat` | 없음 | root `MESSAGE`, payload `MessageItem` |
|
||||||
| 텍스트 메시지 보내기 | WebSocket send | `/ws/v2/user-creator-chat` | `SEND_TEXT` with `roomId`, `requestId`, `textMessage` | `SEND_ACK` 또는 `ERROR` |
|
| 텍스트 메시지 보내기 | WebSocket send | `/ws/v2/user-creator-chat` | root `SEND_TEXT`, `requestId`, `roomId`, payload `{textMessage}` | root `SEND_ACK` payload `MessageItem` 또는 `ERROR` |
|
||||||
| Heartbeat | WebSocket send/receive | `/ws/v2/user-creator-chat` | `PING` | `PONG` |
|
| Heartbeat | WebSocket send/receive | `/ws/v2/user-creator-chat` | root `PING`, `requestId`, `roomId`, empty `payload` | `PONG` |
|
||||||
| 방 이탈 | WebSocket send/close | `/ws/v2/user-creator-chat` | `LEAVE_ROOM` | socket close |
|
| 방 이탈 | WebSocket send/close | `/ws/v2/user-creator-chat` | root `LEAVE_ROOM`, `requestId`, `roomId`, empty `payload` | socket close |
|
||||||
| 과거 메시지 조회 | `GET` | `/api/v2/user-creator-chat/rooms/{roomId}/messages?cursor={cursor}&limit=20` | 없음 | `UserCreatorChatMessagesPageResponse` |
|
| 과거 메시지 조회 | `GET` | `/api/v2/user-creator-chat/rooms/{roomId}/messages?cursor={cursor}&limit=20` | 없음 | `UserCreatorChatMessagesPageResponse` |
|
||||||
| 음성 메시지 보내기 | `POST` | `/api/v2/user-creator-chat/rooms/{roomId}/messages/voice` | `multipart/form-data` | 기존 서버 계약 유지 |
|
| 음성 메시지 보내기 | `POST` | `/api/v2/user-creator-chat/rooms/{roomId}/messages/voice` | `multipart/form-data` | DTO/API 계약 보존, 이번 UI 미연결 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -379,12 +433,21 @@ data class UserCreatorChatMessageItemDto(
|
|||||||
- `roomId` 기반 진입 시 OpenRoom이 `limit=20`으로 호출된다.
|
- `roomId` 기반 진입 시 OpenRoom이 `limit=20`으로 호출된다.
|
||||||
- OpenRoom 응답 메시지가 시간순으로 `RecyclerView`에 표시된다.
|
- OpenRoom 응답 메시지가 시간순으로 `RecyclerView`에 표시된다.
|
||||||
- OpenRoom 응답의 `opponentNickname`, `opponentProfileImageUrl`이 header 상대 정보로 표시된다.
|
- OpenRoom 응답의 `opponentNickname`, `opponentProfileImageUrl`이 header 상대 정보로 표시된다.
|
||||||
|
- 상대방 메시지는 왼쪽 정렬로 표시된다.
|
||||||
|
- 내가 보낸 메시지는 오른쪽 정렬로 표시된다.
|
||||||
|
- 채팅 버블은 현재 구현된 DM 채팅 버블을 사용한다.
|
||||||
|
- DM 메시지 item은 `createdAt`을 기존 메시지 시간 포맷 기준으로 표시한다.
|
||||||
|
- 입력 UI에서는 텍스트 메시지만 전송할 수 있고 음성 메시지 UI는 노출되지 않는다.
|
||||||
- OpenRoom 성공 전에는 WebSocket 연결을 시작하지 않는다.
|
- OpenRoom 성공 전에는 WebSocket 연결을 시작하지 않는다.
|
||||||
- OpenRoom 성공 후 WebSocket `/ws/v2/user-creator-chat`에 access token handshake header로 연결한다.
|
- OpenRoom 성공 후 WebSocket `/ws/v2/user-creator-chat`에 access token handshake header로 연결한다.
|
||||||
- WebSocket 연결 직후 `JOIN_ROOM`을 보내고 `JOINED` 수신 후에만 실시간 수신 상태로 판단한다.
|
- WebSocket 연결 직후 `JOIN_ROOM`을 보내고 `JOINED` 수신 후에만 실시간 수신 상태로 판단한다.
|
||||||
|
- client send raw JSON은 root `type`/`requestId`/`roomId`/`payload` 계약을 따른다.
|
||||||
|
- `roomId`가 0/null이면 `JOIN_ROOM`을 보내지 않아 서버 `ERROR` 및 1008 close를 유발하지 않는다.
|
||||||
- 상단 스크롤 시 `hasMore=true`와 `nextCursor` 조건에 따라 GetMessages가 호출된다.
|
- 상단 스크롤 시 `hasMore=true`와 `nextCursor` 조건에 따라 GetMessages가 호출된다.
|
||||||
- `MESSAGE` 수신 시 현재 채팅방 메시지 목록에 상대방 메시지가 append된다.
|
- `MESSAGE` 수신 시 root `roomId`가 현재 채팅방과 같으면 payload `MessageItem`을 현재 채팅방 메시지 목록에 append한다.
|
||||||
- 텍스트 메시지 전송 시 `requestId`가 생성되고 pending 메시지와 `SEND_ACK`가 매칭된다.
|
- 텍스트 메시지 전송 시 `requestId`가 생성되고 pending 메시지와 `SEND_ACK`가 매칭된다.
|
||||||
|
- `SEND_ACK`는 root `requestId`와 payload `MessageItem`으로 pending 메시지를 확정한다.
|
||||||
|
- `ERROR`는 `payload.messageKey`를 파싱해 pending 실패 또는 세션 오류 처리에 사용한다.
|
||||||
- 텍스트 메시지 전송 성공 시 `SEND_ACK`의 `messageId`, `createdAt`, 프로필 정보가 pending 메시지에 반영된다.
|
- 텍스트 메시지 전송 성공 시 `SEND_ACK`의 `messageId`, `createdAt`, 프로필 정보가 pending 메시지에 반영된다.
|
||||||
- 텍스트 메시지 전송 실패 시 `ERROR` 또는 timeout 기준으로 pending 메시지가 실패 상태와 재시도 버튼을 표시한다.
|
- 텍스트 메시지 전송 실패 시 `ERROR` 또는 timeout 기준으로 pending 메시지가 실패 상태와 재시도 버튼을 표시한다.
|
||||||
- 화면 이탈, 앱 background 전환, 로그아웃 시 `LEAVE_ROOM` 전송 후 WebSocket close가 발생한다.
|
- 화면 이탈, 앱 background 전환, 로그아웃 시 `LEAVE_ROOM` 전송 후 WebSocket close가 발생한다.
|
||||||
@@ -392,6 +455,7 @@ data class UserCreatorChatMessageItemDto(
|
|||||||
- 네트워크 오류로 WebSocket이 끊기면 현재 채팅방 화면에 남아 있는 동안에만 재연결한다.
|
- 네트워크 오류로 WebSocket이 끊기면 현재 채팅방 화면에 남아 있는 동안에만 재연결한다.
|
||||||
- 재연결 성공 후 `JOIN_ROOM`을 다시 보내고 필요 시 GetMessages API로 누락 메시지를 동기화한다.
|
- 재연결 성공 후 `JOIN_ROOM`을 다시 보내고 필요 시 GetMessages API로 누락 메시지를 동기화한다.
|
||||||
- `PING`/`PONG` heartbeat timeout 시 연결 상태가 disconnected로 전환된다.
|
- `PING`/`PONG` heartbeat timeout 시 연결 상태가 disconnected로 전환된다.
|
||||||
|
- `PING`은 `JOINED` 상태에서 30초 주기로 root `roomId`와 UUID `requestId`를 포함해 전송한다.
|
||||||
- 푸시 payload의 `deep_link`가 `${URISCHEME}://chat/{roomId}` 형식이면 `roomId` 기준으로 채팅방에 진입하고, 일반 진입과 동일하게 OpenRoom 및 WebSocket join을 수행한다.
|
- 푸시 payload의 `deep_link`가 `${URISCHEME}://chat/{roomId}` 형식이면 `roomId` 기준으로 채팅방에 진입하고, 일반 진입과 동일하게 OpenRoom 및 WebSocket join을 수행한다.
|
||||||
- `GET /events`, `POST /events/disconnect`, `POST /messages/text`는 WebSocket 전환된 텍스트 송수신 경로에서 호출되지 않는다.
|
- `GET /events`, `POST /events/disconnect`, `POST /messages/text`는 WebSocket 전환된 텍스트 송수신 경로에서 호출되지 않는다.
|
||||||
- 제거 대상 UI(`character_type_badge`, `ll_can_badge`, `iv_more`, `notice_container`)가 DM 화면에 나타나지 않는다.
|
- 제거 대상 UI(`character_type_badge`, `ll_can_badge`, `iv_more`, `notice_container`)가 DM 화면에 나타나지 않는다.
|
||||||
@@ -401,9 +465,7 @@ data class UserCreatorChatMessageItemDto(
|
|||||||
|
|
||||||
## 12. Open Questions
|
## 12. Open Questions
|
||||||
- DM 채팅방 진입점별 intent extra 이름은 구현 계획에서 확정한다.
|
- DM 채팅방 진입점별 intent extra 이름은 구현 계획에서 확정한다.
|
||||||
- WebSocket envelope의 정확한 JSON 필드명, `JOIN_ROOM`/`SEND_TEXT`/`SEND_ACK` payload 스키마는 서버 계약 문서 또는 백엔드 구현과 대조해 구현 계획에서 확정한다.
|
- `PING` 주기는 최신 계약상 30초로 확정한다. `PONG` timeout, reconnect backoff와 최대 재시도 횟수는 서버 권장값 또는 앱 공통 정책에 맞춰 구현 계획에서 확정한다.
|
||||||
- `PING` 주기, `PONG` timeout, reconnect backoff와 최대 재시도 횟수는 서버 권장값 또는 앱 공통 정책에 맞춰 구현 계획에서 확정한다.
|
|
||||||
- 음성 메시지 UI/UX와 `VOICE` 메시지 표시 방식은 후속 범위에서 확정한다.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -436,3 +498,8 @@ data class UserCreatorChatMessageItemDto(
|
|||||||
- 2026-06-18: 사용자 제공 WebSocket 전환 요구사항을 기준으로 기존 SSE 기반 PRD를 갱신했다. Core Features를 `WebSocket Room Session`, `WebSocket Message Receive`, `Send Text Message`, `Voice Message Send`, `Leave Room And Close`, `Reconnect And Heartbeat`, `Push Notification Entry`, `Removed SSE Endpoints`로 분리하고, 제거되는 SSE endpoint와 REST 텍스트 전송 endpoint가 더 이상 텍스트 실시간 송수신 경로에서 호출되지 않도록 성공 기준을 보강했다.
|
- 2026-06-18: 사용자 제공 WebSocket 전환 요구사항을 기준으로 기존 SSE 기반 PRD를 갱신했다. Core Features를 `WebSocket Room Session`, `WebSocket Message Receive`, `Send Text Message`, `Voice Message Send`, `Leave Room And Close`, `Reconnect And Heartbeat`, `Push Notification Entry`, `Removed SSE Endpoints`로 분리하고, 제거되는 SSE endpoint와 REST 텍스트 전송 endpoint가 더 이상 텍스트 실시간 송수신 경로에서 호출되지 않도록 성공 기준을 보강했다.
|
||||||
- 2026-06-18: 이번 단계는 PRD 문서 수정만 수행했으며 Android 구현, plan-task 갱신, 빌드, 테스트는 실행하지 않았다.
|
- 2026-06-18: 이번 단계는 PRD 문서 수정만 수행했으며 Android 구현, plan-task 갱신, 빌드, 테스트는 실행하지 않았다.
|
||||||
- 2026-06-19: 사용자 제공 최신 FCM payload 계약을 반영해 Push Notification Entry 요구사항과 성공 기준을 `chat_type`/`room_id` 기준에서 `deep_link=${URISCHEME}://chat/{roomId}` 단독 기준으로 갱신했다. 이번 단계는 PRD 문서 수정만 수행했으며 Android 구현, 빌드, 테스트는 실행하지 않았다.
|
- 2026-06-19: 사용자 제공 최신 FCM payload 계약을 반영해 Push Notification Entry 요구사항과 성공 기준을 `chat_type`/`room_id` 기준에서 `deep_link=${URISCHEME}://chat/{roomId}` 단독 기준으로 갱신했다. 이번 단계는 PRD 문서 수정만 수행했으며 Android 구현, 빌드, 테스트는 실행하지 않았다.
|
||||||
|
- 2026-07-10: 최신 서버 계약을 기준으로 `DmChatSocketModels.kt`, `DmChatSocketClient.kt`, `DmChatRepository.kt`, `DmChatRoomViewModel.kt`, `DmChatApi.kt`, `DmChatModels.kt`, `DmChatEventClient.kt`, `AppDI.kt`를 확인했다. REST create/open/messages와 제거된 text/disconnect endpoint 미사용은 대체로 맞지만, 기존 SSE `DmChatEventClient.kt`와 `/events` URL 정의는 main source에 남아 있다. 또한 현재 WebSocket envelope는 root `requestId`/`roomId`가 없고 `roomId`/`requestId`를 payload에 넣거나 생략한다. `MESSAGE`/`SEND_ACK` payload를 `{message: ...}` wrapper로 기대하고, `ERROR`를 `payload.messageKey`가 아닌 nullable `code`/`message`로 파싱하며, `PING`에 `roomId`/`requestId`가 없다. 이 차이를 PRD와 계획 문서의 후속 보정 범위로 반영했다. 이번 단계는 문서 수정만 수행했으며 Android 구현, 빌드, 테스트는 실행하지 않았다.
|
||||||
|
- 2026-07-10: 사용자 추가 요청을 반영해 현재 요구사항에서 legacy realtime 관련 내용은 active 구현 대상이 아니라 제거 대상으로만 다루도록 정리했다. 음성 메시지는 DTO/API 계약을 구현하더라도 이번 UI에서는 사용하지 않고, DM 채팅방에서는 텍스트 메시지만 보낼 수 있도록 명시했다. 채팅 UI는 상대방 메시지 왼쪽 정렬, 내 메시지 오른쪽 정렬, 현재 구현된 DM 채팅 버블 유지로 보강했다. 기존 검증 기록은 저장소 규칙에 따라 삭제하지 않았다.
|
||||||
|
- 2026-07-11: DM 메시지 item에서 `DmChatMessageUiItem.createdAt`을 기존 `formatMessageTime` 포맷으로 표시해야 한다는 후속 요구사항을 DM Chat Room UI 요구사항과 Metrics에 반영했다. 이번 단계는 문서 요구사항 갱신이며 구현/빌드/테스트는 후속 계획 문서에 따라 진행한다.
|
||||||
|
- 2026-07-11: DM 메시지 시간 데이터가 서버 DTO `DmChatMessageResponse.createdAt`에서 UI 모델 `DmChatMessageUiItem.createdAt`까지 보존되는 것을 확인했다. 메시지 item UI는 `tv_time`과 기존 `formatMessageTime(createdAt)` 바인딩으로 내 메시지/상대 메시지 모두 시간을 표시하도록 확인했으며, 상세 구현과 검증 기록은 `plan-task.md`의 Phase 16에 누적했다.
|
||||||
|
- 2026-07-11: 리뷰 지적에 따라 WebSocket close frame/closing 상태와 heartbeat `PING` 전송 실패를 연결 끊김으로 처리하는 요구사항을 추가했다. 또한 `JOINED` 전 또는 재연결 중 전송이 수락되지 않은 경우 Activity 입력창을 비우지 않도록 텍스트 전송 수락 여부 요구사항을 명시했다.
|
||||||
|
|||||||
@@ -559,7 +559,21 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### Phase 7: 유료 미구매 게시글 반응/댓글 숨김 후속
|
||||||
|
|
||||||
|
- [x] **Task 7.1: 리스트형 유료 미구매 게시글 reaction/comment 숨김**
|
||||||
|
- 대상 파일:
|
||||||
|
- `app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/community/model/CreatorChannelCommunityMappers.kt`
|
||||||
|
- `app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/community/model/CreatorChannelCommunityUiModels.kt`
|
||||||
|
- `app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/community/ui/CreatorChannelCommunityListAdapter.kt`
|
||||||
|
- `app/src/main/res/layout/item_creator_channel_community_list.xml`
|
||||||
|
- `app/src/test/java/kr/co/vividnext/sodalive/v2/creator/channel/community/CreatorChannelCommunityMapperTest.kt`
|
||||||
|
- 작업: `price > 0 && !existOrdered && !isOwner` 잠금 게시글에서는 리스트형 item의 reaction 현황과 댓글 아이콘/카운트를 숨긴다.
|
||||||
|
- 검증 기록:
|
||||||
|
- 2026-07-12: RED로 `CreatorChannelCommunityMapperTest.유료 미구매 타인 게시글은 reaction 현황과 댓글을 숨긴다`를 추가했고, production 변경 전 `showReaction` 미구현 컴파일 실패를 확인했다. 이후 UI 모델/mapper/list adapter에 `showReaction`과 잠금 조건을 반영했다.
|
||||||
|
|
||||||
## Verification Log
|
## Verification Log
|
||||||
|
- 2026-07-12: Phase 7 유료 미구매 리스트형 게시글 reaction/comment 숨김 변경을 완료했다. RED는 `CreatorChannelCommunityMapperTest.유료 미구매 타인 게시글은 reaction 현황과 댓글을 숨긴다`의 `showReaction` 미구현 컴파일 실패로 확인했고, 구현 후 focused 테스트, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.creator.channel.community.*"`, `./gradlew :app:mergeDebugResources :app:compileDebugKotlin :app:ktlintCheck`가 모두 `BUILD SUCCESSFUL`로 PASS했다. `ktlintCheck`의 `.editorconfig disabled_rules` deprecation 경고는 기존 설정 경고다.
|
||||||
- 계획 문서 생성 단계에서는 코드 변경을 수행하지 않았다. 구현 후 통합 검증, 회귀 검증, 최종 수동 확인 기록을 이 섹션에 누적한다.
|
- 계획 문서 생성 단계에서는 코드 변경을 수행하지 않았다. 구현 후 통합 검증, 회귀 검증, 최종 수동 확인 기록을 이 섹션에 누적한다.
|
||||||
|
|
||||||
- 2026-06-22 Phase 6 통합 검증과 수동 확인:
|
- 2026-06-22 Phase 6 통합 검증과 수동 확인:
|
||||||
@@ -615,6 +629,14 @@
|
|||||||
- GREEN: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.creator.channel.community.CreatorChannelCommunityFragmentLayoutTest"` PASS.
|
- GREEN: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.creator.channel.community.CreatorChannelCommunityFragmentLayoutTest"` PASS.
|
||||||
- 회귀/빌드: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.creator.channel.community.*" --tests "kr.co.vividnext.sodalive.v2.creator.channel.CreatorChannelActivitySourceTest"` PASS, `./gradlew :app:mergeDebugResources :app:compileDebugKotlin :app:ktlintCheck` PASS, `git diff --check` PASS. `ktlintCheck`는 기존 `.editorconfig disabled_rules` deprecation 경고만 출력됐다.
|
- 회귀/빌드: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.creator.channel.community.*" --tests "kr.co.vividnext.sodalive.v2.creator.channel.CreatorChannelActivitySourceTest"` PASS, `./gradlew :app:mergeDebugResources :app:compileDebugKotlin :app:ktlintCheck` PASS, `git diff --check` PASS. `ktlintCheck`는 기존 `.editorconfig disabled_rules` deprecation 경고만 출력됐다.
|
||||||
|
|
||||||
|
- 2026-07-12 썸네일형 Grid 잠금 overlay 사이즈 보정 계획:
|
||||||
|
- 요구사항: `layout_creator_channel_community_grid_locked_overlay`가 `visible` 상태일 때 `ConstraintLayout` 내부에서 숨겨지거나 0 크기로 보이지 않고, adapter가 지정한 grid item 정사각 영역 전체를 덮어야 한다. 새 잠금 UI의 lock icon과 가격 캡슐은 overlay 중앙에 표시한다.
|
||||||
|
- 수정 계획: `item_creator_channel_community_grid.xml`의 잠금 overlay는 기존 변경된 `LinearLayout` 기반 lock/price 구조를 유지하되 `match_parent` 크기와 중앙 gravity 계약을 명시한다. 관련 layout test는 새 가격 TextView ID와 overlay size/gravity 계약만 최소 갱신한다.
|
||||||
|
- 검증 계획: `CreatorChannelCommunityFragmentLayoutTest`, `mergeDebugResources`, `compileDebugKotlin`, `git diff --check`를 실행한다. 실제 기기/에뮬레이터가 연결되어 있으면 썸네일형 유료 미구매 tile 표시를 수동 확인한다.
|
||||||
|
- 수정 기록: `item_creator_channel_community_grid.xml`의 `layout_creator_channel_community_grid_locked_overlay`를 `match_parent` 크기와 `gravity="center"`로 보정해 visible 상태에서 grid item 전체를 덮고 lock icon/가격 캡슐을 중앙에 표시하도록 했다. XML 구조 변경에 맞춰 `CreatorChannelCommunityGridAdapter`의 제거된 child view binding 참조를 정리했고, layout test에 overlay size/gravity 계약을 추가했다.
|
||||||
|
- 검증: 최초 focused test는 XML에서 제거된 `ivCreatorChannelCommunityGridLock` binding 참조로 `compileDebugKotlin` 실패를 확인했고, adapter 참조 보정 후 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.creator.channel.community.CreatorChannelCommunityFragmentLayoutTest"` PASS, `./gradlew :app:mergeDebugResources` PASS, `./gradlew :app:compileDebugKotlin` PASS를 확인했다.
|
||||||
|
- 수동 확인: `adb devices` 결과 연결된 기기/에뮬레이터가 없어 실제 썸네일형 유료 미구매 tile 화면은 직접 조작 검증하지 못했다.
|
||||||
|
|
||||||
- 2026-06-21 Phase 2 검증:
|
- 2026-06-21 Phase 2 검증:
|
||||||
- RED: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.creator.channel.community.CreatorChannelCommunityViewModelTest" --tests "kr.co.vividnext.sodalive.v2.creator.channel.community.CreatorChannelCommunityPaginationTest"`는 production ViewModel 구현 전 `CreatorChannelCommunityViewModel`/`CreatorChannelCommunityUiState`/`CreatorChannelCommunityViewMode` 미구현으로 `:app:compileDebugUnitTestKotlin` 실패를 확인했다.
|
- RED: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.creator.channel.community.CreatorChannelCommunityViewModelTest" --tests "kr.co.vividnext.sodalive.v2.creator.channel.community.CreatorChannelCommunityPaginationTest"`는 production ViewModel 구현 전 `CreatorChannelCommunityViewModel`/`CreatorChannelCommunityUiState`/`CreatorChannelCommunityViewMode` 미구현으로 `:app:compileDebugUnitTestKotlin` 실패를 확인했다.
|
||||||
- GREEN: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.creator.channel.community.*"` PASS.
|
- GREEN: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.creator.channel.community.*"` PASS.
|
||||||
|
|||||||
@@ -201,6 +201,7 @@ Sort-bar는 전체 커뮤니티 게시글 수와 현재 보기 방식을 표시
|
|||||||
- text-only grid item의 본문 preview 길이와 줄 수는 기존 `CreatorCommunityAllGridAdapter` 정책을 우선 참고한다.
|
- text-only grid item의 본문 preview 길이와 줄 수는 기존 `CreatorCommunityAllGridAdapter` 정책을 우선 참고한다.
|
||||||
- `isPinned == true`이면 Figma처럼 pin/notice icon을 grid item 상단에 표시한다.
|
- `isPinned == true`이면 Figma처럼 pin/notice icon을 grid item 상단에 표시한다.
|
||||||
- `price > 0 && existOrdered == false`이고 본인 채널 작성자 권한도 아닌 유료 미구매 게시글은 grid item에 잠금/가격 표시를 노출한다.
|
- `price > 0 && existOrdered == false`이고 본인 채널 작성자 권한도 아닌 유료 미구매 게시글은 grid item에 잠금/가격 표시를 노출한다.
|
||||||
|
- 유료 미구매 grid item의 잠금 overlay는 grid item의 실제 정사각 영역 전체를 덮고, lock icon과 가격 캡슐을 중앙 정렬로 표시한다.
|
||||||
- 본인 또는 구매한 사용자는 grid item에 실제 이미지 또는 text preview를 표시하고 잠금 overlay를 표시하지 않는다.
|
- 본인 또는 구매한 사용자는 grid item에 실제 이미지 또는 text preview를 표시하고 잠금 overlay를 표시하지 않는다.
|
||||||
- grid item 터치 시 아무 동작도 수행하지 않는다. 상세 화면은 다음 범위에서 추가한다.
|
- grid item 터치 시 아무 동작도 수행하지 않는다. 상세 화면은 다음 범위에서 추가한다.
|
||||||
|
|
||||||
|
|||||||
@@ -42,7 +42,7 @@
|
|||||||
- Phase 4: 필수 참조
|
- Phase 4: 필수 참조
|
||||||
- 배너, `오직 보이스온에서만!`, `새로 올라온 오디오`, `무료 오디오`, `포인트 오디오`, `추천 오디오` 카드 크기/간격을 확인한다.
|
- 배너, `오직 보이스온에서만!`, `새로 올라온 오디오`, `무료 오디오`, `포인트 오디오`, `추천 오디오` 카드 크기/간격을 확인한다.
|
||||||
- Phase 5: 필수 참조
|
- Phase 5: 필수 참조
|
||||||
- `New&Hot` 리스트 묶음과 `최근 댓글이 많은 오디오` 댓글 카드 구조를 확인한다.
|
- `New&Hot` 리스트 묶음과 `크리에이터의 댓글 많은 오디오` 댓글 카드 구조를 확인한다.
|
||||||
- Phase 6: 제한 참조
|
- Phase 6: 제한 참조
|
||||||
- routing, section visibility, loading/error는 기존 코드 패턴 중심으로 검증한다.
|
- routing, section visibility, loading/error는 기존 코드 패턴 중심으로 검증한다.
|
||||||
- Phase 7: 필수 참조
|
- Phase 7: 필수 참조
|
||||||
@@ -86,7 +86,7 @@
|
|||||||
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/ui/ContentNewAndHotAdapter.kt`
|
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/ui/ContentNewAndHotAdapter.kt`
|
||||||
- `New&Hot` 3개 row 단위 가로 묶음 리스트를 처리한다.
|
- `New&Hot` 3개 row 단위 가로 묶음 리스트를 처리한다.
|
||||||
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/ui/ContentCommentedAudioAdapter.kt`
|
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/ui/ContentCommentedAudioAdapter.kt`
|
||||||
- 최근 댓글이 많은 오디오 카드와 댓글 영역을 처리한다.
|
- 크리에이터의 댓글 많은 오디오 카드와 댓글 영역을 처리한다.
|
||||||
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/ui/ContentRecyclerItemLayoutParams.kt`
|
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/ui/ContentRecyclerItemLayoutParams.kt`
|
||||||
- 가로 section item spacing과 grid spacing helper를 둔다.
|
- 가로 section item spacing과 grid spacing helper를 둔다.
|
||||||
- Create: `app/src/main/res/layout/item_content_original_series.xml`
|
- Create: `app/src/main/res/layout/item_content_original_series.xml`
|
||||||
@@ -140,7 +140,7 @@
|
|||||||
- 배너는 `BannerView`/`BannerItem` 재사용으로 고정한다.
|
- 배너는 `BannerView`/`BannerItem` 재사용으로 고정한다.
|
||||||
- 일반 오디오 카드는 `AudioContentCardView`와 `AudioContentCardSize.Medium/Large` 재사용으로 고정한다.
|
- 일반 오디오 카드는 `AudioContentCardView`와 `AudioContentCardSize.Medium/Large` 재사용으로 고정한다.
|
||||||
- `오직 보이스온에서만!`은 label 없는 cover만 필요하므로 기존 `SeriesContentCardView`를 직접 변경하지 않고 신규 `item_content_original_series.xml`로 처리한다.
|
- `오직 보이스온에서만!`은 label 없는 cover만 필요하므로 기존 `SeriesContentCardView`를 직접 변경하지 않고 신규 `item_content_original_series.xml`로 처리한다.
|
||||||
- `New&Hot`, `최근 댓글이 많은 오디오`는 Figma 구조가 공통 카드와 달라 신규 item view로 처리한다.
|
- `New&Hot`, `크리에이터의 댓글 많은 오디오`는 Figma 구조가 공통 카드와 달라 신규 item view로 처리한다.
|
||||||
- 검증:
|
- 검증:
|
||||||
- PRD의 재사용 후보와 신규 item 범위가 일치하는지 문서상 체크한다.
|
- PRD의 재사용 후보와 신규 item 범위가 일치하는지 문서상 체크한다.
|
||||||
- 검증 기록:
|
- 검증 기록:
|
||||||
@@ -388,7 +388,7 @@
|
|||||||
- 검증 기록:
|
- 검증 기록:
|
||||||
- 2026-06-23: `ContentNewAndHotAdapter`에서 `items.chunked(NEW_AND_HOT_GROUP_SIZE)`와 `private const val NEW_AND_HOT_GROUP_SIZE = 3` 정책을 구현했다. 마지막 group이 3개 미만이면 실제 item row만 inflate하도록 처리했다.
|
- 2026-06-23: `ContentNewAndHotAdapter`에서 `items.chunked(NEW_AND_HOT_GROUP_SIZE)`와 `private const val NEW_AND_HOT_GROUP_SIZE = 3` 정책을 구현했다. 마지막 group이 3개 미만이면 실제 item row만 inflate하도록 처리했다.
|
||||||
|
|
||||||
- [x] **Task 5.2: 최근 댓글이 많은 오디오 adapter 구현**
|
- [x] **Task 5.2: 크리에이터의 댓글 많은 오디오 adapter 구현**
|
||||||
- 생성:
|
- 생성:
|
||||||
- `app/src/main/res/layout/item_content_commented_audio.xml`
|
- `app/src/main/res/layout/item_content_commented_audio.xml`
|
||||||
- `app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/ui/ContentCommentedAudioAdapter.kt`
|
- `app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/ui/ContentCommentedAudioAdapter.kt`
|
||||||
@@ -410,11 +410,11 @@
|
|||||||
- `app/src/main/res/layout/fragment_v2_main_content.xml`
|
- `app/src/main/res/layout/fragment_v2_main_content.xml`
|
||||||
- 작업:
|
- 작업:
|
||||||
- `New&Hot`는 horizontal group RecyclerView로 바인딩한다.
|
- `New&Hot`는 horizontal group RecyclerView로 바인딩한다.
|
||||||
- `최근 댓글이 많은 오디오`는 horizontal card RecyclerView로 바인딩한다.
|
- `크리에이터의 댓글 많은 오디오`는 horizontal card RecyclerView로 바인딩한다.
|
||||||
- 각 section은 item list가 비면 전체를 숨긴다.
|
- 각 section은 item list가 비면 전체를 숨긴다.
|
||||||
- 검증:
|
- 검증:
|
||||||
- Figma 순서상 `New&Hot`가 최신 오디오 뒤, 무료 오디오 앞에 배치되는지 확인한다.
|
- Figma 순서상 `New&Hot`가 최신 오디오 뒤, 무료 오디오 앞에 배치되는지 확인한다.
|
||||||
- `최근 댓글이 많은 오디오`가 포인트 오디오 뒤, 추천 오디오 앞에 배치되는지 확인한다.
|
- `크리에이터의 댓글 많은 오디오`가 포인트 오디오 뒤, 추천 오디오 앞에 배치되는지 확인한다.
|
||||||
- 검증 기록:
|
- 검증 기록:
|
||||||
- 2026-06-23: `fragment_v2_main_content.xml`의 Phase 3 section 순서를 유지하며 New&Hot는 최신 뒤/무료 앞, 최근 댓글은 포인트 뒤/추천 앞에 adapter를 연결했다. 제외 섹션 id/string은 추가하지 않았다.
|
- 2026-06-23: `fragment_v2_main_content.xml`의 Phase 3 section 순서를 유지하며 New&Hot는 최신 뒤/무료 앞, 최근 댓글은 포인트 뒤/추천 앞에 adapter를 연결했다. 제외 섹션 id/string은 추가하지 않았다.
|
||||||
|
|
||||||
@@ -489,7 +489,7 @@
|
|||||||
- 확인:
|
- 확인:
|
||||||
- title-bar와 tab-bar가 스크롤 중 고정된다.
|
- title-bar와 tab-bar가 스크롤 중 고정된다.
|
||||||
- 스크롤 영역은 배너부터 시작한다.
|
- 스크롤 영역은 배너부터 시작한다.
|
||||||
- 섹션 순서는 배너 → 오직 보이스온에서만! → 새로 올라온 오디오 → New&Hot → 무료 오디오 → 포인트 오디오 → 최근 댓글이 많은 오디오 → 추천 오디오다.
|
- 섹션 순서는 배너 → 오직 보이스온에서만! → 새로 올라온 오디오 → New&Hot → 무료 오디오 → 포인트 오디오 → 크리에이터의 댓글 많은 오디오 → 추천 오디오다.
|
||||||
- `추천 시리즈`, `키워드의 오디오`는 표시되지 않는다.
|
- `추천 시리즈`, `키워드의 오디오`는 표시되지 않는다.
|
||||||
- 각 section의 empty 상태에서 빈 title/list 공간이 남지 않는다.
|
- 각 section의 empty 상태에서 빈 title/list 공간이 남지 않는다.
|
||||||
- `duration`은 어떤 오디오 카드에도 표시되지 않는다.
|
- `duration`은 어떤 오디오 카드에도 표시되지 않는다.
|
||||||
@@ -555,6 +555,23 @@
|
|||||||
- 2026-07-09: root 배경 보정 후 `./gradlew :app:mergeDebugResources`, `./gradlew :app:compileDebugKotlin`, `./gradlew :app:installDebug`, `git diff --check` 결과 모두 성공했다. 실제 기기 `SM-G960N - Android 10`에서 `adb shell am start -W -n kr.co.vividnext.sodalive.debug/kr.co.vividnext.sodalive.splash.SplashActivity`가 `Status: ok`, `LaunchState: COLD`로 완료됐고, `dumpsys window`에서 `kr.co.vividnext.sodalive.v2.main.MainV2Activity` focus를 확인했다.
|
- 2026-07-09: root 배경 보정 후 `./gradlew :app:mergeDebugResources`, `./gradlew :app:compileDebugKotlin`, `./gradlew :app:installDebug`, `git diff --check` 결과 모두 성공했다. 실제 기기 `SM-G960N - Android 10`에서 `adb shell am start -W -n kr.co.vividnext.sodalive.debug/kr.co.vividnext.sodalive.splash.SplashActivity`가 `Status: ok`, `LaunchState: COLD`로 완료됐고, `dumpsys window`에서 `kr.co.vividnext.sodalive.v2.main.MainV2Activity` focus를 확인했다.
|
||||||
- 2026-07-09: 추가 요청에 따라 댓글 많은 오디오 item이 여러 개 표시될 때 각 item width를 device width에서 좌우 margin 14dp씩 총 28dp를 뺀 값으로 설정하고, item 사이 간격은 기존 horizontal item decoration의 8dp를 유지했다. 댓글 작성자 profile image는 원형 outline clipping을 적용했고, 콘텐츠 cover image는 `radius_14` outline clipping을 유지했다. `./gradlew :app:mergeDebugResources`, `./gradlew :app:compileDebugKotlin`, `./gradlew :app:installDebug`, `git diff --check` 결과 모두 성공했다. 실제 기기에서 `adb shell am start -W -n kr.co.vividnext.sodalive.debug/kr.co.vividnext.sodalive.splash.SplashActivity`가 `Status: ok`, `LaunchState: COLD`로 완료됐고, `dumpsys window`에서 `kr.co.vividnext.sodalive.v2.main.MainV2Activity` focus를 확인했다.
|
- 2026-07-09: 추가 요청에 따라 댓글 많은 오디오 item이 여러 개 표시될 때 각 item width를 device width에서 좌우 margin 14dp씩 총 28dp를 뺀 값으로 설정하고, item 사이 간격은 기존 horizontal item decoration의 8dp를 유지했다. 댓글 작성자 profile image는 원형 outline clipping을 적용했고, 콘텐츠 cover image는 `radius_14` outline clipping을 유지했다. `./gradlew :app:mergeDebugResources`, `./gradlew :app:compileDebugKotlin`, `./gradlew :app:installDebug`, `git diff --check` 결과 모두 성공했다. 실제 기기에서 `adb shell am start -W -n kr.co.vividnext.sodalive.debug/kr.co.vividnext.sodalive.splash.SplashActivity`가 `Status: ok`, `LaunchState: COLD`로 완료됐고, `dumpsys window`에서 `kr.co.vividnext.sodalive.v2.main.MainV2Activity` focus를 확인했다.
|
||||||
|
|
||||||
|
### 2026-07-12 후속 변경: 댓글 많은 오디오 섹션 타이틀 변경
|
||||||
|
|
||||||
|
- [x] **Task 9.2: 댓글 많은 오디오 섹션 타이틀 문구 변경**
|
||||||
|
- 수정:
|
||||||
|
- `app/src/main/res/values/strings.xml`
|
||||||
|
- `docs/20260623_메인_콘텐츠_추천_탭/prd.md`
|
||||||
|
- `docs/20260623_메인_콘텐츠_추천_탭/plan-task.md`
|
||||||
|
- 작업:
|
||||||
|
- 추천 탭 댓글 많은 오디오 섹션 타이틀을 `최근 댓글이 많은 오디오`에서 `크리에이터의 댓글 많은 오디오`로 변경한다.
|
||||||
|
- API, adapter, layout 구조는 변경하지 않는다.
|
||||||
|
- 검증:
|
||||||
|
- `rg -n "최근 댓글이 많은 오디오|크리에이터의 댓글 많은 오디오|content_recommendation_section_most_commented_audio" app/src/main/res/values/strings.xml docs/20260623_메인_콘텐츠_추천_탭/prd.md docs/20260623_메인_콘텐츠_추천_탭/plan-task.md`
|
||||||
|
- `./gradlew :app:mergeDebugResources`
|
||||||
|
- `git diff --check`
|
||||||
|
- 검증 기록:
|
||||||
|
- 2026-07-12: 섹션 표시 문자열 `content_recommendation_section_most_commented_audio`를 `크리에이터의 댓글 많은 오디오`로 변경하고, PRD와 plan-task의 현재 요구사항/섹션 순서 표현을 동일한 타이틀로 갱신했다.
|
||||||
|
|
||||||
### 2026-07-09 후속 변경: 추천 오디오 2열 그리드 동적 폭 보정
|
### 2026-07-09 후속 변경: 추천 오디오 2열 그리드 동적 폭 보정
|
||||||
|
|
||||||
- [x] **Task 10.1: 추천 오디오 grid item width 자동 계산 적용**
|
- [x] **Task 10.1: 추천 오디오 grid item width 자동 계산 적용**
|
||||||
|
|||||||
@@ -219,11 +219,11 @@ Figma `24:6813` 기준으로 포인트 오디오 가로 카드 목록을 표시
|
|||||||
- 무료/오리지널/첫 콘텐츠/성인 태그도 응답 값에 따라 함께 표시한다.
|
- 무료/오리지널/첫 콘텐츠/성인 태그도 응답 값에 따라 함께 표시한다.
|
||||||
- item 터치 시 오디오 콘텐츠 상세로 이동한다.
|
- item 터치 시 오디오 콘텐츠 상세로 이동한다.
|
||||||
|
|
||||||
### Phase 7: 최근 댓글이 많은 오디오
|
### Phase 7: 크리에이터의 댓글 많은 오디오
|
||||||
Figma `24:6820`, item `24:6822` 기준으로 댓글이 많은 오디오를 가로 카드 묶음으로 표시한다.
|
Figma `24:6820`, item `24:6822` 기준으로 댓글이 많은 오디오를 가로 카드 묶음으로 표시한다.
|
||||||
|
|
||||||
#### Requirements
|
#### Requirements
|
||||||
- 섹션 타이틀은 `최근 댓글이 많은 오디오`이다.
|
- 섹션 타이틀은 `크리에이터의 댓글 많은 오디오`이다.
|
||||||
- `mostCommentedAudios`를 가로 스크롤 가능한 카드 묶음으로 표시한다.
|
- `mostCommentedAudios`를 가로 스크롤 가능한 카드 묶음으로 표시한다.
|
||||||
- 각 카드에는 오디오 리스트 item, 최근 댓글 본문, 최근 댓글 작성자 프로필 이미지 영역을 함께 표시한다.
|
- 각 카드에는 오디오 리스트 item, 최근 댓글 본문, 최근 댓글 작성자 프로필 이미지 영역을 함께 표시한다.
|
||||||
- 댓글 영역에는 `latestComment`를 최대 2줄 말줄임으로 표시하고, 우측 chevron은 표시하지 않는다.
|
- 댓글 영역에는 `latestComment`를 최대 2줄 말줄임으로 표시하고, 우측 chevron은 표시하지 않는다.
|
||||||
@@ -285,7 +285,7 @@ Figma에는 존재하지만 이번 범위에서는 구현하지 않는다.
|
|||||||
## 8. UX / UI Expectations
|
## 8. UX / UI Expectations
|
||||||
- 전체 배경은 Figma처럼 black 계열을 유지한다.
|
- 전체 배경은 Figma처럼 black 계열을 유지한다.
|
||||||
- title-bar와 tab-bar는 고정되고, 콘텐츠 영역만 세로 스크롤된다.
|
- title-bar와 tab-bar는 고정되고, 콘텐츠 영역만 세로 스크롤된다.
|
||||||
- 섹션은 Figma 순서대로 배치한다: 배너 → 오직 보이스온에서만! → 새로 올라온 오디오 → New&Hot → 무료 오디오 → 포인트 오디오 → 최근 댓글이 많은 오디오 → 추천 오디오.
|
- 섹션은 Figma 순서대로 배치한다: 배너 → 오직 보이스온에서만! → 새로 올라온 오디오 → New&Hot → 무료 오디오 → 포인트 오디오 → 크리에이터의 댓글 많은 오디오 → 추천 오디오.
|
||||||
- 각 섹션은 데이터가 없으면 숨겨 스크롤 중 빈 영역이 생기지 않게 한다.
|
- 각 섹션은 데이터가 없으면 숨겨 스크롤 중 빈 영역이 생기지 않게 한다.
|
||||||
- 가로 스크롤 섹션은 Figma 기준 좌측 inset과 item spacing을 구현 계획에서 수치화한다.
|
- 가로 스크롤 섹션은 Figma 기준 좌측 inset과 item spacing을 구현 계획에서 수치화한다.
|
||||||
- 카드 썸네일은 centerCrop과 radius 14dp를 기본으로 한다.
|
- 카드 썸네일은 centerCrop과 radius 14dp를 기본으로 한다.
|
||||||
|
|||||||
@@ -478,9 +478,97 @@
|
|||||||
- 2026-06-25: PASS. sort popup이 기존 `CreatorChannelSortPopup` 형태로 열리고 `ContentSort` 옵션 `최신순`, `인기순`, `소장순`, `높은 가격순`, `낮은 가격순`이 표시됨을 캡처 `sodalive_phase6_sort_popup.png`와 hierarchy로 확인했다.
|
- 2026-06-25: PASS. sort popup이 기존 `CreatorChannelSortPopup` 형태로 열리고 `ContentSort` 옵션 `최신순`, `인기순`, `소장순`, `높은 가격순`, `낮은 가격순`이 표시됨을 캡처 `sodalive_phase6_sort_popup.png`와 hierarchy로 확인했다.
|
||||||
- 2026-06-25: BLOCKED. `GET /api/v2/audio/contents`가 수동 검증 환경에서 `HTTP 404`를 반환해 실제 오디오/시리즈 카드 3열 grid 데이터 표시, 하단 스크롤 append, 시리즈 성인 배지의 실데이터 표시는 확인하지 못했다. 해당 항목은 단위 테스트와 source/layout 검증으로 대체 확인했다.
|
- 2026-06-25: BLOCKED. `GET /api/v2/audio/contents`가 수동 검증 환경에서 `HTTP 404`를 반환해 실제 오디오/시리즈 카드 3열 grid 데이터 표시, 하단 스크롤 append, 시리즈 성인 배지의 실데이터 표시는 확인하지 못했다. 해당 항목은 단위 테스트와 source/layout 검증으로 대체 확인했다.
|
||||||
|
|
||||||
|
### Phase 7: 전체 탭 sort-bar와 정렬 조건 후속 수정
|
||||||
|
|
||||||
|
- [x] **Task 7.1: sort-bar 전체 개수 표시 제거**
|
||||||
|
- 수정:
|
||||||
|
- `app/src/main/res/layout/fragment_v2_main_content.xml`
|
||||||
|
- `app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/ContentMainFragment.kt`
|
||||||
|
- `app/src/test/java/kr/co/vividnext/sodalive/v2/main/content/ContentMainFragmentSourceTest.kt`
|
||||||
|
- 작업:
|
||||||
|
- sort-bar 좌측에는 `전체` 라벨만 표시하고 `totalCount` TextView와 binding 업데이트를 제거한다.
|
||||||
|
- `MainContentAllTabUiState.totalCount`는 Empty 판단과 상태 보존을 위해 유지하되 화면에는 표시하지 않는다.
|
||||||
|
- 검증:
|
||||||
|
- Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.content.ContentMainFragmentSourceTest"`
|
||||||
|
- Expected: 전체 탭 layout/source에서 total count 표시 계약이 제거된다.
|
||||||
|
- 검증 기록:
|
||||||
|
- 2026-07-10: PASS. `tv_content_all_total_count` layout 제거, `binding.tvContentAllTotalCount.text` source 제거를 반영했고, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.content.*"`에서 `ContentMainFragmentSourceTest` 포함 전체 탭 패키지 테스트 성공.
|
||||||
|
|
||||||
|
- [x] **Task 7.2: 전체 탭 정렬 옵션 노출 정책 적용**
|
||||||
|
- 수정:
|
||||||
|
- `app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/model/MainContentAllTabUiModels.kt`
|
||||||
|
- `app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/ContentAllTabViewModel.kt`
|
||||||
|
- `app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/ContentMainFragment.kt`
|
||||||
|
- `app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/ui/CreatorChannelSortPopup.kt`
|
||||||
|
- `app/src/test/java/kr/co/vividnext/sodalive/v2/main/content/ContentAllTabViewModelTest.kt`
|
||||||
|
- `app/src/test/java/kr/co/vividnext/sodalive/v2/main/content/ContentMainFragmentSourceTest.kt`
|
||||||
|
- 작업:
|
||||||
|
- 전체 탭 정렬 옵션에서 `ContentSort.OWNED`를 제외한다.
|
||||||
|
- `MainContentAllType.FREE` 선택 시 `ContentSort.PRICE_HIGH`, `ContentSort.PRICE_LOW`를 제외한다.
|
||||||
|
- 무료 타입에서 가격 정렬이 외부 선택 또는 이전 상태로 유입되면 `LATEST`로 보정해 표시/요청 조건이 일치하게 한다.
|
||||||
|
- 기존 `CreatorChannelSortPopup` 사용처 영향 방지를 위해 옵션 목록은 기본값을 유지하고 전체 탭에서만 필터 목록을 전달한다.
|
||||||
|
- 검증:
|
||||||
|
- Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.content.*"`
|
||||||
|
- Expected: 전체 탭 정렬 option/source/ViewModel 테스트가 PASS한다.
|
||||||
|
- 검증 기록:
|
||||||
|
- 2026-07-10: PASS. `MainContentAllType.contentAllSortOptions()`와 `normalizeContentAllSort()`를 추가해 전체 탭 정렬에서 `OWNED`를 제외하고, `FREE` 타입에서는 `PRICE_HIGH`, `PRICE_LOW`를 제외했다.
|
||||||
|
- 2026-07-10: PASS. `ContentAllTabViewModelTest`에 외부 FREE 가격 정렬 보정, 무료 타입 변경 시 이전 가격 정렬 보정, 소장순 보정 테스트를 추가했다.
|
||||||
|
- 2026-07-10: PASS. `MainContentAllTabMapperTest`에 전체 탭 정렬 옵션의 소장순 제외와 무료 타입 가격순 제외 테스트를 추가했다.
|
||||||
|
- 2026-07-10: PASS. `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.content.*"` 성공.
|
||||||
|
|
||||||
|
- [x] **Task 7.3: 후속 수정 통합 검증**
|
||||||
|
- 실행:
|
||||||
|
- `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.content.*"`
|
||||||
|
- `./gradlew :app:mergeDebugResources`
|
||||||
|
- `./gradlew :app:compileDebugKotlin`
|
||||||
|
- `./gradlew :app:ktlintCheck`
|
||||||
|
- `git diff --check`
|
||||||
|
- 기대 결과:
|
||||||
|
- 전체 탭 후속 수정 관련 테스트, 리소스 merge, Kotlin compile, ktlint, whitespace 검증이 모두 성공한다.
|
||||||
|
- 검증 기록:
|
||||||
|
- 2026-07-10: PASS. `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.content.*"` 성공.
|
||||||
|
- 2026-07-10: PASS. `./gradlew :app:mergeDebugResources` 성공. 최초 일반 실행은 Gradle wrapper lock 파일 접근이 샌드박스에서 차단되어 실패했고, 권한 상승 실행으로 재검증했다.
|
||||||
|
- 2026-07-10: PASS. `./gradlew :app:compileDebugKotlin` 성공.
|
||||||
|
- 2026-07-10: PASS. `./gradlew :app:ktlintCheck` 성공. `.editorconfig disabled_rules` deprecation warning은 기존 설정 경고로 남아 있다.
|
||||||
|
- 2026-07-10: PASS. `git diff --check` 성공.
|
||||||
|
|
||||||
|
- [x] **Task 7.4: sort-bar 좌측 전체 라벨 제거와 정렬 위치 유지**
|
||||||
|
- 수정:
|
||||||
|
- `app/src/main/res/layout/fragment_v2_main_content.xml`
|
||||||
|
- `app/src/test/java/kr/co/vividnext/sodalive/v2/main/content/ContentMainFragmentSourceTest.kt`
|
||||||
|
- 작업:
|
||||||
|
- `tv_content_all_total_label`을 제거한다.
|
||||||
|
- 정렬 버튼은 기존처럼 sort-bar 우측에 위치하도록 spacer를 유지한다.
|
||||||
|
- 검증:
|
||||||
|
- Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.content.ContentMainFragmentSourceTest"`
|
||||||
|
- Expected: 전체 탭 sort-bar에 좌측 `전체` 라벨 id/text가 없고, 정렬 버튼 우측 배치 구조가 유지된다.
|
||||||
|
- 검증 기록:
|
||||||
|
- 2026-07-10: PASS. `tv_content_all_total_label`을 제거하고 같은 위치 유지용 `Space`를 추가했다.
|
||||||
|
- 2026-07-10: PASS. `ContentMainFragmentSourceTest`에 label id/text 제거와 spacer/정렬 버튼 구조 유지 계약을 반영했다.
|
||||||
|
- 2026-07-10: PASS. `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.content.ContentMainFragmentSourceTest"` 성공.
|
||||||
|
- 2026-07-10: PASS. `./gradlew :app:ktlintCheck` 성공. `.editorconfig disabled_rules` deprecation warning은 기존 설정 경고로 남아 있다.
|
||||||
|
- 2026-07-10: PASS. `git diff --check` 성공.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Verification Log
|
## Verification Log
|
||||||
|
- 2026-07-10 Phase 7 전체 탭 sort-bar/정렬 조건 후속 수정 검증:
|
||||||
|
- 수정 완료: sort-bar의 `tv_content_all_total_count`와 `ContentMainFragment`의 total count binding 업데이트를 제거해 화면에 콘텐츠 전체 개수가 표시되지 않도록 했다.
|
||||||
|
- 수정 완료: 전체 탭 전용 정렬 옵션에서 `ContentSort.OWNED`를 제외하고, `FREE` 타입에서는 `ContentSort.PRICE_HIGH`, `ContentSort.PRICE_LOW`도 제외했다.
|
||||||
|
- 수정 완료: 외부 선택 또는 이전 타입 상태에서 금지 정렬이 유입되면 `ContentSort.LATEST`로 보정하도록 `ContentAllTabViewModel`에 적용했다.
|
||||||
|
- PASS: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.content.*"`
|
||||||
|
- PASS: `./gradlew :app:mergeDebugResources`
|
||||||
|
- PASS: `./gradlew :app:compileDebugKotlin`
|
||||||
|
- PASS: `./gradlew :app:ktlintCheck`
|
||||||
|
- PASS: `git diff --check`
|
||||||
|
- 참고: `./gradlew :app:mergeDebugResources`는 최초 일반 실행에서 Gradle wrapper lock 파일 접근이 샌드박스에서 차단되어 실패했고, 권한 상승 실행으로 재검증해 성공했다.
|
||||||
|
- 참고: `ktlintCheck`의 `.editorconfig disabled_rules` deprecation warning과 Gradle deprecation warning은 기존 경고로 이번 변경과 무관하다.
|
||||||
|
- 2026-07-10 Phase 7 sort-bar 좌측 전체 라벨 추가 제거 검증:
|
||||||
|
- 수정 완료: `fragment_v2_main_content.xml`에서 `tv_content_all_total_label`을 제거하고, `Space`에 `layout_weight="1"`을 적용해 정렬 버튼의 우측 위치를 유지했다.
|
||||||
|
- PASS: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.content.ContentMainFragmentSourceTest"`
|
||||||
|
- PASS: `./gradlew :app:ktlintCheck`
|
||||||
|
- PASS: `git diff --check`
|
||||||
|
- 참고: `ktlintCheck`의 `.editorconfig disabled_rules` deprecation warning은 기존 경고로 이번 변경과 무관하다.
|
||||||
- 문서 작성 시점에는 구현 전이므로 실행한 빌드/테스트가 없다.
|
- 문서 작성 시점에는 구현 전이므로 실행한 빌드/테스트가 없다.
|
||||||
- 2026-06-25 Phase 1~3 구현 검증:
|
- 2026-06-25 Phase 1~3 구현 검증:
|
||||||
- PASS: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.content.MainContentAllDayOfWeekMapperTest"`
|
- PASS: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.content.MainContentAllDayOfWeekMapperTest"`
|
||||||
@@ -677,3 +765,58 @@
|
|||||||
- PASS: device `2cec640c34017ece`에서 debug 앱 실행 후 콘텐츠 `전체 > 오디오`, `전체 > 시리즈`를 탭하고 UI hierarchy 기준 카드 x bounds가 3열 grid 열 안에 들어오는 것을 확인했다.
|
- PASS: device `2cec640c34017ece`에서 debug 앱 실행 후 콘텐츠 `전체 > 오디오`, `전체 > 시리즈`를 탭하고 UI hierarchy 기준 카드 x bounds가 3열 grid 열 안에 들어오는 것을 확인했다.
|
||||||
- 참고: 최초 `ktlintCheck` 실행은 `ContentMainFragmentSourceTest`의 긴 assertion 한 줄로 실패했고, 줄바꿈 보정 후 재실행해 통과했다.
|
- 참고: 최초 `ktlintCheck` 실행은 `ContentMainFragmentSourceTest`의 긴 assertion 한 줄로 실패했고, 줄바꿈 보정 후 재실행해 통과했다.
|
||||||
- 참고: `ktlintCheck`의 `.editorconfig disabled_rules` deprecation warning과 Gradle deprecation warning은 기존 경고로 이번 변경과 무관하다.
|
- 참고: `ktlintCheck`의 `.editorconfig disabled_rules` deprecation warning과 Gradle deprecation warning은 기존 경고로 이번 변경과 무관하다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 8: 전체 탭 pagination 스크롤 안정화
|
||||||
|
|
||||||
|
- [x] **Task 8.1: adapter 보존 source 회귀 테스트 추가**
|
||||||
|
- 수정:
|
||||||
|
- `app/src/test/java/kr/co/vividnext/sodalive/v2/main/content/ContentMainFragmentSourceTest.kt`
|
||||||
|
- 작업:
|
||||||
|
- `bindAllTabContent()`가 같은 콘텐츠 계열에서 `rvContentAllItems.adapter`를 반복 대입하지 않도록 guard helper를 요구한다.
|
||||||
|
- `clearAllTabItems()`가 render-time 상태 정리 중 `rvContentAllItems.adapter = null`을 호출하지 않도록 고정한다.
|
||||||
|
- `onDestroyView()` lifecycle cleanup의 adapter null 처리만 유지되는지 확인한다.
|
||||||
|
- `ContentAllAudioCardAdapter`, `ContentAllSeriesCardAdapter`가 `DiffUtil.calculateDiff(...)`를 사용하고 `submitItems()`에서 `notifyDataSetChanged()`를 호출하지 않도록 고정한다.
|
||||||
|
- 검증:
|
||||||
|
- Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.content.ContentMainFragmentSourceTest"`
|
||||||
|
- Expected: 구현 전 RED 실패, 구현 후 PASS.
|
||||||
|
- 2026-07-12: RED 확인. `ContentMainFragmentSourceTest`에 pagination 렌더링 adapter 보존과 전체 탭 adapter DiffUtil 사용 source 검증을 추가한 뒤 실행했고, 기존 구현에서 신규 테스트 2건이 실패했다. 첫 실행은 120초 timeout으로 결과를 받지 못해 300초 제한으로 재실행했다.
|
||||||
|
|
||||||
|
- [x] **Task 8.2: ContentMainFragment adapter 반복 재설정 방지**
|
||||||
|
- 수정:
|
||||||
|
- `app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/ContentMainFragment.kt`
|
||||||
|
- 작업:
|
||||||
|
- 현재 adapter와 목표 adapter가 다를 때만 `rvContentAllItems.adapter`를 변경한다.
|
||||||
|
- 추가 페이지 로딩 상태 재렌더링 중 같은 adapter를 유지한다.
|
||||||
|
- Empty/Error/Loading 렌더링의 item clear는 adapter를 분리하지 않고 목록만 비운다.
|
||||||
|
- 검증:
|
||||||
|
- Source test와 Kotlin compile로 확인한다.
|
||||||
|
- 2026-07-12: `setAllTabAdapterIfNeeded(adapter: RecyclerView.Adapter<*>)` helper를 추가해 현재 adapter와 다를 때만 `rvContentAllItems.adapter`를 교체하도록 변경했다. `clearAllTabItems()`에서는 adapter null 처리를 제거하고 `onDestroyView()` lifecycle cleanup에는 adapter null 처리를 유지했다.
|
||||||
|
|
||||||
|
- [x] **Task 8.3: 전체 탭 오디오/시리즈 adapter DiffUtil 전환**
|
||||||
|
- 수정:
|
||||||
|
- `app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/ui/ContentAllAudioCardAdapter.kt`
|
||||||
|
- `app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/ui/ContentAllSeriesCardAdapter.kt`
|
||||||
|
- 작업:
|
||||||
|
- public `submitItems(...)` API는 유지한다.
|
||||||
|
- 오디오 identity는 `audioContentId`, 시리즈 identity는 `seriesId`로 비교한다.
|
||||||
|
- content 비교는 UI model data class equality를 사용한다.
|
||||||
|
- grid item width 변경은 기존 guard를 유지하되 전체 dataset reset 대신 range changed로 처리한다.
|
||||||
|
- 검증:
|
||||||
|
- Source test와 Kotlin compile로 확인한다.
|
||||||
|
- 2026-07-12: `ContentAllAudioCardAdapter`와 `ContentAllSeriesCardAdapter`의 `submitItems()`를 `DiffUtil.calculateDiff(...)` + `dispatchUpdatesTo(this)` 방식으로 변경했다. identity는 각각 `audioContentId`, `seriesId`를 사용하고, width 변경은 기존 guard 후 `notifyItemRangeChanged(0, items.size)`만 호출하도록 변경했다.
|
||||||
|
|
||||||
|
- [x] **Task 8.4: 통합 검증과 수동 확인 가능 여부 기록**
|
||||||
|
- 실행:
|
||||||
|
- `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.content.ContentMainFragmentSourceTest"`
|
||||||
|
- `./gradlew :app:compileDebugKotlin`
|
||||||
|
- `./gradlew :app:ktlintCheck`
|
||||||
|
- `git diff --check`
|
||||||
|
- 수동 확인:
|
||||||
|
- 연결 기기/에뮬레이터가 있으면 콘텐츠 `전체` 탭에서 하단 pagination 시 스크롤이 상단으로 튀지 않는지 확인한다.
|
||||||
|
- 기기가 없으면 환경 차단으로 기록하고 source/compile/style 검증으로 대체한다.
|
||||||
|
- 검증 기록:
|
||||||
|
- 2026-07-12: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.content.ContentMainFragmentSourceTest"` 재실행 결과 BUILD SUCCESSFUL을 확인했다.
|
||||||
|
- 2026-07-12: `./gradlew :app:compileDebugKotlin`, `./gradlew :app:ktlintCheck`, `git diff --check`가 모두 통과했다. `ktlintCheck`의 `.editorconfig disabled_rules` deprecation warning과 Gradle deprecation warning은 기존 경고로 이번 변경과 무관하다.
|
||||||
|
- 2026-07-12: `adb devices`에서 device `2cec640c34017ece` 연결을 확인했고 `./gradlew :app:installDebug`로 debug 앱 설치가 BUILD SUCCESSFUL이었다. `adb shell monkey -p kr.co.vividnext.sodalive.debug -c android.intent.category.LAUNCHER 1` 실행 후 foreground가 `kr.co.vividnext.sodalive.debug/kr.co.vividnext.sodalive.splash.SplashActivity`임을 확인했다. SplashActivity에 머물러 콘텐츠 전체 탭까지 이동한 실제 스크롤 육안 확인은 완료하지 못했다.
|
||||||
|
|||||||
@@ -22,6 +22,12 @@
|
|||||||
- 이번 범위의 `ORIGINAL`은 오리지널 시리즈 목록만 의미한다.
|
- 이번 범위의 `ORIGINAL`은 오리지널 시리즈 목록만 의미한다.
|
||||||
- `SERIES` 타입에서만 요일 UI를 노출하고 `dayOfWeek` query parameter를 함께 전송한다.
|
- `SERIES` 타입에서만 요일 UI를 노출하고 `dayOfWeek` query parameter를 함께 전송한다.
|
||||||
- Figma 디자인의 오디오, 시리즈, 오리지널 상태를 기준으로 기존 V2 위젯 재사용 후보를 우선 검토한다.
|
- Figma 디자인의 오디오, 시리즈, 오리지널 상태를 기준으로 기존 V2 위젯 재사용 후보를 우선 검토한다.
|
||||||
|
- 2026-07-10 후속 변경: 전체 탭 sort-bar에는 콘텐츠 전체 개수를 표시하지 않는다.
|
||||||
|
- 2026-07-10 후속 변경: 전체 탭 sort-bar에는 좌측 `전체` 라벨도 표시하지 않는다.
|
||||||
|
- 2026-07-10 후속 변경: 전체 탭 정렬 조건에서 `소장순`을 제거한다.
|
||||||
|
- 2026-07-10 후속 변경: `무료` 타입 선택 시 정렬 조건에서 `높은 가격순`, `낮은 가격순`을 표시하지 않는다.
|
||||||
|
- 2026-07-12 후속 변경: 추가 페이지 로딩 시 `RecyclerView` adapter를 반복 재설정하지 않아 스크롤 위치가 위로 튀지 않게 한다.
|
||||||
|
- 2026-07-12 후속 변경: 전체 탭 오디오/시리즈 adapter는 append 갱신 시 `DiffUtil` 기반으로 변경분만 반영한다.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -178,14 +184,16 @@ data class MainContentSeriesResponse(
|
|||||||
- 첫 페이지 로딩, 추가 페이지 로딩, 빈 목록, 에러 상태를 구분한다.
|
- 첫 페이지 로딩, 추가 페이지 로딩, 빈 목록, 에러 상태를 구분한다.
|
||||||
- `hasNext = true`이고 사용자가 목록 하단에 접근하면 다음 `page`를 요청한다.
|
- `hasNext = true`이고 사용자가 목록 하단에 접근하면 다음 `page`를 요청한다.
|
||||||
- 추가 페이지 응답은 기존 목록 뒤에 append한다.
|
- 추가 페이지 응답은 기존 목록 뒤에 append한다.
|
||||||
- `totalCount`는 Figma의 `전체 23` 영역처럼 전체 개수 표시가 필요한 경우 사용한다.
|
- `totalCount`는 상태 판단과 내부 모델 보존에는 사용할 수 있으나 sort-bar에 표시하지 않는다.
|
||||||
|
- sort-bar 좌측의 `전체` 라벨은 표시하지 않고, 정렬 버튼 위치는 기존 우측 위치를 유지한다.
|
||||||
- 정렬 기본값은 `ContentSort.LATEST`이며 화면에는 기존 최신순 문자열을 사용한다.
|
- 정렬 기본값은 `ContentSort.LATEST`이며 화면에는 기존 최신순 문자열을 사용한다.
|
||||||
- 정렬 옵션은 기존 `ContentSort` 값을 모두 사용한다.
|
- 정렬 옵션은 전체 탭 전용 노출 정책을 따른다.
|
||||||
- `LATEST`
|
- `LATEST`
|
||||||
- `POPULAR`
|
- `POPULAR`
|
||||||
- `OWNED`
|
|
||||||
- `PRICE_HIGH`
|
- `PRICE_HIGH`
|
||||||
- `PRICE_LOW`
|
- `PRICE_LOW`
|
||||||
|
- `OWNED`는 전체 탭 정렬 옵션에 노출하지 않는다.
|
||||||
|
- `FREE` 타입 선택 시에는 `PRICE_HIGH`, `PRICE_LOW`도 정렬 옵션에 노출하지 않는다.
|
||||||
- 정렬 UI는 기존 `ContentSort` 사용 화면과 동일한 팝업/선택 UI 패턴을 따른다.
|
- 정렬 UI는 기존 `ContentSort` 사용 화면과 동일한 팝업/선택 UI 패턴을 따른다.
|
||||||
- 기존 후보: `CreatorChannelSortPopup`, `ContentSort.toLabelResId()`
|
- 기존 후보: `CreatorChannelSortPopup`, `ContentSort.toLabelResId()`
|
||||||
- 정렬 변경 시 `page = 0`부터 현재 선택된 `type`, `dayOfWeek` 조건으로 다시 조회한다.
|
- 정렬 변경 시 `page = 0`부터 현재 선택된 `type`, `dayOfWeek` 조건으로 다시 조회한다.
|
||||||
@@ -247,6 +255,8 @@ data class MainContentSeriesResponse(
|
|||||||
- 일본어 요일 UI의 `RANDOM` 표기는 `その他`로 표시한다.
|
- 일본어 요일 UI의 `RANDOM` 표기는 `その他`로 표시한다.
|
||||||
- 영어 요일 UI의 `RANDOM` 표기는 `OTHER`로 표시한다.
|
- 영어 요일 UI의 `RANDOM` 표기는 `OTHER`로 표시한다.
|
||||||
- 시리즈 카드 성인 배지는 Figma Large/Small 태그 크기와 기존 오디오 성인 배지 표현을 따른다.
|
- 시리즈 카드 성인 배지는 Figma Large/Small 태그 크기와 기존 오디오 성인 배지 표현을 따른다.
|
||||||
|
- 추가 페이지 로딩 중에는 현재 `RecyclerView` adapter를 유지하고, 같은 콘텐츠 계열의 상태 재렌더링만으로 adapter를 다시 대입하지 않는다.
|
||||||
|
- 첫 페이지 loading/empty/error 렌더링처럼 목록을 비우는 상태에서도 lifecycle cleanup이 아닌 이상 adapter를 `null`로 분리하지 않는다.
|
||||||
|
|
||||||
### 재사용 가능한 V2 위젯 후보
|
### 재사용 가능한 V2 위젯 후보
|
||||||
- `kr.co.vividnext.sodalive.v2.widget.CapsuleTabBarView`
|
- `kr.co.vividnext.sodalive.v2.widget.CapsuleTabBarView`
|
||||||
@@ -278,6 +288,7 @@ data class MainContentSeriesResponse(
|
|||||||
- 응답 처리는 기존 `ApiResponse<T>`와 Rx 타입(`Single`, `Flowable`) 패턴을 우선 검토한다.
|
- 응답 처리는 기존 `ApiResponse<T>`와 Rx 타입(`Single`, `Flowable`) 패턴을 우선 검토한다.
|
||||||
- 공개 API 스키마는 사용자 요청에 명시된 계약을 그대로 따른다.
|
- 공개 API 스키마는 사용자 요청에 명시된 계약을 그대로 따른다.
|
||||||
- `BuildConfig` 값이나 민감 정보는 로그/Toast/크래시 메시지에 노출하지 않는다.
|
- `BuildConfig` 값이나 민감 정보는 로그/Toast/크래시 메시지에 노출하지 않는다.
|
||||||
|
- 전체 탭 pagination append는 `notifyDataSetChanged()` 전체 갱신이 아니라 `DiffUtil.calculateDiff(...)` 결과를 dispatch하는 방식으로 처리한다.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -300,3 +311,4 @@ data class MainContentSeriesResponse(
|
|||||||
## 12. Verification Log
|
## 12. Verification Log
|
||||||
- 2026-06-25: 사용자 후속 요구사항에 따라 전체 탭 오디오/시리즈 카드가 fixed Small `122dp` card width에 의존하지 않고 RecyclerView 3열 가용 item width를 사용해야 함을 문서화했다. 오디오 썸네일은 item width 기준 1:1, 시리즈 썸네일은 item width 기준 122:172 비율을 유지한다.
|
- 2026-06-25: 사용자 후속 요구사항에 따라 전체 탭 오디오/시리즈 카드가 fixed Small `122dp` card width에 의존하지 않고 RecyclerView 3열 가용 item width를 사용해야 함을 문서화했다. 오디오 썸네일은 item width 기준 1:1, 시리즈 썸네일은 item width 기준 122:172 비율을 유지한다.
|
||||||
- 2026-06-25: 후속 UI 보정 검증으로 focused source/widget tests, `mergeDebugResources`, `compileDebugKotlin`, `ktlintCheck`, `git diff --check`가 모두 PASS했다. 전체 탭 adapter의 fixed Small 호출 제거와 동적 grid width API 사용, 오디오 1:1 및 시리즈 122:172 썸네일 계약을 테스트로 고정했다.
|
- 2026-06-25: 후속 UI 보정 검증으로 focused source/widget tests, `mergeDebugResources`, `compileDebugKotlin`, `ktlintCheck`, `git diff --check`가 모두 PASS했다. 전체 탭 adapter의 fixed Small 호출 제거와 동적 grid width API 사용, 오디오 1:1 및 시리즈 122:172 썸네일 계약을 테스트로 고정했다.
|
||||||
|
- 2026-07-12: 사용자 후속 요구사항에 따라 전체 탭 다음 페이지 로딩 시 adapter 반복 재설정으로 스크롤이 상단으로 튀는 문제를 수정 대상으로 추가했다. 같은 콘텐츠 계열에서는 adapter를 유지하고, 오디오/시리즈 전체 탭 adapter는 `DiffUtil` 기반으로 변경분만 반영해야 한다.
|
||||||
|
|||||||
@@ -742,7 +742,46 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### Phase 11: 최근 소식 COMMUNITY_POST 상세 이동
|
||||||
|
|
||||||
|
- [x] **Task 11.1: 문서와 RED 테스트로 커뮤니티 상세 이동 계약 고정**
|
||||||
|
- 수정:
|
||||||
|
- `docs/20260625_메인_홈_팔로잉_탭/prd.md`
|
||||||
|
- `docs/20260625_메인_홈_팔로잉_탭/plan-task.md`
|
||||||
|
- `app/src/test/java/kr/co/vividnext/sodalive/v2/main/home/HomeMainFragmentLoginGuardSourceTest.kt`
|
||||||
|
- 작업:
|
||||||
|
- 최근 소식 `COMMUNITY_POST` item 터치 시 `CreatorChannelCommunityDetailActivity.newIntent(requireContext(), postId)`를 사용한다는 계약을 문서화한다.
|
||||||
|
- `HomeMainFragment.onFollowingNewsClick()`이 `HomeFollowingNewsUiItem.Community`만 처리하고, `postId <= 0L`은 가드보다 먼저 무시하는지 source test로 고정한다.
|
||||||
|
- legacy `CreatorCommunityAllActivity` 또는 `Constants.EXTRA_COMMUNITY_POST_ID` 직접 extra 조립을 사용하지 않는지 검증한다.
|
||||||
|
- 검증:
|
||||||
|
- Run: `./gradlew --no-daemon :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.home.HomeMainFragmentLoginGuardSourceTest"`
|
||||||
|
- Expected RED: 구현 전 `onFollowingNewsClick()` no-op 때문에 새 source test가 실패한다.
|
||||||
|
|
||||||
|
- [x] **Task 11.2: COMMUNITY_POST 상세 이동 구현**
|
||||||
|
- 수정:
|
||||||
|
- `app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/HomeMainFragment.kt`
|
||||||
|
- 작업:
|
||||||
|
- `onFollowingNewsClick()`에서 `HomeFollowingNewsUiItem.Community`가 아니면 즉시 return한다.
|
||||||
|
- `community.postId`가 0 이하이면 즉시 return한다.
|
||||||
|
- 유효한 `postId`는 `ensureMainV2NavigationAllowed` 안에서 `CreatorChannelCommunityDetailActivity.newIntent(requireContext(), postId)`로 시작한다.
|
||||||
|
- 검증:
|
||||||
|
- Run: `./gradlew --no-daemon :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.home.HomeMainFragmentLoginGuardSourceTest"`
|
||||||
|
- Expected GREEN: source test PASS.
|
||||||
|
|
||||||
|
- [x] **Task 11.3: 최근 소식 이동 회귀 검증**
|
||||||
|
- 검증:
|
||||||
|
- Run: `./gradlew --no-daemon :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.home.*Following*"`
|
||||||
|
- Expected: 팔로잉 관련 local unit/source test PASS.
|
||||||
|
- Run: `./gradlew --no-daemon :app:compileDebugKotlin`
|
||||||
|
- Expected: Kotlin compile PASS.
|
||||||
|
- Run: `git diff --check`
|
||||||
|
- Expected: whitespace error 없음.
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Verification Log
|
## Verification Log
|
||||||
|
- 2026-07-12 Phase 11 검증: 최근 소식 `COMMUNITY_POST` 상세 이동 계약을 source test로 추가하고 `HomeMainFragment.onFollowingNewsClick()`에서 `HomeFollowingNewsUiItem.Community`만 `CreatorChannelCommunityDetailActivity.newIntent(requireContext(), postId)`로 이동하도록 구현했다. RED 시도: 구현 전 targeted test `./gradlew --no-daemon :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.home.HomeMainFragmentLoginGuardSourceTest"`는 120초/300초 timeout으로 assertion 결과까지 도달하지 못했지만, 당시 production은 no-op 상태였고 새 source test는 `ensureMainV2NavigationAllowed`와 상세 이동 호출 문자열을 요구하도록 추가되어 실패 조건을 고정했다. GREEN: 동일 targeted test PASS. 회귀 검증 `./gradlew --no-daemon :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.home.*Following*"`, `./gradlew --no-daemon :app:compileDebugKotlin`, `git diff --check` PASS. `./gradlew --no-daemon :app:ktlintCheck`는 이번 변경과 무관한 기존 legacy/source 위반(`explorer/profile/creator_community` package underscore, `LiveRoomActivity.kt` formatting, `NicknameUpdateViewModel.kt` indentation 등)으로 FAIL. 실기기 수동 QA는 `adb devices` 결과 연결된 device가 없어 blocked.
|
||||||
- 2026-06-25 Phase 1-3 구현 검증: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.home.*Following*"`, `./gradlew :app:mergeDebugResources`, `./gradlew :app:compileDebugKotlin`, `./gradlew :app:ktlintCheck`, `git diff --check` 모두 PASS.
|
- 2026-06-25 Phase 1-3 구현 검증: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.home.*Following*"`, `./gradlew :app:mergeDebugResources`, `./gradlew :app:compileDebugKotlin`, `./gradlew :app:ktlintCheck`, `git diff --check` 모두 PASS.
|
||||||
- 2026-06-25 Phase 1-3 코드 리뷰 및 재검증: blocking finding 없음. `./gradlew :app:mergeDebugResources`는 최초 sandbox lock 권한 오류 후 승인 실행으로 PASS했고, 나머지 검증 명령도 PASS.
|
- 2026-06-25 Phase 1-3 코드 리뷰 및 재검증: blocking finding 없음. `./gradlew :app:mergeDebugResources`는 최초 sandbox lock 권한 오류 후 승인 실행으로 PASS했고, 나머지 검증 명령도 PASS.
|
||||||
- 2026-06-25 Phase 4 코드 리뷰 및 검증: Figma `24:5682` 기준 UI 필드 바인딩 누락을 보완했고, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.home.*Following*"`, `./gradlew :app:mergeDebugResources`, `./gradlew :app:compileDebugKotlin`, `./gradlew :app:ktlintCheck`, `git diff --check` 모두 PASS.
|
- 2026-06-25 Phase 4 코드 리뷰 및 검증: Figma `24:5682` 기준 UI 필드 바인딩 누락을 보완했고, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.home.*Following*"`, `./gradlew :app:mergeDebugResources`, `./gradlew :app:compileDebugKotlin`, `./gradlew :app:ktlintCheck`, `git diff --check` 모두 PASS.
|
||||||
|
|||||||
@@ -281,6 +281,12 @@ Figma `24:5682` 기준 상단 title bar와 tab bar는 기존 홈 화면 구조
|
|||||||
- Android DTO, mapper, UI model, adapter binding, 테스트 fixture는 새 nested payload 구조를 기준으로 갱신한다.
|
- Android DTO, mapper, UI model, adapter binding, 테스트 fixture는 새 nested payload 구조를 기준으로 갱신한다.
|
||||||
- 새 응답 변경은 최근 소식 섹션에만 적용하며, 팔로잉 크리에이터, On Air, 최근 대화, 이달의 스케줄, 로그인 유도, API endpoint, optional authorization 정책은 변경하지 않는다.
|
- 새 응답 변경은 최근 소식 섹션에만 적용하며, 팔로잉 크리에이터, On Air, 최근 대화, 이달의 스케줄, 로그인 유도, API endpoint, optional authorization 정책은 변경하지 않는다.
|
||||||
|
|
||||||
|
#### 2026-07-12 COMMUNITY_POST 상세 이동 Requirements
|
||||||
|
- 최근 소식 섹션의 `COMMUNITY_POST` item 터치 시 `communityPost.postId`를 사용해 `CreatorChannelCommunityDetailActivity`로 이동한다.
|
||||||
|
- 이동은 기존 MainV2 로그인 가드(`ensureMainV2NavigationAllowed`)를 통과한 뒤 실행한다.
|
||||||
|
- `COMMUNITY_POST`가 아닌 최근 소식 item은 이번 작업 범위에서 기존처럼 이동하지 않는다.
|
||||||
|
- legacy `CreatorCommunityAllActivity`나 `Constants.EXTRA_COMMUNITY_POST_ID` 직접 extra 조립 경로는 사용하지 않는다.
|
||||||
|
|
||||||
#### 2026-06-30 후속 수정 Requirements
|
#### 2026-06-30 후속 수정 Requirements
|
||||||
- `CREATOR_RANKING` 최근 소식 본문은 순위 숫자만 단독 표시하지 않고, Figma `24:5717` 기준으로 크리에이터 이름과 순위를 포함한 문장형 메시지를 표시한다.
|
- `CREATOR_RANKING` 최근 소식 본문은 순위 숫자만 단독 표시하지 않고, Figma `24:5717` 기준으로 크리에이터 이름과 순위를 포함한 문장형 메시지를 표시한다.
|
||||||
- ranking 문장 안의 순위 텍스트는 기존 `FeedRankTextStyler` highlight range로 강조한다.
|
- ranking 문장 안의 순위 텍스트는 기존 `FeedRankTextStyler` highlight range로 강조한다.
|
||||||
|
|||||||
@@ -243,11 +243,53 @@
|
|||||||
- 검증: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.creator.channel.community.*"`, `./gradlew :app:compileDebugKotlin`, `./gradlew :app:ktlintCheck`, `git diff --check`.
|
- 검증: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.creator.channel.community.*"`, `./gradlew :app:compileDebugKotlin`, `./gradlew :app:ktlintCheck`, `git diff --check`.
|
||||||
- 검증 기록(2026-07-09): 상세/답글 Activity에 `showSoftInput`/`hideSoftInputFromWindow` 기반 키보드 제어를 추가했다. 수정 target이 새로 설정되면 입력창 focus 후 키보드를 올리고, 댓글 작성/수정 및 답글 작성/수정/부모 댓글 수정 성공 이벤트에서 키보드를 내리도록 연결했다. UI 계약 테스트에 IME 제어 source 계약을 추가했다. `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.creator.channel.community.*"`, `./gradlew :app:compileDebugKotlin`, `./gradlew :app:ktlintCheck`, `git diff --check`가 모두 통과했다.
|
- 검증 기록(2026-07-09): 상세/답글 Activity에 `showSoftInput`/`hideSoftInputFromWindow` 기반 키보드 제어를 추가했다. 수정 target이 새로 설정되면 입력창 focus 후 키보드를 올리고, 댓글 작성/수정 및 답글 작성/수정/부모 댓글 수정 성공 이벤트에서 키보드를 내리도록 연결했다. UI 계약 테스트에 IME 제어 source 계약을 추가했다. `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.creator.channel.community.*"`, `./gradlew :app:compileDebugKotlin`, `./gradlew :app:ktlintCheck`, `git diff --check`가 모두 통과했다.
|
||||||
|
|
||||||
|
|
||||||
|
### Phase 12: 커뮤니티 상세 UI border 후속 반영
|
||||||
|
|
||||||
|
- [x] **Task 12.1: 상세 상단 게시글 배경과 댓글 경계선 수정**
|
||||||
|
- 수정: `app/src/main/res/layout/activity_creator_channel_community_detail.xml`, `app/src/main/res/layout/item_creator_channel_community_comment.xml`, `CreatorChannelCommunityCommentAdapter.kt`
|
||||||
|
- 작업: 상세 게시글 컨테이너의 `bg_feed_card` rounded 배경을 제거하고, 게시글과 댓글 헤더 사이 1dp border를 추가한다. 댓글 item 사이에는 1dp border를 표시하되 마지막 댓글 하단 border는 숨긴다.
|
||||||
|
- 검증: `./gradlew :app:mergeDebugResources`, `./gradlew :app:compileDebugKotlin`, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.creator.channel.community.CreatorChannelCommunityDetailReplyUiContractTest"`, `./gradlew :app:ktlintCheck`, `git diff --check`
|
||||||
|
- 검증 기록(2026-07-12): 상세 게시글 컨테이너의 `bg_feed_card` 배경을 제거해 목록형 rounded card 배경이 상세 상단 본문에는 적용되지 않도록 했다. 게시글과 댓글 헤더 사이에는 `gray_800` 1dp divider를 추가하고 댓글 가능 상태에서만 노출되도록 Activity binding을 연결했다. 댓글 item 하단 divider에는 id를 부여하고 adapter에서 마지막 댓글의 divider를 숨겨 댓글 사이 border로만 보이게 했다. `./gradlew :app:mergeDebugResources`, `./gradlew :app:compileDebugKotlin`, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.creator.channel.community.CreatorChannelCommunityDetailReplyUiContractTest"`, `./gradlew :app:ktlintCheck`, `git diff --check`가 모두 통과했다. `ktlintCheck`의 `.editorconfig disabled_rules` deprecation 경고는 기존 설정 경고로 남아 있다.
|
||||||
|
|
||||||
|
### Phase 13: 커뮤니티 상세 오디오/구매 후속 반영
|
||||||
|
|
||||||
|
- [x] **Task 13.1: 상세 오디오 중앙 버튼과 구매 회귀 테스트 추가**
|
||||||
|
- 수정: `app/src/test/java/kr/co/vividnext/sodalive/v2/creator/channel/community/CreatorChannelCommunityDetailViewModelTest.kt`, `CreatorChannelCommunityDetailReplyUiContractTest.kt`
|
||||||
|
- 작업: 상세 layout에 이미지 중앙 play/pause 버튼이 있고, Activity가 `PurchaseCommunityPostDialog`와 `purchaseCommunityPost`를 연결하며, ViewModel이 구매 성공 후 상세를 재조회하는 계약을 RED로 고정한다.
|
||||||
|
- 검증: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.creator.channel.community.CreatorChannelCommunityDetailViewModelTest"`, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.creator.channel.community.CreatorChannelCommunityDetailReplyUiContractTest"`
|
||||||
|
- 검증 기록(2026-07-12): 상세 layout의 중앙 오디오 버튼 id, legacy 구매 dialog 연결, ViewModel 구매 성공 후 상세 재조회 계약을 테스트에 추가했다. 최초 병렬 Gradle 실행은 120초/300초 timeout 및 병렬 resource packaging 충돌성 `NoSuchFileException`으로 완료 결과를 얻지 못했으나, 구현 후 순차 재실행에서 동일 테스트들이 PASS했다.
|
||||||
|
|
||||||
|
- [x] **Task 13.2: 상세 오디오 중앙 버튼과 구매 흐름 구현**
|
||||||
|
- 수정: `app/src/main/res/layout/activity_creator_channel_community_detail.xml`, `CreatorChannelCommunityDetailActivity.kt`, `CreatorChannelCommunityDetailViewModel.kt`
|
||||||
|
- 작업: `item_creator_community_all.xml`의 `iv_play_or_pause`처럼 상세 이미지 중앙 버튼을 추가하고, 재생 상태에 따라 play/pause drawable을 갱신한다. 상세 paywall 가격 클릭 시 기존 구매 dialog를 띄우고 구매 성공 후 상세를 다시 로드한다.
|
||||||
|
- 검증: targeted test PASS, `./gradlew :app:mergeDebugResources`, `./gradlew :app:compileDebugKotlin`
|
||||||
|
- 검증 기록(2026-07-12): `activity_creator_channel_community_detail.xml`의 이미지 컨테이너 중앙에 `iv_creator_channel_community_detail_play_or_pause`를 추가하고 `btn_audio_content_play`/`btn_audio_content_pause`를 사용하도록 연결했다. `CreatorChannelCommunityDetailActivity`는 이미지+오디오 게시물에서 중앙 버튼을 표시하고, 이미지 없는 오디오 게시물은 기존 텍스트 오디오 버튼을 유지한다. paywall 영역 클릭 시 기존 `PurchaseCommunityPostDialog`를 띄우고 확인 시 `CreatorChannelCommunityDetailViewModel.purchaseCommunityPost()`를 호출한다. ViewModel은 기존 `CreatorCommunityRepository.purchaseCommunityPost`를 호출한 뒤 성공 시 상세를 다시 로드한다. `./gradlew :app:mergeDebugResources`, `./gradlew :app:compileDebugKotlin` PASS로 확인했다.
|
||||||
|
|
||||||
|
- [x] **Task 13.3: 후속 검증**
|
||||||
|
- 실행: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.creator.channel.community.*"`, `./gradlew :app:compileDebugKotlin`, `./gradlew :app:ktlintCheck`, `git diff --check`
|
||||||
|
- 검증 기록: `Verification Log`에 누적한다.
|
||||||
|
- 검증 기록(2026-07-12): `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.creator.channel.community.CreatorChannelCommunityDetailViewModelTest"`, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.creator.channel.community.CreatorChannelCommunityDetailReplyUiContractTest"`, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.creator.channel.community.*"`, `./gradlew :app:mergeDebugResources`, `./gradlew :app:compileDebugKotlin`, `./gradlew :app:ktlintCheck`, `git diff --check`가 모두 통과했다. `ktlintCheck`의 `.editorconfig disabled_rules` deprecation 경고와 Gradle deprecated feature 경고는 기존 설정 경고로 남아 있다.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### Phase 14: 유료 미구매 상세 반응/댓글 숨김 후속
|
||||||
|
|
||||||
|
- [x] **Task 14.1: 상세 유료 미구매 게시글 reaction/comment 숨김**
|
||||||
|
- 대상 파일:
|
||||||
|
- `app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/community/detail/CreatorChannelCommunityDetailViewModel.kt`
|
||||||
|
- `app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/community/detail/CreatorChannelCommunityDetailActivity.kt`
|
||||||
|
- `app/src/main/res/layout/activity_creator_channel_community_detail.xml`
|
||||||
|
- `app/src/test/java/kr/co/vividnext/sodalive/v2/creator/channel/community/CreatorChannelCommunityDetailViewModelTest.kt`
|
||||||
|
- 작업: `price > 0 && !existOrdered && creatorId != SharedPreferenceManager.userId` 잠금 게시글에서는 상세의 reaction 현황, 댓글 목록, 댓글 헤더, 댓글 입력을 숨긴다.
|
||||||
|
- 검증 기록:
|
||||||
|
- 2026-07-12: RED로 `CreatorChannelCommunityDetailViewModelTest.유료 미구매 게시물은 작성자가 아니면 reaction 현황과 댓글을 숨긴다`를 추가했고, production 변경 전 `showReaction` 미구현 컴파일 실패를 확인했다. 이후 상세 UI 모델/상태와 Activity binding에 잠금 조건을 반영해 focused 테스트 GREEN을 확인했다.
|
||||||
|
|
||||||
## Verification Log
|
## Verification Log
|
||||||
|
- 2026-07-12: Phase 14 유료 미구매 상세 게시글 reaction/comment 숨김 변경을 완료했다. RED는 `CreatorChannelCommunityDetailViewModelTest.유료 미구매 게시물은 작성자가 아니면 reaction 현황과 댓글을 숨긴다`의 `showReaction` 미구현 컴파일 실패로 확인했고, 구현 후 focused 테스트, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.creator.channel.community.*"`, `./gradlew :app:mergeDebugResources :app:compileDebugKotlin :app:ktlintCheck`가 모두 `BUILD SUCCESSFUL`로 PASS했다. 실제 화면 직접 조작은 Activity가 내부 진입 경로와 데이터 계정에 의존해 이번 검증에서는 단위/리소스/컴파일/스타일 검증으로 대체했다.
|
||||||
|
|
||||||
- 구현 전 문서 작성 단계에서는 Gradle 검증을 실행하지 않았다.
|
- 구현 전 문서 작성 단계에서는 Gradle 검증을 실행하지 않았다.
|
||||||
|
- 2026-07-12: 커뮤니티 상세 오디오 중앙 버튼 및 유료 게시글 구매 후속 변경을 반영했다. 상세 ViewModel 구매 회귀 테스트, 상세 UI 계약 테스트, 커뮤니티 targeted 테스트, 리소스 병합, Kotlin 컴파일, ktlint, diff whitespace 검증이 모두 통과했다. 최초 병렬 Gradle 실행 중 resource packaging incremental output 충돌성 오류가 한 번 발생했으나 순차 재실행에서 재현되지 않았다.
|
||||||
- 2026-07-08: 추가 review findings 반영 범위를 확정했다. 상세/답글 변경 결과를 상위 커뮤니티 목록과 상세 댓글/답글 카운트에 반영하고, 좋아요 API의 `success=false` 응답에서 낙관적 상태를 롤백하며, 댓글/답글 입력창을 3줄까지 자동 확장되도록 고정 높이를 제거하고, 수정 내용이 없을 때 기존 `audio_content_comment_no_change` toast를 표시하도록 보강한다.
|
- 2026-07-08: 추가 review findings 반영 범위를 확정했다. 상세/답글 변경 결과를 상위 커뮤니티 목록과 상세 댓글/답글 카운트에 반영하고, 좋아요 API의 `success=false` 응답에서 낙관적 상태를 롤백하며, 댓글/답글 입력창을 3줄까지 자동 확장되도록 고정 높이를 제거하고, 수정 내용이 없을 때 기존 `audio_content_comment_no_change` toast를 표시하도록 보강한다.
|
||||||
- 2026-07-08: Phase 2.3/3.2 UI 구현 전 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.creator.channel.community.*"`를 실행해 신규 activity/layout/item/source가 없어 `compileDebugUnitTestKotlin` RED 실패를 확인했다.
|
- 2026-07-08: Phase 2.3/3.2 UI 구현 전 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.creator.channel.community.*"`를 실행해 신규 activity/layout/item/source가 없어 `compileDebugUnitTestKotlin` RED 실패를 확인했다.
|
||||||
- 2026-07-08: Phase 2.3/3.2 UI 구현 후 동일 targeted 테스트를 재실행해 48개 테스트 PASS를 확인했다. Robolectric inflate로 상세/답글 layout의 title back/content/input bar 및 item layout 계약을 확인했고, source 검사로 AppDI/manifest/ViewModel binding/popup menu/comment hidden/newIntent 계약을 확인했다.
|
- 2026-07-08: Phase 2.3/3.2 UI 구현 후 동일 targeted 테스트를 재실행해 48개 테스트 PASS를 확인했다. Robolectric inflate로 상세/답글 layout의 title back/content/input bar 및 item layout 계약을 확인했고, source 검사로 AppDI/manifest/ViewModel binding/popup menu/comment hidden/newIntent 계약을 확인했다.
|
||||||
@@ -265,3 +307,4 @@
|
|||||||
- 2026-07-09: 사용자 후속 요청 4건을 반영했다. 상세/답글 titlebar의 `커뮤니티`, `답글` 문구를 제거했고, 댓글/답글 더보기 메뉴 문구를 `수정하기`, `삭제하기`로 변경했다. v2 패키지 내부 삭제 확인은 `V2ModalDialog`로 교체했으며, 수정은 Dialog 없이 하단 입력창 수정 모드로 전환되도록 구현했다. 수정 모드에서는 send 버튼으로 수정 API를 호출하고, send 버튼 오른쪽 X 버튼으로 수정 취소가 가능하다. 검증으로 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.creator.channel.community.*"`, `./gradlew :app:compileDebugKotlin`, `./gradlew :app:ktlintCheck`, `git diff --check`가 모두 통과했다. `ktlintCheck`의 `.editorconfig disabled_rules` deprecation 경고는 기존 설정 경고로 남아 있다.
|
- 2026-07-09: 사용자 후속 요청 4건을 반영했다. 상세/답글 titlebar의 `커뮤니티`, `답글` 문구를 제거했고, 댓글/답글 더보기 메뉴 문구를 `수정하기`, `삭제하기`로 변경했다. v2 패키지 내부 삭제 확인은 `V2ModalDialog`로 교체했으며, 수정은 Dialog 없이 하단 입력창 수정 모드로 전환되도록 구현했다. 수정 모드에서는 send 버튼으로 수정 API를 호출하고, send 버튼 오른쪽 X 버튼으로 수정 취소가 가능하다. 검증으로 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.creator.channel.community.*"`, `./gradlew :app:compileDebugKotlin`, `./gradlew :app:ktlintCheck`, `git diff --check`가 모두 통과했다. `ktlintCheck`의 `.editorconfig disabled_rules` deprecation 경고는 기존 설정 경고로 남아 있다.
|
||||||
- 2026-07-09: 댓글/답글 더보기 메뉴 font 후속 요청을 반영했다. `PopupMenu`는 메뉴 item font를 직접 지정하기 어려워, 댓글/답글 더보기만 `PopupWindow` 기반 `CreatorChannelCommunityMorePopup`으로 교체하고 메뉴 TextView에 `android:fontFamily="@font/regular"`를 명시했다. 검증 명령은 Phase 10.1 기록에 누적한다.
|
- 2026-07-09: 댓글/답글 더보기 메뉴 font 후속 요청을 반영했다. `PopupMenu`는 메뉴 item font를 직접 지정하기 어려워, 댓글/답글 더보기만 `PopupWindow` 기반 `CreatorChannelCommunityMorePopup`으로 교체하고 메뉴 TextView에 `android:fontFamily="@font/regular"`를 명시했다. 검증 명령은 Phase 10.1 기록에 누적한다.
|
||||||
- 2026-07-09: 수정하기 터치 시 키보드가 바로 올라오고, 입력/수정 API success 후 키보드가 내려가야 한다는 후속 요청을 반영했다. 상세/답글 Activity에서 수정 모드 진입 시 입력창 focus와 IME show를 수행하고, 작성/수정 성공 이벤트에서 IME hide와 focus clear를 수행한다. 검증 명령은 Phase 11.1 기록에 누적한다.
|
- 2026-07-09: 수정하기 터치 시 키보드가 바로 올라오고, 입력/수정 API success 후 키보드가 내려가야 한다는 후속 요청을 반영했다. 상세/답글 Activity에서 수정 모드 진입 시 입력창 focus와 IME show를 수행하고, 작성/수정 성공 이벤트에서 IME hide와 focus clear를 수행한다. 검증 명령은 Phase 11.1 기록에 누적한다.
|
||||||
|
- 2026-07-12: 커뮤니티 상세 상단 게시글 영역의 rounded card 배경 제거와 게시글-댓글/댓글 사이 1dp border 후속 요청을 반영했다. 검증 명령은 Phase 12.1 기록에 누적한다.
|
||||||
|
|||||||
@@ -39,7 +39,7 @@
|
|||||||
|
|
||||||
## 4. Non-Goals
|
## 4. Non-Goals
|
||||||
- 게시글 작성/수정 화면 및 API는 이번 범위에서 구현하지 않는다(기존 화면 유지).
|
- 게시글 작성/수정 화면 및 API는 이번 범위에서 구현하지 않는다(기존 화면 유지).
|
||||||
- 유료 게시글 구매(paywall unlock) 흐름은 이번 범위에서 새로 정의하지 않는다. 상세에서는 `price`, `existOrdered` 기반 표시만 다룬다.
|
- 유료 게시글 구매(paywall unlock) 흐름은 최초 상세 구현 범위에서는 제외했으나, 2026-07-12 후속 요구사항으로 상세에서 기존 크리에이터 커뮤니티 게시물과 동일한 구매 흐름을 재사용한다.
|
||||||
- 팬Talk 상세 화면 자체는 이번 범위에서 구현하지 않는다(답글 화면과 UI만 동일).
|
- 팬Talk 상세 화면 자체는 이번 범위에서 구현하지 않는다(답글 화면과 UI만 동일).
|
||||||
- 서버 API schema나 endpoint를 새로 정의하지 않는다.
|
- 서버 API schema나 endpoint를 새로 정의하지 않는다.
|
||||||
- 레거시 커뮤니티/댓글 관련 파일을 직접 수정하지 않는다.
|
- 레거시 커뮤니티/댓글 관련 파일을 직접 수정하지 않는다.
|
||||||
@@ -69,6 +69,21 @@
|
|||||||
|
|
||||||
## 7. Core Features
|
## 7. Core Features
|
||||||
|
|
||||||
|
### F0. 2026-07-12 후속 변경사항
|
||||||
|
크리에이터 커뮤니티 상세의 오디오/구매 동작을 기존 레거시 목록 UI와 동일한 사용성으로 보정한다.
|
||||||
|
|
||||||
|
#### Requirements
|
||||||
|
- 상세 게시글에 `audioUrl`과 `imageUrl`이 함께 있으면 `item_creator_community_all.xml`의 `iv_play_or_pause`와 동일하게 이미지 중앙에 play/pause 버튼을 표시한다.
|
||||||
|
- 상세 오디오 버튼은 `btn_audio_content_play`, `btn_audio_content_pause` drawable을 사용한다.
|
||||||
|
- 상세 유료 미구매 게시글의 paywall 가격 영역을 터치하면 기존 `PurchaseCommunityPostDialog`를 표시한다.
|
||||||
|
- 구매 확인 시 기존 `CreatorCommunityRepository.purchaseCommunityPost(postId)` API를 호출하고, 성공 후 상세를 다시 조회해 구매 완료 상태와 본문 미디어를 표시한다.
|
||||||
|
|
||||||
|
#### Edge Cases
|
||||||
|
- 이미지가 없는 오디오 게시물은 기존 상세의 오디오 텍스트 영역 동작을 유지한다.
|
||||||
|
- 구매 API 실패 시 기존 toast/error 표시 정책을 따른다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### F1. 커뮤니티 게시물 상세 진입
|
### F1. 커뮤니티 게시물 상세 진입
|
||||||
커뮤니티 탭 게시글 item 터치 시 상세 화면으로 이동한다.
|
커뮤니티 탭 게시글 item 터치 시 상세 화면으로 이동한다.
|
||||||
|
|
||||||
@@ -196,6 +211,7 @@ Figma `290:9266` 기준으로 댓글 불가 상태를 처리한다.
|
|||||||
|
|
||||||
## 8. UX / UI Expectations
|
## 8. UX / UI Expectations
|
||||||
- 게시글 item 터치부터 상세 진입까지 자연스럽게 이어진다.
|
- 게시글 item 터치부터 상세 진입까지 자연스럽게 이어진다.
|
||||||
|
- 커뮤니티 상세 상단 게시글 영역은 목록 카드처럼 rounded 배경을 깔지 않고, 게시글과 댓글 영역 및 댓글 사이에는 Figma `290:9243` 기준 1dp border를 표시한다.
|
||||||
- 하트 상태와 좋아요 수는 터치 즉시 반응한다(낙관적 업데이트 후 실패 시 롤백).
|
- 하트 상태와 좋아요 수는 터치 즉시 반응한다(낙관적 업데이트 후 실패 시 롤백).
|
||||||
- 입력 영역은 키보드 있음/없음 상태를 기준으로 자연스럽게 대응한다.
|
- 입력 영역은 키보드 있음/없음 상태를 기준으로 자연스럽게 대응한다.
|
||||||
- 답글 화면은 팬Talk 상세와 시각적으로 동일하게 보인다.
|
- 답글 화면은 팬Talk 상세와 시각적으로 동일하게 보인다.
|
||||||
|
|||||||
303
docs/20260710_메인_랭킹_UI_반응형_크기_조정/plan-task.md
Normal file
303
docs/20260710_메인_랭킹_UI_반응형_크기_조정/plan-task.md
Normal file
@@ -0,0 +1,303 @@
|
|||||||
|
# 메인 랭킹 UI 반응형 크기 조정 Plan / Task
|
||||||
|
|
||||||
|
**Goal:** 메인 홈 크리에이터 랭킹과 메인 콘텐츠 랭킹에서 402dp 이상은 현재 크기를 유지하고, 402dp 미만은 현재 크기를 `device width / 402` 비율로 축소한다. 크리에이터 랭킹 순위 숫자 폰트는 `Pattaya`로 맞춘다.
|
||||||
|
|
||||||
|
**Architecture:** 기존 `creatorranking`/`contentranking` 위젯 구조를 유지하고, 공통 402dp scale 계산은 두 랭킹 위젯에서 재사용 가능한 최소 단위로 분리한다. 기존 rank placement, card variant, mapper, API, 클릭 이동은 변경하지 않는다.
|
||||||
|
|
||||||
|
**Tech Stack:** Kotlin, Android XML Views, ViewBinding, RecyclerView, Android resource font, JUnit4/Robolectric local unit test.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 전제와 성공 기준
|
||||||
|
- PRD: `docs/20260710_메인_랭킹_UI_반응형_크기_조정/prd.md`
|
||||||
|
- 기준 폭은 `402dp`이다.
|
||||||
|
- `device width >= 402dp`이면 현재 글자/아이콘/UI 크기와 동일해야 한다.
|
||||||
|
- `device width < 402dp`이면 현재 기준값에 `device width / 402` scale을 적용한다.
|
||||||
|
- `Font - Pattaya` 적용 대상은 크리에이터 랭킹의 순위 숫자 텍스트로 확정한다.
|
||||||
|
- 402dp 미만 축소는 device width 기준 비율 적용이므로 별도 최소 글자 크기 하한은 두지 않는다.
|
||||||
|
- 콘텐츠 랭킹 순위 숫자 폰트는 기존 `Pattaya` 표시를 유지한다.
|
||||||
|
- 콘텐츠 닉네임은 현재 설정값을 기준으로 402dp 이상 유지, 402dp 미만 비율 축소한다.
|
||||||
|
- API, DTO, mapper, ranking type, click routing, empty/error/loading 정책은 변경하지 않는다.
|
||||||
|
- 구현 완료 후 최소 다음 명령을 실행한다.
|
||||||
|
- `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.creatorranking.*"`
|
||||||
|
- `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.contentranking.*"`
|
||||||
|
- `./gradlew :app:mergeDebugResources`
|
||||||
|
- `./gradlew :app:compileDebugKotlin`
|
||||||
|
- `./gradlew :app:ktlintCheck`
|
||||||
|
- `git diff --check`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 현재 기준값
|
||||||
|
|
||||||
|
### 크리에이터 랭킹
|
||||||
|
| Rank group | 순위 글자 | 순위 변동 숫자 | 순위 변동 아이콘 | 크리에이터 닉네임 |
|
||||||
|
| --- | ---: | ---: | ---: | ---: |
|
||||||
|
| 1위 | `96sp` | `16sp` | 기본 `14dp x 14dp`, NEW `36dp x 23dp` | `32sp` |
|
||||||
|
| 2~7위 | `54sp` | `16sp` | 기본 `14dp x 14dp`, NEW `36dp x 23dp` | `22sp` |
|
||||||
|
| 8~10위 | `36sp` | `14sp` | 기본 `14dp x 14dp`, NEW `36dp x 23dp` | `14sp` |
|
||||||
|
| 11위 이상 | `40sp` | `16sp` | 기본 `14dp x 14dp`, NEW `36dp x 23dp` | `18sp` |
|
||||||
|
|
||||||
|
### 콘텐츠 랭킹
|
||||||
|
| Rank group | 순위 글자 | 순위 변동 숫자 | 순위 변동 아이콘 | 콘텐츠 타이틀 | 콘텐츠 닉네임 |
|
||||||
|
| --- | ---: | ---: | ---: | ---: | ---: |
|
||||||
|
| 1위 Large | `96sp` | `16sp` | 기본 `14dp x 14dp`, NEW `36dp x 23dp` | `22sp` | `12sp` |
|
||||||
|
| 2~7위 MediumGrid | `54sp` | `16sp` | 기본 `14dp x 14dp`, NEW `36dp x 23dp` | `22sp` | `12sp` |
|
||||||
|
| 8~10위 SmallGrid | `widthPx <= 140`이면 `36sp`, 그 외 `54sp` | `16sp` | 기본 `14dp x 14dp`, NEW `36dp x 23dp` | `widthPx <= 140`이면 `14sp`, 그 외 `22sp` | `12sp` |
|
||||||
|
| 11위 이상 Horizontal | `40sp` | `16sp` | 기본 `14dp x 14dp`, NEW `36dp x 23dp` | `18sp` | `14sp` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 파일 구조
|
||||||
|
- 수정 후보: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/ranking/RankingResponsiveScale.kt`
|
||||||
|
- 402dp 기준 scale 계산을 공통 로직으로 분리한다.
|
||||||
|
- 생성 후보: `app/src/test/java/kr/co/vividnext/sodalive/v2/widget/ranking/RankingResponsiveScaleTest.kt`
|
||||||
|
- `402dp`, `401dp`, `360dp`, fallback scale 계약을 검증한다.
|
||||||
|
- 수정 후보: `app/src/main/res/layout/view_creator_ranking_top_card.xml`
|
||||||
|
- 크리에이터 랭킹 순위 숫자 fontFamily를 `@font/pattaya_regular`로 변경한다.
|
||||||
|
- 수정 후보: `app/src/main/res/layout/view_creator_ranking_lower_row.xml`
|
||||||
|
- 크리에이터 랭킹 순위 숫자 fontFamily를 `@font/pattaya_regular`로 변경한다.
|
||||||
|
- 수정 후보: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingTextStyle.kt`
|
||||||
|
- 현재 기준 크기와 scale 적용 결과를 분리해 계산한다.
|
||||||
|
- 수정 후보: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingTopCardView.kt`
|
||||||
|
- 순위/닉네임/순위 변동 텍스트와 순위 변동 아이콘 크기에 scale을 적용한다.
|
||||||
|
- 수정 후보: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingLowerRowView.kt`
|
||||||
|
- 11위 이상 row의 순위/닉네임/순위 변동 텍스트와 순위 변동 아이콘 크기에 scale을 적용한다.
|
||||||
|
- 수정 후보: `app/src/test/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingTextStyleTest.kt`
|
||||||
|
- rank group별 baseline과 402dp scale 적용 계약을 검증한다.
|
||||||
|
- 수정 후보: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingLargeCardView.kt`
|
||||||
|
- Large 카드의 순위/타이틀/닉네임/순위 변동 UI에 scale을 적용한다.
|
||||||
|
- 수정 후보: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingGridCardView.kt`
|
||||||
|
- MediumGrid/SmallGrid 카드의 조건부 baseline에 scale을 적용한다.
|
||||||
|
- 수정 후보: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingHorizontalCardView.kt`
|
||||||
|
- Horizontal 카드의 순위/타이틀/닉네임/순위 변동 UI에 scale을 적용한다.
|
||||||
|
- 생성 후보: `app/src/test/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingResponsiveSizeTest.kt`
|
||||||
|
- 콘텐츠 랭킹 card variant별 baseline과 scale 적용 계약을 검증한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 1: 기존 구조와 기준값 재확인
|
||||||
|
|
||||||
|
- [x] **Task 1.1: 크리에이터 랭킹 현재 기준값 확인**
|
||||||
|
- 확인:
|
||||||
|
- `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingTextStyle.kt`
|
||||||
|
- `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingDeltaPresentation.kt`
|
||||||
|
- `app/src/main/res/layout/view_creator_ranking_top_card.xml`
|
||||||
|
- `app/src/main/res/layout/view_creator_ranking_lower_row.xml`
|
||||||
|
- 작업:
|
||||||
|
- rank group별 순위/닉네임/순위 변동 텍스트 baseline을 확인한다.
|
||||||
|
- caret/New 아이콘 baseline과 pill padding/margin 값을 확인한다.
|
||||||
|
- 현재 순위 숫자 fontFamily와 목표 `Pattaya` 차이를 확인한다.
|
||||||
|
- 검증:
|
||||||
|
- Run: `rg -n "rankTextSizeSp|nameTextSizeSp|deltaTextSizeSp|iconWidthDp|iconHeightDp|fontFamily" app/src/main/java/kr/co/vividnext/sodalive/v2/widget/creatorranking app/src/main/res/layout/view_creator_ranking_top_card.xml app/src/main/res/layout/view_creator_ranking_lower_row.xml`
|
||||||
|
- Expected: PRD baseline 값과 변경 대상 fontFamily가 확인된다.
|
||||||
|
- 결과:
|
||||||
|
- 2026-07-10: `CreatorRankingTextStyle.kt`, `CreatorRankingDeltaPresentation.kt`, creator ranking layout 2개에서 PRD baseline과 현재 순위 숫자 fontFamily `@font/phosphate_solid`를 확인했다. `@font/pattaya_regular` 리소스가 존재하므로 변경 대상은 creator ranking 순위 숫자 TextView 2곳으로 확정했다.
|
||||||
|
|
||||||
|
- [x] **Task 1.2: 콘텐츠 랭킹 현재 기준값 확인**
|
||||||
|
- 확인:
|
||||||
|
- `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingLargeCardView.kt`
|
||||||
|
- `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingGridCardView.kt`
|
||||||
|
- `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingHorizontalCardView.kt`
|
||||||
|
- `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingDeltaPresentation.kt`
|
||||||
|
- `app/src/main/res/layout/view_content_ranking_large_card.xml`
|
||||||
|
- `app/src/main/res/layout/view_content_ranking_grid_card_content.xml`
|
||||||
|
- `app/src/main/res/layout/view_content_ranking_horizontal_card.xml`
|
||||||
|
- 작업:
|
||||||
|
- card variant별 순위/타이틀/닉네임/순위 변동 baseline을 확인한다.
|
||||||
|
- `SmallGrid`의 `widthPx <= 140` 조건부 baseline을 확인한다.
|
||||||
|
- 콘텐츠 랭킹 순위 숫자 fontFamily가 기존 `Pattaya` 계열인지 확인한다.
|
||||||
|
- 검증:
|
||||||
|
- Run: `rg -n "textSize|widthPx <= SMALL_THRESHOLD_PX|SMALL_THRESHOLD_PX|iconWidthDp|iconHeightDp|fontFamily" app/src/main/java/kr/co/vividnext/sodalive/v2/widget/contentranking app/src/main/res/layout/view_content_ranking_large_card.xml app/src/main/res/layout/view_content_ranking_grid_card_content.xml app/src/main/res/layout/view_content_ranking_horizontal_card.xml`
|
||||||
|
- Expected: PRD baseline 값과 조건부 `SmallGrid` 정책이 확인된다.
|
||||||
|
- 결과:
|
||||||
|
- 2026-07-10: `ContentRankingLargeCardView.kt`, `ContentRankingGridCardView.kt`, `ContentRankingHorizontalCardView.kt`, `ContentRankingDeltaPresentation.kt`, content ranking layout 3개에서 PRD baseline과 `SmallGrid`의 `widthPx <= 140` 분기를 확인했다. 콘텐츠 랭킹 순위 숫자 fontFamily는 이미 `@font/pattaya_regular`이다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 2: 402dp 반응형 scale 계약 추가
|
||||||
|
|
||||||
|
- [x] **Task 2.1: 공통 scale 계산 테스트 작성**
|
||||||
|
- 생성:
|
||||||
|
- `app/src/test/java/kr/co/vividnext/sodalive/v2/widget/ranking/RankingResponsiveScaleTest.kt`
|
||||||
|
- 테스트:
|
||||||
|
- `screenWidthDp=402`이면 `1.0`
|
||||||
|
- `screenWidthDp=430`이면 `1.0`
|
||||||
|
- `screenWidthDp=360`이면 `360f / 402f`
|
||||||
|
- `screenWidthDp<=0`이면 `1.0`
|
||||||
|
- 검증:
|
||||||
|
- Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.ranking.RankingResponsiveScaleTest"`
|
||||||
|
- Expected: 구현 전 RED 실패.
|
||||||
|
- 결과:
|
||||||
|
- 2026-07-10: 구현 전 `RankingResponsiveScale` 미존재로 `Unresolved reference` RED 실패를 확인했다.
|
||||||
|
|
||||||
|
- [x] **Task 2.2: 공통 scale 계산 로직 구현**
|
||||||
|
- 생성:
|
||||||
|
- `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/ranking/RankingResponsiveScale.kt`
|
||||||
|
- 구현:
|
||||||
|
- 기준 폭 상수 `BASE_WIDTH_DP = 402`
|
||||||
|
- `screenWidthDp >= BASE_WIDTH_DP`이면 `1f`
|
||||||
|
- `screenWidthDp in 1 until BASE_WIDTH_DP`이면 `screenWidthDp / BASE_WIDTH_DP.toFloat()`
|
||||||
|
- fallback은 `1f`
|
||||||
|
- 검증:
|
||||||
|
- Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.ranking.RankingResponsiveScaleTest"`
|
||||||
|
- Expected: PASS.
|
||||||
|
- 결과:
|
||||||
|
- 2026-07-10: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.ranking.RankingResponsiveScaleTest"` PASS.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 3: 크리에이터 랭킹 적용
|
||||||
|
|
||||||
|
- [x] **Task 3.1: 크리에이터 랭킹 텍스트 스타일 테스트 작성**
|
||||||
|
- 수정 또는 생성:
|
||||||
|
- `app/src/test/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingTextStyleTest.kt`
|
||||||
|
- 테스트:
|
||||||
|
- 402dp 이상에서 기존 baseline 크기 유지
|
||||||
|
- 360dp에서 baseline에 `360 / 402` scale 적용
|
||||||
|
- rank group별 1위, 2~7위, 8~10위, 11위 이상 baseline 유지
|
||||||
|
- 검증:
|
||||||
|
- Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.creatorranking.CreatorRankingTextStyleTest"`
|
||||||
|
- Expected: scale 적용 전 RED 실패.
|
||||||
|
- 결과:
|
||||||
|
- 2026-07-10: 구현 전 `CreatorRankingTextStyle.scaledBy` 미존재로 RED 실패를 확인했다.
|
||||||
|
|
||||||
|
- [x] **Task 3.2: 크리에이터 랭킹 폰트와 텍스트 scale 적용**
|
||||||
|
- 수정:
|
||||||
|
- `app/src/main/res/layout/view_creator_ranking_top_card.xml`
|
||||||
|
- `app/src/main/res/layout/view_creator_ranking_lower_row.xml`
|
||||||
|
- `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingTextStyle.kt`
|
||||||
|
- `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingTopCardView.kt`
|
||||||
|
- `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingLowerRowView.kt`
|
||||||
|
- 구현:
|
||||||
|
- 순위 숫자 fontFamily를 `@font/pattaya_regular`로 변경한다.
|
||||||
|
- `resources.configuration.screenWidthDp`에서 scale을 계산한다.
|
||||||
|
- 순위/닉네임/순위 변동 숫자 `setTextSize()`에 scale 적용 값을 전달한다.
|
||||||
|
- 검증:
|
||||||
|
- Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.creatorranking.*"`
|
||||||
|
- Expected: PASS.
|
||||||
|
- 결과:
|
||||||
|
- 2026-07-10: creator ranking 순위 숫자 fontFamily를 `@font/pattaya_regular`로 변경하고, 순위/닉네임/순위 변동 텍스트에 402dp scale을 적용했다. `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.creatorranking.*"` PASS.
|
||||||
|
|
||||||
|
- [x] **Task 3.3: 크리에이터 랭킹 순위 변동 UI scale 적용**
|
||||||
|
- 수정:
|
||||||
|
- `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingTopCardView.kt`
|
||||||
|
- `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingLowerRowView.kt`
|
||||||
|
- 구현:
|
||||||
|
- caret 아이콘 `14dp x 14dp`에 scale 적용
|
||||||
|
- NEW 아이콘 `36dp x 23dp`에 scale 적용
|
||||||
|
- pill padding과 icon margin이 시각 크기에 영향을 주는 경우 scale 적용
|
||||||
|
- `showRankChange=false` 숨김 정책은 유지
|
||||||
|
- 검증:
|
||||||
|
- Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.creatorranking.*"`
|
||||||
|
- Expected: PASS.
|
||||||
|
- 결과:
|
||||||
|
- 2026-07-10: creator ranking caret/New 아이콘, icon margin, pill horizontal padding에 402dp scale을 적용했다. creator ranking targeted test PASS.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 4: 콘텐츠 랭킹 적용
|
||||||
|
|
||||||
|
- [x] **Task 4.1: 콘텐츠 랭킹 반응형 크기 테스트 작성**
|
||||||
|
- 생성:
|
||||||
|
- `app/src/test/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingResponsiveSizeTest.kt`
|
||||||
|
- 테스트:
|
||||||
|
- Large baseline은 402dp 이상에서 순위 `96sp`, 타이틀 `22sp`, 닉네임 `12sp`
|
||||||
|
- MediumGrid baseline은 402dp 이상에서 순위 `54sp`, 타이틀 `22sp`, 닉네임 `12sp`
|
||||||
|
- SmallGrid `widthPx <= 140` baseline은 순위 `36sp`, 타이틀 `14sp`, 닉네임 `12sp`
|
||||||
|
- Horizontal baseline은 순위 `40sp`, 타이틀 `18sp`, 닉네임 `14sp`
|
||||||
|
- 360dp에서는 각 baseline에 `360 / 402` scale 적용
|
||||||
|
- 검증:
|
||||||
|
- Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.contentranking.ContentRankingResponsiveSizeTest"`
|
||||||
|
- Expected: scale 적용 전 RED 실패.
|
||||||
|
- 결과:
|
||||||
|
- 2026-07-10: 구현 전 `ContentRankingTextStyle` 미존재로 RED 실패를 확인했다.
|
||||||
|
- 2026-07-10: 후속 수정으로 `SmallGrid`가 `setCardSize()` 전 `bind()`만 호출되어도 402dp 미만 text size를 축소하는 회귀 테스트를 `ContentRankingCardViewTest`에 추가했다.
|
||||||
|
|
||||||
|
- [x] **Task 4.2: 콘텐츠 랭킹 텍스트 scale 적용**
|
||||||
|
- 수정:
|
||||||
|
- `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingLargeCardView.kt`
|
||||||
|
- `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingGridCardView.kt`
|
||||||
|
- `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingHorizontalCardView.kt`
|
||||||
|
- 구현:
|
||||||
|
- `resources.configuration.screenWidthDp` 기준 scale을 적용한다.
|
||||||
|
- 순위/타이틀/닉네임/순위 변동 숫자에 scale 적용 값을 전달한다.
|
||||||
|
- `SmallGrid`는 기존 `widthPx <= 140` 분기 결과를 baseline으로 삼고 scale만 적용한다.
|
||||||
|
- 검증:
|
||||||
|
- Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.contentranking.*"`
|
||||||
|
- Expected: PASS.
|
||||||
|
- 결과:
|
||||||
|
- 2026-07-10: content ranking Large/MediumGrid/SmallGrid/Horizontal의 순위/타이틀/닉네임/순위 변동 숫자에 402dp scale을 적용했고, `SmallGrid`의 `widthPx <= 140` 분기는 유지했다. `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.contentranking.*"` PASS.
|
||||||
|
- 2026-07-10: 후속 수정으로 `ContentRankingGridCardView`가 adapter의 `setCardSize()` 선호출에만 의존하지 않도록 `bind()`에서 fallback text style을 적용하게 보완했다.
|
||||||
|
|
||||||
|
- [x] **Task 4.3: 콘텐츠 랭킹 순위 변동 UI scale 적용**
|
||||||
|
- 수정:
|
||||||
|
- `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingLargeCardView.kt`
|
||||||
|
- `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingGridCardView.kt`
|
||||||
|
- `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingHorizontalCardView.kt`
|
||||||
|
- 구현:
|
||||||
|
- caret 아이콘 `14dp x 14dp`에 scale 적용
|
||||||
|
- NEW 아이콘 `36dp x 23dp`에 scale 적용
|
||||||
|
- pill padding과 icon margin이 시각 크기에 영향을 주는 경우 scale 적용
|
||||||
|
- `showRankChange=false` 숨김 정책은 유지
|
||||||
|
- 검증:
|
||||||
|
- Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.contentranking.*"`
|
||||||
|
- Expected: PASS.
|
||||||
|
- 결과:
|
||||||
|
- 2026-07-10: content ranking caret/New 아이콘, icon margin, pill horizontal padding에 402dp scale을 적용했다. content ranking targeted test PASS.
|
||||||
|
- 2026-07-10: 후속 수정으로 delta text size도 각 카드의 `ContentRankingTextStyle.deltaTextSizeSp`를 사용하도록 정리해 텍스트 scale 기준을 단일화했다.
|
||||||
|
|
||||||
|
- [x] **Task 4.4: 콘텐츠 랭킹 SmallGrid 8~10위 순위 숫자 기준값 보정**
|
||||||
|
- 수정:
|
||||||
|
- `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingTextStyle.kt`
|
||||||
|
- `app/src/test/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingResponsiveSizeTest.kt`
|
||||||
|
- `app/src/test/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingCardViewTest.kt`
|
||||||
|
- 구현:
|
||||||
|
- `ContentRankingGridCardView`의 8~10위 SmallGrid가 사용하는 순위 숫자 baseline을 `36sp`로 맞춘다.
|
||||||
|
- 콘텐츠 타이틀 `14sp`, 콘텐츠 닉네임 `12sp`, 402dp 미만 비율 축소 정책은 유지한다.
|
||||||
|
- 검증:
|
||||||
|
- Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.contentranking.*"`
|
||||||
|
- Expected: PASS.
|
||||||
|
- 결과:
|
||||||
|
- 2026-07-10: `ContentRankingTextStyle.smallGrid()`의 순위 숫자 baseline을 `40sp`에서 `36sp`로 보정했다. 콘텐츠 타이틀 `14sp`, 콘텐츠 닉네임 `12sp`, 402dp 미만 비율 축소 정책은 유지했다. `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.contentranking.*"` PASS.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 5: 통합 검증
|
||||||
|
|
||||||
|
- [x] **Task 5.1: 리소스와 Kotlin 컴파일 검증**
|
||||||
|
- 검증:
|
||||||
|
- Run: `./gradlew :app:mergeDebugResources`
|
||||||
|
- Expected: font resource 및 XML 변경이 정상 merge된다.
|
||||||
|
- Run: `./gradlew :app:compileDebugKotlin`
|
||||||
|
- Expected: Kotlin 컴파일 성공.
|
||||||
|
- 결과:
|
||||||
|
- 2026-07-10: `./gradlew :app:mergeDebugResources` PASS. `./gradlew :app:compileDebugKotlin` PASS.
|
||||||
|
|
||||||
|
- [x] **Task 5.2: 린트와 diff 검증**
|
||||||
|
- 검증:
|
||||||
|
- Run: `./gradlew :app:ktlintCheck`
|
||||||
|
- Expected: Kotlin style 위반 없음.
|
||||||
|
- Run: `git diff --check`
|
||||||
|
- Expected: trailing whitespace 또는 patch 오류 없음.
|
||||||
|
- 결과:
|
||||||
|
- 2026-07-10: 최초 `ktlintCheck`에서 신규 테스트 공백 2건과 미사용 import 1건을 수정했다. 재실행 결과 `./gradlew :app:ktlintCheck` PASS, `git diff --check` PASS.
|
||||||
|
|
||||||
|
- [x] **Task 5.3: 수동 화면 확인**
|
||||||
|
- 확인:
|
||||||
|
- 402dp 이상 기기 또는 emulator에서 기존 크기 유지
|
||||||
|
- 402dp 미만 기기 또는 emulator에서 비율 축소
|
||||||
|
- 크리에이터 랭킹 순위 숫자 폰트가 `Pattaya`
|
||||||
|
- 콘텐츠 닉네임이 402dp 이상에서 현재 크기 유지, 402dp 미만에서 축소
|
||||||
|
- 순위 변동 caret/New 아이콘과 pill이 텍스트와 함께 축소
|
||||||
|
- 검증 기록:
|
||||||
|
- 구현 후 실행 기기, 화면 폭, 확인 결과를 이 항목 아래에 누적한다.
|
||||||
|
- 2026-07-10: 실제 emulator 실행은 수행하지 못했다. 대신 402dp 이상/미만/폭 미확인 fallback 계약은 `RankingResponsiveScaleTest`, creator/content baseline 및 360dp scale 계약은 `CreatorRankingTextStyleTest`, `ContentRankingResponsiveSizeTest`로 검증했고, resource merge로 creator Pattaya font XML 적용을 확인했다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verification Log
|
||||||
|
- 2026-07-10: 콘텐츠 랭킹 SmallGrid 8~10위 순위 숫자 기준값을 요구사항에 맞춰 `36sp`로 보정했다. `ContentRankingResponsiveSizeTest`, `ContentRankingCardViewTest` 기대값을 함께 갱신했고, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.contentranking.*"`, `./gradlew :app:ktlintCheck`, `git diff --check` PASS.
|
||||||
|
- 2026-07-10: 콘텐츠 랭킹 글자 크기 적용 누락 의심 후속 확인을 진행했다. 실제 `TextView.textSize` 검증 테스트를 `ContentRankingCardViewTest`에 추가했고, Large/MediumGrid/SmallGrid/Horizontal 카드에서 360dp 기준 축소가 적용됨을 확인했다. 콘텐츠 랭킹 view의 `setCardSize()`/`bind()` 순서와 무관하게 텍스트 스타일이 유지되도록 적용 위치를 보강했다. `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.contentranking.*"`, `./gradlew :app:ktlintCheck`, `git diff --check` PASS.
|
||||||
|
- 2026-07-10: 사용자 요청에 따라 문서만 생성했다. Production code, test code, resource file은 수정하지 않았고 Gradle 검증은 실행하지 않았다.
|
||||||
|
- 2026-07-10: 랭킹 402dp 반응형 scale 구현 후 targeted creator/content/ranking 테스트, resource merge, Kotlin compile, ktlintCheck, git diff --check를 실행해 PASS를 확인했다. 실제 emulator 수동 확인은 수행하지 못해 테스트와 resource merge 기반으로 대체 기록했다.
|
||||||
163
docs/20260710_메인_랭킹_UI_반응형_크기_조정/prd.md
Normal file
163
docs/20260710_메인_랭킹_UI_반응형_크기_조정/prd.md
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
# PRD: 메인 랭킹 UI 반응형 크기 조정
|
||||||
|
|
||||||
|
## 1. Overview
|
||||||
|
메인 홈 `랭킹` 탭의 크리에이터 랭킹과 메인 콘텐츠 `랭킹` 탭의 콘텐츠 랭킹에서 순위/순위 변동/타이틀/닉네임 크기를 402dp 기준으로 반응형 조정한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Problem
|
||||||
|
- 현재 랭킹 UI는 카드 구간별 고정 `sp`/`dp` 값 중심으로 구성되어 있어 402dp 미만 폭의 기기에서 동일한 시각 밀도를 유지하기 어렵다.
|
||||||
|
- 크리에이터 랭킹 순위 숫자 폰트는 요구사항상 `Pattaya`로 맞춰야 한다.
|
||||||
|
- 기존 글자 크기 자체는 402dp 이상 기기에서 유지해야 하므로, 무조건적인 크기 변경이 아니라 화면 폭 기준 축소 정책이 필요하다.
|
||||||
|
- 크리에이터 랭킹과 콘텐츠 랭킹이 유사한 랭킹 구조를 사용하므로, 402dp 기준 동작을 두 위젯에 일관되게 적용해야 한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Goals
|
||||||
|
- 크리에이터 랭킹 순위 숫자 폰트를 `Pattaya`로 표시한다.
|
||||||
|
- 크리에이터 랭킹의 현재 순위 글자 크기, 순위 변동 UI 크기, 닉네임 글자 크기는 `device width >= 402dp`에서 그대로 유지한다.
|
||||||
|
- 크리에이터 랭킹의 위 항목은 `device width < 402dp`에서 `device width / 402` 비율로 축소한다.
|
||||||
|
- 콘텐츠 랭킹도 크리에이터 랭킹과 동일하게 `402dp` 기준 반응형 축소 정책을 적용한다.
|
||||||
|
- 콘텐츠 랭킹의 콘텐츠 닉네임은 현재 설정 크기를 `device width >= 402dp`에서 그대로 유지하고, `device width < 402dp`에서 비율대로 축소한다.
|
||||||
|
- 구현 전 현재 설정값과 변경 대상 파일을 계획 문서에서 검증 가능하게 정리한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Non-Goals
|
||||||
|
- 이번 요청에서는 코드, 리소스, 테스트 파일을 수정하지 않는다.
|
||||||
|
- API 스키마, 랭킹 정렬, 랭킹 데이터 매핑, 클릭 이동, 차단/접근 불가 정책은 변경하지 않는다.
|
||||||
|
- 랭킹 카드의 순위 구간 정책은 변경하지 않는다.
|
||||||
|
- 크리에이터 랭킹: 1위, 2~7위, 8~10위, 11위 이상
|
||||||
|
- 콘텐츠 랭킹: 1위 Large, 2~7위 MediumGrid, 8~10위 SmallGrid, 11위 이상 Horizontal
|
||||||
|
- 레거시 오디오 콘텐츠 랭킹 화면과 기존 `AudioContentRankingAllActivity` 계열은 수정하지 않는다.
|
||||||
|
- 새 화면, 새 API, 새 탭, 추가 필터, 디자인 리팩터링은 포함하지 않는다.
|
||||||
|
- 402dp 이상 기기의 현재 글자 크기를 키우거나 줄이지 않는다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Target Users
|
||||||
|
- 402dp 미만 폭의 Android 기기에서 메인 홈/콘텐츠 랭킹을 확인하는 앱 사용자.
|
||||||
|
- 랭킹 카드에서 순위, 순위 변동, 콘텐츠/크리에이터 이름을 읽는 앱 사용자.
|
||||||
|
- `kr.co.vividnext.sodalive.v2.widget.creatorranking` 및 `kr.co.vividnext.sodalive.v2.widget.contentranking` 위젯을 유지보수하는 Android 개발자.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. User Stories
|
||||||
|
- 사용자는 좁은 기기에서도 랭킹 순위와 이름이 과도하게 커 보이지 않길 원한다.
|
||||||
|
- 사용자는 402dp 이상 기기에서는 현재 랭킹 UI와 동일한 크기를 기대한다.
|
||||||
|
- 사용자는 크리에이터 랭킹과 콘텐츠 랭킹에서 동일한 기준으로 축소되는 UI를 기대한다.
|
||||||
|
- 개발자는 랭킹별 현재 기준값과 축소 기준을 문서로 확인한 뒤 최소 변경으로 구현하고 싶다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Core Features
|
||||||
|
|
||||||
|
### Responsive Ranking Size Policy
|
||||||
|
402dp를 기준 폭으로 사용하고, 기준 폭 미만에서만 현재 설정 크기를 비율대로 축소한다.
|
||||||
|
|
||||||
|
#### Requirements
|
||||||
|
- 기준 폭은 `402dp`이다.
|
||||||
|
- `device width >= 402dp`이면 scale은 `1.0`이다.
|
||||||
|
- `device width < 402dp`이면 scale은 `device width / 402`이다.
|
||||||
|
- 축소 대상은 다음 항목이다.
|
||||||
|
- 순위 숫자 글자 크기
|
||||||
|
- 순위 변동 숫자 글자 크기
|
||||||
|
- 순위 변동 caret/New 아이콘 크기
|
||||||
|
- 순위 변동 pill의 현재 padding/margin 중 시각 크기에 직접 영향을 주는 값
|
||||||
|
- 크리에이터 랭킹 닉네임 글자 크기
|
||||||
|
- 콘텐츠 랭킹 타이틀 글자 크기
|
||||||
|
- 콘텐츠 랭킹 닉네임 글자 크기
|
||||||
|
- 스케일 계산은 px 기반 parent width가 아니라 기기 폭 dp 기준으로 정의한다.
|
||||||
|
- 402dp 이상에서 현재 값과 동일해야 하므로 `fontScale`, 접근성 설정, display density에 따른 Android 기본 변환을 임의로 우회하지 않는다.
|
||||||
|
|
||||||
|
#### Edge Cases
|
||||||
|
- `screenWidthDp`를 확인할 수 없는 환경에서는 scale `1.0`을 사용해 기존 UI를 유지한다.
|
||||||
|
- scale 적용 후 너무 작은 값 방지를 위한 최소값은 별도 요구가 없으므로 임의로 추가하지 않는다.
|
||||||
|
- 기존 콘텐츠 랭킹 `SmallGrid`의 조건부 크기 정책은 현재 동작을 기준값으로 유지하고, 선택된 기준값에 scale을 곱한다.
|
||||||
|
|
||||||
|
### Creator Ranking UI
|
||||||
|
메인 홈 `랭킹` 탭의 크리에이터 랭킹 위젯에 폰트와 반응형 크기 정책을 적용한다.
|
||||||
|
|
||||||
|
#### Current Baseline
|
||||||
|
| Rank group | 순위 글자 | 순위 변동 숫자 | 순위 변동 아이콘 | 크리에이터 닉네임 |
|
||||||
|
| --- | ---: | ---: | ---: | ---: |
|
||||||
|
| 1위 | `96sp` | `16sp` | 기본 `14dp x 14dp`, NEW `36dp x 23dp` | `32sp` |
|
||||||
|
| 2~7위 | `54sp` | `16sp` | 기본 `14dp x 14dp`, NEW `36dp x 23dp` | `22sp` |
|
||||||
|
| 8~10위 | `36sp` | `14sp` | 기본 `14dp x 14dp`, NEW `36dp x 23dp` | `14sp` |
|
||||||
|
| 11위 이상 | `40sp` | `16sp` | 기본 `14dp x 14dp`, NEW `36dp x 23dp` | `18sp` |
|
||||||
|
|
||||||
|
#### Requirements
|
||||||
|
- 순위 숫자 폰트는 `Pattaya`를 사용한다.
|
||||||
|
- 위 baseline 값은 `device width >= 402dp`에서 동일해야 한다.
|
||||||
|
- `device width < 402dp`에서는 baseline 값에 scale을 곱해 표시한다.
|
||||||
|
- 기존 `CreatorRankingPlacement`의 rank group 분류는 유지한다.
|
||||||
|
- 기존 `RankingChangeType.Increase`, `Decrease`, `Stay`, `New` 표시 의미는 유지한다.
|
||||||
|
|
||||||
|
#### Edge Cases
|
||||||
|
- `showRankChange=false` item은 기존처럼 순위 변동 UI를 숨긴다.
|
||||||
|
- `RankingChangeType.New`는 숫자 텍스트 없이 NEW 아이콘만 표시하는 기존 의미를 유지한다.
|
||||||
|
- 긴 닉네임의 `ellipsize`, `maxLines` 정책은 변경하지 않는다.
|
||||||
|
|
||||||
|
### Content Ranking UI
|
||||||
|
메인 콘텐츠 `랭킹` 탭의 콘텐츠 랭킹 위젯에 크리에이터 랭킹과 동일한 반응형 크기 정책을 적용한다.
|
||||||
|
|
||||||
|
#### Current Baseline
|
||||||
|
| Rank group | 순위 글자 | 순위 변동 숫자 | 순위 변동 아이콘 | 콘텐츠 타이틀 | 콘텐츠 닉네임 |
|
||||||
|
| --- | ---: | ---: | ---: | ---: | ---: |
|
||||||
|
| 1위 Large | `96sp` | `16sp` | 기본 `14dp x 14dp`, NEW `36dp x 23dp` | `22sp` | `12sp` |
|
||||||
|
| 2~7위 MediumGrid | `54sp` | `16sp` | 기본 `14dp x 14dp`, NEW `36dp x 23dp` | `22sp` | `12sp` |
|
||||||
|
| 8~10위 SmallGrid | 조건부 | `16sp` | 기본 `14dp x 14dp`, NEW `36dp x 23dp` | 조건부 | `12sp` |
|
||||||
|
| 11위 이상 Horizontal | `40sp` | `16sp` | 기본 `14dp x 14dp`, NEW `36dp x 23dp` | `18sp` | `14sp` |
|
||||||
|
|
||||||
|
#### SmallGrid Baseline Detail
|
||||||
|
- 현재 `SmallGrid`는 카드 폭 기준으로 분기한다.
|
||||||
|
- `widthPx <= 140`이면 순위 `36sp`, 타이틀 `14sp`를 사용한다.
|
||||||
|
- `widthPx > 140`이면 순위 `54sp`, 타이틀 `22sp`를 사용한다.
|
||||||
|
- 반응형 구현 시 이 분기 자체를 바꾸지 않고, 분기 결과로 선택된 baseline 값에 402dp scale을 적용한다.
|
||||||
|
|
||||||
|
#### Requirements
|
||||||
|
- 콘텐츠 랭킹도 `device width >= 402dp`이면 현재 baseline 값을 유지한다.
|
||||||
|
- `device width < 402dp`이면 크리에이터 랭킹과 동일한 scale을 적용한다.
|
||||||
|
- 콘텐츠 닉네임은 현재 설정 크기를 기준으로 유지/축소한다.
|
||||||
|
- 콘텐츠 랭킹 순위 숫자 폰트는 기존 `Pattaya` 계열 표시를 유지한다.
|
||||||
|
- 기존 `ContentRankingPlacement`, card variant, layout 계산 정책은 유지한다.
|
||||||
|
|
||||||
|
#### Edge Cases
|
||||||
|
- 접근 불가 콘텐츠의 대체 텍스트 표시 정책은 변경하지 않는다.
|
||||||
|
- `showRankChange=false` item은 기존처럼 순위 변동 UI를 숨긴다.
|
||||||
|
- 긴 콘텐츠 타이틀/닉네임의 `ellipsize`, `maxLines` 정책은 변경하지 않는다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. UX / UI Expectations
|
||||||
|
- 402dp 이상 기기에서는 사용자가 기존과 동일한 랭킹 UI 크기를 보아야 한다.
|
||||||
|
- 402dp 미만 기기에서는 랭킹 숫자, 순위 변동 UI, 타이틀/닉네임이 동일 비율로 줄어 화면 폭에 맞아야 한다.
|
||||||
|
- 크리에이터 랭킹과 콘텐츠 랭킹의 축소 체감은 일관되어야 한다.
|
||||||
|
- 순위 변동 pill, caret, NEW 아이콘은 텍스트와 따로 커지거나 작아 보여서는 안 된다.
|
||||||
|
- 텍스트 크기 축소가 기존 정렬, 말줄임, 카드 variant 구분을 깨지 않아야 한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Technical Constraints
|
||||||
|
- Android XML Views, ViewBinding, RecyclerView 기반 기존 구조를 유지한다.
|
||||||
|
- 신규 구현이 필요할 경우 기존 `v2.widget.creatorranking`, `v2.widget.contentranking`, 공통 `v2.widget.ranking` 하위 구조를 우선 검토한다.
|
||||||
|
- 신규 `Activity`, `Fragment`, `ViewModel`은 만들지 않는다.
|
||||||
|
- 레거시 파일은 직접 수정하지 않는다.
|
||||||
|
- 구현 전 `docs/20260710_메인_랭킹_UI_반응형_크기_조정/plan-task.md`의 checklist를 먼저 갱신한 뒤 진행한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Metrics
|
||||||
|
- `device width >= 402dp`에서 크리에이터 랭킹 baseline 크기가 유지된다.
|
||||||
|
- `device width < 402dp`에서 크리에이터 랭킹 baseline 크기에 `device width / 402` scale이 적용된다.
|
||||||
|
- `device width >= 402dp`에서 콘텐츠 랭킹 baseline 크기가 유지된다.
|
||||||
|
- `device width < 402dp`에서 콘텐츠 랭킹 baseline 크기에 `device width / 402` scale이 적용된다.
|
||||||
|
- 크리에이터 랭킹 순위 숫자 폰트가 `Pattaya`로 표시된다.
|
||||||
|
- 콘텐츠 닉네임은 402dp 이상에서 현재 크기, 402dp 미만에서 비율 축소 크기를 사용한다.
|
||||||
|
- 기존 API/mapper/click routing 관련 테스트나 컴파일 결과가 회귀하지 않는다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Decisions
|
||||||
|
- `Font - Pattaya` 적용 대상은 크리에이터 랭킹의 순위 숫자 텍스트로 확정한다.
|
||||||
|
- 402dp 미만 축소는 device width 기준 비율 적용이므로 별도 최소 글자 크기 하한은 두지 않는다.
|
||||||
@@ -5,6 +5,9 @@ pluginManagement {
|
|||||||
gradlePluginPortal()
|
gradlePluginPortal()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
plugins {
|
||||||
|
id 'org.gradle.toolchains.foojay-resolver-convention' version '0.10.0'
|
||||||
|
}
|
||||||
dependencyResolutionManagement {
|
dependencyResolutionManagement {
|
||||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||||
repositories {
|
repositories {
|
||||||
|
|||||||
Reference in New Issue
Block a user