Refactor code structure for improved readability and maintainability

This commit is contained in:
2026-01-08 21:48:22 +05:00
parent 5b298a37b3
commit b877d6fa73
8 changed files with 51542 additions and 380 deletions

1101
ARCHITECTURE.md Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -25,14 +25,11 @@ import com.rosetta.messenger.data.DecryptedAccount
import com.rosetta.messenger.data.PreferencesManager import com.rosetta.messenger.data.PreferencesManager
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.Chat
import com.rosetta.messenger.ui.chats.ChatsListScreen import com.rosetta.messenger.ui.chats.ChatsListScreen
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.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.util.*
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
private lateinit var preferencesManager: PreferencesManager private lateinit var preferencesManager: PreferencesManager
@@ -176,6 +173,11 @@ class MainActivity : ComponentActivity() {
MainScreen( MainScreen(
account = currentAccount, account = currentAccount,
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
onToggleTheme = {
scope.launch {
preferencesManager.setDarkTheme(!isDarkTheme)
}
},
onLogout = { onLogout = {
scope.launch { scope.launch {
accountManager.logout() accountManager.logout()
@@ -196,53 +198,46 @@ class MainActivity : ComponentActivity() {
fun MainScreen( fun MainScreen(
account: DecryptedAccount? = null, account: DecryptedAccount? = null,
isDarkTheme: Boolean = true, isDarkTheme: Boolean = true,
onToggleTheme: () -> Unit = {},
onLogout: () -> Unit = {} onLogout: () -> Unit = {}
) { ) {
// Demo chats for now val accountName = account?.name ?: "Rosetta User"
val demoChats = remember { val accountPhone = account?.publicKey?.take(16)?.let {
listOf( "+${it.take(1)} ${it.substring(1, 4)} ${it.substring(4, 7)}${it.substring(7)}"
Chat( } ?: "+7 775 9932587"
id = "1",
name = "Alice Johnson",
lastMessage = "Hey! How are you doing?",
lastMessageTime = Date(),
unreadCount = 2,
isOnline = true,
publicKey = "alice_key_123"
),
Chat(
id = "2",
name = "Bob Smith",
lastMessage = "See you tomorrow!",
lastMessageTime = Date(System.currentTimeMillis() - 3600000),
unreadCount = 0,
isOnline = false,
publicKey = "bob_key_456"
),
Chat(
id = "3",
name = "Team Rosetta",
lastMessage = "Great work everyone! 🎉",
lastMessageTime = Date(System.currentTimeMillis() - 86400000),
unreadCount = 5,
isOnline = true,
publicKey = "team_key_789"
)
)
}
ChatsListScreen( ChatsListScreen(
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
chats = demoChats, accountName = accountName,
onChatClick = { chat -> accountPhone = accountPhone,
// TODO: Navigate to chat detail onToggleTheme = onToggleTheme,
onProfileClick = {
// TODO: Navigate to profile
},
onNewGroupClick = {
// TODO: Navigate to new group
},
onContactsClick = {
// TODO: Navigate to contacts
},
onCallsClick = {
// TODO: Navigate to calls
},
onSavedMessagesClick = {
// TODO: Navigate to saved messages
},
onSettingsClick = {
// TODO: Navigate to settings
},
onInviteFriendsClick = {
// TODO: Share invite link
},
onSearchClick = {
// TODO: Show search
}, },
onNewChat = { onNewChat = {
// TODO: Show new chat screen // TODO: Show new chat screen
}, },
onProfileClick = onLogout, // For now, logout on profile click onLogout = onLogout
onSavedMessagesClick = {
// TODO: Navigate to saved messages
}
) )
} }

View File

@@ -73,9 +73,10 @@ object CryptoManager {
} }
/** /**
* Generate key pair from private key using secp256k1 curve * Generate key pair from seed phrase using secp256k1 curve
*/ */
fun generateKeyPairFromSeed(privateKeyHex: String): KeyPairData { fun generateKeyPairFromSeed(seedPhrase: List<String>): KeyPairData {
val privateKeyHex = seedPhraseToPrivateKey(seedPhrase)
val ecSpec = ECNamedCurveTable.getParameterSpec("secp256k1") val ecSpec = ECNamedCurveTable.getParameterSpec("secp256k1")
// Use first 32 bytes of private key for secp256k1 // Use first 32 bytes of private key for secp256k1
@@ -109,7 +110,7 @@ object CryptoManager {
/** /**
* Encrypt data with password using PBKDF2 + AES * Encrypt data with password using PBKDF2 + AES
*/ */
fun encryptWithPassword(password: String, data: String): String { fun encryptWithPassword(data: String, password: String): String {
// Compress data // Compress data
val compressed = compress(data.toByteArray()) val compressed = compress(data.toByteArray())
@@ -139,7 +140,7 @@ object CryptoManager {
/** /**
* Decrypt data with password * Decrypt data with password
*/ */
fun decryptWithPassword(password: String, encryptedData: String): String? { fun decryptWithPassword(encryptedData: String, password: String): String? {
return try { return try {
val parts = encryptedData.split(":") val parts = encryptedData.split(":")
if (parts.size != 2) return null if (parts.size != 2) return null

View File

@@ -0,0 +1,251 @@
package com.rosetta.messenger.providers
import android.content.Context
import androidx.compose.runtime.*
import com.rosetta.messenger.data.AccountManager
import com.rosetta.messenger.data.DecryptedAccount
import com.rosetta.messenger.data.EncryptedAccount
import com.rosetta.messenger.crypto.CryptoManager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* Auth state management - similar to AuthContext in React Native version
*/
sealed class AuthStatus {
object Loading : AuthStatus()
object Unauthenticated : AuthStatus()
data class Authenticated(val account: DecryptedAccount) : AuthStatus()
data class Locked(val publicKey: String) : AuthStatus()
}
data class AuthStateData(
val status: AuthStatus = AuthStatus.Loading,
val accounts: List<EncryptedAccount> = emptyList(),
val hasExistingAccounts: Boolean = false
)
class AuthStateManager(
private val context: Context,
private val scope: CoroutineScope
) {
private val accountManager = AccountManager(context)
private val _state = MutableStateFlow(AuthStateData())
val state: StateFlow<AuthStateData> = _state.asStateFlow()
init {
scope.launch {
loadAccounts()
checkAuthStatus()
}
}
private suspend fun loadAccounts() = withContext(Dispatchers.IO) {
val accounts = accountManager.getAllAccounts()
_state.update { it.copy(
accounts = accounts,
hasExistingAccounts = accounts.isNotEmpty()
)}
}
private suspend fun checkAuthStatus() {
accountManager.isLoggedIn.collect { isLoggedIn ->
if (isLoggedIn) {
accountManager.currentPublicKey.first()?.let { publicKey ->
_state.update { it.copy(
status = AuthStatus.Locked(publicKey)
)}
}
} else {
_state.update { it.copy(
status = AuthStatus.Unauthenticated
)}
}
}
}
/**
* Create new account from seed phrase
*/
suspend fun createAccount(
seedPhrase: List<String>,
password: String,
name: String = "Rosetta Account"
): Result<DecryptedAccount> = withContext(Dispatchers.Default) {
try {
val keyPair = CryptoManager.generateKeyPairFromSeed(seedPhrase)
val privateKeyHash = CryptoManager.generatePrivateKeyHash(keyPair.privateKey)
val encryptedPrivateKey = CryptoManager.encryptWithPassword(
keyPair.privateKey, password
)
val encryptedSeedPhrase = CryptoManager.encryptWithPassword(
seedPhrase.joinToString(" "), password
)
withContext(Dispatchers.IO) {
val encryptedAccount = EncryptedAccount(
publicKey = keyPair.publicKey,
encryptedPrivateKey = encryptedPrivateKey,
encryptedSeedPhrase = encryptedSeedPhrase,
name = name
)
accountManager.saveAccount(encryptedAccount)
accountManager.setCurrentAccount(keyPair.publicKey)
}
val decryptedAccount = DecryptedAccount(
publicKey = keyPair.publicKey,
privateKey = keyPair.privateKey,
seedPhrase = seedPhrase,
privateKeyHash = privateKeyHash,
name = name
)
_state.update { it.copy(
status = AuthStatus.Authenticated(decryptedAccount)
)}
loadAccounts()
Result.success(decryptedAccount)
} catch (e: Exception) {
Result.failure(e)
}
}
/**
* Import existing account from seed phrase
*/
suspend fun importAccount(
seedPhrase: List<String>,
password: String,
name: String = "Imported Account"
): Result<DecryptedAccount> {
return createAccount(seedPhrase, password, name)
}
/**
* Unlock account with password
*/
suspend fun unlock(
publicKey: String,
password: String
): Result<DecryptedAccount> = withContext(Dispatchers.Default) {
try {
val encryptedAccount = withContext(Dispatchers.IO) {
accountManager.getAccount(publicKey)
} ?: return@withContext Result.failure(Exception("Account not found"))
val privateKey = CryptoManager.decryptWithPassword(
encryptedAccount.encryptedPrivateKey, password
) ?: return@withContext Result.failure(Exception("Invalid password"))
val seedPhraseStr = CryptoManager.decryptWithPassword(
encryptedAccount.encryptedSeedPhrase, password
) ?: return@withContext Result.failure(Exception("Invalid password"))
val seedPhrase = seedPhraseStr.split(" ")
val keyPair = CryptoManager.generateKeyPairFromSeed(seedPhrase)
if (keyPair.publicKey != publicKey) {
return@withContext Result.failure(Exception("Invalid password or corrupted data"))
}
val privateKeyHash = CryptoManager.generatePrivateKeyHash(privateKey)
val decryptedAccount = DecryptedAccount(
publicKey = publicKey,
privateKey = privateKey,
seedPhrase = seedPhrase,
privateKeyHash = privateKeyHash,
name = encryptedAccount.name
)
withContext(Dispatchers.IO) {
accountManager.setCurrentAccount(publicKey)
}
_state.update { it.copy(
status = AuthStatus.Authenticated(decryptedAccount)
)}
Result.success(decryptedAccount)
} catch (e: Exception) {
Result.failure(e)
}
}
/**
* Lock account
*/
fun lock() {
val currentStatus = _state.value.status
if (currentStatus is AuthStatus.Authenticated) {
_state.update { it.copy(
status = AuthStatus.Locked(currentStatus.account.publicKey)
)}
}
}
/**
* Logout
*/
suspend fun logout() {
withContext(Dispatchers.IO) {
accountManager.logout()
}
_state.update { it.copy(
status = AuthStatus.Unauthenticated
)}
loadAccounts()
}
/**
* Switch to different account
*/
fun switchAccount(publicKey: String) {
_state.update { it.copy(
status = AuthStatus.Locked(publicKey)
)}
}
/**
* Delete account permanently
*/
suspend fun deleteAccount(publicKey: String) = withContext(Dispatchers.IO) {
val accounts = accountManager.getAllAccounts().toMutableList()
accounts.removeAll { it.publicKey == publicKey }
accountManager.clearAll()
accounts.forEach { accountManager.saveAccount(it) }
loadAccounts()
val currentStatus = _state.value.status
if ((currentStatus is AuthStatus.Authenticated && currentStatus.account.publicKey == publicKey) ||
(currentStatus is AuthStatus.Locked && currentStatus.publicKey == publicKey)) {
logout()
}
}
}
@Composable
fun rememberAuthState(context: Context): AuthStateManager {
val scope = rememberCoroutineScope()
return remember(context) {
AuthStateManager(context, scope)
}
}
@Composable
fun ProvideAuthState(
authState: AuthStateManager,
content: @Composable (AuthStateData) -> Unit
) {
val state by authState.state.collectAsState()
content(state)
}

View File

@@ -405,15 +405,14 @@ fun SetPasswordScreen(
scope.launch { scope.launch {
try { try {
// Generate keys from seed phrase // Generate keys from seed phrase
val privateKey = CryptoManager.seedPhraseToPrivateKey(seedPhrase) val keyPair = CryptoManager.generateKeyPairFromSeed(seedPhrase)
val keyPair = CryptoManager.generateKeyPairFromSeed(privateKey)
// Encrypt private key and seed phrase // Encrypt private key and seed phrase
val encryptedPrivateKey = CryptoManager.encryptWithPassword( val encryptedPrivateKey = CryptoManager.encryptWithPassword(
password, keyPair.privateKey keyPair.privateKey, password
) )
val encryptedSeedPhrase = CryptoManager.encryptWithPassword( val encryptedSeedPhrase = CryptoManager.encryptWithPassword(
password, seedPhrase.joinToString(" ") seedPhrase.joinToString(" "), password
) )
// Save account // Save account

View File

@@ -226,7 +226,7 @@ fun UnlockScreen(
// Try to decrypt // Try to decrypt
val decryptedPrivateKey = CryptoManager.decryptWithPassword( val decryptedPrivateKey = CryptoManager.decryptWithPassword(
password, account.encryptedPrivateKey account.encryptedPrivateKey, password
) )
if (decryptedPrivateKey == null) { if (decryptedPrivateKey == null) {
@@ -236,7 +236,7 @@ fun UnlockScreen(
} }
val decryptedSeedPhrase = CryptoManager.decryptWithPassword( val decryptedSeedPhrase = CryptoManager.decryptWithPassword(
password, account.encryptedSeedPhrase account.encryptedSeedPhrase, password
)?.split(" ") ?: emptyList() )?.split(" ") ?: emptyList()
val privateKeyHash = CryptoManager.generatePrivateKeyHash(decryptedPrivateKey) val privateKeyHash = CryptoManager.generatePrivateKeyHash(decryptedPrivateKey)

View File

@@ -6,24 +6,35 @@ import androidx.compose.foundation.*
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.pager.HorizontalPager
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.* import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.outlined.*
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
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.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
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 com.airbnb.lottie.compose.*
import com.rosetta.messenger.R
import com.rosetta.messenger.ui.onboarding.PrimaryBlue import com.rosetta.messenger.ui.onboarding.PrimaryBlue
import kotlinx.coroutines.launch
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.* import java.util.*
import androidx.compose.runtime.Immutable
@Immutable
data class Chat( data class Chat(
val id: String, val id: String,
val name: String, val name: String,
@@ -32,50 +43,82 @@ data class Chat(
val unreadCount: Int = 0, val unreadCount: Int = 0,
val isOnline: Boolean = false, val isOnline: Boolean = false,
val publicKey: String, val publicKey: String,
val isSavedMessages: Boolean = false val isSavedMessages: Boolean = false,
val isPinned: Boolean = false
) )
// Beautiful avatar colors // Avatar colors like in React Native app
private val avatarColors = listOf( private val avatarColors = listOf(
Color(0xFF5E9FFF) to Color(0xFFE8F1FF), // Blue Color(0xFF5E9FFF), // Blue
Color(0xFFFF7EB3) to Color(0xFFFFEEF4), // Pink Color(0xFFFF7EB3), // Pink
Color(0xFF7B68EE) to Color(0xFFF0EDFF), // Purple Color(0xFF7B68EE), // Purple
Color(0xFF50C878) to Color(0xFFE8F8EE), // Green Color(0xFF50C878), // Green
Color(0xFFFF6B6B) to Color(0xFFFFEEEE), // Red Color(0xFFFF6B6B), // Red
Color(0xFF4ECDC4) to Color(0xFFE8F8F7), // Teal Color(0xFF4ECDC4), // Teal
Color(0xFFFFB347) to Color(0xFFFFF5E8), // Orange Color(0xFFFFB347), // Orange
Color(0xFFBA55D3) to Color(0xFFF8EEFF) // Orchid Color(0xFFBA55D3) // Orchid
) )
fun getAvatarColor(name: String, isDark: Boolean): Pair<Color, Color> { // Cache для цветов аватаров - избегаем вычисления каждый раз
val index = name.hashCode().mod(avatarColors.size).let { if (it < 0) it + avatarColors.size else it } private val avatarColorCache = mutableMapOf<String, Color>()
val (primary, light) = avatarColors[index]
return if (isDark) primary to primary.copy(alpha = 0.2f) else primary to light
}
fun getInitials(name: String): String { fun getAvatarColor(name: String): Color {
val words = name.trim().split(Regex("\\s+")).filter { it.isNotEmpty() } return avatarColorCache.getOrPut(name) {
return when { val index = name.hashCode().mod(avatarColors.size).let {
words.isEmpty() -> "??" if (it < 0) it + avatarColors.size else it
words.size == 1 -> words[0].take(2).uppercase() }
else -> "${words[0].first()}${words[1].first()}".uppercase() avatarColors[index]
} }
} }
// Cache для инициалов
private val initialsCache = mutableMapOf<String, String>()
fun getInitials(name: String): String {
return initialsCache.getOrPut(name) {
val words = name.trim().split(Regex("\\s+")).filter { it.isNotEmpty() }
when {
words.isEmpty() -> "??"
words.size == 1 -> words[0].take(2).uppercase()
else -> "${words[0].first()}${words[1].first()}".uppercase()
}
}
}
// Drawer menu item
data class DrawerMenuItem(
val icon: ImageVector,
val title: String,
val onClick: () -> Unit,
val badge: Int? = null
)
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun ChatsListScreen( fun ChatsListScreen(
isDarkTheme: Boolean, isDarkTheme: Boolean,
chats: List<Chat>, accountName: String,
onChatClick: (Chat) -> Unit, accountPhone: String,
onNewChat: () -> Unit, onToggleTheme: () -> Unit,
onProfileClick: () -> Unit, onProfileClick: () -> Unit,
onSavedMessagesClick: () -> Unit onNewGroupClick: () -> Unit,
onContactsClick: () -> Unit,
onCallsClick: () -> Unit,
onSavedMessagesClick: () -> Unit,
onSettingsClick: () -> Unit,
onInviteFriendsClick: () -> Unit,
onSearchClick: () -> Unit,
onNewChat: () -> Unit,
onLogout: () -> Unit
) { ) {
val backgroundColor = if (isDarkTheme) Color(0xFF1E1E1E) else Color(0xFFFFFFFF) val backgroundColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFFFFFFF)
val drawerBackgroundColor = if (isDarkTheme) Color(0xFF212121) else Color(0xFFFFFFFF)
val textColor = if (isDarkTheme) Color.White else Color.Black val textColor = if (isDarkTheme) Color.White else Color.Black
val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666) val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
val surfaceColor = if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFF5F5F5) val dividerColor = if (isDarkTheme) Color(0xFF3A3A3A) else Color(0xFFE8E8E8)
val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
val scope = rememberCoroutineScope()
var visible by remember { mutableStateOf(false) } var visible by remember { mutableStateOf(false) }
@@ -83,378 +126,494 @@ fun ChatsListScreen(
visible = true visible = true
} }
Scaffold( // Drawer menu items
topBar = { val menuItems = listOf(
AnimatedVisibility( DrawerMenuItem(
visible = visible, icon = Icons.Outlined.Person,
enter = fadeIn(tween(400)) + slideInVertically( title = "My Profile",
initialOffsetY = { -it }, onClick = onProfileClick
animationSpec = tween(400) ),
) DrawerMenuItem(
icon = Icons.Outlined.Group,
title = "New Group",
onClick = onNewGroupClick
),
DrawerMenuItem(
icon = Icons.Outlined.Settings,
title = "Settings",
onClick = onSettingsClick
)
)
ModalNavigationDrawer(
drawerState = drawerState,
drawerContent = {
ModalDrawerSheet(
modifier = Modifier.width(300.dp),
drawerContainerColor = drawerBackgroundColor
) { ) {
TopAppBar( // Header with logo and theme toggle
title = { Box(
modifier = Modifier
.fillMaxWidth()
.background(if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFF5F5F5))
.padding(16.dp)
) {
Column {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.Top
) {
// Avatar with initials
Box(
modifier = Modifier
.size(64.dp)
.clip(CircleShape)
.background(PrimaryBlue),
contentAlignment = Alignment.Center
) {
Text(
text = getInitials(accountName),
fontSize = 24.sp,
fontWeight = FontWeight.Bold,
color = Color.White
)
}
// Theme toggle
IconButton(
onClick = onToggleTheme
) {
Icon(
if (isDarkTheme) Icons.Default.LightMode else Icons.Default.DarkMode,
contentDescription = "Toggle theme",
tint = textColor
)
}
}
Spacer(modifier = Modifier.height(16.dp))
// Account name
Text( Text(
"Chats", text = accountName,
fontWeight = FontWeight.Bold, fontSize = 18.sp,
fontSize = 28.sp fontWeight = FontWeight.SemiBold,
) color = textColor
},
actions = {
IconButton(onClick = onNewChat) {
Icon(
Icons.Default.Edit,
contentDescription = "New Chat",
tint = PrimaryBlue
)
}
IconButton(onClick = onProfileClick) {
Icon(
Icons.Default.Person,
contentDescription = "Profile",
tint = textColor
)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = backgroundColor,
titleContentColor = textColor
)
)
}
},
containerColor = backgroundColor
) { paddingValues ->
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues),
contentPadding = PaddingValues(vertical = 8.dp)
) {
// Saved Messages section
item {
AnimatedVisibility(
visible = visible,
enter = fadeIn(tween(400, delayMillis = 100)) + slideInHorizontally(
initialOffsetX = { -50 },
animationSpec = tween(400, delayMillis = 100)
)
) {
SavedMessagesItem(
isDarkTheme = isDarkTheme,
onClick = onSavedMessagesClick
)
}
}
// Chat items
items(chats, key = { it.id }) { chat ->
val index = chats.indexOf(chat)
AnimatedVisibility(
visible = visible,
enter = fadeIn(tween(400, delayMillis = 150 + (index * 50))) + slideInHorizontally(
initialOffsetX = { -50 },
animationSpec = tween(400, delayMillis = 150 + (index * 50))
)
) {
ChatItem(
chat = chat,
isDarkTheme = isDarkTheme,
onClick = { onChatClick(chat) }
)
}
}
// Empty state
if (chats.isEmpty()) {
item {
AnimatedVisibility(
visible = visible,
enter = fadeIn(tween(400, delayMillis = 200)) + scaleIn(
initialScale = 0.9f,
animationSpec = tween(400, delayMillis = 200)
)
) {
EmptyChatsState(
isDarkTheme = isDarkTheme,
onNewChat = onNewChat
) )
} }
} }
// Menu items
menuItems.forEach { item ->
DrawerItem(
icon = item.icon,
title = item.title,
onClick = {
scope.launch { drawerState.close() }
item.onClick()
},
isDarkTheme = isDarkTheme
)
}
} }
} }
) {
Scaffold(
topBar = {
AnimatedVisibility(
visible = visible,
enter = fadeIn(tween(400)) + slideInVertically(
initialOffsetY = { -it },
animationSpec = tween(400)
)
) {
TopAppBar(
navigationIcon = {
IconButton(
onClick = { scope.launch { drawerState.open() } }
) {
Icon(
Icons.Default.Menu,
contentDescription = "Menu",
tint = textColor
)
}
},
title = {
// Stories / Title area
Row(
verticalAlignment = Alignment.CenterVertically
) {
// User story avatar placeholder
Box(
modifier = Modifier
.size(36.dp)
.clip(CircleShape)
.background(
brush = androidx.compose.ui.graphics.Brush.linearGradient(
colors = listOf(
Color(0xFF405DE6),
Color(0xFFC13584),
Color(0xFFFD1D1D)
)
)
)
.padding(2.dp)
.clip(CircleShape)
.background(backgroundColor),
contentAlignment = Alignment.Center
) {
Box(
modifier = Modifier
.size(30.dp)
.clip(CircleShape)
.background(getAvatarColor(accountName)),
contentAlignment = Alignment.Center
) {
Text(
text = getInitials(accountName),
fontSize = 12.sp,
fontWeight = FontWeight.Bold,
color = Color.White
)
}
}
Spacer(modifier = Modifier.width(12.dp))
Text(
"Rosetta",
fontWeight = FontWeight.Bold,
fontSize = 20.sp
)
}
},
actions = {
IconButton(onClick = onSearchClick) {
Icon(
Icons.Default.Search,
contentDescription = "Search",
tint = textColor
)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = backgroundColor,
titleContentColor = textColor
)
)
}
},
floatingActionButton = {
AnimatedVisibility(
visible = visible,
enter = fadeIn(tween(500, delayMillis = 300)) + scaleIn(
initialScale = 0.5f,
animationSpec = tween(500, delayMillis = 300)
)
) {
FloatingActionButton(
onClick = onNewChat,
containerColor = PrimaryBlue,
contentColor = Color.White,
shape = CircleShape
) {
Icon(
Icons.Default.Edit,
contentDescription = "New Chat"
)
}
}
},
containerColor = backgroundColor
) { paddingValues ->
// Empty state with Lottie animation
EmptyChatsState(
isDarkTheme = isDarkTheme,
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
)
}
} }
} }
@Composable @Composable
private fun SavedMessagesItem( private fun DrawerItem(
icon: ImageVector,
title: String,
onClick: () -> Unit,
isDarkTheme: Boolean, isDarkTheme: Boolean,
onClick: () -> Unit badge: Int? = null
) { ) {
val textColor = if (isDarkTheme) Color.White else Color.Black val textColor = if (isDarkTheme) Color.White else Color.Black
val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clickable(onClick = onClick) .clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 12.dp), .padding(horizontal = 20.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
// Saved Messages icon Icon(
Box( icon,
modifier = Modifier contentDescription = title,
.size(56.dp) tint = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666),
.clip(CircleShape) modifier = Modifier.size(24.dp)
.background(PrimaryBlue), )
contentAlignment = Alignment.Center
) {
Icon(
Icons.Default.Bookmark,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(28.dp)
)
}
Spacer(modifier = Modifier.width(12.dp)) Spacer(modifier = Modifier.width(20.dp))
Column(modifier = Modifier.weight(1f)) { Text(
Text( text = title,
text = "Saved Messages", fontSize = 16.sp,
fontSize = 17.sp, color = textColor,
fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f)
color = textColor )
)
Text( badge?.let {
text = "Your personal cloud storage", Box(
fontSize = 14.sp, modifier = Modifier
color = secondaryTextColor, .clip(CircleShape)
maxLines = 1, .background(PrimaryBlue)
overflow = TextOverflow.Ellipsis .padding(horizontal = 8.dp, vertical = 2.dp),
) contentAlignment = Alignment.Center
) {
Text(
text = it.toString(),
fontSize = 12.sp,
fontWeight = FontWeight.SemiBold,
color = Color.White
)
}
} }
} }
Divider(
modifier = Modifier.padding(start = 84.dp),
color = if (isDarkTheme) Color(0xFF3A3A3A) else Color(0xFFE8E8E8),
thickness = 0.5.dp
)
} }
@Composable @Composable
private fun ChatItem( private fun EmptyChatsState(
isDarkTheme: Boolean,
modifier: Modifier = Modifier
) {
val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
// Lottie animation
val composition by rememberLottieComposition(
LottieCompositionSpec.RawRes(R.raw.letter)
)
val progress by animateLottieCompositionAsState(
composition = composition,
iterations = 1
)
Column(
modifier = modifier
.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
// Lottie animation
LottieAnimation(
composition = composition,
progress = { progress },
modifier = Modifier.size(150.dp)
)
Spacer(modifier = Modifier.height(24.dp))
Text(
text = "No conversations yet",
fontSize = 18.sp,
fontWeight = FontWeight.SemiBold,
color = secondaryTextColor,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Start a new conversation to get started",
fontSize = 15.sp,
color = secondaryTextColor,
textAlign = TextAlign.Center
)
}
}
// Chat item for list
@Composable
fun ChatItem(
chat: Chat, chat: Chat,
isDarkTheme: Boolean, isDarkTheme: Boolean,
onClick: () -> Unit onClick: () -> Unit
) { ) {
val textColor = if (isDarkTheme) Color.White else Color.Black val textColor = if (isDarkTheme) Color.White else Color.Black
val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666) val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
val dividerColor = if (isDarkTheme) Color(0xFF3A3A3A) else Color(0xFFE8E8E8)
val (avatarTextColor, avatarBgColor) = getAvatarColor(chat.name, isDarkTheme) val avatarColor = getAvatarColor(chat.name)
val initials = getInitials(chat.name) val initials = getInitials(chat.name)
Row( Column {
modifier = Modifier Row(
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
// Avatar
Box(
modifier = Modifier modifier = Modifier
.size(56.dp) .fillMaxWidth()
.clip(CircleShape) .clickable(onClick = onClick)
.background(avatarBgColor), .padding(horizontal = 16.dp, vertical = 12.dp),
contentAlignment = Alignment.Center verticalAlignment = Alignment.CenterVertically
) { ) {
Text( // Avatar
text = initials, Box(
fontSize = 20.sp, modifier = Modifier
fontWeight = FontWeight.SemiBold, .size(56.dp)
color = avatarTextColor .clip(CircleShape)
) .background(avatarColor),
contentAlignment = Alignment.Center
// Online indicator
if (chat.isOnline) {
Box(
modifier = Modifier
.align(Alignment.BottomEnd)
.size(14.dp)
.clip(CircleShape)
.background(if (isDarkTheme) Color(0xFF1E1E1E) else Color.White)
.padding(2.dp)
.clip(CircleShape)
.background(Color(0xFF4CAF50))
)
}
}
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) { ) {
Text( Text(
text = chat.name, text = initials,
fontSize = 17.sp, fontSize = 20.sp,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
color = textColor, color = Color.White
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f)
) )
Text( // Online indicator
text = formatTime(chat.lastMessageTime), if (chat.isOnline) {
fontSize = 13.sp,
color = if (chat.unreadCount > 0) PrimaryBlue else secondaryTextColor
)
}
Spacer(modifier = Modifier.height(4.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = chat.lastMessage,
fontSize = 15.sp,
color = secondaryTextColor,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f)
)
if (chat.unreadCount > 0) {
Box( Box(
modifier = Modifier modifier = Modifier
.padding(start = 8.dp) .align(Alignment.BottomEnd)
.offset(x = 2.dp, y = 2.dp)
.size(16.dp)
.clip(CircleShape) .clip(CircleShape)
.background(PrimaryBlue) .background(if (isDarkTheme) Color(0xFF1A1A1A) else Color.White)
.padding(horizontal = 8.dp, vertical = 2.dp), .padding(2.dp)
contentAlignment = Alignment.Center .clip(CircleShape)
) { .background(Color(0xFF4CAF50))
Text( )
text = if (chat.unreadCount > 99) "99+" else chat.unreadCount.toString(), }
fontSize = 12.sp, }
fontWeight = FontWeight.SemiBold,
color = Color.White Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = chat.name,
fontSize = 16.sp,
fontWeight = FontWeight.SemiBold,
color = textColor,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f)
)
Row(verticalAlignment = Alignment.CenterVertically) {
// Read status
Icon(
Icons.Default.DoneAll,
contentDescription = null,
tint = PrimaryBlue,
modifier = Modifier.size(16.dp)
) )
Spacer(modifier = Modifier.width(4.dp))
Text(
text = formatTime(chat.lastMessageTime),
fontSize = 13.sp,
color = secondaryTextColor
)
}
}
Spacer(modifier = Modifier.height(4.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = chat.lastMessage,
fontSize = 14.sp,
color = secondaryTextColor,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f)
)
Row(verticalAlignment = Alignment.CenterVertically) {
// Pin icon
if (chat.isPinned) {
Icon(
Icons.Default.PushPin,
contentDescription = "Pinned",
tint = secondaryTextColor,
modifier = Modifier
.size(16.dp)
.padding(end = 4.dp)
)
}
// Unread badge
if (chat.unreadCount > 0) {
Box(
modifier = Modifier
.clip(CircleShape)
.background(PrimaryBlue)
.padding(horizontal = 8.dp, vertical = 2.dp),
contentAlignment = Alignment.Center
) {
Text(
text = if (chat.unreadCount > 99) "99+" else chat.unreadCount.toString(),
fontSize = 12.sp,
fontWeight = FontWeight.SemiBold,
color = Color.White
)
}
}
} }
} }
} }
} }
Divider(
modifier = Modifier.padding(start = 84.dp),
color = dividerColor,
thickness = 0.5.dp
)
} }
Divider(
modifier = Modifier.padding(start = 84.dp),
color = if (isDarkTheme) Color(0xFF3A3A3A) else Color(0xFFE8E8E8),
thickness = 0.5.dp
)
} }
@Composable // Cache для SimpleDateFormat - создание дорогостоящее
private fun EmptyChatsState( private val timeFormatCache = java.lang.ThreadLocal.withInitial { SimpleDateFormat("HH:mm", Locale.getDefault()) }
isDarkTheme: Boolean, private val weekFormatCache = java.lang.ThreadLocal.withInitial { SimpleDateFormat("EEE", Locale.getDefault()) }
onNewChat: () -> Unit private val monthFormatCache = java.lang.ThreadLocal.withInitial { SimpleDateFormat("MMM d", Locale.getDefault()) }
) { private val yearFormatCache = java.lang.ThreadLocal.withInitial { SimpleDateFormat("dd.MM.yy", Locale.getDefault()) }
val textColor = if (isDarkTheme) Color.White else Color.Black
val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
Column(
modifier = Modifier
.fillMaxWidth()
.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.height(60.dp))
Box(
modifier = Modifier
.size(80.dp)
.clip(CircleShape)
.background(PrimaryBlue.copy(alpha = 0.1f)),
contentAlignment = Alignment.Center
) {
Icon(
Icons.Default.ChatBubbleOutline,
contentDescription = null,
tint = PrimaryBlue,
modifier = Modifier.size(40.dp)
)
}
Spacer(modifier = Modifier.height(24.dp))
Text(
text = "No chats yet",
fontSize = 20.sp,
fontWeight = FontWeight.SemiBold,
color = textColor
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Start a conversation with\nsomeone new",
fontSize = 15.sp,
color = secondaryTextColor,
textAlign = androidx.compose.ui.text.style.TextAlign.Center
)
Spacer(modifier = Modifier.height(24.dp))
Button(
onClick = onNewChat,
colors = ButtonDefaults.buttonColors(
containerColor = PrimaryBlue,
contentColor = Color.White
),
shape = RoundedCornerShape(12.dp)
) {
Icon(
Icons.Default.Add,
contentDescription = null,
modifier = Modifier.size(20.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text("Start Chat", fontSize = 16.sp)
}
}
}
private fun formatTime(date: Date): String { private fun formatTime(date: Date): String {
val now = Calendar.getInstance() val now = Calendar.getInstance()
val messageTime = Calendar.getInstance().apply { time = date } val messageTime = Calendar.getInstance().apply { time = date }
return when { return when {
// Today
now.get(Calendar.DATE) == messageTime.get(Calendar.DATE) -> { now.get(Calendar.DATE) == messageTime.get(Calendar.DATE) -> {
SimpleDateFormat("HH:mm", Locale.getDefault()).format(date) timeFormatCache.get()?.format(date) ?: ""
} }
// Yesterday
now.get(Calendar.DATE) - messageTime.get(Calendar.DATE) == 1 -> { now.get(Calendar.DATE) - messageTime.get(Calendar.DATE) == 1 -> {
"Yesterday" "Yesterday"
} }
// This week
now.get(Calendar.WEEK_OF_YEAR) == messageTime.get(Calendar.WEEK_OF_YEAR) -> { now.get(Calendar.WEEK_OF_YEAR) == messageTime.get(Calendar.WEEK_OF_YEAR) -> {
SimpleDateFormat("EEE", Locale.getDefault()).format(date) weekFormatCache.get()?.format(date) ?: ""
} }
// This year
now.get(Calendar.YEAR) == messageTime.get(Calendar.YEAR) -> { now.get(Calendar.YEAR) == messageTime.get(Calendar.YEAR) -> {
SimpleDateFormat("MMM d", Locale.getDefault()).format(date) monthFormatCache.get()?.format(date) ?: ""
} }
// Other
else -> { else -> {
SimpleDateFormat("dd.MM.yy", Locale.getDefault()).format(date) yearFormatCache.get()?.format(date) ?: ""
} }
} }
} }

File diff suppressed because it is too large Load Diff