feat(settings): ChangePasswordScreen + ViewModel with validation and typed error mapping
This commit is contained in:
parent
7fe095ef3b
commit
30000e7150
|
|
@ -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()
|
||||
}
|
||||
Loading…
Reference in New Issue