From 028a9ecc12cc2cd3f080a6ac4777e0290bed5145 Mon Sep 17 00:00:00 2001 From: Hafiz Date: Wed, 4 Jun 2025 12:00:08 -0500 Subject: [PATCH] Implement change password bottom sheet Replaces the old change password dialog with a bottom sheet using compose. --- Habitica/res/values/strings.xml | 7 +- .../preferences/AccountPreferenceFragment.kt | 37 +-- .../preferences/ChangePasswordBottomSheet.kt | 68 +++++ .../habitica/ui/views/ChangePasswordScreen.kt | 270 ++++++++++++++++++ 4 files changed, 347 insertions(+), 35 deletions(-) create mode 100644 Habitica/src/main/java/com/habitrpg/android/habitica/ui/fragments/preferences/ChangePasswordBottomSheet.kt create mode 100644 Habitica/src/main/java/com/habitrpg/android/habitica/ui/views/ChangePasswordScreen.kt diff --git a/Habitica/res/values/strings.xml b/Habitica/res/values/strings.xml index 98ee7ac2a..6ce390d7f 100644 --- a/Habitica/res/values/strings.xml +++ b/Habitica/res/values/strings.xml @@ -752,6 +752,7 @@ Login names are now unique usernames that will be visible beside your display name and used for invitations, chat @mentions, and messaging. If you’d like to learn more about this change, visit our wiki. Usernames should conform to our Terms of Service and Community Guidelines. If you didn’t previously set a login name, your username was auto-generated. + You agree to our Terms of Service and have read our Privacy Policy. Are you sure you want to confirm your current username? Confirming your username will make it public for invitations, @mentions and messaging. You can change your username from settings at any time. Cancel @@ -1582,10 +1583,8 @@ Error getting credentials for authentication. Received invalid credentials. Unknown error during authentication. - Start chatting! - Remember to be friendly and follow the Community Guidelines. - - + Passwords must be 8 characters or more. Changing your password will log you out of any other devices and third-party tools you may use. + Confirm new Password You diff --git a/Habitica/src/main/java/com/habitrpg/android/habitica/ui/fragments/preferences/AccountPreferenceFragment.kt b/Habitica/src/main/java/com/habitrpg/android/habitica/ui/fragments/preferences/AccountPreferenceFragment.kt index ba327967d..bc5c6eb36 100644 --- a/Habitica/src/main/java/com/habitrpg/android/habitica/ui/fragments/preferences/AccountPreferenceFragment.kt +++ b/Habitica/src/main/java/com/habitrpg/android/habitica/ui/fragments/preferences/AccountPreferenceFragment.kt @@ -271,46 +271,21 @@ class AccountPreferenceFragment : } private fun showChangePasswordDialog() { - val inflater = context?.layoutInflater - val view = inflater?.inflate(R.layout.dialog_edittext_change_pw, null) - val oldPasswordEditText = - view?.findViewById(R.id.old_password_edit_text) - val passwordEditText = view?.findViewById(R.id.new_password_edit_text) - passwordEditText?.validator = { (it?.length ?: 0) >= 8 } - passwordEditText?.errorText = getString(R.string.password_too_short, 8) - val passwordRepeatEditText = - view?.findViewById(R.id.new_password_repeat_edit_text) - passwordRepeatEditText?.validator = { it == passwordEditText?.text } - passwordRepeatEditText?.errorText = getString(R.string.password_not_matching) - context?.let { context -> - val dialog = HabiticaAlertDialog(context) - dialog.setTitle(R.string.change_password) - dialog.addButton(R.string.change, true, false, false) { d, _ -> + ChangePasswordBottomSheet{ oldPassword, newPassword -> + lifecycleScope.launchCatching { KeyboardUtil.dismissKeyboard(activity) - passwordEditText?.showErrorIfNecessary() - passwordRepeatEditText?.showErrorIfNecessary() - if (passwordEditText?.isValid != true || passwordRepeatEditText?.isValid != true) return@addButton lifecycleScope.launchCatching { val response = userRepository.updatePassword( - oldPasswordEditText?.text ?: "", - passwordEditText.text ?: "", - passwordRepeatEditText.text ?: "", + oldPassword, + newPassword, + newPassword, ) response?.apiToken?.let { viewModel.saveTokens(it, user?.id ?: "") } - (activity as? SnackbarActivity)?.showSnackbar( - content = context.getString(R.string.password_changed), - displayType = HabiticaSnackbar.SnackbarDisplayType.SUCCESS, - ) } - d.dismiss() } - dialog.addCancelButton() - dialog.setAdditionalContentView(view) - dialog.setAdditionalContentSidePadding(12) - dialog.show() - } + }.show(childFragmentManager, ChangePasswordBottomSheet.TAG) } private fun showAddPasswordDialog(showEmail: Boolean) { diff --git a/Habitica/src/main/java/com/habitrpg/android/habitica/ui/fragments/preferences/ChangePasswordBottomSheet.kt b/Habitica/src/main/java/com/habitrpg/android/habitica/ui/fragments/preferences/ChangePasswordBottomSheet.kt new file mode 100644 index 000000000..95aa258e5 --- /dev/null +++ b/Habitica/src/main/java/com/habitrpg/android/habitica/ui/fragments/preferences/ChangePasswordBottomSheet.kt @@ -0,0 +1,68 @@ +package com.habitrpg.android.habitica.ui.fragments.preferences + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.ComposeView +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import com.habitrpg.android.habitica.ui.views.ChangePasswordScreen +import com.habitrpg.common.habitica.theme.HabiticaTheme +import androidx.compose.runtime.setValue +import com.habitrpg.android.habitica.R + +class ChangePasswordBottomSheet(val onForgotPassword: () -> Unit = {}, val onPasswordChanged: (oldPassword: String, newPassword: String) -> Unit = { _, _ -> }) : BottomSheetDialogFragment() { + override fun onStart() { + super.onStart() + dialog?.let { dlg -> + val bottomSheet = dlg.findViewById(com.google.android.material.R.id.design_bottom_sheet) + bottomSheet?.layoutParams?.height = ViewGroup.LayoutParams.MATCH_PARENT + val behavior = com.google.android.material.bottomsheet.BottomSheetBehavior.from(bottomSheet!!) + behavior.state = com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_EXPANDED + behavior.isDraggable = false + behavior.skipCollapsed = true + } + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + return ComposeView(requireContext()).apply { + setContent { + HabiticaTheme { + var visible by remember { mutableStateOf(false) } + LaunchedEffect(Unit) { visible = true } + AnimatedVisibility( + visible = visible, + enter = fadeIn() + ) { + ChangePasswordScreen( + onBack = { dismiss() }, + onSave = { oldPassword, newPassword -> + onPasswordChanged(oldPassword, newPassword) + dismiss() + }, + onForgotPassword = { + onForgotPassword() + dismiss() + } + ) + } + } + } + } + } + + + companion object { + const val TAG = "ChangePasswordFragment" + } +} diff --git a/Habitica/src/main/java/com/habitrpg/android/habitica/ui/views/ChangePasswordScreen.kt b/Habitica/src/main/java/com/habitrpg/android/habitica/ui/views/ChangePasswordScreen.kt new file mode 100644 index 000000000..2c8803f42 --- /dev/null +++ b/Habitica/src/main/java/com/habitrpg/android/habitica/ui/views/ChangePasswordScreen.kt @@ -0,0 +1,270 @@ +package com.habitrpg.android.habitica.ui.views + +import android.content.res.Configuration +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.unit.dp +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp +import com.habitrpg.android.habitica.R +import androidx.compose.foundation.layout.Column +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.TextButton +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue +import com.habitrpg.android.habitica.ui.theme.colors +import com.habitrpg.common.habitica.theme.HabiticaTheme +import androidx.compose.material3.IconButton +import androidx.compose.material3.Icon +import androidx.compose.foundation.layout.size +import androidx.compose.material3.HorizontalDivider +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.colorResource + + + + + + + +@Composable +fun ChangePasswordScreen( + onBack: () -> Unit, + onSave: (oldPassword: String, newPassword: String) -> Unit, + onForgotPassword: () -> Unit +) { + val colors = HabiticaTheme.colors + val backgroundColor = colors.windowBackground + val fieldColor = colors.contentBackground + val labelColor = colors.textSecondary + val buttonColor = colors.tintedUiMain + val textColor = colors.textPrimary + + var oldPassword by remember { mutableStateOf("") } + var newPassword by remember { mutableStateOf("") } + var confirmPassword by remember { mutableStateOf("") } + var attemptedSave by remember { mutableStateOf(false) } + + val passwordValid = newPassword.length >= 8 + val passwordsMatch = newPassword == confirmPassword && newPassword.isNotEmpty() + val canSave = passwordValid && passwordsMatch && oldPassword.isNotBlank() + + Surface( + modifier = Modifier.fillMaxSize(), + color = backgroundColor + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 16.dp), + ) { + Spacer(modifier = Modifier.height(16.dp)) + Row( + Modifier + .fillMaxWidth() + .padding(top = 4.dp, bottom = 0.dp), + verticalAlignment = Alignment.CenterVertically + ) { + IconButton(onClick = onBack, modifier = Modifier.size(40.dp)) { + Icon( + painterResource(id = R.drawable.arrow_back), + contentDescription = stringResource(R.string.action_back), + tint = textColor + ) + } + } + + Text( + text = stringResource(R.string.change_password), + fontWeight = FontWeight.Bold, + fontSize = 28.sp, + color = textColor, + modifier = Modifier + .align(Alignment.Start) + .padding(top = 4.dp) + ) + + Text( + text = stringResource(R.string.password_change_info), + color = labelColor, + fontSize = 14.sp, + modifier = Modifier + .align(Alignment.CenterHorizontally) + .padding(top = 6.dp, bottom = 22.dp) + ) + + PasswordField( + label = stringResource(R.string.old_password), + value = oldPassword, + onValueChange = { oldPassword = it }, + fieldColor = fieldColor, + labelColor = labelColor, + textColor = textColor, + modifier = Modifier + .fillMaxWidth() + .height(56.dp) + ) + Spacer(modifier = Modifier.height(14.dp)) + PasswordField( + label = stringResource(R.string.new_password), + value = newPassword, + onValueChange = { newPassword = it }, + fieldColor = fieldColor, + labelColor = labelColor, + textColor = textColor, + isError = attemptedSave && !passwordValid, + modifier = Modifier + .fillMaxWidth() + .height(56.dp) + ) + if (attemptedSave && !passwordValid) { + Text( + text = stringResource(R.string.password_too_short), + color = Color.Red, + fontSize = 13.sp, + modifier = Modifier.padding(start = 8.dp, top = 4.dp) + ) + } + Spacer(modifier = Modifier.height(14.dp)) + PasswordField( + label = stringResource(R.string.confirm_new_password), + value = confirmPassword, + onValueChange = { confirmPassword = it }, + fieldColor = fieldColor, + labelColor = labelColor, + textColor = textColor, + isError = attemptedSave && !passwordsMatch, + modifier = Modifier + .fillMaxWidth() + .height(56.dp) + ) + if (attemptedSave && !passwordsMatch) { + Text( + text = stringResource(R.string.password_not_matching), + color = Color.Red, + fontSize = 13.sp, + modifier = Modifier.padding(start = 8.dp, top = 4.dp) + ) + } + Spacer(modifier = Modifier.height(32.dp)) + + Button( + onClick = { + attemptedSave = true + if (canSave) onSave(oldPassword, newPassword) + }, + enabled = canSave, + colors = ButtonDefaults.buttonColors( + containerColor = buttonColor, + disabledContainerColor = buttonColor.copy(alpha = 0.3f) + ), + shape = RoundedCornerShape(14.dp), + modifier = Modifier + .fillMaxWidth() + .height(52.dp) + ) { + Text( + text = stringResource(R.string.change_password), + color = Color.White, + fontSize = 17.sp, + fontWeight = FontWeight.Bold, + ) + } + Spacer(modifier = Modifier.height(18.dp)) + + TextButton( + onClick = onForgotPassword, + modifier = Modifier.align(Alignment.CenterHorizontally) + ) { + Text( + text = stringResource(R.string.forgot_pw_btn), + color = buttonColor, + fontWeight = FontWeight.Medium, + fontSize = 16.sp + ) + } + } + } +} + +@Composable +fun PasswordField( + label: String, + value: String, + onValueChange: (String) -> Unit, + fieldColor: Color, + labelColor: Color, + textColor: Color, + isError: Boolean = false, + modifier: Modifier = Modifier +) { + val dividerColor = if (value.isNotBlank()) colorResource(id = R.color.purple400_purple500) else colorResource(id = R.color.gray_400) + + Column(modifier = modifier) { + OutlinedTextField( + value = value, + onValueChange = onValueChange, + label = { + Text(label, color = labelColor, fontSize = 17.sp) + }, + singleLine = true, + modifier = Modifier.fillMaxWidth().height(56.dp), + shape = RoundedCornerShape(10.dp), + isError = isError, + visualTransformation = PasswordVisualTransformation(), + colors = OutlinedTextFieldDefaults.colors( + unfocusedContainerColor = fieldColor, + focusedContainerColor = fieldColor, + unfocusedBorderColor = Color.Transparent, + focusedBorderColor = Color.Transparent, + cursorColor = Color(0xFF9C8DF6), + unfocusedTextColor = textColor, + focusedTextColor = textColor + ) + ) + HorizontalDivider( + color = dividerColor, + thickness = 1.dp, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 2.dp) + ) + } +} + + +@Preview(showBackground = true, widthDp = 327, heightDp = 704, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +fun ChangePasswordScreenPreview() { + HabiticaTheme { + ChangePasswordScreen( + onBack = {}, + onSave = { _, _ -> }, + onForgotPassword = {} + ) + } +} + + + + +