From 30000e71505b11b67b5cbbee4e8194fac11c2ae2 Mon Sep 17 00:00:00 2001 From: null Date: Wed, 1 Jul 2026 02:19:14 -0500 Subject: [PATCH] feat(settings): ChangePasswordScreen + ViewModel with validation and typed error mapping --- .../ui/settings/ChangePasswordScreen.kt | 303 ++++++++++++++++++ 1 file changed, 303 insertions(+) create mode 100644 app/src/main/java/app/closer/ui/settings/ChangePasswordScreen.kt diff --git a/app/src/main/java/app/closer/ui/settings/ChangePasswordScreen.kt b/app/src/main/java/app/closer/ui/settings/ChangePasswordScreen.kt new file mode 100644 index 00000000..79161d9b --- /dev/null +++ b/app/src/main/java/app/closer/ui/settings/ChangePasswordScreen.kt @@ -0,0 +1,303 @@ +package app.closer.ui.settings + +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.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawingPadding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import app.closer.domain.repository.AuthRepository +import app.closer.domain.repository.ChangePasswordException +import app.closer.ui.components.CloserGlyphs +import app.closer.ui.components.CloserHeartLoader +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +data class ChangePasswordUiState( + val currentPassword: String = "", + val newPassword: String = "", + val confirmPassword: String = "", + val passwordsVisible: Boolean = false, + val isLoading: Boolean = false, + val error: String? = null, + val done: Boolean = false +) + +@HiltViewModel +class ChangePasswordViewModel @Inject constructor( + private val authRepository: AuthRepository +) : ViewModel() { + + private val _uiState = MutableStateFlow(ChangePasswordUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + fun updateCurrent(v: String) = _uiState.update { it.copy(currentPassword = v, error = null) } + fun updateNew(v: String) = _uiState.update { it.copy(newPassword = v, error = null) } + fun updateConfirm(v: String) = _uiState.update { it.copy(confirmPassword = v, error = null) } + fun toggleVisibility() = _uiState.update { it.copy(passwordsVisible = !it.passwordsVisible) } + fun dismissError() = _uiState.update { it.copy(error = null) } + + fun submit() { + val s = _uiState.value + if (s.isLoading) return + validate(s)?.let { message -> + _uiState.update { it.copy(error = message) } + return + } + _uiState.update { it.copy(isLoading = true, error = null) } + viewModelScope.launch { + authRepository.changePassword(s.currentPassword, s.newPassword) + .onSuccess { + // Don't keep the plaintext passwords around once we're done with them. + _uiState.update { + it.copy( + isLoading = false, + done = true, + currentPassword = "", + newPassword = "", + confirmPassword = "" + ) + } + } + .onFailure { e -> _uiState.update { it.copy(isLoading = false, error = friendlyError(e)) } } + } + } + + /** Client-side validation mirroring the sign-up policy; returns an error message or null if valid. */ + private fun validate(s: ChangePasswordUiState): String? = when { + s.currentPassword.isBlank() -> "Enter your current password." + s.newPassword.length < 8 -> "New password must be at least 8 characters." + !s.newPassword.any { it.isLetter() } || !s.newPassword.any { it.isDigit() } -> + "New password must include both letters and numbers." + s.newPassword != s.confirmPassword -> "New passwords don't match." + s.newPassword == s.currentPassword -> "New password must be different from your current one." + else -> null + } + + private fun friendlyError(e: Throwable): String = when (e) { + is ChangePasswordException.WrongCurrentPassword -> "Current password is incorrect." + is ChangePasswordException.WeakNewPassword -> + "That password is too weak — use at least 8 characters with letters and numbers." + is ChangePasswordException.TooManyAttempts -> "Too many attempts. Please wait a bit and try again." + is ChangePasswordException.ReauthRequired -> + "For your security, please sign out and back in, then change your password." + is ChangePasswordException.NoPassword -> + "This account signs in with Google — there's no password to change." + else -> e.message ?: "Couldn't change your password. Please try again." + } +} + +@Composable +fun ChangePasswordScreen( + onNavigate: (String) -> Unit = {}, + viewModel: ChangePasswordViewModel = hiltViewModel() +) { + val state by viewModel.uiState.collectAsState() + + SettingsSubpage(title = "Change password", onBack = { onNavigate("back") }) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .safeDrawingPadding() + .navigationBarsPadding() + .verticalScroll(rememberScrollState()) + .padding(padding) + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + if (state.done) { + Spacer(Modifier.height(32.dp)) + Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { + Surface( + shape = RoundedCornerShape(50), + color = SettingsPrimary.copy(alpha = 0.16f), + modifier = Modifier.size(72.dp) + ) { + Box(contentAlignment = Alignment.Center) { + Icon( + CloserGlyphs.Check, + contentDescription = null, + tint = SettingsPrimaryDeep, + modifier = Modifier.size(32.dp) + ) + } + } + } + Spacer(Modifier.height(16.dp)) + Text( + "Password updated", + style = MaterialTheme.typography.headlineSmall, + color = SettingsInk, + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center + ) + Text( + "Your new password is set. Use it next time you sign in — this device stays signed in.", + style = MaterialTheme.typography.bodyMedium, + color = SettingsMuted, + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center + ) + Spacer(Modifier.height(16.dp)) + Button( + onClick = { onNavigate("back") }, + modifier = Modifier.fillMaxWidth().height(52.dp), + shape = RoundedCornerShape(12.dp), + colors = ButtonDefaults.buttonColors( + containerColor = SettingsPrimary, + contentColor = SettingsOnPrimary + ) + ) { + Text("Done", fontWeight = FontWeight.SemiBold) + } + return@Column + } + + Text( + text = "Enter your current password, then choose a new one.", + style = MaterialTheme.typography.bodyMedium, + color = SettingsMuted + ) + + val transform: VisualTransformation = + if (state.passwordsVisible) VisualTransformation.None else PasswordVisualTransformation() + + val fieldColors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = SettingsPrimary, + unfocusedBorderColor = SettingsMuted.copy(alpha = 0.4f), + cursorColor = SettingsPrimary, + focusedLabelColor = SettingsPrimary, + focusedTextColor = SettingsInk, + unfocusedTextColor = SettingsInk + ) + + OutlinedTextField( + value = state.currentPassword, + onValueChange = viewModel::updateCurrent, + label = { Text("Current password") }, + singleLine = true, + visualTransformation = transform, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), + isError = state.error != null, + colors = fieldColors, + modifier = Modifier.fillMaxWidth() + ) + OutlinedTextField( + value = state.newPassword, + onValueChange = viewModel::updateNew, + label = { Text("New password") }, + singleLine = true, + visualTransformation = transform, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), + isError = state.error != null, + colors = fieldColors, + modifier = Modifier.fillMaxWidth() + ) + OutlinedTextField( + value = state.confirmPassword, + onValueChange = viewModel::updateConfirm, + label = { Text("Confirm new password") }, + singleLine = true, + visualTransformation = transform, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), + isError = state.error != null, + colors = fieldColors, + modifier = Modifier.fillMaxWidth() + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "At least 8 characters, with letters and numbers.", + style = MaterialTheme.typography.bodySmall, + color = SettingsMuted, + modifier = Modifier.weight(1f) + ) + TextButton(onClick = viewModel::toggleVisibility) { + Text( + if (state.passwordsVisible) "Hide" else "Show", + color = SettingsPrimaryDeep + ) + } + } + + state.error?.let { err -> + Text( + text = err, + style = MaterialTheme.typography.bodySmall, + color = SettingsDanger + ) + } + + Spacer(Modifier.height(8.dp)) + + Button( + onClick = viewModel::submit, + enabled = !state.isLoading, + modifier = Modifier.fillMaxWidth().height(52.dp), + shape = RoundedCornerShape(12.dp), + colors = ButtonDefaults.buttonColors( + containerColor = SettingsPrimary, + contentColor = SettingsOnPrimary + ) + ) { + if (state.isLoading) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { + CloserHeartLoader(size = 20.dp) + Text("Updating…") + } + } else { + Text("Update password", fontWeight = FontWeight.SemiBold) + } + } + } + } +} + +@Preview +@Composable +fun ChangePasswordScreenPreview() { + ChangePasswordScreen() +}