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
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										11
									
								
								app/src/main/res/layout/dialog_character_comment.xml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								app/src/main/res/layout/dialog_character_comment.xml
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,11 @@
 | 
			
		||||
<?xml version="1.0" encoding="utf-8"?>
 | 
			
		||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
 | 
			
		||||
    android:layout_width="match_parent"
 | 
			
		||||
    android:layout_height="match_parent"
 | 
			
		||||
    android:orientation="vertical">
 | 
			
		||||
 | 
			
		||||
    <FrameLayout
 | 
			
		||||
        android:id="@+id/fl_container"
 | 
			
		||||
        android:layout_width="match_parent"
 | 
			
		||||
        android:layout_height="wrap_content" />
 | 
			
		||||
</LinearLayout>
 | 
			
		||||
							
								
								
									
										31
									
								
								app/src/main/res/layout/dialog_character_comment_more.xml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										31
									
								
								app/src/main/res/layout/dialog_character_comment_more.xml
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,31 @@
 | 
			
		||||
<?xml version="1.0" encoding="utf-8"?>
 | 
			
		||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
 | 
			
		||||
    android:layout_width="match_parent"
 | 
			
		||||
    android:layout_height="wrap_content"
 | 
			
		||||
    android:orientation="vertical"
 | 
			
		||||
    android:padding="16dp"
 | 
			
		||||
    android:background="@color/color_131313">
 | 
			
		||||
 | 
			
		||||
    <TextView
 | 
			
		||||
        android:id="@+id/tv_report"
 | 
			
		||||
        android:layout_width="match_parent"
 | 
			
		||||
        android:layout_height="wrap_content"
 | 
			
		||||
        android:paddingVertical="12dp"
 | 
			
		||||
        android:text="신고"
 | 
			
		||||
        android:textColor="@color/white"
 | 
			
		||||
        android:textSize="16sp" />
 | 
			
		||||
 | 
			
		||||
    <View
 | 
			
		||||
        android:layout_width="match_parent"
 | 
			
		||||
        android:layout_height="1dp"
 | 
			
		||||
        android:background="#78909C" />
 | 
			
		||||
 | 
			
		||||
    <TextView
 | 
			
		||||
        android:id="@+id/tv_delete"
 | 
			
		||||
        android:layout_width="match_parent"
 | 
			
		||||
        android:layout_height="wrap_content"
 | 
			
		||||
        android:paddingVertical="12dp"
 | 
			
		||||
        android:text="삭제"
 | 
			
		||||
        android:textColor="#EF5350"
 | 
			
		||||
        android:textSize="16sp" />
 | 
			
		||||
</LinearLayout>
 | 
			
		||||
							
								
								
									
										135
									
								
								app/src/main/res/layout/fragment_character_comment_list.xml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										135
									
								
								app/src/main/res/layout/fragment_character_comment_list.xml
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,135 @@
 | 
			
		||||
<?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="wrap_content"
 | 
			
		||||
    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_title"
 | 
			
		||||
            android:layout_width="wrap_content"
 | 
			
		||||
            android:layout_height="wrap_content"
 | 
			
		||||
            android:layout_marginStart="13.3dp"
 | 
			
		||||
            android:fontFamily="@font/gmarket_sans_medium"
 | 
			
		||||
            android:text="댓글"
 | 
			
		||||
            android:textColor="@color/white"
 | 
			
		||||
            android:textSize="14.7sp"
 | 
			
		||||
            app:layout_constraintBottom_toBottomOf="@+id/iv_close"
 | 
			
		||||
            app:layout_constraintStart_toStartOf="parent"
 | 
			
		||||
            app:layout_constraintTop_toTopOf="@+id/iv_close" />
 | 
			
		||||
 | 
			
		||||
        <TextView
 | 
			
		||||
            android:id="@+id/tv_comment_count"
 | 
			
		||||
            android:layout_width="wrap_content"
 | 
			
		||||
            android:layout_height="wrap_content"
 | 
			
		||||
            android:layout_marginStart="6.7dp"
 | 
			
		||||
            android:layout_toEndOf="@+id/tv_title"
 | 
			
		||||
            android:fontFamily="@font/gmarket_sans_medium"
 | 
			
		||||
            android:textColor="@color/color_909090"
 | 
			
		||||
            android:textSize="12sp"
 | 
			
		||||
            app:layout_constraintBottom_toBottomOf="@+id/iv_close"
 | 
			
		||||
            app:layout_constraintStart_toEndOf="@+id/tv_title"
 | 
			
		||||
            app:layout_constraintTop_toTopOf="@+id/iv_close"
 | 
			
		||||
            tools:text="1,204" />
 | 
			
		||||
 | 
			
		||||
        <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="13.3dp"
 | 
			
		||||
        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: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"
 | 
			
		||||
        android:layout_width="match_parent"
 | 
			
		||||
        android:layout_height="0dp"
 | 
			
		||||
        android:layout_below="@+id/divider2"
 | 
			
		||||
        android:layout_alignParentBottom="true"
 | 
			
		||||
        android:clipToPadding="false"
 | 
			
		||||
        android:padding="13.3dp" />
 | 
			
		||||
</RelativeLayout>
 | 
			
		||||
							
								
								
									
										93
									
								
								app/src/main/res/layout/item_character_comment.xml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										93
									
								
								app/src/main/res/layout/item_character_comment.xml
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,93 @@
 | 
			
		||||
<?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">
 | 
			
		||||
 | 
			
		||||
    <ImageView
 | 
			
		||||
        android:id="@+id/iv_comment_profile"
 | 
			
		||||
        android:layout_width="36dp"
 | 
			
		||||
        android:layout_height="36dp"
 | 
			
		||||
        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="24dp"
 | 
			
		||||
        android:layout_height="24dp"
 | 
			
		||||
        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="댓글 내용이 표시됩니다." />
 | 
			
		||||
 | 
			
		||||
    <TextView
 | 
			
		||||
        android:id="@+id/tv_write_reply"
 | 
			
		||||
        android:layout_width="wrap_content"
 | 
			
		||||
        android:layout_height="wrap_content"
 | 
			
		||||
        android:layout_marginTop="8dp"
 | 
			
		||||
        android:fontFamily="@font/pretendard_bold"
 | 
			
		||||
        android:text="답글 쓰기"
 | 
			
		||||
        android:textColor="@color/color_3bb9f1"
 | 
			
		||||
        android:textSize="14sp"
 | 
			
		||||
        app:layout_constraintStart_toStartOf="@+id/tv_comment"
 | 
			
		||||
        app:layout_constraintTop_toBottomOf="@+id/tv_comment" />
 | 
			
		||||
 | 
			
		||||
    <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_write_reply" />
 | 
			
		||||
</androidx.constraintlayout.widget.ConstraintLayout>
 | 
			
		||||
		Reference in New Issue
	
	Block a user