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.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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
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 {
|
||||
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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) ?: ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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