feat(character-gallery): 갤러리 탭 UI/페이징 및 API 연동, DI 적용

- API: CharacterApi에 이미지 리스트 API 추가(characterId, page, size)
- VM: 페이징(loadInitial/loadNext), 요청 중복 방지, 마지막 페이지 판단, 누적 리스트 관리
- UI: ProgressBar(배경 #37474F/진행 #3BB9F1, radius 999dp, 비활성) + 좌/우 텍스트 구성
- Grid 3열 + 2dp 간격, item 4:5 비율, 잠금/구매 버튼 UI 적용
- UX: tv_ratio_right에서 ownedCount만 #FDD453로 강조(white 대비)
This commit is contained in:
2025-08-22 17:03:01 +09:00
parent f917eb8c93
commit 13ee098cfc
12 changed files with 445 additions and 10 deletions

View File

@@ -2,10 +2,12 @@ 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.common.ApiResponse
import retrofit2.http.GET
import retrofit2.http.Header
import retrofit2.http.Path
import retrofit2.http.Query
interface CharacterApi {
@GET("/api/chat/character/main")
@@ -18,4 +20,12 @@ interface CharacterApi {
@Header("Authorization") authHeader: String,
@Path("characterId") characterId: Long
): Single<ApiResponse<CharacterDetailResponse>>
@GET("/api/chat/character/image/list")
fun getCharacterImageList(
@Header("Authorization") authHeader: String,
@Query("characterId") characterId: Long,
@Query("page") page: Int,
@Query("size") size: Int
): Single<ApiResponse<CharacterImageListResponse>>
}

View File

@@ -0,0 +1,49 @@
package kr.co.vividnext.sodalive.chat.character.detail.gallery
import android.annotation.SuppressLint
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import com.bumptech.glide.Glide
import kr.co.vividnext.sodalive.databinding.ItemCharacterGalleryBinding
class CharacterGalleryAdapter(
private var items: List<CharacterImageListItemResponse> = emptyList()
) : RecyclerView.Adapter<CharacterGalleryAdapter.ViewHolder>() {
inner class ViewHolder(
private val binding: ItemCharacterGalleryBinding
) : RecyclerView.ViewHolder(binding.root) {
fun bind(item: CharacterImageListItemResponse) {
Glide.with(binding.ivImage)
.load(item.imageUrl)
.into(binding.ivImage)
if (item.isOwned) {
binding.llLock.visibility = View.GONE
} else {
binding.llLock.visibility = View.VISIBLE
binding.tvPrice.text = item.imagePriceCan.toString()
}
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val binding =
ItemCharacterGalleryBinding.inflate(LayoutInflater.from(parent.context), parent, false)
return ViewHolder(binding)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
holder.bind(items[position])
}
override fun getItemCount(): Int = items.size
@SuppressLint("NotifyDataSetChanged")
fun submitItems(newItems: List<CharacterImageListItemResponse>) {
items = newItems
notifyDataSetChanged()
}
}

View File

@@ -1,18 +1,103 @@
package kr.co.vividnext.sodalive.chat.character.detail.gallery
import android.annotation.SuppressLint
import android.os.Bundle
import android.view.View
import androidx.core.graphics.toColorInt
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView
import kr.co.vividnext.sodalive.base.BaseFragment
import kr.co.vividnext.sodalive.chat.character.detail.CharacterDetailActivity.Companion.EXTRA_CHARACTER_ID
import kr.co.vividnext.sodalive.common.GridSpacingItemDecoration
import kr.co.vividnext.sodalive.databinding.FragmentCharacterGalleryBinding
import kr.co.vividnext.sodalive.extensions.dpToPx
import org.koin.androidx.viewmodel.ext.android.viewModel
/**
* 캐릭터 상세 - 갤러리 탭 (빈 화면)
* 캐릭터 상세 - 갤러리 탭
*/
class CharacterGalleryFragment : BaseFragment<FragmentCharacterGalleryBinding>(
FragmentCharacterGalleryBinding::inflate
) {
private val viewModel: CharacterGalleryViewModel by viewModel()
private val adapter = CharacterGalleryAdapter()
private val characterId: Long by lazy {
arguments?.getLong("arg_character_id")
?: requireActivity().intent.getLongExtra(EXTRA_CHARACTER_ID, 0L)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
// 추후 갤러리 콘텐츠 추가 예정
setupRecyclerView()
observeState()
viewModel.loadInitial(characterId)
}
private fun setupRecyclerView() {
val layoutManager = GridLayoutManager(requireContext(), 3)
binding.rvGallery.layoutManager = layoutManager
if (binding.rvGallery.itemDecorationCount == 0) {
binding.rvGallery.addItemDecoration(
GridSpacingItemDecoration(3, 2f.dpToPx().toInt(), true)
)
}
binding.rvGallery.adapter = adapter
binding.rvGallery.addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
super.onScrolled(recyclerView, dx, dy)
if (dy <= 0) return
val totalItemCount = layoutManager.itemCount
val lastVisible = layoutManager.findLastVisibleItemPosition()
if (lastVisible >= totalItemCount - 6) {
viewModel.loadNext()
}
}
})
}
@SuppressLint("SetTextI18n")
private fun observeState() {
viewModel.uiState.observe(viewLifecycleOwner) { state ->
binding.tvEmptyGallery.visibility = if (state.items.isEmpty() && !state.isLoading) {
View.VISIBLE
} else {
View.GONE
}
if (state.items.isNotEmpty() && !state.isLoading) {
binding.rvGallery.visibility = View.VISIBLE
binding.clRatio.visibility = View.VISIBLE
val percent = (state.ratio * 100).toInt()
// 좌측 라벨은 고정("보유중"), 우측은 보유/전체 개수(ownedCount만 강조 색상 적용)
binding.tvRatioLeft.text = "$percent% 보유중"
val ownedStr = state.ownedCount.toString()
val totalStr = state.totalCount.toString()
val fullText = "$ownedStr / ${totalStr}"
val spannable = android.text.SpannableString(fullText)
val ownedColor = "#FDD453".toColorInt()
spannable.setSpan(
android.text.style.ForegroundColorSpan(ownedColor),
/* start */ 0,
/* end */ ownedStr.length,
android.text.Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
)
// 나머지는 TextView의 기본 색상(white)을 사용
binding.tvRatioRight.text = spannable
// 슬라이더(ProgressBar) 값 설정: 0~100
binding.progressRatio.progress = percent
adapter.submitItems(state.items)
} else {
binding.rvGallery.visibility = View.VISIBLE
binding.clRatio.visibility = View.GONE
}
state.error?.let { showToast(it) }
}
}
}

View File

@@ -0,0 +1,10 @@
package kr.co.vividnext.sodalive.chat.character.detail.gallery
import kr.co.vividnext.sodalive.chat.character.CharacterApi
class CharacterGalleryRepository(
private val characterApi: CharacterApi
) {
fun getCharacterImageList(token: String, characterId: Long, page: Int, size: Int) =
characterApi.getCharacterImageList(authHeader = token, characterId = characterId, page = page, size = size)
}

View File

@@ -0,0 +1,99 @@
package kr.co.vividnext.sodalive.chat.character.detail.gallery
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import com.orhanobut.logger.Logger
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.schedulers.Schedulers
import kr.co.vividnext.sodalive.base.BaseViewModel
import kr.co.vividnext.sodalive.common.SharedPreferenceManager
class CharacterGalleryViewModel(
private val repository: CharacterGalleryRepository
) : BaseViewModel() {
data class UiState(
val totalCount: Long = 0L,
val ownedCount: Long = 0L,
val ratio: Float = 0f, // 0.0 ~ 1.0
val items: List<CharacterImageListItemResponse> = emptyList(),
val isLoading: Boolean = false,
val error: String? = null
)
private val _uiState = MutableLiveData(UiState())
val uiState: LiveData<UiState> get() = _uiState
private var characterId: Long = 0L
private var currentPage: Int = 0
private val pageSize: Int = 20
private var isLastPage: Boolean = false
private var isRequesting: Boolean = false
private val accumulatedItems = mutableListOf<CharacterImageListItemResponse>()
fun loadInitial(characterId: Long) {
// 상태 초기화
this.characterId = characterId
currentPage = 0
isLastPage = false
isRequesting = false
accumulatedItems.clear()
_uiState.value = UiState(isLoading = true)
request(page = currentPage)
}
fun loadNext() {
if (isRequesting || isLastPage) return
request(page = currentPage + 1)
}
private fun request(page: Int) {
val token = "Bearer ${SharedPreferenceManager.token}"
isRequesting = true
compositeDisposable.add(
repository.getCharacterImageList(token = token, characterId = characterId, page = page, size = pageSize)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe({ response ->
val success = response.success
val data = response.data
if (success && data != null) {
// 누적 처리
val newItems = data.items
if (page == 0) accumulatedItems.clear()
accumulatedItems.addAll(newItems)
val total = data.totalCount
val owned = data.ownedCount
val ratio = if (total > 0) owned.toFloat() / total.toFloat() else 0f
// 마지막 페이지 판단: 새 항목이 pageSize보다 적거나, 누적 >= total
isLastPage = newItems.size < pageSize || accumulatedItems.size.toLong() >= total
currentPage = page
_uiState.value = UiState(
totalCount = total,
ownedCount = owned,
ratio = ratio,
items = accumulatedItems.toList(),
isLoading = false,
error = null
)
} else {
_uiState.value = _uiState.value?.copy(
isLoading = false,
error = response.message ?: "갤러리 정보를 불러오지 못했습니다."
)
}
isRequesting = false
}, { throwable ->
Logger.e(throwable, throwable.message ?: "")
_uiState.value = _uiState.value?.copy(
isLoading = false,
error = "네트워크 오류가 발생했습니다. 잠시 후 다시 시도해 주세요."
)
isRequesting = false
})
)
}
}

View File

@@ -0,0 +1,19 @@
package kr.co.vividnext.sodalive.chat.character.detail.gallery
import androidx.annotation.Keep
import com.google.gson.annotations.SerializedName
@Keep
data class CharacterImageListItemResponse(
@SerializedName("id") val id: Long,
@SerializedName("imageUrl") val imageUrl: String,
@SerializedName("isOwned") val isOwned: Boolean,
@SerializedName("imagePriceCan") val imagePriceCan: Long
)
@Keep
data class CharacterImageListResponse(
@SerializedName("totalCount") val totalCount: Long,
@SerializedName("ownedCount") val ownedCount: Long,
@SerializedName("items") val items: List<CharacterImageListItemResponse>
)

View File

@@ -71,6 +71,8 @@ import kr.co.vividnext.sodalive.chat.character.comment.CharacterCommentApi
import kr.co.vividnext.sodalive.chat.character.comment.CharacterCommentRepository
import kr.co.vividnext.sodalive.chat.character.detail.detail.CharacterDetailRepository
import kr.co.vividnext.sodalive.chat.character.detail.detail.CharacterDetailViewModel
import kr.co.vividnext.sodalive.chat.character.detail.gallery.CharacterGalleryRepository
import kr.co.vividnext.sodalive.chat.character.detail.gallery.CharacterGalleryViewModel
import kr.co.vividnext.sodalive.chat.talk.TalkApi
import kr.co.vividnext.sodalive.chat.talk.TalkTabRepository
import kr.co.vividnext.sodalive.chat.talk.TalkTabViewModel
@@ -358,6 +360,7 @@ class AppDI(private val context: Context, isDebugMode: Boolean) {
viewModel { HomeViewModel(get(), get()) }
viewModel { CharacterTabViewModel(get()) }
viewModel { CharacterDetailViewModel(get()) }
viewModel { CharacterGalleryViewModel(get()) }
viewModel { TalkTabViewModel(get()) }
viewModel { kr.co.vividnext.sodalive.chat.character.comment.CharacterCommentListViewModel(get()) }
viewModel { kr.co.vividnext.sodalive.chat.character.comment.CharacterCommentReplyViewModel(get()) }
@@ -407,6 +410,7 @@ class AppDI(private val context: Context, isDebugMode: Boolean) {
factory { HomeRepository(get()) }
factory { CharacterTabRepository(get()) }
factory { CharacterDetailRepository(get(), get()) }
factory { CharacterGalleryRepository(get()) }
factory { TalkTabRepository(get()) }
factory { CharacterCommentRepository(get()) }
}