From a8e0f2377d66c6df7899250c7c635faea3e50676 Mon Sep 17 00:00:00 2001 From: klaus Date: Thu, 21 May 2026 15:53:40 +0900 Subject: [PATCH] =?UTF-8?q?feat(feed):=20=ED=94=BC=EB=93=9C=20=EC=96=B4?= =?UTF-8?q?=EB=8C=91=ED=84=B0=EC=99=80=20=EB=B7=B0=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=EB=A5=BC=20=EC=B6=94=EA=B0=80=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sodalive/v2/widget/feed/FeedAdapter.kt | 120 +++++++++++++++++ .../sodalive/v2/widget/feed/FeedViewTest.kt | 124 ++++++++++++++++++ 2 files changed, 244 insertions(+) create mode 100644 app/src/main/java/kr/co/vividnext/sodalive/v2/widget/feed/FeedAdapter.kt create mode 100644 app/src/test/java/kr/co/vividnext/sodalive/v2/widget/feed/FeedViewTest.kt 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 new file mode 100644 index 00000000..f7a936d7 --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/widget/feed/FeedAdapter.kt @@ -0,0 +1,120 @@ +package kr.co.vividnext.sodalive.v2.widget.feed + +import android.view.LayoutInflater +import android.view.ViewGroup +import android.widget.ImageView +import androidx.recyclerview.widget.RecyclerView +import kr.co.vividnext.sodalive.R +import kotlin.math.roundToInt + +class FeedAdapter( + private val widthMode: FeedWidthMode = FeedWidthMode.FigmaFixed, + private val horizontalItemDecorationDp: Int = 0, + private val onClickItem: (FeedItem) -> Unit, + private val onBindImages: (FeedImageViews, FeedItem) -> Unit = { _, _ -> } +) : RecyclerView.Adapter() { + + private val items = mutableListOf() + + override fun getItemViewType(position: Int): Int = when (items[position].variant) { + FeedVariant.Rank -> VIEW_TYPE_RANK + FeedVariant.Live -> VIEW_TYPE_LIVE + FeedVariant.Content -> VIEW_TYPE_CONTENT + FeedVariant.Community -> VIEW_TYPE_COMMUNITY + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + val inflater = LayoutInflater.from(parent.context) + 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) + else -> error("Unknown viewType: $viewType") + } + } + + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + when (val item = items[position]) { + is FeedItem.Rank -> (holder as RankViewHolder).bind(item) + is FeedItem.Live -> (holder as LiveViewHolder).bind(item) + is FeedItem.Content -> (holder as ContentViewHolder).bind(item) + is FeedItem.Community -> (holder as CommunityViewHolder).bind(item) + } + } + + override fun getItemCount(): Int = items.size + + fun submitItems(items: List) { + this.items.clear() + this.items.addAll(items) + notifyDataSetChanged() + } + + private inner class RankViewHolder( + private val view: FeedRankView, + private val parent: ViewGroup + ) : RecyclerView.ViewHolder(view) { + fun bind(item: FeedItem.Rank) { + view.setFeedSize(calculateSize(item.variant, parent)) + view.bind(item) + view.setOnFeedClick(onClickItem) + onBindImages(FeedImageViews(primary = view.imageView()), item) + } + } + + private inner class LiveViewHolder( + private val view: FeedLiveView, + private val parent: ViewGroup + ) : RecyclerView.ViewHolder(view) { + fun bind(item: FeedItem.Live) { + view.setFeedSize(calculateSize(item.variant, parent)) + view.bind(item) + view.setOnFeedClick(onClickItem) + onBindImages(FeedImageViews(profile = view.profileImageView()), item) + } + } + + private inner class ContentViewHolder( + private val view: FeedContentView, + private val parent: ViewGroup + ) : RecyclerView.ViewHolder(view) { + fun bind(item: FeedItem.Content) { + view.setFeedSize(calculateSize(item.variant, parent)) + view.bind(item) + view.setOnFeedClick(onClickItem) + onBindImages(FeedImageViews(primary = view.contentImageView(), profile = view.profileImageView()), item) + } + } + + private inner class CommunityViewHolder( + private val view: FeedCommunityView, + private val parent: ViewGroup + ) : RecyclerView.ViewHolder(view) { + fun bind(item: FeedItem.Community) { + view.setFeedSize(calculateSize(item.variant, parent)) + view.bind(item) + view.setOnFeedClick(onClickItem) + onBindImages(FeedImageViews(profile = view.profileImageView()), item) + } + } + + private fun calculateSize(variant: FeedVariant, parent: ViewGroup): FeedSize { + val parentWidthPx = parent.width.takeIf { it > 0 } ?: parent.resources.displayMetrics.widthPixels + val availableWidthPx = parentWidthPx - parent.paddingLeft - parent.paddingRight + val availableWidthDp = (availableWidthPx / parent.resources.displayMetrics.density).roundToInt() + return FeedSize.from(variant, widthMode, availableWidthDp, horizontalItemDecorationDp) + } + + companion object { + private const val VIEW_TYPE_RANK = 1 + private const val VIEW_TYPE_LIVE = 2 + private const val VIEW_TYPE_CONTENT = 3 + private const val VIEW_TYPE_COMMUNITY = 4 + } +} + +data class FeedImageViews( + val primary: ImageView? = null, + val profile: ImageView? = null +) 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 new file mode 100644 index 00000000..1d55377d --- /dev/null +++ b/app/src/test/java/kr/co/vividnext/sodalive/v2/widget/feed/FeedViewTest.kt @@ -0,0 +1,124 @@ +package kr.co.vividnext.sodalive.v2.widget.feed + +import android.app.Application +import android.content.Context +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.FrameLayout +import android.widget.ImageView +import android.widget.TextView +import androidx.test.core.app.ApplicationProvider +import kr.co.vividnext.sodalive.R +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], application = Application::class) +class FeedViewTest { + + @Test + fun `live bind displays server provided ended message in one constrained text view`() { + val view = inflateView(R.layout.view_feed_live) + val item = sampleLiveItem(endedMessage = "서버에서 내려온 라이브 종료 문구") + + view.bind(item) + + assertEquals("서버에서 내려온 라이브 종료 문구", view.endedMessageTextView().text.toString()) + } + + @Test + fun `content category tag and time share bottom meta row`() { + val view = inflateView(R.layout.view_feed_content) + val category = view.findViewById(R.id.tv_feed_content_category) + val time = view.findViewById(R.id.tv_feed_content_created_at) + + assertSame(view.findViewById(R.id.ll_feed_content_meta), category.parent) + assertSame(category.parent, time.parent) + } + + @Test + fun `community can hide empty body and keyword rows 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 `rank overlay uses figma overflow position`() { + val view = inflateView(R.layout.view_feed_rank) + val imageContainer = view.findViewById(R.id.fl_feed_rank_image_container) + val overlay = view.findViewById(R.id.tv_feed_rank_overlay) + val params = overlay.layoutParams as FrameLayout.LayoutParams + + assertFalse(imageContainer.clipChildren) + assertFalse(imageContainer.clipToPadding) + assertEquals((-6).dpToPx(), params.marginEnd) + assertEquals((-22).dpToPx(), params.bottomMargin) + } + + @Test + fun `feed content images expose simple accessibility descriptions`() { + val rankView = inflateView(R.layout.view_feed_rank) + val liveView = inflateView(R.layout.view_feed_live) + val contentView = inflateView(R.layout.view_feed_content) + val communityView = inflateView(R.layout.view_feed_community) + + assertNotEmptyContentDescription(rankView.findViewById(R.id.iv_feed_rank_image)) + assertNotEmptyContentDescription(liveView.findViewById(R.id.iv_feed_live_profile)) + assertNotEmptyContentDescription(contentView.findViewById(R.id.iv_feed_content_image)) + assertNotEmptyContentDescription(contentView.findViewById(R.id.iv_feed_content_profile)) + assertNotEmptyContentDescription(communityView.findViewById(R.id.iv_feed_community_profile)) + } + + private inline fun inflateView(layoutResId: Int): T { + val context = ApplicationProvider.getApplicationContext() + return LayoutInflater.from(context).inflate(layoutResId, null, false) as T + } + + private fun sampleLiveItem(endedMessage: String) = FeedItem.Live( + feedId = "feed-live-1", + creatorId = "creator-1", + creatorName = "크리에이터이름", + creatorImageUrl = "https://example.com/profile.png", + liveId = "live-1", + liveTitle = "라이브 방송 이름", + createdAtText = "2분 전", + endedMessage = endedMessage + ) + + private fun sampleCommunityItem( + bodyText: String, + keywordText: String + ) = FeedItem.Community( + feedId = "feed-community-1", + creatorId = "creator-1", + creatorName = "크리에이터 이름", + creatorImageUrl = "https://example.com/profile.png", + postId = "post-1", + bodyText = bodyText, + keywordText = keywordText, + createdAtText = "2분 전", + commentCount = 5, + likeCount = 6 + ) + + private fun Int.dpToPx(): Int { + val context = ApplicationProvider.getApplicationContext() + return (this * context.resources.displayMetrics.density).toInt() + } + + private fun assertNotEmptyContentDescription(imageView: ImageView) { + assertTrue(!imageView.contentDescription.isNullOrEmpty()) + } +}