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 348e6a55d3..06990987b5 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, containerColor = Color.Transparent, 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 941f73c809..039a6ffd2d 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 @@ -36,6 +36,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 @@ -73,6 +75,25 @@ 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() + } + + // 갱신으로 사라진 항목의 키가 선택에 남으면 "총 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 deleteOutcome = (uiState as? DraftListUiState.Success)?.deleteOutcome LaunchedEffect(deleteOutcome) { when (deleteOutcome) { 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 1bde87a91d..e1d28fa6f2 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 @@ -35,11 +36,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) { @@ -104,17 +118,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..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 @@ -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,39 @@ 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 + // 보고 있던 필터·정렬을 승계한다. 새 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 -> + // 재진입 갱신 실패는 보고 있던 화면을 유지한다. + if (showsLoading) { + _uiState.value = + ReceiverMindRecordUiState.Error( + message = e.message ?: "마음의 기록을 불러오지 못했습니다.", + ) + } + } + } } /** 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(), + ), + ) +}