Show inline validation errors in forms

Move several save-time validation failures from toast messages to field error states in the server and subscription editors. Password validation now marks the relevant server form field, and subscription editing highlights invalid URLs and update intervals before save instead of relying on transient toasts.
This commit is contained in:
2dust committed 2026-09-21 19:10:10 +08:00
1 parent e4c0b09e1b
commit 1ad5f30ede
8 files changed
+50 -44

No files matched your search

@@ -382,13 +382,13 @@ abstract class BaseServerActivity : BaseComponentActivity() {
protected open fun validateProtocolConfig(config: ProfileItem): Boolean = true
protected open fun validateCommonConfig(config: ProfileItem): Boolean {
protected open fun validateCommonConfig(state: ServerUiState, config: ProfileItem): Boolean {
if (config.password.isNullOrBlank()) {
state.isPasswordError = true
if (config.configType == EConfigType.VMESS ||
config.configType == EConfigType.VLESS
) {
toast(R.string.server_lab_id)
return false
}
@@ -396,7 +396,6 @@ abstract class BaseServerActivity : BaseComponentActivity() {
config.configType == EConfigType.SHADOWSOCKS ||
config.configType == EConfigType.HYSTERIA2
) {
toast(R.string.server_lab_id3)
return false
}
}
@@ -422,7 +421,7 @@ abstract class BaseServerActivity : BaseComponentActivity() {
protected fun saveServer(state: ServerUiState): Boolean {
if (!validateBasicConfig(state)) return false
val config = state.toProfileItem(initialConfig)
if (!validateCommonConfig(config)) return false
if (!validateCommonConfig(state, config)) return false
if (!validateProtocolConfig(config)) return false
config.description = AngConfigManager.generateDescription(config)
@@ -1,14 +1,12 @@
package com.v2ray.ang.ui.server
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.res.stringResource
import com.v2ray.ang.AppConfig
import com.v2ray.ang.R
import com.v2ray.ang.dto.entities.ProfileItem
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.extension.toast
import com.v2ray.ang.ui.compose.FormTextField
import com.v2ray.ang.ui.compose.SettingsSwitchItem
@@ -18,7 +16,6 @@ class ServerHysteria2Activity : BaseServerActivity() {
@Composable
override fun ScreenContent() {
val scope = rememberCoroutineScope()
val uiState = rememberSaveable(saver = ServerUiState.Saver) {
ServerUiState.from(
initialConfig = initialConfig
@@ -39,7 +36,6 @@ class ServerHysteria2Activity : BaseServerActivity() {
override fun validateProtocolConfig(config: ProfileItem): Boolean {
if (config.password.isNullOrBlank()) {
toast(R.string.server_lab_id3)
return false
}
if (config.security.isNullOrBlank()) {
@@ -51,9 +47,10 @@ class ServerHysteria2Activity : BaseServerActivity() {
@Composable
private fun Hysteria2ProtocolFields(state: ServerUiState) {
FormTextField(
stringResource(R.string.server_lab_id3),
state.password,
{ state.password = it }
label = stringResource(R.string.server_lab_id3),
value = state.password,
onValueChange = { state.password = it },
isError = state.isPasswordError
)
FormTextField(
stringResource(R.string.server_obfs_password),
@@ -52,9 +52,10 @@ class ServerShadowsocksActivity : BaseServerActivity() {
methodOptions: List<String>
) {
FormTextField(
stringResource(R.string.server_lab_id3),
state.password,
{ state.password = it }
label = stringResource(R.string.server_lab_id3),
value = state.password,
onValueChange = { state.password = it },
isError = state.isPasswordError
)
FormDropdownField(
stringResource(R.string.server_lab_security),
@@ -7,7 +7,6 @@ import androidx.compose.ui.res.stringResource
import com.v2ray.ang.R
import com.v2ray.ang.dto.entities.ProfileItem
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.extension.toast
import com.v2ray.ang.ui.compose.FormTextField
class ServerTrojanActivity : BaseServerActivity() {
@@ -44,11 +43,9 @@ class ServerTrojanActivity : BaseServerActivity() {
override fun validateProtocolConfig(config: ProfileItem): Boolean {
if (config.password.isNullOrBlank()) {
toast(R.string.server_lab_id3)
return false
}
if (config.security.isNullOrBlank()) {
toast(R.string.server_lab_stream_security)
return false
}
return true
@@ -57,9 +54,10 @@ class ServerTrojanActivity : BaseServerActivity() {
@Composable
private fun TrojanProtocolFields(state: ServerUiState) {
FormTextField(
stringResource(R.string.server_lab_id3),
state.password,
{ state.password = it }
label = stringResource(R.string.server_lab_id3),
value = state.password,
onValueChange = { state.password = it },
isError = state.isPasswordError
)
}
}
@@ -117,6 +117,7 @@ class ServerUiState(
var isRemarksError by mutableStateOf(false)
var isAddressError by mutableStateOf(false)
var isPortError by mutableStateOf(false)
var isPasswordError by mutableStateOf(false)
fun toProfileItem(initialConfig: ProfileItem): ProfileItem {
val isVmess = configType == EConfigType.VMESS
@@ -8,7 +8,6 @@ import androidx.compose.ui.res.stringResource
import com.v2ray.ang.R
import com.v2ray.ang.dto.entities.ProfileItem
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.extension.toast
import com.v2ray.ang.ui.compose.FormDropdownField
import com.v2ray.ang.ui.compose.FormTextField
@@ -47,7 +46,6 @@ class ServerVlessActivity : BaseServerActivity() {
override fun validateProtocolConfig(config: ProfileItem): Boolean {
if (config.password.isNullOrBlank()) {
toast(R.string.server_lab_id)
return false
}
return true
@@ -59,9 +57,10 @@ class ServerVlessActivity : BaseServerActivity() {
flowOptions: List<String>
) {
FormTextField(
stringResource(R.string.server_lab_id),
state.password,
{ state.password = it }
label = stringResource(R.string.server_lab_id),
value = state.password,
onValueChange = { state.password = it },
isError = state.isPasswordError
)
FormTextField(
stringResource(R.string.server_lab_encryption),
@@ -8,7 +8,6 @@ import androidx.compose.ui.res.stringResource
import com.v2ray.ang.R
import com.v2ray.ang.dto.entities.ProfileItem
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.extension.toast
import com.v2ray.ang.ui.compose.FormDropdownField
import com.v2ray.ang.ui.compose.FormTextField
@@ -47,7 +46,6 @@ class ServerVmessActivity : BaseServerActivity() {
override fun validateProtocolConfig(config: ProfileItem): Boolean {
if (config.password.isNullOrBlank()) {
toast(R.string.server_lab_id)
return false
}
return true
@@ -59,9 +57,10 @@ class ServerVmessActivity : BaseServerActivity() {
methodOptions: List<String>
) {
FormTextField(
stringResource(R.string.server_lab_id),
state.password,
{ state.password = it }
label = stringResource(R.string.server_lab_id),
value = state.password,
onValueChange = { state.password = it },
isError = state.isPasswordError
)
FormDropdownField(
stringResource(R.string.server_lab_security),
@@ -29,7 +29,6 @@ import com.v2ray.ang.R
import com.v2ray.ang.dto.entities.SubscriptionItem
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.extension.toLongEx
import com.v2ray.ang.extension.toast
import com.v2ray.ang.extension.toastSuccess
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.SettingsChangeManager
@@ -78,25 +77,19 @@ class SubEditActivity : BaseComponentActivity() {
}
private fun saveServer(subItem: SubscriptionItem): Boolean {
if (TextUtils.isEmpty(subItem.remarks)) {
return false
}
if (subItem.url.isNotEmpty()) {
if (!Utils.isValidUrl(subItem.url)) {
toast(R.string.toast_invalid_url)
return false
}
if (!Utils.isValidSubUrl(subItem.url)) {
toast(R.string.toast_insecure_url_protocol)
if (!subItem.allowInsecureUrl) {
return false
}
if (!Utils.isValidSubUrl(subItem.url) && !subItem.allowInsecureUrl) {
return false
}
}
if (subItem.autoUpdate && subItem.updateInterval < AppConfig.SUBSCRIPTION_MIN_INTERVAL_MINUTES) {
toast(R.string.toast_invalid_update_interval)
return false
}
@@ -133,12 +126,14 @@ fun SubEditScreen(
var remarks by rememberSaveable { mutableStateOf(initial.remarks.orEmpty()) }
var isRemarksError by rememberSaveable { mutableStateOf(false) }
var url by rememberSaveable { mutableStateOf(initial.url.orEmpty()) }
var isUrlError by rememberSaveable { mutableStateOf(false) }
var userAgent by rememberSaveable { mutableStateOf(initial.userAgent.orEmpty()) }
var requestHeaders by rememberSaveable { mutableStateOf(initial.requestHeaders.orEmpty()) }
var filter by rememberSaveable { mutableStateOf(initial.filter ?: "") }
var enabled by rememberSaveable { mutableStateOf(initial.enabled) }
var autoUpdate by rememberSaveable { mutableStateOf(initial.autoUpdate) }
var updateInterval by rememberSaveable { mutableStateOf(initial.updateInterval.toString()) }
var isUpdateIntervalError by rememberSaveable { mutableStateOf(false) }
var allowInsecureUrl by rememberSaveable { mutableStateOf(initial.allowInsecureUrl) }
var prevProfile by rememberSaveable { mutableStateOf(initial.prevProfile ?: "") }
var nextProfile by rememberSaveable { mutableStateOf(initial.nextProfile ?: "") }
@@ -179,9 +174,16 @@ fun SubEditScreen(
}
IconButton(onClick = {
val remarksErr = remarks.isBlank()
isRemarksError = remarksErr
val urlErr = url.isNotEmpty() && (
!Utils.isValidUrl(url) || (!Utils.isValidSubUrl(url) && !allowInsecureUrl)
)
val intervalErr = autoUpdate && updateInterval.toLongEx() < AppConfig.SUBSCRIPTION_MIN_INTERVAL_MINUTES
val hasError = remarksErr
isRemarksError = remarksErr
isUrlError = urlErr
isUpdateIntervalError = intervalErr
val hasError = remarksErr || urlErr || intervalErr
if (!hasError) {
onSave(buildSubItem())
}
@@ -209,7 +211,13 @@ fun SubEditScreen(
onValueChange = { remarks = it },
isError = isRemarksError
)
FormTextField(stringResource(R.string.sub_setting_url), url, { url = it })
FormTextField(
label = stringResource(R.string.sub_setting_url),
value = url,
onValueChange = { url = it },
isError = isUrlError,
supportingText = if (isUrlError) stringResource(R.string.toast_invalid_url) else null
)
FormTextField(stringResource(R.string.sub_setting_user_agent), userAgent, { userAgent = it })
FormTextField(stringResource(R.string.sub_setting_request_headers), requestHeaders, { requestHeaders = it })
FormTextField(stringResource(R.string.sub_setting_filter), filter, { filter = it })
@@ -226,8 +234,12 @@ fun SubEditScreen(
)
FormTextField(
stringResource(R.string.title_pref_auto_update_interval),
updateInterval, { updateInterval = it }, keyboardType = KeyboardType.Number
label = stringResource(R.string.title_pref_auto_update_interval),
value = updateInterval,
onValueChange = { updateInterval = it },
keyboardType = KeyboardType.Number,
isError = isUpdateIntervalError,
supportingText = if (isUpdateIntervalError) stringResource(R.string.toast_invalid_update_interval) else null
)
SettingsSwitchItem(