diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt index a7e7ca5..d422de0 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt @@ -1949,32 +1949,7 @@ fun ChatDetailScreen( // 📸 Отправляем фото с caption напрямую showMediaPicker = false inputFocusTrigger++ - scope.launch { - val base64 = - MediaUtils.uriToBase64Image( - context, - mediaItem.uri - ) - val blurhash = - MediaUtils.generateBlurhash( - context, - mediaItem.uri - ) - val (width, height) = - MediaUtils.getImageDimensions( - context, - mediaItem.uri - ) - if (base64 != null) { - viewModel.sendImageMessage( - base64, - blurhash, - caption, - width, - height - ) - } - } + viewModel.sendImageFromUri(mediaItem.uri, caption) }, onOpenCamera = { // 📷 Открываем встроенную камеру (без системного превью!) diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt index 3264d48..e97d12e 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt @@ -41,6 +41,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { private const val DECRYPT_CHUNK_SIZE = 15 // Расшифровываем по 15 сообщений за раз private const val DECRYPT_PARALLELISM = 4 // Параллельная расшифровка private const val MAX_CACHE_SIZE = 500 // 🔥 Максимум сообщений в кэше для защиты от OOM + private val backgroundUploadScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) // 🔥 ГЛОБАЛЬНЫЙ кэш сообщений на уровне диалогов (dialogKey -> List) // Сделан глобальным чтобы можно было очистить при удалении диалога @@ -86,6 +87,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { // 🔥 Кэш расшифрованных сообщений (messageId -> plainText) private val decryptionCache = ConcurrentHashMap() + @Volatile private var isCleared = false // Информация о собеседнике private var opponentTitle: String = "" @@ -1759,13 +1761,11 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { addMessageSafely(optimisticMessage) _inputText.value = "" - // 2. 💾 СРАЗУ сохраняем в БД со статусом SENDING (delivered = 0) - // Чтобы при выходе из диалога сообщение не пропадало - viewModelScope.launch(Dispatchers.IO) { + // 2. 🔄 В фоне, независимо от жизненного цикла экрана: + // сохраняем optimistic в БД -> конвертируем -> загружаем -> отправляем пакет. + backgroundUploadScope.launch { try { - // Сохраняем с localUri и размерами в attachments для восстановления при возврате в - // чат - val attachmentsJson = + val optimisticAttachmentsJson = JSONArray() .apply { put( @@ -1774,66 +1774,52 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { put("type", AttachmentType.IMAGE.value) put("preview", "") put("blob", "") - put( - "width", - imageWidth - ) // 🔥 Сохраняем размеры в БД - put( - "height", - imageHeight - ) // 🔥 Сохраняем размеры в БД - put( - "localUri", - imageUri.toString() - ) // 🔥 Сохраняем localUri + put("width", imageWidth) + put("height", imageHeight) + put("localUri", imageUri.toString()) } ) } .toString() - // Сохраняем optimistic сообщение в БД saveMessageToDatabase( messageId = messageId, text = text, - encryptedContent = "", // Пустой пока не зашифровали + encryptedContent = "", encryptedKey = "", timestamp = timestamp, isFromMe = true, - delivered = 0, // SENDING - attachmentsJson = attachmentsJson + delivered = 0, + attachmentsJson = optimisticAttachmentsJson, + opponentPublicKey = recipient ) - } catch (e: Exception) { - // Игнорируем ошибку - главное что в UI показали + } catch (_: Exception) { } - } - // 3. 🔄 В ФОНЕ: конвертируем, генерируем blurhash и отправляем - viewModelScope.launch(Dispatchers.IO) { try { - // Получаем размеры изображения val (width, height) = com.rosetta.messenger.utils.MediaUtils.getImageDimensions(context, imageUri) - // Конвертируем в base64 val imageBase64 = com.rosetta.messenger.utils.MediaUtils.uriToBase64Image(context, imageUri) if (imageBase64 == null) { - withContext(Dispatchers.Main) { - updateMessageStatus(messageId, MessageStatus.ERROR) + if (!isCleared) { + withContext(Dispatchers.Main) { + updateMessageStatus(messageId, MessageStatus.ERROR) + } } return@launch } - // Генерируем blurhash val blurhash = com.rosetta.messenger.utils.MediaUtils.generateBlurhash(context, imageUri) - // 4. 🔄 Обновляем optimistic сообщение с реальными данными - withContext(Dispatchers.Main) { - updateOptimisticImageMessage(messageId, imageBase64, blurhash, width, height) + if (!isCleared) { + withContext(Dispatchers.Main) { + updateOptimisticImageMessage(messageId, imageBase64, blurhash, width, height) + } } - // 5. 📤 Отправляем (шифрование + загрузка на сервер) sendImageMessageInternal( messageId = messageId, imageBase64 = imageBase64, @@ -1846,9 +1832,11 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { sender = sender, privateKey = privateKey ) - } catch (e: Exception) { - withContext(Dispatchers.Main) { - updateMessageStatus(messageId, MessageStatus.ERROR) + } catch (_: Exception) { + if (!isCleared) { + withContext(Dispatchers.Main) { + updateMessageStatus(messageId, MessageStatus.ERROR) + } } } } @@ -1991,7 +1979,11 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { updateMessageAttachments(messageId, null) } - saveDialog(if (caption.isNotEmpty()) caption else "photo", timestamp) + saveDialog( + lastMessage = if (caption.isNotEmpty()) caption else "photo", + timestamp = timestamp, + opponentPublicKey = recipient + ) } catch (e: Exception) { withContext(Dispatchers.Main) { updateMessageStatus(messageId, MessageStatus.ERROR) } } @@ -2053,7 +2045,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { addMessageSafely(optimisticMessage) _inputText.value = "" - viewModelScope.launch(Dispatchers.IO) { + backgroundUploadScope.launch { try { // Шифрование текста val encryptResult = MessageCrypto.encryptForSending(text, recipient) @@ -2144,7 +2136,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { timestamp = timestamp, isFromMe = true, delivered = if (isSavedMessages) 2 else 0, // SENDING для обычных - attachmentsJson = attachmentsJson + attachmentsJson = attachmentsJson, + opponentPublicKey = recipient ) // 🔥 После успешной отправки обновляем статус на SENT (2) в БД и UI @@ -2154,7 +2147,11 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { withContext(Dispatchers.Main) { updateMessageStatus(messageId, MessageStatus.SENT) } - saveDialog(if (text.isNotEmpty()) text else "photo", timestamp) + saveDialog( + lastMessage = if (text.isNotEmpty()) text else "photo", + timestamp = timestamp, + opponentPublicKey = recipient + ) } catch (e: Exception) { withContext(Dispatchers.Main) { updateMessageStatus(messageId, MessageStatus.SENT) } } finally { @@ -2234,7 +2231,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { addMessageSafely(optimisticMessage) _inputText.value = "" - viewModelScope.launch(Dispatchers.IO) { + backgroundUploadScope.launch { try { // Шифрование текста val encryptResult = MessageCrypto.encryptForSending(text, recipient) @@ -2326,7 +2323,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { timestamp = timestamp, isFromMe = true, delivered = if (isSavedMessages) 2 else 0, - attachmentsJson = attachmentsJsonArray.toString() + attachmentsJson = attachmentsJsonArray.toString(), + opponentPublicKey = recipient ) // 🔥 Обновляем статус в БД после отправки @@ -2337,7 +2335,11 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { // Обновляем UI withContext(Dispatchers.Main) { updateMessageStatus(messageId, MessageStatus.SENT) } - saveDialog(if (text.isNotEmpty()) text else "📷 ${images.size} photos", timestamp) + saveDialog( + lastMessage = if (text.isNotEmpty()) text else "📷 ${images.size} photos", + timestamp = timestamp, + opponentPublicKey = recipient + ) } catch (e: Exception) { withContext(Dispatchers.Main) { updateMessageStatus(messageId, MessageStatus.SENT) } } finally { @@ -2724,9 +2726,9 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { * Сохранить диалог в базу данных 🔥 Используем updateDialogFromMessages для пересчета счетчиков * из messages 📁 SAVED MESSAGES: Использует специальный метод для saved messages */ - private suspend fun saveDialog(lastMessage: String, timestamp: Long) { + private suspend fun saveDialog(lastMessage: String, timestamp: Long, opponentPublicKey: String? = null) { val account = myPublicKey ?: return - val opponent = opponentKey ?: return + val opponent = opponentPublicKey ?: opponentKey ?: return try { // 🔥 КРИТИЧНО: Используем updateDialogFromMessages который пересчитывает счетчики @@ -3076,6 +3078,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { override fun onCleared() { super.onCleared() + isCleared = true // 🔥 КРИТИЧНО: Удаляем обработчики пакетов чтобы избежать накопления и дубликатов ProtocolManager.unwaitPacket(0x0B, typingPacketHandler) diff --git a/app/src/main/java/com/rosetta/messenger/ui/components/SwipeBackContainer.kt b/app/src/main/java/com/rosetta/messenger/ui/components/SwipeBackContainer.kt index bd78890..b14489b 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/components/SwipeBackContainer.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/components/SwipeBackContainer.kt @@ -23,12 +23,15 @@ import kotlinx.coroutines.launch // Telegram's CubicBezierInterpolator(0.25, 0.1, 0.25, 1.0) private val TelegramEasing = CubicBezierEasing(0.25f, 0.1f, 0.25f, 1.0f) -// Swipe-back thresholds (Telegram-like) -private const val COMPLETION_THRESHOLD = 0.2f // 20% of screen width — very easy to complete -private const val FLING_VELOCITY_THRESHOLD = 150f // px/s — very sensitive to flings +// Swipe-back thresholds tuned for ultra-light navigation. +private const val COMPLETION_THRESHOLD = 0.05f // 5% of screen width +private const val FLING_VELOCITY_THRESHOLD = 35f // px/s private const val ANIMATION_DURATION_ENTER = 300 private const val ANIMATION_DURATION_EXIT = 200 -private const val EDGE_ZONE_DP = 200 +private const val EDGE_ZONE_DP = 320 +private const val EDGE_ZONE_WIDTH_FRACTION = 0.85f +private const val TOUCH_SLOP_FACTOR = 0.35f +private const val HORIZONTAL_DOMINANCE_RATIO = 1.05f /** * Telegram-style swipe back container (optimized) @@ -40,7 +43,7 @@ private const val EDGE_ZONE_DP = 200 * - Smooth Telegram-style bezier animation * - Scrim (dimming) on background * - Shadow on left edge during swipe - * - Threshold: 50% of width OR 600px/s fling velocity + * - Low completion threshold for comfortable one-handed back swipe */ @Composable fun SwipeBackContainer( @@ -60,6 +63,7 @@ fun SwipeBackContainer( val configuration = LocalConfiguration.current val screenWidthPx = with(density) { configuration.screenWidthDp.dp.toPx() } val edgeZonePx = with(density) { EDGE_ZONE_DP.dp.toPx() } + val effectiveEdgeZonePx = maxOf(edgeZonePx, screenWidthPx * EDGE_ZONE_WIDTH_FRACTION) // Animation state for swipe (used only for swipe animations, not during drag) val offsetAnimatable = remember { Animatable(0f) } @@ -148,7 +152,9 @@ fun SwipeBackContainer( if (swipeEnabled && !isAnimatingIn && !isAnimatingOut) { Modifier.pointerInput(Unit) { val velocityTracker = VelocityTracker() - val touchSlop = viewConfiguration.touchSlop + val touchSlop = + viewConfiguration.touchSlop * + TOUCH_SLOP_FACTOR awaitEachGesture { val down = @@ -157,7 +163,7 @@ fun SwipeBackContainer( ) // Edge-only detection - if (down.position.x > edgeZonePx) { + if (down.position.x > effectiveEdgeZonePx) { return@awaitEachGesture } @@ -205,7 +211,8 @@ fun SwipeBackContainer( ) > kotlin.math.abs( totalDragY - ) * 1.5f + ) * + HORIZONTAL_DOMINANCE_RATIO ) { passedSlop = true startedSwipe = true @@ -255,15 +262,15 @@ fun SwipeBackContainer( dragOffset / screenWidthPx val shouldComplete = - currentProgress > - 0.5f || // Past 50% — always - // complete - velocity > - FLING_VELOCITY_THRESHOLD || // Fast fling right - (currentProgress > - COMPLETION_THRESHOLD && + currentProgress >= + COMPLETION_THRESHOLD || + velocity > + FLING_VELOCITY_THRESHOLD || // Fast fling right + (currentProgress >= + COMPLETION_THRESHOLD * + 0.5f && velocity > - -FLING_VELOCITY_THRESHOLD) // 20%+ and not flinging back + -FLING_VELOCITY_THRESHOLD * 4f) // Complete by default once swipe starts unless user strongly throws back scope.launch { offsetAnimatable.snapTo(dragOffset) diff --git a/app/src/main/java/com/rosetta/messenger/ui/settings/ProfileScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/settings/ProfileScreen.kt index caec63a..8fe2dc9 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/settings/ProfileScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/settings/ProfileScreen.kt @@ -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(null) } + var serverUsernameError by remember { mutableStateOf(null) } + var serverGeneralError by remember { mutableStateOf(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) {