From 6c57c5a98a8c590259f06fa0ce9b2964b923f951 Mon Sep 17 00:00:00 2001 From: klaus Date: Sat, 23 Aug 2025 01:48:57 +0900 Subject: [PATCH] =?UTF-8?q?feat(character-gallery):=20=EA=B5=AC=EB=A7=A4?= =?UTF-8?q?=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EC=A0=84=EC=B2=B4=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20Carousel=20=EB=B7=B0=EC=96=B4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 구매된 이미지를 탭하면 전체화면 DialogFragment로 열리고, ViewPager2 기반 Carousel로 좌우 슬라이딩 탐색이 가능하도록 구현. --- .../detail/gallery/CharacterGalleryAdapter.kt | 8 +- .../gallery/CharacterGalleryFragment.kt | 28 ++++-- .../CharacterGalleryViewerDialogFragment.kt | 89 +++++++++++++++++++ app/src/main/res/layout/dialog_soda.xml | 6 +- .../fragment_character_gallery_viewer.xml | 29 ++++++ .../main/res/layout/item_fullscreen_image.xml | 14 +++ 6 files changed, 165 insertions(+), 9 deletions(-) create mode 100644 app/src/main/java/kr/co/vividnext/sodalive/chat/character/detail/gallery/CharacterGalleryViewerDialogFragment.kt create mode 100644 app/src/main/res/layout/fragment_character_gallery_viewer.xml create mode 100644 app/src/main/res/layout/item_fullscreen_image.xml 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 349321fe..8efda151 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 @@ -11,7 +11,8 @@ import kr.co.vividnext.sodalive.databinding.ItemCharacterGalleryBinding class CharacterGalleryAdapter( private var items: List = emptyList(), - private val onClickBuy: (item: CharacterImageListItemResponse, position: Int) -> Unit = { _, _ -> } + private val onClickBuy: (item: CharacterImageListItemResponse, position: Int) -> Unit = { _, _ -> }, + private val onClickOwned: (item: CharacterImageListItemResponse, position: Int) -> Unit = { _, _ -> } ) : RecyclerView.Adapter() { inner class ViewHolder( @@ -26,12 +27,17 @@ class CharacterGalleryAdapter( if (item.isOwned) { binding.llLock.visibility = View.GONE binding.btnBuy.setOnClickListener(null) + binding.root.setOnClickListener { + onClickOwned(item, bindingAdapterPosition) + } } else { binding.llLock.visibility = View.VISIBLE binding.tvPrice.text = item.imagePriceCan.toString() binding.btnBuy.setOnClickListener { onClickBuy(item, bindingAdapterPosition) } + // 잠금 상태에서는 아이템 클릭 시 아무 동작 없음 (구매 버튼만 활성) + binding.root.setOnClickListener(null) } } } 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 706c8f22..5673df23 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 @@ -2,6 +2,7 @@ package kr.co.vividnext.sodalive.chat.character.detail.gallery import android.annotation.SuppressLint import android.os.Bundle +import android.text.style.ForegroundColorSpan import android.view.View import androidx.core.graphics.toColorInt import androidx.recyclerview.widget.GridLayoutManager @@ -26,6 +27,8 @@ class CharacterGalleryFragment : BaseFragment( private lateinit var adapter: CharacterGalleryAdapter private lateinit var loadingDialog: LoadingDialog + private var latestItems: List = emptyList() + private val characterId: Long by lazy { arguments?.getLong("arg_character_id") ?: requireActivity().intent.getLongExtra(EXTRA_CHARACTER_ID, 0L) @@ -49,9 +52,24 @@ class CharacterGalleryFragment : BaseFragment( GridSpacingItemDecoration(3, 2f.dpToPx().toInt(), true) ) } - adapter = CharacterGalleryAdapter(onClickBuy = { item, position -> - showPurchaseDialog(item, position) - }) + adapter = CharacterGalleryAdapter( + onClickBuy = { item, position -> + showPurchaseDialog(item, position) + }, + onClickOwned = { item, position -> + // 구매된 항목만 전체화면 뷰어로 진입 + val ownedItems = latestItems.filter { it.isOwned } + if (ownedItems.isEmpty()) return@CharacterGalleryAdapter + val startIndex = ownedItems.indexOfFirst { + it.id == item.id + }.coerceAtLeast(0) + val urls = ownedItems.map { it.imageUrl } + val dialog = CharacterGalleryViewerDialogFragment.newInstance(urls, startIndex) + if (!dialog.isAdded) { + dialog.show(parentFragmentManager, "CharacterGalleryViewerDialog") + } + } + ) binding.rvGallery.adapter = adapter binding.rvGallery.addOnScrollListener(object : RecyclerView.OnScrollListener() { @@ -88,7 +106,6 @@ class CharacterGalleryFragment : BaseFragment( binding.clRatio.visibility = View.VISIBLE val percent = (state.ratio * 100).toInt() - // 좌측 라벨은 고정("보유중"), 우측은 보유/전체 개수(ownedCount만 강조 색상 적용) binding.tvRatioLeft.text = "$percent% 보유중" val ownedStr = state.ownedCount.toString() @@ -97,7 +114,7 @@ class CharacterGalleryFragment : BaseFragment( val spannable = android.text.SpannableString(fullText) val ownedColor = "#FDD453".toColorInt() spannable.setSpan( - android.text.style.ForegroundColorSpan(ownedColor), + ForegroundColorSpan(ownedColor), /* start */ 0, /* end */ ownedStr.length, android.text.Spannable.SPAN_EXCLUSIVE_EXCLUSIVE @@ -108,6 +125,7 @@ class CharacterGalleryFragment : BaseFragment( // 슬라이더(ProgressBar) 값 설정: 0~100 binding.progressRatio.progress = percent + latestItems = state.items adapter.submitItems(state.items) } else { binding.rvGallery.visibility = View.VISIBLE diff --git a/app/src/main/java/kr/co/vividnext/sodalive/chat/character/detail/gallery/CharacterGalleryViewerDialogFragment.kt b/app/src/main/java/kr/co/vividnext/sodalive/chat/character/detail/gallery/CharacterGalleryViewerDialogFragment.kt new file mode 100644 index 00000000..ef13be3c --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/chat/character/detail/gallery/CharacterGalleryViewerDialogFragment.kt @@ -0,0 +1,89 @@ +package kr.co.vividnext.sodalive.chat.character.detail.gallery + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.DialogFragment +import androidx.recyclerview.widget.RecyclerView +import com.bumptech.glide.Glide +import com.bumptech.glide.load.engine.DiskCacheStrategy +import kr.co.vividnext.sodalive.databinding.FragmentCharacterGalleryViewerBinding +import kr.co.vividnext.sodalive.databinding.ItemFullscreenImageBinding + +class CharacterGalleryViewerDialogFragment : DialogFragment() { + + private var _binding: FragmentCharacterGalleryViewerBinding? = null + private val binding get() = _binding!! + + private val imageUrls: ArrayList by lazy { + arguments?.getStringArrayList(ARG_URLS) ?: arrayListOf() + } + private val startIndex: Int by lazy { + arguments?.getInt(ARG_START_INDEX) ?: 0 + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setStyle(STYLE_NORMAL, android.R.style.Theme_Black_NoTitleBar_Fullscreen) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentCharacterGalleryViewerBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + binding.viewPager.adapter = ImagePagerAdapter(imageUrls) + if (startIndex in imageUrls.indices) { + binding.viewPager.setCurrentItem(startIndex, false) + } + + binding.btnClose.setOnClickListener { dismissAllowingStateLoss() } + } + + override fun onDestroyView() { + _binding = null + super.onDestroyView() + } + + class ImagePagerAdapter(private val urls: List) : RecyclerView.Adapter() { + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ImageViewHolder { + val binding = ItemFullscreenImageBinding.inflate(LayoutInflater.from(parent.context), parent, false) + return ImageViewHolder(binding) + } + override fun onBindViewHolder(holder: ImageViewHolder, position: Int) { + holder.bind(urls[position]) + } + override fun getItemCount(): Int = urls.size + } + + class ImageViewHolder(private val binding: ItemFullscreenImageBinding) : RecyclerView.ViewHolder(binding.root) { + fun bind(url: String) { + Glide.with(binding.ivFull) + .load(url) + .diskCacheStrategy(DiskCacheStrategy.AUTOMATIC) + .into(binding.ivFull) + } + } + + companion object { + private const val ARG_URLS = "arg_urls" + private const val ARG_START_INDEX = "arg_start_index" + + fun newInstance(urls: List, startIndex: Int): CharacterGalleryViewerDialogFragment { + val fragment = CharacterGalleryViewerDialogFragment() + fragment.arguments = Bundle().apply { + putStringArrayList(ARG_URLS, ArrayList(urls)) + putInt(ARG_START_INDEX, startIndex) + } + return fragment + } + } +} diff --git a/app/src/main/res/layout/dialog_soda.xml b/app/src/main/res/layout/dialog_soda.xml index 7ea8811c..a10f806d 100644 --- a/app/src/main/res/layout/dialog_soda.xml +++ b/app/src/main/res/layout/dialog_soda.xml @@ -26,7 +26,7 @@ android:id="@+id/tv_desc" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:layout_marginTop="6dp" + android:layout_marginTop="12dp" android:fontFamily="@font/gmarket_sans_medium" android:textColor="@color/color_bbbbbb" android:textSize="15sp" @@ -38,7 +38,7 @@ + + + + + + + diff --git a/app/src/main/res/layout/item_fullscreen_image.xml b/app/src/main/res/layout/item_fullscreen_image.xml new file mode 100644 index 00000000..e4995299 --- /dev/null +++ b/app/src/main/res/layout/item_fullscreen_image.xml @@ -0,0 +1,14 @@ + + + + + +