From 750a40976111245b53b479e4b9d582b5331e1beb Mon Sep 17 00:00:00 2001 From: Sadturtleman Date: Mon, 24 Aug 2026 12:33:51 +0900 Subject: [PATCH 1/4] =?UTF-8?q?fix(mindrecord):=20=EC=88=98=EC=8B=A0?= =?UTF-8?q?=EC=9E=90=EA=B0=80=20=EB=B0=9B=EC=9D=80=20=EA=B8=B0=EB=A1=9D?= =?UTF-8?q?=EC=9D=98=20=EB=B3=B8=EB=AC=B8=EC=9D=84=20=EC=97=B4=EC=96=B4=20?= =?UTF-8?q?=EB=B3=BC=20=EC=88=98=20=EC=9E=88=EA=B2=8C=20=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 수신자 마음의 기록에서 카드를 탭해도 아무 일이 없어 본문을 읽을 수 없었다. 카드는 clickable 인데 화면이 받은 onRecordClick 이 기본 빈 람다였고, 라우트에서도 아무것도 넘기지 않았다. 목록에서 제목만 보이고 content·todayMood 에 도달할 경로가 없었다. 서버는 목록 응답에 본문을 함께 준다. 그래서 추가 조회 없이 화면이 들고 있는 항목을 그대로 펼친다. 별도 라우트가 아니라 시트인 이유는 이 화면에 뒤로가기가 없어(#614) 새 화면을 쌓으면 나올 방법이 시스템 백키뿐이기 때문이다. 목록이 버리던 todayMood 를 도메인까지 올려 본문과 함께 보여준다. 일기에만 있는 값이라 없으면 자리도 만들지 않는다. Closes #618 --- .../data/mapper/MindRecordMapper.kt | 1 + .../domain/model/MindRecordModels.kt | 6 + .../mindrecord/presentation/build.gradle.kts | 7 ++ .../component/ReceiverRecordDetailSheet.kt | 107 ++++++++++++++++ .../receiver/ReceiverMindRecordScreen.kt | 33 ++++- .../receiver/ReceiverRecordDetailTest.kt | 118 ++++++++++++++++++ 6 files changed, 271 insertions(+), 1 deletion(-) create mode 100644 feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/ReceiverRecordDetailSheet.kt create mode 100644 feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/screen/receiver/ReceiverRecordDetailTest.kt diff --git a/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/mapper/MindRecordMapper.kt b/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/mapper/MindRecordMapper.kt index 6910cbf6e6..dfe84e3315 100644 --- a/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/mapper/MindRecordMapper.kt +++ b/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/mapper/MindRecordMapper.kt @@ -31,4 +31,5 @@ fun ReceiverDiaryItemDto.toDomain(): MindRecordSummary = isDraft = isDraft, createdAt = createdAt, imageUrl = imageUrl, + todayMood = todayMood?.toDomain(), ) diff --git a/feature/mindrecord/domain/src/main/kotlin/com/afternote/feature/mindrecord/domain/model/MindRecordModels.kt b/feature/mindrecord/domain/src/main/kotlin/com/afternote/feature/mindrecord/domain/model/MindRecordModels.kt index 6d42259fdc..6556b4d69f 100644 --- a/feature/mindrecord/domain/src/main/kotlin/com/afternote/feature/mindrecord/domain/model/MindRecordModels.kt +++ b/feature/mindrecord/domain/src/main/kotlin/com/afternote/feature/mindrecord/domain/model/MindRecordModels.kt @@ -22,4 +22,10 @@ data class MindRecordSummary( /** 서버 원본 표기 (`yyyy.MM.dd 요일`) — 카드에 그대로 노출. */ val createdAt: String, val imageUrl: String? = null, + /** + * 작성자가 고른 오늘의 기분. 일기에만 있고 데일리질문에는 없다. + * + * 종전에는 목록이 이 값을 버려서, 수신자가 본문과 함께 볼 수 없었다 (#618). + */ + val todayMood: TodayMood? = null, ) diff --git a/feature/mindrecord/presentation/build.gradle.kts b/feature/mindrecord/presentation/build.gradle.kts index 93b81b6fd8..58f43aeb94 100644 --- a/feature/mindrecord/presentation/build.gradle.kts +++ b/feature/mindrecord/presentation/build.gradle.kts @@ -7,6 +7,8 @@ plugins { android { namespace = "com.afternote.feature.mindrecord.presentation" resourcePrefix = "mindrecord_" + + testOptions.unitTests.isIncludeAndroidResources = true } dependencies { @@ -17,4 +19,9 @@ dependencies { implementation(projects.core.model) implementation(libs.coil.compose) implementation(libs.compose.rich.editor) + + // 수신자 기록 본문 시트를 JVM 에서 실제로 렌더해 확인한다 (#618). + testImplementation(libs.robolectric) + testImplementation(libs.androidx.compose.ui.test.junit4) + debugImplementation(libs.androidx.compose.ui.test.manifest) } diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/ReceiverRecordDetailSheet.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/ReceiverRecordDetailSheet.kt new file mode 100644 index 0000000000..d3c96d50b4 --- /dev/null +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/ReceiverRecordDetailSheet.kt @@ -0,0 +1,107 @@ +package com.afternote.feature.mindrecord.presentation.component + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.afternote.core.ui.theme.AfternoteDesign +import com.afternote.core.ui.theme.AfternoteTheme +import com.afternote.feature.mindrecord.domain.model.MindRecordSummary +import com.afternote.feature.mindrecord.domain.model.MindRecordType +import com.afternote.feature.mindrecord.domain.model.TodayMood +import com.afternote.feature.mindrecord.presentation.mapper.toEmoji +import com.afternote.feature.mindrecord.presentation.util.htmlToPlainText + +/** + * 수신자가 받은 기록 하나의 본문을 읽는 시트 (#618). + * + * 카드를 탭해도 아무 일이 없어 **제목 목록에서 멈춰** 있었다. 수신자가 고인의 기록을 + * 읽는 것이 이 화면의 목적인데 본문·기분에 도달할 경로가 없었다. + * + * **추가 조회를 하지 않는다.** 목록 응답이 이미 `content` 와 `todayMood` 를 함께 주므로 + * (`GET receiver-auth/diary` 실측), 화면이 들고 있는 항목을 그대로 펼치면 된다. + * + * 별도 라우트가 아니라 시트인 이유: 이 화면에는 뒤로가기가 없어(#614) 새 화면을 쌓으면 + * 나올 방법이 시스템 백키뿐이다. 시트는 스와이프·바깥 탭으로 닫힌다. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ReceiverRecordDetailSheet( + record: MindRecordSummary, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + containerColor = AfternoteDesign.colors.white, + modifier = modifier, + ) { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp) + .padding(bottom = 40.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text = record.createdAt, + style = AfternoteDesign.typography.captionLargeR, + color = AfternoteDesign.colors.gray6, + ) + // 일기에만 있는 값이라 없으면 자리도 만들지 않는다. + record.todayMood?.let { mood -> + Text(text = mood.toEmoji(), style = AfternoteDesign.typography.captionLargeR) + } + } + + Text( + text = record.title, + style = AfternoteDesign.typography.bodyLargeB, + color = AfternoteDesign.colors.gray9, + ) + + // 본문은 HTML 조각이라 태그를 벗겨 읽을 수 있는 텍스트로 만든다. + Text( + text = record.content.htmlToPlainText(), + style = AfternoteDesign.typography.bodySmallR, + color = AfternoteDesign.colors.gray9, + ) + } + } +} + +@Preview(showBackground = true) +@Composable +private fun ReceiverRecordDetailSheetPreview() { + AfternoteTheme { + ReceiverRecordDetailSheet( + record = + MindRecordSummary( + id = 11L, + type = MindRecordType.DIARY, + title = "오늘의 산책", + content = "

강변을 한 시간 걸었다.

", + recordDate = "2026-07-29", + isDraft = false, + createdAt = "2026.07.29 수", + todayMood = TodayMood.HAPPY, + ), + onDismiss = {}, + ) + } +} 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 0c9f30c1d1..30e8f11051 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 @@ -40,6 +40,7 @@ import com.afternote.feature.mindrecord.domain.model.MindRecordType import com.afternote.feature.mindrecord.presentation.component.ReceiverDiaryGridCard import com.afternote.feature.mindrecord.presentation.component.ReceiverMindRecordTopBar import com.afternote.feature.mindrecord.presentation.component.ReceiverRecordCard +import com.afternote.feature.mindrecord.presentation.component.ReceiverRecordDetailSheet import com.afternote.feature.mindrecord.presentation.viewmodel.ReceiverMindRecordUiState import com.afternote.feature.mindrecord.presentation.viewmodel.ReceiverMindRecordViewModel import androidx.compose.foundation.lazy.grid.items as gridItems @@ -59,6 +60,8 @@ fun ReceiverMindRecordScreen( ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() var filterSheetVisible by remember { mutableStateOf(false) } + // 탭한 기록의 본문을 여는 시트. 목록 응답이 이미 본문을 갖고 있어 추가 조회가 없다 (#618). + var openedRecordId by remember { mutableStateOf(null) } Scaffold(modifier = modifier) { paddingValues -> Box(modifier = Modifier.padding(paddingValues)) { @@ -75,13 +78,24 @@ fun ReceiverMindRecordScreen( SuccessContent( state = state, onFilterClick = { filterSheetVisible = true }, - onRecordClick = onRecordClick, + onRecordClick = { id -> + openedRecordId = id + onRecordClick(id) + }, ) } } } } + val openedRecord = findOpenedRecord(uiState, openedRecordId) + openedRecord?.let { record -> + ReceiverRecordDetailSheet( + record = record, + onDismiss = { openedRecordId = null }, + ) + } + if (filterSheetVisible && uiState is ReceiverMindRecordUiState.Success) { ReceiverMindRecordFilterSheet( current = (uiState as ReceiverMindRecordUiState.Success).filter, @@ -242,3 +256,20 @@ private fun ReceiverMindRecordScreenPreview() { ) } } + +/** + * 탭한 기록을 화면이 들고 있는 목록에서 찾는다 (#618). + * + * 두 탭(데일리질문·일기)이 같은 시트를 쓰므로 양쪽을 함께 뒤진다. 목록 응답이 이미 + * 본문을 갖고 있어 여기서 찾은 항목을 그대로 펼치면 되고, 추가 조회가 필요 없다. + * + * 필터·정렬로 목록에서 빠진 항목은 자연히 null 이 되어 시트가 닫힌다. + */ +internal fun findOpenedRecord( + uiState: ReceiverMindRecordUiState, + openedRecordId: Long?, +): MindRecordSummary? { + if (openedRecordId == null) return null + val success = uiState as? ReceiverMindRecordUiState.Success ?: return null + return (success.dailyQuestions + success.diaries).firstOrNull { it.id == openedRecordId } +} diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/screen/receiver/ReceiverRecordDetailTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/screen/receiver/ReceiverRecordDetailTest.kt new file mode 100644 index 0000000000..de2a31f086 --- /dev/null +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/screen/receiver/ReceiverRecordDetailTest.kt @@ -0,0 +1,118 @@ +package com.afternote.feature.mindrecord.presentation.screen.receiver + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithText +import com.afternote.core.ui.theme.AfternoteTheme +import com.afternote.feature.mindrecord.domain.model.MindRecordSummary +import com.afternote.feature.mindrecord.domain.model.MindRecordType +import com.afternote.feature.mindrecord.domain.model.TodayMood +import com.afternote.feature.mindrecord.presentation.component.ReceiverRecordDetailSheet +import com.afternote.feature.mindrecord.presentation.viewmodel.ReceiverMindRecordFilter +import com.afternote.feature.mindrecord.presentation.viewmodel.ReceiverMindRecordUiState +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * 수신자 기록 본문 열람 가드 (#618). + * + * 카드가 clickable 인데 콜백이 빈 람다라 탭해도 아무 일이 없었고, 목록에서 제목만 보이고 + * `content`·`todayMood` 에 도달할 경로가 없었다. 서버는 목록 응답에 본문을 함께 주므로 + * 화면이 들고 있는 항목을 펼치면 된다 — 추가 조회가 없다는 점도 함께 고정한다. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class ReceiverRecordDetailTest { + @get:Rule + val composeRule = createAndroidComposeRule() + + private val diary = + MindRecordSummary( + id = 11L, + type = MindRecordType.DIARY, + title = "오늘의 산책", + content = "

강변을 한 시간 걸었다.

", + recordDate = "2026-07-29", + isDraft = false, + createdAt = "2026.07.29 수", + todayMood = TodayMood.HAPPY, + ) + + private val answer = + MindRecordSummary( + id = 22L, + type = MindRecordType.DAILY_QUESTION, + title = "가장 기억에 남는 여행지는?", + content = "

제주.

", + recordDate = "2026-07-28", + isDraft = false, + createdAt = "2026.07.28 화", + ) + + private fun success() = + ReceiverMindRecordUiState.Success( + dailyQuestions = listOf(answer), + diaries = listOf(diary), + filter = ReceiverMindRecordFilter(), + ) + + @Test + fun `탭한 일기를 두 탭 목록에서 찾아낸다`() { + assertEquals(diary, findOpenedRecord(success(), 11L)) + } + + @Test + fun `탭한 데일리질문도 같은 경로로 찾는다`() { + assertEquals(answer, findOpenedRecord(success(), 22L)) + } + + @Test + fun `아무것도 안 눌렀으면 열지 않는다`() { + assertNull(findOpenedRecord(success(), null)) + } + + @Test + fun `필터로 목록에서 빠진 항목은 열리지 않는다`() { + // 시트가 열린 채 필터가 바뀌면 자연히 닫힌다 — 없는 기록을 계속 붙들지 않는다. + assertNull(findOpenedRecord(success(), 999L)) + } + + @Test + fun `본문 시트가 제목과 본문과 기분을 보여준다`() { + // 목록에서 제목만 보이던 상태에서 content·todayMood 에 도달하는 것이 이 이슈의 목적이다. + composeRule.setContent { + AfternoteTheme { + ReceiverRecordDetailSheet(record = diary, onDismiss = {}) + } + } + + composeRule.onNodeWithText("오늘의 산책").assertIsDisplayed() + // HTML 조각이라 태그를 벗겨 읽을 수 있는 텍스트로 보여준다. + composeRule.onNodeWithText("강변을 한 시간 걸었다.").assertIsDisplayed() + composeRule.onNodeWithText("2026.07.29 수").assertIsDisplayed() + composeRule.onNodeWithText("😊").assertIsDisplayed() + } + + @Test + fun `기분이 없는 데일리질문은 기분 자리를 만들지 않는다`() { + composeRule.setContent { + AfternoteTheme { + ReceiverRecordDetailSheet(record = answer, onDismiss = {}) + } + } + + composeRule.onNodeWithText("제주.").assertIsDisplayed() + assertEquals( + "기분이 없는 기록에는 이모지 자리가 없다", + 0, + composeRule.onAllNodesWithText("😊").fetchSemanticsNodes().size, + ) + } +} From 6551d72e6b24054f8066dde3ea432eee439944c0 Mon Sep 17 00:00:00 2001 From: Sadturtleman Date: Mon, 24 Aug 2026 12:41:24 +0900 Subject: [PATCH 2/4] =?UTF-8?q?fix(mindrecord):=20=EC=88=98=EC=8B=A0?= =?UTF-8?q?=EC=9E=90=20=EC=97=B4=EB=9E=8C=20=EC=8B=A4=ED=8C=A8=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=EC=9D=98=20=EB=8D=B0=EB=93=9C=EC=97=94=EB=93=9C?= =?UTF-8?q?=EB=A5=BC=20=EC=97=86=EC=95=A4=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 수신자가 마음의 기록을 열람하지 못하는 상황에서 화면에 남는 것이 오류 문구 하나뿐이라, 앱바·뒤로가기·재시도가 전혀 없어 시스템 백키 외에는 빠져나갈 방법이 없었다. 서버 응답의 message 도 그대로 화면 문구가 돼, 사용자는 "전달 조건" 이 무엇인지 자기가 무엇을 해야 하는지 알 수 없었다 — 실제로는 발신자가 설정해야 풀리는 상태라 수신자가 할 수 있는 일이 없다. 앱바를 붙여 이탈 수단을 만들고, 이미 받아 두고도 쓰지 않던 재시도 콜백을 실제 버튼으로 잇는다. 실패 문구는 서버 원문 대신 도메인 문구로 바꾼다 — 전달 조건 미충족(403/2009)은 기다리는 상태임을 알리고, 나머지는 일반 실패 문구로 수렴시킨다. Closes #614 --- .../mindrecord/presentation/build.gradle.kts | 1 + .../navigation/MindRecordNavGraph.kt | 3 +- .../receiver/ReceiverMindRecordScreen.kt | 42 +++++++++++++--- .../viewmodel/ReceiverMindRecordUiState.kt | 10 +++- .../viewmodel/ReceiverMindRecordViewModel.kt | 23 +++++++-- .../src/main/res/values/strings.xml | 5 ++ .../screen/receiver/ReceiverDeadEndTest.kt | 48 +++++++++++++++++++ 7 files changed, 121 insertions(+), 11 deletions(-) create mode 100644 feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/screen/receiver/ReceiverDeadEndTest.kt diff --git a/feature/mindrecord/presentation/build.gradle.kts b/feature/mindrecord/presentation/build.gradle.kts index 58f43aeb94..e96506b712 100644 --- a/feature/mindrecord/presentation/build.gradle.kts +++ b/feature/mindrecord/presentation/build.gradle.kts @@ -15,6 +15,7 @@ dependencies { implementation(projects.feature.mindrecord.domain) implementation(projects.core.common) implementation(projects.core.domain) + implementation(projects.core.network) implementation(projects.core.ui) implementation(projects.core.model) implementation(libs.coil.compose) diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/navigation/MindRecordNavGraph.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/navigation/MindRecordNavGraph.kt index 5e2813c196..50ee0019a3 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/navigation/MindRecordNavGraph.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/navigation/MindRecordNavGraph.kt @@ -33,7 +33,8 @@ fun NavGraphBuilder.mindRecordNavGraph(actions: MindRecordNavActions) { MemorySpaceScreen(onBackClick = actions::onMemorySpaceBack) } composable { - ReceiverMindRecordScreen() + // 앱바 뒤로가기를 실제로 붙인다 — 없으면 이 화면이 막다른 곳이 된다 (#614). + ReceiverMindRecordScreen(onBackClick = actions::onMemorySpaceBack) } composable { DailyQuestionWriteScreen( 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 30e8f11051..6a67316bd5 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 @@ -19,6 +19,7 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Tab import androidx.compose.material3.TabRowDefaults import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -29,14 +30,19 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign 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.asString import com.afternote.core.ui.theme.AfternoteDesign import com.afternote.core.ui.theme.AfternoteTheme +import com.afternote.core.ui.topbar.DetailTopBar import com.afternote.feature.mindrecord.domain.model.MindRecordSummary import com.afternote.feature.mindrecord.domain.model.MindRecordType +import com.afternote.feature.mindrecord.presentation.R import com.afternote.feature.mindrecord.presentation.component.ReceiverDiaryGridCard import com.afternote.feature.mindrecord.presentation.component.ReceiverMindRecordTopBar import com.afternote.feature.mindrecord.presentation.component.ReceiverRecordCard @@ -63,7 +69,17 @@ fun ReceiverMindRecordScreen( // 탭한 기록의 본문을 여는 시트. 목록 응답이 이미 본문을 갖고 있어 추가 조회가 없다 (#618). var openedRecordId by remember { mutableStateOf(null) } - Scaffold(modifier = modifier) { paddingValues -> + // 앱바가 없으면 이 화면은 막다른 곳이 된다 — 실패 시 화면에 남는 것이 오류 문구 하나뿐이라 + // 시스템 백키 외에 빠져나갈 수단이 없었다 (#614). + Scaffold( + modifier = modifier, + topBar = { + DetailTopBar( + title = stringResource(R.string.mindrecord_receiver_title), + onBackClick = onBackClick, + ) + }, + ) { paddingValues -> Box(modifier = Modifier.padding(paddingValues)) { when (val state = uiState) { ReceiverMindRecordUiState.Loading -> { @@ -71,7 +87,7 @@ fun ReceiverMindRecordScreen( } is ReceiverMindRecordUiState.Error -> { - ErrorBox(message = state.message, onRetry = viewModel::refresh) + ErrorBox(message = state.message.asString(), onRetry = viewModel::refresh) } is ReceiverMindRecordUiState.Success -> { @@ -219,10 +235,24 @@ private fun ErrorBox( onRetry: () -> Unit, ) { Box(modifier = Modifier.fillMaxSize().padding(20.dp), contentAlignment = Alignment.Center) { - Text(text = message, color = AfternoteDesign.colors.gray9) - // 재시도 트리거는 후속 PR. 현재는 라이프사이클 재진입으로 처리. - @Suppress("UNUSED_EXPRESSION") - onRetry + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = message, + color = AfternoteDesign.colors.gray9, + textAlign = TextAlign.Center, + ) + // 종전에는 콜백을 받아 두고 쓰지 않아, 화면에 재시도 수단이 없었다 (#614). + TextButton(onClick = onRetry) { + Text( + text = stringResource(R.string.mindrecord_receiver_retry), + style = AfternoteDesign.typography.bodySmallB, + color = AfternoteDesign.colors.gray9, + ) + } + } } } diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/ReceiverMindRecordUiState.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/ReceiverMindRecordUiState.kt index b1db3312b9..23b4722f8c 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/ReceiverMindRecordUiState.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/ReceiverMindRecordUiState.kt @@ -1,5 +1,6 @@ package com.afternote.feature.mindrecord.presentation.viewmodel +import com.afternote.core.ui.UiText import com.afternote.feature.mindrecord.domain.model.MindRecordSummary /** @@ -16,8 +17,15 @@ sealed interface ReceiverMindRecordUiState { val filter: ReceiverMindRecordFilter = ReceiverMindRecordFilter(), ) : ReceiverMindRecordUiState + /** + * 조회 실패. + * + * [message] 는 **도메인 문구**다. 종전에는 서버 응답의 `message` 를 그대로 실어 + * "아직 전달 조건이 충족되지 않았습니다" 같은 원문이 화면 문구가 됐다 — 사용자는 + * "전달 조건" 이 무엇인지, 자기가 무엇을 해야 하는지 알 수 없었다 (#614). + */ data class Error( - val message: String, + val message: UiText, ) : ReceiverMindRecordUiState } diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/ReceiverMindRecordViewModel.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/ReceiverMindRecordViewModel.kt index 9a9b2ad137..0cc344952d 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/ReceiverMindRecordViewModel.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/ReceiverMindRecordViewModel.kt @@ -2,9 +2,12 @@ package com.afternote.feature.mindrecord.presentation.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.afternote.core.network.model.ApiException +import com.afternote.core.ui.UiText import com.afternote.feature.mindrecord.domain.model.MindRecordSummary import com.afternote.feature.mindrecord.domain.model.ReceiverMindRecords import com.afternote.feature.mindrecord.domain.repository.MindRecordReceiverRepository +import com.afternote.feature.mindrecord.presentation.R import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -62,9 +65,7 @@ class ReceiverMindRecordViewModel ).withDerived() }.onFailure { e -> _uiState.value = - ReceiverMindRecordUiState.Error( - message = e.message ?: "마음의 기록을 불러오지 못했습니다.", - ) + ReceiverMindRecordUiState.Error(message = e.toDomainMessage()) } } } @@ -103,3 +104,19 @@ class ReceiverMindRecordViewModel } } } + +/** + * 서버 실패를 **도메인 문구**로 바꾼다 (#614). + * + * 전달 조건 미충족(403 / code 2009)은 수신자가 할 수 있는 일이 없는 상태다 — 발신자가 + * 전달 조건을 설정해야 풀린다. 그래서 원문("아직 전달 조건이 충족되지 않았습니다") 대신 + * 무슨 상황이고 무엇을 기다리는지 알려 준다. + */ +internal fun Throwable.toDomainMessage(): UiText = + when ((this as? ApiException)?.code) { + DELIVERY_CONDITION_NOT_MET -> UiText.Resource(R.string.mindrecord_receiver_delivery_not_ready) + else -> UiText.Resource(R.string.mindrecord_receiver_load_failed) + } + +/** 전달 조건 미충족 — `403 {"code":2009}`. */ +private const val DELIVERY_CONDITION_NOT_MET = 2009 diff --git a/feature/mindrecord/presentation/src/main/res/values/strings.xml b/feature/mindrecord/presentation/src/main/res/values/strings.xml index ea7f4f8d8e..4afa1e8ded 100644 --- a/feature/mindrecord/presentation/src/main/res/values/strings.xml +++ b/feature/mindrecord/presentation/src/main/res/values/strings.xml @@ -164,4 +164,9 @@ 임시 저장된 일기를 불러오지 못했습니다. 등록에 실패했습니다. 주간 리포트를 불러오지 못했습니다. + + 아직 열람할 수 있는 기록이 없어요.\n작성자가 전달 시점을 정하면 이곳에서 볼 수 있어요. + 마음의 기록을 불러오지 못했어요. + 다시 시도 + 마음의 기록 diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/screen/receiver/ReceiverDeadEndTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/screen/receiver/ReceiverDeadEndTest.kt new file mode 100644 index 0000000000..312a3d40ed --- /dev/null +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/screen/receiver/ReceiverDeadEndTest.kt @@ -0,0 +1,48 @@ +package com.afternote.feature.mindrecord.presentation.screen.receiver + +import com.afternote.core.network.model.ApiException +import com.afternote.core.ui.UiText +import com.afternote.feature.mindrecord.presentation.R +import com.afternote.feature.mindrecord.presentation.viewmodel.toDomainMessage +import org.junit.Assert.assertEquals +import org.junit.Test +import java.io.IOException + +/** + * 수신자 열람 실패 문구 가드 (#614). + * + * 종전에는 서버 응답의 `message` 를 그대로 화면에 실어 «아직 전달 조건이 충족되지 + * 않았습니다» 라는 원문이 그대로 노출됐다. 사용자는 "전달 조건" 이 무엇인지, 자기가 + * 무엇을 해야 하는지 알 수 없었다 — 실제로는 발신자가 설정해야 풀리는 상태라 + * 수신자가 할 수 있는 일이 없다. + */ +class ReceiverDeadEndTest { + @Test + fun `전달 조건 미충족은 기다리는 상태임을 알리는 문구가 된다`() { + val error = ApiException(status = 403, code = 2009, serverMessage = "아직 전달 조건이 충족되지 않았습니다.", message = "아직 전달 조건이 충족되지 않았습니다.") + + assertEquals(UiText.Resource(R.string.mindrecord_receiver_delivery_not_ready), error.toDomainMessage()) + } + + @Test + fun `서버 원문을 화면 문구로 쓰지 않는다`() { + // 어떤 코드가 오든 결과는 리소스 문구다 — 원문이 새어 나갈 자리가 없다. + val message = + ApiException( + status = 500, + code = 1004, + serverMessage = "서버 내부 오류가 발생했습니다.", + message = "서버 내부 오류가 발생했습니다.", + ).toDomainMessage() + + assertEquals(UiText.Resource(R.string.mindrecord_receiver_load_failed), message) + } + + @Test + fun `네트워크 예외도 같은 실패 문구로 수렴한다`() { + assertEquals( + UiText.Resource(R.string.mindrecord_receiver_load_failed), + IOException("timeout").toDomainMessage(), + ) + } +} From 857dda704639bfa2aa7e3a209c526f66216be16a Mon Sep 17 00:00:00 2001 From: Sadturtleman Date: Mon, 24 Aug 2026 15:49:56 +0900 Subject: [PATCH 3/4] =?UTF-8?q?refactor(mindrecord):=20=EC=88=98=EC=8B=A0?= =?UTF-8?q?=EC=9E=90=20=EC=8B=A4=ED=8C=A8=20=EB=A7=A4=ED=95=91=EC=9D=84=20?= =?UTF-8?q?data=20=EA=B3=84=EC=B8=B5=EC=9C=BC=EB=A1=9C=20=EB=82=B4?= =?UTF-8?q?=EB=A6=B0=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit presentation 이 core:network 를 직접 무는 것은 이 레포에서 처음 열리는 경계였다. ApiException.code 를 화면 코드가 까 보면 «서버 에러 코드» 라는 전송 계층 개념이 presentation 까지 올라온다. mapAccountFailure 선례대로 data 가 도메인 예외(DeliveryNotReadyException)로 바꾸고 화면은 타입만 본다. presentation 의 core:network 의존도 함께 사라진다. 테스트는 두 계층으로 나눠, 코드→예외 계약은 data 에서, 예외→문구 계약은 presentation 에서 잡는다. 수신자 기록 화면의 뒤로가기도 onMemorySpaceBack 차용을 그만두고 전용 액션을 둔다. 리뷰 지적. Co-Authored-By: Claude Opus 5 (1M context) --- .../navigation/AppNavigationActions.kt | 4 ++ .../MindRecordReceiverFailureMapper.kt | 28 +++++++++ .../MindRecordReceiverRepositoryImpl.kt | 2 +- .../MindRecordReceiverFailureMapperTest.kt | 58 +++++++++++++++++++ .../domain/error/DeliveryNotReadyException.kt | 11 ++++ .../mindrecord/presentation/build.gradle.kts | 1 - .../navigation/MindRecordNavActions.kt | 9 +++ .../navigation/MindRecordNavGraph.kt | 2 +- .../viewmodel/ReceiverMindRecordViewModel.kt | 17 +++--- .../screen/receiver/ReceiverDeadEndTest.kt | 27 +++++---- 10 files changed, 132 insertions(+), 27 deletions(-) create mode 100644 feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/repositoryimpl/MindRecordReceiverFailureMapper.kt create mode 100644 feature/mindrecord/data/src/test/kotlin/com/afternote/feature/mindrecord/data/repositoryimpl/MindRecordReceiverFailureMapperTest.kt create mode 100644 feature/mindrecord/domain/src/main/kotlin/com/afternote/feature/mindrecord/domain/error/DeliveryNotReadyException.kt diff --git a/app/src/main/java/com/afternote/afternote_fe/navigation/AppNavigationActions.kt b/app/src/main/java/com/afternote/afternote_fe/navigation/AppNavigationActions.kt index 40cb28a7a5..87de0b211a 100644 --- a/app/src/main/java/com/afternote/afternote_fe/navigation/AppNavigationActions.kt +++ b/app/src/main/java/com/afternote/afternote_fe/navigation/AppNavigationActions.kt @@ -100,6 +100,10 @@ fun rememberMindRecordNavActions(navController: NavController): MindRecordNavAct navController.popBackStack() } + override fun onReceiverMindRecordBack() { + navController.popBackStack() + } + override fun onWriteDailyQuestion() { navController.navigate(MindRecordRoute.DailyQuestionWriteRoute) } diff --git a/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/repositoryimpl/MindRecordReceiverFailureMapper.kt b/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/repositoryimpl/MindRecordReceiverFailureMapper.kt new file mode 100644 index 0000000000..e9b356b558 --- /dev/null +++ b/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/repositoryimpl/MindRecordReceiverFailureMapper.kt @@ -0,0 +1,28 @@ +package com.afternote.feature.mindrecord.data.repositoryimpl + +import com.afternote.core.network.model.ApiException +import com.afternote.feature.mindrecord.domain.error.DeliveryNotReadyException + +/** 전달 조건 미충족 — `403 {"code":2009}` (실서버 실측, 2026-08-23). */ +private const val CODE_DELIVERY_CONDITION_NOT_MET = 2009 + +/** + * 수신자 기록 조회 실패를 도메인 예외로 옮긴다 — presentation 이 `core:network` 를 모른 채 + * 타입만으로 분기하게 하는 것이 목적이다 (`mapAccountFailure` 와 같은 자리·같은 이유). + * + * 가르는 신호는 서버 봉투의 `code` 뿐이고 `message` 는 옮기지 않는다. 그 필드가 사용자 + * 노출용이라는 규정이 명세에 없다. 표시 문구는 화면이 자기 리소스로 갖는다. + */ +internal fun Result.mapReceiverFailure(): Result = + when (val exception = exceptionOrNull()) { + is ApiException -> { + when (exception.code) { + CODE_DELIVERY_CONDITION_NOT_MET -> Result.failure(DeliveryNotReadyException(exception)) + else -> this + } + } + + else -> { + this + } + } diff --git a/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/repositoryimpl/MindRecordReceiverRepositoryImpl.kt b/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/repositoryimpl/MindRecordReceiverRepositoryImpl.kt index 21ca3c9bed..8362a9fab0 100644 --- a/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/repositoryimpl/MindRecordReceiverRepositoryImpl.kt +++ b/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/repositoryimpl/MindRecordReceiverRepositoryImpl.kt @@ -25,5 +25,5 @@ class MindRecordReceiverRepositoryImpl diaries = diariesDeferred.await().diaries.map { it.toDomain() }, ) } - } + }.mapReceiverFailure() } diff --git a/feature/mindrecord/data/src/test/kotlin/com/afternote/feature/mindrecord/data/repositoryimpl/MindRecordReceiverFailureMapperTest.kt b/feature/mindrecord/data/src/test/kotlin/com/afternote/feature/mindrecord/data/repositoryimpl/MindRecordReceiverFailureMapperTest.kt new file mode 100644 index 0000000000..9ddd632418 --- /dev/null +++ b/feature/mindrecord/data/src/test/kotlin/com/afternote/feature/mindrecord/data/repositoryimpl/MindRecordReceiverFailureMapperTest.kt @@ -0,0 +1,58 @@ +package com.afternote.feature.mindrecord.data.repositoryimpl + +import com.afternote.core.network.model.ApiException +import com.afternote.feature.mindrecord.domain.error.DeliveryNotReadyException +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.IOException + +/** + * 수신자 조회 실패의 계층 경계 가드 (#614). + * + * "서버 에러 코드" 는 전송 계층 개념이라 화면까지 올라가면 안 된다. 여기서 도메인 예외로 + * 바꿔 두면 presentation 은 타입만 보고 분기하고 `core:network` 를 모른다. + */ +class MindRecordReceiverFailureMapperTest { + private fun apiFailure( + status: Int, + code: Int, + ) = Result.failure( + ApiException(status = status, code = code, serverMessage = "서버 원문", message = "서버 원문"), + ) + + @Test + fun `전달 조건 미충족 코드는 도메인 예외가 된다`() { + val mapped = apiFailure(status = 403, code = 2009).mapReceiverFailure() + + assertTrue(mapped.exceptionOrNull() is DeliveryNotReadyException) + } + + @Test + fun `원래 예외를 cause 로 남긴다`() { + // 진단 정보를 버리지 않는다 — 화면에 쓰지 않을 뿐이다. + val original = apiFailure(status = 403, code = 2009) + val mapped = original.mapReceiverFailure() + + assertSame(original.exceptionOrNull(), mapped.exceptionOrNull()?.cause) + } + + @Test + fun `다른 서버 코드는 그대로 흘려보낸다`() { + val original = apiFailure(status = 500, code = 1004) + + assertSame(original.exceptionOrNull(), original.mapReceiverFailure().exceptionOrNull()) + } + + @Test + fun `서버 응답이 없는 실패는 손대지 않는다`() { + val original = Result.failure(IOException("timeout")) + + assertSame(original.exceptionOrNull(), original.mapReceiverFailure().exceptionOrNull()) + } + + @Test + fun `성공은 그대로 통과한다`() { + assertSame(Unit, Result.success(Unit).mapReceiverFailure().getOrNull()) + } +} diff --git a/feature/mindrecord/domain/src/main/kotlin/com/afternote/feature/mindrecord/domain/error/DeliveryNotReadyException.kt b/feature/mindrecord/domain/src/main/kotlin/com/afternote/feature/mindrecord/domain/error/DeliveryNotReadyException.kt new file mode 100644 index 0000000000..aebe73cd68 --- /dev/null +++ b/feature/mindrecord/domain/src/main/kotlin/com/afternote/feature/mindrecord/domain/error/DeliveryNotReadyException.kt @@ -0,0 +1,11 @@ +package com.afternote.feature.mindrecord.domain.error + +/** + * 전달 조건이 아직 충족되지 않아 수신자가 기록을 열람할 수 없는 상태 (#614). + * + * 수신자가 할 수 있는 일이 없다 — 발신자가 전달 조건을 설정해야 풀린다. 화면은 이 타입만 + * 보고 문구를 고르고, 어떤 서버 코드에서 왔는지는 data 계층의 매핑만 안다. + */ +class DeliveryNotReadyException( + cause: Throwable? = null, +) : Exception(cause) diff --git a/feature/mindrecord/presentation/build.gradle.kts b/feature/mindrecord/presentation/build.gradle.kts index e96506b712..58f43aeb94 100644 --- a/feature/mindrecord/presentation/build.gradle.kts +++ b/feature/mindrecord/presentation/build.gradle.kts @@ -15,7 +15,6 @@ dependencies { implementation(projects.feature.mindrecord.domain) implementation(projects.core.common) implementation(projects.core.domain) - implementation(projects.core.network) implementation(projects.core.ui) implementation(projects.core.model) implementation(libs.coil.compose) diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/navigation/MindRecordNavActions.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/navigation/MindRecordNavActions.kt index eb6213e21b..6b7fefc2aa 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/navigation/MindRecordNavActions.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/navigation/MindRecordNavActions.kt @@ -10,6 +10,15 @@ package com.afternote.feature.mindrecord.presentation.navigation interface MindRecordNavActions { fun onMemorySpaceBack() + /** + * 수신자 기록 화면에서 나간다. + * + * 지금 동작은 추억 공간과 같은 `popBackStack()` 이지만 이름을 빌리지 않는다 — 이 + * 인터페이스는 화면별로 액션을 나눠 두는 곳이고, 빌려 쓰면 «추억 공간만 다르게 + * 처리하자» 는 변경이 이 화면까지 조용히 끌고 간다. + */ + fun onReceiverMindRecordBack() + fun onWriteDailyQuestion() fun onWriteDiary() diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/navigation/MindRecordNavGraph.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/navigation/MindRecordNavGraph.kt index 50ee0019a3..6632640691 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/navigation/MindRecordNavGraph.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/navigation/MindRecordNavGraph.kt @@ -34,7 +34,7 @@ fun NavGraphBuilder.mindRecordNavGraph(actions: MindRecordNavActions) { } composable { // 앱바 뒤로가기를 실제로 붙인다 — 없으면 이 화면이 막다른 곳이 된다 (#614). - ReceiverMindRecordScreen(onBackClick = actions::onMemorySpaceBack) + ReceiverMindRecordScreen(onBackClick = actions::onReceiverMindRecordBack) } composable { DailyQuestionWriteScreen( diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/ReceiverMindRecordViewModel.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/ReceiverMindRecordViewModel.kt index 0cc344952d..e9a213f413 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/ReceiverMindRecordViewModel.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/ReceiverMindRecordViewModel.kt @@ -2,8 +2,8 @@ package com.afternote.feature.mindrecord.presentation.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.afternote.core.network.model.ApiException import com.afternote.core.ui.UiText +import com.afternote.feature.mindrecord.domain.error.DeliveryNotReadyException import com.afternote.feature.mindrecord.domain.model.MindRecordSummary import com.afternote.feature.mindrecord.domain.model.ReceiverMindRecords import com.afternote.feature.mindrecord.domain.repository.MindRecordReceiverRepository @@ -106,17 +106,14 @@ class ReceiverMindRecordViewModel } /** - * 서버 실패를 **도메인 문구**로 바꾼다 (#614). + * 실패를 화면 문구로 바꾼다 (#614). * - * 전달 조건 미충족(403 / code 2009)은 수신자가 할 수 있는 일이 없는 상태다 — 발신자가 - * 전달 조건을 설정해야 풀린다. 그래서 원문("아직 전달 조건이 충족되지 않았습니다") 대신 - * 무슨 상황이고 무엇을 기다리는지 알려 준다. + * 서버 에러 코드는 보지 않는다 — 그 판정은 data 계층(`mapReceiverFailure`)이 하고 여기는 + * 도메인 예외 타입만 본다. 전달 조건 미충족은 수신자가 할 수 있는 일이 없는 상태라 + * (발신자가 조건을 설정해야 풀린다) 원문 대신 무엇을 기다리는지 알려 준다. */ internal fun Throwable.toDomainMessage(): UiText = - when ((this as? ApiException)?.code) { - DELIVERY_CONDITION_NOT_MET -> UiText.Resource(R.string.mindrecord_receiver_delivery_not_ready) + when (this) { + is DeliveryNotReadyException -> UiText.Resource(R.string.mindrecord_receiver_delivery_not_ready) else -> UiText.Resource(R.string.mindrecord_receiver_load_failed) } - -/** 전달 조건 미충족 — `403 {"code":2009}`. */ -private const val DELIVERY_CONDITION_NOT_MET = 2009 diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/screen/receiver/ReceiverDeadEndTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/screen/receiver/ReceiverDeadEndTest.kt index 312a3d40ed..c588513835 100644 --- a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/screen/receiver/ReceiverDeadEndTest.kt +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/screen/receiver/ReceiverDeadEndTest.kt @@ -1,7 +1,7 @@ package com.afternote.feature.mindrecord.presentation.screen.receiver -import com.afternote.core.network.model.ApiException import com.afternote.core.ui.UiText +import com.afternote.feature.mindrecord.domain.error.DeliveryNotReadyException import com.afternote.feature.mindrecord.presentation.R import com.afternote.feature.mindrecord.presentation.viewmodel.toDomainMessage import org.junit.Assert.assertEquals @@ -15,27 +15,26 @@ import java.io.IOException * 않았습니다» 라는 원문이 그대로 노출됐다. 사용자는 "전달 조건" 이 무엇인지, 자기가 * 무엇을 해야 하는지 알 수 없었다 — 실제로는 발신자가 설정해야 풀리는 상태라 * 수신자가 할 수 있는 일이 없다. + * + * 화면은 **도메인 예외 타입만** 본다. 어떤 서버 코드가 그 타입이 되는지는 data 계층의 + * `mapReceiverFailure` 가 알고, 그쪽 계약은 `MindRecordReceiverFailureMapperTest` 가 잡는다. */ class ReceiverDeadEndTest { @Test fun `전달 조건 미충족은 기다리는 상태임을 알리는 문구가 된다`() { - val error = ApiException(status = 403, code = 2009, serverMessage = "아직 전달 조건이 충족되지 않았습니다.", message = "아직 전달 조건이 충족되지 않았습니다.") - - assertEquals(UiText.Resource(R.string.mindrecord_receiver_delivery_not_ready), error.toDomainMessage()) + assertEquals( + UiText.Resource(R.string.mindrecord_receiver_delivery_not_ready), + DeliveryNotReadyException().toDomainMessage(), + ) } @Test fun `서버 원문을 화면 문구로 쓰지 않는다`() { - // 어떤 코드가 오든 결과는 리소스 문구다 — 원문이 새어 나갈 자리가 없다. - val message = - ApiException( - status = 500, - code = 1004, - serverMessage = "서버 내부 오류가 발생했습니다.", - message = "서버 내부 오류가 발생했습니다.", - ).toDomainMessage() - - assertEquals(UiText.Resource(R.string.mindrecord_receiver_load_failed), message) + // 결과 타입이 UiText.Resource 라 원문이 들어갈 자리 자체가 없다. + assertEquals( + UiText.Resource(R.string.mindrecord_receiver_load_failed), + IllegalStateException("서버 내부 오류가 발생했습니다.").toDomainMessage(), + ) } @Test From 892844e15c3034ed96343464ecd3424c94a6eba7 Mon Sep 17 00:00:00 2001 From: Sadturtleman Date: Mon, 24 Aug 2026 16:38:57 +0900 Subject: [PATCH 4/4] =?UTF-8?q?fix(mindrecord):=20=EC=88=98=EC=8B=A0?= =?UTF-8?q?=EC=9E=90=20=EA=B8=B0=EB=A1=9D=20=EC=B9=B4=EB=93=9C=EC=9D=98=20?= =?UTF-8?q?=EB=AA=A9=EC=A0=81=EC=A7=80=EB=A5=BC=20=EC=8B=9C=ED=8A=B8=20?= =?UTF-8?q?=ED=95=98=EB=82=98=EB=A1=9C=20=EB=AA=A8=EC=9D=80=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 화면이 받아 두고 아무도 넘기지 않던 onRecordClick 파라미터를 지운다. 남겨 두면 나중에 실제 네비게이션이 붙는 순간 시트가 열리면서 화면까지 전환된다 — #618 이 문제로 든 그 콜백이다. «필터로 빠진 항목은 열리지 않는다» 테스트는 목록에 없던 id 를 넣어 위의 «못 찾으면 null» 을 한 번 더 밟고 있었다. 방금까지 열려 있던 id 를 그대로 두고 그 항목이 빠진 목록을 넣는 형태로 바꾼다. develop 에서 넘어온 컴파일 깨짐(#1013)도 이 브랜치에서 해소한다. 리뷰 지적. Co-Authored-By: Claude Opus 5 (1M context) --- .../screen/receiver/ReceiverMindRecordScreen.kt | 10 +++++----- .../screen/receiver/ReceiverRecordDetailTest.kt | 9 ++++++++- .../presentation/viewmodel/DraftListDeleteTest.kt | 6 ++++++ 3 files changed, 19 insertions(+), 6 deletions(-) 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 a6136585e2..9299dcaecf 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 @@ -56,7 +56,6 @@ fun ReceiverMindRecordScreen( modifier: Modifier = Modifier, viewModel: ReceiverMindRecordViewModel = hiltViewModel(), onBackClick: () -> Unit = {}, - onRecordClick: (Long) -> Unit = {}, ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() var filterSheetVisible by remember { mutableStateOf(false) } @@ -81,10 +80,11 @@ fun ReceiverMindRecordScreen( SuccessContent( state = state, onFilterClick = { filterSheetVisible = true }, - onRecordClick = { id -> - openedRecordId = id - onRecordClick(id) - }, + // 카드 탭의 목적지는 이 화면 안의 시트다. 종전에는 화면이 받아 두고 + // 아무도 넘기지 않는 `onRecordClick` 파라미터가 있었는데(#618 이 지목한 + // 그 콜백), 남겨 두면 나중에 실제 네비게이션이 붙는 순간 시트가 열리며 + // 화면까지 전환된다. 소유자를 하나로 둔다. + onRecordClick = { id -> openedRecordId = id }, ) } } diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/screen/receiver/ReceiverRecordDetailTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/screen/receiver/ReceiverRecordDetailTest.kt index de2a31f086..40cef0447e 100644 --- a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/screen/receiver/ReceiverRecordDetailTest.kt +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/screen/receiver/ReceiverRecordDetailTest.kt @@ -81,7 +81,14 @@ class ReceiverRecordDetailTest { @Test fun `필터로 목록에서 빠진 항목은 열리지 않는다`() { // 시트가 열린 채 필터가 바뀌면 자연히 닫힌다 — 없는 기록을 계속 붙들지 않는다. - assertNull(findOpenedRecord(success(), 999L)) + // 목록에 없던 id 를 넣으면 위의 «못 찾으면 null» 을 한 번 더 밟을 뿐이므로, + // **방금까지 열려 있던** 11L 을 그대로 두고 그 항목이 빠진 목록을 넣는다. + val openedId = 11L + assertEquals(diary, findOpenedRecord(success(), openedId)) + + val filtered = success().copy(diaries = emptyList()) + + assertNull(findOpenedRecord(filtered, openedId)) } @Test diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DraftListDeleteTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DraftListDeleteTest.kt index cfcf947974..cb93f3c8c5 100644 --- a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DraftListDeleteTest.kt +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DraftListDeleteTest.kt @@ -100,6 +100,12 @@ class DraftListDeleteTest { private fun viewModel(dailyQuestionRepository: DailyQuestionRepository): DraftListViewModel = DraftListViewModel( + // #769 가 목록 조회를 loader 로 뽑아낸 뒤 생성자가 3개로 늘었다. + loader = + MindRecordDraftLoader( + diaryRepository = EmptyDiaryDraftRepository, + dailyQuestionRepository = dailyQuestionRepository, + ), diaryRepository = EmptyDiaryDraftRepository, dailyQuestionRepository = dailyQuestionRepository, )