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 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.CharacterImagePurchaseRequest
|
||||
import kr.co.vividnext.sodalive.chat.character.detail.gallery.CharacterImagePurchaseResponse
|
||||
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.Header
|
||||
import retrofit2.http.Path
|
||||
import retrofit2.http.Query
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.POST
|
||||
|
||||
interface CharacterApi {
|
||||
@GET("/api/chat/character/main")
|
||||
@@ -28,4 +32,10 @@ interface CharacterApi {
|
||||
@Query("page") page: Int,
|
||||
@Query("size") size: Int
|
||||
): 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
|
||||
|
||||
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>() {
|
||||
|
||||
inner class ViewHolder(
|
||||
@@ -22,9 +23,13 @@ class CharacterGalleryAdapter(
|
||||
|
||||
if (item.isOwned) {
|
||||
binding.llLock.visibility = View.GONE
|
||||
binding.btnBuy.setOnClickListener(null)
|
||||
} else {
|
||||
binding.llLock.visibility = View.VISIBLE
|
||||
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.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.core.graphics.toColorInt
|
||||
import androidx.recyclerview.widget.GridLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
@@ -20,7 +21,7 @@ class CharacterGalleryFragment : BaseFragment<FragmentCharacterGalleryBinding>(
|
||||
FragmentCharacterGalleryBinding::inflate
|
||||
) {
|
||||
private val viewModel: CharacterGalleryViewModel by viewModel()
|
||||
private val adapter = CharacterGalleryAdapter()
|
||||
private lateinit var adapter: CharacterGalleryAdapter
|
||||
|
||||
private val characterId: Long by lazy {
|
||||
arguments?.getLong("arg_character_id")
|
||||
@@ -42,6 +43,9 @@ class CharacterGalleryFragment : BaseFragment<FragmentCharacterGalleryBinding>(
|
||||
GridSpacingItemDecoration(3, 2f.dpToPx().toInt(), true)
|
||||
)
|
||||
}
|
||||
adapter = CharacterGalleryAdapter(onClickBuy = { item, position ->
|
||||
showPurchaseDialog(item, position)
|
||||
})
|
||||
binding.rvGallery.adapter = adapter
|
||||
|
||||
binding.rvGallery.addOnScrollListener(object : RecyclerView.OnScrollListener() {
|
||||
@@ -100,4 +104,17 @@ class CharacterGalleryFragment : BaseFragment<FragmentCharacterGalleryBinding>(
|
||||
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) =
|
||||
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 isRequesting: Boolean = false
|
||||
private val accumulatedItems = mutableListOf<CharacterImageListItemResponse>()
|
||||
private var isPurchasing: Boolean = false
|
||||
|
||||
fun loadInitial(characterId: Long) {
|
||||
// 상태 초기화
|
||||
@@ -51,7 +52,12 @@ class CharacterGalleryViewModel(
|
||||
val token = "Bearer ${SharedPreferenceManager.token}"
|
||||
isRequesting = true
|
||||
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())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe({ response ->
|
||||
@@ -68,7 +74,8 @@ class CharacterGalleryViewModel(
|
||||
val ratio = if (total > 0) owned.toFloat() / total.toFloat() else 0f
|
||||
|
||||
// 마지막 페이지 판단: 새 항목이 pageSize보다 적거나, 누적 >= total
|
||||
isLastPage = newItems.size < pageSize || accumulatedItems.size.toLong() >= total
|
||||
isLastPage =
|
||||
newItems.size < pageSize || accumulatedItems.size.toLong() >= total
|
||||
currentPage = page
|
||||
|
||||
_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