From 34b0f9c9b25d40941f99458112405d08b7b0aefa Mon Sep 17 00:00:00 2001 From: Sadturtleman Date: Sun, 23 Aug 2026 23:13:09 +0900 Subject: [PATCH 1/2] =?UTF-8?q?fix(mindrecord):=20=EB=8D=B0=EC=9D=BC?= =?UTF-8?q?=EB=A6=AC=EC=A7=88=EB=AC=B8=C2=B7=EC=9D=BC=EA=B8=B0=20'?= =?UTF-8?q?=EC=88=98=EC=A0=95=ED=95=98=EA=B8=B0'=20=EB=A5=BC=20=ED=8E=B8?= =?UTF-8?q?=EC=A7=91=20=ED=99=94=EB=A9=B4=EC=97=90=20=EC=97=B0=EA=B2=B0?= =?UTF-8?q?=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 목록 항목의 "수정하기" 가 둘 다 아무 동작도 하지 않았다. 일기는 카드가 onEdit 을 아예 전달하지 않아 빈 람다가 호출됐고, 정식 기록을 여는 라우트 자체가 없었다. 기존 임시저장 이어쓰기 경로를 정식 기록까지 넓힌다. 두 경우는 같은 목록 API 의 draftOnly 만 다르므로 라우트 인자를 recordId/yearMonth/isDraft 로 일반화하고, 데일리질문에는 answerId 를 더한다. 저장은 두 경우 모두 PATCH 로 나간다. 데일리질문 수정 모드는 오늘 질문을 부르지 않는다 — 대상이 이미 특정된 레코드라 questionId 가 필요 없고, 명세의 PATCH 요청에도 없다. 그래서 canSubmit 도 questionId 대신 대상 레코드 유무로 판단한다. 리치 에디터는 본문을 초기 시드로만 읽으므로, 비동기 프리필이 끝나면 key() 로 에디터를 다시 만들어 내용을 다시 시드한다. 일기 화면은 이미 같은 방식을 쓰고 있었다. 데일리질문 삭제에 확인 절차를 둔다. 되돌릴 수 없는 동작인데 메뉴를 누르는 즉시 실행됐다. Closes #582 --- .../navigation/AppNavigationActions.kt | 22 ++- .../mindrecord/presentation/build.gradle.kts | 7 + .../component/DeleteConfirmDialog.kt | 59 +++++++ .../navigation/MindRecordNavActions.kt | 9 + .../navigation/MindRecordNavGraph.kt | 4 + .../navigation/MindRecordRoute.kt | 22 ++- .../sender/DailyQuestionAnswerListScreen.kt | 24 ++- .../screen/sender/DailyQuestionWriteScreen.kt | 18 +- .../presentation/screen/sender/DiaryScreen.kt | 5 + .../presentation/screen/sender/HomeScreen.kt | 22 ++- .../viewmodel/DailyQuestionWriteUiState.kt | 14 +- .../viewmodel/DailyQuestionWriteViewModel.kt | 54 +++++- .../viewmodel/DiaryWriteViewModel.kt | 33 ++-- .../src/main/res/values/strings.xml | 5 + .../viewmodel/DailyQuestionEditTest.kt | 156 ++++++++++++++++++ 15 files changed, 420 insertions(+), 34 deletions(-) create mode 100644 feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/DeleteConfirmDialog.kt create mode 100644 feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionEditTest.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..dff7e238c9 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 @@ -101,7 +101,7 @@ fun rememberMindRecordNavActions(navController: NavController): MindRecordNavAct } override fun onWriteDailyQuestion() { - navController.navigate(MindRecordRoute.DailyQuestionWriteRoute) + navController.navigate(MindRecordRoute.DailyQuestionWriteRoute()) } override fun onWriteDiary() { @@ -124,12 +124,30 @@ fun rememberMindRecordNavActions(navController: NavController): MindRecordNavAct navController.popBackStack() } + override fun onEditDailyQuestion(answerId: Long) { + navController.navigate(MindRecordRoute.DailyQuestionWriteRoute(answerId = answerId)) + } + + override fun onEditDiary( + diaryId: Long, + yearMonth: String, + ) { + navController.navigate( + MindRecordRoute.DiaryWriteRoute( + recordId = diaryId, + yearMonth = yearMonth, + // 정식 기록이라 draft 목록이 아닌 전체 목록에서 찾는다 (#582). + isDraft = false, + ), + ) + } + override fun onEditDiaryDraft( draftId: Long, draftYearMonth: String, ) { navController.navigate( - MindRecordRoute.DiaryWriteRoute(draftId = draftId, draftYearMonth = draftYearMonth), + MindRecordRoute.DiaryWriteRoute(recordId = draftId, yearMonth = draftYearMonth), ) } } diff --git a/feature/mindrecord/presentation/build.gradle.kts b/feature/mindrecord/presentation/build.gradle.kts index 93b81b6fd8..bd66a39e67 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) + + testImplementation(libs.coroutines.test) + + // 라우트 인자 해석이 Bundle 을 타므로 실제 Android 구현이 필요하다 (#582). + testImplementation(libs.robolectric) } diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/DeleteConfirmDialog.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/DeleteConfirmDialog.kt new file mode 100644 index 0000000000..869991d46d --- /dev/null +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/DeleteConfirmDialog.kt @@ -0,0 +1,59 @@ +package com.afternote.feature.mindrecord.presentation.component + +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import com.afternote.core.ui.theme.AfternoteDesign +import com.afternote.feature.mindrecord.presentation.R + +/** + * 기록 삭제 확인 (#582). + * + * 삭제는 되돌릴 수 없는데 종전에는 메뉴를 누르는 **즉시** 실행됐다. 실수로 눌러도 + * 복구할 방법이 없으므로 한 단계를 둔다. + */ +@Composable +fun DeleteConfirmDialog( + onConfirm: () -> Unit, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + containerColor = Color.White, + title = { + Text( + text = stringResource(R.string.mindrecord_delete_confirm_title), + style = AfternoteDesign.typography.bodyLargeB, + color = AfternoteDesign.colors.gray9, + ) + }, + text = { + Text( + text = stringResource(R.string.mindrecord_delete_confirm_message), + style = AfternoteDesign.typography.bodySmallR, + color = AfternoteDesign.colors.gray6, + ) + }, + confirmButton = { + TextButton(onClick = onConfirm) { + Text( + text = stringResource(R.string.mindrecord_delete_confirm), + style = AfternoteDesign.typography.bodySmallB, + color = AfternoteDesign.colors.gray9, + ) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text( + text = stringResource(R.string.mindrecord_delete_cancel), + style = AfternoteDesign.typography.bodySmallR, + color = AfternoteDesign.colors.gray6, + ) + } + }, + ) +} 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..0d11647f64 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 @@ -22,6 +22,15 @@ interface MindRecordNavActions { fun onDraftListBack() + /** 목록의 "수정하기" — 정식 데일리질문 답변을 프리필한 작성 화면으로 연다 (#582). */ + fun onEditDailyQuestion(answerId: Long) + + /** 목록의 "수정하기" — 정식 일기를 프리필한 작성 화면으로 연다 (#582). */ + fun onEditDiary( + diaryId: Long, + yearMonth: String, + ) + /** 임시저장 목록에서 일기 draft 를 탭 — 일기 작성 화면을 이어쓰기 모드로 연다. */ fun onEditDiaryDraft( draftId: Long, 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..33121cfcc1 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 @@ -10,6 +10,7 @@ import com.afternote.feature.mindrecord.presentation.screen.sender.DailyQuestion import com.afternote.feature.mindrecord.presentation.screen.sender.DiaryWriteScreen import com.afternote.feature.mindrecord.presentation.screen.sender.DraftListScreen import com.afternote.feature.mindrecord.presentation.screen.sender.HomeScreen +import java.time.YearMonth /** * 마인드레코드 피처의 루트 [NavHost] 등록 묶음. @@ -27,6 +28,9 @@ fun NavGraphBuilder.mindRecordNavGraph(actions: MindRecordNavActions) { MindRecordCategoryUi.WeeklyReport -> Unit } }, + onEditDailyQuestion = actions::onEditDailyQuestion, + // 목록은 보고 있는 달의 항목만 담으므로, 그 달을 함께 넘겨 프리필 조회 범위를 좁힌다. + onEditDiary = { diaryId -> actions.onEditDiary(diaryId, YearMonth.now().toString()) }, ) } composable { diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/navigation/MindRecordRoute.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/navigation/MindRecordRoute.kt index e4766f0152..e84c881742 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/navigation/MindRecordRoute.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/navigation/MindRecordRoute.kt @@ -3,17 +3,29 @@ package com.afternote.feature.mindrecord.presentation.navigation import kotlinx.serialization.Serializable sealed interface MindRecordRoute { + /** + * 데일리질문 작성 화면. + * + * [answerId] 가 있으면 **정식 답변 수정** 모드 — 그 답변을 프리필하고 저장 시 PATCH 한다. + * 없으면 오늘 질문에 새로 답한다(임시저장이 있으면 이어쓰기). + */ @Serializable - data object DailyQuestionWriteRoute : MindRecordRoute + data class DailyQuestionWriteRoute( + val answerId: Long? = null, + ) : MindRecordRoute /** - * 일기 작성 화면. [draftId] 가 있으면 임시저장 이어쓰기 모드 — 해당 달([draftYearMonth], - * `yyyy-MM`)의 draft 목록에서 항목을 찾아 프리필하고, 저장 시 PATCH 로 수정한다. + * 일기 작성 화면. + * + * [recordId] 가 있으면 그 일기를 프리필하고 저장 시 PATCH 한다. [isDraft] 가 true 면 + * 임시저장 이어쓰기(등록 시 `isDraft=false` 로 전환), false 면 **정식 기록 수정**이다. + * [yearMonth](`yyyy-MM`) 는 해당 항목을 찾을 달이다. */ @Serializable data class DiaryWriteRoute( - val draftId: Long? = null, - val draftYearMonth: String? = null, + val recordId: Long? = null, + val yearMonth: String? = null, + val isDraft: Boolean = true, ) : MindRecordRoute /** 작성 화면 키보드 툴바의 "임시저장 N" 영역에서 진입하는 임시저장 목록 화면. */ diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/DailyQuestionAnswerListScreen.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/DailyQuestionAnswerListScreen.kt index 45d6ae6f91..646bf0d4a3 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/DailyQuestionAnswerListScreen.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/DailyQuestionAnswerListScreen.kt @@ -26,6 +26,7 @@ import com.afternote.core.ui.theme.AfternoteDesign import com.afternote.core.ui.theme.AfternoteTheme import com.afternote.feature.mindrecord.presentation.component.DailyCalendar import com.afternote.feature.mindrecord.presentation.component.DailyQuestionListCard +import com.afternote.feature.mindrecord.presentation.component.DeleteConfirmDialog import com.afternote.feature.mindrecord.presentation.component.MindRecordEmptyState import com.afternote.feature.mindrecord.presentation.model.DailyQuestion import com.afternote.feature.mindrecord.presentation.model.MindRecordCategoryUi @@ -37,9 +38,21 @@ import java.time.YearMonth fun DailyQuestionAnswerListScreen( modifier: Modifier = Modifier, isListView: Boolean = true, + onEditClick: (Long) -> Unit = {}, viewModel: DailyQuestionListViewModel = hiltViewModel(), ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() + var pendingDeleteId by remember { mutableStateOf(null) } + + pendingDeleteId?.let { id -> + DeleteConfirmDialog( + onConfirm = { + pendingDeleteId = null + viewModel.delete(id) + }, + onDismiss = { pendingDeleteId = null }, + ) + } when (val state = uiState) { DailyQuestionListUiState.Loading -> { @@ -55,7 +68,9 @@ fun DailyQuestionAnswerListScreen( modifier = modifier, isListView = isListView, answers = state.answers, - onDelete = viewModel::delete, + onEdit = onEditClick, + // 삭제는 되돌릴 수 없다 — 종전에는 메뉴를 누르는 즉시 실행됐다 (#582). + onDelete = { pendingDeleteId = it }, ) } } @@ -66,6 +81,7 @@ private fun DailyQuestionListContent( isListView: Boolean, answers: List, modifier: Modifier = Modifier, + onEdit: (Long) -> Unit = {}, onDelete: (Long) -> Unit = {}, ) { var yearMonth by remember { mutableStateOf(YearMonth.now()) } @@ -101,7 +117,11 @@ private fun DailyQuestionListContent( } // Figma 2757:16116 — 리스트 형은 답변 카드만 나열 items(answers, key = { it.id }) { answer -> - DailyQuestionListCard(answer = answer, onDelete = { onDelete(answer.id) }) + DailyQuestionListCard( + answer = answer, + onEdit = { onEdit(answer.id) }, + onDelete = { onDelete(answer.id) }, + ) } } } diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/DailyQuestionWriteScreen.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/DailyQuestionWriteScreen.kt index 8044dc21e5..8bc47e07d2 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/DailyQuestionWriteScreen.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/DailyQuestionWriteScreen.kt @@ -12,6 +12,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState @@ -117,13 +118,16 @@ fun DailyQuestionWriteScreen( } Spacer(modifier = Modifier.height(16.dp)) - WriteTextField( - value = uiState.answer, - onValueChange = viewModel::onAnswerChanged, - onSaveDraftClick = { viewModel.submit(isDraft = true) }, - onDraftCountClick = onDraftListClick, - onImagePicked = viewModel::uploadImage, - ) + // 프리필이 비동기로 끝나므로 에디터를 다시 만들어 본문을 다시 시드한다 (#582). + key(uiState.contentLoaded) { + WriteTextField( + value = uiState.answer, + onValueChange = viewModel::onAnswerChanged, + onSaveDraftClick = { viewModel.submit(isDraft = true) }, + onDraftCountClick = onDraftListClick, + onImagePicked = viewModel::uploadImage, + ) + } } } } diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/DiaryScreen.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/DiaryScreen.kt index b25ba97100..15a30b09d2 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/DiaryScreen.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/DiaryScreen.kt @@ -48,6 +48,7 @@ private val PreviewYearMonth = YearMonth.of(2026, 7) fun DiaryScreen( modifier: Modifier = Modifier, isListView: Boolean = true, + onEditClick: (Long) -> Unit = {}, viewModel: DiaryListViewModel = hiltViewModel(), ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() @@ -69,6 +70,7 @@ fun DiaryScreen( yearMonth = state.yearMonth, monthDiaryCount = state.monthDiaryCount, weeklyMoodEmoji = state.weeklyDominantMood?.toEmoji(), + onEdit = onEditClick, onDelete = viewModel::delete, onYearMonthChanged = viewModel::selectYearMonth, ) @@ -87,6 +89,7 @@ private fun DiaryListContent( modifier: Modifier = Modifier, monthDiaryCount: Int = 0, weeklyMoodEmoji: String? = null, + onEdit: (Long) -> Unit = {}, onDelete: (Long) -> Unit = {}, onYearMonthChanged: (YearMonth) -> Unit = {}, ) { @@ -139,6 +142,7 @@ private fun DiaryListContent( items(diaries, key = { it.id }) { diary -> DiaryComponent( + onEdit = { onEdit(diary.id) }, diary = diary, modifier = Modifier.padding(vertical = 8.dp), onDelete = { onDelete(diary.id) }, @@ -162,6 +166,7 @@ private fun DiaryListContent( } gridItems(diaries, key = { it.id }) { diary -> DiaryCard( + onEdit = { onEdit(diary.id) }, diary = diary, onDelete = { onDelete(diary.id) }, ) diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/HomeScreen.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/HomeScreen.kt index ce76864003..92afbdc169 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/HomeScreen.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/HomeScreen.kt @@ -51,6 +51,8 @@ fun HomeScreen( // 프리페치가 되므로 트레이드오프를 받아들인다. weeklyReportViewModel: WeeklyReportViewModel = hiltViewModel(), onWriteClick: (MindRecordCategoryUi) -> Unit = {}, + onEditDailyQuestion: (Long) -> Unit = {}, + onEditDiary: (Long) -> Unit = {}, ) { // Figma 2757:16116 — 마음의 기록 탭은 데일리 질문 / 일기 / 주간리포트 3개 val categories = @@ -172,9 +174,23 @@ fun HomeScreen( HorizontalPager(state = pagerState) { _ -> when (selectedCategory) { - MindRecordCategoryUi.DailyQuestion -> DailyQuestionAnswerListScreen(isListView = isListView) - MindRecordCategoryUi.Diary -> DiaryScreen(isListView = isListView) - else -> WeeklyReportScreen() + MindRecordCategoryUi.DailyQuestion -> { + DailyQuestionAnswerListScreen( + isListView = isListView, + onEditClick = onEditDailyQuestion, + ) + } + + MindRecordCategoryUi.Diary -> { + DiaryScreen( + isListView = isListView, + onEditClick = onEditDiary, + ) + } + + else -> { + WeeklyReportScreen() + } } } } diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionWriteUiState.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionWriteUiState.kt index c8cc6dbbcc..0137c06ba3 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionWriteUiState.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionWriteUiState.kt @@ -7,16 +7,26 @@ data class DailyQuestionWriteUiState( /** "Day N" 배너 표기용 — 오늘의 질문이 서비스 기준 몇 일차인지. */ val questionDay: Int? = null, val questionContent: String = "", - /** 오늘 이미 임시저장된 답변 레코드 ID — null 이 아니면 제출 시 POST 대신 PATCH 로 전환한다. */ + /** + * 수정 대상 답변 레코드 ID — null 이 아니면 제출 시 POST 대신 PATCH 로 전환한다. + * + * 오늘의 임시저장 이어쓰기와 목록의 "수정하기" 가 같은 값을 쓴다 (#582). + */ val draftId: Long? = null, val answer: String = "", + /** + * 본문이 서버에서 채워졌는지. 리치 에디터는 [answer] 를 **초기 시드로만** 읽으므로, + * 비동기 프리필이 끝난 뒤 에디터를 다시 만들어야 내용이 보인다 (#582). + */ + val contentLoaded: Boolean = false, val imageUrl: String? = null, val isQuestionLoading: Boolean = true, val questionLoadError: UiText? = null, val submitState: SubmitState = SubmitState.Idle, ) { val canSubmit: Boolean - get() = questionId != null && answer.isNotBlank() && submitState != SubmitState.InProgress + // 수정 모드는 오늘 질문을 부르지 않아 questionId 가 없다 — 대상 레코드가 있으면 저장할 수 있다. + get() = (questionId != null || draftId != null) && answer.isNotBlank() && submitState != SubmitState.InProgress } sealed interface SubmitState { 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 294f793e43..0da4778d82 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 @@ -1,13 +1,16 @@ package com.afternote.feature.mindrecord.presentation.viewmodel +import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import androidx.navigation.toRoute import com.afternote.core.domain.repository.PhotoUploadRepository import com.afternote.core.ui.UiText import com.afternote.feature.mindrecord.domain.model.DailyQuestionCreatePayload import com.afternote.feature.mindrecord.domain.model.DailyQuestionUpdatePayload import com.afternote.feature.mindrecord.domain.repository.DailyQuestionRepository import com.afternote.feature.mindrecord.presentation.R +import com.afternote.feature.mindrecord.presentation.navigation.MindRecordRoute import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -21,14 +24,56 @@ import javax.inject.Inject class DailyQuestionWriteViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, private val repository: DailyQuestionRepository, private val photoUploadRepository: PhotoUploadRepository, ) : ViewModel() { private val _uiState = MutableStateFlow(DailyQuestionWriteUiState()) val uiState: StateFlow = _uiState.asStateFlow() + /** 수정 대상 답변 ID. 목록의 "수정하기" 로 들어오면 채워진다 (#582). */ + private val editingAnswerId: Long? = + savedStateHandle.toRoute().answerId + init { - loadTodayQuestion() + if (editingAnswerId != null) loadAnswer(editingAnswerId) else loadTodayQuestion() + } + + /** + * 정식 답변을 프리필한다 (#582). + * + * 오늘 질문을 다시 묻지 않는다 — 수정 대상은 이미 특정된 레코드이고, 저장은 + * `PATCH /daily-questions/{id}` 로 나간다. `questionId` 도 그래서 필요 없다. + */ + private fun loadAnswer(answerId: Long) { + viewModelScope.launch { + _uiState.update { it.copy(isQuestionLoading = true, questionLoadError = null) } + repository + .getList() + .mapCatching { list -> list.first { it.dailyQuestionId == answerId } } + .onSuccess { answer -> + _uiState.update { + it.copy( + draftId = answer.dailyQuestionId, + questionContent = answer.title, + answer = answer.content, + isQuestionLoading = false, + contentLoaded = true, + ) + } + }.onFailure { e -> + _uiState.update { + it.copy( + isQuestionLoading = false, + questionLoadError = + UiText.DynamicOrResource( + value = e.message, + fallbackResId = R.string.mindrecord_error_daily_question_today_failed, + ), + ) + } + } + } } private fun loadTodayQuestion() { @@ -105,8 +150,11 @@ class DailyQuestionWriteViewModel fun submit(isDraft: Boolean = false) { val state = _uiState.value - val questionId = state.questionId ?: return if (!state.canSubmit) return + // 수정 모드에는 오늘 질문을 부르지 않으므로 questionId 가 없다. PATCH 는 + // 대상 레코드 ID 만 있으면 되고, 명세에도 questionId 가 없다 (#582). + // canSubmit 이 이미 둘 중 하나는 있음을 보장한다. + val questionId = state.questionId viewModelScope.launch { _uiState.update { it.copy(submitState = SubmitState.InProgress) } @@ -128,7 +176,7 @@ class DailyQuestionWriteViewModel DailyQuestionCreatePayload( content = state.answer, isDraft = isDraft, - questionId = questionId, + questionId = requireNotNull(questionId), imageUrl = state.imageUrl, ), ) 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 81c200e5cc..19606eaa76 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 @@ -28,8 +28,9 @@ import javax.inject.Inject * 일기 작성/이어쓰기 ViewModel. * * - 신규 작성: `POST /diary` (+ 선택한 수신자 ID 를 `receiverIds` 로 함께 전송). - * - 이어쓰기: 라우트의 `draftId` 로 해당 달 draft 목록에서 항목을 찾아 프리필하고, - * 저장 시 `PATCH /diary/{diaryId}` 로 수정한다 (등록 시 isDraft=false 로 전환). + * - 수정: 라우트의 `recordId` 로 해당 달 목록에서 항목을 찾아 프리필하고, 저장 시 + * `PATCH /diary/{diaryId}` 로 수정한다. 임시저장 이어쓰기(`isDraft=true`)와 정식 기록 + * 수정(`isDraft=false`)이 같은 경로를 쓰며, 조회하는 목록의 `draftOnly` 만 다르다 (#582). */ @HiltViewModel class DiaryWriteViewModel @@ -41,14 +42,16 @@ class DiaryWriteViewModel private val userRepository: UserRepository, ) : ViewModel() { private val route = savedStateHandle.toRoute() - private val editingDraftId: Long? = route.draftId + + /** 수정 대상 일기 ID. 임시저장 이어쓰기와 정식 기록 수정이 같은 값을 쓴다 (#582). */ + private val editingDiaryId: Long? = route.recordId private val _uiState = MutableStateFlow(DiaryWriteUiState()) val uiState: StateFlow = _uiState.asStateFlow() init { loadReceivers() - editingDraftId?.let { loadDraft(it, route.draftYearMonth) } + editingDiaryId?.let { loadExisting(it, route.yearMonth, route.isDraft) } } fun onTitleChanged(value: String) { @@ -96,9 +99,9 @@ class DiaryWriteViewModel viewModelScope.launch { _uiState.update { it.copy(submitState = SubmitState.InProgress) } val result = - if (editingDraftId != null) { + if (editingDiaryId != null) { repository.update( - id = editingDraftId, + id = editingDiaryId, payload = DiaryUpdatePayload( title = state.title, @@ -155,15 +158,25 @@ class DiaryWriteViewModel } // 단건 조회 엔드포인트가 없어 해당 달의 draft 목록에서 id 로 찾는다. - private fun loadDraft( - draftId: Long, + + /** + * 수정 대상 일기를 프리필한다. + * + * 임시저장과 정식 기록은 **같은 목록 API 의 `draftOnly` 만 다르다.** 종전에는 임시저장 + * 경로만 있어서 목록의 "수정하기" 가 갈 곳이 없었다 (#582). + */ + private fun loadExisting( + diaryId: Long, yearMonth: String?, + isDraft: Boolean, ) { viewModelScope.launch { _uiState.update { it.copy(isDraftLoading = true, draftLoadError = null) } repository - .getList(yearMonth = yearMonth ?: YearMonth.now().toString(), draftOnly = true) - .mapCatching { list -> list.diaries.first { it.diaryId == draftId } } + .getList( + yearMonth = yearMonth ?: YearMonth.now().toString(), + draftOnly = if (isDraft) true else null, + ).mapCatching { list -> list.diaries.first { it.diaryId == diaryId } } .onSuccess { draft -> _uiState.update { it.copy( diff --git a/feature/mindrecord/presentation/src/main/res/values/strings.xml b/feature/mindrecord/presentation/src/main/res/values/strings.xml index ea7f4f8d8e..6ccd272f48 100644 --- a/feature/mindrecord/presentation/src/main/res/values/strings.xml +++ b/feature/mindrecord/presentation/src/main/res/values/strings.xml @@ -22,6 +22,11 @@ 삭제하기 + + 이 기록을 삭제할까요? + 삭제한 기록은 되돌릴 수 없어요. + 삭제 + 취소 수정하기 더보기 메뉴 diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionEditTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionEditTest.kt new file mode 100644 index 0000000000..1ccb9c341b --- /dev/null +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionEditTest.kt @@ -0,0 +1,156 @@ +package com.afternote.feature.mindrecord.presentation.viewmodel + +import androidx.lifecycle.SavedStateHandle +import com.afternote.core.domain.repository.PhotoUploadRepository +import com.afternote.feature.mindrecord.domain.model.DailyQuestion +import com.afternote.feature.mindrecord.domain.model.DailyQuestionCreatePayload +import com.afternote.feature.mindrecord.domain.model.DailyQuestionUpdatePayload +import com.afternote.feature.mindrecord.domain.model.TodayDailyQuestion +import com.afternote.feature.mindrecord.domain.repository.DailyQuestionRepository +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +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.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * 데일리질문 "수정하기" 경로 가드 (#582). + * + * 종전에는 목록의 수정하기가 빈 콜백이라 팝업만 닫히고 아무 일도 일어나지 않았다. + * 편집 경로가 붙은 뒤에도 두 가지가 어긋나기 쉽다. + * + * - 수정 모드는 **오늘 질문을 부르지 않는다** — 대상은 이미 특정된 레코드다. + * - 저장은 새 답변을 만드는 POST 가 아니라 그 레코드를 고치는 PATCH 여야 한다. + */ +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class DailyQuestionEditTest { + @Before + fun setUp() { + Dispatchers.setMain(UnconfinedTestDispatcher()) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `수정 모드는 대상 답변을 프리필하고 오늘 질문을 부르지 않는다`() { + val repository = FakeRepository(answers = listOf(answer(id = 7L, content = "

원본

"))) + + val viewModel = editViewModel(repository, answerId = 7L) + + assertEquals("

원본

", viewModel.uiState.value.answer) + assertEquals(7L, viewModel.uiState.value.draftId) + assertEquals("오늘 질문은 부르지 않는다", 0, repository.todayCalls) + assertTrue("에디터를 다시 시드해야 본문이 보인다", viewModel.uiState.value.contentLoaded) + } + + @Test + fun `수정 모드 저장은 새로 만들지 않고 PATCH 로 나간다`() { + val repository = FakeRepository(answers = listOf(answer(id = 7L, content = "

원본

"))) + val viewModel = editViewModel(repository, answerId = 7L) + + viewModel.onAnswerChanged("

고친 본문

") + viewModel.submit() + + assertEquals(0, repository.createCalls) + assertEquals(listOf(7L), repository.updatedIds) + assertEquals("

고친 본문

", repository.updatedPayloads.single().content) + } + + @Test + fun `수정 모드는 questionId 가 없어도 저장할 수 있다`() { + // 명세의 PATCH 요청에는 questionId 가 없다 — 대상 레코드 ID 만 있으면 된다. + val repository = FakeRepository(answers = listOf(answer(id = 7L, content = "

원본

"))) + val viewModel = editViewModel(repository, answerId = 7L) + + assertNull(viewModel.uiState.value.questionId) + assertTrue("저장 버튼이 살아 있어야 한다", viewModel.uiState.value.canSubmit) + } + + @Test + fun `신규 작성은 종전대로 오늘 질문을 부른다`() { + val repository = FakeRepository(answers = emptyList()) + + val viewModel = editViewModel(repository, answerId = null) + + assertEquals(1, repository.todayCalls) + assertEquals("", viewModel.uiState.value.answer) + } + + // ── 테스트 도구 ─────────────────────────────────────────────────────────── + + private fun editViewModel( + repository: DailyQuestionRepository, + answerId: Long?, + ): DailyQuestionWriteViewModel { + val handle = + SavedStateHandle( + if (answerId == null) emptyMap() else mapOf("answerId" to answerId), + ) + return DailyQuestionWriteViewModel( + handle, + repository, + PhotoUploadRepository { _, _ -> error("업로드는 이 시나리오에서 호출되면 안 됨") }, + ) + } + + private fun answer( + id: Long, + content: String, + ) = DailyQuestion( + dailyQuestionId = id, + title = "오늘의 질문", + content = content, + createdAt = "2026.08.23 일", + ) + + private class FakeRepository( + private val answers: List, + ) : DailyQuestionRepository { + var todayCalls = 0 + var createCalls = 0 + val updatedIds = mutableListOf() + val updatedPayloads = mutableListOf() + + override suspend fun getList( + date: String?, + draftOnly: Boolean?, + ): Result> = Result.success(answers) + + override suspend fun getToday(): Result { + todayCalls++ + return Result.success( + TodayDailyQuestion(questionId = 1L, day = 1, content = "오늘의 질문", isAnswered = false), + ) + } + + override suspend fun create(payload: DailyQuestionCreatePayload): Result { + createCalls++ + return Result.success(Unit) + } + + override suspend fun update( + id: Long, + payload: DailyQuestionUpdatePayload, + ): Result { + updatedIds += id + updatedPayloads += payload + return Result.success(Unit) + } + + override suspend fun delete(id: Long): Result = error("delete 는 이 시나리오에서 호출되면 안 됨") + } +} From 93efc5ed05a5c2389854179df64905bab63ccc36 Mon Sep 17 00:00:00 2001 From: Sadturtleman Date: Tue, 25 Aug 2026 09:03:50 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix(app):=20=ED=99=88=EC=9D=98=20'=EB=8D=B0?= =?UTF-8?q?=EC=9D=BC=EB=A6=AC=EC=A7=88=EB=AC=B8=20=EB=8B=B5=EB=B3=80?= =?UTF-8?q?=ED=95=98=EA=B8=B0'=20=EA=B0=80=20=EB=88=8C=EB=A6=AC=EC=A7=80?= =?UTF-8?q?=20=EC=95=8A=EB=8D=98=20=EA=B2=83=EC=9D=84=20=EA=B3=A0=EC=B9=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 라우트가 data class 로 바뀌면서(#582·#770 의 answerId/isDraft) 홈 배선이 클래스 참조를 그대로 navigate 에 넘기고 있었다. 이동이 조용히 무산돼 버튼이 아무 반응도 하지 않았고, Android Lint 의 WrongNavigateRouteType 이 이 자리를 잡아냈다. 에뮬레이터에서 홈 카드 → 데일리질문 작성 화면 진입을 확인했다. Co-Authored-By: Claude Opus 5 (1M context) --- .../afternote/afternote_fe/navigation/AppNavigationActions.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 ce0b6e5e83..275a09a6e6 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 @@ -373,7 +373,9 @@ fun rememberHomeTabActions( override fun onAnswerClick() { // 카드 문구가 "데일리질문 답변하기" 라 답변 작성 화면으로 보낸다. // 기록 탭의 작성 진입(`MindRecordNavActions.onWriteDailyQuestion`)과 같은 목적지다. - appState.navController.navigate(MindRecordRoute.DailyQuestionWriteRoute) + // 라우트가 data class 라 **인스턴스**를 넘겨야 한다 — 클래스 참조로 넘기면 + // 이동이 조용히 무산되고 홈의 이 버튼이 눌리지 않는다 (#770). + appState.navController.navigate(MindRecordRoute.DailyQuestionWriteRoute()) } override fun onNextStepClick() {