feat(gallery): 캐릭터 이미지 구매 기능 추가

갤러리 아이템의 구매 버튼 클릭 시 확인 다이얼로그를 표시하고,
확인 시 /api/chat/character/image/purchase API를 호출하여 이미지 URL을 갱신.
구매 성공 시 isOwned=true 처리 및 보유 비율/개수 업데이트.
This commit is contained in:
2025-08-22 21:49:44 +09:00
parent 13ee098cfc
commit e3ed816fb3
7 changed files with 123 additions and 4 deletions

View File

@@ -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>>
}

View File

@@ -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)
}
}
}
}

View File

@@ -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()
}
}

View File

@@ -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)
)
}

View File

@@ -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
})
)
}
}

View File

@@ -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"
)

View File

@@ -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
)