feat(character detail): 캐릭터 상세 페이지 UI 추가
This commit is contained in:
@@ -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>(
|
||||
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"
|
||||
}
|
||||
}
|
||||
@@ -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<OtherCharacter>
|
||||
)
|
||||
|
||||
@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
|
||||
)
|
||||
@@ -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<UiState> 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)
|
||||
}
|
||||
}
|
||||
@@ -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<OtherCharacter> = emptyList(),
|
||||
private val onItemClick: ((OtherCharacter) -> Unit)? = null
|
||||
) : RecyclerView.Adapter<OtherCharacterAdapter.OtherCharacterViewHolder>() {
|
||||
|
||||
@SuppressLint("NotifyDataSetChanged")
|
||||
fun submitList(newItems: List<OtherCharacter>) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user