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:
		@@ -2,10 +2,12 @@ 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.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
 | 
				
			||||||
 | 
					
 | 
				
			||||||
interface CharacterApi {
 | 
					interface CharacterApi {
 | 
				
			||||||
    @GET("/api/chat/character/main")
 | 
					    @GET("/api/chat/character/main")
 | 
				
			||||||
@@ -18,4 +20,12 @@ interface CharacterApi {
 | 
				
			|||||||
        @Header("Authorization") authHeader: String,
 | 
					        @Header("Authorization") authHeader: String,
 | 
				
			||||||
        @Path("characterId") characterId: Long
 | 
					        @Path("characterId") characterId: Long
 | 
				
			||||||
    ): Single<ApiResponse<CharacterDetailResponse>>
 | 
					    ): 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>>
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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()
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -1,18 +1,103 @@
 | 
				
			|||||||
package kr.co.vividnext.sodalive.chat.character.detail.gallery
 | 
					package kr.co.vividnext.sodalive.chat.character.detail.gallery
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import android.annotation.SuppressLint
 | 
				
			||||||
import android.os.Bundle
 | 
					import android.os.Bundle
 | 
				
			||||||
import android.view.View
 | 
					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.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.databinding.FragmentCharacterGalleryBinding
 | 
				
			||||||
 | 
					import kr.co.vividnext.sodalive.extensions.dpToPx
 | 
				
			||||||
 | 
					import org.koin.androidx.viewmodel.ext.android.viewModel
 | 
				
			||||||
 | 
					
 | 
				
			||||||
/**
 | 
					/**
 | 
				
			||||||
 * 캐릭터 상세 - 갤러리 탭 (빈 화면)
 | 
					 * 캐릭터 상세 - 갤러리 탭
 | 
				
			||||||
 */
 | 
					 */
 | 
				
			||||||
class CharacterGalleryFragment : BaseFragment<FragmentCharacterGalleryBinding>(
 | 
					class CharacterGalleryFragment : BaseFragment<FragmentCharacterGalleryBinding>(
 | 
				
			||||||
    FragmentCharacterGalleryBinding::inflate
 | 
					    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?) {
 | 
					    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
 | 
				
			||||||
        super.onViewCreated(view, savedInstanceState)
 | 
					        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) }
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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)
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -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
 | 
				
			||||||
 | 
					                })
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -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>
 | 
				
			||||||
 | 
					)
 | 
				
			||||||
@@ -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.comment.CharacterCommentRepository
 | 
				
			||||||
import kr.co.vividnext.sodalive.chat.character.detail.detail.CharacterDetailRepository
 | 
					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.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.TalkApi
 | 
				
			||||||
import kr.co.vividnext.sodalive.chat.talk.TalkTabRepository
 | 
					import kr.co.vividnext.sodalive.chat.talk.TalkTabRepository
 | 
				
			||||||
import kr.co.vividnext.sodalive.chat.talk.TalkTabViewModel
 | 
					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 { HomeViewModel(get(), get()) }
 | 
				
			||||||
        viewModel { CharacterTabViewModel(get()) }
 | 
					        viewModel { CharacterTabViewModel(get()) }
 | 
				
			||||||
        viewModel { CharacterDetailViewModel(get()) }
 | 
					        viewModel { CharacterDetailViewModel(get()) }
 | 
				
			||||||
 | 
					        viewModel { CharacterGalleryViewModel(get()) }
 | 
				
			||||||
        viewModel { TalkTabViewModel(get()) }
 | 
					        viewModel { TalkTabViewModel(get()) }
 | 
				
			||||||
        viewModel { kr.co.vividnext.sodalive.chat.character.comment.CharacterCommentListViewModel(get()) }
 | 
					        viewModel { kr.co.vividnext.sodalive.chat.character.comment.CharacterCommentListViewModel(get()) }
 | 
				
			||||||
        viewModel { kr.co.vividnext.sodalive.chat.character.comment.CharacterCommentReplyViewModel(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 { HomeRepository(get()) }
 | 
				
			||||||
        factory { CharacterTabRepository(get()) }
 | 
					        factory { CharacterTabRepository(get()) }
 | 
				
			||||||
        factory { CharacterDetailRepository(get(), get()) }
 | 
					        factory { CharacterDetailRepository(get(), get()) }
 | 
				
			||||||
 | 
					        factory { CharacterGalleryRepository(get()) }
 | 
				
			||||||
        factory { TalkTabRepository(get()) }
 | 
					        factory { TalkTabRepository(get()) }
 | 
				
			||||||
        factory { CharacterCommentRepository(get()) }
 | 
					        factory { CharacterCommentRepository(get()) }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										
											BIN
										
									
								
								app/src/main/res/drawable-mdpi/ic_new_lock.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								app/src/main/res/drawable-mdpi/ic_new_lock.png
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| 
		 After Width: | Height: | Size: 359 B  | 
							
								
								
									
										6
									
								
								app/src/main/res/drawable/bg_buy_button.xml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								app/src/main/res/drawable/bg_buy_button.xml
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,6 @@
 | 
				
			|||||||
 | 
					<?xml version="1.0" encoding="utf-8"?>
 | 
				
			||||||
 | 
					<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
 | 
				
			||||||
 | 
					    <solid android:color="#B5E7FA" />
 | 
				
			||||||
 | 
					    <corners android:radius="30dp" />
 | 
				
			||||||
 | 
					    <stroke android:width="1dp" android:color="#3BB9F1" />
 | 
				
			||||||
 | 
					</shape>
 | 
				
			||||||
							
								
								
									
										17
									
								
								app/src/main/res/drawable/bg_gallery_progress.xml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								app/src/main/res/drawable/bg_gallery_progress.xml
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,17 @@
 | 
				
			|||||||
 | 
					<?xml version="1.0" encoding="utf-8"?>
 | 
				
			||||||
 | 
					<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
 | 
				
			||||||
 | 
					    <item android:id="@android:id/background">
 | 
				
			||||||
 | 
					        <shape android:shape="rectangle">
 | 
				
			||||||
 | 
					            <corners android:radius="999dp" />
 | 
				
			||||||
 | 
					            <solid android:color="#37474F" />
 | 
				
			||||||
 | 
					        </shape>
 | 
				
			||||||
 | 
					    </item>
 | 
				
			||||||
 | 
					    <item android:id="@android:id/progress">
 | 
				
			||||||
 | 
					        <clip>
 | 
				
			||||||
 | 
					            <shape android:shape="rectangle">
 | 
				
			||||||
 | 
					                <corners android:radius="999dp" />
 | 
				
			||||||
 | 
					                <solid android:color="#3BB9F1" />
 | 
				
			||||||
 | 
					            </shape>
 | 
				
			||||||
 | 
					        </clip>
 | 
				
			||||||
 | 
					    </item>
 | 
				
			||||||
 | 
					</layer-list>
 | 
				
			||||||
@@ -1,17 +1,87 @@
 | 
				
			|||||||
<?xml version="1.0" encoding="utf-8"?>
 | 
					<?xml version="1.0" encoding="utf-8"?>
 | 
				
			||||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
 | 
					<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
 | 
				
			||||||
 | 
					    xmlns:app="http://schemas.android.com/apk/res-auto"
 | 
				
			||||||
 | 
					    xmlns:tools="http://schemas.android.com/tools"
 | 
				
			||||||
    android:layout_width="match_parent"
 | 
					    android:layout_width="match_parent"
 | 
				
			||||||
    android:layout_height="match_parent"
 | 
					    android:layout_height="match_parent"
 | 
				
			||||||
    android:background="@color/color_131313"
 | 
					    android:background="@color/color_131313"
 | 
				
			||||||
    android:padding="24dp">
 | 
					    android:orientation="vertical">
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    <!-- 상단: 레이블(좌/우) + 슬라이더(ProgressBar) -->
 | 
				
			||||||
 | 
					    <androidx.constraintlayout.widget.ConstraintLayout
 | 
				
			||||||
 | 
					        android:id="@+id/cl_ratio"
 | 
				
			||||||
 | 
					        android:layout_width="match_parent"
 | 
				
			||||||
 | 
					        android:layout_height="wrap_content"
 | 
				
			||||||
 | 
					        android:layout_marginHorizontal="24dp"
 | 
				
			||||||
 | 
					        android:layout_marginTop="24dp"
 | 
				
			||||||
 | 
					        android:visibility="gone">
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        <ProgressBar
 | 
				
			||||||
 | 
					            android:id="@+id/progress_ratio"
 | 
				
			||||||
 | 
					            style="@android:style/Widget.ProgressBar.Horizontal"
 | 
				
			||||||
 | 
					            android:layout_width="0dp"
 | 
				
			||||||
 | 
					            android:layout_height="10dp"
 | 
				
			||||||
 | 
					            android:layout_marginTop="8dp"
 | 
				
			||||||
 | 
					            android:clickable="false"
 | 
				
			||||||
 | 
					            android:enabled="false"
 | 
				
			||||||
 | 
					            android:focusable="false"
 | 
				
			||||||
 | 
					            android:indeterminate="false"
 | 
				
			||||||
 | 
					            android:max="100"
 | 
				
			||||||
 | 
					            android:progress="0"
 | 
				
			||||||
 | 
					            android:progressDrawable="@drawable/bg_gallery_progress"
 | 
				
			||||||
 | 
					            app:layout_constraintEnd_toEndOf="parent"
 | 
				
			||||||
 | 
					            app:layout_constraintStart_toStartOf="parent"
 | 
				
			||||||
 | 
					            app:layout_constraintTop_toBottomOf="@+id/tv_ratio_left" />
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        <TextView
 | 
				
			||||||
 | 
					            android:id="@+id/tv_ratio_left"
 | 
				
			||||||
 | 
					            android:layout_width="wrap_content"
 | 
				
			||||||
 | 
					            android:layout_height="wrap_content"
 | 
				
			||||||
 | 
					            android:fontFamily="@font/pretendard_bold"
 | 
				
			||||||
 | 
					            android:textColor="@color/white"
 | 
				
			||||||
 | 
					            android:textSize="18sp"
 | 
				
			||||||
 | 
					            app:layout_constraintStart_toStartOf="parent"
 | 
				
			||||||
 | 
					            app:layout_constraintTop_toTopOf="parent"
 | 
				
			||||||
 | 
					            tools:text="40% 보유중" />
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        <TextView
 | 
				
			||||||
 | 
					            android:id="@+id/tv_ratio_right"
 | 
				
			||||||
 | 
					            android:layout_width="wrap_content"
 | 
				
			||||||
 | 
					            android:layout_height="wrap_content"
 | 
				
			||||||
 | 
					            android:fontFamily="@font/pretendard_regular"
 | 
				
			||||||
 | 
					            android:textColor="@color/white"
 | 
				
			||||||
 | 
					            android:textSize="16sp"
 | 
				
			||||||
 | 
					            app:layout_constraintEnd_toEndOf="parent"
 | 
				
			||||||
 | 
					            app:layout_constraintTop_toTopOf="parent"
 | 
				
			||||||
 | 
					            tools:text="0 / 0개" />
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    </androidx.constraintlayout.widget.ConstraintLayout>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    <!-- RecyclerView: 3열 그리드 -->
 | 
				
			||||||
 | 
					    <androidx.recyclerview.widget.RecyclerView
 | 
				
			||||||
 | 
					        android:id="@+id/rv_gallery"
 | 
				
			||||||
 | 
					        android:layout_width="match_parent"
 | 
				
			||||||
 | 
					        android:layout_height="0dp"
 | 
				
			||||||
 | 
					        android:layout_marginTop="24dp"
 | 
				
			||||||
 | 
					        android:layout_weight="1"
 | 
				
			||||||
 | 
					        android:overScrollMode="never"
 | 
				
			||||||
 | 
					        android:visibility="gone"
 | 
				
			||||||
 | 
					        app:layoutManager="androidx.recyclerview.widget.GridLayoutManager"
 | 
				
			||||||
 | 
					        app:spanCount="3" />
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    <!-- 빈 상태 안내 -->
 | 
				
			||||||
    <TextView
 | 
					    <TextView
 | 
				
			||||||
        android:id="@+id/tv_empty_gallery"
 | 
					        android:id="@+id/tv_empty_gallery"
 | 
				
			||||||
        android:layout_width="wrap_content"
 | 
					        android:layout_width="match_parent"
 | 
				
			||||||
        android:layout_height="wrap_content"
 | 
					        android:layout_height="0dp"
 | 
				
			||||||
        android:text="갤러리 콘텐츠가 없습니다."
 | 
					        android:layout_gravity="center_horizontal"
 | 
				
			||||||
 | 
					        android:layout_marginTop="24dp"
 | 
				
			||||||
 | 
					        android:layout_weight="1"
 | 
				
			||||||
 | 
					        android:fontFamily="@font/pretendard_bold"
 | 
				
			||||||
 | 
					        android:gravity="center"
 | 
				
			||||||
 | 
					        android:text="갤러리가 비어있습니다"
 | 
				
			||||||
        android:textColor="@color/white"
 | 
					        android:textColor="@color/white"
 | 
				
			||||||
        android:textSize="16sp"
 | 
					        android:textSize="20sp"
 | 
				
			||||||
        android:layout_gravity="center" />
 | 
					        android:visibility="gone" />
 | 
				
			||||||
 | 
					
 | 
				
			||||||
</FrameLayout>
 | 
					</LinearLayout>
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										66
									
								
								app/src/main/res/layout/item_character_gallery.xml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										66
									
								
								app/src/main/res/layout/item_character_gallery.xml
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,66 @@
 | 
				
			|||||||
 | 
					<?xml version="1.0" encoding="utf-8"?>
 | 
				
			||||||
 | 
					<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
 | 
				
			||||||
 | 
					    xmlns:app="http://schemas.android.com/apk/res-auto"
 | 
				
			||||||
 | 
					    xmlns:tools="http://schemas.android.com/tools"
 | 
				
			||||||
 | 
					    android:layout_width="match_parent"
 | 
				
			||||||
 | 
					    android:layout_height="wrap_content">
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    <!-- 이미지 컨테이너: 4:5 비율(800:1000) -->
 | 
				
			||||||
 | 
					    <ImageView
 | 
				
			||||||
 | 
					        android:id="@+id/iv_image"
 | 
				
			||||||
 | 
					        android:layout_width="0dp"
 | 
				
			||||||
 | 
					        android:layout_height="0dp"
 | 
				
			||||||
 | 
					        android:contentDescription="@null"
 | 
				
			||||||
 | 
					        android:scaleType="centerCrop"
 | 
				
			||||||
 | 
					        app:layout_constraintDimensionRatio="4:5"
 | 
				
			||||||
 | 
					        app:layout_constraintEnd_toEndOf="parent"
 | 
				
			||||||
 | 
					        app:layout_constraintStart_toStartOf="parent"
 | 
				
			||||||
 | 
					        app:layout_constraintTop_toTopOf="parent" />
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    <LinearLayout
 | 
				
			||||||
 | 
					        android:id="@+id/ll_lock"
 | 
				
			||||||
 | 
					        android:layout_width="wrap_content"
 | 
				
			||||||
 | 
					        android:layout_height="wrap_content"
 | 
				
			||||||
 | 
					        android:gravity="center"
 | 
				
			||||||
 | 
					        android:orientation="vertical"
 | 
				
			||||||
 | 
					        android:visibility="gone"
 | 
				
			||||||
 | 
					        app:layout_constraintBottom_toBottomOf="@id/iv_image"
 | 
				
			||||||
 | 
					        app:layout_constraintEnd_toEndOf="@id/iv_image"
 | 
				
			||||||
 | 
					        app:layout_constraintStart_toStartOf="@id/iv_image"
 | 
				
			||||||
 | 
					        app:layout_constraintTop_toTopOf="@id/iv_image">
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        <ImageView
 | 
				
			||||||
 | 
					            android:layout_width="wrap_content"
 | 
				
			||||||
 | 
					            android:layout_height="wrap_content"
 | 
				
			||||||
 | 
					            android:contentDescription="@null"
 | 
				
			||||||
 | 
					            android:src="@drawable/ic_new_lock" />
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        <LinearLayout
 | 
				
			||||||
 | 
					            android:id="@+id/btn_buy"
 | 
				
			||||||
 | 
					            android:layout_width="wrap_content"
 | 
				
			||||||
 | 
					            android:layout_height="wrap_content"
 | 
				
			||||||
 | 
					            android:layout_margin="10dp"
 | 
				
			||||||
 | 
					            android:background="@drawable/bg_buy_button"
 | 
				
			||||||
 | 
					            android:gravity="center_vertical"
 | 
				
			||||||
 | 
					            android:paddingHorizontal="10dp"
 | 
				
			||||||
 | 
					            android:paddingVertical="3dp">
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            <ImageView
 | 
				
			||||||
 | 
					                android:layout_width="16dp"
 | 
				
			||||||
 | 
					                android:layout_height="16dp"
 | 
				
			||||||
 | 
					                android:layout_marginEnd="4dp"
 | 
				
			||||||
 | 
					                android:contentDescription="@null"
 | 
				
			||||||
 | 
					                android:src="@drawable/ic_can" />
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            <TextView
 | 
				
			||||||
 | 
					                android:id="@+id/tv_price"
 | 
				
			||||||
 | 
					                android:layout_width="wrap_content"
 | 
				
			||||||
 | 
					                android:layout_height="wrap_content"
 | 
				
			||||||
 | 
					                android:fontFamily="@font/pretendard_bold"
 | 
				
			||||||
 | 
					                android:textColor="#263238"
 | 
				
			||||||
 | 
					                android:textSize="16sp"
 | 
				
			||||||
 | 
					                tools:text="0" />
 | 
				
			||||||
 | 
					        </LinearLayout>
 | 
				
			||||||
 | 
					    </LinearLayout>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					</androidx.constraintlayout.widget.ConstraintLayout>
 | 
				
			||||||
		Reference in New Issue
	
	Block a user