From ac2482a6450a1a40fa188a611e492c20f0d93f0e Mon Sep 17 00:00:00 2001 From: klaus Date: Tue, 12 Aug 2025 22:15:52 +0900 Subject: [PATCH] =?UTF-8?q?feat(character=20detail):=20=EC=BA=90=EB=A6=AD?= =?UTF-8?q?=ED=84=B0=20=EC=83=81=EC=84=B8=20=ED=8E=98=EC=9D=B4=EC=A7=80=20?= =?UTF-8?q?UI=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/src/main/AndroidManifest.xml | 3 + .../detail/CharacterDetailActivity.kt | 207 ++++++++++ .../detail/CharacterDetailResponse.kt | 48 +++ .../detail/CharacterDetailViewModel.kt | 73 ++++ .../character/detail/OtherCharacterAdapter.kt | 58 +++ .../bg_character_status_character.xml | 6 + .../drawable/bg_character_status_clone.xml | 6 + .../drawable/bg_character_status_creator.xml | 6 + .../bg_round_corner_16_solid_3bb9f1.xml | 6 + .../bg_round_corner_16_stroke_37474f.xml | 9 + .../bg_round_corner_16_stroke_3bb9f1.xml | 9 + app/src/main/res/drawable/ic_chevron_down.xml | 14 + app/src/main/res/drawable/ic_chevron_up.xml | 14 + .../res/layout/activity_character_detail.xml | 389 ++++++++++++++++++ .../main/res/layout/item_other_character.xml | 38 ++ app/src/main/res/values/colors.xml | 2 + 16 files changed, 888 insertions(+) create mode 100644 app/src/main/java/kr/co/vividnext/sodalive/chat/character/detail/CharacterDetailActivity.kt create mode 100644 app/src/main/java/kr/co/vividnext/sodalive/chat/character/detail/CharacterDetailResponse.kt create mode 100644 app/src/main/java/kr/co/vividnext/sodalive/chat/character/detail/CharacterDetailViewModel.kt create mode 100644 app/src/main/java/kr/co/vividnext/sodalive/chat/character/detail/OtherCharacterAdapter.kt create mode 100644 app/src/main/res/drawable/bg_character_status_character.xml create mode 100644 app/src/main/res/drawable/bg_character_status_clone.xml create mode 100644 app/src/main/res/drawable/bg_character_status_creator.xml create mode 100644 app/src/main/res/drawable/bg_round_corner_16_solid_3bb9f1.xml create mode 100644 app/src/main/res/drawable/bg_round_corner_16_stroke_37474f.xml create mode 100644 app/src/main/res/drawable/bg_round_corner_16_stroke_3bb9f1.xml create mode 100644 app/src/main/res/drawable/ic_chevron_down.xml create mode 100644 app/src/main/res/drawable/ic_chevron_up.xml create mode 100644 app/src/main/res/layout/activity_character_detail.xml create mode 100644 app/src/main/res/layout/item_other_character.xml diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index adfe0180..bcf2571a 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -282,5 +282,8 @@ android:name="com.facebook.FacebookActivity" android:configChanges="keyboard|keyboardHidden|screenLayout|screenSize|orientation" /> + + + 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 new file mode 100644 index 00000000..935d0d6f --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/chat/character/detail/CharacterDetailActivity.kt @@ -0,0 +1,207 @@ +package kr.co.vividnext.sodalive.chat.character.detail + +import android.content.Intent +import android.graphics.Rect +import android.os.Bundle +import android.text.TextUtils +import android.view.View +import androidx.activity.viewModels +import androidx.core.net.toUri +import androidx.recyclerview.widget.LinearLayoutManager +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.databinding.ActivityCharacterDetailBinding +import kr.co.vividnext.sodalive.extensions.dpToPx + +class CharacterDetailActivity : BaseActivity( + ActivityCharacterDetailBinding::inflate +) { + private val viewModel: CharacterDetailViewModel by viewModels() + + 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() +// } + + viewModel.loadMock(characterId) + bindObservers() + } + + override fun setupView() { + // 뒤로 가기 + binding.detailToolbar.tvBack.setOnClickListener { finish() } + + // 다른 캐릭터 리스트: 가로 스크롤 + 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() + } + } + + private fun bindObservers() { + viewModel.uiState.observe(this) { state -> + 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 + applyWorldviewCollapsedLayout() + binding.tvWorldviewContent.post { + val needExpand = binding.tvWorldviewContent.lineCount > 3 + binding.llWorldviewExpand.visibility = if (needExpand) View.VISIBLE else View.GONE + } + + // 성격 내용과 버튼 가시성 초기화 + val personalityText = detail.personalities?.description.orEmpty() + binding.tvPersonalityContent.text = personalityText + applyPersonalityCollapsedLayout() + binding.tvPersonalityContent.post { + val needExpand = binding.tvPersonalityContent.lineCount > 3 + binding.llPersonalityExpand.visibility = if (needExpand) View.VISIBLE else View.GONE + } + + // 원작 섹션 표시/숨김 + 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())) + } + } + } + + // 다른 캐릭터 리스트 + adapter.submitList(detail.others) + } + } + + 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 + } + + companion object { + const val EXTRA_CHARACTER_ID = "extra_character_id" + } +} 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/CharacterDetailResponse.kt new file mode 100644 index 00000000..6c146602 --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/chat/character/detail/CharacterDetailResponse.kt @@ -0,0 +1,48 @@ +package kr.co.vividnext.sodalive.chat.character.detail + +import androidx.annotation.Keep +import com.google.gson.annotations.SerializedName + +@Keep +data class CharacterDetailResponse( + @SerializedName("characterId") val characterId: Long, + @SerializedName("name") val name: String, + @SerializedName("description") val description: String, + @SerializedName("mbti") val mbti: String?, + @SerializedName("imageUrl") val imageUrl: String, + @SerializedName("personalities") val personalities: CharacterPersonalityResponse?, + @SerializedName("backgrounds") val backgrounds: CharacterBackgroundResponse?, + @SerializedName("tags") val tags: String, + @SerializedName("originalTitle") val originalTitle: String?, + @SerializedName("originalLink") val originalLink: String?, + @SerializedName("characterType") val characterType: CharacterType, + @SerializedName("others") val others: List +) + +@Keep +enum class CharacterType { + @SerializedName("CLONE") + CLONE, + @SerializedName("CHARACTER") + CHARACTER +} + +@Keep +data class OtherCharacter( + @SerializedName("characterId") val characterId: Long, + @SerializedName("name") val name: String, + @SerializedName("imageUrl") val imageUrl: String, + @SerializedName("tags") val tags: String +) + +@Keep +data class CharacterPersonalityResponse( + @SerializedName("trait") val trait: String, + @SerializedName("description") val description: String +) + +@Keep +data class CharacterBackgroundResponse( + @SerializedName("topic") val topic: String, + @SerializedName("description") val description: String +) 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/CharacterDetailViewModel.kt new file mode 100644 index 00000000..c2b3ed7e --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/chat/character/detail/CharacterDetailViewModel.kt @@ -0,0 +1,73 @@ +package kr.co.vividnext.sodalive.chat.character.detail + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import kr.co.vividnext.sodalive.base.BaseViewModel + +/** + * 캐릭터 상세 화면에서 사용하는 ViewModel. + * - 캐릭터 명과 상태 + * - 캐릭터 소개 + * - 태그 문자열 (예: "#태그1 #태그2") + * - 세계관 내용 (3줄 이상일 경우 전체보기 토글) + * - 원작 섹션 (빈 값이면 UI에서 숨김) + * - 다른 캐릭터 목록 (이미지, 캐릭터 명, 태그) + */ +class CharacterDetailViewModel : BaseViewModel() { + // UiState를 CharacterDetailResponse 구조에 맞게 변경 + data class UiState( + val detail: CharacterDetailResponse? = null + ) + + private val _uiState = MutableLiveData(UiState()) + val uiState: LiveData get() = _uiState + + fun loadMock(characterId: Long) { + // TODO: Repository 연동 예정. 현재는 더미 데이터로 표시 + val demoWorldview = CharacterBackgroundResponse( + topic = "세계관", + description = "특별한 꽃을 길러낼 수 있는 능력을 가진 리엘라.\n그녀는 호손 공작의 상속자가 되고 말아버리는데...\n\n뜻하지 않은 만남과 비밀이 펼쳐진다." + ) + val demoPersonality = CharacterPersonalityResponse( + trait = "밝음, 고집", + description = "밝고 쾌활하지만 고집이 있으며, 친구를 소중히 여기고 어려움 앞에서도 물러서지 않습니다.\n상황에 따라 유연하게 대처하지만 신념은 확고합니다.\n\n상황에 따라 유연하게 대처하지만 신념은 확고합니다.\n\n\n상황에 따라 유연하게 대처하지만 신념은 확고합니다." + ) + val others = listOf( + OtherCharacter( + characterId = 1, + name = "엘리시아", + imageUrl = "https://picsum.photos/seed/char1/300/300", + tags = "#마법 #학생 #쾌활" + ), + OtherCharacter( + characterId = 2, + name = "루카", + imageUrl = "https://picsum.photos/seed/char2/300/300", + tags = "#기사 #충직 #과묵" + ), + OtherCharacter( + characterId = 3, + name = "세라", + imageUrl = "https://picsum.photos/seed/char3/300/300", + tags = "#암살자 #쿨 #시크" + ) + ) + + val response = CharacterDetailResponse( + characterId = characterId, + name = "리엘라", + description = "꽃을 키우는 공작가의 상속자", + mbti = "ENFP", + imageUrl = "https://picsum.photos/seed/bg1/1000/1000", + personalities = demoPersonality, + backgrounds = demoWorldview, + tags = "#커버곡 #라이브 #연애 #썸 #채팅 #라방", + originalTitle = "네이버 시리즈 독 안에 든 선생님", + originalLink = "https://series.naver.com/", + characterType = CharacterType.CLONE, + others = others + ) + + _uiState.value = UiState(detail = response) + } +} 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/OtherCharacterAdapter.kt new file mode 100644 index 00000000..28d3ee92 --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/chat/character/detail/OtherCharacterAdapter.kt @@ -0,0 +1,58 @@ +package kr.co.vividnext.sodalive.chat.character.detail + +import android.annotation.SuppressLint +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import coil.load +import coil.transform.RoundedCornersTransformation +import kr.co.vividnext.sodalive.R +import kr.co.vividnext.sodalive.databinding.ItemOtherCharacterBinding +import kr.co.vividnext.sodalive.extensions.dpToPx + +class OtherCharacterAdapter( + private var items: List = emptyList(), + private val onItemClick: ((OtherCharacter) -> Unit)? = null +) : RecyclerView.Adapter() { + + @SuppressLint("NotifyDataSetChanged") + fun submitList(newItems: List) { + items = newItems + notifyDataSetChanged() + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): OtherCharacterViewHolder { + return OtherCharacterViewHolder( + ItemOtherCharacterBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) + ) + } + + override fun onBindViewHolder(holder: OtherCharacterViewHolder, position: Int) { + holder.bind(items[position]) + } + + override fun getItemCount(): Int = items.size + + inner class OtherCharacterViewHolder( + private val binding: ItemOtherCharacterBinding + ) : RecyclerView.ViewHolder(binding.root) { + fun bind(item: OtherCharacter) { + binding.tvName.text = item.name + binding.tvTags.text = item.tags + binding.ivThumb.load(item.imageUrl) { + crossfade(true) + placeholder(R.drawable.ic_place_holder) + transformations(RoundedCornersTransformation(16f.dpToPx())) + } + + + binding.root.setOnClickListener { + onItemClick?.invoke(item) + } + } + } +} diff --git a/app/src/main/res/drawable/bg_character_status_character.xml b/app/src/main/res/drawable/bg_character_status_character.xml new file mode 100644 index 00000000..a05ed00a --- /dev/null +++ b/app/src/main/res/drawable/bg_character_status_character.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_character_status_clone.xml b/app/src/main/res/drawable/bg_character_status_clone.xml new file mode 100644 index 00000000..1f9810c1 --- /dev/null +++ b/app/src/main/res/drawable/bg_character_status_clone.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_character_status_creator.xml b/app/src/main/res/drawable/bg_character_status_creator.xml new file mode 100644 index 00000000..05a12731 --- /dev/null +++ b/app/src/main/res/drawable/bg_character_status_creator.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_round_corner_16_solid_3bb9f1.xml b/app/src/main/res/drawable/bg_round_corner_16_solid_3bb9f1.xml new file mode 100644 index 00000000..2a2bb323 --- /dev/null +++ b/app/src/main/res/drawable/bg_round_corner_16_solid_3bb9f1.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/app/src/main/res/drawable/bg_round_corner_16_stroke_37474f.xml b/app/src/main/res/drawable/bg_round_corner_16_stroke_37474f.xml new file mode 100644 index 00000000..f086e3d0 --- /dev/null +++ b/app/src/main/res/drawable/bg_round_corner_16_stroke_37474f.xml @@ -0,0 +1,9 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_round_corner_16_stroke_3bb9f1.xml b/app/src/main/res/drawable/bg_round_corner_16_stroke_3bb9f1.xml new file mode 100644 index 00000000..8f31048c --- /dev/null +++ b/app/src/main/res/drawable/bg_round_corner_16_stroke_3bb9f1.xml @@ -0,0 +1,9 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_chevron_down.xml b/app/src/main/res/drawable/ic_chevron_down.xml new file mode 100644 index 00000000..54f20bb0 --- /dev/null +++ b/app/src/main/res/drawable/ic_chevron_down.xml @@ -0,0 +1,14 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_chevron_up.xml b/app/src/main/res/drawable/ic_chevron_up.xml new file mode 100644 index 00000000..0a798045 --- /dev/null +++ b/app/src/main/res/drawable/ic_chevron_up.xml @@ -0,0 +1,14 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_character_detail.xml b/app/src/main/res/layout/activity_character_detail.xml new file mode 100644 index 00000000..840d2fa9 --- /dev/null +++ b/app/src/main/res/layout/activity_character_detail.xml @@ -0,0 +1,389 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/item_other_character.xml b/app/src/main/res/layout/item_other_character.xml new file mode 100644 index 00000000..3e3ff8f8 --- /dev/null +++ b/app/src/main/res/layout/item_other_character.xml @@ -0,0 +1,38 @@ + + + + + + + + + + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index c5748ebb..f3b6a0ce 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -133,4 +133,6 @@ #7849BC #607D8B #B0BEC5 + #7C7C80 + #37474F