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 index 00d2bb1d..4d4f9c85 100644 --- 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 @@ -52,4 +52,13 @@ class CharacterCommentListBottomSheet( .replace(R.id.fl_container, fragment, tag) .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() + } } 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 index d79f0ec3..ea7621a0 100644 --- 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 @@ -103,7 +103,9 @@ class CharacterCommentListFragment : BaseFragment + (parentFragment as? CharacterCommentListBottomSheet)?.openReply(item) + } ) val recyclerView = binding.rvComment diff --git a/app/src/main/java/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentReplyAdapter.kt b/app/src/main/java/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentReplyAdapter.kt new file mode 100644 index 00000000..4fa4ad1b --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentReplyAdapter.kt @@ -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() { + + // 첫 번째 아이템은 항상 원본 댓글 + val items = mutableListOf() // [CharacterCommentResponse] + List + + 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}년전" +} diff --git a/app/src/main/java/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentReplyFragment.kt b/app/src/main/java/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentReplyFragment.kt new file mode 100644 index 00000000..3e7813e3 --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentReplyFragment.kt @@ -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::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 } + } + } +} diff --git a/app/src/main/res/layout/fragment_character_comment_reply.xml b/app/src/main/res/layout/fragment_character_comment_reply.xml new file mode 100644 index 00000000..dbeb27f8 --- /dev/null +++ b/app/src/main/res/layout/fragment_character_comment_reply.xml @@ -0,0 +1,124 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/item_character_comment_reply.xml b/app/src/main/res/layout/item_character_comment_reply.xml new file mode 100644 index 00000000..abf37221 --- /dev/null +++ b/app/src/main/res/layout/item_character_comment_reply.xml @@ -0,0 +1,82 @@ + + + + + + + + + + + + + + + + + +