From a9742a07c0c7eeec5711abe20b9cf85e978ecdc3 Mon Sep 17 00:00:00 2001 From: klaus Date: Wed, 20 Aug 2025 00:42:15 +0900 Subject: [PATCH] =?UTF-8?q?feat(character-comment):=20=EC=BA=90=EB=A6=AD?= =?UTF-8?q?=ED=84=B0=20=EB=8C=93=EA=B8=80=20=EB=A6=AC=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?BottomSheet=20UI=20=EB=B0=8F=20=ED=8E=98=EC=9D=B4=EC=A7=95=20?= =?UTF-8?q?=EC=8A=A4=ED=85=81=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CharacterDetail 댓글 섹션 터치 시 BottomSheet 표시 - 헤더/입력폼/Divider/리스트/더보기 BottomSheet 구성 - RecyclerView 하단 도달 시 더미 데이터 추가 로드(Stub) - 상대시간 표기(분/시간/일/년 전) - API 연동은 이후 작업 예정 (스텁) --- .../character/comment/CharacterCommentDto.kt | 29 ++- .../CharacterCommentListBottomSheet.kt | 55 +++++ .../comment/CharacterCommentListFragment.kt | 205 ++++++++++++++++++ .../CharacterCommentMoreBottomSheet.kt | 55 +++++ .../comment/CharacterCommentsAdapter.kt | 77 +++++++ .../detail/CharacterDetailActivity.kt | 10 +- .../res/layout/dialog_character_comment.xml | 11 + .../layout/dialog_character_comment_more.xml | 31 +++ .../fragment_character_comment_list.xml | 135 ++++++++++++ .../res/layout/item_character_comment.xml | 93 ++++++++ 10 files changed, 694 insertions(+), 7 deletions(-) create mode 100644 app/src/main/java/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentListBottomSheet.kt create mode 100644 app/src/main/java/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentListFragment.kt create mode 100644 app/src/main/java/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentMoreBottomSheet.kt create mode 100644 app/src/main/java/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentsAdapter.kt create mode 100644 app/src/main/res/layout/dialog_character_comment.xml create mode 100644 app/src/main/res/layout/dialog_character_comment_more.xml create mode 100644 app/src/main/res/layout/fragment_character_comment_list.xml create mode 100644 app/src/main/res/layout/item_character_comment.xml diff --git a/app/src/main/java/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentDto.kt b/app/src/main/java/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentDto.kt index 323d2001..28a29dff 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentDto.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentDto.kt @@ -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 + @SerializedName("replies") val replies: List, + @SerializedName("cursor") val cursor: Long? +) + +// 댓글 리스트 조회 Response 컨테이너 +// - 전체 댓글 개수(totalCount) +// - 댓글 목록(comments) +@Keep +data class CharacterCommentListResponse( + @SerializedName("totalCount") val totalCount: Int, + @SerializedName("comments") val comments: List, + @SerializedName("cursor") val cursor: Long? +) + +// 신고 Request +@Keep +data class ReportCharacterCommentRequest( + @SerializedName("content") val content: String ) diff --git a/app/src/main/java/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentListBottomSheet.kt b/app/src/main/java/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentListBottomSheet.kt new file mode 100644 index 00000000..00d2bb1d --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentListBottomSheet.kt @@ -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(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() + } +} diff --git a/app/src/main/java/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentListFragment.kt b/app/src/main/java/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentListFragment.kt new file mode 100644 index 00000000..d79f0ec3 --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentListFragment.kt @@ -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::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 + } + } +} diff --git a/app/src/main/java/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentMoreBottomSheet.kt b/app/src/main/java/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentMoreBottomSheet.kt new file mode 100644 index 00000000..7cb030b4 --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentMoreBottomSheet.kt @@ -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(R.id.tv_report) + val tvDelete = view.findViewById(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 + } + } +} diff --git a/app/src/main/java/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentsAdapter.kt b/app/src/main/java/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentsAdapter.kt new file mode 100644 index 00000000..c5af658c --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentsAdapter.kt @@ -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() { + + val items = mutableListOf() + + 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}년전" +} diff --git a/app/src/main/java/kr/co/vividnext/sodalive/chat/character/detail/CharacterDetailActivity.kt b/app/src/main/java/kr/co/vividnext/sodalive/chat/character/detail/CharacterDetailActivity.kt index 6b3e8a57..69dc5d91 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/chat/character/detail/CharacterDetailActivity.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/chat/character/detail/CharacterDetailActivity.kt @@ -222,10 +222,18 @@ class CharacterDetailActivity : BaseActivity( // 댓글 섹션 바인딩 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 diff --git a/app/src/main/res/layout/dialog_character_comment.xml b/app/src/main/res/layout/dialog_character_comment.xml new file mode 100644 index 00000000..177474f6 --- /dev/null +++ b/app/src/main/res/layout/dialog_character_comment.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/app/src/main/res/layout/dialog_character_comment_more.xml b/app/src/main/res/layout/dialog_character_comment_more.xml new file mode 100644 index 00000000..5817f85b --- /dev/null +++ b/app/src/main/res/layout/dialog_character_comment_more.xml @@ -0,0 +1,31 @@ + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_character_comment_list.xml b/app/src/main/res/layout/fragment_character_comment_list.xml new file mode 100644 index 00000000..e052f3e3 --- /dev/null +++ b/app/src/main/res/layout/fragment_character_comment_list.xml @@ -0,0 +1,135 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/item_character_comment.xml b/app/src/main/res/layout/item_character_comment.xml new file mode 100644 index 00000000..dbcdb4ce --- /dev/null +++ b/app/src/main/res/layout/item_character_comment.xml @@ -0,0 +1,93 @@ + + + + + + + + + + + + + + + + + + + +