mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-10 23:21:59 +01:00
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:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user