fix(mypage): 기능 버튼 배치와 쿠폰 분기를 정리한다

This commit is contained in:
2026-04-02 12:12:59 +09:00
parent 9ef2cb1731
commit 8d8d5e340f
4 changed files with 218 additions and 211 deletions

View File

@@ -4,11 +4,15 @@ import android.annotation.SuppressLint
import android.content.Intent import android.content.Intent
import android.graphics.Rect import android.graphics.Rect
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup
import android.webkit.URLUtil import android.webkit.URLUtil
import android.widget.Toast import android.widget.Toast
import androidx.annotation.DrawableRes
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import coil.load import coil.load
@@ -20,10 +24,12 @@ import kr.co.vividnext.sodalive.audio_content.detail.AudioContentDetailActivity
import kr.co.vividnext.sodalive.base.BaseFragment import kr.co.vividnext.sodalive.base.BaseFragment
import kr.co.vividnext.sodalive.common.Constants import kr.co.vividnext.sodalive.common.Constants
import kr.co.vividnext.sodalive.common.FunctionButtonHelper import kr.co.vividnext.sodalive.common.FunctionButtonHelper
import kr.co.vividnext.sodalive.common.GridSpacingItemDecoration
import kr.co.vividnext.sodalive.common.LoadingDialog import kr.co.vividnext.sodalive.common.LoadingDialog
import kr.co.vividnext.sodalive.common.SharedPreferenceManager import kr.co.vividnext.sodalive.common.SharedPreferenceManager
import kr.co.vividnext.sodalive.common.SodaLiveApplicationHolder import kr.co.vividnext.sodalive.common.SodaLiveApplicationHolder
import kr.co.vividnext.sodalive.databinding.FragmentMyBinding import kr.co.vividnext.sodalive.databinding.FragmentMyBinding
import kr.co.vividnext.sodalive.databinding.ItemFunctionButtonBinding
import kr.co.vividnext.sodalive.explorer.profile.UserProfileActivity import kr.co.vividnext.sodalive.explorer.profile.UserProfileActivity
import kr.co.vividnext.sodalive.extensions.dpToPx import kr.co.vividnext.sodalive.extensions.dpToPx
import kr.co.vividnext.sodalive.extensions.moneyFormat import kr.co.vividnext.sodalive.extensions.moneyFormat
@@ -52,10 +58,15 @@ import org.koin.android.ext.android.inject
@UnstableApi @UnstableApi
class MyPageFragment : BaseFragment<FragmentMyBinding>(FragmentMyBinding::inflate) { class MyPageFragment : BaseFragment<FragmentMyBinding>(FragmentMyBinding::inflate) {
companion object {
private const val FUNCTION_BUTTON_SPAN_COUNT = 4
}
private val viewModel: MyPageViewModel by inject() private val viewModel: MyPageViewModel by inject()
private val recentContentViewModel: RecentContentViewModel by inject() private val recentContentViewModel: RecentContentViewModel by inject()
private lateinit var loadingDialog: LoadingDialog private lateinit var loadingDialog: LoadingDialog
private val functionButtonAdapter = FunctionButtonAdapter()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
@@ -187,6 +198,7 @@ class MyPageFragment : BaseFragment<FragmentMyBinding>(FragmentMyBinding::inflat
binding.llProfileLoginContainer.visibility = View.GONE binding.llProfileLoginContainer.visibility = View.GONE
binding.llFunctionButtonGrid.visibility = View.VISIBLE binding.llFunctionButtonGrid.visibility = View.VISIBLE
setupFunctionButtonGrid()
binding.ivSettings.setOnClickListener { binding.ivSettings.setOnClickListener {
startActivity( startActivity(
@@ -239,84 +251,6 @@ class MyPageFragment : BaseFragment<FragmentMyBinding>(FragmentMyBinding::inflat
} }
} }
FunctionButtonHelper.setupFunctionButton(
buttonView = binding.btnStorage.root,
iconRes = R.drawable.ic_my_storage,
title = getString(R.string.screen_my_storage)
) {
startActivity(
Intent(
requireContext(),
AudioContentBoxActivity::class.java
)
)
}
FunctionButtonHelper.setupFunctionButton(
buttonView = binding.btnBlockList.root,
iconRes = R.drawable.ic_my_block,
title = getString(R.string.screen_my_block_list)
) {
startActivity(
Intent(
requireContext(),
BlockMemberActivity::class.java
)
)
}
FunctionButtonHelper.setupFunctionButton(
buttonView = binding.btnMorningCall.root,
iconRes = R.drawable.ic_my_alarm,
title = getString(R.string.screen_my_morning_call)
) {
startActivity(
Intent(
requireActivity(),
AlarmListActivity::class.java
)
)
}
FunctionButtonHelper.setupFunctionButton(
buttonView = binding.btnNotice.root,
iconRes = R.drawable.ic_my_notice,
title = getString(R.string.screen_my_notice)
) {
startActivity(
Intent(
requireActivity(),
NoticeActivity::class.java
)
)
}
FunctionButtonHelper.setupFunctionButton(
buttonView = binding.btnEvent.root,
iconRes = R.drawable.ic_my_event,
title = getString(R.string.screen_my_event)
) {
startActivity(
Intent(
requireActivity(),
EventActivity::class.java
)
)
}
FunctionButtonHelper.setupFunctionButton(
buttonView = binding.btnCustomerService.root,
iconRes = R.drawable.ic_my_service_center,
title = getString(R.string.screen_my_customer_service)
) {
startActivity(
Intent(
requireActivity(),
ServiceCenterActivity::class.java
)
)
}
if (SharedPreferenceManager.role == MemberRole.CREATOR.name) { if (SharedPreferenceManager.role == MemberRole.CREATOR.name) {
binding.tvMyChannel.visibility = View.VISIBLE binding.tvMyChannel.visibility = View.VISIBLE
binding.tvMyChannel.setOnClickListener { binding.tvMyChannel.setOnClickListener {
@@ -335,6 +269,8 @@ class MyPageFragment : BaseFragment<FragmentMyBinding>(FragmentMyBinding::inflat
} else { } else {
binding.tvMyChannel.visibility = View.GONE binding.tvMyChannel.visibility = View.GONE
} }
updateFunctionButtons()
} else { } else {
binding.ivSettings.visibility = View.GONE binding.ivSettings.visibility = View.GONE
binding.llFunctionButtonGrid.visibility = View.GONE binding.llFunctionButtonGrid.visibility = View.GONE
@@ -380,29 +316,6 @@ class MyPageFragment : BaseFragment<FragmentMyBinding>(FragmentMyBinding::inflat
} }
viewModel.myPageLiveData.observe(viewLifecycleOwner) { viewModel.myPageLiveData.observe(viewLifecycleOwner) {
val isKoreanUser = SharedPreferenceManager.countryCode.ifBlank { "KR" } == "KR"
if (isKoreanUser) {
binding.btnIdentityVerification.root.visibility = View.VISIBLE
if (it.isAuth) {
FunctionButtonHelper.setupFunctionButton(
buttonView = binding.btnIdentityVerification.root,
iconRes = R.drawable.ic_my_auth,
title = getString(R.string.screen_my_identity_verified)
)
} else {
FunctionButtonHelper.setupFunctionButton(
buttonView = binding.btnIdentityVerification.root,
iconRes = R.drawable.ic_my_auth,
title = getString(R.string.screen_my_identity_verification)
) {
showAuthDialog()
}
}
} else {
binding.btnIdentityVerification.root.visibility = View.INVISIBLE
}
binding.ivProfile.load(it.profileUrl) { binding.ivProfile.load(it.profileUrl) {
crossfade(true) crossfade(true)
placeholder(R.drawable.ic_place_holder) placeholder(R.drawable.ic_place_holder)
@@ -413,41 +326,75 @@ class MyPageFragment : BaseFragment<FragmentMyBinding>(FragmentMyBinding::inflat
binding.tvCanAmount.text = (it.chargeCan + it.rewardCan).moneyFormat() binding.tvCanAmount.text = (it.chargeCan + it.rewardCan).moneyFormat()
binding.tvPointAmount.text = it.point.moneyFormat() binding.tvPointAmount.text = it.point.moneyFormat()
val shouldShowCouponButton = if (isKoreanUser) { updateFunctionButtons(it.isAuth)
true }
} else { }
SharedPreferenceManager.isAdultContentVisible
}
binding.btnCoupon.root.visibility = if (shouldShowCouponButton) { private fun setupFunctionButtonGrid() {
View.VISIBLE binding.rvFunctionButtons.layoutManager = GridLayoutManager(
} else { requireContext(),
View.GONE FUNCTION_BUTTON_SPAN_COUNT
} )
if (!shouldShowCouponButton) { if (binding.rvFunctionButtons.itemDecorationCount == 0) {
return@observe binding.rvFunctionButtons.addItemDecoration(
} GridSpacingItemDecoration(
spanCount = FUNCTION_BUTTON_SPAN_COUNT,
spacing = 16f.dpToPx().toInt(),
includeEdge = false
)
)
}
if (it.isAuth || !isKoreanUser) { binding.rvFunctionButtons.adapter = functionButtonAdapter
FunctionButtonHelper.setupFunctionButton( }
buttonView = binding.btnCoupon.root,
iconRes = R.drawable.ic_my_coupon, private fun updateFunctionButtons(isAuth: Boolean? = null) {
title = getString(R.string.screen_my_coupon_register) val isKoreanUser = SharedPreferenceManager.countryCode.ifBlank { "KR" } == "KR"
) { val items = mutableListOf(
FunctionButtonItem(
iconRes = R.drawable.ic_my_storage,
title = getString(R.string.screen_my_storage)
) {
startActivity(
Intent(
requireContext(),
AudioContentBoxActivity::class.java
)
)
},
FunctionButtonItem(
iconRes = R.drawable.ic_my_block,
title = getString(R.string.screen_my_block_list)
) {
startActivity(
Intent(
requireContext(),
BlockMemberActivity::class.java
)
)
}
)
val shouldShowCouponButton = if (isKoreanUser) {
isAuth != null
} else {
SharedPreferenceManager.isAdultContentVisible
}
if (shouldShowCouponButton) {
items += FunctionButtonItem(
iconRes = R.drawable.ic_my_coupon,
title = getString(R.string.screen_my_coupon_register)
) {
if ((isAuth == true) || !isKoreanUser) {
startActivity( startActivity(
Intent( Intent(
requireActivity(), requireActivity(),
CanCouponActivity::class.java CanCouponActivity::class.java
) )
) )
} } else {
} else {
FunctionButtonHelper.setupFunctionButton(
buttonView = binding.btnCoupon.root,
iconRes = R.drawable.ic_my_coupon,
title = getString(R.string.screen_my_coupon_register)
) {
Toast.makeText( Toast.makeText(
requireContext(), requireContext(),
getString(R.string.screen_my_auth_required), getString(R.string.screen_my_auth_required),
@@ -458,6 +405,70 @@ class MyPageFragment : BaseFragment<FragmentMyBinding>(FragmentMyBinding::inflat
} }
} }
} }
items += listOf(
FunctionButtonItem(
iconRes = R.drawable.ic_my_alarm,
title = getString(R.string.screen_my_morning_call)
) {
startActivity(
Intent(
requireActivity(),
AlarmListActivity::class.java
)
)
},
FunctionButtonItem(
iconRes = R.drawable.ic_my_notice,
title = getString(R.string.screen_my_notice)
) {
startActivity(
Intent(
requireActivity(),
NoticeActivity::class.java
)
)
},
FunctionButtonItem(
iconRes = R.drawable.ic_my_event,
title = getString(R.string.screen_my_event)
) {
startActivity(
Intent(
requireActivity(),
EventActivity::class.java
)
)
},
FunctionButtonItem(
iconRes = R.drawable.ic_my_service_center,
title = getString(R.string.screen_my_customer_service)
) {
startActivity(
Intent(
requireActivity(),
ServiceCenterActivity::class.java
)
)
}
)
if (isKoreanUser && isAuth != null) {
items += FunctionButtonItem(
iconRes = R.drawable.ic_my_auth,
title = if (isAuth) {
getString(R.string.screen_my_identity_verified)
} else {
getString(R.string.screen_my_identity_verification)
}
) {
if (!isAuth) {
showAuthDialog()
}
}
}
functionButtonAdapter.submitList(items)
} }
private fun showAuthDialog() { private fun showAuthDialog() {
@@ -486,3 +497,47 @@ class MyPageFragment : BaseFragment<FragmentMyBinding>(FragmentMyBinding::inflat
} }
} }
} }
private data class FunctionButtonItem(
@field:DrawableRes val iconRes: Int,
val title: String,
val onClick: () -> Unit
)
private class FunctionButtonAdapter : RecyclerView.Adapter<FunctionButtonAdapter.ViewHolder>() {
private val items = mutableListOf<FunctionButtonItem>()
inner class ViewHolder(
private val binding: ItemFunctionButtonBinding
) : RecyclerView.ViewHolder(binding.root) {
fun bind(item: FunctionButtonItem) {
FunctionButtonHelper.setupFunctionButton(
buttonView = binding.root,
iconRes = item.iconRes,
title = item.title,
clickListener = View.OnClickListener { item.onClick() }
)
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = ViewHolder(
ItemFunctionButtonBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
)
)
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
holder.bind(items[position])
}
override fun getItemCount() = items.size
@SuppressLint("NotifyDataSetChanged")
fun submitList(newItems: List<FunctionButtonItem>) {
items.clear()
items.addAll(newItems)
notifyDataSetChanged()
}
}

View File

@@ -276,86 +276,13 @@
android:orientation="vertical" android:orientation="vertical"
android:visibility="gone"> android:visibility="gone">
<!-- First Row --> <androidx.recyclerview.widget.RecyclerView
<LinearLayout android:id="@+id/rv_function_buttons"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginBottom="16dp" android:nestedScrollingEnabled="false"
android:baselineAligned="false" tools:itemCount="8"
android:orientation="horizontal"> tools:listitem="@layout/item_function_button" />
<include
android:id="@+id/btn_storage"
layout="@layout/item_function_button"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
android:layout_weight="1" />
<include
android:id="@+id/btn_block_list"
layout="@layout/item_function_button"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginHorizontal="8dp"
android:layout_weight="1" />
<include
android:id="@+id/btn_coupon"
layout="@layout/item_function_button"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginHorizontal="8dp"
android:layout_weight="1" />
<include
android:id="@+id/btn_morning_call"
layout="@layout/item_function_button"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_weight="1" />
</LinearLayout>
<!-- Second Row -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:baselineAligned="false"
android:orientation="horizontal">
<include
android:id="@+id/btn_notice"
layout="@layout/item_function_button"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
android:layout_weight="1" />
<include
android:id="@+id/btn_event"
layout="@layout/item_function_button"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginHorizontal="8dp"
android:layout_weight="1" />
<include
android:id="@+id/btn_customer_service"
layout="@layout/item_function_button"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginHorizontal="8dp"
android:layout_weight="1" />
<include
android:id="@+id/btn_identity_verification"
layout="@layout/item_function_button"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_weight="1" />
</LinearLayout>
</LinearLayout> </LinearLayout>
<ImageView <ImageView

View File

@@ -1,8 +1,7 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="0dp" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_weight="1"
android:gravity="center" android:gravity="center"
android:orientation="vertical"> android:orientation="vertical">

View File

@@ -3,6 +3,7 @@
## 작업 목표 ## 작업 목표
- 마이페이지 `btnCoupon` 터치 시 한국 사용자는 기존처럼 본인인증이 필요하고, 한국이 아닌 사용자는 본인인증 없이 쿠폰 등록 화면으로 이동하도록 수정한다. - 마이페이지 `btnCoupon` 터치 시 한국 사용자는 기존처럼 본인인증이 필요하고, 한국이 아닌 사용자는 본인인증 없이 쿠폰 등록 화면으로 이동하도록 수정한다.
- 마이페이지 `btnCoupon`은 한국이 아닌 사용자에 한해 민감한 콘텐츠 보기 설정이 켜져 있을 때만 화면에 보이도록 수정한다. - 마이페이지 `btnCoupon`은 한국이 아닌 사용자에 한해 민감한 콘텐츠 보기 설정이 켜져 있을 때만 화면에 보이도록 수정한다.
- 마이페이지 기능 버튼 영역을 `RecyclerView` 기반 그리드로 전환해 숨겨진 버튼이 있어도 중간 빈 슬롯 없이 왼쪽부터 자연스럽게 재배치되도록 수정한다.
## 체크리스트 ## 체크리스트
- [x] AC1: `countryCode == "KR"` 이고 `isAuth == false`인 경우 기존처럼 인증 필요 토스트와 `showAuthDialog()`가 실행된다. - [x] AC1: `countryCode == "KR"` 이고 `isAuth == false`인 경우 기존처럼 인증 필요 토스트와 `showAuthDialog()`가 실행된다.
@@ -13,12 +14,16 @@
- QA: 인증 완료 사용자의 쿠폰 등록 진입 동작 유지 코드 확인 - QA: 인증 완료 사용자의 쿠폰 등록 진입 동작 유지 코드 확인
- [x] AC4: 변경 파일 진단/검증 명령 결과를 기록한다. - [x] AC4: 변경 파일 진단/검증 명령 결과를 기록한다.
- QA: `lsp_diagnostics`, 관련 Gradle 검증 명령 결과 기록 - QA: `lsp_diagnostics`, 관련 Gradle 검증 명령 결과 기록
- [x] AC5: `countryCode != "KR"` 이고 `SharedPreferenceManager.isAdultContentVisible == true`인 경우에만 `btnCoupon` 화면에 보인다. - [x] AC5: `countryCode != "KR"` 이고 `SharedPreferenceManager.isAdultContentVisible == true`인 경우에만 쿠폰 버튼이 기능 버튼 목록에 포함되어 화면에 보인다.
- QA: 비한국 사용자에서 `btnCoupon.root.visibility` 민감한 콘텐츠 보기 설정값에 따라 제어되는지 코드 확인 - QA: 비한국 사용자에서 쿠폰 버튼 아이템이 민감한 콘텐츠 보기 설정값에 따라 리스트에 포함되는지 코드 확인
- [x] AC6: `countryCode != "KR"` 이고 `SharedPreferenceManager.isAdultContentVisible == false`인 경우 `btnCoupon` 화면에 보이지 않는다. - [x] AC6: `countryCode != "KR"` 이고 `SharedPreferenceManager.isAdultContentVisible == false`인 경우 쿠폰 버튼이 기능 버튼 목록에서 제외되어 화면에 보이지 않는다.
- QA: 비한국 사용자에서 쿠폰 버튼`View.GONE` 처리되는지 코드 확인 - QA: 비한국 사용자에서 쿠폰 버튼 아이템이 리스트에 추가되지 않는지 코드 확인
- [x] AC7: `countryCode == "KR"` 인 경우 쿠폰 버튼 노출과 기존 한국/비한국 클릭 분기는 유지된다. - [x] AC7: `countryCode == "KR"` 인 경우 쿠폰 버튼 노출과 기존 한국/비한국 클릭 분기는 유지된다.
- QA: 한국 사용자에서는 버튼이 계속 보이고, 클릭 시 기존 인증 분기가 유지되는지 코드 확인 - QA: 한국 사용자에서는 버튼이 계속 보이고, 클릭 시 기존 인증 분기가 유지되는지 코드 확인
- [x] AC8: 기능 버튼 영역이 `RecyclerView` 기반으로 렌더링되고, 숨겨진 버튼은 데이터 목록에서 제외되어 남은 버튼이 좌→우/상→하로 자연스럽게 압축 배치된다.
- QA: `fragment_my.xml``RecyclerView`를 사용하고, `MyPageFragment`가 쿠폰/본인인증 버튼을 조건에 따라 아이템 리스트에 포함/제외하는지 코드 확인
- [x] AC9: 쿠폰 버튼이 숨겨지는 비한국 사용자(`isAdultContentVisible == false`)에서도 기능 버튼 간 가로/세로 간격이 기존 4열 그리드와 동일하게 유지된다.
- QA: `GridLayoutManager(4)``GridSpacingItemDecoration(..., 16dp, false)` 적용으로 기존 16dp 간격 패턴을 유지하는지 코드 확인
## 검증 기록 ## 검증 기록
- 2026-04-02 - 2026-04-02
@@ -45,3 +50,24 @@
- 한국 사용자는 쿠폰 버튼이 계속 노출되고, 기존 인증 기반 클릭 분기도 유지된다. - 한국 사용자는 쿠폰 버튼이 계속 노출되고, 기존 인증 기반 클릭 분기도 유지된다.
- `.kt` 파일 대상 `lsp_diagnostics`는 현재 환경에 Kotlin LSP가 없어 실행 불가(`No LSP server configured for extension: .kt`)였다. - `.kt` 파일 대상 `lsp_diagnostics`는 현재 환경에 Kotlin LSP가 없어 실행 불가(`No LSP server configured for extension: .kt`)였다.
- `:app:testDebugUnitTest`, `:app:assembleDebug` 실행은 `BUILD SUCCESSFUL`로 완료됐다. - `:app:testDebugUnitTest`, `:app:assembleDebug` 실행은 `BUILD SUCCESSFUL`로 완료됐다.
- 2026-04-02
- 무엇/왜/어떻게: 기능 버튼 영역이 고정 2행 x 4열 `include` 구조여서 비한국 사용자에게서 쿠폰 버튼이 숨겨질 때 중간 빈칸이 남았다. `fragment_my.xml``RecyclerView` 기반 4열 그리드로 바꾸고, `MyPageFragment`가 기존 버튼 순서를 유지한 채 조건에 맞는 버튼만 리스트에 담아 렌더링하도록 리팩터링했다.
- 실행 명령/도구:
- `apply_patch(app/src/main/res/layout/fragment_my.xml)`
- `apply_patch(app/src/main/res/layout/item_function_button.xml)`
- `apply_patch(app/src/main/java/kr/co/vividnext/sodalive/mypage/MyPageFragment.kt)`
- `apply_patch(docs/20260402_쿠폰등록해외사용자본인인증예외.md)`
- `read(app/src/main/res/layout/fragment_my.xml)`
- `read(app/src/main/res/layout/item_function_button.xml)`
- `read(app/src/main/java/kr/co/vividnext/sodalive/mypage/MyPageFragment.kt)`
- `lsp_diagnostics(app/src/main/java/kr/co/vividnext/sodalive/mypage/MyPageFragment.kt)`
- `lsp_diagnostics(app/src/main/res/layout/fragment_my.xml)`
- `lsp_diagnostics(app/src/main/res/layout/item_function_button.xml)`
- `./gradlew :app:testDebugUnitTest :app:assembleDebug`
- 결과:
- 쿠폰 버튼과 본인인증 버튼은 더 이상 고정 슬롯의 `visibility`로 숨기지 않고, 조건에 맞을 때만 `RecyclerView` 데이터에 포함된다.
- 비한국 사용자이면서 `SharedPreferenceManager.isAdultContentVisible == false`인 경우 쿠폰 버튼이 목록에서 빠져 나머지 기능 버튼이 좌측부터 자연스럽게 압축 배치된다.
- 가로/세로 간격은 `GridLayoutManager(4)` + `GridSpacingItemDecoration(..., 16dp, false)`로 기존 4열 레이아웃의 16dp 간격 패턴을 유지한다.
- 기존 버튼 제목, 아이콘, 클릭 액션, 한국/비한국 및 인증/민감 콘텐츠 조건은 그대로 유지됐다.
- `.kt`/`.xml` 대상 `lsp_diagnostics`는 현재 환경에 Kotlin/XML LSP가 없어 실행 불가(`No LSP server configured for extension: .kt/.xml`)였다.
- `:app:testDebugUnitTest`, `:app:assembleDebug` 실행은 `BUILD SUCCESSFUL`로 완료됐다.