From dcde2b125e4c6fbd7fa1c3603d92a48bdae0ebcb Mon Sep 17 00:00:00 2001 From: klaus Date: Mon, 15 Sep 2025 18:57:26 +0900 Subject: [PATCH] =?UTF-8?q?feat(chat-original):=20=EC=9B=90=EC=9E=91=20?= =?UTF-8?q?=EC=83=81=EC=84=B8=20=ED=99=94=EB=A9=B4=20=EB=B0=8F=20=EC=BA=90?= =?UTF-8?q?=EB=A6=AD=ED=84=B0=20=EB=AC=B4=ED=95=9C=20=EC=8A=A4=ED=81=AC?= =?UTF-8?q?=EB=A1=A4=20=EB=A1=9C=EB=94=A9=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/src/main/AndroidManifest.xml | 1 + .../chat/original/OriginalTabFragment.kt | 100 ++++++++++++- .../sodalive/chat/original/OriginalWorkApi.kt | 15 ++ .../OriginalWorkCharactersPageResponse.kt | 11 ++ .../original/OriginalWorkDetailResponse.kt | 17 +++ .../chat/original/OriginalWorkRepository.kt | 22 ++- .../detail/OriginalWorkDetailActivity.kt | 117 +++++++++++++++ .../detail/OriginalWorkDetailAdapter.kt | 137 ++++++++++++++++++ .../detail/OriginalWorkDetailViewModel.kt | 96 ++++++++++++ .../common/GridSpacingItemDecoration.kt | 22 ++- .../java/kr/co/vividnext/sodalive/di/AppDI.kt | 1 + .../bg_round_corner_4_263238_3bb9f1.xml | 9 ++ .../bg_round_corner_4_263238_ff5c49.xml | 9 ++ .../bg_round_corner_4_263238_ffffff.xml | 9 ++ .../layout/activity_original_work_detail.xml | 60 ++++++++ .../layout/item_original_detail_character.xml | 50 +++++++ .../layout/item_original_detail_header.xml | 113 +++++++++++++++ .../main/res/layout/item_original_work.xml | 28 ++-- 18 files changed, 796 insertions(+), 21 deletions(-) create mode 100644 app/src/main/java/kr/co/vividnext/sodalive/chat/original/OriginalWorkCharactersPageResponse.kt create mode 100644 app/src/main/java/kr/co/vividnext/sodalive/chat/original/OriginalWorkDetailResponse.kt create mode 100644 app/src/main/java/kr/co/vividnext/sodalive/chat/original/detail/OriginalWorkDetailActivity.kt create mode 100644 app/src/main/java/kr/co/vividnext/sodalive/chat/original/detail/OriginalWorkDetailAdapter.kt create mode 100644 app/src/main/java/kr/co/vividnext/sodalive/chat/original/detail/OriginalWorkDetailViewModel.kt create mode 100644 app/src/main/res/drawable/bg_round_corner_4_263238_3bb9f1.xml create mode 100644 app/src/main/res/drawable/bg_round_corner_4_263238_ff5c49.xml create mode 100644 app/src/main/res/drawable/bg_round_corner_4_263238_ffffff.xml create mode 100644 app/src/main/res/layout/activity_original_work_detail.xml create mode 100644 app/src/main/res/layout/item_original_detail_character.xml create mode 100644 app/src/main/res/layout/item_original_detail_header.xml diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 9bd82503..ae63af07 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -192,6 +192,7 @@ + (FragmentOriginalTabBinding::inflate) { private val viewModel: OriginalWorkViewModel by inject() + private val myPageViewModel: MyPageViewModel by inject() private lateinit var adapter: OriginalWorkListAdapter + private lateinit var loadingDialog: LoadingDialog + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) + + loadingDialog = LoadingDialog(requireActivity(), layoutInflater) + setupRecycler() bind() viewModel.loadMore() @@ -28,7 +51,18 @@ class OriginalTabFragment : private fun setupRecycler() { val spanCount = 3 val spacingPx = 16f.dpToPx().toInt() - adapter = OriginalWorkListAdapter { /* TODO: 상세 페이지 이동 정의 시 연결 */ } + adapter = OriginalWorkListAdapter { id -> + ensureLoginAndAuth { + startActivity( + Intent( + requireContext(), + OriginalWorkDetailActivity::class.java + ).apply { + this.putExtra(OriginalWorkDetailActivity.EXTRA_ORIGINAL_ID, id) + } + ) + } + } binding.rvOriginal.layoutManager = GridLayoutManager(requireContext(), spanCount) binding.rvOriginal.addItemDecoration( GridSpacingItemDecoration( @@ -56,6 +90,68 @@ class OriginalTabFragment : // 누적 리스트를 어댑터에 추가 adapter.addItems(list.drop(adapter.itemCount)) } - // 필요 시 로딩/토스트 처리 추가 + + viewModel.isLoading.observe(viewLifecycleOwner) { + if (it) { + loadingDialog.show(screenWidth) + } else { + loadingDialog.dismiss() + } + } + + viewModel.toast.observe(viewLifecycleOwner) { + it?.let { Toast.makeText(requireActivity(), it, Toast.LENGTH_LONG).show() } + } + } + + private fun ensureLoginAndAuth(onAuthed: () -> Unit) { + if (SharedPreferenceManager.token.isBlank()) { + (requireActivity() as MainActivity).showLoginActivity() + return + } + + if (!SharedPreferenceManager.isAuth) { + SodaDialog( + activity = requireActivity(), + layoutInflater = layoutInflater, + title = "본인인증", + desc = "보이스온의 오픈월드 캐릭터톡은\n청소년 보호를 위해 본인인증한\n성인만 이용이 가능합니다.\n" + + "캐릭터톡 서비스를 이용하시려면\n본인인증을 하고 이용해주세요.", + confirmButtonTitle = "본인인증 하러가기", + confirmButtonClick = { startAuthFlow() }, + cancelButtonTitle = "취소", + cancelButtonClick = {}, + descGravity = Gravity.CENTER + ).show(screenWidth) + return + } + + onAuthed() + } + + private fun startAuthFlow() { + Auth.auth(requireActivity(), requireContext()) { json -> + val bootpayResponse = Gson().fromJson( + json, + BootpayResponse::class.java + ) + val request = AuthVerifyRequest(receiptId = bootpayResponse.data.receiptId) + requireActivity().runOnUiThread { + myPageViewModel.authVerify(request) { + startActivity( + Intent( + requireContext(), + SplashActivity::class.java + ).apply { + addFlags( + Intent.FLAG_ACTIVITY_CLEAR_TASK or + Intent.FLAG_ACTIVITY_NEW_TASK + ) + } + ) + requireActivity().finish() + } + } + } } } diff --git a/app/src/main/java/kr/co/vividnext/sodalive/chat/original/OriginalWorkApi.kt b/app/src/main/java/kr/co/vividnext/sodalive/chat/original/OriginalWorkApi.kt index 3b2ba0b0..d17277ff 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/chat/original/OriginalWorkApi.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/chat/original/OriginalWorkApi.kt @@ -4,6 +4,7 @@ import io.reactivex.rxjava3.core.Single import kr.co.vividnext.sodalive.common.ApiResponse import retrofit2.http.GET import retrofit2.http.Header +import retrofit2.http.Path import retrofit2.http.Query interface OriginalWorkApi { @@ -13,4 +14,18 @@ interface OriginalWorkApi { @Query("page") page: Int, @Query("size") size: Int ): Single> + + @GET("/api/chat/original/{id}") + fun getOriginalWorkDetail( + @Header("Authorization") authHeader: String, + @Path("id") id: Long + ): Single> + + @GET("/api/chat/original/{id}/characters") + fun getOriginalWorkCharacters( + @Header("Authorization") authHeader: String, + @Path("id") id: Long, + @Query("page") page: Int, + @Query("size") size: Int + ): Single> } diff --git a/app/src/main/java/kr/co/vividnext/sodalive/chat/original/OriginalWorkCharactersPageResponse.kt b/app/src/main/java/kr/co/vividnext/sodalive/chat/original/OriginalWorkCharactersPageResponse.kt new file mode 100644 index 00000000..889a9644 --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/chat/original/OriginalWorkCharactersPageResponse.kt @@ -0,0 +1,11 @@ +package kr.co.vividnext.sodalive.chat.original + +import androidx.annotation.Keep +import com.google.gson.annotations.SerializedName +import kr.co.vividnext.sodalive.chat.character.Character + +@Keep +data class OriginalWorkCharactersPageResponse( + @SerializedName("totalCount") val totalCount: Long, + @SerializedName("content") val content: List +) diff --git a/app/src/main/java/kr/co/vividnext/sodalive/chat/original/OriginalWorkDetailResponse.kt b/app/src/main/java/kr/co/vividnext/sodalive/chat/original/OriginalWorkDetailResponse.kt new file mode 100644 index 00000000..bc033e22 --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/chat/original/OriginalWorkDetailResponse.kt @@ -0,0 +1,17 @@ +package kr.co.vividnext.sodalive.chat.original + +import androidx.annotation.Keep +import com.google.gson.annotations.SerializedName +import kr.co.vividnext.sodalive.chat.character.Character + +@Keep +data class OriginalWorkDetailResponse( + @SerializedName("imageUrl") val imageUrl: String?, + @SerializedName("title") val title: String, + @SerializedName("contentType") val contentType: String, + @SerializedName("category") val category: String, + @SerializedName("isAdult") val isAdult: Boolean, + @SerializedName("description") val description: String, + @SerializedName("originalLink") val originalLink: String?, + @SerializedName("characters") val characters: List +) diff --git a/app/src/main/java/kr/co/vividnext/sodalive/chat/original/OriginalWorkRepository.kt b/app/src/main/java/kr/co/vividnext/sodalive/chat/original/OriginalWorkRepository.kt index fbdede9a..e085e48c 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/chat/original/OriginalWorkRepository.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/chat/original/OriginalWorkRepository.kt @@ -6,7 +6,27 @@ import kr.co.vividnext.sodalive.common.ApiResponse class OriginalWorkRepository( private val api: OriginalWorkApi ) { - fun getOriginalWorks(token: String, page: Int, size: Int): Single> { + fun getOriginalWorks( + token: String, + page: Int, + size: Int + ): Single> { return api.getOriginalWorkList(token, page, size) } + + fun getOriginalDetail( + token: String, + id: Long + ): Single> { + return api.getOriginalWorkDetail(token, id) + } + + fun getOriginalCharacters( + token: String, + id: Long, + page: Int, + size: Int + ): Single> { + return api.getOriginalWorkCharacters(token, id, page, size) + } } diff --git a/app/src/main/java/kr/co/vividnext/sodalive/chat/original/detail/OriginalWorkDetailActivity.kt b/app/src/main/java/kr/co/vividnext/sodalive/chat/original/detail/OriginalWorkDetailActivity.kt new file mode 100644 index 00000000..7c903f85 --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/chat/original/detail/OriginalWorkDetailActivity.kt @@ -0,0 +1,117 @@ +package kr.co.vividnext.sodalive.chat.original.detail + +import android.content.Intent +import android.os.Bundle +import androidx.core.net.toUri +import androidx.recyclerview.widget.GridLayoutManager +import androidx.recyclerview.widget.RecyclerView +import coil.load +import coil.size.Scale +import coil.transform.BlurTransformation +import kr.co.vividnext.sodalive.R +import kr.co.vividnext.sodalive.base.BaseActivity +import kr.co.vividnext.sodalive.chat.character.detail.CharacterDetailActivity +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.ActivityOriginalWorkDetailBinding +import kr.co.vividnext.sodalive.extensions.dpToPx +import org.koin.android.ext.android.inject + +class OriginalWorkDetailActivity : BaseActivity( + ActivityOriginalWorkDetailBinding::inflate +) { + + companion object { + const val EXTRA_ORIGINAL_ID = "extra_original_id" + } + + private val viewModel: OriginalWorkDetailViewModel by inject() + + private lateinit var adapter: OriginalWorkDetailAdapter + + private var originalId: Long = -1 + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + originalId = intent.getLongExtra(EXTRA_ORIGINAL_ID, -1) + + setupRecycler() + bind() + + if (originalId > 0) viewModel.loadDetail(originalId) + } + + override fun setupView() { + // 배경 이미지 높이를 화면 너비 비율에 맞게 설정(306:432) + binding.ivBg.post { + val width = binding.ivBg.width.takeIf { it > 0 } ?: resources.displayMetrics.widthPixels + val height = width * 432 / 306 + val lp = binding.ivBg.layoutParams + lp.height = height + binding.ivBg.layoutParams = lp + } + + // Toolbar back + binding.ivBack.setOnClickListener { finish() } + } + + private fun setupRecycler() { + adapter = OriginalWorkDetailAdapter( + onClickOpenLink = { url -> + startActivity(Intent(Intent.ACTION_VIEW, url.toUri())) + }, + onClickCharacter = { characterId -> + startActivity( + Intent(this, CharacterDetailActivity::class.java).apply { + putExtra(EXTRA_CHARACTER_ID, characterId) + } + ) + } + ) + val spanCount = 2 + val spacingPx = 16f.dpToPx().toInt() + val layoutManager = GridLayoutManager(this, spanCount) + layoutManager.spanSizeLookup = object : GridLayoutManager.SpanSizeLookup() { + override fun getSpanSize(position: Int): Int { + return if (adapter.getItemViewType(position) == 0) spanCount else 1 + } + } + binding.rvDetail.layoutManager = layoutManager + binding.rvDetail.addItemDecoration(GridSpacingItemDecoration(spanCount, spacingPx, true, headerCount = 1)) + binding.rvDetail.adapter = adapter + + binding.rvDetail.addOnScrollListener(object : RecyclerView.OnScrollListener() { + override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { + super.onScrolled(recyclerView, dx, dy) + if (!recyclerView.canScrollVertically(1)) { + if (originalId > 0) viewModel.loadMoreCharacters(originalId) + } + } + }) + } + + private fun bind() { + viewModel.detail.observe(this) { data -> + adapter.setHeader(data) + // 배경 이미지 Blur 처리 및 채우기 + val imageUrl = data?.imageUrl + if (!imageUrl.isNullOrBlank()) { + binding.ivBg.load(imageUrl) { + transformations( + BlurTransformation( + this@OriginalWorkDetailActivity, + 25f, + 2.5f + ) + ) + scale(Scale.FILL) + } + } else { + binding.ivBg.setImageResource(R.drawable.bg_placeholder) + } + } + viewModel.characters.observe(this) { list -> + adapter.setItems(list) + } + } +} diff --git a/app/src/main/java/kr/co/vividnext/sodalive/chat/original/detail/OriginalWorkDetailAdapter.kt b/app/src/main/java/kr/co/vividnext/sodalive/chat/original/detail/OriginalWorkDetailAdapter.kt new file mode 100644 index 00000000..d3470861 --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/chat/original/detail/OriginalWorkDetailAdapter.kt @@ -0,0 +1,137 @@ +package kr.co.vividnext.sodalive.chat.original.detail + +import android.annotation.SuppressLint +import android.text.TextUtils +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import coil.load +import coil.transform.RoundedCornersTransformation +import kr.co.vividnext.sodalive.R +import kr.co.vividnext.sodalive.chat.character.Character +import kr.co.vividnext.sodalive.chat.original.OriginalWorkDetailResponse +import kr.co.vividnext.sodalive.databinding.ItemOriginalDetailCharacterBinding +import kr.co.vividnext.sodalive.databinding.ItemOriginalDetailHeaderBinding +import kr.co.vividnext.sodalive.extensions.dpToPx + +class OriginalWorkDetailAdapter( + private val onClickOpenLink: (String) -> Unit, + private val onClickCharacter: (Long) -> Unit +) : RecyclerView.Adapter() { + + // 작품소개 확장 상태 (헤더 1개이므로 어댑터 레벨에서 유지) + private var isDescriptionExpanded: Boolean = false + + companion object { + private const val TYPE_HEADER = 0 + private const val TYPE_ITEM = 1 + } + + private var header: OriginalWorkDetailResponse? = null + private val items = mutableListOf() + + fun setHeader(data: OriginalWorkDetailResponse?) { + header = data + notifyItemChanged(0) + } + + @SuppressLint("NotifyDataSetChanged") + fun setItems(chars: List) { + items.clear() + items.addAll(chars) + notifyDataSetChanged() + } + + override fun getItemViewType(position: Int): Int = if (position == 0) TYPE_HEADER else TYPE_ITEM + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + return if (viewType == TYPE_HEADER) { + val binding = ItemOriginalDetailHeaderBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) + HeaderVH(binding) + } else { + val binding = ItemOriginalDetailCharacterBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) + ItemVH(binding) + } + } + + override fun getItemCount(): Int = 1 + items.size + + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + if (holder is HeaderVH) { + holder.bind(header) + } else if (holder is ItemVH) { + holder.bind(items[position - 1]) + } + } + + inner class HeaderVH(private val binding: ItemOriginalDetailHeaderBinding) : + RecyclerView.ViewHolder(binding.root) { + private fun applyDescriptionState() { + if (isDescriptionExpanded) { + binding.tvDescription.maxLines = Int.MAX_VALUE + binding.tvDescription.ellipsize = null + } else { + binding.tvDescription.maxLines = 2 + binding.tvDescription.ellipsize = TextUtils.TruncateAt.END + } + } + + fun bind(data: OriginalWorkDetailResponse?) { + if (data == null) return + + // Cover small card + binding.ivCover.load(data.imageUrl) { + crossfade(true) + placeholder(R.drawable.bg_placeholder) + transformations(RoundedCornersTransformation(16f.dpToPx())) + } + + binding.tvTitle.text = data.title + binding.tvContentType.text = data.contentType + binding.tvCategory.text = data.category + binding.tvDescription.text = data.description + + binding.tvAdult.visibility = if (data.isAdult) { + View.VISIBLE + } else { + View.GONE + } + + // 설명 토글 (2줄/전체) + applyDescriptionState() + binding.tvDescription.setOnClickListener { + isDescriptionExpanded = !isDescriptionExpanded + applyDescriptionState() + } + + binding.tvOpenOriginal.isEnabled = !data.originalLink.isNullOrBlank() + binding.tvOpenOriginal.alpha = if (data.originalLink.isNullOrBlank()) 0.5f else 1f + binding.tvOpenOriginal.setOnClickListener { + data.originalLink?.let { onClickOpenLink(it) } + } + } + } + + inner class ItemVH(private val binding: ItemOriginalDetailCharacterBinding) : + RecyclerView.ViewHolder(binding.root) { + fun bind(item: Character) { + binding.tvCharacterName.text = item.name + binding.tvCharacterDescription.text = item.description + binding.ivCharacter.load(item.imageUrl) { + crossfade(true) + placeholder(R.drawable.ic_logo_service_center) + transformations(RoundedCornersTransformation(16f.dpToPx())) + } + binding.root.setOnClickListener { onClickCharacter(item.characterId) } + } + } +} diff --git a/app/src/main/java/kr/co/vividnext/sodalive/chat/original/detail/OriginalWorkDetailViewModel.kt b/app/src/main/java/kr/co/vividnext/sodalive/chat/original/detail/OriginalWorkDetailViewModel.kt new file mode 100644 index 00000000..22cc8b5f --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/chat/original/detail/OriginalWorkDetailViewModel.kt @@ -0,0 +1,96 @@ +package kr.co.vividnext.sodalive.chat.original.detail + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +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.chat.character.Character +import kr.co.vividnext.sodalive.chat.original.OriginalWorkDetailResponse +import kr.co.vividnext.sodalive.chat.original.OriginalWorkRepository +import kr.co.vividnext.sodalive.common.SharedPreferenceManager + +class OriginalWorkDetailViewModel( + private val repository: OriginalWorkRepository +) : BaseViewModel() { + + private val _isLoading = MutableLiveData(false) + val isLoading: LiveData get() = _isLoading + + private val _toast = MutableLiveData(null) + val toast: LiveData get() = _toast + + private val _detail = MutableLiveData(null) + val detail: LiveData get() = _detail + + private val _characters = MutableLiveData>(emptyList()) + val characters: LiveData> get() = _characters + + private val size = 20 + private var page = 1 // 초기 로딩 이후부터 사용하므로 1부터 시작 + private var isLast = false + + fun loadDetail(id: Long) { + if (_isLoading.value == true) return + _isLoading.value = true + compositeDisposable.add( + repository.getOriginalDetail( + token = "Bearer ${SharedPreferenceManager.token}", + id = id + ) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe({ response -> + val data = response.data + if (response.success && data != null) { + _detail.value = data + // 상세 응답 내 캐릭터를 초기 세팅 + _characters.value = data.characters + // 초기 캐릭터가 없으면 다음 로딩에서 page=1 그대로 시도 + page = 1 + isLast = false + } else { + _toast.value = response.message ?: "알 수 없는 오류가 발생했습니다." + } + _isLoading.value = false + }, { e -> + _isLoading.value = false + _toast.value = e.message ?: "알 수 없는 오류가 발생했습니다." + }) + ) + } + + fun loadMoreCharacters(id: Long) { + if (_isLoading.value == true || isLast) return + _isLoading.value = true + compositeDisposable.add( + repository.getOriginalCharacters( + token = "Bearer ${SharedPreferenceManager.token}", + id = id, + page = page, + size = size + ) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe({ response -> + val data = response.data + if (response.success && data != null) { + val current = _characters.value ?: emptyList() + val next = current + data.content + _characters.value = next + if (data.content.isNotEmpty()) { + page += 1 + } else { + isLast = true + } + } else { + _toast.value = response.message ?: "알 수 없는 오류가 발생했습니다." + } + _isLoading.value = false + }, { e -> + _isLoading.value = false + _toast.value = e.message ?: "알 수 없는 오류가 발생했습니다." + }) + ) + } +} diff --git a/app/src/main/java/kr/co/vividnext/sodalive/common/GridSpacingItemDecoration.kt b/app/src/main/java/kr/co/vividnext/sodalive/common/GridSpacingItemDecoration.kt index 771764c8..6a1803c8 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/common/GridSpacingItemDecoration.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/common/GridSpacingItemDecoration.kt @@ -5,11 +5,14 @@ import android.view.View import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView.ItemDecoration - +/** + * Grid 간격 데코레이션. 헤더가 있는 경우 headerCount로 보정하여 첫 행 판단 및 컬럼 계산을 정확히 수행한다. + */ class GridSpacingItemDecoration( private val spanCount: Int, private val spacing: Int, - private val includeEdge: Boolean + private val includeEdge: Boolean, + private val headerCount: Int = 0 ) : ItemDecoration() { override fun getItemOffsets( outRect: Rect, @@ -17,19 +20,26 @@ class GridSpacingItemDecoration( parent: RecyclerView, state: RecyclerView.State ) { - val position = parent.getChildAdapterPosition(view) // Item position - val column = position % spanCount // Current column + val position = parent.getChildAdapterPosition(view) + // 헤더 범위는 간격을 적용하지 않음 + val adjustedPosition = position - headerCount + if (adjustedPosition < 0) { + outRect.set(0, 0, 0, 0) + return + } + + val column = adjustedPosition % spanCount if (includeEdge) { outRect.left = spacing - column * spacing / spanCount outRect.right = (column + 1) * spacing / spanCount - if (position < spanCount) { // Top edge + if (adjustedPosition < spanCount) { // Top edge (헤더 제외 첫 행) outRect.top = spacing } outRect.bottom = spacing // Item bottom } else { outRect.left = column * spacing / spanCount outRect.right = spacing - (column + 1) * spacing / spanCount - if (position >= spanCount) { + if (adjustedPosition >= spanCount) { outRect.top = spacing // Item top } } 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 9e13fe3f..9dcc79d4 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 @@ -372,6 +372,7 @@ class AppDI(private val context: Context, isDebugMode: Boolean) { viewModel { kr.co.vividnext.sodalive.chat.character.comment.CharacterCommentReplyViewModel(get()) } viewModel { NewCharactersAllViewModel(get()) } viewModel { OriginalWorkViewModel(get()) } + viewModel { kr.co.vividnext.sodalive.chat.original.detail.OriginalWorkDetailViewModel(get()) } } private val repositoryModule = module { diff --git a/app/src/main/res/drawable/bg_round_corner_4_263238_3bb9f1.xml b/app/src/main/res/drawable/bg_round_corner_4_263238_3bb9f1.xml new file mode 100644 index 00000000..111ac987 --- /dev/null +++ b/app/src/main/res/drawable/bg_round_corner_4_263238_3bb9f1.xml @@ -0,0 +1,9 @@ + + + + + + diff --git a/app/src/main/res/drawable/bg_round_corner_4_263238_ff5c49.xml b/app/src/main/res/drawable/bg_round_corner_4_263238_ff5c49.xml new file mode 100644 index 00000000..db5a29f6 --- /dev/null +++ b/app/src/main/res/drawable/bg_round_corner_4_263238_ff5c49.xml @@ -0,0 +1,9 @@ + + + + + + diff --git a/app/src/main/res/drawable/bg_round_corner_4_263238_ffffff.xml b/app/src/main/res/drawable/bg_round_corner_4_263238_ffffff.xml new file mode 100644 index 00000000..88c39131 --- /dev/null +++ b/app/src/main/res/drawable/bg_round_corner_4_263238_ffffff.xml @@ -0,0 +1,9 @@ + + + + + + diff --git a/app/src/main/res/layout/activity_original_work_detail.xml b/app/src/main/res/layout/activity_original_work_detail.xml new file mode 100644 index 00000000..8802a5fe --- /dev/null +++ b/app/src/main/res/layout/activity_original_work_detail.xml @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/item_original_detail_character.xml b/app/src/main/res/layout/item_original_detail_character.xml new file mode 100644 index 00000000..b4d55c97 --- /dev/null +++ b/app/src/main/res/layout/item_original_detail_character.xml @@ -0,0 +1,50 @@ + + + + + + + + + + diff --git a/app/src/main/res/layout/item_original_detail_header.xml b/app/src/main/res/layout/item_original_detail_header.xml new file mode 100644 index 00000000..0fa6e840 --- /dev/null +++ b/app/src/main/res/layout/item_original_detail_header.xml @@ -0,0 +1,113 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/item_original_work.xml b/app/src/main/res/layout/item_original_work.xml index eb4fa623..83f5cc12 100644 --- a/app/src/main/res/layout/item_original_work.xml +++ b/app/src/main/res/layout/item_original_work.xml @@ -9,40 +9,44 @@ android:id="@+id/iv_cover" android:layout_width="0dp" android:layout_height="0dp" - android:scaleType="centerCrop" android:contentDescription="@null" - app:layout_constraintTop_toTopOf="parent" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintEnd_toEndOf="parent" + android:scaleType="centerCrop" app:layout_constraintDimensionRatio="306:432" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="parent" tools:src="@drawable/ic_logo_service_center" />