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,13 +131,38 @@ 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 ->
|
||||
|
||||
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, 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(
|
||||
@@ -140,9 +175,14 @@ class ContentAllTabViewModel(
|
||||
content
|
||||
}
|
||||
} else {
|
||||
showFirstPageError(type, sort, dayOfWeek, response.message)
|
||||
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"
|
||||
app:layout_constraintTop_toBottomOf="@id/view_chat_filter_tabs">
|
||||
|
||||
<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="0dp"
|
||||
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"
|
||||
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>
|
||||
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
|
||||
@@ -99,30 +99,35 @@
|
||||
</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">
|
||||
|
||||
<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"
|
||||
@@ -134,6 +139,8 @@
|
||||
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"
|
||||
@@ -343,4 +361,5 @@
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
</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"
|
||||
@@ -217,18 +222,24 @@
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
</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"
|
||||
@@ -358,19 +369,26 @@
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
</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"
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -46,7 +46,7 @@
|
||||
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/HomeMainFragment.kt`
|
||||
- Test: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/home/HomeMainFragmentSourceTest.kt`
|
||||
|
||||
- [ ] **Task 1.1: 홈 layout source test를 먼저 추가한다**
|
||||
- [x] **Task 1.1: 홈 layout source test를 먼저 추가한다**
|
||||
|
||||
`HomeMainFragmentSourceTest.kt`에 아래 검증을 추가한다.
|
||||
|
||||
@@ -69,7 +69,7 @@
|
||||
|
||||
Expected: FAIL because `swipe_home_recommendation`, `swipe_home_ranking`, `swipe_home_following` do not exist yet.
|
||||
|
||||
- [ ] **Task 1.2: 홈 layout에 탭별 SwipeRefreshLayout을 추가한다**
|
||||
- [x] **Task 1.2: 홈 layout에 탭별 SwipeRefreshLayout을 추가한다**
|
||||
|
||||
`fragment_v2_main_home.xml`에서 `text_tab_bar_home` 아래 콘텐츠 3개를 각각 아래 구조로 감싼다. 기존 child ID는 유지한다.
|
||||
|
||||
@@ -143,7 +143,7 @@
|
||||
|
||||
Expected: BUILD SUCCESSFUL.
|
||||
|
||||
- [ ] **Task 1.3: 홈 Fragment source test를 추가한다**
|
||||
- [x] **Task 1.3: 홈 Fragment source test를 추가한다**
|
||||
|
||||
`HomeMainFragmentSourceTest.kt`에 아래 검증을 추가한다.
|
||||
|
||||
@@ -169,7 +169,7 @@
|
||||
|
||||
Expected: FAIL because refresh dispatch code does not exist yet.
|
||||
|
||||
- [ ] **Task 1.4: 홈 Fragment에 refresh listener와 visibility 전환을 연결한다**
|
||||
- [x] **Task 1.4: 홈 Fragment에 refresh listener와 visibility 전환을 연결한다**
|
||||
|
||||
`HomeMainFragment.kt`의 `onViewCreated()`에서 `setUpFollowingAdapters()` 다음에 `setUpRefreshLayouts()`를 호출한다.
|
||||
|
||||
@@ -250,7 +250,7 @@
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Task 1.5: 홈 Phase 검증 기록을 남긴다**
|
||||
- [x] **Task 1.5: 홈 Phase 검증 기록을 남긴다**
|
||||
|
||||
Run:
|
||||
- `./gradlew :app:mergeDebugResources`
|
||||
@@ -261,6 +261,12 @@
|
||||
|
||||
검증 성공 후 이 Phase 아래에 `검증 기록`을 한국어로 누적한다.
|
||||
|
||||
검증 기록:
|
||||
- 2026-07-22: `HomeMainFragmentSourceTest.kt`에 홈 layout refresh container 검증과 현재 홈 탭별 refresh dispatch 검증을 먼저 추가한 뒤 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.home.HomeMainFragmentSourceTest"`를 실행했다. 초기 2회는 Gradle 빌드 시간 초과로 테스트 결과 전 종료되었고, 3회 실행에서 신규 테스트 2개가 `HomeMainFragmentSourceTest.kt:62`, `HomeMainFragmentSourceTest.kt:77`의 `assertTrue` 실패로 RED가 확인되었다.
|
||||
- 2026-07-22: `fragment_v2_main_home.xml`에서 홈 추천/랭킹/팔로잉 콘텐츠 표면을 각각 `swipe_home_recommendation`, `swipe_home_ranking`, `swipe_home_following`으로 감싸고 기존 child ID를 유지했다. `HomeMainFragment.kt`에서 `setUpRefreshLayouts()`, 현재 탭별 재조회, wrapper visibility 전환, loading dismiss 시 indicator 종료를 연결했다.
|
||||
- 2026-07-22: 구현 후 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.home.HomeMainFragmentSourceTest"`를 실행해 BUILD SUCCESSFUL을 확인했다.
|
||||
- 2026-07-22: Phase 1 최종 검증으로 `./gradlew :app:mergeDebugResources`, `./gradlew :app:compileDebugKotlin`, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.home.HomeMainFragmentSourceTest"`를 실행했고 모두 BUILD SUCCESSFUL이다. Gradle deprecation warning은 기존 빌드 경고로 확인되었고 이번 변경 파일과 직접 관련된 오류는 없었다.
|
||||
|
||||
### Phase 2: 콘텐츠 탭 새로고침 연결
|
||||
|
||||
**Files:**
|
||||
@@ -269,7 +275,7 @@
|
||||
- Test: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/content/ContentMainFragmentSourceTest.kt`
|
||||
- Test: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/content/ContentRankingViewModelTest.kt`
|
||||
|
||||
- [ ] **Task 2.1: 콘텐츠 layout source test를 먼저 추가한다**
|
||||
- [x] **Task 2.1: 콘텐츠 layout source test를 먼저 추가한다**
|
||||
|
||||
`ContentMainFragmentSourceTest.kt`에 아래 검증을 추가한다.
|
||||
|
||||
@@ -293,7 +299,7 @@
|
||||
|
||||
Expected: FAIL because content refresh wrappers do not exist yet.
|
||||
|
||||
- [ ] **Task 2.2: 콘텐츠 layout에 SwipeRefreshLayout을 추가한다**
|
||||
- [x] **Task 2.2: 콘텐츠 layout에 SwipeRefreshLayout을 추가한다**
|
||||
|
||||
추천 `NestedScrollView`를 `swipe_content_recommendation`으로 감싼다.
|
||||
|
||||
@@ -378,7 +384,7 @@
|
||||
|
||||
Expected: BUILD SUCCESSFUL.
|
||||
|
||||
- [ ] **Task 2.3: 콘텐츠 refresh 분기 source test를 추가한다**
|
||||
- [x] **Task 2.3: 콘텐츠 refresh 분기 source test를 추가한다**
|
||||
|
||||
`ContentMainFragmentSourceTest.kt`에 아래 검증을 추가한다.
|
||||
|
||||
@@ -405,7 +411,7 @@
|
||||
|
||||
Expected: FAIL because refresh dispatch code does not exist yet.
|
||||
|
||||
- [ ] **Task 2.4: 콘텐츠 Fragment에 refresh listener를 연결한다**
|
||||
- [x] **Task 2.4: 콘텐츠 Fragment에 refresh listener를 연결한다**
|
||||
|
||||
`ContentMainFragment.kt`에서 `setUpAdapters()` 다음에 `setUpRefreshLayouts()`를 호출한다.
|
||||
|
||||
@@ -495,7 +501,7 @@
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Task 2.5: 콘텐츠 랭킹 force refresh 테스트를 실행한다**
|
||||
- [x] **Task 2.5: 콘텐츠 랭킹 force refresh 테스트를 실행한다**
|
||||
|
||||
`ContentRankingViewModelTest.kt`에는 이미 아래 테스트가 있으므로 새 테스트를 추가하지 않는다.
|
||||
|
||||
@@ -518,7 +524,7 @@
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Task 2.6: 콘텐츠 Phase 검증 기록을 남긴다**
|
||||
- [x] **Task 2.6: 콘텐츠 Phase 검증 기록을 남긴다**
|
||||
|
||||
Run:
|
||||
- `./gradlew :app:mergeDebugResources`
|
||||
@@ -531,6 +537,13 @@
|
||||
|
||||
검증 성공 후 이 Phase 아래에 `검증 기록`을 한국어로 누적한다.
|
||||
|
||||
검증 기록:
|
||||
- 2026-07-22: `ContentMainFragmentSourceTest.kt`에 콘텐츠 layout refresh container 검증과 현재 콘텐츠 탭별 refresh dispatch 검증을 먼저 추가한 뒤 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.content.ContentMainFragmentSourceTest"`를 실행했다. 신규 테스트 2개가 각각 `ContentMainFragmentSourceTest.kt:78`, `ContentMainFragmentSourceTest.kt:94`의 `AssertionError`로 실패해 RED를 확인했다.
|
||||
- 2026-07-22: `fragment_v2_main_content.xml`에서 콘텐츠 추천/랭킹/전체 콘텐츠 표면을 각각 `swipe_content_recommendation`, `swipe_content_ranking`, `swipe_content_all`로 감싸고 기존 child ID를 유지했다. 전체 탭은 `swipe_content_all`의 단일 direct child `FrameLayout` 안에 `rv_content_all_items`와 `layout_content_all_empty_error`를 보존했다.
|
||||
- 2026-07-22: `ContentMainFragment.kt`에서 `setUpAdapters()` 다음 `setUpRefreshLayouts()` 호출, 현재 콘텐츠 탭별 재조회, 랭킹 현재 type `force = true` 재조회, wrapper visibility 전환, loading dismiss 시 refresh indicator 종료를 연결했다.
|
||||
- 2026-07-22: 구현 후 `./gradlew :app:mergeDebugResources`, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.content.ContentMainFragmentSourceTest"`, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.content.ContentRankingViewModelTest"`를 실행했고 모두 BUILD SUCCESSFUL이다.
|
||||
- 2026-07-22: Phase 2 최종 검증으로 `./gradlew :app:mergeDebugResources`, `./gradlew :app:compileDebugKotlin`, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.content.ContentMainFragmentSourceTest"`, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.content.ContentRankingViewModelTest"`, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.content.ContentAllTabViewModelTest"`를 실행했고 모두 BUILD SUCCESSFUL이다. Gradle deprecation warning은 기존 빌드 경고로 확인되었고 이번 변경 파일과 직접 관련된 오류는 없었다.
|
||||
|
||||
### Phase 3: 대화 탭 새로고침 연결
|
||||
|
||||
**Files:**
|
||||
@@ -539,7 +552,7 @@
|
||||
- Test: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/ChatMainFragmentLayoutTest.kt`
|
||||
- Test: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/ChatMainViewModelTest.kt`
|
||||
|
||||
- [ ] **Task 3.1: 대화 layout test를 먼저 추가한다**
|
||||
- [x] **Task 3.1: 대화 layout test를 먼저 추가한다**
|
||||
|
||||
`ChatMainFragmentLayoutTest.kt`에 아래 검증을 추가하고, 기존 `채팅 fragment layout은 title bar tab list만 포함한다`, `채팅 fragment list는 계획된 constraint를 사용한다` 테스트는 `rv_chat_rooms`와 `tv_chat_empty_message`의 부모가 root가 아니라 `swipe_chat_rooms` 내부 `FrameLayout`임을 기준으로 갱신한다.
|
||||
|
||||
@@ -561,7 +574,7 @@
|
||||
|
||||
Expected: FAIL because `swipe_chat_rooms` does not exist yet.
|
||||
|
||||
- [ ] **Task 3.2: 대화 layout에 SwipeRefreshLayout을 추가한다**
|
||||
- [x] **Task 3.2: 대화 layout에 SwipeRefreshLayout을 추가한다**
|
||||
|
||||
`fragment_v2_main_chat.xml`에서 `rv_chat_rooms`와 `tv_chat_empty_message`를 하나의 `SwipeRefreshLayout` 안 `FrameLayout`으로 감싼다.
|
||||
|
||||
@@ -606,7 +619,7 @@
|
||||
|
||||
Expected: BUILD SUCCESSFUL.
|
||||
|
||||
- [ ] **Task 3.3: 대화 Fragment source test를 추가한다**
|
||||
- [x] **Task 3.3: 대화 Fragment source test를 추가한다**
|
||||
|
||||
`ChatMainFragmentLayoutTest.kt`에 아래 검증을 추가한다.
|
||||
|
||||
@@ -627,7 +640,7 @@
|
||||
|
||||
Expected: FAIL because refresh listener code does not exist yet.
|
||||
|
||||
- [ ] **Task 3.4: 대화 Fragment에 refresh listener를 연결한다**
|
||||
- [x] **Task 3.4: 대화 Fragment에 refresh listener를 연결한다**
|
||||
|
||||
`ChatMainFragment.kt`의 `onViewCreated()`에서 `setupChatRooms()` 다음에 `setupRefresh()`를 호출한다.
|
||||
|
||||
@@ -664,7 +677,7 @@
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Task 3.5: 대화 ViewModel 첫 페이지 교체 테스트를 추가한다**
|
||||
- [x] **Task 3.5: 대화 ViewModel 첫 페이지 교체 테스트를 추가한다**
|
||||
|
||||
`ChatMainViewModelTest.kt`에 같은 filter 첫 페이지 재호출이 기존 목록을 교체하고 cursor 없이 요청하는 테스트를 추가한다.
|
||||
|
||||
@@ -693,7 +706,7 @@
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Task 3.6: 대화 Phase 검증 기록을 남긴다**
|
||||
- [x] **Task 3.6: 대화 Phase 검증 기록을 남긴다**
|
||||
|
||||
Run:
|
||||
- `./gradlew :app:mergeDebugResources`
|
||||
@@ -705,13 +718,20 @@
|
||||
|
||||
검증 성공 후 이 Phase 아래에 `검증 기록`을 한국어로 누적한다.
|
||||
|
||||
검증 기록:
|
||||
- 2026-07-22: `ChatMainFragmentLayoutTest.kt`에 대화 layout refresh wrapper 검증, 기존 부모/constraint 기대값 갱신, 대화 refresh source 검증을 먼저 추가했다. 초기 1회 실행은 신규 `R.id.swipe_chat_rooms`가 아직 생성되지 않아 테스트 컴파일 오류로 멈춰, 새 ID 부재를 런타임 실패로 검증하도록 기존 helper 기반 조회로 수정했다.
|
||||
- 2026-07-22: 수정 후 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.ChatMainFragmentLayoutTest"`를 실행했고, `chat layout wraps room list and empty message in swipe refresh`는 `ChatMainFragmentLayoutTest.kt:75`, `chat refresh reloads first page with selected filter`는 `ChatMainFragmentLayoutTest.kt:154`의 `AssertionError`로 실패했다. 갱신된 기존 layout 테스트 2개도 `swipe_chat_rooms` 부재로 실패해 RED를 확인했다.
|
||||
- 2026-07-22: `ChatMainViewModelTest.kt`에 `loadFirstPage with current filter requests first page and replaces existing rooms` 테스트를 추가한 뒤 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.ChatMainViewModelTest"`를 실행했고 BUILD SUCCESSFUL을 확인했다. 기존 `loadFirstPage(filter)` 구현이 같은 filter 첫 페이지 재호출, null cursor 요청, 기존 목록 교체를 이미 만족해 ViewModel production 코드는 변경하지 않았다.
|
||||
- 2026-07-22: `fragment_v2_main_chat.xml`에서 `rv_chat_rooms`와 `tv_chat_empty_message`를 `swipe_chat_rooms` 안의 단일 direct child `FrameLayout`로 감싸고, 기존 list/empty constraint는 wrapper로 옮겼다. `ChatMainFragment.kt`에서는 `setupChatRooms()` 다음 `setupRefresh()`를 호출하고, refresh 시 `viewModel.loadFirstPage(selectedFilter)`를 실행하며 loading dismiss 시 `binding.swipeChatRooms.isRefreshing = false`로 indicator를 닫도록 연결했다.
|
||||
- 2026-07-22: Phase 3 최종 검증으로 `./gradlew :app:mergeDebugResources`, `./gradlew :app:compileDebugKotlin`, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.ChatMainFragmentLayoutTest"`, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.ChatMainViewModelTest"`를 실행했고 모두 BUILD SUCCESSFUL이다. Gradle deprecation warning은 기존 빌드 경고로 확인되었고 이번 변경 파일과 직접 관련된 오류는 없었다.
|
||||
|
||||
### Phase 4: 통합 검증과 문서 갱신
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/20260722_메인_홈_콘텐츠_대화_탭_당겨서_새로고침/prd.md`
|
||||
- Modify: `docs/20260722_메인_홈_콘텐츠_대화_탭_당겨서_새로고침/plan-task.md`
|
||||
|
||||
- [ ] **Task 4.1: PRD Open Questions 결과를 문서에 반영한다**
|
||||
- [x] **Task 4.1: PRD Open Questions 결과를 문서에 반영한다**
|
||||
|
||||
`prd.md`의 `Open Questions` 아래에 구현 계획 확정 내용을 추가한다.
|
||||
|
||||
@@ -727,7 +747,7 @@
|
||||
|
||||
Expected: added lines are found.
|
||||
|
||||
- [ ] **Task 4.2: 전체 변경 검증을 실행한다**
|
||||
- [x] **Task 4.2: 전체 변경 검증을 실행한다**
|
||||
|
||||
Run:
|
||||
- `./gradlew :app:mergeDebugResources`
|
||||
@@ -739,7 +759,7 @@
|
||||
|
||||
Expected: all BUILD SUCCESSFUL. 기존 경고가 있으면 경고 문구와 변경 관련 여부를 `Verification Log`에 기록한다.
|
||||
|
||||
- [ ] **Task 4.3: 수동 QA를 수행한다**
|
||||
- [x] **Task 4.3: 수동 QA를 수행한다**
|
||||
|
||||
연결 기기가 있으면 실행한다.
|
||||
|
||||
@@ -759,7 +779,7 @@
|
||||
|
||||
기기가 없으면 `adb devices` 결과를 `Verification Log`에 남기고 수동 QA는 환경 차단으로 표시한다.
|
||||
|
||||
- [ ] **Task 4.4: 최종 문서 검증 기록을 누적한다**
|
||||
- [x] **Task 4.4: 최종 문서 검증 기록을 누적한다**
|
||||
|
||||
`plan-task.md` 최하단 `Verification Log`와 `prd.md`의 `Verification Log`에 아래 형식으로 실제 실행 결과를 누적한다.
|
||||
|
||||
@@ -776,9 +796,116 @@
|
||||
4. 각 ViewModel의 기존 첫 페이지 재조회/force/cache 동작을 테스트로 고정한다.
|
||||
5. PRD Open Questions 확정 내용을 문서화하고 통합 검증을 수행한다.
|
||||
|
||||
### Phase 5: 요청 경합 방어 보완
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/HomeRecommendationViewModel.kt`
|
||||
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/HomeCreatorRankingViewModel.kt`
|
||||
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/HomeFollowingViewModel.kt`
|
||||
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/ContentMainViewModel.kt`
|
||||
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/ContentAllTabViewModel.kt`
|
||||
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/HomeMainFragment.kt`
|
||||
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/ContentMainFragment.kt`
|
||||
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/ChatMainFragment.kt`
|
||||
- Test: 관련 ViewModel/Fragment source tests
|
||||
|
||||
- [x] **Task 5.1: 중복 첫 페이지 요청과 오래된 응답 방어 RED 테스트를 추가한다**
|
||||
|
||||
Home 추천/랭킹/팔로잉, Content 추천 ViewModel은 첫 페이지 로딩 중 같은 load 요청을 무시하고 최신 요청 응답만 반영해야 한다.
|
||||
|
||||
- [x] **Task 5.2: Content 전체 탭 pagination 경합 RED 테스트를 추가한다**
|
||||
|
||||
`loadMore()` pending 중 첫 페이지 refresh가 실패해도 복원 state의 `isLoadingMore`는 `false`가 되어 이후 `loadMore()`가 재시도 가능해야 한다.
|
||||
|
||||
- [x] **Task 5.3: Fragment refresh 소유권 RED 테스트를 추가한다**
|
||||
|
||||
Chat은 refresh 시작 filter, Home/Content는 refresh 시작 tab과 같은 terminal 응답만 pull-refresh 보존/종료/top-scroll 처리를 해야 한다.
|
||||
|
||||
- [x] **Task 5.4: 최소 구현으로 RED 테스트를 통과시킨다**
|
||||
|
||||
전역 LoadingDialog 차단에 의존하지 않고 ViewModel request generation/중복 무시, ContentAll 복원 snapshot 정규화, Fragment refresh 시작 탭/필터 저장을 적용한다. Minor의 indicator 종료 지연은 대상 요청 소유권 기반 종료로 함께 완화한다.
|
||||
|
||||
- [x] **Task 5.5: 최종 검증과 리뷰 재요청을 수행한다**
|
||||
|
||||
관련 targeted unit test, `compileDebugKotlin`, `ktlintCheck`, `git diff --check`를 실행하고 `Verification Log`에 결과를 누적한다.
|
||||
|
||||
### Phase 6: 리뷰 Important 보완
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/ContentRankingViewModel.kt`
|
||||
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/ContentAllTabViewModel.kt`
|
||||
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/ChatMainViewModel.kt`
|
||||
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/HomeRecommendationViewModel.kt`
|
||||
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/HomeMainFragment.kt`
|
||||
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/ContentMainFragment.kt`
|
||||
- Test: 관련 ViewModel/Fragment source tests
|
||||
|
||||
- [x] **Task 6.1: 첫 페이지 in-flight key 중복 방어를 보완한다**
|
||||
|
||||
Content 랭킹/전체, Chat 첫 페이지 요청에서 같은 key가 진행 중이면 중복 호출을 무시하고, 다른 type/filter/sort/dayOfWeek 요청은 허용한다.
|
||||
|
||||
- [x] **Task 6.2: refresh indicator 종료를 대상 wrapper로 한정한다**
|
||||
|
||||
Home/Content loading dialog 집계에서 전체 indicator 종료를 제거하고, `finishHomePullRefresh(tab)` / `finishContentPullRefresh(tab)`에서 대상 wrapper만 종료한다.
|
||||
|
||||
- [x] **Task 6.3: 홈 추천 loading과 follow action loading을 분리한다**
|
||||
|
||||
`HomeRecommendationViewModel`의 추천 목록 loading과 `followCreators()` action loading을 별도 LiveData로 분리한다.
|
||||
|
||||
- [x] **Task 6.4: 콘텐츠 전체 탭 refresh 결과를 명시 신호로 처리한다**
|
||||
|
||||
동일 데이터 정상 응답을 실패 복원으로 오인하지 않도록 데이터 동등성 기반 `isSameAllTabPage()` 판정을 제거하고, ViewModel의 `ContentAllTabRefreshResult`로 성공/실패를 판단한다.
|
||||
|
||||
검증 기록:
|
||||
- 2026-07-22: RED 테스트로 `PullRefreshRequestRaceSourceTest`, `HomeMainFragmentSourceTest`, `ContentMainFragmentSourceTest`, `ContentRankingViewModelTest`, `ContentAllTabViewModelTest`, `ChatMainViewModelTest`, `HomeRecommendationViewModelTest`를 먼저 보강했다. 첫 실행은 `ContentAllTabRefreshResult`, `refreshResultLiveData`, `isFollowLoading` 미구현 컴파일 오류로 실패해 RED를 확인했다.
|
||||
- 2026-07-22: 같은 key 중복 요청 무시, 대상 wrapper indicator 종료, 추천/follow loading 분리, Content 전체 탭 명시 refresh 결과 처리를 최소 구현으로 반영했다. 이후 동일 targeted test 묶음은 BUILD SUCCESSFUL이다.
|
||||
- 2026-07-22: `./gradlew :app:ktlintCheck` 첫 실행은 신규 source test의 line length/argument wrapping과 unused import로 실패했다. 줄바꿈과 import만 수정한 뒤 `./gradlew :app:ktlintCheck`를 재실행해 BUILD SUCCESSFUL을 확인했다.
|
||||
- 2026-07-22: ktlint 수정 후 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.PullRefreshRequestRaceSourceTest" --tests "kr.co.vividnext.sodalive.v2.main.home.HomeMainFragmentSourceTest" --tests "kr.co.vividnext.sodalive.v2.main.content.ContentMainFragmentSourceTest" --tests "kr.co.vividnext.sodalive.v2.main.content.ContentRankingViewModelTest" --tests "kr.co.vividnext.sodalive.v2.main.content.ContentAllTabViewModelTest" --tests "kr.co.vividnext.sodalive.v2.main.chat.ChatMainViewModelTest" --tests "kr.co.vividnext.sodalive.v2.main.home.HomeRecommendationViewModelTest"`를 재실행했고 BUILD SUCCESSFUL이다.
|
||||
- 2026-07-22: read-only 리뷰에서 `ContentAllTabViewModel` stale 첫 페이지 응답의 in-flight key 잔류와 Content 전체 탭 실패 시 indicator 미종료 가능성이 지적되었다. `ContentAllTabViewModelTest`에 stale 응답 후 같은 key 재요청 가능 테스트를 추가하고, `ContentMainFragmentSourceTest`는 failure cancel이 대상 wrapper를 끄도록 고정했다. 수정 후 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.content.ContentAllTabViewModelTest" --tests "kr.co.vividnext.sodalive.v2.main.content.ContentMainFragmentSourceTest" --tests "kr.co.vividnext.sodalive.v2.main.home.HomeMainFragmentSourceTest"`를 실행해 BUILD SUCCESSFUL을 확인했다.
|
||||
|
||||
## Verification Log
|
||||
|
||||
- 2026-07-22: `docs/20260722_메인_홈_콘텐츠_대화_탭_당겨서_새로고침/prd.md`를 기준으로 계획을 작성했다.
|
||||
- 2026-07-22: `app/build.gradle`에서 `androidx.swiperefreshlayout:swiperefreshlayout:1.1.0`이 이미 추가되어 있음을 확인해 신규 의존성 추가 없이 구현하도록 계획했다.
|
||||
- 2026-07-22: `HomeMainFragment`, `ContentMainFragment`, `ChatMainFragment`와 각 ViewModel을 확인해 현재 선택 탭/필터 기준 첫 페이지 재호출 경로를 확정했다.
|
||||
- 2026-07-22: 이번 단계는 plan-task 문서 작성만 수행했으며 구현/빌드/테스트는 실행하지 않는다.
|
||||
- 2026-07-22: `prd.md`의 Open Questions 아래에 구현 계획 확정 내용을 반영했고, `rg "구현 계획 확정|HomeCreatorRankingViewModel|force = true" docs/20260722_메인_홈_콘텐츠_대화_탭_당겨서_새로고침/prd.md`로 추가 내용을 확인했다.
|
||||
- 2026-07-22: 통합 검증으로 `./gradlew :app:mergeDebugResources`, `./gradlew :app:compileDebugKotlin`, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.home.*"`, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.content.*"`, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.*"`를 실행했고 결과는 BUILD SUCCESSFUL이다.
|
||||
- 2026-07-22: `./gradlew :app:ktlintCheck` 최초 재실행에서 새 테스트 파일의 line length/argument wrapping 위반이 발견되어 줄바꿈만 수정했다.
|
||||
- 2026-07-22: `adb devices`를 실행했으나 `List of devices attached` 아래 연결 기기가 없어 `./gradlew :app:installDebug`와 실제 기기 수동 QA는 환경 차단으로 수행하지 못했다.
|
||||
- 2026-07-22: 최종 리뷰에서 pull-to-refresh 실패 시 기존 데이터 보존과 성공 후 최상단 복귀 누락이 지적되어, `HomeMainFragmentSourceTest`, `ContentMainFragmentSourceTest`, `ChatMainFragmentLayoutTest`에 RED 검증을 먼저 추가했다. RED는 각각 `HomeMainFragmentSourceTest.kt:94`, `ContentMainFragmentSourceTest.kt:111`, `ChatMainFragmentLayoutTest.kt:194`의 `AssertionError`로 확인했다.
|
||||
- 2026-07-22: 리뷰 지적 반영 후 `./gradlew :app:mergeDebugResources`, `./gradlew :app:compileDebugKotlin`, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.home.*"`, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.content.*"`, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.*"`, `./gradlew :app:ktlintCheck`를 재실행했고 모두 BUILD SUCCESSFUL이다. `git diff --check`도 출력 없이 통과했다. ktlint 실행 중 `.editorconfig`의 `disabled_rules` deprecation warning은 출력되었다.
|
||||
- 2026-07-22: 두 번째 리뷰에서 Chat empty 상태 실패 보존과 Chat/Content 전체 탭 pagination 상태 보존이 지적되어 `ChatMainFragmentLayoutTest`, `ChatMainViewModelTest`, `ContentAllTabViewModelTest`에 RED 검증을 먼저 추가했다. RED는 각각 `ChatMainFragmentLayoutTest.kt:163`의 `AssertionError`, `ChatMainViewModelTest.kt:157`의 `ClassCastException`, `ContentAllTabViewModelTest.kt:534`의 `ClassCastException`으로 확인했다. 수정 후 동일한 최종 Gradle 검증을 다시 실행했고 모두 BUILD SUCCESSFUL이다.
|
||||
- 2026-07-22: 리뷰 blocker 수정 RED 테스트로 `HomeMainFragmentSourceTest`, `ContentMainFragmentSourceTest`, `ChatMainFragmentLayoutTest`에 pull-refresh 실패 시 기존 표시 데이터 보존과 성공 시 현재 표면 top-scroll source 검증을 먼저 추가했다. 첫 실행은 `HomeMainFragmentSourceTest.kt`의 `assertEquals` import 누락으로 컴파일 실패해 테스트 결함을 수정했고, 재실행에서 `HomeMainFragmentSourceTest.kt:94`, `ContentMainFragmentSourceTest.kt:111`, `ChatMainFragmentLayoutTest.kt:194`의 `AssertionError`로 기대 RED를 확인했다.
|
||||
- 2026-07-22: `HomeMainFragment`, `ContentMainFragment`, `ChatMainFragment` 내부에만 pull-refresh in-progress flag를 추가했다. Home/Content는 pull-refresh error에서 기존 content/list clearing을 건너뛰고, Content 전체 탭은 refresh loading/error 동안 기존 전체 탭 content 상태와 adapter items를 유지하도록 했다. Home/Content 성공 또는 empty terminal state는 현재 탭의 `NestedScrollView`/`RecyclerView`를 top으로 이동시키고 flag를 해제하며, Chat은 refresh error에서 기존 adapter items를 유지한다.
|
||||
- 2026-07-22: 구현 후 RED 대상 묶음 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.home.HomeMainFragmentSourceTest" --tests "kr.co.vividnext.sodalive.v2.main.content.ContentMainFragmentSourceTest" --tests "kr.co.vividnext.sodalive.v2.main.chat.ChatMainFragmentLayoutTest"`를 실행해 BUILD SUCCESSFUL을 확인했다. 이후 `./gradlew :app:compileDebugKotlin`, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.content.*"`, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.*"`도 BUILD SUCCESSFUL이다.
|
||||
- 2026-07-22: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.home.*"` 첫 확장 실행은 기존 Home source test 3개가 과거 한 줄 branch 문자열을 고정해 `HomeFollowingFragmentSourceTest.kt:84`, `HomeFollowingFragmentSourceTest.kt:139`, `HomeMainFragmentLayoutTest.kt:1230`에서 실패했다. 테스트 기대값을 새 guard/block 구조로 갱신한 뒤 같은 명령을 재실행해 BUILD SUCCESSFUL을 확인했다.
|
||||
- 2026-07-22: 2차 리뷰 blocker 수정 RED 테스트로 `ChatMainFragmentLayoutTest`에 empty 상태 pull-refresh Loading placeholder 보존 source 검증, `ChatMainViewModelTest`에 같은 filter 첫 페이지 refresh 실패 후 이전 cursor 기반 `loadNextPage()` append 검증, `ContentAllTabViewModelTest`에 첫 페이지 refresh 실패 후 이전 page 기반 `loadMore()` append 검증을 먼저 추가했다. production 변경 전 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.ChatMainFragmentLayoutTest" --tests "kr.co.vividnext.sodalive.v2.main.chat.ChatMainViewModelTest" --tests "kr.co.vividnext.sodalive.v2.main.content.ContentAllTabViewModelTest"`를 실행했고 `ChatMainFragmentLayoutTest.kt:163` AssertionError, `ChatMainViewModelTest.kt:157` ClassCastException, `ContentAllTabViewModelTest.kt:534` ClassCastException으로 RED를 확인했다.
|
||||
- 2026-07-22: `ChatMainFragment`는 pull-refresh Loading 중 empty message를 숨기지 않도록 가드했고, `ChatMainViewModel`은 같은 filter 첫 페이지 실패 시 이전 `currentItems`/`nextCursor`/`hasMore`와 Content/Empty 상태를 복원하도록 했다. `ContentAllTabViewModel`은 같은 type/sort/dayOfWeek의 기존 Content가 있는 첫 페이지 refresh 실패에서 이전 Content를 다시 emit해 pagination 상태를 보존하도록 했다.
|
||||
- 2026-07-22: 구현 후 RED 대상 묶음 재실행은 첫 시도에서 `ContentAllTabViewModel.kt`의 `requestContents` 호출부 시그니처 정렬 오류로 `:app:compileDebugKotlin`이 실패했고, 기존 `loadMore()` trailing lambda 호출을 보존하도록 파라미터 순서를 바로잡았다. 이후 같은 RED 대상 묶음 명령을 재실행해 BUILD SUCCESSFUL을 확인했다.
|
||||
- 2026-07-22: 2차 리뷰 blocker 수정 최종 검증으로 `./gradlew :app:compileDebugKotlin`, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.*"`, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.content.*"`를 실행했고 모두 BUILD SUCCESSFUL이다. Gradle deprecation warning은 기존 빌드 경고로 확인되었고 이번 변경 파일 관련 실패는 없었다.
|
||||
- 2026-07-22: Phase 6 리뷰 Important 보완 후 targeted unit test 묶음과 `./gradlew :app:ktlintCheck`를 실행했고 모두 BUILD SUCCESSFUL이다. ktlint의 `.editorconfig disabled_rules` deprecation warning과 Gradle deprecation warning은 기존 경고로 남아 있다.
|
||||
- 2026-07-22: Phase 6 read-only 리뷰에서 나온 Important 2건을 추가 보완했다. stale 첫 페이지 응답도 in-flight key를 해제하도록 하고, Content/Home error 보존 및 Content 전체 탭 명시 실패 결과가 대상 `SwipeRefreshLayout` indicator를 종료하도록 수정했다. 관련 targeted test는 BUILD SUCCESSFUL이다.
|
||||
- 2026-07-22: 추가 리뷰에서 `SwipeRefreshLayout` direct child가 `FrameLayout`인 Chat/Content 전체 탭의 child scroll 판정 누락, Chat/Content 전체 탭 refresh 실패 복원 시 top-scroll 발생, Home/Content 일부 ViewModel의 실패 후 terminal state가 `Error`로 남는 문제가 지적되었다. `ChatMainFragmentLayoutTest`와 `ContentMainFragmentSourceTest`에 child scroll callback 및 실패 복원 시 top-scroll 방지 source 검증을 추가했고, production 변경 전 각각 `ChatMainFragmentLayoutTest.kt:155`, `ChatMainFragmentLayoutTest.kt:189`, `ChatMainFragmentLayoutTest.kt:197`, `ContentMainFragmentSourceTest.kt:89`, `ContentMainFragmentSourceTest.kt:129`의 AssertionError로 RED를 확인했다.
|
||||
- 2026-07-22: ViewModel terminal state 보존 RED 검증으로 `ContentRankingViewModelTest`와 `HomeFollowingViewModelTest`에 refresh 실패 시 기존 Content 유지 테스트를 추가했다. production 변경 전 `ContentRankingViewModelTest.kt:170`, `HomeFollowingViewModelTest.kt:124`의 ClassCastException으로 RED를 확인했다.
|
||||
- 2026-07-22: 보완 구현으로 Chat/Content 전체 탭 `SwipeRefreshLayout`에 `setOnChildScrollUpCallback`을 추가했고, Chat은 같은 Content state identity 재방출을 실패 복원으로 판정해 top-scroll을 막았다. Content 전체 탭은 refresh 실패 복원 시 `paginationErrorMessage`를 consume하기 전에 refresh flag를 해제해 top-scroll을 막았다. Home/Content 관련 ViewModel은 이전 terminal state가 있으면 실패 시 그 상태를 되돌리고 toast만 발행하도록 최소 변경했다.
|
||||
- 2026-07-22: 보완 후 검증으로 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.ChatMainFragmentLayoutTest"`, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.content.ContentMainFragmentSourceTest"`, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.content.ContentRankingViewModelTest"`, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.home.HomeFollowingViewModelTest"`를 순차 실행했고 모두 BUILD SUCCESSFUL이다. 병렬 Gradle 실행 중 한 차례 `app/build/tmp/kotlin-classes/debug` 삭제 충돌이 발생해 이후 검증은 순차 실행으로 전환했다.
|
||||
- 2026-07-22: 최종 확장 검증으로 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.content.*"`, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.home.*"`, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.*"`, `./gradlew :app:mergeDebugResources`, `./gradlew :app:compileDebugKotlin`, `./gradlew :app:ktlintCheck`, `git diff --check`를 실행했다. Gradle 명령은 모두 BUILD SUCCESSFUL이며 `git diff --check`는 출력 없이 통과했다. `ktlintCheck`는 새 테스트 긴 줄 위반을 한 차례 발견해 줄바꿈만 수정한 뒤 재실행 통과했고, `.editorconfig disabled_rules` deprecation warning은 기존 경고로 남아 있다.
|
||||
- 2026-07-22: 리뷰 세션 재검토 결과 이전 fallback `Content` 오분류 blocker는 해결됐고 Critical/Important 이슈는 남아 있지 않다고 확인받았다. 비차단 잔여 위험으로 `ContentAllTabViewModelTest`가 복원 객체의 `assertSame`까지 고정하지는 않는다는 의견이 있었으나, 현재 source test와 ViewModel 테스트 조합은 저장소 테스트 관례 기준 충분하다고 판단되었다.
|
||||
- 2026-07-22: 추가 리뷰 Important 1~4 대응으로 `PullRefreshRequestRaceSourceTest`와 `ContentAllTabViewModelTest` 회귀 테스트를 먼저 추가했다. production 변경 전 `PullRefreshRequestRaceSourceTest.kt:19`, `PullRefreshRequestRaceSourceTest.kt:32`, `ContentAllTabViewModelTest.kt:595`에서 RED를 확인했다.
|
||||
- 2026-07-22: Home 추천/랭킹/팔로잉 및 Content 추천 ViewModel에 로딩 중 동일 첫 페이지 요청 무시와 request generation guard를 추가했다. `ContentAllTabViewModel`은 refresh 실패 복원 snapshot의 `isLoadingMore=false`, `paginationErrorMessage=null` 정규화를 적용했다. Home/Content/Chat Fragment는 refresh 시작 탭/필터를 저장하고 동일 소유자 terminal 응답에만 indicator 종료/top-scroll/실패 보존을 적용하도록 수정했다. Minor의 indicator 종료 지연은 전역 LoadingDialog 차단 대신 대상 요청 소유권 기반 종료로 완화했다.
|
||||
- 2026-07-22: 후속 검증으로 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.PullRefreshRequestRaceSourceTest" --tests "kr.co.vividnext.sodalive.v2.main.content.ContentAllTabViewModelTest"`, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.home.HomeMainFragmentSourceTest" --tests "kr.co.vividnext.sodalive.v2.main.content.ContentMainFragmentSourceTest" --tests "kr.co.vividnext.sodalive.v2.main.chat.ChatMainFragmentLayoutTest"`, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.home.*"`, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.content.*"`, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.*"`, `./gradlew :app:compileDebugKotlin`, `./gradlew :app:mergeDebugResources`, `./gradlew :app:ktlintCheck`, `git diff --check`를 실행했다. Gradle 명령은 모두 BUILD SUCCESSFUL이며 `git diff --check`는 출력 없이 통과했다. ktlint의 `.editorconfig disabled_rules` deprecation warning과 일부 테스트 deprecated API warning은 기존 경고로 남아 있다.
|
||||
- 2026-07-22: 재리뷰에서 남은 Content 전체 탭 refresh 실패 복원 top-scroll, Chat filter 전환 후 stale refresh ownership 문제를 추가 보완했다. RED는 `ContentMainFragmentSourceTest.kt:152`, `ChatMainFragmentLayoutTest.kt:205`에서 확인했다. Content 전체 탭은 복원 snapshot copy를 참조 동일성만이 아니라 동일 page/content 비교로도 실패 복원 판정하고, Chat은 filter 변경 시 기존 refresh ownership과 indicator를 취소한다.
|
||||
- 2026-07-22: 최종 재검증으로 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.content.ContentMainFragmentSourceTest" --tests "kr.co.vividnext.sodalive.v2.main.chat.ChatMainFragmentLayoutTest"`, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.PullRefreshRequestRaceSourceTest" --tests "kr.co.vividnext.sodalive.v2.main.content.ContentAllTabViewModelTest"`, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.home.*"`, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.content.*"`, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.*"`, `./gradlew :app:compileDebugKotlin`, `./gradlew :app:mergeDebugResources`, `./gradlew :app:ktlintCheck`, `git diff --check`를 실행했다. 모두 BUILD SUCCESSFUL 또는 출력 없음으로 통과했다.
|
||||
- 2026-07-22: 최종 재리뷰에서 Critical/Important 이슈가 남아 있지 않다고 확인받았다. Minor로 Content 전체 탭에서 성공 응답 데이터가 기존 page/content와 완전히 동일하면 top-scroll이 생략될 수 있다는 UX 미세 위험이 남았으나 blocker는 아니라고 판단되었다.
|
||||
- 2026-07-22: 후속 리뷰 Important 대응으로 Content 랭킹/전체, Chat의 A→B→A 지연 응답 race 테스트와 Home 추천 refresh/follow 완료 순서 테스트를 먼저 추가했다. production 변경 전 targeted test 실행에서 `ContentRankingViewModelTest.kt:211`, `ContentAllTabViewModelTest.kt:793`, `ChatMainViewModelTest.kt:215`, `HomeRecommendationViewModelTest.kt:102`, `HomeMainFragmentLoginGuardSourceTest.kt:145` 실패로 RED를 확인했다.
|
||||
- 2026-07-22: `Set<key>` 방식 대신 최신 first-page 요청만 나타내는 `activeFirstPageKey`로 세 ViewModel을 보완했고, Home 추천 follow 성공은 refresh 중에도 flag로 보존해 최종 Content에 반영하도록 수정했다. `HomeMainFragmentLoginGuardSourceTest`와 `PullRefreshRequestRaceSourceTest`는 새 source 계약에 맞춰 갱신했다.
|
||||
- 2026-07-22: 후속 수정 검증으로 targeted 묶음 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.PullRefreshRequestRaceSourceTest" --tests "kr.co.vividnext.sodalive.v2.main.content.ContentRankingViewModelTest" --tests "kr.co.vividnext.sodalive.v2.main.content.ContentAllTabViewModelTest" --tests "kr.co.vividnext.sodalive.v2.main.chat.ChatMainViewModelTest" --tests "kr.co.vividnext.sodalive.v2.main.home.HomeRecommendationViewModelTest" --tests "kr.co.vividnext.sodalive.v2.main.home.HomeMainFragmentLoginGuardSourceTest"`와 전체 `./gradlew :app:testDebugUnitTest`를 실행했고 모두 BUILD SUCCESSFUL이다. Reviewer gate에서도 blocker 없음으로 확인받았다.
|
||||
- 2026-07-22: 추가 리뷰 Important 2건 대응으로 `ContentRankingViewModelTest`에 캐시 반환 경로 active key 고착 회귀 테스트를 추가하고, `HomeRecommendationViewModelTest`에 refresh/follow 실패 완료 순서별 최신 Content 보존 테스트를 추가했다. production 변경 전 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.content.ContentRankingViewModelTest" --tests "kr.co.vividnext.sodalive.v2.main.home.HomeRecommendationViewModelTest"` 실행에서 `ContentRankingViewModelTest.kt:254` AssertionError, `HomeRecommendationViewModelTest.kt:144` ClassCastException, `HomeRecommendationViewModelTest.kt:132` AssertionError로 RED를 확인했다.
|
||||
- 2026-07-22: `ContentRankingViewModel`은 캐시 반환 시 `latestRequestId`를 증가시키고 `activeFirstPageKey`를 비워 진행 중 stale 응답과 active key를 무효화하도록 수정했다. `HomeRecommendationViewModel`은 fallback Content에도 follow 완료 상태를 반영하고, follow 실패는 현재 추천 화면을 `Error`로 덮지 않고 toast만 발행하도록 수정했다.
|
||||
- 2026-07-22: 후속 검증으로 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.content.ContentRankingViewModelTest" --tests "kr.co.vividnext.sodalive.v2.main.home.HomeRecommendationViewModelTest"`, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.PullRefreshRequestRaceSourceTest" --tests "kr.co.vividnext.sodalive.v2.main.content.ContentRankingViewModelTest" --tests "kr.co.vividnext.sodalive.v2.main.home.HomeRecommendationViewModelTest"`, 전체 `./gradlew :app:testDebugUnitTest`, `./gradlew :app:ktlintCheck`, `git diff --check`를 실행했다. Gradle 명령은 모두 BUILD SUCCESSFUL이며 `git diff --check`는 출력 없이 통과했다. Reviewer gate도 blocker 없음으로 확인받았다.
|
||||
- 2026-07-22: Minor 2건 대응으로 `ContentAllTabViewModelTest`와 `ChatMainViewModelTest`의 A→B→A 테스트에 후속 pagination 요청 조건 검증을 추가했다. 이후 `lastRenderedContentAllState`, `stopHomeRefreshIndicators()`, `stopContentRefreshIndicators()`, 호출자 없는 `onStaleResponse` 파라미터를 제거하고 관련 source test 기대값을 갱신했다.
|
||||
- 2026-07-22: Minor 반영 검증으로 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.content.ContentAllTabViewModelTest" --tests "kr.co.vividnext.sodalive.v2.main.chat.ChatMainViewModelTest"`, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.content.ContentAllTabViewModelTest" --tests "kr.co.vividnext.sodalive.v2.main.chat.ChatMainViewModelTest" --tests "kr.co.vividnext.sodalive.v2.main.content.ContentMainFragmentSourceTest" --tests "kr.co.vividnext.sodalive.v2.main.home.HomeMainFragmentSourceTest"`, 전체 `./gradlew :app:testDebugUnitTest`, `./gradlew :app:ktlintCheck`, `git diff --check`를 실행했다. Gradle 명령은 모두 BUILD SUCCESSFUL이며 `git diff --check`는 출력 없이 통과했다. 기존 `.editorconfig disabled_rules` deprecation warning과 테스트 deprecated API warning은 남아 있다.
|
||||
- 2026-07-22: 추가 비차단 Minor 대응으로 `ContentMainFragmentSourceTest`에서 `lastRenderedContentAllState` 부재를 고정하던 과거 구현 문자열 assertion 3개를 제거했다. 또한 `code-style.md`의 신규 UI 레이아웃/표현 속성 테스트 금지 규칙에 맞춰 Home/Content/Chat의 신규 refresh XML class/ID/hierarchy assertion 테스트를 제거하고, refresh dispatch와 상태 전이 source 검증은 유지했다.
|
||||
- 2026-07-22: 추가 Minor 정리 검증으로 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.content.ContentMainFragmentSourceTest" --tests "kr.co.vividnext.sodalive.v2.main.chat.ChatMainFragmentLayoutTest" --tests "kr.co.vividnext.sodalive.v2.main.home.HomeMainFragmentSourceTest"`, 전체 `./gradlew :app:testDebugUnitTest`, `./gradlew :app:ktlintCheck`, `git diff --check`를 실행했다. Gradle 명령은 모두 BUILD SUCCESSFUL이며 `git diff --check`는 출력 없이 통과했다. 제거 대상 문자열 검색 `rg -n "lastRenderedContentAllState|content layout has refresh containers|chat layout wraps room list|home layout has refresh containers" app/src/test/java/kr/co/vividnext/sodalive/v2/main`도 출력이 없었다.
|
||||
- 2026-07-22: 추가 재검토에서 Chat/Home 테스트에 남아 있던 신규 refresh hierarchy assertion과 unused helper를 더 제거했다. 제거 대상 문자열 검색 `rg -n "swipe_home_recommendation|swipe_home_ranking|swipe_home_following|SwipeRefreshLayout|swipe_chat_rooms|findViewByName|content layout has refresh containers|home layout has refresh containers|chat layout wraps room list|lastRenderedContentAllState" app/src/test/java/kr/co/vividnext/sodalive/v2/main`은 출력이 없었다. 관련 targeted test 묶음은 BUILD SUCCESSFUL이다. 이후 전체 `./gradlew :app:testDebugUnitTest`는 `CreatorChannelHomeViewModelTest.kt:285`의 공유 상태성 실패가 1회 발생했으나 같은 명령 단독 재실행은 BUILD SUCCESSFUL이다. `./gradlew :app:ktlintCheck`는 BUILD SUCCESSFUL, `git diff --check`는 출력 없이 통과했다. 기존 `.editorconfig disabled_rules` deprecation warning과 테스트 deprecated API warning은 남아 있다.
|
||||
|
||||
@@ -169,6 +169,12 @@
|
||||
- 콘텐츠 `랭킹` 탭에서 내부 ranking type이 여러 개일 경우 현재 선택 type 유지 방식은 구현 계획에서 실제 ViewModel 상태를 기준으로 확정한다.
|
||||
- refresh indicator 색상은 별도 디자인 요구가 없으므로 기존 theme 기본값을 우선 사용하고, 필요 시 구현 계획에서 기존 앱 색상과 맞춘다.
|
||||
|
||||
### 2026-07-22 구현 계획 확정
|
||||
- 홈 `랭킹` 탭은 `HomeCreatorRankingViewModel.loadCreatorRankings()`와 `HomeCreatorRankingRepository.getCreatorRankings()` 흐름을 새로고침 대상으로 사용한다.
|
||||
- 홈 `팔로잉` 탭은 기존 `ensureV2Access(AccessRequirement.Login)` 진입 가드를 유지한다. 로그인되지 않은 상태에서는 팔로잉 탭으로 전환되지 않으므로 새로고침도 노출하지 않는다.
|
||||
- 콘텐츠 `랭킹` 탭은 `ContentRankingViewModel.selectedTypeLiveData`의 현재 `AudioRankingType`을 `force = true`로 다시 호출한다.
|
||||
- refresh indicator 색상은 별도 디자인 요구가 없으므로 `SwipeRefreshLayout` 기본 theme 색상을 사용한다.
|
||||
|
||||
---
|
||||
|
||||
## 12. References
|
||||
@@ -188,3 +194,16 @@
|
||||
- 2026-07-22: `ContentAllTabViewModel`, `MainContentAllTabApi`, `ContentOverviewViewModel`, 관련 UI state 구조를 codegraph로 확인해 첫 페이지 재조회와 pagination 분리 요구사항을 문서화했다.
|
||||
- 2026-07-22: 사용자 확인에 따라 적용 범위를 메인 홈 `추천/랭킹/팔로잉`, 콘텐츠 `추천/랭킹/전체`, 대화 `전체/AI 채팅/DM` 현재 선택 상태별 새로고침으로 확정했다.
|
||||
- 2026-07-22: 이번 단계는 PRD 작성만 수행했으며 구현/빌드/테스트는 실행하지 않는다.
|
||||
- 2026-07-22: 당겨서 새로고침 구현 후 `./gradlew :app:mergeDebugResources`, `./gradlew :app:compileDebugKotlin`, 홈/콘텐츠/대화 관련 targeted unit test를 실행했고 결과는 BUILD SUCCESSFUL이다.
|
||||
- 2026-07-22: `adb devices` 결과 연결 기기가 없어 수동 QA는 환경 차단으로 기록했다.
|
||||
- 2026-07-22: 최종 리뷰에서 지적된 pull-to-refresh 실패 시 기존 데이터 보존과 성공 후 최상단 복귀를 Fragment/ViewModel 내부 최소 변경으로 반영한 뒤 `./gradlew :app:mergeDebugResources`, `./gradlew :app:compileDebugKotlin`, 홈/콘텐츠/대화 관련 targeted unit test, `./gradlew :app:ktlintCheck`를 재실행했고 결과는 BUILD SUCCESSFUL이다. ktlint 실행 중 `.editorconfig`의 `disabled_rules` deprecation warning은 출력되었다.
|
||||
- 2026-07-22: 추가 리뷰에서 지적된 `SwipeRefreshLayout` child scroll 판정, 실패 복원 시 불필요한 top-scroll, Home/Content ViewModel terminal state 보존을 보완했다. RED는 `ChatMainFragmentLayoutTest`, `ContentMainFragmentSourceTest`, `ContentRankingViewModelTest`, `HomeFollowingViewModelTest`에서 먼저 확인했고, 수정 후 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.content.*"`, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.home.*"`, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.*"`, `./gradlew :app:mergeDebugResources`, `./gradlew :app:compileDebugKotlin`, `./gradlew :app:ktlintCheck`, `git diff --check`를 재실행했다. 모두 BUILD SUCCESSFUL 또는 출력 없음으로 통과했으며, ktlint의 `.editorconfig disabled_rules` deprecation warning은 기존 경고로 남아 있다.
|
||||
- 2026-07-22: Home/Content refresh 실패 복원 state identity 판정과 Content 전체 탭 refresh 실패 시 이전 Content state 재방출을 보완한 뒤 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.content.*"`, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.home.*"`, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.*"`, `./gradlew :app:mergeDebugResources`, `./gradlew :app:compileDebugKotlin`, `./gradlew :app:ktlintCheck`, `git diff --check`를 재실행했다. Gradle 명령은 모두 BUILD SUCCESSFUL이며 `git diff --check`는 출력 없이 통과했다. `ktlintCheck`는 새 테스트 긴 줄 위반을 한 차례 발견해 줄바꿈만 수정한 뒤 재실행 통과했고, `.editorconfig disabled_rules` deprecation warning은 기존 경고로 남아 있다.
|
||||
- 2026-07-22: 리뷰 세션 재검토 결과 이전 fallback `Content` 오분류 blocker는 해결됐고 Critical/Important 이슈는 남아 있지 않다고 확인받았다. 비차단 잔여 위험으로 `ContentAllTabViewModelTest`가 복원 객체의 `assertSame`까지 고정하지는 않는다는 의견이 있었으나, 현재 source test와 ViewModel 테스트 조합은 저장소 테스트 관례 기준 충분하다고 판단되었다.
|
||||
- 2026-07-22: 추가 리뷰에서 첫 페이지 중복 요청/오래된 응답, pagination 중 refresh 실패 복원, Chat filter 전환 실패, Home/Content 탭 전환 중 refresh 종료 소유권 문제가 지적되었다. 전역 LoadingDialog 차단은 보조 UX일 뿐 correctness를 보장하지 못하므로, ViewModel request generation/중복 무시와 Fragment refresh 시작 탭/필터 저장으로 최소 보완하기로 확정했다.
|
||||
- 2026-07-22: 추가 리뷰 Important 1~4를 반영해 첫 페이지 ViewModel 중복 요청 무시/request generation guard, Content 전체 탭 refresh 실패 복원 snapshot 정규화, Home/Content/Chat refresh 시작 탭/필터 소유권 기반 완료 처리를 적용했다. Minor의 indicator 종료 지연은 전역 UI 차단 대신 대상 요청 소유권 기반 종료로 완화했다. 관련 targeted unit test, `compileDebugKotlin`, `mergeDebugResources`, `ktlintCheck`, `git diff --check`는 모두 통과했다.
|
||||
- 2026-07-22: 재리뷰에서 남은 Content 전체 탭 refresh 실패 복원 top-scroll과 Chat filter 전환 후 stale refresh ownership을 추가 보완했다. Content 전체 탭은 복원 snapshot copy를 동일 page/content 비교로 실패 복원 판정하고, Chat은 filter 변경 시 기존 refresh ownership과 indicator를 취소한다. 관련 targeted unit test, `compileDebugKotlin`, `mergeDebugResources`, `ktlintCheck`, `git diff --check`는 모두 통과했다.
|
||||
- 2026-07-22: 최종 재리뷰에서 Critical/Important 이슈가 남아 있지 않다고 확인받았다. Minor로 Content 전체 탭에서 성공 응답 데이터가 기존 page/content와 완전히 동일하면 top-scroll이 생략될 수 있다는 UX 미세 위험이 남았으나 blocker는 아니라고 판단되었다.
|
||||
- 2026-07-22: 추가 Important 리뷰 지적을 반영해 Content 랭킹/전체/Chat의 첫 페이지 in-flight key 중복 방어, Home/Content 대상 wrapper indicator 종료, Home 추천/follow loading 분리, Content 전체 탭 명시 refresh 결과 신호를 보완했다. RED는 신규/수정 테스트의 미구현 컴파일 오류로 먼저 확인했고, 구현 후 targeted unit test 묶음은 BUILD SUCCESSFUL이다.
|
||||
- 2026-07-22: Phase 6 보완 후 `./gradlew :app:ktlintCheck`와 targeted unit test 묶음을 재실행했고 모두 BUILD SUCCESSFUL이다. ktlint의 `.editorconfig disabled_rules` deprecation warning은 기존 경고로 남아 있다.
|
||||
- 2026-07-22: read-only 리뷰에서 확인된 Content 전체 탭 stale key 잔류와 실패 indicator 미종료 위험을 추가 보완했다. stale 응답 후 같은 key 재요청 가능성과 대상 wrapper indicator 종료를 테스트로 고정했고 관련 targeted unit test는 BUILD SUCCESSFUL이다.
|
||||
|
||||
Reference in New Issue
Block a user