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.ui.auth.AccountInfo
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.onboarding.OnboardingScreen
import com.rosetta.messenger.ui.splash.SplashScreen
import com.rosetta.messenger.ui.theme.RosettaAndroidTheme
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import java.util.*
class MainActivity : ComponentActivity() {
private lateinit var preferencesManager: PreferencesManager
@@ -176,6 +173,11 @@ class MainActivity : ComponentActivity() {
MainScreen(
account = currentAccount,
isDarkTheme = isDarkTheme,
onToggleTheme = {
scope.launch {
preferencesManager.setDarkTheme(!isDarkTheme)
}
},
onLogout = {
scope.launch {
accountManager.logout()
@@ -196,53 +198,46 @@ class MainActivity : ComponentActivity() {
fun MainScreen(
account: DecryptedAccount? = null,
isDarkTheme: Boolean = true,
onToggleTheme: () -> Unit = {},
onLogout: () -> Unit = {}
) {
// Demo chats for now
val demoChats = remember {
listOf(
Chat(
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"
)
)
}
val accountName = account?.name ?: "Rosetta User"
val accountPhone = account?.publicKey?.take(16)?.let {
"+${it.take(1)} ${it.substring(1, 4)} ${it.substring(4, 7)}${it.substring(7)}"
} ?: "+7 775 9932587"
ChatsListScreen(
isDarkTheme = isDarkTheme,
chats = demoChats,
onChatClick = { chat ->
// TODO: Navigate to chat detail
accountName = accountName,
accountPhone = accountPhone,
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 = {
// TODO: Show new chat screen
},
onProfileClick = onLogout, // For now, logout on profile click
onSavedMessagesClick = {
// TODO: Navigate to saved messages
}
onLogout = onLogout
)
}

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")
// Use first 32 bytes of private key for secp256k1
@@ -109,7 +110,7 @@ object CryptoManager {
/**
* Encrypt data with password using PBKDF2 + AES
*/
fun encryptWithPassword(password: String, data: String): String {
fun encryptWithPassword(data: String, password: String): String {
// Compress data
val compressed = compress(data.toByteArray())
@@ -139,7 +140,7 @@ object CryptoManager {
/**
* Decrypt data with password
*/
fun decryptWithPassword(password: String, encryptedData: String): String? {
fun decryptWithPassword(encryptedData: String, password: String): String? {
return try {
val parts = encryptedData.split(":")
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 {
try {
// Generate keys from seed phrase
val privateKey = CryptoManager.seedPhraseToPrivateKey(seedPhrase)
val keyPair = CryptoManager.generateKeyPairFromSeed(privateKey)
val keyPair = CryptoManager.generateKeyPairFromSeed(seedPhrase)
// Encrypt private key and seed phrase
val encryptedPrivateKey = CryptoManager.encryptWithPassword(
password, keyPair.privateKey
keyPair.privateKey, password
)
val encryptedSeedPhrase = CryptoManager.encryptWithPassword(
password, seedPhrase.joinToString(" ")
seedPhrase.joinToString(" "), password
)
// Save account

View File

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

View File

@@ -6,24 +6,35 @@ import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
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.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.outlined.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
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.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.airbnb.lottie.compose.*
import com.rosetta.messenger.R
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
import kotlinx.coroutines.launch
import java.text.SimpleDateFormat
import java.util.*
import androidx.compose.runtime.Immutable
@Immutable
data class Chat(
val id: String,
val name: String,
@@ -32,50 +43,82 @@ data class Chat(
val unreadCount: Int = 0,
val isOnline: Boolean = false,
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(
Color(0xFF5E9FFF) to Color(0xFFE8F1FF), // Blue
Color(0xFFFF7EB3) to Color(0xFFFFEEF4), // Pink
Color(0xFF7B68EE) to Color(0xFFF0EDFF), // Purple
Color(0xFF50C878) to Color(0xFFE8F8EE), // Green
Color(0xFFFF6B6B) to Color(0xFFFFEEEE), // Red
Color(0xFF4ECDC4) to Color(0xFFE8F8F7), // Teal
Color(0xFFFFB347) to Color(0xFFFFF5E8), // Orange
Color(0xFFBA55D3) to Color(0xFFF8EEFF) // Orchid
Color(0xFF5E9FFF), // Blue
Color(0xFFFF7EB3), // Pink
Color(0xFF7B68EE), // Purple
Color(0xFF50C878), // Green
Color(0xFFFF6B6B), // Red
Color(0xFF4ECDC4), // Teal
Color(0xFFFFB347), // Orange
Color(0xFFBA55D3) // Orchid
)
fun getAvatarColor(name: String, isDark: Boolean): Pair<Color, Color> {
val index = name.hashCode().mod(avatarColors.size).let { if (it < 0) it + avatarColors.size else it }
val (primary, light) = avatarColors[index]
return if (isDark) primary to primary.copy(alpha = 0.2f) else primary to light
// Cache для цветов аватаров - избегаем вычисления каждый раз
private val avatarColorCache = mutableMapOf<String, Color>()
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 {
return initialsCache.getOrPut(name) {
val words = name.trim().split(Regex("\\s+")).filter { it.isNotEmpty() }
return when {
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)
@Composable
fun ChatsListScreen(
isDarkTheme: Boolean,
chats: List<Chat>,
onChatClick: (Chat) -> Unit,
onNewChat: () -> Unit,
accountName: String,
accountPhone: String,
onToggleTheme: () -> 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 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) }
@@ -83,6 +126,100 @@ fun ChatsListScreen(
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(
topBar = {
AnimatedVisibility(
@@ -93,25 +230,71 @@ fun ChatsListScreen(
)
) {
TopAppBar(
title = {
Text(
"Chats",
fontWeight = FontWeight.Bold,
fontSize = 28.sp
)
},
actions = {
IconButton(onClick = onNewChat) {
navigationIcon = {
IconButton(
onClick = { scope.launch { drawerState.open() } }
) {
Icon(
Icons.Default.Edit,
contentDescription = "New Chat",
tint = PrimaryBlue
Icons.Default.Menu,
contentDescription = "Menu",
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(
Icons.Default.Person,
contentDescription = "Profile",
Icons.Default.Search,
contentDescription = "Search",
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
) { 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)
)
) {
// Empty state with Lottie animation
EmptyChatsState(
isDarkTheme = isDarkTheme,
onNewChat = onNewChat
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
)
}
}
}
}
}
}
@Composable
private fun SavedMessagesItem(
private fun DrawerItem(
icon: ImageVector,
title: String,
onClick: () -> Unit,
isDarkTheme: Boolean,
onClick: () -> Unit
badge: Int? = null
) {
val textColor = if (isDarkTheme) Color.White else Color.Black
val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 12.dp),
.padding(horizontal = 20.dp, vertical = 14.dp),
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(
modifier = Modifier
.size(56.dp)
.clip(CircleShape)
.background(PrimaryBlue),
.background(PrimaryBlue)
.padding(horizontal = 8.dp, vertical = 2.dp),
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 = "Saved Messages",
fontSize = 17.sp,
text = it.toString(),
fontSize = 12.sp,
fontWeight = FontWeight.SemiBold,
color = textColor
)
Text(
text = "Your personal cloud storage",
fontSize = 14.sp,
color = secondaryTextColor,
maxLines = 1,
overflow = TextOverflow.Ellipsis
color = Color.White
)
}
}
Divider(
modifier = Modifier.padding(start = 84.dp),
color = if (isDarkTheme) Color(0xFF3A3A3A) else Color(0xFFE8E8E8),
thickness = 0.5.dp
)
}
}
@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,
isDarkTheme: Boolean,
onClick: () -> Unit
) {
val textColor = if (isDarkTheme) Color.White else Color.Black
val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
val dividerColor = if (isDarkTheme) Color(0xFF3A3A3A) else Color(0xFFE8E8E8)
val (avatarTextColor, avatarBgColor) = getAvatarColor(chat.name, isDarkTheme)
val avatarColor = getAvatarColor(chat.name)
val initials = getInitials(chat.name)
Column {
Row(
modifier = Modifier
.fillMaxWidth()
@@ -267,14 +469,14 @@ private fun ChatItem(
modifier = Modifier
.size(56.dp)
.clip(CircleShape)
.background(avatarBgColor),
.background(avatarColor),
contentAlignment = Alignment.Center
) {
Text(
text = initials,
fontSize = 20.sp,
fontWeight = FontWeight.SemiBold,
color = avatarTextColor
color = Color.White
)
// Online indicator
@@ -282,9 +484,10 @@ private fun ChatItem(
Box(
modifier = Modifier
.align(Alignment.BottomEnd)
.size(14.dp)
.offset(x = 2.dp, y = 2.dp)
.size(16.dp)
.clip(CircleShape)
.background(if (isDarkTheme) Color(0xFF1E1E1E) else Color.White)
.background(if (isDarkTheme) Color(0xFF1A1A1A) else Color.White)
.padding(2.dp)
.clip(CircleShape)
.background(Color(0xFF4CAF50))
@@ -302,7 +505,7 @@ private fun ChatItem(
) {
Text(
text = chat.name,
fontSize = 17.sp,
fontSize = 16.sp,
fontWeight = FontWeight.SemiBold,
color = textColor,
maxLines = 1,
@@ -310,12 +513,22 @@ private fun ChatItem(
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 = if (chat.unreadCount > 0) PrimaryBlue else secondaryTextColor
color = secondaryTextColor
)
}
}
Spacer(modifier = Modifier.height(4.dp))
@@ -326,17 +539,30 @@ private fun ChatItem(
) {
Text(
text = chat.lastMessage,
fontSize = 15.sp,
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
.padding(start = 8.dp)
.clip(CircleShape)
.background(PrimaryBlue)
.padding(horizontal = 8.dp, vertical = 2.dp),
@@ -353,108 +579,41 @@ private fun ChatItem(
}
}
}
}
Divider(
modifier = Modifier.padding(start = 84.dp),
color = if (isDarkTheme) Color(0xFF3A3A3A) else Color(0xFFE8E8E8),
color = dividerColor,
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 {
val now = Calendar.getInstance()
val messageTime = Calendar.getInstance().apply { time = date }
return when {
// Today
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 -> {
"Yesterday"
}
// This week
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) -> {
SimpleDateFormat("MMM d", Locale.getDefault()).format(date)
monthFormatCache.get()?.format(date) ?: ""
}
// Other
else -> {
SimpleDateFormat("dd.MM.yy", Locale.getDefault()).format(date)
yearFormatCache.get()?.format(date) ?: ""
}
}
}

File diff suppressed because it is too large Load Diff