feat(android): Settings — Profile + Password cards

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) <noreply@anthropic.com>
This commit is contained in:
2026-05-26 20:15:01 -04:00
parent f18b7d4658
commit 14c5262ed7
4 changed files with 382 additions and 0 deletions
@@ -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),
)
}
@@ -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<PasswordUiState> = 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"}",
)
}
}
}
}
}
@@ -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,
)
}
}
@@ -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<ProfileUiState> = 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"}",
)
}
}
}
}
}