From 61995e9286fd59de038868cee30eb762057c9166 Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Sun, 18 Jan 2026 17:26:04 +0500 Subject: [PATCH] feat: Simplify animations across multiple screens by replacing slide transitions with fade animations for improved user experience --- .../com/rosetta/messenger/MainActivity.kt | 118 +++---- .../messenger/data/MessageRepository.kt | 15 + .../messenger/database/MessageEntities.kt | 66 +++- .../messenger/database/RosettaDatabase.kt | 16 +- .../messenger/network/ProtocolManager.kt | 11 +- .../ui/auth/ConfirmSeedPhraseScreen.kt | 20 +- .../ui/auth/ImportSeedPhraseScreen.kt | 20 +- .../messenger/ui/auth/SeedPhraseScreen.kt | 20 +- .../messenger/ui/auth/SelectAccountScreen.kt | 15 +- .../messenger/ui/auth/SetPasswordScreen.kt | 58 +--- .../rosetta/messenger/ui/auth/UnlockScreen.kt | 68 ++-- .../messenger/ui/auth/WelcomeScreen.kt | 28 +- .../messenger/ui/chats/ChatDetailScreen.kt | 290 +++++++++++------- .../messenger/ui/chats/ChatViewModel.kt | 20 ++ .../messenger/ui/chats/ChatsListScreen.kt | 108 ++++--- .../messenger/ui/chats/ChatsListViewModel.kt | 32 +- 16 files changed, 506 insertions(+), 399 deletions(-) diff --git a/app/src/main/java/com/rosetta/messenger/MainActivity.kt b/app/src/main/java/com/rosetta/messenger/MainActivity.kt index 5992af7..78d41c2 100644 --- a/app/src/main/java/com/rosetta/messenger/MainActivity.kt +++ b/app/src/main/java/com/rosetta/messenger/MainActivity.kt @@ -23,8 +23,12 @@ import androidx.compose.animation.core.spring import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text @@ -350,13 +354,33 @@ fun MainScreen( onToggleTheme: () -> Unit = {}, onLogout: () -> Unit = {} ) { - val accountName = account?.name ?: "Account" - val accountPhone = account?.publicKey?.take(16)?.let { + // 🔥 КРИТИЧНО: Если account == null, показываем загрузку вместо мокового аккаунта + if (account == null) { + Box( + modifier = Modifier + .fillMaxSize() + .background(if (isDarkTheme) Color(0xFF1B1B1B) else Color.White), + contentAlignment = Alignment.Center + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + CircularProgressIndicator() + Spacer(modifier = Modifier.height(16.dp)) + Text( + "Loading account...", + color = if (isDarkTheme) Color.White else Color.Black + ) + } + } + return + } + + val accountName = account.name + val accountPhone = account.publicKey.take(16).let { "+${it.take(1)} ${it.substring(1, 4)} ${it.substring(4, 7)}${it.substring(7)}" - } ?: "+7 775 9932587" - val accountPublicKey = account?.publicKey ?: "04c266b98ae5" - val accountPrivateKey = account?.privateKey ?: "" - val privateKeyHash = account?.privateKeyHash ?: "" + } + val accountPublicKey = account.publicKey + val accountPrivateKey = account.privateKey + val privateKeyHash = account.privateKeyHash // Состояние протокола для передачи в SearchScreen val protocolState by ProtocolManager.state.collectAsState() @@ -375,72 +399,48 @@ fun MainScreen( val isExitingSearch = !targetState.second && initialState.second when { - // 🚀 Вход в чат - быстрый slide справа (как Telegram/iOS) - // Новый экран полностью покрывает старый, никакой прозрачности + // 🚀 Вход в чат - плавный fade isEnteringChat -> { - slideInHorizontally( - initialOffsetX = { fullWidth -> fullWidth }, - animationSpec = spring( - dampingRatio = Spring.DampingRatioNoBouncy, - stiffness = Spring.StiffnessHigh // 🔥 Быстрее для плавности - ) - ) togetherWith slideOutHorizontally( - targetOffsetX = { fullWidth -> -fullWidth / 5 }, // Меньше смещение - animationSpec = spring( - dampingRatio = Spring.DampingRatioNoBouncy, - stiffness = Spring.StiffnessHigh - ) + fadeIn( + animationSpec = tween(200) + ) togetherWith fadeOut( + animationSpec = tween(150) ) } - // 🔙 Выход из чата - обратный slide + // 🔙 Выход из чата - плавный fade isExitingChat -> { - slideInHorizontally( - initialOffsetX = { fullWidth -> -fullWidth / 5 }, - animationSpec = spring( - dampingRatio = Spring.DampingRatioNoBouncy, - stiffness = Spring.StiffnessHigh - ) - ) togetherWith slideOutHorizontally( - targetOffsetX = { fullWidth -> fullWidth }, - animationSpec = spring( - dampingRatio = Spring.DampingRatioNoBouncy, - stiffness = Spring.StiffnessHigh - ) + fadeIn( + animationSpec = tween(200) + ) togetherWith fadeOut( + animationSpec = tween(150) ) } - // 🔍 Вход в поиск - быстрый slide справа + // 🔍 Вход в Search - плавный fade isEnteringSearch -> { - slideInHorizontally( - initialOffsetX = { fullWidth -> fullWidth }, - animationSpec = spring( - dampingRatio = Spring.DampingRatioNoBouncy, - stiffness = Spring.StiffnessHigh // 🔥 Быстрее для плавности - ) - ) togetherWith slideOutHorizontally( - targetOffsetX = { fullWidth -> -fullWidth / 5 }, // Меньше смещение - animationSpec = spring( - dampingRatio = Spring.DampingRatioNoBouncy, - stiffness = Spring.StiffnessHigh - ) + fadeIn( + animationSpec = tween(200) + ) togetherWith fadeOut( + animationSpec = tween(150) ) } - // ❌ Выход из поиска - обратный slide + // 🔙 Выход из Search - плавный fade + isEnteringSearch -> { + fadeIn( + animationSpec = tween(200) + ) togetherWith fadeOut( + animationSpec = tween(150) + ) + } + + // 🔙 Выход из Search - плавный fade isExitingSearch -> { - slideInHorizontally( - initialOffsetX = { fullWidth -> -fullWidth / 5 }, - animationSpec = spring( - dampingRatio = Spring.DampingRatioNoBouncy, - stiffness = Spring.StiffnessHigh - ) - ) togetherWith slideOutHorizontally( - targetOffsetX = { fullWidth -> fullWidth }, - animationSpec = spring( - dampingRatio = Spring.DampingRatioNoBouncy, - stiffness = Spring.StiffnessHigh - ) + fadeIn( + animationSpec = tween(200) + ) togetherWith fadeOut( + animationSpec = tween(150) ) } diff --git a/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt b/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt index 19b931c..2d37170 100644 --- a/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt +++ b/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt @@ -66,6 +66,9 @@ class MessageRepository private constructor(private val context: Context) { private val _newMessageEvents = MutableSharedFlow(replay = 0, extraBufferCapacity = 10) val newMessageEvents: SharedFlow = _newMessageEvents.asSharedFlow() + // 🔥 Tracking для уже запрошенных user info - предотвращает бесконечные запросы + private val requestedUserInfoKeys = mutableSetOf() + // Текущий аккаунт private var currentAccount: String? = null private var currentPrivateKey: String? = null @@ -97,6 +100,11 @@ class MessageRepository private constructor(private val context: Context) { * Инициализация с текущим аккаунтом */ fun initialize(publicKey: String, privateKey: String) { + // 🔥 Очищаем кэш запрошенных user info при смене аккаунта + if (currentAccount != publicKey) { + requestedUserInfoKeys.clear() + } + currentAccount = publicKey currentPrivateKey = privateKey @@ -582,10 +590,17 @@ class MessageRepository private constructor(private val context: Context) { /** * Запросить информацию о пользователе с сервера + * 🔥 Защита от бесконечных запросов - каждый ключ запрашивается только один раз */ fun requestUserInfo(publicKey: String) { val privateKey = currentPrivateKey ?: return + // 🔥 Не запрашиваем если уже запрашивали + if (requestedUserInfoKeys.contains(publicKey)) { + return + } + requestedUserInfoKeys.add(publicKey) + scope.launch { val privateKeyHash = CryptoManager.generatePrivateKeyHash(privateKey) val packet = PacketSearch().apply { diff --git a/app/src/main/java/com/rosetta/messenger/database/MessageEntities.kt b/app/src/main/java/com/rosetta/messenger/database/MessageEntities.kt index 07a01ad..07e4eb3 100644 --- a/app/src/main/java/com/rosetta/messenger/database/MessageEntities.kt +++ b/app/src/main/java/com/rosetta/messenger/database/MessageEntities.kt @@ -106,7 +106,16 @@ data class DialogEntity( val verified: Int = 0, // Верифицирован @ColumnInfo(name = "i_have_sent", defaultValue = "0") - val iHaveSent: Int = 0 // Отправлял ли я сообщения в этот диалог (0/1) + val iHaveSent: Int = 0, // Отправлял ли я сообщения в этот диалог (0/1) + + @ColumnInfo(name = "last_message_from_me", defaultValue = "0") + val lastMessageFromMe: Int = 0, // Последнее сообщение от меня (0/1) + + @ColumnInfo(name = "last_message_delivered", defaultValue = "0") + val lastMessageDelivered: Int = 0, // Статус доставки последнего сообщения (0=WAITING, 1=DELIVERED, 2=ERROR) + + @ColumnInfo(name = "last_message_read", defaultValue = "0") + val lastMessageRead: Int = 0 // Прочитано последнее сообщение (0/1) ) /** @@ -465,7 +474,10 @@ interface DialogDao { is_online, last_seen, verified, - i_have_sent + i_have_sent, + last_message_from_me, + last_message_delivered, + last_message_read ) SELECT :account AS account, @@ -525,7 +537,31 @@ interface DialogDao { (SELECT i_have_sent FROM dialogs WHERE account = :account AND opponent_key = :opponentKey), 0 ) - END AS i_have_sent + END AS i_have_sent, + COALESCE( + (SELECT from_me FROM messages + WHERE account = :account + AND ((from_public_key = :opponentKey AND to_public_key = :account) + OR (from_public_key = :account AND to_public_key = :opponentKey)) + ORDER BY timestamp DESC LIMIT 1), + 0 + ) AS last_message_from_me, + COALESCE( + (SELECT delivered FROM messages + WHERE account = :account + AND ((from_public_key = :opponentKey AND to_public_key = :account) + OR (from_public_key = :account AND to_public_key = :opponentKey)) + ORDER BY timestamp DESC LIMIT 1), + 0 + ) AS last_message_delivered, + COALESCE( + (SELECT read FROM messages + WHERE account = :account + AND ((from_public_key = :opponentKey AND to_public_key = :account) + OR (from_public_key = :account AND to_public_key = :opponentKey)) + ORDER BY timestamp DESC LIMIT 1), + 0 + ) AS last_message_read WHERE EXISTS ( SELECT 1 FROM messages WHERE account = :account @@ -557,7 +593,10 @@ interface DialogDao { is_online, last_seen, verified, - i_have_sent + i_have_sent, + last_message_from_me, + last_message_delivered, + last_message_read ) SELECT :account AS account, @@ -598,7 +637,24 @@ interface DialogDao { (SELECT verified FROM dialogs WHERE account = :account AND opponent_key = :account), 0 ) AS verified, - 1 AS i_have_sent + 1 AS i_have_sent, + 1 AS last_message_from_me, + COALESCE( + (SELECT delivered FROM messages + WHERE account = :account + AND from_public_key = :account + AND to_public_key = :account + ORDER BY timestamp DESC LIMIT 1), + 0 + ) AS last_message_delivered, + COALESCE( + (SELECT read FROM messages + WHERE account = :account + AND from_public_key = :account + AND to_public_key = :account + ORDER BY timestamp DESC LIMIT 1), + 0 + ) AS last_message_read WHERE EXISTS ( SELECT 1 FROM messages WHERE account = :account diff --git a/app/src/main/java/com/rosetta/messenger/database/RosettaDatabase.kt b/app/src/main/java/com/rosetta/messenger/database/RosettaDatabase.kt index 9225ac5..8d29f41 100644 --- a/app/src/main/java/com/rosetta/messenger/database/RosettaDatabase.kt +++ b/app/src/main/java/com/rosetta/messenger/database/RosettaDatabase.kt @@ -4,6 +4,8 @@ import android.content.Context import androidx.room.Database import androidx.room.Room import androidx.room.RoomDatabase +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase @Database( entities = [ @@ -12,7 +14,7 @@ import androidx.room.RoomDatabase DialogEntity::class, BlacklistEntity::class ], - version = 4, + version = 5, exportSchema = false ) abstract class RosettaDatabase : RoomDatabase() { @@ -24,6 +26,15 @@ abstract class RosettaDatabase : RoomDatabase() { companion object { @Volatile private var INSTANCE: RosettaDatabase? = null + + private val MIGRATION_4_5 = object : Migration(4, 5) { + override fun migrate(database: SupportSQLiteDatabase) { + // Добавляем новые столбцы для индикаторов прочтения + database.execSQL("ALTER TABLE dialogs ADD COLUMN last_message_from_me INTEGER NOT NULL DEFAULT 0") + database.execSQL("ALTER TABLE dialogs ADD COLUMN last_message_delivered INTEGER NOT NULL DEFAULT 0") + database.execSQL("ALTER TABLE dialogs ADD COLUMN last_message_read INTEGER NOT NULL DEFAULT 0") + } + } fun getDatabase(context: Context): RosettaDatabase { return INSTANCE ?: synchronized(this) { @@ -33,7 +44,8 @@ abstract class RosettaDatabase : RoomDatabase() { "rosetta_secure.db" ) .setJournalMode(JournalMode.WRITE_AHEAD_LOGGING) // WAL mode for performance - .fallbackToDestructiveMigration() // Для разработки + .addMigrations(MIGRATION_4_5) + .fallbackToDestructiveMigration() // Для разработки - только если миграция не найдена .build() INSTANCE = instance instance diff --git a/app/src/main/java/com/rosetta/messenger/network/ProtocolManager.kt b/app/src/main/java/com/rosetta/messenger/network/ProtocolManager.kt index b00f4be..1bbf0af 100644 --- a/app/src/main/java/com/rosetta/messenger/network/ProtocolManager.kt +++ b/app/src/main/java/com/rosetta/messenger/network/ProtocolManager.kt @@ -72,7 +72,7 @@ object ProtocolManager { private fun setupStateMonitoring() { scope.launch { getProtocol().state.collect { newState -> - addLog("📊 STATE MONITOR: Connection state changed to $newState") + addLog("📡 STATE CHANGED: $newState") } } } @@ -202,8 +202,7 @@ object ProtocolManager { */ fun getProtocol(): Protocol { if (protocol == null) { - addLog("Creating new Protocol instance") - addLog("Server: $SERVER_ADDRESS") + addLog("🔌 Creating new Protocol instance for $SERVER_ADDRESS") protocol = Protocol(SERVER_ADDRESS) { msg -> addLog(msg) } } return protocol!! @@ -225,7 +224,7 @@ object ProtocolManager { * Connect to server */ fun connect() { - addLog("🔌 CONNECT REQUESTED from ProtocolManager") + addLog("🔌 CONNECT REQUESTED - calling Protocol.connect()") getProtocol().connect() } @@ -233,9 +232,7 @@ object ProtocolManager { * Authenticate with server */ fun authenticate(publicKey: String, privateHash: String) { - addLog("🔐 AUTHENTICATE called from ProtocolManager") - addLog(" PublicKey: ${publicKey.take(30)}...") - addLog(" PrivateHash: ${privateHash.take(20)}...") + addLog("🔐 AUTHENTICATE REQUESTED for ${publicKey.take(16)}...") getProtocol().startHandshake(publicKey, privateHash) } diff --git a/app/src/main/java/com/rosetta/messenger/ui/auth/ConfirmSeedPhraseScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/auth/ConfirmSeedPhraseScreen.kt index 353b2b9..cc8a954 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/auth/ConfirmSeedPhraseScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/auth/ConfirmSeedPhraseScreen.kt @@ -159,10 +159,7 @@ fun ConfirmSeedPhraseScreen( AnimatedVisibility( visible = visible, - enter = fadeIn(tween(250)) + slideInVertically( - initialOffsetY = { -40 }, - animationSpec = tween(250) - ) + enter = fadeIn(tween(300)) ) { Text( text = "Confirm Backup", @@ -176,10 +173,7 @@ fun ConfirmSeedPhraseScreen( AnimatedVisibility( visible = visible, - enter = fadeIn(tween(500, delayMillis = 100)) + slideInVertically( - initialOffsetY = { -20 }, - animationSpec = tween(500, delayMillis = 100) - ) + enter = fadeIn(tween(400, delayMillis = 100)) ) { Text( text = "Enter words #${wordsToConfirm[0].first + 1}, #${wordsToConfirm[1].first + 1}, #${wordsToConfirm[2].first + 1}, #${wordsToConfirm[3].first + 1}\nto confirm you've backed up your phrase.", @@ -354,10 +348,7 @@ fun ConfirmSeedPhraseScreen( // Continue Button AnimatedVisibility( visible = visible, - enter = fadeIn(tween(500, delayMillis = 600)) + slideInVertically( - initialOffsetY = { 50 }, - animationSpec = tween(500, delayMillis = 600) - ) + enter = fadeIn(tween(400, delayMillis = 600)) ) { Button( onClick = { @@ -408,10 +399,7 @@ private fun AnimatedConfirmWordItem( ) { AnimatedVisibility( visible = visible, - enter = fadeIn(tween(400, delayMillis = delay)) + slideInHorizontally( - initialOffsetX = { if (number <= 6) -50 else 50 }, - animationSpec = tween(400, delayMillis = delay) - ) + enter = fadeIn(tween(400, delayMillis = delay)) ) { if (isInput) { ConfirmWordInputItem( diff --git a/app/src/main/java/com/rosetta/messenger/ui/auth/ImportSeedPhraseScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/auth/ImportSeedPhraseScreen.kt index 7d21ce9..9073266 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/auth/ImportSeedPhraseScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/auth/ImportSeedPhraseScreen.kt @@ -78,10 +78,7 @@ fun ImportSeedPhraseScreen( AnimatedVisibility( visible = visible, - enter = fadeIn(tween(250)) + slideInVertically( - initialOffsetY = { -40 }, - animationSpec = tween(250) - ) + enter = fadeIn(tween(300)) ) { Text( text = "Import Account", @@ -95,10 +92,7 @@ fun ImportSeedPhraseScreen( AnimatedVisibility( visible = visible, - enter = fadeIn(tween(500, delayMillis = 100)) + slideInVertically( - initialOffsetY = { -20 }, - animationSpec = tween(500, delayMillis = 100) - ) + enter = fadeIn(tween(400, delayMillis = 100)) ) { Text( text = "Enter your 12-word recovery phrase\nto restore your account.", @@ -243,10 +237,7 @@ fun ImportSeedPhraseScreen( // Import button AnimatedVisibility( visible = visible, - enter = fadeIn(tween(500, delayMillis = 600)) + slideInVertically( - initialOffsetY = { 50 }, - animationSpec = tween(500, delayMillis = 600) - ) + enter = fadeIn(tween(400, delayMillis = 600)) ) { Button( onClick = { @@ -302,10 +293,7 @@ private fun AnimatedWordInputItem( ) { AnimatedVisibility( visible = visible, - enter = fadeIn(tween(400, delayMillis = delay)) + slideInHorizontally( - initialOffsetX = { if (number <= 6) -50 else 50 }, - animationSpec = tween(400, delayMillis = delay) - ) + enter = fadeIn(tween(400, delayMillis = delay)) ) { WordInputItem( number = number, diff --git a/app/src/main/java/com/rosetta/messenger/ui/auth/SeedPhraseScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/auth/SeedPhraseScreen.kt index 569bc65..17be948 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/auth/SeedPhraseScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/auth/SeedPhraseScreen.kt @@ -80,10 +80,7 @@ fun SeedPhraseScreen( AnimatedVisibility( visible = visible, - enter = fadeIn(tween(250)) + slideInVertically( - initialOffsetY = { -40 }, - animationSpec = tween(250) - ) + enter = fadeIn(tween(300)) ) { Text( text = "Your Recovery Phrase", @@ -97,10 +94,7 @@ fun SeedPhraseScreen( AnimatedVisibility( visible = visible, - enter = fadeIn(tween(500, delayMillis = 100)) + slideInVertically( - initialOffsetY = { -20 }, - animationSpec = tween(500, delayMillis = 100) - ) + enter = fadeIn(tween(400, delayMillis = 100)) ) { Text( text = "Write down these 12 words in order.\nYou'll need them to restore your account.", @@ -213,10 +207,7 @@ fun SeedPhraseScreen( // Continue button AnimatedVisibility( visible = visible, - enter = fadeIn(tween(500, delayMillis = 700)) + slideInVertically( - initialOffsetY = { 50 }, - animationSpec = tween(500, delayMillis = 700) - ) + enter = fadeIn(tween(400, delayMillis = 700)) ) { Button( onClick = { onConfirm(seedPhrase) }, @@ -313,10 +304,7 @@ private fun AnimatedWordItem( ) { AnimatedVisibility( visible = visible, - enter = fadeIn(tween(400, delayMillis = delay)) + slideInHorizontally( - initialOffsetX = { if (number <= 6) -50 else 50 }, - animationSpec = tween(400, delayMillis = delay) - ) + enter = fadeIn(tween(400, delayMillis = delay)) ) { WordItem( number = number, diff --git a/app/src/main/java/com/rosetta/messenger/ui/auth/SelectAccountScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/auth/SelectAccountScreen.kt index 7d0c0f8..da0d91d 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/auth/SelectAccountScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/auth/SelectAccountScreen.kt @@ -103,10 +103,7 @@ fun SelectAccountScreen( // Header AnimatedVisibility( visible = visible, - enter = fadeIn(tween(250)) + slideInVertically( - initialOffsetY = { -40 }, - animationSpec = tween(250) - ) + enter = fadeIn(tween(300)) ) { Column { Row { @@ -269,10 +266,7 @@ private fun AccountListItem( AnimatedVisibility( visible = visible, - enter = fadeIn(tween(200)) + slideInHorizontally( - initialOffsetX = { 50 }, - animationSpec = tween(200) - ) + enter = fadeIn(tween(300)) ) { Card( modifier = Modifier @@ -378,10 +372,7 @@ private fun AddAccountButton( AnimatedVisibility( visible = visible, - enter = fadeIn(tween(200)) + slideInHorizontally( - initialOffsetX = { -50 }, - animationSpec = tween(200) - ) + enter = fadeIn(tween(300)) ) { Card( modifier = Modifier diff --git a/app/src/main/java/com/rosetta/messenger/ui/auth/SetPasswordScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/auth/SetPasswordScreen.kt index 673609f..66b23a6 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/auth/SetPasswordScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/auth/SetPasswordScreen.kt @@ -178,12 +178,7 @@ fun SetPasswordScreen( AnimatedVisibility( visible = visible, - enter = - fadeIn(tween(500, delayMillis = 100)) + - slideInVertically( - initialOffsetY = { -20 }, - animationSpec = tween(500, delayMillis = 100) - ) + enter = fadeIn(tween(400, delayMillis = 100)) ) { Text( text = "Protect Your Account", @@ -214,12 +209,7 @@ fun SetPasswordScreen( // Password Field AnimatedVisibility( visible = visible, - enter = - fadeIn(tween(500, delayMillis = 300)) + - slideInVertically( - initialOffsetY = { 50 }, - animationSpec = tween(500, delayMillis = 300) - ) + enter = fadeIn(tween(400, delayMillis = 300)) ) { OutlinedTextField( value = password, @@ -270,12 +260,7 @@ fun SetPasswordScreen( Spacer(modifier = Modifier.height(8.dp)) AnimatedVisibility( visible = visible, - enter = - fadeIn(tween(400, delayMillis = 350)) + - slideInHorizontally( - initialOffsetX = { -30 }, - animationSpec = tween(400, delayMillis = 350) - ) + enter = fadeIn(tween(400, delayMillis = 350)) ) { Column(modifier = Modifier.fillMaxWidth()) { Row( @@ -345,12 +330,7 @@ fun SetPasswordScreen( // Confirm Password Field AnimatedVisibility( visible = visible, - enter = - fadeIn(tween(500, delayMillis = 400)) + - slideInVertically( - initialOffsetY = { 50 }, - animationSpec = tween(500, delayMillis = 400) - ) + enter = fadeIn(tween(400, delayMillis = 400)) ) { OutlinedTextField( value = confirmPassword, @@ -407,12 +387,7 @@ fun SetPasswordScreen( Spacer(modifier = Modifier.height(8.dp)) AnimatedVisibility( visible = visible, - enter = - fadeIn(tween(400, delayMillis = 450)) + - slideInHorizontally( - initialOffsetX = { -30 }, - animationSpec = tween(400, delayMillis = 450) - ) + enter = fadeIn(tween(400, delayMillis = 450)) ) { Row( modifier = Modifier.fillMaxWidth(), @@ -459,20 +434,8 @@ fun SetPasswordScreen( // Info - hide when keyboard is visible AnimatedVisibility( visible = visible && !isKeyboardVisible, - enter = - fadeIn(tween(200)) + - slideInVertically( - initialOffsetY = { 30 }, - animationSpec = tween(200) - ) + - scaleIn(initialScale = 0.9f, animationSpec = tween(200)), - exit = - fadeOut(tween(150)) + - slideOutVertically( - targetOffsetY = { 30 }, - animationSpec = tween(150) - ) + - scaleOut(targetScale = 0.9f, animationSpec = tween(150)) + enter = fadeIn(tween(300)), + exit = fadeOut(tween(200)) ) { Row( modifier = @@ -504,12 +467,7 @@ fun SetPasswordScreen( // Create Account Button AnimatedVisibility( visible = visible, - enter = - fadeIn(tween(500, delayMillis = 600)) + - slideInVertically( - initialOffsetY = { 50 }, - animationSpec = tween(500, delayMillis = 600) - ) + enter = fadeIn(tween(400, delayMillis = 600)) ) { Button( onClick = { diff --git a/app/src/main/java/com/rosetta/messenger/ui/auth/UnlockScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/auth/UnlockScreen.kt index 08c6810..8d9ab55 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/auth/UnlockScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/auth/UnlockScreen.kt @@ -38,6 +38,7 @@ import com.rosetta.messenger.data.AccountManager import com.rosetta.messenger.data.DecryptedAccount import com.rosetta.messenger.data.EncryptedAccount import com.rosetta.messenger.network.ProtocolManager +import com.rosetta.messenger.network.ProtocolState import com.rosetta.messenger.ui.chats.getAvatarColor import com.rosetta.messenger.ui.chats.getAvatarText import com.rosetta.messenger.ui.onboarding.PrimaryBlue @@ -195,12 +196,7 @@ fun UnlockScreen( // Title AnimatedVisibility( visible = visible, - enter = - fadeIn(tween(600, delayMillis = 200)) + - slideInVertically( - initialOffsetY = { 30 }, - animationSpec = tween(600, delayMillis = 200) - ) + enter = fadeIn(tween(400, delayMillis = 200)) ) { Text( text = "Welcome Back", @@ -226,12 +222,7 @@ fun UnlockScreen( // Account Selector Card AnimatedVisibility( visible = visible, - enter = - fadeIn(tween(600, delayMillis = 350)) + - slideInVertically( - initialOffsetY = { 50 }, - animationSpec = tween(600, delayMillis = 350) - ) + enter = fadeIn(tween(400, delayMillis = 350)) ) { Column { // Account selector dropdown @@ -511,12 +502,7 @@ fun UnlockScreen( // Password Field AnimatedVisibility( visible = visible && !isDropdownExpanded, - enter = - fadeIn(tween(600, delayMillis = 400)) + - slideInVertically( - initialOffsetY = { 50 }, - animationSpec = tween(600, delayMillis = 400) - ) + enter = fadeIn(tween(400, delayMillis = 400)) ) { OutlinedTextField( value = password, @@ -566,8 +552,8 @@ fun UnlockScreen( // Error message AnimatedVisibility( visible = error != null, - enter = fadeIn() + slideInVertically { -10 }, - exit = fadeOut() + enter = fadeIn(tween(200)), + exit = fadeOut(tween(150)) ) { Spacer(modifier = Modifier.height(8.dp)) Text(text = error ?: "", fontSize = 14.sp, color = Color(0xFFE53935)) @@ -578,12 +564,7 @@ fun UnlockScreen( // Unlock Button AnimatedVisibility( visible = visible && !isDropdownExpanded, - enter = - fadeIn(tween(600, delayMillis = 500)) + - slideInVertically( - initialOffsetY = { 50 }, - animationSpec = tween(600, delayMillis = 500) - ) + enter = fadeIn(tween(400, delayMillis = 500)) ) { Button( onClick = { @@ -636,13 +617,37 @@ fun UnlockScreen( name = account.name ) + android.util.Log.d("UnlockScreen", "🔐 Starting connection and authentication...") + android.util.Log.d("UnlockScreen", " PublicKey: ${account.publicKey.take(16)}...") + android.util.Log.d("UnlockScreen", " PrivateKeyHash: ${privateKeyHash.take(16)}...") + // Connect to server and authenticate ProtocolManager.connect() - // Give WebSocket time to connect before authenticating - kotlinx.coroutines.delay(500) + + // 🔥 Ждем пока websocket подключится (CONNECTED state) + var waitAttempts = 0 + while (ProtocolManager.state.value == ProtocolState.DISCONNECTED && waitAttempts < 50) { + kotlinx.coroutines.delay(100) + waitAttempts++ + } + + android.util.Log.d("UnlockScreen", "🔌 Connection state after wait: ${ProtocolManager.state.value}") + + if (ProtocolManager.state.value == ProtocolState.DISCONNECTED) { + error = "Failed to connect to server" + isUnlocking = false + return@launch + } + + // Еще немного ждем для стабильности + kotlinx.coroutines.delay(300) + + android.util.Log.d("UnlockScreen", "🔐 Starting authentication...") ProtocolManager.authenticate(account.publicKey, privateKeyHash) accountManager.setCurrentAccount(account.publicKey) + + android.util.Log.d("UnlockScreen", "✅ Unlock complete, calling onUnlocked callback") onUnlocked(decryptedAccount) } catch (e: Exception) { error = "Failed to unlock: \${e.message}" @@ -684,12 +689,7 @@ fun UnlockScreen( // Create New Account button AnimatedVisibility( visible = visible && !isDropdownExpanded, - enter = - fadeIn(tween(600, delayMillis = 600)) + - slideInVertically( - initialOffsetY = { 50 }, - animationSpec = tween(600, delayMillis = 600) - ) + enter = fadeIn(tween(400, delayMillis = 600)) ) { TextButton(onClick = onSwitchAccount) { Icon( diff --git a/app/src/main/java/com/rosetta/messenger/ui/auth/WelcomeScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/auth/WelcomeScreen.kt index bc6b54c..3da7c2d 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/auth/WelcomeScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/auth/WelcomeScreen.kt @@ -115,12 +115,7 @@ fun WelcomeScreen( // Title AnimatedVisibility( visible = visible, - enter = - fadeIn(tween(600, delayMillis = 200)) + - slideInVertically( - initialOffsetY = { 50 }, - animationSpec = tween(600, delayMillis = 200) - ) + enter = fadeIn(tween(400, delayMillis = 200)) ) { Text( text = "Your Keys,\nYour Messages", @@ -137,12 +132,7 @@ fun WelcomeScreen( // Subtitle AnimatedVisibility( visible = visible, - enter = - fadeIn(tween(600, delayMillis = 300)) + - slideInVertically( - initialOffsetY = { 50 }, - animationSpec = tween(600, delayMillis = 300) - ) + enter = fadeIn(tween(400, delayMillis = 300)) ) { Text( text = "Secure messaging with\ncryptographic keys", @@ -188,12 +178,7 @@ fun WelcomeScreen( // Create Seed Button AnimatedVisibility( visible = visible, - enter = - fadeIn(tween(600, delayMillis = 500)) + - slideInVertically( - initialOffsetY = { 100 }, - animationSpec = tween(600, delayMillis = 500) - ) + enter = fadeIn(tween(400, delayMillis = 500)) ) { Button( onClick = onCreateSeed, @@ -229,12 +214,7 @@ fun WelcomeScreen( // Import Seed Button AnimatedVisibility( visible = visible, - enter = - fadeIn(tween(600, delayMillis = 600)) + - slideInVertically( - initialOffsetY = { 100 }, - animationSpec = tween(600, delayMillis = 600) - ) + enter = fadeIn(tween(400, delayMillis = 600)) ) { TextButton( onClick = onImportSeed, diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt index d828285..8e29dc5 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt @@ -59,6 +59,9 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Popup import androidx.compose.ui.window.PopupProperties +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.lifecycle.viewmodel.compose.viewModel import app.rosette.android.ui.keyboard.AnimatedKeyboardTransition import app.rosette.android.ui.keyboard.KeyboardTransitionCoordinator @@ -474,8 +477,30 @@ fun ChatDetailScreen( // 🔥 Обработка системной кнопки назад BackHandler { hideKeyboardAndBack() } - // 🔥 Cleanup при выходе из экрана - только закрываем диалог - DisposableEffect(Unit) { onDispose { viewModel.closeDialog() } } + // 🔥 Lifecycle-aware отслеживание активности экрана + val lifecycleOwner = LocalLifecycleOwner.current + var isScreenActive by remember { mutableStateOf(true) } + + DisposableEffect(lifecycleOwner) { + val observer = LifecycleEventObserver { _, event -> + when (event) { + Lifecycle.Event.ON_RESUME -> { + isScreenActive = true + viewModel.setDialogActive(true) + } + Lifecycle.Event.ON_PAUSE -> { + isScreenActive = false + viewModel.setDialogActive(false) + } + else -> {} + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { + lifecycleOwner.lifecycle.removeObserver(observer) + viewModel.closeDialog() + } + } // Инициализируем ViewModel с ключами и открываем диалог LaunchedEffect(user.publicKey) { @@ -489,9 +514,9 @@ fun ChatDetailScreen( com.rosetta.messenger.ui.components.EmojiCache.preload(context) } - // Отмечаем сообщения как прочитанные когда они видны - LaunchedEffect(messages) { - if (messages.isNotEmpty()) { + // Отмечаем сообщения как прочитанные только когда экран активен (RESUMED) + LaunchedEffect(messages, isScreenActive) { + if (messages.isNotEmpty() && isScreenActive) { viewModel.markVisibleMessagesAsRead() } } @@ -855,28 +880,44 @@ fun ChatDetailScreen( } } - // Кнопка меню - открывает bottom sheet - IconButton( - onClick = { - // Закрываем клавиатуру перед открытием меню - keyboardController?.hide() - focusManager.clearFocus() - // Даём клавиатуре время закрыться перед показом - // bottom sheet - scope.launch { - delay( - 150 - ) // Задержка для плавного закрытия клавиатуры + // Кнопка меню - открывает kebab menu + Box { + IconButton( + onClick = { + // Закрываем клавиатуру перед открытием меню + keyboardController?.hide() + focusManager.clearFocus() showMenu = true - } + }, + modifier = Modifier.size(48.dp).clip(CircleShape) + ) { + Icon( + Icons.Default.MoreVert, + contentDescription = "More", + tint = headerIconColor.copy(alpha = 0.6f), + modifier = Modifier.size(26.dp) + ) + } + + // 🔥 TELEGRAM-STYLE KEBAB MENU + KebabMenu( + expanded = showMenu, + onDismiss = { showMenu = false }, + isDarkTheme = isDarkTheme, + isSavedMessages = isSavedMessages, + isBlocked = isBlocked, + onBlockClick = { + showMenu = false + showBlockConfirm = true }, - modifier = Modifier.size(48.dp).clip(CircleShape) - ) { - Icon( - Icons.Default.MoreVert, - contentDescription = "More", - tint = headerIconColor.copy(alpha = 0.6f), - modifier = Modifier.size(26.dp) + onUnblockClick = { + showMenu = false + showUnblockConfirm = true + }, + onDeleteClick = { + showMenu = false + showDeleteConfirm = true + } ) } } @@ -927,29 +968,8 @@ fun ChatDetailScreen( AnimatedContent( targetState = isSelectionMode, transitionSpec = { - if (targetState) { - // Selection mode появляется: снизу вверх - slideInVertically( - animationSpec = tween(300, easing = TelegramEasing), - initialOffsetY = { it } - ) + fadeIn(animationSpec = tween(200)) togetherWith - slideOutVertically( - animationSpec = - tween(300, easing = TelegramEasing), - targetOffsetY = { -it } - ) + fadeOut(animationSpec = tween(150)) - } else { - // Input bar возвращается: снизу вверх - slideInVertically( - animationSpec = tween(300, easing = TelegramEasing), - initialOffsetY = { it } - ) + fadeIn(animationSpec = tween(200)) togetherWith - slideOutVertically( - animationSpec = - tween(300, easing = TelegramEasing), - targetOffsetY = { it } - ) + fadeOut(animationSpec = tween(150)) - } + fadeIn(animationSpec = tween(200)) togetherWith + fadeOut(animationSpec = tween(150)) }, label = "bottomBarContent" ) { selectionMode -> @@ -1680,74 +1700,7 @@ fun ChatDetailScreen( ) } - // � Bottom Sheet меню (вместо popup menu) - if (showMenu) { - ModalBottomSheet( - onDismissRequest = { showMenu = false }, - containerColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color.White, - shape = RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp), - scrimColor = Color.Black.copy(alpha = 0.6f), - windowInsets = - WindowInsets(0, 0, 0, 0), // Перекрываем весь экран включая status bar - dragHandle = { - Box( - modifier = - Modifier.padding(vertical = 12.dp) - .width(36.dp) - .height(4.dp) - .clip(RoundedCornerShape(2.dp)) - .background( - if (isDarkTheme) Color.White.copy(alpha = 0.3f) - else Color.Black.copy(alpha = 0.2f) - ) - ) - } - ) { - Column(modifier = Modifier.fillMaxWidth().padding(bottom = 32.dp)) { - // Block/Unblock User - if (!isSavedMessages) { - BottomSheetMenuItem( - icon = - if (isBlocked) Icons.Default.CheckCircle - else Icons.Default.Block, - text = if (isBlocked) "Unblock User" else "Block User", - onClick = { - showMenu = false - if (isBlocked) { - showUnblockConfirm = true - } else { - showBlockConfirm = true - } - }, - isDarkTheme = isDarkTheme, - tintColor = PrimaryBlue - ) - - Divider( - modifier = Modifier.padding(horizontal = 16.dp), - thickness = 0.5.dp, - color = - if (isDarkTheme) Color.White.copy(alpha = 0.1f) - else Color.Black.copy(alpha = 0.08f) - ) - } - - // Delete Chat - BottomSheetMenuItem( - icon = Icons.Default.Delete, - text = "Delete Chat", - onClick = { - showMenu = false - showDeleteConfirm = true - }, - isDarkTheme = isDarkTheme, - isDestructive = true - ) - } - } - } - - // �📨 Forward Chat Picker BottomSheet + // 📨 Forward Chat Picker BottomSheet if (showForwardPicker) { ForwardChatPickerBottomSheet( dialogs = dialogsList, @@ -3180,3 +3133,108 @@ private fun BottomSheetMenuItem( Text(text = text, color = textColor, fontSize = 18.sp, fontWeight = FontWeight.Medium) } } + +/** + * 🔥 TELEGRAM-STYLE KEBAB MENU + * Красивое выпадающее меню с анимациями и современным дизайном + */ +@Composable +private fun KebabMenu( + expanded: Boolean, + onDismiss: () -> Unit, + isDarkTheme: Boolean, + isSavedMessages: Boolean, + isBlocked: Boolean, + onBlockClick: () -> Unit, + onUnblockClick: () -> Unit, + onDeleteClick: () -> Unit +) { + val menuBackgroundColor = if (isDarkTheme) Color(0xFF2C2C2E) else Color.White + val textColor = if (isDarkTheme) Color.White else Color.Black + val dividerColor = if (isDarkTheme) Color.White.copy(alpha = 0.1f) else Color.Black.copy(alpha = 0.08f) + + DropdownMenu( + expanded = expanded, + onDismissRequest = onDismiss, + modifier = Modifier.width(220.dp), + properties = PopupProperties( + focusable = true, + dismissOnBackPress = true, + dismissOnClickOutside = true + ) + ) { + // Block/Unblock User (не для Saved Messages) + if (!isSavedMessages) { + KebabMenuItem( + icon = if (isBlocked) Icons.Default.CheckCircle else Icons.Default.Block, + text = if (isBlocked) "Unblock User" else "Block User", + onClick = { + if (isBlocked) onUnblockClick() else onBlockClick() + }, + tintColor = PrimaryBlue, + textColor = textColor + ) + + // Divider + Box( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 4.dp) + .height(0.5.dp) + .background(dividerColor) + ) + } + + // Delete Chat + KebabMenuItem( + icon = Icons.Default.Delete, + text = "Delete Chat", + onClick = onDeleteClick, + tintColor = Color(0xFFFF3B30), + textColor = Color(0xFFFF3B30) + ) + } +} + +/** + * 🔥 Элемент Kebab меню + */ +@Composable +private fun KebabMenuItem( + icon: ImageVector, + text: String, + onClick: () -> Unit, + tintColor: Color, + textColor: Color +) { + val interactionSource = remember { MutableInteractionSource() } + + DropdownMenuItem( + text = { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = tintColor, + modifier = Modifier.size(24.dp) + ) + Spacer(modifier = Modifier.width(14.dp)) + Text( + text = text, + color = textColor, + fontSize = 16.sp, + fontWeight = FontWeight.Medium + ) + } + }, + onClick = onClick, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 4.dp), + interactionSource = interactionSource, + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 12.dp) + ) +} diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt index 536a9ac..abe03a1 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt @@ -140,6 +140,9 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { // Как currentDialogPublicKeyView в архиве private var isDialogActive = false + // 🟢 Флаг что уже подписаны на онлайн статус собеседника + private var subscribedToOnlineStatus = false + init { setupPacketListeners() setupNewMessageListener() @@ -351,6 +354,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { isLoadingMessages = false lastReadMessageTimestamp = 0L readReceiptSentForCurrentDialog = false + subscribedToOnlineStatus = false // 🔥 Сбрасываем флаг подписки при смене диалога isDialogActive = true // 🔥 Диалог активен! // 📨 Применяем Forward сообщения СРАЗУ после сброса @@ -390,6 +394,14 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { isDialogActive = false } + /** + * 🔥 Установить состояние активности диалога (вызывается при ON_RESUME/ON_PAUSE) + * Предотвращает отметку сообщений как прочитанных когда приложение в фоне + */ + fun setDialogActive(active: Boolean) { + isDialogActive = active + } + /** * 🚀 СУПЕР-оптимизированная загрузка сообщений * 🔥 ОПТИМИЗАЦИЯ: Кэш на уровне диалога + задержка для анимации + чанковая расшифровка @@ -1428,8 +1440,12 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { /** * 🟢 Подписаться на онлайн статус собеседника * 📁 Для Saved Messages - не подписываемся + * 🔥 Проверяем флаг чтобы не подписываться повторно */ fun subscribeToOnlineStatus() { + // 🔥 Если уже подписаны - не подписываемся повторно! + if (subscribedToOnlineStatus) return + val opponent = opponentKey ?: return val privateKey = myPrivateKey ?: return val account = myPublicKey ?: return @@ -1439,6 +1455,9 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { return } + // 🔥 Устанавливаем флаг ДО отправки пакета + subscribedToOnlineStatus = true + viewModelScope.launch(Dispatchers.IO) { try { val privateKeyHash = CryptoManager.generatePrivateKeyHash(privateKey) @@ -1460,6 +1479,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { super.onCleared() lastReadMessageTimestamp = 0L readReceiptSentForCurrentDialog = false + subscribedToOnlineStatus = false // 🔥 Сбрасываем флаг при очистке opponentKey = null } } diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt index 142ab76..5c7a99e 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt @@ -235,6 +235,11 @@ fun ChatsListScreen( } */ + // Enable UI logs when status dialog is shown + LaunchedEffect(showStatusDialog) { + ProtocolManager.enableUILogs(showStatusDialog) + } + // Status dialog with logs if (showStatusDialog) { val clipboardManager = androidx.compose.ui.platform.LocalClipboardManager.current @@ -576,8 +581,12 @@ fun ChatsListScreen( iconColor = menuIconColor, textColor = textColor, onClick = { - scope.launch { drawerState.close() } - onSavedMessagesClick() + scope.launch { + drawerState.close() + // Ждём завершения анимации закрытия drawer + kotlinx.coroutines.delay(250) + onSavedMessagesClick() + } } ) @@ -893,37 +902,8 @@ fun ChatsListScreen( AnimatedContent( targetState = showRequestsScreen, transitionSpec = { - if (targetState) { - // Переход на Requests: быстрый slide + fade - (slideInHorizontally( - animationSpec = - tween(200, easing = FastOutSlowInEasing), - initialOffsetX = { it } - ) + fadeIn(tween(150))) togetherWith - (slideOutHorizontally( - animationSpec = - tween( - 200, - easing = FastOutSlowInEasing - ), - targetOffsetX = { -it / 4 } - ) + fadeOut(tween(100))) - } else { - // Возврат из Requests: slide out to right - (slideInHorizontally( - animationSpec = - tween(200, easing = FastOutSlowInEasing), - initialOffsetX = { -it / 4 } - ) + fadeIn(tween(150))) togetherWith - (slideOutHorizontally( - animationSpec = - tween( - 200, - easing = FastOutSlowInEasing - ), - targetOffsetX = { it } - ) + fadeOut(tween(100))) - } + fadeIn(animationSpec = tween(200)) togetherWith + fadeOut(animationSpec = tween(150)) }, label = "RequestsTransition" ) { isRequestsScreen -> @@ -1662,11 +1642,63 @@ fun DialogItemContent( modifier = Modifier.weight(1f) ) - Text( - text = formatTime(Date(dialog.lastMessageTimestamp)), - fontSize = 13.sp, - color = if (dialog.unreadCount > 0) PrimaryBlue else secondaryTextColor - ) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.End + ) { + // Показываем статус только для исходящих сообщений + if (dialog.lastMessageFromMe == 1) { + when (dialog.lastMessageDelivered) { + 2 -> { + // ERROR - показываем иконку ошибки + Icon( + imageVector = Icons.Outlined.ErrorOutline, + contentDescription = "Sending failed", + tint = Color(0xFFFF3B30), // iOS красный + modifier = Modifier.size(16.dp) + ) + Spacer(modifier = Modifier.width(4.dp)) + } + 1 -> { + // DELIVERED - две галочки + val checkmarkColor = if (dialog.lastMessageRead == 1) { + PrimaryBlue // прочитано - синие + } else { + secondaryTextColor.copy(alpha = 0.6f) // доставлено - серые + } + Icon( + imageVector = Icons.Default.Done, + contentDescription = null, + tint = checkmarkColor, + modifier = Modifier.size(14.dp) + ) + Icon( + imageVector = Icons.Default.Done, + contentDescription = null, + tint = checkmarkColor, + modifier = Modifier.size(14.dp).offset(x = (-6).dp) + ) + Spacer(modifier = Modifier.width(2.dp)) + } + 0 -> { + // WAITING - одна серая галочка (отправлено, ждём доставку) + Icon( + imageVector = Icons.Default.Done, + contentDescription = null, + tint = secondaryTextColor.copy(alpha = 0.6f), + modifier = Modifier.size(14.dp) + ) + Spacer(modifier = Modifier.width(4.dp)) + } + } + } + + Text( + text = formatTime(Date(dialog.lastMessageTimestamp)), + fontSize = 13.sp, + color = if (dialog.unreadCount > 0) PrimaryBlue else secondaryTextColor + ) + } } Spacer(modifier = Modifier.height(4.dp)) diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListViewModel.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListViewModel.kt index 7ccafa0..b6fb2c2 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListViewModel.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListViewModel.kt @@ -30,7 +30,10 @@ data class DialogUiModel( val isOnline: Int, val lastSeen: Long, 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 lastMessageDelivered: Int = 0, // Статус доставки (0=WAITING, 1=DELIVERED, 2=ERROR) + val lastMessageRead: Int = 0 // Прочитано (0/1) ) /** @@ -61,6 +64,9 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio // 🔥 Tracking для уже запрошенных user info - предотвращает бесконечные запросы private val requestedUserInfoKeys = mutableSetOf() + // 🟢 Tracking для уже подписанных онлайн-статусов - предотвращает бесконечный цикл + private val subscribedOnlineKeys = mutableSetOf() + // Список диалогов с расшифрованными сообщениями private val _dialogs = MutableStateFlow>(emptyList()) val dialogs: StateFlow> = _dialogs.asStateFlow() @@ -97,6 +103,10 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio if (currentAccount == publicKey) { return } + + // 🔥 Очищаем кэш запрошенных user info при смене аккаунта + requestedUserInfoKeys.clear() + currentAccount = publicKey currentPrivateKey = privateKey @@ -138,7 +148,10 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio isOnline = dialog.isOnline, lastSeen = dialog.lastSeen, verified = dialog.verified, - isSavedMessages = isSavedMessages // 📁 Saved Messages + isSavedMessages = isSavedMessages, // 📁 Saved Messages + lastMessageFromMe = dialog.lastMessageFromMe, + lastMessageDelivered = dialog.lastMessageDelivered, + lastMessageRead = dialog.lastMessageRead ) } } @@ -194,7 +207,10 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio isOnline = dialog.isOnline, lastSeen = dialog.lastSeen, verified = dialog.verified, - isSavedMessages = (dialog.account == dialog.opponentKey) // 📁 Saved Messages + isSavedMessages = (dialog.account == dialog.opponentKey), // 📁 Saved Messages + lastMessageFromMe = dialog.lastMessageFromMe, + lastMessageDelivered = dialog.lastMessageDelivered, + lastMessageRead = dialog.lastMessageRead ) } } @@ -216,17 +232,25 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio /** * 🟢 Подписаться на онлайн-статусы всех собеседников + * 🔥 Фильтруем уже подписанные ключи чтобы избежать бесконечного цикла */ private fun subscribeToOnlineStatuses(opponentKeys: List, privateKey: String) { if (opponentKeys.isEmpty()) return + // 🔥 КРИТИЧНО: Фильтруем уже подписанные ключи! + val newKeys = opponentKeys.filter { !subscribedOnlineKeys.contains(it) } + if (newKeys.isEmpty()) return // Все уже подписаны + + // Добавляем в Set ДО отправки пакета чтобы избежать race condition + subscribedOnlineKeys.addAll(newKeys) + viewModelScope.launch(Dispatchers.IO) { try { val privateKeyHash = CryptoManager.generatePrivateKeyHash(privateKey) val packet = PacketOnlineSubscribe().apply { this.privateKey = privateKeyHash - opponentKeys.forEach { key -> + newKeys.forEach { key -> addPublicKey(key) } }