Refactor code structure for improved readability and maintainability

This commit is contained in:
k1ngsterr1
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 getAvatarColor(name: String): Color {
return avatarColorCache.getOrPut(name) {
val index = name.hashCode().mod(avatarColors.size).let {
if (it < 0) it + avatarColors.size else it
}
avatarColors[index]
}
} }
// Cache для инициалов
private val initialsCache = mutableMapOf<String, String>()
fun getInitials(name: String): String { fun getInitials(name: String): String {
return initialsCache.getOrPut(name) {
val words = name.trim().split(Regex("\\s+")).filter { it.isNotEmpty() } val words = name.trim().split(Regex("\\s+")).filter { it.isNotEmpty() }
return when { when {
words.isEmpty() -> "??" words.isEmpty() -> "??"
words.size == 1 -> words[0].take(2).uppercase() words.size == 1 -> words[0].take(2).uppercase()
else -> "${words[0].first()}${words[1].first()}".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,6 +126,100 @@ fun ChatsListScreen(
visible = true visible = true
} }
// Drawer menu items
val menuItems = listOf(
DrawerMenuItem(
icon = Icons.Outlined.Person,
title = "My Profile",
onClick = onProfileClick
),
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
) {
// Header with logo and theme toggle
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 = accountName,
fontSize = 18.sp,
fontWeight = FontWeight.SemiBold,
color = textColor
)
}
}
// Menu items
menuItems.forEach { item ->
DrawerItem(
icon = item.icon,
title = item.title,
onClick = {
scope.launch { drawerState.close() }
item.onClick()
},
isDarkTheme = isDarkTheme
)
}
}
}
) {
Scaffold( Scaffold(
topBar = { topBar = {
AnimatedVisibility( AnimatedVisibility(
@@ -93,25 +230,71 @@ fun ChatsListScreen(
) )
) { ) {
TopAppBar( TopAppBar(
title = { navigationIcon = {
Text( IconButton(
"Chats", onClick = { scope.launch { drawerState.open() } }
fontWeight = FontWeight.Bold, ) {
fontSize = 28.sp
)
},
actions = {
IconButton(onClick = onNewChat) {
Icon( Icon(
Icons.Default.Edit, Icons.Default.Menu,
contentDescription = "New Chat", contentDescription = "Menu",
tint = PrimaryBlue tint = textColor
) )
} }
IconButton(onClick = onProfileClick) { },
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( Icon(
Icons.Default.Person, Icons.Default.Search,
contentDescription = "Profile", contentDescription = "Search",
tint = textColor tint = textColor
) )
} }
@@ -123,138 +306,157 @@ fun ChatsListScreen(
) )
} }
}, },
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 containerColor = backgroundColor
) { paddingValues -> ) { paddingValues ->
LazyColumn( // Empty state with Lottie animation
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( EmptyChatsState(
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
onNewChat = onNewChat 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(
icon,
contentDescription = title,
tint = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666),
modifier = Modifier.size(24.dp)
)
Spacer(modifier = Modifier.width(20.dp))
Text(
text = title,
fontSize = 16.sp,
color = textColor,
modifier = Modifier.weight(1f)
)
badge?.let {
Box( Box(
modifier = Modifier modifier = Modifier
.size(56.dp)
.clip(CircleShape) .clip(CircleShape)
.background(PrimaryBlue), .background(PrimaryBlue)
.padding(horizontal = 8.dp, vertical = 2.dp),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Icon(
Icons.Default.Bookmark,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(28.dp)
)
}
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text( Text(
text = "Saved Messages", text = it.toString(),
fontSize = 17.sp, fontSize = 12.sp,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
color = textColor color = Color.White
)
Text(
text = "Your personal cloud storage",
fontSize = 14.sp,
color = secondaryTextColor,
maxLines = 1,
overflow = TextOverflow.Ellipsis
) )
} }
} }
}
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)
Column {
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -267,14 +469,14 @@ private fun ChatItem(
modifier = Modifier modifier = Modifier
.size(56.dp) .size(56.dp)
.clip(CircleShape) .clip(CircleShape)
.background(avatarBgColor), .background(avatarColor),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Text( Text(
text = initials, text = initials,
fontSize = 20.sp, fontSize = 20.sp,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
color = avatarTextColor color = Color.White
) )
// Online indicator // Online indicator
@@ -282,9 +484,10 @@ private fun ChatItem(
Box( Box(
modifier = Modifier modifier = Modifier
.align(Alignment.BottomEnd) .align(Alignment.BottomEnd)
.size(14.dp) .offset(x = 2.dp, y = 2.dp)
.size(16.dp)
.clip(CircleShape) .clip(CircleShape)
.background(if (isDarkTheme) Color(0xFF1E1E1E) else Color.White) .background(if (isDarkTheme) Color(0xFF1A1A1A) else Color.White)
.padding(2.dp) .padding(2.dp)
.clip(CircleShape) .clip(CircleShape)
.background(Color(0xFF4CAF50)) .background(Color(0xFF4CAF50))
@@ -302,7 +505,7 @@ private fun ChatItem(
) { ) {
Text( Text(
text = chat.name, text = chat.name,
fontSize = 17.sp, fontSize = 16.sp,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
color = textColor, color = textColor,
maxLines = 1, maxLines = 1,
@@ -310,12 +513,22 @@ private fun ChatItem(
modifier = Modifier.weight(1f) 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(
text = formatTime(chat.lastMessageTime), text = formatTime(chat.lastMessageTime),
fontSize = 13.sp, fontSize = 13.sp,
color = if (chat.unreadCount > 0) PrimaryBlue else secondaryTextColor color = secondaryTextColor
) )
} }
}
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(4.dp))
@@ -326,17 +539,30 @@ private fun ChatItem(
) { ) {
Text( Text(
text = chat.lastMessage, text = chat.lastMessage,
fontSize = 15.sp, fontSize = 14.sp,
color = secondaryTextColor, color = secondaryTextColor,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f) 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) { if (chat.unreadCount > 0) {
Box( Box(
modifier = Modifier modifier = Modifier
.padding(start = 8.dp)
.clip(CircleShape) .clip(CircleShape)
.background(PrimaryBlue) .background(PrimaryBlue)
.padding(horizontal = 8.dp, vertical = 2.dp), .padding(horizontal = 8.dp, vertical = 2.dp),
@@ -353,108 +579,41 @@ private fun ChatItem(
} }
} }
} }
}
Divider( Divider(
modifier = Modifier.padding(start = 84.dp), modifier = Modifier.padding(start = 84.dp),
color = if (isDarkTheme) Color(0xFF3A3A3A) else Color(0xFFE8E8E8), color = dividerColor,
thickness = 0.5.dp thickness = 0.5.dp
) )
}
@Composable
private fun EmptyChatsState(
isDarkTheme: Boolean,
onNewChat: () -> Unit
) {
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)
}
} }
} }
// Cache для SimpleDateFormat - создание дорогостоящее
private val timeFormatCache = java.lang.ThreadLocal.withInitial { SimpleDateFormat("HH:mm", Locale.getDefault()) }
private val weekFormatCache = java.lang.ThreadLocal.withInitial { SimpleDateFormat("EEE", Locale.getDefault()) }
private val monthFormatCache = java.lang.ThreadLocal.withInitial { SimpleDateFormat("MMM d", Locale.getDefault()) }
private val yearFormatCache = java.lang.ThreadLocal.withInitial { SimpleDateFormat("dd.MM.yy", Locale.getDefault()) }
private fun formatTime(date: Date): String { 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