feat(creator): 라이브 탭 레이아웃을 추가한다

This commit is contained in:
2026-06-17 23:24:13 +09:00
parent dd13c619ac
commit 7fb52b3c85
6 changed files with 926 additions and 0 deletions

View File

@@ -0,0 +1,183 @@
package kr.co.vividnext.sodalive.v2.creator.channel.live
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.FragmentCreatorChannelLiveBinding
import kr.co.vividnext.sodalive.extensions.moneyFormat
import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelLiveResponse
import kr.co.vividnext.sodalive.v2.creator.channel.live.model.toLabelResId
import kr.co.vividnext.sodalive.v2.creator.channel.live.model.toReplayUiModel
import kr.co.vividnext.sodalive.v2.creator.channel.live.ui.CreatorChannelLiveReplayAdapter
import kr.co.vividnext.sodalive.v2.creator.channel.ui.formatCreatorChannelLiveDateTime
import org.koin.androidx.viewmodel.ext.android.viewModel
class CreatorChannelLiveFragment : BaseFragment<FragmentCreatorChannelLiveBinding>(
FragmentCreatorChannelLiveBinding::inflate
) {
private val viewModel: CreatorChannelLiveViewModel by viewModel()
private val replayAdapter = CreatorChannelLiveReplayAdapter { item ->
host.onCreatorChannelLiveReplayClicked(item.audioContentId)
}
private var lastContentLayoutKey: CreatorChannelLiveContentLayoutKey? = 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()
setupReplayList()
setupClickListeners()
observeViewModel()
}
fun onCreatorChannelLiveTabSelected() {
if (creatorId > 0L) {
viewModel.loadLive(creatorId)
}
}
override fun onDestroyView() {
binding.rvCreatorChannelLiveReplays.adapter = null
super.onDestroyView()
}
private fun setupReplayList() = with(binding.rvCreatorChannelLiveReplays) {
layoutManager = LinearLayoutManager(requireContext())
adapter = replayAdapter
}
fun onCreatorChannelLiveScrolledToBottom() {
viewModel.loadMore()
}
private fun setupClickListeners() {
binding.ivCreatorChannelLiveSort.setImageResource(R.drawable.ic_new_sort)
binding.btnCreatorChannelLiveRetry.setOnClickListener {
viewModel.retryLive()
}
}
private fun observeViewModel() {
viewModel.liveStateLiveData.observe(viewLifecycleOwner) { state ->
when (state) {
CreatorChannelLiveUiState.Loading -> bindLoading()
CreatorChannelLiveUiState.Empty -> bindEmpty()
is CreatorChannelLiveUiState.Error -> bindError(state)
is CreatorChannelLiveUiState.Content -> bindContent(state)
}
}
}
private fun bindLoading() = with(binding) {
lastContentLayoutKey = null
layoutCreatorChannelLiveSortBar.isVisible = false
layoutCreatorChannelLiveCurrentCard.isVisible = false
rvCreatorChannelLiveReplays.isVisible = false
tvCreatorChannelLiveEmptyMessage.isVisible = false
tvCreatorChannelLiveErrorMessage.isVisible = false
btnCreatorChannelLiveRetry.isVisible = false
}
private fun bindEmpty() = with(binding) {
lastContentLayoutKey = null
layoutCreatorChannelLiveSortBar.isVisible = false
layoutCreatorChannelLiveCurrentCard.isVisible = false
rvCreatorChannelLiveReplays.isVisible = false
tvCreatorChannelLiveEmptyMessage.isVisible = true
tvCreatorChannelLiveErrorMessage.isVisible = false
btnCreatorChannelLiveRetry.isVisible = false
host.onCreatorChannelLiveContentChanged()
}
private fun bindError(state: CreatorChannelLiveUiState.Error) = with(binding) {
lastContentLayoutKey = null
layoutCreatorChannelLiveSortBar.isVisible = false
layoutCreatorChannelLiveCurrentCard.isVisible = false
rvCreatorChannelLiveReplays.isVisible = false
tvCreatorChannelLiveEmptyMessage.isVisible = false
tvCreatorChannelLiveErrorMessage.isVisible = true
tvCreatorChannelLiveErrorMessage.text = state.message ?: getString(R.string.creator_channel_live_error_message)
btnCreatorChannelLiveRetry.isVisible = true
host.onCreatorChannelLiveContentChanged()
}
private fun bindContent(state: CreatorChannelLiveUiState.Content) = with(binding) {
tvCreatorChannelLiveEmptyMessage.isVisible = false
tvCreatorChannelLiveErrorMessage.isVisible = false
btnCreatorChannelLiveRetry.isVisible = false
layoutCreatorChannelLiveSortBar.isVisible = true
tvCreatorChannelLiveTotalCount.text = state.liveReplayContentCount.moneyFormat()
tvCreatorChannelLiveSortLabel.setText(state.selectedSort.toLabelResId())
bindCurrentLive(state.currentLive)
rvCreatorChannelLiveReplays.isVisible = true
replayAdapter.submitItems(state.liveReplayContents.map { it.toReplayUiModel() })
notifyContentChangedIfLayoutChanged(state)
state.paginationErrorMessage?.let {
Toast.makeText(requireContext(), it, Toast.LENGTH_SHORT).show()
viewModel.consumePaginationErrorMessage()
}
}
private fun notifyContentChangedIfLayoutChanged(state: CreatorChannelLiveUiState.Content) {
val contentLayoutKey = state.toContentLayoutKey()
if (contentLayoutKey == lastContentLayoutKey) return
lastContentLayoutKey = contentLayoutKey
host.onCreatorChannelLiveContentChanged()
}
private fun bindCurrentLive(live: CreatorChannelLiveResponse?) = with(binding) {
layoutCreatorChannelLiveCurrentCard.isVisible = live != null
if (live == null) return@with
tvCreatorChannelLiveCurrentTitle.text = live.title
tvCreatorChannelLiveCurrentTime.text = formatCreatorChannelLiveDateTime(live.beginDateTimeUtc)
tvCreatorChannelLiveCurrentPrice.text = if (live.price > 0) {
live.price.moneyFormat()
} else {
getString(R.string.audio_content_tag_free)
}
layoutCreatorChannelLiveCurrentPrice.isVisible = true
ivCreatorChannelLiveCurrentPriceCash.isVisible = live.price > 0
layoutCreatorChannelLiveCurrentCard.setOnClickListener {
host.onCreatorChannelCurrentLiveClicked(live)
}
}
interface Host {
fun onCreatorChannelCurrentLiveClicked(live: CreatorChannelLiveResponse)
fun onCreatorChannelLiveReplayClicked(audioContentId: Long)
fun onCreatorChannelLiveContentChanged()
}
companion object {
private const val ARG_CREATOR_ID: String = "arg_creator_id"
fun newInstance(creatorId: Long): CreatorChannelLiveFragment {
return CreatorChannelLiveFragment().apply {
arguments = Bundle().apply { putLong(ARG_CREATOR_ID, creatorId) }
}
}
}
}
private data class CreatorChannelLiveContentLayoutKey(
val liveReplayContentCount: Int,
val currentLive: CreatorChannelLiveResponse?,
val liveReplayContentIds: List<Long>
)
private fun CreatorChannelLiveUiState.Content.toContentLayoutKey(): CreatorChannelLiveContentLayoutKey {
return CreatorChannelLiveContentLayoutKey(
liveReplayContentCount = liveReplayContentCount,
currentLive = currentLive,
liveReplayContentIds = liveReplayContents.map { it.audioContentId }
)
}

View File

@@ -0,0 +1,90 @@
package kr.co.vividnext.sodalive.v2.creator.channel.live.ui
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.databinding.ItemCreatorChannelLiveReplayBinding
import kr.co.vividnext.sodalive.extensions.loadUrl
import kr.co.vividnext.sodalive.extensions.moneyFormat
import kr.co.vividnext.sodalive.v2.creator.channel.live.model.CreatorChannelLiveReplayStatus
import kr.co.vividnext.sodalive.v2.creator.channel.live.model.CreatorChannelLiveReplayUiModel
import kr.co.vividnext.sodalive.v2.widget.AudioContentTag
class CreatorChannelLiveReplayAdapter(
private val onReplayClick: (CreatorChannelLiveReplayUiModel) -> Unit = {}
) : RecyclerView.Adapter<CreatorChannelLiveReplayAdapter.ViewHolder>() {
private var items: List<CreatorChannelLiveReplayUiModel> = emptyList()
fun submitItems(items: List<CreatorChannelLiveReplayUiModel>) {
this.items = items
notifyDataSetChanged()
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
return ViewHolder(
ItemCreatorChannelLiveReplayBinding.inflate(LayoutInflater.from(parent.context), parent, false),
onReplayClick
)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
holder.bind(items[position])
}
override fun getItemCount(): Int = items.size
class ViewHolder(
private val binding: ItemCreatorChannelLiveReplayBinding,
private val onReplayClick: (CreatorChannelLiveReplayUiModel) -> Unit
) : RecyclerView.ViewHolder(binding.root) {
fun bind(item: CreatorChannelLiveReplayUiModel) = with(binding) {
ivCreatorChannelLiveReplayThumbnail.loadUrl(item.imageUrl)
tvCreatorChannelLiveReplayTitle.text = item.title
tvCreatorChannelLiveReplayDuration.text = item.secondaryText.orEmpty()
tvCreatorChannelLiveReplayDuration.isVisible = !item.secondaryText.isNullOrBlank()
ivCreatorChannelLiveReplayAdultBadge.setImageResource(R.drawable.ic_new_shield_small)
ivCreatorChannelLiveReplayAdultBadge.isVisible = item.showAdultBadge
bindTag(ivCreatorChannelLiveReplayOriginalTag, AudioContentTag.Original, item.tags)
bindTag(ivCreatorChannelLiveReplayFirstTag, AudioContentTag.First, item.tags)
bindTag(ivCreatorChannelLiveReplayPointTag, AudioContentTag.Point, item.tags)
tvCreatorChannelLiveReplayFreeTag.isVisible = AudioContentTag.Free in item.tags
bindStatus(item.status)
root.setOnClickListener { onReplayClick(item) }
}
private fun bindTag(view: View, tag: AudioContentTag, tags: Set<AudioContentTag>) {
view.isVisible = tag in tags
}
private fun bindStatus(status: CreatorChannelLiveReplayStatus) = with(binding) {
ivCreatorChannelLiveReplayPlay.setImageResource(R.drawable.ic_new_player_play)
when (status) {
CreatorChannelLiveReplayStatus.Play -> {
ivCreatorChannelLiveReplayPlay.isVisible = true
ivCreatorChannelLiveReplayCan.isVisible = false
layoutCreatorChannelLiveReplayActionText.isVisible = false
}
CreatorChannelLiveReplayStatus.Owned -> bindTextStatus(R.string.audio_content_badge_owned)
CreatorChannelLiveReplayStatus.Rented -> bindTextStatus(R.string.audio_content_badge_rented)
is CreatorChannelLiveReplayStatus.Price -> {
ivCreatorChannelLiveReplayPlay.isVisible = false
layoutCreatorChannelLiveReplayActionText.isVisible = true
ivCreatorChannelLiveReplayCan.isVisible = true
tvCreatorChannelLiveReplayActionText.text = status.price.moneyFormat()
}
}
}
private fun bindTextStatus(textResId: Int) = with(binding) {
ivCreatorChannelLiveReplayPlay.isVisible = true
layoutCreatorChannelLiveReplayActionText.isVisible = true
ivCreatorChannelLiveReplayCan.isVisible = false
tvCreatorChannelLiveReplayActionText.setText(textResId)
}
}
}

View File

@@ -0,0 +1,276 @@
<?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="match_parent"
android:background="@color/black">
<LinearLayout
android:id="@+id/layout_creator_channel_live_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_live_total_label"
style="@style/Typography.Body2"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:includeFontPadding="false"
android:text="@string/creator_channel_live_total_label"
android:textColor="@color/white" />
<TextView
android:id="@+id/tv_creator_channel_live_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_live_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_live_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_live_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.constraintlayout.widget.ConstraintLayout
android:id="@+id/layout_creator_channel_live_current_card"
android:layout_width="0dp"
android:layout_height="78dp"
android:layout_marginHorizontal="@dimen/spacing_14"
android:background="@drawable/bg_creator_channel_live_current"
android:paddingStart="@dimen/spacing_24"
android:paddingEnd="@dimen/spacing_14"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/layout_creator_channel_live_sort_bar">
<LinearLayout
android:id="@+id/ll_creator_channel_live_current_text"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginEnd="@dimen/spacing_14"
android:orientation="vertical"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@id/layout_creator_channel_live_current_price"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="18dp"
android:gravity="center_vertical"
android:orientation="horizontal">
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="18dp"
android:background="@drawable/bg_live_thumbnail_badge_capsule"
android:gravity="center"
android:orientation="horizontal"
android:paddingHorizontal="@dimen/spacing_4">
<View
android:layout_width="@dimen/spacing_8"
android:layout_height="@dimen/spacing_8"
android:background="@drawable/bg_live_thumbnail_dot" />
<TextView
style="@style/Typography.Caption3"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/spacing_4"
android:includeFontPadding="true"
android:text="LIVE"
android:textColor="@color/white"
tools:ignore="HardcodedText,SmallSp" />
</LinearLayout>
<TextView
android:id="@+id/tv_creator_channel_live_current_time"
style="@style/Typography.Body6"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/spacing_8"
android:layout_weight="1"
android:ellipsize="end"
android:includeFontPadding="false"
android:maxLines="1"
android:textColor="@color/gray_700"
tools:text="00:00" />
</LinearLayout>
<TextView
android:id="@+id/tv_creator_channel_live_current_title"
style="@style/Typography.Heading4"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/spacing_4"
android:ellipsize="end"
android:includeFontPadding="false"
android:maxLines="1"
android:textColor="@color/black"
tools:text="라이브 제목" />
</LinearLayout>
<FrameLayout
android:id="@+id/layout_creator_channel_live_current_price"
android:layout_width="wrap_content"
android:layout_height="match_parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent">
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:background="@drawable/bg_creator_channel_live_price"
android:gravity="center"
android:orientation="horizontal"
android:paddingHorizontal="@dimen/spacing_6"
android:paddingVertical="@dimen/spacing_4">
<ImageView
android:id="@+id/iv_creator_channel_live_current_price_cash"
android:layout_width="18dp"
android:layout_height="18dp"
android:contentDescription="@null"
android:src="@drawable/ic_bar_cash" />
<TextView
android:id="@+id/tv_creator_channel_live_current_price"
style="@style/Typography.Body6"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="2dp"
android:includeFontPadding="false"
android:textColor="@color/white"
tools:text="300" />
</LinearLayout>
</FrameLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rv_creator_channel_live_replays"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:clipToPadding="false"
android:nestedScrollingEnabled="false"
android:paddingHorizontal="@dimen/spacing_14"
android:paddingTop="@dimen/spacing_20"
android:paddingBottom="@dimen/spacing_32"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/layout_creator_channel_live_current_card"
tools:itemCount="3"
tools:listitem="@layout/item_creator_channel_live_replay" />
<TextView
android:id="@+id/tv_creator_channel_live_empty_message"
style="@style/Typography.Body3"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:gravity="center"
android:text="@string/creator_channel_live_empty_message"
android:textColor="@color/gray_500"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.58"
tools:visibility="visible" />
<TextView
android:id="@+id/tv_creator_channel_live_error_message"
style="@style/Typography.Body3"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:gravity="center"
android:text="@string/creator_channel_live_error_message"
android:textColor="@color/gray_500"
android:visibility="gone"
app:layout_constraintBottom_toTopOf="@id/btn_creator_channel_live_retry"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_chainStyle="packed" />
<TextView
android:id="@+id/btn_creator_channel_live_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_live_retry_button"
android:textColor="@color/white"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/tv_creator_channel_live_error_message" />
<LinearLayout
android:id="@+id/layout_creator_channel_live_owner_cta"
android:layout_width="0dp"
android:layout_height="56dp"
android:layout_marginHorizontal="@dimen/spacing_14"
android:layout_marginBottom="@dimen/spacing_14"
android:background="@drawable/bg_creator_channel_owner_fab"
android:gravity="center"
android:orientation="horizontal"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent">
<ImageView
android:layout_width="24dp"
android:layout_height="24dp"
android:contentDescription="@null"
android:src="@drawable/ic_new_create_live" />
<TextView
style="@style/Typography.Body1"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/spacing_8"
android:includeFontPadding="false"
android:text="@string/creator_channel_live_start_button"
android:textColor="@color/black" />
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -0,0 +1,161 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="88dp"
android:baselineAligned="false"
android:gravity="center_vertical"
android:orientation="horizontal">
<FrameLayout
android:id="@+id/layout_creator_channel_live_replay_thumbnail"
android:layout_width="88dp"
android:layout_height="88dp"
android:background="@drawable/bg_audio_content_card_thumbnail">
<ImageView
android:id="@+id/iv_creator_channel_live_replay_thumbnail"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:contentDescription="@null"
android:scaleType="centerCrop"
tools:src="@drawable/ic_launcher_background" />
<ImageView
android:id="@+id/iv_creator_channel_live_replay_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" />
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="24dp"
android:layout_gravity="top|start"
android:orientation="horizontal">
<ImageView
android:id="@+id/iv_creator_channel_live_replay_original_tag"
android:layout_width="24dp"
android:layout_height="24dp"
android:contentDescription="@null"
android:src="@drawable/ic_content_tag_original" />
<ImageView
android:id="@+id/iv_creator_channel_live_replay_first_tag"
android:layout_width="24dp"
android:layout_height="24dp"
android:background="@drawable/bg_audio_content_tag_first"
android:contentDescription="@null"
android:padding="3dp"
android:src="@drawable/ic_content_tag_first_star" />
</LinearLayout>
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="24dp"
android:layout_gravity="bottom|start"
android:orientation="horizontal">
<ImageView
android:id="@+id/iv_creator_channel_live_replay_point_tag"
android:layout_width="24dp"
android:layout_height="24dp"
android:contentDescription="@null"
android:src="@drawable/ic_content_tag_point" />
<TextView
android:id="@+id/tv_creator_channel_live_replay_free_tag"
style="@style/Typography.Body6"
android:layout_width="wrap_content"
android:layout_height="24dp"
android:background="@drawable/bg_audio_content_tag_free"
android:gravity="center"
android:includeFontPadding="false"
android:minWidth="34dp"
android:paddingHorizontal="@dimen/spacing_4"
android:text="@string/audio_content_tag_free"
android:textColor="@color/white" />
</LinearLayout>
</FrameLayout>
<LinearLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_marginStart="@dimen/spacing_14"
android:layout_weight="1"
android:gravity="center_vertical"
android:orientation="vertical">
<TextView
android:id="@+id/tv_creator_channel_live_replay_title"
style="@style/Typography.Body1"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:includeFontPadding="false"
android:maxLines="2"
android:textColor="@color/white"
tools:text="라이브 다시 듣기 오디오 제목 라이브 다시 듣기 오디오 제목" />
<TextView
android:id="@+id/tv_creator_channel_live_replay_duration"
style="@style/Typography.Body5"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/spacing_4"
android:ellipsize="end"
android:includeFontPadding="false"
android:maxLines="1"
android:textColor="@color/gray_500"
tools:text="3:35" />
</LinearLayout>
<LinearLayout
android:id="@+id/layout_creator_channel_live_replay_action"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:gravity="center"
android:orientation="vertical">
<ImageView
android:id="@+id/iv_creator_channel_live_replay_play"
android:layout_width="28dp"
android:layout_height="28dp"
android:background="@drawable/bg_creator_channel_live_replay_play"
android:contentDescription="@null"
android:padding="6dp"
android:src="@drawable/ic_new_player_play" />
<LinearLayout
android:id="@+id/layout_creator_channel_live_replay_action_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/spacing_8"
android:gravity="center"
android:orientation="horizontal">
<ImageView
android:id="@+id/iv_creator_channel_live_replay_can"
android:layout_width="18dp"
android:layout_height="18dp"
android:contentDescription="@null"
android:src="@drawable/ic_bar_cash" />
<TextView
android:id="@+id/tv_creator_channel_live_replay_action_text"
style="@style/Typography.Caption2"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="2dp"
android:includeFontPadding="false"
android:textColor="@color/gray_400"
tools:text="대여중" />
</LinearLayout>
</LinearLayout>
</LinearLayout>

View File

@@ -0,0 +1,33 @@
package kr.co.vividnext.sodalive.v2.creator.channel
import android.app.Application
import androidx.fragment.app.FragmentActivity
import kr.co.vividnext.sodalive.v2.creator.channel.live.CreatorChannelLiveFragment
import kr.co.vividnext.sodalive.v2.creator.channel.model.CreatorChannelTab
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.Robolectric
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [28], application = Application::class)
class CreatorChannelPagerAdapterTest {
@Test
fun `createFragment는 Home과 Live를 실제 Fragment로 생성하고 나머지는 placeholder를 유지한다`() {
val activity = Robolectric.buildActivity(FragmentActivity::class.java).setup().get()
val adapter = CreatorChannelPagerAdapter(activity, creatorId = 123L)
assertTrue(adapter.createFragment(CreatorChannelTab.Home.ordinal) is CreatorChannelHomeFragment)
assertTrue(adapter.createFragment(CreatorChannelTab.Live.ordinal) is CreatorChannelLiveFragment)
CreatorChannelTab.entries
.filterNot { it == CreatorChannelTab.Home || it == CreatorChannelTab.Live }
.forEach { tab ->
assertTrue(adapter.createFragment(tab.ordinal) is CreatorChannelPlaceholderFragment)
}
assertEquals(CreatorChannelTab.entries.size, adapter.itemCount)
}
}

View File

@@ -0,0 +1,183 @@
package kr.co.vividnext.sodalive.v2.creator.channel.live
import android.app.Application
import android.content.Context
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
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.assertFalse
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 CreatorChannelLiveFragmentLayoutTest {
@Test
fun `라이브 fragment layout은 sort current live list empty error owner CTA를 제공한다`() {
val root = inflateView(R.layout.fragment_creator_channel_live)
val sortBar = requireNotNull(root.findViewById<View>(R.id.layout_creator_channel_live_sort_bar))
val currentLiveCard = requireNotNull(root.findViewById<View>(R.id.layout_creator_channel_live_current_card))
val replayList = requireNotNull(root.findViewById<RecyclerView>(R.id.rv_creator_channel_live_replays))
val emptyMessage = requireNotNull(root.findViewById<TextView>(R.id.tv_creator_channel_live_empty_message))
val errorMessage = requireNotNull(root.findViewById<TextView>(R.id.tv_creator_channel_live_error_message))
val retryButton = requireNotNull(root.findViewById<TextView>(R.id.btn_creator_channel_live_retry))
val ownerCta = requireNotNull(root.findViewById<View>(R.id.layout_creator_channel_live_owner_cta))
assertSame(root, sortBar.parent)
assertSame(root, replayList.parent)
assertSame(root, emptyMessage.parent)
assertSame(root, errorMessage.parent)
assertSame(root, retryButton.parent)
assertSame(root, ownerCta.parent)
assertNotNull(currentLiveCard.findViewById<TextView>(R.id.tv_creator_channel_live_current_title))
assertEquals(false, replayList.clipToPadding)
}
@Test
fun `라이브 sort bar는 전체 count 정렬 label sort icon을 제공한다`() {
val root = inflateView(R.layout.fragment_creator_channel_live)
val sortBar = requireNotNull(root.findViewById<View>(R.id.layout_creator_channel_live_sort_bar))
assertNotNull(sortBar.findViewById<TextView>(R.id.tv_creator_channel_live_total_label))
assertNotNull(sortBar.findViewById<TextView>(R.id.tv_creator_channel_live_total_count))
assertNotNull(sortBar.findViewById<TextView>(R.id.tv_creator_channel_live_sort_label))
assertNotNull(sortBar.findViewById<ImageView>(R.id.iv_creator_channel_live_sort))
}
@Test
fun `라이브 다시듣기 item layout은 썸네일 tag title duration action 영역을 제공한다`() {
val item = inflateView(R.layout.item_creator_channel_live_replay)
assertNotNull(item.findViewById<ImageView>(R.id.iv_creator_channel_live_replay_thumbnail))
assertNotNull(item.findViewById<ImageView>(R.id.iv_creator_channel_live_replay_adult_badge))
assertNotNull(item.findViewById<ImageView>(R.id.iv_creator_channel_live_replay_original_tag))
assertNotNull(item.findViewById<ImageView>(R.id.iv_creator_channel_live_replay_first_tag))
assertNotNull(item.findViewById<ImageView>(R.id.iv_creator_channel_live_replay_point_tag))
assertNotNull(item.findViewById<TextView>(R.id.tv_creator_channel_live_replay_free_tag))
assertNotNull(item.findViewById<TextView>(R.id.tv_creator_channel_live_replay_title))
assertNotNull(item.findViewById<TextView>(R.id.tv_creator_channel_live_replay_duration))
assertNotNull(item.findViewById<View>(R.id.layout_creator_channel_live_replay_action))
assertNotNull(item.findViewById<ImageView>(R.id.iv_creator_channel_live_replay_play))
assertNotNull(item.findViewById<ImageView>(R.id.iv_creator_channel_live_replay_can))
assertNotNull(item.findViewById<View>(R.id.layout_creator_channel_live_replay_action_text))
assertNotNull(item.findViewById<TextView>(R.id.tv_creator_channel_live_replay_action_text))
}
@Test
fun `라이브 layout은 가격 영역을 유연한 너비와 bar cash icon으로 제공한다`() {
val fragmentLayout = projectFile("app/src/main/res/layout/fragment_creator_channel_live.xml").readText()
val itemLayout = projectFile("app/src/main/res/layout/item_creator_channel_live_replay.xml").readText()
val root = inflateView(R.layout.fragment_creator_channel_live)
val item = inflateView(R.layout.item_creator_channel_live_replay)
val currentPrice = requireNotNull(root.findViewById<View>(R.id.layout_creator_channel_live_current_price))
val currentPriceCash = requireNotNull(root.findViewById<ImageView>(R.id.iv_creator_channel_live_current_price_cash))
val replayAction = requireNotNull(item.findViewById<View>(R.id.layout_creator_channel_live_replay_action))
val replayPlay = requireNotNull(item.findViewById<ImageView>(R.id.iv_creator_channel_live_replay_play))
val actionText = requireNotNull(item.findViewById<View>(R.id.layout_creator_channel_live_replay_action_text))
val adultBadge = requireNotNull(item.findViewById<ImageView>(R.id.iv_creator_channel_live_replay_adult_badge))
val adultBadgeParams = adultBadge.layoutParams as ViewGroup.MarginLayoutParams
assertEquals(ViewGroup.LayoutParams.WRAP_CONTENT, currentPrice.layoutParams.width)
assertNotNull(currentPriceCash)
assertEquals(ViewGroup.LayoutParams.WRAP_CONTENT, replayAction.layoutParams.width)
assertEquals(dp(28), replayPlay.layoutParams.width)
assertEquals(dp(28), replayPlay.layoutParams.height)
assertEquals(dp(6), replayPlay.paddingStart)
assertEquals(dp(8), (actionText.layoutParams as ViewGroup.MarginLayoutParams).topMargin)
assertEquals(dp(6), adultBadgeParams.topMargin)
assertEquals(dp(6), adultBadgeParams.marginEnd)
assertTrue(fragmentLayout.contains("@drawable/ic_bar_cash"))
assertTrue(itemLayout.contains("@drawable/ic_bar_cash"))
assertTrue(itemLayout.contains("@drawable/bg_creator_channel_live_replay_play"))
assertTrue(itemLayout.contains("android:orientation=\"vertical\""))
assertFalse(fragmentLayout.contains("@drawable/ic_can"))
assertFalse(itemLayout.contains("@drawable/ic_can"))
assertFalse(
fragmentLayout.contains(
"android:id=\"@+id/layout_creator_channel_live_current_price\"\n" +
" android:layout_width=\"60dp\""
)
)
assertFalse(
itemLayout.contains(
"android:id=\"@+id/layout_creator_channel_live_replay_action\"\n" +
" android:layout_width=\"60dp\""
)
)
}
@Test
fun `라이브 fragment와 adapter source는 필수 drawable과 retry loadMore click 연결을 포함한다`() {
val fragment = projectFile(
"app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/live/CreatorChannelLiveFragment.kt"
).readText()
val adapter = projectFile(
"app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/live/ui/CreatorChannelLiveReplayAdapter.kt"
).readText()
val layout = projectFile("app/src/main/res/layout/fragment_creator_channel_live.xml").readText()
val eagerLoadOnViewCreated = """
onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
setupReplayList()
setupClickListeners()
observeViewModel()
if (creatorId > 0L)
""".trimIndent()
assertTrue(layout.contains("android:layout_height=\"match_parent\""))
assertTrue(fragment.contains("R.drawable.ic_new_sort"))
assertTrue(fragment.contains("super.onViewCreated(view, savedInstanceState)\n bindLoading()"))
assertTrue(fragment.contains("retryLive()"))
assertTrue(fragment.contains("fun onCreatorChannelLiveTabSelected()"))
assertTrue(fragment.contains("viewModel.loadLive(creatorId)"))
assertFalse(fragment.contains(eagerLoadOnViewCreated))
assertTrue(fragment.contains("fun onCreatorChannelLiveScrolledToBottom()"))
assertTrue(fragment.contains("viewModel.loadMore()"))
assertTrue(fragment.contains("host.onCreatorChannelLiveContentChanged()"))
assertTrue(fragment.contains("notifyContentChangedIfLayoutChanged(state)"))
assertTrue(fragment.contains("if (contentLayoutKey == lastContentLayoutKey) return"))
assertTrue(fragment.contains("viewModel.consumePaginationErrorMessage()"))
assertTrue(fragment.contains("bindEmpty() = with(binding)"))
assertTrue(fragment.contains("bindError(state: CreatorChannelLiveUiState.Error) = with(binding)"))
assertTrue(fragment.contains("formatCreatorChannelLiveDateTime(live.beginDateTimeUtc)"))
assertTrue(fragment.contains("ivCreatorChannelLiveCurrentPriceCash.isVisible = live.price > 0"))
assertTrue(fragment.contains("R.string.audio_content_tag_free"))
assertTrue(fragment.contains("onCreatorChannelCurrentLiveClicked"))
assertTrue(fragment.contains("onCreatorChannelLiveContentChanged"))
assertFalse(fragment.contains("addOnScrollListener(object : RecyclerView.OnScrollListener()"))
assertTrue(adapter.contains("R.drawable.ic_new_shield_small"))
assertTrue(adapter.contains("R.drawable.ic_new_player_play"))
assertTrue(adapter.contains("ivCreatorChannelLiveReplayPlay.isVisible = true"))
assertTrue(adapter.contains("layoutCreatorChannelLiveReplayActionText.isVisible = false"))
assertTrue(adapter.contains("layoutCreatorChannelLiveReplayActionText.isVisible = true"))
}
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")
}
}