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 275a09a6e6..5a12d114f5 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 @@ -146,6 +146,12 @@ fun rememberMindRecordNavActions(navController: NavController): MindRecordNavAct ) } + override fun onEditDailyQuestionDraft(draftId: Long) { + navController.navigate( + MindRecordRoute.DailyQuestionWriteRoute(answerId = draftId, isDraft = true), + ) + } + override fun onEditDiaryDraft( draftId: Long, draftYearMonth: String, 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 22b0149adc..9b7f5278a7 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 @@ -40,6 +40,9 @@ interface MindRecordNavActions { yearMonth: String, ) + /** 임시저장 목록에서 데일리질문 draft 를 탭 — 작성 화면을 이어쓰기 모드로 연다 (#770). */ + fun onEditDailyQuestionDraft(draftId: Long) + /** 임시저장 목록에서 일기 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 b69ed4eec3..c84ffbc6f0 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 @@ -60,6 +60,7 @@ fun NavGraphBuilder.mindRecordNavGraph(actions: MindRecordNavActions) { DraftListScreen( onBackClick = actions::onDraftListBack, onDiaryDraftClick = actions::onEditDiaryDraft, + onDailyQuestionDraftClick = actions::onEditDailyQuestionDraft, ) } } 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 e84c881742..921c232c4b 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 @@ -6,12 +6,17 @@ sealed interface MindRecordRoute { /** * 데일리질문 작성 화면. * - * [answerId] 가 있으면 **정식 답변 수정** 모드 — 그 답변을 프리필하고 저장 시 PATCH 한다. - * 없으면 오늘 질문에 새로 답한다(임시저장이 있으면 이어쓰기). + * [answerId] 가 있으면 그 레코드를 프리필하고 저장 시 PATCH 한다. [isDraft] 가 true 면 + * **임시저장 이어쓰기**(등록 시 `isDraft=false` 로 전환), false 면 **정식 답변 수정**이다. + * 없으면 오늘 질문에 새로 답한다(당일 임시저장이 있으면 이어쓰기). + * + * 당일이 지난 임시저장은 이 인자로만 열 수 있다 — today 응답 기반 경로는 오늘 것만 + * 찾기 때문이다 (#770). */ @Serializable data class DailyQuestionWriteRoute( val answerId: Long? = null, + val isDraft: Boolean = false, ) : MindRecordRoute /** diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/DraftListScreen.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/DraftListScreen.kt index 18788fa5e7..edbfc7491f 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/DraftListScreen.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/DraftListScreen.kt @@ -72,6 +72,8 @@ fun DraftListScreen( onBackClick: () -> Unit = {}, /** 일기 draft 탭 → 이어쓰기. (draftId, 해당 달 `yyyy-MM`) 전달. */ onDiaryDraftClick: (Long, String) -> Unit = { _, _ -> }, + /** 데일리질문 draft 탭 → 이어쓰기. 당일이 지난 draft 는 이 경로로만 열 수 있다 (#770). */ + onDailyQuestionDraftClick: (Long) -> Unit = {}, viewModel: DraftListViewModel = hiltViewModel(), ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() @@ -187,9 +189,19 @@ fun DraftListScreen( viewModel.delete(state.items.filter { it.key() in selectedKeys }) }, onItemClick = { item -> - // 이어쓰기는 일기만 지원. - if (item.category == DraftCategory.Diary) { - onDiaryDraftClick(item.id, YearMonth.from(item.date).toString()) + when (item.category) { + DraftCategory.Diary -> { + onDiaryDraftClick(item.id, YearMonth.from(item.date).toString()) + } + + DraftCategory.DailyQuestion -> { + onDailyQuestionDraftClick(item.id) + } + + // 목록에 담기는 항목은 두 종류뿐이라 도달하지 않는다. + DraftCategory.All -> { + Unit + } } }, ) 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 33a096b459..867947d716 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 @@ -46,13 +46,14 @@ data class DailyQuestionWriteUiState( val canSubmit: Boolean get() = answer.isNotBlank() && - // 수정 모드는 오늘 질문을 부르지 않아 questionId 가 없다 — 대상 레코드가 - // 있으면 저장할 수 있다 (#582). 그래서 isQuestionLoading 대신 «둘 중 하나» 를 본다. - (questionId != null || draftId != null) && + // 수정·이어쓰기 모드는 오늘 질문을 부르지 않아 questionId 가 없다 — 대상 레코드가 + // 있으면 PATCH 로 나가므로 질문 조회를 기다릴 이유가 없다 (#582·#770). + (draftId != null || !isQuestionLoading) && // 이어쓸 본문이 도착하기 전에 저장하면 draftId 가 아직 null 이라 POST 로 나가고, // 서버가 questionId upsert 라 기존 임시저장 본문이 덮인다 — #923 과 같은 유실이다. !isResumingDraft && submitState != SubmitState.InProgress && + // 업로드 중 저장하면 본문에 아직 안 들어간 이미지가 빠진 채 나간다 (#716). !isUploadingImage } 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 9475326f8c..28fd1833ed 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 @@ -35,36 +35,39 @@ class DailyQuestionWriteViewModel private val _uiState = MutableStateFlow(DailyQuestionWriteUiState()) val uiState: StateFlow = _uiState.asStateFlow() - /** 수정 대상 답변 ID. 목록의 "수정하기" 로 들어오면 채워진다 (#582). */ - private val editingAnswerId: Long? = - savedStateHandle.toRoute().answerId + private val route = savedStateHandle.toRoute() + + /** 프리필 대상 레코드 ID. 목록의 "수정하기"(#582)·임시저장 목록 탭(#770)이 채운다. */ + private val editingAnswerId: Long? = route.answerId init { // 수정 진입이면 대상 레코드를 프리필하고, 신규면 오늘 질문을 부른다 (#582). - if (editingAnswerId != null) loadAnswer(editingAnswerId) else loadTodayQuestion() - loadDraftCount() - } - - /** 툴바 카운트는 화면 장식이라 실패해도 화면을 막지 않고 '모름' 으로 남긴다. */ - private fun loadDraftCount() { - viewModelScope.launch { - draftLoader.count().onSuccess { count -> - _uiState.update { it.copy(draftCount = count) } - } + // 임시저장은 draftOnly=true 로만 내려오므로 어느 목록을 볼지 isDraft 로 가른다 (#770). + if (editingAnswerId != null) { + loadAnswer(editingAnswerId, route.isDraft) + } else { + loadTodayQuestion() } + loadDraftCount() } /** - * 정식 답변을 프리필한다 (#582). + * 대상 레코드를 프리필한다 (#582·#770). * - * 오늘 질문을 다시 묻지 않는다 — 수정 대상은 이미 특정된 레코드이고, 저장은 + * 오늘 질문을 다시 묻지 않는다 — 대상은 이미 특정된 레코드이고, 저장은 * `PATCH /daily-questions/{id}` 로 나간다. `questionId` 도 그래서 필요 없다. + * 임시저장 이어쓰기든 정식 답변 수정이든 조회하는 목록의 `draftOnly` 만 다르다. */ - private fun loadAnswer(answerId: Long) { + private fun loadAnswer( + answerId: Long, + isDraft: Boolean, + ) { viewModelScope.launch { _uiState.update { it.copy(isQuestionLoading = true, questionLoadError = null) } repository - .getList() + // 임시저장은 draftOnly=true 로만 내려온다. 당일이 지난 draft 는 이 경로가 + // 유일한 진입 수단이다 (#770). + .getList(draftOnly = if (isDraft) true else null) .mapCatching { list -> list.first { it.dailyQuestionId == answerId } } .onSuccess { answer -> _uiState.update { @@ -91,6 +94,15 @@ class DailyQuestionWriteViewModel } } + /** 툴바 카운트는 화면 장식이라 실패해도 화면을 막지 않고 '모름' 으로 남긴다. */ + private fun loadDraftCount() { + viewModelScope.launch { + draftLoader.count().onSuccess { count -> + _uiState.update { it.copy(draftCount = count) } + } + } + } + private fun loadTodayQuestion() { viewModelScope.launch { _uiState.update { it.copy(isQuestionLoading = true, questionLoadError = null) } @@ -246,6 +258,7 @@ class DailyQuestionWriteViewModel // ID 만 있으면 되고 명세에도 questionId 가 없다 (#582). 그래서 «둘 다 없을 때» 만 // 막는다 — 신규 작성인데 오늘 질문 조회가 실패한 경우다. val questionId = state.questionId + // 수정·이어쓰기 대상 레코드가 있으면 PATCH 로 나가므로 questionId 가 없어도 된다 (#582·#770). if (questionId == null && state.draftId == null) { // 서버가 답변을 어디에 붙일지 알 수 없어 요청 자체가 불가능하다. 사유를 // 알리고 조회를 다시 걸어 사용자가 재시도할 수 있게 한다 (#565). 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 index 2ad73b35c2..97f4d73120 100644 --- 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 @@ -94,24 +94,72 @@ class DailyQuestionEditTest { assertEquals("", viewModel.uiState.value.answer) } + @Test + fun `임시저장 이어쓰기는 draft 목록에서 대상을 찾는다`() { + // 당일이 지난 draft 는 draftOnly=true 로만 내려온다 — 이 경로가 유일한 진입 수단이다 (#770). + val repository = + FakeRepository( + answers = emptyList(), + drafts = listOf(answer(id = 11L, content = "

지난 임시저장

")), + ) + + val viewModel = editViewModel(repository, answerId = 11L, isDraft = true) + + assertEquals("

지난 임시저장

", viewModel.uiState.value.answer) + assertEquals(11L, viewModel.uiState.value.draftId) + assertEquals("draft 목록을 조회해야 한다", listOf(true), repository.listDraftOnlyArgs) + } + + @Test + fun `임시저장 이어쓰기를 등록하면 같은 레코드를 확정으로 바꾼다`() { + val repository = + FakeRepository( + answers = emptyList(), + drafts = listOf(answer(id = 11L, content = "

지난 임시저장

")), + ) + val viewModel = editViewModel(repository, answerId = 11L, isDraft = true) + + viewModel.submit(isDraft = false) + + assertEquals(0, repository.createCalls) + assertEquals(listOf(11L), repository.updatedIds) + assertEquals(false, repository.updatedPayloads.single().isDraft) + } + + @Test + fun `정식 수정은 draft 목록을 조회하지 않는다`() { + val repository = FakeRepository(answers = listOf(answer(id = 7L, content = "

원본

"))) + + editViewModel(repository, answerId = 7L, isDraft = false) + + assertEquals(listOf(null), repository.listDraftOnlyArgs) + } + // ── 테스트 도구 ─────────────────────────────────────────────────────────── private fun editViewModel( repository: DailyQuestionRepository, answerId: Long?, + isDraft: Boolean = false, ): DailyQuestionWriteViewModel { val handle = SavedStateHandle( - if (answerId == null) emptyMap() else mapOf("answerId" to answerId), + if (answerId == null) { + emptyMap() + } else { + mapOf("answerId" to answerId, "isDraft" to isDraft) + }, ) return DailyQuestionWriteViewModel( handle, repository, PhotoUploadRepository { _, _ -> error("업로드는 이 시나리오에서 호출되면 안 됨") }, - // 툴바 카운트는 이 시나리오의 관심사가 아니다 (#769). + // 툴바 카운트는 이 시나리오의 관심사가 아니다 — **다른** 저장소를 넘긴다. + // 같은 fake 를 넘기면 카운트 조회의 draftOnly=true 가 프리필 조회 기록에 섞여 + // «어느 목록을 봤는가» 단언이 무너진다 (#769·#770). MindRecordDraftLoader( diaryRepository = EditTestEmptyDiaryRepository, - dailyQuestionRepository = repository, + dailyQuestionRepository = EditTestEmptyDailyQuestionRepository, ), ) } @@ -128,16 +176,21 @@ class DailyQuestionEditTest { private class FakeRepository( private val answers: List, + private val drafts: List = emptyList(), ) : DailyQuestionRepository { var todayCalls = 0 var createCalls = 0 val updatedIds = mutableListOf() val updatedPayloads = mutableListOf() + val listDraftOnlyArgs = mutableListOf() override suspend fun getList( date: String?, draftOnly: Boolean?, - ): Result> = Result.success(answers) + ): Result> { + listDraftOnlyArgs += draftOnly + return Result.success(if (draftOnly == true) drafts else answers) + } override suspend fun getToday(): Result { todayCalls++ @@ -180,3 +233,22 @@ private object EditTestEmptyDiaryRepository : DiaryRepository { override suspend fun delete(id: Long): Result = error("호출되면 안 됨") } + +/** 툴바 카운트 조회만 받아 주는 빈 데일리질문 저장소. */ +private object EditTestEmptyDailyQuestionRepository : DailyQuestionRepository { + override suspend fun getList( + date: String?, + draftOnly: Boolean?, + ): Result> = Result.success(emptyList()) + + override suspend fun getToday(): Result = error("호출되면 안 됨") + + override suspend fun create(payload: DailyQuestionCreatePayload): Result = error("호출되면 안 됨") + + override suspend fun update( + id: Long, + payload: DailyQuestionUpdatePayload, + ): Result = error("호출되면 안 됨") + + override suspend fun delete(id: Long): Result = error("호출되면 안 됨") +} diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionWriteViewModelTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionWriteViewModelTest.kt index 7fc161c41d..eb413dc58d 100644 --- a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionWriteViewModelTest.kt +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionWriteViewModelTest.kt @@ -304,13 +304,13 @@ private object NoopPhotoUploadRepository : PhotoUploadRepository { private const val CREATED_ANSWER_ID = 19L /** 툴바 카운트는 이 테스트의 관심사가 아니다 — 0건으로 고정한다 (#769). */ -private fun noopDraftLoader() = +internal fun noopDraftLoader() = MindRecordDraftLoader( diaryRepository = EmptyDraftDiaryRepository, dailyQuestionRepository = EmptyDraftDailyQuestionRepository, ) -private object EmptyDraftDiaryRepository : DiaryRepository { +internal object EmptyDraftDiaryRepository : DiaryRepository { override suspend fun getList( yearMonth: String, draftOnly: Boolean?, @@ -326,7 +326,7 @@ private object EmptyDraftDiaryRepository : DiaryRepository { override suspend fun delete(id: Long): Result = error("호출되면 안 됨") } -private object EmptyDraftDailyQuestionRepository : DailyQuestionRepository { +internal object EmptyDraftDailyQuestionRepository : DailyQuestionRepository { override suspend fun getList( date: String?, draftOnly: Boolean?,