diff --git a/core/data/src/main/java/com/afternote/core/data/repoimpl/UserReceiverRepositoryImpl.kt b/core/data/src/main/java/com/afternote/core/data/repoimpl/UserReceiverRepositoryImpl.kt index 8b312b4b96..29b588b632 100644 --- a/core/data/src/main/java/com/afternote/core/data/repoimpl/UserReceiverRepositoryImpl.kt +++ b/core/data/src/main/java/com/afternote/core/data/repoimpl/UserReceiverRepositoryImpl.kt @@ -5,6 +5,7 @@ import com.afternote.core.common.result.runCatchingCancellable import com.afternote.core.data.mapper.delivery.toRequestDto import com.afternote.core.data.mapper.user.toDomain import com.afternote.core.domain.error.ReceiverRequestRejectedException +import com.afternote.core.domain.model.ReceiverListState import com.afternote.core.domain.repository.UserReceiverRepository import com.afternote.core.domain.repository.auth.AuthRepository import com.afternote.core.model.delivery.DeliveryConditionItem @@ -29,6 +30,7 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.flow.update import javax.inject.Inject import javax.inject.Singleton @@ -55,47 +57,54 @@ internal class UserReceiverRepositoryImpl ) : UserReceiverRepository { private val receiverRefreshRevision = MutableStateFlow(0L) - // 조회 실패를 예외로 흘리면 구독 중인 화면이 미처리 예외로 죽는다. 일반적인 일시 실패는 같은 - // 로그인 구간에서 이 collector가 마지막으로 성공한 목록으로 낮춘다. 캐시를 flow 안에 두는 이유는 - // 저장소 인스턴스보다 수명이 짧은 «로그인 구간 + collector»에 귀속해 계정 사이에 섞이지 않게 하기 위함이다. @OptIn(ExperimentalCoroutinesApi::class) - override val receiverListFlow: Flow> = + override val receiverListStateFlow: Flow = authRepository.isLoggedIn .distinctUntilChanged() .flatMapLatest { loggedIn -> if (!loggedIn) { - flowOf(emptyList()) + flowOf(ReceiverListState.SignedOut) } else { receiverListForAuthenticatedSession() } } - private fun receiverListForAuthenticatedSession(): Flow> = + // 기존 소비자는 로딩 중 방출을 받지 않으며 실패 시 같은 구독의 마지막 목록을 받는다. + override val receiverListFlow: Flow> = + receiverListStateFlow.mapNotNull { state -> + when (state) { + is ReceiverListState.Loading -> null + is ReceiverListState.Success -> state.receivers + is ReceiverListState.Failure -> state.previousReceivers.orEmpty() + ReceiverListState.SignedOut -> emptyList() + } + } + + private fun receiverListForAuthenticatedSession(): Flow = flow { - var lastKnownReceivers = emptyList() + // 저장소가 아닌 로그인 구간 + collector에 귀속하여 계정 간 캐시 공유를 막는다. + var lastKnownReceivers: List? = null receiverRefreshRevision.collect { - val receivers = + emit(ReceiverListState.Loading(lastKnownReceivers)) + val state = runCatchingCancellable { getReceivers() } - .onFailure { - // 이 flow 가 하는 일이 «예외를 삼켜 화면을 살리는 것» 이라, 삼킨 뒤의 - // 기록이 이 실패 경로의 유일한 신호다. logcat 은 실기에서 회수되지 않으므로 - // 크래시 리포팅 창구로 남긴다. 취소 제외·문구 redaction 은 리포터 정책이 담당한다. - errorReporter.recordFailure( - throwable = it, - attributes = mapOf(KEY_STAGE to STAGE_RECEIVER_LIST), - ) - }.fold( - onSuccess = { it }, + .fold( + onSuccess = { receivers -> + lastKnownReceivers = receivers + ReceiverListState.Success(receivers) + }, onFailure = { failure -> + errorReporter.recordFailure( + throwable = failure, + attributes = mapOf(KEY_STAGE to STAGE_RECEIVER_LIST), + ) if (failure is ApiException && failure.status == UNAUTHORIZED_STATUS) { - emptyList() - } else { - lastKnownReceivers + lastKnownReceivers = null } + ReceiverListState.Failure(lastKnownReceivers) }, ) - lastKnownReceivers = receivers - emit(receivers) + emit(state) } } diff --git a/core/data/src/test/java/com/afternote/core/data/repoimpl/UserRepositoryImplTest.kt b/core/data/src/test/java/com/afternote/core/data/repoimpl/UserRepositoryImplTest.kt index 91d255bf3b..dc50c7ffc0 100644 --- a/core/data/src/test/java/com/afternote/core/data/repoimpl/UserRepositoryImplTest.kt +++ b/core/data/src/test/java/com/afternote/core/data/repoimpl/UserRepositoryImplTest.kt @@ -1,6 +1,7 @@ package com.afternote.core.data.repoimpl import com.afternote.core.common.reporting.ErrorReporter +import com.afternote.core.domain.model.ReceiverListState import com.afternote.core.domain.repository.UserReceiverRepository import com.afternote.core.domain.repository.UserRepository import com.afternote.core.domain.testing.FakeAuthRepository @@ -28,7 +29,10 @@ import com.afternote.core.network.dto.delivery.ReceiverDeliveryConditionUpdateRe import com.afternote.core.network.model.ApiException import com.afternote.core.network.model.BaseResponse import com.afternote.core.network.service.UserApiService +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.first @@ -37,6 +41,7 @@ import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withTimeout import org.junit.Assert.assertEquals import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue import org.junit.Test import java.net.UnknownHostException @@ -464,6 +469,223 @@ class UserRepositoryImplTest { } } + @Test + fun `receiverListStateFlow - 로딩 뒤 성공한 빈 목록을 명시한다`() = + runBlocking { + val response = CompletableDeferred>>() + val repository = repository(onGetReceivers = { response.await() }) + val emissions = Channel(capacity = Channel.UNLIMITED) + val collector = + launch(start = CoroutineStart.UNDISPATCHED) { + repository.receiverListStateFlow.collect { emissions.send(it) } + } + + try { + assertEquals(ReceiverListState.Loading(null), emissions.nextState()) + response.complete(dataResponse(emptyList())) + + assertEquals(ReceiverListState.Success(emptyList()), emissions.nextState()) + assertTrue(errorReporter.writtenFailures.isEmpty()) + } finally { + collector.cancelAndJoin() + } + } + + @Test + fun `receiverListStateFlow - 첫 실패는 빈 목록 성공과 구별되고 같은 구독에서 재시도한다`() = + runBlocking { + var requestCount = 0 + val repository = + repository( + onGetReceivers = { + requestCount++ + if (requestCount == 1) throw UnknownHostException("첫 조회 실패") + dataResponse(listOf(receiverDto("재시도 성공"))) + }, + ) + val emissions = Channel(capacity = Channel.UNLIMITED) + val collector = + launch(start = CoroutineStart.UNDISPATCHED) { + repository.receiverListStateFlow.collect { emissions.send(it) } + } + + try { + assertEquals(ReceiverListState.Loading(null), emissions.nextState()) + assertEquals(ReceiverListState.Failure(null), emissions.nextState()) + assertEquals(1, errorReporter.writtenFailures.size) + + val contract: UserReceiverRepository = repository + contract.refreshReceiverList() + + assertEquals(ReceiverListState.Loading(null), emissions.nextState()) + val recovered = emissions.nextState() as ReceiverListState.Success + assertEquals(listOf("재시도 성공"), recovered.receivers.map { it.name }) + assertEquals(2, requestCount) + assertEquals(1, errorReporter.writtenFailures.size) + } finally { + collector.cancelAndJoin() + } + } + + @Test + fun `receiverListStateFlow - 갱신 로딩과 실패에 마지막 성공 목록을 보존한다`() = + runBlocking { + var requestCount = 0 + val repository = + repository( + onGetReceivers = { + requestCount++ + if (requestCount > 1) throw UnknownHostException("갱신 실패") + dataResponse(listOf(receiverDto("기존 수신자"))) + }, + ) + val emissions = Channel(capacity = Channel.UNLIMITED) + val collector = + launch(start = CoroutineStart.UNDISPATCHED) { + repository.receiverListStateFlow.collect { emissions.send(it) } + } + + try { + assertEquals(ReceiverListState.Loading(null), emissions.nextState()) + val loaded = emissions.nextState() as ReceiverListState.Success + assertEquals(listOf("기존 수신자"), loaded.receivers.map { it.name }) + + repository.refreshReceiverList() + + assertEquals(ReceiverListState.Loading(loaded.receivers), emissions.nextState()) + assertEquals(ReceiverListState.Failure(loaded.receivers), emissions.nextState()) + assertEquals(2, requestCount) + assertEquals(1, errorReporter.writtenFailures.size) + } finally { + collector.cancelAndJoin() + } + } + + @Test + fun `receiverListStateFlow - 401 뒤에는 다음 로딩과 일시 실패에도 폐기한 캐시를 내지 않는다`() = + runBlocking { + var requestCount = 0 + val repository = + repository( + onGetReceivers = { + requestCount++ + when (requestCount) { + 1 -> dataResponse(listOf(receiverDto("권한이 끝난 계정"))) + 2 -> throw ApiException(401, 401, "인증 만료", "인증 만료") + else -> throw UnknownHostException("401 이후 네트워크 실패") + } + }, + ) + val emissions = Channel(capacity = Channel.UNLIMITED) + val collector = + launch(start = CoroutineStart.UNDISPATCHED) { + repository.receiverListStateFlow.collect { emissions.send(it) } + } + + try { + assertEquals(ReceiverListState.Loading(null), emissions.nextState()) + val loaded = emissions.nextState() as ReceiverListState.Success + assertEquals(listOf("권한이 끝난 계정"), loaded.receivers.map { it.name }) + + repository.refreshReceiverList() + + assertEquals(ReceiverListState.Loading(loaded.receivers), emissions.nextState()) + assertEquals(ReceiverListState.Failure(null), emissions.nextState()) + + repository.refreshReceiverList() + + assertEquals(ReceiverListState.Loading(null), emissions.nextState()) + assertEquals(ReceiverListState.Failure(null), emissions.nextState()) + assertEquals(3, requestCount) + assertEquals(2, errorReporter.writtenFailures.size) + } finally { + collector.cancelAndJoin() + } + } + + @Test + fun `receiverListStateFlow - 로그아웃과 새 로그인 사이에 이전 계정 캐시를 격리한다`() = + runBlocking { + var requestCount = 0 + val authRepository = receiverAuthRepository(loggedIn = true) + val repository = + repository( + authRepository = authRepository, + onGetReceivers = { + requestCount++ + if (requestCount > 1) throw UnknownHostException("새 계정 조회 실패") + dataResponse(listOf(receiverDto("이전 계정 수신자"))) + }, + ) + val emissions = Channel(capacity = Channel.UNLIMITED) + val collector = + launch(start = CoroutineStart.UNDISPATCHED) { + repository.receiverListStateFlow.collect { emissions.send(it) } + } + + try { + assertEquals(ReceiverListState.Loading(null), emissions.nextState()) + val loaded = emissions.nextState() as ReceiverListState.Success + assertEquals(listOf("이전 계정 수신자"), loaded.receivers.map { it.name }) + + authRepository.loggedIn = false + + assertEquals(ReceiverListState.SignedOut, emissions.nextState()) + assertEquals(1, requestCount) + + authRepository.loggedIn = true + + assertEquals(ReceiverListState.Loading(null), emissions.nextState()) + assertEquals(ReceiverListState.Failure(null), emissions.nextState()) + assertEquals(2, requestCount) + } finally { + collector.cancelAndJoin() + } + } + + @Test + fun `receiverListStateFlow - 수집 취소를 API에 전파하고 실패 상태와 리포트를 만들지 않는다`() = + runBlocking { + val requestStarted = CompletableDeferred() + val cancelledRequest = CompletableDeferred() + val repository = + repository( + onGetReceivers = { + requestStarted.complete(Unit) + try { + awaitCancellation() + } catch (cancellation: CancellationException) { + cancelledRequest.complete(cancellation) + throw cancellation + } + }, + ) + val emissions = Channel(capacity = Channel.UNLIMITED) + val collector = + launch(start = CoroutineStart.UNDISPATCHED) { + repository.receiverListStateFlow.collect { emissions.send(it) } + } + + try { + assertEquals(ReceiverListState.Loading(null), emissions.nextState()) + withTimeout(TEST_TIMEOUT_MILLIS) { requestStarted.await() } + val cancellation = CancellationException("화면 수집 종료") + + collector.cancel(cancellation) + withTimeout(TEST_TIMEOUT_MILLIS) { collector.join() } + + val propagated = withTimeout(TEST_TIMEOUT_MILLIS) { cancelledRequest.await() } + assertEquals(cancellation.message, propagated.message) + assertTrue(collector.isCancelled) + assertTrue(emissions.tryReceive().isFailure) + assertTrue(errorReporter.writtenFailures.isEmpty()) + } finally { + collector.cancelAndJoin() + } + } + + private suspend fun Channel.nextState(): ReceiverListState = withTimeout(TEST_TIMEOUT_MILLIS) { receive() } + private fun receiverAuthRepository(loggedIn: Boolean): FakeAuthRepository = FakeAuthRepository.strict(loggedIn = loggedIn).apply { onIsLoggedIn = { loggedInState } diff --git a/core/domain/src/main/java/com/afternote/core/domain/model/ReceiverListState.kt b/core/domain/src/main/java/com/afternote/core/domain/model/ReceiverListState.kt new file mode 100644 index 0000000000..b0522284b2 --- /dev/null +++ b/core/domain/src/main/java/com/afternote/core/domain/model/ReceiverListState.kt @@ -0,0 +1,22 @@ +package com.afternote.core.domain.model + +import com.afternote.core.model.user.Receiver + +/** 수신자 목록의 조회 결과. 캐시는 로그인 구간과 구독에 귀속된다. */ +sealed interface ReceiverListState { + /** null은 이 구독에서 아직 성공한 조회가 없다는 뜻이다. */ + data class Loading( + val previousReceivers: List?, + ) : ReceiverListState + + data class Success( + val receivers: List, + ) : ReceiverListState + + /** 401은 이전 목록을 폐기한다. 원래 실패는 데이터 계층의 ErrorReporter가 기록한다. */ + data class Failure( + val previousReceivers: List?, + ) : ReceiverListState + + data object SignedOut : ReceiverListState +} diff --git a/core/domain/src/main/java/com/afternote/core/domain/repository/UserReceiverRepository.kt b/core/domain/src/main/java/com/afternote/core/domain/repository/UserReceiverRepository.kt index 77080cba55..9095c0bef0 100644 --- a/core/domain/src/main/java/com/afternote/core/domain/repository/UserReceiverRepository.kt +++ b/core/domain/src/main/java/com/afternote/core/domain/repository/UserReceiverRepository.kt @@ -1,5 +1,6 @@ package com.afternote.core.domain.repository +import com.afternote.core.domain.model.ReceiverListState import com.afternote.core.model.delivery.DeliveryConditionItem import com.afternote.core.model.delivery.ReceiverDeliveryConditions import com.afternote.core.model.user.Receiver @@ -20,10 +21,13 @@ interface UserReceiverRepository { * * **빈 목록은 «수신인이 없음» 을 뜻하지 않는다 — 실패도 같은 모양으로 들어온다.** 화면이 이 값만 보고 * «등록된 수신인이 없어요» 를 확정하면 오프라인에서 그 거짓을 사용자에게 보여 주게 된다. 실패와 없음을 - * 가르려면 별도 신호가 필요하고, 그 표기는 #714 범위다. + * 가르려면 [receiverListStateFlow]를 사용한다. */ val receiverListFlow: Flow> + /** 로딩·실패·실제 빈 목록을 구분하며, 기존 목록 전용 구독과 같은 갱신 신호를 사용한다. */ + val receiverListStateFlow: Flow + /** * 현재 구독 중인 수신자 목록을 다시 조회하도록 요청한다. 조회 결과와 세션별 실패 처리는 * [receiverListFlow] 계약을 따르며, 새 구독은 이 호출 없이도 최신 목록을 조회한다. diff --git a/core/domain/src/testFixtures/kotlin/com/afternote/core/domain/testing/FakeUserReceiverRepository.kt b/core/domain/src/testFixtures/kotlin/com/afternote/core/domain/testing/FakeUserReceiverRepository.kt index 1de30c816e..342f4de91c 100644 --- a/core/domain/src/testFixtures/kotlin/com/afternote/core/domain/testing/FakeUserReceiverRepository.kt +++ b/core/domain/src/testFixtures/kotlin/com/afternote/core/domain/testing/FakeUserReceiverRepository.kt @@ -1,5 +1,6 @@ package com.afternote.core.domain.testing +import com.afternote.core.domain.model.ReceiverListState import com.afternote.core.domain.repository.UserReceiverRepository import com.afternote.core.model.delivery.DeliveryConditionItem import com.afternote.core.model.delivery.ReceiverDeliveryConditions @@ -8,6 +9,7 @@ import com.afternote.core.model.user.ReceiverCreated import com.afternote.core.model.user.ReceiverDetail import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.map import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.CopyOnWriteArrayList import java.util.concurrent.atomic.AtomicInteger @@ -32,6 +34,7 @@ class FakeUserReceiverRepository( var onGetReceiverDeliveryConditions: (suspend (Long) -> ReceiverDeliveryConditions)? = null, var onUpdateReceiverDeliveryConditions: (suspend (Long, List) -> ReceiverDeliveryConditions)? = null, var onRefreshReceiverList: (() -> Unit)? = null, + var onReceiverListStateFlow: (() -> Flow)? = null, ) : UserReceiverRepository { val receiverState = MutableStateFlow(receivers.toList()) val receiverDetails = ConcurrentHashMap(receiverDetails) @@ -69,6 +72,9 @@ class FakeUserReceiverRepository( onRefreshReceiverList?.invoke() } + override val receiverListStateFlow: Flow + get() = onReceiverListStateFlow?.invoke() ?: receiverListFlow.map(ReceiverListState::Success) + override suspend fun getReceivers(): List { getReceiversCounter.incrementAndGet() onGetReceivers?.let { return it() } diff --git a/core/ui/src/main/kotlin/com/afternote/core/ui/receiver/ReceiverSelectScreen.kt b/core/ui/src/main/kotlin/com/afternote/core/ui/receiver/ReceiverSelectScreen.kt index a8b2736ecd..8bcc7dede2 100644 --- a/core/ui/src/main/kotlin/com/afternote/core/ui/receiver/ReceiverSelectScreen.kt +++ b/core/ui/src/main/kotlin/com/afternote/core/ui/receiver/ReceiverSelectScreen.kt @@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.text.input.rememberTextFieldState @@ -165,6 +166,8 @@ private fun ReceiverSelectScaffold( .padding(top = 10.dp), ) { val searchState = rememberTextFieldState() + // 로딩 슬롯이 목록을 잠시 대체해도 기존 스크롤 위치는 화면 수명 동안 유지한다. + val listState = rememberLazyListState() AfternoteTextField( state = searchState, @@ -179,6 +182,7 @@ private fun ReceiverSelectScaffold( ReceiverSelectList( receivers = receivers, searchQuery = searchState.text.toString(), + listState = listState, selectedReceiverIds = selectedReceiverIds, onReceiverToggle = onReceiverToggle, ) @@ -191,10 +195,10 @@ private fun ReceiverSelectScaffold( private fun ReceiverSelectList( receivers: List, searchQuery: String, + listState: LazyListState, selectedReceiverIds: List, onReceiverToggle: (Long) -> Unit, ) { - val listState = rememberLazyListState() val coroutineScope = rememberCoroutineScope() var selectedConsonant by remember { mutableStateOf(null) } diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/navigation/SettingNavHost.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/navigation/SettingNavHost.kt index fb219d9b83..65cf11471c 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/navigation/SettingNavHost.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/navigation/SettingNavHost.kt @@ -25,8 +25,7 @@ import com.afternote.feature.setting.presentation.screen.PassKeyScreen import com.afternote.feature.setting.presentation.screen.ProfileEditScreen import com.afternote.feature.setting.presentation.screen.PushNotificationScreen import com.afternote.feature.setting.presentation.screen.ReceiverEditScreen -import com.afternote.feature.setting.presentation.screen.ReceiverListScreen -import com.afternote.feature.setting.presentation.screen.ReceiverManageScreen +import com.afternote.feature.setting.presentation.screen.ReceiverListRouteContent import com.afternote.feature.setting.presentation.screen.ReceiverRegisterScreen import com.afternote.feature.setting.presentation.screen.SettingScreen import com.afternote.feature.setting.presentation.screen.WithdrawConfirmScreen @@ -141,21 +140,16 @@ public fun SettingNavHost( viewModel.onIntent(ReceiverListIntent.ObservationStarted) onStopOrDispose { viewModel.onIntent(ReceiverListIntent.ObservationStopped) } } - if (route.selectForDeliveryConditions) { - ReceiverListScreen( - receivers = uiState.receivers, - onBackClick = actions::popBack, - onConfirmClick = { receiver -> - actions.onDeliveryConditionsRecipientSelected(receiver.receiverId) - }, - ) - } else { - ReceiverManageScreen( - receivers = uiState.receivers, - onBackClick = actions::popBack, - onReceiverClick = actions::onRecipientEditClick, - ) - } + ReceiverListRouteContent( + uiState = uiState, + selectForDeliveryConditions = route.selectForDeliveryConditions, + onBackClick = actions::popBack, + onRetry = { viewModel.onIntent(ReceiverListIntent.Retry) }, + onConfirmClick = { receiver -> + actions.onDeliveryConditionsRecipientSelected(receiver.receiverId) + }, + onReceiverClick = actions::onRecipientEditClick, + ) } entry { diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/screen/ReceiverListRouteContent.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/screen/ReceiverListRouteContent.kt new file mode 100644 index 0000000000..5f8b9b8d7f --- /dev/null +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/screen/ReceiverListRouteContent.kt @@ -0,0 +1,94 @@ +package com.afternote.feature.setting.presentation.screen + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.afternote.core.model.setting.ReceiverListItem +import com.afternote.core.ui.loading.LoadingBody +import com.afternote.feature.setting.presentation.R +import com.afternote.feature.setting.presentation.viewmodel.ReceiverListLoadState +import com.afternote.feature.setting.presentation.viewmodel.ReceiverListUiState + +/** 조회 상태는 선택·관리 두 진입에서 동일하게 소비하고, 성공 시 기존 화면 동작을 유지한다. */ +@Composable +internal fun ReceiverListRouteContent( + uiState: ReceiverListUiState, + selectForDeliveryConditions: Boolean, + onBackClick: () -> Unit, + onRetry: () -> Unit, + onConfirmClick: (ReceiverListItem) -> Unit, + onReceiverClick: (Long) -> Unit, + modifier: Modifier = Modifier, +) { + val isInitialLoading = uiState.loadState == ReceiverListLoadState.Loading && uiState.receivers.isEmpty() + val listReplacement: (@Composable () -> Unit)? = + if (isInitialLoading || uiState.loadState == ReceiverListLoadState.InitialFailure) { + { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + if (isInitialLoading) { + LoadingBody() + } else { + ReceiverListRetry(onRetry = onRetry) + } + } + } + } else { + null + } + + Column(modifier = modifier.fillMaxSize()) { + Box(Modifier.weight(1f)) { + if (selectForDeliveryConditions) { + ReceiverListScreen( + receivers = uiState.receivers, + onBackClick = onBackClick, + onConfirmClick = onConfirmClick, + listReplacement = listReplacement, + ) + } else { + ReceiverManageScreen( + receivers = uiState.receivers, + onBackClick = onBackClick, + onReceiverClick = onReceiverClick, + listReplacement = listReplacement, + ) + } + } + when (uiState.loadState) { + ReceiverListLoadState.Loading -> { + if (!isInitialLoading) LinearProgressIndicator(Modifier.fillMaxWidth()) + } + + ReceiverListLoadState.RefreshFailure -> { + ReceiverListRetry(onRetry = onRetry) + } + + ReceiverListLoadState.Ready, ReceiverListLoadState.InitialFailure -> { + Unit + } + } + } +} + +@Composable +private fun ReceiverListRetry(onRetry: () -> Unit) { + Column( + modifier = Modifier.fillMaxWidth().padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text(stringResource(R.string.setting_receiver_list_load_failed)) + TextButton(onClick = onRetry) { + Text(stringResource(R.string.setting_receiver_list_retry)) + } + } +} diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/screen/ReceiverListScreen.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/screen/ReceiverListScreen.kt index 390421ebce..4b91b2365e 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/screen/ReceiverListScreen.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/screen/ReceiverListScreen.kt @@ -21,11 +21,12 @@ import com.afternote.core.ui.receiver.ReceiverSelectScreen * 시의 선택 플로우에만 쓰인다. */ @Composable -fun ReceiverListScreen( +internal fun ReceiverListScreen( receivers: List, onBackClick: () -> Unit, onConfirmClick: (ReceiverListItem) -> Unit, modifier: Modifier = Modifier, + listReplacement: (@Composable () -> Unit)? = null, ) { var selectedId by remember { mutableStateOf(null) } @@ -37,7 +38,7 @@ fun ReceiverListScreen( remember(receivers) { receivers.map { ReceiverSelectItem(id = it.receiverId, name = it.name, relation = it.relation) } }, - selectedReceiverId = selectedId, + selectedReceiverId = selectedId?.takeIf { id -> receivers.any { it.receiverId == id } }, onReceiverToggle = { receiverId -> selectedId = if (selectedId == receiverId) null else receiverId }, @@ -46,5 +47,6 @@ fun ReceiverListScreen( receivers.find { it.receiverId == receiverId }?.let(onConfirmClick) }, modifier = modifier, + listReplacement = listReplacement, ) } diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/screen/ReceiverManageScreen.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/screen/ReceiverManageScreen.kt index adc1a5137e..629481da18 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/screen/ReceiverManageScreen.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/screen/ReceiverManageScreen.kt @@ -1,11 +1,13 @@ package com.afternote.feature.setting.presentation.screen import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier @@ -26,12 +28,14 @@ import com.afternote.feature.setting.presentation.R * 다루는 관리 화면으로, 행 탭이 곧바로 수신자 수정 화면(#595)으로 이동한다. */ @Composable -fun ReceiverManageScreen( +internal fun ReceiverManageScreen( receivers: List, onBackClick: () -> Unit, onReceiverClick: (Long) -> Unit, modifier: Modifier = Modifier, + listReplacement: (@Composable () -> Unit)? = null, ) { + val listState = rememberLazyListState() Scaffold( modifier = modifier, containerColor = Color.Transparent, @@ -42,18 +46,24 @@ fun ReceiverManageScreen( ) }, ) { innerPadding -> - LazyColumn( - modifier = - Modifier - .fillMaxSize() - .padding(innerPadding) - .padding(horizontal = 20.dp), - ) { - items(receivers, key = { it.receiverId }) { receiver -> - ReceiverManageRow( - receiver = receiver, - onClick = { onReceiverClick(receiver.receiverId) }, - ) + Box(Modifier.fillMaxSize().padding(innerPadding)) { + if (listReplacement != null) { + listReplacement() + } else { + LazyColumn( + state = listState, + modifier = + Modifier + .fillMaxSize() + .padding(horizontal = 20.dp), + ) { + items(receivers, key = { it.receiverId }) { receiver -> + ReceiverManageRow( + receiver = receiver, + onClick = { onReceiverClick(receiver.receiverId) }, + ) + } + } } } } diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/viewmodel/ReceiverListContract.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/viewmodel/ReceiverListContract.kt index 80f6856170..7413f8cda7 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/viewmodel/ReceiverListContract.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/viewmodel/ReceiverListContract.kt @@ -1,5 +1,6 @@ package com.afternote.feature.setting.presentation.viewmodel +import com.afternote.core.domain.model.ReceiverListState import com.afternote.core.model.setting.ReceiverListItem import com.afternote.core.ui.mvi.MviIntent import com.afternote.core.ui.mvi.ReducerEvent @@ -8,15 +9,22 @@ import com.afternote.core.ui.mvi.UiState internal sealed interface ReceiverListIntent : MviIntent { data object ObservationStarted : ReceiverListIntent + data object Retry : ReceiverListIntent + data object ObservationStopped : ReceiverListIntent } internal data class ReceiverListUiState( val receivers: List = emptyList(), + val loadState: ReceiverListLoadState = ReceiverListLoadState.Loading, ) : UiState +internal enum class ReceiverListLoadState { Loading, Ready, InitialFailure, RefreshFailure } + internal sealed interface ReceiverListReducerEvent : ReducerEvent { - data class ReceiversChanged( - val receivers: List, + data object RetryStarted : ReceiverListReducerEvent + + data class ResultChanged( + val result: ReceiverListState, ) : ReceiverListReducerEvent } diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/viewmodel/ReceiverListViewModel.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/viewmodel/ReceiverListViewModel.kt index 36f8c83e14..75ea729a02 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/viewmodel/ReceiverListViewModel.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/viewmodel/ReceiverListViewModel.kt @@ -1,6 +1,7 @@ package com.afternote.feature.setting.presentation.viewmodel import androidx.lifecycle.viewModelScope +import com.afternote.core.domain.model.ReceiverListState import com.afternote.core.domain.repository.UserReceiverRepository import com.afternote.core.model.setting.ReceiverListItem import com.afternote.core.ui.mvi.MviViewModel @@ -16,14 +17,28 @@ internal class ReceiverListViewModel constructor( private val userRepository: UserReceiverRepository, ) : MviViewModel(ReceiverListUiState()) { - private val receivers = userRepository.receiverListFlow + private val receivers = userRepository.receiverListStateFlow private var observationJob: Job? = null private var stopJob: Job? = null override fun onIntent(intent: ReceiverListIntent) { when (intent) { - ReceiverListIntent.ObservationStarted -> startObservation() - ReceiverListIntent.ObservationStopped -> stopObservation() + ReceiverListIntent.ObservationStarted -> { + startObservation() + } + + ReceiverListIntent.ObservationStopped -> { + stopObservation() + } + + ReceiverListIntent.Retry -> { + if (currentState.loadState == ReceiverListLoadState.InitialFailure || + currentState.loadState == ReceiverListLoadState.RefreshFailure + ) { + dispatch(ReceiverListReducerEvent.RetryStarted) + userRepository.refreshReceiverList() + } + } } } @@ -32,7 +47,41 @@ internal class ReceiverListViewModel event: ReceiverListReducerEvent, ): ReceiverListUiState = when (event) { - is ReceiverListReducerEvent.ReceiversChanged -> state.copy(receivers = event.receivers) + ReceiverListReducerEvent.RetryStarted -> { + state.copy(loadState = ReceiverListLoadState.Loading) + } + + is ReceiverListReducerEvent.ResultChanged -> { + val result = event.result + val receivers = + when (result) { + is ReceiverListState.Loading -> result.previousReceivers.orEmpty() + is ReceiverListState.Success -> result.receivers + is ReceiverListState.Failure -> result.previousReceivers.orEmpty() + ReceiverListState.SignedOut -> emptyList() + } + ReceiverListUiState( + receivers = receivers.map { ReceiverListItem(it.receiverId, it.name, it.relation) }, + loadState = + when (result) { + is ReceiverListState.Loading, ReceiverListState.SignedOut -> { + ReceiverListLoadState.Loading + } + + is ReceiverListState.Success -> { + ReceiverListLoadState.Ready + } + + is ReceiverListState.Failure -> { + if (result.previousReceivers == null) { + ReceiverListLoadState.InitialFailure + } else { + ReceiverListLoadState.RefreshFailure + } + } + }, + ) + } } private fun startObservation() { @@ -47,11 +96,7 @@ internal class ReceiverListViewModel observationJob = viewModelScope.launch { receivers.collect { receivers -> - dispatch( - ReceiverListReducerEvent.ReceiversChanged( - receivers.map { ReceiverListItem(it.receiverId, it.name, it.relation) }, - ), - ) + dispatch(ReceiverListReducerEvent.ResultChanged(receivers)) } } } diff --git a/feature/setting/presentation/src/main/res/values/strings.xml b/feature/setting/presentation/src/main/res/values/strings.xml index edbe58f905..3c46605c99 100644 --- a/feature/setting/presentation/src/main/res/values/strings.xml +++ b/feature/setting/presentation/src/main/res/values/strings.xml @@ -167,4 +167,6 @@ 수신자 정보를 불러오지 못했습니다. 현재 이 메뉴는 이용할 수 없습니다. 확인 + 수신자 목록을 불러오지 못했어요. + 다시 시도 diff --git a/feature/setting/presentation/src/screenshotTest/kotlin/com/afternote/feature/setting/presentation/screen/ReceiverListStateScreenshotTest.kt b/feature/setting/presentation/src/screenshotTest/kotlin/com/afternote/feature/setting/presentation/screen/ReceiverListStateScreenshotTest.kt new file mode 100644 index 0000000000..921c71fa4c --- /dev/null +++ b/feature/setting/presentation/src/screenshotTest/kotlin/com/afternote/feature/setting/presentation/screen/ReceiverListStateScreenshotTest.kt @@ -0,0 +1,112 @@ +package com.afternote.feature.setting.presentation.screen + +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import com.afternote.core.model.setting.ReceiverListItem +import com.afternote.core.ui.theme.AfternoteTheme +import com.afternote.feature.setting.presentation.COMPACT_DEVICE_SPEC +import com.afternote.feature.setting.presentation.viewmodel.ReceiverListLoadState +import com.afternote.feature.setting.presentation.viewmodel.ReceiverListUiState +import com.android.tools.screenshot.PreviewTest + +@PreviewTest +@Preview(showBackground = true, device = COMPACT_DEVICE_SPEC) +@Composable +internal fun receiverListInitialLoadingScreenshot() { + ReceiverListStateScreenshotContent(loadState = ReceiverListLoadState.Loading) +} + +@PreviewTest +@Preview(showBackground = true, device = COMPACT_DEVICE_SPEC) +@Composable +internal fun receiverManageInitialFailureScreenshot() { + ReceiverListStateScreenshotContent(loadState = ReceiverListLoadState.InitialFailure) +} + +@PreviewTest +@Preview(showBackground = true, device = COMPACT_DEVICE_SPEC) +@Composable +internal fun receiverSelectInitialFailureScreenshot() { + ReceiverListStateScreenshotContent( + loadState = ReceiverListLoadState.InitialFailure, + selectForDeliveryConditions = true, + ) +} + +@PreviewTest +@Preview(showBackground = true, device = COMPACT_DEVICE_SPEC) +@Composable +internal fun receiverManageRefreshFailureScreenshot() { + ReceiverListStateScreenshotContent( + loadState = ReceiverListLoadState.RefreshFailure, + receivers = receiverListScreenshotItems, + ) +} + +@PreviewTest +@Preview(showBackground = true, device = COMPACT_DEVICE_SPEC) +@Composable +internal fun receiverSelectRefreshFailureScreenshot() { + ReceiverListStateScreenshotContent( + loadState = ReceiverListLoadState.RefreshFailure, + selectForDeliveryConditions = true, + receivers = receiverListScreenshotItems, + ) +} + +@PreviewTest +@Preview(showBackground = true, device = COMPACT_DEVICE_SPEC) +@Composable +internal fun receiverManageReadyScreenshot() { + ReceiverListStateScreenshotContent( + loadState = ReceiverListLoadState.Ready, + receivers = receiverListScreenshotItems, + ) +} + +@PreviewTest +@Preview(showBackground = true, device = COMPACT_DEVICE_SPEC) +@Composable +internal fun receiverSelectReadyScreenshot() { + ReceiverListStateScreenshotContent( + loadState = ReceiverListLoadState.Ready, + selectForDeliveryConditions = true, + receivers = receiverListScreenshotItems, + ) +} + +@PreviewTest +@Preview(showBackground = true, device = COMPACT_DEVICE_SPEC, fontScale = 1.5f) +@Composable +internal fun receiverSelectRefreshFailureLargeFontScreenshot() { + ReceiverListStateScreenshotContent( + loadState = ReceiverListLoadState.RefreshFailure, + selectForDeliveryConditions = true, + receivers = receiverListScreenshotItems, + ) +} + +@Composable +private fun ReceiverListStateScreenshotContent( + loadState: ReceiverListLoadState, + selectForDeliveryConditions: Boolean = false, + receivers: List = emptyList(), +) { + AfternoteTheme { + ReceiverListRouteContent( + uiState = ReceiverListUiState(receivers = receivers, loadState = loadState), + selectForDeliveryConditions = selectForDeliveryConditions, + onBackClick = {}, + onRetry = {}, + onConfirmClick = {}, + onReceiverClick = {}, + ) + } +} + +private val receiverListScreenshotItems = + listOf( + ReceiverListItem(receiverId = 1L, name = "김서연", relation = "가족"), + ReceiverListItem(receiverId = 2L, name = "박지훈", relation = "친구"), + ReceiverListItem(receiverId = 3L, name = "이하늘", relation = "연인"), + ) diff --git a/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/screen/ReceiverListStateScreenshotTestKt/receiverListInitialLoadingScreenshot_c2abd7e6_0.png b/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/screen/ReceiverListStateScreenshotTestKt/receiverListInitialLoadingScreenshot_c2abd7e6_0.png new file mode 100644 index 0000000000..f9cb1e0a01 Binary files /dev/null and b/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/screen/ReceiverListStateScreenshotTestKt/receiverListInitialLoadingScreenshot_c2abd7e6_0.png differ diff --git a/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/screen/ReceiverListStateScreenshotTestKt/receiverManageInitialFailureScreenshot_c2abd7e6_0.png b/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/screen/ReceiverListStateScreenshotTestKt/receiverManageInitialFailureScreenshot_c2abd7e6_0.png new file mode 100644 index 0000000000..d55d599f16 Binary files /dev/null and b/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/screen/ReceiverListStateScreenshotTestKt/receiverManageInitialFailureScreenshot_c2abd7e6_0.png differ diff --git a/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/screen/ReceiverListStateScreenshotTestKt/receiverManageReadyScreenshot_c2abd7e6_0.png b/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/screen/ReceiverListStateScreenshotTestKt/receiverManageReadyScreenshot_c2abd7e6_0.png new file mode 100644 index 0000000000..a319514222 Binary files /dev/null and b/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/screen/ReceiverListStateScreenshotTestKt/receiverManageReadyScreenshot_c2abd7e6_0.png differ diff --git a/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/screen/ReceiverListStateScreenshotTestKt/receiverManageRefreshFailureScreenshot_c2abd7e6_0.png b/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/screen/ReceiverListStateScreenshotTestKt/receiverManageRefreshFailureScreenshot_c2abd7e6_0.png new file mode 100644 index 0000000000..f2437663c5 Binary files /dev/null and b/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/screen/ReceiverListStateScreenshotTestKt/receiverManageRefreshFailureScreenshot_c2abd7e6_0.png differ diff --git a/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/screen/ReceiverListStateScreenshotTestKt/receiverSelectInitialFailureScreenshot_c2abd7e6_0.png b/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/screen/ReceiverListStateScreenshotTestKt/receiverSelectInitialFailureScreenshot_c2abd7e6_0.png new file mode 100644 index 0000000000..38dfa88dc3 Binary files /dev/null and b/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/screen/ReceiverListStateScreenshotTestKt/receiverSelectInitialFailureScreenshot_c2abd7e6_0.png differ diff --git a/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/screen/ReceiverListStateScreenshotTestKt/receiverSelectReadyScreenshot_c2abd7e6_0.png b/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/screen/ReceiverListStateScreenshotTestKt/receiverSelectReadyScreenshot_c2abd7e6_0.png new file mode 100644 index 0000000000..a97239a6d5 Binary files /dev/null and b/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/screen/ReceiverListStateScreenshotTestKt/receiverSelectReadyScreenshot_c2abd7e6_0.png differ diff --git a/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/screen/ReceiverListStateScreenshotTestKt/receiverSelectRefreshFailureLargeFontScreenshot_7b20d0ed_0.png b/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/screen/ReceiverListStateScreenshotTestKt/receiverSelectRefreshFailureLargeFontScreenshot_7b20d0ed_0.png new file mode 100644 index 0000000000..0f30117fdb Binary files /dev/null and b/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/screen/ReceiverListStateScreenshotTestKt/receiverSelectRefreshFailureLargeFontScreenshot_7b20d0ed_0.png differ diff --git a/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/screen/ReceiverListStateScreenshotTestKt/receiverSelectRefreshFailureScreenshot_c2abd7e6_0.png b/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/screen/ReceiverListStateScreenshotTestKt/receiverSelectRefreshFailureScreenshot_c2abd7e6_0.png new file mode 100644 index 0000000000..77a1d9335d Binary files /dev/null and b/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/screen/ReceiverListStateScreenshotTestKt/receiverSelectRefreshFailureScreenshot_c2abd7e6_0.png differ diff --git a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/ReceiverListRouteContentTest.kt b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/ReceiverListRouteContentTest.kt new file mode 100644 index 0000000000..b17620e444 --- /dev/null +++ b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/ReceiverListRouteContentTest.kt @@ -0,0 +1,137 @@ +package com.afternote.feature.setting.presentation + +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.hasScrollToIndexAction +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollToIndex +import androidx.compose.ui.test.performTextInput +import com.afternote.core.model.setting.ReceiverListItem +import com.afternote.core.ui.theme.AfternoteTheme +import com.afternote.feature.setting.presentation.screen.ReceiverListRouteContent +import com.afternote.feature.setting.presentation.viewmodel.ReceiverListLoadState +import com.afternote.feature.setting.presentation.viewmodel.ReceiverListUiState +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(sdk = [35]) +class ReceiverListRouteContentTest { + @get:Rule val composeRule = createComposeRule() + private val receivers = listOf(ReceiverListItem(7L, "김수신", "가족"), ReceiverListItem(8L, "박친구", "친구")) + + @Test + fun initialFailure_providesRetryAndBackWithoutShowingEmptySuccess() { + var retries = 0 + var backs = 0 + composeRule.setContent { + AfternoteTheme { + ReceiverListRouteContent( + uiState = ReceiverListUiState(loadState = ReceiverListLoadState.InitialFailure), + selectForDeliveryConditions = false, + onBackClick = { backs++ }, + onRetry = { retries++ }, + onConfirmClick = {}, + onReceiverClick = {}, + ) + } + } + composeRule.onNodeWithText("수신자 목록을 불러오지 못했어요.").assertIsDisplayed() + composeRule.onNodeWithText("다시 시도").performClick() + composeRule.onNodeWithContentDescription("뒤로가기").performClick() + assertEquals(1, retries) + assertEquals(1, backs) + } + + @Test + fun refreshFailure_keepsManagementRowsAndRetryClickable() { + var selected: Long? = null + var retries = 0 + composeRule.setContent { + AfternoteTheme { + ReceiverListRouteContent( + uiState = ReceiverListUiState(receivers, ReceiverListLoadState.RefreshFailure), + selectForDeliveryConditions = false, + onBackClick = {}, + onRetry = { retries++ }, + onConfirmClick = {}, + onReceiverClick = { selected = it }, + ) + } + } + composeRule.onNodeWithText("박친구").performClick() + composeRule.onNodeWithText("다시 시도").performClick() + assertEquals(8L, selected) + assertEquals(1, retries) + } + + @Test + fun resubscriptionLoading_preservesSearchAndSelectionButDisablesStaleConfirmation() { + val state = mutableStateOf(ReceiverListUiState(receivers, ReceiverListLoadState.Ready)) + var confirmed: Long? = null + composeRule.setContent { + AfternoteTheme { + ReceiverListRouteContent( + uiState = state.value, + selectForDeliveryConditions = true, + onBackClick = {}, + onRetry = {}, + onConfirmClick = { confirmed = it.receiverId }, + onReceiverClick = {}, + ) + } + } + composeRule.onNodeWithText("이름으로 검색하기").performTextInput("김") + composeRule.onAllNodes(SemanticsMatcher.expectValue(SemanticsProperties.Role, Role.Checkbox))[0].performClick() + composeRule.runOnIdle { state.value = ReceiverListUiState(loadState = ReceiverListLoadState.Loading) } + composeRule.onNodeWithText("수신자 선택 완료하기").assertIsNotEnabled() + composeRule.onNodeWithText("김수신").assertDoesNotExist() + composeRule.runOnIdle { state.value = ReceiverListUiState(receivers, ReceiverListLoadState.Ready) } + composeRule.onNodeWithText("김수신").assertIsDisplayed() + composeRule.onNodeWithText("박친구").assertDoesNotExist() + composeRule.onNodeWithText("수신자 선택 완료하기").performClick() + assertEquals(7L, confirmed) + } + + @Test + fun selectionList_resubscriptionPreservesScrollPosition() = assertScrollPreserved(selecting = true) + + @Test + fun managementList_resubscriptionPreservesScrollPosition() = assertScrollPreserved(selecting = false) + + private fun assertScrollPreserved(selecting: Boolean) { + val longList = (0..49).map { ReceiverListItem(it.toLong(), "가수신%02d".format(it), "친구") } + val state = mutableStateOf(ReceiverListUiState(longList, ReceiverListLoadState.Ready)) + composeRule.setContent { + AfternoteTheme { + ReceiverListRouteContent( + uiState = state.value, + selectForDeliveryConditions = selecting, + onBackClick = {}, + onRetry = {}, + onConfirmClick = {}, + onReceiverClick = {}, + ) + } + } + composeRule.onAllNodes(hasScrollToIndexAction())[0].performScrollToIndex(35) + composeRule.onNodeWithText("가수신35").assertIsDisplayed() + composeRule.runOnIdle { state.value = ReceiverListUiState(loadState = ReceiverListLoadState.Loading) } + composeRule.onNodeWithText("가수신35").assertDoesNotExist() + composeRule.runOnIdle { state.value = ReceiverListUiState(longList, ReceiverListLoadState.Ready) } + composeRule.onNodeWithText("가수신35").assertIsDisplayed() + } +} diff --git a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/viewmodel/ReceiverListViewModelTest.kt b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/viewmodel/ReceiverListViewModelTest.kt index 4bbb8f1cfe..245ddeee2e 100644 --- a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/viewmodel/ReceiverListViewModelTest.kt +++ b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/viewmodel/ReceiverListViewModelTest.kt @@ -1,6 +1,7 @@ package com.afternote.feature.setting.presentation.viewmodel import androidx.lifecycle.ViewModelStore +import com.afternote.core.domain.model.ReceiverListState import com.afternote.core.domain.testing.FakeUserReceiverRepository import com.afternote.core.model.setting.ReceiverListItem import com.afternote.core.model.user.Receiver @@ -180,6 +181,84 @@ class ReceiverListViewModelTest { assertEquals(1, cancellations) } + @Test + fun `최초 로딩과 성공한 실제 빈 목록은 다른 상태다`() = + runTest(dispatcher) { + val results = MutableStateFlow(ReceiverListState.Loading(null)) + val viewModel = stateViewModel(results) + viewModel.onIntent(ReceiverListIntent.ObservationStarted) + runCurrent() + assertEquals(ReceiverListLoadState.Loading, viewModel.uiState.value.loadState) + + results.value = ReceiverListState.Success(emptyList()) + runCurrent() + assertEquals(ReceiverListLoadState.Ready, viewModel.uiState.value.loadState) + assertEquals(emptyList(), viewModel.uiState.value.receivers) + } + + @Test + fun `첫 조회 실패와 빈 목록 갱신 실패를 구분한다`() = + runTest(dispatcher) { + val results = MutableStateFlow(ReceiverListState.Failure(null)) + val viewModel = stateViewModel(results) + viewModel.onIntent(ReceiverListIntent.ObservationStarted) + runCurrent() + assertEquals(ReceiverListLoadState.InitialFailure, viewModel.uiState.value.loadState) + + results.value = ReceiverListState.Failure(emptyList()) + runCurrent() + assertEquals(ReceiverListLoadState.RefreshFailure, viewModel.uiState.value.loadState) + } + + @Test + fun `갱신 실패는 마지막 목록을 유지하고 중복 재시도를 하나로 합친다`() = + runTest(dispatcher) { + val results = MutableStateFlow(ReceiverListState.Failure(receivers.value)) + val viewModel = stateViewModel(results) + viewModel.onIntent(ReceiverListIntent.ObservationStarted) + runCurrent() + assertEquals(ReceiverListLoadState.RefreshFailure, viewModel.uiState.value.loadState) + assertEquals(listOf(ReceiverListItem(7L, "김수신", "가족")), viewModel.uiState.value.receivers) + + viewModel.onIntent(ReceiverListIntent.Retry) + viewModel.onIntent(ReceiverListIntent.Retry) + assertEquals(1, refreshes) + assertEquals(ReceiverListLoadState.Loading, viewModel.uiState.value.loadState) + assertEquals(listOf(ReceiverListItem(7L, "김수신", "가족")), viewModel.uiState.value.receivers) + + results.value = ReceiverListState.Success(receivers.value) + runCurrent() + assertEquals(ReceiverListLoadState.Ready, viewModel.uiState.value.loadState) + } + + @Test + fun `인증 종료와 캐시 없는 실패는 이전 목록을 지운다`() = + runTest(dispatcher) { + val results = MutableStateFlow(ReceiverListState.Success(receivers.value)) + val viewModel = stateViewModel(results) + viewModel.onIntent(ReceiverListIntent.ObservationStarted) + runCurrent() + + results.value = ReceiverListState.Failure(null) + runCurrent() + assertEquals(emptyList(), viewModel.uiState.value.receivers) + assertEquals(ReceiverListLoadState.InitialFailure, viewModel.uiState.value.loadState) + + results.value = ReceiverListState.SignedOut + runCurrent() + assertEquals(emptyList(), viewModel.uiState.value.receivers) + assertEquals(ReceiverListLoadState.Loading, viewModel.uiState.value.loadState) + } + + private fun stateViewModel(results: MutableStateFlow): ReceiverListViewModel { + val repository = + FakeUserReceiverRepository.strict().apply { + onReceiverListStateFlow = { results } + onRefreshReceiverList = { refreshes++ } + } + return ReceiverListViewModel(repository).also { store.put("receiver-list", it) } + } + private fun viewModel(): ReceiverListViewModel { val repository = FakeUserReceiverRepository.strict().apply {