fix(character-detail): characterId 전달 및 상세 탭 전환 로직 수정

fix(character-detail): 탭 전환 시 프래그먼트 캐싱하여 재로딩 방지

CharacterDetailFragment에 newInstance(characterId) 도입 및 ARG 전달 구조 추가.
Fragment에서 잘못된 intent 참조 제거하고 arguments → activity.intent 순으로 안전하게 조회.
Activity 초기 진입 시 상세 탭 로딩 경로 정리 및 characterId 유효성 검사 시 종료 처리 보강.

replace 기반 교체를 add/show/hide 구조로 전환.
TAG_DETAIL/TAG_GALLERY로 인스턴스를 식별하여 FragmentManager 복원/재사용.
탭 이동 시 기존 인스턴스 표시만 수행하여 onViewCreated 재호출/네트워크 재요청 방지.
This commit is contained in:
2025-08-22 15:18:28 +09:00
parent 989a0f361b
commit f917eb8c93
14 changed files with 962 additions and 881 deletions

View File

@@ -1,7 +1,7 @@
package kr.co.vividnext.sodalive.chat.character
import io.reactivex.rxjava3.core.Single
import kr.co.vividnext.sodalive.chat.character.detail.CharacterDetailResponse
import kr.co.vividnext.sodalive.chat.character.detail.detail.CharacterDetailResponse
import kr.co.vividnext.sodalive.common.ApiResponse
import retrofit2.http.GET
import retrofit2.http.Header

View File

@@ -1,394 +1,96 @@
package kr.co.vividnext.sodalive.chat.character.detail
import android.annotation.SuppressLint
import android.content.Intent
import android.graphics.Rect
import android.os.Bundle
import android.text.TextUtils
import android.view.View
import androidx.core.net.toUri
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import coil.load
import coil.transform.CircleCropTransformation
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.schedulers.Schedulers
import androidx.fragment.app.Fragment
import com.google.android.material.tabs.TabLayout
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.base.BaseActivity
import kr.co.vividnext.sodalive.chat.character.comment.CharacterCommentListBottomSheet
import kr.co.vividnext.sodalive.chat.character.comment.CharacterCommentRepository
import kr.co.vividnext.sodalive.chat.talk.room.ChatRoomActivity
import kr.co.vividnext.sodalive.common.LoadingDialog
import kr.co.vividnext.sodalive.common.SharedPreferenceManager
import kr.co.vividnext.sodalive.chat.character.detail.detail.CharacterDetailFragment
import kr.co.vividnext.sodalive.chat.character.detail.gallery.CharacterGalleryFragment
import kr.co.vividnext.sodalive.databinding.ActivityCharacterDetailBinding
import kr.co.vividnext.sodalive.extensions.dpToPx
import org.koin.android.ext.android.inject
import org.koin.androidx.viewmodel.ext.android.viewModel
class CharacterDetailActivity : BaseActivity<ActivityCharacterDetailBinding>(
ActivityCharacterDetailBinding::inflate
) {
private val viewModel: CharacterDetailViewModel by viewModel()
private val commentRepository: CharacterCommentRepository by inject()
private lateinit var loadingDialog: LoadingDialog
private val adapter by lazy {
OtherCharacterAdapter(
onItemClick = { item ->
startActivity(
Intent(this, CharacterDetailActivity::class.java).apply {
putExtra(EXTRA_CHARACTER_ID, item.characterId)
}
)
}
)
}
private var isWorldviewExpanded = false
private var isPersonalityExpanded = false
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// 더미 데이터 로드 (추후 Intent/Repository 연동)
val characterId = intent.getLongExtra(EXTRA_CHARACTER_ID, 0)
if (characterId <= 0) {
showToast("잘못된 접근 입니다.")
finish()
} else {
bindObservers()
viewModel.load(characterId)
return
}
super.onCreate(savedInstanceState)
}
override fun setupView() {
loadingDialog = LoadingDialog(this, layoutInflater)
// 뒤로 가기
binding.detailToolbar.tvBack.setOnClickListener { finish() }
// 탭 구성: 상세, 갤러리
binding.tabLayout.addTab(binding.tabLayout.newTab().setText("상세"))
binding.tabLayout.addTab(binding.tabLayout.newTab().setText("갤러리"))
binding.tabLayout.addOnTabSelectedListener(object :
com.google.android.material.tabs.TabLayout.OnTabSelectedListener {
override fun onTabSelected(tab: com.google.android.material.tabs.TabLayout.Tab) {
if (tab.position == 0) {
// 상세 탭: 기존 스크롤 화면 표시
binding.scrollViewCharacterDetail.visibility = View.VISIBLE
binding.flContainer.visibility = View.GONE
} else {
// 갤러리 탭: 컨테이너 표시 및 갤러리 프래그먼트 로드
binding.scrollViewCharacterDetail.visibility = View.GONE
binding.flContainer.visibility = View.VISIBLE
val fragment =
kr.co.vividnext.sodalive.chat.character.detail.gallery.CharacterGalleryFragment()
supportFragmentManager.beginTransaction()
.replace(R.id.fl_container, fragment)
.commit()
val characterId = intent.getLongExtra(EXTRA_CHARACTER_ID, 0)
// 기존 프래그먼트 복원/재사용
var detail = supportFragmentManager.findFragmentByTag(TAG_DETAIL)
var gallery = supportFragmentManager.findFragmentByTag(TAG_GALLERY)
val transaction = supportFragmentManager.beginTransaction()
if (detail == null) {
detail = CharacterDetailFragment.newInstance(characterId)
transaction.add(R.id.fl_container, detail, TAG_DETAIL)
}
if (gallery == null) {
gallery = CharacterGalleryFragment()
transaction.add(R.id.fl_container, gallery, TAG_GALLERY)
transaction.hide(gallery)
}
transaction.show(detail).commit()
binding.tabLayout
.addOnTabSelectedListener(object : TabLayout.OnTabSelectedListener {
override fun onTabSelected(tab: TabLayout.Tab) {
showTab(tab.position)
}
}
override fun onTabUnselected(tab: com.google.android.material.tabs.TabLayout.Tab) {}
override fun onTabReselected(tab: com.google.android.material.tabs.TabLayout.Tab) {}
})
// 다른 캐릭터 리스트: 가로 스크롤
val recyclerView = binding.rvOtherCharacters
recyclerView.layoutManager = LinearLayoutManager(
this,
LinearLayoutManager.HORIZONTAL,
false
)
recyclerView.addItemDecoration(object : RecyclerView.ItemDecoration() {
override fun getItemOffsets(
outRect: Rect,
view: View,
parent: RecyclerView,
state: RecyclerView.State
) {
super.getItemOffsets(outRect, view, parent, state)
when (parent.getChildAdapterPosition(view)) {
0 -> {
outRect.left = 0
outRect.right = 8f.dpToPx().toInt()
}
adapter.itemCount - 1 -> {
outRect.left = 8f.dpToPx().toInt()
outRect.right = 0
}
else -> {
outRect.left = 8f.dpToPx().toInt()
outRect.right = 8f.dpToPx().toInt()
}
}
}
})
recyclerView.adapter = adapter
// 세계관 전체보기 토글 클릭 리스너
binding.llWorldviewExpand.setOnClickListener {
toggleWorldviewExpand()
}
// 성격 전체보기 토글 클릭 리스너
binding.llPersonalityExpand.setOnClickListener {
togglePersonalityExpand()
}
// 대화하기 버튼 클릭: 채팅방 생성 API 호출
binding.btnChat.setOnClickListener {
val idFromState = viewModel.uiState.value?.detail?.characterId ?: 0L
val idFromIntent = intent.getLongExtra(EXTRA_CHARACTER_ID, 0L)
val targetId = if (idFromState > 0) idFromState else idFromIntent
if (targetId > 0) {
viewModel.createChatRoom(targetId)
} else {
showToast("잘못된 접근 입니다.")
}
}
override fun onTabUnselected(tab: TabLayout.Tab) {}
override fun onTabReselected(tab: TabLayout.Tab) {}
})
}
@SuppressLint("SetTextI18n")
private fun bindObservers() {
viewModel.uiState.observe(this) { state ->
// 1) 로딩 상태 처리
if (state.isLoading) {
loadingDialog.show(screenWidth)
} else {
loadingDialog.dismiss()
}
private fun showTab(position: Int) {
val detail = supportFragmentManager.findFragmentByTag(TAG_DETAIL)
val gallery = supportFragmentManager.findFragmentByTag(TAG_GALLERY)
val transaction = supportFragmentManager.beginTransaction()
// 2) 에러 토스트 처리
state.error?.let { errorMsg ->
if (errorMsg.isNotBlank()) {
showToast(errorMsg)
}
}
// 2-1) 채팅방 생성 성공 처리 (이벤트)
state.chatRoomId?.let { roomId ->
startActivity(ChatRoomActivity.newIntent(this, roomId))
viewModel.consumeChatRoomCreated()
}
// 3) 상세 데이터가 있을 경우에만 기존 UI 바인딩 수행
val detail = state.detail ?: return@observe
// 배경 이미지
binding.ivCharacterBackground.load(detail.imageUrl) { crossfade(true) }
// 기본 정보
binding.detailToolbar.tvBack.text = detail.name
binding.tvCharacterName.text = detail.name
binding.tvCharacterStatus.text = when (detail.characterType) {
CharacterType.CLONE -> "Clone"
CharacterType.CHARACTER -> "Character"
}
// 캐릭터 타입에 따른 배경 설정
binding.tvCharacterStatus.setBackgroundResource(
when (detail.characterType) {
CharacterType.CLONE -> R.drawable.bg_character_status_clone
CharacterType.CHARACTER -> R.drawable.bg_character_status_character
}
)
binding.tvCharacterDescription.text = detail.description
binding.tvCharacterTags.text = detail.tags
// 세계관 내용과 버튼 가시성 초기화
val worldviewText = detail.backgrounds?.description.orEmpty()
binding.tvWorldviewContent.text = worldviewText
// 먼저 전체 줄 수를 측정한 뒤 접힘 레이아웃 적용
binding.tvWorldviewContent.post {
val totalLines = binding.tvWorldviewContent.layout?.lineCount
?: binding.tvWorldviewContent.lineCount
val needExpand = totalLines > 3
binding.llWorldviewExpand.visibility = if (needExpand) View.VISIBLE else View.GONE
// 표시 상태는 항상 접힘 상태로 시작
applyWorldviewCollapsedLayout()
isWorldviewExpanded = false
binding.tvWorldviewExpand.text = "더보기"
binding.ivWorldviewExpand.setImageResource(R.drawable.ic_chevron_down)
}
// 성격 내용과 버튼 가시성 초기화
val personalityText = detail.personalities?.description.orEmpty()
binding.tvPersonalityContent.text = personalityText
// 먼저 전체 줄 수를 측정한 뒤 접힘 레이아웃 적용
binding.tvPersonalityContent.post {
val totalLines = binding.tvPersonalityContent.layout?.lineCount
?: binding.tvPersonalityContent.lineCount
val needExpand = totalLines > 3
binding.llPersonalityExpand.visibility = if (needExpand) View.VISIBLE else View.GONE
applyPersonalityCollapsedLayout()
isPersonalityExpanded = false
binding.tvPersonalityExpand.text = "더보기"
binding.ivPersonalityExpand.setImageResource(R.drawable.ic_chevron_down)
}
// 원작 섹션 표시/숨김
if (detail.originalTitle.isNullOrBlank() || detail.originalLink.isNullOrBlank()) {
binding.llOriginalSection.visibility = View.GONE
} else {
binding.llOriginalSection.visibility = View.VISIBLE
binding.tvOriginalContent.text = detail.originalTitle
binding.tvOriginalLink.setOnClickListener {
runCatching {
startActivity(Intent(Intent.ACTION_VIEW, detail.originalLink.toUri()))
}
}
}
// 다른 캐릭터 리스트
if (detail.others.isEmpty()) {
binding.llOtherCharactersSection.visibility = View.GONE
} else {
binding.llOtherCharactersSection.visibility = View.VISIBLE
adapter.submitList(detail.others)
}
// 댓글 섹션 바인딩
binding.tvCommentsCount.text = "${detail.totalComments}"
// 댓글 섹션 터치 시 리스트 BottomSheet 열기 (댓글 1개 이상일 때)
binding.llCommentsSection.setOnClickListener(null)
if (detail.totalComments > 0) {
binding.llCommentsSection.setOnClickListener {
val sheet = CharacterCommentListBottomSheet(detail.characterId)
sheet.show(supportFragmentManager, "character_comments")
}
}
if (
detail.totalComments > 0 &&
detail.latestComment != null &&
detail.latestComment.comment.isNotBlank()
) {
binding.llLatestComment.visibility = View.VISIBLE
binding.llNoComment.visibility = View.GONE
val latest = detail.latestComment
val profileUrl = latest.memberProfileImage
if (profileUrl.isNotBlank()) {
binding.ivCommentProfile.load(profileUrl) {
crossfade(true)
placeholder(R.drawable.ic_placeholder_profile)
error(R.drawable.ic_placeholder_profile)
transformations(CircleCropTransformation())
}
} else {
binding.ivMyProfile.load(R.drawable.ic_placeholder_profile) {
crossfade(true)
placeholder(R.drawable.ic_placeholder_profile)
error(R.drawable.ic_placeholder_profile)
transformations(CircleCropTransformation())
}
}
binding.tvLatestComment.text = latest.comment.ifBlank {
latest.memberNickname
}
} else {
binding.llLatestComment.visibility = View.GONE
binding.llNoComment.visibility = View.VISIBLE
// 내 프로필 이미지는 SharedPreference의 profileImage 사용 (fallback: placeholder)
val myProfileUrl = SharedPreferenceManager.profileImage
if (myProfileUrl.isNotBlank()) {
binding.ivMyProfile.load(myProfileUrl) {
crossfade(true)
placeholder(R.drawable.ic_placeholder_profile)
error(R.drawable.ic_placeholder_profile)
transformations(CircleCropTransformation())
}
} else {
binding.ivMyProfile.load(R.drawable.ic_placeholder_profile) {
crossfade(true)
placeholder(R.drawable.ic_placeholder_profile)
error(R.drawable.ic_placeholder_profile)
transformations(CircleCropTransformation())
}
}
binding.ivSendComment.setOnClickListener {
val text = binding.etCommentInput.text?.toString()?.trim().orEmpty()
if (text.isBlank()) return@setOnClickListener
val idFromState = viewModel.uiState.value?.detail?.characterId ?: 0L
val idFromIntent = intent.getLongExtra(EXTRA_CHARACTER_ID, 0L)
val characterId = if (idFromState > 0) idFromState else idFromIntent
if (characterId <= 0) {
showToast("잘못된 접근 입니다.")
return@setOnClickListener
}
val token = "Bearer ${SharedPreferenceManager.token}"
loadingDialog.show(screenWidth)
val d = commentRepository.createComment(characterId, text, token)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.doFinally { loadingDialog.dismiss() }
.subscribe({ resp ->
if (resp.success) {
binding.etCommentInput.setText("")
showToast("등록되었습니다.")
viewModel.load(characterId)
} else {
showToast(resp.message ?: "요청 중 오류가 발생했습니다")
}
}, { e ->
showToast(e.message ?: "요청 중 오류가 발생했습니다")
})
compositeDisposable.add(d)
}
}
fun Fragment?.hideIfExists() {
if (this != null && !this.isHidden) transaction.hide(this)
}
}
private fun toggleWorldviewExpand() {
isWorldviewExpanded = !isWorldviewExpanded
if (isWorldviewExpanded) {
// 확장 상태
binding.tvWorldviewContent.maxLines = Integer.MAX_VALUE
binding.tvWorldviewContent.ellipsize = null
binding.tvWorldviewExpand.text = "간략히"
binding.ivWorldviewExpand.setImageResource(R.drawable.ic_chevron_up)
} else {
// 접힘 상태 (3줄)
applyWorldviewCollapsedLayout()
binding.tvWorldviewExpand.text = "더보기"
binding.ivWorldviewExpand.setImageResource(R.drawable.ic_chevron_down)
// 모두 숨김
detail.hideIfExists()
gallery.hideIfExists()
// 포지션에 맞게 표시
val toShow: Fragment? = when (position) {
0 -> detail
else -> gallery
}
if (toShow != null) transaction.show(toShow)
transaction.commit()
}
private fun applyWorldviewCollapsedLayout() {
binding.tvWorldviewContent.maxLines = 3
binding.tvWorldviewContent.ellipsize = TextUtils.TruncateAt.END
}
private fun togglePersonalityExpand() {
isPersonalityExpanded = !isPersonalityExpanded
if (isPersonalityExpanded) {
binding.tvPersonalityContent.maxLines = Integer.MAX_VALUE
binding.tvPersonalityContent.ellipsize = null
binding.tvPersonalityExpand.text = "간략히"
binding.ivPersonalityExpand.setImageResource(R.drawable.ic_chevron_up)
} else {
applyPersonalityCollapsedLayout()
binding.tvPersonalityExpand.text = "더보기"
binding.ivPersonalityExpand.setImageResource(R.drawable.ic_chevron_down)
}
}
private fun applyPersonalityCollapsedLayout() {
binding.tvPersonalityContent.maxLines = 3
binding.tvPersonalityContent.ellipsize = TextUtils.TruncateAt.END
fun setTitle(title: String) {
binding.detailToolbar.tvBack.text = title
}
companion object {
const val EXTRA_CHARACTER_ID = "extra_character_id"
private const val TAG_DETAIL = "tag_character_detail"
private const val TAG_GALLERY = "tag_character_gallery"
}
}

View File

@@ -1,19 +1,383 @@
package kr.co.vividnext.sodalive.chat.character.detail.detail
import android.annotation.SuppressLint
import android.content.Intent
import android.graphics.Rect
import android.os.Bundle
import android.text.TextUtils
import android.view.View
import androidx.core.net.toUri
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import coil.load
import coil.transform.CircleCropTransformation
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.schedulers.Schedulers
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.base.BaseFragment
import kr.co.vividnext.sodalive.chat.character.comment.CharacterCommentListBottomSheet
import kr.co.vividnext.sodalive.chat.character.comment.CharacterCommentRepository
import kr.co.vividnext.sodalive.chat.character.detail.CharacterDetailActivity
import kr.co.vividnext.sodalive.chat.character.detail.CharacterDetailActivity.Companion.EXTRA_CHARACTER_ID
import kr.co.vividnext.sodalive.chat.talk.room.ChatRoomActivity
import kr.co.vividnext.sodalive.common.LoadingDialog
import kr.co.vividnext.sodalive.common.SharedPreferenceManager
import kr.co.vividnext.sodalive.databinding.FragmentCharacterDetailBinding
import kr.co.vividnext.sodalive.extensions.dpToPx
import org.koin.android.ext.android.inject
import org.koin.androidx.viewmodel.ext.android.viewModel
/**
* 캐릭터 상세 - 상세 탭
* TODO: 기존 CharacterDetailActivity UI 바인딩 로직을 이 Fragment로 점진적으로 이전합니다.
*/
class CharacterDetailFragment : BaseFragment<FragmentCharacterDetailBinding>(
FragmentCharacterDetailBinding::inflate
) {
companion object {
private const val ARG_CHARACTER_ID = "arg_character_id"
fun newInstance(characterId: Long): CharacterDetailFragment =
CharacterDetailFragment().apply {
arguments = Bundle().apply { putLong(ARG_CHARACTER_ID, characterId) }
}
}
private val viewModel: CharacterDetailViewModel by viewModel()
private val commentRepository: CharacterCommentRepository by inject()
private lateinit var loadingDialog: LoadingDialog
private val characterId: Long by lazy {
arguments?.getLong(ARG_CHARACTER_ID)
?: requireActivity().intent.getLongExtra(EXTRA_CHARACTER_ID, 0L)
}
private val adapter by lazy {
OtherCharacterAdapter(
onItemClick = { item ->
startActivity(
Intent(
requireActivity(),
CharacterDetailActivity::class.java
).apply {
putExtra(EXTRA_CHARACTER_ID, item.characterId)
}
)
}
)
}
private var isWorldviewExpanded = false
private var isPersonalityExpanded = false
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
// 추후 상세 UI/로직 반영 예정
setupView()
bindObservers()
viewModel.load(characterId)
}
@SuppressLint("SetTextI18n")
private fun bindObservers() {
viewModel.uiState.observe(viewLifecycleOwner) { state ->
// 1) 로딩 상태 처리
if (state.isLoading) {
loadingDialog.show(screenWidth)
} else {
loadingDialog.dismiss()
}
// 2) 에러 토스트 처리
state.error?.let { errorMsg ->
if (errorMsg.isNotBlank()) {
showToast(errorMsg)
}
}
// 2-1) 채팅방 생성 성공 처리 (이벤트)
state.chatRoomId?.let { roomId ->
startActivity(
ChatRoomActivity.newIntent(
requireActivity(),
roomId
)
)
viewModel.consumeChatRoomCreated()
}
// 3) 상세 데이터가 있을 경우에만 기존 UI 바인딩 수행
val detail = state.detail ?: return@observe
// 배경 이미지
binding.ivCharacterBackground.load(detail.imageUrl) { crossfade(true) }
// 기본 정보
(requireActivity() as CharacterDetailActivity).setTitle(detail.name)
binding.tvCharacterName.text = detail.name
binding.tvCharacterStatus.text = when (detail.characterType) {
CharacterType.CLONE -> "Clone"
CharacterType.CHARACTER -> "Character"
}
// 캐릭터 타입에 따른 배경 설정
binding.tvCharacterStatus.setBackgroundResource(
when (detail.characterType) {
CharacterType.CLONE -> R.drawable.bg_character_status_clone
CharacterType.CHARACTER -> R.drawable.bg_character_status_character
}
)
binding.tvCharacterDescription.text = detail.description
binding.tvCharacterTags.text = detail.tags
// 세계관 내용과 버튼 가시성 초기화
val worldviewText = detail.backgrounds?.description.orEmpty()
binding.tvWorldviewContent.text = worldviewText
// 먼저 전체 줄 수를 측정한 뒤 접힘 레이아웃 적용
binding.tvWorldviewContent.post {
val totalLines = binding.tvWorldviewContent.layout?.lineCount
?: binding.tvWorldviewContent.lineCount
val needExpand = totalLines > 3
binding.llWorldviewExpand.visibility = if (needExpand) View.VISIBLE else View.GONE
// 표시 상태는 항상 접힘 상태로 시작
applyWorldviewCollapsedLayout()
isWorldviewExpanded = false
binding.tvWorldviewExpand.text = "더보기"
binding.ivWorldviewExpand.setImageResource(R.drawable.ic_chevron_down)
}
// 성격 내용과 버튼 가시성 초기화
val personalityText = detail.personalities?.description.orEmpty()
binding.tvPersonalityContent.text = personalityText
// 먼저 전체 줄 수를 측정한 뒤 접힘 레이아웃 적용
binding.tvPersonalityContent.post {
val totalLines = binding.tvPersonalityContent.layout?.lineCount
?: binding.tvPersonalityContent.lineCount
val needExpand = totalLines > 3
binding.llPersonalityExpand.visibility = if (needExpand) View.VISIBLE else View.GONE
applyPersonalityCollapsedLayout()
isPersonalityExpanded = false
binding.tvPersonalityExpand.text = "더보기"
binding.ivPersonalityExpand.setImageResource(R.drawable.ic_chevron_down)
}
// 원작 섹션 표시/숨김
if (detail.originalTitle.isNullOrBlank() || detail.originalLink.isNullOrBlank()) {
binding.llOriginalSection.visibility = View.GONE
} else {
binding.llOriginalSection.visibility = View.VISIBLE
binding.tvOriginalContent.text = detail.originalTitle
binding.tvOriginalLink.setOnClickListener {
runCatching {
startActivity(Intent(Intent.ACTION_VIEW, detail.originalLink.toUri()))
}
}
}
// 다른 캐릭터 리스트
if (detail.others.isEmpty()) {
binding.llOtherCharactersSection.visibility = View.GONE
} else {
binding.llOtherCharactersSection.visibility = View.VISIBLE
adapter.submitList(detail.others)
}
// 댓글 섹션 바인딩
binding.tvCommentsCount.text = "${detail.totalComments}"
// 댓글 섹션 터치 시 리스트 BottomSheet 열기 (댓글 1개 이상일 때)
binding.llCommentsSection.setOnClickListener(null)
if (detail.totalComments > 0) {
binding.llCommentsSection.setOnClickListener {
val sheet = CharacterCommentListBottomSheet(detail.characterId)
sheet.show(requireActivity().supportFragmentManager, "character_comments")
}
}
if (
detail.totalComments > 0 &&
detail.latestComment != null &&
detail.latestComment.comment.isNotBlank()
) {
binding.llLatestComment.visibility = View.VISIBLE
binding.llNoComment.visibility = View.GONE
val latest = detail.latestComment
val profileUrl = latest.memberProfileImage
if (profileUrl.isNotBlank()) {
binding.ivCommentProfile.load(profileUrl) {
crossfade(true)
placeholder(R.drawable.ic_placeholder_profile)
error(R.drawable.ic_placeholder_profile)
transformations(CircleCropTransformation())
}
} else {
binding.ivMyProfile.load(R.drawable.ic_placeholder_profile) {
crossfade(true)
placeholder(R.drawable.ic_placeholder_profile)
error(R.drawable.ic_placeholder_profile)
transformations(CircleCropTransformation())
}
}
binding.tvLatestComment.text = latest.comment.ifBlank {
latest.memberNickname
}
} else {
binding.llLatestComment.visibility = View.GONE
binding.llNoComment.visibility = View.VISIBLE
// 내 프로필 이미지는 SharedPreference의 profileImage 사용 (fallback: placeholder)
val myProfileUrl = SharedPreferenceManager.profileImage
if (myProfileUrl.isNotBlank()) {
binding.ivMyProfile.load(myProfileUrl) {
crossfade(true)
placeholder(R.drawable.ic_placeholder_profile)
error(R.drawable.ic_placeholder_profile)
transformations(CircleCropTransformation())
}
} else {
binding.ivMyProfile.load(R.drawable.ic_placeholder_profile) {
crossfade(true)
placeholder(R.drawable.ic_placeholder_profile)
error(R.drawable.ic_placeholder_profile)
transformations(CircleCropTransformation())
}
}
binding.ivSendComment.setOnClickListener {
val text = binding.etCommentInput.text?.toString()?.trim().orEmpty()
if (text.isBlank()) return@setOnClickListener
val idFromState = viewModel.uiState.value?.detail?.characterId ?: 0L
val targetCharacterId = if (idFromState > 0) idFromState else characterId
if (targetCharacterId <= 0) {
showToast("잘못된 접근 입니다.")
return@setOnClickListener
}
val token = "Bearer ${SharedPreferenceManager.token}"
loadingDialog.show(screenWidth)
val d = commentRepository.createComment(targetCharacterId, text, token)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.doFinally { loadingDialog.dismiss() }
.subscribe({ resp ->
if (resp.success) {
binding.etCommentInput.setText("")
showToast("등록되었습니다.")
viewModel.load(targetCharacterId)
} else {
showToast(resp.message ?: "요청 중 오류가 발생했습니다")
}
}, { e ->
showToast(e.message ?: "요청 중 오류가 발생했습니다")
})
compositeDisposable.add(d)
}
}
}
}
private fun setupView() {
loadingDialog = LoadingDialog(requireActivity(), layoutInflater)
// 다른 캐릭터 리스트: 가로 스크롤
val recyclerView = binding.rvOtherCharacters
recyclerView.layoutManager = LinearLayoutManager(
requireContext(),
LinearLayoutManager.HORIZONTAL,
false
)
recyclerView.addItemDecoration(object : RecyclerView.ItemDecoration() {
override fun getItemOffsets(
outRect: Rect,
view: View,
parent: RecyclerView,
state: RecyclerView.State
) {
super.getItemOffsets(outRect, view, parent, state)
when (parent.getChildAdapterPosition(view)) {
0 -> {
outRect.left = 0
outRect.right = 8f.dpToPx().toInt()
}
adapter.itemCount - 1 -> {
outRect.left = 8f.dpToPx().toInt()
outRect.right = 0
}
else -> {
outRect.left = 8f.dpToPx().toInt()
outRect.right = 8f.dpToPx().toInt()
}
}
}
})
recyclerView.adapter = adapter
// 세계관 전체보기 토글 클릭 리스너
binding.llWorldviewExpand.setOnClickListener {
toggleWorldviewExpand()
}
// 성격 전체보기 토글 클릭 리스너
binding.llPersonalityExpand.setOnClickListener {
togglePersonalityExpand()
}
// 대화하기 버튼 클릭: 채팅방 생성 API 호출
binding.btnChat.setOnClickListener {
val idFromState = viewModel.uiState.value?.detail?.characterId ?: 0L
val targetId = if (idFromState > 0) idFromState else characterId
if (targetId > 0) {
viewModel.createChatRoom(targetId)
} else {
showToast("잘못된 접근 입니다.")
}
}
}
private fun toggleWorldviewExpand() {
isWorldviewExpanded = !isWorldviewExpanded
if (isWorldviewExpanded) {
// 확장 상태
binding.tvWorldviewContent.maxLines = Integer.MAX_VALUE
binding.tvWorldviewContent.ellipsize = null
binding.tvWorldviewExpand.text = "간략히"
binding.ivWorldviewExpand.setImageResource(R.drawable.ic_chevron_up)
} else {
// 접힘 상태 (3줄)
applyWorldviewCollapsedLayout()
binding.tvWorldviewExpand.text = "더보기"
binding.ivWorldviewExpand.setImageResource(R.drawable.ic_chevron_down)
}
}
private fun applyWorldviewCollapsedLayout() {
binding.tvWorldviewContent.maxLines = 3
binding.tvWorldviewContent.ellipsize = TextUtils.TruncateAt.END
}
private fun togglePersonalityExpand() {
isPersonalityExpanded = !isPersonalityExpanded
if (isPersonalityExpanded) {
binding.tvPersonalityContent.maxLines = Integer.MAX_VALUE
binding.tvPersonalityContent.ellipsize = null
binding.tvPersonalityExpand.text = "간략히"
binding.ivPersonalityExpand.setImageResource(R.drawable.ic_chevron_up)
} else {
applyPersonalityCollapsedLayout()
binding.tvPersonalityExpand.text = "더보기"
binding.ivPersonalityExpand.setImageResource(R.drawable.ic_chevron_down)
}
}
private fun applyPersonalityCollapsedLayout() {
binding.tvPersonalityContent.maxLines = 3
binding.tvPersonalityContent.ellipsize = TextUtils.TruncateAt.END
}
}

View File

@@ -1,4 +1,4 @@
package kr.co.vividnext.sodalive.chat.character.detail
package kr.co.vividnext.sodalive.chat.character.detail.detail
import kr.co.vividnext.sodalive.chat.character.CharacterApi
import kr.co.vividnext.sodalive.chat.talk.TalkApi

View File

@@ -1,4 +1,4 @@
package kr.co.vividnext.sodalive.chat.character.detail
package kr.co.vividnext.sodalive.chat.character.detail.detail
import androidx.annotation.Keep
import com.google.gson.annotations.SerializedName

View File

@@ -1,4 +1,4 @@
package kr.co.vividnext.sodalive.chat.character.detail
package kr.co.vividnext.sodalive.chat.character.detail.detail
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData

View File

@@ -1,4 +1,4 @@
package kr.co.vividnext.sodalive.chat.character.detail
package kr.co.vividnext.sodalive.chat.character.detail.detail
import android.annotation.SuppressLint
import android.view.LayoutInflater

View File

@@ -5,7 +5,7 @@ package kr.co.vividnext.sodalive.chat.talk.room
import androidx.annotation.Keep
import com.google.gson.annotations.SerializedName
import kr.co.vividnext.sodalive.chat.character.detail.CharacterType
import kr.co.vividnext.sodalive.chat.character.detail.detail.CharacterType
@Keep
data class CharacterInfo(

View File

@@ -16,7 +16,7 @@ import androidx.recyclerview.widget.RecyclerView
import coil.load
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.base.BaseActivity
import kr.co.vividnext.sodalive.chat.character.detail.CharacterType
import kr.co.vividnext.sodalive.chat.character.detail.detail.CharacterType
import kr.co.vividnext.sodalive.common.SharedPreferenceManager
import kr.co.vividnext.sodalive.databinding.ActivityChatRoomBinding
import kr.co.vividnext.sodalive.extensions.dpToPx

View File

@@ -69,8 +69,8 @@ import kr.co.vividnext.sodalive.chat.character.CharacterTabRepository
import kr.co.vividnext.sodalive.chat.character.CharacterTabViewModel
import kr.co.vividnext.sodalive.chat.character.comment.CharacterCommentApi
import kr.co.vividnext.sodalive.chat.character.comment.CharacterCommentRepository
import kr.co.vividnext.sodalive.chat.character.detail.CharacterDetailRepository
import kr.co.vividnext.sodalive.chat.character.detail.CharacterDetailViewModel
import kr.co.vividnext.sodalive.chat.character.detail.detail.CharacterDetailRepository
import kr.co.vividnext.sodalive.chat.character.detail.detail.CharacterDetailViewModel
import kr.co.vividnext.sodalive.chat.talk.TalkApi
import kr.co.vividnext.sodalive.chat.talk.TalkTabRepository
import kr.co.vividnext.sodalive.chat.talk.TalkTabViewModel