Compare commits

..

10 Commits

24 changed files with 441 additions and 93 deletions

View File

@@ -86,7 +86,7 @@ android {
applicationId "kr.co.vividnext.sodalive" applicationId "kr.co.vividnext.sodalive"
minSdk 23 minSdk 23
targetSdk 35 targetSdk 35
versionCode 239 versionCode 242
versionName "1.55.0" versionName "1.55.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
} }

View File

@@ -655,14 +655,11 @@ class ChatMessageAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
// endregion // endregion
/** 쿼터 안내 메시지 뷰홀더: 광고 보기 + 캔 구매 버튼 */
class QuotaNoticeViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { class QuotaNoticeViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
private val btnRewardedAd: View = itemView.findViewById(R.id.ll_rewarded_ad)
private val btnPurchase10Can: View = itemView.findViewById(R.id.ll_purchase_10_can) private val btnPurchase10Can: View = itemView.findViewById(R.id.ll_purchase_10_can)
private val btnPurchase20Can: View = itemView.findViewById(R.id.ll_purchase_20_can) private val btnPurchase20Can: View = itemView.findViewById(R.id.ll_purchase_20_can)
fun bind(onAction: (ChatQuotaNoticeAction) -> Unit) { fun bind(onAction: (ChatQuotaNoticeAction) -> Unit) {
btnRewardedAd.setOnClickListener { onAction(ChatQuotaNoticeAction.REWARDED_AD) }
btnPurchase10Can.setOnClickListener { onAction(ChatQuotaNoticeAction.PURCHASE_10_CAN) } btnPurchase10Can.setOnClickListener { onAction(ChatQuotaNoticeAction.PURCHASE_10_CAN) }
btnPurchase20Can.setOnClickListener { onAction(ChatQuotaNoticeAction.PURCHASE_20_CAN) } btnPurchase20Can.setOnClickListener { onAction(ChatQuotaNoticeAction.PURCHASE_20_CAN) }
} }

View File

@@ -3,8 +3,12 @@ package kr.co.vividnext.sodalive.v2.creator.channel.community.detail
import android.app.Activity import android.app.Activity
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.graphics.Outline
import android.graphics.drawable.Drawable
import android.os.Bundle import android.os.Bundle
import android.text.method.ScrollingMovementMethod import android.text.method.ScrollingMovementMethod
import android.view.View
import android.view.ViewOutlineProvider
import android.view.inputmethod.InputMethodManager import android.view.inputmethod.InputMethodManager
import android.widget.Toast import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
@@ -13,6 +17,10 @@ import androidx.core.widget.doAfterTextChanged
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import coil.transform.CircleCropTransformation import coil.transform.CircleCropTransformation
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
import com.bumptech.glide.load.DataSource
import com.bumptech.glide.load.engine.GlideException
import com.bumptech.glide.request.RequestListener
import com.bumptech.glide.request.target.Target
import kr.co.vividnext.sodalive.R import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.base.BaseActivity import kr.co.vividnext.sodalive.base.BaseActivity
import kr.co.vividnext.sodalive.databinding.ActivityCreatorChannelCommunityDetailBinding import kr.co.vividnext.sodalive.databinding.ActivityCreatorChannelCommunityDetailBinding
@@ -24,6 +32,7 @@ import kr.co.vividnext.sodalive.extensions.moneyFormat
import kr.co.vividnext.sodalive.v2.components.modal.V2ModalDialog import kr.co.vividnext.sodalive.v2.components.modal.V2ModalDialog
import kr.co.vividnext.sodalive.v2.creator.channel.community.detail.reply.CreatorChannelCommunityReplyActivity import kr.co.vividnext.sodalive.v2.creator.channel.community.detail.reply.CreatorChannelCommunityReplyActivity
import kr.co.vividnext.sodalive.v2.creator.channel.community.detail.ui.CreatorChannelCommunityCommentAdapter import kr.co.vividnext.sodalive.v2.creator.channel.community.detail.ui.CreatorChannelCommunityCommentAdapter
import kr.co.vividnext.sodalive.v2.widget.feed.calculateFeedCommunityImageHeight
import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.androidx.viewmodel.ext.android.viewModel
class CreatorChannelCommunityDetailActivity : BaseActivity<ActivityCreatorChannelCommunityDetailBinding>( class CreatorChannelCommunityDetailActivity : BaseActivity<ActivityCreatorChannelCommunityDetailBinding>(
@@ -56,6 +65,8 @@ class CreatorChannelCommunityDetailActivity : BaseActivity<ActivityCreatorChanne
} }
binding.btnCreatorChannelCommunityDetailBack.setOnClickListener { finish() } binding.btnCreatorChannelCommunityDetailBack.setOnClickListener { finish() }
binding.layoutCreatorChannelCommunityDetailImageContainer.clipToOutline = true
binding.layoutCreatorChannelCommunityDetailImageContainer.outlineProvider = roundedOutlineProvider()
binding.rvCreatorChannelCommunityDetailComments.layoutManager = LinearLayoutManager(this) binding.rvCreatorChannelCommunityDetailComments.layoutManager = LinearLayoutManager(this)
binding.rvCreatorChannelCommunityDetailComments.adapter = commentAdapter binding.rvCreatorChannelCommunityDetailComments.adapter = commentAdapter
binding.scrollCreatorChannelCommunityDetail.setOnScrollChangeListener { scrollView, _, scrollY, _, oldScrollY -> binding.scrollCreatorChannelCommunityDetail.setOnScrollChangeListener { scrollView, _, scrollY, _, oldScrollY ->
@@ -156,6 +167,10 @@ class CreatorChannelCommunityDetailActivity : BaseActivity<ActivityCreatorChanne
tvCreatorChannelCommunityDetailPrice.text = post.price.moneyFormat() tvCreatorChannelCommunityDetailPrice.text = post.price.moneyFormat()
tvCreatorChannelCommunityDetailPurchased.isVisible = post.existOrdered && post.price > 0 tvCreatorChannelCommunityDetailPurchased.isVisible = post.existOrdered && post.price > 0
layoutCreatorChannelCommunityDetailImageContainer.isVisible = post.imageUrl != null || post.showPaywall layoutCreatorChannelCommunityDetailImageContainer.isVisible = post.imageUrl != null || post.showPaywall
layoutCreatorChannelCommunityDetailImageContainer.layoutParams =
layoutCreatorChannelCommunityDetailImageContainer.layoutParams.apply {
height = if (post.imageUrl != null) DEFAULT_IMAGE_HEIGHT_DP.dpToPx() else DEFAULT_PAYWALL_HEIGHT_DP.dpToPx()
}
layoutCreatorChannelCommunityDetailPaywall.isVisible = post.showPaywall layoutCreatorChannelCommunityDetailPaywall.isVisible = post.showPaywall
tvCreatorChannelCommunityDetailPaywallPrice.text = post.price.moneyFormat() tvCreatorChannelCommunityDetailPaywallPrice.text = post.price.moneyFormat()
layoutCreatorChannelCommunityDetailPaywall.setOnClickListener { layoutCreatorChannelCommunityDetailPaywall.setOnClickListener {
@@ -165,6 +180,25 @@ class CreatorChannelCommunityDetailActivity : BaseActivity<ActivityCreatorChanne
Glide.with(ivCreatorChannelCommunityDetailImage) Glide.with(ivCreatorChannelCommunityDetailImage)
.load(post.imageUrl) .load(post.imageUrl)
.placeholder(R.drawable.ic_place_holder) .placeholder(R.drawable.ic_place_holder)
.listener(object : RequestListener<Drawable> {
override fun onLoadFailed(
e: GlideException?,
model: Any?,
target: Target<Drawable>,
isFirstResource: Boolean
): Boolean = false
override fun onResourceReady(
resource: Drawable,
model: Any,
target: Target<Drawable>?,
dataSource: DataSource,
isFirstResource: Boolean
): Boolean {
applyDetailImageSize(resource.intrinsicWidth, resource.intrinsicHeight)
return false
}
})
.into(ivCreatorChannelCommunityDetailImage) .into(ivCreatorChannelCommunityDetailImage)
} else { } else {
Glide.with(ivCreatorChannelCommunityDetailImage).clear(ivCreatorChannelCommunityDetailImage) Glide.with(ivCreatorChannelCommunityDetailImage).clear(ivCreatorChannelCommunityDetailImage)
@@ -196,6 +230,29 @@ class CreatorChannelCommunityDetailActivity : BaseActivity<ActivityCreatorChanne
updateSendButton(content.commentInput.trim().isNotEmpty() && !content.isSendingComment) updateSendButton(content.commentInput.trim().isNotEmpty() && !content.isSendingComment)
} }
private fun applyDetailImageSize(intrinsicWidthPx: Int, intrinsicHeightPx: Int): Unit = with(binding) {
val container = layoutCreatorChannelCommunityDetailImageContainer
val containerWidth = container.width.takeIf { it > 0 }
?: container.measuredWidth.takeIf { it > 0 }
?: ivCreatorChannelCommunityDetailImage.width.takeIf { it > 0 }
?: run {
container.post { applyDetailImageSize(intrinsicWidthPx, intrinsicHeightPx) }
return@with
}
val imageHeight = calculateFeedCommunityImageHeight(
containerWidthPx = containerWidth,
intrinsicWidthPx = intrinsicWidthPx,
intrinsicHeightPx = intrinsicHeightPx
) ?: return@with
container.layoutParams = container.layoutParams.apply { height = imageHeight }
}
private fun roundedOutlineProvider() = object : ViewOutlineProvider() {
override fun getOutline(view: View, outline: Outline) {
outline.setRoundRect(0, 0, view.width, view.height, COMMUNITY_IMAGE_RADIUS_DP.dpToPx().toFloat())
}
}
private fun updateSendButton(enabled: Boolean) = with(binding.btnCreatorChannelCommunityDetailSend) { private fun updateSendButton(enabled: Boolean) = with(binding.btnCreatorChannelCommunityDetailSend) {
isEnabled = enabled isEnabled = enabled
setBackgroundResource( setBackgroundResource(
@@ -269,10 +326,15 @@ class CreatorChannelCommunityDetailActivity : BaseActivity<ActivityCreatorChanne
companion object { companion object {
private const val EXTRA_POST_ID = "extra_post_id" private const val EXTRA_POST_ID = "extra_post_id"
private const val DEFAULT_IMAGE_HEIGHT_DP = 236
private const val DEFAULT_PAYWALL_HEIGHT_DP = 236
private const val COMMUNITY_IMAGE_RADIUS_DP = 14
fun newIntent(context: Context, postId: Long): Intent { fun newIntent(context: Context, postId: Long): Intent {
return Intent(context, CreatorChannelCommunityDetailActivity::class.java) return Intent(context, CreatorChannelCommunityDetailActivity::class.java)
.putExtra(EXTRA_POST_ID, postId) .putExtra(EXTRA_POST_ID, postId)
} }
} }
private fun Int.dpToPx(): Int = (this * resources.displayMetrics.density).toInt()
} }

View File

@@ -1,20 +1,26 @@
package kr.co.vividnext.sodalive.v2.creator.channel.community.ui package kr.co.vividnext.sodalive.v2.creator.channel.community.ui
import android.graphics.Outline
import android.graphics.drawable.Drawable
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.view.ViewOutlineProvider
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import coil.transform.CircleCropTransformation import coil.transform.CircleCropTransformation
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
import com.bumptech.glide.load.resource.bitmap.CenterCrop import com.bumptech.glide.load.DataSource
import com.bumptech.glide.load.resource.bitmap.RoundedCorners import com.bumptech.glide.load.engine.GlideException
import com.bumptech.glide.request.RequestOptions import com.bumptech.glide.request.RequestListener
import com.bumptech.glide.request.target.Target
import kr.co.vividnext.sodalive.R import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.databinding.ItemCreatorChannelCommunityListBinding import kr.co.vividnext.sodalive.databinding.ItemCreatorChannelCommunityListBinding
import kr.co.vividnext.sodalive.extensions.dpToPx import kr.co.vividnext.sodalive.extensions.dpToPx
import kr.co.vividnext.sodalive.extensions.loadUrl import kr.co.vividnext.sodalive.extensions.loadUrl
import kr.co.vividnext.sodalive.extensions.moneyFormat import kr.co.vividnext.sodalive.extensions.moneyFormat
import kr.co.vividnext.sodalive.v2.creator.channel.community.model.CreatorChannelCommunityPostUiModel import kr.co.vividnext.sodalive.v2.creator.channel.community.model.CreatorChannelCommunityPostUiModel
import kr.co.vividnext.sodalive.v2.widget.feed.calculateFeedCommunityImageHeight
class CreatorChannelCommunityListAdapter( class CreatorChannelCommunityListAdapter(
private val onPostClick: (CreatorChannelCommunityPostUiModel) -> Unit = {}, private val onPostClick: (CreatorChannelCommunityPostUiModel) -> Unit = {},
@@ -54,6 +60,11 @@ class CreatorChannelCommunityListAdapter(
private val isPlayingContent: (Long) -> Boolean private val isPlayingContent: (Long) -> Boolean
) : RecyclerView.ViewHolder(binding.root) { ) : RecyclerView.ViewHolder(binding.root) {
init {
binding.layoutCreatorChannelCommunityListImageContainer.clipToOutline = true
binding.layoutCreatorChannelCommunityListImageContainer.outlineProvider = roundedOutlineProvider()
}
fun bind(item: CreatorChannelCommunityPostUiModel) = with(binding) { fun bind(item: CreatorChannelCommunityPostUiModel) = with(binding) {
root.setOnClickListener { onPostClick(item) } root.setOnClickListener { onPostClick(item) }
ivCreatorChannelCommunityListProfile.loadUrl(item.creatorProfileUrl) { ivCreatorChannelCommunityListProfile.loadUrl(item.creatorProfileUrl) {
@@ -77,11 +88,30 @@ class CreatorChannelCommunityListAdapter(
layoutCreatorChannelCommunityListImageContainer.isVisible = layoutCreatorChannelCommunityListImageContainer.isVisible =
visibleImageUrl != null || item.isLocked || item.showPlayButton visibleImageUrl != null || item.isLocked || item.showPlayButton
ivCreatorChannelCommunityListImage.isVisible = visibleImageUrl != null ivCreatorChannelCommunityListImage.isVisible = visibleImageUrl != null
resetCommunityImageHeight()
if (visibleImageUrl != null) { if (visibleImageUrl != null) {
Glide.with(ivCreatorChannelCommunityListImage) Glide.with(ivCreatorChannelCommunityListImage)
.load(visibleImageUrl) .load(visibleImageUrl)
.placeholder(R.drawable.ic_place_holder) .placeholder(R.drawable.ic_place_holder)
.apply(communityImageRequestOptions()) .listener(object : RequestListener<Drawable> {
override fun onLoadFailed(
e: GlideException?,
model: Any?,
target: Target<Drawable>,
isFirstResource: Boolean
): Boolean = false
override fun onResourceReady(
resource: Drawable,
model: Any,
target: Target<Drawable>?,
dataSource: DataSource,
isFirstResource: Boolean
): Boolean {
applyCommunityImageSize(resource.intrinsicWidth, resource.intrinsicHeight)
return false
}
})
.into(ivCreatorChannelCommunityListImage) .into(ivCreatorChannelCommunityListImage)
} else { } else {
Glide.with(ivCreatorChannelCommunityListImage).clear(ivCreatorChannelCommunityListImage) Glide.with(ivCreatorChannelCommunityListImage).clear(ivCreatorChannelCommunityListImage)
@@ -104,11 +134,45 @@ class CreatorChannelCommunityListAdapter(
ivCreatorChannelCommunityListOwnerMore.setOnClickListener { onOwnerMoreClick(item) } ivCreatorChannelCommunityListOwnerMore.setOnClickListener { onOwnerMoreClick(item) }
} }
private fun communityImageRequestOptions(): RequestOptions { private fun resetCommunityImageHeight() {
return RequestOptions().transform( binding.layoutCreatorChannelCommunityListImageContainer.layoutParams =
CenterCrop(), binding.layoutCreatorChannelCommunityListImageContainer.layoutParams.apply {
RoundedCorners(14f.dpToPx().toInt()) height = DEFAULT_IMAGE_HEIGHT_DP.dpToPx().toInt()
) }
}
private fun applyCommunityImageSize(intrinsicWidthPx: Int, intrinsicHeightPx: Int): Unit = with(binding) {
val container = layoutCreatorChannelCommunityListImageContainer
val containerWidth = container.width.takeIf { it > 0 }
?: container.measuredWidth.takeIf { it > 0 }
?: root.width.takeIf { it > 0 }?.let { it - root.paddingLeft - root.paddingRight }
?: run {
container.post { applyCommunityImageSize(intrinsicWidthPx, intrinsicHeightPx) }
return@with
}
val imageHeight = calculateFeedCommunityImageHeight(
containerWidthPx = containerWidth,
intrinsicWidthPx = intrinsicWidthPx,
intrinsicHeightPx = intrinsicHeightPx
) ?: return@with
container.layoutParams = container.layoutParams.apply { height = imageHeight }
}
private fun roundedOutlineProvider() = object : ViewOutlineProvider() {
override fun getOutline(view: View, outline: Outline) {
outline.setRoundRect(
0,
0,
view.width,
view.height,
COMMUNITY_IMAGE_RADIUS_DP.dpToPx()
)
}
}
private companion object {
const val DEFAULT_IMAGE_HEIGHT_DP = 236f
const val COMMUNITY_IMAGE_RADIUS_DP = 14f
} }
} }
} }

View File

@@ -435,6 +435,14 @@ class CreatorChannelHomeSectionAdapter(
val communityImageUrl = community.imageUrl.takeIf { !it.isNullOrBlank() } val communityImageUrl = community.imageUrl.takeIf { !it.isNullOrBlank() }
if (communityImageUrl != null) { if (communityImageUrl != null) {
communityRow.communityImageView().loadUrl(communityImageUrl) { communityRow.communityImageView().loadUrl(communityImageUrl) {
listener(
onSuccess = { _, result ->
communityRow.applyCommunityImageSize(
result.drawable.intrinsicWidth,
result.drawable.intrinsicHeight
)
}
)
if (isCommunityLocked) { if (isCommunityLocked) {
transformations(BlurTransformation(itemView.context, 25f, 2.5f)) transformations(BlurTransformation(itemView.context, 25f, 2.5f))
} }

View File

@@ -59,7 +59,6 @@ class ChatMainFragment : BaseFragment<FragmentV2MainChatBinding>(
setupChatRooms() setupChatRooms()
bindViewModel() bindViewModel()
binding.btnChatFloating.setOnClickListener { }
viewModel.loadFirstPage(initialFilter) viewModel.loadFirstPage(initialFilter)
} }

View File

@@ -132,7 +132,9 @@ class HomeFollowingNewsAdapter(
view.setFeedSize(feedSize(FeedVariant.Community, parent)) view.setFeedSize(feedSize(FeedVariant.Community, parent))
view.bind(feedItem) view.bind(feedItem)
view.profileImageView().loadHomeCreatorProfileImage(feedItem.creatorImageUrl) view.profileImageView().loadHomeCreatorProfileImage(feedItem.creatorImageUrl)
bindImage(view.communityImageView(), feedItem.imageUrl.orEmpty()) bindImage(view.communityImageView(), feedItem.imageUrl.orEmpty()) { width, height ->
view.applyCommunityImageSize(width, height)
}
view.setOnFeedClick { onClickItem(community) } view.setOnFeedClick { onClickItem(community) }
} }
} }
@@ -176,11 +178,21 @@ class HomeFollowingNewsAdapter(
return FeedSize.from(variant, FeedWidthMode.ParentAvailable, availableWidthDp) return FeedSize.from(variant, FeedWidthMode.ParentAvailable, availableWidthDp)
} }
private fun bindImage(imageView: android.widget.ImageView, url: String) { private fun bindImage(
imageView: android.widget.ImageView,
url: String,
onImageLoaded: ((Int, Int) -> Unit)? = null
) {
if (url.isBlank()) { if (url.isBlank()) {
imageView.setImageDrawable(null) imageView.setImageDrawable(null)
} else { } else {
imageView.loadUrl(url) imageView.loadUrl(url) {
listener(
onSuccess = { _, result ->
onImageLoaded?.invoke(result.drawable.intrinsicWidth, result.drawable.intrinsicHeight)
}
)
}
} }
} }

View File

@@ -53,20 +53,27 @@ class HomePopularCommunityAdapter(
bindImage( bindImage(
imageUrl = item.item.imageUrl, imageUrl = item.item.imageUrl,
imageView = view.communityImageView(), imageView = view.communityImageView(),
shouldBlur = item.item.price > 0 && !item.item.existOrdered shouldBlur = item.item.price > 0 && !item.item.existOrdered,
onImageLoaded = { width, height -> view.applyCommunityImageSize(width, height) }
) )
} }
private fun bindImage( private fun bindImage(
imageUrl: String?, imageUrl: String?,
imageView: android.widget.ImageView, imageView: android.widget.ImageView,
shouldBlur: Boolean = false shouldBlur: Boolean = false,
onImageLoaded: ((Int, Int) -> Unit)? = null
) { ) {
if (imageUrl.isNullOrBlank()) { if (imageUrl.isNullOrBlank()) {
imageView.dispose() imageView.dispose()
imageView.setImageDrawable(null) imageView.setImageDrawable(null)
} else { } else {
imageView.loadUrl(imageUrl) { imageView.loadUrl(imageUrl) {
listener(
onSuccess = { _, result ->
onImageLoaded?.invoke(result.drawable.intrinsicWidth, result.drawable.intrinsicHeight)
}
)
if (shouldBlur) { if (shouldBlur) {
transformations(BlurTransformation(imageView.context, 25f, 2.5f)) transformations(BlurTransformation(imageView.context, 25f, 2.5f))
} }

View File

@@ -0,0 +1,12 @@
package kr.co.vividnext.sodalive.v2.widget.feed
import kotlin.math.roundToInt
fun calculateFeedCommunityImageHeight(
containerWidthPx: Int,
intrinsicWidthPx: Int,
intrinsicHeightPx: Int
): Int? {
if (containerWidthPx <= 0 || intrinsicWidthPx <= 0 || intrinsicHeightPx <= 0) return null
return (containerWidthPx * intrinsicHeightPx.toFloat() / intrinsicWidthPx).roundToInt()
}

View File

@@ -62,6 +62,7 @@ class FeedCommunityView @JvmOverloads constructor(
val isLocked = item.price > 0 && !item.existOrdered val isLocked = item.price > 0 && !item.existOrdered
val hasImage = !item.imageUrl.isNullOrBlank() val hasImage = !item.imageUrl.isNullOrBlank()
requireNotNull(communityImageContainer).isVisible = hasImage || isLocked requireNotNull(communityImageContainer).isVisible = hasImage || isLocked
resetCommunityImageHeight()
requireNotNull(communityImage).isVisible = hasImage requireNotNull(communityImage).isVisible = hasImage
if (!hasImage || isLocked) { if (!hasImage || isLocked) {
requireNotNull(communityImage).setImageDrawable(null) requireNotNull(communityImage).setImageDrawable(null)
@@ -79,6 +80,33 @@ class FeedCommunityView @JvmOverloads constructor(
fun boundItem(): FeedItem.Community? = currentItem fun boundItem(): FeedItem.Community? = currentItem
fun applyCommunityImageSize(
intrinsicWidthPx: Int,
intrinsicHeightPx: Int
) {
val container = requireNotNull(communityImageContainer)
val width = container.width.takeIf { it > 0 }
?: container.measuredWidth.takeIf { it > 0 }
?: (width - paddingLeft - paddingRight).takeIf { it > 0 }
?: run {
post { applyCommunityImageSize(intrinsicWidthPx, intrinsicHeightPx) }
return
}
val imageHeight = calculateFeedCommunityImageHeight(
containerWidthPx = width,
intrinsicWidthPx = intrinsicWidthPx,
intrinsicHeightPx = intrinsicHeightPx
) ?: return
container.layoutParams = container.layoutParams.apply { height = imageHeight }
}
private fun resetCommunityImageHeight() {
val container = requireNotNull(communityImageContainer)
container.layoutParams = container.layoutParams.apply {
height = DEFAULT_IMAGE_HEIGHT_DP.dpToPx()
}
}
fun setFeedSize(size: FeedSize) { fun setFeedSize(size: FeedSize) {
updateRootWidth(size.rootWidthDp.dpToPx()) updateRootWidth(size.rootWidthDp.dpToPx())
} }
@@ -128,5 +156,6 @@ class FeedCommunityView @JvmOverloads constructor(
private companion object { private companion object {
const val CARD_RADIUS_DP = 14 const val CARD_RADIUS_DP = 14
const val COMMUNITY_IMAGE_RADIUS_DP = 14 const val COMMUNITY_IMAGE_RADIUS_DP = 14
const val DEFAULT_IMAGE_HEIGHT_DP = 236
} }
} }

View File

@@ -136,7 +136,7 @@
<FrameLayout <FrameLayout
android:id="@+id/layout_creator_channel_community_detail_image_container" android:id="@+id/layout_creator_channel_community_detail_image_container"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="236dp" android:layout_height="wrap_content"
android:layout_marginTop="@dimen/spacing_14" android:layout_marginTop="@dimen/spacing_14"
android:background="@drawable/bg_feed_community_image" android:background="@drawable/bg_feed_community_image"
android:visibility="gone" android:visibility="gone"
@@ -147,7 +147,8 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:contentDescription="@string/a11y_feed_content_image" android:contentDescription="@string/a11y_feed_content_image"
android:scaleType="centerCrop" /> android:adjustViewBounds="true"
android:scaleType="fitCenter" />
<ImageView <ImageView
android:id="@+id/iv_creator_channel_community_detail_play_or_pause" android:id="@+id/iv_creator_channel_community_detail_play_or_pause"

View File

@@ -50,17 +50,4 @@
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/view_chat_filter_tabs" app:layout_constraintTop_toBottomOf="@id/view_chat_filter_tabs"
tools:text="아직 나눈 대화가 없어요.\n크리에이터와 이야기를 시작해 보세요." /> tools:text="아직 나눈 대화가 없어요.\n크리에이터와 이야기를 시작해 보세요." />
<ImageView
android:id="@+id/btn_chat_floating"
android:layout_width="@dimen/spacing_48"
android:layout_height="@dimen/spacing_48"
android:layout_marginEnd="@dimen/spacing_20"
android:layout_marginBottom="@dimen/spacing_20"
android:background="@drawable/bg_chat_floating_button"
android:contentDescription="@string/screen_chat_floating_button"
android:scaleType="center"
android:src="@drawable/ic_plus_no_bg"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout> </androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -6,47 +6,8 @@
android:paddingVertical="20dp"> android:paddingVertical="20dp">
<LinearLayout <LinearLayout
android:id="@+id/ll_rewarded_ad"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:background="@drawable/bg_chat_quota_rewarded_ad_button"
android:gravity="center"
android:orientation="horizontal"
android:paddingVertical="12dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:fontFamily="@font/bold"
android:includeFontPadding="false"
android:text="@string/chat_quota_rewarded_ad_label"
android:textColor="@color/color_37474f"
android:textSize="24sp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginHorizontal="4dp"
android:fontFamily="@font/medium"
android:includeFontPadding="false"
android:text="@string/chat_quota_separator"
android:textColor="@color/color_37474f"
android:textSize="18sp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:fontFamily="@font/medium"
android:includeFontPadding="false"
android:text="@string/chat_quota_rewarded_ad_chat_count"
android:textColor="@color/color_37474f"
android:textSize="18sp" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:baselineAligned="false" android:baselineAligned="false"
android:gravity="center" android:gravity="center"
android:orientation="horizontal"> android:orientation="horizontal">

View File

@@ -160,7 +160,7 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:contentDescription="@string/a11y_feed_content_image" android:contentDescription="@string/a11y_feed_content_image"
android:scaleType="centerCrop" android:scaleType="fitCenter"
tools:src="@drawable/ic_launcher_background" /> tools:src="@drawable/ic_launcher_background" />
<View <View

View File

@@ -67,8 +67,9 @@
<FrameLayout <FrameLayout
android:id="@+id/fl_feed_community_image_container" android:id="@+id/fl_feed_community_image_container"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="236dp" android:layout_height="wrap_content"
android:layout_marginTop="@dimen/spacing_14" android:layout_marginTop="@dimen/spacing_14"
android:background="@drawable/bg_feed_community_image"
android:visibility="gone" android:visibility="gone"
tools:visibility="visible"> tools:visibility="visible">
@@ -76,9 +77,8 @@
android:id="@+id/iv_feed_community_image" android:id="@+id/iv_feed_community_image"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:background="@drawable/bg_feed_community_image"
android:contentDescription="@string/a11y_feed_content_image" android:contentDescription="@string/a11y_feed_content_image"
android:scaleType="centerCrop" android:scaleType="fitCenter"
tools:src="@drawable/ic_launcher_background" /> tools:src="@drawable/ic_launcher_background" />
<LinearLayout <LinearLayout

View File

@@ -71,7 +71,7 @@
<string name="chat_quota_20_can">20</string> <string name="chat_quota_20_can">20</string>
<string name="chat_quota_20_can_chat_count">40 chats</string> <string name="chat_quota_20_can_chat_count">40 chats</string>
<string name="chat_quota_separator">/</string> <string name="chat_quota_separator">/</string>
<string name="chat_quota_rewarded_ad_unavailable">Unable to load the ad.</string> <string name="chat_quota_rewarded_ad_unavailable">Unable to load the ad.\nPlease try again in a moment.</string>
<string name="chat_more_title">Chat settings</string> <string name="chat_more_title">Chat settings</string>
<string name="chat_more_background_label">Background image</string> <string name="chat_more_background_label">Background image</string>
<string name="chat_more_background_change">Change background image</string> <string name="chat_more_background_change">Change background image</string>

View File

@@ -71,7 +71,7 @@
<string name="chat_quota_20_can">20</string> <string name="chat_quota_20_can">20</string>
<string name="chat_quota_20_can_chat_count">40チャット</string> <string name="chat_quota_20_can_chat_count">40チャット</string>
<string name="chat_quota_separator">/</string> <string name="chat_quota_separator">/</string>
<string name="chat_quota_rewarded_ad_unavailable">広告を読み込めませんでした。</string> <string name="chat_quota_rewarded_ad_unavailable">広告を読み込めませんでした。\nしばらくしてからもう一度お試しください。</string>
<string name="chat_more_title">トーク設定</string> <string name="chat_more_title">トーク設定</string>
<string name="chat_more_background_label">背景画像</string> <string name="chat_more_background_label">背景画像</string>
<string name="chat_more_background_change">背景画像の変更</string> <string name="chat_more_background_change">背景画像の変更</string>

View File

@@ -71,7 +71,7 @@
<string name="chat_quota_20_can">20</string> <string name="chat_quota_20_can">20</string>
<string name="chat_quota_20_can_chat_count">40채팅</string> <string name="chat_quota_20_can_chat_count">40채팅</string>
<string name="chat_quota_separator">/</string> <string name="chat_quota_separator">/</string>
<string name="chat_quota_rewarded_ad_unavailable">광고를 불러오지 못했어요.</string> <string name="chat_quota_rewarded_ad_unavailable">광고를 불러오지 못했어요\n잠시 후 다시 시도해 주세요.</string>
<string name="chat_more_title">대화 설정</string> <string name="chat_more_title">대화 설정</string>
<string name="chat_more_background_label">배경 이미지</string> <string name="chat_more_background_label">배경 이미지</string>
<string name="chat_more_background_change">배경 이미지 변경</string> <string name="chat_more_background_change">배경 이미지 변경</string>

View File

@@ -1847,6 +1847,7 @@ class CreatorChannelActivitySourceTest {
assertTrue(adapter.contains("communityWidthDp.dp()")) assertTrue(adapter.contains("communityWidthDp.dp()"))
assertTrue(adapter.contains("LinearLayout.LayoutParams.WRAP_CONTENT")) assertTrue(adapter.contains("LinearLayout.LayoutParams.WRAP_CONTENT"))
assertTrue(adapter.contains("communityImageUrl = community.imageUrl.takeIf { !it.isNullOrBlank() }")) assertTrue(adapter.contains("communityImageUrl = community.imageUrl.takeIf { !it.isNullOrBlank() }"))
assertTrue(adapter.contains("communityRow.applyCommunityImageSize("))
assertTrue(adapter.contains("communityImageView().setImageDrawable(null)")) assertTrue(adapter.contains("communityImageView().setImageDrawable(null)"))
assertTrue(adapter.contains("private const val MAX_COMMUNITY_ITEM_COUNT = 3")) assertTrue(adapter.contains("private const val MAX_COMMUNITY_ITEM_COUNT = 3"))
assertFalse(adapter.contains("CreatorChannelCommunityCardView")) assertFalse(adapter.contains("CreatorChannelCommunityCardView"))

View File

@@ -46,19 +46,17 @@ class ChatMainFragmentLayoutTest {
} }
@Test @Test
fun `채팅 fragment layout은 title bar tab list floating button만 포함한다`() { fun `채팅 fragment layout은 title bar tab list만 포함한다`() {
val root = inflateView(R.layout.fragment_v2_main_chat) as ConstraintLayout val root = inflateView(R.layout.fragment_v2_main_chat) as ConstraintLayout
val titleBar = requireNotNull(root.findViewById<View>(R.id.view_chat_title_bar)) val titleBar = requireNotNull(root.findViewById<View>(R.id.view_chat_title_bar))
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 emptyMessage = requireNotNull(root.findViewById<TextView>(R.id.tv_chat_empty_message)) 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, emptyMessage.parent) assertSame(root, emptyMessage.parent)
assertEquals(View.GONE, emptyMessage.visibility) assertEquals(View.GONE, emptyMessage.visibility)
assertFalse(root.containsClassName("com.google.android.material.bottomnavigation.BottomNavigationView")) assertFalse(root.containsClassName("com.google.android.material.bottomnavigation.BottomNavigationView"))
@@ -66,17 +64,15 @@ class ChatMainFragmentLayoutTest {
} }
@Test @Test
fun `채팅 fragment list와 floating button은 계획된 constraint를 사용한다`() { fun `채팅 fragment list 계획된 constraint를 사용한다`() {
val root = inflateView(R.layout.fragment_v2_main_chat) val root = inflateView(R.layout.fragment_v2_main_chat)
val titleBar = requireNotNull(root.findViewById<View>(R.id.view_chat_title_bar)) val titleBar = requireNotNull(root.findViewById<View>(R.id.view_chat_title_bar))
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 emptyMessage = requireNotNull(root.findViewById<TextView>(R.id.tv_chat_empty_message)) 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 emptyParams = emptyMessage.layoutParams as ConstraintLayout.LayoutParams
val buttonParams = floatingButton.layoutParams as ConstraintLayout.LayoutParams
assertEquals(60.dpToPx(), titleBar.layoutParams.height) assertEquals(60.dpToPx(), titleBar.layoutParams.height)
assertEquals(52.dpToPx(), tabBar.layoutParams.height) assertEquals(52.dpToPx(), tabBar.layoutParams.height)
@@ -87,8 +83,6 @@ class ChatMainFragmentLayoutTest {
assertEquals(ConstraintLayout.LayoutParams.PARENT_ID, emptyParams.bottomToBottom) 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.bottomToBottom)
} }
@Test @Test
@@ -141,7 +135,6 @@ class ChatMainFragmentLayoutTest {
assertTrue(source.contains("ChatRoomActivity.newIntent(requireContext(), item.roomId)")) assertTrue(source.contains("ChatRoomActivity.newIntent(requireContext(), item.roomId)"))
assertTrue(source.contains("ChatRoomType.DM ->")) assertTrue(source.contains("ChatRoomType.DM ->"))
assertTrue(source.contains("DmChatRoomActivity.newIntentByRoomId(requireContext(), item.roomId)")) assertTrue(source.contains("DmChatRoomActivity.newIntentByRoomId(requireContext(), item.roomId)"))
assertTrue(source.contains("btnChatFloating.setOnClickListener { }"))
} }
@Test @Test

View File

@@ -0,0 +1,41 @@
package kr.co.vividnext.sodalive.v2.widget.feed
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class FeedCommunityImageSizeTest {
@Test
fun `커뮤니티 이미지는 원본 비율로 높이를 계산한다`() {
val height = calculateFeedCommunityImageHeight(
containerWidthPx = 300,
intrinsicWidthPx = 1200,
intrinsicHeightPx = 800
)
assertEquals(200, height)
}
@Test
fun `커뮤니티 이미지는 특정 최대 높이로 제한하지 않는다`() {
val height = calculateFeedCommunityImageHeight(
containerWidthPx = 300,
intrinsicWidthPx = 600,
intrinsicHeightPx = 1800
)
assertEquals(900, height)
}
@Test
fun `커뮤니티 이미지는 크기 정보가 없으면 높이를 계산하지 않는다`() {
val height = calculateFeedCommunityImageHeight(
containerWidthPx = 300,
intrinsicWidthPx = 0,
intrinsicHeightPx = 800
)
assertNull(height)
}
}

View File

@@ -85,14 +85,15 @@ class FeedViewTest {
} }
@Test @Test
fun `community layout matches creator channel feed row structure`() { fun `community layout uses runtime image height instead of fixed crop height`() {
val view = inflateView<FeedCommunityView>(R.layout.view_feed_community) val view = inflateView<FeedCommunityView>(R.layout.view_feed_community)
val layout = projectFile("app/src/main/res/layout/view_feed_community.xml").readText() val layout = projectFile("app/src/main/res/layout/view_feed_community.xml").readText()
assertFalse(layout.contains("@+id/tv_feed_community_keyword")) assertFalse(layout.contains("@+id/tv_feed_community_keyword"))
assertTrue(layout.contains("android:layout_width=\"match_parent\"")) assertTrue(layout.contains("android:layout_width=\"match_parent\""))
assertTrue(layout.contains("android:maxLines=\"5\"")) assertTrue(layout.contains("android:maxLines=\"5\""))
assertTrue(layout.contains("android:layout_height=\"236dp\"")) assertFalse(layout.contains("android:layout_height=\"236dp\""))
assertTrue(layout.contains("android:scaleType=\"fitCenter\""))
assertTrue(layout.contains("@drawable/bg_creator_channel_community_price")) assertTrue(layout.contains("@drawable/bg_creator_channel_community_price"))
assertTrue(layout.contains("@drawable/ic_bar_cash")) assertTrue(layout.contains("@drawable/ic_bar_cash"))
assertFalse(layout.contains("android:drawableStart=\"@drawable/ic_bar_cash\"")) assertFalse(layout.contains("android:drawableStart=\"@drawable/ic_bar_cash\""))
@@ -152,17 +153,21 @@ class FeedViewTest {
} }
@Test @Test
fun `community image container keeps creator channel fixed height`() { fun `community image container keeps fallback height until original image ratio is applied`() {
val view = inflateView<FeedCommunityView>(R.layout.view_feed_community) val view = inflateView<FeedCommunityView>(R.layout.view_feed_community)
val imageContainer = view.findViewById<FrameLayout>(R.id.fl_feed_community_image_container) val imageContainer = view.findViewById<FrameLayout>(R.id.fl_feed_community_image_container)
view.bind(sampleCommunityItem(bodyText = "본문", keywordText = "", imageUrl = "https://example.com/post.png")) view.bind(sampleCommunityItem(bodyText = "본문", keywordText = "", imageUrl = "https://example.com/post.png"))
view.measure(exactly(402.dpToPx()), View.MeasureSpec.UNSPECIFIED)
assertEquals(236.dpToPx(), imageContainer.measuredHeight)
view.applyCommunityImageSize(intrinsicWidthPx = 600, intrinsicHeightPx = 1800)
view.measure(exactly(402.dpToPx()), View.MeasureSpec.UNSPECIFIED) view.measure(exactly(402.dpToPx()), View.MeasureSpec.UNSPECIFIED)
val expectedImageWidth = 402.dpToPx() - view.paddingLeft - view.paddingRight val expectedImageWidth = 402.dpToPx() - view.paddingLeft - view.paddingRight
assertEquals(expectedImageWidth, imageContainer.measuredWidth) assertEquals(expectedImageWidth, imageContainer.measuredWidth)
assertEquals(236.dpToPx(), imageContainer.measuredHeight) assertEquals(expectedImageWidth * 3, imageContainer.measuredHeight)
assertEquals(true, imageContainer.clipToOutline) assertEquals(true, imageContainer.clipToOutline)
assertNotNull(imageContainer.outlineProvider) assertNotNull(imageContainer.outlineProvider)
} }

View File

@@ -0,0 +1,78 @@
# v2 크리에이터 커뮤니티 게시글 이미지 원본비율 구현 계획/TASK
## Goal
크리에이터 게시물 상세, 메인 홈 추천 탭 인기 커뮤니티 게시물, 메인 홈 팔로잉 탭 최근 소식 커뮤니티 게시물, 크리에이터 채널 커뮤니티 리스트형 아이템의 이미지를 특정 최대 높이 제한 없이 원본 비율로 표시한다.
## References
- PRD: `docs/20260712_v2_크리에이터_커뮤니티_게시글_이미지_원본비율/prd.md`
- 상세 layout: `app/src/main/res/layout/activity_creator_channel_community_detail.xml`
- 홈 공통 layout: `app/src/main/res/layout/view_feed_community.xml`
- 홈 공통 view: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/feed/FeedCommunityView.kt`
- 상세 activity: `app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/community/detail/CreatorChannelCommunityDetailActivity.kt`
## Scenario Contract
### Scenario 1: 일반 이미지 원본 비율 표시
- Pass 조건: 컨테이너 폭 300px, 원본 1200x800 이미지는 높이 200px로 계산된다.
- Real surface: 홈/상세 커뮤니티 이미지 영역이 고정 `236dp`가 아니라 로드된 이미지 비율 기반 높이를 사용한다.
- Test: `FeedCommunityImageSizeTest#커뮤니티 이미지는 원본 비율로 높이를 계산한다`
### Scenario 2: 세로 이미지 최대 높이 미제한
- Pass 조건: 컨테이너 폭 300px, 원본 600x1800 이미지는 높이 900px로 계산된다.
- Real surface: 세로로 긴 커뮤니티 이미지도 특정 최대 높이로 자르지 않고 원본 비율대로 표시한다.
- Test: `FeedCommunityImageSizeTest#커뮤니티 이미지는 특정 최대 높이로 제한하지 않는다`
### Scenario 3: 인접 동작 회귀 방지
- Pass 조건: 이미지가 없는 커뮤니티 게시글은 이미지 컨테이너가 기존처럼 `GONE`이다.
- Real surface: 이미지 없는 홈/상세 게시글의 텍스트/반응 영역 표시가 유지된다.
- Test: `FeedViewTest#community recommendation image is hidden when image url is null`
### Scenario 4: 로드 전 높이와 둥근 모서리 유지
- Pass 조건: 이미지 URL이 있는 커뮤니티 게시글은 로드 성공 전 기본 높이를 유지하고, 로드 성공 후 원본 비율 높이를 적용한다.
- Real surface: 홈 추천 인기 커뮤니티 이미지가 사라지지 않고, 홈 팔로잉/상세/리스트 이미지 둥근 모서리가 유지된다.
- Test: `FeedViewTest#community image container keeps fallback height until original image ratio is applied`
## Tasks
- [x] **Task 1: RED 테스트 작성**
- 순수 높이 계산 테스트를 추가한다.
- 기존 `236dp` 고정 높이 테스트를 원본 비율 기반 런타임 높이 테스트로 변경한다.
- 검증: 구현 전 테스트가 실패한다.
- [x] **Task 2: 공통 사이징 helper 구현**
- `FeedCommunityImageSize.kt`에 특정 최대 높이 제한 없는 원본 비율 높이 계산 로직을 추가한다.
- 검증: helper 테스트가 통과한다.
- [x] **Task 3: 홈 공통 커뮤니티 카드 적용**
- `view_feed_community.xml``236dp` 고정 높이와 `centerCrop`을 제거한다.
- `FeedCommunityView`에 로드된 이미지 크기를 적용하는 메서드를 추가한다.
- 홈 추천/팔로잉 어댑터의 이미지 로드 성공 시 원본 크기로 높이를 적용한다.
- 검증: `FeedViewTest`가 통과한다.
- [x] **Task 4: 상세 화면 적용**
- 상세 layout의 `236dp` 고정 높이와 `centerCrop`을 제거한다.
- Glide 로드 성공 시 원본 크기로 높이를 적용한다.
- 검증: 컴파일 통과.
- [x] **Task 5: 최종 검증과 문서 기록**
- 관련 단위 테스트, Kotlin 컴파일, ktlint/build를 실행한다.
- 가능한 경우 실제 표면을 실행 확인하고, 불가하면 환경 제약을 기록한다.
- [x] **Task 6: 후속 버그 수정 반영**
- 이미지 높이 특정 최대 제한을 제거한다.
- 홈 공통 커뮤니티 이미지 컨테이너의 기본 높이와 clipping 배경을 복구한다.
- 상세/리스트 이미지 컨테이너에 clipping 기반 둥근 모서리를 적용한다.
- 크리에이터 채널 커뮤니티 리스트형 아이템에 원본 비율 sizing을 적용한다.
- 검증: 관련 단위 테스트와 Gradle 검증 명령으로 확인한다.
## Verification Log
- 2026-07-12: 사용자 요청, 기존 코드 조사 결과, Plan Agent 결과를 기준으로 구현 계획/TASK를 작성했다. 구현 전 문서 작성 단계이므로 빌드/테스트는 아직 실행하지 않았다.
- 2026-07-12: RED 확인. `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.feed.FeedCommunityImageSizeTest" --tests "kr.co.vividnext.sodalive.v2.widget.feed.FeedViewTest"` 실행 결과 신규 `calculateFeedCommunityImageHeight`, `applyCommunityImageSize` 미구현 컴파일 오류가 발생했다. 동일 실행에서 기존 `ChatMainFragmentLayoutTest.kt``btn_chat_floating` 미해결 참조도 함께 노출됐다.
- 2026-07-12: 구현 후 `./gradlew --no-daemon :app:compileDebugKotlin` 실행 결과 BUILD SUCCESSFUL을 확인했다.
- 2026-07-12: 구현 후 `./gradlew --no-daemon :app:assembleDebug` 실행 결과 BUILD SUCCESSFUL을 확인했다.
- 2026-07-12: 구현 후 `./gradlew --no-daemon :app:ktlintCheck` 실행 결과 BUILD SUCCESSFUL을 확인했다. 기존 `.editorconfig disabled_rules` deprecation warning은 출력됐으나 lint 실패는 없었다.
- 2026-07-12: 대상 단위 테스트 재실행은 `app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/ChatMainFragmentLayoutTest.kt:54`, `:74`의 기존 `btn_chat_floating` 미해결 참조로 `compileDebugUnitTestKotlin` 단계에서 차단됐다. 신규 테스트 자체 실행 단계에는 도달하지 못했다.
- 2026-07-12: `adb devices` 실행 결과 연결된 기기/에뮬레이터가 없어 홈 추천/팔로잉/상세 화면 실기기 수동 확인은 수행하지 못했다.
- 2026-07-12: 후속 요청의 4개 버그 원인을 확인했다. 홈 추천 이미지는 `wrap_content` 컨테이너와 `match_parent` 이미지 조합으로 로드 전 높이가 0이 될 수 있었고, 둥근 모서리는 이미지가 아닌 컨테이너 clipping 책임이 불완전했으며, 크리에이터 채널 커뮤니티 리스트형 아이템은 별도 경로라 기존 `236dp`/`centerCrop` 정책이 남아 있었다.
- 2026-07-12: 후속 버그 수정 후 `./gradlew --no-daemon :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.feed.FeedCommunityImageSizeTest" --tests "kr.co.vividnext.sodalive.v2.widget.feed.FeedViewTest" --tests "kr.co.vividnext.sodalive.v2.main.chat.ChatMainFragmentLayoutTest"` 실행 결과 BUILD SUCCESSFUL을 확인했다.
- 2026-07-12: 후속 버그 수정 후 `./gradlew --no-daemon :app:compileDebugKotlin`, `./gradlew --no-daemon :app:ktlintCheck`, `./gradlew --no-daemon :app:assembleDebug` 실행 결과 모두 BUILD SUCCESSFUL을 확인했다. ktlint 실행 중 기존 `.editorconfig disabled_rules` deprecation warning은 출력됐으나 실패는 없었다.

View File

@@ -0,0 +1,91 @@
# PRD: v2 크리에이터 커뮤니티 게시글 이미지 원본비율
## 1. Overview
v2 크리에이터 커뮤니티 게시글 이미지가 고정 높이로 잘리지 않고 원본 이미지 비율대로 표시되도록 조정한다.
---
## 2. Problem
- 현재 크리에이터 커뮤니티 게시물 상세, 메인 홈 추천 탭 인기 커뮤니티 게시물, 메인 홈 팔로잉 탭 최근 소식 커뮤니티 게시물의 이미지는 `236dp` 고정 높이와 `centerCrop`으로 표시된다.
- 크리에이터 채널 커뮤니티 리스트형 아이템도 별도 레이아웃/어댑터를 사용해 동일한 고정 높이와 crop 정책이 남아 있다.
- 원본 이미지 비율과 다른 이미지가 잘려 보일 수 있다.
- 이전 최대 높이 제한 구현 후 일부 화면에서 이미지가 사라지거나 둥근 모서리가 사라지는 회귀가 발생했다.
---
## 3. Goals
- 요청된 커뮤니티 게시글 이미지를 특정 최대 높이로 제한하지 않고 원본 이미지 비율대로 표시한다.
- 이미지 로드 전에는 기존 기본 높이를 임시로 유지해 이미지 영역이 사라지지 않도록 한다.
- 기존 유료 잠금 overlay, 오디오 재생 버튼, 댓글/좋아요 영역 동작은 유지한다.
---
## 4. Non-Goals
- 레거시 커뮤니티 목록/상세 화면은 변경하지 않는다.
- API/DTO/model 필드는 변경하지 않는다.
- 이미지 업로드/크롭 정책은 변경하지 않는다.
- 별도 최소 높이, placeholder 비율 정책은 추가하지 않는다.
---
## 5. Target Users
- 크리에이터 커뮤니티 게시글을 보는 사용자
- 메인 홈 추천/팔로잉 탭에서 커뮤니티 게시글을 탐색하는 사용자
---
## 6. User Stories
- 사용자는 커뮤니티 게시글 이미지를 원본 비율대로 확인하고 싶다.
- 사용자는 이미지 로드 전후에도 커뮤니티 이미지 영역과 둥근 모서리가 안정적으로 유지되기를 원한다.
---
## 7. Core Features
### Feature A: 커뮤니티 이미지 원본비율 표시
#### Requirements
- 크리에이터 게시물 상세 이미지는 원본 이미지 비율로 표시한다.
- 메인 홈 추천 탭 인기 커뮤니티 게시물 이미지는 원본 이미지 비율로 표시한다.
- 메인 홈 팔로잉 탭 최근 소식 커뮤니티 게시물 이미지는 원본 이미지 비율로 표시한다.
- 크리에이터 채널 커뮤니티 리스트형 아이템 이미지는 원본 이미지 비율로 표시한다.
- 계산된 이미지 높이를 특정 최대 높이로 제한하지 않는다.
#### Edge Cases
- 원본 이미지 크기 정보가 없으면 기존 화면이 깨지지 않도록 높이를 강제로 계산하지 않는다.
- 유료 미구매 overlay는 계산된 이미지 영역 전체를 덮는다.
- 이미지가 없는 게시글은 기존처럼 이미지 영역을 숨긴다.
---
## 8. UX / UI Expectations
- 이미지는 잘리지 않고 원본 비율 기준으로 표시된다.
- `236dp` 고정 높이로 인한 crop은 제거된다.
- 로드 성공 전 임시 기본 높이는 유지하되, 로드 성공 후 원본 비율 높이로 교체된다.
- 이미지 컨테이너의 둥근 모서리는 홈/상세/리스트 화면에서 유지된다.
---
## 9. Technical Constraints
- 신규 코드는 `kr.co.vividnext.sodalive.v2` 하위에 둔다.
- 레거시 파일은 직접 수정하지 않는다.
- 홈 추천/팔로잉 커뮤니티 카드는 공통 `FeedCommunityView``view_feed_community.xml` 변경으로 처리한다.
- 상세 화면은 `CreatorChannelCommunityDetailActivity`와 해당 layout에서 최소 변경한다.
---
## 10. Metrics
- 순수 로직 테스트에서 특정 최대 높이 제한 없는 원본 비율 계산이 통과한다.
- 관련 Gradle 테스트/빌드가 통과한다.
---
## 11. Open Questions
- 현재 추가 확인이 필요한 사항은 없다.
---
## Verification Log
- 2026-07-12: 사용자 요청과 기존 조사 결과를 기준으로 PRD를 작성했다. 구현 전 문서 작성 단계이므로 빌드/테스트는 아직 실행하지 않았다.
- 2026-07-12: 구현 결과로 요청된 3개 위치의 커뮤니티 게시글 이미지 고정 `236dp`/`centerCrop` 표시를 제거하고, 이미지 로드 성공 시 원본 비율 기반 높이를 적용하되 기기 높이의 1/2로 제한하도록 반영했다. 이미지 URL이 없는 유료 잠금/paywall-only 영역은 원본 비율 계산 대상이 아니므로 기존 기본 높이를 유지한다.
- 2026-07-12: 후속 버그 수정 요청에 따라 특정 최대 높이 제한 요구사항을 제거하고, 이미지 로드 전 기본 높이 유지, 컨테이너 clipping 기반 둥근 모서리 유지, 크리에이터 채널 커뮤니티 리스트형 아이템 원본 비율 표시를 추가 범위로 반영했다.