From 73118e79109b1dfd29971ff23285a4308148ae41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Sun, 20 Sep 2026 22:06:00 +0900 Subject: [PATCH 1/2] refactor(setting): extract receiver save policies (#1691) --- feature/setting/domain/build.gradle.kts | 6 + .../domain/UpdateReceiverInfoUseCase.kt | 68 +++++++ ...pdateTimeLetterDeliveryConditionUseCase.kt | 58 ++++++ .../setting/domain/ReceiverSavePolicyTest.kt | 181 ++++++++++++++++++ .../delivery/DeliveryConditionScreen.kt | 2 +- .../delivery/DeliveryConditionViewModel.kt | 55 +----- .../receiver/ReceiverEditScreen.kt | 2 +- .../receiver/ReceiverEditViewModel.kt | 64 +++---- .../SettingAccountSecurityTest.kt | 2 + .../presentation/SettingCompletionTest.kt | 5 + 10 files changed, 363 insertions(+), 80 deletions(-) create mode 100644 feature/setting/domain/src/main/java/com/afternote/feature/setting/domain/UpdateReceiverInfoUseCase.kt create mode 100644 feature/setting/domain/src/main/java/com/afternote/feature/setting/domain/UpdateTimeLetterDeliveryConditionUseCase.kt create mode 100644 feature/setting/domain/src/test/kotlin/com/afternote/feature/setting/domain/ReceiverSavePolicyTest.kt diff --git a/feature/setting/domain/build.gradle.kts b/feature/setting/domain/build.gradle.kts index 39b64eebd..9b6f1c894 100644 --- a/feature/setting/domain/build.gradle.kts +++ b/feature/setting/domain/build.gradle.kts @@ -6,6 +6,12 @@ plugins { dependencies { // 계정·알림 계약이 노출하는 도메인 모델(UserConnectedAccount·UserPushSetting·UserMarketingConsent). implementation(projects.core.model) + // 수신자 저장 UseCase 가 core 계약 UserReceiverRepository 를 쓴다 (#1691). + implementation(projects.core.domain) + implementation(libs.coroutines.core) testFixturesImplementation(projects.core.model) + + testImplementation(libs.coroutines.test) + testImplementation(testFixtures(projects.core.domain)) } diff --git a/feature/setting/domain/src/main/java/com/afternote/feature/setting/domain/UpdateReceiverInfoUseCase.kt b/feature/setting/domain/src/main/java/com/afternote/feature/setting/domain/UpdateReceiverInfoUseCase.kt new file mode 100644 index 000000000..3444b9aa1 --- /dev/null +++ b/feature/setting/domain/src/main/java/com/afternote/feature/setting/domain/UpdateReceiverInfoUseCase.kt @@ -0,0 +1,68 @@ +package com.afternote.feature.setting.domain + +import com.afternote.core.domain.repository.UserReceiverRepository +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive +import javax.inject.Inject + +/** + * 수신자 저장 결과 (#1691). + * + * 기본정보와 인사말은 서로 다른 쓰기라 실패한 요청 단계에 따라 사용자에게 알릴 내용을 구분한다. + * 인사말 요청 단계에서 실패하면 기본정보 요청은 이미 성공한 상태다. + */ +public sealed interface UpdateReceiverInfoResult { + public data object Success : UpdateReceiverInfoResult + + /** + * 기본정보 쓰기가 실패해 인사말 쓰기를 시도하지 않았다. + * + * [cause] 는 저장소가 던진 원인 그대로다. 소비자가 거절 예외를 가려 문구를 고르므로 감싸지 않는다. + * `Exception` 이 아니라 `Throwable` 인 것은 이 정책을 옮겨 오기 전 ViewModel 이 쓰던 + * `runCatchingCancellable` 의 포착 범위(취소를 뺀 모든 `Throwable`)를 그대로 보존한 결과다. + */ + public data class BasicInfoFailed( + public val cause: Throwable, + ) : UpdateReceiverInfoResult + + public data object MessageFailedAfterBasicInfoUpdated : UpdateReceiverInfoResult +} + +/** + * 수신자 기본정보를 저장한 뒤 인사말을 저장한다 (#1691). + * + * 순서를 고정한다. 기본정보가 실패하면 인사말은 시도하지 않는다. 두 쓰기 사이에서 취소되면 + * 인사말 쓰기를 시작하지 않고 취소를 그대로 전파한다. + */ +public class UpdateReceiverInfoUseCase + @Inject + constructor( + private val repository: UserReceiverRepository, + ) { + public suspend operator fun invoke( + receiverId: Long, + name: String, + phone: String, + relation: String, + email: String, + message: String, + ): UpdateReceiverInfoResult { + try { + repository.updateReceiver(receiverId, name, phone, relation, email) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (failure: Throwable) { + return UpdateReceiverInfoResult.BasicInfoFailed(failure) + } + currentCoroutineContext().ensureActive() + return try { + repository.updateReceiverMessage(receiverId, message) + UpdateReceiverInfoResult.Success + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Throwable) { + UpdateReceiverInfoResult.MessageFailedAfterBasicInfoUpdated + } + } + } diff --git a/feature/setting/domain/src/main/java/com/afternote/feature/setting/domain/UpdateTimeLetterDeliveryConditionUseCase.kt b/feature/setting/domain/src/main/java/com/afternote/feature/setting/domain/UpdateTimeLetterDeliveryConditionUseCase.kt new file mode 100644 index 000000000..83c12da89 --- /dev/null +++ b/feature/setting/domain/src/main/java/com/afternote/feature/setting/domain/UpdateTimeLetterDeliveryConditionUseCase.kt @@ -0,0 +1,58 @@ +package com.afternote.feature.setting.domain + +import com.afternote.core.domain.repository.UserReceiverRepository +import com.afternote.core.model.delivery.ConditionState +import com.afternote.core.model.delivery.DeliveryConditionItem +import com.afternote.core.model.delivery.DeliveryConditionType +import com.afternote.core.model.delivery.DeliveryContentType +import com.afternote.core.model.delivery.InactivityPeriod +import com.afternote.core.model.delivery.ReceiverDeliveryConditions +import javax.inject.Inject + +/** + * 타임레터 전달조건만 바꿔 수신자의 전달조건 전량을 저장한다 (#1691). + * + * 저장 API 가 조건 목록을 통째로 받으므로 다른 콘텐츠의 조건은 손대지 않고 그대로 실어 보낸다. + * 타임레터 조건이 아직 없으면 ACTIVE 로 합성하고, 수신자 요청 방식에는 무활동 기간을 비운다. + * + * 실패는 호출부가 판정한다. 이 UseCase 는 예외를 삼키지 않고 저장소 응답을 그대로 돌려준다. + */ +public class UpdateTimeLetterDeliveryConditionUseCase + @Inject + constructor( + private val repository: UserReceiverRepository, + ) { + public suspend operator fun invoke( + receiverId: Long, + conditions: List, + conditionType: DeliveryConditionType, + inactivityPeriod: InactivityPeriod, + ): ReceiverDeliveryConditions { + val period = inactivityPeriod.takeIf { conditionType == DeliveryConditionType.INACTIVITY } + val updated = + conditions + .map { condition -> + if (condition.contentType == DeliveryContentType.TIME_LETTER) { + condition.copy(conditionType = conditionType, inactivityPeriod = period) + } else { + condition + } + }.let { items -> + if (items.any { it.contentType == DeliveryContentType.TIME_LETTER }) { + items + } else { + items + + DeliveryConditionItem( + contentType = DeliveryContentType.TIME_LETTER, + conditionType = conditionType, + inactivityPeriod = period, + state = ConditionState.ACTIVE, + fulfilled = false, + gracePeriodStartedAt = null, + fulfilledAt = null, + ) + } + } + return repository.updateReceiverDeliveryConditions(receiverId, updated) + } + } diff --git a/feature/setting/domain/src/test/kotlin/com/afternote/feature/setting/domain/ReceiverSavePolicyTest.kt b/feature/setting/domain/src/test/kotlin/com/afternote/feature/setting/domain/ReceiverSavePolicyTest.kt new file mode 100644 index 000000000..860f6cd60 --- /dev/null +++ b/feature/setting/domain/src/test/kotlin/com/afternote/feature/setting/domain/ReceiverSavePolicyTest.kt @@ -0,0 +1,181 @@ +package com.afternote.feature.setting.domain + +import com.afternote.core.domain.testing.FakeUserReceiverRepository +import com.afternote.core.model.delivery.ConditionState +import com.afternote.core.model.delivery.DeliveryConditionItem +import com.afternote.core.model.delivery.DeliveryConditionType +import com.afternote.core.model.delivery.DeliveryContentType +import com.afternote.core.model.delivery.InactivityPeriod +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.cancel +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test + +class ReceiverSavePolicyTest { + @Test + fun `basic info failure skips message and preserves cause`() = + runTest { + val error = IllegalStateException("save failed") + val repository = FakeUserReceiverRepository(onUpdateReceiver = { _, _, _, _, _ -> throw error }) + val result = save(repository) + assertSame(error, (result as UpdateReceiverInfoResult.BasicInfoFailed).cause) + assertTrue(repository.receiverMessageCalls.isEmpty()) + } + + /** + * 옮겨 오기 전 ViewModel 은 `runCatchingCancellable` 로 감싸 `Exception` 이 아닌 `Throwable` 도 + * 화면 오류로 바꿨다. UseCase 가 `Exception` 만 잡으면 그 경로가 코루틴 밖으로 튀어 화면이 저장 중에 + * 머문다 — 포착 범위를 좁히는 회귀를 여기서 막는다. + */ + @Test + fun `basic info failure keeps non exception throwable as cause`() = + runTest { + val error = AssertionError("save failed") + val repository = FakeUserReceiverRepository(onUpdateReceiver = { _, _, _, _, _ -> throw error }) + val result = save(repository) + assertSame(error, (result as UpdateReceiverInfoResult.BasicInfoFailed).cause) + assertTrue(repository.receiverMessageCalls.isEmpty()) + } + + @Test + fun `message failure reports already saved basic info`() = + runTest { + val repository = FakeUserReceiverRepository(onUpdateReceiverMessage = { _, _ -> error("message failed") }) + assertEquals(UpdateReceiverInfoResult.MessageFailedAfterBasicInfoUpdated, save(repository)) + assertEquals( + "updated", + repository.receiverState.value + .single() + .name, + ) + assertEquals(1, repository.receiverMessageCalls.size) + } + + /** 인사말 쪽도 같은 이유로 `Throwable` 까지 부분 저장 결과로 접는다. */ + @Test + fun `message failure reports partial save for non exception throwable`() = + runTest { + val repository = + FakeUserReceiverRepository( + onUpdateReceiverMessage = { _, _ -> throw AssertionError("message failed") }, + ) + assertEquals(UpdateReceiverInfoResult.MessageFailedAfterBasicInfoUpdated, save(repository)) + assertEquals(1, repository.receiverMessageCalls.size) + } + + @Test + fun `success saves basic info before message`() = + runTest { + val repository = FakeUserReceiverRepository() + repository.onUpdateReceiverMessage = { _, _ -> + assertEquals( + "updated", + repository.receiverState.value + .single() + .name, + ) + } + assertEquals(UpdateReceiverInfoResult.Success, save(repository)) + } + + @Test + fun `cancellation between writes does not save message`() = + runTest { + val repository = FakeUserReceiverRepository() + repository.onUpdateReceiver = { _, _, _, _, _ -> + currentCoroutineContext().cancel() + repository.receiverState.value.single() + } + var completed = false + val job = + launch { + save(repository) + completed = true + } + job.join() + assertTrue(job.isCancelled) + assertTrue(repository.receiverMessageCalls.isEmpty()) + assertEquals(false, completed) + } + + @Test + fun `basic info cancellation propagates`() = + runTest { + val cancelled = CancellationException("cancelled") + val repository = FakeUserReceiverRepository(onUpdateReceiver = { _, _, _, _, _ -> throw cancelled }) + try { + save(repository) + error("Expected cancellation") + } catch (actual: CancellationException) { + assertSame(cancelled, actual) + } + assertTrue(repository.receiverMessageCalls.isEmpty()) + } + + @Test + fun `message cancellation propagates`() = + runTest { + val cancelled = CancellationException("cancelled") + val repository = FakeUserReceiverRepository(onUpdateReceiverMessage = { _, _ -> throw cancelled }) + try { + save(repository) + error("Expected cancellation") + } catch (actual: CancellationException) { + assertSame(cancelled, actual) + } + } + + @Test + fun `time letter patch preserves other conditions and removes request period`() = + runTest { + val otherType = DeliveryContentType.entries.first { it != DeliveryContentType.TIME_LETTER } + val other = condition(otherType) + val timeLetter = condition(DeliveryContentType.TIME_LETTER) + val repository = FakeUserReceiverRepository() + val result = + UpdateTimeLetterDeliveryConditionUseCase(repository)( + 7L, + listOf(other, timeLetter), + DeliveryConditionType.RECEIVER_REQUEST, + InactivityPeriod.ONE_YEAR, + ) + assertEquals( + listOf(other, timeLetter.copy(conditionType = DeliveryConditionType.RECEIVER_REQUEST, inactivityPeriod = null)), + result.conditions, + ) + assertEquals(result.conditions, repository.deliveryUpdateCalls.single().conditions) + } + + @Test + fun `missing time letter is synthesized with selected inactivity period`() = + runTest { + val repository = FakeUserReceiverRepository() + val result = + UpdateTimeLetterDeliveryConditionUseCase(repository)( + 7L, + emptyList(), + DeliveryConditionType.INACTIVITY, + InactivityPeriod.ONE_YEAR, + ) + assertEquals(listOf(condition(DeliveryContentType.TIME_LETTER)), result.conditions) + } + + private suspend fun save(repository: FakeUserReceiverRepository): UpdateReceiverInfoResult = + UpdateReceiverInfoUseCase(repository)(7L, "updated", "01012345678", "가족", "receiver@test.com", "hello") + + private fun condition(type: DeliveryContentType) = + DeliveryConditionItem( + contentType = type, + conditionType = DeliveryConditionType.INACTIVITY, + inactivityPeriod = InactivityPeriod.ONE_YEAR, + state = ConditionState.ACTIVE, + fulfilled = false, + gracePeriodStartedAt = null, + fulfilledAt = null, + ) +} diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/delivery/DeliveryConditionScreen.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/delivery/DeliveryConditionScreen.kt index f2a6df626..e4916a34e 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/delivery/DeliveryConditionScreen.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/delivery/DeliveryConditionScreen.kt @@ -36,7 +36,7 @@ import com.afternote.feature.setting.presentation.delivery.component.RadioGroup import com.afternote.feature.setting.presentation.delivery.component.RadioGroupItem @Composable -fun DeliveryConditionScreen( +internal fun DeliveryConditionScreen( onBack: () -> Unit, onSaveSuccess: () -> Unit, onLastGreetingEditClick: () -> Unit, diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/delivery/DeliveryConditionViewModel.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/delivery/DeliveryConditionViewModel.kt index 3297bbd07..96ebe4c01 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/delivery/DeliveryConditionViewModel.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/delivery/DeliveryConditionViewModel.kt @@ -6,11 +6,10 @@ import androidx.lifecycle.viewModelScope import androidx.navigation.toRoute import com.afternote.core.common.result.runCatchingCancellable import com.afternote.core.domain.repository.UserReceiverRepository -import com.afternote.core.model.delivery.ConditionState -import com.afternote.core.model.delivery.DeliveryConditionItem import com.afternote.core.model.delivery.DeliveryConditionType import com.afternote.core.model.delivery.DeliveryContentType import com.afternote.core.model.delivery.InactivityPeriod +import com.afternote.feature.setting.domain.UpdateTimeLetterDeliveryConditionUseCase import com.afternote.feature.setting.presentation.navigation.SettingRoute import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.channels.Channel @@ -23,11 +22,12 @@ import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel -class DeliveryConditionViewModel +internal class DeliveryConditionViewModel @Inject constructor( savedStateHandle: SavedStateHandle, private val receiverRepository: UserReceiverRepository, + private val updateTimeLetterDeliveryCondition: UpdateTimeLetterDeliveryConditionUseCase, ) : ViewModel() { private val receiverId = savedStateHandle.toRoute().receiverId @@ -75,41 +75,15 @@ class DeliveryConditionViewModel val state = _uiState.value if (!state.isInitialized || state.isSaving) return - val hasTimeLetterCondition = - state.conditions.any { it.contentType == DeliveryContentType.TIME_LETTER } - val updatedConditions = - state.conditions - .map { condition -> - if (condition.contentType == DeliveryContentType.TIME_LETTER) { - condition.copy( - conditionType = state.conditionType, - inactivityPeriod = - state.inactivityPeriod.takeIf { - state.conditionType == DeliveryConditionType.INACTIVITY - }, - ) - } else { - condition - } - }.let { conditions -> - if (hasTimeLetterCondition) { - conditions - } else { - conditions + - defaultCondition(DeliveryContentType.TIME_LETTER).copy( - conditionType = state.conditionType, - inactivityPeriod = - state.inactivityPeriod.takeIf { - state.conditionType == DeliveryConditionType.INACTIVITY - }, - ) - } - } - viewModelScope.launch { _uiState.update { it.copy(isSaving = true) } runCatchingCancellable { - receiverRepository.updateReceiverDeliveryConditions(receiverId, updatedConditions) + updateTimeLetterDeliveryCondition( + receiverId = receiverId, + conditions = state.conditions, + conditionType = state.conditionType, + inactivityPeriod = state.inactivityPeriod, + ) }.onSuccess { response -> _uiState.update { it.copy(isSaving = false, conditions = response.conditions) } _saveSuccess.send(Unit) @@ -118,15 +92,4 @@ class DeliveryConditionViewModel } } } - - private fun defaultCondition(contentType: DeliveryContentType) = - DeliveryConditionItem( - contentType = contentType, - conditionType = DeliveryConditionType.INACTIVITY, - inactivityPeriod = InactivityPeriod.ONE_YEAR, - state = ConditionState.ACTIVE, - fulfilled = false, - gracePeriodStartedAt = null, - fulfilledAt = null, - ) } diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverEditScreen.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverEditScreen.kt index c271a3a21..a1eae8bfb 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverEditScreen.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverEditScreen.kt @@ -9,7 +9,7 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle @Composable -fun ReceiverEditScreen( +internal fun ReceiverEditScreen( onBackClick: () -> Unit, onEditSuccess: () -> Unit, modifier: Modifier = Modifier, diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverEditViewModel.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverEditViewModel.kt index 7d96bde7b..bee820eda 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverEditViewModel.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverEditViewModel.kt @@ -7,6 +7,8 @@ import androidx.navigation.toRoute import com.afternote.core.common.result.runCatchingCancellable import com.afternote.core.domain.repository.UserReceiverRepository import com.afternote.core.ui.UiText +import com.afternote.feature.setting.domain.UpdateReceiverInfoResult +import com.afternote.feature.setting.domain.UpdateReceiverInfoUseCase import com.afternote.feature.setting.presentation.R import com.afternote.feature.setting.presentation.navigation.SettingRoute import dagger.hilt.android.lifecycle.HiltViewModel @@ -19,11 +21,12 @@ import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel -class ReceiverEditViewModel +internal class ReceiverEditViewModel @Inject constructor( savedStateHandle: SavedStateHandle, private val receiverRepository: UserReceiverRepository, + private val updateReceiverInfo: UpdateReceiverInfoUseCase, ) : ViewModel() { private val receiverId = savedStateHandle.toRoute().receiverId @@ -76,40 +79,37 @@ class ReceiverEditViewModel _uiState.update { it.copy(isSaving = true, errorMessage = null) } viewModelScope.launch { - val receiverUpdateResult = - runCatchingCancellable { - receiverRepository.updateReceiver( - receiverId = receiverId, - name = name, - phone = phone.normalizeReceiverPhone(), - relation = relation, - email = email.trim(), - ) - } - receiverUpdateResult.exceptionOrNull()?.let { error -> - _uiState.update { - it.copy( - isSaving = false, - errorMessage = error.toReceiverFailureMessage(R.string.setting_receiver_edit_failed), - ) - } - return@launch - } - - runCatchingCancellable { - receiverRepository.updateReceiverMessage( + val result = + updateReceiverInfo( receiverId = receiverId, + name = name, + phone = phone.normalizeReceiverPhone(), + relation = relation, + email = email.trim(), message = message, ) - }.onSuccess { - _uiState.update { it.copy(isSaving = false) } - _events.send(ReceiverEditEvent.EditSuccess) - }.onFailure { - _uiState.update { - it.copy( - isSaving = false, - errorMessage = UiText.Resource(R.string.setting_receiver_message_update_partial_failed), - ) + when (result) { + is UpdateReceiverInfoResult.BasicInfoFailed -> { + _uiState.update { + it.copy( + isSaving = false, + errorMessage = result.cause.toReceiverFailureMessage(R.string.setting_receiver_edit_failed), + ) + } + } + + UpdateReceiverInfoResult.MessageFailedAfterBasicInfoUpdated -> { + _uiState.update { + it.copy( + isSaving = false, + errorMessage = UiText.Resource(R.string.setting_receiver_message_update_partial_failed), + ) + } + } + + UpdateReceiverInfoResult.Success -> { + _uiState.update { it.copy(isSaving = false) } + _events.send(ReceiverEditEvent.EditSuccess) } } } diff --git a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingAccountSecurityTest.kt b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingAccountSecurityTest.kt index 65f796b11..195e19c59 100644 --- a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingAccountSecurityTest.kt +++ b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingAccountSecurityTest.kt @@ -23,6 +23,7 @@ import com.afternote.core.model.user.User import com.afternote.core.model.user.UserConnectedAccount import com.afternote.core.ui.UiText import com.afternote.core.ui.theme.AfternoteTheme +import com.afternote.feature.setting.domain.UpdateTimeLetterDeliveryConditionUseCase import com.afternote.feature.setting.domain.testing.FakeSettingAccountRepository import com.afternote.feature.setting.domain.testing.FakeSettingAccountRepository.ConnectedAccountLinkCall import com.afternote.feature.setting.presentation.account.ConnectedAccountsEvent @@ -206,6 +207,7 @@ class SettingAccountSecurityTest { DeliveryConditionViewModel( savedStateHandle = SavedStateHandle(mapOf("receiverId" to RECEIVER_ID)), receiverRepository = repository, + updateTimeLetterDeliveryCondition = UpdateTimeLetterDeliveryConditionUseCase(repository), ) composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { viewModel.uiState.value.isInitialized diff --git a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingCompletionTest.kt b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingCompletionTest.kt index e2069f57f..18ebb9186 100644 --- a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingCompletionTest.kt +++ b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingCompletionTest.kt @@ -35,6 +35,8 @@ import com.afternote.core.model.user.UserMarketingConsent import com.afternote.core.model.user.UserPushSetting import com.afternote.core.ui.UiText import com.afternote.core.ui.theme.AfternoteTheme +import com.afternote.feature.setting.domain.UpdateReceiverInfoUseCase +import com.afternote.feature.setting.domain.UpdateTimeLetterDeliveryConditionUseCase import com.afternote.feature.setting.domain.testing.FakeSettingAccountRepository import com.afternote.feature.setting.domain.testing.FakeSettingNotificationRepository import com.afternote.feature.setting.presentation.account.ConnectedAccountsViewModel @@ -380,6 +382,7 @@ class SettingCompletionTest { ReceiverEditViewModel( savedStateHandle = SavedStateHandle(mapOf("receiverId" to RECEIVER_ID)), receiverRepository = repository, + updateReceiverInfo = UpdateReceiverInfoUseCase(repository), ) composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { viewModel.uiState.value.receiver == COMPLETION_DEFAULT_RECEIVER_DETAIL @@ -473,6 +476,7 @@ class SettingCompletionTest { ReceiverEditViewModel( savedStateHandle = SavedStateHandle(mapOf("receiverId" to RECEIVER_ID)), receiverRepository = repository, + updateReceiverInfo = UpdateReceiverInfoUseCase(repository), ) composeRule.setContent { @@ -531,6 +535,7 @@ class SettingCompletionTest { DeliveryConditionViewModel( savedStateHandle = SavedStateHandle(mapOf("receiverId" to RECEIVER_ID)), receiverRepository = repository, + updateTimeLetterDeliveryCondition = UpdateTimeLetterDeliveryConditionUseCase(repository), ) composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { viewModel.uiState.value.isInitialized From f37c03f43fdabe7e9cb5d487873ee8eac010e2d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Sun, 27 Sep 2026 13:36:48 +0900 Subject: [PATCH 2/2] =?UTF-8?q?refactor(setting):=20=ED=8E=B8=EC=A7=91=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=203=EA=B0=9C=EC=9D=98=20MVI=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=EC=99=80=20=EC=8B=A0=ED=98=B8=20=EA=B3=84=EC=95=BD=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9=20(#2179)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../delivery/DeliveryConditionContract.kt | 40 ++++++ .../delivery/DeliveryConditionScreen.kt | 20 +-- .../delivery/DeliveryConditionUiState.kt | 6 +- .../delivery/DeliveryConditionViewModel.kt | 106 +++++++++------ .../profile/ProfileEditContract.kt | 36 +++++ .../presentation/profile/ProfileEditScreen.kt | 38 ++++-- .../profile/ProfileEditUiState.kt | 7 +- .../profile/ProfileEditViewModel.kt | 85 ++++++++---- .../receiver/ReceiverEditContract.kt | 38 ++++++ .../receiver/ReceiverEditScreen.kt | 24 ++-- .../receiver/ReceiverEditUiState.kt | 8 +- .../receiver/ReceiverEditViewModel.kt | 80 +++++------ .../SettingAccountSecurityTest.kt | 15 ++- .../presentation/SettingCompletionTest.kt | 41 ++++-- .../profile/ProfileEditMviTest.kt | 126 ++++++++++++++++++ .../konsist/MviContractKonsistTest.kt | 3 - 16 files changed, 499 insertions(+), 174 deletions(-) create mode 100644 feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/delivery/DeliveryConditionContract.kt create mode 100644 feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditContract.kt create mode 100644 feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverEditContract.kt create mode 100644 feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditMviTest.kt diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/delivery/DeliveryConditionContract.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/delivery/DeliveryConditionContract.kt new file mode 100644 index 000000000..53e1170cd --- /dev/null +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/delivery/DeliveryConditionContract.kt @@ -0,0 +1,40 @@ +package com.afternote.feature.setting.presentation.delivery + +import com.afternote.core.model.delivery.DeliveryConditionItem +import com.afternote.core.model.delivery.DeliveryConditionType +import com.afternote.core.ui.mvi.MviIntent +import com.afternote.core.ui.mvi.ReducerEvent + +internal sealed interface DeliveryConditionIntent : MviIntent { + data class SelectConditionType( + val index: Int, + ) : DeliveryConditionIntent + + data object Save : DeliveryConditionIntent + + data object ConsumeSuccess : DeliveryConditionIntent +} + +internal sealed interface DeliveryConditionReducerEvent : ReducerEvent { + data object Loading : DeliveryConditionReducerEvent + + data class Loaded( + val conditions: List, + ) : DeliveryConditionReducerEvent + + data object LoadFailed : DeliveryConditionReducerEvent + + data class ConditionSelected( + val type: DeliveryConditionType, + ) : DeliveryConditionReducerEvent + + data object Saving : DeliveryConditionReducerEvent + + data class Saved( + val conditions: List, + ) : DeliveryConditionReducerEvent + + data object SaveFailed : DeliveryConditionReducerEvent + + data object SuccessConsumed : DeliveryConditionReducerEvent +} diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/delivery/DeliveryConditionScreen.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/delivery/DeliveryConditionScreen.kt index 53a22194b..62aad55e4 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/delivery/DeliveryConditionScreen.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/delivery/DeliveryConditionScreen.kt @@ -17,9 +17,7 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -28,6 +26,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.afternote.core.model.delivery.DeliveryConditionType +import com.afternote.core.ui.mvi.ObserveSignal import com.afternote.core.ui.theme.AfternoteDesign import com.afternote.core.ui.topbar.DetailTopBar import com.afternote.feature.setting.presentation.R @@ -35,25 +34,26 @@ import com.afternote.feature.setting.presentation.delivery.component.RadioGroup import com.afternote.feature.setting.presentation.delivery.component.RadioGroupItem @Composable -fun DeliveryConditionScreen( +internal fun DeliveryConditionScreen( onBack: () -> Unit, onSaveSuccess: () -> Unit, onLastGreetingEditClick: () -> Unit, viewModel: DeliveryConditionViewModel, ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() - val currentOnSaveSuccess by rememberUpdatedState(onSaveSuccess) - - LaunchedEffect(Unit) { - viewModel.saveSuccess.collect { currentOnSaveSuccess() } - } + ObserveSignal( + signal = uiState.pendingEvent, + consumed = DeliveryConditionIntent.ConsumeSuccess, + onIntent = viewModel::onIntent, + onSignal = { onSaveSuccess() }, + ) DeliveryConditionContent( uiState = uiState, onBack = onBack, - onConditionTypeSelect = viewModel::onConditionTypeSelected, + onConditionTypeSelect = { viewModel.onIntent(DeliveryConditionIntent.SelectConditionType(it)) }, onLastGreetingEditClick = onLastGreetingEditClick, - onSave = viewModel::onSave, + onSave = { viewModel.onIntent(DeliveryConditionIntent.Save) }, ) } diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/delivery/DeliveryConditionUiState.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/delivery/DeliveryConditionUiState.kt index 723942ec0..d3fc8aa93 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/delivery/DeliveryConditionUiState.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/delivery/DeliveryConditionUiState.kt @@ -4,8 +4,9 @@ import com.afternote.core.model.delivery.DeliveryConditionItem import com.afternote.core.model.delivery.DeliveryConditionType import com.afternote.core.model.delivery.DeliveryContentType import com.afternote.core.model.delivery.InactivityPeriod +import com.afternote.core.ui.mvi.UiState -data class DeliveryConditionUiState( +internal data class DeliveryConditionUiState( val isLoading: Boolean = false, val isInitialized: Boolean = false, val conditionType: DeliveryConditionType = DeliveryConditionType.INACTIVITY, @@ -13,7 +14,8 @@ data class DeliveryConditionUiState( val conditions: List = emptyList(), val error: DeliveryConditionError? = null, val isSaving: Boolean = false, -) + val pendingEvent: Unit? = null, +) : UiState enum class DeliveryConditionError { LOAD_FAILED, diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/delivery/DeliveryConditionViewModel.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/delivery/DeliveryConditionViewModel.kt index f1fbd5447..b6ea20b5c 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/delivery/DeliveryConditionViewModel.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/delivery/DeliveryConditionViewModel.kt @@ -1,6 +1,5 @@ package com.afternote.feature.setting.presentation.delivery -import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.afternote.core.common.result.runCatchingCancellable import com.afternote.core.domain.repository.UserReceiverRepository @@ -9,37 +8,75 @@ import com.afternote.core.model.delivery.DeliveryConditionItem import com.afternote.core.model.delivery.DeliveryConditionType import com.afternote.core.model.delivery.DeliveryContentType import com.afternote.core.model.delivery.InactivityPeriod +import com.afternote.core.ui.mvi.MviViewModel import com.afternote.feature.setting.presentation.navigation.SettingRoute import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.receiveAsFlow -import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -/** - * 대상 수신자 [SettingRoute.AfterDeliveryRoute] 는 assisted 로 받는다 — Nav3 entry 에는 Nav2 의 - * `savedStateHandle.toRoute()` 자동 채움이 없다 (#1695). - */ @HiltViewModel(assistedFactory = DeliveryConditionViewModel.Factory::class) -class DeliveryConditionViewModel +internal class DeliveryConditionViewModel @AssistedInject constructor( @Assisted route: SettingRoute.AfterDeliveryRoute, private val receiverRepository: UserReceiverRepository, - ) : ViewModel() { + ) : MviViewModel(DeliveryConditionUiState()) { private val receiverId = route.receiverId - private val _uiState = MutableStateFlow(DeliveryConditionUiState()) - val uiState: StateFlow = _uiState.asStateFlow() + override fun onIntent(intent: DeliveryConditionIntent) { + when (intent) { + is DeliveryConditionIntent.SelectConditionType -> onConditionTypeSelected(intent.index) + DeliveryConditionIntent.Save -> onSave() + DeliveryConditionIntent.ConsumeSuccess -> dispatch(DeliveryConditionReducerEvent.SuccessConsumed) + } + } + + override fun reduce( + state: DeliveryConditionUiState, + event: DeliveryConditionReducerEvent, + ): DeliveryConditionUiState = + when (event) { + DeliveryConditionReducerEvent.Loading -> { + state.copy(isLoading = true) + } + + is DeliveryConditionReducerEvent.Loaded -> { + val representative = event.conditions.firstOrNull { it.contentType == DeliveryContentType.TIME_LETTER } + state.copy( + isLoading = false, + isInitialized = true, + conditionType = representative?.conditionType ?: DeliveryConditionType.INACTIVITY, + inactivityPeriod = representative?.inactivityPeriod ?: InactivityPeriod.ONE_YEAR, + conditions = event.conditions, + ) + } + + DeliveryConditionReducerEvent.LoadFailed -> { + state.copy(isLoading = false, error = DeliveryConditionError.LOAD_FAILED) + } + + is DeliveryConditionReducerEvent.ConditionSelected -> { + state.copy(conditionType = event.type) + } - private val _saveSuccess = Channel(Channel.BUFFERED) - val saveSuccess = _saveSuccess.receiveAsFlow() + DeliveryConditionReducerEvent.Saving -> { + state.copy(isSaving = true) + } + + is DeliveryConditionReducerEvent.Saved -> { + state.copy(isSaving = false, conditions = event.conditions, pendingEvent = Unit) + } + + DeliveryConditionReducerEvent.SaveFailed -> { + state.copy(isSaving = false, error = DeliveryConditionError.SAVE_FAILED) + } + + DeliveryConditionReducerEvent.SuccessConsumed -> { + state.copy(pendingEvent = null) + } + } init { loadDeliveryConditions() @@ -47,37 +84,25 @@ class DeliveryConditionViewModel private fun loadDeliveryConditions() { viewModelScope.launch { - _uiState.update { it.copy(isLoading = true) } + dispatch(DeliveryConditionReducerEvent.Loading) runCatchingCancellable { receiverRepository.getReceiverDeliveryConditions(receiverId) } .onSuccess { response -> - val representative = - response.conditions.firstOrNull { - it.contentType == DeliveryContentType.TIME_LETTER - } - _uiState.update { - it.copy( - isLoading = false, - isInitialized = true, - conditionType = representative?.conditionType ?: DeliveryConditionType.INACTIVITY, - inactivityPeriod = representative?.inactivityPeriod ?: InactivityPeriod.ONE_YEAR, - conditions = response.conditions, - ) - } + dispatch(DeliveryConditionReducerEvent.Loaded(response.conditions)) }.onFailure { - _uiState.update { it.copy(isLoading = false, error = DeliveryConditionError.LOAD_FAILED) } + dispatch(DeliveryConditionReducerEvent.LoadFailed) } } } - fun onConditionTypeSelected(index: Int) { + private fun onConditionTypeSelected(index: Int) { val conditionType = if (index == 1) DeliveryConditionType.RECEIVER_REQUEST else DeliveryConditionType.INACTIVITY - _uiState.update { it.copy(conditionType = conditionType) } + dispatch(DeliveryConditionReducerEvent.ConditionSelected(conditionType)) } - fun onSave() { - val state = _uiState.value - if (!state.isInitialized || state.isSaving) return + private fun onSave() { + val state = currentState + if (!state.isInitialized || state.isSaving || state.pendingEvent != null) return val hasTimeLetterCondition = state.conditions.any { it.contentType == DeliveryContentType.TIME_LETTER } @@ -110,15 +135,14 @@ class DeliveryConditionViewModel } } + dispatch(DeliveryConditionReducerEvent.Saving) viewModelScope.launch { - _uiState.update { it.copy(isSaving = true) } runCatchingCancellable { receiverRepository.updateReceiverDeliveryConditions(receiverId, updatedConditions) }.onSuccess { response -> - _uiState.update { it.copy(isSaving = false, conditions = response.conditions) } - _saveSuccess.send(Unit) + dispatch(DeliveryConditionReducerEvent.Saved(response.conditions)) }.onFailure { - _uiState.update { it.copy(isSaving = false, error = DeliveryConditionError.SAVE_FAILED) } + dispatch(DeliveryConditionReducerEvent.SaveFailed) } } } diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditContract.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditContract.kt new file mode 100644 index 000000000..3084d5a82 --- /dev/null +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditContract.kt @@ -0,0 +1,36 @@ +package com.afternote.feature.setting.presentation.profile + +import com.afternote.core.ui.mvi.MviIntent +import com.afternote.core.ui.mvi.ReducerEvent + +internal sealed interface ProfileEditIntent : MviIntent { + data class UpdateProfile( + val name: String, + val phone: String, + ) : ProfileEditIntent + + /** 화면이 처리한 [event] 를 되돌려준다. 그사이 다른 신호가 올라왔으면 그 신호는 남는다. */ + data class ConsumeEvent( + val event: ProfileEditEvent, + ) : ProfileEditIntent +} + +internal sealed interface ProfileEditReducerEvent : ReducerEvent { + data class Loaded( + val name: String, + val phone: String, + val email: String, + ) : ProfileEditReducerEvent + + data object LoadFailed : ProfileEditReducerEvent + + data object Updating : ProfileEditReducerEvent + + data object UpdateSucceeded : ProfileEditReducerEvent + + data object UpdateFailed : ProfileEditReducerEvent + + data class EventConsumed( + val event: ProfileEditEvent, + ) : ProfileEditReducerEvent +} diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditScreen.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditScreen.kt index 5bb9aff05..11f779540 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditScreen.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditScreen.kt @@ -17,43 +17,61 @@ import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.afternote.core.ui.AfternoteTextField import com.afternote.core.ui.button.AfternoteButton import com.afternote.core.ui.button.AfternoteButtonType +import com.afternote.core.ui.mvi.ObserveSignal import com.afternote.core.ui.theme.AfternoteDesign import com.afternote.core.ui.topbar.DetailTopBar -import com.afternote.feature.setting.presentation.R import com.afternote.feature.setting.presentation.shared.component.ProfilePhotoWithAddBadge @Composable -fun ProfileEditScreen( +internal fun ProfileEditScreen( onBackClick: () -> Unit, onWithdrawGuideClick: () -> Unit, modifier: Modifier = Modifier, viewModel: ProfileEditViewModel = hiltViewModel(), ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() - val currentOnBackClick by rememberUpdatedState(onBackClick) - LaunchedEffect(Unit) { - viewModel.events.collect { event -> + (uiState as? ProfileEditUiState.Success)?.pendingEvent?.let { pendingEvent -> + ObserveSignal( + signal = pendingEvent, + // 소비 Intent 에 처리한 신호를 실어, 늦게 도착한 소비가 새로 올라온 다른 신호를 지우지 않게 한다. + consumed = ProfileEditIntent.ConsumeEvent(pendingEvent), + onIntent = viewModel::onIntent, + ) { event -> when (event) { - ProfileEditEvent.UpdateSuccess -> currentOnBackClick() + ProfileEditEvent.UpdateSuccess -> onBackClick() ProfileEditEvent.UpdateFailure -> Unit } } } + ProfileEditContent( + uiState = uiState, + onIntent = viewModel::onIntent, + onBackClick = onBackClick, + onWithdrawGuideClick = onWithdrawGuideClick, + modifier = modifier, + ) +} + +@Composable +private fun ProfileEditContent( + uiState: ProfileEditUiState, + onIntent: (ProfileEditIntent) -> Unit, + onBackClick: () -> Unit, + onWithdrawGuideClick: () -> Unit, + modifier: Modifier = Modifier, +) { Scaffold( topBar = { DetailTopBar( @@ -80,7 +98,7 @@ fun ProfileEditScreen( is ProfileEditUiState.Success -> { ProfileEditForm( state = state, - onUpdateClick = viewModel::updateProfile, + onUpdateClick = { name, phone -> onIntent(ProfileEditIntent.UpdateProfile(name, phone)) }, onWithdrawGuideClick = onWithdrawGuideClick, modifier = Modifier.padding(innerPadding), ) diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditUiState.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditUiState.kt index bc4fbf71d..f6465f472 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditUiState.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditUiState.kt @@ -1,6 +1,8 @@ package com.afternote.feature.setting.presentation.profile -sealed interface ProfileEditUiState { +import com.afternote.core.ui.mvi.UiState + +internal sealed interface ProfileEditUiState : UiState { data object Loading : ProfileEditUiState data class Success( @@ -8,12 +10,13 @@ sealed interface ProfileEditUiState { val phone: String, val email: String, val isUpdating: Boolean = false, + val pendingEvent: ProfileEditEvent? = null, ) : ProfileEditUiState data object Error : ProfileEditUiState } -sealed interface ProfileEditEvent { +internal sealed interface ProfileEditEvent { data object UpdateSuccess : ProfileEditEvent data object UpdateFailure : ProfileEditEvent diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditViewModel.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditViewModel.kt index 6fabdd298..f442a5586 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditViewModel.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditViewModel.kt @@ -1,68 +1,101 @@ package com.afternote.feature.setting.presentation.profile -import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.afternote.core.common.result.runCatchingCancellable import com.afternote.core.domain.repository.MyProfileRepository +import com.afternote.core.ui.mvi.MviViewModel import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.receiveAsFlow -import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel -class ProfileEditViewModel +internal class ProfileEditViewModel @Inject constructor( private val myProfileRepository: MyProfileRepository, - ) : ViewModel() { - private val _uiState = MutableStateFlow(ProfileEditUiState.Loading) - val uiState = _uiState.asStateFlow() - - private val _events = Channel(Channel.BUFFERED) - val events = _events.receiveAsFlow() - + ) : MviViewModel(ProfileEditUiState.Loading) { init { loadProfile() } + override fun onIntent(intent: ProfileEditIntent) { + when (intent) { + is ProfileEditIntent.UpdateProfile -> updateProfile(intent.name, intent.phone) + is ProfileEditIntent.ConsumeEvent -> dispatch(ProfileEditReducerEvent.EventConsumed(intent.event)) + } + } + + override fun reduce( + state: ProfileEditUiState, + event: ProfileEditReducerEvent, + ): ProfileEditUiState = + when (event) { + is ProfileEditReducerEvent.Loaded -> { + ProfileEditUiState.Success(name = event.name, phone = event.phone, email = event.email) + } + + ProfileEditReducerEvent.LoadFailed -> { + ProfileEditUiState.Error + } + + ProfileEditReducerEvent.Updating -> { + state.updateSuccess { it.copy(isUpdating = true, pendingEvent = null) } + } + + // 성공 뒤 화면이 닫히는 사이 재제출을 막도록 isUpdating 을 유지한다. + ProfileEditReducerEvent.UpdateSucceeded -> { + state.updateSuccess { it.copy(pendingEvent = ProfileEditEvent.UpdateSuccess) } + } + + ProfileEditReducerEvent.UpdateFailed -> { + state.updateSuccess { it.copy(isUpdating = false, pendingEvent = ProfileEditEvent.UpdateFailure) } + } + + is ProfileEditReducerEvent.EventConsumed -> { + state.updateSuccess { if (it.pendingEvent == event.event) it.copy(pendingEvent = null) else it } + } + } + private fun loadProfile() { viewModelScope.launch { - runCatching { myProfileRepository.getMyProfile() } + runCatchingCancellable { myProfileRepository.getMyProfile() } .onSuccess { user -> - _uiState.value = - ProfileEditUiState.Success( + dispatch( + ProfileEditReducerEvent.Loaded( name = user.name, phone = user.phone.orEmpty(), email = user.email, - ) + ), + ) }.onFailure { - _uiState.value = ProfileEditUiState.Error + dispatch(ProfileEditReducerEvent.LoadFailed) } } } - fun updateProfile( + private fun updateProfile( name: String, phone: String, ) { - val current = _uiState.value as? ProfileEditUiState.Success ?: return - _uiState.update { current.copy(isUpdating = true) } + val current = currentState as? ProfileEditUiState.Success ?: return + if (current.isUpdating) return + dispatch(ProfileEditReducerEvent.Updating) viewModelScope.launch { - runCatching { + runCatchingCancellable { myProfileRepository.updateMyProfile( name = name.takeIf { it.isNotBlank() }, phone = phone.takeIf { it.isNotBlank() }, profileImageUrl = null, ) }.onSuccess { - _events.send(ProfileEditEvent.UpdateSuccess) + dispatch(ProfileEditReducerEvent.UpdateSucceeded) }.onFailure { - _uiState.update { current.copy(isUpdating = false) } - _events.send(ProfileEditEvent.UpdateFailure) + dispatch(ProfileEditReducerEvent.UpdateFailed) } } } } + +private inline fun ProfileEditUiState.updateSuccess( + transform: (ProfileEditUiState.Success) -> ProfileEditUiState.Success, +): ProfileEditUiState = if (this is ProfileEditUiState.Success) transform(this) else this diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverEditContract.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverEditContract.kt new file mode 100644 index 000000000..6127d5ad4 --- /dev/null +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverEditContract.kt @@ -0,0 +1,38 @@ +package com.afternote.feature.setting.presentation.receiver + +import com.afternote.core.model.user.ReceiverDetail +import com.afternote.core.ui.UiText +import com.afternote.core.ui.mvi.MviIntent +import com.afternote.core.ui.mvi.ReducerEvent + +internal sealed interface ReceiverEditIntent : MviIntent { + data class Update( + val name: String, + val relation: String, + val phone: String, + val email: String, + val message: String, + ) : ReceiverEditIntent + + data object ConsumeSuccess : ReceiverEditIntent +} + +internal sealed interface ReceiverEditReducerEvent : ReducerEvent { + data class Loaded( + val receiver: ReceiverDetail, + ) : ReceiverEditReducerEvent + + data class LoadFailed( + val message: UiText, + ) : ReceiverEditReducerEvent + + data object Saving : ReceiverEditReducerEvent + + data object Saved : ReceiverEditReducerEvent + + data class SaveFailed( + val message: UiText, + ) : ReceiverEditReducerEvent + + data object SuccessConsumed : ReceiverEditReducerEvent +} diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverEditScreen.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverEditScreen.kt index d401aec77..4bfae8840 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverEditScreen.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverEditScreen.kt @@ -1,34 +1,32 @@ package com.afternote.feature.setting.presentation.receiver import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.afternote.core.ui.mvi.ObserveSignal @Composable -fun ReceiverEditScreen( +internal fun ReceiverEditScreen( onBackClick: () -> Unit, onEditSuccess: () -> Unit, viewModel: ReceiverEditViewModel, modifier: Modifier = Modifier, ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() - val currentOnEditSuccess by rememberUpdatedState(onEditSuccess) - - LaunchedEffect(Unit) { - viewModel.events.collect { event -> - when (event) { - ReceiverEditEvent.EditSuccess -> currentOnEditSuccess() - } - } - } + ObserveSignal( + signal = uiState.pendingEvent, + consumed = ReceiverEditIntent.ConsumeSuccess, + onIntent = viewModel::onIntent, + onSignal = { onEditSuccess() }, + ) ReceiverEditContent( uiState = uiState, onBackClick = onBackClick, - onRegister = viewModel::update, + onRegister = { name, relation, phone, email, message -> + viewModel.onIntent(ReceiverEditIntent.Update(name, relation, phone, email, message)) + }, modifier = modifier, ) } diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverEditUiState.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverEditUiState.kt index 376bb44a0..c4fc89585 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverEditUiState.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverEditUiState.kt @@ -2,14 +2,16 @@ package com.afternote.feature.setting.presentation.receiver import com.afternote.core.model.user.ReceiverDetail import com.afternote.core.ui.UiText +import com.afternote.core.ui.mvi.UiState -data class ReceiverEditUiState( +internal data class ReceiverEditUiState( val isLoading: Boolean = true, val isSaving: Boolean = false, val receiver: ReceiverDetail? = null, val errorMessage: UiText? = null, -) + val pendingEvent: ReceiverEditEvent? = null, +) : UiState -sealed interface ReceiverEditEvent { +internal sealed interface ReceiverEditEvent { data object EditSuccess : ReceiverEditEvent } diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverEditViewModel.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverEditViewModel.kt index 6928e2707..0e377b850 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverEditViewModel.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverEditViewModel.kt @@ -1,41 +1,46 @@ package com.afternote.feature.setting.presentation.receiver -import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.afternote.core.common.result.runCatchingCancellable import com.afternote.core.domain.repository.UserReceiverRepository import com.afternote.core.ui.UiText +import com.afternote.core.ui.mvi.MviViewModel import com.afternote.feature.setting.presentation.R import com.afternote.feature.setting.presentation.navigation.SettingRoute import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.receiveAsFlow -import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -/** - * 수정 대상 [SettingRoute.RecipientEditRoute] 는 assisted 로 받는다 — Nav3 entry 에는 Nav2 의 - * `savedStateHandle.toRoute()` 자동 채움이 없다 (#1695). - */ @HiltViewModel(assistedFactory = ReceiverEditViewModel.Factory::class) -class ReceiverEditViewModel +internal class ReceiverEditViewModel @AssistedInject constructor( @Assisted route: SettingRoute.RecipientEditRoute, private val receiverRepository: UserReceiverRepository, - ) : ViewModel() { + ) : MviViewModel(ReceiverEditUiState()) { private val receiverId = route.receiverId - private val _uiState = MutableStateFlow(ReceiverEditUiState()) - val uiState = _uiState.asStateFlow() + override fun onIntent(intent: ReceiverEditIntent) { + when (intent) { + is ReceiverEditIntent.Update -> update(intent.name, intent.relation, intent.phone, intent.email, intent.message) + ReceiverEditIntent.ConsumeSuccess -> dispatch(ReceiverEditReducerEvent.SuccessConsumed) + } + } - private val _events = Channel(Channel.BUFFERED) - val events = _events.receiveAsFlow() + override fun reduce( + state: ReceiverEditUiState, + event: ReceiverEditReducerEvent, + ): ReceiverEditUiState = + when (event) { + is ReceiverEditReducerEvent.Loaded -> state.copy(isLoading = false, receiver = event.receiver) + is ReceiverEditReducerEvent.LoadFailed -> state.copy(isLoading = false, errorMessage = event.message) + ReceiverEditReducerEvent.Saving -> state.copy(isSaving = true, errorMessage = null) + ReceiverEditReducerEvent.Saved -> state.copy(isSaving = false, pendingEvent = ReceiverEditEvent.EditSuccess) + is ReceiverEditReducerEvent.SaveFailed -> state.copy(isSaving = false, errorMessage = event.message) + ReceiverEditReducerEvent.SuccessConsumed -> state.copy(pendingEvent = null) + } init { loadReceiver() @@ -45,25 +50,23 @@ class ReceiverEditViewModel viewModelScope.launch { runCatchingCancellable { receiverRepository.getReceiverDetail(receiverId) } .onSuccess { receiver -> - _uiState.update { it.copy(isLoading = false, receiver = receiver) } + dispatch(ReceiverEditReducerEvent.Loaded(receiver)) }.onFailure { - _uiState.update { - it.copy(isLoading = false, errorMessage = UiText.Resource(R.string.setting_receiver_load_failed)) - } + dispatch(ReceiverEditReducerEvent.LoadFailed(UiText.Resource(R.string.setting_receiver_load_failed))) } } } - fun update( + private fun update( name: String, relation: String, phone: String, email: String, message: String, ) { - if (_uiState.value.isSaving) return + if (currentState.isSaving || currentState.pendingEvent != null) return if (!email.isValidReceiverEmail()) { - _uiState.update { it.copy(errorMessage = UiText.Resource(R.string.setting_receiver_email_invalid)) } + dispatch(ReceiverEditReducerEvent.SaveFailed(UiText.Resource(R.string.setting_receiver_email_invalid))) return } val phoneValidation = phone.validateReceiverPhone(isRequired = true) @@ -74,13 +77,13 @@ class ReceiverEditViewModel } else { R.string.setting_receiver_phone_invalid } - _uiState.update { it.copy(errorMessage = UiText.Resource(messageRes)) } + dispatch(ReceiverEditReducerEvent.SaveFailed(UiText.Resource(messageRes))) return } - _uiState.update { it.copy(isSaving = true, errorMessage = null) } + dispatch(ReceiverEditReducerEvent.Saving) viewModelScope.launch { - val receiverUpdateResult = + val basicInfoResult = runCatchingCancellable { receiverRepository.updateReceiver( receiverId = receiverId, @@ -90,31 +93,16 @@ class ReceiverEditViewModel email = email.trim(), ) } - receiverUpdateResult.exceptionOrNull()?.let { error -> - _uiState.update { - it.copy( - isSaving = false, - errorMessage = error.toReceiverFailureMessage(R.string.setting_receiver_edit_failed), - ) - } + basicInfoResult.exceptionOrNull()?.let { failure -> + dispatch(ReceiverEditReducerEvent.SaveFailed(failure.toReceiverFailureMessage(R.string.setting_receiver_edit_failed))) return@launch } - runCatchingCancellable { - receiverRepository.updateReceiverMessage( - receiverId = receiverId, - message = message, - ) + receiverRepository.updateReceiverMessage(receiverId = receiverId, message = message) }.onSuccess { - _uiState.update { it.copy(isSaving = false) } - _events.send(ReceiverEditEvent.EditSuccess) + dispatch(ReceiverEditReducerEvent.Saved) }.onFailure { - _uiState.update { - it.copy( - isSaving = false, - errorMessage = UiText.Resource(R.string.setting_receiver_message_update_partial_failed), - ) - } + dispatch(ReceiverEditReducerEvent.SaveFailed(UiText.Resource(R.string.setting_receiver_message_update_partial_failed))) } } } diff --git a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingAccountSecurityTest.kt b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingAccountSecurityTest.kt index cb449670e..2b7ae6893 100644 --- a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingAccountSecurityTest.kt +++ b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingAccountSecurityTest.kt @@ -30,6 +30,7 @@ import com.afternote.feature.setting.presentation.applock.AppLockSetupScreen import com.afternote.feature.setting.presentation.applock.AppLockSetupViewModel import com.afternote.feature.setting.presentation.applock.PinSetupStep import com.afternote.feature.setting.presentation.delivery.DeliveryConditionError +import com.afternote.feature.setting.presentation.delivery.DeliveryConditionIntent import com.afternote.feature.setting.presentation.delivery.DeliveryConditionViewModel import com.afternote.feature.setting.presentation.home.SettingUiState import com.afternote.feature.setting.presentation.home.SettingViewModel @@ -39,6 +40,7 @@ import com.afternote.feature.setting.presentation.navigation.SettingRoute import com.afternote.feature.setting.presentation.passkey.PassKeyListScreen import com.afternote.feature.setting.presentation.passkey.PassKeyScreen import com.afternote.feature.setting.presentation.profile.ProfileEditEvent +import com.afternote.feature.setting.presentation.profile.ProfileEditIntent import com.afternote.feature.setting.presentation.profile.ProfileEditScreen import com.afternote.feature.setting.presentation.profile.ProfileEditUiState import com.afternote.feature.setting.presentation.profile.ProfileEditViewModel @@ -87,7 +89,7 @@ class SettingAccountSecurityTest { } composeRule.onNodeWithText("프로필을 불러올 수 없습니다.").assertIsDisplayed() - composeRule.runOnIdle { loadFailureViewModel.updateProfile("새 이름", "01012345678") } + composeRule.runOnIdle { loadFailureViewModel.onIntent(ProfileEditIntent.UpdateProfile("새 이름", "01012345678")) } assertTrue(loadFailureRepository.profileUpdateCalls.isEmpty()) val updateFailureRepository = settingContractProfileRepository() @@ -97,8 +99,11 @@ class SettingAccountSecurityTest { } updateFailureRepository.onUpdateMyProfile = { _, _, _ -> throw IllegalStateException("offline") } - composeRule.runOnIdle { updateFailureViewModel.updateProfile(" ", "") } - val event = awaitEvent(updateFailureViewModel.events) + composeRule.runOnIdle { updateFailureViewModel.onIntent(ProfileEditIntent.UpdateProfile(" ", "")) } + composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { + (updateFailureViewModel.uiState.value as? ProfileEditUiState.Success)?.pendingEvent != null + } + val event = (updateFailureViewModel.uiState.value as ProfileEditUiState.Success).pendingEvent composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { (updateFailureViewModel.uiState.value as? ProfileEditUiState.Success)?.isUpdating == false } @@ -212,8 +217,8 @@ class SettingAccountSecurityTest { } composeRule.runOnIdle { - viewModel.onConditionTypeSelected(index = 1) - viewModel.onSave() + viewModel.onIntent(DeliveryConditionIntent.SelectConditionType(index = 1)) + viewModel.onIntent(DeliveryConditionIntent.Save) } composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { viewModel.uiState.value.error == DeliveryConditionError.SAVE_FAILED diff --git a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingCompletionTest.kt b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingCompletionTest.kt index 602547e92..43ca95cbf 100644 --- a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingCompletionTest.kt +++ b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingCompletionTest.kt @@ -38,6 +38,7 @@ import com.afternote.feature.setting.domain.testing.FakeSettingAccountRepository import com.afternote.feature.setting.domain.testing.FakeSettingNotificationRepository import com.afternote.feature.setting.presentation.account.ConnectedAccountsViewModel import com.afternote.feature.setting.presentation.delivery.DeliveryConditionError +import com.afternote.feature.setting.presentation.delivery.DeliveryConditionIntent import com.afternote.feature.setting.presentation.delivery.DeliveryConditionViewModel import com.afternote.feature.setting.presentation.home.SettingUiState import com.afternote.feature.setting.presentation.home.SettingViewModel @@ -45,9 +46,11 @@ import com.afternote.feature.setting.presentation.home.WithdrawUiState import com.afternote.feature.setting.presentation.navigation.SettingRoute import com.afternote.feature.setting.presentation.notification.PushNotificationViewModel import com.afternote.feature.setting.presentation.profile.ProfileEditEvent +import com.afternote.feature.setting.presentation.profile.ProfileEditIntent import com.afternote.feature.setting.presentation.profile.ProfileEditUiState import com.afternote.feature.setting.presentation.profile.ProfileEditViewModel import com.afternote.feature.setting.presentation.receiver.ReceiverEditEvent +import com.afternote.feature.setting.presentation.receiver.ReceiverEditIntent import com.afternote.feature.setting.presentation.receiver.ReceiverEditScreen import com.afternote.feature.setting.presentation.receiver.ReceiverEditViewModel import com.afternote.feature.setting.presentation.receiver.ReceiverRegisterEvent @@ -97,7 +100,7 @@ class SettingCompletionTest { } composeRule.runOnIdle { - viewModel.updateProfile(name = "새 이름", phone = "01098765432") + viewModel.onIntent(ProfileEditIntent.UpdateProfile(name = "새 이름", phone = "01098765432")) } composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { repository.profileUpdateCalls.size == 1 @@ -113,7 +116,11 @@ class SettingCompletionTest { Result.success(COMPLETION_DEFAULT_USER.copy(name = "새 이름", phone = "01098765432")), ) - assertEquals(ProfileEditEvent.UpdateSuccess, awaitEvent(viewModel.events)) + composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { + (viewModel.uiState.value as? ProfileEditUiState.Success)?.pendingEvent == ProfileEditEvent.UpdateSuccess + } + composeRule.runOnIdle { viewModel.onIntent(ProfileEditIntent.ConsumeEvent(ProfileEditEvent.UpdateSuccess)) } + assertEquals(null, (viewModel.uiState.value as ProfileEditUiState.Success).pendingEvent) } @Test @@ -398,12 +405,14 @@ class SettingCompletionTest { message = "수정한 마지막 인사말", ) val update: () -> Unit = { - viewModel.update( - name = expectedBasicCall.name, - relation = expectedBasicCall.relation, - phone = expectedBasicCall.phone, - email = expectedBasicCall.email, - message = expectedMessageCall.message, + viewModel.onIntent( + ReceiverEditIntent.Update( + name = expectedBasicCall.name, + relation = expectedBasicCall.relation, + phone = expectedBasicCall.phone, + email = expectedBasicCall.email, + message = expectedMessageCall.message, + ), ) } @@ -450,7 +459,11 @@ class SettingCompletionTest { } finalMessageGate.complete(Result.success(Unit)) - assertEquals(ReceiverEditEvent.EditSuccess, awaitEvent(viewModel.events)) + composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { + viewModel.uiState.value.pendingEvent == ReceiverEditEvent.EditSuccess + } + composeRule.runOnIdle { viewModel.onIntent(ReceiverEditIntent.ConsumeSuccess) } + assertEquals(null, viewModel.uiState.value.pendingEvent) composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { !viewModel.uiState.value.isSaving } @@ -546,8 +559,8 @@ class SettingCompletionTest { val expectedCall = CompletionDeliveryUpdateCall(RECEIVER_ID, expectedConditions) composeRule.runOnIdle { - viewModel.onConditionTypeSelected(index = 1) - viewModel.onSave() + viewModel.onIntent(DeliveryConditionIntent.SelectConditionType(index = 1)) + viewModel.onIntent(DeliveryConditionIntent.Save) } composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { repository.deliveryUpdateCalls.size == 1 @@ -563,7 +576,7 @@ class SettingCompletionTest { assertFalse(viewModel.uiState.value.isSaving) assertEquals(initialConditions, viewModel.uiState.value.conditions) - composeRule.runOnIdle { viewModel.onSave() } + composeRule.runOnIdle { viewModel.onIntent(DeliveryConditionIntent.Save) } composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { repository.deliveryUpdateCalls.size == 2 } @@ -584,7 +597,9 @@ class SettingCompletionTest { ), ) - assertEquals(Unit, awaitEvent(viewModel.saveSuccess)) + composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { viewModel.uiState.value.pendingEvent != null } + composeRule.runOnIdle { viewModel.onIntent(DeliveryConditionIntent.ConsumeSuccess) } + assertEquals(null, viewModel.uiState.value.pendingEvent) composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { viewModel.uiState.value.conditions == serverConditions } diff --git a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditMviTest.kt b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditMviTest.kt new file mode 100644 index 000000000..7ec329092 --- /dev/null +++ b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditMviTest.kt @@ -0,0 +1,126 @@ +package com.afternote.feature.setting.presentation.profile + +import com.afternote.core.domain.testing.FakeMyProfileRepository +import com.afternote.core.model.user.User +import kotlinx.coroutines.CompletableDeferred +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.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import kotlin.coroutines.cancellation.CancellationException + +@OptIn(ExperimentalCoroutinesApi::class) +class ProfileEditMviTest { + private val updateGates = ArrayDeque>>() + + private val repository = + FakeMyProfileRepository( + profile = PROFILE, + onUpdateMyProfile = { _, _, _ -> updateGates.removeFirst().await().getOrThrow() }, + ) + + @Before + fun setUp() { + Dispatchers.setMain(UnconfinedTestDispatcher()) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `load fills form and load failure becomes error`() { + assertEquals( + ProfileEditUiState.Success(name = "홍길동", phone = "", email = "hong@afternote.local"), + ProfileEditViewModel(repository).uiState.value, + ) + + val failing = FakeMyProfileRepository(onGetMyProfile = { error("load failed") }) + assertEquals(ProfileEditUiState.Error, ProfileEditViewModel(failing).uiState.value) + } + + @Test + fun `update while in flight or after success does not submit again`() { + val gate = enqueueUpdate() + val viewModel = ProfileEditViewModel(repository) + + viewModel.onIntent(ProfileEditIntent.UpdateProfile(name = "새 이름", phone = "")) + viewModel.onIntent(ProfileEditIntent.UpdateProfile(name = "또 이름", phone = "")) + + assertTrue(viewModel.success().isUpdating) + assertEquals( + listOf(FakeMyProfileRepository.ProfileUpdateCall(name = "새 이름", phone = null, profileImageUrl = null)), + repository.profileUpdateCalls, + ) + + gate.complete(Result.success(PROFILE)) + viewModel.onIntent(ProfileEditIntent.UpdateProfile(name = "또 이름", phone = "")) + + assertEquals(ProfileEditEvent.UpdateSuccess, viewModel.success().pendingEvent) + assertEquals(1, repository.profileUpdateCalls.size) + } + + @Test + fun `success signal stays until consumed and consuming clears it once`() { + enqueueUpdate().complete(Result.success(PROFILE)) + val viewModel = ProfileEditViewModel(repository) + + viewModel.onIntent(ProfileEditIntent.UpdateProfile(name = "새 이름", phone = "01012345678")) + + assertEquals(ProfileEditEvent.UpdateSuccess, viewModel.success().pendingEvent) + + viewModel.onIntent(ProfileEditIntent.ConsumeEvent(ProfileEditEvent.UpdateFailure)) + + assertEquals(ProfileEditEvent.UpdateSuccess, viewModel.success().pendingEvent) + + viewModel.onIntent(ProfileEditIntent.ConsumeEvent(ProfileEditEvent.UpdateSuccess)) + + assertNull(viewModel.success().pendingEvent) + assertTrue(viewModel.success().isUpdating) + } + + @Test + fun `failure allows retry and a late consume does not clear the newer signal`() { + enqueueUpdate().complete(Result.failure(IllegalStateException("update failed"))) + val viewModel = ProfileEditViewModel(repository) + + viewModel.onIntent(ProfileEditIntent.UpdateProfile(name = "새 이름", phone = "")) + + assertEquals(ProfileEditEvent.UpdateFailure, viewModel.success().pendingEvent) + assertFalse(viewModel.success().isUpdating) + + enqueueUpdate().complete(Result.success(PROFILE)) + viewModel.onIntent(ProfileEditIntent.UpdateProfile(name = "새 이름", phone = "")) + viewModel.onIntent(ProfileEditIntent.ConsumeEvent(ProfileEditEvent.UpdateFailure)) + + assertEquals(2, repository.profileUpdateCalls.size) + assertEquals(ProfileEditEvent.UpdateSuccess, viewModel.success().pendingEvent) + } + + @Test + fun `cancelled update is not reported as failure`() { + enqueueUpdate().completeExceptionally(CancellationException("cancelled")) + val viewModel = ProfileEditViewModel(repository) + + viewModel.onIntent(ProfileEditIntent.UpdateProfile(name = "새 이름", phone = "")) + + assertNull(viewModel.success().pendingEvent) + } + + private fun enqueueUpdate(): CompletableDeferred> = CompletableDeferred>().also(updateGates::addLast) + + private fun ProfileEditViewModel.success(): ProfileEditUiState.Success = uiState.value as ProfileEditUiState.Success + + private companion object { + val PROFILE = User("홍길동", "hong@afternote.local", null, null) + } +} diff --git a/konsist/src/test/kotlin/com/afternote/konsist/MviContractKonsistTest.kt b/konsist/src/test/kotlin/com/afternote/konsist/MviContractKonsistTest.kt index 88993df1b..26d7bb7cf 100644 --- a/konsist/src/test/kotlin/com/afternote/konsist/MviContractKonsistTest.kt +++ b/konsist/src/test/kotlin/com/afternote/konsist/MviContractKonsistTest.kt @@ -431,12 +431,9 @@ class MviContractKonsistTest { setOf( "com.afternote.feature.setting.presentation.account.ConnectedAccountsViewModel", "com.afternote.feature.setting.presentation.applock.AppLockSetupViewModel", - "com.afternote.feature.setting.presentation.delivery.DeliveryConditionViewModel", "com.afternote.feature.setting.presentation.home.SettingViewModel", "com.afternote.feature.setting.presentation.notification.PushNotificationViewModel", "com.afternote.feature.setting.presentation.passkey.PassKeyViewModel", - "com.afternote.feature.setting.presentation.profile.ProfileEditViewModel", - "com.afternote.feature.setting.presentation.receiver.ReceiverEditViewModel", "com.afternote.feature.setting.presentation.receiver.ReceiverRegisterViewModel", )