Skip to content

Commit

Permalink
refactoring confirm-password dialog
Browse files Browse the repository at this point in the history
  • Loading branch information
softartdev committed Nov 3, 2024
1 parent 3f8854d commit 92a81db
Show file tree
Hide file tree
Showing 4 changed files with 264 additions and 128 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -2,130 +2,96 @@ 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.confirm.ConfirmResult
import com.softartdev.notedelight.shared.presentation.settings.security.confirm.ConfirmViewModel
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.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.error_title
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
import org.jetbrains.compose.resources.getString
import org.jetbrains.compose.resources.stringResource

@Composable
fun ConfirmPasswordDialog(
confirmViewModel: ConfirmViewModel,
snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }
) {
val confirmResultState: State<ConfirmResult> = confirmViewModel.resultStateFlow.collectAsState()
var labelResource by remember { mutableStateOf(Res.string.enter_password) }
var error by remember { mutableStateOf(false) }
var repeatLabelResource by remember { mutableStateOf(Res.string.confirm_password) }
var repeatError by remember { mutableStateOf(false) }
val passwordState: MutableState<String> = remember { mutableStateOf("") }
val repeatPasswordState: MutableState<String> = remember { mutableStateOf("") }
val coroutineScope = rememberCoroutineScope()
when (val confirmResult: ConfirmResult = confirmResultState.value) {
is ConfirmResult.InitState,
is ConfirmResult.Loading -> Unit
is ConfirmResult.EmptyPasswordError -> {
labelResource = Res.string.empty_password
error = true
}
is ConfirmResult.PasswordsNoMatchError -> {
repeatLabelResource = Res.string.passwords_do_not_match
repeatError = true
}
is ConfirmResult.Error -> coroutineScope.launch {
snackbarHostState.showSnackbar(
message = confirmResult.message ?: getString(Res.string.error_title)
)
val result: ConfirmResult by confirmViewModel.stateFlow.collectAsState()

LaunchedEffect(key1 = confirmViewModel, key2 = result, key3 = result.snackBarMessageType) {
result.snackBarMessageType?.let { msg: String ->
snackbarHostState.showSnackbar(msg)
result.disposeOneTimeEvents()
}
}
ShowConfirmPasswordDialog(
showLoaing = confirmResultState.value is ConfirmResult.Loading,
passwordState = passwordState,
repeatPasswordState = repeatPasswordState,
labelResource = labelResource,
repeatLabelResource = repeatLabelResource,
isError = error,
isRepeatError = repeatError,
snackbarHostState = snackbarHostState,
dismissDialog = confirmViewModel::navigateUp,
onConfirmClick = {
confirmViewModel.conformCheck(
password = passwordState.value,
repeatPassword = repeatPasswordState.value
)
}
)
ShowConfirmPasswordDialog(result)
}

@Composable
fun ShowConfirmPasswordDialog(
showLoaing: Boolean = true,
passwordState: MutableState<String> = mutableStateOf("password"),
repeatPasswordState: MutableState<String> = mutableStateOf("repeat password"),
labelResource: StringResource = Res.string.enter_password,
repeatLabelResource: StringResource = Res.string.confirm_password,
isError: Boolean = false,
isRepeatError: Boolean = true,
snackbarHostState: SnackbarHostState = SnackbarHostState(),
dismissDialog: () -> Unit = {},
onConfirmClick: () -> Unit = {},
) = AlertDialog(
fun ShowConfirmPasswordDialog(result: ConfirmResult) = AlertDialog(
title = { Text(text = stringResource(Res.string.dialog_title_conform_password)) },
text = {
Column {
if (showLoaing) LinearProgressIndicator()
if (result.loading) LinearProgressIndicator(modifier = Modifier.fillMaxWidth())

PasswordField(
passwordState = passwordState,
label = stringResource(labelResource),
isError = isError,
contentDescription = stringResource(Res.string.enter_password),
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
}
),
isError = result.isPasswordError,
contentDescription = stringResource(Res.string.enter_password)
)

Spacer(modifier = Modifier.height(8.dp))

PasswordField(
passwordState = repeatPasswordState,
label = stringResource(repeatLabelResource),
isError = isRepeatError,
contentDescription = stringResource(Res.string.confirm_password),
)
SnackbarHost(
hostState = snackbarHostState,
modifier = Modifier.align(Alignment.CenterHorizontally)
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
}
),
isError = result.isRepeatPasswordError,
contentDescription = stringResource(Res.string.confirm_password)
)
}
},
confirmButton = { Button(onClick = onConfirmClick) { Text(stringResource(Res.string.yes)) } },
dismissButton = { Button(onClick = dismissDialog) { Text(stringResource(Res.string.cancel)) } },
onDismissRequest = dismissDialog,
confirmButton = { Button(onClick = result.onConfirmClick) { Text(stringResource(Res.string.yes)) } },
dismissButton = { Button(onClick = result.onCancel) { Text(stringResource(Res.string.cancel)) } },
onDismissRequest = result.onCancel
)

@Preview
@Composable
fun PreviewConfirmPasswordDialog() = PreviewDialog { ShowConfirmPasswordDialog() }
fun PreviewConfirmPasswordDialog() = PreviewDialog {
ShowConfirmPasswordDialog(ConfirmResult())
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,25 @@ package com.softartdev.notedelight.shared.presentation.settings.security.confirm
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import app.cash.turbine.test
import com.softartdev.notedelight.shared.CoroutineDispatchersStub
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.usecase.crypt.ChangePasswordUseCase
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 ConfirmViewModelTest {

@get:Rule
Expand All @@ -24,52 +30,162 @@ class ConfirmViewModelTest {
@get:Rule
val mainDispatcherRule = MainDispatcherRule()

private val changePasswordUseCase = Mockito.mock(ChangePasswordUseCase::class.java)
private val router = Mockito.mock(Router::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 confirmViewModel = ConfirmViewModel(changePasswordUseCase, router, coroutineDispatchers)
private val viewModel = ConfirmViewModel(
changePasswordUseCase = mockChangePasswordUseCase,
router = mockRouter,
coroutineDispatchers = coroutineDispatchers
)

@Before
fun setUp() = Napier.base(PrintAntilog())

@After
fun tearDown() {
Napier.takeLogarithm()
Mockito.reset(mockChangePasswordUseCase, mockRouter)
}

@Test
fun `initial state`() = runTest {
viewModel.stateFlow.test {
val initialState = awaitItem()
assertFalse(initialState.loading)
assertTrue(initialState.password.isEmpty())
assertTrue(initialState.repeatPassword.isEmpty())
assertFalse(initialState.isPasswordError)
assertFalse(initialState.isRepeatPasswordError)
assertEquals(ConfirmResult.LabelType.ENTER, initialState.passwordLabelType)
assertEquals(ConfirmResult.LabelType.ENTER, initialState.repeatPasswordLabelType)
assertNull(initialState.snackBarMessageType)

cancelAndIgnoreRemainingEvents()
}
}

@Test
fun conformCheckPasswordsNoMatchError() = runTest {
confirmViewModel.resultStateFlow.test {
assertEquals(ConfirmResult.InitState, awaitItem())
fun `confirm success`() = runTest {
viewModel.stateFlow.test {
val initialState = awaitItem()

val password = "password"
initialState.onEditPassword(password)
var state = awaitItem()
assertEquals(password, state.password)
assertEquals(ConfirmResult.LabelType.ENTER, state.passwordLabelType)
assertEquals(ConfirmResult.LabelType.ENTER, state.repeatPasswordLabelType)
assertFalse(state.isPasswordError)
assertFalse(state.isRepeatPasswordError)

state.onEditRepeatPassword(password)
state = awaitItem()
assertEquals(password, state.repeatPassword)
assertFalse(state.isPasswordError)
assertFalse(state.isRepeatPasswordError)

confirmViewModel.conformCheck(StubEditable("pass"), StubEditable("new pass"))
assertEquals(ConfirmResult.Loading, awaitItem())
assertEquals(ConfirmResult.PasswordsNoMatchError, awaitItem())
state.onConfirmClick()
state = awaitItem()
assertTrue(state.loading)

verify(mockRouter).popBackStack()

cancelAndIgnoreRemainingEvents()
}
}

@Test
fun conformCheckEmptyPasswordError() = runTest {
confirmViewModel.resultStateFlow.test {
assertEquals(ConfirmResult.InitState, awaitItem())
fun `empty password error`() = runTest {
viewModel.stateFlow.test {
val initialState = awaitItem()

initialState.onEditRepeatPassword("password") // Only set repeat password
var state = awaitItem()

confirmViewModel.conformCheck(StubEditable(""), StubEditable(""))
assertEquals(ConfirmResult.Loading, awaitItem())
assertEquals(ConfirmResult.EmptyPasswordError, awaitItem())
state.onConfirmClick()
state = awaitItem()
assertTrue(state.loading)

state = awaitItem()
assertEquals(ConfirmResult.LabelType.ENTER, state.passwordLabelType)
assertEquals(ConfirmResult.LabelType.NO_MATCH, state.repeatPasswordLabelType)
assertTrue(state.isRepeatPasswordError)
assertFalse(state.isPasswordError)

cancelAndIgnoreRemainingEvents()
}
}

@Test
fun conformCheckSuccess() = runTest {
confirmViewModel.resultStateFlow.test {
assertEquals(ConfirmResult.InitState, awaitItem())
fun `passwords do not match error`() = runTest {
viewModel.stateFlow.test {
val initialState = awaitItem()

// Enter different passwords
initialState.onEditPassword("password1")
var state = awaitItem()
assertFalse(state.isPasswordError)
assertFalse(state.isRepeatPasswordError)

state.onEditRepeatPassword("password2")
state = awaitItem()
assertFalse(state.isPasswordError)
assertFalse(state.isRepeatPasswordError)

state.onConfirmClick()
state = awaitItem()
assertTrue(state.loading)

confirmViewModel.conformCheck(StubEditable("pass"), StubEditable("pass"))
advanceUntilIdle()
assertEquals(ConfirmResult.Loading, awaitItem())
state = awaitItem()
assertEquals(ConfirmResult.LabelType.ENTER, state.passwordLabelType)
assertEquals(ConfirmResult.LabelType.NO_MATCH, state.repeatPasswordLabelType)
assertFalse(state.isPasswordError)
assertTrue(state.isRepeatPasswordError)

cancelAndIgnoreRemainingEvents()
}
}

@Test
fun `edit clears errors`() = runTest {
viewModel.stateFlow.test {
var state = awaitItem()
state.onConfirmClick() // Trigger empty password error

state = awaitItem() // Loading
assertTrue(state.loading)

state = awaitItem() // Error state
assertTrue(state.isPasswordError)
assertEquals(ConfirmResult.LabelType.EMPTY, state.passwordLabelType)

state = awaitItem()
assertFalse(state.loading)

state.onEditPassword("password")

state = awaitItem()
assertFalse(state.isPasswordError)
assertFalse(state.isRepeatPasswordError)
assertEquals(ConfirmResult.LabelType.ENTER, state.passwordLabelType)
assertEquals(ConfirmResult.LabelType.ENTER, state.repeatPasswordLabelType)

cancelAndIgnoreRemainingEvents()
}
}

@Test
fun `cancel navigation`() = runTest {
viewModel.stateFlow.test {
val initialState = awaitItem()

Mockito.verify(router).popBackStack()
Mockito.verifyNoMoreInteractions(router)
initialState.onCancel()
verify(mockRouter).popBackStack()

cancelAndIgnoreRemainingEvents()
}
}
}
}
Loading

0 comments on commit 92a81db

Please sign in to comment.