feat(main): 메인 탭 당겨서 새로고침을 추가한다
This commit is contained in:
@@ -48,6 +48,9 @@ class ChatMainFragment : BaseFragment<FragmentV2MainChatBinding>(
|
||||
private val chatRoomListAdapter = ChatRoomListAdapter { onChatRoomClick(it) }
|
||||
private lateinit var loadingDialog: LoadingDialog
|
||||
private var selectedFilter: ChatRoomFilter = ChatRoomFilter.ALL
|
||||
private var isChatPullRefreshing = false
|
||||
private var refreshingChatFilter: ChatRoomFilter? = null
|
||||
private var lastRenderedChatContentState: ChatRoomListUiState.Content? = null
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
@@ -58,12 +61,16 @@ class ChatMainFragment : BaseFragment<FragmentV2MainChatBinding>(
|
||||
selectedFilter = initialFilter
|
||||
setupFilterTabs(initialFilter)
|
||||
setupChatRooms()
|
||||
setupRefresh()
|
||||
bindViewModel()
|
||||
|
||||
viewModel.loadFirstPage(initialFilter)
|
||||
}
|
||||
|
||||
fun selectFilter(filter: ChatRoomFilter) {
|
||||
if (refreshingChatFilter != null && refreshingChatFilter != filter) {
|
||||
cancelChatPullRefresh()
|
||||
}
|
||||
selectedFilter = filter
|
||||
selectFilterTab(filter)
|
||||
viewModel.selectFilter(filter)
|
||||
@@ -108,6 +115,9 @@ class ChatMainFragment : BaseFragment<FragmentV2MainChatBinding>(
|
||||
)
|
||||
binding.viewChatFilterTabs.root.setOnTabSelectedListener { index ->
|
||||
val filter = ChatRoomFilter.fromTabIndex(index)
|
||||
if (refreshingChatFilter != null && refreshingChatFilter != filter) {
|
||||
cancelChatPullRefresh()
|
||||
}
|
||||
selectedFilter = filter
|
||||
viewModel.selectFilter(filter)
|
||||
binding.rvChatRooms.scrollToPosition(0)
|
||||
@@ -133,18 +143,60 @@ class ChatMainFragment : BaseFragment<FragmentV2MainChatBinding>(
|
||||
})
|
||||
}
|
||||
|
||||
private fun setupRefresh() {
|
||||
binding.swipeChatRooms.setOnChildScrollUpCallback { _, _ ->
|
||||
binding.rvChatRooms.canScrollVertically(-1)
|
||||
}
|
||||
binding.swipeChatRooms.setOnRefreshListener {
|
||||
isChatPullRefreshing = true
|
||||
refreshingChatFilter = selectedFilter
|
||||
viewModel.loadFirstPage(selectedFilter)
|
||||
}
|
||||
}
|
||||
|
||||
private fun bindViewModel() {
|
||||
viewModel.chatRoomStateLiveData.observe(viewLifecycleOwner) { state ->
|
||||
val isRefreshForCurrentFilter = refreshingChatFilter == selectedFilter
|
||||
when (state) {
|
||||
is ChatRoomListUiState.Content -> {
|
||||
bindChatRooms(state.items, showEmpty = false)
|
||||
if (!state.isAppending) {
|
||||
val isRestoredPullRefreshError = isChatPullRefreshing &&
|
||||
isRefreshForCurrentFilter &&
|
||||
lastRenderedChatContentState === state
|
||||
val shouldScrollToTop = !isRestoredPullRefreshError &&
|
||||
((isChatPullRefreshing && isRefreshForCurrentFilter) || !state.isAppending)
|
||||
lastRenderedChatContentState = state
|
||||
if (isRefreshForCurrentFilter) {
|
||||
isChatPullRefreshing = false
|
||||
refreshingChatFilter = null
|
||||
}
|
||||
if (shouldScrollToTop) {
|
||||
binding.rvChatRooms.scrollToPosition(0)
|
||||
}
|
||||
}
|
||||
ChatRoomListUiState.Empty -> bindChatRooms(emptyList(), showEmpty = true)
|
||||
is ChatRoomListUiState.Error -> bindChatRooms(emptyList(), showEmpty = false)
|
||||
ChatRoomListUiState.Loading -> binding.tvChatEmptyMessage.isVisible = false
|
||||
ChatRoomListUiState.Empty -> {
|
||||
lastRenderedChatContentState = null
|
||||
bindChatRooms(emptyList(), showEmpty = true)
|
||||
if (isRefreshForCurrentFilter) {
|
||||
isChatPullRefreshing = false
|
||||
refreshingChatFilter = null
|
||||
}
|
||||
}
|
||||
is ChatRoomListUiState.Error -> {
|
||||
lastRenderedChatContentState = null
|
||||
if (!isChatPullRefreshing || !isRefreshForCurrentFilter) {
|
||||
bindChatRooms(emptyList(), showEmpty = false)
|
||||
}
|
||||
if (isRefreshForCurrentFilter) {
|
||||
isChatPullRefreshing = false
|
||||
refreshingChatFilter = null
|
||||
}
|
||||
}
|
||||
ChatRoomListUiState.Loading -> {
|
||||
if (!isChatPullRefreshing) {
|
||||
binding.tvChatEmptyMessage.isVisible = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
viewModel.isLoading.observe(viewLifecycleOwner) {
|
||||
@@ -152,6 +204,7 @@ class ChatMainFragment : BaseFragment<FragmentV2MainChatBinding>(
|
||||
loadingDialog.show(screenWidth)
|
||||
} else {
|
||||
loadingDialog.dismiss()
|
||||
binding.swipeChatRooms.isRefreshing = false
|
||||
}
|
||||
}
|
||||
viewModel.toastLiveData.observe(viewLifecycleOwner) {
|
||||
@@ -162,6 +215,12 @@ class ChatMainFragment : BaseFragment<FragmentV2MainChatBinding>(
|
||||
}
|
||||
}
|
||||
|
||||
private fun cancelChatPullRefresh() {
|
||||
isChatPullRefreshing = false
|
||||
refreshingChatFilter = null
|
||||
binding.swipeChatRooms.isRefreshing = false
|
||||
}
|
||||
|
||||
private fun bindChatRooms(items: List<ChatRoomListUiItem>, showEmpty: Boolean) {
|
||||
chatRoomListAdapter.submitItems(items)
|
||||
binding.rvChatRooms.isVisible = !showEmpty
|
||||
|
||||
@@ -25,6 +25,7 @@ class ChatMainViewModel(
|
||||
private var nextCursor: String? = null
|
||||
private var hasMore: Boolean = false
|
||||
private var requestGeneration: Long = 0L
|
||||
private var activeFirstPageKey: ChatRoomFilter? = null
|
||||
|
||||
private val _chatRoomStateLiveData = MutableLiveData<ChatRoomListUiState>()
|
||||
val chatRoomStateLiveData: LiveData<ChatRoomListUiState>
|
||||
@@ -43,6 +44,15 @@ class ChatMainViewModel(
|
||||
get() = _isAppending
|
||||
|
||||
fun loadFirstPage(filter: ChatRoomFilter = currentFilter) {
|
||||
val requestKey = filter
|
||||
if (activeFirstPageKey == requestKey) return
|
||||
activeFirstPageKey = requestKey
|
||||
val previousState = _chatRoomStateLiveData.value
|
||||
val canRestoreOnError = filter == currentFilter &&
|
||||
(previousState is ChatRoomListUiState.Content || previousState is ChatRoomListUiState.Empty)
|
||||
val previousItems = currentItems
|
||||
val previousNextCursor = nextCursor
|
||||
val previousHasMore = hasMore
|
||||
currentFilter = filter
|
||||
requestGeneration += 1L
|
||||
val generation = requestGeneration
|
||||
@@ -53,6 +63,18 @@ class ChatMainViewModel(
|
||||
_isAppending.value = false
|
||||
_chatRoomStateLiveData.value = ChatRoomListUiState.Loading
|
||||
|
||||
fun showFirstPageError(message: String?) {
|
||||
if (canRestoreOnError) {
|
||||
currentItems = previousItems
|
||||
nextCursor = previousNextCursor
|
||||
hasMore = previousHasMore
|
||||
_chatRoomStateLiveData.value = previousState
|
||||
} else {
|
||||
_chatRoomStateLiveData.value = ChatRoomListUiState.Error(message = message)
|
||||
}
|
||||
_toastLiveData.value = ToastMessage(resId = R.string.common_error_unknown)
|
||||
}
|
||||
|
||||
compositeDisposable.add(
|
||||
repository.getChatRooms(
|
||||
token = authToken(),
|
||||
@@ -64,19 +86,21 @@ class ChatMainViewModel(
|
||||
.subscribe(
|
||||
{
|
||||
if (generation != requestGeneration) return@subscribe
|
||||
clearActiveFirstPageKey(requestKey, generation)
|
||||
_isLoading.value = false
|
||||
val data = it.data
|
||||
if (it.success && data != null) {
|
||||
handleFirstPageSuccess(data)
|
||||
} else {
|
||||
showUnknownError(it.message)
|
||||
showFirstPageError(it.message)
|
||||
}
|
||||
},
|
||||
{
|
||||
if (generation != requestGeneration) return@subscribe
|
||||
clearActiveFirstPageKey(requestKey, generation)
|
||||
_isLoading.value = false
|
||||
it.message?.let { message -> Logger.e(message) }
|
||||
showUnknownError(it.message)
|
||||
showFirstPageError(it.message)
|
||||
}
|
||||
)
|
||||
)
|
||||
@@ -138,6 +162,12 @@ class ChatMainViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
private fun clearActiveFirstPageKey(requestKey: ChatRoomFilter, generation: Long) {
|
||||
if (activeFirstPageKey == requestKey && requestGeneration == generation) {
|
||||
activeFirstPageKey = null
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleNextPageSuccess(data: ChatRoomListPageResponse) {
|
||||
currentItems = currentItems + data.rooms.toUiItems()
|
||||
hasMore = data.hasMore
|
||||
|
||||
@@ -23,6 +23,11 @@ import kr.co.vividnext.sodalive.v2.main.content.model.toUiModel
|
||||
import kr.co.vividnext.sodalive.v2.main.content.model.usesDayOfWeekQuery
|
||||
import kr.co.vividnext.sodalive.v2.main.content.model.usesSeriesItems
|
||||
|
||||
enum class ContentAllTabRefreshResult {
|
||||
Success,
|
||||
Failure
|
||||
}
|
||||
|
||||
class ContentAllTabViewModel(
|
||||
private val repository: MainContentAllTabRepository,
|
||||
private val currentDayOfWeekProvider: () -> SeriesPublishedDaysOfWeek = { currentDeviceDayOfWeek() }
|
||||
@@ -40,10 +45,15 @@ class ContentAllTabViewModel(
|
||||
val toastLiveData: LiveData<ToastMessage?>
|
||||
get() = _toastLiveData
|
||||
|
||||
private val _refreshResultLiveData = MutableLiveData<ContentAllTabRefreshResult>()
|
||||
val refreshResultLiveData: LiveData<ContentAllTabRefreshResult>
|
||||
get() = _refreshResultLiveData
|
||||
|
||||
private var selectedType: MainContentAllType = MainContentAllType.AUDIO
|
||||
private var selectedSort: ContentSort = ContentSort.LATEST
|
||||
private var selectedDayOfWeek: SeriesPublishedDaysOfWeek? = null
|
||||
private var requestGeneration: Int = 0
|
||||
private var activeFirstPageKey: FirstPageRequestKey? = null
|
||||
|
||||
fun loadContents() {
|
||||
loadFirstPage(selectedType, selectedSort, selectedDayOfWeekFor(selectedType))
|
||||
@@ -121,28 +131,58 @@ class ContentAllTabViewModel(
|
||||
sort: ContentSort,
|
||||
dayOfWeek: SeriesPublishedDaysOfWeek?
|
||||
) {
|
||||
val requestKey = FirstPageRequestKey(type, sort, dayOfWeek)
|
||||
if (activeFirstPageKey == requestKey) return
|
||||
activeFirstPageKey = requestKey
|
||||
val previousContent = (_allTabStateLiveData.value as? MainContentAllTabUiState.Content)
|
||||
?.takeIf { it.matches(type, sort, dayOfWeek) }
|
||||
?.copy(isLoadingMore = false, paginationErrorMessage = null)
|
||||
val generation = ++requestGeneration
|
||||
_isLoading.value = true
|
||||
_allTabStateLiveData.value = MainContentAllTabUiState.Loading(type, sort, dayOfWeek, totalCount = 0)
|
||||
requestContents(type, sort, FIRST_PAGE, dayOfWeek, generation) { response ->
|
||||
_isLoading.value = false
|
||||
val data = response.data
|
||||
if (response.success && data != null) {
|
||||
val content = data.toContent()
|
||||
_allTabStateLiveData.value = if (content.isSelectedTypeEmpty()) {
|
||||
MainContentAllTabUiState.Empty(
|
||||
content.selectedType,
|
||||
content.selectedSort,
|
||||
content.selectedDayOfWeek,
|
||||
content.totalCount
|
||||
)
|
||||
} else {
|
||||
content
|
||||
}
|
||||
|
||||
fun showFirstPageError(message: String?) {
|
||||
_refreshResultLiveData.value = ContentAllTabRefreshResult.Failure
|
||||
if (previousContent != null) {
|
||||
_allTabStateLiveData.value = previousContent
|
||||
_toastLiveData.value = ToastMessage(resId = R.string.common_error_unknown)
|
||||
} else {
|
||||
showFirstPageError(type, sort, dayOfWeek, response.message)
|
||||
showFirstPageError(type, sort, dayOfWeek, message)
|
||||
}
|
||||
}
|
||||
|
||||
requestContents(
|
||||
type = type,
|
||||
sort = sort,
|
||||
page = FIRST_PAGE,
|
||||
dayOfWeek = dayOfWeek,
|
||||
generation = generation,
|
||||
onSuccess = { response ->
|
||||
clearActiveFirstPageKey(requestKey, generation)
|
||||
_isLoading.value = false
|
||||
val data = response.data
|
||||
if (response.success && data != null) {
|
||||
_refreshResultLiveData.value = ContentAllTabRefreshResult.Success
|
||||
val content = data.toContent()
|
||||
_allTabStateLiveData.value = if (content.isSelectedTypeEmpty()) {
|
||||
MainContentAllTabUiState.Empty(
|
||||
content.selectedType,
|
||||
content.selectedSort,
|
||||
content.selectedDayOfWeek,
|
||||
content.totalCount
|
||||
)
|
||||
} else {
|
||||
content
|
||||
}
|
||||
} else {
|
||||
showFirstPageError(response.message)
|
||||
}
|
||||
},
|
||||
onFirstPageError = { message ->
|
||||
clearActiveFirstPageKey(requestKey, generation)
|
||||
showFirstPageError(message)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun requestContents(
|
||||
@@ -151,6 +191,7 @@ class ContentAllTabViewModel(
|
||||
page: Int,
|
||||
dayOfWeek: SeriesPublishedDaysOfWeek?,
|
||||
generation: Int,
|
||||
onFirstPageError: ((String?) -> Unit)? = null,
|
||||
onSuccess: (ApiResponse<MainContentAllTabResponse>) -> Unit
|
||||
) {
|
||||
compositeDisposable.add(
|
||||
@@ -164,7 +205,9 @@ class ContentAllTabViewModel(
|
||||
}
|
||||
},
|
||||
{
|
||||
if (generation != requestGeneration) return@subscribe
|
||||
if (generation != requestGeneration) {
|
||||
return@subscribe
|
||||
}
|
||||
|
||||
it.message?.let { message -> Logger.e(message) }
|
||||
_isLoading.value = false
|
||||
@@ -175,7 +218,7 @@ class ContentAllTabViewModel(
|
||||
paginationErrorMessage = it.message
|
||||
)
|
||||
} else {
|
||||
showFirstPageError(type, sort, dayOfWeek, it.message)
|
||||
onFirstPageError?.invoke(it.message) ?: showFirstPageError(type, sort, dayOfWeek, it.message)
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -186,10 +229,24 @@ class ContentAllTabViewModel(
|
||||
return if (type.usesDayOfWeekQuery()) selectedDayOfWeek ?: currentDayOfWeekProvider() else null
|
||||
}
|
||||
|
||||
private fun clearActiveFirstPageKey(requestKey: FirstPageRequestKey, generation: Int) {
|
||||
if (activeFirstPageKey == requestKey && requestGeneration == generation) {
|
||||
activeFirstPageKey = null
|
||||
}
|
||||
}
|
||||
|
||||
private fun MainContentAllTabUiState.Content.isSelectedTypeEmpty(): Boolean {
|
||||
return if (selectedType.usesSeriesItems()) seriesItems.isEmpty() else audioItems.isEmpty()
|
||||
}
|
||||
|
||||
private fun MainContentAllTabUiState.Content.matches(
|
||||
type: MainContentAllType,
|
||||
sort: ContentSort,
|
||||
dayOfWeek: SeriesPublishedDaysOfWeek?
|
||||
): Boolean {
|
||||
return selectedType == type && selectedSort == sort && selectedDayOfWeek == dayOfWeek
|
||||
}
|
||||
|
||||
private fun MainContentAllTabUiState.Content.append(
|
||||
data: MainContentAllTabResponse
|
||||
): MainContentAllTabUiState.Content {
|
||||
@@ -241,4 +298,10 @@ class ContentAllTabViewModel(
|
||||
const val DEFAULT_PAGE_SIZE = 20
|
||||
private const val FIRST_PAGE = 0
|
||||
}
|
||||
|
||||
private data class FirstPageRequestKey(
|
||||
val type: MainContentAllType,
|
||||
val sort: ContentSort,
|
||||
val dayOfWeek: SeriesPublishedDaysOfWeek?
|
||||
)
|
||||
}
|
||||
|
||||
@@ -112,6 +112,11 @@ class ContentMainFragment : BaseFragment<FragmentV2MainContentBinding>(
|
||||
private var currentContentTab = CONTENT_TAB_RECOMMENDATION
|
||||
private var currentAllTabState: MainContentAllTabUiState? = null
|
||||
private var pendingAllTabSelection: ContentAllTabSelection? = null
|
||||
private var isContentPullRefreshing = false
|
||||
private var refreshingContentTab: Int? = null
|
||||
private var hasRenderedAllTabContent = false
|
||||
private var lastRenderedContentRecommendationState: AudioRecommendationsUiState.Content? = null
|
||||
private var lastRenderedContentRankingState: AudioRankingsUiState.Content? = null
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
@@ -124,6 +129,7 @@ class ContentMainFragment : BaseFragment<FragmentV2MainContentBinding>(
|
||||
showContentTab(CONTENT_TAB_RECOMMENDATION)
|
||||
setUpSectionTitles()
|
||||
setUpAdapters()
|
||||
setUpRefreshLayouts()
|
||||
bindObservers()
|
||||
contentMainViewModel.loadRecommendations()
|
||||
applyPendingAllTabSelection()
|
||||
@@ -249,16 +255,16 @@ class ContentMainFragment : BaseFragment<FragmentV2MainContentBinding>(
|
||||
}
|
||||
|
||||
private fun showRecommendationContent() {
|
||||
binding.nsvContentRecommendationContent.visibility = View.VISIBLE
|
||||
binding.swipeContentRecommendation.visibility = View.VISIBLE
|
||||
binding.viewContentRankingTypeTabs.root.visibility = View.GONE
|
||||
binding.rvContentRankings.visibility = View.GONE
|
||||
binding.swipeContentRanking.visibility = View.GONE
|
||||
binding.layoutContentAllSurface.visibility = View.GONE
|
||||
}
|
||||
|
||||
private fun showRankingContent() {
|
||||
binding.nsvContentRecommendationContent.visibility = View.GONE
|
||||
binding.swipeContentRecommendation.visibility = View.GONE
|
||||
binding.viewContentRankingTypeTabs.root.visibility = View.VISIBLE
|
||||
binding.rvContentRankings.visibility = View.VISIBLE
|
||||
binding.swipeContentRanking.visibility = View.VISIBLE
|
||||
binding.layoutContentAllSurface.visibility = View.GONE
|
||||
if (!hasSelectedRankingTab) {
|
||||
hasSelectedRankingTab = true
|
||||
@@ -267,9 +273,9 @@ class ContentMainFragment : BaseFragment<FragmentV2MainContentBinding>(
|
||||
}
|
||||
|
||||
private fun showAllContent() {
|
||||
binding.nsvContentRecommendationContent.visibility = View.GONE
|
||||
binding.swipeContentRecommendation.visibility = View.GONE
|
||||
binding.viewContentRankingTypeTabs.root.visibility = View.GONE
|
||||
binding.rvContentRankings.visibility = View.GONE
|
||||
binding.swipeContentRanking.visibility = View.GONE
|
||||
binding.layoutContentAllSurface.visibility = View.VISIBLE
|
||||
currentAllTabState?.let(::renderAllTabState)
|
||||
if (currentAllTabState == null && !hasSelectedAllTab) {
|
||||
@@ -279,9 +285,9 @@ class ContentMainFragment : BaseFragment<FragmentV2MainContentBinding>(
|
||||
}
|
||||
|
||||
private fun hideContentSurfaces() {
|
||||
binding.nsvContentRecommendationContent.visibility = View.GONE
|
||||
binding.swipeContentRecommendation.visibility = View.GONE
|
||||
binding.viewContentRankingTypeTabs.root.visibility = View.GONE
|
||||
binding.rvContentRankings.visibility = View.GONE
|
||||
binding.swipeContentRanking.visibility = View.GONE
|
||||
binding.layoutContentAllSurface.visibility = View.GONE
|
||||
}
|
||||
|
||||
@@ -351,12 +357,90 @@ class ContentMainFragment : BaseFragment<FragmentV2MainContentBinding>(
|
||||
}
|
||||
}
|
||||
|
||||
private fun setUpRefreshLayouts() {
|
||||
binding.swipeContentRecommendation.setOnRefreshListener { refreshCurrentContentTab() }
|
||||
binding.swipeContentRanking.setOnRefreshListener { refreshCurrentContentTab() }
|
||||
binding.swipeContentAll.setOnChildScrollUpCallback { _, _ ->
|
||||
binding.rvContentAllItems.canScrollVertically(-1)
|
||||
}
|
||||
binding.swipeContentAll.setOnRefreshListener { refreshCurrentContentTab() }
|
||||
}
|
||||
|
||||
private fun refreshCurrentContentTab() {
|
||||
isContentPullRefreshing = true
|
||||
refreshingContentTab = currentContentTab
|
||||
when (currentContentTab) {
|
||||
CONTENT_TAB_RECOMMENDATION -> contentMainViewModel.loadRecommendations()
|
||||
CONTENT_TAB_RANKING -> refreshCurrentRankingTab()
|
||||
CONTENT_TAB_ALL -> contentAllTabViewModel.loadContents()
|
||||
}
|
||||
}
|
||||
|
||||
private fun refreshCurrentRankingTab() {
|
||||
val type = contentRankingViewModel.selectedTypeLiveData.value ?: AudioRankingType.WEEKLY_POPULAR
|
||||
contentRankingViewModel.loadRankings(type, force = true)
|
||||
}
|
||||
|
||||
private fun finishContentPullRefresh(tab: Int, isRestoredPullRefreshError: Boolean = false) {
|
||||
if (!isContentPullRefreshing) return
|
||||
if (refreshingContentTab != tab) return
|
||||
if (!isRestoredPullRefreshError) {
|
||||
scrollContentTabToTop(tab)
|
||||
}
|
||||
when (tab) {
|
||||
CONTENT_TAB_RECOMMENDATION -> binding.swipeContentRecommendation.isRefreshing = false
|
||||
CONTENT_TAB_RANKING -> binding.swipeContentRanking.isRefreshing = false
|
||||
CONTENT_TAB_ALL -> binding.swipeContentAll.isRefreshing = false
|
||||
}
|
||||
isContentPullRefreshing = false
|
||||
refreshingContentTab = null
|
||||
}
|
||||
|
||||
private fun cancelContentPullRefresh(tab: Int? = refreshingContentTab) {
|
||||
when (tab) {
|
||||
CONTENT_TAB_RECOMMENDATION -> binding.swipeContentRecommendation.isRefreshing = false
|
||||
CONTENT_TAB_RANKING -> binding.swipeContentRanking.isRefreshing = false
|
||||
CONTENT_TAB_ALL -> binding.swipeContentAll.isRefreshing = false
|
||||
}
|
||||
isContentPullRefreshing = false
|
||||
refreshingContentTab = null
|
||||
}
|
||||
|
||||
private fun shouldPreserveContentOnPullRefreshError(tab: Int): Boolean {
|
||||
if (!isContentPullRefreshing) return false
|
||||
if (refreshingContentTab != tab) return false
|
||||
cancelContentPullRefresh(tab)
|
||||
return true
|
||||
}
|
||||
|
||||
private fun scrollContentTabToTop(tab: Int) {
|
||||
when (tab) {
|
||||
CONTENT_TAB_RECOMMENDATION -> binding.nsvContentRecommendationContent.scrollTo(0, 0)
|
||||
CONTENT_TAB_RANKING -> binding.rvContentRankings.scrollToPosition(0)
|
||||
CONTENT_TAB_ALL -> binding.rvContentAllItems.scrollToPosition(0)
|
||||
}
|
||||
}
|
||||
|
||||
private fun bindObservers() {
|
||||
contentMainViewModel.recommendationsStateLiveData.observe(viewLifecycleOwner) { state ->
|
||||
when (state) {
|
||||
is AudioRecommendationsUiState.Content -> bindContent(state)
|
||||
AudioRecommendationsUiState.Empty,
|
||||
is AudioRecommendationsUiState.Error -> bindContent(emptyContent())
|
||||
is AudioRecommendationsUiState.Content -> {
|
||||
val isRestoredPullRefreshError = isContentPullRefreshing &&
|
||||
refreshingContentTab == CONTENT_TAB_RECOMMENDATION &&
|
||||
lastRenderedContentRecommendationState === state
|
||||
bindContent(state)
|
||||
lastRenderedContentRecommendationState = state
|
||||
finishContentPullRefresh(CONTENT_TAB_RECOMMENDATION, isRestoredPullRefreshError)
|
||||
}
|
||||
AudioRecommendationsUiState.Empty -> {
|
||||
lastRenderedContentRecommendationState = null
|
||||
bindContent(emptyContent())
|
||||
finishContentPullRefresh(CONTENT_TAB_RECOMMENDATION)
|
||||
}
|
||||
is AudioRecommendationsUiState.Error -> {
|
||||
if (shouldPreserveContentOnPullRefreshError(CONTENT_TAB_RECOMMENDATION)) return@observe
|
||||
bindContent(emptyContent())
|
||||
}
|
||||
|
||||
AudioRecommendationsUiState.Loading -> Unit
|
||||
}
|
||||
@@ -370,9 +454,23 @@ class ContentMainFragment : BaseFragment<FragmentV2MainContentBinding>(
|
||||
}
|
||||
contentRankingViewModel.rankingStateLiveData.observe(viewLifecycleOwner) { state ->
|
||||
when (state) {
|
||||
is AudioRankingsUiState.Content -> contentRankingAdapter.submitItems(state.items)
|
||||
is AudioRankingsUiState.Empty,
|
||||
is AudioRankingsUiState.Error -> contentRankingAdapter.submitItems(emptyList())
|
||||
is AudioRankingsUiState.Content -> {
|
||||
val isRestoredPullRefreshError = isContentPullRefreshing &&
|
||||
refreshingContentTab == CONTENT_TAB_RANKING &&
|
||||
lastRenderedContentRankingState === state
|
||||
contentRankingAdapter.submitItems(state.items)
|
||||
lastRenderedContentRankingState = state
|
||||
finishContentPullRefresh(CONTENT_TAB_RANKING, isRestoredPullRefreshError)
|
||||
}
|
||||
is AudioRankingsUiState.Empty -> {
|
||||
lastRenderedContentRankingState = null
|
||||
contentRankingAdapter.submitItems(emptyList())
|
||||
finishContentPullRefresh(CONTENT_TAB_RANKING)
|
||||
}
|
||||
is AudioRankingsUiState.Error -> {
|
||||
if (shouldPreserveContentOnPullRefreshError(CONTENT_TAB_RANKING)) return@observe
|
||||
contentRankingAdapter.submitItems(emptyList())
|
||||
}
|
||||
|
||||
AudioRankingsUiState.Loading -> Unit
|
||||
}
|
||||
@@ -385,6 +483,14 @@ class ContentMainFragment : BaseFragment<FragmentV2MainContentBinding>(
|
||||
toastMessage?.let(::showToast)
|
||||
}
|
||||
contentAllTabViewModel.allTabStateLiveData.observe(viewLifecycleOwner) { state ->
|
||||
val isAllTabPullRefresh = isContentPullRefreshing && refreshingContentTab == CONTENT_TAB_ALL
|
||||
if (state is MainContentAllTabUiState.Loading && isAllTabPullRefresh && hasRenderedAllTabContent) {
|
||||
return@observe
|
||||
}
|
||||
if (state is MainContentAllTabUiState.Error && isAllTabPullRefresh && hasRenderedAllTabContent) {
|
||||
cancelContentPullRefresh()
|
||||
return@observe
|
||||
}
|
||||
currentAllTabState = state
|
||||
renderAllTabState(state)
|
||||
}
|
||||
@@ -395,6 +501,13 @@ class ContentMainFragment : BaseFragment<FragmentV2MainContentBinding>(
|
||||
contentAllTabViewModel.toastLiveData.observe(viewLifecycleOwner) { toastMessage ->
|
||||
toastMessage?.let(::showToast)
|
||||
}
|
||||
contentAllTabViewModel.refreshResultLiveData.observe(viewLifecycleOwner) { result ->
|
||||
if (!isContentPullRefreshing || refreshingContentTab != CONTENT_TAB_ALL) return@observe
|
||||
when (result) {
|
||||
ContentAllTabRefreshResult.Success -> finishContentPullRefresh(CONTENT_TAB_ALL)
|
||||
ContentAllTabRefreshResult.Failure -> cancelContentPullRefresh(CONTENT_TAB_ALL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun renderAllTabState(state: MainContentAllTabUiState) {
|
||||
@@ -409,6 +522,7 @@ class ContentMainFragment : BaseFragment<FragmentV2MainContentBinding>(
|
||||
}
|
||||
|
||||
private fun bindAllTabContent(state: MainContentAllTabUiState.Content) {
|
||||
hasRenderedAllTabContent = true
|
||||
bindAllTabControls(state)
|
||||
binding.layoutContentAllSurface.visibility = View.VISIBLE
|
||||
hideAllTabEmptyError()
|
||||
@@ -422,8 +536,14 @@ class ContentMainFragment : BaseFragment<FragmentV2MainContentBinding>(
|
||||
contentAllSeriesCardAdapter.submitItems(emptyList())
|
||||
contentAllAudioCardAdapter.submitItems(state.audioItems)
|
||||
}
|
||||
state.paginationErrorMessage?.let { message ->
|
||||
val paginationErrorMessage = state.paginationErrorMessage
|
||||
paginationErrorMessage?.let { message ->
|
||||
showToast(message)
|
||||
if (isContentPullRefreshing && refreshingContentTab == CONTENT_TAB_ALL) {
|
||||
cancelContentPullRefresh()
|
||||
contentAllTabViewModel.consumePaginationErrorMessage()
|
||||
return
|
||||
}
|
||||
contentAllTabViewModel.consumePaginationErrorMessage()
|
||||
}
|
||||
}
|
||||
@@ -448,14 +568,21 @@ class ContentMainFragment : BaseFragment<FragmentV2MainContentBinding>(
|
||||
}
|
||||
|
||||
private fun bindAllTabEmpty(state: MainContentAllTabUiState.Empty) {
|
||||
hasRenderedAllTabContent = false
|
||||
bindAllTabControls(state)
|
||||
binding.layoutContentAllSurface.visibility = View.VISIBLE
|
||||
binding.layoutContentAllEmptyError.visibility = View.VISIBLE
|
||||
binding.tvContentAllEmptyError.setText(R.string.screen_content_all_empty)
|
||||
clearAllTabItems()
|
||||
finishContentPullRefresh(CONTENT_TAB_ALL)
|
||||
}
|
||||
|
||||
private fun bindAllTabError(state: MainContentAllTabUiState.Error) {
|
||||
if (isContentPullRefreshing && refreshingContentTab == CONTENT_TAB_ALL && hasRenderedAllTabContent) {
|
||||
cancelContentPullRefresh()
|
||||
return
|
||||
}
|
||||
hasRenderedAllTabContent = false
|
||||
bindAllTabControls(state)
|
||||
binding.layoutContentAllSurface.visibility = View.VISIBLE
|
||||
binding.layoutContentAllEmptyError.visibility = View.VISIBLE
|
||||
@@ -467,6 +594,7 @@ class ContentMainFragment : BaseFragment<FragmentV2MainContentBinding>(
|
||||
bindAllTabControls(state)
|
||||
binding.layoutContentAllSurface.visibility = View.VISIBLE
|
||||
hideAllTabEmptyError()
|
||||
if (isContentPullRefreshing && refreshingContentTab == CONTENT_TAB_ALL && hasRenderedAllTabContent) return
|
||||
clearAllTabItems()
|
||||
}
|
||||
|
||||
|
||||
@@ -29,7 +29,13 @@ class ContentMainViewModel(
|
||||
val isLoading: LiveData<Boolean>
|
||||
get() = _isLoading
|
||||
|
||||
private var requestGeneration: Int = 0
|
||||
|
||||
fun loadRecommendations() {
|
||||
if (_isLoading.value == true) return
|
||||
|
||||
val previousState = _recommendationsStateLiveData.value
|
||||
val generation = ++requestGeneration
|
||||
_isLoading.value = true
|
||||
_recommendationsStateLiveData.value = AudioRecommendationsUiState.Loading
|
||||
|
||||
@@ -39,6 +45,8 @@ class ContentMainViewModel(
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(
|
||||
{
|
||||
if (generation != requestGeneration) return@subscribe
|
||||
|
||||
_isLoading.value = false
|
||||
val data = it.data
|
||||
if (it.success && data != null) {
|
||||
@@ -49,20 +57,22 @@ class ContentMainViewModel(
|
||||
content
|
||||
}
|
||||
} else {
|
||||
showUnknownError(it.message)
|
||||
showUnknownError(it.message, previousState)
|
||||
}
|
||||
},
|
||||
{
|
||||
if (generation != requestGeneration) return@subscribe
|
||||
|
||||
_isLoading.value = false
|
||||
it.message?.let { message -> Logger.e(message) }
|
||||
showUnknownError(it.message)
|
||||
showUnknownError(it.message, previousState)
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun showUnknownError(message: String?) {
|
||||
_recommendationsStateLiveData.value = AudioRecommendationsUiState.Error(message = message)
|
||||
private fun showUnknownError(message: String?, fallbackState: AudioRecommendationsUiState? = null) {
|
||||
_recommendationsStateLiveData.value = fallbackState ?: AudioRecommendationsUiState.Error(message = message)
|
||||
_toastLiveData.postValue(ToastMessage(resId = R.string.common_error_unknown))
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ class ContentRankingViewModel(
|
||||
) : BaseViewModel() {
|
||||
|
||||
private val cachedStates = mutableMapOf<AudioRankingType, AudioRankingsUiState>()
|
||||
private var activeFirstPageKey: AudioRankingType? = null
|
||||
private var latestRequestId = 0L
|
||||
|
||||
private val _rankingStateLiveData = MutableLiveData<AudioRankingsUiState>()
|
||||
@@ -39,13 +40,18 @@ class ContentRankingViewModel(
|
||||
|
||||
fun loadRankings(type: AudioRankingType, force: Boolean = false) {
|
||||
_selectedTypeLiveData.value = type
|
||||
val requestId = ++latestRequestId
|
||||
val cachedState = cachedStates[type]
|
||||
if (!force && cachedState != null) {
|
||||
latestRequestId += 1L
|
||||
activeFirstPageKey = null
|
||||
_isLoading.value = false
|
||||
_rankingStateLiveData.value = cachedState
|
||||
return
|
||||
}
|
||||
val requestKey = type
|
||||
if (activeFirstPageKey == requestKey) return
|
||||
activeFirstPageKey = requestKey
|
||||
val requestId = ++latestRequestId
|
||||
|
||||
_isLoading.value = true
|
||||
_rankingStateLiveData.value = AudioRankingsUiState.Loading
|
||||
@@ -57,6 +63,7 @@ class ContentRankingViewModel(
|
||||
.subscribe(
|
||||
{
|
||||
if (!isCurrentRequest(requestId, type)) return@subscribe
|
||||
clearActiveFirstPageKey(requestKey, requestId)
|
||||
_isLoading.value = false
|
||||
val data = it.data
|
||||
if (it.success && data != null) {
|
||||
@@ -70,14 +77,15 @@ class ContentRankingViewModel(
|
||||
cachedStates[type] = state
|
||||
_rankingStateLiveData.value = state
|
||||
} else {
|
||||
showUnknownError(type, it.message)
|
||||
showUnknownError(type, it.message, cachedState)
|
||||
}
|
||||
},
|
||||
{
|
||||
if (!isCurrentRequest(requestId, type)) return@subscribe
|
||||
clearActiveFirstPageKey(requestKey, requestId)
|
||||
_isLoading.value = false
|
||||
it.message?.let { message -> Logger.e(message) }
|
||||
showUnknownError(type, it.message)
|
||||
showUnknownError(type, it.message, cachedState)
|
||||
}
|
||||
)
|
||||
)
|
||||
@@ -87,9 +95,19 @@ class ContentRankingViewModel(
|
||||
return requestId == latestRequestId && _selectedTypeLiveData.value == type
|
||||
}
|
||||
|
||||
private fun showUnknownError(type: AudioRankingType, message: String?) {
|
||||
private fun clearActiveFirstPageKey(requestKey: AudioRankingType, requestId: Long) {
|
||||
if (activeFirstPageKey == requestKey && latestRequestId == requestId) {
|
||||
activeFirstPageKey = null
|
||||
}
|
||||
}
|
||||
|
||||
private fun showUnknownError(
|
||||
type: AudioRankingType,
|
||||
message: String?,
|
||||
fallbackState: AudioRankingsUiState? = null
|
||||
) {
|
||||
if (_selectedTypeLiveData.value != type) return
|
||||
_rankingStateLiveData.value = AudioRankingsUiState.Error(type = type, message = message)
|
||||
_rankingStateLiveData.value = fallbackState ?: AudioRankingsUiState.Error(type = type, message = message)
|
||||
_toastLiveData.value = ToastMessage(resId = R.string.common_error_unknown)
|
||||
}
|
||||
|
||||
|
||||
@@ -29,7 +29,13 @@ class HomeCreatorRankingViewModel(
|
||||
val isLoading: LiveData<Boolean>
|
||||
get() = _isLoading
|
||||
|
||||
private var requestGeneration: Int = 0
|
||||
|
||||
fun loadCreatorRankings() {
|
||||
if (_isLoading.value == true) return
|
||||
|
||||
val previousState = _rankingStateLiveData.value
|
||||
val generation = ++requestGeneration
|
||||
_isLoading.value = true
|
||||
_rankingStateLiveData.value = HomeCreatorRankingUiState.Loading
|
||||
|
||||
@@ -39,6 +45,8 @@ class HomeCreatorRankingViewModel(
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(
|
||||
{
|
||||
if (generation != requestGeneration) return@subscribe
|
||||
|
||||
_isLoading.value = false
|
||||
val data = it.data
|
||||
if (it.success && data != null) {
|
||||
@@ -49,20 +57,22 @@ class HomeCreatorRankingViewModel(
|
||||
HomeCreatorRankingUiState.Content(items = items)
|
||||
}
|
||||
} else {
|
||||
showUnknownError(it.message)
|
||||
showUnknownError(it.message, previousState)
|
||||
}
|
||||
},
|
||||
{
|
||||
if (generation != requestGeneration) return@subscribe
|
||||
|
||||
_isLoading.value = false
|
||||
it.message?.let { message -> Logger.e(message) }
|
||||
showUnknownError(it.message)
|
||||
showUnknownError(it.message, previousState)
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun showUnknownError(message: String?) {
|
||||
_rankingStateLiveData.value = HomeCreatorRankingUiState.Error(message = message)
|
||||
private fun showUnknownError(message: String?, fallbackState: HomeCreatorRankingUiState? = null) {
|
||||
_rankingStateLiveData.value = fallbackState ?: HomeCreatorRankingUiState.Error(message = message)
|
||||
_toastLiveData.postValue(ToastMessage(resId = R.string.common_error_unknown))
|
||||
}
|
||||
|
||||
|
||||
@@ -33,7 +33,13 @@ class HomeFollowingViewModel(
|
||||
val isLoading: LiveData<Boolean>
|
||||
get() = _isLoading
|
||||
|
||||
private var requestGeneration: Int = 0
|
||||
|
||||
fun loadFollowing() {
|
||||
if (_isLoading.value == true) return
|
||||
|
||||
val previousState = _followingStateLiveData.value
|
||||
val generation = ++requestGeneration
|
||||
_isLoading.value = true
|
||||
_followingStateLiveData.value = HomeFollowingUiState.Loading
|
||||
|
||||
@@ -43,25 +49,29 @@ class HomeFollowingViewModel(
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(
|
||||
{
|
||||
if (generation != requestGeneration) return@subscribe
|
||||
|
||||
_isLoading.value = false
|
||||
val data = it.data
|
||||
if (it.success && data != null) {
|
||||
_followingStateLiveData.value = data.toUiState(relativeTimeTextFormatter)
|
||||
} else {
|
||||
showUnknownError(it.message)
|
||||
showUnknownError(it.message, previousState)
|
||||
}
|
||||
},
|
||||
{
|
||||
if (generation != requestGeneration) return@subscribe
|
||||
|
||||
_isLoading.value = false
|
||||
it.message?.let { message -> Logger.e(message) }
|
||||
showUnknownError(it.message)
|
||||
showUnknownError(it.message, previousState)
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun showUnknownError(message: String?) {
|
||||
_followingStateLiveData.value = HomeFollowingUiState.Error(message = message)
|
||||
private fun showUnknownError(message: String?, fallbackState: HomeFollowingUiState? = null) {
|
||||
_followingStateLiveData.value = fallbackState ?: HomeFollowingUiState.Error(message = message)
|
||||
_toastLiveData.value = ToastMessage(resId = R.string.common_error_unknown)
|
||||
}
|
||||
|
||||
|
||||
@@ -125,9 +125,15 @@ class HomeMainFragment : BaseFragment<FragmentV2MainHomeBinding>(
|
||||
private var hasLoadedFollowing = false
|
||||
private var currentHomeTabIndex = HOME_TAB_RECOMMENDATION
|
||||
private var isRecommendationLoading = false
|
||||
private var isRecommendationFollowLoading = false
|
||||
private var isCreatorRankingLoading = false
|
||||
private var isFollowingLoading = false
|
||||
private var isLiveEntryLoading = false
|
||||
private var isHomePullRefreshing = false
|
||||
private var refreshingHomeTabIndex: Int? = null
|
||||
private var lastRenderedHomeRecommendationState: HomeRecommendationUiState.Content? = null
|
||||
private var lastRenderedHomeRankingState: HomeCreatorRankingUiState.Content? = null
|
||||
private var lastRenderedHomeFollowingState: HomeFollowingUiState.Content? = null
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
@@ -150,6 +156,7 @@ class HomeMainFragment : BaseFragment<FragmentV2MainHomeBinding>(
|
||||
setUpRecommendationAdapters()
|
||||
setUpCreatorRankingAdapter()
|
||||
setUpFollowingAdapters()
|
||||
setUpRefreshLayouts()
|
||||
setUpBusinessInfo()
|
||||
setupTitleBarActions()
|
||||
bindHomeRecommendationObservers()
|
||||
@@ -260,12 +267,78 @@ class HomeMainFragment : BaseFragment<FragmentV2MainHomeBinding>(
|
||||
}
|
||||
}
|
||||
|
||||
private fun setUpRefreshLayouts() {
|
||||
binding.swipeHomeRecommendation.setOnRefreshListener { refreshCurrentHomeTab() }
|
||||
binding.swipeHomeRanking.setOnRefreshListener { refreshCurrentHomeTab() }
|
||||
binding.swipeHomeFollowing.setOnRefreshListener { refreshCurrentHomeTab() }
|
||||
}
|
||||
|
||||
private fun refreshCurrentHomeTab() {
|
||||
isHomePullRefreshing = true
|
||||
refreshingHomeTabIndex = currentHomeTabIndex
|
||||
when (currentHomeTabIndex) {
|
||||
HOME_TAB_RECOMMENDATION -> homeRecommendationViewModel.loadRecommendations()
|
||||
HOME_TAB_RANKING -> homeCreatorRankingViewModel.loadCreatorRankings()
|
||||
HOME_TAB_FOLLOWING -> homeFollowingViewModel.loadFollowing()
|
||||
}
|
||||
}
|
||||
|
||||
private fun finishHomePullRefresh(tabIndex: Int, isRestoredPullRefreshError: Boolean = false) {
|
||||
if (!isHomePullRefreshing) return
|
||||
if (refreshingHomeTabIndex != tabIndex) return
|
||||
if (!isRestoredPullRefreshError) {
|
||||
scrollHomeTabToTop(tabIndex)
|
||||
}
|
||||
when (tabIndex) {
|
||||
HOME_TAB_RECOMMENDATION -> binding.swipeHomeRecommendation.isRefreshing = false
|
||||
HOME_TAB_RANKING -> binding.swipeHomeRanking.isRefreshing = false
|
||||
HOME_TAB_FOLLOWING -> binding.swipeHomeFollowing.isRefreshing = false
|
||||
}
|
||||
isHomePullRefreshing = false
|
||||
refreshingHomeTabIndex = null
|
||||
}
|
||||
|
||||
private fun shouldPreserveHomeContentOnPullRefreshError(tabIndex: Int): Boolean {
|
||||
if (!isHomePullRefreshing) return false
|
||||
if (refreshingHomeTabIndex != tabIndex) return false
|
||||
when (tabIndex) {
|
||||
HOME_TAB_RECOMMENDATION -> binding.swipeHomeRecommendation.isRefreshing = false
|
||||
HOME_TAB_RANKING -> binding.swipeHomeRanking.isRefreshing = false
|
||||
HOME_TAB_FOLLOWING -> binding.swipeHomeFollowing.isRefreshing = false
|
||||
}
|
||||
isHomePullRefreshing = false
|
||||
refreshingHomeTabIndex = null
|
||||
return true
|
||||
}
|
||||
|
||||
private fun scrollHomeTabToTop(tabIndex: Int) {
|
||||
when (tabIndex) {
|
||||
HOME_TAB_RECOMMENDATION -> binding.nsvHomeRecommendationContent.scrollTo(0, 0)
|
||||
HOME_TAB_RANKING -> binding.rvHomeCreatorRankings.scrollToPosition(0)
|
||||
HOME_TAB_FOLLOWING -> binding.nsvHomeFollowingContent.scrollTo(0, 0)
|
||||
}
|
||||
}
|
||||
|
||||
private fun bindHomeCreatorRankingObservers() {
|
||||
homeCreatorRankingViewModel.rankingStateLiveData.observe(viewLifecycleOwner) { state ->
|
||||
when (state) {
|
||||
is HomeCreatorRankingUiState.Content -> creatorRankingAdapter.submitItems(state.items)
|
||||
HomeCreatorRankingUiState.Empty,
|
||||
is HomeCreatorRankingUiState.Error -> creatorRankingAdapter.submitItems(emptyList())
|
||||
is HomeCreatorRankingUiState.Content -> {
|
||||
val isRestoredPullRefreshError = isHomePullRefreshing &&
|
||||
refreshingHomeTabIndex == HOME_TAB_RANKING &&
|
||||
lastRenderedHomeRankingState === state
|
||||
creatorRankingAdapter.submitItems(state.items)
|
||||
lastRenderedHomeRankingState = state
|
||||
finishHomePullRefresh(HOME_TAB_RANKING, isRestoredPullRefreshError)
|
||||
}
|
||||
HomeCreatorRankingUiState.Empty -> {
|
||||
lastRenderedHomeRankingState = null
|
||||
creatorRankingAdapter.submitItems(emptyList())
|
||||
finishHomePullRefresh(HOME_TAB_RANKING)
|
||||
}
|
||||
is HomeCreatorRankingUiState.Error -> {
|
||||
if (shouldPreserveHomeContentOnPullRefreshError(HOME_TAB_RANKING)) return@observe
|
||||
creatorRankingAdapter.submitItems(emptyList())
|
||||
}
|
||||
|
||||
HomeCreatorRankingUiState.Loading -> Unit
|
||||
}
|
||||
@@ -293,15 +366,15 @@ class HomeMainFragment : BaseFragment<FragmentV2MainHomeBinding>(
|
||||
currentHomeTabIndex = index
|
||||
when (index) {
|
||||
HOME_TAB_RECOMMENDATION -> {
|
||||
binding.nsvHomeRecommendationContent.visibility = View.VISIBLE
|
||||
binding.rvHomeCreatorRankings.visibility = View.GONE
|
||||
binding.nsvHomeFollowingContent.visibility = View.GONE
|
||||
binding.swipeHomeRecommendation.visibility = View.VISIBLE
|
||||
binding.swipeHomeRanking.visibility = View.GONE
|
||||
binding.swipeHomeFollowing.visibility = View.GONE
|
||||
}
|
||||
|
||||
HOME_TAB_RANKING -> {
|
||||
binding.nsvHomeRecommendationContent.visibility = View.GONE
|
||||
binding.rvHomeCreatorRankings.visibility = View.VISIBLE
|
||||
binding.nsvHomeFollowingContent.visibility = View.GONE
|
||||
binding.swipeHomeRecommendation.visibility = View.GONE
|
||||
binding.swipeHomeRanking.visibility = View.VISIBLE
|
||||
binding.swipeHomeFollowing.visibility = View.GONE
|
||||
if (!hasLoadedCreatorRankings) {
|
||||
hasLoadedCreatorRankings = true
|
||||
homeCreatorRankingViewModel.loadCreatorRankings()
|
||||
@@ -309,9 +382,9 @@ class HomeMainFragment : BaseFragment<FragmentV2MainHomeBinding>(
|
||||
}
|
||||
|
||||
HOME_TAB_FOLLOWING -> {
|
||||
binding.nsvHomeFollowingContent.visibility = View.VISIBLE
|
||||
binding.nsvHomeRecommendationContent.visibility = View.GONE
|
||||
binding.rvHomeCreatorRankings.visibility = View.GONE
|
||||
binding.swipeHomeFollowing.visibility = View.VISIBLE
|
||||
binding.swipeHomeRecommendation.visibility = View.GONE
|
||||
binding.swipeHomeRanking.visibility = View.GONE
|
||||
if (!hasLoadedFollowing) {
|
||||
hasLoadedFollowing = true
|
||||
homeFollowingViewModel.loadFollowing()
|
||||
@@ -323,10 +396,24 @@ class HomeMainFragment : BaseFragment<FragmentV2MainHomeBinding>(
|
||||
private fun bindHomeFollowingObservers() {
|
||||
homeFollowingViewModel.followingStateLiveData.observe(viewLifecycleOwner) { state ->
|
||||
when (state) {
|
||||
is HomeFollowingUiState.Content -> bindHomeFollowingContent(state)
|
||||
HomeFollowingUiState.Empty -> bindHomeFollowingEmpty(showEmptyMessage = true)
|
||||
HomeFollowingUiState.LoginRequired,
|
||||
is HomeFollowingUiState.Error -> bindHomeFollowingEmpty(showEmptyMessage = false)
|
||||
is HomeFollowingUiState.Content -> {
|
||||
val isRestoredPullRefreshError = isHomePullRefreshing &&
|
||||
refreshingHomeTabIndex == HOME_TAB_FOLLOWING &&
|
||||
lastRenderedHomeFollowingState === state
|
||||
bindHomeFollowingContent(state)
|
||||
lastRenderedHomeFollowingState = state
|
||||
finishHomePullRefresh(HOME_TAB_FOLLOWING, isRestoredPullRefreshError)
|
||||
}
|
||||
HomeFollowingUiState.Empty -> {
|
||||
lastRenderedHomeFollowingState = null
|
||||
bindHomeFollowingEmpty(showEmptyMessage = true)
|
||||
finishHomePullRefresh(HOME_TAB_FOLLOWING)
|
||||
}
|
||||
HomeFollowingUiState.LoginRequired -> bindHomeFollowingEmpty(showEmptyMessage = false)
|
||||
is HomeFollowingUiState.Error -> {
|
||||
if (shouldPreserveHomeContentOnPullRefreshError(HOME_TAB_FOLLOWING)) return@observe
|
||||
bindHomeFollowingEmpty(showEmptyMessage = false)
|
||||
}
|
||||
|
||||
HomeFollowingUiState.Loading -> Unit
|
||||
}
|
||||
@@ -343,9 +430,23 @@ class HomeMainFragment : BaseFragment<FragmentV2MainHomeBinding>(
|
||||
private fun bindHomeRecommendationObservers() {
|
||||
homeRecommendationViewModel.recommendationStateLiveData.observe(viewLifecycleOwner) { state ->
|
||||
when (state) {
|
||||
is HomeRecommendationUiState.Content -> bindHomeRecommendationContent(state)
|
||||
HomeRecommendationUiState.Empty,
|
||||
is HomeRecommendationUiState.Error -> bindHomeRecommendationContent(emptyHomeRecommendationContent())
|
||||
is HomeRecommendationUiState.Content -> {
|
||||
val isRestoredPullRefreshError = isHomePullRefreshing &&
|
||||
refreshingHomeTabIndex == HOME_TAB_RECOMMENDATION &&
|
||||
lastRenderedHomeRecommendationState === state
|
||||
bindHomeRecommendationContent(state)
|
||||
lastRenderedHomeRecommendationState = state
|
||||
finishHomePullRefresh(HOME_TAB_RECOMMENDATION, isRestoredPullRefreshError)
|
||||
}
|
||||
HomeRecommendationUiState.Empty -> {
|
||||
lastRenderedHomeRecommendationState = null
|
||||
bindHomeRecommendationContent(emptyHomeRecommendationContent())
|
||||
finishHomePullRefresh(HOME_TAB_RECOMMENDATION)
|
||||
}
|
||||
is HomeRecommendationUiState.Error -> {
|
||||
if (shouldPreserveHomeContentOnPullRefreshError(HOME_TAB_RECOMMENDATION)) return@observe
|
||||
bindHomeRecommendationContent(emptyHomeRecommendationContent())
|
||||
}
|
||||
|
||||
HomeRecommendationUiState.Loading -> Unit
|
||||
}
|
||||
@@ -354,6 +455,10 @@ class HomeMainFragment : BaseFragment<FragmentV2MainHomeBinding>(
|
||||
isRecommendationLoading = isLoading
|
||||
updateLoadingDialog()
|
||||
}
|
||||
homeRecommendationViewModel.isFollowLoading.observe(viewLifecycleOwner) { isLoading ->
|
||||
isRecommendationFollowLoading = isLoading
|
||||
updateLoadingDialog()
|
||||
}
|
||||
homeRecommendationViewModel.toastLiveData.observe(viewLifecycleOwner) { toastMessage ->
|
||||
toastMessage?.let(::showToast)
|
||||
}
|
||||
@@ -370,7 +475,13 @@ class HomeMainFragment : BaseFragment<FragmentV2MainHomeBinding>(
|
||||
}
|
||||
|
||||
private fun updateLoadingDialog() {
|
||||
if (isRecommendationLoading || isCreatorRankingLoading || isFollowingLoading || isLiveEntryLoading) {
|
||||
if (
|
||||
isRecommendationLoading ||
|
||||
isRecommendationFollowLoading ||
|
||||
isCreatorRankingLoading ||
|
||||
isFollowingLoading ||
|
||||
isLiveEntryLoading
|
||||
) {
|
||||
loadingDialog.show(screenWidth)
|
||||
} else {
|
||||
loadingDialog.dismiss()
|
||||
|
||||
@@ -30,7 +30,18 @@ class HomeRecommendationViewModel(
|
||||
val isLoading: LiveData<Boolean>
|
||||
get() = _isLoading
|
||||
|
||||
private val _isFollowLoading = MutableLiveData(false)
|
||||
val isFollowLoading: LiveData<Boolean>
|
||||
get() = _isFollowLoading
|
||||
|
||||
private var requestGeneration: Int = 0
|
||||
private var isCheerCreatorsFollowCompleted = false
|
||||
|
||||
fun loadRecommendations() {
|
||||
if (_isLoading.value == true) return
|
||||
|
||||
val previousState = _recommendationStateLiveData.value
|
||||
val generation = ++requestGeneration
|
||||
_isLoading.value = true
|
||||
_recommendationStateLiveData.value = HomeRecommendationUiState.Loading
|
||||
|
||||
@@ -40,23 +51,27 @@ class HomeRecommendationViewModel(
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(
|
||||
{
|
||||
if (generation != requestGeneration) return@subscribe
|
||||
|
||||
_isLoading.value = false
|
||||
val data = it.data
|
||||
if (it.success && data != null) {
|
||||
val content = data.toContent()
|
||||
val content = data.toContent().withFollowCompleted()
|
||||
_recommendationStateLiveData.value = if (content.isEmpty) {
|
||||
HomeRecommendationUiState.Empty
|
||||
} else {
|
||||
content
|
||||
}
|
||||
} else {
|
||||
showUnknownError(it.message)
|
||||
showUnknownError(it.message, previousState)
|
||||
}
|
||||
},
|
||||
{
|
||||
if (generation != requestGeneration) return@subscribe
|
||||
|
||||
_isLoading.value = false
|
||||
it.message?.let { message -> Logger.e(message) }
|
||||
showUnknownError(it.message)
|
||||
showUnknownError(it.message, previousState)
|
||||
}
|
||||
)
|
||||
)
|
||||
@@ -65,7 +80,7 @@ class HomeRecommendationViewModel(
|
||||
fun followCreators(sectionKey: String, creatorIds: List<Long>) {
|
||||
if (creatorIds.isEmpty()) return
|
||||
|
||||
_isLoading.value = true
|
||||
_isFollowLoading.value = true
|
||||
compositeDisposable.add(
|
||||
repository.followRecommendedCreators(
|
||||
request = FollowRecommendedCreatorsRequest(creatorIds = creatorIds),
|
||||
@@ -75,35 +90,50 @@ class HomeRecommendationViewModel(
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(
|
||||
{
|
||||
_isLoading.value = false
|
||||
_isFollowLoading.value = false
|
||||
if (it.success) {
|
||||
updateFollowCompleted(sectionKey)
|
||||
} else {
|
||||
showUnknownError(it.message)
|
||||
showFollowError()
|
||||
}
|
||||
},
|
||||
{
|
||||
_isLoading.value = false
|
||||
_isFollowLoading.value = false
|
||||
it.message?.let { message -> Logger.e(message) }
|
||||
showUnknownError(it.message)
|
||||
showFollowError()
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun updateFollowCompleted(sectionKey: String) {
|
||||
val content = _recommendationStateLiveData.value as? HomeRecommendationUiState.Content ?: return
|
||||
if (sectionKey != SECTION_KEY_CHEER_CREATORS) return
|
||||
isCheerCreatorsFollowCompleted = true
|
||||
val content = _recommendationStateLiveData.value as? HomeRecommendationUiState.Content ?: return
|
||||
_recommendationStateLiveData.value = content.copy(
|
||||
cheerCreators = content.cheerCreators.copy(isFollowCompleted = true)
|
||||
)
|
||||
}
|
||||
|
||||
private fun showUnknownError(message: String?) {
|
||||
_recommendationStateLiveData.value = HomeRecommendationUiState.Error(message = message)
|
||||
private fun HomeRecommendationUiState.Content.withFollowCompleted(): HomeRecommendationUiState.Content {
|
||||
if (!isCheerCreatorsFollowCompleted) return this
|
||||
return copy(cheerCreators = cheerCreators.copy(isFollowCompleted = true))
|
||||
}
|
||||
|
||||
private fun showFollowError() {
|
||||
_toastLiveData.postValue(ToastMessage(resId = R.string.common_error_unknown))
|
||||
}
|
||||
|
||||
private fun showUnknownError(message: String?, fallbackState: HomeRecommendationUiState? = null) {
|
||||
_recommendationStateLiveData.value = fallbackState.withFollowCompleted()
|
||||
?: HomeRecommendationUiState.Error(message = message)
|
||||
_toastLiveData.postValue(ToastMessage(resId = R.string.common_error_unknown))
|
||||
}
|
||||
|
||||
private fun HomeRecommendationUiState?.withFollowCompleted(): HomeRecommendationUiState? {
|
||||
return (this as? HomeRecommendationUiState.Content)?.withFollowCompleted() ?: this
|
||||
}
|
||||
|
||||
private fun authToken(): String = "Bearer ${SharedPreferenceManager.token}"
|
||||
|
||||
private companion object {
|
||||
|
||||
@@ -24,30 +24,38 @@
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/view_chat_title_bar" />
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/rv_chat_rooms"
|
||||
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||
android:id="@+id/swipe_chat_rooms"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
android:clipToPadding="false"
|
||||
android:paddingBottom="@dimen/spacing_48"
|
||||
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/view_chat_filter_tabs"
|
||||
tools:listitem="@layout/item_v2_chat_room" />
|
||||
app:layout_constraintTop_toBottomOf="@id/view_chat_filter_tabs">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tv_chat_empty_message"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center"
|
||||
android:textColor="@color/color_777777"
|
||||
android:textSize="14sp"
|
||||
android:visibility="gone"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/view_chat_filter_tabs"
|
||||
tools:text="아직 나눈 대화가 없어요.\n크리에이터와 이야기를 시작해 보세요." />
|
||||
<FrameLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/rv_chat_rooms"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:clipToPadding="false"
|
||||
android:paddingBottom="@dimen/spacing_48"
|
||||
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
|
||||
tools:listitem="@layout/item_v2_chat_room" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tv_chat_empty_message"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center"
|
||||
android:gravity="center"
|
||||
android:textColor="@color/color_777777"
|
||||
android:textSize="14sp"
|
||||
android:visibility="gone"
|
||||
tools:text="아직 나눈 대화가 없어요.\n크리에이터와 이야기를 시작해 보세요." />
|
||||
</FrameLayout>
|
||||
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
|
||||
@@ -99,41 +99,48 @@
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/rv_content_all_items"
|
||||
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||
android:id="@+id/swipe_content_all"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
android:clipToPadding="false"
|
||||
android:paddingHorizontal="@dimen/spacing_14"
|
||||
android:paddingTop="@dimen/spacing_14"
|
||||
android:paddingBottom="@dimen/spacing_28"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/layout_content_all_sort_bar" />
|
||||
|
||||
<FrameLayout
|
||||
android:id="@+id/layout_content_all_empty_error"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
android:paddingHorizontal="@dimen/spacing_28"
|
||||
android:visibility="gone"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/layout_content_all_sort_bar">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tv_content_all_empty_error"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center"
|
||||
android:fontFamily="@font/medium"
|
||||
android:gravity="center"
|
||||
android:includeFontPadding="false"
|
||||
android:textColor="@color/color_777777"
|
||||
android:textSize="14sp" />
|
||||
</FrameLayout>
|
||||
<FrameLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/rv_content_all_items"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:clipToPadding="false"
|
||||
android:paddingHorizontal="@dimen/spacing_14"
|
||||
android:paddingTop="@dimen/spacing_14"
|
||||
android:paddingBottom="@dimen/spacing_28" />
|
||||
|
||||
<FrameLayout
|
||||
android:id="@+id/layout_content_all_empty_error"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:paddingHorizontal="@dimen/spacing_28"
|
||||
android:visibility="gone">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tv_content_all_empty_error"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center"
|
||||
android:fontFamily="@font/medium"
|
||||
android:gravity="center"
|
||||
android:includeFontPadding="false"
|
||||
android:textColor="@color/color_777777"
|
||||
android:textSize="14sp" />
|
||||
</FrameLayout>
|
||||
</FrameLayout>
|
||||
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
|
||||
<include
|
||||
@@ -146,30 +153,41 @@
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/text_tab_bar_content" />
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/rv_content_rankings"
|
||||
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||
android:id="@+id/swipe_content_ranking"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
android:clipToPadding="false"
|
||||
android:paddingHorizontal="@dimen/spacing_14"
|
||||
android:paddingTop="@dimen/spacing_14"
|
||||
android:paddingBottom="@dimen/spacing_28"
|
||||
android:visibility="gone"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/view_content_ranking_type_tabs" />
|
||||
app:layout_constraintTop_toBottomOf="@id/view_content_ranking_type_tabs">
|
||||
|
||||
<androidx.core.widget.NestedScrollView
|
||||
android:id="@+id/nsv_content_recommendation_content"
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/rv_content_rankings"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:clipToPadding="false"
|
||||
android:paddingHorizontal="@dimen/spacing_14"
|
||||
android:paddingTop="@dimen/spacing_14"
|
||||
android:paddingBottom="@dimen/spacing_28" />
|
||||
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
|
||||
|
||||
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||
android:id="@+id/swipe_content_recommendation"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
android:fillViewport="true"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/text_tab_bar_content">
|
||||
|
||||
<androidx.core.widget.NestedScrollView
|
||||
android:id="@+id/nsv_content_recommendation_content"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:fillViewport="true">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/ll_content_recommendation_content"
|
||||
android:layout_width="match_parent"
|
||||
@@ -342,5 +360,6 @@
|
||||
android:paddingHorizontal="@dimen/spacing_14" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
</androidx.core.widget.NestedScrollView>
|
||||
</androidx.core.widget.NestedScrollView>
|
||||
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
|
||||
@@ -24,16 +24,21 @@
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/view_home_title_bar" />
|
||||
|
||||
<androidx.core.widget.NestedScrollView
|
||||
android:id="@+id/nsv_home_recommendation_content"
|
||||
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||
android:id="@+id/swipe_home_recommendation"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
android:fillViewport="true"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/text_tab_bar_home">
|
||||
|
||||
<androidx.core.widget.NestedScrollView
|
||||
android:id="@+id/nsv_home_recommendation_content"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:fillViewport="true">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/ll_home_recommendation_content"
|
||||
android:layout_width="match_parent"
|
||||
@@ -216,19 +221,25 @@
|
||||
android:textColor="@color/gray_500" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
</androidx.core.widget.NestedScrollView>
|
||||
</androidx.core.widget.NestedScrollView>
|
||||
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
|
||||
|
||||
<androidx.core.widget.NestedScrollView
|
||||
android:id="@+id/nsv_home_following_content"
|
||||
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||
android:id="@+id/swipe_home_following"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
android:fillViewport="true"
|
||||
android:visibility="gone"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/text_tab_bar_home">
|
||||
|
||||
<androidx.core.widget.NestedScrollView
|
||||
android:id="@+id/nsv_home_following_content"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:fillViewport="true">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/ll_home_following_content"
|
||||
android:layout_width="match_parent"
|
||||
@@ -357,20 +368,27 @@
|
||||
tools:listitem="@layout/item_home_following_news_content" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
</androidx.core.widget.NestedScrollView>
|
||||
</androidx.core.widget.NestedScrollView>
|
||||
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/rv_home_creator_rankings"
|
||||
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||
android:id="@+id/swipe_home_ranking"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
android:clipToPadding="false"
|
||||
android:paddingHorizontal="@dimen/spacing_14"
|
||||
android:paddingTop="@dimen/spacing_14"
|
||||
android:paddingBottom="@dimen/spacing_28"
|
||||
android:visibility="gone"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/text_tab_bar_home"
|
||||
tools:listitem="@layout/view_creator_ranking_lower_row" />
|
||||
app:layout_constraintTop_toBottomOf="@id/text_tab_bar_home">
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/rv_home_creator_rankings"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:clipToPadding="false"
|
||||
android:paddingHorizontal="@dimen/spacing_14"
|
||||
android:paddingTop="@dimen/spacing_14"
|
||||
android:paddingBottom="@dimen/spacing_28"
|
||||
tools:listitem="@layout/view_creator_ranking_lower_row" />
|
||||
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
package kr.co.vividnext.sodalive.v2.main
|
||||
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import java.io.File
|
||||
|
||||
class PullRefreshRequestRaceSourceTest {
|
||||
|
||||
@Test
|
||||
fun `첫 페이지 ViewModel은 중복 loading 요청과 오래된 응답을 방어한다`() {
|
||||
listOf(
|
||||
"app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/HomeRecommendationViewModel.kt",
|
||||
"app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/HomeCreatorRankingViewModel.kt",
|
||||
"app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/HomeFollowingViewModel.kt",
|
||||
"app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/ContentMainViewModel.kt"
|
||||
).forEach { path ->
|
||||
val source = projectFile(path).readText()
|
||||
|
||||
assertTrue("$path should ignore duplicate first-page loads", source.contains("if (_isLoading.value == true) return"))
|
||||
assertTrue("$path should track latest first-page request", source.contains("private var requestGeneration: Int = 0"))
|
||||
assertTrue("$path should create a request generation", source.contains("val generation = ++requestGeneration"))
|
||||
assertTrue(
|
||||
"$path should ignore stale success responses",
|
||||
source.contains("if (generation != requestGeneration) return@subscribe")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `자동 로딩 중 pull refresh는 같은 key 요청만 무시하고 다른 조건 요청은 허용한다`() {
|
||||
listOf(
|
||||
"app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/ContentRankingViewModel.kt",
|
||||
"app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/ContentAllTabViewModel.kt",
|
||||
"app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/ChatMainViewModel.kt"
|
||||
).forEach { path ->
|
||||
val source = projectFile(path).readText()
|
||||
|
||||
assertTrue("$path should track active first-page request key", source.contains("activeFirstPageKey"))
|
||||
assertTrue(
|
||||
"$path should ignore only same first-page key",
|
||||
source.contains("if (activeFirstPageKey == requestKey) return")
|
||||
)
|
||||
assertTrue("$path should clear latest first-page key", source.contains("clearActiveFirstPageKey(requestKey,"))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fragment는 pull refresh 완료를 시작한 탭이나 filter에만 묶는다`() {
|
||||
val home = projectFile("app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/HomeMainFragment.kt").readText()
|
||||
val content = projectFile("app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/ContentMainFragment.kt").readText()
|
||||
val chat = projectFile("app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/ChatMainFragment.kt").readText()
|
||||
|
||||
assertTrue(home.contains("private var refreshingHomeTabIndex: Int? = null"))
|
||||
assertTrue(home.contains("refreshingHomeTabIndex = currentHomeTabIndex"))
|
||||
assertTrue(home.contains("if (refreshingHomeTabIndex != tabIndex) return"))
|
||||
assertTrue(home.contains("scrollHomeTabToTop(tabIndex)"))
|
||||
|
||||
assertTrue(content.contains("private var refreshingContentTab: Int? = null"))
|
||||
assertTrue(content.contains("refreshingContentTab = currentContentTab"))
|
||||
assertTrue(content.contains("if (refreshingContentTab != tab) return"))
|
||||
assertTrue(content.contains("scrollContentTabToTop(tab)"))
|
||||
|
||||
assertTrue(chat.contains("private var refreshingChatFilter: ChatRoomFilter? = null"))
|
||||
assertTrue(chat.contains("refreshingChatFilter = selectedFilter"))
|
||||
assertTrue(chat.contains("val isRefreshForCurrentFilter = refreshingChatFilter == selectedFilter"))
|
||||
}
|
||||
|
||||
private fun projectFile(relativePath: String): File {
|
||||
val candidates = listOf(File(relativePath), File("../$relativePath"))
|
||||
return candidates.firstOrNull { it.exists() }
|
||||
?: error("Missing project file: $relativePath")
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,6 @@ import android.widget.ImageView
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import androidx.constraintlayout.widget.ConstraintLayout
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import kr.co.vividnext.sodalive.R
|
||||
import kr.co.vividnext.sodalive.v2.widget.CapsuleTabBarView
|
||||
@@ -50,41 +49,16 @@ class ChatMainFragmentLayoutTest {
|
||||
val root = inflateView(R.layout.fragment_v2_main_chat) as ConstraintLayout
|
||||
val titleBar = requireNotNull(root.findViewById<View>(R.id.view_chat_title_bar))
|
||||
val tabBar = requireNotNull(root.findViewById<CapsuleTabBarView>(R.id.view_chat_filter_tabs))
|
||||
val recyclerView = requireNotNull(root.findViewById<RecyclerView>(R.id.rv_chat_rooms))
|
||||
val emptyMessage = requireNotNull(root.findViewById<TextView>(R.id.tv_chat_empty_message))
|
||||
|
||||
assertEquals(Color.BLACK, (root.background as ColorDrawable).color)
|
||||
assertSame(root, titleBar.parent)
|
||||
assertSame(root, tabBar.parent)
|
||||
assertSame(root, recyclerView.parent)
|
||||
assertSame(root, emptyMessage.parent)
|
||||
assertEquals(View.GONE, emptyMessage.visibility)
|
||||
assertFalse(root.containsClassName("com.google.android.material.bottomnavigation.BottomNavigationView"))
|
||||
assertFalse(root.containsViewIdContaining("unread"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `채팅 fragment list는 계획된 constraint를 사용한다`() {
|
||||
val root = inflateView(R.layout.fragment_v2_main_chat)
|
||||
val titleBar = requireNotNull(root.findViewById<View>(R.id.view_chat_title_bar))
|
||||
val tabBar = requireNotNull(root.findViewById<CapsuleTabBarView>(R.id.view_chat_filter_tabs))
|
||||
val recyclerView = requireNotNull(root.findViewById<RecyclerView>(R.id.rv_chat_rooms))
|
||||
val emptyMessage = requireNotNull(root.findViewById<TextView>(R.id.tv_chat_empty_message))
|
||||
val tabParams = tabBar.layoutParams as ConstraintLayout.LayoutParams
|
||||
val listParams = recyclerView.layoutParams as ConstraintLayout.LayoutParams
|
||||
val emptyParams = emptyMessage.layoutParams as ConstraintLayout.LayoutParams
|
||||
|
||||
assertEquals(60.dpToPx(), titleBar.layoutParams.height)
|
||||
assertEquals(52.dpToPx(), tabBar.layoutParams.height)
|
||||
assertEquals(R.id.view_chat_title_bar, tabParams.topToBottom)
|
||||
assertEquals(R.id.view_chat_filter_tabs, listParams.topToBottom)
|
||||
assertEquals(ConstraintLayout.LayoutParams.PARENT_ID, listParams.bottomToBottom)
|
||||
assertEquals(R.id.view_chat_filter_tabs, emptyParams.topToBottom)
|
||||
assertEquals(ConstraintLayout.LayoutParams.PARENT_ID, emptyParams.bottomToBottom)
|
||||
assertEquals(false, recyclerView.clipToPadding)
|
||||
assertTrue(recyclerView.paddingBottom >= 28.dpToPx())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `채팅 fragment source는 화면 초기 구성과 첫 페이지 로드를 연결한다`() {
|
||||
val source = chatMainFragmentSource()
|
||||
@@ -125,6 +99,26 @@ class ChatMainFragmentLayoutTest {
|
||||
assertTrue(source.contains("viewModel.toastLiveData.observe(viewLifecycleOwner)"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `chat refresh reloads first page with selected filter`() {
|
||||
val source = chatMainFragmentSource()
|
||||
|
||||
assertTrue(source.contains("binding.swipeChatRooms.setOnRefreshListener"))
|
||||
assertTrue(source.contains("binding.swipeChatRooms.setOnChildScrollUpCallback"))
|
||||
assertTrue(source.contains("binding.rvChatRooms.canScrollVertically(-1)"))
|
||||
assertTrue(source.contains("viewModel.loadFirstPage(selectedFilter)"))
|
||||
assertTrue(source.contains("binding.swipeChatRooms.isRefreshing = false"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `채팅 empty 상태에서 pull refresh loading은 empty message를 숨기지 않는다`() {
|
||||
val source = chatMainFragmentSource()
|
||||
|
||||
assertTrue(source.contains("ChatRoomListUiState.Loading -> {"))
|
||||
assertTrue(source.contains("if (!isChatPullRefreshing)"))
|
||||
assertTrue(source.contains("binding.tvChatEmptyMessage.isVisible = false"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `채팅 fragment source는 AI와 DM 항목을 각각 채팅방으로 이동한다`() {
|
||||
val source = chatMainFragmentSource()
|
||||
@@ -144,21 +138,61 @@ class ChatMainFragmentLayoutTest {
|
||||
val source = chatMainFragmentSource()
|
||||
|
||||
assertTrue(source.contains("setOnTabSelectedListener"))
|
||||
assertTrue(source.contains("rvChatRooms.scrollToPosition(0)"))
|
||||
assertTrue(
|
||||
source.contains(
|
||||
"val shouldScrollToTop = !isRestoredPullRefreshError &&"
|
||||
)
|
||||
)
|
||||
assertTrue(source.contains("((isChatPullRefreshing && isRefreshForCurrentFilter) || !state.isAppending)"))
|
||||
assertTrue(source.contains("if (shouldScrollToTop)"))
|
||||
assertTrue(source.contains("!state.isAppending"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `채팅 pull refresh 실패로 이전 상태가 재방출되면 스크롤을 최상단으로 이동하지 않는다`() {
|
||||
val source = chatMainFragmentSource()
|
||||
|
||||
assertTrue(source.contains("private var lastRenderedChatContentState"))
|
||||
assertTrue(source.contains("private var refreshingChatFilter: ChatRoomFilter? = null"))
|
||||
assertTrue(source.contains("private fun cancelChatPullRefresh()"))
|
||||
assertTrue(source.contains("if (refreshingChatFilter != null && refreshingChatFilter != filter)"))
|
||||
assertTrue(source.contains("binding.swipeChatRooms.isRefreshing = false"))
|
||||
assertTrue(
|
||||
source.contains("val isRestoredPullRefreshError = isChatPullRefreshing &&")
|
||||
)
|
||||
assertTrue(source.contains("isRefreshForCurrentFilter &&"))
|
||||
assertTrue(source.contains("lastRenderedChatContentState === state"))
|
||||
assertTrue(
|
||||
source.contains(
|
||||
"val shouldScrollToTop = !isRestoredPullRefreshError &&"
|
||||
)
|
||||
)
|
||||
assertTrue(source.contains("if (shouldScrollToTop)"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `채팅 fragment source는 탭별 empty state 문구를 표시한다`() {
|
||||
val source = chatMainFragmentSource()
|
||||
val filter = projectFile(
|
||||
"app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/model/ChatRoomFilter.kt"
|
||||
).readText()
|
||||
val errorBranch = source.substringBetween(
|
||||
"is ChatRoomListUiState.Error ->",
|
||||
"ChatRoomListUiState.Loading ->"
|
||||
)
|
||||
val emptyBranch = source.substringBetween(
|
||||
"ChatRoomListUiState.Empty ->",
|
||||
"is ChatRoomListUiState.Error ->"
|
||||
)
|
||||
|
||||
assertTrue(source.contains("private var selectedFilter: ChatRoomFilter = ChatRoomFilter.ALL"))
|
||||
assertTrue(source.contains("private var isChatPullRefreshing = false"))
|
||||
assertTrue(source.contains("isChatPullRefreshing = true"))
|
||||
assertTrue(source.contains("refreshingChatFilter = selectedFilter"))
|
||||
assertTrue(source.contains("binding.tvChatEmptyMessage.setText(selectedFilter.emptyMessageResId())"))
|
||||
assertTrue(source.contains("ChatRoomListUiState.Empty -> bindChatRooms(emptyList(), showEmpty = true)"))
|
||||
assertTrue(source.contains("is ChatRoomListUiState.Error -> bindChatRooms(emptyList(), showEmpty = false)"))
|
||||
assertTrue(emptyBranch.contains("bindChatRooms(emptyList(), showEmpty = true)"))
|
||||
assertTrue(errorBranch.contains("if (!isChatPullRefreshing || !isRefreshForCurrentFilter)"))
|
||||
assertTrue(errorBranch.contains("bindChatRooms(emptyList(), showEmpty = false)"))
|
||||
assertTrue(source.contains("binding.rvChatRooms.isVisible = !showEmpty"))
|
||||
assertTrue(filter.contains("ChatRoomFilter.ALL -> R.string.screen_chat_empty_all"))
|
||||
assertTrue(filter.contains("ChatRoomFilter.AI -> R.string.screen_chat_empty_ai"))
|
||||
@@ -174,6 +208,14 @@ class ChatMainFragmentLayoutTest {
|
||||
"app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/ChatMainFragment.kt"
|
||||
).readText()
|
||||
|
||||
private fun String.substringBetween(startMarker: String, endMarker: String): String {
|
||||
val startIndex = indexOf(startMarker)
|
||||
assertTrue("Missing start marker: $startMarker", startIndex >= 0)
|
||||
val endIndex = indexOf(endMarker, startIndex + startMarker.length)
|
||||
assertTrue("Missing end marker: $endMarker", endIndex >= 0)
|
||||
return substring(startIndex, endIndex)
|
||||
}
|
||||
|
||||
private fun projectFile(relativePath: String): File {
|
||||
val candidates = listOf(
|
||||
File(relativePath),
|
||||
@@ -197,9 +239,4 @@ class ChatMainFragmentLayoutTest {
|
||||
if (this !is ViewGroup) return false
|
||||
return (0 until childCount).any { getChildAt(it).containsClassName(className) }
|
||||
}
|
||||
|
||||
private fun Int.dpToPx(): Int {
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
return (this * context.resources.displayMetrics.density).toInt()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,6 +89,25 @@ class ChatMainViewModelTest {
|
||||
assertEquals(1, api.calls.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `loadFirstPage with current filter requests first page and replaces existing rooms`() {
|
||||
api.enqueueSuccess(page(rooms = listOf(room(roomId = 1L)), hasMore = true, nextCursor = "next"))
|
||||
viewModel.loadFirstPage(ChatRoomFilter.AI)
|
||||
api.enqueueSuccess(page(rooms = listOf(room(roomId = 2L)), hasMore = false, nextCursor = null))
|
||||
|
||||
viewModel.loadFirstPage(ChatRoomFilter.AI)
|
||||
|
||||
assertEquals(
|
||||
listOf(
|
||||
ApiCall("Bearer test-token", "AI", null),
|
||||
ApiCall("Bearer test-token", "AI", null)
|
||||
),
|
||||
api.calls
|
||||
)
|
||||
val state = viewModel.chatRoomStateLiveData.value as ChatRoomListUiState.Content
|
||||
assertEquals(listOf(2L), state.items.map { it.roomId })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `첫 페이지 success와 rooms가 있으면 Content를 emit한다`() {
|
||||
api.enqueueSuccess(page(rooms = listOf(room(roomId = 10L, targetName = "크리에이터"))))
|
||||
@@ -128,6 +147,95 @@ class ChatMainViewModelTest {
|
||||
assertFalse(state.isAppending)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `같은 filter 첫 페이지 refresh 실패 후 loadNextPage는 이전 cursor로 append한다`() {
|
||||
api.enqueueSuccess(page(rooms = listOf(room(roomId = 1L)), hasMore = true, nextCursor = "next-1"))
|
||||
viewModel.loadFirstPage(ChatRoomFilter.AI)
|
||||
api.enqueueSuccess(ApiResponse(false, null, "refresh failed"))
|
||||
|
||||
viewModel.loadFirstPage(ChatRoomFilter.AI)
|
||||
val restoredState = viewModel.chatRoomStateLiveData.requireValue() as ChatRoomListUiState.Content
|
||||
api.enqueueSuccess(page(rooms = listOf(room(roomId = 2L)), hasMore = false, nextCursor = null))
|
||||
viewModel.loadNextPage()
|
||||
val appendedState = viewModel.chatRoomStateLiveData.requireValue() as ChatRoomListUiState.Content
|
||||
|
||||
assertEquals(listOf(1L), restoredState.items.map { it.roomId })
|
||||
assertEquals(ApiCall("Bearer test-token", "AI", "next-1"), api.calls.last())
|
||||
assertEquals(listOf(1L, 2L), appendedState.items.map { it.roomId })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `같은 filter 첫 페이지 요청이 진행 중이면 중복 호출을 무시한다`() {
|
||||
val pending = SingleSubject.create<ApiResponse<ChatRoomListPageResponse>>()
|
||||
api.enqueue(pending)
|
||||
|
||||
viewModel.loadFirstPage(ChatRoomFilter.ALL)
|
||||
viewModel.loadFirstPage(ChatRoomFilter.ALL)
|
||||
|
||||
assertEquals(1, api.calls.size)
|
||||
|
||||
pending.onSuccess(ApiResponse(true, page(rooms = listOf(room(roomId = 1L))), null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `다른 filter 첫 페이지 요청은 기존 요청 진행 중에도 허용한다`() {
|
||||
val pendingAll = SingleSubject.create<ApiResponse<ChatRoomListPageResponse>>()
|
||||
val pendingDm = SingleSubject.create<ApiResponse<ChatRoomListPageResponse>>()
|
||||
api.enqueue(pendingAll)
|
||||
api.enqueue(pendingDm)
|
||||
|
||||
viewModel.loadFirstPage(ChatRoomFilter.ALL)
|
||||
viewModel.loadFirstPage(ChatRoomFilter.DM)
|
||||
|
||||
assertEquals(
|
||||
listOf(
|
||||
ApiCall("Bearer test-token", "ALL", null),
|
||||
ApiCall("Bearer test-token", "DM", null)
|
||||
),
|
||||
api.calls
|
||||
)
|
||||
|
||||
pendingAll.onSuccess(ApiResponse(true, page(rooms = listOf(room(roomId = 1L))), null))
|
||||
pendingDm.onSuccess(ApiResponse(true, page(rooms = listOf(room(roomId = 2L))), null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `filter를 A에서 B로 바꾼 뒤 A 응답 전 다시 A를 요청하면 최신 A 요청을 보낸다`() {
|
||||
val pendingAllFirst = SingleSubject.create<ApiResponse<ChatRoomListPageResponse>>()
|
||||
val pendingDm = SingleSubject.create<ApiResponse<ChatRoomListPageResponse>>()
|
||||
val pendingAllLatest = SingleSubject.create<ApiResponse<ChatRoomListPageResponse>>()
|
||||
api.enqueue(pendingAllFirst)
|
||||
api.enqueue(pendingDm)
|
||||
api.enqueue(pendingAllLatest)
|
||||
|
||||
viewModel.loadFirstPage(ChatRoomFilter.ALL)
|
||||
viewModel.loadFirstPage(ChatRoomFilter.DM)
|
||||
viewModel.loadFirstPage(ChatRoomFilter.ALL)
|
||||
|
||||
assertEquals(
|
||||
listOf(
|
||||
ApiCall("Bearer test-token", "ALL", null),
|
||||
ApiCall("Bearer test-token", "DM", null),
|
||||
ApiCall("Bearer test-token", "ALL", null)
|
||||
),
|
||||
api.calls
|
||||
)
|
||||
|
||||
pendingAllFirst.onSuccess(ApiResponse(true, page(rooms = listOf(room(roomId = 1L))), null))
|
||||
pendingDm.onSuccess(ApiResponse(true, page(rooms = listOf(room(roomId = 2L, chatType = "DM"))), null))
|
||||
pendingAllLatest.onSuccess(
|
||||
ApiResponse(true, page(rooms = listOf(room(roomId = 3L)), hasMore = true, nextCursor = "all-next"), null)
|
||||
)
|
||||
val state = viewModel.chatRoomStateLiveData.requireValue() as ChatRoomListUiState.Content
|
||||
|
||||
assertEquals(listOf(3L), state.items.map { it.roomId })
|
||||
api.enqueueSuccess(page(rooms = listOf(room(roomId = 4L)), hasMore = false, nextCursor = null))
|
||||
|
||||
viewModel.loadNextPage()
|
||||
|
||||
assertEquals(ApiCall("Bearer test-token", "ALL", "all-next"), api.calls.last())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `hasMore가 false이면 다음 페이지 API를 호출하지 않는다`() {
|
||||
api.enqueueSuccess(page(rooms = listOf(room(roomId = 1L)), hasMore = false, nextCursor = null))
|
||||
|
||||
@@ -517,6 +517,218 @@ class ContentAllTabViewModelTest {
|
||||
verifyGetContents(page = 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `첫 페이지 refresh 실패 후 loadMore는 이전 page 다음을 append한다`() {
|
||||
whenever(
|
||||
repository.getContents(
|
||||
"Bearer test-token",
|
||||
MainContentAllType.AUDIO,
|
||||
ContentSort.LATEST,
|
||||
0,
|
||||
DEFAULT_PAGE_SIZE,
|
||||
null
|
||||
)
|
||||
)
|
||||
.thenReturn(
|
||||
Single.just(ApiResponse(true, response(audios = listOf(audio(1L)), hasNext = true), null)),
|
||||
Single.just(ApiResponse(false, null, "refresh failed"))
|
||||
)
|
||||
stubGetContents(
|
||||
page = 1,
|
||||
response = Single.just(ApiResponse(true, response(page = 1, audios = listOf(audio(2L)), hasNext = false), null))
|
||||
)
|
||||
|
||||
viewModel.loadContents()
|
||||
viewModel.loadContents()
|
||||
val restoredState = viewModel.allTabStateLiveData.requireValue() as MainContentAllTabUiState.Content
|
||||
viewModel.loadMore()
|
||||
val appendedState = viewModel.allTabStateLiveData.requireValue() as MainContentAllTabUiState.Content
|
||||
|
||||
assertEquals(0, restoredState.page)
|
||||
assertTrue(restoredState.hasNext)
|
||||
assertEquals(null, restoredState.paginationErrorMessage)
|
||||
assertEquals(listOf(1L), restoredState.audioItems.map { it.audioContentId })
|
||||
assertEquals(1, appendedState.page)
|
||||
assertEquals(listOf(1L, 2L), appendedState.audioItems.map { it.audioContentId })
|
||||
verifyGetContents(page = 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `같은 조건 첫 페이지 요청이 진행 중이면 중복 호출을 무시한다`() {
|
||||
val pending = SingleSubject.create<ApiResponse<MainContentAllTabResponse>>()
|
||||
stubGetContents(response = pending)
|
||||
|
||||
viewModel.loadContents()
|
||||
viewModel.loadContents()
|
||||
|
||||
verifyGetContents(times = 1)
|
||||
|
||||
pending.onSuccess(ApiResponse(true, response(audios = listOf(audio(1L))), null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `다른 조건 첫 페이지 요청은 기존 요청 진행 중에도 허용한다`() {
|
||||
val pendingAudio = SingleSubject.create<ApiResponse<MainContentAllTabResponse>>()
|
||||
val pendingFree = SingleSubject.create<ApiResponse<MainContentAllTabResponse>>()
|
||||
stubGetContents(response = pendingAudio)
|
||||
stubGetContents(type = MainContentAllType.FREE, response = pendingFree)
|
||||
|
||||
viewModel.loadContents()
|
||||
viewModel.changeType(MainContentAllType.FREE)
|
||||
|
||||
verifyGetContents(type = MainContentAllType.AUDIO)
|
||||
verifyGetContents(type = MainContentAllType.FREE)
|
||||
|
||||
pendingAudio.onSuccess(ApiResponse(true, response(audios = listOf(audio(1L))), null))
|
||||
pendingFree.onSuccess(ApiResponse(true, response(type = MainContentAllType.FREE), null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `오래된 첫 페이지 응답 후 같은 key를 다시 요청할 수 있다`() {
|
||||
val pendingAudio = SingleSubject.create<ApiResponse<MainContentAllTabResponse>>()
|
||||
val pendingFree = SingleSubject.create<ApiResponse<MainContentAllTabResponse>>()
|
||||
whenever(
|
||||
repository.getContents(
|
||||
"Bearer test-token",
|
||||
MainContentAllType.AUDIO,
|
||||
ContentSort.LATEST,
|
||||
0,
|
||||
DEFAULT_PAGE_SIZE,
|
||||
null
|
||||
)
|
||||
)
|
||||
.thenReturn(
|
||||
pendingAudio,
|
||||
Single.just(ApiResponse(true, response(audios = listOf(audio(3L))), null))
|
||||
)
|
||||
stubGetContents(type = MainContentAllType.FREE, response = pendingFree)
|
||||
|
||||
viewModel.loadContents()
|
||||
viewModel.changeType(MainContentAllType.FREE)
|
||||
pendingAudio.onSuccess(ApiResponse(true, response(audios = listOf(audio(1L))), null))
|
||||
viewModel.changeType(MainContentAllType.AUDIO)
|
||||
|
||||
verifyGetContents(type = MainContentAllType.AUDIO, times = 2)
|
||||
|
||||
pendingFree.onSuccess(ApiResponse(true, response(type = MainContentAllType.FREE), null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `type을 A에서 B로 바꾼 뒤 A 응답 전 다시 A를 선택하면 최신 A 요청을 보낸다`() {
|
||||
val pendingAudioFirst = SingleSubject.create<ApiResponse<MainContentAllTabResponse>>()
|
||||
val pendingAudioLatest = SingleSubject.create<ApiResponse<MainContentAllTabResponse>>()
|
||||
val pendingFree = SingleSubject.create<ApiResponse<MainContentAllTabResponse>>()
|
||||
whenever(
|
||||
repository.getContents(
|
||||
"Bearer test-token",
|
||||
MainContentAllType.AUDIO,
|
||||
ContentSort.LATEST,
|
||||
0,
|
||||
DEFAULT_PAGE_SIZE,
|
||||
null
|
||||
)
|
||||
)
|
||||
.thenReturn(pendingAudioFirst, pendingAudioLatest)
|
||||
stubGetContents(type = MainContentAllType.FREE, response = pendingFree)
|
||||
|
||||
viewModel.loadContents()
|
||||
viewModel.changeType(MainContentAllType.FREE)
|
||||
viewModel.changeType(MainContentAllType.AUDIO)
|
||||
|
||||
verifyGetContents(type = MainContentAllType.AUDIO, times = 2)
|
||||
|
||||
pendingAudioFirst.onSuccess(ApiResponse(true, response(audios = listOf(audio(1L))), null))
|
||||
pendingFree.onSuccess(ApiResponse(true, response(type = MainContentAllType.FREE, audios = listOf(audio(2L))), null))
|
||||
pendingAudioLatest.onSuccess(
|
||||
ApiResponse(true, response(audios = listOf(audio(3L)), hasNext = true), null)
|
||||
)
|
||||
val state = viewModel.allTabStateLiveData.requireValue() as MainContentAllTabUiState.Content
|
||||
|
||||
assertEquals(MainContentAllType.AUDIO, state.selectedType)
|
||||
assertEquals(listOf(3L), state.audioItems.map { it.audioContentId })
|
||||
stubGetContents(
|
||||
type = MainContentAllType.AUDIO,
|
||||
page = 1,
|
||||
response = Single.just(ApiResponse(true, response(page = 1, audios = listOf(audio(4L))), null))
|
||||
)
|
||||
|
||||
viewModel.loadMore()
|
||||
|
||||
verifyGetContents(type = MainContentAllType.AUDIO, page = 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `첫 페이지 refresh 성공과 실패 결과를 명시적으로 emit한다`() {
|
||||
whenever(
|
||||
repository.getContents(
|
||||
"Bearer test-token",
|
||||
MainContentAllType.AUDIO,
|
||||
ContentSort.LATEST,
|
||||
0,
|
||||
DEFAULT_PAGE_SIZE,
|
||||
null
|
||||
)
|
||||
)
|
||||
.thenReturn(
|
||||
Single.just(ApiResponse(true, response(audios = listOf(audio(1L))), null)),
|
||||
Single.just(ApiResponse(true, response(audios = listOf(audio(1L))), null)),
|
||||
Single.just(ApiResponse(false, null, "refresh failed"))
|
||||
)
|
||||
|
||||
viewModel.loadContents()
|
||||
viewModel.loadContents()
|
||||
assertEquals(ContentAllTabRefreshResult.Success, viewModel.refreshResultLiveData.requireValue())
|
||||
|
||||
viewModel.loadContents()
|
||||
assertEquals(ContentAllTabRefreshResult.Failure, viewModel.refreshResultLiveData.requireValue())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `loadMore 진행 중 refresh 실패 후 loadMore는 다시 재시도할 수 있다`() {
|
||||
val pendingLoadMore = SingleSubject.create<ApiResponse<MainContentAllTabResponse>>()
|
||||
whenever(
|
||||
repository.getContents(
|
||||
"Bearer test-token",
|
||||
MainContentAllType.AUDIO,
|
||||
ContentSort.LATEST,
|
||||
0,
|
||||
DEFAULT_PAGE_SIZE,
|
||||
null
|
||||
)
|
||||
)
|
||||
.thenReturn(
|
||||
Single.just(ApiResponse(true, response(audios = listOf(audio(1L)), hasNext = true), null)),
|
||||
Single.just(ApiResponse(false, null, "refresh failed"))
|
||||
)
|
||||
whenever(
|
||||
repository.getContents(
|
||||
"Bearer test-token",
|
||||
MainContentAllType.AUDIO,
|
||||
ContentSort.LATEST,
|
||||
1,
|
||||
DEFAULT_PAGE_SIZE,
|
||||
null
|
||||
)
|
||||
)
|
||||
.thenReturn(
|
||||
pendingLoadMore,
|
||||
Single.just(ApiResponse(true, response(page = 1, audios = listOf(audio(2L)), hasNext = false), null))
|
||||
)
|
||||
|
||||
viewModel.loadContents()
|
||||
viewModel.loadMore()
|
||||
viewModel.loadContents()
|
||||
val restoredState = viewModel.allTabStateLiveData.requireValue() as MainContentAllTabUiState.Content
|
||||
viewModel.loadMore()
|
||||
val appendedState = viewModel.allTabStateLiveData.requireValue() as MainContentAllTabUiState.Content
|
||||
|
||||
assertFalse(restoredState.isLoadingMore)
|
||||
assertEquals(null, restoredState.paginationErrorMessage)
|
||||
assertEquals(1, appendedState.page)
|
||||
assertEquals(listOf(1L, 2L), appendedState.audioItems.map { it.audioContentId })
|
||||
verifyGetContents(page = 1, times = 2)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `loadMore 요청이 진행 중이면 중복 요청하지 않는다`() {
|
||||
val pending = SingleSubject.create<ApiResponse<MainContentAllTabResponse>>()
|
||||
|
||||
@@ -71,6 +71,84 @@ class ContentMainFragmentSourceTest {
|
||||
assertTrue(source.contains("screen_content_ranking_type_like_count"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `content refresh dispatches by selected content tab`() {
|
||||
val source = projectFile(
|
||||
"app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/ContentMainFragment.kt"
|
||||
).readText()
|
||||
|
||||
assertTrue(source.contains("setUpRefreshLayouts()"))
|
||||
assertTrue(source.contains("private fun refreshCurrentContentTab()"))
|
||||
assertTrue(source.contains("CONTENT_TAB_RECOMMENDATION -> contentMainViewModel.loadRecommendations()"))
|
||||
assertTrue(source.contains("CONTENT_TAB_RANKING -> refreshCurrentRankingTab()"))
|
||||
assertTrue(source.contains("CONTENT_TAB_ALL -> contentAllTabViewModel.loadContents()"))
|
||||
assertTrue(source.contains("contentRankingViewModel.loadRankings(type, force = true)"))
|
||||
assertTrue(source.contains("binding.swipeContentRecommendation.isRefreshing = false"))
|
||||
assertTrue(source.contains("binding.swipeContentRanking.isRefreshing = false"))
|
||||
assertTrue(source.contains("binding.swipeContentAll.isRefreshing = false"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `content pull refresh error preserves visible content and success scrolls current tab to top`() {
|
||||
val source = projectFile(
|
||||
"app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/ContentMainFragment.kt"
|
||||
).readText()
|
||||
val updateLoadingDialog = source.substringFrom("private fun updateLoadingDialog()")
|
||||
val finishPullRefresh = source.substringFrom("private fun finishContentPullRefresh")
|
||||
|
||||
assertTrue(source.contains("private var isContentPullRefreshing = false"))
|
||||
assertTrue(source.contains("private var refreshingContentTab: Int? = null"))
|
||||
assertTrue(source.contains("private var hasRenderedAllTabContent = false"))
|
||||
assertTrue(source.contains("isContentPullRefreshing = true"))
|
||||
assertTrue(source.contains("refreshingContentTab = currentContentTab"))
|
||||
assertTrue(source.contains("private fun shouldPreserveContentOnPullRefreshError(tab: Int): Boolean"))
|
||||
assertEquals(2, source.countOccurrences("if (shouldPreserveContentOnPullRefreshError(CONTENT_TAB_"))
|
||||
assertTrue(
|
||||
source.contains("val isAllTabPullRefresh = isContentPullRefreshing && refreshingContentTab == CONTENT_TAB_ALL")
|
||||
)
|
||||
assertTrue(
|
||||
source.contains("state is MainContentAllTabUiState.Loading && isAllTabPullRefresh && hasRenderedAllTabContent")
|
||||
)
|
||||
assertTrue(
|
||||
source.contains("state is MainContentAllTabUiState.Error && isAllTabPullRefresh && hasRenderedAllTabContent")
|
||||
)
|
||||
assertTrue(
|
||||
source.contains("if (isContentPullRefreshing && refreshingContentTab == CONTENT_TAB_ALL && hasRenderedAllTabContent)")
|
||||
)
|
||||
assertTrue(source.contains("private fun finishContentPullRefresh(tab: Int, isRestoredPullRefreshError: Boolean = false)"))
|
||||
assertTrue(source.contains("private fun cancelContentPullRefresh(tab: Int? = refreshingContentTab)"))
|
||||
assertTrue(source.contains("private fun scrollContentTabToTop(tab: Int)"))
|
||||
assertTrue(source.contains("CONTENT_TAB_RECOMMENDATION -> binding.nsvContentRecommendationContent.scrollTo(0, 0)"))
|
||||
assertTrue(source.contains("CONTENT_TAB_RANKING -> binding.rvContentRankings.scrollToPosition(0)"))
|
||||
assertTrue(source.contains("CONTENT_TAB_ALL -> binding.rvContentAllItems.scrollToPosition(0)"))
|
||||
assertTrue(source.contains("val paginationErrorMessage = state.paginationErrorMessage"))
|
||||
assertTrue(source.contains("if (isContentPullRefreshing && refreshingContentTab == CONTENT_TAB_ALL)"))
|
||||
assertTrue(source.contains("private var lastRenderedContentRecommendationState"))
|
||||
assertTrue(source.contains("private var lastRenderedContentRankingState"))
|
||||
assertTrue(source.contains("refreshingContentTab == CONTENT_TAB_RECOMMENDATION"))
|
||||
assertTrue(source.contains("refreshingContentTab == CONTENT_TAB_RANKING"))
|
||||
assertTrue(source.contains("refreshingContentTab == CONTENT_TAB_ALL"))
|
||||
assertTrue(source.contains("lastRenderedContentRecommendationState === state"))
|
||||
assertTrue(source.contains("lastRenderedContentRankingState === state"))
|
||||
assertFalse(source.contains("private fun MainContentAllTabUiState.Content.isSameAllTabPage("))
|
||||
assertTrue(source.contains("contentAllTabViewModel.refreshResultLiveData.observe(viewLifecycleOwner)"))
|
||||
assertTrue(source.contains("ContentAllTabRefreshResult.Success -> finishContentPullRefresh(CONTENT_TAB_ALL)"))
|
||||
assertTrue(source.contains("ContentAllTabRefreshResult.Failure -> cancelContentPullRefresh(CONTENT_TAB_ALL)"))
|
||||
assertTrue(source.contains("finishContentPullRefresh(CONTENT_TAB_RECOMMENDATION, isRestoredPullRefreshError)"))
|
||||
assertTrue(source.contains("finishContentPullRefresh(CONTENT_TAB_RANKING, isRestoredPullRefreshError)"))
|
||||
assertFalse(source.contains("finishContentPullRefresh(CONTENT_TAB_ALL, isRestoredPullRefreshError)"))
|
||||
assertFalse(updateLoadingDialog.contains("stopContentRefreshIndicators()"))
|
||||
assertTrue(
|
||||
finishPullRefresh.contains(
|
||||
"CONTENT_TAB_RECOMMENDATION -> binding.swipeContentRecommendation.isRefreshing = false"
|
||||
)
|
||||
)
|
||||
assertTrue(finishPullRefresh.contains("CONTENT_TAB_RANKING -> binding.swipeContentRanking.isRefreshing = false"))
|
||||
assertTrue(finishPullRefresh.contains("CONTENT_TAB_ALL -> binding.swipeContentAll.isRefreshing = false"))
|
||||
assertTrue(source.contains("private fun cancelContentPullRefresh(tab: Int? = refreshingContentTab)"))
|
||||
assertTrue(source.contains("CONTENT_TAB_ALL -> binding.swipeContentAll.isRefreshing = false"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `content 전체 layout과 source는 Phase 5 요구를 포함한다`() {
|
||||
val source = projectFile(
|
||||
@@ -644,6 +722,8 @@ class ContentMainFragmentSourceTest {
|
||||
assertTrue(message ?: "Expected source to contain: $expected", source.contains(expected))
|
||||
}
|
||||
|
||||
private fun String.countOccurrences(needle: String): Int = split(needle).size - 1
|
||||
|
||||
private fun String.substringFrom(marker: String): String {
|
||||
val startIndex = indexOf(marker)
|
||||
assertTrue("Missing function: $marker", startIndex >= 0)
|
||||
|
||||
@@ -160,6 +160,132 @@ class ContentRankingViewModelTest {
|
||||
assertEquals(listOf("2"), state.items.map { it.contentId })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `같은 type 첫 페이지 요청이 진행 중이면 중복 호출을 무시한다`() {
|
||||
val pending = SingleSubject.create<ApiResponse<AudioRankingResponse>>()
|
||||
api.enqueue(pending)
|
||||
|
||||
viewModel.loadRankings(AudioRankingType.WEEKLY_POPULAR, force = true)
|
||||
viewModel.loadRankings(AudioRankingType.WEEKLY_POPULAR, force = true)
|
||||
|
||||
assertEquals(1, api.calls.size)
|
||||
|
||||
pending.onSuccess(ApiResponse(true, response(type = AudioRankingType.WEEKLY_POPULAR), null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `다른 type 첫 페이지 요청은 기존 요청 진행 중에도 허용한다`() {
|
||||
val pendingWeekly = SingleSubject.create<ApiResponse<AudioRankingResponse>>()
|
||||
val pendingRising = SingleSubject.create<ApiResponse<AudioRankingResponse>>()
|
||||
api.enqueue(pendingWeekly)
|
||||
api.enqueue(pendingRising)
|
||||
|
||||
viewModel.loadRankings(AudioRankingType.WEEKLY_POPULAR, force = true)
|
||||
viewModel.loadRankings(AudioRankingType.RISING, force = true)
|
||||
|
||||
assertEquals(
|
||||
listOf(
|
||||
ApiCall("Bearer test-token", "WEEKLY_POPULAR"),
|
||||
ApiCall("Bearer test-token", "RISING")
|
||||
),
|
||||
api.calls
|
||||
)
|
||||
|
||||
pendingWeekly.onSuccess(ApiResponse(true, response(type = AudioRankingType.WEEKLY_POPULAR), null))
|
||||
pendingRising.onSuccess(ApiResponse(true, response(type = AudioRankingType.RISING), null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `type을 A에서 B로 바꾼 뒤 A 응답 전 다시 A를 선택하면 최신 A 요청을 보낸다`() {
|
||||
val pendingWeeklyFirst = SingleSubject.create<ApiResponse<AudioRankingResponse>>()
|
||||
val pendingRising = SingleSubject.create<ApiResponse<AudioRankingResponse>>()
|
||||
val pendingWeeklyLatest = SingleSubject.create<ApiResponse<AudioRankingResponse>>()
|
||||
api.enqueue(pendingWeeklyFirst)
|
||||
api.enqueue(pendingRising)
|
||||
api.enqueue(pendingWeeklyLatest)
|
||||
|
||||
viewModel.loadRankings(AudioRankingType.WEEKLY_POPULAR, force = true)
|
||||
viewModel.loadRankings(AudioRankingType.RISING, force = true)
|
||||
viewModel.loadRankings(AudioRankingType.WEEKLY_POPULAR, force = true)
|
||||
|
||||
assertEquals(
|
||||
listOf(
|
||||
ApiCall("Bearer test-token", "WEEKLY_POPULAR"),
|
||||
ApiCall("Bearer test-token", "RISING"),
|
||||
ApiCall("Bearer test-token", "WEEKLY_POPULAR")
|
||||
),
|
||||
api.calls
|
||||
)
|
||||
|
||||
pendingWeeklyFirst.onSuccess(
|
||||
ApiResponse(true, response(type = AudioRankingType.WEEKLY_POPULAR, items = listOf(item(contentId = 1L))), null)
|
||||
)
|
||||
pendingRising.onSuccess(
|
||||
ApiResponse(true, response(type = AudioRankingType.RISING, items = listOf(item(contentId = 2L))), null)
|
||||
)
|
||||
pendingWeeklyLatest.onSuccess(
|
||||
ApiResponse(true, response(type = AudioRankingType.WEEKLY_POPULAR, items = listOf(item(contentId = 3L))), null)
|
||||
)
|
||||
val state = viewModel.rankingStateLiveData.requireValue() as AudioRankingsUiState.Content
|
||||
|
||||
assertEquals(AudioRankingType.WEEKLY_POPULAR, state.type)
|
||||
assertEquals(listOf("3"), state.items.map { it.contentId })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `진행 중인 B 응답 전 캐시된 A를 표시하면 B key가 고착되지 않는다`() {
|
||||
api.enqueueSuccess(response(type = AudioRankingType.WEEKLY_POPULAR, items = listOf(item(contentId = 1L))))
|
||||
viewModel.loadRankings(AudioRankingType.WEEKLY_POPULAR)
|
||||
val pendingWeeklyRefresh = SingleSubject.create<ApiResponse<AudioRankingResponse>>()
|
||||
val pendingRising = SingleSubject.create<ApiResponse<AudioRankingResponse>>()
|
||||
val pendingRisingLatest = SingleSubject.create<ApiResponse<AudioRankingResponse>>()
|
||||
api.enqueue(pendingWeeklyRefresh)
|
||||
api.enqueue(pendingRising)
|
||||
api.enqueue(pendingRisingLatest)
|
||||
|
||||
viewModel.loadRankings(AudioRankingType.WEEKLY_POPULAR, force = true)
|
||||
viewModel.loadRankings(AudioRankingType.RISING, force = true)
|
||||
viewModel.loadRankings(AudioRankingType.WEEKLY_POPULAR)
|
||||
pendingRising.onSuccess(
|
||||
ApiResponse(true, response(type = AudioRankingType.RISING, items = listOf(item(contentId = 2L))), null)
|
||||
)
|
||||
viewModel.loadRankings(AudioRankingType.RISING, force = true)
|
||||
|
||||
assertEquals(
|
||||
listOf(
|
||||
ApiCall("Bearer test-token", "WEEKLY_POPULAR"),
|
||||
ApiCall("Bearer test-token", "WEEKLY_POPULAR"),
|
||||
ApiCall("Bearer test-token", "RISING"),
|
||||
ApiCall("Bearer test-token", "RISING")
|
||||
),
|
||||
api.calls
|
||||
)
|
||||
|
||||
pendingWeeklyRefresh.onSuccess(
|
||||
ApiResponse(true, response(type = AudioRankingType.WEEKLY_POPULAR, items = listOf(item(contentId = 3L))), null)
|
||||
)
|
||||
pendingRisingLatest.onSuccess(
|
||||
ApiResponse(true, response(type = AudioRankingType.RISING, items = listOf(item(contentId = 4L))), null)
|
||||
)
|
||||
val state = viewModel.rankingStateLiveData.requireValue() as AudioRankingsUiState.Content
|
||||
|
||||
assertEquals(AudioRankingType.RISING, state.type)
|
||||
assertEquals(listOf("4"), state.items.map { it.contentId })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `force refresh 실패는 기존 ranking content를 유지한다`() {
|
||||
api.enqueueSuccess(response(type = AudioRankingType.WEEKLY_POPULAR, items = listOf(item(contentId = 1L))))
|
||||
viewModel.loadRankings(AudioRankingType.WEEKLY_POPULAR)
|
||||
api.enqueueSuccess(ApiResponse(false, null, "refresh failed"))
|
||||
|
||||
viewModel.loadRankings(AudioRankingType.WEEKLY_POPULAR, force = true)
|
||||
val state = viewModel.rankingStateLiveData.requireValue() as AudioRankingsUiState.Content
|
||||
|
||||
assertEquals(listOf("1"), state.items.map { it.contentId })
|
||||
assertEquals(R.string.common_error_unknown, viewModel.toastLiveData.requireValue()?.resId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `선택 type 변경 전 응답이 늦게 도착하면 화면 상태를 덮어쓰지 않는다`() {
|
||||
val pendingWeekly = SingleSubject.create<ApiResponse<AudioRankingResponse>>()
|
||||
|
||||
@@ -44,9 +44,9 @@ class HomeFollowingFragmentSourceTest {
|
||||
.substringAfter("HOME_TAB_FOLLOWING ->")
|
||||
.substringBefore("}\n }")
|
||||
|
||||
assertTrue(branch.contains("binding.nsvHomeFollowingContent.visibility = View.VISIBLE"))
|
||||
assertTrue(branch.contains("binding.nsvHomeRecommendationContent.visibility = View.GONE"))
|
||||
assertTrue(branch.contains("binding.rvHomeCreatorRankings.visibility = View.GONE"))
|
||||
assertTrue(branch.contains("binding.swipeHomeFollowing.visibility = View.VISIBLE"))
|
||||
assertTrue(branch.contains("binding.swipeHomeRecommendation.visibility = View.GONE"))
|
||||
assertTrue(branch.contains("binding.swipeHomeRanking.visibility = View.GONE"))
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -81,10 +81,14 @@ class HomeFollowingFragmentSourceTest {
|
||||
val source = homeMainFragmentSource()
|
||||
|
||||
assertTrue(source.contains("followingStateLiveData.observe(viewLifecycleOwner)"))
|
||||
assertTrue(source.contains("is HomeFollowingUiState.Content -> bindHomeFollowingContent(state)"))
|
||||
assertTrue(source.contains("HomeFollowingUiState.Empty -> bindHomeFollowingEmpty(showEmptyMessage = true)"))
|
||||
assertTrue(source.contains("HomeFollowingUiState.LoginRequired,"))
|
||||
assertTrue(source.contains("is HomeFollowingUiState.Error -> bindHomeFollowingEmpty(showEmptyMessage = false)"))
|
||||
assertTrue(source.contains("is HomeFollowingUiState.Content ->"))
|
||||
assertTrue(source.contains("bindHomeFollowingContent(state)"))
|
||||
assertTrue(source.contains("HomeFollowingUiState.Empty ->"))
|
||||
assertTrue(source.contains("bindHomeFollowingEmpty(showEmptyMessage = true)"))
|
||||
assertTrue(source.contains("HomeFollowingUiState.LoginRequired -> bindHomeFollowingEmpty(showEmptyMessage = false)"))
|
||||
assertTrue(source.contains("is HomeFollowingUiState.Error ->"))
|
||||
assertTrue(source.contains("if (shouldPreserveHomeContentOnPullRefreshError(HOME_TAB_FOLLOWING)) return@observe"))
|
||||
assertTrue(source.contains("bindHomeFollowingEmpty(showEmptyMessage = false)"))
|
||||
assertTrue(source.contains("followingCreatorAdapter.submitItems(emptyList())"))
|
||||
assertTrue(source.contains("followingLiveAdapter.submitItems(emptyList())"))
|
||||
assertTrue(source.contains("followingChatAdapter.submitItems(emptyList())"))
|
||||
@@ -136,8 +140,10 @@ class HomeFollowingFragmentSourceTest {
|
||||
|
||||
assertTrue(source.contains("binding.tvHomeFollowingEmpty.visibility = View.GONE"))
|
||||
assertTrue(source.contains("binding.tvHomeFollowingEmpty.visibility = if (showEmptyMessage) View.VISIBLE else View.GONE"))
|
||||
assertTrue(source.contains("HomeFollowingUiState.Empty -> bindHomeFollowingEmpty(showEmptyMessage = true)"))
|
||||
assertTrue(source.contains("is HomeFollowingUiState.Error -> bindHomeFollowingEmpty(showEmptyMessage = false)"))
|
||||
assertTrue(source.contains("HomeFollowingUiState.Empty ->"))
|
||||
assertTrue(source.contains("bindHomeFollowingEmpty(showEmptyMessage = true)"))
|
||||
assertTrue(source.contains("is HomeFollowingUiState.Error ->"))
|
||||
assertTrue(source.contains("bindHomeFollowingEmpty(showEmptyMessage = false)"))
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -114,6 +114,19 @@ class HomeFollowingViewModelTest {
|
||||
assertEquals(R.string.common_error_unknown, viewModel.toastLiveData.requireValue()?.resId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `refresh 실패는 기존 following content를 유지한다`() {
|
||||
api.enqueueSuccess(response(followingCreators = listOf(creator())))
|
||||
viewModel.loadFollowing()
|
||||
api.enqueueSuccess(ApiResponse(false, null, "refresh failed"))
|
||||
|
||||
viewModel.loadFollowing()
|
||||
val state = viewModel.followingStateLiveData.requireValue() as HomeFollowingUiState.Content
|
||||
|
||||
assertEquals(1L, state.followingCreators.items.single().creatorId)
|
||||
assertEquals(R.string.common_error_unknown, viewModel.toastLiveData.requireValue()?.resId)
|
||||
}
|
||||
|
||||
private fun setImmediateRxSchedulers() {
|
||||
val trampoline = { _: Scheduler -> Schedulers.trampoline() }
|
||||
RxJavaPlugins.setIoSchedulerHandler(trampoline)
|
||||
|
||||
@@ -105,7 +105,6 @@ class HomeMainFragmentLayoutTest {
|
||||
assertNotNull(scrollView)
|
||||
assertSame(root, titleBar.parent)
|
||||
assertSame(root, tabBar.parent)
|
||||
assertSame(root, scrollView.parent)
|
||||
assertEquals(60.dpToPx(), titleBar.layoutParams.height)
|
||||
assertEquals(52.dpToPx(), tabBar.layoutParams.height)
|
||||
assertFalse(root.containsClassName("androidx.viewpager2.widget.ViewPager2"))
|
||||
@@ -1202,10 +1201,10 @@ class HomeMainFragmentLayoutTest {
|
||||
assertTrue(source.contains("HOME_TAB_FOLLOWING = 2"))
|
||||
assertTrue(source.contains("binding.textTabBarHome.root.setOnTabSelectedListener { index ->"))
|
||||
assertTrue(source.contains("showHomeTab(index)"))
|
||||
assertTrue(source.contains("binding.nsvHomeRecommendationContent.visibility = View.GONE"))
|
||||
assertTrue(source.contains("binding.rvHomeCreatorRankings.visibility = View.VISIBLE"))
|
||||
assertTrue(source.contains("binding.nsvHomeRecommendationContent.visibility = View.VISIBLE"))
|
||||
assertTrue(source.contains("binding.rvHomeCreatorRankings.visibility = View.GONE"))
|
||||
assertTrue(source.contains("binding.swipeHomeRecommendation.visibility = View.GONE"))
|
||||
assertTrue(source.contains("binding.swipeHomeRanking.visibility = View.VISIBLE"))
|
||||
assertTrue(source.contains("binding.swipeHomeRecommendation.visibility = View.VISIBLE"))
|
||||
assertTrue(source.contains("binding.swipeHomeRanking.visibility = View.GONE"))
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -1223,9 +1222,12 @@ class HomeMainFragmentLayoutTest {
|
||||
val source = homeMainFragmentSource()
|
||||
|
||||
assertTrue(source.contains("rankingStateLiveData.observe(viewLifecycleOwner)"))
|
||||
assertTrue(source.contains("is HomeCreatorRankingUiState.Content -> creatorRankingAdapter.submitItems(state.items)"))
|
||||
assertTrue(source.contains("HomeCreatorRankingUiState.Empty,"))
|
||||
assertTrue(source.contains("is HomeCreatorRankingUiState.Error -> creatorRankingAdapter.submitItems(emptyList())"))
|
||||
assertTrue(source.contains("is HomeCreatorRankingUiState.Content ->"))
|
||||
assertTrue(source.contains("creatorRankingAdapter.submitItems(state.items)"))
|
||||
assertTrue(source.contains("HomeCreatorRankingUiState.Empty ->"))
|
||||
assertTrue(source.contains("is HomeCreatorRankingUiState.Error ->"))
|
||||
assertTrue(source.contains("if (shouldPreserveHomeContentOnPullRefreshError(HOME_TAB_RANKING)) return@observe"))
|
||||
assertTrue(source.contains("creatorRankingAdapter.submitItems(emptyList())"))
|
||||
assertTrue(source.contains("HomeCreatorRankingUiState.Loading -> Unit"))
|
||||
}
|
||||
|
||||
|
||||
@@ -137,11 +137,11 @@ class HomeMainFragmentLoginGuardSourceTest {
|
||||
assertTrue(source.contains("private var isLiveEntryLoading = false"))
|
||||
|
||||
val updateSource = source.substringFrom("private fun updateLoadingDialog()")
|
||||
val expectedLoadingExpression = "isRecommendationLoading || " +
|
||||
"isCreatorRankingLoading || " +
|
||||
"isFollowingLoading || " +
|
||||
"isLiveEntryLoading"
|
||||
assertTrue(updateSource.contains(expectedLoadingExpression))
|
||||
assertTrue(updateSource.contains("isRecommendationLoading ||"))
|
||||
assertTrue(updateSource.contains("isRecommendationFollowLoading ||"))
|
||||
assertTrue(updateSource.contains("isCreatorRankingLoading ||"))
|
||||
assertTrue(updateSource.contains("isFollowingLoading ||"))
|
||||
assertTrue(updateSource.contains("isLiveEntryLoading"))
|
||||
assertTrue(updateSource.contains("loadingDialog.show(screenWidth)"))
|
||||
assertTrue(updateSource.contains("loadingDialog.dismiss()"))
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package kr.co.vividnext.sodalive.v2.main.home
|
||||
|
||||
import android.app.Application
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
@@ -55,6 +56,71 @@ class HomeMainFragmentSourceTest {
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `home refresh dispatches by current selected tab`() {
|
||||
val source = projectFile(
|
||||
"app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/HomeMainFragment.kt"
|
||||
).readText()
|
||||
|
||||
assertTrue(source.contains("setUpRefreshLayouts()"))
|
||||
assertTrue(source.contains("private fun refreshCurrentHomeTab()"))
|
||||
assertTrue(source.contains("HOME_TAB_RECOMMENDATION -> homeRecommendationViewModel.loadRecommendations()"))
|
||||
assertTrue(source.contains("HOME_TAB_RANKING -> homeCreatorRankingViewModel.loadCreatorRankings()"))
|
||||
assertTrue(source.contains("HOME_TAB_FOLLOWING -> homeFollowingViewModel.loadFollowing()"))
|
||||
assertTrue(source.contains("binding.swipeHomeRecommendation.isRefreshing = false"))
|
||||
assertTrue(source.contains("binding.swipeHomeRanking.isRefreshing = false"))
|
||||
assertTrue(source.contains("binding.swipeHomeFollowing.isRefreshing = false"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `home pull refresh error preserves visible content and success scrolls current tab to top`() {
|
||||
val source = projectFile(
|
||||
"app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/HomeMainFragment.kt"
|
||||
).readText()
|
||||
val updateLoadingDialog = source.substringFrom("private fun updateLoadingDialog()")
|
||||
val finishPullRefresh = source.substringFrom("private fun finishHomePullRefresh")
|
||||
|
||||
assertTrue(source.contains("private var isHomePullRefreshing = false"))
|
||||
assertTrue(source.contains("private var refreshingHomeTabIndex: Int? = null"))
|
||||
assertTrue(source.contains("isHomePullRefreshing = true"))
|
||||
assertTrue(source.contains("refreshingHomeTabIndex = currentHomeTabIndex"))
|
||||
assertTrue(source.contains("private fun shouldPreserveHomeContentOnPullRefreshError(tabIndex: Int): Boolean"))
|
||||
assertEquals(3, source.countOccurrences("if (shouldPreserveHomeContentOnPullRefreshError(HOME_TAB_"))
|
||||
assertTrue(
|
||||
source.contains(
|
||||
"private fun finishHomePullRefresh(tabIndex: Int, isRestoredPullRefreshError: Boolean = false)"
|
||||
)
|
||||
)
|
||||
assertTrue(source.contains("private var lastRenderedHomeRecommendationState"))
|
||||
assertTrue(source.contains("private var lastRenderedHomeRankingState"))
|
||||
assertTrue(source.contains("private var lastRenderedHomeFollowingState"))
|
||||
assertEquals(3, source.countOccurrences("refreshingHomeTabIndex == HOME_TAB_"))
|
||||
assertTrue(source.contains("lastRenderedHomeRecommendationState === state"))
|
||||
assertTrue(source.contains("lastRenderedHomeRankingState === state"))
|
||||
assertTrue(source.contains("lastRenderedHomeFollowingState === state"))
|
||||
assertTrue(source.contains("finishHomePullRefresh(HOME_TAB_RECOMMENDATION, isRestoredPullRefreshError)"))
|
||||
assertTrue(source.contains("finishHomePullRefresh(HOME_TAB_RANKING, isRestoredPullRefreshError)"))
|
||||
assertTrue(source.contains("finishHomePullRefresh(HOME_TAB_FOLLOWING, isRestoredPullRefreshError)"))
|
||||
assertTrue(source.contains("private fun scrollHomeTabToTop(tabIndex: Int)"))
|
||||
assertTrue(source.contains("HOME_TAB_RECOMMENDATION -> binding.nsvHomeRecommendationContent.scrollTo(0, 0)"))
|
||||
assertTrue(source.contains("HOME_TAB_RANKING -> binding.rvHomeCreatorRankings.scrollToPosition(0)"))
|
||||
assertTrue(source.contains("HOME_TAB_FOLLOWING -> binding.nsvHomeFollowingContent.scrollTo(0, 0)"))
|
||||
assertFalse(updateLoadingDialog.contains("stopHomeRefreshIndicators()"))
|
||||
assertTrue(finishPullRefresh.contains("HOME_TAB_RECOMMENDATION -> binding.swipeHomeRecommendation.isRefreshing = false"))
|
||||
assertTrue(finishPullRefresh.contains("HOME_TAB_RANKING -> binding.swipeHomeRanking.isRefreshing = false"))
|
||||
assertTrue(finishPullRefresh.contains("HOME_TAB_FOLLOWING -> binding.swipeHomeFollowing.isRefreshing = false"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `홈 추천 follow loading은 추천 목록 loading과 분리한다`() {
|
||||
val source = projectFile(
|
||||
"app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/HomeMainFragment.kt"
|
||||
).readText()
|
||||
|
||||
assertTrue(source.contains("homeRecommendationViewModel.isLoading.observe(viewLifecycleOwner)"))
|
||||
assertTrue(source.contains("homeRecommendationViewModel.isFollowLoading.observe(viewLifecycleOwner)"))
|
||||
}
|
||||
|
||||
private fun String.substringFrom(marker: String): String {
|
||||
val startIndex = indexOf(marker)
|
||||
assertTrue("Missing function: $marker", startIndex >= 0)
|
||||
@@ -64,6 +130,8 @@ class HomeMainFragmentSourceTest {
|
||||
return substring(startIndex, nextFunctionIndex)
|
||||
}
|
||||
|
||||
private fun String.countOccurrences(needle: String): Int = split(needle).size - 1
|
||||
|
||||
private fun projectFile(relativePath: String): File {
|
||||
val candidates = listOf(File(relativePath), File("../$relativePath"))
|
||||
return candidates.firstOrNull { it.exists() }
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
package kr.co.vividnext.sodalive.v2.main.home
|
||||
|
||||
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 io.reactivex.rxjava3.subjects.SingleSubject
|
||||
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||
import kr.co.vividnext.sodalive.common.SharedPreferenceManager
|
||||
import kr.co.vividnext.sodalive.v2.main.home.data.FollowRecommendedCreatorsRequest
|
||||
import kr.co.vividnext.sodalive.v2.main.home.data.HomeAiCharacterItem
|
||||
import kr.co.vividnext.sodalive.v2.main.home.data.HomeBannerItem
|
||||
import kr.co.vividnext.sodalive.v2.main.home.data.HomeLiveItem
|
||||
import kr.co.vividnext.sodalive.v2.main.home.data.HomeActiveCreatorItem
|
||||
import kr.co.vividnext.sodalive.v2.main.home.data.HomeCreatorItem
|
||||
import kr.co.vividnext.sodalive.v2.main.home.data.HomeGenreCreatorGroupItem
|
||||
import kr.co.vividnext.sodalive.v2.main.home.data.HomePopularCommunityPostItem
|
||||
import kr.co.vividnext.sodalive.v2.main.home.data.HomeRecommendationApi
|
||||
import kr.co.vividnext.sodalive.v2.main.home.data.HomeRecommendationRepository
|
||||
import kr.co.vividnext.sodalive.v2.main.home.data.HomeRecommendationResponse
|
||||
import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationUiState
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [28], application = Application::class)
|
||||
class HomeRecommendationViewModelTest {
|
||||
|
||||
private val context: Context = ApplicationProvider.getApplicationContext()
|
||||
private lateinit var api: FakeHomeRecommendationApi
|
||||
private lateinit var viewModel: HomeRecommendationViewModel
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
setImmediateRxSchedulers()
|
||||
SharedPreferenceManager.resetForTest()
|
||||
SharedPreferenceManager.init(context)
|
||||
SharedPreferenceManager.token = "test-token"
|
||||
api = FakeHomeRecommendationApi()
|
||||
viewModel = HomeRecommendationViewModel(HomeRecommendationRepository(api))
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
RxJavaPlugins.reset()
|
||||
RxAndroidPlugins.reset()
|
||||
SharedPreferenceManager.resetForTest()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `follow 요청 loading은 추천 목록 loading을 끄지 않는다`() {
|
||||
val pendingRecommendations = SingleSubject.create<ApiResponse<HomeRecommendationResponse>>()
|
||||
api.enqueueRecommendations(pendingRecommendations)
|
||||
api.enqueueFollow(ApiResponse(true, Any(), null))
|
||||
|
||||
viewModel.loadRecommendations()
|
||||
viewModel.followCreators("cheerCreators", listOf(1L))
|
||||
|
||||
assertEquals(true, viewModel.isLoading.requireValue())
|
||||
assertEquals(false, viewModel.isFollowLoading.requireValue())
|
||||
|
||||
pendingRecommendations.onSuccess(ApiResponse(true, response(), null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `추천 목록 요청 완료는 follow 요청 loading을 끄지 않는다`() {
|
||||
val pendingFollow = SingleSubject.create<ApiResponse<Any>>()
|
||||
api.enqueueRecommendations(ApiResponse(true, response(), null))
|
||||
api.enqueueFollow(pendingFollow)
|
||||
|
||||
viewModel.followCreators("cheerCreators", listOf(1L))
|
||||
viewModel.loadRecommendations()
|
||||
|
||||
assertEquals(false, viewModel.isLoading.requireValue())
|
||||
assertEquals(true, viewModel.isFollowLoading.requireValue())
|
||||
|
||||
pendingFollow.onSuccess(ApiResponse(true, Any(), null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `추천 refresh 중 follow가 먼저 성공해도 최종 follow 완료 상태를 유지한다`() {
|
||||
val pendingRecommendations = SingleSubject.create<ApiResponse<HomeRecommendationResponse>>()
|
||||
api.enqueueRecommendations(pendingRecommendations)
|
||||
api.enqueueFollow(ApiResponse(true, Any(), null))
|
||||
|
||||
viewModel.loadRecommendations()
|
||||
viewModel.followCreators("cheerCreators", listOf(1L))
|
||||
pendingRecommendations.onSuccess(ApiResponse(true, response(), null))
|
||||
val state = viewModel.recommendationStateLiveData.requireValue() as HomeRecommendationUiState.Content
|
||||
|
||||
assertEquals(true, state.cheerCreators.isFollowCompleted)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `추천 refresh가 먼저 끝난 뒤 follow가 성공해도 최종 follow 완료 상태를 유지한다`() {
|
||||
val pendingFollow = SingleSubject.create<ApiResponse<Any>>()
|
||||
api.enqueueRecommendations(ApiResponse(true, response(), null))
|
||||
api.enqueueFollow(pendingFollow)
|
||||
|
||||
viewModel.loadRecommendations()
|
||||
viewModel.followCreators("cheerCreators", listOf(1L))
|
||||
pendingFollow.onSuccess(ApiResponse(true, Any(), null))
|
||||
val state = viewModel.recommendationStateLiveData.requireValue() as HomeRecommendationUiState.Content
|
||||
|
||||
assertEquals(true, state.cheerCreators.isFollowCompleted)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `추천 refresh 중 follow 성공 후 refresh 실패해도 follow 완료 상태를 유지한다`() {
|
||||
api.enqueueRecommendations(ApiResponse(true, response(), null))
|
||||
viewModel.loadRecommendations()
|
||||
val pendingRecommendations = SingleSubject.create<ApiResponse<HomeRecommendationResponse>>()
|
||||
api.enqueueRecommendations(pendingRecommendations)
|
||||
api.enqueueFollow(ApiResponse(true, Any(), null))
|
||||
|
||||
viewModel.loadRecommendations()
|
||||
viewModel.followCreators("cheerCreators", listOf(1L))
|
||||
pendingRecommendations.onSuccess(ApiResponse(false, null, "refresh failed"))
|
||||
val state = viewModel.recommendationStateLiveData.requireValue() as HomeRecommendationUiState.Content
|
||||
|
||||
assertEquals(true, state.cheerCreators.isFollowCompleted)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `추천 refresh 성공 후 follow 실패해도 최신 추천 목록을 유지한다`() {
|
||||
api.enqueueRecommendations(ApiResponse(true, response(creatorId = 1L), null))
|
||||
viewModel.loadRecommendations()
|
||||
api.enqueueRecommendations(ApiResponse(true, response(creatorId = 2L), null))
|
||||
api.enqueueFollow(ApiResponse(false, null, "follow failed"))
|
||||
|
||||
viewModel.loadRecommendations()
|
||||
viewModel.followCreators("cheerCreators", listOf(2L))
|
||||
val state = viewModel.recommendationStateLiveData.requireValue() as HomeRecommendationUiState.Content
|
||||
|
||||
assertEquals(listOf(2L), state.cheerCreators.items.map { it.creatorId })
|
||||
}
|
||||
|
||||
private fun response(creatorId: Long = 1L) = HomeRecommendationResponse(
|
||||
lives = listOf(HomeLiveItem(1L, "크리에이터", "https://example.com/profile.png")),
|
||||
banners = emptyList<HomeBannerItem>(),
|
||||
recentlyActiveCreators = emptyList<HomeActiveCreatorItem>(),
|
||||
recentDebutCreators = emptyList<HomeCreatorItem>(),
|
||||
aiCharacters = emptyList<HomeAiCharacterItem>(),
|
||||
genreCreators = emptyList<HomeGenreCreatorGroupItem>(),
|
||||
cheerCreators = listOf(HomeCreatorItem(creatorId, "크리에이터", "https://example.com/profile.png")),
|
||||
popularCommunityPosts = emptyList<HomePopularCommunityPostItem>()
|
||||
)
|
||||
|
||||
private fun setImmediateRxSchedulers() {
|
||||
val trampoline = { _: Scheduler -> Schedulers.trampoline() }
|
||||
RxJavaPlugins.setIoSchedulerHandler(trampoline)
|
||||
RxAndroidPlugins.setInitMainThreadSchedulerHandler { Schedulers.trampoline() }
|
||||
RxAndroidPlugins.setMainThreadSchedulerHandler { Schedulers.trampoline() }
|
||||
}
|
||||
|
||||
private fun <T> LiveData<T>.requireValue(): T? {
|
||||
var value: T? = null
|
||||
val observer = Observer<T> { value = it }
|
||||
observeForever(observer)
|
||||
removeObserver(observer)
|
||||
return value
|
||||
}
|
||||
|
||||
private class FakeHomeRecommendationApi : HomeRecommendationApi {
|
||||
private val recommendationResponses = ArrayDeque<Single<ApiResponse<HomeRecommendationResponse>>>()
|
||||
private val followResponses = ArrayDeque<Single<ApiResponse<Any>>>()
|
||||
|
||||
fun enqueueRecommendations(response: ApiResponse<HomeRecommendationResponse>) {
|
||||
enqueueRecommendations(Single.just(response))
|
||||
}
|
||||
|
||||
fun enqueueRecommendations(response: Single<ApiResponse<HomeRecommendationResponse>>) {
|
||||
recommendationResponses.addLast(response)
|
||||
}
|
||||
|
||||
fun enqueueFollow(response: ApiResponse<Any>) {
|
||||
enqueueFollow(Single.just(response))
|
||||
}
|
||||
|
||||
fun enqueueFollow(response: Single<ApiResponse<Any>>) {
|
||||
followResponses.addLast(response)
|
||||
}
|
||||
|
||||
override fun getRecommendations(authHeader: String): Single<ApiResponse<HomeRecommendationResponse>> {
|
||||
return recommendationResponses.removeFirst()
|
||||
}
|
||||
|
||||
override fun followRecommendedCreators(
|
||||
request: FollowRecommendedCreatorsRequest,
|
||||
authHeader: String
|
||||
): Single<ApiResponse<Any>> {
|
||||
return followResponses.removeFirst()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user