fix: refine swipe-back thresholds for improved navigation responsiveness

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

View File

@@ -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 = {
// 📷 Открываем встроенную камеру (без системного превью!)

View File

@@ -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<ChatMessage>)
// Сделан глобальным чтобы можно было очистить при удалении диалога
@@ -86,6 +87,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
// 🔥 Кэш расшифрованных сообщений (messageId -> plainText)
private val decryptionCache = ConcurrentHashMap<String, String>()
@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)

View File

@@ -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)

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) {