diff --git a/app/src/main/java/com/rosetta/messenger/MainActivity.kt b/app/src/main/java/com/rosetta/messenger/MainActivity.kt index 7b7c215..044e40e 100644 --- a/app/src/main/java/com/rosetta/messenger/MainActivity.kt +++ b/app/src/main/java/com/rosetta/messenger/MainActivity.kt @@ -1,7 +1,6 @@ package com.rosetta.messenger import android.Manifest -import android.content.Context import android.content.pm.PackageManager import android.os.Build import android.os.Bundle @@ -11,110 +10,115 @@ import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.activity.result.contract.ActivityResultContracts -import androidx.core.content.ContextCompat -import androidx.lifecycle.lifecycleScope import androidx.compose.animation.* -import androidx.compose.animation.core.FastOutSlowInEasing -import androidx.compose.animation.core.FastOutLinearInEasing -import androidx.compose.animation.core.LinearOutSlowInEasing -import androidx.compose.animation.core.LinearEasing -import androidx.compose.animation.core.Spring -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 import androidx.compose.runtime.* -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.IntOffset -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp +import androidx.core.content.ContextCompat +import androidx.lifecycle.lifecycleScope import com.google.firebase.FirebaseApp import com.google.firebase.messaging.FirebaseMessaging -import com.rosetta.messenger.crypto.CryptoManager import com.rosetta.messenger.data.AccountManager import com.rosetta.messenger.data.DecryptedAccount import com.rosetta.messenger.data.PreferencesManager import com.rosetta.messenger.data.RecentSearchesManager import com.rosetta.messenger.network.PacketPushNotification -import com.rosetta.messenger.network.PushNotificationAction import com.rosetta.messenger.network.ProtocolManager import com.rosetta.messenger.network.ProtocolState +import com.rosetta.messenger.network.PushNotificationAction +import com.rosetta.messenger.network.SearchUser import com.rosetta.messenger.ui.auth.AccountInfo import com.rosetta.messenger.ui.auth.AuthFlow -import com.rosetta.messenger.ui.chats.ChatsListScreen import com.rosetta.messenger.ui.chats.ChatDetailScreen +import com.rosetta.messenger.ui.chats.ChatsListScreen import com.rosetta.messenger.ui.chats.SearchScreen -import com.rosetta.messenger.network.SearchUser -import com.rosetta.messenger.ui.components.EmojiCache import com.rosetta.messenger.ui.components.OptimizedEmojiCache import com.rosetta.messenger.ui.onboarding.OnboardingScreen import com.rosetta.messenger.ui.splash.SplashScreen import com.rosetta.messenger.ui.theme.RosettaAndroidTheme -import kotlinx.coroutines.launch +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale import kotlinx.coroutines.delay +import kotlinx.coroutines.launch class MainActivity : ComponentActivity() { private lateinit var preferencesManager: PreferencesManager private lateinit var accountManager: AccountManager - + companion object { private const val TAG = "MainActivity" + + // 🔔 FCM Логи для отображения в UI + private val _fcmLogs = mutableStateListOf() + val fcmLogs: List + get() = _fcmLogs + + fun addFcmLog(message: String) { + val timestamp = SimpleDateFormat("HH:mm:ss", Locale.getDefault()).format(Date()) + _fcmLogs.add(0, "[$timestamp] $message") // Добавляем в начало списка + // Ограничиваем количество логов + if (_fcmLogs.size > 20) { + _fcmLogs.removeAt(_fcmLogs.size - 1) + } + Log.d(TAG, "FCM: $message") + } + + fun clearFcmLogs() { + _fcmLogs.clear() + } } - + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() - + preferencesManager = PreferencesManager(this) accountManager = AccountManager(this) RecentSearchesManager.init(this) - + // 🔥 Инициализируем ProtocolManager для обработки онлайн статусов ProtocolManager.initialize(this) - + // 🔔 Инициализируем Firebase для push-уведомлений initializeFirebase() - + // 🔥 Помечаем что приложение в foreground com.rosetta.messenger.push.RosettaFirebaseMessagingService.isAppInForeground = true - + // 📱 Предзагружаем эмодзи в фоне для мгновенного открытия пикера // Используем новый оптимизированный кэш OptimizedEmojiCache.preload(this) - - setContent { // 🔔 Запрос разрешения на уведомления для Android 13+ - val notificationPermissionLauncher = rememberLauncherForActivityResult( - contract = ActivityResultContracts.RequestPermission(), - onResult = { isGranted -> - } - ) - + + setContent { // 🔔 Запрос разрешения на уведомления для Android 13+ + val notificationPermissionLauncher = + rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestPermission(), + onResult = { isGranted -> } + ) + // Запрашиваем разрешение при первом запуске (Android 13+) LaunchedEffect(Unit) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - val hasPermission = ContextCompat.checkSelfPermission( - this@MainActivity, - Manifest.permission.POST_NOTIFICATIONS - ) == PackageManager.PERMISSION_GRANTED - + val hasPermission = + ContextCompat.checkSelfPermission( + this@MainActivity, + Manifest.permission.POST_NOTIFICATIONS + ) == PackageManager.PERMISSION_GRANTED + if (!hasPermission) { - notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) + notificationPermissionLauncher.launch( + Manifest.permission.POST_NOTIFICATIONS + ) } } } - + val scope = rememberCoroutineScope() val isDarkTheme by preferencesManager.isDarkTheme.collectAsState(initial = true) val isLoggedIn by accountManager.isLoggedIn.collectAsState(initial = null) @@ -123,152 +127,179 @@ class MainActivity : ComponentActivity() { var hasExistingAccount by remember { mutableStateOf(null) } var currentAccount by remember { mutableStateOf(null) } var accountInfoList by remember { mutableStateOf>(emptyList()) } - + // Check for existing accounts and build AccountInfo list // Also force logout so user always sees unlock screen on app restart LaunchedEffect(Unit) { accountManager.logout() // Always start logged out val accounts = accountManager.getAllAccounts() hasExistingAccount = accounts.isNotEmpty() - accountInfoList = accounts.map { account -> - val shortKey = account.publicKey.take(7) - val displayName = account.name ?: shortKey - val initials = displayName.trim().split(Regex("\\s+")) - .filter { it.isNotEmpty() } - .let { words -> - when { - words.isEmpty() -> "??" - words.size == 1 -> words[0].take(2).uppercase() - else -> "${words[0].first()}${words[1].first()}".uppercase() - } + accountInfoList = + accounts.map { account -> + val shortKey = account.publicKey.take(7) + val displayName = account.name ?: shortKey + val initials = + displayName + .trim() + .split(Regex("\\s+")) + .filter { it.isNotEmpty() } + .let { words -> + when { + words.isEmpty() -> "??" + words.size == 1 -> words[0].take(2).uppercase() + else -> + "${words[0].first()}${words[1].first()}".uppercase() + } + } + AccountInfo( + id = account.publicKey, + name = displayName, + initials = initials, + publicKey = account.publicKey + ) } - AccountInfo( - id = account.publicKey, - name = displayName, - initials = initials, - publicKey = account.publicKey - ) - } } - + // Wait for initial load if (hasExistingAccount == null) { Box( - modifier = Modifier - .fillMaxSize() - .background(if (isDarkTheme) Color(0xFF1B1B1B) else Color.White) + modifier = + Modifier.fillMaxSize() + .background( + if (isDarkTheme) Color(0xFF1B1B1B) else Color.White + ) ) return@setContent } - - RosettaAndroidTheme( - darkTheme = isDarkTheme, - animated = true - ) { + + RosettaAndroidTheme(darkTheme = isDarkTheme, animated = true) { Surface( - modifier = Modifier.fillMaxSize(), - color = if (isDarkTheme) Color(0xFF1B1B1B) else Color.White + modifier = Modifier.fillMaxSize(), + color = if (isDarkTheme) Color(0xFF1B1B1B) else Color.White ) { AnimatedContent( - targetState = when { - showSplash -> "splash" - showOnboarding && hasExistingAccount == false -> "onboarding" - isLoggedIn != true && hasExistingAccount == false -> "auth_new" - isLoggedIn != true && hasExistingAccount == true -> "auth_unlock" - else -> "main" - }, - transitionSpec = { - fadeIn(animationSpec = tween(600)) togetherWith - fadeOut(animationSpec = tween(600)) - }, - label = "screenTransition" + targetState = + when { + showSplash -> "splash" + showOnboarding && hasExistingAccount == false -> + "onboarding" + isLoggedIn != true && hasExistingAccount == false -> + "auth_new" + isLoggedIn != true && hasExistingAccount == true -> + "auth_unlock" + else -> "main" + }, + transitionSpec = { + fadeIn(animationSpec = tween(600)) togetherWith + fadeOut(animationSpec = tween(600)) + }, + label = "screenTransition" ) { screen -> when (screen) { "splash" -> { SplashScreen( - isDarkTheme = isDarkTheme, - onSplashComplete = { showSplash = false } + isDarkTheme = isDarkTheme, + onSplashComplete = { showSplash = false } ) } "onboarding" -> { OnboardingScreen( - isDarkTheme = isDarkTheme, - onThemeToggle = { - scope.launch { - preferencesManager.setDarkTheme(!isDarkTheme) - } - }, - onStartMessaging = { - showOnboarding = false - } + isDarkTheme = isDarkTheme, + onThemeToggle = { + scope.launch { + preferencesManager.setDarkTheme(!isDarkTheme) + } + }, + onStartMessaging = { showOnboarding = false } ) } "auth_new", "auth_unlock" -> { AuthFlow( - isDarkTheme = isDarkTheme, - hasExistingAccount = screen == "auth_unlock", - accounts = accountInfoList, - accountManager = accountManager, - onAuthComplete = { account -> - currentAccount = account - hasExistingAccount = true - // Save as last logged account - account?.let { accountManager.setLastLoggedPublicKey(it.publicKey) } - - // 📤 Отправляем FCM токен на сервер после успешной аутентификации - account?.let { sendFcmTokenToServer(it) } - - // Reload accounts list - scope.launch { - val accounts = accountManager.getAllAccounts() - accountInfoList = accounts.map { acc -> - val shortKey = acc.publicKey.take(7) - val displayName = acc.name ?: shortKey - val initials = displayName.trim().split(Regex("\\s+")) - .filter { it.isNotEmpty() } - .let { words -> - when { - words.isEmpty() -> "??" - words.size == 1 -> words[0].take(2).uppercase() - else -> "${words[0].first()}${words[1].first()}".uppercase() + isDarkTheme = isDarkTheme, + hasExistingAccount = screen == "auth_unlock", + accounts = accountInfoList, + accountManager = accountManager, + onAuthComplete = { account -> + currentAccount = account + hasExistingAccount = true + // Save as last logged account + account?.let { + accountManager.setLastLoggedPublicKey(it.publicKey) + } + + // 📤 Отправляем FCM токен на сервер после успешной + // аутентификации + account?.let { sendFcmTokenToServer(it) } + + // Reload accounts list + scope.launch { + val accounts = accountManager.getAllAccounts() + accountInfoList = + accounts.map { acc -> + val shortKey = acc.publicKey.take(7) + val displayName = acc.name ?: shortKey + val initials = + displayName + .trim() + .split(Regex("\\s+")) + .filter { + it.isNotEmpty() + } + .let { words -> + when { + words.isEmpty() -> + "??" + words.size == + 1 -> + words[0] + .take( + 2 + ) + .uppercase() + else -> + "${words[0].first()}${words[1].first()}".uppercase() + } + } + AccountInfo( + id = acc.publicKey, + name = displayName, + initials = initials, + publicKey = acc.publicKey + ) } - } - AccountInfo( - id = acc.publicKey, - name = displayName, - initials = initials, - publicKey = acc.publicKey - ) + } + }, + onLogout = { + // Set currentAccount to null immediately to prevent UI + // lag + currentAccount = null + scope.launch { + com.rosetta.messenger.network.ProtocolManager + .disconnect() + accountManager.logout() } } - }, - onLogout = { - // Set currentAccount to null immediately to prevent UI lag - currentAccount = null - scope.launch { - com.rosetta.messenger.network.ProtocolManager.disconnect() - accountManager.logout() - } - } ) } "main" -> { MainScreen( - account = currentAccount, - isDarkTheme = isDarkTheme, - onToggleTheme = { - scope.launch { - preferencesManager.setDarkTheme(!isDarkTheme) + account = currentAccount, + isDarkTheme = isDarkTheme, + onToggleTheme = { + scope.launch { + preferencesManager.setDarkTheme(!isDarkTheme) + } + }, + onLogout = { + // Set currentAccount to null immediately to prevent UI + // lag + currentAccount = null + scope.launch { + com.rosetta.messenger.network.ProtocolManager + .disconnect() + accountManager.logout() + } } - }, - onLogout = { - // Set currentAccount to null immediately to prevent UI lag - currentAccount = null - scope.launch { - com.rosetta.messenger.network.ProtocolManager.disconnect() - accountManager.logout() - } - } ) } } @@ -277,86 +308,107 @@ class MainActivity : ComponentActivity() { } } } - + override fun onResume() { super.onResume() // 🔥 Приложение стало видимым - отключаем уведомления com.rosetta.messenger.push.RosettaFirebaseMessagingService.isAppInForeground = true } - + override fun onPause() { super.onPause() // 🔥 Приложение ушло в background - включаем уведомления com.rosetta.messenger.push.RosettaFirebaseMessagingService.isAppInForeground = false } - - /** - * 🔔 Инициализация Firebase Cloud Messaging - */ + + /** 🔔 Инициализация Firebase Cloud Messaging */ private fun initializeFirebase() { try { + addFcmLog("🔔 Инициализация Firebase...") // Инициализируем Firebase FirebaseApp.initializeApp(this) - + addFcmLog("✅ Firebase инициализирован") + // Получаем FCM токен + addFcmLog("📲 Запрос FCM токена...") FirebaseMessaging.getInstance().token.addOnCompleteListener { task -> if (!task.isSuccessful) { + addFcmLog("❌ Ошибка получения токена: ${task.exception?.message}") return@addOnCompleteListener } - + val token = task.result - - // Сохраняем токен локально - token?.let { saveFcmToken(it) } - + + if (token != null) { + val shortToken = "${token.take(12)}...${token.takeLast(8)}" + addFcmLog("✅ FCM токен получен: $shortToken") + // Сохраняем токен локально + saveFcmToken(token) + addFcmLog("💾 Токен сохранен локально") + } else { + addFcmLog("⚠️ Токен пустой") + } + // Токен будет отправлен на сервер после успешной аутентификации // (см. вызов sendFcmTokenToServer в onAccountLogin) } - } catch (e: Exception) { + addFcmLog("❌ Ошибка Firebase: ${e.message}") } } - - /** - * Сохранить FCM токен в SharedPreferences - */ + + /** Сохранить FCM токен в SharedPreferences */ private fun saveFcmToken(token: String) { val prefs = getSharedPreferences("rosetta_prefs", MODE_PRIVATE) prefs.edit().putString("fcm_token", token).apply() } - + /** - * Отправить FCM токен на сервер - * Вызывается после успешной аутентификации, когда аккаунт уже расшифрован + * Отправить FCM токен на сервер Вызывается после успешной аутентификации, когда аккаунт уже + * расшифрован */ private fun sendFcmTokenToServer(account: DecryptedAccount) { lifecycleScope.launch { try { val prefs = getSharedPreferences("rosetta_prefs", MODE_PRIVATE) val token = prefs.getString("fcm_token", null) - + if (token == null) { + addFcmLog("⚠️ Нет сохраненного токена для отправки") return@launch } - + + val shortToken = "${token.take(12)}...${token.takeLast(8)}" + addFcmLog("📤 Подготовка к отправке токена на сервер") + addFcmLog("⏳ Ожидание аутентификации...") + // 🔥 КРИТИЧНО: Ждем пока протокол станет AUTHENTICATED var waitAttempts = 0 - while (ProtocolManager.state.value != ProtocolState.AUTHENTICATED && waitAttempts < 50) { + while (ProtocolManager.state.value != ProtocolState.AUTHENTICATED && + waitAttempts < 50) { delay(100) // Ждем 100ms waitAttempts++ } - + if (ProtocolManager.state.value != ProtocolState.AUTHENTICATED) { + addFcmLog("❌ Таймаут аутентификации (${waitAttempts * 100}ms)") return@launch } - - val packet = PacketPushNotification().apply { - this.notificationsToken = token - this.action = PushNotificationAction.SUBSCRIBE - } - + + addFcmLog("✅ Аутентификация успешна") + addFcmLog("📨 Отправка токена: $shortToken") + + val packet = + PacketPushNotification().apply { + this.notificationsToken = token + this.action = PushNotificationAction.SUBSCRIBE + } + ProtocolManager.send(packet) + addFcmLog("✅ Пакет отправлен на сервер (ID: 0x10)") + addFcmLog("🎉 FCM токен успешно зарегистрирован!") } catch (e: Exception) { + addFcmLog("❌ Ошибка отправки: ${e.message}") } } } @@ -364,177 +416,162 @@ class MainActivity : ComponentActivity() { @Composable fun MainScreen( - account: DecryptedAccount? = null, - isDarkTheme: Boolean = true, - onToggleTheme: () -> Unit = {}, - onLogout: () -> Unit = {} + account: DecryptedAccount? = null, + isDarkTheme: Boolean = true, + onToggleTheme: () -> Unit = {}, + onLogout: () -> Unit = {} ) { val accountName = account?.name ?: "Account" - 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 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 ?: "" - + // Состояние протокола для передачи в SearchScreen val protocolState by ProtocolManager.state.collectAsState() - + // Навигация между экранами var selectedUser by remember { mutableStateOf(null) } var showSearchScreen by remember { mutableStateOf(false) } - + // 🔥 TELEGRAM-STYLE анимация - чистый slide БЕЗ прозрачности AnimatedContent( - targetState = Triple(selectedUser, showSearchScreen, Unit), - transitionSpec = { - val isEnteringChat = targetState.first != null && initialState.first == null - val isExitingChat = targetState.first == null && initialState.first != null - val isEnteringSearch = targetState.second && !initialState.second - val isExitingSearch = !targetState.second && initialState.second - - when { - // 🚀 Вход в чат - плавный fade - isEnteringChat -> { - fadeIn( - animationSpec = tween(200) - ) togetherWith fadeOut( - animationSpec = tween(150) - ) + targetState = Triple(selectedUser, showSearchScreen, Unit), + transitionSpec = { + val isEnteringChat = targetState.first != null && initialState.first == null + val isExitingChat = targetState.first == null && initialState.first != null + val isEnteringSearch = targetState.second && !initialState.second + val isExitingSearch = !targetState.second && initialState.second + + when { + // 🚀 Вход в чат - плавный fade + isEnteringChat -> { + fadeIn(animationSpec = tween(200)) togetherWith + fadeOut(animationSpec = tween(150)) + } + + // 🔙 Выход из чата - плавный fade + isExitingChat -> { + fadeIn(animationSpec = tween(200)) togetherWith + fadeOut(animationSpec = tween(150)) + } + + // 🔍 Вход в Search - плавный fade + isEnteringSearch -> { + fadeIn(animationSpec = tween(200)) togetherWith + fadeOut(animationSpec = tween(150)) + } + + // 🔙 Выход из Search - плавный fade + isEnteringSearch -> { + fadeIn(animationSpec = tween(200)) togetherWith + fadeOut(animationSpec = tween(150)) + } + + // 🔙 Выход из Search - плавный fade + isExitingSearch -> { + fadeIn(animationSpec = tween(200)) togetherWith + fadeOut(animationSpec = tween(150)) + } + + // Default - мгновенный переход + else -> { + EnterTransition.None togetherWith ExitTransition.None + } } - - // 🔙 Выход из чата - плавный fade - isExitingChat -> { - fadeIn( - animationSpec = tween(200) - ) togetherWith fadeOut( - animationSpec = tween(150) - ) - } - - // 🔍 Вход в Search - плавный fade - isEnteringSearch -> { - fadeIn( - animationSpec = tween(200) - ) togetherWith fadeOut( - animationSpec = tween(150) - ) - } - - // 🔙 Выход из Search - плавный fade - isEnteringSearch -> { - fadeIn( - animationSpec = tween(200) - ) togetherWith fadeOut( - animationSpec = tween(150) - ) - } - - // 🔙 Выход из Search - плавный fade - isExitingSearch -> { - fadeIn( - animationSpec = tween(200) - ) togetherWith fadeOut( - animationSpec = tween(150) - ) - } - - // Default - мгновенный переход - else -> { - EnterTransition.None togetherWith ExitTransition.None - } - } - }, - label = "screenNavigation" + }, + label = "screenNavigation" ) { (currentUser, isSearchOpen, _) -> when { currentUser != null -> { // Экран чата ChatDetailScreen( - user = currentUser, - currentUserPublicKey = accountPublicKey, - currentUserPrivateKey = accountPrivateKey, - isDarkTheme = isDarkTheme, - onBack = { selectedUser = null }, - onNavigateToChat = { publicKey -> - // 📨 Forward: переход в выбранный чат - // Нужно получить SearchUser из публичного ключа - // Используем минимальные данные - остальное подгрузится в ChatDetailScreen - selectedUser = SearchUser( - title = "", - username = "", - publicKey = publicKey, - verified = 0, - online = 0 - ) - } + user = currentUser, + currentUserPublicKey = accountPublicKey, + currentUserPrivateKey = accountPrivateKey, + isDarkTheme = isDarkTheme, + onBack = { selectedUser = null }, + onNavigateToChat = { publicKey -> + // 📨 Forward: переход в выбранный чат + // Нужно получить SearchUser из публичного ключа + // Используем минимальные данные - остальное подгрузится в + // ChatDetailScreen + selectedUser = + SearchUser( + title = "", + username = "", + publicKey = publicKey, + verified = 0, + online = 0 + ) + } ) } isSearchOpen -> { // Экран поиска SearchScreen( - privateKeyHash = privateKeyHash, - currentUserPublicKey = accountPublicKey, - isDarkTheme = isDarkTheme, - protocolState = protocolState, - onBackClick = { showSearchScreen = false }, - onUserSelect = { selectedSearchUser -> - showSearchScreen = false - selectedUser = selectedSearchUser - } + privateKeyHash = privateKeyHash, + currentUserPublicKey = accountPublicKey, + isDarkTheme = isDarkTheme, + protocolState = protocolState, + onBackClick = { showSearchScreen = false }, + onUserSelect = { selectedSearchUser -> + showSearchScreen = false + selectedUser = selectedSearchUser + } ) } else -> { // Список чатов ChatsListScreen( - isDarkTheme = isDarkTheme, - accountName = accountName, - accountPhone = accountPhone, - accountPublicKey = accountPublicKey, - accountPrivateKey = accountPrivateKey, - privateKeyHash = privateKeyHash, - onToggleTheme = onToggleTheme, - onProfileClick = { - // TODO: Navigate to profile - }, - onNewGroupClick = { - // TODO: Navigate to new group - }, - onContactsClick = { - // TODO: Navigate to contacts - }, - onCallsClick = { - // TODO: Navigate to calls - }, - onSavedMessagesClick = { - // Открываем чат с самим собой (Saved Messages) - selectedUser = SearchUser( - title = "Saved Messages", - username = "", - publicKey = accountPublicKey, - verified = 0, - online = 1 - ) - }, - onSettingsClick = { - // TODO: Navigate to settings - }, - onInviteFriendsClick = { - // TODO: Share invite link - }, - onSearchClick = { - showSearchScreen = true - }, - onNewChat = { - // TODO: Show new chat screen - }, - onUserSelect = { selectedChatUser -> - selectedUser = selectedChatUser - }, - onLogout = onLogout + isDarkTheme = isDarkTheme, + accountName = accountName, + accountPhone = accountPhone, + accountPublicKey = accountPublicKey, + accountPrivateKey = accountPrivateKey, + privateKeyHash = privateKeyHash, + onToggleTheme = onToggleTheme, + onProfileClick = { + // TODO: Navigate to profile + }, + onNewGroupClick = { + // TODO: Navigate to new group + }, + onContactsClick = { + // TODO: Navigate to contacts + }, + onCallsClick = { + // TODO: Navigate to calls + }, + onSavedMessagesClick = { + // Открываем чат с самим собой (Saved Messages) + selectedUser = + SearchUser( + title = "Saved Messages", + username = "", + publicKey = accountPublicKey, + verified = 0, + online = 1 + ) + }, + onSettingsClick = { + // TODO: Navigate to settings + }, + onInviteFriendsClick = { + // TODO: Share invite link + }, + onSearchClick = { showSearchScreen = true }, + onNewChat = { + // TODO: Show new chat screen + }, + onUserSelect = { selectedChatUser -> selectedUser = selectedChatUser }, + onLogout = onLogout ) } } } } - diff --git a/app/src/main/java/com/rosetta/messenger/push/RosettaFirebaseMessagingService.kt b/app/src/main/java/com/rosetta/messenger/push/RosettaFirebaseMessagingService.kt index b50eb36..6e449eb 100644 --- a/app/src/main/java/com/rosetta/messenger/push/RosettaFirebaseMessagingService.kt +++ b/app/src/main/java/com/rosetta/messenger/push/RosettaFirebaseMessagingService.kt @@ -6,70 +6,59 @@ import android.app.PendingIntent import android.content.Context import android.content.Intent import android.os.Build -import android.util.Log import androidx.core.app.NotificationCompat import com.google.firebase.messaging.FirebaseMessagingService import com.google.firebase.messaging.RemoteMessage import com.rosetta.messenger.MainActivity import com.rosetta.messenger.R -import com.rosetta.messenger.crypto.CryptoManager -import com.rosetta.messenger.data.AccountManager -import com.rosetta.messenger.network.ProtocolManager import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.launch /** * Firebase Cloud Messaging Service для обработки push-уведомлений - * + * * Обрабатывает: * - Получение нового FCM токена * - Получение push-уведомлений о новых сообщениях * - Отображение уведомлений */ class RosettaFirebaseMessagingService : FirebaseMessagingService() { - + private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) - + companion object { private const val TAG = "RosettaFCM" private const val CHANNEL_ID = "rosetta_messages" private const val CHANNEL_NAME = "Messages" private const val NOTIFICATION_ID = 1 - + // 🔥 Флаг - приложение в foreground (видимо пользователю) - @Volatile - var isAppInForeground = false + @Volatile var isAppInForeground = false } - - /** - * Вызывается когда получен новый FCM токен - * Отправляем его на сервер через протокол - */ + + /** Вызывается когда получен новый FCM токен Отправляем его на сервер через протокол */ override fun onNewToken(token: String) { super.onNewToken(token) - + // Сохраняем токен локально saveFcmToken(token) - + // 📤 Токен будет отправлен на сервер после успешного логина в MainActivity } - - /** - * Вызывается когда получено push-уведомление - */ + + /** Вызывается когда получено push-уведомление */ override fun onMessageReceived(remoteMessage: RemoteMessage) { super.onMessageReceived(remoteMessage) - + // Обрабатываем data payload remoteMessage.data.isNotEmpty().let { - val type = remoteMessage.data["type"] val senderPublicKey = remoteMessage.data["sender_public_key"] - val senderName = remoteMessage.data["sender_name"] ?: senderPublicKey?.take(10) ?: "Unknown" + val senderName = + remoteMessage.data["sender_name"] ?: senderPublicKey?.take(10) ?: "Unknown" val messagePreview = remoteMessage.data["message_preview"] ?: "New message" - + when (type) { "new_message" -> { // Показываем уведомление о новом сообщении @@ -78,112 +67,118 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() { "message_read" -> { // Сообщение прочитано - можно обновить UI если приложение открыто } - else -> { - } + else -> {} } } - + // Обрабатываем notification payload (если есть) remoteMessage.notification?.let { showSimpleNotification(it.title ?: "Rosetta", it.body ?: "New message") } } - - /** - * Показать уведомление о новом сообщении - */ - private fun showMessageNotification(senderPublicKey: String?, senderName: String, messagePreview: String) { + + /** Показать уведомление о новом сообщении */ + private fun showMessageNotification( + senderPublicKey: String?, + senderName: String, + messagePreview: String + ) { // 🔥 Не показываем уведомление если приложение открыто if (isAppInForeground) { return } - + createNotificationChannel() - + // Intent для открытия чата - val intent = Intent(this, MainActivity::class.java).apply { - flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK - putExtra("open_chat", senderPublicKey) - } - - val pendingIntent = PendingIntent.getActivity( - this, - 0, - intent, - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE - ) - - val notification = NotificationCompat.Builder(this, CHANNEL_ID) - .setSmallIcon(R.drawable.ic_launcher_foreground) - .setContentTitle(senderName) - .setContentText(messagePreview) - .setPriority(NotificationCompat.PRIORITY_HIGH) - .setCategory(NotificationCompat.CATEGORY_MESSAGE) - .setAutoCancel(true) - .setContentIntent(pendingIntent) - .build() - - val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + val intent = + Intent(this, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + putExtra("open_chat", senderPublicKey) + } + + val pendingIntent = + PendingIntent.getActivity( + this, + 0, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + + val notification = + NotificationCompat.Builder(this, CHANNEL_ID) + .setSmallIcon(R.drawable.ic_notification) + .setContentTitle(senderName) + .setContentText(messagePreview) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setCategory(NotificationCompat.CATEGORY_MESSAGE) + .setAutoCancel(true) + .setContentIntent(pendingIntent) + .build() + + val notificationManager = + getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager notificationManager.notify(NOTIFICATION_ID, notification) } - - /** - * Показать простое уведомление - */ + + /** Показать простое уведомление */ private fun showSimpleNotification(title: String, body: String) { // 🔥 Не показываем уведомление если приложение открыто if (isAppInForeground) { return } - + createNotificationChannel() - - val intent = Intent(this, MainActivity::class.java).apply { - flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK - } - - val pendingIntent = PendingIntent.getActivity( - this, - 0, - intent, - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE - ) - - val notification = NotificationCompat.Builder(this, CHANNEL_ID) - .setSmallIcon(R.drawable.ic_launcher_foreground) - .setContentTitle(title) - .setContentText(body) - .setPriority(NotificationCompat.PRIORITY_HIGH) - .setAutoCancel(true) - .setContentIntent(pendingIntent) - .build() - - val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + + val intent = + Intent(this, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + } + + val pendingIntent = + PendingIntent.getActivity( + this, + 0, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + + val notification = + NotificationCompat.Builder(this, CHANNEL_ID) + .setSmallIcon(R.drawable.ic_notification) + .setContentTitle(title) + .setContentText(body) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setAutoCancel(true) + .setContentIntent(pendingIntent) + .build() + + val notificationManager = + getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager notificationManager.notify(NOTIFICATION_ID, notification) } - - /** - * Создать notification channel для Android 8+ - */ + + /** Создать notification channel для Android 8+ */ private fun createNotificationChannel() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val channel = NotificationChannel( - CHANNEL_ID, - CHANNEL_NAME, - NotificationManager.IMPORTANCE_HIGH - ).apply { - description = "Notifications for new messages" - enableVibration(true) - } - - val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + val channel = + NotificationChannel( + CHANNEL_ID, + CHANNEL_NAME, + NotificationManager.IMPORTANCE_HIGH + ) + .apply { + description = "Notifications for new messages" + enableVibration(true) + } + + val notificationManager = + getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager notificationManager.createNotificationChannel(channel) } } - - /** - * Сохранить FCM токен в SharedPreferences - */ + + /** Сохранить FCM токен в SharedPreferences */ private fun saveFcmToken(token: String) { val prefs = getSharedPreferences("rosetta_prefs", Context.MODE_PRIVATE) prefs.edit().putString("fcm_token", token).apply() diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt index 59e4559..8b5d65b 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt @@ -94,32 +94,35 @@ data class AvatarColors(val textColor: Color, val backgroundColor: Color) private val avatarColorCache = mutableMapOf() fun getAvatarColor(name: String, isDarkTheme: Boolean): AvatarColors { - val cacheKey = "${name}_${if (isDarkTheme) "dark" else "light"}" - return avatarColorCache.getOrPut(cacheKey) { - val colors = if (isDarkTheme) avatarColorsDark else avatarColorsLight - val index = name.hashCode().mod(colors.size).let { if (it < 0) it + colors.size else it } - val (textColor, bgColor) = colors[index] - AvatarColors(textColor, bgColor) - } + val cacheKey = "${name}_${if (isDarkTheme) "dark" else "light"}" + return avatarColorCache.getOrPut(cacheKey) { + val colors = if (isDarkTheme) avatarColorsDark else avatarColorsLight + val index = + name.hashCode().mod(colors.size).let { + if (it < 0) it + colors.size else it + } + val (textColor, bgColor) = colors[index] + AvatarColors(textColor, bgColor) + } } // Cache для инициалов private val initialsCache = mutableMapOf() fun getInitials(name: String): String { - return initialsCache.getOrPut(name) { - val words = name.trim().split(Regex("\\s+")).filter { it.isNotEmpty() } - when { - words.isEmpty() -> "??" - words.size == 1 -> words[0].take(2).uppercase() - else -> "${words[0].first()}${words[1].first()}".uppercase() + return initialsCache.getOrPut(name) { + val words = name.trim().split(Regex("\\s+")).filter { it.isNotEmpty() } + when { + words.isEmpty() -> "??" + words.size == 1 -> words[0].take(2).uppercase() + else -> "${words[0].first()}${words[1].first()}".uppercase() + } } - } } // Get avatar text from public key (first 2 chars) fun getAvatarText(publicKey: String): String { - return publicKey.take(2).uppercase() + return publicKey.take(2).uppercase() } @OptIn(ExperimentalMaterial3Api::class) @@ -145,561 +148,155 @@ fun ChatsListScreen( chatsViewModel: ChatsListViewModel = androidx.lifecycle.viewmodel.compose.viewModel(), onLogout: () -> Unit ) { - // Theme transition state - var hasInitialized by remember { mutableStateOf(false) } + // Theme transition state + var hasInitialized by remember { mutableStateOf(false) } - LaunchedEffect(Unit) { hasInitialized = true } + LaunchedEffect(Unit) { hasInitialized = true } - val view = androidx.compose.ui.platform.LocalView.current - val context = androidx.compose.ui.platform.LocalContext.current - val focusManager = androidx.compose.ui.platform.LocalFocusManager.current - val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed) - val scope = rememberCoroutineScope() - - // 🔥 ВСЕГДА закрываем клавиатуру при появлении ChatsListScreen - // Используем DisposableEffect чтобы срабатывало при каждом появлении экрана - DisposableEffect(Unit) { - // Закрываем клавиатуру сразу - val imm = context.getSystemService(android.content.Context.INPUT_METHOD_SERVICE) as android.view.inputmethod.InputMethodManager - imm.hideSoftInputFromWindow(view.windowToken, 0) - focusManager.clearFocus() - - onDispose { } - } - - // Дополнительно закрываем клавиатуру с небольшой задержкой (на случай если она появляется после) - LaunchedEffect(Unit) { - kotlinx.coroutines.delay(100) - val imm = context.getSystemService(android.content.Context.INPUT_METHOD_SERVICE) as android.view.inputmethod.InputMethodManager - imm.hideSoftInputFromWindow(view.windowToken, 0) - focusManager.clearFocus() - } + val view = androidx.compose.ui.platform.LocalView.current + val context = androidx.compose.ui.platform.LocalContext.current + val focusManager = androidx.compose.ui.platform.LocalFocusManager.current + val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed) + val scope = rememberCoroutineScope() - // Update status bar and completely hide navigation bar - LaunchedEffect(isDarkTheme) { - if (!view.isInEditMode) { - val window = (view.context as android.app.Activity).window - val insetsController = androidx.core.view.WindowCompat.getInsetsController(window, view) + // 🔥 ВСЕГДА закрываем клавиатуру при появлении ChatsListScreen + // Используем DisposableEffect чтобы срабатывало при каждом появлении экрана + DisposableEffect(Unit) { + // Закрываем клавиатуру сразу + val imm = + context.getSystemService(android.content.Context.INPUT_METHOD_SERVICE) as + android.view.inputmethod.InputMethodManager + imm.hideSoftInputFromWindow(view.windowToken, 0) + focusManager.clearFocus() - // Status bar - insetsController.isAppearanceLightStatusBars = !isDarkTheme - window.statusBarColor = android.graphics.Color.TRANSPARENT - - // Completely hide navigation bar - insetsController.hide(androidx.core.view.WindowInsetsCompat.Type.navigationBars()) - insetsController.systemBarsBehavior = - androidx.core.view.WindowInsetsControllerCompat - .BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + onDispose {} } - } - // Colors - instant change, no animation - 🔥 КЭШИРУЕМ для производительности - val backgroundColor = remember(isDarkTheme) { if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFFFFFFF) } - val drawerBackgroundColor = remember(isDarkTheme) { if (isDarkTheme) Color(0xFF212121) else Color(0xFFFFFFFF) } - val textColor = remember(isDarkTheme) { if (isDarkTheme) Color.White else Color.Black } - val secondaryTextColor = remember(isDarkTheme) { if (isDarkTheme) Color(0xFF8E8E8E) else Color(0xFF8E8E93) } - - // Protocol connection state - val protocolState by ProtocolManager.state.collectAsState() - - // 🔥 Пользователи, которые сейчас печатают - val typingUsers by ProtocolManager.typingUsers.collectAsState() - - // Load dialogs when account is available - LaunchedEffect(accountPublicKey, accountPrivateKey) { - if (accountPublicKey.isNotEmpty() && accountPrivateKey.isNotEmpty()) { - chatsViewModel.setAccount(accountPublicKey, accountPrivateKey) - // Устанавливаем аккаунт для RecentSearchesManager - RecentSearchesManager.setAccount(accountPublicKey) - // 🔥 КРИТИЧНО: Инициализируем MessageRepository для обработки входящих сообщений - ProtocolManager.initializeAccount(accountPublicKey, accountPrivateKey) + // Дополнительно закрываем клавиатуру с небольшой задержкой (на случай если она появляется + // после) + LaunchedEffect(Unit) { + kotlinx.coroutines.delay(100) + val imm = + context.getSystemService(android.content.Context.INPUT_METHOD_SERVICE) as + android.view.inputmethod.InputMethodManager + imm.hideSoftInputFromWindow(view.windowToken, 0) + focusManager.clearFocus() } - } - // Status dialog state - var showStatusDialog by remember { mutableStateOf(false) } - val debugLogs by ProtocolManager.debugLogs.collectAsState() + // Update status bar and completely hide navigation bar + LaunchedEffect(isDarkTheme) { + if (!view.isInEditMode) { + val window = (view.context as android.app.Activity).window + val insetsController = + androidx.core.view.WindowCompat.getInsetsController(window, view) - // 📱 FCM токен диалог - var showFcmDialog by remember { mutableStateOf(false) } + // Status bar + insetsController.isAppearanceLightStatusBars = !isDarkTheme + window.statusBarColor = android.graphics.Color.TRANSPARENT - // 📬 Requests screen state - var showRequestsScreen by remember { mutableStateOf(false) } - - // 🔥 Используем rememberSaveable чтобы сохранить состояние при навигации - // Header сразу visible = true, без анимации при возврате из чата - var visible by rememberSaveable { mutableStateOf(true) } - - // Confirmation dialogs state - var dialogToDelete by remember { mutableStateOf(null) } - var dialogToBlock by remember { mutableStateOf(null) } - var dialogToUnblock by remember { mutableStateOf(null) } - - // Trigger для обновления статуса блокировки - var blocklistUpdateTrigger by remember { mutableStateOf(0) } - - // Dev console dialog - commented out for now - /* - if (showDevConsole) { - AlertDialog( - onDismissRequest = { showDevConsole = false }, - title = { Text("Dev Console", fontWeight = FontWeight.Bold) }, - text = { Text("Dev console temporarily disabled") }, - confirmButton = { - Button(onClick = { showDevConsole = false }) { - Text("Close") + // Completely hide navigation bar + insetsController.hide( + androidx.core.view.WindowInsetsCompat.Type.navigationBars() + ) + insetsController.systemBarsBehavior = + androidx.core.view.WindowInsetsControllerCompat + .BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE } - }, - containerColor = if (isDarkTheme) Color(0xFF212121) else Color.White - ) - } - */ + } - // Status dialog - if (showStatusDialog) { - AlertDialog( - onDismissRequest = { showStatusDialog = false }, - title = { - Text( - "Connection Status", - fontWeight = FontWeight.Bold, - color = textColor - ) - }, - text = { - Column(modifier = Modifier.fillMaxWidth()) { - // Status indicator - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth().padding(bottom = 12.dp) - ) { - Box( - modifier = - Modifier.size(12.dp) - .clip(CircleShape) - .background( - when (protocolState) { - ProtocolState.AUTHENTICATED -> - Color(0xFF4CAF50) - ProtocolState.CONNECTING, - ProtocolState.CONNECTED, - ProtocolState.HANDSHAKING -> - Color(0xFFFFC107) - ProtocolState.DISCONNECTED -> - Color(0xFFF44336) - } - ) - ) - Spacer(modifier = Modifier.width(12.dp)) - Text( - text = - when (protocolState) { - ProtocolState.DISCONNECTED -> "Disconnected" - ProtocolState.CONNECTING -> "Connecting..." - ProtocolState.CONNECTED -> "Connected" - ProtocolState.HANDSHAKING -> "Authenticating..." - ProtocolState.AUTHENTICATED -> "Authenticated" - }, - fontSize = 16.sp, - fontWeight = FontWeight.Medium, - color = textColor - ) - } - } - }, + // Colors - instant change, no animation - 🔥 КЭШИРУЕМ для производительности + val backgroundColor = + remember(isDarkTheme) { if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFFFFFFF) } + val drawerBackgroundColor = + remember(isDarkTheme) { if (isDarkTheme) Color(0xFF212121) else Color(0xFFFFFFFF) } + val textColor = remember(isDarkTheme) { if (isDarkTheme) Color.White else Color.Black } + val secondaryTextColor = + remember(isDarkTheme) { if (isDarkTheme) Color(0xFF8E8E8E) else Color(0xFF8E8E93) } + + // Protocol connection state + val protocolState by ProtocolManager.state.collectAsState() + + // 🔥 Пользователи, которые сейчас печатают + val typingUsers by ProtocolManager.typingUsers.collectAsState() + + // Load dialogs when account is available + LaunchedEffect(accountPublicKey, accountPrivateKey) { + if (accountPublicKey.isNotEmpty() && accountPrivateKey.isNotEmpty()) { + chatsViewModel.setAccount(accountPublicKey, accountPrivateKey) + // Устанавливаем аккаунт для RecentSearchesManager + RecentSearchesManager.setAccount(accountPublicKey) + // 🔥 КРИТИЧНО: Инициализируем MessageRepository для обработки входящих + // сообщений + ProtocolManager.initializeAccount(accountPublicKey, accountPrivateKey) + } + } + + // Status dialog state + var showStatusDialog by remember { mutableStateOf(false) } + val debugLogs by ProtocolManager.debugLogs.collectAsState() + + // 📱 FCM токен диалог + var showFcmDialog by remember { mutableStateOf(false) } + + // 📬 Requests screen state + var showRequestsScreen by remember { mutableStateOf(false) } + + // 🔥 Используем rememberSaveable чтобы сохранить состояние при навигации + // Header сразу visible = true, без анимации при возврате из чата + var visible by rememberSaveable { mutableStateOf(true) } + + // Confirmation dialogs state + var dialogToDelete by remember { mutableStateOf(null) } + var dialogToBlock by remember { mutableStateOf(null) } + var dialogToUnblock by remember { mutableStateOf(null) } + + // Trigger для обновления статуса блокировки + var blocklistUpdateTrigger by remember { mutableStateOf(0) } + + // Dev console dialog - commented out for now + /* + if (showDevConsole) { + AlertDialog( + onDismissRequest = { showDevConsole = false }, + title = { Text("Dev Console", fontWeight = FontWeight.Bold) }, + text = { Text("Dev console temporarily disabled") }, confirmButton = { - Button( - onClick = { showStatusDialog = false }, - colors = ButtonDefaults.buttonColors(containerColor = PrimaryBlue) - ) { Text("Close", color = Color.White) } + Button(onClick = { showDevConsole = false }) { + Text("Close") + } }, containerColor = if (isDarkTheme) Color(0xFF212121) else Color.White - ) - } + ) + } + */ - // Simple background - Box(modifier = Modifier.fillMaxSize().background(backgroundColor).navigationBarsPadding()) { - ModalNavigationDrawer( - drawerState = drawerState, - drawerContent = { - ModalDrawerSheet( - drawerContainerColor = Color.Transparent, - windowInsets = - WindowInsets( - 0 - ), // 🎨 Убираем системные отступы - drawer идет до верха - modifier = Modifier.width(300.dp) - ) { - Column( - modifier = Modifier.fillMaxSize().background(drawerBackgroundColor) - ) { - // ═══════════════════════════════════════════════════════════ - // 🎨 DRAWER HEADER - Avatar and status - // ═══════════════════════════════════════════════════════════ - val headerColor = - if (isDarkTheme) { - Color(0xFF2C5282) - } else { - Color(0xFF4A90D9) - } - - Box( - modifier = - Modifier.fillMaxWidth() - .background(color = headerColor) - .statusBarsPadding() // 🎨 Контент начинается - // после status bar - .padding( - top = 16.dp, - start = 20.dp, - end = 20.dp, - bottom = 20.dp - ) - ) { - Column { - // Avatar with border - val avatarColors = getAvatarColor(accountPublicKey, isDarkTheme) - Box( - modifier = - Modifier.size(72.dp) - .clip(CircleShape) - .background( - Color.White.copy(alpha = 0.2f) - ) - .padding(3.dp) - .clip(CircleShape) - .background( - avatarColors.backgroundColor - ), - contentAlignment = Alignment.Center - ) { - Text( - text = getAvatarText(accountPublicKey), - fontSize = 26.sp, - fontWeight = FontWeight.Bold, - color = avatarColors.textColor - ) - } - - Spacer(modifier = Modifier.height(14.dp)) - - // Public key (username style) - clickable для копирования - val truncatedKey = - if (accountPublicKey.length > 16) { - "${accountPublicKey.take(8)}...${accountPublicKey.takeLast(6)}" - } else accountPublicKey - - val context = androidx.compose.ui.platform.LocalContext.current - var showCopiedToast by remember { mutableStateOf(false) } - - // Плавная замена текста - ускоренная анимация - AnimatedContent( - targetState = showCopiedToast, - transitionSpec = { - fadeIn(animationSpec = tween(150)) togetherWith - fadeOut(animationSpec = tween(150)) - }, - label = "copiedAnimation" - ) { isCopied -> - Text( - text = if (isCopied) "Copied!" else truncatedKey, - fontSize = 16.sp, - fontWeight = FontWeight.SemiBold, - color = Color.White, - fontStyle = - if (isCopied) - androidx.compose.ui.text.font - .FontStyle.Italic - else - androidx.compose.ui.text.font - .FontStyle.Normal, + // Status dialog + if (showStatusDialog) { + AlertDialog( + onDismissRequest = { showStatusDialog = false }, + title = { + Text( + "Connection Status", + fontWeight = FontWeight.Bold, + color = textColor + ) + }, + text = { + Column(modifier = Modifier.fillMaxWidth()) { + // Status indicator + Row( + verticalAlignment = Alignment.CenterVertically, modifier = - Modifier.clickable { - if (!showCopiedToast) { - // Копируем публичный ключ - val clipboard = - context.getSystemService( - android.content - .Context - .CLIPBOARD_SERVICE - ) as - android.content.ClipboardManager - val clip = - android.content.ClipData - .newPlainText( - "Public Key", - accountPublicKey - ) - clipboard.setPrimaryClip(clip) - showCopiedToast = true - } - } - ) - } - - // Автоматически возвращаем обратно через 1.5 секунды - if (showCopiedToast) { - LaunchedEffect(Unit) { - kotlinx.coroutines.delay(1500) - showCopiedToast = false - } - } - - Spacer(modifier = Modifier.height(6.dp)) - - // Username display - if (accountName.isNotEmpty()) { - Text( - text = "@$accountName", - fontSize = 13.sp, - color = Color.White.copy(alpha = 0.85f) - ) - } - } - } - - // ═══════════════════════════════════════════════════════════ - // 📱 MENU ITEMS - // ═══════════════════════════════════════════════════════════ - Column( - modifier = - Modifier.fillMaxWidth() - .weight(1f) - .verticalScroll(rememberScrollState()) - .padding(vertical = 8.dp) - ) { - val menuIconColor = textColor.copy(alpha = 0.6f) - - // 👤 Profile Section - DrawerMenuItemEnhanced( - icon = Icons.Outlined.Person, - text = "My Profile", - iconColor = menuIconColor, - textColor = textColor, - onClick = { - scope.launch { drawerState.close() } - onProfileClick() - } - ) - - // 📖 Saved Messages - DrawerMenuItemEnhanced( - icon = Icons.Outlined.Bookmark, - text = "Saved Messages", - iconColor = menuIconColor, - textColor = textColor, - onClick = { - scope.launch { - drawerState.close() - // Ждём завершения анимации закрытия drawer - kotlinx.coroutines.delay(250) - onSavedMessagesClick() - } - } - ) - - DrawerDivider(isDarkTheme) - - // 👥 Contacts - DrawerMenuItemEnhanced( - icon = Icons.Outlined.Contacts, - text = "Contacts", - iconColor = menuIconColor, - textColor = textColor, - onClick = { - scope.launch { drawerState.close() } - onContactsClick() - } - ) - - // 📞 Calls - DrawerMenuItemEnhanced( - icon = Icons.Outlined.Call, - text = "Calls", - iconColor = menuIconColor, - textColor = textColor, - onClick = { - scope.launch { drawerState.close() } - onCallsClick() - } - ) - - // ➕ Invite Friends - DrawerMenuItemEnhanced( - icon = Icons.Outlined.PersonAdd, - text = "Invite Friends", - iconColor = menuIconColor, - textColor = textColor, - onClick = { - scope.launch { drawerState.close() } - onInviteFriendsClick() - } - ) - - DrawerDivider(isDarkTheme) - - // ⚙️ Settings - DrawerMenuItemEnhanced( - icon = Icons.Outlined.Settings, - text = "Settings", - iconColor = menuIconColor, - textColor = textColor, - onClick = { - scope.launch { drawerState.close() } - onSettingsClick() - } - ) - - // 🌓 Theme Toggle - DrawerMenuItemEnhanced( - icon = - if (isDarkTheme) Icons.Outlined.LightMode - else Icons.Outlined.DarkMode, - text = if (isDarkTheme) "Light Mode" else "Dark Mode", - iconColor = menuIconColor, - textColor = textColor, - onClick = { onToggleTheme() } - ) - - // ❓ Help - DrawerMenuItemEnhanced( - icon = Icons.Outlined.HelpOutline, - text = "Help & FAQ", - iconColor = menuIconColor, - textColor = textColor, - onClick = { - scope.launch { drawerState.close() } - // TODO: Add help screen navigation - } - ) - } - - // ═══════════════════════════════════════════════════════════ - // 🚪 FOOTER - Logout & Version - // ═══════════════════════════════════════════════════════════ - Column(modifier = Modifier.fillMaxWidth()) { - Divider( - color = - if (isDarkTheme) Color(0xFF2A2A2A) - else Color(0xFFE8E8E8), - thickness = 0.5.dp - ) - - // Logout - DrawerMenuItemEnhanced( - icon = Icons.Outlined.Logout, - text = "Log Out", - iconColor = Color(0xFFFF4444), - textColor = Color(0xFFFF4444), - onClick = { - scope.launch { - drawerState.close() - kotlinx.coroutines.delay(150) - onLogout() - } - } - ) - - // Version info - Box( - modifier = - Modifier.fillMaxWidth() - .padding( - horizontal = 20.dp, - vertical = 12.dp - ), - contentAlignment = Alignment.CenterStart - ) { - Text( - text = "Rosetta v1.0.0", - fontSize = 12.sp, - color = - if (isDarkTheme) Color(0xFF666666) - else Color(0xFF999999) - ) - } - - Spacer(modifier = Modifier.height(16.dp)) - } - } - } - } - ) { - Scaffold( - topBar = { - AnimatedVisibility( - visible = visible, - enter = - fadeIn(tween(300)) + - expandVertically( - animationSpec = - tween( - 300, - easing = FastOutSlowInEasing - ) - ), - exit = - fadeOut(tween(200)) + - shrinkVertically(animationSpec = tween(200)) - ) { - key(isDarkTheme, showRequestsScreen) { - TopAppBar( - navigationIcon = { - if (showRequestsScreen) { - // Back button for Requests - IconButton( - onClick = { showRequestsScreen = false } - ) { - Icon( - Icons.Default.ArrowBack, - contentDescription = "Back", - tint = PrimaryBlue - ) - } - } else { - // Menu button for main screen - IconButton( - onClick = { - scope.launch { drawerState.open() } - } - ) { - Icon( - Icons.Default.Menu, - contentDescription = "Menu", - tint = textColor.copy(alpha = 0.6f) - ) - } - } - }, - title = { - if (showRequestsScreen) { - // Requests title - Text( - "Requests", - fontWeight = FontWeight.Bold, - fontSize = 20.sp, - color = textColor - ) - } else { - // Rosetta title with status - Row( - verticalAlignment = - Alignment.CenterVertically - ) { - Text( - "Rosetta", - fontWeight = FontWeight.Bold, - fontSize = 20.sp, - color = textColor - ) - Spacer(modifier = Modifier.width(8.dp)) - Box( - modifier = - Modifier.size(10.dp) - .clip(CircleShape) - .background( - when (protocolState - ) { + Modifier.fillMaxWidth() + .padding(bottom = 12.dp) + ) { + Box( + modifier = + Modifier.size(12.dp) + .clip(CircleShape) + .background( + when (protocolState + ) { ProtocolState .AUTHENTICATED -> Color( @@ -719,491 +316,1333 @@ fun ChatsListScreen( Color( 0xFFF44336 ) - } - ) - .clickable { - showStatusDialog = - true - } - ) - } - } - }, - actions = { - // Search only on main screen - if (!showRequestsScreen) { - IconButton( - onClick = { - if (protocolState == - ProtocolState - .AUTHENTICATED - ) { - onSearchClick() - } - }, - enabled = - protocolState == - ProtocolState.AUTHENTICATED - ) { - Icon( - Icons.Default.Search, - contentDescription = "Search", - tint = - if (protocolState == - ProtocolState - .AUTHENTICATED - ) - textColor.copy( - alpha = 0.6f - ) - else - textColor.copy( - alpha = 0.5f - ) - ) - } - } - }, + } + ) + ) + Spacer(modifier = Modifier.width(12.dp)) + Text( + text = + when (protocolState) { + ProtocolState + .DISCONNECTED -> + "Disconnected" + ProtocolState.CONNECTING -> + "Connecting..." + ProtocolState.CONNECTED -> + "Connected" + ProtocolState.HANDSHAKING -> + "Authenticating..." + ProtocolState + .AUTHENTICATED -> + "Authenticated" + }, + fontSize = 16.sp, + fontWeight = FontWeight.Medium, + color = textColor + ) + } + } + }, + confirmButton = { + Button( + onClick = { showStatusDialog = false }, colors = - TopAppBarDefaults.topAppBarColors( - containerColor = backgroundColor, - scrolledContainerColor = backgroundColor, - navigationIconContentColor = textColor, - titleContentColor = textColor, - actionIconContentColor = textColor + ButtonDefaults.buttonColors( + containerColor = PrimaryBlue ) - ) - } - } - }, - floatingActionButton = { - AnimatedVisibility( - visible = false, // Hidden for now - enter = - fadeIn(tween(500, delayMillis = 300)) + - scaleIn( - initialScale = 0.5f, - animationSpec = - tween(500, delayMillis = 300) - ) - ) { - FloatingActionButton( - onClick = onNewChat, - containerColor = PrimaryBlue, - contentColor = Color.White, - shape = CircleShape - ) { Icon(Icons.Default.Edit, contentDescription = "New Chat") } - } - }, - containerColor = backgroundColor - ) { paddingValues -> - // Main content - Box(modifier = Modifier.fillMaxSize().padding(paddingValues)) { - // � Используем комбинированное состояние для атомарного обновления - // Это предотвращает "дергание" UI когда dialogs и requests обновляются - // независимо - val chatsState by chatsViewModel.chatsState.collectAsState() - val requests = chatsState.requests - val requestsCount = chatsState.requestsCount + ) { Text("Close", color = Color.White) } + }, + containerColor = if (isDarkTheme) Color(0xFF212121) else Color.White + ) + } - // 🔥 ИСПРАВЛЕНИЕ МЕРЦАНИЯ: Запоминаем, что контент УЖЕ был показан - // Это предотвращает показ EmptyState при временных пустых обновлениях - var hasShownContent by rememberSaveable { mutableStateOf(false) } - if (chatsState.hasContent) { - hasShownContent = true - } - - // 🎯 Показываем Empty State только если контент НИКОГДА не показывался - val shouldShowEmptyState = chatsState.isEmpty && !hasShownContent - - // 🎬 Animated content transition between main list and requests - AnimatedContent( - targetState = showRequestsScreen, - transitionSpec = { - fadeIn(animationSpec = tween(200)) togetherWith - fadeOut(animationSpec = tween(150)) - }, - label = "RequestsTransition" - ) { isRequestsScreen -> - if (isRequestsScreen) { - // 📬 Show Requests Screen - RequestsScreen( - requests = requests, - isDarkTheme = isDarkTheme, - onBack = { showRequestsScreen = false }, - onRequestClick = { request -> - showRequestsScreen = false - val user = chatsViewModel.dialogToSearchUser(request) - onUserSelect(user) - } - ) - } else if (shouldShowEmptyState) { - // 🔥 Empty state - показываем только если контент НЕ был показан ранее - EmptyChatsState( - isDarkTheme = isDarkTheme, - modifier = Modifier.fillMaxSize() - ) - } else { - // Show dialogs list - val dividerColor = - if (isDarkTheme) Color(0xFF3A3A3A) else Color(0xFFE8E8E8) - // 🔥 Берем dialogs из chatsState для консистентности - val currentDialogs = chatsState.dialogs - - LazyColumn(modifier = Modifier.fillMaxSize()) { - // 📬 Requests Section - if (requestsCount > 0) { - item(key = "requests_section") { - RequestsSection( - count = requestsCount, - isDarkTheme = isDarkTheme, - onClick = { showRequestsScreen = true } + // FCM Logs dialog + if (showFcmDialog) { + val fcmLogs = com.rosetta.messenger.MainActivity.fcmLogs + AlertDialog( + onDismissRequest = { showFcmDialog = false }, + title = { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + "🔔 FCM Token Logs", + fontWeight = FontWeight.Bold, + color = textColor ) - Divider(color = dividerColor, thickness = 0.5.dp) - } + if (fcmLogs.isNotEmpty()) { + TextButton( + onClick = { + com.rosetta.messenger.MainActivity + .clearFcmLogs() + }, + contentPadding = + PaddingValues(horizontal = 8.dp) + ) { + Text( + "Clear", + fontSize = 13.sp, + color = PrimaryBlue + ) + } + } } + }, + text = { + Column(modifier = Modifier.fillMaxWidth().heightIn(max = 400.dp)) { + if (fcmLogs.isEmpty()) { + Text( + text = + "No FCM logs yet...\n\nLogs will appear here when Firebase initializes and FCM token is sent to the server.", + fontSize = 14.sp, + color = secondaryTextColor, + textAlign = TextAlign.Center, + modifier = + Modifier.padding(vertical = 20.dp) + ) + } else { + LazyColumn { + items(fcmLogs.size) { index -> + val log = fcmLogs[index] + Text( + text = log, + fontSize = 12.sp, + color = textColor, + fontFamily = + FontFamily + .Monospace, + lineHeight = 18.sp, + modifier = + Modifier.padding( + vertical = + 2.dp + ) + ) + } + } + } + } + }, + confirmButton = { + Button( + onClick = { showFcmDialog = false }, + colors = + ButtonDefaults.buttonColors( + containerColor = PrimaryBlue + ) + ) { Text("Close", color = Color.White) } + }, + containerColor = if (isDarkTheme) Color(0xFF212121) else Color.White + ) + } - items(currentDialogs, key = { it.opponentKey }) { dialog -> - val isSavedMessages = dialog.opponentKey == accountPublicKey - // Check if user is blocked - var isBlocked by remember { mutableStateOf(false) } - LaunchedEffect(dialog.opponentKey, blocklistUpdateTrigger) { - isBlocked = chatsViewModel.isUserBlocked(dialog.opponentKey) - } + // Simple background + Box(modifier = Modifier.fillMaxSize().background(backgroundColor).navigationBarsPadding()) { + ModalNavigationDrawer( + drawerState = drawerState, + drawerContent = { + ModalDrawerSheet( + drawerContainerColor = Color.Transparent, + windowInsets = + WindowInsets( + 0 + ), // 🎨 Убираем системные отступы - drawer идет до + // верха + modifier = Modifier.width(300.dp) + ) { + Column( + modifier = + Modifier.fillMaxSize() + .background(drawerBackgroundColor) + ) { + // ═══════════════════════════════════════════════════════════ + // 🎨 DRAWER HEADER - Avatar and status + // ═══════════════════════════════════════════════════════════ + val headerColor = + if (isDarkTheme) { + Color(0xFF2C5282) + } else { + Color(0xFF4A90D9) + } - Column { - SwipeableDialogItem( - dialog = dialog, - isDarkTheme = isDarkTheme, - isTyping = typingUsers.contains(dialog.opponentKey), - isBlocked = isBlocked, - isSavedMessages = isSavedMessages, - onClick = { - val user = - chatsViewModel.dialogToSearchUser( - dialog - ) - onUserSelect(user) + Box( + modifier = + Modifier.fillMaxWidth() + .background( + color = headerColor + ) + .statusBarsPadding() // 🎨 + // Контент начинается + // после status bar + .padding( + top = 16.dp, + start = 20.dp, + end = 20.dp, + bottom = 20.dp + ) + ) { + Column { + // Avatar with border + val avatarColors = + getAvatarColor( + accountPublicKey, + isDarkTheme + ) + Box( + modifier = + Modifier.size(72.dp) + .clip( + CircleShape + ) + .background( + Color.White + .copy( + alpha = + 0.2f + ) + ) + .padding( + 3.dp + ) + .clip( + CircleShape + ) + .background( + avatarColors + .backgroundColor + ), + contentAlignment = + Alignment.Center + ) { + Text( + text = + getAvatarText( + accountPublicKey + ), + fontSize = 26.sp, + fontWeight = + FontWeight + .Bold, + color = + avatarColors + .textColor + ) + } + + Spacer( + modifier = + Modifier.height( + 14.dp + ) + ) + + // Public key (username style) - + // clickable для копирования + val truncatedKey = + if (accountPublicKey + .length > 16 + ) { + "${accountPublicKey.take(8)}...${accountPublicKey.takeLast(6)}" + } else accountPublicKey + + val context = + androidx.compose.ui.platform + .LocalContext + .current + var showCopiedToast by remember { + mutableStateOf(false) + } + + // Плавная замена текста - + // ускоренная анимация + AnimatedContent( + targetState = + showCopiedToast, + transitionSpec = { + fadeIn( + animationSpec = + tween( + 150 + ) + ) togetherWith + fadeOut( + animationSpec = + tween( + 150 + ) + ) + }, + label = "copiedAnimation" + ) { isCopied -> + Text( + text = + if (isCopied + ) + "Copied!" + else + truncatedKey, + fontSize = 16.sp, + fontWeight = + FontWeight + .SemiBold, + color = Color.White, + fontStyle = + if (isCopied + ) + androidx.compose + .ui + .text + .font + .FontStyle + .Italic + else + androidx.compose + .ui + .text + .font + .FontStyle + .Normal, + modifier = + Modifier + .clickable { + if (!showCopiedToast + ) { + // Копируем публичный ключ + val clipboard = + context.getSystemService( + android.content + .Context + .CLIPBOARD_SERVICE + ) as + android.content.ClipboardManager + val clip = + android.content + .ClipData + .newPlainText( + "Public Key", + accountPublicKey + ) + clipboard + .setPrimaryClip( + clip + ) + showCopiedToast = + true + } + } + ) + } + + // Автоматически возвращаем обратно + // через 1.5 секунды + if (showCopiedToast) { + LaunchedEffect(Unit) { + kotlinx.coroutines + .delay(1500) + showCopiedToast = + false + } + } + + Spacer( + modifier = + Modifier.height( + 6.dp + ) + ) + + // Username display + if (accountName.isNotEmpty()) { + Text( + text = + "@$accountName", + fontSize = 13.sp, + color = + Color.White + .copy( + alpha = + 0.85f + ) + ) + } + } + } + + // ═══════════════════════════════════════════════════════════ + // 📱 MENU ITEMS + // ═══════════════════════════════════════════════════════════ + Column( + modifier = + Modifier.fillMaxWidth() + .weight(1f) + .verticalScroll( + rememberScrollState() + ) + .padding(vertical = 8.dp) + ) { + val menuIconColor = + textColor.copy(alpha = 0.6f) + + // 👤 Profile Section + DrawerMenuItemEnhanced( + icon = Icons.Outlined.Person, + text = "My Profile", + iconColor = menuIconColor, + textColor = textColor, + onClick = { + scope.launch { + drawerState.close() + } + onProfileClick() + } + ) + + // 📖 Saved Messages + DrawerMenuItemEnhanced( + icon = Icons.Outlined.Bookmark, + text = "Saved Messages", + iconColor = menuIconColor, + textColor = textColor, + onClick = { + scope.launch { + drawerState.close() + // Ждём завершения + // анимации закрытия + // drawer + kotlinx.coroutines + .delay(250) + onSavedMessagesClick() + } + } + ) + + DrawerDivider(isDarkTheme) + + // 👥 Contacts + DrawerMenuItemEnhanced( + icon = Icons.Outlined.Contacts, + text = "Contacts", + iconColor = menuIconColor, + textColor = textColor, + onClick = { + scope.launch { + drawerState.close() + } + onContactsClick() + } + ) + + // 📞 Calls + DrawerMenuItemEnhanced( + icon = Icons.Outlined.Call, + text = "Calls", + iconColor = menuIconColor, + textColor = textColor, + onClick = { + scope.launch { + drawerState.close() + } + onCallsClick() + } + ) + + // ➕ Invite Friends + DrawerMenuItemEnhanced( + icon = Icons.Outlined.PersonAdd, + text = "Invite Friends", + iconColor = menuIconColor, + textColor = textColor, + onClick = { + scope.launch { + drawerState.close() + } + onInviteFriendsClick() + } + ) + + DrawerDivider(isDarkTheme) + + // 🔔 FCM Logs + DrawerMenuItemEnhanced( + icon = Icons.Outlined.Notifications, + text = "FCM Token Logs", + iconColor = menuIconColor, + textColor = textColor, + onClick = { + scope.launch { + drawerState.close() + } + showFcmDialog = true + } + ) + + // ⚙️ Settings + DrawerMenuItemEnhanced( + icon = Icons.Outlined.Settings, + text = "Settings", + iconColor = menuIconColor, + textColor = textColor, + onClick = { + scope.launch { + drawerState.close() + } + onSettingsClick() + } + ) + + // 🌓 Theme Toggle + DrawerMenuItemEnhanced( + icon = + if (isDarkTheme) + Icons.Outlined + .LightMode + else + Icons.Outlined + .DarkMode, + text = + if (isDarkTheme) + "Light Mode" + else "Dark Mode", + iconColor = menuIconColor, + textColor = textColor, + onClick = { onToggleTheme() } + ) + + // ❓ Help + DrawerMenuItemEnhanced( + icon = Icons.Outlined.HelpOutline, + text = "Help & FAQ", + iconColor = menuIconColor, + textColor = textColor, + onClick = { + scope.launch { + drawerState.close() + } + // TODO: Add help screen + // navigation + } + ) + } + + // ═══════════════════════════════════════════════════════════ + // 🚪 FOOTER - Logout & Version + // ═══════════════════════════════════════════════════════════ + Column(modifier = Modifier.fillMaxWidth()) { + Divider( + color = + if (isDarkTheme) + Color(0xFF2A2A2A) + else Color(0xFFE8E8E8), + thickness = 0.5.dp + ) + + // Logout + DrawerMenuItemEnhanced( + icon = Icons.Outlined.Logout, + text = "Log Out", + iconColor = Color(0xFFFF4444), + textColor = Color(0xFFFF4444), + onClick = { + scope.launch { + drawerState.close() + kotlinx.coroutines + .delay(150) + onLogout() + } + } + ) + + // Version info + Box( + modifier = + Modifier.fillMaxWidth() + .padding( + horizontal = + 20.dp, + vertical = + 12.dp + ), + contentAlignment = + Alignment.CenterStart + ) { + Text( + text = "Rosetta v1.0.0", + fontSize = 12.sp, + color = + if (isDarkTheme) + Color( + 0xFF666666 + ) + else + Color( + 0xFF999999 + ) + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + } + } + } + } + ) { + Scaffold( + topBar = { + AnimatedVisibility( + visible = visible, + enter = + fadeIn(tween(300)) + + expandVertically( + animationSpec = + tween( + 300, + easing = + FastOutSlowInEasing + ) + ), + exit = + fadeOut(tween(200)) + + shrinkVertically( + animationSpec = tween(200) + ) + ) { + key(isDarkTheme, showRequestsScreen) { + TopAppBar( + navigationIcon = { + if (showRequestsScreen) { + // Back button for + // Requests + IconButton( + onClick = { + showRequestsScreen = + false + } + ) { + Icon( + Icons.Default + .ArrowBack, + contentDescription = + "Back", + tint = + PrimaryBlue + ) + } + } else { + // Menu button for + // main screen + IconButton( + onClick = { + scope + .launch { + drawerState + .open() + } + } + ) { + Icon( + Icons.Default + .Menu, + contentDescription = + "Menu", + tint = + textColor + .copy( + alpha = + 0.6f + ) + ) + } + } + }, + title = { + if (showRequestsScreen) { + // Requests title + Text( + "Requests", + fontWeight = + FontWeight + .Bold, + fontSize = + 20.sp, + color = + textColor + ) + } else { + // Rosetta title + // with status + Row( + verticalAlignment = + Alignment + .CenterVertically + ) { + Text( + "Rosetta", + fontWeight = + FontWeight + .Bold, + fontSize = + 20.sp, + color = + textColor + ) + Spacer( + modifier = + Modifier.width( + 8.dp + ) + ) + Box( + modifier = + Modifier.size( + 10.dp + ) + .clip( + CircleShape + ) + .background( + when (protocolState + ) { + ProtocolState + .AUTHENTICATED -> + Color( + 0xFF4CAF50 + ) + ProtocolState + .CONNECTING, + ProtocolState + .CONNECTED, + ProtocolState + .HANDSHAKING -> + Color( + 0xFFFFC107 + ) + ProtocolState + .DISCONNECTED -> + Color( + 0xFFF44336 + ) + } + ) + .clickable { + showStatusDialog = + true + } + ) + } + } + }, + actions = { + // Search only on main + // screen + if (!showRequestsScreen) { + IconButton( + onClick = { + if (protocolState == + ProtocolState + .AUTHENTICATED + ) { + onSearchClick() + } + }, + enabled = + protocolState == + ProtocolState + .AUTHENTICATED + ) { + Icon( + Icons.Default + .Search, + contentDescription = + "Search", + tint = + if (protocolState == + ProtocolState + .AUTHENTICATED + ) + textColor + .copy( + alpha = + 0.6f + ) + else + textColor + .copy( + alpha = + 0.5f + ) + ) + } + } + }, + colors = + TopAppBarDefaults + .topAppBarColors( + containerColor = + backgroundColor, + scrolledContainerColor = + backgroundColor, + navigationIconContentColor = + textColor, + titleContentColor = + textColor, + actionIconContentColor = + textColor + ) + ) + } + } + }, + floatingActionButton = { + AnimatedVisibility( + visible = false, // Hidden for now + enter = + fadeIn(tween(500, delayMillis = 300)) + + scaleIn( + initialScale = 0.5f, + animationSpec = + tween( + 500, + delayMillis = + 300 + ) + ) + ) { + FloatingActionButton( + onClick = onNewChat, + containerColor = PrimaryBlue, + contentColor = Color.White, + shape = CircleShape + ) { + Icon( + Icons.Default.Edit, + contentDescription = "New Chat" + ) + } + } + }, + containerColor = backgroundColor + ) { paddingValues -> + // Main content + Box(modifier = Modifier.fillMaxSize().padding(paddingValues)) { + // � Используем комбинированное состояние для атомарного + // обновления + // Это предотвращает "дергание" UI когда dialogs и requests + // обновляются + // независимо + val chatsState by chatsViewModel.chatsState.collectAsState() + val requests = chatsState.requests + val requestsCount = chatsState.requestsCount + + // 🔥 ИСПРАВЛЕНИЕ МЕРЦАНИЯ: Запоминаем, что контент УЖЕ был + // показан + // Это предотвращает показ EmptyState при временных пустых + // обновлениях + var hasShownContent by rememberSaveable { + mutableStateOf(false) + } + if (chatsState.hasContent) { + hasShownContent = true + } + + // 🎯 Показываем Empty State только если контент НИКОГДА не + // показывался + val shouldShowEmptyState = + chatsState.isEmpty && !hasShownContent + + // 🎬 Animated content transition between main list and + // requests + AnimatedContent( + targetState = showRequestsScreen, + transitionSpec = { + fadeIn( + animationSpec = tween(200) + ) togetherWith + fadeOut(animationSpec = tween(150)) }, - onDelete = { dialogToDelete = dialog }, - onBlock = { dialogToBlock = dialog }, - onUnblock = { dialogToUnblock = dialog } - ) + label = "RequestsTransition" + ) { isRequestsScreen -> + if (isRequestsScreen) { + // 📬 Show Requests Screen + RequestsScreen( + requests = requests, + isDarkTheme = isDarkTheme, + onBack = { + showRequestsScreen = false + }, + onRequestClick = { request -> + showRequestsScreen = false + val user = + chatsViewModel + .dialogToSearchUser( + request + ) + onUserSelect(user) + } + ) + } else if (shouldShowEmptyState) { + // 🔥 Empty state - показываем только если + // контент НЕ был показан ранее + EmptyChatsState( + isDarkTheme = isDarkTheme, + modifier = Modifier.fillMaxSize() + ) + } else { + // Show dialogs list + val dividerColor = + if (isDarkTheme) Color(0xFF3A3A3A) + else Color(0xFFE8E8E8) + // 🔥 Берем dialogs из chatsState для + // консистентности + val currentDialogs = chatsState.dialogs - // 🔥 СЕПАРАТОР - линия разделения между диалогами - Divider( - modifier = Modifier.padding(start = 84.dp), - color = dividerColor, - thickness = 0.5.dp - ) - } + LazyColumn( + modifier = Modifier.fillMaxSize() + ) { + if (requestsCount > 0) { + item( + key = + "requests_section" + ) { + RequestsSection( + count = + requestsCount, + isDarkTheme = + isDarkTheme, + onClick = { + showRequestsScreen = + true + } + ) + Divider( + color = + dividerColor, + thickness = + 0.5.dp + ) + } + } + + items( + currentDialogs, + key = { it.opponentKey } + ) { dialog -> + val isSavedMessages = + dialog.opponentKey == + accountPublicKey + // Check if user is blocked + var isBlocked by remember { + mutableStateOf( + false + ) + } + LaunchedEffect( + dialog.opponentKey, + blocklistUpdateTrigger + ) { + isBlocked = + chatsViewModel + .isUserBlocked( + dialog.opponentKey + ) + } + + Column { + SwipeableDialogItem( + dialog = + dialog, + isDarkTheme = + isDarkTheme, + isTyping = + typingUsers + .contains( + dialog.opponentKey + ), + isBlocked = + isBlocked, + isSavedMessages = + isSavedMessages, + onClick = { + val user = + chatsViewModel + .dialogToSearchUser( + dialog + ) + onUserSelect( + user + ) + }, + onDelete = { + dialogToDelete = + dialog + }, + onBlock = { + dialogToBlock = + dialog + }, + onUnblock = { + dialogToUnblock = + dialog + } + ) + + // 🔥 СЕПАРАТОР - + // линия разделения + // между диалогами + Divider( + modifier = + Modifier.padding( + start = + 84.dp + ), + color = + dividerColor, + thickness = + 0.5.dp + ) + } + } + } + } + } // Close AnimatedContent + + // Console button removed } - } } - } // Close AnimatedContent + } // Close ModalNavigationDrawer - // Console button removed + // 🔥 Confirmation Dialogs + + // Delete Dialog Confirmation + dialogToDelete?.let { dialog -> + AlertDialog( + onDismissRequest = { dialogToDelete = null }, + containerColor = + if (isDarkTheme) Color(0xFF2C2C2E) else Color.White, + title = { + Text( + "Delete Chat", + fontWeight = FontWeight.Bold, + color = textColor + ) + }, + text = { + Text( + "Are you sure you want to delete this chat? This action cannot be undone.", + color = secondaryTextColor + ) + }, + confirmButton = { + TextButton( + onClick = { + val opponentKey = dialog.opponentKey + dialogToDelete = null + scope.launch { + chatsViewModel.deleteDialog( + opponentKey + ) + } + } + ) { Text("Delete", color = Color(0xFFFF3B30)) } + }, + dismissButton = { + TextButton(onClick = { dialogToDelete = null }) { + Text("Cancel", color = PrimaryBlue) + } + } + ) } - } - } // Close ModalNavigationDrawer - // 🔥 Confirmation Dialogs - - // Delete Dialog Confirmation - dialogToDelete?.let { dialog -> - AlertDialog( - onDismissRequest = { dialogToDelete = null }, - containerColor = if (isDarkTheme) Color(0xFF2C2C2E) else Color.White, - title = { - Text("Delete Chat", fontWeight = FontWeight.Bold, color = textColor) - }, - text = { - Text( - "Are you sure you want to delete this chat? This action cannot be undone.", - color = secondaryTextColor - ) - }, - confirmButton = { - TextButton( - onClick = { - val opponentKey = dialog.opponentKey - dialogToDelete = null - scope.launch { chatsViewModel.deleteDialog(opponentKey) } + // Block Dialog Confirmation + dialogToBlock?.let { dialog -> + AlertDialog( + onDismissRequest = { dialogToBlock = null }, + containerColor = + if (isDarkTheme) Color(0xFF2C2C2E) else Color.White, + title = { + Text( + "Block ${dialog.opponentTitle.ifEmpty { "User" }}", + fontWeight = FontWeight.Bold, + color = textColor + ) + }, + text = { + Text( + "Are you sure you want to block this user? They won't be able to send you messages.", + color = secondaryTextColor + ) + }, + confirmButton = { + TextButton( + onClick = { + val opponentKey = dialog.opponentKey + dialogToBlock = null + scope.launch { + chatsViewModel.blockUser( + opponentKey + ) + blocklistUpdateTrigger++ + } + } + ) { Text("Block", color = Color(0xFFFF3B30)) } + }, + dismissButton = { + TextButton(onClick = { dialogToBlock = null }) { + Text("Cancel", color = PrimaryBlue) + } } - ) { Text("Delete", color = Color(0xFFFF3B30)) } - }, - dismissButton = { - TextButton(onClick = { dialogToDelete = null }) { - Text("Cancel", color = PrimaryBlue) - } - } - ) - } + ) + } - // Block Dialog Confirmation - dialogToBlock?.let { dialog -> - AlertDialog( - onDismissRequest = { dialogToBlock = null }, - containerColor = if (isDarkTheme) Color(0xFF2C2C2E) else Color.White, - title = { - Text( - "Block ${dialog.opponentTitle.ifEmpty { "User" }}", - fontWeight = FontWeight.Bold, - color = textColor - ) - }, - text = { - Text( - "Are you sure you want to block this user? They won't be able to send you messages.", - color = secondaryTextColor - ) - }, - confirmButton = { - TextButton( - onClick = { - val opponentKey = dialog.opponentKey - dialogToBlock = null - scope.launch { - chatsViewModel.blockUser(opponentKey) - blocklistUpdateTrigger++ - } + // Unblock Dialog Confirmation + dialogToUnblock?.let { dialog -> + AlertDialog( + onDismissRequest = { dialogToUnblock = null }, + containerColor = + if (isDarkTheme) Color(0xFF2C2C2E) else Color.White, + title = { + Text( + "Unblock ${dialog.opponentTitle.ifEmpty { "User" }}", + fontWeight = FontWeight.Bold, + color = textColor + ) + }, + text = { + Text( + "Are you sure you want to unblock this user? They will be able to send you messages again.", + color = secondaryTextColor + ) + }, + confirmButton = { + TextButton( + onClick = { + val opponentKey = dialog.opponentKey + dialogToUnblock = null + scope.launch { + chatsViewModel.unblockUser( + opponentKey + ) + blocklistUpdateTrigger++ + } + } + ) { Text("Unblock", color = PrimaryBlue) } + }, + dismissButton = { + TextButton(onClick = { dialogToUnblock = null }) { + Text("Cancel", color = Color(0xFF8E8E93)) + } } - ) { Text("Block", color = Color(0xFFFF3B30)) } - }, - dismissButton = { - TextButton(onClick = { dialogToBlock = null }) { - Text("Cancel", color = PrimaryBlue) - } - } - ) - } - - // Unblock Dialog Confirmation - dialogToUnblock?.let { dialog -> - AlertDialog( - onDismissRequest = { dialogToUnblock = null }, - containerColor = if (isDarkTheme) Color(0xFF2C2C2E) else Color.White, - title = { - Text( - "Unblock ${dialog.opponentTitle.ifEmpty { "User" }}", - fontWeight = FontWeight.Bold, - color = textColor ) - }, - text = { - Text( - "Are you sure you want to unblock this user? They will be able to send you messages again.", - color = secondaryTextColor - ) - }, - confirmButton = { - TextButton( - onClick = { - val opponentKey = dialog.opponentKey - dialogToUnblock = null - scope.launch { - chatsViewModel.unblockUser(opponentKey) - blocklistUpdateTrigger++ - } - } - ) { Text("Unblock", color = PrimaryBlue) } - }, - dismissButton = { - TextButton(onClick = { dialogToUnblock = null }) { - Text("Cancel", color = Color(0xFF8E8E93)) - } - } - ) - } - } // Close Box + } + } // Close Box } @Composable private fun EmptyChatsState(isDarkTheme: Boolean, modifier: Modifier = Modifier) { - val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666) + val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666) - // Lottie animation - val composition by rememberLottieComposition(LottieCompositionSpec.RawRes(R.raw.letter)) - val progress by animateLottieCompositionAsState(composition = composition, iterations = 1) - - Column( - modifier = modifier.fillMaxSize(), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center - ) { // Lottie animation - LottieAnimation( - composition = composition, - progress = { progress }, - modifier = Modifier.size(150.dp) - ) + val composition by rememberLottieComposition(LottieCompositionSpec.RawRes(R.raw.letter)) + val progress by animateLottieCompositionAsState(composition = composition, iterations = 1) - Spacer(modifier = Modifier.height(24.dp)) + Column( + modifier = modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + // Lottie animation + LottieAnimation( + composition = composition, + progress = { progress }, + modifier = Modifier.size(150.dp) + ) - Text( - text = "No conversations yet", - fontSize = 18.sp, - fontWeight = FontWeight.SemiBold, - color = secondaryTextColor, - textAlign = TextAlign.Center - ) + Spacer(modifier = Modifier.height(24.dp)) - Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "No conversations yet", + fontSize = 18.sp, + fontWeight = FontWeight.SemiBold, + color = secondaryTextColor, + textAlign = TextAlign.Center + ) - Text( - text = "Start a new conversation to get started", - fontSize = 15.sp, - color = secondaryTextColor, - textAlign = TextAlign.Center - ) - } + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = "Start a new conversation to get started", + fontSize = 15.sp, + color = secondaryTextColor, + textAlign = TextAlign.Center + ) + } } // Chat item for list @Composable fun ChatItem(chat: Chat, isDarkTheme: Boolean, onClick: () -> Unit) { - val textColor = if (isDarkTheme) Color.White else Color.Black - val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666) - val dividerColor = if (isDarkTheme) Color(0xFF3A3A3A) else Color(0xFFE8E8E8) + val textColor = if (isDarkTheme) Color.White else Color.Black + val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666) + val dividerColor = if (isDarkTheme) Color(0xFF3A3A3A) else Color(0xFFE8E8E8) - val avatarColors = getAvatarColor(chat.publicKey, isDarkTheme) - val avatarText = getAvatarText(chat.publicKey) + val avatarColors = getAvatarColor(chat.publicKey, isDarkTheme) + val avatarText = getAvatarText(chat.publicKey) - Column { - Row( - modifier = - Modifier.fillMaxWidth() - .clickable(onClick = onClick) - .padding(horizontal = 16.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically - ) { - // Avatar - Box( - modifier = - Modifier.size(56.dp) - .clip(CircleShape) - .background(avatarColors.backgroundColor), - contentAlignment = Alignment.Center - ) { - Text( - text = avatarText, - fontSize = 20.sp, - fontWeight = FontWeight.SemiBold, - color = avatarColors.textColor - ) - - // Online indicator - if (chat.isOnline) { - Box( - modifier = - Modifier.align(Alignment.BottomEnd) - .offset(x = 2.dp, y = 2.dp) - .size(16.dp) - .clip(CircleShape) - .background( - if (isDarkTheme) Color(0xFF1A1A1A) - else Color.White - ) - .padding(2.dp) - .clip(CircleShape) - .background(Color(0xFF4CAF50)) - ) - } - } - - Spacer(modifier = Modifier.width(12.dp)) - - Column(modifier = Modifier.weight(1f)) { + Column { Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, + modifier = + Modifier.fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 12.dp), verticalAlignment = Alignment.CenterVertically ) { - Text( - text = chat.name, - fontSize = 16.sp, - fontWeight = FontWeight.SemiBold, - color = textColor, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f) - ) - - Row(verticalAlignment = Alignment.CenterVertically) { - // Read status - Icon( - Icons.Default.DoneAll, - contentDescription = null, - tint = PrimaryBlue, - modifier = Modifier.size(16.dp) - ) - Spacer(modifier = Modifier.width(4.dp)) - Text( - text = formatTime(chat.lastMessageTime), - fontSize = 13.sp, - color = secondaryTextColor - ) - } - } - - Spacer(modifier = Modifier.height(4.dp)) - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - // 🔥 Используем AppleEmojiText для отображения эмодзи - AppleEmojiText( - text = chat.lastMessage, - fontSize = 14.sp, - color = secondaryTextColor, - maxLines = 1, - overflow = android.text.TextUtils.TruncateAt.END, - modifier = Modifier.weight(1f) - ) - - Row(verticalAlignment = Alignment.CenterVertically) { - // Pin icon - if (chat.isPinned) { - Icon( - Icons.Default.PushPin, - contentDescription = "Pinned", - tint = secondaryTextColor.copy(alpha = 0.6f), - modifier = Modifier.size(16.dp).padding(end = 4.dp) - ) - } - - // Unread badge - if (chat.unreadCount > 0) { - Box( - modifier = - Modifier.clip(CircleShape) - .background(PrimaryBlue) - .padding(horizontal = 8.dp, vertical = 2.dp), - contentAlignment = Alignment.Center - ) { + // Avatar + Box( + modifier = + Modifier.size(56.dp) + .clip(CircleShape) + .background(avatarColors.backgroundColor), + contentAlignment = Alignment.Center + ) { Text( - text = - if (chat.unreadCount > 99) "99+" - else chat.unreadCount.toString(), - fontSize = 12.sp, + text = avatarText, + fontSize = 20.sp, fontWeight = FontWeight.SemiBold, - color = Color.White + color = avatarColors.textColor ) - } - } - } - } - } - } - Divider( - modifier = Modifier.padding(start = 84.dp), - color = dividerColor, - thickness = 0.5.dp - ) - } + // Online indicator + if (chat.isOnline) { + Box( + modifier = + Modifier.align(Alignment.BottomEnd) + .offset(x = 2.dp, y = 2.dp) + .size(16.dp) + .clip(CircleShape) + .background( + if (isDarkTheme) + Color(0xFF1A1A1A) + else Color.White + ) + .padding(2.dp) + .clip(CircleShape) + .background(Color(0xFF4CAF50)) + ) + } + } + + Spacer(modifier = Modifier.width(12.dp)) + + Column(modifier = Modifier.weight(1f)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = chat.name, + fontSize = 16.sp, + fontWeight = FontWeight.SemiBold, + color = textColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) + + Row(verticalAlignment = Alignment.CenterVertically) { + // Read status + Icon( + Icons.Default.DoneAll, + contentDescription = null, + tint = PrimaryBlue, + modifier = Modifier.size(16.dp) + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = formatTime(chat.lastMessageTime), + fontSize = 13.sp, + color = secondaryTextColor + ) + } + } + + Spacer(modifier = Modifier.height(4.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + // 🔥 Используем AppleEmojiText для отображения эмодзи + AppleEmojiText( + text = chat.lastMessage, + fontSize = 14.sp, + color = secondaryTextColor, + maxLines = 1, + overflow = android.text.TextUtils.TruncateAt.END, + modifier = Modifier.weight(1f) + ) + + Row(verticalAlignment = Alignment.CenterVertically) { + // Pin icon + if (chat.isPinned) { + Icon( + Icons.Default.PushPin, + contentDescription = "Pinned", + tint = + secondaryTextColor.copy( + alpha = 0.6f + ), + modifier = + Modifier.size(16.dp) + .padding(end = 4.dp) + ) + } + + // Unread badge + if (chat.unreadCount > 0) { + Box( + modifier = + Modifier.clip(CircleShape) + .background( + PrimaryBlue + ) + .padding( + horizontal = + 8.dp, + vertical = + 2.dp + ), + contentAlignment = Alignment.Center + ) { + Text( + text = + if (chat.unreadCount > + 99 + ) + "99+" + else + chat.unreadCount + .toString(), + fontSize = 12.sp, + fontWeight = + FontWeight.SemiBold, + color = Color.White + ) + } + } + } + } + } + } + + Divider( + modifier = Modifier.padding(start = 84.dp), + color = dividerColor, + thickness = 0.5.dp + ) + } } // Cache для SimpleDateFormat - создание дорогостоящее @@ -1217,26 +1656,26 @@ private val yearFormatCache = java.lang.ThreadLocal.withInitial { SimpleDateFormat("dd.MM.yy", Locale.getDefault()) } private fun formatTime(date: Date): String { - val now = Calendar.getInstance() - val messageTime = Calendar.getInstance().apply { time = date } + val now = Calendar.getInstance() + val messageTime = Calendar.getInstance().apply { time = date } - return when { - now.get(Calendar.DATE) == messageTime.get(Calendar.DATE) -> { - timeFormatCache.get()?.format(date) ?: "" + return when { + now.get(Calendar.DATE) == messageTime.get(Calendar.DATE) -> { + timeFormatCache.get()?.format(date) ?: "" + } + now.get(Calendar.DATE) - messageTime.get(Calendar.DATE) == 1 -> { + "Yesterday" + } + now.get(Calendar.WEEK_OF_YEAR) == messageTime.get(Calendar.WEEK_OF_YEAR) -> { + weekFormatCache.get()?.format(date) ?: "" + } + now.get(Calendar.YEAR) == messageTime.get(Calendar.YEAR) -> { + monthFormatCache.get()?.format(date) ?: "" + } + else -> { + yearFormatCache.get()?.format(date) ?: "" + } } - now.get(Calendar.DATE) - messageTime.get(Calendar.DATE) == 1 -> { - "Yesterday" - } - now.get(Calendar.WEEK_OF_YEAR) == messageTime.get(Calendar.WEEK_OF_YEAR) -> { - weekFormatCache.get()?.format(date) ?: "" - } - now.get(Calendar.YEAR) == messageTime.get(Calendar.YEAR) -> { - monthFormatCache.get()?.format(date) ?: "" - } - else -> { - yearFormatCache.get()?.format(date) ?: "" - } - } } /** Элемент меню в боковом drawer */ @@ -1248,36 +1687,41 @@ fun DrawerMenuItem( isDestructive: Boolean = false, onClick: () -> Unit ) { - val textColor = - if (isDestructive) { - Color(0xFFFF3B30) - } else { - if (isDarkTheme) Color.White else Color.Black - } + val textColor = + if (isDestructive) { + Color(0xFFFF3B30) + } else { + if (isDarkTheme) Color.White else Color.Black + } - val backgroundColor = if (isDarkTheme) Color(0xFF2C2C2E) else Color(0xFFF2F2F7) + val backgroundColor = if (isDarkTheme) Color(0xFF2C2C2E) else Color(0xFFF2F2F7) - Row( - modifier = - Modifier.fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 4.dp) - .clip(RoundedCornerShape(12.dp)) - .background(backgroundColor) - .clickable(onClick = onClick) - .padding(horizontal = 16.dp, vertical = 14.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - imageVector = icon, - contentDescription = null, - tint = textColor.copy(alpha = 0.6f), - modifier = Modifier.size(24.dp) - ) + Row( + modifier = + Modifier.fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 4.dp) + .clip(RoundedCornerShape(12.dp)) + .background(backgroundColor) + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = textColor.copy(alpha = 0.6f), + modifier = Modifier.size(24.dp) + ) - Spacer(modifier = Modifier.width(16.dp)) + Spacer(modifier = Modifier.width(16.dp)) - Text(text = text, fontSize = 16.sp, color = textColor, fontWeight = FontWeight.Medium) - } + Text( + text = text, + fontSize = 16.sp, + color = textColor, + fontWeight = FontWeight.Medium + ) + } } /** @@ -1296,140 +1740,159 @@ fun SwipeableDialogItem( onBlock: () -> Unit = {}, onUnblock: () -> Unit = {} ) { - val backgroundColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFFFFFFF) - var offsetX by remember { mutableStateOf(0f) } - val swipeWidthDp = if (isSavedMessages) 80.dp else 160.dp - val density = androidx.compose.ui.platform.LocalDensity.current - val swipeWidthPx = with(density) { swipeWidthDp.toPx() } + val backgroundColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFFFFFFF) + var offsetX by remember { mutableStateOf(0f) } + val swipeWidthDp = if (isSavedMessages) 80.dp else 160.dp + val density = androidx.compose.ui.platform.LocalDensity.current + val swipeWidthPx = with(density) { swipeWidthDp.toPx() } - // Фиксированная высота элемента (как в DialogItem) - val itemHeight = 80.dp + // Фиксированная высота элемента (как в DialogItem) + val itemHeight = 80.dp - // Анимация возврата - val animatedOffsetX by - animateFloatAsState( - targetValue = offsetX, - animationSpec = spring(dampingRatio = 0.7f, stiffness = 400f), - label = "swipeOffset" - ) + // Анимация возврата + val animatedOffsetX by + animateFloatAsState( + targetValue = offsetX, + animationSpec = spring(dampingRatio = 0.7f, stiffness = 400f), + label = "swipeOffset" + ) - Box(modifier = Modifier.fillMaxWidth().height(itemHeight).clipToBounds()) { - // 1. КНОПКИ - позиционированы справа, всегда видны при свайпе - Row(modifier = Modifier.align(Alignment.CenterEnd).height(itemHeight).width(swipeWidthDp)) { - // Кнопка Block/Unblock (только если не Saved Messages) - if (!isSavedMessages) { - Box( + Box(modifier = Modifier.fillMaxWidth().height(itemHeight).clipToBounds()) { + // 1. КНОПКИ - позиционированы справа, всегда видны при свайпе + Row( modifier = - Modifier.width(80.dp) - .fillMaxHeight() - .background( - if (isBlocked) Color(0xFF4CAF50) - else Color(0xFFFF6B6B) - ) - .clickable { - if (isBlocked) onUnblock() else onBlock() - offsetX = 0f - }, - contentAlignment = Alignment.Center + Modifier.align(Alignment.CenterEnd) + .height(itemHeight) + .width(swipeWidthDp) ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center - ) { - Icon( - imageVector = - if (isBlocked) Icons.Default.LockOpen - else Icons.Default.Block, - contentDescription = if (isBlocked) "Unblock" else "Block", - tint = Color.White, - modifier = Modifier.size(22.dp) - ) - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = if (isBlocked) "Unblock" else "Block", - color = Color.White, - fontSize = 12.sp, - fontWeight = FontWeight.SemiBold - ) - } - } - } - - // Кнопка Delete - Box( - modifier = - Modifier.width(80.dp) - .fillMaxHeight() - .background(PrimaryBlue) - .clickable { - // Закрываем свайп мгновенно перед удалением - offsetX = 0f - onDelete() - }, - contentAlignment = Alignment.Center - ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center - ) { - Icon( - imageVector = Icons.Default.Delete, - contentDescription = "Delete", - tint = Color.White, - modifier = Modifier.size(22.dp) - ) - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = "Delete", - color = Color.White, - fontSize = 12.sp, - fontWeight = FontWeight.SemiBold - ) - } - } - } - - // 2. КОНТЕНТ - поверх кнопок, сдвигается при свайпе - Column( - modifier = - Modifier.fillMaxSize() - .offset { IntOffset(animatedOffsetX.toInt(), 0) } - .background(backgroundColor) - .pointerInput(Unit) { - detectHorizontalDragGestures( - onDragEnd = { - // Если свайпнули больше чем на половину - фиксируем - if (kotlin.math.abs(offsetX) > swipeWidthPx / 2) { - offsetX = -swipeWidthPx - } else { - offsetX = 0f - } - }, - onDragCancel = { offsetX = 0f }, - onHorizontalDrag = { _, dragAmount -> - // Только свайп влево (отрицательное значение) - val newOffset = offsetX + dragAmount - offsetX = newOffset.coerceIn(-swipeWidthPx, 0f) - } - ) + // Кнопка Block/Unblock (только если не Saved Messages) + if (!isSavedMessages) { + Box( + modifier = + Modifier.width(80.dp) + .fillMaxHeight() + .background( + if (isBlocked) Color(0xFF4CAF50) + else Color(0xFFFF6B6B) + ) + .clickable { + if (isBlocked) onUnblock() + else onBlock() + offsetX = 0f + }, + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Icon( + imageVector = + if (isBlocked) + Icons.Default.LockOpen + else Icons.Default.Block, + contentDescription = + if (isBlocked) "Unblock" + else "Block", + tint = Color.White, + modifier = Modifier.size(22.dp) + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = + if (isBlocked) "Unblock" + else "Block", + color = Color.White, + fontSize = 12.sp, + fontWeight = FontWeight.SemiBold + ) + } } - ) { - DialogItemContent( - dialog = dialog, - isDarkTheme = isDarkTheme, - isTyping = isTyping, - onClick = onClick - ) + } - // Сепаратор внутри контента - val dividerColor = if (isDarkTheme) Color(0xFF3A3A3A) else Color(0xFFE8E8E8) - Divider( - modifier = Modifier.padding(start = 84.dp), - color = dividerColor, - thickness = 0.5.dp - ) + // Кнопка Delete + Box( + modifier = + Modifier.width(80.dp) + .fillMaxHeight() + .background(PrimaryBlue) + .clickable { + // Закрываем свайп мгновенно перед удалением + offsetX = 0f + onDelete() + }, + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = "Delete", + tint = Color.White, + modifier = Modifier.size(22.dp) + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "Delete", + color = Color.White, + fontSize = 12.sp, + fontWeight = FontWeight.SemiBold + ) + } + } + } + + // 2. КОНТЕНТ - поверх кнопок, сдвигается при свайпе + Column( + modifier = + Modifier.fillMaxSize() + .offset { IntOffset(animatedOffsetX.toInt(), 0) } + .background(backgroundColor) + .pointerInput(Unit) { + detectHorizontalDragGestures( + onDragEnd = { + // Если свайпнули больше чем на + // половину - фиксируем + if (kotlin.math.abs(offsetX) > + swipeWidthPx / 2 + ) { + offsetX = -swipeWidthPx + } else { + offsetX = 0f + } + }, + onDragCancel = { offsetX = 0f }, + onHorizontalDrag = { _, dragAmount -> + // Только свайп влево (отрицательное + // значение) + val newOffset = offsetX + dragAmount + offsetX = + newOffset.coerceIn( + -swipeWidthPx, + 0f + ) + } + ) + } + ) { + DialogItemContent( + dialog = dialog, + isDarkTheme = isDarkTheme, + isTyping = isTyping, + onClick = onClick + ) + + // Сепаратор внутри контента + val dividerColor = if (isDarkTheme) Color(0xFF3A3A3A) else Color(0xFFE8E8E8) + Divider( + modifier = Modifier.padding(start = 84.dp), + color = dividerColor, + thickness = 0.5.dp + ) + } } - } } /** Элемент диалога из базы данных - ОПТИМИЗИРОВАННЫЙ (без сепаратора для SwipeableDialogItem) */ @@ -1440,261 +1903,318 @@ fun DialogItemContent( isTyping: Boolean = false, onClick: () -> Unit ) { - // 🔥 ОПТИМИЗАЦИЯ: Кешируем цвета и строки - val textColor = remember(isDarkTheme) { if (isDarkTheme) Color.White else Color.Black } - val secondaryTextColor = - remember(isDarkTheme) { if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666) } + // 🔥 ОПТИМИЗАЦИЯ: Кешируем цвета и строки + val textColor = remember(isDarkTheme) { if (isDarkTheme) Color.White else Color.Black } + val secondaryTextColor = + remember(isDarkTheme) { if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666) } - val avatarColors = - remember(dialog.opponentKey, isDarkTheme) { - getAvatarColor(dialog.opponentKey, isDarkTheme) - } - - // 📁 Для Saved Messages показываем специальное имя - // 🔥 Как в Архиве: title > username > "DELETED" - val displayName = - remember(dialog.opponentTitle, dialog.opponentUsername, dialog.opponentKey, dialog.isSavedMessages) { - if (dialog.isSavedMessages) { - "Saved Messages" - } else if (dialog.opponentTitle.isNotEmpty() && - dialog.opponentTitle != dialog.opponentKey && - dialog.opponentTitle != dialog.opponentKey.take(7) && - dialog.opponentTitle != dialog.opponentKey.take(8)) { - // 🔥 Показываем title как основное имя (как в десктопной версии) - // Обрезаем до 15 символов как в Архиве - if (dialog.opponentTitle.length > 15) { - dialog.opponentTitle.take(15) + "..." - } else { - dialog.opponentTitle - } - } else if (dialog.opponentUsername.isNotEmpty()) { - // Username только если нет title - "@${dialog.opponentUsername}" - } else { - // 🔥 Как в Архиве - если нет информации, показываем часть ключа - dialog.opponentKey.take(7) + val avatarColors = + remember(dialog.opponentKey, isDarkTheme) { + getAvatarColor(dialog.opponentKey, isDarkTheme) } - } - - // 📁 Для Saved Messages показываем иконку закладки - // 🔥 Как в Архиве: инициалы из title или username или DELETED - val initials = - remember(dialog.opponentTitle, dialog.opponentUsername, dialog.opponentKey, dialog.isSavedMessages) { - if (dialog.isSavedMessages) { - "" // Для Saved Messages - пустая строка, будет использоваться иконка - } else if (dialog.opponentTitle.isNotEmpty() && - dialog.opponentTitle != dialog.opponentKey && - dialog.opponentTitle != dialog.opponentKey.take(7) && - dialog.opponentTitle != dialog.opponentKey.take(8)) { - // Используем title для инициалов - dialog.opponentTitle - .split(" ") - .take(2) - .mapNotNull { it.firstOrNull()?.uppercase() } - .joinToString("") - .ifEmpty { dialog.opponentTitle.take(2).uppercase() } - } else if (dialog.opponentUsername.isNotEmpty()) { - // Если только username - берем первые 2 символа - dialog.opponentUsername.take(2).uppercase() - } else { - dialog.opponentKey.take(2).uppercase() - } - } - Row( - modifier = - Modifier.fillMaxWidth() - .clickable(onClick = onClick) - .padding(horizontal = 16.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically - ) { - // Avatar container with online indicator - Box(modifier = Modifier.size(56.dp)) { - // Avatar - Box( - modifier = - Modifier.fillMaxSize() - .clip(CircleShape) - .background( - if (dialog.isSavedMessages) PrimaryBlue - else avatarColors.backgroundColor - ), - contentAlignment = Alignment.Center - ) { - if (dialog.isSavedMessages) { - Icon( - Icons.Default.Bookmark, - contentDescription = null, - tint = Color.White, - modifier = Modifier.size(24.dp) - ) - } else { - Text( - text = initials, - color = avatarColors.textColor, - fontWeight = FontWeight.SemiBold, - fontSize = 18.sp - ) - } - } - - // Online indicator - зелёный кружок с белой обводкой - if (dialog.isOnline == 1) { - Box( - modifier = - Modifier.size(18.dp) - .align(Alignment.BottomEnd) - .offset(x = (-2).dp, y = (-2).dp) - .clip(CircleShape) - .background( - if (isDarkTheme) Color(0xFF1C1C1E) else Color.White - ) - .padding(3.dp) - .clip(CircleShape) - .background(Color(0xFF34C759)) // iOS зелёный цвет - ) - } - } - - Spacer(modifier = Modifier.width(12.dp)) - - // Name and last message - Column(modifier = Modifier.weight(1f)) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = displayName, - fontWeight = FontWeight.SemiBold, - fontSize = 16.sp, - color = textColor, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f) - ) - - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.End + // 📁 Для Saved Messages показываем специальное имя + // 🔥 Как в Архиве: title > username > "DELETED" + val displayName = + remember( + dialog.opponentTitle, + dialog.opponentUsername, + dialog.opponentKey, + dialog.isSavedMessages ) { - // 📁 Для Saved Messages ВСЕГДА показываем синие двойные галочки (прочитано) - if (dialog.isSavedMessages) { - Icon( - imageVector = Icons.Default.DoneAll, - contentDescription = null, - tint = PrimaryBlue, - modifier = Modifier.size(16.dp) - ) - Spacer(modifier = Modifier.width(4.dp)) - } else if (dialog.lastMessageFromMe == 1) { - // Показываем статус только для исходящих сообщений (кроме Saved Messages) - // 🔥 ПРАВИЛЬНАЯ ЛОГИКА (синхронизировано с ChatViewModel): - // - lastMessageDelivered == 3 → две синие галочки (прочитано собеседником) - // - lastMessageDelivered == 1 → одна галочка (доставлено) - // - lastMessageDelivered == 0 → часики (отправляется) - // - lastMessageDelivered == 2 → ошибка - 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)) - } - 3 -> { - // READ (delivered=3) - две синие галочки - Icon( - imageVector = Icons.Default.DoneAll, - contentDescription = null, - tint = PrimaryBlue, - modifier = Modifier.size(16.dp) - ) - Spacer(modifier = Modifier.width(4.dp)) - } - else -> { - // DELIVERED (1) или SENDING (0) - одна серая галочка - Icon( - imageVector = Icons.Default.Done, - contentDescription = null, - tint = secondaryTextColor.copy(alpha = 0.6f), - modifier = Modifier.size(16.dp) - ) - Spacer(modifier = Modifier.width(4.dp)) - } + if (dialog.isSavedMessages) { + "Saved Messages" + } else if (dialog.opponentTitle.isNotEmpty() && + dialog.opponentTitle != dialog.opponentKey && + dialog.opponentTitle != dialog.opponentKey.take(7) && + dialog.opponentTitle != dialog.opponentKey.take(8) + ) { + // 🔥 Показываем title как основное имя (как в десктопной версии) + // Обрезаем до 15 символов как в Архиве + if (dialog.opponentTitle.length > 15) { + dialog.opponentTitle.take(15) + "..." + } else { + dialog.opponentTitle + } + } else if (dialog.opponentUsername.isNotEmpty()) { + // Username только если нет title + "@${dialog.opponentUsername}" + } else { + // 🔥 Как в Архиве - если нет информации, показываем часть ключа + dialog.opponentKey.take(7) } - } - - Text( - text = formatTime(Date(dialog.lastMessageTimestamp)), - fontSize = 13.sp, - color = if (dialog.unreadCount > 0) PrimaryBlue else secondaryTextColor - ) - } - } - - Spacer(modifier = Modifier.height(4.dp)) - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - // 🔥 Показываем typing индикатор или последнее сообщение - if (isTyping) { - TypingIndicatorSmall() - } else { - // 🔥 Используем AppleEmojiText для отображения эмодзи - // Если есть непрочитанные - текст темнее - AppleEmojiText( - text = dialog.lastMessage.ifEmpty { "No messages" }, - fontSize = 14.sp, - color = - if (dialog.unreadCount > 0) textColor.copy(alpha = 0.85f) - else secondaryTextColor, - fontWeight = - if (dialog.unreadCount > 0) FontWeight.Medium - else FontWeight.Normal, - maxLines = 1, - overflow = android.text.TextUtils.TruncateAt.END, - modifier = Modifier.weight(1f) - ) } - // Unread badge - if (dialog.unreadCount > 0) { - Spacer(modifier = Modifier.width(8.dp)) - val unreadText = - when { - dialog.unreadCount > 999 -> "999+" - dialog.unreadCount > 99 -> "99+" - else -> dialog.unreadCount.toString() - } - Box( - modifier = - Modifier.height(22.dp) - .widthIn(min = 22.dp) - .clip(RoundedCornerShape(11.dp)) - .background(PrimaryBlue) - .padding(horizontal = 6.dp), - contentAlignment = Alignment.Center - ) { - Text( - text = unreadText, - fontSize = 12.sp, - fontWeight = FontWeight.Bold, - color = Color.White, - maxLines = 1 - ) - } + // 📁 Для Saved Messages показываем иконку закладки + // 🔥 Как в Архиве: инициалы из title или username или DELETED + val initials = + remember( + dialog.opponentTitle, + dialog.opponentUsername, + dialog.opponentKey, + dialog.isSavedMessages + ) { + if (dialog.isSavedMessages) { + "" // Для Saved Messages - пустая строка, будет использоваться + // иконка + } else if (dialog.opponentTitle.isNotEmpty() && + dialog.opponentTitle != dialog.opponentKey && + dialog.opponentTitle != dialog.opponentKey.take(7) && + dialog.opponentTitle != dialog.opponentKey.take(8) + ) { + // Используем title для инициалов + dialog.opponentTitle + .split(" ") + .take(2) + .mapNotNull { it.firstOrNull()?.uppercase() } + .joinToString("") + .ifEmpty { dialog.opponentTitle.take(2).uppercase() } + } else if (dialog.opponentUsername.isNotEmpty()) { + // Если только username - берем первые 2 символа + dialog.opponentUsername.take(2).uppercase() + } else { + dialog.opponentKey.take(2).uppercase() + } + } + + Row( + modifier = + Modifier.fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // Avatar container with online indicator + Box(modifier = Modifier.size(56.dp)) { + // Avatar + Box( + modifier = + Modifier.fillMaxSize() + .clip(CircleShape) + .background( + if (dialog.isSavedMessages) PrimaryBlue + else avatarColors.backgroundColor + ), + contentAlignment = Alignment.Center + ) { + if (dialog.isSavedMessages) { + Icon( + Icons.Default.Bookmark, + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(24.dp) + ) + } else { + Text( + text = initials, + color = avatarColors.textColor, + fontWeight = FontWeight.SemiBold, + fontSize = 18.sp + ) + } + } + + // Online indicator - зелёный кружок с белой обводкой + if (dialog.isOnline == 1) { + Box( + modifier = + Modifier.size(18.dp) + .align(Alignment.BottomEnd) + .offset(x = (-2).dp, y = (-2).dp) + .clip(CircleShape) + .background( + if (isDarkTheme) Color(0xFF1C1C1E) + else Color.White + ) + .padding(3.dp) + .clip(CircleShape) + .background( + Color(0xFF34C759) + ) // iOS зелёный цвет + ) + } + } + + Spacer(modifier = Modifier.width(12.dp)) + + // Name and last message + Column(modifier = Modifier.weight(1f)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = displayName, + fontWeight = FontWeight.SemiBold, + fontSize = 16.sp, + color = textColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.End + ) { + // 📁 Для Saved Messages ВСЕГДА показываем синие двойные + // галочки (прочитано) + if (dialog.isSavedMessages) { + Icon( + imageVector = Icons.Default.DoneAll, + contentDescription = null, + tint = PrimaryBlue, + modifier = Modifier.size(16.dp) + ) + Spacer(modifier = Modifier.width(4.dp)) + } else if (dialog.lastMessageFromMe == 1) { + // Показываем статус только для исходящих сообщений + // (кроме Saved Messages) + // 🔥 ПРАВИЛЬНАЯ ЛОГИКА (синхронизировано с + // ChatViewModel): + // - lastMessageDelivered == 3 → две синие галочки + // (прочитано собеседником) + // - lastMessageDelivered == 1 → одна галочка + // (доставлено) + // - lastMessageDelivered == 0 → часики + // (отправляется) + // - lastMessageDelivered == 2 → ошибка + 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) + ) + } + 3 -> { + // READ (delivered=3) - две синие + // галочки + Icon( + imageVector = + Icons.Default + .DoneAll, + contentDescription = null, + tint = PrimaryBlue, + modifier = + Modifier.size(16.dp) + ) + Spacer( + modifier = + Modifier.width(4.dp) + ) + } + else -> { + // DELIVERED (1) или SENDING (0) - + // одна серая галочка + Icon( + imageVector = + Icons.Default.Done, + contentDescription = null, + tint = + secondaryTextColor + .copy( + alpha = + 0.6f + ), + modifier = + Modifier.size(16.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)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + // 🔥 Показываем typing индикатор или последнее сообщение + if (isTyping) { + TypingIndicatorSmall() + } else { + // 🔥 Используем AppleEmojiText для отображения эмодзи + // Если есть непрочитанные - текст темнее + AppleEmojiText( + text = dialog.lastMessage.ifEmpty { "No messages" }, + fontSize = 14.sp, + color = + if (dialog.unreadCount > 0) + textColor.copy(alpha = 0.85f) + else secondaryTextColor, + fontWeight = + if (dialog.unreadCount > 0) + FontWeight.Medium + else FontWeight.Normal, + maxLines = 1, + overflow = android.text.TextUtils.TruncateAt.END, + modifier = Modifier.weight(1f) + ) + } + + // Unread badge + if (dialog.unreadCount > 0) { + Spacer(modifier = Modifier.width(8.dp)) + val unreadText = + when { + dialog.unreadCount > 999 -> "999+" + dialog.unreadCount > 99 -> "99+" + else -> dialog.unreadCount.toString() + } + Box( + modifier = + Modifier.height(22.dp) + .widthIn(min = 22.dp) + .clip(RoundedCornerShape(11.dp)) + .background(PrimaryBlue) + .padding(horizontal = 6.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = unreadText, + fontSize = 12.sp, + fontWeight = FontWeight.Bold, + color = Color.White, + maxLines = 1 + ) + } + } + } } - } } - } } /** @@ -1702,73 +2222,78 @@ fun DialogItemContent( */ @Composable fun TypingIndicatorSmall() { - val infiniteTransition = rememberInfiniteTransition(label = "typing") - val typingColor = PrimaryBlue + val infiniteTransition = rememberInfiniteTransition(label = "typing") + val typingColor = PrimaryBlue - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(1.dp) - ) { - Text(text = "typing", fontSize = 14.sp, color = typingColor, fontWeight = FontWeight.Medium) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(1.dp) + ) { + Text( + text = "typing", + fontSize = 14.sp, + color = typingColor, + fontWeight = FontWeight.Medium + ) - // 3 анимированные точки - repeat(3) { index -> - val offsetY by - infiniteTransition.animateFloat( - initialValue = 0f, - targetValue = -3f, - animationSpec = - infiniteRepeatable( - animation = - tween( - durationMillis = 500, - delayMillis = index * 120, - easing = FastOutSlowInEasing - ), - repeatMode = RepeatMode.Reverse - ), - label = "dot$index" - ) + // 3 анимированные точки + repeat(3) { index -> + val offsetY by + infiniteTransition.animateFloat( + initialValue = 0f, + targetValue = -3f, + animationSpec = + infiniteRepeatable( + animation = + tween( + durationMillis = 500, + delayMillis = index * 120, + easing = FastOutSlowInEasing + ), + repeatMode = RepeatMode.Reverse + ), + label = "dot$index" + ) - Text( - text = ".", - fontSize = 14.sp, - color = typingColor, - fontWeight = FontWeight.Medium, - modifier = Modifier.offset(y = offsetY.dp) - ) + Text( + text = ".", + fontSize = 14.sp, + color = typingColor, + fontWeight = FontWeight.Medium, + modifier = Modifier.offset(y = offsetY.dp) + ) + } } - } } /** 📬 Секция Requests - кнопка для перехода к списку запросов */ @Composable fun RequestsSection(count: Int, isDarkTheme: Boolean, onClick: () -> Unit) { - val textColor = if (isDarkTheme) Color(0xFF4DABF7) else Color(0xFF228BE6) - val arrowColor = if (isDarkTheme) Color(0xFFC9C9C9) else Color(0xFF228BE6) + val textColor = if (isDarkTheme) Color(0xFF4DABF7) else Color(0xFF228BE6) + val arrowColor = if (isDarkTheme) Color(0xFFC9C9C9) else Color(0xFF228BE6) - Row( - modifier = - Modifier.fillMaxWidth() - .clickable(onClick = onClick) - .padding(horizontal = 16.dp, vertical = 14.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = "Requests +$count", - fontSize = 16.sp, - fontWeight = FontWeight.SemiBold, - color = textColor - ) + Row( + modifier = + Modifier.fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 14.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "Requests +$count", + fontSize = 16.sp, + fontWeight = FontWeight.SemiBold, + color = textColor + ) - Icon( - imageVector = Icons.Default.ChevronRight, - contentDescription = "Open requests", - tint = arrowColor.copy(alpha = 0.6f), - modifier = Modifier.size(24.dp) - ) - } + Icon( + imageVector = Icons.Default.ChevronRight, + contentDescription = "Open requests", + tint = arrowColor.copy(alpha = 0.6f), + modifier = Modifier.size(24.dp) + ) + } } /** 📬 Экран со списком Requests (без хедера - хедер в основном TopAppBar) */ @@ -1779,43 +2304,45 @@ fun RequestsScreen( onBack: () -> Unit, onRequestClick: (DialogUiModel) -> Unit ) { - val backgroundColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFFFFFFF) - val dividerColor = if (isDarkTheme) Color(0xFF3A3A3A) else Color(0xFFE8E8E8) + val backgroundColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFFFFFFF) + val dividerColor = if (isDarkTheme) Color(0xFF3A3A3A) else Color(0xFFE8E8E8) - Column(modifier = Modifier.fillMaxSize().background(backgroundColor)) { - if (requests.isEmpty()) { - // Empty state - Box( - modifier = Modifier.fillMaxSize().padding(32.dp), - contentAlignment = Alignment.Center - ) { - Text( - text = "No requests", - fontSize = 16.sp, - color = if (isDarkTheme) Color(0xFF8E8E8E) else Color(0xFF8E8E93), - textAlign = TextAlign.Center - ) - } - } else { - // Requests list - LazyColumn(modifier = Modifier.fillMaxSize()) { - items(requests, key = { it.opponentKey }) { request -> - DialogItemContent( - dialog = request, - isDarkTheme = isDarkTheme, - isTyping = false, - onClick = { onRequestClick(request) } - ) + Column(modifier = Modifier.fillMaxSize().background(backgroundColor)) { + if (requests.isEmpty()) { + // Empty state + Box( + modifier = Modifier.fillMaxSize().padding(32.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = "No requests", + fontSize = 16.sp, + color = + if (isDarkTheme) Color(0xFF8E8E8E) + else Color(0xFF8E8E93), + textAlign = TextAlign.Center + ) + } + } else { + // Requests list + LazyColumn(modifier = Modifier.fillMaxSize()) { + items(requests, key = { it.opponentKey }) { request -> + DialogItemContent( + dialog = request, + isDarkTheme = isDarkTheme, + isTyping = false, + onClick = { onRequestClick(request) } + ) - Divider( - modifier = Modifier.padding(start = 84.dp), - color = dividerColor, - thickness = 0.5.dp - ) + Divider( + modifier = Modifier.padding(start = 84.dp), + color = dividerColor, + thickness = 0.5.dp + ) + } + } } - } } - } } /** 🎨 Enhanced Drawer Menu Item - красивый пункт меню с hover эффектом */ @@ -1828,58 +2355,165 @@ fun DrawerMenuItemEnhanced( badge: String? = null, onClick: () -> Unit ) { - Row( - modifier = - Modifier.fillMaxWidth() - .clickable(onClick = onClick) - .padding(horizontal = 20.dp, vertical = 14.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - imageVector = icon, - contentDescription = null, - tint = iconColor, - modifier = Modifier.size(24.dp) - ) - - Spacer(modifier = Modifier.width(20.dp)) - - Text( - text = text, - fontSize = 16.sp, - fontWeight = FontWeight.Normal, - color = textColor, - modifier = Modifier.weight(1f) - ) - - badge?.let { - Box( - modifier = - Modifier.background( - color = Color(0xFF4A90D9), - shape = RoundedCornerShape(10.dp) - ) - .padding(horizontal = 8.dp, vertical = 2.dp) - ) { - Text( - text = it, - fontSize = 12.sp, - fontWeight = FontWeight.Medium, - color = Color.White + Row( + modifier = + Modifier.fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 20.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = iconColor, + modifier = Modifier.size(24.dp) ) - } + + Spacer(modifier = Modifier.width(20.dp)) + + Text( + text = text, + fontSize = 16.sp, + fontWeight = FontWeight.Normal, + color = textColor, + modifier = Modifier.weight(1f) + ) + + badge?.let { + Box( + modifier = + Modifier.background( + color = Color(0xFF4A90D9), + shape = RoundedCornerShape(10.dp) + ) + .padding(horizontal = 8.dp, vertical = 2.dp) + ) { + Text( + text = it, + fontSize = 12.sp, + fontWeight = FontWeight.Medium, + color = Color.White + ) + } + } } - } } /** 📏 Drawer Divider - разделитель между секциями */ @Composable fun DrawerDivider(isDarkTheme: Boolean) { - Spacer(modifier = Modifier.height(8.dp)) - Divider( - modifier = Modifier.padding(horizontal = 20.dp), - color = if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFEEEEEE), - thickness = 0.5.dp - ) - Spacer(modifier = Modifier.height(8.dp)) + Spacer(modifier = Modifier.height(8.dp)) + Divider( + modifier = Modifier.padding(horizontal = 20.dp), + color = if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFEEEEEE), + thickness = 0.5.dp + ) + Spacer(modifier = Modifier.height(8.dp)) +} + +/** 🔔 FCM Logs Section - отображение логов отправки FCM токена */ +@Composable +fun FcmLogsSection(logs: List, isDarkTheme: Boolean, onClearLogs: () -> Unit) { + val backgroundColor = if (isDarkTheme) Color(0xFF2C2C2E) else Color(0xFFF8F9FA) + val textColor = if (isDarkTheme) Color(0xFFE0E0E0) else Color(0xFF1A1A1A) + val accentColor = if (isDarkTheme) Color(0xFF4A90D9) else Color(0xFF2979FF) + + var expanded by remember { mutableStateOf(true) } + + Column(modifier = Modifier.fillMaxWidth().background(backgroundColor)) { + // Header + Row( + modifier = + Modifier.fillMaxWidth() + .clickable { expanded = !expanded } + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = + if (expanded) Icons.Default.KeyboardArrowDown + else Icons.Default.KeyboardArrowRight, + contentDescription = null, + tint = accentColor, + modifier = Modifier.size(24.dp) + ) + + Spacer(modifier = Modifier.width(12.dp)) + + Text( + text = "🔔 FCM Token Status", + fontSize = 16.sp, + fontWeight = FontWeight.Bold, + color = textColor, + modifier = Modifier.weight(1f) + ) + + // Clear button + if (logs.isNotEmpty()) { + TextButton( + onClick = onClearLogs, + contentPadding = + PaddingValues(horizontal = 8.dp, vertical = 4.dp) + ) { Text(text = "Clear", fontSize = 13.sp, color = accentColor) } + } + } + + // Logs content + AnimatedVisibility( + visible = expanded, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut() + ) { + Column( + modifier = + Modifier.fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp) + .background( + color = + if (isDarkTheme) Color(0xFF1A1A1A) + else Color.White, + shape = RoundedCornerShape(8.dp) + ) + .padding(12.dp) + ) { + if (logs.isEmpty()) { + Text( + text = "No FCM logs yet...", + fontSize = 14.sp, + color = + if (isDarkTheme) Color(0xFF8E8E8E) + else Color(0xFF999999), + fontFamily = FontFamily.Monospace + ) + } else { + logs.take(10).forEach { log -> + Text( + text = log, + fontSize = 12.sp, + color = textColor, + fontFamily = FontFamily.Monospace, + lineHeight = 18.sp, + modifier = Modifier.padding(vertical = 2.dp) + ) + } + + if (logs.size > 10) { + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "... и ещё ${logs.size - 10} логов", + fontSize = 11.sp, + color = + if (isDarkTheme) Color(0xFF8E8E8E) + else Color(0xFF999999), + fontStyle = + androidx.compose.ui.text.font + .FontStyle.Italic + ) + } + } + } + } + + Spacer(modifier = Modifier.height(8.dp)) + } } diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt.bak b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt.bak new file mode 100644 index 0000000..da6c493 --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt.bak @@ -0,0 +1,2469 @@ +package com.rosetta.messenger.ui.chats + +import android.content.Context +import androidx.compose.animation.* +import androidx.compose.animation.core.* +import androidx.compose.foundation.* +import androidx.compose.foundation.gestures.detectHorizontalDragGestures +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material.icons.outlined.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.airbnb.lottie.compose.* +import com.rosetta.messenger.R +import com.rosetta.messenger.data.RecentSearchesManager +import com.rosetta.messenger.network.ProtocolManager +import com.rosetta.messenger.network.ProtocolState +import com.rosetta.messenger.ui.components.AppleEmojiText +import com.rosetta.messenger.ui.onboarding.PrimaryBlue +import java.text.SimpleDateFormat +import java.util.* +import kotlinx.coroutines.launch + +@Immutable +data class Chat( + val id: String, + val name: String, + val lastMessage: String, + val lastMessageTime: Date, + val unreadCount: Int = 0, + val isOnline: Boolean = false, + val publicKey: String, + val isSavedMessages: Boolean = false, + val isPinned: Boolean = false +) + +// Avatar colors matching React Native app (Mantine inspired) +// Light theme colors (background lighter, text darker) +private val avatarColorsLight = + listOf( + Color(0xFF1971c2) to Color(0xFFd0ebff), // blue + Color(0xFF0c8599) to Color(0xFFc5f6fa), // cyan + Color(0xFF9c36b5) to Color(0xFFeebefa), // grape + Color(0xFF2f9e44) to Color(0xFFd3f9d8), // green + Color(0xFF4263eb) to Color(0xFFdbe4ff), // indigo + Color(0xFF5c940d) to Color(0xFFe9fac8), // lime + Color(0xFFd9480f) to Color(0xFFffe8cc), // orange + Color(0xFFc2255c) to Color(0xFFffdeeb), // pink + Color(0xFFe03131) to Color(0xFFffe0e0), // red + Color(0xFF099268) to Color(0xFFc3fae8), // teal + Color(0xFF6741d9) to Color(0xFFe5dbff) // violet + ) + +// Dark theme colors (background darker, text lighter) +private val avatarColorsDark = + listOf( + Color(0xFF7dd3fc) to Color(0xFF2d3548), // blue + Color(0xFF67e8f9) to Color(0xFF2d4248), // cyan + Color(0xFFd8b4fe) to Color(0xFF39334c), // grape + Color(0xFF86efac) to Color(0xFF2d3f32), // green + Color(0xFFa5b4fc) to Color(0xFF333448), // indigo + Color(0xFFbef264) to Color(0xFF383f2d), // lime + Color(0xFFfdba74) to Color(0xFF483529), // orange + Color(0xFFf9a8d4) to Color(0xFF482d3d), // pink + Color(0xFFfca5a5) to Color(0xFF482d2d), // red + Color(0xFF5eead4) to Color(0xFF2d4340), // teal + Color(0xFFc4b5fd) to Color(0xFF3a334c) // violet + ) + +// Cache для цветов аватаров +data class AvatarColors(val textColor: Color, val backgroundColor: Color) + +private val avatarColorCache = mutableMapOf() + +fun getAvatarColor(name: String, isDarkTheme: Boolean): AvatarColors { + val cacheKey = "${name}_${if (isDarkTheme) "dark" else "light"}" + return avatarColorCache.getOrPut(cacheKey) { + val colors = if (isDarkTheme) avatarColorsDark else avatarColorsLight + val index = + name.hashCode().mod(colors.size).let { + if (it < 0) it + colors.size else it + } + val (textColor, bgColor) = colors[index] + AvatarColors(textColor, bgColor) + } +} + +// Cache для инициалов +private val initialsCache = mutableMapOf() + +fun getInitials(name: String): String { + return initialsCache.getOrPut(name) { + val words = name.trim().split(Regex("\\s+")).filter { it.isNotEmpty() } + when { + words.isEmpty() -> "??" + words.size == 1 -> words[0].take(2).uppercase() + else -> "${words[0].first()}${words[1].first()}".uppercase() + } + } +} + +// Get avatar text from public key (first 2 chars) +fun getAvatarText(publicKey: String): String { + return publicKey.take(2).uppercase() +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ChatsListScreen( + isDarkTheme: Boolean, + accountName: String, + accountPhone: String, + accountPublicKey: String, + accountPrivateKey: String = "", + privateKeyHash: String = "", + onToggleTheme: () -> Unit, + onProfileClick: () -> Unit, + onNewGroupClick: () -> Unit, + onContactsClick: () -> Unit, + onCallsClick: () -> Unit, + onSavedMessagesClick: () -> Unit, + onSettingsClick: () -> Unit, + onInviteFriendsClick: () -> Unit, + onSearchClick: () -> Unit, + onNewChat: () -> Unit, + onUserSelect: (com.rosetta.messenger.network.SearchUser) -> Unit = {}, + chatsViewModel: ChatsListViewModel = androidx.lifecycle.viewmodel.compose.viewModel(), + onLogout: () -> Unit +) { + // Theme transition state + var hasInitialized by remember { mutableStateOf(false) } + + LaunchedEffect(Unit) { hasInitialized = true } + + val view = androidx.compose.ui.platform.LocalView.current + val context = androidx.compose.ui.platform.LocalContext.current + val focusManager = androidx.compose.ui.platform.LocalFocusManager.current + val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed) + val scope = rememberCoroutineScope() + + // 🔥 ВСЕГДА закрываем клавиатуру при появлении ChatsListScreen + // Используем DisposableEffect чтобы срабатывало при каждом появлении экрана + DisposableEffect(Unit) { + // Закрываем клавиатуру сразу + val imm = + context.getSystemService(android.content.Context.INPUT_METHOD_SERVICE) as + android.view.inputmethod.InputMethodManager + imm.hideSoftInputFromWindow(view.windowToken, 0) + focusManager.clearFocus() + + onDispose {} + } + + // Дополнительно закрываем клавиатуру с небольшой задержкой (на случай если она появляется + // после) + LaunchedEffect(Unit) { + kotlinx.coroutines.delay(100) + val imm = + context.getSystemService(android.content.Context.INPUT_METHOD_SERVICE) as + android.view.inputmethod.InputMethodManager + imm.hideSoftInputFromWindow(view.windowToken, 0) + focusManager.clearFocus() + } + + // Update status bar and completely hide navigation bar + LaunchedEffect(isDarkTheme) { + if (!view.isInEditMode) { + val window = (view.context as android.app.Activity).window + val insetsController = + androidx.core.view.WindowCompat.getInsetsController(window, view) + + // Status bar + insetsController.isAppearanceLightStatusBars = !isDarkTheme + window.statusBarColor = android.graphics.Color.TRANSPARENT + + // Completely hide navigation bar + insetsController.hide( + androidx.core.view.WindowInsetsCompat.Type.navigationBars() + ) + insetsController.systemBarsBehavior = + androidx.core.view.WindowInsetsControllerCompat + .BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + } + } + + // Colors - instant change, no animation - 🔥 КЭШИРУЕМ для производительности + val backgroundColor = + remember(isDarkTheme) { if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFFFFFFF) } + val drawerBackgroundColor = + remember(isDarkTheme) { if (isDarkTheme) Color(0xFF212121) else Color(0xFFFFFFFF) } + val textColor = remember(isDarkTheme) { if (isDarkTheme) Color.White else Color.Black } + val secondaryTextColor = + remember(isDarkTheme) { if (isDarkTheme) Color(0xFF8E8E8E) else Color(0xFF8E8E93) } + + // Protocol connection state + val protocolState by ProtocolManager.state.collectAsState() + + // 🔥 Пользователи, которые сейчас печатают + val typingUsers by ProtocolManager.typingUsers.collectAsState() + + // Load dialogs when account is available + LaunchedEffect(accountPublicKey, accountPrivateKey) { + if (accountPublicKey.isNotEmpty() && accountPrivateKey.isNotEmpty()) { + chatsViewModel.setAccount(accountPublicKey, accountPrivateKey) + // Устанавливаем аккаунт для RecentSearchesManager + RecentSearchesManager.setAccount(accountPublicKey) + // 🔥 КРИТИЧНО: Инициализируем MessageRepository для обработки входящих + // сообщений + ProtocolManager.initializeAccount(accountPublicKey, accountPrivateKey) + } + } + + // Status dialog state + var showStatusDialog by remember { mutableStateOf(false) } + val debugLogs by ProtocolManager.debugLogs.collectAsState() + + // 📱 FCM токен диалог + var showFcmDialog by remember { mutableStateOf(false) } + + // 📬 Requests screen state + var showRequestsScreen by remember { mutableStateOf(false) } + + // 🔥 Используем rememberSaveable чтобы сохранить состояние при навигации + // Header сразу visible = true, без анимации при возврате из чата + var visible by rememberSaveable { mutableStateOf(true) } + + // Confirmation dialogs state + var dialogToDelete by remember { mutableStateOf(null) } + var dialogToBlock by remember { mutableStateOf(null) } + var dialogToUnblock by remember { mutableStateOf(null) } + + // Trigger для обновления статуса блокировки + var blocklistUpdateTrigger by remember { mutableStateOf(0) } + + // Dev console dialog - commented out for now + /* + if (showDevConsole) { + AlertDialog( + onDismissRequest = { showDevConsole = false }, + title = { Text("Dev Console", fontWeight = FontWeight.Bold) }, + text = { Text("Dev console temporarily disabled") }, + confirmButton = { + Button(onClick = { showDevConsole = false }) { + Text("Close") + } + }, + containerColor = if (isDarkTheme) Color(0xFF212121) else Color.White + ) + } + */ + + // Status dialog + if (showStatusDialog) { + AlertDialog( + onDismissRequest = { showStatusDialog = false }, + title = { + Text( + "Connection Status", + fontWeight = FontWeight.Bold, + color = textColor + ) + }, + text = { + Column(modifier = Modifier.fillMaxWidth()) { + // Status indicator + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier.fillMaxWidth() + .padding(bottom = 12.dp) + ) { + Box( + modifier = + Modifier.size(12.dp) + .clip(CircleShape) + .background( + when (protocolState + ) { + ProtocolState + .AUTHENTICATED -> + Color( + 0xFF4CAF50 + ) + ProtocolState + .CONNECTING, + ProtocolState + .CONNECTED, + ProtocolState + .HANDSHAKING -> + Color( + 0xFFFFC107 + ) + ProtocolState + .DISCONNECTED -> + Color( + 0xFFF44336 + ) + } + ) + ) + Spacer(modifier = Modifier.width(12.dp)) + Text( + text = + when (protocolState) { + ProtocolState + .DISCONNECTED -> + "Disconnected" + ProtocolState.CONNECTING -> + "Connecting..." + ProtocolState.CONNECTED -> + "Connected" + ProtocolState.HANDSHAKING -> + "Authenticating..." + ProtocolState + .AUTHENTICATED -> + "Authenticated" + }, + fontSize = 16.sp, + fontWeight = FontWeight.Medium, + color = textColor + ) + } + } + }, + confirmButton = { + Button( + onClick = { showStatusDialog = false }, + colors = + ButtonDefaults.buttonColors( + containerColor = PrimaryBlue + ) + ) { Text("Close", color = Color.White) } + }, + containerColor = if (isDarkTheme) Color(0xFF212121) else Color.White + ) + } + + // Simple background + Box(modifier = Modifier.fillMaxSize().background(backgroundColor).navigationBarsPadding()) { + ModalNavigationDrawer( + drawerState = drawerState, + drawerContent = { + ModalDrawerSheet( + drawerContainerColor = Color.Transparent, + windowInsets = + WindowInsets( + 0 + ), // 🎨 Убираем системные отступы - drawer идет до + // верха + modifier = Modifier.width(300.dp) + ) { + Column( + modifier = + Modifier.fillMaxSize() + .background(drawerBackgroundColor) + ) { + // ═══════════════════════════════════════════════════════════ + // 🎨 DRAWER HEADER - Avatar and status + // ═══════════════════════════════════════════════════════════ + val headerColor = + if (isDarkTheme) { + Color(0xFF2C5282) + } else { + Color(0xFF4A90D9) + } + + Box( + modifier = + Modifier.fillMaxWidth() + .background( + color = headerColor + ) + .statusBarsPadding() // 🎨 + // Контент начинается + // после status bar + .padding( + top = 16.dp, + start = 20.dp, + end = 20.dp, + bottom = 20.dp + ) + ) { + Column { + // Avatar with border + val avatarColors = + getAvatarColor( + accountPublicKey, + isDarkTheme + ) + Box( + modifier = + Modifier.size(72.dp) + .clip( + CircleShape + ) + .background( + Color.White + .copy( + alpha = + 0.2f + ) + ) + .padding( + 3.dp + ) + .clip( + CircleShape + ) + .background( + avatarColors + .backgroundColor + ), + contentAlignment = + Alignment.Center + ) { + Text( + text = + getAvatarText( + accountPublicKey + ), + fontSize = 26.sp, + fontWeight = + FontWeight + .Bold, + color = + avatarColors + .textColor + ) + } + + Spacer( + modifier = + Modifier.height( + 14.dp + ) + ) + + // Public key (username style) - + // clickable для копирования + val truncatedKey = + if (accountPublicKey + .length > 16 + ) { + "${accountPublicKey.take(8)}...${accountPublicKey.takeLast(6)}" + } else accountPublicKey + + val context = + androidx.compose.ui.platform + .LocalContext + .current + var showCopiedToast by remember { + mutableStateOf(false) + } + + // Плавная замена текста - + // ускоренная анимация + AnimatedContent( + targetState = + showCopiedToast, + transitionSpec = { + fadeIn( + animationSpec = + tween( + 150 + ) + ) togetherWith + fadeOut( + animationSpec = + tween( + 150 + ) + ) + }, + label = "copiedAnimation" + ) { isCopied -> + Text( + text = + if (isCopied + ) + "Copied!" + else + truncatedKey, + fontSize = 16.sp, + fontWeight = + FontWeight + .SemiBold, + color = Color.White, + fontStyle = + if (isCopied + ) + androidx.compose + .ui + .text + .font + .FontStyle + .Italic + else + androidx.compose + .ui + .text + .font + .FontStyle + .Normal, + modifier = + Modifier + .clickable { + if (!showCopiedToast + ) { + // Копируем публичный ключ + val clipboard = + context.getSystemService( + android.content + .Context + .CLIPBOARD_SERVICE + ) as + android.content.ClipboardManager + val clip = + android.content + .ClipData + .newPlainText( + "Public Key", + accountPublicKey + ) + clipboard + .setPrimaryClip( + clip + ) + showCopiedToast = + true + } + } + ) + } + + // Автоматически возвращаем обратно + // через 1.5 секунды + if (showCopiedToast) { + LaunchedEffect(Unit) { + kotlinx.coroutines + .delay(1500) + showCopiedToast = + false + } + } + + Spacer( + modifier = + Modifier.height( + 6.dp + ) + ) + + // Username display + if (accountName.isNotEmpty()) { + Text( + text = + "@$accountName", + fontSize = 13.sp, + color = + Color.White + .copy( + alpha = + 0.85f + ) + ) + } + } + } + + // ═══════════════════════════════════════════════════════════ + // 📱 MENU ITEMS + // ═══════════════════════════════════════════════════════════ + Column( + modifier = + Modifier.fillMaxWidth() + .weight(1f) + .verticalScroll( + rememberScrollState() + ) + .padding(vertical = 8.dp) + ) { + val menuIconColor = + textColor.copy(alpha = 0.6f) + + // 👤 Profile Section + DrawerMenuItemEnhanced( + icon = Icons.Outlined.Person, + text = "My Profile", + iconColor = menuIconColor, + textColor = textColor, + onClick = { + scope.launch { + drawerState.close() + } + onProfileClick() + } + ) + + // 📖 Saved Messages + DrawerMenuItemEnhanced( + icon = Icons.Outlined.Bookmark, + text = "Saved Messages", + iconColor = menuIconColor, + textColor = textColor, + onClick = { + scope.launch { + drawerState.close() + // Ждём завершения + // анимации закрытия + // drawer + kotlinx.coroutines + .delay(250) + onSavedMessagesClick() + } + } + ) + + DrawerDivider(isDarkTheme) + + // 👥 Contacts + DrawerMenuItemEnhanced( + icon = Icons.Outlined.Contacts, + text = "Contacts", + iconColor = menuIconColor, + textColor = textColor, + onClick = { + scope.launch { + drawerState.close() + } + onContactsClick() + } + ) + + // 📞 Calls + DrawerMenuItemEnhanced( + icon = Icons.Outlined.Call, + text = "Calls", + iconColor = menuIconColor, + textColor = textColor, + onClick = { + scope.launch { + drawerState.close() + } + onCallsClick() + } + ) + + // ➕ Invite Friends + DrawerMenuItemEnhanced( + icon = Icons.Outlined.PersonAdd, + text = "Invite Friends", + iconColor = menuIconColor, + textColor = textColor, + onClick = { + scope.launch { + drawerState.close() + } + onInviteFriendsClick() + } + ) + + DrawerDivider(isDarkTheme) + + // 🔔 FCM Logs + DrawerMenuItemEnhanced( + icon = Icons.Outlined.Notifications, + text = "FCM Token Logs", + iconColor = menuIconColor, + textColor = textColor, + onClick = { + scope.launch { + drawerState.close() + } + showFcmDialog = true + } + ) + + // ⚙️ Settings + DrawerMenuItemEnhanced( + icon = Icons.Outlined.Settings, + text = "Settings", + iconColor = menuIconColor, + textColor = textColor, + onClick = { + scope.launch { + drawerState.close() + } + onSettingsClick() + } + ) + + // 🌓 Theme Toggle + DrawerMenuItemEnhanced( + icon = + if (isDarkTheme) + Icons.Outlined + .LightMode + else + Icons.Outlined + .DarkMode, + text = + if (isDarkTheme) + "Light Mode" + else "Dark Mode", + iconColor = menuIconColor, + textColor = textColor, + onClick = { onToggleTheme() } + ) + + // ❓ Help + DrawerMenuItemEnhanced( + icon = Icons.Outlined.HelpOutline, + text = "Help & FAQ", + iconColor = menuIconColor, + textColor = textColor, + onClick = { + scope.launch { + drawerState.close() + } + // TODO: Add help screen + // navigation + } + ) + } + + // ═══════════════════════════════════════════════════════════ + // 🚪 FOOTER - Logout & Version + // ═══════════════════════════════════════════════════════════ + Column(modifier = Modifier.fillMaxWidth()) { + Divider( + color = + if (isDarkTheme) + Color(0xFF2A2A2A) + else Color(0xFFE8E8E8), + thickness = 0.5.dp + ) + + // Logout + DrawerMenuItemEnhanced( + icon = Icons.Outlined.Logout, + text = "Log Out", + iconColor = Color(0xFFFF4444), + textColor = Color(0xFFFF4444), + onClick = { + scope.launch { + drawerState.close() + kotlinx.coroutines + .delay(150) + onLogout() + } + } + ) + + // Version info + Box( + modifier = + Modifier.fillMaxWidth() + .padding( + horizontal = + 20.dp, + vertical = + 12.dp + ), + contentAlignment = + Alignment.CenterStart + ) { + Text( + text = "Rosetta v1.0.0", + fontSize = 12.sp, + color = + if (isDarkTheme) + Color( + 0xFF666666 + ) + else + Color( + 0xFF999999 + ) + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + } + } + } + } + ) { + Scaffold( + topBar = { + AnimatedVisibility( + visible = visible, + enter = + fadeIn(tween(300)) + + expandVertically( + animationSpec = + tween( + 300, + easing = + FastOutSlowInEasing + ) + ), + exit = + fadeOut(tween(200)) + + shrinkVertically( + animationSpec = tween(200) + ) + ) { + key(isDarkTheme, showRequestsScreen) { + TopAppBar( + navigationIcon = { + if (showRequestsScreen) { + // Back button for + // Requests + IconButton( + onClick = { + showRequestsScreen = + false + } + ) { + Icon( + Icons.Default + .ArrowBack, + contentDescription = + "Back", + tint = + PrimaryBlue + ) + } + } else { + // Menu button for + // main screen + IconButton( + onClick = { + scope + .launch { + drawerState + .open() + } + } + ) { + Icon( + Icons.Default + .Menu, + contentDescription = + "Menu", + tint = + textColor + .copy( + alpha = + 0.6f + ) + ) + } + } + }, + title = { + if (showRequestsScreen) { + // Requests title + Text( + "Requests", + fontWeight = + FontWeight + .Bold, + fontSize = + 20.sp, + color = + textColor + ) + } else { + // Rosetta title + // with status + Row( + verticalAlignment = + Alignment + .CenterVertically + ) { + Text( + "Rosetta", + fontWeight = + FontWeight + .Bold, + fontSize = + 20.sp, + color = + textColor + ) + Spacer( + modifier = + Modifier.width( + 8.dp + ) + ) + Box( + modifier = + Modifier.size( + 10.dp + ) + .clip( + CircleShape + ) + .background( + when (protocolState + ) { + ProtocolState + .AUTHENTICATED -> + Color( + 0xFF4CAF50 + ) + ProtocolState + .CONNECTING, + ProtocolState + .CONNECTED, + ProtocolState + .HANDSHAKING -> + Color( + 0xFFFFC107 + ) + ProtocolState + .DISCONNECTED -> + Color( + 0xFFF44336 + ) + } + ) + .clickable { + showStatusDialog = + true + } + ) + } + } + }, + actions = { + // Search only on main + // screen + if (!showRequestsScreen) { + IconButton( + onClick = { + if (protocolState == + ProtocolState + .AUTHENTICATED + ) { + onSearchClick() + } + }, + enabled = + protocolState == + ProtocolState + .AUTHENTICATED + ) { + Icon( + Icons.Default + .Search, + contentDescription = + "Search", + tint = + if (protocolState == + ProtocolState + .AUTHENTICATED + ) + textColor + .copy( + alpha = + 0.6f + ) + else + textColor + .copy( + alpha = + 0.5f + ) + ) + } + } + }, + colors = + TopAppBarDefaults + .topAppBarColors( + containerColor = + backgroundColor, + scrolledContainerColor = + backgroundColor, + navigationIconContentColor = + textColor, + titleContentColor = + textColor, + actionIconContentColor = + textColor + ) + ) + } + } + }, + floatingActionButton = { + AnimatedVisibility( + visible = false, // Hidden for now + enter = + fadeIn(tween(500, delayMillis = 300)) + + scaleIn( + initialScale = 0.5f, + animationSpec = + tween( + 500, + delayMillis = + 300 + ) + ) + ) { + FloatingActionButton( + onClick = onNewChat, + containerColor = PrimaryBlue, + contentColor = Color.White, + shape = CircleShape + ) { + Icon( + Icons.Default.Edit, + contentDescription = "New Chat" + ) + } + } + }, + containerColor = backgroundColor + ) { paddingValues -> + // Main content + Box(modifier = Modifier.fillMaxSize().padding(paddingValues)) { + // � Используем комбинированное состояние для атомарного + // обновления + // Это предотвращает "дергание" UI когда dialogs и requests + // обновляются + // независимо + val chatsState by chatsViewModel.chatsState.collectAsState() + val requests = chatsState.requests + val requestsCount = chatsState.requestsCount + + // 🔥 ИСПРАВЛЕНИЕ МЕРЦАНИЯ: Запоминаем, что контент УЖЕ был + // показан + // Это предотвращает показ EmptyState при временных пустых + // обновлениях + var hasShownContent by rememberSaveable { + mutableStateOf(false) + } + if (chatsState.hasContent) { + hasShownContent = true + } + + // 🎯 Показываем Empty State только если контент НИКОГДА не + // показывался + val shouldShowEmptyState = + chatsState.isEmpty && !hasShownContent + + // 🎬 Animated content transition between main list and + // requests + AnimatedContent( + targetState = showRequestsScreen, + transitionSpec = { + fadeIn( + animationSpec = tween(200) + ) togetherWith + fadeOut(animationSpec = tween(150)) + }, + label = "RequestsTransition" + ) { isRequestsScreen -> + if (isRequestsScreen) { + // 📬 Show Requests Screen + RequestsScreen( + requests = requests, + isDarkTheme = isDarkTheme, + onBack = { + showRequestsScreen = false + }, + onRequestClick = { request -> + showRequestsScreen = false + val user = + chatsViewModel + .dialogToSearchUser( + request + ) + onUserSelect(user) + } + ) + } else if (shouldShowEmptyState) { + // 🔥 Empty state - показываем только если + // контент НЕ был показан ранее + EmptyChatsState( + isDarkTheme = isDarkTheme, + modifier = Modifier.fillMaxSize() + ) + } else { + // Show dialogs list + val dividerColor = + if (isDarkTheme) Color(0xFF3A3A3A) + else Color(0xFFE8E8E8) + // 🔥 Берем dialogs из chatsState для + // консистентности + val currentDialogs = chatsState.dialogs + + LazyColumn( + modifier = Modifier.fillMaxSize() + ) { + // � FCM Logs Section + val fcmLogs = + com.rosetta.messenger + .MainActivity + .fcmLogs + if (fcmLogs.isNotEmpty()) { + item( + key = + "fcm_logs_section" + ) { + FcmLogsSection( + logs = + fcmLogs, + isDarkTheme = + isDarkTheme, + onClearLogs = { + com.rosetta + .messenger + .MainActivity + .clearFcmLogs() + } + ) + Divider( + color = + dividerColor, + thickness = + 0.5.dp + ) + } + } + + // �📬 Requests Section + if (requestsCount > 0) { + item( + key = + "requests_section" + ) { + RequestsSection( + count = + requestsCount, + isDarkTheme = + isDarkTheme, + onClick = { + showRequestsScreen = + true + } + ) + Divider( + color = + dividerColor, + thickness = + 0.5.dp + ) + } + } + + items( + currentDialogs, + key = { it.opponentKey } + ) { dialog -> + val isSavedMessages = + dialog.opponentKey == + accountPublicKey + // Check if user is blocked + var isBlocked by remember { + mutableStateOf( + false + ) + } + LaunchedEffect( + dialog.opponentKey, + blocklistUpdateTrigger + ) { + isBlocked = + chatsViewModel + .isUserBlocked( + dialog.opponentKey + ) + } + + Column { + SwipeableDialogItem( + dialog = + dialog, + isDarkTheme = + isDarkTheme, + isTyping = + typingUsers + .contains( + dialog.opponentKey + ), + isBlocked = + isBlocked, + isSavedMessages = + isSavedMessages, + onClick = { + val user = + chatsViewModel + .dialogToSearchUser( + dialog + ) + onUserSelect( + user + ) + }, + onDelete = { + dialogToDelete = + dialog + }, + onBlock = { + dialogToBlock = + dialog + }, + onUnblock = { + dialogToUnblock = + dialog + } + ) + + // 🔥 СЕПАРАТОР - + // линия разделения + // между диалогами + Divider( + modifier = + Modifier.padding( + start = + 84.dp + ), + color = + dividerColor, + thickness = + 0.5.dp + ) + } + } + } + } + } // Close AnimatedContent + + // Console button removed + } + } + } // Close ModalNavigationDrawer + + // 🔥 Confirmation Dialogs + + // Delete Dialog Confirmation + dialogToDelete?.let { dialog -> + AlertDialog( + onDismissRequest = { dialogToDelete = null }, + containerColor = + if (isDarkTheme) Color(0xFF2C2C2E) else Color.White, + title = { + Text( + "Delete Chat", + fontWeight = FontWeight.Bold, + color = textColor + ) + }, + text = { + Text( + "Are you sure you want to delete this chat? This action cannot be undone.", + color = secondaryTextColor + ) + }, + confirmButton = { + TextButton( + onClick = { + val opponentKey = dialog.opponentKey + dialogToDelete = null + scope.launch { + chatsViewModel.deleteDialog( + opponentKey + ) + } + } + ) { Text("Delete", color = Color(0xFFFF3B30)) } + }, + dismissButton = { + TextButton(onClick = { dialogToDelete = null }) { + Text("Cancel", color = PrimaryBlue) + } + } + ) + } + + // Block Dialog Confirmation + dialogToBlock?.let { dialog -> + AlertDialog( + onDismissRequest = { dialogToBlock = null }, + containerColor = + if (isDarkTheme) Color(0xFF2C2C2E) else Color.White, + title = { + Text( + "Block ${dialog.opponentTitle.ifEmpty { "User" }}", + fontWeight = FontWeight.Bold, + color = textColor + ) + }, + text = { + Text( + "Are you sure you want to block this user? They won't be able to send you messages.", + color = secondaryTextColor + ) + }, + confirmButton = { + TextButton( + onClick = { + val opponentKey = dialog.opponentKey + dialogToBlock = null + scope.launch { + chatsViewModel.blockUser( + opponentKey + ) + blocklistUpdateTrigger++ + } + } + ) { Text("Block", color = Color(0xFFFF3B30)) } + }, + dismissButton = { + TextButton(onClick = { dialogToBlock = null }) { + Text("Cancel", color = PrimaryBlue) + } + } + ) + } + + // Unblock Dialog Confirmation + dialogToUnblock?.let { dialog -> + AlertDialog( + onDismissRequest = { dialogToUnblock = null }, + containerColor = + if (isDarkTheme) Color(0xFF2C2C2E) else Color.White, + title = { + Text( + "Unblock ${dialog.opponentTitle.ifEmpty { "User" }}", + fontWeight = FontWeight.Bold, + color = textColor + ) + }, + text = { + Text( + "Are you sure you want to unblock this user? They will be able to send you messages again.", + color = secondaryTextColor + ) + }, + confirmButton = { + TextButton( + onClick = { + val opponentKey = dialog.opponentKey + dialogToUnblock = null + scope.launch { + chatsViewModel.unblockUser( + opponentKey + ) + blocklistUpdateTrigger++ + } + } + ) { Text("Unblock", color = PrimaryBlue) } + }, + dismissButton = { + TextButton(onClick = { dialogToUnblock = null }) { + Text("Cancel", color = Color(0xFF8E8E93)) + } + } + ) + } + } // Close Box +} + +@Composable +private fun EmptyChatsState(isDarkTheme: Boolean, modifier: Modifier = Modifier) { + val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666) + + // Lottie animation + val composition by rememberLottieComposition(LottieCompositionSpec.RawRes(R.raw.letter)) + val progress by animateLottieCompositionAsState(composition = composition, iterations = 1) + + Column( + modifier = modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + // Lottie animation + LottieAnimation( + composition = composition, + progress = { progress }, + modifier = Modifier.size(150.dp) + ) + + Spacer(modifier = Modifier.height(24.dp)) + + Text( + text = "No conversations yet", + fontSize = 18.sp, + fontWeight = FontWeight.SemiBold, + color = secondaryTextColor, + textAlign = TextAlign.Center + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = "Start a new conversation to get started", + fontSize = 15.sp, + color = secondaryTextColor, + textAlign = TextAlign.Center + ) + } +} + +// Chat item for list +@Composable +fun ChatItem(chat: Chat, isDarkTheme: Boolean, onClick: () -> Unit) { + val textColor = if (isDarkTheme) Color.White else Color.Black + val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666) + val dividerColor = if (isDarkTheme) Color(0xFF3A3A3A) else Color(0xFFE8E8E8) + + val avatarColors = getAvatarColor(chat.publicKey, isDarkTheme) + val avatarText = getAvatarText(chat.publicKey) + + Column { + Row( + modifier = + Modifier.fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // Avatar + Box( + modifier = + Modifier.size(56.dp) + .clip(CircleShape) + .background(avatarColors.backgroundColor), + contentAlignment = Alignment.Center + ) { + Text( + text = avatarText, + fontSize = 20.sp, + fontWeight = FontWeight.SemiBold, + color = avatarColors.textColor + ) + + // Online indicator + if (chat.isOnline) { + Box( + modifier = + Modifier.align(Alignment.BottomEnd) + .offset(x = 2.dp, y = 2.dp) + .size(16.dp) + .clip(CircleShape) + .background( + if (isDarkTheme) + Color(0xFF1A1A1A) + else Color.White + ) + .padding(2.dp) + .clip(CircleShape) + .background(Color(0xFF4CAF50)) + ) + } + } + + Spacer(modifier = Modifier.width(12.dp)) + + Column(modifier = Modifier.weight(1f)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = chat.name, + fontSize = 16.sp, + fontWeight = FontWeight.SemiBold, + color = textColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) + + Row(verticalAlignment = Alignment.CenterVertically) { + // Read status + Icon( + Icons.Default.DoneAll, + contentDescription = null, + tint = PrimaryBlue, + modifier = Modifier.size(16.dp) + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = formatTime(chat.lastMessageTime), + fontSize = 13.sp, + color = secondaryTextColor + ) + } + } + + Spacer(modifier = Modifier.height(4.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + // 🔥 Используем AppleEmojiText для отображения эмодзи + AppleEmojiText( + text = chat.lastMessage, + fontSize = 14.sp, + color = secondaryTextColor, + maxLines = 1, + overflow = android.text.TextUtils.TruncateAt.END, + modifier = Modifier.weight(1f) + ) + + Row(verticalAlignment = Alignment.CenterVertically) { + // Pin icon + if (chat.isPinned) { + Icon( + Icons.Default.PushPin, + contentDescription = "Pinned", + tint = + secondaryTextColor.copy( + alpha = 0.6f + ), + modifier = + Modifier.size(16.dp) + .padding(end = 4.dp) + ) + } + + // Unread badge + if (chat.unreadCount > 0) { + Box( + modifier = + Modifier.clip(CircleShape) + .background( + PrimaryBlue + ) + .padding( + horizontal = + 8.dp, + vertical = + 2.dp + ), + contentAlignment = Alignment.Center + ) { + Text( + text = + if (chat.unreadCount > + 99 + ) + "99+" + else + chat.unreadCount + .toString(), + fontSize = 12.sp, + fontWeight = + FontWeight.SemiBold, + color = Color.White + ) + } + } + } + } + } + } + + Divider( + modifier = Modifier.padding(start = 84.dp), + color = dividerColor, + thickness = 0.5.dp + ) + } +} + +// Cache для SimpleDateFormat - создание дорогостоящее +private val timeFormatCache = + java.lang.ThreadLocal.withInitial { SimpleDateFormat("HH:mm", Locale.getDefault()) } +private val weekFormatCache = + java.lang.ThreadLocal.withInitial { SimpleDateFormat("EEE", Locale.getDefault()) } +private val monthFormatCache = + java.lang.ThreadLocal.withInitial { SimpleDateFormat("MMM d", Locale.getDefault()) } +private val yearFormatCache = + java.lang.ThreadLocal.withInitial { SimpleDateFormat("dd.MM.yy", Locale.getDefault()) } + +private fun formatTime(date: Date): String { + val now = Calendar.getInstance() + val messageTime = Calendar.getInstance().apply { time = date } + + return when { + now.get(Calendar.DATE) == messageTime.get(Calendar.DATE) -> { + timeFormatCache.get()?.format(date) ?: "" + } + now.get(Calendar.DATE) - messageTime.get(Calendar.DATE) == 1 -> { + "Yesterday" + } + now.get(Calendar.WEEK_OF_YEAR) == messageTime.get(Calendar.WEEK_OF_YEAR) -> { + weekFormatCache.get()?.format(date) ?: "" + } + now.get(Calendar.YEAR) == messageTime.get(Calendar.YEAR) -> { + monthFormatCache.get()?.format(date) ?: "" + } + else -> { + yearFormatCache.get()?.format(date) ?: "" + } + } +} + +/** Элемент меню в боковом drawer */ +@Composable +fun DrawerMenuItem( + icon: androidx.compose.ui.graphics.vector.ImageVector, + text: String, + isDarkTheme: Boolean, + isDestructive: Boolean = false, + onClick: () -> Unit +) { + val textColor = + if (isDestructive) { + Color(0xFFFF3B30) + } else { + if (isDarkTheme) Color.White else Color.Black + } + + val backgroundColor = if (isDarkTheme) Color(0xFF2C2C2E) else Color(0xFFF2F2F7) + + Row( + modifier = + Modifier.fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 4.dp) + .clip(RoundedCornerShape(12.dp)) + .background(backgroundColor) + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = textColor.copy(alpha = 0.6f), + modifier = Modifier.size(24.dp) + ) + + Spacer(modifier = Modifier.width(16.dp)) + + Text( + text = text, + fontSize = 16.sp, + color = textColor, + fontWeight = FontWeight.Medium + ) + } +} + +/** + * 🔥 Swipeable wrapper для DialogItem с кнопками Block и Delete Свайп влево показывает действия + * (как в React Native версии) + */ +@Composable +fun SwipeableDialogItem( + dialog: DialogUiModel, + isDarkTheme: Boolean, + isTyping: Boolean = false, + isBlocked: Boolean = false, + isSavedMessages: Boolean = false, + onClick: () -> Unit, + onDelete: () -> Unit = {}, + onBlock: () -> Unit = {}, + onUnblock: () -> Unit = {} +) { + val backgroundColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFFFFFFF) + var offsetX by remember { mutableStateOf(0f) } + val swipeWidthDp = if (isSavedMessages) 80.dp else 160.dp + val density = androidx.compose.ui.platform.LocalDensity.current + val swipeWidthPx = with(density) { swipeWidthDp.toPx() } + + // Фиксированная высота элемента (как в DialogItem) + val itemHeight = 80.dp + + // Анимация возврата + val animatedOffsetX by + animateFloatAsState( + targetValue = offsetX, + animationSpec = spring(dampingRatio = 0.7f, stiffness = 400f), + label = "swipeOffset" + ) + + Box(modifier = Modifier.fillMaxWidth().height(itemHeight).clipToBounds()) { + // 1. КНОПКИ - позиционированы справа, всегда видны при свайпе + Row( + modifier = + Modifier.align(Alignment.CenterEnd) + .height(itemHeight) + .width(swipeWidthDp) + ) { + // Кнопка Block/Unblock (только если не Saved Messages) + if (!isSavedMessages) { + Box( + modifier = + Modifier.width(80.dp) + .fillMaxHeight() + .background( + if (isBlocked) Color(0xFF4CAF50) + else Color(0xFFFF6B6B) + ) + .clickable { + if (isBlocked) onUnblock() + else onBlock() + offsetX = 0f + }, + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Icon( + imageVector = + if (isBlocked) + Icons.Default.LockOpen + else Icons.Default.Block, + contentDescription = + if (isBlocked) "Unblock" + else "Block", + tint = Color.White, + modifier = Modifier.size(22.dp) + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = + if (isBlocked) "Unblock" + else "Block", + color = Color.White, + fontSize = 12.sp, + fontWeight = FontWeight.SemiBold + ) + } + } + } + + // Кнопка Delete + Box( + modifier = + Modifier.width(80.dp) + .fillMaxHeight() + .background(PrimaryBlue) + .clickable { + // Закрываем свайп мгновенно перед удалением + offsetX = 0f + onDelete() + }, + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = "Delete", + tint = Color.White, + modifier = Modifier.size(22.dp) + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "Delete", + color = Color.White, + fontSize = 12.sp, + fontWeight = FontWeight.SemiBold + ) + } + } + } + + // 2. КОНТЕНТ - поверх кнопок, сдвигается при свайпе + Column( + modifier = + Modifier.fillMaxSize() + .offset { IntOffset(animatedOffsetX.toInt(), 0) } + .background(backgroundColor) + .pointerInput(Unit) { + detectHorizontalDragGestures( + onDragEnd = { + // Если свайпнули больше чем на + // половину - фиксируем + if (kotlin.math.abs(offsetX) > + swipeWidthPx / 2 + ) { + offsetX = -swipeWidthPx + } else { + offsetX = 0f + } + }, + onDragCancel = { offsetX = 0f }, + onHorizontalDrag = { _, dragAmount -> + // Только свайп влево (отрицательное + // значение) + val newOffset = offsetX + dragAmount + offsetX = + newOffset.coerceIn( + -swipeWidthPx, + 0f + ) + } + ) + } + ) { + DialogItemContent( + dialog = dialog, + isDarkTheme = isDarkTheme, + isTyping = isTyping, + onClick = onClick + ) + + // Сепаратор внутри контента + val dividerColor = if (isDarkTheme) Color(0xFF3A3A3A) else Color(0xFFE8E8E8) + Divider( + modifier = Modifier.padding(start = 84.dp), + color = dividerColor, + thickness = 0.5.dp + ) + } + } +} + +/** Элемент диалога из базы данных - ОПТИМИЗИРОВАННЫЙ (без сепаратора для SwipeableDialogItem) */ +@Composable +fun DialogItemContent( + dialog: DialogUiModel, + isDarkTheme: Boolean, + isTyping: Boolean = false, + onClick: () -> Unit +) { + // 🔥 ОПТИМИЗАЦИЯ: Кешируем цвета и строки + val textColor = remember(isDarkTheme) { if (isDarkTheme) Color.White else Color.Black } + val secondaryTextColor = + remember(isDarkTheme) { if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666) } + + val avatarColors = + remember(dialog.opponentKey, isDarkTheme) { + getAvatarColor(dialog.opponentKey, isDarkTheme) + } + + // 📁 Для Saved Messages показываем специальное имя + // 🔥 Как в Архиве: title > username > "DELETED" + val displayName = + remember( + dialog.opponentTitle, + dialog.opponentUsername, + dialog.opponentKey, + dialog.isSavedMessages + ) { + if (dialog.isSavedMessages) { + "Saved Messages" + } else if (dialog.opponentTitle.isNotEmpty() && + dialog.opponentTitle != dialog.opponentKey && + dialog.opponentTitle != dialog.opponentKey.take(7) && + dialog.opponentTitle != dialog.opponentKey.take(8) + ) { + // 🔥 Показываем title как основное имя (как в десктопной версии) + // Обрезаем до 15 символов как в Архиве + if (dialog.opponentTitle.length > 15) { + dialog.opponentTitle.take(15) + "..." + } else { + dialog.opponentTitle + } + } else if (dialog.opponentUsername.isNotEmpty()) { + // Username только если нет title + "@${dialog.opponentUsername}" + } else { + // 🔥 Как в Архиве - если нет информации, показываем часть ключа + dialog.opponentKey.take(7) + } + } + + // 📁 Для Saved Messages показываем иконку закладки + // 🔥 Как в Архиве: инициалы из title или username или DELETED + val initials = + remember( + dialog.opponentTitle, + dialog.opponentUsername, + dialog.opponentKey, + dialog.isSavedMessages + ) { + if (dialog.isSavedMessages) { + "" // Для Saved Messages - пустая строка, будет использоваться + // иконка + } else if (dialog.opponentTitle.isNotEmpty() && + dialog.opponentTitle != dialog.opponentKey && + dialog.opponentTitle != dialog.opponentKey.take(7) && + dialog.opponentTitle != dialog.opponentKey.take(8) + ) { + // Используем title для инициалов + dialog.opponentTitle + .split(" ") + .take(2) + .mapNotNull { it.firstOrNull()?.uppercase() } + .joinToString("") + .ifEmpty { dialog.opponentTitle.take(2).uppercase() } + } else if (dialog.opponentUsername.isNotEmpty()) { + // Если только username - берем первые 2 символа + dialog.opponentUsername.take(2).uppercase() + } else { + dialog.opponentKey.take(2).uppercase() + } + } + + Row( + modifier = + Modifier.fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // Avatar container with online indicator + Box(modifier = Modifier.size(56.dp)) { + // Avatar + Box( + modifier = + Modifier.fillMaxSize() + .clip(CircleShape) + .background( + if (dialog.isSavedMessages) PrimaryBlue + else avatarColors.backgroundColor + ), + contentAlignment = Alignment.Center + ) { + if (dialog.isSavedMessages) { + Icon( + Icons.Default.Bookmark, + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(24.dp) + ) + } else { + Text( + text = initials, + color = avatarColors.textColor, + fontWeight = FontWeight.SemiBold, + fontSize = 18.sp + ) + } + } + + // Online indicator - зелёный кружок с белой обводкой + if (dialog.isOnline == 1) { + Box( + modifier = + Modifier.size(18.dp) + .align(Alignment.BottomEnd) + .offset(x = (-2).dp, y = (-2).dp) + .clip(CircleShape) + .background( + if (isDarkTheme) Color(0xFF1C1C1E) + else Color.White + ) + .padding(3.dp) + .clip(CircleShape) + .background( + Color(0xFF34C759) + ) // iOS зелёный цвет + ) + } + } + + Spacer(modifier = Modifier.width(12.dp)) + + // Name and last message + Column(modifier = Modifier.weight(1f)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = displayName, + fontWeight = FontWeight.SemiBold, + fontSize = 16.sp, + color = textColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.End + ) { + // 📁 Для Saved Messages ВСЕГДА показываем синие двойные + // галочки (прочитано) + if (dialog.isSavedMessages) { + Icon( + imageVector = Icons.Default.DoneAll, + contentDescription = null, + tint = PrimaryBlue, + modifier = Modifier.size(16.dp) + ) + Spacer(modifier = Modifier.width(4.dp)) + } else if (dialog.lastMessageFromMe == 1) { + // Показываем статус только для исходящих сообщений + // (кроме Saved Messages) + // 🔥 ПРАВИЛЬНАЯ ЛОГИКА (синхронизировано с + // ChatViewModel): + // - lastMessageDelivered == 3 → две синие галочки + // (прочитано собеседником) + // - lastMessageDelivered == 1 → одна галочка + // (доставлено) + // - lastMessageDelivered == 0 → часики + // (отправляется) + // - lastMessageDelivered == 2 → ошибка + 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) + ) + } + 3 -> { + // READ (delivered=3) - две синие + // галочки + Icon( + imageVector = + Icons.Default + .DoneAll, + contentDescription = null, + tint = PrimaryBlue, + modifier = + Modifier.size(16.dp) + ) + Spacer( + modifier = + Modifier.width(4.dp) + ) + } + else -> { + // DELIVERED (1) или SENDING (0) - + // одна серая галочка + Icon( + imageVector = + Icons.Default.Done, + contentDescription = null, + tint = + secondaryTextColor + .copy( + alpha = + 0.6f + ), + modifier = + Modifier.size(16.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)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + // 🔥 Показываем typing индикатор или последнее сообщение + if (isTyping) { + TypingIndicatorSmall() + } else { + // 🔥 Используем AppleEmojiText для отображения эмодзи + // Если есть непрочитанные - текст темнее + AppleEmojiText( + text = dialog.lastMessage.ifEmpty { "No messages" }, + fontSize = 14.sp, + color = + if (dialog.unreadCount > 0) + textColor.copy(alpha = 0.85f) + else secondaryTextColor, + fontWeight = + if (dialog.unreadCount > 0) + FontWeight.Medium + else FontWeight.Normal, + maxLines = 1, + overflow = android.text.TextUtils.TruncateAt.END, + modifier = Modifier.weight(1f) + ) + } + + // Unread badge + if (dialog.unreadCount > 0) { + Spacer(modifier = Modifier.width(8.dp)) + val unreadText = + when { + dialog.unreadCount > 999 -> "999+" + dialog.unreadCount > 99 -> "99+" + else -> dialog.unreadCount.toString() + } + Box( + modifier = + Modifier.height(22.dp) + .widthIn(min = 22.dp) + .clip(RoundedCornerShape(11.dp)) + .background(PrimaryBlue) + .padding(horizontal = 6.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = unreadText, + fontSize = 12.sp, + fontWeight = FontWeight.Bold, + color = Color.White, + maxLines = 1 + ) + } + } + } + } + } +} + +/** + * 🔥 Компактный индикатор typing для списка чатов Голубой текст "typing" с анимированными точками + */ +@Composable +fun TypingIndicatorSmall() { + val infiniteTransition = rememberInfiniteTransition(label = "typing") + val typingColor = PrimaryBlue + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(1.dp) + ) { + Text( + text = "typing", + fontSize = 14.sp, + color = typingColor, + fontWeight = FontWeight.Medium + ) + + // 3 анимированные точки + repeat(3) { index -> + val offsetY by + infiniteTransition.animateFloat( + initialValue = 0f, + targetValue = -3f, + animationSpec = + infiniteRepeatable( + animation = + tween( + durationMillis = 500, + delayMillis = index * 120, + easing = FastOutSlowInEasing + ), + repeatMode = RepeatMode.Reverse + ), + label = "dot$index" + ) + + Text( + text = ".", + fontSize = 14.sp, + color = typingColor, + fontWeight = FontWeight.Medium, + modifier = Modifier.offset(y = offsetY.dp) + ) + } + } +} + +/** 📬 Секция Requests - кнопка для перехода к списку запросов */ +@Composable +fun RequestsSection(count: Int, isDarkTheme: Boolean, onClick: () -> Unit) { + val textColor = if (isDarkTheme) Color(0xFF4DABF7) else Color(0xFF228BE6) + val arrowColor = if (isDarkTheme) Color(0xFFC9C9C9) else Color(0xFF228BE6) + + Row( + modifier = + Modifier.fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 14.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "Requests +$count", + fontSize = 16.sp, + fontWeight = FontWeight.SemiBold, + color = textColor + ) + + Icon( + imageVector = Icons.Default.ChevronRight, + contentDescription = "Open requests", + tint = arrowColor.copy(alpha = 0.6f), + modifier = Modifier.size(24.dp) + ) + } +} + +/** 📬 Экран со списком Requests (без хедера - хедер в основном TopAppBar) */ +@Composable +fun RequestsScreen( + requests: List, + isDarkTheme: Boolean, + onBack: () -> Unit, + onRequestClick: (DialogUiModel) -> Unit +) { + val backgroundColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFFFFFFF) + val dividerColor = if (isDarkTheme) Color(0xFF3A3A3A) else Color(0xFFE8E8E8) + + Column(modifier = Modifier.fillMaxSize().background(backgroundColor)) { + if (requests.isEmpty()) { + // Empty state + Box( + modifier = Modifier.fillMaxSize().padding(32.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = "No requests", + fontSize = 16.sp, + color = + if (isDarkTheme) Color(0xFF8E8E8E) + else Color(0xFF8E8E93), + textAlign = TextAlign.Center + ) + } + } else { + // Requests list + LazyColumn(modifier = Modifier.fillMaxSize()) { + items(requests, key = { it.opponentKey }) { request -> + DialogItemContent( + dialog = request, + isDarkTheme = isDarkTheme, + isTyping = false, + onClick = { onRequestClick(request) } + ) + + Divider( + modifier = Modifier.padding(start = 84.dp), + color = dividerColor, + thickness = 0.5.dp + ) + } + } + } + } +} + +/** 🎨 Enhanced Drawer Menu Item - красивый пункт меню с hover эффектом */ +@Composable +fun DrawerMenuItemEnhanced( + icon: androidx.compose.ui.graphics.vector.ImageVector, + text: String, + iconColor: Color, + textColor: Color, + badge: String? = null, + onClick: () -> Unit +) { + Row( + modifier = + Modifier.fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 20.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = iconColor, + modifier = Modifier.size(24.dp) + ) + + Spacer(modifier = Modifier.width(20.dp)) + + Text( + text = text, + fontSize = 16.sp, + fontWeight = FontWeight.Normal, + color = textColor, + modifier = Modifier.weight(1f) + ) + + badge?.let { + Box( + modifier = + Modifier.background( + color = Color(0xFF4A90D9), + shape = RoundedCornerShape(10.dp) + ) + .padding(horizontal = 8.dp, vertical = 2.dp) + ) { + Text( + text = it, + fontSize = 12.sp, + fontWeight = FontWeight.Medium, + color = Color.White + ) + } + } + } +} + +/** 📏 Drawer Divider - разделитель между секциями */ +@Composable +fun DrawerDivider(isDarkTheme: Boolean) { + Spacer(modifier = Modifier.height(8.dp)) + Divider( + modifier = Modifier.padding(horizontal = 20.dp), + color = if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFEEEEEE), + thickness = 0.5.dp + ) + Spacer(modifier = Modifier.height(8.dp)) +} + +/** 🔔 FCM Logs Section - отображение логов отправки FCM токена */ +@Composable +fun FcmLogsSection(logs: List, isDarkTheme: Boolean, onClearLogs: () -> Unit) { + val backgroundColor = if (isDarkTheme) Color(0xFF2C2C2E) else Color(0xFFF8F9FA) + val textColor = if (isDarkTheme) Color(0xFFE0E0E0) else Color(0xFF1A1A1A) + val accentColor = if (isDarkTheme) Color(0xFF4A90D9) else Color(0xFF2979FF) + + var expanded by remember { mutableStateOf(true) } + + Column(modifier = Modifier.fillMaxWidth().background(backgroundColor)) { + // Header + Row( + modifier = + Modifier.fillMaxWidth() + .clickable { expanded = !expanded } + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = + if (expanded) Icons.Default.KeyboardArrowDown + else Icons.Default.KeyboardArrowRight, + contentDescription = null, + tint = accentColor, + modifier = Modifier.size(24.dp) + ) + + Spacer(modifier = Modifier.width(12.dp)) + + Text( + text = "🔔 FCM Token Status", + fontSize = 16.sp, + fontWeight = FontWeight.Bold, + color = textColor, + modifier = Modifier.weight(1f) + ) + + // Clear button + if (logs.isNotEmpty()) { + TextButton( + onClick = onClearLogs, + contentPadding = + PaddingValues(horizontal = 8.dp, vertical = 4.dp) + ) { Text(text = "Clear", fontSize = 13.sp, color = accentColor) } + } + } + + // Logs content + AnimatedVisibility( + visible = expanded, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut() + ) { + Column( + modifier = + Modifier.fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp) + .background( + color = + if (isDarkTheme) Color(0xFF1A1A1A) + else Color.White, + shape = RoundedCornerShape(8.dp) + ) + .padding(12.dp) + ) { + if (logs.isEmpty()) { + Text( + text = "No FCM logs yet...", + fontSize = 14.sp, + color = + if (isDarkTheme) Color(0xFF8E8E8E) + else Color(0xFF999999), + fontFamily = FontFamily.Monospace + ) + } else { + logs.take(10).forEach { log -> + Text( + text = log, + fontSize = 12.sp, + color = textColor, + fontFamily = FontFamily.Monospace, + lineHeight = 18.sp, + modifier = Modifier.padding(vertical = 2.dp) + ) + } + + if (logs.size > 10) { + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "... и ещё ${logs.size - 10} логов", + fontSize = 11.sp, + color = + if (isDarkTheme) Color(0xFF8E8E8E) + else Color(0xFF999999), + fontStyle = + androidx.compose.ui.text.font + .FontStyle.Italic + ) + } + } + } + } + + Spacer(modifier = Modifier.height(8.dp)) + } +} diff --git a/app/src/main/java/com/rosetta/messenger/ui/onboarding/OnboardingScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/onboarding/OnboardingScreen.kt index 93db36c..d52c2b6 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/onboarding/OnboardingScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/onboarding/OnboardingScreen.kt @@ -1,32 +1,27 @@ package com.rosetta.messenger.ui.onboarding -import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.* -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.scaleIn -import androidx.compose.animation.scaleOut import androidx.compose.foundation.Canvas import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.PagerDefaults +import androidx.compose.foundation.pager.PagerSnapDistance import androidx.compose.foundation.pager.PagerState import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Send -import androidx.compose.material.icons.filled.LightMode import androidx.compose.material.icons.filled.DarkMode +import androidx.compose.material.icons.filled.LightMode import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.draw.rotate import androidx.compose.ui.draw.scale @@ -34,9 +29,10 @@ import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.boundsInWindow +import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight @@ -45,37 +41,39 @@ import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.core.view.WindowCompat +import com.airbnb.lottie.compose.* +import com.rosetta.messenger.R import com.rosetta.messenger.ui.theme.* -import kotlinx.coroutines.delay import kotlin.math.absoluteValue import kotlin.math.hypot -import com.airbnb.lottie.compose.* -import androidx.compose.ui.res.painterResource -import com.rosetta.messenger.R -import androidx.compose.foundation.Image +import kotlinx.coroutines.delay // App colors (matching React Native) -val PrimaryBlue = Color(0xFF248AE6) // primary light theme -val PrimaryBlueDark = Color(0xFF1A72C2) // primary dark theme -val LightBlue = Color(0xFF74C0FC) // lightBlue -val OnboardingBackground = Color(0xFF1E1E1E) // dark background +val PrimaryBlue = Color(0xFF248AE6) // primary light theme +val PrimaryBlueDark = Color(0xFF1A72C2) // primary dark theme +val LightBlue = Color(0xFF74C0FC) // lightBlue +val OnboardingBackground = Color(0xFF1E1E1E) // dark background val OnboardingBackgroundLight = Color(0xFFFFFFFF) @OptIn(ExperimentalFoundationApi::class) @Composable fun OnboardingScreen( - isDarkTheme: Boolean, - onThemeToggle: () -> Unit, - onStartMessaging: () -> Unit + isDarkTheme: Boolean, + onThemeToggle: () -> Unit, + onStartMessaging: () -> Unit ) { val pagerState = rememberPagerState(pageCount = { onboardingPages.size }) - + // Preload Lottie animations - val ideaComposition by rememberLottieComposition(LottieCompositionSpec.Asset("lottie/Idea.json")) - val moneyComposition by rememberLottieComposition(LottieCompositionSpec.Asset("lottie/Money.json")) - val lockComposition by rememberLottieComposition(LottieCompositionSpec.Asset("lottie/lock.json")) - val bookComposition by rememberLottieComposition(LottieCompositionSpec.Asset("lottie/Book.json")) - + val ideaComposition by + rememberLottieComposition(LottieCompositionSpec.Asset("lottie/Idea.json")) + val moneyComposition by + rememberLottieComposition(LottieCompositionSpec.Asset("lottie/Money.json")) + val lockComposition by + rememberLottieComposition(LottieCompositionSpec.Asset("lottie/lock.json")) + val bookComposition by + rememberLottieComposition(LottieCompositionSpec.Asset("lottie/Book.json")) + // Theme transition animation var isTransitioning by remember { mutableStateOf(false) } var transitionProgress by remember { mutableStateOf(0f) } @@ -84,11 +82,9 @@ fun OnboardingScreen( var hasInitialized by remember { mutableStateOf(false) } var previousTheme by remember { mutableStateOf(isDarkTheme) } var targetTheme by remember { mutableStateOf(isDarkTheme) } - - LaunchedEffect(Unit) { - hasInitialized = true - } - + + LaunchedEffect(Unit) { hasInitialized = true } + LaunchedEffect(isTransitioning) { if (isTransitioning) { shouldUpdateStatusBar = false @@ -97,7 +93,7 @@ fun OnboardingScreen( while (transitionProgress < 1f) { val elapsed = System.currentTimeMillis() - startTime transitionProgress = (elapsed / duration).coerceAtMost(1f) - + delay(16) // ~60fps } // Update status bar icons after animation is completely finished @@ -109,7 +105,7 @@ fun OnboardingScreen( previousTheme = targetTheme } } - + // Animate navigation bar color starting at 80% of wave animation val view = LocalView.current LaunchedEffect(isTransitioning, transitionProgress) { @@ -117,25 +113,26 @@ fun OnboardingScreen( val window = (view.context as android.app.Activity).window // Map 0.8-1.0 to 0-1 for smooth interpolation val navProgress = ((transitionProgress - 0.8f) / 0.2f).coerceIn(0f, 1f) - + val oldColor = if (previousTheme) 0xFF1E1E1E else 0xFFFFFFFF val newColor = if (targetTheme) 0xFF1E1E1E else 0xFFFFFFFF - + val r1 = (oldColor shr 16 and 0xFF) val g1 = (oldColor shr 8 and 0xFF) val b1 = (oldColor and 0xFF) val r2 = (newColor shr 16 and 0xFF) val g2 = (newColor shr 8 and 0xFF) val b2 = (newColor and 0xFF) - + val r = (r1 + (r2 - r1) * navProgress).toInt() val g = (g1 + (g2 - g1) * navProgress).toInt() val b = (b1 + (b2 - b1) * navProgress).toInt() - - window.navigationBarColor = (0xFF000000 or (r.toLong() shl 16) or (g.toLong() shl 8) or b.toLong()).toInt() + + window.navigationBarColor = + (0xFF000000 or (r.toLong() shl 16) or (g.toLong() shl 8) or b.toLong()).toInt() } } - + // Update status bar icons when animation finishes LaunchedEffect(shouldUpdateStatusBar) { if (shouldUpdateStatusBar && !view.isInEditMode) { @@ -146,7 +143,7 @@ fun OnboardingScreen( window.statusBarColor = android.graphics.Color.TRANSPARENT } } - + // Set initial navigation bar color only on first launch LaunchedEffect(Unit) { if (!view.isInEditMode) { @@ -154,141 +151,156 @@ fun OnboardingScreen( window.navigationBarColor = if (isDarkTheme) 0xFF1E1E1E.toInt() else 0xFFFFFFFF.toInt() } } - - val backgroundColor by animateColorAsState( - targetValue = if (isDarkTheme) OnboardingBackground else OnboardingBackgroundLight, - animationSpec = if (!hasInitialized) snap() else tween(800, easing = FastOutSlowInEasing), - label = "backgroundColor" - ) - val textColor by animateColorAsState( - targetValue = if (isDarkTheme) Color.White else Color.Black, - animationSpec = if (!hasInitialized) snap() else tween(800, easing = FastOutSlowInEasing), - label = "textColor" - ) - val secondaryTextColor by animateColorAsState( - targetValue = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666), - animationSpec = if (!hasInitialized) snap() else tween(800, easing = FastOutSlowInEasing), - label = "secondaryTextColor" - ) - val indicatorColor by animateColorAsState( - targetValue = if (isDarkTheme) Color(0xFF4A4A4A) else Color(0xFFD0D0D0), - animationSpec = if (!hasInitialized) snap() else tween(800, easing = FastOutSlowInEasing), - label = "indicatorColor" - ) - - Box( - modifier = Modifier - .fillMaxSize() - .navigationBarsPadding() - ) { + + val backgroundColor by + animateColorAsState( + targetValue = + if (isDarkTheme) OnboardingBackground else OnboardingBackgroundLight, + animationSpec = + if (!hasInitialized) snap() + else tween(800, easing = FastOutSlowInEasing), + label = "backgroundColor" + ) + val textColor by + animateColorAsState( + targetValue = if (isDarkTheme) Color.White else Color.Black, + animationSpec = + if (!hasInitialized) snap() + else tween(800, easing = FastOutSlowInEasing), + label = "textColor" + ) + val secondaryTextColor by + animateColorAsState( + targetValue = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666), + animationSpec = + if (!hasInitialized) snap() + else tween(800, easing = FastOutSlowInEasing), + label = "secondaryTextColor" + ) + val indicatorColor by + animateColorAsState( + targetValue = if (isDarkTheme) Color(0xFF4A4A4A) else Color(0xFFD0D0D0), + animationSpec = + if (!hasInitialized) snap() + else tween(800, easing = FastOutSlowInEasing), + label = "indicatorColor" + ) + + Box(modifier = Modifier.fillMaxSize().navigationBarsPadding()) { // Base background - shows the OLD theme color during transition Box( - modifier = Modifier - .fillMaxSize() - .background(if (isTransitioning) { - if (previousTheme) OnboardingBackground else OnboardingBackgroundLight - } else backgroundColor) + modifier = + Modifier.fillMaxSize() + .background( + if (isTransitioning) { + if (previousTheme) OnboardingBackground + else OnboardingBackgroundLight + } else backgroundColor + ) ) - + // Circular reveal overlay - draws the NEW theme color expanding if (isTransitioning) { Canvas(modifier = Modifier.fillMaxSize()) { val maxRadius = hypot(size.width, size.height) val radius = maxRadius * transitionProgress - + // Draw the NEW theme color expanding from click point drawCircle( - color = if (targetTheme) OnboardingBackground else OnboardingBackgroundLight, - radius = radius, - center = clickPosition + color = + if (targetTheme) OnboardingBackground + else OnboardingBackgroundLight, + radius = radius, + center = clickPosition ) } } // Theme toggle button in top right ThemeToggleButton( - isDarkTheme = isDarkTheme, - onToggle = { position -> - if (!isTransitioning) { - previousTheme = isDarkTheme - targetTheme = !isDarkTheme - clickPosition = position - isTransitioning = true - onThemeToggle() - } - }, - modifier = Modifier - .align(Alignment.TopEnd) - .padding(16.dp) - .statusBarsPadding() + isDarkTheme = isDarkTheme, + onToggle = { position -> + if (!isTransitioning) { + previousTheme = isDarkTheme + targetTheme = !isDarkTheme + clickPosition = position + isTransitioning = true + onThemeToggle() + } + }, + modifier = Modifier.align(Alignment.TopEnd).padding(16.dp).statusBarsPadding() ) - + Column( - modifier = Modifier - .fillMaxSize() - .padding(horizontal = 24.dp), - horizontalAlignment = Alignment.CenterHorizontally + modifier = Modifier.fillMaxSize().padding(horizontal = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally ) { Spacer(modifier = Modifier.weight(0.15f)) - + // Animated Logo AnimatedRosettaLogo( - pagerState = pagerState, - ideaComposition = ideaComposition, - moneyComposition = moneyComposition, - lockComposition = lockComposition, - bookComposition = bookComposition, - modifier = Modifier.size(150.dp) + pagerState = pagerState, + ideaComposition = ideaComposition, + moneyComposition = moneyComposition, + lockComposition = lockComposition, + bookComposition = bookComposition, + modifier = Modifier.size(150.dp) ) - + Spacer(modifier = Modifier.height(32.dp)) - + // Pager for text content with easier swipes HorizontalPager( - state = pagerState, - modifier = Modifier - .fillMaxWidth() - .height(150.dp) - .graphicsLayer { - // Hardware acceleration for entire pager - compositingStrategy = androidx.compose.ui.graphics.CompositingStrategy.Offscreen - }, - // Pre-load adjacent pages for smooth swiping - beyondBoundsPageCount = 2, - flingBehavior = PagerDefaults.flingBehavior( state = pagerState, - lowVelocityAnimationSpec = snap(), // Instant! - snapAnimationSpec = snap() // No animation! - ) + modifier = + Modifier.fillMaxWidth().height(150.dp).graphicsLayer { + // Hardware acceleration for entire pager + compositingStrategy = + androidx.compose.ui.graphics.CompositingStrategy.Offscreen + }, + // Pre-load adjacent pages for smooth swiping + beyondBoundsPageCount = 2, + flingBehavior = + PagerDefaults.flingBehavior( + state = pagerState, + pagerSnapDistance = + PagerSnapDistance.atMost(0), // Snap to nearest page + lowVelocityAnimationSpec = snap(), // Instant! + snapAnimationSpec = snap(), // No animation! + positionalThreshold = + 0.4f // Увеличен порог с 0.5 до 0.4 (нужно больше + // свайпнуть) + ) ) { page -> OnboardingPageContent( - page = onboardingPages[page], - textColor = textColor, - secondaryTextColor = secondaryTextColor, - highlightColor = PrimaryBlue, - pageOffset = ((pagerState.currentPage - page) + pagerState.currentPageOffsetFraction).absoluteValue + page = onboardingPages[page], + textColor = textColor, + secondaryTextColor = secondaryTextColor, + highlightColor = PrimaryBlue, + pageOffset = + ((pagerState.currentPage - page) + + pagerState.currentPageOffsetFraction) + .absoluteValue ) } - + Spacer(modifier = Modifier.height(24.dp)) - + // Page indicators PagerIndicator( - pageCount = onboardingPages.size, - currentPage = pagerState.currentPage, - selectedColor = PrimaryBlue, - unselectedColor = indicatorColor + pageCount = onboardingPages.size, + currentPage = pagerState.currentPage, + selectedColor = PrimaryBlue, + unselectedColor = indicatorColor ) - + Spacer(modifier = Modifier.weight(0.3f)) - + // Start messaging button StartMessagingButton( - onClick = onStartMessaging, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp) + onClick = onStartMessaging, + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp) ) - + Spacer(modifier = Modifier.height(48.dp)) } } @@ -296,68 +308,64 @@ fun OnboardingScreen( @Composable fun ThemeToggleButton( - isDarkTheme: Boolean, - onToggle: (androidx.compose.ui.geometry.Offset) -> Unit, - modifier: Modifier = Modifier + isDarkTheme: Boolean, + onToggle: (androidx.compose.ui.geometry.Offset) -> Unit, + modifier: Modifier = Modifier ) { - val rotation by animateFloatAsState( - targetValue = if (isDarkTheme) 360f else 0f, - animationSpec = spring( - dampingRatio = 0.6f, - stiffness = Spring.StiffnessLow - ), - label = "rotation" - ) - - val scale by animateFloatAsState( - targetValue = 1f, - animationSpec = spring( - dampingRatio = 0.4f, - stiffness = Spring.StiffnessMedium - ), - label = "scale" - ) - - val iconColor by animateColorAsState( - targetValue = if (isDarkTheme) Color.White else Color(0xFF2A2A2A), - animationSpec = tween(800, easing = FastOutSlowInEasing), - label = "iconColor" - ) - + val rotation by + animateFloatAsState( + targetValue = if (isDarkTheme) 360f else 0f, + animationSpec = spring(dampingRatio = 0.6f, stiffness = Spring.StiffnessLow), + label = "rotation" + ) + + val scale by + animateFloatAsState( + targetValue = 1f, + animationSpec = spring(dampingRatio = 0.4f, stiffness = Spring.StiffnessMedium), + label = "scale" + ) + + val iconColor by + animateColorAsState( + targetValue = if (isDarkTheme) Color.White else Color(0xFF2A2A2A), + animationSpec = tween(800, easing = FastOutSlowInEasing), + label = "iconColor" + ) + var buttonPosition by remember { mutableStateOf(androidx.compose.ui.geometry.Offset.Zero) } var isClickable by remember { mutableStateOf(true) } - + LaunchedEffect(isDarkTheme) { isClickable = false delay(800) isClickable = true } - + IconButton( - onClick = { if (isClickable) onToggle(buttonPosition) }, - enabled = isClickable, - modifier = modifier - .size(48.dp) - .onGloballyPositioned { coordinates -> - val bounds = coordinates.boundsInWindow() - buttonPosition = androidx.compose.ui.geometry.Offset( - x = bounds.center.x, - y = bounds.center.y - ) - } + onClick = { if (isClickable) onToggle(buttonPosition) }, + enabled = isClickable, + modifier = + modifier.size(48.dp).onGloballyPositioned { coordinates -> + val bounds = coordinates.boundsInWindow() + buttonPosition = + androidx.compose.ui.geometry.Offset( + x = bounds.center.x, + y = bounds.center.y + ) + } ) { Box( - modifier = Modifier - .size(24.dp) - .scale(scale) - .rotate(rotation), - contentAlignment = Alignment.Center + modifier = Modifier.size(24.dp).scale(scale).rotate(rotation), + contentAlignment = Alignment.Center ) { Icon( - imageVector = if (isDarkTheme) Icons.Default.LightMode else Icons.Default.DarkMode, - contentDescription = if (isDarkTheme) "Switch to Light Mode" else "Switch to Dark Mode", - tint = iconColor, - modifier = Modifier.size(24.dp) + imageVector = + if (isDarkTheme) Icons.Default.LightMode else Icons.Default.DarkMode, + contentDescription = + if (isDarkTheme) "Switch to Light Mode" else "Switch to Dark Mode", + tint = iconColor, + modifier = Modifier.size(24.dp) ) } } @@ -366,166 +374,167 @@ fun ThemeToggleButton( @OptIn(ExperimentalFoundationApi::class) @Composable fun AnimatedRosettaLogo( - pagerState: PagerState, - ideaComposition: Any?, - moneyComposition: Any?, - lockComposition: Any?, - bookComposition: Any?, - modifier: Modifier = Modifier + pagerState: PagerState, + ideaComposition: Any?, + moneyComposition: Any?, + lockComposition: Any?, + bookComposition: Any?, + modifier: Modifier = Modifier ) { // Use derivedStateOf for optimized reads - prevents unnecessary recompositions val currentPage by remember { derivedStateOf { pagerState.currentPage } } - + // Pre-calculate all animation progress states ONCE (they stay in memory) val ideaLottieComp = ideaComposition as? com.airbnb.lottie.LottieComposition val moneyLottieComp = moneyComposition as? com.airbnb.lottie.LottieComposition val lockLottieComp = lockComposition as? com.airbnb.lottie.LottieComposition val bookLottieComp = bookComposition as? com.airbnb.lottie.LottieComposition - + // All animations are always "playing" but only visible one shows - val ideaProgress by animateLottieCompositionAsState( - composition = ideaLottieComp, - iterations = 1, - isPlaying = currentPage == 1, - speed = 1.5f, - restartOnPlay = true - ) - val moneyProgress by animateLottieCompositionAsState( - composition = moneyLottieComp, - iterations = 1, - isPlaying = currentPage == 2, - speed = 1.5f, - restartOnPlay = true - ) - val lockProgress by animateLottieCompositionAsState( - composition = lockLottieComp, - iterations = 1, - isPlaying = currentPage == 3, - speed = 1.5f, - restartOnPlay = true - ) - val bookProgress by animateLottieCompositionAsState( - composition = bookLottieComp, - iterations = 1, - isPlaying = currentPage == 4, - speed = 1.5f, - restartOnPlay = true - ) - + val ideaProgress by + animateLottieCompositionAsState( + composition = ideaLottieComp, + iterations = 1, + isPlaying = currentPage == 1, + speed = 1.5f, + restartOnPlay = true + ) + val moneyProgress by + animateLottieCompositionAsState( + composition = moneyLottieComp, + iterations = 1, + isPlaying = currentPage == 2, + speed = 1.5f, + restartOnPlay = true + ) + val lockProgress by + animateLottieCompositionAsState( + composition = lockLottieComp, + iterations = 1, + isPlaying = currentPage == 3, + speed = 1.5f, + restartOnPlay = true + ) + val bookProgress by + animateLottieCompositionAsState( + composition = bookLottieComp, + iterations = 1, + isPlaying = currentPage == 4, + speed = 1.5f, + restartOnPlay = true + ) + // Pulse animation for logo (always running, cheap) - val pulseScale by rememberInfiniteTransition(label = "pulse").animateFloat( - initialValue = 1f, - targetValue = 1.1f, - animationSpec = infiniteRepeatable( - animation = tween(800, easing = FastOutSlowInEasing), - repeatMode = RepeatMode.Reverse - ), - label = "pulseScale" - ) - - Box( - modifier = modifier, - contentAlignment = Alignment.Center - ) { + val pulseScale by + rememberInfiniteTransition(label = "pulse") + .animateFloat( + initialValue = 1f, + targetValue = 1.1f, + animationSpec = + infiniteRepeatable( + animation = tween(800, easing = FastOutSlowInEasing), + repeatMode = RepeatMode.Reverse + ), + label = "pulseScale" + ) + + Box(modifier = modifier, contentAlignment = Alignment.Center) { // === PRE-RENDERED LAYERS - All always exist, just alpha changes === - + // Page 0: Rosetta Logo Box( - modifier = Modifier - .fillMaxSize() - .graphicsLayer { - alpha = if (currentPage == 0) 1f else 0f - }, - contentAlignment = Alignment.Center + modifier = + Modifier.fillMaxSize().graphicsLayer { + alpha = if (currentPage == 0) 1f else 0f + }, + contentAlignment = Alignment.Center ) { // Glow effect Box( - modifier = Modifier - .size(180.dp) - .scale(if (currentPage == 0) pulseScale else 1f) - .background( - color = Color(0xFF54A9EB).copy(alpha = 0.2f), - shape = CircleShape - ) + modifier = + Modifier.size(180.dp) + .scale(if (currentPage == 0) pulseScale else 1f) + .background( + color = Color(0xFF54A9EB).copy(alpha = 0.2f), + shape = CircleShape + ) ) // Main logo Image( - painter = painterResource(id = R.drawable.rosetta_icon), - contentDescription = "Rosetta Logo", - modifier = Modifier - .size(150.dp) - .clip(CircleShape) + painter = painterResource(id = R.drawable.rosetta_icon), + contentDescription = "Rosetta Logo", + modifier = Modifier.size(150.dp).clip(CircleShape) ) } - + // Page 1: Idea animation (always in memory!) if (ideaLottieComp != null) { LottieAnimation( - composition = ideaLottieComp, - progress = { ideaProgress }, - modifier = Modifier - .fillMaxSize() - .graphicsLayer { - alpha = if (currentPage == 1) 1f else 0f - // Hardware layer optimization - compositingStrategy = androidx.compose.ui.graphics.CompositingStrategy.Offscreen - // Disable clipping for performance - clip = false - }, - maintainOriginalImageBounds = true, - // Disable dynamic properties for max performance - enableMergePaths = false + composition = ideaLottieComp, + progress = { ideaProgress }, + modifier = + Modifier.fillMaxSize().graphicsLayer { + alpha = if (currentPage == 1) 1f else 0f + // Hardware layer optimization + compositingStrategy = + androidx.compose.ui.graphics.CompositingStrategy.Offscreen + // Disable clipping for performance + clip = false + }, + maintainOriginalImageBounds = true, + // Disable dynamic properties for max performance + enableMergePaths = false ) } - + // Page 2: Money animation if (moneyLottieComp != null) { LottieAnimation( - composition = moneyLottieComp, - progress = { moneyProgress }, - modifier = Modifier - .fillMaxSize() - .graphicsLayer { - alpha = if (currentPage == 2) 1f else 0f - compositingStrategy = androidx.compose.ui.graphics.CompositingStrategy.Offscreen - clip = false - }, - maintainOriginalImageBounds = true, - enableMergePaths = false + composition = moneyLottieComp, + progress = { moneyProgress }, + modifier = + Modifier.fillMaxSize().graphicsLayer { + alpha = if (currentPage == 2) 1f else 0f + compositingStrategy = + androidx.compose.ui.graphics.CompositingStrategy.Offscreen + clip = false + }, + maintainOriginalImageBounds = true, + enableMergePaths = false ) } - + // Page 3: Lock animation if (lockLottieComp != null) { LottieAnimation( - composition = lockLottieComp, - progress = { lockProgress }, - modifier = Modifier - .fillMaxSize() - .graphicsLayer { - alpha = if (currentPage == 3) 1f else 0f - compositingStrategy = androidx.compose.ui.graphics.CompositingStrategy.Offscreen - clip = false - }, - maintainOriginalImageBounds = true, - enableMergePaths = false + composition = lockLottieComp, + progress = { lockProgress }, + modifier = + Modifier.fillMaxSize().graphicsLayer { + alpha = if (currentPage == 3) 1f else 0f + compositingStrategy = + androidx.compose.ui.graphics.CompositingStrategy.Offscreen + clip = false + }, + maintainOriginalImageBounds = true, + enableMergePaths = false ) } - + // Page 4: Book animation if (bookLottieComp != null) { LottieAnimation( - composition = bookLottieComp, - progress = { bookProgress }, - modifier = Modifier - .fillMaxSize() - .graphicsLayer { - alpha = if (currentPage == 4) 1f else 0f - compositingStrategy = androidx.compose.ui.graphics.CompositingStrategy.Offscreen - clip = false - }, - maintainOriginalImageBounds = true, - enableMergePaths = false + composition = bookLottieComp, + progress = { bookProgress }, + modifier = + Modifier.fillMaxSize().graphicsLayer { + alpha = if (currentPage == 4) 1f else 0f + compositingStrategy = + androidx.compose.ui.graphics.CompositingStrategy.Offscreen + clip = false + }, + maintainOriginalImageBounds = true, + enableMergePaths = false ) } } @@ -533,49 +542,50 @@ fun AnimatedRosettaLogo( @Composable fun OnboardingPageContent( - page: OnboardingPage, - textColor: Color, - secondaryTextColor: Color, - highlightColor: Color, - pageOffset: Float + page: OnboardingPage, + textColor: Color, + secondaryTextColor: Color, + highlightColor: Color, + pageOffset: Float ) { - val alpha by animateFloatAsState( - targetValue = 1f - (pageOffset * 0.7f).coerceIn(0f, 1f), - animationSpec = tween(400, easing = FastOutSlowInEasing), - label = "alpha" - ) - val scale by animateFloatAsState( - targetValue = 1f - (pageOffset * 0.1f).coerceIn(0f, 1f), - animationSpec = tween(400, easing = FastOutSlowInEasing), - label = "scale" - ) - + val alpha by + animateFloatAsState( + targetValue = 1f - (pageOffset * 0.7f).coerceIn(0f, 1f), + animationSpec = tween(400, easing = FastOutSlowInEasing), + label = "alpha" + ) + val scale by + animateFloatAsState( + targetValue = 1f - (pageOffset * 0.1f).coerceIn(0f, 1f), + animationSpec = tween(400, easing = FastOutSlowInEasing), + label = "scale" + ) + Column( - modifier = Modifier - .fillMaxWidth() - .graphicsLayer { - this.alpha = alpha - scaleX = scale - scaleY = scale - }, - horizontalAlignment = Alignment.CenterHorizontally + modifier = + Modifier.fillMaxWidth().graphicsLayer { + this.alpha = alpha + scaleX = scale + scaleY = scale + }, + horizontalAlignment = Alignment.CenterHorizontally ) { // Title Text( - text = page.title, - fontSize = 32.sp, - fontWeight = FontWeight.Bold, - color = textColor, - textAlign = TextAlign.Center + text = page.title, + fontSize = 32.sp, + fontWeight = FontWeight.Bold, + color = textColor, + textAlign = TextAlign.Center ) - + Spacer(modifier = Modifier.height(12.dp)) - + // Description with highlighted words val annotatedDescription = buildAnnotatedString { var currentIndex = 0 val description = page.description - + // Find and highlight words page.highlightWords.forEach { word -> val startIndex = description.indexOf(word, currentIndex, ignoreCase = true) @@ -593,7 +603,7 @@ fun OnboardingPageContent( currentIndex = startIndex + word.length } } - + // Add remaining text if (currentIndex < description.length) { withStyle(SpanStyle(color = secondaryTextColor)) { @@ -601,100 +611,103 @@ fun OnboardingPageContent( } } } - + Text( - text = annotatedDescription, - fontSize = 17.sp, - textAlign = TextAlign.Center, - lineHeight = 24.sp + text = annotatedDescription, + fontSize = 17.sp, + textAlign = TextAlign.Center, + lineHeight = 24.sp ) } } @Composable fun PagerIndicator( - pageCount: Int, - currentPage: Int, - selectedColor: Color, - unselectedColor: Color, - modifier: Modifier = Modifier + pageCount: Int, + currentPage: Int, + selectedColor: Color, + unselectedColor: Color, + modifier: Modifier = Modifier ) { Row( - modifier = modifier, - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically ) { repeat(pageCount) { index -> val isSelected = index == currentPage - val width by animateDpAsState( - targetValue = if (isSelected) 20.dp else 8.dp, - animationSpec = spring(dampingRatio = 0.8f), - label = "indicatorWidth" - ) - + val width by + animateDpAsState( + targetValue = if (isSelected) 20.dp else 8.dp, + animationSpec = spring(dampingRatio = 0.8f), + label = "indicatorWidth" + ) + Box( - modifier = Modifier - .height(8.dp) - .width(width) - .clip(CircleShape) - .background(if (isSelected) selectedColor else unselectedColor) + modifier = + Modifier.height(8.dp) + .width(width) + .clip(CircleShape) + .background(if (isSelected) selectedColor else unselectedColor) ) } } } @Composable -fun StartMessagingButton( - onClick: () -> Unit, - modifier: Modifier = Modifier -) { +fun StartMessagingButton(onClick: () -> Unit, modifier: Modifier = Modifier) { // Shining effect animation val infiniteTransition = rememberInfiniteTransition(label = "shine") - val shimmerTranslate by infiniteTransition.animateFloat( - initialValue = -0.5f, - targetValue = 1.5f, - animationSpec = infiniteRepeatable( - animation = tween(2000, easing = LinearEasing), - repeatMode = RepeatMode.Restart - ), - label = "shimmerTranslate" - ) - + val shimmerTranslate by + infiniteTransition.animateFloat( + initialValue = -0.5f, + targetValue = 1.5f, + animationSpec = + infiniteRepeatable( + animation = tween(2000, easing = LinearEasing), + repeatMode = RepeatMode.Restart + ), + label = "shimmerTranslate" + ) + Surface( - onClick = onClick, - modifier = modifier - .fillMaxWidth() - .height(54.dp), - shape = RoundedCornerShape(12.dp), - color = PrimaryBlue + onClick = onClick, + modifier = modifier.fillMaxWidth().height(54.dp), + shape = RoundedCornerShape(12.dp), + color = PrimaryBlue ) { Box( - modifier = Modifier - .fillMaxSize() - .drawWithContent { - drawContent() - // Draw shimmer on top - val shimmerWidth = size.width * 0.6f - val shimmerStart = shimmerTranslate * size.width - drawRect( - brush = Brush.linearGradient( - colors = listOf( - Color.White.copy(alpha = 0f), - Color.White.copy(alpha = 0.3f), - Color.White.copy(alpha = 0f) - ), - start = Offset(shimmerStart, 0f), - end = Offset(shimmerStart + shimmerWidth, size.height) - ) - ) - }, - contentAlignment = Alignment.Center + modifier = + Modifier.fillMaxSize().drawWithContent { + drawContent() + // Draw shimmer on top + val shimmerWidth = size.width * 0.6f + val shimmerStart = shimmerTranslate * size.width + drawRect( + brush = + Brush.linearGradient( + colors = + listOf( + Color.White.copy(alpha = 0f), + Color.White.copy(alpha = 0.3f), + Color.White.copy(alpha = 0f) + ), + start = Offset(shimmerStart, 0f), + end = + Offset( + shimmerStart + shimmerWidth, + size.height + ) + ) + ) + }, + contentAlignment = Alignment.Center ) { Text( - text = "Start Messaging", - fontSize = 17.sp, - fontWeight = FontWeight.SemiBold, - color = Color.White + text = "Start Messaging", + fontSize = 17.sp, + fontWeight = FontWeight.SemiBold, + color = Color.White ) } } diff --git a/app/src/main/res/drawable/ic_notification.xml b/app/src/main/res/drawable/ic_notification.xml new file mode 100644 index 0000000..7d1f562 --- /dev/null +++ b/app/src/main/res/drawable/ic_notification.xml @@ -0,0 +1,9 @@ + + +