diff --git a/app/src/main/kotlin/com/subtracks/ui/settings/AddSourceScreen.kt b/app/src/main/kotlin/com/subtracks/ui/settings/AddSourceScreen.kt index 6c475bb7..9e1a7fc8 100644 --- a/app/src/main/kotlin/com/subtracks/ui/settings/AddSourceScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/settings/AddSourceScreen.kt @@ -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, - ) - } } } } diff --git a/app/src/main/kotlin/com/subtracks/ui/settings/AddSourceViewModel.kt b/app/src/main/kotlin/com/subtracks/ui/settings/AddSourceViewModel.kt index 3e5efabf..89d66dc7 100644 --- a/app/src/main/kotlin/com/subtracks/ui/settings/AddSourceViewModel.kt +++ b/app/src/main/kotlin/com/subtracks/ui/settings/AddSourceViewModel.kt @@ -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 = _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( diff --git a/app/src/test/kotlin/com/subtracks/ui/settings/AddSourceValidationTest.kt b/app/src/test/kotlin/com/subtracks/ui/settings/AddSourceValidationTest.kt new file mode 100644 index 00000000..cc96bbaa --- /dev/null +++ b/app/src/test/kotlin/com/subtracks/ui/settings/AddSourceValidationTest.kt @@ -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) + } +}