feat(chat-character): 캐릭터 상세 페이지 API 연동 및 UI 상태 처리
- CharacterApi에 캐릭터 상세 조회 엔드포인트 추가 - CharacterDetailRepository 생성 및 Koin DI 등록 - CharacterDetailViewModel에서 실제 API 호출/로딩/에러 상태 관리 - CharacterDetailActivity에서 loadMock 제거 후 load 호출, Koin 주입으로 전환 - 로딩 다이얼로그 및 에러 토스트 처리 로직 추가
This commit is contained in:
		@@ -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
 | 
			
		||||
 
 | 
			
		||||
@@ -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) }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -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>>
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -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,
 | 
			
		||||
 
 | 
			
		||||
@@ -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 {
 | 
			
		||||
 
 | 
			
		||||
@@ -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
 | 
			
		||||
 
 | 
			
		||||
@@ -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(
 | 
			
		||||
 
 | 
			
		||||
@@ -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)
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -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)
 | 
			
		||||
}
 | 
			
		||||
@@ -21,9 +21,9 @@ data class CharacterDetailResponse(
 | 
			
		||||
 | 
			
		||||
@Keep
 | 
			
		||||
enum class CharacterType {
 | 
			
		||||
    @SerializedName("CLONE")
 | 
			
		||||
    @SerializedName("Clone")
 | 
			
		||||
    CLONE,
 | 
			
		||||
    @SerializedName("CHARACTER")
 | 
			
		||||
    @SerializedName("Character")
 | 
			
		||||
    CHARACTER
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -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)
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -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()) }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user