feat(gallery): 캐릭터 이미지 구매 기능 추가
갤러리 아이템의 구매 버튼 클릭 시 확인 다이얼로그를 표시하고, 확인 시 /api/chat/character/image/purchase API를 호출하여 이미지 URL을 갱신. 구매 성공 시 isOwned=true 처리 및 보유 비율/개수 업데이트.
This commit is contained in:
@@ -3,11 +3,15 @@ package kr.co.vividnext.sodalive.chat.character
|
|||||||
import io.reactivex.rxjava3.core.Single
|
import io.reactivex.rxjava3.core.Single
|
||||||
import kr.co.vividnext.sodalive.chat.character.detail.detail.CharacterDetailResponse
|
import kr.co.vividnext.sodalive.chat.character.detail.detail.CharacterDetailResponse
|
||||||
import kr.co.vividnext.sodalive.chat.character.detail.gallery.CharacterImageListResponse
|
import kr.co.vividnext.sodalive.chat.character.detail.gallery.CharacterImageListResponse
|
||||||
|
import kr.co.vividnext.sodalive.chat.character.detail.gallery.CharacterImagePurchaseRequest
|
||||||
|
import kr.co.vividnext.sodalive.chat.character.detail.gallery.CharacterImagePurchaseResponse
|
||||||
import kr.co.vividnext.sodalive.common.ApiResponse
|
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||||
import retrofit2.http.GET
|
import retrofit2.http.GET
|
||||||
import retrofit2.http.Header
|
import retrofit2.http.Header
|
||||||
import retrofit2.http.Path
|
import retrofit2.http.Path
|
||||||
import retrofit2.http.Query
|
import retrofit2.http.Query
|
||||||
|
import retrofit2.http.Body
|
||||||
|
import retrofit2.http.POST
|
||||||
|
|
||||||
interface CharacterApi {
|
interface CharacterApi {
|
||||||
@GET("/api/chat/character/main")
|
@GET("/api/chat/character/main")
|
||||||
@@ -28,4 +32,10 @@ interface CharacterApi {
|
|||||||
@Query("page") page: Int,
|
@Query("page") page: Int,
|
||||||
@Query("size") size: Int
|
@Query("size") size: Int
|
||||||
): Single<ApiResponse<CharacterImageListResponse>>
|
): Single<ApiResponse<CharacterImageListResponse>>
|
||||||
|
|
||||||
|
@POST("/api/chat/character/image/purchase")
|
||||||
|
fun purchaseCharacterImage(
|
||||||
|
@Header("Authorization") authHeader: String,
|
||||||
|
@Body request: CharacterImagePurchaseRequest
|
||||||
|
): Single<ApiResponse<CharacterImagePurchaseResponse>>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ import com.bumptech.glide.Glide
|
|||||||
import kr.co.vividnext.sodalive.databinding.ItemCharacterGalleryBinding
|
import kr.co.vividnext.sodalive.databinding.ItemCharacterGalleryBinding
|
||||||
|
|
||||||
class CharacterGalleryAdapter(
|
class CharacterGalleryAdapter(
|
||||||
private var items: List<CharacterImageListItemResponse> = emptyList()
|
private var items: List<CharacterImageListItemResponse> = emptyList(),
|
||||||
|
private val onClickBuy: (item: CharacterImageListItemResponse, position: Int) -> Unit = { _, _ -> }
|
||||||
) : RecyclerView.Adapter<CharacterGalleryAdapter.ViewHolder>() {
|
) : RecyclerView.Adapter<CharacterGalleryAdapter.ViewHolder>() {
|
||||||
|
|
||||||
inner class ViewHolder(
|
inner class ViewHolder(
|
||||||
@@ -22,9 +23,13 @@ class CharacterGalleryAdapter(
|
|||||||
|
|
||||||
if (item.isOwned) {
|
if (item.isOwned) {
|
||||||
binding.llLock.visibility = View.GONE
|
binding.llLock.visibility = View.GONE
|
||||||
|
binding.btnBuy.setOnClickListener(null)
|
||||||
} else {
|
} else {
|
||||||
binding.llLock.visibility = View.VISIBLE
|
binding.llLock.visibility = View.VISIBLE
|
||||||
binding.tvPrice.text = item.imagePriceCan.toString()
|
binding.tvPrice.text = item.imagePriceCan.toString()
|
||||||
|
binding.btnBuy.setOnClickListener {
|
||||||
|
onClickBuy(item, bindingAdapterPosition)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package kr.co.vividnext.sodalive.chat.character.detail.gallery
|
|||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.View
|
import android.view.View
|
||||||
|
import androidx.appcompat.app.AlertDialog
|
||||||
import androidx.core.graphics.toColorInt
|
import androidx.core.graphics.toColorInt
|
||||||
import androidx.recyclerview.widget.GridLayoutManager
|
import androidx.recyclerview.widget.GridLayoutManager
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
@@ -20,7 +21,7 @@ class CharacterGalleryFragment : BaseFragment<FragmentCharacterGalleryBinding>(
|
|||||||
FragmentCharacterGalleryBinding::inflate
|
FragmentCharacterGalleryBinding::inflate
|
||||||
) {
|
) {
|
||||||
private val viewModel: CharacterGalleryViewModel by viewModel()
|
private val viewModel: CharacterGalleryViewModel by viewModel()
|
||||||
private val adapter = CharacterGalleryAdapter()
|
private lateinit var adapter: CharacterGalleryAdapter
|
||||||
|
|
||||||
private val characterId: Long by lazy {
|
private val characterId: Long by lazy {
|
||||||
arguments?.getLong("arg_character_id")
|
arguments?.getLong("arg_character_id")
|
||||||
@@ -42,6 +43,9 @@ class CharacterGalleryFragment : BaseFragment<FragmentCharacterGalleryBinding>(
|
|||||||
GridSpacingItemDecoration(3, 2f.dpToPx().toInt(), true)
|
GridSpacingItemDecoration(3, 2f.dpToPx().toInt(), true)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
adapter = CharacterGalleryAdapter(onClickBuy = { item, position ->
|
||||||
|
showPurchaseDialog(item, position)
|
||||||
|
})
|
||||||
binding.rvGallery.adapter = adapter
|
binding.rvGallery.adapter = adapter
|
||||||
|
|
||||||
binding.rvGallery.addOnScrollListener(object : RecyclerView.OnScrollListener() {
|
binding.rvGallery.addOnScrollListener(object : RecyclerView.OnScrollListener() {
|
||||||
@@ -100,4 +104,17 @@ class CharacterGalleryFragment : BaseFragment<FragmentCharacterGalleryBinding>(
|
|||||||
state.error?.let { showToast(it) }
|
state.error?.let { showToast(it) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun showPurchaseDialog(item: CharacterImageListItemResponse, position: Int) {
|
||||||
|
AlertDialog.Builder(requireActivity())
|
||||||
|
.setTitle("[구매 확인]")
|
||||||
|
.setMessage("선택한 이미지를 구매하시겠습니까?")
|
||||||
|
.setNegativeButton("취소") { dialog, _ ->
|
||||||
|
dialog.dismiss()
|
||||||
|
}
|
||||||
|
.setPositiveButton("${item.imagePriceCan}캔으로 구매") { dialog, _ ->
|
||||||
|
dialog.dismiss()
|
||||||
|
viewModel.purchaseImage(item.id, position)
|
||||||
|
}.show()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,4 +7,10 @@ class CharacterGalleryRepository(
|
|||||||
) {
|
) {
|
||||||
fun getCharacterImageList(token: String, characterId: Long, page: Int, size: Int) =
|
fun getCharacterImageList(token: String, characterId: Long, page: Int, size: Int) =
|
||||||
characterApi.getCharacterImageList(authHeader = token, characterId = characterId, page = page, size = size)
|
characterApi.getCharacterImageList(authHeader = token, characterId = characterId, page = page, size = size)
|
||||||
|
|
||||||
|
fun purchaseCharacterImage(token: String, imageId: Long) =
|
||||||
|
characterApi.purchaseCharacterImage(
|
||||||
|
authHeader = token,
|
||||||
|
request = CharacterImagePurchaseRequest(imageId = imageId)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ class CharacterGalleryViewModel(
|
|||||||
private var isLastPage: Boolean = false
|
private var isLastPage: Boolean = false
|
||||||
private var isRequesting: Boolean = false
|
private var isRequesting: Boolean = false
|
||||||
private val accumulatedItems = mutableListOf<CharacterImageListItemResponse>()
|
private val accumulatedItems = mutableListOf<CharacterImageListItemResponse>()
|
||||||
|
private var isPurchasing: Boolean = false
|
||||||
|
|
||||||
fun loadInitial(characterId: Long) {
|
fun loadInitial(characterId: Long) {
|
||||||
// 상태 초기화
|
// 상태 초기화
|
||||||
@@ -51,7 +52,12 @@ class CharacterGalleryViewModel(
|
|||||||
val token = "Bearer ${SharedPreferenceManager.token}"
|
val token = "Bearer ${SharedPreferenceManager.token}"
|
||||||
isRequesting = true
|
isRequesting = true
|
||||||
compositeDisposable.add(
|
compositeDisposable.add(
|
||||||
repository.getCharacterImageList(token = token, characterId = characterId, page = page, size = pageSize)
|
repository.getCharacterImageList(
|
||||||
|
token = token,
|
||||||
|
characterId = characterId,
|
||||||
|
page = page,
|
||||||
|
size = pageSize
|
||||||
|
)
|
||||||
.subscribeOn(Schedulers.io())
|
.subscribeOn(Schedulers.io())
|
||||||
.observeOn(AndroidSchedulers.mainThread())
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
.subscribe({ response ->
|
.subscribe({ response ->
|
||||||
@@ -68,7 +74,8 @@ class CharacterGalleryViewModel(
|
|||||||
val ratio = if (total > 0) owned.toFloat() / total.toFloat() else 0f
|
val ratio = if (total > 0) owned.toFloat() / total.toFloat() else 0f
|
||||||
|
|
||||||
// 마지막 페이지 판단: 새 항목이 pageSize보다 적거나, 누적 >= total
|
// 마지막 페이지 판단: 새 항목이 pageSize보다 적거나, 누적 >= total
|
||||||
isLastPage = newItems.size < pageSize || accumulatedItems.size.toLong() >= total
|
isLastPage =
|
||||||
|
newItems.size < pageSize || accumulatedItems.size.toLong() >= total
|
||||||
currentPage = page
|
currentPage = page
|
||||||
|
|
||||||
_uiState.value = UiState(
|
_uiState.value = UiState(
|
||||||
@@ -96,4 +103,59 @@ class CharacterGalleryViewModel(
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun purchaseImage(imageId: Long, position: Int) {
|
||||||
|
if (isPurchasing) return
|
||||||
|
if (position < 0 || position >= accumulatedItems.size) return
|
||||||
|
val target = accumulatedItems[position]
|
||||||
|
if (target.isOwned) return
|
||||||
|
|
||||||
|
val token = "Bearer ${SharedPreferenceManager.token}"
|
||||||
|
isPurchasing = true
|
||||||
|
compositeDisposable.add(
|
||||||
|
repository.purchaseCharacterImage(token = token, imageId = imageId)
|
||||||
|
.subscribeOn(Schedulers.io())
|
||||||
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
|
.subscribe({ response ->
|
||||||
|
val success = response.success
|
||||||
|
val data = response.data
|
||||||
|
if (success && data != null) {
|
||||||
|
// 응답 imageUrl로 교체, 소유 상태 true로 변경
|
||||||
|
val updated = target.copy(
|
||||||
|
imageUrl = data.imageUrl,
|
||||||
|
isOwned = true
|
||||||
|
)
|
||||||
|
accumulatedItems[position] = updated
|
||||||
|
|
||||||
|
val total = _uiState.value?.totalCount ?: accumulatedItems.size.toLong()
|
||||||
|
val ownedBefore = _uiState.value?.ownedCount
|
||||||
|
?: accumulatedItems.count { it.isOwned }.toLong()
|
||||||
|
val ownedAfter = ownedBefore + 1
|
||||||
|
val ratio = if (total > 0) {
|
||||||
|
ownedAfter.toFloat() / total.toFloat()
|
||||||
|
} else {
|
||||||
|
0f
|
||||||
|
}
|
||||||
|
|
||||||
|
_uiState.value = _uiState.value?.copy(
|
||||||
|
ownedCount = ownedAfter,
|
||||||
|
ratio = ratio,
|
||||||
|
items = accumulatedItems.toList(),
|
||||||
|
error = null
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
_uiState.value = _uiState.value?.copy(
|
||||||
|
error = response.message ?: "구매에 실패했습니다."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
isPurchasing = false
|
||||||
|
}, { throwable ->
|
||||||
|
Logger.e(throwable, throwable.message ?: "")
|
||||||
|
_uiState.value = _uiState.value?.copy(
|
||||||
|
error = "네트워크 오류가 발생했습니다. 잠시 후 다시 시도해 주세요."
|
||||||
|
)
|
||||||
|
isPurchasing = false
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package kr.co.vividnext.sodalive.chat.character.detail.gallery
|
||||||
|
|
||||||
|
import androidx.annotation.Keep
|
||||||
|
import com.google.gson.annotations.SerializedName
|
||||||
|
|
||||||
|
@Keep
|
||||||
|
data class CharacterImagePurchaseRequest(
|
||||||
|
@SerializedName("imageId") val imageId: Long,
|
||||||
|
@SerializedName("container") val container: String = "aos"
|
||||||
|
)
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package kr.co.vividnext.sodalive.chat.character.detail.gallery
|
||||||
|
|
||||||
|
import androidx.annotation.Keep
|
||||||
|
import com.google.gson.annotations.SerializedName
|
||||||
|
|
||||||
|
@Keep
|
||||||
|
data class CharacterImagePurchaseResponse(
|
||||||
|
@SerializedName("imageUrl") val imageUrl: String
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user