From 2d58a876da9e9e8a486301af1a83d2c0084b960e Mon Sep 17 00:00:00 2001 From: klaus Date: Mon, 15 Jun 2026 17:19:59 +0900 Subject: [PATCH] =?UTF-8?q?feat(creator):=20=EC=B1=84=EB=84=90=20=ED=99=88?= =?UTF-8?q?=20=EC=8B=9C=EB=A6=AC=EC=A6=88,=20=EC=BB=A4=EB=AE=A4=EB=8B=88?= =?UTF-8?q?=ED=8B=B0,=20=ED=8C=AC=20Talk=20=EC=84=B9=EC=85=98=EC=9D=84=20?= =?UTF-8?q?=EC=9E=AC=EA=B5=AC=EC=84=B1=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../channel/data/CreatorChannelHomeModels.kt | 3 - .../model/CreatorChannelHomeMappers.kt | 2 +- .../ui/CreatorChannelFanTalkCardView.kt | 25 ++ .../ui/CreatorChannelHomeSectionAdapter.kt | 240 +++++++++++------- .../ui/CreatorChannelHomeSeriesCardView.kt | 49 ++++ .../home/ui/HomePopularCommunityAdapter.kt | 19 +- .../v2/widget/feed/FeedCommunityView.kt | 36 +-- .../drawable-mdpi/ic_chevron_down_white.png | Bin 0 -> 225 bytes .../res/drawable-mdpi/ic_new_fantalk_plus.png | Bin 0 -> 245 bytes .../bg_creator_channel_community_price.xml | 5 + .../bg_creator_channel_more_button.xml | 8 + .../item_creator_channel_home_community.xml | 27 +- .../item_creator_channel_home_fantalk.xml | 133 +++++++++- .../item_creator_channel_home_series.xml | 30 ++- ...em_creator_channel_home_series_content.xml | 51 ++++ .../main/res/layout/view_feed_community.xml | 67 ++--- app/src/main/res/values-en/strings.xml | 2 + app/src/main/res/values-ja/strings.xml | 2 + app/src/main/res/values/colors.xml | 1 + app/src/main/res/values/strings.xml | 2 + .../CreatorChannelHomeActivitySourceTest.kt | 231 ++++++++++++++++- .../channel/CreatorChannelHomeMapperTest.kt | 7 +- .../main/home/HomeMainFragmentLayoutTest.kt | 35 +-- .../sodalive/v2/widget/feed/FeedViewTest.kt | 34 ++- .../plan-task.md | 24 +- 25 files changed, 791 insertions(+), 242 deletions(-) create mode 100644 app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/ui/CreatorChannelFanTalkCardView.kt create mode 100644 app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/ui/CreatorChannelHomeSeriesCardView.kt create mode 100644 app/src/main/res/drawable-mdpi/ic_chevron_down_white.png create mode 100644 app/src/main/res/drawable-mdpi/ic_new_fantalk_plus.png create mode 100644 app/src/main/res/drawable/bg_creator_channel_community_price.xml create mode 100644 app/src/main/res/drawable/bg_creator_channel_more_button.xml create mode 100644 app/src/main/res/layout/item_creator_channel_home_series_content.xml diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/data/CreatorChannelHomeModels.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/data/CreatorChannelHomeModels.kt index 56eb8c5c..336610fe 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/data/CreatorChannelHomeModels.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/data/CreatorChannelHomeModels.kt @@ -82,11 +82,8 @@ data class CreatorChannelSeriesResponse( @SerializedName("seriesId") val seriesId: Long, @SerializedName("title") val title: String, @SerializedName("coverImageUrl") val coverImageUrl: String, - @SerializedName("publishedDaysOfWeek") val publishedDaysOfWeek: String, - @SerializedName("isComplete") val isComplete: Boolean, @SerializedName("numberOfContent") val numberOfContent: Int, @SerializedName("isNew") val isNew: Boolean, - @SerializedName("isPopular") val isPopular: Boolean, @SerializedName("isOriginal") val isOriginal: Boolean ) diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/model/CreatorChannelHomeMappers.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/model/CreatorChannelHomeMappers.kt index 5f4eb450..86e84259 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/model/CreatorChannelHomeMappers.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/model/CreatorChannelHomeMappers.kt @@ -18,7 +18,7 @@ fun CreatorChannelHomeResponse.toUiContent(): CreatorChannelHomeUiState.Content audioContents.takeIf { it.isNotEmpty() }?.let { add(CreatorChannelHomeSection.AudioContents(it)) } series.takeIf { it.isNotEmpty() }?.let { add(CreatorChannelHomeSection.Series(it)) } communities.takeIf { it.isNotEmpty() }?.let { add(CreatorChannelHomeSection.Communities(it)) } - fanTalk.takeIf { it.totalCount > 0 || it.latestFanTalk != null }?.let { add(CreatorChannelHomeSection.FanTalk(it)) } + add(CreatorChannelHomeSection.FanTalk(fanTalk)) introduce.takeIf { it.isNotBlank() }?.let { add(CreatorChannelHomeSection.Introduce(it)) } add(CreatorChannelHomeSection.Activity(activity)) sns.toUiItems().takeIf { it.isNotEmpty() }?.let { add(CreatorChannelHomeSection.Sns(it)) } diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/ui/CreatorChannelFanTalkCardView.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/ui/CreatorChannelFanTalkCardView.kt new file mode 100644 index 00000000..503bcbc2 --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/ui/CreatorChannelFanTalkCardView.kt @@ -0,0 +1,25 @@ +package kr.co.vividnext.sodalive.v2.creator.channel.ui + +import android.content.Context +import android.graphics.Outline +import android.util.AttributeSet +import android.view.View +import android.view.ViewOutlineProvider +import android.widget.LinearLayout +import kr.co.vividnext.sodalive.R + +class CreatorChannelFanTalkCardView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : LinearLayout(context, attrs, defStyleAttr) { + + init { + clipToOutline = true + outlineProvider = object : ViewOutlineProvider() { + override fun getOutline(view: View, outline: Outline) { + outline.setRoundRect(0, 0, view.width, view.height, resources.getDimension(R.dimen.radius_14)) + } + } + } +} diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/ui/CreatorChannelHomeSectionAdapter.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/ui/CreatorChannelHomeSectionAdapter.kt index 93a7df3d..081aaaad 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/ui/CreatorChannelHomeSectionAdapter.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/ui/CreatorChannelHomeSectionAdapter.kt @@ -5,7 +5,6 @@ import android.view.Gravity import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import android.widget.HorizontalScrollView import android.widget.ImageView import android.widget.LinearLayout import android.widget.TextView @@ -18,11 +17,17 @@ import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.RecyclerView import kr.co.vividnext.sodalive.R import coil.transform.CircleCropTransformation +import kr.co.vividnext.sodalive.common.image.BlurTransformation import kr.co.vividnext.sodalive.extensions.loadUrl import kr.co.vividnext.sodalive.extensions.moneyFormat import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelAudioContentResponse +import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelCommunityPostResponse import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelScheduleResponse +import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelSeriesResponse import kr.co.vividnext.sodalive.v2.creator.channel.model.CreatorChannelHomeSection +import kr.co.vividnext.sodalive.v2.widget.feed.FeedCommunityView +import kr.co.vividnext.sodalive.v2.widget.feed.FeedItem +import kr.co.vividnext.sodalive.v2.widget.feed.FeedSize import java.text.SimpleDateFormat import java.util.Locale import java.util.TimeZone @@ -30,7 +35,8 @@ import kotlin.math.roundToInt class CreatorChannelHomeSectionAdapter( private val onScheduleClick: (CreatorChannelScheduleResponse) -> Unit = {}, - private val onAudioContentClick: (CreatorChannelAudioContentResponse) -> Unit = {} + private val onAudioContentClick: (CreatorChannelAudioContentResponse) -> Unit = {}, + private val onSeriesClick: (CreatorChannelSeriesResponse) -> Unit = {} ) : RecyclerView.Adapter() { private var items: List = emptyList() @@ -44,7 +50,7 @@ class CreatorChannelHomeSectionAdapter( override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SectionViewHolder { val view = LayoutInflater.from(parent.context).inflate(viewType, parent, false) - return SectionViewHolder(view, onScheduleClick, onAudioContentClick) + return SectionViewHolder(view, onScheduleClick, onAudioContentClick, onSeriesClick) } override fun onBindViewHolder(holder: SectionViewHolder, position: Int) { @@ -56,7 +62,8 @@ class CreatorChannelHomeSectionAdapter( class SectionViewHolder( view: View, private val onScheduleClick: (CreatorChannelScheduleResponse) -> Unit, - private val onAudioContentClick: (CreatorChannelAudioContentResponse) -> Unit + private val onAudioContentClick: (CreatorChannelAudioContentResponse) -> Unit, + private val onSeriesClick: (CreatorChannelSeriesResponse) -> Unit ) : RecyclerView.ViewHolder(view) { private val title: TextView? = view.findViewById(R.id.tv_section_title) private val sectionItems: LinearLayout? = view.findViewById(R.id.ll_section_items) @@ -74,6 +81,15 @@ class CreatorChannelHomeSectionAdapter( private val scheduleTimeline: LinearLayout? = view.findViewById(R.id.ll_schedule_timeline) private val scheduleItems: LinearLayout? = view.findViewById(R.id.ll_schedule_items) private val audioContentsRecyclerView: RecyclerView? = view.findViewById(R.id.rv_audio_contents) + private val seriesItems: LinearLayout? = view.findViewById(R.id.ll_series_items) + private val communityItems: LinearLayout? = view.findViewById(R.id.ll_community_items) + private val fanTalkCard: View? = view.findViewById(R.id.layout_fantalk_card) + private val fanTalkTotalRow: View? = view.findViewById(R.id.layout_fantalk_total_row) + private val fanTalkLatestRow: View? = view.findViewById(R.id.layout_fantalk_latest_row) + private val fanTalkEmpty: View? = view.findViewById(R.id.layout_fantalk_empty) + private val fanTalkTotalCount: TextView? = view.findViewById(R.id.tv_fantalk_total_count) + private val fanTalkProfile: ImageView? = view.findViewById(R.id.iv_fantalk_profile) + private val fanTalkContent: TextView? = view.findViewById(R.id.tv_fantalk_content) private val audioContentGridAdapter = AudioContentGridAdapter( itemWidth = calculateCreatorChannelAudioItemWidthDp(itemView.resources.configuration.screenWidthDp).dp(), onAudioContentClick = onAudioContentClick @@ -90,6 +106,8 @@ class CreatorChannelHomeSectionAdapter( noticeItems?.removeAllViews() scheduleTimeline?.removeAllViews() scheduleItems?.removeAllViews() + seriesItems?.removeAllViews() + communityItems?.removeAllViews() when (item) { is CreatorChannelHomeSection.CurrentLive -> bindCurrentLive(item) is CreatorChannelHomeSection.LatestAudioContent -> bindLatestAudioContent(item) @@ -256,54 +274,118 @@ class CreatorChannelHomeSectionAdapter( } private fun bindSeries(item: CreatorChannelHomeSection.Series) { - val row = createHorizontalRow() - item.series.forEach { series -> - row.addView( - createContentTile( - title = series.title, - body = itemView.context.getString( - R.string.creator_channel_series_summary, - series.numberOfContent, - series.publishedDaysOfWeek - ), - imageUrl = series.coverImageUrl, - imageWidth = 163.dp(), - imageHeight = 230.dp() - ) + val visibleSeries = item.series.take(MAX_SERIES_ITEM_COUNT) + visibleSeries.forEachIndexed { index, series -> + val seriesWidthDp = calculateCreatorChannelSeriesCardWidthDp(itemView.resources.configuration.screenWidthDp) + val row = LayoutInflater.from(itemView.context).inflate( + R.layout.item_creator_channel_home_series_content, + seriesItems, + false ) + row.layoutParams = LinearLayout.LayoutParams( + seriesWidthDp.dp(), + LinearLayout.LayoutParams.WRAP_CONTENT + ).apply { + marginEnd = if (index == visibleSeries.lastIndex) 0 else 4.dp() + } + (row as CreatorChannelHomeSeriesCardView).apply { + setThumbnailSize(seriesWidthDp, calculateCreatorChannelSeriesCardHeightDp(seriesWidthDp)) + bind(series) + } + row.setOnClickListener { onSeriesClick(series) } + seriesItems?.addView(row) } - sectionItems?.addView(createHorizontalScrollRow(row)) } private fun bindCommunities(item: CreatorChannelHomeSection.Communities) { - item.communities.forEach { community -> - addFeedCard( - title = community.content, - body = itemView.context.getString( - R.string.creator_channel_community_summary, - community.creatorNickname, - community.likeCount, - community.commentCount - ), - imageUrl = community.imageUrl + val visibleCommunities = item.communities.take(MAX_COMMUNITY_ITEM_COUNT) + visibleCommunities.forEachIndexed { index, community -> + val communityWidthDp = calculateCreatorChannelCommunityCardWidthDp( + itemView.resources.configuration.screenWidthDp ) + val row = LayoutInflater.from(itemView.context).inflate( + R.layout.view_feed_community, + communityItems, + false + ) + (row as FeedCommunityView).apply { + setFeedSize( + FeedSize( + rootWidthDp = communityWidthDp + ) + ) + setHideEmptyTextRows(true) + bind(community.toFeedCommunityItem()) + } + bindCommunityImages(row, community) + row.layoutParams = LinearLayout.LayoutParams( + communityWidthDp.dp(), + LinearLayout.LayoutParams.WRAP_CONTENT + ).apply { + bottomMargin = if (index == visibleCommunities.lastIndex) 0 else 8.dp() + } + communityItems?.addView(row) } } private fun bindFanTalk(item: CreatorChannelHomeSection.FanTalk) { - item.fanTalk.latestFanTalk?.let { fanTalk -> - addCommentCard( - title = fanTalk.content, - body = itemView.context.getString( - R.string.creator_channel_fantalk_summary, - fanTalk.nickname, - item.fanTalk.totalCount - ), - imageUrl = fanTalk.profileImageUrl - ) + fanTalkCard?.layoutParams = fanTalkCard.layoutParams?.apply { + width = calculateCreatorChannelFanTalkCardWidthDp(itemView.resources.configuration.screenWidthDp).dp() + } + fanTalkTotalCount?.text = item.fanTalk.totalCount.toString() + val fanTalk = item.fanTalk.latestFanTalk + fanTalkTotalRow?.isVisible = fanTalk != null && item.fanTalk.totalCount > 0 + fanTalkLatestRow?.isVisible = fanTalk != null && item.fanTalk.totalCount > 0 + fanTalkEmpty?.isVisible = fanTalk == null || item.fanTalk.totalCount <= 0 + if (fanTalk != null) { + fanTalkContent?.text = fanTalk.content + fanTalkProfile?.loadUrl(fanTalk.profileImageUrl) { + placeholder(R.drawable.ic_placeholder_profile) + transformations(CircleCropTransformation()) + } + } else { + fanTalkContent?.text = "" + fanTalkProfile?.setImageResource(R.drawable.ic_placeholder_profile) } } + private fun bindCommunityImages(row: View, community: CreatorChannelCommunityPostResponse) { + val communityRow = row as FeedCommunityView + communityRow.profileImageView().loadUrl(community.creatorProfileUrl) { + placeholder(R.drawable.ic_placeholder_profile) + transformations(CircleCropTransformation()) + } + val isCommunityLocked = community.price > 0 && !community.existOrdered + val communityImageUrl = community.imageUrl.takeIf { !it.isNullOrBlank() } + if (communityImageUrl != null) { + communityRow.communityImageView().loadUrl(communityImageUrl) { + if (isCommunityLocked) { + transformations(BlurTransformation(itemView.context, 25f, 2.5f)) + } + } + } else { + communityRow.communityImageView().setImageDrawable(null) + } + } + + private fun CreatorChannelCommunityPostResponse.toFeedCommunityItem(): FeedItem.Community = FeedItem.Community( + feedId = postId.toString(), + creatorId = creatorId.toString(), + creatorName = creatorNickname, + creatorImageUrl = creatorProfileUrl, + postId = postId.toString(), + bodyText = content, + keywordText = "", + createdAtText = dateUtc, + commentCount = commentCount, + likeCount = likeCount, + imageUrl = imageUrl, + audioUrl = audioUrl, + price = price, + existOrdered = existOrdered, + showKeyword = false + ) + private fun bindIntroduce(item: CreatorChannelHomeSection.Introduce) { addTextCard(title = item.introduce, body = "", imageUrl = null) } @@ -371,26 +453,6 @@ class CreatorChannelHomeSectionAdapter( sectionItems?.addView(card) } - private fun addFeedCard(title: String, body: String, imageUrl: String?) { - val card = LinearLayout(itemView.context).apply { - orientation = LinearLayout.VERTICAL - setPadding(14.dp(), 14.dp(), 14.dp(), 14.dp()) - setBackgroundResource(R.drawable.bg_round_corner_16_7_222222) - layoutParams = defaultBlockLayoutParams() - } - card.addView(createText(body, R.style.Typography_Caption2, R.color.gray_500, maxLines = 1)) - card.addView(createText(title, R.style.Typography_Body2, R.color.white, maxLines = 4)) - card.addView(createImage(imageUrl, LinearLayout.LayoutParams.MATCH_PARENT, 180.dp())) - sectionItems?.addView(card) - } - - private fun addCommentCard(title: String, body: String, imageUrl: String?) { - val card = createHorizontalCard() - card.addView(createImage(imageUrl, 28.dp(), 28.dp())) - card.addView(createTextGroup(title, body, bodyMaxLines = 2)) - sectionItems?.addView(card) - } - private fun createHorizontalCard(): LinearLayout = LinearLayout(itemView.context).apply { orientation = LinearLayout.HORIZONTAL @@ -412,22 +474,6 @@ class CreatorChannelHomeSectionAdapter( return textGroup } - private fun createContentTile( - title: String, - body: String, - imageUrl: String?, - imageWidth: Int, - imageHeight: Int - ): LinearLayout = LinearLayout(itemView.context).apply { - orientation = LinearLayout.VERTICAL - layoutParams = LinearLayout.LayoutParams(imageWidth, LinearLayout.LayoutParams.WRAP_CONTENT).apply { - marginEnd = 12.dp() - } - addView(createImage(imageUrl, imageWidth, imageHeight)) - addView(createText(title, R.style.Typography_Body2, R.color.white, maxLines = 2)) - addView(createText(body, R.style.Typography_Caption2, R.color.gray_500, maxLines = 1)) - } - private fun createImage(imageUrl: String?, width: Int, height: Int): ImageView = ImageView(itemView.context).apply { layoutParams = LinearLayout.LayoutParams(width, height).apply { bottomMargin = 8.dp() @@ -438,19 +484,6 @@ class CreatorChannelHomeSectionAdapter( imageUrl?.let(::loadUrl) } - private fun createHorizontalRow(): LinearLayout = LinearLayout(itemView.context).apply { - orientation = LinearLayout.HORIZONTAL - layoutParams = defaultBlockLayoutParams() - } - - private fun createHorizontalScrollRow(row: LinearLayout): HorizontalScrollView = - HorizontalScrollView(itemView.context).apply { - isHorizontalScrollBarEnabled = false - overScrollMode = View.OVER_SCROLL_NEVER - layoutParams = defaultBlockLayoutParams() - addView(row) - } - private fun defaultBlockLayoutParams(): LinearLayout.LayoutParams = LinearLayout.LayoutParams( LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT @@ -618,6 +651,8 @@ class CreatorChannelHomeSectionAdapter( private const val MAX_NOTICE_ITEM_COUNT = 3 private const val MAX_SCHEDULE_ITEM_COUNT = 3 private const val MAX_AUDIO_ITEM_COUNT = 9 + private const val MAX_SERIES_ITEM_COUNT = 10 + private const val MAX_COMMUNITY_ITEM_COUNT = 3 private const val AUDIO_GRID_SPAN_COUNT = 3 fun List.joinToText(): String = filter(String::isNotBlank).joinToString(separator = " · ") @@ -699,4 +734,33 @@ internal fun calculateCreatorChannelAudioItemWidthDp(screenWidthDp: Int): Int { } } +internal fun calculateCreatorChannelSeriesCardWidthDp(screenWidthDp: Int): Int { + val width = screenWidthDp.takeIf { it > 0 } ?: 402 + return if (width >= 402) { + 163 + } else { + (163f * width / 402f).roundToInt() + } +} + +internal fun calculateCreatorChannelSeriesCardHeightDp(widthDp: Int): Int = (widthDp * 230f / 163f).roundToInt() + +internal fun calculateCreatorChannelCommunityCardWidthDp(screenWidthDp: Int): Int { + val width = screenWidthDp.takeIf { it > 0 } ?: 402 + return if (width >= 402) { + 374 + } else { + (374f * width / 402f).roundToInt() + } +} + +internal fun calculateCreatorChannelFanTalkCardWidthDp(screenWidthDp: Int): Int { + val width = screenWidthDp.takeIf { it > 0 } ?: 402 + return if (width >= 402) { + 374 + } else { + (374f * width / 402f).roundToInt() + } +} + internal fun calculateCreatorChannelScheduleTimelineLineCount(scheduleCount: Int): Int = (scheduleCount - 1).coerceAtLeast(0) diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/ui/CreatorChannelHomeSeriesCardView.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/ui/CreatorChannelHomeSeriesCardView.kt new file mode 100644 index 00000000..cd6e78f3 --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/ui/CreatorChannelHomeSeriesCardView.kt @@ -0,0 +1,49 @@ +package kr.co.vividnext.sodalive.v2.creator.channel.ui + +import android.content.Context +import android.graphics.Outline +import android.util.AttributeSet +import android.view.View +import android.view.ViewGroup +import android.view.ViewOutlineProvider +import android.widget.FrameLayout +import android.widget.ImageView +import androidx.core.view.isVisible +import kr.co.vividnext.sodalive.R +import kr.co.vividnext.sodalive.extensions.loadUrl +import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelSeriesResponse + +class CreatorChannelHomeSeriesCardView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : FrameLayout(context, attrs, defStyleAttr) { + + private val thumbnailContainer: View by lazy { findViewById(R.id.layout_series_thumbnail) } + private val thumbnail: ImageView by lazy { findViewById(R.id.iv_series_thumbnail) } + private val originalTag: View by lazy { findViewById(R.id.layout_series_original_tag) } + + override fun onFinishInflate() { + super.onFinishInflate() + thumbnailContainer.clipToOutline = true + thumbnailContainer.outlineProvider = object : ViewOutlineProvider() { + override fun getOutline(view: View, outline: Outline) { + outline.setRoundRect(0, 0, view.width, view.height, resources.getDimension(R.dimen.radius_14)) + } + } + } + + fun bind(series: CreatorChannelSeriesResponse) { + thumbnail.loadUrl(series.coverImageUrl) + originalTag.isVisible = series.isOriginal + } + + fun setThumbnailSize(widthDp: Int, heightDp: Int) { + thumbnailContainer.layoutParams = thumbnailContainer.layoutParams.apply { + width = widthDp.dp() + height = heightDp.dp() + } ?: ViewGroup.LayoutParams(widthDp.dp(), heightDp.dp()) + } + + private fun Int.dp(): Int = (this * resources.displayMetrics.density).toInt() +} diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/ui/HomePopularCommunityAdapter.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/ui/HomePopularCommunityAdapter.kt index a0890fb4..58faaa81 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/ui/HomePopularCommunityAdapter.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/ui/HomePopularCommunityAdapter.kt @@ -5,6 +5,7 @@ import android.view.ViewGroup import androidx.recyclerview.widget.RecyclerView import coil.dispose import kr.co.vividnext.sodalive.R +import kr.co.vividnext.sodalive.common.image.BlurTransformation import kr.co.vividnext.sodalive.extensions.loadUrl import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationPopularCommunityPostSection import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationPopularCommunityPostUiModel @@ -49,15 +50,27 @@ class HomePopularCommunityAdapter( view.bind(item.item) view.setOnFeedClick { feedItem -> onClickItem(feedItem as FeedItem.Community) } bindImage(item.item.creatorImageUrl, view.profileImageView()) - bindImage(item.item.imageUrl.takeIf { item.item.price <= 0 || item.item.existOrdered }, view.communityImageView()) + bindImage( + imageUrl = item.item.imageUrl, + imageView = view.communityImageView(), + shouldBlur = item.item.price > 0 && !item.item.existOrdered + ) } - private fun bindImage(imageUrl: String?, imageView: android.widget.ImageView) { + private fun bindImage( + imageUrl: String?, + imageView: android.widget.ImageView, + shouldBlur: Boolean = false + ) { if (imageUrl.isNullOrBlank()) { imageView.dispose() imageView.setImageDrawable(null) } else { - imageView.loadUrl(imageUrl) + imageView.loadUrl(imageUrl) { + if (shouldBlur) { + transformations(BlurTransformation(imageView.context, 25f, 2.5f)) + } + } } } } diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/widget/feed/FeedCommunityView.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/widget/feed/FeedCommunityView.kt index 863d92dd..3147cc14 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/v2/widget/feed/FeedCommunityView.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/widget/feed/FeedCommunityView.kt @@ -9,6 +9,7 @@ import android.view.ViewOutlineProvider import android.widget.ImageView import android.widget.LinearLayout import android.widget.TextView +import androidx.core.view.isVisible import kr.co.vividnext.sodalive.R import kotlin.math.roundToInt @@ -22,7 +23,6 @@ class FeedCommunityView @JvmOverloads constructor( private var creatorText: TextView? = null private var createdAtText: TextView? = null private var bodyText: TextView? = null - private var keywordText: TextView? = null private var communityImageContainer: View? = null private var communityImage: ImageView? = null private var paidOverlay: View? = null @@ -39,7 +39,6 @@ class FeedCommunityView @JvmOverloads constructor( creatorText = findViewById(R.id.tv_feed_community_creator) createdAtText = findViewById(R.id.tv_feed_community_created_at) bodyText = findViewById(R.id.tv_feed_community_body) - keywordText = findViewById(R.id.tv_feed_community_keyword) communityImageContainer = findViewById(R.id.fl_feed_community_image_container) communityImage = findViewById(R.id.iv_feed_community_image) paidOverlay = findViewById(R.id.ll_feed_community_paid_overlay) @@ -54,21 +53,20 @@ class FeedCommunityView @JvmOverloads constructor( requireNotNull(communityImageContainer).outlineProvider = roundedOutlineProvider(COMMUNITY_IMAGE_RADIUS_DP) } - override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { - updateCommunityImageHeight(MeasureSpec.getSize(widthMeasureSpec)) - super.onMeasure(widthMeasureSpec, heightMeasureSpec) - } - fun bind(item: FeedItem.Community) { currentItem = item requireNotNull(creatorText).text = item.creatorName requireNotNull(createdAtText).text = item.createdAtText requireNotNull(bodyText).text = item.bodyText - requireNotNull(keywordText).text = item.keywordText requireNotNull(bodyText).visibility = visibilityForText(item.bodyText) - requireNotNull(keywordText).visibility = if (item.showKeyword) visibilityForText(item.keywordText) else View.GONE - requireNotNull(communityImageContainer).visibility = if (item.imageUrl.isNullOrBlank()) View.GONE else View.VISIBLE - requireNotNull(paidOverlay).visibility = if (item.price > 0 && !item.existOrdered) View.VISIBLE else View.GONE + val isLocked = item.price > 0 && !item.existOrdered + val hasImage = !item.imageUrl.isNullOrBlank() + requireNotNull(communityImageContainer).isVisible = hasImage || isLocked + requireNotNull(communityImage).isVisible = hasImage + if (!hasImage || isLocked) { + requireNotNull(communityImage).setImageDrawable(null) + } + requireNotNull(paidOverlay).isVisible = isLocked requireNotNull(priceText).text = item.price.toString() requireNotNull(commentCountText).text = item.commentCount.toString() requireNotNull(likeCountText).text = item.likeCount.toString() @@ -113,20 +111,6 @@ class FeedCommunityView @JvmOverloads constructor( } } - private fun updateCommunityImageHeight(rootWidth: Int) { - val imageWidth = rootWidth - paddingLeft - paddingRight - if (imageWidth <= 0) return - - val container = requireNotNull(communityImageContainer) - val currentLayoutParams = container.layoutParams - val nextHeight = (imageWidth * COMMUNITY_IMAGE_FIGMA_HEIGHT_DP / COMMUNITY_IMAGE_FIGMA_WIDTH_DP.toFloat()) - .roundToInt() - if (currentLayoutParams.height != nextHeight) { - currentLayoutParams.height = nextHeight - container.layoutParams = currentLayoutParams - } - } - private fun circleOutlineProvider() = object : ViewOutlineProvider() { override fun getOutline(view: View, outline: Outline) { outline.setOval(0, 0, view.width, view.height) @@ -144,7 +128,5 @@ class FeedCommunityView @JvmOverloads constructor( private companion object { const val CARD_RADIUS_DP = 14 const val COMMUNITY_IMAGE_RADIUS_DP = 14 - const val COMMUNITY_IMAGE_FIGMA_WIDTH_DP = 346 - const val COMMUNITY_IMAGE_FIGMA_HEIGHT_DP = 236 } } diff --git a/app/src/main/res/drawable-mdpi/ic_chevron_down_white.png b/app/src/main/res/drawable-mdpi/ic_chevron_down_white.png new file mode 100644 index 0000000000000000000000000000000000000000..944534aeb012ea168b67929675e1ffdb49d0fa57 GIT binary patch literal 225 zcmeAS@N?(olHy`uVBq!ia0vp^LLkh+1|-AI^@Rf|&H|6fVg?3oVGw3ym^DWND9BhG z2t4&kKBKxC_t})XTU8bK6yxO3mQf7A1 z?BMtQ{|2A?pS#X7dL4B0Mp?y7h56HOsxB3tb@22wRojX)S3j3^P6VgcKi>V&Vy|0MI6J>QV#`sp ij}i5ijx(5x9x#QT + + + + diff --git a/app/src/main/res/drawable/bg_creator_channel_more_button.xml b/app/src/main/res/drawable/bg_creator_channel_more_button.xml new file mode 100644 index 00000000..dc0ab517 --- /dev/null +++ b/app/src/main/res/drawable/bg_creator_channel_more_button.xml @@ -0,0 +1,8 @@ + + + + + + diff --git a/app/src/main/res/layout/item_creator_channel_home_community.xml b/app/src/main/res/layout/item_creator_channel_home_community.xml index 5648f17d..5ccec5c6 100644 --- a/app/src/main/res/layout/item_creator_channel_home_community.xml +++ b/app/src/main/res/layout/item_creator_channel_home_community.xml @@ -1,25 +1,30 @@ - + + diff --git a/app/src/main/res/layout/item_creator_channel_home_fantalk.xml b/app/src/main/res/layout/item_creator_channel_home_fantalk.xml index 5648f17d..6ad3f858 100644 --- a/app/src/main/res/layout/item_creator_channel_home_fantalk.xml +++ b/app/src/main/res/layout/item_creator_channel_home_fantalk.xml @@ -4,22 +4,131 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" - android:paddingHorizontal="@dimen/spacing_20" android:paddingTop="@dimen/spacing_20"> - + + + android:layout_marginHorizontal="@dimen/spacing_14" + android:layout_marginTop="@dimen/spacing_14" + android:background="@drawable/bg_feed_card" + android:orientation="vertical" + android:padding="@dimen/spacing_14"> - + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/item_creator_channel_home_series.xml b/app/src/main/res/layout/item_creator_channel_home_series.xml index 5648f17d..93a0b056 100644 --- a/app/src/main/res/layout/item_creator_channel_home_series.xml +++ b/app/src/main/res/layout/item_creator_channel_home_series.xml @@ -1,25 +1,27 @@ - + + - - + android:layout_marginTop="@dimen/spacing_14" + android:clipToPadding="false" + android:overScrollMode="never" + android:paddingStart="@dimen/spacing_14" + android:paddingEnd="@dimen/spacing_20" + android:scrollbars="none"> + + diff --git a/app/src/main/res/layout/item_creator_channel_home_series_content.xml b/app/src/main/res/layout/item_creator_channel_home_series_content.xml new file mode 100644 index 00000000..84c9f49e --- /dev/null +++ b/app/src/main/res/layout/item_creator_channel_home_series_content.xml @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/view_feed_community.xml b/app/src/main/res/layout/view_feed_community.xml index 32c4aec0..2b58fede 100644 --- a/app/src/main/res/layout/view_feed_community.xml +++ b/app/src/main/res/layout/view_feed_community.xml @@ -30,7 +30,7 @@ - - + android:maxLines="5" + android:textColor="@color/white" + tools:text="크리에이터가 커뮤니티에 올린 글이 보이는 부분" /> + android:visibility="gone" + tools:visibility="visible"> @@ -94,10 +85,11 @@ android:id="@+id/ll_feed_community_paid_overlay" android:layout_width="match_parent" android:layout_height="match_parent" - android:background="@color/color_99525252" + android:background="@color/color_26909090" android:gravity="center" android:orientation="vertical" - android:visibility="gone"> + android:visibility="gone" + tools:visibility="visible"> - + android:orientation="horizontal"> + + + + + @@ -139,7 +144,7 @@ android:layout_height="wrap_content" android:layout_marginStart="@dimen/spacing_4" android:includeFontPadding="false" - android:textColor="@color/gray_500" + android:textColor="@color/gray_400" tools:text="5" /> diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index d39dc40d..0f3e7c40 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -297,6 +297,8 @@ %1$d episodes · %2$s %1$s · Likes %2$d · Comments %3$d %1$s · Total %2$d + Be the first\nto cheer them on! + Leave support Live %1$d · %2$d hours · %3$d contributors · Audio %4$d · Series %5$d Debut %1$s (%2$s) diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 68d433e8..ba862d6a 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -297,6 +297,8 @@ %1$d件 · %2$s %1$s · いいね %2$d · コメント %3$d %1$s · 全体 %2$d + 最初の応援を\n待っています! + 応援を残す ライブ %1$d回 · 累計 %2$d時間 · 参加者 %3$d人 · オーディオ %4$d件 · シリーズ %5$d件 デビュー %1$s(%2$s) diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index edc45233..e28fe341 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -60,6 +60,7 @@ #CCD85E37 #CC333333 #33FFFFFF + #4DFFFFFF #303030 #555555 #3E1B93 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8dbe709f..4e30531a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -296,6 +296,8 @@ %1$d개 · %2$s %1$s · 좋아요 %2$d · 댓글 %3$d %1$s · 전체 %2$d + 당신의 첫 응원을\n기다리고 있어요! + 응원 남기기 라이브 %1$d회 · 누적 %2$d시간 · 참여자 %3$d명 · 오디오 %4$d개 · 시리즈 %5$d개 데뷔 %1$s(%2$s) diff --git a/app/src/test/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelHomeActivitySourceTest.kt b/app/src/test/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelHomeActivitySourceTest.kt index 921d93f4..cbeec03b 100644 --- a/app/src/test/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelHomeActivitySourceTest.kt +++ b/app/src/test/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelHomeActivitySourceTest.kt @@ -5,6 +5,10 @@ import kr.co.vividnext.sodalive.v2.creator.channel.ui.calculateCreatorChannelDon import kr.co.vividnext.sodalive.v2.creator.channel.ui.calculateCreatorChannelDonationHeaderColorRes import kr.co.vividnext.sodalive.v2.creator.channel.ui.calculateCreatorChannelNoticeCardWidthDp import kr.co.vividnext.sodalive.v2.creator.channel.ui.calculateCreatorChannelAudioItemWidthDp +import kr.co.vividnext.sodalive.v2.creator.channel.ui.calculateCreatorChannelCommunityCardWidthDp +import kr.co.vividnext.sodalive.v2.creator.channel.ui.calculateCreatorChannelFanTalkCardWidthDp +import kr.co.vividnext.sodalive.v2.creator.channel.ui.calculateCreatorChannelSeriesCardHeightDp +import kr.co.vividnext.sodalive.v2.creator.channel.ui.calculateCreatorChannelSeriesCardWidthDp import kr.co.vividnext.sodalive.v2.creator.channel.ui.calculateCreatorChannelScheduleTimelineLineCount import kr.co.vividnext.sodalive.v2.creator.channel.ui.formatCreatorChannelScheduleDate import kr.co.vividnext.sodalive.v2.creator.channel.ui.formatCreatorChannelScheduleDayOfWeek @@ -156,11 +160,11 @@ class CreatorChannelHomeActivitySourceTest { assertTrue(adapter.contains("ll_section_items")) assertTrue(adapter.contains("sectionItems?.addView")) assertTrue(adapter.contains("addActivityRow")) - assertTrue(adapter.contains("createContentTile")) - assertTrue(adapter.contains("addFeedCard")) + assertFalse(adapter.contains("createContentTile")) + assertFalse(adapter.contains("addFeedCard")) assertFalse(adapter.contains("addScheduleRow")) assertFalse(adapter.contains("addDonationCard")) - assertTrue(adapter.contains("addCommentCard")) + assertFalse(adapter.contains("addCommentCard")) assertTrue(adapter.contains("createSnsButton")) assertTrue(adapter.contains("activity.debutDateUtc")) assertTrue(adapter.contains("activity.liveDurationHours")) @@ -382,14 +386,11 @@ class CreatorChannelHomeActivitySourceTest { } @Test - fun `section adapter source는 가로 시리즈와 SNS 링크와 일정 타입 label을 보존한다`() { + fun `section adapter source는 SNS 링크와 일정 타입 label을 보존한다`() { val adapter = projectFile( "app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/ui/CreatorChannelHomeSectionAdapter.kt" ).readText() - assertTrue(adapter.contains("HorizontalScrollView")) - assertTrue(adapter.contains("createHorizontalScrollRow")) - assertTrue(adapter.contains("createHorizontalScrollRow(row)")) assertTrue(adapter.contains("iconResId = sns.iconResId")) assertTrue(adapter.contains("url = sns.url")) assertTrue(adapter.contains("schedule.type.labelResId")) @@ -542,6 +543,217 @@ class CreatorChannelHomeActivitySourceTest { assertTrue(source.contains("putExtra(Constants.EXTRA_AUDIO_CONTENT_ID, audioContent.audioContentId)")) } + @Test + fun `시리즈 섹션은 Figma Contents series size m 카드 row로 렌더링한다`() { + val adapter = projectFile( + "app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/ui/CreatorChannelHomeSectionAdapter.kt" + ).readText() + val seriesLayout = projectFile("app/src/main/res/layout/item_creator_channel_home_series.xml").readText() + val seriesItemLayout = projectFile( + "app/src/main/res/layout/item_creator_channel_home_series_content.xml" + ).readText() + val seriesCardView = projectFile( + "app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/ui/CreatorChannelHomeSeriesCardView.kt" + ).readText() + + assertTrue(seriesLayout.contains("@layout/view_section_title")) + assertTrue(seriesLayout.contains("@+id/hsv_series_items")) + assertTrue(seriesLayout.contains("@+id/ll_series_items")) + assertFalse(seriesLayout.contains("@+id/ll_section_items")) + assertTrue(seriesItemLayout.contains("CreatorChannelHomeSeriesCardView")) + assertTrue(seriesItemLayout.contains("@+id/layout_series_content_card")) + assertTrue(seriesItemLayout.contains("@+id/layout_series_thumbnail")) + assertTrue(seriesItemLayout.contains("@+id/iv_series_thumbnail")) + assertTrue(seriesItemLayout.contains("@+id/layout_series_original_tag")) + assertTrue(seriesItemLayout.contains("@+id/iv_series_original_icon")) + assertTrue(seriesItemLayout.contains("@+id/tv_series_original_text")) + assertTrue(seriesItemLayout.contains("android:text=\"Only\"")) + assertTrue(seriesItemLayout.contains("@font/phosphate_solid")) + assertTrue(seriesItemLayout.contains("android:layout_width=\"70dp\"")) + assertTrue(seriesItemLayout.contains("@drawable/bg_series_original_tag")) + assertTrue(seriesItemLayout.contains("@drawable/ic_series_original")) + assertTrue(seriesItemLayout.contains("android:layout_width=\"163dp\"")) + assertTrue(seriesItemLayout.contains("android:layout_height=\"230dp\"")) + assertTrue(seriesItemLayout.contains("android:layout_width=\"14dp\"")) + assertFalse(seriesItemLayout.contains("android:clipToOutline")) + assertTrue(seriesCardView.contains("clipToOutline = true")) + assertTrue(seriesCardView.contains("ViewOutlineProvider")) + assertTrue(seriesCardView.contains("outline.setRoundRect")) + assertTrue(seriesCardView.contains("R.dimen.radius_14")) + assertTrue(seriesCardView.contains("originalTag.isVisible = series.isOriginal")) + assertTrue(seriesCardView.contains("thumbnail.loadUrl(series.coverImageUrl)")) + assertTrue(seriesCardView.contains("fun setThumbnailSize(widthDp: Int, heightDp: Int)")) + assertTrue(adapter.contains("private val seriesItems: LinearLayout?")) + assertTrue(adapter.contains("val visibleSeries = item.series.take(MAX_SERIES_ITEM_COUNT)")) + assertTrue(adapter.contains("R.layout.item_creator_channel_home_series_content")) + assertTrue(adapter.contains("(row as CreatorChannelHomeSeriesCardView).apply")) + assertTrue(adapter.contains("bind(series)")) + assertTrue(adapter.contains("setThumbnailSize(seriesWidthDp, calculateCreatorChannelSeriesCardHeightDp(seriesWidthDp))")) + assertTrue(adapter.contains("row.setOnClickListener { onSeriesClick(series) }")) + assertTrue(adapter.contains("calculateCreatorChannelSeriesCardWidthDp")) + assertTrue(adapter.contains("private const val MAX_SERIES_ITEM_COUNT = 10")) + assertFalse(adapter.contains("createContentTile(")) + assertFalse(adapter.contains("creator_channel_series_summary")) + } + + @Test + fun `시리즈 DTO와 bind source는 현재 백엔드 필드만 화면에 사용한다`() { + val model = projectFile( + "app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/data/CreatorChannelHomeModels.kt" + ).readText() + val adapter = projectFile( + "app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/ui/CreatorChannelHomeSectionAdapter.kt" + ).readText() + val seriesCardView = projectFile( + "app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/ui/CreatorChannelHomeSeriesCardView.kt" + ).readText() + + assertTrue(model.contains("@SerializedName(\"seriesId\") val seriesId: Long")) + assertTrue(model.contains("@SerializedName(\"coverImageUrl\") val coverImageUrl: String")) + assertTrue(model.contains("@SerializedName(\"isOriginal\") val isOriginal: Boolean")) + assertFalse(model.contains("publishedDaysOfWeek")) + assertFalse(model.contains("isPopular")) + assertFalse(model.contains("isComplete")) + assertTrue(adapter.contains("onSeriesClick(series)")) + assertTrue(seriesCardView.contains("series.coverImageUrl")) + assertTrue(seriesCardView.contains("series.isOriginal")) + assertFalse(seriesCardView.contains("series.numberOfContent")) + assertFalse(seriesCardView.contains("series.isNew")) + } + + @Test + fun `시리즈 카드 width는 402dp 기준 최대 163dp이고 작은 화면에서는 비율 축소한다`() { + assertEquals(163, calculateCreatorChannelSeriesCardWidthDp(402)) + assertEquals(163, calculateCreatorChannelSeriesCardWidthDp(430)) + assertEquals(146, calculateCreatorChannelSeriesCardWidthDp(360)) + assertEquals(230, calculateCreatorChannelSeriesCardHeightDp(163)) + assertEquals(206, calculateCreatorChannelSeriesCardHeightDp(146)) + } + + @Test + fun `커뮤니티 섹션은 Figma feed card 3개와 전체보기 capsule로 렌더링한다`() { + val adapter = projectFile( + "app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/ui/CreatorChannelHomeSectionAdapter.kt" + ).readText() + val communityLayout = projectFile("app/src/main/res/layout/item_creator_channel_home_community.xml").readText() + val communityViewLayout = projectFile("app/src/main/res/layout/view_feed_community.xml").readText() + val feedCommunityView = projectFile( + "app/src/main/java/kr/co/vividnext/sodalive/v2/widget/feed/FeedCommunityView.kt" + ).readText() + + assertTrue(communityLayout.contains("@layout/view_section_title")) + assertTrue(communityLayout.contains("@+id/ll_community_items")) + assertTrue(communityLayout.contains("@+id/layout_community_more_button")) + assertTrue(communityLayout.contains("@string/view_all")) + assertFalse(communityLayout.contains("@+id/ll_section_items")) + assertTrue(communityViewLayout.contains("FeedCommunityView")) + assertTrue(communityViewLayout.contains("android:layout_width=\"match_parent\"")) + assertTrue(communityViewLayout.contains("@+id/fl_feed_community_image_container")) + assertTrue(communityViewLayout.contains("@+id/iv_feed_community_image")) + assertTrue(communityViewLayout.contains("@+id/ll_feed_community_paid_overlay")) + assertTrue(communityViewLayout.contains("@+id/tv_feed_community_price")) + assertTrue(communityViewLayout.contains("@drawable/bg_creator_channel_community_price")) + assertTrue(communityViewLayout.contains("@drawable/ic_bar_cash")) + assertFalse(communityViewLayout.contains("android:drawableStart=\"@drawable/ic_bar_cash\"")) + assertTrue(communityViewLayout.contains("android:layout_marginStart=\"@dimen/spacing_6\"")) + assertFalse(communityViewLayout.contains("android:clipToOutline")) + assertFalse(communityViewLayout.contains("android:layout_height=\"0dp\"")) + assertTrue(feedCommunityView.contains("clipToOutline = true")) + assertTrue(feedCommunityView.contains("ViewOutlineProvider")) + assertTrue(feedCommunityView.contains("outline.setRoundRect")) + assertTrue(adapter.contains("private val communityItems: LinearLayout?")) + assertTrue(adapter.contains("val visibleCommunities = item.communities.take(MAX_COMMUNITY_ITEM_COUNT)")) + assertTrue(adapter.contains("R.layout.view_feed_community")) + assertTrue(adapter.contains("communityItems?.addView(row)")) + assertTrue(adapter.contains("(row as FeedCommunityView).apply")) + assertTrue(adapter.contains("setFeedSize(")) + assertTrue(adapter.contains("FeedSize(")) + assertTrue(adapter.contains("val communityWidthDp = calculateCreatorChannelCommunityCardWidthDp(")) + assertTrue(adapter.contains("rootWidthDp = communityWidthDp")) + assertTrue(adapter.contains("setHideEmptyTextRows(true)")) + assertTrue(adapter.contains("bind(community.toFeedCommunityItem())")) + assertTrue(adapter.contains("bindCommunityImages(row, community)")) + assertTrue(adapter.contains("BlurTransformation(itemView.context, 25f, 2.5f)")) + assertTrue(adapter.contains("row.layoutParams = LinearLayout.LayoutParams(")) + assertTrue(adapter.contains("communityWidthDp.dp()")) + assertTrue(adapter.contains("LinearLayout.LayoutParams.WRAP_CONTENT")) + assertTrue(adapter.contains("communityImageUrl = community.imageUrl.takeIf { !it.isNullOrBlank() }")) + assertTrue(adapter.contains("communityImageView().setImageDrawable(null)")) + assertTrue(adapter.contains("private const val MAX_COMMUNITY_ITEM_COUNT = 3")) + assertFalse(adapter.contains("CreatorChannelCommunityCardView")) + assertFalse(adapter.contains("CreatorChannelCommunityThumbnailView")) + assertFalse(adapter.contains("private fun addFeedCard")) + assertFalse(adapter.contains("addFeedCard(")) + } + + @Test + fun `커뮤니티 컴포넌트 width는 402dp 기준 최대 374dp이고 작은 화면에서는 비율 축소한다`() { + assertEquals(374, calculateCreatorChannelCommunityCardWidthDp(402)) + assertEquals(374, calculateCreatorChannelCommunityCardWidthDp(430)) + assertEquals(335, calculateCreatorChannelCommunityCardWidthDp(360)) + } + + @Test + fun `팬Talk 섹션은 Figma ListComment layout과 bind로 렌더링한다`() { + val adapter = projectFile( + "app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/ui/CreatorChannelHomeSectionAdapter.kt" + ).readText() + val fanTalkLayout = projectFile("app/src/main/res/layout/item_creator_channel_home_fantalk.xml").readText() + val fanTalkCardView = projectFile( + "app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/ui/CreatorChannelFanTalkCardView.kt" + ).readText() + + assertTrue(fanTalkLayout.contains("@layout/view_section_title")) + assertTrue(fanTalkLayout.contains("CreatorChannelFanTalkCardView")) + assertTrue(fanTalkLayout.contains("@+id/layout_fantalk_card")) + assertTrue(fanTalkLayout.contains("@+id/layout_fantalk_total_row")) + assertTrue(fanTalkLayout.contains("@+id/tv_fantalk_total_label")) + assertTrue(fanTalkLayout.contains("@+id/tv_fantalk_total_count")) + assertTrue(fanTalkLayout.contains("@+id/layout_fantalk_latest_row")) + assertTrue(fanTalkLayout.contains("@+id/iv_fantalk_profile")) + assertTrue(fanTalkLayout.contains("@+id/tv_fantalk_content")) + assertTrue(fanTalkLayout.contains("@+id/layout_fantalk_empty")) + assertTrue(fanTalkLayout.contains("@+id/tv_fantalk_empty_title")) + assertTrue(fanTalkLayout.contains("@+id/layout_fantalk_support_button")) + assertTrue(fanTalkLayout.contains("@string/creator_channel_fantalk_empty_title")) + assertTrue(fanTalkLayout.contains("@string/creator_channel_fantalk_support_action")) + assertTrue(fanTalkLayout.contains("@drawable/ic_new_fantalk_plus")) + assertTrue(fanTalkLayout.contains("android:layout_width=\"match_parent\"")) + assertTrue(fanTalkLayout.contains("android:layout_height=\"169dp\"")) + assertTrue(fanTalkLayout.contains("android:layout_marginBottom=\"@dimen/spacing_6\"")) + assertTrue(fanTalkLayout.contains("@drawable/ic_chevron_down_white")) + assertFalse(fanTalkLayout.contains("@+id/ll_section_items")) + assertFalse(fanTalkLayout.contains("android:clipToOutline")) + assertTrue(fanTalkCardView.contains("clipToOutline = true")) + assertTrue(fanTalkCardView.contains("ViewOutlineProvider")) + assertTrue(fanTalkCardView.contains("outline.setRoundRect")) + assertTrue(adapter.contains("private val fanTalkTotalCount: TextView?")) + assertTrue(adapter.contains("private val fanTalkCard: View?")) + assertTrue(adapter.contains("calculateCreatorChannelFanTalkCardWidthDp")) + assertTrue(adapter.contains("private val fanTalkProfile: ImageView?")) + assertTrue(adapter.contains("private val fanTalkContent: TextView?")) + assertTrue(adapter.contains("private val fanTalkTotalRow: View?")) + assertTrue(adapter.contains("private val fanTalkLatestRow: View?")) + assertTrue(adapter.contains("private val fanTalkEmpty: View?")) + assertTrue(adapter.contains("fanTalkTotalCount?.text = item.fanTalk.totalCount.toString()")) + assertTrue(adapter.contains("fanTalkTotalRow?.isVisible = fanTalk != null")) + assertTrue(adapter.contains("fanTalkLatestRow?.isVisible = fanTalk != null")) + assertTrue(adapter.contains("fanTalkEmpty?.isVisible = fanTalk == null")) + assertTrue(adapter.contains("fanTalkContent?.text = fanTalk.content")) + assertTrue(adapter.contains("fanTalkProfile?.loadUrl(fanTalk.profileImageUrl)")) + assertTrue(adapter.contains("fanTalkContent?.text = \"\"")) + assertTrue(adapter.contains("fanTalkProfile?.setImageResource(R.drawable.ic_placeholder_profile)")) + assertFalse(adapter.contains("private fun addCommentCard")) + assertFalse(adapter.contains("addCommentCard(")) + } + + @Test + fun `팬Talk 카드 width는 402dp 기준 최대 374dp이고 작은 화면에서는 비율 축소한다`() { + assertEquals(374, calculateCreatorChannelFanTalkCardWidthDp(402)) + assertEquals(374, calculateCreatorChannelFanTalkCardWidthDp(430)) + assertEquals(335, calculateCreatorChannelFanTalkCardWidthDp(360)) + } + @Test fun `SNS source는 ic_sns 아이콘을 ImageView로 표시한다`() { val uiModel = projectFile( @@ -606,11 +818,8 @@ class CreatorChannelHomeActivitySourceTest { } @Test - fun `section item layouts는 legacy generic card id를 제거하고 동적 컨테이너만 둔다`() { + fun `남은 section item layouts는 legacy generic card id를 제거하고 동적 컨테이너만 둔다`() { val layoutNames = listOf( - "series", - "community", - "fantalk", "introduce", "activity", "sns" diff --git a/app/src/test/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelHomeMapperTest.kt b/app/src/test/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelHomeMapperTest.kt index b2dd0f74..6c9b7a45 100644 --- a/app/src/test/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelHomeMapperTest.kt +++ b/app/src/test/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelHomeMapperTest.kt @@ -52,7 +52,7 @@ class CreatorChannelHomeMapperTest { } @Test - fun `null 단건 콘텐츠와 빈 리스트와 blank SNS는 section을 생성하지 않는다`() { + fun `null 단건 콘텐츠와 빈 리스트와 blank SNS는 팬Talk empty section만 생성한다`() { val content = response( currentLive = null, latestAudioContent = null, @@ -81,7 +81,7 @@ class CreatorChannelHomeMapperTest { assertFalse(content.sections.any { it is CreatorChannelHomeSection.AudioContents }) assertFalse(content.sections.any { it is CreatorChannelHomeSection.Series }) assertFalse(content.sections.any { it is CreatorChannelHomeSection.Communities }) - assertFalse(content.sections.any { it is CreatorChannelHomeSection.FanTalk }) + assertTrue(content.sections.any { it is CreatorChannelHomeSection.FanTalk }) assertFalse(content.sections.any { it is CreatorChannelHomeSection.Introduce }) assertFalse(content.sections.any { it is CreatorChannelHomeSection.Sns }) } @@ -239,11 +239,8 @@ class CreatorChannelHomeMapperTest { seriesId = 1L, title = "시리즈", coverImageUrl = "https://example.com/series.png", - publishedDaysOfWeek = "MON", - isComplete = false, numberOfContent = 3, isNew = true, - isPopular = false, isOriginal = true ) diff --git a/app/src/test/java/kr/co/vividnext/sodalive/v2/main/home/HomeMainFragmentLayoutTest.kt b/app/src/test/java/kr/co/vividnext/sodalive/v2/main/home/HomeMainFragmentLayoutTest.kt index a3cbf6f2..71c46be4 100644 --- a/app/src/test/java/kr/co/vividnext/sodalive/v2/main/home/HomeMainFragmentLayoutTest.kt +++ b/app/src/test/java/kr/co/vividnext/sodalive/v2/main/home/HomeMainFragmentLayoutTest.kt @@ -1088,28 +1088,15 @@ class HomeMainFragmentLayoutTest { } @Test - fun `home popular community adapter does not load original image for locked paid post`() { - val context = ApplicationProvider.getApplicationContext() - val parent = RecyclerView(context) - parent.layoutManager = LinearLayoutManager(context, RecyclerView.VERTICAL, false) - val adapter = HomePopularCommunityAdapter(onClickItem = {}) - val post = popularCommunityPost(1L).copy( - item = popularCommunityPost(1L).item.copy( - imageUrl = "https://example.com/paid.png", - price = 30, - existOrdered = false - ), - paidStatus = HomeRecommendationPaidStatus.Paid(30) - ) - adapter.submitSection(HomeRecommendationPopularCommunityPostSection(listOf(post))) - val viewHolder = adapter.onCreateViewHolder(parent, 0) - val communityImage = viewHolder.itemView.findViewById(R.id.iv_feed_community_image) - communityImage.setImageResource(R.drawable.ic_launcher_background) + fun `home popular community adapter applies blur when locked paid post image is loaded`() { + val source = projectFile( + "app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/ui/HomePopularCommunityAdapter.kt" + ).readText() - adapter.onBindViewHolder(viewHolder, 0) - - assertEquals(null, communityImage.drawable) - assertEquals(View.VISIBLE, viewHolder.itemView.findViewById(R.id.ll_feed_community_paid_overlay).visibility) + assertTrue(source.contains("imageUrl = item.item.imageUrl")) + assertTrue(source.contains("shouldBlur = item.item.price > 0 && !item.item.existOrdered")) + assertTrue(source.contains("BlurTransformation(imageView.context, 25f, 2.5f)")) + assertFalse(source.contains("item.item.imageUrl.takeIf { item.item.price <= 0 || item.item.existOrdered }")) } @Test @@ -1550,6 +1537,12 @@ class HomeMainFragmentLayoutTest { ) } + private fun projectFile(relativePath: String): java.io.File { + val candidates = listOf(java.io.File(relativePath), java.io.File("../$relativePath")) + return candidates.firstOrNull { it.exists() } + ?: error("Project file not found: $relativePath") + } + private fun genreGroup( genre: String, creators: List diff --git a/app/src/test/java/kr/co/vividnext/sodalive/v2/widget/feed/FeedViewTest.kt b/app/src/test/java/kr/co/vividnext/sodalive/v2/widget/feed/FeedViewTest.kt index 1a3435c2..ab2b10a2 100644 --- a/app/src/test/java/kr/co/vividnext/sodalive/v2/widget/feed/FeedViewTest.kt +++ b/app/src/test/java/kr/co/vividnext/sodalive/v2/widget/feed/FeedViewTest.kt @@ -18,8 +18,6 @@ import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config -import kotlin.math.roundToInt - @RunWith(RobolectricTestRunner::class) @Config(sdk = [28], application = Application::class) class FeedViewTest { @@ -45,23 +43,31 @@ class FeedViewTest { } @Test - fun `community can hide empty body and keyword rows by caller policy`() { + fun `community can hide empty body row by caller policy`() { val view = inflateView(R.layout.view_feed_community) view.setHideEmptyTextRows(true) view.bind(sampleCommunityItem(bodyText = "", keywordText = "")) assertEquals(View.GONE, view.findViewById(R.id.tv_feed_community_body).visibility) - assertEquals(View.GONE, view.findViewById(R.id.tv_feed_community_keyword).visibility) } @Test - fun `community keeps keyword visible by default for existing feed usage`() { + fun `community layout matches creator channel feed row structure`() { val view = inflateView(R.layout.view_feed_community) + val layout = projectFile("app/src/main/res/layout/view_feed_community.xml").readText() - view.bind(sampleCommunityItem(bodyText = "본문", keywordText = "#키워드")) - - assertEquals(View.VISIBLE, view.findViewById(R.id.tv_feed_community_keyword).visibility) + assertFalse(layout.contains("@+id/tv_feed_community_keyword")) + assertTrue(layout.contains("android:layout_width=\"match_parent\"")) + assertTrue(layout.contains("android:maxLines=\"5\"")) + assertTrue(layout.contains("android:layout_height=\"236dp\"")) + assertTrue(layout.contains("@drawable/bg_creator_channel_community_price")) + assertTrue(layout.contains("@drawable/ic_bar_cash")) + assertFalse(layout.contains("android:drawableStart=\"@drawable/ic_bar_cash\"")) + assertTrue(layout.contains("android:layout_marginStart=\"@dimen/spacing_6\"")) + assertTrue(layout.contains("@color/gray_400")) + assertFalse(layout.contains("android:layout_height=\"0dp\"")) + assertEquals(5, view.findViewById(R.id.tv_feed_community_body).maxLines) } @Test @@ -88,8 +94,8 @@ class FeedViewTest { ) ) - assertEquals(View.GONE, view.findViewById(R.id.tv_feed_community_keyword).visibility) assertEquals(View.VISIBLE, view.findViewById(R.id.fl_feed_community_image_container).visibility) + assertEquals(View.VISIBLE, view.findViewById(R.id.iv_feed_community_image).visibility) assertEquals(View.VISIBLE, view.findViewById(R.id.ll_feed_community_paid_overlay).visibility) assertEquals("30", view.findViewById(R.id.tv_feed_community_price).text.toString()) } @@ -114,7 +120,7 @@ class FeedViewTest { } @Test - fun `community image container follows measured card width ratio`() { + fun `community image container keeps creator channel fixed height`() { val view = inflateView(R.layout.view_feed_community) val imageContainer = view.findViewById(R.id.fl_feed_community_image_container) @@ -124,7 +130,7 @@ class FeedViewTest { val expectedImageWidth = 402.dpToPx() - view.paddingLeft - view.paddingRight assertEquals(expectedImageWidth, imageContainer.measuredWidth) - assertEquals((expectedImageWidth * 236 / 346f).roundToInt(), imageContainer.measuredHeight) + assertEquals(236.dpToPx(), imageContainer.measuredHeight) assertEquals(true, imageContainer.clipToOutline) assertNotNull(imageContainer.outlineProvider) } @@ -205,6 +211,12 @@ class FeedViewTest { private fun exactly(size: Int): Int = View.MeasureSpec.makeMeasureSpec(size, View.MeasureSpec.EXACTLY) + private fun projectFile(relativePath: String): java.io.File { + val candidates = listOf(java.io.File(relativePath), java.io.File("../$relativePath")) + return candidates.firstOrNull { it.exists() } + ?: error("Project file not found: $relativePath") + } + private fun assertNotEmptyContentDescription(imageView: ImageView) { assertTrue(!imageView.contentDescription.isNullOrEmpty()) } diff --git a/docs/20260611_크리에이터_채널_홈_탭/plan-task.md b/docs/20260611_크리에이터_채널_홈_탭/plan-task.md index ce200e35..eaf2f5a1 100644 --- a/docs/20260611_크리에이터_채널_홈_탭/plan-task.md +++ b/docs/20260611_크리에이터_채널_홈_탭/plan-task.md @@ -650,7 +650,7 @@ - 검증 기록: - 2026-06-15: Figma `296:14958`을 확인해 오디오 컨텐츠 섹션이 `SectionTitle`, 88dp 썸네일, 16sp title, 14sp secondary text를 가진 item row이며, 아이템 최대 width가 402dp 기준 346dp인 구조임을 반영했다. 사용자 요구에 따라 최대 9개만 표시하고, 섹션은 `RecyclerView + GridLayoutManager`로 구성했다. `AudioContentCardView`는 요구사항과 달라 재사용하지 않고 `CreatorChannelHomeAudioContentCardView` 전용 Android View와 `item_creator_channel_home_audio_content.xml`을 추가했다. item 내부는 썸네일 + 텍스트 컬럼의 단순 행 구조라 사용자 피드백에 따라 `GridLayout`이 아니라 `LinearLayout`으로 구성하고, 목록 grid는 `GridLayoutManager`로 유지했다. 아이템 클릭은 `AudioContentDetailActivity`에 `Constants.EXTRA_AUDIO_CONTENT_ID`로 이동하도록 연결했다. RED로 `CreatorChannelHomeActivitySourceTest`에 오디오 전용 layout/View, 최대 9개, 346dp width 비율 계산, `GridLayoutManager`, 상세 이동 계약을 추가했고, 최초 실행에서 `calculateCreatorChannelAudioItemWidthDp` 미구현으로 실패함을 확인했다. 구현 후 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.creator.channel.CreatorChannelHomeActivitySourceTest"`, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.creator.channel.*"`, `./gradlew :app:mergeDebugResources`, `./gradlew :app:compileDebugKotlin`, `./gradlew :app:ktlintCheck`, `git diff --check`가 PASS했다. `compileDebugKotlin` 중 Kotlin daemon cache/파일 경합으로 fallback compile이 발생했으나 최종 빌드는 성공했다. `ktlintCheck`의 `.editorconfig disabled_rules` deprecation 경고와 Kotlin/Gradle deprecation warning은 기존 경고로 이번 변경과 무관하다. -- [ ] **Task 5R.7: 시리즈 섹션 재구성** +- [x] **Task 5R.7: 시리즈 섹션 재구성** - 수정 예정: - `CreatorChannelHomeSectionAdapter.kt` - `item_creator_channel_home_series.xml` @@ -661,12 +661,17 @@ - 작업: - `series`가 empty이면 섹션을 표시하지 않는다. - 시리즈 썸네일은 163dp x 230dp 비율과 radius 14dp를 따른다. - - `isOriginal`, `isNew`, `isPopular`, `isComplete`, `numberOfContent`, `publishedDaysOfWeek`를 Figma tag/label 정책에 맞춰 표시한다. + - 백엔드에서 내려오지 않는 `isPopular`, `isComplete`, `publishedDaysOfWeek`는 표시하지 않고, 현재 응답에서 화면 표시가 가능한 `coverImageUrl`, `isOriginal`만 표시한다. + - item click에 필요한 `seriesId`는 row click callback으로 전달 가능한 구조를 둔다. - 기존 `SeriesContentCardView` 또는 시리즈 카드 layout을 재사용할 수 있는지 먼저 확인한다. - 검증 기준: - 시리즈 섹션은 Figma `Contents` 구조에 대응하는 View/layout으로 렌더링된다. + - 검증 기록: + - 2026-06-15: Figma `296:14970`을 확인해 시리즈 섹션이 `SectionTitle`, 163dp x 230dp `Contents(type=series, size=m)` 썸네일 카드 row 구조임을 반영했다. 사용자 추가 설명에 따라 `CreatorChannelSeriesResponse`에서 현재 화면에 쓰는 값은 `coverImageUrl`, `isOriginal`이고 터치 시 필요한 값은 `seriesId`로 범위를 축소했다. RED로 `CreatorChannelHomeActivitySourceTest`에 시리즈 전용 layout/custom view, radius clipping custom view, 402dp 기준 163dp width 비율 계산, 백엔드 미전송 필드 미사용 계약을 추가했고, 최초 실행에서 기존 adapter의 `publishedDaysOfWeek` 참조로 `compileDebugKotlin` 실패함을 확인했다. 구현 후 focused source test와 creator channel 전체 테스트가 PASS했다. + - 2026-06-15: reviewer gate에서 작은 화면에서 adapter row width는 146dp로 줄지만 내부 썸네일 XML은 163dp x 230dp 고정이라 클리핑/비율 불일치가 발생할 수 있다는 FAIL을 받았다. RED로 `calculateCreatorChannelSeriesCardHeightDp` 미구현 컴파일 실패를 확인한 뒤, `CreatorChannelHomeSeriesCardView.setThumbnailSize(widthDp, heightDp)`와 높이 비율 계산을 추가해 thumbnail width/height를 함께 조정했다. 수정 후 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.creator.channel.CreatorChannelHomeActivitySourceTest"`, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.creator.channel.*"`, `./gradlew :app:mergeDebugResources`, `./gradlew :app:compileDebugKotlin`, `./gradlew :app:ktlintCheck`, `git diff --check`가 PASS했다. + - 2026-06-15: 사용자 후속 확인에서 `item_creator_channel_home_series_content.xml`의 오리지널 태그가 Figma `296:14973`과 다르다는 지적을 반영했다. 기존 단일 `ic_content_tag_original` 이미지를 제거하고 `layout_series_original_tag` 70dp x 24dp 컨테이너 안에 `iv_series_original_icon`과 `tv_series_original_text(Only)`를 배치했다. `CreatorChannelHomeSeriesCardView`는 새 태그 컨테이너 visibility를 `series.isOriginal`로 토글하도록 변경했다. -- [ ] **Task 5R.8: 커뮤니티 섹션 재구성** +- [x] **Task 5R.8: 커뮤니티 섹션 재구성** - 수정 예정: - `CreatorChannelHomeSectionAdapter.kt` - `item_creator_channel_home_community.xml` @@ -680,8 +685,14 @@ - Figma의 `전체보기` capsule 버튼을 표시하되, 이동 대상이 없는 경우 click listener는 후속 범위로 분리한다. - 검증 기준: - 커뮤니티는 단순 `addFeedCard`가 아니라 Figma `Feed` 구조를 가진 View/layout으로 렌더링된다. + - 검증 기록: + - 2026-06-15: Figma `296:14982`를 확인해 커뮤니티 섹션이 `SectionTitle(hasRightIcon=false)`, 374dp feed card 최대 3개, 42dp 프로필, 작성자/날짜, 본문, 선택 이미지, 유료 잠금 overlay, 댓글/좋아요 reaction, `전체보기` capsule 버튼 구조임을 반영했다. RED로 `CreatorChannelHomeActivitySourceTest`에 커뮤니티 전용 layout/row/custom view, 최대 3개, 402dp 기준 374dp width 비율 계산, `addFeedCard` 제거 계약을 추가했고, 구현 후 focused source test와 creator channel 전체 테스트가 PASS했다. + - 2026-06-15: reviewer gate에서 `imageUrl`이 없는 게시글도 236dp 이미지 컨테이너가 남는 문제와 잠긴 유료 이미지가 그대로 `loadUrl` 되는 privacy leak 가능성으로 FAIL을 받았다. RED로 `layout_community_thumbnail` visibility, `hasCommunityImage`, `isCommunityLocked`, 잠김 이미지 미로드/clear 계약을 추가한 뒤, `bindCommunityRow()`에서 이미지 컨테이너는 이미지가 있거나 잠김 상태일 때만 보이게 하고, 잠긴 상태에서는 썸네일을 숨기고 `setImageDrawable(null)`로 초기화하도록 수정했다. 수정 후 focused source test와 creator channel 전체 테스트, 리소스/컴파일/ktlint/diff check가 PASS했다. + - 2026-06-15: 사용자 후속 확인에서 `FeedCommunityView`와 새로 만든 `CreatorChannelCommunityCardView`가 동일 UI이며, 실제로는 `FeedCommunityView`를 `item_creator_channel_home_community_row.xml`와 동일하게 고쳐 재사용해야 한다는 지적을 반영했다. `view_feed_community.xml`을 커뮤니티 row 구조에 맞춰 keyword row 제거, 본문 5줄, 이미지 컨테이너 236dp 고정, `bg_creator_channel_community_price`/`ic_bar_cash`, reaction `gray_400`, root `match_parent` 구조로 수정했다. `CreatorChannelHomeSectionAdapter`는 `R.layout.view_feed_community`를 inflate하고 `FeedItem.Community`로 변환해 `FeedCommunityView`를 바인딩하도록 변경했으며, 더 이상 필요 없는 `CreatorChannelCommunityCardView`, `CreatorChannelCommunityThumbnailView`, `item_creator_channel_home_community_row.xml`는 참조 제거 후 삭제했다. + - 2026-06-15: 사용자 후속 확인에서 `tv_feed_community_price`의 `drawableStart` 방식은 Figma처럼 가격과 아이콘 간격을 정확히 표현하기 어렵다는 지적을 반영했다. 가격 capsule을 `LinearLayout` 안의 별도 `ImageView(ic_bar_cash)`와 `TextView(tv_feed_community_price)` 구조로 바꾸고, 텍스트에 `layout_marginStart=@dimen/spacing_6`을 지정해 아이콘/가격 간격을 명시했다. 미구매 유료 커뮤니티 이미지 blur는 원본 보호 관점에서 클라이언트 blur보다 서버가 비가역 preview/blur 이미지를 별도 URL로 내려주는 방식이 더 안전하다고 판단했다. + - 2026-06-15: 사용자 후속 요청으로 서버가 미구매 유료 커뮤니티 게시글의 `imageUrl`을 blur 처리하지 않고 내려줄 가능성에 대비해 클라이언트에서도 blur를 한 번 더 적용하도록 변경했다. 기존 커뮤니티 목록 `CreatorCommunityAdapter`에서 사용하던 `BlurTransformation(context, 25f, 2.5f)` 패턴을 확인했고, 크리에이터 채널 홈과 홈 추천의 `FeedCommunityView` 이미지 바인딩에도 같은 transformation을 적용했다. 이제 미구매 유료 게시글도 `imageUrl`을 로드하되 이미지 위에 lock overlay를 유지하고, 구매하지 않은 상태에서는 클라이언트 blur fallback을 적용한다. -- [ ] **Task 5R.9: 팬Talk 섹션 재구성** +- [x] **Task 5R.9: 팬Talk 섹션 재구성** - 수정 예정: - `CreatorChannelHomeSectionAdapter.kt` - `item_creator_channel_home_fantalk.xml` @@ -694,6 +705,10 @@ - 최신 팬Talk의 프로필 28dp 원형 이미지, 댓글 본문 2줄 말줄임, chevron-down 아이콘을 표시한다. - 검증 기준: - 팬Talk는 `addCommentCard`가 아니라 `ListComment` 대응 View/layout으로 렌더링된다. + - 검증 기록: + - 2026-06-15: Figma `296:14989`를 확인해 팬Talk 섹션이 `SectionTitle`, 374dp `ListComment` card, `전체` label과 total count, 28dp 프로필, 2줄 말줄임 본문, 18dp chevron-down 구조임을 반영했다. RED로 `CreatorChannelHomeActivitySourceTest`에 팬Talk 전용 layout/custom view, 402dp 기준 374dp width 비율 계산, `addCommentCard` 제거 계약을 추가했고, 구현 후 focused source test와 creator channel 전체 테스트가 PASS했다. `전체` label은 기존 다국어 string `screen_chat_filter_all`을 재사용했다. + - 2026-06-15: reviewer gate에서 `latestFanTalk == null` 리바인드 시 이전 ViewHolder의 프로필/본문이 남을 수 있다는 FAIL을 받았다. RED로 `layout_fantalk_latest_row`, null 시 row 숨김, 본문 빈 문자열, placeholder 초기화 계약을 추가한 뒤, `bindFanTalk()`에서 최신 팬Talk 유무에 따라 row visibility와 값을 명시적으로 초기화했다. 수정 후 focused source test와 creator channel 전체 테스트, 리소스/컴파일/ktlint/diff check가 PASS했다. + - 2026-06-15: 사용자 후속 요청으로 팬Talk가 없을 때 표시할 empty UI Figma `304:17362`를 반영했다. 기존에는 `totalCount=0`, `latestFanTalk=null`이면 FanTalk section을 생성하지 않았지만, empty card 표시를 위해 mapper가 항상 FanTalk section을 포함하도록 변경했다. `item_creator_channel_home_fantalk.xml`에는 `layout_fantalk_empty`를 추가하고, `당신의 첫 응원을\n기다리고 있어요!` 문구와 `ic_new_fantalk_plus` 아이콘, `응원 남기기` 흰색 capsule 버튼을 배치했다. `bindFanTalk()`는 최신 팬Talk가 있으면 total/latest row를 표시하고 empty UI를 숨기며, 없으면 total/latest row를 숨기고 empty UI를 표시하도록 변경했다. - [ ] **Task 5R.10: 소개 섹션 재구성** - 수정 예정: @@ -934,3 +949,4 @@ - 2026-06-14: Phase 5 SNS 후속 리뷰 검증으로 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.creator.channel.CreatorChannelHomeMapperTest"`, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.creator.channel.CreatorChannelHomeActivitySourceTest"`, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.creator.channel.*"`, `./gradlew :app:compileDebugKotlin`, `./gradlew :app:ktlintCheck`, `git diff --check`를 실행했다. 모두 `BUILD SUCCESSFUL` 또는 출력 없음(`git diff --check`)을 확인했다. 첫 RED 테스트 시도는 sandbox의 `~/.gradle` lock 파일 접근 제한으로 실패해 승인 후 재실행했다. `ktlintCheck`의 `.editorconfig disabled_rules` deprecation 경고는 기존 설정 경고로 이번 변경과 무관하다. - 2026-06-15: 사용자 요청에 따라 탭 바 아래 홈 컨텐츠를 전부 삭제 후 Figma `296:14895`의 `SectionTitle`, `Contents`, `Feed`, `ListComment`, `SNS` 컴포넌트 기준으로 다시 구성하는 Phase 5R을 추가했다. 각 홈 섹션을 하나의 Task로 분리했고, 현재 API 응답에 없는 `화보` 섹션은 이번 재구성 구현 범위에서 제외한다고 명시했다. 이번 단계는 문서 수정만 수행했으며 구현/빌드/테스트는 실행하지 않았다. - 2026-06-15: Phase 5R.4 공지 섹션 재구성으로 Figma `296:14915` 기준 `SectionTitle`, `ic_pin` Notice label, 프로필/작성자/날짜, 본문, 선택 썸네일을 가진 공지 전용 가로 feed card를 추가했다. 공지는 최대 3개까지 표시하고, item width는 402dp 기준 최대 346dp 및 작은 화면 비율 축소로 계산한다. RED로 공지 row/custom thumbnail 부재와 기존 374dp width 계산 실패를 확인한 뒤 구현했으며, `CreatorChannelHomeActivitySourceTest` 공지 단일 테스트 2개, `kr.co.vividnext.sodalive.v2.creator.channel.*`, `:app:mergeDebugResources`, `:app:compileDebugKotlin`, `:app:ktlintCheck`, `git diff --check`가 모두 PASS했다. `ktlintCheck`의 `.editorconfig disabled_rules` deprecation 경고는 기존 설정 경고로 이번 변경과 무관하다. +- 2026-06-15: 코드 리뷰에서 커뮤니티 카드가 `setFeedSize(374dp 기준)` 호출 후 `LinearLayout.LayoutParams.MATCH_PARENT`로 덮여 실제 폭 계산이 적용되지 않는다는 지적을 확인했다. `CreatorChannelHomeSectionAdapter.bindCommunities()`에서 `communityWidthDp`를 한 번 계산해 `FeedSize.rootWidthDp`와 row `layoutParams.width`에 동일하게 적용하도록 수정하고, `CreatorChannelHomeActivitySourceTest`가 `communityWidthDp.dp()` 계약을 검증하도록 갱신했다.