mirror of
https://github.com/openMF/mifos-mobile.git
synced 2026-02-06 11:26:51 +00:00
feat: set new password ui and viewModel (#2857)
This commit is contained in:
parent
5b16aa380b
commit
ca5bb5902b
@ -147,6 +147,22 @@
|
||||
<string name="feature_otp_required_error">OTP is required</string>
|
||||
<string name="feature_otp_invalid_error">Enter valid OTP</string>
|
||||
|
||||
|
||||
<!--Set New Password-->
|
||||
|
||||
<string name="feature_set_new_password_title">Set Password</string>
|
||||
<string name="feature_set_new_password_message">Set your new high security password now!</string>
|
||||
<string name="feature_set_new_password_new_password_label">New Password</string>
|
||||
<string name="feature_set_new_password_confirm_password_label">Confirm New Password</string>
|
||||
<string name="feature_set_new_password_submit">Submit</string>
|
||||
<string name="feature_set_new_password_tip">Remember your password?</string>
|
||||
<string name="feature_set_new_password_action_tip">Log in</string>
|
||||
|
||||
<string name="feature_common_error_password_required_error">Password is Required</string>
|
||||
<string name="feature_common_error_password_short">Password must be at least 6 characters</string>
|
||||
<string name="feature_common_error_password_mismatch">Passwords do not match</string>
|
||||
|
||||
|
||||
<!--Recover Password-->
|
||||
<string name="feature_recover_now_title">Recover Now</string>
|
||||
<string name="feature_recover_now_message">Forgot your password, don’t worry!, we got you covered. Reset your password now!</string>
|
||||
|
||||
@ -15,6 +15,7 @@ import org.mifos.mobile.feature.auth.login.LoginViewModel
|
||||
import org.mifos.mobile.feature.auth.otpAuthentication.OtpAuthenticationViewModel
|
||||
import org.mifos.mobile.feature.auth.recoverPassword.RecoverPasswordViewModel
|
||||
import org.mifos.mobile.feature.auth.registration.RegistrationViewModel
|
||||
import org.mifos.mobile.feature.auth.setNewPassword.SetPasswordViewModel
|
||||
import org.mifos.mobile.feature.auth.uploadId.UploadIdViewModel
|
||||
|
||||
val AuthModule = module {
|
||||
@ -22,5 +23,6 @@ val AuthModule = module {
|
||||
viewModelOf(::RegistrationViewModel)
|
||||
viewModelOf(::UploadIdViewModel)
|
||||
viewModelOf(::OtpAuthenticationViewModel)
|
||||
viewModelOf(::SetPasswordViewModel)
|
||||
viewModelOf(::RecoverPasswordViewModel)
|
||||
}
|
||||
|
||||
@ -27,6 +27,8 @@ import org.mifos.mobile.feature.auth.recoverPassword.navigateToRecoverPasswordSc
|
||||
import org.mifos.mobile.feature.auth.recoverPassword.recoverPasswordDestination
|
||||
import org.mifos.mobile.feature.auth.registration.navigateToRegisterScreen
|
||||
import org.mifos.mobile.feature.auth.registration.registrationDestination
|
||||
import org.mifos.mobile.feature.auth.setNewPassword.navigateToSetPasswordScreen
|
||||
import org.mifos.mobile.feature.auth.setNewPassword.setPasswordDestination
|
||||
import org.mifos.mobile.feature.auth.status.navigateToStatusScreen
|
||||
import org.mifos.mobile.feature.auth.status.statusDestination
|
||||
import org.mifos.mobile.feature.auth.uploadId.navigateToUploadIdScreen
|
||||
@ -48,8 +50,7 @@ fun NavGraphBuilder.authenticationNavGraph(
|
||||
startDestination = LoginRoute,
|
||||
) {
|
||||
loginDestination(
|
||||
// navigateToRegisterScreen = navController::navigateToRegisterScreen,
|
||||
navigateToRegisterScreen = navController::navigateToOtpAuthScreen,
|
||||
navigateToRegisterScreen = navController::navigateToRegisterScreen,
|
||||
navigateToPasscodeScreen = navigateToPasscodeScreen,
|
||||
navigateToForgotPasswordScreen = navController::navigateToRecoverPasswordScreen,
|
||||
)
|
||||
@ -66,6 +67,7 @@ fun NavGraphBuilder.authenticationNavGraph(
|
||||
|
||||
otpAuthenticationDestination(
|
||||
navigateToStatusScreen = navController::navigateToStatusScreen,
|
||||
navigateToSetPasswordScreen = navController::navigateToSetPasswordScreen,
|
||||
)
|
||||
|
||||
statusDestination(
|
||||
@ -78,5 +80,10 @@ fun NavGraphBuilder.authenticationNavGraph(
|
||||
navigateToLoginScreen = navController::navigateToLoginScreen,
|
||||
navigateToOtpAuthenticationScreen = navController::navigateToOtpAuthScreen,
|
||||
)
|
||||
|
||||
setPasswordDestination(
|
||||
navigateToStatusScreen = navController::navigateToStatusScreen,
|
||||
navigateToLoginScreen = navController::navigateToLoginScreen,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -14,22 +14,33 @@ package org.mifos.mobile.feature.auth.otpAuthentication
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.NavGraphBuilder
|
||||
import androidx.navigation.NavOptions
|
||||
import kotlinx.serialization.ExperimentalSerializationApi
|
||||
import kotlinx.serialization.Serializable
|
||||
import org.mifos.mobile.core.ui.composableWithStayTransitions
|
||||
import org.mifos.mobile.feature.auth.status.StatusNavigationRoute
|
||||
|
||||
@OptIn(ExperimentalSerializationApi::class)
|
||||
@Serializable
|
||||
data object OtpAuthenticationRoute
|
||||
data class OtpAuthenticationRoute(
|
||||
val nextRoute: String = StatusNavigationRoute.serializer().descriptor.serialName,
|
||||
)
|
||||
|
||||
fun NavController.navigateToOtpAuthScreen(navOptions: NavOptions? = null) {
|
||||
this.navigate(route = OtpAuthenticationRoute, navOptions = navOptions)
|
||||
@OptIn(ExperimentalSerializationApi::class)
|
||||
fun NavController.navigateToOtpAuthScreen(
|
||||
nextRoute: String = StatusNavigationRoute.serializer().descriptor.serialName,
|
||||
navOptions: NavOptions? = null,
|
||||
) {
|
||||
this.navigate(OtpAuthenticationRoute(nextRoute), navOptions)
|
||||
}
|
||||
|
||||
fun NavGraphBuilder.otpAuthenticationDestination(
|
||||
navigateToStatusScreen: (EventType, String) -> Unit,
|
||||
navigateToSetPasswordScreen: () -> Unit,
|
||||
) {
|
||||
composableWithStayTransitions<OtpAuthenticationRoute> {
|
||||
OtpAuthenticationScreen(
|
||||
navigateToStatusScreen = navigateToStatusScreen,
|
||||
navigateToSetPasswordScreen = navigateToSetPasswordScreen,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -67,6 +67,7 @@ import org.mifos.mobile.core.ui.utils.EventsEffect
|
||||
@Composable
|
||||
internal fun OtpAuthenticationScreen(
|
||||
navigateToStatusScreen: (EventType, String) -> Unit,
|
||||
navigateToSetPasswordScreen: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: OtpAuthenticationViewModel = koinViewModel(),
|
||||
) {
|
||||
@ -80,6 +81,12 @@ internal fun OtpAuthenticationScreen(
|
||||
event.eventDestination,
|
||||
)
|
||||
}
|
||||
|
||||
is OtpAuthEvent.NavigateNext -> {
|
||||
if (uiState.nextRoute == "set_password") {
|
||||
navigateToSetPasswordScreen.invoke()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -9,7 +9,9 @@
|
||||
*/
|
||||
package org.mifos.mobile.feature.auth.otpAuthentication
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.navigation.toRoute
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.update
|
||||
@ -24,9 +26,22 @@ import org.jetbrains.compose.resources.StringResource
|
||||
import org.mifos.mobile.core.ui.utils.BaseViewModel
|
||||
import org.mifos.mobile.feature.auth.login.LoginRoute
|
||||
|
||||
internal class OtpAuthenticationViewModel : BaseViewModel<OtpAuthState, OtpAuthEvent, OtpAuthAction>(
|
||||
internal class OtpAuthenticationViewModel(
|
||||
savedStateHandle: SavedStateHandle,
|
||||
) : BaseViewModel<
|
||||
OtpAuthState,
|
||||
OtpAuthEvent,
|
||||
OtpAuthAction,
|
||||
>(
|
||||
initialState = OtpAuthState(dialogState = null),
|
||||
) {
|
||||
init {
|
||||
val nextRoute = savedStateHandle.toRoute<OtpAuthenticationRoute>()
|
||||
|
||||
mutableStateFlow.update {
|
||||
it.copy(nextRoute = nextRoute.nextRoute)
|
||||
}
|
||||
}
|
||||
|
||||
private var validationJob: Job? = null
|
||||
|
||||
@ -79,7 +94,24 @@ internal class OtpAuthenticationViewModel : BaseViewModel<OtpAuthState, OtpAuthE
|
||||
}
|
||||
|
||||
if (otpError == null) {
|
||||
registerUser()
|
||||
when {
|
||||
state.nextRoute == "set_password" -> handleRecoverPassword()
|
||||
else -> registerUser()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(InternalSerializationApi::class, ExperimentalSerializationApi::class)
|
||||
private fun handleRecoverPassword() {
|
||||
viewModelScope.launch {
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
dialogState = OtpAuthState.DialogState.Loading,
|
||||
)
|
||||
}
|
||||
delay(3000)
|
||||
dismissDialog()
|
||||
sendEvent(OtpAuthEvent.NavigateNext)
|
||||
}
|
||||
}
|
||||
|
||||
@ -124,6 +156,9 @@ internal class OtpAuthenticationViewModel : BaseViewModel<OtpAuthState, OtpAuthE
|
||||
}
|
||||
|
||||
internal data class OtpAuthState(
|
||||
|
||||
val nextRoute: String = "",
|
||||
|
||||
val otp: String = "",
|
||||
val otpError: StringResource? = null,
|
||||
|
||||
@ -151,6 +186,8 @@ internal sealed interface OtpAuthAction {
|
||||
}
|
||||
|
||||
internal sealed interface OtpAuthEvent {
|
||||
data object NavigateNext : OtpAuthEvent
|
||||
|
||||
data class NavigateToStatus(
|
||||
val eventType: EventType,
|
||||
val eventDestination: String,
|
||||
|
||||
@ -0,0 +1,40 @@
|
||||
/*
|
||||
* Copyright 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*
|
||||
* See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md
|
||||
*/
|
||||
@file:Suppress("MatchingDeclarationName")
|
||||
|
||||
package org.mifos.mobile.feature.auth.setNewPassword
|
||||
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.NavGraphBuilder
|
||||
import androidx.navigation.NavOptions
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import org.mifos.mobile.core.ui.composableWithSlideTransitions
|
||||
import org.mifos.mobile.feature.auth.otpAuthentication.EventType
|
||||
|
||||
@Serializable
|
||||
@SerialName("set_password")
|
||||
data object SetPasswordRoute
|
||||
|
||||
fun NavController.navigateToSetPasswordScreen(navOptions: NavOptions? = null) {
|
||||
this.navigate(SetPasswordRoute, navOptions)
|
||||
}
|
||||
|
||||
fun NavGraphBuilder.setPasswordDestination(
|
||||
navigateToStatusScreen: (EventType, String) -> Unit,
|
||||
navigateToLoginScreen: () -> Unit,
|
||||
) {
|
||||
composableWithSlideTransitions<SetPasswordRoute> {
|
||||
SetPasswordScreen(
|
||||
navigateToStatusScreen = navigateToStatusScreen,
|
||||
navigateToLoginScreen = navigateToLoginScreen,
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,290 @@
|
||||
/*
|
||||
* Copyright 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*
|
||||
* See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md
|
||||
*/
|
||||
package org.mifos.mobile.feature.auth.setNewPassword
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
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.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import mifos_mobile.feature.auth.generated.resources.Res
|
||||
import mifos_mobile.feature.auth.generated.resources.feature_set_new_password_action_tip
|
||||
import mifos_mobile.feature.auth.generated.resources.feature_set_new_password_confirm_password_label
|
||||
import mifos_mobile.feature.auth.generated.resources.feature_set_new_password_message
|
||||
import mifos_mobile.feature.auth.generated.resources.feature_set_new_password_new_password_label
|
||||
import mifos_mobile.feature.auth.generated.resources.feature_set_new_password_submit
|
||||
import mifos_mobile.feature.auth.generated.resources.feature_set_new_password_tip
|
||||
import mifos_mobile.feature.auth.generated.resources.feature_set_new_password_title
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.jetbrains.compose.ui.tooling.preview.Preview
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
import org.mifos.mobile.core.designsystem.component.BasicDialogState
|
||||
import org.mifos.mobile.core.designsystem.component.LoadingDialogState
|
||||
import org.mifos.mobile.core.designsystem.component.MifosBasicDialog
|
||||
import org.mifos.mobile.core.designsystem.component.MifosButton
|
||||
import org.mifos.mobile.core.designsystem.component.MifosLoadingDialog
|
||||
import org.mifos.mobile.core.designsystem.component.MifosPasswordField
|
||||
import org.mifos.mobile.core.designsystem.component.MifosScaffold
|
||||
import org.mifos.mobile.core.designsystem.theme.DesignToken
|
||||
import org.mifos.mobile.core.designsystem.theme.MifosMobileTheme
|
||||
import org.mifos.mobile.core.designsystem.theme.MifosTypography
|
||||
import org.mifos.mobile.core.ui.CombinedPasswordErrorCard
|
||||
import org.mifos.mobile.core.ui.PasswordStrengthIndicator
|
||||
import org.mifos.mobile.core.ui.component.MifosPoweredCard
|
||||
import org.mifos.mobile.core.ui.utils.EventsEffect
|
||||
import org.mifos.mobile.feature.auth.otpAuthentication.EventType
|
||||
|
||||
@Composable
|
||||
internal fun SetPasswordScreen(
|
||||
navigateToStatusScreen: (EventType, String) -> Unit,
|
||||
navigateToLoginScreen: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: SetPasswordViewModel = koinViewModel(),
|
||||
) {
|
||||
val uiState by viewModel.stateFlow.collectAsStateWithLifecycle()
|
||||
|
||||
EventsEffect(viewModel.eventFlow) { event ->
|
||||
when (event) {
|
||||
is SetPasswordEvent.NavigateToLogin -> navigateToLoginScreen.invoke()
|
||||
|
||||
is SetPasswordEvent.NavigateToStatus -> navigateToStatusScreen(
|
||||
event.eventType,
|
||||
event.eventDestination,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
SetPasswordDialogs(
|
||||
dialogState = uiState.dialogState,
|
||||
onDismissRequest = remember(viewModel) {
|
||||
{ viewModel.trySendAction(SetPasswordAction.OnDismissDialog) }
|
||||
},
|
||||
)
|
||||
|
||||
SetPasswordScreen(
|
||||
modifier = modifier,
|
||||
state = uiState,
|
||||
onAction = remember(viewModel) {
|
||||
{ viewModel.trySendAction(it) }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SetPasswordDialogs(
|
||||
dialogState: SetPasswordState.DialogState?,
|
||||
onDismissRequest: () -> Unit,
|
||||
) {
|
||||
when (dialogState) {
|
||||
is SetPasswordState.DialogState.Error -> MifosBasicDialog(
|
||||
visibilityState = BasicDialogState.Shown(
|
||||
message = dialogState.message,
|
||||
),
|
||||
onDismissRequest = onDismissRequest,
|
||||
)
|
||||
|
||||
is SetPasswordState.DialogState.Loading -> MifosLoadingDialog(
|
||||
visibilityState = LoadingDialogState.Shown,
|
||||
)
|
||||
|
||||
null -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun SetPasswordScreen(
|
||||
state: SetPasswordState,
|
||||
modifier: Modifier = Modifier,
|
||||
onAction: (SetPasswordAction) -> Unit,
|
||||
) {
|
||||
MifosScaffold(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
bottomBar = {
|
||||
Surface {
|
||||
MifosPoweredCard(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
},
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(top = DesignToken.padding.large)
|
||||
.padding(DesignToken.padding.large)
|
||||
.statusBarsPadding(),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(Res.string.feature_set_new_password_title),
|
||||
style = MifosTypography.headlineMedium,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
Text(
|
||||
text = stringResource(Res.string.feature_set_new_password_message),
|
||||
style = MifosTypography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.secondary,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
SetPasswordInputBox(
|
||||
state = state,
|
||||
onAction = onAction,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun SetPasswordInputBox(
|
||||
state: SetPasswordState,
|
||||
modifier: Modifier = Modifier,
|
||||
onAction: (SetPasswordAction) -> Unit,
|
||||
) {
|
||||
val hasError = state.passwordError != null || state.passwordFeedback.isNotEmpty()
|
||||
|
||||
Column(
|
||||
modifier = modifier,
|
||||
verticalArrangement = Arrangement.spacedBy(DesignToken.spacing.largeIncreased),
|
||||
) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(DesignToken.spacing.extraSmall),
|
||||
) {
|
||||
MifosPasswordField(
|
||||
label = stringResource(Res.string.feature_set_new_password_new_password_label),
|
||||
value = state.password,
|
||||
onValueChange = { onAction(SetPasswordAction.OnPasswordChange(it)) },
|
||||
shape = DesignToken.shapes.medium,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
showPassword = state.isPasswordVisible,
|
||||
showPasswordChange = {
|
||||
onAction(SetPasswordAction.OnTogglePassword)
|
||||
},
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = MaterialTheme.colorScheme.secondaryContainer,
|
||||
unfocusedBorderColor = MaterialTheme.colorScheme.secondaryContainer,
|
||||
errorBorderColor = MaterialTheme.colorScheme.error,
|
||||
),
|
||||
isError = state.passwordError != null,
|
||||
hint = state.passwordError?.let { stringResource(it) },
|
||||
)
|
||||
|
||||
if (state.password.isNotEmpty() && !hasError) {
|
||||
PasswordStrengthIndicator(
|
||||
state = state.passwordStrengthState,
|
||||
currentCharacterCount = state.password.length,
|
||||
minimumCharacterCount = 8,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
|
||||
// Show combined error card with integrated strength indicator when there are errors
|
||||
|
||||
if (hasError && state.password.isNotEmpty()) {
|
||||
CombinedPasswordErrorCard(
|
||||
passwordStrengthState = state.passwordStrengthState,
|
||||
currentCharacterCount = state.password.length,
|
||||
errorText = state.passwordError,
|
||||
errors = state.passwordFeedback,
|
||||
minimumCharacterCount = 8,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
MifosPasswordField(
|
||||
label = stringResource(Res.string.feature_set_new_password_confirm_password_label),
|
||||
value = state.confirmPassword,
|
||||
onValueChange = { onAction(SetPasswordAction.OnConfirmPasswordChange(it)) },
|
||||
shape = DesignToken.shapes.medium,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
showPassword = state.isConfirmPasswordVisible,
|
||||
showPasswordChange = {
|
||||
onAction(SetPasswordAction.OnToggleConfirmPassword)
|
||||
},
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = MaterialTheme.colorScheme.secondaryContainer,
|
||||
unfocusedBorderColor = MaterialTheme.colorScheme.secondaryContainer,
|
||||
errorBorderColor = MaterialTheme.colorScheme.error,
|
||||
),
|
||||
isError = state.confirmPasswordError != null,
|
||||
hint = state.confirmPasswordError?.let { stringResource(it) },
|
||||
keyboardType = KeyboardType.Password,
|
||||
)
|
||||
|
||||
MifosButton(
|
||||
modifier = Modifier.fillMaxWidth().height(DesignToken.sizes.buttonHeight),
|
||||
enabled = state.isSubmitButtonEnabled,
|
||||
onClick = {
|
||||
onAction(SetPasswordAction.OnSubmit)
|
||||
},
|
||||
shape = DesignToken.shapes.medium,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(Res.string.feature_set_new_password_submit),
|
||||
style = MifosTypography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(Res.string.feature_set_new_password_tip),
|
||||
style = MifosTypography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.secondary,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(DesignToken.spacing.small))
|
||||
|
||||
Text(
|
||||
modifier = Modifier.clickable { onAction.invoke(SetPasswordAction.OnLogIn) },
|
||||
text = stringResource(Res.string.feature_set_new_password_action_tip),
|
||||
style = MifosTypography.labelMediumEmphasized,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun Set_Password_Preview() {
|
||||
MifosMobileTheme {
|
||||
SetPasswordScreen(
|
||||
state = SetPasswordState(dialogState = null),
|
||||
onAction = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,267 @@
|
||||
/*
|
||||
* Copyright 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*
|
||||
* See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md
|
||||
*/
|
||||
package org.mifos.mobile.feature.auth.setNewPassword
|
||||
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.ExperimentalSerializationApi
|
||||
import kotlinx.serialization.InternalSerializationApi
|
||||
import kotlinx.serialization.serializer
|
||||
import mifos_mobile.feature.auth.generated.resources.Res
|
||||
import mifos_mobile.feature.auth.generated.resources.feature_signup_error_password_mismatch
|
||||
import mifos_mobile.feature.auth.generated.resources.feature_signup_error_password_required_error
|
||||
import org.jetbrains.compose.resources.StringResource
|
||||
import org.mifos.mobile.core.ui.PasswordStrengthState
|
||||
import org.mifos.mobile.core.ui.utils.BaseViewModel
|
||||
import org.mifos.mobile.core.ui.utils.PasswordChecker
|
||||
import org.mifos.mobile.core.ui.utils.PasswordStrength
|
||||
import org.mifos.mobile.core.ui.utils.PasswordStrengthResult
|
||||
import org.mifos.mobile.feature.auth.login.LoginRoute
|
||||
import org.mifos.mobile.feature.auth.otpAuthentication.EventType
|
||||
|
||||
internal class SetPasswordViewModel : BaseViewModel<SetPasswordState, SetPasswordEvent, SetPasswordAction>(
|
||||
initialState = SetPasswordState(dialogState = null),
|
||||
) {
|
||||
|
||||
private var validationJob: Job? = null
|
||||
private var passwordStrengthJob: Job = Job()
|
||||
|
||||
override fun handleAction(action: SetPasswordAction) {
|
||||
when (action) {
|
||||
is SetPasswordAction.OnConfirmPasswordChange -> handleConfirmPasswordChange(action.confirmPassword)
|
||||
|
||||
is SetPasswordAction.OnLogIn -> sendEvent(SetPasswordEvent.NavigateToLogin)
|
||||
|
||||
is SetPasswordAction.OnPasswordChange -> handlePasswordChange(action.password)
|
||||
|
||||
is SetPasswordAction.OnSubmit -> handleSubmit()
|
||||
|
||||
is SetPasswordAction.OnToggleConfirmPassword -> toggleConfirmPasswordVisibility()
|
||||
|
||||
is SetPasswordAction.OnTogglePassword -> togglePasswordVisibility()
|
||||
|
||||
is SetPasswordAction.Internal.ReceivePasswordStrengthResult -> handlePasswordStrengthResult(
|
||||
action,
|
||||
)
|
||||
|
||||
SetPasswordAction.OnDismissDialog -> dismissDialog()
|
||||
}
|
||||
}
|
||||
|
||||
private fun handlePasswordChange(password: String) {
|
||||
mutableStateFlow.update { it.copy(password = password, passwordError = null) }
|
||||
|
||||
passwordStrengthJob.cancel()
|
||||
|
||||
if (password.isEmpty()) {
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
passwordStrengthState = PasswordStrengthState.NONE,
|
||||
passwordFeedback = emptyList(),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
passwordStrengthJob = viewModelScope.launch {
|
||||
val result = PasswordChecker.getPasswordStrengthResult(password)
|
||||
val feedback = PasswordChecker.getPasswordFeedback(password)
|
||||
trySendAction(SetPasswordAction.Internal.ReceivePasswordStrengthResult(result))
|
||||
|
||||
mutableStateFlow.update {
|
||||
it.copy(passwordFeedback = feedback)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
validationJob?.cancel()
|
||||
validationJob = viewModelScope.launch {
|
||||
delay(300)
|
||||
|
||||
val newResult = validatePassword(password)
|
||||
val confirmResult = if (state.confirmPassword.isNotEmpty()) {
|
||||
validateConfirmPassword(state.confirmPassword, password)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
passwordError = newResult,
|
||||
confirmPasswordError = confirmResult,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("ReturnCount")
|
||||
private fun validatePassword(password: String): StringResource? {
|
||||
if (password.isEmpty()) {
|
||||
return Res.string.feature_signup_error_password_required_error
|
||||
}
|
||||
|
||||
return when (val result = PasswordChecker.getPasswordStrengthResult(password)) {
|
||||
is PasswordStrengthResult.Error -> {
|
||||
result.message
|
||||
}
|
||||
|
||||
is PasswordStrengthResult.Success -> {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleConfirmPasswordChange(confirmPassword: String) {
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
confirmPassword = confirmPassword,
|
||||
confirmPasswordError = null,
|
||||
)
|
||||
}
|
||||
|
||||
debounceValidation {
|
||||
val result = validateConfirmPassword(confirmPassword, state.password)
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
confirmPasswordError = result,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun validateConfirmPassword(confirmPassword: String, password: String): StringResource? =
|
||||
when {
|
||||
confirmPassword.isEmpty() -> Res.string.feature_signup_error_password_required_error
|
||||
password != confirmPassword -> Res.string.feature_signup_error_password_mismatch
|
||||
else -> null
|
||||
}
|
||||
|
||||
private fun handlePasswordStrengthResult(
|
||||
action: SetPasswordAction.Internal
|
||||
.ReceivePasswordStrengthResult,
|
||||
) {
|
||||
when (val result = action.result) {
|
||||
is PasswordStrengthResult.Success -> {
|
||||
val updatedState = when (result.passwordStrength) {
|
||||
PasswordStrength.LEVEL_0 -> PasswordStrengthState.WEAK_1
|
||||
PasswordStrength.LEVEL_1 -> PasswordStrengthState.WEAK_2
|
||||
PasswordStrength.LEVEL_2 -> PasswordStrengthState.WEAK_3
|
||||
PasswordStrength.LEVEL_3 -> PasswordStrengthState.GOOD
|
||||
PasswordStrength.LEVEL_4 -> PasswordStrengthState.STRONG
|
||||
PasswordStrength.LEVEL_5 -> PasswordStrengthState.VERY_STRONG
|
||||
}
|
||||
mutableStateFlow.update { oldState ->
|
||||
oldState.copy(passwordStrengthState = updatedState)
|
||||
}
|
||||
}
|
||||
|
||||
is PasswordStrengthResult.Error -> {
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
passwordError = result.message,
|
||||
passwordStrengthState = PasswordStrengthState.NONE,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(InternalSerializationApi::class, ExperimentalSerializationApi::class)
|
||||
private fun handleSubmit() {
|
||||
viewModelScope.launch {
|
||||
mutableStateFlow.update { it.copy(dialogState = SetPasswordState.DialogState.Loading) }
|
||||
delay(3000)
|
||||
dismissDialog()
|
||||
sendEvent(
|
||||
SetPasswordEvent.NavigateToStatus(
|
||||
EventType.SUCCESS,
|
||||
LoginRoute::class.serializer().descriptor.serialName,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun togglePasswordVisibility() {
|
||||
mutableStateFlow.update { it.copy(isPasswordVisible = !state.isPasswordVisible) }
|
||||
}
|
||||
|
||||
private fun toggleConfirmPasswordVisibility() {
|
||||
mutableStateFlow.update { it.copy(isConfirmPasswordVisible = !state.isConfirmPasswordVisible) }
|
||||
}
|
||||
|
||||
private fun debounceValidation(validation: suspend () -> Unit) {
|
||||
validationJob?.cancel()
|
||||
validationJob = viewModelScope.launch {
|
||||
delay(300)
|
||||
validation()
|
||||
}
|
||||
}
|
||||
|
||||
private fun dismissDialog() {
|
||||
mutableStateFlow.update {
|
||||
it.copy(dialogState = null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal data class SetPasswordState(
|
||||
val password: String = "",
|
||||
val passwordError: StringResource? = null,
|
||||
|
||||
val confirmPassword: String = "",
|
||||
val confirmPasswordError: StringResource? = null,
|
||||
|
||||
val isPasswordVisible: Boolean = false,
|
||||
val isConfirmPasswordVisible: Boolean = false,
|
||||
|
||||
val passwordFeedback: List<StringResource> = emptyList(),
|
||||
val passwordStrengthState: PasswordStrengthState = PasswordStrengthState.NONE,
|
||||
|
||||
val dialogState: DialogState?,
|
||||
) {
|
||||
sealed interface DialogState {
|
||||
data class Error(val message: String) : DialogState
|
||||
|
||||
data object Loading : DialogState
|
||||
}
|
||||
|
||||
val isSubmitButtonEnabled: Boolean
|
||||
get() = password.isNotBlank() && password.isNotBlank()
|
||||
}
|
||||
|
||||
internal sealed interface SetPasswordEvent {
|
||||
|
||||
data object NavigateToLogin : SetPasswordEvent
|
||||
|
||||
data class NavigateToStatus(
|
||||
val eventType: EventType,
|
||||
val eventDestination: String,
|
||||
) : SetPasswordEvent
|
||||
}
|
||||
|
||||
internal sealed interface SetPasswordAction {
|
||||
data class OnPasswordChange(val password: String) : SetPasswordAction
|
||||
data class OnConfirmPasswordChange(val confirmPassword: String) : SetPasswordAction
|
||||
|
||||
data object OnTogglePassword : SetPasswordAction
|
||||
data object OnToggleConfirmPassword : SetPasswordAction
|
||||
|
||||
data object OnSubmit : SetPasswordAction
|
||||
data object OnLogIn : SetPasswordAction
|
||||
|
||||
data object OnDismissDialog : SetPasswordAction
|
||||
|
||||
sealed class Internal : SetPasswordAction {
|
||||
data class ReceivePasswordStrengthResult(
|
||||
val result: PasswordStrengthResult,
|
||||
) : Internal()
|
||||
}
|
||||
}
|
||||
@ -17,7 +17,6 @@ import androidx.navigation.toRoute
|
||||
import kotlinx.serialization.Serializable
|
||||
import org.mifos.mobile.core.ui.composableWithStayTransitions
|
||||
import org.mifos.mobile.feature.auth.otpAuthentication.EventType
|
||||
import org.mifos.mobile.feature.auth.otpAuthentication.OtpAuthenticationRoute
|
||||
|
||||
@Serializable
|
||||
data class StatusNavigationRoute(
|
||||
@ -43,12 +42,7 @@ fun NavController.navigateToStatusScreen(
|
||||
title = title,
|
||||
subtitle = subtitle,
|
||||
),
|
||||
) {
|
||||
popUpTo(OtpAuthenticationRoute) {
|
||||
inclusive = true
|
||||
}
|
||||
launchSingleTop = true
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
fun NavGraphBuilder.statusDestination(
|
||||
|
||||
Loading…
Reference in New Issue
Block a user