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.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
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.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
@@ -71,6 +75,14 @@ fun AddSourceScreen(
onBack: (() -> Unit)?,
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(
modifier = modifier,
topBar = {
@@ -86,24 +98,35 @@ fun AddSourceScreen(
)
},
bottomBar = {
Row(
Column(
modifier =
Modifier
.fillMaxWidth()
.imePadding()
.navigationBarsPadding()
.padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.End),
verticalAlignment = Alignment.CenterVertically,
.navigationBarsPadding(),
) {
if (state.busy) {
CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.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),
)
}
OutlinedButton(onClick = onTest, enabled = !state.busy) {
Text("Test connection")
}
Button(onClick = onSave, enabled = !state.busy) {
Text("Save and sync")
Row(
modifier = Modifier.fillMaxWidth().padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.End),
verticalAlignment = Alignment.CenterVertically,
) {
if (state.busy) {
CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp)
}
OutlinedButton(onClick = onTest, enabled = !state.busy) {
Text("Test connection")
}
Button(onClick = onSave, enabled = !state.busy) {
Text("Save and sync")
}
}
}
},
@@ -127,7 +150,9 @@ fun AddSourceScreen(
onValueChange = onNameChange,
label = { Text("Name") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
isError = state.nameError,
supportingText = if (state.nameError) ({ Text("Required") }) else null,
modifier = Modifier.fillMaxWidth().focusRequester(nameFocus),
)
OutlinedTextField(
value = state.address,
@@ -135,8 +160,10 @@ fun AddSourceScreen(
label = { Text("Server address") },
placeholder = { Text("https://music.example.com") },
singleLine = true,
isError = state.addressError,
supportingText = if (state.addressError) ({ Text("Required") }) else null,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
modifier = Modifier.fillMaxWidth(),
modifier = Modifier.fillMaxWidth().focusRequester(addressFocus),
)
OutlinedTextField(
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,8 +18,18 @@ data class AddSourceState(
val busy: Boolean = false,
val message: String? = null,
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(
private val sourceRepository: SourceRepository,
private val syncManager: SyncManager,
@@ -27,9 +37,9 @@ class AddSourceViewModel(
private val _state = MutableStateFlow(AddSourceState())
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) }
@@ -59,12 +69,13 @@ class AddSourceViewModel(
}
fun save(onSaved: () -> Unit) {
val current = _state.value
if (current.name.isBlank() || current.address.isBlank()) {
_state.update { it.copy(message = "Name and address are required", isError = true) }
val validated = _state.value.validated()
if (validated.nameError || validated.addressError) {
_state.value = validated
return
}
_state.update { it.copy(busy = true, message = null) }
val current = _state.value
viewModelScope.launch {
runCatching {
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)
}
}