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