From e3ed816fb3d769145d6729e23c4e9de9f345b0bf Mon Sep 17 00:00:00 2001 From: klaus Date: Fri, 22 Aug 2025 21:49:44 +0900 Subject: [PATCH] =?UTF-8?q?feat(gallery):=20=EC=BA=90=EB=A6=AD=ED=84=B0=20?= =?UTF-8?q?=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EA=B5=AC=EB=A7=A4=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 갤러리 아이템의 구매 버튼 클릭 시 확인 다이얼로그를 표시하고, 확인 시 /api/chat/character/image/purchase API를 호출하여 이미지 URL을 갱신. 구매 성공 시 isOwned=true 처리 및 보유 비율/개수 업데이트. --- .../sodalive/chat/character/CharacterApi.kt | 10 +++ .../detail/gallery/CharacterGalleryAdapter.kt | 7 +- .../gallery/CharacterGalleryFragment.kt | 19 +++++- .../gallery/CharacterGalleryRepository.kt | 6 ++ .../gallery/CharacterGalleryViewModel.kt | 66 ++++++++++++++++++- .../gallery/CharacterImagePurchaseRequest.kt | 10 +++ .../gallery/CharacterImagePurchaseResponse.kt | 9 +++ 7 files changed, 123 insertions(+), 4 deletions(-) create mode 100644 app/src/main/java/kr/co/vividnext/sodalive/chat/character/detail/gallery/CharacterImagePurchaseRequest.kt create mode 100644 app/src/main/java/kr/co/vividnext/sodalive/chat/character/detail/gallery/CharacterImagePurchaseResponse.kt diff --git a/app/src/main/java/kr/co/vividnext/sodalive/chat/character/CharacterApi.kt b/app/src/main/java/kr/co/vividnext/sodalive/chat/character/CharacterApi.kt index ac3ce44c..0f562400 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/chat/character/CharacterApi.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/chat/character/CharacterApi.kt @@ -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> + + @POST("/api/chat/character/image/purchase") + fun purchaseCharacterImage( + @Header("Authorization") authHeader: String, + @Body request: CharacterImagePurchaseRequest + ): Single> } diff --git a/app/src/main/java/kr/co/vividnext/sodalive/chat/character/detail/gallery/CharacterGalleryAdapter.kt b/app/src/main/java/kr/co/vividnext/sodalive/chat/character/detail/gallery/CharacterGalleryAdapter.kt index bb035d31..cc240ebc 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/chat/character/detail/gallery/CharacterGalleryAdapter.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/chat/character/detail/gallery/CharacterGalleryAdapter.kt @@ -9,7 +9,8 @@ import com.bumptech.glide.Glide import kr.co.vividnext.sodalive.databinding.ItemCharacterGalleryBinding class CharacterGalleryAdapter( - private var items: List = emptyList() + private var items: List = emptyList(), + private val onClickBuy: (item: CharacterImageListItemResponse, position: Int) -> Unit = { _, _ -> } ) : RecyclerView.Adapter() { 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) + } } } } diff --git a/app/src/main/java/kr/co/vividnext/sodalive/chat/character/detail/gallery/CharacterGalleryFragment.kt b/app/src/main/java/kr/co/vividnext/sodalive/chat/character/detail/gallery/CharacterGalleryFragment.kt index d99dd9df..1c3542be 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/chat/character/detail/gallery/CharacterGalleryFragment.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/chat/character/detail/gallery/CharacterGalleryFragment.kt @@ -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::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( 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( 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() + } } diff --git a/app/src/main/java/kr/co/vividnext/sodalive/chat/character/detail/gallery/CharacterGalleryRepository.kt b/app/src/main/java/kr/co/vividnext/sodalive/chat/character/detail/gallery/CharacterGalleryRepository.kt index 0e5255f1..46879ccf 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/chat/character/detail/gallery/CharacterGalleryRepository.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/chat/character/detail/gallery/CharacterGalleryRepository.kt @@ -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) + ) } diff --git a/app/src/main/java/kr/co/vividnext/sodalive/chat/character/detail/gallery/CharacterGalleryViewModel.kt b/app/src/main/java/kr/co/vividnext/sodalive/chat/character/detail/gallery/CharacterGalleryViewModel.kt index 8bd8f17b..41fec3f1 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/chat/character/detail/gallery/CharacterGalleryViewModel.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/chat/character/detail/gallery/CharacterGalleryViewModel.kt @@ -30,6 +30,7 @@ class CharacterGalleryViewModel( private var isLastPage: Boolean = false private var isRequesting: Boolean = false private val accumulatedItems = mutableListOf() + 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 + }) + ) + } } diff --git a/app/src/main/java/kr/co/vividnext/sodalive/chat/character/detail/gallery/CharacterImagePurchaseRequest.kt b/app/src/main/java/kr/co/vividnext/sodalive/chat/character/detail/gallery/CharacterImagePurchaseRequest.kt new file mode 100644 index 00000000..9ebd8f80 --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/chat/character/detail/gallery/CharacterImagePurchaseRequest.kt @@ -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" +) diff --git a/app/src/main/java/kr/co/vividnext/sodalive/chat/character/detail/gallery/CharacterImagePurchaseResponse.kt b/app/src/main/java/kr/co/vividnext/sodalive/chat/character/detail/gallery/CharacterImagePurchaseResponse.kt new file mode 100644 index 00000000..00f1bd6b --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/chat/character/detail/gallery/CharacterImagePurchaseResponse.kt @@ -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 +)