feat(chat-character): 추천 캐릭터 섹션 추가 및 새로고침 API 반영
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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("알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.")
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user