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:
@@ -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"}",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user