From ae2e0926af356742e47b8888711a3c05c3b675f8 Mon Sep 17 00:00:00 2001 From: Sadturtleman Date: Sun, 23 Aug 2026 14:14:12 +0900 Subject: [PATCH 1/2] =?UTF-8?q?fix(mindrecord):=20=EC=9E=84=EC=8B=9C?= =?UTF-8?q?=EC=A0=80=EC=9E=A5=C2=B7=EC=88=98=EC=8B=A0=20=EB=AA=A9=EB=A1=9D?= =?UTF-8?q?=EC=97=90=20=EC=9E=AC=EC=A7=84=EC=9E=85=20=EA=B0=B1=EC=8B=A0?= =?UTF-8?q?=EC=9D=84=20=EC=97=B0=EA=B2=B0=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 두 화면 모두 재조회 함수는 있는데 화면이 부르지 않아, 떠났다 돌아와도 목록이 낡은 채로 남았다. 마인드레코드 홈이 쓰는 결선(ON_RESUME → refreshOnReturn)을 그대로 붙인다. `refresh() = load()` 를 그대로 부르지 않는다 — ON_RESUME 은 화면이 살아 있는 채로 발화하므로 로딩을 방출하면 보고 있던 목록·선택·필터가 스피너로 갈아치워진다. 홈과 같이 로딩을 방출하지 않고 실패해도 기존 화면을 유지하는 `refreshOnReturn()` 을 따로 둔다. 수신 화면의 `refresh()` 는 오류 화면 재시도 버튼이 쓰고 있어 남긴다 — 그쪽은 보고 있던 것이 오류 문구뿐이라 로딩을 보여도 잃을 게 없다. (이슈는 호출부 0건으로 적었지만 `ReceiverMindRecordScreen.kt:71` 에서 쓰고 있었다.) 최초 진입의 중복 호출은 VM 이 든 `loadJob` 으로 막는다. Closes #702 Co-Authored-By: Claude Opus 5 (1M context) --- .../receiver/ReceiverMindRecordScreen.kt | 11 ++++ .../screen/sender/DraftListScreen.kt | 11 ++++ .../viewmodel/DraftListViewModel.kt | 44 ++++++++++---- .../viewmodel/ReceiverMindRecordViewModel.kt | 60 ++++++++++++------- 4 files changed, 94 insertions(+), 32 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 0c9f30c1d1..376b471bd4 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 @@ -32,6 +32,8 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LifecycleEventEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.afternote.core.ui.theme.AfternoteDesign import com.afternote.core.ui.theme.AfternoteTheme @@ -60,6 +62,15 @@ fun ReceiverMindRecordScreen( val uiState by viewModel.uiState.collectAsStateWithLifecycle() var filterSheetVisible by remember { mutableStateOf(false) } + // 화면을 떠났다 돌아오면 다시 조회한다 — 작성·삭제하고 복귀했을 때 목록이 낡은 채로 + // 남지 않게 한다. 마인드레코드 홈이 쓰는 결선과 같다 (#702). + // + // ON_RESUME 은 화면 off/on·홈 버튼 복귀에서도 발화하므로 로딩을 방출하지 않는 + // `refreshOnReturn()` 을 쓴다. 최초 진입의 중복 호출은 VM 이 진행 중인 Job 으로 막는다. + LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { + viewModel.refreshOnReturn() + } + Scaffold(modifier = modifier) { paddingValues -> Box(modifier = Modifier.padding(paddingValues)) { when (val state = uiState) { 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 788f3ad7fa..15bc7a781a 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 @@ -35,6 +35,8 @@ import androidx.compose.ui.semantics.Role import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LifecycleEventEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.afternote.core.ui.asString import com.afternote.core.ui.button.AfternoteCircularCheckbox @@ -71,6 +73,15 @@ fun DraftListScreen( var selectionMode by remember { mutableStateOf(false) } var selectedKeys by remember { mutableStateOf(setOf()) } + // 화면을 떠났다 돌아오면 다시 조회한다 — 작성·삭제하고 복귀했을 때 목록이 낡은 채로 + // 남지 않게 한다. 마인드레코드 홈이 쓰는 결선과 같다 (#702). + // + // ON_RESUME 은 화면 off/on·홈 버튼 복귀에서도 발화하므로 로딩을 방출하지 않는 + // `refreshOnReturn()` 을 쓴다. 최초 진입의 중복 호출은 VM 이 진행 중인 Job 으로 막는다. + LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { + viewModel.refreshOnReturn() + } + val deleteCompleted = (uiState as? DraftListUiState.Success)?.deleteCompleted == true LaunchedEffect(deleteCompleted) { if (deleteCompleted) { diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DraftListViewModel.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DraftListViewModel.kt index 6f5c50e3fb..936b066c55 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DraftListViewModel.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DraftListViewModel.kt @@ -8,6 +8,7 @@ import com.afternote.feature.mindrecord.domain.repository.DiaryRepository import com.afternote.feature.mindrecord.presentation.R import com.afternote.feature.mindrecord.presentation.mapper.toUi import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope @@ -38,11 +39,24 @@ class DraftListViewModel private val _uiState = MutableStateFlow(DraftListUiState.Loading) val uiState: StateFlow = _uiState.asStateFlow() + private var loadJob: Job? = null + init { load() } - fun refresh() = load() + /** + * 화면 재진입 갱신. + * + * 로딩을 방출하지 않는다 — ON_RESUME 은 화면이 살아 있는 채로 발화하므로, 스피너로 + * 갈아치우면 보고 있던 목록과 선택 상태가 사라진다. 실패해도 기존 목록을 유지한다. + * 마인드레코드 홈의 `refreshOnReturn` 과 같은 규칙이다. + */ + fun refreshOnReturn() { + // 진입 직후의 ON_RESUME 은 init 로드와 겹친다 — 진행 중이면 건너뛴다. + if (loadJob?.isActive == true) return + load(showsLoading = false) + } /** 선택 삭제 — 삭제 후 목록을 다시 불러오고 완료 토스트 노출 플래그를 세운다. */ fun delete(items: List) { @@ -89,17 +103,23 @@ class DraftListViewModel } } - private fun load() { - viewModelScope.launch { - _uiState.value = DraftListUiState.Loading - val items = collectDrafts() - _uiState.value = - if (items != null) { - DraftListUiState.Success(items = items) - } else { - DraftListUiState.Error(UiText.Resource(R.string.mindrecord_error_generic)) - } - } + private fun load(showsLoading: Boolean = true) { + loadJob?.cancel() + loadJob = + viewModelScope.launch { + if (showsLoading) _uiState.value = DraftListUiState.Loading + val items = collectDrafts() + _uiState.value = + when { + items != null -> DraftListUiState.Success(items = items) + + // 재진입 갱신이 실패하면 보고 있던 목록을 그대로 둔다 — 화면을 오류로 + // 갈아치우면 사용자가 하던 일(선택·스크롤)이 사라진다. + !showsLoading -> _uiState.value + + else -> DraftListUiState.Error(UiText.Resource(R.string.mindrecord_error_generic)) + } + } } private suspend fun collectDrafts(): List? = 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..baee076493 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 @@ -6,6 +6,7 @@ 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 dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -29,10 +30,24 @@ class ReceiverMindRecordViewModel private val _uiState = MutableStateFlow(ReceiverMindRecordUiState.Loading) val uiState: StateFlow = _uiState.asStateFlow() + private var loadJob: Job? = null + init { load() } + /** + * 화면 재진입 갱신. + * + * 로딩을 방출하지 않는다 — ON_RESUME 은 화면이 살아 있는 채로 발화하므로, 스피너로 + * 갈아치우면 보고 있던 목록과 필터가 사라진다. 실패해도 기존 화면을 유지한다. + */ + fun refreshOnReturn() { + if (loadJob?.isActive == true) return + load(showsLoading = false) + } + + /** 오류 화면의 재시도 — 이쪽은 로딩을 보여야 한다. 보고 있던 것이 오류 문구뿐이라 잃을 게 없다. */ fun refresh() = load() fun applyFilter(filter: ReceiverMindRecordFilter) { @@ -47,26 +62,31 @@ class ReceiverMindRecordViewModel fun resetFilter() = applyFilter(ReceiverMindRecordFilter()) - private fun load() { - viewModelScope.launch { - _uiState.update { ReceiverMindRecordUiState.Loading } - repository - .getAll() - .onSuccess { records -> - rawRecords.value = records - _uiState.value = - ReceiverMindRecordUiState - .Success( - dailyQuestions = emptyList(), - diaries = emptyList(), - ).withDerived() - }.onFailure { e -> - _uiState.value = - ReceiverMindRecordUiState.Error( - message = e.message ?: "마음의 기록을 불러오지 못했습니다.", - ) - } - } + private fun load(showsLoading: Boolean = true) { + loadJob?.cancel() + loadJob = + viewModelScope.launch { + if (showsLoading) _uiState.update { ReceiverMindRecordUiState.Loading } + repository + .getAll() + .onSuccess { records -> + rawRecords.value = records + _uiState.value = + ReceiverMindRecordUiState + .Success( + dailyQuestions = emptyList(), + diaries = emptyList(), + ).withDerived() + }.onFailure { e -> + // 재진입 갱신 실패는 보고 있던 화면을 유지한다. + if (showsLoading) { + _uiState.value = + ReceiverMindRecordUiState.Error( + message = e.message ?: "마음의 기록을 불러오지 못했습니다.", + ) + } + } + } } /** From 0512b8aa2a9eb2bb78706204122811327ec379d2 Mon Sep 17 00:00:00 2001 From: Sadturtleman Date: Mon, 24 Aug 2026 17:24:12 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix(mindrecord):=20=EC=9E=AC=EC=A7=84?= =?UTF-8?q?=EC=9E=85=20=EA=B0=B1=EC=8B=A0=EC=9D=B4=20=EB=B3=B4=EA=B3=A0=20?= =?UTF-8?q?=EC=9E=88=EB=8D=98=20=ED=95=84=ED=84=B0=EB=A5=BC=20=EC=A7=80?= =?UTF-8?q?=EC=9A=B0=EC=A7=80=20=EC=95=8A=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 load() 는 새 Success 를 만들어 상태를 갈아치우는데, 종전에는 init·오류 재시도에서만 돌아 «필터가 적용된 Success 위에서 실행될» 경로가 없었다. 이 PR 의 refreshOnReturn() 이 그 경로를 처음 만들어, 화면 off/on 한 번에 정렬·기간 필터가 조용히 초기화됐다. 로딩을 안 보이게 한 것만으로는 «갈아치우지 않는다» 가 되지 않는다. 임시저장 선택 모드의 유령 선택도 정리한다. selectedKeys 는 화면 remember 라 갱신에도 살아남아, 그 사이 서버에서 지워진 항목의 키가 남으면 «총 N개 선택» 이 과대 표시되고 선택 삭제가 빈 목록으로 나간다. 갱신된 목록과의 교집합으로 좁힌다. 리뷰 지적. Co-Authored-By: Claude Opus 5 (1M context) --- .../screen/sender/DraftListScreen.kt | 10 +++ .../viewmodel/ReceiverMindRecordViewModel.kt | 8 ++ .../viewmodel/ReceiverRefreshFilterTest.kt | 84 +++++++++++++++++++ 3 files changed, 102 insertions(+) create mode 100644 feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/ReceiverRefreshFilterTest.kt 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 15bc7a781a..9c472afba4 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 @@ -82,6 +82,16 @@ fun DraftListScreen( viewModel.refreshOnReturn() } + // 갱신으로 사라진 항목의 키가 선택에 남으면 "총 N개 선택" 이 과대 표시되고, 선택 + // 삭제가 빈 목록으로 나간다. 선택은 화면 remember 라 갱신에도 살아남는다 (리뷰 지적). + val visibleKeys = (uiState as? DraftListUiState.Success)?.items?.map { it.key() }?.toSet() + LaunchedEffect(visibleKeys) { + if (visibleKeys != null) { + selectedKeys = selectedKeys intersect visibleKeys + if (selectedKeys.isEmpty()) selectionMode = false + } + } + val deleteCompleted = (uiState as? DraftListUiState.Success)?.deleteCompleted == true LaunchedEffect(deleteCompleted) { if (deleteCompleted) { 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 baee076493..84c771fb36 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 @@ -71,11 +71,19 @@ class ReceiverMindRecordViewModel .getAll() .onSuccess { records -> rawRecords.value = records + // 보고 있던 필터·정렬을 승계한다. 새 Success 를 기본값으로 만들면 + // 재진입 갱신이 성공할 때마다 정렬·기간 필터가 조용히 초기화된다 — + // 종전에는 load() 가 init·재시도에서만 돌아 이 경로가 없었고, + // 이 PR 의 refreshOnReturn() 이 그 경로를 처음 만든다 (리뷰 지적). + val currentFilter = + (_uiState.value as? ReceiverMindRecordUiState.Success)?.filter + ?: ReceiverMindRecordFilter() _uiState.value = ReceiverMindRecordUiState .Success( dailyQuestions = emptyList(), diaries = emptyList(), + filter = currentFilter, ).withDerived() }.onFailure { e -> // 재진입 갱신 실패는 보고 있던 화면을 유지한다. diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/ReceiverRefreshFilterTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/ReceiverRefreshFilterTest.kt new file mode 100644 index 0000000000..65dd4ab0a5 --- /dev/null +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/ReceiverRefreshFilterTest.kt @@ -0,0 +1,84 @@ +package com.afternote.feature.mindrecord.presentation.viewmodel + +import com.afternote.feature.mindrecord.domain.model.MindRecordSummary +import com.afternote.feature.mindrecord.domain.model.MindRecordType +import com.afternote.feature.mindrecord.domain.model.ReceiverMindRecords +import com.afternote.feature.mindrecord.domain.repository.MindRecordReceiverRepository +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test + +/** + * 재진입 갱신이 보고 있던 필터를 지키는지 (#702 리뷰 지적). + * + * `load()` 는 새 `Success` 를 만들어 상태를 갈아치운다. 종전에는 init·오류 재시도에서만 + * 돌아 «필터가 적용된 Success 위에서 실행될» 경로가 없었는데, 이 PR 의 `refreshOnReturn()` + * 이 그 경로를 처음 만든다 — 승계하지 않으면 화면 off/on 한 번에 정렬·기간 필터가 조용히 + * 초기화된다. 로딩을 안 보이게 한 것만으로는 «갈아치우지 않는다» 가 되지 않는다. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class ReceiverRefreshFilterTest { + private val dispatcher = StandardTestDispatcher() + + @Before + fun setUp() { + Dispatchers.setMain(dispatcher) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `재진입 갱신이 정렬과 기간 필터를 지운다면 안 된다`() = + runTest(dispatcher) { + val viewModel = ReceiverMindRecordViewModel(repository = FakeReceiverRepository()) + advanceUntilIdle() + + val applied = + ReceiverMindRecordFilter( + sortOrder = SortOrder.OLDEST, + fromDate = "2026-08-01", + toDate = "2026-08-31", + ) + viewModel.applyFilter(applied) + advanceUntilIdle() + + viewModel.refreshOnReturn() + advanceUntilIdle() + + val state = viewModel.uiState.value as ReceiverMindRecordUiState.Success + + assertEquals("갱신해도 보고 있던 필터가 남는다", applied, state.filter) + } +} + +private class FakeReceiverRepository : MindRecordReceiverRepository { + override suspend fun getAll(): Result = + Result.success( + ReceiverMindRecords( + dailyQuestions = + listOf( + MindRecordSummary( + id = 1L, + type = MindRecordType.DAILY_QUESTION, + title = "질문", + content = "답변", + recordDate = "2026-08-10", + isDraft = false, + createdAt = "2026.08.10 월", + ), + ), + diaries = emptyList(), + ), + ) +}