From 903ada4f51dd9bb18a0ff0ddf4723aa9cf54f1e4 Mon Sep 17 00:00:00 2001 From: Sadturtleman Date: Tue, 25 Aug 2026 23:30:04 +0900 Subject: [PATCH 1/2] =?UTF-8?q?fix(mindrecord):=20=EC=88=98=EC=8B=A0?= =?UTF-8?q?=EC=9D=B8=20=EC=A1=B0=ED=9A=8C=20=EC=8B=A4=ED=8C=A8=EB=A5=BC=20?= =?UTF-8?q?=C2=AB=EB=93=B1=EB=A1=9D=20=EC=97=86=EC=9D=8C=C2=BB=20=EA=B3=BC?= =?UTF-8?q?=20=EA=B5=AC=EB=B6=84=ED=95=B4=20=EC=95=8C=EB=A6=B0=EB=8B=A4=20?= =?UTF-8?q?(#1019)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit loadReceivers 가 실패를 조용히 흡수해, 화면이 «아직 등록 안 함» 과 «못 불러옴» 을 같은 빈 목록으로 그렸다. 수신자 최소 1명이 필수인 경로가 있어 조회 실패가 곧 등록 불가로 이어지는데 그 사유가 화면 어디에도 없었다. - 상태에 receiverLoadError 를 두고 시트가 문구를 갈라 보여 준다. 실패일 때만 «다시 시도». - 문구는 도메인 고정값이다 — 예외 원문을 쓰면 오프라인에서 «Unable to resolve host …» 가 그대로 노출된다 (에뮬레이터에서 실제로 확인하고 고쳤다, #614 와 같은 규칙). - 호출부 0건이던 consumeImageUploadError 를 지웠다. 업로드 실패 문구의 수명은 «다음 업로드 시작까지» 이고, 걷는 함수만 두면 죽은 코드가 된다. Co-Authored-By: Claude Opus 5 (1M context) --- .../component/ReceiverSelectBottomSheet.kt | 25 ++- .../screen/sender/DiaryWriteScreen.kt | 3 + .../viewmodel/DailyQuestionWriteViewModel.kt | 6 +- .../viewmodel/DiaryWriteUiState.kt | 7 +- .../viewmodel/DiaryWriteViewModel.kt | 27 ++- .../src/main/res/values/strings.xml | 3 + .../viewmodel/ReceiverLoadFailureTest.kt | 165 ++++++++++++++++++ 7 files changed, 221 insertions(+), 15 deletions(-) create mode 100644 feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/ReceiverLoadFailureTest.kt diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/ReceiverSelectBottomSheet.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/ReceiverSelectBottomSheet.kt index e00a6d73dc..ed989c675e 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/ReceiverSelectBottomSheet.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/ReceiverSelectBottomSheet.kt @@ -27,6 +27,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.afternote.core.model.user.Receiver @@ -50,6 +51,9 @@ fun ReceiverSelectBottomSheet( onToggle: (Long) -> Unit, onDismiss: () -> Unit, modifier: Modifier = Modifier, + /** 조회 실패 문구. null 이면 «아직 등록 안 함» 이다 — 둘을 같은 빈 화면으로 보이지 않게 한다 (#1019). */ + loadError: String? = null, + onRetry: (() -> Unit)? = null, ) { ModalBottomSheet( onDismissRequest = onDismiss, @@ -69,6 +73,8 @@ fun ReceiverSelectBottomSheet( ReceiverSelectContent( receivers = receivers, selectedReceiverIds = selectedReceiverIds, + loadError = loadError, + onRetry = onRetry, onToggle = onToggle, onConfirm = onDismiss, modifier = modifier, @@ -83,6 +89,8 @@ private fun ReceiverSelectContent( onToggle: (Long) -> Unit, onConfirm: () -> Unit, modifier: Modifier = Modifier, + loadError: String? = null, + onRetry: (() -> Unit)? = null, ) { Column( modifier = @@ -105,18 +113,29 @@ private fun ReceiverSelectContent( } if (receivers.isEmpty()) { - Box( + Column( modifier = Modifier .fillMaxWidth() .padding(vertical = 40.dp), - contentAlignment = Alignment.Center, + horizontalAlignment = Alignment.CenterHorizontally, ) { + // 조회 실패와 «아직 등록 안 함» 은 같은 빈 목록이지만 사용자가 할 일이 다르다 — + // 실패는 다시 시도, 미등록은 등록이다. 문구로 갈라 준다 (#1019). Text( - text = stringResource(R.string.mindrecord_write_receiver_empty), + text = loadError ?: stringResource(R.string.mindrecord_write_receiver_empty), style = AfternoteDesign.typography.captionLargeR, color = AfternoteDesign.colors.gray6, ) + if (loadError != null && onRetry != null) { + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = stringResource(R.string.mindrecord_write_receiver_retry), + style = AfternoteDesign.typography.captionLargeR, + color = AfternoteDesign.colors.gray9, + modifier = Modifier.clickable(role = Role.Button, onClick = onRetry), + ) + } } } else { LazyColumn { diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/DiaryWriteScreen.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/DiaryWriteScreen.kt index 2a53f0a096..b74fbdddda 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/DiaryWriteScreen.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/DiaryWriteScreen.kt @@ -322,6 +322,9 @@ fun DiaryWriteScreen( ReceiverSelectBottomSheet( receivers = uiState.receivers, selectedReceiverIds = uiState.selectedReceiverIds, + // 실패를 빈 목록으로 흡수하지 않는다 — 사용자가 «등록 안 함» 으로 오해한다 (#1019). + loadError = uiState.receiverLoadError?.asString(), + onRetry = viewModel::loadReceivers, onToggle = viewModel::onReceiverToggled, onDismiss = { showReceiverSheet = false }, ) diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionWriteViewModel.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionWriteViewModel.kt index 24640f1fc1..632c12b372 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionWriteViewModel.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionWriteViewModel.kt @@ -195,6 +195,8 @@ class DailyQuestionWriteViewModel * 키 형태로 바꾼다 ([toWireContent]). 미리보기는 전체 URL 이라야 뜬다. */ suspend fun uploadImage(uriString: String): String? { + // 실패 문구의 수명은 «다음 업로드 시작까지» 다 — 화면에 걷는 수단이 따로 없고, + // 걷는 함수만 두면 호출부 0건인 죽은 코드가 된다 (#1019 리뷰 지적). _uiState.update { it.copy(isUploadingImage = true, imageUploadError = null) } return photoUploadRepository .upload(uriString = uriString, directory = MIND_RECORD_UPLOAD_DIRECTORY) @@ -214,10 +216,6 @@ class DailyQuestionWriteViewModel }.getOrNull() } - fun consumeImageUploadError() { - _uiState.update { it.copy(imageUploadError = null) } - } - /** * 저장(`isDraft=false`) / 임시저장(`isDraft=true`). * diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DiaryWriteUiState.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DiaryWriteUiState.kt index bcb1082cfa..dfc20469f2 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DiaryWriteUiState.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DiaryWriteUiState.kt @@ -11,8 +11,13 @@ data class DiaryWriteUiState( val mood: TodayMood? = null, val date: LocalDate = LocalDate.now(), val imageUrl: String? = null, - /** `GET /users/receivers` 로 불러온 내 수신인 목록. 로드 실패 시 빈 목록 (작성은 가능). */ + /** `GET /users/receivers` 로 불러온 내 수신인 목록. */ val receivers: List = emptyList(), + /** + * 수신인 조회 실패 안내. **빈 목록과 구분한다** — 조용히 흡수하면 사용자는 시트를 열어 + * 빈 목록만 보고 «등록된 수신인이 없다» 로 읽는다 (#1019). 작성 자체는 계속 가능하다. + */ + val receiverLoadError: UiText? = null, /** 수신자 선택 바텀시트에서 고른 수신자 ID. 등록 payload 의 `receiverIds` 로 전송. */ val selectedReceiverIds: Set = emptySet(), /** 임시저장 이어쓰기 — draft 프리필 로딩 중 여부. */ diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DiaryWriteViewModel.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DiaryWriteViewModel.kt index 68754836c8..dca0b05194 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DiaryWriteViewModel.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DiaryWriteViewModel.kt @@ -4,6 +4,7 @@ import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.navigation.toRoute +import com.afternote.core.common.result.runCatchingCancellable import com.afternote.core.domain.repository.PhotoUploadRepository import com.afternote.core.domain.repository.UserRepository import com.afternote.core.ui.UiText @@ -109,6 +110,8 @@ class DiaryWriteViewModel * 제출 직전에 키로 바꾼다 ([toWireContent]). 미리보기는 전체 URL 이라야 뜬다 (#1016). */ suspend fun uploadImage(uriString: String): String? { + // 실패 문구의 수명은 «다음 업로드 시작까지» 다 — 화면에 걷는 수단이 따로 없고, + // 걷는 함수만 두면 호출부 0건인 죽은 코드가 된다 (#1019 리뷰 지적). _uiState.update { it.copy(isUploadingImage = true, imageUploadError = null) } return photoUploadRepository .upload(uriString = uriString, directory = MIND_RECORD_UPLOAD_DIRECTORY) @@ -130,10 +133,6 @@ class DiaryWriteViewModel }.getOrNull() } - fun consumeImageUploadError() { - _uiState.update { it.copy(imageUploadError = null) } - } - fun submit(isDraft: Boolean = false) { val state = _uiState.value if (!state.canSubmit) return @@ -193,11 +192,25 @@ class DiaryWriteViewModel } // 실패해도 수신자 행이 "수신자 설정하기" 로 남을 뿐 작성 자체는 가능 — 에러는 조용히 무시. - private fun loadReceivers() { + + /** + * 수신인 목록 조회. **실패를 빈 목록으로 흡수하지 않는다** (#1019). + * + * 흡수하면 화면이 «아직 등록 안 함» 과 «못 불러옴» 을 구분하지 못해, 사용자는 시트를 + * 열어 빈 목록만 본다. 수신자 없이도 작성은 되므로 화면을 막지는 않는다. + */ + fun loadReceivers() { viewModelScope.launch { - runCatching { userRepository.getReceivers() } + _uiState.update { it.copy(receiverLoadError = null) } + runCatchingCancellable { userRepository.getReceivers() } .onSuccess { receivers -> - _uiState.update { it.copy(receivers = receivers) } + _uiState.update { it.copy(receivers = receivers, receiverLoadError = null) } + }.onFailure { + // 서버·예외 원문을 화면 문구로 쓰지 않는다 — 오프라인이면 «Unable to + // resolve host …» 가 그대로 노출된다. 도메인 문구로 고정한다 (#614·#1019). + _uiState.update { + it.copy(receiverLoadError = UiText.Resource(R.string.mindrecord_error_receiver_load_failed)) + } } } } diff --git a/feature/mindrecord/presentation/src/main/res/values/strings.xml b/feature/mindrecord/presentation/src/main/res/values/strings.xml index e67c22f8bb..a46de1ac0c 100644 --- a/feature/mindrecord/presentation/src/main/res/values/strings.xml +++ b/feature/mindrecord/presentation/src/main/res/values/strings.xml @@ -210,4 +210,7 @@ 마음의 기록 – + + 수신인 목록을 불러오지 못했어요. + 다시 시도 diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/ReceiverLoadFailureTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/ReceiverLoadFailureTest.kt new file mode 100644 index 0000000000..fed9b57bc6 --- /dev/null +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/ReceiverLoadFailureTest.kt @@ -0,0 +1,165 @@ +package com.afternote.feature.mindrecord.presentation.viewmodel + +import androidx.lifecycle.SavedStateHandle +import com.afternote.core.domain.repository.PhotoUploadRepository +import com.afternote.core.domain.repository.UserRepository +import com.afternote.core.ui.UiText +import com.afternote.feature.mindrecord.domain.model.DiaryCreatePayload +import com.afternote.feature.mindrecord.domain.model.DiaryList +import com.afternote.feature.mindrecord.domain.model.DiaryUpdatePayload +import com.afternote.feature.mindrecord.domain.repository.DailyQuestionRepository +import com.afternote.feature.mindrecord.domain.repository.DiaryRepository +import com.afternote.feature.mindrecord.presentation.R +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flowOf +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.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test +import java.io.IOException +import java.lang.reflect.Proxy + +/** + * 수신인 조회 실패가 «등록된 수신인 없음» 과 구분되는지 (#1019). + * + * 종전에는 `runCatching { }.onSuccess { }` 로 실패를 조용히 흘려, 화면이 두 경우를 같은 빈 + * 목록으로 그렸다. 수신자 최소 1명이 필수인 경로가 있어 조회 실패가 곧 «등록 불가» 로 + * 이어지는데, 그 사유가 화면 어디에도 없었다. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class ReceiverLoadFailureTest { + private val dispatcher = StandardTestDispatcher() + + @Before + fun setUp() { + Dispatchers.setMain(dispatcher) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `조회에 실패하면 사유가 상태에 남는다`() = + runTest(dispatcher) { + val viewModel = viewModel(failure = IOException("offline")) + advanceUntilIdle() + + assertNotNull("실패가 조용히 흡수됐다", viewModel.uiState.value.receiverLoadError) + assertEquals(emptyList(), viewModel.uiState.value.receivers) + } + + @Test + fun `실패 문구는 예외 원문이 아니라 도메인 문구다`() = + runTest(dispatcher) { + // 오프라인이면 예외 message 가 «Unable to resolve host "afternote.kro.kr" …» 이다. + // 그대로 쓰면 사용자에게 기술 원문이 노출된다 (#614 가 수신자 화면에서 막은 것과 같다). + val viewModel = viewModel(failure = IOException("Unable to resolve host \"afternote.kro.kr\"")) + advanceUntilIdle() + + assertEquals( + UiText.Resource(R.string.mindrecord_error_receiver_load_failed), + viewModel.uiState.value.receiverLoadError, + ) + } + + @Test + fun `조회에 성공하면 사유가 남지 않는다`() = + runTest(dispatcher) { + val viewModel = viewModel(failure = null) + advanceUntilIdle() + + assertNull(viewModel.uiState.value.receiverLoadError) + } + + @Test + fun `재시도가 성공하면 안내가 걷힌다`() = + runTest(dispatcher) { + // 실패 → 재시도 성공. 안내가 남아 있으면 «불러왔는데 실패 문구» 가 된다. + var shouldFail = true + val viewModel = + DiaryWriteViewModel( + savedStateHandle = SavedStateHandle(emptyMap()), + repository = NoopDiaryRepository, + photoUploadRepository = PhotoUploadRepository { _, _ -> error("업로드는 호출되지 않는다") }, + userRepository = + userRepository { + if (shouldFail) throw IOException("offline") else emptyList() + }, + draftLoader = MindRecordDraftLoader(NoopDiaryRepository, NoopDailyQuestionRepository), + ) + advanceUntilIdle() + assertNotNull(viewModel.uiState.value.receiverLoadError) + + shouldFail = false + viewModel.loadReceivers() + advanceUntilIdle() + + assertNull("재시도 성공 뒤에도 실패 안내가 남았다", viewModel.uiState.value.receiverLoadError) + } + + private fun viewModel(failure: Throwable?): DiaryWriteViewModel = + DiaryWriteViewModel( + savedStateHandle = SavedStateHandle(emptyMap()), + repository = NoopDiaryRepository, + photoUploadRepository = PhotoUploadRepository { _, _ -> error("업로드는 호출되지 않는다") }, + userRepository = userRepository { failure?.let { throw it } ?: emptyList() }, + draftLoader = MindRecordDraftLoader(NoopDiaryRepository, NoopDailyQuestionRepository), + ) + + /** UserRepository 는 표면이 넓다 — 이 시나리오가 타는 호출만 답한다. */ + private fun userRepository(onGetReceivers: () -> Any): UserRepository = + Proxy.newProxyInstance( + UserRepository::class.java.classLoader, + arrayOf(UserRepository::class.java), + ) { _, method, _ -> + when (method.name) { + "getReceivers" -> onGetReceivers() + "getReceiverListFlow" -> flowOf(emptyList()) + else -> error("Unexpected call: ${method.name}") + } + } as UserRepository +} + +private object NoopDiaryRepository : DiaryRepository { + override suspend fun getList( + yearMonth: String, + draftOnly: Boolean?, + ): Result = Result.success(DiaryList(diaries = emptyList(), monthDiaryCount = 0, weeklyDominantMood = null)) + + override suspend fun create(payload: DiaryCreatePayload): Result = error("호출되면 안 됨") + + override suspend fun update( + id: Long, + payload: DiaryUpdatePayload, + ): Result = error("호출되면 안 됨") + + override suspend fun delete(id: Long): Result = error("호출되면 안 됨") +} + +private object NoopDailyQuestionRepository : DailyQuestionRepository { + override suspend fun getList( + date: String?, + draftOnly: Boolean?, + ) = Result.success(emptyList()) + + override suspend fun getToday() = error("호출되면 안 됨") + + override suspend fun create(payload: com.afternote.feature.mindrecord.domain.model.DailyQuestionCreatePayload) = error("호출되면 안 됨") + + override suspend fun update( + id: Long, + payload: com.afternote.feature.mindrecord.domain.model.DailyQuestionUpdatePayload, + ) = error("호출되면 안 됨") + + override suspend fun delete(id: Long): Result = error("호출되면 안 됨") +} From 4683f172282ae34e566503ce046e364906b136b7 Mon Sep 17 00:00:00 2001 From: Sadturtleman Date: Wed, 26 Aug 2026 16:08:26 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix(mindrecord):=20=EC=88=98=EC=8B=A0?= =?UTF-8?q?=EC=9D=B8=20=EC=A1=B0=ED=9A=8C=20=EC=A4=91=EC=9D=84=20=C2=AB?= =?UTF-8?q?=EB=AF=B8=EB=93=B1=EB=A1=9D=C2=BB=20=EA=B3=BC=20=EA=B0=88?= =?UTF-8?q?=EB=9D=BC=20=EC=9E=AC=EC=8B=9C=EB=8F=84=20=EC=B0=BD=EC=9D=98=20?= =?UTF-8?q?=ED=98=BC=EB=8F=99=EC=9D=84=20=EB=A7=89=EB=8A=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 시트는 «오류 문구 ?: 미등록 문구» 로 고른다. 재시도를 누르면 오류만 지워지고 목록은 아직 비어 있어, 응답이 올 때까지 «등록된 수신자가 없습니다» 가 떴다 — 이 PR 이 없애려던 혼동이 사용자가 직접 누른 화면으로 되돌아온다. 기내모드는 DNS 에서 즉시 실패해 이 창을 가렸고, 서버 무응답이면 연결 10초·호출 30초까지 간다. - DiaryWriteUiState 에 isReceiverLoading 을 두고, loadReceivers() 가 요청 전후로 세운다 - 시트가 조회 중 · 조회 실패 · 미등록 세 상태를 가른다 - 재시도를 TextButton 으로 바꿔 MindRecordErrorBox 와 모양을 맞추고 터치 타깃을 48dp 로 올린다 (종전 평문 Text + clickable 은 글자 높이 ≈18dp 가 전부였다) - «다시 시도» 중복 문자열 셋을 mindrecord_error_retry 하나로 합친다 - 이 PR 의 논리와 정반대인 «에러는 조용히 무시» 주석을 지운다 가드: ReceiverLoadFailureTest 가 요청이 떠 있는 창의 상태를 fake 안에서 관찰한다. isReceiverLoading 세팅을 빼면 실패하는 것을 확인했다. 실기동: 오프라인 진입 → 실패 문구 + 다시 시도, 재시도 탭 → 진행 표시(지연 4초 주입), 응답 → 미등록 문구. 재시도 버튼 실측 높이 126px @420dpi = 48dp. Co-Authored-By: Claude Opus 5 (1M context) --- .../component/ReceiverSelectBottomSheet.kt | 45 +++++++++++++----- .../receiver/ReceiverMindRecordScreen.kt | 2 +- .../screen/sender/DiaryWriteScreen.kt | 1 + .../viewmodel/DiaryWriteUiState.kt | 8 ++++ .../viewmodel/DiaryWriteViewModel.kt | 13 +++-- .../src/main/res/values/strings.xml | 2 - .../viewmodel/ReceiverLoadFailureTest.kt | 47 +++++++++++++++++++ 7 files changed, 97 insertions(+), 21 deletions(-) diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/ReceiverSelectBottomSheet.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/ReceiverSelectBottomSheet.kt index ed989c675e..7e5af656f6 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/ReceiverSelectBottomSheet.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/ReceiverSelectBottomSheet.kt @@ -10,6 +10,7 @@ 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.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items @@ -17,17 +18,18 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource -import androidx.compose.ui.semantics.Role import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.afternote.core.model.user.Receiver @@ -54,6 +56,7 @@ fun ReceiverSelectBottomSheet( /** 조회 실패 문구. null 이면 «아직 등록 안 함» 이다 — 둘을 같은 빈 화면으로 보이지 않게 한다 (#1019). */ loadError: String? = null, onRetry: (() -> Unit)? = null, + isLoading: Boolean = false, ) { ModalBottomSheet( onDismissRequest = onDismiss, @@ -75,6 +78,7 @@ fun ReceiverSelectBottomSheet( selectedReceiverIds = selectedReceiverIds, loadError = loadError, onRetry = onRetry, + isLoading = isLoading, onToggle = onToggle, onConfirm = onDismiss, modifier = modifier, @@ -91,6 +95,7 @@ private fun ReceiverSelectContent( modifier: Modifier = Modifier, loadError: String? = null, onRetry: (() -> Unit)? = null, + isLoading: Boolean = false, ) { Column( modifier = @@ -120,21 +125,35 @@ private fun ReceiverSelectContent( .padding(vertical = 40.dp), horizontalAlignment = Alignment.CenterHorizontally, ) { - // 조회 실패와 «아직 등록 안 함» 은 같은 빈 목록이지만 사용자가 할 일이 다르다 — - // 실패는 다시 시도, 미등록은 등록이다. 문구로 갈라 준다 (#1019). - Text( - text = loadError ?: stringResource(R.string.mindrecord_write_receiver_empty), - style = AfternoteDesign.typography.captionLargeR, - color = AfternoteDesign.colors.gray6, - ) - if (loadError != null && onRetry != null) { - Spacer(modifier = Modifier.height(12.dp)) + // 조회 중이 빠지면 재시도를 누른 순간부터 응답이 올 때까지 이 자리가 «등록 안 함» + // 을 고른다 — 목록은 아직 비었고 오류만 지워진 창이다. 이 PR 이 없애려던 혼동이 + // 사용자 손으로 되돌아오므로, 세 상태를 모두 가른다 (#1019 리뷰). + if (isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + color = AfternoteDesign.colors.gray6, + strokeWidth = 2.dp, + ) + } else { + // 조회 실패와 «아직 등록 안 함» 은 같은 빈 목록이지만 사용자가 할 일이 다르다 — + // 실패는 다시 시도, 미등록은 등록이다. 문구로 갈라 준다 (#1019). Text( - text = stringResource(R.string.mindrecord_write_receiver_retry), + text = loadError ?: stringResource(R.string.mindrecord_write_receiver_empty), style = AfternoteDesign.typography.captionLargeR, - color = AfternoteDesign.colors.gray9, - modifier = Modifier.clickable(role = Role.Button, onClick = onRetry), + color = AfternoteDesign.colors.gray6, ) + if (loadError != null && onRetry != null) { + // 이 모듈의 재시도는 MindRecordErrorBox 가 TextButton 으로 그린다. + // 평문 Text + clickable 은 모양이 갈릴 뿐 아니라 터치 타깃이 글자 + // 높이(≈18dp)에 그쳐 접근성 최소치에 못 미친다 (#1019 리뷰). + TextButton(onClick = onRetry) { + Text( + text = stringResource(R.string.mindrecord_error_retry), + style = AfternoteDesign.typography.bodySmallB, + color = AfternoteDesign.colors.gray9, + ) + } + } } } } else { diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/receiver/ReceiverMindRecordScreen.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/receiver/ReceiverMindRecordScreen.kt index 73680c45bf..2edb001f08 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/receiver/ReceiverMindRecordScreen.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/receiver/ReceiverMindRecordScreen.kt @@ -261,7 +261,7 @@ private fun ErrorBox( // 종전에는 콜백을 받아 두고 쓰지 않아, 화면에 재시도 수단이 없었다 (#614). TextButton(onClick = onRetry) { Text( - text = stringResource(R.string.mindrecord_receiver_retry), + text = stringResource(R.string.mindrecord_error_retry), style = AfternoteDesign.typography.bodySmallB, color = AfternoteDesign.colors.gray9, ) diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/DiaryWriteScreen.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/DiaryWriteScreen.kt index b74fbdddda..f3a2b65132 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/DiaryWriteScreen.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/DiaryWriteScreen.kt @@ -324,6 +324,7 @@ fun DiaryWriteScreen( selectedReceiverIds = uiState.selectedReceiverIds, // 실패를 빈 목록으로 흡수하지 않는다 — 사용자가 «등록 안 함» 으로 오해한다 (#1019). loadError = uiState.receiverLoadError?.asString(), + isLoading = uiState.isReceiverLoading, onRetry = viewModel::loadReceivers, onToggle = viewModel::onReceiverToggled, onDismiss = { showReceiverSheet = false }, diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DiaryWriteUiState.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DiaryWriteUiState.kt index dfc20469f2..919f3039c0 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DiaryWriteUiState.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DiaryWriteUiState.kt @@ -18,6 +18,14 @@ data class DiaryWriteUiState( * 빈 목록만 보고 «등록된 수신인이 없다» 로 읽는다 (#1019). 작성 자체는 계속 가능하다. */ val receiverLoadError: UiText? = null, + /** + * 수신인 목록을 조회하는 중. + * + * 이게 없으면 «못 불러옴» 과 «아직 등록 안 함» 을 갈라 놓고도, 재시도를 누른 순간부터 + * 응답이 올 때까지 `receivers` 는 빈 목록이고 오류만 지워져 시트가 다시 «등록 안 함» 을 + * 고른다 — 이 PR 이 없애려던 그 혼동이 사용자 손으로 되돌아온다 (#1019 리뷰). + */ + val isReceiverLoading: Boolean = false, /** 수신자 선택 바텀시트에서 고른 수신자 ID. 등록 payload 의 `receiverIds` 로 전송. */ val selectedReceiverIds: Set = emptySet(), /** 임시저장 이어쓰기 — draft 프리필 로딩 중 여부. */ diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DiaryWriteViewModel.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DiaryWriteViewModel.kt index dca0b05194..7f24cc8b80 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DiaryWriteViewModel.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DiaryWriteViewModel.kt @@ -191,8 +191,6 @@ class DiaryWriteViewModel _uiState.update { it.copy(submitState = SubmitState.Idle) } } - // 실패해도 수신자 행이 "수신자 설정하기" 로 남을 뿐 작성 자체는 가능 — 에러는 조용히 무시. - /** * 수신인 목록 조회. **실패를 빈 목록으로 흡수하지 않는다** (#1019). * @@ -201,15 +199,20 @@ class DiaryWriteViewModel */ fun loadReceivers() { viewModelScope.launch { - _uiState.update { it.copy(receiverLoadError = null) } + _uiState.update { it.copy(isReceiverLoading = true, receiverLoadError = null) } runCatchingCancellable { userRepository.getReceivers() } .onSuccess { receivers -> - _uiState.update { it.copy(receivers = receivers, receiverLoadError = null) } + _uiState.update { + it.copy(receivers = receivers, isReceiverLoading = false, receiverLoadError = null) + } }.onFailure { // 서버·예외 원문을 화면 문구로 쓰지 않는다 — 오프라인이면 «Unable to // resolve host …» 가 그대로 노출된다. 도메인 문구로 고정한다 (#614·#1019). _uiState.update { - it.copy(receiverLoadError = UiText.Resource(R.string.mindrecord_error_receiver_load_failed)) + it.copy( + isReceiverLoading = false, + receiverLoadError = UiText.Resource(R.string.mindrecord_error_receiver_load_failed), + ) } } } diff --git a/feature/mindrecord/presentation/src/main/res/values/strings.xml b/feature/mindrecord/presentation/src/main/res/values/strings.xml index a46de1ac0c..0f3355cac4 100644 --- a/feature/mindrecord/presentation/src/main/res/values/strings.xml +++ b/feature/mindrecord/presentation/src/main/res/values/strings.xml @@ -206,11 +206,9 @@ 아직 열람할 수 있는 기록이 없어요.\n작성자가 전달 시점을 정하면 이곳에서 볼 수 있어요. 마음의 기록을 불러오지 못했어요. - 다시 시도 마음의 기록 – 수신인 목록을 불러오지 못했어요. - 다시 시도 diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/ReceiverLoadFailureTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/ReceiverLoadFailureTest.kt index fed9b57bc6..0ed31682b9 100644 --- a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/ReceiverLoadFailureTest.kt +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/ReceiverLoadFailureTest.kt @@ -20,8 +20,10 @@ import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain import org.junit.After import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import java.io.IOException @@ -107,6 +109,51 @@ class ReceiverLoadFailureTest { assertNull("재시도 성공 뒤에도 실패 안내가 남았다", viewModel.uiState.value.receiverLoadError) } + @Test + fun `재시도 중에는 미등록 문구가 아니라 조회 중이다`() = + runTest(dispatcher) { + // 시트는 «오류 문구 ?: 미등록 문구» 로 고른다. 재시도를 누른 순간 오류만 지워지고 + // 목록은 아직 비어 있으므로, 조회 중 상태가 없으면 그 창에서 «등록된 수신자가 + // 없습니다» 가 뜬다 — 이 PR 이 없애려던 혼동이 사용자 손으로 되돌아온다. + // + // 창의 길이는 서버가 정한다. 응답이 없으면 연결 10초·호출 30초까지 간다 (#1019 리뷰). + // fake 의 람다가 «요청이 떠 있는 그 창» 에서 실행되므로, 거기서 상태를 읽으면 + // 사용자가 그 순간 보는 화면을 그대로 관찰할 수 있다. + lateinit var viewModel: DiaryWriteViewModel + var shouldFail = true + var stateInFlight: DiaryWriteUiState? = null + viewModel = + DiaryWriteViewModel( + savedStateHandle = SavedStateHandle(emptyMap()), + repository = NoopDiaryRepository, + photoUploadRepository = PhotoUploadRepository { _, _ -> error("업로드는 호출되지 않는다") }, + userRepository = + userRepository { + if (shouldFail) { + throw IOException("offline") + } else { + stateInFlight = viewModel.uiState.value + emptyList() + } + }, + draftLoader = MindRecordDraftLoader(NoopDiaryRepository, NoopDailyQuestionRepository), + ) + advanceUntilIdle() + assertNotNull(viewModel.uiState.value.receiverLoadError) + + shouldFail = false + viewModel.loadReceivers() + advanceUntilIdle() + + val inFlight = checkNotNull(stateInFlight) { "재시도가 조회를 걸지 않았다" } + // 오류는 이미 걷혔고 목록은 아직 비었다 — 이 창을 «미등록» 으로 읽으면 안 된다. + assertNull(inFlight.receiverLoadError) + assertEquals(emptyList(), inFlight.receivers) + assertTrue("조회 중 상태가 없어 시트가 «미등록» 을 고른다", inFlight.isReceiverLoading) + + assertFalse("응답 뒤에도 조회 중이 남았다", viewModel.uiState.value.isReceiverLoading) + } + private fun viewModel(failure: Throwable?): DiaryWriteViewModel = DiaryWriteViewModel( savedStateHandle = SavedStateHandle(emptyMap()),