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.animation.core.tween
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Box
|
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.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
@@ -350,13 +354,33 @@ fun MainScreen(
|
|||||||
onToggleTheme: () -> Unit = {},
|
onToggleTheme: () -> Unit = {},
|
||||||
onLogout: () -> Unit = {}
|
onLogout: () -> Unit = {}
|
||||||
) {
|
) {
|
||||||
val accountName = account?.name ?: "Account"
|
// 🔥 КРИТИЧНО: Если account == null, показываем загрузку вместо мокового аккаунта
|
||||||
val accountPhone = account?.publicKey?.take(16)?.let {
|
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)}"
|
"+${it.take(1)} ${it.substring(1, 4)} ${it.substring(4, 7)}${it.substring(7)}"
|
||||||
} ?: "+7 775 9932587"
|
}
|
||||||
val accountPublicKey = account?.publicKey ?: "04c266b98ae5"
|
val accountPublicKey = account.publicKey
|
||||||
val accountPrivateKey = account?.privateKey ?: ""
|
val accountPrivateKey = account.privateKey
|
||||||
val privateKeyHash = account?.privateKeyHash ?: ""
|
val privateKeyHash = account.privateKeyHash
|
||||||
|
|
||||||
// Состояние протокола для передачи в SearchScreen
|
// Состояние протокола для передачи в SearchScreen
|
||||||
val protocolState by ProtocolManager.state.collectAsState()
|
val protocolState by ProtocolManager.state.collectAsState()
|
||||||
@@ -375,72 +399,48 @@ fun MainScreen(
|
|||||||
val isExitingSearch = !targetState.second && initialState.second
|
val isExitingSearch = !targetState.second && initialState.second
|
||||||
|
|
||||||
when {
|
when {
|
||||||
// 🚀 Вход в чат - быстрый slide справа (как Telegram/iOS)
|
// 🚀 Вход в чат - плавный fade
|
||||||
// Новый экран полностью покрывает старый, никакой прозрачности
|
|
||||||
isEnteringChat -> {
|
isEnteringChat -> {
|
||||||
slideInHorizontally(
|
fadeIn(
|
||||||
initialOffsetX = { fullWidth -> fullWidth },
|
animationSpec = tween(200)
|
||||||
animationSpec = spring(
|
) togetherWith fadeOut(
|
||||||
dampingRatio = Spring.DampingRatioNoBouncy,
|
animationSpec = tween(150)
|
||||||
stiffness = Spring.StiffnessHigh // 🔥 Быстрее для плавности
|
|
||||||
)
|
|
||||||
) togetherWith slideOutHorizontally(
|
|
||||||
targetOffsetX = { fullWidth -> -fullWidth / 5 }, // Меньше смещение
|
|
||||||
animationSpec = spring(
|
|
||||||
dampingRatio = Spring.DampingRatioNoBouncy,
|
|
||||||
stiffness = Spring.StiffnessHigh
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🔙 Выход из чата - обратный slide
|
// 🔙 Выход из чата - плавный fade
|
||||||
isExitingChat -> {
|
isExitingChat -> {
|
||||||
slideInHorizontally(
|
fadeIn(
|
||||||
initialOffsetX = { fullWidth -> -fullWidth / 5 },
|
animationSpec = tween(200)
|
||||||
animationSpec = spring(
|
) togetherWith fadeOut(
|
||||||
dampingRatio = Spring.DampingRatioNoBouncy,
|
animationSpec = tween(150)
|
||||||
stiffness = Spring.StiffnessHigh
|
|
||||||
)
|
|
||||||
) togetherWith slideOutHorizontally(
|
|
||||||
targetOffsetX = { fullWidth -> fullWidth },
|
|
||||||
animationSpec = spring(
|
|
||||||
dampingRatio = Spring.DampingRatioNoBouncy,
|
|
||||||
stiffness = Spring.StiffnessHigh
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🔍 Вход в поиск - быстрый slide справа
|
// 🔍 Вход в Search - плавный fade
|
||||||
isEnteringSearch -> {
|
isEnteringSearch -> {
|
||||||
slideInHorizontally(
|
fadeIn(
|
||||||
initialOffsetX = { fullWidth -> fullWidth },
|
animationSpec = tween(200)
|
||||||
animationSpec = spring(
|
) togetherWith fadeOut(
|
||||||
dampingRatio = Spring.DampingRatioNoBouncy,
|
animationSpec = tween(150)
|
||||||
stiffness = Spring.StiffnessHigh // 🔥 Быстрее для плавности
|
|
||||||
)
|
|
||||||
) togetherWith slideOutHorizontally(
|
|
||||||
targetOffsetX = { fullWidth -> -fullWidth / 5 }, // Меньше смещение
|
|
||||||
animationSpec = spring(
|
|
||||||
dampingRatio = Spring.DampingRatioNoBouncy,
|
|
||||||
stiffness = Spring.StiffnessHigh
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ❌ Выход из поиска - обратный slide
|
// 🔙 Выход из Search - плавный fade
|
||||||
|
isEnteringSearch -> {
|
||||||
|
fadeIn(
|
||||||
|
animationSpec = tween(200)
|
||||||
|
) togetherWith fadeOut(
|
||||||
|
animationSpec = tween(150)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🔙 Выход из Search - плавный fade
|
||||||
isExitingSearch -> {
|
isExitingSearch -> {
|
||||||
slideInHorizontally(
|
fadeIn(
|
||||||
initialOffsetX = { fullWidth -> -fullWidth / 5 },
|
animationSpec = tween(200)
|
||||||
animationSpec = spring(
|
) togetherWith fadeOut(
|
||||||
dampingRatio = Spring.DampingRatioNoBouncy,
|
animationSpec = tween(150)
|
||||||
stiffness = Spring.StiffnessHigh
|
|
||||||
)
|
|
||||||
) togetherWith slideOutHorizontally(
|
|
||||||
targetOffsetX = { fullWidth -> fullWidth },
|
|
||||||
animationSpec = spring(
|
|
||||||
dampingRatio = Spring.DampingRatioNoBouncy,
|
|
||||||
stiffness = Spring.StiffnessHigh
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -66,6 +66,9 @@ class MessageRepository private constructor(private val context: Context) {
|
|||||||
private val _newMessageEvents = MutableSharedFlow<String>(replay = 0, extraBufferCapacity = 10)
|
private val _newMessageEvents = MutableSharedFlow<String>(replay = 0, extraBufferCapacity = 10)
|
||||||
val newMessageEvents: SharedFlow<String> = _newMessageEvents.asSharedFlow()
|
val newMessageEvents: SharedFlow<String> = _newMessageEvents.asSharedFlow()
|
||||||
|
|
||||||
|
// 🔥 Tracking для уже запрошенных user info - предотвращает бесконечные запросы
|
||||||
|
private val requestedUserInfoKeys = mutableSetOf<String>()
|
||||||
|
|
||||||
// Текущий аккаунт
|
// Текущий аккаунт
|
||||||
private var currentAccount: String? = null
|
private var currentAccount: String? = null
|
||||||
private var currentPrivateKey: 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) {
|
fun initialize(publicKey: String, privateKey: String) {
|
||||||
|
// 🔥 Очищаем кэш запрошенных user info при смене аккаунта
|
||||||
|
if (currentAccount != publicKey) {
|
||||||
|
requestedUserInfoKeys.clear()
|
||||||
|
}
|
||||||
|
|
||||||
currentAccount = publicKey
|
currentAccount = publicKey
|
||||||
currentPrivateKey = privateKey
|
currentPrivateKey = privateKey
|
||||||
|
|
||||||
@@ -582,10 +590,17 @@ class MessageRepository private constructor(private val context: Context) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Запросить информацию о пользователе с сервера
|
* Запросить информацию о пользователе с сервера
|
||||||
|
* 🔥 Защита от бесконечных запросов - каждый ключ запрашивается только один раз
|
||||||
*/
|
*/
|
||||||
fun requestUserInfo(publicKey: String) {
|
fun requestUserInfo(publicKey: String) {
|
||||||
val privateKey = currentPrivateKey ?: return
|
val privateKey = currentPrivateKey ?: return
|
||||||
|
|
||||||
|
// 🔥 Не запрашиваем если уже запрашивали
|
||||||
|
if (requestedUserInfoKeys.contains(publicKey)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
requestedUserInfoKeys.add(publicKey)
|
||||||
|
|
||||||
scope.launch {
|
scope.launch {
|
||||||
val privateKeyHash = CryptoManager.generatePrivateKeyHash(privateKey)
|
val privateKeyHash = CryptoManager.generatePrivateKeyHash(privateKey)
|
||||||
val packet = PacketSearch().apply {
|
val packet = PacketSearch().apply {
|
||||||
|
|||||||
@@ -106,7 +106,16 @@ data class DialogEntity(
|
|||||||
val verified: Int = 0, // Верифицирован
|
val verified: Int = 0, // Верифицирован
|
||||||
|
|
||||||
@ColumnInfo(name = "i_have_sent", defaultValue = "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,
|
is_online,
|
||||||
last_seen,
|
last_seen,
|
||||||
verified,
|
verified,
|
||||||
i_have_sent
|
i_have_sent,
|
||||||
|
last_message_from_me,
|
||||||
|
last_message_delivered,
|
||||||
|
last_message_read
|
||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
:account AS account,
|
:account AS account,
|
||||||
@@ -525,7 +537,31 @@ interface DialogDao {
|
|||||||
(SELECT i_have_sent FROM dialogs WHERE account = :account AND opponent_key = :opponentKey),
|
(SELECT i_have_sent FROM dialogs WHERE account = :account AND opponent_key = :opponentKey),
|
||||||
0
|
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 (
|
WHERE EXISTS (
|
||||||
SELECT 1 FROM messages
|
SELECT 1 FROM messages
|
||||||
WHERE account = :account
|
WHERE account = :account
|
||||||
@@ -557,7 +593,10 @@ interface DialogDao {
|
|||||||
is_online,
|
is_online,
|
||||||
last_seen,
|
last_seen,
|
||||||
verified,
|
verified,
|
||||||
i_have_sent
|
i_have_sent,
|
||||||
|
last_message_from_me,
|
||||||
|
last_message_delivered,
|
||||||
|
last_message_read
|
||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
:account AS account,
|
:account AS account,
|
||||||
@@ -598,7 +637,24 @@ interface DialogDao {
|
|||||||
(SELECT verified FROM dialogs WHERE account = :account AND opponent_key = :account),
|
(SELECT verified FROM dialogs WHERE account = :account AND opponent_key = :account),
|
||||||
0
|
0
|
||||||
) AS verified,
|
) 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 (
|
WHERE EXISTS (
|
||||||
SELECT 1 FROM messages
|
SELECT 1 FROM messages
|
||||||
WHERE account = :account
|
WHERE account = :account
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import android.content.Context
|
|||||||
import androidx.room.Database
|
import androidx.room.Database
|
||||||
import androidx.room.Room
|
import androidx.room.Room
|
||||||
import androidx.room.RoomDatabase
|
import androidx.room.RoomDatabase
|
||||||
|
import androidx.room.migration.Migration
|
||||||
|
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||||
|
|
||||||
@Database(
|
@Database(
|
||||||
entities = [
|
entities = [
|
||||||
@@ -12,7 +14,7 @@ import androidx.room.RoomDatabase
|
|||||||
DialogEntity::class,
|
DialogEntity::class,
|
||||||
BlacklistEntity::class
|
BlacklistEntity::class
|
||||||
],
|
],
|
||||||
version = 4,
|
version = 5,
|
||||||
exportSchema = false
|
exportSchema = false
|
||||||
)
|
)
|
||||||
abstract class RosettaDatabase : RoomDatabase() {
|
abstract class RosettaDatabase : RoomDatabase() {
|
||||||
@@ -25,6 +27,15 @@ abstract class RosettaDatabase : RoomDatabase() {
|
|||||||
@Volatile
|
@Volatile
|
||||||
private var INSTANCE: RosettaDatabase? = null
|
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 {
|
fun getDatabase(context: Context): RosettaDatabase {
|
||||||
return INSTANCE ?: synchronized(this) {
|
return INSTANCE ?: synchronized(this) {
|
||||||
val instance = Room.databaseBuilder(
|
val instance = Room.databaseBuilder(
|
||||||
@@ -33,7 +44,8 @@ abstract class RosettaDatabase : RoomDatabase() {
|
|||||||
"rosetta_secure.db"
|
"rosetta_secure.db"
|
||||||
)
|
)
|
||||||
.setJournalMode(JournalMode.WRITE_AHEAD_LOGGING) // WAL mode for performance
|
.setJournalMode(JournalMode.WRITE_AHEAD_LOGGING) // WAL mode for performance
|
||||||
.fallbackToDestructiveMigration() // Для разработки
|
.addMigrations(MIGRATION_4_5)
|
||||||
|
.fallbackToDestructiveMigration() // Для разработки - только если миграция не найдена
|
||||||
.build()
|
.build()
|
||||||
INSTANCE = instance
|
INSTANCE = instance
|
||||||
instance
|
instance
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ object ProtocolManager {
|
|||||||
private fun setupStateMonitoring() {
|
private fun setupStateMonitoring() {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
getProtocol().state.collect { newState ->
|
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 {
|
fun getProtocol(): Protocol {
|
||||||
if (protocol == null) {
|
if (protocol == null) {
|
||||||
addLog("Creating new Protocol instance")
|
addLog("🔌 Creating new Protocol instance for $SERVER_ADDRESS")
|
||||||
addLog("Server: $SERVER_ADDRESS")
|
|
||||||
protocol = Protocol(SERVER_ADDRESS) { msg -> addLog(msg) }
|
protocol = Protocol(SERVER_ADDRESS) { msg -> addLog(msg) }
|
||||||
}
|
}
|
||||||
return protocol!!
|
return protocol!!
|
||||||
@@ -225,7 +224,7 @@ object ProtocolManager {
|
|||||||
* Connect to server
|
* Connect to server
|
||||||
*/
|
*/
|
||||||
fun connect() {
|
fun connect() {
|
||||||
addLog("🔌 CONNECT REQUESTED from ProtocolManager")
|
addLog("🔌 CONNECT REQUESTED - calling Protocol.connect()")
|
||||||
getProtocol().connect()
|
getProtocol().connect()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -233,9 +232,7 @@ object ProtocolManager {
|
|||||||
* Authenticate with server
|
* Authenticate with server
|
||||||
*/
|
*/
|
||||||
fun authenticate(publicKey: String, privateHash: String) {
|
fun authenticate(publicKey: String, privateHash: String) {
|
||||||
addLog("🔐 AUTHENTICATE called from ProtocolManager")
|
addLog("🔐 AUTHENTICATE REQUESTED for ${publicKey.take(16)}...")
|
||||||
addLog(" PublicKey: ${publicKey.take(30)}...")
|
|
||||||
addLog(" PrivateHash: ${privateHash.take(20)}...")
|
|
||||||
getProtocol().startHandshake(publicKey, privateHash)
|
getProtocol().startHandshake(publicKey, privateHash)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -159,10 +159,7 @@ fun ConfirmSeedPhraseScreen(
|
|||||||
|
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = visible,
|
visible = visible,
|
||||||
enter = fadeIn(tween(250)) + slideInVertically(
|
enter = fadeIn(tween(300))
|
||||||
initialOffsetY = { -40 },
|
|
||||||
animationSpec = tween(250)
|
|
||||||
)
|
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "Confirm Backup",
|
text = "Confirm Backup",
|
||||||
@@ -176,10 +173,7 @@ fun ConfirmSeedPhraseScreen(
|
|||||||
|
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = visible,
|
visible = visible,
|
||||||
enter = fadeIn(tween(500, delayMillis = 100)) + slideInVertically(
|
enter = fadeIn(tween(400, delayMillis = 100))
|
||||||
initialOffsetY = { -20 },
|
|
||||||
animationSpec = tween(500, delayMillis = 100)
|
|
||||||
)
|
|
||||||
) {
|
) {
|
||||||
Text(
|
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.",
|
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
|
// Continue Button
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = visible,
|
visible = visible,
|
||||||
enter = fadeIn(tween(500, delayMillis = 600)) + slideInVertically(
|
enter = fadeIn(tween(400, delayMillis = 600))
|
||||||
initialOffsetY = { 50 },
|
|
||||||
animationSpec = tween(500, delayMillis = 600)
|
|
||||||
)
|
|
||||||
) {
|
) {
|
||||||
Button(
|
Button(
|
||||||
onClick = {
|
onClick = {
|
||||||
@@ -408,10 +399,7 @@ private fun AnimatedConfirmWordItem(
|
|||||||
) {
|
) {
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = visible,
|
visible = visible,
|
||||||
enter = fadeIn(tween(400, delayMillis = delay)) + slideInHorizontally(
|
enter = fadeIn(tween(400, delayMillis = delay))
|
||||||
initialOffsetX = { if (number <= 6) -50 else 50 },
|
|
||||||
animationSpec = tween(400, delayMillis = delay)
|
|
||||||
)
|
|
||||||
) {
|
) {
|
||||||
if (isInput) {
|
if (isInput) {
|
||||||
ConfirmWordInputItem(
|
ConfirmWordInputItem(
|
||||||
|
|||||||
@@ -78,10 +78,7 @@ fun ImportSeedPhraseScreen(
|
|||||||
|
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = visible,
|
visible = visible,
|
||||||
enter = fadeIn(tween(250)) + slideInVertically(
|
enter = fadeIn(tween(300))
|
||||||
initialOffsetY = { -40 },
|
|
||||||
animationSpec = tween(250)
|
|
||||||
)
|
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "Import Account",
|
text = "Import Account",
|
||||||
@@ -95,10 +92,7 @@ fun ImportSeedPhraseScreen(
|
|||||||
|
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = visible,
|
visible = visible,
|
||||||
enter = fadeIn(tween(500, delayMillis = 100)) + slideInVertically(
|
enter = fadeIn(tween(400, delayMillis = 100))
|
||||||
initialOffsetY = { -20 },
|
|
||||||
animationSpec = tween(500, delayMillis = 100)
|
|
||||||
)
|
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "Enter your 12-word recovery phrase\nto restore your account.",
|
text = "Enter your 12-word recovery phrase\nto restore your account.",
|
||||||
@@ -243,10 +237,7 @@ fun ImportSeedPhraseScreen(
|
|||||||
// Import button
|
// Import button
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = visible,
|
visible = visible,
|
||||||
enter = fadeIn(tween(500, delayMillis = 600)) + slideInVertically(
|
enter = fadeIn(tween(400, delayMillis = 600))
|
||||||
initialOffsetY = { 50 },
|
|
||||||
animationSpec = tween(500, delayMillis = 600)
|
|
||||||
)
|
|
||||||
) {
|
) {
|
||||||
Button(
|
Button(
|
||||||
onClick = {
|
onClick = {
|
||||||
@@ -302,10 +293,7 @@ private fun AnimatedWordInputItem(
|
|||||||
) {
|
) {
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = visible,
|
visible = visible,
|
||||||
enter = fadeIn(tween(400, delayMillis = delay)) + slideInHorizontally(
|
enter = fadeIn(tween(400, delayMillis = delay))
|
||||||
initialOffsetX = { if (number <= 6) -50 else 50 },
|
|
||||||
animationSpec = tween(400, delayMillis = delay)
|
|
||||||
)
|
|
||||||
) {
|
) {
|
||||||
WordInputItem(
|
WordInputItem(
|
||||||
number = number,
|
number = number,
|
||||||
|
|||||||
@@ -80,10 +80,7 @@ fun SeedPhraseScreen(
|
|||||||
|
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = visible,
|
visible = visible,
|
||||||
enter = fadeIn(tween(250)) + slideInVertically(
|
enter = fadeIn(tween(300))
|
||||||
initialOffsetY = { -40 },
|
|
||||||
animationSpec = tween(250)
|
|
||||||
)
|
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "Your Recovery Phrase",
|
text = "Your Recovery Phrase",
|
||||||
@@ -97,10 +94,7 @@ fun SeedPhraseScreen(
|
|||||||
|
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = visible,
|
visible = visible,
|
||||||
enter = fadeIn(tween(500, delayMillis = 100)) + slideInVertically(
|
enter = fadeIn(tween(400, delayMillis = 100))
|
||||||
initialOffsetY = { -20 },
|
|
||||||
animationSpec = tween(500, delayMillis = 100)
|
|
||||||
)
|
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "Write down these 12 words in order.\nYou'll need them to restore your account.",
|
text = "Write down these 12 words in order.\nYou'll need them to restore your account.",
|
||||||
@@ -213,10 +207,7 @@ fun SeedPhraseScreen(
|
|||||||
// Continue button
|
// Continue button
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = visible,
|
visible = visible,
|
||||||
enter = fadeIn(tween(500, delayMillis = 700)) + slideInVertically(
|
enter = fadeIn(tween(400, delayMillis = 700))
|
||||||
initialOffsetY = { 50 },
|
|
||||||
animationSpec = tween(500, delayMillis = 700)
|
|
||||||
)
|
|
||||||
) {
|
) {
|
||||||
Button(
|
Button(
|
||||||
onClick = { onConfirm(seedPhrase) },
|
onClick = { onConfirm(seedPhrase) },
|
||||||
@@ -313,10 +304,7 @@ private fun AnimatedWordItem(
|
|||||||
) {
|
) {
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = visible,
|
visible = visible,
|
||||||
enter = fadeIn(tween(400, delayMillis = delay)) + slideInHorizontally(
|
enter = fadeIn(tween(400, delayMillis = delay))
|
||||||
initialOffsetX = { if (number <= 6) -50 else 50 },
|
|
||||||
animationSpec = tween(400, delayMillis = delay)
|
|
||||||
)
|
|
||||||
) {
|
) {
|
||||||
WordItem(
|
WordItem(
|
||||||
number = number,
|
number = number,
|
||||||
|
|||||||
@@ -103,10 +103,7 @@ fun SelectAccountScreen(
|
|||||||
// Header
|
// Header
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = visible,
|
visible = visible,
|
||||||
enter = fadeIn(tween(250)) + slideInVertically(
|
enter = fadeIn(tween(300))
|
||||||
initialOffsetY = { -40 },
|
|
||||||
animationSpec = tween(250)
|
|
||||||
)
|
|
||||||
) {
|
) {
|
||||||
Column {
|
Column {
|
||||||
Row {
|
Row {
|
||||||
@@ -269,10 +266,7 @@ private fun AccountListItem(
|
|||||||
|
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = visible,
|
visible = visible,
|
||||||
enter = fadeIn(tween(200)) + slideInHorizontally(
|
enter = fadeIn(tween(300))
|
||||||
initialOffsetX = { 50 },
|
|
||||||
animationSpec = tween(200)
|
|
||||||
)
|
|
||||||
) {
|
) {
|
||||||
Card(
|
Card(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -378,10 +372,7 @@ private fun AddAccountButton(
|
|||||||
|
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = visible,
|
visible = visible,
|
||||||
enter = fadeIn(tween(200)) + slideInHorizontally(
|
enter = fadeIn(tween(300))
|
||||||
initialOffsetX = { -50 },
|
|
||||||
animationSpec = tween(200)
|
|
||||||
)
|
|
||||||
) {
|
) {
|
||||||
Card(
|
Card(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
|||||||
@@ -178,12 +178,7 @@ fun SetPasswordScreen(
|
|||||||
|
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = visible,
|
visible = visible,
|
||||||
enter =
|
enter = fadeIn(tween(400, delayMillis = 100))
|
||||||
fadeIn(tween(500, delayMillis = 100)) +
|
|
||||||
slideInVertically(
|
|
||||||
initialOffsetY = { -20 },
|
|
||||||
animationSpec = tween(500, delayMillis = 100)
|
|
||||||
)
|
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "Protect Your Account",
|
text = "Protect Your Account",
|
||||||
@@ -214,12 +209,7 @@ fun SetPasswordScreen(
|
|||||||
// Password Field
|
// Password Field
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = visible,
|
visible = visible,
|
||||||
enter =
|
enter = fadeIn(tween(400, delayMillis = 300))
|
||||||
fadeIn(tween(500, delayMillis = 300)) +
|
|
||||||
slideInVertically(
|
|
||||||
initialOffsetY = { 50 },
|
|
||||||
animationSpec = tween(500, delayMillis = 300)
|
|
||||||
)
|
|
||||||
) {
|
) {
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = password,
|
value = password,
|
||||||
@@ -270,12 +260,7 @@ fun SetPasswordScreen(
|
|||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = visible,
|
visible = visible,
|
||||||
enter =
|
enter = fadeIn(tween(400, delayMillis = 350))
|
||||||
fadeIn(tween(400, delayMillis = 350)) +
|
|
||||||
slideInHorizontally(
|
|
||||||
initialOffsetX = { -30 },
|
|
||||||
animationSpec = tween(400, delayMillis = 350)
|
|
||||||
)
|
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.fillMaxWidth()) {
|
Column(modifier = Modifier.fillMaxWidth()) {
|
||||||
Row(
|
Row(
|
||||||
@@ -345,12 +330,7 @@ fun SetPasswordScreen(
|
|||||||
// Confirm Password Field
|
// Confirm Password Field
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = visible,
|
visible = visible,
|
||||||
enter =
|
enter = fadeIn(tween(400, delayMillis = 400))
|
||||||
fadeIn(tween(500, delayMillis = 400)) +
|
|
||||||
slideInVertically(
|
|
||||||
initialOffsetY = { 50 },
|
|
||||||
animationSpec = tween(500, delayMillis = 400)
|
|
||||||
)
|
|
||||||
) {
|
) {
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = confirmPassword,
|
value = confirmPassword,
|
||||||
@@ -407,12 +387,7 @@ fun SetPasswordScreen(
|
|||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = visible,
|
visible = visible,
|
||||||
enter =
|
enter = fadeIn(tween(400, delayMillis = 450))
|
||||||
fadeIn(tween(400, delayMillis = 450)) +
|
|
||||||
slideInHorizontally(
|
|
||||||
initialOffsetX = { -30 },
|
|
||||||
animationSpec = tween(400, delayMillis = 450)
|
|
||||||
)
|
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
@@ -459,20 +434,8 @@ fun SetPasswordScreen(
|
|||||||
// Info - hide when keyboard is visible
|
// Info - hide when keyboard is visible
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = visible && !isKeyboardVisible,
|
visible = visible && !isKeyboardVisible,
|
||||||
enter =
|
enter = fadeIn(tween(300)),
|
||||||
fadeIn(tween(200)) +
|
exit = fadeOut(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))
|
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier =
|
modifier =
|
||||||
@@ -504,12 +467,7 @@ fun SetPasswordScreen(
|
|||||||
// Create Account Button
|
// Create Account Button
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = visible,
|
visible = visible,
|
||||||
enter =
|
enter = fadeIn(tween(400, delayMillis = 600))
|
||||||
fadeIn(tween(500, delayMillis = 600)) +
|
|
||||||
slideInVertically(
|
|
||||||
initialOffsetY = { 50 },
|
|
||||||
animationSpec = tween(500, delayMillis = 600)
|
|
||||||
)
|
|
||||||
) {
|
) {
|
||||||
Button(
|
Button(
|
||||||
onClick = {
|
onClick = {
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import com.rosetta.messenger.data.AccountManager
|
|||||||
import com.rosetta.messenger.data.DecryptedAccount
|
import com.rosetta.messenger.data.DecryptedAccount
|
||||||
import com.rosetta.messenger.data.EncryptedAccount
|
import com.rosetta.messenger.data.EncryptedAccount
|
||||||
import com.rosetta.messenger.network.ProtocolManager
|
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.getAvatarColor
|
||||||
import com.rosetta.messenger.ui.chats.getAvatarText
|
import com.rosetta.messenger.ui.chats.getAvatarText
|
||||||
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
|
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
|
||||||
@@ -195,12 +196,7 @@ fun UnlockScreen(
|
|||||||
// Title
|
// Title
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = visible,
|
visible = visible,
|
||||||
enter =
|
enter = fadeIn(tween(400, delayMillis = 200))
|
||||||
fadeIn(tween(600, delayMillis = 200)) +
|
|
||||||
slideInVertically(
|
|
||||||
initialOffsetY = { 30 },
|
|
||||||
animationSpec = tween(600, delayMillis = 200)
|
|
||||||
)
|
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "Welcome Back",
|
text = "Welcome Back",
|
||||||
@@ -226,12 +222,7 @@ fun UnlockScreen(
|
|||||||
// Account Selector Card
|
// Account Selector Card
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = visible,
|
visible = visible,
|
||||||
enter =
|
enter = fadeIn(tween(400, delayMillis = 350))
|
||||||
fadeIn(tween(600, delayMillis = 350)) +
|
|
||||||
slideInVertically(
|
|
||||||
initialOffsetY = { 50 },
|
|
||||||
animationSpec = tween(600, delayMillis = 350)
|
|
||||||
)
|
|
||||||
) {
|
) {
|
||||||
Column {
|
Column {
|
||||||
// Account selector dropdown
|
// Account selector dropdown
|
||||||
@@ -511,12 +502,7 @@ fun UnlockScreen(
|
|||||||
// Password Field
|
// Password Field
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = visible && !isDropdownExpanded,
|
visible = visible && !isDropdownExpanded,
|
||||||
enter =
|
enter = fadeIn(tween(400, delayMillis = 400))
|
||||||
fadeIn(tween(600, delayMillis = 400)) +
|
|
||||||
slideInVertically(
|
|
||||||
initialOffsetY = { 50 },
|
|
||||||
animationSpec = tween(600, delayMillis = 400)
|
|
||||||
)
|
|
||||||
) {
|
) {
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = password,
|
value = password,
|
||||||
@@ -566,8 +552,8 @@ fun UnlockScreen(
|
|||||||
// Error message
|
// Error message
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = error != null,
|
visible = error != null,
|
||||||
enter = fadeIn() + slideInVertically { -10 },
|
enter = fadeIn(tween(200)),
|
||||||
exit = fadeOut()
|
exit = fadeOut(tween(150))
|
||||||
) {
|
) {
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
Text(text = error ?: "", fontSize = 14.sp, color = Color(0xFFE53935))
|
Text(text = error ?: "", fontSize = 14.sp, color = Color(0xFFE53935))
|
||||||
@@ -578,12 +564,7 @@ fun UnlockScreen(
|
|||||||
// Unlock Button
|
// Unlock Button
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = visible && !isDropdownExpanded,
|
visible = visible && !isDropdownExpanded,
|
||||||
enter =
|
enter = fadeIn(tween(400, delayMillis = 500))
|
||||||
fadeIn(tween(600, delayMillis = 500)) +
|
|
||||||
slideInVertically(
|
|
||||||
initialOffsetY = { 50 },
|
|
||||||
animationSpec = tween(600, delayMillis = 500)
|
|
||||||
)
|
|
||||||
) {
|
) {
|
||||||
Button(
|
Button(
|
||||||
onClick = {
|
onClick = {
|
||||||
@@ -636,13 +617,37 @@ fun UnlockScreen(
|
|||||||
name = account.name
|
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
|
// Connect to server and authenticate
|
||||||
ProtocolManager.connect()
|
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)
|
ProtocolManager.authenticate(account.publicKey, privateKeyHash)
|
||||||
|
|
||||||
accountManager.setCurrentAccount(account.publicKey)
|
accountManager.setCurrentAccount(account.publicKey)
|
||||||
|
|
||||||
|
android.util.Log.d("UnlockScreen", "✅ Unlock complete, calling onUnlocked callback")
|
||||||
onUnlocked(decryptedAccount)
|
onUnlocked(decryptedAccount)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
error = "Failed to unlock: \${e.message}"
|
error = "Failed to unlock: \${e.message}"
|
||||||
@@ -684,12 +689,7 @@ fun UnlockScreen(
|
|||||||
// Create New Account button
|
// Create New Account button
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = visible && !isDropdownExpanded,
|
visible = visible && !isDropdownExpanded,
|
||||||
enter =
|
enter = fadeIn(tween(400, delayMillis = 600))
|
||||||
fadeIn(tween(600, delayMillis = 600)) +
|
|
||||||
slideInVertically(
|
|
||||||
initialOffsetY = { 50 },
|
|
||||||
animationSpec = tween(600, delayMillis = 600)
|
|
||||||
)
|
|
||||||
) {
|
) {
|
||||||
TextButton(onClick = onSwitchAccount) {
|
TextButton(onClick = onSwitchAccount) {
|
||||||
Icon(
|
Icon(
|
||||||
|
|||||||
@@ -115,12 +115,7 @@ fun WelcomeScreen(
|
|||||||
// Title
|
// Title
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = visible,
|
visible = visible,
|
||||||
enter =
|
enter = fadeIn(tween(400, delayMillis = 200))
|
||||||
fadeIn(tween(600, delayMillis = 200)) +
|
|
||||||
slideInVertically(
|
|
||||||
initialOffsetY = { 50 },
|
|
||||||
animationSpec = tween(600, delayMillis = 200)
|
|
||||||
)
|
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "Your Keys,\nYour Messages",
|
text = "Your Keys,\nYour Messages",
|
||||||
@@ -137,12 +132,7 @@ fun WelcomeScreen(
|
|||||||
// Subtitle
|
// Subtitle
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = visible,
|
visible = visible,
|
||||||
enter =
|
enter = fadeIn(tween(400, delayMillis = 300))
|
||||||
fadeIn(tween(600, delayMillis = 300)) +
|
|
||||||
slideInVertically(
|
|
||||||
initialOffsetY = { 50 },
|
|
||||||
animationSpec = tween(600, delayMillis = 300)
|
|
||||||
)
|
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "Secure messaging with\ncryptographic keys",
|
text = "Secure messaging with\ncryptographic keys",
|
||||||
@@ -188,12 +178,7 @@ fun WelcomeScreen(
|
|||||||
// Create Seed Button
|
// Create Seed Button
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = visible,
|
visible = visible,
|
||||||
enter =
|
enter = fadeIn(tween(400, delayMillis = 500))
|
||||||
fadeIn(tween(600, delayMillis = 500)) +
|
|
||||||
slideInVertically(
|
|
||||||
initialOffsetY = { 100 },
|
|
||||||
animationSpec = tween(600, delayMillis = 500)
|
|
||||||
)
|
|
||||||
) {
|
) {
|
||||||
Button(
|
Button(
|
||||||
onClick = onCreateSeed,
|
onClick = onCreateSeed,
|
||||||
@@ -229,12 +214,7 @@ fun WelcomeScreen(
|
|||||||
// Import Seed Button
|
// Import Seed Button
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = visible,
|
visible = visible,
|
||||||
enter =
|
enter = fadeIn(tween(400, delayMillis = 600))
|
||||||
fadeIn(tween(600, delayMillis = 600)) +
|
|
||||||
slideInVertically(
|
|
||||||
initialOffsetY = { 100 },
|
|
||||||
animationSpec = tween(600, delayMillis = 600)
|
|
||||||
)
|
|
||||||
) {
|
) {
|
||||||
TextButton(
|
TextButton(
|
||||||
onClick = onImportSeed,
|
onClick = onImportSeed,
|
||||||
|
|||||||
@@ -59,6 +59,9 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.compose.ui.window.Popup
|
import androidx.compose.ui.window.Popup
|
||||||
import androidx.compose.ui.window.PopupProperties
|
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 androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import app.rosette.android.ui.keyboard.AnimatedKeyboardTransition
|
import app.rosette.android.ui.keyboard.AnimatedKeyboardTransition
|
||||||
import app.rosette.android.ui.keyboard.KeyboardTransitionCoordinator
|
import app.rosette.android.ui.keyboard.KeyboardTransitionCoordinator
|
||||||
@@ -474,8 +477,30 @@ fun ChatDetailScreen(
|
|||||||
// 🔥 Обработка системной кнопки назад
|
// 🔥 Обработка системной кнопки назад
|
||||||
BackHandler { hideKeyboardAndBack() }
|
BackHandler { hideKeyboardAndBack() }
|
||||||
|
|
||||||
// 🔥 Cleanup при выходе из экрана - только закрываем диалог
|
// 🔥 Lifecycle-aware отслеживание активности экрана
|
||||||
DisposableEffect(Unit) { onDispose { viewModel.closeDialog() } }
|
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 с ключами и открываем диалог
|
// Инициализируем ViewModel с ключами и открываем диалог
|
||||||
LaunchedEffect(user.publicKey) {
|
LaunchedEffect(user.publicKey) {
|
||||||
@@ -489,9 +514,9 @@ fun ChatDetailScreen(
|
|||||||
com.rosetta.messenger.ui.components.EmojiCache.preload(context)
|
com.rosetta.messenger.ui.components.EmojiCache.preload(context)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Отмечаем сообщения как прочитанные когда они видны
|
// Отмечаем сообщения как прочитанные только когда экран активен (RESUMED)
|
||||||
LaunchedEffect(messages) {
|
LaunchedEffect(messages, isScreenActive) {
|
||||||
if (messages.isNotEmpty()) {
|
if (messages.isNotEmpty() && isScreenActive) {
|
||||||
viewModel.markVisibleMessagesAsRead()
|
viewModel.markVisibleMessagesAsRead()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -855,20 +880,14 @@ fun ChatDetailScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Кнопка меню - открывает bottom sheet
|
// Кнопка меню - открывает kebab menu
|
||||||
|
Box {
|
||||||
IconButton(
|
IconButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
// Закрываем клавиатуру перед открытием меню
|
// Закрываем клавиатуру перед открытием меню
|
||||||
keyboardController?.hide()
|
keyboardController?.hide()
|
||||||
focusManager.clearFocus()
|
focusManager.clearFocus()
|
||||||
// Даём клавиатуре время закрыться перед показом
|
|
||||||
// bottom sheet
|
|
||||||
scope.launch {
|
|
||||||
delay(
|
|
||||||
150
|
|
||||||
) // Задержка для плавного закрытия клавиатуры
|
|
||||||
showMenu = true
|
showMenu = true
|
||||||
}
|
|
||||||
},
|
},
|
||||||
modifier = Modifier.size(48.dp).clip(CircleShape)
|
modifier = Modifier.size(48.dp).clip(CircleShape)
|
||||||
) {
|
) {
|
||||||
@@ -879,6 +898,28 @@ fun ChatDetailScreen(
|
|||||||
modifier = Modifier.size(26.dp)
|
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
|
||||||
|
},
|
||||||
|
onUnblockClick = {
|
||||||
|
showMenu = false
|
||||||
|
showUnblockConfirm = true
|
||||||
|
},
|
||||||
|
onDeleteClick = {
|
||||||
|
showMenu = false
|
||||||
|
showDeleteConfirm = true
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} // Закрытие Crossfade
|
} // Закрытие Crossfade
|
||||||
@@ -927,29 +968,8 @@ fun ChatDetailScreen(
|
|||||||
AnimatedContent(
|
AnimatedContent(
|
||||||
targetState = isSelectionMode,
|
targetState = isSelectionMode,
|
||||||
transitionSpec = {
|
transitionSpec = {
|
||||||
if (targetState) {
|
fadeIn(animationSpec = tween(200)) togetherWith
|
||||||
// Selection mode появляется: снизу вверх
|
fadeOut(animationSpec = tween(150))
|
||||||
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))
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
label = "bottomBarContent"
|
label = "bottomBarContent"
|
||||||
) { selectionMode ->
|
) { selectionMode ->
|
||||||
@@ -1680,74 +1700,7 @@ fun ChatDetailScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// <EFBFBD> Bottom Sheet меню (вместо popup menu)
|
// 📨 Forward Chat Picker BottomSheet
|
||||||
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
|
|
||||||
if (showForwardPicker) {
|
if (showForwardPicker) {
|
||||||
ForwardChatPickerBottomSheet(
|
ForwardChatPickerBottomSheet(
|
||||||
dialogs = dialogsList,
|
dialogs = dialogsList,
|
||||||
@@ -3180,3 +3133,108 @@ private fun BottomSheetMenuItem(
|
|||||||
Text(text = text, color = textColor, fontSize = 18.sp, fontWeight = FontWeight.Medium)
|
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 в архиве
|
// Как currentDialogPublicKeyView в архиве
|
||||||
private var isDialogActive = false
|
private var isDialogActive = false
|
||||||
|
|
||||||
|
// 🟢 Флаг что уже подписаны на онлайн статус собеседника
|
||||||
|
private var subscribedToOnlineStatus = false
|
||||||
|
|
||||||
init {
|
init {
|
||||||
setupPacketListeners()
|
setupPacketListeners()
|
||||||
setupNewMessageListener()
|
setupNewMessageListener()
|
||||||
@@ -351,6 +354,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
isLoadingMessages = false
|
isLoadingMessages = false
|
||||||
lastReadMessageTimestamp = 0L
|
lastReadMessageTimestamp = 0L
|
||||||
readReceiptSentForCurrentDialog = false
|
readReceiptSentForCurrentDialog = false
|
||||||
|
subscribedToOnlineStatus = false // 🔥 Сбрасываем флаг подписки при смене диалога
|
||||||
isDialogActive = true // 🔥 Диалог активен!
|
isDialogActive = true // 🔥 Диалог активен!
|
||||||
|
|
||||||
// 📨 Применяем Forward сообщения СРАЗУ после сброса
|
// 📨 Применяем Forward сообщения СРАЗУ после сброса
|
||||||
@@ -390,6 +394,14 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
isDialogActive = false
|
isDialogActive = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🔥 Установить состояние активности диалога (вызывается при ON_RESUME/ON_PAUSE)
|
||||||
|
* Предотвращает отметку сообщений как прочитанных когда приложение в фоне
|
||||||
|
*/
|
||||||
|
fun setDialogActive(active: Boolean) {
|
||||||
|
isDialogActive = active
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 🚀 СУПЕР-оптимизированная загрузка сообщений
|
* 🚀 СУПЕР-оптимизированная загрузка сообщений
|
||||||
* 🔥 ОПТИМИЗАЦИЯ: Кэш на уровне диалога + задержка для анимации + чанковая расшифровка
|
* 🔥 ОПТИМИЗАЦИЯ: Кэш на уровне диалога + задержка для анимации + чанковая расшифровка
|
||||||
@@ -1428,8 +1440,12 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
/**
|
/**
|
||||||
* 🟢 Подписаться на онлайн статус собеседника
|
* 🟢 Подписаться на онлайн статус собеседника
|
||||||
* 📁 Для Saved Messages - не подписываемся
|
* 📁 Для Saved Messages - не подписываемся
|
||||||
|
* 🔥 Проверяем флаг чтобы не подписываться повторно
|
||||||
*/
|
*/
|
||||||
fun subscribeToOnlineStatus() {
|
fun subscribeToOnlineStatus() {
|
||||||
|
// 🔥 Если уже подписаны - не подписываемся повторно!
|
||||||
|
if (subscribedToOnlineStatus) return
|
||||||
|
|
||||||
val opponent = opponentKey ?: return
|
val opponent = opponentKey ?: return
|
||||||
val privateKey = myPrivateKey ?: return
|
val privateKey = myPrivateKey ?: return
|
||||||
val account = myPublicKey ?: return
|
val account = myPublicKey ?: return
|
||||||
@@ -1439,6 +1455,9 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🔥 Устанавливаем флаг ДО отправки пакета
|
||||||
|
subscribedToOnlineStatus = true
|
||||||
|
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
val privateKeyHash = CryptoManager.generatePrivateKeyHash(privateKey)
|
val privateKeyHash = CryptoManager.generatePrivateKeyHash(privateKey)
|
||||||
@@ -1460,6 +1479,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
super.onCleared()
|
super.onCleared()
|
||||||
lastReadMessageTimestamp = 0L
|
lastReadMessageTimestamp = 0L
|
||||||
readReceiptSentForCurrentDialog = false
|
readReceiptSentForCurrentDialog = false
|
||||||
|
subscribedToOnlineStatus = false // 🔥 Сбрасываем флаг при очистке
|
||||||
opponentKey = null
|
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
|
// Status dialog with logs
|
||||||
if (showStatusDialog) {
|
if (showStatusDialog) {
|
||||||
val clipboardManager = androidx.compose.ui.platform.LocalClipboardManager.current
|
val clipboardManager = androidx.compose.ui.platform.LocalClipboardManager.current
|
||||||
@@ -576,9 +581,13 @@ fun ChatsListScreen(
|
|||||||
iconColor = menuIconColor,
|
iconColor = menuIconColor,
|
||||||
textColor = textColor,
|
textColor = textColor,
|
||||||
onClick = {
|
onClick = {
|
||||||
scope.launch { drawerState.close() }
|
scope.launch {
|
||||||
|
drawerState.close()
|
||||||
|
// Ждём завершения анимации закрытия drawer
|
||||||
|
kotlinx.coroutines.delay(250)
|
||||||
onSavedMessagesClick()
|
onSavedMessagesClick()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
DrawerDivider(isDarkTheme)
|
DrawerDivider(isDarkTheme)
|
||||||
@@ -893,37 +902,8 @@ fun ChatsListScreen(
|
|||||||
AnimatedContent(
|
AnimatedContent(
|
||||||
targetState = showRequestsScreen,
|
targetState = showRequestsScreen,
|
||||||
transitionSpec = {
|
transitionSpec = {
|
||||||
if (targetState) {
|
fadeIn(animationSpec = tween(200)) togetherWith
|
||||||
// Переход на Requests: быстрый slide + fade
|
fadeOut(animationSpec = tween(150))
|
||||||
(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)))
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
label = "RequestsTransition"
|
label = "RequestsTransition"
|
||||||
) { isRequestsScreen ->
|
) { isRequestsScreen ->
|
||||||
@@ -1662,12 +1642,64 @@ fun DialogItemContent(
|
|||||||
modifier = Modifier.weight(1f)
|
modifier = Modifier.weight(1f)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
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(
|
||||||
text = formatTime(Date(dialog.lastMessageTimestamp)),
|
text = formatTime(Date(dialog.lastMessageTimestamp)),
|
||||||
fontSize = 13.sp,
|
fontSize = 13.sp,
|
||||||
color = if (dialog.unreadCount > 0) PrimaryBlue else secondaryTextColor
|
color = if (dialog.unreadCount > 0) PrimaryBlue else secondaryTextColor
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
|
||||||
|
|||||||
@@ -30,7 +30,10 @@ data class DialogUiModel(
|
|||||||
val isOnline: Int,
|
val isOnline: Int,
|
||||||
val lastSeen: Long,
|
val lastSeen: Long,
|
||||||
val verified: Int,
|
val verified: Int,
|
||||||
val isSavedMessages: Boolean = false // 📁 Флаг для Saved Messages (account == opponentKey)
|
val isSavedMessages: Boolean = false, // 📁 Флаг для Saved Messages (account == opponentKey)
|
||||||
|
val lastMessageFromMe: Int = 0, // Последнее сообщение от меня (0/1)
|
||||||
|
val 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 - предотвращает бесконечные запросы
|
// 🔥 Tracking для уже запрошенных user info - предотвращает бесконечные запросы
|
||||||
private val requestedUserInfoKeys = mutableSetOf<String>()
|
private val requestedUserInfoKeys = mutableSetOf<String>()
|
||||||
|
|
||||||
|
// 🟢 Tracking для уже подписанных онлайн-статусов - предотвращает бесконечный цикл
|
||||||
|
private val subscribedOnlineKeys = mutableSetOf<String>()
|
||||||
|
|
||||||
// Список диалогов с расшифрованными сообщениями
|
// Список диалогов с расшифрованными сообщениями
|
||||||
private val _dialogs = MutableStateFlow<List<DialogUiModel>>(emptyList())
|
private val _dialogs = MutableStateFlow<List<DialogUiModel>>(emptyList())
|
||||||
val dialogs: StateFlow<List<DialogUiModel>> = _dialogs.asStateFlow()
|
val dialogs: StateFlow<List<DialogUiModel>> = _dialogs.asStateFlow()
|
||||||
@@ -97,6 +103,10 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
|
|||||||
if (currentAccount == publicKey) {
|
if (currentAccount == publicKey) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🔥 Очищаем кэш запрошенных user info при смене аккаунта
|
||||||
|
requestedUserInfoKeys.clear()
|
||||||
|
|
||||||
currentAccount = publicKey
|
currentAccount = publicKey
|
||||||
currentPrivateKey = privateKey
|
currentPrivateKey = privateKey
|
||||||
|
|
||||||
@@ -138,7 +148,10 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
|
|||||||
isOnline = dialog.isOnline,
|
isOnline = dialog.isOnline,
|
||||||
lastSeen = dialog.lastSeen,
|
lastSeen = dialog.lastSeen,
|
||||||
verified = dialog.verified,
|
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,
|
isOnline = dialog.isOnline,
|
||||||
lastSeen = dialog.lastSeen,
|
lastSeen = dialog.lastSeen,
|
||||||
verified = dialog.verified,
|
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) {
|
private fun subscribeToOnlineStatuses(opponentKeys: List<String>, privateKey: String) {
|
||||||
if (opponentKeys.isEmpty()) return
|
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) {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
val privateKeyHash = CryptoManager.generatePrivateKeyHash(privateKey)
|
val privateKeyHash = CryptoManager.generatePrivateKeyHash(privateKey)
|
||||||
|
|
||||||
val packet = PacketOnlineSubscribe().apply {
|
val packet = PacketOnlineSubscribe().apply {
|
||||||
this.privateKey = privateKeyHash
|
this.privateKey = privateKeyHash
|
||||||
opponentKeys.forEach { key ->
|
newKeys.forEach { key ->
|
||||||
addPublicKey(key)
|
addPublicKey(key)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user