From 0f3bc46560690db6d5c248d6e2f4ee64b2113507 Mon Sep 17 00:00:00 2001 From: kyungmin Date: Fri, 28 Aug 2026 21:27:25 +0900 Subject: [PATCH 01/13] =?UTF-8?q?fix(timeletter):=20=EC=84=9C=EB=B2=84=20?= =?UTF-8?q?=EC=8B=A4=ED=8C=A8=20=EC=83=81=ED=83=9C=EC=99=80=20=EC=9E=AC?= =?UTF-8?q?=EC=8B=9C=EB=8F=84=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../presentation/component/AlignButton.kt | 3 +- .../presentation/component/RecipientCard.kt | 3 +- .../presentation/component/SendScheduleRow.kt | 5 +- .../component/TimeLetterBottomBar.kt | 12 +- .../component/TimeLetterLoadErrorContent.kt | 37 +++++ .../component/TimeLetterTextButton.kt | 23 +++- .../navigation/TimeLetterNavGraph.kt | 1 + .../screen/sender/DraftLetterScreen.kt | 50 ++++++- .../screen/sender/RecipientListScreen.kt | 56 +++++++- .../screen/sender/TimeLetterWriteScreen.kt | 24 +++- .../screen/sender/TimeletterScreen.kt | 7 +- .../viewmodel/DraftLetterUiState.kt | 2 + .../viewmodel/DraftLetterViewModel.kt | 18 ++- .../viewmodel/RecipientListViewModel.kt | 42 +++++- .../viewmodel/TimeLetterWriteUiState.kt | 2 + .../viewmodel/TimeLetterWriteViewModel.kt | 33 +++-- .../presentation/TimeLetterLifecycleTest.kt | 11 +- .../viewmodel/DraftLetterViewModelTest.kt | 128 ++++++++++++++++++ 18 files changed, 409 insertions(+), 48 deletions(-) create mode 100644 feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/component/TimeLetterLoadErrorContent.kt create mode 100644 feature/timeletter/presentation/src/test/kotlin/com/afternote/feature/timeletter/presentation/viewmodel/DraftLetterViewModelTest.kt 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 32b4f0b45c..462f061938 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 @@ -38,6 +38,7 @@ fun TimeLetterBottomBar( onDraftClick: () -> Unit, onDraftCountClick: () -> Unit, modifier: Modifier = Modifier, + isEnabled: Boolean = true, ) { Row( modifier = @@ -52,7 +53,7 @@ fun TimeLetterBottomBar( modifier = Modifier .size(24.dp) - .clickable(onClick = onMediaAddClick), + .clickable(enabled = isEnabled, onClick = onMediaAddClick), contentAlignment = Alignment.Center, ) { Icon( @@ -69,7 +70,7 @@ fun TimeLetterBottomBar( modifier = Modifier .size(24.dp) - .clickable(onClick = onTextStyleClick), + .clickable(enabled = isEnabled, onClick = onTextStyleClick), contentAlignment = Alignment.Center, ) { Icon( @@ -95,22 +96,25 @@ fun TimeLetterBottomBar( painter = painterResource(R.drawable.ic_align_center), selected = textAlign == TextAlign.Center, // 상태 반영 onClick = onAlignCenterClick, + enabled = isEnabled, ) AlignButton( painter = painterResource(R.drawable.ic_align_left), selected = textAlign == TextAlign.Start, onClick = onAlignLeftClick, + enabled = isEnabled, ) AlignButton( painter = painterResource(R.drawable.ic_align_right), selected = textAlign == TextAlign.End, onClick = onAlignRightClick, + enabled = isEnabled, ) } Spacer(modifier = Modifier.weight(1f)) - TextButton(onClick = onDraftClick) { + TextButton(onClick = onDraftClick, enabled = isEnabled) { Text( text = "임시저장", style = AfternoteDesign.typography.captionLargeR, @@ -122,7 +126,7 @@ fun TimeLetterBottomBar( text = "$draftCount", style = AfternoteDesign.typography.captionLargeR, color = AfternoteDesign.colors.gray7, - modifier = Modifier.clickable(onClick = onDraftCountClick), + modifier = Modifier.clickable(enabled = isEnabled, 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..8af1f1bac7 --- /dev/null +++ b/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/component/TimeLetterLoadErrorContent.kt @@ -0,0 +1,37 @@ +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 com.afternote.core.ui.theme.AfternoteDesign + +@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 = "다시 시도", + 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..3a4713569d 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,7 @@ fun TimeLetterTextButton( onClick: () -> Unit, modifier: Modifier = Modifier, isActive: Boolean = false, + isLoading: Boolean = false, ) { val shape = RoundedCornerShape(6.dp) val backgroundColor = @@ -38,14 +41,22 @@ fun TimeLetterTextButton( .height(36.dp) .clip(shape) .background(color = backgroundColor, shape = shape) - .clickable(onClick = onClick) + .clickable(enabled = isActive && !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 c0779d48e9..2e77f9999c 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 @@ -58,6 +58,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() diff --git a/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/screen/sender/DraftLetterScreen.kt b/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/screen/sender/DraftLetterScreen.kt index c97ae8ac2f..d90b7e40c1 100644 --- a/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/screen/sender/DraftLetterScreen.kt +++ b/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/screen/sender/DraftLetterScreen.kt @@ -1,12 +1,19 @@ package com.afternote.feature.timeletter.presentation.screen.sender +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel @@ -15,6 +22,7 @@ import com.afternote.core.ui.topbar.DetailTopBar import com.afternote.feature.timeletter.domain.model.TimeLetter import com.afternote.feature.timeletter.domain.model.TimeLetterStatus import com.afternote.feature.timeletter.presentation.component.DraftLetterItem +import com.afternote.feature.timeletter.presentation.component.TimeLetterLoadErrorContent import com.afternote.feature.timeletter.presentation.component.TimeLetterTextButton import com.afternote.feature.timeletter.presentation.viewmodel.DraftLetterUiState import com.afternote.feature.timeletter.presentation.viewmodel.DraftLetterViewModel @@ -26,6 +34,14 @@ fun DraftLetterScreen( viewModel: DraftLetterViewModel = hiltViewModel(), ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val snackbarHostState = remember { SnackbarHostState() } + val errorMessage = (uiState as? DraftLetterUiState.Success)?.errorMessage + + LaunchedEffect(errorMessage) { + errorMessage ?: return@LaunchedEffect + snackbarHostState.showSnackbar(errorMessage) + viewModel.consumeErrorMessage() + } DraftLetterContent( uiState = uiState, @@ -39,6 +55,8 @@ fun DraftLetterScreen( } }, onToggleSelection = viewModel::toggleSelection, + onRetry = viewModel::loadDrafts, + snackbarHostState = snackbarHostState, modifier = modifier, ) } @@ -49,13 +67,17 @@ private fun DraftLetterContent( onBackClick: () -> Unit, onEditCompleteClick: () -> Unit, onToggleSelection: (Long) -> Unit, + onRetry: () -> Unit, + snackbarHostState: SnackbarHostState, modifier: Modifier = Modifier, ) { val isEditMode = (uiState as? DraftLetterUiState.Success)?.isEditMode ?: false val selectedIds = (uiState as? DraftLetterUiState.Success)?.selectedIds ?: emptySet() + val isDeleting = (uiState as? DraftLetterUiState.Success)?.isDeleting ?: false Scaffold( modifier = modifier, + snackbarHost = { SnackbarHost(snackbarHostState) }, topBar = { DetailTopBar( title = "임시저장된 레터", @@ -68,7 +90,8 @@ private fun DraftLetterContent( selectedIds.isEmpty() -> "취소" else -> "삭제" }, - isActive = isEditMode && selectedIds.isNotEmpty(), + isActive = !isDeleting, + isLoading = isDeleting, onClick = onEditCompleteClick, ) }, @@ -77,11 +100,20 @@ private fun DraftLetterContent( ) { innerPadding -> when (uiState) { is DraftLetterUiState.Loading -> { - Unit + Box( + modifier = Modifier.fillMaxSize().padding(innerPadding), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } } is DraftLetterUiState.Error -> { - Unit + TimeLetterLoadErrorContent( + message = uiState.message, + onRetry = onRetry, + modifier = Modifier.padding(innerPadding), + ) } is DraftLetterUiState.Success -> { @@ -103,6 +135,14 @@ private fun DraftLetterContent( ) } } + if (isDeleting) { + Box( + modifier = Modifier.fillMaxSize().padding(innerPadding), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } + } } } } @@ -148,6 +188,8 @@ private fun DraftLetterScreenNormalPreview() { onBackClick = {}, onEditCompleteClick = {}, onToggleSelection = {}, + onRetry = {}, + snackbarHostState = remember { SnackbarHostState() }, ) } @@ -184,5 +226,7 @@ private fun DraftLetterScreenEditModePreview() { onBackClick = {}, onEditCompleteClick = {}, onToggleSelection = {}, + onRetry = {}, + snackbarHostState = remember { SnackbarHostState() }, ) } 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..78afad8fdb 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,6 +11,7 @@ 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 @@ -41,6 +42,8 @@ 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.component.TimeLetterLoadErrorContent +import com.afternote.feature.timeletter.presentation.viewmodel.RecipientListUiState import com.afternote.feature.timeletter.presentation.viewmodel.RecipientListViewModel import kotlinx.coroutines.launch @@ -52,14 +55,53 @@ fun RecipientListScreen( allowEmptyConfirm: Boolean = false, viewModel: RecipientListViewModel = hiltViewModel(), ) { - val recipients by viewModel.recipients.collectAsStateWithLifecycle() - RecipientListContent( - recipients = recipients, - onBackClick = onBackClick, - onConfirmClick = onConfirmClick, - allowEmptyConfirm = allowEmptyConfirm, + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + when (val state = uiState) { + RecipientListUiState.Loading -> { + RecipientListLoadingContent(onBackClick, modifier) + } + + is RecipientListUiState.Error -> { + Scaffold( + modifier = modifier, + topBar = { DetailTopBar(title = "수신자 목록", onBackClick = onBackClick) }, + ) { innerPadding -> + TimeLetterLoadErrorContent( + message = state.message, + onRetry = viewModel::retry, + modifier = Modifier.padding(innerPadding), + ) + } + } + + is RecipientListUiState.Success -> { + RecipientListContent( + recipients = state.recipients, + onBackClick = onBackClick, + onConfirmClick = onConfirmClick, + allowEmptyConfirm = allowEmptyConfirm, + modifier = modifier, + ) + } + } +} + +@Composable +private fun RecipientListLoadingContent( + onBackClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Scaffold( modifier = modifier, - ) + topBar = { DetailTopBar(title = "수신자 목록", onBackClick = onBackClick) }, + ) { innerPadding -> + Box( + modifier = Modifier.fillMaxSize().padding(innerPadding), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } + } } @Composable 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 8b2583162a..55f00e04b1 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 @@ -74,6 +74,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 @@ -100,6 +101,7 @@ fun TimeLetterWriteScreen( onDraftClick: (title: String, textContents: Map) -> Unit = { _, _ -> }, onNavigateToDraft: () -> Unit = {}, onErrorShown: () -> Unit = {}, + onRetryEditingLetter: () -> Unit = {}, onAddImageBlock: (Uri) -> Unit = {}, onAddAudioBlock: (Uri) -> Unit = {}, onAddFileBlock: (Uri) -> Unit = {}, @@ -179,6 +181,21 @@ fun TimeLetterWriteScreen( return } + uiState.editingLoadError?.let { message -> + Scaffold( + modifier = modifier, + topBar = { DetailTopBar(title = "", onBackClick = onBackClick) }, + containerColor = AfternoteDesign.colors.white, + ) { innerPadding -> + TimeLetterLoadErrorContent( + message = message, + 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) { @@ -189,6 +206,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) } @@ -361,7 +379,8 @@ fun TimeLetterWriteScreen( collectTextContents(), ) }, - isActive = !uiState.isSaving && uiState.sendAt != null, + isActive = !isSubmitting && uiState.sendAt != null, + isLoading = isSubmitting, ) }, ) @@ -377,6 +396,7 @@ fun TimeLetterWriteScreen( onAlignRightClick = onAlignRightClick, onDraftClick = { onDraftClick(titleState.text.toString(), collectTextContents()) }, onDraftCountClick = onNavigateToDraft, + isEnabled = !isSubmitting, ) }, containerColor = AfternoteDesign.colors.white, @@ -394,6 +414,7 @@ fun TimeLetterWriteScreen( collectTextContents(), ) }, + enabled = !isSubmitting, ) } item(key = "divider_1") { @@ -405,6 +426,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 84639183fa..0de97de5bb 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 @@ -34,6 +34,7 @@ import com.afternote.feature.timeletter.domain.model.TimeLetterStatus import com.afternote.feature.timeletter.presentation.R import com.afternote.feature.timeletter.presentation.component.EmptyTimeLetterContent 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.TimeletterViewModel import com.afternote.feature.timeletter.presentation.viewmodel.ViewMode @@ -104,7 +105,11 @@ fun TimeletterScreen( } is TimeletterUiState.Error -> { - EmptyTimeLetterContent(modifier = Modifier.padding(paddingValues)) + TimeLetterLoadErrorContent( + message = stringResource(R.string.timeletter_write_load_failed), + onRetry = viewModel::load, + modifier = Modifier.padding(paddingValues), + ) } is TimeletterUiState.Success -> { diff --git a/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/viewmodel/DraftLetterUiState.kt b/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/viewmodel/DraftLetterUiState.kt index e6283c7dbe..2386e6c835 100644 --- a/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/viewmodel/DraftLetterUiState.kt +++ b/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/viewmodel/DraftLetterUiState.kt @@ -9,6 +9,8 @@ sealed interface DraftLetterUiState { val drafts: List, val isEditMode: Boolean = false, val selectedIds: Set = emptySet(), + val isDeleting: Boolean = false, + val errorMessage: String? = null, ) : DraftLetterUiState data class Error( diff --git a/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/viewmodel/DraftLetterViewModel.kt b/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/viewmodel/DraftLetterViewModel.kt index 9a1d9a87fc..3f71977c39 100644 --- a/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/viewmodel/DraftLetterViewModel.kt +++ b/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/viewmodel/DraftLetterViewModel.kt @@ -37,18 +37,21 @@ class DraftLetterViewModel fun toggleEditMode() { val current = _uiState.value as? DraftLetterUiState.Success ?: return + if (current.isDeleting) return _uiState.value = current.copy(isEditMode = !current.isEditMode, selectedIds = emptySet()) } fun toggleSelection(id: Long) { val current = _uiState.value as? DraftLetterUiState.Success ?: return + if (current.isDeleting) return val updated = if (id in current.selectedIds) current.selectedIds - id else current.selectedIds + id _uiState.value = current.copy(selectedIds = updated) } fun deleteSelected() { val current = _uiState.value as? DraftLetterUiState.Success ?: return - if (current.selectedIds.isEmpty()) return + if (current.selectedIds.isEmpty() || current.isDeleting) return + _uiState.value = current.copy(isDeleting = true, errorMessage = null) viewModelScope.launch { runCatching { timeLetterRepository.deleteTimeLetters(current.selectedIds.toList()) @@ -58,18 +61,31 @@ class DraftLetterViewModel drafts = current.drafts.filter { it.id !in current.selectedIds }, selectedIds = emptySet(), isEditMode = false, + isDeleting = false, ) + }.onFailure { + _uiState.value = current.copy(isDeleting = false, errorMessage = "임시저장 레터를 삭제할 수 없습니다.") } } } fun deleteAll() { + val current = _uiState.value as? DraftLetterUiState.Success ?: return + if (current.isDeleting) return + _uiState.value = current.copy(isDeleting = true, errorMessage = null) viewModelScope.launch { runCatching { timeLetterRepository.deleteAllTemporary() }.onSuccess { _uiState.value = DraftLetterUiState.Success(drafts = emptyList()) + }.onFailure { + _uiState.value = current.copy(isDeleting = false, errorMessage = "임시저장 레터를 삭제할 수 없습니다.") } } } + + fun consumeErrorMessage() { + val current = _uiState.value as? DraftLetterUiState.Success ?: return + _uiState.value = current.copy(errorMessage = null) + } } 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..42b26e6b05 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 @@ -5,24 +5,56 @@ import androidx.lifecycle.viewModelScope import com.afternote.core.domain.repository.UserRepository import com.afternote.core.model.setting.ReceiverListItem import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update import javax.inject.Inject +sealed interface RecipientListUiState { + data object Loading : RecipientListUiState + + data class Success( + val recipients: List, + ) : RecipientListUiState + + data class Error( + val message: String, + ) : RecipientListUiState +} + @HiltViewModel +@OptIn(ExperimentalCoroutinesApi::class) class RecipientListViewModel @Inject constructor( private val userRepository: UserRepository, ) : ViewModel() { - val recipients: StateFlow> = - userRepository.receiverListFlow - .map { receivers -> receivers.map { ReceiverListItem(it.receiverId, it.name, it.relation) } } - .stateIn( + private val retryRevision = MutableStateFlow(0L) + + val uiState: StateFlow = + retryRevision + .flatMapLatest { + userRepository.receiverListFlow + .map { receivers -> + RecipientListUiState.Success( + receivers.map { ReceiverListItem(it.receiverId, it.name, it.relation) }, + ) as RecipientListUiState + }.onStart { emit(RecipientListUiState.Loading) } + .catch { emit(RecipientListUiState.Error("수신자 목록을 불러올 수 없습니다.")) } + }.stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(5_000), - initialValue = emptyList(), + initialValue = RecipientListUiState.Loading, ) + + fun retry() { + retryRevision.update { it + 1 } + } } 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 547264359f..9d31d172e6 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 @@ -6,6 +6,7 @@ import androidx.compose.ui.text.style.TextAlign data class TimeLetterWriteUiState( val editingTimeLetterId: Long? = null, val isLoadingEditingLetter: Boolean = false, + val editingLoadError: String? = null, val draftTitle: String? = null, val draftTextContents: Map = emptyMap(), val recipientIds: List = emptyList(), @@ -16,6 +17,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 fe2b094355..3c03f3649a 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 @@ -46,13 +46,13 @@ class TimeLetterWriteViewModel private val _uiState = MutableStateFlow( TimeLetterWriteUiState( + editingTimeLetterId = editingTimeLetterId, isLoadingEditingLetter = editingTimeLetterId != null, ), ) val uiState: StateFlow = _uiState.asStateFlow() private var receiverNameMap: Map = emptyMap() - private var isCheckingRegisterLimit: Boolean = false init { viewModelScope.launch { @@ -60,8 +60,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() } @@ -141,7 +144,7 @@ class TimeLetterWriteViewModel textContents: Map, ) { val state = _uiState.value - if (state.isSaving || isCheckingRegisterLimit) return + if (state.isSaving || state.isCheckingRegisterLimit || state.editingLoadError != null) return if (state.recipientIds.isEmpty()) { _uiState.update { it.copy(error = TimeLetterWriteError.RECIPIENT_REQUIRED) } @@ -151,7 +154,7 @@ class TimeLetterWriteViewModel _uiState.update { it.copy(error = TimeLetterWriteError.SEND_DATE_REQUIRED) } return } - isCheckingRegisterLimit = true + _uiState.update { it.copy(isCheckingRegisterLimit = true) } viewModelScope.launch { try { if (state.editingTimeLetterId == null) { @@ -172,11 +175,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 = null) } + viewModelScope.launch { loadEditingTimeLetter(timeLetterId) } + } + fun clearError() { _uiState.update { it.copy(error = null) } } @@ -278,14 +288,14 @@ class TimeLetterWriteViewModel status: TimeLetterStatus, ) { val state = _uiState.value - if (state.isSaving) return + if (state.isSaving || state.editingLoadError != null) return if (state.recipientIds.isEmpty()) { _uiState.update { it.copy(error = TimeLetterWriteError.RECIPIENT_REQUIRED) } return } + _uiState.update { it.copy(isSaving = true) } viewModelScope.launch { - _uiState.update { it.copy(isSaving = true) } try { val sendAt = state.sendAt?.let { date -> @@ -361,6 +371,7 @@ class TimeLetterWriteViewModel it.copy( editingTimeLetterId = letter.id, isLoadingEditingLetter = false, + editingLoadError = null, draftTitle = letter.title.orEmpty(), draftTextContents = letter.blocks @@ -378,8 +389,12 @@ class TimeLetterWriteViewModel ) } }.onFailure { - _uiState.update { it.copy(isLoadingEditingLetter = false) } - _uiState.update { it.copy(error = TimeLetterWriteError.LOAD_FAILED) } + _uiState.update { + it.copy( + isLoadingEditingLetter = false, + editingLoadError = "타임레터를 불러올 수 없습니다.", + ) + } } } 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 7454ecf752..3cdaa84ac3 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 @@ -105,9 +105,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( @@ -119,10 +118,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/viewmodel/DraftLetterViewModelTest.kt b/feature/timeletter/presentation/src/test/kotlin/com/afternote/feature/timeletter/presentation/viewmodel/DraftLetterViewModelTest.kt new file mode 100644 index 0000000000..cf8e4ccc4c --- /dev/null +++ b/feature/timeletter/presentation/src/test/kotlin/com/afternote/feature/timeletter/presentation/viewmodel/DraftLetterViewModelTest.kt @@ -0,0 +1,128 @@ +package com.afternote.feature.timeletter.presentation.viewmodel + +import com.afternote.feature.timeletter.domain.model.NewTimeLetterBlock +import com.afternote.feature.timeletter.domain.model.TimeLetter +import com.afternote.feature.timeletter.domain.model.TimeLetterDeliveryMode +import com.afternote.feature.timeletter.domain.model.TimeLetterList +import com.afternote.feature.timeletter.domain.model.TimeLetterStatus +import com.afternote.feature.timeletter.domain.repository.TimeLetterRepository +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +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.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class DraftLetterViewModelTest { + private val dispatcher = StandardTestDispatcher() + + @Before + fun setUp() { + Dispatchers.setMain(dispatcher) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `조회 실패는 빈 목록이 아니라 Error 상태가 된다`() = + runTest(dispatcher) { + val viewModel = DraftLetterViewModel(FakeDraftTimeLetterRepository(loadFailure = true)) + + advanceUntilIdle() + + assertTrue(viewModel.uiState.value is DraftLetterUiState.Error) + } + + @Test + fun `조회 실패 후 다시 시도하면 목록을 복구한다`() = + runTest(dispatcher) { + val repository = FakeDraftTimeLetterRepository(loadFailure = true) + val viewModel = DraftLetterViewModel(repository) + advanceUntilIdle() + repository.loadFailure = false + + viewModel.loadDrafts() + advanceUntilIdle() + + val state = viewModel.uiState.value as DraftLetterUiState.Success + assertEquals(listOf(1L), state.drafts.map { it.id }) + } + + @Test + fun `선택 삭제 실패 시 기존 목록과 선택을 유지하고 오류를 노출한다`() = + runTest(dispatcher) { + val repository = FakeDraftTimeLetterRepository(deleteFailure = true) + val viewModel = DraftLetterViewModel(repository) + advanceUntilIdle() + viewModel.toggleEditMode() + viewModel.toggleSelection(1L) + + viewModel.deleteSelected() + advanceUntilIdle() + + val state = viewModel.uiState.value as DraftLetterUiState.Success + assertEquals(listOf(1L), state.drafts.map { it.id }) + assertEquals(setOf(1L), state.selectedIds) + assertEquals("임시저장 레터를 삭제할 수 없습니다.", state.errorMessage) + assertEquals(false, state.isDeleting) + } +} + +private class FakeDraftTimeLetterRepository( + var loadFailure: Boolean = false, + var deleteFailure: Boolean = false, +) : TimeLetterRepository { + private val draft = + TimeLetter( + id = 1L, + title = "임시저장", + sendAt = null, + deliveredAt = null, + status = TimeLetterStatus.DRAFT, + blocks = emptyList(), + receiverIds = emptyList(), + ) + + override suspend fun getTemporaryTimeLetters(): TimeLetterList { + if (loadFailure) error("조회 실패") + return TimeLetterList(timeLetters = listOf(draft), totalCount = 1) + } + + override suspend fun deleteTimeLetters(timeLetterIds: List) { + if (deleteFailure) error("삭제 실패") + } + + override suspend fun getTimeLetters(): TimeLetterList = error("사용하지 않음") + + override suspend fun getTimeLetter(timeLetterId: Long): TimeLetter = error("사용하지 않음") + + override suspend fun createTimeLetter( + title: String?, + blocks: List, + sendAt: String?, + deliveryMode: TimeLetterDeliveryMode, + status: TimeLetterStatus, + receiverIds: List, + ): TimeLetter = error("사용하지 않음") + + override suspend fun updateTimeLetter( + timeLetterId: Long, + title: String?, + blocks: List, + sendAt: String?, + deliveryMode: TimeLetterDeliveryMode?, + status: TimeLetterStatus?, + ): TimeLetter = error("사용하지 않음") + + override suspend fun deleteAllTemporary() = Unit +} From 321ddeb72ae90af312d85885135406dd17fbeaa6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Sat, 29 Aug 2026 04:15:36 +0900 Subject: [PATCH 02/13] =?UTF-8?q?ci:=20CI=20Test=20Plan=20=EC=9E=AC?= =?UTF-8?q?=EA=B2=80=EC=A6=9D=20=ED=8A=B8=EB=A6=AC=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From f9a48d3d3dbb83d67e45f97085dd5f4c5f900b4c Mon Sep 17 00:00:00 2001 From: kyungmin Date: Sat, 5 Sep 2026 23:23:32 +0900 Subject: [PATCH 03/13] =?UTF-8?q?fix(timeletter):=20=EB=A6=AC=EB=B7=B0=20?= =?UTF-8?q?=EC=A7=80=EC=A0=81=206=EA=B1=B4=EA=B3=BC=20=EC=BB=B4=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=20=EB=B8=94=EB=A1=9C=EC=BB=A4=EB=A5=BC=20=ED=95=B4?= =?UTF-8?q?=EC=86=8C=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1hyok/Sadturtleman 님의 #714 리뷰 지적을 해소한다: - [blocker] RecipientListViewModel.recipients가 uiState.Success 안으로 옮겨가며 develop의 ReceiverSessionIsolationAndroidTest(92·98행, #1288 계열)가 컴파일 단계에서 깨졌다. uiState 캐스팅 기준으로 고친다. - TimeLetterTextButton의 clickable이 isActive에 묶여 등록 버튼이 발송일 미선택 시 무반응으로 퇴행하고(발송일 검증 스낵바 무력화), develop의 TimeLetterMindRecordCompletionAndroidTest 탭 경로를 깼다. clickable을 전용 enabled 파라미터로 분리하고 isActive는 순수 스타일로 되돌린다. - 위 분리에 맞춰 DraftLetterScreen의 isActive를 원래 의미(편집모드+선택 있음)로 되돌리고 isLoading=isDeleting·enabled=!isDeleting만 추가한다. - RecipientListViewModel의 receiverListFlow.catch는 이미 머지된 #1099 (조회 실패를 빈 목록으로 낮춤) 이후 도달 불가능한 죽은 코드였다. suspend getReceivers() 프로브로 실패를 독립적으로 감지하되 receiverListFlow 구독은 유지해 세션 격리(로그아웃 시 자동 비움)를 보존한다. 이 경로를 검증하는 RecipientListViewModelTest를 새로 추가한다. - TimeLetterWriteViewModel.editingLoadError·DraftLetterViewModel의 삭제 실패 문구가 ViewModel 리터럴로 들어가 있던 것을 Boolean 상태로 좁히고 화면에서 string 리소스로 매핑한다. - TimeLetterLoadErrorContent의 "다시 시도" 하드코딩을 리소스로 뺀다. - TimeLetterBottomBar의 isEnabled를 형제 컴포넌트(AlignButton 등)와 같은 enabled로 통일한다. TimeLetterLoadErrorContent와 상세화면 3곳(TimeLetterDetailScreen 등)의 스타일 통일은 #446 정렬 후속 과제로 남겨 PR 본문에 명시한다. Co-Authored-By: Claude Sonnet 5 --- .../ReceiverSessionIsolationAndroidTest.kt | 8 +- .../component/TimeLetterBottomBar.kt | 16 ++-- .../component/TimeLetterLoadErrorContent.kt | 4 +- .../component/TimeLetterTextButton.kt | 3 +- .../screen/sender/DraftLetterScreen.kt | 16 ++-- .../screen/sender/RecipientListScreen.kt | 4 +- .../screen/sender/TimeLetterWriteScreen.kt | 7 +- .../viewmodel/DraftLetterUiState.kt | 2 +- .../viewmodel/DraftLetterViewModel.kt | 12 +-- .../viewmodel/RecipientListViewModel.kt | 34 ++++--- .../viewmodel/TimeLetterWriteUiState.kt | 2 +- .../viewmodel/TimeLetterWriteViewModel.kt | 10 +-- .../src/main/res/values/strings.xml | 3 + .../viewmodel/DraftLetterViewModelTest.kt | 2 +- .../viewmodel/RecipientListViewModelTest.kt | 90 +++++++++++++++++++ 15 files changed, 164 insertions(+), 49 deletions(-) create mode 100644 feature/timeletter/presentation/src/test/kotlin/com/afternote/feature/timeletter/presentation/viewmodel/RecipientListViewModelTest.kt 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 81c38347e3..caf81db581 100644 --- a/app/src/androidTest/java/com/afternote/afternote_fe/ReceiverSessionIsolationAndroidTest.kt +++ b/app/src/androidTest/java/com/afternote/afternote_fe/ReceiverSessionIsolationAndroidTest.kt @@ -31,6 +31,7 @@ import com.afternote.core.network.model.BaseResponse import com.afternote.core.network.service.UserApiService 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.Assert.assertEquals import org.junit.Rule @@ -89,13 +90,16 @@ class ReceiverSessionIsolationAndroidTest { } composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { - userApi.receiverCalls == 1 && viewModel.recipients.value.any { it.name == PREVIOUS_ACCOUNT_RECEIVER } + userApi.receiverCalls == 1 && + (viewModel.uiState.value as? RecipientListUiState.Success) + ?.recipients + ?.any { it.name == PREVIOUS_ACCOUNT_RECEIVER } == true } composeRule.onNodeWithText(PREVIOUS_ACCOUNT_RECEIVER).assertIsDisplayed() composeRule.runOnIdle { authRepository.loggedIn = false } composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { - viewModel.recipients.value.isEmpty() + (viewModel.uiState.value as? RecipientListUiState.Success)?.recipients?.isEmpty() == true } assertEquals(1, userApi.receiverCalls) composeRule.onNodeWithText(PREVIOUS_ACCOUNT_RECEIVER).assertDoesNotExist() 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 462f061938..db57221c9c 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 @@ -38,7 +38,7 @@ fun TimeLetterBottomBar( onDraftClick: () -> Unit, onDraftCountClick: () -> Unit, modifier: Modifier = Modifier, - isEnabled: Boolean = true, + enabled: Boolean = true, ) { Row( modifier = @@ -53,7 +53,7 @@ fun TimeLetterBottomBar( modifier = Modifier .size(24.dp) - .clickable(enabled = isEnabled, onClick = onMediaAddClick), + .clickable(enabled = enabled, onClick = onMediaAddClick), contentAlignment = Alignment.Center, ) { Icon( @@ -70,7 +70,7 @@ fun TimeLetterBottomBar( modifier = Modifier .size(24.dp) - .clickable(enabled = isEnabled, onClick = onTextStyleClick), + .clickable(enabled = enabled, onClick = onTextStyleClick), contentAlignment = Alignment.Center, ) { Icon( @@ -96,25 +96,25 @@ fun TimeLetterBottomBar( painter = painterResource(R.drawable.ic_align_center), selected = textAlign == TextAlign.Center, // 상태 반영 onClick = onAlignCenterClick, - enabled = isEnabled, + enabled = enabled, ) AlignButton( painter = painterResource(R.drawable.ic_align_left), selected = textAlign == TextAlign.Start, onClick = onAlignLeftClick, - enabled = isEnabled, + enabled = enabled, ) AlignButton( painter = painterResource(R.drawable.ic_align_right), selected = textAlign == TextAlign.End, onClick = onAlignRightClick, - enabled = isEnabled, + enabled = enabled, ) } Spacer(modifier = Modifier.weight(1f)) - TextButton(onClick = onDraftClick, enabled = isEnabled) { + TextButton(onClick = onDraftClick, enabled = enabled) { Text( text = "임시저장", style = AfternoteDesign.typography.captionLargeR, @@ -126,7 +126,7 @@ fun TimeLetterBottomBar( text = "$draftCount", style = AfternoteDesign.typography.captionLargeR, color = AfternoteDesign.colors.gray7, - modifier = Modifier.clickable(enabled = isEnabled, onClick = onDraftCountClick), + modifier = Modifier.clickable(enabled = enabled, 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 index 8af1f1bac7..e3207ec212 100644 --- 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 @@ -8,7 +8,9 @@ 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( @@ -28,7 +30,7 @@ fun TimeLetterLoadErrorContent( ) TextButton(onClick = onRetry) { Text( - 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 3a4713569d..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 @@ -26,6 +26,7 @@ fun TimeLetterTextButton( modifier: Modifier = Modifier, isActive: Boolean = false, isLoading: Boolean = false, + enabled: Boolean = true, ) { val shape = RoundedCornerShape(6.dp) val backgroundColor = @@ -41,7 +42,7 @@ fun TimeLetterTextButton( .height(36.dp) .clip(shape) .background(color = backgroundColor, shape = shape) - .clickable(enabled = isActive && !isLoading, onClick = onClick) + .clickable(enabled = enabled && !isLoading, onClick = onClick) .padding(start = 18.dp, top = 8.dp, end = 18.dp, bottom = 8.dp), ) { if (isLoading) { diff --git a/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/screen/sender/DraftLetterScreen.kt b/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/screen/sender/DraftLetterScreen.kt index d90b7e40c1..607a614a00 100644 --- a/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/screen/sender/DraftLetterScreen.kt +++ b/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/screen/sender/DraftLetterScreen.kt @@ -15,12 +15,14 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.afternote.core.ui.topbar.DetailTopBar import com.afternote.feature.timeletter.domain.model.TimeLetter import com.afternote.feature.timeletter.domain.model.TimeLetterStatus +import com.afternote.feature.timeletter.presentation.R import com.afternote.feature.timeletter.presentation.component.DraftLetterItem import com.afternote.feature.timeletter.presentation.component.TimeLetterLoadErrorContent import com.afternote.feature.timeletter.presentation.component.TimeLetterTextButton @@ -35,12 +37,13 @@ fun DraftLetterScreen( ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() val snackbarHostState = remember { SnackbarHostState() } - val errorMessage = (uiState as? DraftLetterUiState.Success)?.errorMessage + val deleteFailed = (uiState as? DraftLetterUiState.Success)?.deleteFailed ?: false + val deleteFailedMessage = stringResource(R.string.timeletter_draft_delete_failed) - LaunchedEffect(errorMessage) { - errorMessage ?: return@LaunchedEffect - snackbarHostState.showSnackbar(errorMessage) - viewModel.consumeErrorMessage() + LaunchedEffect(deleteFailed) { + if (!deleteFailed) return@LaunchedEffect + snackbarHostState.showSnackbar(deleteFailedMessage) + viewModel.consumeDeleteFailed() } DraftLetterContent( @@ -90,8 +93,9 @@ private fun DraftLetterContent( selectedIds.isEmpty() -> "취소" else -> "삭제" }, - isActive = !isDeleting, + isActive = isEditMode && selectedIds.isNotEmpty(), isLoading = isDeleting, + enabled = !isDeleting, onClick = onEditCompleteClick, ) }, 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 78afad8fdb..f2acd54e87 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 @@ -61,13 +61,13 @@ fun RecipientListScreen( RecipientListLoadingContent(onBackClick, modifier) } - is RecipientListUiState.Error -> { + RecipientListUiState.Error -> { Scaffold( modifier = modifier, topBar = { DetailTopBar(title = "수신자 목록", onBackClick = onBackClick) }, ) { innerPadding -> TimeLetterLoadErrorContent( - message = state.message, + message = stringResource(R.string.timeletter_recipient_list_load_failed), onRetry = viewModel::retry, modifier = Modifier.padding(innerPadding), ) 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 55f00e04b1..dc0a0310b2 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 @@ -181,14 +181,14 @@ fun TimeLetterWriteScreen( return } - uiState.editingLoadError?.let { message -> + if (uiState.editingLoadError) { Scaffold( modifier = modifier, topBar = { DetailTopBar(title = "", onBackClick = onBackClick) }, containerColor = AfternoteDesign.colors.white, ) { innerPadding -> TimeLetterLoadErrorContent( - message = message, + message = stringResource(R.string.timeletter_write_load_failed), onRetry = onRetryEditingLetter, modifier = Modifier.padding(innerPadding), ) @@ -381,6 +381,7 @@ fun TimeLetterWriteScreen( }, isActive = !isSubmitting && uiState.sendAt != null, isLoading = isSubmitting, + enabled = !isSubmitting, ) }, ) @@ -396,7 +397,7 @@ fun TimeLetterWriteScreen( onAlignRightClick = onAlignRightClick, onDraftClick = { onDraftClick(titleState.text.toString(), collectTextContents()) }, onDraftCountClick = onNavigateToDraft, - isEnabled = !isSubmitting, + enabled = !isSubmitting, ) }, containerColor = AfternoteDesign.colors.white, diff --git a/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/viewmodel/DraftLetterUiState.kt b/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/viewmodel/DraftLetterUiState.kt index 2386e6c835..5f39120f96 100644 --- a/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/viewmodel/DraftLetterUiState.kt +++ b/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/viewmodel/DraftLetterUiState.kt @@ -10,7 +10,7 @@ sealed interface DraftLetterUiState { val isEditMode: Boolean = false, val selectedIds: Set = emptySet(), val isDeleting: Boolean = false, - val errorMessage: String? = null, + val deleteFailed: Boolean = false, ) : DraftLetterUiState data class Error( diff --git a/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/viewmodel/DraftLetterViewModel.kt b/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/viewmodel/DraftLetterViewModel.kt index 3f71977c39..55566a8358 100644 --- a/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/viewmodel/DraftLetterViewModel.kt +++ b/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/viewmodel/DraftLetterViewModel.kt @@ -51,7 +51,7 @@ class DraftLetterViewModel fun deleteSelected() { val current = _uiState.value as? DraftLetterUiState.Success ?: return if (current.selectedIds.isEmpty() || current.isDeleting) return - _uiState.value = current.copy(isDeleting = true, errorMessage = null) + _uiState.value = current.copy(isDeleting = true, deleteFailed = false) viewModelScope.launch { runCatching { timeLetterRepository.deleteTimeLetters(current.selectedIds.toList()) @@ -64,7 +64,7 @@ class DraftLetterViewModel isDeleting = false, ) }.onFailure { - _uiState.value = current.copy(isDeleting = false, errorMessage = "임시저장 레터를 삭제할 수 없습니다.") + _uiState.value = current.copy(isDeleting = false, deleteFailed = true) } } } @@ -72,20 +72,20 @@ class DraftLetterViewModel fun deleteAll() { val current = _uiState.value as? DraftLetterUiState.Success ?: return if (current.isDeleting) return - _uiState.value = current.copy(isDeleting = true, errorMessage = null) + _uiState.value = current.copy(isDeleting = true, deleteFailed = false) viewModelScope.launch { runCatching { timeLetterRepository.deleteAllTemporary() }.onSuccess { _uiState.value = DraftLetterUiState.Success(drafts = emptyList()) }.onFailure { - _uiState.value = current.copy(isDeleting = false, errorMessage = "임시저장 레터를 삭제할 수 없습니다.") + _uiState.value = current.copy(isDeleting = false, deleteFailed = true) } } } - fun consumeErrorMessage() { + fun consumeDeleteFailed() { val current = _uiState.value as? DraftLetterUiState.Success ?: return - _uiState.value = current.copy(errorMessage = null) + _uiState.value = current.copy(deleteFailed = false) } } 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 42b26e6b05..fea6e6c4a6 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,6 +2,7 @@ package com.afternote.feature.timeletter.presentation.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.afternote.core.common.result.runCatchingCancellable import com.afternote.core.domain.repository.UserRepository import com.afternote.core.model.setting.ReceiverListItem import dagger.hilt.android.lifecycle.HiltViewModel @@ -9,10 +10,10 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.emitAll import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import javax.inject.Inject @@ -24,9 +25,7 @@ sealed interface RecipientListUiState { val recipients: List, ) : RecipientListUiState - data class Error( - val message: String, - ) : RecipientListUiState + data object Error : RecipientListUiState } @HiltViewModel @@ -41,13 +40,24 @@ class RecipientListViewModel val uiState: StateFlow = retryRevision .flatMapLatest { - userRepository.receiverListFlow - .map { receivers -> - RecipientListUiState.Success( - receivers.map { ReceiverListItem(it.receiverId, it.name, it.relation) }, - ) as RecipientListUiState - }.onStart { emit(RecipientListUiState.Loading) } - .catch { emit(RecipientListUiState.Error("수신자 목록을 불러올 수 없습니다.")) } + flow { + emit(RecipientListUiState.Loading) + // receiverListFlow는 실패를 예외로 흘리지 않고 마지막 성공 목록 또는 빈 + // 목록으로 낮춘다(#1099) — 이 화면이 실패를 그 정책과 독립적으로 보려면 + // 별도 suspend 조회로 확인해야 한다. + val probe = runCatchingCancellable { userRepository.getReceivers() } + if (probe.isFailure) { + emit(RecipientListUiState.Error) + } else { + emitAll( + userRepository.receiverListFlow.map { receivers -> + RecipientListUiState.Success( + receivers.map { ReceiverListItem(it.receiverId, it.name, it.relation) }, + ) + }, + ) + } + } }.stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(5_000), 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 9d31d172e6..1c05023076 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 @@ -6,7 +6,7 @@ import androidx.compose.ui.text.style.TextAlign data class TimeLetterWriteUiState( val editingTimeLetterId: Long? = null, val isLoadingEditingLetter: Boolean = false, - val editingLoadError: String? = null, + val editingLoadError: Boolean = false, val draftTitle: String? = null, val draftTextContents: Map = emptyMap(), val recipientIds: List = emptyList(), 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 3c03f3649a..3bb23ae7cc 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 @@ -144,7 +144,7 @@ class TimeLetterWriteViewModel textContents: Map, ) { val state = _uiState.value - if (state.isSaving || state.isCheckingRegisterLimit || state.editingLoadError != null) return + if (state.isSaving || state.isCheckingRegisterLimit || state.editingLoadError) return if (state.recipientIds.isEmpty()) { _uiState.update { it.copy(error = TimeLetterWriteError.RECIPIENT_REQUIRED) } @@ -183,7 +183,7 @@ class TimeLetterWriteViewModel fun retryEditingLetter() { val timeLetterId = _uiState.value.editingTimeLetterId ?: return if (_uiState.value.isLoadingEditingLetter) return - _uiState.update { it.copy(isLoadingEditingLetter = true, editingLoadError = null) } + _uiState.update { it.copy(isLoadingEditingLetter = true, editingLoadError = false) } viewModelScope.launch { loadEditingTimeLetter(timeLetterId) } } @@ -288,7 +288,7 @@ class TimeLetterWriteViewModel status: TimeLetterStatus, ) { val state = _uiState.value - if (state.isSaving || state.editingLoadError != null) return + if (state.isSaving || state.editingLoadError) return if (state.recipientIds.isEmpty()) { _uiState.update { it.copy(error = TimeLetterWriteError.RECIPIENT_REQUIRED) } return @@ -371,7 +371,7 @@ class TimeLetterWriteViewModel it.copy( editingTimeLetterId = letter.id, isLoadingEditingLetter = false, - editingLoadError = null, + editingLoadError = false, draftTitle = letter.title.orEmpty(), draftTextContents = letter.blocks @@ -392,7 +392,7 @@ class TimeLetterWriteViewModel _uiState.update { it.copy( isLoadingEditingLetter = false, - editingLoadError = "타임레터를 불러올 수 없습니다.", + 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 7f44e760bb..e29e33584c 100644 --- a/feature/timeletter/presentation/src/main/res/values/strings.xml +++ b/feature/timeletter/presentation/src/main/res/values/strings.xml @@ -8,5 +8,8 @@ 예 아니오 타임레터를 삭제하지 못했습니다. 다시 시도해 주세요. + 임시저장 레터를 삭제할 수 없습니다. + 다시 시도 + 수신자 목록을 불러올 수 없습니다. 이름으로 검색하기 diff --git a/feature/timeletter/presentation/src/test/kotlin/com/afternote/feature/timeletter/presentation/viewmodel/DraftLetterViewModelTest.kt b/feature/timeletter/presentation/src/test/kotlin/com/afternote/feature/timeletter/presentation/viewmodel/DraftLetterViewModelTest.kt index cf8e4ccc4c..05b9f5c580 100644 --- a/feature/timeletter/presentation/src/test/kotlin/com/afternote/feature/timeletter/presentation/viewmodel/DraftLetterViewModelTest.kt +++ b/feature/timeletter/presentation/src/test/kotlin/com/afternote/feature/timeletter/presentation/viewmodel/DraftLetterViewModelTest.kt @@ -73,7 +73,7 @@ class DraftLetterViewModelTest { val state = viewModel.uiState.value as DraftLetterUiState.Success assertEquals(listOf(1L), state.drafts.map { it.id }) assertEquals(setOf(1L), state.selectedIds) - assertEquals("임시저장 레터를 삭제할 수 없습니다.", state.errorMessage) + assertEquals(true, state.deleteFailed) assertEquals(false, state.isDeleting) } } 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..30219fc29d --- /dev/null +++ b/feature/timeletter/presentation/src/test/kotlin/com/afternote/feature/timeletter/presentation/viewmodel/RecipientListViewModelTest.kt @@ -0,0 +1,90 @@ +package com.afternote.feature.timeletter.presentation.viewmodel + +import com.afternote.core.domain.testing.FakeUserRepository +import com.afternote.core.model.user.Receiver +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +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.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class RecipientListViewModelTest { + private val dispatcher = StandardTestDispatcher() + + @Before + fun setUp() { + Dispatchers.setMain(dispatcher) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `getReceivers 조회 실패는 receiverListFlow 정책과 무관하게 Error 상태가 된다`() = + runTest(dispatcher) { + // receiverListFlow는 실패를 삼켜 빈 목록을 내지만(#1099 계약), getReceivers 직접 + // 호출은 예외를 던지는 상황을 재현한다 — 화면은 그 정책과 독립적으로 실패를 봐야 한다. + val repository = + FakeUserRepository( + receivers = emptyList(), + onGetReceivers = { error("network down") }, + ) + val viewModel = RecipientListViewModel(repository) + val job = launch { viewModel.uiState.collect {} } + + advanceUntilIdle() + + assertEquals(RecipientListUiState.Error, viewModel.uiState.value) + job.cancel() + } + + @Test + fun `조회 성공 시 receiverListFlow의 목록을 그대로 노출한다`() = + runTest(dispatcher) { + val repository = FakeUserRepository(receivers = listOf(Receiver(1L, "김수신", "가족", "auth-1"))) + val viewModel = RecipientListViewModel(repository) + val job = launch { viewModel.uiState.collect {} } + + advanceUntilIdle() + + val state = viewModel.uiState.value + assertTrue(state is RecipientListUiState.Success) + assertEquals(listOf("김수신"), (state as RecipientListUiState.Success).recipients.map { it.name }) + job.cancel() + } + + @Test + fun `실패 후 재시도가 성공하면 목록을 복구한다`() = + runTest(dispatcher) { + var shouldFail = true + val repository = + FakeUserRepository( + receivers = listOf(Receiver(1L, "김수신", "가족", "auth-1")), + onGetReceivers = { + if (shouldFail) error("network down") else listOf(Receiver(1L, "김수신", "가족", "auth-1")) + }, + ) + val viewModel = RecipientListViewModel(repository) + val job = launch { viewModel.uiState.collect {} } + advanceUntilIdle() + assertEquals(RecipientListUiState.Error, viewModel.uiState.value) + + shouldFail = false + viewModel.retry() + advanceUntilIdle() + + assertTrue(viewModel.uiState.value is RecipientListUiState.Success) + job.cancel() + } +} From 963dbf60800cbf97833ddc79d71307bbaa11e96f Mon Sep 17 00:00:00 2001 From: kyungmin Date: Sun, 6 Sep 2026 00:20:31 +0900 Subject: [PATCH 04/13] =?UTF-8?q?fix(timeletter):=20develop=20=EB=A8=B8?= =?UTF-8?q?=EC=A7=80=20=ED=9B=84=20=EB=B9=A0=EC=A1=8C=EB=8D=98=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=EC=82=AC=ED=95=AD=202=EA=B1=B4=EC=9D=84=20=EC=BB=A4?= =?UTF-8?q?=EB=B0=8B=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 병합 커밋에 stage가 누락됐던 변경사항: - TimeLetterLifecycleTest: TimeletterScreen의 에러 상태가 develop에서 전용 메시지+재시도 버튼 대신 EmptyTimeLetterContent(빈 목록과 동일 문구)로 단순화됐다. 재시도도 UI 버튼 클릭이 아니라 viewModel.load() 직접 호출로 시뮬레이션하도록 테스트를 develop 쪽 방식에 맞춘다. - strings.xml: DraftLetterViewModel을 develop 버전으로 교체하며 더 이상 안 쓰는 timeletter_draft_delete_failed 리소스를 정리한다. Co-Authored-By: Claude Sonnet 5 --- .../presentation/src/main/res/values/strings.xml | 1 - .../presentation/TimeLetterLifecycleTest.kt | 11 +++++++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/feature/timeletter/presentation/src/main/res/values/strings.xml b/feature/timeletter/presentation/src/main/res/values/strings.xml index 841c58fa5e..f2e4a9b904 100644 --- a/feature/timeletter/presentation/src/main/res/values/strings.xml +++ b/feature/timeletter/presentation/src/main/res/values/strings.xml @@ -25,7 +25,6 @@ 예 아니오 타임레터를 삭제하지 못했습니다. 다시 시도해 주세요. - 임시저장 레터를 삭제할 수 없습니다. 다시 시도 수신자 목록을 불러올 수 없습니다. 이름으로 검색하기 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 fe1c97e353..ce5ca0c119 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,8 +109,9 @@ class TimeLetterLifecycleTest { firstLoad.complete(Result.failure(IllegalStateException("offline"))) composeRule.waitUntil(timeoutMillis = TIMEOUT) { viewModel.uiState.value == TimeletterUiState.Error } - composeRule.onNodeWithText("타임레터를 불러올 수 없습니다.").assertIsDisplayed() - composeRule.onNodeWithText("다시 시도").assertIsDisplayed() + composeRule + .onNodeWithText("아직 등록된 타임레터가 없어요.", substring = true) + .assertIsDisplayed() val visibleLetters = TimeLetterList( @@ -122,8 +123,10 @@ class TimeLetterLifecycleTest { totalCount = 2, ) val retryLoad = CompletableDeferred>() - composeRule.runOnIdle { nextListLoad = retryLoad } - composeRule.onNodeWithText("다시 시도").performClick() + composeRule.runOnIdle { + nextListLoad = retryLoad + viewModel.load() + } composeRule.waitUntil(timeoutMillis = TIMEOUT) { viewModel.uiState.value == TimeletterUiState.Loading } retryLoad.complete(Result.success(visibleLetters)) From 1edc58c2a32c0af2dd9e2ddd8e404c1626885da3 Mon Sep 17 00:00:00 2001 From: kyungmin Date: Tue, 8 Sep 2026 12:11:21 +0900 Subject: [PATCH 05/13] =?UTF-8?q?fix(timeletter):=20=EB=B0=9C=EC=8B=A0=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=20=EC=8B=A4=ED=8C=A8=20?= =?UTF-8?q?=EC=8B=9C=20=EC=9E=AC=EC=8B=9C=EB=8F=84=20UI=EB=A5=BC=20?= =?UTF-8?q?=EB=90=98=EC=82=B4=EB=A6=B0=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit develop 병합 과정에서 TimeletterScreen 이 TimeletterScreenContent 로 쪼개지며 Error 분기가 TimeLetterLoadErrorContent(재시도 버튼 포함) 대신 EmptyTimeLetterContent(빈 상태)를 그리도록 되돌아갔다. 서버 조회가 실패해도 사용자는 "아직 등록된 타임레터가 없어요."만 보고 재시도할 방법이 없었다 (#714 가 원래 잡으려던 증상). TimeletterScreenContent 에 onRetry 콜백을 추가해 Error 분기를 TimeLetterLoadErrorContent 로 되돌리고, TimeletterScreen 에서 viewModel::load 를 연결한다. TimeLetterLifecycleTest 도 재시도 버튼 클릭으로 재조회가 나가는지 다시 단언하도록 복구한다. Co-Authored-By: Claude Sonnet 5 --- .../presentation/screen/sender/TimeletterScreen.kt | 1 + .../screen/sender/TimeletterScreenContent.kt | 11 +++++++++-- .../screen/sender/TimeletterScreenScreenshotTest.kt | 1 + .../presentation/TimeLetterLifecycleTest.kt | 11 ++++------- 4 files changed, 15 insertions(+), 9 deletions(-) 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 228f09a694..f6106140b4 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/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 8cfb978a22..d1a7deed8c 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 ce5ca0c119..fe1c97e353 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)) From 95941c7eef824bc985ef52bad7fb204eea36c240 Mon Sep 17 00:00:00 2001 From: kyungmin Date: Wed, 9 Sep 2026 15:34:09 +0900 Subject: [PATCH 06/13] =?UTF-8?q?fix(timeletter):=20=EC=88=98=EC=8B=A0?= =?UTF-8?q?=EC=9D=B8=20=EB=AA=A9=EB=A1=9D=20Loading=C2=B7Error=20=EC=83=81?= =?UTF-8?q?=EB=8B=A8=EB=B0=94=20=EC=A0=9C=EB=AA=A9=EC=9D=84=20Success?= =?UTF-8?q?=EC=99=80=20=EB=A7=9E=EC=B6=98=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5 --- .../presentation/screen/sender/RecipientListScreen.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 f2acd54e87..d9b736d235 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 @@ -64,7 +64,7 @@ fun RecipientListScreen( RecipientListUiState.Error -> { Scaffold( modifier = modifier, - topBar = { DetailTopBar(title = "수신자 목록", onBackClick = onBackClick) }, + topBar = { DetailTopBar(title = "수신인 목록", onBackClick = onBackClick) }, ) { innerPadding -> TimeLetterLoadErrorContent( message = stringResource(R.string.timeletter_recipient_list_load_failed), @@ -93,7 +93,7 @@ private fun RecipientListLoadingContent( ) { Scaffold( modifier = modifier, - topBar = { DetailTopBar(title = "수신자 목록", onBackClick = onBackClick) }, + topBar = { DetailTopBar(title = "수신인 목록", onBackClick = onBackClick) }, ) { innerPadding -> Box( modifier = Modifier.fillMaxSize().padding(innerPadding), From e19064703a3c1b3427daa830effdac5c33f38551 Mon Sep 17 00:00:00 2001 From: kyungmin Date: Wed, 9 Sep 2026 15:35:24 +0900 Subject: [PATCH 07/13] =?UTF-8?q?fix(timeletter):=20=EC=88=98=EC=8B=A0?= =?UTF-8?q?=EC=9D=B8=20=EC=84=A0=ED=83=9D=20=EC=83=81=ED=83=9C=EB=A5=BC=20?= =?UTF-8?q?rememberSaveable=EB=A1=9C=20=EC=98=AE=EA=B2=A8=20=EB=B0=B1?= =?UTF-8?q?=EA=B7=B8=EB=9D=BC=EC=9A=B4=EB=93=9C=20=EB=B3=B5=EA=B7=80=20?= =?UTF-8?q?=EC=8B=9C=20=EC=86=8C=EC=8B=A4=EC=9D=84=20=EB=A7=89=EB=8A=94?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5 --- .../screen/sender/RecipientListScreen.kt | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) 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 d9b736d235..b12a4ceb0a 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 @@ -18,9 +18,9 @@ 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 @@ -113,7 +113,7 @@ private fun RecipientListContent( allowEmptyConfirm: Boolean = false, ) { val searchState = rememberTextFieldState() - val selectedIds = remember { mutableStateSetOf() } + var selectedIds by rememberSaveable { mutableStateOf(emptySet()) } val listState = rememberLazyListState() val coroutineScope = rememberCoroutineScope() var selectedConsonant by remember { mutableStateOf(null) } @@ -198,11 +198,12 @@ private fun RecipientListContent( recipient = recipient, selected = recipient.receiverId in selectedIds, onSelectedChange = { checked -> - if (checked) { - selectedIds.add(recipient.receiverId) - } else { - selectedIds.remove(recipient.receiverId) - } + selectedIds = + if (checked) { + selectedIds + recipient.receiverId + } else { + selectedIds - recipient.receiverId + } }, ) } From 9b6770b38a133fcdd995a1277b5cee32646a962e Mon Sep 17 00:00:00 2001 From: kyungmin Date: Wed, 9 Sep 2026 15:37:28 +0900 Subject: [PATCH 08/13] =?UTF-8?q?fix(timeletter):=20deleteSelected=20?= =?UTF-8?q?=EC=84=B1=EA=B3=B5=20=EB=B6=84=EA=B8=B0=EC=9D=98=20=EC=A4=91?= =?UTF-8?q?=EB=B3=B5=EB=90=9C=20isDeleting=20=EB=8C=80=EC=9E=85=EC=9D=84?= =?UTF-8?q?=20=EC=A7=80=EC=9A=B4=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5 --- .../timeletter/presentation/viewmodel/DraftLetterViewModel.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/viewmodel/DraftLetterViewModel.kt b/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/viewmodel/DraftLetterViewModel.kt index dfc67633c6..54d8962772 100644 --- a/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/viewmodel/DraftLetterViewModel.kt +++ b/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/viewmodel/DraftLetterViewModel.kt @@ -77,7 +77,6 @@ class DraftLetterViewModel drafts = latest.drafts.filter { it.id !in selectedIds }, selectedIds = emptySet(), isEditMode = false, - isDeleting = false, ) } } catch (cancellationException: CancellationException) { From a89e102d5ed125c2986efeac27b63d420a271bca Mon Sep 17 00:00:00 2001 From: kyungmin Date: Sat, 12 Sep 2026 14:49:43 +0900 Subject: [PATCH 09/13] =?UTF-8?q?fix(timeletter):=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?=EC=84=B1=EA=B3=B5=20=EB=92=A4=20=EC=9E=AC=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?=EC=8B=A4=ED=8C=A8=EA=B0=80=20=EB=B9=88=20=EB=AA=A9=EB=A1=9D?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EA=B0=80=EB=A6=AC=EB=8A=94=20=EB=AC=B8?= =?UTF-8?q?=EC=A0=9C=EB=A5=BC=20=EA=B3=A0=EC=B9=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getReceivers() probe가 성공해도 receiverListFlow를 새로 구독해 그 값을 버렸다. receiverListFlow는 구독마다 빈 lastKnownReceivers에서 다시 조회하므로, probe 직후의 재조회만 일시 실패해도 방금 받은 목록이 Success(emptyList())로 가려졌다. probe의 성공값을 그대로 Success로 내보내고, receiverListFlow는 구독 즉시 오는 중복 재조회 결과만 drop(1)로 버려 이후 방출(세션 전환·다른 화면의 등록/수정)부터 실시간 반영에 쓰도록 정리했다. 수신인 목록 상단바 제목 세 곳도 문자열 리소스로 추출한다. Co-Authored-By: Claude Sonnet 5 --- .../screen/sender/RecipientListScreen.kt | 16 ++++++++-- .../viewmodel/RecipientListViewModel.kt | 31 ++++++++++++------- .../src/main/res/values/strings.xml | 1 + .../viewmodel/RecipientListViewModelTest.kt | 29 +++++++++++++++++ 4 files changed, 63 insertions(+), 14 deletions(-) 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 b12a4ceb0a..0efc9a3dd2 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 @@ -64,7 +64,12 @@ fun RecipientListScreen( RecipientListUiState.Error -> { Scaffold( modifier = modifier, - topBar = { DetailTopBar(title = "수신인 목록", onBackClick = onBackClick) }, + topBar = { + DetailTopBar( + title = stringResource(R.string.timeletter_recipient_list_title), + onBackClick = onBackClick, + ) + }, ) { innerPadding -> TimeLetterLoadErrorContent( message = stringResource(R.string.timeletter_recipient_list_load_failed), @@ -93,7 +98,12 @@ private fun RecipientListLoadingContent( ) { Scaffold( modifier = modifier, - topBar = { DetailTopBar(title = "수신인 목록", onBackClick = onBackClick) }, + topBar = { + DetailTopBar( + title = stringResource(R.string.timeletter_recipient_list_title), + onBackClick = onBackClick, + ) + }, ) { innerPadding -> Box( modifier = Modifier.fillMaxSize().padding(innerPadding), @@ -152,7 +162,7 @@ private fun RecipientListContent( modifier = modifier, topBar = { DetailTopBar( - title = "수신인 목록", + title = stringResource(R.string.timeletter_recipient_list_title), onBackClick = onBackClick, ) }, 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 fea6e6c4a6..dc70e20af6 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 @@ -5,11 +5,13 @@ import androidx.lifecycle.viewModelScope import com.afternote.core.common.result.runCatchingCancellable import com.afternote.core.domain.repository.UserRepository import com.afternote.core.model.setting.ReceiverListItem +import com.afternote.core.model.user.Receiver import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.emitAll import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flow @@ -46,17 +48,22 @@ class RecipientListViewModel // 목록으로 낮춘다(#1099) — 이 화면이 실패를 그 정책과 독립적으로 보려면 // 별도 suspend 조회로 확인해야 한다. val probe = runCatchingCancellable { userRepository.getReceivers() } - if (probe.isFailure) { - emit(RecipientListUiState.Error) - } else { - emitAll( - userRepository.receiverListFlow.map { receivers -> - RecipientListUiState.Success( - receivers.map { ReceiverListItem(it.receiverId, it.name, it.relation) }, - ) - }, - ) - } + probe.fold( + onSuccess = { receivers -> + emit(RecipientListUiState.Success(receivers.toRecipientItems())) + // receiverListFlow는 구독마다 빈 lastKnownReceivers에서 다시 조회한다 + // (#1099) — 그 첫 방출은 방금 받은 성공 결과와 중복된 조회이자, 그 조회만 + // 일시 실패해도 방금 받은 목록을 빈 목록으로 덮어써 버린다. 첫 방출을 버리고 + // 이후 방출부터 실시간 반영(세션 전환·다른 화면의 등록/수정)에 쓴다. + emitAll( + userRepository.receiverListFlow + .drop(1) + .map { it.toRecipientItems() } + .map(RecipientListUiState::Success), + ) + }, + onFailure = { emit(RecipientListUiState.Error) }, + ) } }.stateIn( scope = viewModelScope, @@ -68,3 +75,5 @@ class RecipientListViewModel retryRevision.update { it + 1 } } } + +private fun List.toRecipientItems(): List = map { ReceiverListItem(it.receiverId, it.name, it.relation) } diff --git a/feature/timeletter/presentation/src/main/res/values/strings.xml b/feature/timeletter/presentation/src/main/res/values/strings.xml index f2e4a9b904..d623005fc0 100644 --- a/feature/timeletter/presentation/src/main/res/values/strings.xml +++ b/feature/timeletter/presentation/src/main/res/values/strings.xml @@ -26,6 +26,7 @@ 아니오 타임레터를 삭제하지 못했습니다. 다시 시도해 주세요. 다시 시도 + 수신인 목록 수신자 목록을 불러올 수 없습니다. 이름으로 검색하기 임시 저장된 레터 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 index 30219fc29d..6a0ff3b944 100644 --- 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 @@ -4,6 +4,7 @@ import com.afternote.core.domain.testing.FakeUserRepository import com.afternote.core.model.user.Receiver import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.launch import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.advanceUntilIdle @@ -87,4 +88,32 @@ class RecipientListViewModelTest { assertTrue(viewModel.uiState.value is RecipientListUiState.Success) job.cancel() } + + @Test + fun `조회 성공 뒤 receiverListFlow의 첫 재조회가 비어 있어도 방금 받은 성공 목록을 유지한다`() = + runTest(dispatcher) { + // 실제 UserReceiverRepositoryImpl.receiverListFlow는 구독마다 빈 lastKnownReceivers에서 + // 다시 조회하므로, getReceivers()가 성공한 직후에도 그 구독의 첫 방출은 일시 실패로 비어 + // 있을 수 있다(#1099). 그 첫 방출을 흉내 내 방금 받은 성공 목록이 덮이지 않는지 검증한다. + val successReceivers = listOf(Receiver(1L, "김수신", "가족", "auth-1")) + var receiverListFlowCallCount = 0 + val repository = + FakeUserRepository( + receivers = successReceivers, + onGetReceivers = { successReceivers }, + onReceiverListFlow = { + receiverListFlowCallCount += 1 + if (receiverListFlowCallCount == 1) flowOf(emptyList()) else flowOf(successReceivers) + }, + ) + val viewModel = RecipientListViewModel(repository) + val job = launch { viewModel.uiState.collect {} } + + advanceUntilIdle() + + val state = viewModel.uiState.value + assertTrue(state is RecipientListUiState.Success) + assertEquals(listOf("김수신"), (state as RecipientListUiState.Success).recipients.map { it.name }) + job.cancel() + } } From 83ff32dc149928ced86116d584d78a15c1483c92 Mon Sep 17 00:00:00 2001 From: kyungmin Date: Sun, 13 Sep 2026 23:05:36 +0900 Subject: [PATCH 10/13] =?UTF-8?q?fix(timeletter):=20=EC=88=98=EC=8B=A0?= =?UTF-8?q?=EC=9D=B8=20=EC=84=A0=ED=83=9D=20=EC=86=8C=EC=8B=A4=EA=B3=BC=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=95=84=EC=9B=83=20=EC=8B=A0=ED=98=B8=20?= =?UTF-8?q?=EC=9C=A0=EC=8B=A4=EC=9D=84=20=EA=B3=A0=EC=B9=9C=EB=8B=A4=20(#7?= =?UTF-8?q?14)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1hyok/Sadturtleman 님 리뷰 지적 해소: - selectedIds를 RecipientListScreen 최상단으로 올려 uiState가 Loading을 거칠 때(WhileSubscribed(5_000) 재구독) 선택이 사라지지 않게 한다. - UserReceiverRepositoryImpl의 마지막 성공 목록 캐시를 flow 지역 변수에서 로그인 구간 단위 인스턴스 상태로 옮겨, 재구독의 중복 조회가 일시 실패해도 목록이 비지 않게 한다. 이에 따라 RecipientListViewModel의 drop(1)을 제거한다 — 로그아웃으로 인한 빈 목록 방출까지 함께 삼켜 세션이 끝났는데도 이전 목록이 남는 문제가 있었다. Co-Authored-By: Claude Sonnet 5 --- .../repoimpl/UserReceiverRepositoryImpl.kt | 20 ++++++++----- .../data/repoimpl/UserRepositoryImplTest.kt | 28 +++++++++++++++++++ .../screen/sender/RecipientListScreen.kt | 16 +++++++++-- .../viewmodel/RecipientListViewModel.kt | 10 +++---- .../viewmodel/RecipientListViewModelTest.kt | 23 +++++++-------- 5 files changed, 68 insertions(+), 29 deletions(-) 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 7abe8e1b4a..c7e1ff7f7b 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 @@ -37,8 +37,8 @@ import com.afternote.core.data.mapper.delivery.toDomain as toDeliveryConditionsD /** * 사용자 계정에 등록된 수신자 계약 구현 (#1282). * - * 이 구현만 수신자 갱신 revision 과 «로그인 구간 + collector» 에 귀속된 목록 캐시를 소유한다 — - * 프로필·계정·푸시 설정과 상태를 공유하지 않는다. + * 이 구현만 수신자 갱신 revision 과 «로그인 구간» 에 귀속된 목록 캐시를 소유한다 — 캐시는 재구독에 + * 걸쳐 살아남고 로그아웃에 비워진다. 프로필·계정·푸시 설정과 상태를 공유하지 않는다. * * `@Singleton` 인 이유 — 위 revision 과 캐시는 합본 [UserRepositoryImpl] 을 거치는 경로와 [UserReceiverRepository] * 를 직접 주입받는 경로가 **같은 인스턴스** 로 봐야 한다(좁은 계약으로 만든 수신자가 합본 구독자의 목록도 @@ -55,15 +55,22 @@ internal class UserReceiverRepositoryImpl ) : UserReceiverRepository { private val receiverRefreshRevision = MutableStateFlow(0L) + // 로그인 구간 동안의 «마지막으로 성공한 목록» — collector 재구독(백그라운드 복귀 등으로 cold + // flow 가 다시 처음부터 도는 경우 포함)에 걸쳐 살아남아야 한다. flow 지역 변수로 두면 재구독마다 + // 빈 값으로 리셋되어, 새 구독의 첫 조회가 일시 실패했을 뿐인데도 화면에 떠 있던 목록이 사라진다. + // 로그인 구간이 바뀌면(로그아웃) 아래 flatMapLatest 분기에서 명시적으로 비운다 — 그래야 재로그인 + // 첫 실패가 이전 계정의 목록을 잠깐이라도 비추지 않는다. + private val lastKnownReceivers = MutableStateFlow(emptyList()) + // 조회 실패를 예외로 흘리면 구독 중인 화면이 미처리 예외로 죽는다. 일반적인 일시 실패는 같은 - // 로그인 구간에서 이 collector가 마지막으로 성공한 목록으로 낮춘다. 캐시를 flow 안에 두는 이유는 - // 저장소 인스턴스보다 수명이 짧은 «로그인 구간 + collector»에 귀속해 계정 사이에 섞이지 않게 하기 위함이다. + // 로그인 구간에서 마지막으로 성공한 목록으로 낮춘다. @OptIn(ExperimentalCoroutinesApi::class) override val receiverListFlow: Flow> = authRepository.isLoggedIn .distinctUntilChanged() .flatMapLatest { loggedIn -> if (!loggedIn) { + lastKnownReceivers.value = emptyList() flowOf(emptyList()) } else { receiverListForAuthenticatedSession() @@ -72,7 +79,6 @@ internal class UserReceiverRepositoryImpl private fun receiverListForAuthenticatedSession(): Flow> = flow { - var lastKnownReceivers = emptyList() receiverRefreshRevision.collect { val receivers = runCatchingCancellable { getReceivers() } @@ -90,11 +96,11 @@ internal class UserReceiverRepositoryImpl if (failure is ApiException && failure.status == UNAUTHORIZED_STATUS) { emptyList() } else { - lastKnownReceivers + lastKnownReceivers.value } }, ) - lastKnownReceivers = receivers + lastKnownReceivers.value = receivers emit(receivers) } } 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 01390a4133..87f93602b3 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 @@ -300,6 +300,34 @@ class UserRepositoryImplTest { assertEquals("복구 후 목록", recovered.single().name) } + /** + * 배경 5초 이상 복귀 등으로 cold flow 가 처음부터 다시 도는 재구독 상황을 흉내 낸다. 이전에는 + * lastKnownReceivers 가 flow 지역 변수라 재구독마다 빈 값으로 리셋됐다 — 재구독의 첫 조회가 + * 일시 실패하면 화면에 떠 있던 목록이 사라졌다. 로그인 구간 단위로 캐시를 들고 있어야 한다. + */ + @Test + fun `receiverListFlow - 재구독 시 조회가 실패해도 같은 세션의 마지막 목록을 유지한다`() { + var requestCount = 0 + val repository = + repository( + onGetReceivers = { + requestCount += 1 + if (requestCount == 1) { + dataResponse(listOf(receiverDto("첫 조회 성공"))) + } else { + throw UnknownHostException("재구독 조회 일시 실패") + } + }, + ) + + val first = runBlocking { repository.receiverListFlow.first() } + val resubscribed = runBlocking { repository.receiverListFlow.first() } + + assertEquals(listOf("첫 조회 성공"), first.map { it.name }) + assertEquals(listOf("첫 조회 성공"), resubscribed.map { it.name }) + assertEquals(2, requestCount) + } + @Test fun `createReceiver - 성공하면 구독 중인 목록을 다시 조회한다`() = runBlocking { 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 0efc9a3dd2..8579f30a89 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 @@ -56,6 +56,10 @@ fun RecipientListScreen( viewModel: RecipientListViewModel = hiltViewModel(), ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() + // Success 분기 안에 두면 5초 이상 백그라운드 뒤 재구독으로 uiState 가 Loading 을 거쳐 갈 때마다 + // RecipientListContent 가 컴포지션을 떠나 선택이 날아간다(WhileSubscribed(5_000)). 화면 자체는 + // uiState 변화와 무관하게 계속 컴포지션에 남아 있으므로 여기로 올려 생존시킨다. + var selectedIds by rememberSaveable { mutableStateOf(emptySet()) } when (val state = uiState) { RecipientListUiState.Loading -> { RecipientListLoadingContent(onBackClick, modifier) @@ -82,6 +86,8 @@ fun RecipientListScreen( is RecipientListUiState.Success -> { RecipientListContent( recipients = state.recipients, + selectedIds = selectedIds, + onSelectedIdsChange = { selectedIds = it }, onBackClick = onBackClick, onConfirmClick = onConfirmClick, allowEmptyConfirm = allowEmptyConfirm, @@ -117,13 +123,14 @@ private fun RecipientListLoadingContent( @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() - var selectedIds by rememberSaveable { mutableStateOf(emptySet()) } val listState = rememberLazyListState() val coroutineScope = rememberCoroutineScope() var selectedConsonant by remember { mutableStateOf(null) } @@ -208,12 +215,13 @@ private fun RecipientListContent( recipient = recipient, selected = recipient.receiverId in selectedIds, onSelectedChange = { checked -> - selectedIds = + onSelectedIdsChange( if (checked) { selectedIds + recipient.receiverId } else { selectedIds - recipient.receiverId - } + }, + ) }, ) } @@ -262,6 +270,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/viewmodel/RecipientListViewModel.kt b/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/viewmodel/RecipientListViewModel.kt index dc70e20af6..cbffdf752d 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 @@ -11,7 +11,6 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.emitAll import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flow @@ -51,13 +50,12 @@ class RecipientListViewModel probe.fold( onSuccess = { receivers -> emit(RecipientListUiState.Success(receivers.toRecipientItems())) - // receiverListFlow는 구독마다 빈 lastKnownReceivers에서 다시 조회한다 - // (#1099) — 그 첫 방출은 방금 받은 성공 결과와 중복된 조회이자, 그 조회만 - // 일시 실패해도 방금 받은 목록을 빈 목록으로 덮어써 버린다. 첫 방출을 버리고 - // 이후 방출부터 실시간 반영(세션 전환·다른 화면의 등록/수정)에 쓴다. + // receiverListFlow는 로그인 구간 동안의 마지막 성공 목록을 저장소가 직접 + // 들고 있어 재구독의 중복 조회가 일시 실패해도 그 목록으로 낮춘다 — 여기서 + // 방출을 걸러낼 필요가 없다. 걸러내면 로그아웃으로 인한 빈 목록 방출까지 + // 함께 삼켜져 세션이 끝났는데도 이전 목록이 그대로 남는다. emitAll( userRepository.receiverListFlow - .drop(1) .map { it.toRecipientItems() } .map(RecipientListUiState::Success), ) 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 index 6a0ff3b944..bf9c499714 100644 --- 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 @@ -90,21 +90,18 @@ class RecipientListViewModelTest { } @Test - fun `조회 성공 뒤 receiverListFlow의 첫 재조회가 비어 있어도 방금 받은 성공 목록을 유지한다`() = + fun `probe 성공과 flow 구독 사이에 로그아웃되면 빈 목록으로 갱신된다`() = runTest(dispatcher) { - // 실제 UserReceiverRepositoryImpl.receiverListFlow는 구독마다 빈 lastKnownReceivers에서 - // 다시 조회하므로, getReceivers()가 성공한 직후에도 그 구독의 첫 방출은 일시 실패로 비어 - // 있을 수 있다(#1099). 그 첫 방출을 흉내 내 방금 받은 성공 목록이 덮이지 않는지 검증한다. - val successReceivers = listOf(Receiver(1L, "김수신", "가족", "auth-1")) - var receiverListFlowCallCount = 0 + // getReceivers() 직접 호출(probe)이 이전 계정 목록으로 성공한 뒤, receiverListFlow 를 + // 구독하는 시점엔 이미 로그아웃돼 있는 경합을 흉내 낸다. 전에는 receiverListFlow 의 첫 + // 방출을 무조건 버려서 이 경우 세션 종료 신호(빈 목록)까지 삼켜져 화면이 이전 계정 목록에 + // 멈춰 있었다 — 세션 변경은 항상 반영해야 한다. + val staleReceivers = listOf(Receiver(1L, "이전 계정 수신인", "가족", "auth-1")) val repository = FakeUserRepository( - receivers = successReceivers, - onGetReceivers = { successReceivers }, - onReceiverListFlow = { - receiverListFlowCallCount += 1 - if (receiverListFlowCallCount == 1) flowOf(emptyList()) else flowOf(successReceivers) - }, + receivers = staleReceivers, + onGetReceivers = { staleReceivers }, + onReceiverListFlow = { flowOf(emptyList()) }, ) val viewModel = RecipientListViewModel(repository) val job = launch { viewModel.uiState.collect {} } @@ -113,7 +110,7 @@ class RecipientListViewModelTest { val state = viewModel.uiState.value assertTrue(state is RecipientListUiState.Success) - assertEquals(listOf("김수신"), (state as RecipientListUiState.Success).recipients.map { it.name }) + assertEquals(emptyList(), (state as RecipientListUiState.Success).recipients.map { it.name }) job.cancel() } } From fddd25be3a7d53ab16b1554cb6dc2cb23dcbcc6e Mon Sep 17 00:00:00 2001 From: kyungmin Date: Mon, 14 Sep 2026 00:00:52 +0900 Subject: [PATCH 11/13] =?UTF-8?q?test(timeletter):=20=EC=88=98=EC=8B=A0?= =?UTF-8?q?=EC=9D=B8=20=EC=84=A0=ED=83=9D=20=EC=9C=A0=EC=A7=80=EB=A5=BC=20?= =?UTF-8?q?uiState=20=EC=88=9C=ED=99=98=EC=9C=BC=EB=A1=9C=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=ED=95=9C=EB=8B=A4=20(#714)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RecipientListScreen이 ViewModel을 직접 들고 있으면 Loading→Success 순환을 실제 코루틴 타이밍 없이 재현할 수 없었다. TimeLetterWriteScreen과 같은 패턴으로 uiState를 호출부(NavGraph) 책임으로 옮겨 상태 없는 화면으로 만들고, uiState를 직접 갈아끼워 선택 유지를 검증하는 RecipientListScreenTest를 추가한다. Co-Authored-By: Claude Sonnet 5 --- .../navigation/TimeLetterNavGraph.kt | 9 +++ .../screen/sender/RecipientListScreen.kt | 19 +++-- .../screen/sender/RecipientListScreenTest.kt | 74 +++++++++++++++++++ 3 files changed, 95 insertions(+), 7 deletions(-) create mode 100644 feature/timeletter/presentation/src/test/kotlin/com/afternote/feature/timeletter/presentation/screen/sender/RecipientListScreenTest.kt 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 ad9ac26096..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 @@ -123,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, ) } @@ -138,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 8579f30a89..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 @@ -25,12 +25,11 @@ 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 @@ -44,19 +43,24 @@ import com.afternote.feature.timeletter.presentation.R import com.afternote.feature.timeletter.presentation.component.RecipientListItem import com.afternote.feature.timeletter.presentation.component.TimeLetterLoadErrorContent import com.afternote.feature.timeletter.presentation.viewmodel.RecipientListUiState -import com.afternote.feature.timeletter.presentation.viewmodel.RecipientListViewModel 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 uiState by viewModel.uiState.collectAsStateWithLifecycle() - // Success 분기 안에 두면 5초 이상 백그라운드 뒤 재구독으로 uiState 가 Loading 을 거쳐 갈 때마다 + // Success 분기 안에 두면 5초 이상 백그라운드 뒤 재구독으로 uiState 가 Loading 을 거칠 때마다 // RecipientListContent 가 컴포지션을 떠나 선택이 날아간다(WhileSubscribed(5_000)). 화면 자체는 // uiState 변화와 무관하게 계속 컴포지션에 남아 있으므로 여기로 올려 생존시킨다. var selectedIds by rememberSaveable { mutableStateOf(emptySet()) } @@ -77,7 +81,7 @@ fun RecipientListScreen( ) { innerPadding -> TimeLetterLoadErrorContent( message = stringResource(R.string.timeletter_recipient_list_load_failed), - onRetry = viewModel::retry, + onRetry = onRetry, modifier = Modifier.padding(innerPadding), ) } @@ -223,6 +227,7 @@ private fun RecipientListContent( }, ) }, + modifier = Modifier.testTag("recipient_item_${recipient.receiverId}"), ) } } 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 }) + } +} From 2409ef898e9b3468f200863f6a27d7f31e2caf3c Mon Sep 17 00:00:00 2001 From: kyungmin Date: Mon, 14 Sep 2026 00:16:19 +0900 Subject: [PATCH 12/13] =?UTF-8?q?fix(timeletter):=20androidTest=EB=A5=BC?= =?UTF-8?q?=20RecipientListScreen=EC=9D=98=20=EC=83=81=ED=83=9C=20?= =?UTF-8?q?=EC=97=86=EB=8A=94=20=EC=8B=9C=EA=B7=B8=EB=8B=88=EC=B2=98?= =?UTF-8?q?=EC=97=90=20=EB=A7=9E=EC=B6=98=EB=8B=A4=20(#714)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RecipientListScreen이 ViewModel 대신 uiState를 직접 받도록 바뀌면서 컴파일이 깨졌던 두 androidTest(ReceiverSessionIsolationAndroidTest, TimeLetterMindRecordCompletionAndroidTest)를 새 시그니처에 맞춘다. Co-Authored-By: Claude Sonnet 5 --- .../afternote_fe/ReceiverSessionIsolationAndroidTest.kt | 6 +++++- .../TimeLetterMindRecordCompletionAndroidTest.kt | 4 +++- 2 files changed, 8 insertions(+), 2 deletions(-) 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 23c4c002f4..e46be4427f 100644 --- a/app/src/androidTest/java/com/afternote/afternote_fe/ReceiverSessionIsolationAndroidTest.kt +++ b/app/src/androidTest/java/com/afternote/afternote_fe/ReceiverSessionIsolationAndroidTest.kt @@ -1,11 +1,13 @@ 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 @@ -44,10 +46,12 @@ class ReceiverSessionIsolationAndroidTest { composeRule.setContent { AfternoteTheme { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() RecipientListScreen( + uiState = uiState, onBackClick = {}, onConfirmClick = {}, - viewModel = viewModel, + onRetry = viewModel::retry, ) } } 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() From 997783108bfb4ebd56189153f7f72b7bca764a62 Mon Sep 17 00:00:00 2001 From: kyungmin Date: Tue, 13 Oct 2026 19:15:01 +0900 Subject: [PATCH 13/13] =?UTF-8?q?fix(timeletter):=20=EC=88=98=EC=8B=A0?= =?UTF-8?q?=EC=9D=B8=20=EC=84=A0=ED=83=9D=EC=9D=84=20receiverListStateFlow?= =?UTF-8?q?=20=EB=A1=9C=20=EC=98=AE=EA=B2=A8=20=EC=8B=A4=ED=8C=A8=EB=A5=BC?= =?UTF-8?q?=20=EB=B9=88=20=EB=AA=A9=EB=A1=9D=EA=B3=BC=20=EA=B0=80=EB=A5=B8?= =?UTF-8?q?=EB=8B=A4=20(#714)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 저장소의 마지막 목록 캐시가 구독 단위로 돌아가(#2135) receiverListFlow 만으로는 재구독 첫 조회 실패가 다시 무음 빈 목록이 된다. 설정 화면(#2045)과 같은 상태 Flow 를 구독해 로딩·실패·로그아웃을 가르고, 재시도는 refreshReceiverList 로 같은 구독을 다시 조회한다. getReceivers probe 이중 조회도 없앤다. Co-Authored-By: Claude Opus 5.5 --- .../viewmodel/RecipientListViewModel.kt | 64 +++++------ .../viewmodel/RecipientListViewModelTest.kt | 101 ++++++++++-------- 2 files changed, 84 insertions(+), 81 deletions(-) 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 cbffdf752d..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,21 +2,15 @@ package com.afternote.feature.timeletter.presentation.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.afternote.core.common.result.runCatchingCancellable -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.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.emitAll -import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.flow.update import javax.inject.Inject sealed interface RecipientListUiState { @@ -30,48 +24,40 @@ sealed interface RecipientListUiState { } @HiltViewModel -@OptIn(ExperimentalCoroutinesApi::class) class RecipientListViewModel @Inject constructor( - private val userRepository: UserRepository, + private val receiverRepository: UserReceiverRepository, ) : ViewModel() { - private val retryRevision = MutableStateFlow(0L) - + // receiverListFlow 는 실패를 마지막 목록이나 빈 목록으로 낮춰 실패와 실제 0건이 같은 모양이 된다(#1099). + // 상태 Flow 는 둘을 가르고(#2045), 세션 교체 시 이전 계정 목록도 SignedOut 으로 먼저 비운다(#2135). val uiState: StateFlow = - retryRevision - .flatMapLatest { - flow { - emit(RecipientListUiState.Loading) - // receiverListFlow는 실패를 예외로 흘리지 않고 마지막 성공 목록 또는 빈 - // 목록으로 낮춘다(#1099) — 이 화면이 실패를 그 정책과 독립적으로 보려면 - // 별도 suspend 조회로 확인해야 한다. - val probe = runCatchingCancellable { userRepository.getReceivers() } - probe.fold( - onSuccess = { receivers -> - emit(RecipientListUiState.Success(receivers.toRecipientItems())) - // receiverListFlow는 로그인 구간 동안의 마지막 성공 목록을 저장소가 직접 - // 들고 있어 재구독의 중복 조회가 일시 실패해도 그 목록으로 낮춘다 — 여기서 - // 방출을 걸러낼 필요가 없다. 걸러내면 로그아웃으로 인한 빈 목록 방출까지 - // 함께 삼켜져 세션이 끝났는데도 이전 목록이 그대로 남는다. - emitAll( - userRepository.receiverListFlow - .map { it.toRecipientItems() } - .map(RecipientListUiState::Success), - ) - }, - onFailure = { emit(RecipientListUiState.Error) }, - ) - } - }.stateIn( + receiverRepository.receiverListStateFlow + .map { it.toUiState() } + .stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(5_000), initialValue = RecipientListUiState.Loading, ) fun retry() { - retryRevision.update { it + 1 } + receiverRepository.refreshReceiverList() } } -private fun List.toRecipientItems(): List = map { ReceiverListItem(it.receiverId, it.name, it.relation) } +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/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 index bf9c499714..6b0958bbfa 100644 --- 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 @@ -1,10 +1,11 @@ package com.afternote.feature.timeletter.presentation.viewmodel -import com.afternote.core.domain.testing.FakeUserRepository +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.flowOf +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.advanceUntilIdle @@ -13,13 +14,15 @@ import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain import org.junit.After import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue 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() { @@ -32,18 +35,14 @@ class RecipientListViewModelTest { } @Test - fun `getReceivers 조회 실패는 receiverListFlow 정책과 무관하게 Error 상태가 된다`() = + fun `첫 조회 실패는 빈 목록이 아니라 Error 상태가 된다`() = runTest(dispatcher) { - // receiverListFlow는 실패를 삼켜 빈 목록을 내지만(#1099 계약), getReceivers 직접 - // 호출은 예외를 던지는 상황을 재현한다 — 화면은 그 정책과 독립적으로 실패를 봐야 한다. - val repository = - FakeUserRepository( - receivers = emptyList(), - onGetReceivers = { error("network down") }, - ) 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) @@ -51,66 +50,84 @@ class RecipientListViewModelTest { } @Test - fun `조회 성공 시 receiverListFlow의 목록을 그대로 노출한다`() = + fun `조회 성공 시 목록을 그대로 노출한다`() = runTest(dispatcher) { - val repository = FakeUserRepository(receivers = listOf(Receiver(1L, "김수신", "가족", "auth-1"))) + states.value = ReceiverListState.Success(listOf(receiver)) val viewModel = RecipientListViewModel(repository) val job = launch { viewModel.uiState.collect {} } advanceUntilIdle() - val state = viewModel.uiState.value - assertTrue(state is RecipientListUiState.Success) - assertEquals(listOf("김수신"), (state as RecipientListUiState.Success).recipients.map { it.name }) + assertEquals(listOf("김수신"), viewModel.uiState.value.recipientNames()) job.cancel() } @Test - fun `실패 후 재시도가 성공하면 목록을 복구한다`() = + fun `재시도는 저장소 갱신을 요청하고 성공하면 목록을 복구한다`() = runTest(dispatcher) { - var shouldFail = true - val repository = - FakeUserRepository( - receivers = listOf(Receiver(1L, "김수신", "가족", "auth-1")), - onGetReceivers = { - if (shouldFail) error("network down") else listOf(Receiver(1L, "김수신", "가족", "auth-1")) - }, - ) + 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) - shouldFail = false 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()) - assertTrue(viewModel.uiState.value is RecipientListUiState.Success) + states.value = ReceiverListState.Failure(listOf(receiver), discardPrevious = false) + advanceUntilIdle() + assertEquals(listOf("김수신"), viewModel.uiState.value.recipientNames()) job.cancel() } @Test - fun `probe 성공과 flow 구독 사이에 로그아웃되면 빈 목록으로 갱신된다`() = + fun `401 로 버린 목록은 유지하지 않고 Error 를 보인다`() = runTest(dispatcher) { - // getReceivers() 직접 호출(probe)이 이전 계정 목록으로 성공한 뒤, receiverListFlow 를 - // 구독하는 시점엔 이미 로그아웃돼 있는 경합을 흉내 낸다. 전에는 receiverListFlow 의 첫 - // 방출을 무조건 버려서 이 경우 세션 종료 신호(빈 목록)까지 삼켜져 화면이 이전 계정 목록에 - // 멈춰 있었다 — 세션 변경은 항상 반영해야 한다. - val staleReceivers = listOf(Receiver(1L, "이전 계정 수신인", "가족", "auth-1")) - val repository = - FakeUserRepository( - receivers = staleReceivers, - onGetReceivers = { staleReceivers }, - onReceiverListFlow = { flowOf(emptyList()) }, - ) val viewModel = RecipientListViewModel(repository) val job = launch { viewModel.uiState.collect {} } + states.value = ReceiverListState.Failure(listOf(receiver), discardPrevious = true) advanceUntilIdle() - val state = viewModel.uiState.value - assertTrue(state is RecipientListUiState.Success) - assertEquals(emptyList(), (state as RecipientListUiState.Success).recipients.map { it.name }) + 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" + } }