feat(creator): 시리즈 탭 UI를 구현한다

This commit is contained in:
2026-06-20 04:50:30 +09:00
parent 7ea06fda2f
commit 92cea6d3ee
5 changed files with 717 additions and 0 deletions

View File

@@ -0,0 +1,182 @@
package kr.co.vividnext.sodalive.v2.creator.channel.series
import android.os.Bundle
import android.view.View
import android.widget.Toast
import androidx.core.view.isVisible
import androidx.recyclerview.widget.LinearLayoutManager
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.base.BaseFragment
import kr.co.vividnext.sodalive.databinding.FragmentCreatorChannelSeriesBinding
import kr.co.vividnext.sodalive.extensions.moneyFormat
import kr.co.vividnext.sodalive.v2.creator.channel.model.toLabelResId
import kr.co.vividnext.sodalive.v2.creator.channel.series.ui.CreatorChannelSeriesAdapter
import kr.co.vividnext.sodalive.v2.creator.channel.ui.CreatorChannelSortPopup
import org.koin.androidx.viewmodel.ext.android.viewModel
class CreatorChannelSeriesFragment : BaseFragment<FragmentCreatorChannelSeriesBinding>(
FragmentCreatorChannelSeriesBinding::inflate
) {
private val viewModel: CreatorChannelSeriesViewModel by viewModel()
private val seriesAdapter = CreatorChannelSeriesAdapter { seriesId ->
host.onCreatorChannelSeriesClicked(seriesId)
}
private var sortPopup: CreatorChannelSortPopup? = null
private var currentContentState: CreatorChannelSeriesUiState.Content? = null
private var lastContentLayoutKey: CreatorChannelSeriesContentLayoutKey? = null
private val creatorId: Long by lazy { arguments?.getLong(ARG_CREATOR_ID) ?: 0L }
private val host: Host
get() = requireActivity() as Host
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
bindLoading()
setupSeriesList()
setupClickListeners()
observeViewModel()
}
override fun onDestroyView() {
sortPopup?.dismiss()
sortPopup = null
currentContentState = null
lastContentLayoutKey = null
binding.rvCreatorChannelSeries.adapter = null
super.onDestroyView()
}
private fun setupSeriesList() = with(binding.rvCreatorChannelSeries) {
layoutManager = LinearLayoutManager(requireContext())
adapter = seriesAdapter
}
private fun setupClickListeners() = with(binding) {
ivCreatorChannelSeriesSort.setImageResource(R.drawable.ic_new_sort)
layoutCreatorChannelSeriesSortButton.setOnClickListener {
currentContentState?.let { state -> showSortPopup(state) }
}
btnCreatorChannelSeriesRetry.setOnClickListener {
viewModel.retrySeries()
}
}
private fun observeViewModel() {
viewModel.seriesStateLiveData.observe(viewLifecycleOwner) { state ->
when (state) {
CreatorChannelSeriesUiState.Loading -> bindLoading()
CreatorChannelSeriesUiState.Empty -> bindEmpty()
is CreatorChannelSeriesUiState.Error -> bindError(state)
is CreatorChannelSeriesUiState.Content -> bindContent(state)
}
}
}
fun onCreatorChannelSeriesTabSelected() {
if (creatorId > 0L) {
viewModel.loadSeries(creatorId, isOwner = host.isCreatorChannelOwner())
}
}
fun onCreatorChannelSeriesScrolledToBottom() {
viewModel.loadMore()
}
@Suppress("UNUSED_PARAMETER")
fun onCreatorChannelSeriesViewportHeightChanged(minHeight: Int) = Unit
private fun bindLoading() = with(binding) {
currentContentState = null
lastContentLayoutKey = null
layoutCreatorChannelSeriesSortBar.isVisible = false
rvCreatorChannelSeries.isVisible = false
layoutCreatorChannelSeriesEmpty.isVisible = false
tvCreatorChannelSeriesErrorMessage.isVisible = false
btnCreatorChannelSeriesRetry.isVisible = false
}
private fun bindEmpty() = with(binding) {
currentContentState = null
lastContentLayoutKey = null
layoutCreatorChannelSeriesSortBar.isVisible = false
rvCreatorChannelSeries.isVisible = false
layoutCreatorChannelSeriesEmpty.isVisible = true
tvCreatorChannelSeriesErrorMessage.isVisible = false
btnCreatorChannelSeriesRetry.isVisible = false
host.onCreatorChannelSeriesContentChanged()
}
private fun bindError(state: CreatorChannelSeriesUiState.Error) = with(binding) {
currentContentState = null
lastContentLayoutKey = null
layoutCreatorChannelSeriesSortBar.isVisible = false
rvCreatorChannelSeries.isVisible = false
layoutCreatorChannelSeriesEmpty.isVisible = false
tvCreatorChannelSeriesErrorMessage.isVisible = true
tvCreatorChannelSeriesErrorMessage.text = state.message ?: getString(R.string.creator_channel_series_error_message)
btnCreatorChannelSeriesRetry.isVisible = true
host.onCreatorChannelSeriesContentChanged()
}
private fun bindContent(state: CreatorChannelSeriesUiState.Content) = with(binding) {
currentContentState = state
layoutCreatorChannelSeriesSortBar.isVisible = true
tvCreatorChannelSeriesTotalCount.text = state.seriesCount.moneyFormat()
tvCreatorChannelSeriesSortLabel.setText(state.selectedSort.toLabelResId())
rvCreatorChannelSeries.isVisible = true
seriesAdapter.submitItems(state.series)
layoutCreatorChannelSeriesEmpty.isVisible = false
tvCreatorChannelSeriesErrorMessage.isVisible = false
btnCreatorChannelSeriesRetry.isVisible = false
notifyContentChangedIfLayoutChanged(state)
state.paginationErrorMessage?.let {
Toast.makeText(requireContext(), it, Toast.LENGTH_SHORT).show()
viewModel.consumePaginationErrorMessage()
}
}
private fun notifyContentChangedIfLayoutChanged(state: CreatorChannelSeriesUiState.Content) {
val contentLayoutKey = state.toContentLayoutKey()
if (contentLayoutKey == lastContentLayoutKey) return
lastContentLayoutKey = contentLayoutKey
host.onCreatorChannelSeriesContentChanged()
}
private fun showSortPopup(state: CreatorChannelSeriesUiState.Content) {
sortPopup?.dismiss()
sortPopup = CreatorChannelSortPopup(
anchor = binding.layoutCreatorChannelSeriesSortButton,
selectedSort = state.selectedSort,
onSortSelected = { sort -> viewModel.changeSort(sort) }
).also { it.show() }
}
interface Host {
fun isCreatorChannelOwner(): Boolean
fun onCreatorChannelSeriesClicked(seriesId: Long)
fun onCreatorChannelSeriesContentChanged()
}
companion object {
private const val ARG_CREATOR_ID: String = "arg_creator_id"
fun newInstance(creatorId: Long): CreatorChannelSeriesFragment {
return CreatorChannelSeriesFragment().apply {
arguments = Bundle().apply { putLong(ARG_CREATOR_ID, creatorId) }
}
}
}
}
private data class CreatorChannelSeriesContentLayoutKey(
val seriesCount: Int,
val seriesIds: List<Long>
)
private fun CreatorChannelSeriesUiState.Content.toContentLayoutKey(): CreatorChannelSeriesContentLayoutKey {
return CreatorChannelSeriesContentLayoutKey(
seriesCount = seriesCount,
seriesIds = series.map { it.seriesId }
)
}

View File

@@ -0,0 +1,110 @@
package kr.co.vividnext.sodalive.v2.creator.channel.series.ui
import android.graphics.Outline
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.ViewOutlineProvider
import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.databinding.ItemCreatorChannelSeriesBinding
import kr.co.vividnext.sodalive.extensions.loadUrl
import kr.co.vividnext.sodalive.extensions.moneyFormat
import kr.co.vividnext.sodalive.v2.creator.channel.series.model.CreatorChannelSeriesItemUiModel
import kr.co.vividnext.sodalive.v2.creator.channel.series.model.CreatorChannelSeriesSubtitleUiModel
class CreatorChannelSeriesAdapter(
private val onSeriesClicked: (Long) -> Unit = {}
) : RecyclerView.Adapter<CreatorChannelSeriesAdapter.ViewHolder>() {
private var items: List<CreatorChannelSeriesItemUiModel> = emptyList()
fun submitItems(items: List<CreatorChannelSeriesItemUiModel>) {
this.items = items
notifyDataSetChanged()
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
return ViewHolder(
ItemCreatorChannelSeriesBinding.inflate(LayoutInflater.from(parent.context), parent, false),
onSeriesClicked
)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
holder.bind(items[position])
}
override fun getItemCount(): Int = items.size
class ViewHolder(
private val binding: ItemCreatorChannelSeriesBinding,
private val onSeriesClicked: (Long) -> Unit
) : RecyclerView.ViewHolder(binding.root) {
init {
binding.layoutCreatorChannelSeriesThumbnail.clipToOutline = true
binding.layoutCreatorChannelSeriesThumbnail.outlineProvider = object : ViewOutlineProvider() {
override fun getOutline(view: View, outline: Outline) {
outline.setRoundRect(
0,
0,
view.width,
view.height,
view.resources.getDimension(R.dimen.radius_14)
)
}
}
}
fun bind(item: CreatorChannelSeriesItemUiModel) = with(binding) {
ivCreatorChannelSeriesThumbnail.loadUrl(item.coverImageUrl)
layoutCreatorChannelSeriesOriginalTag.isVisible = item.showOriginalTag
ivCreatorChannelSeriesAdultBadge.isVisible = item.showAdultBadge
tvCreatorChannelSeriesTitle.text = item.title
tvCreatorChannelSeriesSubtitle.text = formatSubtitle(item.subtitle)
bindProgress(item)
root.setOnClickListener { onSeriesClicked(item.seriesId) }
}
private fun formatSubtitle(subtitle: CreatorChannelSeriesSubtitleUiModel): String {
return listOfNotNull(
subtitle.publishedDaysOfWeek,
binding.root.context.getString(
R.string.creator_channel_series_subtitle_content_count,
subtitle.contentCount.moneyFormat()
),
binding.root.context.getString(
if (subtitle.isProceeding) {
R.string.creator_channel_series_status_proceeding
} else {
R.string.creator_channel_series_status_completed
}
)
).joinToString(BULLET_SEPARATOR)
}
private fun bindProgress(item: CreatorChannelSeriesItemUiModel) = with(binding) {
val progress = item.progress
layoutCreatorChannelSeriesProgress.isVisible = progress != null
if (progress == null) return@with
tvCreatorChannelSeriesProgressCount.text = root.context.getString(
R.string.creator_channel_series_progress_count,
progress.purchasedCount.moneyFormat(),
progress.paidCount.moneyFormat()
)
tvCreatorChannelSeriesProgressPercent.text = root.context.getString(
R.string.creator_channel_series_progress_percent,
progress.ratePercent.toInt().moneyFormat()
)
viewCreatorChannelSeriesProgressFill.pivotX = 0f
viewCreatorChannelSeriesProgressFill.scaleX = progress.progressScale
}
}
companion object {
private const val BULLET_SEPARATOR = ""
}
}

View File

@@ -0,0 +1,134 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/black">
<LinearLayout
android:id="@+id/layout_creator_channel_series_sort_bar"
android:layout_width="0dp"
android:layout_height="52dp"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingHorizontal="@dimen/spacing_14"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<TextView
android:id="@+id/tv_creator_channel_series_total_label"
style="@style/Typography.Body2"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:includeFontPadding="false"
android:text="@string/creator_channel_series_total_content_count"
android:textColor="@color/white" />
<TextView
android:id="@+id/tv_creator_channel_series_total_count"
style="@style/Typography.Body2"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/spacing_4"
android:layout_weight="1"
android:includeFontPadding="false"
android:textColor="@color/gray_500"
tools:text="23" />
<LinearLayout
android:id="@+id/layout_creator_channel_series_sort_button"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:gravity="center"
android:orientation="horizontal">
<TextView
android:id="@+id/tv_creator_channel_series_sort_label"
style="@style/Typography.Body3"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:includeFontPadding="false"
android:textColor="@color/gray_400"
tools:text="최신순" />
<ImageView
android:id="@+id/iv_creator_channel_series_sort"
android:layout_width="18dp"
android:layout_height="18dp"
android:layout_marginStart="@dimen/spacing_4"
android:contentDescription="@null"
android:src="@drawable/ic_new_sort" />
</LinearLayout>
</LinearLayout>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rv_creator_channel_series"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:clipToPadding="false"
android:nestedScrollingEnabled="false"
android:paddingHorizontal="@dimen/spacing_14"
android:paddingTop="@dimen/spacing_8"
android:paddingBottom="@dimen/spacing_32"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/layout_creator_channel_series_sort_bar"
tools:itemCount="3"
tools:listitem="@layout/item_creator_channel_series" />
<FrameLayout
android:id="@+id/layout_creator_channel_series_empty"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:paddingTop="@dimen/spacing_48"
android:paddingBottom="@dimen/spacing_32"
android:visibility="gone"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:visibility="visible">
<TextView
android:id="@+id/tv_creator_channel_series_empty_message"
style="@style/Typography.Body3"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="top|center_horizontal"
android:gravity="center"
android:text="@string/creator_channel_series_empty_message"
android:textColor="@color/gray_500" />
</FrameLayout>
<TextView
android:id="@+id/tv_creator_channel_series_error_message"
style="@style/Typography.Body3"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:gravity="center"
android:text="@string/creator_channel_series_error_message"
android:textColor="@color/gray_500"
android:visibility="gone"
app:layout_constraintBottom_toTopOf="@id/btn_creator_channel_series_retry"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/btn_creator_channel_series_retry"
style="@style/Typography.Body5"
android:layout_width="wrap_content"
android:layout_height="40dp"
android:layout_marginTop="@dimen/spacing_14"
android:background="@drawable/bg_creator_channel_live_retry"
android:gravity="center"
android:minWidth="96dp"
android:paddingHorizontal="@dimen/spacing_20"
android:text="@string/creator_channel_series_retry_button"
android:textColor="@color/white"
android:visibility="gone"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/tv_creator_channel_series_error_message" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -0,0 +1,153 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="@dimen/spacing_8"
android:baselineAligned="false"
android:gravity="center_vertical"
android:orientation="horizontal">
<FrameLayout
android:id="@+id/layout_creator_channel_series_thumbnail"
android:layout_width="122dp"
android:layout_height="172dp"
android:background="@drawable/bg_series_content_thumbnail"
android:clipToOutline="true"
android:outlineProvider="background">
<ImageView
android:id="@+id/iv_creator_channel_series_thumbnail"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:contentDescription="@null"
android:scaleType="centerCrop"
tools:src="@drawable/ic_launcher_background" />
<FrameLayout
android:id="@+id/layout_creator_channel_series_original_tag"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="top|start"
android:background="@drawable/bg_series_original_tag"
android:minHeight="24dp"
android:paddingStart="8dp"
android:paddingEnd="8dp">
<ImageView
android:id="@+id/iv_creator_channel_series_original_icon"
android:layout_width="14dp"
android:layout_height="14dp"
android:layout_gravity="start|center_vertical"
android:contentDescription="@null"
android:src="@drawable/ic_series_original" />
<ImageView
android:id="@+id/iv_creator_channel_series_original_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical"
android:layout_marginStart="18dp"
android:contentDescription="@null"
android:src="@drawable/img_new_only" />
</FrameLayout>
<ImageView
android:id="@+id/iv_creator_channel_series_adult_badge"
android:layout_width="18dp"
android:layout_height="18dp"
android:layout_gravity="top|end"
android:layout_marginTop="@dimen/spacing_6"
android:layout_marginEnd="@dimen/spacing_6"
android:background="@drawable/bg_creator_channel_live_adult_badge"
android:contentDescription="@null"
android:padding="2dp"
android:src="@drawable/ic_new_shield_small" />
</FrameLayout>
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_marginStart="@dimen/spacing_14"
android:layout_weight="1">
<TextView
android:id="@+id/tv_creator_channel_series_title"
style="@style/Typography.Body2"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/spacing_8"
android:ellipsize="end"
android:includeFontPadding="false"
android:maxLines="1"
android:textStyle="bold"
android:textColor="@color/white"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="시리즈 이름" />
<TextView
android:id="@+id/tv_creator_channel_series_subtitle"
style="@style/Typography.Body5"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:ellipsize="end"
android:includeFontPadding="false"
android:maxLines="1"
android:textColor="@color/gray_500"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/tv_creator_channel_series_title"
tools:text="매주 월 • 총 nn화 • 완결" />
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/layout_creator_channel_series_progress"
android:layout_width="0dp"
android:layout_height="28dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent">
<TextView
android:id="@+id/tv_creator_channel_series_progress_count"
style="@style/Typography.Body5"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:includeFontPadding="false"
android:textColor="@color/white"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="12/45화" />
<TextView
android:id="@+id/tv_creator_channel_series_progress_percent"
style="@style/Typography.Body5"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:includeFontPadding="false"
android:textColor="@color/soda_400"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="40%" />
<FrameLayout
android:id="@+id/view_creator_channel_series_progress_track"
android:layout_width="0dp"
android:layout_height="4dp"
android:background="@drawable/bg_creator_channel_audio_rate_track"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent">
<View
android:id="@+id/view_creator_channel_series_progress_fill"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/bg_creator_channel_audio_rate_fill" />
</FrameLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
</LinearLayout>

View File

@@ -0,0 +1,138 @@
package kr.co.vividnext.sodalive.v2.creator.channel.series
import android.app.Application
import android.content.Context
import android.view.LayoutInflater
import android.view.View
import android.widget.ImageView
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView
import androidx.test.core.app.ApplicationProvider
import kr.co.vividnext.sodalive.R
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
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
import java.io.File
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [28], application = Application::class)
class CreatorChannelSeriesFragmentLayoutTest {
@Test
fun `시리즈 fragment layout은 sort list empty error retry를 제공한다`() {
val root = inflateView(R.layout.fragment_creator_channel_series)
val layout = projectFile("app/src/main/res/layout/fragment_creator_channel_series.xml").readText()
val sortBar = requireNotNull(root.findViewById<View>(R.id.layout_creator_channel_series_sort_bar))
val seriesList = requireNotNull(root.findViewById<RecyclerView>(R.id.rv_creator_channel_series))
val emptyContainer = requireNotNull(root.findViewById<View>(R.id.layout_creator_channel_series_empty))
val emptyMessage = requireNotNull(root.findViewById<TextView>(R.id.tv_creator_channel_series_empty_message))
val errorMessage = requireNotNull(root.findViewById<TextView>(R.id.tv_creator_channel_series_error_message))
val retryButton = requireNotNull(root.findViewById<TextView>(R.id.btn_creator_channel_series_retry))
assertSame(root, sortBar.parent)
assertSame(root, seriesList.parent)
assertSame(root, emptyContainer.parent)
assertSame(emptyContainer, emptyMessage.parent)
assertSame(root, errorMessage.parent)
assertSame(root, retryButton.parent)
assertEquals(false, seriesList.clipToPadding)
assertTrue(layout.contains("android:background=\"@color/black\""))
assertTrue(layout.contains("android:text=\"@string/creator_channel_series_empty_message\""))
assertTrue(layout.contains("android:text=\"@string/creator_channel_series_error_message\""))
assertTrue(layout.contains("tools:listitem=\"@layout/item_creator_channel_series\""))
}
@Test
fun `시리즈 sort bar는 전체 count 정렬 label sort icon을 제공한다`() {
val root = inflateView(R.layout.fragment_creator_channel_series)
val sortBar = requireNotNull(root.findViewById<View>(R.id.layout_creator_channel_series_sort_bar))
assertNotNull(sortBar.findViewById<TextView>(R.id.tv_creator_channel_series_total_label))
assertNotNull(sortBar.findViewById<TextView>(R.id.tv_creator_channel_series_total_count))
assertNotNull(sortBar.findViewById<View>(R.id.layout_creator_channel_series_sort_button))
assertNotNull(sortBar.findViewById<TextView>(R.id.tv_creator_channel_series_sort_label))
assertNotNull(sortBar.findViewById<ImageView>(R.id.iv_creator_channel_series_sort))
}
@Test
fun `시리즈 item layout은 thumbnail info progress를 제공하고 우측 action을 만들지 않는다`() {
val item = inflateView(R.layout.item_creator_channel_series)
val itemLayout = projectFile("app/src/main/res/layout/item_creator_channel_series.xml").readText()
val thumbnail = requireNotNull(item.findViewById<View>(R.id.layout_creator_channel_series_thumbnail))
assertNotNull(item.findViewById<ImageView>(R.id.iv_creator_channel_series_thumbnail))
assertNotNull(item.findViewById<View>(R.id.layout_creator_channel_series_original_tag))
assertNotNull(item.findViewById<ImageView>(R.id.iv_creator_channel_series_adult_badge))
assertNotNull(item.findViewById<TextView>(R.id.tv_creator_channel_series_title))
assertNotNull(item.findViewById<TextView>(R.id.tv_creator_channel_series_subtitle))
assertNotNull(item.findViewById<View>(R.id.layout_creator_channel_series_progress))
assertNotNull(item.findViewById<TextView>(R.id.tv_creator_channel_series_progress_count))
assertNotNull(item.findViewById<TextView>(R.id.tv_creator_channel_series_progress_percent))
assertNotNull(item.findViewById<View>(R.id.view_creator_channel_series_progress_fill))
assertEquals(dp(122), thumbnail.layoutParams.width)
assertEquals(dp(172), thumbnail.layoutParams.height)
assertTrue(!itemLayout.contains("전체소장"))
assertTrue(!itemLayout.contains("button-play"))
assertTrue(!itemLayout.contains("iv_creator_channel_series_play"))
}
@Test
fun `시리즈 empty 문자열은 한국어 영어 일본어에 존재한다`() {
val ko = projectFile("app/src/main/res/values/strings.xml").readText()
val en = projectFile("app/src/main/res/values-en/strings.xml").readText()
val ja = projectFile("app/src/main/res/values-ja/strings.xml").readText()
assertTrue(ko.contains("name=\"creator_channel_series_empty_message\""))
assertTrue(en.contains("name=\"creator_channel_series_empty_message\""))
assertTrue(ja.contains("name=\"creator_channel_series_empty_message\""))
}
@Test
fun `시리즈 fragment source는 Audio 탭과 같은 sort pagination content change 계약을 사용한다`() {
val fragment = projectFile(
"app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/series/CreatorChannelSeriesFragment.kt"
).readText()
val adapter = projectFile(
"app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/series/ui/CreatorChannelSeriesAdapter.kt"
).readText()
assertTrue(fragment.contains("BaseFragment<FragmentCreatorChannelSeriesBinding>"))
assertTrue(fragment.contains("private val viewModel: CreatorChannelSeriesViewModel by viewModel()"))
assertTrue(fragment.contains("fun onCreatorChannelSeriesTabSelected()"))
assertTrue(fragment.contains("viewModel.loadSeries(creatorId, isOwner = host.isCreatorChannelOwner())"))
assertTrue(fragment.contains("fun onCreatorChannelSeriesScrolledToBottom()"))
assertTrue(fragment.contains("viewModel.loadMore()"))
assertTrue(fragment.contains("CreatorChannelSortPopup"))
assertTrue(fragment.contains("viewModel.consumePaginationErrorMessage()"))
assertTrue(fragment.contains("notifyContentChangedIfLayoutChanged(state)"))
assertTrue(adapter.contains("ItemCreatorChannelSeriesBinding"))
assertTrue(adapter.contains("layoutCreatorChannelSeriesThumbnail.clipToOutline = true"))
assertTrue(adapter.contains("formatSubtitle(item.subtitle)"))
assertTrue(adapter.contains("R.string.creator_channel_series_subtitle_content_count"))
assertTrue(adapter.contains("R.string.creator_channel_series_status_proceeding"))
assertTrue(adapter.contains("R.string.creator_channel_series_status_completed"))
assertTrue(!adapter.contains("tvCreatorChannelSeriesSubtitle.text = item.subtitle"))
}
private fun inflateView(layoutResId: Int): View {
val context = ApplicationProvider.getApplicationContext<Context>()
return LayoutInflater.from(context).inflate(layoutResId, null, false)
}
private fun dp(value: Int): Int {
val context = ApplicationProvider.getApplicationContext<Context>()
return (value * context.resources.displayMetrics.density).toInt()
}
private fun projectFile(relativePath: String): File {
val candidates = listOf(File(relativePath), File("../$relativePath"))
return candidates.firstOrNull { it.exists() }
?: error("Project file not found: $relativePath")
}
}