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:
OneWay committed 2026-10-04 12:12:00 +00:00
1 parent c56bb4e2a6
commit 32349e2358
1 file changed
+34 -30
@@ -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
)