feat(creator): 채널 홈 오디오 섹션을 재구성한다

This commit is contained in:
2026-06-15 15:01:41 +09:00
parent edb7d98de7
commit 5fde0bc469
4 changed files with 182 additions and 37 deletions

View File

@@ -26,6 +26,7 @@ import kr.co.vividnext.sodalive.extensions.loadUrl
import kr.co.vividnext.sodalive.extensions.moneyFormat import kr.co.vividnext.sodalive.extensions.moneyFormat
import kr.co.vividnext.sodalive.live.room.detail.LiveRoomDetailFragment import kr.co.vividnext.sodalive.live.room.detail.LiveRoomDetailFragment
import kr.co.vividnext.sodalive.v2.common.CreatorActivityType 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.data.CreatorChannelScheduleResponse
import kr.co.vividnext.sodalive.v2.creator.channel.model.CreatorChannelHeaderUiModel import kr.co.vividnext.sodalive.v2.creator.channel.model.CreatorChannelHeaderUiModel
import kr.co.vividnext.sodalive.v2.creator.channel.model.CreatorChannelHomeUiState import kr.co.vividnext.sodalive.v2.creator.channel.model.CreatorChannelHomeUiState
@@ -40,7 +41,7 @@ class CreatorChannelHomeActivity : BaseActivity<ActivityCreatorChannelHomeBindin
) { ) {
private val viewModel: CreatorChannelHomeViewModel by viewModel() private val viewModel: CreatorChannelHomeViewModel by viewModel()
private val sectionAdapter = CreatorChannelHomeSectionAdapter(::onScheduleClicked) private val sectionAdapter = CreatorChannelHomeSectionAdapter(::onScheduleClicked, ::onAudioContentClicked)
private var creatorId: Long = 0L private var creatorId: Long = 0L
private var currentHeader: CreatorChannelHeaderUiModel? = null private var currentHeader: CreatorChannelHeaderUiModel? = null
@@ -235,6 +236,14 @@ class CreatorChannelHomeActivity : BaseActivity<ActivityCreatorChannelHomeBindin
} }
} }
private fun onAudioContentClicked(audioContent: CreatorChannelAudioContentResponse) {
startActivity(
Intent(this, AudioContentDetailActivity::class.java).apply {
putExtra(Constants.EXTRA_AUDIO_CONTENT_ID, audioContent.audioContentId)
}
)
}
private fun showLiveRoomDetail(roomId: Long) { private fun showLiveRoomDetail(roomId: Long) {
val detailFragment = LiveRoomDetailFragment( val detailFragment = LiveRoomDetailFragment(
roomId, roomId,

View File

@@ -14,6 +14,7 @@ import androidx.annotation.LayoutRes
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import kr.co.vividnext.sodalive.R import kr.co.vividnext.sodalive.R
import coil.transform.CircleCropTransformation import coil.transform.CircleCropTransformation
@@ -28,7 +29,8 @@ import java.util.TimeZone
import kotlin.math.roundToInt import kotlin.math.roundToInt
class CreatorChannelHomeSectionAdapter( class CreatorChannelHomeSectionAdapter(
private val onScheduleClick: (CreatorChannelScheduleResponse) -> Unit = {} private val onScheduleClick: (CreatorChannelScheduleResponse) -> Unit = {},
private val onAudioContentClick: (CreatorChannelAudioContentResponse) -> Unit = {}
) : RecyclerView.Adapter<CreatorChannelHomeSectionAdapter.SectionViewHolder>() { ) : RecyclerView.Adapter<CreatorChannelHomeSectionAdapter.SectionViewHolder>() {
private var items: List<CreatorChannelHomeSection> = emptyList() private var items: List<CreatorChannelHomeSection> = emptyList()
@@ -42,7 +44,7 @@ class CreatorChannelHomeSectionAdapter(
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SectionViewHolder { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SectionViewHolder {
val view = LayoutInflater.from(parent.context).inflate(viewType, parent, false) 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) { override fun onBindViewHolder(holder: SectionViewHolder, position: Int) {
@@ -53,7 +55,8 @@ class CreatorChannelHomeSectionAdapter(
class SectionViewHolder( class SectionViewHolder(
view: View, view: View,
private val onScheduleClick: (CreatorChannelScheduleResponse) -> Unit private val onScheduleClick: (CreatorChannelScheduleResponse) -> Unit,
private val onAudioContentClick: (CreatorChannelAudioContentResponse) -> Unit
) : RecyclerView.ViewHolder(view) { ) : RecyclerView.ViewHolder(view) {
private val title: TextView? = view.findViewById(R.id.tv_section_title) private val title: TextView? = view.findViewById(R.id.tv_section_title)
private val sectionItems: LinearLayout? = view.findViewById(R.id.ll_section_items) 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 noticeItems: LinearLayout? = view.findViewById(R.id.ll_notice_items)
private val scheduleTimeline: LinearLayout? = view.findViewById(R.id.ll_schedule_timeline) private val scheduleTimeline: LinearLayout? = view.findViewById(R.id.ll_schedule_timeline)
private val scheduleItems: LinearLayout? = view.findViewById(R.id.ll_schedule_items) 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) { fun bind(item: CreatorChannelHomeSection) {
title?.setText(item.titleResId) title?.setText(item.titleResId)
@@ -225,11 +237,22 @@ class CreatorChannelHomeSectionAdapter(
} }
private fun bindAudioContents(item: CreatorChannelHomeSection.AudioContents) { private fun bindAudioContents(item: CreatorChannelHomeSection.AudioContents) {
val row = createHorizontalRow() val visibleAudioContents = item.audioContents.take(MAX_AUDIO_ITEM_COUNT)
item.audioContents.forEach { audioContent -> audioContentGridAdapter.submitItems(visibleAudioContents)
row.addView(createAudioTile(audioContent)) }
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) { private fun bindSeries(item: CreatorChannelHomeSection.Series) {
@@ -335,15 +358,6 @@ class CreatorChannelHomeSectionAdapter(
sectionItems?.addView(row) 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?) { private fun addTextCard(title: String, body: String, imageUrl: String?) {
val card = LinearLayout(itemView.context).apply { val card = LinearLayout(itemView.context).apply {
orientation = LinearLayout.HORIZONTAL orientation = LinearLayout.HORIZONTAL
@@ -504,6 +518,67 @@ class CreatorChannelHomeSectionAdapter(
private fun Int.dp(): Int = (this * itemView.resources.displayMetrics.density).toInt() 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<AudioContentGridAdapter.AudioContentViewHolder>() {
private var items: List<CreatorChannelAudioContentResponse> = emptyList()
fun submitItems(items: List<CreatorChannelAudioContentResponse>) {
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 { private companion object {
@get:LayoutRes @get:LayoutRes
val CreatorChannelHomeSection.layoutResId: Int val CreatorChannelHomeSection.layoutResId: Int
@@ -542,6 +617,8 @@ class CreatorChannelHomeSectionAdapter(
private const val MAX_DONATION_ITEM_COUNT = 8 private const val MAX_DONATION_ITEM_COUNT = 8
private const val MAX_NOTICE_ITEM_COUNT = 3 private const val MAX_NOTICE_ITEM_COUNT = 3
private const val MAX_SCHEDULE_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<String>.joinToText(): String = filter(String::isNotBlank).joinToString(separator = " · ") fun List<String>.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) internal fun calculateCreatorChannelScheduleTimelineLineCount(scheduleCount: Int): Int = (scheduleCount - 1).coerceAtLeast(0)

View File

@@ -1,25 +1,20 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:orientation="vertical" android:orientation="vertical"
android:paddingHorizontal="@dimen/spacing_20"
android:paddingTop="@dimen/spacing_20"> android:paddingTop="@dimen/spacing_20">
<TextView <include layout="@layout/view_section_title" />
android:id="@+id/tv_section_title"
style="@style/Typography.Heading3"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textColor="@color/white"
tools:text="섹션" />
<LinearLayout <androidx.recyclerview.widget.RecyclerView
android:id="@+id/ll_section_items" android:id="@+id/rv_audio_contents"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="@dimen/spacing_8" android:layout_marginTop="@dimen/spacing_14"
android:orientation="vertical" /> android:clipToPadding="false"
android:overScrollMode="never"
android:paddingHorizontal="@dimen/spacing_14"
android:scrollbars="none" />
</LinearLayout> </LinearLayout>

View File

@@ -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.calculateCreatorChannelDonationCardWidthDp
import kr.co.vividnext.sodalive.v2.creator.channel.ui.calculateCreatorChannelDonationHeaderColorRes 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.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.calculateCreatorChannelScheduleTimelineLineCount
import kr.co.vividnext.sodalive.v2.creator.channel.ui.formatCreatorChannelScheduleDate import kr.co.vividnext.sodalive.v2.creator.channel.ui.formatCreatorChannelScheduleDate
import kr.co.vividnext.sodalive.v2.creator.channel.ui.formatCreatorChannelScheduleDayOfWeek 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" "app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelHomeActivity.kt"
).readText() ).readText()
assertTrue(source.contains("CreatorChannelHomeSectionAdapter(::onScheduleClicked)")) assertTrue(source.contains("CreatorChannelHomeSectionAdapter(::onScheduleClicked"))
assertTrue(source.contains("private fun onScheduleClicked(schedule: CreatorChannelScheduleResponse)")) assertTrue(source.contains("private fun onScheduleClicked(schedule: CreatorChannelScheduleResponse)"))
assertTrue(source.contains("CreatorActivityType.Audio")) assertTrue(source.contains("CreatorActivityType.Audio"))
assertTrue(source.contains("CreatorActivityType.LiveReplay")) assertTrue(source.contains("CreatorActivityType.LiveReplay"))
@@ -474,16 +475,71 @@ class CreatorChannelHomeActivitySourceTest {
} }
@Test @Test
fun `section adapter source는 오디오 텐츠를 단일 가로 스크롤 row로 표시한다`() { fun `오디오 텐츠 섹션은 전용 row 카드와 RecyclerView grid로 최대 9개를 렌더링한다`() {
val adapter = projectFile( val adapter = projectFile(
"app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/ui/CreatorChannelHomeSectionAdapter.kt" "app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/ui/CreatorChannelHomeSectionAdapter.kt"
).readText() ).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("<LinearLayout"))
assertTrue(audioItemLayout.contains("@+id/layout_audio_content_card"))
assertTrue(audioItemLayout.contains("@+id/iv_audio_content_thumbnail"))
assertTrue(audioItemLayout.contains("@+id/iv_audio_content_original_tag"))
assertTrue(audioItemLayout.contains("@+id/tv_audio_content_title"))
assertTrue(audioItemLayout.contains("@+id/tv_audio_content_secondary"))
assertTrue(audioItemLayout.contains("android:layout_width=\"88dp\""))
assertTrue(audioItemLayout.contains("android:layout_height=\"88dp\""))
assertFalse(audioItemLayout.contains("kr.co.vividnext.sodalive.v2.widget.AudioContentCardView"))
assertFalse(audioItemLayout.contains("android:clipToOutline"))
assertFalse(audioCardView.contains("GridLayout"))
assertTrue(audioCardView.contains("clipToOutline = true"))
assertTrue(audioCardView.contains("ViewOutlineProvider"))
assertTrue(audioCardView.contains("outline.setRoundRect"))
assertTrue(audioCardView.contains("R.dimen.radius_14"))
assertTrue(audioCardView.contains("originalTag.isVisible = audioContent.isOriginalSeries == true"))
assertTrue(adapter.contains("private fun bindAudioContents")) assertTrue(adapter.contains("private fun bindAudioContents"))
assertTrue(adapter.contains("item.audioContents.forEach { audioContent ->")) assertTrue(adapter.contains("private val audioContentsRecyclerView: RecyclerView?"))
assertTrue(adapter.contains("row.addView(createAudioTile(audioContent))")) assertTrue(adapter.contains("GridLayoutManager(itemView.context, AUDIO_GRID_SPAN_COUNT, RecyclerView.HORIZONTAL, false)"))
assertTrue(adapter.contains("sectionItems?.addView(createHorizontalScrollRow(row))")) assertTrue(adapter.contains("val visibleAudioContents = item.audioContents.take(MAX_AUDIO_ITEM_COUNT)"))
assertFalse(adapter.contains("item.audioContents.forEach { audioContent ->\n addAudioCard(audioContent)")) 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 @Test
@@ -552,7 +608,6 @@ class CreatorChannelHomeActivitySourceTest {
@Test @Test
fun `section item layouts는 legacy generic card id를 제거하고 동적 컨테이너만 둔다`() { fun `section item layouts는 legacy generic card id를 제거하고 동적 컨테이너만 둔다`() {
val layoutNames = listOf( val layoutNames = listOf(
"audio",
"series", "series",
"community", "community",
"fantalk", "fantalk",