Refactor SwipeBackContainer for improved performance and readability

- Added lazy composition to skip setup until the screen is first opened, reducing allocations.
- Cleaned up code formatting for better readability.
- Enhanced comments for clarity on functionality.
- Streamlined gesture handling logic for swipe detection and animation.
This commit is contained in:
k1ngsterr1
2026-02-08 07:34:25 +05:00
parent 58b754d5ba
commit 11a8ff7644
5 changed files with 1744 additions and 1679 deletions

View File

@@ -8,15 +8,15 @@ import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
@Database(
entities = [
EncryptedAccountEntity::class,
MessageEntity::class,
DialogEntity::class,
BlacklistEntity::class,
AvatarCacheEntity::class
],
version = 10,
exportSchema = false
entities =
[
EncryptedAccountEntity::class,
MessageEntity::class,
DialogEntity::class,
BlacklistEntity::class,
AvatarCacheEntity::class],
version = 11,
exportSchema = false
)
abstract class RosettaDatabase : RoomDatabase() {
abstract fun accountDao(): AccountDao
@@ -26,93 +26,140 @@ abstract class RosettaDatabase : RoomDatabase() {
abstract fun avatarDao(): AvatarDao
companion object {
@Volatile
private var INSTANCE: RosettaDatabase? = null
@Volatile private var INSTANCE: RosettaDatabase? = null
private val MIGRATION_4_5 = object : Migration(4, 5) {
override fun migrate(database: SupportSQLiteDatabase) {
// Добавляем новые столбцы для индикаторов прочтения
database.execSQL("ALTER TABLE dialogs ADD COLUMN last_message_from_me INTEGER NOT NULL DEFAULT 0")
database.execSQL("ALTER TABLE dialogs ADD COLUMN last_message_delivered INTEGER NOT NULL DEFAULT 0")
database.execSQL("ALTER TABLE dialogs ADD COLUMN last_message_read INTEGER NOT NULL DEFAULT 0")
}
}
private val MIGRATION_4_5 =
object : Migration(4, 5) {
override fun migrate(database: SupportSQLiteDatabase) {
// Добавляем новые столбцы для индикаторов прочтения
database.execSQL(
"ALTER TABLE dialogs ADD COLUMN last_message_from_me INTEGER NOT NULL DEFAULT 0"
)
database.execSQL(
"ALTER TABLE dialogs ADD COLUMN last_message_delivered INTEGER NOT NULL DEFAULT 0"
)
database.execSQL(
"ALTER TABLE dialogs ADD COLUMN last_message_read INTEGER NOT NULL DEFAULT 0"
)
}
}
private val MIGRATION_5_6 = object : Migration(5, 6) {
override fun migrate(database: SupportSQLiteDatabase) {
// Добавляем поле username в encrypted_accounts
database.execSQL("ALTER TABLE encrypted_accounts ADD COLUMN username TEXT")
}
}
private val MIGRATION_5_6 =
object : Migration(5, 6) {
override fun migrate(database: SupportSQLiteDatabase) {
// Добавляем поле username в encrypted_accounts
database.execSQL("ALTER TABLE encrypted_accounts ADD COLUMN username TEXT")
}
}
private val MIGRATION_6_7 = object : Migration(6, 7) {
override fun migrate(database: SupportSQLiteDatabase) {
// Создаем таблицу для кэша аватаров
database.execSQL("""
private val MIGRATION_6_7 =
object : Migration(6, 7) {
override fun migrate(database: SupportSQLiteDatabase) {
// Создаем таблицу для кэша аватаров
database.execSQL(
"""
CREATE TABLE IF NOT EXISTS avatar_cache (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
public_key TEXT NOT NULL,
avatar TEXT NOT NULL,
timestamp INTEGER NOT NULL
)
""")
database.execSQL("CREATE INDEX IF NOT EXISTS index_avatar_cache_public_key_timestamp ON avatar_cache (public_key, timestamp)")
}
}
"""
)
database.execSQL(
"CREATE INDEX IF NOT EXISTS index_avatar_cache_public_key_timestamp ON avatar_cache (public_key, timestamp)"
)
}
}
private val MIGRATION_7_8 = object : Migration(7, 8) {
override fun migrate(database: SupportSQLiteDatabase) {
// Удаляем таблицу avatar_delivery (больше не нужна)
database.execSQL("DROP TABLE IF EXISTS avatar_delivery")
}
}
private val MIGRATION_7_8 =
object : Migration(7, 8) {
override fun migrate(database: SupportSQLiteDatabase) {
// Удаляем таблицу avatar_delivery (больше не нужна)
database.execSQL("DROP TABLE IF EXISTS avatar_delivery")
}
}
/**
* 🔥 МИГРАЦИЯ 8->9: Очищаем blob из attachments (как в desktop)
* Blob слишком большой для SQLite CursorWindow (2MB лимит)
* Просто обнуляем attachments - изображения перескачаются с CDN
* 🔥 МИГРАЦИЯ 8->9: Очищаем blob из attachments (как в desktop) Blob слишком большой для
* SQLite CursorWindow (2MB лимит) Просто обнуляем attachments - изображения перескачаются с
* CDN
*/
private val MIGRATION_8_9 = object : Migration(8, 9) {
override fun migrate(database: SupportSQLiteDatabase) {
// Очищаем все attachments с большими blob'ами
// Они будут перескачаны с CDN при открытии
database.execSQL("""
private val MIGRATION_8_9 =
object : Migration(8, 9) {
override fun migrate(database: SupportSQLiteDatabase) {
// Очищаем все attachments с большими blob'ами
// Они будут перескачаны с CDN при открытии
database.execSQL(
"""
UPDATE messages
SET attachments = '[]'
WHERE length(attachments) > 10000
""")
}
}
"""
)
}
}
/**
* 🔥 МИГРАЦИЯ 9->10: Повторная очистка blob из attachments
* Для пользователей которые уже были на версии 9
* 🔥 МИГРАЦИЯ 9->10: Повторная очистка blob из attachments Для пользователей которые уже
* были на версии 9
*/
private val MIGRATION_9_10 = object : Migration(9, 10) {
override fun migrate(database: SupportSQLiteDatabase) {
// Очищаем все attachments с большими blob'ами
database.execSQL("""
private val MIGRATION_9_10 =
object : Migration(9, 10) {
override fun migrate(database: SupportSQLiteDatabase) {
// Очищаем все attachments с большими blob'ами
database.execSQL(
"""
UPDATE messages
SET attachments = '[]'
WHERE length(attachments) > 10000
""")
}
}
"""
)
}
}
/**
* 🚀 МИГРАЦИЯ 10->11: Денормализация — кэш attachments последнего сообщения в dialogs
* Устраняет N+1 проблему: ранее для каждого диалога делался отдельный запрос к messages
*/
private val MIGRATION_10_11 =
object : Migration(10, 11) {
override fun migrate(database: SupportSQLiteDatabase) {
// Добавляем столбец для кэша attachments последнего сообщения
database.execSQL(
"ALTER TABLE dialogs ADD COLUMN last_message_attachments TEXT NOT NULL DEFAULT '[]'"
)
}
}
fun getDatabase(context: Context): RosettaDatabase {
return INSTANCE ?: synchronized(this) {
val instance = Room.databaseBuilder(
context.applicationContext,
RosettaDatabase::class.java,
"rosetta_secure.db"
)
.setJournalMode(JournalMode.WRITE_AHEAD_LOGGING) // WAL mode for performance
.addMigrations(MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8, MIGRATION_8_9, MIGRATION_9_10)
.fallbackToDestructiveMigration() // Для разработки - только если миграция не найдена
.build()
INSTANCE = instance
instance
}
return INSTANCE
?: synchronized(this) {
val instance =
Room.databaseBuilder(
context.applicationContext,
RosettaDatabase::class.java,
"rosetta_secure.db"
)
.setJournalMode(
JournalMode.WRITE_AHEAD_LOGGING
) // WAL mode for performance
.addMigrations(
MIGRATION_4_5,
MIGRATION_5_6,
MIGRATION_6_7,
MIGRATION_7_8,
MIGRATION_8_9,
MIGRATION_9_10,
MIGRATION_10_11
)
.fallbackToDestructiveMigration() // Для разработки - только
// если миграция не
// найдена
.build()
INSTANCE = instance
instance
}
}
}
}

View File

@@ -1,71 +1,68 @@
package com.rosetta.messenger.ui.chats
import android.app.Application
import androidx.compose.runtime.Immutable
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.rosetta.messenger.crypto.CryptoManager
import com.rosetta.messenger.data.MessageRepository
import com.rosetta.messenger.database.DialogEntity
import com.rosetta.messenger.database.RosettaDatabase
import com.rosetta.messenger.database.BlacklistEntity
import com.rosetta.messenger.database.RosettaDatabase
import com.rosetta.messenger.network.PacketOnlineSubscribe
import com.rosetta.messenger.network.PacketSearch
import com.rosetta.messenger.network.ProtocolManager
import com.rosetta.messenger.network.SearchUser
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import androidx.compose.runtime.Immutable
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* UI модель диалога с расшифрованным lastMessage
*/
/** UI модель диалога с расшифрованным lastMessage */
@Immutable
data class DialogUiModel(
val id: Long,
val account: String,
val opponentKey: String,
val opponentTitle: String,
val opponentUsername: String,
val lastMessage: String, // 🔓 Расшифрованный текст
val lastMessageTimestamp: Long,
val unreadCount: Int,
val isOnline: Int,
val lastSeen: Long,
val verified: Int,
val isSavedMessages: Boolean = false, // 📁 Флаг для Saved Messages (account == opponentKey)
val lastMessageFromMe: Int = 0, // Последнее сообщение от меня (0/1)
val lastMessageDelivered: Int = 0, // Статус доставки (0=WAITING, 1=DELIVERED, 2=ERROR)
val lastMessageRead: Int = 0, // Прочитано (0/1)
val lastMessageAttachmentType: String? = null // 📎 Тип attachment: "Photo", "File", или null
val id: Long,
val account: String,
val opponentKey: String,
val opponentTitle: String,
val opponentUsername: String,
val lastMessage: String, // 🔓 Расшифрованный текст
val lastMessageTimestamp: Long,
val unreadCount: Int,
val isOnline: Int,
val lastSeen: Long,
val verified: Int,
val isSavedMessages: Boolean = false, // 📁 Флаг для Saved Messages (account == opponentKey)
val lastMessageFromMe: Int = 0, // Последнее сообщение от меня (0/1)
val lastMessageDelivered: Int = 0, // Статус доставки (0=WAITING, 1=DELIVERED, 2=ERROR)
val lastMessageRead: Int = 0, // Прочитано (0/1)
val lastMessageAttachmentType: String? =
null // 📎 Тип attachment: "Photo", "File", или null
)
/**
* 🔥 Комбинированное состояние чатов для атомарного обновления UI
* Это предотвращает "дергание" когда dialogs и requests обновляются независимо
* 🔥 Комбинированное состояние чатов для атомарного обновления UI Это предотвращает "дергание"
* когда dialogs и requests обновляются независимо
*/
@Immutable
data class ChatsUiState(
val dialogs: List<DialogUiModel> = emptyList(),
val requests: List<DialogUiModel> = emptyList(),
val requestsCount: Int = 0
val dialogs: List<DialogUiModel> = emptyList(),
val requests: List<DialogUiModel> = emptyList(),
val requestsCount: Int = 0
) {
val isEmpty: Boolean get() = dialogs.isEmpty() && requestsCount == 0
val hasContent: Boolean get() = dialogs.isNotEmpty() || requestsCount > 0
val isEmpty: Boolean
get() = dialogs.isEmpty() && requestsCount == 0
val hasContent: Boolean
get() = dialogs.isNotEmpty() || requestsCount > 0
}
/**
* ViewModel для списка чатов
* Загружает диалоги из базы данных и расшифровывает lastMessage
*/
/** ViewModel для списка чатов Загружает диалоги из базы данных и расшифровывает lastMessage */
class ChatsListViewModel(application: Application) : AndroidViewModel(application) {
private val database = RosettaDatabase.getDatabase(application)
private val dialogDao = database.dialogDao()
private val messageDao = database.messageDao() // 🔥 Добавляем для получения статуса последнего сообщения
private var currentAccount: String = ""
private var currentPrivateKey: String? = null
@@ -94,19 +91,18 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
// 🔥 НОВОЕ: Комбинированное состояние - обновляется атомарно!
// 🎯 ИСПРАВЛЕНИЕ: debounce предотвращает мерцание при быстрых обновлениях
val chatsState: StateFlow<ChatsUiState> = combine(
_dialogs,
_requests,
_requestsCount
) { dialogs, requests, count ->
ChatsUiState(dialogs, requests, count)
}
.distinctUntilChanged() // 🔥 Игнорируем дублирующиеся состояния
.stateIn(
viewModelScope,
SharingStarted.Eagerly, // 🔥 КРИТИЧНО: Eagerly вместо WhileSubscribed - сразу начинаем следить
ChatsUiState()
)
val chatsState: StateFlow<ChatsUiState> =
combine(_dialogs, _requests, _requestsCount) { dialogs, requests, count ->
ChatsUiState(dialogs, requests, count)
}
.distinctUntilChanged() // 🔥 Игнорируем дублирующиеся состояния
.stateIn(
viewModelScope,
SharingStarted
.Eagerly, // 🔥 КРИТИЧНО: Eagerly вместо WhileSubscribed - сразу
// начинаем следить
ChatsUiState()
)
// Загрузка
private val _isLoading = MutableStateFlow(false)
@@ -114,12 +110,10 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
private val TAG = "ChatsListVM"
/**
* Установить текущий аккаунт и загрузить диалоги
*/
/** Установить текущий аккаунт и загрузить диалоги */
fun setAccount(publicKey: String, privateKey: String) {
val setAccountStart = System.currentTimeMillis()
if (currentAccount == publicKey) {
if (currentAccount == publicKey) {
return
}
@@ -129,222 +123,324 @@ if (currentAccount == publicKey) {
currentAccount = publicKey
currentPrivateKey = privateKey
// Подписываемся на обычные диалоги
@OptIn(FlowPreview::class)
viewModelScope.launch {
dialogDao.getDialogsFlow(publicKey)
.flowOn(Dispatchers.IO) // 🚀 Flow работает на IO
.map { dialogsList ->
val mapStart = System.currentTimeMillis()
// <20> ОПТИМИЗАЦИЯ: Параллельная расшифровка всех сообщений
withContext(Dispatchers.Default) {
dialogsList.map { dialog ->
async {
// 🔥 Загружаем информацию о пользователе если её нет
// 📁 НЕ загружаем для Saved Messages
val isSavedMessages = (dialog.account == dialog.opponentKey)
if (!isSavedMessages && (dialog.opponentTitle.isEmpty() || dialog.opponentTitle == dialog.opponentKey || dialog.opponentTitle == dialog.opponentKey.take(7))) {
loadUserInfoForDialog(dialog.opponentKey)
}
// 🚀 Расшифровка теперь кэшируется в CryptoManager!
val decryptedLastMessage = try {
if (privateKey.isNotEmpty() && dialog.lastMessage.isNotEmpty()) {
CryptoManager.decryptWithPassword(dialog.lastMessage, privateKey)
?: dialog.lastMessage
} else {
dialog.lastMessage
}
} catch (e: Exception) {
dialog.lastMessage // Fallback на зашифрованный текст
}
// 🔥🔥🔥 НОВЫЙ ПОДХОД: Получаем статус НАПРЯМУЮ из таблицы messages
// Это гарантирует синхронизацию с тем что показывается в диалоге
val lastMsgStatus = messageDao.getLastMessageStatus(publicKey, dialog.opponentKey)
val actualFromMe = lastMsgStatus?.fromMe ?: 0
val actualDelivered = if (actualFromMe == 1) (lastMsgStatus?.delivered ?: 0) else 0
val actualRead = if (actualFromMe == 1) (lastMsgStatus?.read ?: 0) else 0
// 📎 Определяем тип attachment последнего сообщения
// 🔥 Reply vs Forward: если есть текст - это Reply (показываем текст),
// если текст пустой - это Forward (показываем "Forwarded message")
val attachmentType = try {
val attachmentsJson = messageDao.getLastMessageAttachments(publicKey, dialog.opponentKey)
if (!attachmentsJson.isNullOrEmpty() && attachmentsJson != "[]") {
val attachments = org.json.JSONArray(attachmentsJson)
if (attachments.length() > 0) {
val firstAttachment = attachments.getJSONObject(0)
val type = firstAttachment.optInt("type", -1)
when (type) {
0 -> "Photo" // AttachmentType.IMAGE = 0
1 -> {
// AttachmentType.MESSAGES = 1 (Reply или Forward)
// Reply: есть текст сообщения -> показываем текст (null)
// Forward: текст пустой -> показываем "Forwarded"
if (decryptedLastMessage.isNotEmpty()) null else "Forwarded"
}
2 -> "File" // AttachmentType.FILE = 2
3 -> "Avatar" // AttachmentType.AVATAR = 3
else -> null
dialogDao
.getDialogsFlow(publicKey)
.flowOn(Dispatchers.IO) // 🚀 Flow работает на IO
.debounce(100) // 🚀 Батчим быстрые обновления (N сообщений → 1 обработка)
.map { dialogsList ->
val mapStart = System.currentTimeMillis()
// <20> ОПТИМИЗАЦИЯ: Параллельная расшифровка всех сообщений
withContext(Dispatchers.Default) {
dialogsList
.map { dialog ->
async {
// 🔥 Загружаем информацию о пользователе если её нет
// 📁 НЕ загружаем для Saved Messages
val isSavedMessages =
(dialog.account == dialog.opponentKey)
if (!isSavedMessages &&
(dialog.opponentTitle.isEmpty() ||
dialog.opponentTitle ==
dialog.opponentKey ||
dialog.opponentTitle ==
dialog.opponentKey.take(
7
))
) {
loadUserInfoForDialog(dialog.opponentKey)
}
} else null
} else null
} catch (e: Exception) {
null
// 🚀 Расшифровка теперь кэшируется в CryptoManager!
val decryptedLastMessage =
try {
if (privateKey.isNotEmpty() &&
dialog.lastMessage
.isNotEmpty()
) {
CryptoManager.decryptWithPassword(
dialog.lastMessage,
privateKey
)
?: dialog.lastMessage
} else {
dialog.lastMessage
}
} catch (e: Exception) {
dialog.lastMessage // Fallback на
// зашифрованный текст
}
// <20> ОПТИМИЗАЦИЯ: Используем денормализованные поля из
// DialogEntity
// Статус и attachments уже записаны в dialogs через
// updateDialogFromMessages()
// Это устраняет N+1 проблему (ранее: 2 запроса на
// каждый диалог)
// 📎 Определяем тип attachment из кэшированного поля в
// DialogEntity
val attachmentType =
try {
val attachmentsJson =
dialog.lastMessageAttachments
if (attachmentsJson.isNotEmpty() &&
attachmentsJson != "[]"
) {
val attachments =
org.json.JSONArray(
attachmentsJson
)
if (attachments.length() > 0) {
val firstAttachment =
attachments.getJSONObject(0)
val type =
firstAttachment.optInt(
"type",
-1
)
when (type) {
0 ->
"Photo" // AttachmentType.IMAGE = 0
1 -> {
// AttachmentType.MESSAGES =
// 1 (Reply или Forward)
// Reply: есть текст
// сообщения -> показываем
// текст (null)
// Forward: текст пустой ->
// показываем "Forwarded"
if (decryptedLastMessage
.isNotEmpty()
)
null
else "Forwarded"
}
2 ->
"File" // AttachmentType.FILE = 2
3 ->
"Avatar" // AttachmentType.AVATAR = 3
else -> null
}
} else null
} else null
} catch (e: Exception) {
null
}
DialogUiModel(
id = dialog.id,
account = dialog.account,
opponentKey = dialog.opponentKey,
opponentTitle = dialog.opponentTitle,
opponentUsername = dialog.opponentUsername,
lastMessage = decryptedLastMessage,
lastMessageTimestamp =
dialog.lastMessageTimestamp,
unreadCount = dialog.unreadCount,
isOnline = dialog.isOnline,
lastSeen = dialog.lastSeen,
verified = dialog.verified,
isSavedMessages =
isSavedMessages, // 📁 Saved Messages
lastMessageFromMe =
dialog.lastMessageFromMe, // 🚀 Из
// DialogEntity (денормализовано)
lastMessageDelivered =
dialog.lastMessageDelivered, // 🚀 Из
// DialogEntity (денормализовано)
lastMessageRead =
dialog.lastMessageRead, // 🚀 Из
// DialogEntity
// (денормализовано)
lastMessageAttachmentType =
attachmentType // 📎 Тип attachment
)
}
}
.awaitAll()
}
.also {
val mapTime = System.currentTimeMillis() - mapStart
}
}
.distinctUntilChanged() // 🔥 ИСПРАВЛЕНИЕ: Игнорируем дублирующиеся списки
.collect { decryptedDialogs ->
_dialogs.value = decryptedDialogs
DialogUiModel(
id = dialog.id,
account = dialog.account,
opponentKey = dialog.opponentKey,
opponentTitle = dialog.opponentTitle,
opponentUsername = dialog.opponentUsername,
lastMessage = decryptedLastMessage,
lastMessageTimestamp = dialog.lastMessageTimestamp,
unreadCount = dialog.unreadCount,
isOnline = dialog.isOnline,
lastSeen = dialog.lastSeen,
verified = dialog.verified,
isSavedMessages = isSavedMessages, // 📁 Saved Messages
lastMessageFromMe = actualFromMe, // 🔥 Используем актуальные данные из messages
lastMessageDelivered = actualDelivered, // 🔥 Используем актуальные данные из messages
lastMessageRead = actualRead, // 🔥 Используем актуальные данные из messages
lastMessageAttachmentType = attachmentType // 📎 Тип attachment
)
}
}.awaitAll()
}.also {
val mapTime = System.currentTimeMillis() - mapStart
}
}
.distinctUntilChanged() // 🔥 ИСПРАВЛЕНИЕ: Игнорируем дублирующиеся списки
.collect { decryptedDialogs ->
_dialogs.value = decryptedDialogs
// 🟢 Подписываемся на онлайн-статусы всех собеседников
// 📁 Исключаем Saved Messages - не нужно подписываться на свой собственный статус
val opponentsToSubscribe = decryptedDialogs
.filter { !it.isSavedMessages }
.map { it.opponentKey }
subscribeToOnlineStatuses(opponentsToSubscribe, privateKey)
}
// 🟢 Подписываемся на онлайн-статусы всех собеседников
// 📁 Исключаем Saved Messages - не нужно подписываться на свой собственный
// статус
val opponentsToSubscribe =
decryptedDialogs.filter { !it.isSavedMessages }.map {
it.opponentKey
}
subscribeToOnlineStatuses(opponentsToSubscribe, privateKey)
}
}
// 📬 Подписываемся на requests (запросы от новых пользователей)
@OptIn(FlowPreview::class)
viewModelScope.launch {
dialogDao.getRequestsFlow(publicKey)
.flowOn(Dispatchers.IO)
.map { requestsList ->
// 🚀 ОПТИМИЗАЦИЯ: Параллельная расшифровка
withContext(Dispatchers.Default) {
requestsList.map { dialog ->
async {
// 🔥 Загружаем информацию о пользователе если её нет
// 📁 НЕ загружаем для Saved Messages
val isSavedMessages = (dialog.account == dialog.opponentKey)
if (!isSavedMessages && (dialog.opponentTitle.isEmpty() || dialog.opponentTitle == dialog.opponentKey)) {
loadUserInfoForRequest(dialog.opponentKey)
}
// 🚀 Расшифровка теперь кэшируется в CryptoManager!
val decryptedLastMessage = try {
if (privateKey.isNotEmpty() && dialog.lastMessage.isNotEmpty()) {
CryptoManager.decryptWithPassword(dialog.lastMessage, privateKey)
?: dialog.lastMessage
} else {
dialog.lastMessage
}
} catch (e: Exception) {
dialog.lastMessage
}
// 📎 Определяем тип attachment последнего сообщения
// 🔥 Reply vs Forward: если есть текст - это Reply (показываем текст),
// если текст пустой - это Forward (показываем "Forwarded message")
val attachmentType = try {
val attachmentsJson = messageDao.getLastMessageAttachments(publicKey, dialog.opponentKey)
if (!attachmentsJson.isNullOrEmpty() && attachmentsJson != "[]") {
val attachments = org.json.JSONArray(attachmentsJson)
if (attachments.length() > 0) {
val firstAttachment = attachments.getJSONObject(0)
val type = firstAttachment.optInt("type", -1)
when (type) {
0 -> "Photo" // AttachmentType.IMAGE = 0
1 -> {
// AttachmentType.MESSAGES = 1 (Reply или Forward)
// Reply: есть текст сообщения -> показываем текст (null)
// Forward: текст пустой -> показываем "Forwarded"
if (decryptedLastMessage.isNotEmpty()) null else "Forwarded"
}
2 -> "File" // AttachmentType.FILE = 2
3 -> "Avatar" // AttachmentType.AVATAR = 3
else -> null
dialogDao
.getRequestsFlow(publicKey)
.flowOn(Dispatchers.IO)
.debounce(100) // 🚀 Батчим быстрые обновления
.map { requestsList ->
// 🚀 ОПТИМИЗАЦИЯ: Параллельная расшифровка
withContext(Dispatchers.Default) {
requestsList
.map { dialog ->
async {
// 🔥 Загружаем информацию о пользователе если её нет
// 📁 НЕ загружаем для Saved Messages
val isSavedMessages =
(dialog.account == dialog.opponentKey)
if (!isSavedMessages &&
(dialog.opponentTitle.isEmpty() ||
dialog.opponentTitle ==
dialog.opponentKey)
) {
loadUserInfoForRequest(dialog.opponentKey)
}
} else null
} else null
} catch (e: Exception) {
null
}
DialogUiModel(
id = dialog.id,
account = dialog.account,
opponentKey = dialog.opponentKey,
opponentTitle = dialog.opponentTitle, // 🔥 Показываем имя как в обычных чатах
opponentUsername = dialog.opponentUsername,
lastMessage = decryptedLastMessage,
lastMessageTimestamp = dialog.lastMessageTimestamp,
unreadCount = dialog.unreadCount,
isOnline = dialog.isOnline,
lastSeen = dialog.lastSeen,
verified = dialog.verified,
isSavedMessages = (dialog.account == dialog.opponentKey), // 📁 Saved Messages
lastMessageFromMe = dialog.lastMessageFromMe,
lastMessageDelivered = dialog.lastMessageDelivered,
lastMessageRead = dialog.lastMessageRead,
lastMessageAttachmentType = attachmentType // 📎 Тип attachment
)
}
}.awaitAll()
// 🚀 Расшифровка теперь кэшируется в CryptoManager!
val decryptedLastMessage =
try {
if (privateKey.isNotEmpty() &&
dialog.lastMessage
.isNotEmpty()
) {
CryptoManager.decryptWithPassword(
dialog.lastMessage,
privateKey
)
?: dialog.lastMessage
} else {
dialog.lastMessage
}
} catch (e: Exception) {
dialog.lastMessage
}
// 📎 Определяем тип attachment из кэшированного поля в
// DialogEntity
val attachmentType =
try {
val attachmentsJson =
dialog.lastMessageAttachments
if (attachmentsJson.isNotEmpty() &&
attachmentsJson != "[]"
) {
val attachments =
org.json.JSONArray(
attachmentsJson
)
if (attachments.length() > 0) {
val firstAttachment =
attachments.getJSONObject(0)
val type =
firstAttachment.optInt(
"type",
-1
)
when (type) {
0 ->
"Photo" // AttachmentType.IMAGE = 0
1 -> {
// AttachmentType.MESSAGES =
// 1 (Reply или Forward)
// Reply: есть текст
// сообщения -> показываем
// текст (null)
// Forward: текст пустой ->
// показываем "Forwarded"
if (decryptedLastMessage
.isNotEmpty()
)
null
else "Forwarded"
}
2 ->
"File" // AttachmentType.FILE = 2
3 ->
"Avatar" // AttachmentType.AVATAR = 3
else -> null
}
} else null
} else null
} catch (e: Exception) {
null
}
DialogUiModel(
id = dialog.id,
account = dialog.account,
opponentKey = dialog.opponentKey,
opponentTitle =
dialog.opponentTitle, // 🔥 Показываем
// имя как в
// обычных чатах
opponentUsername = dialog.opponentUsername,
lastMessage = decryptedLastMessage,
lastMessageTimestamp =
dialog.lastMessageTimestamp,
unreadCount = dialog.unreadCount,
isOnline = dialog.isOnline,
lastSeen = dialog.lastSeen,
verified = dialog.verified,
isSavedMessages =
(dialog.account ==
dialog.opponentKey), // 📁 Saved
// Messages
lastMessageFromMe = dialog.lastMessageFromMe,
lastMessageDelivered =
dialog.lastMessageDelivered,
lastMessageRead = dialog.lastMessageRead,
lastMessageAttachmentType =
attachmentType // 📎 Тип attachment
)
}
}
.awaitAll()
}
}
}
.distinctUntilChanged() // 🔥 ИСПРАВЛЕНИЕ: Игнорируем дублирующиеся списки
.collect { decryptedRequests ->
_requests.value = decryptedRequests
}
.distinctUntilChanged() // 🔥 ИСПРАВЛЕНИЕ: Игнорируем дублирующиеся списки
.collect { decryptedRequests -> _requests.value = decryptedRequests }
}
// 📊 Подписываемся на количество requests
viewModelScope.launch {
dialogDao.getRequestsCountFlow(publicKey)
.flowOn(Dispatchers.IO)
.distinctUntilChanged() // 🔥 ИСПРАВЛЕНИЕ: Игнорируем дублирующиеся значения
.collect { count ->
_requestsCount.value = count
}
dialogDao
.getRequestsCountFlow(publicKey)
.flowOn(Dispatchers.IO)
.distinctUntilChanged() // 🔥 ИСПРАВЛЕНИЕ: Игнорируем дублирующиеся значения
.collect { count -> _requestsCount.value = count }
}
// 🔥 Подписываемся на блоклист — Room автоматически переэмитит при blockUser()/unblockUser()
// 🔥 Подписываемся на блоклист — Room автоматически переэмитит при
// blockUser()/unblockUser()
viewModelScope.launch {
database.blacklistDao().getBlockedUsers(publicKey)
.flowOn(Dispatchers.IO)
.map { entities -> entities.map { it.publicKey }.toSet() }
.distinctUntilChanged()
.collect { blockedSet ->
_blockedUsers.value = blockedSet
}
database.blacklistDao()
.getBlockedUsers(publicKey)
.flowOn(Dispatchers.IO)
.map { entities -> entities.map { it.publicKey }.toSet() }
.distinctUntilChanged()
.collect { blockedSet -> _blockedUsers.value = blockedSet }
}
}
/**
* 🟢 Подписаться на онлайн-статусы всех собеседников
* 🔥 Фильтруем уже подписанные ключи чтобы избежать бесконечного цикла
* 🟢 Подписаться на онлайн-статусы всех собеседников 🔥 Фильтруем уже подписанные ключи чтобы
* избежать бесконечного цикла
*/
private fun subscribeToOnlineStatuses(opponentKeys: List<String>, privateKey: String) {
if (opponentKeys.isEmpty()) return
// 🔥 КРИТИЧНО: Фильтруем уже подписанные ключи!
val newKeys = opponentKeys.filter { !subscribedOnlineKeys.contains(it) }
if (newKeys.isEmpty()) return // Все уже подписаны
if (newKeys.isEmpty()) return // Все уже подписаны
// Добавляем в Set ДО отправки пакета чтобы избежать race condition
subscribedOnlineKeys.addAll(newKeys)
@@ -353,32 +449,30 @@ if (currentAccount == publicKey) {
try {
val privateKeyHash = CryptoManager.generatePrivateKeyHash(privateKey)
val packet = PacketOnlineSubscribe().apply {
this.privateKey = privateKeyHash
newKeys.forEach { key ->
addPublicKey(key)
}
}
val packet =
PacketOnlineSubscribe().apply {
this.privateKey = privateKeyHash
newKeys.forEach { key -> addPublicKey(key) }
}
ProtocolManager.send(packet)
} catch (e: Exception) {
}
} catch (e: Exception) {}
}
}
/**
* Создать или обновить диалог после отправки/получения сообщения
* 🔥 Используем updateDialogFromMessages для пересчета счетчиков из messages
* 📁 SAVED MESSAGES: Использует специальный метод для saved messages
* Создать или обновить диалог после отправки/получения сообщения 🔥 Используем
* updateDialogFromMessages для пересчета счетчиков из messages 📁 SAVED MESSAGES: Использует
* специальный метод для saved messages
*/
suspend fun upsertDialog(
opponentKey: String,
opponentTitle: String,
opponentUsername: String = "",
lastMessage: String,
timestamp: Long,
verified: Int = 0,
isOnline: Int = 0
opponentKey: String,
opponentTitle: String,
opponentUsername: String = "",
lastMessage: String,
timestamp: Long,
verified: Int = 0,
isOnline: Int = 0
) {
if (currentAccount.isEmpty()) return
@@ -394,29 +488,31 @@ if (currentAccount == publicKey) {
// Обновляем информацию о собеседнике если есть
if (opponentTitle.isNotEmpty()) {
dialogDao.updateOpponentInfo(currentAccount, opponentKey, opponentTitle, opponentUsername, verified)
dialogDao.updateOpponentInfo(
currentAccount,
opponentKey,
opponentTitle,
opponentUsername,
verified
)
}
} catch (e: Exception) {
}
} catch (e: Exception) {}
}
/**
* Конвертировать DialogUiModel в SearchUser для навигации
*/
/** Конвертировать DialogUiModel в SearchUser для навигации */
fun dialogToSearchUser(dialog: DialogUiModel): SearchUser {
return SearchUser(
title = dialog.opponentTitle,
username = dialog.opponentUsername,
publicKey = dialog.opponentKey,
verified = dialog.verified,
online = dialog.isOnline
title = dialog.opponentTitle,
username = dialog.opponentUsername,
publicKey = dialog.opponentKey,
verified = dialog.verified,
online = dialog.isOnline
)
}
/**
* Удалить диалог и все сообщения с собеседником
* 🔥 ПОЛНОСТЬЮ очищает все данные - диалог, сообщения, кэш
* Удалить диалог и все сообщения с собеседником 🔥 ПОЛНОСТЬЮ очищает все данные - диалог,
* сообщения, кэш
*/
suspend fun deleteDialog(opponentKey: String) {
if (currentAccount.isEmpty()) return
@@ -430,11 +526,12 @@ if (currentAccount == publicKey) {
_requestsCount.value = _requests.value.size
// Вычисляем правильный dialog_key (отсортированная комбинация ключей)
val dialogKey = if (currentAccount < opponentKey) {
"$currentAccount:$opponentKey"
} else {
"$opponentKey:$currentAccount"
}
val dialogKey =
if (currentAccount < opponentKey) {
"$currentAccount:$opponentKey"
} else {
"$opponentKey:$currentAccount"
}
// 🗑️ 1. Очищаем ВСЕ кэши сообщений
MessageRepository.getInstance(getApplication()).clearDialogCache(opponentKey)
@@ -442,79 +539,72 @@ if (currentAccount == publicKey) {
ChatViewModel.clearCacheForOpponent(opponentKey)
// 🗑️ 2. Проверяем сколько сообщений в БД до удаления
val messageCountBefore = database.messageDao().getMessageCount(currentAccount, dialogKey)
val messageCountBefore =
database.messageDao().getMessageCount(currentAccount, dialogKey)
// 🗑️ 3. Удаляем все сообщения из диалога по dialog_key
val deletedByDialogKey = database.messageDao().deleteDialog(
account = currentAccount,
dialogKey = dialogKey
)
val deletedByDialogKey =
database.messageDao()
.deleteDialog(account = currentAccount, dialogKey = dialogKey)
// 🗑️ 4. Также удаляем по from/to ключам (на всякий случай - старые сообщения)
val deletedBetweenUsers = database.messageDao().deleteMessagesBetweenUsers(
account = currentAccount,
user1 = opponentKey,
user2 = currentAccount
)
val deletedBetweenUsers =
database.messageDao()
.deleteMessagesBetweenUsers(
account = currentAccount,
user1 = opponentKey,
user2 = currentAccount
)
// 🗑️ 5. Проверяем сколько сообщений осталось
val messageCountAfter = database.messageDao().getMessageCount(currentAccount, dialogKey)
// 🗑️ 6. Удаляем диалог из таблицы dialogs
database.dialogDao().deleteDialog(
account = currentAccount,
opponentKey = opponentKey
)
database.dialogDao().deleteDialog(account = currentAccount, opponentKey = opponentKey)
// 🗑️ 7. Проверяем что диалог удален
val dialogAfter = database.dialogDao().getDialog(currentAccount, opponentKey)
} catch (e: Exception) {
// В случае ошибки - возвращаем диалог обратно (откатываем оптимистичное обновление)
// Flow обновится автоматически из БД
}
}
/**
* Заблокировать пользователя
*/
/** Заблокировать пользователя */
suspend fun blockUser(publicKey: String) {
if (currentAccount.isEmpty()) return
try {
database.blacklistDao().blockUser(
com.rosetta.messenger.database.BlacklistEntity(
publicKey = publicKey,
account = currentAccount
)
)
} catch (e: Exception) {
}
database.blacklistDao()
.blockUser(
com.rosetta.messenger.database.BlacklistEntity(
publicKey = publicKey,
account = currentAccount
)
)
} catch (e: Exception) {}
}
/**
* Разблокировать пользователя
*/
/** Разблокировать пользователя */
suspend fun unblockUser(publicKey: String) {
if (currentAccount.isEmpty()) return
try {
database.blacklistDao().unblockUser(publicKey, currentAccount)
} catch (e: Exception) {
}
} catch (e: Exception) {}
}
/**
* 📬 Загрузить информацию о пользователе для request
* 📁 НЕ загружаем для Saved Messages (свой publicKey)
* 📬 Загрузить информацию о пользователе для request 📁 НЕ загружаем для Saved Messages (свой
* publicKey)
*/
private fun loadUserInfoForRequest(publicKey: String) {
loadUserInfoForDialog(publicKey)
}
/**
* 🔥 Загрузить информацию о пользователе для диалога
* 📁 НЕ загружаем для Saved Messages (свой publicKey)
* 🔥 Загрузить информацию о пользователе для диалога 📁 НЕ загружаем для Saved Messages (свой
* publicKey)
*/
private fun loadUserInfoForDialog(publicKey: String) {
// 📁 Не запрашиваем информацию о самом себе (Saved Messages)
@@ -528,10 +618,11 @@ if (currentAccount == publicKey) {
}
requestedUserInfoKeys.add(publicKey)
viewModelScope.launch(Dispatchers.IO) {
try {
val sharedPrefs = getApplication<Application>().getSharedPreferences("rosetta", Application.MODE_PRIVATE)
val sharedPrefs =
getApplication<Application>()
.getSharedPreferences("rosetta", Application.MODE_PRIVATE)
val currentUserPrivateKey = sharedPrefs.getString("private_key", "") ?: ""
if (currentUserPrivateKey.isEmpty()) return@launch
@@ -539,21 +630,18 @@ if (currentAccount == publicKey) {
// 🔥 ВАЖНО: Используем хеш ключа, как в MessageRepository.requestUserInfo
val privateKeyHash = CryptoManager.generatePrivateKeyHash(currentUserPrivateKey)
// Запрашиваем информацию о пользователе с сервера
val packet = PacketSearch().apply {
this.privateKey = privateKeyHash
this.search = publicKey
}
val packet =
PacketSearch().apply {
this.privateKey = privateKeyHash
this.search = publicKey
}
ProtocolManager.send(packet)
} catch (e: Exception) {
}
} catch (e: Exception) {}
}
}
/**
* Проверить заблокирован ли пользователь
*/
/** Проверить заблокирован ли пользователь */
suspend fun isUserBlocked(publicKey: String): Boolean {
if (currentAccount.isEmpty()) return false

View File

@@ -1,5 +1,7 @@
package com.rosetta.messenger.ui.components
import android.content.Context
import android.view.inputmethod.InputMethodManager
import androidx.compose.animation.core.*
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.*
@@ -16,16 +18,14 @@ import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.unit.dp
import android.content.Context
import android.view.inputmethod.InputMethodManager
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
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
private const val ANIMATION_DURATION_ENTER = 300
private const val ANIMATION_DURATION_EXIT = 200
private const val EDGE_ZONE_DP = 200
@@ -33,8 +33,7 @@ private const val EDGE_ZONE_DP = 200
/**
* Telegram-style swipe back container (optimized)
*
* Wraps content and allows swiping from the left edge to go back.
* Features:
* Wraps content and allows swiping from the left edge to go back. Features:
* - Edge-only swipe detection (left 30dp)
* - Direct state update during drag (no coroutine overhead)
* - VelocityTracker for fling detection
@@ -45,12 +44,18 @@ private const val EDGE_ZONE_DP = 200
*/
@Composable
fun SwipeBackContainer(
isVisible: Boolean,
onBack: () -> Unit,
isDarkTheme: Boolean,
swipeEnabled: Boolean = true,
content: @Composable () -> Unit
isVisible: Boolean,
onBack: () -> Unit,
isDarkTheme: Boolean,
swipeEnabled: Boolean = true,
content: @Composable () -> Unit
) {
// 🚀 Lazy composition: skip ALL setup until the screen is opened for the first time.
// Saves ~15 remember/Animatable allocations per unused screen at startup (×12 screens).
var wasEverVisible by remember { mutableStateOf(false) }
if (isVisible) wasEverVisible = true
if (!wasEverVisible) return
val density = LocalDensity.current
val configuration = LocalConfiguration.current
val screenWidthPx = with(density) { configuration.screenWidthDp.dp.toPx() }
@@ -74,7 +79,8 @@ fun SwipeBackContainer(
// Coroutine scope for animations
val scope = rememberCoroutineScope()
// 🔥 Keyboard controller для скрытия клавиатуры при свайпе (надёжный метод через InputMethodManager)
// 🔥 Keyboard controller для скрытия клавиатуры при свайпе (надёжный метод через
// InputMethodManager)
val context = LocalContext.current
val view = LocalView.current
val focusManager = LocalFocusManager.current
@@ -94,14 +100,15 @@ fun SwipeBackContainer(
// Animate in: fade-in
shouldShow = true
isAnimatingIn = true
offsetAnimatable.snapTo(0f) // No slide for entry
offsetAnimatable.snapTo(0f) // No slide for entry
alphaAnimatable.snapTo(0f)
alphaAnimatable.animateTo(
targetValue = 1f,
animationSpec = tween(
durationMillis = ANIMATION_DURATION_ENTER,
easing = FastOutSlowInEasing
)
targetValue = 1f,
animationSpec =
tween(
durationMillis = ANIMATION_DURATION_ENTER,
easing = FastOutSlowInEasing
)
)
isAnimatingIn = false
} else if (!isVisible && shouldShow && !isAnimatingOut) {
@@ -109,11 +116,8 @@ fun SwipeBackContainer(
isAnimatingOut = true
alphaAnimatable.snapTo(1f)
alphaAnimatable.animateTo(
targetValue = 0f,
animationSpec = tween(
durationMillis = 200,
easing = FastOutSlowInEasing
)
targetValue = 0f,
animationSpec = tween(durationMillis = 200, easing = FastOutSlowInEasing)
)
shouldShow = false
isAnimatingOut = false
@@ -128,133 +132,176 @@ fun SwipeBackContainer(
Box(modifier = Modifier.fillMaxSize()) {
// Scrim (dimming layer behind the screen) - only when swiping
if (currentOffset > 0f) {
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = scrimAlpha))
)
Box(modifier = Modifier.fillMaxSize().background(Color.Black.copy(alpha = scrimAlpha)))
}
// Content with swipe gesture
Box(
modifier = Modifier
.fillMaxSize()
.graphicsLayer {
translationX = currentOffset
alpha = currentAlpha
}
.background(if (isDarkTheme) Color(0xFF1B1B1B) else Color.White)
.then(
if (swipeEnabled && !isAnimatingIn && !isAnimatingOut) {
Modifier.pointerInput(Unit) {
val velocityTracker = VelocityTracker()
val touchSlop = viewConfiguration.touchSlop
awaitEachGesture {
val down = awaitFirstDown(requireUnconsumed = false)
// Edge-only detection
if (down.position.x > edgeZonePx) {
return@awaitEachGesture
modifier =
Modifier.fillMaxSize()
.graphicsLayer {
translationX = currentOffset
alpha = currentAlpha
}
.background(if (isDarkTheme) Color(0xFF1B1B1B) else Color.White)
.then(
if (swipeEnabled && !isAnimatingIn && !isAnimatingOut) {
Modifier.pointerInput(Unit) {
val velocityTracker = VelocityTracker()
val touchSlop = viewConfiguration.touchSlop
velocityTracker.resetTracking()
var startedSwipe = false
var totalDragX = 0f
var totalDragY = 0f
var passedSlop = false
awaitEachGesture {
val down =
awaitFirstDown(
requireUnconsumed = false
)
// Use Initial pass to intercept BEFORE children
while (true) {
val event = awaitPointerEvent(PointerEventPass.Initial)
val change = event.changes.firstOrNull { it.id == down.id }
?: break
// Edge-only detection
if (down.position.x > edgeZonePx) {
return@awaitEachGesture
}
if (change.changedToUpIgnoreConsumed()) {
break
}
velocityTracker.resetTracking()
var startedSwipe = false
var totalDragX = 0f
var totalDragY = 0f
var passedSlop = false
val dragDelta = change.positionChange()
totalDragX += dragDelta.x
totalDragY += dragDelta.y
// Use Initial pass to intercept BEFORE children
while (true) {
val event =
awaitPointerEvent(
PointerEventPass.Initial
)
val change =
event.changes.firstOrNull {
it.id == down.id
}
?: break
if (!passedSlop) {
val totalDistance = kotlin.math.sqrt(totalDragX * totalDragX + totalDragY * totalDragY)
if (totalDistance < touchSlop) continue
if (change.changedToUpIgnoreConsumed()) {
break
}
// Slop exceeded — only claim rightward + mostly horizontal
if (totalDragX > 0 && kotlin.math.abs(totalDragX) > kotlin.math.abs(totalDragY) * 1.5f) {
passedSlop = true
startedSwipe = true
isDragging = true
dragOffset = offsetAnimatable.value
val dragDelta = change.positionChange()
totalDragX += dragDelta.x
totalDragY += dragDelta.y
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
imm.hideSoftInputFromWindow(view.windowToken, 0)
focusManager.clearFocus()
if (!passedSlop) {
val totalDistance =
kotlin.math.sqrt(
totalDragX *
totalDragX +
totalDragY *
totalDragY
)
if (totalDistance < touchSlop) continue
change.consume()
// Slop exceeded — only claim rightward
// + mostly horizontal
if (totalDragX > 0 &&
kotlin.math.abs(
totalDragX
) >
kotlin.math.abs(
totalDragY
) * 1.5f
) {
passedSlop = true
startedSwipe = true
isDragging = true
dragOffset = offsetAnimatable.value
val imm =
context.getSystemService(
Context.INPUT_METHOD_SERVICE
) as
InputMethodManager
imm.hideSoftInputFromWindow(
view.windowToken,
0
)
focusManager.clearFocus()
change.consume()
} else {
// Vertical or leftward — let
// children handle
break
}
} else {
// We own the gesture — update drag
dragOffset =
(dragOffset + dragDelta.x)
.coerceIn(
0f,
screenWidthPx
)
velocityTracker.addPosition(
change.uptimeMillis,
change.position
)
change.consume()
}
}
// Handle drag end
if (startedSwipe) {
isDragging = false
val velocity =
velocityTracker.calculateVelocity()
.x
val currentProgress =
dragOffset / screenWidthPx
val shouldComplete =
currentProgress >
0.5f || // Past 50% — always
// complete
velocity >
FLING_VELOCITY_THRESHOLD || // Fast fling right
(currentProgress >
COMPLETION_THRESHOLD &&
velocity >
-FLING_VELOCITY_THRESHOLD) // 20%+ and not flinging back
scope.launch {
offsetAnimatable.snapTo(dragOffset)
if (shouldComplete) {
offsetAnimatable.animateTo(
targetValue = screenWidthPx,
animationSpec =
tween(
durationMillis =
ANIMATION_DURATION_EXIT,
easing =
TelegramEasing
)
)
onBack()
} else {
offsetAnimatable.animateTo(
targetValue = 0f,
animationSpec =
tween(
durationMillis =
ANIMATION_DURATION_EXIT,
easing =
TelegramEasing
)
)
}
dragOffset = 0f
}
}
}
}
} else {
// Vertical or leftward — let children handle
break
Modifier
}
} else {
// We own the gesture — update drag
dragOffset = (dragOffset + dragDelta.x)
.coerceIn(0f, screenWidthPx)
velocityTracker.addPosition(
change.uptimeMillis,
change.position
)
change.consume()
}
}
// Handle drag end
if (startedSwipe) {
isDragging = false
val velocity = velocityTracker.calculateVelocity().x
val currentProgress = dragOffset / screenWidthPx
val shouldComplete =
currentProgress > 0.5f || // Past 50% — always complete
velocity > FLING_VELOCITY_THRESHOLD || // Fast fling right
(currentProgress > COMPLETION_THRESHOLD &&
velocity > -FLING_VELOCITY_THRESHOLD) // 20%+ and not flinging back
scope.launch {
offsetAnimatable.snapTo(dragOffset)
if (shouldComplete) {
offsetAnimatable.animateTo(
targetValue = screenWidthPx,
animationSpec = tween(
durationMillis = ANIMATION_DURATION_EXIT,
easing = TelegramEasing
)
)
onBack()
} else {
offsetAnimatable.animateTo(
targetValue = 0f,
animationSpec = tween(
durationMillis = ANIMATION_DURATION_EXIT,
easing = TelegramEasing
)
)
}
dragOffset = 0f
}
}
}
}
} else {
Modifier
}
)
) {
content()
}
)
) { content() }
}
}