From 307670e691e8b8fa3c7836659630c921c8d7e1dd Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Thu, 8 Jan 2026 20:04:51 +0500 Subject: [PATCH] Enhance OnboardingScreen with smoother pager swipes and add pulse animation to Rosetta logo - Implemented custom fling behavior for HorizontalPager to improve swipe experience. - Added pulse animation effect to the Rosetta logo on the onboarding screen for better visual appeal. - Updated the layout and animation specifications to enhance user interaction and aesthetics. --- .../com/rosetta/messenger/MainActivity.kt | 112 +++- .../com/rosetta/messenger/ui/auth/AuthFlow.kt | 58 ++- .../ui/auth/ConfirmSeedPhraseScreen.kt | 481 ++++++++++++------ .../ui/auth/ImportSeedPhraseScreen.kt | 438 ++++++++-------- .../messenger/ui/auth/SeedPhraseScreen.kt | 62 ++- .../messenger/ui/auth/SelectAccountScreen.kt | 447 ++++++++++++++++ .../rosetta/messenger/ui/auth/UnlockScreen.kt | 34 +- .../messenger/ui/auth/WelcomeScreen.kt | 3 +- .../messenger/ui/chats/ChatsListScreen.kt | 458 +++++++++++++++++ .../ui/onboarding/OnboardingScreen.kt | 50 +- 10 files changed, 1740 insertions(+), 403 deletions(-) create mode 100644 app/src/main/java/com/rosetta/messenger/ui/auth/SelectAccountScreen.kt create mode 100644 app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt diff --git a/app/src/main/java/com/rosetta/messenger/MainActivity.kt b/app/src/main/java/com/rosetta/messenger/MainActivity.kt index 9ecb076..e916522 100644 --- a/app/src/main/java/com/rosetta/messenger/MainActivity.kt +++ b/app/src/main/java/com/rosetta/messenger/MainActivity.kt @@ -23,12 +23,16 @@ import androidx.compose.ui.unit.sp import com.rosetta.messenger.data.AccountManager 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 @@ -49,11 +53,31 @@ class MainActivity : ComponentActivity() { var showOnboarding by remember { mutableStateOf(true) } var hasExistingAccount by remember { mutableStateOf(null) } var currentAccount by remember { mutableStateOf(null) } + var accountInfoList by remember { mutableStateOf>(emptyList()) } - // Check for existing accounts + // Check for existing accounts and build AccountInfo list LaunchedEffect(Unit) { val accounts = accountManager.getAllAccounts() hasExistingAccount = accounts.isNotEmpty() + accountInfoList = accounts.map { account -> + val shortKey = account.publicKey.take(7) + val displayName = account.name ?: shortKey + val initials = displayName.trim().split(Regex("\\s+")) + .filter { it.isNotEmpty() } + .let { words -> + when { + words.isEmpty() -> "??" + words.size == 1 -> words[0].take(2).uppercase() + else -> "${words[0].first()}${words[1].first()}".uppercase() + } + } + AccountInfo( + id = account.publicKey, + name = displayName, + initials = initials, + publicKey = account.publicKey + ) + } } // Wait for initial load @@ -112,15 +136,46 @@ class MainActivity : ComponentActivity() { AuthFlow( isDarkTheme = isDarkTheme, hasExistingAccount = screen == "auth_unlock", + accounts = accountInfoList, onAuthComplete = { account -> currentAccount = account hasExistingAccount = true + // Reload accounts list + scope.launch { + val accounts = accountManager.getAllAccounts() + accountInfoList = accounts.map { acc -> + val shortKey = acc.publicKey.take(7) + val displayName = acc.name ?: shortKey + val initials = displayName.trim().split(Regex("\\s+")) + .filter { it.isNotEmpty() } + .let { words -> + when { + words.isEmpty() -> "??" + words.size == 1 -> words[0].take(2).uppercase() + else -> "${words[0].first()}${words[1].first()}".uppercase() + } + } + AccountInfo( + id = acc.publicKey, + name = displayName, + initials = initials, + publicKey = acc.publicKey + ) + } + } + }, + onLogout = { + scope.launch { + accountManager.logout() + currentAccount = null + } } ) } "main" -> { MainScreen( account = currentAccount, + isDarkTheme = isDarkTheme, onLogout = { scope.launch { accountManager.logout() @@ -140,17 +195,54 @@ class MainActivity : ComponentActivity() { @Composable fun MainScreen( account: DecryptedAccount? = null, + isDarkTheme: Boolean = true, onLogout: () -> Unit = {} ) { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - Text( - text = "Welcome to Rosetta! 🚀\n\nYou're logged in!", - fontSize = 24.sp, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onBackground + // 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" + ) ) } + + ChatsListScreen( + isDarkTheme = isDarkTheme, + chats = demoChats, + onChatClick = { chat -> + // TODO: Navigate to chat detail + }, + onNewChat = { + // TODO: Show new chat screen + }, + onProfileClick = onLogout, // For now, logout on profile click + onSavedMessagesClick = { + // TODO: Navigate to saved messages + } + ) } diff --git a/app/src/main/java/com/rosetta/messenger/ui/auth/AuthFlow.kt b/app/src/main/java/com/rosetta/messenger/ui/auth/AuthFlow.kt index 678d392..2bdb59b 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/auth/AuthFlow.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/auth/AuthFlow.kt @@ -6,6 +6,7 @@ import androidx.compose.runtime.* import com.rosetta.messenger.data.DecryptedAccount enum class AuthScreen { + SELECT_ACCOUNT, WELCOME, SEED_PHRASE, CONFIRM_SEED, @@ -18,12 +19,22 @@ enum class AuthScreen { fun AuthFlow( isDarkTheme: Boolean, hasExistingAccount: Boolean, - onAuthComplete: (DecryptedAccount?) -> Unit + accounts: List = emptyList(), + onAuthComplete: (DecryptedAccount?) -> Unit, + onLogout: () -> Unit = {} ) { var currentScreen by remember { - mutableStateOf(if (hasExistingAccount) AuthScreen.UNLOCK else AuthScreen.WELCOME) + mutableStateOf( + when { + hasExistingAccount && accounts.size > 1 -> AuthScreen.SELECT_ACCOUNT + hasExistingAccount -> AuthScreen.UNLOCK + else -> AuthScreen.WELCOME + } + ) } var seedPhrase by remember { mutableStateOf>(emptyList()) } + var selectedAccountId by remember { mutableStateOf(accounts.firstOrNull()?.id) } + var showCreateModal by remember { mutableStateOf(false) } AnimatedContent( targetState = currentScreen, @@ -34,6 +45,29 @@ fun AuthFlow( label = "authScreenTransition" ) { screen -> when (screen) { + AuthScreen.SELECT_ACCOUNT -> { + SelectAccountScreen( + isDarkTheme = isDarkTheme, + accounts = accounts, + selectedAccountId = selectedAccountId, + onSelectAccount = { accountId -> + selectedAccountId = accountId + currentScreen = AuthScreen.UNLOCK + }, + onAddAccount = { showCreateModal = true }, + showCreateModal = showCreateModal, + onCreateNew = { + showCreateModal = false + currentScreen = AuthScreen.SEED_PHRASE + }, + onImportSeed = { + showCreateModal = false + currentScreen = AuthScreen.IMPORT_SEED + }, + onDismissModal = { showCreateModal = false } + ) + } + AuthScreen.WELCOME -> { WelcomeScreen( isDarkTheme = isDarkTheme, @@ -45,7 +79,11 @@ fun AuthFlow( AuthScreen.SEED_PHRASE -> { SeedPhraseScreen( isDarkTheme = isDarkTheme, - onBack = { currentScreen = AuthScreen.WELCOME }, + onBack = { + currentScreen = if (hasExistingAccount && accounts.size > 1) + AuthScreen.SELECT_ACCOUNT + else AuthScreen.WELCOME + }, onConfirm = { words -> seedPhrase = words currentScreen = AuthScreen.CONFIRM_SEED @@ -74,7 +112,11 @@ fun AuthFlow( AuthScreen.IMPORT_SEED -> { ImportSeedPhraseScreen( isDarkTheme = isDarkTheme, - onBack = { currentScreen = AuthScreen.WELCOME }, + onBack = { + currentScreen = if (hasExistingAccount && accounts.size > 1) + AuthScreen.SELECT_ACCOUNT + else AuthScreen.WELCOME + }, onSeedPhraseImported = { words -> seedPhrase = words currentScreen = AuthScreen.SET_PASSWORD @@ -85,7 +127,13 @@ fun AuthFlow( AuthScreen.UNLOCK -> { UnlockScreen( isDarkTheme = isDarkTheme, - onUnlocked = { account -> onAuthComplete(account) } + selectedAccountId = selectedAccountId, + onUnlocked = { account -> onAuthComplete(account) }, + onSwitchAccount = { + if (accounts.size > 1) { + currentScreen = AuthScreen.SELECT_ACCOUNT + } + } ) } } diff --git a/app/src/main/java/com/rosetta/messenger/ui/auth/ConfirmSeedPhraseScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/auth/ConfirmSeedPhraseScreen.kt index 2a1ded2..67929b9 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/auth/ConfirmSeedPhraseScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/auth/ConfirmSeedPhraseScreen.kt @@ -4,12 +4,8 @@ import androidx.compose.animation.* import androidx.compose.animation.core.* import androidx.compose.foundation.* import androidx.compose.foundation.layout.* -import androidx.compose.foundation.lazy.grid.GridCells -import androidx.compose.foundation.lazy.grid.LazyVerticalGrid -import androidx.compose.foundation.lazy.grid.itemsIndexed import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.BasicTextField import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.* import androidx.compose.material3.* @@ -17,19 +13,33 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.focus.FocusDirection +import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import com.rosetta.messenger.crypto.CryptoManager import com.rosetta.messenger.ui.onboarding.PrimaryBlue +// Beautiful solid colors that fit the theme +private val wordColors = listOf( + Color(0xFF5E9FFF), // Soft blue + Color(0xFFFF7EB3), // Soft pink + Color(0xFF7B68EE), // Medium purple + Color(0xFF50C878), // Emerald green + Color(0xFFFF6B6B), // Coral red + Color(0xFF4ECDC4), // Teal + Color(0xFFFFB347), // Pastel orange + Color(0xFFBA55D3), // Medium orchid + Color(0xFF87CEEB), // Sky blue + Color(0xFFDDA0DD), // Plum + Color(0xFF98D8C8), // Mint + Color(0xFFF7DC6F) // Soft yellow +) + @Composable fun ConfirmSeedPhraseScreen( seedPhrase: List, @@ -37,18 +47,16 @@ fun ConfirmSeedPhraseScreen( onBack: () -> Unit, onConfirmed: () -> Unit ) { - val themeAnimSpec = tween(durationMillis = 500, easing = CubicBezierEasing(0.4f, 0f, 0.2f, 1f)) - val backgroundColor by animateColorAsState(if (isDarkTheme) AuthBackground else AuthBackgroundLight, animationSpec = themeAnimSpec) - val textColor by animateColorAsState(if (isDarkTheme) Color.White else Color.Black, animationSpec = themeAnimSpec) - val secondaryTextColor by animateColorAsState(if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666), animationSpec = themeAnimSpec) - val cardColor by animateColorAsState(if (isDarkTheme) AuthSurface else AuthSurfaceLight, animationSpec = themeAnimSpec) + val backgroundColor = if (isDarkTheme) Color(0xFF1E1E1E) else Color(0xFFFFFFFF) + val textColor = if (isDarkTheme) Color.White else Color.Black + val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666) - // Select 4 random words to confirm + // Select 4 words at fixed positions to confirm (2, 5, 9, 12) val wordsToConfirm = remember { listOf(1, 4, 8, 11).map { index -> index to seedPhrase[index] } } - var userInputs by remember { mutableStateOf(List(4) { "" }) } + var userInputs by remember { mutableStateOf(List(12) { "" }) } var showError by remember { mutableStateOf(false) } var visible by remember { mutableStateOf(false) } @@ -56,43 +64,33 @@ fun ConfirmSeedPhraseScreen( visible = true } - val allCorrect = wordsToConfirm.mapIndexed { i, (_, word) -> - userInputs[i].trim().lowercase() == word.lowercase() - }.all { it } + // Check if the 4 confirmation words are correct + val allCorrect = wordsToConfirm.all { (index, word) -> + userInputs[index].trim().lowercase() == word.lowercase() + } + + // Check if all 4 words have input + val allFilled = wordsToConfirm.all { (index, _) -> + userInputs[index].isNotBlank() + } Box( modifier = Modifier .fillMaxSize() .background(backgroundColor) + .statusBarsPadding() ) { - Column( - modifier = Modifier - .fillMaxSize() - .statusBarsPadding() - ) { - // Top Bar + Column(modifier = Modifier.fillMaxSize()) { + // Top bar Row( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 8.dp, vertical = 8.dp), + .padding(4.dp), verticalAlignment = Alignment.CenterVertically ) { IconButton(onClick = onBack) { - Icon( - imageVector = Icons.Default.ArrowBack, - contentDescription = "Back", - tint = textColor - ) + Icon(Icons.Default.ArrowBack, "Back", tint = textColor) } - Spacer(modifier = Modifier.weight(1f)) - Text( - text = "Confirm Backup", - fontSize = 18.sp, - fontWeight = FontWeight.SemiBold, - color = textColor - ) - Spacer(modifier = Modifier.weight(1f)) - Spacer(modifier = Modifier.width(48.dp)) } Column( @@ -104,82 +102,127 @@ fun ConfirmSeedPhraseScreen( ) { Spacer(modifier = Modifier.height(16.dp)) - // Info Card AnimatedVisibility( visible = visible, enter = fadeIn(tween(500)) + slideInVertically( - initialOffsetY = { -30 }, + initialOffsetY = { -20 }, animationSpec = tween(500) ) ) { - Row( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(12.dp)) - .background(cardColor) - .padding(16.dp), - verticalAlignment = Alignment.Top - ) { - Icon( - imageVector = Icons.Default.CheckCircle, - contentDescription = null, - tint = PrimaryBlue, - modifier = Modifier.size(24.dp) - ) - Spacer(modifier = Modifier.width(12.dp)) Text( - text = "Enter the following words from your seed phrase to confirm you've backed it up correctly.", - fontSize = 14.sp, - color = secondaryTextColor, - lineHeight = 18.sp + text = "Confirm Backup", + fontSize = 28.sp, + fontWeight = FontWeight.Bold, + color = textColor ) } + + Spacer(modifier = Modifier.height(12.dp)) + + AnimatedVisibility( + visible = visible, + enter = fadeIn(tween(500, delayMillis = 100)) + slideInVertically( + initialOffsetY = { -20 }, + animationSpec = tween(500, delayMillis = 100) + ) + ) { + Text( + text = "Enter words #${wordsToConfirm[0].first + 1}, #${wordsToConfirm[1].first + 1}, #${wordsToConfirm[2].first + 1}, #${wordsToConfirm[3].first + 1}\nto confirm you've backed up your phrase.", + fontSize = 15.sp, + color = secondaryTextColor, + textAlign = TextAlign.Center, + lineHeight = 22.sp + ) } Spacer(modifier = Modifier.height(32.dp)) - // Word inputs - wordsToConfirm.forEachIndexed { index, (wordIndex, _) -> - val isCorrect = userInputs[index].trim().lowercase() == - wordsToConfirm[index].second.lowercase() - val hasInput = userInputs[index].isNotBlank() - - AnimatedVisibility( - visible = visible, - enter = fadeIn(tween(500, delayMillis = 100 + (index * 100))) + slideInVertically( - initialOffsetY = { 50 }, - animationSpec = tween(500, delayMillis = 100 + (index * 100)) - ) + // Two column layout like SeedPhraseScreen + AnimatedVisibility( + visible = visible, + enter = fadeIn(tween(500, delayMillis = 200)) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp) ) { - WordInputField( - wordNumber = wordIndex + 1, - value = userInputs[index], - onValueChange = { - userInputs = userInputs.toMutableList().apply { - this[index] = it - } - showError = false - }, - isCorrect = if (hasInput) isCorrect else null, - isDarkTheme = isDarkTheme - ) + // Left column (words 1-6) + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + for (i in 0..5) { + val needsInput = wordsToConfirm.any { it.first == i } + val correctWord = seedPhrase[i] + + AnimatedConfirmWordItem( + number = i + 1, + displayWord = if (needsInput) "" else correctWord, + inputValue = if (needsInput) userInputs[i] else "", + isInput = needsInput, + onValueChange = { newValue -> + userInputs = userInputs.toMutableList().apply { + this[i] = newValue.lowercase().trim() + } + showError = false + }, + isCorrect = if (needsInput && userInputs[i].isNotBlank()) { + userInputs[i].trim().lowercase() == correctWord.lowercase() + } else null, + isDarkTheme = isDarkTheme, + visible = visible, + delay = 250 + (i * 50) + ) + } + } + + // Right column (words 7-12) + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + for (i in 6..11) { + val needsInput = wordsToConfirm.any { it.first == i } + val correctWord = seedPhrase[i] + + AnimatedConfirmWordItem( + number = i + 1, + displayWord = if (needsInput) "" else correctWord, + inputValue = if (needsInput) userInputs[i] else "", + isInput = needsInput, + onValueChange = { newValue -> + userInputs = userInputs.toMutableList().apply { + this[i] = newValue.lowercase().trim() + } + showError = false + }, + isCorrect = if (needsInput && userInputs[i].isNotBlank()) { + userInputs[i].trim().lowercase() == correctWord.lowercase() + } else null, + isDarkTheme = isDarkTheme, + visible = visible, + delay = 250 + (i * 50) + ) + } + } } - - Spacer(modifier = Modifier.height(16.dp)) } // Error message AnimatedVisibility( visible = showError, - enter = fadeIn() + slideInVertically { -10 }, - exit = fadeOut() + enter = fadeIn(tween(300)) + scaleIn(initialScale = 0.9f), + exit = fadeOut(tween(200)) ) { - Text( - text = "Some words don't match. Please check and try again.", - fontSize = 14.sp, - color = Color(0xFFE53935), - textAlign = TextAlign.Center - ) + Column { + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = "Some words don't match. Please check and try again.", + fontSize = 14.sp, + color = Color(0xFFE53935), + textAlign = TextAlign.Center + ) + } } Spacer(modifier = Modifier.weight(1f)) @@ -187,90 +230,208 @@ fun ConfirmSeedPhraseScreen( // Continue Button AnimatedVisibility( visible = visible, - enter = fadeIn(tween(500, delayMillis = 500)) + slideInVertically( + enter = fadeIn(tween(500, delayMillis = 600)) + slideInVertically( initialOffsetY = { 50 }, - animationSpec = tween(500, delayMillis = 500) + animationSpec = tween(500, delayMillis = 600) ) ) { Button( - onClick = { - if (allCorrect) { - onConfirmed() - } else { - showError = true - } - }, - modifier = Modifier - .fillMaxWidth() - .height(56.dp), - colors = ButtonDefaults.buttonColors( - containerColor = if (allCorrect) PrimaryBlue else PrimaryBlue.copy(alpha = 0.5f) - ), - shape = RoundedCornerShape(12.dp) - ) { - Text( - text = "Confirm", - fontSize = 16.sp, - fontWeight = FontWeight.SemiBold - ) - } + onClick = { + if (allCorrect) { + onConfirmed() + } else { + showError = true + } + }, + enabled = allFilled, + modifier = Modifier + .fillMaxWidth() + .height(50.dp), + colors = ButtonDefaults.buttonColors( + containerColor = PrimaryBlue, + contentColor = Color.White, + disabledContainerColor = if (isDarkTheme) Color(0xFF3A3A3A) else Color(0xFFE8E8E8), + disabledContentColor = if (isDarkTheme) Color(0xFF666666) else Color(0xFF999999) + ), + shape = RoundedCornerShape(12.dp) + ) { + Text( + text = "Confirm", + fontSize = 17.sp, + fontWeight = FontWeight.Medium + ) + } } - Spacer(modifier = Modifier.height(32.dp)) + Spacer(modifier = Modifier.height(40.dp)) } } } } -@OptIn(ExperimentalMaterial3Api::class) @Composable -private fun WordInputField( - wordNumber: Int, +private fun AnimatedConfirmWordItem( + number: Int, + displayWord: String, + inputValue: String, + isInput: Boolean, + onValueChange: (String) -> Unit, + isCorrect: Boolean?, + isDarkTheme: Boolean, + visible: Boolean, + delay: Int, + modifier: Modifier = Modifier +) { + AnimatedVisibility( + visible = visible, + enter = fadeIn(tween(400, delayMillis = delay)) + slideInHorizontally( + initialOffsetX = { if (number <= 6) -50 else 50 }, + animationSpec = tween(400, delayMillis = delay) + ) + ) { + if (isInput) { + ConfirmWordInputItem( + number = number, + value = inputValue, + onValueChange = onValueChange, + isCorrect = isCorrect, + isDarkTheme = isDarkTheme, + modifier = modifier + ) + } else { + DisplayWordItem( + number = number, + word = displayWord, + isDarkTheme = isDarkTheme, + modifier = modifier + ) + } + } +} + +@Composable +private fun DisplayWordItem( + number: Int, + word: String, + isDarkTheme: Boolean, + modifier: Modifier = Modifier +) { + val numberColor = if (isDarkTheme) Color(0xFF888888) else Color(0xFF999999) + val bgColor = if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFF5F5F5) + val wordColor = wordColors[(number - 1) % wordColors.size] + + Box( + modifier = modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .background(bgColor) + .padding(horizontal = 16.dp, vertical = 14.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = "$number.", + fontSize = 15.sp, + color = numberColor, + modifier = Modifier.width(28.dp) + ) + Text( + text = word, + fontSize = 17.sp, + fontWeight = FontWeight.SemiBold, + color = wordColor, + fontFamily = FontFamily.Monospace + ) + } + } +} + +@Composable +private fun ConfirmWordInputItem( + number: Int, value: String, onValueChange: (String) -> Unit, isCorrect: Boolean?, - isDarkTheme: Boolean + isDarkTheme: Boolean, + modifier: Modifier = Modifier ) { - val textColor = if (isDarkTheme) Color.White else Color.Black - val labelColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666) + val numberColor = if (isDarkTheme) Color(0xFF888888) else Color(0xFF999999) + val hintColor = if (isDarkTheme) Color(0xFF555555) else Color(0xFFBBBBBB) + val bgColor = if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFF5F5F5) + val wordColor = wordColors[(number - 1) % wordColors.size] + var isFocused by remember { mutableStateOf(false) } + + // Border color based on correctness val borderColor = when (isCorrect) { - true -> Color(0xFF4CAF50) - false -> Color(0xFFE53935) - null -> if (isDarkTheme) Color(0xFF4A4A4A) else Color(0xFFD0D0D0) + true -> Color(0xFF4CAF50) // Green + false -> Color(0xFFE53935) // Red + null -> if (isFocused) PrimaryBlue else Color.Transparent } - val trailingIcon: @Composable (() -> Unit)? = when (isCorrect) { - true -> { - { Icon(Icons.Default.Check, null, tint = Color(0xFF4CAF50)) } + Box( + modifier = modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .border( + width = 2.dp, + color = borderColor, + shape = RoundedCornerShape(12.dp) + ) + .background(bgColor) + .padding(horizontal = 16.dp, vertical = 14.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = "$number.", + fontSize = 15.sp, + color = numberColor, + modifier = Modifier.width(28.dp) + ) + BasicTextField( + value = value, + onValueChange = onValueChange, + textStyle = TextStyle( + fontSize = 17.sp, + fontWeight = FontWeight.SemiBold, + color = if (value.isNotEmpty()) wordColor else hintColor, + fontFamily = FontFamily.Monospace + ), + singleLine = true, + cursorBrush = SolidColor(PrimaryBlue), + modifier = Modifier + .weight(1f) + .onFocusChanged { isFocused = it.isFocused }, + decorationBox = { innerTextField -> + Box { + if (value.isEmpty()) { + Text( + "enter word", + fontSize = 17.sp, + color = hintColor, + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.SemiBold + ) + } + innerTextField() + } + } + ) + + // Show check/cross icon + if (isCorrect != null) { + Icon( + imageVector = if (isCorrect) Icons.Default.Check else Icons.Default.Close, + contentDescription = null, + tint = if (isCorrect) Color(0xFF4CAF50) else Color(0xFFE53935), + modifier = Modifier.size(20.dp) + ) + } } - false -> { - { Icon(Icons.Default.Close, null, tint = Color(0xFFE53935)) } - } - null -> null } - - OutlinedTextField( - value = value, - onValueChange = { onValueChange(it.lowercase().trim()) }, - label = { Text("Word #$wordNumber") }, - placeholder = { Text("Enter word $wordNumber") }, - singleLine = true, - trailingIcon = trailingIcon, - colors = OutlinedTextFieldDefaults.colors( - focusedBorderColor = PrimaryBlue, - unfocusedBorderColor = borderColor, - focusedLabelColor = PrimaryBlue, - unfocusedLabelColor = labelColor, - cursorColor = PrimaryBlue, - focusedTextColor = textColor, - unfocusedTextColor = textColor - ), - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(12.dp), - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Text, - imeAction = ImeAction.Next - ) - ) } diff --git a/app/src/main/java/com/rosetta/messenger/ui/auth/ImportSeedPhraseScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/auth/ImportSeedPhraseScreen.kt index aebc773..d88b57d 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/auth/ImportSeedPhraseScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/auth/ImportSeedPhraseScreen.kt @@ -16,6 +16,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight @@ -35,12 +36,10 @@ fun ImportSeedPhraseScreen( val backgroundColor = if (isDarkTheme) Color(0xFF1E1E1E) else Color(0xFFFFFFFF) val textColor = if (isDarkTheme) Color.White else Color.Black val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666) - val cardBackground = if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFF5F5F5) + val clipboardManager = LocalClipboardManager.current var words by remember { mutableStateOf(List(12) { "" }) } var error by remember { mutableStateOf(null) } - var pastedText by remember { mutableStateOf("") } - var showPasteDialog by remember { mutableStateOf(false) } var visible by remember { mutableStateOf(false) } LaunchedEffect(Unit) { @@ -49,18 +48,6 @@ fun ImportSeedPhraseScreen( val allWordsFilled = words.all { it.isNotBlank() } - // Parse pasted text - LaunchedEffect(pastedText) { - if (pastedText.isNotBlank()) { - val parsed = pastedText.trim().lowercase().split("\\s+".toRegex()) - if (parsed.size == 12) { - words = parsed - showPasteDialog = false - error = null - } - } - } - Box( modifier = Modifier .fillMaxSize() @@ -68,7 +55,7 @@ fun ImportSeedPhraseScreen( .statusBarsPadding() ) { Column(modifier = Modifier.fillMaxSize()) { - // Simple top bar + // Top bar Row( modifier = Modifier .fillMaxWidth() @@ -80,17 +67,13 @@ fun ImportSeedPhraseScreen( } } - AnimatedVisibility( - visible = visible, - enter = fadeIn(tween(500, easing = FastOutSlowInEasing)) + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 24.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Spacer(modifier = Modifier.height(16.dp)) + Spacer(modifier = Modifier.height(16.dp)) AnimatedVisibility( visible = visible, @@ -111,98 +94,140 @@ fun ImportSeedPhraseScreen( AnimatedVisibility( visible = visible, - enter = fadeIn(tween(500, delayMillis = 100)) + enter = fadeIn(tween(500, delayMillis = 100)) + slideInVertically( + initialOffsetY = { -20 }, + animationSpec = tween(500, delayMillis = 100) + ) ) { Text( - text = "Enter your 12-word recovery phrase", + text = "Enter your 12-word recovery phrase\nto restore your account.", fontSize = 15.sp, color = secondaryTextColor, - textAlign = TextAlign.Center + textAlign = TextAlign.Center, + lineHeight = 22.sp ) } Spacer(modifier = Modifier.height(24.dp)) - // Paste button + // Paste button - directly reads from clipboard AnimatedVisibility( visible = visible, - enter = fadeIn(tween(500, delayMillis = 200)) + scaleIn( + enter = fadeIn(tween(500, delayMillis = 150)) + scaleIn( initialScale = 0.9f, - animationSpec = tween(500, delayMillis = 200) + animationSpec = tween(500, delayMillis = 150) ) ) { - Button( - onClick = { showPasteDialog = true }, - modifier = Modifier - .fillMaxWidth() - .height(50.dp), - colors = ButtonDefaults.buttonColors( - containerColor = PrimaryBlue, - contentColor = Color.White - ), - shape = RoundedCornerShape(12.dp) - ) { - Icon( - Icons.Default.ContentPaste, - contentDescription = null, - modifier = Modifier.size(22.dp) - ) - Spacer(modifier = Modifier.width(10.dp)) - Text( - "Paste All 12 Words", - fontSize = 16.sp, - fontWeight = FontWeight.SemiBold - ) - } + TextButton( + onClick = { + val clipboardText = clipboardManager.getText()?.text + if (clipboardText != null && clipboardText.isNotBlank()) { + val parsed = clipboardText.trim().lowercase() + .split(Regex("[\\s,;]+")) + .filter { it.isNotBlank() } + .map { it.trim() } + + when { + parsed.size == 12 -> { + words = parsed.toList() + error = null + } + parsed.size > 12 -> { + words = parsed.take(12).toList() + error = null + } + parsed.isNotEmpty() -> { + error = "Clipboard contains ${parsed.size} words, need 12" + } + else -> { + error = "No valid words found in clipboard" + } + } + } else { + error = "Clipboard is empty" + } + } + ) { + Icon( + Icons.Default.ContentPaste, + contentDescription = null, + tint = PrimaryBlue, + modifier = Modifier.size(20.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + "Paste all 12 words", + color = PrimaryBlue, + fontSize = 15.sp + ) + } } - Spacer(modifier = Modifier.height(24.dp)) + Spacer(modifier = Modifier.height(20.dp)) - // Clean grid + // Two column layout like SeedPhraseScreen AnimatedVisibility( visible = visible, - enter = fadeIn(tween(500, delayMillis = 300)) + enter = fadeIn(tween(500, delayMillis = 200)) ) { - Column( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(12.dp)) - .background(cardBackground) - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - for (row in 0..3) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(12.dp) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + // Left column (words 1-6) + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(12.dp) ) { - for (col in 0..2) { - val index = row * 3 + col - WordInputItem( - number = index + 1, - value = words[index], + for (i in 0..5) { + AnimatedWordInputItem( + number = i + 1, + value = words[i], onValueChange = { newValue -> words = words.toMutableList().apply { - this[index] = newValue.lowercase().trim() + this[i] = newValue.lowercase().trim() } error = null }, isDarkTheme = isDarkTheme, - modifier = Modifier.weight(1f) + visible = visible, + delay = 250 + (i * 50) + ) + } + } + + // Right column (words 7-12) + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + for (i in 6..11) { + AnimatedWordInputItem( + number = i + 1, + value = words[i], + onValueChange = { newValue -> + words = words.toMutableList().apply { + this[i] = newValue.lowercase().trim() + } + error = null + }, + isDarkTheme = isDarkTheme, + visible = visible, + delay = 250 + (i * 50) ) } } } } - } // Error - if (error != null) { - Spacer(modifier = Modifier.height(12.dp)) - AnimatedVisibility( - visible = true, - enter = fadeIn(tween(300)) + scaleIn(initialScale = 0.9f) - ) { + AnimatedVisibility( + visible = error != null, + enter = fadeIn(tween(300)) + scaleIn(initialScale = 0.9f), + exit = fadeOut(tween(200)) + ) { + Column { + Spacer(modifier = Modifier.height(16.dp)) Text( text = error ?: "", fontSize = 14.sp, @@ -217,95 +242,80 @@ fun ImportSeedPhraseScreen( // Import button AnimatedVisibility( visible = visible, - enter = fadeIn(tween(500, delayMillis = 400)) + slideInVertically( + enter = fadeIn(tween(500, delayMillis = 600)) + slideInVertically( initialOffsetY = { 50 }, - animationSpec = tween(500, delayMillis = 400) + animationSpec = tween(500, delayMillis = 600) ) ) { Button( - onClick = { - val seedPhrase = words.map { it.trim() } - - if (seedPhrase.any { it.isBlank() }) { - error = "Please fill in all words" - return@Button - } - - if (!CryptoManager.validateSeedPhrase(seedPhrase)) { - error = "Invalid recovery phrase" - return@Button - } - - onSeedPhraseImported(seedPhrase) - }, - enabled = allWordsFilled, - modifier = Modifier - .fillMaxWidth() - .height(50.dp), - colors = ButtonDefaults.buttonColors( - containerColor = PrimaryBlue, - contentColor = Color.White, - disabledContainerColor = if (isDarkTheme) Color(0xFF3A3A3A) else Color(0xFFE8E8E8), - disabledContentColor = if (isDarkTheme) Color(0xFF666666) else Color(0xFF999999) - ), - shape = RoundedCornerShape(12.dp) - ) { - Text("Continue", fontSize = 17.sp, fontWeight = FontWeight.Medium) - } - } - - Spacer(modifier = Modifier.height(40.dp)) - } - } - } - - // Paste dialog - if (showPasteDialog) { - AlertDialog( - onDismissRequest = { showPasteDialog = false }, - title = { - Text("Paste Recovery Phrase", fontWeight = FontWeight.Bold) - }, - text = { - OutlinedTextField( - value = pastedText, - onValueChange = { pastedText = it }, - placeholder = { Text("Paste your 12 words here") }, + onClick = { + val seedPhrase = words.map { it.trim() } + + if (seedPhrase.any { it.isBlank() }) { + error = "Please fill in all words" + return@Button + } + + if (!CryptoManager.validateSeedPhrase(seedPhrase)) { + error = "Invalid recovery phrase" + return@Button + } + + onSeedPhraseImported(seedPhrase) + }, + enabled = allWordsFilled, modifier = Modifier .fillMaxWidth() - .height(120.dp), - colors = OutlinedTextFieldDefaults.colors( - focusedBorderColor = PrimaryBlue, - cursorColor = PrimaryBlue + .height(50.dp), + colors = ButtonDefaults.buttonColors( + containerColor = PrimaryBlue, + contentColor = Color.White, + disabledContainerColor = if (isDarkTheme) Color(0xFF3A3A3A) else Color(0xFFE8E8E8), + disabledContentColor = if (isDarkTheme) Color(0xFF666666) else Color(0xFF999999) ), shape = RoundedCornerShape(12.dp) - ) - }, - confirmButton = { - TextButton( - onClick = { - val parsed = pastedText.trim().lowercase().split("\\s+".toRegex()) - if (parsed.size == 12) { - words = parsed - showPasteDialog = false - } - } ) { - Text("Import", color = PrimaryBlue, fontWeight = FontWeight.Medium) + Text( + text = "Continue", + fontSize = 17.sp, + fontWeight = FontWeight.Medium + ) } - }, - dismissButton = { - TextButton(onClick = { showPasteDialog = false }) { - Text("Cancel") - } - }, - containerColor = if (isDarkTheme) Color(0xFF2A2A2A) else Color.White, - shape = RoundedCornerShape(20.dp) - ) + } + + Spacer(modifier = Modifier.height(40.dp)) + } } } } +@Composable +private fun AnimatedWordInputItem( + number: Int, + value: String, + onValueChange: (String) -> Unit, + isDarkTheme: Boolean, + visible: Boolean, + delay: Int, + modifier: Modifier = Modifier +) { + AnimatedVisibility( + visible = visible, + enter = fadeIn(tween(400, delayMillis = delay)) + slideInHorizontally( + initialOffsetX = { if (number <= 6) -50 else 50 }, + animationSpec = tween(400, delayMillis = delay) + ) + ) { + WordInputItem( + number = number, + value = value, + onValueChange = onValueChange, + isDarkTheme = isDarkTheme, + modifier = modifier + ) + } +} + @Composable private fun WordInputItem( number: Int, @@ -314,55 +324,81 @@ private fun WordInputItem( isDarkTheme: Boolean, modifier: Modifier = Modifier ) { - val itemBg = if (isDarkTheme) Color(0xFF1E1E1E) else Color.White - val numberColor = if (isDarkTheme) Color(0xFF666666) else Color(0xFFAAAAAA) - val textColor = if (isDarkTheme) Color.White else Color.Black - val hintColor = if (isDarkTheme) Color(0xFF666666) else Color(0xFFAAAAAA) + val numberColor = if (isDarkTheme) Color(0xFF888888) else Color(0xFF999999) + val hintColor = if (isDarkTheme) Color(0xFF555555) else Color(0xFFBBBBBB) + val bgColor = if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFF5F5F5) var isFocused by remember { mutableStateOf(false) } - val borderColor = if (isFocused) PrimaryBlue else Color.Transparent - Row( + // Beautiful solid colors that fit the theme - same as SeedPhraseScreen + val wordColors = listOf( + Color(0xFF5E9FFF), // Soft blue + Color(0xFFFF7EB3), // Soft pink + Color(0xFF7B68EE), // Medium purple + Color(0xFF50C878), // Emerald green + Color(0xFFFF6B6B), // Coral red + Color(0xFF4ECDC4), // Teal + Color(0xFFFFB347), // Pastel orange + Color(0xFFBA55D3), // Medium orchid + Color(0xFF87CEEB), // Sky blue + Color(0xFFDDA0DD), // Plum + Color(0xFF98D8C8), // Mint + Color(0xFFF7DC6F) // Soft yellow + ) + + val wordColor = wordColors[(number - 1) % wordColors.size] + + Box( modifier = modifier - .clip(RoundedCornerShape(8.dp)) - .border(1.dp, borderColor, RoundedCornerShape(8.dp)) - .background(itemBg) - .padding(horizontal = 10.dp, vertical = 10.dp), - verticalAlignment = Alignment.CenterVertically + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .border( + width = if (isFocused) 2.dp else 0.dp, + color = if (isFocused) PrimaryBlue else Color.Transparent, + shape = RoundedCornerShape(12.dp) + ) + .background(bgColor) + .padding(horizontal = 16.dp, vertical = 14.dp) ) { - Text( - text = "$number.", - fontSize = 13.sp, - color = numberColor, - modifier = Modifier.width(22.dp) - ) - BasicTextField( - value = value, - onValueChange = onValueChange, - textStyle = TextStyle( + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = "$number.", fontSize = 15.sp, - fontWeight = FontWeight.Medium, - color = textColor, - fontFamily = FontFamily.Monospace - ), - singleLine = true, - cursorBrush = SolidColor(PrimaryBlue), - modifier = Modifier - .weight(1f) - .onFocusChanged { isFocused = it.isFocused }, - decorationBox = { innerTextField -> - Box { - if (value.isEmpty()) { - Text( - "word", - fontSize = 15.sp, - color = hintColor.copy(alpha = 0.5f), - fontFamily = FontFamily.Monospace - ) + color = numberColor, + modifier = Modifier.width(28.dp) + ) + BasicTextField( + value = value, + onValueChange = onValueChange, + textStyle = TextStyle( + fontSize = 17.sp, + fontWeight = FontWeight.SemiBold, + color = if (value.isNotEmpty()) wordColor else hintColor, + fontFamily = FontFamily.Monospace + ), + singleLine = true, + cursorBrush = SolidColor(PrimaryBlue), + modifier = Modifier + .weight(1f) + .onFocusChanged { isFocused = it.isFocused }, + decorationBox = { innerTextField -> + Box { + if (value.isEmpty()) { + Text( + "word", + fontSize = 17.sp, + color = hintColor, + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.SemiBold + ) + } + innerTextField() } - innerTextField() } - } - ) + ) + } } } diff --git a/app/src/main/java/com/rosetta/messenger/ui/auth/SeedPhraseScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/auth/SeedPhraseScreen.kt index 9f47b48..2cb1131 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/auth/SeedPhraseScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/auth/SeedPhraseScreen.kt @@ -12,6 +12,7 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight @@ -248,31 +249,52 @@ private fun WordItem( isDarkTheme: Boolean, modifier: Modifier = Modifier ) { - val itemBg = if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFF5F5F5) - val numberColor = if (isDarkTheme) Color(0xFF666666) else Color(0xFFAAAAAA) - val wordColor = if (isDarkTheme) Color.White else Color.Black + val numberColor = if (isDarkTheme) Color(0xFF888888) else Color(0xFF999999) - Row( + // Beautiful solid colors that fit the theme + val wordColors = listOf( + Color(0xFF5E9FFF), // Soft blue + Color(0xFFFF7EB3), // Soft pink + Color(0xFF7B68EE), // Medium purple + Color(0xFF50C878), // Emerald green + Color(0xFFFF6B6B), // Coral red + Color(0xFF4ECDC4), // Teal + Color(0xFFFFB347), // Pastel orange + Color(0xFFBA55D3), // Medium orchid + Color(0xFF87CEEB), // Sky blue + Color(0xFFDDA0DD), // Plum + Color(0xFF98D8C8), // Mint + Color(0xFFF7DC6F) // Soft yellow + ) + + val wordColor = wordColors[(number - 1) % wordColors.size] + val bgColor = if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFF5F5F5) + + Box( modifier = modifier .fillMaxWidth() .clip(RoundedCornerShape(12.dp)) - .background(itemBg) - .padding(horizontal = 16.dp, vertical = 14.dp), - verticalAlignment = Alignment.CenterVertically + .background(bgColor) + .padding(horizontal = 16.dp, vertical = 14.dp) ) { - Text( - text = "$number.", - fontSize = 15.sp, - color = numberColor, - modifier = Modifier.width(28.dp) - ) - Text( - text = word, - fontSize = 17.sp, - fontWeight = FontWeight.SemiBold, - color = wordColor, - fontFamily = FontFamily.Monospace - ) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = "$number.", + fontSize = 15.sp, + color = numberColor, + modifier = Modifier.width(28.dp) + ) + Text( + text = word, + fontSize = 17.sp, + fontWeight = FontWeight.SemiBold, + color = wordColor, + fontFamily = FontFamily.Monospace + ) + } } } diff --git a/app/src/main/java/com/rosetta/messenger/ui/auth/SelectAccountScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/auth/SelectAccountScreen.kt new file mode 100644 index 0000000..d3d7036 --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/ui/auth/SelectAccountScreen.kt @@ -0,0 +1,447 @@ +package com.rosetta.messenger.ui.auth + +import androidx.compose.animation.* +import androidx.compose.animation.core.* +import androidx.compose.foundation.* +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +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.material3.* +import androidx.compose.runtime.* +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.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.rosetta.messenger.ui.onboarding.PrimaryBlue + +data class AccountInfo( + val id: String, + val name: String, + val initials: String, + val publicKey: String +) + +// Avatar colors for accounts +private val accountColors = listOf( + 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 getAccountColor(name: String): Color { + val index = name.hashCode().mod(accountColors.size).let { if (it < 0) it + accountColors.size else it } + return accountColors[index] +} + +@Composable +fun SelectAccountScreen( + isDarkTheme: Boolean, + accounts: List, + selectedAccountId: String?, + onSelectAccount: (String) -> Unit, + onAddAccount: () -> Unit, + showCreateModal: Boolean, + onCreateNew: () -> Unit, + onImportSeed: () -> Unit, + onDismissModal: () -> Unit +) { + val backgroundColor = if (isDarkTheme) Color(0xFF1E1E1E) else Color(0xFFFFFFFF) + val textColor = if (isDarkTheme) Color.White else Color.Black + val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666) + + var visible by remember { mutableStateOf(false) } + + LaunchedEffect(Unit) { + visible = true + } + + Box( + modifier = Modifier + .fillMaxSize() + .background(backgroundColor) + .statusBarsPadding() + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 32.dp) + ) { + Spacer(modifier = Modifier.height(60.dp)) + + // Header + AnimatedVisibility( + visible = visible, + enter = fadeIn(tween(500)) + slideInVertically( + initialOffsetY = { -30 }, + animationSpec = tween(500) + ) + ) { + Column { + Row { + Text( + text = "Select ", + fontSize = 32.sp, + fontWeight = FontWeight.Bold, + color = PrimaryBlue + ) + Text( + text = "account", + fontSize = 32.sp, + fontWeight = FontWeight.Bold, + color = textColor + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = "Select your account for login,\nor add new account", + fontSize = 15.sp, + color = secondaryTextColor, + lineHeight = 22.sp + ) + } + } + + Spacer(modifier = Modifier.height(40.dp)) + + // Accounts grid + AnimatedVisibility( + visible = visible, + enter = fadeIn(tween(500, delayMillis = 200)) + ) { + LazyVerticalGrid( + columns = GridCells.Fixed(2), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.fillMaxWidth() + ) { + items(accounts, key = { it.id }) { account -> + val index = accounts.indexOf(account) + AccountCard( + account = account, + isSelected = account.id == selectedAccountId, + isDarkTheme = isDarkTheme, + onClick = { onSelectAccount(account.id) }, + animationDelay = 250 + (index * 100) + ) + } + + // Add Account card + item { + AddAccountCard( + isDarkTheme = isDarkTheme, + onClick = onAddAccount, + animationDelay = 250 + (accounts.size * 100) + ) + } + } + } + } + + // Create Account Modal + if (showCreateModal) { + CreateAccountModal( + isDarkTheme = isDarkTheme, + onCreateNew = onCreateNew, + onImportSeed = onImportSeed, + onDismiss = onDismissModal + ) + } + } +} + +@Composable +private fun AccountCard( + account: AccountInfo, + isSelected: Boolean, + isDarkTheme: Boolean, + onClick: () -> Unit, + animationDelay: Int +) { + val surfaceColor = if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFF5F5F5) + val borderColor = if (isDarkTheme) Color(0xFF3A3A3A) else Color(0xFFE0E0E0) + val textColor = if (isDarkTheme) Color.White else Color.Black + val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666) + + val avatarColor = getAccountColor(account.name) + + var visible by remember { mutableStateOf(false) } + + LaunchedEffect(Unit) { + kotlinx.coroutines.delay(animationDelay.toLong()) + visible = true + } + + AnimatedVisibility( + visible = visible, + enter = fadeIn(tween(400)) + scaleIn( + initialScale = 0.9f, + animationSpec = tween(400) + ) + ) { + Card( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(0.85f) + .clickable(onClick = onClick), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors( + containerColor = if (isSelected) PrimaryBlue.copy(alpha = 0.1f) else surfaceColor + ), + border = BorderStroke( + width = 2.dp, + color = if (isSelected) PrimaryBlue else borderColor + ) + ) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + // Checkmark + if (isSelected) { + Box( + modifier = Modifier + .align(Alignment.TopStart) + .padding(12.dp) + .size(24.dp) + .clip(CircleShape) + .background(PrimaryBlue), + contentAlignment = Alignment.Center + ) { + Icon( + Icons.Default.Check, + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(14.dp) + ) + } + } else { + Box( + modifier = Modifier + .align(Alignment.TopStart) + .padding(12.dp) + .size(24.dp) + .clip(CircleShape) + .border(2.dp, borderColor, CircleShape) + ) + } + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.padding(20.dp) + ) { + // Avatar + Box( + modifier = Modifier + .size(80.dp) + .clip(CircleShape) + .background(avatarColor.copy(alpha = 0.2f)), + contentAlignment = Alignment.Center + ) { + Text( + text = account.initials, + fontSize = 28.sp, + fontWeight = FontWeight.SemiBold, + color = avatarColor + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + + // Name + Text( + text = account.name, + fontSize = 16.sp, + fontWeight = FontWeight.Medium, + color = if (isSelected) PrimaryBlue else textColor, + textAlign = TextAlign.Center, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + } + } + } +} + +@Composable +private fun AddAccountCard( + isDarkTheme: Boolean, + onClick: () -> Unit, + animationDelay: Int +) { + val surfaceColor = if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFF5F5F5) + val borderColor = if (isDarkTheme) Color(0xFF3A3A3A) else Color(0xFFE0E0E0) + val textColor = if (isDarkTheme) Color.White else Color.Black + + var visible by remember { mutableStateOf(false) } + + LaunchedEffect(Unit) { + kotlinx.coroutines.delay(animationDelay.toLong()) + visible = true + } + + AnimatedVisibility( + visible = visible, + enter = fadeIn(tween(400)) + scaleIn( + initialScale = 0.9f, + animationSpec = tween(400) + ) + ) { + Card( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(0.85f) + .clickable(onClick = onClick), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors(containerColor = surfaceColor), + border = BorderStroke( + width = 2.dp, + color = borderColor, + ) + ) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.padding(20.dp) + ) { + // Plus icon + Box( + modifier = Modifier + .size(80.dp) + .clip(CircleShape) + .border(2.dp, PrimaryBlue, CircleShape), + contentAlignment = Alignment.Center + ) { + Icon( + Icons.Default.Add, + contentDescription = null, + tint = PrimaryBlue, + modifier = Modifier.size(40.dp) + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = "Add Account", + fontSize = 16.sp, + fontWeight = FontWeight.Medium, + color = PrimaryBlue, + textAlign = TextAlign.Center + ) + } + } + } + } +} + +@Composable +private fun CreateAccountModal( + isDarkTheme: Boolean, + onCreateNew: () -> Unit, + onImportSeed: () -> Unit, + onDismiss: () -> Unit +) { + val backgroundColor = if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFFFFFFF) + val textColor = if (isDarkTheme) Color.White else Color.Black + val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666) + + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.5f)) + .clickable(onClick = onDismiss), + contentAlignment = Alignment.Center + ) { + Card( + modifier = Modifier + .padding(32.dp) + .clickable(enabled = false, onClick = {}), + shape = RoundedCornerShape(20.dp), + colors = CardDefaults.cardColors(containerColor = backgroundColor) + ) { + Column( + modifier = Modifier.padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "Create account", + fontSize = 20.sp, + fontWeight = FontWeight.Bold, + color = textColor + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = "You may be create new account\nor import existing", + fontSize = 15.sp, + color = secondaryTextColor, + textAlign = TextAlign.Center + ) + + Spacer(modifier = Modifier.height(24.dp)) + + // Create New button + Button( + onClick = onCreateNew, + modifier = Modifier + .fillMaxWidth() + .height(50.dp), + colors = ButtonDefaults.buttonColors( + containerColor = PrimaryBlue, + contentColor = Color.White + ), + shape = RoundedCornerShape(12.dp) + ) { + Text( + text = "Create new", + fontSize = 16.sp, + fontWeight = FontWeight.Medium + ) + } + + Spacer(modifier = Modifier.height(12.dp)) + + // Import (Recover) button + OutlinedButton( + onClick = onImportSeed, + modifier = Modifier + .fillMaxWidth() + .height(50.dp), + colors = ButtonDefaults.outlinedButtonColors( + contentColor = PrimaryBlue + ), + border = BorderStroke(1.dp, PrimaryBlue), + shape = RoundedCornerShape(12.dp) + ) { + Text( + text = "Recover", + fontSize = 16.sp, + fontWeight = FontWeight.Medium + ) + } + } + } + } +} diff --git a/app/src/main/java/com/rosetta/messenger/ui/auth/UnlockScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/auth/UnlockScreen.kt index fece606..3ba6132 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/auth/UnlockScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/auth/UnlockScreen.kt @@ -37,7 +37,9 @@ import kotlinx.coroutines.launch @Composable fun UnlockScreen( isDarkTheme: Boolean, - onUnlocked: (DecryptedAccount) -> Unit + selectedAccountId: String? = null, + onUnlocked: (DecryptedAccount) -> Unit, + onSwitchAccount: () -> Unit = {} ) { val themeAnimSpec = tween(durationMillis = 500, easing = CubicBezierEasing(0.4f, 0f, 0.2f, 1f)) val backgroundColor by animateColorAsState(if (isDarkTheme) AuthBackground else AuthBackgroundLight, animationSpec = themeAnimSpec) @@ -56,7 +58,7 @@ fun UnlockScreen( // Load current account LaunchedEffect(Unit) { - currentPublicKey = accountManager.currentPublicKey.first() + currentPublicKey = selectedAccountId ?: accountManager.currentPublicKey.first() } // Entry animation @@ -290,6 +292,34 @@ fun UnlockScreen( } } + Spacer(modifier = Modifier.height(16.dp)) + + // Switch Account button + AnimatedVisibility( + visible = visible, + enter = fadeIn(tween(600, delayMillis = 600)) + slideInVertically( + initialOffsetY = { 50 }, + animationSpec = tween(600, delayMillis = 600) + ) + ) { + TextButton( + onClick = onSwitchAccount + ) { + Icon( + imageVector = Icons.Default.SwapHoriz, + contentDescription = null, + tint = PrimaryBlue, + modifier = Modifier.size(20.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "Switch Account", + color = PrimaryBlue, + fontSize = 15.sp + ) + } + } + Spacer(modifier = Modifier.weight(0.3f)) } } diff --git a/app/src/main/java/com/rosetta/messenger/ui/auth/WelcomeScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/auth/WelcomeScreen.kt index 91a33b8..b3f239d 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/auth/WelcomeScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/auth/WelcomeScreen.kt @@ -45,7 +45,8 @@ fun WelcomeScreen( val lockComposition by rememberLottieComposition(LottieCompositionSpec.Asset("lottie/lock.json")) val lockProgress by animateLottieCompositionAsState( composition = lockComposition, - iterations = LottieConstants.IterateForever + iterations = 1, // Play once + speed = 1f ) // Entry animation diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt new file mode 100644 index 0000000..1719fbf --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt @@ -0,0 +1,458 @@ +package com.rosetta.messenger.ui.chats + +import androidx.compose.animation.* +import androidx.compose.animation.core.* +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.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +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.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.rosetta.messenger.ui.onboarding.PrimaryBlue +import java.text.SimpleDateFormat +import java.util.* + +data class Chat( + val id: String, + val name: String, + val lastMessage: String, + val lastMessageTime: Date, + val unreadCount: Int = 0, + val isOnline: Boolean = false, + val publicKey: String, + val isSavedMessages: Boolean = false +) + +// Beautiful avatar colors +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 +) + +fun getAvatarColor(name: String, isDark: Boolean): Pair { + 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 +} + +fun getInitials(name: String): String { + val words = name.trim().split(Regex("\\s+")).filter { it.isNotEmpty() } + return when { + words.isEmpty() -> "??" + words.size == 1 -> words[0].take(2).uppercase() + else -> "${words[0].first()}${words[1].first()}".uppercase() + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ChatsListScreen( + isDarkTheme: Boolean, + chats: List, + onChatClick: (Chat) -> Unit, + onNewChat: () -> Unit, + onProfileClick: () -> Unit, + onSavedMessagesClick: () -> Unit +) { + val backgroundColor = if (isDarkTheme) Color(0xFF1E1E1E) 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) + + var visible by remember { mutableStateOf(false) } + + LaunchedEffect(Unit) { + visible = true + } + + Scaffold( + topBar = { + AnimatedVisibility( + visible = visible, + enter = fadeIn(tween(400)) + slideInVertically( + initialOffsetY = { -it }, + animationSpec = tween(400) + ) + ) { + TopAppBar( + title = { + Text( + "Chats", + fontWeight = FontWeight.Bold, + fontSize = 28.sp + ) + }, + actions = { + IconButton(onClick = onNewChat) { + Icon( + Icons.Default.Edit, + contentDescription = "New Chat", + tint = PrimaryBlue + ) + } + IconButton(onClick = onProfileClick) { + Icon( + Icons.Default.Person, + contentDescription = "Profile", + tint = textColor + ) + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = backgroundColor, + titleContentColor = textColor + ) + ) + } + }, + containerColor = backgroundColor + ) { paddingValues -> + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + contentPadding = PaddingValues(vertical = 8.dp) + ) { + // Saved Messages section + item { + AnimatedVisibility( + visible = visible, + enter = fadeIn(tween(400, delayMillis = 100)) + slideInHorizontally( + initialOffsetX = { -50 }, + animationSpec = tween(400, delayMillis = 100) + ) + ) { + SavedMessagesItem( + isDarkTheme = isDarkTheme, + onClick = onSavedMessagesClick + ) + } + } + + // Chat items + items(chats, key = { it.id }) { chat -> + val index = chats.indexOf(chat) + AnimatedVisibility( + visible = visible, + enter = fadeIn(tween(400, delayMillis = 150 + (index * 50))) + slideInHorizontally( + initialOffsetX = { -50 }, + animationSpec = tween(400, delayMillis = 150 + (index * 50)) + ) + ) { + ChatItem( + chat = chat, + isDarkTheme = isDarkTheme, + onClick = { onChatClick(chat) } + ) + } + } + + // Empty state + if (chats.isEmpty()) { + item { + AnimatedVisibility( + visible = visible, + enter = fadeIn(tween(400, delayMillis = 200)) + scaleIn( + initialScale = 0.9f, + animationSpec = tween(400, delayMillis = 200) + ) + ) { + EmptyChatsState( + isDarkTheme = isDarkTheme, + onNewChat = onNewChat + ) + } + } + } + } + } +} + +@Composable +private fun SavedMessagesItem( + isDarkTheme: Boolean, + onClick: () -> Unit +) { + 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), + verticalAlignment = Alignment.CenterVertically + ) { + // Saved Messages icon + Box( + modifier = Modifier + .size(56.dp) + .clip(CircleShape) + .background(PrimaryBlue), + contentAlignment = Alignment.Center + ) { + Icon( + Icons.Default.Bookmark, + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(28.dp) + ) + } + + Spacer(modifier = Modifier.width(12.dp)) + + Column(modifier = Modifier.weight(1f)) { + Text( + text = "Saved Messages", + fontSize = 17.sp, + fontWeight = FontWeight.SemiBold, + color = textColor + ) + Text( + text = "Your personal cloud storage", + fontSize = 14.sp, + color = secondaryTextColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + + HorizontalDivider( + modifier = Modifier.padding(start = 84.dp), + color = if (isDarkTheme) Color(0xFF3A3A3A) else Color(0xFFE8E8E8) + ) +} + +@Composable +private 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 (avatarTextColor, avatarBgColor) = getAvatarColor(chat.name, isDarkTheme) + val initials = getInitials(chat.name) + + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // Avatar + Box( + modifier = Modifier + .size(56.dp) + .clip(CircleShape) + .background(avatarBgColor), + contentAlignment = Alignment.Center + ) { + Text( + text = initials, + fontSize = 20.sp, + fontWeight = FontWeight.SemiBold, + color = avatarTextColor + ) + + // Online indicator + if (chat.isOnline) { + Box( + modifier = Modifier + .align(Alignment.BottomEnd) + .size(14.dp) + .clip(CircleShape) + .background(if (isDarkTheme) Color(0xFF1E1E1E) else Color.White) + .padding(2.dp) + .clip(CircleShape) + .background(Color(0xFF4CAF50)) + ) + } + } + + Spacer(modifier = Modifier.width(12.dp)) + + Column(modifier = Modifier.weight(1f)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = chat.name, + fontSize = 17.sp, + fontWeight = FontWeight.SemiBold, + color = textColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) + + Text( + text = formatTime(chat.lastMessageTime), + fontSize = 13.sp, + color = if (chat.unreadCount > 0) PrimaryBlue else secondaryTextColor + ) + } + + Spacer(modifier = Modifier.height(4.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = chat.lastMessage, + fontSize = 15.sp, + color = secondaryTextColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) + + if (chat.unreadCount > 0) { + Box( + modifier = Modifier + .padding(start = 8.dp) + .clip(CircleShape) + .background(PrimaryBlue) + .padding(horizontal = 8.dp, vertical = 2.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = if (chat.unreadCount > 99) "99+" else chat.unreadCount.toString(), + fontSize = 12.sp, + fontWeight = FontWeight.SemiBold, + color = Color.White + ) + } + } + } + } + } + + HorizontalDivider( + modifier = Modifier.padding(start = 84.dp), + color = if (isDarkTheme) Color(0xFF3A3A3A) else Color(0xFFE8E8E8) + ) +} + +@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) + } + } +} + +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) + } + // 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) + } + // This year + now.get(Calendar.YEAR) == messageTime.get(Calendar.YEAR) -> { + SimpleDateFormat("MMM d", Locale.getDefault()).format(date) + } + // Other + else -> { + SimpleDateFormat("dd.MM.yy", Locale.getDefault()).format(date) + } + } +} diff --git a/app/src/main/java/com/rosetta/messenger/ui/onboarding/OnboardingScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/onboarding/OnboardingScreen.kt index e7b2263..e9ebeed 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/onboarding/OnboardingScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/onboarding/OnboardingScreen.kt @@ -12,6 +12,7 @@ import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.PagerDefaults import androidx.compose.foundation.pager.PagerState import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.shape.CircleShape @@ -238,12 +239,23 @@ fun OnboardingScreen( Spacer(modifier = Modifier.height(32.dp)) - // Pager for text content + // Pager for text content with easier swipes HorizontalPager( state = pagerState, modifier = Modifier .fillMaxWidth() - .height(150.dp) + .height(150.dp), + flingBehavior = PagerDefaults.flingBehavior( + state = pagerState, + lowVelocityAnimationSpec = tween( + durationMillis = 300, + easing = FastOutSlowInEasing + ), + snapAnimationSpec = spring( + dampingRatio = Spring.DampingRatioLowBouncy, + stiffness = Spring.StiffnessLow + ) + ) ) { page -> OnboardingPageContent( page = onboardingPages[page], @@ -381,14 +393,44 @@ fun AnimatedRosettaLogo( contentAlignment = Alignment.Center ) { // Pre-render all animations to avoid lag - Box(modifier = Modifier.fillMaxSize()) { - // Rosetta icon (page 0) + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + // Rosetta icon (page 0) with pulse animation if (currentPage == 0) { + val pulseScale by rememberInfiniteTransition(label = "pulse").animateFloat( + initialValue = 1f, + targetValue = 1.08f, + animationSpec = infiniteRepeatable( + animation = tween(1000, easing = FastOutSlowInEasing), + repeatMode = RepeatMode.Reverse + ), + label = "pulseScale" + ) + + // Glow effect behind logo - separate Box without clipping + Box( + modifier = Modifier + .size(200.dp) + .scale(pulseScale) + .background( + color = Color(0xFF54A9EB).copy(alpha = 0.15f), + shape = CircleShape + ) + ) + Image( + painter = painterResource(id = R.drawable.rosetta_icon), + contentDescription = "Rosetta Logo", + modifier = Modifier + .size(180.dp) + .scale(pulseScale) + .clip(CircleShape) + ) + } painter = painterResource(id = R.drawable.rosetta_icon), contentDescription = "Rosetta Logo", modifier = Modifier .fillMaxSize() + .scale(pulseScale) .clip(CircleShape) ) }