From 62125f08739d5104805968326db4c41708369fa4 Mon Sep 17 00:00:00 2001 From: klaus Date: Tue, 11 Nov 2025 17:17:18 +0900 Subject: [PATCH] =?UTF-8?q?feat(chat-character):=20=EC=B6=94=EC=B2=9C=20?= =?UTF-8?q?=EC=BA=90=EB=A6=AD=ED=84=B0=20=EC=84=B9=EC=85=98=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20=EB=B0=8F=20=EC=83=88=EB=A1=9C=EA=B3=A0=EC=B9=A8=20?= =?UTF-8?q?API=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sodalive/chat/character/CharacterApi.kt | 6 ++ .../chat/character/CharacterHomeResponse.kt | 1 + .../chat/character/CharacterTabFragment.kt | 61 +++++++++++++++++++ .../chat/character/CharacterTabRepository.kt | 4 ++ .../chat/character/CharacterTabViewModel.kt | 31 ++++++++++ .../res/layout/fragment_character_tab.xml | 44 +++++++++++++ 6 files changed, 147 insertions(+) diff --git a/app/src/main/java/kr/co/vividnext/sodalive/chat/character/CharacterApi.kt b/app/src/main/java/kr/co/vividnext/sodalive/chat/character/CharacterApi.kt index 6631322e..e57eee53 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/chat/character/CharacterApi.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/chat/character/CharacterApi.kt @@ -51,6 +51,12 @@ interface CharacterApi { @Query("size") size: Int ): Single> + // 추천 캐릭터 새로고침 + @GET("/api/chat/character/recommend") + fun refreshRecommendCharacters( + @Header("Authorization") authHeader: String + ): Single>> + @POST("/api/chat/character/image/purchase") fun purchaseCharacterImage( @Header("Authorization") authHeader: String, diff --git a/app/src/main/java/kr/co/vividnext/sodalive/chat/character/CharacterHomeResponse.kt b/app/src/main/java/kr/co/vividnext/sodalive/chat/character/CharacterHomeResponse.kt index 5ffe22cf..c929f58f 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/chat/character/CharacterHomeResponse.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/chat/character/CharacterHomeResponse.kt @@ -10,6 +10,7 @@ data class CharacterHomeResponse( @SerializedName("recentCharacters") val recentCharacters: List, @SerializedName("popularCharacters") val popularCharacters: List, @SerializedName("newCharacters") val newCharacters: List, + @SerializedName("recommendCharacters") val recommendCharacters: List ) @Keep diff --git a/app/src/main/java/kr/co/vividnext/sodalive/chat/character/CharacterTabFragment.kt b/app/src/main/java/kr/co/vividnext/sodalive/chat/character/CharacterTabFragment.kt index 637800df..2ee4e4c5 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/chat/character/CharacterTabFragment.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/chat/character/CharacterTabFragment.kt @@ -10,6 +10,7 @@ import android.widget.Toast import androidx.annotation.OptIn import androidx.core.content.ContextCompat import androidx.media3.common.util.UnstableApi +import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView 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.Companion.EXTRA_CHARACTER_ID 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.common.LoadingDialog import kr.co.vividnext.sodalive.common.SharedPreferenceManager @@ -46,6 +48,7 @@ class CharacterTabFragment : BaseFragment( private lateinit var recentCharacterAdapter: RecentCharacterAdapter private lateinit var popularCharacterAdapter: CharacterAdapter private lateinit var newCharacterAdapter: CharacterAdapter + private lateinit var recommendCharacterAdapter: NewCharactersAllAdapter private lateinit var loadingDialog: LoadingDialog override fun onViewCreated(view: View, savedInstanceState: Bundle?) { @@ -63,6 +66,7 @@ class CharacterTabFragment : BaseFragment( setupRecentCharactersRecyclerView() setupPopularCharactersRecyclerView() setupNewCharactersRecyclerView() + setupRecommendCharactersRecyclerView() } private fun setupBanner() { @@ -308,6 +312,63 @@ class CharacterTabFragment : BaseFragment( } } + 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) { if (SharedPreferenceManager.token.isBlank()) { (requireActivity() as MainActivity).showLoginActivity() diff --git a/app/src/main/java/kr/co/vividnext/sodalive/chat/character/CharacterTabRepository.kt b/app/src/main/java/kr/co/vividnext/sodalive/chat/character/CharacterTabRepository.kt index c4359d4e..6687f81b 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/chat/character/CharacterTabRepository.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/chat/character/CharacterTabRepository.kt @@ -4,4 +4,8 @@ class CharacterTabRepository(private val api: CharacterApi) { fun getCharacterMain( token: String ) = api.getCharacterMain(authHeader = token) + + fun refreshRecommendCharacters( + token: String + ) = api.refreshRecommendCharacters(authHeader = token) } diff --git a/app/src/main/java/kr/co/vividnext/sodalive/chat/character/CharacterTabViewModel.kt b/app/src/main/java/kr/co/vividnext/sodalive/chat/character/CharacterTabViewModel.kt index 6659af58..e610a3dc 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/chat/character/CharacterTabViewModel.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/chat/character/CharacterTabViewModel.kt @@ -39,6 +39,11 @@ class CharacterTabViewModel( val newCharacters: LiveData> get() = _newCharacters + // 추천 캐릭터 LiveData + private val _recommendCharacters = MutableLiveData>(emptyList()) + val recommendCharacters: LiveData> + get() = _recommendCharacters + fun fetchData() { _isLoading.value = true @@ -54,6 +59,7 @@ class CharacterTabViewModel( _recentCharacters.value = data.recentCharacters _popularCharacters.value = data.popularCharacters _newCharacters.value = data.newCharacters + _recommendCharacters.value = data.recommendCharacters } else { _toastLiveData.value = 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("알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.") + } + ) + ) + } } diff --git a/app/src/main/res/layout/fragment_character_tab.xml b/app/src/main/res/layout/fragment_character_tab.xml index 0398cc93..f3b89c7d 100644 --- a/app/src/main/res/layout/fragment_character_tab.xml +++ b/app/src/main/res/layout/fragment_character_tab.xml @@ -174,5 +174,49 @@ android:paddingHorizontal="24dp" /> + + + + + + + + + + + + + + + +