fix: refine swipe-back thresholds for improved navigation responsiveness
This commit is contained in:
@@ -1949,32 +1949,7 @@ fun ChatDetailScreen(
|
|||||||
// 📸 Отправляем фото с caption напрямую
|
// 📸 Отправляем фото с caption напрямую
|
||||||
showMediaPicker = false
|
showMediaPicker = false
|
||||||
inputFocusTrigger++
|
inputFocusTrigger++
|
||||||
scope.launch {
|
viewModel.sendImageFromUri(mediaItem.uri, caption)
|
||||||
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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
onOpenCamera = {
|
onOpenCamera = {
|
||||||
// 📷 Открываем встроенную камеру (без системного превью!)
|
// 📷 Открываем встроенную камеру (без системного превью!)
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
private const val DECRYPT_CHUNK_SIZE = 15 // Расшифровываем по 15 сообщений за раз
|
private const val DECRYPT_CHUNK_SIZE = 15 // Расшифровываем по 15 сообщений за раз
|
||||||
private const val DECRYPT_PARALLELISM = 4 // Параллельная расшифровка
|
private const val DECRYPT_PARALLELISM = 4 // Параллельная расшифровка
|
||||||
private const val MAX_CACHE_SIZE = 500 // 🔥 Максимум сообщений в кэше для защиты от OOM
|
private const val MAX_CACHE_SIZE = 500 // 🔥 Максимум сообщений в кэше для защиты от OOM
|
||||||
|
private val backgroundUploadScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
|
|
||||||
// 🔥 ГЛОБАЛЬНЫЙ кэш сообщений на уровне диалогов (dialogKey -> List<ChatMessage>)
|
// 🔥 ГЛОБАЛЬНЫЙ кэш сообщений на уровне диалогов (dialogKey -> List<ChatMessage>)
|
||||||
// Сделан глобальным чтобы можно было очистить при удалении диалога
|
// Сделан глобальным чтобы можно было очистить при удалении диалога
|
||||||
@@ -86,6 +87,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
|
|
||||||
// 🔥 Кэш расшифрованных сообщений (messageId -> plainText)
|
// 🔥 Кэш расшифрованных сообщений (messageId -> plainText)
|
||||||
private val decryptionCache = ConcurrentHashMap<String, String>()
|
private val decryptionCache = ConcurrentHashMap<String, String>()
|
||||||
|
@Volatile private var isCleared = false
|
||||||
|
|
||||||
// Информация о собеседнике
|
// Информация о собеседнике
|
||||||
private var opponentTitle: String = ""
|
private var opponentTitle: String = ""
|
||||||
@@ -1759,13 +1761,11 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
addMessageSafely(optimisticMessage)
|
addMessageSafely(optimisticMessage)
|
||||||
_inputText.value = ""
|
_inputText.value = ""
|
||||||
|
|
||||||
// 2. 💾 СРАЗУ сохраняем в БД со статусом SENDING (delivered = 0)
|
// 2. 🔄 В фоне, независимо от жизненного цикла экрана:
|
||||||
// Чтобы при выходе из диалога сообщение не пропадало
|
// сохраняем optimistic в БД -> конвертируем -> загружаем -> отправляем пакет.
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
backgroundUploadScope.launch {
|
||||||
try {
|
try {
|
||||||
// Сохраняем с localUri и размерами в attachments для восстановления при возврате в
|
val optimisticAttachmentsJson =
|
||||||
// чат
|
|
||||||
val attachmentsJson =
|
|
||||||
JSONArray()
|
JSONArray()
|
||||||
.apply {
|
.apply {
|
||||||
put(
|
put(
|
||||||
@@ -1774,66 +1774,52 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
put("type", AttachmentType.IMAGE.value)
|
put("type", AttachmentType.IMAGE.value)
|
||||||
put("preview", "")
|
put("preview", "")
|
||||||
put("blob", "")
|
put("blob", "")
|
||||||
put(
|
put("width", imageWidth)
|
||||||
"width",
|
put("height", imageHeight)
|
||||||
imageWidth
|
put("localUri", imageUri.toString())
|
||||||
) // 🔥 Сохраняем размеры в БД
|
|
||||||
put(
|
|
||||||
"height",
|
|
||||||
imageHeight
|
|
||||||
) // 🔥 Сохраняем размеры в БД
|
|
||||||
put(
|
|
||||||
"localUri",
|
|
||||||
imageUri.toString()
|
|
||||||
) // 🔥 Сохраняем localUri
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.toString()
|
.toString()
|
||||||
|
|
||||||
// Сохраняем optimistic сообщение в БД
|
|
||||||
saveMessageToDatabase(
|
saveMessageToDatabase(
|
||||||
messageId = messageId,
|
messageId = messageId,
|
||||||
text = text,
|
text = text,
|
||||||
encryptedContent = "", // Пустой пока не зашифровали
|
encryptedContent = "",
|
||||||
encryptedKey = "",
|
encryptedKey = "",
|
||||||
timestamp = timestamp,
|
timestamp = timestamp,
|
||||||
isFromMe = true,
|
isFromMe = true,
|
||||||
delivered = 0, // SENDING
|
delivered = 0,
|
||||||
attachmentsJson = attachmentsJson
|
attachmentsJson = optimisticAttachmentsJson,
|
||||||
|
opponentPublicKey = recipient
|
||||||
)
|
)
|
||||||
} catch (e: Exception) {
|
} catch (_: Exception) {
|
||||||
// Игнорируем ошибку - главное что в UI показали
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 🔄 В ФОНЕ: конвертируем, генерируем blurhash и отправляем
|
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
|
||||||
try {
|
try {
|
||||||
// Получаем размеры изображения
|
|
||||||
val (width, height) =
|
val (width, height) =
|
||||||
com.rosetta.messenger.utils.MediaUtils.getImageDimensions(context, imageUri)
|
com.rosetta.messenger.utils.MediaUtils.getImageDimensions(context, imageUri)
|
||||||
|
|
||||||
// Конвертируем в base64
|
|
||||||
val imageBase64 =
|
val imageBase64 =
|
||||||
com.rosetta.messenger.utils.MediaUtils.uriToBase64Image(context, imageUri)
|
com.rosetta.messenger.utils.MediaUtils.uriToBase64Image(context, imageUri)
|
||||||
if (imageBase64 == null) {
|
if (imageBase64 == null) {
|
||||||
|
if (!isCleared) {
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
updateMessageStatus(messageId, MessageStatus.ERROR)
|
updateMessageStatus(messageId, MessageStatus.ERROR)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
|
|
||||||
// Генерируем blurhash
|
|
||||||
val blurhash =
|
val blurhash =
|
||||||
com.rosetta.messenger.utils.MediaUtils.generateBlurhash(context, imageUri)
|
com.rosetta.messenger.utils.MediaUtils.generateBlurhash(context, imageUri)
|
||||||
|
|
||||||
// 4. 🔄 Обновляем optimistic сообщение с реальными данными
|
if (!isCleared) {
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
updateOptimisticImageMessage(messageId, imageBase64, blurhash, width, height)
|
updateOptimisticImageMessage(messageId, imageBase64, blurhash, width, height)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 5. 📤 Отправляем (шифрование + загрузка на сервер)
|
|
||||||
sendImageMessageInternal(
|
sendImageMessageInternal(
|
||||||
messageId = messageId,
|
messageId = messageId,
|
||||||
imageBase64 = imageBase64,
|
imageBase64 = imageBase64,
|
||||||
@@ -1846,13 +1832,15 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
sender = sender,
|
sender = sender,
|
||||||
privateKey = privateKey
|
privateKey = privateKey
|
||||||
)
|
)
|
||||||
} catch (e: Exception) {
|
} catch (_: Exception) {
|
||||||
|
if (!isCleared) {
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
updateMessageStatus(messageId, MessageStatus.ERROR)
|
updateMessageStatus(messageId, MessageStatus.ERROR)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** 🔄 Обновляет optimistic сообщение с реальными данными изображения */
|
/** 🔄 Обновляет optimistic сообщение с реальными данными изображения */
|
||||||
private fun updateOptimisticImageMessage(
|
private fun updateOptimisticImageMessage(
|
||||||
@@ -1991,7 +1979,11 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
updateMessageAttachments(messageId, null)
|
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) {
|
} catch (e: Exception) {
|
||||||
withContext(Dispatchers.Main) { updateMessageStatus(messageId, MessageStatus.ERROR) }
|
withContext(Dispatchers.Main) { updateMessageStatus(messageId, MessageStatus.ERROR) }
|
||||||
}
|
}
|
||||||
@@ -2053,7 +2045,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
addMessageSafely(optimisticMessage)
|
addMessageSafely(optimisticMessage)
|
||||||
_inputText.value = ""
|
_inputText.value = ""
|
||||||
|
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
backgroundUploadScope.launch {
|
||||||
try {
|
try {
|
||||||
// Шифрование текста
|
// Шифрование текста
|
||||||
val encryptResult = MessageCrypto.encryptForSending(text, recipient)
|
val encryptResult = MessageCrypto.encryptForSending(text, recipient)
|
||||||
@@ -2144,7 +2136,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
timestamp = timestamp,
|
timestamp = timestamp,
|
||||||
isFromMe = true,
|
isFromMe = true,
|
||||||
delivered = if (isSavedMessages) 2 else 0, // SENDING для обычных
|
delivered = if (isSavedMessages) 2 else 0, // SENDING для обычных
|
||||||
attachmentsJson = attachmentsJson
|
attachmentsJson = attachmentsJson,
|
||||||
|
opponentPublicKey = recipient
|
||||||
)
|
)
|
||||||
|
|
||||||
// 🔥 После успешной отправки обновляем статус на SENT (2) в БД и UI
|
// 🔥 После успешной отправки обновляем статус на SENT (2) в БД и UI
|
||||||
@@ -2154,7 +2147,11 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
|
|
||||||
withContext(Dispatchers.Main) { updateMessageStatus(messageId, MessageStatus.SENT) }
|
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) {
|
} catch (e: Exception) {
|
||||||
withContext(Dispatchers.Main) { updateMessageStatus(messageId, MessageStatus.SENT) }
|
withContext(Dispatchers.Main) { updateMessageStatus(messageId, MessageStatus.SENT) }
|
||||||
} finally {
|
} finally {
|
||||||
@@ -2234,7 +2231,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
addMessageSafely(optimisticMessage)
|
addMessageSafely(optimisticMessage)
|
||||||
_inputText.value = ""
|
_inputText.value = ""
|
||||||
|
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
backgroundUploadScope.launch {
|
||||||
try {
|
try {
|
||||||
// Шифрование текста
|
// Шифрование текста
|
||||||
val encryptResult = MessageCrypto.encryptForSending(text, recipient)
|
val encryptResult = MessageCrypto.encryptForSending(text, recipient)
|
||||||
@@ -2326,7 +2323,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
timestamp = timestamp,
|
timestamp = timestamp,
|
||||||
isFromMe = true,
|
isFromMe = true,
|
||||||
delivered = if (isSavedMessages) 2 else 0,
|
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
|
// Обновляем UI
|
||||||
withContext(Dispatchers.Main) { updateMessageStatus(messageId, MessageStatus.SENT) }
|
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) {
|
} catch (e: Exception) {
|
||||||
withContext(Dispatchers.Main) { updateMessageStatus(messageId, MessageStatus.SENT) }
|
withContext(Dispatchers.Main) { updateMessageStatus(messageId, MessageStatus.SENT) }
|
||||||
} finally {
|
} finally {
|
||||||
@@ -2724,9 +2726,9 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
* Сохранить диалог в базу данных 🔥 Используем updateDialogFromMessages для пересчета счетчиков
|
* Сохранить диалог в базу данных 🔥 Используем updateDialogFromMessages для пересчета счетчиков
|
||||||
* из messages 📁 SAVED MESSAGES: Использует специальный метод для saved messages
|
* из 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 account = myPublicKey ?: return
|
||||||
val opponent = opponentKey ?: return
|
val opponent = opponentPublicKey ?: opponentKey ?: return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 🔥 КРИТИЧНО: Используем updateDialogFromMessages который пересчитывает счетчики
|
// 🔥 КРИТИЧНО: Используем updateDialogFromMessages который пересчитывает счетчики
|
||||||
@@ -3076,6 +3078,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
|
|
||||||
override fun onCleared() {
|
override fun onCleared() {
|
||||||
super.onCleared()
|
super.onCleared()
|
||||||
|
isCleared = true
|
||||||
|
|
||||||
// 🔥 КРИТИЧНО: Удаляем обработчики пакетов чтобы избежать накопления и дубликатов
|
// 🔥 КРИТИЧНО: Удаляем обработчики пакетов чтобы избежать накопления и дубликатов
|
||||||
ProtocolManager.unwaitPacket(0x0B, typingPacketHandler)
|
ProtocolManager.unwaitPacket(0x0B, typingPacketHandler)
|
||||||
|
|||||||
@@ -23,12 +23,15 @@ import kotlinx.coroutines.launch
|
|||||||
// Telegram's CubicBezierInterpolator(0.25, 0.1, 0.25, 1.0)
|
// Telegram's CubicBezierInterpolator(0.25, 0.1, 0.25, 1.0)
|
||||||
private val TelegramEasing = CubicBezierEasing(0.25f, 0.1f, 0.25f, 1.0f)
|
private val TelegramEasing = CubicBezierEasing(0.25f, 0.1f, 0.25f, 1.0f)
|
||||||
|
|
||||||
// Swipe-back thresholds (Telegram-like)
|
// Swipe-back thresholds tuned for ultra-light navigation.
|
||||||
private const val COMPLETION_THRESHOLD = 0.2f // 20% of screen width — very easy to complete
|
private const val COMPLETION_THRESHOLD = 0.05f // 5% of screen width
|
||||||
private const val FLING_VELOCITY_THRESHOLD = 150f // px/s — very sensitive to flings
|
private const val FLING_VELOCITY_THRESHOLD = 35f // px/s
|
||||||
private const val ANIMATION_DURATION_ENTER = 300
|
private const val ANIMATION_DURATION_ENTER = 300
|
||||||
private const val ANIMATION_DURATION_EXIT = 200
|
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)
|
* Telegram-style swipe back container (optimized)
|
||||||
@@ -40,7 +43,7 @@ private const val EDGE_ZONE_DP = 200
|
|||||||
* - Smooth Telegram-style bezier animation
|
* - Smooth Telegram-style bezier animation
|
||||||
* - Scrim (dimming) on background
|
* - Scrim (dimming) on background
|
||||||
* - Shadow on left edge during swipe
|
* - 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
|
@Composable
|
||||||
fun SwipeBackContainer(
|
fun SwipeBackContainer(
|
||||||
@@ -60,6 +63,7 @@ fun SwipeBackContainer(
|
|||||||
val configuration = LocalConfiguration.current
|
val configuration = LocalConfiguration.current
|
||||||
val screenWidthPx = with(density) { configuration.screenWidthDp.dp.toPx() }
|
val screenWidthPx = with(density) { configuration.screenWidthDp.dp.toPx() }
|
||||||
val edgeZonePx = with(density) { EDGE_ZONE_DP.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)
|
// Animation state for swipe (used only for swipe animations, not during drag)
|
||||||
val offsetAnimatable = remember { Animatable(0f) }
|
val offsetAnimatable = remember { Animatable(0f) }
|
||||||
@@ -148,7 +152,9 @@ fun SwipeBackContainer(
|
|||||||
if (swipeEnabled && !isAnimatingIn && !isAnimatingOut) {
|
if (swipeEnabled && !isAnimatingIn && !isAnimatingOut) {
|
||||||
Modifier.pointerInput(Unit) {
|
Modifier.pointerInput(Unit) {
|
||||||
val velocityTracker = VelocityTracker()
|
val velocityTracker = VelocityTracker()
|
||||||
val touchSlop = viewConfiguration.touchSlop
|
val touchSlop =
|
||||||
|
viewConfiguration.touchSlop *
|
||||||
|
TOUCH_SLOP_FACTOR
|
||||||
|
|
||||||
awaitEachGesture {
|
awaitEachGesture {
|
||||||
val down =
|
val down =
|
||||||
@@ -157,7 +163,7 @@ fun SwipeBackContainer(
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Edge-only detection
|
// Edge-only detection
|
||||||
if (down.position.x > edgeZonePx) {
|
if (down.position.x > effectiveEdgeZonePx) {
|
||||||
return@awaitEachGesture
|
return@awaitEachGesture
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,7 +211,8 @@ fun SwipeBackContainer(
|
|||||||
) >
|
) >
|
||||||
kotlin.math.abs(
|
kotlin.math.abs(
|
||||||
totalDragY
|
totalDragY
|
||||||
) * 1.5f
|
) *
|
||||||
|
HORIZONTAL_DOMINANCE_RATIO
|
||||||
) {
|
) {
|
||||||
passedSlop = true
|
passedSlop = true
|
||||||
startedSwipe = true
|
startedSwipe = true
|
||||||
@@ -255,15 +262,15 @@ fun SwipeBackContainer(
|
|||||||
dragOffset / screenWidthPx
|
dragOffset / screenWidthPx
|
||||||
|
|
||||||
val shouldComplete =
|
val shouldComplete =
|
||||||
currentProgress >
|
currentProgress >=
|
||||||
0.5f || // Past 50% — always
|
COMPLETION_THRESHOLD ||
|
||||||
// complete
|
|
||||||
velocity >
|
velocity >
|
||||||
FLING_VELOCITY_THRESHOLD || // Fast fling right
|
FLING_VELOCITY_THRESHOLD || // Fast fling right
|
||||||
(currentProgress >
|
(currentProgress >=
|
||||||
COMPLETION_THRESHOLD &&
|
COMPLETION_THRESHOLD *
|
||||||
|
0.5f &&
|
||||||
velocity >
|
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 {
|
scope.launch {
|
||||||
offsetAnimatable.snapTo(dragOffset)
|
offsetAnimatable.snapTo(dragOffset)
|
||||||
|
|||||||
@@ -68,6 +68,73 @@ import kotlinx.coroutines.launch
|
|||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
private const val TAG = "ProfileScreen"
|
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
|
// 🎨 Avatar colors - используем те же цвета что и в ChatsListScreen
|
||||||
private val avatarColorsLight =
|
private val avatarColorsLight =
|
||||||
@@ -300,11 +367,35 @@ fun ProfileScreen(
|
|||||||
var editedName by remember(accountName) { mutableStateOf(accountName) }
|
var editedName by remember(accountName) { mutableStateOf(accountName) }
|
||||||
var editedUsername by remember(accountUsername) { mutableStateOf(accountUsername) }
|
var editedUsername by remember(accountUsername) { mutableStateOf(accountUsername) }
|
||||||
var hasChanges by remember { mutableStateOf(false) }
|
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)
|
// Sync edited fields when account data changes from parent (after save)
|
||||||
LaunchedEffect(accountName, accountUsername) {
|
LaunchedEffect(accountName, accountUsername) {
|
||||||
editedName = accountName
|
editedName = accountName
|
||||||
editedUsername = accountUsername
|
editedUsername = accountUsername
|
||||||
|
nameTouched = false
|
||||||
|
usernameTouched = false
|
||||||
|
showValidationErrors = false
|
||||||
|
serverNameError = null
|
||||||
|
serverUsernameError = null
|
||||||
|
serverGeneralError = null
|
||||||
}
|
}
|
||||||
|
|
||||||
// ViewModel state
|
// ViewModel state
|
||||||
@@ -546,6 +637,9 @@ fun ProfileScreen(
|
|||||||
viewModel.updateLocalProfile(accountPublicKey, editedName, editedUsername)
|
viewModel.updateLocalProfile(accountPublicKey, editedName, editedUsername)
|
||||||
|
|
||||||
hasChanges = false
|
hasChanges = false
|
||||||
|
serverNameError = null
|
||||||
|
serverUsernameError = null
|
||||||
|
serverGeneralError = null
|
||||||
viewModel.resetSaveState()
|
viewModel.resetSaveState()
|
||||||
|
|
||||||
// Notify parent about profile update (updates UI in MainActivity)
|
// Notify parent about profile update (updates UI in MainActivity)
|
||||||
@@ -555,10 +649,28 @@ fun ProfileScreen(
|
|||||||
|
|
||||||
// Show error toast
|
// Show error toast
|
||||||
LaunchedEffect(profileState.error) {
|
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(
|
android.widget.Toast.makeText(
|
||||||
context,
|
context,
|
||||||
"Error: $error",
|
mappedError.toastMessage,
|
||||||
android.widget.Toast.LENGTH_SHORT
|
android.widget.Toast.LENGTH_SHORT
|
||||||
)
|
)
|
||||||
.show()
|
.show()
|
||||||
@@ -590,15 +702,34 @@ fun ProfileScreen(
|
|||||||
// ═════════════════════════════════════════════════════════════
|
// ═════════════════════════════════════════════════════════════
|
||||||
TelegramSectionTitle(title = "Account", isDarkTheme = isDarkTheme)
|
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
|
// Name field
|
||||||
TelegramTextField(
|
TelegramTextField(
|
||||||
value = editedName,
|
value = editedName,
|
||||||
label = "Your name",
|
label = "Your name",
|
||||||
isDarkTheme = isDarkTheme,
|
isDarkTheme = isDarkTheme,
|
||||||
isEditable = true,
|
isEditable = true,
|
||||||
onValueChange = { editedName = it },
|
onValueChange = {
|
||||||
|
editedName = it
|
||||||
|
nameTouched = true
|
||||||
|
serverNameError = null
|
||||||
|
serverGeneralError = null
|
||||||
|
},
|
||||||
showDivider = true,
|
showDivider = true,
|
||||||
placeholder = "Add your name"
|
placeholder = "Add your name",
|
||||||
|
errorText = nameError
|
||||||
)
|
)
|
||||||
|
|
||||||
// Username field
|
// Username field
|
||||||
@@ -607,9 +738,15 @@ fun ProfileScreen(
|
|||||||
label = "Username",
|
label = "Username",
|
||||||
isDarkTheme = isDarkTheme,
|
isDarkTheme = isDarkTheme,
|
||||||
isEditable = true,
|
isEditable = true,
|
||||||
onValueChange = { editedUsername = it },
|
onValueChange = {
|
||||||
|
editedUsername = it
|
||||||
|
usernameTouched = true
|
||||||
|
serverUsernameError = null
|
||||||
|
serverGeneralError = null
|
||||||
|
},
|
||||||
showDivider = true,
|
showDivider = true,
|
||||||
placeholder = "Add username"
|
placeholder = "Add username",
|
||||||
|
errorText = usernameError
|
||||||
)
|
)
|
||||||
|
|
||||||
// Public Key field
|
// Public Key field
|
||||||
@@ -696,7 +833,27 @@ fun ProfileScreen(
|
|||||||
expansionProgress = expansionProgress,
|
expansionProgress = expansionProgress,
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
hasChanges = hasChanges,
|
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:
|
// Following desktop version logic:
|
||||||
// 1. Send packet to server
|
// 1. Send packet to server
|
||||||
// 2. Wait for response in ProfileViewModel
|
// 2. Wait for response in ProfileViewModel
|
||||||
@@ -704,8 +861,8 @@ fun ProfileScreen(
|
|||||||
viewModel.saveProfile(
|
viewModel.saveProfile(
|
||||||
publicKey = accountPublicKey,
|
publicKey = accountPublicKey,
|
||||||
privateKeyHash = accountPrivateKeyHash,
|
privateKeyHash = accountPrivateKeyHash,
|
||||||
name = editedName,
|
name = nameToSave,
|
||||||
username = editedUsername
|
username = usernameToSave
|
||||||
)
|
)
|
||||||
// Note: Local update happens in LaunchedEffect when saveSuccess is true
|
// Note: Local update happens in LaunchedEffect when saveSuccess is true
|
||||||
},
|
},
|
||||||
@@ -761,6 +918,7 @@ private fun CollapsingProfileHeader(
|
|||||||
expansionProgress: Float,
|
expansionProgress: Float,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
hasChanges: Boolean,
|
hasChanges: Boolean,
|
||||||
|
isSaveEnabled: Boolean,
|
||||||
onSave: () -> Unit,
|
onSave: () -> Unit,
|
||||||
isDarkTheme: Boolean,
|
isDarkTheme: Boolean,
|
||||||
showAvatarMenu: Boolean,
|
showAvatarMenu: Boolean,
|
||||||
@@ -892,10 +1050,17 @@ private fun CollapsingProfileHeader(
|
|||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
AnimatedVisibility(visible = hasChanges, enter = fadeIn(), exit = fadeOut()) {
|
AnimatedVisibility(visible = hasChanges, enter = fadeIn(), exit = fadeOut()) {
|
||||||
TextButton(onClick = onSave) {
|
TextButton(onClick = onSave, enabled = isSaveEnabled) {
|
||||||
Text(
|
Text(
|
||||||
text = "Save",
|
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
|
fontWeight = FontWeight.SemiBold
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1178,14 +1343,46 @@ private fun TelegramTextField(
|
|||||||
isEditable: Boolean = false,
|
isEditable: Boolean = false,
|
||||||
onValueChange: ((String) -> Unit)? = null,
|
onValueChange: ((String) -> Unit)? = null,
|
||||||
showDivider: Boolean = false,
|
showDivider: Boolean = false,
|
||||||
placeholder: String = ""
|
placeholder: String = "",
|
||||||
|
errorText: String? = null
|
||||||
) {
|
) {
|
||||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||||
val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
|
val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
|
||||||
val dividerColor = if (isDarkTheme) Color(0xFF3A3A3A) else Color(0xFFE0E0E0)
|
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 {
|
||||||
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) {
|
if (isEditable && onValueChange != null) {
|
||||||
BasicTextField(
|
BasicTextField(
|
||||||
value = value,
|
value = value,
|
||||||
@@ -1217,7 +1414,17 @@ private fun TelegramTextField(
|
|||||||
|
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
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) {
|
if (showDivider) {
|
||||||
|
|||||||
Reference in New Issue
Block a user