Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -73,6 +75,25 @@ fun DraftListScreen(
var selectionMode by remember { mutableStateOf(false) }
var selectedKeys by remember { mutableStateOf(setOf<String>()) }

// 화면을 떠났다 돌아오면 다시 조회한다 — 작성·삭제하고 복귀했을 때 목록이 낡은 채로
// 남지 않게 한다. 마인드레코드 홈이 쓰는 결선과 같다 (#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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -35,11 +36,24 @@ class DraftListViewModel
private val _uiState = MutableStateFlow<DraftListUiState>(DraftListUiState.Loading)
val uiState: StateFlow<DraftListUiState> = _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<DraftItem>) {
Expand Down Expand Up @@ -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<DraftItem>? =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -29,10 +30,24 @@ class ReceiverMindRecordViewModel
private val _uiState = MutableStateFlow<ReceiverMindRecordUiState>(ReceiverMindRecordUiState.Loading)
val uiState: StateFlow<ReceiverMindRecordUiState> = _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) {
Expand All @@ -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 ?: "마음의 기록을 불러오지 못했습니다.",
)
}
}
}
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -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<ReceiverMindRecords> =
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(),
),
)
}
Loading