fix: refine swipe-back thresholds for improved navigation responsiveness

This commit is contained in:
2026-02-09 12:59:54 +05:00
parent 1139bd6be6
commit b6e4f20c4c
4 changed files with 296 additions and 104 deletions

View File

@@ -68,6 +68,73 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
private const val TAG = "ProfileScreen"
private const val PROFILE_NAME_MAX_LENGTH = 40
private const val PROFILE_USERNAME_MIN_LENGTH = 5
private const val PROFILE_USERNAME_MAX_LENGTH = 32
private val PROFILE_NAME_ALLOWED_REGEX = Regex("^[\\p{L}\\p{N} ._'-]+\$")
private val PROFILE_USERNAME_ALLOWED_REGEX = Regex("^[A-Za-z0-9_]+\$")
private val PROFILE_ERROR_CODE_REGEX = Regex("error code:\\s*(\\d+)", RegexOption.IGNORE_CASE)
private data class ProfileSaveErrorUi(
val toastMessage: String,
val nameError: String? = null,
val usernameError: String? = null,
val generalMessage: String? = null
)
private fun mapProfileSaveError(rawError: String): ProfileSaveErrorUi {
val errorCode = PROFILE_ERROR_CODE_REGEX.find(rawError)?.groupValues?.getOrNull(1)?.toIntOrNull()
return when {
errorCode == 3 ->
ProfileSaveErrorUi(
toastMessage = "Username is unavailable. Try another one.",
usernameError = "Username is unavailable or invalid",
generalMessage = "Could not save profile: username is unavailable."
)
rawError.contains("timeout", ignoreCase = true) ->
ProfileSaveErrorUi(
toastMessage = "Server timeout. Please try again.",
generalMessage = "Server timeout. Check connection and try again."
)
rawError.contains("network", ignoreCase = true) ->
ProfileSaveErrorUi(
toastMessage = "Network error. Please try again.",
generalMessage = "Network error while saving profile."
)
else ->
ProfileSaveErrorUi(
toastMessage = "Error: $rawError",
generalMessage = "Could not save profile. Please try again."
)
}
}
private fun validateProfileName(name: String): String? {
if (name.isBlank()) return "Name can't be empty"
if (name.length > PROFILE_NAME_MAX_LENGTH) {
return "Name is too long (max $PROFILE_NAME_MAX_LENGTH)"
}
if (!PROFILE_NAME_ALLOWED_REGEX.matches(name)) {
return "Only letters, numbers, spaces, . _ - ' are allowed"
}
return null
}
private fun validateProfileUsername(username: String): String? {
if (username.isBlank()) return null // optional field
if (username.length < PROFILE_USERNAME_MIN_LENGTH) {
return "Username must be at least $PROFILE_USERNAME_MIN_LENGTH characters"
}
if (username.length > PROFILE_USERNAME_MAX_LENGTH) {
return "Username is too long (max $PROFILE_USERNAME_MAX_LENGTH)"
}
if (!PROFILE_USERNAME_ALLOWED_REGEX.matches(username)) {
return "Use only letters, numbers, and underscore"
}
return null
}
// 🎨 Avatar colors - используем те же цвета что и в ChatsListScreen
private val avatarColorsLight =
@@ -300,11 +367,35 @@ fun ProfileScreen(
var editedName by remember(accountName) { mutableStateOf(accountName) }
var editedUsername by remember(accountUsername) { mutableStateOf(accountUsername) }
var hasChanges by remember { mutableStateOf(false) }
var nameTouched by remember { mutableStateOf(false) }
var usernameTouched by remember { mutableStateOf(false) }
var showValidationErrors by remember { mutableStateOf(false) }
var serverNameError by remember { mutableStateOf<String?>(null) }
var serverUsernameError by remember { mutableStateOf<String?>(null) }
var serverGeneralError by remember { mutableStateOf<String?>(null) }
val trimmedName = editedName.trim()
val trimmedUsername = editedUsername.trim()
val nameValidationError =
if (nameTouched || showValidationErrors) validateProfileName(trimmedName) else null
val usernameValidationError =
if (usernameTouched || showValidationErrors)
validateProfileUsername(trimmedUsername)
else null
val nameError = nameValidationError ?: serverNameError
val usernameError = usernameValidationError ?: serverUsernameError
val isFormValid = nameError == null && usernameError == null
// Sync edited fields when account data changes from parent (after save)
LaunchedEffect(accountName, accountUsername) {
editedName = accountName
editedUsername = accountUsername
nameTouched = false
usernameTouched = false
showValidationErrors = false
serverNameError = null
serverUsernameError = null
serverGeneralError = null
}
// ViewModel state
@@ -546,6 +637,9 @@ fun ProfileScreen(
viewModel.updateLocalProfile(accountPublicKey, editedName, editedUsername)
hasChanges = false
serverNameError = null
serverUsernameError = null
serverGeneralError = null
viewModel.resetSaveState()
// Notify parent about profile update (updates UI in MainActivity)
@@ -555,10 +649,28 @@ fun ProfileScreen(
// Show error toast
LaunchedEffect(profileState.error) {
profileState.error?.let { error ->
profileState.error?.let { rawError ->
val mappedError = mapProfileSaveError(rawError)
serverNameError = mappedError.nameError
serverUsernameError = mappedError.usernameError
serverGeneralError =
if (mappedError.nameError == null && mappedError.usernameError == null) {
mappedError.generalMessage
} else {
null
}
if (mappedError.nameError != null) {
nameTouched = true
showValidationErrors = true
}
if (mappedError.usernameError != null) {
usernameTouched = true
showValidationErrors = true
}
android.widget.Toast.makeText(
context,
"Error: $error",
mappedError.toastMessage,
android.widget.Toast.LENGTH_SHORT
)
.show()
@@ -590,15 +702,34 @@ fun ProfileScreen(
// ═════════════════════════════════════════════════════════════
TelegramSectionTitle(title = "Account", isDarkTheme = isDarkTheme)
AnimatedVisibility(
visible = !serverGeneralError.isNullOrBlank(),
enter = fadeIn(),
exit = fadeOut()
) {
Text(
text = serverGeneralError ?: "",
fontSize = 13.sp,
color = Color(0xFFFF3B30),
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp)
)
}
// Name field
TelegramTextField(
value = editedName,
label = "Your name",
isDarkTheme = isDarkTheme,
isEditable = true,
onValueChange = { editedName = it },
onValueChange = {
editedName = it
nameTouched = true
serverNameError = null
serverGeneralError = null
},
showDivider = true,
placeholder = "Add your name"
placeholder = "Add your name",
errorText = nameError
)
// Username field
@@ -607,9 +738,15 @@ fun ProfileScreen(
label = "Username",
isDarkTheme = isDarkTheme,
isEditable = true,
onValueChange = { editedUsername = it },
onValueChange = {
editedUsername = it
usernameTouched = true
serverUsernameError = null
serverGeneralError = null
},
showDivider = true,
placeholder = "Add username"
placeholder = "Add username",
errorText = usernameError
)
// Public Key field
@@ -696,7 +833,27 @@ fun ProfileScreen(
expansionProgress = expansionProgress,
onBack = onBack,
hasChanges = hasChanges,
onSave = {
isSaveEnabled = isFormValid && !profileState.isSaving,
onSave = saveProfile@{
showValidationErrors = true
nameTouched = true
usernameTouched = true
val nameToSave = editedName.trim()
val usernameToSave = editedUsername.trim()
val localNameError = validateProfileName(nameToSave)
val localUsernameError = validateProfileUsername(usernameToSave)
if (localNameError != null || localUsernameError != null) {
return@saveProfile
}
serverNameError = null
serverUsernameError = null
serverGeneralError = null
editedName = nameToSave
editedUsername = usernameToSave
// Following desktop version logic:
// 1. Send packet to server
// 2. Wait for response in ProfileViewModel
@@ -704,8 +861,8 @@ fun ProfileScreen(
viewModel.saveProfile(
publicKey = accountPublicKey,
privateKeyHash = accountPrivateKeyHash,
name = editedName,
username = editedUsername
name = nameToSave,
username = usernameToSave
)
// Note: Local update happens in LaunchedEffect when saveSuccess is true
},
@@ -761,6 +918,7 @@ private fun CollapsingProfileHeader(
expansionProgress: Float,
onBack: () -> Unit,
hasChanges: Boolean,
isSaveEnabled: Boolean,
onSave: () -> Unit,
isDarkTheme: Boolean,
showAvatarMenu: Boolean,
@@ -892,10 +1050,17 @@ private fun CollapsingProfileHeader(
contentAlignment = Alignment.Center
) {
AnimatedVisibility(visible = hasChanges, enter = fadeIn(), exit = fadeOut()) {
TextButton(onClick = onSave) {
TextButton(onClick = onSave, enabled = isSaveEnabled) {
Text(
text = "Save",
color = if (isDarkTheme) Color.White else Color.Black,
color =
if (isSaveEnabled) {
if (isDarkTheme) Color.White else Color.Black
} else {
(if (isDarkTheme) Color.White else Color.Black).copy(
alpha = 0.45f
)
},
fontWeight = FontWeight.SemiBold
)
}
@@ -1178,14 +1343,46 @@ private fun TelegramTextField(
isEditable: Boolean = false,
onValueChange: ((String) -> Unit)? = null,
showDivider: Boolean = false,
placeholder: String = ""
placeholder: String = "",
errorText: String? = null
) {
val textColor = if (isDarkTheme) Color.White else Color.Black
val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
val dividerColor = if (isDarkTheme) Color(0xFF3A3A3A) else Color(0xFFE0E0E0)
val hasError = !errorText.isNullOrBlank()
val errorColor = Color(0xFFFF3B30)
val labelColor by
animateColorAsState(
targetValue = if (hasError) errorColor else secondaryTextColor,
label = "profile_field_label_color"
)
val containerColor by
animateColorAsState(
targetValue =
if (hasError) {
if (isDarkTheme) errorColor.copy(alpha = 0.18f)
else errorColor.copy(alpha = 0.08f)
} else {
Color.Transparent
},
label = "profile_field_container_color"
)
val borderColor by
animateColorAsState(
targetValue = if (hasError) errorColor else Color.Transparent,
label = "profile_field_border_color"
)
Column {
Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp)) {
Column(
modifier =
Modifier.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp)
.clip(RoundedCornerShape(12.dp))
.background(containerColor)
.border(1.dp, borderColor, RoundedCornerShape(12.dp))
.padding(horizontal = 12.dp, vertical = 8.dp)
) {
if (isEditable && onValueChange != null) {
BasicTextField(
value = value,
@@ -1217,7 +1414,17 @@ private fun TelegramTextField(
Spacer(modifier = Modifier.height(4.dp))
Text(text = label, fontSize = 13.sp, color = secondaryTextColor)
Text(text = label, fontSize = 13.sp, color = labelColor)
if (hasError) {
Spacer(modifier = Modifier.height(4.dp))
Text(
text = errorText ?: "",
fontSize = 12.sp,
color = errorColor,
lineHeight = 14.sp
)
}
}
if (showDivider) {