feat(content): 전체보기 화면 UI를 추가한다

This commit is contained in:
2026-06-27 08:29:58 +09:00
parent 0134942172
commit 0906a21c6d
3 changed files with 306 additions and 0 deletions

View File

@@ -0,0 +1,153 @@
package kr.co.vividnext.sodalive.v2.main.content.overview
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.view.View
import androidx.core.view.doOnLayout
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.audio_content.detail.AudioContentDetailActivity
import kr.co.vividnext.sodalive.base.BaseActivity
import kr.co.vividnext.sodalive.common.Constants
import kr.co.vividnext.sodalive.databinding.ActivityContentOverviewBinding
import kr.co.vividnext.sodalive.v2.main.content.overview.data.ContentOverviewType
import kr.co.vividnext.sodalive.v2.main.content.overview.model.ContentOverviewUiState
import kr.co.vividnext.sodalive.v2.main.content.overview.model.toTitleResId
import kr.co.vividnext.sodalive.v2.main.content.overview.ui.ContentOverviewAdapter
import kr.co.vividnext.sodalive.v2.main.content.ui.addContentGridItemSpacing
import kr.co.vividnext.sodalive.v2.main.content.ui.calculateContentGridItemWidthPx
import org.koin.androidx.viewmodel.ext.android.viewModel
class ContentOverviewActivity : BaseActivity<ActivityContentOverviewBinding>(
ActivityContentOverviewBinding::inflate
) {
private val viewModel: ContentOverviewViewModel by viewModel()
private val overviewAdapter = ContentOverviewAdapter(::openAudioContentDetail)
private val overviewType: ContentOverviewType by lazy { intent.contentOverviewType() }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
viewModel.loadFirstPage(overviewType)
}
override fun setupView() {
setupTitleBar()
setupRecyclerView()
observeViewModel()
}
private fun setupTitleBar() {
binding.tvContentOverviewTitle.setText(overviewType.toTitleResId())
binding.ivContentOverviewBack.setOnClickListener { finish() }
}
private fun setupRecyclerView() {
binding.rvContentOverviewItems.adapter = overviewAdapter
binding.rvContentOverviewItems.layoutManager = GridLayoutManager(this, CONTENT_OVERVIEW_GRID_SPAN_COUNT)
binding.rvContentOverviewItems.addContentGridItemSpacing(CONTENT_OVERVIEW_GRID_SPAN_COUNT)
binding.rvContentOverviewItems.doOnLayout { updateGridItemWidth() }
binding.rvContentOverviewItems.addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
super.onScrolled(recyclerView, dx, dy)
if (!recyclerView.canScrollVertically(1)) {
viewModel.loadMore()
}
}
})
}
private fun observeViewModel() {
viewModel.isLoading.observe(this) { isLoading ->
binding.pbContentOverviewInitialLoading.visibility = if (isLoading) View.VISIBLE else View.GONE
}
viewModel.overviewStateLiveData.observe(this, ::renderState)
viewModel.toastLiveData.observe(this) { toastMessage ->
toastMessage?.let(::showToast)
}
}
private fun renderState(state: ContentOverviewUiState) {
when (state) {
is ContentOverviewUiState.Loading -> renderLoading()
is ContentOverviewUiState.Content -> renderContent(state)
is ContentOverviewUiState.Empty -> renderEmpty()
is ContentOverviewUiState.Error -> renderError()
}
}
private fun renderLoading() {
binding.rvContentOverviewItems.visibility = View.GONE
binding.tvContentOverviewEmptyError.visibility = View.GONE
binding.pbContentOverviewInitialLoading.visibility = View.VISIBLE
binding.pbContentOverviewLoadMore.visibility = View.GONE
}
private fun renderContent(state: ContentOverviewUiState.Content) {
binding.rvContentOverviewItems.visibility = View.VISIBLE
binding.tvContentOverviewEmptyError.visibility = View.GONE
binding.pbContentOverviewInitialLoading.visibility = View.GONE
binding.pbContentOverviewLoadMore.visibility = if (state.isLoadingMore) View.VISIBLE else View.GONE
overviewAdapter.submitItems(state.items)
updateGridItemWidth()
state.paginationErrorMessage?.let {
showToast(it)
viewModel.consumePaginationErrorMessage()
}
}
private fun renderEmpty() {
overviewAdapter.submitItems(emptyList())
binding.rvContentOverviewItems.visibility = View.GONE
binding.tvContentOverviewEmptyError.visibility = View.VISIBLE
binding.pbContentOverviewInitialLoading.visibility = View.GONE
binding.pbContentOverviewLoadMore.visibility = View.GONE
binding.tvContentOverviewEmptyError.setText(R.string.screen_content_all_empty)
}
private fun renderError() {
overviewAdapter.submitItems(emptyList())
binding.rvContentOverviewItems.visibility = View.GONE
binding.tvContentOverviewEmptyError.visibility = View.VISIBLE
binding.pbContentOverviewInitialLoading.visibility = View.GONE
binding.pbContentOverviewLoadMore.visibility = View.GONE
binding.tvContentOverviewEmptyError.setText(R.string.common_error_unknown)
}
private fun updateGridItemWidth() {
val widthPx = binding.rvContentOverviewItems.calculateContentGridItemWidthPx(CONTENT_OVERVIEW_GRID_SPAN_COUNT)
overviewAdapter.setGridItemWidthPx(widthPx)
}
private fun showToast(toastMessage: kr.co.vividnext.sodalive.common.ToastMessage) {
toastMessage.message?.let { message -> showToast(message) }
?: toastMessage.resId?.let { resId -> showToast(getString(resId)) }
}
private fun openAudioContentDetail(contentId: Long) {
if (contentId <= 0) return
startActivity(
Intent(this, AudioContentDetailActivity::class.java).apply {
putExtra(Constants.EXTRA_AUDIO_CONTENT_ID, contentId)
}
)
}
private fun Intent.contentOverviewType(): ContentOverviewType {
val typeName = getStringExtra(EXTRA_CONTENT_OVERVIEW_TYPE)
return ContentOverviewType.entries.firstOrNull { it.name == typeName }
?: ContentOverviewType.NEW_AND_HOT_AUDIO
}
companion object {
const val CONTENT_OVERVIEW_GRID_SPAN_COUNT = 2
private const val EXTRA_CONTENT_OVERVIEW_TYPE = "content_overview_type"
fun newIntent(context: Context, type: ContentOverviewType): Intent {
return Intent(context, ContentOverviewActivity::class.java)
.putExtra(EXTRA_CONTENT_OVERVIEW_TYPE, type.name)
}
}
}

View File

@@ -0,0 +1,57 @@
package kr.co.vividnext.sodalive.v2.main.content.overview.ui
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import kr.co.vividnext.sodalive.databinding.ItemContentAudioCardBinding
import kr.co.vividnext.sodalive.extensions.loadUrl
import kr.co.vividnext.sodalive.v2.main.content.overview.model.ContentOverviewUiModel
class ContentOverviewAdapter(
private val onContentClick: (Long) -> Unit = {}
) : RecyclerView.Adapter<ContentOverviewAdapter.ViewHolder>() {
private var items: List<ContentOverviewUiModel> = emptyList()
private var gridItemWidthPx: Int = 0
fun setGridItemWidthPx(widthPx: Int) {
if (widthPx <= 0 || gridItemWidthPx == widthPx) return
gridItemWidthPx = widthPx
notifyDataSetChanged()
}
fun submitItems(items: List<ContentOverviewUiModel>) {
this.items = items
notifyDataSetChanged()
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
return ViewHolder(
ItemContentAudioCardBinding.inflate(LayoutInflater.from(parent.context), parent, false),
onContentClick
)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
holder.bind(items[position], gridItemWidthPx)
}
override fun getItemCount(): Int = items.size
class ViewHolder(
private val binding: ItemContentAudioCardBinding,
private val onContentClick: (Long) -> Unit
) : RecyclerView.ViewHolder(binding.root) {
fun bind(item: ContentOverviewUiModel, gridItemWidthPx: Int) = with(binding.audioContentCard.root) {
setGridItemWidthPx(gridItemWidthPx)
setContent(item.title, item.creatorNickname)
setTags(item.tags)
setAdultVisible(item.showAdultBadge)
thumbnailView().loadUrl(item.coverImage)
setOnClickListener {
if (item.contentId <= 0) return@setOnClickListener
onContentClick(item.contentId)
}
}
}
}

View File

@@ -0,0 +1,96 @@
<?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:id="@+id/layout_content_overview_root"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/black">
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/layout_content_overview_title_bar"
android:layout_width="0dp"
android:layout_height="60dp"
android:background="@color/black"
android:paddingHorizontal="@dimen/spacing_14"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<ImageView
android:id="@+id/iv_content_overview_back"
android:layout_width="@dimen/spacing_24"
android:layout_height="@dimen/spacing_24"
android:contentDescription="@null"
android:importantForAccessibility="no"
android:src="@drawable/ic_new_bar_back"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/tv_content_overview_title"
style="@style/Typography.Heading2"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/spacing_14"
android:ellipsize="end"
android:maxLines="1"
android:textColor="@color/white"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/iv_content_overview_back"
app:layout_constraintTop_toTopOf="parent"
tools:text="New&amp;Hot" />
</androidx.constraintlayout.widget.ConstraintLayout>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rv_content_overview_items"
android:layout_width="0dp"
android:layout_height="0dp"
android:clipToPadding="false"
android:paddingHorizontal="@dimen/spacing_14"
android:paddingTop="@dimen/spacing_8"
android:paddingBottom="@dimen/spacing_24"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/layout_content_overview_title_bar"
tools:listitem="@layout/item_content_audio_card" />
<TextView
android:id="@+id/tv_content_overview_empty_error"
style="@style/Typography.Body2"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
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_toBottomOf="@id/layout_content_overview_title_bar"
tools:text="콘텐츠가 없습니다."
tools:visibility="visible" />
<ProgressBar
android:id="@+id/pb_content_overview_initial_loading"
android:layout_width="50dp"
android:layout_height="50dp"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/layout_content_overview_title_bar"
tools:visibility="visible" />
<ProgressBar
android:id="@+id/pb_content_overview_load_more"
android:layout_width="32dp"
android:layout_height="32dp"
android:layout_marginBottom="@dimen/spacing_16"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
tools:visibility="visible" />
</androidx.constraintlayout.widget.ConstraintLayout>