feat(settings): ChangePasswordScreen + ViewModel with validation and typed error mapping

This commit is contained in:
null 2026-07-01 02:19:14 -05:00
parent 7fe095ef3b
commit 30000e7150
1 changed files with 303 additions and 0 deletions

View File

@ -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<ChangePasswordUiState> = _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()
}