diff --git a/app/src/androidTest/java/com/afternote/afternote_fe/ReceiverSessionIsolationAndroidTest.kt b/app/src/androidTest/java/com/afternote/afternote_fe/ReceiverSessionIsolationAndroidTest.kt index c5e6f36a26..75a2fb1f56 100644 --- a/app/src/androidTest/java/com/afternote/afternote_fe/ReceiverSessionIsolationAndroidTest.kt +++ b/app/src/androidTest/java/com/afternote/afternote_fe/ReceiverSessionIsolationAndroidTest.kt @@ -1,17 +1,20 @@ package com.afternote.afternote_fe +import androidx.compose.runtime.getValue import androidx.compose.ui.graphics.asAndroidBitmap import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.captureToImage import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onRoot +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.test.ext.junit.runners.AndroidJUnit4 import com.afternote.afternote_fe.test.FailureArtifactRule import com.afternote.afternote_fe.test.appTestUserRepository import com.afternote.core.model.user.Receiver import com.afternote.core.ui.theme.AfternoteTheme import com.afternote.feature.timeletter.presentation.screen.sender.RecipientListScreen +import com.afternote.feature.timeletter.presentation.viewmodel.RecipientListUiState import com.afternote.feature.timeletter.presentation.viewmodel.RecipientListViewModel import org.junit.Rule import org.junit.Test @@ -43,22 +46,26 @@ class ReceiverSessionIsolationAndroidTest { composeRule.setContent { AfternoteTheme { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() RecipientListScreen( + uiState = uiState, onBackClick = {}, onConfirmClick = {}, - viewModel = viewModel, + onRetry = viewModel::retry, ) } } composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { - viewModel.recipients.value.any { it.name == PREVIOUS_ACCOUNT_RECEIVER } + (viewModel.uiState.value as? RecipientListUiState.Success) + ?.recipients + ?.any { it.name == PREVIOUS_ACCOUNT_RECEIVER } == true } composeRule.onNodeWithText(PREVIOUS_ACCOUNT_RECEIVER).assertIsDisplayed() composeRule.runOnIdle { repository.receiverState.value = emptyList() } composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { - viewModel.recipients.value.isEmpty() + (viewModel.uiState.value as? RecipientListUiState.Success)?.recipients?.isEmpty() == true } composeRule.onNodeWithText("수신인 목록").assertIsDisplayed() diff --git a/app/src/androidTest/java/com/afternote/afternote_fe/TimeLetterMindRecordCompletionAndroidTest.kt b/app/src/androidTest/java/com/afternote/afternote_fe/TimeLetterMindRecordCompletionAndroidTest.kt index 90c7f7e36d..5ff0d7b9b0 100644 --- a/app/src/androidTest/java/com/afternote/afternote_fe/TimeLetterMindRecordCompletionAndroidTest.kt +++ b/app/src/androidTest/java/com/afternote/afternote_fe/TimeLetterMindRecordCompletionAndroidTest.kt @@ -234,13 +234,15 @@ class TimeLetterMindRecordCompletionAndroidTest { composeRule.setContent { AfternoteTheme { if (showRecipient) { + val recipientUiState by recipientViewModel.uiState.collectAsStateWithLifecycle() RecipientListScreen( + uiState = recipientUiState, onBackClick = { showRecipient = false }, onConfirmClick = { recipients -> writeViewModel.setRecipients(recipients.map { it.receiverId }) showRecipient = false }, - viewModel = recipientViewModel, + onRetry = recipientViewModel::retry, ) } else { val uiState by writeViewModel.uiState.collectAsStateWithLifecycle() diff --git a/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/component/AlignButton.kt b/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/component/AlignButton.kt index f019959ae2..cb71f4d0fe 100644 --- a/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/component/AlignButton.kt +++ b/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/component/AlignButton.kt @@ -20,6 +20,7 @@ fun AlignButton( selected: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier, + enabled: Boolean = true, ) { Box( modifier = @@ -33,7 +34,7 @@ fun AlignButton( } else { Modifier }, - ).clickable(onClick = onClick), + ).clickable(enabled = enabled, onClick = onClick), contentAlignment = Alignment.Center, ) { Icon( diff --git a/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/component/RecipientCard.kt b/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/component/RecipientCard.kt index 9b50e7f7f6..ba4091babc 100644 --- a/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/component/RecipientCard.kt +++ b/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/component/RecipientCard.kt @@ -24,6 +24,7 @@ fun RecipientCard( recipientName: String, onClick: () -> Unit, modifier: Modifier = Modifier, + enabled: Boolean = true, ) { Row( modifier = @@ -31,7 +32,7 @@ fun RecipientCard( .fillMaxWidth() .height(115.dp) .background(AfternoteDesign.colors.gray2) - .clickable(onClick = onClick) + .clickable(enabled = enabled, onClick = onClick) .padding(horizontal = 20.dp, vertical = 16.dp), verticalAlignment = Alignment.CenterVertically, ) { diff --git a/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/component/SendScheduleRow.kt b/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/component/SendScheduleRow.kt index 776aac887d..64f8086740 100644 --- a/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/component/SendScheduleRow.kt +++ b/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/component/SendScheduleRow.kt @@ -24,6 +24,7 @@ fun SendScheduleRow( onDateClick: () -> Unit, onTimeClick: () -> Unit, modifier: Modifier = Modifier, + enabled: Boolean = true, ) { Row( modifier = @@ -39,7 +40,7 @@ fun SendScheduleRow( ) Spacer(modifier = Modifier.width(12.dp)) Row( - modifier = Modifier.clickable(onClick = onDateClick), + modifier = Modifier.clickable(enabled = enabled, onClick = onDateClick), verticalAlignment = Alignment.CenterVertically, ) { Text( @@ -65,7 +66,7 @@ fun SendScheduleRow( ) Spacer(modifier = Modifier.width(12.dp)) Row( - modifier = Modifier.clickable(onClick = onTimeClick), + modifier = Modifier.clickable(enabled = enabled, onClick = onTimeClick), verticalAlignment = Alignment.CenterVertically, ) { Text( diff --git a/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/component/TimeLetterBottomBar.kt b/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/component/TimeLetterBottomBar.kt index 68642e2c51..191cd1a6e8 100644 --- a/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/component/TimeLetterBottomBar.kt +++ b/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/component/TimeLetterBottomBar.kt @@ -40,6 +40,7 @@ fun TimeLetterBottomBar( onDraftClick: () -> Unit, onDraftCountClick: () -> Unit, modifier: Modifier = Modifier, + enabled: Boolean = true, ) { Row( modifier = @@ -54,7 +55,7 @@ fun TimeLetterBottomBar( modifier = Modifier .size(24.dp) - .clickable(onClick = onMediaAddClick), + .clickable(enabled = enabled, onClick = onMediaAddClick), contentAlignment = Alignment.Center, ) { Icon( @@ -71,7 +72,7 @@ fun TimeLetterBottomBar( modifier = Modifier .size(24.dp) - .clickable(onClick = onTextStyleClick), + .clickable(enabled = enabled, onClick = onTextStyleClick), contentAlignment = Alignment.Center, ) { Icon( @@ -99,22 +100,25 @@ fun TimeLetterBottomBar( painter = painterResource(CoreUiR.drawable.core_ui_ic_align_center), selected = textAlign == TextAlign.Center, // 상태 반영 onClick = onAlignCenterClick, + enabled = enabled, ) AlignButton( painter = painterResource(CoreUiR.drawable.core_ui_ic_align_left), selected = textAlign == TextAlign.Start, onClick = onAlignLeftClick, + enabled = enabled, ) AlignButton( painter = painterResource(CoreUiR.drawable.core_ui_ic_align_right), selected = textAlign == TextAlign.End, onClick = onAlignRightClick, + enabled = enabled, ) } Spacer(modifier = Modifier.weight(1f)) - TextButton(onClick = onDraftClick) { + TextButton(onClick = onDraftClick, enabled = enabled) { Text( text = "임시저장", style = AfternoteDesign.typography.captionLargeR, @@ -126,6 +130,7 @@ fun TimeLetterBottomBar( Modifier .sizeIn(minWidth = 48.dp, minHeight = 48.dp) .clickable( + enabled = enabled, onClickLabel = "임시저장 목록 열기", role = Role.Button, onClick = onDraftCountClick, diff --git a/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/component/TimeLetterLoadErrorContent.kt b/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/component/TimeLetterLoadErrorContent.kt new file mode 100644 index 0000000000..e3207ec212 --- /dev/null +++ b/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/component/TimeLetterLoadErrorContent.kt @@ -0,0 +1,39 @@ +package com.afternote.feature.timeletter.presentation.component + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +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 com.afternote.core.ui.theme.AfternoteDesign +import com.afternote.feature.timeletter.presentation.R + +@Composable +fun TimeLetterLoadErrorContent( + message: String, + onRetry: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text( + text = message, + style = AfternoteDesign.typography.bodyLargeR, + color = AfternoteDesign.colors.gray7, + ) + TextButton(onClick = onRetry) { + Text( + text = stringResource(R.string.timeletter_retry), + style = AfternoteDesign.typography.bodyLargeB, + color = AfternoteDesign.colors.gray9, + ) + } + } +} diff --git a/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/component/TimeLetterTextButton.kt b/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/component/TimeLetterTextButton.kt index 69be3a5c14..f9cc7fef68 100644 --- a/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/component/TimeLetterTextButton.kt +++ b/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/component/TimeLetterTextButton.kt @@ -6,8 +6,10 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment @@ -23,6 +25,8 @@ fun TimeLetterTextButton( onClick: () -> Unit, modifier: Modifier = Modifier, isActive: Boolean = false, + isLoading: Boolean = false, + enabled: Boolean = true, ) { val shape = RoundedCornerShape(6.dp) val backgroundColor = @@ -38,14 +42,22 @@ fun TimeLetterTextButton( .height(36.dp) .clip(shape) .background(color = backgroundColor, shape = shape) - .clickable(onClick = onClick) + .clickable(enabled = enabled && !isLoading, onClick = onClick) .padding(start = 18.dp, top = 8.dp, end = 18.dp, bottom = 8.dp), ) { - Text( - text = text, - style = AfternoteDesign.typography.bodySmallR, - color = textColor, - ) + if (isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(18.dp), + color = AfternoteDesign.colors.gray7, + strokeWidth = 2.dp, + ) + } else { + Text( + text = text, + style = AfternoteDesign.typography.bodySmallR, + color = textColor, + ) + } } } diff --git a/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/navigation/TimeLetterNavGraph.kt b/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/navigation/TimeLetterNavGraph.kt index c28aea6a6d..c53efea1f7 100644 --- a/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/navigation/TimeLetterNavGraph.kt +++ b/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/navigation/TimeLetterNavGraph.kt @@ -17,6 +17,7 @@ import com.afternote.feature.timeletter.presentation.screen.sender.RecipientList import com.afternote.feature.timeletter.presentation.screen.sender.TimeLetterDetailScreen import com.afternote.feature.timeletter.presentation.screen.sender.TimeLetterWriteScreen import com.afternote.feature.timeletter.presentation.screen.sender.TimeletterScreen +import com.afternote.feature.timeletter.presentation.viewmodel.RecipientListViewModel import com.afternote.feature.timeletter.presentation.viewmodel.TimeLetterWriteViewModel import com.afternote.feature.timeletter.presentation.viewmodel.TimeletterViewModel @@ -63,6 +64,7 @@ fun NavGraphBuilder.timeLetterNavGraph( onDraftClick = { title, textContents -> viewModel.saveDraft(title, textContents) }, onNavigateToDraft = actions::onNavigateToDraft, onErrorShown = { viewModel.clearError() }, + onRetryEditingLetter = viewModel::retryEditingLetter, onRecipientClick = { title, textContents -> viewModel.updateDraftContent(title, textContents) actions.onNavigateToRecipient() @@ -122,12 +124,16 @@ fun NavGraphBuilder.timeLetterNavGraph( ?: navController.getBackStackEntry(TimeLetterRoute.TimeLetterWriteRoute()) } val writeViewModel: TimeLetterWriteViewModel = hiltViewModel(writeEntry) + val recipientListViewModel: RecipientListViewModel = hiltViewModel() + val recipientListUiState by recipientListViewModel.uiState.collectAsStateWithLifecycle() RecipientListScreen( + uiState = recipientListUiState, onBackClick = actions::onRecipientBack, onConfirmClick = { recipients -> writeViewModel.setRecipients(recipients.map { it.receiverId }) actions.onRecipientBack() }, + onRetry = recipientListViewModel::retry, ) } @@ -137,12 +143,16 @@ fun NavGraphBuilder.timeLetterNavGraph( navController.getBackStackEntry(TimeLetterRoute.TimeLetterHomeRoute) } val timeletterViewModel: TimeletterViewModel = hiltViewModel(homeEntry) + val recipientListViewModel: RecipientListViewModel = hiltViewModel() + val recipientListUiState by recipientListViewModel.uiState.collectAsStateWithLifecycle() RecipientListScreen( + uiState = recipientListUiState, onBackClick = actions::onRecipientFilterBack, onConfirmClick = { recipients -> timeletterViewModel.setReceiverFilter(recipients.map { it.receiverId }) actions.onRecipientFilterBack() }, + onRetry = recipientListViewModel::retry, allowEmptyConfirm = true, ) } diff --git a/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/screen/sender/RecipientListScreen.kt b/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/screen/sender/RecipientListScreen.kt index a24f19de44..73d017ab0b 100644 --- a/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/screen/sender/RecipientListScreen.kt +++ b/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/screen/sender/RecipientListScreen.kt @@ -11,25 +11,25 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.mutableStateSetOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.afternote.core.common.util.KoreanConsonantUtil import com.afternote.core.model.setting.ReceiverListItem import com.afternote.core.ui.AfternoteTextField @@ -41,37 +41,100 @@ import com.afternote.core.ui.theme.AfternoteDesign import com.afternote.core.ui.topbar.DetailTopBar import com.afternote.feature.timeletter.presentation.R import com.afternote.feature.timeletter.presentation.component.RecipientListItem -import com.afternote.feature.timeletter.presentation.viewmodel.RecipientListViewModel +import com.afternote.feature.timeletter.presentation.component.TimeLetterLoadErrorContent +import com.afternote.feature.timeletter.presentation.viewmodel.RecipientListUiState import kotlinx.coroutines.launch +/** + * 상태를 ViewModel에서 직접 구독하지 않는다 — [uiState]는 호출부(NavGraph)가 + * [com.afternote.feature.timeletter.presentation.viewmodel.RecipientListViewModel]에서 읽어 + * 넘긴다. Loading·Error·Success 분기를 실제 ViewModel 코루틴 없이 직접 값으로 오가며 검증할 수 + * 있어야 하기 때문이다(#714 — 수신인 선택 소실 회귀 테스트). + */ @Composable fun RecipientListScreen( + uiState: RecipientListUiState, onBackClick: () -> Unit, onConfirmClick: (List) -> Unit, + onRetry: () -> Unit, modifier: Modifier = Modifier, allowEmptyConfirm: Boolean = false, - viewModel: RecipientListViewModel = hiltViewModel(), ) { - val recipients by viewModel.recipients.collectAsStateWithLifecycle() - RecipientListContent( - recipients = recipients, - onBackClick = onBackClick, - onConfirmClick = onConfirmClick, - allowEmptyConfirm = allowEmptyConfirm, + // Success 분기 안에 두면 5초 이상 백그라운드 뒤 재구독으로 uiState 가 Loading 을 거칠 때마다 + // RecipientListContent 가 컴포지션을 떠나 선택이 날아간다(WhileSubscribed(5_000)). 화면 자체는 + // uiState 변화와 무관하게 계속 컴포지션에 남아 있으므로 여기로 올려 생존시킨다. + var selectedIds by rememberSaveable { mutableStateOf(emptySet()) } + when (val state = uiState) { + RecipientListUiState.Loading -> { + RecipientListLoadingContent(onBackClick, modifier) + } + + RecipientListUiState.Error -> { + Scaffold( + modifier = modifier, + topBar = { + DetailTopBar( + title = stringResource(R.string.timeletter_recipient_list_title), + onBackClick = onBackClick, + ) + }, + ) { innerPadding -> + TimeLetterLoadErrorContent( + message = stringResource(R.string.timeletter_recipient_list_load_failed), + onRetry = onRetry, + modifier = Modifier.padding(innerPadding), + ) + } + } + + is RecipientListUiState.Success -> { + RecipientListContent( + recipients = state.recipients, + selectedIds = selectedIds, + onSelectedIdsChange = { selectedIds = it }, + onBackClick = onBackClick, + onConfirmClick = onConfirmClick, + allowEmptyConfirm = allowEmptyConfirm, + modifier = modifier, + ) + } + } +} + +@Composable +private fun RecipientListLoadingContent( + onBackClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Scaffold( modifier = modifier, - ) + topBar = { + DetailTopBar( + title = stringResource(R.string.timeletter_recipient_list_title), + onBackClick = onBackClick, + ) + }, + ) { innerPadding -> + Box( + modifier = Modifier.fillMaxSize().padding(innerPadding), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } + } } @Composable private fun RecipientListContent( recipients: List, + selectedIds: Set, + onSelectedIdsChange: (Set) -> Unit, onBackClick: () -> Unit, onConfirmClick: (List) -> Unit, modifier: Modifier = Modifier, allowEmptyConfirm: Boolean = false, ) { val searchState = rememberTextFieldState() - val selectedIds = remember { mutableStateSetOf() } val listState = rememberLazyListState() val coroutineScope = rememberCoroutineScope() var selectedConsonant by remember { mutableStateOf(null) } @@ -110,7 +173,7 @@ private fun RecipientListContent( modifier = modifier, topBar = { DetailTopBar( - title = "수신인 목록", + title = stringResource(R.string.timeletter_recipient_list_title), onBackClick = onBackClick, ) }, @@ -156,12 +219,15 @@ private fun RecipientListContent( recipient = recipient, selected = recipient.receiverId in selectedIds, onSelectedChange = { checked -> - if (checked) { - selectedIds.add(recipient.receiverId) - } else { - selectedIds.remove(recipient.receiverId) - } + onSelectedIdsChange( + if (checked) { + selectedIds + recipient.receiverId + } else { + selectedIds - recipient.receiverId + }, + ) }, + modifier = Modifier.testTag("recipient_item_${recipient.receiverId}"), ) } } @@ -209,6 +275,8 @@ private fun RecipientListScreenPrev() { ReceiverListItem(receiverId = 2L, name = "김철수", relation = "가족"), ReceiverListItem(receiverId = 3L, name = "이영희", relation = "연인"), ), + selectedIds = emptySet(), + onSelectedIdsChange = {}, onBackClick = {}, onConfirmClick = {}, ) diff --git a/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/screen/sender/TimeLetterWriteScreen.kt b/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/screen/sender/TimeLetterWriteScreen.kt index b34294f162..1bce9bfbd1 100644 --- a/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/screen/sender/TimeLetterWriteScreen.kt +++ b/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/screen/sender/TimeLetterWriteScreen.kt @@ -91,6 +91,7 @@ import com.afternote.feature.timeletter.presentation.component.MediaBottomSheetC import com.afternote.feature.timeletter.presentation.component.RecipientCard import com.afternote.feature.timeletter.presentation.component.SendScheduleRow import com.afternote.feature.timeletter.presentation.component.TimeLetterBottomBar +import com.afternote.feature.timeletter.presentation.component.TimeLetterLoadErrorContent import com.afternote.feature.timeletter.presentation.component.TimeLetterTextButton import com.afternote.feature.timeletter.presentation.component.TimeLetterTitleTextField import com.afternote.feature.timeletter.presentation.component.TimeWheelPicker @@ -130,6 +131,7 @@ fun TimeLetterWriteScreen( onDraftClick: (title: String, textContents: Map) -> Unit = { _, _ -> }, onNavigateToDraft: () -> Unit = {}, onErrorShown: () -> Unit = {}, + onRetryEditingLetter: () -> Unit = {}, onAddImageBlock: (Uri) -> Unit = {}, onAddAudioBlock: (Uri) -> Unit = {}, onAddFileBlock: (Uri) -> Unit = {}, @@ -276,6 +278,21 @@ fun TimeLetterWriteScreen( return } + if (uiState.editingLoadError) { + Scaffold( + modifier = modifier, + topBar = { DetailTopBar(title = "", onBackClick = onBackClick) }, + containerColor = AfternoteDesign.colors.white, + ) { innerPadding -> + TimeLetterLoadErrorContent( + message = stringResource(R.string.timeletter_write_load_failed), + onRetry = onRetryEditingLetter, + modifier = Modifier.padding(innerPadding), + ) + } + return + } + // Apply the loaded title once per editing destination. Keying this to draftTitle would // overwrite the user's input whenever the draft is synchronized back to the ViewModel. LaunchedEffect(uiState.editingTimeLetterId) { @@ -286,6 +303,7 @@ fun TimeLetterWriteScreen( } } + val isSubmitting = uiState.isSaving || uiState.isCheckingRegisterLimit var showDatePicker by remember { mutableStateOf(false) } var showTimePicker by remember { mutableStateOf(false) } var pendingHour by remember { mutableIntStateOf(LocalTime.now().hour) } @@ -482,7 +500,9 @@ fun TimeLetterWriteScreen( collectTextContents(), ) }, - isActive = !uiState.isSaving && uiState.sendAt != null, + isActive = !isSubmitting && uiState.sendAt != null, + isLoading = isSubmitting, + enabled = !isSubmitting, ) }, ) @@ -498,6 +518,7 @@ fun TimeLetterWriteScreen( onAlignRightClick = onAlignRightClick, onDraftClick = { onDraftClick(titleState.text.toString(), collectTextContents()) }, onDraftCountClick = onNavigateToDraft, + enabled = !isSubmitting, ) }, containerColor = AfternoteDesign.colors.white, @@ -515,6 +536,7 @@ fun TimeLetterWriteScreen( collectTextContents(), ) }, + enabled = !isSubmitting, ) } item(key = "divider_1") { @@ -526,6 +548,7 @@ fun TimeLetterWriteScreen( time = uiState.sendTime ?: "", onDateClick = { showDatePicker = true }, onTimeClick = { showTimePicker = true }, + enabled = !isSubmitting, ) } item(key = "divider_2") { diff --git a/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/screen/sender/TimeletterScreen.kt b/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/screen/sender/TimeletterScreen.kt index e4bede3e23..2e2424e9ca 100644 --- a/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/screen/sender/TimeletterScreen.kt +++ b/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/screen/sender/TimeletterScreen.kt @@ -86,6 +86,7 @@ fun TimeletterScreen( onEditClick = onEditClick, onFilterRecipientClick = onFilterRecipientClick, onDeleteClick = { pendingDeleteId = it }, + onRetry = viewModel::load, modifier = modifier, ) } diff --git a/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/screen/sender/TimeletterScreenContent.kt b/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/screen/sender/TimeletterScreenContent.kt index beb3d155d3..4a4aa60cee 100644 --- a/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/screen/sender/TimeletterScreenContent.kt +++ b/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/screen/sender/TimeletterScreenContent.kt @@ -11,10 +11,12 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource import com.afternote.core.ui.button.FAB.PenFloatingActionButton import com.afternote.core.ui.topbar.HomeTopBar -import com.afternote.feature.timeletter.presentation.component.EmptyTimeLetterContent +import com.afternote.feature.timeletter.presentation.R import com.afternote.feature.timeletter.presentation.component.TimeLetterContent +import com.afternote.feature.timeletter.presentation.component.TimeLetterLoadErrorContent import com.afternote.feature.timeletter.presentation.viewmodel.TimeletterUiState import com.afternote.feature.timeletter.presentation.viewmodel.ViewMode @@ -36,6 +38,7 @@ internal fun TimeletterScreenContent( onEditClick: (Long) -> Unit, onFilterRecipientClick: () -> Unit, onDeleteClick: (Long) -> Unit, + onRetry: () -> Unit, modifier: Modifier = Modifier, ) { Scaffold( @@ -56,7 +59,11 @@ internal fun TimeletterScreenContent( } is TimeletterUiState.Error -> { - EmptyTimeLetterContent(modifier = Modifier.padding(paddingValues)) + TimeLetterLoadErrorContent( + message = stringResource(R.string.timeletter_write_load_failed), + onRetry = onRetry, + modifier = Modifier.padding(paddingValues), + ) } is TimeletterUiState.Success -> { diff --git a/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/viewmodel/RecipientListViewModel.kt b/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/viewmodel/RecipientListViewModel.kt index c5d097141b..5c7cfc45f9 100644 --- a/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/viewmodel/RecipientListViewModel.kt +++ b/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/viewmodel/RecipientListViewModel.kt @@ -2,8 +2,10 @@ package com.afternote.feature.timeletter.presentation.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.afternote.core.domain.repository.UserRepository +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.model.user.Receiver import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -11,18 +13,51 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import javax.inject.Inject +sealed interface RecipientListUiState { + data object Loading : RecipientListUiState + + data class Success( + val recipients: List, + ) : RecipientListUiState + + data object Error : RecipientListUiState +} + @HiltViewModel class RecipientListViewModel @Inject constructor( - private val userRepository: UserRepository, + private val receiverRepository: UserReceiverRepository, ) : ViewModel() { - val recipients: StateFlow> = - userRepository.receiverListFlow - .map { receivers -> receivers.map { ReceiverListItem(it.receiverId, it.name, it.relation) } } + // receiverListFlow 는 실패를 마지막 목록이나 빈 목록으로 낮춰 실패와 실제 0건이 같은 모양이 된다(#1099). + // 상태 Flow 는 둘을 가르고(#2045), 세션 교체 시 이전 계정 목록도 SignedOut 으로 먼저 비운다(#2135). + val uiState: StateFlow = + receiverRepository.receiverListStateFlow + .map { it.toUiState() } .stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(5_000), - initialValue = emptyList(), + initialValue = RecipientListUiState.Loading, ) + + fun retry() { + receiverRepository.refreshReceiverList() + } + } + +private fun ReceiverListState.toUiState(): RecipientListUiState = + when (this) { + ReceiverListState.SignedOut -> RecipientListUiState.Success(emptyList()) + + // 갱신 로딩은 이 구독이 이미 낸 목록을 그대로 둔다 — 재시도·재조회 중 선택 화면이 깜빡이지 않게. + is ReceiverListState.Loading -> previousReceivers?.toSuccess() ?: RecipientListUiState.Loading + + is ReceiverListState.Success -> receivers.toSuccess() + + // 이전 목록이 있으면 그 목록을 유지하고, 없거나 401 로 버렸으면 실패를 그대로 보인다(#714). + is ReceiverListState.Failure -> + previousReceivers?.takeUnless { discardPrevious }?.toSuccess() ?: RecipientListUiState.Error } + +private fun List.toSuccess(): RecipientListUiState = + RecipientListUiState.Success(map { ReceiverListItem(it.receiverId, it.name, it.relation) }) diff --git a/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/viewmodel/TimeLetterWriteUiState.kt b/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/viewmodel/TimeLetterWriteUiState.kt index 07c7de5843..978873cb2a 100644 --- a/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/viewmodel/TimeLetterWriteUiState.kt +++ b/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/viewmodel/TimeLetterWriteUiState.kt @@ -7,6 +7,7 @@ import com.afternote.feature.timeletter.domain.model.RecordedAudio data class TimeLetterWriteUiState( val editingTimeLetterId: Long? = null, val isLoadingEditingLetter: Boolean = false, + val editingLoadError: Boolean = false, val draftTitle: String? = null, val draftTextContents: Map = emptyMap(), val recipientIds: List = emptyList(), @@ -17,6 +18,7 @@ data class TimeLetterWriteUiState( val sendMinute: Int = 0, val draftCount: Int = 0, val isSaving: Boolean = false, + val isCheckingRegisterLimit: Boolean = false, val textAlign: TextAlign = TextAlign.Start, val error: TimeLetterWriteError? = null, val editorBlocks: List = listOf(EditorBlock.Text(id = 0L)), diff --git a/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/viewmodel/TimeLetterWriteViewModel.kt b/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/viewmodel/TimeLetterWriteViewModel.kt index 30fd000bf8..3fe1076e68 100644 --- a/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/viewmodel/TimeLetterWriteViewModel.kt +++ b/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/viewmodel/TimeLetterWriteViewModel.kt @@ -52,6 +52,7 @@ class TimeLetterWriteViewModel private val _uiState = MutableStateFlow( TimeLetterWriteUiState( + editingTimeLetterId = editingTimeLetterId, isLoadingEditingLetter = editingTimeLetterId != null, ), ) @@ -75,8 +76,11 @@ class TimeLetterWriteViewModel runCatchingCancellable { userRepository.getReceivers() } .getOrElse { emptyList() } .associate { it.receiverId to it.name } - editingTimeLetterId?.let { loadEditingTimeLetter(it) } + _uiState.update { state -> + state.copy(recipientNames = state.recipientIds.mapNotNull { receiverNameMap[it] }) + } } + editingTimeLetterId?.let { id -> viewModelScope.launch { loadEditingTimeLetter(id) } } loadDraftCount() observeRecipientResult() } @@ -156,7 +160,7 @@ class TimeLetterWriteViewModel textContents: Map, ) { val state = _uiState.value - if (state.isSaving || isCheckingRegisterLimit) return + if (state.isSaving || state.isCheckingRegisterLimit || state.editingLoadError) return if (state.recipientIds.isEmpty()) { _uiState.update { it.copy(error = TimeLetterWriteError.RecipientRequired) } @@ -166,7 +170,7 @@ class TimeLetterWriteViewModel _uiState.update { it.copy(error = TimeLetterWriteError.SendDateRequired) } return } - isCheckingRegisterLimit = true + _uiState.update { it.copy(isCheckingRegisterLimit = true) } viewModelScope.launch { try { if (originalEditingStatus != TimeLetterStatus.SCHEDULED) { @@ -187,11 +191,18 @@ class TimeLetterWriteViewModel save(title = title, textContents = textContents, status = TimeLetterStatus.SCHEDULED) } finally { - isCheckingRegisterLimit = false + _uiState.update { it.copy(isCheckingRegisterLimit = false) } } } } + fun retryEditingLetter() { + val timeLetterId = _uiState.value.editingTimeLetterId ?: return + if (_uiState.value.isLoadingEditingLetter) return + _uiState.update { it.copy(isLoadingEditingLetter = true, editingLoadError = false) } + viewModelScope.launch { loadEditingTimeLetter(timeLetterId) } + } + fun clearError() { _uiState.update { it.copy(error = null) } } @@ -435,14 +446,14 @@ class TimeLetterWriteViewModel status: TimeLetterStatus, ) { val state = _uiState.value - if (state.isSaving) return + if (state.isSaving || state.editingLoadError) return if (state.recipientIds.isEmpty()) { _uiState.update { it.copy(error = TimeLetterWriteError.RecipientRequired) } return } + _uiState.update { it.copy(isSaving = true) } viewModelScope.launch { - _uiState.update { it.copy(isSaving = true) } try { val sendAt = state.sendAt?.let { date -> @@ -531,6 +542,7 @@ class TimeLetterWriteViewModel it.copy( editingTimeLetterId = letter.id, isLoadingEditingLetter = false, + editingLoadError = false, draftTitle = letter.title.orEmpty(), draftTextContents = letter.blocks @@ -548,16 +560,22 @@ class TimeLetterWriteViewModel ) } }.onFailure { error -> - _uiState.update { it.copy(isLoadingEditingLetter = false) } - _uiState.update { - it.copy( - error = - if (error is TimeLetterServerRejectionException) { - TimeLetterWriteError.ServerRejection - } else { - TimeLetterWriteError.LoadFailed - }, - ) + if (error is TimeLetterServerRejectionException) { + // 명시적 거절(예: 삭제된 타임레터)은 재시도해도 같은 결과라 스낵바로 안내하고 끝낸다. + _uiState.update { + it.copy( + isLoadingEditingLetter = false, + error = TimeLetterWriteError.ServerRejection, + ) + } + } else { + // 네트워크 등 일시적 실패는 재시도가 의미 있어 전체 화면 재시도 UI로 안내한다. + _uiState.update { + it.copy( + isLoadingEditingLetter = false, + editingLoadError = true, + ) + } } } } diff --git a/feature/timeletter/presentation/src/main/res/values/strings.xml b/feature/timeletter/presentation/src/main/res/values/strings.xml index deca9f35d2..d623005fc0 100644 --- a/feature/timeletter/presentation/src/main/res/values/strings.xml +++ b/feature/timeletter/presentation/src/main/res/values/strings.xml @@ -25,6 +25,9 @@ 예 아니오 타임레터를 삭제하지 못했습니다. 다시 시도해 주세요. + 다시 시도 + 수신인 목록 + 수신자 목록을 불러올 수 없습니다. 이름으로 검색하기 임시 저장된 레터 선택 diff --git a/feature/timeletter/presentation/src/screenshotTest/kotlin/com/afternote/feature/timeletter/presentation/screen/sender/TimeletterScreenScreenshotTest.kt b/feature/timeletter/presentation/src/screenshotTest/kotlin/com/afternote/feature/timeletter/presentation/screen/sender/TimeletterScreenScreenshotTest.kt index 668bda0ad5..6c6ef9d224 100644 --- a/feature/timeletter/presentation/src/screenshotTest/kotlin/com/afternote/feature/timeletter/presentation/screen/sender/TimeletterScreenScreenshotTest.kt +++ b/feature/timeletter/presentation/src/screenshotTest/kotlin/com/afternote/feature/timeletter/presentation/screen/sender/TimeletterScreenScreenshotTest.kt @@ -74,6 +74,7 @@ private fun TimeletterScreenScreenshotContent( onEditClick = {}, onFilterRecipientClick = {}, onDeleteClick = {}, + onRetry = {}, ) } } diff --git a/feature/timeletter/presentation/src/test/kotlin/com/afternote/feature/timeletter/presentation/TimeLetterLifecycleTest.kt b/feature/timeletter/presentation/src/test/kotlin/com/afternote/feature/timeletter/presentation/TimeLetterLifecycleTest.kt index 89bd9bedfe..2ef119ffe4 100644 --- a/feature/timeletter/presentation/src/test/kotlin/com/afternote/feature/timeletter/presentation/TimeLetterLifecycleTest.kt +++ b/feature/timeletter/presentation/src/test/kotlin/com/afternote/feature/timeletter/presentation/TimeLetterLifecycleTest.kt @@ -109,9 +109,8 @@ class TimeLetterLifecycleTest { firstLoad.complete(Result.failure(IllegalStateException("offline"))) composeRule.waitUntil(timeoutMillis = TIMEOUT) { viewModel.uiState.value == TimeletterUiState.Error } - composeRule - .onNodeWithText("아직 등록된 타임레터가 없어요.", substring = true) - .assertIsDisplayed() + composeRule.onNodeWithText("타임레터를 불러올 수 없습니다.").assertIsDisplayed() + composeRule.onNodeWithText("다시 시도").assertIsDisplayed() val visibleLetters = TimeLetterList( @@ -123,10 +122,8 @@ class TimeLetterLifecycleTest { totalCount = 2, ) val retryLoad = CompletableDeferred>() - composeRule.runOnIdle { - nextListLoad = retryLoad - viewModel.load() - } + composeRule.runOnIdle { nextListLoad = retryLoad } + composeRule.onNodeWithText("다시 시도").performClick() composeRule.waitUntil(timeoutMillis = TIMEOUT) { viewModel.uiState.value == TimeletterUiState.Loading } retryLoad.complete(Result.success(visibleLetters)) diff --git a/feature/timeletter/presentation/src/test/kotlin/com/afternote/feature/timeletter/presentation/screen/sender/RecipientListScreenTest.kt b/feature/timeletter/presentation/src/test/kotlin/com/afternote/feature/timeletter/presentation/screen/sender/RecipientListScreenTest.kt new file mode 100644 index 0000000000..7be702951f --- /dev/null +++ b/feature/timeletter/presentation/src/test/kotlin/com/afternote/feature/timeletter/presentation/screen/sender/RecipientListScreenTest.kt @@ -0,0 +1,74 @@ +package com.afternote.feature.timeletter.presentation.screen.sender + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.test.filterToOne +import androidx.compose.ui.test.hasClickAction +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onChildren +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import com.afternote.core.model.setting.ReceiverListItem +import com.afternote.core.ui.theme.AfternoteTheme +import com.afternote.feature.timeletter.presentation.viewmodel.RecipientListUiState +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 + +/** + * 1hyok/Sadturtleman 님 리뷰 지적 — 백그라운드 5초 이상 뒤 재구독으로 uiState 가 Loading 을 거쳐 + * Success 로 돌아올 때(WhileSubscribed(5_000)) 체크해 둔 수신인 선택이 사라지던 회귀(#714)의 검증. + * RecipientListScreen 은 이 순환과 무관하게 계속 컴포지션에 남아 있어야 하고, 선택은 그 화면 + * 최상단으로 옮겨졌으니 살아남아야 한다 — uiState 를 직접 갈아끼워 그 순환만 재현한다. + */ +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(sdk = [35]) +class RecipientListScreenTest { + @get:Rule + val composeRule = createComposeRule() + + @Test + fun `uiState가 Loading을 거쳐 다시 Success가 되어도 체크해 둔 수신인 선택이 유지된다`() { + val recipients = + listOf( + ReceiverListItem(receiverId = 1L, name = "김수신", relation = "가족"), + ReceiverListItem(receiverId = 2L, name = "이수신", relation = "친구"), + ) + var uiState by mutableStateOf(RecipientListUiState.Success(recipients)) + var confirmed: List? = null + + composeRule.setContent { + AfternoteTheme { + RecipientListScreen( + uiState = uiState, + onBackClick = {}, + onConfirmClick = { confirmed = it }, + onRetry = {}, + ) + } + } + + composeRule + .onNodeWithTag("recipient_item_1", useUnmergedTree = true) + .onChildren() + .filterToOne(hasClickAction()) + .performClick() + + // WhileSubscribed(5_000) 재구독이 uiState 에 만드는 것과 같은 Loading→Success 순환. + composeRule.runOnIdle { uiState = RecipientListUiState.Loading } + composeRule.waitForIdle() + composeRule.runOnIdle { uiState = RecipientListUiState.Success(recipients) } + composeRule.waitForIdle() + + composeRule.onNodeWithText("수신자 선택 완료하기").performClick() + + assertEquals(listOf(1L), confirmed?.map { it.receiverId }) + } +} diff --git a/feature/timeletter/presentation/src/test/kotlin/com/afternote/feature/timeletter/presentation/viewmodel/RecipientListViewModelTest.kt b/feature/timeletter/presentation/src/test/kotlin/com/afternote/feature/timeletter/presentation/viewmodel/RecipientListViewModelTest.kt new file mode 100644 index 0000000000..6b0958bbfa --- /dev/null +++ b/feature/timeletter/presentation/src/test/kotlin/com/afternote/feature/timeletter/presentation/viewmodel/RecipientListViewModelTest.kt @@ -0,0 +1,133 @@ +package com.afternote.feature.timeletter.presentation.viewmodel + +import com.afternote.core.domain.model.ReceiverListState +import com.afternote.core.domain.testing.FakeUserReceiverRepository +import com.afternote.core.model.user.Receiver +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class RecipientListViewModelTest { + private val dispatcher = StandardTestDispatcher() + private val receiver = Receiver(1L, "김수신", "가족") + private val states = MutableStateFlow(ReceiverListState.Loading(null, SESSION)) + private val repository = FakeUserReceiverRepository(onReceiverListStateFlow = { states }) + + @Before + fun setUp() { + Dispatchers.setMain(dispatcher) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `첫 조회 실패는 빈 목록이 아니라 Error 상태가 된다`() = + runTest(dispatcher) { + val viewModel = RecipientListViewModel(repository) + val job = launch { viewModel.uiState.collect {} } + advanceUntilIdle() + assertEquals(RecipientListUiState.Loading, viewModel.uiState.value) + + states.value = ReceiverListState.Failure(previousReceivers = null, discardPrevious = false) + advanceUntilIdle() + + assertEquals(RecipientListUiState.Error, viewModel.uiState.value) + job.cancel() + } + + @Test + fun `조회 성공 시 목록을 그대로 노출한다`() = + runTest(dispatcher) { + states.value = ReceiverListState.Success(listOf(receiver)) + val viewModel = RecipientListViewModel(repository) + val job = launch { viewModel.uiState.collect {} } + + advanceUntilIdle() + + assertEquals(listOf("김수신"), viewModel.uiState.value.recipientNames()) + job.cancel() + } + + @Test + fun `재시도는 저장소 갱신을 요청하고 성공하면 목록을 복구한다`() = + runTest(dispatcher) { + states.value = ReceiverListState.Failure(previousReceivers = null, discardPrevious = false) + val viewModel = RecipientListViewModel(repository) + val job = launch { viewModel.uiState.collect {} } + advanceUntilIdle() + assertEquals(RecipientListUiState.Error, viewModel.uiState.value) + + viewModel.retry() + states.value = ReceiverListState.Success(listOf(receiver)) + advanceUntilIdle() + + assertEquals(1, repository.refreshReceiverListCalls) + assertEquals(listOf("김수신"), viewModel.uiState.value.recipientNames()) + job.cancel() + } + + @Test + fun `갱신 로딩과 갱신 실패는 마지막 성공 목록을 유지한다`() = + runTest(dispatcher) { + val viewModel = RecipientListViewModel(repository) + val job = launch { viewModel.uiState.collect {} } + + states.value = ReceiverListState.Loading(listOf(receiver), SESSION) + advanceUntilIdle() + assertEquals(listOf("김수신"), viewModel.uiState.value.recipientNames()) + + states.value = ReceiverListState.Failure(listOf(receiver), discardPrevious = false) + advanceUntilIdle() + assertEquals(listOf("김수신"), viewModel.uiState.value.recipientNames()) + job.cancel() + } + + @Test + fun `401 로 버린 목록은 유지하지 않고 Error 를 보인다`() = + runTest(dispatcher) { + val viewModel = RecipientListViewModel(repository) + val job = launch { viewModel.uiState.collect {} } + + states.value = ReceiverListState.Failure(listOf(receiver), discardPrevious = true) + advanceUntilIdle() + + assertEquals(RecipientListUiState.Error, viewModel.uiState.value) + job.cancel() + } + + @Test + fun `세션이 끝나면 이전 계정 목록을 비운다`() = + runTest(dispatcher) { + states.value = ReceiverListState.Success(listOf(receiver)) + val viewModel = RecipientListViewModel(repository) + val job = launch { viewModel.uiState.collect {} } + advanceUntilIdle() + + states.value = ReceiverListState.SignedOut + advanceUntilIdle() + + assertEquals(emptyList(), viewModel.uiState.value.recipientNames()) + job.cancel() + } + + private fun RecipientListUiState.recipientNames(): List? = + (this as? RecipientListUiState.Success)?.recipients?.map { it.name } + + private companion object { + const val SESSION = "session-a" + } +}