diff --git a/app/src/main/java/team/aliens/dms/android/app/DmsApp.kt b/app/src/main/java/team/aliens/dms/android/app/DmsApp.kt index d90ac627e..c30fbe061 100644 --- a/app/src/main/java/team/aliens/dms/android/app/DmsApp.kt +++ b/app/src/main/java/team/aliens/dms/android/app/DmsApp.kt @@ -31,6 +31,8 @@ import team.aliens.dms.android.app.navigation.unauthorized.UnauthorizedNavGraph import team.aliens.dms.android.feature.destinations.TermsScreenDestination import team.aliens.dms.android.feature.editpassword.EditPasswordViewModel import team.aliens.dms.android.feature.editpassword.navigation.EditPasswordNavGraph +import team.aliens.dms.android.feature.resetpassword.ResetPasswordViewModel +import team.aliens.dms.android.feature.resetpassword.navigation.ResetPasswordNavGraph import team.aliens.dms.android.feature.signup.SignUpViewModel import team.aliens.dms.android.feature.signup.TermsUrl import team.aliens.dms.android.feature.signup.navigation.SignUpNavGraph @@ -70,6 +72,13 @@ fun DmsApp( } hiltViewModel(parentEntry) } + + dependency(ResetPasswordNavGraph) { + val parentEntry = remember(navBackStackEntry) { + navController.getBackStackEntry(ResetPasswordNavGraph.route) + } + hiltViewModel(parentEntry) + } }, ) } diff --git a/app/src/main/java/team/aliens/dms/android/app/navigation/DmsNavigator.kt b/app/src/main/java/team/aliens/dms/android/app/navigation/DmsNavigator.kt index 31ab6655f..64029a1bd 100644 --- a/app/src/main/java/team/aliens/dms/android/app/navigation/DmsNavigator.kt +++ b/app/src/main/java/team/aliens/dms/android/app/navigation/DmsNavigator.kt @@ -104,11 +104,11 @@ class DmsNavigator( } override fun openResetPasswordEnterEmailVerificationCode() { - navController.navigateSingleTop(ResetPasswordEnterEmailVerificationCodeScreenDestination within navGraph) + navController.navigateSingleTop(ResetPasswordEnterEmailVerificationCodeScreenDestination within ResetPasswordNavGraph) } override fun openResetPasswordSetPassword() { - navController.navigateSingleTop(ResetPasswordSetPasswordScreenDestination within navGraph) + navController.navigateSingleTop(ResetPasswordSetPasswordScreenDestination within ResetPasswordNavGraph) } override fun openAuthorizedNav() { diff --git a/data/src/main/java/team/aliens/dms/android/data/auth/repository/AuthRepositoryImpl.kt b/data/src/main/java/team/aliens/dms/android/data/auth/repository/AuthRepositoryImpl.kt index cd8d3d6aa..f040e1e3e 100644 --- a/data/src/main/java/team/aliens/dms/android/data/auth/repository/AuthRepositoryImpl.kt +++ b/data/src/main/java/team/aliens/dms/android/data/auth/repository/AuthRepositoryImpl.kt @@ -72,9 +72,10 @@ internal class AuthRepositoryImpl @Inject constructor( ) } - override suspend fun checkIdExists(accountId: String): HashedEmail { - TODO("Not yet implemented") - } + override suspend fun checkIdExists(accountId: String): HashedEmail = + networkAuthDataSource.checkIdExists( + accountId = accountId, + ).email override suspend fun signOut() { jwtProvider.clearCaches() diff --git a/data/src/main/java/team/aliens/dms/android/data/student/repository/StudentRepositoryImpl.kt b/data/src/main/java/team/aliens/dms/android/data/student/repository/StudentRepositoryImpl.kt index 13321c844..a32338de3 100644 --- a/data/src/main/java/team/aliens/dms/android/data/student/repository/StudentRepositoryImpl.kt +++ b/data/src/main/java/team/aliens/dms/android/data/student/repository/StudentRepositoryImpl.kt @@ -6,7 +6,9 @@ import team.aliens.dms.android.data.student.mapper.toModel import team.aliens.dms.android.data.student.model.HashedEmail import team.aliens.dms.android.data.student.model.MyPage import team.aliens.dms.android.data.student.model.StudentName +import team.aliens.dms.android.network.auth.datasource.NetworkAuthDataSource import team.aliens.dms.android.network.student.datasource.NetworkStudentDataSource +import team.aliens.dms.android.network.student.model.ResetPasswordRequest import team.aliens.dms.android.network.student.model.SignUpRequest import team.aliens.dms.android.network.student.model.SignUpResponse import team.aliens.dms.android.network.student.model.extractFeatures @@ -82,7 +84,15 @@ internal class StudentRepositoryImpl @Inject constructor( emailVerificationCode: String, newPassword: String, ) { - TODO("Not yet implemented") + networkStudentDataSource.resetPassword( + ResetPasswordRequest( + accountId = accountId, + studentName = studentName, + email = email, + emailVerificationCode = emailVerificationCode, + newPassword = newPassword, + ), + ) } override suspend fun checkIdDuplication(id: String) { diff --git a/feature/src/main/java/team/aliens/dms/android/feature/resetpassword/1_AccountVerificationScreen.kt b/feature/src/main/java/team/aliens/dms/android/feature/resetpassword/1_AccountVerificationScreen.kt index 7a27c801b..17b83340d 100644 --- a/feature/src/main/java/team/aliens/dms/android/feature/resetpassword/1_AccountVerificationScreen.kt +++ b/feature/src/main/java/team/aliens/dms/android/feature/resetpassword/1_AccountVerificationScreen.kt @@ -1,228 +1,236 @@ package team.aliens.dms.android.feature.resetpassword +import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.ramcosta.composedestinations.annotation.Destination +import kotlinx.coroutines.delay +import team.aliens.dms.android.core.designsystem.ContainedButton +import team.aliens.dms.android.core.designsystem.DmsScaffold +import team.aliens.dms.android.core.designsystem.DmsTheme +import team.aliens.dms.android.core.designsystem.DmsTopAppBar +import team.aliens.dms.android.core.designsystem.LocalToast +import team.aliens.dms.android.core.designsystem.ShadowDefaults +import team.aliens.dms.android.core.designsystem.TextField +import team.aliens.dms.android.core.ui.Banner +import team.aliens.dms.android.core.ui.BannerDefaults +import team.aliens.dms.android.core.ui.DefaultVerticalSpace +import team.aliens.dms.android.core.ui.bottomPadding +import team.aliens.dms.android.core.ui.collectInLaunchedEffectWithLifecycle +import team.aliens.dms.android.core.ui.horizontalPadding +import team.aliens.dms.android.core.ui.startPadding +import team.aliens.dms.android.core.ui.topPadding +import team.aliens.dms.android.core.ui.verticalPadding +import team.aliens.dms.android.feature.R import team.aliens.dms.android.feature.resetpassword.navigation.ResetPasswordNavigator // TODO Pop backstack +@OptIn(ExperimentalMaterial3Api::class) @Destination @Composable fun AccountVerificationScreen( modifier: Modifier = Modifier, navigator: ResetPasswordNavigator, - // changePasswordViewModel: ChangePasswordViewModel = hiltViewModel(), // fixme - // resetPasswordVerificationViewModel: ResetPasswordVerificationViewModel = hiltViewModel(), // fixme -) {/* - - val focusManager = LocalFocusManager.current - + viewModel: ResetPasswordViewModel, +) { + val uiState by viewModel.stateFlow.collectAsStateWithLifecycle() + val toast = LocalToast.current val context = LocalContext.current + val (idChecked, onChangeIdChecked) = rememberSaveable { mutableStateOf(false) } - val toast = rememberToast() - - val pattern = Patterns.EMAIL_ADDRESS - - var id by remember { mutableStateOf("") } - var name by remember { mutableStateOf("") } - var userEmail by remember { mutableStateOf("") } - - var isIdError by remember { mutableStateOf(false) } - var isNameError by remember { mutableStateOf(false) } - var isEmailError by remember { mutableStateOf(false) } - - val onIdChange = { userId: String -> - if (userId.length != id.length) isIdError = false - id = userId - } - - val onNameChange = { userName: String -> - if (userName.length != name.length) isNameError = false - name = userName - } - - val onEmailChange = { value: String -> - if (value.length != userEmail.length) isEmailError = false - userEmail = value + LaunchedEffect(uiState.accountId) { + if (uiState.accountId.isNotEmpty()) { + delay(300L) + viewModel.postIntent(ResetPasswordIntent.CheckAccountId) + } } - var emailResponse by remember { mutableStateOf("") } + var isAccountIdError by rememberSaveable(uiState.accountId) { mutableStateOf(false) } - LaunchedEffect(Unit) { - changePasswordViewModel.editPasswordEffect.collect { - when (it) { - is ChangePasswordViewModel.Event.CheckIdSuccess -> { - emailResponse = it.email - } - - is ChangePasswordViewModel.Event.NotFoundException -> { - isIdError = true - } - - else -> { - toast( - getStringFromEvent( - context = context, - event = it, - ) - ) - } + viewModel.sideEffectFlow.collectInLaunchedEffectWithLifecycle { sideEffect -> + when (sideEffect) { + ResetPasswordSideEffect.AccountIdExists -> { + onChangeIdChecked(true) } - } - } - LaunchedEffect(Unit) { - resetPasswordVerificationViewModel.registerEmailEvent.collect { - when (it) { - is ResetPasswordVerificationEvent.SendEmailSuccess -> navigator::openResetPasswordEnterEmailVerificationCode - is ResetPasswordVerificationEvent.TooManyRequestsException -> { - toast(context.getString(R.string.Retry)) - } + ResetPasswordSideEffect.AccountIdNotExists -> toast.showErrorToast( + message = context.getString(R.string.reset_password_account_verification_account_id_does_not_exist), + ) - else -> toast( - getStringFromEmailEvent( - context = context, - event = it, - ) - ) + ResetPasswordSideEffect.SendEmailVerificationCodeSuccess -> navigator.openResetPasswordEnterEmailVerificationCode() + + else -> {/* explicit blank */ } } } - Column( - modifier = modifier - .fillMaxSize() - .background( - DormTheme.colors.surface, - ) - .fillMaxHeight(0.8f) - .padding( - top = 108.dp, - start = 16.dp, - end = 16.dp, + DmsScaffold( + modifier = modifier, + topBar = { + DmsTopAppBar( + title = {}, + navigationIcon = { + IconButton( + onClick = { navigator.openSignIn() }, + ) { + Icon( + painter = painterResource(id = R.drawable.ic_baseline_arrow_back_24), + contentDescription = stringResource(id = R.string.top_bar_back_button), + ) + } + }, ) - .dormClickable( - rippleEnabled = false, - ) { - focusManager.clearFocus() - } - ) { - AppLogo( - darkIcon = isSystemInDarkTheme(), - ) - Space(space = 8.dp) - Body2( - text = stringResource(id = R.string.Identification), - ) + }, + ) { padValues -> Column( modifier = Modifier - .padding(top = 60.dp) + .fillMaxSize() + .padding(padValues) + .imePadding(), + verticalArrangement = Arrangement.spacedBy(DefaultVerticalSpace), ) { - Box( - modifier = Modifier.height(68.dp), - ) { - DormTextField( - value = id, - onValueChange = onIdChange, - hint = stringResource(id = R.string.EnterId), - error = isIdError, - errorDescription = stringResource(id = R.string.NotExistId), - keyboardActions = KeyboardActions { - if (id.isNotBlank()) { - changePasswordViewModel.checkId( - accountId = id, - ) - focusManager.clearFocus() - } - }, - imeAction = ImeAction.Done, - ) - } + Banner( + modifier = Modifier + .fillMaxWidth() + .topPadding(BannerDefaults.DefaultTopSpace) + .startPadding(), + message = { + BannerDefaults.DefaultText(text = stringResource(id = R.string.reset_password_account_verification_identification_verification)) + }, + ) + Spacer(modifier = Modifier.weight(1f)) + TextField( + modifier = Modifier + .fillMaxWidth() + .horizontalPadding(), + value = uiState.accountId, + hint = { + Text(text = stringResource(id = R.string.reset_password_account_verification_enter_account_id)) + }, + onValueChange = { viewModel.postIntent(ResetPasswordIntent.UpdateAccountId(value = it)) }, + supportingText = if (isAccountIdError) { + { Text(text = stringResource(id = R.string.reset_password_account_verification_enter_account_id_invalid_format)) } + } else { + null + }, + isError = isAccountIdError, + ) AnimatedVisibility( - visible = emailResponse.isNotBlank() + modifier = Modifier.fillMaxWidth(), + visible = idChecked, ) { Column( - modifier = Modifier + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(DefaultVerticalSpace), ) { - Column( + AccountAssertionCard( modifier = Modifier - .fillMaxWidth() - .background( - DormTheme.colors.background, - ) - .padding( - horizontal = 16.dp, - vertical = 12.dp, - ) - ) { - Body2( - text = stringResource(id = R.string.MatchEmailToId), - ) - Body2( - modifier = Modifier.padding( - top = 8.dp, - ), - text = emailResponse, - color = DormTheme.colors.primary, - ) - } - Box( + .horizontalPadding() + .verticalPadding() + .fillMaxWidth(), + hashedEmail = uiState.hashedEmail, + ) + TextField( modifier = Modifier - .height(68.dp) - .padding(top = 24.dp) - ) { - DormTextField( - value = name, - onValueChange = onNameChange, - hint = stringResource(id = R.string.EnterName), - imeAction = ImeAction.Next, - ) - } - Space(space = 32.dp) - Box( + .fillMaxWidth() + .horizontalPadding(), + value = uiState.studentName, + hint = { + Text(text = stringResource(id = R.string.reset_password_account_verification_enter_student_name)) + }, + onValueChange = { + viewModel.postIntent(ResetPasswordIntent.UpdateStudentName(value = it)) + }, + ) + TextField( modifier = Modifier - .height(68.dp) - ) { - DormTextField( - value = userEmail, - onValueChange = onEmailChange, - hint = stringResource(id = R.string.EnterEmailAddress), - error = isEmailError, - keyboardType = KeyboardType.Email, - errorDescription = context.getString(R.string.NotValidEmailFormat), - keyboardActions = KeyboardActions { - focusManager.clearFocus() - }, - imeAction = ImeAction.Done, - ) - } + .fillMaxWidth() + .horizontalPadding(), + value = uiState.email, + hint = { + Text(text = stringResource(id = R.string.reset_password_account_verification_enter_email)) + }, + onValueChange = { + viewModel.postIntent(ResetPasswordIntent.UpdateEmail(value = it)) + }, + ) } - } - - RatioSpace(height = if (emailResponse.isEmpty()) 0.05f else 0.622f) - DormContainedLargeButton( - text = stringResource(id = R.string.Next), - color = DormButtonColor.Blue, - enabled = if (emailResponse.isEmpty()) id.isNotBlank() && !isIdError - else (id.isNotBlank() && name.isNotBlank() && userEmail.isNotBlank()) - ) { - if (id.isNotBlank() && name.isNotBlank() && userEmail.isNotBlank()) { - if (pattern.matcher(userEmail).find()) { - resetPasswordVerificationViewModel.requestEmailCode( - email = userEmail.trim(), - type = EmailVerificationType.PASSWORD, + } + Spacer(modifier = Modifier.weight(3f)) + ContainedButton( + modifier = Modifier + .fillMaxWidth() + .horizontalPadding() + .bottomPadding(), + onClick = { + viewModel.postIntent( + ResetPasswordIntent.SendEmailVerificationCode( + uiState.email ) - } else { - isEmailError = true - } - } else { - changePasswordViewModel.checkId( - accountId = id.trim(), ) - focusManager.clearFocus() - } + }, + enabled = uiState.accountId.isNotEmpty() && uiState.studentName.isNotEmpty() && uiState.email.isNotEmpty(), + ) { + Text(text = stringResource(id = R.string.next)) } } - }*/ + } + BackHandler { + navigator.openSignIn() + } } + +@Composable +private fun AccountAssertionCard( + modifier: Modifier = Modifier, + hashedEmail: String, +) { + Card( + modifier = modifier, + shape = DmsTheme.shapes.surfaceSmall, + colors = CardDefaults.cardColors( + containerColor = DmsTheme.colorScheme.surface, + contentColor = DmsTheme.colorScheme.onSurface, + ), + elevation = CardDefaults.outlinedCardElevation(defaultElevation = ShadowDefaults.SmallElevation), + ) { + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(DefaultVerticalSpace), + ) { + Text( + modifier = Modifier.startPadding(), + text = stringResource(id = R.string.reset_password_account_verification_success_account_id_matches_email), + ) + Text( + modifier = Modifier.startPadding(), + text = hashedEmail, + color = DmsTheme.colorScheme.primary, + ) + } + } +} \ No newline at end of file diff --git a/feature/src/main/java/team/aliens/dms/android/feature/resetpassword/2_EnterEmailVerificationCodeScreen.kt b/feature/src/main/java/team/aliens/dms/android/feature/resetpassword/2_EnterEmailVerificationCodeScreen.kt deleted file mode 100644 index 91340f018..000000000 --- a/feature/src/main/java/team/aliens/dms/android/feature/resetpassword/2_EnterEmailVerificationCodeScreen.kt +++ /dev/null @@ -1,15 +0,0 @@ -package team.aliens.dms.android.feature.resetpassword - -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import com.ramcosta.composedestinations.annotation.Destination -import team.aliens.dms.android.feature.resetpassword.navigation.ResetPasswordNavigator - -@Destination -@Composable -internal fun ResetPasswordEnterEmailVerificationCodeScreen( - modifier: Modifier = Modifier, - navigator: ResetPasswordNavigator, -) { - -} diff --git a/feature/src/main/java/team/aliens/dms/android/feature/resetpassword/2_ResetPasswordEnterEmailVerificationCodeScreen.kt b/feature/src/main/java/team/aliens/dms/android/feature/resetpassword/2_ResetPasswordEnterEmailVerificationCodeScreen.kt new file mode 100644 index 000000000..2c5807f4c --- /dev/null +++ b/feature/src/main/java/team/aliens/dms/android/feature/resetpassword/2_ResetPasswordEnterEmailVerificationCodeScreen.kt @@ -0,0 +1,212 @@ +package team.aliens.dms.android.feature.resetpassword + +import android.os.CountDownTimer +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.ramcosta.composedestinations.annotation.Destination +import team.aliens.dms.android.core.designsystem.ButtonDefaults +import team.aliens.dms.android.core.designsystem.ContainedButton +import team.aliens.dms.android.core.designsystem.DmsScaffold +import team.aliens.dms.android.core.designsystem.DmsTheme +import team.aliens.dms.android.core.designsystem.DmsTopAppBar +import team.aliens.dms.android.core.designsystem.LocalToast +import team.aliens.dms.android.core.designsystem.TextButton +import team.aliens.dms.android.core.designsystem.VerificationCodeInput +import team.aliens.dms.android.core.designsystem.VerificationCodeInputDefaults +import team.aliens.dms.android.core.ui.Banner +import team.aliens.dms.android.core.ui.BannerDefaults +import team.aliens.dms.android.core.ui.DefaultVerticalSpace +import team.aliens.dms.android.core.ui.bottomPadding +import team.aliens.dms.android.core.ui.collectInLaunchedEffectWithLifecycle +import team.aliens.dms.android.core.ui.horizontalPadding +import team.aliens.dms.android.core.ui.startPadding +import team.aliens.dms.android.core.ui.topPadding +import team.aliens.dms.android.feature.R +import team.aliens.dms.android.feature.resetpassword.navigation.ResetPasswordNavigator + +// Millisecond * Second * Minutes +private const val TIMER_TOTAL_SECONDS: Long = 1000 * 60 * 3 +private const val TIMER_INTERVAL: Long = 1000 + +@OptIn(ExperimentalMaterial3Api::class) +@Destination +@Composable +internal fun ResetPasswordEnterEmailVerificationCodeScreen( + modifier: Modifier = Modifier, + navigator: ResetPasswordNavigator, + viewModel: ResetPasswordViewModel, +) { + val uiState by viewModel.stateFlow.collectAsStateWithLifecycle() + val toast = LocalToast.current + val context = LocalContext.current + var timerText: String by remember { mutableStateOf("") } + + var isVerificationInputAvailable by remember { mutableStateOf(true) } + var isSessionExpired by remember { mutableStateOf(false) } + + val timer: CountDownTimer = remember { + object : CountDownTimer(TIMER_TOTAL_SECONDS, TIMER_INTERVAL) { + override fun onTick(millisUntilFinished: Long) { + val minutes = millisUntilFinished / (1000 * 60) + val seconds = millisUntilFinished / 1000 % 60 + timerText = context.getString(R.string.format_timer_m_s, minutes, seconds) + } + + override fun onFinish() { + isVerificationInputAvailable = false + isSessionExpired = true + viewModel.postIntent(ResetPasswordIntent.UpdateEmailVerificationCode(value = "")) + } + } + } + + LaunchedEffect(uiState.sessionId) { + timer.start() + } + + viewModel.sideEffectFlow.collectInLaunchedEffectWithLifecycle { sideEffect -> + when (sideEffect) { + ResetPasswordSideEffect.EmailVerificationCodeChecked -> { + timer.cancel() + navigator.openResetPasswordSetPassword() + } + + ResetPasswordSideEffect.EmailVerificationCodeIncorrect -> toast.showErrorToast( + message = context.getString(R.string.sign_up_enter_email_verification_code_error_verification_code_incorrect), + ) + + ResetPasswordSideEffect.EmailVerificationSessionReset -> { + isVerificationInputAvailable = true + with(timer) { + cancel() + start() + } + toast.showSuccessToast( + message = context.getString(R.string.sign_up_enter_email_verification_code_success_resent_email), + ) + } + + ResetPasswordSideEffect.EmailVerificationSessionResetFailed -> toast.showErrorToast( + message = context.getString(R.string.sign_up_enter_email_verification_code_error_cannot_resend_verification_code), + ) + + else -> {//* explicit blank *//* + } + } + } + + DmsScaffold( + modifier = modifier, + topBar = { + DmsTopAppBar( + title = {}, + navigationIcon = { + IconButton(onClick = navigator::openSignIn) { + Icon( + painter = painterResource(id = R.drawable.ic_baseline_arrow_back_24), + contentDescription = stringResource(id = R.string.top_bar_back_button), + ) + } + }, + ) + }, + ) { padValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padValues) + .imePadding(), + ) { + Banner( + modifier = Modifier + .fillMaxWidth() + .topPadding(BannerDefaults.DefaultTopSpace) + .startPadding(), + message = { + BannerDefaults.DefaultText(text = stringResource(id = R.string.sign_up_enter_email_verification_code)) + }, + ) + Spacer(modifier = Modifier.weight(1f)) + VerificationCodeInput( + modifier = Modifier + .fillMaxWidth() + .horizontalPadding(), + totalLength = ResetPasswordViewModel.EMAIL_VERIFICATION_CODE_LENGTH, + text = uiState.emailVerificationCode, + onValueChange = { verificationCode -> + viewModel.postIntent(ResetPasswordIntent.UpdateEmailVerificationCode(value = verificationCode)) + }, + supportingText = { + VerificationCodeInputDefaults.SupportingText( + text = stringResource(id = R.string.sign_up_enter_email_verification_code_please_enter_6_digit_code_sent_to_email), + ) + }, + enabled = isVerificationInputAvailable, + ) + Spacer(modifier = Modifier.height(DefaultVerticalSpace)) + Text( + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + text = if (isVerificationInputAvailable) { + timerText + } else { + stringResource( + id = if (isSessionExpired) { + R.string.sign_up_enter_email_verification_code_error_timeout + } else { + R.string.sign_up_enter_email_verification_code_error_verification_code_incorrect + }, + ) + }, + style = DmsTheme.typography.caption, + color = if (isVerificationInputAvailable) { + DmsTheme.colorScheme.primary + } else { + DmsTheme.colorScheme.error + }, + ) + Spacer(modifier = Modifier.weight(3f)) + TextButton( + modifier = Modifier + .fillMaxWidth() + .horizontalPadding(), + onClick = { viewModel.postIntent(ResetPasswordIntent.ResetEmailVerificationSession) }, + colors = ButtonDefaults.textGrayButtonColors(), + ) { + Text(text = stringResource(id = R.string.sign_up_enter_email_verification_code_resend_verification_code)) + } + Spacer(modifier = Modifier.height(DefaultVerticalSpace)) + ContainedButton( + modifier = Modifier + .fillMaxWidth() + .horizontalPadding() + .bottomPadding(), + onClick = { viewModel.postIntent(ResetPasswordIntent.CheckEmailVerificationCode) }, + enabled = uiState.emailVerificationCode.length == ResetPasswordViewModel.EMAIL_VERIFICATION_CODE_LENGTH, + ) { + Text(text = stringResource(id = R.string.verify)) + } + } + } +} diff --git a/feature/src/main/java/team/aliens/dms/android/feature/resetpassword/3_SetPasswordScreen.kt b/feature/src/main/java/team/aliens/dms/android/feature/resetpassword/3_SetPasswordScreen.kt index c8a2ee8bc..4786eee56 100644 --- a/feature/src/main/java/team/aliens/dms/android/feature/resetpassword/3_SetPasswordScreen.kt +++ b/feature/src/main/java/team/aliens/dms/android/feature/resetpassword/3_SetPasswordScreen.kt @@ -1,205 +1,182 @@ package team.aliens.dms.android.feature.resetpassword +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.ramcosta.composedestinations.annotation.Destination +import team.aliens.dms.android.core.designsystem.AlertDialog +import team.aliens.dms.android.core.designsystem.ContainedButton +import team.aliens.dms.android.core.designsystem.DmsScaffold +import team.aliens.dms.android.core.designsystem.DmsTopAppBar +import team.aliens.dms.android.core.designsystem.LocalToast +import team.aliens.dms.android.core.designsystem.TextButton +import team.aliens.dms.android.core.ui.Banner +import team.aliens.dms.android.core.ui.BannerDefaults +import team.aliens.dms.android.core.ui.DefaultVerticalSpace +import team.aliens.dms.android.core.ui.bottomPadding +import team.aliens.dms.android.core.ui.collectInLaunchedEffectWithLifecycle +import team.aliens.dms.android.core.ui.composable.PasswordTextField +import team.aliens.dms.android.core.ui.horizontalPadding +import team.aliens.dms.android.core.ui.startPadding +import team.aliens.dms.android.core.ui.topPadding +import team.aliens.dms.android.feature.R import team.aliens.dms.android.feature.resetpassword.navigation.ResetPasswordNavigator @Suppress("ConstPropertyName") private const val passwordFormat = "^(?=.*[A-Za-z])(?=.*[0-9])(?=.*[!@#$%^&*()_+=-]).{8,20}" +@OptIn(ExperimentalMaterial3Api::class) @Destination @Composable fun ResetPasswordSetPasswordScreen( modifier: Modifier = Modifier, navigator: ResetPasswordNavigator, - // changePasswordViewModel: ChangePasswordViewModel = hiltViewModel(), -) {/* + viewModel: ResetPasswordViewModel, +) { + val uiState by viewModel.stateFlow.collectAsStateWithLifecycle() - val context = LocalContext.current - - val toast = rememberToast() - - val focusManager = LocalFocusManager.current - - var password by remember { mutableStateOf("") } - var repeatPassword by remember { mutableStateOf("") } + val (showPassword, onShowPasswordChange) = remember { mutableStateOf(false) } + val (showPasswordRepeat, onShowPasswordRepeatChange) = remember { mutableStateOf(false) } - var isPasswordFormatError by remember { mutableStateOf(false) } - var isPasswordMatchError by remember { mutableStateOf(false) } - - var isShowDialog by remember { mutableStateOf(false) } - var isPressedBackButton by remember { mutableStateOf(false) } + val toast = LocalToast.current + val context = LocalContext.current - val onPasswordChange = { passwordValue: String -> - if (passwordValue.length != password.length) isPasswordFormatError = false - password = passwordValue + val (shouldShowQuitSignUpDialog, onShouldShowQuitSignUpDialogChange) = remember { + mutableStateOf(false) } - val onRepeatPasswordChange = { repeatPasswordValue: String -> - if (repeatPasswordValue.length != repeatPassword.length) isPasswordMatchError = false - repeatPassword = repeatPasswordValue + if (shouldShowQuitSignUpDialog) { + AlertDialog( + text = { Text(text = stringResource(id = R.string.reset_password_set_password_password_success_changed)) }, + onDismissRequest = { /* explicit blank */ }, + confirmButton = { + TextButton( + onClick = navigator::openSignIn, + ) { + Text(text = stringResource(id = R.string.reset_password_go_to_sign_in_screen)) + } + }, + ) } - BackHandler(enabled = true) { - isPressedBackButton = true - } - Column( - modifier = modifier - .fillMaxSize() - .background( - color = DormTheme.colors.surface, + viewModel.sideEffectFlow.collectInLaunchedEffectWithLifecycle { sideEffect -> + when (sideEffect) { + ResetPasswordSideEffect.PasswordReset -> onShouldShowQuitSignUpDialogChange(true) + ResetPasswordSideEffect.PasswordMismatch -> toast.showErrorToast( + message = context.getString(R.string.sign_up_set_password_error_password_mismatch), ) - .dormClickable( - rippleEnabled = false, - ) { - focusManager.clearFocus() - }, - ) { - - TopBar( - title = stringResource(R.string.ChangePassword), - onPrevious = navigator::popBackStack, - ) - if (isShowDialog) { - DormCustomDialog( - onDismissRequest = {}, - ) { - DormSingleButtonDialog( - content = stringResource(id = R.string.SuccessChangePassword), - mainBtnText = stringResource(id = R.string.GoLogin), - onMainBtnClick = navigator::openSignIn, - mainBtnTextColor = DormColor.DormPrimary, + ResetPasswordSideEffect.InvalidPassword -> { + toast.showErrorToast( + message = context.getString(R.string.sign_up_set_password_invalid_password), ) } - Image( - modifier = Modifier - .padding(top = 32.dp, bottom = 7.dp) - .height(85.dp) - .width(85.dp), - painter = painterResource(team.aliens.dms.android.designsystem.R.drawable.ic_information), - contentDescription = stringResource(id = R.string.MainLogo), - ) - - Space(space = 1.dp) - - Body4( - text = stringResource(id = R.string.SetNewPassword), - ) - } - - if (isPressedBackButton) { - DormCustomDialog( - onDismissRequest = { *//*TODO*//* }, - ) { - DormDoubleButtonDialog( - content = stringResource(id = R.string.FinishResetPassword), - mainBtnText = stringResource(id = R.string.Yes), - subBtnText = stringResource(id = R.string.No), - onMainBtnClick = navigator::openSignIn, - onSubBtnClick = { isPressedBackButton = false }, - ) + else -> {/* explicit blank */ } } + } - LaunchedEffect(Unit) { - changePasswordViewModel.editPasswordEffect.collect { - when (it) { - is ChangePasswordViewModel.Event.ResetPasswordSuccess -> { - toast(context.getString(R.string.SuccessResetPassword)) - navigator.openSignIn() - } - - else -> { - toast( - getStringFromEvent( - context = context, - event = it, - ) + DmsScaffold( + modifier = modifier, + topBar = { + DmsTopAppBar( + title = {}, + navigationIcon = { + IconButton(onClick = { navigator.openSignIn() }) { + Icon( + painter = painterResource(id = R.drawable.ic_baseline_arrow_back_24), + contentDescription = stringResource(id = R.string.top_bar_back_button), ) } - } - } - } - + }, + ) + }, + ) { padValues -> Column( modifier = Modifier .fillMaxSize() - .padding( - top = 38.dp, - start = 16.dp, - end = 16.dp, - ) + .padding(padValues) + .imePadding(), ) { - AppLogo() - Space(space = 8.dp) - Body2( - text = stringResource(id = R.string.ChangePassword), - ) - Space(space = 4.dp) - Caption( - text = stringResource(id = R.string.PasswordWarning), - color = DormColor.Gray500, - ) - Column( + Spacer(modifier = Modifier.weight(1f)) + Banner( modifier = Modifier .fillMaxWidth() - .padding(top = 40.dp), + .topPadding(BannerDefaults.DefaultTopSpace) + .startPadding(), + message = { BannerDefaults.DefaultText(text = stringResource(id = R.string.edit_password_new_password)) }, + ) + Spacer(modifier = Modifier.weight(1f)) + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(DefaultVerticalSpace), ) { - Box(modifier = Modifier.height(76.dp)) { - DormTextField( - value = password, - onValueChange = onPasswordChange, - error = isPasswordFormatError, - isPassword = true, - hint = stringResource(id = R.string.ScanNewPassword), - errorDescription = stringResource(id = R.string.CheckPasswordFormat), - imeAction = ImeAction.Next, - ) - } - Space(space = 7.dp) - Box( - modifier = Modifier.height(76.dp), - ) { - DormTextField( - value = repeatPassword, - onValueChange = onRepeatPasswordChange, - error = isPasswordMatchError, - isPassword = true, - hint = stringResource(id = R.string.CheckScanNewPassword), - errorDescription = stringResource(id = R.string.MismatchRepeatPassword), - keyboardActions = KeyboardActions { - focusManager.clearFocus() - }, - imeAction = ImeAction.Done, - ) - } + PasswordTextField( + modifier = Modifier + .fillMaxWidth() + .horizontalPadding(), + value = uiState.newPassword, + onValueChange = { + viewModel.postIntent( + ResetPasswordIntent.UpdateNewPassword( + value = it + ) + ) + }, + passwordShowing = showPassword, + onPasswordShowingChange = onShowPasswordChange, + hintText = stringResource(id = R.string.edit_password_please_enter_new_password) + ) + PasswordTextField( + modifier = Modifier + .fillMaxWidth() + .horizontalPadding(), + value = uiState.newPasswordRepeat, + onValueChange = { + viewModel.postIntent( + ResetPasswordIntent.UpdateNewPasswordRepeat( + value = it + ) + ) + }, + passwordShowing = showPasswordRepeat, + onPasswordShowingChange = onShowPasswordRepeatChange, + hintText = stringResource(id = R.string.edit_password_please_enter_new_password_repeat) + ) } - RatioSpace(height = 0.742f) - DormContainedLargeButton( - text = stringResource(id = R.string.Check), - color = DormButtonColor.Blue, - enabled = (password.isNotBlank() && repeatPassword.isNotBlank()) + Spacer(modifier = Modifier.weight(3f)) + ContainedButton( + modifier = Modifier + .fillMaxWidth() + .horizontalPadding() + .bottomPadding(), + onClick = { viewModel.postIntent(ResetPasswordIntent.SetPassword) }, + enabled = uiState.newPassword.isNotEmpty() && uiState.newPasswordRepeat.isNotEmpty(), ) { - if (password != repeatPassword) { - isPasswordMatchError = true - } else if (!Pattern.compile(passwordFormat).matcher(password).find()) { - isPasswordFormatError = true - } else { - *//* todo - navController.previousBackStackEntry?.arguments?.run { - changePasswordViewModel.resetPassword( - accountId = getString("accountId").toString(), - emailVerificationCode = getString("authCode").toString(), - email = getString("email").toString(), - studentName = getString("name").toString(), - newPassword = password, - ) - }*//* - } + Text(text = stringResource(id = R.string.next)) } } - }*/ + } + BackHandler { + onShouldShowQuitSignUpDialogChange(true) + } } diff --git a/feature/src/main/java/team/aliens/dms/android/feature/resetpassword/ResetPasswordViewModel.kt b/feature/src/main/java/team/aliens/dms/android/feature/resetpassword/ResetPasswordViewModel.kt index 4ee0478c5..fcb563e28 100644 --- a/feature/src/main/java/team/aliens/dms/android/feature/resetpassword/ResetPasswordViewModel.kt +++ b/feature/src/main/java/team/aliens/dms/android/feature/resetpassword/ResetPasswordViewModel.kt @@ -1,338 +1,226 @@ package team.aliens.dms.android.feature.resetpassword -/* -import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -import team.aliens.dms.android.domain.model._common.EmailVerificationType -import team.aliens.dms.android.domain.model.auth.CheckEmailVerificationCodeInput -import team.aliens.dms.android.feature._legacy.base.BaseViewModel1 -import team.aliens.dms.android.feature._legacy.util.MutableEventFlow -import team.aliens.dms.android.feature._legacy.util.asEventFlow -import team.aliens.dms.android.domain.model.auth.CheckIdExistsInput -import team.aliens.dms.android.domain.model.auth.SendEmailVerificationCodeInput -import team.aliens.dms.android.domain.model.student.CheckEmailDuplicationInput -import team.aliens.dms.android.domain.model.student.ResetPasswordInput -import team.aliens.dms.android.domain.model.user.ComparePasswordInput -import team.aliens.dms.android.domain.model.user.EditPasswordInput -import team.aliens.dms.android.domain.usecase.auth.CheckEmailVerificationCodeUseCase -import team.aliens.dms.android.domain.usecase.auth.CheckIdExistsUseCase -import team.aliens.dms.android.domain.usecase.auth.SendEmailVerificationCodeUseCase -import team.aliens.dms.android.domain.usecase.student.CheckEmailDuplicationUseCase -import team.aliens.dms.android.domain.usecase.student.ResetPasswordUseCase -import team.aliens.dms.android.domain.usecase.user.ComparePasswordUseCase -import team.aliens.dms.android.domain.usecase.user.EditPasswordUseCase -import team.aliens.dms.android.feature._legacy.base.MviEvent -import team.aliens.dms.android.feature._legacy.base._MviState +import team.aliens.dms.android.core.ui.mvi.BaseMviViewModel +import team.aliens.dms.android.core.ui.mvi.Intent +import team.aliens.dms.android.core.ui.mvi.SideEffect +import team.aliens.dms.android.core.ui.mvi.UiState +import team.aliens.dms.android.data.auth.model.EmailVerificationType +import team.aliens.dms.android.data.auth.repository.AuthRepository +import team.aliens.dms.android.data.student.repository.StudentRepository +import team.aliens.dms.android.shared.validator.checkIfPasswordValid +import java.util.UUID import javax.inject.Inject @HiltViewModel -class ChangePasswordViewModel @Inject constructor( - private val changePasswordUseCase: ResetPasswordUseCase, - private val editPasswordUseCase: EditPasswordUseCase, - private val comparePasswordUseCase: ComparePasswordUseCase, - private val checkIdUseCase: CheckIdExistsUseCase, -) : BaseViewModel1() { - - */ -/* - 디자인에서 처음 본인인증할때 아이디만을 사용해서 "아이디 존재 여부(비밀번호 재설정)"이라는 Api로 이에 해당하는 Email를 받습니다. - 그다음 아이디를 입력 받은 다음에 "이메일 검증이라는 Api를 사용하여 이메일과 아이디를 서버에 보낸뒤 이 값들이 정보와 일치하는지 검사합니다." - 검사에서 가능이 뜨게 된다면 "이메일 인증번호 보내기 APi"를 사용해서 사용자 이메일에 이메일을 발송합니다. - 그리고 이메일 인증번호 확인 Api를 사용하여 인증을 완료하고 Students의 비밀번호 재설정 Api를 사용하여 재설정합니다. - *//* - - - override val initialState: ChangePasswordState - get() = ChangePasswordState.getDefaultInstance() - - private val _editPasswordEffect = MutableEventFlow() - var editPasswordEffect = _editPasswordEffect.asEventFlow() - - internal fun editPassword() { - viewModelScope.launch { - kotlin.runCatching { - with(state.value) { - if (newPassword == repeatPassword) { - editPasswordUseCase( - editPasswordInput = EditPasswordInput( - currentPassword = currentPassword, - newPassword = newPassword, - ) - ) - } - } - }.onSuccess { - event(Event.EditPasswordSuccess) - }.onFailure { - event(getEventFromThrowableChangePassword(it)) - } +class ResetPasswordViewModel @Inject constructor( + private val studentRepository: StudentRepository, + private val authRepository: AuthRepository, +) : BaseMviViewModel( + initialState = ResetPasswordUiState.initial() +) { + /*디자인에서 처음 본인인증할때 아이디만을 사용해서 "아이디 존재 여부(비밀번호 재설정)"이라는 Api로 이에 해당하는 Email를 받습니다. + 그다음 아이디를 입력 받은 다음에 "이메일 검증이라는 Api를 사용하여 이메일과 아이디를 서버에 보낸뒤 이 값들이 정보와 일치하는지 검사합니다." + 검사에서 가능이 뜨게 된다면 "이메일 인증번호 보내기 APi"를 사용해서 사용자 이메일에 이메일을 발송합니다. + 그리고 이메일 인증번호 확인 Api를 사용하여 인증을 완료하고 Students의 비밀번호 재설정 Api를 사용하여 재설정합니다.*/ + + override fun processIntent(intent: ResetPasswordIntent) { + when (intent) { + ResetPasswordIntent.SetPassword -> resetPassword() + is ResetPasswordIntent.UpdateNewPassword -> this.updateNewPassword(value = intent.value) + is ResetPasswordIntent.UpdateEmailVerificationCode -> updateEmailVerificationCode(value = intent.value) + ResetPasswordIntent.CheckEmailVerificationCode -> checkEmailVerificationCode() + ResetPasswordIntent.ResetEmailVerificationSession -> resetEmailVerificationSession() + is ResetPasswordIntent.UpdateAccountId -> this.updateAccountId(value = intent.value) + is ResetPasswordIntent.CheckAccountId -> checkIdExists() + is ResetPasswordIntent.UpdateStudentName -> this.updateStudentName(value = intent.value) + is ResetPasswordIntent.UpdateEmail -> this.updateEmail(value = intent.value) + is ResetPasswordIntent.SendEmailVerificationCode -> this.sendEmailVerificationCode(email = intent.value) + is ResetPasswordIntent.UpdateNewPasswordRepeat -> updateNewPasswordRepeat(value = intent.value) } } - internal fun comparePassword() { - viewModelScope.launch { - kotlin.runCatching { - comparePasswordUseCase( - comparePasswordInput = ComparePasswordInput( - password = state.value.currentPassword, - ), - ) - }.onSuccess { - event(Event.ComparePasswordSuccess) - }.onFailure { - event(getEventFromThrowableChangePassword(it)) - } - } - } - internal fun checkId( - accountId: String, - ) { - viewModelScope.launch { - kotlin.runCatching { - checkIdUseCase( - checkIdExistsInput = CheckIdExistsInput( - accountId = accountId, - ), - ) - }.onSuccess { - event(Event.CheckIdSuccess(it.email)) - }.onFailure { - event(getEventFromThrowableChangePassword(it)) - } + private fun resetPassword() = viewModelScope.launch(Dispatchers.IO) { + val capturedState = stateFlow.value + if (capturedState.newPassword != capturedState.newPasswordRepeat) { + postSideEffect(ResetPasswordSideEffect.PasswordMismatch) + return@launch } - } - internal fun resetPassword( - accountId: String, - email: String, - emailVerificationCode: String, - studentName: String, - newPassword: String, - ) { - viewModelScope.launch { - kotlin.runCatching { - state.value.run { - changePasswordUseCase( - resetPasswordInput = ResetPasswordInput( - accountId = accountId, - studentName = studentName, - email = email, - emailVerificationCode = emailVerificationCode, - newPassword = newPassword, - ), - ) - } - }.onSuccess { - event(Event.ResetPasswordSuccess) - }.onFailure { - event(getEventFromThrowableChangePassword(it)) - } + if (!checkIfPasswordValid(capturedState.newPassword)) { + postSideEffect(ResetPasswordSideEffect.PasswordFormatError) + return@launch } - } - internal fun setCurrentPassword( - currentPassword: String, - ) { - sendEvent(event = ChangePasswordEvent.SetCurrentPassword(currentPassword)) + runCatching { + studentRepository.resetPassword( + accountId = capturedState.accountId, + studentName = capturedState.studentName, + email = capturedState.email, + emailVerificationCode = capturedState.emailVerificationCode, + newPassword = capturedState.newPassword, + ) + }.onSuccess { + postSideEffect(ResetPasswordSideEffect.PasswordReset) + }.onFailure { + it.printStackTrace() + postSideEffect(ResetPasswordSideEffect.PasswordFormatError) + } } - internal fun setRepeatPassword( - repeatPassword: String, - ) { - sendEvent(event = ChangePasswordEvent.SetRepeatPassword(repeatPassword)) - } + private fun checkIdExists() = viewModelScope.launch(Dispatchers.IO) { + val capturedState = stateFlow.value - internal fun setNewPassword( - newPassword: String, - ) { - sendEvent(event = ChangePasswordEvent.SetNewPassword(newPassword)) + runCatching { + authRepository.checkIdExists( + accountId = capturedState.accountId, + ) + }.onSuccess { + reduce( + newState = stateFlow.value.copy( + hashedEmail = it, + ), + ) + postSideEffect(ResetPasswordSideEffect.AccountIdExists) + }.onFailure { + postSideEffect(ResetPasswordSideEffect.AccountIdNotExists) + } } - override fun reduceEvent(oldState: ChangePasswordState, event: ChangePasswordEvent) { - when (event) { - is ChangePasswordEvent.SetCurrentPassword -> { - setState(state = oldState.copy(currentPassword = event.currentPassword)) - } - - is ChangePasswordEvent.SetRepeatPassword -> { - setState(state = oldState.copy(repeatPassword = event.repeatPassword)) + private fun sendEmailVerificationCode(email: String) = + runCatching { + viewModelScope.launch(Dispatchers.IO) { + authRepository.sendEmailVerificationCode( + email = email, + type = EmailVerificationType.PASSWORD, + ) } + }.onSuccess { + postSideEffect(ResetPasswordSideEffect.SendEmailVerificationCodeSuccess) + } - is ChangePasswordEvent.SetNewPassword -> { - setState(state = oldState.copy(newPassword = event.newPassword)) - } - else -> {} + private fun updateEmailVerificationCode(value: String) = run { + if (value.length > ResetPasswordViewModel.EMAIL_VERIFICATION_CODE_LENGTH) { + return@run false } + reduce(newState = stateFlow.value.copy(emailVerificationCode = value)) } - private fun event(event: Event) { - viewModelScope.launch { - _editPasswordEffect.emit(event) + private fun checkEmailVerificationCode() = viewModelScope.launch(Dispatchers.IO) { + val capturedState = stateFlow.value + runCatching { + authRepository.checkEmailVerificationCode( + email = capturedState.email, + code = capturedState.emailVerificationCode, + type = EmailVerificationType.PASSWORD, + ) + }.onSuccess { + postSideEffect(ResetPasswordSideEffect.EmailVerificationCodeChecked) + }.onFailure { + postSideEffect(ResetPasswordSideEffect.EmailVerificationCodeIncorrect) + reduce(newState = stateFlow.value.copy(emailVerificationCode = "")) } } - sealed class Event { - object EditPasswordSuccess : Event() - object ComparePasswordSuccess : Event() - data class CheckIdSuccess(val email: String) : Event() - object ResetPasswordSuccess : Event() - - object BadRequestException : Event() - object NotFoundException : Event() - object UnauthorizedException : Event() - object ForbiddenException : Event() - object TooManyRequestException : Event() - object ServerException : Event() - object UnknownException : Event() - } -} - -// TODO 추후에 리팩토링 필요 -private fun getEventFromThrowableChangePassword( - throwable: Throwable?, -): ChangePasswordViewModel.Event { - return when (throwable) { - else -> ChangePasswordViewModel.Event.UnknownException + private fun resetEmailVerificationSession() = viewModelScope.launch(Dispatchers.IO) { + runCatching { + this@ResetPasswordViewModel.sendEmailVerificationCode(email = stateFlow.value.email) + }.onSuccess { + postSideEffect(ResetPasswordSideEffect.EmailVerificationSessionReset) + reduce(newState = stateFlow.value.copy(emailVerificationCode = "")) + }.onFailure { + postSideEffect(ResetPasswordSideEffect.EmailVerificationSessionResetFailed) + } } -} -sealed class ChangePasswordEvent : MviEvent { - object ChangePasswordSuccess : ChangePasswordEvent() - object BadRequestException : ChangePasswordEvent() - object UnAuthorizedException : ChangePasswordEvent() - object NotFoundException : ChangePasswordEvent() - object TooManyRequestException : ChangePasswordEvent() - object InternalServerException : ChangePasswordEvent() - object UnKnownException : ChangePasswordEvent() + private fun updateNewPassword(value: String) = reduce( + newState = stateFlow.value.copy( + newPassword = value, + ) + ) + + private fun updateAccountId(value: String) = reduce( + newState = stateFlow.value.copy( + accountId = value, + ) + ) + + private fun updateStudentName(value: String) = reduce( + newState = stateFlow.value.copy( + studentName = value + ) + ) + + private fun updateEmail(value: String) = reduce( + newState = stateFlow.value.copy( + email = value, + ) + ) + + private fun updateNewPasswordRepeat(value: String) = reduce( + newState = stateFlow.value.copy( + newPasswordRepeat = value, + ) + ) - data class SetCurrentPassword(val currentPassword: String): ChangePasswordEvent() - data class SetRepeatPassword(val repeatPassword: String): ChangePasswordEvent() - data class SetNewPassword(val newPassword: String): ChangePasswordEvent() + companion object { + const val EMAIL_VERIFICATION_CODE_LENGTH = 6 + } } -data class ChangePasswordState( - val currentPassword: String, - val repeatPassword: String, +data class ResetPasswordUiState( + val accountId: String, + val studentName: String, + val email: String, + val emailVerificationCode: String, val newPassword: String, -) : _MviState { - + val newPasswordRepeat: String, + val hashedEmail: String, + val sessionId: UUID, +) : UiState() { companion object { - fun getDefaultInstance() = - ChangePasswordState( - currentPassword = "", - repeatPassword = "", - newPassword = "", - ) + fun initial() = ResetPasswordUiState( + accountId = "", + studentName = "", + email = "", + emailVerificationCode = "", + newPassword = "", + newPasswordRepeat = "", + hashedEmail = "", + sessionId = UUID.randomUUID(), + ) } } -@HiltViewModel -class ResetPasswordVerificationViewModel @Inject constructor( - private val sendEmailVerificationCodeUseCase: SendEmailVerificationCodeUseCase, - private val checkEmailVerificationCodeUseCase: CheckEmailVerificationCodeUseCase, - private val checkEmailDuplicationUseCase: CheckEmailDuplicationUseCase, -) : ViewModel() { - - private val _resetPasswordVerificationEvent = MutableEventFlow() - val registerEmailEvent = _resetPasswordVerificationEvent.asEventFlow() - - fun requestEmailCode( - email: String, - type: EmailVerificationType, - ) { - viewModelScope.launch { - kotlin.runCatching { - sendEmailVerificationCodeUseCase( - sendEmailVerificationCodeInput = SendEmailVerificationCodeInput( - email = email, - type = type, - ), - ) - }.onSuccess { - event(ResetPasswordVerificationEvent.SendEmailSuccess) - }.onFailure { - // fixme 추후에 리팩토링 필요 - when (it) { - else -> event(ResetPasswordVerificationEvent.UnKnownException) - } - } - } - } - - fun checkEmailCode( - email: String, - authCode: String, - type: EmailVerificationType, - ) { - viewModelScope.launch { - kotlin.runCatching { - checkEmailVerificationCodeUseCase( - checkEmailVerificationCodeInput = CheckEmailVerificationCodeInput( - email = email, - type = type, - authCode = authCode, - ), - ) - }.onSuccess { - event(ResetPasswordVerificationEvent.CheckEmailSuccess) - }.onFailure { - // fixme 추후에 리팩토링 필요 - when (it) { - else -> event(ResetPasswordVerificationEvent.InternalServerException) - } - } - } - } - - internal fun checkEmailDuplicate( - email: String, - ) { - viewModelScope.launch { - kotlin.runCatching { - checkEmailDuplicationUseCase( - checkEmailDuplicationInput = CheckEmailDuplicationInput( - email = email, - ), - ) - }.onSuccess { - event(ResetPasswordVerificationEvent.AllowEmail) - }.onFailure { - event(getEventFromThrowable(it)) - } - } - } - - private fun event(event: ResetPasswordVerificationEvent) { - viewModelScope.launch { - _resetPasswordVerificationEvent.emit(event) - } - } +sealed class ResetPasswordIntent : Intent() { + class UpdateNewPassword(val value: String) : ResetPasswordIntent() + class UpdateNewPasswordRepeat(val value: String) : ResetPasswordIntent() + data object SetPassword : ResetPasswordIntent() + class UpdateEmailVerificationCode(val value: String) : ResetPasswordIntent() + data object CheckEmailVerificationCode : ResetPasswordIntent() + data object ResetEmailVerificationSession : ResetPasswordIntent() + class UpdateAccountId(val value: String) : ResetPasswordIntent() + data object CheckAccountId : ResetPasswordIntent() + class UpdateStudentName(val value: String) : ResetPasswordIntent() + class UpdateEmail(val value: String) : ResetPasswordIntent() + class SendEmailVerificationCode(val value: String) : ResetPasswordIntent() } -// fixme 추후에 리팩토링 필요 -private fun getEventFromThrowable( - throwable: Throwable?, -): ResetPasswordVerificationEvent = - when (throwable) { - else -> { - ResetPasswordVerificationEvent.UnKnownException - } - } -sealed class ResetPasswordVerificationEvent : MviEvent { - object SendEmailSuccess : ResetPasswordVerificationEvent() - object CheckEmailSuccess : ResetPasswordVerificationEvent() - object BadRequestException : ResetPasswordVerificationEvent() - object CheckEmailNotFound : ResetPasswordVerificationEvent() - object CheckEmailUnauthorized : ResetPasswordVerificationEvent() - object TooManyRequestsException : ResetPasswordVerificationEvent() - object InternalServerException : ResetPasswordVerificationEvent() - object UnKnownException : ResetPasswordVerificationEvent() - - object AllowEmail: ResetPasswordVerificationEvent() - object ConflictException: ResetPasswordVerificationEvent() -} -*/ +sealed class ResetPasswordSideEffect : SideEffect() { + data object PasswordMismatch : ResetPasswordSideEffect() + data object PasswordFormatError : ResetPasswordSideEffect() + data object PasswordReset : ResetPasswordSideEffect() + data object InvalidPassword : ResetPasswordSideEffect() + data object AccountIdExists : ResetPasswordSideEffect() + data object AccountIdNotExists : ResetPasswordSideEffect() + data object SendEmailVerificationCodeSuccess : ResetPasswordSideEffect() + data object EmailVerificationCodeChecked : ResetPasswordSideEffect() + data object EmailVerificationCodeIncorrect : ResetPasswordSideEffect() + data object EmailVerificationSessionReset : ResetPasswordSideEffect() + data object EmailVerificationSessionResetFailed : ResetPasswordSideEffect() +} \ No newline at end of file diff --git a/feature/src/main/java/team/aliens/dms/android/feature/signin/SignInScreen.kt b/feature/src/main/java/team/aliens/dms/android/feature/signin/SignInScreen.kt index 4c422ad91..14d06c31e 100644 --- a/feature/src/main/java/team/aliens/dms/android/feature/signin/SignInScreen.kt +++ b/feature/src/main/java/team/aliens/dms/android/feature/signin/SignInScreen.kt @@ -7,6 +7,7 @@ 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.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState @@ -17,6 +18,7 @@ import androidx.compose.material3.ProvideTextStyle import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.VerticalDivider import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -32,6 +34,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.ramcosta.composedestinations.annotation.Destination @@ -248,8 +251,6 @@ private fun UnauthorizedActions( .clickable(onClick = onSignUp), text = stringResource(id = R.string.sign_in_sign_up), ) - // TODO: v1.2.0 - /* VerticalDivider( modifier = Modifier.height(12.dp), color = DmsTheme.colorScheme.onSurfaceVariant, @@ -270,7 +271,6 @@ private fun UnauthorizedActions( .clickable(onClick = onResetPassword), text = stringResource(id = R.string.sign_in_reset_password), ) - */ } } } diff --git a/feature/src/main/res/values/strings.xml b/feature/src/main/res/values/strings.xml index 5e02bc60e..7b84fd0fa 100644 --- a/feature/src/main/res/values/strings.xml +++ b/feature/src/main/res/values/strings.xml @@ -170,7 +170,7 @@ 비밀번호는 영문, 숫자, 기호를 포함한 8~20자이어야 합니다 - 로그인 화면으로 + 로그인 화면으로 %s 으로 아이디가 전송되었습니다 학교를 선택해주세요 @@ -290,4 +290,17 @@ %s 님이 맞으신가요? 새로고침 급식을 다시 불러왔습니다 + + + + 일치하는 아이디가 존재하지 않습니다. + 아이디 입력 + 본인확인 인증 + 아이디와 일치하는 이메일입니다 + 아이디 형식이 일치하지 않습니다. + 이름 입력 + 이메일 입력 + 비밀번호가 변경되었습니다. + 로그인 화면으로 + \ No newline at end of file diff --git a/network/src/main/java/team/aliens/dms/android/network/student/apiservice/StudentApiService.kt b/network/src/main/java/team/aliens/dms/android/network/student/apiservice/StudentApiService.kt index feadee987..7b0920dee 100644 --- a/network/src/main/java/team/aliens/dms/android/network/student/apiservice/StudentApiService.kt +++ b/network/src/main/java/team/aliens/dms/android/network/student/apiservice/StudentApiService.kt @@ -1,5 +1,6 @@ package team.aliens.dms.android.network.student.apiservice +import retrofit2.Response import retrofit2.http.Body import retrofit2.http.DELETE import retrofit2.http.GET @@ -40,7 +41,7 @@ internal interface StudentApiService { ): FindIdResponse @PATCH("/students/password/initialization") - suspend fun resetPassword(@Body request: ResetPasswordRequest) + suspend fun resetPassword(@Body request: ResetPasswordRequest): Response? @GET("/students/account-id/duplication") suspend fun checkIdDuplication(@Query("account_id") accountId: String) diff --git a/network/src/main/java/team/aliens/dms/android/network/student/datasource/NetworkStudentDataSourceImpl.kt b/network/src/main/java/team/aliens/dms/android/network/student/datasource/NetworkStudentDataSourceImpl.kt index 9941002d9..2acf9accb 100644 --- a/network/src/main/java/team/aliens/dms/android/network/student/datasource/NetworkStudentDataSourceImpl.kt +++ b/network/src/main/java/team/aliens/dms/android/network/student/datasource/NetworkStudentDataSourceImpl.kt @@ -44,7 +44,7 @@ internal class NetworkStudentDataSourceImpl @Inject constructor( number = number, ) - override suspend fun resetPassword(request: ResetPasswordRequest) = + override suspend fun resetPassword(request: ResetPasswordRequest): Unit = handleNetworkRequest { studentApiService.resetPassword(request) } override suspend fun checkIdDuplication(id: String) =