diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelHomeActivity.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelHomeActivity.kt index 3342340d..977dd865 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelHomeActivity.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelHomeActivity.kt @@ -26,6 +26,7 @@ import kr.co.vividnext.sodalive.extensions.loadUrl import kr.co.vividnext.sodalive.extensions.moneyFormat import kr.co.vividnext.sodalive.live.room.detail.LiveRoomDetailFragment import kr.co.vividnext.sodalive.v2.common.CreatorActivityType +import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelAudioContentResponse import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelScheduleResponse import kr.co.vividnext.sodalive.v2.creator.channel.model.CreatorChannelHeaderUiModel import kr.co.vividnext.sodalive.v2.creator.channel.model.CreatorChannelHomeUiState @@ -40,7 +41,7 @@ class CreatorChannelHomeActivity : BaseActivity Unit = {} + private val onScheduleClick: (CreatorChannelScheduleResponse) -> Unit = {}, + private val onAudioContentClick: (CreatorChannelAudioContentResponse) -> Unit = {} ) : RecyclerView.Adapter() { private var items: List = emptyList() @@ -42,7 +44,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) + return SectionViewHolder(view, onScheduleClick, onAudioContentClick) } override fun onBindViewHolder(holder: SectionViewHolder, position: Int) { @@ -53,7 +55,8 @@ class CreatorChannelHomeSectionAdapter( class SectionViewHolder( view: View, - private val onScheduleClick: (CreatorChannelScheduleResponse) -> Unit + private val onScheduleClick: (CreatorChannelScheduleResponse) -> Unit, + private val onAudioContentClick: (CreatorChannelAudioContentResponse) -> 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) @@ -70,6 +73,15 @@ class CreatorChannelHomeSectionAdapter( private val noticeItems: LinearLayout? = view.findViewById(R.id.ll_notice_items) 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 audioContentGridAdapter = AudioContentGridAdapter( + itemWidth = calculateCreatorChannelAudioItemWidthDp(itemView.resources.configuration.screenWidthDp).dp(), + onAudioContentClick = onAudioContentClick + ) + + init { + setupAudioContentsRecyclerView() + } fun bind(item: CreatorChannelHomeSection) { title?.setText(item.titleResId) @@ -225,11 +237,22 @@ class CreatorChannelHomeSectionAdapter( } private fun bindAudioContents(item: CreatorChannelHomeSection.AudioContents) { - val row = createHorizontalRow() - item.audioContents.forEach { audioContent -> - row.addView(createAudioTile(audioContent)) + val visibleAudioContents = item.audioContents.take(MAX_AUDIO_ITEM_COUNT) + audioContentGridAdapter.submitItems(visibleAudioContents) + } + + private fun setupAudioContentsRecyclerView() { + audioContentsRecyclerView?.apply { + if (layoutManager == null) { + layoutManager = GridLayoutManager(itemView.context, AUDIO_GRID_SPAN_COUNT, RecyclerView.HORIZONTAL, false) + } + if (adapter == null) { + adapter = audioContentGridAdapter + } + if (itemDecorationCount == 0) { + addItemDecoration(AudioContentGridSpacingDecoration(horizontalSpacing = 8.dp(), verticalSpacing = 8.dp())) + } } - sectionItems?.addView(createHorizontalScrollRow(row)) } private fun bindSeries(item: CreatorChannelHomeSection.Series) { @@ -335,15 +358,6 @@ class CreatorChannelHomeSectionAdapter( sectionItems?.addView(row) } - private fun createAudioTile(audioContent: CreatorChannelAudioContentResponse): LinearLayout = - createContentTile( - title = audioContent.title, - body = listOfNotNull(audioContent.seriesName, audioContent.duration).joinToText(), - imageUrl = audioContent.imageUrl, - imageWidth = 122.dp(), - imageHeight = 122.dp() - ) - private fun addTextCard(title: String, body: String, imageUrl: String?) { val card = LinearLayout(itemView.context).apply { orientation = LinearLayout.HORIZONTAL @@ -504,6 +518,67 @@ class CreatorChannelHomeSectionAdapter( private fun Int.dp(): Int = (this * itemView.resources.displayMetrics.density).toInt() } + private class AudioContentGridAdapter( + private val itemWidth: Int, + private val onAudioContentClick: (CreatorChannelAudioContentResponse) -> Unit + ) : RecyclerView.Adapter() { + + private var items: List = emptyList() + + fun submitItems(items: List) { + this.items = items + notifyDataSetChanged() + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AudioContentViewHolder { + val view = LayoutInflater.from(parent.context).inflate( + R.layout.item_creator_channel_home_audio_content, + parent, + false + ) + view.layoutParams = RecyclerView.LayoutParams(itemWidth, RecyclerView.LayoutParams.WRAP_CONTENT) + return AudioContentViewHolder(view, onAudioContentClick) + } + + override fun onBindViewHolder(holder: AudioContentViewHolder, position: Int) { + holder.bind(items[position]) + } + + override fun getItemCount(): Int = items.size + + class AudioContentViewHolder( + view: View, + private val onAudioContentClick: (CreatorChannelAudioContentResponse) -> Unit + ) : RecyclerView.ViewHolder(view) { + private val card: CreatorChannelHomeAudioContentCardView = view as CreatorChannelHomeAudioContentCardView + + fun bind(audioContent: CreatorChannelAudioContentResponse) { + card.bind(audioContent) + card.setOnClickListener { onAudioContentClick(audioContent) } + } + } + } + + private class AudioContentGridSpacingDecoration( + private val horizontalSpacing: Int, + private val verticalSpacing: Int + ) : RecyclerView.ItemDecoration() { + override fun getItemOffsets( + outRect: android.graphics.Rect, + view: View, + parent: RecyclerView, + state: RecyclerView.State + ) { + val position = parent.getChildAdapterPosition(view) + if (position == RecyclerView.NO_POSITION) return + val itemCount = parent.adapter?.itemCount ?: return + val lastColumnStartPosition = ((itemCount - 1) / AUDIO_GRID_SPAN_COUNT) * AUDIO_GRID_SPAN_COUNT + + outRect.right = if (position >= lastColumnStartPosition) 0 else horizontalSpacing + outRect.bottom = if (position % AUDIO_GRID_SPAN_COUNT == AUDIO_GRID_SPAN_COUNT - 1) 0 else verticalSpacing + } + } + private companion object { @get:LayoutRes val CreatorChannelHomeSection.layoutResId: Int @@ -542,6 +617,8 @@ class CreatorChannelHomeSectionAdapter( private const val MAX_DONATION_ITEM_COUNT = 8 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 AUDIO_GRID_SPAN_COUNT = 3 fun List.joinToText(): String = filter(String::isNotBlank).joinToString(separator = " · ") } @@ -613,4 +690,13 @@ internal fun calculateCreatorChannelNoticeCardWidthDp(screenWidthDp: Int): Int { } } +internal fun calculateCreatorChannelAudioItemWidthDp(screenWidthDp: Int): Int { + val width = screenWidthDp.takeIf { it > 0 } ?: 402 + return if (width >= 402) { + 346 + } else { + (346f * width / 402f).roundToInt() + } +} + internal fun calculateCreatorChannelScheduleTimelineLineCount(scheduleCount: Int): Int = (scheduleCount - 1).coerceAtLeast(0) diff --git a/app/src/main/res/layout/item_creator_channel_home_audio.xml b/app/src/main/res/layout/item_creator_channel_home_audio.xml index 5648f17d..1e087207 100644 --- a/app/src/main/res/layout/item_creator_channel_home_audio.xml +++ b/app/src/main/res/layout/item_creator_channel_home_audio.xml @@ -1,25 +1,20 @@ - + - + android:layout_marginTop="@dimen/spacing_14" + android:clipToPadding="false" + android:overScrollMode="never" + android:paddingHorizontal="@dimen/spacing_14" + android:scrollbars="none" /> 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 839afee7..921d93f4 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 @@ -4,6 +4,7 @@ import kr.co.vividnext.sodalive.R import kr.co.vividnext.sodalive.v2.creator.channel.ui.calculateCreatorChannelDonationCardWidthDp 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.calculateCreatorChannelScheduleTimelineLineCount import kr.co.vividnext.sodalive.v2.creator.channel.ui.formatCreatorChannelScheduleDate import kr.co.vividnext.sodalive.v2.creator.channel.ui.formatCreatorChannelScheduleDayOfWeek @@ -461,7 +462,7 @@ class CreatorChannelHomeActivitySourceTest { "app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelHomeActivity.kt" ).readText() - assertTrue(source.contains("CreatorChannelHomeSectionAdapter(::onScheduleClicked)")) + assertTrue(source.contains("CreatorChannelHomeSectionAdapter(::onScheduleClicked")) assertTrue(source.contains("private fun onScheduleClicked(schedule: CreatorChannelScheduleResponse)")) assertTrue(source.contains("CreatorActivityType.Audio")) assertTrue(source.contains("CreatorActivityType.LiveReplay")) @@ -474,16 +475,71 @@ class CreatorChannelHomeActivitySourceTest { } @Test - fun `section adapter source는 오디오 콘텐츠를 단일 가로 스크롤 row로 표시한다`() { + fun `오디오 컨텐츠 섹션은 전용 row 카드와 RecyclerView grid로 최대 9개를 렌더링한다`() { val adapter = projectFile( "app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/ui/CreatorChannelHomeSectionAdapter.kt" ).readText() + val audioLayout = projectFile("app/src/main/res/layout/item_creator_channel_home_audio.xml").readText() + val audioItemLayout = projectFile( + "app/src/main/res/layout/item_creator_channel_home_audio_content.xml" + ).readText() + val audioCardView = projectFile( + "app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/ui/CreatorChannelHomeAudioContentCardView.kt" + ).readText() + assertTrue(audioLayout.contains("@layout/view_section_title")) + assertTrue(audioLayout.contains("@+id/rv_audio_contents")) + assertFalse(audioLayout.contains("@+id/ll_section_items")) + assertTrue(audioItemLayout.contains("CreatorChannelHomeAudioContentCardView")) + assertTrue(audioItemLayout.contains("")) - assertTrue(adapter.contains("row.addView(createAudioTile(audioContent))")) - assertTrue(adapter.contains("sectionItems?.addView(createHorizontalScrollRow(row))")) - assertFalse(adapter.contains("item.audioContents.forEach { audioContent ->\n addAudioCard(audioContent)")) + assertTrue(adapter.contains("private val audioContentsRecyclerView: RecyclerView?")) + assertTrue(adapter.contains("GridLayoutManager(itemView.context, AUDIO_GRID_SPAN_COUNT, RecyclerView.HORIZONTAL, false)")) + assertTrue(adapter.contains("val visibleAudioContents = item.audioContents.take(MAX_AUDIO_ITEM_COUNT)")) + assertTrue(adapter.contains("private val audioContentGridAdapter = AudioContentGridAdapter(")) + assertTrue(adapter.contains("setupAudioContentsRecyclerView()")) + assertTrue(adapter.contains("audioContentGridAdapter.submitItems(visibleAudioContents)")) + assertTrue(adapter.contains("calculateCreatorChannelAudioItemWidthDp")) + assertTrue(adapter.contains("R.layout.item_creator_channel_home_audio_content")) + assertTrue(adapter.contains("onAudioContentClick(audioContent)")) + assertTrue(adapter.contains("private const val MAX_AUDIO_ITEM_COUNT = 9")) + assertFalse(adapter.contains("adapter = AudioContentGridAdapter(")) + assertFalse(adapter.contains("row.addView(createAudioTile(audioContent))")) + assertFalse(adapter.contains("private fun createAudioTile")) + } + + @Test + fun `오디오 컨텐츠 item width는 402dp 기준 최대 346dp이고 작은 화면에서는 비율 축소한다`() { + assertEquals(346, calculateCreatorChannelAudioItemWidthDp(402)) + assertEquals(346, calculateCreatorChannelAudioItemWidthDp(430)) + assertEquals(310, calculateCreatorChannelAudioItemWidthDp(360)) + } + + @Test + fun `오디오 컨텐츠 클릭은 콘텐츠 상세 이동 계약을 연결한다`() { + val source = projectFile( + "app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelHomeActivity.kt" + ).readText() + + assertTrue(source.contains("CreatorChannelHomeSectionAdapter(::onScheduleClicked, ::onAudioContentClicked)")) + assertTrue(source.contains("private fun onAudioContentClicked(audioContent: CreatorChannelAudioContentResponse)")) + assertTrue(source.contains("AudioContentDetailActivity::class.java")) + assertTrue(source.contains("putExtra(Constants.EXTRA_AUDIO_CONTENT_ID, audioContent.audioContentId)")) } @Test @@ -552,7 +608,6 @@ class CreatorChannelHomeActivitySourceTest { @Test fun `section item layouts는 legacy generic card id를 제거하고 동적 컨테이너만 둔다`() { val layoutNames = listOf( - "audio", "series", "community", "fantalk",