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:
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 import androidx.sqlite.db.SupportSQLiteDatabase
@Database( @Database(
entities = [ entities =
EncryptedAccountEntity::class, [
MessageEntity::class, EncryptedAccountEntity::class,
DialogEntity::class, MessageEntity::class,
BlacklistEntity::class, DialogEntity::class,
AvatarCacheEntity::class BlacklistEntity::class,
], AvatarCacheEntity::class],
version = 10, version = 11,
exportSchema = false exportSchema = false
) )
abstract class RosettaDatabase : RoomDatabase() { abstract class RosettaDatabase : RoomDatabase() {
abstract fun accountDao(): AccountDao abstract fun accountDao(): AccountDao
@@ -26,93 +26,140 @@ abstract class RosettaDatabase : RoomDatabase() {
abstract fun avatarDao(): AvatarDao abstract fun avatarDao(): AvatarDao
companion object { companion object {
@Volatile @Volatile private var INSTANCE: RosettaDatabase? = null
private var INSTANCE: RosettaDatabase? = null
private val MIGRATION_4_5 = object : Migration(4, 5) { private val MIGRATION_4_5 =
override fun migrate(database: SupportSQLiteDatabase) { 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(
database.execSQL("ALTER TABLE dialogs ADD COLUMN last_message_read INTEGER NOT NULL DEFAULT 0") "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) { private val MIGRATION_5_6 =
override fun migrate(database: SupportSQLiteDatabase) { object : Migration(5, 6) {
// Добавляем поле username в encrypted_accounts override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("ALTER TABLE encrypted_accounts ADD COLUMN username TEXT") // Добавляем поле username в encrypted_accounts
} database.execSQL("ALTER TABLE encrypted_accounts ADD COLUMN username TEXT")
} }
}
private val MIGRATION_6_7 = object : Migration(6, 7) { private val MIGRATION_6_7 =
override fun migrate(database: SupportSQLiteDatabase) { object : Migration(6, 7) {
// Создаем таблицу для кэша аватаров override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL(""" // Создаем таблицу для кэша аватаров
database.execSQL(
"""
CREATE TABLE IF NOT EXISTS avatar_cache ( CREATE TABLE IF NOT EXISTS avatar_cache (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
public_key TEXT NOT NULL, public_key TEXT NOT NULL,
avatar TEXT NOT NULL, avatar TEXT NOT NULL,
timestamp INTEGER 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) { private val MIGRATION_7_8 =
override fun migrate(database: SupportSQLiteDatabase) { object : Migration(7, 8) {
// Удаляем таблицу avatar_delivery (больше не нужна) override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("DROP TABLE IF EXISTS avatar_delivery") // Удаляем таблицу avatar_delivery (больше не нужна)
} database.execSQL("DROP TABLE IF EXISTS avatar_delivery")
} }
}
/** /**
* 🔥 МИГРАЦИЯ 8->9: Очищаем blob из attachments (как в desktop) * 🔥 МИГРАЦИЯ 8->9: Очищаем blob из attachments (как в desktop) Blob слишком большой для
* Blob слишком большой для SQLite CursorWindow (2MB лимит) * SQLite CursorWindow (2MB лимит) Просто обнуляем attachments - изображения перескачаются с
* Просто обнуляем attachments - изображения перескачаются с CDN * CDN
*/ */
private val MIGRATION_8_9 = object : Migration(8, 9) { private val MIGRATION_8_9 =
override fun migrate(database: SupportSQLiteDatabase) { object : Migration(8, 9) {
// Очищаем все attachments с большими blob'ами override fun migrate(database: SupportSQLiteDatabase) {
// Они будут перескачаны с CDN при открытии // Очищаем все attachments с большими blob'ами
database.execSQL(""" // Они будут перескачаны с CDN при открытии
database.execSQL(
"""
UPDATE messages UPDATE messages
SET attachments = '[]' SET attachments = '[]'
WHERE length(attachments) > 10000 WHERE length(attachments) > 10000
""") """
} )
} }
}
/** /**
* 🔥 МИГРАЦИЯ 9->10: Повторная очистка blob из attachments * 🔥 МИГРАЦИЯ 9->10: Повторная очистка blob из attachments Для пользователей которые уже
* Для пользователей которые уже были на версии 9 * были на версии 9
*/ */
private val MIGRATION_9_10 = object : Migration(9, 10) { private val MIGRATION_9_10 =
override fun migrate(database: SupportSQLiteDatabase) { object : Migration(9, 10) {
// Очищаем все attachments с большими blob'ами override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL(""" // Очищаем все attachments с большими blob'ами
database.execSQL(
"""
UPDATE messages UPDATE messages
SET attachments = '[]' SET attachments = '[]'
WHERE length(attachments) > 10000 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 { fun getDatabase(context: Context): RosettaDatabase {
return INSTANCE ?: synchronized(this) { return INSTANCE
val instance = Room.databaseBuilder( ?: synchronized(this) {
context.applicationContext, val instance =
RosettaDatabase::class.java, Room.databaseBuilder(
"rosetta_secure.db" context.applicationContext,
) RosettaDatabase::class.java,
.setJournalMode(JournalMode.WRITE_AHEAD_LOGGING) // WAL mode for performance "rosetta_secure.db"
.addMigrations(MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8, MIGRATION_8_9, MIGRATION_9_10) )
.fallbackToDestructiveMigration() // Для разработки - только если миграция не найдена .setJournalMode(
.build() JournalMode.WRITE_AHEAD_LOGGING
INSTANCE = instance ) // WAL mode for performance
instance .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 package com.rosetta.messenger.ui.chats
import android.app.Application import android.app.Application
import androidx.compose.runtime.Immutable
import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.rosetta.messenger.crypto.CryptoManager import com.rosetta.messenger.crypto.CryptoManager
import com.rosetta.messenger.data.MessageRepository 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.BlacklistEntity
import com.rosetta.messenger.database.RosettaDatabase
import com.rosetta.messenger.network.PacketOnlineSubscribe import com.rosetta.messenger.network.PacketOnlineSubscribe
import com.rosetta.messenger.network.PacketSearch import com.rosetta.messenger.network.PacketSearch
import com.rosetta.messenger.network.ProtocolManager import com.rosetta.messenger.network.ProtocolManager
import com.rosetta.messenger.network.SearchUser import com.rosetta.messenger.network.SearchUser
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll import kotlinx.coroutines.awaitAll
import androidx.compose.runtime.Immutable
import kotlinx.coroutines.flow.* import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
/** /** UI модель диалога с расшифрованным lastMessage */
* UI модель диалога с расшифрованным lastMessage
*/
@Immutable @Immutable
data class DialogUiModel( data class DialogUiModel(
val id: Long, val id: Long,
val account: String, val account: String,
val opponentKey: String, val opponentKey: String,
val opponentTitle: String, val opponentTitle: String,
val opponentUsername: String, val opponentUsername: String,
val lastMessage: String, // 🔓 Расшифрованный текст val lastMessage: String, // 🔓 Расшифрованный текст
val lastMessageTimestamp: Long, val lastMessageTimestamp: Long,
val unreadCount: Int, val unreadCount: Int,
val isOnline: Int, val isOnline: Int,
val lastSeen: Long, val lastSeen: Long,
val verified: Int, val verified: Int,
val isSavedMessages: Boolean = false, // 📁 Флаг для Saved Messages (account == opponentKey) val isSavedMessages: Boolean = false, // 📁 Флаг для Saved Messages (account == opponentKey)
val lastMessageFromMe: Int = 0, // Последнее сообщение от меня (0/1) val lastMessageFromMe: Int = 0, // Последнее сообщение от меня (0/1)
val lastMessageDelivered: Int = 0, // Статус доставки (0=WAITING, 1=DELIVERED, 2=ERROR) val lastMessageDelivered: Int = 0, // Статус доставки (0=WAITING, 1=DELIVERED, 2=ERROR)
val lastMessageRead: Int = 0, // Прочитано (0/1) val lastMessageRead: Int = 0, // Прочитано (0/1)
val lastMessageAttachmentType: String? = null // 📎 Тип attachment: "Photo", "File", или null val lastMessageAttachmentType: String? =
null // 📎 Тип attachment: "Photo", "File", или null
) )
/** /**
* 🔥 Комбинированное состояние чатов для атомарного обновления UI * 🔥 Комбинированное состояние чатов для атомарного обновления UI Это предотвращает "дергание"
* Это предотвращает "дергание" когда dialogs и requests обновляются независимо * когда dialogs и requests обновляются независимо
*/ */
@Immutable @Immutable
data class ChatsUiState( data class ChatsUiState(
val dialogs: List<DialogUiModel> = emptyList(), val dialogs: List<DialogUiModel> = emptyList(),
val requests: List<DialogUiModel> = emptyList(), val requests: List<DialogUiModel> = emptyList(),
val requestsCount: Int = 0 val requestsCount: Int = 0
) { ) {
val isEmpty: Boolean get() = dialogs.isEmpty() && requestsCount == 0 val isEmpty: Boolean
val hasContent: Boolean get() = dialogs.isNotEmpty() || requestsCount > 0 get() = dialogs.isEmpty() && requestsCount == 0
val hasContent: Boolean
get() = dialogs.isNotEmpty() || requestsCount > 0
} }
/** /** ViewModel для списка чатов Загружает диалоги из базы данных и расшифровывает lastMessage */
* ViewModel для списка чатов
* Загружает диалоги из базы данных и расшифровывает lastMessage
*/
class ChatsListViewModel(application: Application) : AndroidViewModel(application) { class ChatsListViewModel(application: Application) : AndroidViewModel(application) {
private val database = RosettaDatabase.getDatabase(application) private val database = RosettaDatabase.getDatabase(application)
private val dialogDao = database.dialogDao() private val dialogDao = database.dialogDao()
private val messageDao = database.messageDao() // 🔥 Добавляем для получения статуса последнего сообщения
private var currentAccount: String = "" private var currentAccount: String = ""
private var currentPrivateKey: String? = null private var currentPrivateKey: String? = null
@@ -94,19 +91,18 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
// 🔥 НОВОЕ: Комбинированное состояние - обновляется атомарно! // 🔥 НОВОЕ: Комбинированное состояние - обновляется атомарно!
// 🎯 ИСПРАВЛЕНИЕ: debounce предотвращает мерцание при быстрых обновлениях // 🎯 ИСПРАВЛЕНИЕ: debounce предотвращает мерцание при быстрых обновлениях
val chatsState: StateFlow<ChatsUiState> = combine( val chatsState: StateFlow<ChatsUiState> =
_dialogs, combine(_dialogs, _requests, _requestsCount) { dialogs, requests, count ->
_requests, ChatsUiState(dialogs, requests, count)
_requestsCount }
) { dialogs, requests, count -> .distinctUntilChanged() // 🔥 Игнорируем дублирующиеся состояния
ChatsUiState(dialogs, requests, count) .stateIn(
} viewModelScope,
.distinctUntilChanged() // 🔥 Игнорируем дублирующиеся состояния SharingStarted
.stateIn( .Eagerly, // 🔥 КРИТИЧНО: Eagerly вместо WhileSubscribed - сразу
viewModelScope, // начинаем следить
SharingStarted.Eagerly, // 🔥 КРИТИЧНО: Eagerly вместо WhileSubscribed - сразу начинаем следить ChatsUiState()
ChatsUiState() )
)
// Загрузка // Загрузка
private val _isLoading = MutableStateFlow(false) private val _isLoading = MutableStateFlow(false)
@@ -114,12 +110,10 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
private val TAG = "ChatsListVM" private val TAG = "ChatsListVM"
/** /** Установить текущий аккаунт и загрузить диалоги */
* Установить текущий аккаунт и загрузить диалоги
*/
fun setAccount(publicKey: String, privateKey: String) { fun setAccount(publicKey: String, privateKey: String) {
val setAccountStart = System.currentTimeMillis() val setAccountStart = System.currentTimeMillis()
if (currentAccount == publicKey) { if (currentAccount == publicKey) {
return return
} }
@@ -129,222 +123,324 @@ if (currentAccount == publicKey) {
currentAccount = publicKey currentAccount = publicKey
currentPrivateKey = privateKey currentPrivateKey = privateKey
// Подписываемся на обычные диалоги // Подписываемся на обычные диалоги
@OptIn(FlowPreview::class)
viewModelScope.launch { viewModelScope.launch {
dialogDao.getDialogsFlow(publicKey) dialogDao
.flowOn(Dispatchers.IO) // 🚀 Flow работает на IO .getDialogsFlow(publicKey)
.map { dialogsList -> .flowOn(Dispatchers.IO) // 🚀 Flow работает на IO
val mapStart = System.currentTimeMillis() .debounce(100) // 🚀 Батчим быстрые обновления (N сообщений → 1 обработка)
// <20> ОПТИМИЗАЦИЯ: Параллельная расшифровка всех сообщений .map { dialogsList ->
withContext(Dispatchers.Default) { val mapStart = System.currentTimeMillis()
dialogsList.map { dialog -> // <20> ОПТИМИЗАЦИЯ: Параллельная расшифровка всех сообщений
async { withContext(Dispatchers.Default) {
// 🔥 Загружаем информацию о пользователе если её нет dialogsList
// 📁 НЕ загружаем для Saved Messages .map { dialog ->
val isSavedMessages = (dialog.account == dialog.opponentKey) async {
if (!isSavedMessages && (dialog.opponentTitle.isEmpty() || dialog.opponentTitle == dialog.opponentKey || dialog.opponentTitle == dialog.opponentKey.take(7))) { // 🔥 Загружаем информацию о пользователе если её нет
loadUserInfoForDialog(dialog.opponentKey) // 📁 НЕ загружаем для Saved Messages
} val isSavedMessages =
(dialog.account == dialog.opponentKey)
// 🚀 Расшифровка теперь кэшируется в CryptoManager! if (!isSavedMessages &&
val decryptedLastMessage = try { (dialog.opponentTitle.isEmpty() ||
if (privateKey.isNotEmpty() && dialog.lastMessage.isNotEmpty()) { dialog.opponentTitle ==
CryptoManager.decryptWithPassword(dialog.lastMessage, privateKey) dialog.opponentKey ||
?: dialog.lastMessage dialog.opponentTitle ==
} else { dialog.opponentKey.take(
dialog.lastMessage 7
} ))
} catch (e: Exception) { ) {
dialog.lastMessage // Fallback на зашифрованный текст loadUserInfoForDialog(dialog.opponentKey)
}
// 🔥🔥🔥 НОВЫЙ ПОДХОД: Получаем статус НАПРЯМУЮ из таблицы 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
} }
} else null
} else null // 🚀 Расшифровка теперь кэшируется в CryptoManager!
} catch (e: Exception) { val decryptedLastMessage =
null 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, // 📁 Исключаем Saved Messages - не нужно подписываться на свой собственный
account = dialog.account, // статус
opponentKey = dialog.opponentKey, val opponentsToSubscribe =
opponentTitle = dialog.opponentTitle, decryptedDialogs.filter { !it.isSavedMessages }.map {
opponentUsername = dialog.opponentUsername, it.opponentKey
lastMessage = decryptedLastMessage, }
lastMessageTimestamp = dialog.lastMessageTimestamp, subscribeToOnlineStatuses(opponentsToSubscribe, privateKey)
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)
}
} }
// 📬 Подписываемся на requests (запросы от новых пользователей) // 📬 Подписываемся на requests (запросы от новых пользователей)
@OptIn(FlowPreview::class)
viewModelScope.launch { viewModelScope.launch {
dialogDao.getRequestsFlow(publicKey) dialogDao
.flowOn(Dispatchers.IO) .getRequestsFlow(publicKey)
.map { requestsList -> .flowOn(Dispatchers.IO)
// 🚀 ОПТИМИЗАЦИЯ: Параллельная расшифровка .debounce(100) // 🚀 Батчим быстрые обновления
withContext(Dispatchers.Default) { .map { requestsList ->
requestsList.map { dialog -> // 🚀 ОПТИМИЗАЦИЯ: Параллельная расшифровка
async { withContext(Dispatchers.Default) {
// 🔥 Загружаем информацию о пользователе если её нет requestsList
// 📁 НЕ загружаем для Saved Messages .map { dialog ->
val isSavedMessages = (dialog.account == dialog.opponentKey) async {
if (!isSavedMessages && (dialog.opponentTitle.isEmpty() || dialog.opponentTitle == dialog.opponentKey)) { // 🔥 Загружаем информацию о пользователе если её нет
loadUserInfoForRequest(dialog.opponentKey) // 📁 НЕ загружаем для Saved Messages
} val isSavedMessages =
(dialog.account == dialog.opponentKey)
// 🚀 Расшифровка теперь кэшируется в CryptoManager! if (!isSavedMessages &&
val decryptedLastMessage = try { (dialog.opponentTitle.isEmpty() ||
if (privateKey.isNotEmpty() && dialog.lastMessage.isNotEmpty()) { dialog.opponentTitle ==
CryptoManager.decryptWithPassword(dialog.lastMessage, privateKey) dialog.opponentKey)
?: dialog.lastMessage ) {
} else { loadUserInfoForRequest(dialog.opponentKey)
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
} }
} else null
} else null
} catch (e: Exception) {
null
}
DialogUiModel( // 🚀 Расшифровка теперь кэшируется в CryptoManager!
id = dialog.id, val decryptedLastMessage =
account = dialog.account, try {
opponentKey = dialog.opponentKey, if (privateKey.isNotEmpty() &&
opponentTitle = dialog.opponentTitle, // 🔥 Показываем имя как в обычных чатах dialog.lastMessage
opponentUsername = dialog.opponentUsername, .isNotEmpty()
lastMessage = decryptedLastMessage, ) {
lastMessageTimestamp = dialog.lastMessageTimestamp, CryptoManager.decryptWithPassword(
unreadCount = dialog.unreadCount, dialog.lastMessage,
isOnline = dialog.isOnline, privateKey
lastSeen = dialog.lastSeen, )
verified = dialog.verified, ?: dialog.lastMessage
isSavedMessages = (dialog.account == dialog.opponentKey), // 📁 Saved Messages } else {
lastMessageFromMe = dialog.lastMessageFromMe, dialog.lastMessage
lastMessageDelivered = dialog.lastMessageDelivered, }
lastMessageRead = dialog.lastMessageRead, } catch (e: Exception) {
lastMessageAttachmentType = attachmentType // 📎 Тип attachment dialog.lastMessage
) }
}
}.awaitAll() // 📎 Определяем тип 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() // 🔥 ИСПРАВЛЕНИЕ: Игнорируем дублирующиеся списки
.distinctUntilChanged() // 🔥 ИСПРАВЛЕНИЕ: Игнорируем дублирующиеся списки .collect { decryptedRequests -> _requests.value = decryptedRequests }
.collect { decryptedRequests ->
_requests.value = decryptedRequests
}
} }
// 📊 Подписываемся на количество requests // 📊 Подписываемся на количество requests
viewModelScope.launch { viewModelScope.launch {
dialogDao.getRequestsCountFlow(publicKey) dialogDao
.flowOn(Dispatchers.IO) .getRequestsCountFlow(publicKey)
.distinctUntilChanged() // 🔥 ИСПРАВЛЕНИЕ: Игнорируем дублирующиеся значения .flowOn(Dispatchers.IO)
.collect { count -> .distinctUntilChanged() // 🔥 ИСПРАВЛЕНИЕ: Игнорируем дублирующиеся значения
_requestsCount.value = count .collect { count -> _requestsCount.value = count }
}
} }
// 🔥 Подписываемся на блоклист — Room автоматически переэмитит при blockUser()/unblockUser() // 🔥 Подписываемся на блоклист — Room автоматически переэмитит при
// blockUser()/unblockUser()
viewModelScope.launch { viewModelScope.launch {
database.blacklistDao().getBlockedUsers(publicKey) database.blacklistDao()
.flowOn(Dispatchers.IO) .getBlockedUsers(publicKey)
.map { entities -> entities.map { it.publicKey }.toSet() } .flowOn(Dispatchers.IO)
.distinctUntilChanged() .map { entities -> entities.map { it.publicKey }.toSet() }
.collect { blockedSet -> .distinctUntilChanged()
_blockedUsers.value = blockedSet .collect { blockedSet -> _blockedUsers.value = blockedSet }
}
} }
} }
/** /**
* 🟢 Подписаться на онлайн-статусы всех собеседников * 🟢 Подписаться на онлайн-статусы всех собеседников 🔥 Фильтруем уже подписанные ключи чтобы
* 🔥 Фильтруем уже подписанные ключи чтобы избежать бесконечного цикла * избежать бесконечного цикла
*/ */
private fun subscribeToOnlineStatuses(opponentKeys: List<String>, privateKey: String) { private fun subscribeToOnlineStatuses(opponentKeys: List<String>, privateKey: String) {
if (opponentKeys.isEmpty()) return if (opponentKeys.isEmpty()) return
// 🔥 КРИТИЧНО: Фильтруем уже подписанные ключи! // 🔥 КРИТИЧНО: Фильтруем уже подписанные ключи!
val newKeys = opponentKeys.filter { !subscribedOnlineKeys.contains(it) } val newKeys = opponentKeys.filter { !subscribedOnlineKeys.contains(it) }
if (newKeys.isEmpty()) return // Все уже подписаны if (newKeys.isEmpty()) return // Все уже подписаны
// Добавляем в Set ДО отправки пакета чтобы избежать race condition // Добавляем в Set ДО отправки пакета чтобы избежать race condition
subscribedOnlineKeys.addAll(newKeys) subscribedOnlineKeys.addAll(newKeys)
@@ -353,32 +449,30 @@ if (currentAccount == publicKey) {
try { try {
val privateKeyHash = CryptoManager.generatePrivateKeyHash(privateKey) val privateKeyHash = CryptoManager.generatePrivateKeyHash(privateKey)
val packet = PacketOnlineSubscribe().apply { val packet =
this.privateKey = privateKeyHash PacketOnlineSubscribe().apply {
newKeys.forEach { key -> this.privateKey = privateKeyHash
addPublicKey(key) newKeys.forEach { key -> addPublicKey(key) }
} }
}
ProtocolManager.send(packet) ProtocolManager.send(packet)
} catch (e: Exception) { } catch (e: Exception) {}
}
} }
} }
/** /**
* Создать или обновить диалог после отправки/получения сообщения * Создать или обновить диалог после отправки/получения сообщения 🔥 Используем
* 🔥 Используем updateDialogFromMessages для пересчета счетчиков из messages * updateDialogFromMessages для пересчета счетчиков из messages 📁 SAVED MESSAGES: Использует
* 📁 SAVED MESSAGES: Использует специальный метод для saved messages * специальный метод для saved messages
*/ */
suspend fun upsertDialog( suspend fun upsertDialog(
opponentKey: String, opponentKey: String,
opponentTitle: String, opponentTitle: String,
opponentUsername: String = "", opponentUsername: String = "",
lastMessage: String, lastMessage: String,
timestamp: Long, timestamp: Long,
verified: Int = 0, verified: Int = 0,
isOnline: Int = 0 isOnline: Int = 0
) { ) {
if (currentAccount.isEmpty()) return if (currentAccount.isEmpty()) return
@@ -394,29 +488,31 @@ if (currentAccount == publicKey) {
// Обновляем информацию о собеседнике если есть // Обновляем информацию о собеседнике если есть
if (opponentTitle.isNotEmpty()) { 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 { fun dialogToSearchUser(dialog: DialogUiModel): SearchUser {
return SearchUser( return SearchUser(
title = dialog.opponentTitle, title = dialog.opponentTitle,
username = dialog.opponentUsername, username = dialog.opponentUsername,
publicKey = dialog.opponentKey, publicKey = dialog.opponentKey,
verified = dialog.verified, verified = dialog.verified,
online = dialog.isOnline online = dialog.isOnline
) )
} }
/** /**
* Удалить диалог и все сообщения с собеседником * Удалить диалог и все сообщения с собеседником 🔥 ПОЛНОСТЬЮ очищает все данные - диалог,
* 🔥 ПОЛНОСТЬЮ очищает все данные - диалог, сообщения, кэш * сообщения, кэш
*/ */
suspend fun deleteDialog(opponentKey: String) { suspend fun deleteDialog(opponentKey: String) {
if (currentAccount.isEmpty()) return if (currentAccount.isEmpty()) return
@@ -430,11 +526,12 @@ if (currentAccount == publicKey) {
_requestsCount.value = _requests.value.size _requestsCount.value = _requests.value.size
// Вычисляем правильный dialog_key (отсортированная комбинация ключей) // Вычисляем правильный dialog_key (отсортированная комбинация ключей)
val dialogKey = if (currentAccount < opponentKey) { val dialogKey =
"$currentAccount:$opponentKey" if (currentAccount < opponentKey) {
} else { "$currentAccount:$opponentKey"
"$opponentKey:$currentAccount" } else {
} "$opponentKey:$currentAccount"
}
// 🗑️ 1. Очищаем ВСЕ кэши сообщений // 🗑️ 1. Очищаем ВСЕ кэши сообщений
MessageRepository.getInstance(getApplication()).clearDialogCache(opponentKey) MessageRepository.getInstance(getApplication()).clearDialogCache(opponentKey)
@@ -442,79 +539,72 @@ if (currentAccount == publicKey) {
ChatViewModel.clearCacheForOpponent(opponentKey) ChatViewModel.clearCacheForOpponent(opponentKey)
// 🗑️ 2. Проверяем сколько сообщений в БД до удаления // 🗑️ 2. Проверяем сколько сообщений в БД до удаления
val messageCountBefore = database.messageDao().getMessageCount(currentAccount, dialogKey) val messageCountBefore =
database.messageDao().getMessageCount(currentAccount, dialogKey)
// 🗑️ 3. Удаляем все сообщения из диалога по dialog_key // 🗑️ 3. Удаляем все сообщения из диалога по dialog_key
val deletedByDialogKey = database.messageDao().deleteDialog( val deletedByDialogKey =
account = currentAccount, database.messageDao()
dialogKey = dialogKey .deleteDialog(account = currentAccount, dialogKey = dialogKey)
)
// 🗑️ 4. Также удаляем по from/to ключам (на всякий случай - старые сообщения) // 🗑️ 4. Также удаляем по from/to ключам (на всякий случай - старые сообщения)
val deletedBetweenUsers = database.messageDao().deleteMessagesBetweenUsers( val deletedBetweenUsers =
account = currentAccount, database.messageDao()
user1 = opponentKey, .deleteMessagesBetweenUsers(
user2 = currentAccount account = currentAccount,
) user1 = opponentKey,
user2 = currentAccount
)
// 🗑️ 5. Проверяем сколько сообщений осталось // 🗑️ 5. Проверяем сколько сообщений осталось
val messageCountAfter = database.messageDao().getMessageCount(currentAccount, dialogKey) val messageCountAfter = database.messageDao().getMessageCount(currentAccount, dialogKey)
// 🗑️ 6. Удаляем диалог из таблицы dialogs // 🗑️ 6. Удаляем диалог из таблицы dialogs
database.dialogDao().deleteDialog( database.dialogDao().deleteDialog(account = currentAccount, opponentKey = opponentKey)
account = currentAccount,
opponentKey = opponentKey
)
// 🗑️ 7. Проверяем что диалог удален // 🗑️ 7. Проверяем что диалог удален
val dialogAfter = database.dialogDao().getDialog(currentAccount, opponentKey) val dialogAfter = database.dialogDao().getDialog(currentAccount, opponentKey)
} catch (e: Exception) { } catch (e: Exception) {
// В случае ошибки - возвращаем диалог обратно (откатываем оптимистичное обновление) // В случае ошибки - возвращаем диалог обратно (откатываем оптимистичное обновление)
// Flow обновится автоматически из БД // Flow обновится автоматически из БД
} }
} }
/** /** Заблокировать пользователя */
* Заблокировать пользователя
*/
suspend fun blockUser(publicKey: String) { suspend fun blockUser(publicKey: String) {
if (currentAccount.isEmpty()) return if (currentAccount.isEmpty()) return
try { try {
database.blacklistDao().blockUser( database.blacklistDao()
com.rosetta.messenger.database.BlacklistEntity( .blockUser(
publicKey = publicKey, com.rosetta.messenger.database.BlacklistEntity(
account = currentAccount publicKey = publicKey,
) account = currentAccount
) )
} catch (e: Exception) { )
} } catch (e: Exception) {}
} }
/** /** Разблокировать пользователя */
* Разблокировать пользователя
*/
suspend fun unblockUser(publicKey: String) { suspend fun unblockUser(publicKey: String) {
if (currentAccount.isEmpty()) return if (currentAccount.isEmpty()) return
try { try {
database.blacklistDao().unblockUser(publicKey, currentAccount) database.blacklistDao().unblockUser(publicKey, currentAccount)
} catch (e: Exception) { } catch (e: Exception) {}
}
} }
/** /**
* 📬 Загрузить информацию о пользователе для request * 📬 Загрузить информацию о пользователе для request 📁 НЕ загружаем для Saved Messages (свой
* 📁 НЕ загружаем для Saved Messages (свой publicKey) * publicKey)
*/ */
private fun loadUserInfoForRequest(publicKey: String) { private fun loadUserInfoForRequest(publicKey: String) {
loadUserInfoForDialog(publicKey) loadUserInfoForDialog(publicKey)
} }
/** /**
* 🔥 Загрузить информацию о пользователе для диалога * 🔥 Загрузить информацию о пользователе для диалога 📁 НЕ загружаем для Saved Messages (свой
* 📁 НЕ загружаем для Saved Messages (свой publicKey) * publicKey)
*/ */
private fun loadUserInfoForDialog(publicKey: String) { private fun loadUserInfoForDialog(publicKey: String) {
// 📁 Не запрашиваем информацию о самом себе (Saved Messages) // 📁 Не запрашиваем информацию о самом себе (Saved Messages)
@@ -528,10 +618,11 @@ if (currentAccount == publicKey) {
} }
requestedUserInfoKeys.add(publicKey) requestedUserInfoKeys.add(publicKey)
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
try { 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", "") ?: "" val currentUserPrivateKey = sharedPrefs.getString("private_key", "") ?: ""
if (currentUserPrivateKey.isEmpty()) return@launch if (currentUserPrivateKey.isEmpty()) return@launch
@@ -539,21 +630,18 @@ if (currentAccount == publicKey) {
// 🔥 ВАЖНО: Используем хеш ключа, как в MessageRepository.requestUserInfo // 🔥 ВАЖНО: Используем хеш ключа, как в MessageRepository.requestUserInfo
val privateKeyHash = CryptoManager.generatePrivateKeyHash(currentUserPrivateKey) val privateKeyHash = CryptoManager.generatePrivateKeyHash(currentUserPrivateKey)
// Запрашиваем информацию о пользователе с сервера // Запрашиваем информацию о пользователе с сервера
val packet = PacketSearch().apply { val packet =
this.privateKey = privateKeyHash PacketSearch().apply {
this.search = publicKey this.privateKey = privateKeyHash
} this.search = publicKey
}
ProtocolManager.send(packet) ProtocolManager.send(packet)
} catch (e: Exception) { } catch (e: Exception) {}
}
} }
} }
/** /** Проверить заблокирован ли пользователь */
* Проверить заблокирован ли пользователь
*/
suspend fun isUserBlocked(publicKey: String): Boolean { suspend fun isUserBlocked(publicKey: String): Boolean {
if (currentAccount.isEmpty()) return false if (currentAccount.isEmpty()) return false

View File

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