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
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
@@ -11,59 +10,42 @@ 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
@@ -71,6 +53,25 @@ class MainActivity : ComponentActivity() {
companion object {
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?) {
@@ -94,23 +95,26 @@ class MainActivity : ComponentActivity() {
// Используем новый оптимизированный кэш
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
)
}
}
}
@@ -130,145 +134,172 @@ class MainActivity : ComponentActivity() {
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) }
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) }
// 📤 Отправляем 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()
// 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()
}
}
)
}
}
@@ -290,44 +321,51 @@ class MainActivity : ComponentActivity() {
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 {
@@ -336,27 +374,41 @@ class MainActivity : ComponentActivity() {
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,15 +416,17 @@ 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 ?: ""
@@ -386,155 +440,138 @@ fun MainScreen(
// 🔥 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
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)
)
}
when {
// 🚀 Вход в чат - плавный fade
isEnteringChat -> {
fadeIn(animationSpec = tween(200)) togetherWith
fadeOut(animationSpec = tween(150))
}
// 🔙 Выход из чата - плавный fade
isExitingChat -> {
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
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)
)
}
// 🔙 Выход из Search - плавный fade
isExitingSearch -> {
fadeIn(animationSpec = tween(200)) togetherWith
fadeOut(animationSpec = tween(150))
}
// Default - мгновенный переход
else -> {
EnterTransition.None togetherWith ExitTransition.None
// 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
)
}
}
}
}

View File

@@ -6,19 +6,14 @@ 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-уведомлений
@@ -39,14 +34,10 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
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)
@@ -56,18 +47,16 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
// 📤 Токен будет отправлен на сервер после успешного логина в 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) {
@@ -78,8 +67,7 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
"message_read" -> {
// Сообщение прочитано - можно обновить UI если приложение открыто
}
else -> {
}
else -> {}
}
}
@@ -89,10 +77,12 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
}
}
/**
* Показать уведомление о новом сообщении
*/
private fun showMessageNotification(senderPublicKey: String?, senderName: String, messagePreview: String) {
/** Показать уведомление о новом сообщении */
private fun showMessageNotification(
senderPublicKey: String?,
senderName: String,
messagePreview: String
) {
// 🔥 Не показываем уведомление если приложение открыто
if (isAppInForeground) {
return
@@ -101,35 +91,37 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
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 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 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 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
val notificationManager =
getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
notificationManager.notify(NOTIFICATION_ID, notification)
}
/**
* Показать простое уведомление
*/
/** Показать простое уведомление */
private fun showSimpleNotification(title: String, body: String) {
// 🔥 Не показываем уведомление если приложение открыто
if (isAppInForeground) {
@@ -138,52 +130,55 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
createNotificationChannel()
val intent = Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
}
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 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 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
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 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 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()

File diff suppressed because it is too large Load Diff

View File

@@ -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,36 +41,38 @@ 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) }
@@ -85,9 +83,7 @@ fun OnboardingScreen(
var previousTheme by remember { mutableStateOf(isDarkTheme) }
var targetTheme by remember { mutableStateOf(isDarkTheme) }
LaunchedEffect(Unit) {
hasInitialized = true
}
LaunchedEffect(Unit) { hasInitialized = true }
LaunchedEffect(isTransitioning) {
if (isTransitioning) {
@@ -132,7 +128,8 @@ fun OnboardingScreen(
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()
}
}
@@ -155,39 +152,51 @@ fun OnboardingScreen(
}
}
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"
)
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()
) {
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
@@ -198,74 +207,79 @@ fun OnboardingScreen(
// 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
)
}
@@ -273,20 +287,18 @@ fun OnboardingScreen(
// 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,33 +308,30 @@ 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 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 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 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) }
@@ -334,30 +343,29 @@ fun ThemeToggleButton(
}
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,12 +374,12 @@ 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 } }
@@ -383,149 +391,150 @@ fun AnimatedRosettaLogo(
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"
)
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
) {
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,40 +542,41 @@ 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))
@@ -603,98 +613,101 @@ 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
)
}
}

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>