feat(main): 메인 탭 당겨서 새로고침을 추가한다

This commit is contained in:
2026-07-22 17:48:33 +09:00
parent b17e250665
commit 6755c8ae99
27 changed files with 1830 additions and 239 deletions

View File

@@ -48,6 +48,9 @@ class ChatMainFragment : BaseFragment<FragmentV2MainChatBinding>(
private val chatRoomListAdapter = ChatRoomListAdapter { onChatRoomClick(it) } private val chatRoomListAdapter = ChatRoomListAdapter { onChatRoomClick(it) }
private lateinit var loadingDialog: LoadingDialog private lateinit var loadingDialog: LoadingDialog
private var selectedFilter: ChatRoomFilter = ChatRoomFilter.ALL 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?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
@@ -58,12 +61,16 @@ class ChatMainFragment : BaseFragment<FragmentV2MainChatBinding>(
selectedFilter = initialFilter selectedFilter = initialFilter
setupFilterTabs(initialFilter) setupFilterTabs(initialFilter)
setupChatRooms() setupChatRooms()
setupRefresh()
bindViewModel() bindViewModel()
viewModel.loadFirstPage(initialFilter) viewModel.loadFirstPage(initialFilter)
} }
fun selectFilter(filter: ChatRoomFilter) { fun selectFilter(filter: ChatRoomFilter) {
if (refreshingChatFilter != null && refreshingChatFilter != filter) {
cancelChatPullRefresh()
}
selectedFilter = filter selectedFilter = filter
selectFilterTab(filter) selectFilterTab(filter)
viewModel.selectFilter(filter) viewModel.selectFilter(filter)
@@ -108,6 +115,9 @@ class ChatMainFragment : BaseFragment<FragmentV2MainChatBinding>(
) )
binding.viewChatFilterTabs.root.setOnTabSelectedListener { index -> binding.viewChatFilterTabs.root.setOnTabSelectedListener { index ->
val filter = ChatRoomFilter.fromTabIndex(index) val filter = ChatRoomFilter.fromTabIndex(index)
if (refreshingChatFilter != null && refreshingChatFilter != filter) {
cancelChatPullRefresh()
}
selectedFilter = filter selectedFilter = filter
viewModel.selectFilter(filter) viewModel.selectFilter(filter)
binding.rvChatRooms.scrollToPosition(0) binding.rvChatRooms.scrollToPosition(0)
@@ -133,18 +143,60 @@ class ChatMainFragment : BaseFragment<FragmentV2MainChatBinding>(
}) })
} }
private fun setupRefresh() {
binding.swipeChatRooms.setOnChildScrollUpCallback { _, _ ->
binding.rvChatRooms.canScrollVertically(-1)
}
binding.swipeChatRooms.setOnRefreshListener {
isChatPullRefreshing = true
refreshingChatFilter = selectedFilter
viewModel.loadFirstPage(selectedFilter)
}
}
private fun bindViewModel() { private fun bindViewModel() {
viewModel.chatRoomStateLiveData.observe(viewLifecycleOwner) { state -> viewModel.chatRoomStateLiveData.observe(viewLifecycleOwner) { state ->
val isRefreshForCurrentFilter = refreshingChatFilter == selectedFilter
when (state) { when (state) {
is ChatRoomListUiState.Content -> { is ChatRoomListUiState.Content -> {
bindChatRooms(state.items, showEmpty = false) 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) binding.rvChatRooms.scrollToPosition(0)
} }
} }
ChatRoomListUiState.Empty -> bindChatRooms(emptyList(), showEmpty = true) ChatRoomListUiState.Empty -> {
is ChatRoomListUiState.Error -> bindChatRooms(emptyList(), showEmpty = false) lastRenderedChatContentState = null
ChatRoomListUiState.Loading -> binding.tvChatEmptyMessage.isVisible = false 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) { viewModel.isLoading.observe(viewLifecycleOwner) {
@@ -152,6 +204,7 @@ class ChatMainFragment : BaseFragment<FragmentV2MainChatBinding>(
loadingDialog.show(screenWidth) loadingDialog.show(screenWidth)
} else { } else {
loadingDialog.dismiss() loadingDialog.dismiss()
binding.swipeChatRooms.isRefreshing = false
} }
} }
viewModel.toastLiveData.observe(viewLifecycleOwner) { viewModel.toastLiveData.observe(viewLifecycleOwner) {
@@ -162,6 +215,12 @@ class ChatMainFragment : BaseFragment<FragmentV2MainChatBinding>(
} }
} }
private fun cancelChatPullRefresh() {
isChatPullRefreshing = false
refreshingChatFilter = null
binding.swipeChatRooms.isRefreshing = false
}
private fun bindChatRooms(items: List<ChatRoomListUiItem>, showEmpty: Boolean) { private fun bindChatRooms(items: List<ChatRoomListUiItem>, showEmpty: Boolean) {
chatRoomListAdapter.submitItems(items) chatRoomListAdapter.submitItems(items)
binding.rvChatRooms.isVisible = !showEmpty binding.rvChatRooms.isVisible = !showEmpty

View File

@@ -25,6 +25,7 @@ class ChatMainViewModel(
private var nextCursor: String? = null private var nextCursor: String? = null
private var hasMore: Boolean = false private var hasMore: Boolean = false
private var requestGeneration: Long = 0L private var requestGeneration: Long = 0L
private var activeFirstPageKey: ChatRoomFilter? = null
private val _chatRoomStateLiveData = MutableLiveData<ChatRoomListUiState>() private val _chatRoomStateLiveData = MutableLiveData<ChatRoomListUiState>()
val chatRoomStateLiveData: LiveData<ChatRoomListUiState> val chatRoomStateLiveData: LiveData<ChatRoomListUiState>
@@ -43,6 +44,15 @@ class ChatMainViewModel(
get() = _isAppending get() = _isAppending
fun loadFirstPage(filter: ChatRoomFilter = currentFilter) { 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 currentFilter = filter
requestGeneration += 1L requestGeneration += 1L
val generation = requestGeneration val generation = requestGeneration
@@ -53,6 +63,18 @@ class ChatMainViewModel(
_isAppending.value = false _isAppending.value = false
_chatRoomStateLiveData.value = ChatRoomListUiState.Loading _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( compositeDisposable.add(
repository.getChatRooms( repository.getChatRooms(
token = authToken(), token = authToken(),
@@ -64,19 +86,21 @@ class ChatMainViewModel(
.subscribe( .subscribe(
{ {
if (generation != requestGeneration) return@subscribe if (generation != requestGeneration) return@subscribe
clearActiveFirstPageKey(requestKey, generation)
_isLoading.value = false _isLoading.value = false
val data = it.data val data = it.data
if (it.success && data != null) { if (it.success && data != null) {
handleFirstPageSuccess(data) handleFirstPageSuccess(data)
} else { } else {
showUnknownError(it.message) showFirstPageError(it.message)
} }
}, },
{ {
if (generation != requestGeneration) return@subscribe if (generation != requestGeneration) return@subscribe
clearActiveFirstPageKey(requestKey, generation)
_isLoading.value = false _isLoading.value = false
it.message?.let { message -> Logger.e(message) } 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) { private fun handleNextPageSuccess(data: ChatRoomListPageResponse) {
currentItems = currentItems + data.rooms.toUiItems() currentItems = currentItems + data.rooms.toUiItems()
hasMore = data.hasMore hasMore = data.hasMore

View File

@@ -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.usesDayOfWeekQuery
import kr.co.vividnext.sodalive.v2.main.content.model.usesSeriesItems import kr.co.vividnext.sodalive.v2.main.content.model.usesSeriesItems
enum class ContentAllTabRefreshResult {
Success,
Failure
}
class ContentAllTabViewModel( class ContentAllTabViewModel(
private val repository: MainContentAllTabRepository, private val repository: MainContentAllTabRepository,
private val currentDayOfWeekProvider: () -> SeriesPublishedDaysOfWeek = { currentDeviceDayOfWeek() } private val currentDayOfWeekProvider: () -> SeriesPublishedDaysOfWeek = { currentDeviceDayOfWeek() }
@@ -40,10 +45,15 @@ class ContentAllTabViewModel(
val toastLiveData: LiveData<ToastMessage?> val toastLiveData: LiveData<ToastMessage?>
get() = _toastLiveData get() = _toastLiveData
private val _refreshResultLiveData = MutableLiveData<ContentAllTabRefreshResult>()
val refreshResultLiveData: LiveData<ContentAllTabRefreshResult>
get() = _refreshResultLiveData
private var selectedType: MainContentAllType = MainContentAllType.AUDIO private var selectedType: MainContentAllType = MainContentAllType.AUDIO
private var selectedSort: ContentSort = ContentSort.LATEST private var selectedSort: ContentSort = ContentSort.LATEST
private var selectedDayOfWeek: SeriesPublishedDaysOfWeek? = null private var selectedDayOfWeek: SeriesPublishedDaysOfWeek? = null
private var requestGeneration: Int = 0 private var requestGeneration: Int = 0
private var activeFirstPageKey: FirstPageRequestKey? = null
fun loadContents() { fun loadContents() {
loadFirstPage(selectedType, selectedSort, selectedDayOfWeekFor(selectedType)) loadFirstPage(selectedType, selectedSort, selectedDayOfWeekFor(selectedType))
@@ -121,13 +131,38 @@ class ContentAllTabViewModel(
sort: ContentSort, sort: ContentSort,
dayOfWeek: SeriesPublishedDaysOfWeek? 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 val generation = ++requestGeneration
_isLoading.value = true _isLoading.value = true
_allTabStateLiveData.value = MainContentAllTabUiState.Loading(type, sort, dayOfWeek, totalCount = 0) _allTabStateLiveData.value = MainContentAllTabUiState.Loading(type, sort, dayOfWeek, totalCount = 0)
requestContents(type, sort, FIRST_PAGE, dayOfWeek, generation) { response ->
fun showFirstPageError(message: String?) {
_refreshResultLiveData.value = ContentAllTabRefreshResult.Failure
if (previousContent != null) {
_allTabStateLiveData.value = previousContent
_toastLiveData.value = ToastMessage(resId = R.string.common_error_unknown)
} else {
showFirstPageError(type, sort, dayOfWeek, message)
}
}
requestContents(
type = type,
sort = sort,
page = FIRST_PAGE,
dayOfWeek = dayOfWeek,
generation = generation,
onSuccess = { response ->
clearActiveFirstPageKey(requestKey, generation)
_isLoading.value = false _isLoading.value = false
val data = response.data val data = response.data
if (response.success && data != null) { if (response.success && data != null) {
_refreshResultLiveData.value = ContentAllTabRefreshResult.Success
val content = data.toContent() val content = data.toContent()
_allTabStateLiveData.value = if (content.isSelectedTypeEmpty()) { _allTabStateLiveData.value = if (content.isSelectedTypeEmpty()) {
MainContentAllTabUiState.Empty( MainContentAllTabUiState.Empty(
@@ -140,9 +175,14 @@ class ContentAllTabViewModel(
content content
} }
} else { } else {
showFirstPageError(type, sort, dayOfWeek, response.message) showFirstPageError(response.message)
} }
},
onFirstPageError = { message ->
clearActiveFirstPageKey(requestKey, generation)
showFirstPageError(message)
} }
)
} }
private fun requestContents( private fun requestContents(
@@ -151,6 +191,7 @@ class ContentAllTabViewModel(
page: Int, page: Int,
dayOfWeek: SeriesPublishedDaysOfWeek?, dayOfWeek: SeriesPublishedDaysOfWeek?,
generation: Int, generation: Int,
onFirstPageError: ((String?) -> Unit)? = null,
onSuccess: (ApiResponse<MainContentAllTabResponse>) -> Unit onSuccess: (ApiResponse<MainContentAllTabResponse>) -> Unit
) { ) {
compositeDisposable.add( 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) } it.message?.let { message -> Logger.e(message) }
_isLoading.value = false _isLoading.value = false
@@ -175,7 +218,7 @@ class ContentAllTabViewModel(
paginationErrorMessage = it.message paginationErrorMessage = it.message
) )
} else { } 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 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 { private fun MainContentAllTabUiState.Content.isSelectedTypeEmpty(): Boolean {
return if (selectedType.usesSeriesItems()) seriesItems.isEmpty() else audioItems.isEmpty() 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( private fun MainContentAllTabUiState.Content.append(
data: MainContentAllTabResponse data: MainContentAllTabResponse
): MainContentAllTabUiState.Content { ): MainContentAllTabUiState.Content {
@@ -241,4 +298,10 @@ class ContentAllTabViewModel(
const val DEFAULT_PAGE_SIZE = 20 const val DEFAULT_PAGE_SIZE = 20
private const val FIRST_PAGE = 0 private const val FIRST_PAGE = 0
} }
private data class FirstPageRequestKey(
val type: MainContentAllType,
val sort: ContentSort,
val dayOfWeek: SeriesPublishedDaysOfWeek?
)
} }

View File

@@ -112,6 +112,11 @@ class ContentMainFragment : BaseFragment<FragmentV2MainContentBinding>(
private var currentContentTab = CONTENT_TAB_RECOMMENDATION private var currentContentTab = CONTENT_TAB_RECOMMENDATION
private var currentAllTabState: MainContentAllTabUiState? = null private var currentAllTabState: MainContentAllTabUiState? = null
private var pendingAllTabSelection: ContentAllTabSelection? = 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?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
@@ -124,6 +129,7 @@ class ContentMainFragment : BaseFragment<FragmentV2MainContentBinding>(
showContentTab(CONTENT_TAB_RECOMMENDATION) showContentTab(CONTENT_TAB_RECOMMENDATION)
setUpSectionTitles() setUpSectionTitles()
setUpAdapters() setUpAdapters()
setUpRefreshLayouts()
bindObservers() bindObservers()
contentMainViewModel.loadRecommendations() contentMainViewModel.loadRecommendations()
applyPendingAllTabSelection() applyPendingAllTabSelection()
@@ -249,16 +255,16 @@ class ContentMainFragment : BaseFragment<FragmentV2MainContentBinding>(
} }
private fun showRecommendationContent() { private fun showRecommendationContent() {
binding.nsvContentRecommendationContent.visibility = View.VISIBLE binding.swipeContentRecommendation.visibility = View.VISIBLE
binding.viewContentRankingTypeTabs.root.visibility = View.GONE binding.viewContentRankingTypeTabs.root.visibility = View.GONE
binding.rvContentRankings.visibility = View.GONE binding.swipeContentRanking.visibility = View.GONE
binding.layoutContentAllSurface.visibility = View.GONE binding.layoutContentAllSurface.visibility = View.GONE
} }
private fun showRankingContent() { private fun showRankingContent() {
binding.nsvContentRecommendationContent.visibility = View.GONE binding.swipeContentRecommendation.visibility = View.GONE
binding.viewContentRankingTypeTabs.root.visibility = View.VISIBLE binding.viewContentRankingTypeTabs.root.visibility = View.VISIBLE
binding.rvContentRankings.visibility = View.VISIBLE binding.swipeContentRanking.visibility = View.VISIBLE
binding.layoutContentAllSurface.visibility = View.GONE binding.layoutContentAllSurface.visibility = View.GONE
if (!hasSelectedRankingTab) { if (!hasSelectedRankingTab) {
hasSelectedRankingTab = true hasSelectedRankingTab = true
@@ -267,9 +273,9 @@ class ContentMainFragment : BaseFragment<FragmentV2MainContentBinding>(
} }
private fun showAllContent() { private fun showAllContent() {
binding.nsvContentRecommendationContent.visibility = View.GONE binding.swipeContentRecommendation.visibility = View.GONE
binding.viewContentRankingTypeTabs.root.visibility = View.GONE binding.viewContentRankingTypeTabs.root.visibility = View.GONE
binding.rvContentRankings.visibility = View.GONE binding.swipeContentRanking.visibility = View.GONE
binding.layoutContentAllSurface.visibility = View.VISIBLE binding.layoutContentAllSurface.visibility = View.VISIBLE
currentAllTabState?.let(::renderAllTabState) currentAllTabState?.let(::renderAllTabState)
if (currentAllTabState == null && !hasSelectedAllTab) { if (currentAllTabState == null && !hasSelectedAllTab) {
@@ -279,9 +285,9 @@ class ContentMainFragment : BaseFragment<FragmentV2MainContentBinding>(
} }
private fun hideContentSurfaces() { private fun hideContentSurfaces() {
binding.nsvContentRecommendationContent.visibility = View.GONE binding.swipeContentRecommendation.visibility = View.GONE
binding.viewContentRankingTypeTabs.root.visibility = View.GONE binding.viewContentRankingTypeTabs.root.visibility = View.GONE
binding.rvContentRankings.visibility = View.GONE binding.swipeContentRanking.visibility = View.GONE
binding.layoutContentAllSurface.visibility = View.GONE binding.layoutContentAllSurface.visibility = View.GONE
} }
@@ -351,12 +357,90 @@ class ContentMainFragment : BaseFragment<FragmentV2MainContentBinding>(
} }
} }
private fun setUpRefreshLayouts() {
binding.swipeContentRecommendation.setOnRefreshListener { refreshCurrentContentTab() }
binding.swipeContentRanking.setOnRefreshListener { refreshCurrentContentTab() }
binding.swipeContentAll.setOnChildScrollUpCallback { _, _ ->
binding.rvContentAllItems.canScrollVertically(-1)
}
binding.swipeContentAll.setOnRefreshListener { refreshCurrentContentTab() }
}
private fun refreshCurrentContentTab() {
isContentPullRefreshing = true
refreshingContentTab = currentContentTab
when (currentContentTab) {
CONTENT_TAB_RECOMMENDATION -> contentMainViewModel.loadRecommendations()
CONTENT_TAB_RANKING -> refreshCurrentRankingTab()
CONTENT_TAB_ALL -> contentAllTabViewModel.loadContents()
}
}
private fun refreshCurrentRankingTab() {
val type = contentRankingViewModel.selectedTypeLiveData.value ?: AudioRankingType.WEEKLY_POPULAR
contentRankingViewModel.loadRankings(type, force = true)
}
private fun finishContentPullRefresh(tab: Int, isRestoredPullRefreshError: Boolean = false) {
if (!isContentPullRefreshing) return
if (refreshingContentTab != tab) return
if (!isRestoredPullRefreshError) {
scrollContentTabToTop(tab)
}
when (tab) {
CONTENT_TAB_RECOMMENDATION -> binding.swipeContentRecommendation.isRefreshing = false
CONTENT_TAB_RANKING -> binding.swipeContentRanking.isRefreshing = false
CONTENT_TAB_ALL -> binding.swipeContentAll.isRefreshing = false
}
isContentPullRefreshing = false
refreshingContentTab = null
}
private fun cancelContentPullRefresh(tab: Int? = refreshingContentTab) {
when (tab) {
CONTENT_TAB_RECOMMENDATION -> binding.swipeContentRecommendation.isRefreshing = false
CONTENT_TAB_RANKING -> binding.swipeContentRanking.isRefreshing = false
CONTENT_TAB_ALL -> binding.swipeContentAll.isRefreshing = false
}
isContentPullRefreshing = false
refreshingContentTab = null
}
private fun shouldPreserveContentOnPullRefreshError(tab: Int): Boolean {
if (!isContentPullRefreshing) return false
if (refreshingContentTab != tab) return false
cancelContentPullRefresh(tab)
return true
}
private fun scrollContentTabToTop(tab: Int) {
when (tab) {
CONTENT_TAB_RECOMMENDATION -> binding.nsvContentRecommendationContent.scrollTo(0, 0)
CONTENT_TAB_RANKING -> binding.rvContentRankings.scrollToPosition(0)
CONTENT_TAB_ALL -> binding.rvContentAllItems.scrollToPosition(0)
}
}
private fun bindObservers() { private fun bindObservers() {
contentMainViewModel.recommendationsStateLiveData.observe(viewLifecycleOwner) { state -> contentMainViewModel.recommendationsStateLiveData.observe(viewLifecycleOwner) { state ->
when (state) { when (state) {
is AudioRecommendationsUiState.Content -> bindContent(state) is AudioRecommendationsUiState.Content -> {
AudioRecommendationsUiState.Empty, val isRestoredPullRefreshError = isContentPullRefreshing &&
is AudioRecommendationsUiState.Error -> bindContent(emptyContent()) 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 AudioRecommendationsUiState.Loading -> Unit
} }
@@ -370,9 +454,23 @@ class ContentMainFragment : BaseFragment<FragmentV2MainContentBinding>(
} }
contentRankingViewModel.rankingStateLiveData.observe(viewLifecycleOwner) { state -> contentRankingViewModel.rankingStateLiveData.observe(viewLifecycleOwner) { state ->
when (state) { when (state) {
is AudioRankingsUiState.Content -> contentRankingAdapter.submitItems(state.items) is AudioRankingsUiState.Content -> {
is AudioRankingsUiState.Empty, val isRestoredPullRefreshError = isContentPullRefreshing &&
is AudioRankingsUiState.Error -> contentRankingAdapter.submitItems(emptyList()) 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 AudioRankingsUiState.Loading -> Unit
} }
@@ -385,6 +483,14 @@ class ContentMainFragment : BaseFragment<FragmentV2MainContentBinding>(
toastMessage?.let(::showToast) toastMessage?.let(::showToast)
} }
contentAllTabViewModel.allTabStateLiveData.observe(viewLifecycleOwner) { state -> 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 currentAllTabState = state
renderAllTabState(state) renderAllTabState(state)
} }
@@ -395,6 +501,13 @@ class ContentMainFragment : BaseFragment<FragmentV2MainContentBinding>(
contentAllTabViewModel.toastLiveData.observe(viewLifecycleOwner) { toastMessage -> contentAllTabViewModel.toastLiveData.observe(viewLifecycleOwner) { toastMessage ->
toastMessage?.let(::showToast) 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) { private fun renderAllTabState(state: MainContentAllTabUiState) {
@@ -409,6 +522,7 @@ class ContentMainFragment : BaseFragment<FragmentV2MainContentBinding>(
} }
private fun bindAllTabContent(state: MainContentAllTabUiState.Content) { private fun bindAllTabContent(state: MainContentAllTabUiState.Content) {
hasRenderedAllTabContent = true
bindAllTabControls(state) bindAllTabControls(state)
binding.layoutContentAllSurface.visibility = View.VISIBLE binding.layoutContentAllSurface.visibility = View.VISIBLE
hideAllTabEmptyError() hideAllTabEmptyError()
@@ -422,8 +536,14 @@ class ContentMainFragment : BaseFragment<FragmentV2MainContentBinding>(
contentAllSeriesCardAdapter.submitItems(emptyList()) contentAllSeriesCardAdapter.submitItems(emptyList())
contentAllAudioCardAdapter.submitItems(state.audioItems) contentAllAudioCardAdapter.submitItems(state.audioItems)
} }
state.paginationErrorMessage?.let { message -> val paginationErrorMessage = state.paginationErrorMessage
paginationErrorMessage?.let { message ->
showToast(message) showToast(message)
if (isContentPullRefreshing && refreshingContentTab == CONTENT_TAB_ALL) {
cancelContentPullRefresh()
contentAllTabViewModel.consumePaginationErrorMessage()
return
}
contentAllTabViewModel.consumePaginationErrorMessage() contentAllTabViewModel.consumePaginationErrorMessage()
} }
} }
@@ -448,14 +568,21 @@ class ContentMainFragment : BaseFragment<FragmentV2MainContentBinding>(
} }
private fun bindAllTabEmpty(state: MainContentAllTabUiState.Empty) { private fun bindAllTabEmpty(state: MainContentAllTabUiState.Empty) {
hasRenderedAllTabContent = false
bindAllTabControls(state) bindAllTabControls(state)
binding.layoutContentAllSurface.visibility = View.VISIBLE binding.layoutContentAllSurface.visibility = View.VISIBLE
binding.layoutContentAllEmptyError.visibility = View.VISIBLE binding.layoutContentAllEmptyError.visibility = View.VISIBLE
binding.tvContentAllEmptyError.setText(R.string.screen_content_all_empty) binding.tvContentAllEmptyError.setText(R.string.screen_content_all_empty)
clearAllTabItems() clearAllTabItems()
finishContentPullRefresh(CONTENT_TAB_ALL)
} }
private fun bindAllTabError(state: MainContentAllTabUiState.Error) { private fun bindAllTabError(state: MainContentAllTabUiState.Error) {
if (isContentPullRefreshing && refreshingContentTab == CONTENT_TAB_ALL && hasRenderedAllTabContent) {
cancelContentPullRefresh()
return
}
hasRenderedAllTabContent = false
bindAllTabControls(state) bindAllTabControls(state)
binding.layoutContentAllSurface.visibility = View.VISIBLE binding.layoutContentAllSurface.visibility = View.VISIBLE
binding.layoutContentAllEmptyError.visibility = View.VISIBLE binding.layoutContentAllEmptyError.visibility = View.VISIBLE
@@ -467,6 +594,7 @@ class ContentMainFragment : BaseFragment<FragmentV2MainContentBinding>(
bindAllTabControls(state) bindAllTabControls(state)
binding.layoutContentAllSurface.visibility = View.VISIBLE binding.layoutContentAllSurface.visibility = View.VISIBLE
hideAllTabEmptyError() hideAllTabEmptyError()
if (isContentPullRefreshing && refreshingContentTab == CONTENT_TAB_ALL && hasRenderedAllTabContent) return
clearAllTabItems() clearAllTabItems()
} }

View File

@@ -29,7 +29,13 @@ class ContentMainViewModel(
val isLoading: LiveData<Boolean> val isLoading: LiveData<Boolean>
get() = _isLoading get() = _isLoading
private var requestGeneration: Int = 0
fun loadRecommendations() { fun loadRecommendations() {
if (_isLoading.value == true) return
val previousState = _recommendationsStateLiveData.value
val generation = ++requestGeneration
_isLoading.value = true _isLoading.value = true
_recommendationsStateLiveData.value = AudioRecommendationsUiState.Loading _recommendationsStateLiveData.value = AudioRecommendationsUiState.Loading
@@ -39,6 +45,8 @@ class ContentMainViewModel(
.observeOn(AndroidSchedulers.mainThread()) .observeOn(AndroidSchedulers.mainThread())
.subscribe( .subscribe(
{ {
if (generation != requestGeneration) return@subscribe
_isLoading.value = false _isLoading.value = false
val data = it.data val data = it.data
if (it.success && data != null) { if (it.success && data != null) {
@@ -49,20 +57,22 @@ class ContentMainViewModel(
content content
} }
} else { } else {
showUnknownError(it.message) showUnknownError(it.message, previousState)
} }
}, },
{ {
if (generation != requestGeneration) return@subscribe
_isLoading.value = false _isLoading.value = false
it.message?.let { message -> Logger.e(message) } it.message?.let { message -> Logger.e(message) }
showUnknownError(it.message) showUnknownError(it.message, previousState)
} }
) )
) )
} }
private fun showUnknownError(message: String?) { private fun showUnknownError(message: String?, fallbackState: AudioRecommendationsUiState? = null) {
_recommendationsStateLiveData.value = AudioRecommendationsUiState.Error(message = message) _recommendationsStateLiveData.value = fallbackState ?: AudioRecommendationsUiState.Error(message = message)
_toastLiveData.postValue(ToastMessage(resId = R.string.common_error_unknown)) _toastLiveData.postValue(ToastMessage(resId = R.string.common_error_unknown))
} }

View File

@@ -19,6 +19,7 @@ class ContentRankingViewModel(
) : BaseViewModel() { ) : BaseViewModel() {
private val cachedStates = mutableMapOf<AudioRankingType, AudioRankingsUiState>() private val cachedStates = mutableMapOf<AudioRankingType, AudioRankingsUiState>()
private var activeFirstPageKey: AudioRankingType? = null
private var latestRequestId = 0L private var latestRequestId = 0L
private val _rankingStateLiveData = MutableLiveData<AudioRankingsUiState>() private val _rankingStateLiveData = MutableLiveData<AudioRankingsUiState>()
@@ -39,13 +40,18 @@ class ContentRankingViewModel(
fun loadRankings(type: AudioRankingType, force: Boolean = false) { fun loadRankings(type: AudioRankingType, force: Boolean = false) {
_selectedTypeLiveData.value = type _selectedTypeLiveData.value = type
val requestId = ++latestRequestId
val cachedState = cachedStates[type] val cachedState = cachedStates[type]
if (!force && cachedState != null) { if (!force && cachedState != null) {
latestRequestId += 1L
activeFirstPageKey = null
_isLoading.value = false _isLoading.value = false
_rankingStateLiveData.value = cachedState _rankingStateLiveData.value = cachedState
return return
} }
val requestKey = type
if (activeFirstPageKey == requestKey) return
activeFirstPageKey = requestKey
val requestId = ++latestRequestId
_isLoading.value = true _isLoading.value = true
_rankingStateLiveData.value = AudioRankingsUiState.Loading _rankingStateLiveData.value = AudioRankingsUiState.Loading
@@ -57,6 +63,7 @@ class ContentRankingViewModel(
.subscribe( .subscribe(
{ {
if (!isCurrentRequest(requestId, type)) return@subscribe if (!isCurrentRequest(requestId, type)) return@subscribe
clearActiveFirstPageKey(requestKey, requestId)
_isLoading.value = false _isLoading.value = false
val data = it.data val data = it.data
if (it.success && data != null) { if (it.success && data != null) {
@@ -70,14 +77,15 @@ class ContentRankingViewModel(
cachedStates[type] = state cachedStates[type] = state
_rankingStateLiveData.value = state _rankingStateLiveData.value = state
} else { } else {
showUnknownError(type, it.message) showUnknownError(type, it.message, cachedState)
} }
}, },
{ {
if (!isCurrentRequest(requestId, type)) return@subscribe if (!isCurrentRequest(requestId, type)) return@subscribe
clearActiveFirstPageKey(requestKey, requestId)
_isLoading.value = false _isLoading.value = false
it.message?.let { message -> Logger.e(message) } 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 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 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) _toastLiveData.value = ToastMessage(resId = R.string.common_error_unknown)
} }

View File

@@ -29,7 +29,13 @@ class HomeCreatorRankingViewModel(
val isLoading: LiveData<Boolean> val isLoading: LiveData<Boolean>
get() = _isLoading get() = _isLoading
private var requestGeneration: Int = 0
fun loadCreatorRankings() { fun loadCreatorRankings() {
if (_isLoading.value == true) return
val previousState = _rankingStateLiveData.value
val generation = ++requestGeneration
_isLoading.value = true _isLoading.value = true
_rankingStateLiveData.value = HomeCreatorRankingUiState.Loading _rankingStateLiveData.value = HomeCreatorRankingUiState.Loading
@@ -39,6 +45,8 @@ class HomeCreatorRankingViewModel(
.observeOn(AndroidSchedulers.mainThread()) .observeOn(AndroidSchedulers.mainThread())
.subscribe( .subscribe(
{ {
if (generation != requestGeneration) return@subscribe
_isLoading.value = false _isLoading.value = false
val data = it.data val data = it.data
if (it.success && data != null) { if (it.success && data != null) {
@@ -49,20 +57,22 @@ class HomeCreatorRankingViewModel(
HomeCreatorRankingUiState.Content(items = items) HomeCreatorRankingUiState.Content(items = items)
} }
} else { } else {
showUnknownError(it.message) showUnknownError(it.message, previousState)
} }
}, },
{ {
if (generation != requestGeneration) return@subscribe
_isLoading.value = false _isLoading.value = false
it.message?.let { message -> Logger.e(message) } it.message?.let { message -> Logger.e(message) }
showUnknownError(it.message) showUnknownError(it.message, previousState)
} }
) )
) )
} }
private fun showUnknownError(message: String?) { private fun showUnknownError(message: String?, fallbackState: HomeCreatorRankingUiState? = null) {
_rankingStateLiveData.value = HomeCreatorRankingUiState.Error(message = message) _rankingStateLiveData.value = fallbackState ?: HomeCreatorRankingUiState.Error(message = message)
_toastLiveData.postValue(ToastMessage(resId = R.string.common_error_unknown)) _toastLiveData.postValue(ToastMessage(resId = R.string.common_error_unknown))
} }

View File

@@ -33,7 +33,13 @@ class HomeFollowingViewModel(
val isLoading: LiveData<Boolean> val isLoading: LiveData<Boolean>
get() = _isLoading get() = _isLoading
private var requestGeneration: Int = 0
fun loadFollowing() { fun loadFollowing() {
if (_isLoading.value == true) return
val previousState = _followingStateLiveData.value
val generation = ++requestGeneration
_isLoading.value = true _isLoading.value = true
_followingStateLiveData.value = HomeFollowingUiState.Loading _followingStateLiveData.value = HomeFollowingUiState.Loading
@@ -43,25 +49,29 @@ class HomeFollowingViewModel(
.observeOn(AndroidSchedulers.mainThread()) .observeOn(AndroidSchedulers.mainThread())
.subscribe( .subscribe(
{ {
if (generation != requestGeneration) return@subscribe
_isLoading.value = false _isLoading.value = false
val data = it.data val data = it.data
if (it.success && data != null) { if (it.success && data != null) {
_followingStateLiveData.value = data.toUiState(relativeTimeTextFormatter) _followingStateLiveData.value = data.toUiState(relativeTimeTextFormatter)
} else { } else {
showUnknownError(it.message) showUnknownError(it.message, previousState)
} }
}, },
{ {
if (generation != requestGeneration) return@subscribe
_isLoading.value = false _isLoading.value = false
it.message?.let { message -> Logger.e(message) } it.message?.let { message -> Logger.e(message) }
showUnknownError(it.message) showUnknownError(it.message, previousState)
} }
) )
) )
} }
private fun showUnknownError(message: String?) { private fun showUnknownError(message: String?, fallbackState: HomeFollowingUiState? = null) {
_followingStateLiveData.value = HomeFollowingUiState.Error(message = message) _followingStateLiveData.value = fallbackState ?: HomeFollowingUiState.Error(message = message)
_toastLiveData.value = ToastMessage(resId = R.string.common_error_unknown) _toastLiveData.value = ToastMessage(resId = R.string.common_error_unknown)
} }

View File

@@ -125,9 +125,15 @@ class HomeMainFragment : BaseFragment<FragmentV2MainHomeBinding>(
private var hasLoadedFollowing = false private var hasLoadedFollowing = false
private var currentHomeTabIndex = HOME_TAB_RECOMMENDATION private var currentHomeTabIndex = HOME_TAB_RECOMMENDATION
private var isRecommendationLoading = false private var isRecommendationLoading = false
private var isRecommendationFollowLoading = false
private var isCreatorRankingLoading = false private var isCreatorRankingLoading = false
private var isFollowingLoading = false private var isFollowingLoading = false
private var isLiveEntryLoading = 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?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
@@ -150,6 +156,7 @@ class HomeMainFragment : BaseFragment<FragmentV2MainHomeBinding>(
setUpRecommendationAdapters() setUpRecommendationAdapters()
setUpCreatorRankingAdapter() setUpCreatorRankingAdapter()
setUpFollowingAdapters() setUpFollowingAdapters()
setUpRefreshLayouts()
setUpBusinessInfo() setUpBusinessInfo()
setupTitleBarActions() setupTitleBarActions()
bindHomeRecommendationObservers() bindHomeRecommendationObservers()
@@ -260,12 +267,78 @@ class HomeMainFragment : BaseFragment<FragmentV2MainHomeBinding>(
} }
} }
private fun setUpRefreshLayouts() {
binding.swipeHomeRecommendation.setOnRefreshListener { refreshCurrentHomeTab() }
binding.swipeHomeRanking.setOnRefreshListener { refreshCurrentHomeTab() }
binding.swipeHomeFollowing.setOnRefreshListener { refreshCurrentHomeTab() }
}
private fun refreshCurrentHomeTab() {
isHomePullRefreshing = true
refreshingHomeTabIndex = currentHomeTabIndex
when (currentHomeTabIndex) {
HOME_TAB_RECOMMENDATION -> homeRecommendationViewModel.loadRecommendations()
HOME_TAB_RANKING -> homeCreatorRankingViewModel.loadCreatorRankings()
HOME_TAB_FOLLOWING -> homeFollowingViewModel.loadFollowing()
}
}
private fun finishHomePullRefresh(tabIndex: Int, isRestoredPullRefreshError: Boolean = false) {
if (!isHomePullRefreshing) return
if (refreshingHomeTabIndex != tabIndex) return
if (!isRestoredPullRefreshError) {
scrollHomeTabToTop(tabIndex)
}
when (tabIndex) {
HOME_TAB_RECOMMENDATION -> binding.swipeHomeRecommendation.isRefreshing = false
HOME_TAB_RANKING -> binding.swipeHomeRanking.isRefreshing = false
HOME_TAB_FOLLOWING -> binding.swipeHomeFollowing.isRefreshing = false
}
isHomePullRefreshing = false
refreshingHomeTabIndex = null
}
private fun shouldPreserveHomeContentOnPullRefreshError(tabIndex: Int): Boolean {
if (!isHomePullRefreshing) return false
if (refreshingHomeTabIndex != tabIndex) return false
when (tabIndex) {
HOME_TAB_RECOMMENDATION -> binding.swipeHomeRecommendation.isRefreshing = false
HOME_TAB_RANKING -> binding.swipeHomeRanking.isRefreshing = false
HOME_TAB_FOLLOWING -> binding.swipeHomeFollowing.isRefreshing = false
}
isHomePullRefreshing = false
refreshingHomeTabIndex = null
return true
}
private fun scrollHomeTabToTop(tabIndex: Int) {
when (tabIndex) {
HOME_TAB_RECOMMENDATION -> binding.nsvHomeRecommendationContent.scrollTo(0, 0)
HOME_TAB_RANKING -> binding.rvHomeCreatorRankings.scrollToPosition(0)
HOME_TAB_FOLLOWING -> binding.nsvHomeFollowingContent.scrollTo(0, 0)
}
}
private fun bindHomeCreatorRankingObservers() { private fun bindHomeCreatorRankingObservers() {
homeCreatorRankingViewModel.rankingStateLiveData.observe(viewLifecycleOwner) { state -> homeCreatorRankingViewModel.rankingStateLiveData.observe(viewLifecycleOwner) { state ->
when (state) { when (state) {
is HomeCreatorRankingUiState.Content -> creatorRankingAdapter.submitItems(state.items) is HomeCreatorRankingUiState.Content -> {
HomeCreatorRankingUiState.Empty, val isRestoredPullRefreshError = isHomePullRefreshing &&
is HomeCreatorRankingUiState.Error -> creatorRankingAdapter.submitItems(emptyList()) 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 HomeCreatorRankingUiState.Loading -> Unit
} }
@@ -293,15 +366,15 @@ class HomeMainFragment : BaseFragment<FragmentV2MainHomeBinding>(
currentHomeTabIndex = index currentHomeTabIndex = index
when (index) { when (index) {
HOME_TAB_RECOMMENDATION -> { HOME_TAB_RECOMMENDATION -> {
binding.nsvHomeRecommendationContent.visibility = View.VISIBLE binding.swipeHomeRecommendation.visibility = View.VISIBLE
binding.rvHomeCreatorRankings.visibility = View.GONE binding.swipeHomeRanking.visibility = View.GONE
binding.nsvHomeFollowingContent.visibility = View.GONE binding.swipeHomeFollowing.visibility = View.GONE
} }
HOME_TAB_RANKING -> { HOME_TAB_RANKING -> {
binding.nsvHomeRecommendationContent.visibility = View.GONE binding.swipeHomeRecommendation.visibility = View.GONE
binding.rvHomeCreatorRankings.visibility = View.VISIBLE binding.swipeHomeRanking.visibility = View.VISIBLE
binding.nsvHomeFollowingContent.visibility = View.GONE binding.swipeHomeFollowing.visibility = View.GONE
if (!hasLoadedCreatorRankings) { if (!hasLoadedCreatorRankings) {
hasLoadedCreatorRankings = true hasLoadedCreatorRankings = true
homeCreatorRankingViewModel.loadCreatorRankings() homeCreatorRankingViewModel.loadCreatorRankings()
@@ -309,9 +382,9 @@ class HomeMainFragment : BaseFragment<FragmentV2MainHomeBinding>(
} }
HOME_TAB_FOLLOWING -> { HOME_TAB_FOLLOWING -> {
binding.nsvHomeFollowingContent.visibility = View.VISIBLE binding.swipeHomeFollowing.visibility = View.VISIBLE
binding.nsvHomeRecommendationContent.visibility = View.GONE binding.swipeHomeRecommendation.visibility = View.GONE
binding.rvHomeCreatorRankings.visibility = View.GONE binding.swipeHomeRanking.visibility = View.GONE
if (!hasLoadedFollowing) { if (!hasLoadedFollowing) {
hasLoadedFollowing = true hasLoadedFollowing = true
homeFollowingViewModel.loadFollowing() homeFollowingViewModel.loadFollowing()
@@ -323,10 +396,24 @@ class HomeMainFragment : BaseFragment<FragmentV2MainHomeBinding>(
private fun bindHomeFollowingObservers() { private fun bindHomeFollowingObservers() {
homeFollowingViewModel.followingStateLiveData.observe(viewLifecycleOwner) { state -> homeFollowingViewModel.followingStateLiveData.observe(viewLifecycleOwner) { state ->
when (state) { when (state) {
is HomeFollowingUiState.Content -> bindHomeFollowingContent(state) is HomeFollowingUiState.Content -> {
HomeFollowingUiState.Empty -> bindHomeFollowingEmpty(showEmptyMessage = true) val isRestoredPullRefreshError = isHomePullRefreshing &&
HomeFollowingUiState.LoginRequired, refreshingHomeTabIndex == HOME_TAB_FOLLOWING &&
is HomeFollowingUiState.Error -> bindHomeFollowingEmpty(showEmptyMessage = false) 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 HomeFollowingUiState.Loading -> Unit
} }
@@ -343,9 +430,23 @@ class HomeMainFragment : BaseFragment<FragmentV2MainHomeBinding>(
private fun bindHomeRecommendationObservers() { private fun bindHomeRecommendationObservers() {
homeRecommendationViewModel.recommendationStateLiveData.observe(viewLifecycleOwner) { state -> homeRecommendationViewModel.recommendationStateLiveData.observe(viewLifecycleOwner) { state ->
when (state) { when (state) {
is HomeRecommendationUiState.Content -> bindHomeRecommendationContent(state) is HomeRecommendationUiState.Content -> {
HomeRecommendationUiState.Empty, val isRestoredPullRefreshError = isHomePullRefreshing &&
is HomeRecommendationUiState.Error -> bindHomeRecommendationContent(emptyHomeRecommendationContent()) 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 HomeRecommendationUiState.Loading -> Unit
} }
@@ -354,6 +455,10 @@ class HomeMainFragment : BaseFragment<FragmentV2MainHomeBinding>(
isRecommendationLoading = isLoading isRecommendationLoading = isLoading
updateLoadingDialog() updateLoadingDialog()
} }
homeRecommendationViewModel.isFollowLoading.observe(viewLifecycleOwner) { isLoading ->
isRecommendationFollowLoading = isLoading
updateLoadingDialog()
}
homeRecommendationViewModel.toastLiveData.observe(viewLifecycleOwner) { toastMessage -> homeRecommendationViewModel.toastLiveData.observe(viewLifecycleOwner) { toastMessage ->
toastMessage?.let(::showToast) toastMessage?.let(::showToast)
} }
@@ -370,7 +475,13 @@ class HomeMainFragment : BaseFragment<FragmentV2MainHomeBinding>(
} }
private fun updateLoadingDialog() { private fun updateLoadingDialog() {
if (isRecommendationLoading || isCreatorRankingLoading || isFollowingLoading || isLiveEntryLoading) { if (
isRecommendationLoading ||
isRecommendationFollowLoading ||
isCreatorRankingLoading ||
isFollowingLoading ||
isLiveEntryLoading
) {
loadingDialog.show(screenWidth) loadingDialog.show(screenWidth)
} else { } else {
loadingDialog.dismiss() loadingDialog.dismiss()

View File

@@ -30,7 +30,18 @@ class HomeRecommendationViewModel(
val isLoading: LiveData<Boolean> val isLoading: LiveData<Boolean>
get() = _isLoading get() = _isLoading
private val _isFollowLoading = MutableLiveData(false)
val isFollowLoading: LiveData<Boolean>
get() = _isFollowLoading
private var requestGeneration: Int = 0
private var isCheerCreatorsFollowCompleted = false
fun loadRecommendations() { fun loadRecommendations() {
if (_isLoading.value == true) return
val previousState = _recommendationStateLiveData.value
val generation = ++requestGeneration
_isLoading.value = true _isLoading.value = true
_recommendationStateLiveData.value = HomeRecommendationUiState.Loading _recommendationStateLiveData.value = HomeRecommendationUiState.Loading
@@ -40,23 +51,27 @@ class HomeRecommendationViewModel(
.observeOn(AndroidSchedulers.mainThread()) .observeOn(AndroidSchedulers.mainThread())
.subscribe( .subscribe(
{ {
if (generation != requestGeneration) return@subscribe
_isLoading.value = false _isLoading.value = false
val data = it.data val data = it.data
if (it.success && data != null) { if (it.success && data != null) {
val content = data.toContent() val content = data.toContent().withFollowCompleted()
_recommendationStateLiveData.value = if (content.isEmpty) { _recommendationStateLiveData.value = if (content.isEmpty) {
HomeRecommendationUiState.Empty HomeRecommendationUiState.Empty
} else { } else {
content content
} }
} else { } else {
showUnknownError(it.message) showUnknownError(it.message, previousState)
} }
}, },
{ {
if (generation != requestGeneration) return@subscribe
_isLoading.value = false _isLoading.value = false
it.message?.let { message -> Logger.e(message) } it.message?.let { message -> Logger.e(message) }
showUnknownError(it.message) showUnknownError(it.message, previousState)
} }
) )
) )
@@ -65,7 +80,7 @@ class HomeRecommendationViewModel(
fun followCreators(sectionKey: String, creatorIds: List<Long>) { fun followCreators(sectionKey: String, creatorIds: List<Long>) {
if (creatorIds.isEmpty()) return if (creatorIds.isEmpty()) return
_isLoading.value = true _isFollowLoading.value = true
compositeDisposable.add( compositeDisposable.add(
repository.followRecommendedCreators( repository.followRecommendedCreators(
request = FollowRecommendedCreatorsRequest(creatorIds = creatorIds), request = FollowRecommendedCreatorsRequest(creatorIds = creatorIds),
@@ -75,35 +90,50 @@ class HomeRecommendationViewModel(
.observeOn(AndroidSchedulers.mainThread()) .observeOn(AndroidSchedulers.mainThread())
.subscribe( .subscribe(
{ {
_isLoading.value = false _isFollowLoading.value = false
if (it.success) { if (it.success) {
updateFollowCompleted(sectionKey) updateFollowCompleted(sectionKey)
} else { } else {
showUnknownError(it.message) showFollowError()
} }
}, },
{ {
_isLoading.value = false _isFollowLoading.value = false
it.message?.let { message -> Logger.e(message) } it.message?.let { message -> Logger.e(message) }
showUnknownError(it.message) showFollowError()
} }
) )
) )
} }
private fun updateFollowCompleted(sectionKey: String) { private fun updateFollowCompleted(sectionKey: String) {
val content = _recommendationStateLiveData.value as? HomeRecommendationUiState.Content ?: return
if (sectionKey != SECTION_KEY_CHEER_CREATORS) return if (sectionKey != SECTION_KEY_CHEER_CREATORS) return
isCheerCreatorsFollowCompleted = true
val content = _recommendationStateLiveData.value as? HomeRecommendationUiState.Content ?: return
_recommendationStateLiveData.value = content.copy( _recommendationStateLiveData.value = content.copy(
cheerCreators = content.cheerCreators.copy(isFollowCompleted = true) cheerCreators = content.cheerCreators.copy(isFollowCompleted = true)
) )
} }
private fun showUnknownError(message: String?) { private fun HomeRecommendationUiState.Content.withFollowCompleted(): HomeRecommendationUiState.Content {
_recommendationStateLiveData.value = HomeRecommendationUiState.Error(message = message) if (!isCheerCreatorsFollowCompleted) return this
return copy(cheerCreators = cheerCreators.copy(isFollowCompleted = true))
}
private fun showFollowError() {
_toastLiveData.postValue(ToastMessage(resId = R.string.common_error_unknown)) _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 fun authToken(): String = "Bearer ${SharedPreferenceManager.token}"
private companion object { private companion object {

View File

@@ -24,30 +24,38 @@
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/view_chat_title_bar" /> app:layout_constraintTop_toBottomOf="@id/view_chat_title_bar" />
<androidx.recyclerview.widget.RecyclerView <androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/rv_chat_rooms" android:id="@+id/swipe_chat_rooms"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="0dp" android:layout_height="0dp"
android:clipToPadding="false"
android:paddingBottom="@dimen/spacing_48"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/view_chat_filter_tabs" app:layout_constraintTop_toBottomOf="@id/view_chat_filter_tabs">
<FrameLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rv_chat_rooms"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
android:paddingBottom="@dimen/spacing_48"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
tools:listitem="@layout/item_v2_chat_room" /> tools:listitem="@layout/item_v2_chat_room" />
<TextView <TextView
android:id="@+id/tv_chat_empty_message" android:id="@+id/tv_chat_empty_message"
android:layout_width="0dp" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_gravity="center"
android:gravity="center" android:gravity="center"
android:textColor="@color/color_777777" android:textColor="@color/color_777777"
android:textSize="14sp" android:textSize="14sp"
android:visibility="gone" android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/view_chat_filter_tabs"
tools:text="아직 나눈 대화가 없어요.\n크리에이터와 이야기를 시작해 보세요." /> tools:text="아직 나눈 대화가 없어요.\n크리에이터와 이야기를 시작해 보세요." />
</FrameLayout>
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
</androidx.constraintlayout.widget.ConstraintLayout> </androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -99,30 +99,35 @@
</LinearLayout> </LinearLayout>
</LinearLayout> </LinearLayout>
<androidx.recyclerview.widget.RecyclerView <androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/rv_content_all_items" android:id="@+id/swipe_content_all"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="0dp" android:layout_height="0dp"
android:clipToPadding="false"
android:paddingHorizontal="@dimen/spacing_14"
android:paddingTop="@dimen/spacing_14"
android:paddingBottom="@dimen/spacing_28"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/layout_content_all_sort_bar" />
<FrameLayout
android:id="@+id/layout_content_all_empty_error"
android:layout_width="0dp"
android:layout_height="0dp"
android:paddingHorizontal="@dimen/spacing_28"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/layout_content_all_sort_bar"> app:layout_constraintTop_toBottomOf="@id/layout_content_all_sort_bar">
<FrameLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rv_content_all_items"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
android:paddingHorizontal="@dimen/spacing_14"
android:paddingTop="@dimen/spacing_14"
android:paddingBottom="@dimen/spacing_28" />
<FrameLayout
android:id="@+id/layout_content_all_empty_error"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:paddingHorizontal="@dimen/spacing_28"
android:visibility="gone">
<TextView <TextView
android:id="@+id/tv_content_all_empty_error" android:id="@+id/tv_content_all_empty_error"
android:layout_width="wrap_content" android:layout_width="wrap_content"
@@ -134,6 +139,8 @@
android:textColor="@color/color_777777" android:textColor="@color/color_777777"
android:textSize="14sp" /> android:textSize="14sp" />
</FrameLayout> </FrameLayout>
</FrameLayout>
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
</androidx.constraintlayout.widget.ConstraintLayout> </androidx.constraintlayout.widget.ConstraintLayout>
<include <include
@@ -146,30 +153,41 @@
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/text_tab_bar_content" /> app:layout_constraintTop_toBottomOf="@id/text_tab_bar_content" />
<androidx.recyclerview.widget.RecyclerView <androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/rv_content_rankings" android:id="@+id/swipe_content_ranking"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="0dp" android:layout_height="0dp"
android:clipToPadding="false"
android:paddingHorizontal="@dimen/spacing_14"
android:paddingTop="@dimen/spacing_14"
android:paddingBottom="@dimen/spacing_28"
android:visibility="gone" android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/view_content_ranking_type_tabs" /> app:layout_constraintTop_toBottomOf="@id/view_content_ranking_type_tabs">
<androidx.core.widget.NestedScrollView <androidx.recyclerview.widget.RecyclerView
android:id="@+id/nsv_content_recommendation_content" android:id="@+id/rv_content_rankings"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
android:paddingHorizontal="@dimen/spacing_14"
android:paddingTop="@dimen/spacing_14"
android:paddingBottom="@dimen/spacing_28" />
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/swipe_content_recommendation"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="0dp" android:layout_height="0dp"
android:fillViewport="true"
app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/text_tab_bar_content"> app:layout_constraintTop_toBottomOf="@id/text_tab_bar_content">
<androidx.core.widget.NestedScrollView
android:id="@+id/nsv_content_recommendation_content"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fillViewport="true">
<LinearLayout <LinearLayout
android:id="@+id/ll_content_recommendation_content" android:id="@+id/ll_content_recommendation_content"
android:layout_width="match_parent" android:layout_width="match_parent"
@@ -343,4 +361,5 @@
</LinearLayout> </LinearLayout>
</LinearLayout> </LinearLayout>
</androidx.core.widget.NestedScrollView> </androidx.core.widget.NestedScrollView>
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
</androidx.constraintlayout.widget.ConstraintLayout> </androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -24,16 +24,21 @@
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/view_home_title_bar" /> app:layout_constraintTop_toBottomOf="@id/view_home_title_bar" />
<androidx.core.widget.NestedScrollView <androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/nsv_home_recommendation_content" android:id="@+id/swipe_home_recommendation"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="0dp" android:layout_height="0dp"
android:fillViewport="true"
app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/text_tab_bar_home"> app:layout_constraintTop_toBottomOf="@id/text_tab_bar_home">
<androidx.core.widget.NestedScrollView
android:id="@+id/nsv_home_recommendation_content"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fillViewport="true">
<LinearLayout <LinearLayout
android:id="@+id/ll_home_recommendation_content" android:id="@+id/ll_home_recommendation_content"
android:layout_width="match_parent" android:layout_width="match_parent"
@@ -217,18 +222,24 @@
</LinearLayout> </LinearLayout>
</LinearLayout> </LinearLayout>
</androidx.core.widget.NestedScrollView> </androidx.core.widget.NestedScrollView>
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
<androidx.core.widget.NestedScrollView <androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/nsv_home_following_content" android:id="@+id/swipe_home_following"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="0dp" android:layout_height="0dp"
android:fillViewport="true"
android:visibility="gone" android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/text_tab_bar_home"> app:layout_constraintTop_toBottomOf="@id/text_tab_bar_home">
<androidx.core.widget.NestedScrollView
android:id="@+id/nsv_home_following_content"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fillViewport="true">
<LinearLayout <LinearLayout
android:id="@+id/ll_home_following_content" android:id="@+id/ll_home_following_content"
android:layout_width="match_parent" android:layout_width="match_parent"
@@ -358,19 +369,26 @@
</LinearLayout> </LinearLayout>
</LinearLayout> </LinearLayout>
</androidx.core.widget.NestedScrollView> </androidx.core.widget.NestedScrollView>
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
<androidx.recyclerview.widget.RecyclerView <androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/rv_home_creator_rankings" android:id="@+id/swipe_home_ranking"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="0dp" android:layout_height="0dp"
android:clipToPadding="false"
android:paddingHorizontal="@dimen/spacing_14"
android:paddingTop="@dimen/spacing_14"
android:paddingBottom="@dimen/spacing_28"
android:visibility="gone" android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/text_tab_bar_home" app:layout_constraintTop_toBottomOf="@id/text_tab_bar_home">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rv_home_creator_rankings"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
android:paddingHorizontal="@dimen/spacing_14"
android:paddingTop="@dimen/spacing_14"
android:paddingBottom="@dimen/spacing_28"
tools:listitem="@layout/view_creator_ranking_lower_row" /> tools:listitem="@layout/view_creator_ranking_lower_row" />
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
</androidx.constraintlayout.widget.ConstraintLayout> </androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -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")
}
}

View File

@@ -11,7 +11,6 @@ import android.widget.ImageView
import android.widget.LinearLayout import android.widget.LinearLayout
import android.widget.TextView import android.widget.TextView
import androidx.constraintlayout.widget.ConstraintLayout import androidx.constraintlayout.widget.ConstraintLayout
import androidx.recyclerview.widget.RecyclerView
import androidx.test.core.app.ApplicationProvider import androidx.test.core.app.ApplicationProvider
import kr.co.vividnext.sodalive.R import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.v2.widget.CapsuleTabBarView 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 root = inflateView(R.layout.fragment_v2_main_chat) as ConstraintLayout
val titleBar = requireNotNull(root.findViewById<View>(R.id.view_chat_title_bar)) val titleBar = requireNotNull(root.findViewById<View>(R.id.view_chat_title_bar))
val tabBar = requireNotNull(root.findViewById<CapsuleTabBarView>(R.id.view_chat_filter_tabs)) val tabBar = requireNotNull(root.findViewById<CapsuleTabBarView>(R.id.view_chat_filter_tabs))
val recyclerView = requireNotNull(root.findViewById<RecyclerView>(R.id.rv_chat_rooms))
val emptyMessage = requireNotNull(root.findViewById<TextView>(R.id.tv_chat_empty_message)) val emptyMessage = requireNotNull(root.findViewById<TextView>(R.id.tv_chat_empty_message))
assertEquals(Color.BLACK, (root.background as ColorDrawable).color) assertEquals(Color.BLACK, (root.background as ColorDrawable).color)
assertSame(root, titleBar.parent) assertSame(root, titleBar.parent)
assertSame(root, tabBar.parent) assertSame(root, tabBar.parent)
assertSame(root, recyclerView.parent)
assertSame(root, emptyMessage.parent)
assertEquals(View.GONE, emptyMessage.visibility) assertEquals(View.GONE, emptyMessage.visibility)
assertFalse(root.containsClassName("com.google.android.material.bottomnavigation.BottomNavigationView")) assertFalse(root.containsClassName("com.google.android.material.bottomnavigation.BottomNavigationView"))
assertFalse(root.containsViewIdContaining("unread")) assertFalse(root.containsViewIdContaining("unread"))
} }
@Test
fun `채팅 fragment list는 계획된 constraint를 사용한다`() {
val root = inflateView(R.layout.fragment_v2_main_chat)
val titleBar = requireNotNull(root.findViewById<View>(R.id.view_chat_title_bar))
val tabBar = requireNotNull(root.findViewById<CapsuleTabBarView>(R.id.view_chat_filter_tabs))
val recyclerView = requireNotNull(root.findViewById<RecyclerView>(R.id.rv_chat_rooms))
val emptyMessage = requireNotNull(root.findViewById<TextView>(R.id.tv_chat_empty_message))
val tabParams = tabBar.layoutParams as ConstraintLayout.LayoutParams
val listParams = recyclerView.layoutParams as ConstraintLayout.LayoutParams
val emptyParams = emptyMessage.layoutParams as ConstraintLayout.LayoutParams
assertEquals(60.dpToPx(), titleBar.layoutParams.height)
assertEquals(52.dpToPx(), tabBar.layoutParams.height)
assertEquals(R.id.view_chat_title_bar, tabParams.topToBottom)
assertEquals(R.id.view_chat_filter_tabs, listParams.topToBottom)
assertEquals(ConstraintLayout.LayoutParams.PARENT_ID, listParams.bottomToBottom)
assertEquals(R.id.view_chat_filter_tabs, emptyParams.topToBottom)
assertEquals(ConstraintLayout.LayoutParams.PARENT_ID, emptyParams.bottomToBottom)
assertEquals(false, recyclerView.clipToPadding)
assertTrue(recyclerView.paddingBottom >= 28.dpToPx())
}
@Test @Test
fun `채팅 fragment source는 화면 초기 구성과 첫 페이지 로드를 연결한다`() { fun `채팅 fragment source는 화면 초기 구성과 첫 페이지 로드를 연결한다`() {
val source = chatMainFragmentSource() val source = chatMainFragmentSource()
@@ -125,6 +99,26 @@ class ChatMainFragmentLayoutTest {
assertTrue(source.contains("viewModel.toastLiveData.observe(viewLifecycleOwner)")) 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 @Test
fun `채팅 fragment source는 AI와 DM 항목을 각각 채팅방으로 이동한다`() { fun `채팅 fragment source는 AI와 DM 항목을 각각 채팅방으로 이동한다`() {
val source = chatMainFragmentSource() val source = chatMainFragmentSource()
@@ -144,21 +138,61 @@ class ChatMainFragmentLayoutTest {
val source = chatMainFragmentSource() val source = chatMainFragmentSource()
assertTrue(source.contains("setOnTabSelectedListener")) 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")) 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 @Test
fun `채팅 fragment source는 탭별 empty state 문구를 표시한다`() { fun `채팅 fragment source는 탭별 empty state 문구를 표시한다`() {
val source = chatMainFragmentSource() val source = chatMainFragmentSource()
val filter = projectFile( val filter = projectFile(
"app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/model/ChatRoomFilter.kt" "app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/model/ChatRoomFilter.kt"
).readText() ).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 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("binding.tvChatEmptyMessage.setText(selectedFilter.emptyMessageResId())"))
assertTrue(source.contains("ChatRoomListUiState.Empty -> bindChatRooms(emptyList(), showEmpty = true)")) assertTrue(emptyBranch.contains("bindChatRooms(emptyList(), showEmpty = true)"))
assertTrue(source.contains("is ChatRoomListUiState.Error -> bindChatRooms(emptyList(), showEmpty = false)")) assertTrue(errorBranch.contains("if (!isChatPullRefreshing || !isRefreshForCurrentFilter)"))
assertTrue(errorBranch.contains("bindChatRooms(emptyList(), showEmpty = false)"))
assertTrue(source.contains("binding.rvChatRooms.isVisible = !showEmpty")) assertTrue(source.contains("binding.rvChatRooms.isVisible = !showEmpty"))
assertTrue(filter.contains("ChatRoomFilter.ALL -> R.string.screen_chat_empty_all")) assertTrue(filter.contains("ChatRoomFilter.ALL -> R.string.screen_chat_empty_all"))
assertTrue(filter.contains("ChatRoomFilter.AI -> R.string.screen_chat_empty_ai")) 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" "app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/ChatMainFragment.kt"
).readText() ).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 { private fun projectFile(relativePath: String): File {
val candidates = listOf( val candidates = listOf(
File(relativePath), File(relativePath),
@@ -197,9 +239,4 @@ class ChatMainFragmentLayoutTest {
if (this !is ViewGroup) return false if (this !is ViewGroup) return false
return (0 until childCount).any { getChildAt(it).containsClassName(className) } return (0 until childCount).any { getChildAt(it).containsClassName(className) }
} }
private fun Int.dpToPx(): Int {
val context = ApplicationProvider.getApplicationContext<Context>()
return (this * context.resources.displayMetrics.density).toInt()
}
} }

View File

@@ -89,6 +89,25 @@ class ChatMainViewModelTest {
assertEquals(1, api.calls.size) 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 @Test
fun `첫 페이지 success와 rooms가 있으면 Content를 emit한다`() { fun `첫 페이지 success와 rooms가 있으면 Content를 emit한다`() {
api.enqueueSuccess(page(rooms = listOf(room(roomId = 10L, targetName = "크리에이터")))) api.enqueueSuccess(page(rooms = listOf(room(roomId = 10L, targetName = "크리에이터"))))
@@ -128,6 +147,95 @@ class ChatMainViewModelTest {
assertFalse(state.isAppending) assertFalse(state.isAppending)
} }
@Test
fun `같은 filter 첫 페이지 refresh 실패 후 loadNextPage는 이전 cursor로 append한다`() {
api.enqueueSuccess(page(rooms = listOf(room(roomId = 1L)), hasMore = true, nextCursor = "next-1"))
viewModel.loadFirstPage(ChatRoomFilter.AI)
api.enqueueSuccess(ApiResponse(false, null, "refresh failed"))
viewModel.loadFirstPage(ChatRoomFilter.AI)
val restoredState = viewModel.chatRoomStateLiveData.requireValue() as ChatRoomListUiState.Content
api.enqueueSuccess(page(rooms = listOf(room(roomId = 2L)), hasMore = false, nextCursor = null))
viewModel.loadNextPage()
val appendedState = viewModel.chatRoomStateLiveData.requireValue() as ChatRoomListUiState.Content
assertEquals(listOf(1L), restoredState.items.map { it.roomId })
assertEquals(ApiCall("Bearer test-token", "AI", "next-1"), api.calls.last())
assertEquals(listOf(1L, 2L), appendedState.items.map { it.roomId })
}
@Test
fun `같은 filter 첫 페이지 요청이 진행 중이면 중복 호출을 무시한다`() {
val pending = SingleSubject.create<ApiResponse<ChatRoomListPageResponse>>()
api.enqueue(pending)
viewModel.loadFirstPage(ChatRoomFilter.ALL)
viewModel.loadFirstPage(ChatRoomFilter.ALL)
assertEquals(1, api.calls.size)
pending.onSuccess(ApiResponse(true, page(rooms = listOf(room(roomId = 1L))), null))
}
@Test
fun `다른 filter 첫 페이지 요청은 기존 요청 진행 중에도 허용한다`() {
val pendingAll = SingleSubject.create<ApiResponse<ChatRoomListPageResponse>>()
val pendingDm = SingleSubject.create<ApiResponse<ChatRoomListPageResponse>>()
api.enqueue(pendingAll)
api.enqueue(pendingDm)
viewModel.loadFirstPage(ChatRoomFilter.ALL)
viewModel.loadFirstPage(ChatRoomFilter.DM)
assertEquals(
listOf(
ApiCall("Bearer test-token", "ALL", null),
ApiCall("Bearer test-token", "DM", null)
),
api.calls
)
pendingAll.onSuccess(ApiResponse(true, page(rooms = listOf(room(roomId = 1L))), null))
pendingDm.onSuccess(ApiResponse(true, page(rooms = listOf(room(roomId = 2L))), null))
}
@Test
fun `filter를 A에서 B로 바꾼 뒤 A 응답 전 다시 A를 요청하면 최신 A 요청을 보낸다`() {
val pendingAllFirst = SingleSubject.create<ApiResponse<ChatRoomListPageResponse>>()
val pendingDm = SingleSubject.create<ApiResponse<ChatRoomListPageResponse>>()
val pendingAllLatest = SingleSubject.create<ApiResponse<ChatRoomListPageResponse>>()
api.enqueue(pendingAllFirst)
api.enqueue(pendingDm)
api.enqueue(pendingAllLatest)
viewModel.loadFirstPage(ChatRoomFilter.ALL)
viewModel.loadFirstPage(ChatRoomFilter.DM)
viewModel.loadFirstPage(ChatRoomFilter.ALL)
assertEquals(
listOf(
ApiCall("Bearer test-token", "ALL", null),
ApiCall("Bearer test-token", "DM", null),
ApiCall("Bearer test-token", "ALL", null)
),
api.calls
)
pendingAllFirst.onSuccess(ApiResponse(true, page(rooms = listOf(room(roomId = 1L))), null))
pendingDm.onSuccess(ApiResponse(true, page(rooms = listOf(room(roomId = 2L, chatType = "DM"))), null))
pendingAllLatest.onSuccess(
ApiResponse(true, page(rooms = listOf(room(roomId = 3L)), hasMore = true, nextCursor = "all-next"), null)
)
val state = viewModel.chatRoomStateLiveData.requireValue() as ChatRoomListUiState.Content
assertEquals(listOf(3L), state.items.map { it.roomId })
api.enqueueSuccess(page(rooms = listOf(room(roomId = 4L)), hasMore = false, nextCursor = null))
viewModel.loadNextPage()
assertEquals(ApiCall("Bearer test-token", "ALL", "all-next"), api.calls.last())
}
@Test @Test
fun `hasMore가 false이면 다음 페이지 API를 호출하지 않는다`() { fun `hasMore가 false이면 다음 페이지 API를 호출하지 않는다`() {
api.enqueueSuccess(page(rooms = listOf(room(roomId = 1L)), hasMore = false, nextCursor = null)) api.enqueueSuccess(page(rooms = listOf(room(roomId = 1L)), hasMore = false, nextCursor = null))

View File

@@ -517,6 +517,218 @@ class ContentAllTabViewModelTest {
verifyGetContents(page = 1) verifyGetContents(page = 1)
} }
@Test
fun `첫 페이지 refresh 실패 후 loadMore는 이전 page 다음을 append한다`() {
whenever(
repository.getContents(
"Bearer test-token",
MainContentAllType.AUDIO,
ContentSort.LATEST,
0,
DEFAULT_PAGE_SIZE,
null
)
)
.thenReturn(
Single.just(ApiResponse(true, response(audios = listOf(audio(1L)), hasNext = true), null)),
Single.just(ApiResponse(false, null, "refresh failed"))
)
stubGetContents(
page = 1,
response = Single.just(ApiResponse(true, response(page = 1, audios = listOf(audio(2L)), hasNext = false), null))
)
viewModel.loadContents()
viewModel.loadContents()
val restoredState = viewModel.allTabStateLiveData.requireValue() as MainContentAllTabUiState.Content
viewModel.loadMore()
val appendedState = viewModel.allTabStateLiveData.requireValue() as MainContentAllTabUiState.Content
assertEquals(0, restoredState.page)
assertTrue(restoredState.hasNext)
assertEquals(null, restoredState.paginationErrorMessage)
assertEquals(listOf(1L), restoredState.audioItems.map { it.audioContentId })
assertEquals(1, appendedState.page)
assertEquals(listOf(1L, 2L), appendedState.audioItems.map { it.audioContentId })
verifyGetContents(page = 1)
}
@Test
fun `같은 조건 첫 페이지 요청이 진행 중이면 중복 호출을 무시한다`() {
val pending = SingleSubject.create<ApiResponse<MainContentAllTabResponse>>()
stubGetContents(response = pending)
viewModel.loadContents()
viewModel.loadContents()
verifyGetContents(times = 1)
pending.onSuccess(ApiResponse(true, response(audios = listOf(audio(1L))), null))
}
@Test
fun `다른 조건 첫 페이지 요청은 기존 요청 진행 중에도 허용한다`() {
val pendingAudio = SingleSubject.create<ApiResponse<MainContentAllTabResponse>>()
val pendingFree = SingleSubject.create<ApiResponse<MainContentAllTabResponse>>()
stubGetContents(response = pendingAudio)
stubGetContents(type = MainContentAllType.FREE, response = pendingFree)
viewModel.loadContents()
viewModel.changeType(MainContentAllType.FREE)
verifyGetContents(type = MainContentAllType.AUDIO)
verifyGetContents(type = MainContentAllType.FREE)
pendingAudio.onSuccess(ApiResponse(true, response(audios = listOf(audio(1L))), null))
pendingFree.onSuccess(ApiResponse(true, response(type = MainContentAllType.FREE), null))
}
@Test
fun `오래된 첫 페이지 응답 후 같은 key를 다시 요청할 수 있다`() {
val pendingAudio = SingleSubject.create<ApiResponse<MainContentAllTabResponse>>()
val pendingFree = SingleSubject.create<ApiResponse<MainContentAllTabResponse>>()
whenever(
repository.getContents(
"Bearer test-token",
MainContentAllType.AUDIO,
ContentSort.LATEST,
0,
DEFAULT_PAGE_SIZE,
null
)
)
.thenReturn(
pendingAudio,
Single.just(ApiResponse(true, response(audios = listOf(audio(3L))), null))
)
stubGetContents(type = MainContentAllType.FREE, response = pendingFree)
viewModel.loadContents()
viewModel.changeType(MainContentAllType.FREE)
pendingAudio.onSuccess(ApiResponse(true, response(audios = listOf(audio(1L))), null))
viewModel.changeType(MainContentAllType.AUDIO)
verifyGetContents(type = MainContentAllType.AUDIO, times = 2)
pendingFree.onSuccess(ApiResponse(true, response(type = MainContentAllType.FREE), null))
}
@Test
fun `type을 A에서 B로 바꾼 뒤 A 응답 전 다시 A를 선택하면 최신 A 요청을 보낸다`() {
val pendingAudioFirst = SingleSubject.create<ApiResponse<MainContentAllTabResponse>>()
val pendingAudioLatest = SingleSubject.create<ApiResponse<MainContentAllTabResponse>>()
val pendingFree = SingleSubject.create<ApiResponse<MainContentAllTabResponse>>()
whenever(
repository.getContents(
"Bearer test-token",
MainContentAllType.AUDIO,
ContentSort.LATEST,
0,
DEFAULT_PAGE_SIZE,
null
)
)
.thenReturn(pendingAudioFirst, pendingAudioLatest)
stubGetContents(type = MainContentAllType.FREE, response = pendingFree)
viewModel.loadContents()
viewModel.changeType(MainContentAllType.FREE)
viewModel.changeType(MainContentAllType.AUDIO)
verifyGetContents(type = MainContentAllType.AUDIO, times = 2)
pendingAudioFirst.onSuccess(ApiResponse(true, response(audios = listOf(audio(1L))), null))
pendingFree.onSuccess(ApiResponse(true, response(type = MainContentAllType.FREE, audios = listOf(audio(2L))), null))
pendingAudioLatest.onSuccess(
ApiResponse(true, response(audios = listOf(audio(3L)), hasNext = true), null)
)
val state = viewModel.allTabStateLiveData.requireValue() as MainContentAllTabUiState.Content
assertEquals(MainContentAllType.AUDIO, state.selectedType)
assertEquals(listOf(3L), state.audioItems.map { it.audioContentId })
stubGetContents(
type = MainContentAllType.AUDIO,
page = 1,
response = Single.just(ApiResponse(true, response(page = 1, audios = listOf(audio(4L))), null))
)
viewModel.loadMore()
verifyGetContents(type = MainContentAllType.AUDIO, page = 1)
}
@Test
fun `첫 페이지 refresh 성공과 실패 결과를 명시적으로 emit한다`() {
whenever(
repository.getContents(
"Bearer test-token",
MainContentAllType.AUDIO,
ContentSort.LATEST,
0,
DEFAULT_PAGE_SIZE,
null
)
)
.thenReturn(
Single.just(ApiResponse(true, response(audios = listOf(audio(1L))), null)),
Single.just(ApiResponse(true, response(audios = listOf(audio(1L))), null)),
Single.just(ApiResponse(false, null, "refresh failed"))
)
viewModel.loadContents()
viewModel.loadContents()
assertEquals(ContentAllTabRefreshResult.Success, viewModel.refreshResultLiveData.requireValue())
viewModel.loadContents()
assertEquals(ContentAllTabRefreshResult.Failure, viewModel.refreshResultLiveData.requireValue())
}
@Test
fun `loadMore 진행 중 refresh 실패 후 loadMore는 다시 재시도할 수 있다`() {
val pendingLoadMore = SingleSubject.create<ApiResponse<MainContentAllTabResponse>>()
whenever(
repository.getContents(
"Bearer test-token",
MainContentAllType.AUDIO,
ContentSort.LATEST,
0,
DEFAULT_PAGE_SIZE,
null
)
)
.thenReturn(
Single.just(ApiResponse(true, response(audios = listOf(audio(1L)), hasNext = true), null)),
Single.just(ApiResponse(false, null, "refresh failed"))
)
whenever(
repository.getContents(
"Bearer test-token",
MainContentAllType.AUDIO,
ContentSort.LATEST,
1,
DEFAULT_PAGE_SIZE,
null
)
)
.thenReturn(
pendingLoadMore,
Single.just(ApiResponse(true, response(page = 1, audios = listOf(audio(2L)), hasNext = false), null))
)
viewModel.loadContents()
viewModel.loadMore()
viewModel.loadContents()
val restoredState = viewModel.allTabStateLiveData.requireValue() as MainContentAllTabUiState.Content
viewModel.loadMore()
val appendedState = viewModel.allTabStateLiveData.requireValue() as MainContentAllTabUiState.Content
assertFalse(restoredState.isLoadingMore)
assertEquals(null, restoredState.paginationErrorMessage)
assertEquals(1, appendedState.page)
assertEquals(listOf(1L, 2L), appendedState.audioItems.map { it.audioContentId })
verifyGetContents(page = 1, times = 2)
}
@Test @Test
fun `loadMore 요청이 진행 중이면 중복 요청하지 않는다`() { fun `loadMore 요청이 진행 중이면 중복 요청하지 않는다`() {
val pending = SingleSubject.create<ApiResponse<MainContentAllTabResponse>>() val pending = SingleSubject.create<ApiResponse<MainContentAllTabResponse>>()

View File

@@ -71,6 +71,84 @@ class ContentMainFragmentSourceTest {
assertTrue(source.contains("screen_content_ranking_type_like_count")) 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 @Test
fun `content 전체 layout과 source는 Phase 5 요구를 포함한다`() { fun `content 전체 layout과 source는 Phase 5 요구를 포함한다`() {
val source = projectFile( val source = projectFile(
@@ -644,6 +722,8 @@ class ContentMainFragmentSourceTest {
assertTrue(message ?: "Expected source to contain: $expected", source.contains(expected)) 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 { private fun String.substringFrom(marker: String): String {
val startIndex = indexOf(marker) val startIndex = indexOf(marker)
assertTrue("Missing function: $marker", startIndex >= 0) assertTrue("Missing function: $marker", startIndex >= 0)

View File

@@ -160,6 +160,132 @@ class ContentRankingViewModelTest {
assertEquals(listOf("2"), state.items.map { it.contentId }) assertEquals(listOf("2"), state.items.map { it.contentId })
} }
@Test
fun `같은 type 첫 페이지 요청이 진행 중이면 중복 호출을 무시한다`() {
val pending = SingleSubject.create<ApiResponse<AudioRankingResponse>>()
api.enqueue(pending)
viewModel.loadRankings(AudioRankingType.WEEKLY_POPULAR, force = true)
viewModel.loadRankings(AudioRankingType.WEEKLY_POPULAR, force = true)
assertEquals(1, api.calls.size)
pending.onSuccess(ApiResponse(true, response(type = AudioRankingType.WEEKLY_POPULAR), null))
}
@Test
fun `다른 type 첫 페이지 요청은 기존 요청 진행 중에도 허용한다`() {
val pendingWeekly = SingleSubject.create<ApiResponse<AudioRankingResponse>>()
val pendingRising = SingleSubject.create<ApiResponse<AudioRankingResponse>>()
api.enqueue(pendingWeekly)
api.enqueue(pendingRising)
viewModel.loadRankings(AudioRankingType.WEEKLY_POPULAR, force = true)
viewModel.loadRankings(AudioRankingType.RISING, force = true)
assertEquals(
listOf(
ApiCall("Bearer test-token", "WEEKLY_POPULAR"),
ApiCall("Bearer test-token", "RISING")
),
api.calls
)
pendingWeekly.onSuccess(ApiResponse(true, response(type = AudioRankingType.WEEKLY_POPULAR), null))
pendingRising.onSuccess(ApiResponse(true, response(type = AudioRankingType.RISING), null))
}
@Test
fun `type을 A에서 B로 바꾼 뒤 A 응답 전 다시 A를 선택하면 최신 A 요청을 보낸다`() {
val pendingWeeklyFirst = SingleSubject.create<ApiResponse<AudioRankingResponse>>()
val pendingRising = SingleSubject.create<ApiResponse<AudioRankingResponse>>()
val pendingWeeklyLatest = SingleSubject.create<ApiResponse<AudioRankingResponse>>()
api.enqueue(pendingWeeklyFirst)
api.enqueue(pendingRising)
api.enqueue(pendingWeeklyLatest)
viewModel.loadRankings(AudioRankingType.WEEKLY_POPULAR, force = true)
viewModel.loadRankings(AudioRankingType.RISING, force = true)
viewModel.loadRankings(AudioRankingType.WEEKLY_POPULAR, force = true)
assertEquals(
listOf(
ApiCall("Bearer test-token", "WEEKLY_POPULAR"),
ApiCall("Bearer test-token", "RISING"),
ApiCall("Bearer test-token", "WEEKLY_POPULAR")
),
api.calls
)
pendingWeeklyFirst.onSuccess(
ApiResponse(true, response(type = AudioRankingType.WEEKLY_POPULAR, items = listOf(item(contentId = 1L))), null)
)
pendingRising.onSuccess(
ApiResponse(true, response(type = AudioRankingType.RISING, items = listOf(item(contentId = 2L))), null)
)
pendingWeeklyLatest.onSuccess(
ApiResponse(true, response(type = AudioRankingType.WEEKLY_POPULAR, items = listOf(item(contentId = 3L))), null)
)
val state = viewModel.rankingStateLiveData.requireValue() as AudioRankingsUiState.Content
assertEquals(AudioRankingType.WEEKLY_POPULAR, state.type)
assertEquals(listOf("3"), state.items.map { it.contentId })
}
@Test
fun `진행 중인 B 응답 전 캐시된 A를 표시하면 B key가 고착되지 않는다`() {
api.enqueueSuccess(response(type = AudioRankingType.WEEKLY_POPULAR, items = listOf(item(contentId = 1L))))
viewModel.loadRankings(AudioRankingType.WEEKLY_POPULAR)
val pendingWeeklyRefresh = SingleSubject.create<ApiResponse<AudioRankingResponse>>()
val pendingRising = SingleSubject.create<ApiResponse<AudioRankingResponse>>()
val pendingRisingLatest = SingleSubject.create<ApiResponse<AudioRankingResponse>>()
api.enqueue(pendingWeeklyRefresh)
api.enqueue(pendingRising)
api.enqueue(pendingRisingLatest)
viewModel.loadRankings(AudioRankingType.WEEKLY_POPULAR, force = true)
viewModel.loadRankings(AudioRankingType.RISING, force = true)
viewModel.loadRankings(AudioRankingType.WEEKLY_POPULAR)
pendingRising.onSuccess(
ApiResponse(true, response(type = AudioRankingType.RISING, items = listOf(item(contentId = 2L))), null)
)
viewModel.loadRankings(AudioRankingType.RISING, force = true)
assertEquals(
listOf(
ApiCall("Bearer test-token", "WEEKLY_POPULAR"),
ApiCall("Bearer test-token", "WEEKLY_POPULAR"),
ApiCall("Bearer test-token", "RISING"),
ApiCall("Bearer test-token", "RISING")
),
api.calls
)
pendingWeeklyRefresh.onSuccess(
ApiResponse(true, response(type = AudioRankingType.WEEKLY_POPULAR, items = listOf(item(contentId = 3L))), null)
)
pendingRisingLatest.onSuccess(
ApiResponse(true, response(type = AudioRankingType.RISING, items = listOf(item(contentId = 4L))), null)
)
val state = viewModel.rankingStateLiveData.requireValue() as AudioRankingsUiState.Content
assertEquals(AudioRankingType.RISING, state.type)
assertEquals(listOf("4"), state.items.map { it.contentId })
}
@Test
fun `force refresh 실패는 기존 ranking content를 유지한다`() {
api.enqueueSuccess(response(type = AudioRankingType.WEEKLY_POPULAR, items = listOf(item(contentId = 1L))))
viewModel.loadRankings(AudioRankingType.WEEKLY_POPULAR)
api.enqueueSuccess(ApiResponse(false, null, "refresh failed"))
viewModel.loadRankings(AudioRankingType.WEEKLY_POPULAR, force = true)
val state = viewModel.rankingStateLiveData.requireValue() as AudioRankingsUiState.Content
assertEquals(listOf("1"), state.items.map { it.contentId })
assertEquals(R.string.common_error_unknown, viewModel.toastLiveData.requireValue()?.resId)
}
@Test @Test
fun `선택 type 변경 전 응답이 늦게 도착하면 화면 상태를 덮어쓰지 않는다`() { fun `선택 type 변경 전 응답이 늦게 도착하면 화면 상태를 덮어쓰지 않는다`() {
val pendingWeekly = SingleSubject.create<ApiResponse<AudioRankingResponse>>() val pendingWeekly = SingleSubject.create<ApiResponse<AudioRankingResponse>>()

View File

@@ -44,9 +44,9 @@ class HomeFollowingFragmentSourceTest {
.substringAfter("HOME_TAB_FOLLOWING ->") .substringAfter("HOME_TAB_FOLLOWING ->")
.substringBefore("}\n }") .substringBefore("}\n }")
assertTrue(branch.contains("binding.nsvHomeFollowingContent.visibility = View.VISIBLE")) assertTrue(branch.contains("binding.swipeHomeFollowing.visibility = View.VISIBLE"))
assertTrue(branch.contains("binding.nsvHomeRecommendationContent.visibility = View.GONE")) assertTrue(branch.contains("binding.swipeHomeRecommendation.visibility = View.GONE"))
assertTrue(branch.contains("binding.rvHomeCreatorRankings.visibility = View.GONE")) assertTrue(branch.contains("binding.swipeHomeRanking.visibility = View.GONE"))
} }
@Test @Test
@@ -81,10 +81,14 @@ class HomeFollowingFragmentSourceTest {
val source = homeMainFragmentSource() val source = homeMainFragmentSource()
assertTrue(source.contains("followingStateLiveData.observe(viewLifecycleOwner)")) assertTrue(source.contains("followingStateLiveData.observe(viewLifecycleOwner)"))
assertTrue(source.contains("is HomeFollowingUiState.Content -> bindHomeFollowingContent(state)")) assertTrue(source.contains("is HomeFollowingUiState.Content ->"))
assertTrue(source.contains("HomeFollowingUiState.Empty -> bindHomeFollowingEmpty(showEmptyMessage = true)")) assertTrue(source.contains("bindHomeFollowingContent(state)"))
assertTrue(source.contains("HomeFollowingUiState.LoginRequired,")) assertTrue(source.contains("HomeFollowingUiState.Empty ->"))
assertTrue(source.contains("is HomeFollowingUiState.Error -> bindHomeFollowingEmpty(showEmptyMessage = false)")) 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("followingCreatorAdapter.submitItems(emptyList())"))
assertTrue(source.contains("followingLiveAdapter.submitItems(emptyList())")) assertTrue(source.contains("followingLiveAdapter.submitItems(emptyList())"))
assertTrue(source.contains("followingChatAdapter.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 = View.GONE"))
assertTrue(source.contains("binding.tvHomeFollowingEmpty.visibility = if (showEmptyMessage) View.VISIBLE else 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("HomeFollowingUiState.Empty ->"))
assertTrue(source.contains("is HomeFollowingUiState.Error -> bindHomeFollowingEmpty(showEmptyMessage = false)")) assertTrue(source.contains("bindHomeFollowingEmpty(showEmptyMessage = true)"))
assertTrue(source.contains("is HomeFollowingUiState.Error ->"))
assertTrue(source.contains("bindHomeFollowingEmpty(showEmptyMessage = false)"))
} }
@Test @Test

View File

@@ -114,6 +114,19 @@ class HomeFollowingViewModelTest {
assertEquals(R.string.common_error_unknown, viewModel.toastLiveData.requireValue()?.resId) 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() { private fun setImmediateRxSchedulers() {
val trampoline = { _: Scheduler -> Schedulers.trampoline() } val trampoline = { _: Scheduler -> Schedulers.trampoline() }
RxJavaPlugins.setIoSchedulerHandler(trampoline) RxJavaPlugins.setIoSchedulerHandler(trampoline)

View File

@@ -105,7 +105,6 @@ class HomeMainFragmentLayoutTest {
assertNotNull(scrollView) assertNotNull(scrollView)
assertSame(root, titleBar.parent) assertSame(root, titleBar.parent)
assertSame(root, tabBar.parent) assertSame(root, tabBar.parent)
assertSame(root, scrollView.parent)
assertEquals(60.dpToPx(), titleBar.layoutParams.height) assertEquals(60.dpToPx(), titleBar.layoutParams.height)
assertEquals(52.dpToPx(), tabBar.layoutParams.height) assertEquals(52.dpToPx(), tabBar.layoutParams.height)
assertFalse(root.containsClassName("androidx.viewpager2.widget.ViewPager2")) assertFalse(root.containsClassName("androidx.viewpager2.widget.ViewPager2"))
@@ -1202,10 +1201,10 @@ class HomeMainFragmentLayoutTest {
assertTrue(source.contains("HOME_TAB_FOLLOWING = 2")) assertTrue(source.contains("HOME_TAB_FOLLOWING = 2"))
assertTrue(source.contains("binding.textTabBarHome.root.setOnTabSelectedListener { index ->")) assertTrue(source.contains("binding.textTabBarHome.root.setOnTabSelectedListener { index ->"))
assertTrue(source.contains("showHomeTab(index)")) assertTrue(source.contains("showHomeTab(index)"))
assertTrue(source.contains("binding.nsvHomeRecommendationContent.visibility = View.GONE")) assertTrue(source.contains("binding.swipeHomeRecommendation.visibility = View.GONE"))
assertTrue(source.contains("binding.rvHomeCreatorRankings.visibility = View.VISIBLE")) assertTrue(source.contains("binding.swipeHomeRanking.visibility = View.VISIBLE"))
assertTrue(source.contains("binding.nsvHomeRecommendationContent.visibility = View.VISIBLE")) assertTrue(source.contains("binding.swipeHomeRecommendation.visibility = View.VISIBLE"))
assertTrue(source.contains("binding.rvHomeCreatorRankings.visibility = View.GONE")) assertTrue(source.contains("binding.swipeHomeRanking.visibility = View.GONE"))
} }
@Test @Test
@@ -1223,9 +1222,12 @@ class HomeMainFragmentLayoutTest {
val source = homeMainFragmentSource() val source = homeMainFragmentSource()
assertTrue(source.contains("rankingStateLiveData.observe(viewLifecycleOwner)")) assertTrue(source.contains("rankingStateLiveData.observe(viewLifecycleOwner)"))
assertTrue(source.contains("is HomeCreatorRankingUiState.Content -> creatorRankingAdapter.submitItems(state.items)")) assertTrue(source.contains("is HomeCreatorRankingUiState.Content ->"))
assertTrue(source.contains("HomeCreatorRankingUiState.Empty,")) assertTrue(source.contains("creatorRankingAdapter.submitItems(state.items)"))
assertTrue(source.contains("is HomeCreatorRankingUiState.Error -> creatorRankingAdapter.submitItems(emptyList())")) 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")) assertTrue(source.contains("HomeCreatorRankingUiState.Loading -> Unit"))
} }

View File

@@ -137,11 +137,11 @@ class HomeMainFragmentLoginGuardSourceTest {
assertTrue(source.contains("private var isLiveEntryLoading = false")) assertTrue(source.contains("private var isLiveEntryLoading = false"))
val updateSource = source.substringFrom("private fun updateLoadingDialog()") val updateSource = source.substringFrom("private fun updateLoadingDialog()")
val expectedLoadingExpression = "isRecommendationLoading || " + assertTrue(updateSource.contains("isRecommendationLoading ||"))
"isCreatorRankingLoading || " + assertTrue(updateSource.contains("isRecommendationFollowLoading ||"))
"isFollowingLoading || " + assertTrue(updateSource.contains("isCreatorRankingLoading ||"))
"isLiveEntryLoading" assertTrue(updateSource.contains("isFollowingLoading ||"))
assertTrue(updateSource.contains(expectedLoadingExpression)) assertTrue(updateSource.contains("isLiveEntryLoading"))
assertTrue(updateSource.contains("loadingDialog.show(screenWidth)")) assertTrue(updateSource.contains("loadingDialog.show(screenWidth)"))
assertTrue(updateSource.contains("loadingDialog.dismiss()")) assertTrue(updateSource.contains("loadingDialog.dismiss()"))

View File

@@ -1,6 +1,7 @@
package kr.co.vividnext.sodalive.v2.main.home package kr.co.vividnext.sodalive.v2.main.home
import android.app.Application import android.app.Application
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Test 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 { private fun String.substringFrom(marker: String): String {
val startIndex = indexOf(marker) val startIndex = indexOf(marker)
assertTrue("Missing function: $marker", startIndex >= 0) assertTrue("Missing function: $marker", startIndex >= 0)
@@ -64,6 +130,8 @@ class HomeMainFragmentSourceTest {
return substring(startIndex, nextFunctionIndex) return substring(startIndex, nextFunctionIndex)
} }
private fun String.countOccurrences(needle: String): Int = split(needle).size - 1
private fun projectFile(relativePath: String): File { private fun projectFile(relativePath: String): File {
val candidates = listOf(File(relativePath), File("../$relativePath")) val candidates = listOf(File(relativePath), File("../$relativePath"))
return candidates.firstOrNull { it.exists() } return candidates.firstOrNull { it.exists() }

View File

@@ -0,0 +1,206 @@
package kr.co.vividnext.sodalive.v2.main.home
import android.app.Application
import android.content.Context
import androidx.lifecycle.LiveData
import androidx.lifecycle.Observer
import androidx.test.core.app.ApplicationProvider
import io.reactivex.rxjava3.android.plugins.RxAndroidPlugins
import io.reactivex.rxjava3.core.Scheduler
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.plugins.RxJavaPlugins
import io.reactivex.rxjava3.schedulers.Schedulers
import io.reactivex.rxjava3.subjects.SingleSubject
import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.common.SharedPreferenceManager
import kr.co.vividnext.sodalive.v2.main.home.data.FollowRecommendedCreatorsRequest
import kr.co.vividnext.sodalive.v2.main.home.data.HomeAiCharacterItem
import kr.co.vividnext.sodalive.v2.main.home.data.HomeBannerItem
import kr.co.vividnext.sodalive.v2.main.home.data.HomeLiveItem
import kr.co.vividnext.sodalive.v2.main.home.data.HomeActiveCreatorItem
import kr.co.vividnext.sodalive.v2.main.home.data.HomeCreatorItem
import kr.co.vividnext.sodalive.v2.main.home.data.HomeGenreCreatorGroupItem
import kr.co.vividnext.sodalive.v2.main.home.data.HomePopularCommunityPostItem
import kr.co.vividnext.sodalive.v2.main.home.data.HomeRecommendationApi
import kr.co.vividnext.sodalive.v2.main.home.data.HomeRecommendationRepository
import kr.co.vividnext.sodalive.v2.main.home.data.HomeRecommendationResponse
import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationUiState
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [28], application = Application::class)
class HomeRecommendationViewModelTest {
private val context: Context = ApplicationProvider.getApplicationContext()
private lateinit var api: FakeHomeRecommendationApi
private lateinit var viewModel: HomeRecommendationViewModel
@Before
fun setUp() {
setImmediateRxSchedulers()
SharedPreferenceManager.resetForTest()
SharedPreferenceManager.init(context)
SharedPreferenceManager.token = "test-token"
api = FakeHomeRecommendationApi()
viewModel = HomeRecommendationViewModel(HomeRecommendationRepository(api))
}
@After
fun tearDown() {
RxJavaPlugins.reset()
RxAndroidPlugins.reset()
SharedPreferenceManager.resetForTest()
}
@Test
fun `follow 요청 loading은 추천 목록 loading을 끄지 않는다`() {
val pendingRecommendations = SingleSubject.create<ApiResponse<HomeRecommendationResponse>>()
api.enqueueRecommendations(pendingRecommendations)
api.enqueueFollow(ApiResponse(true, Any(), null))
viewModel.loadRecommendations()
viewModel.followCreators("cheerCreators", listOf(1L))
assertEquals(true, viewModel.isLoading.requireValue())
assertEquals(false, viewModel.isFollowLoading.requireValue())
pendingRecommendations.onSuccess(ApiResponse(true, response(), null))
}
@Test
fun `추천 목록 요청 완료는 follow 요청 loading을 끄지 않는다`() {
val pendingFollow = SingleSubject.create<ApiResponse<Any>>()
api.enqueueRecommendations(ApiResponse(true, response(), null))
api.enqueueFollow(pendingFollow)
viewModel.followCreators("cheerCreators", listOf(1L))
viewModel.loadRecommendations()
assertEquals(false, viewModel.isLoading.requireValue())
assertEquals(true, viewModel.isFollowLoading.requireValue())
pendingFollow.onSuccess(ApiResponse(true, Any(), null))
}
@Test
fun `추천 refresh 중 follow가 먼저 성공해도 최종 follow 완료 상태를 유지한다`() {
val pendingRecommendations = SingleSubject.create<ApiResponse<HomeRecommendationResponse>>()
api.enqueueRecommendations(pendingRecommendations)
api.enqueueFollow(ApiResponse(true, Any(), null))
viewModel.loadRecommendations()
viewModel.followCreators("cheerCreators", listOf(1L))
pendingRecommendations.onSuccess(ApiResponse(true, response(), null))
val state = viewModel.recommendationStateLiveData.requireValue() as HomeRecommendationUiState.Content
assertEquals(true, state.cheerCreators.isFollowCompleted)
}
@Test
fun `추천 refresh가 먼저 끝난 뒤 follow가 성공해도 최종 follow 완료 상태를 유지한다`() {
val pendingFollow = SingleSubject.create<ApiResponse<Any>>()
api.enqueueRecommendations(ApiResponse(true, response(), null))
api.enqueueFollow(pendingFollow)
viewModel.loadRecommendations()
viewModel.followCreators("cheerCreators", listOf(1L))
pendingFollow.onSuccess(ApiResponse(true, Any(), null))
val state = viewModel.recommendationStateLiveData.requireValue() as HomeRecommendationUiState.Content
assertEquals(true, state.cheerCreators.isFollowCompleted)
}
@Test
fun `추천 refresh 중 follow 성공 후 refresh 실패해도 follow 완료 상태를 유지한다`() {
api.enqueueRecommendations(ApiResponse(true, response(), null))
viewModel.loadRecommendations()
val pendingRecommendations = SingleSubject.create<ApiResponse<HomeRecommendationResponse>>()
api.enqueueRecommendations(pendingRecommendations)
api.enqueueFollow(ApiResponse(true, Any(), null))
viewModel.loadRecommendations()
viewModel.followCreators("cheerCreators", listOf(1L))
pendingRecommendations.onSuccess(ApiResponse(false, null, "refresh failed"))
val state = viewModel.recommendationStateLiveData.requireValue() as HomeRecommendationUiState.Content
assertEquals(true, state.cheerCreators.isFollowCompleted)
}
@Test
fun `추천 refresh 성공 후 follow 실패해도 최신 추천 목록을 유지한다`() {
api.enqueueRecommendations(ApiResponse(true, response(creatorId = 1L), null))
viewModel.loadRecommendations()
api.enqueueRecommendations(ApiResponse(true, response(creatorId = 2L), null))
api.enqueueFollow(ApiResponse(false, null, "follow failed"))
viewModel.loadRecommendations()
viewModel.followCreators("cheerCreators", listOf(2L))
val state = viewModel.recommendationStateLiveData.requireValue() as HomeRecommendationUiState.Content
assertEquals(listOf(2L), state.cheerCreators.items.map { it.creatorId })
}
private fun response(creatorId: Long = 1L) = HomeRecommendationResponse(
lives = listOf(HomeLiveItem(1L, "크리에이터", "https://example.com/profile.png")),
banners = emptyList<HomeBannerItem>(),
recentlyActiveCreators = emptyList<HomeActiveCreatorItem>(),
recentDebutCreators = emptyList<HomeCreatorItem>(),
aiCharacters = emptyList<HomeAiCharacterItem>(),
genreCreators = emptyList<HomeGenreCreatorGroupItem>(),
cheerCreators = listOf(HomeCreatorItem(creatorId, "크리에이터", "https://example.com/profile.png")),
popularCommunityPosts = emptyList<HomePopularCommunityPostItem>()
)
private fun setImmediateRxSchedulers() {
val trampoline = { _: Scheduler -> Schedulers.trampoline() }
RxJavaPlugins.setIoSchedulerHandler(trampoline)
RxAndroidPlugins.setInitMainThreadSchedulerHandler { Schedulers.trampoline() }
RxAndroidPlugins.setMainThreadSchedulerHandler { Schedulers.trampoline() }
}
private fun <T> LiveData<T>.requireValue(): T? {
var value: T? = null
val observer = Observer<T> { value = it }
observeForever(observer)
removeObserver(observer)
return value
}
private class FakeHomeRecommendationApi : HomeRecommendationApi {
private val recommendationResponses = ArrayDeque<Single<ApiResponse<HomeRecommendationResponse>>>()
private val followResponses = ArrayDeque<Single<ApiResponse<Any>>>()
fun enqueueRecommendations(response: ApiResponse<HomeRecommendationResponse>) {
enqueueRecommendations(Single.just(response))
}
fun enqueueRecommendations(response: Single<ApiResponse<HomeRecommendationResponse>>) {
recommendationResponses.addLast(response)
}
fun enqueueFollow(response: ApiResponse<Any>) {
enqueueFollow(Single.just(response))
}
fun enqueueFollow(response: Single<ApiResponse<Any>>) {
followResponses.addLast(response)
}
override fun getRecommendations(authHeader: String): Single<ApiResponse<HomeRecommendationResponse>> {
return recommendationResponses.removeFirst()
}
override fun followRecommendedCreators(
request: FollowRecommendedCreatorsRequest,
authHeader: String
): Single<ApiResponse<Any>> {
return followResponses.removeFirst()
}
}
}

View File

@@ -46,7 +46,7 @@
- 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/home/HomeMainFragment.kt`
- Test: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/home/HomeMainFragmentSourceTest.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`에 아래 검증을 추가한다. `HomeMainFragmentSourceTest.kt`에 아래 검증을 추가한다.
@@ -69,7 +69,7 @@
Expected: FAIL because `swipe_home_recommendation`, `swipe_home_ranking`, `swipe_home_following` do not exist yet. 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는 유지한다. `fragment_v2_main_home.xml`에서 `text_tab_bar_home` 아래 콘텐츠 3개를 각각 아래 구조로 감싼다. 기존 child ID는 유지한다.
@@ -143,7 +143,7 @@
Expected: BUILD SUCCESSFUL. Expected: BUILD SUCCESSFUL.
- [ ] **Task 1.3: 홈 Fragment source test를 추가한다** - [x] **Task 1.3: 홈 Fragment source test를 추가한다**
`HomeMainFragmentSourceTest.kt`에 아래 검증을 추가한다. `HomeMainFragmentSourceTest.kt`에 아래 검증을 추가한다.
@@ -169,7 +169,7 @@
Expected: FAIL because refresh dispatch code does not exist yet. 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()`를 호출한다. `HomeMainFragment.kt`의 `onViewCreated()`에서 `setUpFollowingAdapters()` 다음에 `setUpRefreshLayouts()`를 호출한다.
@@ -250,7 +250,7 @@
Expected: PASS. Expected: PASS.
- [ ] **Task 1.5: 홈 Phase 검증 기록을 남긴다** - [x] **Task 1.5: 홈 Phase 검증 기록을 남긴다**
Run: Run:
- `./gradlew :app:mergeDebugResources` - `./gradlew :app:mergeDebugResources`
@@ -261,6 +261,12 @@
검증 성공 후 이 Phase 아래에 `검증 기록`을 한국어로 누적한다. 검증 성공 후 이 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: 콘텐츠 탭 새로고침 연결 ### Phase 2: 콘텐츠 탭 새로고침 연결
**Files:** **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/ContentMainFragmentSourceTest.kt`
- Test: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/content/ContentRankingViewModelTest.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`에 아래 검증을 추가한다. `ContentMainFragmentSourceTest.kt`에 아래 검증을 추가한다.
@@ -293,7 +299,7 @@
Expected: FAIL because content refresh wrappers do not exist yet. 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`으로 감싼다. 추천 `NestedScrollView`를 `swipe_content_recommendation`으로 감싼다.
@@ -378,7 +384,7 @@
Expected: BUILD SUCCESSFUL. Expected: BUILD SUCCESSFUL.
- [ ] **Task 2.3: 콘텐츠 refresh 분기 source test를 추가한다** - [x] **Task 2.3: 콘텐츠 refresh 분기 source test를 추가한다**
`ContentMainFragmentSourceTest.kt`에 아래 검증을 추가한다. `ContentMainFragmentSourceTest.kt`에 아래 검증을 추가한다.
@@ -405,7 +411,7 @@
Expected: FAIL because refresh dispatch code does not exist yet. 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()`를 호출한다. `ContentMainFragment.kt`에서 `setUpAdapters()` 다음에 `setUpRefreshLayouts()`를 호출한다.
@@ -495,7 +501,7 @@
Expected: PASS. Expected: PASS.
- [ ] **Task 2.5: 콘텐츠 랭킹 force refresh 테스트를 실행한다** - [x] **Task 2.5: 콘텐츠 랭킹 force refresh 테스트를 실행한다**
`ContentRankingViewModelTest.kt`에는 이미 아래 테스트가 있으므로 새 테스트를 추가하지 않는다. `ContentRankingViewModelTest.kt`에는 이미 아래 테스트가 있으므로 새 테스트를 추가하지 않는다.
@@ -518,7 +524,7 @@
Expected: PASS. Expected: PASS.
- [ ] **Task 2.6: 콘텐츠 Phase 검증 기록을 남긴다** - [x] **Task 2.6: 콘텐츠 Phase 검증 기록을 남긴다**
Run: Run:
- `./gradlew :app:mergeDebugResources` - `./gradlew :app:mergeDebugResources`
@@ -531,6 +537,13 @@
검증 성공 후 이 Phase 아래에 `검증 기록`을 한국어로 누적한다. 검증 성공 후 이 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: 대화 탭 새로고침 연결 ### Phase 3: 대화 탭 새로고침 연결
**Files:** **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/ChatMainFragmentLayoutTest.kt`
- Test: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/ChatMainViewModelTest.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`임을 기준으로 갱신한다. `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. 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`으로 감싼다. `fragment_v2_main_chat.xml`에서 `rv_chat_rooms`와 `tv_chat_empty_message`를 하나의 `SwipeRefreshLayout` 안 `FrameLayout`으로 감싼다.
@@ -606,7 +619,7 @@
Expected: BUILD SUCCESSFUL. Expected: BUILD SUCCESSFUL.
- [ ] **Task 3.3: 대화 Fragment source test를 추가한다** - [x] **Task 3.3: 대화 Fragment source test를 추가한다**
`ChatMainFragmentLayoutTest.kt`에 아래 검증을 추가한다. `ChatMainFragmentLayoutTest.kt`에 아래 검증을 추가한다.
@@ -627,7 +640,7 @@
Expected: FAIL because refresh listener code does not exist yet. 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()`를 호출한다. `ChatMainFragment.kt`의 `onViewCreated()`에서 `setupChatRooms()` 다음에 `setupRefresh()`를 호출한다.
@@ -664,7 +677,7 @@
Expected: PASS. Expected: PASS.
- [ ] **Task 3.5: 대화 ViewModel 첫 페이지 교체 테스트를 추가한다** - [x] **Task 3.5: 대화 ViewModel 첫 페이지 교체 테스트를 추가한다**
`ChatMainViewModelTest.kt`에 같은 filter 첫 페이지 재호출이 기존 목록을 교체하고 cursor 없이 요청하는 테스트를 추가한다. `ChatMainViewModelTest.kt`에 같은 filter 첫 페이지 재호출이 기존 목록을 교체하고 cursor 없이 요청하는 테스트를 추가한다.
@@ -693,7 +706,7 @@
Expected: PASS. Expected: PASS.
- [ ] **Task 3.6: 대화 Phase 검증 기록을 남긴다** - [x] **Task 3.6: 대화 Phase 검증 기록을 남긴다**
Run: Run:
- `./gradlew :app:mergeDebugResources` - `./gradlew :app:mergeDebugResources`
@@ -705,13 +718,20 @@
검증 성공 후 이 Phase 아래에 `검증 기록`을 한국어로 누적한다. 검증 성공 후 이 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: 통합 검증과 문서 갱신 ### Phase 4: 통합 검증과 문서 갱신
**Files:** **Files:**
- Modify: `docs/20260722_메인_홈_콘텐츠_대화_탭_당겨서_새로고침/prd.md` - Modify: `docs/20260722_메인_홈_콘텐츠_대화_탭_당겨서_새로고침/prd.md`
- Modify: `docs/20260722_메인_홈_콘텐츠_대화_탭_당겨서_새로고침/plan-task.md` - Modify: `docs/20260722_메인_홈_콘텐츠_대화_탭_당겨서_새로고침/plan-task.md`
- [ ] **Task 4.1: PRD Open Questions 결과를 문서에 반영한다** - [x] **Task 4.1: PRD Open Questions 결과를 문서에 반영한다**
`prd.md`의 `Open Questions` 아래에 구현 계획 확정 내용을 추가한다. `prd.md`의 `Open Questions` 아래에 구현 계획 확정 내용을 추가한다.
@@ -727,7 +747,7 @@
Expected: added lines are found. Expected: added lines are found.
- [ ] **Task 4.2: 전체 변경 검증을 실행한다** - [x] **Task 4.2: 전체 변경 검증을 실행한다**
Run: Run:
- `./gradlew :app:mergeDebugResources` - `./gradlew :app:mergeDebugResources`
@@ -739,7 +759,7 @@
Expected: all BUILD SUCCESSFUL. 기존 경고가 있으면 경고 문구와 변경 관련 여부를 `Verification Log`에 기록한다. Expected: all BUILD SUCCESSFUL. 기존 경고가 있으면 경고 문구와 변경 관련 여부를 `Verification Log`에 기록한다.
- [ ] **Task 4.3: 수동 QA를 수행한다** - [x] **Task 4.3: 수동 QA를 수행한다**
연결 기기가 있으면 실행한다. 연결 기기가 있으면 실행한다.
@@ -759,7 +779,7 @@
기기가 없으면 `adb devices` 결과를 `Verification Log`에 남기고 수동 QA는 환경 차단으로 표시한다. 기기가 없으면 `adb devices` 결과를 `Verification Log`에 남기고 수동 QA는 환경 차단으로 표시한다.
- [ ] **Task 4.4: 최종 문서 검증 기록을 누적한다** - [x] **Task 4.4: 최종 문서 검증 기록을 누적한다**
`plan-task.md` 최하단 `Verification Log`와 `prd.md`의 `Verification Log`에 아래 형식으로 실제 실행 결과를 누적한다. `plan-task.md` 최하단 `Verification Log`와 `prd.md`의 `Verification Log`에 아래 형식으로 실제 실행 결과를 누적한다.
@@ -776,9 +796,116 @@
4. 각 ViewModel의 기존 첫 페이지 재조회/force/cache 동작을 테스트로 고정한다. 4. 각 ViewModel의 기존 첫 페이지 재조회/force/cache 동작을 테스트로 고정한다.
5. PRD Open Questions 확정 내용을 문서화하고 통합 검증을 수행한다. 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 ## Verification Log
- 2026-07-22: `docs/20260722_메인_홈_콘텐츠_대화_탭_당겨서_새로고침/prd.md`를 기준으로 계획을 작성했다. - 2026-07-22: `docs/20260722_메인_홈_콘텐츠_대화_탭_당겨서_새로고침/prd.md`를 기준으로 계획을 작성했다.
- 2026-07-22: `app/build.gradle`에서 `androidx.swiperefreshlayout:swiperefreshlayout:1.1.0`이 이미 추가되어 있음을 확인해 신규 의존성 추가 없이 구현하도록 계획했다. - 2026-07-22: `app/build.gradle`에서 `androidx.swiperefreshlayout:swiperefreshlayout:1.1.0`이 이미 추가되어 있음을 확인해 신규 의존성 추가 없이 구현하도록 계획했다.
- 2026-07-22: `HomeMainFragment`, `ContentMainFragment`, `ChatMainFragment`와 각 ViewModel을 확인해 현재 선택 탭/필터 기준 첫 페이지 재호출 경로를 확정했다. - 2026-07-22: `HomeMainFragment`, `ContentMainFragment`, `ChatMainFragment`와 각 ViewModel을 확인해 현재 선택 탭/필터 기준 첫 페이지 재호출 경로를 확정했다.
- 2026-07-22: 이번 단계는 plan-task 문서 작성만 수행했으며 구현/빌드/테스트는 실행하지 않는다. - 2026-07-22: 이번 단계는 plan-task 문서 작성만 수행했으며 구현/빌드/테스트는 실행하지 않는다.
- 2026-07-22: `prd.md`의 Open Questions 아래에 구현 계획 확정 내용을 반영했고, `rg "구현 계획 확정|HomeCreatorRankingViewModel|force = true" docs/20260722_메인_홈_콘텐츠_대화_탭_당겨서_새로고침/prd.md`로 추가 내용을 확인했다.
- 2026-07-22: 통합 검증으로 `./gradlew :app:mergeDebugResources`, `./gradlew :app:compileDebugKotlin`, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.home.*"`, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.content.*"`, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.*"`를 실행했고 결과는 BUILD SUCCESSFUL이다.
- 2026-07-22: `./gradlew :app:ktlintCheck` 최초 재실행에서 새 테스트 파일의 line length/argument wrapping 위반이 발견되어 줄바꿈만 수정했다.
- 2026-07-22: `adb devices`를 실행했으나 `List of devices attached` 아래 연결 기기가 없어 `./gradlew :app:installDebug`와 실제 기기 수동 QA는 환경 차단으로 수행하지 못했다.
- 2026-07-22: 최종 리뷰에서 pull-to-refresh 실패 시 기존 데이터 보존과 성공 후 최상단 복귀 누락이 지적되어, `HomeMainFragmentSourceTest`, `ContentMainFragmentSourceTest`, `ChatMainFragmentLayoutTest`에 RED 검증을 먼저 추가했다. RED는 각각 `HomeMainFragmentSourceTest.kt:94`, `ContentMainFragmentSourceTest.kt:111`, `ChatMainFragmentLayoutTest.kt:194`의 `AssertionError`로 확인했다.
- 2026-07-22: 리뷰 지적 반영 후 `./gradlew :app:mergeDebugResources`, `./gradlew :app:compileDebugKotlin`, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.home.*"`, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.content.*"`, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.*"`, `./gradlew :app:ktlintCheck`를 재실행했고 모두 BUILD SUCCESSFUL이다. `git diff --check`도 출력 없이 통과했다. ktlint 실행 중 `.editorconfig`의 `disabled_rules` deprecation warning은 출력되었다.
- 2026-07-22: 두 번째 리뷰에서 Chat empty 상태 실패 보존과 Chat/Content 전체 탭 pagination 상태 보존이 지적되어 `ChatMainFragmentLayoutTest`, `ChatMainViewModelTest`, `ContentAllTabViewModelTest`에 RED 검증을 먼저 추가했다. RED는 각각 `ChatMainFragmentLayoutTest.kt:163`의 `AssertionError`, `ChatMainViewModelTest.kt:157`의 `ClassCastException`, `ContentAllTabViewModelTest.kt:534`의 `ClassCastException`으로 확인했다. 수정 후 동일한 최종 Gradle 검증을 다시 실행했고 모두 BUILD SUCCESSFUL이다.
- 2026-07-22: 리뷰 blocker 수정 RED 테스트로 `HomeMainFragmentSourceTest`, `ContentMainFragmentSourceTest`, `ChatMainFragmentLayoutTest`에 pull-refresh 실패 시 기존 표시 데이터 보존과 성공 시 현재 표면 top-scroll source 검증을 먼저 추가했다. 첫 실행은 `HomeMainFragmentSourceTest.kt`의 `assertEquals` import 누락으로 컴파일 실패해 테스트 결함을 수정했고, 재실행에서 `HomeMainFragmentSourceTest.kt:94`, `ContentMainFragmentSourceTest.kt:111`, `ChatMainFragmentLayoutTest.kt:194`의 `AssertionError`로 기대 RED를 확인했다.
- 2026-07-22: `HomeMainFragment`, `ContentMainFragment`, `ChatMainFragment` 내부에만 pull-refresh in-progress flag를 추가했다. Home/Content는 pull-refresh error에서 기존 content/list clearing을 건너뛰고, Content 전체 탭은 refresh loading/error 동안 기존 전체 탭 content 상태와 adapter items를 유지하도록 했다. Home/Content 성공 또는 empty terminal state는 현재 탭의 `NestedScrollView`/`RecyclerView`를 top으로 이동시키고 flag를 해제하며, Chat은 refresh error에서 기존 adapter items를 유지한다.
- 2026-07-22: 구현 후 RED 대상 묶음 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.home.HomeMainFragmentSourceTest" --tests "kr.co.vividnext.sodalive.v2.main.content.ContentMainFragmentSourceTest" --tests "kr.co.vividnext.sodalive.v2.main.chat.ChatMainFragmentLayoutTest"`를 실행해 BUILD SUCCESSFUL을 확인했다. 이후 `./gradlew :app:compileDebugKotlin`, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.content.*"`, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.*"`도 BUILD SUCCESSFUL이다.
- 2026-07-22: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.home.*"` 첫 확장 실행은 기존 Home source test 3개가 과거 한 줄 branch 문자열을 고정해 `HomeFollowingFragmentSourceTest.kt:84`, `HomeFollowingFragmentSourceTest.kt:139`, `HomeMainFragmentLayoutTest.kt:1230`에서 실패했다. 테스트 기대값을 새 guard/block 구조로 갱신한 뒤 같은 명령을 재실행해 BUILD SUCCESSFUL을 확인했다.
- 2026-07-22: 2차 리뷰 blocker 수정 RED 테스트로 `ChatMainFragmentLayoutTest`에 empty 상태 pull-refresh Loading placeholder 보존 source 검증, `ChatMainViewModelTest`에 같은 filter 첫 페이지 refresh 실패 후 이전 cursor 기반 `loadNextPage()` append 검증, `ContentAllTabViewModelTest`에 첫 페이지 refresh 실패 후 이전 page 기반 `loadMore()` append 검증을 먼저 추가했다. production 변경 전 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.ChatMainFragmentLayoutTest" --tests "kr.co.vividnext.sodalive.v2.main.chat.ChatMainViewModelTest" --tests "kr.co.vividnext.sodalive.v2.main.content.ContentAllTabViewModelTest"`를 실행했고 `ChatMainFragmentLayoutTest.kt:163` AssertionError, `ChatMainViewModelTest.kt:157` ClassCastException, `ContentAllTabViewModelTest.kt:534` ClassCastException으로 RED를 확인했다.
- 2026-07-22: `ChatMainFragment`는 pull-refresh Loading 중 empty message를 숨기지 않도록 가드했고, `ChatMainViewModel`은 같은 filter 첫 페이지 실패 시 이전 `currentItems`/`nextCursor`/`hasMore`와 Content/Empty 상태를 복원하도록 했다. `ContentAllTabViewModel`은 같은 type/sort/dayOfWeek의 기존 Content가 있는 첫 페이지 refresh 실패에서 이전 Content를 다시 emit해 pagination 상태를 보존하도록 했다.
- 2026-07-22: 구현 후 RED 대상 묶음 재실행은 첫 시도에서 `ContentAllTabViewModel.kt`의 `requestContents` 호출부 시그니처 정렬 오류로 `:app:compileDebugKotlin`이 실패했고, 기존 `loadMore()` trailing lambda 호출을 보존하도록 파라미터 순서를 바로잡았다. 이후 같은 RED 대상 묶음 명령을 재실행해 BUILD SUCCESSFUL을 확인했다.
- 2026-07-22: 2차 리뷰 blocker 수정 최종 검증으로 `./gradlew :app:compileDebugKotlin`, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.*"`, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.content.*"`를 실행했고 모두 BUILD SUCCESSFUL이다. Gradle deprecation warning은 기존 빌드 경고로 확인되었고 이번 변경 파일 관련 실패는 없었다.
- 2026-07-22: Phase 6 리뷰 Important 보완 후 targeted unit test 묶음과 `./gradlew :app:ktlintCheck`를 실행했고 모두 BUILD SUCCESSFUL이다. ktlint의 `.editorconfig disabled_rules` deprecation warning과 Gradle deprecation warning은 기존 경고로 남아 있다.
- 2026-07-22: Phase 6 read-only 리뷰에서 나온 Important 2건을 추가 보완했다. stale 첫 페이지 응답도 in-flight key를 해제하도록 하고, Content/Home error 보존 및 Content 전체 탭 명시 실패 결과가 대상 `SwipeRefreshLayout` indicator를 종료하도록 수정했다. 관련 targeted test는 BUILD SUCCESSFUL이다.
- 2026-07-22: 추가 리뷰에서 `SwipeRefreshLayout` direct child가 `FrameLayout`인 Chat/Content 전체 탭의 child scroll 판정 누락, Chat/Content 전체 탭 refresh 실패 복원 시 top-scroll 발생, Home/Content 일부 ViewModel의 실패 후 terminal state가 `Error`로 남는 문제가 지적되었다. `ChatMainFragmentLayoutTest`와 `ContentMainFragmentSourceTest`에 child scroll callback 및 실패 복원 시 top-scroll 방지 source 검증을 추가했고, production 변경 전 각각 `ChatMainFragmentLayoutTest.kt:155`, `ChatMainFragmentLayoutTest.kt:189`, `ChatMainFragmentLayoutTest.kt:197`, `ContentMainFragmentSourceTest.kt:89`, `ContentMainFragmentSourceTest.kt:129`의 AssertionError로 RED를 확인했다.
- 2026-07-22: ViewModel terminal state 보존 RED 검증으로 `ContentRankingViewModelTest`와 `HomeFollowingViewModelTest`에 refresh 실패 시 기존 Content 유지 테스트를 추가했다. production 변경 전 `ContentRankingViewModelTest.kt:170`, `HomeFollowingViewModelTest.kt:124`의 ClassCastException으로 RED를 확인했다.
- 2026-07-22: 보완 구현으로 Chat/Content 전체 탭 `SwipeRefreshLayout`에 `setOnChildScrollUpCallback`을 추가했고, Chat은 같은 Content state identity 재방출을 실패 복원으로 판정해 top-scroll을 막았다. Content 전체 탭은 refresh 실패 복원 시 `paginationErrorMessage`를 consume하기 전에 refresh flag를 해제해 top-scroll을 막았다. Home/Content 관련 ViewModel은 이전 terminal state가 있으면 실패 시 그 상태를 되돌리고 toast만 발행하도록 최소 변경했다.
- 2026-07-22: 보완 후 검증으로 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.ChatMainFragmentLayoutTest"`, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.content.ContentMainFragmentSourceTest"`, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.content.ContentRankingViewModelTest"`, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.home.HomeFollowingViewModelTest"`를 순차 실행했고 모두 BUILD SUCCESSFUL이다. 병렬 Gradle 실행 중 한 차례 `app/build/tmp/kotlin-classes/debug` 삭제 충돌이 발생해 이후 검증은 순차 실행으로 전환했다.
- 2026-07-22: 최종 확장 검증으로 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.content.*"`, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.home.*"`, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.*"`, `./gradlew :app:mergeDebugResources`, `./gradlew :app:compileDebugKotlin`, `./gradlew :app:ktlintCheck`, `git diff --check`를 실행했다. Gradle 명령은 모두 BUILD SUCCESSFUL이며 `git diff --check`는 출력 없이 통과했다. `ktlintCheck`는 새 테스트 긴 줄 위반을 한 차례 발견해 줄바꿈만 수정한 뒤 재실행 통과했고, `.editorconfig disabled_rules` deprecation warning은 기존 경고로 남아 있다.
- 2026-07-22: 리뷰 세션 재검토 결과 이전 fallback `Content` 오분류 blocker는 해결됐고 Critical/Important 이슈는 남아 있지 않다고 확인받았다. 비차단 잔여 위험으로 `ContentAllTabViewModelTest`가 복원 객체의 `assertSame`까지 고정하지는 않는다는 의견이 있었으나, 현재 source test와 ViewModel 테스트 조합은 저장소 테스트 관례 기준 충분하다고 판단되었다.
- 2026-07-22: 추가 리뷰 Important 1~4 대응으로 `PullRefreshRequestRaceSourceTest`와 `ContentAllTabViewModelTest` 회귀 테스트를 먼저 추가했다. production 변경 전 `PullRefreshRequestRaceSourceTest.kt:19`, `PullRefreshRequestRaceSourceTest.kt:32`, `ContentAllTabViewModelTest.kt:595`에서 RED를 확인했다.
- 2026-07-22: Home 추천/랭킹/팔로잉 및 Content 추천 ViewModel에 로딩 중 동일 첫 페이지 요청 무시와 request generation guard를 추가했다. `ContentAllTabViewModel`은 refresh 실패 복원 snapshot의 `isLoadingMore=false`, `paginationErrorMessage=null` 정규화를 적용했다. Home/Content/Chat Fragment는 refresh 시작 탭/필터를 저장하고 동일 소유자 terminal 응답에만 indicator 종료/top-scroll/실패 보존을 적용하도록 수정했다. Minor의 indicator 종료 지연은 전역 LoadingDialog 차단 대신 대상 요청 소유권 기반 종료로 완화했다.
- 2026-07-22: 후속 검증으로 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.PullRefreshRequestRaceSourceTest" --tests "kr.co.vividnext.sodalive.v2.main.content.ContentAllTabViewModelTest"`, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.home.HomeMainFragmentSourceTest" --tests "kr.co.vividnext.sodalive.v2.main.content.ContentMainFragmentSourceTest" --tests "kr.co.vividnext.sodalive.v2.main.chat.ChatMainFragmentLayoutTest"`, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.home.*"`, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.content.*"`, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.*"`, `./gradlew :app:compileDebugKotlin`, `./gradlew :app:mergeDebugResources`, `./gradlew :app:ktlintCheck`, `git diff --check`를 실행했다. Gradle 명령은 모두 BUILD SUCCESSFUL이며 `git diff --check`는 출력 없이 통과했다. ktlint의 `.editorconfig disabled_rules` deprecation warning과 일부 테스트 deprecated API warning은 기존 경고로 남아 있다.
- 2026-07-22: 재리뷰에서 남은 Content 전체 탭 refresh 실패 복원 top-scroll, Chat filter 전환 후 stale refresh ownership 문제를 추가 보완했다. RED는 `ContentMainFragmentSourceTest.kt:152`, `ChatMainFragmentLayoutTest.kt:205`에서 확인했다. Content 전체 탭은 복원 snapshot copy를 참조 동일성만이 아니라 동일 page/content 비교로도 실패 복원 판정하고, Chat은 filter 변경 시 기존 refresh ownership과 indicator를 취소한다.
- 2026-07-22: 최종 재검증으로 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.content.ContentMainFragmentSourceTest" --tests "kr.co.vividnext.sodalive.v2.main.chat.ChatMainFragmentLayoutTest"`, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.PullRefreshRequestRaceSourceTest" --tests "kr.co.vividnext.sodalive.v2.main.content.ContentAllTabViewModelTest"`, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.home.*"`, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.content.*"`, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.*"`, `./gradlew :app:compileDebugKotlin`, `./gradlew :app:mergeDebugResources`, `./gradlew :app:ktlintCheck`, `git diff --check`를 실행했다. 모두 BUILD SUCCESSFUL 또는 출력 없음으로 통과했다.
- 2026-07-22: 최종 재리뷰에서 Critical/Important 이슈가 남아 있지 않다고 확인받았다. Minor로 Content 전체 탭에서 성공 응답 데이터가 기존 page/content와 완전히 동일하면 top-scroll이 생략될 수 있다는 UX 미세 위험이 남았으나 blocker는 아니라고 판단되었다.
- 2026-07-22: 후속 리뷰 Important 대응으로 Content 랭킹/전체, Chat의 A→B→A 지연 응답 race 테스트와 Home 추천 refresh/follow 완료 순서 테스트를 먼저 추가했다. production 변경 전 targeted test 실행에서 `ContentRankingViewModelTest.kt:211`, `ContentAllTabViewModelTest.kt:793`, `ChatMainViewModelTest.kt:215`, `HomeRecommendationViewModelTest.kt:102`, `HomeMainFragmentLoginGuardSourceTest.kt:145` 실패로 RED를 확인했다.
- 2026-07-22: `Set<key>` 방식 대신 최신 first-page 요청만 나타내는 `activeFirstPageKey`로 세 ViewModel을 보완했고, Home 추천 follow 성공은 refresh 중에도 flag로 보존해 최종 Content에 반영하도록 수정했다. `HomeMainFragmentLoginGuardSourceTest`와 `PullRefreshRequestRaceSourceTest`는 새 source 계약에 맞춰 갱신했다.
- 2026-07-22: 후속 수정 검증으로 targeted 묶음 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.PullRefreshRequestRaceSourceTest" --tests "kr.co.vividnext.sodalive.v2.main.content.ContentRankingViewModelTest" --tests "kr.co.vividnext.sodalive.v2.main.content.ContentAllTabViewModelTest" --tests "kr.co.vividnext.sodalive.v2.main.chat.ChatMainViewModelTest" --tests "kr.co.vividnext.sodalive.v2.main.home.HomeRecommendationViewModelTest" --tests "kr.co.vividnext.sodalive.v2.main.home.HomeMainFragmentLoginGuardSourceTest"`와 전체 `./gradlew :app:testDebugUnitTest`를 실행했고 모두 BUILD SUCCESSFUL이다. Reviewer gate에서도 blocker 없음으로 확인받았다.
- 2026-07-22: 추가 리뷰 Important 2건 대응으로 `ContentRankingViewModelTest`에 캐시 반환 경로 active key 고착 회귀 테스트를 추가하고, `HomeRecommendationViewModelTest`에 refresh/follow 실패 완료 순서별 최신 Content 보존 테스트를 추가했다. production 변경 전 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.content.ContentRankingViewModelTest" --tests "kr.co.vividnext.sodalive.v2.main.home.HomeRecommendationViewModelTest"` 실행에서 `ContentRankingViewModelTest.kt:254` AssertionError, `HomeRecommendationViewModelTest.kt:144` ClassCastException, `HomeRecommendationViewModelTest.kt:132` AssertionError로 RED를 확인했다.
- 2026-07-22: `ContentRankingViewModel`은 캐시 반환 시 `latestRequestId`를 증가시키고 `activeFirstPageKey`를 비워 진행 중 stale 응답과 active key를 무효화하도록 수정했다. `HomeRecommendationViewModel`은 fallback Content에도 follow 완료 상태를 반영하고, follow 실패는 현재 추천 화면을 `Error`로 덮지 않고 toast만 발행하도록 수정했다.
- 2026-07-22: 후속 검증으로 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.content.ContentRankingViewModelTest" --tests "kr.co.vividnext.sodalive.v2.main.home.HomeRecommendationViewModelTest"`, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.PullRefreshRequestRaceSourceTest" --tests "kr.co.vividnext.sodalive.v2.main.content.ContentRankingViewModelTest" --tests "kr.co.vividnext.sodalive.v2.main.home.HomeRecommendationViewModelTest"`, 전체 `./gradlew :app:testDebugUnitTest`, `./gradlew :app:ktlintCheck`, `git diff --check`를 실행했다. Gradle 명령은 모두 BUILD SUCCESSFUL이며 `git diff --check`는 출력 없이 통과했다. Reviewer gate도 blocker 없음으로 확인받았다.
- 2026-07-22: Minor 2건 대응으로 `ContentAllTabViewModelTest`와 `ChatMainViewModelTest`의 A→B→A 테스트에 후속 pagination 요청 조건 검증을 추가했다. 이후 `lastRenderedContentAllState`, `stopHomeRefreshIndicators()`, `stopContentRefreshIndicators()`, 호출자 없는 `onStaleResponse` 파라미터를 제거하고 관련 source test 기대값을 갱신했다.
- 2026-07-22: Minor 반영 검증으로 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.content.ContentAllTabViewModelTest" --tests "kr.co.vividnext.sodalive.v2.main.chat.ChatMainViewModelTest"`, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.content.ContentAllTabViewModelTest" --tests "kr.co.vividnext.sodalive.v2.main.chat.ChatMainViewModelTest" --tests "kr.co.vividnext.sodalive.v2.main.content.ContentMainFragmentSourceTest" --tests "kr.co.vividnext.sodalive.v2.main.home.HomeMainFragmentSourceTest"`, 전체 `./gradlew :app:testDebugUnitTest`, `./gradlew :app:ktlintCheck`, `git diff --check`를 실행했다. Gradle 명령은 모두 BUILD SUCCESSFUL이며 `git diff --check`는 출력 없이 통과했다. 기존 `.editorconfig disabled_rules` deprecation warning과 테스트 deprecated API warning은 남아 있다.
- 2026-07-22: 추가 비차단 Minor 대응으로 `ContentMainFragmentSourceTest`에서 `lastRenderedContentAllState` 부재를 고정하던 과거 구현 문자열 assertion 3개를 제거했다. 또한 `code-style.md`의 신규 UI 레이아웃/표현 속성 테스트 금지 규칙에 맞춰 Home/Content/Chat의 신규 refresh XML class/ID/hierarchy assertion 테스트를 제거하고, refresh dispatch와 상태 전이 source 검증은 유지했다.
- 2026-07-22: 추가 Minor 정리 검증으로 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.content.ContentMainFragmentSourceTest" --tests "kr.co.vividnext.sodalive.v2.main.chat.ChatMainFragmentLayoutTest" --tests "kr.co.vividnext.sodalive.v2.main.home.HomeMainFragmentSourceTest"`, 전체 `./gradlew :app:testDebugUnitTest`, `./gradlew :app:ktlintCheck`, `git diff --check`를 실행했다. Gradle 명령은 모두 BUILD SUCCESSFUL이며 `git diff --check`는 출력 없이 통과했다. 제거 대상 문자열 검색 `rg -n "lastRenderedContentAllState|content layout has refresh containers|chat layout wraps room list|home layout has refresh containers" app/src/test/java/kr/co/vividnext/sodalive/v2/main`도 출력이 없었다.
- 2026-07-22: 추가 재검토에서 Chat/Home 테스트에 남아 있던 신규 refresh hierarchy assertion과 unused helper를 더 제거했다. 제거 대상 문자열 검색 `rg -n "swipe_home_recommendation|swipe_home_ranking|swipe_home_following|SwipeRefreshLayout|swipe_chat_rooms|findViewByName|content layout has refresh containers|home layout has refresh containers|chat layout wraps room list|lastRenderedContentAllState" app/src/test/java/kr/co/vividnext/sodalive/v2/main`은 출력이 없었다. 관련 targeted test 묶음은 BUILD SUCCESSFUL이다. 이후 전체 `./gradlew :app:testDebugUnitTest`는 `CreatorChannelHomeViewModelTest.kt:285`의 공유 상태성 실패가 1회 발생했으나 같은 명령 단독 재실행은 BUILD SUCCESSFUL이다. `./gradlew :app:ktlintCheck`는 BUILD SUCCESSFUL, `git diff --check`는 출력 없이 통과했다. 기존 `.editorconfig disabled_rules` deprecation warning과 테스트 deprecated API warning은 남아 있다.

View File

@@ -169,6 +169,12 @@
- 콘텐츠 `랭킹` 탭에서 내부 ranking type이 여러 개일 경우 현재 선택 type 유지 방식은 구현 계획에서 실제 ViewModel 상태를 기준으로 확정한다. - 콘텐츠 `랭킹` 탭에서 내부 ranking type이 여러 개일 경우 현재 선택 type 유지 방식은 구현 계획에서 실제 ViewModel 상태를 기준으로 확정한다.
- refresh indicator 색상은 별도 디자인 요구가 없으므로 기존 theme 기본값을 우선 사용하고, 필요 시 구현 계획에서 기존 앱 색상과 맞춘다. - refresh indicator 색상은 별도 디자인 요구가 없으므로 기존 theme 기본값을 우선 사용하고, 필요 시 구현 계획에서 기존 앱 색상과 맞춘다.
### 2026-07-22 구현 계획 확정
-`랭킹` 탭은 `HomeCreatorRankingViewModel.loadCreatorRankings()``HomeCreatorRankingRepository.getCreatorRankings()` 흐름을 새로고침 대상으로 사용한다.
-`팔로잉` 탭은 기존 `ensureV2Access(AccessRequirement.Login)` 진입 가드를 유지한다. 로그인되지 않은 상태에서는 팔로잉 탭으로 전환되지 않으므로 새로고침도 노출하지 않는다.
- 콘텐츠 `랭킹` 탭은 `ContentRankingViewModel.selectedTypeLiveData`의 현재 `AudioRankingType``force = true`로 다시 호출한다.
- refresh indicator 색상은 별도 디자인 요구가 없으므로 `SwipeRefreshLayout` 기본 theme 색상을 사용한다.
--- ---
## 12. References ## 12. References
@@ -188,3 +194,16 @@
- 2026-07-22: `ContentAllTabViewModel`, `MainContentAllTabApi`, `ContentOverviewViewModel`, 관련 UI state 구조를 codegraph로 확인해 첫 페이지 재조회와 pagination 분리 요구사항을 문서화했다. - 2026-07-22: `ContentAllTabViewModel`, `MainContentAllTabApi`, `ContentOverviewViewModel`, 관련 UI state 구조를 codegraph로 확인해 첫 페이지 재조회와 pagination 분리 요구사항을 문서화했다.
- 2026-07-22: 사용자 확인에 따라 적용 범위를 메인 홈 `추천/랭킹/팔로잉`, 콘텐츠 `추천/랭킹/전체`, 대화 `전체/AI 채팅/DM` 현재 선택 상태별 새로고침으로 확정했다. - 2026-07-22: 사용자 확인에 따라 적용 범위를 메인 홈 `추천/랭킹/팔로잉`, 콘텐츠 `추천/랭킹/전체`, 대화 `전체/AI 채팅/DM` 현재 선택 상태별 새로고침으로 확정했다.
- 2026-07-22: 이번 단계는 PRD 작성만 수행했으며 구현/빌드/테스트는 실행하지 않는다. - 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이다.