diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/widget/feed/FeedAdapter.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/widget/feed/FeedAdapter.kt index f7a936d7..41fadac2 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/v2/widget/feed/FeedAdapter.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/widget/feed/FeedAdapter.kt @@ -28,8 +28,14 @@ class FeedAdapter( return when (viewType) { VIEW_TYPE_RANK -> RankViewHolder(inflater.inflate(R.layout.view_feed_rank, parent, false) as FeedRankView, parent) VIEW_TYPE_LIVE -> LiveViewHolder(inflater.inflate(R.layout.view_feed_live, parent, false) as FeedLiveView, parent) - VIEW_TYPE_CONTENT -> ContentViewHolder(inflater.inflate(R.layout.view_feed_content, parent, false) as FeedContentView, parent) - VIEW_TYPE_COMMUNITY -> CommunityViewHolder(inflater.inflate(R.layout.view_feed_community, parent, false) as FeedCommunityView, parent) + VIEW_TYPE_CONTENT -> ContentViewHolder( + inflater.inflate(R.layout.view_feed_content, parent, false) as FeedContentView, + parent + ) + VIEW_TYPE_COMMUNITY -> CommunityViewHolder( + inflater.inflate(R.layout.view_feed_community, parent, false) as FeedCommunityView, + parent + ) else -> error("Unknown viewType: $viewType") } } @@ -95,7 +101,7 @@ class FeedAdapter( view.setFeedSize(calculateSize(item.variant, parent)) view.bind(item) view.setOnFeedClick(onClickItem) - onBindImages(FeedImageViews(profile = view.profileImageView()), item) + onBindImages(FeedImageViews(primary = view.communityImageView(), profile = view.profileImageView()), item) } } 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 73bea907..863d92dd 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 @@ -23,6 +23,10 @@ class FeedCommunityView @JvmOverloads constructor( 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 + private var priceText: TextView? = null private var commentCountText: TextView? = null private var likeCountText: TextView? = null private var currentItem: FeedItem.Community? = null @@ -36,10 +40,23 @@ class FeedCommunityView @JvmOverloads constructor( 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) + priceText = findViewById(R.id.tv_feed_community_price) commentCountText = findViewById(R.id.tv_feed_community_comment_count) likeCountText = findViewById(R.id.tv_feed_community_like_count) + clipToOutline = true + outlineProvider = roundedOutlineProvider(CARD_RADIUS_DP) profileImageView().clipToOutline = true profileImageView().outlineProvider = circleOutlineProvider() + requireNotNull(communityImageContainer).clipToOutline = true + 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) { @@ -49,7 +66,10 @@ class FeedCommunityView @JvmOverloads constructor( requireNotNull(bodyText).text = item.bodyText requireNotNull(keywordText).text = item.keywordText requireNotNull(bodyText).visibility = visibilityForText(item.bodyText) - requireNotNull(keywordText).visibility = visibilityForText(item.keywordText) + 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 + requireNotNull(priceText).text = item.price.toString() requireNotNull(commentCountText).text = item.commentCount.toString() requireNotNull(likeCountText).text = item.likeCount.toString() applyClickState(item) @@ -57,6 +77,10 @@ class FeedCommunityView @JvmOverloads constructor( fun profileImageView(): ImageView = requireNotNull(profileImage) + fun communityImageView(): ImageView = requireNotNull(communityImage) + + fun boundItem(): FeedItem.Community? = currentItem + fun setFeedSize(size: FeedSize) { updateRootWidth(size.rootWidthDp.dpToPx()) } @@ -89,11 +113,38 @@ 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) } } + private fun roundedOutlineProvider(radiusDp: Int) = object : ViewOutlineProvider() { + override fun getOutline(view: View, outline: Outline) { + outline.setRoundRect(0, 0, view.width, view.height, radiusDp.dpToPx().toFloat()) + } + } + private fun Int.dpToPx(): Int = (this * resources.displayMetrics.density).roundToInt() + + 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/java/kr/co/vividnext/sodalive/v2/widget/feed/FeedItem.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/widget/feed/FeedItem.kt index f836e3fe..21cbf5a4 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/v2/widget/feed/FeedItem.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/widget/feed/FeedItem.kt @@ -42,6 +42,11 @@ sealed class FeedItem(open val feedId: String, val variant: FeedVariant) { val keywordText: String, val createdAtText: String, val commentCount: Int, - val likeCount: Int + val likeCount: Int, + val imageUrl: String? = null, + val audioUrl: String? = null, + val price: Int = 0, + val existOrdered: Boolean = false, + val showKeyword: Boolean = true ) : FeedItem(feedId, FeedVariant.Community) } diff --git a/app/src/main/res/drawable/bg_feed_community_image.xml b/app/src/main/res/drawable/bg_feed_community_image.xml new file mode 100644 index 00000000..c38ad032 --- /dev/null +++ b/app/src/main/res/drawable/bg_feed_community_image.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/layout/view_feed_community.xml b/app/src/main/res/layout/view_feed_community.xml index 8b9407dd..32c4aec0 100644 --- a/app/src/main/res/layout/view_feed_community.xml +++ b/app/src/main/res/layout/view_feed_community.xml @@ -1,10 +1,9 @@ @@ -75,6 +74,51 @@ android:textColor="@color/soda_400" tools:text="#키워드 #키워드 #키워드" /> + + + + + + + + + + + + (R.id.tv_feed_community_keyword).visibility) } + @Test + fun `community keeps keyword visible by default for existing feed usage`() { + val view = inflateView(R.layout.view_feed_community) + + view.bind(sampleCommunityItem(bodyText = "본문", keywordText = "#키워드")) + + assertEquals(View.VISIBLE, view.findViewById(R.id.tv_feed_community_keyword).visibility) + } + + @Test + fun `community recommendation image is hidden when image url is null`() { + val view = inflateView(R.layout.view_feed_community) + + view.bind(sampleCommunityItem(bodyText = "본문", keywordText = "#키워드", imageUrl = null)) + + assertEquals(View.GONE, view.findViewById(R.id.fl_feed_community_image_container).visibility) + } + + @Test + fun `community paid image shows lock overlay and price capsule only when not purchased`() { + val view = inflateView(R.layout.view_feed_community) + + view.bind( + sampleCommunityItem( + bodyText = "본문", + keywordText = "", + imageUrl = "https://example.com/post.png", + price = 30, + existOrdered = false, + showKeyword = false + ) + ) + + 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.ll_feed_community_paid_overlay).visibility) + assertEquals("30", view.findViewById(R.id.tv_feed_community_price).text.toString()) + } + + @Test + fun `community purchased paid image hides lock overlay and price capsule`() { + val view = inflateView(R.layout.view_feed_community) + + view.bind( + sampleCommunityItem( + bodyText = "본문", + keywordText = "", + imageUrl = "https://example.com/post.png", + price = 30, + existOrdered = true, + showKeyword = false + ) + ) + + assertEquals(View.VISIBLE, view.findViewById(R.id.fl_feed_community_image_container).visibility) + assertEquals(View.GONE, view.findViewById(R.id.ll_feed_community_paid_overlay).visibility) + } + + @Test + fun `community image container follows measured card width ratio`() { + val view = inflateView(R.layout.view_feed_community) + val imageContainer = view.findViewById(R.id.fl_feed_community_image_container) + + view.bind(sampleCommunityItem(bodyText = "본문", keywordText = "", imageUrl = "https://example.com/post.png")) + + view.measure(exactly(402.dpToPx()), View.MeasureSpec.UNSPECIFIED) + + val expectedImageWidth = 402.dpToPx() - view.paddingLeft - view.paddingRight + assertEquals(expectedImageWidth, imageContainer.measuredWidth) + assertEquals((expectedImageWidth * 236 / 346f).roundToInt(), imageContainer.measuredHeight) + assertEquals(true, imageContainer.clipToOutline) + assertNotNull(imageContainer.outlineProvider) + } + @Test fun `rank overlay uses figma overflow position`() { val view = inflateView(R.layout.view_feed_rank) @@ -98,7 +174,12 @@ class FeedViewTest { private fun sampleCommunityItem( bodyText: String, - keywordText: String + keywordText: String, + imageUrl: String? = null, + audioUrl: String? = null, + price: Int = 0, + existOrdered: Boolean = false, + showKeyword: Boolean = true ) = FeedItem.Community( feedId = "feed-community-1", creatorId = "creator-1", @@ -109,7 +190,12 @@ class FeedViewTest { keywordText = keywordText, createdAtText = "2분 전", commentCount = 5, - likeCount = 6 + likeCount = 6, + imageUrl = imageUrl, + audioUrl = audioUrl, + price = price, + existOrdered = existOrdered, + showKeyword = showKeyword ) private fun Int.dpToPx(): Int { @@ -117,6 +203,8 @@ class FeedViewTest { return (this * context.resources.displayMetrics.density).toInt() } + private fun exactly(size: Int): Int = View.MeasureSpec.makeMeasureSpec(size, View.MeasureSpec.EXACTLY) + private fun assertNotEmptyContentDescription(imageView: ImageView) { assertTrue(!imageView.contentDescription.isNullOrEmpty()) }