diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsContent.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsContent.kt index 1a51323a4d..5ae70a7d4b 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsContent.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsContent.kt @@ -1,5 +1,6 @@ package com.afternote.feature.setting.presentation.account +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -14,10 +15,12 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import com.afternote.core.ui.loading.LoadingBody 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.account.component.SocialAccountRow +import com.afternote.feature.setting.presentation.shared.component.SettingLoadErrorContent /** * 연결된 계정 화면의 상태 없는 본문. @@ -30,6 +33,7 @@ internal fun ConnectedAccountsContent( snackbarHostState: SnackbarHostState, onBack: () -> Unit, onToggle: (provider: String, enabled: Boolean) -> Unit, + onRetry: () -> Unit, modifier: Modifier = Modifier, ) { Scaffold( @@ -43,30 +47,46 @@ internal fun ConnectedAccountsContent( ) }, ) { paddingValues -> - Column( - modifier = - Modifier - .fillMaxSize() - .padding(paddingValues) - .padding(horizontal = 20.dp, vertical = 16.dp), - ) { - Text( - text = stringResource(R.string.setting_sns_login_section_title), - style = AfternoteDesign.typography.bodyLargeR, - ) - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = stringResource(R.string.setting_sns_login_section_desc), - style = AfternoteDesign.typography.bodySmallR, - ) - Spacer(modifier = Modifier.height(24.dp)) + when { + uiState.isLoading -> { + LoadingBody(modifier = Modifier.padding(paddingValues)) + } - uiState.accounts.forEach { account -> - SocialAccountRow( - account = account, - onToggle = { enabled -> onToggle(account.provider, enabled) }, + uiState.errorMessage != null -> { + SettingLoadErrorContent( + message = uiState.errorMessage, + onRetry = onRetry, + modifier = Modifier.padding(paddingValues), ) - Spacer(modifier = Modifier.height(8.dp)) + } + + else -> { + Column( + modifier = + Modifier + .fillMaxSize() + .padding(paddingValues) + .padding(horizontal = 20.dp, vertical = 16.dp), + ) { + Text( + text = stringResource(R.string.setting_sns_login_section_title), + style = AfternoteDesign.typography.bodyLargeR, + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = stringResource(R.string.setting_sns_login_section_desc), + style = AfternoteDesign.typography.bodySmallR, + ) + Spacer(modifier = Modifier.height(24.dp)) + + uiState.accounts.forEach { account -> + SocialAccountRow( + account = account, + onToggle = { enabled -> if (!uiState.isUpdating) onToggle(account.provider, enabled) }, + ) + Spacer(modifier = Modifier.height(8.dp)) + } + } } } } 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 54ccab2404..cb5c3ae9af 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,12 @@ import com.afternote.core.ui.mvi.MviIntent import com.afternote.core.ui.mvi.ReducerEvent internal sealed interface ConnectedAccountsIntent : MviIntent { + data object RetryLoad : ConnectedAccountsIntent + + data class ConsumeError( + val message: String, + ) : ConnectedAccountsIntent + data class Toggle( val provider: String, val enabled: Boolean, @@ -22,6 +28,14 @@ internal sealed interface ConnectedAccountsIntent : MviIntent { } internal sealed interface ConnectedAccountsReducerEvent : ReducerEvent { + data object Loading : ConnectedAccountsReducerEvent + + data object Changing : ConnectedAccountsReducerEvent + + data class ErrorConsumed( + val message: String, + ) : ConnectedAccountsReducerEvent + data class Loaded( val accounts: List, ) : ConnectedAccountsReducerEvent 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 807850e881..a28a48c960 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 @@ -89,10 +89,17 @@ internal fun ConnectedAccountsScreen( } } + uiState.pendingError?.let { message -> + ObserveSignal(message, ConnectedAccountsIntent.ConsumeError(message), viewModel::onIntent) { + linkScope.launch { snackbarHostState.showSnackbar(it) } + } + } + ConnectedAccountsContent( uiState = uiState, snackbarHostState = snackbarHostState, onBack = onBack, + onRetry = { viewModel.onIntent(ConnectedAccountsIntent.RetryLoad) }, onToggle = { provider, enabled -> viewModel.onIntent(ConnectedAccountsIntent.Toggle(provider, enabled)) }, modifier = modifier, ) diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsUiState.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsUiState.kt index e17aaa182f..56754d98a2 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsUiState.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsUiState.kt @@ -6,6 +6,8 @@ data class ConnectedAccountsUiState( val isLoading: Boolean = false, val accounts: List = emptyList(), val errorMessage: String? = null, + val isUpdating: Boolean = false, + val pendingError: String? = null, /** * 화면이 플랫폼 인증(카카오 SDK·Credential Manager)을 시작해야 하는 제공자 신호다 (#1502). * 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 50f8d516f9..37d0a2abdc 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 @@ -7,6 +7,7 @@ import com.afternote.core.ui.mvi.MviViewModel import com.afternote.feature.setting.domain.SettingAccountRepository import com.afternote.feature.setting.presentation.R import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job import kotlinx.coroutines.launch import javax.inject.Inject @@ -18,12 +19,22 @@ internal class ConnectedAccountsViewModel ) : MviViewModel( ConnectedAccountsUiState(isLoading = true), ) { + private var loadJob: Job? = null + init { loadConnectedAccounts() } override fun onIntent(intent: ConnectedAccountsIntent) { when (intent) { + ConnectedAccountsIntent.RetryLoad -> { + if (currentState.errorMessage != null) loadConnectedAccounts() + } + + is ConnectedAccountsIntent.ConsumeError -> { + dispatch(ConnectedAccountsReducerEvent.ErrorConsumed(intent.message)) + } + is ConnectedAccountsIntent.Toggle -> { toggle(intent.provider, intent.enabled) } @@ -45,8 +56,26 @@ internal class ConnectedAccountsViewModel event: ConnectedAccountsReducerEvent, ): ConnectedAccountsUiState = when (event) { + ConnectedAccountsReducerEvent.Loading -> { + state.copy(isLoading = true, errorMessage = null) + } + + ConnectedAccountsReducerEvent.Changing -> { + state.copy(isUpdating = true, pendingError = null) + } + + is ConnectedAccountsReducerEvent.ErrorConsumed -> { + if (state.pendingError == + event.message + ) { + state.copy(pendingError = null) + } else { + state + } + } + is ConnectedAccountsReducerEvent.Loaded -> { - state.copy(isLoading = false, accounts = event.accounts) + state.copy(isLoading = false, errorMessage = null, accounts = event.accounts) } is ConnectedAccountsReducerEvent.LoadFailed -> { @@ -54,11 +83,11 @@ internal class ConnectedAccountsViewModel } is ConnectedAccountsReducerEvent.AccountsChanged -> { - state.copy(accounts = event.accounts) + state.copy(accounts = event.accounts, isUpdating = false) } is ConnectedAccountsReducerEvent.ChangeFailed -> { - state.copy(errorMessage = event.message) + state.copy(pendingError = event.message, isUpdating = false) } is ConnectedAccountsReducerEvent.LinkRequested -> { @@ -72,20 +101,24 @@ internal class ConnectedAccountsViewModel } private fun loadConnectedAccounts() { - viewModelScope.launch { - runCatchingCancellable { accountRepository.getConnectedAccounts() } - .onSuccess { accounts -> - dispatch(ConnectedAccountsReducerEvent.Loaded(accounts.toStateList())) - }.onFailure { - dispatch(ConnectedAccountsReducerEvent.LoadFailed("계정 정보를 불러올 수 없습니다.")) - } - } + if (loadJob?.isActive == true) return + dispatch(ConnectedAccountsReducerEvent.Loading) + loadJob = + viewModelScope.launch { + runCatchingCancellable { accountRepository.getConnectedAccounts() } + .onSuccess { accounts -> + dispatch(ConnectedAccountsReducerEvent.Loaded(accounts.toStateList())) + }.onFailure { + dispatch(ConnectedAccountsReducerEvent.LoadFailed("계정 정보를 불러올 수 없습니다.")) + } + } } private fun toggle( provider: String, enabled: Boolean, ) { + if (currentState.isLoading || currentState.errorMessage != null || currentState.isUpdating) return if (enabled) { dispatch(ConnectedAccountsReducerEvent.LinkRequested(provider)) } else { @@ -97,6 +130,8 @@ internal class ConnectedAccountsViewModel provider: String, accessToken: String, ) { + if (currentState.isLoading || currentState.errorMessage != null || currentState.isUpdating) return + dispatch(ConnectedAccountsReducerEvent.Changing) viewModelScope.launch { runCatchingCancellable { accountRepository.linkConnectedAccount(provider, accessToken) } .onSuccess { accounts -> dispatch(ConnectedAccountsReducerEvent.AccountsChanged(accounts.toStateList())) } @@ -105,6 +140,7 @@ internal class ConnectedAccountsViewModel } private fun unlink(provider: String) { + dispatch(ConnectedAccountsReducerEvent.Changing) viewModelScope.launch { runCatchingCancellable { accountRepository.unlinkConnectedAccount(provider) } .onSuccess { accounts -> dispatch(ConnectedAccountsReducerEvent.AccountsChanged(accounts.toStateList())) } diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/home/SettingContract.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/home/SettingContract.kt new file mode 100644 index 0000000000..99198174b3 --- /dev/null +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/home/SettingContract.kt @@ -0,0 +1,68 @@ +package com.afternote.feature.setting.presentation.home + +import com.afternote.core.ui.mvi.MviIntent +import com.afternote.core.ui.mvi.ReducerEvent +import com.afternote.core.ui.mvi.UiState + +internal data class SettingUiState( + val profile: SettingProfileState = SettingProfileState.Loading, + val logoutCompleted: Unit? = null, + val withdraw: WithdrawUiState = WithdrawUiState.Idle, +) : UiState + +internal sealed interface SettingProfileState { + data object Loading : SettingProfileState + + data class Success( + val name: String, + val email: String, + ) : SettingProfileState + + /** 문구는 화면이 `setting_profile_load_error` 리소스로 그린다. 프로필 수정 화면과 같은 문장이다. */ + data object Error : SettingProfileState +} + +internal sealed interface WithdrawUiState { + data object Idle : WithdrawUiState + + data object Loading : WithdrawUiState + + data object Success : WithdrawUiState + + data object Error : WithdrawUiState +} + +internal sealed interface SettingIntent : MviIntent { + data object Refresh : SettingIntent + + data object Logout : SettingIntent + + data object ConsumeLogoutSuccess : SettingIntent + + data object DeleteAccount : SettingIntent + + data object DismissWithdrawError : SettingIntent +} + +internal sealed interface SettingReducerEvent : ReducerEvent { + data object ProfileLoading : SettingReducerEvent + + data class ProfileLoaded( + val name: String, + val email: String, + ) : SettingReducerEvent + + data object ProfileFailed : SettingReducerEvent + + data object LoggedOut : SettingReducerEvent + + data object LogoutConsumed : SettingReducerEvent + + data object Withdrawing : SettingReducerEvent + + data object Withdrawn : SettingReducerEvent + + data object WithdrawFailed : SettingReducerEvent + + data object WithdrawErrorDismissed : SettingReducerEvent +} diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/home/SettingScreen.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/home/SettingScreen.kt index 0474ef8b65..9178b05dab 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/home/SettingScreen.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/home/SettingScreen.kt @@ -1,7 +1,10 @@ package com.afternote.feature.setting.presentation.home +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll @@ -26,12 +29,14 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.Lifecycle import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.afternote.core.ui.mvi.ObserveSignal import com.afternote.core.ui.popup.Popup import com.afternote.core.ui.popup.PopupType import com.afternote.core.ui.topbar.DetailTopBar import com.afternote.feature.setting.presentation.R import com.afternote.feature.setting.presentation.home.component.SettingProfile import com.afternote.feature.setting.presentation.home.component.SettingSection +import com.afternote.feature.setting.presentation.shared.component.SettingLoadErrorContent import com.afternote.feature.setting.presentation.shared.component.SettingMenuItem import kotlinx.coroutines.launch @@ -59,19 +64,18 @@ internal fun SettingScreen( val unavailableMessage = stringResource(R.string.setting_menu_unavailable) val acknowledgeLabel = stringResource(R.string.setting_menu_acknowledge) val uiState by viewModel.uiState.collectAsStateWithLifecycle() - val logoutCompleted by viewModel.logoutCompleted.collectAsStateWithLifecycle() val currentOnLogoutSuccess by rememberUpdatedState(onLogoutSuccess) val lifecycle = LocalLifecycleOwner.current.lifecycle val lifecycleState by lifecycle.currentStateFlow.collectAsStateWithLifecycle() LaunchedEffect(lifecycleState) { if (lifecycleState == Lifecycle.State.RESUMED) { - viewModel.refresh() + viewModel.onIntent(SettingIntent.Refresh) } } - LaunchedEffect(logoutCompleted) { - if (logoutCompleted) { + uiState.logoutCompleted?.let { signal -> + ObserveSignal(signal, SettingIntent.ConsumeLogoutSuccess, viewModel::onIntent) { currentOnLogoutSuccess() } } @@ -88,8 +92,9 @@ internal fun SettingScreen( containerColor = Color.Transparent, ) { innerPadding -> SettingScreenContent( - uiState = uiState, - onLogoutClick = viewModel::logout, + uiState = uiState.profile, + onRetry = { viewModel.onIntent(SettingIntent.Refresh) }, + onLogoutClick = { viewModel.onIntent(SettingIntent.Logout) }, onUnavailableClick = { scope.launch { snackbarHostState.currentSnackbarData?.dismiss() @@ -114,7 +119,8 @@ internal fun SettingScreen( @Composable private fun SettingScreenContent( - uiState: SettingUiState, + uiState: SettingProfileState, + onRetry: () -> Unit, onLogoutClick: () -> Unit, onUnavailableClick: () -> Unit, onProfileEditClick: () -> Unit, @@ -146,23 +152,17 @@ private fun SettingScreenContent( ) } - when (val state = uiState) { - is SettingUiState.Loading -> { - Column( - modifier = modifier.fillMaxSize(), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - CircularProgressIndicator() + Column( + modifier = modifier.fillMaxSize().verticalScroll(rememberScrollState()), + ) { + when (val state = uiState) { + SettingProfileState.Loading -> { + Box(Modifier.fillMaxWidth().height(160.dp), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } } - } - is SettingUiState.Success -> { - Column( - modifier = - modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()), - ) { + is SettingProfileState.Success -> { SettingProfile( name = state.name, email = state.email, @@ -170,101 +170,96 @@ private fun SettingScreenContent( onNoticeClick = onNoticeClick, onRecipientListClick = onRecipientListClick, ) + } - SettingSection(title = stringResource(R.string.setting_section_account)) { - SettingMenuItem( - label = stringResource(R.string.setting_account_profile_edit), - onClick = onProfileEditClick, - ) - SettingMenuItem( - label = stringResource(R.string.setting_account_password_change), - onClick = onPasswordChangeClick, - ) - SettingMenuItem( - label = stringResource(R.string.setting_account_linked_account), - onClick = onLinkedAccountClick, - ) - SettingMenuItem( - label = stringResource(R.string.setting_account_notification), - onClick = onNotificationClick, - ) - } - - SettingSection(title = stringResource(R.string.setting_section_recipient)) { - SettingMenuItem( - label = stringResource(R.string.setting_recipient_list), - onClick = onRecipientListClick, - ) - SettingMenuItem( - label = stringResource(R.string.setting_recipient_register), - onClick = onRecipientRegisterClick, - ) - SettingMenuItem( - label = stringResource(R.string.setting_recipient_after_delivery), - onClick = onDeliveryConditionsClick, - ) - } - - SettingSection(title = stringResource(R.string.setting_section_security)) { - SettingMenuItem( - label = stringResource(R.string.setting_security_passkey), - onClick = onPasskeyClick, - ) - SettingMenuItem( - label = stringResource(R.string.setting_security_app_lock), - onClick = onAppLockClick, - ) - } - - SettingSection(title = stringResource(R.string.setting_section_support)) { - SettingMenuItem( - label = stringResource(R.string.setting_support_faq), - onClick = onUnavailableClick, - ) - SettingMenuItem( - label = stringResource(R.string.setting_support_inquiry), - onClick = onUnavailableClick, - ) - SettingMenuItem( - label = stringResource(R.string.setting_support_notice), - onClick = onNoticeClick, - ) - SettingMenuItem( - label = stringResource(R.string.setting_support_terms), - onClick = onUnavailableClick, - ) - SettingMenuItem( - label = stringResource(R.string.setting_support_privacy), - onClick = onUnavailableClick, - ) - SettingMenuItem( - label = stringResource(R.string.setting_support_service_info), - onClick = onUnavailableClick, - ) - } - - SettingMenuItem( - label = stringResource(R.string.setting_logout), - onClick = { showLogoutDialog = true }, - modifier = Modifier.padding(top = 8.dp), - ) - SettingMenuItem( - label = stringResource(R.string.setting_account_withdraw), - onClick = onWithdrawGuideClick, + SettingProfileState.Error -> { + SettingLoadErrorContent( + message = stringResource(R.string.setting_profile_load_error), + onRetry = onRetry, + modifier = Modifier.height(160.dp), ) } } + SettingSection(title = stringResource(R.string.setting_section_account)) { + SettingMenuItem( + label = stringResource(R.string.setting_account_profile_edit), + onClick = onProfileEditClick, + ) + SettingMenuItem( + label = stringResource(R.string.setting_account_password_change), + onClick = onPasswordChangeClick, + ) + SettingMenuItem( + label = stringResource(R.string.setting_account_linked_account), + onClick = onLinkedAccountClick, + ) + SettingMenuItem( + label = stringResource(R.string.setting_account_notification), + onClick = onNotificationClick, + ) + } - is SettingUiState.Error -> { - Column( - modifier = modifier.fillMaxSize(), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - SettingMenuItem( - label = stringResource(R.string.setting_logout), - onClick = { showLogoutDialog = true }, - ) - } + SettingSection(title = stringResource(R.string.setting_section_recipient)) { + SettingMenuItem( + label = stringResource(R.string.setting_recipient_list), + onClick = onRecipientListClick, + ) + SettingMenuItem( + label = stringResource(R.string.setting_recipient_register), + onClick = onRecipientRegisterClick, + ) + SettingMenuItem( + label = stringResource(R.string.setting_recipient_after_delivery), + onClick = onDeliveryConditionsClick, + ) + } + + SettingSection(title = stringResource(R.string.setting_section_security)) { + SettingMenuItem( + label = stringResource(R.string.setting_security_passkey), + onClick = onPasskeyClick, + ) + SettingMenuItem( + label = stringResource(R.string.setting_security_app_lock), + onClick = onAppLockClick, + ) + } + + SettingSection(title = stringResource(R.string.setting_section_support)) { + SettingMenuItem( + label = stringResource(R.string.setting_support_faq), + onClick = onUnavailableClick, + ) + SettingMenuItem( + label = stringResource(R.string.setting_support_inquiry), + onClick = onUnavailableClick, + ) + SettingMenuItem( + label = stringResource(R.string.setting_support_notice), + onClick = onNoticeClick, + ) + SettingMenuItem( + label = stringResource(R.string.setting_support_terms), + onClick = onUnavailableClick, + ) + SettingMenuItem( + label = stringResource(R.string.setting_support_privacy), + onClick = onUnavailableClick, + ) + SettingMenuItem( + label = stringResource(R.string.setting_support_service_info), + onClick = onUnavailableClick, + ) } + + SettingMenuItem( + label = stringResource(R.string.setting_logout), + onClick = { showLogoutDialog = true }, + modifier = Modifier.padding(top = 8.dp), + ) + SettingMenuItem( + label = stringResource(R.string.setting_account_withdraw), + onClick = onWithdrawGuideClick, + ) } } diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/home/SettingViewModel.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/home/SettingViewModel.kt index 6a461aa179..64652613b9 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/home/SettingViewModel.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/home/SettingViewModel.kt @@ -1,47 +1,16 @@ package com.afternote.feature.setting.presentation.home -import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.afternote.core.domain.repository.MyProfileRepository import com.afternote.core.domain.repository.auth.AuthRepository import com.afternote.core.domain.result.runCatchingCancellable +import com.afternote.core.ui.mvi.MviViewModel import com.afternote.feature.setting.domain.SettingAccountRepository import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.Job import kotlinx.coroutines.launch import javax.inject.Inject -sealed interface SettingUiState { - data object Loading : SettingUiState - - data class Success( - val name: String, - val email: String, - ) : SettingUiState - - data class Error( - val message: String, - ) : SettingUiState -} - -sealed interface WithdrawUiState { - data object Idle : WithdrawUiState - - data object Loading : WithdrawUiState - - data object Success : WithdrawUiState - - data object Error : WithdrawUiState -} - -/** - * 설정 화면용 ViewModel. - * - * 로그아웃 시 [AuthRepository.logout] 한 번 호출로 끝낸다. 내부적으로 서버 호출은 best-effort, - * 로컬 토큰은 결과 무관하게 정리하도록 위임돼 있다. 호출처는 토큰 정리 여부를 신경 쓸 필요 없음. - */ @HiltViewModel internal class SettingViewModel @Inject @@ -49,64 +18,99 @@ internal class SettingViewModel private val authRepository: AuthRepository, private val myProfileRepository: MyProfileRepository, private val accountRepository: SettingAccountRepository, - ) : ViewModel() { - private val _uiState = MutableStateFlow(SettingUiState.Loading) - val uiState = _uiState.asStateFlow() - - private val _logoutCompleted = MutableStateFlow(false) - val logoutCompleted = _logoutCompleted.asStateFlow() + ) : MviViewModel(SettingUiState()) { + private var loadJob: Job? = null + private var logoutJob: Job? = null init { loadProfile() } - fun refresh() = loadProfile() - - private fun loadProfile() { - viewModelScope.launch { - runCatchingCancellable { myProfileRepository.getMyProfile() } - .onSuccess { profile -> - _uiState.value = - SettingUiState.Success( - name = profile.name, - email = profile.email, - ) - }.onFailure { - _uiState.value = SettingUiState.Error("프로필을 불러올 수 없습니다.") - } + override fun onIntent(intent: SettingIntent) { + when (intent) { + SettingIntent.Refresh -> loadProfile() + SettingIntent.Logout -> logout() + SettingIntent.ConsumeLogoutSuccess -> dispatch(SettingReducerEvent.LogoutConsumed) + SettingIntent.DeleteAccount -> deleteAccount() + SettingIntent.DismissWithdrawError -> dispatch(SettingReducerEvent.WithdrawErrorDismissed) } } - fun logout() { - viewModelScope.launch { - // logout() 내부에서 서버 호출(best-effort) + 로컬 토큰 정리까지 처리한다. - authRepository.logout() - _logoutCompleted.value = true - } - } + override fun reduce( + state: SettingUiState, + event: SettingReducerEvent, + ): SettingUiState = + when (event) { + SettingReducerEvent.ProfileLoading -> { + if (state.profile is SettingProfileState.Success) state else state.copy(profile = SettingProfileState.Loading) + } - private val _withdrawUiState = MutableStateFlow(WithdrawUiState.Idle) - val withdrawUiState = _withdrawUiState.asStateFlow() + is SettingReducerEvent.ProfileLoaded -> { + state.copy(profile = SettingProfileState.Success(event.name, event.email)) + } - fun deleteAccount() { - if (_withdrawUiState.value == WithdrawUiState.Loading) return - _withdrawUiState.value = WithdrawUiState.Loading + SettingReducerEvent.ProfileFailed -> { + state.copy(profile = SettingProfileState.Error) + } - viewModelScope.launch { - try { - accountRepository.deleteAccount() - _withdrawUiState.value = WithdrawUiState.Success - } catch (exception: CancellationException) { - throw exception - } catch (_: Exception) { - _withdrawUiState.value = WithdrawUiState.Error + SettingReducerEvent.LoggedOut -> { + state.copy(logoutCompleted = Unit) + } + + SettingReducerEvent.LogoutConsumed -> { + state.copy(logoutCompleted = null) + } + + SettingReducerEvent.Withdrawing -> { + state.copy(withdraw = WithdrawUiState.Loading) + } + + SettingReducerEvent.Withdrawn -> { + state.copy(withdraw = WithdrawUiState.Success) + } + + SettingReducerEvent.WithdrawFailed -> { + state.copy(withdraw = WithdrawUiState.Error) + } + + SettingReducerEvent.WithdrawErrorDismissed -> { + if (state.withdraw == + WithdrawUiState.Error + ) { + state.copy(withdraw = WithdrawUiState.Idle) + } else { + state + } } } + + private fun loadProfile() { + if (loadJob?.isActive == true) return + loadJob = + viewModelScope.launch { + dispatch(SettingReducerEvent.ProfileLoading) + runCatchingCancellable { myProfileRepository.getMyProfile() } + .onSuccess { dispatch(SettingReducerEvent.ProfileLoaded(it.name, it.email)) } + .onFailure { dispatch(SettingReducerEvent.ProfileFailed) } + } } - fun dismissWithdrawError() { - if (_withdrawUiState.value == WithdrawUiState.Error) { - _withdrawUiState.value = WithdrawUiState.Idle + private fun logout() { + if (logoutJob?.isActive == true || currentState.logoutCompleted != null) return + logoutJob = + viewModelScope.launch { + authRepository.logout() + dispatch(SettingReducerEvent.LoggedOut) + } + } + + private fun deleteAccount() { + if (currentState.withdraw == WithdrawUiState.Loading || currentState.withdraw == WithdrawUiState.Success) return + dispatch(SettingReducerEvent.Withdrawing) + viewModelScope.launch { + runCatchingCancellable { accountRepository.deleteAccount() } + .onSuccess { dispatch(SettingReducerEvent.Withdrawn) } + .onFailure { dispatch(SettingReducerEvent.WithdrawFailed) } } } } diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/home/WithdrawConfirmScreen.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/home/WithdrawConfirmScreen.kt index ea165e8839..67f490d968 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/home/WithdrawConfirmScreen.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/home/WithdrawConfirmScreen.kt @@ -1,10 +1,8 @@ package com.afternote.feature.setting.presentation.home import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource -import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.afternote.core.ui.popup.Popup import com.afternote.core.ui.popup.PopupType import com.afternote.feature.setting.presentation.R @@ -17,9 +15,9 @@ internal fun WithdrawConfirmScreen( viewModel: SettingViewModel, modifier: Modifier = Modifier, ) { - val userName = (uiState as? SettingUiState.Success)?.name.orEmpty() - val userEmail = (uiState as? SettingUiState.Success)?.email.orEmpty() - val withdrawUiState by viewModel.withdrawUiState.collectAsStateWithLifecycle() + val userName = (uiState.profile as? SettingProfileState.Success)?.name.orEmpty() + val userEmail = (uiState.profile as? SettingProfileState.Success)?.email.orEmpty() + val withdrawUiState = uiState.withdraw when (withdrawUiState) { WithdrawUiState.Success -> { @@ -38,8 +36,8 @@ internal fun WithdrawConfirmScreen( message = stringResource(R.string.setting_withdraw_failed_message), confirmText = stringResource(R.string.setting_withdraw_retry_button), dismissText = stringResource(R.string.setting_withdraw_close_button), - onConfirm = viewModel::deleteAccount, - onDismiss = viewModel::dismissWithdrawError, + onConfirm = { viewModel.onIntent(SettingIntent.DeleteAccount) }, + onDismiss = { viewModel.onIntent(SettingIntent.DismissWithdrawError) }, ) } @@ -52,7 +50,7 @@ internal fun WithdrawConfirmScreen( userName = userName, userEmail = userEmail, onBackClick = onBackClick, - onWithdrawClick = viewModel::deleteAccount, + onWithdrawClick = { viewModel.onIntent(SettingIntent.DeleteAccount) }, isLoading = withdrawUiState == WithdrawUiState.Loading, modifier = modifier, ) diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/home/WithdrawGuideScreen.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/home/WithdrawGuideScreen.kt index 3916385035..c5204986d0 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/home/WithdrawGuideScreen.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/home/WithdrawGuideScreen.kt @@ -35,15 +35,15 @@ import com.afternote.feature.setting.presentation.R // 설정 - 회원 탈퇴 안내 @Composable -fun WithdrawGuideScreen( +internal fun WithdrawGuideScreen( uiState: SettingUiState, onBackClick: () -> Unit, onCancelClick: () -> Unit, onConfirmClick: () -> Unit, modifier: Modifier = Modifier, ) { - val userName = (uiState as? SettingUiState.Success)?.name.orEmpty() - val userEmail = (uiState as? SettingUiState.Success)?.email.orEmpty() + val userName = (uiState.profile as? SettingProfileState.Success)?.name.orEmpty() + val userEmail = (uiState.profile as? SettingProfileState.Success)?.email.orEmpty() var agreed by remember { mutableStateOf(false) } Scaffold( topBar = { diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationContent.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationContent.kt index 231273c3c7..d2fdd7c93e 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationContent.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationContent.kt @@ -1,8 +1,10 @@ package com.afternote.feature.setting.presentation.notification +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.material3.Scaffold @@ -12,10 +14,13 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import com.afternote.core.ui.asString +import com.afternote.core.ui.loading.LoadingBody 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.notification.component.PushToggleSection +import com.afternote.feature.setting.presentation.shared.component.SettingLoadErrorContent /** * 푸시 알림 설정 화면의 상태 없는 본문. @@ -31,6 +36,7 @@ internal fun PushNotificationContent( onNewsletterToggle: (Boolean) -> Unit, onMindRecordToggle: (Boolean) -> Unit, onAfternoteToggle: (Boolean) -> Unit, + onRetry: () -> Unit, ) { Scaffold( topBar = { @@ -48,12 +54,28 @@ internal fun PushNotificationContent( .padding(padding) .padding(horizontal = 20.dp, vertical = 16.dp), ) { - PushToggleSection( - uiState = uiState, - onNewsletterToggle = onNewsletterToggle, - onMindRecordToggle = onMindRecordToggle, - onAfternoteToggle = onAfternoteToggle, - ) + when { + uiState.isLoading -> { + LoadingBody(modifier = Modifier.weight(1f)) + } + + uiState.errorMessage != null -> { + SettingLoadErrorContent( + message = uiState.errorMessage.asString(), + onRetry = onRetry, + modifier = Modifier.weight(1f), + ) + } + + else -> { + PushToggleSection( + uiState = uiState, + onNewsletterToggle = onNewsletterToggle, + onMindRecordToggle = onMindRecordToggle, + onAfternoteToggle = onAfternoteToggle, + ) + } + } Spacer(Modifier.height(24.dp)) Text( 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 0ac593a7a6..3ad1bf42a0 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 RetryLoad : PushNotificationIntent + /** 기기 알림 허용 여부를 다시 읽는다. 화면 재개와 권한 요청 결과 때 보낸다. */ data object RefreshDeviceAlarmStatus : 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 ae9c1ec4e2..a0e68dfe4e 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 @@ -17,6 +17,7 @@ internal fun PushNotificationScreen( PushNotificationContent( uiState = uiState, onBack = onBack, + onRetry = { viewModel.onIntent(PushNotificationIntent.RetryLoad) }, onNewsletterToggle = { viewModel.onIntent(PushNotificationIntent.TogglePushSetting(PushSetting.NEWSLETTER, it)) }, onMindRecordToggle = { viewModel.onIntent(PushNotificationIntent.TogglePushSetting(PushSetting.MIND_RECORD, it)) }, onAfternoteToggle = { viewModel.onIntent(PushNotificationIntent.TogglePushSetting(PushSetting.AFTERNOTE, it)) }, 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 adcf6fd7a7..71c9a1f277 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 @@ -1,9 +1,11 @@ package com.afternote.feature.setting.presentation.notification +import com.afternote.core.ui.UiText import com.afternote.core.ui.mvi.UiState internal data class PushNotificationUiState( val isLoading: Boolean = false, + val errorMessage: UiText? = null, val isDeviceAlarmOn: Boolean = false, // 마케팅 알림 (기기 알림 꺼졌을 때) val isSmsChecked: Boolean = true, 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 1eb6e3a3f8..4a3356e5e9 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 @@ -7,11 +7,14 @@ import androidx.lifecycle.viewModelScope import com.afternote.core.common.reporting.ErrorReporter import com.afternote.core.domain.error.PushSettingFailure import com.afternote.core.domain.result.runCatchingCancellable +import com.afternote.core.ui.UiText import com.afternote.core.ui.mvi.MviViewModel import com.afternote.feature.setting.domain.SettingNotificationRepository +import com.afternote.feature.setting.presentation.R import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import jakarta.inject.Inject +import kotlinx.coroutines.Job import kotlinx.coroutines.launch @HiltViewModel @@ -21,7 +24,11 @@ internal class PushNotificationViewModel @ApplicationContext private val context: Context, private val notificationRepository: SettingNotificationRepository, private val errorReporter: ErrorReporter, - ) : MviViewModel(PushNotificationUiState()) { + ) : MviViewModel( + PushNotificationUiState(isLoading = true), + ) { + private var loadJob: Job? = null + init { refreshDeviceAlarmStatus() loadPushSettings() @@ -30,6 +37,10 @@ internal class PushNotificationViewModel override fun onIntent(intent: PushNotificationIntent) { when (intent) { + PushNotificationIntent.RetryLoad -> { + if (currentState.errorMessage != null) loadPushSettings() + } + PushNotificationIntent.RefreshDeviceAlarmStatus -> { refreshDeviceAlarmStatus() } @@ -74,12 +85,13 @@ internal class PushNotificationViewModel } PushNotificationReducerEvent.PushSettingsLoading -> { - state.copy(isLoading = true) + state.copy(isLoading = true, errorMessage = null) } is PushNotificationReducerEvent.PushSettingsLoaded -> { state.copy( isLoading = false, + errorMessage = null, isNewsletterOn = event.setting.timeLetter, isMindRecordOn = event.setting.mindRecord, isAfternoteOn = event.setting.afterNote, @@ -87,7 +99,7 @@ internal class PushNotificationViewModel } PushNotificationReducerEvent.PushSettingsLoadFailed -> { - state.copy(isLoading = false) + state.copy(isLoading = false, errorMessage = UiText.Resource(R.string.setting_push_load_error)) } is PushNotificationReducerEvent.MarketingConsentsLoaded -> { @@ -148,19 +160,21 @@ internal class PushNotificationViewModel } private fun loadPushSettings() { - viewModelScope.launch { - Log.d(TAG, "loadPushSettings: start") - dispatch(PushNotificationReducerEvent.PushSettingsLoading) - runCatchingCancellable { notificationRepository.getMyPushSettings() } - .onSuccess { setting -> - Log.d(TAG, "loadPushSettings: success=$setting") - dispatch(PushNotificationReducerEvent.PushSettingsLoaded(setting)) - }.onFailure { e -> - // 조회 실패는 Logcat 에만 남긴다. 반복 조회 잡음이 저장 실패 진단의 보관 한도를 밀어내지 않게 한다 (#963). - Log.e(TAG, "loadPushSettings: failed", e) - dispatch(PushNotificationReducerEvent.PushSettingsLoadFailed) - } - } + if (loadJob?.isActive == true) return + dispatch(PushNotificationReducerEvent.PushSettingsLoading) + loadJob = + viewModelScope.launch { + Log.d(TAG, "loadPushSettings: start") + runCatchingCancellable { notificationRepository.getMyPushSettings() } + .onSuccess { setting -> + Log.d(TAG, "loadPushSettings: success=$setting") + dispatch(PushNotificationReducerEvent.PushSettingsLoaded(setting)) + }.onFailure { e -> + // 조회 실패는 Logcat 에만 남긴다. 반복 조회 잡음이 저장 실패 진단의 보관 한도를 밀어내지 않게 한다 (#963). + Log.e(TAG, "loadPushSettings: failed", e) + dispatch(PushNotificationReducerEvent.PushSettingsLoadFailed) + } + } } private fun loadMarketingConsents() { @@ -204,7 +218,7 @@ internal class PushNotificationViewModel } private fun updatePushSetting(update: PushSettingUpdate) { - if (currentState.isUpdating(update.setting)) return + if (currentState.isLoading || currentState.errorMessage != null || currentState.isUpdating(update.setting)) return 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 a6001854a8..bdb8ee77cf 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 RetryLoad : ProfileEditIntent + data class SelectPhoto( val uri: String, ) : ProfileEditIntent @@ -20,6 +22,8 @@ internal sealed interface ProfileEditIntent : MviIntent { } internal sealed interface ProfileEditReducerEvent : ReducerEvent { + data object Loading : ProfileEditReducerEvent + data class Loaded( val name: String, val phone: String, 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 840c9482a0..58140d8ba5 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 @@ -19,12 +19,15 @@ import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment @@ -45,6 +48,8 @@ import com.afternote.core.ui.topbar.DetailTopBar import com.afternote.feature.setting.presentation.R import com.afternote.feature.setting.presentation.receiver.ReceiverPhoneValidation import com.afternote.feature.setting.presentation.receiver.validateReceiverPhone +import com.afternote.feature.setting.presentation.shared.component.SettingLoadErrorContent +import kotlinx.coroutines.launch @Composable internal fun ProfileEditScreen( @@ -54,6 +59,9 @@ internal fun ProfileEditScreen( viewModel: ProfileEditViewModel = hiltViewModel(), ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val snackbarHostState = remember { SnackbarHostState() } + val feedbackScope = rememberCoroutineScope() + val updateFailedMessage = stringResource(R.string.setting_profile_update_error) val onPickProfileImage = rememberProfileImagePicker( canAcceptPhoto = (uiState as? ProfileEditUiState.Success)?.isUpdating == false, @@ -68,13 +76,19 @@ internal fun ProfileEditScreen( onIntent = viewModel::onIntent, ) { event -> when (event) { - ProfileEditEvent.UpdateSuccess -> onBackClick() - ProfileEditEvent.UpdateFailure -> Unit + ProfileEditEvent.UpdateSuccess -> { + onBackClick() + } + + ProfileEditEvent.UpdateFailure -> { + feedbackScope.launch { snackbarHostState.showSnackbar(updateFailedMessage) } + } } } } ProfileEditContent( + snackbarHostState = snackbarHostState, uiState = uiState, onPickImageClick = onPickProfileImage, onIntent = viewModel::onIntent, @@ -86,6 +100,7 @@ internal fun ProfileEditScreen( @Composable private fun ProfileEditContent( + snackbarHostState: SnackbarHostState, uiState: ProfileEditUiState, onPickImageClick: () -> Unit, onIntent: (ProfileEditIntent) -> Unit, @@ -102,6 +117,7 @@ private fun ProfileEditContent( }, modifier = modifier, containerColor = Color.Transparent, + snackbarHost = { SnackbarHost(snackbarHostState) }, ) { innerPadding -> when (val state = uiState) { is ProfileEditUiState.Loading -> { @@ -127,15 +143,11 @@ private fun ProfileEditContent( } is ProfileEditUiState.Error -> { - Box( - modifier = - Modifier - .fillMaxSize() - .padding(innerPadding), - contentAlignment = Alignment.Center, - ) { - Text(text = "프로필을 불러올 수 없습니다.") - } + SettingLoadErrorContent( + message = stringResource(R.string.setting_profile_load_error), + onRetry = { onIntent(ProfileEditIntent.RetryLoad) }, + modifier = Modifier.padding(innerPadding), + ) } } } 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 a066ecb330..ac7630d468 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 @@ -8,6 +8,7 @@ import com.afternote.core.ui.mvi.MviViewModel import com.afternote.feature.setting.presentation.receiver.ReceiverPhoneValidation import com.afternote.feature.setting.presentation.receiver.validateReceiverPhone import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job import kotlinx.coroutines.launch import javax.inject.Inject @@ -20,12 +21,15 @@ internal class ProfileEditViewModel private val myProfileRepository: MyProfileRepository, private val photoUploadRepository: PhotoUploadRepository, ) : MviViewModel(ProfileEditUiState.Loading) { + private var loadJob: Job? = null + init { loadProfile() } override fun onIntent(intent: ProfileEditIntent) { when (intent) { + ProfileEditIntent.RetryLoad -> if (currentState == ProfileEditUiState.Error) loadProfile() is ProfileEditIntent.SelectPhoto -> dispatch(ProfileEditReducerEvent.PhotoSelected(intent.uri)) is ProfileEditIntent.UpdateProfile -> updateProfile(intent.name, intent.phone) is ProfileEditIntent.ConsumeEvent -> dispatch(ProfileEditReducerEvent.EventConsumed(intent.event)) @@ -37,6 +41,10 @@ internal class ProfileEditViewModel event: ProfileEditReducerEvent, ): ProfileEditUiState = when (event) { + ProfileEditReducerEvent.Loading -> { + ProfileEditUiState.Loading + } + is ProfileEditReducerEvent.Loaded -> { ProfileEditUiState.Success( name = event.name, @@ -74,21 +82,24 @@ internal class ProfileEditViewModel } private fun loadProfile() { - viewModelScope.launch { - runCatchingCancellable { myProfileRepository.getMyProfile() } - .onSuccess { user -> - dispatch( - ProfileEditReducerEvent.Loaded( - name = user.name, - phone = user.phone.orEmpty(), - email = user.email, - profileImageUrl = user.profileImageUrl, - ), - ) - }.onFailure { - dispatch(ProfileEditReducerEvent.LoadFailed) - } - } + if (loadJob?.isActive == true) return + dispatch(ProfileEditReducerEvent.Loading) + loadJob = + viewModelScope.launch { + runCatchingCancellable { myProfileRepository.getMyProfile() } + .onSuccess { user -> + dispatch( + ProfileEditReducerEvent.Loaded( + name = user.name, + phone = user.phone.orEmpty(), + email = user.email, + profileImageUrl = user.profileImageUrl, + ), + ) + }.onFailure { + dispatch(ProfileEditReducerEvent.LoadFailed) + } + } } private fun updateProfile( diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/shared/component/SettingLoadErrorContent.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/shared/component/SettingLoadErrorContent.kt new file mode 100644 index 0000000000..a742b55256 --- /dev/null +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/shared/component/SettingLoadErrorContent.kt @@ -0,0 +1,42 @@ +package com.afternote.feature.setting.presentation.shared.component + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.afternote.core.ui.theme.AfternoteDesign +import com.afternote.feature.setting.presentation.R + +@Composable +internal fun SettingLoadErrorContent( + message: String, + onRetry: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.fillMaxSize().padding(horizontal = 20.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = message, + style = AfternoteDesign.typography.bodySmallR, + color = AfternoteDesign.colors.gray6, + textAlign = TextAlign.Center, + ) + Spacer(modifier = Modifier.height(8.dp)) + TextButton(onClick = onRetry) { + Text(text = stringResource(R.string.setting_load_retry)) + } + } +} diff --git a/feature/setting/presentation/src/main/res/values/strings.xml b/feature/setting/presentation/src/main/res/values/strings.xml index d594c9e076..75a012b7a6 100644 --- a/feature/setting/presentation/src/main/res/values/strings.xml +++ b/feature/setting/presentation/src/main/res/values/strings.xml @@ -180,4 +180,8 @@ 소셜 로그인으로 가입한 계정은 비밀번호를 변경할 수 없습니다. 네트워크 연결을 확인해주세요. 비밀번호를 변경하지 못했습니다. 입력하신 내용을 확인해 주세요. + 프로필을 불러올 수 없습니다. + 프로필 수정에 실패했습니다. 다시 시도해 주세요. + 알림 설정을 불러올 수 없습니다. + 다시 시도 diff --git a/feature/setting/presentation/src/screenshotTest/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsScreenScreenshotTest.kt b/feature/setting/presentation/src/screenshotTest/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsScreenScreenshotTest.kt index 680fa0fba8..cca855c10c 100644 --- a/feature/setting/presentation/src/screenshotTest/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsScreenScreenshotTest.kt +++ b/feature/setting/presentation/src/screenshotTest/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsScreenScreenshotTest.kt @@ -24,10 +24,11 @@ internal fun connectedAccountsScreenCompactScreenshot() { } @Composable -private fun ConnectedAccountsScreenScreenshotContent() { +private fun ConnectedAccountsScreenScreenshotContent(uiState: ConnectedAccountsUiState = connectedAccountsPreviewState) { AfternoteTheme { ConnectedAccountsContent( - uiState = connectedAccountsPreviewState, + onRetry = {}, + uiState = uiState, snackbarHostState = remember { SnackbarHostState() }, onBack = {}, onToggle = { _, _ -> }, @@ -68,3 +69,19 @@ private val connectedAccountsPreviewState = ), ), ) + +@PreviewTest +@Preview(showBackground = true) +@Preview(showBackground = true, device = COMPACT_DEVICE_SPEC) +@Composable +internal fun connectedAccountsLoadingScreenshot() { + ConnectedAccountsScreenScreenshotContent(ConnectedAccountsUiState(isLoading = true)) +} + +@PreviewTest +@Preview(showBackground = true) +@Preview(showBackground = true, device = COMPACT_DEVICE_SPEC) +@Composable +internal fun connectedAccountsLoadErrorScreenshot() { + ConnectedAccountsScreenScreenshotContent(ConnectedAccountsUiState(errorMessage = "계정 정보를 불러올 수 없습니다.")) +} diff --git a/feature/setting/presentation/src/screenshotTest/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationScreenScreenshotTest.kt b/feature/setting/presentation/src/screenshotTest/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationScreenScreenshotTest.kt index d3168b6c7b..07e49c4ead 100644 --- a/feature/setting/presentation/src/screenshotTest/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationScreenScreenshotTest.kt +++ b/feature/setting/presentation/src/screenshotTest/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationScreenScreenshotTest.kt @@ -2,8 +2,10 @@ package com.afternote.feature.setting.presentation.notification import androidx.compose.runtime.Composable import androidx.compose.ui.tooling.preview.Preview +import com.afternote.core.ui.UiText import com.afternote.core.ui.theme.AfternoteTheme import com.afternote.feature.setting.presentation.COMPACT_DEVICE_SPEC +import com.afternote.feature.setting.presentation.R import com.android.tools.screenshot.PreviewTest @PreviewTest @@ -21,10 +23,11 @@ internal fun pushNotificationCompactScreenshot() { } @Composable -private fun PushNotificationScreenshotContent() { +private fun PushNotificationScreenshotContent(uiState: PushNotificationUiState = PushNotificationUiState(isAfternoteOn = true)) { AfternoteTheme { PushNotificationContent( - uiState = PushNotificationUiState(isAfternoteOn = true), + onRetry = {}, + uiState = uiState, onBack = {}, onNewsletterToggle = {}, onMindRecordToggle = {}, @@ -32,3 +35,19 @@ private fun PushNotificationScreenshotContent() { ) } } + +@PreviewTest +@Preview(showBackground = true) +@Preview(showBackground = true, device = COMPACT_DEVICE_SPEC) +@Composable +internal fun pushNotificationLoadingScreenshot() { + PushNotificationScreenshotContent(PushNotificationUiState(isLoading = true)) +} + +@PreviewTest +@Preview(showBackground = true) +@Preview(showBackground = true, device = COMPACT_DEVICE_SPEC) +@Composable +internal fun pushNotificationLoadErrorScreenshot() { + PushNotificationScreenshotContent(PushNotificationUiState(errorMessage = UiText.Resource(R.string.setting_push_load_error))) +} diff --git a/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/account/ConnectedAccountsScreenScreenshotTestKt/connectedAccountsLoadErrorScreenshot_748aa731_0.png b/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/account/ConnectedAccountsScreenScreenshotTestKt/connectedAccountsLoadErrorScreenshot_748aa731_0.png new file mode 100644 index 0000000000..6852feed9e Binary files /dev/null and b/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/account/ConnectedAccountsScreenScreenshotTestKt/connectedAccountsLoadErrorScreenshot_748aa731_0.png differ diff --git a/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/account/ConnectedAccountsScreenScreenshotTestKt/connectedAccountsLoadErrorScreenshot_c2abd7e6_0.png b/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/account/ConnectedAccountsScreenScreenshotTestKt/connectedAccountsLoadErrorScreenshot_c2abd7e6_0.png new file mode 100644 index 0000000000..74ab59ac8d Binary files /dev/null and b/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/account/ConnectedAccountsScreenScreenshotTestKt/connectedAccountsLoadErrorScreenshot_c2abd7e6_0.png differ diff --git a/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/account/ConnectedAccountsScreenScreenshotTestKt/connectedAccountsLoadingScreenshot_748aa731_0.png b/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/account/ConnectedAccountsScreenScreenshotTestKt/connectedAccountsLoadingScreenshot_748aa731_0.png new file mode 100644 index 0000000000..8332c47afb Binary files /dev/null and b/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/account/ConnectedAccountsScreenScreenshotTestKt/connectedAccountsLoadingScreenshot_748aa731_0.png differ diff --git a/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/account/ConnectedAccountsScreenScreenshotTestKt/connectedAccountsLoadingScreenshot_c2abd7e6_0.png b/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/account/ConnectedAccountsScreenScreenshotTestKt/connectedAccountsLoadingScreenshot_c2abd7e6_0.png new file mode 100644 index 0000000000..04fd32d7af Binary files /dev/null and b/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/account/ConnectedAccountsScreenScreenshotTestKt/connectedAccountsLoadingScreenshot_c2abd7e6_0.png differ diff --git a/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/notification/PushNotificationScreenScreenshotTestKt/pushNotificationLoadErrorScreenshot_748aa731_0.png b/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/notification/PushNotificationScreenScreenshotTestKt/pushNotificationLoadErrorScreenshot_748aa731_0.png new file mode 100644 index 0000000000..236b375198 Binary files /dev/null and b/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/notification/PushNotificationScreenScreenshotTestKt/pushNotificationLoadErrorScreenshot_748aa731_0.png differ diff --git a/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/notification/PushNotificationScreenScreenshotTestKt/pushNotificationLoadErrorScreenshot_c2abd7e6_0.png b/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/notification/PushNotificationScreenScreenshotTestKt/pushNotificationLoadErrorScreenshot_c2abd7e6_0.png new file mode 100644 index 0000000000..42ec702aad Binary files /dev/null and b/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/notification/PushNotificationScreenScreenshotTestKt/pushNotificationLoadErrorScreenshot_c2abd7e6_0.png differ diff --git a/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/notification/PushNotificationScreenScreenshotTestKt/pushNotificationLoadingScreenshot_748aa731_0.png b/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/notification/PushNotificationScreenScreenshotTestKt/pushNotificationLoadingScreenshot_748aa731_0.png new file mode 100644 index 0000000000..ff4783be9e Binary files /dev/null and b/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/notification/PushNotificationScreenScreenshotTestKt/pushNotificationLoadingScreenshot_748aa731_0.png differ diff --git a/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/notification/PushNotificationScreenScreenshotTestKt/pushNotificationLoadingScreenshot_c2abd7e6_0.png b/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/notification/PushNotificationScreenScreenshotTestKt/pushNotificationLoadingScreenshot_c2abd7e6_0.png new file mode 100644 index 0000000000..cd52495352 Binary files /dev/null and b/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/notification/PushNotificationScreenScreenshotTestKt/pushNotificationLoadingScreenshot_c2abd7e6_0.png differ 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 7f94d4e029..1075ae0e80 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 @@ -6,6 +6,7 @@ import androidx.compose.ui.test.junit4.v2.createComposeRule import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performTextInput +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.afternote.core.domain.testing.FakeAuthRepository import com.afternote.core.domain.testing.FakeMyProfileRepository import com.afternote.core.domain.testing.FakeMyProfileRepository.ProfileUpdateCall @@ -34,7 +35,6 @@ 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 import com.afternote.feature.setting.presentation.home.WithdrawConfirmScreen import com.afternote.feature.setting.presentation.home.WithdrawUiState @@ -131,7 +131,7 @@ class SettingAccountSecurityTest { linkRepository.onLinkConnectedAccount = { _, _ -> throw IllegalStateException("oauth rejected") } composeRule.runOnIdle { linkViewModel.onIntent(ConnectedAccountsIntent.Link(provider = "google", accessToken = "google-token")) } composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { - linkViewModel.uiState.value.errorMessage == "계정 연결에 실패했습니다." + linkViewModel.uiState.value.pendingError == "계정 연결에 실패했습니다." } assertEquals( @@ -156,7 +156,7 @@ class SettingAccountSecurityTest { } assertEquals(listOf("google"), unlinkRepository.connectedUnlinkCalls) - assertEquals("계정 연결 해제에 실패했습니다.", unlinkViewModel.uiState.value.errorMessage) + assertEquals("계정 연결 해제에 실패했습니다.", unlinkViewModel.uiState.value.pendingError) } @Test @@ -313,7 +313,7 @@ class SettingAccountSecurityTest { composeRule.setContent { AfternoteTheme { WithdrawConfirmScreen( - uiState = SettingUiState.Success(name = DEFAULT_USER.name, email = DEFAULT_USER.email), + uiState = viewModel.uiState.collectAsStateWithLifecycle().value, onBackClick = {}, onWithdrawSuccess = { successCalls += 1 }, viewModel = viewModel, @@ -332,7 +332,7 @@ class SettingAccountSecurityTest { } composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { - viewModel.withdrawUiState.value == WithdrawUiState.Error + viewModel.uiState.value.withdraw == WithdrawUiState.Error } composeRule .onNodeWithText("회원 탈퇴에 실패했습니다. 잠시 후 다시 시도해 주세요.") 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 aaeb57841e..238a57a594 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 @@ -44,6 +44,8 @@ import com.afternote.feature.setting.presentation.account.ConnectedAccountsViewM 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.SettingIntent +import com.afternote.feature.setting.presentation.home.SettingProfileState import com.afternote.feature.setting.presentation.home.SettingUiState import com.afternote.feature.setting.presentation.home.SettingViewModel import com.afternote.feature.setting.presentation.home.WithdrawUiState @@ -650,28 +652,28 @@ class SettingCompletionTest { } val viewModel = SettingViewModel(authRepository, profileRepository, repository) composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { - viewModel.uiState.value is SettingUiState.Success + viewModel.uiState.value.profile is SettingProfileState.Success } - composeRule.runOnIdle { viewModel.deleteAccount() } + composeRule.runOnIdle { viewModel.onIntent(SettingIntent.DeleteAccount) } composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { repository.deleteAccountCalls == 1 } - assertEquals(WithdrawUiState.Loading, viewModel.withdrawUiState.value) + assertEquals(WithdrawUiState.Loading, viewModel.uiState.value.withdraw) firstGate.completeExceptionally(IllegalStateException("서버가 503 으로 거절했다")) composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { - viewModel.withdrawUiState.value == WithdrawUiState.Error + viewModel.uiState.value.withdraw == WithdrawUiState.Error } - composeRule.runOnIdle { viewModel.deleteAccount() } + composeRule.runOnIdle { viewModel.onIntent(SettingIntent.DeleteAccount) } composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { repository.deleteAccountCalls == 2 } retryGate.complete(Unit) composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { - viewModel.withdrawUiState.value == WithdrawUiState.Success + viewModel.uiState.value.withdraw == WithdrawUiState.Success } assertEquals(2, repository.deleteAccountCalls) } diff --git a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingCoroutineCancellationTest.kt b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingCoroutineCancellationTest.kt index 4f78f1ac52..a2a78bae53 100644 --- a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingCoroutineCancellationTest.kt +++ b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingCoroutineCancellationTest.kt @@ -15,6 +15,7 @@ import com.afternote.feature.setting.domain.testing.FakeSettingAccountRepository import com.afternote.feature.setting.domain.testing.FakeSettingNotificationRepository import com.afternote.feature.setting.presentation.account.ConnectedAccountsIntent import com.afternote.feature.setting.presentation.account.ConnectedAccountsViewModel +import com.afternote.feature.setting.presentation.home.SettingProfileState import com.afternote.feature.setting.presentation.home.SettingUiState import com.afternote.feature.setting.presentation.home.SettingViewModel import com.afternote.feature.setting.presentation.notification.PushNotificationIntent @@ -238,7 +239,7 @@ class SettingCoroutineCancellationTest { runCurrent() assertTrue(pending.isCancelled) - assertEquals(SettingUiState.Loading, viewModel.uiState.value) + assertEquals(SettingProfileState.Loading, viewModel.uiState.value.profile) } @Test @@ -326,7 +327,7 @@ class SettingCoroutineCancellationTest { ) runCurrent() - assertEquals(SettingUiState.Error("프로필을 불러올 수 없습니다."), viewModel.uiState.value) + assertEquals(SettingProfileState.Error, viewModel.uiState.value.profile) } private fun pushViewModel( diff --git a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingFlowTest.kt b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingFlowTest.kt index 3e40f3677a..adac4e7fb9 100644 --- a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingFlowTest.kt +++ b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingFlowTest.kt @@ -14,6 +14,7 @@ import com.afternote.core.domain.testing.FakeMyProfileRepository import com.afternote.core.ui.theme.AfternoteTheme import com.afternote.feature.setting.domain.testing.FakeSettingAccountRepository import com.afternote.feature.setting.domain.testing.FakeSettingNotificationRepository +import com.afternote.feature.setting.presentation.home.SettingIntent import com.afternote.feature.setting.presentation.home.SettingScreen import com.afternote.feature.setting.presentation.home.SettingViewModel import com.afternote.feature.setting.presentation.notification.PushNotificationIntent @@ -34,6 +35,30 @@ class SettingFlowTest { @get:Rule val composeRule = createComposeRule() + @Test + fun profileLoadFailureKeepsSettingsEntriesAndRetryRecoversProfile() { + var reads = 0 + var offline = true + val profile = + com.afternote.core.domain.testing.FakeMyProfileRepository.strict().apply { + onGetMyProfile = { + reads++ + if (offline) throw java.io.IOException("offline") + com.afternote.core.model.user + .User("복구 사용자", "test@example.com", null, null) + } + } + val viewModel = SettingViewModel(settingFlowAuthRepository(loggedIn = true), profile, settingFlowAccountRepository()) + setSettingContent(viewModel) + composeRule.onNodeWithText("프로필을 불러올 수 없습니다.").assertIsDisplayed() + composeRule.onNodeWithText("프로필 수정").assertIsDisplayed() + val failedReads = reads + composeRule.runOnIdle { offline = false } + composeRule.onNodeWithText("다시 시도").performClick() + composeRule.onNodeWithText("복구 사용자").assertIsDisplayed() + assertEquals(failedReads + 1, reads) + } + @Test fun profileAndSecurityEntries_emitExpectedNavigation() { val auth = settingFlowAuthRepository(loggedIn = true) @@ -85,7 +110,7 @@ class SettingFlowTest { composeRule.setContent { AfternoteTheme {} } assertEquals(0, account.deleteAccountCalls) - composeRule.runOnIdle { viewModel.deleteAccount() } + composeRule.runOnIdle { viewModel.onIntent(SettingIntent.DeleteAccount) } composeRule.waitUntil(timeoutMillis = 5_000) { account.deleteAccountCalls == 1 } assertEquals(1, account.deleteAccountCalls) diff --git a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingLoadFeedbackTest.kt b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingLoadFeedbackTest.kt new file mode 100644 index 0000000000..80e9b32004 --- /dev/null +++ b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingLoadFeedbackTest.kt @@ -0,0 +1,111 @@ +package com.afternote.feature.setting.presentation + +import androidx.compose.material3.SnackbarHostState +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.hasClickAction +import androidx.compose.ui.test.hasScrollToIndexAction +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import androidx.compose.ui.test.performScrollToNode +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.UiText +import com.afternote.core.ui.theme.AfternoteTheme +import com.afternote.feature.setting.domain.testing.FakeSettingAccountRepository +import com.afternote.feature.setting.presentation.account.ConnectedAccountsContent +import com.afternote.feature.setting.presentation.account.ConnectedAccountsIntent +import com.afternote.feature.setting.presentation.account.ConnectedAccountsScreen +import com.afternote.feature.setting.presentation.account.ConnectedAccountsUiState +import com.afternote.feature.setting.presentation.account.ConnectedAccountsViewModel +import com.afternote.feature.setting.presentation.notification.PushNotificationContent +import com.afternote.feature.setting.presentation.notification.PushNotificationUiState +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.assertEquals +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 +import java.io.IOException + +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(sdk = [35]) +class SettingLoadFeedbackTest { + @get:Rule val composeRule = createComposeRule() + + @Test fun pushLoadErrorHidesSwitchesAndExposesRetry() { + var retries = 0 + composeRule.setContent { + AfternoteTheme { + PushNotificationContent( + uiState = PushNotificationUiState(errorMessage = UiText.Resource(R.string.setting_push_load_error)), + onBack = {}, + onNewsletterToggle = {}, + onMindRecordToggle = {}, + onAfternoteToggle = {}, + onRetry = { retries++ }, + ) + } + } + composeRule.onNodeWithText("알림 설정을 불러올 수 없습니다.").assertIsDisplayed() + composeRule.onNodeWithText("뉴스레터").assertDoesNotExist() + composeRule.onNodeWithText("다시 시도").performClick() + assertEquals(1, retries) + } + + @Test fun accountLoadErrorExposesRetryInsteadOfAnEmptyAccountList() { + var retries = 0 + composeRule.setContent { + AfternoteTheme { + ConnectedAccountsContent( + uiState = ConnectedAccountsUiState(errorMessage = "계정 정보를 불러올 수 없습니다."), + snackbarHostState = SnackbarHostState(), + onBack = {}, + onToggle = { _, _ -> }, + onRetry = { retries++ }, + ) + } + } + composeRule.onNodeWithText("계정 정보를 불러올 수 없습니다.").assertIsDisplayed() + composeRule.onNodeWithText("다시 시도").performClick() + assertEquals(1, retries) + } + + @Test fun profileLoadRetryRecoversAndSaveFailureIsVisible() { + var reads = 0 + val repository = + FakeMyProfileRepository.strict().apply { + onGetMyProfile = { if (++reads == 1) throw IOException() else User("테스트", "test@example.com", "01012345678", null) } + onUpdateMyProfile = { _, _, _ -> throw IOException() } + } + val vm = ProfileEditViewModel(repository, FakePhotoUploadRepository.strict()) + composeRule.setContent { AfternoteTheme { ProfileEditScreen({}, {}, viewModel = vm) } } + composeRule.onNodeWithText("프로필을 불러올 수 없습니다.").assertIsDisplayed() + composeRule.onNodeWithText("다시 시도").performClick() + composeRule.waitUntil(5_000) { vm.uiState.value is ProfileEditUiState.Success } + composeRule.onNode(hasScrollToIndexAction()).performScrollToNode(hasText("수정하기")) + composeRule.onNodeWithText("수정하기").performClick() + composeRule.onNodeWithText("프로필 수정에 실패했습니다. 다시 시도해 주세요.").assertIsDisplayed() + assertEquals(2, reads) + } + + @Test fun unlinkFailureShowsFeedbackAndKeepsAccountRows() { + val repository = FakeSettingAccountRepository().apply { onUnlinkConnectedAccount = { throw IOException() } } + val vm = ConnectedAccountsViewModel(repository) + composeRule.setContent { AfternoteTheme { ConnectedAccountsScreen({}, viewModel = vm) } } + composeRule.waitUntil(5_000) { !vm.uiState.value.isLoading } + composeRule.runOnIdle { vm.onIntent(ConnectedAccountsIntent.Toggle("kakao", false)) } + composeRule.onNodeWithText("계정 연결 해제에 실패했습니다.").assertIsDisplayed() + assertEquals(4, vm.uiState.value.accounts.size) + assertEquals(null, vm.uiState.value.errorMessage) + } +} diff --git a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingLoadRecoveryTest.kt b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingLoadRecoveryTest.kt new file mode 100644 index 0000000000..43ee6a4476 --- /dev/null +++ b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingLoadRecoveryTest.kt @@ -0,0 +1,184 @@ +package com.afternote.feature.setting.presentation + +import androidx.test.core.app.ApplicationProvider +import com.afternote.core.domain.testing.FakeAuthRepository +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.model.user.UserPushSetting +import com.afternote.feature.setting.domain.testing.FakeSettingAccountRepository +import com.afternote.feature.setting.domain.testing.FakeSettingNotificationRepository +import com.afternote.feature.setting.presentation.account.ConnectedAccountsIntent +import com.afternote.feature.setting.presentation.account.ConnectedAccountsViewModel +import com.afternote.feature.setting.presentation.home.SettingIntent +import com.afternote.feature.setting.presentation.home.SettingProfileState +import com.afternote.feature.setting.presentation.home.SettingViewModel +import com.afternote.feature.setting.presentation.home.WithdrawUiState +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.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 +import java.io.IOException + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class SettingLoadRecoveryTest { + private val dispatcher = StandardTestDispatcher() + private val profile = User("테스트", "test@example.com", "01012345678", null) + + @Before fun setUp() { + Dispatchers.setMain(dispatcher) + } + + @After fun tearDown() { + Dispatchers.resetMain() + } + + @Test fun `home retry recovers profile without resetting account operation`() = + runTest(dispatcher) { + var reads = 0 + val gate = CompletableDeferred() + val repository = + FakeMyProfileRepository.strict().apply { + onGetMyProfile = { if (++reads == 1) throw IOException() else gate.await() } + } + val vm = SettingViewModel(FakeAuthRepository.strict(), repository, FakeSettingAccountRepository()) + runCurrent() + assertEquals(SettingProfileState.Error, vm.uiState.value.profile) + vm.onIntent(SettingIntent.DeleteAccount) + runCurrent() + vm.onIntent(SettingIntent.Refresh) + vm.onIntent(SettingIntent.Refresh) + runCurrent() + assertEquals(2, reads) + assertEquals(WithdrawUiState.Success, vm.uiState.value.withdraw) + gate.complete(profile) + runCurrent() + assertEquals(SettingProfileState.Success(profile.name, profile.email), vm.uiState.value.profile) + assertEquals(WithdrawUiState.Success, vm.uiState.value.withdraw) + } + + @Test fun `profile retry is single flight and failed load cannot submit a patch`() = + runTest(dispatcher) { + var reads = 0 + val gate = CompletableDeferred() + val repository = + FakeMyProfileRepository.strict().apply { + onGetMyProfile = { if (++reads == 1) throw IOException() else gate.await() } + } + val vm = ProfileEditViewModel(repository, FakePhotoUploadRepository.strict()) + runCurrent() + assertEquals(ProfileEditUiState.Error, vm.uiState.value) + vm.onIntent(ProfileEditIntent.UpdateProfile("new", "01012345678")) + assertTrue(repository.profileUpdateCalls.isEmpty()) + vm.onIntent(ProfileEditIntent.RetryLoad) + vm.onIntent(ProfileEditIntent.RetryLoad) + assertEquals(ProfileEditUiState.Loading, vm.uiState.value) + runCurrent() + assertEquals(2, reads) + gate.complete(profile) + runCurrent() + assertEquals(profile.phone, (vm.uiState.value as ProfileEditUiState.Success).phone) + vm.onIntent(ProfileEditIntent.RetryLoad) + runCurrent() + assertEquals(2, reads) + } + + @Test fun `account load failure blocks writes and retry restores rows`() = + runTest(dispatcher) { + var fail = true + val repository = + FakeSettingAccountRepository().apply { + onGetConnectedAccounts = { if (fail) throw IOException() else connectedAccounts } + } + val vm = ConnectedAccountsViewModel(repository) + runCurrent() + assertNotNull(vm.uiState.value.errorMessage) + vm.onIntent(ConnectedAccountsIntent.Toggle("kakao", false)) + vm.onIntent(ConnectedAccountsIntent.Link("kakao", "unused")) + runCurrent() + assertTrue(repository.connectedUnlinkCalls.isEmpty()) + assertTrue(repository.connectedLinkCalls.isEmpty()) + fail = false + vm.onIntent(ConnectedAccountsIntent.RetryLoad) + vm.onIntent(ConnectedAccountsIntent.RetryLoad) + runCurrent() + assertEquals(2, repository.getConnectedAccountsCalls) + assertNull(vm.uiState.value.errorMessage) + assertEquals(4, vm.uiState.value.accounts.size) + } + + @Test fun `account write failure preserves rows and consumes feedback independently of load state`() = + runTest(dispatcher) { + val repository = FakeSettingAccountRepository().apply { onUnlinkConnectedAccount = { throw IOException() } } + val vm = ConnectedAccountsViewModel(repository) + runCurrent() + val rows = vm.uiState.value.accounts + vm.onIntent(ConnectedAccountsIntent.Toggle("kakao", false)) + vm.onIntent(ConnectedAccountsIntent.Toggle("kakao", false)) + runCurrent() + assertEquals(listOf("kakao"), repository.connectedUnlinkCalls) + assertEquals(rows, vm.uiState.value.accounts) + assertNull(vm.uiState.value.errorMessage) + val error = requireNotNull(vm.uiState.value.pendingError) + vm.onIntent(ConnectedAccountsIntent.ConsumeError("another message")) + assertEquals(error, vm.uiState.value.pendingError) + vm.onIntent(ConnectedAccountsIntent.ConsumeError(error)) + assertNull(vm.uiState.value.pendingError) + } + + @Test fun `push load error cannot save default values and retry reads real settings`() = + runTest(dispatcher) { + var reads = 0 + var writes = 0 + var fail = true + val repository = + FakeSettingNotificationRepository().apply { + onGetMyPushSettings = { + reads++ + if (fail) throw IOException() else UserPushSetting(timeLetter = true, mindRecord = true, afterNote = false) + } + onUpdateMyPushSettings = { _, _, _ -> + writes++ + UserPushSetting(false, false, false) + } + } + val vm = PushNotificationViewModel(ApplicationProvider.getApplicationContext(), repository, NoOpErrorReporter) + assertTrue(vm.uiState.value.isLoading) + vm.onIntent(PushNotificationIntent.TogglePushSetting(PushSetting.NEWSLETTER, true)) + runCurrent() + assertNotNull(vm.uiState.value.errorMessage) + vm.onIntent(PushNotificationIntent.TogglePushSetting(PushSetting.NEWSLETTER, true)) + runCurrent() + assertEquals(0, writes) + fail = false + vm.onIntent(PushNotificationIntent.RetryLoad) + vm.onIntent(PushNotificationIntent.RetryLoad) + runCurrent() + assertEquals(2, reads) + assertNull(vm.uiState.value.errorMessage) + assertTrue(vm.uiState.value.isNewsletterOn) + assertTrue(vm.uiState.value.isMindRecordOn) + } +} diff --git a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsMviTest.kt b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsMviTest.kt index 6baf72799c..c2eb7d27ab 100644 --- a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsMviTest.kt +++ b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsMviTest.kt @@ -100,7 +100,7 @@ class ConnectedAccountsMviTest { repeat(2) { viewModel.onIntent(ConnectedAccountsIntent.Link(provider = "google", accessToken = "token-$it")) - assertEquals("계정 연결에 실패했습니다.", viewModel.uiState.value.errorMessage) + assertEquals("계정 연결에 실패했습니다.", viewModel.uiState.value.pendingError) assertFalse( viewModel.uiState.value.accounts .first { it.provider == "google" } diff --git a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/home/SettingViewModelTest.kt b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/home/SettingViewModelTest.kt index f1582f606a..eee0ea0263 100644 --- a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/home/SettingViewModelTest.kt +++ b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/home/SettingViewModelTest.kt @@ -35,11 +35,11 @@ class SettingViewModelTest { runTest(dispatcher) { val viewModel = viewModel(onDeleteAccount = {}) - viewModel.deleteAccount() + viewModel.onIntent(SettingIntent.DeleteAccount) - assertEquals(WithdrawUiState.Loading, viewModel.withdrawUiState.value) + assertEquals(WithdrawUiState.Loading, viewModel.uiState.value.withdraw) runCurrent() - assertEquals(WithdrawUiState.Success, viewModel.withdrawUiState.value) + assertEquals(WithdrawUiState.Success, viewModel.uiState.value.withdraw) } @Test @@ -47,12 +47,12 @@ class SettingViewModelTest { runTest(dispatcher) { val viewModel = viewModel(onDeleteAccount = { throw IllegalStateException("failure") }) - viewModel.deleteAccount() + viewModel.onIntent(SettingIntent.DeleteAccount) runCurrent() - assertEquals(WithdrawUiState.Error, viewModel.withdrawUiState.value) - viewModel.dismissWithdrawError() - assertEquals(WithdrawUiState.Idle, viewModel.withdrawUiState.value) + assertEquals(WithdrawUiState.Error, viewModel.uiState.value.withdraw) + viewModel.onIntent(SettingIntent.DismissWithdrawError) + assertEquals(WithdrawUiState.Idle, viewModel.uiState.value.withdraw) } @Test @@ -61,8 +61,8 @@ class SettingViewModelTest { var requestCount = 0 val viewModel = viewModel(onDeleteAccount = { requestCount++ }) - viewModel.deleteAccount() - viewModel.deleteAccount() + viewModel.onIntent(SettingIntent.DeleteAccount) + viewModel.onIntent(SettingIntent.DeleteAccount) runCurrent() assertEquals(1, requestCount) diff --git a/konsist/src/test/kotlin/com/afternote/konsist/MviContractKonsistTest.kt b/konsist/src/test/kotlin/com/afternote/konsist/MviContractKonsistTest.kt index 33528579ec..7e4dadd601 100644 --- a/konsist/src/test/kotlin/com/afternote/konsist/MviContractKonsistTest.kt +++ b/konsist/src/test/kotlin/com/afternote/konsist/MviContractKonsistTest.kt @@ -541,7 +541,6 @@ class MviContractKonsistTest { private val ISSUE_1805_SETTING = setOf( "com.afternote.feature.setting.presentation.applock.AppLockSetupViewModel", - "com.afternote.feature.setting.presentation.home.SettingViewModel", "com.afternote.feature.setting.presentation.passkey.PassKeyViewModel", "com.afternote.feature.setting.presentation.receiver.ReceiverRegisterViewModel", )