Refactor code structure for improved readability and maintainability
This commit is contained in:
1101
ARCHITECTURE.md
Normal file
1101
ARCHITECTURE.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
251
app/src/main/java/com/rosetta/messenger/providers/AuthState.kt
Normal file
251
app/src/main/java/com/rosetta/messenger/providers/AuthState.kt
Normal 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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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) ?: ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
49656
app/src/main/res/raw/letter.json
Normal file
49656
app/src/main/res/raw/letter.json
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user