diff --git a/shared-compose-ui/src/commonMain/kotlin/com/softartdev/notedelight/ui/dialog/security/ChangePasswordDialog.kt b/shared-compose-ui/src/commonMain/kotlin/com/softartdev/notedelight/ui/dialog/security/ChangePasswordDialog.kt index ecf81e5c..8479295c 100644 --- a/shared-compose-ui/src/commonMain/kotlin/com/softartdev/notedelight/ui/dialog/security/ChangePasswordDialog.kt +++ b/shared-compose-ui/src/commonMain/kotlin/com/softartdev/notedelight/ui/dialog/security/ChangePasswordDialog.kt @@ -2,41 +2,32 @@ package com.softartdev.notedelight.ui.dialog.security import androidx.compose.desktop.ui.tooling.preview.Preview import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.LinearProgressIndicator -import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.MutableState -import androidx.compose.runtime.State +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp import com.softartdev.notedelight.shared.presentation.settings.security.change.ChangeResult import com.softartdev.notedelight.shared.presentation.settings.security.change.ChangeViewModel import com.softartdev.notedelight.ui.PasswordField import com.softartdev.notedelight.ui.dialog.PreviewDialog -import kotlinx.coroutines.launch import notedelight.shared_compose_ui.generated.resources.Res import notedelight.shared_compose_ui.generated.resources.cancel import notedelight.shared_compose_ui.generated.resources.dialog_title_change_password -import notedelight.shared_compose_ui.generated.resources.empty_password import notedelight.shared_compose_ui.generated.resources.enter_new_password import notedelight.shared_compose_ui.generated.resources.enter_old_password -import notedelight.shared_compose_ui.generated.resources.error_title -import notedelight.shared_compose_ui.generated.resources.incorrect_password -import notedelight.shared_compose_ui.generated.resources.passwords_do_not_match import notedelight.shared_compose_ui.generated.resources.repeat_new_password import notedelight.shared_compose_ui.generated.resources.yes -import org.jetbrains.compose.resources.StringResource -import org.jetbrains.compose.resources.getString import org.jetbrains.compose.resources.stringResource @Composable @@ -44,112 +35,55 @@ fun ChangePasswordDialog( changeViewModel: ChangeViewModel, snackbarHostState: SnackbarHostState = remember { SnackbarHostState() } ) { - val changeResultState: State = changeViewModel.resultStateFlow.collectAsState() - var oldLabelResource by remember { mutableStateOf(Res.string.enter_old_password) } - var oldError by remember { mutableStateOf(false) } - val oldPasswordState: MutableState = remember { mutableStateOf("") } - var newLabelResource by remember { mutableStateOf(Res.string.enter_new_password) } - var newError by remember { mutableStateOf(false) } - val newPasswordState: MutableState = remember { mutableStateOf("") } - var repeatLabelResource by remember { mutableStateOf(Res.string.repeat_new_password) } - var repeatError by remember { mutableStateOf(false) } - val repeatPasswordState: MutableState = remember { mutableStateOf("") } - val coroutineScope = rememberCoroutineScope() - when (val changeResult: ChangeResult = changeResultState.value) { - is ChangeResult.InitState, is ChangeResult.Loading -> Unit - is ChangeResult.OldEmptyPasswordError -> { - oldLabelResource = Res.string.empty_password - oldError = true - } - is ChangeResult.NewEmptyPasswordError -> { - newLabelResource = Res.string.empty_password - newError = true - } - is ChangeResult.PasswordsNoMatchError -> { - repeatLabelResource = Res.string.passwords_do_not_match - repeatError = true - } - is ChangeResult.IncorrectPasswordError -> { - oldLabelResource = Res.string.incorrect_password - oldError = true - } - is ChangeResult.Error -> coroutineScope.launch { - snackbarHostState.showSnackbar( - message = changeResult.message ?: getString(Res.string.error_title) - ) + val result: ChangeResult by changeViewModel.stateFlow.collectAsState() + + LaunchedEffect(key1 = changeViewModel, key2 = result, key3 = result.snackBarMessageType) { + result.snackBarMessageType?.let { msg: String -> + snackbarHostState.showSnackbar(msg) + result.disposeOneTimeEvents() } } - ShowChangePasswordDialog( - showLoaing = changeResultState.value is ChangeResult.Loading, - oldLabelResource = oldLabelResource, - oldError = oldError, - oldPasswordState = oldPasswordState, - newLabelResource = newLabelResource, - newError = newError, - newPasswordState = newPasswordState, - repeatLabelResource = repeatLabelResource, - repeatError = repeatError, - repeatPasswordState = repeatPasswordState, - snackbarHostState = snackbarHostState, - dismissDialog = changeViewModel::navigateUp, - ) { - changeViewModel.checkChange( - oldPassword = oldPasswordState.value, - newPassword = newPasswordState.value, - repeatNewPassword = repeatPasswordState.value - ) - } + ShowChangePasswordDialog(result) } @Composable -fun ShowChangePasswordDialog( - showLoaing: Boolean = true, - oldLabelResource: StringResource = Res.string.enter_old_password, - oldError: Boolean = false, - oldPasswordState: MutableState = mutableStateOf("old password"), - newLabelResource: StringResource = Res.string.enter_new_password, - newError: Boolean = false, - newPasswordState: MutableState = mutableStateOf("new password"), - repeatLabelResource: StringResource = Res.string.repeat_new_password, - repeatError: Boolean = true, - repeatPasswordState: MutableState = mutableStateOf("repeat new password"), - snackbarHostState: SnackbarHostState = SnackbarHostState(), - dismissDialog: () -> Unit = {}, - onChangeClick: () -> Unit = {}, -) = AlertDialog( +fun ShowChangePasswordDialog(result: ChangeResult) = AlertDialog( title = { Text(text = stringResource(Res.string.dialog_title_change_password)) }, text = { Column { - if (showLoaing) LinearProgressIndicator() + if (result.loading) LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) PasswordField( - passwordState = oldPasswordState, - label = stringResource(oldLabelResource), - isError = oldError, - contentDescription = stringResource(Res.string.enter_old_password), + password = result.oldPassword, + onPasswordChange = result.onEditOldPassword, + label = result.oldPasswordFieldLabel.resString, + isError = result.isOldPasswordError, + contentDescription = stringResource(Res.string.enter_old_password) ) + Spacer(modifier = Modifier.height(8.dp)) PasswordField( - passwordState = newPasswordState, - label = stringResource(newLabelResource), - isError = newError, - contentDescription = stringResource(Res.string.enter_new_password), + password = result.newPassword, + onPasswordChange = result.onEditNewPassword, + label = result.newPasswordFieldLabel.resString, + isError = result.isNewPasswordError, + contentDescription = stringResource(Res.string.enter_new_password) ) + Spacer(modifier = Modifier.height(8.dp)) PasswordField( - passwordState = repeatPasswordState, - label = stringResource(repeatLabelResource), - isError = repeatError, - contentDescription = stringResource(Res.string.repeat_new_password), - ) - SnackbarHost( - hostState = snackbarHostState, - modifier = Modifier.align(Alignment.CenterHorizontally) + password = result.repeatNewPassword, + onPasswordChange = result.onEditRepeatPassword, + label = result.repeatPasswordFieldLabel.resString, + isError = result.isRepeatPasswordError, + contentDescription = stringResource(Res.string.repeat_new_password) ) } }, - confirmButton = { Button(onClick = onChangeClick) { Text(stringResource(Res.string.yes)) } }, - dismissButton = { Button(onClick = dismissDialog) { Text(stringResource(Res.string.cancel)) } }, - onDismissRequest = dismissDialog, + confirmButton = { Button(onClick = result.onChangeClick) { Text(stringResource(Res.string.yes)) } }, + dismissButton = { Button(onClick = result.onCancel) { Text(stringResource(Res.string.cancel)) } }, + onDismissRequest = result.onCancel ) @Preview @Composable -fun PreviewChangePasswordDialog() = PreviewDialog { ShowChangePasswordDialog() } \ No newline at end of file +fun PreviewChangePasswordDialog() = PreviewDialog { + ShowChangePasswordDialog(ChangeResult()) +} diff --git a/shared-compose-ui/src/commonMain/kotlin/com/softartdev/notedelight/ui/dialog/security/ConfirmPasswordDialog.kt b/shared-compose-ui/src/commonMain/kotlin/com/softartdev/notedelight/ui/dialog/security/ConfirmPasswordDialog.kt index b7f0d5d4..23e41460 100644 --- a/shared-compose-ui/src/commonMain/kotlin/com/softartdev/notedelight/ui/dialog/security/ConfirmPasswordDialog.kt +++ b/shared-compose-ui/src/commonMain/kotlin/com/softartdev/notedelight/ui/dialog/security/ConfirmPasswordDialog.kt @@ -25,9 +25,7 @@ import notedelight.shared_compose_ui.generated.resources.Res import notedelight.shared_compose_ui.generated.resources.cancel import notedelight.shared_compose_ui.generated.resources.confirm_password import notedelight.shared_compose_ui.generated.resources.dialog_title_conform_password -import notedelight.shared_compose_ui.generated.resources.empty_password import notedelight.shared_compose_ui.generated.resources.enter_password -import notedelight.shared_compose_ui.generated.resources.passwords_do_not_match import notedelight.shared_compose_ui.generated.resources.yes import org.jetbrains.compose.resources.stringResource @@ -57,13 +55,7 @@ fun ShowConfirmPasswordDialog(result: ConfirmResult) = AlertDialog( PasswordField( password = result.password, onPasswordChange = result.onEditPassword, - label = stringResource( - when (result.passwordLabelType) { - ConfirmResult.LabelType.ENTER -> Res.string.enter_password - ConfirmResult.LabelType.EMPTY -> Res.string.empty_password - ConfirmResult.LabelType.NO_MATCH -> Res.string.passwords_do_not_match - } - ), + label = result.passwordFieldLabel.resString, isError = result.isPasswordError, contentDescription = stringResource(Res.string.enter_password) ) @@ -73,13 +65,7 @@ fun ShowConfirmPasswordDialog(result: ConfirmResult) = AlertDialog( PasswordField( password = result.repeatPassword, onPasswordChange = result.onEditRepeatPassword, - label = stringResource( - when (result.repeatPasswordLabelType) { - ConfirmResult.LabelType.ENTER -> Res.string.confirm_password - ConfirmResult.LabelType.EMPTY -> Res.string.empty_password - ConfirmResult.LabelType.NO_MATCH -> Res.string.passwords_do_not_match - } - ), + label = result.repeatPasswordFieldLabel.resString, isError = result.isRepeatPasswordError, contentDescription = stringResource(Res.string.confirm_password) ) diff --git a/shared-compose-ui/src/commonMain/kotlin/com/softartdev/notedelight/ui/dialog/security/EnterPasswordDialog.kt b/shared-compose-ui/src/commonMain/kotlin/com/softartdev/notedelight/ui/dialog/security/EnterPasswordDialog.kt index a674157a..f78fb0ca 100644 --- a/shared-compose-ui/src/commonMain/kotlin/com/softartdev/notedelight/ui/dialog/security/EnterPasswordDialog.kt +++ b/shared-compose-ui/src/commonMain/kotlin/com/softartdev/notedelight/ui/dialog/security/EnterPasswordDialog.kt @@ -21,11 +21,8 @@ import com.softartdev.notedelight.ui.dialog.PreviewDialog import notedelight.shared_compose_ui.generated.resources.Res import notedelight.shared_compose_ui.generated.resources.cancel import notedelight.shared_compose_ui.generated.resources.dialog_title_enter_password -import notedelight.shared_compose_ui.generated.resources.empty_password import notedelight.shared_compose_ui.generated.resources.enter_password -import notedelight.shared_compose_ui.generated.resources.incorrect_password import notedelight.shared_compose_ui.generated.resources.yes -import org.jetbrains.compose.resources.StringResource import org.jetbrains.compose.resources.stringResource @Composable @@ -45,14 +42,7 @@ fun EnterPasswordDialog( } @Composable -fun ShowEnterPasswordDialog( - result: EnterResult, - labelResource: StringResource = when (result.labelType) { - EnterResult.LabelType.ENTER -> Res.string.enter_password - EnterResult.LabelType.EMPTY -> Res.string.empty_password - EnterResult.LabelType.INCORRECT -> Res.string.incorrect_password - } -) = AlertDialog( +fun ShowEnterPasswordDialog(result: EnterResult) = AlertDialog( title = { Text(text = stringResource(Res.string.dialog_title_enter_password)) }, text = { Column { @@ -60,7 +50,7 @@ fun ShowEnterPasswordDialog( PasswordField( password = result.password, onPasswordChange = result.onEditPassword, - label = stringResource(labelResource), + label = result.fieldLabel.resString, isError = result.isError, contentDescription = stringResource(Res.string.enter_password), ) diff --git a/shared-compose-ui/src/commonMain/kotlin/com/softartdev/notedelight/ui/dialog/security/fieldLabelRes.kt b/shared-compose-ui/src/commonMain/kotlin/com/softartdev/notedelight/ui/dialog/security/fieldLabelRes.kt new file mode 100644 index 00000000..e8b05dce --- /dev/null +++ b/shared-compose-ui/src/commonMain/kotlin/com/softartdev/notedelight/ui/dialog/security/fieldLabelRes.kt @@ -0,0 +1,22 @@ +package com.softartdev.notedelight.ui.dialog.security + +import androidx.compose.runtime.Composable +import com.softartdev.notedelight.shared.presentation.settings.security.FieldLabel +import notedelight.shared_compose_ui.generated.resources.Res +import notedelight.shared_compose_ui.generated.resources.empty_password +import notedelight.shared_compose_ui.generated.resources.enter_password +import notedelight.shared_compose_ui.generated.resources.incorrect_password +import notedelight.shared_compose_ui.generated.resources.passwords_do_not_match +import org.jetbrains.compose.resources.StringResource +import org.jetbrains.compose.resources.stringResource + +val FieldLabel.resString: String + @Composable get() = stringResource(this.res) + +private val FieldLabel.res: StringResource + get() = when (this) { + FieldLabel.ENTER -> Res.string.enter_password + FieldLabel.EMPTY -> Res.string.empty_password + FieldLabel.INCORRECT -> Res.string.incorrect_password + FieldLabel.NO_MATCH -> Res.string.passwords_do_not_match + } diff --git a/shared/src/androidUnitTest/kotlin/com/softartdev/notedelight/shared/presentation/settings/security/change/ChangeViewModelTest.kt b/shared/src/androidUnitTest/kotlin/com/softartdev/notedelight/shared/presentation/settings/security/change/ChangeViewModelTest.kt index f51e1979..9268dc3a 100644 --- a/shared/src/androidUnitTest/kotlin/com/softartdev/notedelight/shared/presentation/settings/security/change/ChangeViewModelTest.kt +++ b/shared/src/androidUnitTest/kotlin/com/softartdev/notedelight/shared/presentation/settings/security/change/ChangeViewModelTest.kt @@ -3,20 +3,27 @@ package com.softartdev.notedelight.shared.presentation.settings.security.change import androidx.arch.core.executor.testing.InstantTaskExecutorRule import app.cash.turbine.test import com.softartdev.notedelight.shared.CoroutineDispatchersStub -import com.softartdev.notedelight.shared.presentation.MainDispatcherRule -import com.softartdev.notedelight.shared.StubEditable +import com.softartdev.notedelight.shared.PrintAntilog import com.softartdev.notedelight.shared.navigation.Router +import com.softartdev.notedelight.shared.presentation.MainDispatcherRule +import com.softartdev.notedelight.shared.presentation.settings.security.FieldLabel import com.softartdev.notedelight.shared.usecase.crypt.ChangePasswordUseCase import com.softartdev.notedelight.shared.usecase.crypt.CheckPasswordUseCase +import io.github.aakira.napier.Napier import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest +import org.junit.After import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before import org.junit.Rule import org.junit.Test import org.mockito.Mockito +import org.mockito.Mockito.verify -@ExperimentalCoroutinesApi +@OptIn(ExperimentalCoroutinesApi::class) class ChangeViewModelTest { @get:Rule @@ -25,90 +32,227 @@ class ChangeViewModelTest { @get:Rule val mainDispatcherRule = MainDispatcherRule() - private val checkPasswordUseCase = Mockito.mock(CheckPasswordUseCase::class.java) - private val changePasswordUseCase = Mockito.mock(ChangePasswordUseCase::class.java) - private val router = Mockito.mock(Router::class.java) + private val mockCheckPasswordUseCase = Mockito.mock(CheckPasswordUseCase::class.java) + private val mockChangePasswordUseCase = Mockito.mock(ChangePasswordUseCase::class.java) + private val mockRouter = Mockito.mock(Router::class.java) private val coroutineDispatchers = CoroutineDispatchersStub( scheduler = mainDispatcherRule.testDispatcher.scheduler ) - private val changeViewModel = ChangeViewModel(checkPasswordUseCase, changePasswordUseCase, router, coroutineDispatchers) + private val viewModel = ChangeViewModel( + checkPasswordUseCase = mockCheckPasswordUseCase, + changePasswordUseCase = mockChangePasswordUseCase, + router = mockRouter, + coroutineDispatchers = coroutineDispatchers + ) + + @Before + fun setUp() = Napier.base(PrintAntilog()) + + @After + fun tearDown() { + Napier.takeLogarithm() + Mockito.reset(mockCheckPasswordUseCase, mockChangePasswordUseCase, mockRouter) + } + + @Test + fun `initial state`() = runTest { + viewModel.stateFlow.test { + val initialState = awaitItem() + // Check initial values + assertFalse(initialState.loading) + assertTrue(initialState.oldPassword.isEmpty()) + assertTrue(initialState.newPassword.isEmpty()) + assertTrue(initialState.repeatNewPassword.isEmpty()) + // Check initial errors state + assertFalse(initialState.isOldPasswordError) + assertFalse(initialState.isNewPasswordError) + assertFalse(initialState.isRepeatPasswordError) + // Check initial label types + assertEquals(FieldLabel.ENTER, initialState.oldPasswordFieldLabel) + assertEquals(FieldLabel.ENTER, initialState.newPasswordFieldLabel) + assertEquals(FieldLabel.ENTER, initialState.repeatPasswordFieldLabel) + assertNull(initialState.snackBarMessageType) + + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `change password success`() = runTest { + val oldPassword = "old" + val newPassword = "new" + Mockito.`when`(mockCheckPasswordUseCase(oldPassword)).thenReturn(true) + + viewModel.stateFlow.test { + var state = awaitItem() // Initial state + + state.onEditOldPassword(oldPassword) + state = awaitItem() + assertEquals(oldPassword, state.oldPassword) + + state.onEditNewPassword(newPassword) + state = awaitItem() + assertEquals(newPassword, state.newPassword) + + state.onEditRepeatPassword(newPassword) + state = awaitItem() + assertEquals(newPassword, state.repeatNewPassword) + + state.onChangeClick() + state = awaitItem() // Loading state + assertTrue(state.loading) + + verify(mockRouter).popBackStack() + verify(mockChangePasswordUseCase).invoke(oldPassword, newPassword) + + cancelAndIgnoreRemainingEvents() + } + } @Test - fun checkChangeOldEmptyPasswordError() = runTest { - changeViewModel.resultStateFlow.test { - assertEquals(ChangeResult.InitState, awaitItem()) + fun `empty old password error`() = runTest { + viewModel.stateFlow.test { + var state = awaitItem() + // Set only new passwords + state.onEditNewPassword("new") + state = awaitItem() + state.onEditRepeatPassword("new") + state = awaitItem() - val old = StubEditable("") - val new = StubEditable("new") - changeViewModel.checkChange(old, new, new) - assertEquals(ChangeResult.Loading, awaitItem()) - assertEquals(ChangeResult.OldEmptyPasswordError, awaitItem()) + state.onChangeClick() + state = awaitItem() // Loading + assertTrue(state.loading) + + state = awaitItem() // Error state + assertEquals(FieldLabel.EMPTY, state.oldPasswordFieldLabel) + assertTrue(state.isOldPasswordError) + assertFalse(state.isNewPasswordError) + assertFalse(state.isRepeatPasswordError) + + state = awaitItem() // Loading finished + assertFalse(state.loading) cancelAndIgnoreRemainingEvents() } } @Test - fun checkChangeNewEmptyPasswordError() = runTest { - changeViewModel.resultStateFlow.test { - assertEquals(ChangeResult.InitState, awaitItem()) + fun `empty new password error`() = runTest { + viewModel.stateFlow.test { + var state = awaitItem() + // Set only old password + state.onEditOldPassword("old") + state = awaitItem() + + state.onChangeClick() + state = awaitItem() // Loading + assertTrue(state.loading) - val old = StubEditable("old") - val new = StubEditable("") - changeViewModel.checkChange(old, new, new) - assertEquals(ChangeResult.Loading, awaitItem()) - assertEquals(ChangeResult.NewEmptyPasswordError, awaitItem()) + state = awaitItem() // Error state + assertEquals(FieldLabel.EMPTY, state.newPasswordFieldLabel) + assertFalse(state.isOldPasswordError) + assertTrue(state.isNewPasswordError) + assertFalse(state.isRepeatPasswordError) + + state = awaitItem() // Loading finished + assertFalse(state.loading) cancelAndIgnoreRemainingEvents() } } @Test - fun checkChangePasswordsNoMatchError() = runTest { - changeViewModel.resultStateFlow.test { - assertEquals(ChangeResult.InitState, awaitItem()) + fun `passwords do not match error`() = runTest { + viewModel.stateFlow.test { + var state = awaitItem() + + state.onEditOldPassword("old") + state = awaitItem() + state.onEditNewPassword("new1") + state = awaitItem() + state.onEditRepeatPassword("new2") + state = awaitItem() + + state.onChangeClick() + state = awaitItem() // Loading + assertTrue(state.loading) - val old = StubEditable("old") - val new = StubEditable("new") - val rep = StubEditable("rep") - changeViewModel.checkChange(old, new, rep) - assertEquals(ChangeResult.Loading, awaitItem()) - assertEquals(ChangeResult.PasswordsNoMatchError, awaitItem()) + state = awaitItem() // Error state + assertEquals(FieldLabel.NO_MATCH, state.repeatPasswordFieldLabel) + assertFalse(state.isOldPasswordError) + assertFalse(state.isNewPasswordError) + assertTrue(state.isRepeatPasswordError) + + state = awaitItem() // Loading finished + assertFalse(state.loading) cancelAndIgnoreRemainingEvents() } } @Test - fun checkChangeSuccess() = runTest { - changeViewModel.resultStateFlow.test { - assertEquals(ChangeResult.InitState, awaitItem()) + fun `incorrect old password error`() = runTest { + val oldPassword = "wrong" + Mockito.`when`(mockCheckPasswordUseCase(oldPassword)).thenReturn(false) + + viewModel.stateFlow.test { + var state = awaitItem() - val old = StubEditable("old") - Mockito.`when`(checkPasswordUseCase(old)).thenReturn(true) - val new = StubEditable("new") - changeViewModel.checkChange(old, new, new) - advanceUntilIdle() - assertEquals(ChangeResult.Loading, awaitItem()) + state.onEditOldPassword(oldPassword) + state = awaitItem() + state.onEditNewPassword("new") + state = awaitItem() + state.onEditRepeatPassword("new") + state = awaitItem() - Mockito.verify(router).popBackStack() - Mockito.verifyNoMoreInteractions(router) + state.onChangeClick() + state = awaitItem() // Loading + assertTrue(state.loading) + + state = awaitItem() // Error state + assertEquals(FieldLabel.INCORRECT, state.oldPasswordFieldLabel) + assertTrue(state.isOldPasswordError) + assertFalse(state.isNewPasswordError) + assertFalse(state.isRepeatPasswordError) + + state = awaitItem() // Loading finished + assertFalse(state.loading) + + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `edit clears errors`() = runTest { + viewModel.stateFlow.test { + var state = awaitItem() + state.onChangeClick() // Trigger empty old password error + state = awaitItem() // Loading + assertTrue(state.loading) + state = awaitItem() // Error state + assertTrue(state.isOldPasswordError) + state = awaitItem() // Loading finished + + state.onEditOldPassword("old") + state = awaitItem() + assertFalse(state.isOldPasswordError) + assertFalse(state.isNewPasswordError) + assertFalse(state.isRepeatPasswordError) + assertEquals(FieldLabel.ENTER, state.oldPasswordFieldLabel) + assertEquals(FieldLabel.ENTER, state.newPasswordFieldLabel) + assertEquals(FieldLabel.ENTER, state.repeatPasswordFieldLabel) cancelAndIgnoreRemainingEvents() } } @Test - fun checkChangeIncorrectPasswordError() = runTest { - changeViewModel.resultStateFlow.test { - assertEquals(ChangeResult.InitState, awaitItem()) - - val old = StubEditable("old") - Mockito.`when`(checkPasswordUseCase(old)).thenReturn(false) - val new = StubEditable("new") - changeViewModel.checkChange(old, new, new) - assertEquals(ChangeResult.Loading, awaitItem()) - assertEquals(ChangeResult.IncorrectPasswordError, awaitItem()) + fun `cancel navigation`() = runTest { + viewModel.stateFlow.test { + val initialState = awaitItem() + + initialState.onCancel() + verify(mockRouter).popBackStack() cancelAndIgnoreRemainingEvents() } diff --git a/shared/src/androidUnitTest/kotlin/com/softartdev/notedelight/shared/presentation/settings/security/confirm/ConfirmViewModelTest.kt b/shared/src/androidUnitTest/kotlin/com/softartdev/notedelight/shared/presentation/settings/security/confirm/ConfirmViewModelTest.kt index 14b31e8a..256096b5 100644 --- a/shared/src/androidUnitTest/kotlin/com/softartdev/notedelight/shared/presentation/settings/security/confirm/ConfirmViewModelTest.kt +++ b/shared/src/androidUnitTest/kotlin/com/softartdev/notedelight/shared/presentation/settings/security/confirm/ConfirmViewModelTest.kt @@ -6,6 +6,7 @@ import com.softartdev.notedelight.shared.CoroutineDispatchersStub import com.softartdev.notedelight.shared.PrintAntilog import com.softartdev.notedelight.shared.navigation.Router import com.softartdev.notedelight.shared.presentation.MainDispatcherRule +import com.softartdev.notedelight.shared.presentation.settings.security.FieldLabel import com.softartdev.notedelight.shared.usecase.crypt.ChangePasswordUseCase import io.github.aakira.napier.Napier import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -59,8 +60,8 @@ class ConfirmViewModelTest { assertTrue(initialState.repeatPassword.isEmpty()) assertFalse(initialState.isPasswordError) assertFalse(initialState.isRepeatPasswordError) - assertEquals(ConfirmResult.LabelType.ENTER, initialState.passwordLabelType) - assertEquals(ConfirmResult.LabelType.ENTER, initialState.repeatPasswordLabelType) + assertEquals(FieldLabel.ENTER, initialState.passwordFieldLabel) + assertEquals(FieldLabel.ENTER, initialState.repeatPasswordFieldLabel) assertNull(initialState.snackBarMessageType) cancelAndIgnoreRemainingEvents() @@ -76,8 +77,8 @@ class ConfirmViewModelTest { initialState.onEditPassword(password) var state = awaitItem() assertEquals(password, state.password) - assertEquals(ConfirmResult.LabelType.ENTER, state.passwordLabelType) - assertEquals(ConfirmResult.LabelType.ENTER, state.repeatPasswordLabelType) + assertEquals(FieldLabel.ENTER, state.passwordFieldLabel) + assertEquals(FieldLabel.ENTER, state.repeatPasswordFieldLabel) assertFalse(state.isPasswordError) assertFalse(state.isRepeatPasswordError) @@ -110,8 +111,8 @@ class ConfirmViewModelTest { assertTrue(state.loading) state = awaitItem() - assertEquals(ConfirmResult.LabelType.ENTER, state.passwordLabelType) - assertEquals(ConfirmResult.LabelType.NO_MATCH, state.repeatPasswordLabelType) + assertEquals(FieldLabel.ENTER, state.passwordFieldLabel) + assertEquals(FieldLabel.NO_MATCH, state.repeatPasswordFieldLabel) assertTrue(state.isRepeatPasswordError) assertFalse(state.isPasswordError) @@ -140,8 +141,8 @@ class ConfirmViewModelTest { assertTrue(state.loading) state = awaitItem() - assertEquals(ConfirmResult.LabelType.ENTER, state.passwordLabelType) - assertEquals(ConfirmResult.LabelType.NO_MATCH, state.repeatPasswordLabelType) + assertEquals(FieldLabel.ENTER, state.passwordFieldLabel) + assertEquals(FieldLabel.NO_MATCH, state.repeatPasswordFieldLabel) assertFalse(state.isPasswordError) assertTrue(state.isRepeatPasswordError) @@ -160,7 +161,7 @@ class ConfirmViewModelTest { state = awaitItem() // Error state assertTrue(state.isPasswordError) - assertEquals(ConfirmResult.LabelType.EMPTY, state.passwordLabelType) + assertEquals(FieldLabel.EMPTY, state.passwordFieldLabel) state = awaitItem() assertFalse(state.loading) @@ -170,8 +171,8 @@ class ConfirmViewModelTest { state = awaitItem() assertFalse(state.isPasswordError) assertFalse(state.isRepeatPasswordError) - assertEquals(ConfirmResult.LabelType.ENTER, state.passwordLabelType) - assertEquals(ConfirmResult.LabelType.ENTER, state.repeatPasswordLabelType) + assertEquals(FieldLabel.ENTER, state.passwordFieldLabel) + assertEquals(FieldLabel.ENTER, state.repeatPasswordFieldLabel) cancelAndIgnoreRemainingEvents() } diff --git a/shared/src/androidUnitTest/kotlin/com/softartdev/notedelight/shared/presentation/settings/security/enter/EnterViewModelTest.kt b/shared/src/androidUnitTest/kotlin/com/softartdev/notedelight/shared/presentation/settings/security/enter/EnterViewModelTest.kt index 4cd92895..1fccc0ed 100644 --- a/shared/src/androidUnitTest/kotlin/com/softartdev/notedelight/shared/presentation/settings/security/enter/EnterViewModelTest.kt +++ b/shared/src/androidUnitTest/kotlin/com/softartdev/notedelight/shared/presentation/settings/security/enter/EnterViewModelTest.kt @@ -6,6 +6,7 @@ import com.softartdev.notedelight.shared.CoroutineDispatchersStub import com.softartdev.notedelight.shared.PrintAntilog import com.softartdev.notedelight.shared.navigation.Router import com.softartdev.notedelight.shared.presentation.MainDispatcherRule +import com.softartdev.notedelight.shared.presentation.settings.security.FieldLabel import com.softartdev.notedelight.shared.usecase.crypt.ChangePasswordUseCase import com.softartdev.notedelight.shared.usecase.crypt.CheckPasswordUseCase import io.github.aakira.napier.Napier @@ -101,7 +102,7 @@ class EnterViewModelTest { assertTrue(resultState.loading) resultState = awaitItem() - assertEquals(EnterResult.LabelType.EMPTY, resultState.labelType) + assertEquals(FieldLabel.EMPTY, resultState.fieldLabel) resultState = awaitItem() assertTrue(resultState.isError) @@ -121,7 +122,7 @@ class EnterViewModelTest { var resultState = awaitItem() assertFalse(resultState.loading) assertFalse(resultState.isError) - assertEquals(EnterResult.LabelType.ENTER, resultState.labelType) + assertEquals(FieldLabel.ENTER, resultState.fieldLabel) resultState.onEditPassword(password) resultState = awaitItem() @@ -131,7 +132,7 @@ class EnterViewModelTest { assertTrue(resultState.loading) resultState = awaitItem() - assertEquals(EnterResult.LabelType.INCORRECT, resultState.labelType) + assertEquals(FieldLabel.INCORRECT, resultState.fieldLabel) resultState = awaitItem() assertTrue(resultState.isError) diff --git a/shared/src/commonMain/kotlin/com/softartdev/notedelight/shared/presentation/settings/security/FieldLabel.kt b/shared/src/commonMain/kotlin/com/softartdev/notedelight/shared/presentation/settings/security/FieldLabel.kt new file mode 100644 index 00000000..3414a65a --- /dev/null +++ b/shared/src/commonMain/kotlin/com/softartdev/notedelight/shared/presentation/settings/security/FieldLabel.kt @@ -0,0 +1,5 @@ +package com.softartdev.notedelight.shared.presentation.settings.security + +enum class FieldLabel { + ENTER, EMPTY, INCORRECT, NO_MATCH +} diff --git a/shared/src/commonMain/kotlin/com/softartdev/notedelight/shared/presentation/settings/security/change/ChangeResult.kt b/shared/src/commonMain/kotlin/com/softartdev/notedelight/shared/presentation/settings/security/change/ChangeResult.kt index a4da44e7..9ce3e7d2 100644 --- a/shared/src/commonMain/kotlin/com/softartdev/notedelight/shared/presentation/settings/security/change/ChangeResult.kt +++ b/shared/src/commonMain/kotlin/com/softartdev/notedelight/shared/presentation/settings/security/change/ChangeResult.kt @@ -1,11 +1,40 @@ package com.softartdev.notedelight.shared.presentation.settings.security.change -sealed class ChangeResult { - data object InitState: ChangeResult() - data object Loading: ChangeResult() - data object OldEmptyPasswordError: ChangeResult() - data object NewEmptyPasswordError: ChangeResult() - data object PasswordsNoMatchError: ChangeResult() - data object IncorrectPasswordError: ChangeResult() - data class Error(val message: String?): ChangeResult() +import com.softartdev.notedelight.shared.presentation.settings.security.FieldLabel + +data class ChangeResult( + val loading: Boolean = false, + // Old password field + val oldPassword: String = "", + val oldPasswordFieldLabel: FieldLabel = FieldLabel.ENTER, + val isOldPasswordError: Boolean = false, + // New password field + val newPassword: String = "", + val newPasswordFieldLabel: FieldLabel = FieldLabel.ENTER, + val isNewPasswordError: Boolean = false, + // Repeat new password field + val repeatNewPassword: String = "", + val repeatPasswordFieldLabel: FieldLabel = FieldLabel.ENTER, + val isRepeatPasswordError: Boolean = false, + // Common + val snackBarMessageType: String? = null, + // Events + val onCancel: () -> Unit = {}, + val onEditOldPassword: (String) -> Unit = {}, + val onEditNewPassword: (String) -> Unit = {}, + val onEditRepeatPassword: (String) -> Unit = {}, + val onChangeClick: () -> Unit = {}, + val disposeOneTimeEvents: () -> Unit = {} +) { + fun showLoading(): ChangeResult = copy(loading = true) + fun hideLoading(): ChangeResult = copy(loading = false) + fun hideErrors(): ChangeResult = copy( + isOldPasswordError = false, + isNewPasswordError = false, + isRepeatPasswordError = false, + oldPasswordFieldLabel = FieldLabel.ENTER, + newPasswordFieldLabel = FieldLabel.ENTER, + repeatPasswordFieldLabel = FieldLabel.ENTER + ) + fun hideSnackBarMessage(): ChangeResult = copy(snackBarMessageType = null) } diff --git a/shared/src/commonMain/kotlin/com/softartdev/notedelight/shared/presentation/settings/security/change/ChangeViewModel.kt b/shared/src/commonMain/kotlin/com/softartdev/notedelight/shared/presentation/settings/security/change/ChangeViewModel.kt index 2d200a28..67650dcb 100644 --- a/shared/src/commonMain/kotlin/com/softartdev/notedelight/shared/presentation/settings/security/change/ChangeViewModel.kt +++ b/shared/src/commonMain/kotlin/com/softartdev/notedelight/shared/presentation/settings/security/change/ChangeViewModel.kt @@ -3,13 +3,16 @@ package com.softartdev.notedelight.shared.presentation.settings.security.change import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.softartdev.notedelight.shared.navigation.Router +import com.softartdev.notedelight.shared.presentation.settings.security.FieldLabel import com.softartdev.notedelight.shared.usecase.crypt.ChangePasswordUseCase import com.softartdev.notedelight.shared.usecase.crypt.CheckPasswordUseCase import com.softartdev.notedelight.shared.util.CoroutineDispatchers import io.github.aakira.napier.Napier import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext class ChangeViewModel( private val checkPasswordUseCase: CheckPasswordUseCase, @@ -18,42 +21,92 @@ class ChangeViewModel( private val coroutineDispatchers: CoroutineDispatchers, ) : ViewModel() { private val mutableStateFlow: MutableStateFlow = MutableStateFlow( - value = ChangeResult.InitState + value = ChangeResult( + onCancel = this::cancel, + onChangeClick = this::change, + onEditOldPassword = this::onEditOldPassword, + onEditNewPassword = this::onEditNewPassword, + onEditRepeatPassword = this::onEditRepeatPassword, + disposeOneTimeEvents = this::disposeOneTimeEvents + ) ) - val resultStateFlow: StateFlow = mutableStateFlow - - fun checkChange( - oldPassword: CharSequence, - newPassword: CharSequence, - repeatNewPassword: CharSequence - ) = viewModelScope.launch(context = coroutineDispatchers.io) { - mutableStateFlow.value = ChangeResult.Loading + val stateFlow: StateFlow = mutableStateFlow + + private fun onEditOldPassword(password: String) = viewModelScope.launch { + mutableStateFlow.update(ChangeResult::hideErrors) + mutableStateFlow.update { it.copy(oldPassword = password) } + } + + private fun onEditNewPassword(password: String) = viewModelScope.launch { + mutableStateFlow.update(ChangeResult::hideErrors) + mutableStateFlow.update { it.copy(newPassword = password) } + } + + private fun onEditRepeatPassword(password: String) = viewModelScope.launch { + mutableStateFlow.update(ChangeResult::hideErrors) + mutableStateFlow.update { it.copy(repeatNewPassword = password) } + } + + private fun change() = viewModelScope.launch(context = coroutineDispatchers.io) { + mutableStateFlow.update(ChangeResult::showLoading) try { + val oldPassword = mutableStateFlow.value.oldPassword + val newPassword = mutableStateFlow.value.newPassword + val repeatNewPassword = mutableStateFlow.value.repeatNewPassword + when { oldPassword.isEmpty() -> { - mutableStateFlow.value = ChangeResult.OldEmptyPasswordError + mutableStateFlow.update { + it.copy( + oldPasswordFieldLabel = FieldLabel.EMPTY, + isOldPasswordError = true + ) + } } newPassword.isEmpty() -> { - mutableStateFlow.value = ChangeResult.NewEmptyPasswordError + mutableStateFlow.update { + it.copy( + newPasswordFieldLabel = FieldLabel.EMPTY, + isNewPasswordError = true + ) + } } - newPassword.toString() != repeatNewPassword.toString() -> { - mutableStateFlow.value = ChangeResult.PasswordsNoMatchError + newPassword != repeatNewPassword -> { + mutableStateFlow.update { + it.copy( + repeatPasswordFieldLabel = FieldLabel.NO_MATCH, + isRepeatPasswordError = true + ) + } } checkPasswordUseCase(oldPassword) -> { changePasswordUseCase(oldPassword, newPassword) - navigateUp() + withContext(coroutineDispatchers.main) { + router.popBackStack() + } } else -> { - mutableStateFlow.value = ChangeResult.IncorrectPasswordError + mutableStateFlow.update { + it.copy( + oldPasswordFieldLabel = FieldLabel.INCORRECT, + isOldPasswordError = true + ) + } } } } catch (e: Throwable) { Napier.e("❌", e) - mutableStateFlow.value = ChangeResult.Error(e.message) + mutableStateFlow.update { it.copy(snackBarMessageType = e.message) } + } finally { + mutableStateFlow.update(ChangeResult::hideLoading) } } - fun navigateUp() = viewModelScope.launch(context = coroutineDispatchers.main) { + private fun cancel() = viewModelScope.launch { router.popBackStack() } + + private fun disposeOneTimeEvents() = viewModelScope.launch { + mutableStateFlow.update(ChangeResult::hideSnackBarMessage) + } } diff --git a/shared/src/commonMain/kotlin/com/softartdev/notedelight/shared/presentation/settings/security/confirm/ConfirmResult.kt b/shared/src/commonMain/kotlin/com/softartdev/notedelight/shared/presentation/settings/security/confirm/ConfirmResult.kt index 6c9b8087..f71915f3 100644 --- a/shared/src/commonMain/kotlin/com/softartdev/notedelight/shared/presentation/settings/security/confirm/ConfirmResult.kt +++ b/shared/src/commonMain/kotlin/com/softartdev/notedelight/shared/presentation/settings/security/confirm/ConfirmResult.kt @@ -1,11 +1,13 @@ package com.softartdev.notedelight.shared.presentation.settings.security.confirm +import com.softartdev.notedelight.shared.presentation.settings.security.FieldLabel + data class ConfirmResult( val loading: Boolean = false, val password: String = "", val repeatPassword: String = "", - val passwordLabelType: LabelType = LabelType.ENTER, - val repeatPasswordLabelType: LabelType = LabelType.ENTER, + val passwordFieldLabel: FieldLabel = FieldLabel.ENTER, + val repeatPasswordFieldLabel: FieldLabel = FieldLabel.ENTER, val isPasswordError: Boolean = false, val isRepeatPasswordError: Boolean = false, val snackBarMessageType: String? = null, @@ -15,8 +17,6 @@ data class ConfirmResult( val onConfirmClick: () -> Unit = {}, val disposeOneTimeEvents: () -> Unit = {} ) { - enum class LabelType { ENTER, EMPTY, NO_MATCH } - fun showLoading(): ConfirmResult = copy(loading = true) fun hideLoading(): ConfirmResult = copy(loading = false) fun showPasswordError(): ConfirmResult = copy(isPasswordError = true) @@ -24,8 +24,8 @@ data class ConfirmResult( fun hideErrors(): ConfirmResult = copy( isPasswordError = false, isRepeatPasswordError = false, - passwordLabelType = LabelType.ENTER, - repeatPasswordLabelType = LabelType.ENTER + passwordFieldLabel = FieldLabel.ENTER, + repeatPasswordFieldLabel = FieldLabel.ENTER ) fun hideSnackBarMessage(): ConfirmResult = copy(snackBarMessageType = null) } diff --git a/shared/src/commonMain/kotlin/com/softartdev/notedelight/shared/presentation/settings/security/confirm/ConfirmViewModel.kt b/shared/src/commonMain/kotlin/com/softartdev/notedelight/shared/presentation/settings/security/confirm/ConfirmViewModel.kt index b520c4fd..53343ec2 100644 --- a/shared/src/commonMain/kotlin/com/softartdev/notedelight/shared/presentation/settings/security/confirm/ConfirmViewModel.kt +++ b/shared/src/commonMain/kotlin/com/softartdev/notedelight/shared/presentation/settings/security/confirm/ConfirmViewModel.kt @@ -3,6 +3,7 @@ package com.softartdev.notedelight.shared.presentation.settings.security.confirm import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.softartdev.notedelight.shared.navigation.Router +import com.softartdev.notedelight.shared.presentation.settings.security.FieldLabel import com.softartdev.notedelight.shared.usecase.crypt.ChangePasswordUseCase import com.softartdev.notedelight.shared.util.CoroutineDispatchers import io.github.aakira.napier.Napier @@ -46,13 +47,13 @@ class ConfirmViewModel( when { password != repeatPassword -> { mutableStateFlow.update { - it.copy(repeatPasswordLabelType = ConfirmResult.LabelType.NO_MATCH) + it.copy(repeatPasswordFieldLabel = FieldLabel.NO_MATCH) .showRepeatPasswordError() } } password.isEmpty() -> { mutableStateFlow.update { - it.copy(passwordLabelType = ConfirmResult.LabelType.EMPTY) + it.copy(passwordFieldLabel = FieldLabel.EMPTY) .showPasswordError() } } diff --git a/shared/src/commonMain/kotlin/com/softartdev/notedelight/shared/presentation/settings/security/enter/EnterResult.kt b/shared/src/commonMain/kotlin/com/softartdev/notedelight/shared/presentation/settings/security/enter/EnterResult.kt index 585a0687..411e06b4 100644 --- a/shared/src/commonMain/kotlin/com/softartdev/notedelight/shared/presentation/settings/security/enter/EnterResult.kt +++ b/shared/src/commonMain/kotlin/com/softartdev/notedelight/shared/presentation/settings/security/enter/EnterResult.kt @@ -1,8 +1,10 @@ package com.softartdev.notedelight.shared.presentation.settings.security.enter +import com.softartdev.notedelight.shared.presentation.settings.security.FieldLabel + data class EnterResult( val loading: Boolean = false, - val labelType: LabelType = LabelType.ENTER, + val fieldLabel: FieldLabel = FieldLabel.ENTER, val password: String = "", val isPasswordVisible: Boolean = false, val isError: Boolean = false, @@ -13,8 +15,6 @@ data class EnterResult( val onEnterClick: () -> Unit = {}, val disposeOneTimeEvents: () -> Unit = {} ) { - enum class LabelType { ENTER, EMPTY, INCORRECT } - fun showLoading(): EnterResult = copy(loading = true) fun hideLoading(): EnterResult = copy(loading = false) fun showError(): EnterResult = copy(isError = true) diff --git a/shared/src/commonMain/kotlin/com/softartdev/notedelight/shared/presentation/settings/security/enter/EnterViewModel.kt b/shared/src/commonMain/kotlin/com/softartdev/notedelight/shared/presentation/settings/security/enter/EnterViewModel.kt index de0d62be..f8e908c4 100644 --- a/shared/src/commonMain/kotlin/com/softartdev/notedelight/shared/presentation/settings/security/enter/EnterViewModel.kt +++ b/shared/src/commonMain/kotlin/com/softartdev/notedelight/shared/presentation/settings/security/enter/EnterViewModel.kt @@ -3,6 +3,7 @@ package com.softartdev.notedelight.shared.presentation.settings.security.enter import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.softartdev.notedelight.shared.navigation.Router +import com.softartdev.notedelight.shared.presentation.settings.security.FieldLabel import com.softartdev.notedelight.shared.usecase.crypt.ChangePasswordUseCase import com.softartdev.notedelight.shared.usecase.crypt.CheckPasswordUseCase import com.softartdev.notedelight.shared.util.CoroutineDispatchers @@ -31,7 +32,7 @@ class EnterViewModel( private fun onEditPassword(password: String) = viewModelScope.launch { mutableStateFlow.update(EnterResult::hideError) - mutableStateFlow.update { it.copy(labelType = EnterResult.LabelType.ENTER) } + mutableStateFlow.update { it.copy(fieldLabel = FieldLabel.ENTER) } mutableStateFlow.update { it.copy(password = password) } } @@ -45,7 +46,7 @@ class EnterViewModel( val password = mutableStateFlow.value.password when { password.isEmpty() -> { - mutableStateFlow.update { it.copy(labelType = EnterResult.LabelType.EMPTY) } + mutableStateFlow.update { it.copy(fieldLabel = FieldLabel.EMPTY) } mutableStateFlow.update(EnterResult::showError) } checkPasswordUseCase(password) -> { @@ -53,7 +54,7 @@ class EnterViewModel( navigateUp() } else -> { - mutableStateFlow.update { it.copy(labelType = EnterResult.LabelType.INCORRECT) } + mutableStateFlow.update { it.copy(fieldLabel = FieldLabel.INCORRECT) } mutableStateFlow.update(EnterResult::showError) } }