feat: Simplify animations across multiple screens by replacing slide transitions with fade animations for improved user experience
This commit is contained in:
@@ -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)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -66,6 +66,9 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
private val _newMessageEvents = MutableSharedFlow<String>(replay = 0, extraBufferCapacity = 10)
|
||||
val newMessageEvents: SharedFlow<String> = _newMessageEvents.asSharedFlow()
|
||||
|
||||
// 🔥 Tracking для уже запрошенных user info - предотвращает бесконечные запросы
|
||||
private val requestedUserInfoKeys = mutableSetOf<String>()
|
||||
|
||||
// Текущий аккаунт
|
||||
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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
)
|
||||
}
|
||||
|
||||
// <EFBFBD> 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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// <20>📨 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)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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<String>()
|
||||
|
||||
// 🟢 Tracking для уже подписанных онлайн-статусов - предотвращает бесконечный цикл
|
||||
private val subscribedOnlineKeys = mutableSetOf<String>()
|
||||
|
||||
// Список диалогов с расшифрованными сообщениями
|
||||
private val _dialogs = MutableStateFlow<List<DialogUiModel>>(emptyList())
|
||||
val dialogs: StateFlow<List<DialogUiModel>> = _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<String>, 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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user