feat(widget): 커뮤니티 이미지와 유료 overlay를 추가한다

This commit is contained in:
2026-06-05 13:16:47 +09:00
parent cd274a6d2f
commit 5b3b7c72d2
6 changed files with 208 additions and 9 deletions

View File

@@ -28,8 +28,14 @@ class FeedAdapter(
return when (viewType) { return when (viewType) {
VIEW_TYPE_RANK -> RankViewHolder(inflater.inflate(R.layout.view_feed_rank, parent, false) as FeedRankView, parent) 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_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_CONTENT -> ContentViewHolder(
VIEW_TYPE_COMMUNITY -> CommunityViewHolder(inflater.inflate(R.layout.view_feed_community, parent, false) as FeedCommunityView, parent) 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") else -> error("Unknown viewType: $viewType")
} }
} }
@@ -95,7 +101,7 @@ class FeedAdapter(
view.setFeedSize(calculateSize(item.variant, parent)) view.setFeedSize(calculateSize(item.variant, parent))
view.bind(item) view.bind(item)
view.setOnFeedClick(onClickItem) view.setOnFeedClick(onClickItem)
onBindImages(FeedImageViews(profile = view.profileImageView()), item) onBindImages(FeedImageViews(primary = view.communityImageView(), profile = view.profileImageView()), item)
} }
} }

View File

@@ -23,6 +23,10 @@ class FeedCommunityView @JvmOverloads constructor(
private var createdAtText: TextView? = null private var createdAtText: TextView? = null
private var bodyText: TextView? = null private var bodyText: TextView? = null
private var keywordText: 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 commentCountText: TextView? = null
private var likeCountText: TextView? = null private var likeCountText: TextView? = null
private var currentItem: FeedItem.Community? = null private var currentItem: FeedItem.Community? = null
@@ -36,10 +40,23 @@ class FeedCommunityView @JvmOverloads constructor(
createdAtText = findViewById(R.id.tv_feed_community_created_at) createdAtText = findViewById(R.id.tv_feed_community_created_at)
bodyText = findViewById(R.id.tv_feed_community_body) bodyText = findViewById(R.id.tv_feed_community_body)
keywordText = findViewById(R.id.tv_feed_community_keyword) 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) commentCountText = findViewById(R.id.tv_feed_community_comment_count)
likeCountText = findViewById(R.id.tv_feed_community_like_count) likeCountText = findViewById(R.id.tv_feed_community_like_count)
clipToOutline = true
outlineProvider = roundedOutlineProvider(CARD_RADIUS_DP)
profileImageView().clipToOutline = true profileImageView().clipToOutline = true
profileImageView().outlineProvider = circleOutlineProvider() 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) { fun bind(item: FeedItem.Community) {
@@ -49,7 +66,10 @@ class FeedCommunityView @JvmOverloads constructor(
requireNotNull(bodyText).text = item.bodyText requireNotNull(bodyText).text = item.bodyText
requireNotNull(keywordText).text = item.keywordText requireNotNull(keywordText).text = item.keywordText
requireNotNull(bodyText).visibility = visibilityForText(item.bodyText) 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(commentCountText).text = item.commentCount.toString()
requireNotNull(likeCountText).text = item.likeCount.toString() requireNotNull(likeCountText).text = item.likeCount.toString()
applyClickState(item) applyClickState(item)
@@ -57,6 +77,10 @@ class FeedCommunityView @JvmOverloads constructor(
fun profileImageView(): ImageView = requireNotNull(profileImage) fun profileImageView(): ImageView = requireNotNull(profileImage)
fun communityImageView(): ImageView = requireNotNull(communityImage)
fun boundItem(): FeedItem.Community? = currentItem
fun setFeedSize(size: FeedSize) { fun setFeedSize(size: FeedSize) {
updateRootWidth(size.rootWidthDp.dpToPx()) 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() { private fun circleOutlineProvider() = object : ViewOutlineProvider() {
override fun getOutline(view: View, outline: Outline) { override fun getOutline(view: View, outline: Outline) {
outline.setOval(0, 0, view.width, view.height) 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 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
}
} }

View File

@@ -42,6 +42,11 @@ sealed class FeedItem(open val feedId: String, val variant: FeedVariant) {
val keywordText: String, val keywordText: String,
val createdAtText: String, val createdAtText: String,
val commentCount: Int, 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) ) : FeedItem(feedId, FeedVariant.Community)
} }

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="@color/gray_900" />
<corners android:radius="14dp" />
</shape>

View File

@@ -1,10 +1,9 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<kr.co.vividnext.sodalive.v2.widget.feed.FeedCommunityView xmlns:android="http://schemas.android.com/apk/res/android" <kr.co.vividnext.sodalive.v2.widget.feed.FeedCommunityView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
android:layout_width="374dp" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:background="@drawable/bg_feed_card" android:background="@drawable/bg_feed_card"
android:clipToOutline="true"
android:orientation="vertical" android:orientation="vertical"
android:padding="@dimen/spacing_14"> android:padding="@dimen/spacing_14">
@@ -75,6 +74,51 @@
android:textColor="@color/soda_400" android:textColor="@color/soda_400"
tools:text="#키워드 #키워드 #키워드" /> tools:text="#키워드 #키워드 #키워드" />
<FrameLayout
android:id="@+id/fl_feed_community_image_container"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_marginTop="@dimen/spacing_14"
android:background="@drawable/bg_feed_community_image"
android:visibility="gone">
<ImageView
android:id="@+id/iv_feed_community_image"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:contentDescription="@string/a11y_feed_content_image"
android:scaleType="centerCrop"
tools:src="@drawable/ic_launcher_background" />
<LinearLayout
android:id="@+id/ll_feed_community_paid_overlay"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/color_99525252"
android:gravity="center"
android:orientation="vertical"
android:visibility="gone">
<ImageView
android:layout_width="24dp"
android:layout_height="24dp"
android:contentDescription="@null"
android:src="@drawable/ic_new_community_lock" />
<TextView
android:id="@+id/tv_feed_community_price"
style="@style/Typography.Body3"
android:layout_width="70dp"
android:layout_height="36dp"
android:layout_marginTop="@dimen/spacing_4"
android:background="@drawable/bg_round_corner_999_white"
android:gravity="center"
android:includeFontPadding="false"
android:textColor="@color/black"
tools:text="30" />
</LinearLayout>
</FrameLayout>
<LinearLayout <LinearLayout
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="24dp" android:layout_height="24dp"

View File

@@ -11,12 +11,14 @@ import androidx.test.core.app.ApplicationProvider
import kr.co.vividnext.sodalive.R import kr.co.vividnext.sodalive.R
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertSame import org.junit.Assert.assertSame
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config import org.robolectric.annotation.Config
import kotlin.math.roundToInt
@RunWith(RobolectricTestRunner::class) @RunWith(RobolectricTestRunner::class)
@Config(sdk = [28], application = Application::class) @Config(sdk = [28], application = Application::class)
@@ -53,6 +55,80 @@ class FeedViewTest {
assertEquals(View.GONE, view.findViewById<TextView>(R.id.tv_feed_community_keyword).visibility) assertEquals(View.GONE, view.findViewById<TextView>(R.id.tv_feed_community_keyword).visibility)
} }
@Test
fun `community keeps keyword visible by default for existing feed usage`() {
val view = inflateView<FeedCommunityView>(R.layout.view_feed_community)
view.bind(sampleCommunityItem(bodyText = "본문", keywordText = "#키워드"))
assertEquals(View.VISIBLE, view.findViewById<TextView>(R.id.tv_feed_community_keyword).visibility)
}
@Test
fun `community recommendation image is hidden when image url is null`() {
val view = inflateView<FeedCommunityView>(R.layout.view_feed_community)
view.bind(sampleCommunityItem(bodyText = "본문", keywordText = "#키워드", imageUrl = null))
assertEquals(View.GONE, view.findViewById<FrameLayout>(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<FeedCommunityView>(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<TextView>(R.id.tv_feed_community_keyword).visibility)
assertEquals(View.VISIBLE, view.findViewById<FrameLayout>(R.id.fl_feed_community_image_container).visibility)
assertEquals(View.VISIBLE, view.findViewById<View>(R.id.ll_feed_community_paid_overlay).visibility)
assertEquals("30", view.findViewById<TextView>(R.id.tv_feed_community_price).text.toString())
}
@Test
fun `community purchased paid image hides lock overlay and price capsule`() {
val view = inflateView<FeedCommunityView>(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<FrameLayout>(R.id.fl_feed_community_image_container).visibility)
assertEquals(View.GONE, view.findViewById<View>(R.id.ll_feed_community_paid_overlay).visibility)
}
@Test
fun `community image container follows measured card width ratio`() {
val view = inflateView<FeedCommunityView>(R.layout.view_feed_community)
val imageContainer = view.findViewById<FrameLayout>(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 @Test
fun `rank overlay uses figma overflow position`() { fun `rank overlay uses figma overflow position`() {
val view = inflateView<FeedRankView>(R.layout.view_feed_rank) val view = inflateView<FeedRankView>(R.layout.view_feed_rank)
@@ -98,7 +174,12 @@ class FeedViewTest {
private fun sampleCommunityItem( private fun sampleCommunityItem(
bodyText: String, bodyText: String,
keywordText: String keywordText: String,
imageUrl: String? = null,
audioUrl: String? = null,
price: Int = 0,
existOrdered: Boolean = false,
showKeyword: Boolean = true
) = FeedItem.Community( ) = FeedItem.Community(
feedId = "feed-community-1", feedId = "feed-community-1",
creatorId = "creator-1", creatorId = "creator-1",
@@ -109,7 +190,12 @@ class FeedViewTest {
keywordText = keywordText, keywordText = keywordText,
createdAtText = "2분 전", createdAtText = "2분 전",
commentCount = 5, commentCount = 5,
likeCount = 6 likeCount = 6,
imageUrl = imageUrl,
audioUrl = audioUrl,
price = price,
existOrdered = existOrdered,
showKeyword = showKeyword
) )
private fun Int.dpToPx(): Int { private fun Int.dpToPx(): Int {
@@ -117,6 +203,8 @@ class FeedViewTest {
return (this * context.resources.displayMetrics.density).toInt() 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) { private fun assertNotEmptyContentDescription(imageView: ImageView) {
assertTrue(!imageView.contentDescription.isNullOrEmpty()) assertTrue(!imageView.contentDescription.isNullOrEmpty())
} }