From 13ee098cfcb3942c5cfd74546a4eb41ac8f98461 Mon Sep 17 00:00:00 2001 From: klaus Date: Fri, 22 Aug 2025 17:03:01 +0900 Subject: [PATCH] =?UTF-8?q?feat(character-gallery):=20=EA=B0=A4=EB=9F=AC?= =?UTF-8?q?=EB=A6=AC=20=ED=83=AD=20UI/=ED=8E=98=EC=9D=B4=EC=A7=95=20?= =?UTF-8?q?=EB=B0=8F=20API=20=EC=97=B0=EB=8F=99,=20DI=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 대비) --- .../sodalive/chat/character/CharacterApi.kt | 10 ++ .../detail/gallery/CharacterGalleryAdapter.kt | 49 +++++++++ .../gallery/CharacterGalleryFragment.kt | 89 +++++++++++++++- .../gallery/CharacterGalleryRepository.kt | 10 ++ .../gallery/CharacterGalleryViewModel.kt | 99 ++++++++++++++++++ .../gallery/CharacterImageListResponse.kt | 19 ++++ .../java/kr/co/vividnext/sodalive/di/AppDI.kt | 4 + .../main/res/drawable-mdpi/ic_new_lock.png | Bin 0 -> 359 bytes app/src/main/res/drawable/bg_buy_button.xml | 6 ++ .../main/res/drawable/bg_gallery_progress.xml | 17 +++ .../res/layout/fragment_character_gallery.xml | 86 +++++++++++++-- .../res/layout/item_character_gallery.xml | 66 ++++++++++++ 12 files changed, 445 insertions(+), 10 deletions(-) create mode 100644 app/src/main/java/kr/co/vividnext/sodalive/chat/character/detail/gallery/CharacterGalleryAdapter.kt create mode 100644 app/src/main/java/kr/co/vividnext/sodalive/chat/character/detail/gallery/CharacterGalleryRepository.kt create mode 100644 app/src/main/java/kr/co/vividnext/sodalive/chat/character/detail/gallery/CharacterGalleryViewModel.kt create mode 100644 app/src/main/java/kr/co/vividnext/sodalive/chat/character/detail/gallery/CharacterImageListResponse.kt create mode 100644 app/src/main/res/drawable-mdpi/ic_new_lock.png create mode 100644 app/src/main/res/drawable/bg_buy_button.xml create mode 100644 app/src/main/res/drawable/bg_gallery_progress.xml create mode 100644 app/src/main/res/layout/item_character_gallery.xml 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 96ae369b..ac3ce44c 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 @@ -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> + + @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> } 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 new file mode 100644 index 00000000..bb035d31 --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/chat/character/detail/gallery/CharacterGalleryAdapter.kt @@ -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 = emptyList() +) : RecyclerView.Adapter() { + + 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) { + items = newItems + notifyDataSetChanged() + } +} 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 63bd57d0..d99dd9df 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 @@ -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::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) } + } } } 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 new file mode 100644 index 00000000..0e5255f1 --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/chat/character/detail/gallery/CharacterGalleryRepository.kt @@ -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) +} 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 new file mode 100644 index 00000000..8bd8f17b --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/chat/character/detail/gallery/CharacterGalleryViewModel.kt @@ -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 = emptyList(), + val isLoading: Boolean = false, + val error: String? = null + ) + + private val _uiState = MutableLiveData(UiState()) + val uiState: LiveData 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() + + 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 + }) + ) + } +} diff --git a/app/src/main/java/kr/co/vividnext/sodalive/chat/character/detail/gallery/CharacterImageListResponse.kt b/app/src/main/java/kr/co/vividnext/sodalive/chat/character/detail/gallery/CharacterImageListResponse.kt new file mode 100644 index 00000000..03eec411 --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/chat/character/detail/gallery/CharacterImageListResponse.kt @@ -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 +) diff --git a/app/src/main/java/kr/co/vividnext/sodalive/di/AppDI.kt b/app/src/main/java/kr/co/vividnext/sodalive/di/AppDI.kt index 11a239c2..39c917c0 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/di/AppDI.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/di/AppDI.kt @@ -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()) } } diff --git a/app/src/main/res/drawable-mdpi/ic_new_lock.png b/app/src/main/res/drawable-mdpi/ic_new_lock.png new file mode 100644 index 0000000000000000000000000000000000000000..a6a13de46cddc9a648f412854db3d6320003423b GIT binary patch literal 359 zcmeAS@N?(olHy`uVBq!ia0vp^5ylrKeYp5)mPDW3=ed6A&%FX(l~~0tui`XuYuNhZ zhu3b6<$9BN7fG9_bH}F7aAK$pUtu3_a7kTg=5ZAPWnTklF2?@{j%c5>e{ui86U)O) z|EzY{Ry6q>pZxXjmFX(WWX(C2FOtjoZ~d>&yx8{I)X1|D+`w>P@O1TaS?83{1OT=v Bi~|4w literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable/bg_buy_button.xml b/app/src/main/res/drawable/bg_buy_button.xml new file mode 100644 index 00000000..7b125cd0 --- /dev/null +++ b/app/src/main/res/drawable/bg_buy_button.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/res/drawable/bg_gallery_progress.xml b/app/src/main/res/drawable/bg_gallery_progress.xml new file mode 100644 index 00000000..603b7d3f --- /dev/null +++ b/app/src/main/res/drawable/bg_gallery_progress.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_character_gallery.xml b/app/src/main/res/layout/fragment_character_gallery.xml index 2fd9dad7..3d716c94 100644 --- a/app/src/main/res/layout/fragment_character_gallery.xml +++ b/app/src/main/res/layout/fragment_character_gallery.xml @@ -1,17 +1,87 @@ - + android:orientation="vertical"> + + + + + + + + + + + + + + + + android:textSize="20sp" + android:visibility="gone" /> - + diff --git a/app/src/main/res/layout/item_character_gallery.xml b/app/src/main/res/layout/item_character_gallery.xml new file mode 100644 index 00000000..00eed467 --- /dev/null +++ b/app/src/main/res/layout/item_character_gallery.xml @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + +