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.
This commit is contained in:
k1ngsterr1
2026-01-08 20:04:51 +05:00
parent fc54cc89df
commit 307670e691
10 changed files with 1740 additions and 403 deletions

View File

@@ -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<Boolean?>(null) }
var currentAccount by remember { mutableStateOf<DecryptedAccount?>(null) }
var accountInfoList by remember { mutableStateOf<List<AccountInfo>>(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
}
)
}

View File

@@ -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<AccountInfo> = 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<List<String>>(emptyList()) }
var selectedAccountId by remember { mutableStateOf<String?>(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
}
}
)
}
}

View File

@@ -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<String>,
@@ -37,18 +47,16 @@ fun ConfirmSeedPhraseScreen(
onBack: () -> Unit,
onConfirmed: () -> Unit
) {
val themeAnimSpec = tween<Color>(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)
) {
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,76 +102,120 @@ 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()
// Two column layout like SeedPhraseScreen
AnimatedVisibility(
visible = visible,
enter = fadeIn(tween(500, delayMillis = 100 + (index * 100))) + slideInVertically(
initialOffsetY = { 50 },
animationSpec = tween(500, delayMillis = 100 + (index * 100))
)
enter = fadeIn(tween(500, delayMillis = 200))
) {
WordInputField(
wordNumber = wordIndex + 1,
value = userInputs[index],
onValueChange = {
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 (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[index] = it
this[i] = newValue.lowercase().trim()
}
showError = false
},
isCorrect = if (hasInput) isCorrect else null,
isDarkTheme = isDarkTheme
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))
// 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)
)
}
}
}
}
// Error message
AnimatedVisibility(
visible = showError,
enter = fadeIn() + slideInVertically { -10 },
exit = fadeOut()
enter = fadeIn(tween(300)) + scaleIn(initialScale = 0.9f),
exit = fadeOut(tween(200))
) {
Column {
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Some words don't match. Please check and try again.",
fontSize = 14.sp,
@@ -181,15 +223,16 @@ fun ConfirmSeedPhraseScreen(
textAlign = TextAlign.Center
)
}
}
Spacer(modifier = Modifier.weight(1f))
// 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(
@@ -200,77 +243,195 @@ fun ConfirmSeedPhraseScreen(
showError = true
}
},
enabled = allFilled,
modifier = Modifier
.fillMaxWidth()
.height(56.dp),
.height(50.dp),
colors = ButtonDefaults.buttonColors(
containerColor = if (allCorrect) PrimaryBlue else PrimaryBlue.copy(alpha = 0.5f)
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 = 16.sp,
fontWeight = FontWeight.SemiBold
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)) }
}
false -> {
{ Icon(Icons.Default.Close, null, tint = Color(0xFFE53935)) }
}
null -> null
}
OutlinedTextField(
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(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
onValueChange = onValueChange,
textStyle = TextStyle(
fontSize = 17.sp,
fontWeight = FontWeight.SemiBold,
color = if (value.isNotEmpty()) wordColor else hintColor,
fontFamily = FontFamily.Monospace
),
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Text,
imeAction = ImeAction.Next
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)
)
}
}
}
}

View File

@@ -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<String?>(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,13 +67,9 @@ fun ImportSeedPhraseScreen(
}
}
AnimatedVisibility(
visible = visible,
enter = fadeIn(tween(500, easing = FastOutSlowInEasing))
) {
Column(
modifier = Modifier
.fillMaxWidth()
.fillMaxSize()
.padding(horizontal = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
@@ -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)
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,
modifier = Modifier.size(22.dp)
tint = PrimaryBlue,
modifier = Modifier.size(20.dp)
)
Spacer(modifier = Modifier.width(10.dp))
Spacer(modifier = Modifier.width(8.dp))
Text(
"Paste All 12 Words",
fontSize = 16.sp,
fontWeight = FontWeight.SemiBold
"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)
) {
for (col in 0..2) {
val index = row * 3 + col
WordInputItem(
number = index + 1,
value = words[index],
// Left column (words 1-6)
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
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)
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,9 +242,9 @@ 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(
@@ -250,7 +275,11 @@ fun ImportSeedPhraseScreen(
),
shape = RoundedCornerShape(12.dp)
) {
Text("Continue", fontSize = 17.sp, fontWeight = FontWeight.Medium)
Text(
text = "Continue",
fontSize = 17.sp,
fontWeight = FontWeight.Medium
)
}
}
@@ -258,52 +287,33 @@ fun ImportSeedPhraseScreen(
}
}
}
}
// 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") },
modifier = Modifier
.fillMaxWidth()
.height(120.dp),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = PrimaryBlue,
cursorColor = PrimaryBlue
),
shape = RoundedCornerShape(12.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)
)
},
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)
}
},
dismissButton = {
TextButton(onClick = { showPasteDialog = false }) {
Text("Cancel")
}
},
containerColor = if (isDarkTheme) Color(0xFF2A2A2A) else Color.White,
shape = RoundedCornerShape(20.dp)
WordInputItem(
number = number,
value = value,
onValueChange = onValueChange,
isDarkTheme = isDarkTheme,
modifier = modifier
)
}
}
}
@Composable
@@ -314,35 +324,59 @@ 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)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Text(
text = "$number.",
fontSize = 13.sp,
fontSize = 15.sp,
color = numberColor,
modifier = Modifier.width(22.dp)
modifier = Modifier.width(28.dp)
)
BasicTextField(
value = value,
onValueChange = onValueChange,
textStyle = TextStyle(
fontSize = 15.sp,
fontWeight = FontWeight.Medium,
color = textColor,
fontSize = 17.sp,
fontWeight = FontWeight.SemiBold,
color = if (value.isNotEmpty()) wordColor else hintColor,
fontFamily = FontFamily.Monospace
),
singleLine = true,
@@ -355,9 +389,10 @@ private fun WordInputItem(
if (value.isEmpty()) {
Text(
"word",
fontSize = 15.sp,
color = hintColor.copy(alpha = 0.5f),
fontFamily = FontFamily.Monospace
fontSize = 17.sp,
color = hintColor,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.SemiBold
)
}
innerTextField()
@@ -365,4 +400,5 @@ private fun WordInputItem(
}
)
}
}
}

View File

@@ -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,17 +249,37 @@ 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)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Text(
text = "$number.",
@@ -274,6 +295,7 @@ private fun WordItem(
fontFamily = FontFamily.Monospace
)
}
}
}
@Composable

View File

@@ -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<AccountInfo>,
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
)
}
}
}
}
}

View File

@@ -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<Color>(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))
}
}

View File

@@ -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

View File

@@ -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<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
}
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<Chat>,
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)
}
}
}

View File

@@ -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)
)
}