diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/ChatMainFragment.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/ChatMainFragment.kt index df5c626e..5f6b872e 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/ChatMainFragment.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/ChatMainFragment.kt @@ -48,6 +48,9 @@ class ChatMainFragment : BaseFragment( 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( 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( ) 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( }) } + 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( loadingDialog.show(screenWidth) } else { loadingDialog.dismiss() + binding.swipeChatRooms.isRefreshing = false } } viewModel.toastLiveData.observe(viewLifecycleOwner) { @@ -162,6 +215,12 @@ class ChatMainFragment : BaseFragment( } } + private fun cancelChatPullRefresh() { + isChatPullRefreshing = false + refreshingChatFilter = null + binding.swipeChatRooms.isRefreshing = false + } + private fun bindChatRooms(items: List, showEmpty: Boolean) { chatRoomListAdapter.submitItems(items) binding.rvChatRooms.isVisible = !showEmpty diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/ChatMainViewModel.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/ChatMainViewModel.kt index b48bb40d..a6bab34b 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/ChatMainViewModel.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/ChatMainViewModel.kt @@ -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() val chatRoomStateLiveData: LiveData @@ -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 diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/ContentAllTabViewModel.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/ContentAllTabViewModel.kt index 2355e5ef..67d0b8b7 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/ContentAllTabViewModel.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/ContentAllTabViewModel.kt @@ -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 get() = _toastLiveData + private val _refreshResultLiveData = MutableLiveData() + val refreshResultLiveData: LiveData + get() = _refreshResultLiveData + private var selectedType: MainContentAllType = MainContentAllType.AUDIO private var selectedSort: ContentSort = ContentSort.LATEST private var selectedDayOfWeek: SeriesPublishedDaysOfWeek? = null private var requestGeneration: Int = 0 + private var activeFirstPageKey: FirstPageRequestKey? = null fun loadContents() { loadFirstPage(selectedType, selectedSort, selectedDayOfWeekFor(selectedType)) @@ -121,28 +131,58 @@ class ContentAllTabViewModel( sort: ContentSort, dayOfWeek: SeriesPublishedDaysOfWeek? ) { + val requestKey = FirstPageRequestKey(type, sort, dayOfWeek) + if (activeFirstPageKey == requestKey) return + activeFirstPageKey = requestKey + val previousContent = (_allTabStateLiveData.value as? MainContentAllTabUiState.Content) + ?.takeIf { it.matches(type, sort, dayOfWeek) } + ?.copy(isLoadingMore = false, paginationErrorMessage = null) val generation = ++requestGeneration _isLoading.value = true _allTabStateLiveData.value = MainContentAllTabUiState.Loading(type, sort, dayOfWeek, totalCount = 0) - requestContents(type, sort, FIRST_PAGE, dayOfWeek, generation) { response -> - _isLoading.value = false - val data = response.data - if (response.success && data != null) { - val content = data.toContent() - _allTabStateLiveData.value = if (content.isSelectedTypeEmpty()) { - MainContentAllTabUiState.Empty( - content.selectedType, - content.selectedSort, - content.selectedDayOfWeek, - content.totalCount - ) - } else { - content - } + + fun showFirstPageError(message: String?) { + _refreshResultLiveData.value = ContentAllTabRefreshResult.Failure + if (previousContent != null) { + _allTabStateLiveData.value = previousContent + _toastLiveData.value = ToastMessage(resId = R.string.common_error_unknown) } else { - showFirstPageError(type, sort, dayOfWeek, response.message) + showFirstPageError(type, sort, dayOfWeek, message) } } + + requestContents( + type = type, + sort = sort, + page = FIRST_PAGE, + dayOfWeek = dayOfWeek, + generation = generation, + onSuccess = { response -> + clearActiveFirstPageKey(requestKey, generation) + _isLoading.value = false + val data = response.data + if (response.success && data != null) { + _refreshResultLiveData.value = ContentAllTabRefreshResult.Success + val content = data.toContent() + _allTabStateLiveData.value = if (content.isSelectedTypeEmpty()) { + MainContentAllTabUiState.Empty( + content.selectedType, + content.selectedSort, + content.selectedDayOfWeek, + content.totalCount + ) + } else { + content + } + } else { + showFirstPageError(response.message) + } + }, + onFirstPageError = { message -> + clearActiveFirstPageKey(requestKey, generation) + showFirstPageError(message) + } + ) } private fun requestContents( @@ -151,6 +191,7 @@ class ContentAllTabViewModel( page: Int, dayOfWeek: SeriesPublishedDaysOfWeek?, generation: Int, + onFirstPageError: ((String?) -> Unit)? = null, onSuccess: (ApiResponse) -> 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? + ) } diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/ContentMainFragment.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/ContentMainFragment.kt index b39d18c3..50482f34 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/ContentMainFragment.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/ContentMainFragment.kt @@ -112,6 +112,11 @@ class ContentMainFragment : BaseFragment( 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( showContentTab(CONTENT_TAB_RECOMMENDATION) setUpSectionTitles() setUpAdapters() + setUpRefreshLayouts() bindObservers() contentMainViewModel.loadRecommendations() applyPendingAllTabSelection() @@ -249,16 +255,16 @@ class ContentMainFragment : BaseFragment( } 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( } 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( } 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( } } + 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( } 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( 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( 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( } private fun bindAllTabContent(state: MainContentAllTabUiState.Content) { + hasRenderedAllTabContent = true bindAllTabControls(state) binding.layoutContentAllSurface.visibility = View.VISIBLE hideAllTabEmptyError() @@ -422,8 +536,14 @@ class ContentMainFragment : BaseFragment( 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( } 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( bindAllTabControls(state) binding.layoutContentAllSurface.visibility = View.VISIBLE hideAllTabEmptyError() + if (isContentPullRefreshing && refreshingContentTab == CONTENT_TAB_ALL && hasRenderedAllTabContent) return clearAllTabItems() } diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/ContentMainViewModel.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/ContentMainViewModel.kt index 4ba66360..6c2d355f 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/ContentMainViewModel.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/ContentMainViewModel.kt @@ -29,7 +29,13 @@ class ContentMainViewModel( val isLoading: LiveData 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)) } diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/ContentRankingViewModel.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/ContentRankingViewModel.kt index d6fabea6..7855bda1 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/ContentRankingViewModel.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/ContentRankingViewModel.kt @@ -19,6 +19,7 @@ class ContentRankingViewModel( ) : BaseViewModel() { private val cachedStates = mutableMapOf() + private var activeFirstPageKey: AudioRankingType? = null private var latestRequestId = 0L private val _rankingStateLiveData = MutableLiveData() @@ -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) } diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/HomeCreatorRankingViewModel.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/HomeCreatorRankingViewModel.kt index e2fa96e4..db30d57f 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/HomeCreatorRankingViewModel.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/HomeCreatorRankingViewModel.kt @@ -29,7 +29,13 @@ class HomeCreatorRankingViewModel( val isLoading: LiveData 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)) } diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/HomeFollowingViewModel.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/HomeFollowingViewModel.kt index 72ea1fb7..6854be9d 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/HomeFollowingViewModel.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/HomeFollowingViewModel.kt @@ -33,7 +33,13 @@ class HomeFollowingViewModel( val isLoading: LiveData 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) } diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/HomeMainFragment.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/HomeMainFragment.kt index 415606fd..014aef85 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/HomeMainFragment.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/HomeMainFragment.kt @@ -125,9 +125,15 @@ class HomeMainFragment : BaseFragment( 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( setUpRecommendationAdapters() setUpCreatorRankingAdapter() setUpFollowingAdapters() + setUpRefreshLayouts() setUpBusinessInfo() setupTitleBarActions() bindHomeRecommendationObservers() @@ -260,12 +267,78 @@ class HomeMainFragment : BaseFragment( } } + 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( 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( } 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( 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( 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( 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( } private fun updateLoadingDialog() { - if (isRecommendationLoading || isCreatorRankingLoading || isFollowingLoading || isLiveEntryLoading) { + if ( + isRecommendationLoading || + isRecommendationFollowLoading || + isCreatorRankingLoading || + isFollowingLoading || + isLiveEntryLoading + ) { loadingDialog.show(screenWidth) } else { loadingDialog.dismiss() diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/HomeRecommendationViewModel.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/HomeRecommendationViewModel.kt index 5277adbe..a294e428 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/HomeRecommendationViewModel.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/HomeRecommendationViewModel.kt @@ -30,7 +30,18 @@ class HomeRecommendationViewModel( val isLoading: LiveData get() = _isLoading + private val _isFollowLoading = MutableLiveData(false) + val isFollowLoading: LiveData + 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) { 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 { diff --git a/app/src/main/res/layout/fragment_v2_main_chat.xml b/app/src/main/res/layout/fragment_v2_main_chat.xml index d44685e7..a246b433 100644 --- a/app/src/main/res/layout/fragment_v2_main_chat.xml +++ b/app/src/main/res/layout/fragment_v2_main_chat.xml @@ -24,30 +24,38 @@ app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@id/view_chat_title_bar" /> - + app:layout_constraintTop_toBottomOf="@id/view_chat_filter_tabs"> - + + + + + + + diff --git a/app/src/main/res/layout/fragment_v2_main_content.xml b/app/src/main/res/layout/fragment_v2_main_content.xml index e3dffa17..6728bdc0 100644 --- a/app/src/main/res/layout/fragment_v2_main_content.xml +++ b/app/src/main/res/layout/fragment_v2_main_content.xml @@ -99,41 +99,48 @@ - - - - - + + + + + + + + + + - + app:layout_constraintTop_toBottomOf="@id/view_content_ranking_type_tabs"> - + + + + + - + + diff --git a/app/src/main/res/layout/fragment_v2_main_home.xml b/app/src/main/res/layout/fragment_v2_main_home.xml index efa033e5..035ed826 100644 --- a/app/src/main/res/layout/fragment_v2_main_home.xml +++ b/app/src/main/res/layout/fragment_v2_main_home.xml @@ -24,16 +24,21 @@ app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@id/view_home_title_bar" /> - + + - + + - + + - + + - + app:layout_constraintTop_toBottomOf="@id/text_tab_bar_home"> + + + diff --git a/app/src/test/java/kr/co/vividnext/sodalive/v2/main/PullRefreshRequestRaceSourceTest.kt b/app/src/test/java/kr/co/vividnext/sodalive/v2/main/PullRefreshRequestRaceSourceTest.kt new file mode 100644 index 00000000..d85d3b6f --- /dev/null +++ b/app/src/test/java/kr/co/vividnext/sodalive/v2/main/PullRefreshRequestRaceSourceTest.kt @@ -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") + } +} diff --git a/app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/ChatMainFragmentLayoutTest.kt b/app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/ChatMainFragmentLayoutTest.kt index a224fc2a..95fdaa8a 100644 --- a/app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/ChatMainFragmentLayoutTest.kt +++ b/app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/ChatMainFragmentLayoutTest.kt @@ -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(R.id.view_chat_title_bar)) val tabBar = requireNotNull(root.findViewById(R.id.view_chat_filter_tabs)) - val recyclerView = requireNotNull(root.findViewById(R.id.rv_chat_rooms)) val emptyMessage = requireNotNull(root.findViewById(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(R.id.view_chat_title_bar)) - val tabBar = requireNotNull(root.findViewById(R.id.view_chat_filter_tabs)) - val recyclerView = requireNotNull(root.findViewById(R.id.rv_chat_rooms)) - val emptyMessage = requireNotNull(root.findViewById(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() - return (this * context.resources.displayMetrics.density).toInt() - } } diff --git a/app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/ChatMainViewModelTest.kt b/app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/ChatMainViewModelTest.kt index 8f492381..c6532c4a 100644 --- a/app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/ChatMainViewModelTest.kt +++ b/app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/ChatMainViewModelTest.kt @@ -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>() + 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>() + val pendingDm = SingleSubject.create>() + 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>() + val pendingDm = SingleSubject.create>() + val pendingAllLatest = SingleSubject.create>() + 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)) diff --git a/app/src/test/java/kr/co/vividnext/sodalive/v2/main/content/ContentAllTabViewModelTest.kt b/app/src/test/java/kr/co/vividnext/sodalive/v2/main/content/ContentAllTabViewModelTest.kt index 83c95189..430e1b5e 100644 --- a/app/src/test/java/kr/co/vividnext/sodalive/v2/main/content/ContentAllTabViewModelTest.kt +++ b/app/src/test/java/kr/co/vividnext/sodalive/v2/main/content/ContentAllTabViewModelTest.kt @@ -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>() + 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>() + val pendingFree = SingleSubject.create>() + 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>() + val pendingFree = SingleSubject.create>() + 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>() + val pendingAudioLatest = SingleSubject.create>() + val pendingFree = SingleSubject.create>() + 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>() + 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>() diff --git a/app/src/test/java/kr/co/vividnext/sodalive/v2/main/content/ContentMainFragmentSourceTest.kt b/app/src/test/java/kr/co/vividnext/sodalive/v2/main/content/ContentMainFragmentSourceTest.kt index fe7739f2..04de5ddd 100644 --- a/app/src/test/java/kr/co/vividnext/sodalive/v2/main/content/ContentMainFragmentSourceTest.kt +++ b/app/src/test/java/kr/co/vividnext/sodalive/v2/main/content/ContentMainFragmentSourceTest.kt @@ -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) diff --git a/app/src/test/java/kr/co/vividnext/sodalive/v2/main/content/ContentRankingViewModelTest.kt b/app/src/test/java/kr/co/vividnext/sodalive/v2/main/content/ContentRankingViewModelTest.kt index a12bf181..12541e0a 100644 --- a/app/src/test/java/kr/co/vividnext/sodalive/v2/main/content/ContentRankingViewModelTest.kt +++ b/app/src/test/java/kr/co/vividnext/sodalive/v2/main/content/ContentRankingViewModelTest.kt @@ -160,6 +160,132 @@ class ContentRankingViewModelTest { assertEquals(listOf("2"), state.items.map { it.contentId }) } + @Test + fun `같은 type 첫 페이지 요청이 진행 중이면 중복 호출을 무시한다`() { + val pending = SingleSubject.create>() + 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>() + val pendingRising = SingleSubject.create>() + 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>() + val pendingRising = SingleSubject.create>() + val pendingWeeklyLatest = SingleSubject.create>() + 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>() + val pendingRising = SingleSubject.create>() + val pendingRisingLatest = SingleSubject.create>() + 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>() diff --git a/app/src/test/java/kr/co/vividnext/sodalive/v2/main/home/HomeFollowingFragmentSourceTest.kt b/app/src/test/java/kr/co/vividnext/sodalive/v2/main/home/HomeFollowingFragmentSourceTest.kt index 1fb8df6c..9c4b108b 100644 --- a/app/src/test/java/kr/co/vividnext/sodalive/v2/main/home/HomeFollowingFragmentSourceTest.kt +++ b/app/src/test/java/kr/co/vividnext/sodalive/v2/main/home/HomeFollowingFragmentSourceTest.kt @@ -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 diff --git a/app/src/test/java/kr/co/vividnext/sodalive/v2/main/home/HomeFollowingViewModelTest.kt b/app/src/test/java/kr/co/vividnext/sodalive/v2/main/home/HomeFollowingViewModelTest.kt index 220835ba..dbf3dfc6 100644 --- a/app/src/test/java/kr/co/vividnext/sodalive/v2/main/home/HomeFollowingViewModelTest.kt +++ b/app/src/test/java/kr/co/vividnext/sodalive/v2/main/home/HomeFollowingViewModelTest.kt @@ -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) diff --git a/app/src/test/java/kr/co/vividnext/sodalive/v2/main/home/HomeMainFragmentLayoutTest.kt b/app/src/test/java/kr/co/vividnext/sodalive/v2/main/home/HomeMainFragmentLayoutTest.kt index ba5105b4..219b86ec 100644 --- a/app/src/test/java/kr/co/vividnext/sodalive/v2/main/home/HomeMainFragmentLayoutTest.kt +++ b/app/src/test/java/kr/co/vividnext/sodalive/v2/main/home/HomeMainFragmentLayoutTest.kt @@ -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")) } diff --git a/app/src/test/java/kr/co/vividnext/sodalive/v2/main/home/HomeMainFragmentLoginGuardSourceTest.kt b/app/src/test/java/kr/co/vividnext/sodalive/v2/main/home/HomeMainFragmentLoginGuardSourceTest.kt index 205f0596..1a88be38 100644 --- a/app/src/test/java/kr/co/vividnext/sodalive/v2/main/home/HomeMainFragmentLoginGuardSourceTest.kt +++ b/app/src/test/java/kr/co/vividnext/sodalive/v2/main/home/HomeMainFragmentLoginGuardSourceTest.kt @@ -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()")) diff --git a/app/src/test/java/kr/co/vividnext/sodalive/v2/main/home/HomeMainFragmentSourceTest.kt b/app/src/test/java/kr/co/vividnext/sodalive/v2/main/home/HomeMainFragmentSourceTest.kt index 7ae5697b..c91ea2f8 100644 --- a/app/src/test/java/kr/co/vividnext/sodalive/v2/main/home/HomeMainFragmentSourceTest.kt +++ b/app/src/test/java/kr/co/vividnext/sodalive/v2/main/home/HomeMainFragmentSourceTest.kt @@ -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() } diff --git a/app/src/test/java/kr/co/vividnext/sodalive/v2/main/home/HomeRecommendationViewModelTest.kt b/app/src/test/java/kr/co/vividnext/sodalive/v2/main/home/HomeRecommendationViewModelTest.kt new file mode 100644 index 00000000..417a3852 --- /dev/null +++ b/app/src/test/java/kr/co/vividnext/sodalive/v2/main/home/HomeRecommendationViewModelTest.kt @@ -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>() + 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>() + 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>() + 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>() + 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>() + 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(), + recentlyActiveCreators = emptyList(), + recentDebutCreators = emptyList(), + aiCharacters = emptyList(), + genreCreators = emptyList(), + cheerCreators = listOf(HomeCreatorItem(creatorId, "크리에이터", "https://example.com/profile.png")), + popularCommunityPosts = emptyList() + ) + + private fun setImmediateRxSchedulers() { + val trampoline = { _: Scheduler -> Schedulers.trampoline() } + RxJavaPlugins.setIoSchedulerHandler(trampoline) + RxAndroidPlugins.setInitMainThreadSchedulerHandler { Schedulers.trampoline() } + RxAndroidPlugins.setMainThreadSchedulerHandler { Schedulers.trampoline() } + } + + private fun LiveData.requireValue(): T? { + var value: T? = null + val observer = Observer { value = it } + observeForever(observer) + removeObserver(observer) + return value + } + + private class FakeHomeRecommendationApi : HomeRecommendationApi { + private val recommendationResponses = ArrayDeque>>() + private val followResponses = ArrayDeque>>() + + fun enqueueRecommendations(response: ApiResponse) { + enqueueRecommendations(Single.just(response)) + } + + fun enqueueRecommendations(response: Single>) { + recommendationResponses.addLast(response) + } + + fun enqueueFollow(response: ApiResponse) { + enqueueFollow(Single.just(response)) + } + + fun enqueueFollow(response: Single>) { + followResponses.addLast(response) + } + + override fun getRecommendations(authHeader: String): Single> { + return recommendationResponses.removeFirst() + } + + override fun followRecommendedCreators( + request: FollowRecommendedCreatorsRequest, + authHeader: String + ): Single> { + return followResponses.removeFirst() + } + } +} diff --git a/docs/20260722_메인_홈_콘텐츠_대화_탭_당겨서_새로고침/plan-task.md b/docs/20260722_메인_홈_콘텐츠_대화_탭_당겨서_새로고침/plan-task.md index 6400c1b4..80b5d5c6 100644 --- a/docs/20260722_메인_홈_콘텐츠_대화_탭_당겨서_새로고침/plan-task.md +++ b/docs/20260722_메인_홈_콘텐츠_대화_탭_당겨서_새로고침/plan-task.md @@ -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` 방식 대신 최신 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은 남아 있다. diff --git a/docs/20260722_메인_홈_콘텐츠_대화_탭_당겨서_새로고침/prd.md b/docs/20260722_메인_홈_콘텐츠_대화_탭_당겨서_새로고침/prd.md index d95294ec..4a57f064 100644 --- a/docs/20260722_메인_홈_콘텐츠_대화_탭_당겨서_새로고침/prd.md +++ b/docs/20260722_메인_홈_콘텐츠_대화_탭_당겨서_새로고침/prd.md @@ -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이다.