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.layout.*
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
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.foundation.verticalScroll
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
@@ -16,7 +19,6 @@ import androidx.compose.ui.semantics.semantics
|
|||||||
import androidx.compose.ui.semantics.testTagsAsResourceId
|
import androidx.compose.ui.semantics.testTagsAsResourceId
|
||||||
import androidx.compose.ui.text.input.ImeAction
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
import androidx.compose.ui.text.input.TextFieldValue
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.example.research.R
|
import com.example.research.R
|
||||||
import com.example.research.core.domain.model.DictionarySource
|
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
|
@Composable
|
||||||
fun DictionaryUrlInputContent(
|
fun DictionaryUrlInputContent(
|
||||||
existingSources: List<DictionarySource>,
|
existingSources: List<DictionarySource>,
|
||||||
@@ -71,7 +87,7 @@ fun DictionaryUrlInputContent(
|
|||||||
onCancel: () -> Unit,
|
onCancel: () -> Unit,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
val urls = remember { mutableStateListOf(TextFieldValue("")) }
|
val urls = remember { mutableStateListOf(TextFieldState("")) }
|
||||||
val validationErrors = remember { mutableStateMapOf<Int, String>() }
|
val validationErrors = remember { mutableStateMapOf<Int, String>() }
|
||||||
val scrollState = rememberScrollState()
|
val scrollState = rememberScrollState()
|
||||||
val focusManager = LocalFocusManager.current
|
val focusManager = LocalFocusManager.current
|
||||||
@@ -96,8 +112,8 @@ fun DictionaryUrlInputContent(
|
|||||||
// Cache prefixes for all input URLs to avoid repeated extraction during recomposition
|
// Cache prefixes for all input URLs to avoid repeated extraction during recomposition
|
||||||
val inputUrlPrefixes = remember {
|
val inputUrlPrefixes = remember {
|
||||||
derivedStateOf {
|
derivedStateOf {
|
||||||
urls.associate { urlValue ->
|
urls.associate { urlState ->
|
||||||
val urlString = urlValue.text.trim()
|
val urlString = urlState.text.trim().toString()
|
||||||
urlString to if (urlString.isNotEmpty()) {
|
urlString to if (urlString.isNotEmpty()) {
|
||||||
DictionarySource.extractPrefix(urlString)
|
DictionarySource.extractPrefix(urlString)
|
||||||
} else null
|
} else null
|
||||||
@@ -119,10 +135,10 @@ fun DictionaryUrlInputContent(
|
|||||||
style = MaterialTheme.typography.headlineSmall
|
style = MaterialTheme.typography.headlineSmall
|
||||||
)
|
)
|
||||||
|
|
||||||
urls.forEachIndexed { index, urlValue ->
|
urls.forEachIndexed { index, urlState ->
|
||||||
val isLastItem = index == urls.lastIndex
|
val isLastItem = index == urls.lastIndex
|
||||||
|
|
||||||
val urlString = urlValue.text.trim()
|
val urlString = urlState.text.trim().toString()
|
||||||
val cachedPrefix = inputUrlPrefixes.value[urlString]
|
val cachedPrefix = inputUrlPrefixes.value[urlString]
|
||||||
val realtimeError = remember(urlString, existingPrefixes, urls.size, cachedPrefix) {
|
val realtimeError = remember(urlString, existingPrefixes, urls.size, cachedPrefix) {
|
||||||
validateDictionaryUrl(
|
validateDictionaryUrl(
|
||||||
@@ -130,7 +146,7 @@ fun DictionaryUrlInputContent(
|
|||||||
url = urlString,
|
url = urlString,
|
||||||
existingSources = existingSources,
|
existingSources = existingSources,
|
||||||
existingPrefixes = existingPrefixes,
|
existingPrefixes = existingPrefixes,
|
||||||
urlsInForm = urls.map { it.text.trim() },
|
urlsInForm = urls.map { it.text.trim().toString() },
|
||||||
urlPrefix = cachedPrefix,
|
urlPrefix = cachedPrefix,
|
||||||
errorMessages = errorMessages
|
errorMessages = errorMessages
|
||||||
)
|
)
|
||||||
@@ -140,20 +156,8 @@ fun DictionaryUrlInputContent(
|
|||||||
val displayError = validationErrors[index] ?: realtimeError
|
val displayError = validationErrors[index] ?: realtimeError
|
||||||
|
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = urlValue,
|
state = urlState,
|
||||||
onValueChange = { newValue ->
|
inputTransformation = urlInputTransformation,
|
||||||
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)
|
|
||||||
},
|
|
||||||
label = { Text(if (index == 0) stringResource(R.string.dictionary_source_url_hint) else "URL ${index + 1}") },
|
label = { Text(if (index == 0) stringResource(R.string.dictionary_source_url_hint) else "URL ${index + 1}") },
|
||||||
isError = displayError != null,
|
isError = displayError != null,
|
||||||
supportingText = displayError?.let { { Text(it) } },
|
supportingText = displayError?.let { { Text(it) } },
|
||||||
@@ -168,7 +172,7 @@ fun DictionaryUrlInputContent(
|
|||||||
Modifier
|
Modifier
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
singleLine = true,
|
lineLimits = TextFieldLineLimits.SingleLine,
|
||||||
keyboardOptions = KeyboardOptions(
|
keyboardOptions = KeyboardOptions(
|
||||||
keyboardType = KeyboardType.Uri,
|
keyboardType = KeyboardType.Uri,
|
||||||
imeAction = if (isLastItem) ImeAction.Done else ImeAction.Next
|
imeAction = if (isLastItem) ImeAction.Done else ImeAction.Next
|
||||||
@@ -188,7 +192,7 @@ fun DictionaryUrlInputContent(
|
|||||||
|
|
||||||
TextButton(
|
TextButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
urls.add(TextFieldValue(""))
|
urls.add(TextFieldState(""))
|
||||||
},
|
},
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.align(Alignment.Start)
|
.align(Alignment.Start)
|
||||||
@@ -224,8 +228,8 @@ fun DictionaryUrlInputContent(
|
|||||||
val validUrls = mutableListOf<String>()
|
val validUrls = mutableListOf<String>()
|
||||||
val seenUrls = mutableSetOf<String>()
|
val seenUrls = mutableSetOf<String>()
|
||||||
|
|
||||||
urls.forEachIndexed { index, urlValue ->
|
urls.forEachIndexed { index, urlState ->
|
||||||
val urlString = urlValue.text.trim()
|
val urlString = urlState.text.trim().toString()
|
||||||
if (urlString.isBlank()) {
|
if (urlString.isBlank()) {
|
||||||
if (urls.size == 1) {
|
if (urls.size == 1) {
|
||||||
validationErrors[index] = errorMessages.empty
|
validationErrors[index] = errorMessages.empty
|
||||||
@@ -263,9 +267,9 @@ fun DictionaryUrlInputContent(
|
|||||||
.weight(1f)
|
.weight(1f)
|
||||||
.testTag("add_confirm_button")
|
.testTag("add_confirm_button")
|
||||||
.semantics { testTagsAsResourceId = true },
|
.semantics { testTagsAsResourceId = true },
|
||||||
enabled = urls.any { it.text.trim().isNotBlank() } &&
|
enabled = urls.any { it.text.trim().toString().isNotBlank() } &&
|
||||||
urls.none { urlValue ->
|
urls.none { urlState ->
|
||||||
val urlString = urlValue.text.trim()
|
val urlString = urlState.text.trim().toString()
|
||||||
if (urlString.isEmpty()) {
|
if (urlString.isEmpty()) {
|
||||||
false
|
false
|
||||||
} else {
|
} else {
|
||||||
@@ -275,7 +279,7 @@ fun DictionaryUrlInputContent(
|
|||||||
url = urlString,
|
url = urlString,
|
||||||
existingSources = existingSources,
|
existingSources = existingSources,
|
||||||
existingPrefixes = existingPrefixes,
|
existingPrefixes = existingPrefixes,
|
||||||
urlsInForm = urls.map { it.text.trim() },
|
urlsInForm = urls.map { it.text.trim().toString() },
|
||||||
urlPrefix = urlPrefix,
|
urlPrefix = urlPrefix,
|
||||||
errorMessages = errorMessages
|
errorMessages = errorMessages
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in new issue
Block a user