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 828101b1e2..e353a0218a 100644 --- a/app/src/androidTest/java/com/afternote/afternote_fe/TimeLetterMindRecordCompletionAndroidTest.kt +++ b/app/src/androidTest/java/com/afternote/afternote_fe/TimeLetterMindRecordCompletionAndroidTest.kt @@ -151,7 +151,7 @@ class TimeLetterMindRecordCompletionAndroidTest { val register = composeRule.onNode(hasText("등록") and hasClickAction()) register.performClick() composeRule.waitUntil(timeoutMillis = TIMEOUT) { - viewModel.uiState.value.error == TimeLetterWriteError.RECIPIENT_REQUIRED + viewModel.uiState.value.error == TimeLetterWriteError.RecipientRequired } composeRule.onNodeWithText("수신자를 선택해주세요.").assertIsDisplayed() @@ -162,7 +162,7 @@ class TimeLetterMindRecordCompletionAndroidTest { } register.performClick() composeRule.waitUntil(timeoutMillis = TIMEOUT) { - viewModel.uiState.value.error == TimeLetterWriteError.SEND_DATE_REQUIRED + viewModel.uiState.value.error == TimeLetterWriteError.SendDateRequired } composeRule.onNodeWithText("발송 날짜를 선택해주세요.").assertIsDisplayed() diff --git a/feature/timeletter/data/src/main/kotlin/com/afternote/feature/timeletter/data/repositoryImpl/TimeLetterRepositoryImpl.kt b/feature/timeletter/data/src/main/kotlin/com/afternote/feature/timeletter/data/repositoryImpl/TimeLetterRepositoryImpl.kt index 5726627acb..cdae857bb8 100644 --- a/feature/timeletter/data/src/main/kotlin/com/afternote/feature/timeletter/data/repositoryImpl/TimeLetterRepositoryImpl.kt +++ b/feature/timeletter/data/src/main/kotlin/com/afternote/feature/timeletter/data/repositoryImpl/TimeLetterRepositoryImpl.kt @@ -1,5 +1,6 @@ package com.afternote.feature.timeletter.data.repositoryImpl +import com.afternote.core.network.model.ApiException import com.afternote.core.network.model.requireData import com.afternote.core.network.model.requireStatus import com.afternote.feature.timeletter.data.api.TimeLetterApiService @@ -8,6 +9,7 @@ import com.afternote.feature.timeletter.data.dto.TimeLetterDeleteRequestDto import com.afternote.feature.timeletter.data.dto.TimeLetterUpdateRequestDto import com.afternote.feature.timeletter.data.mapper.toDomain import com.afternote.feature.timeletter.data.mapper.toDto +import com.afternote.feature.timeletter.domain.error.TimeLetterServerRejectionException import com.afternote.feature.timeletter.domain.model.NewTimeLetterBlock import com.afternote.feature.timeletter.domain.model.TimeLetter import com.afternote.feature.timeletter.domain.model.TimeLetterDeliveryMode @@ -34,10 +36,12 @@ class TimeLetterRepositoryImpl .toDomain() override suspend fun getTimeLetter(timeLetterId: Long): TimeLetter = - timeLetterApiService - .getTimeLetter(timeLetterId) - .requireData() - .toDomain() + mapTimeLetterRequest { + timeLetterApiService + .getTimeLetter(timeLetterId) + .requireData() + .toDomain() + } override suspend fun createTimeLetter( title: String?, @@ -47,18 +51,20 @@ class TimeLetterRepositoryImpl status: TimeLetterStatus, receiverIds: List, ): TimeLetter = - timeLetterApiService - .createTimeLetter( - TimeLetterCreateRequestDto( - title = title, - sendAt = sendAt, - deliveryMode = deliveryMode.toDto(), - status = status.toDto(), - blocks = blocks.map { it.toDto() }, - receiverIds = receiverIds, - ), - ).requireData() - .toDomain() + mapTimeLetterRequest { + timeLetterApiService + .createTimeLetter( + TimeLetterCreateRequestDto( + title = title, + sendAt = sendAt, + deliveryMode = deliveryMode.toDto(), + status = status.toDto(), + blocks = blocks.map { it.toDto() }, + receiverIds = receiverIds, + ), + ).requireData() + .toDomain() + } override suspend fun updateTimeLetter( timeLetterId: Long, @@ -68,19 +74,21 @@ class TimeLetterRepositoryImpl deliveryMode: TimeLetterDeliveryMode?, status: TimeLetterStatus?, ): TimeLetter = - timeLetterApiService - .updateTimeLetter( - timeLetterId = timeLetterId, - request = - TimeLetterUpdateRequestDto( - title = title, - sendAt = sendAt, - deliveryMode = deliveryMode?.toDto(), - status = status?.toDto(), - blocks = blocks.map { it.toDto() }, - ), - ).requireData() - .toDomain() + mapTimeLetterRequest { + timeLetterApiService + .updateTimeLetter( + timeLetterId = timeLetterId, + request = + TimeLetterUpdateRequestDto( + title = title, + sendAt = sendAt, + deliveryMode = deliveryMode?.toDto(), + status = status?.toDto(), + blocks = blocks.map { it.toDto() }, + ), + ).requireData() + .toDomain() + } override suspend fun deleteTimeLetters(timeLetterIds: List) { timeLetterApiService @@ -93,4 +101,15 @@ class TimeLetterRepositoryImpl .deleteAllTemporary() .requireStatus() } + + private suspend inline fun mapTimeLetterRequest(crossinline request: suspend () -> T): T = + try { + request() + } catch (error: ApiException) { + val serverMessage = error.serverMessage?.takeIf { it.isNotBlank() } + if (error.status in 400..499 && serverMessage != null) { + throw TimeLetterServerRejectionException(error.status, serverMessage, error) + } + throw error + } } diff --git a/feature/timeletter/domain/src/main/kotlin/com/afternote/feature/timeletter/domain/error/TimeLetterServerRejectionException.kt b/feature/timeletter/domain/src/main/kotlin/com/afternote/feature/timeletter/domain/error/TimeLetterServerRejectionException.kt new file mode 100644 index 0000000000..b000ed9a45 --- /dev/null +++ b/feature/timeletter/domain/src/main/kotlin/com/afternote/feature/timeletter/domain/error/TimeLetterServerRejectionException.kt @@ -0,0 +1,13 @@ +package com.afternote.feature.timeletter.domain.error + +/** + * A 4xx the server explicitly rejected (as opposed to a network/technical failure). + * + * [serverMessage] is the server's raw text — for logs and crash reports only, never for direct + * display. The UI shows a fixed local message for this case; see `TimeLetterWriteError.ServerRejection`. + */ +class TimeLetterServerRejectionException( + val status: Int, + val serverMessage: String, + cause: Throwable, +) : Exception(serverMessage, cause) diff --git a/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/component/DraftDateFormatter.kt b/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/component/DraftDateFormatter.kt new file mode 100644 index 0000000000..6254de6828 --- /dev/null +++ b/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/component/DraftDateFormatter.kt @@ -0,0 +1,18 @@ +package com.afternote.feature.timeletter.presentation.component + +import java.time.LocalDate +import java.time.format.DateTimeFormatter +import java.time.format.DateTimeParseException +import java.util.Locale + +private val draftDateFormatter = DateTimeFormatter.ofPattern("yyyy.MM.dd.", Locale.ROOT) + +internal fun formatDraftSendAt(sendAt: String?): String { + if (sendAt.isNullOrBlank()) return "–" + + return try { + LocalDate.parse(sendAt.take(10)).format(draftDateFormatter) + } catch (_: DateTimeParseException) { + "–" + } +} diff --git a/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/component/DraftLetterItem.kt b/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/component/DraftLetterItem.kt index d59d2d113b..faef0f9d61 100644 --- a/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/component/DraftLetterItem.kt +++ b/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/component/DraftLetterItem.kt @@ -1,59 +1,128 @@ package com.afternote.feature.timeletter.presentation.component import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.afternote.core.ui.button.AfternoteCircularCheckbox import com.afternote.core.ui.button.CheckboxState +import com.afternote.core.ui.theme.AfternoteDesign import com.afternote.feature.timeletter.domain.model.TimeLetter import com.afternote.feature.timeletter.domain.model.TimeLetterStatus +import com.afternote.feature.timeletter.presentation.R @Composable fun DraftLetterItem( draft: TimeLetter, modifier: Modifier = Modifier, + receiverNameMap: Map = emptyMap(), isEditMode: Boolean = false, isSelected: Boolean = false, + onOpen: () -> Unit = {}, onToggle: () -> Unit = {}, ) { - Row( - modifier = - modifier - .fillMaxWidth() - .padding(horizontal = 20.dp) - .height(78.dp) - .then(if (isEditMode) Modifier.clickable { onToggle() } else Modifier), - verticalAlignment = Alignment.CenterVertically, - ) { - if (isEditMode) { - AfternoteCircularCheckbox( - state = if (isSelected) CheckboxState.Default else CheckboxState.None, - onClick = { onToggle() }, - size = 20.dp, - ) + val resolvedReceiverNames = + draft.receiverIds + .mapNotNull(receiverNameMap::get) + val receiverLabel = + when { + draft.receiverIds.isEmpty() -> { + stringResource(R.string.timeletter_draft_recipient_unspecified) + } + + resolvedReceiverNames.size != draft.receiverIds.size -> { + stringResource(R.string.timeletter_draft_recipient_count, draft.receiverIds.size) + } + + else -> { + stringResource( + R.string.timeletter_draft_recipient_names, + resolvedReceiverNames.joinToString(", "), + ) + } } - Column { - Text("수신인 : ${if (draft.receiverIds.isEmpty()) "미지정" else "${draft.receiverIds.size}명"}") - Text(draft.title ?: "제목 없음") + + Column(modifier = modifier.fillMaxWidth()) { + Row( + modifier = + Modifier + .fillMaxWidth() + .then( + if (isEditMode) { + Modifier.clickable(role = Role.Checkbox, onClick = onToggle) + } else { + Modifier.clickable(role = Role.Button, onClick = onOpen) + }, + ).padding(horizontal = 20.dp, vertical = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (isEditMode) { + AfternoteCircularCheckbox( + state = if (isSelected) CheckboxState.Default else CheckboxState.None, + onClick = onToggle, + size = 20.dp, + ) + Spacer(modifier = Modifier.width(16.dp)) + } + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Row(modifier = Modifier.fillMaxWidth()) { + Text( + text = receiverLabel, + modifier = Modifier.weight(1f), + style = AfternoteDesign.typography.footnoteCaption, + color = AfternoteDesign.colors.gray6, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = + stringResource( + R.string.timeletter_draft_scheduled_date, + formatDraftSendAt(draft.sendAt), + ), + style = AfternoteDesign.typography.footnoteCaption, + color = AfternoteDesign.colors.gray6, + ) + } + Text( + text = + draft.title?.takeIf(String::isNotBlank) + ?: stringResource(R.string.timeletter_draft_untitled), + style = AfternoteDesign.typography.bodyBase, + color = AfternoteDesign.colors.gray9, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } } - Spacer(modifier = Modifier.weight(1f)) - Text("발송예정일: ${draft.sendAt ?: "-"}") + HorizontalDivider( + modifier = Modifier.padding(horizontal = 20.dp), + color = AfternoteDesign.colors.gray3, + ) } } @Preview(showBackground = true) @Composable -private fun DraftLetterItemPrev() { +private fun DraftLetterItemPreview() { DraftLetterItem( draft = TimeLetter( @@ -65,5 +134,6 @@ private fun DraftLetterItemPrev() { blocks = emptyList(), receiverIds = listOf(1L), ), + receiverNameMap = mapOf(1L to "김지은"), ) } 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 3aa84c0492..68642e2c51 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 @@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.sizeIn import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Icon @@ -19,6 +20,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource +import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -119,12 +121,21 @@ fun TimeLetterBottomBar( color = AfternoteDesign.colors.gray7, ) } - if (draftCount > 0) { + Box( + modifier = + Modifier + .sizeIn(minWidth = 48.dp, minHeight = 48.dp) + .clickable( + onClickLabel = "임시저장 목록 열기", + role = Role.Button, + onClick = onDraftCountClick, + ), + contentAlignment = Alignment.Center, + ) { Text( text = "$draftCount", style = AfternoteDesign.typography.captionLargeR, color = AfternoteDesign.colors.gray7, - modifier = Modifier.clickable(onClick = onDraftCountClick), ) } } 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 1990d862a3..c28aea6a6d 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 @@ -7,6 +7,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavController +import androidx.navigation.NavDestination.Companion.hasRoute import androidx.navigation.NavGraphBuilder import androidx.navigation.compose.composable import androidx.navigation.navigation @@ -19,6 +20,8 @@ import com.afternote.feature.timeletter.presentation.screen.sender.TimeletterScr import com.afternote.feature.timeletter.presentation.viewmodel.TimeLetterWriteViewModel import com.afternote.feature.timeletter.presentation.viewmodel.TimeletterViewModel +private const val DRAFT_CHANGED_RESULT = "timeletter_draft_changed" + fun NavGraphBuilder.timeLetterNavGraph( navController: NavController, actions: TimeLetterNavActions, @@ -40,12 +43,14 @@ fun NavGraphBuilder.timeLetterNavGraph( LaunchedEffect(uiState.savedAsDraft) { if (uiState.savedAsDraft) { + navController.notifyPreviousDraftChanged() viewModel.onSavedAsDraftShown() actions.onDraftBack() } } LaunchedEffect(uiState.registered) { if (uiState.registered) { + navController.notifyPreviousDraftChanged() viewModel.onRegisteredShown() actions.onWriteBack() } @@ -89,8 +94,19 @@ fun NavGraphBuilder.timeLetterNavGraph( ) } - composable { - DraftLetterScreen(onBackClick = actions::onDraftBack) + composable { backStackEntry -> + val refreshRequested by + backStackEntry.savedStateHandle + .getStateFlow(DRAFT_CHANGED_RESULT, false) + .collectAsStateWithLifecycle() + DraftLetterScreen( + onBackClick = actions::onDraftBack, + onOpenDraft = actions::onNavigateToEdit, + refreshRequested = refreshRequested, + onRefreshConsumed = { + backStackEntry.savedStateHandle[DRAFT_CHANGED_RESULT] = false + }, + ) } composable { @@ -132,3 +148,10 @@ fun NavGraphBuilder.timeLetterNavGraph( } } } + +private fun NavController.notifyPreviousDraftChanged() { + previousBackStackEntry + ?.takeIf { it.destination.hasRoute() } + ?.savedStateHandle + ?.set(DRAFT_CHANGED_RESULT, true) +} 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..46e3af064a 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,19 +1,35 @@ package com.afternote.feature.timeletter.presentation.screen.sender +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.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.material3.Text 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.res.stringResource 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.ui.button.AfternoteButton +import com.afternote.core.ui.button.AfternoteButtonType +import com.afternote.core.ui.theme.AfternoteDesign 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.TimeLetterTextButton import com.afternote.feature.timeletter.presentation.viewmodel.DraftLetterUiState @@ -22,23 +38,40 @@ import com.afternote.feature.timeletter.presentation.viewmodel.DraftLetterViewMo @Composable fun DraftLetterScreen( onBackClick: () -> Unit, + onOpenDraft: (Long) -> Unit, modifier: Modifier = Modifier, viewModel: DraftLetterViewModel = hiltViewModel(), + refreshRequested: Boolean = false, + onRefreshConsumed: () -> Unit, ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val snackbarHostState = remember { SnackbarHostState() } + val messageRes = (uiState as? DraftLetterUiState.Success)?.messageRes + val message = messageRes?.let { stringResource(it) } + + LaunchedEffect(refreshRequested) { + if (refreshRequested) { + onRefreshConsumed() + viewModel.loadDrafts() + } + } + + LaunchedEffect(messageRes) { + if (message != null) { + snackbarHostState.showSnackbar(message) + viewModel.onMessageShown() + } + } DraftLetterContent( uiState = uiState, onBackClick = onBackClick, - onEditCompleteClick = { - val current = uiState as? DraftLetterUiState.Success ?: return@DraftLetterContent - if (current.isEditMode && current.selectedIds.isNotEmpty()) { - viewModel.deleteSelected() - } else { - viewModel.toggleEditMode() - } - }, + onOpenDraft = onOpenDraft, + onEditCompleteClick = viewModel::toggleEditMode, onToggleSelection = viewModel::toggleSelection, + onDeleteAll = viewModel::deleteAll, + onDeleteSelected = viewModel::deleteSelected, + snackbarHostState = snackbarHostState, modifier = modifier, ) } @@ -47,60 +80,105 @@ fun DraftLetterScreen( private fun DraftLetterContent( uiState: DraftLetterUiState, onBackClick: () -> Unit, + onOpenDraft: (Long) -> Unit, onEditCompleteClick: () -> Unit, onToggleSelection: (Long) -> Unit, + onDeleteAll: () -> Unit, + onDeleteSelected: () -> Unit, + snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }, modifier: Modifier = Modifier, ) { - val isEditMode = (uiState as? DraftLetterUiState.Success)?.isEditMode ?: false - val selectedIds = (uiState as? DraftLetterUiState.Success)?.selectedIds ?: emptySet() + val successState = uiState as? DraftLetterUiState.Success + val isEditMode = successState?.isEditMode == true Scaffold( modifier = modifier, + snackbarHost = { SnackbarHost(snackbarHostState) }, topBar = { DetailTopBar( - title = "임시저장된 레터", + title = stringResource(R.string.timeletter_draft_title), onBackClick = onBackClick, actions = { - TimeLetterTextButton( - text = - when { - !isEditMode -> "수정" - selectedIds.isEmpty() -> "취소" - else -> "삭제" - }, - isActive = isEditMode && selectedIds.isNotEmpty(), - onClick = onEditCompleteClick, - ) + if (successState != null && successState.drafts.isNotEmpty()) { + TimeLetterTextButton( + text = stringResource(if (isEditMode) R.string.timeletter_draft_done else R.string.timeletter_draft_edit), + isActive = isEditMode, + onClick = onEditCompleteClick, + ) + } }, ) }, + bottomBar = { + if (isEditMode) { + AfternoteButton( + text = stringResource(R.string.timeletter_draft_delete_all), + onClick = onDeleteAll, + secondaryText = stringResource(R.string.timeletter_draft_delete_selected), + onSecondaryClick = onDeleteSelected, + type = AfternoteButtonType.Variant5, + isLoading = successState.isDeleting, + isSecondaryEnabled = successState.isDeleteSelectedEnabled, + modifier = Modifier.padding(horizontal = 20.dp, vertical = 16.dp), + ) + } + }, + containerColor = AfternoteDesign.colors.white, ) { innerPadding -> when (uiState) { - is DraftLetterUiState.Loading -> { - Unit + DraftLetterUiState.Loading -> { + Box( + modifier = Modifier.fillMaxSize().padding(innerPadding), + contentAlignment = Alignment.Center, + ) { CircularProgressIndicator() } } is DraftLetterUiState.Error -> { - Unit + Box( + modifier = Modifier.fillMaxSize().padding(innerPadding).padding(20.dp), + contentAlignment = Alignment.Center, + ) { + Text( + stringResource(uiState.messageRes), + style = AfternoteDesign.typography.captionLargeR, + color = AfternoteDesign.colors.gray6, + ) + } } is DraftLetterUiState.Success -> { - LazyColumn( - modifier = - Modifier - .fillMaxSize() - .padding(innerPadding), - ) { - items( - items = uiState.drafts, - key = { it.id }, - ) { draft -> - DraftLetterItem( - draft = draft, - isEditMode = isEditMode, - isSelected = draft.id in selectedIds, - onToggle = { onToggleSelection(draft.id) }, - ) + Column(modifier = Modifier.fillMaxSize().padding(innerPadding)) { + Text( + text = + stringResource( + if (uiState.isEditMode) R.string.timeletter_draft_total_selected else R.string.timeletter_draft_total, + if (uiState.isEditMode) uiState.selectedIds.size else uiState.drafts.size, + ), + style = AfternoteDesign.typography.footnoteCaption, + color = AfternoteDesign.colors.gray9, + modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp), + ) + if (uiState.drafts.isEmpty()) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text( + stringResource(R.string.timeletter_draft_empty), + style = AfternoteDesign.typography.captionLargeR, + color = AfternoteDesign.colors.gray6, + ) + } + } else { + LazyColumn(modifier = Modifier.fillMaxWidth()) { + items(items = uiState.drafts, key = { it.id }) { draft -> + DraftLetterItem( + draft = draft, + receiverNameMap = uiState.receiverNameMap, + isEditMode = uiState.isEditMode, + isSelected = draft.id in uiState.selectedIds, + onOpen = { onOpenDraft(draft.id) }, + onToggle = { onToggleSelection(draft.id) }, + ) + } + } } } } @@ -108,81 +186,24 @@ private fun DraftLetterContent( } } -@Preview(showBackground = true, name = "일반 - 목록 있음") -@Composable -private fun DraftLetterScreenNormalPreview() { - DraftLetterContent( - uiState = - DraftLetterUiState.Success( - drafts = - listOf( - TimeLetter( - id = 1L, - title = "첫 번째 레터", - sendAt = "2026-12-25T00:00:00", - deliveredAt = null, - status = TimeLetterStatus.DRAFT, - blocks = emptyList(), - receiverIds = listOf(1L), - ), - TimeLetter( - id = 2L, - title = "두 번째 레터", - sendAt = "2026-06-01T00:00:00", - deliveredAt = null, - status = TimeLetterStatus.DRAFT, - blocks = emptyList(), - receiverIds = listOf(2L), - ), - TimeLetter( - id = 3L, - title = null, - sendAt = null, - deliveredAt = null, - status = TimeLetterStatus.DRAFT, - blocks = emptyList(), - receiverIds = emptyList(), - ), - ), - ), - onBackClick = {}, - onEditCompleteClick = {}, - onToggleSelection = {}, - ) -} - -@Preview(showBackground = true, name = "수정 모드 - 항목 선택됨") +@Preview(showBackground = true) @Composable -private fun DraftLetterScreenEditModePreview() { +private fun DraftLetterScreenPreview() { DraftLetterContent( uiState = DraftLetterUiState.Success( drafts = listOf( - TimeLetter( - id = 1L, - title = "첫 번째 레터", - sendAt = "2026-12-25T00:00:00", - deliveredAt = null, - status = TimeLetterStatus.DRAFT, - blocks = emptyList(), - receiverIds = listOf(1L), - ), - TimeLetter( - id = 2L, - title = "두 번째 레터", - sendAt = "2026-06-01T00:00:00", - deliveredAt = null, - status = TimeLetterStatus.DRAFT, - blocks = emptyList(), - receiverIds = listOf(2L), - ), + TimeLetter(1L, "첫 번째 레터", "2026-12-25T00:00:00", null, TimeLetterStatus.DRAFT, emptyList(), listOf(1L)), + TimeLetter(2L, "두 번째 레터", null, null, TimeLetterStatus.DRAFT, emptyList(), listOf(2L)), ), - isEditMode = true, - selectedIds = setOf(1L), + receiverNameMap = mapOf(1L to "김지은", 2L to "이현우"), ), onBackClick = {}, + onOpenDraft = {}, onEditCompleteClick = {}, onToggleSelection = {}, + onDeleteAll = {}, + onDeleteSelected = {}, ) } 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 18e3f8e290..b34294f162 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 @@ -596,16 +596,15 @@ internal fun collectTextBlockContents( @Composable private fun TimeLetterWriteError.message(): String = - stringResource( - when (this) { - TimeLetterWriteError.SEND_DATE_REQUIRED -> R.string.timeletter_write_send_date_required - TimeLetterWriteError.LOAD_FAILED -> R.string.timeletter_write_load_failed - TimeLetterWriteError.RECIPIENT_REQUIRED -> R.string.timeletter_write_recipient_required - TimeLetterWriteError.SAVE_FAILED -> R.string.timeletter_write_save_failed - TimeLetterWriteError.VOICE_RECORDING_START_FAILED -> R.string.timeletter_voice_recording_start_error - TimeLetterWriteError.VOICE_RECORDING_STOP_FAILED -> R.string.timeletter_voice_recording_stop_error - }, - ) + when (this) { + TimeLetterWriteError.SendDateRequired -> stringResource(R.string.timeletter_write_send_date_required) + TimeLetterWriteError.LoadFailed -> stringResource(R.string.timeletter_write_load_failed) + TimeLetterWriteError.RecipientRequired -> stringResource(R.string.timeletter_write_recipient_required) + TimeLetterWriteError.SaveFailed -> stringResource(R.string.timeletter_write_save_failed) + TimeLetterWriteError.ServerRejection -> stringResource(R.string.timeletter_write_rejected) + TimeLetterWriteError.VoiceRecordingStartFailed -> stringResource(R.string.timeletter_voice_recording_start_error) + TimeLetterWriteError.VoiceRecordingStopFailed -> stringResource(R.string.timeletter_voice_recording_stop_error) + } @Composable private fun TextBlockItem( 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..5d77d08a66 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 @@ -1,5 +1,6 @@ package com.afternote.feature.timeletter.presentation.viewmodel +import androidx.annotation.StringRes import com.afternote.feature.timeletter.domain.model.TimeLetter sealed interface DraftLetterUiState { @@ -7,11 +8,17 @@ sealed interface DraftLetterUiState { data class Success( val drafts: List, + val receiverNameMap: Map = emptyMap(), val isEditMode: Boolean = false, + val isDeleting: Boolean = false, val selectedIds: Set = emptySet(), - ) : DraftLetterUiState + @StringRes val messageRes: Int? = null, + ) : DraftLetterUiState { + val isDeleteSelectedEnabled: Boolean + get() = selectedIds.isNotEmpty() && !isDeleting + } data class Error( - val message: String, + @StringRes val messageRes: Int, ) : DraftLetterUiState } 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..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 @@ -2,8 +2,12 @@ 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.feature.timeletter.domain.repository.TimeLetterRepository +import com.afternote.feature.timeletter.presentation.R import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -15,6 +19,7 @@ class DraftLetterViewModel @Inject constructor( private val timeLetterRepository: TimeLetterRepository, + private val userRepository: UserRepository, ) : ViewModel() { private val _uiState = MutableStateFlow(DraftLetterUiState.Loading) val uiState: StateFlow = _uiState.asStateFlow() @@ -26,50 +31,95 @@ class DraftLetterViewModel fun loadDrafts() { viewModelScope.launch { _uiState.value = DraftLetterUiState.Loading - runCatching { timeLetterRepository.getTemporaryTimeLetters() } - .onSuccess { result -> - _uiState.value = DraftLetterUiState.Success(drafts = result.timeLetters) - }.onFailure { - _uiState.value = DraftLetterUiState.Error("임시저장 레터를 불러올 수 없습니다.") - } + try { + val result = timeLetterRepository.getTemporaryTimeLetters() + val receiverNameMap = + runCatchingCancellable { userRepository.getReceivers() } + .getOrElse { emptyList() } + .associate { receiver -> receiver.receiverId to receiver.name } + _uiState.value = + DraftLetterUiState.Success( + drafts = result.timeLetters, + receiverNameMap = receiverNameMap, + ) + } catch (cancellationException: CancellationException) { + throw cancellationException + } catch (_: Exception) { + _uiState.value = DraftLetterUiState.Error(R.string.timeletter_draft_load_error) + } } } 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 + if (current.drafts.none { it.id == id }) 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.isDeleting || current.selectedIds.isEmpty()) return + val selectedIds = current.selectedIds + _uiState.value = current.copy(isDeleting = true, messageRes = null) viewModelScope.launch { - runCatching { - timeLetterRepository.deleteTimeLetters(current.selectedIds.toList()) - }.onSuccess { - _uiState.value = - current.copy( - drafts = current.drafts.filter { it.id !in current.selectedIds }, + try { + timeLetterRepository.deleteTimeLetters(selectedIds.toList()) + updateSuccessState { latest -> + latest.copy( + drafts = latest.drafts.filter { it.id !in selectedIds }, selectedIds = emptySet(), isEditMode = false, ) + } + } catch (cancellationException: CancellationException) { + throw cancellationException + } catch (_: Exception) { + updateSuccessState { latest -> latest.copy(messageRes = R.string.timeletter_draft_delete_error) } + } finally { + updateSuccessState { latest -> latest.copy(isDeleting = false) } } } } fun deleteAll() { + val current = _uiState.value as? DraftLetterUiState.Success ?: return + if (current.isDeleting) return + _uiState.value = current.copy(isDeleting = true, messageRes = null) viewModelScope.launch { - runCatching { + try { timeLetterRepository.deleteAllTemporary() - }.onSuccess { - _uiState.value = DraftLetterUiState.Success(drafts = emptyList()) + updateSuccessState { latest -> + latest.copy( + drafts = emptyList(), + selectedIds = emptySet(), + isEditMode = false, + ) + } + } catch (cancellationException: CancellationException) { + throw cancellationException + } catch (_: Exception) { + updateSuccessState { latest -> latest.copy(messageRes = R.string.timeletter_draft_delete_error) } + } finally { + updateSuccessState { latest -> latest.copy(isDeleting = false) } } } } + + fun onMessageShown() { + val current = _uiState.value as? DraftLetterUiState.Success ?: return + _uiState.value = current.copy(messageRes = null) + } + + private inline fun updateSuccessState(transform: (DraftLetterUiState.Success) -> DraftLetterUiState.Success) { + val current = _uiState.value as? DraftLetterUiState.Success ?: return + _uiState.value = transform(current) + } } 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 e4990d8aca..07c7de5843 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 @@ -29,13 +29,21 @@ data class TimeLetterWriteUiState( val showFreePlanLimitPopup: Boolean = false, ) -enum class TimeLetterWriteError { - SEND_DATE_REQUIRED, - LOAD_FAILED, - RECIPIENT_REQUIRED, - SAVE_FAILED, - VOICE_RECORDING_START_FAILED, - VOICE_RECORDING_STOP_FAILED, +sealed interface TimeLetterWriteError { + data object SendDateRequired : TimeLetterWriteError + + data object LoadFailed : TimeLetterWriteError + + data object RecipientRequired : TimeLetterWriteError + + data object SaveFailed : TimeLetterWriteError + + /** A 4xx the server explicitly rejected. Shown as a generic message — the server's own text is not user-facing. */ + data object ServerRejection : TimeLetterWriteError + + data object VoiceRecordingStartFailed : TimeLetterWriteError + + data object VoiceRecordingStopFailed : TimeLetterWriteError } sealed interface VoiceRecordingState { 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 0f6a74bb8f..30fd000bf8 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 @@ -9,6 +9,7 @@ import androidx.lifecycle.viewModelScope import androidx.navigation.toRoute import com.afternote.core.common.result.runCatchingCancellable import com.afternote.core.domain.repository.UserRepository +import com.afternote.feature.timeletter.domain.error.TimeLetterServerRejectionException import com.afternote.feature.timeletter.domain.model.BlockInput import com.afternote.feature.timeletter.domain.model.TimeLetter import com.afternote.feature.timeletter.domain.model.TimeLetterBlockType @@ -59,6 +60,7 @@ class TimeLetterWriteViewModel private var receiverNameMap: Map = emptyMap() private var recordingTimerJob: Job? = null private var isCheckingRegisterLimit: Boolean = false + private var originalEditingStatus: TimeLetterStatus? = null /** * 화면이 닫힌 뒤 도착하는 start()/stop() 완료를 걸러낸다. discard/retry/openVoiceRecorder @@ -157,22 +159,22 @@ class TimeLetterWriteViewModel if (state.isSaving || isCheckingRegisterLimit) return if (state.recipientIds.isEmpty()) { - _uiState.update { it.copy(error = TimeLetterWriteError.RECIPIENT_REQUIRED) } + _uiState.update { it.copy(error = TimeLetterWriteError.RecipientRequired) } return } if (state.sendAt == null) { - _uiState.update { it.copy(error = TimeLetterWriteError.SEND_DATE_REQUIRED) } + _uiState.update { it.copy(error = TimeLetterWriteError.SendDateRequired) } return } isCheckingRegisterLimit = true viewModelScope.launch { try { - if (state.editingTimeLetterId == null) { + if (originalEditingStatus != TimeLetterStatus.SCHEDULED) { val registeredCount = runCatchingCancellable { timeLetterRepository.getTimeLetters().totalCount } .getOrElse { _uiState.update { current -> - current.copy(error = TimeLetterWriteError.LOAD_FAILED) + current.copy(error = TimeLetterWriteError.LoadFailed) } return@launch } @@ -265,7 +267,7 @@ class TimeLetterWriteViewModel _uiState.update { state -> state.copy( voiceRecordingState = VoiceRecordingState.Idle, - error = TimeLetterWriteError.VOICE_RECORDING_START_FAILED, + error = TimeLetterWriteError.VoiceRecordingStartFailed, ) } } @@ -295,7 +297,7 @@ class TimeLetterWriteViewModel _uiState.update { state -> state.copy( voiceRecordingState = VoiceRecordingState.Idle, - error = TimeLetterWriteError.VOICE_RECORDING_STOP_FAILED, + error = TimeLetterWriteError.VoiceRecordingStopFailed, ) } } @@ -435,7 +437,7 @@ class TimeLetterWriteViewModel val state = _uiState.value if (state.isSaving) return if (state.recipientIds.isEmpty()) { - _uiState.update { it.copy(error = TimeLetterWriteError.RECIPIENT_REQUIRED) } + _uiState.update { it.copy(error = TimeLetterWriteError.RecipientRequired) } return } @@ -444,9 +446,11 @@ class TimeLetterWriteViewModel try { val sendAt = state.sendAt?.let { date -> - "${date}T${ - state.sendHour.toString().padStart(2, '0') - }:${state.sendMinute.toString().padStart(2, '0')}:00" + formatSendAt( + date = date, + hour = state.sendHour, + minute = state.sendMinute, + ) } val saveResult = if (state.editingTimeLetterId == null) { @@ -485,8 +489,17 @@ class TimeLetterWriteViewModel } else { _uiState.update { it.copy(registered = true) } } - }.onFailure { - _uiState.update { it.copy(error = TimeLetterWriteError.SAVE_FAILED) } + }.onFailure { error -> + _uiState.update { + it.copy( + error = + if (error is TimeLetterServerRejectionException) { + TimeLetterWriteError.ServerRejection + } else { + TimeLetterWriteError.SaveFailed + }, + ) + } } } finally { _uiState.update { it.copy(isSaving = false) } @@ -497,6 +510,7 @@ class TimeLetterWriteViewModel private suspend fun loadEditingTimeLetter(timeLetterId: Long) { runCatchingCancellable { timeLetterRepository.getTimeLetter(timeLetterId) } .onSuccess { letter -> + originalEditingStatus = letter.status val editorBlocks = letter.toEditorBlocks() val sendAtDate = letter.sendAt?.take(10) val sendHour = @@ -533,9 +547,18 @@ class TimeLetterWriteViewModel nextBlockId = (editorBlocks.maxOfOrNull { block -> block.id } ?: 0L) + 1L, ) } - }.onFailure { + }.onFailure { error -> _uiState.update { it.copy(isLoadingEditingLetter = false) } - _uiState.update { it.copy(error = TimeLetterWriteError.LOAD_FAILED) } + _uiState.update { + it.copy( + error = + if (error is TimeLetterServerRejectionException) { + TimeLetterWriteError.ServerRejection + } else { + TimeLetterWriteError.LoadFailed + }, + ) + } } } @@ -685,3 +708,9 @@ class TimeLetterWriteViewModel const val FREE_PLAN_REGISTER_LIMIT = 3 } } + +private fun formatSendAt( + date: String, + hour: Int, + minute: Int, +): String = "${date}T${hour.toString().padStart(2, '0')}:${minute.toString().padStart(2, '0')}:00" diff --git a/feature/timeletter/presentation/src/main/res/values/strings.xml b/feature/timeletter/presentation/src/main/res/values/strings.xml index 1126382433..deca9f35d2 100644 --- a/feature/timeletter/presentation/src/main/res/values/strings.xml +++ b/feature/timeletter/presentation/src/main/res/values/strings.xml @@ -4,6 +4,7 @@ 타임레터를 불러올 수 없습니다. 수신자를 선택해주세요. 저장에 실패했어요. 다시 시도해주세요. + 요청을 처리할 수 없어요. 다시 확인해주세요. 음성 메시지 녹음 녹음 버튼을 눌러 음성 메시지를 남겨주세요. 녹음 시작 @@ -25,4 +26,22 @@ 아니오 타임레터를 삭제하지 못했습니다. 다시 시도해 주세요. 이름으로 검색하기 + 임시 저장된 레터 + 선택 + 완료 + 총 %1$d 개 + 총 %1$d 개 선택 + 수신인 %1$s + 수신인 %1$d명 + 수신인이 지정되지 않았습니다. + 발송 예정일 %1$s + 제목 없음 + 전체 삭제 + 선택 삭제 + 임시 저장된 레터가 없습니다. + 임시 저장된 레터를 불러올 수 없습니다. + 임시 저장된 레터를 삭제하지 못했습니다. + 발송 날짜를 선택해주세요. + 타임레터를 불러올 수 없습니다. + 저장에 실패했어요. 다시 시도해주세요. diff --git a/feature/timeletter/presentation/src/test/kotlin/com/afternote/feature/timeletter/presentation/TimeLetterFlowTest.kt b/feature/timeletter/presentation/src/test/kotlin/com/afternote/feature/timeletter/presentation/TimeLetterFlowTest.kt index 47072afb8f..0e8b2100bf 100644 --- a/feature/timeletter/presentation/src/test/kotlin/com/afternote/feature/timeletter/presentation/TimeLetterFlowTest.kt +++ b/feature/timeletter/presentation/src/test/kotlin/com/afternote/feature/timeletter/presentation/TimeLetterFlowTest.kt @@ -90,7 +90,7 @@ class TimeLetterFlowTest { viewModel.register("가을 편지", mapOf(0L to "잊지 않을게")) } composeRule.waitUntil(timeoutMillis = 5_000) { - viewModel.uiState.value.error == TimeLetterWriteError.SAVE_FAILED + viewModel.uiState.value.error == TimeLetterWriteError.SaveFailed } assertFalse(viewModel.uiState.value.registered) 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 fe8c63de47..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 @@ -4,6 +4,8 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotEnabled import androidx.compose.ui.test.hasClickAction import androidx.compose.ui.test.hasScrollAction import androidx.compose.ui.test.hasText @@ -28,6 +30,7 @@ import com.afternote.feature.timeletter.domain.model.TimeLetterBlockType 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 com.afternote.feature.timeletter.domain.testing.FakeFileMetadataRepository import com.afternote.feature.timeletter.domain.testing.FakeReceiverTimeLetterRepository import com.afternote.feature.timeletter.domain.testing.FakeTimeLetterRepository @@ -159,32 +162,57 @@ class TimeLetterLifecycleTest { @Test fun drafts_selectionDeleteReentryAndDeleteAll_reloadDurableRepositoryState() { - val firstDraft = timeLetter(id = 31L, title = "첫 임시 편지", status = TimeLetterStatus.DRAFT) - val secondDraft = timeLetter(id = 32L, title = "둘째 임시 편지", status = TimeLetterStatus.DRAFT) + val firstDraft = + timeLetter( + id = 31L, + title = "한 줄을 넘길 만큼 아주 긴 첫 임시 편지 제목이 레이아웃 높이를 늘리지 않아야 합니다", + status = TimeLetterStatus.DRAFT, + ) + val secondDraft = + timeLetter( + id = 32L, + title = "둘째 임시 편지", + sendAt = "2026-11-02T10:00:00", + status = TimeLetterStatus.DRAFT, + ) val repository = FakeTimeLetterRepository( temporaryLetters = TimeLetterList(listOf(firstDraft, secondDraft), totalCount = 2), ) - var activeViewModel by mutableStateOf(DraftLetterViewModel(repository)) + val userRepository = privateUserRepository(testReceivers) + var openedDraftId: Long? = null + var activeViewModel by mutableStateOf(DraftLetterViewModel(repository, userRepository)) composeRule.setContent { AfternoteTheme { - DraftLetterScreen(onBackClick = {}, viewModel = activeViewModel) + DraftLetterScreen( + onBackClick = {}, + onOpenDraft = { openedDraftId = it }, + viewModel = activeViewModel, + onRefreshConsumed = {}, + ) } } - composeRule.onNodeWithText("첫 임시 편지").assertIsDisplayed() + composeRule.onNodeWithText("한 줄을 넘길 만큼 아주 긴 첫 임시 편지 제목", substring = true).assertIsDisplayed() + composeRule.onNodeWithText("발송 예정일 2026.10.01.").assertIsDisplayed() composeRule.onNodeWithText("둘째 임시 편지").assertIsDisplayed() - - composeRule.onNodeWithText("수정").performClick() - composeRule.onNode(hasText("첫 임시 편지") and hasClickAction()).performClick() - composeRule.onNodeWithText("삭제").performClick() + composeRule.onNode(hasText("한 줄을 넘길 만큼 아주 긴 첫 임시 편지 제목", substring = true) and hasClickAction()).performClick() + assertEquals(31L, openedDraftId) + + composeRule.onNodeWithText("선택").performClick() + composeRule.onNodeWithText("선택 삭제").assertIsNotEnabled() + composeRule.onNode(hasText("한 줄을 넘길 만큼 아주 긴 첫 임시 편지 제목", substring = true) and hasClickAction()).performClick() + composeRule.onNodeWithText("선택 삭제").assertIsEnabled() + composeRule.onNodeWithText("선택 삭제").performClick() composeRule.waitUntil(timeoutMillis = TIMEOUT) { repository.deleteCalls == listOf(listOf(31L)) } - composeRule.onNodeWithText("첫 임시 편지").assertDoesNotExist() + composeRule.onNodeWithText("한 줄을 넘길 만큼 아주 긴 첫 임시 편지 제목", substring = true).assertDoesNotExist() composeRule.onNodeWithText("둘째 임시 편지").assertIsDisplayed() - composeRule.runOnIdle { activeViewModel = DraftLetterViewModel(repository) } + composeRule.runOnIdle { + activeViewModel = DraftLetterViewModel(repository, userRepository) + } composeRule.waitUntil(timeoutMillis = TIMEOUT) { repository.getTemporaryTimeLettersCalls == 2 } - composeRule.onNodeWithText("첫 임시 편지").assertDoesNotExist() + composeRule.onNodeWithText("한 줄을 넘길 만큼 아주 긴 첫 임시 편지 제목", substring = true).assertDoesNotExist() composeRule.onNodeWithText("둘째 임시 편지").assertIsDisplayed() composeRule.runOnIdle { activeViewModel.deleteAll() } @@ -197,6 +225,49 @@ class TimeLetterLifecycleTest { assertTrue(repository.temporaryLetters.timeLetters.isEmpty()) } + @Test + fun draftReturnResult_refreshesRepositoryExactlyOnce() { + val draft = timeLetter(id = 35L, title = "등록 전 임시 편지", status = TimeLetterStatus.DRAFT) + val repository = + PrivateTimeLetterRepository( + draftLetters = TimeLetterList(listOf(draft), totalCount = 1), + ) + val viewModel = DraftLetterViewModel(repository, privateUserRepository(testReceivers)) + var refreshRequested by mutableStateOf(false) + + composeRule.setContent { + AfternoteTheme { + DraftLetterScreen( + onBackClick = {}, + onOpenDraft = {}, + viewModel = viewModel, + refreshRequested = refreshRequested, + onRefreshConsumed = { refreshRequested = false }, + ) + } + } + composeRule.onNodeWithText("등록 전 임시 편지").assertIsDisplayed() + assertEquals(1, repository.temporaryListCalls) + + composeRule.runOnIdle { + repository.replaceDraftLetters(TimeLetterList(emptyList(), totalCount = 0)) + refreshRequested = true + } + // v2 rule 은 StandardTestDispatcher 라 LaunchedEffect(refreshRequested) 가 트리거한 + // viewModelScope 코루틴을 waitUntil 폴링만으로는 못 잡는다 — 한 번 명시적으로 idle 해 + // 재조합·이펙트를 먼저 흘려보낸다. + composeRule.waitForIdle() + + composeRule.waitUntil(timeoutMillis = TIMEOUT) { + repository.temporaryListCalls == 2 && + viewModel.uiState.value.let { it is DraftLetterUiState.Success && it.drafts.isEmpty() } + } + composeRule.onNodeWithText("등록 전 임시 편지").assertDoesNotExist() + composeRule.runOnIdle { assertFalse(refreshRequested) } + composeRule.waitForIdle() + assertEquals(2, repository.temporaryListCalls) + } + @Test fun editingExistingLetter_showsLoadedStateAndSendsExactUpdatePayload() { val existingLetter = @@ -375,6 +446,114 @@ class TimeLetterLifecycleTest { } } +private data class PrivateTimeLetterUpdateCall( + val timeLetterId: Long, + val title: String?, + val blocks: List, + val sendAt: String?, + val deliveryMode: TimeLetterDeliveryMode?, + val status: TimeLetterStatus?, +) + +private class PrivateTimeLetterRepository( + registeredLetters: TimeLetterList = TimeLetterList(emptyList(), 0), + draftLetters: TimeLetterList = TimeLetterList(emptyList(), 0), + private val editingLetter: TimeLetter? = null, +) : TimeLetterRepository { + var registeredLetters = registeredLetters + private set + var draftLetters = draftLetters + private set + var nextListLoad: CompletableDeferred>? = null + val deleteResults = ArrayDeque>() + val deleteCalls = mutableListOf>() + val updateCalls = mutableListOf() + var listCalls = 0 + private set + var temporaryListCalls = 0 + private set + var detailCalls = 0 + private set + var createCalls = 0 + private set + var deleteAllTemporaryCalls = 0 + private set + + fun replaceDraftLetters(value: TimeLetterList) { + draftLetters = value + } + + override suspend fun getTimeLetters(): TimeLetterList { + listCalls += 1 + val pending = nextListLoad + nextListLoad = null + val loaded = pending?.await()?.getOrThrow() ?: registeredLetters + registeredLetters = loaded + return loaded + } + + override suspend fun getTemporaryTimeLetters(): TimeLetterList { + temporaryListCalls += 1 + return draftLetters + } + + override suspend fun getTimeLetter(timeLetterId: Long): TimeLetter { + detailCalls += 1 + return requireNotNull(editingLetter).also { assertEquals(timeLetterId, it.id) } + } + + override suspend fun createTimeLetter( + title: String?, + blocks: List, + sendAt: String?, + deliveryMode: TimeLetterDeliveryMode, + status: TimeLetterStatus, + receiverIds: List, + ): TimeLetter { + createCalls += 1 + return timeLetter( + id = 999L, + title = title, + receiverIds = receiverIds, + sendAt = sendAt, + status = status, + ) + } + + override suspend fun updateTimeLetter( + timeLetterId: Long, + title: String?, + blocks: List, + sendAt: String?, + deliveryMode: TimeLetterDeliveryMode?, + status: TimeLetterStatus?, + ): TimeLetter { + updateCalls += + PrivateTimeLetterUpdateCall( + timeLetterId = timeLetterId, + title = title, + blocks = blocks, + sendAt = sendAt, + deliveryMode = deliveryMode, + status = status, + ) + return requireNotNull(editingLetter).copy(title = title, sendAt = sendAt) + } + + override suspend fun deleteTimeLetters(timeLetterIds: List) { + deleteCalls += timeLetterIds + val result = deleteResults.removeFirstOrNull() ?: Result.success(Unit) + result.getOrThrow() + registeredLetters = registeredLetters.without(timeLetterIds) + draftLetters = draftLetters.without(timeLetterIds) + } + + override suspend fun deleteAllTemporary() { + deleteAllTemporaryCalls += 1 + draftLetters = TimeLetterList(emptyList(), 0) + } +} + private fun privateUserRepository(receivers: List): FakeUserRepository = FakeUserRepository.strict().apply { onReceiverListFlow = { flowOf(receivers) } diff --git a/feature/timeletter/presentation/src/test/kotlin/com/afternote/feature/timeletter/presentation/component/DraftDateFormatterTest.kt b/feature/timeletter/presentation/src/test/kotlin/com/afternote/feature/timeletter/presentation/component/DraftDateFormatterTest.kt new file mode 100644 index 0000000000..db63e5e6f8 --- /dev/null +++ b/feature/timeletter/presentation/src/test/kotlin/com/afternote/feature/timeletter/presentation/component/DraftDateFormatterTest.kt @@ -0,0 +1,23 @@ +package com.afternote.feature.timeletter.presentation.component + +import org.junit.Assert.assertEquals +import org.junit.Test + +class DraftDateFormatterTest { + @Test + fun `ISO date-time is formatted for the draft list`() { + assertEquals("2026.07.31.", formatDraftSendAt("2026-07-31T00:00:00")) + } + + @Test + fun `date-only value is formatted for the draft list`() { + assertEquals("2029.11.20.", formatDraftSendAt("2029-11-20")) + } + + @Test + fun `missing or malformed value uses dash`() { + assertEquals("–", formatDraftSendAt(null)) + assertEquals("–", formatDraftSendAt("")) + assertEquals("–", formatDraftSendAt("not-a-date")) + } +} 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..375d1c5cd5 --- /dev/null +++ b/feature/timeletter/presentation/src/test/kotlin/com/afternote/feature/timeletter/presentation/viewmodel/DraftLetterViewModelTest.kt @@ -0,0 +1,188 @@ +package com.afternote.feature.timeletter.presentation.viewmodel + +import com.afternote.core.domain.repository.UserRepository +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.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.lang.reflect.Proxy + +@OptIn(ExperimentalCoroutinesApi::class) +class DraftLetterViewModelTest { + @Before + fun setUp() { + Dispatchers.setMain(UnconfinedTestDispatcher()) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `receiver load failure keeps drafts visible`() { + val viewModel = + DraftLetterViewModel( + timeLetterRepository { methodName, _ -> + when (methodName) { + "getTemporaryTimeLetters" -> testDrafts + else -> error("Unexpected repository call: $methodName") + } + }, + userRepository { throw IllegalStateException("receiver load failed") }, + ) + + val state = viewModel.uiState.value as DraftLetterUiState.Success + + assertEquals(testDrafts.timeLetters, state.drafts) + assertTrue(state.receiverNameMap.isEmpty()) + } + + @Test + fun `duplicate delete requests are ignored while deletion is running`() { + val deleteResult = CompletableDeferred() + var deleteCalls = 0 + val viewModel = + DraftLetterViewModel( + object : TimeLetterRepository { + override suspend fun getTemporaryTimeLetters(): TimeLetterList = testDrafts + + override suspend fun deleteAllTemporary() { + deleteCalls += 1 + deleteResult.await() + } + + override suspend fun getTimeLetters(): TimeLetterList = unexpectedCall() + + override suspend fun getTimeLetter(timeLetterId: Long): TimeLetter = unexpectedCall() + + override suspend fun createTimeLetter( + title: String?, + blocks: List, + sendAt: String?, + deliveryMode: TimeLetterDeliveryMode, + status: TimeLetterStatus, + receiverIds: List, + ): TimeLetter = unexpectedCall() + + override suspend fun updateTimeLetter( + timeLetterId: Long, + title: String?, + blocks: List, + sendAt: String?, + deliveryMode: TimeLetterDeliveryMode?, + status: TimeLetterStatus?, + ): TimeLetter = unexpectedCall() + + override suspend fun deleteTimeLetters(timeLetterIds: List) = unexpectedCall() + }, + userRepository { emptyList() }, + ) + + viewModel.deleteAll() + viewModel.deleteAll() + + assertEquals(1, deleteCalls) + assertTrue((viewModel.uiState.value as DraftLetterUiState.Success).isDeleting) + + deleteResult.complete(Unit) + + val state = viewModel.uiState.value as DraftLetterUiState.Success + assertTrue(state.drafts.isEmpty()) + assertFalse(state.isDeleting) + } + + @Test + fun `delete failure keeps latest drafts and exposes message`() { + val viewModel = + DraftLetterViewModel( + timeLetterRepository { methodName, _ -> + when (methodName) { + "getTemporaryTimeLetters" -> testDrafts + "deleteAllTemporary" -> throw IllegalStateException("delete failed") + else -> error("Unexpected repository call: $methodName") + } + }, + userRepository { emptyList() }, + ) + + viewModel.deleteAll() + + val state = viewModel.uiState.value as DraftLetterUiState.Success + assertEquals(testDrafts.timeLetters, state.drafts) + assertFalse(state.isDeleting) + assertEquals(com.afternote.feature.timeletter.presentation.R.string.timeletter_draft_delete_error, state.messageRes) + } + + @Test + fun `delete selected is disabled when no draft is selected`() { + val state = DraftLetterUiState.Success(drafts = testDrafts.timeLetters) + + assertFalse(state.isDeleteSelectedEnabled) + } + + @Test + fun `delete selected is enabled when a draft is selected`() { + val state = + DraftLetterUiState.Success( + drafts = testDrafts.timeLetters, + selectedIds = setOf(testDrafts.timeLetters.single().id), + ) + + assertTrue(state.isDeleteSelectedEnabled) + } + + private fun timeLetterRepository(handler: (String, Array?) -> Any?): TimeLetterRepository = + Proxy.newProxyInstance( + TimeLetterRepository::class.java.classLoader, + arrayOf(TimeLetterRepository::class.java), + ) { _, method, args -> handler(method.name, args) } as TimeLetterRepository + + private fun userRepository(getReceivers: () -> Any): UserRepository = + Proxy.newProxyInstance( + UserRepository::class.java.classLoader, + arrayOf(UserRepository::class.java), + ) { _, method, _ -> + when (method.name) { + "getReceiverListFlow" -> flowOf(emptyList()) + "getReceivers" -> getReceivers() + else -> error("Unexpected user repository call: ${method.name}") + } + } as UserRepository + + private fun unexpectedCall(): T = error("Unexpected repository call") + + private companion object { + val testDrafts = + TimeLetterList( + timeLetters = + listOf( + TimeLetter( + id = 1L, + title = "draft", + sendAt = null, + deliveredAt = null, + status = TimeLetterStatus.DRAFT, + blocks = emptyList(), + receiverIds = listOf(1L), + ), + ), + totalCount = 1, + ) + } +} diff --git a/feature/timeletter/presentation/src/test/kotlin/com/afternote/feature/timeletter/presentation/viewmodel/TimeLetterWriteViewModelTest.kt b/feature/timeletter/presentation/src/test/kotlin/com/afternote/feature/timeletter/presentation/viewmodel/TimeLetterWriteViewModelTest.kt index 5769dcc839..d62a1254e9 100644 --- a/feature/timeletter/presentation/src/test/kotlin/com/afternote/feature/timeletter/presentation/viewmodel/TimeLetterWriteViewModelTest.kt +++ b/feature/timeletter/presentation/src/test/kotlin/com/afternote/feature/timeletter/presentation/viewmodel/TimeLetterWriteViewModelTest.kt @@ -3,6 +3,9 @@ package com.afternote.feature.timeletter.presentation.viewmodel import androidx.lifecycle.SavedStateHandle import com.afternote.core.domain.testing.FakePhotoUploadRepository import com.afternote.core.domain.testing.FakeUserRepository +import com.afternote.feature.timeletter.domain.model.TimeLetter +import com.afternote.feature.timeletter.domain.model.TimeLetterList +import com.afternote.feature.timeletter.domain.model.TimeLetterStatus import com.afternote.feature.timeletter.domain.testing.FakeFileMetadataRepository import com.afternote.feature.timeletter.domain.testing.FakeTimeLetterRepository import com.afternote.feature.timeletter.domain.testing.FakeVoiceRecorderRepository @@ -21,6 +24,7 @@ import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import org.junit.runner.RunWith @@ -53,7 +57,7 @@ class TimeLetterWriteViewModelTest { advanceUntilIdle() assertEquals(0, repository.createCalls.size) - assertEquals(TimeLetterWriteError.RECIPIENT_REQUIRED, viewModel.uiState.value.error) + assertEquals(TimeLetterWriteError.RecipientRequired, viewModel.uiState.value.error) assertFalse(viewModel.uiState.value.savedAsDraft) } @@ -69,7 +73,7 @@ class TimeLetterWriteViewModelTest { assertEquals(0, repository.getTimeLettersCalls) assertEquals(0, repository.createCalls.size) - assertEquals(TimeLetterWriteError.RECIPIENT_REQUIRED, viewModel.uiState.value.error) + assertEquals(TimeLetterWriteError.RecipientRequired, viewModel.uiState.value.error) assertFalse(viewModel.uiState.value.registered) } @@ -87,7 +91,7 @@ class TimeLetterWriteViewModelTest { advanceUntilIdle() assertEquals(1, repository.createCalls.size) - assertEquals(TimeLetterWriteError.SAVE_FAILED, viewModel.uiState.value.error) + assertEquals(TimeLetterWriteError.SaveFailed, viewModel.uiState.value.error) assertFalse(viewModel.uiState.value.savedAsDraft) } @@ -142,7 +146,52 @@ class TimeLetterWriteViewModelTest { assertFalse(viewModel.uiState.value.savedAsDraft) } - private fun viewModel(repository: FakeTimeLetterRepository): TimeLetterWriteViewModel { + @Test + fun `draft edit checks the free plan limit before scheduling`() = + runTest { + val editingLetter = testEditingLetter(status = TimeLetterStatus.DRAFT) + val repository = + FakeTimeLetterRepository( + registeredLetters = TimeLetterList(emptyList(), totalCount = 3), + details = mapOf(editingLetter.id to editingLetter), + ) + val viewModel = viewModel(repository, editingLetter.id) + advanceUntilIdle() + + viewModel.register(title = "title", textContents = emptyMap()) + advanceUntilIdle() + + assertEquals(1, repository.getTimeLettersCalls) + assertEquals(0, repository.updateCalls.size) + assertTrue(viewModel.uiState.value.showFreePlanLimitPopup) + assertFalse(viewModel.uiState.value.registered) + } + + @Test + fun `scheduled edit does not check the free plan limit again`() = + runTest { + val editingLetter = testEditingLetter(status = TimeLetterStatus.SCHEDULED) + val repository = + FakeTimeLetterRepository( + registeredLetters = TimeLetterList(emptyList(), totalCount = 3), + details = mapOf(editingLetter.id to editingLetter), + ) + val viewModel = viewModel(repository, editingLetter.id) + advanceUntilIdle() + + viewModel.register(title = "title", textContents = emptyMap()) + advanceUntilIdle() + + assertEquals(0, repository.getTimeLettersCalls) + assertEquals(1, repository.updateCalls.size) + assertFalse(viewModel.uiState.value.showFreePlanLimitPopup) + assertTrue(viewModel.uiState.value.registered) + } + + private fun viewModel( + repository: FakeTimeLetterRepository, + editingTimeLetterId: Long? = null, + ): TimeLetterWriteViewModel { val resolveUseCase = ResolveTimeLetterBlocksUseCase(FakePhotoUploadRepository.strict()) return TimeLetterWriteViewModel( createTimeLetterUseCase = CreateTimeLetterUseCase(repository, resolveUseCase), @@ -155,7 +204,18 @@ class TimeLetterWriteViewModelTest { }, fileMetadataRepository = FakeFileMetadataRepository.strict(), voiceRecorderRepository = FakeVoiceRecorderRepository, - savedStateHandle = SavedStateHandle(mapOf("timeLetterId" to null)), + savedStateHandle = SavedStateHandle(mapOf("timeLetterId" to editingTimeLetterId)), ) } + + private fun testEditingLetter(status: TimeLetterStatus): TimeLetter = + TimeLetter( + id = 10L, + title = "existing title", + sendAt = "2026-08-29T19:30:00", + deliveredAt = null, + status = status, + blocks = emptyList(), + receiverIds = listOf(1L), + ) }