feat(character-comment): 캐릭터 댓글 리스트 BottomSheet UI 및 페이징 스텁 구현
- CharacterDetail 댓글 섹션 터치 시 BottomSheet 표시 - 헤더/입력폼/Divider/리스트/더보기 BottomSheet 구성 - RecyclerView 하단 도달 시 더미 데이터 추가 로드(Stub) - 상대시간 표기(분/시간/일/년 전) - API 연동은 이후 작업 예정 (스텁)
This commit is contained in:
@@ -16,15 +16,15 @@ data class CreateCharacterCommentRequest(
|
||||
// - 댓글 쓴 Member 닉네임
|
||||
// - 댓글 쓴 시간 timestamp(long)
|
||||
// - 답글 수
|
||||
|
||||
@Keep
|
||||
data class CharacterCommentResponse(
|
||||
@SerializedName("commentId") val commentId: Long,
|
||||
@SerializedName("memberId") val memberId: Long,
|
||||
@SerializedName("memberProfileImage") val memberProfileImage: String,
|
||||
@SerializedName("memberNickname") val memberNickname: String,
|
||||
@SerializedName("createdAt") val createdAt: Long,
|
||||
@SerializedName("replyCount") val replyCount: Int,
|
||||
@SerializedName("comment") val comment: String?
|
||||
@SerializedName("comment") val comment: String
|
||||
)
|
||||
|
||||
// 답글 Response 단건(목록 원소)
|
||||
@@ -32,21 +32,38 @@ data class CharacterCommentResponse(
|
||||
// - 답글 쓴 Member 프로필 이미지
|
||||
// - 답글 쓴 Member 닉네임
|
||||
// - 답글 쓴 시간 timestamp(long)
|
||||
|
||||
@Keep
|
||||
data class CharacterReplyResponse(
|
||||
@SerializedName("replyId") val replyId: Long,
|
||||
@SerializedName("memberId") val memberId: Long,
|
||||
@SerializedName("memberProfileImage") val memberProfileImage: String,
|
||||
@SerializedName("memberNickname") val memberNickname: String,
|
||||
@SerializedName("createdAt") val createdAt: Long
|
||||
@SerializedName("createdAt") val createdAt: Long,
|
||||
@SerializedName("comment") val comment: String
|
||||
)
|
||||
|
||||
// 댓글의 답글 조회 Response 컨테이너
|
||||
// - 원본 댓글 Response
|
||||
// - 답글 목록(위 사양의 필드 포함)
|
||||
|
||||
@Keep
|
||||
data class CharacterCommentRepliesResponse(
|
||||
@SerializedName("original") val original: CharacterCommentResponse,
|
||||
@SerializedName("replies") val replies: List<CharacterReplyResponse>
|
||||
@SerializedName("replies") val replies: List<CharacterReplyResponse>,
|
||||
@SerializedName("cursor") val cursor: Long?
|
||||
)
|
||||
|
||||
// 댓글 리스트 조회 Response 컨테이너
|
||||
// - 전체 댓글 개수(totalCount)
|
||||
// - 댓글 목록(comments)
|
||||
@Keep
|
||||
data class CharacterCommentListResponse(
|
||||
@SerializedName("totalCount") val totalCount: Int,
|
||||
@SerializedName("comments") val comments: List<CharacterCommentResponse>,
|
||||
@SerializedName("cursor") val cursor: Long?
|
||||
)
|
||||
|
||||
// 신고 Request
|
||||
@Keep
|
||||
data class ReportCharacterCommentRequest(
|
||||
@SerializedName("content") val content: String
|
||||
)
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
package kr.co.vividnext.sodalive.chat.character.comment
|
||||
|
||||
import android.app.Dialog
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.FrameLayout
|
||||
import androidx.fragment.app.Fragment
|
||||
import com.google.android.material.bottomsheet.BottomSheetBehavior
|
||||
import com.google.android.material.bottomsheet.BottomSheetDialog
|
||||
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
|
||||
import kr.co.vividnext.sodalive.R
|
||||
import kr.co.vividnext.sodalive.databinding.DialogCharacterCommentBinding
|
||||
|
||||
/**
|
||||
* 캐릭터 댓글 리스트 BottomSheet 컨테이너
|
||||
* 내부에 CharacterCommentListFragment를 호스팅합니다.
|
||||
*/
|
||||
class CharacterCommentListBottomSheet(
|
||||
private val characterId: Long
|
||||
) : BottomSheetDialogFragment() {
|
||||
|
||||
private lateinit var binding: DialogCharacterCommentBinding
|
||||
|
||||
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||
val dialog = super.onCreateDialog(savedInstanceState)
|
||||
dialog.setOnShowListener {
|
||||
val d = it as BottomSheetDialog
|
||||
val bottomSheet = d.findViewById<FrameLayout>(com.google.android.material.R.id.design_bottom_sheet)
|
||||
bottomSheet?.let { bs ->
|
||||
BottomSheetBehavior.from(bs).state = BottomSheetBehavior.STATE_EXPANDED
|
||||
}
|
||||
}
|
||||
return dialog
|
||||
}
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
binding = DialogCharacterCommentBinding.inflate(inflater, container, false)
|
||||
return binding.root
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
val tag = "CHARACTER_COMMENT_LIST"
|
||||
val fragment: Fragment = CharacterCommentListFragment.newInstance(characterId)
|
||||
childFragmentManager.beginTransaction()
|
||||
.replace(R.id.fl_container, fragment, tag)
|
||||
.commit()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
package kr.co.vividnext.sodalive.chat.character.comment
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Service
|
||||
import android.graphics.Rect
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.inputmethod.InputMethodManager
|
||||
import android.widget.Toast
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import coil.load
|
||||
import coil.transform.CircleCropTransformation
|
||||
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
|
||||
import kr.co.vividnext.sodalive.R
|
||||
import kr.co.vividnext.sodalive.base.BaseFragment
|
||||
import kr.co.vividnext.sodalive.common.LoadingDialog
|
||||
import kr.co.vividnext.sodalive.common.SharedPreferenceManager
|
||||
import kr.co.vividnext.sodalive.databinding.FragmentCharacterCommentListBinding
|
||||
import kr.co.vividnext.sodalive.extensions.dpToPx
|
||||
|
||||
class CharacterCommentListFragment : BaseFragment<FragmentCharacterCommentListBinding>(
|
||||
FragmentCharacterCommentListBinding::inflate
|
||||
) {
|
||||
|
||||
private lateinit var imm: InputMethodManager
|
||||
private lateinit var loadingDialog: LoadingDialog
|
||||
private lateinit var adapter: CharacterCommentsAdapter
|
||||
|
||||
private var characterId: Long = 0
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View? {
|
||||
characterId = arguments?.getLong(EXTRA_CHARACTER_ID) ?: 0
|
||||
return super.onCreateView(inflater, container, savedInstanceState)
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
loadingDialog = LoadingDialog(requireActivity(), layoutInflater)
|
||||
imm = requireContext()
|
||||
.getSystemService(Service.INPUT_METHOD_SERVICE) as InputMethodManager
|
||||
|
||||
setupView()
|
||||
bindData()
|
||||
// 초기 로드 (스텁)
|
||||
loadMore()
|
||||
}
|
||||
|
||||
private fun hideDialog() {
|
||||
(parentFragment as? BottomSheetDialogFragment)?.dismiss()
|
||||
}
|
||||
|
||||
private fun setupView() {
|
||||
binding.ivClose.setOnClickListener { hideDialog() }
|
||||
|
||||
binding.ivCommentProfile.load(SharedPreferenceManager.profileImage) {
|
||||
crossfade(true)
|
||||
placeholder(R.drawable.ic_placeholder_profile)
|
||||
error(R.drawable.ic_placeholder_profile)
|
||||
transformations(CircleCropTransformation())
|
||||
}
|
||||
|
||||
binding.ivCommentSend.setOnClickListener {
|
||||
hideKeyboard()
|
||||
val comment = binding.etComment.text.toString()
|
||||
if (comment.isBlank()) return@setOnClickListener
|
||||
// 스텁: 로컬에 즉시 추가 (CharacterCommentDto 기반)
|
||||
val me = CharacterCommentResponse(
|
||||
commentId = System.currentTimeMillis(),
|
||||
memberId = SharedPreferenceManager.userId,
|
||||
memberProfileImage = SharedPreferenceManager.profileImage,
|
||||
memberNickname = SharedPreferenceManager.nickname,
|
||||
createdAt = System.currentTimeMillis(),
|
||||
replyCount = 0,
|
||||
comment = comment
|
||||
)
|
||||
adapter.items.add(me)
|
||||
adapter.notifyItemInserted(adapter.items.size - 1)
|
||||
binding.rvComment.scrollToPosition(adapter.items.size - 1)
|
||||
binding.etComment.setText("")
|
||||
Toast.makeText(requireContext(), "등록되었습니다 (stub)", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
adapter = CharacterCommentsAdapter(
|
||||
currentUserId = SharedPreferenceManager.userId,
|
||||
onClickMore = { item, isOwner, anchor ->
|
||||
CharacterCommentMoreBottomSheet.newInstance(isOwner).apply {
|
||||
onReport = {
|
||||
Toast.makeText(requireContext(), "신고되었습니다 (stub)", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
onDelete = {
|
||||
val index = adapter.items.indexOfFirst { it.commentId == item.commentId }
|
||||
if (index >= 0) {
|
||||
adapter.items.removeAt(index)
|
||||
adapter.notifyItemRemoved(index)
|
||||
}
|
||||
}
|
||||
}.show(childFragmentManager, "comment_more")
|
||||
},
|
||||
onClickItem = { /* 답글 보기로 이동 예정 (stub) */ }
|
||||
)
|
||||
|
||||
val recyclerView = binding.rvComment
|
||||
recyclerView.setHasFixedSize(true)
|
||||
recyclerView.layoutManager = LinearLayoutManager(
|
||||
activity,
|
||||
LinearLayoutManager.VERTICAL,
|
||||
false
|
||||
)
|
||||
recyclerView.addItemDecoration(object : RecyclerView.ItemDecoration() {
|
||||
override fun getItemOffsets(
|
||||
outRect: Rect,
|
||||
view: View,
|
||||
parent: RecyclerView,
|
||||
state: RecyclerView.State
|
||||
) {
|
||||
super.getItemOffsets(outRect, view, parent, state)
|
||||
|
||||
outRect.left = 13.3f.dpToPx().toInt()
|
||||
outRect.right = 13.3f.dpToPx().toInt()
|
||||
|
||||
when (parent.getChildAdapterPosition(view)) {
|
||||
0 -> {
|
||||
outRect.top = 13.3f.dpToPx().toInt()
|
||||
outRect.bottom = 6.7f.dpToPx().toInt()
|
||||
}
|
||||
|
||||
adapter.itemCount - 1 -> {
|
||||
outRect.top = 6.7f.dpToPx().toInt()
|
||||
outRect.bottom = 13.3f.dpToPx().toInt()
|
||||
}
|
||||
|
||||
else -> {
|
||||
outRect.top = 6.7f.dpToPx().toInt()
|
||||
outRect.bottom = 6.7f.dpToPx().toInt()
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() {
|
||||
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
|
||||
super.onScrolled(recyclerView, dx, dy)
|
||||
val lastVisible =
|
||||
(recyclerView.layoutManager as LinearLayoutManager).findLastCompletelyVisibleItemPosition()
|
||||
val total = recyclerView.adapter?.itemCount ?: 0
|
||||
if (!recyclerView.canScrollVertically(1) && lastVisible == total - 1) {
|
||||
loadMore()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
recyclerView.adapter = adapter
|
||||
}
|
||||
|
||||
@SuppressLint("NotifyDataSetChanged")
|
||||
private fun bindData() {
|
||||
// total count 스텁: 어댑터 크기 사용
|
||||
// 필요 시 ViewModel 도입 가능
|
||||
}
|
||||
|
||||
private fun hideKeyboard() {
|
||||
imm.hideSoftInputFromWindow(view?.windowToken, 0)
|
||||
}
|
||||
|
||||
private var page = 1
|
||||
private fun loadMore() {
|
||||
// API 스텁: 더미 데이터 생성
|
||||
val newItems = (1..10).map { idx ->
|
||||
val id = page * 1000L + idx
|
||||
val writerId = if (idx % 5 == 0) SharedPreferenceManager.userId else -idx.toLong()
|
||||
CharacterCommentResponse(
|
||||
commentId = id,
|
||||
memberId = writerId,
|
||||
memberProfileImage = "",
|
||||
memberNickname = "게스트$id",
|
||||
createdAt = System.currentTimeMillis() - (idx * 60_000L * page),
|
||||
replyCount = (idx % 3),
|
||||
comment = "캐릭터 댓글 예시 텍스트 $id"
|
||||
)
|
||||
}
|
||||
if (page == 1) adapter.items.clear()
|
||||
val start = adapter.items.size
|
||||
adapter.items.addAll(newItems)
|
||||
adapter.notifyItemRangeInserted(start, newItems.size)
|
||||
binding.tvCommentCount.text = "${adapter.items.size}"
|
||||
page++
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val EXTRA_CHARACTER_ID = "extra_character_id"
|
||||
fun newInstance(characterId: Long): CharacterCommentListFragment {
|
||||
val args = Bundle().apply { putLong(EXTRA_CHARACTER_ID, characterId) }
|
||||
val f = CharacterCommentListFragment()
|
||||
f.arguments = args
|
||||
return f
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package kr.co.vividnext.sodalive.chat.character.comment
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.TextView
|
||||
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
|
||||
import kr.co.vividnext.sodalive.R
|
||||
|
||||
class CharacterCommentMoreBottomSheet : BottomSheetDialogFragment() {
|
||||
|
||||
var onReport: (() -> Unit)? = null
|
||||
var onDelete: (() -> Unit)? = null
|
||||
|
||||
private var isOwner: Boolean = false
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
isOwner = arguments?.getBoolean(ARG_IS_OWNER) ?: false
|
||||
}
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
val view = inflater.inflate(R.layout.dialog_character_comment_more, container, false)
|
||||
val tvReport = view.findViewById<TextView>(R.id.tv_report)
|
||||
val tvDelete = view.findViewById<TextView>(R.id.tv_delete)
|
||||
tvReport.setOnClickListener {
|
||||
dismiss()
|
||||
onReport?.invoke()
|
||||
}
|
||||
if (isOwner) {
|
||||
tvDelete.visibility = View.VISIBLE
|
||||
tvDelete.setOnClickListener {
|
||||
dismiss()
|
||||
onDelete?.invoke()
|
||||
}
|
||||
} else {
|
||||
tvDelete.visibility = View.GONE
|
||||
}
|
||||
return view
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val ARG_IS_OWNER = "arg_is_owner"
|
||||
fun newInstance(isOwner: Boolean): CharacterCommentMoreBottomSheet {
|
||||
val f = CharacterCommentMoreBottomSheet()
|
||||
f.arguments = Bundle().apply { putBoolean(ARG_IS_OWNER, isOwner) }
|
||||
return f
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package kr.co.vividnext.sodalive.chat.character.comment
|
||||
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import coil.load
|
||||
import coil.transform.CircleCropTransformation
|
||||
import kr.co.vividnext.sodalive.R
|
||||
import kr.co.vividnext.sodalive.databinding.ItemCharacterCommentBinding
|
||||
|
||||
class CharacterCommentsAdapter(
|
||||
private val currentUserId: Long,
|
||||
private val onClickMore: (item: CharacterCommentResponse, isOwner: Boolean, anchor: View) -> Unit,
|
||||
private val onClickItem: (CharacterCommentResponse) -> Unit
|
||||
) : RecyclerView.Adapter<CharacterCommentsAdapter.VH>() {
|
||||
|
||||
val items = mutableListOf<CharacterCommentResponse>()
|
||||
|
||||
inner class VH(private val binding: ItemCharacterCommentBinding) :
|
||||
RecyclerView.ViewHolder(binding.root) {
|
||||
fun bind(item: CharacterCommentResponse) {
|
||||
if (item.memberProfileImage.isNotBlank()) {
|
||||
binding.ivCommentProfile.load(item.memberProfileImage) {
|
||||
crossfade(true)
|
||||
placeholder(R.drawable.ic_placeholder_profile)
|
||||
error(R.drawable.ic_placeholder_profile)
|
||||
transformations(CircleCropTransformation())
|
||||
}
|
||||
} else {
|
||||
binding.ivCommentProfile.setImageResource(R.drawable.ic_placeholder_profile)
|
||||
}
|
||||
|
||||
binding.tvCommentNickname.text = item.memberNickname
|
||||
binding.tvCommentDate.text = timeAgo(item.createdAt)
|
||||
binding.tvComment.text = item.comment
|
||||
binding.tvWriteReply.text = if (item.replyCount > 0) {
|
||||
"답글 ${item.replyCount}개"
|
||||
} else {
|
||||
"답글 쓰기"
|
||||
}
|
||||
|
||||
val isOwner = item.memberId == currentUserId
|
||||
binding.ivMenu.visibility = View.VISIBLE
|
||||
binding.ivMenu.setOnClickListener { onClickMore(item, isOwner, it) }
|
||||
|
||||
// 전체영역 터치 시: 답글 보기로 이동(콜백)
|
||||
binding.root.setOnClickListener { onClickItem(item) }
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH {
|
||||
val binding =
|
||||
ItemCharacterCommentBinding.inflate(LayoutInflater.from(parent.context), parent, false)
|
||||
return VH(binding)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: VH, position: Int) {
|
||||
holder.bind(items[position])
|
||||
}
|
||||
|
||||
override fun getItemCount(): Int = items.size
|
||||
}
|
||||
|
||||
private fun timeAgo(createdAtMillis: Long): String {
|
||||
val now = System.currentTimeMillis()
|
||||
val diff = (now - createdAtMillis).coerceAtLeast(0)
|
||||
val minutes = diff / 60_000
|
||||
if (minutes < 1) return "방금전"
|
||||
if (minutes < 60) return "${minutes}분전"
|
||||
val hours = minutes / 60
|
||||
if (hours < 24) return "${hours}시간전"
|
||||
val days = hours / 24
|
||||
if (days < 365) return "${days}일전"
|
||||
val years = days / 365
|
||||
return "${years}년전"
|
||||
}
|
||||
@@ -222,10 +222,18 @@ class CharacterDetailActivity : BaseActivity<ActivityCharacterDetailBinding>(
|
||||
|
||||
// 댓글 섹션 바인딩
|
||||
binding.tvCommentsCount.text = "${detail.totalComments}"
|
||||
// 댓글 섹션 터치 시 리스트 BottomSheet 열기 (댓글 1개 이상일 때)
|
||||
binding.llCommentsSection.setOnClickListener(null)
|
||||
if (detail.totalComments > 0) {
|
||||
binding.llCommentsSection.setOnClickListener {
|
||||
val sheet = kr.co.vividnext.sodalive.chat.character.comment.CharacterCommentListBottomSheet(detail.characterId)
|
||||
sheet.show(supportFragmentManager, "character_comments")
|
||||
}
|
||||
}
|
||||
if (
|
||||
detail.totalComments > 0 &&
|
||||
detail.latestComment != null &&
|
||||
detail.latestComment.comment.isNullOrBlank()
|
||||
!detail.latestComment.comment.isNullOrBlank()
|
||||
) {
|
||||
binding.llLatestComment.visibility = View.VISIBLE
|
||||
binding.llNoComment.visibility = View.GONE
|
||||
|
||||
Reference in New Issue
Block a user