From 648d8dfd23aacb458d75c68cf23160366697d6d4 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 01/10] =?UTF-8?q?refactor(setting):=20=ED=8E=B8=EC=A7=91?= =?UTF-8?q?=20=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 | 18 +-- .../delivery/DeliveryConditionUiState.kt | 6 +- .../delivery/DeliveryConditionViewModel.kt | 104 +++++++++------ .../profile/ProfileEditContract.kt | 36 +++++ .../presentation/profile/ProfileEditScreen.kt | 38 ++++-- .../profile/ProfileEditUiState.kt | 7 +- .../profile/ProfileEditViewModel.kt | 85 ++++++++---- .../receiver/ReceiverEditContract.kt | 38 ++++++ .../receiver/ReceiverEditScreen.kt | 22 ++- .../receiver/ReceiverEditUiState.kt | 8 +- .../receiver/ReceiverEditViewModel.kt | 78 +++++------ .../SettingAccountSecurityTest.kt | 15 ++- .../presentation/SettingCompletionTest.kt | 41 ++++-- .../profile/ProfileEditMviTest.kt | 126 ++++++++++++++++++ .../konsist/MviContractKonsistTest.kt | 3 - 16 files changed, 495 insertions(+), 170 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 50dc3c669..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 @@ -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() } - } + 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 2be5e9e7c..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) 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 @@ 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) { + 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 @@ 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..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 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..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 7c190f0cb..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) 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 @@ 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.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 @@ internal 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 @@ 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/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 52472d710..cecd2b42b 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", ) From 07ba9e718def4923269babe446cd9278794c2983 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Wed, 30 Sep 2026 20:25:31 +0900 Subject: [PATCH 02/10] =?UTF-8?q?refactor(setting):=20=EB=A6=AC=EB=B7=B0?= =?UTF-8?q?=20=EB=B0=98=EC=98=81=20=E2=80=94=20=EC=A0=80=EC=9E=A5=20?= =?UTF-8?q?=EC=99=84=EB=A3=8C=EB=8A=94=20ObserveFlag,=20=EC=9E=85=EB=A0=A5?= =?UTF-8?q?=20=EA=B2=80=EC=A6=9D=20=EC=8B=A4=ED=8C=A8=EB=8A=94=20=EB=B3=84?= =?UTF-8?q?=EB=8F=84=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20(#2179)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 전달 조건 저장 완료 신호를 Unit? 대신 isSaved 플래그로 두고 core:ui ObserveFlag 로 소비한다. - 전달 조건·수신인 편집 ViewModel 의 assisted route 근거 KDoc(#1695)을 되살린다. - 수신인 편집의 이메일·전화번호 형식 검증 실패를 SaveFailed 가 아니라 ValidationFailed 로 보낸다. --- .../presentation/delivery/DeliveryConditionScreen.kt | 8 ++++---- .../presentation/delivery/DeliveryConditionUiState.kt | 2 +- .../delivery/DeliveryConditionViewModel.kt | 10 +++++++--- .../presentation/receiver/ReceiverEditContract.kt | 5 +++++ .../presentation/receiver/ReceiverEditViewModel.kt | 9 +++++++-- .../setting/presentation/SettingCompletionTest.kt | 4 ++-- 6 files changed, 26 insertions(+), 12 deletions(-) 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 62aad55e4..8e3595cd7 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 @@ -26,7 +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.mvi.ObserveFlag import com.afternote.core.ui.theme.AfternoteDesign import com.afternote.core.ui.topbar.DetailTopBar import com.afternote.feature.setting.presentation.R @@ -41,11 +41,11 @@ internal fun DeliveryConditionScreen( viewModel: DeliveryConditionViewModel, ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() - ObserveSignal( - signal = uiState.pendingEvent, + ObserveFlag( + raised = uiState.isSaved, consumed = DeliveryConditionIntent.ConsumeSuccess, onIntent = viewModel::onIntent, - onSignal = { onSaveSuccess() }, + onRaised = onSaveSuccess, ) DeliveryConditionContent( 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 d3fc8aa93..7ba60d8f0 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 @@ -14,7 +14,7 @@ internal data class DeliveryConditionUiState( val conditions: List = emptyList(), val error: DeliveryConditionError? = null, val isSaving: Boolean = false, - val pendingEvent: Unit? = null, + val isSaved: Boolean = false, ) : UiState enum class DeliveryConditionError { 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 b6ea20b5c..838fb5f4c 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 @@ -16,6 +16,10 @@ import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.launch +/** + * 대상 수신자 [SettingRoute.AfterDeliveryRoute] 는 assisted 로 받는다 — Nav3 entry 에는 Nav2 의 + * `savedStateHandle.toRoute()` 자동 채움이 없다 (#1695). + */ @HiltViewModel(assistedFactory = DeliveryConditionViewModel.Factory::class) internal class DeliveryConditionViewModel @AssistedInject @@ -66,7 +70,7 @@ internal class DeliveryConditionViewModel } is DeliveryConditionReducerEvent.Saved -> { - state.copy(isSaving = false, conditions = event.conditions, pendingEvent = Unit) + state.copy(isSaving = false, conditions = event.conditions, isSaved = true) } DeliveryConditionReducerEvent.SaveFailed -> { @@ -74,7 +78,7 @@ internal class DeliveryConditionViewModel } DeliveryConditionReducerEvent.SuccessConsumed -> { - state.copy(pendingEvent = null) + state.copy(isSaved = false) } } @@ -102,7 +106,7 @@ internal class DeliveryConditionViewModel private fun onSave() { val state = currentState - if (!state.isInitialized || state.isSaving || state.pendingEvent != null) return + if (!state.isInitialized || state.isSaving || state.isSaved) return val hasTimeLetterCondition = state.conditions.any { it.contentType == DeliveryContentType.TIME_LETTER } 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 index 6127d5ad4..d3d586e4b 100644 --- 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 @@ -26,6 +26,11 @@ internal sealed interface ReceiverEditReducerEvent : ReducerEvent { val message: UiText, ) : ReceiverEditReducerEvent + /** 요청을 보내기 전의 입력 형식 검증 실패. 서버 저장 실패([SaveFailed])와 구분한다. */ + data class ValidationFailed( + val message: UiText, + ) : ReceiverEditReducerEvent + data object Saving : ReceiverEditReducerEvent data object Saved : ReceiverEditReducerEvent 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 0e377b850..55e922dd8 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 @@ -13,6 +13,10 @@ import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.launch +/** + * 수정 대상 [SettingRoute.RecipientEditRoute] 는 assisted 로 받는다 — Nav3 entry 에는 Nav2 의 + * `savedStateHandle.toRoute()` 자동 채움이 없다 (#1695). + */ @HiltViewModel(assistedFactory = ReceiverEditViewModel.Factory::class) internal class ReceiverEditViewModel @AssistedInject @@ -36,6 +40,7 @@ internal class ReceiverEditViewModel 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, pendingEvent = ReceiverEditEvent.EditSuccess) is ReceiverEditReducerEvent.SaveFailed -> state.copy(isSaving = false, errorMessage = event.message) @@ -66,7 +71,7 @@ internal class ReceiverEditViewModel ) { if (currentState.isSaving || currentState.pendingEvent != null) return if (!email.isValidReceiverEmail()) { - dispatch(ReceiverEditReducerEvent.SaveFailed(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) @@ -77,7 +82,7 @@ internal class ReceiverEditViewModel } else { R.string.setting_receiver_phone_invalid } - dispatch(ReceiverEditReducerEvent.SaveFailed(UiText.Resource(messageRes))) + dispatch(ReceiverEditReducerEvent.ValidationFailed(UiText.Resource(messageRes))) return } 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 cecd2b42b..f45105d4c 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 @@ -597,9 +597,9 @@ class SettingCompletionTest { ), ) - composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { viewModel.uiState.value.pendingEvent != null } + composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { viewModel.uiState.value.isSaved } composeRule.runOnIdle { viewModel.onIntent(DeliveryConditionIntent.ConsumeSuccess) } - assertEquals(null, viewModel.uiState.value.pendingEvent) + assertFalse(viewModel.uiState.value.isSaved) composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { viewModel.uiState.value.conditions == serverConditions } From 07d30f9c01d8abb9c5613cae1b1e92a1524f9f54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Thu, 1 Oct 2026 00:57:16 +0900 Subject: [PATCH 03/10] =?UTF-8?q?test(setting):=20=ED=8E=B8=EC=A7=91=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=20=EC=A0=80=EC=9E=A5=20=EC=8B=A0=ED=98=B8?= =?UTF-8?q?=EA=B0=80=20=EC=8A=A4=ED=83=9D=EC=9D=84=20=ED=95=9C=20=EC=B9=B8?= =?UTF-8?q?=EB=A7=8C=20=EB=82=B4=EB=A6=AC=EB=8A=94=EC=A7=80=20=EA=B3=84?= =?UTF-8?q?=EC=B8=A1=EC=9C=BC=EB=A1=9C=20=EA=B3=A0=EC=A0=95=ED=95=9C?= =?UTF-8?q?=EB=8B=A4=20(#2179)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 수신자 수정의 pendingEvent 와 전달 조건의 isSaved 가 화면에서 한 번만 소비되는지는 ViewModel 단위 테스트로 보이지 않는다. 실제 설정 로컬 스택을 띄워 저장 성공은 정확히 한 칸 내려오고, 저장 실패와 잘못된 이메일·연락처는 화면을 닫지 않고 요청도 보내지 않는지 잰다. 성공 콜백을 두 번 부르게 바꾸면 성공 케이스 두 건이 실패하는 것을 로컬에서 확인했다. --- .../SettingEditorSaveSignalAndroidTest.kt | 236 ++++++++++++++++++ 1 file changed, 236 insertions(+) create mode 100644 app/src/androidTest/java/com/afternote/afternote_fe/SettingEditorSaveSignalAndroidTest.kt 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..410d021b9 --- /dev/null +++ b/app/src/androidTest/java/com/afternote/afternote_fe/SettingEditorSaveSignalAndroidTest.kt @@ -0,0 +1,236 @@ +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.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.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`, 전달 조건의 `isSaved`)가 화면에서 한 번만 + * 소비되어 스택을 정확히 한 칸 내리는지, 실패와 입력 검증은 화면을 닫지 않는지를 잰다. 신호 소비가 + * 두 번 일어나면 한 칸이 아니라 설정 홈까지 내려가고, 소비가 안 되면 편집 화면에 남는다. 둘 다 + * 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.onAllNodes(hasText(EDIT_FAILED_MESSAGE)).fetchSemanticsNodes().isNotEmpty() + } + // 실패는 화면을 닫지 않고, 저장 중 잠금도 풀려 다시 누를 수 있다. + 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) + } +} From 6436685709c0028876b910dfc3747e730acf9956 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Thu, 1 Oct 2026 20:45:15 +0900 Subject: [PATCH 04/10] test(setting): scroll to lazy save error before asserting (#2179) --- .../afternote_fe/SettingEditorSaveSignalAndroidTest.kt | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/app/src/androidTest/java/com/afternote/afternote_fe/SettingEditorSaveSignalAndroidTest.kt b/app/src/androidTest/java/com/afternote/afternote_fe/SettingEditorSaveSignalAndroidTest.kt index 410d021b9..78f61b966 100644 --- a/app/src/androidTest/java/com/afternote/afternote_fe/SettingEditorSaveSignalAndroidTest.kt +++ b/app/src/androidTest/java/com/afternote/afternote_fe/SettingEditorSaveSignalAndroidTest.kt @@ -10,6 +10,7 @@ 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 @@ -18,6 +19,7 @@ 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 @@ -118,8 +120,11 @@ class SettingEditorSaveSignalAndroidTest { composeRule.onNodeWithText(SAVE_ACTION).performClick() composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { - composeRule.onAllNodes(hasText(EDIT_FAILED_MESSAGE)).fetchSemanticsNodes().isNotEmpty() + 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() From ebbbbea6dcc875263eb2c8a7fb7ce31a626a2c50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Sun, 4 Oct 2026 04:09:16 +0000 Subject: [PATCH 05/10] =?UTF-8?q?refactor(setting):=20=EC=A0=84=EB=8B=AC?= =?UTF-8?q?=20=EC=A1=B0=EA=B1=B4=20Intent=20=EB=A5=BC=20=ED=83=AD=20?= =?UTF-8?q?=EC=9D=B8=EB=8D=B1=EC=8A=A4=20=EB=8C=80=EC=8B=A0=20=EC=A1=B0?= =?UTF-8?q?=EA=B1=B4=20=ED=83=80=EC=9E=85=EC=9C=BC=EB=A1=9C=20=EB=B0=9B?= =?UTF-8?q?=EB=8A=94=EB=8B=A4=20(#2179)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 탭 순서(0=미활동, 1=수신자 요청)를 Screen(타입→인덱스)과 ViewModel(인덱스→타입)이 반대 방향으로 따로 알고 있어, 라디오 순서만 바꿔도 매핑이 조용히 어긋날 수 있었다. 매핑을 Screen 의 conditionTypes 한 곳으로 모으고 SelectConditionType 은 도메인 타입을 싣게 해 ViewModel 분기를 dispatch 한 줄로 줄인다. --- .../presentation/delivery/DeliveryConditionContract.kt | 2 +- .../presentation/delivery/DeliveryConditionScreen.kt | 8 +++++--- .../presentation/delivery/DeliveryConditionViewModel.kt | 8 +------- .../setting/presentation/SettingAccountSecurityTest.kt | 2 +- .../feature/setting/presentation/SettingCompletionTest.kt | 2 +- 5 files changed, 9 insertions(+), 13 deletions(-) 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 index 53e1170cd..5806ff18c 100644 --- 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 @@ -7,7 +7,7 @@ import com.afternote.core.ui.mvi.ReducerEvent internal sealed interface DeliveryConditionIntent : MviIntent { data class SelectConditionType( - val index: Int, + val type: DeliveryConditionType, ) : DeliveryConditionIntent data object Save : DeliveryConditionIntent 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 8e3595cd7..f90fa7660 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 @@ -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( @@ -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/DeliveryConditionViewModel.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/delivery/DeliveryConditionViewModel.kt index 838fb5f4c..6789a7eb8 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 @@ -31,7 +31,7 @@ internal class DeliveryConditionViewModel override fun onIntent(intent: DeliveryConditionIntent) { when (intent) { - is DeliveryConditionIntent.SelectConditionType -> onConditionTypeSelected(intent.index) + is DeliveryConditionIntent.SelectConditionType -> dispatch(DeliveryConditionReducerEvent.ConditionSelected(intent.type)) DeliveryConditionIntent.Save -> onSave() DeliveryConditionIntent.ConsumeSuccess -> dispatch(DeliveryConditionReducerEvent.SuccessConsumed) } @@ -98,12 +98,6 @@ internal class DeliveryConditionViewModel } } - private fun onConditionTypeSelected(index: Int) { - val conditionType = - if (index == 1) DeliveryConditionType.RECEIVER_REQUEST else DeliveryConditionType.INACTIVITY - dispatch(DeliveryConditionReducerEvent.ConditionSelected(conditionType)) - } - private fun onSave() { val state = currentState if (!state.isInitialized || state.isSaving || state.isSaved) return 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 2b7ae6893..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 @@ -217,7 +217,7 @@ class SettingAccountSecurityTest { } composeRule.runOnIdle { - viewModel.onIntent(DeliveryConditionIntent.SelectConditionType(index = 1)) + viewModel.onIntent(DeliveryConditionIntent.SelectConditionType(DeliveryConditionType.RECEIVER_REQUEST)) viewModel.onIntent(DeliveryConditionIntent.Save) } composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { 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 f45105d4c..94a3c0b89 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 @@ -559,7 +559,7 @@ class SettingCompletionTest { val expectedCall = CompletionDeliveryUpdateCall(RECEIVER_ID, expectedConditions) composeRule.runOnIdle { - viewModel.onIntent(DeliveryConditionIntent.SelectConditionType(index = 1)) + viewModel.onIntent(DeliveryConditionIntent.SelectConditionType(DeliveryConditionType.RECEIVER_REQUEST)) viewModel.onIntent(DeliveryConditionIntent.Save) } composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { From 35efc25643295fe05324e53794d2c23b6778328a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Sun, 4 Oct 2026 08:10:35 +0000 Subject: [PATCH 06/10] =?UTF-8?q?fix(setting):=20=EC=88=98=EC=8B=A0?= =?UTF-8?q?=EC=9E=90=C2=B7=EC=A0=84=EB=8B=AC=20=EC=A1=B0=EA=B1=B4=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5=20=EC=84=B1=EA=B3=B5=20=EB=92=A4=EC=97=90?= =?UTF-8?q?=EB=8F=84=20=EC=A0=80=EC=9E=A5=20=EC=9E=A0=EA=B8=88=EC=9D=84=20?= =?UTF-8?q?=EC=9C=A0=EC=A7=80=ED=95=9C=EB=8B=A4=20(#2179)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 두 화면은 성공 때 isSaving 을 풀고 완료 신호(pendingEvent, isSaved)로 재제출을 막았다. 신호는 ObserveSignal 이 화면을 닫는 호출 직후 바로 소비되므로, 퇴장 애니메이션 동안에는 버튼이 다시 켜지고 가드도 열려 같은 저장 요청이 한 번 더 나갈 수 있었다. 프로필 편집과 같이 성공 뒤 isSaving 을 유지해 잠금은 isSaving 하나가, 전달은 신호가 맡게 한다. --- .../delivery/DeliveryConditionViewModel.kt | 5 +++-- .../presentation/receiver/ReceiverEditViewModel.kt | 5 +++-- .../setting/presentation/SettingCompletionTest.kt | 13 +++++++++---- 3 files changed, 15 insertions(+), 8 deletions(-) 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 6789a7eb8..5caff6589 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 @@ -69,8 +69,9 @@ internal class DeliveryConditionViewModel state.copy(isSaving = true) } + // 성공 뒤 화면이 닫히는 사이 재제출을 막도록 isSaving 을 유지한다. is DeliveryConditionReducerEvent.Saved -> { - state.copy(isSaving = false, conditions = event.conditions, isSaved = true) + state.copy(conditions = event.conditions, isSaved = true) } DeliveryConditionReducerEvent.SaveFailed -> { @@ -100,7 +101,7 @@ internal class DeliveryConditionViewModel private fun onSave() { val state = currentState - if (!state.isInitialized || state.isSaving || state.isSaved) return + if (!state.isInitialized || state.isSaving) return val hasTimeLetterCondition = state.conditions.any { it.contentType == DeliveryContentType.TIME_LETTER } 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 55e922dd8..20d5468d0 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 @@ -42,7 +42,8 @@ internal class ReceiverEditViewModel 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, pendingEvent = ReceiverEditEvent.EditSuccess) + // 성공 뒤 화면이 닫히는 사이 재제출을 막도록 isSaving 을 유지한다. + ReceiverEditReducerEvent.Saved -> state.copy(pendingEvent = ReceiverEditEvent.EditSuccess) is ReceiverEditReducerEvent.SaveFailed -> state.copy(isSaving = false, errorMessage = event.message) ReceiverEditReducerEvent.SuccessConsumed -> state.copy(pendingEvent = null) } @@ -69,7 +70,7 @@ internal class ReceiverEditViewModel email: String, message: String, ) { - if (currentState.isSaving || currentState.pendingEvent != null) return + if (currentState.isSaving) return if (!email.isValidReceiverEmail()) { dispatch(ReceiverEditReducerEvent.ValidationFailed(UiText.Resource(R.string.setting_receiver_email_invalid))) return 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 94a3c0b89..7b1b5ec5a 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 @@ -464,9 +464,10 @@ class SettingCompletionTest { } composeRule.runOnIdle { viewModel.onIntent(ReceiverEditIntent.ConsumeSuccess) } assertEquals(null, viewModel.uiState.value.pendingEvent) - composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { - !viewModel.uiState.value.isSaving - } + // 신호를 소비한 뒤에도 화면이 닫히는 동안에는 잠금이 남아 재제출을 무시한다. + assertTrue(viewModel.uiState.value.isSaving) + composeRule.runOnIdle(update) + composeRule.waitForIdle() assertEquals( listOf(expectedBasicCall, expectedBasicCall, expectedBasicCall), repository.receiverUpdateCalls, @@ -603,7 +604,11 @@ class SettingCompletionTest { composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { viewModel.uiState.value.conditions == serverConditions } - assertFalse(viewModel.uiState.value.isSaving) + // 신호를 소비한 뒤에도 화면이 닫히는 동안에는 잠금이 남아 재제출을 무시한다. + assertTrue(viewModel.uiState.value.isSaving) + composeRule.runOnIdle { viewModel.onIntent(DeliveryConditionIntent.Save) } + composeRule.waitForIdle() + assertEquals(listOf(expectedCall, expectedCall), repository.deliveryUpdateCalls) } @Test From 9c004344971e17b39c64039054a7998daa7bc687 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Sun, 4 Oct 2026 08:13:03 +0000 Subject: [PATCH 07/10] =?UTF-8?q?style(setting):=20=EC=88=98=EC=8B=A0?= =?UTF-8?q?=EC=9E=90=20=EC=88=98=EC=A0=95=20=EB=A6=AC=EB=93=80=EC=84=9C=20?= =?UTF-8?q?=EB=B6=84=EA=B8=B0=20=EC=82=AC=EC=9D=B4=EC=97=90=20=EB=B9=88=20?= =?UTF-8?q?=EC=A4=84=EC=9D=84=20=EB=91=94=EB=8B=A4=20(#2179)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 주석이 붙은 Saved 분기가 여러 줄로 잡혀 ktlint blank-line-between-when-conditions 가 실패했다. --- .../setting/presentation/receiver/ReceiverEditViewModel.kt | 6 ++++++ 1 file changed, 6 insertions(+) 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 20d5468d0..209b734be 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 @@ -39,12 +39,18 @@ internal class ReceiverEditViewModel ): 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) + // 성공 뒤 화면이 닫히는 사이 재제출을 막도록 isSaving 을 유지한다. ReceiverEditReducerEvent.Saved -> state.copy(pendingEvent = ReceiverEditEvent.EditSuccess) + is ReceiverEditReducerEvent.SaveFailed -> state.copy(isSaving = false, errorMessage = event.message) + ReceiverEditReducerEvent.SuccessConsumed -> state.copy(pendingEvent = null) } From 2861ce6150f84e75fb6519c6689a9fcf52a690b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Sun, 4 Oct 2026 08:18:01 +0000 Subject: [PATCH 08/10] =?UTF-8?q?refactor(setting):=20=EC=A0=80=EC=9E=A5?= =?UTF-8?q?=20=EC=99=84=EB=A3=8C=20=EC=82=AC=EC=8B=A4=EC=9D=84=20=EC=A7=84?= =?UTF-8?q?=ED=96=89=20=ED=94=8C=EB=9E=98=EA=B7=B8=EC=99=80=20=EC=8B=A0?= =?UTF-8?q?=ED=98=B8=EC=97=90=EC=84=9C=20=EB=96=BC=EC=96=B4=20=EB=91=94?= =?UTF-8?q?=EB=8B=A4=20(#2179)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 성공 뒤에도 isSaving·isUpdating 을 true 로 두면 저장이 끝났는데 상태가 저장 중이라고 말하게 된다. 세 편집 화면에 되돌리지 않는 완료 사실(isSaved·isUpdated)을 두고, 진행 플래그는 성공 때 풀며, 재제출 가드와 버튼은 isSaveLocked·isUpdateLocked 로 둘을 함께 본다. 전달 조건의 화면 닫기 신호는 완료 사실과 이름이 겹치지 않게 shouldNavigateBack 으로 옮겼고, ReceiverRegisterContent 의 isLoading 은 실제 쓰임대로 isActionLocked 로 바꿨다. --- .../SettingEditorSaveSignalAndroidTest.kt | 2 +- .../delivery/DeliveryConditionScreen.kt | 4 +-- .../delivery/DeliveryConditionUiState.kt | 6 +++- .../delivery/DeliveryConditionViewModel.kt | 7 ++--- .../presentation/profile/ProfileEditScreen.kt | 2 +- .../profile/ProfileEditUiState.kt | 6 +++- .../profile/ProfileEditViewModel.kt | 7 +++-- .../receiver/ReceiverEditContent.kt | 2 +- .../receiver/ReceiverEditUiState.kt | 6 +++- .../receiver/ReceiverEditViewModel.kt | 31 +++++++++++++------ .../receiver/ReceiverRegisterScreen.kt | 6 ++-- .../presentation/SettingCompletionTest.kt | 14 +++++---- .../profile/ProfileEditMviTest.kt | 3 +- 13 files changed, 62 insertions(+), 34 deletions(-) diff --git a/app/src/androidTest/java/com/afternote/afternote_fe/SettingEditorSaveSignalAndroidTest.kt b/app/src/androidTest/java/com/afternote/afternote_fe/SettingEditorSaveSignalAndroidTest.kt index 78f61b966..c2af8e80d 100644 --- a/app/src/androidTest/java/com/afternote/afternote_fe/SettingEditorSaveSignalAndroidTest.kt +++ b/app/src/androidTest/java/com/afternote/afternote_fe/SettingEditorSaveSignalAndroidTest.kt @@ -43,7 +43,7 @@ import javax.inject.Inject /** * 설정 편집 화면의 저장 신호를 실제 로컬 스택에서 본다 (#2179). * - * reducer 가 세운 완료 신호(수신자 수정의 `pendingEvent`, 전달 조건의 `isSaved`)가 화면에서 한 번만 + * reducer 가 세운 완료 신호(수신자 수정의 `pendingEvent`, 전달 조건의 `shouldNavigateBack`)가 화면에서 한 번만 * 소비되어 스택을 정확히 한 칸 내리는지, 실패와 입력 검증은 화면을 닫지 않는지를 잰다. 신호 소비가 * 두 번 일어나면 한 칸이 아니라 설정 홈까지 내려가고, 소비가 안 되면 편집 화면에 남는다. 둘 다 * ViewModel 단위 테스트로는 보이지 않는 자리다. 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 f90fa7660..2a6a44dfd 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 @@ -42,7 +42,7 @@ internal fun DeliveryConditionScreen( ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() ObserveFlag( - raised = uiState.isSaved, + raised = uiState.shouldNavigateBack, consumed = DeliveryConditionIntent.ConsumeSuccess, onIntent = viewModel::onIntent, onRaised = onSaveSuccess, @@ -85,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), 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 7ba60d8f0..c75426711 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 @@ -14,8 +14,12 @@ internal data class DeliveryConditionUiState( val conditions: List = emptyList(), val error: DeliveryConditionError? = null, val isSaving: Boolean = false, + /** 저장을 마쳤다는 사실. 소비되는 [shouldNavigateBack] 과 달리 되돌리지 않아, 화면이 닫히는 동안에도 재제출을 막는다. */ val isSaved: Boolean = false, -) : UiState + val shouldNavigateBack: 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 5caff6589..19d64a41d 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 @@ -69,9 +69,8 @@ internal class DeliveryConditionViewModel state.copy(isSaving = true) } - // 성공 뒤 화면이 닫히는 사이 재제출을 막도록 isSaving 을 유지한다. is DeliveryConditionReducerEvent.Saved -> { - state.copy(conditions = event.conditions, isSaved = true) + state.copy(isSaving = false, conditions = event.conditions, isSaved = true, shouldNavigateBack = true) } DeliveryConditionReducerEvent.SaveFailed -> { @@ -79,7 +78,7 @@ internal class DeliveryConditionViewModel } DeliveryConditionReducerEvent.SuccessConsumed -> { - state.copy(isSaved = false) + state.copy(shouldNavigateBack = false) } } @@ -101,7 +100,7 @@ internal class DeliveryConditionViewModel private fun onSave() { val state = currentState - if (!state.isInitialized || state.isSaving) return + if (!state.isInitialized || state.isSaveLocked) return val hasTimeLetterCondition = state.conditions.any { it.contentType == DeliveryContentType.TIME_LETTER } 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 11f779540..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 @@ -204,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 f6465f472..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 @@ -10,8 +10,12 @@ internal sealed interface ProfileEditUiState : UiState { val phone: String, val email: String, val isUpdating: Boolean = false, + /** 수정을 마쳤다는 사실. 소비되는 [pendingEvent] 와 달리 되돌리지 않아, 화면이 닫히는 동안에도 재제출을 막는다. */ + val isUpdated: Boolean = false, val pendingEvent: ProfileEditEvent? = null, - ) : ProfileEditUiState + ) : ProfileEditUiState { + val isUpdateLocked: Boolean get() = isUpdating || isUpdated + } data object Error : ProfileEditUiState } 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 f442a5586..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 @@ -42,9 +42,10 @@ internal class ProfileEditViewModel state.updateSuccess { it.copy(isUpdating = true, pendingEvent = null) } } - // 성공 뒤 화면이 닫히는 사이 재제출을 막도록 isUpdating 을 유지한다. ProfileEditReducerEvent.UpdateSucceeded -> { - state.updateSuccess { it.copy(pendingEvent = ProfileEditEvent.UpdateSuccess) } + state.updateSuccess { + it.copy(isUpdating = false, isUpdated = true, pendingEvent = ProfileEditEvent.UpdateSuccess) + } } ProfileEditReducerEvent.UpdateFailed -> { @@ -78,7 +79,7 @@ internal class ProfileEditViewModel phone: String, ) { val current = currentState as? ProfileEditUiState.Success ?: return - if (current.isUpdating) return + if (current.isUpdateLocked) return dispatch(ProfileEditReducerEvent.Updating) viewModelScope.launch { runCatchingCancellable { 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/ReceiverEditUiState.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverEditUiState.kt index c4fc89585..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 @@ -7,10 +7,14 @@ import com.afternote.core.ui.mvi.UiState 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 +) : UiState { + val isSaveLocked: Boolean get() = isSaving || isSaved +} 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 209b734be..47e1446fa 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 @@ -38,20 +38,33 @@ internal class ReceiverEditViewModel event: ReceiverEditReducerEvent, ): ReceiverEditUiState = when (event) { - is ReceiverEditReducerEvent.Loaded -> state.copy(isLoading = false, receiver = event.receiver) + is ReceiverEditReducerEvent.Loaded -> { + state.copy(isLoading = false, receiver = event.receiver) + } - is ReceiverEditReducerEvent.LoadFailed -> state.copy(isLoading = false, errorMessage = event.message) + is ReceiverEditReducerEvent.LoadFailed -> { + state.copy(isLoading = false, errorMessage = event.message) + } - is ReceiverEditReducerEvent.ValidationFailed -> state.copy(errorMessage = event.message) + is ReceiverEditReducerEvent.ValidationFailed -> { + state.copy(errorMessage = event.message) + } - ReceiverEditReducerEvent.Saving -> state.copy(isSaving = true, errorMessage = null) + ReceiverEditReducerEvent.Saving -> { + state.copy(isSaving = true, errorMessage = null) + } - // 성공 뒤 화면이 닫히는 사이 재제출을 막도록 isSaving 을 유지한다. - ReceiverEditReducerEvent.Saved -> state.copy(pendingEvent = ReceiverEditEvent.EditSuccess) + ReceiverEditReducerEvent.Saved -> { + state.copy(isSaving = false, isSaved = true, pendingEvent = ReceiverEditEvent.EditSuccess) + } - is ReceiverEditReducerEvent.SaveFailed -> state.copy(isSaving = false, errorMessage = event.message) + is ReceiverEditReducerEvent.SaveFailed -> { + state.copy(isSaving = false, errorMessage = event.message) + } - ReceiverEditReducerEvent.SuccessConsumed -> state.copy(pendingEvent = null) + ReceiverEditReducerEvent.SuccessConsumed -> { + state.copy(pendingEvent = null) + } } init { @@ -76,7 +89,7 @@ internal class ReceiverEditViewModel email: String, message: String, ) { - if (currentState.isSaving) return + if (currentState.isSaveLocked) return if (!email.isValidReceiverEmail()) { dispatch(ReceiverEditReducerEvent.ValidationFailed(UiText.Resource(R.string.setting_receiver_email_invalid))) return 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/SettingCompletionTest.kt b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingCompletionTest.kt index 7b1b5ec5a..dfe115d1d 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 @@ -464,8 +464,9 @@ class SettingCompletionTest { } composeRule.runOnIdle { viewModel.onIntent(ReceiverEditIntent.ConsumeSuccess) } assertEquals(null, viewModel.uiState.value.pendingEvent) - // 신호를 소비한 뒤에도 화면이 닫히는 동안에는 잠금이 남아 재제출을 무시한다. - assertTrue(viewModel.uiState.value.isSaving) + // 신호를 소비해도 저장 완료 사실은 남아, 화면이 닫히는 동안의 재제출을 무시한다. + assertFalse(viewModel.uiState.value.isSaving) + assertTrue(viewModel.uiState.value.isSaved) composeRule.runOnIdle(update) composeRule.waitForIdle() assertEquals( @@ -598,14 +599,15 @@ class SettingCompletionTest { ), ) - composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { viewModel.uiState.value.isSaved } + composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { viewModel.uiState.value.shouldNavigateBack } composeRule.runOnIdle { viewModel.onIntent(DeliveryConditionIntent.ConsumeSuccess) } - assertFalse(viewModel.uiState.value.isSaved) + assertFalse(viewModel.uiState.value.shouldNavigateBack) composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { viewModel.uiState.value.conditions == serverConditions } - // 신호를 소비한 뒤에도 화면이 닫히는 동안에는 잠금이 남아 재제출을 무시한다. - assertTrue(viewModel.uiState.value.isSaving) + // 신호를 소비해도 저장 완료 사실은 남아, 화면이 닫히는 동안의 재제출을 무시한다. + assertFalse(viewModel.uiState.value.isSaving) + assertTrue(viewModel.uiState.value.isSaved) composeRule.runOnIdle { viewModel.onIntent(DeliveryConditionIntent.Save) } composeRule.waitForIdle() assertEquals(listOf(expectedCall, expectedCall), repository.deliveryUpdateCalls) 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 index 7ec329092..b46c6bf4b 100644 --- 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 @@ -85,7 +85,8 @@ class ProfileEditMviTest { viewModel.onIntent(ProfileEditIntent.ConsumeEvent(ProfileEditEvent.UpdateSuccess)) assertNull(viewModel.success().pendingEvent) - assertTrue(viewModel.success().isUpdating) + assertFalse(viewModel.success().isUpdating) + assertTrue(viewModel.success().isUpdated) } @Test From 1ef061372f74f6f0c106488610077913153a84f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Sun, 4 Oct 2026 08:30:57 +0000 Subject: [PATCH 09/10] =?UTF-8?q?refactor(setting):=20=EC=A0=84=EB=8B=AC?= =?UTF-8?q?=20=EC=A1=B0=EA=B1=B4=20=EC=A0=80=EC=9E=A5=20=EC=84=B1=EA=B3=B5?= =?UTF-8?q?=20=EC=8B=A0=ED=98=B8=EB=A5=BC=20=EC=9D=B4=EB=8F=99=20=EC=A7=80?= =?UTF-8?q?=EC=8B=9C=EA=B0=80=20=EC=95=84=EB=8B=8C=20=EC=82=AC=EA=B1=B4=20?= =?UTF-8?q?=EC=9D=B4=EB=A6=84=EC=9C=BC=EB=A1=9C=20=EB=91=94=EB=8B=A4=20(#2?= =?UTF-8?q?179)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit shouldNavigateBack 은 ViewModel 이 뒤로 가기를 정하는 것처럼 읽혔다. 이동은 onSaveSuccess 를 받은 호스트가 정하므로, 신호를 콜백·소비 Intent 와 같은 사건(저장 성공)으로 isSaveSuccessPending 이라 부른다. --- .../afternote_fe/SettingEditorSaveSignalAndroidTest.kt | 2 +- .../setting/presentation/delivery/DeliveryConditionScreen.kt | 2 +- .../presentation/delivery/DeliveryConditionUiState.kt | 5 +++-- .../presentation/delivery/DeliveryConditionViewModel.kt | 4 ++-- .../feature/setting/presentation/SettingCompletionTest.kt | 4 ++-- 5 files changed, 9 insertions(+), 8 deletions(-) diff --git a/app/src/androidTest/java/com/afternote/afternote_fe/SettingEditorSaveSignalAndroidTest.kt b/app/src/androidTest/java/com/afternote/afternote_fe/SettingEditorSaveSignalAndroidTest.kt index c2af8e80d..670eaaf97 100644 --- a/app/src/androidTest/java/com/afternote/afternote_fe/SettingEditorSaveSignalAndroidTest.kt +++ b/app/src/androidTest/java/com/afternote/afternote_fe/SettingEditorSaveSignalAndroidTest.kt @@ -43,7 +43,7 @@ import javax.inject.Inject /** * 설정 편집 화면의 저장 신호를 실제 로컬 스택에서 본다 (#2179). * - * reducer 가 세운 완료 신호(수신자 수정의 `pendingEvent`, 전달 조건의 `shouldNavigateBack`)가 화면에서 한 번만 + * reducer 가 세운 완료 신호(수신자 수정의 `pendingEvent`, 전달 조건의 `isSaveSuccessPending`)가 화면에서 한 번만 * 소비되어 스택을 정확히 한 칸 내리는지, 실패와 입력 검증은 화면을 닫지 않는지를 잰다. 신호 소비가 * 두 번 일어나면 한 칸이 아니라 설정 홈까지 내려가고, 소비가 안 되면 편집 화면에 남는다. 둘 다 * ViewModel 단위 테스트로는 보이지 않는 자리다. 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 2a6a44dfd..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 @@ -42,7 +42,7 @@ internal fun DeliveryConditionScreen( ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() ObserveFlag( - raised = uiState.shouldNavigateBack, + raised = uiState.isSaveSuccessPending, consumed = DeliveryConditionIntent.ConsumeSuccess, onIntent = viewModel::onIntent, onRaised = onSaveSuccess, 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 c75426711..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 @@ -14,9 +14,10 @@ internal data class DeliveryConditionUiState( val conditions: List = emptyList(), val error: DeliveryConditionError? = null, val isSaving: Boolean = false, - /** 저장을 마쳤다는 사실. 소비되는 [shouldNavigateBack] 과 달리 되돌리지 않아, 화면이 닫히는 동안에도 재제출을 막는다. */ + /** 저장을 마쳤다는 사실. 소비되는 [isSaveSuccessPending] 과 달리 되돌리지 않아, 화면이 닫히는 동안에도 재제출을 막는다. */ val isSaved: Boolean = false, - val shouldNavigateBack: Boolean = false, + /** 화면에 알릴 저장 성공 신호. 어디로 이동할지는 이 신호를 콜백으로 받은 호스트가 정한다. */ + val isSaveSuccessPending: Boolean = false, ) : UiState { val isSaveLocked: Boolean get() = isSaving || isSaved } 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 19d64a41d..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 @@ -70,7 +70,7 @@ internal class DeliveryConditionViewModel } is DeliveryConditionReducerEvent.Saved -> { - state.copy(isSaving = false, conditions = event.conditions, isSaved = true, shouldNavigateBack = true) + state.copy(isSaving = false, conditions = event.conditions, isSaved = true, isSaveSuccessPending = true) } DeliveryConditionReducerEvent.SaveFailed -> { @@ -78,7 +78,7 @@ internal class DeliveryConditionViewModel } DeliveryConditionReducerEvent.SuccessConsumed -> { - state.copy(shouldNavigateBack = false) + state.copy(isSaveSuccessPending = false) } } 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 dfe115d1d..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 @@ -599,9 +599,9 @@ class SettingCompletionTest { ), ) - composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { viewModel.uiState.value.shouldNavigateBack } + composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { viewModel.uiState.value.isSaveSuccessPending } composeRule.runOnIdle { viewModel.onIntent(DeliveryConditionIntent.ConsumeSuccess) } - assertFalse(viewModel.uiState.value.shouldNavigateBack) + assertFalse(viewModel.uiState.value.isSaveSuccessPending) composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { viewModel.uiState.value.conditions == serverConditions } From 90af61a5df428015fc61a3bc51964e1a167f02c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Sun, 4 Oct 2026 08:42:46 +0000 Subject: [PATCH 10/10] =?UTF-8?q?style(setting):=20=EC=88=98=EC=8B=A0?= =?UTF-8?q?=EC=9E=90=20=EC=88=98=EC=A0=95=20=EB=A6=AC=EB=93=80=EC=84=9C?= =?UTF-8?q?=EB=A5=BC=20=EC=9B=90=EB=9E=98=20=ED=95=9C=20=EC=A4=84=20?= =?UTF-8?q?=EB=B6=84=EA=B8=B0=EB=A1=9C=20=EB=90=98=EB=8F=8C=EB=A6=B0?= =?UTF-8?q?=EB=8B=A4=20(#2179)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Saved 분기만 중괄호로 감싸 when-entry-bracing 이 모든 분기를 바꾸게 했다. 한 줄로도 ktlint 를 통과하므로 바뀐 내용(isSaved = true)만 diff 에 남긴다. --- .../receiver/ReceiverEditViewModel.kt | 34 ++++--------------- 1 file changed, 7 insertions(+), 27 deletions(-) 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 47e1446fa..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 @@ -38,33 +38,13 @@ internal class ReceiverEditViewModel 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) - } + 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 {