feat(creator): 시리즈 탭 상태 관리를 추가한다

This commit is contained in:
2026-06-20 02:53:24 +09:00
parent 185c92e9af
commit c25d4cd161
7 changed files with 826 additions and 0 deletions

View File

@@ -181,6 +181,7 @@ import kr.co.vividnext.sodalive.v2.creator.channel.audio.CreatorChannelAudioView
import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelApi
import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelRepository
import kr.co.vividnext.sodalive.v2.creator.channel.live.CreatorChannelLiveViewModel
import kr.co.vividnext.sodalive.v2.creator.channel.series.CreatorChannelSeriesViewModel
import kr.co.vividnext.sodalive.v2.main.MainV2ViewModel
import kr.co.vividnext.sodalive.v2.main.chat.ChatMainViewModel
import kr.co.vividnext.sodalive.v2.main.chat.data.ChatRoomApi
@@ -412,6 +413,7 @@ class AppDI(private val context: Context, isDebugMode: Boolean) {
viewModel { CreatorChannelHomeViewModel(get()) }
viewModel { CreatorChannelLiveViewModel(get()) }
viewModel { CreatorChannelAudioViewModel(get()) }
viewModel { CreatorChannelSeriesViewModel(get()) }
viewModel { PushNotificationListViewModel(get()) }
viewModel { CharacterTabViewModel(get()) }
viewModel { CharacterDetailViewModel(get()) }

View File

@@ -0,0 +1,175 @@
package kr.co.vividnext.sodalive.v2.creator.channel.series
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.ApiResponse
import kr.co.vividnext.sodalive.common.SharedPreferenceManager
import kr.co.vividnext.sodalive.v2.common.data.ContentSort
import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelRepository
import kr.co.vividnext.sodalive.v2.creator.channel.series.data.CreatorChannelSeriesTabResponse
import kr.co.vividnext.sodalive.v2.creator.channel.series.model.CreatorChannelSeriesItemUiModel
import kr.co.vividnext.sodalive.v2.creator.channel.series.model.toSeriesItemUiModels
class CreatorChannelSeriesViewModel(
private val repository: CreatorChannelRepository
) : BaseViewModel() {
private val _seriesStateLiveData = MutableLiveData<CreatorChannelSeriesUiState>()
val seriesStateLiveData: LiveData<CreatorChannelSeriesUiState>
get() = _seriesStateLiveData
private var creatorId: Long = 0L
private var isOwner: Boolean = false
private var selectedSort: ContentSort = ContentSort.LATEST
private var requestGeneration: Int = 0
fun loadSeries(creatorId: Long, isOwner: Boolean) {
if (creatorId <= 0) return
val shouldSkipReload = this.creatorId == creatorId && this.isOwner == isOwner && _seriesStateLiveData.value != null
if (shouldSkipReload) return
this.creatorId = creatorId
this.isOwner = isOwner
loadFirstPage(selectedSort)
}
fun changeSort(sort: ContentSort) {
if (sort == selectedSort) return
if (creatorId <= 0) return
selectedSort = sort
loadFirstPage(sort)
}
fun retrySeries() {
if (creatorId <= 0) return
loadFirstPage(selectedSort)
}
fun loadMore() {
val content = _seriesStateLiveData.value as? CreatorChannelSeriesUiState.Content ?: return
if (!content.hasNext || content.isLoadingMore || creatorId <= 0) return
val generation = requestGeneration
_seriesStateLiveData.value = content.copy(isLoadingMore = true, paginationErrorMessage = null)
requestSeries(page = content.page + 1, sort = content.selectedSort, generation = generation) { response ->
val data = response.data
val current = _seriesStateLiveData.value as? CreatorChannelSeriesUiState.Content ?: content
if (response.success && data != null) {
_seriesStateLiveData.value = current.copy(
series = current.series + data.series.toSeriesItemUiModels(isOwner),
page = data.page,
size = data.size,
hasNext = data.hasNext,
isLoadingMore = false
)
} else {
_seriesStateLiveData.value = current.copy(
isLoadingMore = false,
paginationErrorMessage = response.message
)
}
}
}
fun consumePaginationErrorMessage() {
val content = _seriesStateLiveData.value as? CreatorChannelSeriesUiState.Content ?: return
if (content.paginationErrorMessage == null) return
_seriesStateLiveData.value = content.copy(paginationErrorMessage = null)
}
private fun loadFirstPage(sort: ContentSort) {
val generation = ++requestGeneration
_seriesStateLiveData.value = CreatorChannelSeriesUiState.Loading
requestSeries(page = FIRST_PAGE, sort = sort, generation = generation) { response ->
val data = response.data
if (response.success && data != null) {
val series = data.series.toSeriesItemUiModels(isOwner)
_seriesStateLiveData.value = if (series.isEmpty() || data.seriesCount == 0) {
CreatorChannelSeriesUiState.Empty
} else {
data.toContentState(series = series)
}
} else {
_seriesStateLiveData.value = CreatorChannelSeriesUiState.Error(response.message)
}
}
}
private fun requestSeries(
page: Int,
sort: ContentSort,
generation: Int,
onSuccess: (ApiResponse<CreatorChannelSeriesTabResponse>) -> Unit
) {
compositeDisposable.add(
repository.getSeries(
creatorId = creatorId,
page = page,
size = DEFAULT_PAGE_SIZE,
sort = sort,
token = authToken()
)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{
if (generation == requestGeneration) {
onSuccess(it)
}
},
{
if (generation != requestGeneration) return@subscribe
it.message?.let { message -> Logger.e(message) }
val current = _seriesStateLiveData.value as? CreatorChannelSeriesUiState.Content
_seriesStateLiveData.value = if (current != null && page > FIRST_PAGE) {
current.copy(isLoadingMore = false, paginationErrorMessage = it.message)
} else {
CreatorChannelSeriesUiState.Error(it.message)
}
}
)
)
}
private fun CreatorChannelSeriesTabResponse.toContentState(
series: List<CreatorChannelSeriesItemUiModel>
) = CreatorChannelSeriesUiState.Content(
seriesCount = seriesCount,
selectedSort = sort,
series = series,
page = page,
size = size,
hasNext = hasNext
)
private fun authToken(): String = "Bearer ${SharedPreferenceManager.token}"
companion object {
val DEFAULT_PAGE_SIZE = 20
private const val FIRST_PAGE = 0
}
}
sealed interface CreatorChannelSeriesUiState {
data object Loading : CreatorChannelSeriesUiState
data object Empty : CreatorChannelSeriesUiState
data class Error(val message: String?) : CreatorChannelSeriesUiState
data class Content(
val seriesCount: Int,
val selectedSort: ContentSort,
val series: List<CreatorChannelSeriesItemUiModel>,
val page: Int,
val size: Int,
val hasNext: Boolean,
val isLoadingMore: Boolean = false,
val paginationErrorMessage: String? = null
) : CreatorChannelSeriesUiState
}

View File

@@ -0,0 +1,45 @@
package kr.co.vividnext.sodalive.v2.creator.channel.series.model
import kr.co.vividnext.sodalive.v2.creator.channel.series.data.CreatorChannelSeriesResponse
private const val BULLET_SEPARATOR = ""
private const val STATUS_PROCEEDING = "연재"
private const val STATUS_COMPLETED = "완결"
fun List<CreatorChannelSeriesResponse>.toSeriesItemUiModels(
isOwner: Boolean
): List<CreatorChannelSeriesItemUiModel> = mapNotNull { it.toSeriesItemUiModel(isOwner) }
private fun CreatorChannelSeriesResponse.toSeriesItemUiModel(isOwner: Boolean): CreatorChannelSeriesItemUiModel? {
if (title.isBlank()) return null
return CreatorChannelSeriesItemUiModel(
seriesId = seriesId,
title = title,
subtitle = subtitle(),
coverImageUrl = coverImageUrl,
showOriginalTag = isOriginal,
showAdultBadge = isAdult,
progress = toProgressUiModel(isOwner)
)
}
private fun CreatorChannelSeriesResponse.subtitle(): String = listOfNotNull(
publishedDaysOfWeek?.takeIf { it.isNotBlank() },
"${contentCount}",
if (isProceeding) STATUS_PROCEEDING else STATUS_COMPLETED
).joinToString(BULLET_SEPARATOR)
private fun CreatorChannelSeriesResponse.toProgressUiModel(isOwner: Boolean): CreatorChannelSeriesProgressUiModel? {
if (isOwner) return null
val purchasedCount = purchasedContentCount ?: return null
val paidCount = paidContentCount ?: return null
val ratePercent = purchasedPaidContentRate ?: return null
return CreatorChannelSeriesProgressUiModel(
purchasedCount = purchasedCount,
paidCount = paidCount,
ratePercent = ratePercent,
progressScale = (ratePercent / 100f).toFloat().coerceIn(0f, 1f)
)
}

View File

@@ -0,0 +1,18 @@
package kr.co.vividnext.sodalive.v2.creator.channel.series.model
data class CreatorChannelSeriesItemUiModel(
val seriesId: Long,
val title: String,
val subtitle: String,
val coverImageUrl: String?,
val showOriginalTag: Boolean,
val showAdultBadge: Boolean,
val progress: CreatorChannelSeriesProgressUiModel?
)
data class CreatorChannelSeriesProgressUiModel(
val purchasedCount: Int,
val paidCount: Int,
val ratePercent: Double,
val progressScale: Float
)