diff --git a/app/src/androidTest/java/com/afternote/afternote_fe/SettingEditorSaveSignalAndroidTest.kt b/app/src/androidTest/java/com/afternote/afternote_fe/SettingEditorSaveSignalAndroidTest.kt new file mode 100644 index 000000000..670eaaf97 --- /dev/null +++ b/app/src/androidTest/java/com/afternote/afternote_fe/SettingEditorSaveSignalAndroidTest.kt @@ -0,0 +1,241 @@ +package com.afternote.afternote_fe + +import androidx.activity.compose.setContent +import androidx.compose.ui.graphics.asAndroidBitmap +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.captureToImage +import androidx.compose.ui.test.hasScrollToIndexAction +import androidx.compose.ui.test.hasSetTextAction +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.isEnabled +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import androidx.compose.ui.test.performScrollToNode +import androidx.compose.ui.test.performTextReplacement +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.afternote.afternote_fe.test.FailureArtifactRule +import com.afternote.core.domain.repository.UserRepository +import com.afternote.core.domain.testing.FakeUserRepository +import com.afternote.core.model.user.ReceiverDetail +import com.afternote.core.ui.navigation.FeatureNavigationCallbacks +import com.afternote.core.ui.theme.AfternoteTheme +import com.afternote.feature.setting.presentation.navigation.SettingExternalActions +import com.afternote.feature.setting.presentation.navigation.SettingNavHost +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import java.io.IOException +import javax.inject.Inject + +/** + * 설정 편집 화면의 저장 신호를 실제 로컬 스택에서 본다 (#2179). + * + * reducer 가 세운 완료 신호(수신자 수정의 `pendingEvent`, 전달 조건의 `isSaveSuccessPending`)가 화면에서 한 번만 + * 소비되어 스택을 정확히 한 칸 내리는지, 실패와 입력 검증은 화면을 닫지 않는지를 잰다. 신호 소비가 + * 두 번 일어나면 한 칸이 아니라 설정 홈까지 내려가고, 소비가 안 되면 편집 화면에 남는다. 둘 다 + * ViewModel 단위 테스트로는 보이지 않는 자리다. + */ +@HiltAndroidTest +@RunWith(AndroidJUnit4::class) +class SettingEditorSaveSignalAndroidTest { + @get:Rule(order = 0) + val hiltRule = HiltAndroidRule(this) + + @get:Rule(order = 1) + val composeRule = createAndroidComposeRule() + + @get:Rule(order = 2) + val failureArtifactRule = + FailureArtifactRule { + composeRule.onRoot().captureToImage().asAndroidBitmap() + } + + @Inject + lateinit var userRepository: UserRepository + + private val fakeUserRepository: FakeUserRepository + get() = userRepository as FakeUserRepository + + /** 로컬 스택 바닥에서 셸로 나간 횟수 — 설정 안에서 도는 동안은 0 이어야 한다. */ + private var exits = 0 + + @Before + fun setUp() { + hiltRule.inject() + fakeUserRepository.receiverDetails[RECEIVER_ID] = + ReceiverDetail( + receiverId = RECEIVER_ID, + name = RECEIVER_NAME, + relation = "가족", + phone = "01012345678", + email = "kim@afternote.local", + dailyQuestionCount = 0, + timeLetterCount = 0, + afterNoteCount = 0, + message = "잘 지내", + ) + fakeUserRepository.onGetReceiverDetail = null + } + + @Test + fun receiverEdit_saveSuccessPopsExactlyOneScreenToReceiverList() { + fakeUserRepository.onUpdateReceiver = null + fakeUserRepository.onUpdateReceiverMessage = null + openReceiverEdit() + val updatesBefore = fakeUserRepository.receiverUpdateCalls.size + val messagesBefore = fakeUserRepository.receiverMessageCalls.size + + composeRule.onNode(hasSetTextAction() and hasText(RECEIVER_NAME)).performTextReplacement("김수정") + composeRule.onNodeWithText(SAVE_ACTION).assertIsEnabled().performClick() + + waitUntilGone(EDIT_TITLE) + // 한 칸만 내려와 목록이 보인다 — 신호가 두 번 소비되면 설정 홈까지 내려간다. + composeRule.onNodeWithText("김수정").assertIsDisplayed() + composeRule.onAllNodes(hasText("프로필 수정")).assertCountEquals(0) + assertEquals(1, fakeUserRepository.receiverUpdateCalls.size - updatesBefore) + assertEquals(1, fakeUserRepository.receiverMessageCalls.size - messagesBefore) + assertEquals(0, exits) + } + + @Test + fun receiverEdit_saveFailureShowsMessageAndStaysOnEdit() { + fakeUserRepository.onUpdateReceiver = { _, _, _, _, _ -> throw IOException("offline") } + openReceiverEdit() + val updatesBefore = fakeUserRepository.receiverUpdateCalls.size + val messagesBefore = fakeUserRepository.receiverMessageCalls.size + + composeRule.onNodeWithText(SAVE_ACTION).performClick() + + composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { + composeRule.runOnIdle { fakeUserRepository.receiverUpdateCalls.size > updatesBefore } + } + // 오류는 LazyColumn의 마지막 항목이므로 작은 화면에서도 스크롤해 실제 표시를 확인한다. + composeRule.onNode(hasScrollToIndexAction()).performScrollToNode(hasText(EDIT_FAILED_MESSAGE)) + composeRule.onNodeWithText(EDIT_FAILED_MESSAGE).assertIsDisplayed() + // 실패는 화면을 닫지 않고, 저장 중 잠금도 풀려 다시 누를 수 있다. + composeRule.onNodeWithText(EDIT_TITLE).assertIsDisplayed() + composeRule.onNodeWithText(SAVE_ACTION).assertIsEnabled() + assertEquals(1, fakeUserRepository.receiverUpdateCalls.size - updatesBefore) + assertEquals(0, fakeUserRepository.receiverMessageCalls.size - messagesBefore) + assertEquals(0, exits) + } + + @Test + fun receiverEdit_invalidEmailOrPhoneBlocksSaveWithoutRequest() { + openReceiverEdit() + val updatesBefore = fakeUserRepository.receiverUpdateCalls.size + + composeRule.onNode(hasSetTextAction() and hasText("kim@afternote.local")).performTextReplacement("kim@afternote") + composeRule.onNodeWithText("올바른 이메일 주소를 입력해주세요.").assertIsDisplayed() + composeRule.onNodeWithText(SAVE_ACTION).assertIsNotEnabled().performClick() + + composeRule.onNode(hasSetTextAction() and hasText("kim@afternote")).performTextReplacement("kim@afternote.local") + composeRule.onNode(hasSetTextAction() and hasText("010", substring = true)).performTextReplacement("0101234") + composeRule.onNodeWithText("올바른 연락처를 입력해주세요.").assertIsDisplayed() + composeRule.onNodeWithText(SAVE_ACTION).assertIsNotEnabled().performClick() + + composeRule.onNodeWithText(EDIT_TITLE).assertIsDisplayed() + assertEquals(0, composeRule.runOnIdle { fakeUserRepository.receiverUpdateCalls.size - updatesBefore }) + assertEquals(0, exits) + } + + @Test + fun deliveryCondition_saveSuccessPopsExactlyOneScreenToReceiverSelection() { + fakeUserRepository.onGetReceiverDeliveryConditions = null + fakeUserRepository.onUpdateReceiverDeliveryConditions = null + launchHost() + waitForSettingHomeContent() + val updatesBefore = fakeUserRepository.deliveryUpdateCalls.size + composeRule.onNodeWithText("사후 전달 조건").performScrollTo().performClick() + composeRule.onAllNodes(checkboxMatcher).run { + assertCountEquals(1) + get(0).performClick() + } + composeRule.onNodeWithText(RECEIVER_SELECT_DONE).performClick() + + // 조회가 끝나야 저장이 열린다. + composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { + composeRule.onAllNodes(hasText(DELIVERY_SAVE) and isEnabled()).fetchSemanticsNodes().isNotEmpty() + } + composeRule.onNodeWithText(DELIVERY_SAVE).performClick() + + waitUntilShown(RECEIVER_SELECT_DONE) + // 한 칸만 내려와 수신자 선택이 보인다 — 신호가 두 번 소비되면 설정 홈까지 내려간다. + composeRule.onAllNodes(hasText("프로필 수정")).assertCountEquals(0) + assertEquals(1, fakeUserRepository.deliveryUpdateCalls.size - updatesBefore) + assertEquals(RECEIVER_ID, fakeUserRepository.deliveryUpdateCalls[updatesBefore].receiverId) + assertEquals(0, exits) + } + + /** 설정 홈 → 수신자 목록 → 김수신 행으로 수정 화면을 연다. 프리필이 끝나야 폼이 그려진다. */ + private fun openReceiverEdit() { + launchHost() + waitForSettingHomeContent() + composeRule.onAllNodes(hasText("수신자 목록")).run { + assertCountEquals(2) + get(1).performScrollTo().performClick() + } + composeRule.onNodeWithText(RECEIVER_NAME).performClick() + composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { + composeRule.onAllNodes(hasSetTextAction() and hasText(RECEIVER_NAME)).fetchSemanticsNodes().isNotEmpty() + } + } + + private fun launchHost() { + composeRule.activityRule.scenario.onActivity { activity -> + activity.setContent { + AfternoteTheme { + SettingNavHost( + navigationCallbacks = FeatureNavigationCallbacks { exits += 1 }, + externalActions = + object : SettingExternalActions { + override fun onLogoutSuccess() = Unit + + override fun onWithdrawSuccess() = Unit + }, + startWithRecipientRegistration = false, + ) + } + } + } + } + + private fun waitForSettingHomeContent() = waitUntilShown("프로필 수정") + + private fun waitUntilShown(text: String) { + composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { + composeRule.onAllNodes(hasText(text)).fetchSemanticsNodes().isNotEmpty() + } + } + + private fun waitUntilGone(text: String) { + composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { + composeRule.onAllNodes(hasText(text)).fetchSemanticsNodes().isEmpty() + } + } + + private companion object { + const val TIMEOUT_MILLIS = 10_000L + const val RECEIVER_ID = 7L + const val RECEIVER_NAME = "김수신" + const val EDIT_TITLE = "수신자 수정" + const val SAVE_ACTION = "수정" + const val EDIT_FAILED_MESSAGE = "수신자 수정에 실패했습니다. 잠시 후 다시 시도해주세요." + const val DELIVERY_SAVE = "저장" + const val RECEIVER_SELECT_DONE = "수신자 선택 완료하기" + val checkboxMatcher = SemanticsMatcher.expectValue(SemanticsProperties.Role, Role.Checkbox) + } +} 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..5806ff18c --- /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 type: DeliveryConditionType, + ) : 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 50dc3c669..d42a0abc5 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.ObserveFlag import com.afternote.core.ui.theme.AfternoteDesign import com.afternote.core.ui.topbar.DetailTopBar import com.afternote.feature.setting.presentation.R @@ -42,18 +41,19 @@ internal fun DeliveryConditionScreen( viewModel: DeliveryConditionViewModel, ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() - val currentOnSaveSuccess by rememberUpdatedState(onSaveSuccess) - - LaunchedEffect(Unit) { - viewModel.saveSuccess.collect { currentOnSaveSuccess() } - } + ObserveFlag( + raised = uiState.isSaveSuccessPending, + consumed = DeliveryConditionIntent.ConsumeSuccess, + onIntent = viewModel::onIntent, + onRaised = 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) }, ) } @@ -61,10 +61,12 @@ internal fun DeliveryConditionScreen( private fun DeliveryConditionContent( uiState: DeliveryConditionUiState, onBack: () -> Unit, - onConditionTypeSelect: (Int) -> Unit, + onConditionTypeSelect: (DeliveryConditionType) -> Unit, onLastGreetingEditClick: () -> Unit, onSave: () -> Unit, ) { + // 라디오 순서의 단일 출처. processingMethodItems 와 같은 순서로 둔다. + val conditionTypes = listOf(DeliveryConditionType.INACTIVITY, DeliveryConditionType.RECEIVER_REQUEST) val processingMethodItems = listOf( RadioGroupItem( @@ -83,7 +85,7 @@ private fun DeliveryConditionContent( title = stringResource(R.string.setting_recipient_after_delivery), onBackClick = onBack, actions = { - val isSaveEnabled = uiState.isInitialized && !uiState.isLoading && !uiState.isSaving + val isSaveEnabled = uiState.isInitialized && !uiState.isLoading && !uiState.isSaveLocked TextButton(onClick = onSave, enabled = isSaveEnabled) { Text( text = stringResource(R.string.setting_delivery_condition_save), @@ -127,8 +129,8 @@ private fun DeliveryConditionContent( Spacer(Modifier.height(28.dp)) RadioGroup( items = processingMethodItems, - selectedIndex = if (uiState.conditionType == DeliveryConditionType.INACTIVITY) 0 else 1, - onSelectIndex = onConditionTypeSelect, + selectedIndex = conditionTypes.indexOf(uiState.conditionType), + onSelectIndex = { onConditionTypeSelect(conditionTypes[it]) }, ) Spacer(Modifier.height(32.dp)) 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..2039fc4b8 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,13 @@ data class DeliveryConditionUiState( val conditions: List = emptyList(), val error: DeliveryConditionError? = null, val isSaving: Boolean = false, -) + /** 저장을 마쳤다는 사실. 소비되는 [isSaveSuccessPending] 과 달리 되돌리지 않아, 화면이 닫히는 동안에도 재제출을 막는다. */ + val isSaved: Boolean = false, + /** 화면에 알릴 저장 성공 신호. 어디로 이동할지는 이 신호를 콜백으로 받은 호스트가 정한다. */ + val isSaveSuccessPending: Boolean = false, +) : UiState { + val isSaveLocked: Boolean get() = isSaving || isSaved +} 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 2be5e9e7c..2f86862f4 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,17 +8,12 @@ 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 /** @@ -32,14 +26,61 @@ internal class DeliveryConditionViewModel 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 -> dispatch(DeliveryConditionReducerEvent.ConditionSelected(intent.type)) + 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) + } + + DeliveryConditionReducerEvent.Saving -> { + state.copy(isSaving = true) + } + + is DeliveryConditionReducerEvent.Saved -> { + state.copy(isSaving = false, conditions = event.conditions, isSaved = true, isSaveSuccessPending = true) + } - private val _saveSuccess = Channel(Channel.BUFFERED) - val saveSuccess = _saveSuccess.receiveAsFlow() + DeliveryConditionReducerEvent.SaveFailed -> { + state.copy(isSaving = false, error = DeliveryConditionError.SAVE_FAILED) + } + + DeliveryConditionReducerEvent.SuccessConsumed -> { + state.copy(isSaveSuccessPending = false) + } + } init { loadDeliveryConditions() @@ -47,37 +88,19 @@ internal 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) { - val conditionType = - if (index == 1) DeliveryConditionType.RECEIVER_REQUEST else DeliveryConditionType.INACTIVITY - _uiState.update { it.copy(conditionType = conditionType) } - } - - fun onSave() { - val state = _uiState.value - if (!state.isInitialized || state.isSaving) return + private fun onSave() { + val state = currentState + if (!state.isInitialized || state.isSaveLocked) return val hasTimeLetterCondition = state.conditions.any { it.contentType == DeliveryContentType.TIME_LETTER } @@ -110,15 +133,14 @@ internal 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..7caf1537d 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), ) @@ -186,7 +204,7 @@ private fun ProfileEditForm( AfternoteButton( text = "수정하기", onClick = { onUpdateClick(nameState.text.toString(), phoneState.text.toString()) }, - type = if (state.isUpdating) AfternoteButtonType.Un else AfternoteButtonType.Default, + type = if (state.isUpdateLocked) AfternoteButtonType.Un else AfternoteButtonType.Default, modifier = Modifier .padding(horizontal = 20.dp) 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..28d930b9b 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,17 @@ sealed interface ProfileEditUiState { val phone: String, val email: String, val isUpdating: Boolean = false, - ) : ProfileEditUiState + /** 수정을 마쳤다는 사실. 소비되는 [pendingEvent] 와 달리 되돌리지 않아, 화면이 닫히는 동안에도 재제출을 막는다. */ + val isUpdated: Boolean = false, + val pendingEvent: ProfileEditEvent? = null, + ) : ProfileEditUiState { + val isUpdateLocked: Boolean get() = isUpdating || isUpdated + } 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..3f9f300b9 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,102 @@ 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) } + } + + ProfileEditReducerEvent.UpdateSucceeded -> { + state.updateSuccess { + it.copy(isUpdating = false, isUpdated = true, 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.isUpdateLocked) 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/ReceiverEditContent.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverEditContent.kt index 12791f9ea..dbca359d5 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverEditContent.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverEditContent.kt @@ -31,7 +31,7 @@ internal fun ReceiverEditContent( title = "수신자 수정", actionText = "수정", isPhoneRequired = true, - isLoading = uiState.isSaving, + isActionLocked = uiState.isSaveLocked, errorMessage = uiState.errorMessage, onBackClick = onBackClick, onRegister = onRegister, 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..d3d586e4b --- /dev/null +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverEditContract.kt @@ -0,0 +1,43 @@ +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 + + /** 요청을 보내기 전의 입력 형식 검증 실패. 서버 저장 실패([SaveFailed])와 구분한다. */ + data class ValidationFailed( + 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 96280cd46..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,11 +1,10 @@ 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 internal fun ReceiverEditScreen( @@ -15,20 +14,19 @@ internal fun ReceiverEditScreen( 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..76d436b7c 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,20 @@ 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, + /** 저장을 마쳤다는 사실. 소비되는 [pendingEvent] 와 달리 되돌리지 않아, 화면이 닫히는 동안에도 재제출을 막는다. */ + val isSaved: Boolean = false, val receiver: ReceiverDetail? = null, val errorMessage: UiText? = null, -) + val pendingEvent: ReceiverEditEvent? = null, +) : UiState { + val isSaveLocked: Boolean get() = isSaving || isSaved +} -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 7c190f0cb..6da2b1aae 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,21 +1,16 @@ 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 /** @@ -28,14 +23,29 @@ internal class ReceiverEditViewModel 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) + is ReceiverEditReducerEvent.ValidationFailed -> state.copy(errorMessage = event.message) + ReceiverEditReducerEvent.Saving -> state.copy(isSaving = true, errorMessage = null) + ReceiverEditReducerEvent.Saved -> state.copy(isSaving = false, isSaved = true, pendingEvent = ReceiverEditEvent.EditSuccess) + is ReceiverEditReducerEvent.SaveFailed -> state.copy(isSaving = false, errorMessage = event.message) + ReceiverEditReducerEvent.SuccessConsumed -> state.copy(pendingEvent = null) + } init { loadReceiver() @@ -45,25 +55,23 @@ internal 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.isSaveLocked) return if (!email.isValidReceiverEmail()) { - _uiState.update { it.copy(errorMessage = UiText.Resource(R.string.setting_receiver_email_invalid)) } + dispatch(ReceiverEditReducerEvent.ValidationFailed(UiText.Resource(R.string.setting_receiver_email_invalid))) return } val phoneValidation = phone.validateReceiverPhone(isRequired = true) @@ -74,13 +82,13 @@ internal class ReceiverEditViewModel } else { R.string.setting_receiver_phone_invalid } - _uiState.update { it.copy(errorMessage = UiText.Resource(messageRes)) } + dispatch(ReceiverEditReducerEvent.ValidationFailed(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 +98,16 @@ internal 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/main/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverRegisterScreen.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverRegisterScreen.kt index d140f2259..181d9cfcb 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverRegisterScreen.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverRegisterScreen.kt @@ -74,7 +74,7 @@ fun ReceiverRegisterScreen( title = "수신자 등록", actionText = "등록", isPhoneRequired = true, - isLoading = uiState.isLoading, + isActionLocked = uiState.isLoading, errorMessage = uiState.errorMessage, onBackClick = onBackClick, onRegister = viewModel::register, @@ -87,7 +87,7 @@ internal fun ReceiverRegisterContent( title: String, actionText: String, isPhoneRequired: Boolean, - isLoading: Boolean, + isActionLocked: Boolean, errorMessage: UiText?, onBackClick: () -> Unit, onRegister: (name: String, relation: String, phone: String, email: String, message: String) -> Unit, @@ -149,7 +149,7 @@ internal fun ReceiverRegisterContent( messageState.text.toString(), ) }, - enabled = isFormValid && !isLoading, + enabled = isFormValid && !isActionLocked, colors = ButtonDefaults.textButtonColors( contentColor = AfternoteDesign.colors.gray9, 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..b1e985377 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(DeliveryConditionType.RECEIVER_REQUEST)) + 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 52472d710..2ec681531 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,10 +459,16 @@ class SettingCompletionTest { } finalMessageGate.complete(Result.success(Unit)) - assertEquals(ReceiverEditEvent.EditSuccess, awaitEvent(viewModel.events)) composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { - !viewModel.uiState.value.isSaving + viewModel.uiState.value.pendingEvent == ReceiverEditEvent.EditSuccess } + composeRule.runOnIdle { viewModel.onIntent(ReceiverEditIntent.ConsumeSuccess) } + assertEquals(null, viewModel.uiState.value.pendingEvent) + // 신호를 소비해도 저장 완료 사실은 남아, 화면이 닫히는 동안의 재제출을 무시한다. + assertFalse(viewModel.uiState.value.isSaving) + assertTrue(viewModel.uiState.value.isSaved) + composeRule.runOnIdle(update) + composeRule.waitForIdle() assertEquals( listOf(expectedBasicCall, expectedBasicCall, expectedBasicCall), repository.receiverUpdateCalls, @@ -546,8 +561,8 @@ class SettingCompletionTest { val expectedCall = CompletionDeliveryUpdateCall(RECEIVER_ID, expectedConditions) composeRule.runOnIdle { - viewModel.onConditionTypeSelected(index = 1) - viewModel.onSave() + viewModel.onIntent(DeliveryConditionIntent.SelectConditionType(DeliveryConditionType.RECEIVER_REQUEST)) + viewModel.onIntent(DeliveryConditionIntent.Save) } composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { repository.deliveryUpdateCalls.size == 1 @@ -563,7 +578,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,11 +599,18 @@ class SettingCompletionTest { ), ) - assertEquals(Unit, awaitEvent(viewModel.saveSuccess)) + composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { viewModel.uiState.value.isSaveSuccessPending } + composeRule.runOnIdle { viewModel.onIntent(DeliveryConditionIntent.ConsumeSuccess) } + assertFalse(viewModel.uiState.value.isSaveSuccessPending) composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { viewModel.uiState.value.conditions == serverConditions } + // 신호를 소비해도 저장 완료 사실은 남아, 화면이 닫히는 동안의 재제출을 무시한다. assertFalse(viewModel.uiState.value.isSaving) + assertTrue(viewModel.uiState.value.isSaved) + composeRule.runOnIdle { viewModel.onIntent(DeliveryConditionIntent.Save) } + composeRule.waitForIdle() + assertEquals(listOf(expectedCall, expectedCall), repository.deliveryUpdateCalls) } @Test 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..b46c6bf4b --- /dev/null +++ b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditMviTest.kt @@ -0,0 +1,127 @@ +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) + assertFalse(viewModel.success().isUpdating) + assertTrue(viewModel.success().isUpdated) + } + + @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", )