feat(home): 홈 추천 콘텐츠 섹션 추가 및 API 연동

This commit is contained in:
2025-11-11 16:21:53 +09:00
parent 81760ec99d
commit 3353ebb777
7 changed files with 175 additions and 6 deletions

View File

@@ -24,5 +24,6 @@ data class GetHomeResponse(
@SerializedName("contentRanking") val contentRanking: List<GetAudioContentRankingItem>, @SerializedName("contentRanking") val contentRanking: List<GetAudioContentRankingItem>,
@SerializedName("recommendChannelList") val recommendChannelList: List<RecommendChannelResponse>, @SerializedName("recommendChannelList") val recommendChannelList: List<RecommendChannelResponse>,
@SerializedName("freeContentList") val freeContentList: List<AudioContentMainItem>, @SerializedName("freeContentList") val freeContentList: List<AudioContentMainItem>,
@SerializedName("pointAvailableContentList") val pointAvailableContentList: List<AudioContentMainItem> @SerializedName("pointAvailableContentList") val pointAvailableContentList: List<AudioContentMainItem>,
@SerializedName("recommendContentList") val recommendContentList: List<AudioContentMainItem>
) )

View File

@@ -32,4 +32,11 @@ interface HomeApi {
@Query("contentType") contentType: ContentType, @Query("contentType") contentType: ContentType,
@Header("Authorization") authHeader: String @Header("Authorization") authHeader: String
): Single<ApiResponse<List<GetSeriesListResponse.SeriesListItem>>> ): Single<ApiResponse<List<GetSeriesListResponse.SeriesListItem>>>
@GET("/api/home/recommend-contents")
fun getRecommendContents(
@Query("isAdultContentVisible") isAdultContentVisible: Boolean,
@Query("contentType") contentType: ContentType,
@Header("Authorization") authHeader: String
): Single<ApiResponse<List<AudioContentMainItem>>>
} }

View File

@@ -12,6 +12,7 @@ import kr.co.vividnext.sodalive.databinding.ItemHomeContentBinding
import kr.co.vividnext.sodalive.extensions.dpToPx import kr.co.vividnext.sodalive.extensions.dpToPx
class HomeContentAdapter( class HomeContentAdapter(
private val itemSquareSizePx: Int? = null,
private val onClickItem: (Long) -> Unit, private val onClickItem: (Long) -> Unit,
) : RecyclerView.Adapter<HomeContentAdapter.ViewHolder>() { ) : RecyclerView.Adapter<HomeContentAdapter.ViewHolder>() {
private val items = mutableListOf<AudioContentMainItem>() private val items = mutableListOf<AudioContentMainItem>()
@@ -40,6 +41,18 @@ class HomeContentAdapter(
private val binding: ItemHomeContentBinding private val binding: ItemHomeContentBinding
) : RecyclerView.ViewHolder(binding.root) { ) : RecyclerView.ViewHolder(binding.root) {
fun bind(item: AudioContentMainItem) { fun bind(item: AudioContentMainItem) {
// 아이템 크기(정사각형) 동적 적용: 추천 콘텐츠 섹션에서만 사용
itemSquareSizePx?.let { size ->
val rootLp = binding.root.layoutParams
rootLp.width = size
binding.root.layoutParams = rootLp
val imageLp = binding.ivContentCoverImage.layoutParams
imageLp.width = size
imageLp.height = size
binding.ivContentCoverImage.layoutParams = imageLp
}
binding.ivPoint.visibility = if (item.isPointAvailable) { binding.ivPoint.visibility = if (item.isPointAvailable) {
View.VISIBLE View.VISIBLE
} else { } else {

View File

@@ -3,7 +3,6 @@ package kr.co.vividnext.sodalive.home
import android.content.Intent import android.content.Intent
import android.content.SharedPreferences import android.content.SharedPreferences
import android.graphics.Rect import android.graphics.Rect
import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
@@ -16,6 +15,7 @@ import android.widget.LinearLayout
import android.widget.Toast import android.widget.Toast
import androidx.annotation.OptIn import androidx.annotation.OptIn
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.net.toUri
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
@@ -87,6 +87,7 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>(FragmentHomeBinding::infl
private lateinit var recommendChannelAdapter: HomeRecommendChannelAdapter private lateinit var recommendChannelAdapter: HomeRecommendChannelAdapter
private lateinit var homeFreeContentAdapter: HomeContentAdapter private lateinit var homeFreeContentAdapter: HomeContentAdapter
private lateinit var homePointContentAdapter: HomeContentAdapter private lateinit var homePointContentAdapter: HomeContentAdapter
private lateinit var recommendContentAdapter: HomeContentAdapter
private val handler = Handler(Looper.getMainLooper()) private val handler = Handler(Looper.getMainLooper())
@@ -191,6 +192,7 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>(FragmentHomeBinding::infl
setupRecommendChannel() setupRecommendChannel()
setupFreeContent() setupFreeContent()
setupPointContent() setupPointContent()
setupRecommendContent()
} }
private fun setupLiveView() { private fun setupLiveView() {
@@ -534,7 +536,7 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>(FragmentHomeBinding::infl
} }
AudioContentBannerType.LINK -> { AudioContentBannerType.LINK -> {
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(it.link!!))) startActivity(Intent(Intent.ACTION_VIEW, it.link!!.toUri()))
} }
} }
} else { } else {
@@ -1156,6 +1158,76 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>(FragmentHomeBinding::infl
} }
} }
private fun setupRecommendContent() {
// 제목 설정: 로그인 여부에 따라 변경
val title = if (SharedPreferenceManager.token.isNotBlank()) {
"${SharedPreferenceManager.nickname}님을 위한 추천 콘텐츠"
} else {
"추천 콘텐츠"
}
binding.tvRecommendContent.text = title
// 아이템 정사각형 크기 계산: (screenWidth - (24*2) - 16) / 2
val itemSize = ((screenWidth - 24f.dpToPx() * 2 - 16f.dpToPx()) / 2f).toInt()
// 어댑터 설정 (아이템 클릭 동작은 무료/포인트 콘텐츠와 동일)
recommendContentAdapter = HomeContentAdapter(onClickItem = {
if (SharedPreferenceManager.token.isNotBlank()) {
startActivity(
Intent(requireContext(), AudioContentDetailActivity::class.java).apply {
putExtra(Constants.EXTRA_AUDIO_CONTENT_ID, it)
}
)
} else {
(requireActivity() as MainActivity).showLoginActivity()
}
}, itemSquareSizePx = itemSize)
val rv = binding.rvRecommendContent
// 한 줄에 아이템 2개인 Grid
rv.layoutManager = GridLayoutManager(context, 2)
rv.addItemDecoration(object : RecyclerView.ItemDecoration() {
override fun getItemOffsets(
outRect: Rect,
view: View,
parent: RecyclerView,
state: RecyclerView.State
) {
super.getItemOffsets(outRect, view, parent, state)
outRect.top = 8f.dpToPx().toInt()
outRect.bottom = 8f.dpToPx().toInt()
val position = parent.getChildAdapterPosition(view)
// 좌/우 간격 설정: 각 컬럼 간 간격 유지
if (position % 2 == 0) {
// 왼쪽 컬럼
outRect.left = 0f.dpToPx().toInt()
outRect.right = 8f.dpToPx().toInt()
} else {
// 오른쪽 컬럼
outRect.left = 8f.dpToPx().toInt()
outRect.right = 0f.dpToPx().toInt()
}
}
})
rv.adapter = recommendContentAdapter
// LiveData observe
viewModel.recommendContentListLiveData.observe(viewLifecycleOwner) {
if (it.isNotEmpty() || recommendContentAdapter.itemCount > 0) {
binding.llRecommendContent.visibility = View.VISIBLE
recommendContentAdapter.addItems(it)
} else {
binding.llRecommendContent.visibility = View.GONE
}
}
// 새로고침 아이콘 클릭
binding.ivRecommendRefresh.setOnClickListener {
viewModel.refreshRecommendContents()
}
}
private fun bindData() { private fun bindData() {
viewModel.isLoading.observe(viewLifecycleOwner) { viewModel.isLoading.observe(viewLifecycleOwner) {
if (it) { if (it) {

View File

@@ -8,14 +8,14 @@ class HomeRepository(private val api: HomeApi) {
fun fetchData(token: String) = api.getHomeData( fun fetchData(token: String) = api.getHomeData(
timezone = TimeZone.getDefault().id, timezone = TimeZone.getDefault().id,
isAdultContentVisible = SharedPreferenceManager.isAdultContentVisible, isAdultContentVisible = SharedPreferenceManager.isAdultContentVisible,
contentType = ContentType.values()[SharedPreferenceManager.contentPreference], contentType = ContentType.entries[SharedPreferenceManager.contentPreference],
authHeader = token authHeader = token
) )
fun getLatestContentByTheme(theme: String, token: String) = api.getLatestContentByTheme( fun getLatestContentByTheme(theme: String, token: String) = api.getLatestContentByTheme(
theme = theme, theme = theme,
isAdultContentVisible = SharedPreferenceManager.isAdultContentVisible, isAdultContentVisible = SharedPreferenceManager.isAdultContentVisible,
contentType = ContentType.values()[SharedPreferenceManager.contentPreference], contentType = ContentType.entries[SharedPreferenceManager.contentPreference],
authHeader = token authHeader = token
) )
@@ -24,7 +24,13 @@ class HomeRepository(private val api: HomeApi) {
) = api.getDayOfWeekSeriesList( ) = api.getDayOfWeekSeriesList(
dayOfWeek = dayOfWeek, dayOfWeek = dayOfWeek,
isAdultContentVisible = SharedPreferenceManager.isAdultContentVisible, isAdultContentVisible = SharedPreferenceManager.isAdultContentVisible,
contentType = ContentType.values()[SharedPreferenceManager.contentPreference], contentType = ContentType.entries[SharedPreferenceManager.contentPreference],
authHeader = token
)
fun getRecommendContents(token: String) = api.getRecommendContents(
isAdultContentVisible = SharedPreferenceManager.isAdultContentVisible,
contentType = ContentType.entries[SharedPreferenceManager.contentPreference],
authHeader = token authHeader = token
) )
} }

View File

@@ -79,6 +79,10 @@ class HomeViewModel(
val pointAvailableContentListLiveData: LiveData<List<AudioContentMainItem>> val pointAvailableContentListLiveData: LiveData<List<AudioContentMainItem>>
get() = _pointAvailableContentListLiveData get() = _pointAvailableContentListLiveData
private var _recommendContentListLiveData = MutableLiveData<List<AudioContentMainItem>>()
val recommendContentListLiveData: LiveData<List<AudioContentMainItem>>
get() = _recommendContentListLiveData
fun fetchData() { fun fetchData() {
_isLoading.value = true _isLoading.value = true
@@ -108,6 +112,7 @@ class HomeViewModel(
_pointAvailableContentListLiveData.value = _pointAvailableContentListLiveData.value =
data.pointAvailableContentList data.pointAvailableContentList
_recommendChannelListLiveData.value = data.recommendChannelList _recommendChannelListLiveData.value = data.recommendChannelList
_recommendContentListLiveData.value = data.recommendContentList
} else { } else {
if (it.message != null) { if (it.message != null) {
_toastLiveData.postValue(it.message) _toastLiveData.postValue(it.message)
@@ -127,6 +132,28 @@ class HomeViewModel(
) )
} }
fun refreshRecommendContents() {
_isLoading.value = true
compositeDisposable.add(
repository.getRecommendContents(token = "Bearer ${SharedPreferenceManager.token}")
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{
_isLoading.value = false
if (it.success && it.data != null) {
_recommendContentListLiveData.value = it.data!!
}
},
{
_isLoading.value = false
it.message?.let { message -> Logger.e(message) }
_toastLiveData.postValue("알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.")
}
)
)
}
fun getLatestContentByTheme(theme: String) { fun getLatestContentByTheme(theme: String) {
_isLoading.value = true _isLoading.value = true

View File

@@ -434,6 +434,49 @@
android:paddingHorizontal="24dp" /> android:paddingHorizontal="24dp" />
</LinearLayout> </LinearLayout>
<!-- 추천 콘텐츠 섹션 -->
<LinearLayout
android:id="@+id/ll_recommend_content"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="48dp"
android:orientation="vertical"
android:visibility="gone">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingHorizontal="24dp">
<TextView
android:id="@+id/tv_recommend_content"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:fontFamily="@font/pretendard_bold"
android:text="추천 콘텐츠"
android:textColor="@color/white"
android:textSize="24sp" />
<ImageView
android:id="@+id/iv_recommend_refresh"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:contentDescription="@null"
android:src="@drawable/ic_refresh" />
</LinearLayout>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rv_recommend_content"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:clipToPadding="false"
android:paddingHorizontal="24dp" />
</LinearLayout>
<io.github.glailton.expandabletextview.ExpandableTextView <io.github.glailton.expandabletextview.ExpandableTextView
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"