diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/series/CreatorChannelSeriesFragment.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/series/CreatorChannelSeriesFragment.kt new file mode 100644 index 00000000..7c30a2f5 --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/series/CreatorChannelSeriesFragment.kt @@ -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::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 +) + +private fun CreatorChannelSeriesUiState.Content.toContentLayoutKey(): CreatorChannelSeriesContentLayoutKey { + return CreatorChannelSeriesContentLayoutKey( + seriesCount = seriesCount, + seriesIds = series.map { it.seriesId } + ) +} diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/series/ui/CreatorChannelSeriesAdapter.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/series/ui/CreatorChannelSeriesAdapter.kt new file mode 100644 index 00000000..20d903ab --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/series/ui/CreatorChannelSeriesAdapter.kt @@ -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() { + + private var items: List = emptyList() + + fun submitItems(items: List) { + 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 = " • " + } +} diff --git a/app/src/main/res/layout/fragment_creator_channel_series.xml b/app/src/main/res/layout/fragment_creator_channel_series.xml new file mode 100644 index 00000000..51f2ce88 --- /dev/null +++ b/app/src/main/res/layout/fragment_creator_channel_series.xml @@ -0,0 +1,134 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/item_creator_channel_series.xml b/app/src/main/res/layout/item_creator_channel_series.xml new file mode 100644 index 00000000..b5e4bfdd --- /dev/null +++ b/app/src/main/res/layout/item_creator_channel_series.xml @@ -0,0 +1,153 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/test/java/kr/co/vividnext/sodalive/v2/creator/channel/series/CreatorChannelSeriesFragmentLayoutTest.kt b/app/src/test/java/kr/co/vividnext/sodalive/v2/creator/channel/series/CreatorChannelSeriesFragmentLayoutTest.kt new file mode 100644 index 00000000..8561290c --- /dev/null +++ b/app/src/test/java/kr/co/vividnext/sodalive/v2/creator/channel/series/CreatorChannelSeriesFragmentLayoutTest.kt @@ -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(R.id.layout_creator_channel_series_sort_bar)) + val seriesList = requireNotNull(root.findViewById(R.id.rv_creator_channel_series)) + val emptyContainer = requireNotNull(root.findViewById(R.id.layout_creator_channel_series_empty)) + val emptyMessage = requireNotNull(root.findViewById(R.id.tv_creator_channel_series_empty_message)) + val errorMessage = requireNotNull(root.findViewById(R.id.tv_creator_channel_series_error_message)) + val retryButton = requireNotNull(root.findViewById(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(R.id.layout_creator_channel_series_sort_bar)) + + assertNotNull(sortBar.findViewById(R.id.tv_creator_channel_series_total_label)) + assertNotNull(sortBar.findViewById(R.id.tv_creator_channel_series_total_count)) + assertNotNull(sortBar.findViewById(R.id.layout_creator_channel_series_sort_button)) + assertNotNull(sortBar.findViewById(R.id.tv_creator_channel_series_sort_label)) + assertNotNull(sortBar.findViewById(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(R.id.layout_creator_channel_series_thumbnail)) + assertNotNull(item.findViewById(R.id.iv_creator_channel_series_thumbnail)) + assertNotNull(item.findViewById(R.id.layout_creator_channel_series_original_tag)) + assertNotNull(item.findViewById(R.id.iv_creator_channel_series_adult_badge)) + assertNotNull(item.findViewById(R.id.tv_creator_channel_series_title)) + assertNotNull(item.findViewById(R.id.tv_creator_channel_series_subtitle)) + assertNotNull(item.findViewById(R.id.layout_creator_channel_series_progress)) + assertNotNull(item.findViewById(R.id.tv_creator_channel_series_progress_count)) + assertNotNull(item.findViewById(R.id.tv_creator_channel_series_progress_percent)) + assertNotNull(item.findViewById(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")) + 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() + return LayoutInflater.from(context).inflate(layoutResId, null, false) + } + + private fun dp(value: Int): Int { + val context = ApplicationProvider.getApplicationContext() + 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") + } +}