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