feat(chat-character): 캐릭터 상세 페이지 API 연동 및 UI 상태 처리

- CharacterApi에 캐릭터 상세 조회 엔드포인트 추가
- CharacterDetailRepository 생성 및 Koin DI 등록
- CharacterDetailViewModel에서 실제 API 호출/로딩/에러 상태 관리
- CharacterDetailActivity에서 loadMock 제거 후 load 호출, Koin 주입으로 전환
- 로딩 다이얼로그 및 에러 토스트 처리 로직 추가
This commit is contained in:
2025-08-13 00:50:33 +09:00
parent ff1e134fe4
commit 0c3bca0f9e
12 changed files with 91 additions and 65 deletions

View File

@@ -5,7 +5,7 @@ import com.google.gson.annotations.SerializedName
@Keep @Keep
data class Character( data class Character(
@SerializedName("id") val id: String, @SerializedName("characterId") val characterId: Long,
@SerializedName("name") val name: String, @SerializedName("name") val name: String,
@SerializedName("description") val description: String, @SerializedName("description") val description: String,
@SerializedName("imageUrl") val imageUrl: String @SerializedName("imageUrl") val imageUrl: String

View File

@@ -15,7 +15,7 @@ import kr.co.vividnext.sodalive.extensions.dpToPx
class CharacterAdapter( class CharacterAdapter(
private var characters: List<Character> = emptyList(), private var characters: List<Character> = emptyList(),
private val showRanking: Boolean = false, private val showRanking: Boolean = false,
private val onCharacterClick: (Character) -> Unit = {} private val onCharacterClick: (Long) -> Unit = {}
) : RecyclerView.Adapter<CharacterAdapter.ViewHolder>() { ) : RecyclerView.Adapter<CharacterAdapter.ViewHolder>() {
inner class ViewHolder( inner class ViewHolder(
@@ -46,7 +46,7 @@ class CharacterAdapter(
) )
.into(binding.ivCharacter) .into(binding.ivCharacter)
binding.root.setOnClickListener { onCharacterClick(character) } binding.root.setOnClickListener { onCharacterClick(character.characterId) }
} }
} }

View File

@@ -1,13 +1,21 @@
package kr.co.vividnext.sodalive.chat.character package kr.co.vividnext.sodalive.chat.character
import io.reactivex.rxjava3.core.Single import io.reactivex.rxjava3.core.Single
import kr.co.vividnext.sodalive.chat.character.detail.CharacterDetailResponse
import kr.co.vividnext.sodalive.common.ApiResponse import kr.co.vividnext.sodalive.common.ApiResponse
import retrofit2.http.GET import retrofit2.http.GET
import retrofit2.http.Header import retrofit2.http.Header
import retrofit2.http.Path
interface CharacterApi { interface CharacterApi {
@GET("/api/chat/character/main") @GET("/api/chat/character/main")
fun getCharacterMain( fun getCharacterMain(
@Header("Authorization") authHeader: String @Header("Authorization") authHeader: String
): Single<ApiResponse<CharacterHomeResponse>> ): Single<ApiResponse<CharacterHomeResponse>>
@GET("/api/chat/character/{characterId}")
fun getCharacterDetail(
@Header("Authorization") authHeader: String,
@Path("characterId") characterId: Long
): Single<ApiResponse<CharacterDetailResponse>>
} }

View File

@@ -11,7 +11,6 @@ import com.bumptech.glide.request.transition.Transition
import com.zhpan.bannerview.BaseBannerAdapter import com.zhpan.bannerview.BaseBannerAdapter
import com.zhpan.bannerview.BaseViewHolder import com.zhpan.bannerview.BaseViewHolder
import kr.co.vividnext.sodalive.R import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.audio_content.main.GetAudioContentBannerResponse
class CharacterBannerAdapter( class CharacterBannerAdapter(
private val context: Context, private val context: Context,

View File

@@ -371,11 +371,11 @@ class CharacterTabFragment : BaseFragment<FragmentCharacterTabBinding>(
// TODO: 최근 대화한 캐릭터 클릭 처리 // TODO: 최근 대화한 캐릭터 클릭 처리
} }
private fun onCharacterClick(character: Character) { private fun onCharacterClick(characterId: Long) {
if (SharedPreferenceManager.token.isNotBlank()) { if (SharedPreferenceManager.token.isNotBlank()) {
startActivity( startActivity(
Intent(requireContext(), CharacterDetailActivity::class.java).apply { Intent(requireContext(), CharacterDetailActivity::class.java).apply {
putExtra(EXTRA_CHARACTER_ID, character.id) putExtra(EXTRA_CHARACTER_ID, characterId)
} }
) )
} else { } else {

View File

@@ -5,7 +5,6 @@ import androidx.lifecycle.MutableLiveData
import com.orhanobut.logger.Logger import com.orhanobut.logger.Logger
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.schedulers.Schedulers import io.reactivex.rxjava3.schedulers.Schedulers
import kr.co.vividnext.sodalive.audio_content.main.GetAudioContentBannerResponse
import kr.co.vividnext.sodalive.base.BaseViewModel import kr.co.vividnext.sodalive.base.BaseViewModel
import kr.co.vividnext.sodalive.chat.character.curation.CurationSection import kr.co.vividnext.sodalive.chat.character.curation.CurationSection
import kr.co.vividnext.sodalive.chat.character.recent.RecentCharacter import kr.co.vividnext.sodalive.chat.character.recent.RecentCharacter

View File

@@ -8,14 +8,13 @@ import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import kr.co.vividnext.sodalive.chat.character.Character
import kr.co.vividnext.sodalive.chat.character.CharacterAdapter import kr.co.vividnext.sodalive.chat.character.CharacterAdapter
import kr.co.vividnext.sodalive.databinding.ItemCurationSectionBinding import kr.co.vividnext.sodalive.databinding.ItemCurationSectionBinding
import kr.co.vividnext.sodalive.extensions.dpToPx import kr.co.vividnext.sodalive.extensions.dpToPx
class CurationSectionAdapter( class CurationSectionAdapter(
private var sections: List<CurationSection> = emptyList(), private var sections: List<CurationSection> = emptyList(),
private val onCharacterClick: (Character) -> Unit = {} private val onCharacterClick: (Long) -> Unit = {}
) : RecyclerView.Adapter<CurationSectionAdapter.ViewHolder>() { ) : RecyclerView.Adapter<CurationSectionAdapter.ViewHolder>() {
inner class ViewHolder( inner class ViewHolder(

View File

@@ -5,20 +5,23 @@ import android.graphics.Rect
import android.os.Bundle import android.os.Bundle
import android.text.TextUtils import android.text.TextUtils
import android.view.View import android.view.View
import androidx.activity.viewModels
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import coil.load import coil.load
import kr.co.vividnext.sodalive.R import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.base.BaseActivity import kr.co.vividnext.sodalive.base.BaseActivity
import kr.co.vividnext.sodalive.common.LoadingDialog
import kr.co.vividnext.sodalive.databinding.ActivityCharacterDetailBinding import kr.co.vividnext.sodalive.databinding.ActivityCharacterDetailBinding
import kr.co.vividnext.sodalive.extensions.dpToPx import kr.co.vividnext.sodalive.extensions.dpToPx
import org.koin.androidx.viewmodel.ext.android.viewModel
class CharacterDetailActivity : BaseActivity<ActivityCharacterDetailBinding>( class CharacterDetailActivity : BaseActivity<ActivityCharacterDetailBinding>(
ActivityCharacterDetailBinding::inflate ActivityCharacterDetailBinding::inflate
) { ) {
private val viewModel: CharacterDetailViewModel by viewModels() private val viewModel: CharacterDetailViewModel by viewModel()
private lateinit var loadingDialog: LoadingDialog
private val adapter by lazy { private val adapter by lazy {
OtherCharacterAdapter( OtherCharacterAdapter(
@@ -43,13 +46,15 @@ class CharacterDetailActivity : BaseActivity<ActivityCharacterDetailBinding>(
if (characterId <= 0) { if (characterId <= 0) {
showToast("잘못된 접근 입니다.") showToast("잘못된 접근 입니다.")
finish() finish()
} } else {
viewModel.loadMock(characterId)
bindObservers() bindObservers()
viewModel.load(characterId)
}
} }
override fun setupView() { override fun setupView() {
loadingDialog = LoadingDialog(this, layoutInflater)
// 뒤로 가기 // 뒤로 가기
binding.detailToolbar.tvBack.setOnClickListener { finish() } binding.detailToolbar.tvBack.setOnClickListener { finish() }
@@ -103,6 +108,21 @@ class CharacterDetailActivity : BaseActivity<ActivityCharacterDetailBinding>(
private fun bindObservers() { private fun bindObservers() {
viewModel.uiState.observe(this) { state -> viewModel.uiState.observe(this) { state ->
// 1) 로딩 상태 처리
if (state.isLoading) {
loadingDialog.show(screenWidth)
} else {
loadingDialog.dismiss()
}
// 2) 에러 토스트 처리
state.error?.let { errorMsg ->
if (errorMsg.isNotBlank()) {
showToast(errorMsg)
}
}
// 3) 상세 데이터가 있을 경우에만 기존 UI 바인딩 수행
val detail = state.detail ?: return@observe val detail = state.detail ?: return@observe
// 배경 이미지 // 배경 이미지
@@ -157,9 +177,14 @@ class CharacterDetailActivity : BaseActivity<ActivityCharacterDetailBinding>(
} }
// 다른 캐릭터 리스트 // 다른 캐릭터 리스트
if (detail.others.isEmpty()) {
binding.llOtherCharactersSection.visibility = View.GONE
} else {
binding.llOtherCharactersSection.visibility = View.VISIBLE
adapter.submitList(detail.others) adapter.submitList(detail.others)
} }
} }
}
private fun toggleWorldviewExpand() { private fun toggleWorldviewExpand() {
isWorldviewExpanded = !isWorldviewExpanded isWorldviewExpanded = !isWorldviewExpanded

View File

@@ -0,0 +1,8 @@
package kr.co.vividnext.sodalive.chat.character.detail
import kr.co.vividnext.sodalive.chat.character.CharacterApi
class CharacterDetailRepository(private val api: CharacterApi) {
fun getCharacterDetail(token: String, characterId: Long) =
api.getCharacterDetail(authHeader = token, characterId = characterId)
}

View File

@@ -21,9 +21,9 @@ data class CharacterDetailResponse(
@Keep @Keep
enum class CharacterType { enum class CharacterType {
@SerializedName("CLONE") @SerializedName("Clone")
CLONE, CLONE,
@SerializedName("CHARACTER") @SerializedName("Character")
CHARACTER CHARACTER
} }

View File

@@ -2,7 +2,11 @@ package kr.co.vividnext.sodalive.chat.character.detail
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import com.orhanobut.logger.Logger
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.schedulers.Schedulers
import kr.co.vividnext.sodalive.base.BaseViewModel import kr.co.vividnext.sodalive.base.BaseViewModel
import kr.co.vividnext.sodalive.common.SharedPreferenceManager
/** /**
* 캐릭터 상세 화면에서 사용하는 ViewModel. * 캐릭터 상세 화면에서 사용하는 ViewModel.
@@ -13,61 +17,41 @@ import kr.co.vividnext.sodalive.base.BaseViewModel
* - 원작 섹션 (빈 값이면 UI에서 숨김) * - 원작 섹션 (빈 값이면 UI에서 숨김)
* - 다른 캐릭터 목록 (이미지, 캐릭터 명, 태그) * - 다른 캐릭터 목록 (이미지, 캐릭터 명, 태그)
*/ */
class CharacterDetailViewModel : BaseViewModel() { class CharacterDetailViewModel(
// UiState를 CharacterDetailResponse 구조에 맞게 변경 private val repository: CharacterDetailRepository
) : BaseViewModel() {
data class UiState( data class UiState(
val detail: CharacterDetailResponse? = null val detail: CharacterDetailResponse? = null,
val isLoading: Boolean = false,
val error: String? = null
) )
private val _uiState = MutableLiveData(UiState()) private val _uiState = MutableLiveData(UiState())
val uiState: LiveData<UiState> get() = _uiState val uiState: LiveData<UiState> get() = _uiState
fun loadMock(characterId: Long) { fun load(characterId: Long) {
// TODO: Repository 연동 예정. 현재는 더미 데이터로 표시 _uiState.value = _uiState.value?.copy(isLoading = true, error = null)
val demoWorldview = CharacterBackgroundResponse( val token = "Bearer ${SharedPreferenceManager.token}"
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( compositeDisposable.add(
characterId = characterId, repository.getCharacterDetail(token = token, characterId = characterId)
name = "리엘라", .subscribeOn(Schedulers.io())
description = "꽃을 키우는 공작가의 상속자", .observeOn(AndroidSchedulers.mainThread())
mbti = "ENFP", .subscribe(
imageUrl = "https://picsum.photos/seed/bg1/1000/1000", { response ->
personalities = demoPersonality, val success = response.success
backgrounds = demoWorldview, val data = response.data
tags = "#커버곡 #라이브 #연애 #썸 #채팅 #라방", if (success && data != null) {
originalTitle = "네이버 시리즈 독 안에 든 선생님", _uiState.value = UiState(detail = data, isLoading = false, error = null)
originalLink = "https://series.naver.com/", } else {
characterType = CharacterType.CLONE, _uiState.value = UiState(detail = null, isLoading = false, error = response.message ?: "알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.")
others = others }
},
{ throwable ->
Logger.e(throwable, throwable.message ?: "")
_uiState.value = UiState(detail = null, isLoading = false, error = "알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.")
}
)
) )
_uiState.value = UiState(detail = response)
} }
} }

View File

@@ -67,6 +67,8 @@ import kr.co.vividnext.sodalive.audition.role.AuditionRoleDetailViewModel
import kr.co.vividnext.sodalive.chat.character.CharacterApi import kr.co.vividnext.sodalive.chat.character.CharacterApi
import kr.co.vividnext.sodalive.chat.character.CharacterTabRepository import kr.co.vividnext.sodalive.chat.character.CharacterTabRepository
import kr.co.vividnext.sodalive.chat.character.CharacterTabViewModel import kr.co.vividnext.sodalive.chat.character.CharacterTabViewModel
import kr.co.vividnext.sodalive.chat.character.detail.CharacterDetailRepository
import kr.co.vividnext.sodalive.chat.character.detail.CharacterDetailViewModel
import kr.co.vividnext.sodalive.chat.talk.TalkApi import kr.co.vividnext.sodalive.chat.talk.TalkApi
import kr.co.vividnext.sodalive.chat.talk.TalkTabRepository import kr.co.vividnext.sodalive.chat.talk.TalkTabRepository
import kr.co.vividnext.sodalive.chat.talk.TalkTabViewModel import kr.co.vividnext.sodalive.chat.talk.TalkTabViewModel
@@ -351,6 +353,7 @@ class AppDI(private val context: Context, isDebugMode: Boolean) {
viewModel { PointStatusViewModel(get()) } viewModel { PointStatusViewModel(get()) }
viewModel { HomeViewModel(get(), get()) } viewModel { HomeViewModel(get(), get()) }
viewModel { CharacterTabViewModel(get()) } viewModel { CharacterTabViewModel(get()) }
viewModel { CharacterDetailViewModel(get()) }
viewModel { TalkTabViewModel(get()) } viewModel { TalkTabViewModel(get()) }
} }
@@ -397,6 +400,7 @@ class AppDI(private val context: Context, isDebugMode: Boolean) {
factory { PointStatusRepository(get()) } factory { PointStatusRepository(get()) }
factory { HomeRepository(get()) } factory { HomeRepository(get()) }
factory { CharacterTabRepository(get()) } factory { CharacterTabRepository(get()) }
factory { CharacterDetailRepository(get()) }
factory { TalkTabRepository(get()) } factory { TalkTabRepository(get()) }
} }