Implement change password bottom sheet

Replaces the old change password dialog with a bottom sheet using compose.
This commit is contained in:
Hafiz 2025-06-04 12:00:08 -05:00 committed by Phillip Thelen
parent ab8015b487
commit 028a9ecc12
4 changed files with 347 additions and 35 deletions

View file

@ -752,6 +752,7 @@
<string name="usernamePromptBody">Login names are now unique usernames that will be visible beside your display name and used for invitations, chat @mentions, and messaging.</string>
<string name="usernamePromptWiki">If youd like to learn more about this change, <a href="https://habitica.wikia.com/wiki/Player_Names">visit our wiki.</a></string>
<string name="usernamePromptDisclaimer">Usernames should conform to our <a href="https://habitica.com/static/terms">Terms of Service</a> and <a href="https://habitica.com/static/community-guidelines">Community Guidelines</a>. If you didnt previously set a login name, your username was auto-generated.</string>
<string name="register_tos_confirm">You agree to our <a href="https://habitica.com/static/terms">Terms of Service</a> and have read our <a href="https://habitica.com/static/privacy">Privacy Policy</a>.</string>
<string name="confirm_username_title">Are you sure you want to confirm your current username?</string>
<string name="confirm_username_description">Confirming your username will make it public for invitations, @mentions and messaging. You can change your username from settings at any time.</string>
<string name="cancel">Cancel</string>
@ -1582,10 +1583,8 @@
<string name="auth_get_credentials_error">Error getting credentials for authentication.</string>
<string name="auth_invalid_credentials">Received invalid credentials.</string>
<string name="auth_unknown_error">Unknown error during authentication.</string>
<string name="chat_empty_state_title">Start chatting!</string>
<string name="chat_empty_state_description">Remember to be friendly and follow the Community Guidelines.</string>
<string name="password_change_info">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.</string>
<string name="confirm_new_password">Confirm new Password</string>
<plurals name="you_x_others">
<item quantity="zero">You</item>

View file

@ -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<ValidatingEditText>(R.id.old_password_edit_text)
val passwordEditText = view?.findViewById<ValidatingEditText>(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<ValidatingEditText>(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) {

View file

@ -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<View>(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"
}
}

View file

@ -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 = {}
)
}
}