feat(chat-character): 추천 캐릭터 섹션 추가 및 새로고침 API 반영

This commit is contained in:
2025-11-11 17:17:18 +09:00
parent f97f9296b6
commit 62125f0873
6 changed files with 147 additions and 0 deletions

View File

@@ -51,6 +51,12 @@ interface CharacterApi {
@Query("size") size: Int @Query("size") size: Int
): Single<ApiResponse<kr.co.vividnext.sodalive.chat.character.newcharacters.RecentCharactersResponse>> ): Single<ApiResponse<kr.co.vividnext.sodalive.chat.character.newcharacters.RecentCharactersResponse>>
// 추천 캐릭터 새로고침
@GET("/api/chat/character/recommend")
fun refreshRecommendCharacters(
@Header("Authorization") authHeader: String
): Single<ApiResponse<List<Character>>>
@POST("/api/chat/character/image/purchase") @POST("/api/chat/character/image/purchase")
fun purchaseCharacterImage( fun purchaseCharacterImage(
@Header("Authorization") authHeader: String, @Header("Authorization") authHeader: String,

View File

@@ -10,6 +10,7 @@ data class CharacterHomeResponse(
@SerializedName("recentCharacters") val recentCharacters: List<RecentCharacter>, @SerializedName("recentCharacters") val recentCharacters: List<RecentCharacter>,
@SerializedName("popularCharacters") val popularCharacters: List<Character>, @SerializedName("popularCharacters") val popularCharacters: List<Character>,
@SerializedName("newCharacters") val newCharacters: List<Character>, @SerializedName("newCharacters") val newCharacters: List<Character>,
@SerializedName("recommendCharacters") val recommendCharacters: List<Character>
) )
@Keep @Keep

View File

@@ -10,6 +10,7 @@ import android.widget.Toast
import androidx.annotation.OptIn import androidx.annotation.OptIn
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.google.gson.Gson import com.google.gson.Gson
@@ -22,6 +23,7 @@ import kr.co.vividnext.sodalive.base.SodaDialog
import kr.co.vividnext.sodalive.chat.character.detail.CharacterDetailActivity import kr.co.vividnext.sodalive.chat.character.detail.CharacterDetailActivity
import kr.co.vividnext.sodalive.chat.character.detail.CharacterDetailActivity.Companion.EXTRA_CHARACTER_ID import kr.co.vividnext.sodalive.chat.character.detail.CharacterDetailActivity.Companion.EXTRA_CHARACTER_ID
import kr.co.vividnext.sodalive.chat.character.newcharacters.NewCharactersAllActivity import kr.co.vividnext.sodalive.chat.character.newcharacters.NewCharactersAllActivity
import kr.co.vividnext.sodalive.chat.character.newcharacters.NewCharactersAllAdapter
import kr.co.vividnext.sodalive.chat.character.recent.RecentCharacterAdapter import kr.co.vividnext.sodalive.chat.character.recent.RecentCharacterAdapter
import kr.co.vividnext.sodalive.common.LoadingDialog import kr.co.vividnext.sodalive.common.LoadingDialog
import kr.co.vividnext.sodalive.common.SharedPreferenceManager import kr.co.vividnext.sodalive.common.SharedPreferenceManager
@@ -46,6 +48,7 @@ class CharacterTabFragment : BaseFragment<FragmentCharacterTabBinding>(
private lateinit var recentCharacterAdapter: RecentCharacterAdapter private lateinit var recentCharacterAdapter: RecentCharacterAdapter
private lateinit var popularCharacterAdapter: CharacterAdapter private lateinit var popularCharacterAdapter: CharacterAdapter
private lateinit var newCharacterAdapter: CharacterAdapter private lateinit var newCharacterAdapter: CharacterAdapter
private lateinit var recommendCharacterAdapter: NewCharactersAllAdapter
private lateinit var loadingDialog: LoadingDialog private lateinit var loadingDialog: LoadingDialog
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
@@ -63,6 +66,7 @@ class CharacterTabFragment : BaseFragment<FragmentCharacterTabBinding>(
setupRecentCharactersRecyclerView() setupRecentCharactersRecyclerView()
setupPopularCharactersRecyclerView() setupPopularCharactersRecyclerView()
setupNewCharactersRecyclerView() setupNewCharactersRecyclerView()
setupRecommendCharactersRecyclerView()
} }
private fun setupBanner() { private fun setupBanner() {
@@ -308,6 +312,63 @@ class CharacterTabFragment : BaseFragment<FragmentCharacterTabBinding>(
} }
} }
private fun setupRecommendCharactersRecyclerView() {
// 추천 캐릭터 RecyclerView 설정 (2단 Grid)
recommendCharacterAdapter = NewCharactersAllAdapter {
onCharacterClick(it)
}
val recyclerView = binding.rvRecommendCharacters
recyclerView.layoutManager = GridLayoutManager(requireContext(), 2)
// 아이템 간격: 좌우 16dp(아이템 사이), 상하 16dp(행 사이). 가장자리는 RecyclerView padding(24dp) 사용
recyclerView.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)
if (position == RecyclerView.NO_POSITION) return
val spanCount = 2
val column = position % spanCount
val spacing = 16f.dpToPx().toInt()
// 좌/우 균등 분배: 좌 컬럼은 right=spacing/2, 우 컬럼은 left=spacing/2
outRect.left = if (column == 0) 0 else spacing / 2
outRect.right = if (column == 0) spacing / 2 else 0
// 수직 간격: 첫 번째 행 제외하고 top = spacing
if (position >= spanCount) {
outRect.top = spacing
} else {
outRect.top = 0
}
outRect.bottom = 0
}
})
recyclerView.adapter = recommendCharacterAdapter
// 새로고침 버튼 클릭 처리
binding.ivRecommendRefresh.setOnClickListener {
viewModel.refreshRecommendCharacters()
}
// 추천 캐릭터 관찰
viewModel.recommendCharacters.observe(viewLifecycleOwner) {
if (it.isNotEmpty()) {
binding.llRecommendCharacters.visibility = View.VISIBLE
recommendCharacterAdapter.clear()
recommendCharacterAdapter.addItems(it)
} else {
binding.llRecommendCharacters.visibility = View.GONE
}
}
}
private fun ensureLoginAndAuth(onAuthed: () -> Unit) { private fun ensureLoginAndAuth(onAuthed: () -> Unit) {
if (SharedPreferenceManager.token.isBlank()) { if (SharedPreferenceManager.token.isBlank()) {
(requireActivity() as MainActivity).showLoginActivity() (requireActivity() as MainActivity).showLoginActivity()

View File

@@ -4,4 +4,8 @@ class CharacterTabRepository(private val api: CharacterApi) {
fun getCharacterMain( fun getCharacterMain(
token: String token: String
) = api.getCharacterMain(authHeader = token) ) = api.getCharacterMain(authHeader = token)
fun refreshRecommendCharacters(
token: String
) = api.refreshRecommendCharacters(authHeader = token)
} }

View File

@@ -39,6 +39,11 @@ class CharacterTabViewModel(
val newCharacters: LiveData<List<Character>> val newCharacters: LiveData<List<Character>>
get() = _newCharacters get() = _newCharacters
// 추천 캐릭터 LiveData
private val _recommendCharacters = MutableLiveData<List<Character>>(emptyList())
val recommendCharacters: LiveData<List<Character>>
get() = _recommendCharacters
fun fetchData() { fun fetchData() {
_isLoading.value = true _isLoading.value = true
@@ -54,6 +59,7 @@ class CharacterTabViewModel(
_recentCharacters.value = data.recentCharacters _recentCharacters.value = data.recentCharacters
_popularCharacters.value = data.popularCharacters _popularCharacters.value = data.popularCharacters
_newCharacters.value = data.newCharacters _newCharacters.value = data.newCharacters
_recommendCharacters.value = data.recommendCharacters
} else { } else {
_toastLiveData.value = _toastLiveData.value =
it.message ?: "알 수 없는 오류가 발생했습니다. 다시 시도해 주세요." it.message ?: "알 수 없는 오류가 발생했습니다. 다시 시도해 주세요."
@@ -68,4 +74,29 @@ class CharacterTabViewModel(
) )
) )
} }
fun refreshRecommendCharacters() {
_isLoading.value = true
compositeDisposable.add(
repository.refreshRecommendCharacters(token = "Bearer ${SharedPreferenceManager.token}")
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{ response ->
if (response.success && response.data != null) {
_recommendCharacters.value = response.data
} else {
_toastLiveData.value = response.message
?: "알 수 없는 오류가 발생했습니다. 다시 시도해 주세요."
}
_isLoading.value = false
},
{ throwable ->
_isLoading.value = false
throwable.message?.let { msg -> Logger.e(msg) }
_toastLiveData.postValue("알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.")
}
)
)
}
} }

View File

@@ -174,5 +174,49 @@
android:paddingHorizontal="24dp" /> android:paddingHorizontal="24dp" />
</LinearLayout> </LinearLayout>
<!-- 추천 캐릭터 섹션 -->
<LinearLayout
android:id="@+id/ll_recommend_characters"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="24dp"
android:orientation="vertical">
<!-- 제목과 새로고침 -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingHorizontal="24dp">
<TextView
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>
<!-- 2단 Grid 리스트 -->
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rv_recommend_characters"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:clipToPadding="false"
android:paddingHorizontal="24dp" />
</LinearLayout>
</LinearLayout> </LinearLayout>
</androidx.core.widget.NestedScrollView> </androidx.core.widget.NestedScrollView>