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:
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user