From f917eb8c93b1eed1339e06f2fe031b85b9bce66c Mon Sep 17 00:00:00 2001 From: klaus Date: Fri, 22 Aug 2025 15:18:28 +0900 Subject: [PATCH] =?UTF-8?q?fix(character-detail):=20characterId=20?= =?UTF-8?q?=EC=A0=84=EB=8B=AC=20=EB=B0=8F=20=EC=83=81=EC=84=B8=20=ED=83=AD?= =?UTF-8?q?=20=EC=A0=84=ED=99=98=20=EB=A1=9C=EC=A7=81=20=EC=88=98=EC=A0=95?= =?UTF-8?q?=20fix(character-detail):=20=ED=83=AD=20=EC=A0=84=ED=99=98=20?= =?UTF-8?q?=EC=8B=9C=20=ED=94=84=EB=9E=98=EA=B7=B8=EB=A8=BC=ED=8A=B8=20?= =?UTF-8?q?=EC=BA=90=EC=8B=B1=ED=95=98=EC=97=AC=20=EC=9E=AC=EB=A1=9C?= =?UTF-8?q?=EB=94=A9=20=EB=B0=A9=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CharacterDetailFragment에 newInstance(characterId) 도입 및 ARG 전달 구조 추가. Fragment에서 잘못된 intent 참조 제거하고 arguments → activity.intent 순으로 안전하게 조회. Activity 초기 진입 시 상세 탭 로딩 경로 정리 및 characterId 유효성 검사 시 종료 처리 보강. replace 기반 교체를 add/show/hide 구조로 전환. TAG_DETAIL/TAG_GALLERY로 인스턴스를 식별하여 FragmentManager 복원/재사용. 탭 이동 시 기존 인스턴스 표시만 수행하여 onViewCreated 재호출/네트워크 재요청 방지. --- .../sodalive/chat/character/CharacterApi.kt | 2 +- .../detail/CharacterDetailActivity.kt | 404 ++----------- .../detail/detail/CharacterDetailFragment.kt | 368 +++++++++++- .../{ => detail}/CharacterDetailRepository.kt | 2 +- .../{ => detail}/CharacterDetailResponse.kt | 2 +- .../{ => detail}/CharacterDetailViewModel.kt | 2 +- .../{ => detail}/OtherCharacterAdapter.kt | 2 +- .../sodalive/chat/talk/room/CharacterInfo.kt | 2 +- .../chat/talk/room/ChatRoomActivity.kt | 2 +- .../java/kr/co/vividnext/sodalive/di/AppDI.kt | 4 +- .../res/layout/activity_character_detail.xml | 531 +----------------- app/src/main/res/layout/detail_toolbar.xml | 4 +- .../res/layout/fragment_character_detail.xml | 515 ++++++++++++++++- .../chat/talk/room/ChatRepositoryTest.kt | 3 +- 14 files changed, 962 insertions(+), 881 deletions(-) rename app/src/main/java/kr/co/vividnext/sodalive/chat/character/detail/{ => detail}/CharacterDetailRepository.kt (90%) rename app/src/main/java/kr/co/vividnext/sodalive/chat/character/detail/{ => detail}/CharacterDetailResponse.kt (96%) rename app/src/main/java/kr/co/vividnext/sodalive/chat/character/detail/{ => detail}/CharacterDetailViewModel.kt (98%) rename app/src/main/java/kr/co/vividnext/sodalive/chat/character/detail/{ => detail}/OtherCharacterAdapter.kt (96%) diff --git a/app/src/main/java/kr/co/vividnext/sodalive/chat/character/CharacterApi.kt b/app/src/main/java/kr/co/vividnext/sodalive/chat/character/CharacterApi.kt index 9bd7975e..96ae369b 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/chat/character/CharacterApi.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/chat/character/CharacterApi.kt @@ -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 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 5b15b903..2fe3e385 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 @@ -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::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" } } diff --git a/app/src/main/java/kr/co/vividnext/sodalive/chat/character/detail/detail/CharacterDetailFragment.kt b/app/src/main/java/kr/co/vividnext/sodalive/chat/character/detail/detail/CharacterDetailFragment.kt index 1dbc4bc3..67834d41 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/chat/character/detail/detail/CharacterDetailFragment.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/chat/character/detail/detail/CharacterDetailFragment.kt @@ -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::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 + } + } diff --git a/app/src/main/java/kr/co/vividnext/sodalive/chat/character/detail/CharacterDetailRepository.kt b/app/src/main/java/kr/co/vividnext/sodalive/chat/character/detail/detail/CharacterDetailRepository.kt similarity index 90% rename from app/src/main/java/kr/co/vividnext/sodalive/chat/character/detail/CharacterDetailRepository.kt rename to app/src/main/java/kr/co/vividnext/sodalive/chat/character/detail/detail/CharacterDetailRepository.kt index 140cd25b..cea429f4 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/chat/character/detail/CharacterDetailRepository.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/chat/character/detail/detail/CharacterDetailRepository.kt @@ -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 diff --git a/app/src/main/java/kr/co/vividnext/sodalive/chat/character/detail/CharacterDetailResponse.kt b/app/src/main/java/kr/co/vividnext/sodalive/chat/character/detail/detail/CharacterDetailResponse.kt similarity index 96% rename from app/src/main/java/kr/co/vividnext/sodalive/chat/character/detail/CharacterDetailResponse.kt rename to app/src/main/java/kr/co/vividnext/sodalive/chat/character/detail/detail/CharacterDetailResponse.kt index 672a2cba..82e5a80e 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/chat/character/detail/CharacterDetailResponse.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/chat/character/detail/detail/CharacterDetailResponse.kt @@ -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 diff --git a/app/src/main/java/kr/co/vividnext/sodalive/chat/character/detail/CharacterDetailViewModel.kt b/app/src/main/java/kr/co/vividnext/sodalive/chat/character/detail/detail/CharacterDetailViewModel.kt similarity index 98% rename from app/src/main/java/kr/co/vividnext/sodalive/chat/character/detail/CharacterDetailViewModel.kt rename to app/src/main/java/kr/co/vividnext/sodalive/chat/character/detail/detail/CharacterDetailViewModel.kt index 35c188cc..fe119714 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/chat/character/detail/CharacterDetailViewModel.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/chat/character/detail/detail/CharacterDetailViewModel.kt @@ -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 diff --git a/app/src/main/java/kr/co/vividnext/sodalive/chat/character/detail/OtherCharacterAdapter.kt b/app/src/main/java/kr/co/vividnext/sodalive/chat/character/detail/detail/OtherCharacterAdapter.kt similarity index 96% rename from app/src/main/java/kr/co/vividnext/sodalive/chat/character/detail/OtherCharacterAdapter.kt rename to app/src/main/java/kr/co/vividnext/sodalive/chat/character/detail/detail/OtherCharacterAdapter.kt index 28d3ee92..98e2dd95 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/chat/character/detail/OtherCharacterAdapter.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/chat/character/detail/detail/OtherCharacterAdapter.kt @@ -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 diff --git a/app/src/main/java/kr/co/vividnext/sodalive/chat/talk/room/CharacterInfo.kt b/app/src/main/java/kr/co/vividnext/sodalive/chat/talk/room/CharacterInfo.kt index 7f08bb8a..2c263b7d 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/chat/talk/room/CharacterInfo.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/chat/talk/room/CharacterInfo.kt @@ -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( diff --git a/app/src/main/java/kr/co/vividnext/sodalive/chat/talk/room/ChatRoomActivity.kt b/app/src/main/java/kr/co/vividnext/sodalive/chat/talk/room/ChatRoomActivity.kt index 1ecca2bc..22751052 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/chat/talk/room/ChatRoomActivity.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/chat/talk/room/ChatRoomActivity.kt @@ -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 diff --git a/app/src/main/java/kr/co/vividnext/sodalive/di/AppDI.kt b/app/src/main/java/kr/co/vividnext/sodalive/di/AppDI.kt index e166f46f..11a239c2 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/di/AppDI.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/di/AppDI.kt @@ -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 diff --git a/app/src/main/res/layout/activity_character_detail.xml b/app/src/main/res/layout/activity_character_detail.xml index afc0d028..61c75de7 100644 --- a/app/src/main/res/layout/activity_character_detail.xml +++ b/app/src/main/res/layout/activity_character_detail.xml @@ -1,7 +1,6 @@ - @@ -9,14 +8,21 @@ + layout="@layout/detail_toolbar" + android:layout_width="0dp" + android:layout_height="wrap_content" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="parent" /> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + android:layout_width="0dp" + android:layout_height="0dp" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@+id/tab_layout" /> - - - + diff --git a/app/src/main/res/layout/detail_toolbar.xml b/app/src/main/res/layout/detail_toolbar.xml index 00d8d53b..d5000265 100644 --- a/app/src/main/res/layout/detail_toolbar.xml +++ b/app/src/main/res/layout/detail_toolbar.xml @@ -4,7 +4,7 @@ xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="51.7dp" - android:background="@color/black" + android:background="@color/color_131313" android:paddingHorizontal="13.3dp"> - - + + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/test/java/kr/co/vividnext/sodalive/chat/talk/room/ChatRepositoryTest.kt b/app/src/test/java/kr/co/vividnext/sodalive/chat/talk/room/ChatRepositoryTest.kt index 012f3783..72b0f507 100644 --- a/app/src/test/java/kr/co/vividnext/sodalive/chat/talk/room/ChatRepositoryTest.kt +++ b/app/src/test/java/kr/co/vividnext/sodalive/chat/talk/room/ChatRepositoryTest.kt @@ -5,6 +5,7 @@ import io.mockk.coVerify import io.mockk.every import io.mockk.mockk import io.reactivex.rxjava3.core.Single +import kr.co.vividnext.sodalive.chat.character.detail.detail.CharacterType import kr.co.vividnext.sodalive.chat.talk.TalkApi import kr.co.vividnext.sodalive.chat.talk.room.db.ChatMessageDao import kr.co.vividnext.sodalive.common.ApiResponse @@ -23,7 +24,7 @@ class ChatRepositoryTest { ServerChatMessage(1, "a1", "", mine = false, createdAt = 1000L), ServerChatMessage(2, "u1", "", mine = true, createdAt = 2000L) ) - val character = CharacterInfo(10, "name", "", kr.co.vividnext.sodalive.chat.character.detail.CharacterType.CLONE) + val character = CharacterInfo(10, "name", "", CharacterType.CLONE) val resp = ChatRoomEnterResponse(99, character, serverMessages, hasMoreMessages = false) every { api.enterChatRoom(any(), any()) } returns Single.just(ApiResponse(true, resp, null))