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
data class Character(
@SerializedName("id") val id: String,
@SerializedName("characterId") val characterId: Long,
@SerializedName("name") val name: String,
@SerializedName("description") val description: String,
@SerializedName("imageUrl") val imageUrl: String

View File

@@ -15,7 +15,7 @@ import kr.co.vividnext.sodalive.extensions.dpToPx
class CharacterAdapter(
private var characters: List<Character> = emptyList(),
private val showRanking: Boolean = false,
private val onCharacterClick: (Character) -> Unit = {}
private val onCharacterClick: (Long) -> Unit = {}
) : RecyclerView.Adapter<CharacterAdapter.ViewHolder>() {
inner class ViewHolder(
@@ -46,7 +46,7 @@ class CharacterAdapter(
)
.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
import io.reactivex.rxjava3.core.Single
import kr.co.vividnext.sodalive.chat.character.detail.CharacterDetailResponse
import kr.co.vividnext.sodalive.common.ApiResponse
import retrofit2.http.GET
import retrofit2.http.Header
import retrofit2.http.Path
interface CharacterApi {
@GET("/api/chat/character/main")
fun getCharacterMain(
@Header("Authorization") authHeader: String
): 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.BaseViewHolder
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.audio_content.main.GetAudioContentBannerResponse
class CharacterBannerAdapter(
private val context: Context,

View File

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

View File

@@ -5,7 +5,6 @@ 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.audio_content.main.GetAudioContentBannerResponse
import kr.co.vividnext.sodalive.base.BaseViewModel
import kr.co.vividnext.sodalive.chat.character.curation.CurationSection
import kr.co.vividnext.sodalive.chat.character.recent.RecentCharacter

View File

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

View File

@@ -5,20 +5,23 @@ 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.common.LoadingDialog
import kr.co.vividnext.sodalive.databinding.ActivityCharacterDetailBinding
import kr.co.vividnext.sodalive.extensions.dpToPx
import org.koin.androidx.viewmodel.ext.android.viewModel
class CharacterDetailActivity : BaseActivity<ActivityCharacterDetailBinding>(
ActivityCharacterDetailBinding::inflate
) {
private val viewModel: CharacterDetailViewModel by viewModels()
private val viewModel: CharacterDetailViewModel by viewModel()
private lateinit var loadingDialog: LoadingDialog
private val adapter by lazy {
OtherCharacterAdapter(
@@ -43,13 +46,15 @@ class CharacterDetailActivity : BaseActivity<ActivityCharacterDetailBinding>(
if (characterId <= 0) {
showToast("잘못된 접근 입니다.")
finish()
} else {
bindObservers()
viewModel.load(characterId)
}
viewModel.loadMock(characterId)
bindObservers()
}
override fun setupView() {
loadingDialog = LoadingDialog(this, layoutInflater)
// 뒤로 가기
binding.detailToolbar.tvBack.setOnClickListener { finish() }
@@ -103,6 +108,21 @@ class CharacterDetailActivity : BaseActivity<ActivityCharacterDetailBinding>(
private fun bindObservers() {
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
// 배경 이미지
@@ -157,7 +177,12 @@ class CharacterDetailActivity : BaseActivity<ActivityCharacterDetailBinding>(
}
// 다른 캐릭터 리스트
adapter.submitList(detail.others)
if (detail.others.isEmpty()) {
binding.llOtherCharactersSection.visibility = View.GONE
} else {
binding.llOtherCharactersSection.visibility = View.VISIBLE
adapter.submitList(detail.others)
}
}
}

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
enum class CharacterType {
@SerializedName("CLONE")
@SerializedName("Clone")
CLONE,
@SerializedName("CHARACTER")
@SerializedName("Character")
CHARACTER
}

View File

@@ -2,7 +2,11 @@ package kr.co.vividnext.sodalive.chat.character.detail
import androidx.lifecycle.LiveData
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.common.SharedPreferenceManager
/**
* 캐릭터 상세 화면에서 사용하는 ViewModel.
@@ -13,61 +17,41 @@ import kr.co.vividnext.sodalive.base.BaseViewModel
* - 원작 섹션 (빈 값이면 UI에서 숨김)
* - 다른 캐릭터 목록 (이미지, 캐릭터 명, 태그)
*/
class CharacterDetailViewModel : BaseViewModel() {
// UiState를 CharacterDetailResponse 구조에 맞게 변경
class CharacterDetailViewModel(
private val repository: CharacterDetailRepository
) : BaseViewModel() {
data class UiState(
val detail: CharacterDetailResponse? = null
val detail: CharacterDetailResponse? = null,
val isLoading: Boolean = false,
val error: String? = 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 = "#암살자 #쿨 #시크"
)
)
fun load(characterId: Long) {
_uiState.value = _uiState.value?.copy(isLoading = true, error = null)
val token = "Bearer ${SharedPreferenceManager.token}"
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
compositeDisposable.add(
repository.getCharacterDetail(token = token, characterId = characterId)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{ response ->
val success = response.success
val data = response.data
if (success && data != null) {
_uiState.value = UiState(detail = data, isLoading = false, error = null)
} else {
_uiState.value = UiState(detail = null, isLoading = false, error = response.message ?: "알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.")
}
},
{ 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.CharacterTabRepository
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.TalkTabRepository
import kr.co.vividnext.sodalive.chat.talk.TalkTabViewModel
@@ -351,6 +353,7 @@ class AppDI(private val context: Context, isDebugMode: Boolean) {
viewModel { PointStatusViewModel(get()) }
viewModel { HomeViewModel(get(), get()) }
viewModel { CharacterTabViewModel(get()) }
viewModel { CharacterDetailViewModel(get()) }
viewModel { TalkTabViewModel(get()) }
}
@@ -397,6 +400,7 @@ class AppDI(private val context: Context, isDebugMode: Boolean) {
factory { PointStatusRepository(get()) }
factory { HomeRepository(get()) }
factory { CharacterTabRepository(get()) }
factory { CharacterDetailRepository(get()) }
factory { TalkTabRepository(get()) }
}