diff --git a/app/src/main/java/kr/co/vividnext/sodalive/di/AppDI.kt b/app/src/main/java/kr/co/vividnext/sodalive/di/AppDI.kt index fd3d5eea..95501001 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/di/AppDI.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/di/AppDI.kt @@ -187,6 +187,9 @@ import kr.co.vividnext.sodalive.v2.creator.channel.donation.CreatorChannelDonati import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.CreatorChannelFanTalkViewModel 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.live.onair.HomeOnAirLiveViewModel +import kr.co.vividnext.sodalive.v2.live.onair.data.HomeOnAirLiveApi +import kr.co.vividnext.sodalive.v2.live.onair.data.HomeOnAirLiveRepository 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 @@ -332,6 +335,7 @@ class AppDI(private val context: Context, isDebugMode: Boolean) { single { ApiBuilder().build(get(), HomeFollowingApi::class.java) } single { ApiBuilder().build(get(), HomeRecommendationApi::class.java) } single { ApiBuilder().build(get(), CreatorChannelApi::class.java) } + single { ApiBuilder().build(get(), HomeOnAirLiveApi::class.java) } single { ApiBuilder().build(get(), CharacterApi::class.java) } single { ApiBuilder().build(get(), TalkApi::class.java) } single { ApiBuilder().build(get(), CharacterCommentApi::class.java) } @@ -437,6 +441,7 @@ class AppDI(private val context: Context, isDebugMode: Boolean) { viewModel { HomeCreatorRankingViewModel(get()) } viewModel { HomeFollowingViewModel(get(), get()) } viewModel { HomeRecommendationViewModel(get()) } + viewModel { HomeOnAirLiveViewModel(get()) } viewModel { CreatorChannelHomeViewModel(get()) } viewModel { CreatorChannelLiveViewModel(get()) } viewModel { CreatorChannelAudioViewModel(get()) } @@ -502,6 +507,7 @@ class AppDI(private val context: Context, isDebugMode: Boolean) { factory { HomeCreatorRankingRepository(get()) } factory { HomeFollowingRepository(get()) } factory { HomeRecommendationRepository(get()) } + factory { HomeOnAirLiveRepository(get()) } factory { CreatorChannelRepository( api = get(), diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/live/onair/HomeOnAirLiveViewModel.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/live/onair/HomeOnAirLiveViewModel.kt new file mode 100644 index 00000000..c7774d81 --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/live/onair/HomeOnAirLiveViewModel.kt @@ -0,0 +1,140 @@ +package kr.co.vividnext.sodalive.v2.live.onair + +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.R +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.common.ToastMessage +import kr.co.vividnext.sodalive.v2.live.onair.data.HomeOnAirLivePageResponse +import kr.co.vividnext.sodalive.v2.live.onair.data.HomeOnAirLiveRepository +import kr.co.vividnext.sodalive.v2.live.onair.model.HomeOnAirLivePageUiState +import kr.co.vividnext.sodalive.v2.live.onair.model.homeOnAirLiveAuthHeader +import kr.co.vividnext.sodalive.v2.live.onair.model.toUiState + +class HomeOnAirLiveViewModel( + private val repository: HomeOnAirLiveRepository +) : BaseViewModel() { + + private val _onAirLiveStateLiveData = MutableLiveData() + val onAirLiveStateLiveData: LiveData + get() = _onAirLiveStateLiveData + + private val _isLoading = MutableLiveData(false) + val isLoading: LiveData + get() = _isLoading + + private val _toastLiveData = MutableLiveData() + val toastLiveData: LiveData + get() = _toastLiveData + + private var requestGeneration: Int = 0 + + fun loadFirstPage() { + val generation = ++requestGeneration + _isLoading.value = true + _onAirLiveStateLiveData.value = HomeOnAirLivePageUiState.Loading + requestOnAirLives(page = FIRST_PAGE, generation = generation) { response -> + _isLoading.value = false + val data = response.data + if (response.success && data != null) { + val state = data.toUiState() + _onAirLiveStateLiveData.value = if (state.items.isEmpty()) { + HomeOnAirLivePageUiState.Empty + } else { + HomeOnAirLivePageUiState.Content(state) + } + } else { + showFirstPageError(response.message) + } + } + } + + fun loadNextPage() { + val content = (_onAirLiveStateLiveData.value as? HomeOnAirLivePageUiState.Content)?.content ?: return + if (!content.hasNext || content.isLoadingMore) return + + val generation = requestGeneration + _onAirLiveStateLiveData.value = HomeOnAirLivePageUiState.Content( + content.copy(isLoadingMore = true, paginationErrorMessage = null) + ) + requestOnAirLives(page = content.page + 1, generation = generation) { response -> + val current = (_onAirLiveStateLiveData.value as? HomeOnAirLivePageUiState.Content)?.content ?: content + val data = response.data + if (response.success && data != null) { + val next = data.toUiState() + _onAirLiveStateLiveData.value = HomeOnAirLivePageUiState.Content( + current.copy( + items = current.items + next.items, + page = next.page, + size = next.size, + hasNext = next.hasNext, + isLoadingMore = false, + paginationErrorMessage = null + ) + ) + } else { + _onAirLiveStateLiveData.value = HomeOnAirLivePageUiState.Content( + current.copy(isLoadingMore = false, paginationErrorMessage = response.message) + ) + } + } + } + + fun consumePaginationErrorMessage() { + val content = (_onAirLiveStateLiveData.value as? HomeOnAirLivePageUiState.Content)?.content ?: return + if (content.paginationErrorMessage == null) return + + _onAirLiveStateLiveData.value = HomeOnAirLivePageUiState.Content( + content.copy(paginationErrorMessage = null) + ) + } + + private fun requestOnAirLives( + page: Int, + generation: Int, + onSuccess: (ApiResponse) -> Unit + ) { + compositeDisposable.add( + repository.getOnAirLives(authHeader = authHeader(), page = page) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { + if (generation == requestGeneration) { + onSuccess(it) + } + }, + { + if (generation != requestGeneration) return@subscribe + + it.message?.let { message -> Logger.e(message) } + _isLoading.value = false + val current = (_onAirLiveStateLiveData.value as? HomeOnAirLivePageUiState.Content)?.content + if (current != null && page > FIRST_PAGE) { + _onAirLiveStateLiveData.value = HomeOnAirLivePageUiState.Content( + current.copy(isLoadingMore = false, paginationErrorMessage = it.message) + ) + } else { + showFirstPageError(it.message) + } + } + ) + ) + } + + private fun showFirstPageError(message: String?) { + _onAirLiveStateLiveData.value = HomeOnAirLivePageUiState.Error(message) + _toastLiveData.value = ToastMessage(resId = R.string.common_error_unknown) + } + + private fun authHeader(): String? = homeOnAirLiveAuthHeader(SharedPreferenceManager.token) + + companion object { + private const val FIRST_PAGE = 0 + } +} diff --git a/app/src/test/java/kr/co/vividnext/sodalive/v2/live/onair/HomeOnAirLiveViewModelTest.kt b/app/src/test/java/kr/co/vividnext/sodalive/v2/live/onair/HomeOnAirLiveViewModelTest.kt new file mode 100644 index 00000000..72698eff --- /dev/null +++ b/app/src/test/java/kr/co/vividnext/sodalive/v2/live/onair/HomeOnAirLiveViewModelTest.kt @@ -0,0 +1,141 @@ +package kr.co.vividnext.sodalive.v2.live.onair + +import android.app.Application +import android.content.Context +import androidx.lifecycle.LiveData +import androidx.lifecycle.Observer +import androidx.test.core.app.ApplicationProvider +import io.reactivex.rxjava3.android.plugins.RxAndroidPlugins +import io.reactivex.rxjava3.core.Scheduler +import io.reactivex.rxjava3.core.Single +import io.reactivex.rxjava3.plugins.RxJavaPlugins +import io.reactivex.rxjava3.schedulers.Schedulers +import kr.co.vividnext.sodalive.common.ApiResponse +import kr.co.vividnext.sodalive.common.SharedPreferenceManager +import kr.co.vividnext.sodalive.v2.live.onair.data.HomeOnAirLivePageResponse +import kr.co.vividnext.sodalive.v2.live.onair.data.HomeOnAirLiveRepository +import kr.co.vividnext.sodalive.v2.live.onair.data.HomeOnAirLiveResponse +import kr.co.vividnext.sodalive.v2.live.onair.model.HomeOnAirLivePageUiState +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], application = Application::class) +class HomeOnAirLiveViewModelTest { + + private val context: Context = ApplicationProvider.getApplicationContext() + private lateinit var repository: HomeOnAirLiveRepository + private lateinit var viewModel: HomeOnAirLiveViewModel + + @Before + fun setUp() { + setImmediateRxSchedulers() + SharedPreferenceManager.resetForTest() + SharedPreferenceManager.init(context) + SharedPreferenceManager.token = "test-token" + repository = org.mockito.kotlin.mock() + viewModel = HomeOnAirLiveViewModel(repository) + } + + @After + fun tearDown() { + RxJavaPlugins.reset() + RxAndroidPlugins.reset() + SharedPreferenceManager.resetForTest() + } + + @Test + fun `최초 로드는 page 0을 호출하고 Content를 emit한다`() { + stubGetOnAirLives(page = 0, response = Single.just(ApiResponse(true, pageResponse(page = 0), null))) + + viewModel.loadFirstPage() + + val state = viewModel.onAirLiveStateLiveData.requireValue() as HomeOnAirLivePageUiState.Content + assertEquals(0, state.content.page) + assertEquals(listOf(1L), state.content.items.map { it.roomId }) + verify(repository).getOnAirLives("Bearer test-token", 0) + } + + @Test + fun `다음 페이지 로드는 기존 items에 추가하고 page를 갱신한다`() { + stubGetOnAirLives( + page = 0, + response = Single.just(ApiResponse(true, pageResponse(page = 0, ids = listOf(1L), hasNext = true), null)) + ) + stubGetOnAirLives( + page = 1, + response = Single.just(ApiResponse(true, pageResponse(page = 1, ids = listOf(2L), hasNext = false), null)) + ) + viewModel.loadFirstPage() + + viewModel.loadNextPage() + + val state = viewModel.onAirLiveStateLiveData.requireValue() as HomeOnAirLivePageUiState.Content + assertEquals(1, state.content.page) + assertEquals(listOf(1L, 2L), state.content.items.map { it.roomId }) + assertEquals(false, state.content.hasNext) + } + + @Test + fun `hasNext가 false이면 다음 페이지를 호출하지 않는다`() { + stubGetOnAirLives( + page = 0, + response = Single.just(ApiResponse(true, pageResponse(page = 0, hasNext = false), null)) + ) + viewModel.loadFirstPage() + + viewModel.loadNextPage() + + verify(repository, never()).getOnAirLives("Bearer test-token", 1) + } + + private fun stubGetOnAirLives( + page: Int, + response: Single> + ) { + whenever(repository.getOnAirLives("Bearer test-token", page)).thenReturn(response) + } + + private fun pageResponse( + page: Int, + ids: List = listOf(1L), + hasNext: Boolean = false + ) = HomeOnAirLivePageResponse( + items = ids.map { live(it) }, + page = page, + size = 20, + hasNext = hasNext + ) + + private fun live(roomId: Long) = HomeOnAirLiveResponse( + roomId = roomId, + creatorNickname = "크리에이터 $roomId", + creatorProfileImage = "https://example.com/profile-$roomId.png", + title = "라이브 $roomId", + price = 0, + beginDateTimeUtc = "2026-06-26T12:00:00Z" + ) + + private fun setImmediateRxSchedulers() { + val trampoline = { _: Scheduler -> Schedulers.trampoline() } + RxJavaPlugins.setIoSchedulerHandler(trampoline) + RxAndroidPlugins.setInitMainThreadSchedulerHandler { Schedulers.trampoline() } + RxAndroidPlugins.setMainThreadSchedulerHandler { Schedulers.trampoline() } + } + + private fun LiveData.requireValue(): T? { + var value: T? = null + val observer = Observer { value = it } + observeForever(observer) + removeObserver(observer) + return value + } +}