mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-11 07:29:14 +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.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,24 +98,35 @@ fun AddSourceScreen(
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
bottomBar = {
|
bottomBar = {
|
||||||
Row(
|
Column(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.imePadding()
|
.imePadding()
|
||||||
.navigationBarsPadding()
|
.navigationBarsPadding(),
|
||||||
.padding(16.dp),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.End),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
) {
|
||||||
if (state.busy) {
|
if (state.message != null) {
|
||||||
CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp)
|
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) {
|
Row(
|
||||||
Text("Test connection")
|
modifier = Modifier.fillMaxWidth().padding(16.dp),
|
||||||
}
|
horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.End),
|
||||||
Button(onClick = onSave, enabled = !state.busy) {
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
Text("Save and sync")
|
) {
|
||||||
|
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,
|
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,8 +18,18 @@ 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(
|
||||||
private val sourceRepository: SourceRepository,
|
private val sourceRepository: SourceRepository,
|
||||||
private val syncManager: SyncManager,
|
private val syncManager: SyncManager,
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user