feat(chat-original): 원작 상세 화면 및 캐릭터 무한 스크롤 로딩 구현

This commit is contained in:
2025-09-15 18:57:26 +09:00
parent f15c6be1a4
commit dcde2b125e
18 changed files with 796 additions and 21 deletions

View File

@@ -1,25 +1,48 @@
package kr.co.vividnext.sodalive.chat.original
import android.content.Intent
import android.os.Bundle
import android.view.Gravity
import android.view.View
import android.widget.Toast
import androidx.annotation.OptIn
import androidx.media3.common.util.UnstableApi
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.google.gson.Gson
import kr.co.vividnext.sodalive.base.BaseFragment
import kr.co.vividnext.sodalive.base.SodaDialog
import kr.co.vividnext.sodalive.chat.original.detail.OriginalWorkDetailActivity
import kr.co.vividnext.sodalive.common.GridSpacingItemDecoration
import kr.co.vividnext.sodalive.common.LoadingDialog
import kr.co.vividnext.sodalive.common.SharedPreferenceManager
import kr.co.vividnext.sodalive.databinding.FragmentOriginalTabBinding
import kr.co.vividnext.sodalive.extensions.dpToPx
import kr.co.vividnext.sodalive.main.MainActivity
import kr.co.vividnext.sodalive.mypage.MyPageViewModel
import kr.co.vividnext.sodalive.mypage.auth.Auth
import kr.co.vividnext.sodalive.mypage.auth.AuthVerifyRequest
import kr.co.vividnext.sodalive.mypage.auth.BootpayResponse
import kr.co.vividnext.sodalive.splash.SplashActivity
import org.koin.android.ext.android.inject
@OptIn(UnstableApi::class)
class OriginalTabFragment :
BaseFragment<FragmentOriginalTabBinding>(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()
}
}
}
}
}

View File

@@ -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<ApiResponse<OriginalWorkListResponse>>
@GET("/api/chat/original/{id}")
fun getOriginalWorkDetail(
@Header("Authorization") authHeader: String,
@Path("id") id: Long
): Single<ApiResponse<OriginalWorkDetailResponse>>
@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<ApiResponse<OriginalWorkCharactersPageResponse>>
}

View File

@@ -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<Character>
)

View File

@@ -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<Character>
)

View File

@@ -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<ApiResponse<OriginalWorkListResponse>> {
fun getOriginalWorks(
token: String,
page: Int,
size: Int
): Single<ApiResponse<OriginalWorkListResponse>> {
return api.getOriginalWorkList(token, page, size)
}
fun getOriginalDetail(
token: String,
id: Long
): Single<ApiResponse<OriginalWorkDetailResponse>> {
return api.getOriginalWorkDetail(token, id)
}
fun getOriginalCharacters(
token: String,
id: Long,
page: Int,
size: Int
): Single<ApiResponse<OriginalWorkCharactersPageResponse>> {
return api.getOriginalWorkCharacters(token, id, page, size)
}
}

View File

@@ -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>(
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)
}
}
}

View File

@@ -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<RecyclerView.ViewHolder>() {
// 작품소개 확장 상태 (헤더 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<Character>()
fun setHeader(data: OriginalWorkDetailResponse?) {
header = data
notifyItemChanged(0)
}
@SuppressLint("NotifyDataSetChanged")
fun setItems(chars: List<Character>) {
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) }
}
}
}

View File

@@ -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<Boolean> get() = _isLoading
private val _toast = MutableLiveData<String?>(null)
val toast: LiveData<String?> get() = _toast
private val _detail = MutableLiveData<OriginalWorkDetailResponse?>(null)
val detail: LiveData<OriginalWorkDetailResponse?> get() = _detail
private val _characters = MutableLiveData<List<Character>>(emptyList())
val characters: LiveData<List<Character>> 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 ?: "알 수 없는 오류가 발생했습니다."
})
)
}
}

View File

@@ -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
}
}

View File

@@ -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 {