Refactor code structure for improved readability and maintainability

This commit is contained in:
2026-01-19 17:20:35 +05:00
parent 19a89ea00e
commit b7cbc35868
6 changed files with 5547 additions and 2390 deletions

View File

@@ -1,7 +1,6 @@
package com.rosetta.messenger package com.rosetta.messenger
import android.Manifest import android.Manifest
import android.content.Context
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
@@ -11,59 +10,42 @@ import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.ContextCompat
import androidx.lifecycle.lifecycleScope
import androidx.compose.animation.* 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.animation.core.tween
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight import androidx.core.content.ContextCompat
import androidx.compose.ui.unit.IntOffset import androidx.lifecycle.lifecycleScope
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.google.firebase.FirebaseApp import com.google.firebase.FirebaseApp
import com.google.firebase.messaging.FirebaseMessaging import com.google.firebase.messaging.FirebaseMessaging
import com.rosetta.messenger.crypto.CryptoManager
import com.rosetta.messenger.data.AccountManager import com.rosetta.messenger.data.AccountManager
import com.rosetta.messenger.data.DecryptedAccount import com.rosetta.messenger.data.DecryptedAccount
import com.rosetta.messenger.data.PreferencesManager import com.rosetta.messenger.data.PreferencesManager
import com.rosetta.messenger.data.RecentSearchesManager import com.rosetta.messenger.data.RecentSearchesManager
import com.rosetta.messenger.network.PacketPushNotification import com.rosetta.messenger.network.PacketPushNotification
import com.rosetta.messenger.network.PushNotificationAction
import com.rosetta.messenger.network.ProtocolManager import com.rosetta.messenger.network.ProtocolManager
import com.rosetta.messenger.network.ProtocolState 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.AccountInfo
import com.rosetta.messenger.ui.auth.AuthFlow 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.ChatDetailScreen
import com.rosetta.messenger.ui.chats.ChatsListScreen
import com.rosetta.messenger.ui.chats.SearchScreen 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.components.OptimizedEmojiCache
import com.rosetta.messenger.ui.onboarding.OnboardingScreen import com.rosetta.messenger.ui.onboarding.OnboardingScreen
import com.rosetta.messenger.ui.splash.SplashScreen import com.rosetta.messenger.ui.splash.SplashScreen
import com.rosetta.messenger.ui.theme.RosettaAndroidTheme 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.delay
import kotlinx.coroutines.launch
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
private lateinit var preferencesManager: PreferencesManager private lateinit var preferencesManager: PreferencesManager
@@ -71,6 +53,25 @@ class MainActivity : ComponentActivity() {
companion object { companion object {
private const val TAG = "MainActivity" private const val TAG = "MainActivity"
// 🔔 FCM Логи для отображения в UI
private val _fcmLogs = mutableStateListOf<String>()
val fcmLogs: List<String>
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?) { override fun onCreate(savedInstanceState: Bundle?) {
@@ -94,23 +95,26 @@ class MainActivity : ComponentActivity() {
// Используем новый оптимизированный кэш // Используем новый оптимизированный кэш
OptimizedEmojiCache.preload(this) OptimizedEmojiCache.preload(this)
setContent { // 🔔 Запрос разрешения на уведомления для Android 13+ setContent { // 🔔 Запрос разрешения на уведомления для Android 13+
val notificationPermissionLauncher = rememberLauncherForActivityResult( val notificationPermissionLauncher =
contract = ActivityResultContracts.RequestPermission(), rememberLauncherForActivityResult(
onResult = { isGranted -> contract = ActivityResultContracts.RequestPermission(),
} onResult = { isGranted -> }
) )
// Запрашиваем разрешение при первом запуске (Android 13+) // Запрашиваем разрешение при первом запуске (Android 13+)
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
val hasPermission = ContextCompat.checkSelfPermission( val hasPermission =
this@MainActivity, ContextCompat.checkSelfPermission(
Manifest.permission.POST_NOTIFICATIONS this@MainActivity,
) == PackageManager.PERMISSION_GRANTED Manifest.permission.POST_NOTIFICATIONS
) == PackageManager.PERMISSION_GRANTED
if (!hasPermission) { if (!hasPermission) {
notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) notificationPermissionLauncher.launch(
Manifest.permission.POST_NOTIFICATIONS
)
} }
} }
} }
@@ -130,145 +134,172 @@ class MainActivity : ComponentActivity() {
accountManager.logout() // Always start logged out accountManager.logout() // Always start logged out
val accounts = accountManager.getAllAccounts() val accounts = accountManager.getAllAccounts()
hasExistingAccount = accounts.isNotEmpty() hasExistingAccount = accounts.isNotEmpty()
accountInfoList = accounts.map { account -> accountInfoList =
val shortKey = account.publicKey.take(7) accounts.map { account ->
val displayName = account.name ?: shortKey val shortKey = account.publicKey.take(7)
val initials = displayName.trim().split(Regex("\\s+")) val displayName = account.name ?: shortKey
.filter { it.isNotEmpty() } val initials =
.let { words -> displayName
when { .trim()
words.isEmpty() -> "??" .split(Regex("\\s+"))
words.size == 1 -> words[0].take(2).uppercase() .filter { it.isNotEmpty() }
else -> "${words[0].first()}${words[1].first()}".uppercase() .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 // Wait for initial load
if (hasExistingAccount == null) { if (hasExistingAccount == null) {
Box( Box(
modifier = Modifier modifier =
.fillMaxSize() Modifier.fillMaxSize()
.background(if (isDarkTheme) Color(0xFF1B1B1B) else Color.White) .background(
if (isDarkTheme) Color(0xFF1B1B1B) else Color.White
)
) )
return@setContent return@setContent
} }
RosettaAndroidTheme( RosettaAndroidTheme(darkTheme = isDarkTheme, animated = true) {
darkTheme = isDarkTheme,
animated = true
) {
Surface( Surface(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
color = if (isDarkTheme) Color(0xFF1B1B1B) else Color.White color = if (isDarkTheme) Color(0xFF1B1B1B) else Color.White
) { ) {
AnimatedContent( AnimatedContent(
targetState = when { targetState =
showSplash -> "splash" when {
showOnboarding && hasExistingAccount == false -> "onboarding" showSplash -> "splash"
isLoggedIn != true && hasExistingAccount == false -> "auth_new" showOnboarding && hasExistingAccount == false ->
isLoggedIn != true && hasExistingAccount == true -> "auth_unlock" "onboarding"
else -> "main" isLoggedIn != true && hasExistingAccount == false ->
}, "auth_new"
transitionSpec = { isLoggedIn != true && hasExistingAccount == true ->
fadeIn(animationSpec = tween(600)) togetherWith "auth_unlock"
fadeOut(animationSpec = tween(600)) else -> "main"
}, },
label = "screenTransition" transitionSpec = {
fadeIn(animationSpec = tween(600)) togetherWith
fadeOut(animationSpec = tween(600))
},
label = "screenTransition"
) { screen -> ) { screen ->
when (screen) { when (screen) {
"splash" -> { "splash" -> {
SplashScreen( SplashScreen(
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
onSplashComplete = { showSplash = false } onSplashComplete = { showSplash = false }
) )
} }
"onboarding" -> { "onboarding" -> {
OnboardingScreen( OnboardingScreen(
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
onThemeToggle = { onThemeToggle = {
scope.launch { scope.launch {
preferencesManager.setDarkTheme(!isDarkTheme) preferencesManager.setDarkTheme(!isDarkTheme)
} }
}, },
onStartMessaging = { onStartMessaging = { showOnboarding = false }
showOnboarding = false
}
) )
} }
"auth_new", "auth_unlock" -> { "auth_new", "auth_unlock" -> {
AuthFlow( AuthFlow(
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
hasExistingAccount = screen == "auth_unlock", hasExistingAccount = screen == "auth_unlock",
accounts = accountInfoList, accounts = accountInfoList,
accountManager = accountManager, accountManager = accountManager,
onAuthComplete = { account -> onAuthComplete = { account ->
currentAccount = account currentAccount = account
hasExistingAccount = true hasExistingAccount = true
// Save as last logged account // Save as last logged account
account?.let { accountManager.setLastLoggedPublicKey(it.publicKey) } account?.let {
accountManager.setLastLoggedPublicKey(it.publicKey)
}
// 📤 Отправляем FCM токен на сервер после успешной аутентификации // 📤 Отправляем FCM токен на сервер после успешной
account?.let { sendFcmTokenToServer(it) } // аутентификации
account?.let { sendFcmTokenToServer(it) }
// Reload accounts list // Reload accounts list
scope.launch { scope.launch {
val accounts = accountManager.getAllAccounts() val accounts = accountManager.getAllAccounts()
accountInfoList = accounts.map { acc -> accountInfoList =
val shortKey = acc.publicKey.take(7) accounts.map { acc ->
val displayName = acc.name ?: shortKey val shortKey = acc.publicKey.take(7)
val initials = displayName.trim().split(Regex("\\s+")) val displayName = acc.name ?: shortKey
.filter { it.isNotEmpty() } val initials =
.let { words -> displayName
when { .trim()
words.isEmpty() -> "??" .split(Regex("\\s+"))
words.size == 1 -> words[0].take(2).uppercase() .filter {
else -> "${words[0].first()}${words[1].first()}".uppercase() 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, onLogout = {
name = displayName, // Set currentAccount to null immediately to prevent UI
initials = initials, // lag
publicKey = acc.publicKey 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" -> { "main" -> {
MainScreen( MainScreen(
account = currentAccount, account = currentAccount,
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
onToggleTheme = { onToggleTheme = {
scope.launch { scope.launch {
preferencesManager.setDarkTheme(!isDarkTheme) 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()
}
}
) )
} }
} }
@@ -290,44 +321,51 @@ class MainActivity : ComponentActivity() {
com.rosetta.messenger.push.RosettaFirebaseMessagingService.isAppInForeground = false com.rosetta.messenger.push.RosettaFirebaseMessagingService.isAppInForeground = false
} }
/** /** 🔔 Инициализация Firebase Cloud Messaging */
* 🔔 Инициализация Firebase Cloud Messaging
*/
private fun initializeFirebase() { private fun initializeFirebase() {
try { try {
addFcmLog("🔔 Инициализация Firebase...")
// Инициализируем Firebase // Инициализируем Firebase
FirebaseApp.initializeApp(this) FirebaseApp.initializeApp(this)
addFcmLog("✅ Firebase инициализирован")
// Получаем FCM токен // Получаем FCM токен
addFcmLog("📲 Запрос FCM токена...")
FirebaseMessaging.getInstance().token.addOnCompleteListener { task -> FirebaseMessaging.getInstance().token.addOnCompleteListener { task ->
if (!task.isSuccessful) { if (!task.isSuccessful) {
addFcmLog("❌ Ошибка получения токена: ${task.exception?.message}")
return@addOnCompleteListener return@addOnCompleteListener
} }
val token = task.result val token = task.result
// Сохраняем токен локально if (token != null) {
token?.let { saveFcmToken(it) } val shortToken = "${token.take(12)}...${token.takeLast(8)}"
addFcmLog("✅ FCM токен получен: $shortToken")
// Сохраняем токен локально
saveFcmToken(token)
addFcmLog("💾 Токен сохранен локально")
} else {
addFcmLog("⚠️ Токен пустой")
}
// Токен будет отправлен на сервер после успешной аутентификации // Токен будет отправлен на сервер после успешной аутентификации
// (см. вызов sendFcmTokenToServer в onAccountLogin) // (см. вызов sendFcmTokenToServer в onAccountLogin)
} }
} catch (e: Exception) { } catch (e: Exception) {
addFcmLog("❌ Ошибка Firebase: ${e.message}")
} }
} }
/** /** Сохранить FCM токен в SharedPreferences */
* Сохранить FCM токен в SharedPreferences
*/
private fun saveFcmToken(token: String) { private fun saveFcmToken(token: String) {
val prefs = getSharedPreferences("rosetta_prefs", MODE_PRIVATE) val prefs = getSharedPreferences("rosetta_prefs", MODE_PRIVATE)
prefs.edit().putString("fcm_token", token).apply() prefs.edit().putString("fcm_token", token).apply()
} }
/** /**
* Отправить FCM токен на сервер * Отправить FCM токен на сервер Вызывается после успешной аутентификации, когда аккаунт уже
* Вызывается после успешной аутентификации, когда аккаунт уже расшифрован * расшифрован
*/ */
private fun sendFcmTokenToServer(account: DecryptedAccount) { private fun sendFcmTokenToServer(account: DecryptedAccount) {
lifecycleScope.launch { lifecycleScope.launch {
@@ -336,27 +374,41 @@ class MainActivity : ComponentActivity() {
val token = prefs.getString("fcm_token", null) val token = prefs.getString("fcm_token", null)
if (token == null) { if (token == null) {
addFcmLog("⚠️ Нет сохраненного токена для отправки")
return@launch return@launch
} }
val shortToken = "${token.take(12)}...${token.takeLast(8)}"
addFcmLog("📤 Подготовка к отправке токена на сервер")
addFcmLog("⏳ Ожидание аутентификации...")
// 🔥 КРИТИЧНО: Ждем пока протокол станет AUTHENTICATED // 🔥 КРИТИЧНО: Ждем пока протокол станет AUTHENTICATED
var waitAttempts = 0 var waitAttempts = 0
while (ProtocolManager.state.value != ProtocolState.AUTHENTICATED && waitAttempts < 50) { while (ProtocolManager.state.value != ProtocolState.AUTHENTICATED &&
waitAttempts < 50) {
delay(100) // Ждем 100ms delay(100) // Ждем 100ms
waitAttempts++ waitAttempts++
} }
if (ProtocolManager.state.value != ProtocolState.AUTHENTICATED) { if (ProtocolManager.state.value != ProtocolState.AUTHENTICATED) {
addFcmLog("❌ Таймаут аутентификации (${waitAttempts * 100}ms)")
return@launch return@launch
} }
val packet = PacketPushNotification().apply { addFcmLog("✅ Аутентификация успешна")
this.notificationsToken = token addFcmLog("📨 Отправка токена: $shortToken")
this.action = PushNotificationAction.SUBSCRIBE
} val packet =
PacketPushNotification().apply {
this.notificationsToken = token
this.action = PushNotificationAction.SUBSCRIBE
}
ProtocolManager.send(packet) ProtocolManager.send(packet)
addFcmLog("✅ Пакет отправлен на сервер (ID: 0x10)")
addFcmLog("🎉 FCM токен успешно зарегистрирован!")
} catch (e: Exception) { } catch (e: Exception) {
addFcmLog("❌ Ошибка отправки: ${e.message}")
} }
} }
} }
@@ -364,15 +416,17 @@ class MainActivity : ComponentActivity() {
@Composable @Composable
fun MainScreen( fun MainScreen(
account: DecryptedAccount? = null, account: DecryptedAccount? = null,
isDarkTheme: Boolean = true, isDarkTheme: Boolean = true,
onToggleTheme: () -> Unit = {}, onToggleTheme: () -> Unit = {},
onLogout: () -> Unit = {} onLogout: () -> Unit = {}
) { ) {
val accountName = account?.name ?: "Account" val accountName = account?.name ?: "Account"
val accountPhone = account?.publicKey?.take(16)?.let { val accountPhone =
"+${it.take(1)} ${it.substring(1, 4)} ${it.substring(4, 7)}${it.substring(7)}" account?.publicKey?.take(16)?.let {
} ?: "+7 775 9932587" "+${it.take(1)} ${it.substring(1, 4)} ${it.substring(4, 7)}${it.substring(7)}"
}
?: "+7 775 9932587"
val accountPublicKey = account?.publicKey ?: "04c266b98ae5" val accountPublicKey = account?.publicKey ?: "04c266b98ae5"
val accountPrivateKey = account?.privateKey ?: "" val accountPrivateKey = account?.privateKey ?: ""
val privateKeyHash = account?.privateKeyHash ?: "" val privateKeyHash = account?.privateKeyHash ?: ""
@@ -386,155 +440,138 @@ fun MainScreen(
// 🔥 TELEGRAM-STYLE анимация - чистый slide БЕЗ прозрачности // 🔥 TELEGRAM-STYLE анимация - чистый slide БЕЗ прозрачности
AnimatedContent( AnimatedContent(
targetState = Triple(selectedUser, showSearchScreen, Unit), targetState = Triple(selectedUser, showSearchScreen, Unit),
transitionSpec = { transitionSpec = {
val isEnteringChat = targetState.first != null && initialState.first == null val isEnteringChat = targetState.first != null && initialState.first == null
val isExitingChat = targetState.first == null && initialState.first != null val isExitingChat = targetState.first == null && initialState.first != null
val isEnteringSearch = targetState.second && !initialState.second val isEnteringSearch = targetState.second && !initialState.second
val isExitingSearch = !targetState.second && initialState.second val isExitingSearch = !targetState.second && initialState.second
when { when {
// 🚀 Вход в чат - плавный fade // 🚀 Вход в чат - плавный fade
isEnteringChat -> { isEnteringChat -> {
fadeIn( fadeIn(animationSpec = tween(200)) togetherWith
animationSpec = tween(200) fadeOut(animationSpec = tween(150))
) togetherWith fadeOut( }
animationSpec = tween(150)
)
}
// 🔙 Выход из чата - плавный fade // 🔙 Выход из чата - плавный fade
isExitingChat -> { isExitingChat -> {
fadeIn( fadeIn(animationSpec = tween(200)) togetherWith
animationSpec = tween(200) fadeOut(animationSpec = tween(150))
) togetherWith fadeOut( }
animationSpec = tween(150)
)
}
// 🔍 Вход в Search - плавный fade // 🔍 Вход в Search - плавный fade
isEnteringSearch -> { isEnteringSearch -> {
fadeIn( fadeIn(animationSpec = tween(200)) togetherWith
animationSpec = tween(200) fadeOut(animationSpec = tween(150))
) togetherWith fadeOut( }
animationSpec = tween(150)
)
}
// 🔙 Выход из Search - плавный fade // 🔙 Выход из Search - плавный fade
isEnteringSearch -> { isEnteringSearch -> {
fadeIn( fadeIn(animationSpec = tween(200)) togetherWith
animationSpec = tween(200) fadeOut(animationSpec = tween(150))
) togetherWith fadeOut( }
animationSpec = tween(150)
)
}
// 🔙 Выход из Search - плавный fade // 🔙 Выход из Search - плавный fade
isExitingSearch -> { isExitingSearch -> {
fadeIn( fadeIn(animationSpec = tween(200)) togetherWith
animationSpec = tween(200) fadeOut(animationSpec = tween(150))
) togetherWith fadeOut( }
animationSpec = tween(150)
)
}
// Default - мгновенный переход // Default - мгновенный переход
else -> { else -> {
EnterTransition.None togetherWith ExitTransition.None EnterTransition.None togetherWith ExitTransition.None
}
} }
} },
}, label = "screenNavigation"
label = "screenNavigation"
) { (currentUser, isSearchOpen, _) -> ) { (currentUser, isSearchOpen, _) ->
when { when {
currentUser != null -> { currentUser != null -> {
// Экран чата // Экран чата
ChatDetailScreen( ChatDetailScreen(
user = currentUser, user = currentUser,
currentUserPublicKey = accountPublicKey, currentUserPublicKey = accountPublicKey,
currentUserPrivateKey = accountPrivateKey, currentUserPrivateKey = accountPrivateKey,
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
onBack = { selectedUser = null }, onBack = { selectedUser = null },
onNavigateToChat = { publicKey -> onNavigateToChat = { publicKey ->
// 📨 Forward: переход в выбранный чат // 📨 Forward: переход в выбранный чат
// Нужно получить SearchUser из публичного ключа // Нужно получить SearchUser из публичного ключа
// Используем минимальные данные - остальное подгрузится в ChatDetailScreen // Используем минимальные данные - остальное подгрузится в
selectedUser = SearchUser( // ChatDetailScreen
title = "", selectedUser =
username = "", SearchUser(
publicKey = publicKey, title = "",
verified = 0, username = "",
online = 0 publicKey = publicKey,
) verified = 0,
} online = 0
)
}
) )
} }
isSearchOpen -> { isSearchOpen -> {
// Экран поиска // Экран поиска
SearchScreen( SearchScreen(
privateKeyHash = privateKeyHash, privateKeyHash = privateKeyHash,
currentUserPublicKey = accountPublicKey, currentUserPublicKey = accountPublicKey,
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
protocolState = protocolState, protocolState = protocolState,
onBackClick = { showSearchScreen = false }, onBackClick = { showSearchScreen = false },
onUserSelect = { selectedSearchUser -> onUserSelect = { selectedSearchUser ->
showSearchScreen = false showSearchScreen = false
selectedUser = selectedSearchUser selectedUser = selectedSearchUser
} }
) )
} }
else -> { else -> {
// Список чатов // Список чатов
ChatsListScreen( ChatsListScreen(
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
accountName = accountName, accountName = accountName,
accountPhone = accountPhone, accountPhone = accountPhone,
accountPublicKey = accountPublicKey, accountPublicKey = accountPublicKey,
accountPrivateKey = accountPrivateKey, accountPrivateKey = accountPrivateKey,
privateKeyHash = privateKeyHash, privateKeyHash = privateKeyHash,
onToggleTheme = onToggleTheme, onToggleTheme = onToggleTheme,
onProfileClick = { onProfileClick = {
// TODO: Navigate to profile // TODO: Navigate to profile
}, },
onNewGroupClick = { onNewGroupClick = {
// TODO: Navigate to new group // TODO: Navigate to new group
}, },
onContactsClick = { onContactsClick = {
// TODO: Navigate to contacts // TODO: Navigate to contacts
}, },
onCallsClick = { onCallsClick = {
// TODO: Navigate to calls // TODO: Navigate to calls
}, },
onSavedMessagesClick = { onSavedMessagesClick = {
// Открываем чат с самим собой (Saved Messages) // Открываем чат с самим собой (Saved Messages)
selectedUser = SearchUser( selectedUser =
title = "Saved Messages", SearchUser(
username = "", title = "Saved Messages",
publicKey = accountPublicKey, username = "",
verified = 0, publicKey = accountPublicKey,
online = 1 verified = 0,
) online = 1
}, )
onSettingsClick = { },
// TODO: Navigate to settings onSettingsClick = {
}, // TODO: Navigate to settings
onInviteFriendsClick = { },
// TODO: Share invite link onInviteFriendsClick = {
}, // TODO: Share invite link
onSearchClick = { },
showSearchScreen = true onSearchClick = { showSearchScreen = true },
}, onNewChat = {
onNewChat = { // TODO: Show new chat screen
// TODO: Show new chat screen },
}, onUserSelect = { selectedChatUser -> selectedUser = selectedChatUser },
onUserSelect = { selectedChatUser -> onLogout = onLogout
selectedUser = selectedChatUser
},
onLogout = onLogout
) )
} }
} }
} }
} }

View File

@@ -6,19 +6,14 @@ import android.app.PendingIntent
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.Build import android.os.Build
import android.util.Log
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import com.google.firebase.messaging.FirebaseMessagingService import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage import com.google.firebase.messaging.RemoteMessage
import com.rosetta.messenger.MainActivity import com.rosetta.messenger.MainActivity
import com.rosetta.messenger.R 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.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
/** /**
* Firebase Cloud Messaging Service для обработки push-уведомлений * Firebase Cloud Messaging Service для обработки push-уведомлений
@@ -39,14 +34,10 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
private const val NOTIFICATION_ID = 1 private const val NOTIFICATION_ID = 1
// 🔥 Флаг - приложение в foreground (видимо пользователю) // 🔥 Флаг - приложение в foreground (видимо пользователю)
@Volatile @Volatile var isAppInForeground = false
var isAppInForeground = false
} }
/** /** Вызывается когда получен новый FCM токен Отправляем его на сервер через протокол */
* Вызывается когда получен новый FCM токен
* Отправляем его на сервер через протокол
*/
override fun onNewToken(token: String) { override fun onNewToken(token: String) {
super.onNewToken(token) super.onNewToken(token)
@@ -56,18 +47,16 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
// 📤 Токен будет отправлен на сервер после успешного логина в MainActivity // 📤 Токен будет отправлен на сервер после успешного логина в MainActivity
} }
/** /** Вызывается когда получено push-уведомление */
* Вызывается когда получено push-уведомление
*/
override fun onMessageReceived(remoteMessage: RemoteMessage) { override fun onMessageReceived(remoteMessage: RemoteMessage) {
super.onMessageReceived(remoteMessage) super.onMessageReceived(remoteMessage)
// Обрабатываем data payload // Обрабатываем data payload
remoteMessage.data.isNotEmpty().let { remoteMessage.data.isNotEmpty().let {
val type = remoteMessage.data["type"] val type = remoteMessage.data["type"]
val senderPublicKey = remoteMessage.data["sender_public_key"] 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" val messagePreview = remoteMessage.data["message_preview"] ?: "New message"
when (type) { when (type) {
@@ -78,8 +67,7 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
"message_read" -> { "message_read" -> {
// Сообщение прочитано - можно обновить UI если приложение открыто // Сообщение прочитано - можно обновить UI если приложение открыто
} }
else -> { else -> {}
}
} }
} }
@@ -89,10 +77,12 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
} }
} }
/** /** Показать уведомление о новом сообщении */
* Показать уведомление о новом сообщении private fun showMessageNotification(
*/ senderPublicKey: String?,
private fun showMessageNotification(senderPublicKey: String?, senderName: String, messagePreview: String) { senderName: String,
messagePreview: String
) {
// 🔥 Не показываем уведомление если приложение открыто // 🔥 Не показываем уведомление если приложение открыто
if (isAppInForeground) { if (isAppInForeground) {
return return
@@ -101,35 +91,37 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
createNotificationChannel() createNotificationChannel()
// Intent для открытия чата // Intent для открытия чата
val intent = Intent(this, MainActivity::class.java).apply { val intent =
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK Intent(this, MainActivity::class.java).apply {
putExtra("open_chat", senderPublicKey) flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
} putExtra("open_chat", senderPublicKey)
}
val pendingIntent = PendingIntent.getActivity( val pendingIntent =
this, PendingIntent.getActivity(
0, this,
intent, 0,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE intent,
) PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val notification = NotificationCompat.Builder(this, CHANNEL_ID) val notification =
.setSmallIcon(R.drawable.ic_launcher_foreground) NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle(senderName) .setSmallIcon(R.drawable.ic_notification)
.setContentText(messagePreview) .setContentTitle(senderName)
.setPriority(NotificationCompat.PRIORITY_HIGH) .setContentText(messagePreview)
.setCategory(NotificationCompat.CATEGORY_MESSAGE) .setPriority(NotificationCompat.PRIORITY_HIGH)
.setAutoCancel(true) .setCategory(NotificationCompat.CATEGORY_MESSAGE)
.setContentIntent(pendingIntent) .setAutoCancel(true)
.build() .setContentIntent(pendingIntent)
.build()
val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager val notificationManager =
getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
notificationManager.notify(NOTIFICATION_ID, notification) notificationManager.notify(NOTIFICATION_ID, notification)
} }
/** /** Показать простое уведомление */
* Показать простое уведомление
*/
private fun showSimpleNotification(title: String, body: String) { private fun showSimpleNotification(title: String, body: String) {
// 🔥 Не показываем уведомление если приложение открыто // 🔥 Не показываем уведомление если приложение открыто
if (isAppInForeground) { if (isAppInForeground) {
@@ -138,52 +130,55 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
createNotificationChannel() createNotificationChannel()
val intent = Intent(this, MainActivity::class.java).apply { val intent =
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK Intent(this, MainActivity::class.java).apply {
} flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
}
val pendingIntent = PendingIntent.getActivity( val pendingIntent =
this, PendingIntent.getActivity(
0, this,
intent, 0,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE intent,
) PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val notification = NotificationCompat.Builder(this, CHANNEL_ID) val notification =
.setSmallIcon(R.drawable.ic_launcher_foreground) NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle(title) .setSmallIcon(R.drawable.ic_notification)
.setContentText(body) .setContentTitle(title)
.setPriority(NotificationCompat.PRIORITY_HIGH) .setContentText(body)
.setAutoCancel(true) .setPriority(NotificationCompat.PRIORITY_HIGH)
.setContentIntent(pendingIntent) .setAutoCancel(true)
.build() .setContentIntent(pendingIntent)
.build()
val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager val notificationManager =
getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
notificationManager.notify(NOTIFICATION_ID, notification) notificationManager.notify(NOTIFICATION_ID, notification)
} }
/** /** Создать notification channel для Android 8+ */
* Создать notification channel для Android 8+
*/
private fun createNotificationChannel() { private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel( val channel =
CHANNEL_ID, NotificationChannel(
CHANNEL_NAME, CHANNEL_ID,
NotificationManager.IMPORTANCE_HIGH CHANNEL_NAME,
).apply { NotificationManager.IMPORTANCE_HIGH
description = "Notifications for new messages" )
enableVibration(true) .apply {
} description = "Notifications for new messages"
enableVibration(true)
}
val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager val notificationManager =
getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
notificationManager.createNotificationChannel(channel) notificationManager.createNotificationChannel(channel)
} }
} }
/** /** Сохранить FCM токен в SharedPreferences */
* Сохранить FCM токен в SharedPreferences
*/
private fun saveFcmToken(token: String) { private fun saveFcmToken(token: String) {
val prefs = getSharedPreferences("rosetta_prefs", Context.MODE_PRIVATE) val prefs = getSharedPreferences("rosetta_prefs", Context.MODE_PRIVATE)
prefs.edit().putString("fcm_token", token).apply() prefs.edit().putString("fcm_token", token).apply()

File diff suppressed because it is too large Load Diff

View File

@@ -1,32 +1,27 @@
package com.rosetta.messenger.ui.onboarding package com.rosetta.messenger.ui.onboarding
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateColorAsState import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.* 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.Canvas
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.PagerDefaults import androidx.compose.foundation.pager.PagerDefaults
import androidx.compose.foundation.pager.PagerSnapDistance
import androidx.compose.foundation.pager.PagerState import androidx.compose.foundation.pager.PagerState
import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons 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.DarkMode
import androidx.compose.material.icons.filled.LightMode
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.draw.rotate import androidx.compose.ui.draw.rotate
import androidx.compose.ui.draw.scale 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.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.boundsInWindow import androidx.compose.ui.layout.boundsInWindow
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
@@ -45,36 +41,38 @@ import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.core.view.WindowCompat import androidx.core.view.WindowCompat
import com.airbnb.lottie.compose.*
import com.rosetta.messenger.R
import com.rosetta.messenger.ui.theme.* import com.rosetta.messenger.ui.theme.*
import kotlinx.coroutines.delay
import kotlin.math.absoluteValue import kotlin.math.absoluteValue
import kotlin.math.hypot import kotlin.math.hypot
import com.airbnb.lottie.compose.* import kotlinx.coroutines.delay
import androidx.compose.ui.res.painterResource
import com.rosetta.messenger.R
import androidx.compose.foundation.Image
// App colors (matching React Native) // App colors (matching React Native)
val PrimaryBlue = Color(0xFF248AE6) // primary light theme val PrimaryBlue = Color(0xFF248AE6) // primary light theme
val PrimaryBlueDark = Color(0xFF1A72C2) // primary dark theme val PrimaryBlueDark = Color(0xFF1A72C2) // primary dark theme
val LightBlue = Color(0xFF74C0FC) // lightBlue val LightBlue = Color(0xFF74C0FC) // lightBlue
val OnboardingBackground = Color(0xFF1E1E1E) // dark background val OnboardingBackground = Color(0xFF1E1E1E) // dark background
val OnboardingBackgroundLight = Color(0xFFFFFFFF) val OnboardingBackgroundLight = Color(0xFFFFFFFF)
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
fun OnboardingScreen( fun OnboardingScreen(
isDarkTheme: Boolean, isDarkTheme: Boolean,
onThemeToggle: () -> Unit, onThemeToggle: () -> Unit,
onStartMessaging: () -> Unit onStartMessaging: () -> Unit
) { ) {
val pagerState = rememberPagerState(pageCount = { onboardingPages.size }) val pagerState = rememberPagerState(pageCount = { onboardingPages.size })
// Preload Lottie animations // Preload Lottie animations
val ideaComposition by rememberLottieComposition(LottieCompositionSpec.Asset("lottie/Idea.json")) val ideaComposition by
val moneyComposition by rememberLottieComposition(LottieCompositionSpec.Asset("lottie/Money.json")) rememberLottieComposition(LottieCompositionSpec.Asset("lottie/Idea.json"))
val lockComposition by rememberLottieComposition(LottieCompositionSpec.Asset("lottie/lock.json")) val moneyComposition by
val bookComposition by rememberLottieComposition(LottieCompositionSpec.Asset("lottie/Book.json")) 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 // Theme transition animation
var isTransitioning by remember { mutableStateOf(false) } var isTransitioning by remember { mutableStateOf(false) }
@@ -85,9 +83,7 @@ fun OnboardingScreen(
var previousTheme by remember { mutableStateOf(isDarkTheme) } var previousTheme by remember { mutableStateOf(isDarkTheme) }
var targetTheme by remember { mutableStateOf(isDarkTheme) } var targetTheme by remember { mutableStateOf(isDarkTheme) }
LaunchedEffect(Unit) { LaunchedEffect(Unit) { hasInitialized = true }
hasInitialized = true
}
LaunchedEffect(isTransitioning) { LaunchedEffect(isTransitioning) {
if (isTransitioning) { if (isTransitioning) {
@@ -132,7 +128,8 @@ fun OnboardingScreen(
val g = (g1 + (g2 - g1) * navProgress).toInt() val g = (g1 + (g2 - g1) * navProgress).toInt()
val b = (b1 + (b2 - b1) * 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()
} }
} }
@@ -155,39 +152,51 @@ fun OnboardingScreen(
} }
} }
val backgroundColor by animateColorAsState( val backgroundColor by
targetValue = if (isDarkTheme) OnboardingBackground else OnboardingBackgroundLight, animateColorAsState(
animationSpec = if (!hasInitialized) snap() else tween(800, easing = FastOutSlowInEasing), targetValue =
label = "backgroundColor" if (isDarkTheme) OnboardingBackground else OnboardingBackgroundLight,
) animationSpec =
val textColor by animateColorAsState( if (!hasInitialized) snap()
targetValue = if (isDarkTheme) Color.White else Color.Black, else tween(800, easing = FastOutSlowInEasing),
animationSpec = if (!hasInitialized) snap() else tween(800, easing = FastOutSlowInEasing), label = "backgroundColor"
label = "textColor" )
) val textColor by
val secondaryTextColor by animateColorAsState( animateColorAsState(
targetValue = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666), targetValue = if (isDarkTheme) Color.White else Color.Black,
animationSpec = if (!hasInitialized) snap() else tween(800, easing = FastOutSlowInEasing), animationSpec =
label = "secondaryTextColor" if (!hasInitialized) snap()
) else tween(800, easing = FastOutSlowInEasing),
val indicatorColor by animateColorAsState( label = "textColor"
targetValue = if (isDarkTheme) Color(0xFF4A4A4A) else Color(0xFFD0D0D0), )
animationSpec = if (!hasInitialized) snap() else tween(800, easing = FastOutSlowInEasing), val secondaryTextColor by
label = "indicatorColor" 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( Box(modifier = Modifier.fillMaxSize().navigationBarsPadding()) {
modifier = Modifier
.fillMaxSize()
.navigationBarsPadding()
) {
// Base background - shows the OLD theme color during transition // Base background - shows the OLD theme color during transition
Box( Box(
modifier = Modifier modifier =
.fillMaxSize() Modifier.fillMaxSize()
.background(if (isTransitioning) { .background(
if (previousTheme) OnboardingBackground else OnboardingBackgroundLight if (isTransitioning) {
} else backgroundColor) if (previousTheme) OnboardingBackground
else OnboardingBackgroundLight
} else backgroundColor
)
) )
// Circular reveal overlay - draws the NEW theme color expanding // Circular reveal overlay - draws the NEW theme color expanding
@@ -198,74 +207,79 @@ fun OnboardingScreen(
// Draw the NEW theme color expanding from click point // Draw the NEW theme color expanding from click point
drawCircle( drawCircle(
color = if (targetTheme) OnboardingBackground else OnboardingBackgroundLight, color =
radius = radius, if (targetTheme) OnboardingBackground
center = clickPosition else OnboardingBackgroundLight,
radius = radius,
center = clickPosition
) )
} }
} }
// Theme toggle button in top right // Theme toggle button in top right
ThemeToggleButton( ThemeToggleButton(
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
onToggle = { position -> onToggle = { position ->
if (!isTransitioning) { if (!isTransitioning) {
previousTheme = isDarkTheme previousTheme = isDarkTheme
targetTheme = !isDarkTheme targetTheme = !isDarkTheme
clickPosition = position clickPosition = position
isTransitioning = true isTransitioning = true
onThemeToggle() onThemeToggle()
} }
}, },
modifier = Modifier modifier = Modifier.align(Alignment.TopEnd).padding(16.dp).statusBarsPadding()
.align(Alignment.TopEnd)
.padding(16.dp)
.statusBarsPadding()
) )
Column( Column(
modifier = Modifier modifier = Modifier.fillMaxSize().padding(horizontal = 24.dp),
.fillMaxSize() horizontalAlignment = Alignment.CenterHorizontally
.padding(horizontal = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) { ) {
Spacer(modifier = Modifier.weight(0.15f)) Spacer(modifier = Modifier.weight(0.15f))
// Animated Logo // Animated Logo
AnimatedRosettaLogo( AnimatedRosettaLogo(
pagerState = pagerState, pagerState = pagerState,
ideaComposition = ideaComposition, ideaComposition = ideaComposition,
moneyComposition = moneyComposition, moneyComposition = moneyComposition,
lockComposition = lockComposition, lockComposition = lockComposition,
bookComposition = bookComposition, bookComposition = bookComposition,
modifier = Modifier.size(150.dp) modifier = Modifier.size(150.dp)
) )
Spacer(modifier = Modifier.height(32.dp)) Spacer(modifier = Modifier.height(32.dp))
// Pager for text content with easier swipes // Pager for text content with easier swipes
HorizontalPager( 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, state = pagerState,
lowVelocityAnimationSpec = snap(), // Instant! modifier =
snapAnimationSpec = snap() // No animation! 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 -> ) { page ->
OnboardingPageContent( OnboardingPageContent(
page = onboardingPages[page], page = onboardingPages[page],
textColor = textColor, textColor = textColor,
secondaryTextColor = secondaryTextColor, secondaryTextColor = secondaryTextColor,
highlightColor = PrimaryBlue, highlightColor = PrimaryBlue,
pageOffset = ((pagerState.currentPage - page) + pagerState.currentPageOffsetFraction).absoluteValue pageOffset =
((pagerState.currentPage - page) +
pagerState.currentPageOffsetFraction)
.absoluteValue
) )
} }
@@ -273,20 +287,18 @@ fun OnboardingScreen(
// Page indicators // Page indicators
PagerIndicator( PagerIndicator(
pageCount = onboardingPages.size, pageCount = onboardingPages.size,
currentPage = pagerState.currentPage, currentPage = pagerState.currentPage,
selectedColor = PrimaryBlue, selectedColor = PrimaryBlue,
unselectedColor = indicatorColor unselectedColor = indicatorColor
) )
Spacer(modifier = Modifier.weight(0.3f)) Spacer(modifier = Modifier.weight(0.3f))
// Start messaging button // Start messaging button
StartMessagingButton( StartMessagingButton(
onClick = onStartMessaging, onClick = onStartMessaging,
modifier = Modifier modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp)
.fillMaxWidth()
.padding(horizontal = 16.dp)
) )
Spacer(modifier = Modifier.height(48.dp)) Spacer(modifier = Modifier.height(48.dp))
@@ -296,33 +308,30 @@ fun OnboardingScreen(
@Composable @Composable
fun ThemeToggleButton( fun ThemeToggleButton(
isDarkTheme: Boolean, isDarkTheme: Boolean,
onToggle: (androidx.compose.ui.geometry.Offset) -> Unit, onToggle: (androidx.compose.ui.geometry.Offset) -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val rotation by animateFloatAsState( val rotation by
targetValue = if (isDarkTheme) 360f else 0f, animateFloatAsState(
animationSpec = spring( targetValue = if (isDarkTheme) 360f else 0f,
dampingRatio = 0.6f, animationSpec = spring(dampingRatio = 0.6f, stiffness = Spring.StiffnessLow),
stiffness = Spring.StiffnessLow label = "rotation"
), )
label = "rotation"
)
val scale by animateFloatAsState( val scale by
targetValue = 1f, animateFloatAsState(
animationSpec = spring( targetValue = 1f,
dampingRatio = 0.4f, animationSpec = spring(dampingRatio = 0.4f, stiffness = Spring.StiffnessMedium),
stiffness = Spring.StiffnessMedium label = "scale"
), )
label = "scale"
)
val iconColor by animateColorAsState( val iconColor by
targetValue = if (isDarkTheme) Color.White else Color(0xFF2A2A2A), animateColorAsState(
animationSpec = tween(800, easing = FastOutSlowInEasing), targetValue = if (isDarkTheme) Color.White else Color(0xFF2A2A2A),
label = "iconColor" animationSpec = tween(800, easing = FastOutSlowInEasing),
) label = "iconColor"
)
var buttonPosition by remember { mutableStateOf(androidx.compose.ui.geometry.Offset.Zero) } var buttonPosition by remember { mutableStateOf(androidx.compose.ui.geometry.Offset.Zero) }
var isClickable by remember { mutableStateOf(true) } var isClickable by remember { mutableStateOf(true) }
@@ -334,30 +343,29 @@ fun ThemeToggleButton(
} }
IconButton( IconButton(
onClick = { if (isClickable) onToggle(buttonPosition) }, onClick = { if (isClickable) onToggle(buttonPosition) },
enabled = isClickable, enabled = isClickable,
modifier = modifier modifier =
.size(48.dp) modifier.size(48.dp).onGloballyPositioned { coordinates ->
.onGloballyPositioned { coordinates -> val bounds = coordinates.boundsInWindow()
val bounds = coordinates.boundsInWindow() buttonPosition =
buttonPosition = androidx.compose.ui.geometry.Offset( androidx.compose.ui.geometry.Offset(
x = bounds.center.x, x = bounds.center.x,
y = bounds.center.y y = bounds.center.y
) )
} }
) { ) {
Box( Box(
modifier = Modifier modifier = Modifier.size(24.dp).scale(scale).rotate(rotation),
.size(24.dp) contentAlignment = Alignment.Center
.scale(scale)
.rotate(rotation),
contentAlignment = Alignment.Center
) { ) {
Icon( Icon(
imageVector = if (isDarkTheme) Icons.Default.LightMode else Icons.Default.DarkMode, imageVector =
contentDescription = if (isDarkTheme) "Switch to Light Mode" else "Switch to Dark Mode", if (isDarkTheme) Icons.Default.LightMode else Icons.Default.DarkMode,
tint = iconColor, contentDescription =
modifier = Modifier.size(24.dp) if (isDarkTheme) "Switch to Light Mode" else "Switch to Dark Mode",
tint = iconColor,
modifier = Modifier.size(24.dp)
) )
} }
} }
@@ -366,12 +374,12 @@ fun ThemeToggleButton(
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
fun AnimatedRosettaLogo( fun AnimatedRosettaLogo(
pagerState: PagerState, pagerState: PagerState,
ideaComposition: Any?, ideaComposition: Any?,
moneyComposition: Any?, moneyComposition: Any?,
lockComposition: Any?, lockComposition: Any?,
bookComposition: Any?, bookComposition: Any?,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
// Use derivedStateOf for optimized reads - prevents unnecessary recompositions // Use derivedStateOf for optimized reads - prevents unnecessary recompositions
val currentPage by remember { derivedStateOf { pagerState.currentPage } } val currentPage by remember { derivedStateOf { pagerState.currentPage } }
@@ -383,149 +391,150 @@ fun AnimatedRosettaLogo(
val bookLottieComp = bookComposition as? com.airbnb.lottie.LottieComposition val bookLottieComp = bookComposition as? com.airbnb.lottie.LottieComposition
// All animations are always "playing" but only visible one shows // All animations are always "playing" but only visible one shows
val ideaProgress by animateLottieCompositionAsState( val ideaProgress by
composition = ideaLottieComp, animateLottieCompositionAsState(
iterations = 1, composition = ideaLottieComp,
isPlaying = currentPage == 1, iterations = 1,
speed = 1.5f, isPlaying = currentPage == 1,
restartOnPlay = true speed = 1.5f,
) restartOnPlay = true
val moneyProgress by animateLottieCompositionAsState( )
composition = moneyLottieComp, val moneyProgress by
iterations = 1, animateLottieCompositionAsState(
isPlaying = currentPage == 2, composition = moneyLottieComp,
speed = 1.5f, iterations = 1,
restartOnPlay = true isPlaying = currentPage == 2,
) speed = 1.5f,
val lockProgress by animateLottieCompositionAsState( restartOnPlay = true
composition = lockLottieComp, )
iterations = 1, val lockProgress by
isPlaying = currentPage == 3, animateLottieCompositionAsState(
speed = 1.5f, composition = lockLottieComp,
restartOnPlay = true iterations = 1,
) isPlaying = currentPage == 3,
val bookProgress by animateLottieCompositionAsState( speed = 1.5f,
composition = bookLottieComp, restartOnPlay = true
iterations = 1, )
isPlaying = currentPage == 4, val bookProgress by
speed = 1.5f, animateLottieCompositionAsState(
restartOnPlay = true composition = bookLottieComp,
) iterations = 1,
isPlaying = currentPage == 4,
speed = 1.5f,
restartOnPlay = true
)
// Pulse animation for logo (always running, cheap) // Pulse animation for logo (always running, cheap)
val pulseScale by rememberInfiniteTransition(label = "pulse").animateFloat( val pulseScale by
initialValue = 1f, rememberInfiniteTransition(label = "pulse")
targetValue = 1.1f, .animateFloat(
animationSpec = infiniteRepeatable( initialValue = 1f,
animation = tween(800, easing = FastOutSlowInEasing), targetValue = 1.1f,
repeatMode = RepeatMode.Reverse animationSpec =
), infiniteRepeatable(
label = "pulseScale" animation = tween(800, easing = FastOutSlowInEasing),
) repeatMode = RepeatMode.Reverse
),
label = "pulseScale"
)
Box( Box(modifier = modifier, contentAlignment = Alignment.Center) {
modifier = modifier,
contentAlignment = Alignment.Center
) {
// === PRE-RENDERED LAYERS - All always exist, just alpha changes === // === PRE-RENDERED LAYERS - All always exist, just alpha changes ===
// Page 0: Rosetta Logo // Page 0: Rosetta Logo
Box( Box(
modifier = Modifier modifier =
.fillMaxSize() Modifier.fillMaxSize().graphicsLayer {
.graphicsLayer { alpha = if (currentPage == 0) 1f else 0f
alpha = if (currentPage == 0) 1f else 0f },
}, contentAlignment = Alignment.Center
contentAlignment = Alignment.Center
) { ) {
// Glow effect // Glow effect
Box( Box(
modifier = Modifier modifier =
.size(180.dp) Modifier.size(180.dp)
.scale(if (currentPage == 0) pulseScale else 1f) .scale(if (currentPage == 0) pulseScale else 1f)
.background( .background(
color = Color(0xFF54A9EB).copy(alpha = 0.2f), color = Color(0xFF54A9EB).copy(alpha = 0.2f),
shape = CircleShape shape = CircleShape
) )
) )
// Main logo // Main logo
Image( Image(
painter = painterResource(id = R.drawable.rosetta_icon), painter = painterResource(id = R.drawable.rosetta_icon),
contentDescription = "Rosetta Logo", contentDescription = "Rosetta Logo",
modifier = Modifier modifier = Modifier.size(150.dp).clip(CircleShape)
.size(150.dp)
.clip(CircleShape)
) )
} }
// Page 1: Idea animation (always in memory!) // Page 1: Idea animation (always in memory!)
if (ideaLottieComp != null) { if (ideaLottieComp != null) {
LottieAnimation( LottieAnimation(
composition = ideaLottieComp, composition = ideaLottieComp,
progress = { ideaProgress }, progress = { ideaProgress },
modifier = Modifier modifier =
.fillMaxSize() Modifier.fillMaxSize().graphicsLayer {
.graphicsLayer { alpha = if (currentPage == 1) 1f else 0f
alpha = if (currentPage == 1) 1f else 0f // Hardware layer optimization
// Hardware layer optimization compositingStrategy =
compositingStrategy = androidx.compose.ui.graphics.CompositingStrategy.Offscreen androidx.compose.ui.graphics.CompositingStrategy.Offscreen
// Disable clipping for performance // Disable clipping for performance
clip = false clip = false
}, },
maintainOriginalImageBounds = true, maintainOriginalImageBounds = true,
// Disable dynamic properties for max performance // Disable dynamic properties for max performance
enableMergePaths = false enableMergePaths = false
) )
} }
// Page 2: Money animation // Page 2: Money animation
if (moneyLottieComp != null) { if (moneyLottieComp != null) {
LottieAnimation( LottieAnimation(
composition = moneyLottieComp, composition = moneyLottieComp,
progress = { moneyProgress }, progress = { moneyProgress },
modifier = Modifier modifier =
.fillMaxSize() Modifier.fillMaxSize().graphicsLayer {
.graphicsLayer { alpha = if (currentPage == 2) 1f else 0f
alpha = if (currentPage == 2) 1f else 0f compositingStrategy =
compositingStrategy = androidx.compose.ui.graphics.CompositingStrategy.Offscreen androidx.compose.ui.graphics.CompositingStrategy.Offscreen
clip = false clip = false
}, },
maintainOriginalImageBounds = true, maintainOriginalImageBounds = true,
enableMergePaths = false enableMergePaths = false
) )
} }
// Page 3: Lock animation // Page 3: Lock animation
if (lockLottieComp != null) { if (lockLottieComp != null) {
LottieAnimation( LottieAnimation(
composition = lockLottieComp, composition = lockLottieComp,
progress = { lockProgress }, progress = { lockProgress },
modifier = Modifier modifier =
.fillMaxSize() Modifier.fillMaxSize().graphicsLayer {
.graphicsLayer { alpha = if (currentPage == 3) 1f else 0f
alpha = if (currentPage == 3) 1f else 0f compositingStrategy =
compositingStrategy = androidx.compose.ui.graphics.CompositingStrategy.Offscreen androidx.compose.ui.graphics.CompositingStrategy.Offscreen
clip = false clip = false
}, },
maintainOriginalImageBounds = true, maintainOriginalImageBounds = true,
enableMergePaths = false enableMergePaths = false
) )
} }
// Page 4: Book animation // Page 4: Book animation
if (bookLottieComp != null) { if (bookLottieComp != null) {
LottieAnimation( LottieAnimation(
composition = bookLottieComp, composition = bookLottieComp,
progress = { bookProgress }, progress = { bookProgress },
modifier = Modifier modifier =
.fillMaxSize() Modifier.fillMaxSize().graphicsLayer {
.graphicsLayer { alpha = if (currentPage == 4) 1f else 0f
alpha = if (currentPage == 4) 1f else 0f compositingStrategy =
compositingStrategy = androidx.compose.ui.graphics.CompositingStrategy.Offscreen androidx.compose.ui.graphics.CompositingStrategy.Offscreen
clip = false clip = false
}, },
maintainOriginalImageBounds = true, maintainOriginalImageBounds = true,
enableMergePaths = false enableMergePaths = false
) )
} }
} }
@@ -533,40 +542,41 @@ fun AnimatedRosettaLogo(
@Composable @Composable
fun OnboardingPageContent( fun OnboardingPageContent(
page: OnboardingPage, page: OnboardingPage,
textColor: Color, textColor: Color,
secondaryTextColor: Color, secondaryTextColor: Color,
highlightColor: Color, highlightColor: Color,
pageOffset: Float pageOffset: Float
) { ) {
val alpha by animateFloatAsState( val alpha by
targetValue = 1f - (pageOffset * 0.7f).coerceIn(0f, 1f), animateFloatAsState(
animationSpec = tween(400, easing = FastOutSlowInEasing), targetValue = 1f - (pageOffset * 0.7f).coerceIn(0f, 1f),
label = "alpha" animationSpec = tween(400, easing = FastOutSlowInEasing),
) label = "alpha"
val scale by animateFloatAsState( )
targetValue = 1f - (pageOffset * 0.1f).coerceIn(0f, 1f), val scale by
animationSpec = tween(400, easing = FastOutSlowInEasing), animateFloatAsState(
label = "scale" targetValue = 1f - (pageOffset * 0.1f).coerceIn(0f, 1f),
) animationSpec = tween(400, easing = FastOutSlowInEasing),
label = "scale"
)
Column( Column(
modifier = Modifier modifier =
.fillMaxWidth() Modifier.fillMaxWidth().graphicsLayer {
.graphicsLayer { this.alpha = alpha
this.alpha = alpha scaleX = scale
scaleX = scale scaleY = scale
scaleY = scale },
}, horizontalAlignment = Alignment.CenterHorizontally
horizontalAlignment = Alignment.CenterHorizontally
) { ) {
// Title // Title
Text( Text(
text = page.title, text = page.title,
fontSize = 32.sp, fontSize = 32.sp,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
color = textColor, color = textColor,
textAlign = TextAlign.Center textAlign = TextAlign.Center
) )
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
@@ -603,98 +613,101 @@ fun OnboardingPageContent(
} }
Text( Text(
text = annotatedDescription, text = annotatedDescription,
fontSize = 17.sp, fontSize = 17.sp,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
lineHeight = 24.sp lineHeight = 24.sp
) )
} }
} }
@Composable @Composable
fun PagerIndicator( fun PagerIndicator(
pageCount: Int, pageCount: Int,
currentPage: Int, currentPage: Int,
selectedColor: Color, selectedColor: Color,
unselectedColor: Color, unselectedColor: Color,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
Row( Row(
modifier = modifier, modifier = modifier,
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
repeat(pageCount) { index -> repeat(pageCount) { index ->
val isSelected = index == currentPage val isSelected = index == currentPage
val width by animateDpAsState( val width by
targetValue = if (isSelected) 20.dp else 8.dp, animateDpAsState(
animationSpec = spring(dampingRatio = 0.8f), targetValue = if (isSelected) 20.dp else 8.dp,
label = "indicatorWidth" animationSpec = spring(dampingRatio = 0.8f),
) label = "indicatorWidth"
)
Box( Box(
modifier = Modifier modifier =
.height(8.dp) Modifier.height(8.dp)
.width(width) .width(width)
.clip(CircleShape) .clip(CircleShape)
.background(if (isSelected) selectedColor else unselectedColor) .background(if (isSelected) selectedColor else unselectedColor)
) )
} }
} }
} }
@Composable @Composable
fun StartMessagingButton( fun StartMessagingButton(onClick: () -> Unit, modifier: Modifier = Modifier) {
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
// Shining effect animation // Shining effect animation
val infiniteTransition = rememberInfiniteTransition(label = "shine") val infiniteTransition = rememberInfiniteTransition(label = "shine")
val shimmerTranslate by infiniteTransition.animateFloat( val shimmerTranslate by
initialValue = -0.5f, infiniteTransition.animateFloat(
targetValue = 1.5f, initialValue = -0.5f,
animationSpec = infiniteRepeatable( targetValue = 1.5f,
animation = tween(2000, easing = LinearEasing), animationSpec =
repeatMode = RepeatMode.Restart infiniteRepeatable(
), animation = tween(2000, easing = LinearEasing),
label = "shimmerTranslate" repeatMode = RepeatMode.Restart
) ),
label = "shimmerTranslate"
)
Surface( Surface(
onClick = onClick, onClick = onClick,
modifier = modifier modifier = modifier.fillMaxWidth().height(54.dp),
.fillMaxWidth() shape = RoundedCornerShape(12.dp),
.height(54.dp), color = PrimaryBlue
shape = RoundedCornerShape(12.dp),
color = PrimaryBlue
) { ) {
Box( Box(
modifier = Modifier modifier =
.fillMaxSize() Modifier.fillMaxSize().drawWithContent {
.drawWithContent { drawContent()
drawContent() // Draw shimmer on top
// Draw shimmer on top val shimmerWidth = size.width * 0.6f
val shimmerWidth = size.width * 0.6f val shimmerStart = shimmerTranslate * size.width
val shimmerStart = shimmerTranslate * size.width drawRect(
drawRect( brush =
brush = Brush.linearGradient( Brush.linearGradient(
colors = listOf( colors =
Color.White.copy(alpha = 0f), listOf(
Color.White.copy(alpha = 0.3f), Color.White.copy(alpha = 0f),
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) start = Offset(shimmerStart, 0f),
) end =
) Offset(
}, shimmerStart + shimmerWidth,
contentAlignment = Alignment.Center size.height
)
)
)
},
contentAlignment = Alignment.Center
) { ) {
Text( Text(
text = "Start Messaging", text = "Start Messaging",
fontSize = 17.sp, fontSize = 17.sp,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
color = Color.White color = Color.White
) )
} }
} }

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M20,2H4c-1.1,0 -1.99,0.9 -1.99,2L2,22l4,-4h14c1.1,0 2,-0.9 2,-2V4c0,-1.1 -0.9,-2 -2,-2zM18,14H6v-2h12v2zM18,11H6V9h12v2zM18,8H6V6h12v2z"/>
</vector>