From 907b718a3a7f843c607c9c191eb5b95c4ec07984 Mon Sep 17 00:00:00 2001 From: klaus Date: Thu, 13 Nov 2025 18:27:04 +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=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 홈, 요일별, 장르별 탭 추가 - 홈 리스트 UI 및 데이터 - 요일별 UI 및 데이터 --- app/src/main/AndroidManifest.xml | 13 +- .../series/main/SeriesMainActivity.kt | 66 +++++ .../series/main/SeriesMainApi.kt | 55 +++++ .../series/main/SeriesMainRepository.kt | 55 +++++ .../by_genre/GetSeriesGenreListResponse.kt | 10 + .../by_genre/SeriesMainByGenreFragment.kt | 9 + .../by_genre/SeriesMainByGenreViewModel.kt | 18 ++ .../SeriesMainDayOfWeekFragment.kt | 172 +++++++++++++ .../SeriesMainDayOfWeekViewModel.kt | 85 +++++++ .../series/main/home/SeriesBannerAdapter.kt | 53 ++++ .../series/main/home/SeriesHomeResponse.kt | 23 ++ .../main/home/SeriesMainHomeFragment.kt | 233 ++++++++++++++++++ .../main/home/SeriesMainHomeViewModel.kt | 101 ++++++++ .../java/kr/co/vividnext/sodalive/di/AppDI.kt | 10 + .../vividnext/sodalive/home/HomeFragment.kt | 37 ++- .../main/res/layout/activity_series_main.xml | 30 +++ app/src/main/res/layout/fragment_home.xml | 31 ++- .../layout/fragment_series_main_by_genre.xml | 23 ++ .../fragment_series_main_day_of_week.xml | 23 ++ .../res/layout/fragment_series_main_home.xml | 111 +++++++++ 20 files changed, 1141 insertions(+), 17 deletions(-) create mode 100644 app/src/main/java/kr/co/vividnext/sodalive/audio_content/series/main/SeriesMainActivity.kt create mode 100644 app/src/main/java/kr/co/vividnext/sodalive/audio_content/series/main/SeriesMainApi.kt create mode 100644 app/src/main/java/kr/co/vividnext/sodalive/audio_content/series/main/SeriesMainRepository.kt create mode 100644 app/src/main/java/kr/co/vividnext/sodalive/audio_content/series/main/by_genre/GetSeriesGenreListResponse.kt create mode 100644 app/src/main/java/kr/co/vividnext/sodalive/audio_content/series/main/by_genre/SeriesMainByGenreFragment.kt create mode 100644 app/src/main/java/kr/co/vividnext/sodalive/audio_content/series/main/by_genre/SeriesMainByGenreViewModel.kt create mode 100644 app/src/main/java/kr/co/vividnext/sodalive/audio_content/series/main/day_of_week/SeriesMainDayOfWeekFragment.kt create mode 100644 app/src/main/java/kr/co/vividnext/sodalive/audio_content/series/main/day_of_week/SeriesMainDayOfWeekViewModel.kt create mode 100644 app/src/main/java/kr/co/vividnext/sodalive/audio_content/series/main/home/SeriesBannerAdapter.kt create mode 100644 app/src/main/java/kr/co/vividnext/sodalive/audio_content/series/main/home/SeriesHomeResponse.kt create mode 100644 app/src/main/java/kr/co/vividnext/sodalive/audio_content/series/main/home/SeriesMainHomeFragment.kt create mode 100644 app/src/main/java/kr/co/vividnext/sodalive/audio_content/series/main/home/SeriesMainHomeViewModel.kt create mode 100644 app/src/main/res/layout/activity_series_main.xml create mode 100644 app/src/main/res/layout/fragment_series_main_by_genre.xml create mode 100644 app/src/main/res/layout/fragment_series_main_day_of_week.xml create mode 100644 app/src/main/res/layout/fragment_series_main_home.xml diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 3f0857c8..eb11f769 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -86,13 +86,15 @@ - - - + + + + - + android:path="/result" + android:scheme="${URISCHEME}" /> + ( + ActivitySeriesMainBinding::inflate +) { + private var currentTab = 0 + + override fun setupView() { + binding.toolbar.tvBack.text = "시리즈 전체보기" + binding.toolbar.tvBack.setOnClickListener { finish() } + + setupTabs() + } + + private fun setupTabs() { + binding.tabLayout.addTab(binding.tabLayout.newTab().setText("홈")) + binding.tabLayout.addTab(binding.tabLayout.newTab().setText("요일별")) + binding.tabLayout.addTab(binding.tabLayout.newTab().setText("장르별")) + + // 탭 선택 리스너 설정 + binding.tabLayout.addOnTabSelectedListener(object : TabLayout.OnTabSelectedListener { + override fun onTabSelected(tab: TabLayout.Tab) { + currentTab = tab.position + showTabContent(currentTab) + } + + override fun onTabUnselected(tab: TabLayout.Tab) { + // 필요한 경우 구현 + } + + override fun onTabReselected(tab: TabLayout.Tab) { + // 필요한 경우 구현 + } + }) + + // 초기 탭 선택 + showTabContent(currentTab) + } + + private fun showTabContent(position: Int) { + val fragmentTransaction = supportFragmentManager.beginTransaction() + + // 기존 프래그먼트 제거 + supportFragmentManager.fragments.forEach { + fragmentTransaction.remove(it) + } + + // 선택된 탭에 따라 프래그먼트 표시 + val fragment = when (position) { + 1 -> SeriesMainDayOfWeekFragment() + 2 -> SeriesMainByGenreFragment() + else -> SeriesMainHomeFragment() + } + + fragmentTransaction.add(R.id.fl_container, fragment) + fragmentTransaction.commit() + } +} diff --git a/app/src/main/java/kr/co/vividnext/sodalive/audio_content/series/main/SeriesMainApi.kt b/app/src/main/java/kr/co/vividnext/sodalive/audio_content/series/main/SeriesMainApi.kt new file mode 100644 index 00000000..b1264f63 --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/audio_content/series/main/SeriesMainApi.kt @@ -0,0 +1,55 @@ +package kr.co.vividnext.sodalive.audio_content.series.main + +import io.reactivex.rxjava3.core.Single +import kr.co.vividnext.sodalive.audio_content.series.GetSeriesListResponse +import kr.co.vividnext.sodalive.audio_content.series.main.by_genre.GetSeriesGenreListResponse +import kr.co.vividnext.sodalive.audio_content.series.main.home.SeriesHomeResponse +import kr.co.vividnext.sodalive.common.ApiResponse +import kr.co.vividnext.sodalive.home.SeriesPublishedDaysOfWeek +import kr.co.vividnext.sodalive.settings.ContentType +import retrofit2.http.GET +import retrofit2.http.Header +import retrofit2.http.Query + +interface SeriesMainApi { + @GET("/audio-content/series/main") + fun fetchHome( + @Query("isAdultContentVisible") isAdultContentVisible: Boolean, + @Query("contentType") contentType: ContentType, + @Header("Authorization") authHeader: String + ): Single> + + @GET("/audio-content/series/main/recommend") + fun getRecommendSeriesList( + @Query("isAdultContentVisible") isAdultContentVisible: Boolean, + @Query("contentType") contentType: ContentType, + @Header("Authorization") authHeader: String + ): Single>> + + @GET("/audio-content/series/main/day-of-week") + fun getDayOfWeekSeriesList( + @Query("dayOfWeek") dayOfWeek: SeriesPublishedDaysOfWeek, + @Query("isAdultContentVisible") isAdultContentVisible: Boolean, + @Query("contentType") contentType: ContentType, + @Query("page") page: Int, + @Query("size") size: Int, + @Header("Authorization") authHeader: String + ): Single>> + + @GET("/audio-content/series/main/genre-list") + fun getGenreList( + @Query("isAdultContentVisible") isAdultContentVisible: Boolean, + @Query("contentType") contentType: ContentType, + @Header("Authorization") authHeader: String + ): Single>> + + @GET("/audio-content/series/main/list-by-genre") + fun getSeriesListByGenre( + @Query("genreId") genreId: Long, + @Query("isAdultContentVisible") isAdultContentVisible: Boolean, + @Query("contentType") contentType: ContentType, + @Query("page") page: Int, + @Query("size") size: Int, + @Header("Authorization") authHeader: String + ): Single> +} diff --git a/app/src/main/java/kr/co/vividnext/sodalive/audio_content/series/main/SeriesMainRepository.kt b/app/src/main/java/kr/co/vividnext/sodalive/audio_content/series/main/SeriesMainRepository.kt new file mode 100644 index 00000000..baf557d6 --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/audio_content/series/main/SeriesMainRepository.kt @@ -0,0 +1,55 @@ +package kr.co.vividnext.sodalive.audio_content.series.main + +import kr.co.vividnext.sodalive.common.SharedPreferenceManager +import kr.co.vividnext.sodalive.home.SeriesPublishedDaysOfWeek +import kr.co.vividnext.sodalive.settings.ContentType + +class SeriesMainRepository( + private val api: SeriesMainApi +) { + fun fetchData(token: String) = api.fetchHome( + isAdultContentVisible = SharedPreferenceManager.isAdultContentVisible, + contentType = ContentType.entries[SharedPreferenceManager.contentPreference], + authHeader = token + ) + + fun getRecommendSeriesList(token: String) = api.getRecommendSeriesList( + isAdultContentVisible = SharedPreferenceManager.isAdultContentVisible, + contentType = ContentType.entries[SharedPreferenceManager.contentPreference], + authHeader = token + ) + + fun getDayOfWeekSeriesList( + dayOfWeek: SeriesPublishedDaysOfWeek, + page: Int, + size: Int, + token: String + ) = api.getDayOfWeekSeriesList( + dayOfWeek = dayOfWeek, + isAdultContentVisible = SharedPreferenceManager.isAdultContentVisible, + contentType = ContentType.entries[SharedPreferenceManager.contentPreference], + page = page - 1, + size = size, + authHeader = token + ) + + fun getGenreList(token: String) = api.getGenreList( + isAdultContentVisible = SharedPreferenceManager.isAdultContentVisible, + contentType = ContentType.entries[SharedPreferenceManager.contentPreference], + authHeader = token + ) + + fun getSeriesListByGenre( + genreId: Long, + page: Int, + size: Int, + token: String + ) = api.getSeriesListByGenre( + genreId = genreId, + isAdultContentVisible = SharedPreferenceManager.isAdultContentVisible, + contentType = ContentType.entries[SharedPreferenceManager.contentPreference], + page = page - 1, + size = size, + authHeader = token + ) +} diff --git a/app/src/main/java/kr/co/vividnext/sodalive/audio_content/series/main/by_genre/GetSeriesGenreListResponse.kt b/app/src/main/java/kr/co/vividnext/sodalive/audio_content/series/main/by_genre/GetSeriesGenreListResponse.kt new file mode 100644 index 00000000..16dd3035 --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/audio_content/series/main/by_genre/GetSeriesGenreListResponse.kt @@ -0,0 +1,10 @@ +package kr.co.vividnext.sodalive.audio_content.series.main.by_genre + +import androidx.annotation.Keep +import com.google.gson.annotations.SerializedName + +@Keep +data class GetSeriesGenreListResponse( + @SerializedName("id") val id: Long, + @SerializedName("genre") val genre: String +) 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 new file mode 100644 index 00000000..6119b0f2 --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/audio_content/series/main/by_genre/SeriesMainByGenreFragment.kt @@ -0,0 +1,9 @@ +package kr.co.vividnext.sodalive.audio_content.series.main.by_genre + +import kr.co.vividnext.sodalive.base.BaseFragment +import kr.co.vividnext.sodalive.databinding.FragmentSeriesMainByGenreBinding + +class SeriesMainByGenreFragment : BaseFragment( + FragmentSeriesMainByGenreBinding::inflate +) { +} 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 new file mode 100644 index 00000000..7c1a2521 --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/audio_content/series/main/by_genre/SeriesMainByGenreViewModel.kt @@ -0,0 +1,18 @@ +package kr.co.vividnext.sodalive.audio_content.series.main.by_genre + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import kr.co.vividnext.sodalive.audio_content.series.main.SeriesMainRepository +import kr.co.vividnext.sodalive.base.BaseViewModel + +class SeriesMainByGenreViewModel( + private val repository: SeriesMainRepository +) : BaseViewModel() { + private var _isLoading = MutableLiveData(false) + val isLoading: LiveData + get() = _isLoading + + private val _toastLiveData = MutableLiveData() + val toastLiveData: LiveData + get() = _toastLiveData +} diff --git a/app/src/main/java/kr/co/vividnext/sodalive/audio_content/series/main/day_of_week/SeriesMainDayOfWeekFragment.kt b/app/src/main/java/kr/co/vividnext/sodalive/audio_content/series/main/day_of_week/SeriesMainDayOfWeekFragment.kt new file mode 100644 index 00000000..9bc206f2 --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/audio_content/series/main/day_of_week/SeriesMainDayOfWeekFragment.kt @@ -0,0 +1,172 @@ +package kr.co.vividnext.sodalive.audio_content.series.main.day_of_week + +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.FragmentSeriesMainDayOfWeekBinding +import kr.co.vividnext.sodalive.extensions.dpToPx +import kr.co.vividnext.sodalive.home.DayOfWeekAdapter +import kr.co.vividnext.sodalive.home.SeriesPublishedDaysOfWeek +import org.koin.android.ext.android.inject +import java.util.Calendar +import kotlin.math.roundToInt + +class SeriesMainDayOfWeekFragment : BaseFragment( + FragmentSeriesMainDayOfWeekBinding::inflate +) { + private val viewModel: SeriesMainDayOfWeekViewModel by inject() + + private lateinit var loadingDialog: LoadingDialog + private lateinit var adapter: SeriesListAdapter + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + setupView() + observeViewModel() + } + + private fun setupView() { + loadingDialog = LoadingDialog(requireActivity(), layoutInflater) + + setupDayOfWeekDay() + setupSeriesView() + + val dayOfWeeks = listOf( + SeriesPublishedDaysOfWeek.RANDOM, + SeriesPublishedDaysOfWeek.SUN, + SeriesPublishedDaysOfWeek.MON, + SeriesPublishedDaysOfWeek.TUE, + SeriesPublishedDaysOfWeek.WED, + SeriesPublishedDaysOfWeek.THU, + SeriesPublishedDaysOfWeek.FRI, + SeriesPublishedDaysOfWeek.SAT + ) + + val calendar = Calendar.getInstance() + val dayIndex = calendar.get(Calendar.DAY_OF_WEEK) + + viewModel.dayOfWeek = dayOfWeeks[dayIndex] + } + + private fun setupDayOfWeekDay() { + val dayOfWeekAdapter = DayOfWeekAdapter(screenWidth = screenWidth) { + adapter.clear() + viewModel.dayOfWeek = it + } + + val rvDayOfWeek = binding.rvSeriesDayOfWeekDay + val layoutManager = object : LinearLayoutManager( + context, + HORIZONTAL, + false + ) { + override fun canScrollVertically() = false + override fun canScrollHorizontally() = false + } + rvDayOfWeek.layoutManager = layoutManager + + rvDayOfWeek.addItemDecoration(object : RecyclerView.ItemDecoration() { + override fun getItemOffsets( + outRect: Rect, + view: View, + parent: RecyclerView, + state: RecyclerView.State + ) { + super.getItemOffsets(outRect, view, parent, state) + + when (parent.getChildAdapterPosition(view)) { + 0 -> { + outRect.left = 0 + outRect.right = 2.5f.dpToPx().toInt() + } + + dayOfWeekAdapter.itemCount - 1 -> { + outRect.left = 2.5f.dpToPx().toInt() + outRect.right = 0 + } + + else -> { + outRect.left = 2.5f.dpToPx().toInt() + outRect.right = 2.5f.dpToPx().toInt() + } + } + } + }) + rvDayOfWeek.adapter = dayOfWeekAdapter + } + + private fun setupSeriesView() { + adapter = 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.rvSeriesDayOfWeek + recyclerView.layoutManager = GridLayoutManager(requireContext(), spanCount) + + recyclerView.addItemDecoration( + GridSpacingItemDecoration(spanCount, spacingPx, false) + ) + + 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() + } + } + }) + + recyclerView.adapter = adapter + + viewModel.seriesListLiveData.observe(viewLifecycleOwner) { + adapter.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() } + } + } +} diff --git a/app/src/main/java/kr/co/vividnext/sodalive/audio_content/series/main/day_of_week/SeriesMainDayOfWeekViewModel.kt b/app/src/main/java/kr/co/vividnext/sodalive/audio_content/series/main/day_of_week/SeriesMainDayOfWeekViewModel.kt new file mode 100644 index 00000000..032a0528 --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/audio_content/series/main/day_of_week/SeriesMainDayOfWeekViewModel.kt @@ -0,0 +1,85 @@ +package kr.co.vividnext.sodalive.audio_content.series.main.day_of_week + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import com.orhanobut.logger.Logger +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 +import kr.co.vividnext.sodalive.home.SeriesPublishedDaysOfWeek + +class SeriesMainDayOfWeekViewModel( + private val repository: SeriesMainRepository +) : BaseViewModel() { + private var _isLoading = MutableLiveData(false) + val isLoading: LiveData + get() = _isLoading + + private val _toastLiveData = MutableLiveData() + val toastLiveData: LiveData + get() = _toastLiveData + + private val _seriesListLiveData = MutableLiveData>() + val seriesListLiveData: LiveData> + get() = _seriesListLiveData + + private var page = 1 + private var isLast = false + private val pageSize = 20 + + var dayOfWeek = SeriesPublishedDaysOfWeek.RANDOM + set(newValue) { + if (field != newValue) { + page = 1 + isLast = false + field = newValue + getSeriesList() + } + } + + fun getSeriesList() { + if (isLast) return + _isLoading.value = true + + compositeDisposable.add( + repository.getDayOfWeekSeriesList( + dayOfWeek = dayOfWeek, + 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 + + if (data.isNotEmpty()) { + _seriesListLiveData.value = data + } else { + isLast = true + } + } else { + if (it.message != null) { + _toastLiveData.value = it.message + } else { + _toastLiveData.value = "알 수 없는 오류가 발생했습니다. 다시 시도해 주세요." + } + } + }, + { + _isLoading.value = false + it.message?.let { message -> Logger.e(message) } + _toastLiveData.value = "알 수 없는 오류가 발생했습니다. 다시 시도해 주세요." + } + ) + ) + } +} diff --git a/app/src/main/java/kr/co/vividnext/sodalive/audio_content/series/main/home/SeriesBannerAdapter.kt b/app/src/main/java/kr/co/vividnext/sodalive/audio_content/series/main/home/SeriesBannerAdapter.kt new file mode 100644 index 00000000..2e50562a --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/audio_content/series/main/home/SeriesBannerAdapter.kt @@ -0,0 +1,53 @@ +package kr.co.vividnext.sodalive.audio_content.series.main.home + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.drawable.Drawable +import android.widget.FrameLayout +import android.widget.ImageView +import com.bumptech.glide.Glide +import com.bumptech.glide.request.target.CustomTarget +import com.bumptech.glide.request.transition.Transition +import com.zhpan.bannerview.BaseBannerAdapter +import com.zhpan.bannerview.BaseViewHolder +import kr.co.vividnext.sodalive.R + +class SeriesBannerAdapter( + private val context: Context, + private val itemWidth: Int, + private val itemHeight: Int, + private val onClick: (SeriesBannerResponse) -> Unit +) : BaseBannerAdapter() { + override fun bindData( + holder: BaseViewHolder, + data: SeriesBannerResponse, + position: Int, + pageSize: Int + ) { + val ivBanner = holder.findViewById(R.id.iv_recommend_live) + val layoutParams = ivBanner.layoutParams as FrameLayout.LayoutParams + + layoutParams.width = itemWidth + layoutParams.height = itemHeight + + Glide + .with(context) + .asBitmap() + .load(data.imagePath) + .into(object : CustomTarget() { + override fun onResourceReady(resource: Bitmap, transition: Transition?) { + ivBanner.setImageBitmap(resource) + ivBanner.layoutParams = layoutParams + } + + override fun onLoadCleared(placeholder: Drawable?) { + } + }) + + ivBanner.setOnClickListener { onClick(data) } + } + + override fun getLayoutId(viewType: Int): Int { + return R.layout.item_recommend_live + } +} diff --git a/app/src/main/java/kr/co/vividnext/sodalive/audio_content/series/main/home/SeriesHomeResponse.kt b/app/src/main/java/kr/co/vividnext/sodalive/audio_content/series/main/home/SeriesHomeResponse.kt new file mode 100644 index 00000000..f6ac770b --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/audio_content/series/main/home/SeriesHomeResponse.kt @@ -0,0 +1,23 @@ +package kr.co.vividnext.sodalive.audio_content.series.main.home + +import androidx.annotation.Keep +import com.google.gson.annotations.SerializedName +import kr.co.vividnext.sodalive.audio_content.series.GetSeriesListResponse + +@Keep +data class SeriesHomeResponse( + @SerializedName("banners") + val banners: List, + + @SerializedName("completedSeriesList") + val completedSeriesList: List, + + @SerializedName("recommendSeriesList") + val recommendSeriesList: List +) + +@Keep +data class SeriesBannerResponse( + @SerializedName("seriesId") val seriesId: Long, + @SerializedName("imagePath") val imagePath: String +) diff --git a/app/src/main/java/kr/co/vividnext/sodalive/audio_content/series/main/home/SeriesMainHomeFragment.kt b/app/src/main/java/kr/co/vividnext/sodalive/audio_content/series/main/home/SeriesMainHomeFragment.kt new file mode 100644 index 00000000..ee8371ce --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/audio_content/series/main/home/SeriesMainHomeFragment.kt @@ -0,0 +1,233 @@ +package kr.co.vividnext.sodalive.audio_content.series.main.home + +import android.content.Intent +import android.graphics.Rect +import android.os.Bundle +import android.view.View +import android.widget.LinearLayout +import android.widget.Toast +import androidx.core.content.ContextCompat +import androidx.recyclerview.widget.GridLayoutManager +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.zhpan.bannerview.BaseBannerAdapter +import com.zhpan.indicator.enums.IndicatorSlideMode +import com.zhpan.indicator.enums.IndicatorStyle +import kr.co.vividnext.sodalive.R +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.common.SharedPreferenceManager +import kr.co.vividnext.sodalive.databinding.FragmentSeriesMainHomeBinding +import kr.co.vividnext.sodalive.extensions.dpToPx +import kr.co.vividnext.sodalive.home.HomeSeriesAdapter +import kr.co.vividnext.sodalive.main.MainActivity +import org.koin.android.ext.android.inject +import kotlin.math.roundToInt + +class SeriesMainHomeFragment : BaseFragment( + FragmentSeriesMainHomeBinding::inflate +) { + private val viewModel: SeriesMainHomeViewModel by inject() + + private lateinit var loadingDialog: LoadingDialog + + private lateinit var bannerAdapter: SeriesBannerAdapter + private lateinit var completedAdapter: HomeSeriesAdapter + private lateinit var recommendAdapter: SeriesListAdapter + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + setupView() + observeViewModel() + + viewModel.fetchData() + } + + private fun setupView() { + loadingDialog = LoadingDialog(requireActivity(), layoutInflater) + + setupBanner() + setupCompletedSeriesView() + setupRecommendSeriesView() + } + + private fun setupBanner() { + val layoutParams = binding + .bannerSlider + .layoutParams as LinearLayout.LayoutParams + + val pagerWidth = screenWidth + val pagerHeight = pagerWidth * 198 / 352 + layoutParams.width = pagerWidth + layoutParams.height = pagerHeight + + bannerAdapter = SeriesBannerAdapter( + requireContext(), + pagerWidth, + pagerHeight + ) { + startActivity( + Intent( + requireContext(), + SeriesDetailActivity::class.java + ).apply { + putExtra(Constants.EXTRA_SERIES_ID, it.seriesId) + } + ) + } + + binding + .bannerSlider + .layoutParams = layoutParams + + binding.bannerSlider.apply { + adapter = bannerAdapter as BaseBannerAdapter + + setLifecycleRegistry(lifecycle) + setScrollDuration(1000) + setInterval(4 * 1000) + }.create() + + binding + .bannerSlider + .setIndicatorView(binding.indicatorBanner) + .setIndicatorStyle(IndicatorStyle.ROUND_RECT) + .setIndicatorSlideMode(IndicatorSlideMode.SMOOTH) + .setIndicatorVisibility(View.GONE) + .setIndicatorSliderColor( + ContextCompat.getColor(requireContext(), R.color.color_909090), + ContextCompat.getColor(requireContext(), R.color.color_3bb9f1) + ) + .setIndicatorSliderWidth(10f.dpToPx().toInt(), 10f.dpToPx().toInt()) + .setIndicatorHeight(10f.dpToPx().toInt()) + + viewModel.bannerListLiveData.observe(viewLifecycleOwner) { + if (it.isNotEmpty()) { + binding.llBanner.visibility = View.VISIBLE + binding.bannerSlider.refreshData(it) + } else { + binding.llBanner.visibility = View.GONE + } + } + } + + private fun setupCompletedSeriesView() { + completedAdapter = HomeSeriesAdapter { + if (SharedPreferenceManager.token.isNotBlank()) { + startActivity( + Intent(requireContext(), SeriesDetailActivity::class.java).apply { + putExtra(Constants.EXTRA_SERIES_ID, it) + } + ) + } else { + (requireActivity() as MainActivity).showLoginActivity() + } + } + + val recyclerView = binding.rvCompletedSeries + recyclerView.layoutManager = LinearLayoutManager( + context, + LinearLayoutManager.HORIZONTAL, + false + ) + + recyclerView.addItemDecoration(object : RecyclerView.ItemDecoration() { + override fun getItemOffsets( + outRect: Rect, + view: View, + parent: RecyclerView, + state: RecyclerView.State + ) { + super.getItemOffsets(outRect, view, parent, state) + + when (parent.getChildAdapterPosition(view)) { + 0 -> { + outRect.left = 0 + outRect.right = 8f.dpToPx().toInt() + } + + completedAdapter.itemCount - 1 -> { + outRect.left = 8f.dpToPx().toInt() + outRect.right = 0 + } + + else -> { + outRect.left = 8f.dpToPx().toInt() + outRect.right = 8f.dpToPx().toInt() + } + } + } + }) + recyclerView.adapter = completedAdapter + + viewModel.completedSeriesLiveData.observe(viewLifecycleOwner) { + if (it.isNotEmpty()) { + binding.llCompletedSeries.visibility = View.VISIBLE + completedAdapter.addItems(it) + } else { + binding.llCompletedSeries.visibility = View.GONE + } + } + } + + private fun setupRecommendSeriesView() { + recommendAdapter = 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.rvRecommendSeries + recyclerView.layoutManager = GridLayoutManager(requireContext(), spanCount) + + recyclerView.addItemDecoration( + GridSpacingItemDecoration(spanCount, spacingPx, false) + ) + + recyclerView.adapter = recommendAdapter + + binding.ivRecommendRefresh.setOnClickListener { + viewModel.getRecommendSeriesList() + } + + viewModel.recommendSeriesLiveData.observe(viewLifecycleOwner) { + if (it.isNotEmpty()) { + binding.llRecommendSeries.visibility = View.VISIBLE + recommendAdapter.clear() + recommendAdapter.addItems(it) + } else { + binding.llRecommendSeries.visibility = View.GONE + } + } + } + + 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() } + } + } +} diff --git a/app/src/main/java/kr/co/vividnext/sodalive/audio_content/series/main/home/SeriesMainHomeViewModel.kt b/app/src/main/java/kr/co/vividnext/sodalive/audio_content/series/main/home/SeriesMainHomeViewModel.kt new file mode 100644 index 00000000..fd585ce4 --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/audio_content/series/main/home/SeriesMainHomeViewModel.kt @@ -0,0 +1,101 @@ +package kr.co.vividnext.sodalive.audio_content.series.main.home + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import com.orhanobut.logger.Logger +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 SeriesMainHomeViewModel( + private val repository: SeriesMainRepository +) : BaseViewModel() { + private var _isLoading = MutableLiveData(false) + val isLoading: LiveData + get() = _isLoading + + private val _toastLiveData = MutableLiveData() + val toastLiveData: LiveData + get() = _toastLiveData + + private var _bannerListLiveData = MutableLiveData>() + val bannerListLiveData: LiveData> + get() = _bannerListLiveData + + private var _completedSeriesLiveData = + MutableLiveData>() + val completedSeriesLiveData: LiveData> + get() = _completedSeriesLiveData + + private var _recommendSeriesLiveData = + MutableLiveData>() + val recommendSeriesLiveData: LiveData> + get() = _recommendSeriesLiveData + + fun fetchData() { + _isLoading.value = true + + compositeDisposable.add( + repository.fetchData(token = "Bearer ${SharedPreferenceManager.token}") + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { + _isLoading.value = false + + val data = it.data + if (it.success && data != null) { + _bannerListLiveData.value = data.banners + _completedSeriesLiveData.value = data.completedSeriesList + _recommendSeriesLiveData.value = data.recommendSeriesList + } else { + if (it.message != null) { + _toastLiveData.postValue(it.message) + } else { + _toastLiveData.postValue( + "알 수 없는 오류가 발생했습니다. 다시 시도해 주세요." + ) + } + } + }, + { + _isLoading.value = false + it.message?.let { message -> Logger.e(message) } + _toastLiveData.postValue("알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.") + } + ) + ) + } + + fun getRecommendSeriesList() { + _isLoading.value = true + + compositeDisposable.add( + repository.getRecommendSeriesList(token = "Bearer ${SharedPreferenceManager.token}") + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { + _isLoading.value = false + if (it.success && it.data != null) { + _recommendSeriesLiveData.value = it.data + } else { + if (it.message != null) { + _toastLiveData.value = it.message + } else { + _toastLiveData.value = "알 수 없는 오류가 발생했습니다. 다시 시도해 주세요." + } + } + }, + { + _isLoading.value = false + it.message?.let { message -> Logger.e(message) } + _toastLiveData.value = "알 수 없는 오류가 발생했습니다. 다시 시도해 주세요." + } + ) + ) + } +} diff --git a/app/src/main/java/kr/co/vividnext/sodalive/di/AppDI.kt b/app/src/main/java/kr/co/vividnext/sodalive/di/AppDI.kt index 44346947..5f8ed03c 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/di/AppDI.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/di/AppDI.kt @@ -32,6 +32,11 @@ import kr.co.vividnext.sodalive.audio_content.series.SeriesListAllViewModel import kr.co.vividnext.sodalive.audio_content.series.SeriesRepository import kr.co.vividnext.sodalive.audio_content.series.content.SeriesContentAllViewModel import kr.co.vividnext.sodalive.audio_content.series.detail.SeriesDetailViewModel +import kr.co.vividnext.sodalive.audio_content.series.main.SeriesMainApi +import kr.co.vividnext.sodalive.audio_content.series.main.SeriesMainRepository +import kr.co.vividnext.sodalive.audio_content.series.main.by_genre.SeriesMainByGenreViewModel +import kr.co.vividnext.sodalive.audio_content.series.main.day_of_week.SeriesMainDayOfWeekViewModel +import kr.co.vividnext.sodalive.audio_content.series.main.home.SeriesMainHomeViewModel import kr.co.vividnext.sodalive.audio_content.upload.AudioContentUploadViewModel import kr.co.vividnext.sodalive.audio_content.upload.theme.AudioContentThemeViewModel import kr.co.vividnext.sodalive.audition.AuditionApi @@ -224,6 +229,7 @@ class AppDI(private val context: Context, isDebugMode: Boolean) { single { ApiBuilder().build(get(), TermsApi::class.java) } single { ApiBuilder().build(get(), EventApi::class.java) } single { ApiBuilder().build(get(), SeriesApi::class.java) } + single { ApiBuilder().build(get(), SeriesMainApi::class.java) } single { ApiBuilder().build(get(), ReportApi::class.java) } single { ApiBuilder().build(get(), LiveRecommendApi::class.java) } single { ApiBuilder().build(get(), ExplorerApi::class.java) } @@ -333,6 +339,9 @@ class AppDI(private val context: Context, isDebugMode: Boolean) { viewModel { NewCharactersAllViewModel(get()) } viewModel { OriginalWorkViewModel(get()) } viewModel { OriginalWorkDetailViewModel(get()) } + viewModel { SeriesMainHomeViewModel(get()) } + viewModel { SeriesMainByGenreViewModel(get()) } + viewModel { SeriesMainDayOfWeekViewModel(get()) } } private val repositoryModule = module { @@ -376,6 +385,7 @@ class AppDI(private val context: Context, isDebugMode: Boolean) { factory { CharacterCommentRepository(get()) } factory { NewCharactersRepository(get()) } factory { OriginalWorkRepository(get()) } + factory { SeriesMainRepository(get()) } } diff --git a/app/src/main/java/kr/co/vividnext/sodalive/home/HomeFragment.kt b/app/src/main/java/kr/co/vividnext/sodalive/home/HomeFragment.kt index 5879abae..99bf6122 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/home/HomeFragment.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/home/HomeFragment.kt @@ -26,13 +26,16 @@ import com.zhpan.indicator.enums.IndicatorSlideMode import com.zhpan.indicator.enums.IndicatorStyle import kr.co.vividnext.sodalive.R import kr.co.vividnext.sodalive.audio_content.AudioContentPlayService +import kr.co.vividnext.sodalive.audio_content.all.AudioContentAllActivity import kr.co.vividnext.sodalive.audio_content.all.AudioContentNewAllActivity import kr.co.vividnext.sodalive.audio_content.box.AudioContentBoxActivity import kr.co.vividnext.sodalive.audio_content.detail.AudioContentDetailActivity import kr.co.vividnext.sodalive.audio_content.main.AudioContentBannerType import kr.co.vividnext.sodalive.audio_content.main.banner.AudioContentMainBannerAdapter import kr.co.vividnext.sodalive.audio_content.player.AudioContentPlayerService +import kr.co.vividnext.sodalive.audio_content.series.SeriesListAllActivity import kr.co.vividnext.sodalive.audio_content.series.detail.SeriesDetailActivity +import kr.co.vividnext.sodalive.audio_content.series.main.SeriesMainActivity import kr.co.vividnext.sodalive.audio_content.upload.AudioContentUploadActivity import kr.co.vividnext.sodalive.audition.AuditionActivity import kr.co.vividnext.sodalive.base.BaseFragment @@ -646,7 +649,10 @@ class HomeFragment : BaseFragment(FragmentHomeBinding::infl // ‘오직 보이스온에서만’ 전체보기: isOriginal=true로 시리즈 전체보기 화면 진입 if (SharedPreferenceManager.token.isNotBlank()) { startActivity( - Intent(requireContext(), kr.co.vividnext.sodalive.audio_content.series.SeriesListAllActivity::class.java).apply { + Intent( + requireContext(), + SeriesListAllActivity::class.java + ).apply { putExtra(kr.co.vividnext.sodalive.common.Constants.EXTRA_IS_ORIGINAL, true) } ) @@ -791,7 +797,7 @@ class HomeFragment : BaseFragment(FragmentHomeBinding::infl outRect.right = 2.5f.dpToPx().toInt() } - seriesDayOfWeekAdapter.itemCount - 1 -> { + dayOfWeekAdapter.itemCount - 1 -> { outRect.left = 2.5f.dpToPx().toInt() outRect.right = 0 } @@ -804,6 +810,21 @@ class HomeFragment : BaseFragment(FragmentHomeBinding::infl } }) rvDayOfWeek.adapter = dayOfWeekAdapter + + binding.tvSeriesDayOfWeekAll.setOnClickListener { + if (SharedPreferenceManager.token.isNotBlank()) { + startActivity( + Intent( + requireContext(), + SeriesMainActivity::class.java + ).apply { + putExtra(Constants.EXTRA_AUDIO_CONTENT_FREE, true) + } + ) + } else { + (requireActivity() as MainActivity).showLoginActivity() + } + } } private fun setupPopularCharacters() { @@ -1127,7 +1148,7 @@ class HomeFragment : BaseFragment(FragmentHomeBinding::infl binding.tvFreeContentAll.setOnClickListener { if (SharedPreferenceManager.token.isNotBlank()) { startActivity( - Intent(requireContext(), kr.co.vividnext.sodalive.audio_content.all.AudioContentAllActivity::class.java).apply { + Intent(requireContext(), AudioContentAllActivity::class.java).apply { putExtra(Constants.EXTRA_AUDIO_CONTENT_FREE, true) } ) @@ -1211,7 +1232,10 @@ class HomeFragment : BaseFragment(FragmentHomeBinding::infl binding.tvPointContentAll.setOnClickListener { if (SharedPreferenceManager.token.isNotBlank()) { startActivity( - Intent(requireContext(), kr.co.vividnext.sodalive.audio_content.all.AudioContentAllActivity::class.java).apply { + Intent( + requireContext(), + AudioContentAllActivity::class.java + ).apply { putExtra(Constants.EXTRA_AUDIO_CONTENT_POINT_ONLY, true) } ) @@ -1229,7 +1253,10 @@ class HomeFragment : BaseFragment(FragmentHomeBinding::infl recommendContentAdapter = HomeContentAdapter(onClickItem = { if (SharedPreferenceManager.token.isNotBlank()) { startActivity( - Intent(requireContext(), AudioContentDetailActivity::class.java).apply { + Intent( + requireContext(), + AudioContentDetailActivity::class.java + ).apply { putExtra(Constants.EXTRA_AUDIO_CONTENT_ID, it) } ) diff --git a/app/src/main/res/layout/activity_series_main.xml b/app/src/main/res/layout/activity_series_main.xml new file mode 100644 index 00000000..54dce6cf --- /dev/null +++ b/app/src/main/res/layout/activity_series_main.xml @@ -0,0 +1,30 @@ + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_home.xml b/app/src/main/res/layout/fragment_home.xml index 5e5d1a8b..ef32e611 100644 --- a/app/src/main/res/layout/fragment_home.xml +++ b/app/src/main/res/layout/fragment_home.xml @@ -253,15 +253,32 @@ android:layout_marginBottom="48dp" android:orientation="vertical"> - + android:gravity="center_vertical" + android:orientation="horizontal" + android:paddingHorizontal="24dp"> + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_series_main_day_of_week.xml b/app/src/main/res/layout/fragment_series_main_day_of_week.xml new file mode 100644 index 00000000..4dc82d80 --- /dev/null +++ b/app/src/main/res/layout/fragment_series_main_day_of_week.xml @@ -0,0 +1,23 @@ + + + + + + + diff --git a/app/src/main/res/layout/fragment_series_main_home.xml b/app/src/main/res/layout/fragment_series_main_home.xml new file mode 100644 index 00000000..91729c6f --- /dev/null +++ b/app/src/main/res/layout/fragment_series_main_home.xml @@ -0,0 +1,111 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +