From f13b1b6d53f9616369d92743c4cc8aa94d7c9d50 Mon Sep 17 00:00:00 2001 From: Flavia Handrea Date: Mon, 30 Sep 2024 16:11:57 +0300 Subject: [PATCH 1/2] Code verification partial implementation for phone and tablet. Updated "Register" button with "Sign up". --- .../java/org/permanent/permanent/Constants.kt | 3 - .../permanent/network/NetworkClient.kt | 2 + .../permanent/network/StelaAccountService.kt | 3 + .../repositories/StelaAccountRepository.kt | 2 + .../StelaAccountRepositoryImpl.kt | 26 ++ .../ui/composeComponents/CustomSnackbar.kt | 110 +++++--- .../ui/composeComponents/DigitTextField.kt | 81 ++++++ .../ui/login/AuthenticationFragment.kt | 6 +- .../login/compose/AuthenticationContainer.kt | 25 +- .../ui/login/compose/CodeVerificationPage.kt | 219 ++++++++++++++++ .../permanent/ui/login/compose/SignInPage.kt | 28 ++- .../viewmodels/AuthenticationViewModel.kt | 236 ++++++++++++++++++ .../viewmodels/LoginFragmentViewModel.kt | 128 ---------- .../res/layout/fragment_forgot_password.xml | 2 +- app/src/main/res/layout/item_invitation.xml | 2 +- app/src/main/res/values/colors.xml | 2 + app/src/main/res/values/strings.xml | 12 +- 17 files changed, 696 insertions(+), 191 deletions(-) create mode 100644 app/src/main/java/org/permanent/permanent/ui/composeComponents/DigitTextField.kt create mode 100644 app/src/main/java/org/permanent/permanent/ui/login/compose/CodeVerificationPage.kt create mode 100644 app/src/main/java/org/permanent/permanent/viewmodels/AuthenticationViewModel.kt delete mode 100644 app/src/main/java/org/permanent/permanent/viewmodels/LoginFragmentViewModel.kt diff --git a/app/src/main/java/org/permanent/permanent/Constants.kt b/app/src/main/java/org/permanent/permanent/Constants.kt index 93ec1e20..3a4378a6 100644 --- a/app/src/main/java/org/permanent/permanent/Constants.kt +++ b/app/src/main/java/org/permanent/permanent/Constants.kt @@ -24,7 +24,6 @@ class Constants { const val MEDIA_TYPE_OCTET_STREAM = "application/octet-stream" const val AUTH_TYPE_MFA_VALIDATION = "type.auth.mfaValidation" const val AUTH_TYPE_PHONE = "type.auth.phone" - const val AUTH_REQUEST_SCOPE = "offline_access" const val ERROR_MFA_TOKEN = "warning.auth.mfaToken" const val ERROR_INVALID_VERIFICATION_CODE = "warning.auth.token_does_not_match" const val ERROR_EXPIRED_VERIFICATION_CODE = "warning.auth.token_expired" @@ -40,8 +39,6 @@ class Constants { const val ERROR_PASSWORD_NO_MATCH = "warning.registration.password_match" const val ERROR_PASSWORD_OLD_INCORRECT = "warning.auth.bad_old_password" const val SMS_RECEIVED_ACTION = "android.provider.Telephony.SMS_RECEIVED" - const val FILE_DELETED_SUCCESSFULLY = "Record(s) have been deleted." - const val FOLDER_DELETED_SUCCESSFULLY = "Folder has been deleted." // This is also used in manifest const val FILE_PROVIDER_NAME = ".fileprovider" } diff --git a/app/src/main/java/org/permanent/permanent/network/NetworkClient.kt b/app/src/main/java/org/permanent/permanent/network/NetworkClient.kt index dcadbc28..f226426a 100644 --- a/app/src/main/java/org/permanent/permanent/network/NetworkClient.kt +++ b/app/src/main/java/org/permanent/permanent/network/NetworkClient.kt @@ -764,6 +764,8 @@ class NetworkClient(private var okHttpClient: OkHttpClient?, context: Context) { fun addRemoveTags(tags: Tags): Call = stelaAccountService.addRemoveTags(tags) +// fun getTwoFAMethod(): Call = stelaAccountService.getTwoFAMethod() + fun getPaymentIntent( accountId: Int, accountEmail: String?, diff --git a/app/src/main/java/org/permanent/permanent/network/StelaAccountService.kt b/app/src/main/java/org/permanent/permanent/network/StelaAccountService.kt index 0aa312de..60c61de2 100644 --- a/app/src/main/java/org/permanent/permanent/network/StelaAccountService.kt +++ b/app/src/main/java/org/permanent/permanent/network/StelaAccountService.kt @@ -10,4 +10,7 @@ interface StelaAccountService { @PUT("api/v2/account/tags") fun addRemoveTags(@Body tags: Tags): Call + +// @GET("api/v2/idpuser") +// fun getTwoFAMethod(): Call } \ No newline at end of file diff --git a/app/src/main/java/org/permanent/permanent/repositories/StelaAccountRepository.kt b/app/src/main/java/org/permanent/permanent/repositories/StelaAccountRepository.kt index 509b5267..21077f68 100644 --- a/app/src/main/java/org/permanent/permanent/repositories/StelaAccountRepository.kt +++ b/app/src/main/java/org/permanent/permanent/repositories/StelaAccountRepository.kt @@ -6,4 +6,6 @@ import org.permanent.permanent.network.IResponseListener interface StelaAccountRepository { fun addRemoveTags(tags: Tags, listener: IResponseListener) + +// fun getTwoFAMethod(listener: IResponseListener) } \ No newline at end of file diff --git a/app/src/main/java/org/permanent/permanent/repositories/StelaAccountRepositoryImpl.kt b/app/src/main/java/org/permanent/permanent/repositories/StelaAccountRepositoryImpl.kt index 0dd195b4..992ef738 100644 --- a/app/src/main/java/org/permanent/permanent/repositories/StelaAccountRepositoryImpl.kt +++ b/app/src/main/java/org/permanent/permanent/repositories/StelaAccountRepositoryImpl.kt @@ -39,4 +39,30 @@ class StelaAccountRepositoryImpl(context: Context) : StelaAccountRepository { } }) } + +// override fun getTwoFAMethod(listener: IResponseListener) { +// NetworkClient.instance().getTwoFAMethod().enqueue(object : Callback { +// +// override fun onResponse(call: Call, response: Response) { +// if (response.isSuccessful) { +// val responseBody = response.body() +// if (responseBody != null) { +// listener.onSuccess("") +// } else { +// listener.onFailed(appContext.getString(R.string.generic_error)) +// } +// } else { +// try { +// listener.onFailed(response.errorBody().toString()) +// } catch (e: Exception) { +// listener.onFailed(e.message) +// } +// } +// } +// +// override fun onFailure(call: Call, t: Throwable) { +// listener.onFailed(t.message) +// } +// }) +// } } \ No newline at end of file diff --git a/app/src/main/java/org/permanent/permanent/ui/composeComponents/CustomSnackbar.kt b/app/src/main/java/org/permanent/permanent/ui/composeComponents/CustomSnackbar.kt index 2a59c5ba..92671b11 100644 --- a/app/src/main/java/org/permanent/permanent/ui/composeComponents/CustomSnackbar.kt +++ b/app/src/main/java/org/permanent/permanent/ui/composeComponents/CustomSnackbar.kt @@ -1,5 +1,10 @@ package org.permanent.permanent.ui.composeComponents +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement @@ -12,8 +17,14 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Text import androidx.compose.material3.TextButton 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.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.Font @@ -21,49 +32,85 @@ import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import kotlinx.coroutines.delay import org.permanent.permanent.R @Composable fun CustomSnackbar( - message: String, buttonText: String, onButtonClick: () -> Unit, modifier: Modifier = Modifier + modifier: Modifier = Modifier, + isForError: Boolean = true, + message: String, + buttonText: String, + onButtonClick: () -> Unit ) { - Box( + var visible by remember { mutableStateOf(false) } + + // LaunchedEffect ensures animations happen sequentially when the message changes + LaunchedEffect(message) { + if (message.isNotEmpty()) { + visible = false // Start by hiding the old Snackbar + delay(300) // Wait for exit animation to complete + visible = true // Then show the new Snackbar + } else visible = false + } + + AnimatedVisibility( + visible = visible, + enter = slideInVertically( + initialOffsetY = { fullHeight -> fullHeight } // Slide in from bottom + ) + fadeIn(), // Fade in as well + exit = slideOutVertically( + targetOffsetY = { fullHeight -> fullHeight } // Slide out to bottom + ) + fadeOut(), // Fade out as well modifier = modifier - .fillMaxWidth() - .background( - color = colorResource(id = R.color.errorLight), - shape = RoundedCornerShape(size = 12.dp) - ) - .padding(24.dp), contentAlignment = Alignment.Center ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier.fillMaxWidth() + Box( + modifier = modifier + .fillMaxWidth() + .background( + color = colorResource(id = if (isForError) R.color.errorLight else R.color.successLight), + shape = RoundedCornerShape(size = 12.dp) + ) + .padding(24.dp), contentAlignment = Alignment.Center ) { - Image( - painter = painterResource(id = R.drawable.ic_error_cercle), - contentDescription = "error", - modifier = Modifier.size(16.dp) - ) - - Text( - text = message, - color = colorResource(id = R.color.error500), - modifier = Modifier.weight(1f), - fontFamily = FontFamily(Font(R.font.open_sans_regular_ttf)), - fontSize = 14.sp, - lineHeight = 24.sp - ) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth() + ) { + Image( + painter = painterResource(id = if (isForError) R.drawable.ic_error_cercle else R.drawable.ic_done_white), + colorFilter = ColorFilter.tint( + if (isForError) colorResource(id = R.color.error500) else colorResource( + id = R.color.successDark + ) + ), + contentDescription = "error", + modifier = Modifier.size(16.dp) + ) - TextButton(onClick = onButtonClick) { Text( - text = buttonText, - color = colorResource(id = R.color.blue900), - fontFamily = FontFamily(Font(R.font.open_sans_semibold_ttf)), + text = message, + color = if (isForError) colorResource(id = R.color.error500) else colorResource( + id = R.color.successDark + ), + modifier = Modifier.weight(1f), + fontFamily = FontFamily(Font(R.font.open_sans_regular_ttf)), fontSize = 14.sp, lineHeight = 24.sp ) + + TextButton(onClick = onButtonClick) { + Text( + text = buttonText, + color = if (isForError) colorResource(id = R.color.blue900) else colorResource( + id = R.color.successDark + ), + fontFamily = FontFamily(Font(R.font.open_sans_semibold_ttf)), + fontSize = 14.sp, + lineHeight = 24.sp + ) + } } } } @@ -72,7 +119,8 @@ fun CustomSnackbar( @Preview @Composable fun CustomSnackbarPreview() { - CustomSnackbar(message = "The entered data is invalid", + CustomSnackbar( + message = "The entered data is invalid", buttonText = "OK", onButtonClick = { /*TODO*/ }) } diff --git a/app/src/main/java/org/permanent/permanent/ui/composeComponents/DigitTextField.kt b/app/src/main/java/org/permanent/permanent/ui/composeComponents/DigitTextField.kt new file mode 100644 index 00000000..d274e16a --- /dev/null +++ b/app/src/main/java/org/permanent/permanent/ui/composeComponents/DigitTextField.kt @@ -0,0 +1,81 @@ +package org.permanent.permanent.ui.composeComponents + +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.sp +import org.permanent.permanent.R + +@Composable +fun DigitTextField( + value: String, + onValueChange: (String) -> Unit, + focusRequester: FocusRequester, + previousFocusRequester: FocusRequester?, // Add previous focus requester for backspace handling + nextFocusRequester: FocusRequester?, // Keep next focus requester to move forward + modifier: Modifier = Modifier +) { + TextField( + value = value, + onValueChange = { newValue -> + // Only allow numeric input and overwrite current value + if (newValue.length == 1 && newValue.all { it.isDigit() }) { + onValueChange(newValue) + + // Move to the next text field if not null + nextFocusRequester?.requestFocus() + } else if (newValue.isEmpty()) { + onValueChange("") // Handle deletion (clear value) + } + }, + modifier = modifier + .focusRequester(focusRequester) + .onKeyEvent { keyEvent -> + if (keyEvent.key == Key.Backspace && keyEvent.type == KeyEventType.KeyUp) { + // Handle backspace: clear the current field and move to the previous one + onValueChange("") // Clear current field + previousFocusRequester?.requestFocus() // Move to the previous field + true // Consume the event + } else { + false // Let other events be handled normally + } + }, + singleLine = true, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Number, + imeAction = if (nextFocusRequester == null) ImeAction.Done else ImeAction.Next + ), + textStyle = TextStyle( + fontSize = 24.sp, + lineHeight = 32.sp, + textAlign = TextAlign.Center, + fontWeight = FontWeight(400), + ), + colors = TextFieldDefaults.colors( + focusedTextColor = Color.White, + unfocusedTextColor = Color.White, + focusedContainerColor = colorResource(id = R.color.whiteUltraTransparent), + unfocusedContainerColor = colorResource(id = R.color.whiteUltraTransparent), + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + cursorColor = colorResource(id = R.color.blue400), + ), + maxLines = 1 + ) +} diff --git a/app/src/main/java/org/permanent/permanent/ui/login/AuthenticationFragment.kt b/app/src/main/java/org/permanent/permanent/ui/login/AuthenticationFragment.kt index d318f64a..6f94f112 100644 --- a/app/src/main/java/org/permanent/permanent/ui/login/AuthenticationFragment.kt +++ b/app/src/main/java/org/permanent/permanent/ui/login/AuthenticationFragment.kt @@ -18,16 +18,16 @@ import org.permanent.permanent.ui.PreferencesHelper import org.permanent.permanent.ui.activities.MainActivity import org.permanent.permanent.ui.archiveOnboarding.ArchiveOnboardingActivity import org.permanent.permanent.ui.login.compose.AuthenticationContainer -import org.permanent.permanent.viewmodels.LoginFragmentViewModel +import org.permanent.permanent.viewmodels.AuthenticationViewModel class AuthenticationFragment : PermanentBaseFragment() { - private lateinit var viewModel: LoginFragmentViewModel + private lateinit var viewModel: AuthenticationViewModel private lateinit var prefsHelper: PreferencesHelper override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { - viewModel = ViewModelProvider(this)[LoginFragmentViewModel::class.java] + viewModel = ViewModelProvider(this)[AuthenticationViewModel::class.java] prefsHelper = PreferencesHelper( requireContext().getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) diff --git a/app/src/main/java/org/permanent/permanent/ui/login/compose/AuthenticationContainer.kt b/app/src/main/java/org/permanent/permanent/ui/login/compose/AuthenticationContainer.kt index b8f39c9e..68e9628a 100644 --- a/app/src/main/java/org/permanent/permanent/ui/login/compose/AuthenticationContainer.kt +++ b/app/src/main/java/org/permanent/permanent/ui/login/compose/AuthenticationContainer.kt @@ -22,6 +22,7 @@ import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment @@ -44,15 +45,25 @@ import org.permanent.permanent.R import org.permanent.permanent.ui.composeComponents.ButtonColor import org.permanent.permanent.ui.composeComponents.CircularProgressIndicator import org.permanent.permanent.ui.composeComponents.CustomTextButton -import org.permanent.permanent.viewmodels.LoginFragmentViewModel +import org.permanent.permanent.viewmodels.AuthenticationViewModel @Composable fun AuthenticationContainer( - viewModel: LoginFragmentViewModel + viewModel: AuthenticationViewModel ) { val pagerState = rememberPagerState( initialPage = AuthPage.SIGN_IN.value, pageCount = { AuthPage.values().size }) + + val navigateToPage by viewModel.navigateToPage.collectAsState() + + LaunchedEffect(navigateToPage) { + navigateToPage?.let { page -> + pagerState.animateScrollToPage(page.value) + viewModel.clearPageNavigation() + } + } + val isTablet = viewModel.isTablet() val isBusyState by viewModel.isBusyState.collectAsState() @@ -96,11 +107,9 @@ fun AuthenticationContainer( } AuthPage.CODE_VERIFICATION.value -> { -// CodeVerificationPage( -// viewModel = viewModel, -// isTablet = isTablet, -// pagerState = pagerState -// ) + CodeVerificationPage( + viewModel = viewModel + ) } AuthPage.SIGN_UP.value -> { @@ -139,7 +148,7 @@ private fun LeftSideView( Box( modifier = Modifier - .width(2 * oneThirdOfScreenDp + horizontalPaddingDp) + .width(2 * oneThirdOfScreenDp) .fillMaxHeight() ) { Image( diff --git a/app/src/main/java/org/permanent/permanent/ui/login/compose/CodeVerificationPage.kt b/app/src/main/java/org/permanent/permanent/ui/login/compose/CodeVerificationPage.kt new file mode 100644 index 00000000..ff8013d4 --- /dev/null +++ b/app/src/main/java/org/permanent/permanent/ui/login/compose/CodeVerificationPage.kt @@ -0,0 +1,219 @@ +@file:OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) + +package org.permanent.permanent.ui.login.compose + +import android.content.Intent +import android.net.Uri +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.Image +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +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.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.permanent.permanent.R +import org.permanent.permanent.ui.composeComponents.ButtonColor +import org.permanent.permanent.ui.composeComponents.CenteredTextAndIconButton +import org.permanent.permanent.ui.composeComponents.CustomSnackbar +import org.permanent.permanent.ui.composeComponents.CustomTextButton +import org.permanent.permanent.ui.composeComponents.DigitTextField +import org.permanent.permanent.viewmodels.AuthenticationViewModel + +@Composable +fun CodeVerificationPage( + viewModel: AuthenticationViewModel +) { + val context = LocalContext.current + val regularFont = FontFamily(Font(R.font.open_sans_regular_ttf)) + + val snackbarMessage by viewModel.snackbarMessage.collectAsState() + val snackbarType by viewModel.snackbarType.collectAsState() + + val keyboardController = LocalSoftwareKeyboardController.current + val keyboardState by keyboardAsState() + + var codeValues by remember { + mutableStateOf(List(4) { "" }) + } + + // List of FocusRequesters for each TextField + val focusRequesters = List(4) { FocusRequester() } + + Box( + modifier = Modifier.fillMaxSize() + ) { + Column( + modifier = Modifier.fillMaxSize() + ) { + Image( + painter = painterResource(id = R.drawable.img_logo), + contentDescription = "Logo", + modifier = Modifier.size(64.dp) + ) + + Spacer(modifier = Modifier.height(32.dp)) + + Text( + text = stringResource(id = R.string.verify_your_identity_title), + fontSize = 32.sp, + lineHeight = 48.sp, + color = Color.White, + fontFamily = regularFont + ) + + Spacer(modifier = Modifier.height(32.dp)) + + Text( + text = stringResource(id = R.string.verify_your_identity_text), + fontSize = 14.sp, + lineHeight = 24.sp, + color = Color.White, + fontFamily = regularFont + ) + + Spacer(modifier = Modifier.height(32.dp)) + + if (keyboardState == Keyboard.Closed) { + Spacer(modifier = Modifier.height(64.dp)) + } + + Row( + modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween + ) { + codeValues.forEachIndexed { index, codeValue -> + DigitTextField( + value = codeValue, + onValueChange = { newValue -> + codeValues = codeValues.toMutableList().also { it[index] = newValue } + }, + focusRequester = focusRequesters[index], + previousFocusRequester = if (index > 0) focusRequesters[index - 1] else null, + nextFocusRequester = if (index < 3) focusRequesters[index + 1] else null, + modifier = Modifier + .height(64.dp) + .width(70.dp) + .border( + 1.dp, Color.White.copy(alpha = 0.29f), RoundedCornerShape(12.dp) + ) + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + CenteredTextAndIconButton( + buttonColor = ButtonColor.LIGHT, + text = stringResource(id = R.string.verify), + icon = null + ) { + keyboardController?.hide() + val code = codeValues.joinToString("") + viewModel.verifyCode(code) + } + + Spacer(modifier = Modifier.height(32.dp)) + + if (keyboardState == Keyboard.Closed) { + Spacer(modifier = Modifier.weight(1f)) + } + + Row( + verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth() + ) { + HorizontalDivider( + color = colorResource(id = R.color.colorPrimary200), + modifier = Modifier.weight(1f) + ) + + Spacer(modifier = Modifier.width(16.dp)) + + Text( + text = stringResource(id = R.string.didnt_receive_code).uppercase(), + color = colorResource(id = R.color.colorPrimary200), + fontSize = 10.sp, + lineHeight = 24.sp, + fontFamily = FontFamily(Font(R.font.open_sans_semibold_ttf)) + ) + + Spacer(modifier = Modifier.width(16.dp)) + + HorizontalDivider( + color = colorResource(id = R.color.colorPrimary200), + modifier = Modifier.weight(1f) + ) + } + + Spacer(modifier = Modifier.height(32.dp)) + + CenteredTextAndIconButton( + buttonColor = ButtonColor.TRANSPARENT, + text = stringResource(id = R.string.resend_code), + icon = null + ) { + keyboardController?.hide() + viewModel.resendCode() + } + + Spacer(modifier = Modifier.height(16.dp)) + + Box( + modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center + ) { + Text( + text = stringResource(id = R.string.or).uppercase(), + color = colorResource(id = R.color.colorPrimary200), + fontSize = 10.sp, + lineHeight = 24.sp, + fontFamily = FontFamily(Font(R.font.open_sans_semibold_ttf)) + ) + } + + CustomTextButton(text = stringResource(id = R.string.contact_support)) { + val intent = Intent( + Intent.ACTION_VIEW, + Uri.parse("https://permanent.zohodesk.com/portal/en/newticket") + ) + context.startActivity(intent) + } + } + + CustomSnackbar( + modifier = Modifier.align(Alignment.BottomCenter), + isForError = snackbarType == AuthenticationViewModel.SnackbarType.ERROR, + message = snackbarMessage, + buttonText = stringResource(id = R.string.ok), + onButtonClick = { + viewModel.clearSnackbar() + }, + ) + } +} diff --git a/app/src/main/java/org/permanent/permanent/ui/login/compose/SignInPage.kt b/app/src/main/java/org/permanent/permanent/ui/login/compose/SignInPage.kt index e18e7d93..b9ed8eff 100644 --- a/app/src/main/java/org/permanent/permanent/ui/login/compose/SignInPage.kt +++ b/app/src/main/java/org/permanent/permanent/ui/login/compose/SignInPage.kt @@ -54,16 +54,17 @@ import org.permanent.permanent.ui.composeComponents.ButtonColor import org.permanent.permanent.ui.composeComponents.CenteredTextAndIconButton import org.permanent.permanent.ui.composeComponents.CustomSnackbar import org.permanent.permanent.ui.composeComponents.CustomTextButton -import org.permanent.permanent.viewmodels.LoginFragmentViewModel +import org.permanent.permanent.viewmodels.AuthenticationViewModel @Composable fun SignInPage( - viewModel: LoginFragmentViewModel, pagerState: PagerState + viewModel: AuthenticationViewModel, pagerState: PagerState ) { val coroutineScope = rememberCoroutineScope() val regularFont = FontFamily(Font(R.font.open_sans_regular_ttf)) - val errorMessage by viewModel.showError.collectAsState() + val snackbarMessage by viewModel.snackbarMessage.collectAsState() + val snackbarType by viewModel.snackbarType.collectAsState() val keyboardController = LocalSoftwareKeyboardController.current @@ -207,7 +208,9 @@ fun SignInPage( buttonColor = ButtonColor.LIGHT, text = stringResource(id = R.string.sign_in) ) { keyboardController?.hide() - viewModel.login(emailValueState.text.trim(), passwordValueState.text.trim()) + viewModel.login( + true, emailValueState.text.trim(), passwordValueState.text.trim() + ) } Spacer(modifier = Modifier.height(32.dp)) @@ -251,7 +254,7 @@ fun SignInPage( CenteredTextAndIconButton( buttonColor = ButtonColor.TRANSPARENT, - text = stringResource(id = R.string.register), + text = stringResource(id = R.string.sign_up), icon = painterResource(id = R.drawable.ic_account_add_white) ) { coroutineScope.launch { @@ -261,14 +264,13 @@ fun SignInPage( } } - if (errorMessage.isNotEmpty()) { - CustomSnackbar(message = errorMessage, - buttonText = stringResource(id = R.string.ok), - modifier = Modifier.align(Alignment.BottomCenter), - onButtonClick = { - viewModel.clearError() - }) - } + CustomSnackbar(modifier = Modifier.align(Alignment.BottomCenter), + isForError = snackbarType == AuthenticationViewModel.SnackbarType.ERROR, + message = snackbarMessage, + buttonText = stringResource(id = R.string.ok), + onButtonClick = { + viewModel.clearSnackbar() + }) } } diff --git a/app/src/main/java/org/permanent/permanent/viewmodels/AuthenticationViewModel.kt b/app/src/main/java/org/permanent/permanent/viewmodels/AuthenticationViewModel.kt new file mode 100644 index 00000000..aeb78a69 --- /dev/null +++ b/app/src/main/java/org/permanent/permanent/viewmodels/AuthenticationViewModel.kt @@ -0,0 +1,236 @@ +package org.permanent.permanent.viewmodels + +import android.app.Application +import android.content.Context +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import org.permanent.permanent.Constants +import org.permanent.permanent.R +import org.permanent.permanent.Validator +import org.permanent.permanent.models.Archive +import org.permanent.permanent.network.IDataListener +import org.permanent.permanent.network.models.Datum +import org.permanent.permanent.repositories.ArchiveRepositoryImpl +import org.permanent.permanent.repositories.AuthenticationRepositoryImpl +import org.permanent.permanent.repositories.IArchiveRepository +import org.permanent.permanent.repositories.IAuthenticationRepository +import org.permanent.permanent.ui.PREFS_NAME +import org.permanent.permanent.ui.PreferencesHelper +import org.permanent.permanent.ui.login.compose.AuthPage + +class AuthenticationViewModel(application: Application) : ObservableAndroidViewModel(application) { + private val appContext = application.applicationContext + private val prefsHelper = PreferencesHelper( + application.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + ) + private var isTablet = false + private val onLoggedIn = SingleLiveEvent() + private val onUserMissingDefaultArchive = SingleLiveEvent() + private val _isBusyState = MutableStateFlow(false) + val isBusyState: StateFlow = _isBusyState + private val _snackbarMessage = MutableStateFlow("") + val snackbarMessage: StateFlow = _snackbarMessage + private val _snackbarType = MutableStateFlow(SnackbarType.NONE) + val snackbarType: StateFlow = _snackbarType + private val _navigateToPage = MutableStateFlow(null) + val navigateToPage: StateFlow = _navigateToPage + + private var savedEmail: String? = null + private var savedPassword: String? = null + + private var authRepository: IAuthenticationRepository = + AuthenticationRepositoryImpl(application) + private val archiveRepository: IArchiveRepository = ArchiveRepositoryImpl(application) +// private var stelaAccountRepository: StelaAccountRepository = +// StelaAccountRepositoryImpl(application) + + enum class SnackbarType { + SUCCESS, ERROR, NONE + } + + init { + isTablet = prefsHelper.isTablet() + } + + fun login(withNavigation: Boolean, email: String, password: String) { + if (_isBusyState.value) { + return + } + + if (!Validator.isValidEmail(null, email, null, null) || !Validator.isValidPassword( + password, null + ) + ) { + showErrorMessage(appContext.getString(R.string.the_entered_data_is_invalid)) + return + } + + savedEmail = email + savedPassword = password + + _isBusyState.value = true + authRepository.login(email, password, object : IAuthenticationRepository.IOnLoginListener { + override fun onSuccess() { + _isBusyState.value = false + prefsHelper.saveUserLoggedIn(true) + + val defaultArchiveId = prefsHelper.getDefaultArchiveId() + if (defaultArchiveId == 0) { + onUserMissingDefaultArchive.call() + } else { + getArchive(defaultArchiveId) + } + } + + override fun onFailed(error: String?) { + _isBusyState.value = false + when (error) { + Constants.ERROR_UNKNOWN_SIGNIN -> showErrorMessage( + appContext.getString(R.string.login_bad_credentials) + ) + + Constants.ERROR_SERVER_ERROR -> showErrorMessage( + appContext.getString(R.string.server_error) + ) + + Constants.ERROR_MFA_TOKEN -> { + prefsHelper.saveAccountEmail(email) // Save email for verification +// getTwoFAMethod() + if (withNavigation) _navigateToPage.value = AuthPage.CODE_VERIFICATION + else showSuccessMessage(appContext.getString(R.string.code_resent)) + } + + else -> { + if (error != null) { + showErrorMessage(error) + } + } + } + } + }) + } + + fun getArchive(defaultArchiveId: Int) { + archiveRepository.getAllArchives(object : IDataListener { + override fun onSuccess(dataList: List?) { + if (!dataList.isNullOrEmpty()) { + for (data in dataList) { + val archive = Archive(data.ArchiveVO) + if (defaultArchiveId == archive.id) { + prefsHelper.saveCurrentArchiveInfo( + archive.id, + archive.number, + archive.type, + archive.fullName, + archive.thumbURL200, + archive.accessRole + ) + onLoggedIn.call() + return + } + } + } + showErrorMessage(appContext.getString(R.string.generic_error)) + } + + override fun onFailed(error: String?) { + error?.let { showErrorMessage(it) } + } + }) + } + +// fun getTwoFAMethod() { +// stelaAccountRepository.getTwoFAMethod(object : IResponseListener { +// +// override fun onSuccess(message: String?) { +// _isBusyState.value = false +// _navigateToPage.value = AuthPage.CODE_VERIFICATION +// } +// +// override fun onFailed(error: String?) { +// _isBusyState.value = false +// error?.let { showErrorMessage(it) } +// } +// }) +// } + + fun resendCode() { + val email = savedEmail + val password = savedPassword + + if (email != null && password != null) { + login(false, email, password) + } else { + showErrorMessage(appContext.getString(R.string.generic_error)) + } + } + + fun verifyCode(code: String) { + if (_isBusyState.value) { + return + } + if (code.length < 4) { + showErrorMessage(appContext.getString(R.string.code_is_incorrect)) + return + } + + _isBusyState.value = true + authRepository.verifyCode(code, + Constants.AUTH_TYPE_MFA_VALIDATION, + object : IAuthenticationRepository.IOnVerifyListener { + override fun onSuccess() { + _isBusyState.value = false + onLoggedIn.call() + } + + override fun onFailed(error: String?) { + _isBusyState.value = false + showErrorMessage( + if (error.equals(Constants.ERROR_INVALID_VERIFICATION_CODE)) { + appContext.getString(R.string.code_is_incorrect) + } else if (error.equals(Constants.ERROR_EXPIRED_VERIFICATION_CODE)) { + appContext.getString(R.string.code_expired) + } else error ?: "" + ) + } + }) + } + + fun showSuccessMessage(message: String) { + clearSnackbar() + // Post the new message with a small delay to allow UI refresh + viewModelScope.launch { + delay(50) + _snackbarMessage.value = message + _snackbarType.value = SnackbarType.SUCCESS + } + } + + fun showErrorMessage(message: String) { + clearSnackbar() + // Post the new message with a small delay to allow UI refresh + viewModelScope.launch { + delay(50) + _snackbarMessage.value = message + _snackbarType.value = SnackbarType.ERROR + } + } + + fun clearSnackbar() { + _snackbarMessage.value = "" + } + + fun clearPageNavigation() { + _navigateToPage.value = null + } + + fun isTablet() = isTablet + + fun getOnUserMissingDefaultArchive(): MutableLiveData = onUserMissingDefaultArchive + + fun getOnLoggedIn(): MutableLiveData = onLoggedIn +} diff --git a/app/src/main/java/org/permanent/permanent/viewmodels/LoginFragmentViewModel.kt b/app/src/main/java/org/permanent/permanent/viewmodels/LoginFragmentViewModel.kt deleted file mode 100644 index 57a4a863..00000000 --- a/app/src/main/java/org/permanent/permanent/viewmodels/LoginFragmentViewModel.kt +++ /dev/null @@ -1,128 +0,0 @@ -package org.permanent.permanent.viewmodels - -import android.app.Application -import android.content.Context -import androidx.lifecycle.MutableLiveData -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import org.permanent.permanent.Constants -import org.permanent.permanent.R -import org.permanent.permanent.Validator -import org.permanent.permanent.models.Archive -import org.permanent.permanent.network.IDataListener -import org.permanent.permanent.network.models.Datum -import org.permanent.permanent.repositories.ArchiveRepositoryImpl -import org.permanent.permanent.repositories.AuthenticationRepositoryImpl -import org.permanent.permanent.repositories.IArchiveRepository -import org.permanent.permanent.repositories.IAuthenticationRepository -import org.permanent.permanent.ui.PREFS_NAME -import org.permanent.permanent.ui.PreferencesHelper - -class LoginFragmentViewModel(application: Application) : ObservableAndroidViewModel(application) { - private val appContext = application.applicationContext - private val prefsHelper = PreferencesHelper( - application.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) - ) - private var isTablet = false - private val onLoggedIn = SingleLiveEvent() - private val onUserMissingDefaultArchive = SingleLiveEvent() - private val _isBusyState = MutableStateFlow(false) - val isBusyState: StateFlow = _isBusyState - private val _showError = MutableStateFlow("") - val showError: StateFlow = _showError - - private var authRepository: IAuthenticationRepository = - AuthenticationRepositoryImpl(application) - private val archiveRepository: IArchiveRepository = ArchiveRepositoryImpl(application) - - init { - isTablet = prefsHelper.isTablet() - } - - fun login(email: String, password: String) { - if (_isBusyState.value) { - return - } - - if (!Validator.isValidEmail(null, email, null, null) || !Validator.isValidPassword( - password, null)) { - _showError.value = appContext.getString(R.string.the_entered_data_is_invalid) - return - } - - _isBusyState.value = true - authRepository.login(email, password, object : IAuthenticationRepository.IOnLoginListener { - override fun onSuccess() { - _isBusyState.value = false - prefsHelper.saveUserLoggedIn(true) - - val defaultArchiveId = prefsHelper.getDefaultArchiveId() - if (defaultArchiveId == 0) { - onUserMissingDefaultArchive.call() - } else { - getArchive(defaultArchiveId) - } - } - - override fun onFailed(error: String?) { - _isBusyState.value = false - when (error) { - Constants.ERROR_UNKNOWN_SIGNIN -> _showError.value = - appContext.getString(R.string.login_bad_credentials) - - Constants.ERROR_SERVER_ERROR -> _showError.value = - appContext.getString(R.string.server_error) - - Constants.ERROR_MFA_TOKEN -> { - prefsHelper.saveAccountEmail(email) // We save this here for verifyCode - // TODO: move to verifyCode page - } - else -> { - if (error != null) { - _showError.value = error - } - } - } - } - }) - } - - fun clearError() { - _showError.value = "" - } - - fun getArchive(defaultArchiveId: Int) { - archiveRepository.getAllArchives(object : IDataListener { - override fun onSuccess(dataList: List?) { - if (!dataList.isNullOrEmpty()) { - for (data in dataList) { - val archive = Archive(data.ArchiveVO) - if (defaultArchiveId == archive.id) { - prefsHelper.saveCurrentArchiveInfo( - archive.id, - archive.number, - archive.type, - archive.fullName, - archive.thumbURL200, - archive.accessRole - ) - onLoggedIn.call() - return - } - } - } - _showError.value = appContext.getString(R.string.generic_error) - } - - override fun onFailed(error: String?) { - error?.let { _showError.value = it } - } - }) - } - - fun isTablet() = isTablet - - fun getOnUserMissingDefaultArchive(): MutableLiveData = onUserMissingDefaultArchive - - fun getOnLoggedIn(): MutableLiveData = onLoggedIn -} diff --git a/app/src/main/res/layout/fragment_forgot_password.xml b/app/src/main/res/layout/fragment_forgot_password.xml index 1b60d21b..78d65f63 100644 --- a/app/src/main/res/layout/fragment_forgot_password.xml +++ b/app/src/main/res/layout/fragment_forgot_password.xml @@ -120,7 +120,7 @@ android:layout_centerHorizontal="true" android:layout_marginTop="32dp" android:padding="8dp" - android:text="@string/forgot_password_screen_or" + android:text="@string/or" android:textAllCaps="true" android:textColor="@color/whiteTransparent" android:textSize="11sp" diff --git a/app/src/main/res/layout/item_invitation.xml b/app/src/main/res/layout/item_invitation.xml index 0c47b8fb..047d4fb3 100644 --- a/app/src/main/res/layout/item_invitation.xml +++ b/app/src/main/res/layout/item_invitation.xml @@ -53,7 +53,7 @@ android:layout_marginBottom="16dp" android:paddingLeft="12dp" android:paddingRight="12dp" - android:text="@string/invitations_resend_button" + android:text="@string/resend" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@+id/tvEmail" /> diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index 8cec578c..eebd5bbf 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -33,7 +33,9 @@ #F6FEF9 #A6F4C5 #12B76A + #ECFDF3 #32D583 + #039855 #FFFFFF #D6FFFFFF #80FFFFFF diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e874ba90..18c175b8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -12,7 +12,7 @@ New to Permanent? Missing authentication token. Please try again. Use Touch ID - Or + Or Back to Sign in Recover password Ⓒ The Permanent Legacy Foundation 2020 @@ -29,16 +29,22 @@ Code No notifications Verify + Verify your identity + Please check your email or SMS for the 4-digit code and enter it below. Update Sign in Sign in to\nPermanent Email address Password - Register The entered data is invalid Haadsma Dairy Truck | The Haadsma Dairy Archive At Permanent, we celebrate our members\' hard work through our public gallery and archive spotlights. Explore our gallery and, when you\'re ready, publish or share your own public archive. Start Exploring Now + Didn\'t receive the code? + Resend code + The 4-digit code is incorrect. + The code expired. + The code was resent. Verification code can not be empty Invalid verification code. Please try again. @@ -390,7 +396,7 @@ Invite your friends and family and both of your accounts will be credited with one free gigabyte of storage when they sign up. Send New Invite Your Invitations - Resend + Resend Revoke sent resent From d1e3c240a3116ac19c4d9e730e05d1355f3f6d60 Mon Sep 17 00:00:00 2001 From: Flavia Handrea Date: Tue, 1 Oct 2024 14:25:26 +0300 Subject: [PATCH 2/2] UI/UX fixes. --- .../ui/composeComponents/CustomSnackbar.kt | 69 +++++++++++-------- .../ui/login/compose/CodeVerificationPage.kt | 22 +++--- .../permanent/ui/login/compose/SignInPage.kt | 1 + .../viewmodels/AuthenticationViewModel.kt | 18 ++++- app/src/main/res/values/colors.xml | 1 + 5 files changed, 68 insertions(+), 43 deletions(-) diff --git a/app/src/main/java/org/permanent/permanent/ui/composeComponents/CustomSnackbar.kt b/app/src/main/java/org/permanent/permanent/ui/composeComponents/CustomSnackbar.kt index 92671b11..d470c7f9 100644 --- a/app/src/main/java/org/permanent/permanent/ui/composeComponents/CustomSnackbar.kt +++ b/app/src/main/java/org/permanent/permanent/ui/composeComponents/CustomSnackbar.kt @@ -7,15 +7,16 @@ import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.Image import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -54,41 +55,38 @@ fun CustomSnackbar( } else visible = false } - AnimatedVisibility( - visible = visible, - enter = slideInVertically( - initialOffsetY = { fullHeight -> fullHeight } // Slide in from bottom + AnimatedVisibility(visible = visible, + enter = slideInVertically(initialOffsetY = { fullHeight -> fullHeight } // Slide in from bottom ) + fadeIn(), // Fade in as well - exit = slideOutVertically( - targetOffsetY = { fullHeight -> fullHeight } // Slide out to bottom + exit = slideOutVertically(targetOffsetY = { fullHeight -> fullHeight } // Slide out to bottom ) + fadeOut(), // Fade out as well - modifier = modifier - ) { + modifier = modifier) { Box( modifier = modifier .fillMaxWidth() .background( color = colorResource(id = if (isForError) R.color.errorLight else R.color.successLight), shape = RoundedCornerShape(size = 12.dp) - ) - .padding(24.dp), contentAlignment = Alignment.Center + ), contentAlignment = Alignment.Center ) { Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier.fillMaxWidth() + verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth() ) { Image( painter = painterResource(id = if (isForError) R.drawable.ic_error_cercle else R.drawable.ic_done_white), + contentDescription = "error", colorFilter = ColorFilter.tint( if (isForError) colorResource(id = R.color.error500) else colorResource( id = R.color.successDark ) ), - contentDescription = "error", - modifier = Modifier.size(16.dp) + modifier = Modifier + .padding(start = 24.dp) + .size(16.dp) ) + Spacer(modifier = Modifier.width(8.dp)) + Text( text = message, color = if (isForError) colorResource(id = R.color.error500) else colorResource( @@ -100,16 +98,30 @@ fun CustomSnackbar( lineHeight = 24.sp ) - TextButton(onClick = onButtonClick) { - Text( - text = buttonText, - color = if (isForError) colorResource(id = R.color.blue900) else colorResource( - id = R.color.successDark - ), - fontFamily = FontFamily(Font(R.font.open_sans_semibold_ttf)), - fontSize = 14.sp, - lineHeight = 24.sp - ) + if (isForError) { + Box(modifier = Modifier + .clickable { onButtonClick() } + .padding(top = 24.dp, bottom = 24.dp, end = 24.dp, start = 8.dp)) { + Text( + text = buttonText, + color = colorResource(id = R.color.blue900), + fontFamily = FontFamily(Font(R.font.open_sans_semibold_ttf)), + fontSize = 14.sp, + lineHeight = 24.sp + ) + } + } else { + Box(modifier = Modifier + .clickable { onButtonClick() } + .padding(top = 24.dp, bottom = 24.dp, end = 24.dp, start = 8.dp), + contentAlignment = Alignment.Center) { + Image( + painter = painterResource(id = R.drawable.ic_close_white), + contentDescription = "Close", + colorFilter = ColorFilter.tint(colorResource(id = R.color.blue200)), + modifier = Modifier.size(18.dp) + ) + } } } } @@ -119,8 +131,7 @@ fun CustomSnackbar( @Preview @Composable fun CustomSnackbarPreview() { - CustomSnackbar( - message = "The entered data is invalid", + CustomSnackbar(message = "The entered data is invalid", buttonText = "OK", onButtonClick = { /*TODO*/ }) } diff --git a/app/src/main/java/org/permanent/permanent/ui/login/compose/CodeVerificationPage.kt b/app/src/main/java/org/permanent/permanent/ui/login/compose/CodeVerificationPage.kt index ff8013d4..abb40649 100644 --- a/app/src/main/java/org/permanent/permanent/ui/login/compose/CodeVerificationPage.kt +++ b/app/src/main/java/org/permanent/permanent/ui/login/compose/CodeVerificationPage.kt @@ -24,9 +24,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester @@ -61,12 +59,8 @@ fun CodeVerificationPage( val keyboardController = LocalSoftwareKeyboardController.current val keyboardState by keyboardAsState() - var codeValues by remember { - mutableStateOf(List(4) { "" }) - } - - // List of FocusRequesters for each TextField - val focusRequesters = List(4) { FocusRequester() } + val codeValues by viewModel.codeValues.collectAsState() + val focusRequesters = remember { List(4) { FocusRequester() } } Box( modifier = Modifier.fillMaxSize() @@ -113,7 +107,8 @@ fun CodeVerificationPage( DigitTextField( value = codeValue, onValueChange = { newValue -> - codeValues = codeValues.toMutableList().also { it[index] = newValue } + val updatedValues = codeValues.toMutableList().also { it[index] = newValue } + viewModel.updateCodeValues(updatedValues) }, focusRequester = focusRequesters[index], previousFocusRequester = if (index > 0) focusRequesters[index - 1] else null, @@ -121,9 +116,7 @@ fun CodeVerificationPage( modifier = Modifier .height(64.dp) .width(70.dp) - .border( - 1.dp, Color.White.copy(alpha = 0.29f), RoundedCornerShape(12.dp) - ) + .border(1.dp, Color.White.copy(alpha = 0.29f), RoundedCornerShape(12.dp)) ) } } @@ -137,7 +130,10 @@ fun CodeVerificationPage( ) { keyboardController?.hide() val code = codeValues.joinToString("") - viewModel.verifyCode(code) + viewModel.verifyCode(code) { + focusRequesters[0].requestFocus() // Request focus to the first digit field after clearing the code + keyboardController?.hide() + } } Spacer(modifier = Modifier.height(32.dp)) diff --git a/app/src/main/java/org/permanent/permanent/ui/login/compose/SignInPage.kt b/app/src/main/java/org/permanent/permanent/ui/login/compose/SignInPage.kt index b9ed8eff..838cc08e 100644 --- a/app/src/main/java/org/permanent/permanent/ui/login/compose/SignInPage.kt +++ b/app/src/main/java/org/permanent/permanent/ui/login/compose/SignInPage.kt @@ -208,6 +208,7 @@ fun SignInPage( buttonColor = ButtonColor.LIGHT, text = stringResource(id = R.string.sign_in) ) { keyboardController?.hide() + viewModel.clearSnackbar() viewModel.login( true, emailValueState.text.trim(), passwordValueState.text.trim() ) diff --git a/app/src/main/java/org/permanent/permanent/viewmodels/AuthenticationViewModel.kt b/app/src/main/java/org/permanent/permanent/viewmodels/AuthenticationViewModel.kt index aeb78a69..337cad6e 100644 --- a/app/src/main/java/org/permanent/permanent/viewmodels/AuthenticationViewModel.kt +++ b/app/src/main/java/org/permanent/permanent/viewmodels/AuthenticationViewModel.kt @@ -32,6 +32,8 @@ class AuthenticationViewModel(application: Application) : ObservableAndroidViewM private val onUserMissingDefaultArchive = SingleLiveEvent() private val _isBusyState = MutableStateFlow(false) val isBusyState: StateFlow = _isBusyState + private val _codeValues = MutableStateFlow(List(4) { "" }) + val codeValues: StateFlow> = _codeValues private val _snackbarMessage = MutableStateFlow("") val snackbarMessage: StateFlow = _snackbarMessage private val _snackbarType = MutableStateFlow(SnackbarType.NONE) @@ -169,7 +171,7 @@ class AuthenticationViewModel(application: Application) : ObservableAndroidViewM } } - fun verifyCode(code: String) { + fun verifyCode(code: String, onCleared: () -> Unit) { if (_isBusyState.value) { return } @@ -189,6 +191,7 @@ class AuthenticationViewModel(application: Application) : ObservableAndroidViewM override fun onFailed(error: String?) { _isBusyState.value = false + clearCodeValues(onCleared) showErrorMessage( if (error.equals(Constants.ERROR_INVALID_VERIFICATION_CODE)) { appContext.getString(R.string.code_is_incorrect) @@ -200,6 +203,19 @@ class AuthenticationViewModel(application: Application) : ObservableAndroidViewM }) } + fun updateCodeValues(newValues: List) { + _codeValues.value = newValues + } + + private fun clearCodeValues(onCleared: () -> Unit) { + viewModelScope.launch { + _codeValues.value = List(4) { "" } + // Small delay to ensure the state update is propagated before requesting focus + delay(100) + onCleared() // Trigger the focus shift after the state update is fully reflected + } + } + fun showSuccessMessage(message: String) { clearSnackbar() // Post the new message with a small delay to allow UI refresh diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index eebd5bbf..be0753fd 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -1,6 +1,7 @@ #E7E8ED + #A1A4B7 #898DA4 #5A5F80 #131B4A