fix the server form's required-field errors

Flag the field that is actually blank instead of one combined "Name and address are
required", and focus it so it scrolls clear of the keyboard. The connection/save status
moves above the buttons in the ime-padded bottom bar, where the keyboard cannot hide it.
This commit is contained in:
austinried
2026-10-04 00:30:00 +00:00
parent d21b646858
commit 9f250d49f2
3 changed files with 79 additions and 26 deletions
@@ -28,9 +28,13 @@ import androidx.compose.material3.SwitchDefaults
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -71,6 +75,14 @@ fun AddSourceScreen(
onBack: (() -> Unit)?, onBack: (() -> Unit)?,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val nameFocus = remember { FocusRequester() }
val addressFocus = remember { FocusRequester() }
LaunchedEffect(state.nameError, state.addressError) {
when {
state.nameError -> nameFocus.requestFocus()
state.addressError -> addressFocus.requestFocus()
}
}
Scaffold( Scaffold(
modifier = modifier, modifier = modifier,
topBar = { topBar = {
@@ -86,13 +98,23 @@ fun AddSourceScreen(
) )
}, },
bottomBar = { bottomBar = {
Row( Column(
modifier = modifier =
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.imePadding() .imePadding()
.navigationBarsPadding() .navigationBarsPadding(),
.padding(16.dp), ) {
if (state.message != null) {
Text(
text = state.message,
style = MaterialTheme.typography.bodyMedium,
color = if (state.isError) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp),
)
}
Row(
modifier = Modifier.fillMaxWidth().padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.End), horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.End),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
@@ -106,6 +128,7 @@ fun AddSourceScreen(
Text("Save and sync") Text("Save and sync")
} }
} }
}
}, },
) { padding -> ) { padding ->
Column( Column(
@@ -127,7 +150,9 @@ fun AddSourceScreen(
onValueChange = onNameChange, onValueChange = onNameChange,
label = { Text("Name") }, label = { Text("Name") },
singleLine = true, singleLine = true,
modifier = Modifier.fillMaxWidth(), isError = state.nameError,
supportingText = if (state.nameError) ({ Text("Required") }) else null,
modifier = Modifier.fillMaxWidth().focusRequester(nameFocus),
) )
OutlinedTextField( OutlinedTextField(
value = state.address, value = state.address,
@@ -135,8 +160,10 @@ fun AddSourceScreen(
label = { Text("Server address") }, label = { Text("Server address") },
placeholder = { Text("https://music.example.com") }, placeholder = { Text("https://music.example.com") },
singleLine = true, singleLine = true,
isError = state.addressError,
supportingText = if (state.addressError) ({ Text("Required") }) else null,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth().focusRequester(addressFocus),
) )
OutlinedTextField( OutlinedTextField(
value = state.username, value = state.username,
@@ -177,13 +204,6 @@ fun AddSourceScreen(
), ),
) )
} }
if (state.message != null) {
Text(
text = state.message,
style = MaterialTheme.typography.bodyMedium,
color = if (state.isError) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary,
)
}
} }
} }
} }
@@ -18,6 +18,16 @@ data class AddSourceState(
val busy: Boolean = false, val busy: Boolean = false,
val message: String? = null, val message: String? = null,
val isError: Boolean = false, val isError: Boolean = false,
val nameError: Boolean = false,
val addressError: Boolean = false,
)
internal fun AddSourceState.validated(): AddSourceState =
copy(
nameError = name.isBlank(),
addressError = address.isBlank(),
message = null,
isError = false,
) )
class AddSourceViewModel( class AddSourceViewModel(
@@ -27,9 +37,9 @@ class AddSourceViewModel(
private val _state = MutableStateFlow(AddSourceState()) private val _state = MutableStateFlow(AddSourceState())
val state: StateFlow<AddSourceState> = _state val state: StateFlow<AddSourceState> = _state
fun setName(value: String) = _state.update { it.copy(name = value) } fun setName(value: String) = _state.update { it.copy(name = value, nameError = false) }
fun setAddress(value: String) = _state.update { it.copy(address = value) } fun setAddress(value: String) = _state.update { it.copy(address = value, addressError = false) }
fun setUsername(value: String) = _state.update { it.copy(username = value) } fun setUsername(value: String) = _state.update { it.copy(username = value) }
@@ -59,12 +69,13 @@ class AddSourceViewModel(
} }
fun save(onSaved: () -> Unit) { fun save(onSaved: () -> Unit) {
val current = _state.value val validated = _state.value.validated()
if (current.name.isBlank() || current.address.isBlank()) { if (validated.nameError || validated.addressError) {
_state.update { it.copy(message = "Name and address are required", isError = true) } _state.value = validated
return return
} }
_state.update { it.copy(busy = true, message = null) } _state.update { it.copy(busy = true, message = null) }
val current = _state.value
viewModelScope.launch { viewModelScope.launch {
runCatching { runCatching {
sourceRepository.addSource( sourceRepository.addSource(
@@ -0,0 +1,22 @@
package com.subtracks.ui.settings
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class AddSourceValidationTest {
@Test
fun flagsOnlyTheMissingFields() {
val missingName = AddSourceState(name = "", address = "https://music.example.com").validated()
assertTrue("a blank name should be flagged", missingName.nameError)
assertFalse("a present address should not be flagged", missingName.addressError)
val missingAddress = AddSourceState(name = "Home", address = "").validated()
assertFalse("a present name should not be flagged", missingAddress.nameError)
assertTrue("a blank address should be flagged", missingAddress.addressError)
val complete = AddSourceState(name = "Home", address = "https://music.example.com").validated()
assertFalse(complete.nameError)
assertFalse(complete.addressError)
}
}