From 84a679972c7c5a3febdae1a42a613c2ed8f5a693 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Thu, 1 Oct 2026 21:06:35 +0900 Subject: [PATCH] fix: refresh setting screens on return without losing edits (#1920) --- .../account/ConnectedAccountsContract.kt | 2 + .../account/ConnectedAccountsScreen.kt | 3 + .../account/ConnectedAccountsViewModel.kt | 25 +- .../delivery/DeliveryConditionContract.kt | 3 + .../delivery/DeliveryConditionScreen.kt | 3 + .../delivery/DeliveryConditionUiState.kt | 2 + .../delivery/DeliveryConditionViewModel.kt | 64 +- .../notification/NotificationSettingScreen.kt | 2 +- .../notification/PushNotificationContract.kt | 2 + .../notification/PushNotificationScreen.kt | 3 + .../notification/PushNotificationUiState.kt | 1 + .../notification/PushNotificationViewModel.kt | 85 ++- .../profile/ProfileEditContract.kt | 2 + .../presentation/profile/ProfileEditScreen.kt | 19 + .../profile/ProfileEditViewModel.kt | 21 +- .../presentation/ProfileReentryFormTest.kt | 93 +++ .../SettingReentryViewModelTest.kt | 611 ++++++++++++++++++ .../NotificationSettingSignalTest.kt | 19 + .../RefreshOnReturnWiringKonsistTest.kt | 19 +- 19 files changed, 930 insertions(+), 49 deletions(-) create mode 100644 feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/ProfileReentryFormTest.kt create mode 100644 feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingReentryViewModelTest.kt diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsContract.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsContract.kt index cb5c3ae9af..53c4241d99 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsContract.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsContract.kt @@ -4,6 +4,8 @@ import com.afternote.core.ui.mvi.MviIntent import com.afternote.core.ui.mvi.ReducerEvent internal sealed interface ConnectedAccountsIntent : MviIntent { + data object RefreshOnReturn : ConnectedAccountsIntent + data object RetryLoad : ConnectedAccountsIntent data class ConsumeError( diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsScreen.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsScreen.kt index 9decf5f17f..35192d1bf1 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsScreen.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsScreen.kt @@ -11,6 +11,8 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.credentials.CredentialManager import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LifecycleEventEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.afternote.core.domain.error.CoreAuthFailure import com.afternote.core.ui.findActivity @@ -32,6 +34,7 @@ internal fun ConnectedAccountsScreen( viewModel: ConnectedAccountsViewModel = hiltViewModel(), ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() + LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { viewModel.onIntent(ConnectedAccountsIntent.RefreshOnReturn) } val context = LocalContext.current val credentialManager = remember(context) { CredentialManager.create(context) } val snackbarHostState = remember { SnackbarHostState() } diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsViewModel.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsViewModel.kt index 045b26bde4..a076487de2 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsViewModel.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsViewModel.kt @@ -20,6 +20,7 @@ internal class ConnectedAccountsViewModel ConnectedAccountsUiState(isLoading = true), ) { private var loadJob: Job? = null + private var isFirstResume = true init { loadConnectedAccounts() @@ -27,6 +28,10 @@ internal class ConnectedAccountsViewModel override fun onIntent(intent: ConnectedAccountsIntent) { when (intent) { + ConnectedAccountsIntent.RefreshOnReturn -> { + refreshOnReturn() + } + ConnectedAccountsIntent.RetryLoad -> { if (currentState.errorMessage != null) loadConnectedAccounts() } @@ -100,16 +105,26 @@ internal class ConnectedAccountsViewModel } } - private fun loadConnectedAccounts() { - if (loadJob?.isActive == true) return - dispatch(ConnectedAccountsReducerEvent.Loading) + private fun refreshOnReturn() { + if (isFirstResume) { + isFirstResume = false + return + } + loadConnectedAccounts(isAutomatic = true) + } + + private fun loadConnectedAccounts(isAutomatic: Boolean = false) { + if (loadJob?.isActive == true || currentState.isUpdating || currentState.pendingLinkProvider != null) return + if (!isAutomatic) dispatch(ConnectedAccountsReducerEvent.Loading) loadJob = viewModelScope.launch { runCatchingCancellable { accountRepository.getConnectedAccounts() } .onSuccess { accounts -> dispatch(ConnectedAccountsReducerEvent.Loaded(accounts.toStateList())) }.onFailure { - dispatch(ConnectedAccountsReducerEvent.LoadFailed("계정 정보를 불러올 수 없습니다.")) + if (!isAutomatic || currentState.accounts.isEmpty()) { + dispatch(ConnectedAccountsReducerEvent.LoadFailed("계정 정보를 불러올 수 없습니다.")) + } } } } @@ -131,6 +146,7 @@ internal class ConnectedAccountsViewModel accessToken: String, ) { if (currentState.isLoading || currentState.errorMessage != null || currentState.isUpdating) return + loadJob?.cancel() dispatch(ConnectedAccountsReducerEvent.Changing) viewModelScope.launch { runCatchingCancellable { accountRepository.linkConnectedAccount(provider, accessToken) } @@ -140,6 +156,7 @@ internal class ConnectedAccountsViewModel } private fun unlink(provider: String) { + loadJob?.cancel() dispatch(ConnectedAccountsReducerEvent.Changing) viewModelScope.launch { runCatchingCancellable { accountRepository.unlinkConnectedAccount(provider) } 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 53e1170cda..86ac13d4fe 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 @@ -6,6 +6,8 @@ import com.afternote.core.ui.mvi.MviIntent import com.afternote.core.ui.mvi.ReducerEvent internal sealed interface DeliveryConditionIntent : MviIntent { + data object RefreshOnReturn : DeliveryConditionIntent + data class SelectConditionType( val index: Int, ) : DeliveryConditionIntent @@ -31,6 +33,7 @@ internal sealed interface DeliveryConditionReducerEvent : ReducerEvent { data object Saving : DeliveryConditionReducerEvent data class Saved( + val revision: Int, val conditions: List, ) : 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 8e3595cd72..d72d655e1f 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 @@ -24,6 +24,8 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LifecycleEventEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.afternote.core.model.delivery.DeliveryConditionType import com.afternote.core.ui.mvi.ObserveFlag @@ -41,6 +43,7 @@ internal fun DeliveryConditionScreen( viewModel: DeliveryConditionViewModel, ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() + LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { viewModel.onIntent(DeliveryConditionIntent.RefreshOnReturn) } ObserveFlag( raised = uiState.isSaved, consumed = DeliveryConditionIntent.ConsumeSuccess, 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 7ba60d8f0e..7f8a60700d 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 @@ -7,6 +7,8 @@ import com.afternote.core.model.delivery.InactivityPeriod import com.afternote.core.ui.mvi.UiState internal data class DeliveryConditionUiState( + val conditionEditRevision: Int = 0, + val savedConditionRevision: Int = 0, val isLoading: Boolean = false, val isInitialized: Boolean = false, val conditionType: DeliveryConditionType = DeliveryConditionType.INACTIVITY, 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 86b66603fc..b71a1d954d 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 @@ -13,6 +13,7 @@ import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job import kotlinx.coroutines.launch /** @@ -28,9 +29,12 @@ internal class DeliveryConditionViewModel private val updateTimeLetterDeliveryCondition: UpdateTimeLetterDeliveryConditionUseCase, ) : MviViewModel(DeliveryConditionUiState()) { private val receiverId = route.receiverId + private var loadJob: Job? = null + private var isFirstResume = true override fun onIntent(intent: DeliveryConditionIntent) { when (intent) { + DeliveryConditionIntent.RefreshOnReturn -> refreshOnReturn() is DeliveryConditionIntent.SelectConditionType -> onConditionTypeSelected(intent.index) DeliveryConditionIntent.Save -> onSave() DeliveryConditionIntent.ConsumeSuccess -> dispatch(DeliveryConditionReducerEvent.SuccessConsumed) @@ -48,12 +52,26 @@ internal class DeliveryConditionViewModel is DeliveryConditionReducerEvent.Loaded -> { val representative = event.conditions.firstOrNull { it.contentType == DeliveryContentType.TIME_LETTER } + val hasEdits = state.conditionEditRevision != state.savedConditionRevision state.copy( isLoading = false, isInitialized = true, - conditionType = representative?.conditionType ?: DeliveryConditionType.INACTIVITY, - inactivityPeriod = representative?.inactivityPeriod ?: InactivityPeriod.ONE_YEAR, + conditionType = + if (hasEdits) { + state.conditionType + } else { + representative?.conditionType + ?: DeliveryConditionType.INACTIVITY + }, + inactivityPeriod = + if (hasEdits) { + state.inactivityPeriod + } else { + representative?.inactivityPeriod + ?: InactivityPeriod.ONE_YEAR + }, conditions = event.conditions, + error = state.error.takeUnless { it == DeliveryConditionError.LOAD_FAILED }, ) } @@ -62,7 +80,7 @@ internal class DeliveryConditionViewModel } is DeliveryConditionReducerEvent.ConditionSelected -> { - state.copy(conditionType = event.type) + state.copy(conditionType = event.type, conditionEditRevision = state.conditionEditRevision + 1) } DeliveryConditionReducerEvent.Saving -> { @@ -70,7 +88,13 @@ internal class DeliveryConditionViewModel } is DeliveryConditionReducerEvent.Saved -> { - state.copy(isSaving = false, conditions = event.conditions, isSaved = true) + state.copy( + isSaving = false, + conditions = event.conditions, + isSaved = true, + savedConditionRevision = event.revision, + error = null, + ) } DeliveryConditionReducerEvent.SaveFailed -> { @@ -86,16 +110,26 @@ internal class DeliveryConditionViewModel loadDeliveryConditions() } - private fun loadDeliveryConditions() { - viewModelScope.launch { - dispatch(DeliveryConditionReducerEvent.Loading) - runCatchingCancellable { receiverRepository.getReceiverDeliveryConditions(receiverId) } - .onSuccess { response -> - dispatch(DeliveryConditionReducerEvent.Loaded(response.conditions)) - }.onFailure { - dispatch(DeliveryConditionReducerEvent.LoadFailed) - } + private fun refreshOnReturn() { + if (isFirstResume) { + isFirstResume = false + return } + loadDeliveryConditions(isAutomatic = true) + } + + private fun loadDeliveryConditions(isAutomatic: Boolean = false) { + if (loadJob?.isActive == true || currentState.isSaving) return + loadJob = + viewModelScope.launch { + if (!isAutomatic) dispatch(DeliveryConditionReducerEvent.Loading) + runCatchingCancellable { receiverRepository.getReceiverDeliveryConditions(receiverId) } + .onSuccess { response -> + dispatch(DeliveryConditionReducerEvent.Loaded(response.conditions)) + }.onFailure { + if (!isAutomatic || !currentState.isInitialized) dispatch(DeliveryConditionReducerEvent.LoadFailed) + } + } } private fun onConditionTypeSelected(index: Int) { @@ -108,6 +142,8 @@ internal class DeliveryConditionViewModel val state = currentState if (!state.isInitialized || state.isSaving || state.isSaved) return + loadJob?.cancel() + val savingRevision = state.conditionEditRevision dispatch(DeliveryConditionReducerEvent.Saving) viewModelScope.launch { runCatchingCancellable { @@ -118,7 +154,7 @@ internal class DeliveryConditionViewModel inactivityPeriod = state.inactivityPeriod, ) }.onSuccess { response -> - dispatch(DeliveryConditionReducerEvent.Saved(response.conditions)) + dispatch(DeliveryConditionReducerEvent.Saved(savingRevision, response.conditions)) }.onFailure { dispatch(DeliveryConditionReducerEvent.SaveFailed) } diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/NotificationSettingScreen.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/NotificationSettingScreen.kt index dfe059cdfa..9487ad1c42 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/NotificationSettingScreen.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/NotificationSettingScreen.kt @@ -82,7 +82,7 @@ internal fun NotificationSettingScreen( } LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { - viewModel.onIntent(PushNotificationIntent.RefreshDeviceAlarmStatus) + viewModel.onIntent(PushNotificationIntent.RefreshOnReturn) } // 소비가 ObserveFlag 의 effect 를 다시 시작시켜도 스낵바가 끊기지 않게 화면 코루틴에 띄운다. diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationContract.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationContract.kt index 3ad1bf42a0..b3b35773fd 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationContract.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationContract.kt @@ -6,6 +6,8 @@ import com.afternote.core.ui.mvi.MviIntent import com.afternote.core.ui.mvi.ReducerEvent internal sealed interface PushNotificationIntent : MviIntent { + data object RefreshOnReturn : PushNotificationIntent + data object RetryLoad : PushNotificationIntent /** 기기 알림 허용 여부를 다시 읽는다. 화면 재개와 권한 요청 결과 때 보낸다. */ diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationScreen.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationScreen.kt index a0e68dfe4e..87333a6a64 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationScreen.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationScreen.kt @@ -3,6 +3,8 @@ package com.afternote.feature.setting.presentation.notification import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LifecycleEventEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.afternote.core.ui.popup.NetworkErrorPopup import com.afternote.core.ui.popup.ServerErrorPopup @@ -13,6 +15,7 @@ internal fun PushNotificationScreen( viewModel: PushNotificationViewModel = hiltViewModel(), ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() + LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { viewModel.onIntent(PushNotificationIntent.RefreshOnReturn) } PushNotificationContent( uiState = uiState, diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationUiState.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationUiState.kt index 71c9a1f277..2f42a72486 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationUiState.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationUiState.kt @@ -5,6 +5,7 @@ import com.afternote.core.ui.mvi.UiState internal data class PushNotificationUiState( val isLoading: Boolean = false, + val hasLoadedPushSettings: Boolean = false, val errorMessage: UiText? = null, val isDeviceAlarmOn: Boolean = false, // 마케팅 알림 (기기 알림 꺼졌을 때) diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationViewModel.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationViewModel.kt index de78ce49bf..1364a0a6ac 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationViewModel.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationViewModel.kt @@ -28,6 +28,9 @@ internal class PushNotificationViewModel PushNotificationUiState(isLoading = true), ) { private var loadJob: Job? = null + private var marketingLoadJob: Job? = null + private var marketingWritesInFlight = 0 + private var isFirstResume = true init { refreshDeviceAlarmStatus() @@ -37,6 +40,10 @@ internal class PushNotificationViewModel override fun onIntent(intent: PushNotificationIntent) { when (intent) { + PushNotificationIntent.RefreshOnReturn -> { + refreshOnReturn() + } + PushNotificationIntent.RetryLoad -> { if (currentState.errorMessage != null) loadPushSettings() } @@ -92,6 +99,7 @@ internal class PushNotificationViewModel state.copy( isLoading = false, errorMessage = null, + hasLoadedPushSettings = true, isNewsletterOn = event.setting.timeLetter, isMindRecordOn = event.setting.mindRecord, isAfternoteOn = event.setting.afterNote, @@ -159,9 +167,23 @@ internal class PushNotificationViewModel dispatch(PushNotificationReducerEvent.DeviceAlarmStatusRead(deviceAlarmOn)) } - private fun loadPushSettings() { - if (loadJob?.isActive == true) return - dispatch(PushNotificationReducerEvent.PushSettingsLoading) + private fun refreshOnReturn() { + refreshDeviceAlarmStatus() + if (isFirstResume) { + isFirstResume = false + return + } + loadPushSettings(isAutomatic = true) + loadMarketingConsents() + } + + private fun loadPushSettings(isAutomatic: Boolean = false) { + if (loadJob?.isActive == true || + currentState.run { isNewsletterUpdating || isMindRecordUpdating || isAfternoteUpdating } + ) { + return + } + if (!isAutomatic) dispatch(PushNotificationReducerEvent.PushSettingsLoading) loadJob = viewModelScope.launch { Log.d(TAG, "loadPushSettings: start") @@ -172,41 +194,53 @@ internal class PushNotificationViewModel }.onFailure { e -> // 조회 실패는 Logcat 에만 남긴다. 반복 조회 잡음이 저장 실패 진단의 보관 한도를 밀어내지 않게 한다 (#963). Log.e(TAG, "loadPushSettings: failed", e) - dispatch(PushNotificationReducerEvent.PushSettingsLoadFailed) + if (!isAutomatic || + !currentState.hasLoadedPushSettings + ) { + dispatch(PushNotificationReducerEvent.PushSettingsLoadFailed) + } } } } private fun loadMarketingConsents() { - viewModelScope.launch { - Log.d(TAG, "loadMarketingConsents: start") - runCatchingCancellable { notificationRepository.getMyMarketingConsents() } - .onSuccess { consent -> - Log.d(TAG, "loadMarketingConsents: success=$consent") - dispatch(PushNotificationReducerEvent.MarketingConsentsLoaded(consent)) - }.onFailure { e -> - Log.e(TAG, "loadMarketingConsents: failed", e) - } - } + if (marketingLoadJob?.isActive == true || marketingWritesInFlight > 0) return + marketingLoadJob = + viewModelScope.launch { + Log.d(TAG, "loadMarketingConsents: start") + runCatchingCancellable { notificationRepository.getMyMarketingConsents() } + .onSuccess { consent -> + Log.d(TAG, "loadMarketingConsents: success=$consent") + dispatch(PushNotificationReducerEvent.MarketingConsentsLoaded(consent)) + }.onFailure { e -> + Log.e(TAG, "loadMarketingConsents: failed", e) + } + } } private fun updateMarketingConsent( consent: MarketingConsent, checked: Boolean, ) { + marketingLoadJob?.cancel() + marketingWritesInFlight++ dispatch(PushNotificationReducerEvent.MarketingConsentChanged(consent, checked)) viewModelScope.launch { - runCatchingCancellable { - notificationRepository.updateMyMarketingConsents( - sms = checked.takeIf { consent == MarketingConsent.SMS }, - email = checked.takeIf { consent == MarketingConsent.EMAIL }, - push = checked.takeIf { consent == MarketingConsent.PUSH }, - ) - }.onSuccess { - Log.d(TAG, "updateMarketingConsent: success, consent=$consent, checked=$checked") - }.onFailure { e -> - errorReporter.recordFailure(e, mapOf(KEY_STAGE to consent.reportingStage())) - dispatch(PushNotificationReducerEvent.MarketingConsentSaveFailed(consent, requested = checked)) + try { + runCatchingCancellable { + notificationRepository.updateMyMarketingConsents( + sms = checked.takeIf { consent == MarketingConsent.SMS }, + email = checked.takeIf { consent == MarketingConsent.EMAIL }, + push = checked.takeIf { consent == MarketingConsent.PUSH }, + ) + }.onSuccess { + Log.d(TAG, "updateMarketingConsent: success, consent=$consent, checked=$checked") + }.onFailure { e -> + errorReporter.recordFailure(e, mapOf(KEY_STAGE to consent.reportingStage())) + dispatch(PushNotificationReducerEvent.MarketingConsentSaveFailed(consent, requested = checked)) + } + } finally { + marketingWritesInFlight-- } } } @@ -219,6 +253,7 @@ internal class PushNotificationViewModel private fun updatePushSetting(update: PushSettingUpdate) { if (currentState.isLoading || currentState.errorMessage != null || currentState.isUpdating(update.setting)) return + loadJob?.cancel() val previousValue = currentState.valueOf(update.setting) dispatch(PushNotificationReducerEvent.PushSettingSaving(update)) viewModelScope.launch { 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 index bdb8ee77cf..597b2b1813 100644 --- 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 @@ -4,6 +4,8 @@ import com.afternote.core.ui.mvi.MviIntent import com.afternote.core.ui.mvi.ReducerEvent internal sealed interface ProfileEditIntent : MviIntent { + data object RefreshOnReturn : ProfileEditIntent + data object RetryLoad : ProfileEditIntent data class SelectPhoto( 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 9907ed70ac..a969ebad2a 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 @@ -16,6 +16,7 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.text.input.InputTransformation import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Scaffold @@ -30,6 +31,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -37,6 +39,8 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LifecycleEventEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.afternote.core.ui.AfternoteTextField import com.afternote.core.ui.ProfileImagePicker @@ -59,6 +63,7 @@ internal fun ProfileEditScreen( viewModel: ProfileEditViewModel = hiltViewModel(), ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() + LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { viewModel.onIntent(ProfileEditIntent.RefreshOnReturn) } val snackbarHostState = remember { SnackbarHostState() } val feedbackScope = rememberCoroutineScope() val updateFailedMessage = stringResource(R.string.setting_profile_update_error) @@ -164,6 +169,20 @@ private fun ProfileEditForm( val nameState = rememberTextFieldState(initialText = state.name) val phoneState = rememberTextFieldState(initialText = state.phone) val emailState = rememberTextFieldState(initialText = state.email) + var previousName by rememberSaveable { mutableStateOf(state.name) } + var previousPhone by rememberSaveable { mutableStateOf(state.phone) } + LaunchedEffect(state.name, state.phone, state.email) { + // 편집하지 않은 필드만 새 서버 값으로 바꾸고 입력 중인 값과 커서를 보존한다. + if (nameState.text.toString() == previousName && previousName != state.name) { + nameState.setTextAndPlaceCursorAtEnd(state.name) + } + if (phoneState.text.toString() == previousPhone && previousPhone != state.phone) { + phoneState.setTextAndPlaceCursorAtEnd(state.phone) + } + if (emailState.text.toString() != state.email) emailState.setTextAndPlaceCursorAtEnd(state.email) + previousName = state.name + previousPhone = state.phone + } val isPhoneInvalid = phoneState.text.toString().validateReceiverPhone(isRequired = false) != ReceiverPhoneValidation.VALID LazyColumn( 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 782c1d5ffd..39be8a16c0 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 @@ -22,6 +22,7 @@ internal class ProfileEditViewModel private val photoUploadRepository: PhotoUploadRepository, ) : MviViewModel(ProfileEditUiState.Loading) { private var loadJob: Job? = null + private var isFirstResume = true init { loadProfile() @@ -29,6 +30,7 @@ internal class ProfileEditViewModel override fun onIntent(intent: ProfileEditIntent) { when (intent) { + ProfileEditIntent.RefreshOnReturn -> refreshOnReturn() ProfileEditIntent.RetryLoad -> if (currentState == ProfileEditUiState.Error) loadProfile() is ProfileEditIntent.SelectPhoto -> dispatch(ProfileEditReducerEvent.PhotoSelected(intent.uri)) is ProfileEditIntent.UpdateProfile -> updateProfile(intent.name, intent.phone) @@ -51,6 +53,8 @@ internal class ProfileEditViewModel phone = event.phone, email = event.email, profileImageUrl = event.profileImageUrl, + selectedImageUri = (state as? ProfileEditUiState.Success)?.selectedImageUri, + pendingEvent = (state as? ProfileEditUiState.Success)?.pendingEvent, ) } @@ -80,9 +84,17 @@ internal class ProfileEditViewModel } } - private fun loadProfile() { - if (loadJob?.isActive == true) return - dispatch(ProfileEditReducerEvent.Loading) + private fun refreshOnReturn() { + if (isFirstResume) { + isFirstResume = false + return + } + loadProfile(isAutomatic = true) + } + + private fun loadProfile(isAutomatic: Boolean = false) { + if (loadJob?.isActive == true || (currentState as? ProfileEditUiState.Success)?.isUpdating == true) return + if (!isAutomatic) dispatch(ProfileEditReducerEvent.Loading) loadJob = viewModelScope.launch { runCatchingCancellable { myProfileRepository.getMyProfile() } @@ -96,7 +108,7 @@ internal class ProfileEditViewModel ), ) }.onFailure { - dispatch(ProfileEditReducerEvent.LoadFailed) + if (!isAutomatic || currentState !is ProfileEditUiState.Success) dispatch(ProfileEditReducerEvent.LoadFailed) } } } @@ -108,6 +120,7 @@ internal class ProfileEditViewModel val current = currentState as? ProfileEditUiState.Success ?: return if (current.isUpdating) return if (phone.validateReceiverPhone(isRequired = false) != ReceiverPhoneValidation.VALID) return + loadJob?.cancel() dispatch(ProfileEditReducerEvent.Updating) viewModelScope.launch { runCatchingCancellable { diff --git a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/ProfileReentryFormTest.kt b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/ProfileReentryFormTest.kt new file mode 100644 index 0000000000..ecc217b4a3 --- /dev/null +++ b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/ProfileReentryFormTest.kt @@ -0,0 +1,93 @@ +package com.afternote.feature.setting.presentation + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.hasSetTextAction +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performScrollTo +import androidx.compose.ui.test.performTextReplacement +import com.afternote.core.domain.testing.FakeMyProfileRepository +import com.afternote.core.domain.testing.FakePhotoUploadRepository +import com.afternote.core.model.user.User +import com.afternote.core.ui.theme.AfternoteTheme +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 +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(sdk = [35]) +class ProfileReentryFormTest { + @get:Rule + val composeRule = createComposeRule() + + @Test + fun successfulRefresh_preservesEditedNameAndUpdatesUntouchedFields() { + val repository = + FakeMyProfileRepository.strict().apply { + onGetMyProfile = { User("초기 이름", "before@example.com", "01011112222", null) } + } + val viewModel = ProfileEditViewModel(repository, FakePhotoUploadRepository.strict()) + setContent(viewModel) + composeRule.onNode(hasSetTextAction() and hasText("초기 이름")).performScrollTo().performTextReplacement("작성 중인 이름") + + composeRule.runOnIdle { + repository.onGetMyProfile = { User("서버 이름", "after@example.com", "01033334444", null) } + viewModel.onIntent(ProfileEditIntent.RefreshOnReturn) + } + composeRule.waitUntil(timeoutMillis = 5_000) { + (viewModel.uiState.value as? ProfileEditUiState.Success)?.name == "서버 이름" + } + + composeRule.onNodeWithText("작성 중인 이름").performScrollTo().assertIsDisplayed() + composeRule.onNodeWithText("01033334444").performScrollTo().assertIsDisplayed() + composeRule.onNodeWithText("after@example.com").performScrollTo().assertIsDisplayed() + } + + @Test + fun failedRefresh_keepsEditedFormMounted() { + var calls = 0 + val repository = + FakeMyProfileRepository.strict().apply { + onGetMyProfile = { + calls++ + User("초기 이름", "before@example.com", "01011112222", null) + } + } + val viewModel = ProfileEditViewModel(repository, FakePhotoUploadRepository.strict()) + setContent(viewModel) + composeRule.onNode(hasSetTextAction() and hasText("초기 이름")).performScrollTo().performTextReplacement("작성 중인 이름") + composeRule.onNode(hasSetTextAction() and hasText("01011112222")).performScrollTo().performTextReplacement("01099998888") + + composeRule.runOnIdle { + repository.onGetMyProfile = { + calls++ + error("offline") + } + viewModel.onIntent(ProfileEditIntent.RefreshOnReturn) + } + composeRule.waitUntil(timeoutMillis = 5_000) { calls == 2 } + + assertTrue(viewModel.uiState.value is ProfileEditUiState.Success) + composeRule.onNodeWithText("작성 중인 이름").performScrollTo().assertIsDisplayed() + composeRule.onNodeWithText("01099998888").performScrollTo().assertIsDisplayed() + } + + private fun setContent(viewModel: ProfileEditViewModel) { + composeRule.setContent { + AfternoteTheme { + ProfileEditScreen(onBackClick = {}, onWithdrawGuideClick = {}, viewModel = viewModel) + } + } + composeRule.waitUntil(timeoutMillis = 5_000) { viewModel.uiState.value is ProfileEditUiState.Success } + } +} diff --git a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingReentryViewModelTest.kt b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingReentryViewModelTest.kt new file mode 100644 index 0000000000..27013bf8bf --- /dev/null +++ b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingReentryViewModelTest.kt @@ -0,0 +1,611 @@ +package com.afternote.feature.setting.presentation + +import androidx.test.core.app.ApplicationProvider +import com.afternote.core.domain.testing.FakeMyProfileRepository +import com.afternote.core.domain.testing.FakePhotoUploadRepository +import com.afternote.core.domain.testing.FakeUserReceiverRepository +import com.afternote.core.model.delivery.ConditionState +import com.afternote.core.model.delivery.DeliveryConditionItem +import com.afternote.core.model.delivery.DeliveryConditionType +import com.afternote.core.model.delivery.DeliveryContentType +import com.afternote.core.model.delivery.InactivityPeriod +import com.afternote.core.model.delivery.ReceiverDeliveryConditions +import com.afternote.core.model.user.User +import com.afternote.core.model.user.UserConnectedAccount +import com.afternote.core.model.user.UserMarketingConsent +import com.afternote.core.model.user.UserPushSetting +import com.afternote.feature.setting.domain.UpdateTimeLetterDeliveryConditionUseCase +import com.afternote.feature.setting.domain.testing.FakeSettingAccountRepository +import com.afternote.feature.setting.domain.testing.FakeSettingNotificationRepository +import com.afternote.feature.setting.presentation.NoOpErrorReporter +import com.afternote.feature.setting.presentation.account.ConnectedAccountsIntent +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.navigation.SettingRoute +import com.afternote.feature.setting.presentation.notification.MarketingConsent +import com.afternote.feature.setting.presentation.notification.PushNotificationIntent +import com.afternote.feature.setting.presentation.notification.PushNotificationViewModel +import com.afternote.feature.setting.presentation.notification.PushSetting +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 kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class SettingReentryViewModelTest { + private val dispatcher = StandardTestDispatcher() + + @Before + fun setUp() { + Dispatchers.setMain(dispatcher) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun connectedAccounts_firstResumeAndPendingLoadDoNotDuplicateAndRefreshKeepsContent() = + runTest(dispatcher) { + var calls = 0 + var response = CompletableDeferred() + val repository = + FakeSettingAccountRepository.strict().apply { + onGetConnectedAccounts = { + calls++ + response.await() + } + } + val viewModel = ConnectedAccountsViewModel(repository) + viewModel.onIntent(ConnectedAccountsIntent.RefreshOnReturn) + viewModel.onIntent(ConnectedAccountsIntent.RefreshOnReturn) + runCurrent() + assertEquals(1, calls) + response.complete(accounts()) + runCurrent() + val previous = viewModel.uiState.value + + response = CompletableDeferred() + viewModel.onIntent(ConnectedAccountsIntent.RefreshOnReturn) + viewModel.onIntent(ConnectedAccountsIntent.RefreshOnReturn) + runCurrent() + assertEquals(2, calls) + assertEquals(previous, viewModel.uiState.value) + response.complete(accounts(google = true)) + runCurrent() + assertTrue( + viewModel.uiState.value.accounts + .single { it.provider == "google" } + .isConnected, + ) + assertFalse(viewModel.uiState.value.isLoading) + } + + @Test + fun connectedAccounts_failedRefreshPreservesLoadedState() = + runTest(dispatcher) { + val repository = FakeSettingAccountRepository.strict().apply { onGetConnectedAccounts = { accounts() } } + val viewModel = ConnectedAccountsViewModel(repository) + runCurrent() + viewModel.onIntent(ConnectedAccountsIntent.RefreshOnReturn) + val previous = viewModel.uiState.value + repository.onGetConnectedAccounts = { error("offline") } + viewModel.onIntent(ConnectedAccountsIntent.RefreshOnReturn) + runCurrent() + assertEquals(previous, viewModel.uiState.value) + } + + @Test + fun connectedAccounts_successfulReentryClearsInitialLoadError() = + runTest(dispatcher) { + val repository = FakeSettingAccountRepository.strict().apply { onGetConnectedAccounts = { error("offline") } } + val viewModel = ConnectedAccountsViewModel(repository) + runCurrent() + assertNotNull(viewModel.uiState.value.errorMessage) + repository.onGetConnectedAccounts = { accounts() } + viewModel.onIntent(ConnectedAccountsIntent.RefreshOnReturn) + viewModel.onIntent(ConnectedAccountsIntent.RefreshOnReturn) + runCurrent() + assertNull(viewModel.uiState.value.errorMessage) + assertEquals(4, viewModel.uiState.value.accounts.size) + } + + @Test + fun connectedAccounts_linkCancelsStaleReadAndResumeWaitsForMutation() = + runTest(dispatcher) { + var reads = 0 + val stale = CompletableDeferred() + val linked = CompletableDeferred() + val repository = + FakeSettingAccountRepository.strict().apply { + onGetConnectedAccounts = { if (++reads == 1) accounts() else stale.await() } + onLinkConnectedAccount = { _, _ -> linked.await() } + } + val viewModel = ConnectedAccountsViewModel(repository) + runCurrent() + viewModel.onIntent(ConnectedAccountsIntent.RefreshOnReturn) + viewModel.onIntent(ConnectedAccountsIntent.RefreshOnReturn) + runCurrent() + viewModel.onIntent(ConnectedAccountsIntent.Link("google", "token")) + viewModel.onIntent(ConnectedAccountsIntent.RefreshOnReturn) + runCurrent() + assertEquals(2, reads) + linked.complete(accounts(google = true)) + runCurrent() + stale.complete(accounts()) + runCurrent() + assertTrue( + viewModel.uiState.value.accounts + .single { it.provider == "google" } + .isConnected, + ) + assertNull(viewModel.uiState.value.errorMessage) + } + + @Test + fun connectedAccounts_unlinkCancelsStaleReadAndResumeWaitsForMutation() = + runTest(dispatcher) { + var reads = 0 + val stale = CompletableDeferred() + val unlinked = CompletableDeferred() + val repository = + FakeSettingAccountRepository.strict().apply { + onGetConnectedAccounts = { if (++reads == 1) accounts(google = true) else stale.await() } + onUnlinkConnectedAccount = { unlinked.await() } + } + val viewModel = ConnectedAccountsViewModel(repository) + runCurrent() + viewModel.onIntent(ConnectedAccountsIntent.RefreshOnReturn) + viewModel.onIntent(ConnectedAccountsIntent.RefreshOnReturn) + runCurrent() + viewModel.onIntent(ConnectedAccountsIntent.Toggle("google", false)) + viewModel.onIntent(ConnectedAccountsIntent.RefreshOnReturn) + runCurrent() + assertEquals(2, reads) + unlinked.complete(accounts()) + runCurrent() + stale.complete(accounts(google = true)) + runCurrent() + assertFalse( + viewModel.uiState.value.accounts + .single { it.provider == "google" } + .isConnected, + ) + } + + @Test + fun delivery_firstResumeAndPendingLoadDoNotDuplicateAndRefreshKeepsContent() = + runTest(dispatcher) { + var calls = 0 + var response = CompletableDeferred() + val repository = + FakeUserReceiverRepository.strict().apply { + onGetReceiverDeliveryConditions = { + calls++ + response.await() + } + } + val viewModel = deliveryViewModel(repository) + viewModel.onIntent(DeliveryConditionIntent.RefreshOnReturn) + viewModel.onIntent(DeliveryConditionIntent.RefreshOnReturn) + runCurrent() + assertEquals(1, calls) + response.complete(delivery()) + runCurrent() + val previous = viewModel.uiState.value + response = CompletableDeferred() + viewModel.onIntent(DeliveryConditionIntent.RefreshOnReturn) + viewModel.onIntent(DeliveryConditionIntent.RefreshOnReturn) + runCurrent() + assertEquals(2, calls) + assertEquals(previous, viewModel.uiState.value) + response.complete(delivery(DeliveryConditionType.RECEIVER_REQUEST)) + runCurrent() + assertEquals(DeliveryConditionType.RECEIVER_REQUEST, viewModel.uiState.value.conditionType) + assertFalse(viewModel.uiState.value.isLoading) + } + + @Test + fun delivery_refreshPreservesUnsavedSelectionAndSavesItWithRefreshedConditions() = + runTest(dispatcher) { + val response = CompletableDeferred() + val repository = + FakeUserReceiverRepository.strict().apply { + onGetReceiverDeliveryConditions = { delivery() } + onUpdateReceiverDeliveryConditions = { id, conditions -> ReceiverDeliveryConditions(id, conditions) } + } + val viewModel = deliveryViewModel(repository) + runCurrent() + viewModel.onIntent(DeliveryConditionIntent.RefreshOnReturn) + viewModel.onIntent(DeliveryConditionIntent.SelectConditionType(1)) + repository.onGetReceiverDeliveryConditions = { response.await() } + viewModel.onIntent(DeliveryConditionIntent.RefreshOnReturn) + runCurrent() + assertEquals(DeliveryConditionType.RECEIVER_REQUEST, viewModel.uiState.value.conditionType) + val refreshed = + delivery().let { + it.copy( + conditions = + it.conditions + it.conditions.single().copy(contentType = DeliveryContentType.DIARY), + ) + } + response.complete(refreshed) + runCurrent() + assertEquals(DeliveryConditionType.RECEIVER_REQUEST, viewModel.uiState.value.conditionType) + viewModel.onIntent(DeliveryConditionIntent.Save) + runCurrent() + val saved = repository.deliveryUpdateCalls.single().conditions + assertEquals(DeliveryConditionType.RECEIVER_REQUEST, saved.first().conditionType) + assertNull(saved.first().inactivityPeriod) + assertEquals(refreshed.conditions.last(), saved.last()) + } + + @Test + fun delivery_editWhileRefreshIsPendingIsNotOverwritten() = + runTest(dispatcher) { + val response = CompletableDeferred() + val repository = FakeUserReceiverRepository.strict().apply { onGetReceiverDeliveryConditions = { delivery() } } + val viewModel = deliveryViewModel(repository) + runCurrent() + viewModel.onIntent(DeliveryConditionIntent.RefreshOnReturn) + repository.onGetReceiverDeliveryConditions = { response.await() } + viewModel.onIntent(DeliveryConditionIntent.RefreshOnReturn) + runCurrent() + viewModel.onIntent(DeliveryConditionIntent.SelectConditionType(1)) + response.complete(delivery()) + runCurrent() + assertEquals(DeliveryConditionType.RECEIVER_REQUEST, viewModel.uiState.value.conditionType) + } + + @Test + fun delivery_failedRefreshPreservesLoadedStateAndSuccessfulRetryClearsInitialError() = + runTest(dispatcher) { + val repository = FakeUserReceiverRepository.strict().apply { onGetReceiverDeliveryConditions = { error("offline") } } + val viewModel = deliveryViewModel(repository) + runCurrent() + assertEquals(DeliveryConditionError.LOAD_FAILED, viewModel.uiState.value.error) + viewModel.onIntent(DeliveryConditionIntent.RefreshOnReturn) + repository.onGetReceiverDeliveryConditions = { delivery() } + viewModel.onIntent(DeliveryConditionIntent.RefreshOnReturn) + runCurrent() + assertNull(viewModel.uiState.value.error) + val previous = viewModel.uiState.value + repository.onGetReceiverDeliveryConditions = { error("offline") } + viewModel.onIntent(DeliveryConditionIntent.RefreshOnReturn) + runCurrent() + assertEquals(previous, viewModel.uiState.value) + } + + @Test + fun delivery_saveCancelsStaleReadAndResumeDoesNotReloadDuringSave() = + runTest(dispatcher) { + var reads = 0 + val stale = CompletableDeferred() + val saved = CompletableDeferred() + val repository = + FakeUserReceiverRepository.strict().apply { + onGetReceiverDeliveryConditions = { if (++reads == 1) delivery() else stale.await() } + onUpdateReceiverDeliveryConditions = { _, _ -> saved.await() } + } + val viewModel = deliveryViewModel(repository) + runCurrent() + viewModel.onIntent(DeliveryConditionIntent.RefreshOnReturn) + viewModel.onIntent(DeliveryConditionIntent.RefreshOnReturn) + runCurrent() + viewModel.onIntent(DeliveryConditionIntent.SelectConditionType(1)) + viewModel.onIntent(DeliveryConditionIntent.Save) + viewModel.onIntent(DeliveryConditionIntent.Save) + viewModel.onIntent(DeliveryConditionIntent.RefreshOnReturn) + runCurrent() + assertEquals(1, repository.deliveryUpdateCalls.size) + assertEquals(2, reads) + saved.complete(delivery(DeliveryConditionType.RECEIVER_REQUEST)) + runCurrent() + stale.complete(delivery()) + runCurrent() + assertEquals( + DeliveryConditionType.RECEIVER_REQUEST, + viewModel.uiState.value.conditions + .single() + .conditionType, + ) + } + + @Test + fun profile_firstResumeAndPendingLoadDoNotDuplicateAndRefreshKeepsContent() = + runTest(dispatcher) { + var calls = 0 + var response = CompletableDeferred() + val repository = + FakeMyProfileRepository.strict().apply { + onGetMyProfile = { + calls++ + response.await() + } + } + val viewModel = ProfileEditViewModel(repository, FakePhotoUploadRepository.strict()) + viewModel.onIntent(ProfileEditIntent.RefreshOnReturn) + viewModel.onIntent(ProfileEditIntent.RefreshOnReturn) + runCurrent() + assertEquals(1, calls) + response.complete(user()) + runCurrent() + val previous = viewModel.uiState.value + response = CompletableDeferred() + viewModel.onIntent(ProfileEditIntent.RefreshOnReturn) + viewModel.onIntent(ProfileEditIntent.RefreshOnReturn) + runCurrent() + assertEquals(2, calls) + assertEquals(previous, viewModel.uiState.value) + response.complete(user().copy(name = "새 이름")) + runCurrent() + assertEquals("새 이름", (viewModel.uiState.value as ProfileEditUiState.Success).name) + } + + @Test + fun profile_failedRefreshPreservesLoadedForm() = + runTest(dispatcher) { + val repository = FakeMyProfileRepository.strict().apply { onGetMyProfile = { user() } } + val viewModel = ProfileEditViewModel(repository, FakePhotoUploadRepository.strict()) + runCurrent() + viewModel.onIntent(ProfileEditIntent.RefreshOnReturn) + val previous = viewModel.uiState.value + repository.onGetMyProfile = { error("offline") } + viewModel.onIntent(ProfileEditIntent.RefreshOnReturn) + runCurrent() + assertEquals(previous, viewModel.uiState.value) + } + + @Test + fun profile_successfulReentryRecoversInitialError() = + runTest(dispatcher) { + val repository = FakeMyProfileRepository.strict().apply { onGetMyProfile = { error("offline") } } + val viewModel = ProfileEditViewModel(repository, FakePhotoUploadRepository.strict()) + runCurrent() + assertEquals(ProfileEditUiState.Error, viewModel.uiState.value) + repository.onGetMyProfile = { user() } + viewModel.onIntent(ProfileEditIntent.RefreshOnReturn) + viewModel.onIntent(ProfileEditIntent.RefreshOnReturn) + runCurrent() + assertTrue(viewModel.uiState.value is ProfileEditUiState.Success) + } + + @Test + fun profile_saveCancelsStaleReadAndResumeDoesNotReloadDuringSave() = + runTest(dispatcher) { + var reads = 0 + val stale = CompletableDeferred() + val saved = CompletableDeferred() + val repository = + FakeMyProfileRepository.strict().apply { + onGetMyProfile = { if (++reads == 1) user() else stale.await() } + onUpdateMyProfile = { _, _, _ -> saved.await() } + } + val viewModel = ProfileEditViewModel(repository, FakePhotoUploadRepository.strict()) + runCurrent() + viewModel.onIntent(ProfileEditIntent.RefreshOnReturn) + viewModel.onIntent(ProfileEditIntent.RefreshOnReturn) + runCurrent() + viewModel.onIntent(ProfileEditIntent.UpdateProfile("작성한 이름", "01012345678")) + viewModel.onIntent(ProfileEditIntent.UpdateProfile("중복", "01000000000")) + viewModel.onIntent(ProfileEditIntent.RefreshOnReturn) + runCurrent() + assertEquals(2, reads) + assertEquals(1, repository.profileUpdateCalls.size) + saved.completeExceptionally(IllegalStateException("save failed")) + runCurrent() + stale.complete(user().copy(name = "stale")) + runCurrent() + assertEquals("이름", (viewModel.uiState.value as ProfileEditUiState.Success).name) + assertFalse((viewModel.uiState.value as ProfileEditUiState.Success).isUpdating) + } + + @Test + fun push_firstResumeAndPendingLoadDoNotDuplicateAndRefreshKeepsContent() = + runTest(dispatcher) { + var calls = 0 + var response = CompletableDeferred() + val repository = + FakeSettingNotificationRepository.strict().apply { + onGetMyPushSettings = { + calls++ + response.await() + } + } + val viewModel = pushViewModel(repository) + viewModel.onIntent(PushNotificationIntent.RefreshOnReturn) + viewModel.onIntent(PushNotificationIntent.RefreshOnReturn) + runCurrent() + assertEquals(1, calls) + response.complete(UserPushSetting(false, false, false)) + runCurrent() + val previous = viewModel.uiState.value + response = CompletableDeferred() + viewModel.onIntent(PushNotificationIntent.RefreshOnReturn) + viewModel.onIntent(PushNotificationIntent.RefreshOnReturn) + runCurrent() + assertEquals(2, calls) + assertEquals(previous, viewModel.uiState.value) + response.complete(UserPushSetting(true, true, true)) + runCurrent() + assertTrue(viewModel.uiState.value.isNewsletterOn) + assertTrue(viewModel.uiState.value.isMindRecordOn) + assertTrue(viewModel.uiState.value.isAfternoteOn) + assertFalse(viewModel.uiState.value.isLoading) + } + + @Test + fun push_failedRefreshPreservesLoadedState() = + runTest(dispatcher) { + val repository = + FakeSettingNotificationRepository.strict().apply { + onGetMyPushSettings = { + UserPushSetting( + true, + true, + true, + ) + } + } + val viewModel = pushViewModel(repository) + runCurrent() + viewModel.onIntent(PushNotificationIntent.RefreshOnReturn) + val previous = viewModel.uiState.value + repository.onGetMyPushSettings = { error("offline") } + viewModel.onIntent(PushNotificationIntent.RefreshOnReturn) + runCurrent() + assertEquals(previous, viewModel.uiState.value) + } + + @Test + fun push_saveCancelsStaleReadAndResumeDoesNotReloadDuringSave() = + runTest(dispatcher) { + var reads = 0 + val stale = CompletableDeferred() + val saved = CompletableDeferred() + val repository = + FakeSettingNotificationRepository.strict().apply { + onGetMyPushSettings = { if (++reads == 1) UserPushSetting(false, false, false) else stale.await() } + onUpdateMyPushSettings = { _, _, _ -> saved.await() } + } + val viewModel = pushViewModel(repository) + runCurrent() + viewModel.onIntent(PushNotificationIntent.RefreshOnReturn) + viewModel.onIntent(PushNotificationIntent.RefreshOnReturn) + runCurrent() + viewModel.onIntent(PushNotificationIntent.TogglePushSetting(PushSetting.NEWSLETTER, true)) + viewModel.onIntent(PushNotificationIntent.RefreshOnReturn) + runCurrent() + assertEquals(2, reads) + saved.complete(UserPushSetting(true, false, false)) + runCurrent() + stale.complete(UserPushSetting(false, false, false)) + runCurrent() + assertTrue(viewModel.uiState.value.isNewsletterOn) + } + + @Test + fun profile_refreshPreservesPhotoSelectedWhileRequestIsPending() = + runTest(dispatcher) { + val pending = CompletableDeferred() + val repository = FakeMyProfileRepository.strict().apply { onGetMyProfile = { user() } } + val viewModel = ProfileEditViewModel(repository, FakePhotoUploadRepository.strict()) + runCurrent() + viewModel.onIntent(ProfileEditIntent.RefreshOnReturn) + repository.onGetMyProfile = { pending.await() } + viewModel.onIntent(ProfileEditIntent.RefreshOnReturn) + runCurrent() + viewModel.onIntent(ProfileEditIntent.SelectPhoto("content://picked")) + pending.complete(user().copy(profileImageUrl = "https://example.com/server.jpg")) + runCurrent() + assertEquals("content://picked", (viewModel.uiState.value as ProfileEditUiState.Success).displayImageUri) + } + + @Test + fun marketing_firstResumeSkipsReadAndLaterResumeRefreshesServerConsent() = + runTest(dispatcher) { + val repository = FakeSettingNotificationRepository() + val viewModel = PushNotificationViewModel(ApplicationProvider.getApplicationContext(), repository, NoOpErrorReporter) + viewModel.onIntent(PushNotificationIntent.RefreshOnReturn) + runCurrent() + assertEquals(1, repository.getMyMarketingConsentsCalls) + repository.marketingConsent = UserMarketingConsent(false, false, true) + viewModel.onIntent(PushNotificationIntent.RefreshOnReturn) + runCurrent() + assertEquals(2, repository.getMyMarketingConsentsCalls) + assertFalse(viewModel.uiState.value.isSmsChecked) + assertFalse(viewModel.uiState.value.isEmailChecked) + assertTrue(viewModel.uiState.value.isPushChecked) + val previous = viewModel.uiState.value + repository.onGetMyMarketingConsents = { error("offline") } + viewModel.onIntent(PushNotificationIntent.RefreshOnReturn) + runCurrent() + assertEquals(previous, viewModel.uiState.value) + } + + @Test + fun marketing_writeCancelsStaleLoadAndReentryWaitsForPendingWrite() = + runTest(dispatcher) { + val stale = CompletableDeferred() + val saved = CompletableDeferred() + val repository = FakeSettingNotificationRepository() + val viewModel = PushNotificationViewModel(ApplicationProvider.getApplicationContext(), repository, NoOpErrorReporter) + viewModel.onIntent(PushNotificationIntent.RefreshOnReturn) + runCurrent() + repository.onGetMyMarketingConsents = { stale.await() } + viewModel.onIntent(PushNotificationIntent.RefreshOnReturn) + runCurrent() + repository.onUpdateMyMarketingConsents = { _, _, _ -> saved.await() } + viewModel.onIntent(PushNotificationIntent.ChangeMarketingConsent(MarketingConsent.SMS, false)) + viewModel.onIntent(PushNotificationIntent.RefreshOnReturn) + runCurrent() + assertEquals(2, repository.getMyMarketingConsentsCalls) + stale.complete(UserMarketingConsent(true, true, false)) + saved.complete(UserMarketingConsent(false, true, false)) + runCurrent() + assertFalse(viewModel.uiState.value.isSmsChecked) + repository.onGetMyMarketingConsents = { UserMarketingConsent(false, false, true) } + viewModel.onIntent(PushNotificationIntent.RefreshOnReturn) + runCurrent() + assertEquals(3, repository.getMyMarketingConsentsCalls) + assertFalse(viewModel.uiState.value.isEmailChecked) + } + + private fun deliveryViewModel(repository: FakeUserReceiverRepository) = + DeliveryConditionViewModel( + SettingRoute.AfterDeliveryRoute(receiverId = 42L), + repository, + UpdateTimeLetterDeliveryConditionUseCase(repository), + ) + + private fun pushViewModel(repository: FakeSettingNotificationRepository) = + PushNotificationViewModel( + ApplicationProvider.getApplicationContext(), + repository.apply { onGetMyMarketingConsents = { UserMarketingConsent(true, true, false) } }, + NoOpErrorReporter, + ) + + private fun user() = User("이름", "user@example.com", "01012345678", null) + + private fun accounts(google: Boolean = false) = UserConnectedAccount(true, google, false, false, false, null, null, null, null, null) + + private fun delivery(type: DeliveryConditionType = DeliveryConditionType.INACTIVITY) = + ReceiverDeliveryConditions( + receiverId = 42L, + conditions = + listOf( + DeliveryConditionItem( + contentType = DeliveryContentType.TIME_LETTER, + conditionType = type, + inactivityPeriod = InactivityPeriod.SIX_MONTHS.takeIf { type == DeliveryConditionType.INACTIVITY }, + state = ConditionState.ACTIVE, + fulfilled = false, + gracePeriodStartedAt = null, + fulfilledAt = null, + ), + ), + ) +} diff --git a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/notification/NotificationSettingSignalTest.kt b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/notification/NotificationSettingSignalTest.kt index 4ef2fdb83c..69d87b2b6e 100644 --- a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/notification/NotificationSettingSignalTest.kt +++ b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/notification/NotificationSettingSignalTest.kt @@ -16,6 +16,7 @@ import com.afternote.core.ui.theme.AfternoteTheme import com.afternote.feature.setting.domain.testing.FakeSettingNotificationRepository import com.afternote.feature.setting.presentation.NoOpErrorReporter import com.afternote.feature.setting.presentation.R +import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Rule @@ -114,6 +115,24 @@ class NotificationSettingSignalTest { assertFalse(isFailedMessageShown()) } + @Test + fun `알림 설정 화면 첫 진입은 중복 조회하지 않고 복귀하면 서버 동의를 갱신한다`() { + setScreen() + composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { viewModel.uiState.value.isMarketingFeedbackActive } + composeRule.waitForIdle() + assertEquals(1, repository.getMyMarketingConsentsCalls) + composeRule.runOnIdle { + lifecycleOwner.moveTo(Lifecycle.State.CREATED) + repository.onGetMyMarketingConsents = { UserMarketingConsent(sms = false, email = false, push = false) } + } + composeRule.runOnIdle { lifecycleOwner.moveTo(Lifecycle.State.RESUMED) } + composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { + repository.getMyMarketingConsentsCalls == 2 && !viewModel.uiState.value.isSmsChecked + } + assertFalse(viewModel.uiState.value.isEmailChecked) + assertFalse(viewModel.uiState.value.isPushChecked) + } + private fun isFailedMessageShown(): Boolean = composeRule.onAllNodesWithText(failedMessage).fetchSemanticsNodes().isNotEmpty() private fun setScreen() { diff --git a/konsist/src/test/kotlin/com/afternote/konsist/RefreshOnReturnWiringKonsistTest.kt b/konsist/src/test/kotlin/com/afternote/konsist/RefreshOnReturnWiringKonsistTest.kt index a7d1feec94..ce9bd9bce8 100644 --- a/konsist/src/test/kotlin/com/afternote/konsist/RefreshOnReturnWiringKonsistTest.kt +++ b/konsist/src/test/kotlin/com/afternote/konsist/RefreshOnReturnWiringKonsistTest.kt @@ -35,7 +35,7 @@ class RefreshOnReturnWiringKonsistTest { @Test fun `refreshOnReturn 을 선언한 ViewModel 은 ON_RESUME 결선을 갖는다`() { val mainFiles = mainSourceFiles() - val wiringSites = mainFiles.filter { ON_RESUME in it.text && REFRESH_CALL in it.text } + val wiringSites = mainFiles.filter { ON_RESUME in it.text && (REFRESH_CALL in it.text || ".RefreshOnReturn" in it.text) } val unwired = mainFiles @@ -64,6 +64,23 @@ class RefreshOnReturnWiringKonsistTest { } } + @Test + fun `설정 재진입 갱신은 마케팅 동의를 포함한 모든 소비 화면에 결선된다`() { + val expected = + mapOf( + "ConnectedAccountsScreen.kt" to "ConnectedAccountsIntent", + "DeliveryConditionScreen.kt" to "DeliveryConditionIntent", + "ProfileEditScreen.kt" to "ProfileEditIntent", + "PushNotificationScreen.kt" to "PushNotificationIntent", + "NotificationSettingScreen.kt" to "PushNotificationIntent", + ) + val settingFiles = mainSourceFiles().filter { "/feature/setting/" in "/${it.normalizedProjectPath()}" } + expected.forEach { (fileName, intent) -> + val source = settingFiles.single { it.normalizedProjectPath().endsWith("/$fileName") }.text + check(ON_RESUME in source && "$intent.RefreshOnReturn" in source) { "$fileName 재진입 갱신 결선 누락" } + } + } + private fun mainSourceFiles(): List = AfternoteKonsistScope.files.filter { "/src/main/" in it.normalizedProjectPath() }