feat(character-comment): 답글 페이지 UI 및 페이징 스텁 구현

- 댓글 리스트 아이템 터치 시 답글 페이지로 전환 연결
- 상단 뒤로 가기/닫기, 입력 폼, divider, 원본 댓글, 들여 쓰기된 답글 목록 구성
- RecyclerView 최하단 도달 시 더미 데이터 추가 로드(무한 스크롤 스텁)
- 답글 등록/수정/삭제 동작 스텁 처리
- 추가 파일
  - layout: fragment_character_comment_reply.xml, item_character_comment_reply.xml
  - 코드: CharacterCommentReplyFragment, CharacterCommentReplyAdapter
- 변경 파일
  - CharacterCommentListBottomSheet: openReply() 추가
  - CharacterCommentListFragment: 아이템 클릭 시 답글 페이지 진입
This commit is contained in:
2025-08-20 00:54:00 +09:00
parent a9742a07c0
commit d4ec2fbdef
6 changed files with 568 additions and 1 deletions

View File

@@ -52,4 +52,13 @@ class CharacterCommentListBottomSheet(
.replace(R.id.fl_container, fragment, tag) .replace(R.id.fl_container, fragment, tag)
.commit() .commit()
} }
fun openReply(original: CharacterCommentResponse) {
val tag = "CHARACTER_COMMENT_REPLY"
val fragment = CharacterCommentReplyFragment.newInstance(characterId, original)
childFragmentManager.beginTransaction()
.add(R.id.fl_container, fragment, tag)
.addToBackStack(tag)
.commit()
}
} }

View File

@@ -103,7 +103,9 @@ class CharacterCommentListFragment : BaseFragment<FragmentCharacterCommentListBi
} }
}.show(childFragmentManager, "comment_more") }.show(childFragmentManager, "comment_more")
}, },
onClickItem = { /* 답글 보기로 이동 예정 (stub) */ } onClickItem = { item ->
(parentFragment as? CharacterCommentListBottomSheet)?.openReply(item)
}
) )
val recyclerView = binding.rvComment val recyclerView = binding.rvComment

View File

@@ -0,0 +1,141 @@
package kr.co.vividnext.sodalive.chat.character.comment
import android.content.Context
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.PopupMenu
import androidx.recyclerview.widget.RecyclerView
import androidx.viewbinding.ViewBinding
import coil.load
import coil.transform.CircleCropTransformation
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.databinding.ItemCharacterCommentBinding
import kr.co.vividnext.sodalive.databinding.ItemCharacterCommentReplyBinding
class CharacterCommentReplyAdapter(
private val currentUserId: Long,
private val onModify: (id: Long, newText: String, isReply: Boolean) -> Unit,
private val onDelete: (id: Long, isReply: Boolean) -> Unit
) : RecyclerView.Adapter<CharacterReplyVH>() {
// 첫 번째 아이템은 항상 원본 댓글
val items = mutableListOf<Any>() // [CharacterCommentResponse] + List<CharacterReplyResponse>
override fun getItemViewType(position: Int): Int = if (position == 0) 0 else 1
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): CharacterReplyVH {
return if (viewType == 0) {
CharacterReplyHeaderVH(
ItemCharacterCommentBinding.inflate(LayoutInflater.from(parent.context), parent, false)
)
} else {
CharacterReplyItemVH(
context = parent.context,
binding = ItemCharacterCommentReplyBinding.inflate(LayoutInflater.from(parent.context), parent, false),
currentUserId = currentUserId,
onModify = onModify,
onDelete = onDelete
)
}
}
override fun onBindViewHolder(holder: CharacterReplyVH, position: Int) {
val item = items[position]
holder.bind(item)
}
override fun getItemCount(): Int = items.size
}
abstract class CharacterReplyVH(binding: ViewBinding) : RecyclerView.ViewHolder(binding.root) {
abstract fun bind(item: Any)
}
class CharacterReplyHeaderVH(
private val binding: ItemCharacterCommentBinding
) : CharacterReplyVH(binding) {
override fun bind(item: Any) {
val data = item as CharacterCommentResponse
if (data.memberProfileImage.isNotBlank()) {
binding.ivCommentProfile.load(data.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 = data.memberNickname
binding.tvCommentDate.text = timeAgo(data.createdAt)
binding.tvComment.text = data.comment
binding.tvWriteReply.visibility = View.GONE
binding.ivMenu.visibility = View.GONE
}
}
class CharacterReplyItemVH(
private val context: Context,
private val binding: ItemCharacterCommentReplyBinding,
private val currentUserId: Long,
private val onModify: (id: Long, newText: String, isReply: Boolean) -> Unit,
private val onDelete: (id: Long, isReply: Boolean) -> Unit
) : CharacterReplyVH(binding) {
override fun bind(item: Any) {
val data = item as CharacterReplyResponse
if (data.memberProfileImage.isNotBlank()) {
binding.ivCommentProfile.load(data.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 = data.memberNickname
binding.tvCommentDate.text = timeAgo(data.createdAt)
binding.tvComment.text = data.comment
val isOwner = data.memberId == currentUserId
binding.ivMenu.visibility = View.VISIBLE
binding.ivMenu.setOnClickListener {
val popup = PopupMenu(context, it)
if (isOwner) {
popup.menuInflater.inflate(R.menu.content_comment_option_menu, popup.menu)
} else {
popup.menuInflater.inflate(R.menu.content_comment_option_menu2, popup.menu)
}
popup.setOnMenuItemClickListener { mi ->
when (mi.itemId) {
R.id.menu_review_modify -> {
// 간단화: 수정은 현재 텍스트 그대로 콜백 (실서비스는 인라인 에디트 UI 구성)
onModify(data.replyId, data.comment, true)
}
R.id.menu_review_delete -> {
onDelete(data.replyId, true)
}
}
true
}
popup.show()
}
}
}
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}년전"
}

View File

@@ -0,0 +1,209 @@
package kr.co.vividnext.sodalive.chat.character.comment
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 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.FragmentCharacterCommentReplyBinding
import kr.co.vividnext.sodalive.extensions.dpToPx
/**
* 캐릭터 댓글 답글 페이지 (Stub)
* - 상단: 뒤로가기(텍스트 + ic_back), 닫기(X)
* - 입력 폼, divider, 원본 댓글, 답글 목록(들여쓰기)
* - 스크롤 하단 도달 시 더미 데이터 추가 로드
* - API 연동은 추후 (현재 스텁)
*/
class CharacterCommentReplyFragment : BaseFragment<FragmentCharacterCommentReplyBinding>(
FragmentCharacterCommentReplyBinding::inflate
) {
private lateinit var imm: InputMethodManager
private lateinit var loadingDialog: LoadingDialog
private lateinit var adapter: CharacterCommentReplyAdapter
private var original: CharacterCommentResponse? = null
private var characterId: Long = 0
private var page: Int = 1
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
characterId = arguments?.getLong(EXTRA_CHARACTER_ID) ?: 0
original = arguments?.let {
val cid = it.getLong(EXTRA_ORIGINAL_COMMENT_ID, -1)
if (cid == -1L) null else CharacterCommentResponse(
commentId = cid,
memberId = it.getLong(EXTRA_ORIGINAL_MEMBER_ID),
memberProfileImage = it.getString(EXTRA_ORIGINAL_MEMBER_PROFILE_IMAGE) ?: "",
memberNickname = it.getString(EXTRA_ORIGINAL_MEMBER_NICKNAME) ?: "",
createdAt = it.getLong(EXTRA_ORIGINAL_CREATED_AT),
replyCount = it.getInt(EXTRA_ORIGINAL_REPLY_COUNT),
comment = it.getString(EXTRA_ORIGINAL_COMMENT_TEXT) ?: ""
)
}
return super.onCreateView(inflater, container, savedInstanceState)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
if (original == null) {
parentFragmentManager.popBackStack()
return
}
loadingDialog = LoadingDialog(requireActivity(), layoutInflater)
imm = requireContext().getSystemService(Service.INPUT_METHOD_SERVICE) as InputMethodManager
setupView()
loadMore() // 초기 로드 (스텁)
}
private fun setupView() {
binding.tvBack.setOnClickListener { parentFragmentManager.popBackStack() }
binding.ivClose.setOnClickListener { (parentFragment as? CharacterCommentListBottomSheet)?.dismiss() }
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 text = binding.etComment.text.toString()
if (text.isBlank()) return@setOnClickListener
// 스텁: 로컬에 즉시 추가
val me = CharacterReplyResponse(
replyId = System.currentTimeMillis(),
memberId = SharedPreferenceManager.userId,
memberProfileImage = SharedPreferenceManager.profileImage,
memberNickname = SharedPreferenceManager.nickname,
createdAt = System.currentTimeMillis(),
comment = text
)
val insertAt = adapter.items.size
adapter.items.add(me)
adapter.notifyItemInserted(insertAt)
binding.rvCommentReply.scrollToPosition(adapter.items.size - 1)
binding.etComment.setText("")
Toast.makeText(requireContext(), "등록되었습니다 (stub)", Toast.LENGTH_SHORT).show()
}
adapter = CharacterCommentReplyAdapter(
currentUserId = SharedPreferenceManager.userId,
onModify = { id, newText, isReply ->
Toast.makeText(requireContext(), "수정 스텁: $id", Toast.LENGTH_SHORT).show()
},
onDelete = { id, isReply ->
val index = adapter.items.indexOfFirst {
when (it) {
is CharacterReplyResponse -> it.replyId == id
else -> false
}
}
if (index > 0) { // 0은 원본 댓글이므로 제외
adapter.items.removeAt(index)
adapter.notifyItemRemoved(index)
}
}
).apply {
items.clear()
items.add(original!!) // 헤더: 원본 댓글
}
val recyclerView = binding.rvCommentReply
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 = 12f.dpToPx().toInt() }
adapter.itemCount - 1 -> { outRect.top = 12f.dpToPx().toInt(); outRect.bottom = 13.3f.dpToPx().toInt() }
else -> { outRect.top = 12f.dpToPx().toInt(); outRect.bottom = 12f.dpToPx().toInt() }
}
}
})
recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
super.onScrolled(recyclerView, dx, dy)
val lm = recyclerView.layoutManager as LinearLayoutManager
val last = lm.findLastCompletelyVisibleItemPosition()
val total = (recyclerView.adapter?.itemCount ?: 1) - 1
if (!recyclerView.canScrollVertically(1) && last == total) {
loadMore()
}
}
})
recyclerView.adapter = adapter
}
private fun hideKeyboard() {
imm.hideSoftInputFromWindow(view?.windowToken, 0)
}
// 스텁 페이징: 더미 데이터 생성
private fun loadMore() {
// 원본 댓글이 있어야 함
val originalId = original?.commentId ?: return
val newItems = (1..10).map { idx ->
val id = page * 10_000L + idx
val writerId = if (idx % 5 == 0) SharedPreferenceManager.userId else -idx.toLong()
CharacterReplyResponse(
replyId = id,
memberId = writerId,
memberProfileImage = "",
memberNickname = "게스트$id",
createdAt = System.currentTimeMillis() - (idx * 60_000L * page),
comment = "캐릭터 답글 예시 텍스트 $originalId-$id"
)
}
val start = adapter.items.size
adapter.items.addAll(newItems)
adapter.notifyItemRangeInserted(start, newItems.size)
page++
}
companion object {
private const val EXTRA_CHARACTER_ID = "extra_character_id"
private const val EXTRA_ORIGINAL_COMMENT_ID = "extra_original_comment_id"
private const val EXTRA_ORIGINAL_MEMBER_ID = "extra_original_member_id"
private const val EXTRA_ORIGINAL_MEMBER_PROFILE_IMAGE = "extra_original_member_profile_image"
private const val EXTRA_ORIGINAL_MEMBER_NICKNAME = "extra_original_member_nickname"
private const val EXTRA_ORIGINAL_CREATED_AT = "extra_original_created_at"
private const val EXTRA_ORIGINAL_REPLY_COUNT = "extra_original_reply_count"
private const val EXTRA_ORIGINAL_COMMENT_TEXT = "extra_original_comment_text"
fun newInstance(characterId: Long, original: CharacterCommentResponse): CharacterCommentReplyFragment {
val args = Bundle().apply {
putLong(EXTRA_CHARACTER_ID, characterId)
putLong(EXTRA_ORIGINAL_COMMENT_ID, original.commentId)
putLong(EXTRA_ORIGINAL_MEMBER_ID, original.memberId)
putString(EXTRA_ORIGINAL_MEMBER_PROFILE_IMAGE, original.memberProfileImage)
putString(EXTRA_ORIGINAL_MEMBER_NICKNAME, original.memberNickname)
putLong(EXTRA_ORIGINAL_CREATED_AT, original.createdAt)
putInt(EXTRA_ORIGINAL_REPLY_COUNT, original.replyCount)
putString(EXTRA_ORIGINAL_COMMENT_TEXT, original.comment)
}
return CharacterCommentReplyFragment().apply { arguments = args }
}
}
}

View File

@@ -0,0 +1,124 @@
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/bg_top_round_corner_16_7_222222">
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/rl_toolbar"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<TextView
android:id="@+id/tv_back"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="13.3dp"
android:drawablePadding="6.7dp"
android:fontFamily="@font/gmarket_sans_medium"
android:gravity="center_vertical"
android:text="답글"
android:textColor="@color/white"
android:textSize="14.7sp"
app:drawableStartCompat="@drawable/ic_back"
app:layout_constraintBottom_toBottomOf="@+id/iv_close"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="@+id/iv_close" />
<ImageView
android:id="@+id/iv_close"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentEnd="true"
android:layout_marginTop="12dp"
android:contentDescription="@null"
android:paddingHorizontal="13.3dp"
android:paddingTop="12dp"
android:paddingBottom="12dp"
android:src="@drawable/ic_circle_x_white"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
<View
android:id="@+id/divider"
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_below="@+id/rl_toolbar"
android:layout_marginHorizontal="13.3dp"
android:layout_marginTop="12dp"
android:background="#78909C" />
<LinearLayout
android:id="@+id/ll_comment_input"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_below="@+id/divider"
android:background="@drawable/bg_top_round_corner_8_222222"
android:elevation="6dp"
android:gravity="center_vertical"
android:orientation="horizontal"
android:padding="13.3dp">
<ImageView
android:id="@+id/iv_comment_profile"
android:layout_width="33.3dp"
android:layout_height="33.3dp"
android:contentDescription="@null"
tools:src="@drawable/ic_placeholder_profile" />
<RelativeLayout
android:id="@+id/rl_input_comment"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="8dp">
<EditText
android:id="@+id/et_comment"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@drawable/bg_round_corner_10_232323_3bb9f1"
android:hint="답글을 입력해 보세요"
android:importantForAutofill="no"
android:inputType="text|textMultiLine"
android:maxLines="5"
android:paddingVertical="13.3dp"
android:paddingStart="13.3dp"
android:paddingEnd="50.7dp"
android:textColor="@color/color_eeeeee"
android:textColorHint="@color/color_777777"
android:textCursorDrawable="@drawable/edit_text_cursor"
android:textSize="13.3sp"
tools:ignore="LabelFor" />
<ImageView
android:id="@+id/iv_comment_send"
android:layout_width="33.3dp"
android:layout_height="33.3dp"
android:layout_alignParentEnd="true"
android:layout_centerVertical="true"
android:layout_marginEnd="6dp"
android:contentDescription="@null"
android:src="@drawable/btn_message_send" />
</RelativeLayout>
</LinearLayout>
<View
android:id="@+id/divider2"
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_below="@+id/ll_comment_input"
android:layout_marginHorizontal="13.3dp"
android:background="#78909C" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rv_comment_reply"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_alignParentBottom="true"
android:layout_below="@+id/divider2"
android:clipToPadding="false"
android:padding="13.3dp" />
</RelativeLayout>

View File

@@ -0,0 +1,82 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingStart="24dp">
<ImageView
android:id="@+id/iv_comment_profile"
android:layout_width="30dp"
android:layout_height="30dp"
android:contentDescription="@null"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:src="@drawable/ic_placeholder_profile" />
<LinearLayout
android:id="@+id/ll_comment_nickname"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:orientation="horizontal"
app:layout_constraintEnd_toStartOf="@+id/iv_menu"
app:layout_constraintStart_toEndOf="@+id/iv_comment_profile"
app:layout_constraintTop_toTopOf="@+id/iv_comment_profile">
<TextView
android:id="@+id/tv_comment_nickname"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:fontFamily="@font/pretendard_bold"
android:textColor="@color/white"
android:textSize="14sp"
tools:text="닉네임" />
</LinearLayout>
<ImageView
android:id="@+id/iv_menu"
android:layout_width="20dp"
android:layout_height="20dp"
android:contentDescription="@null"
android:src="@drawable/ic_seemore_vertical_white"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="@+id/iv_comment_profile" />
<TextView
android:id="@+id/tv_comment_date"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:fontFamily="@font/pretendard_regular"
android:textColor="@color/color_b0bec5"
android:textSize="12sp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="@+id/ll_comment_nickname"
app:layout_constraintTop_toBottomOf="@+id/ll_comment_nickname"
tools:text="2시간전" />
<TextView
android:id="@+id/tv_comment"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:fontFamily="@font/pretendard_regular"
android:lineSpacingExtra="4dp"
android:textColor="@color/white"
android:textSize="16sp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="@+id/tv_comment_date"
app:layout_constraintTop_toBottomOf="@+id/tv_comment_date"
tools:text="답글 내용이 표시됩니다." />
<View
android:layout_width="0dp"
android:layout_height="1dp"
android:layout_marginTop="12dp"
android:background="#78909C"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/tv_comment" />
</androidx.constraintlayout.widget.ConstraintLayout>