feat(settings): enter multiple dictionary source URLs in one sheet
Allow adding and removing URL fields in the Add Source sheet, validate the whole form at once, and sanitize entered URLs.
This commit is contained in:
1 parent
c56bb4e2a6
commit
32349e2358
1 file changed
+33
-29
+33
-29
@@ -3,6 +3,9 @@ package com.example.research.ui.settings.components
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.text.input.InputTransformation
|
||||
import androidx.compose.foundation.text.input.TextFieldLineLimits
|
||||
import androidx.compose.foundation.text.input.TextFieldState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.material3.*
|
||||
@@ -16,7 +19,6 @@ import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.semantics.testTagsAsResourceId
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.example.research.R
|
||||
import com.example.research.core.domain.model.DictionarySource
|
||||
@@ -64,6 +66,20 @@ private fun validateDictionaryUrl(context: UrlValidationContext): String? {
|
||||
}
|
||||
}
|
||||
|
||||
private val urlInputTransformation = InputTransformation {
|
||||
val text = toString()
|
||||
val filtered = text
|
||||
.take(2048)
|
||||
.filter { char ->
|
||||
!char.isISOControl() &&
|
||||
char != '\u0000' &&
|
||||
char.code !in 128..<160
|
||||
}
|
||||
if (filtered != text) {
|
||||
replace(0, length, filtered)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DictionaryUrlInputContent(
|
||||
existingSources: List<DictionarySource>,
|
||||
@@ -71,7 +87,7 @@ fun DictionaryUrlInputContent(
|
||||
onCancel: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val urls = remember { mutableStateListOf(TextFieldValue("")) }
|
||||
val urls = remember { mutableStateListOf(TextFieldState("")) }
|
||||
val validationErrors = remember { mutableStateMapOf<Int, String>() }
|
||||
val scrollState = rememberScrollState()
|
||||
val focusManager = LocalFocusManager.current
|
||||
@@ -96,8 +112,8 @@ fun DictionaryUrlInputContent(
|
||||
// Cache prefixes for all input URLs to avoid repeated extraction during recomposition
|
||||
val inputUrlPrefixes = remember {
|
||||
derivedStateOf {
|
||||
urls.associate { urlValue ->
|
||||
val urlString = urlValue.text.trim()
|
||||
urls.associate { urlState ->
|
||||
val urlString = urlState.text.trim().toString()
|
||||
urlString to if (urlString.isNotEmpty()) {
|
||||
DictionarySource.extractPrefix(urlString)
|
||||
} else null
|
||||
@@ -119,10 +135,10 @@ fun DictionaryUrlInputContent(
|
||||
style = MaterialTheme.typography.headlineSmall
|
||||
)
|
||||
|
||||
urls.forEachIndexed { index, urlValue ->
|
||||
urls.forEachIndexed { index, urlState ->
|
||||
val isLastItem = index == urls.lastIndex
|
||||
|
||||
val urlString = urlValue.text.trim()
|
||||
val urlString = urlState.text.trim().toString()
|
||||
val cachedPrefix = inputUrlPrefixes.value[urlString]
|
||||
val realtimeError = remember(urlString, existingPrefixes, urls.size, cachedPrefix) {
|
||||
validateDictionaryUrl(
|
||||
@@ -130,7 +146,7 @@ fun DictionaryUrlInputContent(
|
||||
url = urlString,
|
||||
existingSources = existingSources,
|
||||
existingPrefixes = existingPrefixes,
|
||||
urlsInForm = urls.map { it.text.trim() },
|
||||
urlsInForm = urls.map { it.text.trim().toString() },
|
||||
urlPrefix = cachedPrefix,
|
||||
errorMessages = errorMessages
|
||||
)
|
||||
@@ -140,20 +156,8 @@ fun DictionaryUrlInputContent(
|
||||
val displayError = validationErrors[index] ?: realtimeError
|
||||
|
||||
OutlinedTextField(
|
||||
value = urlValue,
|
||||
onValueChange = { newValue ->
|
||||
val filtered = newValue.copy(
|
||||
text = newValue.text
|
||||
.take(2048)
|
||||
.filter { char ->
|
||||
!char.isISOControl() &&
|
||||
char != '\u0000' &&
|
||||
char.code !in 128..<160
|
||||
}
|
||||
)
|
||||
urls[index] = filtered
|
||||
validationErrors.remove(index)
|
||||
},
|
||||
state = urlState,
|
||||
inputTransformation = urlInputTransformation,
|
||||
label = { Text(if (index == 0) stringResource(R.string.dictionary_source_url_hint) else "URL ${index + 1}") },
|
||||
isError = displayError != null,
|
||||
supportingText = displayError?.let { { Text(it) } },
|
||||
@@ -168,7 +172,7 @@ fun DictionaryUrlInputContent(
|
||||
Modifier
|
||||
}
|
||||
),
|
||||
singleLine = true,
|
||||
lineLimits = TextFieldLineLimits.SingleLine,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Uri,
|
||||
imeAction = if (isLastItem) ImeAction.Done else ImeAction.Next
|
||||
@@ -188,7 +192,7 @@ fun DictionaryUrlInputContent(
|
||||
|
||||
TextButton(
|
||||
onClick = {
|
||||
urls.add(TextFieldValue(""))
|
||||
urls.add(TextFieldState(""))
|
||||
},
|
||||
modifier = Modifier
|
||||
.align(Alignment.Start)
|
||||
@@ -224,8 +228,8 @@ fun DictionaryUrlInputContent(
|
||||
val validUrls = mutableListOf<String>()
|
||||
val seenUrls = mutableSetOf<String>()
|
||||
|
||||
urls.forEachIndexed { index, urlValue ->
|
||||
val urlString = urlValue.text.trim()
|
||||
urls.forEachIndexed { index, urlState ->
|
||||
val urlString = urlState.text.trim().toString()
|
||||
if (urlString.isBlank()) {
|
||||
if (urls.size == 1) {
|
||||
validationErrors[index] = errorMessages.empty
|
||||
@@ -263,9 +267,9 @@ fun DictionaryUrlInputContent(
|
||||
.weight(1f)
|
||||
.testTag("add_confirm_button")
|
||||
.semantics { testTagsAsResourceId = true },
|
||||
enabled = urls.any { it.text.trim().isNotBlank() } &&
|
||||
urls.none { urlValue ->
|
||||
val urlString = urlValue.text.trim()
|
||||
enabled = urls.any { it.text.trim().toString().isNotBlank() } &&
|
||||
urls.none { urlState ->
|
||||
val urlString = urlState.text.trim().toString()
|
||||
if (urlString.isEmpty()) {
|
||||
false
|
||||
} else {
|
||||
@@ -275,7 +279,7 @@ fun DictionaryUrlInputContent(
|
||||
url = urlString,
|
||||
existingSources = existingSources,
|
||||
existingPrefixes = existingPrefixes,
|
||||
urlsInForm = urls.map { it.text.trim() },
|
||||
urlsInForm = urls.map { it.text.trim().toString() },
|
||||
urlPrefix = urlPrefix,
|
||||
errorMessages = errorMessages
|
||||
)
|
||||
|
||||
Reference in new issue
Block a user