From 0cfa5f8a328b96cc214551ebb76a579b2b452509 Mon Sep 17 00:00:00 2001 From: klaus Date: Thu, 13 Nov 2025 20:57:18 +0900 Subject: [PATCH] =?UTF-8?q?feat(series-main):=20=EC=8B=9C=EB=A6=AC?= =?UTF-8?q?=EC=A6=88=20=EC=A0=84=EC=B2=B4=EB=B3=B4=EA=B8=B0=20=EC=9E=A5?= =?UTF-8?q?=EB=A5=B4=EB=B3=84=20=ED=83=AD=20UI=20=EB=B0=8F=20=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=84=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../series/main/by_genre/GenreAdapter.kt | 56 +++++++ .../by_genre/SeriesMainByGenreFragment.kt | 142 ++++++++++++++++++ .../by_genre/SeriesMainByGenreViewModel.kt | 103 +++++++++++++ .../layout/fragment_series_main_by_genre.xml | 6 +- 4 files changed, 304 insertions(+), 3 deletions(-) create mode 100644 app/src/main/java/kr/co/vividnext/sodalive/audio_content/series/main/by_genre/GenreAdapter.kt diff --git a/app/src/main/java/kr/co/vividnext/sodalive/audio_content/series/main/by_genre/GenreAdapter.kt b/app/src/main/java/kr/co/vividnext/sodalive/audio_content/series/main/by_genre/GenreAdapter.kt new file mode 100644 index 00000000..9a7a3fe7 --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/audio_content/series/main/by_genre/GenreAdapter.kt @@ -0,0 +1,56 @@ +package kr.co.vividnext.sodalive.audio_content.series.main.by_genre + +import android.annotation.SuppressLint +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import kr.co.vividnext.sodalive.R +import kr.co.vividnext.sodalive.databinding.ItemHomeContentThemeBinding + +class GenreAdapter( + private val onClickItem: (GetSeriesGenreListResponse) -> Unit +) : RecyclerView.Adapter() { + + private val items = mutableListOf() + private var selectedGenreId: Long? = null + + inner class ViewHolder( + private val binding: ItemHomeContentThemeBinding + ) : RecyclerView.ViewHolder(binding.root) { + @SuppressLint("NotifyDataSetChanged") + fun bind(item: GetSeriesGenreListResponse) { + binding.tvTheme.text = item.genre + if (item.id == selectedGenreId) { + binding.tvTheme.setBackgroundResource(R.drawable.bg_round_corner_999_3bb9f1) + } else { + binding.tvTheme.setBackgroundResource(R.drawable.bg_round_corner_999_263238) + } + binding.root.setOnClickListener { + selectedGenreId = item.id + onClickItem(item) + notifyDataSetChanged() + } + } + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = ViewHolder( + ItemHomeContentThemeBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) + ) + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + holder.bind(items[position]) + } + + override fun getItemCount() = items.size + + fun submitList(list: List, preselectId: Long?) { + items.clear() + items.addAll(list) + selectedGenreId = preselectId + notifyDataSetChanged() + } +} diff --git a/app/src/main/java/kr/co/vividnext/sodalive/audio_content/series/main/by_genre/SeriesMainByGenreFragment.kt b/app/src/main/java/kr/co/vividnext/sodalive/audio_content/series/main/by_genre/SeriesMainByGenreFragment.kt index 6119b0f2..688eeb3a 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/audio_content/series/main/by_genre/SeriesMainByGenreFragment.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/audio_content/series/main/by_genre/SeriesMainByGenreFragment.kt @@ -1,9 +1,151 @@ package kr.co.vividnext.sodalive.audio_content.series.main.by_genre +import android.content.Intent +import android.graphics.Rect +import android.os.Bundle +import android.view.View +import android.widget.Toast +import androidx.recyclerview.widget.GridLayoutManager +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import kr.co.vividnext.sodalive.audio_content.series.SeriesListAdapter +import kr.co.vividnext.sodalive.audio_content.series.detail.SeriesDetailActivity import kr.co.vividnext.sodalive.base.BaseFragment +import kr.co.vividnext.sodalive.common.Constants +import kr.co.vividnext.sodalive.common.GridSpacingItemDecoration +import kr.co.vividnext.sodalive.common.LoadingDialog import kr.co.vividnext.sodalive.databinding.FragmentSeriesMainByGenreBinding +import kr.co.vividnext.sodalive.extensions.dpToPx +import org.koin.android.ext.android.inject +import kotlin.math.roundToInt class SeriesMainByGenreFragment : BaseFragment( FragmentSeriesMainByGenreBinding::inflate ) { + private val viewModel: SeriesMainByGenreViewModel by inject() + + private lateinit var loadingDialog: LoadingDialog + private lateinit var seriesAdapter: SeriesListAdapter + private lateinit var genreAdapter: GenreAdapter + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + setupView() + observeViewModel() + viewModel.loadGenres() + } + + private fun setupView() { + loadingDialog = LoadingDialog(requireActivity(), layoutInflater) + setupGenreView() + setupSeriesView() + } + + private fun setupGenreView() { + genreAdapter = GenreAdapter { genre -> + seriesAdapter.clear() + viewModel.setGenre(genre.id) + } + + val rvGenre = binding.rvGenre + val layoutManager = LinearLayoutManager( + context, + LinearLayoutManager.HORIZONTAL, + false + ) + rvGenre.layoutManager = layoutManager + rvGenre.adapter = genreAdapter + + rvGenre.addItemDecoration(object : RecyclerView.ItemDecoration() { + override fun getItemOffsets( + outRect: Rect, + view: View, + parent: RecyclerView, + state: RecyclerView.State + ) { + super.getItemOffsets(outRect, view, parent, state) + + val position = parent.getChildAdapterPosition(view) + val itemCount = parent.adapter?.itemCount ?: 0 + + when (position) { + 0 -> { + outRect.left = 0 + outRect.right = 8f.dpToPx().toInt() + } + + itemCount - 1 -> { + outRect.left = 8f.dpToPx().toInt() + outRect.right = 0 + } + + else -> { + outRect.left = 8f.dpToPx().toInt() + outRect.right = 8f.dpToPx().toInt() + } + } + } + }) + } + + private fun setupSeriesView() { + seriesAdapter = SeriesListAdapter( + itemWidth = ((screenWidth - 24f.dpToPx() * 2 - 16f.dpToPx()) / 2f).roundToInt(), + onClickItem = { + startActivity( + Intent( + requireContext(), + SeriesDetailActivity::class.java + ).apply { + putExtra(Constants.EXTRA_SERIES_ID, it) + } + ) + }, + onClickCreator = {}, + isVisibleCreator = false + ) + + val spanCount = 2 + val spacingPx = 16f.dpToPx().toInt() + val recyclerView = binding.rvSeriesByGenre + recyclerView.layoutManager = GridLayoutManager(requireContext(), spanCount) + recyclerView.addItemDecoration(GridSpacingItemDecoration(spanCount, spacingPx, false)) + recyclerView.adapter = seriesAdapter + + recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() { + override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { + super.onScrolled(recyclerView, dx, dy) + + val lastVisibleItemPosition = (recyclerView.layoutManager as LinearLayoutManager?)!! + .findLastCompletelyVisibleItemPosition() + val itemTotalCount = recyclerView.adapter!!.itemCount - 1 + + if (!recyclerView.canScrollVertically(1) && lastVisibleItemPosition == itemTotalCount) { + viewModel.getSeriesList() + } + } + }) + + viewModel.seriesListLiveData.observe(viewLifecycleOwner) { + seriesAdapter.addItems(it) + } + } + + private fun observeViewModel() { + viewModel.isLoading.observe(viewLifecycleOwner) { + if (it) { + loadingDialog.show(screenWidth) + } else { + loadingDialog.dismiss() + } + } + + viewModel.toastLiveData.observe(viewLifecycleOwner) { + it?.let { Toast.makeText(requireActivity(), it, Toast.LENGTH_LONG).show() } + } + + viewModel.genreListLiveData.observe(viewLifecycleOwner) { genres -> + genreAdapter.submitList(genres, viewModel.selectedGenreId) + } + } } diff --git a/app/src/main/java/kr/co/vividnext/sodalive/audio_content/series/main/by_genre/SeriesMainByGenreViewModel.kt b/app/src/main/java/kr/co/vividnext/sodalive/audio_content/series/main/by_genre/SeriesMainByGenreViewModel.kt index 7c1a2521..a7d576c2 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/audio_content/series/main/by_genre/SeriesMainByGenreViewModel.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/audio_content/series/main/by_genre/SeriesMainByGenreViewModel.kt @@ -2,8 +2,12 @@ package kr.co.vividnext.sodalive.audio_content.series.main.by_genre import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers +import io.reactivex.rxjava3.schedulers.Schedulers +import kr.co.vividnext.sodalive.audio_content.series.GetSeriesListResponse import kr.co.vividnext.sodalive.audio_content.series.main.SeriesMainRepository import kr.co.vividnext.sodalive.base.BaseViewModel +import kr.co.vividnext.sodalive.common.SharedPreferenceManager class SeriesMainByGenreViewModel( private val repository: SeriesMainRepository @@ -15,4 +19,103 @@ class SeriesMainByGenreViewModel( private val _toastLiveData = MutableLiveData() val toastLiveData: LiveData get() = _toastLiveData + + private val _genreListLiveData = MutableLiveData>() + val genreListLiveData: LiveData> + get() = _genreListLiveData + + private val _seriesListLiveData = MutableLiveData>() + val seriesListLiveData: LiveData> + get() = _seriesListLiveData + + private var page = 1 + private var isLast = false + private val pageSize = 20 + + private var _selectedGenreId: Long? = null + val selectedGenreId: Long? + get() = _selectedGenreId + + fun loadGenres() { + _isLoading.value = true + compositeDisposable.add( + repository.getGenreList( + token = "Bearer ${SharedPreferenceManager.token}" + ) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { + _isLoading.value = false + if (it.success && it.data != null) { + val genres = it.data + if (genres.isNotEmpty()) { + _selectedGenreId = genres.first().id + page = 1 + isLast = false + _genreListLiveData.value = genres + getSeriesList() + } else { + _genreListLiveData.value = emptyList() + } + } else { + _toastLiveData.value = it.message ?: "알 수 없는 오류가 발생했습니다. 다시 시도해 주세요." + } + }, + { + _isLoading.value = false + _toastLiveData.value = "알 수 없는 오류가 발생했습니다. 다시 시도해 주세요." + } + ) + ) + } + + fun setGenre(genreId: Long) { + if (_selectedGenreId != genreId) { + _selectedGenreId = genreId + page = 1 + isLast = false + // 시리즈 목록 초기화는 Fragment에서 어댑터를 clear로 처리 + getSeriesList() + } + } + + fun getSeriesList() { + val genreId = _selectedGenreId ?: return + if (isLast) return + if (_isLoading.value == true) return + + _isLoading.value = true + compositeDisposable.add( + repository.getSeriesListByGenre( + genreId = genreId, + page = page, + size = pageSize, + token = "Bearer ${SharedPreferenceManager.token}" + ) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { + _isLoading.value = false + val data = it.data + if (it.success && data != null) { + page += 1 + val items = data.items + if (items.isNotEmpty()) { + _seriesListLiveData.value = items + } else { + isLast = true + } + } else { + _toastLiveData.value = it.message ?: "알 수 없는 오류가 발생했습니다. 다시 시도해 주세요." + } + }, + { + _isLoading.value = false + _toastLiveData.value = "알 수 없는 오류가 발생했습니다. 다시 시도해 주세요." + } + ) + ) + } } diff --git a/app/src/main/res/layout/fragment_series_main_by_genre.xml b/app/src/main/res/layout/fragment_series_main_by_genre.xml index 63e11cdd..8acd3bdf 100644 --- a/app/src/main/res/layout/fragment_series_main_by_genre.xml +++ b/app/src/main/res/layout/fragment_series_main_by_genre.xml @@ -8,7 +8,7 @@ android:id="@+id/rv_genre" android:layout_width="match_parent" android:layout_height="wrap_content" - android:layout_marginTop="24dp" + android:layout_marginTop="16dp" android:clipToPadding="false" android:nestedScrollingEnabled="false" android:paddingHorizontal="24dp" /> @@ -17,7 +17,7 @@ android:id="@+id/rv_series_by_genre" android:layout_width="match_parent" android:layout_height="match_parent" - android:layout_marginTop="24dp" android:clipToPadding="false" - android:paddingHorizontal="24dp" /> + android:paddingHorizontal="24dp" + android:paddingVertical="16dp" />