From 14c5262ed792ed45138d17813273ed4b720a3dbd Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 26 May 2026 20:15:01 -0400 Subject: [PATCH] =?UTF-8?q?feat(android):=20Settings=20=E2=80=94=20Profile?= =?UTF-8?q?=20+=20Password=20cards?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit v2 priority #4 remainder. Profile card loads MyProfile via MeRepository.getProfile, exposes Display Name + Email TextFields, and Save calls updateProfile + re-hydrates the form from the canonical server response. Password card has the standard three- field form (current / new / confirm) with client-side new==confirm guard before hitting MeRepository.changePassword. Both cards surface status inline (text below the action button) rather than snackbar so they stay self-contained — Settings doesn't own a screen-level Scaffold snackbar host for forms. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../minstrel/settings/ui/PasswordCard.kt | 96 +++++++++++++++ .../minstrel/settings/ui/PasswordViewModel.kt | 70 +++++++++++ .../minstrel/settings/ui/ProfileCard.kt | 105 +++++++++++++++++ .../minstrel/settings/ui/ProfileViewModel.kt | 111 ++++++++++++++++++ 4 files changed, 382 insertions(+) create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/settings/ui/PasswordCard.kt create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/settings/ui/PasswordViewModel.kt create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/settings/ui/ProfileCard.kt create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/settings/ui/ProfileViewModel.kt diff --git a/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/PasswordCard.kt b/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/PasswordCard.kt new file mode 100644 index 00000000..f54e2b02 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/PasswordCard.kt @@ -0,0 +1,96 @@ +package com.fabledsword.minstrel.settings.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle + +@Composable +fun PasswordCard(viewModel: PasswordViewModel = hiltViewModel()) { + val state by viewModel.state.collectAsStateWithLifecycle() + ElevatedCard(modifier = Modifier.fillMaxWidth()) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = "Password", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + PasswordField( + value = state.current, + label = "Current password", + onChange = viewModel::setCurrent, + ) + PasswordField( + value = state.next, + label = "New password", + onChange = viewModel::setNext, + ) + PasswordField( + value = state.confirm, + label = "Confirm new password", + onChange = viewModel::setConfirm, + ) + Spacer(Modifier.height(4.dp)) + Button( + onClick = viewModel::change, + enabled = !state.isChanging, + modifier = Modifier.fillMaxWidth(), + ) { + if (state.isChanging) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onPrimary, + ) + Spacer(Modifier.size(8.dp)) + } + Text(if (state.isChanging) "Changing…" else "Change password") + } + if (state.message != null) { + Text( + text = state.message!!, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + +@Composable +private fun PasswordField( + value: String, + label: String, + onChange: (String) -> Unit, +) { + OutlinedTextField( + value = value, + onValueChange = onChange, + label = { Text(label) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), + ) +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/PasswordViewModel.kt b/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/PasswordViewModel.kt new file mode 100644 index 00000000..c980f2ef --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/PasswordViewModel.kt @@ -0,0 +1,70 @@ +package com.fabledsword.minstrel.settings.ui + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.fabledsword.minstrel.settings.data.MeRepository +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 PasswordUiState( + val current: String = "", + val next: String = "", + val confirm: String = "", + val isChanging: Boolean = false, + val message: String? = null, +) + +/** + * Backs the password-change card in Settings. Three-field form + * (current / new / confirm) with client-side new==confirm check + * before hitting [MeRepository.changePassword]. Inline status text + * for both success and error. + */ +@HiltViewModel +class PasswordViewModel @Inject constructor( + private val repository: MeRepository, +) : ViewModel() { + + private val internal = MutableStateFlow(PasswordUiState()) + val state: StateFlow = internal.asStateFlow() + + fun setCurrent(value: String) = internal.update { it.copy(current = value, message = null) } + fun setNext(value: String) = internal.update { it.copy(next = value, message = null) } + fun setConfirm(value: String) = internal.update { it.copy(confirm = value, message = null) } + + fun change() { + val s = internal.value + if (s.isChanging) return + if (s.current.isEmpty() || s.next.isEmpty()) { + internal.update { it.copy(message = "All fields required.") } + return + } + if (s.next != s.confirm) { + internal.update { it.copy(message = "New passwords do not match.") } + return + } + viewModelScope.launch { + internal.update { it.copy(isChanging = true, message = null) } + try { + repository.changePassword(current = s.current, next = s.next) + internal.update { + PasswordUiState(message = "Password changed.") + } + } catch ( + @Suppress("TooGenericExceptionCaught") e: Throwable, + ) { + internal.update { + it.copy( + isChanging = false, + message = "Couldn't change password: ${e.message ?: "unknown error"}", + ) + } + } + } + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/ProfileCard.kt b/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/ProfileCard.kt new file mode 100644 index 00000000..f76661fd --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/ProfileCard.kt @@ -0,0 +1,105 @@ +package com.fabledsword.minstrel.settings.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle + +@Composable +fun ProfileCard(viewModel: ProfileViewModel = hiltViewModel()) { + val state by viewModel.state.collectAsStateWithLifecycle() + ElevatedCard(modifier = Modifier.fillMaxWidth()) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = "Profile", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + if (state.isLoading) { + Box( + modifier = Modifier.fillMaxWidth().padding(16.dp), + contentAlignment = Alignment.Center, + ) { CircularProgressIndicator() } + } else { + ProfileForm( + displayName = state.displayName, + email = state.email, + isSaving = state.isSaving, + message = state.message, + onDisplayNameChange = viewModel::setDisplayName, + onEmailChange = viewModel::setEmail, + onSave = viewModel::save, + ) + } + } + } +} + +@Composable +private fun ProfileForm( + displayName: String, + email: String, + isSaving: Boolean, + message: String?, + onDisplayNameChange: (String) -> Unit, + onEmailChange: (String) -> Unit, + onSave: () -> Unit, +) { + OutlinedTextField( + value = displayName, + onValueChange = onDisplayNameChange, + label = { Text("Display name") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + OutlinedTextField( + value = email, + onValueChange = onEmailChange, + label = { Text("Email (for password reset)") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + Spacer(Modifier.height(4.dp)) + Button( + onClick = onSave, + enabled = !isSaving, + modifier = Modifier.fillMaxWidth(), + ) { + if (isSaving) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onPrimary, + ) + Spacer(Modifier.size(8.dp)) + } + Text(if (isSaving) "Saving…" else "Save profile") + } + if (message != null) { + Text( + text = message, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/ProfileViewModel.kt b/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/ProfileViewModel.kt new file mode 100644 index 00000000..c684ca7f --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/ProfileViewModel.kt @@ -0,0 +1,111 @@ +package com.fabledsword.minstrel.settings.ui + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.fabledsword.minstrel.models.MyProfile +import com.fabledsword.minstrel.settings.data.MeRepository +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 ProfileUiState( + val isLoading: Boolean = true, + val isSaving: Boolean = false, + val displayName: String = "", + val email: String = "", + val loadedProfile: MyProfile? = null, + val message: String? = null, +) + +/** + * Backs the Profile card in Settings. Fetches the caller's profile + * on init via [MeRepository.getProfile]; submit calls + * [MeRepository.updateProfile] and re-hydrates the form fields from + * the canonical server response. + * + * Errors surface via [ProfileUiState.message] (inline status text + * below the Save button) rather than snackbar so the card stays + * self-contained — Settings doesn't host a screen-level Scaffold + * snackbar for the form cards. + */ +@HiltViewModel +class ProfileViewModel @Inject constructor( + private val repository: MeRepository, +) : ViewModel() { + + private val internal = MutableStateFlow(ProfileUiState()) + val state: StateFlow = internal.asStateFlow() + + init { + refresh() + } + + fun setDisplayName(value: String) { + internal.update { it.copy(displayName = value) } + } + + fun setEmail(value: String) { + internal.update { it.copy(email = value) } + } + + fun refresh() { + viewModelScope.launch { + internal.update { it.copy(isLoading = true, message = null) } + try { + val profile = repository.getProfile() + internal.update { + it.copy( + isLoading = false, + loadedProfile = profile, + displayName = profile.displayName.orEmpty(), + email = profile.email.orEmpty(), + ) + } + } catch ( + @Suppress("TooGenericExceptionCaught") e: Throwable, + ) { + internal.update { + it.copy( + isLoading = false, + message = "Couldn't load profile: ${e.message ?: "unknown error"}", + ) + } + } + } + } + + fun save() { + if (internal.value.isSaving) return + viewModelScope.launch { + internal.update { it.copy(isSaving = true, message = null) } + try { + val updated = repository.updateProfile( + displayName = internal.value.displayName.trim(), + email = internal.value.email.trim(), + ) + internal.update { + it.copy( + isSaving = false, + loadedProfile = updated, + displayName = updated.displayName.orEmpty(), + email = updated.email.orEmpty(), + message = "Profile saved.", + ) + } + } catch ( + @Suppress("TooGenericExceptionCaught") e: Throwable, + ) { + internal.update { + it.copy( + isSaving = false, + message = "Couldn't save: ${e.message ?: "unknown error"}", + ) + } + } + } + } +}